Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8e65380
Add adapters/ with Claude Code, Cursor, and NAT reference implementat…
bar-capsule Jun 15, 2026
28e382d
Normalize adapter structure + add masked real-world payload examples
bar-capsule Jun 15, 2026
fbd5d7c
Fix adapter wire format to match canonical v0.1.0 schemas
bar-capsule Jun 17, 2026
a488dfb
Fill ACS-Core conformance gaps: signing, chain, replay, skew, handsha…
bar-capsule Jun 17, 2026
770ce60
Close real conformance gaps: NAT lifecycle, honest Cursor payloads, f…
bar-capsule Jun 17, 2026
635e7e4
Security review + 3 production-failure-mode tests + bug fixes
bar-capsule Jun 17, 2026
0c51540
Fix all 12 edge cases from post-PR audit
bar-capsule Jun 17, 2026
9553897
Add single-file ACS-Core conformance test suite
bar-capsule Jun 18, 2026
b63984a
Strengthen ACS-Core conformance tests with explicit contradictions
bar-capsule Jun 18, 2026
f720862
Adapter cleanup + real-Claude e2e + wire CLI + audit-cause regression…
bar-capsule Jun 20, 2026
1831735
README pass: rewrite Claude Code install flow, prune common README, s…
bar-capsule Jun 20, 2026
9713703
Fix destructive-bash regex evasion via trailing flag letters
bar-capsule Jun 20, 2026
dfa8b70
README: introduce 'What is a Guardian?' section + drop redundant 'Wha…
bar-capsule Jun 20, 2026
c8056ea
Cursor adapter: port Claude Code improvements, add wire CLI + real-po…
bar-capsule Jun 20, 2026
87877c1
NAT adapter: port + live-LLM correctness fixes + shared e2e_report
bar-capsule Jun 20, 2026
db6a6cd
Cursor + Claude e2e_check: collapse Report duplication into shared mo…
bar-capsule Jun 20, 2026
3ee0889
CI: rebuild adapter_tests workflow so NAT-dependent tests run
bar-capsule Jun 20, 2026
b92c93b
PR #22 review fixes: format-checker dep + narrow Wrapped MCP claim
bar-capsule Jun 21, 2026
543296a
PR #22 review fix P2.1: verify_signature robust to malformed base64
bar-capsule Jun 21, 2026
e0b1405
PR #22 review fix P1.2: agentResponse is observation-only on every ad…
bar-capsule Jun 21, 2026
94491b5
PR #22 review fix P3: correct stale NAT README claim on post_invoke r…
bar-capsule Jun 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions adapters/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
*.pyo
201 changes: 201 additions & 0 deletions adapters/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
# ACS Adapters

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.

## Status

| Adapter | Status | Mapping | Working adapter | Tests | Live verification |
|---|---|---|---|---|---|
| [claude-code](./claude-code/) | Reference implementation | ✓ | ✓ | ✓ 13 unit + 2 live tests (`test_live.py`) automate ALLOW + DENY against a real `claude --print` session | ✓ Automated in test suite |
| [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) |
| [nat](./nat/) | Reference implementation | ✓ | ✓ | ✓ 7 unit + 5 live workflow tests (`test_live.py`) exercise the real `function_middleware_invoke` orchestration path against `nvidia-nat-core` 1.7.0 | ✓ Automated in test suite |

---

## How adapters work

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.

### The general pattern (same for all three adapters)

For each event the framework fires:

```
framework adapter Guardian
│ │ │
│ hook event (framework │ │
│ native JSON / call) │ │
│ ───────────────────────► │ │
│ │ ACS JSON-RPC request │
│ │ ──────────────────────► │
│ │ │ evaluate
│ │ │ policy
│ │ ACS decision │
│ │ ◄────────────────────── │
│ decision (framework │ │
│ native response shape) │ │
│ ◄─────────────────────── │ │
│ │ │
▼ ▼ ▼
applies the appends
decision audit chain
```

Six steps:

1. Framework fires its hook with a payload in its own format.
2. Adapter receives that payload, translates to an ACS JSON-RPC request.
3. Adapter POSTs to the Guardian endpoint.
4. Guardian evaluates against policy, returns an ACS decision (`allow` / `deny` / `modify` / `ask` / `defer`).
5. Adapter translates that decision back to whatever the framework expects to receive.
6. Framework applies the decision (run / block / modify the action).

### Concrete walkthrough: Claude Code, ALLOW path

You ask Claude Code to `echo hello`.

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

```json
{
"session_id": "abc-123",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {"command": "echo hello"},
"tool_use_id": "...",
"cwd": "/tmp/...",
"permission_mode": "default"
}
```

**Step 2.** The adapter reads that JSON, builds an ACS JSON-RPC request:

```json
{
"jsonrpc": "2.0",
"method": "steps/toolCallRequest",
"params": {
"session_id": "abc-123",
"step_id": "<uuid>",
"tool": {"name": "Bash", "arguments": {"command": "echo hello"}}
},
"request_id": "<uuid>",
"timestamp": 1718450000000,
"acs_version": "0.1.0"
}
```

**Step 3.** The adapter POSTs to the Guardian endpoint (`http://127.0.0.1:8787/acs`).

**Step 4.** The Guardian evaluates. Our example Guardian's deterministic policy: `echo hello` doesn't match the destructive-Bash regex. Returns:

```json
{"jsonrpc": "2.0", "result": {"decision": "allow"}}
```

**Step 5.** The adapter translates back to Claude Code's expected shape:

```json
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow"}}
```

**Step 6.** Claude Code reads stdout, sees `permissionDecision: "allow"`, executes the Bash tool. You see `hello` printed.

The whole round-trip is ~10 ms. The agent doesn't know any of this happened.

### DENY path differs only in steps 4–6

Same as above, but with `command: "rm -rf /home/u"`:

- **Step 4:** Guardian returns `{"decision": "deny", "reasoning": "destructive Bash pattern in: rm -rf /home/u"}`
- **Step 5:** Adapter emits `{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "destructive Bash pattern..."}}`
- **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."*

### What changes across the three adapters

The general pattern is identical. The framework-specific translation differs:

| | Claude Code | Cursor | NAT |
|---|---|---|---|
| **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 |
| **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` |
| **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` |
| **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` |
| **Native modify mechanism** | `hookSpecificOutput.updatedInput` | `updated_input` | Mutate `context.modified_kwargs` (input) or `context.output` (output) |
| **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 |

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.

### Why the in-process NAT adapter blocks differently

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.

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:

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.

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.

The adapter feature-detects which is available and prefers the action-based path.

### The behavior contract

ACS-Core §6.4 requires the framework to **wait for the verdict and apply it before the action executes.** The adapter relies on this:

- 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.
- For NAT, `pre_invoke` must complete before `call_next(...)` is invoked. NAT's `function_middleware_invoke` orchestration guarantees this.

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.

### The key insight

ACS standardizes the wire format and the decision contract. Adapters live where the boundary is: between the framework and the Guardian. Each adapter:

1. Knows the framework's hook protocol (the framework's JSON shape, response field names, exit codes).
2. Knows ACS (always the same).
3. Translates between them.

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.

---

## Directory layout (identical across all three adapters)

Each adapter follows the same structure. Files differ only where the framework's native naming requires it (config example file extension, etc.):

```
adapters/<framework>/
├── README.md # overview + quick start + conformance status
├── acs_adapter.py # the adapter (same filename across all three)
├── mapping.md # framework event → ACS step method table
├── <config>.example # drop-in framework-native config:
│ # claude-code/settings.json.example
│ # cursor/hooks.json.example
│ # nat/workflow.yml.example
└── tests/
├── __init__.py
├── test_adapter.py # unit / integration tests against real types
├── test_live.py # automated live test (Cursor: skipped placeholder pointing at live_verification.md)
├── example_payloads.md # masked real-world payload examples
└── live_verification.md # (Cursor only) manual reproduction procedure
```

Plus the shared:

```
adapters/example-guardian/
├── README.md
└── example_guardian.py # used by all three adapters' tests
```

---

## Contributing a new adapter

1. Create `adapters/<framework-name>/`.
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.
3. (Optional but encouraged) Write the adapter itself, plus tests. The Claude Code adapter is the template.
4. Add a row to the status table above.
5. Open a PR against `Agent-Control-Standard/ACS`.

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.
82 changes: 82 additions & 0 deletions adapters/claude-code/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# ACS adapter: Claude Code

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.

## What it does

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.

This adapter is that command. It:

1. Reads the Claude Code hook event from stdin.
2. Translates it to an ACS JSON-RPC request (see [mapping.md](./mapping.md)).
3. POSTs it to a Guardian endpoint.
4. Translates the ACS decision back to the format Claude Code expects.
5. Emits the translated response on stdout.

## Quick start

```bash
# 1. Run the example Guardian (in one terminal)
python3 example_guardian.py
# [guardian] listening on 127.0.0.1:8787

# 2. Wire the adapter into Claude Code
# Edit ~/.claude/settings.json (see settings.json.example) and replace
# /path/to/acs_adapter.py with the absolute path on your machine.

# 3. Test it from the shell (no Claude Code needed)
echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf /home/user"}}' \
| ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs python3 acs_adapter.py
# {"decision": "block", "reason": "destructive Bash pattern in: rm -rf /home/user"}
```

## Files

- `acs_adapter.py` — the adapter itself. Stdlib-only. No `pip install` required.
- `example_guardian.py` — a minimal local Guardian for testing. Implements deterministic policy (denies destructive Bash and writes to system paths, allows everything else).
- `settings.json.example` — Claude Code config that wires the adapter into every relevant hook.
- `mapping.md` — Claude Code hook → ACS step method table, plus disposition translation.
- `tests/test_adapter.py` — end-to-end round-trip tests. Run with `python3 -m unittest tests.test_adapter`.
- `tests/test_live.py` — automated live integration test against a real `claude --print` session.
- `tests/example_payloads.md` — masked real-world payload examples showing exactly what Claude Code emits.

## Configuration

The adapter is configured by environment variables, which `settings.json.example` sets per hook:

| Variable | Default | Purpose |
|---|---|---|
| `ACS_GUARDIAN_URL` | `http://127.0.0.1:8787/acs` | Guardian endpoint to POST requests to. |
| `ACS_SESSION_ID` | derived from `$PWD` | Session id sent on every request. Stable across calls in the same working directory by default. |
| `ACS_DEFAULT_DENY` | `1` | If `1`, deny on Guardian-unreachable or adapter errors (fail-closed). Set to `0` for fail-open with audit. |

## Running the tests

```bash
cd adapters/claude-code
python3 -m unittest tests.test_adapter -v
```

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.

## What this is not

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

## Conformance status

| ACS-Core item | Status in this adapter |
|---|---|
| Handshake | Assumed (no per-session negotiation). Production wrapper performs `handshake/hello`. |
| JSON-RPC envelope | ✓ (`request_id`, `timestamp`, `acs_version`, `metadata` populated) |
| Hook taxonomy (6 minimum) | ✓ (`sessionStart`, `userMessage`, `toolCallRequest`, `toolCallResult`, `agentResponse`, `sessionEnd`) |
| Dispositions (ALLOW/DENY/ASK/DEFER) | ✓ on all hooks; MODIFY partial (`PreToolUse` with `parameter_overrides` only) |
| SessionContext | session_id passed every request; Guardian maintains chain_hash |
| Replay protection | ✓ (UUID + timestamp on every request) |
| Baseline integrity | ⚠ deferred to transport layer in this minimal adapter |
| Decision honoring | ✓ (wait for response, apply verdict, configurable fail posture) |
| Liveness `system/ping` | not implemented (SHOULD under slim-Core) |
| 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) |
Loading