diff --git a/adapters/SECURITY.md b/adapters/SECURITY.md index db585b44..e2f6a6bc 100644 --- a/adapters/SECURITY.md +++ b/adapters/SECURITY.md @@ -65,7 +65,10 @@ Attacker controls the env var; sets it to `file:///etc/passwd` to read arbitrary files, or to `data://` to feed a crafted response. *Mitigation:* `validate_guardian_url()` rejects any scheme that is not `http` or `https`. Called from `acs.client.exchange` (every -step's round trip), `open_session` and `ping`. +step's round trip), `open_session` and `ping`. The step and handshake +exchanges do not follow redirects, so a response cannot send the +request to a host the check never saw; a redirect status is a +decision failure under the posture. *Test:* `GuardianUrlSchemeAllowlist::test_file_scheme_rejected` and five other scheme rejections. diff --git a/adapters/docs/claude_code/README.md b/adapters/docs/claude_code/README.md index c0b7e522..f2b36378 100644 --- a/adapters/docs/claude_code/README.md +++ b/adapters/docs/claude_code/README.md @@ -267,10 +267,11 @@ 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_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. | -| `ACS_HANDSHAKE_TIMEOUT_SECONDS` | `5` | Network timeout for handshake/hello. | +| `ACS_HANDSHAKE_TIMEOUT_SECONDS` | `5` | Deadline for the whole handshake/hello exchange, including a response that arrives slowly. | | `ACS_AUDIT_FILE` | (unset) | Append every `ACS_AUDIT` event to this file (created 0600) in addition to stderr — the durable audit half of §6.4's fail-open trade. | | `ACS_DISABLED` | (unset) | `1` = incident kill switch: bypass all hooks immediately, no Guardian traffic. | | `ACS_GUARDIAN_HOST_ALLOWLIST` | (unset) | Optional comma-separated hostname allowlist (defense in depth). | @@ -287,6 +288,7 @@ Honest, item-by-item against the ACS-Core baseline in `docs/spec/conformance.md` | ACS-Core item | Status | |---|---| | Handshake (`handshake/hello`) | ✓ adapter sends ClientHello on the first event of a session; the decision (negotiated, refused, or no answer under `ACS_STARTUP_POSTURE`) holds for the session's lifetime (§4.1). | +| Profiles (`profiles_supported` / `profiles_accepted`) | ✓ claims `acs-core` when a signing secret is configured and no profile without one. A ServerHello accepting a profile the adapter did not claim is not a valid answer, a claimed profile the Guardian leaves out is audited as `profile_not_accepted`, and a hook's `ACS_REQUIRE_PROFILES` turns a left-out profile into a refusal of that hook's steps. | | JSON-RPC envelope shape (`request-envelope.json`) | ✓ validates against canonical schema for every mapped hook (`tests/test_envelope_schema.py`); format checking enforces `uuid` and `date-time`. | | Hook taxonomy (Core minimum set) | ✓ `sessionStart`, `userMessage`, `toolCallRequest`, `toolCallResult`, `agentResponse`, `sessionEnd`; plus `subagentStart`, which ACS-Core requires of a subagent-capable client. Claude Code spawns subagents through the Agent tool (legacy `Task`), and the adapter remaps that PreToolUse to `steps/subagentStart` (see mapping.md). `subagentStop`, which ACS-Core lists as SHOULD-emit, stays deliberately unmapped (see mapping.md). The adapter also emits the turn boundary: `turnStart` (emitted on UserPromptSubmit) / `turnEnd` (Stop). `preCompact` deliberately unmapped — the platform exposes no entry list (mapping.md). | | Dispositions (ALLOW/DENY/ASK/DEFER) | ✓ on **pre-execution** hooks (`PreToolUse`, `UserPromptSubmit`, `turnStart`); MODIFY partial (`PreToolUse` with `parameter_overrides`, merged onto the original input). **Post-execution and lifecycle hooks (`PostToolUse`, `Notification → agentResponse`, `Stop → turnEnd`, `SessionEnd`) are observation-only** — Claude Code fires them after the side effect / message / turn has occurred; a Guardian `deny` on those cannot undo it. See `mapping.md`. | diff --git a/adapters/docs/claude_code/mapping.md b/adapters/docs/claude_code/mapping.md index e819e4c1..0b132d49 100644 --- a/adapters/docs/claude_code/mapping.md +++ b/adapters/docs/claude_code/mapping.md @@ -40,7 +40,7 @@ An arrived decision the host CANNOT apply is substituted to a block/deny (never The Claude Code adapter implements ACS-Core's mandatory floor. Every row below describes what the code in `adapter.py` actually does — this table drifted from the code once (PR #22 review caught six false claims in it), so treat the code as ground truth and this table as its description: -- Handshake: `handshake/hello` is performed on the first hook event of a session, and its answer decides the session (§4.1): a verified ServerHello negotiates; a refusal, or an answer that fails its checks, refuses every step; and no answer leaves the session with no negotiated Guardian, so each hook's `ACS_STARTUP_POSTURE` applies (`proceed` lets the step run with nothing sent to the Guardian; `refuse` denies it). The decision is stored in `~/.cache/acs-adapter-handshake/` with an HMAC under the session key over the decision, the session and the Guardian URL, and holds for the session's lifetime, so a dead Guardian costs one timeout per session; a record that fails its checks, or a decision that cannot be stored, refuses the session. The ServerHello's response envelope is signature-verified and bound to the ClientHello before it is stored; its `on_decision_failure` combines with `ACS_DEFAULT_DENY` (deny wins over proceed), and its `timeout_config.default_ms` replaces the local decision timeout (§6.4). Its `methods_evaluated` bounds what is sent: a method the Guardian omits resolves as ALLOW-by-default without being sent (handshake.json) and is recorded as `method_not_evaluated`; the hook returns no output, so Claude Code's own permission flow still applies, and a result never cites a request that was not sent. With no negotiated list, every mapped hook is sent. `ACS_HANDSHAKE=0` skips the handshake, and the startup posture settles every step. +- Handshake: `handshake/hello` is performed on the first hook event of a session, and its answer decides the session (§4.1): a verified ServerHello negotiates; a refusal, or an answer that fails its checks, refuses every step; and no answer leaves the session with no negotiated Guardian, so each hook's `ACS_STARTUP_POSTURE` applies (`proceed` lets the step run with nothing sent to the Guardian; `refuse` denies it). The decision is stored in `~/.cache/acs-adapter-handshake/` with an HMAC under the session key over the decision, the session and the Guardian URL, and holds for the session's lifetime, so a dead Guardian costs one timeout per session; a record that fails its checks, or a decision that cannot be stored, refuses the session. The ServerHello's response envelope is signature-verified and bound to the ClientHello before it is stored; its `on_decision_failure` combines with `ACS_DEFAULT_DENY` (deny wins over proceed), and its `timeout_config` replaces the local decision timeout, per method where `per_method_ms` names one (§6.4). The ServerHello must answer the ClientHello: a `negotiated_version` sharing a major version the adapter offered, a `methods_evaluated` list, the one transport the adapter offers (its Guardian URL's scheme), a positive `timeout_config.default_ms`, an `on_decision_failure` of `proceed` or `deny`, and only profiles the adapter claimed; otherwise the session has no negotiated Guardian (`invalid_server_hello`). Its `methods_evaluated` bounds what is sent: a method the Guardian omits resolves as ALLOW-by-default without being sent (handshake.json) and is recorded as `method_not_evaluated`; the hook returns no output, so Claude Code's own permission flow still applies, and a result never cites a request that was not sent. With no negotiated list, every mapped hook is sent. `ACS_HANDSHAKE=0` skips the handshake, and the startup posture settles every step. - Turns: a `UserPromptSubmit` opens a turn with an explicit `steps/turnStart` (decision-eligible) before the `steps/userMessage`; `Stop` closes it with `steps/turnEnd`. The `turn_id` is held in per-session state and stamped on `metadata.turn_id` of every in-turn step; session-level steps (`sessionStart`/`sessionEnd`) carry none. - Dispositions: ALLOW / DENY / ASK are honored natively on `PreToolUse` (`permissionDecision`); MODIFY via `updatedInput` (overrides merged onto the original input) when a usable `parameter_overrides` object is present; DEFER substitutes to deny with an audit event. **Post-execution and lifecycle hooks (`PostToolUse`, `Notification → agentResponse`, `Stop → turnEnd`, `SessionEnd`, `SubagentStop`) are observation-only**: Claude Code fires them after the action / message / turn / session has completed; a Guardian `deny` cannot undo the side effect. Deployments needing pre-delivery gating must place the gate at `UserPromptSubmit` (prompt / turn start) for prompts or `PreToolUse` for tools. - Session context: `session_id` comes from Claude Code's own hook event field on every request; the Guardian-side audit chain accumulates against it. `agent_id` is `ACS_AGENT_ID` when set, else derived as `claude-code:`. diff --git a/adapters/docs/cursor/README.md b/adapters/docs/cursor/README.md index 7bfc4019..c7711c82 100644 --- a/adapters/docs/cursor/README.md +++ b/adapters/docs/cursor/README.md @@ -288,10 +288,11 @@ 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_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. | -| `ACS_HANDSHAKE_TIMEOUT_SECONDS` | `5` | Network timeout for handshake/hello. | +| `ACS_HANDSHAKE_TIMEOUT_SECONDS` | `5` | Deadline for the whole handshake/hello exchange, including a response that arrives slowly. | | `ACS_AUDIT_FILE` | (unset) | Append every `ACS_AUDIT` event to this file (created 0600) in addition to stderr. | | `ACS_DISABLED` | (unset) | `1` = incident kill switch: bypass all hooks immediately, no Guardian traffic. | | `ACS_GUARDIAN_HOST_ALLOWLIST` | (unset) | Optional comma-separated hostname allowlist (defense in depth). | @@ -311,6 +312,7 @@ Honest, item-by-item against the ACS-Core baseline in `docs/spec/conformance.md` | ACS-Core item | Status | |---|---| | Handshake (`handshake/hello`) | ✓ on the first event of a session; the decision (negotiated, refused, or no answer under `ACS_STARTUP_POSTURE`) holds for the session's lifetime (§4.1) | +| Profiles (`profiles_supported` / `profiles_accepted`) | ✓ claims `acs-core` when a signing secret is configured and no profile without one. A ServerHello accepting a profile the adapter did not claim is not a valid answer, a claimed profile the Guardian leaves out is audited as `profile_not_accepted`, and a hook's `ACS_REQUIRE_PROFILES` turns a left-out profile into a refusal of that hook's steps. | | 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`. | diff --git a/adapters/docs/cursor/mapping.md b/adapters/docs/cursor/mapping.md index 39ccba13..45a4c5ce 100644 --- a/adapters/docs/cursor/mapping.md +++ b/adapters/docs/cursor/mapping.md @@ -93,7 +93,7 @@ Cursor's per-hook `failClosed: true` makes Cursor block when the hook crashes, t The Cursor adapter implements ACS-Core's mandatory floor in the same shape as the Claude Code adapter: -- Handshake: `handshake/hello` performed on the first event of a session, and its answer decides the session (§4.1): a verified ServerHello negotiates; a refusal, or an answer that fails its checks, refuses every step; and no answer leaves the session with no negotiated Guardian, so each hook's `ACS_STARTUP_POSTURE` applies (`proceed` lets the step run with nothing sent to the Guardian; `refuse` denies it). The decision is stored in `~/.cache/acs-adapter-handshake/` with an HMAC under the session key over the decision, the session and the Guardian URL, and holds for the session's lifetime, so a dead Guardian costs one timeout per session; a record that fails its checks, or a decision that cannot be stored, refuses the session. ServerHello signature-verified, bound to the ClientHello, its `on_decision_failure` combines with `ACS_DEFAULT_DENY` (deny wins over proceed), and its `timeout_config.default_ms` replaces the local decision timeout (§6.4). Its `methods_evaluated` bounds what is sent: a method the Guardian omits resolves as ALLOW-by-default without being sent (handshake.json), recorded as `method_not_evaluated`; permission events and `beforeSubmitPrompt` return their explicit allow shapes, since `failClosed` reads an empty reply as a failed hook. With no negotiated list, every mapped event is sent. `ACS_HANDSHAKE=0` skips the handshake, and the startup posture settles every step. +- Handshake: `handshake/hello` performed on the first event of a session, and its answer decides the session (§4.1): a verified ServerHello negotiates; a refusal, or an answer that fails its checks, refuses every step; and no answer leaves the session with no negotiated Guardian, so each hook's `ACS_STARTUP_POSTURE` applies (`proceed` lets the step run with nothing sent to the Guardian; `refuse` denies it). The decision is stored in `~/.cache/acs-adapter-handshake/` with an HMAC under the session key over the decision, the session and the Guardian URL, and holds for the session's lifetime, so a dead Guardian costs one timeout per session; a record that fails its checks, or a decision that cannot be stored, refuses the session. ServerHello signature-verified, bound to the ClientHello, its `on_decision_failure` combines with `ACS_DEFAULT_DENY` (deny wins over proceed), and its `timeout_config` replaces the local decision timeout, per method where `per_method_ms` names one (§6.4). The ServerHello must answer the ClientHello: a `negotiated_version` sharing a major version the adapter offered, a `methods_evaluated` list, the one transport the adapter offers (its Guardian URL's scheme), a positive `timeout_config.default_ms`, an `on_decision_failure` of `proceed` or `deny`, and only profiles the adapter claimed; otherwise the session has no negotiated Guardian (`invalid_server_hello`). Its `methods_evaluated` bounds what is sent: a method the Guardian omits resolves as ALLOW-by-default without being sent (handshake.json), recorded as `method_not_evaluated`; permission events and `beforeSubmitPrompt` return their explicit allow shapes, since `failClosed` reads an empty reply as a failed hook. With no negotiated list, every mapped event is sent. `ACS_HANDSHAKE=0` skips the handshake, and the startup posture settles every step. - Turns: a `beforeSubmitPrompt` opens a turn with an explicit `steps/turnStart` (decision-eligible) before the `steps/userMessage`; `stop` closes it with `steps/turnEnd`. `turn_id` is held in per-session state and stamped on `metadata.turn_id` of every in-turn step. - Hook taxonomy minimum: all covered, plus many additional Cursor events. `subagentStop`, `beforeReadFile`, `beforeTabFileRead`, `workspaceOpen` deliberately unmapped with documented reasons; unknown events emit an `unmapped_hook_event` audit line rather than going quiet. - Dispositions: ALLOW / DENY / ASK supported on permission events (see the upstream ask-enforcement caveat); MODIFY supported on `preToolUse` (merged overrides); DEFER → ASK substitution (deployment behavior, not spec-mandated). diff --git a/adapters/docs/nat/README.md b/adapters/docs/nat/README.md index a7a38315..0edffcdb 100644 --- a/adapters/docs/nat/README.md +++ b/adapters/docs/nat/README.md @@ -261,7 +261,7 @@ The shared protocol layer is identical: the Guardian sees the same ACS JSON-RPC | `_type` | `acs_guardian` (required) | Registers the middleware via NAT's `register_middleware` decorator. | | `guardian_url` | `http://127.0.0.1:8787/acs` | Guardian endpoint. http/https only. | | `default_deny` | `false` | Fail-open with audit (§6.4 default). Set `true` for fail-closed. The ServerHello's `on_decision_failure: deny` also flips it (most-restrictive-wins). Guardian refusals (bad signature, replay, malformed/oversized envelope) always fail closed regardless — input gate blocks, output gate redacts. See spec issue #32. | -| `timeout_s` | `5.0` | Per-request Guardian round-trip timeout. A ServerHello `timeout_config.default_ms` replaces it for the session. | +| `timeout_s` | `5.0` | Per-request Guardian round-trip timeout. A ServerHello `timeout_config` replaces it for the session, per method where `per_method_ms` names one. | | `session_id` | (auto, per-process) | UUID; auto-generated and stable for the process lifetime. | | `target_function_or_group` | (unset) | Optional metadata label; derives `agent_id` if `ACS_AGENT_ID` env is unset. | @@ -277,6 +277,7 @@ The shared protocol layer is identical: the Guardian sees the same ACS JSON-RPC | `ACS_AUDIT_FILE` | (unset) | Append every `ACS_AUDIT` event to this file (created 0600) in addition to stderr. | | `ACS_DISABLED` | (unset) | `1` = incident kill switch: every call passes through with no Guardian traffic and an `acs_disabled_bypass` audit event. Read when NAT builds the middleware, so set it before the workflow starts. | | `ACS_STARTUP_POSTURE` | follows `default_deny` | `proceed` or `refuse`: what a call does in a session with no negotiated Guardian, because its handshake got no answer (§4.1). `proceed` lets the call run, audited, with nothing sent to the Guardian; `refuse` blocks it. The session keeps no negotiated Guardian for its lifetime. Any other value refuses. Read when NAT builds the middleware. | +| `ACS_REQUIRE_PROFILES` | (unset) | Comma-separated conformance profiles the Guardian must accept, such as `acs-core`. When the session's negotiation leaves one out, this hook's steps are refused; the stored record keeps what the Guardian answered, so each hook applies its own setting. | | `ACS_HANDSHAKE` | `1` | Set to `0` to skip the handshake; the session then counts as unanswered, and `ACS_STARTUP_POSTURE` settles every call. Read when NAT builds the middleware. | ## On-disk state @@ -290,6 +291,7 @@ Honest, item-by-item against the ACS-Core baseline in `docs/spec/conformance.md` | ACS-Core item | Status | |---|---| | Handshake (`handshake/hello`) | ✓ on the first `pre_invoke`; the decision (negotiated, refused, or no answer under `ACS_STARTUP_POSTURE`) holds for the session's lifetime (§4.1) | +| Profiles (`profiles_supported` / `profiles_accepted`) | ✓ claims `acs-core` when a signing secret is configured and no profile without one. A ServerHello accepting a profile the adapter did not claim is not a valid answer, a claimed profile the Guardian leaves out is audited as `profile_not_accepted`, and a hook's `ACS_REQUIRE_PROFILES` turns a left-out profile into a refusal of that hook's steps. | | JSON-RPC envelope shape (`request-envelope.json`) | ✓ validates against canonical schema (`test_envelope_schema.py`) | | Hook taxonomy (Core minimum set) | ⚠ emitted, but read the enforcement split. **Decision-eligible** coverage: `toolCallRequest` / `toolCallResult`, via the `FunctionMiddleware` path — these can actually block/redact. The other four — `sessionStart`, `userMessage`, `agentResponse`, `sessionEnd` — are emitted **observation-only** from the `IntermediateStepManager` subscription (fire-and-forget; the Guardian's verdict is not applied), so they provide audit/trace coverage, **not** enforcement, and should not be counted as decision-eligible Core coverage. ACS-Core requires a subagent-capable client to emit `subagentStart` when it spawns a sub-agent, and a framework with no sub-agent abstraction has nothing to emit. The adapter emits no `subagentStart`: nested workflow functions reach the Guardian as generic tool calls. The Guardian decides by deployment-defined means whether a NAT deployment counts as subagent-capable. Where sub-workflows act as sub-agents, this is a known gap. See `mapping.md`. Verified in `test_lifecycle.py`. | | Dispositions | ALLOW / DENY / MODIFY supported on **function-middleware (pre-execution)** hooks (`pre_invoke` for every wrapped function — tools, sub-workflows, LLM, retrievers). ASK/DEFER substituted to DENY at the middleware boundary; deployments wanting pause-and-resume should compose with NAT's HITL middleware (`nat.middleware.hitl`). **Lifecycle hooks from the `IntermediateStepManager` subscription (`steps/sessionStart`, `steps/userMessage`, `steps/agentResponse`, `steps/sessionEnd`) are observation-only** — subscription callbacks cannot veto a NAT event after it fires. See `mapping.md`. | diff --git a/adapters/docs/nat/mapping.md b/adapters/docs/nat/mapping.md index f9951d25..fb0e486f 100644 --- a/adapters/docs/nat/mapping.md +++ b/adapters/docs/nat/mapping.md @@ -82,6 +82,6 @@ The NAT adapter implements ACS-Core's mandatory floor: - Dispositions: ALLOW / DENY / MODIFY supported normatively; ASK / DEFER substituted to DENY with audit (HITL composition is the recommended path). - SessionContext: `session_id` sent on every request. - Replay protection: `request_id` UUID + timestamp. -- Handshake: the first `pre_invoke` of a session performs `handshake/hello`, and its answer decides the session (§4.1): a refusal blocks every call, and no answer leaves the session with no negotiated Guardian for its lifetime, so `ACS_STARTUP_POSTURE` (unset follows `default_deny`) settles each call with no Guardian traffic. +- Handshake: the first `pre_invoke` of a session performs `handshake/hello`, and its answer decides the session (§4.1). The ServerHello must answer the ClientHello: a `negotiated_version` sharing a major version the adapter offered, a `methods_evaluated` list, the one transport the adapter offers (its Guardian URL's scheme), a positive `timeout_config.default_ms`, an `on_decision_failure` of `proceed` or `deny`, and only profiles the adapter claimed; otherwise the session has no negotiated Guardian (`invalid_server_hello`). Per-method timeouts bound their own methods. The answer: a refusal blocks every call, and no answer leaves the session with no negotiated Guardian for its lifetime, so `ACS_STARTUP_POSTURE` (unset follows `default_deny`) settles each call with no Guardian traffic. - Decision honoring: NAT's middleware contract guarantees the function does not execute if `pre_invoke` raises or sets SKIP. The fail posture is `workflow.yml`'s `default_deny` OR the ServerHello's `on_decision_failure` (most-restrictive-wins); Guardian refusals (signature invalid, replay, malformed/oversized envelope) fail closed regardless of posture, on both the input gate (block) and the output gate (redact) — spec issue #32. Methods absent from the ServerHello's `methods_evaluated` resolve as ALLOW-by-default without being sent (handshake.json) and are recorded as `method_not_evaluated`. - Baseline integrity: HMAC-SHA256 per §10 over the RFC 8785 (JCS) canonical envelope with an HKDF-derived per-session key (`ACS_HMAC_SECRET_FILE` / `ACS_HMAC_SECRET`); `rfc8785` is a hard runtime dependency. Responses are signature-verified and bound to their request under any HTTP status; a mis-bound, badly-signed or unverifiable response fails closed regardless of posture. Unsigned mode (no secret) is announced with a loud `unsigned_mode` audit event. diff --git a/adapters/src/acs/client/_config.py b/adapters/src/acs/client/_config.py index c60089f2..d9241da3 100644 --- a/adapters/src/acs/client/_config.py +++ b/adapters/src/acs/client/_config.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass, replace +from dataclasses import dataclass, field, replace from typing import Any from acs.client._outcome import Handshake, Negotiated, Refused, SessionRefused, Undecided @@ -31,23 +31,36 @@ def label(self) -> str: return _DENY_LABELS.get(self.audit_type, "decision-failure") +def _positive(ms: object) -> bool: + return isinstance(ms, (int, float)) and not isinstance(ms, bool) and ms > 0 + + @dataclass(frozen=True) class FailPosture: """Whether a step proceeds when no usable decision arrives (§6.4), and how long it waits for one.""" deny_on_failure: bool timeout_s: float + # timeout_config.per_method_ms from the ServerHello, in seconds. + per_method_timeout_s: Mapping[str, float] = field(default_factory=dict) + + def timeout_for(self, method: str) -> float: + return self.per_method_timeout_s.get(method, self.timeout_s) def negotiated(self, server_hello: Mapping[str, Any] | None) -> FailPosture: - """Apply a ServerHello: deny wins over proceed, and a negotiated default timeout replaces the local one.""" + """Apply a ServerHello: deny wins over proceed, and negotiated timeouts replace the local one.""" if not isinstance(server_hello, Mapping): return self deny = self.deny_on_failure or server_hello.get("on_decision_failure") == "deny" timeout_s = self.timeout_s timeout_config = server_hello.get("timeout_config") - default_ms = timeout_config.get("default_ms") if isinstance(timeout_config, Mapping) else None - if isinstance(default_ms, (int, float)) and not isinstance(default_ms, bool) and default_ms > 0: + timeout_config = timeout_config if isinstance(timeout_config, Mapping) else {} + default_ms = timeout_config.get("default_ms") + if _positive(default_ms): timeout_s = default_ms / 1000.0 - return replace(self, deny_on_failure=deny, timeout_s=timeout_s) + per_method = timeout_config.get("per_method_ms") + per_method = per_method if isinstance(per_method, Mapping) else {} + overrides = {method: ms / 1000.0 for method, ms in per_method.items() if _positive(ms)} + return replace(self, deny_on_failure=deny, timeout_s=timeout_s, per_method_timeout_s=overrides) def resolve(self, outcome: Undecided | Refused) -> Resolution: """The one place a step without a usable decision is settled: a refusal blocks, anything else follows the posture.""" @@ -60,12 +73,13 @@ def resolve(self, outcome: Undecided | Refused) -> Resolution: @dataclass(frozen=True) class Config: - """ACS_GUARDIAN_URL, ACS_DEFAULT_DENY, ACS_STARTUP_POSTURE, ACS_HANDSHAKE and ACS_DISABLED, read once at the entry point.""" + """ACS_GUARDIAN_URL, ACS_DEFAULT_DENY, ACS_STARTUP_POSTURE, ACS_REQUIRE_PROFILES, ACS_HANDSHAKE and ACS_DISABLED, read once at the entry point.""" guardian_url: str posture: FailPosture handshake_enabled: bool disabled: bool startup_posture: str | None = None + require_profiles: frozenset[str] = frozenset() @classmethod def from_env(cls, env: Mapping[str, str]) -> Config: @@ -76,6 +90,8 @@ def from_env(cls, env: Mapping[str, str]) -> Config: handshake_enabled=env.get("ACS_HANDSHAKE", "1") == "1", disabled=env.get("ACS_DISABLED") == "1", startup_posture=env.get("ACS_STARTUP_POSTURE", "").strip().lower() or None, + require_profiles=frozenset(p.strip().lower() for p in env.get("ACS_REQUIRE_PROFILES", "").split(",") + if p.strip()), ) @property diff --git a/adapters/src/acs/client/_exchange.py b/adapters/src/acs/client/_exchange.py index 2579d8e0..8487f0ff 100644 --- a/adapters/src/acs/client/_exchange.py +++ b/adapters/src/acs/client/_exchange.py @@ -1,7 +1,10 @@ """One Guardian round trip: POST a signed request and classify what came back, with no side effects.""" from __future__ import annotations +import http.client import json +import socket +import threading import urllib.error import urllib.request from dataclasses import replace @@ -47,18 +50,136 @@ def post(request: dict[str, Any], *, guardian_url: str, timeout_s: float) -> Any guardian_url, data=body, headers={"Content-Type": "application/json"}, method="POST", ) + # A socket timeout bounds each read, not the exchange, so the §6.4 deadline waits on a worker and then shuts its sockets. + done, aborter, box = threading.Event(), _Aborter(), {} + + def fetch() -> None: + _worker.aborter = aborter + try: + box["value"] = _fetch(req, timeout_s, aborter) + except BaseException as e: # noqa: BLE001 + box["error"] = e + finally: + aborter.release() + done.set() + + worker = threading.Thread(target=fetch, name="acs-exchange", daemon=True) + worker.start() + if not done.wait(timeout_s): + aborter.abort() + raise TimeoutError(f"no complete response within {timeout_s:g}s") + if "error" in box: + raise box["error"] + return box["value"] + + +class _Aborter: + """The exchange's sockets, held as duplicates because TLS wrapping detaches the original, shut down together at the deadline.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._sockets: list[socket.socket] = [] + self.aborted = threading.Event() + + def add(self, sock: socket.socket) -> None: + with self._lock: + if not self.aborted.is_set(): + self._sockets.append(sock.dup()) + return + sock.close() + raise TimeoutError("the exchange passed its deadline") + + def abort(self) -> None: + with self._lock: + self.aborted.set() + sockets, self._sockets = self._sockets, [] + for sock in sockets: + try: + sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + sock.close() + + def release(self) -> None: + with self._lock: + sockets, self._sockets = self._sockets, [] + for sock in sockets: + sock.close() + + +_worker = threading.local() + + +def _create_connection(*args: Any, **kwargs: Any) -> socket.socket: + sock = socket.create_connection(*args, **kwargs) + aborter = getattr(_worker, "aborter", None) + if aborter is not None: + aborter.add(sock) + return sock + + +class _HTTPConnection(http.client.HTTPConnection): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._create_connection = _create_connection + + +class _HTTPSConnection(http.client.HTTPSConnection): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._create_connection = _create_connection + + +class _HTTPHandler(urllib.request.HTTPHandler): + def http_open(self, req: urllib.request.Request) -> Any: + return self.do_open(_HTTPConnection, req) + + +class _HTTPSHandler(urllib.request.HTTPSHandler): + def https_open(self, req: urllib.request.Request) -> Any: + kwargs: dict[str, Any] = {"context": self._context} + if hasattr(self, "_check_hostname"): + kwargs["check_hostname"] = self._check_hostname + return self.do_open(_HTTPSConnection, req, **kwargs) + + +def _open(req: urllib.request.Request, timeout: float) -> Any: + """Send through the environment's proxies on abortable connections; a redirect is an HTTP error, not a new request.""" + opener = urllib.request.OpenerDirector() + for handler in (urllib.request.ProxyHandler(), _HTTPHandler(), _HTTPSHandler(), + urllib.request.HTTPDefaultErrorHandler(), urllib.request.HTTPErrorProcessor()): + opener.add_handler(handler) + return opener.open(req, timeout=timeout) + + +def _fetch(req: urllib.request.Request, timeout_s: float, aborter: _Aborter) -> Any: + cancelled = aborter.aborted try: - with urllib.request.urlopen(req, timeout=timeout_s) as resp: - raw = resp.read() + with _open(req, timeout=timeout_s) as resp: + raw = _read(resp, cancelled) except urllib.error.HTTPError as e: try: - parsed = json.loads(e.read().decode("utf-8")) + parsed = json.loads(_read(e, cancelled).decode("utf-8")) except Exception: # noqa: BLE001 parsed = None raise GuardianHTTPError(e.code, parsed) from e return json.loads(raw.decode("utf-8")) +def _read(resp: Any, cancelled: threading.Event) -> bytes: + """Read the body one socket read at a time until the deadline.""" + read1 = getattr(resp, "read1", None) + if read1 is None: + return resp.read() + chunks = [] + while not cancelled.is_set(): + chunk = read1(65536) + if not chunk: + break + chunks.append(chunk) + return b"".join(chunks) + + def exchange(request: dict[str, Any], *, guardian_url: str, timeout_s: float, session_id: str | None) -> Outcome: """POST one request and classify the answer; never raises.""" diff --git a/adapters/src/acs/client/_handshake.py b/adapters/src/acs/client/_handshake.py index 9c3a51c6..dfaca0bb 100644 --- a/adapters/src/acs/client/_handshake.py +++ b/adapters/src/acs/client/_handshake.py @@ -7,6 +7,7 @@ import os import tempfile import urllib.error +import urllib.parse import uuid from collections.abc import Mapping from pathlib import Path @@ -27,6 +28,7 @@ is_session_refusal, iso8601_now, jcs_canonicalize, + server_hello_problem, ) @@ -69,20 +71,32 @@ def open_session(*, config: Config, session_id: str | None, agent_id: str, platf path = _handshake_cache_path(session_id, config.guardian_url) stored = _read_record(path, ikm, session_id, config.guardian_url) if stored is not None: - return stored + return _required_profiles(stored, config) + # An unsigned session does not meet acs-core's signed-envelope floor (§10), so it claims no profile. + claimed = (profiles_supported if profiles_supported is not None else ["acs-core"]) if ikm else [] if not config.handshake_enabled: decision: Handshake = NoAnswer("handshake_disabled") else: decision = _hello( guardian_url=config.guardian_url, session_id=session_id, agent_id=agent_id, platform=platform, methods_implemented=methods_implemented, - wrapped_protocols=wrapped_protocols, profiles_supported=profiles_supported, + wrapped_protocols=wrapped_protocols, profiles_supported=claimed, transports_supported=transports_supported, signed=bool(ikm), timeout=_HANDSHAKE_TIMEOUT_S if timeout is None else timeout, ) decision, ours = _store_first(path, decision, ikm, session_id, config.guardian_url) if ours: - _audit_start(decision, config, session_id) + _audit_start(decision, config, session_id, claimed) + return _required_profiles(decision, config) + + +def _required_profiles(decision: Handshake, config: Config) -> Handshake: + """This hook's ACS_REQUIRE_PROFILES applied to the session's negotiation; the stored record keeps what the Guardian answered.""" + if not isinstance(decision, Negotiated): + return decision + missing = sorted(config.require_profiles - set(decision.server_hello.get("profiles_accepted") or [])) + if missing: + return SessionRefused("required_profile_not_accepted", {"profiles": missing}) return decision @@ -95,8 +109,10 @@ def store_session_decision(directory: Path, session_id: str, guardian_url: str, def _hello(*, guardian_url: str, session_id: str, agent_id: str, platform: str, methods_implemented: list[str], wrapped_protocols: list[str] | None, - profiles_supported: list[str] | None, transports_supported: list[str] | None, + profiles_supported: list[str], transports_supported: list[str] | None, signed: bool, timeout: float) -> Handshake: + # The client POSTs to the Guardian URL, so that URL's scheme is the one transport it offers. + scheme = urllib.parse.urlsplit(guardian_url).scheme client_hello = { "jsonrpc": "2.0", "id": str(uuid.uuid4()), @@ -109,17 +125,12 @@ def _hello(*, guardian_url: str, session_id: str, agent_id: str, platform: str, "payload": { "acs_versions_supported": [ACS_VERSION], "methods_implemented": methods_implemented, - "transports_supported": (transports_supported if transports_supported - is not None else ["http", "stdio"]), + "transports_supported": (transports_supported if transports_supported is not None + else [scheme]), "max_payload_size_bytes": 1_000_000, "provenance_producer": "none", "wrapped_protocols": wrapped_protocols or [], - # An unsigned session does not meet acs-core's signed- - # envelope floor (§10), so it must not advertise the profile. - "profiles_supported": ( - (profiles_supported if profiles_supported is not None else ["acs-core"]) - if signed else [] - ), + "profiles_supported": profiles_supported, "signature_algorithms_supported": ["HMAC-SHA256"] if signed else [], }, }, @@ -144,18 +155,18 @@ def _hello(*, guardian_url: str, session_id: str, agent_id: str, platform: str, except Exception as e: # noqa: BLE001 return NoAnswer("adapter_exception", {"error": str(e)}) try: - return _classify_hello(client_hello["id"], response, session_id) + return _classify_hello(client_hello, response, session_id) except Exception as e: # noqa: BLE001 return SessionRefused("response_unverifiable", {"error": str(e)}) -def _classify_hello(hello_id: str, response: Any, session_id: str) -> Handshake: - """An answer that fails its checks refuses the session; a verified error that is not a refusal is no answer (§4.1).""" +def _classify_hello(client_hello: Mapping[str, Any], response: Any, session_id: str) -> Handshake: + """An answer that fails its checks refuses the session; a verified error that is not a refusal, or a ServerHello that does not answer the ClientHello, is no answer (§4.1).""" if not is_jsonrpc_response(response): return NoAnswer("malformed_response") error = response.get("error") code = error.get("code") if error is not None else None - if response.get("id") != hello_id: + if response.get("id") != client_hello["id"]: return SessionRefused("response_binding_mismatch", {"claimed_error_code": code} if code is not None else {}) verified = verify_signature(response, session_id=session_id) @@ -173,14 +184,22 @@ def _classify_hello(hello_id: str, response: Any, session_id: str) -> Handshake: server_hello = response["result"] if "negotiated_version" not in server_hello: return NoAnswer("invalid_server_hello") + problem = server_hello_problem(server_hello, client_hello["params"]["payload"]) + if problem is not None: + return NoAnswer("invalid_server_hello", {"problem": problem}) return Negotiated(server_hello) -def _audit_start(decision: Handshake, config: Config, session_id: str) -> None: - """Record how the session started, once, when its decision is stored (§4.1).""" +def _audit_start(decision: Handshake, config: Config, session_id: str, claimed: list[str]) -> None: common = {"cause": getattr(decision, "cause", None), "session_id": session_id, "guardian_url": config.guardian_url} - if isinstance(decision, SessionRefused): + if isinstance(decision, Negotiated): + accepted = decision.server_hello.get("profiles_accepted") + declined = sorted(set(claimed) - set(accepted)) if isinstance(accepted, list) else [] + if declined: + audit_event("profile_not_accepted", profiles=declined, session_id=session_id, + guardian_url=config.guardian_url) + elif isinstance(decision, SessionRefused): audit_event("session_refused", **{**decision.fields, **common}) elif isinstance(decision, NoAnswer): audit_event("session_without_negotiated_guardian", diff --git a/adapters/src/acs/core/__init__.py b/adapters/src/acs/core/__init__.py index f9c02d13..7598f8ab 100644 --- a/adapters/src/acs/core/__init__.py +++ b/adapters/src/acs/core/__init__.py @@ -25,6 +25,7 @@ "parse_iso8601": "_envelope", "response_matches_request": "_envelope", "schemas_dir": "_schemas", + "server_hello_problem": "_handshake", "sign_with_key": "_signing", "verify_with_key": "_signing", } diff --git a/adapters/src/acs/core/_handshake.py b/adapters/src/acs/core/_handshake.py index ac715f16..a6eece9a 100644 --- a/adapters/src/acs/core/_handshake.py +++ b/adapters/src/acs/core/_handshake.py @@ -1,7 +1,10 @@ -"""Reading a negotiated ServerHello.""" from __future__ import annotations +import re +from collections.abc import Mapping +from typing import Any +_SEMVER = re.compile(r"\d+\.\d+\.\d+") def method_evaluated(server_hello: dict | None, method: str) -> bool: @@ -15,3 +18,46 @@ def method_evaluated(server_hello: dict | None, method: str) -> bool: if not isinstance(evaluated, list): return True return method in evaluated + + +def _positive_int(value: object) -> bool: + """JSON Schema's integer of at least 1: 5000.0 qualifies, booleans do not.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return False + return (isinstance(value, int) or value.is_integer()) and value >= 1 + + +def _strings(value: object) -> bool: + return isinstance(value, list) and all(isinstance(item, str) for item in value) + + +def server_hello_problem(server_hello: Mapping[str, Any], offered: Mapping[str, Any]) -> str | None: + """Why the ServerHello does not answer the ClientHello `offered`, or None.""" + version = server_hello.get("negotiated_version") + if not isinstance(version, str) or not _SEMVER.fullmatch(version): + return "negotiated_version is not a semantic version" + majors = {v.split(".", 1)[0] for v in offered.get("acs_versions_supported", []) if isinstance(v, str)} + if version.split(".", 1)[0] not in majors: + return f"negotiated_version {version} matches no offered major version" + if not _strings(server_hello.get("methods_evaluated")): + return "methods_evaluated is not a list of method names" + transport = server_hello.get("selected_transport") + if transport not in offered.get("transports_supported", []): + return f"selected_transport {transport!r} was not offered" + timeouts = server_hello.get("timeout_config") + if not isinstance(timeouts, Mapping) or not _positive_int(timeouts.get("default_ms")): + return "timeout_config.default_ms is not a positive integer" + per_method = timeouts.get("per_method_ms", {}) + if not isinstance(per_method, Mapping) or not all(_positive_int(ms) for ms in per_method.values()): + return "timeout_config.per_method_ms is not a map of positive integers" + posture = server_hello.get("on_decision_failure", "proceed") + if posture not in ("proceed", "deny"): + return f"on_decision_failure {posture!r} is neither proceed nor deny" + accepted = server_hello.get("profiles_accepted") + if accepted is not None: + if not _strings(accepted): + return "profiles_accepted is not a list of profile names" + unclaimed = sorted(set(accepted) - set(offered.get("profiles_supported", []))) + if unclaimed: + return f"profiles_accepted includes {unclaimed}, which the client did not claim" + return None diff --git a/adapters/src/acs/guardian/example_guardian.py b/adapters/src/acs/guardian/example_guardian.py index c02ab4b5..c22ab359 100755 --- a/adapters/src/acs/guardian/example_guardian.py +++ b/adapters/src/acs/guardian/example_guardian.py @@ -524,16 +524,21 @@ def evaluate_handshake(params: dict, request_id: str) -> dict: f"UNSUPPORTED_VERSION: client majors " f"{[_major(v) for v in client_versions]}, Guardian major {our_major}") + # Answer only with what the client offered: a transport this Guardian serves, and the profiles it implements. + offered = client_hello.get("transports_supported") or [] + transport = next((t for t in ("http", "https") if t in offered), None) + if transport is None: + raise GuardianError(-32602, f"Invalid params: no offered transport this Guardian serves in {offered}") server_hello = { "negotiated_version": ACS_VERSION, "methods_evaluated": client_hello.get("methods_implemented") or [], - "selected_transport": "http", + "selected_transport": transport, "signature_algorithms_supported": (["HMAC-SHA256"] if _hmac_secret() else []), "timeout_config": {"default_ms": 5000}, "skew_window_ms": SKEW_WINDOW_MS, "on_decision_failure": "proceed", # spec default per §6.4 "policy_requires_provenance": False, - "profiles_accepted": ["acs-core"], + "profiles_accepted": [p for p in client_hello.get("profiles_supported") or [] if p == "acs-core"], } # handle_request wraps and signs this direct ServerHello as result. return server_hello diff --git a/adapters/src/acs/integrations/claude_code/adapter.py b/adapters/src/acs/integrations/claude_code/adapter.py index 9dc6ce1c..4556653c 100755 --- a/adapters/src/acs/integrations/claude_code/adapter.py +++ b/adapters/src/acs/integrations/claude_code/adapter.py @@ -667,7 +667,7 @@ def _guardian_roundtrip(request: dict[str, Any], hook_name: str, session_id: str """POST one request. Returns (response, None) for a usable decision, or (None, exit_code) once a failure has been settled in the hook's shape; shared by the turnStart pre-step and the main step.""" - outcome = exchange(request, guardian_url=guardian_url, timeout_s=posture.timeout_s, + outcome = exchange(request, guardian_url=guardian_url, timeout_s=posture.timeout_for(request.get("method", "")), session_id=session_id) if isinstance(outcome, Decided): return outcome.response, None diff --git a/adapters/src/acs/integrations/cursor/adapter.py b/adapters/src/acs/integrations/cursor/adapter.py index 7ef000fd..e63d19c1 100755 --- a/adapters/src/acs/integrations/cursor/adapter.py +++ b/adapters/src/acs/integrations/cursor/adapter.py @@ -862,7 +862,7 @@ def _guardian_roundtrip(request: dict[str, Any], event_name: str, event: dict[st (None, exit_code) once a failure has been settled in the event's shape; shared by the turnStart pre-step and the main step.""" session_id = _session_id(event) - outcome = exchange(request, guardian_url=guardian_url, timeout_s=posture.timeout_s, + outcome = exchange(request, guardian_url=guardian_url, timeout_s=posture.timeout_for(request.get("method", "")), session_id=session_id) if isinstance(outcome, Decided): return outcome.response, None diff --git a/adapters/src/acs/integrations/nat/adapter.py b/adapters/src/acs/integrations/nat/adapter.py index 637d383f..79cab72a 100644 --- a/adapters/src/acs/integrations/nat/adapter.py +++ b/adapters/src/acs/integrations/nat/adapter.py @@ -744,7 +744,7 @@ async def post_invoke(self, context): def _exchange(self, request: dict): return exchange(request, guardian_url=self._acs.guardian_url, - timeout_s=self._posture.timeout_s, session_id=self._session_id) + timeout_s=self._posture.timeout_for(request.get("method", "")), session_id=self._session_id) def _settle_pre(self, context, resolution: Resolution, message: str = ""): """Settle a pre_invoke step without a usable decision: block unless the resolution proceeds, and record why.""" diff --git a/adapters/src/acs/testing/harness.py b/adapters/src/acs/testing/harness.py index a5dad64f..cfde3717 100644 --- a/adapters/src/acs/testing/harness.py +++ b/adapters/src/acs/testing/harness.py @@ -277,19 +277,12 @@ def post_envelope(url: str, envelope: dict, *, timeout: float = 5.0) -> dict: GUARDIAN_SCRIPT = _HERE.parent / "guardian" / "example_guardian.py" -# ──────────────────────────────────────────────────────────────────── -# Programmable Guardian — for tests that need to control responses. -# ──────────────────────────────────────────────────────────────────── -# -# `launch_guardian` runs the REAL example_guardian process — useful for -# integration tests against the production code path. For unit tests -# that need to force specific dispositions (modify, ask, defer) or -# delays, use ProgrammableGuardian: an in-process HTTP server you can -# configure handler-by-handler. - -# A ServerHello for a session whose handshake already happened; no methods_evaluated means every method is sent. +# A ServerHello for a session whose handshake already happened, evaluating every method the reference adapters send. NEGOTIATED_HELLO = { "negotiated_version": "0.1.0", + "methods_evaluated": ["steps/sessionStart", "steps/sessionEnd", "steps/userMessage", "steps/agentResponse", + "steps/turnStart", "steps/turnEnd", "steps/toolCallRequest", "steps/toolCallResult", + "steps/subagentStart", "steps/preCompact"], "selected_transport": "http", "timeout_config": {"default_ms": 5000}, "on_decision_failure": "proceed", @@ -383,17 +376,18 @@ def envelopes_for(self, method: str) -> list[dict]: # ─── Default handlers ─── def _default_handshake(self, req: dict) -> dict: - # The HTTP handler wraps this ServerHello in result and signs the - # complete JSON-RPC envelope. Handshakes do not carry a decision. + # The HTTP handler wraps this ServerHello in result and signs the envelope. + offered = req["params"]["payload"] return { "negotiated_version": "0.1.0", - "methods_evaluated": req["params"]["payload"].get("methods_implemented", []), - "selected_transport": "http", + "methods_evaluated": offered.get("methods_implemented", []), + "selected_transport": next((t for t in offered.get("transports_supported", []) if t != "stdio"), + "http"), "signature_algorithms_supported": ["HMAC-SHA256"], "timeout_config": {"default_ms": 5000}, "skew_window_ms": 300000, "on_decision_failure": "proceed", - "profiles_accepted": ["acs-core"], + "profiles_accepted": [p for p in offered.get("profiles_supported", []) if p == "acs-core"], } def _default_allow(self, req: dict) -> dict: diff --git a/adapters/tests/client/test_config.py b/adapters/tests/client/test_config.py index 308f5d2f..c2b59d94 100644 --- a/adapters/tests/client/test_config.py +++ b/adapters/tests/client/test_config.py @@ -47,6 +47,15 @@ def test_a_missing_or_malformed_server_hello_changes_nothing(self) -> None: self.assertEqual(DENY.negotiated(hello), DENY) self.assertEqual(PROCEED.negotiated(hello), PROCEED) + def test_per_method_timeouts_bound_their_own_methods(self) -> None: + posture = PROCEED.negotiated({"timeout_config": {"default_ms": 2000, "per_method_ms": { + "steps/toolCallRequest": 300, "steps/bad": 0, "steps/also_bad": "5"}}}) + self.assertEqual(posture.timeout_for("steps/toolCallRequest"), 0.3) + self.assertEqual(posture.timeout_for("steps/userMessage"), 2.0) + self.assertEqual(posture.timeout_for("steps/bad"), 2.0) + self.assertEqual(posture.timeout_for("steps/also_bad"), 2.0) + self.assertEqual(PROCEED.timeout_for("steps/toolCallRequest"), 1.0) + def test_negotiation_returns_a_new_posture(self) -> None: negotiated = PROCEED.negotiated({"on_decision_failure": "deny", "timeout_config": {"default_ms": 100}}) @@ -129,6 +138,11 @@ def test_settings(self) -> None: self.assertFalse(config.handshake_enabled) self.assertTrue(config.disabled) + def test_required_profiles(self) -> None: + self.assertEqual(Config.from_env({}).require_profiles, frozenset()) + self.assertEqual(Config.from_env({"ACS_REQUIRE_PROFILES": " ACS-Core, acs-trace ,,"}).require_profiles, + frozenset({"acs-core", "acs-trace"})) + def test_switches_turn_on_only_with_1(self) -> None: for value in ("true", "yes", "0", ""): with self.subTest(value=value): diff --git a/adapters/tests/client/test_exchange.py b/adapters/tests/client/test_exchange.py index 20d7d148..65298834 100644 --- a/adapters/tests/client/test_exchange.py +++ b/adapters/tests/client/test_exchange.py @@ -1,6 +1,7 @@ from __future__ import annotations import io +import json import os import tempfile import unittest @@ -154,7 +155,7 @@ def _http(status: int, body: Any = None): class ExchangeCauses(unittest.TestCase): def _exchange(self, urlopen: Any, *, request: dict | None = None, url: str = URL, timeout_s: float = 2.5): - with mock.patch.object(_exchange.urllib.request, "urlopen", side_effect=urlopen) as fake: + with mock.patch.object(_exchange, "_open", side_effect=urlopen) as fake: outcome = exchange(request or _request(), guardian_url=url, timeout_s=timeout_s, session_id=SESSION) return outcome, fake @@ -211,6 +212,279 @@ def test_a_secret_that_cannot_be_loaded_refuses_the_answer(self) -> None: self.assertEqual(outcome.cause, "response_unverifiable") +class _DrippingGuardian: + """A real HTTP Guardian that answers with a signed deny, sending its body a few bytes at a time.""" + + def __init__(self, interval_s: float, *, handshake: bool = False) -> None: + import http.server + import threading + import time + + def respond(request: dict) -> dict: + if handshake: + result = {"negotiated_version": "0.1.0", "methods_evaluated": [], "selected_transport": "http", + "timeout_config": {"default_ms": 5000}} + else: + result = {"request_id": request["params"]["request_id"], "decision": "deny"} + return _signed({"jsonrpc": "2.0", "id": request["id"], "result": result}) + + class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self) -> None: # noqa: N802 + request = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + body = json.dumps(respond(request)).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + try: + for i in range(0, len(body), 16): + self.wfile.write(body[i:i + 16]) + self.wfile.flush() + time.sleep(interval_s) + except (BrokenPipeError, ConnectionResetError): + pass + + def log_message(self, *args: Any) -> None: + pass + + self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.url = f"http://127.0.0.1:{self.server.server_address[1]}/acs" + threading.Thread(target=self.server.serve_forever, daemon=True).start() + + def close(self) -> None: + self.server.shutdown() + self.server.server_close() + + +@mock.patch.dict(os.environ, {"ACS_HMAC_SECRET": SECRET, "ACS_HMAC_SECRET_FILE": ""}) +class OverallDeadline(unittest.TestCase): + """§6.4: the negotiated timeout bounds the whole wait, including a body that arrives slowly.""" + + def test_a_slowly_delivered_decision_times_out_at_the_deadline(self) -> None: + import time + guardian = _DrippingGuardian(0.08) + self.addCleanup(guardian.close) + started = time.monotonic() + outcome = exchange(_request(), guardian_url=guardian.url, timeout_s=0.15, session_id=SESSION) + elapsed = time.monotonic() - started + self.assertIsInstance(outcome, Undecided, "a deny that finishes arriving after the deadline must not apply") + self.assertEqual(outcome.cause, "decision_timeout") + self.assertLess(elapsed, 0.6, f"the exchange returned {elapsed:.2f}s after it started") + import threading + stop = time.monotonic() + 0.5 + while any(t.name == "acs-exchange" for t in threading.enumerate()) and time.monotonic() < stop: + time.sleep(0.01) + self.assertEqual([t for t in threading.enumerate() if t.name == "acs-exchange"], [], + "the worker reading the slow body must end within 0.5 s of the deadline") + + def test_the_same_decision_within_the_deadline_applies(self) -> None: + guardian = _DrippingGuardian(0.0) + self.addCleanup(guardian.close) + outcome = exchange(_request(), guardian_url=guardian.url, timeout_s=2.0, session_id=SESSION) + self.assertIsInstance(outcome, Decided) + self.assertEqual(outcome.response["result"]["decision"], "deny") + + def test_timed_out_workers_stop_and_send_nothing_more(self) -> None: + """Expiry shuts the exchange's sockets, so a worker blocked on slow headers ends promptly.""" + import socketserver + import threading + import time + connections: list[float] = [] + + class SlowHeaders(socketserver.BaseRequestHandler): + def handle(self) -> None: + connections.append(time.monotonic()) + self.request.recv(65536) + try: + self.request.sendall(b"HTTP/1.1 200 OK\r\n") + for i in range(100): + time.sleep(0.03) + self.request.sendall(b"X-Slow-%d: 1\r\n" % i) + except OSError: + pass + + server = socketserver.ThreadingTCPServer(("127.0.0.1", 0), SlowHeaders) + server.daemon_threads = True + threading.Thread(target=server.serve_forever, daemon=True).start() + self.addCleanup(server.server_close) + self.addCleanup(server.shutdown) + url = f"http://127.0.0.1:{server.server_address[1]}/acs" + for _ in range(4): + started = time.monotonic() + outcome = exchange(_request(), guardian_url=url, timeout_s=0.1, session_id=SESSION) + self.assertEqual(outcome.cause, "decision_timeout") + self.assertLess(time.monotonic() - started, 0.5) + deadline = time.monotonic() + 0.5 + while any(t.name == "acs-exchange" for t in threading.enumerate()) and time.monotonic() < deadline: + time.sleep(0.01) + alive = [t for t in threading.enumerate() if t.name == "acs-exchange"] + self.assertEqual(alive, [], "every timed-out worker must end within 0.5 s of its deadline") + time.sleep(0.2) + self.assertEqual(len(connections), 4, "no request after a caller timed out") + + def test_an_https_guardian_goes_through_the_abortable_transport(self) -> None: + """Builds the HTTPS connection on this Python version; a refused port is a transport failure, not an adapter error.""" + outcome = exchange(_request(), guardian_url="https://127.0.0.1:1/acs", timeout_s=2.0, session_id=SESSION) + self.assertIsInstance(outcome, Undecided) + self.assertEqual(outcome.cause, "transport_failure", outcome.fields) + + def test_a_redirect_is_an_http_error_not_another_request(self) -> None: + import http.server + import threading + requests: list[str] = [] + status = {"code": 307} + + class Redirect(http.server.BaseHTTPRequestHandler): + def _redirect(self) -> None: + requests.append(f"{self.command} {self.path}") + if self.command == "POST": + self.rfile.read(int(self.headers["Content-Length"])) + self.send_response(status["code"]) + self.send_header("Location", "/elsewhere") + self.send_header("Content-Length", "0") + self.end_headers() + + do_GET = do_POST = _redirect # noqa: N815 + + def log_message(self, *args: Any) -> None: + pass + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Redirect) + threading.Thread(target=server.serve_forever, daemon=True).start() + self.addCleanup(server.server_close) + self.addCleanup(server.shutdown) + for code in (301, 302, 303, 307, 308): + with self.subTest(code): + status["code"] = code + requests.clear() + outcome = exchange(_request(), guardian_url=f"http://127.0.0.1:{server.server_address[1]}/acs", + timeout_s=2.0, session_id=SESSION) + self.assertIsInstance(outcome, Undecided) + self.assertEqual(outcome.cause, f"http_{code}_error") + self.assertEqual(requests, ["POST /acs"], "a redirect must not send another request") + + def test_a_slowly_delivered_server_hello_is_no_answer(self) -> None: + import tempfile + from pathlib import Path + from acs.client import Config, NoAnswer, open_session + from acs.client import _handshake + guardian = _DrippingGuardian(0.08, handshake=True) + self.addCleanup(guardian.close) + with tempfile.TemporaryDirectory() as d, mock.patch.object(_handshake, "_HANDSHAKE_CACHE_DIR", Path(d)): + decision = open_session(config=Config.from_env({"ACS_GUARDIAN_URL": guardian.url}), + session_id=SESSION, agent_id="t", platform="t", methods_implemented=[], + timeout=0.15) + self.assertIsInstance(decision, NoAnswer) + self.assertEqual(decision.cause, "handshake_timeout") + + +def _exchange_workers_end(test: unittest.TestCase, within_s: float = 0.5) -> None: + import threading + import time + stop = time.monotonic() + within_s + while any(t.name == "acs-exchange" for t in threading.enumerate()) and time.monotonic() < stop: + time.sleep(0.01) + test.assertEqual([t for t in threading.enumerate() if t.name == "acs-exchange"], [], + f"every timed-out worker must end within {within_s} s of its deadline") + + +class TlsDeadline(unittest.TestCase): + """The deadline ends TLS handshakes and TLS reads too, with certificate verification on.""" + + @classmethod + def setUpClass(cls) -> None: + import shutil + import subprocess + import tempfile + if shutil.which("openssl") is None: + raise AssertionError("these tests make a throwaway certificate with the openssl command") + cls._tmp = tempfile.TemporaryDirectory(prefix="acs-tls-") + d = Path(cls._tmp.name) + (d / "cert.cfg").write_text("[req]\ndistinguished_name=dn\nx509_extensions=ext\nprompt=no\n" + "[dn]\nCN=127.0.0.1\n[ext]\nsubjectAltName=IP:127.0.0.1\n" + "basicConstraints=critical,CA:TRUE\n") + subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", + "-keyout", str(d / "key.pem"), "-out", str(d / "cert.pem"), "-config", str(d / "cert.cfg")], + check=True, capture_output=True, timeout=60) + cls.cert, cls.key = str(d / "cert.pem"), str(d / "key.pem") + + @classmethod + def tearDownClass(cls) -> None: + cls._tmp.cleanup() + + def _serve(self, mode: str) -> str: + import socketserver + import ssl + import threading + import time + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(self.cert, self.key) + + class Handler(socketserver.BaseRequestHandler): + def handle(self) -> None: + if mode == "stalled handshake": + time.sleep(3) + return + try: + with context.wrap_socket(self.request, server_side=True) as tls: + self._answer(tls) + except (OSError, ssl.SSLError): + pass + + def _answer(self, tls: ssl.SSLSocket) -> None: + data = b"" + while b"\r\n\r\n" not in data: + data += tls.recv(65536) + head, _, body = data.partition(b"\r\n\r\n") + length = int([h.split(b":")[1] for h in head.split(b"\r\n") + if h.lower().startswith(b"content-length")][0]) + while len(body) < length: + body += tls.recv(65536) + if mode == "slow headers": + tls.sendall(b"HTTP/1.1 200 OK\r\n") + for i in range(100): + time.sleep(0.03) + tls.sendall(b"X-Slow-%d: 1\r\n" % i) + return + request = json.loads(body) + answer = json.dumps(_signed({"jsonrpc": "2.0", "id": request["id"], "result": { + "request_id": request["params"]["request_id"], "decision": "deny"}})).encode() + tls.sendall(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" + b"Content-Length: %d\r\nConnection: close\r\n\r\n" % len(answer) + answer) + + server = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Handler) + server.daemon_threads = True + threading.Thread(target=server.serve_forever, daemon=True).start() + self.addCleanup(server.server_close) + self.addCleanup(server.shutdown) + return f"https://127.0.0.1:{server.server_address[1]}/acs" + + def _exchange(self, url: str, timeout_s: float): + env = {"ACS_HMAC_SECRET": SECRET, "ACS_HMAC_SECRET_FILE": "", "SSL_CERT_FILE": self.cert} + with mock.patch.dict(os.environ, env): + return exchange(_request(), guardian_url=url, timeout_s=timeout_s, session_id=SESSION) + + def test_a_verified_https_decision_applies(self) -> None: + outcome = self._exchange(self._serve("signed"), 5.0) + self.assertIsInstance(outcome, Decided, outcome) + self.assertEqual(outcome.response["result"]["decision"], "deny") + + def test_slow_tls_headers_end_at_the_deadline(self) -> None: + import time + url = self._serve("slow headers") + for _ in range(4): + started = time.monotonic() + outcome = self._exchange(url, 0.1) + self.assertEqual(outcome.cause, "decision_timeout") + self.assertLess(time.monotonic() - started, 0.5) + _exchange_workers_end(self) + + def test_a_stalled_tls_handshake_ends_at_the_deadline(self) -> None: + outcome = self._exchange(self._serve("stalled handshake"), 0.1) + self.assertEqual(outcome.cause, "decision_timeout") + _exchange_workers_end(self) + + # Each JSON-RPC envelope is checked the same way whatever HTTP status carries it. STATUSES = (200, 400, 413, 500, 503) MATRIX = [ @@ -246,7 +520,7 @@ def test_every_envelope_under_every_status(self) -> None: for name, body, kind, cause in MATRIX: with self.subTest(status=status, body=name): urlopen = _json(body()) if status == 200 else _http(status, body()) - with mock.patch.object(_exchange.urllib.request, "urlopen", side_effect=urlopen): + with mock.patch.object(_exchange, "_open", side_effect=urlopen): outcome = exchange(_request(), guardian_url=URL, timeout_s=1.0, session_id=SESSION) self.assertIsInstance(outcome, kind) if kind is Decided: @@ -266,7 +540,7 @@ def test_bodies_that_are_not_jsonrpc_are_classified_by_status(self) -> None: for name, body in NON_ENVELOPES: with self.subTest(status=status, body=name): urlopen = _json(body) if status == 200 else _http(status, body) - with mock.patch.object(_exchange.urllib.request, "urlopen", side_effect=urlopen): + with mock.patch.object(_exchange, "_open", side_effect=urlopen): outcome = exchange(_request(), guardian_url=URL, timeout_s=1.0, session_id=SESSION) self.assertIsInstance(outcome, kind) self.assertEqual(outcome.cause, cause) diff --git a/adapters/tests/client/test_session.py b/adapters/tests/client/test_session.py index 1570c212..6ed9a5bb 100644 --- a/adapters/tests/client/test_session.py +++ b/adapters/tests/client/test_session.py @@ -11,14 +11,16 @@ from typing import Any from unittest import mock +from acs.client import _exchange from acs.client import Config, Negotiated, NoAnswer, SessionRefused, open_session from acs.client import _handshake from acs.core import derive_session_key, sign_with_key SECRET = "session-gate-test-secret" URL = "http://127.0.0.1:8787/acs" -HELLO = {"negotiated_version": "0.1.0", "selected_transport": "http", - "timeout_config": {"default_ms": 5000}, "on_decision_failure": "proceed"} +HELLO = {"negotiated_version": "0.1.0", "methods_evaluated": ["steps/toolCallRequest"], + "selected_transport": "http", "timeout_config": {"default_ms": 5000}, + "on_decision_failure": "proceed", "profiles_accepted": ["acs-core"]} FORGED = {"algorithm": "HMAC-SHA256", "value": "AAAA"} @@ -107,7 +109,7 @@ def setUp(self) -> None: def _open(self, urlopen=None, *, session_id: str = "session-1", **env: str): config = Config.from_env({"ACS_GUARDIAN_URL": URL, **env}) - with mock.patch.object(_handshake.urllib.request, "urlopen", + 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"]) @@ -152,6 +154,73 @@ def test_a_secret_that_cannot_be_loaded_refuses_the_session(self) -> None: self.assertEqual((decision.cause, calls), ("secret_unavailable", 0)) +class Negotiation(_SessionTest): + """A ServerHello must answer the ClientHello it was sent for; the client offers only what it implements.""" + + def _hello(self, **change): + hello = {**HELLO, **change} + return _answer(lambda h: _result(h, {k: v for k, v in hello.items() if v is not None})) + + def test_a_server_hello_that_does_not_answer_the_client_hello_negotiates_nothing(self) -> None: + cases = {"another major version": {"negotiated_version": "1.0.0"}, + "transport not offered": {"selected_transport": "stdio"}, + "profile not claimed": {"profiles_accepted": ["acs-core", "acs-trace"]}, + "posture in another case": {"on_decision_failure": "Deny"}, + "no default timeout": {"timeout_config": {"per_method_ms": {}}}, + "no methods_evaluated": {"methods_evaluated": None}} + for i, (name, change) in enumerate(cases.items()): + with self.subTest(name): + decision, calls = self._open(self._hello(**change), session_id=f"invalid-{i}") + self.assertEqual(calls, 1) + self.assertIsInstance(decision, NoAnswer) + self.assertEqual(decision.cause, "invalid_server_hello") + self.assertIn("problem", decision.fields) + + def test_the_client_offers_its_guardian_urls_transport(self) -> None: + for scheme in ("http", "https"): + with self.subTest(scheme): + sent: list[dict] = [] + + def urlopen(req, timeout): + sent.append(json.loads(req.data)) + raise urllib.error.URLError("captured") + + config = Config.from_env({"ACS_GUARDIAN_URL": f"{scheme}://127.0.0.1:8787/acs"}) + with mock.patch.object(_exchange, "_open", side_effect=urlopen): + open_session(config=config, session_id=f"transport-{scheme}", agent_id="test", + platform="test", methods_implemented=["steps/toolCallRequest"]) + self.assertEqual(sent[0]["params"]["payload"]["transports_supported"], [scheme]) + + def test_a_required_profile_the_guardian_does_not_accept_refuses_the_session(self) -> None: + required = {"ACS_REQUIRE_PROFILES": "acs-core"} + refused, _ = self._open(self._hello(profiles_accepted=[]), session_id="required-declined", **required) + self.assertEqual(refused, SessionRefused("required_profile_not_accepted", {"profiles": ["acs-core"]})) + missing, _ = self._open(self._hello(profiles_accepted=None), session_id="required-missing", **required) + self.assertEqual(missing.cause, "required_profile_not_accepted") + accepted, _ = self._open(self._hello(), session_id="required-accepted", **required) + self.assertIsInstance(accepted, Negotiated) + + def test_each_hook_applies_its_required_profiles_to_the_stored_negotiation(self) -> None: + declining = self._hello(profiles_accepted=[]) + first, calls = self._open(declining, session_id="per-hook") + self.assertEqual((type(first), calls), (Negotiated, 1)) + refused, calls = self._open(session_id="per-hook", ACS_REQUIRE_PROFILES="acs-core") + self.assertEqual((refused, calls), + (SessionRefused("required_profile_not_accepted", {"profiles": ["acs-core"]}), 0), + "a stored negotiation must not skip this hook's requirement") + again, calls = self._open(session_id="per-hook") + self.assertEqual((again, calls), (first, 0), "the record keeps what the Guardian answered") + + def test_a_claimed_profile_the_guardian_declines_is_audited_once(self) -> None: + self._open(self._hello(profiles_accepted=[]), session_id="declined") + self._open(session_id="declined") + declined = [c for c in self.audit.call_args_list if c.args[0] == "profile_not_accepted"] + self.assertEqual([c.kwargs["profiles"] for c in declined], [["acs-core"]]) + self.audit.reset_mock() + self._open(self._hello(profiles_accepted=None), session_id="unstated") + self.assertNotIn("profile_not_accepted", [c.args[0] for c in self.audit.call_args_list]) + + class Lifetime(_SessionTest): """The first handshake decides the session; nothing expires it, and the next session asks again.""" @@ -313,6 +382,11 @@ def test_a_record_written_without_a_secret_is_refused_once_a_secret_is_configure class UnsignedMode(_SessionTest): ENV: dict = {} + def test_an_unsigned_client_claims_no_profile_so_a_required_one_refuses(self) -> None: + unsigned_hello = _answer(lambda h: _result(h, {**HELLO, "profiles_accepted": []}, sign=False)) + decision, _ = self._open(unsigned_hello, session_id="dev-required", ACS_REQUIRE_PROFILES="acs-core") + self.assertEqual(decision.cause, "required_profile_not_accepted") + def test_records_carry_no_mac_and_still_hold_the_session(self) -> None: first, _ = self._open(_raises(urllib.error.URLError("down")), session_id="dev") self.assertIsNone(json.loads(self._record("dev").read_text())["mac"]) diff --git a/adapters/tests/common/test_edge_cases.py b/adapters/tests/common/test_edge_cases.py index 7993b230..6033456a 100644 --- a/adapters/tests/common/test_edge_cases.py +++ b/adapters/tests/common/test_edge_cases.py @@ -25,6 +25,7 @@ from acs import client # noqa: E402 from acs import core # noqa: E402 +from acs.client import _exchange from acs.client import _handshake as client_handshake # noqa: E402 from acs.client import _store as client_store # noqa: E402 @@ -177,8 +178,9 @@ class Item05_SessionDecisionLifetime(unittest.TestCase): handshake made however old the stored record is.""" def test_a_config_change_reaches_the_next_session_only(self) -> None: - hellos = iter([{"negotiated_version": "0.1.0", "on_decision_failure": "deny"}, - {"negotiated_version": "0.1.0", "on_decision_failure": "proceed"}]) + hello = {"negotiated_version": "0.1.0", "methods_evaluated": [], "selected_transport": "http", + "timeout_config": {"default_ms": 5000}} + hellos = iter([{**hello, "on_decision_failure": "deny"}, {**hello, "on_decision_failure": "proceed"}]) def respond(request, timeout=None): incoming = json.loads(request.data) @@ -190,7 +192,7 @@ def respond(request, timeout=None): with tempfile.TemporaryDirectory() as tmp, \ mock.patch.dict(os.environ, env, clear=True), \ mock.patch.object(client_handshake, "_HANDSHAKE_CACHE_DIR", Path(tmp)), \ - mock.patch.object(client_handshake.urllib.request, "urlopen", side_effect=respond): + mock.patch.object(_exchange, "_open", side_effect=respond): def open_(session_id: str): return client.open_session(config=config, session_id=session_id, agent_id="x", platform="t", methods_implemented=[]) diff --git a/adapters/tests/common/test_methods_evaluated.py b/adapters/tests/common/test_methods_evaluated.py index 37377d74..42eb4660 100644 --- a/adapters/tests/common/test_methods_evaluated.py +++ b/adapters/tests/common/test_methods_evaluated.py @@ -218,16 +218,20 @@ def test_agent_result_checks_the_spawn_method(self) -> None: self.assertEqual(p.returncode, 0, p.stderr) self.assertEqual("request_id_ref" in self._sent_result(guardian, h), cites) - def test_without_a_negotiated_list_every_method_is_sent(self) -> None: - """A ServerHello without methods_evaluated declares no subset, so the - adapters send every method they implement.""" + def test_a_server_hello_without_methods_evaluated_negotiates_nothing(self) -> None: + """handshake.json requires methods_evaluated, so a ServerHello without it is no answer, and the fail-closed startup posture blocks the prompt.""" for adapter in ("claude-code", "cursor"): with self.subTest(adapter=adapter): guardian, h = self._session(None) p = h.run(adapter, *PROMPT[adapter]) - self.assertEqual(p.returncode, 0, p.stderr) - self.assertEqual(guardian.methods_received(), - ["handshake/hello", "steps/turnStart", "steps/userMessage"]) + self.assertEqual(guardian.methods_received(), ["handshake/hello"]) + if adapter == "claude-code": + self.assertEqual(json.loads(p.stdout)["decision"], "block") + else: + self.assertEqual(p.returncode, 2, 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")]) if __name__ == "__main__": diff --git a/adapters/tests/common/test_security.py b/adapters/tests/common/test_security.py index 77e6ad07..5e6a1678 100644 --- a/adapters/tests/common/test_security.py +++ b/adapters/tests/common/test_security.py @@ -29,6 +29,7 @@ from acs import client # noqa: E402 from acs import core # noqa: E402 +from acs.client import _exchange from acs.client import _handshake as client_handshake # noqa: E402 from acs.client import _store as client_store # noqa: E402 from acs.guardian import example_guardian # noqa: E402 @@ -907,7 +908,7 @@ def refuse_after_capture(req, timeout): with tempfile.TemporaryDirectory() as d, \ mock.patch.object(client_handshake, "_HANDSHAKE_CACHE_DIR", Path(d)), \ - mock.patch.object(client_handshake.urllib.request, "urlopen", + mock.patch.object(_exchange, "_open", side_effect=refuse_after_capture): result = client.open_session( config=client.Config.from_env({"ACS_GUARDIAN_URL": "http://127.0.0.1:8787/acs"}), diff --git a/adapters/tests/common/test_serverhello_contract.py b/adapters/tests/common/test_serverhello_contract.py index d4e75984..6f49397a 100644 --- a/adapters/tests/common/test_serverhello_contract.py +++ b/adapters/tests/common/test_serverhello_contract.py @@ -105,7 +105,7 @@ def _assert_audit(self, event: str, cause: str | None = None) -> None: self.assertTrue(matches, self.audit.call_args_list) def test_fresh_direct_result_is_negotiated_and_stored(self): - with mock.patch.object(client_handshake.urllib.request, "urlopen", side_effect=self._respond): + with mock.patch.object(_exchange, "_open", side_effect=self._respond): session = self._run() self.assertIsInstance(session, Negotiated) self._assert_hello_schema(session.server_hello) @@ -119,15 +119,15 @@ def test_fresh_direct_result_is_negotiated_and_stored(self): self.assertEqual(stat.S_IMODE(self.cache_directory.stat().st_mode), 0o700) def test_a_stored_decision_is_read_without_network(self): - with mock.patch.object(client_handshake.urllib.request, "urlopen", side_effect=self._respond): + with mock.patch.object(_exchange, "_open", side_effect=self._respond): first = self._run() - with mock.patch.object(client_handshake.urllib.request, "urlopen") as network: + with mock.patch.object(_exchange, "_open") as network: second = self._run() network.assert_not_called() self.assertEqual(first, second) def test_two_calls_perform_one_handshake(self): - with mock.patch.object(client_handshake.urllib.request, "urlopen", + with mock.patch.object(_exchange, "_open", side_effect=self._respond) as network: first = self._run() second = self._run() @@ -136,12 +136,12 @@ def test_two_calls_perform_one_handshake(self): self.assertEqual(network.call_count, 1) def test_a_modified_record_refuses_the_session_without_network(self): - with mock.patch.object(client_handshake.urllib.request, "urlopen", side_effect=self._respond): + with mock.patch.object(_exchange, "_open", side_effect=self._respond): self._run() record = json.loads(self._record().read_text()) record["server_hello"]["on_decision_failure"] = "proceed" self._record().write_text(json.dumps(record)) - with mock.patch.object(client_handshake.urllib.request, "urlopen") as network: + with mock.patch.object(_exchange, "_open") as network: session = self._run() network.assert_not_called() self.assertEqual(session, SessionRefused("session_record_invalid", {"record": str(self._record())})) @@ -151,7 +151,7 @@ def respond(request, timeout=None): response = json.loads(self._respond(request).read()) response["result"]["on_decision_failure"] = "proceed" return io.BytesIO(json.dumps(response).encode()) - with mock.patch.object(client_handshake.urllib.request, "urlopen", side_effect=respond): + with mock.patch.object(_exchange, "_open", side_effect=respond): session = self._run() self.assertEqual(session, SessionRefused("server_hello_signature_invalid")) self.assertEqual(json.loads(self._record().read_text())["decision"], "refused") @@ -162,7 +162,7 @@ def respond(request, timeout=None): response = json.loads(self._respond(request).read()) response["result"].pop("signature") return io.BytesIO(json.dumps(response).encode()) - with mock.patch.object(client_handshake.urllib.request, "urlopen", side_effect=respond): + with mock.patch.object(_exchange, "_open", side_effect=respond): session = self._run() self.assertEqual(session, SessionRefused("server_hello_signature_invalid")) @@ -172,7 +172,7 @@ def respond(request, timeout=None): response["id"] = "a-different-request" self._sign(response) return io.BytesIO(json.dumps(response).encode()) - with mock.patch.object(client_handshake.urllib.request, "urlopen", side_effect=respond): + with mock.patch.object(_exchange, "_open", side_effect=respond): session = self._run() self.assertEqual(session, SessionRefused("response_binding_mismatch")) @@ -182,7 +182,7 @@ def test_signed_legacy_result_wrapper_is_no_answer(self): "request_id": str(uuid.uuid4()), "decision": "allow", "payload": copy.deepcopy(self.hello), } - with mock.patch.object(client_handshake.urllib.request, "urlopen", side_effect=self._respond): + with mock.patch.object(_exchange, "_open", side_effect=self._respond): session = self._run() self.assertEqual(session, NoAnswer("invalid_server_hello")) self._assert_audit("session_without_negotiated_guardian", "invalid_server_hello") @@ -193,7 +193,7 @@ def test_an_earlier_adapter_cache_refuses_the_session(self): self._record().write_text(json.dumps(self._sign({ "jsonrpc": "2.0", "id": "previous-handshake", "result": copy.deepcopy(self.hello), }))) - with mock.patch.object(client_handshake.urllib.request, "urlopen") as network: + with mock.patch.object(_exchange, "_open") as network: session = self._run() network.assert_not_called() self.assertEqual(session, SessionRefused("session_record_legacy", {"record": str(self._record())})) @@ -203,7 +203,7 @@ def test_unknown_fields_do_not_change_result_extraction(self): # is not a reason to unwrap a valid direct ServerHello. self.hello["payload"] = {"future_extension": True} self.hello["another_extension"] = "preserve" - with mock.patch.object(client_handshake.urllib.request, "urlopen", side_effect=self._respond): + with mock.patch.object(_exchange, "_open", side_effect=self._respond): session = self._run() self._assert_hello_schema(session.server_hello) self.assertEqual(session.server_hello["payload"], {"future_extension": True}) @@ -211,7 +211,7 @@ def test_unknown_fields_do_not_change_result_extraction(self): def test_empty_result_is_no_answer(self): self.hello = {} - with mock.patch.object(client_handshake.urllib.request, "urlopen", side_effect=self._respond): + with mock.patch.object(_exchange, "_open", side_effect=self._respond): self.assertEqual(self._run(), NoAnswer("invalid_server_hello")) def test_fixture_emits_the_handshake_specific_schema(self): @@ -226,6 +226,23 @@ def test_fixture_emits_the_handshake_specific_schema(self): self.assertNotIn("decision", result) self.assertNotIn("payload", result) + def test_real_python_guardian_answers_with_what_the_client_offered(self): + env = os.environ.copy() + env["ACS_GUARDIAN_STATE_DIR"] = str(self.directory / "guardian-state") + proc, port = launch_guardian(env=env) + self.addCleanup(proc.wait, 3) + self.addCleanup(proc.terminate) + url = f"http://127.0.0.1:{port}/acs" + for i, (transports, profiles) in enumerate(((["https"], []), (["http"], ["acs-core"]))): + with self.subTest(transports=transports, profiles=profiles): + session = client.open_session( + config=Config.from_env({"ACS_GUARDIAN_URL": url}), session_id=f"{self.SESSION}-{i}", + agent_id="offer-test", platform="test", methods_implemented=["steps/toolCallRequest"], + profiles_supported=profiles, transports_supported=transports, timeout=2) + self.assertIsInstance(session, Negotiated) + self.assertEqual(session.server_hello["selected_transport"], transports[0]) + self.assertEqual(session.server_hello["profiles_accepted"], profiles) + def test_real_python_guardian_returns_a_signed_direct_result(self): env = os.environ.copy() env["ACS_GUARDIAN_STATE_DIR"] = str(self.directory / "guardian-state") diff --git a/adapters/tests/common/test_session_gate.py b/adapters/tests/common/test_session_gate.py index 953264ef..dcfff84b 100644 --- a/adapters/tests/common/test_session_gate.py +++ b/adapters/tests/common/test_session_gate.py @@ -235,6 +235,70 @@ def test_cursor_startup_proceed_answers_in_each_events_allow_shape(self) -> None self.assertEqual(json.loads(p.stdout), {"permission": "allow"}) self.assertEqual(audits, ["unguarded_session_bypass"]) + def test_integral_timeouts_written_as_decimals_still_negotiate(self) -> None: + """5000.0 is a JSON Schema integer: the session negotiates, so the step reaches the Guardian and its deny applies.""" + guardian = ProgrammableGuardian(hmac_secret=SECRET) + hello = guardian._default_handshake + guardian.handlers["handshake/hello"] = lambda req: { + **hello(req), "on_decision_failure": "deny", + "timeout_config": {"default_ms": 5000.0, "per_method_ms": {"steps/toolCallResult": 300.0}}} + guardian.handlers["steps/toolCallRequest"] = lambda req: { + "type": "final", "acs_version": "0.1.0", "request_id": req["params"]["request_id"], + "decision": "deny", "reasoning": "policy", "chain_hash": "0" * 64} + guardian.start() + 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.assertNotIn("session_without_negotiated_guardian", audits) + + def test_a_later_hooks_required_profile_refuses_a_stored_negotiation(self) -> None: + guardian = ProgrammableGuardian(hmac_secret=SECRET) + hello = guardian._default_handshake + guardian.handlers["handshake/hello"] = lambda req: {**hello(req), "profiles_accepted": []} + guardian.start() + self.addCleanup(guardian.stop) + for name, run in (("claude-code", self._claude), ("cursor", self._cursor)): + with self.subTest(name): + session = str(uuid.uuid4()) + p, denied, _ = run(guardian.url(), session) + self.assertFalse(denied, p.stdout) + guardian.reset() + p, denied, audits = run(guardian.url(), session, ACS_REQUIRE_PROFILES="acs-core") + self.assertTrue(denied, f"stdout={p.stdout!r}") + self.assertIn('"cause": "required_profile_not_accepted"', p.stderr) + self.assertEqual(guardian.methods_seen(), [], "the requirement applies without renegotiating") + guardian.reset() + p, denied, _ = run(guardian.url(), str(uuid.uuid4()), ACS_REQUIRE_PROFILES="acs-core") + self.assertTrue(denied, "control: a fresh session with the requirement is refused too") + + def test_negotiated_per_method_timeouts_bound_their_own_methods(self) -> None: + """A 300 ms toolCallRequest timeout ends that wait; toolCallResult waits under the 5 s default and its deny applies.""" + guardian = ProgrammableGuardian(hmac_secret=SECRET) + hello = guardian._default_handshake + guardian.handlers["handshake/hello"] = lambda req: { + **hello(req), "timeout_config": {"default_ms": 5000, "per_method_ms": {"steps/toolCallRequest": 300}}} + + def slow_deny(req: dict) -> dict: + time.sleep(1.0) + return {"type": "final", "acs_version": "0.1.0", "request_id": req["params"]["request_id"], + "decision": "deny", "reasoning": "slow deny", "chain_hash": "0" * 64} + + guardian.handlers["steps/toolCallRequest"] = slow_deny + guardian.handlers["steps/toolCallResult"] = slow_deny + guardian.start() + self.addCleanup(guardian.stop) + session = str(uuid.uuid4()) + p, denied, audits = self._claude(guardian.url(), session) + # Under the 5 s default the deny would arrive after 1 s and apply. + self.assertFalse(denied, p.stdout) + self.assertIn('"cause": "decision_timeout"', p.stderr) + event = {"session_id": session, "cwd": "/tmp", "hook_event_name": "PostToolUse", "tool_name": "Bash", + "tool_input": {"command": "echo hi"}, "tool_response": {"stdout": "hi"}} + 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(json.loads(p.stdout).get("decision"), "block", p.stderr[-300:]) + def test_a_step_timeout_in_a_negotiated_session_leaves_later_steps_governed(self) -> None: guardian = ProgrammableGuardian(hmac_secret=SECRET) hello = guardian._default_handshake diff --git a/adapters/tests/core/test_handshake.py b/adapters/tests/core/test_handshake.py new file mode 100644 index 00000000..9496b59a --- /dev/null +++ b/adapters/tests/core/test_handshake.py @@ -0,0 +1,57 @@ +"""server_hello_problem checks a ServerHello against the ClientHello it answers (§4, handshake.json).""" +from __future__ import annotations + +import unittest + +from acs.core import server_hello_problem + +OFFERED = {"acs_versions_supported": ["0.1.0"], "transports_supported": ["http"], + "profiles_supported": ["acs-core"], "methods_implemented": ["steps/toolCallRequest"]} +VALID = {"negotiated_version": "0.1.0", "methods_evaluated": ["steps/toolCallRequest"], + "selected_transport": "http", "timeout_config": {"default_ms": 5000}} + + +class ServerHelloProblem(unittest.TestCase): + def test_valid_answers(self) -> None: + for name, change in (("minimal", {}), ("another minor version", {"negotiated_version": "0.2.0"}), + ("per-method timeouts", {"timeout_config": {"default_ms": 1, "per_method_ms": {"m": 9}}}), + ("integral numbers JSON Schema calls integers", + {"timeout_config": {"default_ms": 5000.0, "per_method_ms": {"m": 300.0}}}), + ("deny posture", {"on_decision_failure": "deny"}), + ("accepted claim", {"profiles_accepted": ["acs-core"]}), + ("nothing accepted", {"profiles_accepted": []}), + ("methods the client never sends", {"methods_evaluated": ["steps/other"]})): + with self.subTest(name): + self.assertIsNone(server_hello_problem({**VALID, **change}, OFFERED)) + + def test_invalid_answers(self) -> None: + cases = { + "another major version": ({"negotiated_version": "1.0.0"}, "matches no offered major version"), + "not a version": ({"negotiated_version": "v0.1"}, "is not a semantic version"), + "no methods_evaluated": ({"methods_evaluated": None}, "methods_evaluated is not a list"), + "methods_evaluated not names": ({"methods_evaluated": [1]}, "methods_evaluated is not a list"), + "transport not offered": ({"selected_transport": "stdio"}, "'stdio' was not offered"), + "https for an http client": ({"selected_transport": "https"}, "'https' was not offered"), + "no default timeout": ({"timeout_config": {}}, "default_ms is not a positive integer"), + "zero default timeout": ({"timeout_config": {"default_ms": 0}}, "default_ms is not a positive integer"), + "boolean default timeout": ({"timeout_config": {"default_ms": True}}, "default_ms is not a positive integer"), + "fractional default timeout": ({"timeout_config": {"default_ms": 5000.5}}, "default_ms is not a positive integer"), + "infinite default timeout": ({"timeout_config": {"default_ms": float("inf")}}, + "default_ms is not a positive integer"), + "boolean per-method timeout": ({"timeout_config": {"default_ms": 5, "per_method_ms": {"m": True}}}, + "per_method_ms is not a map"), + "per-method timeout not a number": ({"timeout_config": {"default_ms": 5, "per_method_ms": {"m": "5"}}}, + "per_method_ms is not a map"), + "posture in another case": ({"on_decision_failure": "DENY"}, "is neither proceed nor deny"), + "profile the client did not claim": ({"profiles_accepted": ["acs-core", "acs-trace"]}, + "['acs-trace'], which the client did not claim"), + } + for name, (change, problem) in cases.items(): + with self.subTest(name): + hello = {**VALID, **change} + hello = {k: v for k, v in hello.items() if v is not None} + self.assertIn(problem, server_hello_problem(hello, OFFERED) or "") + + +if __name__ == "__main__": + unittest.main() diff --git a/adapters/tests/integrations/nat/test_untrusted_answers.py b/adapters/tests/integrations/nat/test_untrusted_answers.py index b093db61..0a769939 100644 --- a/adapters/tests/integrations/nat/test_untrusted_answers.py +++ b/adapters/tests/integrations/nat/test_untrusted_answers.py @@ -83,7 +83,7 @@ def urlopen(req: Any, timeout: float) -> _Body: context = FunctionMiddlewareContext(name="Bash", config=None, description=None, input_schema=None, single_output_schema=type(None), stream_output_schema=type(None)) - with mock.patch.object(_exchange.urllib.request, "urlopen", side_effect=urlopen): + with mock.patch.object(_exchange, "_open", side_effect=urlopen): try: asyncio.run(mw.function_middleware_invoke(command="rm -rf /srv/data", call_next=target, context=context))