Skip to content

Commit 98470b4

Browse files
bar-capsuleclaude
andcommitted
Add concrete walkthrough + cross-adapter comparison to adapters/README
Previously the top-level README listed three steps in prose; reviewers and adopters had to read each individual adapter's README to understand the actual mechanics. The new content adds: - A box-diagram of the framework -> adapter -> Guardian flow - A 6-step walkthrough with actual JSON payloads at each step (Claude Code's PreToolUse for `echo hello`) - The DENY path showing only what changes - A comparison table across all three adapters (where the adapter lives, how event type is passed, native field names, native allow/deny output, modify mechanism, process model) - The "why NAT blocks differently" explanation (in-process means raising or InvocationAction.SKIP, not stdout JSON) - The behavior-contract section (why decision honoring matters) All grounded in the real implementations and real test evidence. No code changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c84f863 commit 98470b4

1 file changed

Lines changed: 141 additions & 19 deletions

File tree

adapters/README.md

Lines changed: 141 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,163 @@
11
# ACS Adapters
22

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.
44

55
## Status
66

7-
| Adapter | Status | Mapping | Working adapter | Tests | Live integration verified |
7+
| Adapter | Status | Mapping | Working adapter | Tests | Live verification |
88
|---|---|---|---|---|---|
99
| [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 |
1010
| [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` |
1111
| [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 |
1212

13-
## The adapter pattern
13+
---
1414

15-
Each adapter does the same three things, with framework-specific glue:
15+
## How adapters work
1616

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.
2018

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)
2220

23-
## Why "adapters" and not first-class framework support
21+
For each event the framework fires:
2422

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+
```
2643

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:
2845

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).
3052

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 |
32119
|---|---|---|---|
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+
---
39161

40162
## Contributing a new adapter
41163

0 commit comments

Comments
 (0)