diff --git a/adapters/README.md b/adapters/README.md index 951cb7af..fa117d4c 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -172,14 +172,14 @@ Notice the shape: `acs_version` / `request_id` / `timestamp` / `metadata` live i **What this costs, measured honestly.** For the shell-spawned adapters (Claude Code, Cursor), the dominant cost is NOT the Guardian round-trip — it's the per-event Python process spawn: interpreter start plus imports. Measured on an M-series MacBook (warm cache, local Guardian): ~60–90 ms per hook event end-to-end, of which the Guardian's policy evaluation is single-digit milliseconds. Your numbers will differ with hardware and Python version; measure with `/usr/bin/time` before quoting any. The first event of a session additionally pays the `handshake/hello` round-trip (cached afterward). NAT's adapter runs in-process and pays no spawn cost — its overhead is the HTTP round-trip alone. -**Degraded-Guardian behavior.** The first event of a session handshakes, and the answer decides the whole session (§4.1). A Guardian that refuses (`SESSION_REFUSED`, `UNSUPPORTED_VERSION`, `PROVENANCE_REQUIRED`) refuses every step of that session. When no answer arrives within `ACS_HANDSHAKE_TIMEOUT_SECONDS` (default 5s), the session has no negotiated Guardian for its lifetime, and each hook's `ACS_STARTUP_POSTURE` decides: `proceed` lets the step run, audited, with nothing sent to the Guardian, and `refuse` denies it; unset, it follows `ACS_DEFAULT_DENY`. A dead Guardian costs one timeout per session, and the next session handshakes again. **Incident procedure:** set `ACS_DISABLED=1` in the adapter's environment to bypass all hooks immediately (one stderr line per event, no Guardian traffic); unset to re-enable. This is faster and more reversible than editing hook config files per machine mid-incident. +**Degraded-Guardian behavior.** The first event of a session handshakes, and the answer decides the whole session (§4.1). A Guardian that refuses (`SESSION_REFUSED`, `UNSUPPORTED_VERSION`, `PROVENANCE_REQUIRED`) refuses every step of that session. When no answer arrives within `ACS_HANDSHAKE_TIMEOUT_SECONDS` (default 5s), the session has no negotiated Guardian for its lifetime, and each hook's `ACS_STARTUP_POSTURE` decides: `proceed` lets the step run, audited, with nothing sent to the Guardian, and `refuse`, the default, denies it. With a negotiated Guardian, a step that gets no usable decision is denied unless the hook's own environment sets `ACS_DEFAULT_DENY=0`, and a ServerHello that declares `on_decision_failure: deny` overrides that opt-in, so the Guardian can only tighten the posture. A dead Guardian costs one timeout per session, and the next session handshakes again. **Incident procedure:** set `ACS_DISABLED=1` in the adapter's environment to bypass all hooks immediately (one stderr line per event, no Guardian traffic); unset to re-enable. This is faster and more reversible than editing hook config files per machine mid-incident. ### DENY path differs only in steps 4–6 Same as above, but with `command: "rm -rf /home/u"`: - **Step 4:** Guardian returns `{"decision": "deny", "reasoning": "destructive Bash pattern in: rm -rf /home/u"}` -- **Step 5:** Adapter emits `{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "destructive Bash pattern..."}}` +- **Step 5:** Adapter emits `{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "destructive Bash pattern in: rm -rf /home/u", "additionalContext": "ACS blocked this tool call: destructive Bash pattern in: rm -rf /home/u"}, "systemMessage": "ACS blocked this tool call: destructive Bash pattern in: rm -rf /home/u"}` - **Step 6:** Claude Code reads `permissionDecision: "deny"`, does not execute the Bash tool, and surfaces the reason: *"The command was blocked: a policy denied the Bash tool call, so it never ran."* ### What changes across the three adapters @@ -246,7 +246,7 @@ Installing the package (`uv tool install ./adapters`, or `pip install ./adapters 1. Add `docs//` with a README and a `mapping.md` documenting how the framework's hook events map to ACS `steps/*` methods, and how the framework's response shape relates to ACS dispositions. 2. (Optional but encouraged) Write the adapter in `src/acs/integrations//`, with tests in `tests/integrations//`. The Claude Code adapter is the template. -3. For a hook command, add a standard-library-only `hook.py` that calls `acs.integrations._bootstrap.run_hook` (see `claude_code/hook.py`), and install that as the command, so an adapter whose dependencies fail to import still writes an audit record and applies `ACS_DEFAULT_DENY`. +3. For a hook command, add a standard-library-only `hook.py` that calls `acs.integrations._bootstrap.run_hook` (see `claude_code/hook.py`), and install that as the command, so an adapter whose dependencies fail to import still writes an audit record and denies the step unless the hook sets both `ACS_DEFAULT_DENY=0` and `ACS_STARTUP_POSTURE=proceed`. 4. Register it in `pyproject.toml`: an entry in the `acs.integrations` entry-point group, and its commands under `[project.scripts]`. An integration in another distribution registers in the same group. 5. Add its suite to `PLATFORM_SUITES` in `run_conformance.py`, so `run_conformance.py ` runs it. 6. Open a PR against `GenAI-Security-Project/agent-control-standard`. diff --git a/adapters/docs/claude_code/README.md b/adapters/docs/claude_code/README.md index 83d4ce39..fe141f25 100644 --- a/adapters/docs/claude_code/README.md +++ b/adapters/docs/claude_code/README.md @@ -83,16 +83,16 @@ What it wires by default: | Hook | Posture | |---|---| -| `PreToolUse` | **fail-CLOSED** (gate — silent fail-open is a policy hole) | -| `UserPromptSubmit` | **fail-CLOSED** (gate) | -| `SessionStart` | fail-open (§6.4 default); a Guardian `deny` refuses the session | -| `PostToolUse` | fail-open | -| `PostToolUseFailure` | fail-open; sends the failed tool's error text | -| `Stop` | fail-open; sends the reply (`agentResponse`) and closes the turn | -| `StopFailure` | fail-open; closes the turn with outcome `error` | -| `SessionEnd` | fail-open; `timeout: 15`, since SessionEnd hooks otherwise share a 1.5 s budget | +| `PreToolUse` | **fail-CLOSED** (gate): the tool call is denied | +| `UserPromptSubmit` | **fail-CLOSED** (gate): the prompt is blocked | +| `SessionStart` | the posture; a Guardian `deny` refuses the session | +| `PostToolUse` | the posture; under `deny` the output is withheld | +| `PostToolUseFailure` | the posture; sends the failed tool's error text | +| `Stop` | the posture, recorded only, since Claude Code reads a block on `Stop` as "keep going"; sends the reply (`agentResponse`) and closes the turn | +| `StopFailure` | the posture, recorded only; closes the turn with outcome `error` | +| `SessionEnd` | the posture, recorded only; `timeout: 15`, since SessionEnd hooks otherwise share a 1.5 s budget | -Override with `--default-deny` (fail-closed on every hook) or `--all-fail-open` (strict §6.4 default everywhere). A fail-closed `PreToolUse` or `UserPromptSubmit` hook also carries `"onFailure": "block"`, so Claude Code v2.1.295 or later blocks the tool call or prompt when the hook itself crashes, times out or cannot start. On the other hooks a blocking failure stops nothing, since the action has already happened, so the wire tool leaves the field out there. +The posture is what a step does when no usable decision arrives (§6.4): deny, unless the hook's own environment sets `ACS_DEFAULT_DENY=0`. A ServerHello that declares `on_decision_failure: deny` overrides that opt-in, so the Guardian can only tighten the posture. A gate hook's command carries `ACS_DEFAULT_DENY=1`, so an `ACS_DEFAULT_DENY=0` inherited from Claude Code's environment leaves it closed. In a session whose handshake gets no answer, every hook refuses its step unless it sets `ACS_STARTUP_POSTURE=proceed` (§4.1). Override with `--default-deny` (`ACS_DEFAULT_DENY=1` on every hook) or `--all-fail-open` (`ACS_DEFAULT_DENY=0` and `ACS_STARTUP_POSTURE=proceed` on every hook, gates included, so a step with no usable decision proceeds, audited, unless the Guardian declares `deny`). A fail-closed `PreToolUse` or `UserPromptSubmit` hook also carries `"onFailure": "block"`, so Claude Code v2.1.295 or later blocks the tool call or prompt when the hook itself crashes, times out or cannot start. On the other hooks a blocking failure stops nothing, since the action has already happened, so the wire tool leaves the field out there. To remove the wiring later: `acs-claude-code-wire --unwire --write`. @@ -201,7 +201,7 @@ Expected: the Guardian answers the handshake with `-32004 SIGNATURE_INVALID`, si ``` 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)"}} +{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "ACS adapter: session refused (error_signature_invalid)", "additionalContext": "ACS blocked this tool call: ACS adapter: session refused (error_signature_invalid)"}, "systemMessage": "ACS blocked this tool call: ACS adapter: session refused (error_signature_invalid)"} ``` Guardian unreachable, with the Guardian's secret and label: @@ -214,14 +214,15 @@ acs-claude-code-hook 2>&1 <<'EOF' EOF ``` -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: +Expected: the handshake gets no answer, so the session has no negotiated Guardian, and the hook's startup posture settles the step (`refuse` unless the hook sets `ACS_STARTUP_POSTURE=proceed`); stdout carries the deny: ``` -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", ...} +ACS_AUDIT {"acs_audit_event": "session_without_negotiated_guardian", "cause": "transport_failure", "failure_class": "handshake_failure", "posture": "fail_closed", ...} +ACS_AUDIT {"acs_audit_event": "session_not_started_fail_closed", "cause": "transport_failure", ..., "hook": "PreToolUse", ...} ``` +{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "ACS adapter: session not started (transport_failure)", "additionalContext": "ACS blocked this tool call: ACS adapter: session not started (transport_failure)"}, "systemMessage": "ACS blocked this tool call: ACS adapter: session not started (transport_failure)"} -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`. +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 (`session_not_started_fail_closed`, or `unguarded_session_bypass` under `ACS_STARTUP_POSTURE=proceed`). 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) @@ -267,14 +268,14 @@ The adapter is configured by environment variables, typically set per-hook by `a | `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_DEFAULT_DENY` | (unset) | Unset, or any value but `0`: a step with no usable decision is denied. `0`: it proceeds, audited, unless the ServerHello declares `on_decision_failure: deny` (most-restrictive-wins); this is the only setting that opts into proceed. Guardian refusals (bad signature, replay, malformed/oversized envelope) always fail closed regardless of this setting — see spec issue #32. | +| `ACS_STARTUP_POSTURE` | `refuse` | `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. | | `ACS_HANDSHAKE` | `1` | Set to `0` to skip the handshake; the session then counts as unanswered, and `ACS_STARTUP_POSTURE` settles every step. | | `ACS_AGENT_ID` | derived from `CLAUDE_PROJECT_DIR`, or the session's first `cwd` | Stable agent identifier sent in `metadata.agent_id`. | | `ACS_HANDSHAKE_CACHE` | `~/.cache/acs-adapter-handshake/` | Each session's handshake decision, kept for the session's lifetime. | | `ACS_HANDSHAKE_TIMEOUT_SECONDS` | `5` | Deadline for the whole handshake/hello exchange, including a response that arrives slowly. A value that is not a positive number is replaced by `5` and recorded as `invalid_setting`. | -| `ACS_AUDIT_FILE` | (unset) | Append every `ACS_AUDIT` event to this file (created 0600) in addition to stderr — the durable audit half of §6.4's fail-open trade. | +| `ACS_AUDIT_FILE` | (unset) | Append every `ACS_AUDIT` event to this file (created 0600) in addition to stderr — the durable record of every decision failure (§6.4). | | `ACS_DISABLED` | (unset) | `1` = incident kill switch: bypass all hooks immediately, no Guardian traffic. | | `ACS_GUARDIAN_HOST_ALLOWLIST` | (unset) | Optional comma-separated hostname allowlist (defense in depth). | @@ -298,7 +299,7 @@ Honest, item-by-item against the ACS-Core baseline in `docs/spec/conformance.md` | SessionContext + published `chain_hash` | ✓ session_id propagated; Guardian computes rolling SHA-256 chain per §8.2 (`tests/conformance/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 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. | +| Decision honoring (§6.4) | ✓ adapter blocks on subprocess return; every decision failure emits a structured `ACS_AUDIT` event under either posture; 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. | | Wrapped MCP `protocols/MCP/*` | ⚠ partial. ACS-Core requires `protocols/MCP/*` for any session that involves MCP, and MCP `tools/call` MAY instead flow through the generic tool hooks. The adapter covers only that case: Claude Code surfaces MCP tool calls as `mcp__*` tools, and the adapter sends them as `steps/toolCallRequest` and `steps/toolCallResult`. It wraps no other MCP surface (`initialize`, `prompts/get`, `resources/read`, notifications), so a session that uses MCP falls short of ACS-Core there. The first MCP tool a signed session uses records `profile_claim_degraded` once. Known limitation. | @@ -308,7 +309,7 @@ Honest, item-by-item against the ACS-Core baseline in `docs/spec/conformance.md` | Symptom | Likely cause | |---|---| | Adapter exits 0 with empty stdout; Guardian terminal silent | Adapter pointed at the wrong URL (check `ACS_GUARDIAN_URL` in your settings.json); check stderr for `ACS_AUDIT cause=transport_failure`. | -| Every hook gets denied | Likely `ACS_DEFAULT_DENY=1` + Guardian down. Check the Guardian process is running. | +| Every hook gets denied | Likely the Guardian is down: a session whose handshake gets no answer refuses its steps unless `ACS_STARTUP_POSTURE=proceed`, and a step with no usable decision is denied unless the hook sets `ACS_DEFAULT_DENY=0`. Check the Guardian process is running. | | Adapter says `SecretFilePermissionsError` | The HMAC secret file is mode > 0600. `chmod 600 ~/.acs/hmac.key`. | | Guardian returns `-32004 SIGNATURE_INVALID` | Adapter and Guardian aren't reading the same secret. `cat ~/.acs/hmac.key` on both sides should match. | | Guardian returns `-32006 TIMESTAMP_OUT_OF_WINDOW` | Clock skew between adapter and Guardian > 5 minutes. Sync time (`sudo sntp -sS time.apple.com` on macOS). | diff --git a/adapters/docs/claude_code/example_payloads.md b/adapters/docs/claude_code/example_payloads.md index f534c898..b3117e37 100644 --- a/adapters/docs/claude_code/example_payloads.md +++ b/adapters/docs/claude_code/example_payloads.md @@ -106,8 +106,10 @@ Nothing on stdout. Claude Code's own permission rules and prompt then decide; a "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", - "permissionDecisionReason": "destructive Bash pattern in: rm -rf /home/u" - } + "permissionDecisionReason": "destructive Bash pattern in: rm -rf /home/u", + "additionalContext": "ACS blocked this tool call: destructive Bash pattern in: rm -rf /home/u" + }, + "systemMessage": "ACS blocked this tool call: destructive Bash pattern in: rm -rf /home/u" } ``` diff --git a/adapters/docs/claude_code/mapping.md b/adapters/docs/claude_code/mapping.md index 88ff9178..b758de55 100644 --- a/adapters/docs/claude_code/mapping.md +++ b/adapters/docs/claude_code/mapping.md @@ -31,7 +31,7 @@ Block-shape hooks are `UserPromptSubmit`, where a block stops the prompt, and `P | ACS disposition | Claude Code response on `PreToolUse` | Response on `PostToolUse` / `UserPromptSubmit` | |---|---|---| | `allow` | empty: Claude Code's own permission rules and prompt still decide (a `permissionDecision` of `"allow"` would skip the prompt) | empty (Claude Code proceeds) | -| `deny` | `{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "…"}}` | `UserPromptSubmit`: `{"decision": "block", "reason": "..."}`. `PostToolUse`: the block plus `updatedToolOutput` holding a withheld notice (see the mapping table) | +| `deny` | `{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "…", "additionalContext": "ACS blocked this tool call: …"}}` | `UserPromptSubmit`: `{"decision": "block", "reason": "..."}`. `PostToolUse`: the block plus `updatedToolOutput` holding a withheld notice (see the mapping table) | | `modify` | `…"permissionDecision": "ask", "updatedInput": {…}`: the user sees and approves the rewritten call. The `parameter_overrides` are MERGED onto the original input (Claude Code replaces the whole input, so sending overrides alone would drop other arguments); substituted to `deny` if there is no usable object | `PostToolUse`: `updatedToolOutput` holding `modified_content` for `Bash` and MCP tools; withheld like a `deny` when there is none or the tool is another built-in, where the adapter cannot tell which field the content replaces | | `ask` | `…"permissionDecision": "ask"` — **native**; Claude Code surfaces its own approval prompt. Auto mode runs `SubagentHandback` with no prompt, so there an ASK is a `deny`, audited as `unenforceable_decision`, and a MODIFY is `"allow"` with `updatedInput`, so the rewritten report is what is delivered | `PostToolUse`: withheld like a `deny`, since the tool already ran; `UserPromptSubmit`: substituted to `block` | | `defer` | substituted to `deny` + `defer_substituted_deny` audit event: Claude Code's own `"defer"` ends the turn for a later resume and works only in non-interactive `-p` runs | `PostToolUse`: withheld like a `deny`; `UserPromptSubmit`: substituted to `block` | @@ -46,14 +46,14 @@ Claude Code can run the hooks of parallel tool calls at the same time. The first The Claude Code adapter covers ACS-Core's mandatory floor as listed below. This is the floor the tests check, not a full ACS-Core conformance claim (see `adapters/README.md`). Every row below describes what the code in `adapter.py` actually does — this table drifted from the code once (PR #22 review caught six false claims in it), so treat the code as ground truth and this table as its description: -- Handshake: `handshake/hello` is performed on the first hook event of a session, and its answer decides the session (§4.1): a verified ServerHello negotiates; a refusal, or an answer that fails its checks, refuses every step; and no answer leaves the session with no negotiated Guardian, so each hook's `ACS_STARTUP_POSTURE` applies (`proceed` lets the step run with nothing sent to the Guardian; `refuse` denies it). The decision is stored in `~/.cache/acs-adapter-handshake/` with an HMAC under the session key over the decision, the session and the Guardian URL, and holds for the session's lifetime, so a dead Guardian costs one timeout per session; a record that fails its checks, or a decision that cannot be stored, refuses the session. The ServerHello's response envelope is signature-verified and bound to the ClientHello before it is stored; its `on_decision_failure` combines with `ACS_DEFAULT_DENY` (deny wins over proceed), and its `timeout_config` replaces the local decision timeout, per method where `per_method_ms` names one (§6.4). The ServerHello must answer the ClientHello: a `negotiated_version` sharing a major version the adapter offered, a `methods_evaluated` list, the one transport the adapter offers (its Guardian URL's scheme), a positive `timeout_config.default_ms`, an `on_decision_failure` of `proceed` or `deny`, and only profiles the adapter claimed; otherwise the session has no negotiated Guardian (`invalid_server_hello`). Its `methods_evaluated` bounds what is sent: a method the Guardian omits resolves as ALLOW-by-default without being sent (handshake.json) and is recorded as `method_not_evaluated`; the hook returns no output, so Claude Code's own permission flow still applies, and a result never cites a request that was not sent. With no negotiated list, every mapped hook is sent. `ACS_HANDSHAKE=0` skips the handshake, and the startup posture settles every step. +- 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 (`refuse`, the default, denies the step; `proceed` lets it run with nothing sent to the Guardian). 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`, `StopFailure` with outcome `error`, and the next `UserPromptSubmit` with outcome `interrupted` when neither ran. A turnStart the Guardian does not allow blocks the prompt and is closed at once with outcome `denied_at_start`. 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. - Subagent reports: on Claude Code 2.1.271 or later, a subagent in auto mode or a `-p` run hands its report back through the `SubagentHandback` tool. Its `Agent` result then carries only a short note, and the report reaches the Guardian twice: as the `message` of the `SubagentHandback` call (`steps/toolCallRequest`, which a deny stops) and, once delivered, as a `steps/userMessage` of the main session. A Guardian that keeps a report from the main agent denies the `SubagentHandback` call; a deny on the `Agent` result withholds only the note (live check, Claude Code 2.1.317, 2026-11-03). - Cursor: Cursor runs the Claude Code hooks it imports from `.claude/settings.json` with its own events, which carry `cursor_version`; the hook hands those to the Cursor adapter, so Cursor gets Cursor's answer shapes (live check, Cursor 3.17.8, 2026-11-03). -- What the user sees: `permissionDecisionReason` and a block's `reason` reach only Claude, so every answer that blocks a step or withholds a result also carries `systemMessage`, which Claude Code shows the user (hooks reference, JSON output). +- What the user sees: `permissionDecisionReason` and a block's `reason` reach only Claude, so every answer that blocks a step or withholds a result also carries `systemMessage`, which Claude Code shows the user (hooks reference, JSON output). A `PreToolUse` deny also carries the reason in `additionalContext`: when several `PreToolUse` hooks deny, Claude reads only the `permissionDecisionReason` of the one that finishes last, and `additionalContext` reaches Claude either way (checked live with Claude Code 2.1.318 and a second project hook that denies). - Dispositions: on `PreToolUse`, ALLOW prints nothing, so Claude Code's own permission rules and prompt still decide; DENY and ASK are honored natively (`permissionDecision`); MODIFY is an `ask` carrying `updatedInput` (overrides merged onto the original input) when a usable `parameter_overrides` object is present, so the user approves the rewritten call; DEFER substitutes to deny with an audit event. On `PostToolUse` the tool has already run: a DENY, ASK, DEFER or unusable MODIFY replaces the output Claude sees (see the mapping table) and is recorded as `unenforceable_decision` when the hook carries no output to shape a replacement on. **Lifecycle hooks (`Stop → agentResponse, turnEnd`, `StopFailure`, `SessionEnd`) are observation-only**: Claude Code fires them after the reply / turn / session has completed, so a Guardian `deny` is recorded as `unenforceable_decision`, and a MODIFY as `modify_unsupported`, and neither can undo it. Deployments needing pre-delivery gating place the gate at `UserPromptSubmit` (prompt / turn start) for prompts and `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:` from `CLAUDE_PROJECT_DIR`, or from the `cwd` of the session's first hook, since Claude Code's `cwd` follows Claude's `cd`. Turn state and decision-failure counts are keyed on the session alone. - 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`. An `Agent` result cites none, since hooks.md defines `request_id_ref` as the `request_id` of a `toolCallRequest` and the `Agent` call goes out as `steps/subagentStart`. - 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). Before signing, every step replaces what RFC 8785 cannot encode: a lone surrogate becomes U+FFFD, and an integer past 2^53 - 1, NaN or an infinity becomes a string, recorded as `envelope_normalized`, so the Guardian still sees the step. Two keys that the replacement would turn into one make the envelope unsignable, since the signed view would drop a value. An envelope that still cannot be signed is denied whatever the posture (`envelope_not_signable`) on `PreToolUse`, `UserPromptSubmit` and `PostToolUse`, whose output is withheld; on other hooks it follows the posture. A decision result that fails response-envelope.json's `AcsResult` schema, validated with format checking (for example a missing `type: "final"`, a decision outside the five, a `deny` without `reasoning`, or a `chain_hash` that is not 64 lowercase hex characters), is a `malformed_response` decision failure: an `allow` follows the posture, and any other verdict is denied whatever the posture, since carried out as an allow an unreadable or restrictive verdict could run what the Guardian denied (spec issue #32). Every decision failure is audited under either posture with its `failure_class` (`timeout`, `unreachable`, `malformed_response`, `guardian_error`, `observed_agent_error`, `signature_invalid`, `handshake_failure`, `other`) and `posture`, and `sessionEnd` reports the session's count per class in `summary.decision_failures` (§6.4, #273). A handshake that gets no answer is audited as `handshake_failure`; that session sends the Guardian nothing, `sessionEnd` included (§4.1). -- Audit: every fail-open proceed, refusal, substitution, unevaluated method, and unmapped event emits an `ACS_AUDIT` line to stderr and, when `ACS_AUDIT_FILE` is set, appends to that file (0600). +- Decision honoring: the fail posture is `deny` unless the hook sets `ACS_DEFAULT_DENY=0`, and a ServerHello that declares `on_decision_failure: deny` overrides that opt-in (most-restrictive-wins), so only the hook's own environment opts into proceed. 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). Before signing, every step replaces what RFC 8785 cannot encode: a lone surrogate becomes U+FFFD, and an integer past 2^53 - 1, NaN or an infinity becomes a string, recorded as `envelope_normalized`, so the Guardian still sees the step. Two keys that the replacement would turn into one make the envelope unsignable, since the signed view would drop a value. An envelope that still cannot be signed is denied whatever the posture (`envelope_not_signable`) on `PreToolUse`, `UserPromptSubmit` and `PostToolUse`, whose output is withheld; on other hooks it follows the posture. A decision result that fails response-envelope.json's `AcsResult` schema, validated with format checking (for example a missing `type: "final"`, a decision outside the five, a `deny` without `reasoning`, or a `chain_hash` that is not 64 lowercase hex characters), is a `malformed_response` decision failure: an `allow` follows the posture, and any other verdict is denied whatever the posture, since carried out as an allow an unreadable or restrictive verdict could run what the Guardian denied (spec issue #32). Every decision failure is audited under either posture with its `failure_class` (`timeout`, `unreachable`, `malformed_response`, `guardian_error`, `observed_agent_error`, `signature_invalid`, `handshake_failure`, `other`) and `posture`, and `sessionEnd` reports the session's count per class in `summary.decision_failures` (§6.4, #273). A handshake that gets no answer is audited as `handshake_failure`; that session sends the Guardian nothing, `sessionEnd` included (§4.1). +- Audit: every decision failure, 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 eab72638..59414fc3 100644 --- a/adapters/docs/claude_code/settings.json.example +++ b/adapters/docs/claude_code/settings.json.example @@ -18,13 +18,14 @@ "and give the SAME file to the Guardian.", "", "ACS_AUDIT_FILE: where \u00a76.4 audit events land durably. Hook-process", - "stderr is collected by nothing; this file is the audit half of the", - "fail-open trade.", + "stderr is collected by nothing; this file is the durable record of every", + "decision failure.", "", "ACS_DEFAULT_DENY=1 and onFailure block are set on the GATE hooks (PreToolUse,", - "UserPromptSubmit), the ones whose verdict blocks an action. Observational hooks", - "fail open per the \u00a76.4 default. Incident kill switch: set ACS_DISABLED=1 to", - "bypass all hooks immediately." + "UserPromptSubmit), the ones whose verdict blocks an action. The other hooks", + "fail closed too unless their environment sets ACS_DEFAULT_DENY=0, and a", + "session whose handshake gets no answer refuses its steps. Incident kill", + "switch: set ACS_DISABLED=1 to bypass all hooks immediately." ], "hooks": { "SessionStart": [ diff --git a/adapters/docs/cursor/README.md b/adapters/docs/cursor/README.md index 2373eb02..03468de3 100644 --- a/adapters/docs/cursor/README.md +++ b/adapters/docs/cursor/README.md @@ -100,19 +100,19 @@ What it wires by default: the six unconditional hooks of the ACS-Core minimum se | Cursor event | ACS step method | Posture | |---|---|---| -| `sessionStart` | sessionStart | fail-open (§6.4 default); a Guardian `deny` refuses the session | +| `sessionStart` | sessionStart | the posture; a Guardian `deny` refuses the session | | `beforeSubmitPrompt` | userMessage | **fail-CLOSED** (gate) | | `preToolUse` | toolCallRequest | **fail-CLOSED** (gate) | -| `postToolUse` | toolCallResult | fail-open | -| `postToolUseFailure` | toolCallResult | fail-open | -| `afterAgentResponse` | agentResponse | fail-open | -| `stop` | turnEnd | fail-open | -| `sessionEnd` | sessionEnd | fail-open; `timeout: 15` | +| `postToolUse` | toolCallResult | the posture; under `deny` an MCP tool's output is withheld | +| `postToolUseFailure` | toolCallResult | the posture | +| `afterAgentResponse` | agentResponse | the posture | +| `stop` | turnEnd | the posture | +| `sessionEnd` | sessionEnd | the posture; `timeout: 15` | | `subagentStart` | subagentStart | **fail-CLOSED** (gate. The confused-deputy spawn gate, required by ACS-Core for subagent-capable clients.) | Gate hooks get **both** `ACS_DEFAULT_DENY=1` (our env var) AND `failClosed: true` (Cursor's native flag), and each covers a different failure. `failClosed: true` blocks when the hook process itself fails: it crashes, times out, or returns invalid JSON. `ACS_DEFAULT_DENY` and the negotiated posture decide the failures the adapter handles, such as an unreachable Guardian. Once the adapter decides a step proceeds, it answers `{}` on `preToolUse`, `beforeShellExecution` and `beforeMCPExecution`, which leaves the call to Cursor's own sandbox and approval, and its allow shape on `subagentStart` and `beforeSubmitPrompt`. Each is output, so `failClosed` never turns a decided proceed into a block. An explicit `{"permission": "allow"}` on a tool gate can take the call out of Cursor's sandbox: on 2026-11-03, Cursor 3.17.8 in Allowlist (with Sandbox) mode went straight to an unsandboxed run with it, and kept its sandbox and approval with `{}`. -Override with `--default-deny` (fail-closed on every hook) or `--all-fail-open` (strict §6.4 default everywhere). +The posture is what a step does when no usable decision arrives (§6.4): deny, unless the hook's own environment sets `ACS_DEFAULT_DENY=0`. A ServerHello that declares `on_decision_failure: deny` overrides that opt-in, so the Guardian can only tighten the posture. A gate hook's command carries `ACS_DEFAULT_DENY=1`, so an `ACS_DEFAULT_DENY=0` inherited from Cursor's environment leaves it closed. In a session whose handshake gets no answer, every hook refuses its step unless it sets `ACS_STARTUP_POSTURE=proceed` (§4.1). Override with `--default-deny` (`ACS_DEFAULT_DENY=1` and `failClosed: true` on every hook) or `--all-fail-open` (`ACS_DEFAULT_DENY=0` and `ACS_STARTUP_POSTURE=proceed` on every hook, gates included, so a step with no usable decision proceeds, audited, unless the Guardian declares `deny`). To remove the wiring later: `acs-cursor-wire --unwire --write`. @@ -225,14 +225,15 @@ acs-cursor-hook preToolUse 2>&1 <<'EOF' EOF ``` -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 `{}`, which leaves the call to Cursor: +Expected: the handshake gets no answer, so the session has no negotiated Guardian, and the hook's startup posture settles the step (`refuse` unless the hook sets `ACS_STARTUP_POSTURE=proceed`); stdout carries the deny: ``` -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", ...} +ACS_AUDIT {"acs_audit_event": "session_without_negotiated_guardian", "cause": "transport_failure", "failure_class": "handshake_failure", "posture": "fail_closed", ...} +ACS_AUDIT {"acs_audit_event": "session_not_started_fail_closed", "cause": "transport_failure", ..., "event": "preToolUse", ...} ``` +{"permission": "deny", "user_message": "ACS adapter: session not started (transport_failure)", "agent_message": "ACS adapter: session not started (transport_failure)"} -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`. +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 (`session_not_started_fail_closed`, or `unguarded_session_bypass` under `ACS_STARTUP_POSTURE=proceed`). 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) @@ -295,8 +296,8 @@ The adapter is configured by environment variables, typically set per-hook by `a | `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_DEFAULT_DENY` | (unset) | Unset, or any value but `0`: a step with no usable decision is denied. `0`: it proceeds, audited, unless the ServerHello declares `on_decision_failure: deny` (most-restrictive-wins); this is the only setting that opts into proceed. Guardian refusals (bad signature, replay, malformed/oversized envelope) always fail closed regardless of this setting — see spec issue #32. | +| `ACS_STARTUP_POSTURE` | `refuse` | `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. | | `ACS_HANDSHAKE` | `1` | Set to `0` to skip the handshake; the session then counts as unanswered, and `ACS_STARTUP_POSTURE` settles every step. | | `ACS_AGENT_ID` | derived from cwd | Stable agent identifier sent in `metadata.agent_id`. | @@ -329,7 +330,7 @@ Honest, item-by-item against the ACS-Core baseline in `docs/spec/conformance.md` | 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) | ✓ 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) | +| Decision honoring (§6.4) | ✓ Cursor blocks on permission deny; adapter answers every decided step in Cursor's documented JSON; every decision failure emits an `ACS_AUDIT` event under either posture; audit `cause` field distinguishes failure modes (transport vs signature vs malformed envelope vs replay vs skew) | | Liveness `system/ping` | ⚠ Guardian-side only. ACS-Core says `system/ping` SHOULD be implemented: the Observed Agent declares it in `methods_implemented` and sends it, and the Guardian answers it (§13). The example Guardian answers ping as an always-allow method outside the chain, replay, and signature checks. The adapter does not declare or send `system/ping`, so a deployment that uses it must name an alternative liveness mechanism in its configuration, such as continuous observed hook traffic. Omitting both is non-conformant. | | `nonce` (optional replay field) | ✗ adapter does not emit `nonce`; the envelope field is OPTIONAL in v0.1 | | Wrapped MCP `protocols/MCP/*` | ⚠ partial. ACS-Core requires `protocols/MCP/*` for any session that involves MCP, and MCP `tools/call` MAY instead flow through the generic tool hooks. The adapter covers only that case: it maps `beforeMCPExecution` to `steps/toolCallRequest` and `afterMCPExecution` to `steps/toolCallResult`. It wraps no other MCP surface (`initialize`, `prompts/get`, `resources/read`, notifications), so a session that uses MCP falls short of ACS-Core there. The first MCP tool a session uses records `profile_claim_degraded` once, whether it arrives through the MCP hooks or through `preToolUse` and `postToolUse` as `MCP:`. Known limitation. | @@ -352,7 +353,7 @@ These hooks are emitted only when Cursor's `hooks.json` wires them to the adapte |---|---| | Cursor's agent runs commands as if no Guardian exists | Cursor reads `hooks.json` at startup. **Restart Cursor** after `acs-cursor-wire --write`. | | Adapter exits 0 with empty stdout; Guardian terminal silent | Adapter pointed at the wrong URL (check `ACS_GUARDIAN_URL` in your hooks.json); check stderr for `ACS_AUDIT cause=transport_failure`. | -| Every tool call gets denied | Likely `ACS_DEFAULT_DENY=1` + Guardian down. Check the Guardian process is running. | +| Every tool call gets denied | Likely the Guardian is down: a session whose handshake gets no answer refuses its steps unless `ACS_STARTUP_POSTURE=proceed`, and a step with no usable decision is denied unless the hook sets `ACS_DEFAULT_DENY=0`. Check the Guardian process is running. | | Adapter says `SecretFilePermissionsError` | The HMAC secret file is mode > 0600. `chmod 600 ~/.acs/hmac.key`. | | Guardian returns `-32004 SIGNATURE_INVALID` | Adapter and Guardian aren't reading the same secret. `cat ~/.acs/hmac.key` on both sides should match. | | Guardian returns `-32006 TIMESTAMP_OUT_OF_WINDOW` | Clock skew between adapter and Guardian > 5 minutes. Sync time. | diff --git a/adapters/docs/cursor/example_payloads.md b/adapters/docs/cursor/example_payloads.md index faf84466..21d0ab47 100644 --- a/adapters/docs/cursor/example_payloads.md +++ b/adapters/docs/cursor/example_payloads.md @@ -152,9 +152,7 @@ Cursor's own sandbox and approval then decide the call. ```json { - "permission": "allow", - "updated_input": {"command": "ls -la # sanitized"}, - "user_message": "command sanitized by Guardian" + "updated_input": {"command": "ls -la # sanitized"} } ``` diff --git a/adapters/docs/cursor/mapping.md b/adapters/docs/cursor/mapping.md index b273b9a8..239176da 100644 --- a/adapters/docs/cursor/mapping.md +++ b/adapters/docs/cursor/mapping.md @@ -44,7 +44,7 @@ Cursor's documented response keys are event-specific. The adapter translates ACS | `deny` | `{"permission": "deny", "user_message": reasoning, "agent_message": reasoning}` | Cursor displays user_message in UI; agent_message is fed back to the agent's context. | | `ask` | `{"permission": "ask", "user_message": reasoning, "agent_message": reasoning}`; on `preToolUse`, `{"permission": "deny", ...}` | Cursor documents `ask` as "accepted by the schema but not enforced for preToolUse today", so on `preToolUse` the tool would run: the adapter denies there and audits `unenforceable_decision`. `beforeShellExecution` and `beforeMCPExecution` prompt the user, and `subagentStart` treats `ask` as `deny`. | | `defer` | `{"permission": "ask", ...}`; on `preToolUse`, `{"permission": "deny", ...}` | Deployment substitution (not spec-mandated; §9.2 places substitution on the Guardian): Cursor has no defer, so it asks where Cursor enforces `ask` and denies on `preToolUse`, audited as `unenforceable_decision`. | -| `modify` | `{"permission": "allow", "updated_input": original ⊕ parameter_overrides, "user_message": reasoning}` on `preToolUse` only | `updated_input` REPLACES the whole input, so the adapter MERGES the overrides onto the original arguments. On the other permission events, which have no documented updated-input field, and for an `MCP:` tool, whose `updated_input` Cursor ignores when an HTTP MCP server runs it, a MODIFY is a DENY recorded as `modify_unsupported` with `reason_codes: ["modify_unsupported"]` (§6.5), as are redactions. A contradictory `modifications` object fails closed to deny (§6.3). | +| `modify` | `{"updated_input": original ⊕ parameter_overrides}` on `preToolUse` only | `updated_input` REPLACES the whole input, so the adapter MERGES the overrides onto the original arguments. The answer carries no `permission`, so Cursor keeps its own sandbox and approval for the rewritten call, as with `{}` on allow. Checked live on 2026-11-05 with Cursor 3.28.7 in Allowlist (with Sandbox) mode: the rewritten command ran, sandboxed first and then after approval, and the original never ran. Cursor's chat and approval prompt still show the agent's original command, and Cursor shows `user_message` and `agent_message` only on a deny, so the hook's answer and the Guardian's decision are the record of the rewrite. A deployment that needs the executed action to equal the requested one turns MODIFY off in its Guardian policy, which then answers DENY with `modify_unsupported` (§6.5). On the other permission events, which have no documented updated-input field, and for an `MCP:` tool, whose `updated_input` Cursor ignores when an HTTP MCP server runs it, a MODIFY is a DENY recorded as `modify_unsupported` with `reason_codes: ["modify_unsupported"]` (§6.5), as are redactions. A contradictory `modifications` object fails closed to deny (§6.3). | ### `postToolUse` — the only post-tool event with documented output fields @@ -99,7 +99,7 @@ Cursor runs the hooks of one session at the same time: in Cursor 3.17.8, a new c The Cursor adapter covers ACS-Core's mandatory floor in the same shape as the Claude Code adapter. This is the floor the tests check, not a full ACS-Core conformance claim (see `adapters/README.md`). The floor it covers: -- Handshake: `handshake/hello` performed on the first event of a session, and its answer decides the session (§4.1): a verified ServerHello negotiates; a refusal, or an answer that fails its checks, refuses every step; and no answer leaves the session with no negotiated Guardian, so each hook's `ACS_STARTUP_POSTURE` applies (`proceed` lets the step run with nothing sent to the Guardian; `refuse` denies it). The decision is stored in `~/.cache/acs-adapter-handshake/` with an HMAC under the session key over the decision, the session and the Guardian URL, and holds for the session's lifetime, so a dead Guardian costs one timeout per session; a record that fails its checks, or a decision that cannot be stored, refuses the session. ServerHello signature-verified, bound to the ClientHello, its `on_decision_failure` combines with `ACS_DEFAULT_DENY` (deny wins over proceed), and its `timeout_config` replaces the local decision timeout, per method where `per_method_ms` names one (§6.4). The ServerHello must answer the ClientHello: a `negotiated_version` sharing a major version the adapter offered, a `methods_evaluated` list, the one transport the adapter offers (its Guardian URL's scheme), a positive `timeout_config.default_ms`, an `on_decision_failure` of `proceed` or `deny`, and only profiles the adapter claimed; otherwise the session has no negotiated Guardian (`invalid_server_hello`). Its `methods_evaluated` bounds what is sent: a method the Guardian omits resolves as ALLOW-by-default without being sent (handshake.json), recorded as `method_not_evaluated`; permission events and `beforeSubmitPrompt` return their explicit allow shapes, since `failClosed` reads an empty reply as a failed hook. With no negotiated list, every mapped event is sent. `ACS_HANDSHAKE=0` skips the handshake, and the startup posture settles every step. +- 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 (`refuse`, the default, denies the step; `proceed` lets it run with nothing sent to the Guardian). 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`, the next `beforeSubmitPrompt` with outcome `interrupted` when no `stop` ran, and a turnStart the Guardian does not allow with outcome `denied_at_start`. `turn_id` is held in per-session state and stamped on `metadata.turn_id` of every in-turn step. - What the user sees: a deny carries `user_message` and `agent_message` that name ACS ("ACS blocked this tool call: "), and a blocked prompt's `user_message` does too, so whatever Cursor shows and whatever the model relays says that ACS blocked it (live check, Cursor 3.28.7, 2026-11-03). - Hook taxonomy minimum: all covered, plus many additional Cursor events. `subagentStop`, `beforeReadFile`, `beforeTabFileRead`, `workspaceOpen` deliberately unmapped with documented reasons; unknown events emit an `unmapped_hook_event` audit line rather than going quiet. diff --git a/adapters/docs/guardian/README.md b/adapters/docs/guardian/README.md index 3827693f..90d52079 100644 --- a/adapters/docs/guardian/README.md +++ b/adapters/docs/guardian/README.md @@ -21,7 +21,7 @@ Tool names compare case-insensitively, so `Bash` (Claude Code), `Shell` (Cursor) | Any other `steps/toolCallRequest` | `allow` | | `steps/subagentStart` | `deny`, `subagent_gated` (`subagent_fresh_intent_gated` when `intent_derivation` is `fresh`); `allow` with `ACS_ALLOW_SUBAGENT=1` | | `steps/sessionStart`, `steps/sessionEnd`, `steps/turnStart`, `steps/turnEnd`, `steps/userMessage`, `steps/agentResponse`, `steps/agentTrigger`, `steps/toolCallResult`, `steps/preCompact`, `steps/postCompact`, `steps/subagentStop`, `steps/knowledgeRetrieval`, `steps/memoryStore`, `steps/memoryContextRetrieval` | `allow` | -| `handshake/hello` | a ServerHello (§4) | +| `handshake/hello` | a ServerHello (§4) declaring `on_decision_failure: deny`, or `proceed` with `ACS_ON_DECISION_FAILURE=proceed` | | `system/ping` | `allow` with `{status, echo, server_timestamp}` (§13); unsigned and outside the chain | | Any other method | `deny`, `unknown_method` | diff --git a/adapters/docs/nat/README.md b/adapters/docs/nat/README.md index 04060c79..5f436718 100644 --- a/adapters/docs/nat/README.md +++ b/adapters/docs/nat/README.md @@ -75,14 +75,14 @@ acs-nat-wire \ --write ``` -`acs-nat-wire` walks every attachment point in your YAML and inserts `acs_guardian`: +`acs-nat-wire` finds the ACS middleware by its `_type: acs_guardian`, under whatever name you gave it, and lists that name at every attachment point. With no such middleware it adds one named `acs_guardian`: | YAML location | What `acs-nat-wire` does | |---|---| -| top-level `middleware:` block | adds the `acs_guardian:` definition with your guardian_url, default_deny, timeout_s | -| `workflow.middleware` | prepends `acs_guardian` (policy gate runs before content filters) | -| every `function_groups.*.middleware` | prepends `acs_guardian` | -| every `functions.*.middleware` | prepends `acs_guardian`, creating the list when the function has none | +| top-level `middleware:` block | adds the `acs_guardian:` definition with your guardian_url, default_deny, timeout_s, unless a middleware with `_type: acs_guardian` is already there | +| `workflow.middleware` | prepends that name (policy gate runs before content filters) | +| every `function_groups.*.middleware` | prepends that name | +| every `functions.*.middleware` | prepends that name, creating the list when the function has none | Every line we add carries a `# acs-adapter-wired` marker. Re-running is a no-op. `--unwire --write` removes exactly what we added and nothing else. @@ -186,7 +186,7 @@ cd "$ACS_REPO/adapters" uv run python -m unittest tests.integrations.nat.test_live -v ``` -Five tests spin up the example Guardian (subprocess, not in-process), construct a real NAT middleware invocation, and assert side-effect counters: a benign function executes, a destructive function is denied, write-to-protected-path is denied, fail-closed blocks on Guardian unreachable, fail-open lets the call through with an audit event. `e2e_check.py` (Smoke #3) is the broader assertion surface; `test_live.py` is the minimal unit-style guarantee that the enforcement contract holds. +Six tests spin up the example Guardian (subprocess, not in-process), construct a real NAT middleware invocation, and assert side-effect counters: a benign function executes, a destructive function is denied, write-to-protected-path is denied, `default_deny: true` blocks on Guardian unreachable, an unanswered handshake blocks by default, and `ACS_STARTUP_POSTURE=proceed` lets the call through with an audit event. `e2e_check.py` (Smoke #3) is the broader assertion surface; `test_live.py` is the minimal unit-style guarantee that the enforcement contract holds. ### Smoke #6 — audit-cause differentiation @@ -262,7 +262,7 @@ The shared protocol layer is identical: the Guardian sees the same ACS JSON-RPC |---|---|---| | `_type` | `acs_guardian` (required) | Registers the middleware via NAT's `register_middleware` decorator. | | `guardian_url` | `http://127.0.0.1:8787/acs` | Guardian endpoint. http/https only. | -| `default_deny` | `false` | Fail-open with audit (§6.4 default). Set `true` for fail-closed. The ServerHello's `on_decision_failure: deny` also flips it (most-restrictive-wins). Guardian refusals (bad signature, replay, malformed/oversized envelope) always fail closed regardless — input gate blocks, output gate redacts. See spec issue #32. | +| `default_deny` | `true` | `true`: a call with no usable decision is denied; `acs-nat-wire` writes `true`, and `--all-fail-open` writes `false`. `false`: the call proceeds, audited, unless the ServerHello declares `on_decision_failure: deny` (most-restrictive-wins); this is the only setting that opts into proceed. Guardian refusals (bad signature, replay, malformed/oversized envelope) always fail closed regardless — input gate blocks, output gate redacts. See spec issue #32. | | `timeout_s` | `5.0` | Per-request Guardian round-trip timeout. A ServerHello `timeout_config` replaces it for the session, per method where `per_method_ms` names one. | | `target_function_or_group` | (unset) | Optional metadata label; derives `agent_id` if `ACS_AGENT_ID` env is unset. | @@ -277,7 +277,7 @@ The shared protocol layer is identical: the Guardian sees the same ACS JSON-RPC | `ACS_GUARDIAN_HOST_ALLOWLIST` | (unset) | Optional comma-separated hostname allowlist (defense in depth). | | `ACS_AUDIT_FILE` | (unset) | Append every `ACS_AUDIT` event to this file (created 0600) in addition to stderr. | | `ACS_DISABLED` | (unset) | `1` = incident kill switch: every call passes through with no Guardian traffic and an `acs_disabled_bypass` audit event. Read when NAT builds the middleware, so set it before the workflow starts. | -| `ACS_STARTUP_POSTURE` | follows `default_deny` | `proceed` or `refuse`: what a call does in a session with no negotiated Guardian, because its handshake got no answer (§4.1). `proceed` lets the call run, audited, with nothing sent to the Guardian, until the session ends; `refuse` blocks it. The session keeps no negotiated Guardian for its lifetime. Any other value refuses. Read when NAT builds the middleware. | +| `ACS_STARTUP_POSTURE` | `refuse` | `proceed` or `refuse`: what a call does in a session with no negotiated Guardian, because its handshake got no answer (§4.1). `proceed` lets the call run, audited, with nothing sent to the Guardian, until the session ends; `refuse` blocks it. The session keeps no negotiated Guardian for its lifetime. Any other value refuses. Read when NAT builds the middleware. | | `ACS_REQUIRE_PROFILES` | (unset) | Comma-separated conformance profiles the Guardian must accept, such as `acs-core`. When the session's negotiation leaves one out, this hook's steps are refused; the stored record keeps what the Guardian answered, so each hook applies its own setting. | | `ACS_HANDSHAKE` | `1` | Set to `0` to skip the handshake; the session then counts as unanswered, and `ACS_STARTUP_POSTURE` settles every call. Read when NAT builds the middleware. | @@ -301,7 +301,7 @@ Honest, item-by-item against the ACS-Core baseline in `docs/spec/conformance.md` | SessionContext + published `chain_hash` | ✓ every session's `session_id` is a 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) | ✓ 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 | +| 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`); every decision failure emits an `ACS_AUDIT` event under either posture | | `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. | | Wrapped MCP `protocols/MCP/*` | ✗ not implemented. ACS-Core requires `protocols/MCP/*` for any session that involves MCP, and MCP `tools/call` MAY instead flow through the generic tool hooks. The adapter emits no `protocols/MCP/*` traffic. The function middleware sees an MCP tool only as an ordinary NAT function, so a wired MCP tool call can reach the Guardian only as `steps/toolCallRequest` and `steps/toolCallResult`. This suite does not exercise that path. No other MCP surface is wrapped, so a NAT session that uses MCP falls short of ACS-Core there. Known limitation. | @@ -327,7 +327,7 @@ The adapter works across multiple NAT releases by feature-detecting the block me | `acs-nat-wire --check` keeps reporting gaps for the same function | That function is defined in Python code, not YAML, so acs-nat-wire can't reach it. Move the definition into YAML, or add `acs_guardian` to its `middleware` list in code; until then its calls reach the Guardian neither as a decision nor as an observation. | | `acs-nat-wire` errors out with "ruamel.yaml" missing | Install the `nat` extra: `pip install "$ACS_REPO/adapters[nat]"`. | | NAT runs functions as if no Guardian exists | NAT loaded the workflow before `acs-nat-wire --write` ran. **Restart your NAT process.** | -| Every call gets denied | Likely `default_deny: true` in middleware config + Guardian down. Check the Guardian process is running. | +| Every call gets denied | Likely the Guardian is down: a session whose handshake gets no answer refuses its calls unless `ACS_STARTUP_POSTURE=proceed`, and a call with no usable decision is denied unless the YAML sets `default_deny: false`. Check the Guardian process is running. | | Adapter says `SecretFilePermissionsError` | HMAC secret file is mode > 0600. `chmod 600 ~/.acs/hmac.key`. | | Guardian returns `-32004 SIGNATURE_INVALID` | Adapter and Guardian aren't reading the same secret. `cat ~/.acs/hmac.key` on both sides should match. Audit log shows `cause=signature_invalid_response`. | | Guardian returns `-32005 REPLAY_DETECTED` | Same `request_id` sent twice. Audit log shows `cause=replay_detected_response`. Usually a retry loop bug. | diff --git a/adapters/docs/nat/mapping.md b/adapters/docs/nat/mapping.md index 280d0441..cf08ee46 100644 --- a/adapters/docs/nat/mapping.md +++ b/adapters/docs/nat/mapping.md @@ -100,6 +100,6 @@ The NAT adapter covers ACS-Core's mandatory floor. This is the floor the tests c - Dispositions: ALLOW / DENY / MODIFY supported normatively; ASK / DEFER substituted to DENY with audit (HITL composition is the recommended path). - SessionContext: `session_id` sent on every request: the root Runner's session's, or the middleware's own for a call outside any run. - Replay protection: `request_id` UUID + timestamp. -- Handshake: each session performs `handshake/hello` once, a run's at its start and the middleware's own at its first call outside a run, 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`. Before signing, every step replaces what RFC 8785 cannot encode: a lone surrogate becomes U+FFFD, and an integer past 2^53 - 1, NaN or an infinity becomes a string, recorded as `envelope_normalized`, so the Guardian still sees the step. Two keys that the replacement would turn into one make the envelope unsignable, since the signed view would drop a value. An envelope that still cannot be signed is denied whatever the posture (`envelope_not_signable`) on `pre_invoke` and the workflow input, and its output is withheld on `post_invoke`. Every decision failure is audited under either posture with its `failure_class` (`timeout`, `unreachable`, `malformed_response`, `guardian_error`, `observed_agent_error`, `signature_invalid`, `handshake_failure`, `other`) and `posture`, and each session's `sessionEnd` reports that session's count per class in `summary.decision_failures` (§6.4, #273). A handshake that gets no answer is audited as `handshake_failure`; that session sends the Guardian nothing, `sessionEnd` included (§4.1). +- Handshake: each session performs `handshake/hello` once, a run's at its start and the middleware's own at its first call outside a run, 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` (`refuse` unless set to `proceed`) 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` (default `true`) OR the ServerHello's `on_decision_failure` (most-restrictive-wins), so only the YAML opts into proceed; 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`. Before signing, every step replaces what RFC 8785 cannot encode: a lone surrogate becomes U+FFFD, and an integer past 2^53 - 1, NaN or an infinity becomes a string, recorded as `envelope_normalized`, so the Guardian still sees the step. Two keys that the replacement would turn into one make the envelope unsignable, since the signed view would drop a value. An envelope that still cannot be signed is denied whatever the posture (`envelope_not_signable`) on `pre_invoke` and the workflow input, and its output is withheld on `post_invoke`. Every decision failure is audited under either posture with its `failure_class` (`timeout`, `unreachable`, `malformed_response`, `guardian_error`, `observed_agent_error`, `signature_invalid`, `handshake_failure`, `other`) and `posture`, and each session's `sessionEnd` reports that session's count per class in `summary.decision_failures` (§6.4, #273). A handshake that gets no answer is audited as `handshake_failure`; that session sends the Guardian nothing, `sessionEnd` included (§4.1). - 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/src/acs/client/_config.py b/adapters/src/acs/client/_config.py index 347bdf68..c9d00c20 100644 --- a/adapters/src/acs/client/_config.py +++ b/adapters/src/acs/client/_config.py @@ -120,7 +120,7 @@ class Config: def from_env(cls, env: Mapping[str, str]) -> Config: return cls( guardian_url=env.get("ACS_GUARDIAN_URL", DEFAULT_GUARDIAN_URL), - posture=FailPosture(deny_on_failure=env.get("ACS_DEFAULT_DENY", "0") == "1", + posture=FailPosture(deny_on_failure=env.get("ACS_DEFAULT_DENY") != "0", timeout_s=DEFAULT_TIMEOUT_S), handshake_enabled=env.get("ACS_HANDSHAKE", "1") == "1", disabled=env.get("ACS_DISABLED") == "1", @@ -131,9 +131,7 @@ def from_env(cls, env: Mapping[str, str]) -> Config: @property def startup_proceeds(self) -> bool: - """Whether steps proceed with no negotiated Guardian (§4.1): unset follows the fail posture, "proceed" proceeds, anything else refuses.""" - if self.startup_posture is None: - return not self.posture.deny_on_failure + """Whether steps proceed with no negotiated Guardian (§4.1): only "proceed" proceeds.""" return self.startup_posture == "proceed" def gate(self, handshake: Handshake, method: str | None = None) -> Resolution | None: diff --git a/adapters/src/acs/guardian/example_guardian.py b/adapters/src/acs/guardian/example_guardian.py index 39bc4593..bc790c2d 100755 --- a/adapters/src/acs/guardian/example_guardian.py +++ b/adapters/src/acs/guardian/example_guardian.py @@ -77,6 +77,7 @@ SKEW_WINDOW_MS = int(os.environ.get("ACS_SKEW_WINDOW_MS", str(DEFAULT_SKEW_WINDOW_MS))) ALLOW_SUBAGENT = os.environ.get("ACS_ALLOW_SUBAGENT", "0") == "1" +ON_DECISION_FAILURE = "proceed" if os.environ.get("ACS_ON_DECISION_FAILURE", "").strip().lower() == "proceed" else "deny" PROTECTED_PATH_PREFIXES: tuple[str, ...] = ("/etc/", "/usr/", "/bin/", "/sbin/", "/boot/") @@ -361,7 +362,7 @@ def evaluate_handshake(params: dict, request_id: str) -> dict: "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 + "on_decision_failure": ON_DECISION_FAILURE, "policy_requires_provenance": False, "profiles_accepted": [p for p in client_hello.get("profiles_supported") or [] if p == "acs-core"], } diff --git a/adapters/src/acs/integrations/_bootstrap.py b/adapters/src/acs/integrations/_bootstrap.py index 9687592a..e5e662d8 100644 --- a/adapters/src/acs/integrations/_bootstrap.py +++ b/adapters/src/acs/integrations/_bootstrap.py @@ -17,7 +17,7 @@ def run_hook(module: str, *, event_key: str, event_name: Callable[[str], str], deny: Callable[[str, str], int], allow: Callable[[str], int] = lambda name: 0) -> int: - """Run the adapter's main(): an adapter that cannot load follows ACS_DEFAULT_DENY, one that raises denies, and both are audited.""" + """Run the adapter's main(): an adapter that cannot load follows the startup posture, one that raises denies, and both are audited.""" if sys.argv[1:2] == ["--version"]: # Flags are answered without reading an event, so stdin may stay open. try: @@ -57,7 +57,7 @@ def _settle(event_key: str, name: str, deny: Callable[[str, str], int], allow: C detail="ACS_DISABLED=1 — hook bypassed with no Guardian " "traffic; every step proceeds ungoverned") return allow(name) - deny_on_failure = fail_closed or config.posture.deny_on_failure + deny_on_failure = fail_closed or config.posture.deny_on_failure or not config.startup_proceeds audit_event(audit_type, **{event_key: name}, posture="deny" if deny_on_failure else "proceed", error=f"{type(error).__name__}: {str(error).splitlines()[0] if str(error) else ''}", diff --git a/adapters/src/acs/integrations/claude_code/adapter.py b/adapters/src/acs/integrations/claude_code/adapter.py index 9a8737d9..47f3bb3e 100755 --- a/adapters/src/acs/integrations/claude_code/adapter.py +++ b/adapters/src/acs/integrations/claude_code/adapter.py @@ -138,6 +138,9 @@ def _pretool_response(decision: str, reason: str = "", } if reason: hso["permissionDecisionReason"] = reason + if decision == "deny": + # Claude reads only the last-finishing deny's reason, so another PreToolUse hook's deny would hide this one. + hso["additionalContext"] = f"ACS blocked this tool call: {reason}" if updated_input is not None: hso["updatedInput"] = updated_input return {"hookSpecificOutput": hso} diff --git a/adapters/src/acs/integrations/claude_code/wire.py b/adapters/src/acs/integrations/claude_code/wire.py index c2740518..8b0a023c 100644 --- a/adapters/src/acs/integrations/claude_code/wire.py +++ b/adapters/src/acs/integrations/claude_code/wire.py @@ -39,7 +39,7 @@ --secret-file=~/.acs/hmac.key \\ --hooks=PreToolUse,PostToolUse -# 5. Fail-closed posture (default is fail-open per §6.4) +# 5. ACS_DEFAULT_DENY=1 on every hook, not only the gates acs-claude-code-wire \\ --guardian-url=http://127.0.0.1:8787/acs \\ --secret-file=~/.acs/hmac.key \\ @@ -103,7 +103,7 @@ # Wired by an earlier version; wiring and unwiring both remove them. RETIRED_HOOKS = ["Notification"] -# Hooks that gate an action fail closed by default; the others report what already happened, where §6.4's default is fail-open. +# Hooks that gate an action pin ACS_DEFAULT_DENY=1, so an ACS_DEFAULT_DENY=0 inherited from the environment leaves them closed. GATE_HOOKS = {"PreToolUse", "UserPromptSubmit"} # Embedded in the command, so a wired entry is recognized without parsing it. @@ -129,6 +129,7 @@ def build_command(*, hook_command: str, guardian_url: str, key_id: str | None = None, default_deny: bool, host_allowlist: str | None, + fail_open: bool = False, audit_file: str | None = None) -> str: """The settings.json hook command, with absolute paths, since the hook's shell and working directory vary.""" env_pairs: list[str] = [f"ACS_GUARDIAN_URL={shlex.quote(guardian_url)}"] @@ -141,6 +142,8 @@ def build_command(*, hook_command: str, guardian_url: str, env_pairs.append(f"ACS_HMAC_KEY_ID={shlex.quote(key_id)}") if default_deny: env_pairs.append("ACS_DEFAULT_DENY=1") + elif fail_open: + env_pairs += ["ACS_DEFAULT_DENY=0", "ACS_STARTUP_POSTURE=proceed"] if host_allowlist: env_pairs.append(f"ACS_GUARDIAN_HOST_ALLOWLIST={shlex.quote(host_allowlist)}") if audit_file: @@ -336,17 +339,15 @@ def main(argv: list[str] | None = None) -> int: f"{','.join(ACS_CORE_HOOKS)}).") posture_group = p.add_mutually_exclusive_group() posture_group.add_argument("--default-deny", action="store_true", - help="Force ACS_DEFAULT_DENY=1 (fail-CLOSED) on EVERY wired hook. " - "Default behavior: fail-closed only on gate hooks " - f"({', '.join(sorted(GATE_HOOKS))}) — these block actions " - "until the Guardian decides, so a silent fail-open is a " - "policy hole. Non-gate hooks default to fail-open (matches " - "§6.4 spec default).") + help="Write ACS_DEFAULT_DENY=1 (fail-CLOSED whatever the Guardian declares) on EVERY " + f"wired hook. By default only the gate hooks ({', '.join(sorted(GATE_HOOKS))}) " + "carry it; the others fail closed too unless their environment sets " + "ACS_DEFAULT_DENY=0, and every hook refuses its step in a session whose handshake " + "got no answer.") posture_group.add_argument("--all-fail-open", action="store_true", - help="Force ACS_DEFAULT_DENY=0 (fail-OPEN) on EVERY wired hook, " - "including gates. NOT RECOMMENDED for production — an " - "unreachable Guardian or malformed envelope on a gate hook " - "lets the action run unguarded. Matches strict §6.4 default.") + help="Write ACS_DEFAULT_DENY=0 and ACS_STARTUP_POSTURE=proceed on EVERY wired hook, " + "gates included: a step with no usable decision proceeds, audited, unless the " + "Guardian declares on_decision_failure: deny. NOT RECOMMENDED for production.") p.add_argument("--host-allowlist", default=None, help="Comma-separated hostnames the adapter will accept as Guardian URLs " "(defense in depth against env-var attacks).") @@ -411,6 +412,7 @@ def main(argv: list[str] | None = None) -> int: secret_env=args.secret_env_inline, key_id=args.key_id, default_deny=hook_deny, + fail_open=args.all_fail_open, host_allowlist=args.host_allowlist, audit_file=args.audit_file, ) @@ -433,7 +435,7 @@ def main(argv: list[str] | None = None) -> int: elif hook in GATE_HOOKS: posture = "fail-CLOSED (gate hook default)" else: - posture = "fail-OPEN (observational hook default)" + posture = "fail-CLOSED unless the environment sets ACS_DEFAULT_DENY=0" print(f" {hook:18s} → {posture}") print() diff --git a/adapters/src/acs/integrations/cursor/adapter.py b/adapters/src/acs/integrations/cursor/adapter.py index bfbdd40e..04f7e207 100755 --- a/adapters/src/acs/integrations/cursor/adapter.py +++ b/adapters/src/acs/integrations/cursor/adapter.py @@ -105,16 +105,13 @@ def _emit(payload: dict[str, Any]) -> None: sys.stdout.write("\n") -def _permission_response(decision: str, message: str = "", - updated_input: dict | None = None) -> dict[str, Any]: +def _permission_response(decision: str, message: str = "") -> dict[str, Any]: out: dict[str, Any] = {"permission": decision} if message: if decision == "deny" and not message.startswith("ACS"): message = f"ACS blocked this tool call: {message}" out["user_message"] = message out["agent_message"] = message - if updated_input is not None: - out["updated_input"] = updated_input return out @@ -566,9 +563,9 @@ def _translate_permission(decision: Decision, event_name: str, original_input: d elif not isinstance(overrides, dict): unsupported = "no parameter_overrides object" else: - # updated_input replaces the whole input. + # updated_input replaces the whole input; with no permission key Cursor keeps its sandbox and approval, as with {}. merged = {**original_input, **overrides} - return _permission_response("allow", decision.reasoning, updated_input=merged) + return {"updated_input": merged} audit_modify_unsupported(unsupported, event=event_name, tool=tool_name) return _permission_response("deny", f"MODIFY substituted to DENY: {decision.reasoning}") diff --git a/adapters/src/acs/integrations/cursor/wire.py b/adapters/src/acs/integrations/cursor/wire.py index 10a26687..ac8ebd59 100644 --- a/adapters/src/acs/integrations/cursor/wire.py +++ b/adapters/src/acs/integrations/cursor/wire.py @@ -96,13 +96,13 @@ "sessionEnd", ] -# ACS-Core requires subagentStart of a subagent-capable client; the constant keeps its earlier name because tests import it. -PR21_PROPOSED_HOOKS = [ +# ACS-Core requires subagentStart of a subagent-capable client. +SUBAGENT_HOOKS = [ "subagentStart", ] -DEFAULT_HOOKS = CURRENT_CORE_HOOKS + PR21_PROPOSED_HOOKS +DEFAULT_HOOKS = CURRENT_CORE_HOOKS + SUBAGENT_HOOKS -# Gate hooks get Cursor's failClosed for a crashed hook and ACS_DEFAULT_DENY for a missing decision; the others fail open, the §6.4 default. +# Gate hooks get Cursor's failClosed for a crashed hook and ACS_DEFAULT_DENY=1, so an ACS_DEFAULT_DENY=0 inherited from the environment leaves them closed. GATE_HOOKS = frozenset({ "preToolUse", "beforeSubmitPrompt", "beforeShellExecution", "beforeMCPExecution", "subagentStart", @@ -129,6 +129,7 @@ def build_command(*, hook_command: str, event_name: str, key_id: str | None = None, default_deny: bool, host_allowlist: str | None, + fail_open: bool = False, audit_file: str | None = None) -> str: env_pairs: list[str] = [f"ACS_GUARDIAN_URL={shlex.quote(guardian_url)}"] if secret_file: @@ -139,6 +140,8 @@ def build_command(*, hook_command: str, event_name: str, env_pairs.append(f"ACS_HMAC_KEY_ID={shlex.quote(key_id)}") if default_deny: env_pairs.append("ACS_DEFAULT_DENY=1") + elif fail_open: + env_pairs += ["ACS_DEFAULT_DENY=0", "ACS_STARTUP_POSTURE=proceed"] if host_allowlist: env_pairs.append(f"ACS_GUARDIAN_HOST_ALLOWLIST={shlex.quote(host_allowlist)}") if audit_file: @@ -305,19 +308,20 @@ def main(argv: list[str] | None = None) -> int: help=f"Command the hooks run (default: the absolute path of the installed " f"{HOOK_SCRIPT} console script).") p.add_argument("--hooks", default=",".join(DEFAULT_HOOKS), - help="Comma-separated hook names to wire (default: this " - "branch's ACS-Core minimum plus PR #21's proposed " - f"subagentStart gate: {','.join(DEFAULT_HOOKS)}).") + help="Comma-separated hook names to wire (default: the ACS-Core minimum set, " + "postToolUseFailure, stop, and the subagentStart gate ACS-Core requires of a " + f"subagent-capable client: {','.join(DEFAULT_HOOKS)}).") posture_group = p.add_mutually_exclusive_group() posture_group.add_argument("--default-deny", action="store_true", - help="Force fail-CLOSED on EVERY wired hook (sets BOTH Cursor's " - "native failClosed AND our ACS_DEFAULT_DENY=1). Default behavior: " - "fail-closed only on gate hooks " - f"({', '.join(sorted(GATE_HOOKS & set(DEFAULT_HOOKS)))}).") + help="Write Cursor's failClosed and ACS_DEFAULT_DENY=1 (fail-CLOSED whatever the " + "Guardian declares) on EVERY wired hook. By default only the gate hooks " + f"({', '.join(sorted(GATE_HOOKS & set(DEFAULT_HOOKS)))}) carry them; the others " + "fail closed too unless their environment sets ACS_DEFAULT_DENY=0, and every hook " + "refuses its step in a session whose handshake got no answer.") posture_group.add_argument("--all-fail-open", action="store_true", - help="Force fail-OPEN on EVERY wired hook, including gates. NOT " - "RECOMMENDED — strict §6.4 default but a Guardian outage on a " - "gate hook lets the action run unguarded.") + help="Write ACS_DEFAULT_DENY=0 and ACS_STARTUP_POSTURE=proceed on EVERY wired hook, " + "gates included: a step with no usable decision proceeds, audited, unless the " + "Guardian declares on_decision_failure: deny. NOT RECOMMENDED for production.") p.add_argument("--audit-file", help="Durable ACS_AUDIT sink appended by every hook process " "(created 0600). RECOMMENDED: without it, §6.4 audit " @@ -388,6 +392,7 @@ def main(argv: list[str] | None = None) -> int: secret_env=args.secret_env_inline, key_id=args.key_id, default_deny=hook_deny, + fail_open=args.all_fail_open, host_allowlist=args.host_allowlist, audit_file=args.audit_file, ) @@ -410,7 +415,7 @@ def main(argv: list[str] | None = None) -> int: elif hook in GATE_HOOKS: posture = "fail-CLOSED (gate hook default)" else: - posture = "fail-OPEN (observational hook default)" + posture = "fail-CLOSED unless the environment sets ACS_DEFAULT_DENY=0" print(f" {hook:22s} → {posture}") print() diff --git a/adapters/src/acs/integrations/nat/adapter.py b/adapters/src/acs/integrations/nat/adapter.py index 425433f6..97193c11 100644 --- a/adapters/src/acs/integrations/nat/adapter.py +++ b/adapters/src/acs/integrations/nat/adapter.py @@ -134,8 +134,8 @@ class ACSMiddlewareConfig(FunctionMiddlewareBaseConfig, name="acs_guardian"): # description="ACS Guardian endpoint to POST requests to.", ) default_deny: bool = Field( - default=False, - description="If True, block the call when the Guardian is unreachable, returns malformed responses, or returns an unknown disposition. Default False matches the ACS spec default (§6.4 fail-open with audit event); set True for deployments that prefer fail-closed availability tradeoff.", + default=True, + description="If True, block the call when no usable decision arrives (§6.4). False opts into proceed, unless the ServerHello declares on_decision_failure: deny.", ) timeout_s: float = Field( default=5.0, diff --git a/adapters/src/acs/integrations/nat/wire.py b/adapters/src/acs/integrations/nat/wire.py index b32dd651..e5927424 100644 --- a/adapters/src/acs/integrations/nat/wire.py +++ b/adapters/src/acs/integrations/nat/wire.py @@ -98,10 +98,19 @@ def _line_of(node: Any) -> int | None: return (lc.line or 0) + 1 -def _list_includes_middleware(seq: Any) -> bool: +def _guardian_names(doc: Any) -> list[str]: + """The names under `middleware:` whose `_type` is acs_guardian; NAT lists a middleware by its name, so any name can be ours.""" + mw = doc.get("middleware") if isinstance(doc, (dict, CommentedMap)) else None + if not isinstance(mw, (dict, CommentedMap)): + return [] + return [str(name) for name, definition in mw.items() + if isinstance(definition, (dict, CommentedMap)) and definition.get("_type") == MIDDLEWARE_TYPE] + + +def _list_includes_middleware(seq: Any, names: list[str]) -> bool: if not isinstance(seq, (list, CommentedSeq)): return False - return any(str(item) == MIDDLEWARE_NAME for item in seq) + return any(str(item) in names for item in seq) def find_attachment_points(doc: Any) -> list[Gap]: @@ -112,37 +121,40 @@ def find_attachment_points(doc: Any) -> list[Gap]: return gaps mw_block = doc.get("middleware") - if not isinstance(mw_block, (dict, CommentedMap)) or MIDDLEWARE_NAME not in mw_block: - gaps.append(Gap("error", "middleware_block", "middleware", - _line_of(mw_block) if mw_block is not None else None, - f"top-level `middleware:` block is missing the " - f"`{MIDDLEWARE_NAME}:` definition")) - else: - mw_def = mw_block[MIDDLEWARE_NAME] - if not isinstance(mw_def, (dict, CommentedMap)) or mw_def.get("_type") != MIDDLEWARE_TYPE: + names = _guardian_names(doc) + if not names: + mw_def = mw_block.get(MIDDLEWARE_NAME) if isinstance(mw_block, (dict, CommentedMap)) else None + if mw_def is None: + gaps.append(Gap("error", "middleware_block", "middleware", + _line_of(mw_block) if mw_block is not None else None, + f"top-level `middleware:` block has no middleware with " + f"`_type: {MIDDLEWARE_TYPE}`")) + else: gaps.append(Gap( "error", "config", f"middleware.{MIDDLEWARE_NAME}", _line_of(mw_def), f"`middleware.{MIDDLEWARE_NAME}._type` must be " f"`{MIDDLEWARE_TYPE}` (got {mw_def.get('_type') if isinstance(mw_def, dict) else 'non-mapping'!r})")) + names = [MIDDLEWARE_NAME] + listed = names[0] wf = doc.get("workflow") if isinstance(wf, (dict, CommentedMap)): - if not _list_includes_middleware(wf.get("middleware")): + if not _list_includes_middleware(wf.get("middleware"), names): gaps.append(Gap("error", "workflow", "workflow", _line_of(wf), - f"`workflow.middleware` does not include `{MIDDLEWARE_NAME}` — the agent's top-level execution will not be gated")) + f"`workflow.middleware` does not include `{listed}` — the agent's top-level execution will not be gated")) fgs = doc.get("function_groups") if isinstance(fgs, (dict, CommentedMap)): for name, fg in fgs.items(): if not isinstance(fg, (dict, CommentedMap)): continue - if not _list_includes_middleware(fg.get("middleware")): + if not _list_includes_middleware(fg.get("middleware"), names): gaps.append(Gap( "error", "function_group", f"function_groups.{name}", _line_of(fg), - f"function_group `{name}` does not list `{MIDDLEWARE_NAME}` — every function in this group will bypass the Guardian")) + f"function_group `{name}` does not list `{listed}` — every function in this group will bypass the Guardian")) # 4) Every function: a call to a function, from the workflow or from another # function, runs only that function's own middleware. @@ -151,11 +163,11 @@ def find_attachment_points(doc: Any) -> list[Gap]: for name, fn in funcs.items(): if not isinstance(fn, (dict, CommentedMap)): continue - if not _list_includes_middleware(fn.get("middleware")): + if not _list_includes_middleware(fn.get("middleware"), names): gaps.append(Gap( "error", "function", f"functions.{name}", _line_of(fn), - f"function `{name}` does not list `{MIDDLEWARE_NAME}` — every call to it bypasses the Guardian")) + f"function `{name}` does not list `{listed}` — every call to it bypasses the Guardian")) return gaps @@ -188,52 +200,61 @@ def _ensure_middleware_block(doc: CommentedMap, guardian_url: str, return mutated -def _ensure_middleware_listed(parent: CommentedMap, path_for_log: str) -> bool: +def _ensure_middleware_listed(parent: CommentedMap, listed: str, names: list[str]) -> bool: mw = parent.get("middleware") if mw is None: - new = CommentedSeq([MIDDLEWARE_NAME]) + new = CommentedSeq([listed]) parent["middleware"] = new parent.yaml_add_eol_comment(WIRE_MARKER, key="middleware") return True if isinstance(mw, (list, CommentedSeq)): - if MIDDLEWARE_NAME in [str(x) for x in mw]: + if _list_includes_middleware(mw, names): return False # First, so the policy gate runs before content filters. - mw.insert(0, MIDDLEWARE_NAME) + mw.insert(0, listed) try: mw.yaml_add_eol_comment(WIRE_MARKER, key=0) except (AttributeError, TypeError): pass return True - parent["middleware"] = CommentedSeq([MIDDLEWARE_NAME, mw]) + parent["middleware"] = CommentedSeq([listed, mw]) parent.yaml_add_eol_comment(WIRE_MARKER, key="middleware") return True def install(doc: CommentedMap, *, guardian_url: str, default_deny: bool, timeout_s: float) -> list[str]: + """Raises ValueError when `middleware.acs_guardian` names another middleware, since listing it would gate nothing.""" changes: list[str] = [] - if _ensure_middleware_block(doc, guardian_url, default_deny, timeout_s): - changes.append(f"middleware.{MIDDLEWARE_NAME}: defined ({guardian_url})") + names = _guardian_names(doc) + if not names: + mw = doc.get("middleware") + if isinstance(mw, (dict, CommentedMap)) and MIDDLEWARE_NAME in mw: + raise ValueError(f"`middleware.{MIDDLEWARE_NAME}` is not `_type: {MIDDLEWARE_TYPE}`; " + f"rename it or set its `_type`, then wire again") + if _ensure_middleware_block(doc, guardian_url, default_deny, timeout_s): + changes.append(f"middleware.{MIDDLEWARE_NAME}: defined ({guardian_url})") + names = [MIDDLEWARE_NAME] + listed = names[0] wf = doc.get("workflow") if isinstance(wf, (dict, CommentedMap)): - if _ensure_middleware_listed(wf, "workflow"): - changes.append(f"workflow.middleware: added `{MIDDLEWARE_NAME}`") + if _ensure_middleware_listed(wf, listed, names): + changes.append(f"workflow.middleware: added `{listed}`") fgs = doc.get("function_groups") if isinstance(fgs, (dict, CommentedMap)): for name, fg in fgs.items(): if isinstance(fg, (dict, CommentedMap)): - if _ensure_middleware_listed(fg, f"function_groups.{name}"): - changes.append(f"function_groups.{name}.middleware: added `{MIDDLEWARE_NAME}`") + if _ensure_middleware_listed(fg, listed, names): + changes.append(f"function_groups.{name}.middleware: added `{listed}`") funcs = doc.get("functions") if isinstance(funcs, (dict, CommentedMap)): for name, fn in funcs.items(): if isinstance(fn, (dict, CommentedMap)): - if _ensure_middleware_listed(fn, f"functions.{name}"): - changes.append(f"functions.{name}.middleware: added `{MIDDLEWARE_NAME}`") + if _ensure_middleware_listed(fn, listed, names): + changes.append(f"functions.{name}.middleware: added `{listed}`") return changes @@ -266,6 +287,7 @@ def unwire(text: str) -> tuple[str, list[str]]: if not isinstance(doc, (dict, CommentedMap)): return text, removed + names = _guardian_names(doc) or [MIDDLEWARE_NAME] mw = doc.get("middleware") if isinstance(mw, (dict, CommentedMap)): sub_comment = _eol_comment_text(mw, MIDDLEWARE_NAME) @@ -281,19 +303,19 @@ def unwire(text: str) -> tuple[str, list[str]]: wf = doc.get("workflow") if isinstance(wf, (dict, CommentedMap)): - _strip_listed_middleware(wf, "workflow", removed) + _strip_listed_middleware(wf, "workflow", removed, names) fgs = doc.get("function_groups") if isinstance(fgs, (dict, CommentedMap)): for name, fg in fgs.items(): if isinstance(fg, (dict, CommentedMap)): - _strip_listed_middleware(fg, f"function_groups.{name}", removed) + _strip_listed_middleware(fg, f"function_groups.{name}", removed, names) funcs = doc.get("functions") if isinstance(funcs, (dict, CommentedMap)): for name, fn in funcs.items(): if isinstance(fn, (dict, CommentedMap)): - _strip_listed_middleware(fn, f"functions.{name}", removed) + _strip_listed_middleware(fn, f"functions.{name}", removed, names) buf = io.StringIO() _yaml().dump(doc, buf) @@ -301,7 +323,7 @@ def unwire(text: str) -> tuple[str, list[str]]: def _strip_listed_middleware(parent: CommentedMap, path: str, - removed: list[str]) -> None: + removed: list[str], names: list[str]) -> None: """Remove only entries carrying WIRE_MARKER; hand-wired entries stay.""" mw = parent.get("middleware") if mw is None: @@ -310,28 +332,28 @@ def _strip_listed_middleware(parent: CommentedMap, path: str, # Case A: we created the whole `middleware:` key for this parent if WIRE_MARKER in parent_marker and isinstance(mw, (list, CommentedSeq)): contents = [str(x) for x in mw] - if contents == [MIDDLEWARE_NAME]: + if len(contents) == 1 and contents[0] in names: del parent["middleware"] removed.append(f"{path}.middleware: removed key") return # Or scalar-to-list conversion: list is [acs_guardian, ] - if len(contents) >= 2 and contents[0] == MIDDLEWARE_NAME: + if len(contents) >= 2 and contents[0] in names: del mw[0] - removed.append(f"{path}.middleware: removed inserted `{MIDDLEWARE_NAME}`") + removed.append(f"{path}.middleware: removed inserted `{contents[0]}`") return # Case B: we prepended into an existing list — remove only the # marker-carrying item(s). if isinstance(mw, (list, CommentedSeq)): to_remove: list[int] = [] for i, item in enumerate(mw): - if str(item) != MIDDLEWARE_NAME: + if str(item) not in names: continue item_comment = _eol_comment_text(mw, i) if WIRE_MARKER in item_comment: to_remove.append(i) for i in reversed(to_remove): + removed.append(f"{path}.middleware[{i}]: removed inserted `{mw[i]}`") del mw[i] - removed.append(f"{path}.middleware[{i}]: removed inserted `{MIDDLEWARE_NAME}`") if len(mw) == 0 and WIRE_MARKER in parent_marker: del parent["middleware"] removed.append(f"{path}.middleware: removed empty list") @@ -378,9 +400,17 @@ def main(argv: list[str] | None = None) -> int: help="Path to the workflow.yml to operate on.") p.add_argument("--guardian-url", default="http://127.0.0.1:8787/acs", help="ACS Guardian endpoint (default: http://127.0.0.1:8787/acs).") - p.add_argument("--default-deny", action="store_true", - help="Set the middleware's default_deny: true (fail-closed when " - "Guardian is unreachable). Default false matches §6.4 fail-open + audit.") + posture = p.add_mutually_exclusive_group() + posture.add_argument("--default-deny", action="store_true", + help="Write default_deny: true, the default, in the acs_guardian definition this tool adds: " + "a call with no usable decision is blocked whatever the Guardian declares. An " + "existing middleware with _type: acs_guardian keeps its own default_deny.") + posture.add_argument("--all-fail-open", action="store_true", + help="Write default_deny: false in the acs_guardian definition this tool adds: a call with " + "no usable decision proceeds, audited, unless the Guardian declares " + "on_decision_failure: deny. An existing middleware with _type: acs_guardian keeps its " + "own default_deny. A session whose handshake got no answer proceeds only with " + "ACS_STARTUP_POSTURE=proceed.") p.add_argument("--timeout-s", type=float, default=5.0, help="Per-request Guardian timeout (default 5.0s).") p.add_argument("--write", action="store_true", @@ -426,9 +456,13 @@ def main(argv: list[str] | None = None) -> int: return 0 doc = _yaml().load(before_text) - changes = install(doc, guardian_url=args.guardian_url, - default_deny=args.default_deny, - timeout_s=args.timeout_s) + try: + changes = install(doc, guardian_url=args.guardian_url, + default_deny=not args.all_fail_open, + timeout_s=args.timeout_s) + except ValueError as error: + sys.stderr.write(f"FATAL: {error}\n") + return 2 after_text = render(doc) if not changes: diff --git a/adapters/tests/client/test_config.py b/adapters/tests/client/test_config.py index 7b04fbcd..57a159f1 100644 --- a/adapters/tests/client/test_config.py +++ b/adapters/tests/client/test_config.py @@ -24,6 +24,15 @@ def test_a_server_hello_never_weakens_a_local_deny(self) -> None: with self.subTest(hello=hello): self.assertTrue(DENY.negotiated(hello).deny_on_failure) + def test_only_the_local_config_opts_into_proceed(self) -> None: + """A ServerHello can tighten the posture but never loosen it.""" + table = [({}, "proceed", True), ({}, None, True), ({"ACS_DEFAULT_DENY": "0"}, "proceed", False), + ({"ACS_DEFAULT_DENY": "0"}, None, False), ({"ACS_DEFAULT_DENY": "0"}, "deny", True)] + for env, declared, denies in table: + with self.subTest(env=env, on_decision_failure=declared): + hello = {} if declared is None else {"on_decision_failure": declared} + self.assertIs(Config.from_env(env).posture.negotiated(hello).deny_on_failure, denies) + def test_proceed_stays_proceed_without_a_deny(self) -> None: for hello in ({"on_decision_failure": "proceed"}, {}, {"on_decision_failure": "DENY"}): with self.subTest(hello=hello): @@ -138,7 +147,7 @@ class Gate(unittest.TestCase): def test_gate_table(self) -> None: fields = {"error_code": -32000} - proceed, refuse = Config.from_env({}), Config.from_env({"ACS_STARTUP_POSTURE": "refuse"}) + proceed, refuse = Config.from_env({"ACS_STARTUP_POSTURE": "proceed"}), Config.from_env({}) table = [ (proceed, Negotiated({"negotiated_version": "0.1.0"}), None), (proceed, SessionRefused("session_refused_response", fields), @@ -165,17 +174,17 @@ class FromEnv(unittest.TestCase): def test_defaults(self) -> None: self.assertEqual(Config.from_env({}), Config( guardian_url=DEFAULT_GUARDIAN_URL, - posture=FailPosture(deny_on_failure=False, timeout_s=DEFAULT_TIMEOUT_S), + posture=FailPosture(deny_on_failure=True, timeout_s=DEFAULT_TIMEOUT_S), handshake_enabled=True, disabled=False, startup_posture=None)) def test_startup_posture(self) -> None: - """§4.1: unset follows the local fail posture; anything but proceed refuses.""" - table = [({}, True), ({"ACS_DEFAULT_DENY": "1"}, False), + """§4.1: a session with no negotiated Guardian refuses its steps unless the hook sets proceed.""" + table = [({}, False), ({"ACS_DEFAULT_DENY": "0"}, False), ({"ACS_DEFAULT_DENY": "1"}, False), ({"ACS_STARTUP_POSTURE": "proceed", "ACS_DEFAULT_DENY": "1"}, True), ({"ACS_STARTUP_POSTURE": " Proceed "}, True), ({"ACS_STARTUP_POSTURE": "refuse"}, False), ({"ACS_STARTUP_POSTURE": "procede"}, False), - ({"ACS_STARTUP_POSTURE": ""}, True)] + ({"ACS_STARTUP_POSTURE": ""}, False)] for env, proceeds in table: with self.subTest(env=env): self.assertIs(Config.from_env(env).startup_proceeds, proceeds) @@ -197,14 +206,18 @@ def test_required_profiles(self) -> None: def test_switches_turn_on_only_with_1(self) -> None: for value in ("true", "yes", "0", ""): with self.subTest(value=value): - config = Config.from_env({"ACS_DEFAULT_DENY": value, "ACS_DISABLED": value}) - self.assertFalse(config.posture.deny_on_failure) - self.assertFalse(config.disabled) + self.assertFalse(Config.from_env({"ACS_DISABLED": value}).disabled) + + def test_only_0_opts_out_of_deny(self) -> None: + for value in ("1", "", "false", "no", " 0", "00"): + with self.subTest(value=value): + self.assertTrue(Config.from_env({"ACS_DEFAULT_DENY": value}).posture.deny_on_failure) + self.assertFalse(Config.from_env({"ACS_DEFAULT_DENY": "0"}).posture.deny_on_failure) - @mock.patch.dict(os.environ, {"ACS_DEFAULT_DENY": "1", "ACS_DISABLED": "1"}) + @mock.patch.dict(os.environ, {"ACS_DEFAULT_DENY": "0", "ACS_DISABLED": "1"}) def test_reads_only_the_mapping_it_is_given(self) -> None: config = Config.from_env({}) - self.assertFalse(config.posture.deny_on_failure) + self.assertTrue(config.posture.deny_on_failure) self.assertFalse(config.disabled) diff --git a/adapters/tests/client/test_session.py b/adapters/tests/client/test_session.py index ef3c6aba..abc0bd20 100644 --- a/adapters/tests/client/test_session.py +++ b/adapters/tests/client/test_session.py @@ -276,7 +276,7 @@ def test_the_first_stored_decision_wins(self) -> None: def test_the_start_is_audited_once(self) -> None: event = "session_without_negotiated_guardian" - cases = [({}, "fail_open"), ({"ACS_STARTUP_POSTURE": "refuse"}, "fail_closed")] + cases = [({"ACS_STARTUP_POSTURE": "proceed"}, "fail_open"), ({}, "fail_closed")] for i, (env, posture) in enumerate(cases): with self.subTest(posture): self.audit.reset_mock() @@ -447,8 +447,8 @@ def start(session: Negotiated) -> Resolution: again, _ = self._open(session_id="start-timeout", start=lambda s: self.fail("started twice")) self.assertEqual(first, again) self.assertEqual(first.start_failure["cause"], "decision_timeout") - proceed = Config.from_env({"ACS_GUARDIAN_URL": URL}) - deny = Config.from_env({"ACS_GUARDIAN_URL": URL, "ACS_DEFAULT_DENY": "1"}) + proceed = Config.from_env({"ACS_GUARDIAN_URL": URL, "ACS_DEFAULT_DENY": "0"}) + deny = Config.from_env({"ACS_GUARDIAN_URL": URL}) self.assertIsNone(proceed.gate(again)) self.assertEqual((deny.gate(again).proceed, deny.gate(again).cause), (False, "decision_timeout")) self.assertEqual([c.args[0] for c in self.audit.call_args_list if c.kwargs.get("method") == "steps/sessionStart"], @@ -497,7 +497,7 @@ def fail(session: Negotiated) -> None: self.assertIsInstance(decision, Negotiated) self.assertEqual(starts, []) self.assertEqual([(c.args[0], c.kwargs["error"]) for c in self.audit.call_args_list - if c.kwargs.get("method") == "steps/sessionStart"], [("fail_open_bypass", "boom")]) + if c.kwargs.get("method") == "steps/sessionStart"], [("decision_failure_fail_closed", "boom")]) def test_a_hook_refused_by_its_own_profile_requirement_leaves_the_start_to_a_later_hook(self) -> None: for name, stored_first in (("opens the session", False), ("joins a stored session", True)): @@ -536,7 +536,7 @@ def test_a_start_whose_outcome_is_not_recorded_is_settled_once_as_a_start_withou self.assertEqual(again.start_failure["cause"], "session_start_incomplete") self.assertEqual((later, len(starts), calls, counted), (again, 1, 0, ["observed_agent_error"]), "the start is not repeated, and its failure is settled and counted once") - self.assertIn(("fail_open_bypass", "session_start_incomplete"), + self.assertIn(("decision_failure_fail_closed", "session_start_incomplete"), [(c.args[0], c.kwargs.get("cause")) for c in self.audit.call_args_list]) def test_a_start_that_cannot_be_claimed_sends_nothing(self) -> None: diff --git a/adapters/tests/common/test_guardian_posture.py b/adapters/tests/common/test_guardian_posture.py new file mode 100644 index 00000000..5342e293 --- /dev/null +++ b/adapters/tests/common/test_guardian_posture.py @@ -0,0 +1,39 @@ +"""The example Guardian declares on_decision_failure: deny unless its deployment opts out.""" +from __future__ import annotations + +import os +import subprocess +import sys +import unittest + +from acs.guardian import example_guardian + + +def _declared(value: str | None) -> str: + env = {k: v for k, v in os.environ.items() if k != "ACS_ON_DECISION_FAILURE"} + if value is not None: + env["ACS_ON_DECISION_FAILURE"] = value + run = subprocess.run([sys.executable, "-c", + "from acs.guardian import example_guardian as g; print(g.ON_DECISION_FAILURE)"], + env=env, capture_output=True, text=True, check=True) + return run.stdout.strip() + + +class DeclaredPosture(unittest.TestCase): + def test_the_server_hello_declares_deny(self) -> None: + hello = example_guardian.evaluate_handshake( + {"payload": {"acs_versions_supported": ["0.1.0"], "methods_implemented": ["steps/toolCallRequest"], + "transports_supported": ["http"], "provenance_producer": "none"}}, "req-1") + self.assertEqual(hello["on_decision_failure"], "deny") + + def test_only_proceed_opens_it(self) -> None: + self.assertEqual(_declared(None), "deny") + self.assertEqual(_declared("proceed"), "proceed") + self.assertEqual(_declared(" Proceed "), "proceed") + for value in ("", "deny", "open", "0"): + with self.subTest(value=value): + self.assertEqual(_declared(value), "deny") + + +if __name__ == "__main__": + unittest.main() diff --git a/adapters/tests/common/test_hook_command.py b/adapters/tests/common/test_hook_command.py index c5b99be2..0d65c2ed 100644 --- a/adapters/tests/common/test_hook_command.py +++ b/adapters/tests/common/test_hook_command.py @@ -140,6 +140,32 @@ def test_each_wiring_mode_blocks_exactly_its_fail_closed_hooks(self) -> None: self.assertEqual(self._wire("--all-fail-open")["block"], set()) +class AllFailOpenOpensEveryHook(unittest.TestCase): + """--all-fail-open drops the local floor and lets a session with no negotiated Guardian proceed (§4.1, §6.4).""" + + def _commands(self, module, settings_name: str, script: str, *mode: str) -> list[str]: + with tempfile.TemporaryDirectory() as tmp, contextlib.chdir(tmp), mock.patch.dict(os.environ, {"HOME": tmp}), \ + contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + hook = _executable(Path(tmp) / script) + settings = Path(tmp) / settings_name + module.main(["--guardian-url=http://127.0.0.1:8787/acs", f"--settings={settings}", + f"--hook-command={hook}", "--write", *SECRET_ARGS, *mode]) + return [h["command"] for h in _commands(json.loads(settings.read_text()))] + + def test_only_all_fail_open_writes_an_open_posture(self) -> None: + for module, settings_name, script in ((claude_wire, "settings.json", "acs-claude-code-hook"), + (cursor_wire, "hooks.json", "acs-cursor-hook")): + with self.subTest(module.__name__): + opened = self._commands(module, settings_name, script, "--all-fail-open") + self.assertTrue(opened) + for command in opened: + self.assertIn("ACS_DEFAULT_DENY=0", command) + self.assertIn("ACS_STARTUP_POSTURE=proceed", command) + for command in self._commands(module, settings_name, script): + self.assertNotIn("ACS_STARTUP_POSTURE", command) + self.assertNotIn("ACS_DEFAULT_DENY=0", command) + + class WiredPathsKeepADollarSign(unittest.TestCase): def test_a_literal_dollar_in_a_path_is_written_as_given(self) -> None: for module in (claude_wire, cursor_wire): diff --git a/adapters/tests/common/test_security.py b/adapters/tests/common/test_security.py index f0f479f2..427758eb 100644 --- a/adapters/tests/common/test_security.py +++ b/adapters/tests/common/test_security.py @@ -518,7 +518,7 @@ def _commands(self, script: str, module: str) -> list[tuple[str, list[str]]]: def _run(self, argv: list[str], stdin: str, **env_overrides: str): audit_file = self.tmp / f"audit-{uuid.uuid4().hex}.log" env = os.environ.copy() - for name in ("ACS_DISABLED", "ACS_DEFAULT_DENY"): + for name in ("ACS_DISABLED", "ACS_DEFAULT_DENY", "ACS_STARTUP_POSTURE"): env.pop(name, None) # Shadow first so the broken rfc8785 wins over the installed one. env["PYTHONPATH"] = str(self.shadow) @@ -567,12 +567,24 @@ def event(name: str) -> str: self.assertEqual(json.loads(p.stdout)["decision"], "block") self._unavailable_record(records, "hook", "UserPromptSubmit", "deny") - with self.subTest(form=form, ACS_DEFAULT_DENY="0", hook="PreToolUse"): - p, records = self._run(argv, event("PreToolUse"), ACS_DEFAULT_DENY="0") + with self.subTest(form=form, posture="unset", hook="PreToolUse"): + p, records = self._run(argv, event("PreToolUse")) + self.assertEqual(p.returncode, 0, p.stderr) + self.assertEqual(json.loads(p.stdout)["hookSpecificOutput"]["permissionDecision"], "deny", + "no Guardian can decide a step the adapter cannot send, so it follows the startup posture") + self._unavailable_record(records, "hook", "PreToolUse", "deny") + + with self.subTest(form=form, ACS_STARTUP_POSTURE="proceed", hook="PreToolUse"): + p, records = self._run(argv, event("PreToolUse"), ACS_STARTUP_POSTURE="proceed") + self.assertEqual(json.loads(p.stdout)["hookSpecificOutput"]["permissionDecision"], "deny", + "the startup posture alone leaves a decision failure denied") + self._unavailable_record(records, "hook", "PreToolUse", "deny") + + with self.subTest(form=form, ACS_STARTUP_POSTURE="proceed", ACS_DEFAULT_DENY="0", hook="PreToolUse"): + p, records = self._run(argv, event("PreToolUse"), ACS_STARTUP_POSTURE="proceed", ACS_DEFAULT_DENY="0") self.assertEqual(p.returncode, 0, p.stderr) self.assertEqual(p.stdout.strip(), "", - "fail-open is the §6.4 default, so proceeding is correct " - "here as long as it is audited") + "a deployment that opted into proceed for both postures gets an audited proceed") self._unavailable_record(records, "hook", "PreToolUse", "proceed") with self.subTest(form=form, ACS_DISABLED="1"): @@ -607,8 +619,19 @@ def test_cursor_hook_that_cannot_load_audits_and_honors_the_posture(self) -> Non self.assertIs(json.loads(p.stdout)["continue"], False) self._unavailable_record(records, "event", "beforeSubmitPrompt", "deny") - with self.subTest(form=form, ACS_DEFAULT_DENY="0", event="preToolUse"): - p, records = self._run(argv + ["preToolUse"], event, ACS_DEFAULT_DENY="0") + with self.subTest(form=form, posture="unset", event="preToolUse"): + p, records = self._run(argv + ["preToolUse"], event) + self.assertEqual(p.returncode, 0, p.stderr) + self.assertEqual(json.loads(p.stdout)["permission"], "deny") + self._unavailable_record(records, "event", "preToolUse", "deny") + + with self.subTest(form=form, ACS_STARTUP_POSTURE="proceed", event="preToolUse"): + p, records = self._run(argv + ["preToolUse"], event, ACS_STARTUP_POSTURE="proceed") + self.assertEqual(json.loads(p.stdout)["permission"], "deny") + self._unavailable_record(records, "event", "preToolUse", "deny") + + with self.subTest(form=form, ACS_STARTUP_POSTURE="proceed", ACS_DEFAULT_DENY="0", event="preToolUse"): + p, records = self._run(argv + ["preToolUse"], event, ACS_STARTUP_POSTURE="proceed", ACS_DEFAULT_DENY="0") self.assertEqual(p.returncode, 0, p.stderr) self.assertEqual(p.stdout.strip(), "") self._unavailable_record(records, "event", "preToolUse", "proceed") diff --git a/adapters/tests/common/test_session_gate.py b/adapters/tests/common/test_session_gate.py index f7ab257a..48940dcf 100644 --- a/adapters/tests/common/test_session_gate.py +++ b/adapters/tests/common/test_session_gate.py @@ -78,7 +78,7 @@ def test_an_unguarded_session_never_contacts_a_restored_guardian(self) -> None: sessions = {name: str(uuid.uuid4()) for name in hooks} for name, run in hooks.items(): with self.subTest(name, phase="Guardian down"): - p, denied, audits = run(url, sessions[name]) + p, denied, audits = run(url, sessions[name], ACS_STARTUP_POSTURE="proceed") self.assertFalse(denied, p.stdout) self.assertIn("session_without_negotiated_guardian", audits) self.assertIn("unguarded_session_bypass", audits) @@ -89,7 +89,8 @@ def test_an_unguarded_session_never_contacts_a_restored_guardian(self) -> None: guardian = self._restore(port) self._age_records() # Settings an older adapter read as cache lifetimes; nothing reads them now. - expired = {"ACS_HANDSHAKE_CACHE_TTL_SECONDS": "0", "ACS_HANDSHAKE_FAILURE_CACHE_TTL_SECONDS": "0"} + expired = {"ACS_HANDSHAKE_CACHE_TTL_SECONDS": "0", "ACS_HANDSHAKE_FAILURE_CACHE_TTL_SECONDS": "0", + "ACS_STARTUP_POSTURE": "proceed"} for name, run in hooks.items(): with self.subTest(name, phase="Guardian restored, same session"): p, denied, audits = run(url, sessions[name], **expired) @@ -232,12 +233,12 @@ def _cursor_prompt(self, url: str, session: str, text: str, **extra: str): def test_cursor_startup_proceed_answers_in_each_events_allow_shape(self) -> None: port, url = self._down() session = str(uuid.uuid4()) - p, _, _ = self._cursor(url, session, "beforeSubmitPrompt") + p, _, _ = self._cursor(url, session, "beforeSubmitPrompt", ACS_STARTUP_POSTURE="proceed") self.assertEqual((p.returncode, json.loads(p.stdout)), (0, {"continue": True})) for event_name, allow in (("preToolUse", {}), ("beforeShellExecution", {}), ("beforeMCPExecution", {}), ("subagentStart", {"permission": "allow"})): with self.subTest(event_name): - p, _, audits = self._cursor(url, session, event_name) + p, _, audits = self._cursor(url, session, event_name, ACS_STARTUP_POSTURE="proceed") self.assertEqual(json.loads(p.stdout), allow) self.assertEqual(audits, ["unguarded_session_bypass"]) @@ -477,7 +478,7 @@ def slow_deny(req: dict) -> dict: guardian.start() self.addCleanup(guardian.stop) session = str(uuid.uuid4()) - p, denied, audits = self._claude(guardian.url(), session) + p, denied, audits = self._claude(guardian.url(), session, ACS_DEFAULT_DENY="0") # Under the 5 s default the deny would arrive after 1 s and apply. self.assertFalse(denied, p.stdout) self.assertIn('"cause": "decision_timeout"', p.stderr) @@ -506,12 +507,12 @@ def step(req: dict) -> dict: self.addCleanup(guardian.stop) self.addCleanup(release.set) session = str(uuid.uuid4()) - p, denied, audits = self._claude(guardian.url(), session) + p, denied, audits = self._claude(guardian.url(), session, ACS_DEFAULT_DENY="0") self.assertFalse(denied, p.stdout) self.assertIn("fail_open_bypass", audits) release.set() time.sleep(0.2) - p, denied, audits = self._claude(guardian.url(), session) + p, denied, audits = self._claude(guardian.url(), session, ACS_DEFAULT_DENY="0") self.assertTrue(denied, "the second step reached the Guardian and its deny applied") self.assertEqual(guardian.methods_seen(), ["handshake/hello", "steps/sessionStart", "steps/toolCallRequest", "steps/toolCallRequest"]) @@ -710,13 +711,14 @@ def test_the_session_start_context_reaches_claude_when_another_hook_opened_the_s "hookEventName": "SessionStart", "additionalContext": "context from the Guardian"}}) def test_the_session_start_decision_governs_every_hook_of_the_session(self) -> None: - # sessionStart answer, the negotiated on_decision_failure, and whether the session's tool calls proceed. - cases = {"allow": ("allow", "deny", True), "deny": ("deny", "proceed", False), - "signed refusal": ("refusal", "proceed", False), - "timeout under deny": ("timeout", "deny", False), - "timeout under proceed": ("timeout", "proceed", True)} + # sessionStart answer, the negotiated on_decision_failure, the hook's ACS_DEFAULT_DENY, and whether the session's tool calls proceed. + cases = {"allow": ("allow", "deny", "0", True), "deny": ("deny", "proceed", "0", False), + "signed refusal": ("refusal", "proceed", "0", False), + "timeout under deny": ("timeout", "deny", "0", False), + "timeout under proceed": ("timeout", "proceed", "0", True), + "timeout under a Guardian's proceed alone": ("timeout", "proceed", None, False)} for host, run in (("claude-code", self._claude), ("cursor", self._cursor)): - for name, (answer, on_failure, proceeds) in cases.items(): + for name, (answer, on_failure, local, proceeds) in cases.items(): with self.subTest(host, case=name): guardian = ProgrammableGuardian(hmac_secret=SECRET, threaded=True) @@ -735,7 +737,8 @@ def start(req, answer=answer, allow=guardian._default_allow): self.addCleanup(guardian.stop) session = str(uuid.uuid4()) for process in (1, 2): - p, denied, audits = run(guardian.url(), session) + p, denied, audits = run(guardian.url(), session, + **({} if local is None else {"ACS_DEFAULT_DENY": local})) self.assertEqual(denied, not proceeds, (process, p.stdout, audits)) self.assertEqual(guardian.methods_seen(), ["handshake/hello", "steps/sessionStart"] + (["steps/toolCallRequest"] * 2 if proceeds else [])) @@ -842,7 +845,7 @@ def slow_start(req, allow=guardian._default_allow): time.sleep(0.02) first.kill() first.wait(timeout=10) - p, denied, audits = run(guardian.url(), session) + p, denied, audits = run(guardian.url(), session, ACS_DEFAULT_DENY="0") self.assertFalse(denied, p.stdout) self.assertIn("fail_open_bypass", audits) self.assertIn('"cause": "session_start_incomplete"', p.stderr) @@ -1122,7 +1125,8 @@ def test_parallel_startup_hooks_follow_one_unanswered_handshake(self) -> None: for host in self.STARTUP: with self.subTest(host): guardian.reset() - results = self._startup(host, guardian.url(), together=True, ACS_HANDSHAKE_TIMEOUT_SECONDS="1") + results = self._startup(host, guardian.url(), together=True, ACS_HANDSHAKE_TIMEOUT_SECONDS="1", + ACS_STARTUP_POSTURE="proceed") self.assertEqual(guardian.methods_seen(), ["handshake/hello"]) audits = {name: [r["acs_audit_event"] for r in _audit_records(p.stderr)] for name, p in results} self.assertEqual(sum(a.count("session_without_negotiated_guardian") for a in audits.values()), 1, audits) diff --git a/adapters/tests/conformance/test_acs_core_conformance.py b/adapters/tests/conformance/test_acs_core_conformance.py index ee5c3615..f9d127c3 100644 --- a/adapters/tests/conformance/test_acs_core_conformance.py +++ b/adapters/tests/conformance/test_acs_core_conformance.py @@ -698,7 +698,7 @@ def test_unusable_disposition_denies_regardless_of_posture(self) -> None: f"{bad!r} is outside the decision enum, so it is recorded as malformed_response") def test_non_string_and_non_object_verdicts_fail_closed(self) -> None: - """A non-string decision or non-object modifications fails response-envelope.json and denies under the fail-open default (§6.4, issue #32).""" + """A non-string decision or non-object modifications fails response-envelope.json and denies under a Guardian that declares proceed (§6.4, issue #32).""" cases = [ {"decision": 5}, {"decision": {"v": "deny"}}, @@ -747,7 +747,7 @@ def _env(self, g): "ACS_HANDSHAKE_CACHE": tempfile.mkdtemp(), "ACS_SESSION_STATE_DIR": tempfile.mkdtemp()}) env.pop("ACS_HMAC_SECRET_FILE", None) - env.pop("ACS_DEFAULT_DENY", None) # shipped fail-open default on purpose + env.pop("ACS_DEFAULT_DENY", None) # no local floor, so the Guardian's declared proceed applies return env def test_claude_modify_on_prompt_blocks(self) -> None: @@ -864,11 +864,10 @@ def test_modify_overrides_merge_preserves_other_args(self) -> None: "workspace_roots": ["/tmp"], "tool_name": "Bash", "tool_input": tool_input}), capture_output=True, text=True, env=self._env(g), timeout=15) - ui = (json.loads(proc.stdout).get("updated_input", {}) - if proc.stdout.strip() else {}) - self.assertEqual(ui, {"command": "echo safe", "timeout": 5}, - f"cursor MODIFY must MERGE overrides onto the original input; " - f"got updated_input={ui!r}") + out = json.loads(proc.stdout) if proc.stdout.strip() else {} + self.assertEqual(out, {"updated_input": {"command": "echo safe", "timeout": 5}}, + f"cursor MODIFY must MERGE overrides onto the original input and send no permission, " + f"since an explicit allow can take the call out of Cursor's sandbox; got {out!r}") def test_disjoint_structured_targets_are_not_reported_as_overlap(self) -> None: """Prefix matching must not confuse sibling JSON Pointer paths.""" @@ -1343,7 +1342,7 @@ def test_guardian_error_response_carries_distinct_cause(self) -> None: env_a["ACS_HMAC_SECRET"] = "a-secret-this-guardian-does-not-hold-0123456789" env_a["ACS_HMAC_KEY_ID"] = "test-key" env_a.pop("ACS_HMAC_SECRET_FILE", None) - env_a.pop("ACS_DEFAULT_DENY", None) # default fail-open + env_a.pop("ACS_DEFAULT_DENY", None) # no local floor, so the Guardian's declared proceed applies _negotiated(env_a, "00000000-0000-4000-8000-000000000001") proc = subprocess.run( @@ -1499,7 +1498,7 @@ def _run_shell_adapter(self, platform: str, guardian, }) env.pop("ACS_HMAC_SECRET_FILE", None) env.pop("ACS_HANDSHAKE", None) - env.pop("ACS_DEFAULT_DENY", None) + env["ACS_DEFAULT_DENY"] = "0" if platform == "claude": command = [sys.executable, str(INTEGRATIONS_DIR / "claude_code" / "adapter.py")] event = { diff --git a/adapters/tests/integrations/claude_code/test_adapter.py b/adapters/tests/integrations/claude_code/test_adapter.py index 079bbc96..c2b6622f 100644 --- a/adapters/tests/integrations/claude_code/test_adapter.py +++ b/adapters/tests/integrations/claude_code/test_adapter.py @@ -7,6 +7,7 @@ import tempfile import unittest from pathlib import Path +from unittest import mock HERE = Path(__file__).resolve().parent @@ -109,6 +110,24 @@ def test_destructive_bash_denied(self) -> None: self.assertEqual(hso["permissionDecision"], "deny") self.assertIn("destructive", hso["permissionDecisionReason"].lower()) + def test_a_deny_carries_its_reason_to_claude_in_additional_context(self) -> None: + """Claude reads the permissionDecisionReason of the deny that finishes last, so another hook's deny would hide the ACS reason.""" + cases = { + "guardian deny": ({}, "destructive"), + "decision failure": ({"ACS_GUARDIAN_URL": "http://127.0.0.1:1/dead", "ACS_DEFAULT_DENY": "1", + **self._negotiated("http://127.0.0.1:1/dead", "00000000-0000-4000-8000-000000000001")}, + "decision-failure"), + } + for name, (env, expected) in cases.items(): + with self.subTest(name): + rc, out, err = self._run_adapter(_claude_code_event( + "PreToolUse", tool_name="Bash", tool_input={"command": "rm -rf /home/user"}), env_overrides=env) + self.assertEqual(rc, 0, err) + hso = json.loads(out)["hookSpecificOutput"] + self.assertEqual(hso["permissionDecision"], "deny") + self.assertIn(expected, hso["permissionDecisionReason"].lower()) + self.assertEqual(hso["additionalContext"], f"ACS blocked this tool call: {hso['permissionDecisionReason']}") + def test_write_to_protected_path_denied(self) -> None: rc, out, _ = self._run_adapter(_claude_code_event( "PreToolUse", tool_name="Write", @@ -201,13 +220,24 @@ def test_guardian_unreachable_default_deny_posttool(self) -> None: self.assertIn("decision-failure", payload["reason"].lower()) self.assertIn("ACS_AUDIT", err) - def test_guardian_unreachable_fail_open_default_is_audit(self) -> None: + def test_an_unanswered_handshake_refuses_the_step(self) -> None: + with mock.patch.dict(os.environ): + for name in ("ACS_DEFAULT_DENY", "ACS_STARTUP_POSTURE"): + os.environ.pop(name, None) + rc, out, err = self._run_adapter( + _claude_code_event("PreToolUse", tool_name="Read", tool_input={"file_path": "/tmp/x"}), + env_overrides={"ACS_GUARDIAN_URL": "http://127.0.0.1:1/dead"}) + self.assertEqual(rc, 0, err) + self.assertEqual(json.loads(out)["hookSpecificOutput"]["permissionDecision"], "deny") + self.assertIn("session_not_started_fail_closed", err) + + def test_guardian_unreachable_under_a_local_proceed_is_audited(self) -> None: rc, out, err = self._run_adapter( _claude_code_event( "PreToolUse", tool_name="Read", tool_input={"file_path": "/tmp/x"}, ), - env_overrides={"ACS_GUARDIAN_URL": "http://127.0.0.1:1/dead", + env_overrides={"ACS_GUARDIAN_URL": "http://127.0.0.1:1/dead", "ACS_DEFAULT_DENY": "0", **self._negotiated("http://127.0.0.1:1/dead", "00000000-0000-4000-8000-000000000001")}, ) self.assertEqual(rc, 0, err) @@ -216,6 +246,20 @@ def test_guardian_unreachable_fail_open_default_is_audit(self) -> None: self.assertIn("ACS_AUDIT", err, "fail-open MUST emit an audit event per §6.4") self.assertIn("fail_open_bypass", err) + def test_a_guardian_s_declared_proceed_alone_denies(self) -> None: + """The seeded ServerHello declares proceed, and only the hook's own ACS_DEFAULT_DENY=0 can opt into it.""" + env = {k: v for k, v in os.environ.items() if k != "ACS_DEFAULT_DENY"} + with mock.patch.dict(os.environ, env, clear=True): + rc, out, err = self._run_adapter( + _claude_code_event("PreToolUse", tool_name="Read", tool_input={"file_path": "/tmp/x"}), + env_overrides={"ACS_GUARDIAN_URL": "http://127.0.0.1:1/dead", + **self._negotiated("http://127.0.0.1:1/dead", "00000000-0000-4000-8000-000000000001")}, + ) + self.assertEqual(rc, 0, err) + self.assertEqual(json.loads(out)["hookSpecificOutput"]["permissionDecision"], "deny") + self.assertIn("decision_failure_fail_closed", err) + self.assertNotIn("fail_open_bypass", err) + def test_audit_only_hooks_never_emit_a_block(self) -> None: """Claude Code reads a block on Stop as "keep going", so neither a DENY nor a decision failure may produce one.""" for hook in ("Stop",): diff --git a/adapters/tests/integrations/cursor/test_adapter.py b/adapters/tests/integrations/cursor/test_adapter.py index 40919b4f..cfa2c3c7 100644 --- a/adapters/tests/integrations/cursor/test_adapter.py +++ b/adapters/tests/integrations/cursor/test_adapter.py @@ -148,12 +148,23 @@ def test_guardian_unreachable_default_deny_on_permission_event(self) -> None: self.assertIn("ACS_AUDIT", err) self.assertIn("decision_failure_fail_closed", err) - def test_guardian_unreachable_fail_open_default_is_audit(self) -> None: - """§6.4 spec default: fail-open with audit event.""" + def test_an_unanswered_handshake_refuses_the_step(self) -> None: + with mock.patch.dict(os.environ): + for name in ("ACS_DEFAULT_DENY", "ACS_STARTUP_POSTURE"): + os.environ.pop(name, None) + rc, out, err = self._run("preToolUse", {"session_id": "cur-13", "tool_name": "Read", + "tool_input": {"file_path": "/tmp/x"}}, + env_overrides={"ACS_GUARDIAN_URL": "http://127.0.0.1:1/dead"}) + self.assertEqual(rc, 0, err) + self.assertEqual(json.loads(out)["permission"], "deny") + self.assertIn("session_not_started_fail_closed", err) + + def test_guardian_unreachable_under_a_local_proceed_is_audited(self) -> None: + """A step with no decision proceeds, audited (§6.4), when the hook opts into proceed and the Guardian declares no deny.""" rc, out, err = self._run("preToolUse", {"session_id": "cur-11", "tool_name": "Read", "tool_input": {"file_path": "/tmp/x"}}, - env_overrides={"ACS_GUARDIAN_URL": "http://127.0.0.1:1/dead", + env_overrides={"ACS_GUARDIAN_URL": "http://127.0.0.1:1/dead", "ACS_DEFAULT_DENY": "0", **self._negotiated("http://127.0.0.1:1/dead", coerce_uuid("cur-11", namespace_prefix="cursor"))}, ) self.assertEqual(rc, 0) @@ -162,6 +173,20 @@ def test_guardian_unreachable_fail_open_default_is_audit(self) -> None: self.assertIn("ACS_AUDIT", err, "fail-open MUST emit an audit event per §6.4") self.assertIn("fail_open_bypass", err) + def test_a_guardian_s_declared_proceed_alone_denies(self) -> None: + """The seeded ServerHello declares proceed, and only the hook's own ACS_DEFAULT_DENY=0 can opt into it.""" + env = {k: v for k, v in os.environ.items() if k != "ACS_DEFAULT_DENY"} + with mock.patch.dict(os.environ, env, clear=True): + rc, out, err = self._run("preToolUse", + {"session_id": "cur-11b", "tool_name": "Read", "tool_input": {"file_path": "/tmp/x"}}, + env_overrides={"ACS_GUARDIAN_URL": "http://127.0.0.1:1/dead", + **self._negotiated("http://127.0.0.1:1/dead", coerce_uuid("cur-11b", namespace_prefix="cursor"))}, + ) + self.assertEqual(rc, 0) + self.assertEqual(json.loads(out)["permission"], "deny") + self.assertIn("decision_failure_fail_closed", err) + self.assertNotIn("fail_open_bypass", err) + def test_before_submit_prompt_block_with_readable_message(self) -> None: rc, out, err = self._run("beforeSubmitPrompt", {"session_id": "cur-12", "prompt": "anything"}, diff --git a/adapters/tests/integrations/cursor/test_wire.py b/adapters/tests/integrations/cursor/test_wire.py index a8e35f2a..2ed1e31f 100644 --- a/adapters/tests/integrations/cursor/test_wire.py +++ b/adapters/tests/integrations/cursor/test_wire.py @@ -49,7 +49,7 @@ def test_default_covers_current_core_and_proposed_gate(self) -> None: from acs.integrations.cursor import wire self.assertEqual( wire.DEFAULT_HOOKS, - wire.CURRENT_CORE_HOOKS + wire.PR21_PROPOSED_HOOKS) + wire.CURRENT_CORE_HOOKS + wire.SUBAGENT_HOOKS) for hook in wire.DEFAULT_HOOKS: self.assertIn(hook, self.hooks, f"default wiring missing documented hook {hook!r}; " diff --git a/adapters/tests/integrations/nat/test_live.py b/adapters/tests/integrations/nat/test_live.py index 901ad69f..27ac6111 100644 --- a/adapters/tests/integrations/nat/test_live.py +++ b/adapters/tests/integrations/nat/test_live.py @@ -150,6 +150,19 @@ async def target(command: str) -> str: self.assertEqual(executed["count"], 0, "fail-closed: function must not execute when Guardian unreachable") + def test_guardian_unreachable_blocks_the_function(self) -> None: + executed = {"count": 0} + + async def target(command: str) -> str: + executed["count"] += 1 + return "ran" + + mw = ACSMiddleware(ACSMiddlewareConfig(guardian_url="http://127.0.0.1:1/dead", default_deny=False)) + with self.assertRaises(ACSGuardianDenied): + asyncio.run(mw.function_middleware_invoke(command="ls", call_next=target, context=self._ctx("Bash"))) + self.assertEqual(executed["count"], 0) + + @mock.patch.dict(os.environ, {"ACS_STARTUP_POSTURE": "proceed"}) def test_guardian_unreachable_fail_open_runs_function(self) -> None: executed = {"count": 0} diff --git a/adapters/tests/integrations/nat/test_session_gate.py b/adapters/tests/integrations/nat/test_session_gate.py index d7c9c86e..98378ab4 100644 --- a/adapters/tests/integrations/nat/test_session_gate.py +++ b/adapters/tests/integrations/nat/test_session_gate.py @@ -59,6 +59,12 @@ async def target(command: str) -> str: mw._emit_lifecycle_hook("steps/sessionEnd", {"reason": "completed"}, mw._own) return executed["count"], False + def test_an_unanswered_handshake_never_runs_the_function(self) -> None: + down = ProgrammableGuardian(hmac_secret=SECRET) + down._server.server_close() + self.assertEqual(self._run(down.url(), str(uuid.uuid4())), (0, True)) + + @mock.patch.dict(os.environ, {"ACS_STARTUP_POSTURE": "proceed"}) def test_an_unguarded_session_never_contacts_a_restored_guardian(self) -> None: down = ProgrammableGuardian(hmac_secret=SECRET) down._server.server_close() @@ -112,6 +118,7 @@ def test_a_damaged_refusal_record_never_runs_the_function(self) -> None: self.assertEqual(self._run(guardian.url(), session), (0, True)) self.assertEqual(guardian.methods_seen(), [], "a damaged record must not trigger another handshake") + @mock.patch.dict(os.environ, {"ACS_STARTUP_POSTURE": "proceed"}) def test_a_record_copied_from_another_endpoint_never_runs_the_function(self) -> None: down = ProgrammableGuardian(hmac_secret=SECRET) down._server.server_close() diff --git a/adapters/tests/integrations/nat/test_wire.py b/adapters/tests/integrations/nat/test_wire.py index 5b2f05be..14d0e77a 100644 --- a/adapters/tests/integrations/nat/test_wire.py +++ b/adapters/tests/integrations/nat/test_wire.py @@ -1,7 +1,11 @@ """acs-nat-wire wires and checks every point NAT gates: the workflow, each function group and each function.""" from __future__ import annotations +import contextlib +import io +import tempfile import unittest +from pathlib import Path try: from acs.integrations.nat import wire @@ -46,5 +50,76 @@ def test_write_wires_every_function_and_unwire_restores_the_file(self) -> None: self.assertEqual(restored, PLAIN) +NAMED = """\ +middleware: + acs: + _type: acs_guardian + guardian_url: http://127.0.0.1:8787/acs + +functions: + tool: + _type: current_datetime + middleware: + - acs + lookup: + _type: current_datetime + +workflow: + _type: acs_nested_outer + inner: tool + middleware: + - acs +""" + + +@unittest.skipUnless(_NAT_OK, "nvidia-nat-core is not installed") +class WireFindsTheMiddlewareByType(unittest.TestCase): + def test_check_accepts_the_guardian_middleware_under_any_name(self) -> None: + gaps = wire.find_attachment_points(wire._yaml().load(NAMED)) + self.assertEqual([g.path for g in gaps], ["functions.lookup"]) + + def test_write_lists_the_existing_middleware_and_adds_no_second_guard(self) -> None: + doc = wire._yaml().load(NAMED) + wire.install(doc, guardian_url="http://127.0.0.1:8787/acs", default_deny=True, timeout_s=5.0) + self.assertEqual(list(doc["middleware"]), ["acs"]) + self.assertEqual([str(m) for m in doc["functions"]["tool"]["middleware"]], ["acs"]) + self.assertEqual([str(m) for m in doc["functions"]["lookup"]["middleware"]], ["acs"]) + self.assertEqual([str(m) for m in doc["workflow"]["middleware"]], ["acs"]) + self.assertEqual(wire.find_attachment_points(doc), []) + restored, _ = wire.unwire(wire.render(doc)) + self.assertEqual(restored, NAMED) + + def test_write_refuses_when_acs_guardian_names_another_middleware(self) -> None: + taken = NAMED.replace(" acs:\n _type: acs_guardian", " acs_guardian:\n _type: content_filter") + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "workflow.yml" + path.write_text(taken) + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + self.assertNotEqual(wire.main(["--workflow", str(path), "--write"]), 0) + self.assertEqual(path.read_text(), taken) + + +@unittest.skipUnless(_NAT_OK, "nvidia-nat-core is not installed") +class WiredPosture(unittest.TestCase): + def _wired(self, *flags: str) -> bool: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "workflow.yml" + path.write_text(PLAIN) + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(wire.main(["--workflow", str(path), "--write", *flags]), 0) + return wire._yaml().load(path.read_text())["middleware"]["acs_guardian"]["default_deny"] + + def test_the_middleware_is_wired_fail_closed(self) -> None: + self.assertIs(self._wired(), True) + self.assertIs(self._wired("--default-deny"), True) + + def test_all_fail_open_wires_it_open(self) -> None: + self.assertIs(self._wired("--all-fail-open"), False) + + def test_an_unset_default_deny_denies(self) -> None: + from acs.integrations.nat.adapter import ACSMiddlewareConfig + self.assertIs(ACSMiddlewareConfig(guardian_url="http://127.0.0.1:8787/acs").default_deny, True) + + if __name__ == "__main__": unittest.main()