Skip to content
4 changes: 2 additions & 2 deletions adapters/docs/claude_code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ What it wires by default:
|---|---|
| `PreToolUse` | **fail-CLOSED** (gate — silent fail-open is a policy hole) |
| `UserPromptSubmit` | **fail-CLOSED** (gate) |
| `SessionStart` | fail-open (observational; §6.4 default) |
| `SessionStart` | fail-open (§6.4 default); a Guardian `deny` refuses the session |
| `PostToolUse` | fail-open |
| `Notification` | fail-open |
| `SessionEnd` | fail-open |
Expand Down Expand Up @@ -267,7 +267,7 @@ The adapter is configured by environment variables, typically set per-hook by `a
| `ACS_HMAC_SECRET` | (unset) | Inline secret. Less secure (visible in `ps eauxw`). Prefer the file. |
| `ACS_DEFAULT_DENY` | `0` | Fail-open with audit (§6.4 default). Set to `1` for fail-closed. The ServerHello's `on_decision_failure: deny` also flips it (most-restrictive-wins). Guardian refusals (bad signature, replay, malformed/oversized envelope) always fail closed regardless of this setting — see spec issue #32. |
| `ACS_STARTUP_POSTURE` | follows `ACS_DEFAULT_DENY` | `proceed` or `refuse`: what this hook does in a session with no negotiated Guardian, because its handshake got no answer (§4.1). `proceed` lets the step run, audited, with nothing sent to the Guardian; `refuse` denies it. The session keeps no negotiated Guardian for its lifetime, and the next session handshakes again. Any other value refuses. |
| `ACS_REQUIRE_PROFILES` | (unset) | Comma-separated conformance profiles the Guardian must accept, such as `acs-core`. When the session's negotiation leaves one out, this hook's steps are refused; the stored record keeps what the Guardian answered, so each hook applies its own setting. |
| `ACS_REQUIRE_PROFILES` | (unset) | Comma-separated conformance profiles the Guardian must accept, such as `acs-core`. When the session's negotiation leaves one out, this hook's steps are refused and it sends no `sessionStart`; the stored record keeps what the Guardian answered, so each hook applies its own setting, and a later hook without the requirement starts the session. |
| `ACS_HANDSHAKE` | `1` | Set to `0` to skip the handshake; the session then counts as unanswered, and `ACS_STARTUP_POSTURE` settles every step. |
| `ACS_AGENT_ID` | derived from cwd | Stable agent identifier sent in `metadata.agent_id`. |
| `ACS_HANDSHAKE_CACHE` | `~/.cache/acs-adapter-handshake/` | Each session's handshake decision, kept for the session's lifetime. |
Expand Down
4 changes: 2 additions & 2 deletions adapters/docs/claude_code/mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Each Claude Code hook event maps to an ACS `steps/*` method. The adapter (`src/a

| Claude Code hook | ACS step method | Notes |
|---|---|---|
| `SessionStart` | `steps/sessionStart` | Session bounds. Session-level — carries no `turn_id`. |
| `SessionStart` | `steps/sessionStart` | Session bounds, sent once per session by the hook process that opens the session and built from its event (see Concurrency). A `SessionStart` with `source: resume` or `compact` in a session that already started sends nothing. A Guardian `deny`, or a refusal of the request, refuses the session: every later step of it is denied in its hook's own shape. A `sessionStart` that gets no decision is recorded, and each later hook settles its step with its own fail posture. Session-level — carries no `turn_id`. |
| `SessionEnd` | `steps/sessionEnd` | Session close (chain seal). Session-level — no `turn_id`. |
| `UserPromptSubmit` | `steps/turnStart` **then** `steps/userMessage` | A prompt OPENS a turn: the adapter first emits a decision-eligible `steps/turnStart` (`triggered_by: user_message`; a Guardian MAY `deny` to block the whole turn), then the `steps/userMessage` carrying the `prompt`. Both — and every in-turn step below — carry `metadata.turn_id`. The `turn_id` is held in per-session state until the matching `Stop`. |
| `Stop` | `steps/turnEnd` | End of a **turn** (every reply), not the session. `steps/sessionEnd` "seals the chain" once per session, so mapping `Stop` there sealed it after every reply; `steps/turnEnd` (audit-only, `outcome: completed`) is the correct per-turn boundary and echoes the open turn's `turn_id`. A `Stop` with no open turn is skipped + audited (`turn_end_no_open_turn`) rather than emitting a fabricated `turn_id`. |
Expand Down Expand Up @@ -38,7 +38,7 @@ An arrived decision the host CANNOT apply is substituted to a block/deny (never

## Concurrency

Claude Code can run the hooks of parallel tool calls at the same time. `UserPromptSubmit` and `Stop` open and close the turn, and a signed session's first MCP tool call records `profile_claim_degraded`; other tool hooks only read session state. Every write of session state is one locked read-modify-write through a temp file of its own, and no lock is held during a Guardian round trip. The Guardian's rolling `chain_hash` orders steps by arrival, so the order of concurrent steps is the order they reach the Guardian. A second wired copy of the adapter would send every event twice, and both requests for one tool call would carry the same `request_id`, so `acs-claude-code-wire` refuses to wire when it finds one in user, project, local or managed settings, in the hooks of an installed plugin that `enabledPlugins` turns on, or, for a settings file Cursor imports, in a `hooks.json` Cursor loads.
Claude Code can run the hooks of parallel tool calls at the same time. The first hook process of a session holds the session's startup lock while it sends the session's one `handshake/hello` and its `steps/sessionStart`; it records how `sessionStart` went in a signed startup record next to the session record. Every other hook process of the session waits for that lock and follows the recorded outcome, so the Guardian sees the handshake, then `sessionStart`, then the session's other steps. A hook process that cannot take the lock, finds a startup record that fails its checks, or finds a start claimed but never recorded refuses the session and audits why. `UserPromptSubmit` and `Stop` open and close the turn, and a signed session's first MCP tool call records `profile_claim_degraded`; other tool hooks only read session state. Every write of session state is one locked read-modify-write through a temp file of its own, and apart from the startup lock, no lock is held during a Guardian round trip. The Guardian's rolling `chain_hash` orders steps by arrival, so the order of concurrent steps is the order they reach the Guardian. A second wired copy of the adapter would send every event twice, and both requests for one tool call would carry the same `request_id`, so `acs-claude-code-wire` refuses to wire when it finds one in user, project, local or managed settings, in the hooks of an installed plugin that `enabledPlugins` turns on, or, for a settings file Cursor imports, in a `hooks.json` Cursor loads.

## Conformance posture

Expand Down
12 changes: 6 additions & 6 deletions adapters/docs/cursor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ A drop-in adapter that wires [Cursor](https://cursor.com) hooks to an ACS Guardi

## How it works

Cursor fires a hook (e.g. `preToolUse`) by running a shell command from `hooks.json`. The command receives the hook event as JSON on stdin and emits Cursor-shaped JSON on stdout (or exit code 2 for some events).
Cursor fires a hook (e.g. `preToolUse`) by running a shell command from `hooks.json`. The command receives the hook event as JSON on stdin and emits Cursor-shaped JSON on stdout.

This adapter is that command. On every hook event Cursor spawns it as a subprocess; the adapter:

Expand Down Expand Up @@ -92,7 +92,7 @@ What it wires by default: the six unconditional hooks of the ACS-Core minimum se

| Cursor event | ACS step method | Posture |
|---|---|---|
| `sessionStart` | sessionStart | fail-open (observational) |
| `sessionStart` | sessionStart | fail-open (§6.4 default); a Guardian `deny` refuses the session |
| `beforeSubmitPrompt` | userMessage | **fail-CLOSED** (gate) |
| `preToolUse` | toolCallRequest | **fail-CLOSED** (gate) |
| `postToolUse` | toolCallResult | fail-open |
Expand Down Expand Up @@ -272,7 +272,7 @@ Same architectural pattern (shell-stdin/stdout, JSON in, JSON out), different pr
| Event dispatch | Single command, event type in `hook_event_name` field | One command per event, event name passed as `argv[1]` |
| Allow/deny field | `hookSpecificOutput.permissionDecision` | `permission` (top-level) |
| Modify input field | `hookSpecificOutput.updatedInput` | `updated_input` |
| Block via exit code | Optional (`exit 2`) | Supported (`exit 2`); `beforeSubmitPrompt` uses exit code rather than JSON output |
| Block via exit code | Optional (`exit 2`) | Supported (`exit 2`); the adapter answers `beforeSubmitPrompt` with `{"continue": false, "user_message"}` and exit 0 instead, so Cursor shows the message |
| Fail-closed flag | env var `ACS_DEFAULT_DENY` | hook-level `failClosed: true` in `hooks.json`, PLUS env var |
| Per-event output keys | Mostly uniform via `hookSpecificOutput` | Event-specific (`permission`, `additional_context`, `updated_mcp_tool_output`, `followup_message`, ...) |
| Headless CLI for tests | `claude --print` | None — Cursor is a desktop GUI; e2e is semi-automated |
Expand All @@ -290,7 +290,7 @@ The adapter is configured by environment variables, typically set per-hook by `a
| `ACS_HMAC_SECRET` | (unset) | Inline secret. Less secure (visible in `ps eauxw`). Prefer the file. |
| `ACS_DEFAULT_DENY` | `0` | Fail-open with audit (§6.4 default). Set to `1` for fail-closed. The ServerHello's `on_decision_failure: deny` also flips it (most-restrictive-wins). Guardian refusals (bad signature, replay, malformed/oversized envelope) always fail closed regardless of this setting — see spec issue #32. |
| `ACS_STARTUP_POSTURE` | follows `ACS_DEFAULT_DENY` | `proceed` or `refuse`: what this hook does in a session with no negotiated Guardian, because its handshake got no answer (§4.1). `proceed` lets the step run, audited, with nothing sent to the Guardian; `refuse` denies it. The session keeps no negotiated Guardian for its lifetime, and the next session handshakes again. Any other value refuses. |
| `ACS_REQUIRE_PROFILES` | (unset) | Comma-separated conformance profiles the Guardian must accept, such as `acs-core`. When the session's negotiation leaves one out, this hook's steps are refused; the stored record keeps what the Guardian answered, so each hook applies its own setting. |
| `ACS_REQUIRE_PROFILES` | (unset) | Comma-separated conformance profiles the Guardian must accept, such as `acs-core`. When the session's negotiation leaves one out, this hook's steps are refused and it sends no `sessionStart`; the stored record keeps what the Guardian answered, so each hook applies its own setting, and a later hook without the requirement starts the session. |
| `ACS_HANDSHAKE` | `1` | Set to `0` to skip the handshake; the session then counts as unanswered, and `ACS_STARTUP_POSTURE` settles every step. |
| `ACS_AGENT_ID` | derived from cwd | Stable agent identifier sent in `metadata.agent_id`. |
| `ACS_HANDSHAKE_CACHE` | `~/.cache/acs-adapter-handshake/` | Each session's handshake decision, kept for the session's lifetime. |
Expand All @@ -317,12 +317,12 @@ Honest, item-by-item against the ACS-Core baseline in `docs/spec/conformance.md`
| Profiles (`profiles_supported` / `profiles_accepted`) | ✓ claims `acs-core` when a signing secret is configured and no profile without one. A ServerHello accepting a profile the adapter did not claim is not a valid answer, a claimed profile the Guardian leaves out is audited as `profile_not_accepted`, and a hook's `ACS_REQUIRE_PROFILES` turns a left-out profile into a refusal of that hook's steps. |
| JSON-RPC envelope shape (`request-envelope.json`) | ✓ validates against canonical schema for every mapped hook (emission suite), with format checking |
| Hook taxonomy (Core minimum set) | ✓ the six unconditional hooks are covered, and so is `subagentStart`, which ACS-Core requires of a subagent-capable client. Cursor exposes the spawn boundary, and the default wiring gates it fail-closed. Seventeen Cursor events are mapped in total; `subagentStop` is intentionally omitted — see the honesty table below. |
| Dispositions | ALLOW / DENY / ASK supported on **permission (pre-execution) events** (`preToolUse`, `beforeShellExecution`, `beforeMCPExecution`, `beforeSubmitPrompt`, `subagentStart`). DEFER substituted to ASK (Cursor has no defer). MODIFY supported on `preToolUse` via `updated_input`. **Lifecycle / post-execution hooks (`afterAgentResponse → steps/agentResponse`, `sessionStart`, `sessionEnd`, `afterShellExecution`, etc.) are observation-only** — Cursor fires them after the message / side effect has occurred; a Guardian `deny` cannot undo it. See `mapping.md`. |
| Dispositions | ALLOW / DENY / ASK supported on **permission (pre-execution) events** (`preToolUse`, `beforeShellExecution`, `beforeMCPExecution`, `beforeSubmitPrompt`, `subagentStart`). DEFER substituted to ASK (Cursor has no defer). MODIFY supported on `preToolUse` via `updated_input`. A `deny` on `sessionStart` refuses the session, so every later step of it is denied. **Lifecycle / post-execution hooks (`afterAgentResponse → steps/agentResponse`, `sessionEnd`, `afterShellExecution`, etc.) are observation-only** — Cursor fires them after the message / side effect has occurred; a Guardian `deny` cannot undo it. See `mapping.md`. |
| Unknown-disposition fail posture | ✓ |
| SessionContext + published `chain_hash` | ✓ session_id coerced to UUID; Guardian computes rolling SHA-256 chain |
| Replay protection | ✓ Guardian enforcement (REPLAY_DETECTED -32005, TIMESTAMP_OUT_OF_WINDOW -32006) |
| Baseline integrity (HMAC-SHA256) | ✓ when `ACS_HMAC_SECRET[_FILE]` is set; SIGNATURE_INVALID -32004 on tamper |
| Decision honoring (§6.4) | ✓ Cursor blocks on permission deny; adapter uses exit-2 where stdout JSON is not available; fail-open emits `ACS_AUDIT` event; audit `cause` field distinguishes failure modes (transport vs signature vs malformed envelope vs replay vs skew) |
| Decision honoring (§6.4) | ✓ Cursor blocks on permission deny; adapter answers every decided step in Cursor's documented JSON; fail-open emits `ACS_AUDIT` event; audit `cause` field distinguishes failure modes (transport vs signature vs malformed envelope vs replay vs skew) |
| Liveness `system/ping` | ⚠ Guardian-side only. ACS-Core says `system/ping` SHOULD be implemented: the Observed Agent declares it in `methods_implemented` and sends it, and the Guardian answers it (§13). The example Guardian answers ping as an always-allow method outside the chain, replay, and signature checks. The adapter does not declare or send `system/ping`, so a deployment that uses it must name an alternative liveness mechanism in its configuration, such as continuous observed hook traffic. Omitting both is non-conformant. |
| `nonce` (optional replay field) | ✗ adapter does not emit `nonce`; the envelope field is OPTIONAL in v0.1 |
| Wrapped MCP `protocols/MCP/*` | ⚠ partial. ACS-Core requires `protocols/MCP/*` for any session that involves MCP, and MCP `tools/call` MAY instead flow through the generic tool hooks. The adapter covers only that case: it maps `beforeMCPExecution` to `steps/toolCallRequest` and `afterMCPExecution` to `steps/toolCallResult`. It wraps no other MCP surface (`initialize`, `prompts/get`, `resources/read`, notifications), so a session that uses MCP falls short of ACS-Core there. The first MCP tool a signed session uses records `profile_claim_degraded` once, whether it arrives through the MCP hooks or through `preToolUse` and `postToolUse` as `MCP:<tool>`. Known limitation. |
Expand Down
8 changes: 6 additions & 2 deletions adapters/docs/cursor/example_payloads.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,13 @@ Per-event output keys differ. See the [mapping table](../mapping.md#disposition-
{"additional_context": "audit: tool ran in 19ms; output 142 bytes"}
```

### beforeSubmitPrompt (block via exit code, not stdout)
### beforeSubmitPrompt (blocked)

The adapter writes nothing to stdout and exits with code 2. Cursor treats exit-2 as a block.
```json
{"continue": false, "user_message": "ACS adapter: prompt blocked (transport_failure)"}
```

The adapter exits with code 0, so Cursor reads the JSON and shows the message.

---

Expand Down
11 changes: 6 additions & 5 deletions adapters/docs/cursor/live_verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,12 @@ EOF

A benign prompt that triggers tool use should produce, in order, on the Guardian's stderr:

1. `steps/sessionStart` (Cursor's `sessionStart` event)
2. `steps/userMessage` (Cursor's `beforeSubmitPrompt`)
3. `steps/toolCallRequest` (Cursor's `preToolUse` for the first tool)
4. `steps/toolCallResult` (Cursor's `postToolUse`)
5. Additional `toolCallRequest` / `toolCallResult` pairs for each subsequent tool the agent invokes
1. `handshake/hello`, once for the session
2. `steps/sessionStart`, from whichever of Cursor's first hooks opens the session
3. `steps/turnStart`, then `steps/userMessage` (Cursor's `beforeSubmitPrompt`)
4. `steps/toolCallRequest` (Cursor's `preToolUse` for the first tool)
5. `steps/toolCallResult` (Cursor's `postToolUse`)
6. Additional `toolCallRequest` / `toolCallResult` pairs for each subsequent tool the agent invokes

The Cursor UI should show the agent's tool calls proceeding normally; no policy block messages because the example Guardian's policy only denies destructive Bash patterns and writes to system paths.

Expand Down
Loading
Loading