diff --git a/adapters/docs/claude_code/README.md b/adapters/docs/claude_code/README.md index f8d8ec35..191f39f2 100644 --- a/adapters/docs/claude_code/README.md +++ b/adapters/docs/claude_code/README.md @@ -81,7 +81,7 @@ What it wires by default: |---|---| | `PreToolUse` | **fail-CLOSED** (gate — silent fail-open is a policy hole) | | `UserPromptSubmit` | **fail-CLOSED** (gate) | -| `SessionStart` | fail-open (observational; §6.4 default) | +| `SessionStart` | fail-open (§6.4 default); a Guardian `deny` refuses the session | | `PostToolUse` | fail-open | | `Notification` | fail-open | | `SessionEnd` | fail-open | @@ -267,7 +267,7 @@ The adapter is configured by environment variables, typically set per-hook by `a | `ACS_HMAC_SECRET` | (unset) | Inline secret. Less secure (visible in `ps eauxw`). Prefer the file. | | `ACS_DEFAULT_DENY` | `0` | Fail-open with audit (§6.4 default). Set to `1` for fail-closed. The ServerHello's `on_decision_failure: deny` also flips it (most-restrictive-wins). Guardian refusals (bad signature, replay, malformed/oversized envelope) always fail closed regardless of this setting — see spec issue #32. | | `ACS_STARTUP_POSTURE` | follows `ACS_DEFAULT_DENY` | `proceed` or `refuse`: what this hook does in a session with no negotiated Guardian, because its handshake got no answer (§4.1). `proceed` lets the step run, audited, with nothing sent to the Guardian; `refuse` denies it. The session keeps no negotiated Guardian for its lifetime, and the next session handshakes again. Any other value refuses. | -| `ACS_REQUIRE_PROFILES` | (unset) | Comma-separated conformance profiles the Guardian must accept, such as `acs-core`. When the session's negotiation leaves one out, this hook's steps are refused; the stored record keeps what the Guardian answered, so each hook applies its own setting. | +| `ACS_REQUIRE_PROFILES` | (unset) | Comma-separated conformance profiles the Guardian must accept, such as `acs-core`. When the session's negotiation leaves one out, this hook's steps are refused and it sends no `sessionStart`; the stored record keeps what the Guardian answered, so each hook applies its own setting, and a later hook without the requirement starts the session. | | `ACS_HANDSHAKE` | `1` | Set to `0` to skip the handshake; the session then counts as unanswered, and `ACS_STARTUP_POSTURE` settles every step. | | `ACS_AGENT_ID` | derived from cwd | Stable agent identifier sent in `metadata.agent_id`. | | `ACS_HANDSHAKE_CACHE` | `~/.cache/acs-adapter-handshake/` | Each session's handshake decision, kept for the session's lifetime. | diff --git a/adapters/docs/claude_code/mapping.md b/adapters/docs/claude_code/mapping.md index c4e5af44..7de4c2ab 100644 --- a/adapters/docs/claude_code/mapping.md +++ b/adapters/docs/claude_code/mapping.md @@ -6,7 +6,7 @@ Each Claude Code hook event maps to an ACS `steps/*` method. The adapter (`src/a | Claude Code hook | ACS step method | Notes | |---|---|---| -| `SessionStart` | `steps/sessionStart` | Session bounds. Session-level — carries no `turn_id`. | +| `SessionStart` | `steps/sessionStart` | Session bounds, sent once per session by the hook process that opens the session and built from its event (see Concurrency). A `SessionStart` with `source: resume` or `compact` in a session that already started sends nothing. A Guardian `deny`, or a refusal of the request, refuses the session: every later step of it is denied in its hook's own shape. A `sessionStart` that gets no decision is recorded, and each later hook settles its step with its own fail posture. Session-level — carries no `turn_id`. | | `SessionEnd` | `steps/sessionEnd` | Session close (chain seal). Session-level — no `turn_id`. | | `UserPromptSubmit` | `steps/turnStart` **then** `steps/userMessage` | A prompt OPENS a turn: the adapter first emits a decision-eligible `steps/turnStart` (`triggered_by: user_message`; a Guardian MAY `deny` to block the whole turn), then the `steps/userMessage` carrying the `prompt`. Both — and every in-turn step below — carry `metadata.turn_id`. The `turn_id` is held in per-session state until the matching `Stop`. | | `Stop` | `steps/turnEnd` | End of a **turn** (every reply), not the session. `steps/sessionEnd` "seals the chain" once per session, so mapping `Stop` there sealed it after every reply; `steps/turnEnd` (audit-only, `outcome: completed`) is the correct per-turn boundary and echoes the open turn's `turn_id`. A `Stop` with no open turn is skipped + audited (`turn_end_no_open_turn`) rather than emitting a fabricated `turn_id`. | @@ -38,7 +38,7 @@ An arrived decision the host CANNOT apply is substituted to a block/deny (never ## Concurrency -Claude Code can run the hooks of parallel tool calls at the same time. `UserPromptSubmit` and `Stop` open and close the turn, and a signed session's first MCP tool call records `profile_claim_degraded`; other tool hooks only read session state. Every write of session state is one locked read-modify-write through a temp file of its own, and no lock is held during a Guardian round trip. The Guardian's rolling `chain_hash` orders steps by arrival, so the order of concurrent steps is the order they reach the Guardian. A second wired copy of the adapter would send every event twice, and both requests for one tool call would carry the same `request_id`, so `acs-claude-code-wire` refuses to wire when it finds one in user, project, local or managed settings, in the hooks of an installed plugin that `enabledPlugins` turns on, or, for a settings file Cursor imports, in a `hooks.json` Cursor loads. +Claude Code can run the hooks of parallel tool calls at the same time. The first hook process of a session holds the session's startup lock while it sends the session's one `handshake/hello` and its `steps/sessionStart`; it records how `sessionStart` went in a signed startup record next to the session record. Every other hook process of the session waits for that lock and follows the recorded outcome, so the Guardian sees the handshake, then `sessionStart`, then the session's other steps. A hook process that cannot take the lock, finds a startup record that fails its checks, or finds a start claimed but never recorded refuses the session and audits why. `UserPromptSubmit` and `Stop` open and close the turn, and a signed session's first MCP tool call records `profile_claim_degraded`; other tool hooks only read session state. Every write of session state is one locked read-modify-write through a temp file of its own, and apart from the startup lock, no lock is held during a Guardian round trip. The Guardian's rolling `chain_hash` orders steps by arrival, so the order of concurrent steps is the order they reach the Guardian. A second wired copy of the adapter would send every event twice, and both requests for one tool call would carry the same `request_id`, so `acs-claude-code-wire` refuses to wire when it finds one in user, project, local or managed settings, in the hooks of an installed plugin that `enabledPlugins` turns on, or, for a settings file Cursor imports, in a `hooks.json` Cursor loads. ## Conformance posture diff --git a/adapters/docs/cursor/README.md b/adapters/docs/cursor/README.md index cdc16a13..a49aa490 100644 --- a/adapters/docs/cursor/README.md +++ b/adapters/docs/cursor/README.md @@ -4,7 +4,7 @@ A drop-in adapter that wires [Cursor](https://cursor.com) hooks to an ACS Guardi ## How it works -Cursor fires a hook (e.g. `preToolUse`) by running a shell command from `hooks.json`. The command receives the hook event as JSON on stdin and emits Cursor-shaped JSON on stdout (or exit code 2 for some events). +Cursor fires a hook (e.g. `preToolUse`) by running a shell command from `hooks.json`. The command receives the hook event as JSON on stdin and emits Cursor-shaped JSON on stdout. This adapter is that command. On every hook event Cursor spawns it as a subprocess; the adapter: @@ -92,7 +92,7 @@ What it wires by default: the six unconditional hooks of the ACS-Core minimum se | Cursor event | ACS step method | Posture | |---|---|---| -| `sessionStart` | sessionStart | fail-open (observational) | +| `sessionStart` | sessionStart | fail-open (§6.4 default); a Guardian `deny` refuses the session | | `beforeSubmitPrompt` | userMessage | **fail-CLOSED** (gate) | | `preToolUse` | toolCallRequest | **fail-CLOSED** (gate) | | `postToolUse` | toolCallResult | fail-open | @@ -272,7 +272,7 @@ Same architectural pattern (shell-stdin/stdout, JSON in, JSON out), different pr | Event dispatch | Single command, event type in `hook_event_name` field | One command per event, event name passed as `argv[1]` | | Allow/deny field | `hookSpecificOutput.permissionDecision` | `permission` (top-level) | | Modify input field | `hookSpecificOutput.updatedInput` | `updated_input` | -| Block via exit code | Optional (`exit 2`) | Supported (`exit 2`); `beforeSubmitPrompt` uses exit code rather than JSON output | +| Block via exit code | Optional (`exit 2`) | Supported (`exit 2`); the adapter answers `beforeSubmitPrompt` with `{"continue": false, "user_message"}` and exit 0 instead, so Cursor shows the message | | Fail-closed flag | env var `ACS_DEFAULT_DENY` | hook-level `failClosed: true` in `hooks.json`, PLUS env var | | Per-event output keys | Mostly uniform via `hookSpecificOutput` | Event-specific (`permission`, `additional_context`, `updated_mcp_tool_output`, `followup_message`, ...) | | Headless CLI for tests | `claude --print` | None — Cursor is a desktop GUI; e2e is semi-automated | @@ -290,7 +290,7 @@ The adapter is configured by environment variables, typically set per-hook by `a | `ACS_HMAC_SECRET` | (unset) | Inline secret. Less secure (visible in `ps eauxw`). Prefer the file. | | `ACS_DEFAULT_DENY` | `0` | Fail-open with audit (§6.4 default). Set to `1` for fail-closed. The ServerHello's `on_decision_failure: deny` also flips it (most-restrictive-wins). Guardian refusals (bad signature, replay, malformed/oversized envelope) always fail closed regardless of this setting — see spec issue #32. | | `ACS_STARTUP_POSTURE` | follows `ACS_DEFAULT_DENY` | `proceed` or `refuse`: what this hook does in a session with no negotiated Guardian, because its handshake got no answer (§4.1). `proceed` lets the step run, audited, with nothing sent to the Guardian; `refuse` denies it. The session keeps no negotiated Guardian for its lifetime, and the next session handshakes again. Any other value refuses. | -| `ACS_REQUIRE_PROFILES` | (unset) | Comma-separated conformance profiles the Guardian must accept, such as `acs-core`. When the session's negotiation leaves one out, this hook's steps are refused; the stored record keeps what the Guardian answered, so each hook applies its own setting. | +| `ACS_REQUIRE_PROFILES` | (unset) | Comma-separated conformance profiles the Guardian must accept, such as `acs-core`. When the session's negotiation leaves one out, this hook's steps are refused and it sends no `sessionStart`; the stored record keeps what the Guardian answered, so each hook applies its own setting, and a later hook without the requirement starts the session. | | `ACS_HANDSHAKE` | `1` | Set to `0` to skip the handshake; the session then counts as unanswered, and `ACS_STARTUP_POSTURE` settles every step. | | `ACS_AGENT_ID` | derived from cwd | Stable agent identifier sent in `metadata.agent_id`. | | `ACS_HANDSHAKE_CACHE` | `~/.cache/acs-adapter-handshake/` | Each session's handshake decision, kept for the session's lifetime. | @@ -317,12 +317,12 @@ Honest, item-by-item against the ACS-Core baseline in `docs/spec/conformance.md` | Profiles (`profiles_supported` / `profiles_accepted`) | ✓ claims `acs-core` when a signing secret is configured and no profile without one. A ServerHello accepting a profile the adapter did not claim is not a valid answer, a claimed profile the Guardian leaves out is audited as `profile_not_accepted`, and a hook's `ACS_REQUIRE_PROFILES` turns a left-out profile into a refusal of that hook's steps. | | JSON-RPC envelope shape (`request-envelope.json`) | ✓ validates against canonical schema for every mapped hook (emission suite), with format checking | | Hook taxonomy (Core minimum set) | ✓ the six unconditional hooks are covered, and so is `subagentStart`, which ACS-Core requires of a subagent-capable client. Cursor exposes the spawn boundary, and the default wiring gates it fail-closed. Seventeen Cursor events are mapped in total; `subagentStop` is intentionally omitted — see the honesty table below. | -| Dispositions | ALLOW / DENY / ASK supported on **permission (pre-execution) events** (`preToolUse`, `beforeShellExecution`, `beforeMCPExecution`, `beforeSubmitPrompt`, `subagentStart`). DEFER substituted to ASK (Cursor has no defer). MODIFY supported on `preToolUse` via `updated_input`. **Lifecycle / post-execution hooks (`afterAgentResponse → steps/agentResponse`, `sessionStart`, `sessionEnd`, `afterShellExecution`, etc.) are observation-only** — Cursor fires them after the message / side effect has occurred; a Guardian `deny` cannot undo it. See `mapping.md`. | +| Dispositions | ALLOW / DENY / ASK supported on **permission (pre-execution) events** (`preToolUse`, `beforeShellExecution`, `beforeMCPExecution`, `beforeSubmitPrompt`, `subagentStart`). DEFER substituted to ASK (Cursor has no defer). MODIFY supported on `preToolUse` via `updated_input`. A `deny` on `sessionStart` refuses the session, so every later step of it is denied. **Lifecycle / post-execution hooks (`afterAgentResponse → steps/agentResponse`, `sessionEnd`, `afterShellExecution`, etc.) are observation-only** — Cursor fires them after the message / side effect has occurred; a Guardian `deny` cannot undo it. See `mapping.md`. | | Unknown-disposition fail posture | ✓ | | SessionContext + published `chain_hash` | ✓ session_id coerced to UUID; Guardian computes rolling SHA-256 chain | | Replay protection | ✓ Guardian enforcement (REPLAY_DETECTED -32005, TIMESTAMP_OUT_OF_WINDOW -32006) | | Baseline integrity (HMAC-SHA256) | ✓ when `ACS_HMAC_SECRET[_FILE]` is set; SIGNATURE_INVALID -32004 on tamper | -| Decision honoring (§6.4) | ✓ Cursor blocks on permission deny; adapter uses exit-2 where stdout JSON is not available; fail-open emits `ACS_AUDIT` event; audit `cause` field distinguishes failure modes (transport vs signature vs malformed envelope vs replay vs skew) | +| Decision honoring (§6.4) | ✓ Cursor blocks on permission deny; adapter answers every decided step in Cursor's documented JSON; fail-open emits `ACS_AUDIT` event; audit `cause` field distinguishes failure modes (transport vs signature vs malformed envelope vs replay vs skew) | | Liveness `system/ping` | ⚠ Guardian-side only. ACS-Core says `system/ping` SHOULD be implemented: the Observed Agent declares it in `methods_implemented` and sends it, and the Guardian answers it (§13). The example Guardian answers ping as an always-allow method outside the chain, replay, and signature checks. The adapter does not declare or send `system/ping`, so a deployment that uses it must name an alternative liveness mechanism in its configuration, such as continuous observed hook traffic. Omitting both is non-conformant. | | `nonce` (optional replay field) | ✗ adapter does not emit `nonce`; the envelope field is OPTIONAL in v0.1 | | Wrapped MCP `protocols/MCP/*` | ⚠ partial. ACS-Core requires `protocols/MCP/*` for any session that involves MCP, and MCP `tools/call` MAY instead flow through the generic tool hooks. The adapter covers only that case: it maps `beforeMCPExecution` to `steps/toolCallRequest` and `afterMCPExecution` to `steps/toolCallResult`. It wraps no other MCP surface (`initialize`, `prompts/get`, `resources/read`, notifications), so a session that uses MCP falls short of ACS-Core there. The first MCP tool a signed session uses records `profile_claim_degraded` once, whether it arrives through the MCP hooks or through `preToolUse` and `postToolUse` as `MCP:`. Known limitation. | diff --git a/adapters/docs/cursor/example_payloads.md b/adapters/docs/cursor/example_payloads.md index 19135560..82b811c5 100644 --- a/adapters/docs/cursor/example_payloads.md +++ b/adapters/docs/cursor/example_payloads.md @@ -156,9 +156,13 @@ Per-event output keys differ. See the [mapping table](../mapping.md#disposition- {"additional_context": "audit: tool ran in 19ms; output 142 bytes"} ``` -### beforeSubmitPrompt (block via exit code, not stdout) +### beforeSubmitPrompt (blocked) -The adapter writes nothing to stdout and exits with code 2. Cursor treats exit-2 as a block. +```json +{"continue": false, "user_message": "ACS adapter: prompt blocked (transport_failure)"} +``` + +The adapter exits with code 0, so Cursor reads the JSON and shows the message. --- diff --git a/adapters/docs/cursor/live_verification.md b/adapters/docs/cursor/live_verification.md index 35f025ca..171b2144 100644 --- a/adapters/docs/cursor/live_verification.md +++ b/adapters/docs/cursor/live_verification.md @@ -52,11 +52,12 @@ EOF A benign prompt that triggers tool use should produce, in order, on the Guardian's stderr: -1. `steps/sessionStart` (Cursor's `sessionStart` event) -2. `steps/userMessage` (Cursor's `beforeSubmitPrompt`) -3. `steps/toolCallRequest` (Cursor's `preToolUse` for the first tool) -4. `steps/toolCallResult` (Cursor's `postToolUse`) -5. Additional `toolCallRequest` / `toolCallResult` pairs for each subsequent tool the agent invokes +1. `handshake/hello`, once for the session +2. `steps/sessionStart`, from whichever of Cursor's first hooks opens the session +3. `steps/turnStart`, then `steps/userMessage` (Cursor's `beforeSubmitPrompt`) +4. `steps/toolCallRequest` (Cursor's `preToolUse` for the first tool) +5. `steps/toolCallResult` (Cursor's `postToolUse`) +6. Additional `toolCallRequest` / `toolCallResult` pairs for each subsequent tool the agent invokes The Cursor UI should show the agent's tool calls proceeding normally; no policy block messages because the example Guardian's policy only denies destructive Bash patterns and writes to system paths. diff --git a/adapters/docs/cursor/mapping.md b/adapters/docs/cursor/mapping.md index 2467f132..4b851703 100644 --- a/adapters/docs/cursor/mapping.md +++ b/adapters/docs/cursor/mapping.md @@ -8,7 +8,7 @@ Each Cursor hook event maps to an ACS `steps/*` method. The adapter (`src/acs/in | Cursor hook | ACS step method | Notes | |---|---|---| -| `sessionStart` | `steps/sessionStart` | Session bounds. `platform_context` carries the real fields: `workspace_roots`, `composer_mode`, `is_background_agent`, `cursor_version`. Session-level — no `turn_id`. | +| `sessionStart` | `steps/sessionStart` | Session bounds, sent once per session by the hook process that opens the session and built from its event (see Concurrency). `platform_context` carries the real fields: `workspace_roots`, `cursor_version`, and from Cursor's `sessionStart` event, `composer_mode` and `is_background_agent`. A Guardian `deny`, or a refusal of the request, refuses the session: every later step of it is denied in its hook's own shape. A `sessionStart` that gets no decision is recorded, and each later hook settles its step with its own fail posture. Session-level — no `turn_id`. | | `sessionEnd` | `steps/sessionEnd` | Session close (chain seal). Cursor reasons map onto the ACS enum: `completed`→`completed`, `aborted`→`cancelled`, `error`→`error`, `window_close`/`user_close`→`abandoned`. Session-level — no `turn_id`. | | `stop` | `steps/turnEnd` | End of a **turn** (each reply), not the session. `steps/sessionEnd` seals the chain once per session, so mapping `stop` there sealed it every reply; `steps/turnEnd` (audit-only) is the per-turn boundary and echoes the open turn's `turn_id`. Cursor's `status` maps to the outcome enum: `completed`→`completed`, `aborted`→`interrupted`, `error`→`error`. A `stop` with no open turn is skipped + audited rather than fabricating a `turn_id`. | | `preToolUse` | `steps/toolCallRequest` | Principal interception point; fires for ALL tool types (Shell, Read, Write, MCP, Task, …). `request_id` pinned to uuid5 of `tool_use_id` so the matching result correlates. Carries `metadata.turn_id`. | @@ -61,15 +61,15 @@ Cursor's documented response keys are event-specific. The adapter translates ACS ### `beforeSubmitPrompt` -Cursor's documented output is `{"continue": bool, "user_message": str}`; exit code 2 also blocks (documented as Claude Code-compatible behavior). The adapter emits BOTH — the native JSON and the exit code — so either mechanism suffices: +Cursor's documented output is `{"continue": bool, "user_message": str}`. The adapter answers in that shape with exit code 0, so Cursor reads it and shows `user_message`. Exit code 2 also blocks, but Cursor then shows the raw output instead of the message: | ACS disposition | Adapter behavior | |---|---| | `allow` | `{"continue": true}`, exit 0 | -| `deny` | `{"continue": false, "user_message": reasoning}`, exit 2 | +| `deny` | `{"continue": false, "user_message": reasoning}`, exit 0 | | `ask` / `defer` / `modify` | same as deny (substituted to block + audit; the prompt hook can't pause or edit) | -### Lifecycle events (`sessionStart`, `sessionEnd`, `stop`, `preCompact`, `afterAgentResponse`, `afterAgentThought`) +### Lifecycle events (`sessionEnd`, `stop`, `preCompact`, `afterAgentResponse`, `afterAgentThought`) **Observation-only.** The adapter emits empty output. Cursor fires these after the fact (or fire-and-forget), so a Guardian `deny` / `modify` cannot undo the side effect; ACS records the event in the audit chain. Enforcement on prompts belongs at `beforeSubmitPrompt` (turn gate), on tools at `preToolUse` / `beforeShellExecution` / `beforeMCPExecution`, on spawns at `subagentStart`. ACS-Core §hooks.md describes `agentResponse` as decision-eligible; the framework constraint forces this adapter's mapping to honest observation-only. @@ -91,7 +91,7 @@ Cursor's per-hook `failClosed: true` makes Cursor block when the hook crashes, t ## Concurrency -Cursor's documentation does not say whether hooks of one session overlap, so every session-state update (`record_step` for each step sent, opening or closing a turn, and a signed session's first MCP call's `profile_claim_degraded` record) is one locked read-modify-write through a temp file of its own; no lock is held during a Guardian round trip. The shell and MCP `before`/`after` events carry no call id (see the table above), so parallel shell or MCP calls cannot be paired with their results. The Guardian's rolling `chain_hash` orders steps by arrival, so the order of concurrent steps is the order they reach the Guardian. `acs-cursor-wire` refuses to wire when another copy of the adapter is wired in an enterprise, project or user `hooks.json`, or in the Claude Code settings Cursor's third-party import runs. +Cursor runs the hooks of one session at the same time: in Cursor 3.17.8, a new chat's `sessionStart` and `beforeSubmitPrompt` overlap. The first hook process of a session holds the session's startup lock while it sends the session's one `handshake/hello` and its `steps/sessionStart`; it records how `sessionStart` went in a signed startup record next to the session record. Every other hook process of the session waits for that lock and follows the recorded outcome, so the Guardian sees the handshake, then `sessionStart`, then the session's other steps. A hook process that cannot take the lock, finds a startup record that fails its checks, or finds a start claimed but never recorded refuses the session and audits why. Every session-state update (`record_step` for each step sent, opening or closing a turn, and a signed session's first MCP call's `profile_claim_degraded` record) is one locked read-modify-write through a temp file of its own; apart from the startup lock, no lock is held during a Guardian round trip. The shell and MCP `before`/`after` events carry no call id (see the table above), so parallel shell or MCP calls cannot be paired with their results. The Guardian's rolling `chain_hash` orders steps by arrival, so the order of concurrent steps is the order they reach the Guardian. `acs-cursor-wire` refuses to wire when another copy of the adapter is wired in an enterprise, project or user `hooks.json`, or in the Claude Code settings Cursor's third-party import runs. ## Conformance posture @@ -104,4 +104,4 @@ The Cursor adapter covers ACS-Core's mandatory floor in the same shape as the Cl - SessionContext: session_id sent every request (non-UUID `conversation_id` coerced deterministically). - Replay protection: ✓ - Baseline integrity: HMAC-SHA256 per §10 over the RFC 8785 (JCS) canonical envelope, HKDF per-session key (`ACS_HMAC_SECRET_FILE` / `ACS_HMAC_SECRET`); `rfc8785` is a hard dependency. Responses signature-verified and bound to their request under any HTTP status; refusals, binding mismatches, bad response signatures, and responses that cannot be verified fail closed regardless of posture (deliberately stricter than the current §6.4 text — spec issue #32). Unsigned mode is announced with a loud `unsigned_mode` audit event. -- Decision honoring: ✓ (Cursor enforces the permission verdict; the prompt gate emits the documented `continue:false` JSON plus exit 2) +- Decision honoring: ✓ (Cursor enforces the permission verdict; the prompt gate emits the documented `continue:false` JSON with exit code 0) diff --git a/adapters/docs/nat/README.md b/adapters/docs/nat/README.md index 37f734c0..958f4d4f 100644 --- a/adapters/docs/nat/README.md +++ b/adapters/docs/nat/README.md @@ -16,7 +16,7 @@ NAT exposes a `FunctionMiddleware` abstraction that wraps any function — tools The handshake/hello fires once per session, cached in process state so subsequent events skip the round-trip. -Independently of the middleware path, the adapter reports each run's `WORKFLOW_START` / `WORKFLOW_END` events as `steps/sessionStart` + `steps/userMessage` (on workflow start with input) and `steps/agentResponse` + `steps/sessionEnd` (on workflow end with output). When NAT builds the middleware, it registers an `acs_lifecycle` telemetry exporter, which NAT starts for every run before `WORKFLOW_START`. A middleware built without NAT's builder subscribes on each run's first call instead, so it reports that run from the call on. **The lifecycle stream is observation-only; it cannot block calls.** Enforcement is the middleware path's job. +Independently of the middleware path, the adapter reports each run's `WORKFLOW_START` / `WORKFLOW_END` events as `steps/sessionStart` + `steps/userMessage` (on workflow start with input) and `steps/agentResponse` + `steps/sessionEnd` (on workflow end with output). A run that raises or is cancelled reaches no workflow end, so it sends no `agentResponse` or `sessionEnd` (see `mapping.md`). When NAT builds the middleware, it registers an `acs_lifecycle` telemetry exporter, which NAT starts for every run before `WORKFLOW_START`. A middleware built without NAT's builder subscribes on each run's first call instead, so it reports that run from the call on. **The lifecycle stream is observation-only; it cannot block calls.** Enforcement is the middleware path's job. ### Decision honoring (§6.4) @@ -82,7 +82,7 @@ acs-nat-wire \ | 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` that overrides its group | prepends `acs_guardian` | +| every `functions.*.middleware` | prepends `acs_guardian`, 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. @@ -114,7 +114,7 @@ uv run python -m unittest tests.integrations.nat.test_live -v ## Coverage discipline (the YAML-only rule) -NAT's middleware fires per-attachment-point: workflow, function_groups, and individual functions with their own `middleware:` block. If an attachment point is not wired, every call routed through it bypasses the Guardian. This is a structural property of NAT, not a bug in the adapter — Cursor and Claude Code get framework-wide interception for free; NAT does not. +NAT's middleware fires per attachment point: the workflow, each function group and each function, and gates only the calls to that point. A function the workflow or another function calls is gated by its own `middleware:` list, so every function needs `acs_guardian` there. If an attachment point is not wired, every call routed through it bypasses the Guardian. This is a structural property of NAT, not a bug in the adapter — Cursor and Claude Code get framework-wide interception for free; NAT does not. **The three rules for ACS-conformant NAT deployments:** @@ -124,7 +124,7 @@ NAT's middleware fires per-attachment-point: workflow, function_groups, and indi What this gives you: **the same hard-stop guarantee Cursor's `failClosed: true` gives.** The middleware runs *before* the function executes and waits for the Guardian's decision, off the event loop; a Guardian deny raises `ACSGuardianDenied` (or sets `InvocationAction.SKIP`) and NAT does not call the function. -**The lifecycle observer reports workflow boundaries only.** It sends `sessionStart`, `userMessage`, `agentResponse` and `sessionEnd` for each run and no tool calls, so a function the middleware is not wired to reaches the Guardian neither as a decision nor as an observation. Steps 1 and 2 close that gap: define every function in YAML, and run `acs-nat-wire --check` in CI. +**The lifecycle observer reports workflow boundaries only.** It sends `sessionStart` and `userMessage` for each run, `agentResponse` and `sessionEnd` for each run that returns, and no tool calls, so a function the middleware is not wired to reaches the Guardian neither as a decision nor as an observation. Steps 1 and 2 close that gap: define every function in YAML, and run `acs-nat-wire --check` in CI. ## Smoke tests diff --git a/adapters/docs/nat/mapping.md b/adapters/docs/nat/mapping.md index 40be32b1..feebedbd 100644 --- a/adapters/docs/nat/mapping.md +++ b/adapters/docs/nat/mapping.md @@ -15,7 +15,7 @@ The ACS adapter is implemented as a `FunctionMiddleware` that, for each wrapped | Any wrapped function `pre_invoke` (tools, LLMs, retrievers, memory — all of them) | `steps/toolCallRequest` | **Yes** — every wrapped call, with the function name as the tool name | | Any wrapped function `post_invoke` | `steps/toolCallResult` | **Yes** | | Workflow start (via the `acs_lifecycle` exporter NAT starts for every run) | `steps/sessionStart` + `steps/userMessage` | **Yes** — observation-only, see below | -| Workflow end (same exporter) | `steps/agentResponse` + `steps/sessionEnd` | **Yes** — observation-only, see below | +| Workflow end (same exporter), for a run that returns | `steps/agentResponse` + `steps/sessionEnd` | **Yes** — observation-only, see below | | Retriever calls as `steps/knowledgeRetrieval` | — | **No.** Retrievers surface as `toolCallRequest` with the retriever's function name; the Guardian dispatches on name. A dedicated `knowledgeRetrieval` emission is possible future work. | | Memory read / write as `steps/memoryContextRetrieval` / `steps/memoryStore` | — | **No.** Same status: memory functions surface as generic tool calls. Documented gap — a Guardian keying policy on the memory-specific hooks will not see them from this adapter. | | Sub-workflow invocation as `steps/subagentStart` / `steps/subagentStop` | — | **No.** Nested workflow functions surface as generic tool calls; NAT exposes no distinct spawn boundary to the middleware. ACS-Core requires `subagentStart` only of a subagent-capable client, and the Guardian determines that capability by deployment-defined means. The requirement is vacuous for NAT only when sub-workflows are genuinely not distinguishable at this seam. Otherwise this is a mapping gap. | @@ -26,6 +26,8 @@ The Guardian can dispatch on the tool name to apply retriever-, memory-, or suba The adapter's `_on_intermediate_step` callback emits `steps/sessionStart` + `steps/userMessage` on `WORKFLOW_START` and `steps/agentResponse` + `steps/sessionEnd` on `WORKFLOW_END` via `_emit_lifecycle_hook`. NAT creates a new step stream for each run, so the builder registers an `acs_lifecycle` telemetry exporter that NAT starts for every run before `WORKFLOW_START`; a middleware built without NAT's builder subscribes on each run's first call instead and reports that run from the call on. **This path is fire-and-forget.** NAT's `IntermediateStepManager` subscription model is a notification stream — a subscriber callback cannot veto the event after the fact. So although ACS-Core §hooks.md lists `agentResponse` as decision-eligible (ALLOW / DENY / MODIFY), the adapter cannot actually block or rewrite the workflow's output through this path. The envelopes are for trace + audit; enforcement on outputs must be placed at `pre_invoke` / `post_invoke` of the function that produced them, or as a separate review function in the YAML the workflow calls before returning. +**A failed run has no end.** NAT 1.7.0 pushes `WORKFLOW_END` only after the workflow returns. A run that raises or is cancelled pushes none, so the Guardian receives that run's `steps/sessionStart` and `steps/userMessage` and no `steps/agentResponse` or `steps/sessionEnd`, and the session stays open on the Guardian. Every call the middleware governed during the run was still decided. A separate PR closes such a run with `steps/sessionEnd` (`reason: error` or `cancelled`). + **Deliberately not mapped — the skill lifecycle set (`steps/skillRegister`, `steps/skillLoad`, `steps/skillUnload`).** NAT composes workflows from functions declared in the workflow YAML; the middleware surface exposes no runtime skill registration, activation, or unload events. Function *invocations* are governed at `pre_invoke`/`post_invoke` (`steps/toolCallRequest`/`toolCallResult`); lifecycle wiring becomes possible when NAT exposes lifecycle events for loadable components. ## ACS disposition → NAT behavior diff --git a/adapters/src/acs/client/_config.py b/adapters/src/acs/client/_config.py index cba545f5..77562e02 100644 --- a/adapters/src/acs/client/_config.py +++ b/adapters/src/acs/client/_config.py @@ -103,9 +103,13 @@ def startup_proceeds(self) -> bool: return self.startup_posture == "proceed" def gate(self, handshake: Handshake) -> Resolution | None: - """None when the session's steps go to the Guardian; otherwise how this step ends without one (§4.1).""" + """None when this step goes to the Guardian; otherwise how it ends without one (§4.1), a session whose sessionStart got no decision included.""" if isinstance(handshake, Negotiated): - return None + failure = handshake.start_failure + if failure is None or not self.posture.negotiated(handshake.server_hello).deny_on_failure: + return None + return Resolution(False, "decision_failure_fail_closed", failure["cause"], + {**failure["fields"], "method": "steps/sessionStart"}) if isinstance(handshake, SessionRefused): return Resolution(False, "session_refused_fail_closed", handshake.cause, handshake.fields) if self.startup_proceeds: diff --git a/adapters/src/acs/client/_handshake.py b/adapters/src/acs/client/_handshake.py index dfaca0bb..2609e7a9 100644 --- a/adapters/src/acs/client/_handshake.py +++ b/adapters/src/acs/client/_handshake.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import contextlib import hashlib import hmac import json @@ -9,16 +10,17 @@ import urllib.error import urllib.parse import uuid -from collections.abc import Mapping +from collections.abc import Callable, Mapping from pathlib import Path from typing import Any from acs.client._audit import audit_event -from acs.client._config import Config +from acs.client._config import Config, Resolution from acs.client._exchange import GuardianHTTPError, is_jsonrpc_response, post -from acs.client._outcome import Handshake, Negotiated, NoAnswer, SessionRefused +from acs.client._outcome import Decided, Handshake, Negotiated, NoAnswer, SessionRefused, Undecided from acs.client._secrets import load_hmac_secret from acs.client._signing import sign_envelope, verify_signature +from acs.client._store import _locked from acs.client._urls import validate_guardian_url from acs.core import ( ACS_VERSION, @@ -28,6 +30,7 @@ is_session_refusal, iso8601_now, jcs_canonicalize, + normalize_decision, server_hello_problem, ) @@ -43,6 +46,9 @@ _HANDSHAKE_TIMEOUT_S = float(os.environ.get("ACS_HANDSHAKE_TIMEOUT_SECONDS", "5")) _RECORD_VERSION = 1 +_STARTUP_VERSION = 1 +# A startup record whose sessionStart was claimed but whose outcome was never recorded. +_PENDING = "pending" def _handshake_cache_path(session_id: str, guardian_url: str, directory: Path | None = None) -> Path: @@ -54,13 +60,24 @@ def _handshake_cache_path(session_id: str, guardian_url: str, directory: Path | def open_session(*, config: Config, session_id: str | None, agent_id: str, platform: str, methods_implemented: list[str], wrapped_protocols: list[str] | None = None, timeout: float | None = None, profiles_supported: list[str] | None = None, - transports_supported: list[str] | None = None) -> Handshake: + transports_supported: list[str] | None = None, + start: Callable[[Negotiated], Decided | Resolution | None] | None = None) -> Handshake: """Return the session's decision: the stored one, or the result of one handshake, stored for the session's lifetime; never raises. The record sits where the observed agent can write, so it carries an HMAC under the session key over the decision, the session and the Guardian URL: a record that is modified, corrupted, copied from another endpoint, or cannot be stored refuses the session. + + With start, the hook process that opens a negotiated session also sends + the session's sessionStart through it, once, unless this hook's + ACS_REQUIRE_PROFILES refuses it, and records the outcome in a + signed startup record next to the session record. Hooks of one session can + run at the same time, so every hook process of the session waits for that + handshake and that sessionStart under the session's startup lock, then + follows the recorded outcome: a deny or a refusal refuses the session, and + a sessionStart without a decision is settled by each hook's fail posture. + A startup that cannot be locked or recorded refuses the session. """ if not session_id: return NoAnswer("missing_session_id") @@ -69,9 +86,78 @@ def open_session(*, config: Config, session_id: str | None, agent_id: str, platf except Exception as e: # noqa: BLE001 return SessionRefused("secret_unavailable", {"error": str(e)}) path = _handshake_cache_path(session_id, config.guardian_url) + hello = dict(config=config, session_id=session_id, agent_id=agent_id, platform=platform, + methods_implemented=methods_implemented, wrapped_protocols=wrapped_protocols, timeout=timeout, + profiles_supported=profiles_supported, transports_supported=transports_supported) + if start is None: + return _required_profiles(_decide(path, ikm, **hello), config) + started = Path(f"{path}.started") + stored = _read_record(path, ikm, session_id, config.guardian_url) + if stored is not None and not isinstance(stored, Negotiated): + return stored + if stored is not None: + recorded = _read_startup(started, ikm, session_id, config.guardian_url) + if recorded is not None and recorded != _PENDING: + return _required_profiles(recorded, config) + with contextlib.ExitStack() as startup: + try: + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + startup.enter_context(_locked(path)) + except OSError as e: + audit_event("session_record_unwritable", session_id=session_id, guardian_url=config.guardian_url, + record=f"{path}.lock", error=str(e)) + return SessionRefused("session_startup_lock_unavailable", {"error": str(e)}) + decision = _decide(path, ikm, **hello) + own = _required_profiles(decision, config) + if not isinstance(own, Negotiated): + return own + recorded = _read_startup(started, ikm, session_id, config.guardian_url) + if recorded == _PENDING: + # The process that claimed the sessionStart released the lock without recording its outcome. + return SessionRefused("session_start_incomplete", {"record": str(started)}) + if recorded is not None: + return _required_profiles(recorded, config) + claim = _write_startup(started, None, ikm, session_id, config.guardian_url) + if claim is not None: + return claim + outcome = _start_session(decision, start, config, session_id) + unrecorded = _write_startup(started, outcome, ikm, session_id, config.guardian_url) + return _required_profiles(unrecorded or outcome, config) + + +def _start_session(decision: Negotiated, start: Callable[[Negotiated], Decided | Resolution | None], + config: Config, session_id: str) -> Handshake: + try: + result = start(decision) + except Exception as e: # noqa: BLE001 + result = config.posture.negotiated(decision.server_hello).resolve( + Undecided("adapter_exception", {"error": str(e)}, f"adapter error: {e}")) + if result is None: + # The Guardian does not evaluate sessionStart. + return decision + common = {"method": "steps/sessionStart", "session_id": session_id, "guardian_url": config.guardian_url} + if isinstance(result, Resolution): + audit_event(result.audit_type, **{**result.fields, "cause": result.cause, **common}) + if result.audit_type == "guardian_refusal_fail_closed": + refusal = SessionRefused("session_start_refused", {**result.fields, "refusal": result.cause}) + audit_event("session_refused", **{**refusal.fields, "cause": refusal.cause, **common}) + return refusal + # No decision arrived: recorded once, and each hook's fail posture settles its own steps. + return Negotiated(decision.server_hello, {"cause": result.cause, "fields": dict(result.fields)}) + verdict = normalize_decision((result.response or {}).get("result")) + if verdict.disposition == "allow": + return decision + refusal = SessionRefused("session_start_denied", {"decision": verdict.disposition, "reasoning": verdict.reasoning}) + audit_event("session_refused", **{**refusal.fields, "cause": refusal.cause, **common}) + return refusal + + +def _decide(path: Path, ikm: bytes, *, config: Config, session_id: str, agent_id: str, platform: str, + methods_implemented: list[str], wrapped_protocols: list[str] | None, timeout: float | None, + profiles_supported: list[str] | None, transports_supported: list[str] | None) -> Handshake: stored = _read_record(path, ikm, session_id, config.guardian_url) if stored is not None: - return _required_profiles(stored, config) + return stored # An unsigned session does not meet acs-core's signed-envelope floor (§10), so it claims no profile. claimed = (profiles_supported if profiles_supported is not None else ["acs-core"]) if ikm else [] if not config.handshake_enabled: @@ -87,7 +173,7 @@ def open_session(*, config: Config, session_id: str | None, agent_id: str, platf decision, ours = _store_first(path, decision, ikm, session_id, config.guardian_url) if ours: _audit_start(decision, config, session_id, claimed) - return _required_profiles(decision, config) + return decision def _required_profiles(decision: Handshake, config: Config) -> Handshake: @@ -216,7 +302,10 @@ def _encode(decision: Handshake, session_id: str, guardian_url: str) -> dict[str body: dict[str, Any] = {"acs_session_record": _RECORD_VERSION, "session_id": session_id, "guardian_url": guardian_url} if isinstance(decision, Negotiated): - return {**body, "decision": "negotiated", "server_hello": dict(decision.server_hello)} + negotiated = {**body, "decision": "negotiated", "server_hello": dict(decision.server_hello)} + if decision.start_failure is not None: + negotiated["start_failure"] = dict(decision.start_failure) + return negotiated kind = "refused" if isinstance(decision, SessionRefused) else "no_answer" return {**body, "decision": kind, "cause": decision.cause, "fields": dict(decision.fields)} @@ -224,7 +313,12 @@ def _encode(decision: Handshake, session_id: str, guardian_url: str) -> dict[str def _decode(body: Mapping[str, Any]) -> Handshake | None: kind = body.get("decision") if kind == "negotiated" and isinstance(body.get("server_hello"), dict): - return Negotiated(body["server_hello"]) + failure = body.get("start_failure") + if failure is None: + return Negotiated(body["server_hello"]) + if isinstance(failure, dict) and isinstance(failure.get("cause"), str) and isinstance(failure.get("fields"), dict): + return Negotiated(body["server_hello"], failure) + return None if kind in ("refused", "no_answer") and isinstance(body.get("cause"), str) \ and isinstance(body.get("fields"), dict): cls = SessionRefused if kind == "refused" else NoAnswer @@ -247,6 +341,30 @@ def _is_legacy(record: Any) -> bool: def _read_record(path: Path, ikm: bytes, session_id: str, guardian_url: str) -> Handshake | None: """The stored decision, None only when no record exists, and a refusal for any record that fails its checks.""" + body = _verified(path, ikm, session_id, guardian_url, "acs_session_record", _RECORD_VERSION) + if body is None or isinstance(body, SessionRefused): + return body + decision = _decode(body) + # Only the startup record says how a sessionStart went. + if decision is None or isinstance(decision, Negotiated) and decision.start_failure is not None: + return SessionRefused("session_record_invalid", {"record": str(path)}) + return decision + + +def _read_startup(path: Path, ikm: bytes, session_id: str, guardian_url: str) -> Handshake | str | None: + """The session's decision after its sessionStart, None when none is recorded, _PENDING while it is claimed, and a refusal for a record that fails its checks.""" + body = _verified(path, ikm, session_id, guardian_url, "acs_session_startup", _STARTUP_VERSION) + if body is None or isinstance(body, SessionRefused): + return body + if body.get("state") == _PENDING: + return _PENDING + decision = _decode(body) + return decision if decision is not None else SessionRefused("session_record_invalid", {"record": str(path)}) + + +def _verified(path: Path, ikm: bytes, session_id: str, guardian_url: str, marker: str, + version: int) -> Mapping[str, Any] | SessionRefused | None: + """A record's body once its MAC, session and Guardian URL check out; None when no record exists, a refusal otherwise.""" try: raw = path.read_bytes() except FileNotFoundError: @@ -258,21 +376,56 @@ def _read_record(path: Path, ikm: bytes, session_id: str, guardian_url: str) -> except Exception: # noqa: BLE001 # Any parse failure counts, including RecursionError from deep nesting. record = None - if _is_legacy(record): + if marker == "acs_session_record" and _is_legacy(record): # Its signature covers the session but not the Guardian URL, so it cannot vouch for this endpoint. return SessionRefused("session_record_legacy", {"record": str(path)}) - if not isinstance(record, dict) or record.get("acs_session_record") != _RECORD_VERSION: + # The marker keeps one kind of record from standing in for the other under the same session key. + if not isinstance(record, dict) or record.get(marker) != version: return SessionRefused("session_record_invalid", {"record": str(path)}) body = {k: v for k, v in record.items() if k != "mac"} try: authentic = not ikm or hmac.compare_digest(str(record.get("mac")), _mac(body, ikm, session_id)) except Exception: # noqa: BLE001 authentic = False - bound = body.get("session_id") == session_id and body.get("guardian_url") == guardian_url - decision = _decode(body) if authentic and bound else None - if decision is None: + if not (authentic and body.get("session_id") == session_id and body.get("guardian_url") == guardian_url): return SessionRefused("session_record_invalid", {"record": str(path)}) - return decision + return body + + +def _write_startup(path: Path, outcome: Handshake | None, ikm: bytes, session_id: str, + guardian_url: str) -> SessionRefused | None: + """Claim the session's sessionStart (outcome None), or record its outcome; a refusal, audited, when the record cannot be written.""" + temporary = None + try: + body: dict[str, Any] = {"acs_session_startup": _STARTUP_VERSION, "session_id": session_id, + "guardian_url": guardian_url} + if outcome is None: + body["state"] = _PENDING + else: + decision = _encode(outcome, session_id, guardian_url) + body.update({k: v for k, v in decision.items() if k not in body and k != "acs_session_record"}, + state="done") + record = {**body, "mac": _mac(body, ikm, session_id) if ikm else None} + fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".startup-") + with os.fdopen(fd, "w") as f: + json.dump(record, f) + if outcome is None: + # link() fails if any startup record already exists. + os.link(temporary, path) + else: + os.replace(temporary, path) + temporary = None + return None + except Exception as e: # noqa: BLE001 + audit_event("session_record_unwritable", session_id=session_id, guardian_url=guardian_url, + record=str(path), stage="claim" if outcome is None else "outcome", error=str(e)) + return SessionRefused("session_record_unwritable", {"error": str(e)}) + finally: + if temporary is not None: + try: + os.unlink(temporary) + except OSError: + pass def _store_first(path: Path, decision: Handshake, ikm: bytes, session_id: str, diff --git a/adapters/src/acs/client/_outcome.py b/adapters/src/acs/client/_outcome.py index 2b3b7f69..7e047ce2 100644 --- a/adapters/src/acs/client/_outcome.py +++ b/adapters/src/acs/client/_outcome.py @@ -34,6 +34,8 @@ class Refused: class Negotiated: """The Guardian answered the handshake with a verified ServerHello; the session's steps go to it.""" server_hello: Mapping[str, Any] + # The session's sessionStart got no decision ({"cause", "fields"}); each hook's fail posture settles its steps. + start_failure: Mapping[str, Any] | None = None @dataclass(frozen=True) diff --git a/adapters/src/acs/integrations/claude_code/adapter.py b/adapters/src/acs/integrations/claude_code/adapter.py index 2294bb99..1a50473f 100755 --- a/adapters/src/acs/integrations/claude_code/adapter.py +++ b/adapters/src/acs/integrations/claude_code/adapter.py @@ -67,8 +67,10 @@ from acs.client import ( Config, + Decided, FailPosture, Handshake, + Negotiated, Refused, Resolution, Undecided, @@ -436,9 +438,10 @@ def build_turn_start_request(event: dict[str, Any], turn_id: str) -> dict[str, A return turn_start_envelope(_metadata(event), turn_id) -def _open_session(event: dict[str, Any], config: Config) -> Handshake: - """The session's §4.1 decision. The first event of a session_id handshakes and stores - the decision; every later hook process of that session reads it without a network call.""" +def _open_session(event: dict[str, Any], config: Config, started: list | None = None) -> Handshake: + """The session's §4.1 decision. The first event of a session_id handshakes, stores + the decision and sends sessionStart; every later hook process of that session reads + the decision without a network call.""" return open_session( config=config, session_id=event.get("session_id"), @@ -448,9 +451,27 @@ def _open_session(event: dict[str, Any], config: Config) -> Handshake: # values, so advertise them explicitly. methods_implemented=(list(HOOK_MAP.values()) + ["steps/subagentStart", "steps/turnStart"]), + start=lambda session: _send_session_start(event, session, config, started), ) +def _send_session_start(event: dict[str, Any], session: Negotiated, config: Config, + started: list | None) -> Decided | Resolution | None: + """The session's one sessionStart, built from whichever hook opened the session; the session chain roots there (§8).""" + session_id = event.get("session_id") + if not method_evaluated(session.server_hello, "steps/sessionStart"): + audit_event("method_not_evaluated", method="steps/sessionStart", + hook=event.get("hook_event_name"), session_id=session_id) + return None + opening = {k: event[k] for k in ("session_id", "cwd", "transcript_path", "model", "source") if k in event} + result = round_trip(build_request({**opening, "hook_event_name": "SessionStart"}), + guardian_url=config.guardian_url, posture=config.posture.negotiated(session.server_hello), + session_id=session_id) + if isinstance(result, Decided) and started is not None: + started.append(result) + return result + + # ─── Response translation — dispatch table, one function per hook ───────── def _translate_pretool(decision: Decision, original_input: dict) -> dict[str, Any]: @@ -634,10 +655,16 @@ def main() -> int: session_id = event.get("session_id") # §4.1: a refused or unanswered session sends the Guardian nothing; each step is settled here. - session = _open_session(event, config) + started: list = [] + session = _open_session(event, config, started) gated = config.gate(session) if gated is not None: return _settle(gated, hook_name, session_id) + if hook_name == "SessionStart": + # The session's sessionStart went out when the session opened, from this hook or an earlier one. + if started: + _emit(translate_response(started[0].response, hook_name)) + return 0 server_hello = session.server_hello # §6.4:154: the negotiated posture and default timeout govern every round trip below. posture = config.posture.negotiated(server_hello) diff --git a/adapters/src/acs/integrations/cursor/adapter.py b/adapters/src/acs/integrations/cursor/adapter.py index 562bcd62..2fff0c42 100755 --- a/adapters/src/acs/integrations/cursor/adapter.py +++ b/adapters/src/acs/integrations/cursor/adapter.py @@ -72,8 +72,10 @@ from acs.client import ( Config, + Decided, FailPosture, Handshake, + Negotiated, Refused, Resolution, Undecided, @@ -638,16 +640,10 @@ def _metadata(event_name: str, event: dict[str, Any]) -> dict[str, Any]: def build_turn_start_request(event: dict[str, Any], turn_id: str) -> dict[str, Any]: - """steps/turnStart opening the turn for a beforeSubmitPrompt. turn_id - SHOULD equal the envelope's request_id (turn-start.json), so we use it - for both. Decision-eligible: a Guardian MAY deny to block the turn - (blocks the prompt via exit code 2).""" return turn_start_envelope(_metadata("beforeSubmitPrompt", event), turn_id) def _open_session(event: dict[str, Any], config: Config) -> Handshake: - """The session's §4.1 decision. The first event of a session handshakes and stores - the decision; every later hook process of that session reads it without a network call.""" return open_session( config=config, session_id=_session_id(event), @@ -656,9 +652,20 @@ def _open_session(event: dict[str, Any], config: Config) -> Handshake: # steps/turnStart is emitted on beforeSubmitPrompt but is not a # HOOK_MAP value; advertise it so advertised == emitted. methods_implemented=list(HOOK_MAP.values()) + ["steps/turnStart"], + start=lambda session: _send_session_start(event, session, config), ) +def _send_session_start(event: dict[str, Any], session: Negotiated, config: Config) -> Decided | Resolution | None: + """The session's one sessionStart, from whichever hook opened it; Cursor runs sessionStart and beforeSubmitPrompt concurrently.""" + session_id = _session_id(event) + if not method_evaluated(session.server_hello, "steps/sessionStart"): + audit_event("method_not_evaluated", method="steps/sessionStart", session_id=session_id) + return None + return round_trip(build_request("sessionStart", event), guardian_url=config.guardian_url, + posture=config.posture.negotiated(session.server_hello), session_id=session_id) + + # ─── Response translation — dispatch table by event category ────────────── # # Cursor's response shapes group naturally into 4 categories: @@ -668,7 +675,7 @@ def _open_session(event: dict[str, Any], config: Config) -> Handshake: # - post-tool events (postToolUse, postToolUseFailure, afterShellExecution, # afterMCPExecution, afterFileEdit, afterTabFileEdit): {"additional_context": ..., # "updated_mcp_tool_output": ...} -# - beforeSubmitPrompt: uses exit code 2 (not stdout) to block; carries +# - beforeSubmitPrompt: blocks with {"continue": false, "user_message"}; carries # internal __exit_code/_reasoning keys consumed by main() # - everything else: no response shape (Stop, SessionStart, SessionEnd, etc.) @@ -745,9 +752,7 @@ def _translate_post_tool(decision: Decision, event_name: str) -> dict[str, Any]: def _translate_before_submit_prompt(decision: Decision, event_name: str) -> dict[str, Any]: - """The prompt hook can only allow (exit 0) or block (exit 2), so every - non-allow decision fails closed per §6.4; returns internal - __exit_code/_reasoning keys for main().""" + """The prompt hook can only allow or block, so every other decision blocks (§6.4).""" if decision.disposition == "allow": return {"__exit_code": 0, "_reasoning": None} if decision.disposition != "deny": @@ -838,6 +843,9 @@ def main() -> int: gated = config.gate(session) if gated is not None: return _settle(gated, event_name, session_id) + if event_name == "sessionStart": + # The session's sessionStart went out when the session opened, from this hook or an earlier one. + return 0 server_hello = session.server_hello # §6.4:154: the negotiated posture and default timeout govern every round trip below. posture = config.posture.negotiated(server_hello) @@ -858,9 +866,7 @@ def _govern(event_name: str, event: dict[str, Any], session_id: str | None, if event_name in ("beforeMCPExecution", "afterMCPExecution") or ( isinstance(tool_name, str) and tool_name.startswith("MCP:")): record_mcp_use(session_id, tool=str(tool_name or ""), workspace=_workspace(event)) - # Turn tracking: beforeSubmitPrompt OPENS a turn with an explicit, - # decision-eligible steps/turnStart BEFORE the userMessage. A Guardian - # MAY deny to block the whole turn — which blocks the prompt (exit 2). + # A prompt opens its turn with a decision-eligible turnStart before the userMessage, and a deny blocks the prompt. if event_name == "beforeSubmitPrompt": turn_id = str(uuid.uuid4()) if method_evaluated(server_hello, "steps/turnStart"): @@ -870,8 +876,7 @@ def _govern(event_name: str, event: dict[str, Any], session_id: str | None, return _settle(result, "beforeSubmitPrompt", session_id) turn_start = normalize_decision(result.response.get("result")) if turn_start.disposition != "allow": - # A turn boundary can't be modify/ask'd — any non-allow - # blocks the prompt (exit 2). + # Only allow applies to a turn start, so any other disposition blocks the prompt. audit_event("turn_start_blocked", turn_id=turn_id, disposition=turn_start.disposition or "(unusable)", event="beforeSubmitPrompt") @@ -880,7 +885,7 @@ def _govern(event_name: str, event: dict[str, Any], session_id: str | None, sys.stderr.write(f"acs-adapter: {msg}\n") sys.stdout.write(json.dumps({"continue": False, "user_message": msg}) + "\n") - return 2 + return 0 else: audit_event("method_not_evaluated", method="steps/turnStart", event=event_name, session_id=session_id) @@ -944,10 +949,7 @@ def _govern(event_name: str, event: dict[str, Any], session_id: str | None, if event_name == "beforeSubmitPrompt": exit_code = out.pop("__exit_code", 0) reasoning = out.pop("_reasoning", None) - # Documented output is {"continue": bool, "user_message": str} - # (docs 2026-08-22): emit the native shape and keep exit 2 as - # belt-and-braces; on allow, {"continue": true} keeps - # failClosed:true from misreading the hook as failed. + # Exit 0 so Cursor shows user_message; an allow says continue true, so failClosed reads the hook as answered. if exit_code == 0: sys.stdout.write(json.dumps({"continue": True}) + "\n") return 0 @@ -956,7 +958,7 @@ def _govern(event_name: str, event: dict[str, Any], session_id: str | None, "continue": False, "user_message": reasoning or "blocked by ACS Guardian", }) + "\n") - return exit_code + return 0 _emit(out) return 0 @@ -1003,7 +1005,7 @@ def _deny_with_audit(audit_type: str, event_name: str, "continue": False, "user_message": f"ACS adapter: prompt blocked ({cause})", }) + "\n") - return 2 + return 0 return 0 diff --git a/adapters/src/acs/integrations/cursor/hook.py b/adapters/src/acs/integrations/cursor/hook.py index 73f8d900..3500b3ac 100644 --- a/adapters/src/acs/integrations/cursor/hook.py +++ b/adapters/src/acs/integrations/cursor/hook.py @@ -19,10 +19,10 @@ def _deny(event_name: str, reason: str) -> int: print(json.dumps({"permission": "deny", "user_message": reason, "agent_message": reason})) return 0 if event_name == "beforeSubmitPrompt": - # The documented native block, plus exit code 2. + # The documented native block, read with exit code 0 so Cursor shows the message. sys.stderr.write(f"acs-adapter: {reason}\n") print(json.dumps({"continue": False, "user_message": reason})) - return 2 + return 0 return 0 diff --git a/adapters/src/acs/integrations/nat/wire.py b/adapters/src/acs/integrations/nat/wire.py index dc588c35..ca1c6cc9 100644 --- a/adapters/src/acs/integrations/nat/wire.py +++ b/adapters/src/acs/integrations/nat/wire.py @@ -2,10 +2,10 @@ """ Wire (or unwire / lint) the ACS adapter into a NAT `workflow.yml`. -NAT's middleware is opt-in per attachment point: the user has to list -`acs_guardian` under `middleware:` on the workflow, every function_group, -and any function that overrides its group's middleware. Miss one and -that path bypasses the Guardian. This CLI walks the YAML, finds every +NAT's middleware is opt-in per attachment point, and an attachment +point's middleware gates only the calls to it: the user has to list +`acs_guardian` under `middleware:` on the workflow, every function_group +and every function. Miss one and that path bypasses the Guardian. This CLI walks the YAML, finds every attachment point, and either reports gaps (`--check`) or fills them in (`--write`). It is idempotent: every line we add carries a `# acs-adapter-wired` comment so a re-run is a no-op and `--unwire` @@ -161,18 +161,18 @@ def find_attachment_points(doc: Any) -> list[Gap]: 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")) - # 4) Any individual function with its own middleware: list — that - # override punches a hole through its group's wiring if it omits us. + # 4) Every function: a call to a function, from the workflow or from another + # function, runs only that function's own middleware. funcs = doc.get("functions") if isinstance(funcs, (dict, CommentedMap)): for name, fn in funcs.items(): if not isinstance(fn, (dict, CommentedMap)): continue - if "middleware" in fn and not _list_includes_middleware(fn.get("middleware")): + if not _list_includes_middleware(fn.get("middleware")): gaps.append(Gap( "error", "function", f"functions.{name}", _line_of(fn), - f"function `{name}` overrides `middleware:` but omits `{MIDDLEWARE_NAME}` — this call path bypasses the Guardian")) + f"function `{name}` does not list `{MIDDLEWARE_NAME}` — every call to it bypasses the Guardian")) return gaps @@ -257,7 +257,7 @@ def install(doc: CommentedMap, *, guardian_url: str, default_deny: bool, funcs = doc.get("functions") if isinstance(funcs, (dict, CommentedMap)): for name, fn in funcs.items(): - if isinstance(fn, (dict, CommentedMap)) and "middleware" in fn: + if isinstance(fn, (dict, CommentedMap)): if _ensure_middleware_listed(fn, f"functions.{name}"): changes.append(f"functions.{name}.middleware: added `{MIDDLEWARE_NAME}`") diff --git a/adapters/src/acs/testing/harness.py b/adapters/src/acs/testing/harness.py index cfde3717..d9ef6aa8 100644 --- a/adapters/src/acs/testing/harness.py +++ b/adapters/src/acs/testing/harness.py @@ -312,7 +312,7 @@ class ProgrammableGuardian: DEFAULT_TEST_HMAC_SECRET = "shared-test-harness-secret-not-for-production" def __init__(self, *, hmac_secret: str | None = None, - sign_responses: bool = True, port: int = 0) -> None: + sign_responses: bool = True, port: int = 0, threaded: bool = False) -> None: import http.server self.hmac_secret = hmac_secret or self.DEFAULT_TEST_HMAC_SECRET self.sign_responses = sign_responses @@ -333,8 +333,9 @@ def __init__(self, *, hmac_secret: str | None = None, self.on_request_errors: list[str] = [] self._http = http.server # Port 0 takes an OS-assigned port; a fixed port brings a stopped Guardian back at the same URL. - self._server = self._http.HTTPServer( - ("127.0.0.1", port), self._make_handler_cls()) + # threaded serves requests concurrently, as a deployed Guardian does. + server = self._http.ThreadingHTTPServer if threaded else self._http.HTTPServer + self._server = server(("127.0.0.1", port), self._make_handler_cls()) self.port: int = self._server.server_address[1] self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) diff --git a/adapters/tests/client/test_session.py b/adapters/tests/client/test_session.py index 6ed9a5bb..ce050d1c 100644 --- a/adapters/tests/client/test_session.py +++ b/adapters/tests/client/test_session.py @@ -5,6 +5,7 @@ import json import os import tempfile +import threading import unittest import urllib.error from pathlib import Path @@ -13,6 +14,8 @@ from acs.client import _exchange from acs.client import Config, Negotiated, NoAnswer, SessionRefused, open_session +from acs.client._config import Resolution +from acs.client._outcome import Decided from acs.client import _handshake from acs.core import derive_session_key, sign_with_key @@ -107,12 +110,12 @@ def setUp(self) -> None: self.audit = mock.patch.object(_handshake, "audit_event").start() self.addCleanup(mock.patch.stopall) - def _open(self, urlopen=None, *, session_id: str = "session-1", **env: str): + def _open(self, urlopen=None, *, session_id: str = "session-1", start=None, **env: str): config = Config.from_env({"ACS_GUARDIAN_URL": URL, **env}) with mock.patch.object(_exchange, "_open", side_effect=urlopen or _answer(lambda h: _result(h, HELLO))) as network: decision = open_session(config=config, session_id=session_id, agent_id="test", - platform="test", methods_implemented=["steps/toolCallRequest"]) + platform="test", methods_implemented=["steps/toolCallRequest"], start=start) return decision, network.call_count def _record(self, session_id: str = "session-1") -> Path: @@ -379,6 +382,139 @@ def test_a_record_written_without_a_secret_is_refused_once_a_secret_is_configure self.assertEqual((decision.cause, calls), ("session_record_invalid", 0)) +class Startup(_SessionTest): + """The opening process starts a negotiated session once and records how.""" + + @staticmethod + def _decided(decision: str) -> Decided: + return Decided({"jsonrpc": "2.0", "id": "1", "result": {"decision": decision, "reasoning": "start policy"}}) + + def _started(self, session_id: str) -> Path: + return Path(f"{self._record(session_id)}.started") + + def test_a_negotiated_session_starts_once(self) -> None: + starts: list = [] + first, _ = self._open(session_id="start", start=starts.append) + again, calls = self._open(session_id="start", start=starts.append) + self.assertEqual((starts, again, calls), ([first], first, 0)) + + def test_a_session_that_did_not_negotiate_never_starts(self) -> None: + for name in ("refused", "no answer"): + with self.subTest(name): + starts: list = [] + self._open(Lifetime.ANSWERS[name], session_id=f"start-{name}", start=starts.append) + self._open(session_id=f"start-{name}", start=starts.append) + self.assertEqual(starts, []) + + def test_a_denied_or_refused_start_refuses_the_session_for_every_later_hook(self) -> None: + answers = {"deny": (lambda s: self._decided("deny"), "session_start_denied"), + "unknown decision": (lambda s: self._decided("maybe"), "session_start_denied"), + "signed refusal": (lambda s: Resolution(False, "guardian_refusal_fail_closed", + "session_refused_response", {}), "session_start_refused")} + for name, (start, cause) in answers.items(): + with self.subTest(name): + self.audit.reset_mock() + first, _ = self._open(session_id=f"start-{name}", start=start) + starts: list = [] + again, calls = self._open(session_id=f"start-{name}", start=starts.append) + self.assertEqual((first.cause, again.cause), (cause, cause)) + self.assertEqual((starts, calls), ([], 0)) + self.assertEqual([c.kwargs["cause"] for c in self.audit.call_args_list + if c.args[0] == "session_refused"], [cause]) + + def test_a_start_without_a_decision_is_recorded_for_each_hook_posture(self) -> None: + def start(session: Negotiated) -> Resolution: + return Resolution(True, "fail_open_bypass", "decision_timeout", {"method": "steps/sessionStart"}) + first, _ = self._open(session_id="start-timeout", start=start) + 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"}) + 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"], + ["fail_open_bypass"]) + + def test_a_failed_start_is_audited_once_and_not_retried(self) -> None: + def fail(session: Negotiated) -> None: + raise RuntimeError("boom") + decision, _ = self._open(session_id="start-fails", start=fail) + starts: list = [] + self._open(session_id="start-fails", start=starts.append) + 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")]) + + 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)): + with self.subTest(name): + session = f"start-profile-{stored_first}" + if stored_first: + self._open(session_id=session) + starts: list = [] + strict, _ = self._open(session_id=session, start=starts.append, ACS_REQUIRE_PROFILES="acs-other") + self.assertEqual((strict.cause, starts), ("required_profile_not_accepted", [])) + self.assertFalse(self._started(session).exists()) + later, calls = self._open(session_id=session, start=starts.append) + self.assertIsInstance(later, Negotiated) + self.assertEqual((len(starts), calls), (1, 0), "the negotiation is stored once; the later hook starts") + + def test_a_startup_record_the_start_did_not_write_refuses_the_session(self) -> None: + for name in ("empty", "copied"): + with self.subTest(name): + session = f"forged-{name}" + # The session's own signed record, which must not pass for a recorded start. + self._open(session_id=session) + self._started(session).write_bytes(b"" if name == "empty" else self._record(session).read_bytes()) + starts: list = [] + decision, calls = self._open(session_id=session, start=starts.append) + self.assertEqual((decision.cause, starts, calls), ("session_record_invalid", [], 0)) + + def test_a_start_whose_outcome_is_not_recorded_refuses_the_session_and_is_not_repeated(self) -> None: + starts: list = [] + with mock.patch.object(_handshake.os, "replace", side_effect=OSError(28, "No space left on device")): + first, _ = self._open(session_id="start-unrecorded", start=starts.append) + again, calls = self._open(session_id="start-unrecorded", start=starts.append) + self.assertEqual((first.cause, again.cause), ("session_record_unwritable", "session_start_incomplete")) + self.assertEqual((len(starts), calls), (1, 0)) + self.assertIn("session_record_unwritable", [c.args[0] for c in self.audit.call_args_list]) + + def test_a_start_that_cannot_be_claimed_sends_nothing(self) -> None: + link = os.link + def no_claim(source, destination): + if str(destination).endswith(".started"): + raise OSError(28, "No space left on device") + return link(source, destination) + starts: list = [] + with mock.patch.object(_handshake.os, "link", side_effect=no_claim): + decision, _ = self._open(session_id="start-unclaimed", start=starts.append) + self.assertEqual((decision.cause, starts), ("session_record_unwritable", [])) + + def test_concurrent_hooks_without_a_startup_lock_are_refused_before_any_handshake(self) -> None: + Path(f"{self._record('start-unlocked')}.lock").mkdir(parents=True) + config = Config.from_env({"ACS_GUARDIAN_URL": URL}) + starts: list = [] + results: list = [] + barrier = threading.Barrier(2) + + def hook() -> None: + barrier.wait(timeout=5) + results.append(open_session(config=config, session_id="start-unlocked", agent_id="test", platform="test", + methods_implemented=["steps/toolCallRequest"], start=starts.append)) + # One patch for both threads, so neither thread restores the other's. + with mock.patch.object(_exchange, "_open", side_effect=_answer(lambda h: _result(h, HELLO))) as network: + threads = [threading.Thread(target=hook) for _ in range(2)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=10) + self.assertEqual([d.cause for d in results], ["session_startup_lock_unavailable"] * 2) + self.assertEqual((network.call_count, starts), (0, [])) + self.assertEqual([c.args[0] for c in self.audit.call_args_list], ["session_record_unwritable"] * 2) + + class UnsignedMode(_SessionTest): ENV: dict = {} diff --git a/adapters/tests/common/test_methods_evaluated.py b/adapters/tests/common/test_methods_evaluated.py index 42eb4660..d39a0e72 100644 --- a/adapters/tests/common/test_methods_evaluated.py +++ b/adapters/tests/common/test_methods_evaluated.py @@ -140,7 +140,7 @@ def test_prompt_proceeds_when_turn_and_message_are_unevaluated(self) -> None: self.assertEqual(guardian.methods_received(), ["handshake/hello"]) skipped = [a.get("method") for a in h.audits() if a.get("acs_audit_event") == "method_not_evaluated"] - self.assertEqual(skipped, ["steps/turnStart", "steps/userMessage"]) + self.assertEqual(skipped, ["steps/sessionStart", "steps/turnStart", "steps/userMessage"]) self.assertFalse(FAILURE_EVENTS & set(h.audit_names()), h.audit_names()) def test_unevaluated_gate_resolves_as_allow(self) -> None: @@ -228,7 +228,7 @@ def test_a_server_hello_without_methods_evaluated_negotiates_nothing(self) -> No if adapter == "claude-code": self.assertEqual(json.loads(p.stdout)["decision"], "block") else: - self.assertEqual(p.returncode, 2, p.stderr) + self.assertEqual((p.returncode, json.loads(p.stdout)["continue"]), (0, False), p.stderr) start = [a for a in h.audits() if a.get("acs_audit_event") == "session_without_negotiated_guardian"] self.assertEqual([(a["cause"], a["problem"]) for a in start], [("invalid_server_hello", "methods_evaluated is not a list of method names")]) diff --git a/adapters/tests/common/test_security.py b/adapters/tests/common/test_security.py index 7c1798f6..f0f49a11 100644 --- a/adapters/tests/common/test_security.py +++ b/adapters/tests/common/test_security.py @@ -673,7 +673,7 @@ def event(name: str) -> str: def test_cursor_hook_that_cannot_load_audits_and_honors_the_posture(self) -> None: """Gate events deny via {"permission": "deny"}; beforeSubmitPrompt - blocks via {"continue": false} and exit code 2.""" + blocks via {"continue": false} and exit code 0.""" event = json.dumps({"conversation_id": "c-1", "workspace_roots": ["/tmp"], "tool_name": "Bash", "tool_input": {"command": "rm -rf /home/u"}}) @@ -689,9 +689,8 @@ def test_cursor_hook_that_cannot_load_audits_and_honors_the_posture(self) -> Non with self.subTest(form=form, ACS_DEFAULT_DENY="1", event="beforeSubmitPrompt"): p, records = self._run(argv + ["beforeSubmitPrompt"], event, ACS_DEFAULT_DENY="1") - self.assertEqual(p.returncode, 2, - "beforeSubmitPrompt blocks via exit code 2; anything else " - "lets the prompt through on adapter load failure") + self.assertEqual(p.returncode, 0, + "beforeSubmitPrompt blocks with continue: false and exit code 0, which Cursor reads") self.assertIs(json.loads(p.stdout)["continue"], False) self._unavailable_record(records, "event", "beforeSubmitPrompt", "deny") diff --git a/adapters/tests/common/test_session_gate.py b/adapters/tests/common/test_session_gate.py index 4f873ecb..68b8807b 100644 --- a/adapters/tests/common/test_session_gate.py +++ b/adapters/tests/common/test_session_gate.py @@ -10,6 +10,7 @@ import time import unittest import uuid +from concurrent.futures import ThreadPoolExecutor from pathlib import Path from acs.testing.harness import ProgrammableGuardian @@ -100,7 +101,7 @@ def test_an_unguarded_session_never_contacts_a_restored_guardian(self) -> None: with self.subTest(name, phase="Guardian restored, new session"): guardian.reset() p, denied, audits = run(url, str(uuid.uuid4()), **expired) - self.assertEqual(guardian.methods_seen(), ["handshake/hello", "steps/toolCallRequest"]) + self.assertEqual(guardian.methods_seen(), ["handshake/hello", "steps/sessionStart", "steps/toolCallRequest"]) self.assertFalse(denied, p.stdout) def test_a_refused_session_sends_no_step_in_any_hook_process(self) -> None: @@ -134,7 +135,7 @@ def test_a_refused_start_holds_after_the_guardian_returns(self) -> None: self.assertEqual(guardian.methods_seen(), []) p, denied, _ = self._claude(url, str(uuid.uuid4()), ACS_STARTUP_POSTURE="refuse") self.assertFalse(denied, p.stdout) - self.assertEqual(guardian.methods_seen(), ["handshake/hello", "steps/toolCallRequest"]) + self.assertEqual(guardian.methods_seen(), ["handshake/hello", "steps/sessionStart", "steps/toolCallRequest"]) def test_a_deeply_nested_record_refuses_through_the_installed_hooks(self) -> None: """A record nested past Python's recursion limit must refuse natively, not crash the hook (exit 1 proceeds).""" @@ -185,13 +186,14 @@ def test_an_adapter_error_keeps_the_negotiated_deny(self) -> None: url = "http://127.0.0.1:1/acs" hello = {"negotiated_version": "0.1.0", "on_decision_failure": "deny", "timeout_config": {"default_ms": 2000}} - # Claude Code signs transcript_path into its turnStart; Cursor, with turnStart not - # evaluated, first touches the workspace root when it opens the turn. + # Neither Guardian evaluates sessionStart, so the prompt's own step fails. Claude Code signs + # transcript_path into its turnStart; Cursor, with turnStart not evaluated, first touches the + # workspace root when it opens the turn. cases = { - "claude-code": (self._claude_prompt, hello, + "claude-code": (self._claude_prompt, {**hello, "methods_evaluated": ["steps/turnStart", "steps/userMessage"]}, lambda p: json.loads(p.stdout).get("decision") == "block"), "cursor": (self._cursor_prompt, {**hello, "methods_evaluated": ["steps/userMessage"]}, - lambda p: p.returncode == 2 and json.loads(p.stdout)["continue"] is False), + lambda p: p.returncode == 0 and json.loads(p.stdout)["continue"] is False), } for name, (run, server_hello, blocked) in cases.items(): for case, text in (("lone surrogate fails inside the step", "\\ud800"), ("control", "/tmp")): @@ -202,7 +204,8 @@ def test_an_adapter_error_keeps_the_negotiated_deny(self) -> None: p = run(url, session, text, ACS_DEFAULT_DENY="0") self.assertTrue(blocked(p), f"stdout={p.stdout!r} stderr={p.stderr[-300:]!r}") audits = [(r["acs_audit_event"], r.get("cause")) for r in _audit_records(p.stderr) - if r["acs_audit_event"] != "method_not_evaluated"] + if r["acs_audit_event"] != "method_not_evaluated" + and r.get("method") != "steps/sessionStart"] cause = "adapter_exception" if case.startswith("lone") else "transport_failure" self.assertEqual(audits, [("decision_failure_fail_closed", cause)], "the surrogate must fail signing inside the step, and the control must reach the transport") @@ -249,7 +252,7 @@ def test_integral_timeouts_written_as_decimals_still_negotiate(self) -> None: self.addCleanup(guardian.stop) p, denied, audits = self._claude(guardian.url(), str(uuid.uuid4()), ACS_STARTUP_POSTURE="proceed") self.assertTrue(denied, p.stderr[-300:]) - self.assertEqual(guardian.methods_seen(), ["handshake/hello", "steps/toolCallRequest"]) + self.assertEqual(guardian.methods_seen(), ["handshake/hello", "steps/sessionStart", "steps/toolCallRequest"]) self.assertNotIn("session_without_negotiated_guardian", audits) def test_a_later_hooks_required_profile_refuses_a_stored_negotiation(self) -> None: @@ -291,10 +294,10 @@ def test_a_denied_turn_start_blocks_the_prompt_and_sends_no_message(self) -> Non env=self._env(guardian.url()), timeout=30) out = json.loads(p.stdout or "{}") blocked = (out.get("decision") == "block" if name == "claude-code" - else p.returncode == 2 and out.get("continue") is False) + else p.returncode == 0 and out.get("continue") is False) self.assertTrue(blocked, (p.returncode, p.stdout, p.stderr[-300:])) self.assertIn("turn_start_blocked", [r["acs_audit_event"] for r in _audit_records(p.stderr)]) - self.assertEqual(guardian.methods_seen(), ["handshake/hello", "steps/turnStart"], + self.assertEqual(guardian.methods_seen(), ["handshake/hello", "steps/sessionStart", "steps/turnStart"], "a blocked prompt's userMessage is never sent") def test_mcp_use_records_the_degraded_claim_once_per_session(self) -> None: @@ -378,7 +381,171 @@ def step(req: dict) -> dict: p, denied, audits = self._claude(guardian.url(), session) self.assertTrue(denied, "the second step reached the Guardian and its deny applied") self.assertEqual(guardian.methods_seen(), - ["handshake/hello", "steps/toolCallRequest", "steps/toolCallRequest"]) + ["handshake/hello", "steps/sessionStart", "steps/toolCallRequest", "steps/toolCallRequest"]) + + + # One session's first-prompt hooks per host, the session hook last, the order Cursor ran them live. + STARTUP = { + "claude-code": [([str(BIN / "acs-claude-code-hook")], {"hook_event_name": "UserPromptSubmit", "prompt": "hello"}), + ([str(BIN / "acs-claude-code-hook")], {"hook_event_name": "PreToolUse", "tool_name": "Bash", + "tool_input": {"command": "echo hi"}}), + ([str(BIN / "acs-claude-code-hook")], {"hook_event_name": "SessionStart", "source": "startup"})], + "cursor": [([str(BIN / "acs-cursor-hook"), "beforeSubmitPrompt"], {"prompt": "hello"}), + ([str(BIN / "acs-cursor-hook"), "preToolUse"], {"tool_name": "Bash", "tool_input": {"command": "echo hi"}}), + ([str(BIN / "acs-cursor-hook"), "sessionStart"], {})], + } + + def _startup(self, host: str, url: str, *, together: bool, **extra: str) -> list[tuple[str, subprocess.CompletedProcess]]: + """Runs a new session's startup hooks: in parallel, the session hook 0.2 s after the others, or one by one, the session hook first.""" + session = str(uuid.uuid4()) + key = ({"session_id": session, "cwd": "/tmp"} if host == "claude-code" + else {"conversation_id": session, "workspace_roots": ["/tmp"]}) + + def run(argv: list[str], event: dict) -> subprocess.CompletedProcess: + return subprocess.run(argv, input=json.dumps({**key, **event}), capture_output=True, text=True, + env=self._env(url, **extra), timeout=30) + + hooks = self.STARTUP[host] + names = [event.get("hook_event_name") or argv[1] for argv, event in hooks] + if not together: + return [(names[-1], run(*hooks[-1]))] + [(n, run(*h)) for n, h in zip(names[:-1], hooks[:-1])] + with ThreadPoolExecutor(len(hooks)) as pool: + futures = [pool.submit(run, *hook) for hook in hooks[:-1]] + time.sleep(0.2) + futures.append(pool.submit(run, *hooks[-1])) + return [(n, f.result()) for n, f in zip(names, futures)] + + @staticmethod + def _blocked(name: str, p: subprocess.CompletedProcess) -> bool | None: + """Whether a hook process blocked its step, in that hook's own shape; None for the session hooks, which decide nothing.""" + out = json.loads(p.stdout or "{}") + return {"UserPromptSubmit": out.get("decision") == "block", + "PreToolUse": out.get("hookSpecificOutput", {}).get("permissionDecision") == "deny", + "beforeSubmitPrompt": out.get("continue") is False, + "preToolUse": out.get("permission") == "deny"}.get(name) + + def _slow_hello(self, seconds: float, answer=None) -> ProgrammableGuardian: + guardian = ProgrammableGuardian(hmac_secret=SECRET, threaded=True) + answer = answer or guardian._default_handshake + guardian.handlers["handshake/hello"] = lambda req: (time.sleep(seconds), answer(req))[1] + guardian.start() + self.addCleanup(guardian.stop) + return guardian + + def test_parallel_startup_hooks_send_one_handshake_then_session_start(self) -> None: + guardian = self._slow_hello(0.5) + for host in self.STARTUP: + with self.subTest(host): + guardian.reset() + self._startup(host, guardian.url(), together=False) + one_by_one = guardian.methods_seen() + guardian.reset() + results = self._startup(host, guardian.url(), together=True) + seen = guardian.methods_seen() + self.assertEqual((seen.count("handshake/hello"), seen.count("steps/sessionStart")), (1, 1), seen) + self.assertEqual(seen[:2], ["handshake/hello", "steps/sessionStart"], + "every step of the session follows its one handshake and its sessionStart (§8)") + self.assertEqual(sorted(seen), sorted(one_by_one), "parallel hooks send what the same hooks send one by one") + self.assertEqual(len({r["params"]["metadata"]["session_id"] for r in guardian.received}), 1) + for name, p in results: + self.assertEqual((name, p.returncode, self._blocked(name, p)), + (name, 0, None if self._blocked(name, p) is None else False), p.stderr[-300:]) + + def test_a_resumed_or_compacted_claude_session_keeps_its_one_session_start(self) -> None: + guardian = ProgrammableGuardian(hmac_secret=SECRET) + guardian.handlers["steps/sessionStart"] = lambda req: { + "type": "final", "acs_version": "0.1.0", "request_id": req["params"]["request_id"], + "decision": "allow", "reasoning": "context from the Guardian", "chain_hash": "0" * 64} + guardian.start() + self.addCleanup(guardian.stop) + session = str(uuid.uuid4()) + outputs = [] + for source in ("startup", "resume", "compact"): + event = {"session_id": session, "cwd": "/tmp", "hook_event_name": "SessionStart", "source": source} + p = subprocess.run([str(BIN / "acs-claude-code-hook")], input=json.dumps(event), capture_output=True, + text=True, env=self._env(guardian.url()), timeout=30) + self.assertEqual(p.returncode, 0, p.stderr[-300:]) + outputs.append(json.loads(p.stdout) if p.stdout.strip() else None) + self.assertEqual(guardian.methods_seen(), ["handshake/hello", "steps/sessionStart"]) + self.assertEqual(outputs, [{"hookSpecificOutput": {"hookEventName": "SessionStart", + "additionalContext": "context from the Guardian"}}, None, None]) + self.assertEqual(guardian.envelopes_for("steps/sessionStart")[0]["params"]["payload"]["platform_context"]["source"], + "startup") + + 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)} + for host, run in (("claude-code", self._claude), ("cursor", self._cursor)): + for name, (answer, on_failure, proceeds) in cases.items(): + with self.subTest(host, case=name): + guardian = ProgrammableGuardian(hmac_secret=SECRET, threaded=True) + + def hello(req, base=guardian._default_handshake, on_failure=on_failure): + return {**base(req), "on_decision_failure": on_failure, "timeout_config": {"default_ms": 300}} + + def start(req, answer=answer, allow=guardian._default_allow): + if answer == "refusal": + return {"code": -32000, "message": "session start refused"} + if answer == "timeout": + time.sleep(0.6) + return {**allow(req), "decision": "deny" if answer == "deny" else "allow", + "reasoning": "start policy"} + guardian.handlers.update({"handshake/hello": hello, "steps/sessionStart": start}) + guardian.start() + self.addCleanup(guardian.stop) + session = str(uuid.uuid4()) + for process in (1, 2): + p, denied, audits = run(guardian.url(), session) + self.assertEqual(denied, not proceeds, (process, p.stdout, audits)) + self.assertEqual(guardian.methods_seen(), ["handshake/hello", "steps/sessionStart"] + + (["steps/toolCallRequest"] * 2 if proceeds else [])) + + def test_a_hook_its_own_profile_requirement_refuses_leaves_the_start_to_the_next_hook(self) -> None: + guardian = ProgrammableGuardian(hmac_secret=SECRET) + guardian.handlers["handshake/hello"] = lambda req: {**guardian._default_handshake(req), "profiles_accepted": []} + guardian.start() + self.addCleanup(guardian.stop) + for host, run in (("claude-code", self._claude), ("cursor", self._cursor)): + with self.subTest(host): + guardian.reset() + session = str(uuid.uuid4()) + p, denied, audits = run(guardian.url(), session, ACS_REQUIRE_PROFILES="acs-core") + self.assertTrue(denied, p.stdout) + self.assertEqual(guardian.methods_seen(), ["handshake/hello"]) + p, denied, _ = run(guardian.url(), session) + self.assertFalse(denied, p.stdout) + self.assertEqual(guardian.methods_seen(), ["handshake/hello", "steps/sessionStart", "steps/toolCallRequest"]) + + def test_parallel_startup_hooks_follow_one_refusal(self) -> None: + guardian = self._slow_hello(0.5, lambda req: {"code": -32000, "message": "agent not permitted"}) + for host in self.STARTUP: + with self.subTest(host): + guardian.reset() + results = self._startup(host, guardian.url(), together=True) + self.assertEqual(guardian.methods_seen(), ["handshake/hello"]) + audits = [a for _, p in results for a in (r["acs_audit_event"] for r in _audit_records(p.stderr))] + self.assertEqual(audits.count("session_refused"), 1, audits) + for name, p in results: + self.assertEqual(p.returncode, 0, (name, p.stderr[-300:])) + self.assertIn(self._blocked(name, p), (True, None), (name, p.stdout)) + + def test_parallel_startup_hooks_follow_one_unanswered_handshake(self) -> None: + guardian = self._slow_hello(3) + for host in self.STARTUP: + with self.subTest(host): + guardian.reset() + results = self._startup(host, guardian.url(), together=True, ACS_HANDSHAKE_TIMEOUT_SECONDS="1") + 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) + for name, p in results: + self.assertEqual(p.returncode, 0, (name, p.stderr[-300:])) + if self._blocked(name, p) is not None: + self.assertFalse(self._blocked(name, p), (name, p.stdout)) + self.assertIn("unguarded_session_bypass", audits[name]) if __name__ == "__main__": diff --git a/adapters/tests/conformance/test_acs_core_conformance.py b/adapters/tests/conformance/test_acs_core_conformance.py index a4e9ae86..a9561578 100644 --- a/adapters/tests/conformance/test_acs_core_conformance.py +++ b/adapters/tests/conformance/test_acs_core_conformance.py @@ -984,9 +984,9 @@ def test_cursor_non_allow_on_prompt_blocks_exit2(self) -> None: "workspace_roots": ["/tmp"], "prompt": "do a thing"}), capture_output=True, text=True, env=self._env(g), timeout=15) - self.assertEqual(proc.returncode, 2, - f"{disp} on a Cursor prompt must block via exit 2, not " - f"proceed (exit 0); got rc={proc.returncode}") + self.assertEqual((proc.returncode, json.loads(proc.stdout).get("continue")), (0, False), + f"{disp} on a Cursor prompt must block with continue: false; got rc={proc.returncode} " + f"stdout={proc.stdout!r}") def test_contradictory_modify_fails_closed(self) -> None: """§6.3:146 / modifications.json: a modify carrying BOTH wholesale diff --git a/adapters/tests/integrations/claude_code/test_emission.py b/adapters/tests/integrations/claude_code/test_emission.py index 8e1dd939..689629f9 100644 --- a/adapters/tests/integrations/claude_code/test_emission.py +++ b/adapters/tests/integrations/claude_code/test_emission.py @@ -110,8 +110,8 @@ def test_bare_stop_with_no_open_turn_emits_nothing(self): # and emits nothing (full lifecycle: ClaudeSequentialSession). g = self._emit(_event("Stop", permission_mode="default")) content = [m for m in g.methods() if m.startswith("steps/")] - self.assertEqual(content, [], - f"a Stop with no open turn must emit no step; got {content}") + self.assertEqual(content, ["steps/sessionStart"], + f"a Stop that opens the session sends sessionStart and, with no open turn, no turnEnd; got {content}") def test_subagent_start_via_agent_tool(self): # The spawn tool is `Agent` (docs 2026-08-22), legacy `Task`; diff --git a/adapters/tests/integrations/cursor/test_adapter.py b/adapters/tests/integrations/cursor/test_adapter.py index b2d45671..4efe0b71 100644 --- a/adapters/tests/integrations/cursor/test_adapter.py +++ b/adapters/tests/integrations/cursor/test_adapter.py @@ -179,16 +179,16 @@ 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_before_submit_prompt_block_via_exit_code(self) -> None: - """beforeSubmitPrompt blocks via exit code 2, not stdout (fail-closed mode). - Audit log carries cause=transport_failure since the Guardian is unreachable.""" - rc, _, err = self._run("beforeSubmitPrompt", + def test_before_submit_prompt_block_with_readable_message(self) -> None: + rc, out, err = self._run("beforeSubmitPrompt", {"session_id": "cur-12", "prompt": "anything"}, env_overrides={"ACS_GUARDIAN_URL": "http://127.0.0.1:1/dead", "ACS_DEFAULT_DENY": "1", **self._negotiated("http://127.0.0.1:1/dead", coerce_uuid("cur-12", namespace_prefix="cursor"))}, ) - self.assertEqual(rc, 2) + self.assertEqual(rc, 0) + self.assertEqual(json.loads(out), {"continue": False, + "user_message": "ACS adapter: prompt blocked (transport_failure)"}) self.assertIn("prompt blocked", err.lower()) self.assertIn("ACS_AUDIT", err) self.assertIn("transport_failure", err, diff --git a/adapters/tests/integrations/cursor/test_emission.py b/adapters/tests/integrations/cursor/test_emission.py index 0a79aa94..1b01e79c 100644 --- a/adapters/tests/integrations/cursor/test_emission.py +++ b/adapters/tests/integrations/cursor/test_emission.py @@ -227,8 +227,8 @@ def test_bare_stop_with_no_open_turn_emits_nothing(self): # nothing and audits it (full lifecycle: CursorSequentialSession). g = self._emit("stop", {"status": "completed"}) content = [m for m in g.methods() if m.startswith("steps/")] - self.assertEqual(content, [], - f"a stop with no open turn must emit no step; got {content}") + self.assertEqual(content, ["steps/sessionStart"], + f"a stop that opens the session sends sessionStart and, with no open turn, no turnEnd; got {content}") def test_unknown_event_emits_no_core_hook(self): g = self._emit("someRenamedUpstreamHook", {}) diff --git a/adapters/tests/integrations/nat/test_adapter.py b/adapters/tests/integrations/nat/test_adapter.py index 8fc6b9e3..453fc13d 100644 --- a/adapters/tests/integrations/nat/test_adapter.py +++ b/adapters/tests/integrations/nat/test_adapter.py @@ -85,6 +85,14 @@ def tearDownClass(cls) -> None: except subprocess.TimeoutExpired: cls.guardian_proc.kill() + def setUp(self) -> None: + # Per-test handshake records, so no record left in the user's cache decides a session here. + records = tempfile.TemporaryDirectory(prefix="acs-handshake-") + self.addCleanup(records.cleanup) + patcher = mock.patch.object(client_handshake, "_HANDSHAKE_CACHE_DIR", Path(records.name)) + patcher.start() + self.addCleanup(patcher.stop) + def _make_middleware(self, default_deny: bool = True) -> ACSMiddleware: cfg = ACSMiddlewareConfig( guardian_url=self.guardian_url, diff --git a/adapters/tests/integrations/nat/test_dispositions_live.py b/adapters/tests/integrations/nat/test_dispositions_live.py index 3dccc560..6a62c6ab 100644 --- a/adapters/tests/integrations/nat/test_dispositions_live.py +++ b/adapters/tests/integrations/nat/test_dispositions_live.py @@ -23,9 +23,11 @@ import asyncio import sys +import tempfile import unittest from pathlib import Path from types import SimpleNamespace +from unittest import mock try: from nat.middleware.middleware import ( # type: ignore[import-not-found] @@ -41,6 +43,7 @@ if _NAT_OK: from acs.integrations.nat.adapter import ACSMiddlewareConfig # noqa: E402 +from acs.client import _handshake as client_handshake # noqa: E402 from acs.testing.harness import ProgrammableGuardian # noqa: E402 @@ -103,6 +106,12 @@ def _restore(): def setUp(self) -> None: self.guardian.reset() + # Per-test handshake records, so no record left in the user's cache decides a session here. + records = tempfile.TemporaryDirectory(prefix="acs-handshake-") + self.addCleanup(records.cleanup) + patcher = mock.patch.object(client_handshake, "_HANDSHAKE_CACHE_DIR", Path(records.name)) + patcher.start() + self.addCleanup(patcher.stop) def _set_response(self, method: str, response: dict) -> None: def handler(req): diff --git a/adapters/tests/integrations/nat/test_runner_lifecycle.py b/adapters/tests/integrations/nat/test_runner_lifecycle.py index f4e4dd49..e04a6409 100644 --- a/adapters/tests/integrations/nat/test_runner_lifecycle.py +++ b/adapters/tests/integrations/nat/test_runner_lifecycle.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import contextlib import contextvars import os import tempfile @@ -54,6 +55,18 @@ async def target(command: str) -> str: return await self.middleware.function_middleware_invoke(command=message, call_next=target, context=context) +class _RunFailed(Exception): + pass + + +class _Failing(_Entry): + """A workflow entry function that fails after its wrapped call.""" + + async def ainvoke(self, message: str, to_type=None) -> str: + await super().ainvoke(message, to_type) + raise _RunFailed(message) + + @unittest.skipUnless(_NAT_OK, "nvidia-nat-core not installed") class EveryRunReportsItsLifecycle(unittest.TestCase): def setUp(self) -> None: @@ -68,7 +81,7 @@ def setUp(self) -> None: self.guardian.start() self.addCleanup(self.guardian.stop) - def _runs(self, *messages: str, with_builder: bool = True) -> list[str]: + def _runs(self, *messages: str, with_builder: bool = True, entry: type[_Entry] = _Entry) -> list[str]: """Run each message through NAT's Runner with the exporters the middleware's builder registered.""" executed: list[str] = [] self.exporters: list[str] = [] @@ -83,8 +96,9 @@ async def run() -> None: self.exporters = [name.split("_")[0] + "_" + name.split("_")[1] for name in exporters] manager = ExporterManager.from_exporters(exporters) for message in messages: - async with Runner(message, _Entry(middleware, executed), ContextState.get(), manager) as runner: - await runner.result() + async with Runner(message, entry(middleware, executed), ContextState.get(), manager) as runner: + with contextlib.suppress(_RunFailed): + await runner.result() await middleware._lifecycle_sent() asyncio.run(run()) return executed @@ -94,6 +108,11 @@ def test_two_consecutive_runs_each_report_every_hook_in_order(self) -> None: self.assertEqual(self.exporters, ["acs_lifecycle"]) self.assertEqual(self.guardian.methods_seen(), ["handshake/hello"] + RUN + RUN) + def test_a_failed_run_reports_no_end(self) -> None: + """NAT pushes WORKFLOW_END only once the workflow returns, so a failed run sends no agentResponse or sessionEnd.""" + self.assertEqual(self._runs("first", "second", entry=_Failing), ["first", "second"]) + self.assertEqual(self.guardian.methods_seen(), ["handshake/hello"] + RUN[:4] + RUN[:4]) + def test_without_the_builder_each_run_reports_from_its_first_call(self) -> None: self.assertEqual(self._runs("first", "second", with_builder=False), ["first", "second"]) self.assertEqual(self.guardian.methods_seen(), ["handshake/hello"] + RUN[2:] + RUN[2:], diff --git a/adapters/tests/integrations/nat/test_wire.py b/adapters/tests/integrations/nat/test_wire.py new file mode 100644 index 00000000..5b2f05be --- /dev/null +++ b/adapters/tests/integrations/nat/test_wire.py @@ -0,0 +1,50 @@ +"""acs-nat-wire wires and checks every point NAT gates: the workflow, each function group and each function.""" +from __future__ import annotations + +import unittest + +try: + from acs.integrations.nat import wire + _NAT_OK = True +except ImportError: + _NAT_OK = False + +PLAIN = """\ +general: + use_uvloop: false + +functions: + protected_write: + _type: acs_protected_write + lookup: + _type: current_datetime + middleware: + - content_filter + +workflow: + _type: acs_nested_outer + inner: protected_write +""" + + +@unittest.skipUnless(_NAT_OK, "nvidia-nat-core is not installed") +class WireCoversEveryFunction(unittest.TestCase): + def test_check_reports_every_function_without_acs_guardian(self) -> None: + gaps = wire.find_attachment_points(wire._yaml().load(PLAIN)) + self.assertEqual(sorted(g.path for g in gaps if g.kind == "function"), + ["functions.lookup", "functions.protected_write"], + "a function's middleware gates only the calls to that function, so each one needs acs_guardian") + + def test_write_wires_every_function_and_unwire_restores_the_file(self) -> None: + doc = wire._yaml().load(PLAIN) + wire.install(doc, guardian_url="http://127.0.0.1:8787/acs", default_deny=False, timeout_s=5.0) + self.assertEqual(wire.find_attachment_points(doc), []) + functions = doc["functions"] + self.assertEqual([str(m) for m in functions["protected_write"]["middleware"]], ["acs_guardian"]) + self.assertEqual([str(m) for m in functions["lookup"]["middleware"]], ["acs_guardian", "content_filter"]) + restored, _ = wire.unwire(wire.render(doc)) + self.assertEqual(restored, PLAIN) + + +if __name__ == "__main__": + unittest.main()