|
1 | 1 | # ACS Adapters |
2 | 2 |
|
3 | | -Reference implementations that wire popular agent frameworks to an ACS Guardian. The goal: a framework adopts ACS through configuration, not by editing agent code. |
| 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 | 4 |
|
5 | 5 | ## Status |
6 | 6 |
|
7 | | -| Adapter | Status | Mapping | Working adapter | Tests | Live integration verified | |
| 7 | +| Adapter | Status | Mapping | Working adapter | Tests | Live verification | |
8 | 8 | |---|---|---|---|---|---| |
9 | 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 | 10 | | [cursor](./cursor/) | Reference implementation | ✓ | ✓ | ✓ 13 unit tests | ✓ Manual verification done 2026-06-15; captured payloads in `tests/real_cursor_payloads.example`, procedure in `tests/live_verification.md` | |
11 | 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 | 12 |
|
13 | | -## The adapter pattern |
| 13 | +--- |
14 | 14 |
|
15 | | -Each adapter does the same three things, with framework-specific glue: |
| 15 | +## How adapters work |
16 | 16 |
|
17 | | -1. Listen for the framework's lifecycle events (hook callback, command-driven shell hook, middleware, etc.). |
18 | | -2. Translate each event to an ACS JSON-RPC request and send it to a Guardian endpoint over HTTP. |
19 | | -3. Translate the Guardian's ACS decision back to the framework's expected response shape (allow / block / modify / pause). |
| 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. |
20 | 18 |
|
21 | | -The translation tables live in each adapter's `mapping.md`. The translation code lives in each adapter's reference implementation. The Claude Code and Cursor adapters share the same example Guardian (`adapters/claude-code/example_guardian.py`) — the ACS shape on the wire is identical regardless of which framework emits the event. |
| 19 | +### The general pattern (same for all three adapters) |
22 | 20 |
|
23 | | -## Why "adapters" and not first-class framework support |
| 21 | +For each event the framework fires: |
24 | 22 |
|
25 | | -ACS-Core specifies what a hook event looks like on the wire and what the Guardian's decision looks like coming back. It deliberately does not dictate how a framework physically wires the interception in. That last part is implementation: an MCP proxy, a framework callback, a shell-script command, or a runtime monkey-patch. The choice depends on which boundary the framework already crosses (see the FAQ in `docs/topics/faq.md`, "How do I make my framework ACS-conformant?"). |
| 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 | +``` |
26 | 43 |
|
27 | | -The adapters here demonstrate the boundary choice for each target framework. Other frameworks can ship their own adapters, or contribute new ones to this directory. |
| 44 | +Six steps: |
28 | 45 |
|
29 | | -## Cross-adapter design summary |
| 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). |
30 | 52 |
|
31 | | -| Aspect | Claude Code | Cursor | NAT (draft) | |
| 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 | |
32 | 119 | |---|---|---|---| |
33 | | -| Interception mechanism | Shell command per hook (settings.json) | Shell command per hook (hooks.json) | Config-level middleware (planned) | |
34 | | -| Event dispatch | Event name in stdin JSON | Event name as `argv[1]` | Middleware lifecycle position | |
35 | | -| Permission output | `hookSpecificOutput.permissionDecision` | `permission` (top-level, per-event) | TBD | |
36 | | -| Modify output | `hookSpecificOutput.updatedInput` | `updated_input` | TBD | |
37 | | -| Block via exit code | Optional | Supported (`exit 2`); required on `beforeSubmitPrompt` | TBD | |
38 | | -| Fail-closed flag | Adapter env var | Hook-level `failClosed: true` + adapter env var | TBD | |
| 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 | +--- |
39 | 161 |
|
40 | 162 | ## Contributing a new adapter |
41 | 163 |
|
|
0 commit comments