From db5f0f89a66f5dd8d5ad553847f95ff0a487e271 Mon Sep 17 00:00:00 2001 From: Bar Kaduri Date: Sun, 1 Nov 2026 12:57:14 +0200 Subject: [PATCH 01/12] Name the HMAC secret with a configured key_id ACS_HMAC_KEY_ID labels the secret (section 10). Requests are signed under it, and a response under another label fails verification. Signed-off-by: Bar Kaduri --- adapters/SECURITY.md | 2 +- adapters/docs/claude_code/README.md | 9 +- adapters/docs/claude_code/mapping.md | 2 +- .../docs/claude_code/settings.json.example | 15 +- adapters/docs/cursor/README.md | 10 +- adapters/docs/cursor/hooks.json.example | 22 +-- adapters/docs/cursor/live_verification.md | 12 +- adapters/docs/cursor/mapping.md | 2 +- adapters/docs/nat/README.md | 5 +- adapters/docs/nat/mapping.md | 2 +- adapters/docs/nat/workflow.yml.example | 2 + adapters/scripts/check_wheel.py | 1 + adapters/src/acs/client/__init__.py | 1 + adapters/src/acs/client/_exchange.py | 9 + adapters/src/acs/client/_handshake.py | 12 +- adapters/src/acs/client/_secrets.py | 5 + adapters/src/acs/client/_signing.py | 22 ++- adapters/src/acs/core/_signing.py | 4 +- adapters/src/acs/guardian/example_guardian.py | 32 ++-- .../src/acs/integrations/claude_code/wire.py | 9 + adapters/src/acs/integrations/cursor/wire.py | 9 + adapters/src/acs/testing/harness.py | 14 +- adapters/tests/client/test_exchange.py | 15 +- adapters/tests/client/test_round_trip.py | 4 +- adapters/tests/client/test_session.py | 6 +- adapters/tests/client/test_store.py | 4 +- .../tests/common/test_capture_guardian.py | 4 +- adapters/tests/common/test_edge_cases.py | 12 +- adapters/tests/common/test_key_id.py | 161 ++++++++++++++++++ .../tests/common/test_methods_evaluated.py | 2 +- adapters/tests/common/test_security.py | 11 +- .../tests/common/test_serverhello_contract.py | 3 +- adapters/tests/common/test_session_gate.py | 2 +- .../conformance/test_acs_core_conformance.py | 19 ++- .../integrations/claude_code/test_adapter.py | 2 +- .../integrations/claude_code/test_emission.py | 12 +- .../claude_code/test_example_config.py | 1 + .../integrations/cursor/test_emission.py | 4 +- .../tests/integrations/cursor/test_wire.py | 2 +- adapters/tests/integrations/nat/e2e_check.py | 1 + .../tests/integrations/nat/test_adapter.py | 6 + .../nat/test_dispositions_live.py | 2 + .../tests/integrations/nat/test_emission.py | 1 + .../tests/integrations/nat/test_event_loop.py | 2 +- .../integrations/nat/test_runner_lifecycle.py | 2 +- .../integrations/nat/test_session_gate.py | 2 +- .../nat/test_untrusted_answers.py | 4 +- 47 files changed, 370 insertions(+), 115 deletions(-) create mode 100644 adapters/tests/common/test_key_id.py diff --git a/adapters/SECURITY.md b/adapters/SECURITY.md index b45ecdd8..8d2be002 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. | diff --git a/adapters/docs/claude_code/README.md b/adapters/docs/claude_code/README.md index 191f39f2..25c90bed 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 ``` @@ -265,6 +267,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. | diff --git a/adapters/docs/claude_code/mapping.md b/adapters/docs/claude_code/mapping.md index 7de4c2ab..25734bc1 100644 --- a/adapters/docs/claude_code/mapping.md +++ b/adapters/docs/claude_code/mapping.md @@ -49,6 +49,6 @@ The Claude Code adapter covers ACS-Core's mandatory floor as listed below. This - 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. +- 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. 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). - 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..0a4c9125 100644 --- a/adapters/docs/claude_code/settings.json.example +++ b/adapters/docs/claude_code/settings.json.example @@ -14,6 +14,7 @@ "/path/to/ placeholders with real absolute paths.", "", "ACS_HMAC_SECRET_FILE: REQUIRED for ACS-Core baseline integrity (\u00a710).", + "ACS_HMAC_KEY_ID: REQUIRED with the secret. The label that names it; the Guardian holds the same one.", "Without a secret both ends run unsigned and any local process that", "binds the Guardian port first becomes the Guardian. Generate one:", " mkdir -p ~/.acs && openssl rand -hex 32 > ~/.acs/hmac.key && chmod 600 ~/.acs/hmac.key", @@ -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..450a054b 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 ``` @@ -288,6 +291,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. | 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..b9cc8b8a 100644 --- a/adapters/docs/cursor/mapping.md +++ b/adapters/docs/cursor/mapping.md @@ -103,5 +103,5 @@ The Cursor adapter covers ACS-Core's mandatory floor in the same shape as the Cl - 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). - 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. +- 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). 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) diff --git a/adapters/docs/nat/README.md b/adapters/docs/nat/README.md index 958f4d4f..68c5212b 100644 --- a/adapters/docs/nat/README.md +++ b/adapters/docs/nat/README.md @@ -8,7 +8,7 @@ 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. @@ -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 ``` @@ -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). | diff --git a/adapters/docs/nat/mapping.md b/adapters/docs/nat/mapping.md index feebedbd..039034e0 100644 --- a/adapters/docs/nat/mapping.md +++ b/adapters/docs/nat/mapping.md @@ -90,4 +90,4 @@ The NAT adapter covers ACS-Core's mandatory floor. This is the floor the tests c - 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. +- 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. Unsigned mode (no secret) is announced with a loud `unsigned_mode` audit event. diff --git a/adapters/docs/nat/workflow.yml.example b/adapters/docs/nat/workflow.yml.example index f7f7a6bf..7b20b2dc 100644 --- a/adapters/docs/nat/workflow.yml.example +++ b/adapters/docs/nat/workflow.yml.example @@ -5,6 +5,8 @@ # # Environment (set on the NAT process — the adapter is in-process): # ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key +# 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). Without a secret # both ends run unsigned and any local process that binds the # Guardian port becomes the Guardian. Generate: 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..35b48a51 100644 --- a/adapters/src/acs/client/__init__.py +++ b/adapters/src/acs/client/__init__.py @@ -24,6 +24,7 @@ "current_turn": "_store", "exchange": "_exchange", "load_hmac_secret": "_secrets", + "load_key_id": "_secrets", "load_session_state": "_store", "mark_once": "_store", "open_session": "_handshake", 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..afbc0fe9 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 load_hmac_secret, load_key_id from acs.client._signing import sign_envelope, verify_signature from acs.client._store import _locked from acs.client._urls import validate_guardian_url @@ -160,8 +160,10 @@ def _decide(path: Path, ikm: bytes, *, config: Config, session_id: str, agent_id 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 [] - if not config.handshake_enabled: - decision: Handshake = NoAnswer("handshake_disabled") + if ikm and not load_key_id(): + decision: Handshake = SessionRefused("key_id_missing") + elif not config.handshake_enabled: + decision = NoAnswer("handshake_disabled") else: decision = _hello( guardian_url=config.guardian_url, session_id=session_id, agent_id=agent_id, @@ -258,6 +260,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")} diff --git a/adapters/src/acs/client/_secrets.py b/adapters/src/acs/client/_secrets.py index 915ee257..43c5de75 100644 --- a/adapters/src/acs/client/_secrets.py +++ b/adapters/src/acs/client/_secrets.py @@ -73,3 +73,8 @@ def load_hmac_secret() -> bytes: ) 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() diff --git a/adapters/src/acs/client/_signing.py b/adapters/src/acs/client/_signing.py index b7b2a8cc..5193cdb3 100644 --- a/adapters/src/acs/client/_signing.py +++ b/adapters/src/acs/client/_signing.py @@ -2,7 +2,7 @@ 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 from acs.core import container_key, derive_session_key, sign_with_key, verify_with_key @@ -30,9 +30,9 @@ def _warn_unsigned_once() -> None: 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. Returns the envelope + unchanged (after a loud one-time audit event) if no key material is available.""" if key is None: ikm = load_hmac_secret() if not ikm: @@ -43,13 +43,17 @@ def sign_envelope(envelope: dict, *, key: bytes | None = None, 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: + session_id: str | None = None, key_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 + response) made under key_id, the configured ACS_HMAC_KEY_ID unless + given. Returns True if valid (or if no signature present and no key material configured — local-dev mode).""" container = envelope.get(container_key(envelope)) or {} if not isinstance(container, dict): @@ -63,6 +67,10 @@ def verify_signature(envelope: dict, *, key: bytes | None = None, 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/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..515b4fd0 100755 --- a/adapters/src/acs/guardian/example_guardian.py +++ b/adapters/src/acs/guardian/example_guardian.py @@ -35,6 +35,10 @@ File path is preferred for production (no exposure in `ps aux`); use `chmod 600`. Generate: `openssl rand -hex 32 > /etc/acs/hmac.key` + ACS_HMAC_KEY_ID The label that names the secret (§10), the same one + the adapters send. A request under another label + gets an unsigned SIGNATURE_INVALID. Required with a + secret. ACS_DEV_MODE "1" allows starting without a signing secret. Local development only. ACS-Core baseline integrity (§10) is not satisfied in dev mode. @@ -65,7 +69,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 audit_event, load_hmac_secret, load_key_id, 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, @@ -382,10 +386,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: @@ -421,12 +427,14 @@ def check_skew(timestamp: str) -> None: def check_signature(envelope: dict, session_id: str) -> None: if not _hmac_secret(): return # local-dev mode + signature = (envelope.get("params") or {}).get("signature") + if isinstance(signature, dict) and signature.get("key_id") != load_key_id(): + # §10: reject an unknown key_id without trying another secret. + raise GuardianError(-32004, "SIGNATURE_INVALID: unknown key_id", signed=False) 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(".") @@ -564,16 +572,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 _hmac_secret() and session_id: sign_envelope(envelope, session_id=session_id) return envelope @@ -616,7 +622,7 @@ 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) @@ -628,7 +634,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") @@ -737,6 +743,12 @@ def main() -> int: " ACS_DEV_MODE=1\n" ) return 1 + if load_hmac_secret() and not load_key_id(): + sys.stderr.write( + "[guardian] REFUSING TO START: ACS_HMAC_KEY_ID is not set.\n" + " It names the signing secret (§10); set the same label the adapters use, e.g. ACS_HMAC_KEY_ID=2026-11\n" + ) + return 1 if not load_hmac_secret(): sys.stderr.write( diff --git a/adapters/src/acs/integrations/claude_code/wire.py b/adapters/src/acs/integrations/claude_code/wire.py index f159eeb1..3a12f151 100644 --- a/adapters/src/acs/integrations/claude_code/wire.py +++ b/adapters/src/acs/integrations/claude_code/wire.py @@ -136,6 +136,7 @@ def wired_elsewhere(target: Path, *, home: Path, cwd: Path, cursor_imports: bool def build_command(*, hook_command: str, guardian_url: str, secret_file: str | None, secret_env: str | None, + key_id: str | None = None, default_deny: bool, host_allowlist: str | None, audit_file: str | None = None) -> str: @@ -153,6 +154,8 @@ def build_command(*, hook_command: str, guardian_url: str, env_pairs.append(f"ACS_HMAC_SECRET_FILE={_expand(secret_file)}") elif secret_env: env_pairs.append(f"ACS_HMAC_SECRET={secret_env}") + if key_id: + env_pairs.append(f"ACS_HMAC_KEY_ID={shlex.quote(key_id)}") if default_deny: env_pairs.append("ACS_DEFAULT_DENY=1") if host_allowlist: @@ -368,6 +371,9 @@ def main(argv: list[str] | None = None) -> int: help="Guardian endpoint (http:// or https://). Required unless --unwire.") p.add_argument("--secret-file", help="Path to the HMAC secret file (preferred). The adapter reads it lazily.") + p.add_argument("--key-id", metavar="LABEL", + help="The label that names the secret (§10), the same one the Guardian has; " + "required with --secret-file or --secret-env-inline.") p.add_argument("--secret-env-inline", metavar="HEX", help="HMAC secret inlined into settings.json env (visible in `ps aux`; " "dev only). Use --secret-file for production.") @@ -416,6 +422,8 @@ def main(argv: list[str] | None = None) -> int: p.error("--guardian-url is required (unless --unwire)") if args.secret_file and args.secret_env_inline: p.error("provide --secret-file OR --secret-env-inline, not both") + if (args.secret_file or args.secret_env_inline) and not (args.key_id or "").strip(): + p.error("--key-id is required with a secret: it names the secret, and the Guardian must hold the same label") settings_path = _expand(args.settings) # A hook command that cannot run makes the host proceed with no audit record. @@ -458,6 +466,7 @@ def main(argv: list[str] | None = None) -> int: guardian_url=args.guardian_url, secret_file=args.secret_file, secret_env=args.secret_env_inline, + key_id=args.key_id, default_deny=hook_deny, host_allowlist=args.host_allowlist, audit_file=args.audit_file, diff --git a/adapters/src/acs/integrations/cursor/wire.py b/adapters/src/acs/integrations/cursor/wire.py index 109932a7..291e7a2a 100644 --- a/adapters/src/acs/integrations/cursor/wire.py +++ b/adapters/src/acs/integrations/cursor/wire.py @@ -138,6 +138,7 @@ def build_command(*, hook_command: str, event_name: str, guardian_url: str, secret_file: str | None, secret_env: str | None, + key_id: str | None = None, default_deny: bool, host_allowlist: str | None) -> str: """Compose the hook command string used inside hooks.json. @@ -150,6 +151,8 @@ def build_command(*, hook_command: str, event_name: str, env_pairs.append(f"ACS_HMAC_SECRET_FILE={_expand(secret_file)}") elif secret_env: env_pairs.append(f"ACS_HMAC_SECRET={secret_env}") + if key_id: + env_pairs.append(f"ACS_HMAC_KEY_ID={shlex.quote(key_id)}") if default_deny: env_pairs.append("ACS_DEFAULT_DENY=1") if host_allowlist: @@ -349,6 +352,9 @@ def main(argv: list[str] | None = None) -> int: help="Guardian endpoint (http:// or https://). Required unless --unwire.") p.add_argument("--secret-file", help="Path to the HMAC secret file (preferred). The adapter reads it lazily.") + p.add_argument("--key-id", metavar="LABEL", + help="The label that names the secret (§10), the same one the Guardian has; " + "required with --secret-file or --secret-env-inline.") p.add_argument("--secret-env-inline", metavar="HEX", help="HMAC secret inlined into hooks.json env (visible in `ps aux`; " "dev only). Use --secret-file for production.") @@ -390,6 +396,8 @@ def main(argv: list[str] | None = None) -> int: p.error("--guardian-url is required (unless --unwire)") if args.secret_file and args.secret_env_inline: p.error("provide --secret-file OR --secret-env-inline, not both") + if (args.secret_file or args.secret_env_inline) and not (args.key_id or "").strip(): + p.error("--key-id is required with a secret: it names the secret, and the Guardian must hold the same label") settings_path = _expand(args.settings) # A hook command that cannot run makes the host proceed with no audit record. @@ -437,6 +445,7 @@ def main(argv: list[str] | None = None) -> int: guardian_url=args.guardian_url, secret_file=args.secret_file, secret_env=args.secret_env_inline, + key_id=args.key_id, default_deny=hook_deny, host_allowlist=args.host_allowlist, ) diff --git a/adapters/src/acs/testing/harness.py b/adapters/src/acs/testing/harness.py index d9ef6aa8..d6cca0cf 100644 --- a/adapters/src/acs/testing/harness.py +++ b/adapters/src/acs/testing/harness.py @@ -43,6 +43,8 @@ from acs.core import schemas_dir _HERE = Path(__file__).resolve().parent +# The ACS_HMAC_KEY_ID the test setups configure, and the label the test Guardian signs with by default. +TEST_KEY_ID = "test-key" # ──────────────────────────────────────────────────────────────────── @@ -119,6 +121,7 @@ def make_envelope( agent_id: str = "test", platform: str = "test", sign_with_secret: str | bytes | None = None, + key_id: str = TEST_KEY_ID, ) -> dict: """Build a canonical request envelope ready for the wire. @@ -147,7 +150,7 @@ def make_envelope( if sign_with_secret is not None: secret = sign_with_secret.encode() if isinstance(sign_with_secret, str) else sign_with_secret key = core.derive_session_key(secret, sid) - client.sign_envelope(env, key=key, session_id=sid) + client.sign_envelope(env, key=key, session_id=sid, key_id=key_id) return env @@ -312,9 +315,12 @@ class ProgrammableGuardian: DEFAULT_TEST_HMAC_SECRET = "shared-test-harness-secret-not-for-production" def __init__(self, *, hmac_secret: str | None = None, - sign_responses: bool = True, port: int = 0, threaded: bool = False) -> None: + sign_responses: bool = True, port: int = 0, threaded: bool = False, + key_id: str = TEST_KEY_ID) -> None: import http.server self.hmac_secret = hmac_secret or self.DEFAULT_TEST_HMAC_SECRET + # The label of hmac_secret (§10): requests under another label get an unsigned SIGNATURE_INVALID. + self.key_id = key_id self.sign_responses = sign_responses self.received: list[dict] = [] self.sent: list[dict] = [] @@ -430,7 +436,7 @@ def do_POST(self_h): # noqa: N802 sid = req.get("params", {}).get("metadata", {}).get("session_id", "") key = core.derive_session_key( guardian.hmac_secret.encode(), sid) - if not client.verify_signature(req, key=key, session_id=sid): + if not client.verify_signature(req, key=key, session_id=sid, key_id=guardian.key_id): self_h._reply({ "jsonrpc": "2.0", "id": req.get("id"), "error": {"code": -32004, "message": "SIGNATURE_INVALID"}, @@ -447,7 +453,7 @@ def do_POST(self_h): # noqa: N802 sid = req["params"]["metadata"]["session_id"] key = core.derive_session_key( guardian.hmac_secret.encode(), sid) - client.sign_envelope(resp, key=key, session_id=sid) + client.sign_envelope(resp, key=key, session_id=sid, key_id=guardian.key_id) self_h._reply(resp) def _reply(self_h, resp: dict): diff --git a/adapters/tests/client/test_exchange.py b/adapters/tests/client/test_exchange.py index 65298834..8d2272f8 100644 --- a/adapters/tests/client/test_exchange.py +++ b/adapters/tests/client/test_exchange.py @@ -26,7 +26,7 @@ def _request(**payload: Any) -> dict: def _signed(response: dict) -> dict: - return sign_with_key(response, derive_session_key(SECRET.encode(), SESSION)) + return sign_with_key(response, derive_session_key(SECRET.encode(), SESSION), key_id="test-key") def _result(**overrides: Any) -> dict: @@ -151,7 +151,7 @@ def _http(status: int, body: Any = None): ] -@mock.patch.dict(os.environ, {"ACS_HMAC_SECRET": SECRET, "ACS_HMAC_SECRET_FILE": ""}) +@mock.patch.dict(os.environ, {"ACS_HMAC_SECRET": SECRET, "ACS_HMAC_KEY_ID": "test-key", "ACS_HMAC_SECRET_FILE": "", "ACS_HMAC_KEY_ID": "test-key"}) class ExchangeCauses(unittest.TestCase): def _exchange(self, urlopen: Any, *, request: dict | None = None, url: str = URL, timeout_s: float = 2.5): @@ -206,7 +206,7 @@ def test_a_secret_that_cannot_be_loaded_refuses_the_answer(self) -> None: secret_file = Path(d) / "hmac.key" secret_file.write_text(SECRET) secret_file.chmod(0o644) - with mock.patch.dict(os.environ, {"ACS_HMAC_SECRET_FILE": str(secret_file)}): + with mock.patch.dict(os.environ, {"ACS_HMAC_SECRET_FILE": str(secret_file), "ACS_HMAC_KEY_ID": "test-key"}): outcome, _ = self._exchange(_json(_signed(_result()))) self.assertIsInstance(outcome, Refused) self.assertEqual(outcome.cause, "response_unverifiable") @@ -256,7 +256,7 @@ def close(self) -> None: self.server.server_close() -@mock.patch.dict(os.environ, {"ACS_HMAC_SECRET": SECRET, "ACS_HMAC_SECRET_FILE": ""}) +@mock.patch.dict(os.environ, {"ACS_HMAC_SECRET": SECRET, "ACS_HMAC_KEY_ID": "test-key", "ACS_HMAC_SECRET_FILE": "", "ACS_HMAC_KEY_ID": "test-key"}) class OverallDeadline(unittest.TestCase): """§6.4: the negotiated timeout bounds the whole wait, including a body that arrives slowly.""" @@ -460,7 +460,7 @@ def _answer(self, tls: ssl.SSLSocket) -> None: return f"https://127.0.0.1:{server.server_address[1]}/acs" def _exchange(self, url: str, timeout_s: float): - env = {"ACS_HMAC_SECRET": SECRET, "ACS_HMAC_SECRET_FILE": "", "SSL_CERT_FILE": self.cert} + env = {"ACS_HMAC_SECRET": SECRET, "ACS_HMAC_KEY_ID": "test-key", "ACS_HMAC_SECRET_FILE": "", "ACS_HMAC_KEY_ID": "test-key", "SSL_CERT_FILE": self.cert} with mock.patch.dict(os.environ, env): return exchange(_request(), guardian_url=url, timeout_s=timeout_s, session_id=SESSION) @@ -489,7 +489,8 @@ def test_a_stalled_tls_handshake_ends_at_the_deadline(self) -> None: STATUSES = (200, 400, 413, 500, 503) MATRIX = [ ("unsigned non-refusal error", lambda: _error(-32603), Refused, "error_signature_invalid"), - ("unsigned refusal error", lambda: _error(-32004), Refused, "error_signature_invalid"), + ("unsigned refusal error", lambda: _error(-32005), Refused, "error_signature_invalid"), + ("unsigned SIGNATURE_INVALID, an unknown key_id", lambda: _error(-32004), Refused, "key_id_rejected"), ("error with a forged signature", lambda: _forged_error(-32603), Refused, "error_signature_invalid"), ("signed error for another request", lambda: _signed({**_error(-32603), "id": "rpc-2"}), Refused, "response_binding_mismatch"), @@ -513,7 +514,7 @@ def test_a_stalled_tls_handshake_ends_at_the_deadline(self) -> None: ] -@mock.patch.dict(os.environ, {"ACS_HMAC_SECRET": SECRET, "ACS_HMAC_SECRET_FILE": ""}) +@mock.patch.dict(os.environ, {"ACS_HMAC_SECRET": SECRET, "ACS_HMAC_KEY_ID": "test-key", "ACS_HMAC_SECRET_FILE": "", "ACS_HMAC_KEY_ID": "test-key"}) class HttpStatusMatrix(unittest.TestCase): def test_every_envelope_under_every_status(self) -> None: for status in STATUSES: diff --git a/adapters/tests/client/test_round_trip.py b/adapters/tests/client/test_round_trip.py index 1128c974..83db188e 100644 --- a/adapters/tests/client/test_round_trip.py +++ b/adapters/tests/client/test_round_trip.py @@ -28,7 +28,7 @@ def _request(**payload: Any) -> dict: payload=payload) -@mock.patch.dict(os.environ, {"ACS_HMAC_SECRET": SECRET, "ACS_HMAC_SECRET_FILE": ""}) +@mock.patch.dict(os.environ, {"ACS_HMAC_SECRET": SECRET, "ACS_HMAC_KEY_ID": "test-key", "ACS_HMAC_SECRET_FILE": "", "ACS_HMAC_KEY_ID": "test-key"}) class RoundTrip(unittest.TestCase): def _send(self, request: dict, answer, posture: FailPosture = PROCEED): sent: list[tuple[dict, float]] = [] @@ -46,7 +46,7 @@ def urlopen(req: Any, timeout: float): def _signed_allow(body: dict): response = sign_with_key({"jsonrpc": "2.0", "id": body["id"], "result": { "request_id": body["params"]["request_id"], "decision": "allow"}}, - derive_session_key(SECRET.encode(), SESSION)) + derive_session_key(SECRET.encode(), SESSION), key_id="test-key") return io.BytesIO(json.dumps(response).encode()) def test_an_edit_after_building_is_covered_by_the_one_signature(self) -> None: diff --git a/adapters/tests/client/test_session.py b/adapters/tests/client/test_session.py index ce050d1c..b5e5ede1 100644 --- a/adapters/tests/client/test_session.py +++ b/adapters/tests/client/test_session.py @@ -28,7 +28,7 @@ def _signed(response: dict, session_id: str) -> dict: - return sign_with_key(response, derive_session_key(SECRET.encode(), session_id)) + return sign_with_key(response, derive_session_key(SECRET.encode(), session_id), key_id="test-key") def _answer(build, status: int = 200): @@ -96,7 +96,7 @@ def _forged_hello(hello: dict, **extra: Any) -> dict: class _SessionTest(unittest.TestCase): - ENV = {"ACS_HMAC_SECRET": SECRET} + ENV = {"ACS_HMAC_SECRET": SECRET, "ACS_HMAC_KEY_ID": "test-key"} def setUp(self) -> None: temporary = tempfile.TemporaryDirectory(prefix="acs-session-") @@ -151,7 +151,7 @@ def test_a_secret_that_cannot_be_loaded_refuses_the_session(self) -> None: secret_file = self.cache / "hmac.key" secret_file.write_text(SECRET) secret_file.chmod(0o644) - with mock.patch.dict(os.environ, {"ACS_HMAC_SECRET_FILE": str(secret_file)}): + with mock.patch.dict(os.environ, {"ACS_HMAC_SECRET_FILE": str(secret_file), "ACS_HMAC_KEY_ID": "test-key"}): decision, calls = self._open() self.assertIsInstance(decision, SessionRefused) self.assertEqual((decision.cause, calls), ("secret_unavailable", 0)) diff --git a/adapters/tests/client/test_store.py b/adapters/tests/client/test_store.py index 2b382741..ce1aeac0 100644 --- a/adapters/tests/client/test_store.py +++ b/adapters/tests/client/test_store.py @@ -102,9 +102,9 @@ def _record(self, env: dict, session: str = "session", tool: str = "mcp__srv__to return audit.call_args_list def test_a_signed_session_records_its_degraded_claim_once(self) -> None: - calls = self._record({"ACS_HMAC_SECRET": "s", "ACS_HMAC_SECRET_FILE": ""}) + calls = self._record({"ACS_HMAC_SECRET": "s", "ACS_HMAC_KEY_ID": "test-key", "ACS_HMAC_SECRET_FILE": "", "ACS_HMAC_KEY_ID": "test-key"}) self.assertEqual([c.args[0] for c in calls], ["profile_claim_degraded"]) self.assertEqual((calls[0].kwargs["profile"], calls[0].kwargs["tool"]), ("acs-core", "mcp__srv__tool")) def test_an_unsigned_session_claims_nothing_to_degrade(self) -> None: - self.assertEqual(self._record({"ACS_HMAC_SECRET": "", "ACS_HMAC_SECRET_FILE": ""}), []) + self.assertEqual(self._record({"ACS_HMAC_SECRET": "", "ACS_HMAC_KEY_ID": "test-key", "ACS_HMAC_SECRET_FILE": "", "ACS_HMAC_KEY_ID": "test-key"}), []) diff --git a/adapters/tests/common/test_capture_guardian.py b/adapters/tests/common/test_capture_guardian.py index fbd81de9..b0dc8960 100644 --- a/adapters/tests/common/test_capture_guardian.py +++ b/adapters/tests/common/test_capture_guardian.py @@ -42,7 +42,7 @@ def _signed_tool_call(secret: str, **overrides) -> dict: key = core.derive_session_key(secret.encode(), env["params"]["metadata"].get("session_id", "")) client.sign_envelope(env, key=key, - session_id=env["params"]["metadata"].get("session_id", "")) + session_id=env["params"]["metadata"].get("session_id", ""), key_id="test-key") return env @@ -105,7 +105,7 @@ def test_independent_of_acs_core(self): from acs.testing.capture_guardian import independent_verify env = self._envelope() key = core.derive_session_key(self.SECRET, self.SID) - client.sign_envelope(env, key=key, session_id=self.SID) + client.sign_envelope(env, key=key, session_id=self.SID, key_id="test-key") self.assertTrue(independent_verify(env, self.SECRET, self.SID), "independent verifier and acs.core disagree on a valid envelope") diff --git a/adapters/tests/common/test_edge_cases.py b/adapters/tests/common/test_edge_cases.py index 6033456a..a2a8d01a 100644 --- a/adapters/tests/common/test_edge_cases.py +++ b/adapters/tests/common/test_edge_cases.py @@ -272,21 +272,21 @@ def _make_envelope(self, value): def test_emoji_round_trip(self) -> None: env = self._make_envelope("🚀 from agent — ✨") key = core.derive_session_key(b"test-secret", "sess") - client.sign_envelope(env, key=key, session_id="sess") - self.assertTrue(client.verify_signature(env, key=key, session_id="sess")) + client.sign_envelope(env, key=key, session_id="sess", key_id="test-key") + self.assertTrue(client.verify_signature(env, key=key, session_id="sess", key_id="test-key")) def test_null_byte_round_trip(self) -> None: env = self._make_envelope("before\x00after") key = core.derive_session_key(b"test-secret", "sess") - client.sign_envelope(env, key=key, session_id="sess") - self.assertTrue(client.verify_signature(env, key=key, session_id="sess")) + client.sign_envelope(env, key=key, session_id="sess", key_id="test-key") + self.assertTrue(client.verify_signature(env, key=key, session_id="sess", key_id="test-key")) def test_bmp_and_supplementary_planes_round_trip(self) -> None: # Hebrew (BMP) + emoji (supplementary plane) env = self._make_envelope("שלום 🌍 こんにちは") key = core.derive_session_key(b"test-secret", "sess") - client.sign_envelope(env, key=key, session_id="sess") - self.assertTrue(client.verify_signature(env, key=key, session_id="sess")) + client.sign_envelope(env, key=key, session_id="sess", key_id="test-key") + self.assertTrue(client.verify_signature(env, key=key, session_id="sess", key_id="test-key")) # ===== #8: ISO 8601 parse resilience ===== diff --git a/adapters/tests/common/test_key_id.py b/adapters/tests/common/test_key_id.py new file mode 100644 index 00000000..1f7774f7 --- /dev/null +++ b/adapters/tests/common/test_key_id.py @@ -0,0 +1,161 @@ +"""§10's HMAC key derivation and key_id rules.""" +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import tempfile +import unittest +import urllib.request +import uuid +from pathlib import Path +from unittest import mock + +from acs import client +from acs.client import Config, SessionRefused, _exchange +from acs.client import _handshake +from acs.core import derive_session_key, sign_with_key +from acs.integrations.claude_code import wire as claude_wire +from acs.integrations.cursor import wire as cursor_wire +from acs.testing.harness import ProgrammableGuardian, launch_guardian, make_envelope + +SPEC = Path(__file__).resolve().parents[3] / "docs" / "spec" / "instrument" / "specification.md" +SECRET = "key-id-test-secret-0123456789abcdef" +BIN = Path(sys.executable).parent + + +def _audits(text: str) -> list[dict]: + return [json.loads(line[len("ACS_AUDIT "):]) for line in text.splitlines() if line.startswith("ACS_AUDIT ")] + + +class KeyDerivation(unittest.TestCase): + def test_the_spec_example_derives_the_spec_key(self) -> None: + example = re.search(r"the `session_id` `(?P[0-9a-f-]{36})` give the key `(?P[0-9a-f]{64})`", + SPEC.read_text(encoding="utf-8")) + self.assertIsNotNone(example, "§10 no longer carries the HKDF example in the expected form") + self.assertEqual(derive_session_key(bytes(range(32)), example["sid"]).hex(), example["key"]) + for spelling in (example["sid"].upper(), f"urn:uuid:{example['sid']}"): + self.assertNotEqual(derive_session_key(bytes(range(32)), spelling).hex(), example["key"], + "info is the session_id as sent, not normalized") + + +class ConfiguredLabel(unittest.TestCase): + def setUp(self) -> None: + env = {k: v for k, v in os.environ.items() if not k.startswith("ACS_")} + patcher = mock.patch.dict(os.environ, {**env, "ACS_HMAC_SECRET": SECRET, "ACS_HMAC_KEY_ID": "2026-11"}, + clear=True) + patcher.start() + self.addCleanup(patcher.stop) + self.session = str(uuid.uuid4()) + self.key = derive_session_key(SECRET.encode(), self.session) + + def _response(self, label: str) -> dict: + return sign_with_key({"jsonrpc": "2.0", "id": "rpc-1", "result": {"decision": "allow"}}, self.key, key_id=label) + + def test_requests_carry_the_configured_label(self) -> None: + signed = client.sign_envelope(make_envelope("steps/toolCallRequest", session_id=self.session), + session_id=self.session) + self.assertEqual(signed["params"]["signature"]["key_id"], "2026-11") + + def test_there_is_no_default_label(self) -> None: + with self.assertRaises(TypeError): + sign_with_key({"jsonrpc": "2.0", "id": "x", "result": {}}, self.key) # type: ignore[call-arg] + del os.environ["ACS_HMAC_KEY_ID"] + with self.assertRaisesRegex(ValueError, "ACS_HMAC_KEY_ID"): + client.sign_envelope(make_envelope("steps/toolCallRequest", session_id=self.session), + session_id=self.session) + + def test_a_response_under_another_label_fails_without_a_fallback(self) -> None: + self.assertTrue(client.verify_signature(self._response("2026-11"), session_id=self.session)) + self.assertFalse(client.verify_signature(self._response("2026-10"), session_id=self.session), + "the right key under another label is still rejected") + del os.environ["ACS_HMAC_KEY_ID"] + self.assertFalse(client.verify_signature(self._response("2026-11"), session_id=self.session)) + + def test_a_secret_without_a_label_refuses_the_session_and_sends_nothing(self) -> None: + del os.environ["ACS_HMAC_KEY_ID"] + with tempfile.TemporaryDirectory() as records, \ + mock.patch.object(_handshake, "_HANDSHAKE_CACHE_DIR", Path(records)), \ + mock.patch.object(_exchange, "_open") as network: + session = client.open_session(config=Config.from_env({"ACS_GUARDIAN_URL": "http://127.0.0.1:9/acs"}), + session_id=self.session, agent_id="key-id", platform="test", + methods_implemented=["steps/toolCallRequest"]) + network.assert_not_called() + self.assertEqual(session, SessionRefused("key_id_missing")) + + def test_an_unknown_label_at_the_guardian_never_runs_a_tool(self) -> None: + guardian = ProgrammableGuardian(hmac_secret=SECRET, key_id="2026-10") + guardian.start() + self.addCleanup(guardian.stop) + with tempfile.TemporaryDirectory() as root: + env = {**os.environ, "ACS_GUARDIAN_URL": guardian.url(), "ACS_HANDSHAKE_CACHE": root, + "ACS_SESSION_STATE_DIR": root} + event = {"session_id": self.session, "cwd": "/tmp", "hook_event_name": "PreToolUse", + "tool_name": "Bash", "tool_input": {"command": "rm -rf /srv"}} + for _ in range(2): + p = subprocess.run([str(BIN / "acs-claude-code-hook")], input=json.dumps(event), + capture_output=True, text=True, env=env, timeout=30) + self.assertEqual(json.loads(p.stdout)["hookSpecificOutput"]["permissionDecision"], "deny", + "fail-open does not turn an unverifiable rejection into an allow") + self.assertIn("key_id_rejected", [r.get("cause") for r in _audits(p.stderr)]) + self.assertEqual(guardian.methods_seen(), ["handshake/hello"], "the refusal holds; nothing more is sent") + + +class ExampleGuardianLabel(unittest.TestCase): + def _guardian(self, **extra: str) -> str: + state = tempfile.TemporaryDirectory() + self.addCleanup(state.cleanup) + env = {**{k: v for k, v in os.environ.items() if not k.startswith("ACS_")}, + "ACS_HMAC_SECRET": SECRET, "ACS_HMAC_KEY_ID": "2026-11", "ACS_GUARDIAN_STATE_DIR": state.name, **extra} + proc, port = launch_guardian(env=env) + self.addCleanup(proc.wait, 3) + self.addCleanup(proc.terminate) + return f"http://127.0.0.1:{port}/acs" + + def _post(self, url: str, label: str) -> dict: + hello = {"acs_versions_supported": ["0.1.0"], "methods_implemented": ["steps/toolCallRequest"], + "transports_supported": ["http"], "provenance_producer": "none", "wrapped_protocols": [], + "profiles_supported": ["acs-core"]} + envelope = make_envelope("handshake/hello", hello, sign_with_secret=SECRET, key_id=label) + request = urllib.request.Request(url, data=json.dumps(envelope).encode(), method="POST", + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(request, timeout=5) as response: + return json.loads(response.read()) + + def test_an_unknown_label_gets_an_unsigned_signature_invalid(self) -> None: + answer = self._post(self._guardian(), "2026-10") + self.assertEqual(answer["error"]["code"], -32004) + self.assertNotIn("signature", answer["error"], "no configured secret matches, so the error is unsigned") + + def test_a_response_carries_the_requests_label(self) -> None: + answer = self._post(self._guardian(), "2026-11") + self.assertEqual(answer["result"]["signature"]["key_id"], "2026-11") + + def test_a_guardian_with_a_secret_and_no_label_does_not_start(self) -> None: + with self.assertRaisesRegex(RuntimeError, "ACS_HMAC_KEY_ID"): + self._guardian(ACS_HMAC_KEY_ID="") + + +class WireToolsNameTheSecret(unittest.TestCase): + def test_a_secret_needs_a_label_and_the_command_carries_it(self) -> None: + for name, module in (("claude-code", claude_wire), ("cursor", cursor_wire)): + with self.subTest(name), tempfile.TemporaryDirectory() as d: + hook = Path(d) / "hook" + hook.write_text("#!/bin/sh\n") + hook.chmod(0o755) + base = ["--guardian-url=http://127.0.0.1:8787/acs", f"--settings={d}/settings.json", + f"--hook-command={hook}", "--secret-env-inline=ab" * 1] + with mock.patch.dict(os.environ, {"HOME": d}), mock.patch("sys.stderr"), \ + self.assertRaises(SystemExit): + module.main(base) + command = module.build_command( + hook_command=str(hook), guardian_url="http://127.0.0.1:8787/acs", secret_file=None, + secret_env="ab", key_id="2026-11", default_deny=False, host_allowlist=None, + **({"event_name": "preToolUse"} if module is cursor_wire else {})) + self.assertIn("ACS_HMAC_KEY_ID=2026-11 ", command) + + +if __name__ == "__main__": + unittest.main() diff --git a/adapters/tests/common/test_methods_evaluated.py b/adapters/tests/common/test_methods_evaluated.py index d39a0e72..69990e33 100644 --- a/adapters/tests/common/test_methods_evaluated.py +++ b/adapters/tests/common/test_methods_evaluated.py @@ -64,7 +64,7 @@ def __init__(self, guardian: ProgrammableGuardian, *, fail_closed: bool = True) self.env = {k: v for k, v in os.environ.items() if k not in ("ACS_HMAC_SECRET_FILE", "ACS_DEFAULT_DENY", "ACS_HANDSHAKE")} self.env.update( - ACS_GUARDIAN_URL=guardian.url(), ACS_HMAC_SECRET=guardian.hmac_secret, + ACS_GUARDIAN_URL=guardian.url(), ACS_HMAC_SECRET=guardian.hmac_secret, ACS_HMAC_KEY_ID="test-key", ACS_HANDSHAKE_CACHE=str(d / "handshake"), ACS_SESSION_STATE_DIR=str(d / "state"), ACS_AUDIT_FILE=str(self.audit_path)) diff --git a/adapters/tests/common/test_security.py b/adapters/tests/common/test_security.py index f0f49a11..38b6e015 100644 --- a/adapters/tests/common/test_security.py +++ b/adapters/tests/common/test_security.py @@ -83,10 +83,11 @@ def setUp(self) -> None: self.secret_path = Path(self.tmpdir) / "hmac.key" self.secret_path.write_bytes(b"super-secret-key-material" * 4) self._old_env = { - "ACS_HMAC_SECRET_FILE": os.environ.get("ACS_HMAC_SECRET_FILE"), - "ACS_HMAC_SECRET": os.environ.get("ACS_HMAC_SECRET"), + "ACS_HMAC_SECRET_FILE": os.environ.get("ACS_HMAC_SECRET_FILE"), "ACS_HMAC_KEY_ID": "test-key", + "ACS_HMAC_SECRET": os.environ.get("ACS_HMAC_SECRET"), "ACS_HMAC_KEY_ID": "test-key", } os.environ["ACS_HMAC_SECRET_FILE"] = str(self.secret_path) + os.environ["ACS_HMAC_KEY_ID"] = "test-key" os.environ.pop("ACS_HMAC_SECRET", None) def tearDown(self) -> None: @@ -410,6 +411,7 @@ def _make_envelope(self, sig_value): def test_malformed_signature_returns_false_not_crashes(self) -> None: import os os.environ["ACS_HMAC_SECRET"] = "verify-sig-robustness-test-secret" + os.environ["ACS_HMAC_KEY_ID"] = "test-key" try: for bad in self.BAD_VALUES: with self.subTest(signature=bad[:30]): @@ -528,6 +530,7 @@ class Item19_SecretFileUnreadableRaises(unittest.TestCase): def test_missing_secret_file_raises(self) -> None: os.environ["ACS_HMAC_SECRET_FILE"] = "/nonexistent-secret-file" + os.environ["ACS_HMAC_KEY_ID"] = "test-key" try: with self.assertRaises(client.SecretFileUnreadableError): client.load_hmac_secret() @@ -544,6 +547,7 @@ def test_no_secret_at_all_is_still_devmode_empty(self) -> None: finally: if old is not None: os.environ["ACS_HMAC_SECRET"] = old + os.environ["ACS_HMAC_KEY_ID"] = "test-key" class Item20_DeadGuardianCostsOneTimeoutPerSession(unittest.TestCase): @@ -807,7 +811,7 @@ def _run_claude_adapter(self, *, guardian_url: str, env = os.environ.copy() env.update({ "ACS_GUARDIAN_URL": guardian_url, - "ACS_HMAC_SECRET": secret, + "ACS_HMAC_SECRET": secret, "ACS_HMAC_KEY_ID": "test-key", "ACS_HANDSHAKE_CACHE": str(self.cache_dir), "ACS_HANDSHAKE_TIMEOUT_SECONDS": "2", }) @@ -882,6 +886,7 @@ def _capture_client_hello(self, secret: str | None) -> dict: os.environ.pop("ACS_HMAC_SECRET", None) else: os.environ["ACS_HMAC_SECRET"] = secret + os.environ["ACS_HMAC_KEY_ID"] = "test-key" os.environ.pop("ACS_HMAC_SECRET_FILE", None) captured: list[dict] = [] diff --git a/adapters/tests/common/test_serverhello_contract.py b/adapters/tests/common/test_serverhello_contract.py index 6f49397a..76daa3dc 100644 --- a/adapters/tests/common/test_serverhello_contract.py +++ b/adapters/tests/common/test_serverhello_contract.py @@ -51,6 +51,7 @@ def setUp(self) -> None: if not key.startswith("ACS_") } clean_environment["ACS_HMAC_SECRET"] = self.SECRET + clean_environment["ACS_HMAC_KEY_ID"] = "test-key" self._stack.enter_context(mock.patch.dict(os.environ, clean_environment, clear=True)) self._stack.enter_context(mock.patch.object(client_handshake, "_HANDSHAKE_CACHE_DIR", self.cache_directory)) self.audit = self._stack.enter_context(mock.patch.object(client_handshake, "audit_event")) @@ -75,7 +76,7 @@ def _run(self, url: str | None = None): def _sign(self, response: dict) -> dict: return client.sign_envelope( response, session_id=self.SESSION, - key=core.derive_session_key(self.SECRET.encode(), self.SESSION), + key=core.derive_session_key(self.SECRET.encode(), self.SESSION), key_id="test-key", ) def _respond(self, request, timeout=None): diff --git a/adapters/tests/common/test_session_gate.py b/adapters/tests/common/test_session_gate.py index 68b8807b..9df51204 100644 --- a/adapters/tests/common/test_session_gate.py +++ b/adapters/tests/common/test_session_gate.py @@ -32,7 +32,7 @@ def setUp(self) -> None: def _env(self, url: str, **extra: str) -> dict[str, str]: env = {k: v for k, v in os.environ.items() if not k.startswith("ACS_")} - env.update(ACS_GUARDIAN_URL=url, ACS_HMAC_SECRET=SECRET, + env.update(ACS_GUARDIAN_URL=url, ACS_HMAC_SECRET=SECRET, ACS_HMAC_KEY_ID="test-key", ACS_HANDSHAKE_CACHE=str(self.root / "handshake"), ACS_SESSION_STATE_DIR=str(self.root / "state"), ACS_HANDSHAKE_TIMEOUT_SECONDS="2") diff --git a/adapters/tests/conformance/test_acs_core_conformance.py b/adapters/tests/conformance/test_acs_core_conformance.py index a9561578..99b6a2af 100644 --- a/adapters/tests/conformance/test_acs_core_conformance.py +++ b/adapters/tests/conformance/test_acs_core_conformance.py @@ -134,6 +134,7 @@ def setUpClass(cls) -> None: env["ACS_GUARDIAN_STATE_DIR"] = cls.statedir if cls.HMAC_SECRET: env["ACS_HMAC_SECRET"] = cls.HMAC_SECRET + env["ACS_HMAC_KEY_ID"] = "test-key" env.pop("ACS_DEV_MODE", None) else: env["ACS_DEV_MODE"] = "1" @@ -176,7 +177,7 @@ def _make_envelope(self, method: str, payload: dict | None = None, *, } if sign and self.HMAC_SECRET: key = core.derive_session_key(self.HMAC_SECRET.encode(), sid) - client.sign_envelope(env, key=key, session_id=sid) + client.sign_envelope(env, key=key, session_id=sid, key_id="test-key") return env def _post(self, envelope: dict) -> dict: @@ -766,7 +767,7 @@ def _run_claude(self, guardian) -> dict: env = os.environ.copy() env.update({ "ACS_GUARDIAN_URL": guardian.url(), - "ACS_HMAC_SECRET": self.SECRET, + "ACS_HMAC_SECRET": self.SECRET, "ACS_HMAC_KEY_ID": "test-key", "ACS_HANDSHAKE_CACHE": tempfile.mkdtemp(), # Hermetic per-call turn/session state. "ACS_SESSION_STATE_DIR": tempfile.mkdtemp(), @@ -787,7 +788,7 @@ def _run_cursor(self, guardian) -> dict: env = os.environ.copy() env.update({ "ACS_GUARDIAN_URL": guardian.url(), - "ACS_HMAC_SECRET": self.SECRET, + "ACS_HMAC_SECRET": self.SECRET, "ACS_HMAC_KEY_ID": "test-key", "ACS_HANDSHAKE_CACHE": tempfile.mkdtemp(), # Hermetic per-call turn/session state. "ACS_SESSION_STATE_DIR": tempfile.mkdtemp(), @@ -949,7 +950,7 @@ def _guardian(self, method: str, result: dict): def _env(self, g): env = os.environ.copy() - env.update({"ACS_GUARDIAN_URL": g.url(), "ACS_HMAC_SECRET": self.SECRET, + env.update({"ACS_GUARDIAN_URL": g.url(), "ACS_HMAC_SECRET": self.SECRET, "ACS_HMAC_KEY_ID": "test-key", "ACS_HANDSHAKE_CACHE": tempfile.mkdtemp(), "ACS_SESSION_STATE_DIR": tempfile.mkdtemp()}) env.pop("ACS_HMAC_SECRET_FILE", None) @@ -1388,7 +1389,7 @@ def test_response_is_signed_and_verifies(self) -> None: self.assertIsNotNone(sig, "Guardian response missing `signature` field per §10") key = core.derive_session_key(self.HMAC_SECRET.encode(), sid) - self.assertTrue(client.verify_signature(resp, key=key), + self.assertTrue(client.verify_signature(resp, key=key, key_id="test-key"), "Guardian's response signature must verify with the " "HKDF-derived per-session key") @@ -1415,12 +1416,12 @@ def test_error_response_is_signed_and_verifies(self) -> None: "error response missing `signature` — errors are responses " "too; conformance.md, ACS-Core / Baseline integrity exempts only system/ping") key = core.derive_session_key(self.HMAC_SECRET.encode(), sid) - self.assertTrue(client.verify_signature(resp, key=key), + self.assertTrue(client.verify_signature(resp, key=key, key_id="test-key"), "error-response signature must verify with the per-session key") # And a tampered error must NOT verify (the check is real). tampered = json.loads(json.dumps(resp)) tampered["error"]["message"] = "REPLAY_DETECTED but for a different action" - self.assertFalse(client.verify_signature(tampered, key=key), + self.assertFalse(client.verify_signature(tampered, key=key, key_id="test-key"), "tampered error envelope must fail verification") def test_per_session_key_derivation(self) -> None: @@ -1548,6 +1549,7 @@ def test_guardian_error_response_carries_distinct_cause(self) -> None: adapter = INTEGRATIONS_DIR / "claude_code" / "adapter.py" env_g = os.environ.copy() env_g["ACS_HMAC_SECRET"] = "regression-test-secret" + env_g["ACS_HMAC_KEY_ID"] = "test-key" env_g.pop("ACS_DEV_MODE", None) env_g["ACS_GUARDIAN_STATE_DIR"] = tempfile.mkdtemp() guardian, port = launch_guardian(env=env_g) @@ -1603,6 +1605,7 @@ def test_guardian_error_under_fail_closed_emits_deny(self) -> None: adapter = INTEGRATIONS_DIR / "claude_code" / "adapter.py" env_g = os.environ.copy() env_g["ACS_HMAC_SECRET"] = "regression-test-secret-2" + env_g["ACS_HMAC_KEY_ID"] = "test-key" env_g.pop("ACS_DEV_MODE", None) env_g["ACS_GUARDIAN_STATE_DIR"] = tempfile.mkdtemp() guardian, port = launch_guardian(env=env_g) @@ -1732,7 +1735,7 @@ def _run_shell_adapter(self, platform: str, guardian, env = os.environ.copy() env.update({ "ACS_GUARDIAN_URL": guardian.url(), - "ACS_HMAC_SECRET": guardian.hmac_secret, + "ACS_HMAC_SECRET": guardian.hmac_secret, "ACS_HMAC_KEY_ID": "test-key", "ACS_HANDSHAKE_CACHE": cache_dir, }) env.pop("ACS_HMAC_SECRET_FILE", None) diff --git a/adapters/tests/integrations/claude_code/test_adapter.py b/adapters/tests/integrations/claude_code/test_adapter.py index 790b1047..11f32904 100644 --- a/adapters/tests/integrations/claude_code/test_adapter.py +++ b/adapters/tests/integrations/claude_code/test_adapter.py @@ -259,7 +259,7 @@ def test_audit_only_hooks_never_emit_a_block(self) -> None: # red. State is now isolated per test.) with g, tempfile.TemporaryDirectory() as state: env = {"ACS_GUARDIAN_URL": g.url(), - "ACS_HMAC_SECRET": g.hmac_secret, + "ACS_HMAC_SECRET": g.hmac_secret, "ACS_HMAC_KEY_ID": "test-key", "ACS_SESSION_STATE_DIR": state} if hook == "Stop": # Open the turn: allow turnStart + userMessage diff --git a/adapters/tests/integrations/claude_code/test_emission.py b/adapters/tests/integrations/claude_code/test_emission.py index 689629f9..552e422c 100644 --- a/adapters/tests/integrations/claude_code/test_emission.py +++ b/adapters/tests/integrations/claude_code/test_emission.py @@ -48,12 +48,10 @@ def _emit(self, event: dict) -> CaptureGuardian: env = os.environ.copy() env.update({ "ACS_GUARDIAN_URL": g.url(), - "ACS_HMAC_SECRET": g.hmac_secret, - "ACS_HANDSHAKE": "1", # capture the handshake too - "ACS_HANDSHAKE_CACHE": cache, # fresh → handshake fires - # Fresh, isolated turn/session state per one-shot emit so a - # turn opened by one test can't leak into another (all - # cases reuse one SESSION id). + "ACS_HMAC_SECRET": g.hmac_secret, "ACS_HMAC_KEY_ID": "test-key", + "ACS_HANDSHAKE": "1", + "ACS_HANDSHAKE_CACHE": cache, + # Fresh state per emit, since every case reuses SESSION. "ACS_SESSION_STATE_DIR": cache, }) env.pop("ACS_HMAC_SECRET_FILE", None) @@ -184,7 +182,7 @@ def setUpClass(cls): env = os.environ.copy() env.update({ "ACS_GUARDIAN_URL": cls.g.url(), - "ACS_HMAC_SECRET": cls.g.hmac_secret, + "ACS_HMAC_SECRET": cls.g.hmac_secret, "ACS_HMAC_KEY_ID": "test-key", "ACS_HANDSHAKE": "1", "ACS_HANDSHAKE_CACHE": cls._cache, # SHARED → handshake once "ACS_SESSION_STATE_DIR": cls._state, # SHARED → turn_id persists diff --git a/adapters/tests/integrations/claude_code/test_example_config.py b/adapters/tests/integrations/claude_code/test_example_config.py index 36fc2688..cbac90e7 100644 --- a/adapters/tests/integrations/claude_code/test_example_config.py +++ b/adapters/tests/integrations/claude_code/test_example_config.py @@ -80,6 +80,7 @@ def test_example_equals_wire_py_output(self) -> None: guardian_url="http://127.0.0.1:8787/acs", secret_file="/path/to/.acs/hmac.key", secret_env=None, + key_id="2026-11", default_deny=(hook in wire.GATE_HOOKS), host_allowlist=None, audit_file="/path/to/.acs/audit.log", diff --git a/adapters/tests/integrations/cursor/test_emission.py b/adapters/tests/integrations/cursor/test_emission.py index 1b01e79c..20879181 100644 --- a/adapters/tests/integrations/cursor/test_emission.py +++ b/adapters/tests/integrations/cursor/test_emission.py @@ -36,7 +36,7 @@ def _emit(self, event_name: str, event: dict) -> CaptureGuardian: env = os.environ.copy() env.update({ "ACS_GUARDIAN_URL": g.url(), - "ACS_HMAC_SECRET": g.hmac_secret, + "ACS_HMAC_SECRET": g.hmac_secret, "ACS_HMAC_KEY_ID": "test-key", "ACS_HANDSHAKE": "1", "ACS_HANDSHAKE_CACHE": cache, # subagentStart needs a recorded prior step OR falls back @@ -294,7 +294,7 @@ def setUpClass(cls): env = os.environ.copy() env.update({ "ACS_GUARDIAN_URL": cls.g.url(), - "ACS_HMAC_SECRET": cls.g.hmac_secret, + "ACS_HMAC_SECRET": cls.g.hmac_secret, "ACS_HMAC_KEY_ID": "test-key", "ACS_HANDSHAKE": "1", "ACS_HANDSHAKE_CACHE": cls._cache, # SHARED → handshake once "ACS_SESSION_STATE_DIR": cls._cache, # SHARED → preCompact entries populate diff --git a/adapters/tests/integrations/cursor/test_wire.py b/adapters/tests/integrations/cursor/test_wire.py index ed3262ca..01f64ad7 100644 --- a/adapters/tests/integrations/cursor/test_wire.py +++ b/adapters/tests/integrations/cursor/test_wire.py @@ -30,7 +30,7 @@ def _run_default_wire() -> dict: proc = subprocess.run( [sys.executable, str(WIRE), "--guardian-url=http://127.0.0.1:8787/acs", - f"--secret-file={secret}", + f"--secret-file={secret}", "--key-id=test-key", f"--settings={settings}", "--write"], capture_output=True, text=True, timeout=30, cwd=d, env={**os.environ, "HOME": d}, diff --git a/adapters/tests/integrations/nat/e2e_check.py b/adapters/tests/integrations/nat/e2e_check.py index 96d6f430..7b708c70 100644 --- a/adapters/tests/integrations/nat/e2e_check.py +++ b/adapters/tests/integrations/nat/e2e_check.py @@ -68,6 +68,7 @@ def _build_middleware(guardian_url: str, *, default_deny: bool = True, """Construct an ACSMiddleware with the same env the operator uses in production (HMAC secret on; signs every envelope).""" os.environ["ACS_HMAC_SECRET"] = HMAC_SECRET + os.environ["ACS_HMAC_KEY_ID"] = "test-key" from acs.integrations.nat.adapter import ACSMiddleware, ACSMiddlewareConfig # noqa: E402 return ACSMiddleware(ACSMiddlewareConfig( guardian_url=guardian_url, diff --git a/adapters/tests/integrations/nat/test_adapter.py b/adapters/tests/integrations/nat/test_adapter.py index 453fc13d..1bc472c7 100644 --- a/adapters/tests/integrations/nat/test_adapter.py +++ b/adapters/tests/integrations/nat/test_adapter.py @@ -140,6 +140,7 @@ def delayed_deny(req: dict) -> dict: saved_secret_file = os.environ.get("ACS_HMAC_SECRET_FILE") try: os.environ["ACS_HMAC_SECRET"] = g.hmac_secret + os.environ["ACS_HMAC_KEY_ID"] = "test-key" os.environ.pop("ACS_HMAC_SECRET_FILE", None) with tempfile.TemporaryDirectory() as d, \ mock.patch.object(client_handshake, "_HANDSHAKE_CACHE_DIR", @@ -159,10 +160,12 @@ def delayed_deny(req: dict) -> dict: os.environ.pop("ACS_HMAC_SECRET", None) else: os.environ["ACS_HMAC_SECRET"] = saved_secret + os.environ["ACS_HMAC_KEY_ID"] = "test-key" if saved_secret_file is None: os.environ.pop("ACS_HMAC_SECRET_FILE", None) else: os.environ["ACS_HMAC_SECRET_FILE"] = saved_secret_file + os.environ["ACS_HMAC_KEY_ID"] = "test-key" def test_unevaluated_tool_call_proceeds_unsent(self) -> None: """handshake.json: methods absent from methods_evaluated are @@ -181,6 +184,7 @@ def hello(req: dict) -> dict: saved_secret_file = os.environ.get("ACS_HMAC_SECRET_FILE") try: os.environ["ACS_HMAC_SECRET"] = g.hmac_secret + os.environ["ACS_HMAC_KEY_ID"] = "test-key" os.environ.pop("ACS_HMAC_SECRET_FILE", None) with tempfile.TemporaryDirectory() as d, \ mock.patch.object(client_handshake, "_HANDSHAKE_CACHE_DIR", @@ -203,10 +207,12 @@ def hello(req: dict) -> dict: os.environ.pop("ACS_HMAC_SECRET", None) else: os.environ["ACS_HMAC_SECRET"] = saved_secret + os.environ["ACS_HMAC_KEY_ID"] = "test-key" if saved_secret_file is None: os.environ.pop("ACS_HMAC_SECRET_FILE", None) else: os.environ["ACS_HMAC_SECRET_FILE"] = saved_secret_file + os.environ["ACS_HMAC_KEY_ID"] = "test-key" def test_acs_disabled_bypass_is_written_to_the_durable_sink(self) -> None: """Both NAT bypass boundaries must leave structured durable records.""" diff --git a/adapters/tests/integrations/nat/test_dispositions_live.py b/adapters/tests/integrations/nat/test_dispositions_live.py index 6a62c6ab..67693160 100644 --- a/adapters/tests/integrations/nat/test_dispositions_live.py +++ b/adapters/tests/integrations/nat/test_dispositions_live.py @@ -94,11 +94,13 @@ def _mw(self, *, default_deny: bool = True) -> ACSMiddleware: # invalid. addCleanup runs even on test failure. prior = os.environ.get("ACS_HMAC_SECRET") os.environ["ACS_HMAC_SECRET"] = HMAC + os.environ["ACS_HMAC_KEY_ID"] = "test-key" def _restore(): if prior is None: os.environ.pop("ACS_HMAC_SECRET", None) else: os.environ["ACS_HMAC_SECRET"] = prior + os.environ["ACS_HMAC_KEY_ID"] = "test-key" self.addCleanup(_restore) return ACSMiddleware(ACSMiddlewareConfig( guardian_url=self.url, default_deny=default_deny, diff --git a/adapters/tests/integrations/nat/test_emission.py b/adapters/tests/integrations/nat/test_emission.py index 55c51b01..f737d80d 100644 --- a/adapters/tests/integrations/nat/test_emission.py +++ b/adapters/tests/integrations/nat/test_emission.py @@ -77,6 +77,7 @@ def setUp(self) -> None: patcher.start() self.addCleanup(patcher.stop) os.environ["ACS_HMAC_SECRET"] = HMAC + os.environ["ACS_HMAC_KEY_ID"] = "test-key" os.environ.pop("ACS_HANDSHAKE", None) def _mw(self) -> ACSMiddleware: diff --git a/adapters/tests/integrations/nat/test_event_loop.py b/adapters/tests/integrations/nat/test_event_loop.py index be5c5326..83a813fc 100644 --- a/adapters/tests/integrations/nat/test_event_loop.py +++ b/adapters/tests/integrations/nat/test_event_loop.py @@ -64,7 +64,7 @@ def setUp(self) -> None: env = {k: v for k, v in os.environ.items() if not k.startswith("ACS_")} for patcher in (mock.patch.object(_handshake, "_HANDSHAKE_CACHE_DIR", Path(records.name)), mock.patch.object(_handshake, "_HANDSHAKE_TIMEOUT_S", 5.0), - mock.patch.dict(os.environ, {**env, "ACS_HMAC_SECRET": SECRET}, clear=True)): + mock.patch.dict(os.environ, {**env, "ACS_HMAC_SECRET": SECRET, "ACS_HMAC_KEY_ID": "test-key"}, clear=True)): patcher.start() self.addCleanup(patcher.stop) self.guardian = ProgrammableGuardian(hmac_secret=SECRET) diff --git a/adapters/tests/integrations/nat/test_runner_lifecycle.py b/adapters/tests/integrations/nat/test_runner_lifecycle.py index e04a6409..328df288 100644 --- a/adapters/tests/integrations/nat/test_runner_lifecycle.py +++ b/adapters/tests/integrations/nat/test_runner_lifecycle.py @@ -74,7 +74,7 @@ def setUp(self) -> None: self.addCleanup(records.cleanup) env = {k: v for k, v in os.environ.items() if not k.startswith("ACS_")} for patcher in (mock.patch.object(_handshake, "_HANDSHAKE_CACHE_DIR", Path(records.name)), - mock.patch.dict(os.environ, {**env, "ACS_HMAC_SECRET": SECRET}, clear=True)): + mock.patch.dict(os.environ, {**env, "ACS_HMAC_SECRET": SECRET, "ACS_HMAC_KEY_ID": "test-key"}, clear=True)): patcher.start() self.addCleanup(patcher.stop) self.guardian = ProgrammableGuardian(hmac_secret=SECRET) diff --git a/adapters/tests/integrations/nat/test_session_gate.py b/adapters/tests/integrations/nat/test_session_gate.py index 52baed30..5a681d51 100644 --- a/adapters/tests/integrations/nat/test_session_gate.py +++ b/adapters/tests/integrations/nat/test_session_gate.py @@ -34,7 +34,7 @@ def setUp(self) -> None: env = {k: v for k, v in os.environ.items() if not k.startswith("ACS_")} for patcher in (mock.patch.object(_handshake, "_HANDSHAKE_CACHE_DIR", self.records), mock.patch.object(_handshake, "_HANDSHAKE_TIMEOUT_S", 2.0), - mock.patch.dict(os.environ, {**env, "ACS_HMAC_SECRET": SECRET}, clear=True)): + mock.patch.dict(os.environ, {**env, "ACS_HMAC_SECRET": SECRET, "ACS_HMAC_KEY_ID": "test-key"}, clear=True)): patcher.start() self.addCleanup(patcher.stop) diff --git a/adapters/tests/integrations/nat/test_untrusted_answers.py b/adapters/tests/integrations/nat/test_untrusted_answers.py index 0a769939..8a1a20f7 100644 --- a/adapters/tests/integrations/nat/test_untrusted_answers.py +++ b/adapters/tests/integrations/nat/test_untrusted_answers.py @@ -46,7 +46,7 @@ def read(self) -> bytes: @unittest.skipUnless(_NAT_OK, "nvidia-nat-core not installed") -@mock.patch.dict(os.environ, {"ACS_HMAC_SECRET": SECRET, "ACS_HMAC_SECRET_FILE": "", +@mock.patch.dict(os.environ, {"ACS_HMAC_SECRET": SECRET, "ACS_HMAC_KEY_ID": "test-key", "ACS_HMAC_SECRET_FILE": "", "ACS_HMAC_KEY_ID": "test-key", "ACS_DISABLED": "", "ACS_STARTUP_POSTURE": ""}) class UntrustedAnswersNeverRunTheFunction(unittest.TestCase): def setUp(self) -> None: @@ -75,7 +75,7 @@ async def target(command: str) -> str: def urlopen(req: Any, timeout: float) -> _Body: status, body = answer(json.loads(req.data)) if body.get("sign"): - body = sign_with_key(body["envelope"], key) + body = sign_with_key(body["envelope"], key, key_id="test-key") raw = json.dumps(body).encode() if status == 200: return _Body(raw) From 91b3478a58763ffdb17d98871f43f296a3543fb6 Mon Sep 17 00:00:00 2001 From: Bar Kaduri Date: Sun, 1 Nov 2026 19:08:59 +0200 Subject: [PATCH 02/12] Sign every request and refuse a session without a usable secret open_session refuses a session whose secret is missing, shorter than 32 bytes or unlabeled, and sends nothing (section 10). The example Guardian refuses to start without a secret. Signed-off-by: Bar Kaduri --- adapters/SECURITY.md | 8 +- adapters/docs/claude_code/README.md | 37 ++--- adapters/docs/claude_code/mapping.md | 4 +- .../docs/claude_code/settings.json.example | 6 +- adapters/docs/cursor/README.md | 32 ++--- adapters/docs/cursor/mapping.md | 4 +- adapters/docs/nat/README.md | 2 +- adapters/docs/nat/mapping.md | 4 +- adapters/docs/nat/workflow.yml.example | 6 +- adapters/src/acs/client/__init__.py | 2 + adapters/src/acs/client/_handshake.py | 28 ++-- adapters/src/acs/client/_profiles.py | 8 +- adapters/src/acs/client/_secrets.py | 54 ++++--- adapters/src/acs/client/_signing.py | 43 +----- adapters/src/acs/guardian/example_guardian.py | 133 ++++-------------- .../acs/integrations/claude_code/adapter.py | 6 +- .../src/acs/integrations/claude_code/wire.py | 9 +- adapters/src/acs/integrations/cursor/wire.py | 9 +- adapters/src/acs/testing/harness.py | 12 ++ adapters/tests/client/test_exchange.py | 2 +- adapters/tests/client/test_round_trip.py | 2 +- adapters/tests/client/test_session.py | 17 +-- adapters/tests/client/test_store.py | 6 +- adapters/tests/common/test_edge_cases.py | 27 ++-- adapters/tests/common/test_hook_command.py | 9 +- adapters/tests/common/test_key_id.py | 64 +++++++-- adapters/tests/common/test_security.py | 40 +++--- adapters/tests/common/test_session_gate.py | 2 +- adapters/tests/common/test_wire_duplicates.py | 5 +- .../conformance/test_acs_core_conformance.py | 50 +++---- .../integrations/claude_code/test_adapter.py | 9 +- .../integrations/claude_code/test_live.py | 7 +- .../tests/integrations/cursor/test_adapter.py | 10 +- .../tests/integrations/nat/test_adapter.py | 11 +- .../nat/test_dispositions_live.py | 2 +- .../tests/integrations/nat/test_emission.py | 2 +- .../tests/integrations/nat/test_event_loop.py | 2 +- .../integrations/nat/test_failure_modes.py | 26 ++-- adapters/tests/integrations/nat/test_live.py | 7 +- .../integrations/nat/test_runner_lifecycle.py | 2 +- .../integrations/nat/test_session_gate.py | 2 +- .../nat/test_untrusted_answers.py | 2 +- 42 files changed, 319 insertions(+), 394 deletions(-) diff --git a/adapters/SECURITY.md b/adapters/SECURITY.md index 8d2be002..c064ac9e 100644 --- a/adapters/SECURITY.md +++ b/adapters/SECURITY.md @@ -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 25c90bed..ca015520 100644 --- a/adapters/docs/claude_code/README.md +++ b/adapters/docs/claude_code/README.md @@ -176,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): +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", ..., "hook": "PreToolUse", ...} +{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "ACS adapter: session refused (error_signature_invalid)"}} ``` -And stdout carries a real deny: - -``` -{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", ...}} -``` - -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) @@ -298,7 +289,7 @@ Honest, item-by-item against the ACS-Core baseline in `docs/spec/conformance.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 25734bc1..fca28334 100644 --- a/adapters/docs/claude_code/mapping.md +++ b/adapters/docs/claude_code/mapping.md @@ -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`). One ACS-Core requirement from a later specification change is not met yet: decision failures recorded with their failure class and counted at `sessionEnd` (§6.4, #273). 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`. 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. Without a secret the adapter runs unsigned and says so with a loud `unsigned_mode` audit event. +- 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). - 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 0a4c9125..a4b70354 100644 --- a/adapters/docs/claude_code/settings.json.example +++ b/adapters/docs/claude_code/settings.json.example @@ -13,10 +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).", + "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 both ends run unsigned and any local process that", - "binds the Guardian port first becomes the Guardian. Generate 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.", "", diff --git a/adapters/docs/cursor/README.md b/adapters/docs/cursor/README.md index 450a054b..bab16a10 100644 --- a/adapters/docs/cursor/README.md +++ b/adapters/docs/cursor/README.md @@ -188,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) @@ -325,7 +321,7 @@ Honest, item-by-item against the ACS-Core baseline in `docs/spec/conformance.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/mapping.md b/adapters/docs/cursor/mapping.md index b9cc8b8a..ca3f23a1 100644 --- a/adapters/docs/cursor/mapping.md +++ b/adapters/docs/cursor/mapping.md @@ -95,7 +95,7 @@ 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`). One ACS-Core requirement from a later specification change is not met yet: decision failures recorded with their failure class and counted at `sessionEnd` (§6.4, #273). 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. @@ -103,5 +103,5 @@ The Cursor adapter covers ACS-Core's mandatory floor in the same shape as the Cl - 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). - 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. 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). Unsigned mode is announced with a loud `unsigned_mode` audit event. +- 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) diff --git a/adapters/docs/nat/README.md b/adapters/docs/nat/README.md index 68c5212b..0d1380fd 100644 --- a/adapters/docs/nat/README.md +++ b/adapters/docs/nat/README.md @@ -300,7 +300,7 @@ Honest, item-by-item against the ACS-Core baseline in `docs/spec/conformance.md` | 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 039034e0..81694905 100644 --- a/adapters/docs/nat/mapping.md +++ b/adapters/docs/nat/mapping.md @@ -82,7 +82,7 @@ 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`). One ACS-Core requirement from a later specification change is not met yet: decision failures recorded with their failure class and counted at `sessionEnd` (§6.4, #273). 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). @@ -90,4 +90,4 @@ The NAT adapter covers ACS-Core's mandatory floor. This is the floor the tests c - 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. 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. Unsigned mode (no secret) is announced with a loud `unsigned_mode` audit event. +- 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 7b20b2dc..a309812f 100644 --- a/adapters/docs/nat/workflow.yml.example +++ b/adapters/docs/nat/workflow.yml.example @@ -7,9 +7,9 @@ # ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key # 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). Without a secret -# both ends run unsigned and any local process that binds the -# Guardian port becomes the Guardian. Generate: +# 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 diff --git a/adapters/src/acs/client/__init__.py b/adapters/src/acs/client/__init__.py index 35b48a51..d19a3b10 100644 --- a/adapters/src/acs/client/__init__.py +++ b/adapters/src/acs/client/__init__.py @@ -16,6 +16,7 @@ "Resolution": "_config", "SecretFilePermissionsError": "_secrets", "SecretFileUnreadableError": "_secrets", + "SigningMaterialMissing": "_secrets", "SessionRefused": "_outcome", "Undecided": "_outcome", "audit_event": "_audit", @@ -25,6 +26,7 @@ "exchange": "_exchange", "load_hmac_secret": "_secrets", "load_key_id": "_secrets", + "load_signing_material": "_secrets", "load_session_state": "_store", "mark_once": "_store", "open_session": "_handshake", diff --git a/adapters/src/acs/client/_handshake.py b/adapters/src/acs/client/_handshake.py index afbc0fe9..8919f8cb 100644 --- a/adapters/src/acs/client/_handshake.py +++ b/adapters/src/acs/client/_handshake.py @@ -18,7 +18,7 @@ from acs.client._config import Config, Resolution 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, load_key_id +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 @@ -81,8 +81,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) @@ -158,18 +161,15 @@ 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 [] - if ikm and not load_key_id(): - decision: Handshake = SessionRefused("key_id_missing") - elif not config.handshake_enabled: - decision = NoAnswer("handshake_disabled") + claimed = profiles_supported if profiles_supported is not None else ["acs-core"] + if not config.handshake_enabled: + decision: Handshake = NoAnswer("handshake_disabled") else: decision = _hello( 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) @@ -198,7 +198,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 = { @@ -219,7 +219,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"], }, }, } @@ -388,7 +388,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): @@ -409,7 +409,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) @@ -438,7 +438,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 43c5de75..4d8ddc13 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,28 +49,16 @@ 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: @@ -69,7 +66,7 @@ def load_hmac_secret() -> bytes: 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"" @@ -78,3 +75,18 @@ def load_hmac_secret() -> bytes: 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 5193cdb3..0e72e66e 100644 --- a/adapters/src/acs/client/_signing.py +++ b/adapters/src/acs/client/_signing.py @@ -1,43 +1,14 @@ -"""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, load_key_id +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 | None = None) -> dict: - """Add a signature under key_id, the configured ACS_HMAC_KEY_ID unless given. Returns the envelope - unchanged (after a loud one-time audit event) if no key material is available.""" + """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 {} @@ -51,17 +22,13 @@ def sign_envelope(envelope: dict, *, key: bytes | None = None, def verify_signature(envelope: dict, *, key: bytes | None = None, session_id: str | None = None, key_id: str | None = None) -> bool: - """Verify a signed envelope (request, decision response, or error - response) made under key_id, the configured ACS_HMAC_KEY_ID unless - given. Returns True if valid (or if no signature present and no - key material configured — local-dev mode).""" + """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 diff --git a/adapters/src/acs/guardian/example_guardian.py b/adapters/src/acs/guardian/example_guardian.py index 515b4fd0..241211d3 100755 --- a/adapters/src/acs/guardian/example_guardian.py +++ b/adapters/src/acs/guardian/example_guardian.py @@ -1,58 +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_HMAC_KEY_ID The label that names the secret (§10), the same one - the adapters send. A request under another label - gets an unsigned SIGNATURE_INVALID. Required with a - secret. - 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 @@ -69,7 +16,7 @@ from pathlib import Path from typing import Any -from acs.client import audit_event, load_hmac_secret, load_key_id, 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, @@ -136,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", @@ -425,12 +363,6 @@ def check_skew(timestamp: str) -> None: def check_signature(envelope: dict, session_id: str) -> None: - if not _hmac_secret(): - return # local-dev mode - signature = (envelope.get("params") or {}).get("signature") - if isinstance(signature, dict) and signature.get("key_id") != load_key_id(): - # §10: reject an unknown key_id without trying another secret. - raise GuardianError(-32004, "SIGNATURE_INVALID: unknown key_id", signed=False) if not verify_signature(envelope, session_id=session_id): raise GuardianError(-32004, "SIGNATURE_INVALID") @@ -472,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 @@ -579,7 +511,7 @@ def _error_envelope(request_id, session_id: str, code: int, message: str, *, sig derivable; earlier failures surface as HTTP refusals in do_POST.""" envelope = {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}} - if signed and _hmac_secret() and session_id: + if signed and session_id: sign_envelope(envelope, session_id=session_id) return envelope @@ -593,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)) @@ -606,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 + @@ -624,8 +560,7 @@ def handle_request(request: dict) -> dict: except GuardianError as e: 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 @@ -646,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 @@ -729,32 +663,19 @@ 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=