diff --git a/adapters/SECURITY.md b/adapters/SECURITY.md index b45ecdd8..c064ac9e 100644 --- a/adapters/SECURITY.md +++ b/adapters/SECURITY.md @@ -19,7 +19,7 @@ below. | Adapter middleware (`nat/`) | In-process inside the agent's Python runtime. Same address space as the agent. | | Guardian process (`src/acs/guardian/`) | HTTP server. Holds the HMAC secret. Computes the rolling audit chain. | | Wire transport | JSON-RPC over HTTP (deployments wrap with HTTPS / mTLS for production). | -| HMAC shared secret | Loaded from `ACS_HMAC_SECRET_FILE` (preferred) or `ACS_HMAC_SECRET` (env-var fallback). | +| HMAC shared secret | Loaded from `ACS_HMAC_SECRET_FILE` (preferred) or `ACS_HMAC_SECRET` (env-var fallback), and named by the label in `ACS_HMAC_KEY_ID` (§10). A response signed under any other label fails verification; no other secret is tried. | | Cache files | `~/.cache/acs-adapter-handshake/` (ServerHello cache), `~/.cache/acs-adapter-session/` (per-session step-id tracking). | | Audit log | `ACS_AUDIT ` lines on adapter stderr. | @@ -134,10 +134,10 @@ adding a test would be checking stdlib behavior. **T11 — Handshake downgrade.** Attacker MITMs the handshake response to claim the Guardian doesn't support signing, hoping the adapter then sends unsigned envelopes. -*Mitigation:* The adapter signs iff its own `ACS_HMAC_SECRET` / -`_FILE` is set, regardless of what `signature_algorithms_supported` -the handshake advertised. The handshake's advertised algorithm list -is informational; it does not control the adapter's signing behavior. +*Mitigation:* The adapter signs every request with its own secret, +whatever `signature_algorithms_supported` the handshake advertised, +and a hook with no secret refuses its session and sends nothing. The +handshake's advertised algorithm list does not control signing. *No test* — this is an absence of behavior. Verified by reading the code: `sign_envelope()` consults `load_hmac_secret()` only. diff --git a/adapters/docs/claude_code/README.md b/adapters/docs/claude_code/README.md index 191f39f2..49b4f5ae 100644 --- a/adapters/docs/claude_code/README.md +++ b/adapters/docs/claude_code/README.md @@ -10,7 +10,7 @@ This adapter is that command. On every hook event Claude Code spawns the adapter 1. Reads the Claude Code hook event from stdin. 2. Translates it to an ACS JSON-RPC envelope ([mapping.md](./mapping.md)). -3. Signs it with HMAC-SHA256 (`ACS_HMAC_SECRET_FILE`). +3. Signs it with HMAC-SHA256 (`ACS_HMAC_SECRET_FILE`, under the label `ACS_HMAC_KEY_ID`). 4. POSTs to the Guardian. 5. Verifies the response signature. 6. Translates the verdict back to Claude Code's expected output shape and writes to stdout. @@ -50,7 +50,7 @@ chmod 600 ~/.acs/hmac.key Use the example Guardian for testing; a production Guardian is the same wire protocol with a real policy engine attached. ```bash -ACS_HMAC_SECRET_FILE=~/.acs/hmac.key \ +ACS_HMAC_SECRET_FILE=~/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 \ acs-example-guardian --port 8787 ``` @@ -66,12 +66,14 @@ Keep this terminal open. You should see `[guardian] listening on 127.0.0.1:8787` # Preview the change without touching the file acs-claude-code-wire \ --guardian-url=http://127.0.0.1:8787/acs \ - --secret-file=~/.acs/hmac.key + --secret-file=~/.acs/hmac.key \ + --key-id=2026-11 # Apply it (creates ~/.claude/settings.json.bak.) acs-claude-code-wire \ --guardian-url=http://127.0.0.1:8787/acs \ --secret-file=~/.acs/hmac.key \ + --key-id=2026-11 \ --write ``` @@ -174,53 +176,44 @@ Claude should refuse and surface the Guardian's `reasoning` field. The example G ### Smoke #4 — audit-cause differentiation -Verifies that the adapter's audit log distinguishes "Guardian unreachable" (ops issue) from "Guardian rejected the envelope" (client/operator bug). +Verifies that the adapter's audit log distinguishes a Guardian that rejected the request (a configuration error) from a Guardian it could not reach (an ops issue). Both commands run against the Guardian started above, on `127.0.0.1:8787` under the label `2026-11`. -Unsigned envelope to a signing-required Guardian: +A secret the Guardian does not hold, under the Guardian's label: ```bash ACS_GUARDIAN_URL="http://127.0.0.1:8787/acs" \ -ACS_HMAC_SECRET="" \ +ACS_HMAC_SECRET="$(openssl rand -hex 32)" ACS_HMAC_KEY_ID=2026-11 \ acs-claude-code-hook 2>&1 <<'EOF' {"session_id":"11111111-1111-4111-8111-111111111111","transcript_path":"/tmp/t","cwd":"/tmp","permission_mode":"default","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo test"}} EOF ``` -Expected stderr — note the `cause` field, and that a signature refusal -fails CLOSED regardless of posture (a refusal is an alive Guardian -rejecting the envelope; spec issue #32): - -``` -acs-adapter: Guardian returned JSON-RPC error -32004 (signature_invalid_response): SIGNATURE_INVALID -ACS_AUDIT {"acs_audit_event": "guardian_refusal_fail_closed", "cause": "signature_invalid_response", ...} -``` - -And stdout carries a real deny: +Expected: the Guardian answers the handshake with `-32004 SIGNATURE_INVALID`, signed with its own secret. The hook cannot verify that answer, so it refuses the session whatever the posture (spec issue #32), and stdout carries a deny: ``` -{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", ...}} +ACS_AUDIT {"acs_audit_event": "session_refused", "cause": "error_signature_invalid", "claimed_cause": "signature_invalid_response", "claimed_error_code": -32004, ...} +ACS_AUDIT {"acs_audit_event": "session_refused_fail_closed", "cause": "error_signature_invalid", ..., "hook": "PreToolUse", ...} +{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "ACS adapter: session refused (error_signature_invalid)"}} ``` -Guardian unreachable (different cause, same disposition): +Guardian unreachable, with the Guardian's secret and label: ```bash ACS_GUARDIAN_URL="http://127.0.0.1:1/dead" \ +ACS_HMAC_SECRET_FILE=~/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 \ acs-claude-code-hook 2>&1 <<'EOF' {"session_id":"11111111-1111-4111-8111-111111111111","transcript_path":"/tmp/t","cwd":"/tmp","permission_mode":"default","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo test"}} EOF ``` -Expected: +Expected: the handshake gets no answer, so the session has no negotiated Guardian, and the hook's startup posture settles the step (fail-open when the hook sets no `ACS_DEFAULT_DENY`); stdout is empty, which Claude Code reads as proceed: ``` -acs-adapter: Guardian unreachable: -ACS_AUDIT {"acs_audit_event": "fail_open_bypass", "cause": "transport_failure", ...} +ACS_AUDIT {"acs_audit_event": "session_without_negotiated_guardian", "cause": "transport_failure", "failure_class": "handshake_failure", "posture": "fail_open", ...} +ACS_AUDIT {"acs_audit_event": "unguarded_session_bypass", "cause": "transport_failure", ..., "hook": "PreToolUse", ...} ``` -Distinct `acs_audit_event` AND distinct `cause`: refusals are always -`guardian_refusal_fail_closed`; failures carry the posture -(`fail_open_bypass` / `decision_failure_fail_closed`). Operators grep -on either to triage. +Distinct `acs_audit_event` and `cause`: a rejection refuses the session (`session_refused_fail_closed`) whatever the posture, and an unreachable Guardian follows the startup posture (`unguarded_session_bypass`, or `session_not_started_fail_closed` under a deny posture). Operators grep on either to triage. A hook with no secret sends nothing and refuses the session with `secret_missing`. ### Smoke #5 — pre-flight inventory (paranoid) @@ -265,6 +258,7 @@ The adapter is configured by environment variables, typically set per-hook by `a | `ACS_GUARDIAN_URL` | `http://127.0.0.1:8787/acs` | Guardian endpoint. http/https only; SSRF allowlist refuses other schemes. | | `ACS_HMAC_SECRET_FILE` | (unset) | Path to a 0600 file holding the shared HMAC secret. | | `ACS_HMAC_SECRET` | (unset) | Inline secret. Less secure (visible in `ps eauxw`). Prefer the file. | +| `ACS_HMAC_KEY_ID` | (unset) | The label that names the secret (§10); the Guardian holds the same one. Required with a secret: a secret without a label refuses the session, and a response signed under another label fails verification. | | `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 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. | @@ -288,14 +282,14 @@ Honest, item-by-item against the ACS-Core baseline in `docs/spec/conformance.md` | ACS-Core item | Status | |---|---| | Handshake (`handshake/hello`) | ✓ adapter sends ClientHello on the first event of a session; the decision (negotiated, refused, or no answer under `ACS_STARTUP_POSTURE`) holds for the session's lifetime (§4.1). | -| 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. | +| Profiles (`profiles_supported` / `profiles_accepted`) | ✓ always claims `acs-core`: a hook with no usable secret and label refuses its session before the handshake. 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 (`tests/test_envelope_schema.py`); format checking enforces `uuid` and `date-time`. | | Hook taxonomy (Core minimum set) | ✓ `sessionStart`, `userMessage`, `toolCallRequest`, `toolCallResult`, `agentResponse`, `sessionEnd`; plus `subagentStart`, which ACS-Core requires of a subagent-capable client. Claude Code spawns subagents through the Agent tool (legacy `Task`), and the adapter remaps that PreToolUse to `steps/subagentStart` (see mapping.md). `subagentStop`, which ACS-Core lists as SHOULD-emit, stays deliberately unmapped (see mapping.md). The adapter also emits the turn boundary: `turnStart` (emitted on UserPromptSubmit) / `turnEnd` (Stop). `preCompact` deliberately unmapped — the platform exposes no entry list (mapping.md). | | Dispositions (ALLOW/DENY/ASK/DEFER) | ✓ on **pre-execution** hooks (`PreToolUse`, `UserPromptSubmit`, `turnStart`); MODIFY partial (`PreToolUse` with `parameter_overrides`, merged onto the original input). **Post-execution and lifecycle hooks (`PostToolUse`, `Notification → agentResponse`, `Stop → turnEnd`, `SessionEnd`) are observation-only** — Claude Code fires them after the side effect / message / turn has occurred; a Guardian `deny` on those cannot undo it. See `mapping.md`. | | Unknown-disposition fail posture | ✓ default-deny honored on unknown verdicts when `ACS_DEFAULT_DENY=1`; spec-default fail-open path emits audit event. | | SessionContext + published `chain_hash` | ✓ session_id propagated; Guardian computes rolling SHA-256 chain per §8.2 (`adapters/test_acs_core_conformance.py::Core05_SessionContext`). | | Replay protection (`request_id` + `timestamp`) | ✓ adapter sends both; Guardian rejects duplicate `request_id` (REPLAY_DETECTED -32005) and timestamps outside skew window (TIMESTAMP_OUT_OF_WINDOW -32006) per §10.3. | -| Baseline integrity (HMAC-SHA256 signature) | ✓ HKDF-derived per-session key signs every request and response when `ACS_HMAC_SECRET[_FILE]` is set; Guardian rejects unsigned/tampered with SIGNATURE_INVALID -32004. | +| Baseline integrity (HMAC-SHA256 signature) | ✓ HKDF-derived per-session key signs every request and response under `ACS_HMAC_KEY_ID`; a hook with no secret of at least 32 bytes, or no label, refuses its session and sends nothing. The Guardian rejects an unsigned or tampered request with SIGNATURE_INVALID -32004. | | Decision honoring (§6.4) | ✓ adapter blocks on subprocess return; spec-default fail-open posture emits structured `ACS_AUDIT` event on every bypass; audit `cause` field distinguishes failure modes. | | 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. | diff --git a/adapters/docs/claude_code/mapping.md b/adapters/docs/claude_code/mapping.md index 7de4c2ab..f523ad08 100644 --- a/adapters/docs/claude_code/mapping.md +++ b/adapters/docs/claude_code/mapping.md @@ -6,8 +6,8 @@ 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, 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`. | +| `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; `sessionEnd`, which is audit-only, goes out under either posture and reports that failure (§6.4). Session-level — carries no `turn_id`. | +| `SessionEnd` | `steps/sessionEnd` | Session close (chain seal). Carries `summary.decision_failures`, the session's decision failures per failure class (§6.4). 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`. | | `PreToolUse` | `steps/toolCallRequest` | Fires before the tool runs. Decision gates execution. Carries `metadata.turn_id`. | @@ -34,7 +34,7 @@ Block-shape hooks are only those where a block actually PREVENTS the action: `Po | `ask` | `…"permissionDecision": "ask"` — **native**; Claude Code surfaces its own approval prompt | substituted to `block` | | `defer` | substituted to `deny` + `defer_substituted_deny` audit event (no native defer in `permissionDecision`) | substituted to `block` | -An arrived decision the host CANNOT apply is substituted to a block/deny (never silently proceeded) and audited. Note the substitutions here are **deployment behavior, not spec-mandated**: §9.2 places disposition substitution on the Guardian, and §6's `timeout_decision: deny` default governs an *expired* defer, not an instant client-side substitution. A deployment that needs approver-driven resumption should run ACS-Core's ASK/DEFER flow at the Guardian layer and have the Guardian return an effective decision before responding to the adapter. A contradictory `modifications` object (both wholesale + structured, or overlapping targets) fails closed to `deny` per §6.3:146. +An arrived decision the host CANNOT apply is substituted to a block/deny (never silently proceeded) and audited. A MODIFY the host cannot apply is a DENY recorded as `modify_unsupported` with `reason_codes: ["modify_unsupported"]` (§6.5): redactions or replacement content on a tool call, structured edits on a tool result, and any MODIFY on a prompt. Note the ASK and DEFER substitutions here are **deployment behavior, not spec-mandated**: §9.2 places disposition substitution on the Guardian, and §6's `timeout_decision: deny` default governs an *expired* defer, not an instant client-side substitution. A deployment that needs approver-driven resumption should run ACS-Core's ASK/DEFER flow at the Guardian layer and have the Guardian return an effective decision before responding to the adapter. A contradictory `modifications` object (both wholesale + structured, or overlapping targets) fails closed to `deny` per §6.3:146. ## Concurrency @@ -42,13 +42,13 @@ Claude Code can run the hooks of parallel tool calls at the same time. The first ## Conformance posture -The Claude Code adapter covers ACS-Core's mandatory floor as listed below. This is the floor the tests check, not a full ACS-Core conformance claim (see `adapters/README.md`). Three ACS-Core requirements from later specification changes follow in separate PRs: the `key_id` taken from configuration (§10, #272); decision failures recorded with their failure class and counted at `sessionEnd` (§6.4, #273); and a signature on every request, where today an adapter with no secret configured sends unsigned requests (§10). Every row below describes what the code in `adapter.py` actually does — this table drifted from the code once (PR #22 review caught six false claims in it), so treat the code as ground truth and this table as its description: +The Claude Code adapter covers ACS-Core's mandatory floor as listed below. This is the floor the tests check, not a full ACS-Core conformance claim (see `adapters/README.md`). Every row below describes what the code in `adapter.py` actually does — this table drifted from the code once (PR #22 review caught six false claims in it), so treat the code as ground truth and this table as its description: - Handshake: `handshake/hello` is performed on the first hook event of a session, and its answer decides the session (§4.1): a verified ServerHello negotiates; a refusal, or an answer that fails its checks, refuses every step; and no answer leaves the session with no negotiated Guardian, so each hook's `ACS_STARTUP_POSTURE` applies (`proceed` lets the step run with nothing sent to the Guardian; `refuse` denies it). The decision is stored in `~/.cache/acs-adapter-handshake/` with an HMAC under the session key over the decision, the session and the Guardian URL, and holds for the session's lifetime, so a dead Guardian costs one timeout per session; a record that fails its checks, or a decision that cannot be stored, refuses the session. The ServerHello's response envelope is signature-verified and bound to the ClientHello before it is stored; its `on_decision_failure` combines with `ACS_DEFAULT_DENY` (deny wins over proceed), and its `timeout_config` replaces the local decision timeout, per method where `per_method_ms` names one (§6.4). The ServerHello must answer the ClientHello: a `negotiated_version` sharing a major version the adapter offered, a `methods_evaluated` list, the one transport the adapter offers (its Guardian URL's scheme), a positive `timeout_config.default_ms`, an `on_decision_failure` of `proceed` or `deny`, and only profiles the adapter claimed; otherwise the session has no negotiated Guardian (`invalid_server_hello`). Its `methods_evaluated` bounds what is sent: a method the Guardian omits resolves as ALLOW-by-default without being sent (handshake.json) and is recorded as `method_not_evaluated`; the hook returns no output, so Claude Code's own permission flow still applies, and a result never cites a request that was not sent. With no negotiated list, every mapped hook is sent. `ACS_HANDSHAKE=0` skips the handshake, and the startup posture settles every step. - Turns: a `UserPromptSubmit` opens a turn with an explicit `steps/turnStart` (decision-eligible) before the `steps/userMessage`; `Stop` closes it with `steps/turnEnd`. The `turn_id` is held in per-session state and stamped on `metadata.turn_id` of every in-turn step; session-level steps (`sessionStart`/`sessionEnd`) carry none. - Dispositions: ALLOW / DENY / ASK are honored natively on `PreToolUse` (`permissionDecision`); MODIFY via `updatedInput` (overrides merged onto the original input) when a usable `parameter_overrides` object is present; DEFER substitutes to deny with an audit event. **Post-execution and lifecycle hooks (`PostToolUse`, `Notification → agentResponse`, `Stop → turnEnd`, `SessionEnd`, `SubagentStop`) are observation-only**: Claude Code fires them after the action / message / turn / session has completed; a Guardian `deny` cannot undo the side effect. Deployments needing pre-delivery gating must place the gate at `UserPromptSubmit` (prompt / turn start) for prompts or `PreToolUse` for tools. - Session context: `session_id` comes from Claude Code's own hook event field on every request; the Guardian-side audit chain accumulates against it. `agent_id` is `ACS_AGENT_ID` when set, else derived as `claude-code:`. - Replay protection: `request_id` (UUID) and `timestamp` are populated on every request. `PreToolUse` request_ids are deterministic (UUID5 of Claude Code's `tool_use_id`) so the matching `PostToolUse` can cite `request_id_ref`. -- Baseline integrity: HMAC-SHA256 per §10 over the RFC 8785 (JCS) canonical envelope, with an HKDF-derived per-session key from `ACS_HMAC_SECRET_FILE` / `ACS_HMAC_SECRET`. `rfc8785` is a hard dependency (no fallback canonicalization — §10 permits none). Responses are signature-verified AND bound to their request (`id` + `request_id`) under any HTTP status; a mis-bound, badly-signed or unverifiable response fails closed regardless of posture. Without a secret the adapter runs unsigned and says so with a loud `unsigned_mode` audit event. -- Decision honoring: the fail posture defaults to `proceed` (fail-open with audit event), matching §6.4's default. `ACS_DEFAULT_DENY=1` or a ServerHello `on_decision_failure: deny` flips it to fail-closed (most-restrictive-wins). Guardian **refusals** (`SIGNATURE_INVALID`, `REPLAY_DETECTED`, `TIMESTAMP_OUT_OF_WINDOW`, malformed/oversized envelope) always fail closed regardless of posture — a refusal is an alive Guardian rejecting the envelope, and each refusal is attacker-reachable (spec issue #32). +- Baseline integrity: HMAC-SHA256 per §10 over the RFC 8785 (JCS) canonical envelope, with an HKDF-derived per-session key from `ACS_HMAC_SECRET_FILE` / `ACS_HMAC_SECRET`. The signature names the configured label `ACS_HMAC_KEY_ID` as `key_id`, and a response under any other label fails verification with no other secret tried; an unsigned `SIGNATURE_INVALID`, a Guardian's answer to a label it does not hold, is recorded as `key_id_rejected` and fails closed. `rfc8785` is a hard dependency (no fallback canonicalization — §10 permits none). Responses are signature-verified AND bound to their request (`id` + `request_id`) under any HTTP status; a mis-bound, badly-signed or unverifiable response fails closed regardless of posture. A hook with no secret, a secret under 32 bytes, or no label refuses its session and sends nothing (`secret_missing`, `secret_too_short`, `key_id_missing`), whatever the posture: ACS-Core signs every request. +- Decision honoring: the fail posture defaults to `proceed` (fail-open with audit event), matching §6.4's default. `ACS_DEFAULT_DENY=1` or a ServerHello `on_decision_failure: deny` flips it to fail-closed (most-restrictive-wins). Guardian **refusals** (`SIGNATURE_INVALID`, `REPLAY_DETECTED`, `TIMESTAMP_OUT_OF_WINDOW`, malformed/oversized envelope) always fail closed regardless of posture — a refusal is an alive Guardian rejecting the envelope, and each refusal is attacker-reachable (spec issue #32). Every decision failure is audited under either posture with its `failure_class` (`timeout`, `unreachable`, `malformed_response`, `guardian_error`, `observed_agent_error`, `signature_invalid`, `handshake_failure`, `other`) and `posture`, and `sessionEnd` reports the session's count per class in `summary.decision_failures` (§6.4, #273). A handshake that gets no answer is audited as `handshake_failure`; that session sends the Guardian nothing, `sessionEnd` included (§4.1). - Audit: every fail-open proceed, refusal, substitution, unevaluated method, and unmapped event emits an `ACS_AUDIT` line to stderr and, when `ACS_AUDIT_FILE` is set, appends to that file (0600). diff --git a/adapters/docs/claude_code/settings.json.example b/adapters/docs/claude_code/settings.json.example index e6a33efb..a4b70354 100644 --- a/adapters/docs/claude_code/settings.json.example +++ b/adapters/docs/claude_code/settings.json.example @@ -13,9 +13,10 @@ "Replace ACS_GUARDIAN_URL with your deployment's endpoint and the", "/path/to/ placeholders with real absolute paths.", "", - "ACS_HMAC_SECRET_FILE: REQUIRED for ACS-Core baseline integrity (\u00a710).", - "Without a secret both ends run unsigned and any local process that", - "binds the Guardian port first becomes the Guardian. Generate one:", + "ACS_HMAC_SECRET_FILE: REQUIRED for ACS-Core baseline integrity (\u00a710), at least 32 bytes.", + "ACS_HMAC_KEY_ID: REQUIRED with the secret. The label that names it; the Guardian holds the same one.", + "Without a secret and its label every hook refuses its session and", + "denies every step. Generate one:", " mkdir -p ~/.acs && openssl rand -hex 32 > ~/.acs/hmac.key && chmod 600 ~/.acs/hmac.key", "and give the SAME file to the Guardian.", "", @@ -36,7 +37,7 @@ "hooks": [ { "type": "command", - "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired" + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired" } ] } @@ -47,7 +48,7 @@ "hooks": [ { "type": "command", - "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_DEFAULT_DENY=1 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired" + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_DEFAULT_DENY=1 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired" } ] } @@ -58,7 +59,7 @@ "hooks": [ { "type": "command", - "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_DEFAULT_DENY=1 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired" + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_DEFAULT_DENY=1 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired" } ] } @@ -69,7 +70,7 @@ "hooks": [ { "type": "command", - "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired" + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired" } ] } @@ -80,7 +81,7 @@ "hooks": [ { "type": "command", - "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired" + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired" } ] } @@ -91,7 +92,7 @@ "hooks": [ { "type": "command", - "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired" + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired" } ] } @@ -102,7 +103,7 @@ "hooks": [ { "type": "command", - "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired" + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired" } ] } diff --git a/adapters/docs/cursor/README.md b/adapters/docs/cursor/README.md index a49aa490..187c7614 100644 --- a/adapters/docs/cursor/README.md +++ b/adapters/docs/cursor/README.md @@ -10,7 +10,7 @@ This adapter is that command. On every hook event Cursor spawns it as a subproce 1. Reads the Cursor hook event from stdin. 2. Translates it to an ACS JSON-RPC envelope ([mapping.md](./mapping.md)). -3. Signs it with HMAC-SHA256 (`ACS_HMAC_SECRET_FILE`). +3. Signs it with HMAC-SHA256 (`ACS_HMAC_SECRET_FILE`, under the label `ACS_HMAC_KEY_ID`). 4. POSTs to the Guardian. 5. Verifies the response signature. 6. Translates the verdict back to Cursor's expected output shape and writes to stdout (or exits with code 2 for `beforeSubmitPrompt`). @@ -50,7 +50,7 @@ chmod 600 ~/.acs/hmac.key Use the example Guardian for testing; a production Guardian is the same wire protocol with a real policy engine attached. ```bash -ACS_HMAC_SECRET_FILE=~/.acs/hmac.key \ +ACS_HMAC_SECRET_FILE=~/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 \ acs-example-guardian --port 8787 ``` @@ -72,18 +72,21 @@ Cursor runs the hooks from every source below and merges their answers, with any # Preview wiring into ~/.cursor/hooks.json (default — user-level) acs-cursor-wire \ --guardian-url=http://127.0.0.1:8787/acs \ - --secret-file=~/.acs/hmac.key + --secret-file=~/.acs/hmac.key \ + --key-id=2026-11 # Apply user-level wiring acs-cursor-wire \ --guardian-url=http://127.0.0.1:8787/acs \ --secret-file=~/.acs/hmac.key \ + --key-id=2026-11 \ --write # OR — project-level wiring for a specific workspace acs-cursor-wire \ --guardian-url=http://127.0.0.1:8787/acs \ --secret-file=~/.acs/hmac.key \ + --key-id=2026-11 \ --settings=./.cursor/hooks.json \ --write ``` @@ -185,48 +188,44 @@ The example Guardian's regex matches `rm -rf /...`; Cursor surfaces the Guardian ### Smoke #4 — audit-cause differentiation -Verifies the adapter's audit log distinguishes "Guardian unreachable" (ops issue) from "Guardian rejected the envelope" (client/operator bug). +Verifies that the adapter's audit log distinguishes a Guardian that rejected the request (a configuration error) from a Guardian it could not reach (an ops issue). Both commands run against the Guardian started above, on `127.0.0.1:8787` under the label `2026-11`. -Unsigned envelope to a signing-required Guardian: +A secret the Guardian does not hold, under the Guardian's label: ```bash ACS_GUARDIAN_URL="http://127.0.0.1:8787/acs" \ -ACS_HMAC_SECRET="" \ +ACS_HMAC_SECRET="$(openssl rand -hex 32)" ACS_HMAC_KEY_ID=2026-11 \ acs-cursor-hook preToolUse 2>&1 <<'EOF' {"session_id":"11111111-1111-4111-8111-111111111111","tool_name":"Bash","tool_input":{"command":"echo test"}} EOF ``` -Expected stderr — a signature refusal fails CLOSED regardless of -posture (a refusal is an alive Guardian rejecting the envelope; spec -issue #32), and stdout carries `{"permission": "deny", ...}`: +Expected: the Guardian answers the handshake with `-32004 SIGNATURE_INVALID`, signed with its own secret. The hook cannot verify that answer, so it refuses the session whatever the posture (spec issue #32), and stdout carries a deny: ``` -acs-adapter: Guardian returned JSON-RPC error -32004 (signature_invalid_response): SIGNATURE_INVALID -ACS_AUDIT {"acs_audit_event": "guardian_refusal_fail_closed", "cause": "signature_invalid_response", ...} +ACS_AUDIT {"acs_audit_event": "session_refused", "cause": "error_signature_invalid", "claimed_cause": "signature_invalid_response", "claimed_error_code": -32004, ...} +ACS_AUDIT {"acs_audit_event": "session_refused_fail_closed", "cause": "error_signature_invalid", ..., "event": "preToolUse", ...} +{"permission": "deny", "user_message": "ACS adapter: session refused (error_signature_invalid)", "agent_message": "ACS adapter: session refused (error_signature_invalid)"} ``` -Guardian unreachable (a decision FAILURE, not a refusal — this one -follows the §6.4 posture, fail-open by default): +Guardian unreachable, with the Guardian's secret and label: ```bash ACS_GUARDIAN_URL="http://127.0.0.1:1/dead" \ +ACS_HMAC_SECRET_FILE=~/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 \ acs-cursor-hook preToolUse 2>&1 <<'EOF' {"session_id":"11111111-1111-4111-8111-111111111111","tool_name":"Bash","tool_input":{"command":"echo test"}} EOF ``` -Expected: +Expected: the handshake gets no answer, so the session has no negotiated Guardian, and the hook's startup posture settles the step (fail-open when the hook sets no `ACS_DEFAULT_DENY`); stdout carries `{"permission": "allow"}`: ``` -acs-adapter: Guardian unreachable: -ACS_AUDIT {"acs_audit_event": "fail_open_bypass", "cause": "transport_failure", ...} +ACS_AUDIT {"acs_audit_event": "session_without_negotiated_guardian", "cause": "transport_failure", "failure_class": "handshake_failure", "posture": "fail_open", ...} +ACS_AUDIT {"acs_audit_event": "unguarded_session_bypass", "cause": "transport_failure", ..., "event": "preToolUse", ...} ``` -Distinct `acs_audit_event` AND distinct `cause`: refusals are always -`guardian_refusal_fail_closed`; failures carry the posture -(`fail_open_bypass` / `decision_failure_fail_closed`). Operators grep -on either to triage. +Distinct `acs_audit_event` and `cause`: a rejection refuses the session (`session_refused_fail_closed`) whatever the posture, and an unreachable Guardian follows the startup posture (`unguarded_session_bypass`, or `session_not_started_fail_closed` under a deny posture). Operators grep on either to triage. A hook with no secret sends nothing and refuses the session with `secret_missing`. ### Smoke #5 — pre-flight inventory (paranoid) @@ -288,6 +287,7 @@ The adapter is configured by environment variables, typically set per-hook by `a | `ACS_GUARDIAN_URL` | `http://127.0.0.1:8787/acs` | Guardian endpoint. http/https only; SSRF allowlist refuses other schemes. | | `ACS_HMAC_SECRET_FILE` | (unset) | Path to a 0600 file holding the shared HMAC secret. | | `ACS_HMAC_SECRET` | (unset) | Inline secret. Less secure (visible in `ps eauxw`). Prefer the file. | +| `ACS_HMAC_KEY_ID` | (unset) | The label that names the secret (§10); the Guardian holds the same one. Required with a secret: a secret without a label refuses the session, and a response signed under another label fails verification. | | `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 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. | @@ -314,14 +314,14 @@ Honest, item-by-item against the ACS-Core baseline in `docs/spec/conformance.md` | ACS-Core item | Status | |---|---| | Handshake (`handshake/hello`) | ✓ on the first event of a session; the decision (negotiated, refused, or no answer under `ACS_STARTUP_POSTURE`) holds for the session's lifetime (§4.1) | -| 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. | +| Profiles (`profiles_supported` / `profiles_accepted`) | ✓ always claims `acs-core`: a hook with no usable secret and label refuses its session before the handshake. 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`. 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 | +| Baseline integrity (HMAC-SHA256) | ✓ every request and response signed under `ACS_HMAC_KEY_ID`; a hook with no secret of at least 32 bytes, or no label, refuses its session and sends nothing; SIGNATURE_INVALID -32004 on tamper | | 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 | diff --git a/adapters/docs/cursor/hooks.json.example b/adapters/docs/cursor/hooks.json.example index 57cc11f7..e0c26d25 100644 --- a/adapters/docs/cursor/hooks.json.example +++ b/adapters/docs/cursor/hooks.json.example @@ -4,62 +4,62 @@ "hooks": { "sessionStart": [ { - "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-cursor-hook sessionStart" + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-cursor-hook sessionStart" } ], "beforeSubmitPrompt": [ { - "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_AUDIT_FILE=/path/to/.acs/audit.log ACS_DEFAULT_DENY=1 /path/to/acs-cursor-hook beforeSubmitPrompt", + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log ACS_DEFAULT_DENY=1 /path/to/acs-cursor-hook beforeSubmitPrompt", "failClosed": true } ], "preToolUse": [ { - "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_AUDIT_FILE=/path/to/.acs/audit.log ACS_DEFAULT_DENY=1 /path/to/acs-cursor-hook preToolUse", + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log ACS_DEFAULT_DENY=1 /path/to/acs-cursor-hook preToolUse", "failClosed": true } ], "postToolUse": [ { - "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-cursor-hook postToolUse" + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-cursor-hook postToolUse" } ], "beforeShellExecution": [ { - "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_AUDIT_FILE=/path/to/.acs/audit.log ACS_DEFAULT_DENY=1 /path/to/acs-cursor-hook beforeShellExecution", + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log ACS_DEFAULT_DENY=1 /path/to/acs-cursor-hook beforeShellExecution", "failClosed": true } ], "afterShellExecution": [ { - "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-cursor-hook afterShellExecution" + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-cursor-hook afterShellExecution" } ], "beforeMCPExecution": [ { - "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_AUDIT_FILE=/path/to/.acs/audit.log ACS_DEFAULT_DENY=1 /path/to/acs-cursor-hook beforeMCPExecution", + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log ACS_DEFAULT_DENY=1 /path/to/acs-cursor-hook beforeMCPExecution", "failClosed": true } ], "afterMCPExecution": [ { - "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-cursor-hook afterMCPExecution" + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-cursor-hook afterMCPExecution" } ], "subagentStart": [ { - "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_AUDIT_FILE=/path/to/.acs/audit.log ACS_DEFAULT_DENY=1 /path/to/acs-cursor-hook subagentStart", + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log ACS_DEFAULT_DENY=1 /path/to/acs-cursor-hook subagentStart", "failClosed": true } ], "afterAgentResponse": [ { - "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-cursor-hook afterAgentResponse" + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-cursor-hook afterAgentResponse" } ], "sessionEnd": [ { - "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-cursor-hook sessionEnd" + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-cursor-hook sessionEnd" } ] } diff --git a/adapters/docs/cursor/live_verification.md b/adapters/docs/cursor/live_verification.md index 171b2144..3c7a6cc1 100644 --- a/adapters/docs/cursor/live_verification.md +++ b/adapters/docs/cursor/live_verification.md @@ -11,7 +11,7 @@ The procedure below has been run end-to-end and produced the expected outcomes ( ```bash # 1. Generate a shared signing secret and start the example Guardian mkdir -p ~/.acs && openssl rand -hex 32 > ~/.acs/hmac.key && chmod 600 ~/.acs/hmac.key -ACS_HMAC_SECRET_FILE=~/.acs/hmac.key acs-example-guardian --port 8787 +ACS_HMAC_SECRET_FILE=~/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 acs-example-guardian --port 8787 # 2. In a new shell, set up a test project with the adapter wired in mkdir -p /tmp/acs-cursor-live/.cursor @@ -20,19 +20,19 @@ cat > /tmp/acs-cursor-live/.cursor/hooks.json <<'EOF' "version": 1, "hooks": { "sessionStart": [ - { "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key /path/to/acs-cursor-hook sessionStart" } + { "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 /path/to/acs-cursor-hook sessionStart" } ], "beforeSubmitPrompt": [ - { "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key /path/to/acs-cursor-hook beforeSubmitPrompt" } + { "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 /path/to/acs-cursor-hook beforeSubmitPrompt" } ], "preToolUse": [ - { "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key /path/to/acs-cursor-hook preToolUse" } + { "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 /path/to/acs-cursor-hook preToolUse" } ], "postToolUse": [ - { "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key /path/to/acs-cursor-hook postToolUse" } + { "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 /path/to/acs-cursor-hook postToolUse" } ], "beforeShellExecution": [ - { "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key /path/to/acs-cursor-hook beforeShellExecution" } + { "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 /path/to/acs-cursor-hook beforeShellExecution" } ] } } diff --git a/adapters/docs/cursor/mapping.md b/adapters/docs/cursor/mapping.md index 4b851703..a2813847 100644 --- a/adapters/docs/cursor/mapping.md +++ b/adapters/docs/cursor/mapping.md @@ -8,8 +8,8 @@ Each Cursor hook event maps to an ACS `steps/*` method. The adapter (`src/acs/in | Cursor hook | ACS step method | Notes | |---|---|---| -| `sessionStart` | `steps/sessionStart` | Session bounds, sent once per session by the hook process that opens the session and built from its event (see Concurrency). `platform_context` carries the real fields: `workspace_roots`, `cursor_version`, and from Cursor's `sessionStart` event, `composer_mode` and `is_background_agent`. 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 — no `turn_id`. | -| `sessionEnd` | `steps/sessionEnd` | Session close (chain seal). Cursor reasons map onto the ACS enum: `completed`→`completed`, `aborted`→`cancelled`, `error`→`error`, `window_close`/`user_close`→`abandoned`. Session-level — 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). `platform_context` carries the real fields: `workspace_roots`, `cursor_version`, and from Cursor's `sessionStart` event, `composer_mode` and `is_background_agent`. 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; `sessionEnd`, which is audit-only, goes out under either posture and reports that failure (§6.4). Session-level — no `turn_id`. | +| `sessionEnd` | `steps/sessionEnd` | Session close (chain seal). Cursor reasons map onto the ACS enum: `completed`→`completed`, `aborted`→`cancelled`, `error`→`error`, `window_close`/`user_close`→`abandoned`. Carries `summary.decision_failures`, the session's decision failures per failure class (§6.4). Session-level — no `turn_id`. | | `stop` | `steps/turnEnd` | End of a **turn** (each reply), not the session. `steps/sessionEnd` seals the chain once per session, so mapping `stop` there sealed it every reply; `steps/turnEnd` (audit-only) is the per-turn boundary and echoes the open turn's `turn_id`. Cursor's `status` maps to the outcome enum: `completed`→`completed`, `aborted`→`interrupted`, `error`→`error`. A `stop` with no open turn is skipped + audited rather than fabricating a `turn_id`. | | `preToolUse` | `steps/toolCallRequest` | Principal interception point; fires for ALL tool types (Shell, Read, Write, MCP, Task, …). `request_id` pinned to uuid5 of `tool_use_id` so the matching result correlates. Carries `metadata.turn_id`. | | `postToolUse` | `steps/toolCallResult` | `tool_output` is a JSON-STRINGIFIED result payload ("not raw terminal text"); the adapter parses it into `outputs`, maps a nonzero `exitCode` inside it to `exit_status: failure`, coerces `duration`→`duration_ms` (integer), and cites `request_id_ref` from `tool_use_id`. | @@ -44,7 +44,7 @@ Cursor's documented response keys are event-specific. The adapter translates ACS | `deny` | `{"permission": "deny", "user_message": reasoning, "agent_message": reasoning}` | Cursor displays user_message in UI; agent_message is fed back to the agent's context. | | `ask` | `{"permission": "ask", "user_message": reasoning, "agent_message": reasoning}` | Upstream caveat: Cursor documents `ask` as "accepted by the schema but not enforced for preToolUse today", and `subagentStart` treats `ask` as `deny`. On those events ask is a soft signal, not a hard pause. | | `defer` | `{"permission": "ask", ...}` | Deployment substitution (not spec-mandated; §9.2 places substitution on the Guardian): Cursor has no defer. | -| `modify` | `{"permission": "allow", "updated_input": original ⊕ parameter_overrides, "user_message": reasoning}` on `preToolUse` only | `updated_input` REPLACES the whole input, so the adapter MERGES the overrides onto the original arguments. Other permission events have no documented updated-input field; modify substitutes to deny with audit. A contradictory `modifications` object fails closed to deny (§6.3:146). | +| `modify` | `{"permission": "allow", "updated_input": original ⊕ parameter_overrides, "user_message": reasoning}` on `preToolUse` only | `updated_input` REPLACES the whole input, so the adapter MERGES the overrides onto the original arguments. On the other permission events, which have no documented updated-input field, and for an `MCP:` tool, whose `updated_input` Cursor ignores when an HTTP MCP server runs it, a MODIFY is a DENY recorded as `modify_unsupported` with `reason_codes: ["modify_unsupported"]` (§6.5), as are redactions. A contradictory `modifications` object fails closed to deny (§6.3:146). | ### `postToolUse` — the only post-tool event with documented output fields @@ -52,12 +52,13 @@ Cursor's documented response keys are event-specific. The adapter translates ACS |---|---| | `allow` | `{}` (no output; proceed) | | `deny` | `{"additional_context": "ACS Guardian denied this result: …"}` + `unenforceable_decision` audit (the tool already ran; feedback is the only channel) | -| `modify` with `modified_content` | `{"updated_mcp_tool_output": }` — "For MCP tools only: replaces the tool output seen by the model" | +| `modify` with `modified_content` on an MCP tool | `{"updated_mcp_tool_output": }` — "For MCP tools only: replaces the tool output seen by the model" | +| `modify` on another tool, on an HTTP MCP server's tool (its `tool_output` is `{"redacted": true}`), or with structured edits | as `deny`, recorded as `modify_unsupported` with `reason_codes: ["modify_unsupported"]` (§6.5) | | any other decision with reasoning | `{"additional_context": reasoning}` | ### Other post-tool events (`postToolUseFailure`, `afterShellExecution`, `afterMCPExecution`, `afterFileEdit`, `afterTabFileEdit`) -"No output fields currently supported" (Cursor docs) — the adapter emits nothing; an arrived restrictive decision is recorded as an `unenforceable_decision` audit event. +"No output fields currently supported" (Cursor docs) — the adapter emits nothing; an arrived restrictive decision is recorded as an `unenforceable_decision` audit event, and a MODIFY also as `modify_unsupported` with `reason_codes: ["modify_unsupported"]` (§6.5). ### `beforeSubmitPrompt` @@ -67,7 +68,7 @@ Cursor's documented output is `{"continue": bool, "user_message": str}`. The ada |---|---| | `allow` | `{"continue": true}`, exit 0 | | `deny` | `{"continue": false, "user_message": reasoning}`, exit 0 | -| `ask` / `defer` / `modify` | same as deny (substituted to block + audit; the prompt hook can't pause or edit) | +| `ask` / `defer` / `modify` | same as deny (substituted to block + audit; the prompt hook can't pause or edit); a MODIFY is also recorded as `modify_unsupported` with `reason_codes: ["modify_unsupported"]` (§6.5) | ### Lifecycle events (`sessionEnd`, `stop`, `preCompact`, `afterAgentResponse`, `afterAgentThought`) @@ -95,13 +96,13 @@ Cursor runs the hooks of one session at the same time: in Cursor 3.17.8, a new c ## Conformance posture -The Cursor adapter covers ACS-Core's mandatory floor in the same shape as the Claude Code adapter. This is the floor the tests check, not a full ACS-Core conformance claim (see `adapters/README.md`). Three ACS-Core requirements from later specification changes follow in separate PRs: the `key_id` taken from configuration (§10, #272); decision failures recorded with their failure class and counted at `sessionEnd` (§6.4, #273); and a signature on every request, where today an adapter with no secret configured sends unsigned requests (§10). The floor it covers: +The Cursor adapter covers ACS-Core's mandatory floor in the same shape as the Claude Code adapter. This is the floor the tests check, not a full ACS-Core conformance claim (see `adapters/README.md`). The floor it covers: - Handshake: `handshake/hello` performed on the first event of a session, and its answer decides the session (§4.1): a verified ServerHello negotiates; a refusal, or an answer that fails its checks, refuses every step; and no answer leaves the session with no negotiated Guardian, so each hook's `ACS_STARTUP_POSTURE` applies (`proceed` lets the step run with nothing sent to the Guardian; `refuse` denies it). The decision is stored in `~/.cache/acs-adapter-handshake/` with an HMAC under the session key over the decision, the session and the Guardian URL, and holds for the session's lifetime, so a dead Guardian costs one timeout per session; a record that fails its checks, or a decision that cannot be stored, refuses the session. ServerHello signature-verified, bound to the ClientHello, its `on_decision_failure` combines with `ACS_DEFAULT_DENY` (deny wins over proceed), and its `timeout_config` replaces the local decision timeout, per method where `per_method_ms` names one (§6.4). The ServerHello must answer the ClientHello: a `negotiated_version` sharing a major version the adapter offered, a `methods_evaluated` list, the one transport the adapter offers (its Guardian URL's scheme), a positive `timeout_config.default_ms`, an `on_decision_failure` of `proceed` or `deny`, and only profiles the adapter claimed; otherwise the session has no negotiated Guardian (`invalid_server_hello`). Its `methods_evaluated` bounds what is sent: a method the Guardian omits resolves as ALLOW-by-default without being sent (handshake.json), recorded as `method_not_evaluated`; permission events and `beforeSubmitPrompt` return their explicit allow shapes, since `failClosed` reads an empty reply as a failed hook. With no negotiated list, every mapped event is sent. `ACS_HANDSHAKE=0` skips the handshake, and the startup posture settles every step. - Turns: a `beforeSubmitPrompt` opens a turn with an explicit `steps/turnStart` (decision-eligible) before the `steps/userMessage`; `stop` closes it with `steps/turnEnd`. `turn_id` is held in per-session state and stamped on `metadata.turn_id` of every in-turn step. - Hook taxonomy minimum: all covered, plus many additional Cursor events. `subagentStop`, `beforeReadFile`, `beforeTabFileRead`, `workspaceOpen` deliberately unmapped with documented reasons; unknown events emit an `unmapped_hook_event` audit line rather than going quiet. -- Dispositions: ALLOW / DENY / ASK supported on permission events (see the upstream ask-enforcement caveat); MODIFY supported on `preToolUse` (merged overrides); DEFER → ASK substitution (deployment behavior, not spec-mandated). +- Dispositions: ALLOW / DENY / ASK supported on permission events (see the upstream ask-enforcement caveat); MODIFY supported on `preToolUse` for tools other than MCP tools (merged overrides) and on an MCP tool's `postToolUse` output, and elsewhere a DENY recorded as `modify_unsupported` (§6.5); DEFER → ASK substitution (deployment behavior, not spec-mandated). - SessionContext: session_id sent every request (non-UUID `conversation_id` coerced deterministically). - Replay protection: ✓ -- Baseline integrity: HMAC-SHA256 per §10 over the RFC 8785 (JCS) canonical envelope, HKDF per-session key (`ACS_HMAC_SECRET_FILE` / `ACS_HMAC_SECRET`); `rfc8785` is a hard dependency. Responses signature-verified and bound to their request under any HTTP status; refusals, binding mismatches, bad response signatures, and responses that cannot be verified fail closed regardless of posture (deliberately stricter than the current §6.4 text — spec issue #32). Unsigned mode is announced with a loud `unsigned_mode` audit event. -- Decision honoring: ✓ (Cursor enforces the permission verdict; the prompt gate emits the documented `continue:false` JSON with exit code 0) +- Baseline integrity: HMAC-SHA256 per §10 over the RFC 8785 (JCS) canonical envelope, HKDF per-session key (`ACS_HMAC_SECRET_FILE` / `ACS_HMAC_SECRET`); `rfc8785` is a hard dependency. The signature names the configured label `ACS_HMAC_KEY_ID` as `key_id`, and a response under any other label fails verification with no other secret tried; an unsigned `SIGNATURE_INVALID`, a Guardian's answer to a label it does not hold, is recorded as `key_id_rejected` and fails closed. Responses signature-verified and bound to their request under any HTTP status; refusals, binding mismatches, bad response signatures, and responses that cannot be verified fail closed regardless of posture (deliberately stricter than the current §6.4 text — spec issue #32). A hook with no secret, a secret under 32 bytes, or no label refuses its session and sends nothing (`secret_missing`, `secret_too_short`, `key_id_missing`), whatever the posture: ACS-Core signs every request. +- Decision honoring: ✓ (Cursor enforces the permission verdict; the prompt gate emits the documented `continue:false` JSON with exit code 0). Every decision failure is audited under either posture with its `failure_class` (`timeout`, `unreachable`, `malformed_response`, `guardian_error`, `observed_agent_error`, `signature_invalid`, `handshake_failure`, `other`) and `posture`, and `sessionEnd` reports the session's count per class in `summary.decision_failures` (§6.4, #273). A handshake that gets no answer is audited as `handshake_failure`; that session sends the Guardian nothing, `sessionEnd` included (§4.1). diff --git a/adapters/docs/nat/README.md b/adapters/docs/nat/README.md index 958f4d4f..1f160fb6 100644 --- a/adapters/docs/nat/README.md +++ b/adapters/docs/nat/README.md @@ -8,15 +8,15 @@ NAT exposes a `FunctionMiddleware` abstraction that wraps any function — tools 1. Receives the `InvocationContext` (function name, arguments, output) from NAT's middleware pipeline. 2. Translates it to an ACS JSON-RPC envelope ([mapping.md](./mapping.md)). -3. Signs it with HMAC-SHA256 (`ACS_HMAC_SECRET` or `ACS_HMAC_SECRET_FILE`). +3. Signs it with HMAC-SHA256 (`ACS_HMAC_SECRET` or `ACS_HMAC_SECRET_FILE`, under the label `ACS_HMAC_KEY_ID`). 4. POSTs to the Guardian. 5. Verifies the response signature and the JSON-RPC error/result shape. 6. Applies the verdict: `allow` passes through; `deny` raises `ACSGuardianDenied` (or sets `InvocationAction.SKIP` on NAT releases that expose it); `modify` mutates `context.modified_kwargs`; `ask`/`defer` substitute to deny at the middleware boundary. 7. Records a `steps/toolCallResult` envelope in `post_invoke` and redacts the output on deny. -The handshake/hello fires once per session, cached in process state so subsequent events skip the round-trip. +Each NAT run is its own ACS session, with a `session_id` derived from the configured one and the run; its `handshake/hello` fires once, at the run's start, and the decision is kept in process state for the run's calls. A call outside any NAT run uses the configured `session_id`, which gets its own `sessionStart` before the first such call and its `sessionEnd` when NAT shuts the middleware down (`mapping.md`). -Independently of the middleware path, the adapter reports each run's `WORKFLOW_START` / `WORKFLOW_END` events as `steps/sessionStart` + `steps/userMessage` (on workflow start with input) and `steps/agentResponse` + `steps/sessionEnd` (on workflow end with output). A run that raises or is cancelled reaches no workflow end, so it sends no `agentResponse` or `sessionEnd` (see `mapping.md`). When NAT builds the middleware, it registers an `acs_lifecycle` telemetry exporter, which NAT starts for every run before `WORKFLOW_START`. A middleware built without NAT's builder subscribes on each run's first call instead, so it reports that run from the call on. **The lifecycle stream is observation-only; it cannot block calls.** Enforcement is the middleware path's job. +Independently of the middleware path, the adapter reports each run's `WORKFLOW_START` / `WORKFLOW_END` events as `steps/sessionStart` + `steps/userMessage` (on workflow start with input) and `steps/agentResponse` + `steps/sessionEnd` (on workflow end with output). A run that raises or is cancelled reaches no workflow end, so it sends no `agentResponse`, and its `sessionEnd` carries `reason: error` or `cancelled` (see `mapping.md`). A Guardian that denies a run's `sessionStart` refuses that run's calls. When NAT builds the middleware, it registers an `acs_lifecycle` telemetry exporter, which NAT starts for every run before `WORKFLOW_START`. A middleware built without NAT's builder subscribes on each run's first call instead, so it reports that run from the call on. **Apart from the run's `sessionStart` decision, which the middleware applies to the run's calls, the lifecycle stream is observation-only.** Enforcement is the middleware path's job. ### Decision honoring (§6.4) @@ -50,7 +50,7 @@ chmod 600 ~/.acs/hmac.key The example Guardian is for testing; a production Guardian is the same wire protocol with a real policy engine attached. ```bash -ACS_HMAC_SECRET_FILE=~/.acs/hmac.key \ +ACS_HMAC_SECRET_FILE=~/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 \ acs-example-guardian --port 8787 ``` @@ -124,7 +124,7 @@ NAT's middleware fires per attachment point: the workflow, each function group a What this gives you: **the same hard-stop guarantee Cursor's `failClosed: true` gives.** The middleware runs *before* the function executes and waits for the Guardian's decision, off the event loop; a Guardian deny raises `ACSGuardianDenied` (or sets `InvocationAction.SKIP`) and NAT does not call the function. -**The lifecycle observer reports workflow boundaries only.** It sends `sessionStart` and `userMessage` for each run, `agentResponse` and `sessionEnd` for each run that returns, and no tool calls, so a function the middleware is not wired to reaches the Guardian neither as a decision nor as an observation. Steps 1 and 2 close that gap: define every function in YAML, and run `acs-nat-wire --check` in CI. +**The lifecycle observer reports workflow boundaries only.** It sends `sessionStart` and `userMessage` for each run, `agentResponse` for each run that returns, `sessionEnd` for each run, and no tool calls, so a function the middleware is not wired to reaches the Guardian neither as a decision nor as an observation. Steps 1 and 2 close that gap: define every function in YAML, and run `acs-nat-wire --check` in CI. ## Smoke tests @@ -262,7 +262,7 @@ The shared protocol layer is identical: the Guardian sees the same ACS JSON-RPC | `guardian_url` | `http://127.0.0.1:8787/acs` | Guardian endpoint. http/https only. | | `default_deny` | `false` | Fail-open with audit (§6.4 default). Set `true` 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 — input gate blocks, output gate redacts. See spec issue #32. | | `timeout_s` | `5.0` | Per-request Guardian round-trip timeout. A ServerHello `timeout_config` replaces it for the session, per method where `per_method_ms` names one. | -| `session_id` | (auto, per-process) | UUID; auto-generated and stable for the process lifetime. | +| `session_id` | (auto, per-process) | UUID; auto-generated and stable for the process lifetime. Calls outside a NAT run use it; each run's own `session_id` is derived from it. | | `target_function_or_group` | (unset) | Optional metadata label; derives `agent_id` if `ACS_AGENT_ID` env is unset. | ### Environment variables (read by the adapter at runtime) @@ -271,6 +271,7 @@ The shared protocol layer is identical: the Guardian sees the same ACS JSON-RPC |---|---|---| | `ACS_HMAC_SECRET_FILE` | (unset) | Path to a 0600 file holding the shared HMAC secret. | | `ACS_HMAC_SECRET` | (unset) | Inline secret. Less secure (visible in `ps eauxw`). Prefer the file. | +| `ACS_HMAC_KEY_ID` | (unset) | The label that names the secret (§10); the Guardian holds the same one. Required with a secret: a secret without a label refuses the session, and a response signed under another label fails verification. | | `ACS_AGENT_ID` | derived from `target_function_or_group` | Stable agent identifier sent in `metadata.agent_id`. | | `ACS_SESSION_ID` | derived | Overrides the auto-generated session_id. | | `ACS_GUARDIAN_HOST_ALLOWLIST` | (unset) | Optional comma-separated hostname allowlist (defense in depth). | @@ -282,7 +283,7 @@ The shared protocol layer is identical: the Guardian sees the same ACS JSON-RPC ## On-disk state -NAT runs the adapter in-process. The middleware keeps its session's handshake decision in memory and stores it in `~/.cache/acs-adapter-handshake/` like the other adapters, so a restarted process with the same `session_id` follows the same decision, and a new session handshakes again. The Guardian-side state (`~/.cache/acs-guardian-state/.json`) is the same as for the other adapters — chain head + replay set, survives Guardian restart. +NAT runs the adapter in-process. The middleware keeps each session's handshake decision in memory and stores it in `~/.cache/acs-adapter-handshake/` like the other adapters, so a restarted process with the same `session_id` follows the same decision, and a new session handshakes again. A run's records are removed once its `sessionEnd` has been handled. The Guardian-side state (`~/.cache/acs-guardian-state/.json`) is the same as for the other adapters — chain head + replay set, survives Guardian restart. ## Conformance status @@ -290,16 +291,16 @@ Honest, item-by-item against the ACS-Core baseline in `docs/spec/conformance.md` | ACS-Core item | Status | |---|---| -| Handshake (`handshake/hello`) | ✓ on the first `pre_invoke`; the decision (negotiated, refused, or no answer under `ACS_STARTUP_POSTURE`) holds for the session's lifetime (§4.1) | -| 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. | +| Handshake (`handshake/hello`) | ✓ once per session: each NAT run's at its start, and the configured session's when NAT builds the middleware; the decision (negotiated, refused, or no answer under `ACS_STARTUP_POSTURE`) holds for the session's lifetime (§4.1) | +| Profiles (`profiles_supported` / `profiles_accepted`) | ✓ always claims `acs-core`: the middleware refuses a session with no usable secret and label before the handshake. 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 (`test_envelope_schema.py`) | -| Hook taxonomy (Core minimum set) | ⚠ emitted, but read the enforcement split. **Decision-eligible** coverage: `toolCallRequest` / `toolCallResult`, via the `FunctionMiddleware` path — these can actually block/redact. The other four — `sessionStart`, `userMessage`, `agentResponse`, `sessionEnd` — are emitted **observation-only** from the `IntermediateStepManager` subscription (fire-and-forget; the Guardian's verdict is not applied), so they provide audit/trace coverage, **not** enforcement, and should not be counted as decision-eligible Core coverage. ACS-Core requires a subagent-capable client to emit `subagentStart` when it spawns a sub-agent, and a framework with no sub-agent abstraction has nothing to emit. The adapter emits no `subagentStart`: nested workflow functions reach the Guardian as generic tool calls. The Guardian decides by deployment-defined means whether a NAT deployment counts as subagent-capable. Where sub-workflows act as sub-agents, this is a known gap. See `mapping.md`. Verified in `test_lifecycle.py`. | +| Hook taxonomy (Core minimum set) | ⚠ emitted, but read the enforcement split. **Decision-eligible** coverage: `toolCallRequest` / `toolCallResult`, via the `FunctionMiddleware` path — these can actually block/redact. `sessionStart` (ALLOW / DENY) is applied too: a deny refuses the run's calls. The other three — `userMessage`, `agentResponse`, `sessionEnd` — are emitted **observation-only** from the `IntermediateStepManager` subscription (fire-and-forget; the Guardian's verdict is not applied), so they provide audit/trace coverage, **not** enforcement, and should not be counted as decision-eligible Core coverage. ACS-Core requires a subagent-capable client to emit `subagentStart` when it spawns a sub-agent, and a framework with no sub-agent abstraction has nothing to emit. The adapter emits no `subagentStart`: nested workflow functions reach the Guardian as generic tool calls. The Guardian decides by deployment-defined means whether a NAT deployment counts as subagent-capable. Where sub-workflows act as sub-agents, this is a known gap. See `mapping.md`. Verified in `test_lifecycle.py`. | | Dispositions | ALLOW / DENY / MODIFY supported on **function-middleware (pre-execution)** hooks (`pre_invoke` for every wrapped function — tools, sub-workflows, LLM, retrievers). ASK/DEFER substituted to DENY at the middleware boundary; deployments wanting pause-and-resume should compose with NAT's HITL middleware (`nat.middleware.hitl`). **Lifecycle hooks from the `IntermediateStepManager` subscription (`steps/sessionStart`, `steps/userMessage`, `steps/agentResponse`, `steps/sessionEnd`) are observation-only** — subscription callbacks cannot veto a NAT event after it fires. See `mapping.md`. | | Unknown-disposition fail posture | ✓ | | Post-tool deny redaction | ✓ `post_invoke` clears `context.output = None` and emits an `ACS_AUDIT` `post_invoke_redacted` event per §6.4 output-redaction gate. (NAT's `InvocationContext` is a strict Pydantic model with `validate_assignment=True` — ad-hoc attributes like `acs_post_invoke_redacted` would crash; downstream consumers MUST read the audit event for the redaction signal, not an extra attribute.) | | SessionContext + published `chain_hash` | ✓ session_id coerced to UUID; Guardian computes rolling chain | | Replay protection | ✓ Guardian enforcement (REPLAY_DETECTED -32005, TIMESTAMP_OUT_OF_WINDOW -32006); audit cause distinguishes both | -| Baseline integrity (HMAC-SHA256) | ✓ when `ACS_HMAC_SECRET[_FILE]` is set; signed responses verified by adapter (pre_invoke + post_invoke reject SIGNATURE_INVALID) | +| Baseline integrity (HMAC-SHA256) | ✓ every request and response signed under `ACS_HMAC_KEY_ID`; a middleware with no secret of at least 32 bytes, or no label, refuses its session and denies every call; signed responses verified by adapter (pre_invoke + post_invoke reject SIGNATURE_INVALID) | | Decision honoring (§6.4) | ✓ NAT's middleware contract guarantees the function will not execute if `pre_invoke` raises or sets SKIP — verified in `test_live.py` (deny tests assert `executed["count"] == 0`); fail-open emits `ACS_AUDIT` events | | `cause` field on every audit event | ✓ `transport_failure`, `adapter_exception`, `response_signature_invalid`, plus a cause for every §17 JSON-RPC error code (for example `session_refused_response`, `signature_invalid_response`, `replay_detected_response`, `internal_error_response`) with `guardian_error_response` as the catch-all fallback for unknown codes | | 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. | diff --git a/adapters/docs/nat/mapping.md b/adapters/docs/nat/mapping.md index feebedbd..7fd96f15 100644 --- a/adapters/docs/nat/mapping.md +++ b/adapters/docs/nat/mapping.md @@ -14,19 +14,24 @@ The ACS adapter is implemented as a `FunctionMiddleware` that, for each wrapped |---|---|---| | Any wrapped function `pre_invoke` (tools, LLMs, retrievers, memory — all of them) | `steps/toolCallRequest` | **Yes** — every wrapped call, with the function name as the tool name | | Any wrapped function `post_invoke` | `steps/toolCallResult` | **Yes** | -| Workflow start (via the `acs_lifecycle` exporter NAT starts for every run) | `steps/sessionStart` + `steps/userMessage` | **Yes** — observation-only, see below | +| Workflow start (via the `acs_lifecycle` exporter NAT starts for every run) | `steps/sessionStart` + `steps/userMessage` | **Yes** — a `sessionStart` deny refuses the run's calls; see below | | Workflow end (same exporter), for a run that returns | `steps/agentResponse` + `steps/sessionEnd` | **Yes** — observation-only, see below | +| The end of a run that raises or is cancelled (NAT stops the run's exporter) | `steps/sessionEnd` with `reason: error` or `cancelled` | **Yes** — observation-only, see below | | Retriever calls as `steps/knowledgeRetrieval` | — | **No.** Retrievers surface as `toolCallRequest` with the retriever's function name; the Guardian dispatches on name. A dedicated `knowledgeRetrieval` emission is possible future work. | | Memory read / write as `steps/memoryContextRetrieval` / `steps/memoryStore` | — | **No.** Same status: memory functions surface as generic tool calls. Documented gap — a Guardian keying policy on the memory-specific hooks will not see them from this adapter. | | Sub-workflow invocation as `steps/subagentStart` / `steps/subagentStop` | — | **No.** Nested workflow functions surface as generic tool calls; NAT exposes no distinct spawn boundary to the middleware. ACS-Core requires `subagentStart` only of a subagent-capable client, and the Guardian determines that capability by deployment-defined means. The requirement is vacuous for NAT only when sub-workflows are genuinely not distinguishable at this seam. Otherwise this is a mapping gap. | The Guardian can dispatch on the tool name to apply retriever-, memory-, or subagent-specific policy to the generic `toolCallRequest` stream; what it cannot get from this adapter is the dedicated ACS step methods for those events. -### Lifecycle hooks are observation-only +### Lifecycle hooks after `sessionStart` are observation-only The adapter's `_on_intermediate_step` callback emits `steps/sessionStart` + `steps/userMessage` on `WORKFLOW_START` and `steps/agentResponse` + `steps/sessionEnd` on `WORKFLOW_END` via `_emit_lifecycle_hook`. NAT creates a new step stream for each run, so the builder registers an `acs_lifecycle` telemetry exporter that NAT starts for every run before `WORKFLOW_START`; a middleware built without NAT's builder subscribes on each run's first call instead and reports that run from the call on. **This path is fire-and-forget.** NAT's `IntermediateStepManager` subscription model is a notification stream — a subscriber callback cannot veto the event after the fact. So although ACS-Core §hooks.md lists `agentResponse` as decision-eligible (ALLOW / DENY / MODIFY), the adapter cannot actually block or rewrite the workflow's output through this path. The envelopes are for trace + audit; enforcement on outputs must be placed at `pre_invoke` / `post_invoke` of the function that produced them, or as a separate review function in the YAML the workflow calls before returning. -**A failed run has no end.** NAT 1.7.0 pushes `WORKFLOW_END` only after the workflow returns. A run that raises or is cancelled pushes none, so the Guardian receives that run's `steps/sessionStart` and `steps/userMessage` and no `steps/agentResponse` or `steps/sessionEnd`, and the session stays open on the Guardian. Every call the middleware governed during the run was still decided. A separate PR closes such a run with `steps/sessionEnd` (`reason: error` or `cancelled`). +**Each NAT run is its own ACS session.** `sessionStart` fires once per session, before any other `steps/*` hook for its `session_id`, and `sessionEnd` seals it (hooks.md), so a run's steps carry a `session_id` of their own: a UUIDv5 of the configured `session_id` and NAT's `workflow_run_id`. The run's session opens at its `WORKFLOW_START`, or at its first call when NAT ran the middleware without its exporter, with its own `handshake/hello` and then its `steps/sessionStart`, and closes with its `steps/sessionEnd`, which waits for the run's Guardian exchanges still in flight, a cancelled call's included; its stored records are removed then, and a later step of the run is refused and sends nothing. The adapter remembers the last 4096 runs to end, so a step that arrives after that many more runs have ended is the one case it would open again; a late step comes only from a cancelled call's worker, within moments of its run's end. A call outside any NAT run uses the configured `session_id`, whose handshake runs when NAT builds the middleware: the first such step sends that session's `sessionStart` before it, NAT's shutdown of the middleware waits for a session still opening and then sends its `sessionEnd` and removes its records, and a step after that end is refused and sends nothing. A function a workflow calls, a sub-agent included, belongs to the run that called it. + +**The run's `sessionStart` decides the run.** The Guardian's `deny` of a run's `steps/sessionStart`, or a refusal of that request, refuses the run's session (hooks.md sessionStart: ALLOW / DENY): each later call of the run is blocked and its output withheld, and nothing more of the run is sent, `sessionEnd` included (§4.1). A `sessionStart` that gets no decision is counted once, and each later step of the run follows the fail posture: under `default_deny` it is blocked, and otherwise it goes to the Guardian. The run's `sessionEnd`, which is audit-only, goes out under either posture and reports that count (§6.4). A call waits until the run's `sessionStart` has been answered. + +**A failed run still ends.** NAT 1.7.0 pushes `WORKFLOW_END` only after the workflow returns, so a run that raises or is cancelled sends no `steps/agentResponse`. NAT stops the run's `acs_lifecycle` exporter and completes the run's step stream either way, and for a run with no `WORKFLOW_END` the adapter then sends `steps/sessionEnd` with `reason: cancelled` when the run's task was cancelled and `reason: error` otherwise, carrying the run's decision failures. A middleware built without NAT's builder learns of the end from the stream it subscribed to on the run's first call. **Deliberately not mapped — the skill lifecycle set (`steps/skillRegister`, `steps/skillLoad`, `steps/skillUnload`).** NAT composes workflows from functions declared in the workflow YAML; the middleware surface exposes no runtime skill registration, activation, or unload events. Function *invocations* are governed at `pre_invoke`/`post_invoke` (`steps/toolCallRequest`/`toolCallResult`); lifecycle wiring becomes possible when NAT exposes lifecycle events for loadable components. @@ -36,7 +41,7 @@ The adapter's `_on_intermediate_step` callback emits `steps/sessionStart` + `ste |---|---| | `allow` | `pre_invoke` returns `None` (proceed unchanged) | | `deny` | `pre_invoke` raises `ACSGuardianDenied` (NAT 1.7.0) or sets `context.action = InvocationAction.SKIP` (NAT dev). NAT's runtime documents both: *"Raises: Any exception to abort execution"* and the action-based equivalent. | -| `modify` | If `modifications.parameter_overrides` is present and is a dict, the adapter updates `context.modified_kwargs` in place and returns the context. NAT's runtime invokes the function with the modified kwargs. If `modified_content` is present on a post-tool result, the adapter sets `context.output`. | +| `modify` | If `modifications.parameter_overrides` is present and is a dict, the adapter updates `context.modified_kwargs` in place and returns the context. NAT's runtime invokes the function with the modified kwargs. If `modified_content` is present on a post-tool result, the adapter sets `context.output`. Redactions, a call's MODIFY without a `parameter_overrides` object, and structured edits on a result cannot be applied: the call is blocked or the output withheld, recorded as `modify_unsupported` with `reason_codes: ["modify_unsupported"]` (§6.5). | | `ask` | Substituted to block at the middleware boundary in v1 (NAT has no native pause primitive at the function-middleware layer). Deployments wanting ASK should compose with NAT's HITL middleware (`nat.middleware.hitl`) and have the Guardian resolve before responding. | | `defer` | Substituted to block in v1 (same reason). | @@ -61,7 +66,7 @@ middleware: default_deny: true target_function_or_group: my_tools # optional; otherwise applied via group/workflow membership target_location: input # NAT-standard field - session_id: null # adapter generates one per process + session_id: null # adapter generates one per process; each run's session derives from it function_groups: my_tools: @@ -82,12 +87,12 @@ NAT can invoke several wrapped functions at once in one process. The builder ope ## Conformance posture -The NAT adapter covers ACS-Core's mandatory floor. This is the floor the tests check, not a full ACS-Core conformance claim (see `adapters/README.md`). Three ACS-Core requirements from later specification changes follow in separate PRs: the `key_id` taken from configuration (§10, #272); decision failures recorded with their failure class and counted at `sessionEnd` (§6.4, #273); and a signature on every request, where today an adapter with no secret configured sends unsigned requests (§10). The floor it covers: +The NAT adapter covers ACS-Core's mandatory floor. This is the floor the tests check, not a full ACS-Core conformance claim (see `adapters/README.md`). The floor it covers: - Hook taxonomy: every wrapped function call surfaces as `toolCallRequest` + `toolCallResult`. - Dispositions: ALLOW / DENY / MODIFY supported normatively; ASK / DEFER substituted to DENY with audit (HITL composition is the recommended path). -- SessionContext: `session_id` sent on every request. +- SessionContext: `session_id` sent on every request: the run's own, or the configured one for a call outside any run. - Replay protection: `request_id` UUID + timestamp. -- Handshake: the first `pre_invoke` of a session performs `handshake/hello`, and its answer decides the session (§4.1). The ServerHello must answer the ClientHello: a `negotiated_version` sharing a major version the adapter offered, a `methods_evaluated` list, the one transport the adapter offers (its Guardian URL's scheme), a positive `timeout_config.default_ms`, an `on_decision_failure` of `proceed` or `deny`, and only profiles the adapter claimed; otherwise the session has no negotiated Guardian (`invalid_server_hello`). Per-method timeouts bound their own methods. The answer: a refusal blocks every call, and no answer leaves the session with no negotiated Guardian for its lifetime, so `ACS_STARTUP_POSTURE` (unset follows `default_deny`) settles each call with no Guardian traffic. -- Decision honoring: NAT's middleware contract guarantees the function does not execute if `pre_invoke` raises or sets SKIP. The fail posture is `workflow.yml`'s `default_deny` OR the ServerHello's `on_decision_failure` (most-restrictive-wins); Guardian refusals (signature invalid, replay, malformed/oversized envelope) fail closed regardless of posture, on both the input gate (block) and the output gate (redact) — spec issue #32. Methods absent from the ServerHello's `methods_evaluated` resolve as ALLOW-by-default without being sent (handshake.json) and are recorded as `method_not_evaluated`. -- Baseline integrity: HMAC-SHA256 per §10 over the RFC 8785 (JCS) canonical envelope with an HKDF-derived per-session key (`ACS_HMAC_SECRET_FILE` / `ACS_HMAC_SECRET`); `rfc8785` is a hard runtime dependency. Responses are signature-verified and bound to their request under any HTTP status; a mis-bound, badly-signed or unverifiable response fails closed regardless of posture. Unsigned mode (no secret) is announced with a loud `unsigned_mode` audit event. +- Handshake: each session performs `handshake/hello` once, a run's at its start and the configured session's when NAT builds the middleware, and its answer decides the session (§4.1). The ServerHello must answer the ClientHello: a `negotiated_version` sharing a major version the adapter offered, a `methods_evaluated` list, the one transport the adapter offers (its Guardian URL's scheme), a positive `timeout_config.default_ms`, an `on_decision_failure` of `proceed` or `deny`, and only profiles the adapter claimed; otherwise the session has no negotiated Guardian (`invalid_server_hello`). Per-method timeouts bound their own methods. The answer: a refusal blocks every call, and no answer leaves the session with no negotiated Guardian for its lifetime, so `ACS_STARTUP_POSTURE` (unset follows `default_deny`) settles each call with no Guardian traffic. +- Decision honoring: NAT's middleware contract guarantees the function does not execute if `pre_invoke` raises or sets SKIP. The fail posture is `workflow.yml`'s `default_deny` OR the ServerHello's `on_decision_failure` (most-restrictive-wins); Guardian refusals (signature invalid, replay, malformed/oversized envelope) fail closed regardless of posture, on both the input gate (block) and the output gate (redact) — spec issue #32. Methods absent from the ServerHello's `methods_evaluated` resolve as ALLOW-by-default without being sent (handshake.json) and are recorded as `method_not_evaluated`. Every decision failure is audited under either posture with its `failure_class` (`timeout`, `unreachable`, `malformed_response`, `guardian_error`, `observed_agent_error`, `signature_invalid`, `handshake_failure`, `other`) and `posture`, and each run's `sessionEnd` reports that run's count per class in `summary.decision_failures` (§6.4, #273). A handshake that gets no answer is audited as `handshake_failure`; that session sends the Guardian nothing, `sessionEnd` included (§4.1). +- Baseline integrity: HMAC-SHA256 per §10 over the RFC 8785 (JCS) canonical envelope with an HKDF-derived per-session key (`ACS_HMAC_SECRET_FILE` / `ACS_HMAC_SECRET`); `rfc8785` is a hard runtime dependency. The signature names the configured label `ACS_HMAC_KEY_ID` as `key_id`, and a response under any other label fails verification with no other secret tried; an unsigned `SIGNATURE_INVALID`, a Guardian's answer to a label it does not hold, is recorded as `key_id_rejected` and fails closed. Responses are signature-verified and bound to their request under any HTTP status; a mis-bound, badly-signed or unverifiable response fails closed regardless of posture. A middleware with no secret, a secret under 32 bytes, or no label refuses its session, so every call is denied, and sends nothing (`secret_missing`, `secret_too_short`, `key_id_missing`), whatever the posture: ACS-Core signs every request. diff --git a/adapters/docs/nat/workflow.yml.example b/adapters/docs/nat/workflow.yml.example index f7f7a6bf..8b3ff161 100644 --- a/adapters/docs/nat/workflow.yml.example +++ b/adapters/docs/nat/workflow.yml.example @@ -5,9 +5,11 @@ # # Environment (set on the NAT process — the adapter is in-process): # ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key -# REQUIRED for ACS-Core baseline integrity (§10). Without a secret -# both ends run unsigned and any local process that binds the -# Guardian port becomes the Guardian. Generate: +# ACS_HMAC_KEY_ID=2026-11 +# REQUIRED with the secret: the label that names it (§10); the Guardian holds the same one. +# REQUIRED for ACS-Core baseline integrity (§10), at least 32 bytes. +# Without a secret and its label the middleware refuses its session +# and denies every call. Generate: # mkdir -p ~/.acs && openssl rand -hex 32 > ~/.acs/hmac.key && chmod 600 ~/.acs/hmac.key # and give the SAME file to the Guardian. # ACS_AUDIT_FILE=/path/to/.acs/audit.log @@ -23,7 +25,7 @@ middleware: guardian_url: http://127.0.0.1:8787/acs default_deny: true timeout_s: 5.0 - # session_id is auto-generated per process if omitted + # session_id is auto-generated per process if omitted; each NAT run's session derives from it # target_function_or_group: my_tools function_groups: diff --git a/adapters/scripts/check_wheel.py b/adapters/scripts/check_wheel.py index eaed1745..9270c2ec 100644 --- a/adapters/scripts/check_wheel.py +++ b/adapters/scripts/check_wheel.py @@ -98,6 +98,7 @@ def base_checks(env_dir: Path, wheel_version: str, cwd: Path) -> None: guardian_env = {k: v for k, v in os.environ.items() if k != "ACS_SPEC_DIR"} guardian_env["ACS_HMAC_SECRET"] = "ab" * 32 + guardian_env["ACS_HMAC_KEY_ID"] = "check-wheel" guardian = subprocess.Popen([str(bindir / "acs-example-guardian"), "--port", "0"], cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=guardian_env) diff --git a/adapters/src/acs/client/__init__.py b/adapters/src/acs/client/__init__.py index 5312dcf3..5c84caeb 100644 --- a/adapters/src/acs/client/__init__.py +++ b/adapters/src/acs/client/__init__.py @@ -4,6 +4,11 @@ # Exports load on first use, so _config, _outcome and _audit import without the signing dependency. _EXPORTS = { + "FAILURE_CLASSES": "_config", + "failure_class": "_config", + "decision_failure_counts": "_store", + "discard_session": "_handshake", + "count_decision_failure": "_store", "Config": "_config", "Decided": "_outcome", "FailPosture": "_config", @@ -16,14 +21,18 @@ "Resolution": "_config", "SecretFilePermissionsError": "_secrets", "SecretFileUnreadableError": "_secrets", + "SigningMaterialMissing": "_secrets", "SessionRefused": "_outcome", "Undecided": "_outcome", "audit_event": "_audit", + "audit_modify_unsupported": "_audit", "classify_response": "_exchange", "close_turn": "_store", "current_turn": "_store", "exchange": "_exchange", "load_hmac_secret": "_secrets", + "load_key_id": "_secrets", + "load_signing_material": "_secrets", "load_session_state": "_store", "mark_once": "_store", "open_session": "_handshake", @@ -34,6 +43,7 @@ "record_step": "_store", "round_trip": "_exchange", "save_session_state": "_store", + "session_start_outcome": "_handshake", "sign_envelope": "_signing", "update_session_state": "_store", "validate_guardian_url": "_urls", diff --git a/adapters/src/acs/client/_audit.py b/adapters/src/acs/client/_audit.py index 97944d0e..a36323bd 100644 --- a/adapters/src/acs/client/_audit.py +++ b/adapters/src/acs/client/_audit.py @@ -39,3 +39,8 @@ def audit_event(event_type: str, **fields: Any) -> None: sys.stderr.write( f"ACS_AUDIT_SINK_ERROR cannot append to {audit_file!r}: {e}\n") sys.stderr.flush() + + +def audit_modify_unsupported(detail: str, **fields: Any) -> None: + """Record a MODIFY the client cannot apply and treats as DENY, under reason code modify_unsupported (§6.5).""" + audit_event("modify_unsupported", reason_codes=["modify_unsupported"], detail=detail, **fields) diff --git a/adapters/src/acs/client/_config.py b/adapters/src/acs/client/_config.py index 77562e02..ce37edf0 100644 --- a/adapters/src/acs/client/_config.py +++ b/adapters/src/acs/client/_config.py @@ -10,7 +10,32 @@ DEFAULT_TIMEOUT_S = 5.0 -# The deny message's label for each audit type that blocks. +# §6.4 failure classes, the acs.failure.class values in trace/otel-mapping.json. +FAILURE_CLASSES = ("timeout", "unreachable", "malformed_response", "guardian_error", "observed_agent_error", + "signature_invalid", "handshake_failure", "other") + +_OBSERVED_AGENT_CAUSES = {"adapter_exception", "adapter_build_failed", "adapter_translate_exception", + "invalid_stdin_json", "guardian_url_rejected", "request_exceeds_max_payload"} +_SIGNATURE_CAUSES = {"response_signature_invalid", "error_signature_invalid", "response_unverifiable", + "server_hello_signature_invalid"} + + +def failure_class(cause: str) -> str: + if cause in ("decision_timeout", "handshake_timeout"): + return "timeout" + if cause == "transport_failure": + return "unreachable" + if cause in ("malformed_response", "response_binding_mismatch"): + return "malformed_response" + if cause in _SIGNATURE_CAUSES: + return "signature_invalid" + if cause in _OBSERVED_AGENT_CAUSES: + return "observed_agent_error" + if cause.endswith("_response") or cause.startswith("http_") or cause == "key_id_rejected": + return "guardian_error" + return "other" + + _DENY_LABELS = { "guardian_refusal_fail_closed": "refused", "session_refused_fail_closed": "session refused", @@ -26,6 +51,8 @@ class Resolution: cause: str fields: Mapping[str, Any] message: str = "" + # This step's own decision failure (§6.4), counted once; a session gate restating one is not. + decision_failure: bool = False @property def label(self) -> str: @@ -65,11 +92,14 @@ def negotiated(self, server_hello: Mapping[str, Any] | None) -> FailPosture: def resolve(self, outcome: Undecided | Refused) -> Resolution: """The one place a step without a usable decision is settled: a refusal blocks, anything else follows the posture.""" + proceed = not isinstance(outcome, Refused) and not self.deny_on_failure + fields = {**outcome.fields, "failure_class": failure_class(outcome.cause), + "posture": "fail_open" if proceed else "fail_closed"} if isinstance(outcome, Refused): - return Resolution(False, "guardian_refusal_fail_closed", outcome.cause, outcome.fields, outcome.message) - if self.deny_on_failure: - return Resolution(False, "decision_failure_fail_closed", outcome.cause, outcome.fields, outcome.message) - return Resolution(True, "fail_open_bypass", outcome.cause, outcome.fields, outcome.message) + audit_type = "guardian_refusal_fail_closed" + else: + audit_type = "fail_open_bypass" if proceed else "decision_failure_fail_closed" + return Resolution(proceed, audit_type, outcome.cause, fields, outcome.message, decision_failure=True) @dataclass(frozen=True) @@ -102,14 +132,15 @@ def startup_proceeds(self) -> bool: return not self.posture.deny_on_failure return self.startup_posture == "proceed" - def gate(self, handshake: Handshake) -> Resolution | None: + def gate(self, handshake: Handshake, method: str | None = None) -> Resolution | None: """None when this step goes to the Guardian; otherwise how it ends without one (§4.1), a session whose sessionStart got no decision included.""" if isinstance(handshake, Negotiated): failure = handshake.start_failure - if failure is None or not self.posture.negotiated(handshake.server_hello).deny_on_failure: + # sessionEnd is audit-only and reports the session's decision failures (§6.4), the start's among them. + if failure is None or method == "steps/sessionEnd" or not self.posture.negotiated(handshake.server_hello).deny_on_failure: return None return Resolution(False, "decision_failure_fail_closed", failure["cause"], - {**failure["fields"], "method": "steps/sessionStart"}) + {**failure["fields"], "method": "steps/sessionStart", "posture": "fail_closed"}) if isinstance(handshake, SessionRefused): return Resolution(False, "session_refused_fail_closed", handshake.cause, handshake.fields) if self.startup_proceeds: diff --git a/adapters/src/acs/client/_exchange.py b/adapters/src/acs/client/_exchange.py index 2e0da788..fde39830 100644 --- a/adapters/src/acs/client/_exchange.py +++ b/adapters/src/acs/client/_exchange.py @@ -238,6 +238,11 @@ def exchange(request: dict[str, Any], *, guardian_url: str, timeout_s: float, return replace(outcome, fields={**outcome.fields, "http_status": http_status}) +def key_id_rejected(error: Any) -> bool: + """An unsigned SIGNATURE_INVALID error: how a Guardian answers a key_id it holds no secret for (§10).""" + return isinstance(error, dict) and error.get("code") == -32004 and "signature" not in error + + def classify_response(request: dict[str, Any], response: Any, *, session_id: str | None) -> Outcome: """Classify a decoded Guardian answer; mis-bound or unverifiable answers and refusal codes fail closed.""" params = request.get("params") or {} @@ -255,6 +260,10 @@ def classify_response(request: dict[str, Any], response: Any, *, session_id: str code = err.get("code") # Errors are signed too; an unverifiable error under fail-open would be an allow. if not verify_signature(response, session_id=session_id): + if key_id_rejected(err): + return Refused("key_id_rejected", {"claimed_error_code": code}, + "Guardian answered an unsigned SIGNATURE_INVALID, how it reports a key_id it does not " + "hold (§10); check ACS_HMAC_KEY_ID") return Refused("error_signature_invalid", {"claimed_error_code": code, "claimed_cause": guardian_error_cause(code)}, "error response signature invalid") diff --git a/adapters/src/acs/client/_handshake.py b/adapters/src/acs/client/_handshake.py index 2609e7a9..b2af4c6e 100644 --- a/adapters/src/acs/client/_handshake.py +++ b/adapters/src/acs/client/_handshake.py @@ -16,9 +16,9 @@ from acs.client._audit import audit_event from acs.client._config import Config, Resolution -from acs.client._exchange import GuardianHTTPError, is_jsonrpc_response, post +from acs.client._exchange import GuardianHTTPError, is_jsonrpc_response, key_id_rejected, post from acs.client._outcome import Decided, Handshake, Negotiated, NoAnswer, SessionRefused, Undecided -from acs.client._secrets import load_hmac_secret +from acs.client._secrets import SigningMaterialMissing, load_signing_material from acs.client._signing import sign_envelope, verify_signature from acs.client._store import _locked from acs.client._urls import validate_guardian_url @@ -57,6 +57,13 @@ def _handshake_cache_path(session_id: str, guardian_url: str, directory: Path | return (directory or _HANDSHAKE_CACHE_DIR) / f"{key}.json" +def discard_session(session_id: str, guardian_url: str) -> None: + path = _handshake_cache_path(session_id, guardian_url) + for record in (path, Path(f"{path}.started"), Path(f"{path}.lock")): + with contextlib.suppress(OSError): + record.unlink() + + def open_session(*, config: Config, session_id: str | None, agent_id: str, platform: str, methods_implemented: list[str], wrapped_protocols: list[str] | None = None, timeout: float | None = None, profiles_supported: list[str] | None = None, @@ -81,8 +88,11 @@ def open_session(*, config: Config, session_id: str | None, agent_id: str, platf """ if not session_id: return NoAnswer("missing_session_id") + # §10: every request is signed, so a session with no usable secret and label is refused before any stored one is read. try: - ikm = load_hmac_secret() + ikm, _ = load_signing_material() + except SigningMaterialMissing as e: + return SessionRefused(e.cause, {"error": str(e)}) except Exception as e: # noqa: BLE001 return SessionRefused("secret_unavailable", {"error": str(e)}) path = _handshake_cache_path(session_id, config.guardian_url) @@ -132,10 +142,16 @@ def _start_session(decision: Negotiated, start: Callable[[Negotiated], Decided | except Exception as e: # noqa: BLE001 result = config.posture.negotiated(decision.server_hello).resolve( Undecided("adapter_exception", {"error": str(e)}, f"adapter error: {e}")) + return session_start_outcome(decision, result, session_id=session_id, guardian_url=config.guardian_url) + + +def session_start_outcome(decision: Negotiated, result: Decided | Resolution | None, *, session_id: str, + guardian_url: str) -> Handshake: + """The session's decision after its sessionStart answered with result, audited (hooks.md sessionStart: ALLOW / DENY).""" if result is None: # The Guardian does not evaluate sessionStart. return decision - common = {"method": "steps/sessionStart", "session_id": session_id, "guardian_url": config.guardian_url} + common = {"method": "steps/sessionStart", "session_id": session_id, "guardian_url": guardian_url} if isinstance(result, Resolution): audit_event(result.audit_type, **{**result.fields, "cause": result.cause, **common}) if result.audit_type == "guardian_refusal_fail_closed": @@ -158,8 +174,7 @@ def _decide(path: Path, ikm: bytes, *, config: Config, session_id: str, agent_id stored = _read_record(path, ikm, session_id, config.guardian_url) if stored is not None: return stored - # An unsigned session does not meet acs-core's signed-envelope floor (§10), so it claims no profile. - claimed = (profiles_supported if profiles_supported is not None else ["acs-core"]) if ikm else [] + claimed = profiles_supported if profiles_supported is not None else ["acs-core"] if not config.handshake_enabled: decision: Handshake = NoAnswer("handshake_disabled") else: @@ -167,7 +182,7 @@ def _decide(path: Path, ikm: bytes, *, config: Config, session_id: str, agent_id guardian_url=config.guardian_url, session_id=session_id, agent_id=agent_id, platform=platform, methods_implemented=methods_implemented, wrapped_protocols=wrapped_protocols, profiles_supported=claimed, - transports_supported=transports_supported, signed=bool(ikm), + transports_supported=transports_supported, timeout=_HANDSHAKE_TIMEOUT_S if timeout is None else timeout, ) decision, ours = _store_first(path, decision, ikm, session_id, config.guardian_url) @@ -196,7 +211,7 @@ def store_session_decision(directory: Path, session_id: str, guardian_url: str, def _hello(*, guardian_url: str, session_id: str, agent_id: str, platform: str, methods_implemented: list[str], wrapped_protocols: list[str] | None, profiles_supported: list[str], transports_supported: list[str] | None, - signed: bool, timeout: float) -> Handshake: + timeout: float) -> Handshake: # The client POSTs to the Guardian URL, so that URL's scheme is the one transport it offers. scheme = urllib.parse.urlsplit(guardian_url).scheme client_hello = { @@ -217,7 +232,7 @@ def _hello(*, guardian_url: str, session_id: str, agent_id: str, platform: str, "provenance_producer": "none", "wrapped_protocols": wrapped_protocols or [], "profiles_supported": profiles_supported, - "signature_algorithms_supported": ["HMAC-SHA256"] if signed else [], + "signature_algorithms_supported": ["HMAC-SHA256"], }, }, } @@ -258,6 +273,8 @@ def _classify_hello(client_hello: Mapping[str, Any], response: Any, session_id: verified = verify_signature(response, session_id=session_id) if error is not None: if not verified: + if key_id_rejected(error): + return SessionRefused("key_id_rejected", {"claimed_error_code": code}) return SessionRefused("error_signature_invalid", {"claimed_error_code": code, "claimed_cause": guardian_error_cause(code)}) fields = {"error_code": code, "error_message": error.get("message")} @@ -384,7 +401,7 @@ def _verified(path: Path, ikm: bytes, session_id: str, guardian_url: str, marker return SessionRefused("session_record_invalid", {"record": str(path)}) body = {k: v for k, v in record.items() if k != "mac"} try: - authentic = not ikm or hmac.compare_digest(str(record.get("mac")), _mac(body, ikm, session_id)) + authentic = hmac.compare_digest(str(record.get("mac")), _mac(body, ikm, session_id)) except Exception: # noqa: BLE001 authentic = False if not (authentic and body.get("session_id") == session_id and body.get("guardian_url") == guardian_url): @@ -405,7 +422,7 @@ def _write_startup(path: Path, outcome: Handshake | None, ikm: bytes, session_id decision = _encode(outcome, session_id, guardian_url) body.update({k: v for k, v in decision.items() if k not in body and k != "acs_session_record"}, state="done") - record = {**body, "mac": _mac(body, ikm, session_id) if ikm else None} + record = {**body, "mac": _mac(body, ikm, session_id)} fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".startup-") with os.fdopen(fd, "w") as f: json.dump(record, f) @@ -434,7 +451,7 @@ def _store_first(path: Path, decision: Handshake, ikm: bytes, session_id: str, temporary = None try: body = _encode(decision, session_id, guardian_url) - record = {**body, "mac": _mac(body, ikm, session_id) if ikm else None} + record = {**body, "mac": _mac(body, ikm, session_id)} path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) os.chmod(path.parent, 0o700) fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".record-") diff --git a/adapters/src/acs/client/_profiles.py b/adapters/src/acs/client/_profiles.py index 2e292d2e..bbde80c5 100644 --- a/adapters/src/acs/client/_profiles.py +++ b/adapters/src/acs/client/_profiles.py @@ -1,18 +1,12 @@ from __future__ import annotations from acs.client._audit import audit_event -from acs.client._secrets import load_hmac_secret from acs.client._store import mark_once def record_mcp_use(session_id: str | None, *, tool: str, workspace: str | None = None) -> None: """Record, once per session, that using MCP leaves its acs-core claim partial: protocols/MCP/* is not wrapped.""" - try: - # The ClientHello claims acs-core exactly when a signing secret is configured. - claimed = bool(load_hmac_secret()) - except Exception: # noqa: BLE001 - claimed = False - if claimed and session_id and mark_once(session_id, "profile_claim_degraded", workspace=workspace): + if session_id and mark_once(session_id, "profile_claim_degraded", workspace=workspace): audit_event("profile_claim_degraded", profile="acs-core", session_id=session_id, tool=tool, detail="the session uses MCP; the adapter governs MCP tools/call through the " "tool hooks and does not wrap protocols/MCP/*") diff --git a/adapters/src/acs/client/_secrets.py b/adapters/src/acs/client/_secrets.py index 915ee257..6b142ef1 100644 --- a/adapters/src/acs/client/_secrets.py +++ b/adapters/src/acs/client/_secrets.py @@ -12,10 +12,19 @@ class SecretFilePermissionsError(RuntimeError): class SecretFileUnreadableError(RuntimeError): - """ACS_HMAC_SECRET_FILE is configured but cannot be read. + """ACS_HMAC_SECRET_FILE is configured but cannot be read, or is empty.""" - Raised loudly: silently downgrading to unsigned mode would turn - off the only integrity control ACS-Core mandates.""" + +class SigningMaterialMissing(RuntimeError): + """No usable secret or label, so nothing may be sent: ACS-Core signs every request (§10).""" + + def __init__(self, cause: str, message: str) -> None: + super().__init__(message) + self.cause = cause + + +# §10: the secret MUST be at least 32 bytes from a cryptographically secure random source. +MIN_SECRET_BYTES = 32 def _check_secret_file_perms(path: str) -> None: @@ -40,36 +49,44 @@ def _check_secret_file_perms(path: str) -> None: def load_hmac_secret() -> bytes: - """Read the HMAC input keying material. - - Resolution order (first hit wins): - 1. `ACS_HMAC_SECRET_FILE` — path to a file containing the secret. - Preferred for production. Permissions MUST be 0600 (or 0400) and - the file MUST be owned by the running user. The file's content - (stripped of trailing whitespace) is the secret. Symlinks are - rejected. Insecure permissions raise SecretFilePermissionsError — - the adapter refuses to use a leaked secret rather than silently - proceed. - 2. `ACS_HMAC_SECRET` — env-var fallback. Quick for dev, less secure - (visible in `ps eauxw`, child-process envs, core dumps). - 3. Empty bytes — caller decides whether that means dev-mode or fail. - - Generate a secret: `openssl rand -hex 32 > /etc/acs/hmac.key && chmod 600 /etc/acs/hmac.key` - """ + """The input keying material from ACS_HMAC_SECRET_FILE (owner-only, no symlink) or ACS_HMAC_SECRET; b"" when neither is set.""" path = os.environ.get("ACS_HMAC_SECRET_FILE", "").strip() if path: try: _check_secret_file_perms(path) with open(path, "rb") as f: - return f.read().rstrip(b"\r\n\t ") + secret = f.read().rstrip(b"\r\n\t ") + if not secret: + raise OSError(f"{path} is empty") + return secret except SecretFilePermissionsError: raise except OSError as e: - # Configured-but-unreadable is an incident, not dev mode. + # Configured-but-unreadable is an incident, not a missing secret. audit_event("hmac_secret_file_unreadable", path=path, error=str(e)) raise SecretFileUnreadableError( f"ACS_HMAC_SECRET_FILE {path!r} is configured but cannot " - f"be read ({e}); refusing to continue unsigned" + f"be read ({e})" ) from e env_val = os.environ.get("ACS_HMAC_SECRET", "") return env_val.encode("utf-8") if env_val else b"" + + +def load_key_id() -> str: + """ACS_HMAC_KEY_ID, the deployment's label for the secret (§10); "" when unset.""" + return os.environ.get("ACS_HMAC_KEY_ID", "").strip() + + +def load_signing_material() -> tuple[bytes, str]: + """The secret and the label that names it; raises SigningMaterialMissing when either is missing or the secret is under 32 bytes (§10).""" + secret = load_hmac_secret() + if not secret: + raise SigningMaterialMissing("secret_missing", "no ACS_HMAC_SECRET_FILE or ACS_HMAC_SECRET is configured; " + "ACS-Core signs every request (§10)") + if len(secret) < MIN_SECRET_BYTES: + raise SigningMaterialMissing("secret_too_short", f"the secret is {len(secret)} bytes; " + f"§10 requires at least {MIN_SECRET_BYTES}") + label = load_key_id() + if not label: + raise SigningMaterialMissing("key_id_missing", "ACS_HMAC_KEY_ID is not set; it names the configured secret (§10)") + return secret, label diff --git a/adapters/src/acs/client/_signing.py b/adapters/src/acs/client/_signing.py index b7b2a8cc..0e72e66e 100644 --- a/adapters/src/acs/client/_signing.py +++ b/adapters/src/acs/client/_signing.py @@ -1,68 +1,43 @@ -"""Signing and verifying with the configured secret, and the one-time unsigned-mode warning.""" from __future__ import annotations -from acs.client._audit import audit_event -from acs.client._secrets import load_hmac_secret +from acs.client._secrets import load_hmac_secret, load_key_id, load_signing_material from acs.core import container_key, derive_session_key, sign_with_key, verify_with_key -_warned_unsigned_mode = False - - -def _warn_unsigned_once() -> None: - """One loud warning per process when operating without key material. - - Unsigned mode is an authentication failure, not just a - confidentiality one: any local process that binds the Guardian port - first becomes the Guardian and allows everything. - Fine for a first local experiment; never fine silently.""" - global _warned_unsigned_mode - if not _warned_unsigned_mode: - _warned_unsigned_mode = True - audit_event( - "unsigned_mode", - detail=( - "no ACS_HMAC_SECRET / ACS_HMAC_SECRET_FILE configured — " - "envelopes are UNSIGNED and any local process that binds " - "the Guardian address is trusted" - ), - ) - - def sign_envelope(envelope: dict, *, key: bytes | None = None, - session_id: str | None = None, key_id: str = "default") -> dict: - """Add a signature to the envelope. Returns the envelope unchanged - (after a loud one-time audit event) if no key material is available.""" + session_id: str | None = None, key_id: str | None = None) -> dict: + """Add a signature under key_id, the configured ACS_HMAC_KEY_ID unless given; raises SigningMaterialMissing without a usable secret and label.""" if key is None: - ikm = load_hmac_secret() - if not ikm: - _warn_unsigned_once() - return envelope + ikm, _ = load_signing_material() if not session_id: params = envelope.get("params") or envelope.get("result") or {} meta = params.get("metadata") or {} session_id = meta.get("session_id") or params.get("request_id") or "" key = derive_session_key(ikm, session_id) - return sign_with_key(envelope, key, key_id=key_id) + label = key_id if key_id is not None else load_key_id() + if not label: + raise ValueError("ACS_HMAC_KEY_ID is not set; it names the configured secret (§10)") + return sign_with_key(envelope, key, key_id=label) def verify_signature(envelope: dict, *, key: bytes | None = None, - session_id: str | None = None) -> bool: - """Verify a signed envelope (request, decision response, or error - response). Returns True if valid (or if no signature present and no - key material configured — local-dev mode).""" + session_id: str | None = None, key_id: str | None = None) -> bool: + """Whether the envelope carries a valid signature under key_id (default ACS_HMAC_KEY_ID); an unsigned envelope is not valid.""" container = envelope.get(container_key(envelope)) or {} if not isinstance(container, dict): return False sig = container.get("signature") if sig is None: - # No signature on the wire; valid only if local-dev (no key configured) - return not bool(load_hmac_secret()) + return False if not isinstance(sig, dict) or sig.get("algorithm") != "HMAC-SHA256": return False if not sig.get("value"): return False + # §10: an unknown key_id is rejected, with no fallback to another secret. + label = key_id if key_id is not None else load_key_id() + if not label or sig.get("key_id") != label: + return False if key is None: ikm = load_hmac_secret() diff --git a/adapters/src/acs/client/_store.py b/adapters/src/acs/client/_store.py index 912b17fa..07ce35cc 100644 --- a/adapters/src/acs/client/_store.py +++ b/adapters/src/acs/client/_store.py @@ -161,7 +161,30 @@ def change(state: dict) -> bool: return first is not False -# ----- The open turn ----- +def count_decision_failure(session_id: str | None, failure: str, *, workspace: str | None = None) -> None: + """Count one decision failure for sessionEnd's summary; never raises, so a count cannot stop a deny.""" + if not session_id: + return + + def change(state: dict) -> None: + counts = state.get("decision_failures") + counts = counts if isinstance(counts, dict) else {} + counts[failure] = int(counts.get(failure, 0)) + 1 + state["decision_failures"] = counts + + try: + update_session_state(session_id, change, workspace=workspace) + except Exception: # noqa: BLE001 + # The failure itself is already audited; sessionEnd's count is a lower bound (trace/events.md). + pass + + +def decision_failure_counts(session_id: str | None, *, workspace: str | None = None) -> dict[str, int]: + counts = load_session_state(session_id, workspace=workspace).get("decision_failures") if session_id else None + if not isinstance(counts, dict): + return {} + return {k: v for k, v in counts.items() if isinstance(k, str) and isinstance(v, int) and v >= 0} + def current_turn(session_id: str | None, *, workspace: str | None = None) -> str | None: if not session_id: diff --git a/adapters/src/acs/core/_signing.py b/adapters/src/acs/core/_signing.py index 53570996..c05c8711 100644 --- a/adapters/src/acs/core/_signing.py +++ b/adapters/src/acs/core/_signing.py @@ -38,8 +38,8 @@ def container_key(envelope: dict) -> str: return "result" -def sign_with_key(envelope: dict, key: bytes, *, key_id: str = "default") -> dict: - """Add an HMAC-SHA256 signature made with `key` over JCS(envelope without its signature), in place.""" +def sign_with_key(envelope: dict, key: bytes, *, key_id: str) -> dict: + """Sign in place with HMAC-SHA256 over JCS(envelope without its signature), labelled key_id (§10).""" ckey = container_key(envelope) container = envelope.get(ckey, {}) # Strip any existing signature before signing diff --git a/adapters/src/acs/guardian/example_guardian.py b/adapters/src/acs/guardian/example_guardian.py index f0f5fb7c..241211d3 100755 --- a/adapters/src/acs/guardian/example_guardian.py +++ b/adapters/src/acs/guardian/example_guardian.py @@ -1,54 +1,5 @@ #!/usr/bin/env python3 -""" -Minimal local Guardian for testing the reference adapters. - -Implements the ACS v0.1.0 spec features the adapters exercise: - -- Wire envelope per `request-envelope.json` / `response-envelope.json`. -- HMAC-SHA256 baseline signing (§10) with HKDF-derived per-session key. -- Rolling SHA-256 audit chain per §8.2 (`entry_hash = sha256(JCS(entry) || previous_hash)`). -- Replay rejection on duplicate `request_id` (§10.3, error -32005). -- Timestamp skew rejection (§10.3, error -32006). -- `handshake/hello` with ClientHello/ServerHello (§4). -- `system/ping` always returns allow, never enters the chain (§13). -- Subagent gating: blocks `Task` tool by default. -- Destructive-Bash regex + protected-system-path Write blocks. - -This is NOT a production Guardian. It is a teaching artifact and a test -substrate for the reference adapters. - -Wire format ground truth: - `specification/v0.1.0/request-envelope.json` - `specification/v0.1.0/response-envelope.json` - `specification/v0.1.0/handshake.json` - `specification/v0.1.0/hooks/*.json` - -Usage: - python3 example_guardian.py [--port 8787] - -Environment variables: - ACS_HMAC_SECRET / ACS_HMAC_SECRET_FILE - Shared secret for HMAC-SHA256 signing per §10. The - Guardian verifies every signed request and signs - every response. **The Guardian refuses to start - unless one of these is set, or `ACS_DEV_MODE=1`.** - File path is preferred for production (no exposure - in `ps aux`); use `chmod 600`. - Generate: `openssl rand -hex 32 > /etc/acs/hmac.key` - ACS_DEV_MODE "1" allows starting without a signing secret. Local - development only. ACS-Core baseline integrity (§10) - is not satisfied in dev mode. - ACS_SKEW_WINDOW_MS Timestamp skew tolerance (default 300_000 = 5 min). - ACS_ALLOW_SUBAGENT "1" allows the Task tool. Default "0" gates it. - ACS_GUARDIAN_STATE_DIR - Directory where per-session state (chain head + - seen request_ids) is persisted. Survives Guardian - restart so §10.3 replay protection isn't reset by - crashes / deploys / autoscaling. Defaults to - ~/.cache/acs-guardian-state/. Set to "" to disable - persistence (RAM-only; dev/test only — opens a - replay window across restarts). -""" +"""Example Guardian for testing the reference adapters, not a production Guardian; docs/guardian/README.md describes it.""" from __future__ import annotations import argparse @@ -65,7 +16,7 @@ from pathlib import Path from typing import Any -from acs.client import load_hmac_secret, sign_envelope, verify_signature # noqa: E402 +from acs.client import load_key_id, load_signing_material, sign_envelope, verify_signature # noqa: E402 from acs.examples.destructive_bash import DESTRUCTIVE_SCAN_MAX_LEN, matches_destructive_bash # noqa: E402 from acs.core import ( # noqa: E402 ACS_VERSION, @@ -132,18 +83,9 @@ ALLOW_SUBAGENT = os.environ.get("ACS_ALLOW_SUBAGENT", "0") == "1" -def _hmac_secret() -> bytes: - """Re-read on every call so operators can rotate the secret without - restarting the Guardian.""" - return load_hmac_secret() - - PROTECTED_PATH_PREFIXES: tuple[str, ...] = ("/etc/", "/usr/", "/bin/", "/sbin/", "/boot/") -# Methods this teaching policy acknowledges with unconditional allow. -# steps/subagentStart is deliberately NOT here — it is the confused-deputy -# gate and gets a real policy branch in evaluate_step; steps/subagentStop -# is audit-only per hooks.md. +# Acknowledged with an unconditional allow; subagentStart is absent because it is the confused-deputy gate. INFORMATIONAL_METHODS = { "steps/sessionStart", "steps/sessionEnd", "steps/userMessage", "steps/toolCallResult", "steps/agentResponse", @@ -382,10 +324,12 @@ def append_to_chain(session_id: str, method: str, request_id: str, # ----- Replay + skew checks (§10.3 normative) ----- class GuardianError(Exception): - def __init__(self, code: int, message: str) -> None: + def __init__(self, code: int, message: str, *, signed: bool = True) -> None: super().__init__(message) self.code = code self.message = message + # §10: the error for a key_id no configured secret matches is unsigned. + self.signed = signed def check_replay(session_id: str, request_id: str) -> None: @@ -419,14 +363,10 @@ def check_skew(timestamp: str) -> None: def check_signature(envelope: dict, session_id: str) -> None: - if not _hmac_secret(): - return # local-dev mode if not verify_signature(envelope, session_id=session_id): raise GuardianError(-32004, "SIGNATURE_INVALID") -# ----- Method evaluators ----- - def _major(version: str) -> str | None: """Major component of an X.Y.Z version string, or None if unparseable.""" parts = str(version).split(".") @@ -464,7 +404,7 @@ def evaluate_handshake(params: dict, request_id: str) -> dict: "negotiated_version": ACS_VERSION, "methods_evaluated": client_hello.get("methods_implemented") or [], "selected_transport": transport, - "signature_algorithms_supported": (["HMAC-SHA256"] if _hmac_secret() else []), + "signature_algorithms_supported": ["HMAC-SHA256"], "timeout_config": {"default_ms": 5000}, "skew_window_ms": SKEW_WINDOW_MS, "on_decision_failure": "proceed", # spec default per §6.4 @@ -564,16 +504,14 @@ def evaluate_step(method: str, params: dict, request_id: str, chain_hash: str) - "reason_codes": ["unknown_method"]} -# ----- Request dispatch ----- - -def _error_envelope(request_id, session_id: str, code: int, message: str) -> dict: +def _error_envelope(request_id, session_id: str, code: int, message: str, *, signed: bool = True) -> dict: """Build a SIGNED JSON-RPC error envelope — conformance.md:23 covers error responses too (an unsigned spoofable error under fail-open is an allow). Signed whenever session_id makes the per-session key derivable; earlier failures surface as HTTP refusals in do_POST.""" envelope = {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}} - if _hmac_secret() and session_id: + if signed and session_id: sign_envelope(envelope, session_id=session_id) return envelope @@ -587,8 +525,19 @@ def handle_request(request: dict) -> dict: acs_request_id = params.get("request_id", "") timestamp = params.get("timestamp", "") - # Schema-validate before policy code; system/ping and handshake/hello - # have their own payload shapes and are exempt. + # system/ping is the only unsigned method (§13), so liveness probes survive a key outage. + if method == "system/ping": + result = evaluate_ping(params, acs_request_id) + envelope = {"jsonrpc": "2.0", "id": request_id, "result": result} + return envelope + + # §10: a request under a key_id this Guardian holds no secret for gets an unsigned + # SIGNATURE_INVALID before any other check, since no response to it can be signed. + signature = params.get("signature") + if isinstance(signature, dict) and signature.get("key_id") != load_key_id(): + return _error_envelope(request_id, session_id, -32004, "SIGNATURE_INVALID: unknown key_id", signed=False) + + # Validated before policy; ping and handshake have their own payload shapes. if (_SPEC_VALIDATION_AVAILABLE and method not in ("system/ping", "handshake/hello")): errors = list(_REQUEST_ENVELOPE_VALIDATOR.iter_errors(request)) @@ -600,13 +549,6 @@ def handle_request(request: dict) -> dict: return _error_envelope(request_id, session_id, -32600, f"Invalid Request: envelope failed schema: {paths}") - # system/ping is the ONLY signature-exempt method (§13), so liveness - # probing survives key-resolution outages. - if method == "system/ping": - result = evaluate_ping(params, acs_request_id) - envelope = {"jsonrpc": "2.0", "id": request_id, "result": result} - return envelope - if method == "handshake/hello": # NOT signature-exempt: §10 / conformance.md:23 cover every # request, and the per-session key (pre-shared secret + @@ -616,10 +558,9 @@ def handle_request(request: dict) -> dict: check_signature(request, session_id) result = evaluate_handshake(params, acs_request_id) except GuardianError as e: - return _error_envelope(request_id, session_id, e.code, e.message) + return _error_envelope(request_id, session_id, e.code, e.message, signed=e.signed) envelope = {"jsonrpc": "2.0", "id": request_id, "result": result} - if _hmac_secret(): - sign_envelope(envelope, session_id=session_id) + sign_envelope(envelope, session_id=session_id) return envelope # Standard hook traffic — full pipeline @@ -628,7 +569,7 @@ def handle_request(request: dict) -> dict: check_skew(timestamp) check_replay(session_id, acs_request_id) except GuardianError as e: - return _error_envelope(request_id, session_id, e.code, e.message) + return _error_envelope(request_id, session_id, e.code, e.message, signed=e.signed) # Compute chain entry BEFORE evaluating, then include head in result. payload_canonical = jcs_canonicalize(params).decode("utf-8") @@ -640,8 +581,7 @@ def handle_request(request: dict) -> dict: return _error_envelope(request_id, session_id, e.code, e.message) envelope = {"jsonrpc": "2.0", "id": request_id, "result": result} - if _hmac_secret(): - sign_envelope(envelope, session_id=session_id) + sign_envelope(envelope, session_id=session_id) return envelope @@ -723,27 +663,20 @@ def main() -> int: parser.add_argument("--host", default="127.0.0.1") args = parser.parse_args() - # §10 baseline integrity: refuse to start without a signing secret - # unless the operator explicitly opted into dev mode. - if not load_hmac_secret() and os.environ.get("ACS_DEV_MODE", "0") != "1": + # §10: every request and response is signed, so the Guardian starts only with a usable secret and label. + try: + load_signing_material() + except Exception as e: # noqa: BLE001 sys.stderr.write( - "[guardian] REFUSING TO START: no signing secret configured.\n" - " Configure one of:\n" + f"[guardian] REFUSING TO START: {e}\n" + " Configure a secret of at least 32 bytes and the label that names it:\n" " ACS_HMAC_SECRET_FILE=/path/to/key (preferred; chmod 600)\n" - " ACS_HMAC_SECRET= (env-var fallback)\n" + " ACS_HMAC_KEY_ID=