Skip to content

Commit 34473a2

Browse files
bar-capsuleclaude
andcommitted
Fix Claude Code schema, add Cursor reference impl, verify live integration
Claude Code adapter: - Corrected response shape to use hookSpecificOutput.permissionDecision (per https://code.claude.com/docs/en/hooks); previous version emitted a top-level {"decision": "block"} which Claude Code does not parse on PreToolUse. - Honor Claude Code's native ask/defer permissionDecision values instead of substituting them to deny. - PostToolUse: handle the actual tool_response object (stdout/stderr/...) rather than the docs-claimed tool_output string. Preserve tool_use_id and duration_ms for Guardian correlation. - Added real_claude_code_payloads.example: captured payloads from a real `claude --print` session for reference. - Updated tests with corrected expectations (13 tests, all passing). Live integration verified against a real `claude --print` session: - ALLOW path: command ran, Guardian saw toolCallRequest + toolCallResult with the real Claude Code session id. - DENY path: Claude Code surfaced "The command was blocked - a policy named '...' denied the Bash tool call, so it never ran." Reasoning flowed from Guardian -> adapter -> Claude Code -> user-visible message. Cursor adapter (NEW, promoted from research to reference implementation): - Schema source: ~/.cursor/skills-cursor/create-hook/SKILL.md - Full implementation: cursor_adapter.py covers all 20 documented Cursor hook events. - Per-event dispatch via argv[1] (Cursor wires one command per event). - Per-event output translation: - Permission events (preToolUse, subagentStart, beforeShell, beforeMCP) use top-level {permission, user_message, agent_message, updated_input} - Post-tool events use additional_context / updated_mcp_tool_output - subagentStop uses followup_message - beforeSubmitPrompt uses exit code 2 to block (Cursor's protocol) - Reuses the Claude Code example Guardian (same ACS shape on the wire). - 13 round-trip tests, all passing. Example Guardian (shared): - Recognize both 'Bash' and 'Shell' tool names (Cursor uses Shell). - Allow subagentStart, knowledgeRetrieval, memoryStore, memoryContextRetrieval in addition to the original allow-list. Total: 26/26 tests passing across both adapters. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4d03d79 commit 34473a2

10 files changed

Lines changed: 1014 additions & 159 deletions

File tree

adapters/README.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,39 @@ Reference implementations that wire popular agent frameworks to an ACS Guardian.
44

55
## Status
66

7-
| Adapter | Status | Mapping | Working adapter | Tests |
8-
|---|---|---|---|---|
9-
| [claude-code](./claude-code/) | Reference implementation |||(10 round-trip tests) |
10-
| [nat](./nat/) | Draft || | |
11-
| [cursor](./cursor/) | Research ||||
7+
| Adapter | Status | Mapping | Working adapter | Tests | Live integration verified |
8+
|---|---|---|---|---|---|
9+
| [claude-code](./claude-code/) | Reference implementation |||13 round-trip tests | ✓ ALLOW + DENY paths verified against a real `claude --print` session |
10+
| [cursor](./cursor/) | Reference implementation || | ✓ 13 round-trip tests | ⚠ Manual verification by reviewer with Cursor installed (Cursor has no headless mode) |
11+
| [nat](./nat/) | Draft | | |||
1212

1313
## The adapter pattern
1414

1515
Each adapter does the same three things, with framework-specific glue:
1616

17-
1. Listen for the framework's lifecycle events (hook callback, middleware, command-driven shell hook, etc.).
17+
1. Listen for the framework's lifecycle events (hook callback, command-driven shell hook, middleware, etc.).
1818
2. Translate each event to an ACS JSON-RPC request and send it to a Guardian endpoint over HTTP.
1919
3. Translate the Guardian's ACS decision back to the framework's expected response shape (allow / block / modify / pause).
2020

21-
The translation tables live in each adapter's `mapping.md`. The translation code lives in each adapter's reference implementation (when available).
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.
2222

2323
## Why "adapters" and not first-class framework support
2424

2525
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?").
2626

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

29+
## Cross-adapter design summary
30+
31+
| Aspect | Claude Code | Cursor | NAT (draft) |
32+
|---|---|---|---|
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 |
39+
2940
## Contributing a new adapter
3041

3142
1. Create `adapters/<framework-name>/`.

adapters/claude-code/acs_adapter.py

Lines changed: 160 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,17 @@
66
ACS JSON-RPC request, sends it to a Guardian, and translates the ACS
77
response back into the format Claude Code expects (printed to stdout).
88
9+
Schema source: https://code.claude.com/docs/en/hooks
10+
911
Wire up via Claude Code's `~/.claude/settings.json`. See
1012
settings.json.example in this directory.
1113
1214
Environment variables:
1315
ACS_GUARDIAN_URL Guardian endpoint (default: http://127.0.0.1:8787/acs)
14-
ACS_SESSION_ID Session id to send on every request (default: a stable
15-
id derived from the working directory)
16-
ACS_DEFAULT_DENY If "1", deny on any adapter error. Default: "1".
16+
ACS_DEFAULT_DENY If "1", block on any adapter error. Default: "1".
1717
"""
1818
from __future__ import annotations
1919

20-
import hashlib
2120
import json
2221
import os
2322
import sys
@@ -33,7 +32,7 @@
3332
ACS_VERSION = "0.1.0"
3433

3534

36-
# Claude Code hook event name -> ACS step method
35+
# Claude Code hook_event_name -> ACS step method
3736
HOOK_MAP: dict[str, str] = {
3837
"SessionStart": "steps/sessionStart",
3938
"SessionEnd": "steps/sessionEnd",
@@ -48,36 +47,52 @@
4847
}
4948

5049

51-
def session_id_from_cwd() -> str:
52-
cwd = os.environ.get("PWD") or os.getcwd()
53-
return "cc-" + hashlib.sha256(cwd.encode()).hexdigest()[:16]
54-
55-
5650
def build_payload(event: dict[str, Any]) -> dict[str, Any]:
5751
"""Build the ACS step payload from a Claude Code hook event."""
5852
name = event.get("hook_event_name", "")
5953
payload: dict[str, Any] = {
60-
"session_id": os.environ.get("ACS_SESSION_ID") or session_id_from_cwd(),
54+
"session_id": event.get("session_id", ""),
6155
"step_id": str(uuid.uuid4()),
56+
"cwd": event.get("cwd"),
57+
"transcript_path": event.get("transcript_path"),
58+
"permission_mode": event.get("permission_mode"),
6259
}
60+
# Drop None values to keep the payload clean
61+
payload = {k: v for k, v in payload.items() if v is not None}
62+
6363
if name == "PreToolUse":
6464
payload["tool"] = {
6565
"name": event.get("tool_name", ""),
6666
"arguments": event.get("tool_input", {}),
6767
}
68+
payload["tool_use_id"] = event.get("tool_use_id")
6869
elif name == "PostToolUse":
6970
payload["tool"] = {
7071
"name": event.get("tool_name", ""),
7172
"arguments": event.get("tool_input", {}),
7273
}
74+
# Real Claude Code emits tool_response (object with stdout/stderr/...);
75+
# docs say tool_output (string). Accept either for forward-compat.
7376
payload["result"] = event.get("tool_response", event.get("tool_output"))
77+
payload["tool_use_id"] = event.get("tool_use_id")
78+
payload["duration_ms"] = event.get("duration_ms")
7479
elif name == "UserPromptSubmit":
7580
payload["content"] = event.get("prompt", "")
7681
elif name == "Notification":
7782
payload["content"] = event.get("message", "")
78-
elif name in ("SessionStart", "SessionEnd", "Stop"):
83+
payload["notification_type"] = event.get("notification_type")
84+
elif name == "SessionStart":
85+
payload["source"] = event.get("source")
86+
payload["model"] = event.get("model")
87+
elif name == "SessionEnd":
7988
payload["reason"] = event.get("reason")
80-
return payload
89+
elif name in ("PreCompact", "PostCompact"):
90+
payload["trigger"] = event.get("trigger")
91+
elif name == "SubagentStop":
92+
payload["agent_id"] = event.get("agent_id")
93+
payload["agent_type"] = event.get("agent_type")
94+
95+
return {k: v for k, v in payload.items() if v is not None}
8196

8297

8398
def build_request(event: dict[str, Any]) -> dict[str, Any]:
@@ -109,40 +124,126 @@ def call_guardian(request: dict[str, Any]) -> dict[str, Any]:
109124
return json.loads(resp.read().decode("utf-8"))
110125

111126

127+
# ACS disposition -> Claude Code permissionDecision (PreToolUse only)
128+
PRETOOL_PERMISSION_MAP: dict[str, str] = {
129+
"allow": "allow",
130+
"deny": "deny",
131+
"ask": "ask",
132+
"defer": "defer",
133+
}
134+
135+
112136
def translate_response(acs_response: dict[str, Any], hook_event: str) -> dict[str, Any]:
113137
"""Translate an ACS decision envelope to Claude Code's expected output.
114138
115-
Claude Code expects either:
116-
- empty / no decision => proceed
117-
- {"decision": "approve"} on PreToolUse
118-
- {"decision": "block", "reason": "..."} on PreToolUse
119-
- {"continue": false, "stopReason": "..."} to halt the agent
139+
Schema reference: https://code.claude.com/docs/en/hooks
120140
"""
121141
result = acs_response.get("result", {})
122142
decision = (result.get("decision") or "").lower()
123143
reasoning = result.get("reasoning", "")
144+
modifications = result.get("modifications", {})
124145

125-
if decision == "allow":
126-
return {} # Claude Code interprets empty as proceed
127-
if decision == "deny":
128-
if hook_event == "PreToolUse":
129-
return {"decision": "block", "reason": reasoning or "blocked by Guardian"}
130-
return {"continue": False, "stopReason": reasoning or "blocked by Guardian"}
131-
if decision == "modify":
132-
mods = result.get("modifications", {})
133-
out: dict[str, Any] = {}
134-
if hook_event == "PreToolUse" and "parameter_overrides" in mods:
135-
out["decision"] = "modify"
136-
out["modifiedInput"] = mods["parameter_overrides"]
146+
# ----- PreToolUse: permissionDecision under hookSpecificOutput -----
147+
if hook_event == "PreToolUse":
148+
hso: dict[str, Any] = {"hookEventName": "PreToolUse"}
149+
if decision in PRETOOL_PERMISSION_MAP:
150+
hso["permissionDecision"] = PRETOOL_PERMISSION_MAP[decision]
137151
if reasoning:
138-
out["reason"] = reasoning
139-
return out
140-
# Adapter does not implement MODIFY on other hooks: substitute DENY with audit
141-
return {"decision": "block", "reason": f"MODIFY substituted to DENY: {reasoning}"}
142-
if decision == "ask":
143-
return {"decision": "block", "reason": f"approval required: {reasoning}"}
144-
if decision == "defer":
145-
return {"decision": "block", "reason": f"deferred: {reasoning}"}
152+
hso["permissionDecisionReason"] = reasoning
153+
return {"hookSpecificOutput": hso}
154+
if decision == "modify":
155+
# Claude Code's modify path: updatedInput
156+
overrides = modifications.get("parameter_overrides")
157+
if overrides is not None:
158+
hso["permissionDecision"] = "allow"
159+
hso["updatedInput"] = overrides
160+
if reasoning:
161+
hso["permissionDecisionReason"] = reasoning
162+
return {"hookSpecificOutput": hso}
163+
# MODIFY without parameter_overrides on PreToolUse: substitute DENY
164+
hso["permissionDecision"] = "deny"
165+
hso["permissionDecisionReason"] = (
166+
f"MODIFY substituted to DENY (no parameter_overrides): {reasoning}"
167+
)
168+
return {"hookSpecificOutput": hso}
169+
# Unknown / empty decision: proceed
170+
return {}
171+
172+
# ----- PostToolUse: top-level decision, optional updatedToolOutput -----
173+
if hook_event == "PostToolUse":
174+
if decision == "deny":
175+
return {
176+
"decision": "block",
177+
"reason": reasoning or "blocked by Guardian",
178+
"hookSpecificOutput": {"hookEventName": "PostToolUse"},
179+
}
180+
if decision == "modify":
181+
updated = modifications.get("modified_content")
182+
if updated is not None:
183+
return {
184+
"hookSpecificOutput": {
185+
"hookEventName": "PostToolUse",
186+
"updatedToolOutput": str(updated),
187+
**({"additionalContext": reasoning} if reasoning else {}),
188+
}
189+
}
190+
return {
191+
"decision": "block",
192+
"reason": f"MODIFY substituted to DENY (no modified_content): {reasoning}",
193+
}
194+
if decision in ("ask", "defer"):
195+
return {
196+
"decision": "block",
197+
"reason": f"{decision} on post-tool not supported by Claude Code: {reasoning}",
198+
}
199+
return {}
200+
201+
# ----- UserPromptSubmit: decision block + additionalContext -----
202+
if hook_event == "UserPromptSubmit":
203+
if decision == "deny":
204+
return {
205+
"decision": "block",
206+
"reason": reasoning or "blocked by Guardian",
207+
}
208+
if decision in ("ask", "defer"):
209+
return {
210+
"decision": "block",
211+
"reason": f"{decision} on user prompt: {reasoning}",
212+
}
213+
# Modify on a prompt isn't expressible via this hook's contract;
214+
# Guardian-side rewrite would have to happen before submission.
215+
return {}
216+
217+
# ----- Stop / SubagentStop -----
218+
if hook_event in ("Stop", "SubagentStop"):
219+
if decision == "deny":
220+
return {
221+
"decision": "block",
222+
"reason": reasoning or "blocked by Guardian",
223+
}
224+
return {}
225+
226+
# ----- PreCompact -----
227+
if hook_event == "PreCompact":
228+
if decision == "deny":
229+
return {
230+
"decision": "block",
231+
"reason": reasoning or "compaction blocked by Guardian",
232+
"hookSpecificOutput": {"hookEventName": "PreCompact"},
233+
}
234+
return {}
235+
236+
# ----- SessionStart / SessionEnd / PostCompact / Notification -----
237+
# Informational hooks: ACS records them, Claude Code does not gate on them.
238+
# If the Guardian wants to feed context back, additionalContext goes here.
239+
additional = result.get("additional_context")
240+
if additional:
241+
return {
242+
"hookSpecificOutput": {
243+
"hookEventName": hook_event,
244+
"additionalContext": str(additional),
245+
}
246+
}
146247
return {}
147248

148249

@@ -158,36 +259,47 @@ def main() -> int:
158259

159260
hook_name = event.get("hook_event_name", "")
160261
if hook_name not in HOOK_MAP:
161-
# Not a hook we map; emit empty so Claude Code proceeds
162-
return 0
262+
return 0 # not a hook we map; proceed
163263

164264
try:
165265
request = build_request(event)
166266
response = call_guardian(request)
167267
out = translate_response(response, hook_name)
168268
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError) as e:
169269
sys.stderr.write(f"acs-adapter: Guardian unreachable: {e}\n")
170-
return _fail()
270+
return _fail(hook_name)
171271
except Exception as e: # noqa: BLE001
172272
sys.stderr.write(f"acs-adapter: adapter error: {e}\n")
173-
return _fail()
273+
return _fail(hook_name)
174274

175275
if out:
176276
json.dump(out, sys.stdout)
177277
sys.stdout.write("\n")
178278
return 0
179279

180280

181-
def _fail() -> int:
182-
"""Apply fail posture and emit the corresponding Claude Code output."""
183-
if DEFAULT_DENY:
281+
def _fail(hook_name: str = "") -> int:
282+
"""Emit a fail-closed response in the shape the hook expects."""
283+
if not DEFAULT_DENY:
284+
return 0 # fail-open: proceed with no output
285+
msg = "ACS adapter: Guardian unreachable"
286+
if hook_name == "PreToolUse":
184287
json.dump(
185-
{"decision": "block", "reason": "ACS adapter: Guardian unreachable"},
288+
{
289+
"hookSpecificOutput": {
290+
"hookEventName": "PreToolUse",
291+
"permissionDecision": "deny",
292+
"permissionDecisionReason": msg,
293+
}
294+
},
186295
sys.stdout,
187296
)
188-
sys.stdout.write("\n")
297+
elif hook_name in ("PostToolUse", "UserPromptSubmit", "Stop", "SubagentStop", "PreCompact"):
298+
json.dump({"decision": "block", "reason": msg}, sys.stdout)
299+
else:
300+
# Informational hooks: fail-open is the only viable option (no block contract)
189301
return 0
190-
# Fail-open: proceed (empty output), record nothing (the Guardian is the audit sink)
302+
sys.stdout.write("\n")
191303
return 0
192304

193305

adapters/claude-code/example_guardian.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def evaluate(method: str, params: dict[str, Any]) -> dict[str, Any]:
4343
tool_name = tool.get("name", "")
4444
args = tool.get("arguments", {})
4545

46-
if tool_name == "Bash":
46+
if tool_name in ("Bash", "Shell"):
4747
cmd = args.get("command", "")
4848
if DESTRUCTIVE_BASH.search(cmd):
4949
return {
@@ -73,7 +73,11 @@ def evaluate(method: str, params: dict[str, Any]) -> dict[str, Any]:
7373
"steps/agentResponse",
7474
"steps/preCompact",
7575
"steps/postCompact",
76+
"steps/subagentStart",
7677
"steps/subagentStop",
78+
"steps/knowledgeRetrieval",
79+
"steps/memoryStore",
80+
"steps/memoryContextRetrieval",
7781
):
7882
return {"decision": "allow", "chain_hash": chain_hash}
7983

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
=== HOOK FIRED 2026-06-15T08:55:53Z ===
2+
{"session_id":"befb9d53-a5c4-4024-95e4-09c5b1d08b17","transcript_path":"/Users/barkaduri/.claude/projects/-private-tmp-acs-real-test/befb9d53-a5c4-4024-95e4-09c5b1d08b17.jsonl","cwd":"/private/tmp/acs-real-test","hook_event_name":"SessionStart","source":"startup"}
3+
4+
=== END ===
5+
=== HOOK FIRED 2026-06-15T08:55:53Z ===
6+
{"session_id":"befb9d53-a5c4-4024-95e4-09c5b1d08b17","transcript_path":"/Users/barkaduri/.claude/projects/-private-tmp-acs-real-test/befb9d53-a5c4-4024-95e4-09c5b1d08b17.jsonl","cwd":"/private/tmp/acs-real-test","permission_mode":"acceptEdits","hook_event_name":"UserPromptSubmit","prompt":"Run the shell command: echo HOOK_PROBE_OK"}
7+
8+
=== END ===
9+
=== HOOK FIRED 2026-06-15T08:55:56Z ===
10+
{"session_id":"befb9d53-a5c4-4024-95e4-09c5b1d08b17","transcript_path":"/Users/barkaduri/.claude/projects/-private-tmp-acs-real-test/befb9d53-a5c4-4024-95e4-09c5b1d08b17.jsonl","cwd":"/private/tmp/acs-real-test","permission_mode":"acceptEdits","effort":{"level":"high"},"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo HOOK_PROBE_OK","description":"Echo a probe string"},"tool_use_id":"toolu_01VA6o3dtCvr716MhHFrA7VW"}
11+
12+
=== END ===
13+
=== HOOK FIRED 2026-06-15T08:56:01Z ===
14+
{"session_id":"befb9d53-a5c4-4024-95e4-09c5b1d08b17","transcript_path":"/Users/barkaduri/.claude/projects/-private-tmp-acs-real-test/befb9d53-a5c4-4024-95e4-09c5b1d08b17.jsonl","cwd":"/private/tmp/acs-real-test","permission_mode":"acceptEdits","effort":{"level":"high"},"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"echo HOOK_PROBE_OK","description":"Echo a probe string"},"tool_response":{"stdout":"HOOK_PROBE_OK","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false},"tool_use_id":"toolu_01VA6o3dtCvr716MhHFrA7VW","duration_ms":5616}
15+
16+
=== END ===

0 commit comments

Comments
 (0)