Skip to content

Commit 8e65380

Browse files
bar-capsuleclaude
andcommitted
Add adapters/ with Claude Code, Cursor, and NAT reference implementations
Introduces a top-level adapters/ directory holding reference implementations that wire popular agent frameworks to an ACS Guardian through configuration only, with no agent code changes. Three working adapters: - adapters/claude-code/: shell-stdin adapter wired via Claude Code's settings.json. 13 unit/integration tests + 2 automated live tests that spawn `claude --print` against a project-level settings.json and exercise ALLOW and DENY paths end-to-end. - adapters/cursor/: shell-stdin adapter wired via Cursor's hooks.json. Schema sourced from Cursor's bundled create-hook skill docs. 13 unit tests against the shared example Guardian. Manual live-verification procedure documented in tests/live_verification.md (Cursor is a desktop app with no documented headless mode). - adapters/nat/: in-process Python FunctionMiddleware for NVIDIA Agent Toolkit (nvidia-nat-core 1.7.0). Registered via @register_middleware, inherits FunctionMiddlewareBaseConfig with name="acs_guardian". 7 integration tests against real NAT types + 5 live workflow tests exercising the actual function_middleware_invoke orchestration path. Shared infrastructure: - adapters/example-guardian/: minimal deterministic Guardian used by all three adapters' integration tests. Stdlib-only Python. Documented as a teaching artifact, not a production Guardian. - adapters/README.md: framework -> adapter -> Guardian flow diagram, 6-step walkthrough with concrete JSON payloads at every step, cross-adapter comparison table, behavior-contract explanation. Total tests: 40 automated tests passing across all adapters (13 + 2 claude-code, 13 cursor, 7 + 5 nat). Schema gaps between docs and reality were closed via the live tests for Claude Code and NAT; Cursor was manually verified through a reproduction procedure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f46d260 commit 8e65380

23 files changed

Lines changed: 2936 additions & 0 deletions

adapters/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
__pycache__/
2+
*.pyc
3+
*.pyo

adapters/README.md

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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.

adapters/claude-code/README.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# ACS adapter: Claude Code
2+
3+
A drop-in adapter that wires [Claude Code](https://docs.claude.com/claude-code) hooks to an ACS Guardian. No agent code changes; configuration only.
4+
5+
## What it does
6+
7+
Claude Code fires a hook (e.g. `PreToolUse`) by running a shell command and passing the hook event as JSON on stdin. The command's stdout becomes the hook decision.
8+
9+
This adapter is that command. It:
10+
11+
1. Reads the Claude Code hook event from stdin.
12+
2. Translates it to an ACS JSON-RPC request (see [mapping.md](./mapping.md)).
13+
3. POSTs it to a Guardian endpoint.
14+
4. Translates the ACS decision back to the format Claude Code expects.
15+
5. Emits the translated response on stdout.
16+
17+
## Quick start
18+
19+
```bash
20+
# 1. Run the example Guardian (in one terminal)
21+
python3 example_guardian.py
22+
# [guardian] listening on 127.0.0.1:8787
23+
24+
# 2. Wire the adapter into Claude Code
25+
# Edit ~/.claude/settings.json (see settings.json.example) and replace
26+
# /path/to/acs_adapter.py with the absolute path on your machine.
27+
28+
# 3. Test it from the shell (no Claude Code needed)
29+
echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf /home/user"}}' \
30+
| ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs python3 acs_adapter.py
31+
# {"decision": "block", "reason": "destructive Bash pattern in: rm -rf /home/user"}
32+
```
33+
34+
## Files
35+
36+
- `acs_adapter.py` — the adapter itself. Stdlib-only. No `pip install` required.
37+
- `example_guardian.py` — a minimal local Guardian for testing. Implements deterministic policy (denies destructive Bash and writes to system paths, allows everything else).
38+
- `settings.json.example` — Claude Code config that wires the adapter into every relevant hook.
39+
- `mapping.md` — Claude Code hook → ACS step method table, plus disposition translation.
40+
- `tests/test_adapter.py` — end-to-end round-trip tests. Run with `python3 -m unittest tests.test_adapter`.
41+
42+
## Configuration
43+
44+
The adapter is configured by environment variables, which `settings.json.example` sets per hook:
45+
46+
| Variable | Default | Purpose |
47+
|---|---|---|
48+
| `ACS_GUARDIAN_URL` | `http://127.0.0.1:8787/acs` | Guardian endpoint to POST requests to. |
49+
| `ACS_SESSION_ID` | derived from `$PWD` | Session id sent on every request. Stable across calls in the same working directory by default. |
50+
| `ACS_DEFAULT_DENY` | `1` | If `1`, deny on Guardian-unreachable or adapter errors (fail-closed). Set to `0` for fail-open with audit. |
51+
52+
## Running the tests
53+
54+
```bash
55+
cd adapters/claude-code
56+
python3 -m unittest tests.test_adapter -v
57+
```
58+
59+
The tests start the example Guardian on a free port, pipe sample Claude Code hook payloads through the adapter, and assert the adapter produces the expected Claude Code-shaped output. 10 tests covering happy path, deny paths, lifecycle hooks, unknown hooks, and fail-closed / fail-open posture.
60+
61+
## What this is not
62+
63+
- A production Guardian. `example_guardian.py` is a teaching artifact with three rules. Production Guardians plug in OPA/Rego, Cedar, or a vendor's policy engine.
64+
- A signed-envelope implementation. The adapter does not HMAC the outbound request body. ACS-Core's baseline integrity requirement (§10) is satisfied at the transport layer (typically mTLS or a signed reverse proxy) for this minimal adapter.
65+
- A full handshake implementation. The adapter assumes the Guardian advertises ACS-Core at the endpoint. A production adapter performs `handshake/hello` at session start and caches the negotiated capabilities.
66+
67+
## Conformance status
68+
69+
| ACS-Core item | Status in this adapter |
70+
|---|---|
71+
| Handshake | Assumed (no per-session negotiation). Production wrapper performs `handshake/hello`. |
72+
| JSON-RPC envelope | ✓ (`request_id`, `timestamp`, `acs_version`, `metadata` populated) |
73+
| Hook taxonomy (6 minimum) | ✓ (`sessionStart`, `userMessage`, `toolCallRequest`, `toolCallResult`, `agentResponse`, `sessionEnd`) |
74+
| Dispositions (ALLOW/DENY/ASK/DEFER) | ✓ on all hooks; MODIFY partial (`PreToolUse` with `parameter_overrides` only) |
75+
| SessionContext | session_id passed every request; Guardian maintains chain_hash |
76+
| Replay protection | ✓ (UUID + timestamp on every request) |
77+
| Baseline integrity | ⚠ deferred to transport layer in this minimal adapter |
78+
| Decision honoring | ✓ (wait for response, apply verdict, configurable fail posture) |
79+
| Liveness `system/ping` | not implemented (SHOULD under slim-Core) |
80+
| Wrapped MCP | not implemented (SHOULD-when-MCP-used; Claude Code's MCP traffic goes through its own mechanism and would need a separate wrapping path) |

0 commit comments

Comments
 (0)