|
| 1 | +# ACS Adapters |
| 2 | + |
| 3 | +Reference implementations that wire popular agent frameworks to an ACS Guardian. The goal: a framework adopts ACS through **configuration only**, with no agent code changes. |
| 4 | + |
| 5 | +## Status |
| 6 | + |
| 7 | +| Adapter | Status | Mapping | Working adapter | Tests | Live verification | |
| 8 | +|---|---|---|---|---|---| |
| 9 | +| [claude-code](./claude-code/) | Reference implementation | ✓ | ✓ | ✓ 13 unit + 2 live tests (`test_live_claude_code.py`) automate ALLOW + DENY against a real `claude --print` session | ✓ Automated in test suite | |
| 10 | +| [cursor](./cursor/) | Reference implementation | ✓ | ✓ | ✓ 13 unit tests | ✓ Manual verification procedure documented in `tests/live_verification.md` (Cursor is a desktop app with no headless mode) | |
| 11 | +| [nat](./nat/) | Reference implementation | ✓ | ✓ | ✓ 7 unit + 5 live workflow tests (`test_live_nat_workflow.py`) exercise the real `function_middleware_invoke` orchestration path against `nvidia-nat-core` 1.7.0 | ✓ Automated in test suite | |
| 12 | + |
| 13 | +--- |
| 14 | + |
| 15 | +## How adapters work |
| 16 | + |
| 17 | +The adapters are **translators**. Each one speaks its framework's hook protocol on one side and ACS JSON-RPC on the other. The framework's agent code is untouched. The Guardian's policy code is untouched. The adapter is the bilingual layer between them. |
| 18 | + |
| 19 | +### The general pattern (same for all three adapters) |
| 20 | + |
| 21 | +For each event the framework fires: |
| 22 | + |
| 23 | +``` |
| 24 | + framework adapter Guardian |
| 25 | + │ │ │ |
| 26 | + │ hook event (framework │ │ |
| 27 | + │ native JSON / call) │ │ |
| 28 | + │ ───────────────────────► │ │ |
| 29 | + │ │ ACS JSON-RPC request │ |
| 30 | + │ │ ──────────────────────► │ |
| 31 | + │ │ │ evaluate |
| 32 | + │ │ │ policy |
| 33 | + │ │ ACS decision │ |
| 34 | + │ │ ◄────────────────────── │ |
| 35 | + │ decision (framework │ │ |
| 36 | + │ native response shape) │ │ |
| 37 | + │ ◄─────────────────────── │ │ |
| 38 | + │ │ │ |
| 39 | + ▼ ▼ ▼ |
| 40 | + applies the appends |
| 41 | + decision audit chain |
| 42 | +``` |
| 43 | + |
| 44 | +Six steps: |
| 45 | + |
| 46 | +1. Framework fires its hook with a payload in its own format. |
| 47 | +2. Adapter receives that payload, translates to an ACS JSON-RPC request. |
| 48 | +3. Adapter POSTs to the Guardian endpoint. |
| 49 | +4. Guardian evaluates against policy, returns an ACS decision (`allow` / `deny` / `modify` / `ask` / `defer`). |
| 50 | +5. Adapter translates that decision back to whatever the framework expects to receive. |
| 51 | +6. Framework applies the decision (run / block / modify the action). |
| 52 | + |
| 53 | +### Concrete walkthrough: Claude Code, ALLOW path |
| 54 | + |
| 55 | +You ask Claude Code to `echo hello`. |
| 56 | + |
| 57 | +**Step 1.** Claude Code is about to call its Bash tool. Before it runs, Claude Code's hook system fires `PreToolUse`. Your `settings.json` configures `PreToolUse` to run `python3 acs_adapter.py`. Claude Code spawns that process and pipes the event to stdin: |
| 58 | + |
| 59 | +```json |
| 60 | +{ |
| 61 | + "session_id": "abc-123", |
| 62 | + "hook_event_name": "PreToolUse", |
| 63 | + "tool_name": "Bash", |
| 64 | + "tool_input": {"command": "echo hello"}, |
| 65 | + "tool_use_id": "...", |
| 66 | + "cwd": "/tmp/...", |
| 67 | + "permission_mode": "default" |
| 68 | +} |
| 69 | +``` |
| 70 | + |
| 71 | +**Step 2.** The adapter reads that JSON, builds an ACS JSON-RPC request: |
| 72 | + |
| 73 | +```json |
| 74 | +{ |
| 75 | + "jsonrpc": "2.0", |
| 76 | + "method": "steps/toolCallRequest", |
| 77 | + "params": { |
| 78 | + "session_id": "abc-123", |
| 79 | + "step_id": "<uuid>", |
| 80 | + "tool": {"name": "Bash", "arguments": {"command": "echo hello"}} |
| 81 | + }, |
| 82 | + "request_id": "<uuid>", |
| 83 | + "timestamp": 1718450000000, |
| 84 | + "acs_version": "0.1.0" |
| 85 | +} |
| 86 | +``` |
| 87 | + |
| 88 | +**Step 3.** The adapter POSTs to the Guardian endpoint (`http://127.0.0.1:8787/acs`). |
| 89 | + |
| 90 | +**Step 4.** The Guardian evaluates. Our example Guardian's deterministic policy: `echo hello` doesn't match the destructive-Bash regex. Returns: |
| 91 | + |
| 92 | +```json |
| 93 | +{"jsonrpc": "2.0", "result": {"decision": "allow"}} |
| 94 | +``` |
| 95 | + |
| 96 | +**Step 5.** The adapter translates back to Claude Code's expected shape: |
| 97 | + |
| 98 | +```json |
| 99 | +{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow"}} |
| 100 | +``` |
| 101 | + |
| 102 | +**Step 6.** Claude Code reads stdout, sees `permissionDecision: "allow"`, executes the Bash tool. You see `hello` printed. |
| 103 | + |
| 104 | +The whole round-trip is ~10 ms. The agent doesn't know any of this happened. |
| 105 | + |
| 106 | +### DENY path differs only in steps 4–6 |
| 107 | + |
| 108 | +Same as above, but with `command: "rm -rf /home/u"`: |
| 109 | + |
| 110 | +- **Step 4:** Guardian returns `{"decision": "deny", "reasoning": "destructive Bash pattern in: rm -rf /home/u"}` |
| 111 | +- **Step 5:** Adapter emits `{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "destructive Bash pattern..."}}` |
| 112 | +- **Step 6:** Claude Code reads `permissionDecision: "deny"`, does not execute the Bash tool, and surfaces the reason: *"The command was blocked — a policy denied the Bash tool call, so it never ran."* |
| 113 | + |
| 114 | +### What changes across the three adapters |
| 115 | + |
| 116 | +The general pattern is identical. The framework-specific translation differs: |
| 117 | + |
| 118 | +| | Claude Code | Cursor | NAT | |
| 119 | +|---|---|---|---| |
| 120 | +| **Where the adapter lives** | Separate shell process spawned per hook | Separate shell process spawned per hook | In-process Python class, same memory space as the agent | |
| 121 | +| **How the framework sends the event** | JSON on stdin; event type is a field inside the JSON (`hook_event_name`) | JSON on stdin; event type passed as a CLI argument (one command per event in `hooks.json`) | Python method call: `pre_invoke(context)` with `context.function_context.name` | |
| 122 | +| **Native event field names** | `tool_name`, `tool_input`, `tool_response` | `tool_name`, `tool_input`, `tool_output`, `command` (for shell) | `context.function_context.name`, `context.modified_kwargs` | |
| 123 | +| **Native allow/deny output** | `{"hookSpecificOutput": {"permissionDecision": "allow"|"deny"}}` on stdout | `{"permission": "allow"|"deny"}` on stdout, or `exit 2` to block | Set `context.action = InvocationAction.SKIP` to block, or raise `ACSGuardianDenied` | |
| 124 | +| **Native modify mechanism** | `hookSpecificOutput.updatedInput` | `updated_input` | Mutate `context.modified_kwargs` (input) or `context.output` (output) | |
| 125 | +| **Process model** | OS spawns a Python process for every hook event | OS spawns a Python process for every hook event | Zero IPC; everything in the same Python interpreter | |
| 126 | + |
| 127 | +The Guardian-side wire format is **the same** for all three. The adapter is bilingual: it knows the framework's protocol on one side and ACS on the other. |
| 128 | + |
| 129 | +### Why the in-process NAT adapter blocks differently |
| 130 | + |
| 131 | +Claude Code and Cursor both use the shell-stdin pattern: the adapter is a separate process, the framework reads its stdout to learn what to do. Block by emitting a deny-shaped JSON. |
| 132 | + |
| 133 | +NAT is fundamentally different. The adapter runs inside the agent's process as a Python middleware class. When the Guardian denies, the adapter has two options: |
| 134 | + |
| 135 | +1. **Set `context.action = InvocationAction.SKIP`.** NAT's `function_middleware_invoke` checks the action after `pre_invoke` returns and skips the function call. Clean, no exception. Available on the NAT dev branch. |
| 136 | + |
| 137 | +2. **Raise an exception.** NAT's documented "Raises: Any exception to abort execution." Less clean (shows up in logs) but works on every NAT version including the public 1.7.0 release. |
| 138 | + |
| 139 | +The adapter feature-detects which is available and prefers the action-based path. |
| 140 | + |
| 141 | +### The behavior contract |
| 142 | + |
| 143 | +ACS-Core §6.4 requires the framework to **wait for the verdict and apply it before the action executes.** The adapter relies on this: |
| 144 | + |
| 145 | +- For Claude Code and Cursor, the hook subprocess has to return before the tool runs. The shell hook protocol guarantees this — the framework blocks on the subprocess. |
| 146 | +- For NAT, `pre_invoke` must complete before `call_next(...)` is invoked. NAT's `function_middleware_invoke` orchestration guarantees this. |
| 147 | + |
| 148 | +If a framework were to fire-and-forget the hook (run it asynchronously and continue the action without waiting), the adapter would still send to the Guardian and the audit chain would still record the decision — but the framework wouldn't actually apply it. That would be non-conformant. None of the three frameworks here does that. |
| 149 | + |
| 150 | +### The key insight |
| 151 | + |
| 152 | +ACS standardizes the wire format and the decision contract. Adapters live where the boundary is: between the framework and the Guardian. Each adapter: |
| 153 | + |
| 154 | +1. Knows the framework's hook protocol (the framework's JSON shape, response field names, exit codes). |
| 155 | +2. Knows ACS (always the same). |
| 156 | +3. Translates between them. |
| 157 | + |
| 158 | +The framework's agent code is untouched. The Guardian's policy code is untouched. The adapter is the bilingual translator that makes them speak. **One Guardian, one ACS contract, three adapters that translate three different protocols into that contract.** Add a new framework, write a new adapter, the Guardian doesn't change. |
| 159 | + |
| 160 | +--- |
| 161 | + |
| 162 | +## Contributing a new adapter |
| 163 | + |
| 164 | +1. Create `adapters/<framework-name>/`. |
| 165 | +2. Write `mapping.md` documenting how the framework's hook events map to ACS `steps/*` methods, and how the framework's response shape relates to ACS dispositions. |
| 166 | +3. (Optional but encouraged) Write the adapter itself, plus tests. The Claude Code adapter is the template. |
| 167 | +4. Add a row to the status table above. |
| 168 | +5. Open a PR against `Agent-Control-Standard/ACS`. |
| 169 | + |
| 170 | +The bar for "reference implementation" status is: round-trip tests pass against the example Guardian, documented configuration for users, and an explicit conformance posture statement matching the format in the Claude Code adapter's README. |
0 commit comments