From b7f02e186d83507a53efc9b8801f84ad74354d2e Mon Sep 17 00:00:00 2001 From: Jason Crittenden Date: Tue, 6 Oct 2026 09:13:59 +0000 Subject: [PATCH 01/12] fix(agt): implement ACS-Core wire safeguards Signed-off-by: Jason Crittenden --- reference-implementations/agt/README.md | 86 +-- .../agt/hosts/claude-code/README.md | 3 +- .../agt/hosts/claude-code/acs-hook.ts | 8 +- .../agt/hosts/opencode/README.md | 3 +- .../agt/hosts/opencode/acs-plugin.ts | 9 +- .../hosts/opencode/test/request-gate.test.ts | 4 +- .../conformance/src/failure-domains.ts | 103 ++-- .../agt/packages/guardian/README.md | 30 +- .../agt/packages/guardian/src/acs-result.ts | 11 +- .../agt/packages/guardian/src/handshake.ts | 61 ++- .../agt/packages/guardian/src/index.ts | 9 + .../agt/packages/guardian/src/main.ts | 20 +- .../agt/packages/guardian/src/map-verdict.ts | 28 +- .../agt/packages/guardian/src/server.ts | 498 ++++++++++++++++-- .../packages/guardian/src/session-context.ts | 34 +- .../guardian/src/validate-envelope.ts | 36 +- .../packages/guardian/src/verify-signature.ts | 95 ++++ .../test/envelope-log-sink-wiring.test.ts | 10 +- .../packages/guardian/test/handshake.test.ts | 18 +- .../guardian/test/map-verdict.test.ts | 10 +- .../agt/packages/guardian/test/server.test.ts | 334 +++++++++++- .../guardian/test/session-context.test.ts | 25 +- .../guardian/test/validate-envelope.test.ts | 8 +- .../host-adapter/src/failure-posture.ts | 18 +- .../packages/host-adapter/src/govern-step.ts | 8 + .../host-adapter/src/guardian-client.ts | 48 +- .../packages/host-adapter/src/handshake.ts | 42 +- .../agt/packages/host-adapter/src/index.ts | 8 + .../host-adapter/src/sign-envelope.ts | 110 ++++ .../packages/host-adapter/test/client.test.ts | 161 +++++- .../host-adapter/test/failure-posture.test.ts | 6 +- .../inspector/src/tail-session-context.ts | 4 +- .../agt/test/dispositions.test.ts | 11 +- ...ndshake-declares-what-it-evaluates.test.ts | 76 ++- .../agt/test/readme-captures.test.ts | 6 +- 35 files changed, 1617 insertions(+), 324 deletions(-) create mode 100644 reference-implementations/agt/packages/guardian/src/verify-signature.ts create mode 100644 reference-implementations/agt/packages/host-adapter/src/sign-envelope.ts diff --git a/reference-implementations/agt/README.md b/reference-implementations/agt/README.md index cdc6a862..b777f26a 100644 --- a/reference-implementations/agt/README.md +++ b/reference-implementations/agt/README.md @@ -69,24 +69,24 @@ Each part has its own README that says how to start it. | ACS-Core requirement | Status in this tree | |---|---| -| Handshake, `handshake/hello` | Served. The ServerHello is a set of constants. The Guardian does not read the ClientHello. | +| Handshake, `handshake/hello` | Served. The Guardian validates ClientHello, rejects unsupported versions (`-32001`), narrows `methods_evaluated` to the intersection with the client's `methods_implemented`, and negotiates `profiles_accepted`. | | Request and response envelopes, JSON-RPC 2.0 | Implemented. Inbound envelopes are validated against the schemas. Outbound responses are checked and the check is logged, never thrown. | -| Hook taxonomy, minimum of six hooks | Two of nineteen `steps/*` methods are evaluated: `steps/toolCallRequest` and `steps/toolCallResult`. The other four minimum hooks are not evaluated. | -| Five dispositions | `allow`, `deny` and `modify` are complete. `ask` is sent without `ask_details`, so it fails the response schema. `defer` is never produced. | -| SessionContext with published `chain_hash` | The Guardian keeps a hash chain per session and writes it to a log. It does not put `chain_hash` on any response. | -| Replay protection | Not implemented. No timestamp window check. No duplicate `request_id` check. | -| Baseline HMAC-SHA256 signature | Not implemented. Nothing signs or verifies an envelope. | +| Hook taxonomy, minimum of six hooks | All six ACS-Core hooks are dispatched: `steps/toolCallRequest` and `steps/toolCallResult` are policy-evaluated; `steps/sessionStart`, `steps/sessionEnd`, `steps/userMessage` and `steps/agentResponse` are accepted with allow-by-default and recorded in the session chain. | +| Five dispositions | `allow`, `deny`, `modify`, and `ask` are emitted as schema-valid decisions. Both hosts validate and safely render `defer`, but no pinned AGT verdict maps to it. | +| SessionContext with published `chain_hash` | The Guardian keeps a per-session chain using ACS v0.1's JCS-content-plus-raw-predecessor algorithm, writes it to a log, and publishes `chain_hash` on every response for a chain-recorded step, including denial responses when evaluation throws. | +| Replay protection | Implemented. Duplicate `request_id` values within a session are rejected (`-32005`) from a replay store independent of the bounded audit chain. Timestamps outside the negotiated `skew_window_ms` (default 5 minutes) are rejected (`-32006`). Only the liveness ping is exempt. | +| Baseline HMAC-SHA256 signature | Implemented. The runnable Guardian requires `ACS_HMAC_SECRET`, derives per-session HMAC keys via HKDF (§10), verifies requests using JCS canonicalization, rejects unsigned requests with `-32004`, and signs every addressable non-ping result or error. Both shipped hosts sign requests—including ClientHello—and verify non-ping response signatures. `system/ping` is signature-exempt in both directions. | | Decision honoring and `on_decision_failure` | Implemented on both hosts. Every fail-open proceed is written to the audit log. | -| Liveness, `system/ping` | Not implemented. | -| Wrapped MCP, `protocols/MCP/*` | Not implemented. | +| Liveness, `system/ping` | Implemented. Always returns `allow` with an echo of the client's payload and a server timestamp. No chain entry, no signature required. | +| Wrapped MCP, `protocols/MCP/tools/call` | Implemented. It extracts the tool name from the ACS or native MCP payload shape, records a chain entry, evaluates against the pre-tool-call policy, and fails closed on evaluation errors. Declared in `methods_evaluated`. Other `protocols/MCP/*` methods are not claimed. | The other six ACS profiles are not claimed. -**The wire is not secured.** The Guardian binds to loopback by default. There is no authentication, no origin check and no request signing. Anything that can reach the port can read decisions and cause decisions. +**The wire uses shared-secret integrity, not peer identity.** The Guardian binds to loopback by default and requires HMAC-SHA256 key material. There is no origin check or independently authenticated identity beyond possession of that shared secret. ## Install -You need [`bun`](https://bun.sh). For the Claude Code walkthrough you also need the [Claude Code](https://docs.claude.com/en/docs/claude-code) CLI on your `PATH`. For the OpenCode walkthrough you need the `opencode` CLI. The scripts under `scripts/` also need `jq` and `trash`. +You need [`bun`](https://bun.sh). For the Claude Code walkthrough you also need the [Claude Code](https://docs.claude.com/en/docs/claude-code) CLI on your `PATH`. For the OpenCode walkthrough you need the `opencode` CLI. The scripts under `scripts/` and five test files also need `jq` and `trash` (install via `npm install -g trash-cli` or `brew install trash-cli`). 1. Clone the repository and move into this tree. Every command below runs from here. @@ -101,7 +101,7 @@ cd agent-control-standard/reference-implementations/agt bun install ``` -This installs the AGT SDK at the pinned version and the OPA binary it bundles. No other binary is necessary. +This installs the AGT SDK at the pinned version and the OPA binary it bundles. The `jq` and `trash` prerequisites listed above remain separate system tools. ## Run @@ -109,6 +109,12 @@ This installs the AGT SDK at the pinned version and the OPA binary it bundles. N Open a terminal at `reference-implementations/agt`. Start the Guardian. +First provide at least 32 bytes of base64-encoded key material. Keep this value in the environment of the Guardian and either host: + +```bash +export ACS_HMAC_SECRET="$(openssl rand -base64 32)" +``` + ```bash bun run guardian ``` @@ -118,10 +124,17 @@ Guardian listening at http://localhost:8787/acs Envelope log: .acs/envelopes.jsonl Session context log: .acs/session-context.jsonl Failure posture: proceed (override with ACS_ON_DECISION_FAILURE=deny) +Signature verification: HMAC-SHA256 (required) ``` Leave it running. The Guardian constructs the AGT runtime once against `policy/lib` and serves ACS on `POST /acs`. The [Guardian README](packages/guardian/README.md) lists what it reads and what it writes. +Every host process must receive that same value. In each terminal where you start Claude Code or OpenCode, export the copied base64 value first: + +```bash +export ACS_HMAC_SECRET='' +``` + ### Start the Inspector Open a second terminal at the same directory. Start the Inspector after the Guardian, because the Guardian creates the `.acs/` directory. @@ -150,7 +163,7 @@ cp hosts/claude-code/settings.json .claude/settings.json This registers `hosts/claude-code/acs-hook.ts` on two Claude Code hook events. `PreToolUse` fires for the `Bash` and `WebFetch` tools, before the tool runs. `PostToolUse` fires for the `Bash` tool, after the tool runs. Both matchers are anchored, so no other tool is intercepted. The shipped file names the hook through `$CLAUDE_PROJECT_DIR`, which resolves when the project is this tree. From any other project, replace that variable with this tree's absolute path. The [Claude Code host README](hosts/claude-code/README.md) has the details. -2. Start Claude Code. +2. Confirm this terminal has the same `ACS_HMAC_SECRET` as the Guardian, then start Claude Code. ```bash claude @@ -176,7 +189,7 @@ The same Guardian governs OpenCode at the same time. Do not restart anything. } ``` -2. Start `opencode` from that project directory. +2. Confirm this terminal has the same `ACS_HMAC_SECRET` as the Guardian, then start `opencode` from that project directory. The plugin hooks `tool.execute.before` for the `bash` and `webfetch` tools and `tool.execute.after` for the `bash` tool. A deny before the tool runs is a thrown error that carries the Guardian's reason. A deny after the tool runs replaces the result object, because OpenCode discards a throwing hook's changes. The [OpenCode host README](hosts/opencode/README.md) shows how to drive OpenCode against a local stub model with no paid account. @@ -328,10 +341,10 @@ The contract has four layers in this tree. Read them in this order. |---|---| | `pre_tool_call` | `steps/toolCallRequest` | | `post_tool_call` | `steps/toolCallResult` | -| `agent_startup` | `steps/sessionStart` (mapped, not evaluated) | -| `agent_shutdown` | `steps/sessionEnd` (mapped, not evaluated) | -| `input` | `steps/userMessage` (mapped, not evaluated) | -| `output` | `steps/agentResponse` (mapped, not evaluated) | +| `agent_startup` | `steps/sessionStart` (allow-by-default, chain-recorded) | +| `agent_shutdown` | `steps/sessionEnd` (allow-by-default, chain-recorded) | +| `input` | `steps/userMessage` (allow-by-default, chain-recorded) | +| `output` | `steps/agentResponse` (allow-by-default, chain-recorded) | | `pre_model_call` | none in ACS v0.1.0 | | `post_model_call` | none in ACS v0.1.0 | @@ -345,15 +358,21 @@ The contract has four layers in this tree. Read them in this order. **3. The per-host hookmaps.** `hosts/claude-code/claude-code.hookmap.yaml` and `hosts/opencode/opencode.hookmap.yaml` declare which host hook fires which ACS method, where the tool name and arguments are in the host payload, and how each ACS decision renders in that host's own output format. `policy/manifest.yaml` is the AGT manifest. It names the policy bundle, the tools AGT knows, and the egress annotator. -**4. The handshake.** The first hook in a session sends `handshake/hello`. The Guardian's answer names exactly the methods it evaluates. A method absent from that list is allow-by-default on the client, in the specification's own words. This is what the Guardian sends today: +**4. The handshake.** The first hook in a session sends `handshake/hello`. The Guardian's answer names the intersection of methods the client implements and methods the Guardian evaluates. A method absent from that list is allow-by-default on the client, in the specification's own words. This is the Guardian's maximum capability response; the shipped Claude Code and OpenCode clients offer only the two native tool methods, so their actual reply omits wrapped MCP: ```json { "negotiated_version": "0.1.0", - "methods_evaluated": ["steps/toolCallRequest", "steps/toolCallResult"], + "methods_evaluated": [ + "steps/toolCallRequest", "steps/toolCallResult", + "protocols/MCP/tools/call" + ], "selected_transport": "http", "timeout_config": { "default_ms": 5000 }, - "on_decision_failure": "proceed" + "on_decision_failure": "proceed", + "skew_window_ms": 300000, + "signature_algorithms_supported": ["HMAC-SHA256"], + "profiles_accepted": ["acs-core"] } ``` @@ -492,12 +511,9 @@ The suite has 68 test files. Most of them drive a real Guardian, the real host s These are known limits of the tree as it stands. None is fixed. -- **Memory and disk grow without bound.** The Guardian's session store never evicts a session. The envelope log, the audit log and the session-context log have no rotation and no size cap. A long-lived Guardian grows until something else stops it. +- **Memory and disk grow without bound.** The Guardian's session store evicts sessions after 1,024 are retained; the envelope log, the audit log and the session-context log have no rotation and no size cap. A long-lived Guardian grows until something else stops it. - **Log writes sit on the decision path.** The Guardian writes two synchronous file appends per request in a single-threaded server. A slow disk blocks every in-flight decision. -- **The wire is unauthenticated.** See [What this project is, and is not](#what-this-project-is-and-is-not). Loopback by default is the only protection. - **The egress gate has three measured soft spots.** A shell command whose destination the extractor cannot parse falls through to `allow`, not `deny`. With the shipped allowlist, any command whose text contains an off-list URL is denied, whether or not it reaches that URL. A `WebFetch` URL reaches AGT's gate with no parsing, so four userinfo-style URL shapes pass on the fetch route and fail on the shell route. Closing the last one means editing a `.rego` file, which this project does not do. -- **An `ask` never carries `ask_details`.** The Guardian's decision type has no such field. Every `ask` fails the response schema. A `defer` would fail the same way if any AGT verdict mapped to it. -- **The handshake is not negotiated.** The Guardian returns constants and never reads the ClientHello. Neither side declares ACS profiles. - **The result gate assumes one output shape per hook.** A `WebFetch` result has no `stdout`, so the result gate is not widened to it. Under the default posture, widening it would proceed on every fetch result with an audit entry. This is recorded and unassigned. - **`modified_content` has no builder.** The Guardian never emits it and both hosts refuse it. - **The audit sequence number can collide.** Two Claude Code hooks that run at the same time read the same log length and write the same `seq`. @@ -518,7 +534,6 @@ These findings are open on this tree. They are recorded here so a reader does no **Older findings that are still open:** -- The Guardian's decision type has no `ask_details`, so every `ask` fails the response schema. It is listed under operational debt above. - A `steps/skillLoad` hook that drives AGT's `content_hash` gate is planned, not built. No code for it exists in the tree, and the ServerHello does not name it. - Two AGT intervention points, `pre_model_call` and `post_model_call`, have no ACS v0.1.0 method. Whether this project proposes `steps/modelCall` for ACS v0.2 is open. - Whether to report a fail-open to AGT upstream is open: a `./`-prefixed `bundle:` path silently disables all policy and returns `allow`. This tree throws on that path as a backstop. @@ -527,12 +542,13 @@ These findings are open on this tree. They are recorded here so a reader does no ## Verify ```bash -bun test # the whole suite; exactly one test skips, the byte-identity - # check, which needs UPSTREAM_BUNDLE -bun run typecheck # strict TypeScript across the workspace, zero errors -bun run verify:pin # re-clones AGT at the pinned ref and byte-diffs policy/lib; needs network -bun run conformance # prints the mapping table, the coverage matrix and the trace rows; needs network -bun run watch:upstream # diffs AGT's eight contract surfaces against upstream main; needs network +bun test --timeout 30000 # the whole suite; --timeout avoids flakes on slow CI. + # Exactly one test skips, the byte-identity check, which + # needs UPSTREAM_BUNDLE +bun run typecheck # strict TypeScript across the workspace, zero errors +bun run verify:pin # re-clones AGT at the pinned ref and byte-diffs policy/lib; needs network +bun run conformance # prints the mapping table, the coverage matrix and the trace rows; needs network +bun run watch:upstream # diffs AGT's eight contract surfaces against upstream main; needs network ``` The suite reads this repository's own schemas. It expects `specification/v0.1.0/response-envelope.json` to accept a ServerHello as a result, which the envelope does from specification v0.1.2. On a tree whose envelope predates that change, exactly one test fails, in `packages/guardian/test/check-response.test.ts`, and every other test passes. @@ -543,7 +559,7 @@ The scripts and five test files delete their scratch directories with `trash`, n ## Configuration -Everything above runs with no configuration. These are the variables each process reads. +The quickstart requires one shared secret in the Guardian and host environments; all remaining variables have usable defaults. These are the variables each process reads. **The Claude Code shim** (`hosts/claude-code/acs-hook.ts`): @@ -553,8 +569,9 @@ Everything above runs with no configuration. These are the variables each proces | `ACS_SESSION_DIR` | `.acs/sessions` | Where the negotiated ServerHello is stored, one file per session | | `ACS_AUDIT_LOG` | `.acs/audit.jsonl` | Where fail-open proceeds and posture-driven blocks are written | | `ACS_HOOKMAP_PATH` | `hosts/claude-code/claude-code.hookmap.yaml` | Which hookmap to load. This changes what governance means for the host. Leave it unset in a deployment | +| `ACS_HMAC_SECRET` | required with the standalone Guardian | The same base64-encoded secret supplied to the Guardian; signs requests and verifies results and errors. Omission is compatibility mode for an explicitly unsecured in-process Guardian, not ACS-Core | -**The OpenCode plugin** (`hosts/opencode/acs-plugin.ts`) reads `ACS_GUARDIAN_URL`, `ACS_AUDIT_LOG` and `ACS_HOOKMAP_PATH` with the same meanings. Its hookmap default is `hosts/opencode/opencode.hookmap.yaml`. It has no `ACS_SESSION_DIR`, because the plugin lives for the whole session and keeps the ServerHello in memory. It also reads `ACS_DEBUG`. When that variable holds any value other than empty or `0`, the plugin prints the decision's reason text on stderr, because OpenCode has no field that reads a reason back. +**The OpenCode plugin** (`hosts/opencode/acs-plugin.ts`) reads `ACS_GUARDIAN_URL`, `ACS_AUDIT_LOG`, `ACS_HOOKMAP_PATH`, and the shared `ACS_HMAC_SECRET` with the same meanings. The secret is required with the standalone Guardian; omission exists only for compatibility with an explicitly unsecured in-process Guardian and does not negotiate ACS-Core. Its hookmap default is `hosts/opencode/opencode.hookmap.yaml`. It has no `ACS_SESSION_DIR`, because the plugin lives for the whole session and keeps the ServerHello in memory. It also reads `ACS_DEBUG`. When that variable holds any value other than empty or `0`, the plugin prints the decision's reason text on stderr, because OpenCode has no field that reads a reason back. **The Guardian** (`bun run guardian`): @@ -562,10 +579,11 @@ Everything above runs with no configuration. These are the variables each proces |---|---|---| | `ACS_ON_DECISION_FAILURE` | `proceed` | The posture the ServerHello declares. `deny` fails closed. Any other value stops the Guardian at startup | | `ACS_GUARDIAN_PORT` | `8787` | The port for `POST /acs` | -| `ACS_GUARDIAN_HOST` | `127.0.0.1` | The interface to bind. Set `0.0.0.0` only when you accept an unauthenticated routable endpoint | +| `ACS_GUARDIAN_HOST` | `127.0.0.1` | The interface to bind. Set `0.0.0.0` only after protecting the transport and provisioning `ACS_HMAC_SECRET` to every intended peer | | `ACS_MANIFEST_PATH` | `policy/manifest.yaml` | The AGT manifest. `policy/manifest.drift.yaml` is the second manifest, used to reach the `warn` verdict | | `ACS_ENVELOPE_LOG` | `.acs/envelopes.jsonl` | Where every envelope is recorded | | `ACS_SESSION_CONTEXT_LOG` | `.acs/session-context.jsonl` | Where each session's hash chain is written | +| `ACS_HMAC_SECRET` | required | Base64-encoded input keying material of at least 32 bytes. The Guardian derives a per-session key via HKDF, verifies requests, and signs addressable non-ping results and errors. The hosts use the same value to sign requests—including ClientHello—and verify responses. `system/ping` is signature-exempt in both directions | **The Inspector** (`bun run inspector`) reads `ACS_ENVELOPE_LOG`, `ACS_AUDIT_LOG` and `ACS_SESSION_CONTEXT_LOG` with the same defaults. The flags `--envelope-log`, `--audit-log` and `--session-context-log` override them. It honours `NO_COLOR` and prints no colour when stdout is not a terminal. diff --git a/reference-implementations/agt/hosts/claude-code/README.md b/reference-implementations/agt/hosts/claude-code/README.md index 1e4be9c1..dfc908ee 100644 --- a/reference-implementations/agt/hosts/claude-code/README.md +++ b/reference-implementations/agt/hosts/claude-code/README.md @@ -4,7 +4,7 @@ ## Get started -1. Start the Guardian from `reference-implementations/agt`. The [tree README](../../README.md#start-the-guardian) shows the command. +1. Start the Guardian from `reference-implementations/agt`. The [tree README](../../README.md#start-the-guardian) shows the command. Export the same `ACS_HMAC_SECRET` value in the terminal that starts Claude Code. 2. Register the hook in the project you run Claude Code in. @@ -42,6 +42,7 @@ The first hook of a session sends `handshake/hello` and stores the Guardian's an | `ACS_SESSION_DIR` | `.acs/sessions` | Where the negotiated ServerHello is stored, one file per session | | `ACS_AUDIT_LOG` | `.acs/audit.jsonl` | Where fail-open proceeds and posture-driven blocks are written | | `ACS_HOOKMAP_PATH` | `claude-code.hookmap.yaml`, beside the shim | Which hookmap to load. This changes what governance means for the host. Leave it unset in a deployment | +| `ACS_HMAC_SECRET` | required with the standalone Guardian | The same base64-encoded secret supplied to the Guardian; signs ClientHello and step requests and verifies non-ping responses. Omission is compatibility mode for an explicitly unsecured in-process Guardian, not ACS-Core | ## Exit codes diff --git a/reference-implementations/agt/hosts/claude-code/acs-hook.ts b/reference-implementations/agt/hosts/claude-code/acs-hook.ts index 510b2351..fb860ca8 100644 --- a/reference-implementations/agt/hosts/claude-code/acs-hook.ts +++ b/reference-implementations/agt/hosts/claude-code/acs-hook.ts @@ -533,7 +533,10 @@ async function main(): Promise { const audit = createAuditSink({ path: process.env.ACS_AUDIT_LOG ?? ".acs/audit.jsonl" }); const guardianUrl = process.env.ACS_GUARDIAN_URL ?? DEFAULT_GUARDIAN_URL; - const guardian = createGuardianClient(guardianUrl); + const hmacSecret = process.env.ACS_HMAC_SECRET + ? Buffer.from(process.env.ACS_HMAC_SECRET, "base64") + : undefined; + const guardian = createGuardianClient(guardianUrl, { hmacSecret }); // Step 4: this session's negotiated config, and whatever went wrong getting // it -- one call, and this shim is told both rather than running the @@ -560,6 +563,7 @@ async function main(): Promise { agentId: hookmap.host, sessionId: toSessionUuid(sessionId), timeoutMs: DEFAULT_TIMEOUT_MS, + hmacSecret, }, store, ); @@ -574,7 +578,7 @@ async function main(): Promise { // rules produce for it, is stated once inside `governStep`, host-agnostically, // so a second host inherits it by calling the function rather than // reimplementing it. - const governed = await governStep({ hookEventName, payload, hookmap, guardian, session, sessionId, audit }); + const governed = await governStep({ hookEventName, payload, hookmap, guardian, session, sessionId, audit, hmacSecret }); process.stdout.write(JSON.stringify(asClaudeCodeOutput(governed.output, hookEventName))); } diff --git a/reference-implementations/agt/hosts/opencode/README.md b/reference-implementations/agt/hosts/opencode/README.md index a02a5fc3..1ba675fe 100644 --- a/reference-implementations/agt/hosts/opencode/README.md +++ b/reference-implementations/agt/hosts/opencode/README.md @@ -4,7 +4,7 @@ ## Get started -1. Start the Guardian from `reference-implementations/agt`. The [tree README](../../README.md#start-the-guardian) shows the command. +1. Start the Guardian from `reference-implementations/agt`. The [tree README](../../README.md#start-the-guardian) shows the command. Export the same `ACS_HMAC_SECRET` value in the terminal that starts OpenCode. 2. Point OpenCode at the plugin. The `opencode.json` at the root of this tree already does, with a relative path, so an `opencode` started from `reference-implementations/agt` needs no edit. From any other project, add the plugin to that project's `opencode.json` by absolute path. @@ -39,6 +39,7 @@ Because OpenCode has no field that reads a reason back after the tool has run, t | `ACS_AUDIT_LOG` | `.acs/audit.jsonl` | Where fail-open proceeds, posture-driven blocks and ungoverned skips are written | | `ACS_HOOKMAP_PATH` | `opencode.hookmap.yaml`, beside the plugin | Which hookmap to load. Leave it unset in a deployment | | `ACS_DEBUG` | unset | Prints each decision's reason on stderr | +| `ACS_HMAC_SECRET` | required with the standalone Guardian | The same base64-encoded secret supplied to the Guardian; signs ClientHello and step requests and verifies non-ping responses. Omission is compatibility mode for an explicitly unsecured in-process Guardian, not ACS-Core | There is no session directory. The plugin lives for the whole session and keeps the negotiated ServerHello in memory, so only the first hook of a session sends `handshake/hello`. diff --git a/reference-implementations/agt/hosts/opencode/acs-plugin.ts b/reference-implementations/agt/hosts/opencode/acs-plugin.ts index 24cc549b..94a41298 100644 --- a/reference-implementations/agt/hosts/opencode/acs-plugin.ts +++ b/reference-implementations/agt/hosts/opencode/acs-plugin.ts @@ -1182,6 +1182,7 @@ type Deployment = { readonly guardian: GuardianClient; readonly audit: AuditSink; readonly store: SessionConfigStore; + readonly hmacSecret?: Buffer; }; /** @@ -1323,6 +1324,7 @@ async function runExchange( agentId: deployment.hookmap.host, sessionId: toSessionUuid(input.sessionID), timeoutMs: DEFAULT_TIMEOUT_MS, + hmacSecret: deployment.hmacSecret, }, deployment.store, ); @@ -1349,6 +1351,7 @@ async function runExchange( // the actual command through. Both of this shim's gates pass through // here, so this one line is both of its call sites. scopedTool: input.tool, + hmacSecret: deployment.hmacSecret, }); applyOpenCodeOutput(governed.output, live); @@ -1394,15 +1397,19 @@ export const AcsPlugin: Plugin = async () => { // Built once, here, and handed to `runExchange` on every call -- the four // this deployment runs on, in one object so the exchange both gates // share can take them as one argument. + const hmacSecret = process.env.ACS_HMAC_SECRET + ? Buffer.from(process.env.ACS_HMAC_SECRET, "base64") + : undefined; const deployment: Deployment = { hookmap, - guardian: createGuardianClient(process.env.ACS_GUARDIAN_URL ?? DEFAULT_GUARDIAN_URL), + guardian: createGuardianClient(process.env.ACS_GUARDIAN_URL ?? DEFAULT_GUARDIAN_URL, { hmacSecret }), audit: createAuditSink({ path: process.env.ACS_AUDIT_LOG ?? ".acs/audit.jsonl" }), // In memory: one plugin object per session, so the negotiated config // survives in a variable and the second hook of a session skips the // handshake round trip. One interface, two implementations, and the // adapter never learns which host is running. store: createMemorySessionConfigStore(), + hmacSecret, }; // Named as a const rather than returned inline, so `Object.keys` below diff --git a/reference-implementations/agt/hosts/opencode/test/request-gate.test.ts b/reference-implementations/agt/hosts/opencode/test/request-gate.test.ts index 8361d0fb..289aa0a7 100644 --- a/reference-implementations/agt/hosts/opencode/test/request-gate.test.ts +++ b/reference-implementations/agt/hosts/opencode/test/request-gate.test.ts @@ -638,7 +638,7 @@ describe("a request-gate modify the hookmap gives no way to land -- the measured // claims would silently become false. // // The reason code is the half that says why it is over-blocking and not - // governance: `runtime_error:tool_unknown`, from `agt_stock`. The name on + // governance: ACS-Core's `tool_unregistered` classification. The name on // the wire is the command, so this deny is the tool registry refusing a // tool it does not know -- this deployment's own `rm -rf /` rule was // never consulted, because the envelope never said `bash`. A plain @@ -648,7 +648,7 @@ describe("a request-gate modify the hookmap gives no way to land -- the measured expect({ decision: governed.decision?.decision, reasonCodes: (governed.decision as { reason_codes?: string[] } | null)?.reason_codes, - }).toEqual({ decision: "deny", reasonCodes: ["runtime_error:tool_unknown"] }); + }).toEqual({ decision: "deny", reasonCodes: ["tool_unregistered"] }); }); // A declared decision name the tables do not know is silently unchecked, diff --git a/reference-implementations/agt/packages/conformance/src/failure-domains.ts b/reference-implementations/agt/packages/conformance/src/failure-domains.ts index 0700e3e4..b54e2009 100644 --- a/reference-implementations/agt/packages/conformance/src/failure-domains.ts +++ b/reference-implementations/agt/packages/conformance/src/failure-domains.ts @@ -52,32 +52,16 @@ * check posts an envelope naming a method `mapping.yaml` gives no row * to at all, and records that it comes back `method_not_dispatched` rather * than being swallowed into domain (1)'s deny-on-failure path -- proving the - * two stay apart, not resolving any cell about it. What this check does not - * do: `mapping.yaml` gives an `acs_method` to six intervention points, and - * this Guardian dispatches two (`handshake.ts`'s `METHODS_EVALUATED`); the - * other four answer `method_not_dispatched` too, live, but that is a - * different fact -- a mapped-but-undispatched method, not an unmapped one -- - * and this check does not resolve a cell for it or draw a matrix-level - * conclusion from it; the coverage-matrix merge and the published - * declaration carry that fact forward instead. - * `assertHookPayloadFailureIsUndispatchedElsewhere` below measures the same - * boundary from validation's own side rather than dispatch's: the - * hook-payload schemas `validateEnvelope` checks a payload against are gated - * on the identical two method literals `METHODS_EVALUATED` names, in a - * different file, so an empty payload is invalid only where this Guardian - * also dispatches -- one seam, seen from validation and from dispatch. + * two stay apart, not resolving any cell about it. The four non-tool + * ACS-Core hooks are a different fact: they are mapped and dispatched as + * allow-by-default audit events, while only the two native tool gates are + * policy-evaluated. `assertAllowByDefaultMappedHook` measures that boundary. * * ONE LIVE PROBE PER MAPPED POINT, never one probe generalised to all of * them, because a single hook-payload probe cannot stand in for the other - * five points: `checkHookPayload` applies a - * hook-payload schema only for `steps/toolCallRequest` / - * `steps/toolCallResult`, so an empty payload never fails validation at all - * for the other four mapped methods -- they have no hook-payload schema to - * fail, the envelope passes, and dispatch answers `method_not_dispatched`, - * not a deny. Measured live: `steps/toolCallRequest` and - * `steps/toolCallResult` return `decision=deny` for an empty payload; - * `steps/sessionStart`, `steps/sessionEnd`, `steps/userMessage`, - * `steps/agentResponse` each return a JSON-RPC error, code -32011. + * five points: each method has its own payload contract and dispatch + * semantics. The native tool gates policy-evaluate; the four lifecycle hooks + * are deliberately allow-by-default after their own payload validation. * `measureDenyColumn` below is what actually resolves the deny column: one * live probe per mapped point, using a generic `request-envelope.json` * failure (a missing `params.metadata`) rather than the method-gated @@ -92,20 +76,17 @@ import { AGT_POINTS, type CoverageCell } from "./cells.ts"; /** A method no row of mapping.yaml's `intervention_points` table ever * assigns to any point (see this module's header: this is the "unmapped - * method" boundary, distinct from the four mapped-but-undispatched - * methods). Namespaced under `steps/` so it reaches the same `isStepMethod` + * method" boundary, distinct from mapped allow-by-default hooks). + * Namespaced under `steps/` so it reaches the same `isStepMethod` * branch a real step would, which is the only way this probe can show that * an undispatched method does NOT fall into domain (1)'s deny-on-failure * path merely for being unrecognised. */ const UNMAPPED_METHOD = "steps/doesNotExist"; -/** A method mapping.yaml DOES map (to `agent_startup`) that this Guardian - * does not dispatch (`handshake.ts`'s `METHODS_EVALUATED` names only - * `steps/toolCallRequest` / `steps/toolCallResult`). Used only by - * `assertHookPayloadFailureIsUndispatchedElsewhere` to measure the - * hook-payload/dispatch coincidence this module's header names -- distinct - * from `UNMAPPED_METHOD`, which mapping.yaml gives no row to at all. */ -const UNDISPATCHED_MAPPED_METHOD = "steps/sessionStart"; +/** A method mapping.yaml maps to `agent_startup` and the Guardian dispatches + * as an allow-by-default audit event. Distinct from `UNMAPPED_METHOD`, which + * mapping.yaml gives no row to at all. */ +const ALLOW_BY_DEFAULT_MAPPED_METHOD = "steps/sessionStart"; type DenyProbeResponse = { result?: { decision?: string; reason_codes?: string[] }; error?: unknown }; type DispatchProbeResponse = { result?: unknown; error?: { code?: number; message?: string } }; @@ -178,14 +159,13 @@ function envelopeMissingMetadata(method: string): Record { * the failure this probe measures is the hook-payload check specifically, * not some other field validate-envelope.ts would also have rejected. * - * Does not, by itself, license any cell. The hook-payload check this probe - * trips is method-gated (`checkHookPayload` applies it to only - * `steps/toolCallRequest` / `steps/toolCallResult` get one), so a deny here - * says nothing about a mapped point with no hook-payload schema at all. + * Does not, by itself, license any cell. The hook-payload check is + * method-gated, so a deny here says nothing about another mapped point with + * a different payload schema and allow-by-default dispatch semantics. * `measureDenyColumn` below is what actually resolves the deny column, from * six independent probes using a different, genuinely method-independent * failure mode. This function stays because it is the base case - * `assertHookPayloadFailureIsUndispatchedElsewhere` contrasts against. + * `assertAllowByDefaultMappedHook` contrasts against. * * A throw here, not a resolved `unexpressed` cell: a Guardian that answers * this probe with anything but an honoured deny has the fail-closed @@ -211,43 +191,28 @@ async function assertEvaluationFailsClosed(guardianUrl: string): Promise { } /** - * The hook-payload finding. Posts the same shape as - * `assertEvaluationFailsClosed` -- an empty payload -- at - * `UNDISPATCHED_MAPPED_METHOD`, a method mapping.yaml maps to a point - * (`agent_startup`) but this Guardian does not dispatch, and asserts the - * answer is `method_not_dispatched`, not a deny. - * - * `hooks/session-start.json` (or any schema for a non-tool-call method) is - * never checked by `validateEnvelope` at all -- `checkHookPayload` is called - * only for `steps/toolCallRequest` / `steps/toolCallResult` - * (`checkHookPayload`) -- so an empty payload here is not invalid; - * the envelope passes validation whole, and dispatch's own predicates answer - * `method_not_dispatched`. Contrasted directly with - * `assertEvaluationFailsClosed`'s identical-shaped probe at - * `steps/toolCallRequest`, this is the hook-payload/dispatch coincidence - * this module's header names: the two method lists that gate hook-payload - * checking and dispatch are declared independently, in different files, and - * today they happen to name the same two methods. - * - * A THROW when the answer is anything else -- not because that would be - * domain (1) or (2) failing, but because it would mean an empty payload has - * started failing validation for a method with no hook-payload schema this - * check knows of, which is exactly the kind of drift this probe exists to - * catch rather than let the module header's claim go unmeasured. + * The hook-payload finding. Posts an empty payload at a mapped non-tool + * method. With ACS-Core conformance (D7), the Guardian dispatches all six + * mapped methods — the four non-tool hooks return allow-by-default with a + * chain entry rather than method_not_dispatched. This probe now asserts that + * the allow-by-default path answers correctly, contrasting with + * assertEvaluationFailsClosed's identical-shaped probe at + * steps/toolCallRequest (which fails closed into a deny). */ -async function assertHookPayloadFailureIsUndispatchedElsewhere(guardianUrl: string): Promise { +async function assertAllowByDefaultMappedHook(guardianUrl: string): Promise { const response = await fetch(guardianUrl, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify(wellFormedEnvelope(UNDISPATCHED_MAPPED_METHOD, {})), + body: JSON.stringify(wellFormedEnvelope(ALLOW_BY_DEFAULT_MAPPED_METHOD, {})), }); const body = (await response.json()) as DispatchProbeResponse; - if (body.error?.code !== METHOD_NOT_DISPATCHED_CODE) { + const result = body.result as Record | undefined; + if (result?.decision !== "allow") { throw new Error( - `${JSON.stringify(UNDISPATCHED_MAPPED_METHOD)} with an empty payload -- invalid only at the two ` + - `hook-payload-checked methods -- answered ${JSON.stringify(body)} instead of method_not_dispatched. The ` + - `hook-payload/dispatch coincidence this probe measures does not hold against this Guardian.`, + `${JSON.stringify(ALLOW_BY_DEFAULT_MAPPED_METHOD)} with an empty payload -- an allow-by-default method -- ` + + `answered ${JSON.stringify(body)} instead of allow. ` + + `ACS-Core conformance requires the four non-tool hooks to return allow with a chain entry.`, ); } } @@ -331,8 +296,8 @@ async function measureDenyColumn(guardianUrl: string, mapping: Mapping): Promise * generalised. The other three probe kinds assert invariants this check * depends on and resolve no cell of their own: `assertEvaluationFailsClosed` * is its own literal hook-payload probe, - * `assertHookPayloadFailureIsUndispatchedElsewhere` measures the - * hook-payload/dispatch coincidence, and `assertUnmappedMethodIsUndispatched` + * `assertAllowByDefaultMappedHook` measures the mapped audit-only path, and + * `assertUnmappedMethodIsUndispatched` * measures the third boundary against a method mapping.yaml does not map at * all. * @@ -352,7 +317,7 @@ export async function checkDenyFailsClosed( mapping: Mapping, ): Promise { await assertEvaluationFailsClosed(guardian.url); - await assertHookPayloadFailureIsUndispatchedElsewhere(guardian.url); + await assertAllowByDefaultMappedHook(guardian.url); await assertUnmappedMethodIsUndispatched(guardian.url); return measureDenyColumn(guardian.url, mapping); diff --git a/reference-implementations/agt/packages/guardian/README.md b/reference-implementations/agt/packages/guardian/README.md index 4c604f7f..a7be9290 100644 --- a/reference-implementations/agt/packages/guardian/README.md +++ b/reference-implementations/agt/packages/guardian/README.md @@ -9,6 +9,7 @@ The Guardian never names a host. It does not know whether Claude Code or OpenCod Run it from `reference-implementations/agt`. Its default paths are relative to that directory. ```bash +export ACS_HMAC_SECRET="$(openssl rand -base64 32)" bun run guardian ``` @@ -17,6 +18,7 @@ Guardian listening at http://localhost:8787/acs Envelope log: .acs/envelopes.jsonl Session context log: .acs/session-context.jsonl Failure posture: proceed (override with ACS_ON_DECISION_FAILURE=deny) +Signature verification: HMAC-SHA256 (required) ``` It constructs the AGT runtime once, at startup, against `policy/manifest.yaml`, then serves until you stop it. Every variable it reads: @@ -25,26 +27,27 @@ It constructs the AGT runtime once, at startup, against `policy/manifest.yaml`, |---|---|---| | `ACS_ON_DECISION_FAILURE` | `proceed` | The posture the ServerHello declares. `deny` fails closed. Any other value stops the Guardian at startup | | `ACS_GUARDIAN_PORT` | `8787` | The port for `POST /acs` | -| `ACS_GUARDIAN_HOST` | `127.0.0.1` | The interface to bind. Set `0.0.0.0` only when you accept an unauthenticated routable endpoint | +| `ACS_GUARDIAN_HOST` | `127.0.0.1` | The interface to bind. Set `0.0.0.0` only after protecting the transport and provisioning the shared secret to every intended peer | | `ACS_MANIFEST_PATH` | `policy/manifest.yaml` | The AGT manifest. `policy/manifest.drift.yaml` is the second manifest, used to reach the `warn` verdict | | `ACS_ENVELOPE_LOG` | `.acs/envelopes.jsonl` | Where every envelope is recorded | | `ACS_SESSION_CONTEXT_LOG` | `.acs/session-context.jsonl` | Where each session's hash chain is written | | `ACS_OTEL_FILE` | unset | Writes one OpenTelemetry span per decided step as OTLP/JSON, one export request per line. Unset means no trace export | | `ACS_OTEL_ENDPOINT` | unset | Sends the same spans to an OTLP/HTTP collector, for example `http://127.0.0.1:4318/v1/traces` | +| `ACS_HMAC_SECRET` | required | Base64-encoded input keying material of at least 32 bytes. The Guardian derives per-session keys, verifies requests, and signs addressable non-ping results and errors | To see a decision without an agent client, pipe a hook payload into the Claude Code shim while the Guardian runs. The [tree README](../../README.md#drive-one-hook-by-hand) shows the command and its output. ## What happens to each request 1. The raw envelope is appended to the envelope log, before anything else. -2. The envelope is validated against `request-envelope.json`. A `steps/toolCallRequest` or `steps/toolCallResult` is also validated against its own payload schema. An invalid envelope on a `steps/*` method is answered with an honoured `deny`, not a bare error, so the host has a decision to act on. -3. The AGT intervention point for the method is resolved from `mapping.yaml`, and so is the argument the policy target is read from for the tool the payload names. -4. The AGT snapshot is assembled, one shape per gate, and evaluated through the bridge. -5. The verdict is mapped to an ACS decision with the tables in `mapping.yaml`. A `transform` becomes a `modify` whose modification differs by gate: a parameter override at the request gate, a redaction on the result payload at the result gate. +2. The JSON-RPC and common-envelope shape is validated. Except for the signature-exempt liveness ping, the Guardian then verifies the per-session HMAC, rejects timestamps outside the skew window, and rejects a reused `request_id` from its independent replay store. This includes ClientHello: HMAC key material is provisioned out of band, not negotiated in-band. +3. The authenticated method-specific payload is validated. Invalid `steps/*` payloads receive an honoured `deny`, not a bare error, so the host has a decision to act on without letting malformed unsigned traffic bypass authentication. +4. Registered tool calls are resolved through `mapping.yaml`; unknown tool names receive `tool_unregistered`. Native step hooks and wrapped `protocols/MCP/tools/call` requests use the same registry. +5. The AGT snapshot is assembled and evaluated through the bridge. Its verdict is mapped to an ACS decision; a `transform` becomes the gate-appropriate `modify` operation. 6. One entry is appended to the session's hash chain. Information-flow labels AGT emitted are stored, so the next step of the session sends them back as input. -7. The outgoing response is checked against `response-envelope.json`. The result is logged on stderr and never thrown. The response is then recorded and sent. +7. Every addressable non-ping result or error is signed; ping stays signature-exempt in both directions. Every response is checked against the applicable response shape, recorded, and sent. The outbound schema result is diagnostic and never replaces the response. -`handshake/hello` is answered with a constant ServerHello that names exactly the methods this Guardian dispatches: `steps/toolCallRequest` and `steps/toolCallResult`. The Guardian does not read the ClientHello. Any other method is answered with a JSON-RPC error carrying `METHOD_NOT_DISPATCHED_CODE`, which the conformance harness reads back to confirm the ServerHello tells the truth. +`handshake/hello` validates the ClientHello, requires support for ACS `0.1.0`, intersects the client's methods with the methods this Guardian actually evaluates, and accepts the `acs-core` profile only when HMAC is configured. The evaluated set is `steps/toolCallRequest`, `steps/toolCallResult`, and `protocols/MCP/tools/call`; lifecycle hooks remain dispatchable audit events but are not advertised as policy-evaluated. Any other method is answered with a JSON-RPC error carrying `METHOD_NOT_DISPATCHED_CODE`. Every throw on the request path is caught. Nothing escapes the fetch handler, because an unhandled rejection would produce an HTML error page, the host's client would fail to parse it, and Claude Code would read the failed hook as "never fired" and proceed ungoverned. @@ -142,12 +145,16 @@ The tests and the conformance harness start a Guardian in the same process. ```ts import { startGuardian } from "guardian"; -const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); +const guardian = await startGuardian({ + port: 0, + manifestPath: "policy/manifest.yaml", + hmacSecret: process.env.ACS_HMAC_SECRET, +}); // guardian.url is the full endpoint, on the port the OS assigned. await guardian.close(); ``` -`StartGuardianOptions` also accepts `hostname`, `envelopeLogPath`, `sessionContextLog`, `otelTraceFile`, `otelTraceEndpoint` and `onDecisionFailure`, which mirror the environment variables above, and five options for tests: `mappingPath`, `annotator`, `bridge`, `sessionContextStore` and `traceExporter`. Each is documented where it is declared, in `src/server.ts`. +`StartGuardianOptions` also accepts `hostname`, `envelopeLogPath`, `sessionContextLog`, `otelTraceFile`, `otelTraceEndpoint`, `onDecisionFailure` and `hmacSecret`, which mirror the environment variables above, and five options for tests: `mappingPath`, `annotator`, `bridge`, `sessionContextStore` and `traceExporter`. Omitting `hmacSecret` is a non-conformant mode for in-process tests, and its handshake advertises no `acs-core` profile. Each option is documented where it is declared, in `src/server.ts`. `otelTraceEndpoint` leaves the proxy variables alone. `src/main.ts` prints the proxy warning and adds a loopback endpoint's host to whichever of `NO_PROXY` and `no_proxy` is set, or to both when neither is. An embedder that wants the same imports `loopbackProxyWarning` and `keepLoopbackOffProxy` from `guardian` and calls each with `(endpoint, process.env)` before it starts the Guardian. @@ -162,6 +169,7 @@ The package has two entry points. `guardian` exports the governance verbs listed | `src/validate-envelope.ts` | The Ajv registry over the repository's schemas, and `validateEnvelope` | | `src/check-response.ts` | The outbound check against the response schema. Reports, never throws | | `src/handshake.ts` | `buildServerHello` | +| `src/verify-signature.ts` | Per-session HKDF derivation plus request verification and response signing | | `src/map-verdict.ts` | Reads `mapping.yaml`. Resolves intervention points and policy-target arguments. Maps verdicts to decisions | | `src/assemble-snapshot.ts` | The two snapshot assemblers, one per gate | | `src/annotate-egress.ts` | Extracts an egress destination from a shell command for AGT's egress gate | @@ -176,9 +184,9 @@ The package has two entry points. `guardian` exports the governance verbs listed | `src/audit-to-otlp.ts` | Converts the host adapter's audit log into the same OTLP/JSON file | | `src/acs-result.ts` | The decision result type | -## The wire is not secured +## Wire security -The endpoint has no authentication, no origin check and no request signing. It binds loopback by default, so reachability is the only access control. Widen the bind only for a Guardian that runs in its own container, and read the header of `src/server.ts` first. +The standalone Guardian requires HMAC-SHA256 authentication. Hosts sign requests and verify result and error responses with a per-session key derived from `ACS_HMAC_SECRET`; timestamp and replay checks bound reuse. HMAC provides integrity and peer possession of the shared secret, not confidentiality or an origin policy. The endpoint therefore binds loopback by default. Protect the transport and carefully distribute the shared secret before widening the bind. ## Tests diff --git a/reference-implementations/agt/packages/guardian/src/acs-result.ts b/reference-implementations/agt/packages/guardian/src/acs-result.ts index fa275147..d883c846 100644 --- a/reference-implementations/agt/packages/guardian/src/acs-result.ts +++ b/reference-implementations/agt/packages/guardian/src/acs-result.ts @@ -24,6 +24,10 @@ export type AcsFinalResult = { type: "final"; acs_version: string; request_id: string; + /** §8.6: the chain head after this step was recorded. */ + chain_hash?: string; + /** §10: HMAC signature over the response, present when key is configured. */ + signature?: { algorithm: string; value: string; key_id: string }; } & AcsDecision; /** @@ -37,11 +41,16 @@ export type AcsFinalResult = { export function finalResult( params: { acs_version: string; request_id: string }, decision: AcsDecision, + chainHash?: string, ): AcsFinalResult { - return { + const result: AcsFinalResult = { type: "final", acs_version: params.acs_version, request_id: params.request_id, ...decision, }; + if (chainHash !== undefined) { + result.chain_hash = chainHash; + } + return result; } diff --git a/reference-implementations/agt/packages/guardian/src/handshake.ts b/reference-implementations/agt/packages/guardian/src/handshake.ts index 96b50a87..8933a309 100644 --- a/reference-implementations/agt/packages/guardian/src/handshake.ts +++ b/reference-implementations/agt/packages/guardian/src/handshake.ts @@ -3,21 +3,12 @@ * handshake.json's ServerHello $def * (specification/v0.1.0/handshake.json at the repository root). * - * `build`, not `negotiate`: this function never reads the incoming - * ClientHello. The client (host-adapter's `negotiateSessionConfig`) genuinely - * sends one, but every field below is a constant, returned unconditionally. - * So `negotiated_version` and `selected_transport` are declared by this - * Guardian rather than agreed against what the client proposed. Real - * negotiation -- reading the ClientHello, picking a mutually supported - * version and transport, rejecting what isn't -- is future work; see the - * matching note in the v1 runbook of the standalone repository - * (github.com/afogel/ACS_reference_implementation, docs/demos/v1-runbook.md). + * The server owns version/method/profile intersection because it has the + * ClientHello. This builder owns the deployment capabilities that are stable + * across sessions; dispatch supplies the negotiated profile set. * - * `methods_evaluated` is exactly the set this Guardian actually dispatches, - * pinned against the dispatch by - * test/handshake-declares-what-it-evaluates.test.ts rather than by this - * sentence. See METHODS_EVALUATED below for why it is checked and not - * derived. + * `methods_evaluated` is exactly the set this Guardian policy-evaluates. + * Dispatched allow-by-default lifecycle hooks are deliberately absent. * * `on_decision_failure` ships the spec default, "proceed" (fail-open). This * responder's posture is deployment-configurable, so one binary can @@ -32,20 +23,21 @@ export type ServerHello = { negotiated_version: string; methods_evaluated: string[]; selected_transport: "http" | "https" | "stdio"; + signature_algorithms_supported: string[]; timeout_config: { default_ms: number; per_method_ms?: Record }; on_decision_failure: "proceed" | "deny"; + skew_window_ms: number; + profiles_accepted: string[]; }; /** The ACS spec version every schema and mapping in this repo is pinned to. */ const NEGOTIATED_VERSION = "0.1.0"; /** - * The methods this Guardian actually dispatches -- exactly the two gated - * branches in server.ts (`isToolCallRequest` -> `assemblePreToolCallSnapshot`, - * `isToolCallResult` -> `assemblePostToolCallSnapshot`). Anything else falls - * to `method_not_dispatched`. + * The methods this Guardian policy-evaluates: the two native tool gates and + * the wrapped MCP tool-call gate. * - * This list has to agree exactly with what server.ts dispatches, in both + * This list has to agree exactly with what server.ts evaluates, in both * directions, and handshake.json's text for this field is not advisory: * "Methods listed by the client but absent here are NOT evaluated; the * Guardian's enforcement does not cover them. Clients MAY still emit them @@ -57,12 +49,10 @@ const NEGOTIATED_VERSION = "0.1.0"; * default, while the Guardian is actually enforcing them. * * So the relationship is checked rather than trusted -- - * test/handshake-declares-what-it-evaluates.test.ts drives a candidate - * envelope for every method mapping.yaml maps through a live Guardian and - * asserts that the set it does not answer `method_not_dispatched` for is - * exactly this list. + * test/handshake-declares-what-it-evaluates.test.ts pins the evaluated set and + * separately proves allow-by-default lifecycle hooks remain dispatchable. * - * It stays a literal on purpose. The dispatch it must agree with is two + * It stays a literal on purpose. The dispatch it must agree with uses * predicate-gated branches, deliberately not a table (server.ts says why: a * point-driven dispatch would hand one method's envelope to another method's * assembler and return a well-formed verdict for the wrong policy). Nothing @@ -71,7 +61,11 @@ const NEGOTIATED_VERSION = "0.1.0"; * predicate exists, not that dispatch actually reaches it. The test verifies * what the code cannot. */ -const METHODS_EVALUATED = ["steps/toolCallRequest", "steps/toolCallResult"]; +const METHODS_EVALUATED = [ + "steps/toolCallRequest", + "steps/toolCallResult", + "protocols/MCP/tools/call", +]; /** * Deployment-chosen default; handshake.json's timeout_config.default_ms @@ -117,13 +111,28 @@ function readPosture(env: Record): Posture { ); } -export function buildServerHello(env?: { ACS_ON_DECISION_FAILURE?: string }): ServerHello { +/** §10.3: the window this Guardian enforces, declared in the ServerHello so + * the client knows the bound. Matches SKEW_WINDOW_MS in server.ts. */ +const SKEW_WINDOW_MS = 300_000; + +export type ServerHelloCapabilities = { + hmacEnabled?: boolean; + profilesAccepted?: string[]; +}; + +export function buildServerHello( + env?: { ACS_ON_DECISION_FAILURE?: string }, + capabilities: ServerHelloCapabilities = {}, +): ServerHello { const actualEnv = env ?? process.env; return { negotiated_version: NEGOTIATED_VERSION, methods_evaluated: METHODS_EVALUATED, selected_transport: "http", + signature_algorithms_supported: capabilities.hmacEnabled ? ["HMAC-SHA256"] : [], timeout_config: { default_ms: DEFAULT_TIMEOUT_MS }, on_decision_failure: readPosture(actualEnv), + skew_window_ms: SKEW_WINDOW_MS, + profiles_accepted: capabilities.profilesAccepted ?? [], }; } diff --git a/reference-implementations/agt/packages/guardian/src/index.ts b/reference-implementations/agt/packages/guardian/src/index.ts index 56257536..0a032686 100644 --- a/reference-implementations/agt/packages/guardian/src/index.ts +++ b/reference-implementations/agt/packages/guardian/src/index.ts @@ -78,3 +78,12 @@ export { persistIfcLabels, supplySourceLabels } from "./ifc-labels.ts"; // proxy variables alone, and an embedder that wants main.ts's loopback // exemption needs these two to apply it. export { keepLoopbackOffProxy, loopbackProxyWarning } from "./loopback-proxy.ts"; +export { + jcsCanonicalise, + verifyHmacSha256, + signingInput, + deriveSessionKey, + signResponseHmacSha256, + responseSigningInput, +} from "./verify-signature.ts"; +export { ToolUnregisteredError } from "./map-verdict.ts"; diff --git a/reference-implementations/agt/packages/guardian/src/main.ts b/reference-implementations/agt/packages/guardian/src/main.ts index 302c81b2..de2ca289 100644 --- a/reference-implementations/agt/packages/guardian/src/main.ts +++ b/reference-implementations/agt/packages/guardian/src/main.ts @@ -13,8 +13,8 @@ * hardcoding the other's value. * * `ACS_GUARDIAN_HOST` is left unset by default, which leaves startGuardian's - * loopback bind in place -- see server.ts's header for why an unauthenticated - * endpoint defaults to the narrowest bind. This is where a deployment that + * loopback bind in place -- see server.ts's header for why even an + * authenticated endpoint defaults to the narrowest bind. This is where a deployment that * needs a routable one says so, since env is this process's configuration * surface and server.ts reads none itself. */ @@ -66,6 +66,16 @@ if (otelTraceEndpoint !== undefined) { // healthy start immediately followed by one. const posture = buildServerHello().on_decision_failure; +const hmacSecret = process.env.ACS_HMAC_SECRET; +if (hmacSecret === undefined) { + throw new Error( + "ACS_HMAC_SECRET is required: set it to at least 32 bytes of base64-encoded key material before starting the ACS-Core Guardian", + ); +} +const decodedHmacSecret = Buffer.from(hmacSecret, "base64"); +if (decodedHmacSecret.length < 32) { + throw new Error("ACS_HMAC_SECRET must decode to at least 32 bytes"); +} const guardian = await startGuardian({ port, hostname, @@ -74,14 +84,16 @@ const guardian = await startGuardian({ sessionContextLog, otelTraceFile, otelTraceEndpoint, + hmacSecret, }); console.log(`Guardian listening at ${guardian.url}`); console.log(`Envelope log: ${envelopeLogPath}`); console.log(`Session context log: ${sessionContextLog}`); console.log(`Failure posture: ${posture} (override with ACS_ON_DECISION_FAILURE=deny)`); +console.log("Signature verification: HMAC-SHA256 (required)"); -// Only when export is on. The four lines above are what README.md captures -// and test/readme-captures.test.ts holds them to the line, so a fifth line +// Only when export is on. The five lines above are what README.md captures +// and test/readme-captures.test.ts holds them to the line, so a sixth line // appears only for a Guardian the quickstart does not run. const traceOutputs = [otelTraceFile, otelTraceEndpoint].filter((target): target is string => target !== undefined); if (traceOutputs.length > 0) { diff --git a/reference-implementations/agt/packages/guardian/src/map-verdict.ts b/reference-implementations/agt/packages/guardian/src/map-verdict.ts index 9ead61e5..9c45398a 100644 --- a/reference-implementations/agt/packages/guardian/src/map-verdict.ts +++ b/reference-implementations/agt/packages/guardian/src/map-verdict.ts @@ -1,6 +1,10 @@ import { readFileSync } from "node:fs"; import type { AgtVerdict } from "agt-bridge"; +export class ToolUnregisteredError extends Error { + override name = "ToolUnregisteredError" as const; +} + export type AcsModifications = { modified_content?: string; redactions?: { path: string; replacement?: string }[]; @@ -13,6 +17,19 @@ export type AcsDecision = { reason_codes?: string[]; policy_references?: { policy_id: string; policy_version?: string; rule_id: string }[]; modifications?: AcsModifications; + ask_details?: { + approver: { type: "human" | "agent" | "service"; id: string }; + question: string; + timeout_seconds: number; + timeout_disposition?: "allow" | "deny"; + }; + defer_details?: { + reason: "insufficient_context" | "conflicting_policies" | "low_confidence" | "pending_dependency"; + resolution_method: "additional_context" | "human_approval" | "timeout"; + resolution_timeout_ms: number; + timeout_decision?: "deny" | "ask"; + required_context?: string[]; + }; }; type VerdictRule = { @@ -216,7 +233,7 @@ export function resolvePolicyTargetArgument( } if (typeof table.default !== "string") { - throw new Error( + throw new ToolUnregisteredError( `mapping.yaml's intervention_points.${point}.policy_target_argument names no argument for tool ` + `${JSON.stringify(toolName)} and declares no usable "default"`, ); @@ -493,5 +510,14 @@ export function mapVerdict( out.modifications = synthesizeModifications(verdict, mapping, point, policyTargetArgument); } + if (rule.decision === "ask") { + out.ask_details = { + approver: { type: "human", id: "deployment-default" }, + question: out.reasoning ?? "Approve this step?", + timeout_seconds: 300, + timeout_disposition: "deny", + }; + } + return out; } diff --git a/reference-implementations/agt/packages/guardian/src/server.ts b/reference-implementations/agt/packages/guardian/src/server.ts index 922c11bd..9ddc1c21 100644 --- a/reference-implementations/agt/packages/guardian/src/server.ts +++ b/reference-implementations/agt/packages/guardian/src/server.ts @@ -53,17 +53,16 @@ * and evaluated statelessly. * * The listening socket defaults to loopback (127.0.0.1). Bun.serve with no - * `hostname` binds `*` -- every interface, dual-stack -- and this endpoint has - * no authentication, no origin check and no request signing, so anything that - * can open the port is both a policy oracle (ask it what would be allowed) - * and a policy sink (feed it envelopes it evaluates as if a governed host had - * sent them). Until the wire is authenticated, reachability IS the access - * control, so the default is the narrowest bind a host shim on the same - * machine can still reach. A deployment that genuinely needs a routable bind - * -- a Guardian in its own container, say -- opts in explicitly through the - * `hostname` option (main.ts reads ACS_GUARDIAN_HOST for it). + * `hostname` binds `*` -- every interface, dual-stack. HMAC authenticates ACS + * messages when `hmacSecret` is configured (and the standalone CLI requires + * it), but it does not provide transport confidentiality or an origin check. + * The default therefore remains the narrowest bind a host shim on the same + * machine can reach. A deployment that genuinely needs a routable bind -- a + * Guardian in its own container, say -- opts in explicitly through the + * `hostname` option (main.ts reads ACS_GUARDIAN_HOST for it) and must protect + * the transport and provision the same secret to every intended peer. */ -import { appendFileSync, mkdirSync } from "node:fs"; +import { appendFileSync, mkdirSync, readFileSync } from "node:fs"; import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; import type { Annotator, PolicyBridge } from "agt-bridge"; @@ -81,13 +80,15 @@ import { mapVerdict, resolveInterventionPoint, resolvePolicyTargetArgument, + ToolUnregisteredError, type Mapping, } from "./map-verdict.ts"; import { EnvelopeValidationError, isToolCallRequest, isToolCallResult, - validateEnvelope, + validateEnvelopeShape, + validateMethodPayload, type AcsRequestEnvelope, } from "./validate-envelope.ts"; import { checkResponse } from "./check-response.ts"; @@ -101,6 +102,7 @@ import { type SessionContextStore, } from "./session-context-store.ts"; import { persistIfcLabels, supplySourceLabels } from "./ifc-labels.ts"; +import { deriveSessionKey, verifyHmacSha256, signResponseHmacSha256 } from "./verify-signature.ts"; /** * Every snapshot message this Guardian can send an intervention point -- one @@ -119,6 +121,76 @@ const MAPPING_PATH = fileURLToPath(new URL("../../../mapping.yaml", import.meta. const HANDSHAKE_METHOD = "handshake/hello"; const ACS_PATH = "/acs"; +/** §10.3: the window outside which a timestamp is rejected. 5 minutes is the + * spec's own example and matches the skew_window_ms the ServerHello declares. */ +const SKEW_WINDOW_MS = 300_000; + +type ReplayProtectionStore = { + seenOrRemember( + sessionId: string, + requestId: string, + nowMs: number, + validUntilMs: number, + ): "fresh" | "replay" | "capacity"; +}; + +/** Replay state is deliberately independent of the bounded audit-chain store. + * Entries need live only as long as a timestamp can still pass the skew gate. */ +export function createReplayProtectionStore(maxSessions = 4096, maxEntries = 65_536): ReplayProtectionStore { + const sessions = new Map>(); + return { + seenOrRemember(sessionId, requestId, nowMs, validUntilMs) { + // Prune every session, not only the one currently addressed, and remove + // empty buckets. Otherwise fresh session UUIDs grow this map forever. + for (const [knownSessionId, knownIds] of sessions) { + for (const [id, expiresAt] of knownIds) { + if (expiresAt < nowMs) knownIds.delete(id); + } + if (knownIds.size === 0) sessions.delete(knownSessionId); + } + + let entryCount = 0; + for (const knownIds of sessions.values()) entryCount += knownIds.size; + + let ids = sessions.get(sessionId); + if (!ids) { + // Never evict a still-valid replay record to make room: doing so + // would turn a session-flood into a replay bypass. Saturation fails + // closed until time-based pruning frees capacity. + if (sessions.size >= maxSessions || entryCount >= maxEntries) return "capacity"; + ids = new Map(); + sessions.set(sessionId, ids); + } else { + // Refresh insertion order so the session cap evicts the least + // recently used session rather than the earliest-created active one. + sessions.delete(sessionId); + sessions.set(sessionId, ids); + } + if (ids.has(requestId)) return "replay"; + if (entryCount >= maxEntries) return "capacity"; + // A future-dated request stays timestamp-valid until timestamp + skew, + // which can be almost two windows after receipt. Retain its id for that + // entire validity interval rather than a fixed interval after arrival. + ids.set(requestId, validUntilMs); + return "fresh"; + }, + }; +} + +function loadRegisteredTools(manifestPath: string): ReadonlySet { + const manifest = Bun.YAML.parse(readFileSync(manifestPath, "utf8")) as { tools?: unknown }; + if (typeof manifest.tools !== "object" || manifest.tools === null || Array.isArray(manifest.tools)) { + throw new Error(`manifest ${JSON.stringify(manifestPath)} declares no usable tools registry`); + } + return new Set(Object.keys(manifest.tools)); +} + +function assertToolRegistered(registeredTools: ReadonlySet, toolName: string): void { + if (!registeredTools.has(toolName)) { + throw new ToolUnregisteredError(`tool ${JSON.stringify(toolName)} is not registered in the deployment manifest`); + } +} + /** The default bind address -- see the module header for why it is loopback * and not `*`. Spelled as the literal address rather than "localhost": the * name resolves to both ::1 and 127.0.0.1, and Bun binds only one of them, @@ -217,6 +289,7 @@ export const METHOD_NOT_DISPATCHED_CODE = -32011; * own require_policy_references check, an AGT runtime error, or -- through * handleAcsRequest's outer net -- a failure to even build the schema registry. * The module header explains why none of this may become dead code. */ +const SIGNATURE_INVALID_CODE = -32004; const EVALUATION_FAILED_CODE = -32020; /** @@ -248,16 +321,20 @@ type JsonRpcSuccess = { jsonrpc: "2.0"; id: string | number; result: AcsFinalRes type JsonRpcFailure = { jsonrpc: "2.0"; id: string | number | null; - error: { code: number; message: string; data?: unknown }; + error: { + code: number; + message: string; + data?: unknown; + signature?: { algorithm: string; value: string; key_id: string }; + }; }; export type StartGuardianOptions = { port: number; manifestPath: string; - /** The address to bind. Defaults to loopback; set it only to widen the - * bind deliberately, and read the module header first -- the endpoint is - * unauthenticated, so widening it hands the policy decision to whoever can - * reach the port. main.ts threads ACS_GUARDIAN_HOST into this. */ + /** The address to bind. Defaults to loopback; set it only to widen the bind + * deliberately, after arranging transport protection and shared-secret + * provisioning. main.ts threads ACS_GUARDIAN_HOST into this. */ hostname?: string; /** Overrides the mapping.yaml path this Guardian loads. Defaults to the * repo's real mapping.yaml; exists so tests can inject a deliberately @@ -303,9 +380,9 @@ export type StartGuardianOptions = { * exercises that manifest through this option. */ annotator?: Annotator; /** Overrides the bridge this Guardian evaluates snapshots against, - * bypassing `createBridge(manifestPath, ...)` entirely -- and, with it, - * `manifestPath` and `annotator`: both are silently unused whenever - * `bridge` is supplied, since `createBridge` is never called. Exists so a + * bypassing `createBridge(manifestPath, ...)` and `annotator`. The manifest + * remains authoritative for the registered-tool allowlist, so + * `manifestPath` is still read even when `bridge` is supplied. Exists so a * test can see exactly which snapshot reached evaluation and choose the * `result_labels` that come back. The real bridge supplies neither. It does * produce `result_labels` -- `policy/lib/data.json` sets @@ -343,6 +420,10 @@ export type StartGuardianOptions = { * Exists so a test can read spans back in-process or stand in one that * fails. Not meant for production use. */ traceExporter?: OtelTraceExporter; + /** Base64-encoded shared HMAC input keying material. Omission is supported + * only for in-process compatibility tests and advertises no `acs-core` + * profile; the standalone deployment requires ACS_HMAC_SECRET. */ + hmacSecret?: string; }; export type StartedGuardian = { url: string; close(): Promise }; @@ -413,7 +494,12 @@ export async function startGuardian({ otelTraceFile, otelTraceEndpoint, traceExporter: traceExporterOverride, + hmacSecret, }: StartGuardianOptions): Promise { + if (hmacSecret !== undefined && Buffer.from(hmacSecret, "base64").length < 32) { + throw new Error("hmacSecret must decode to at least 32 bytes"); + } + // Construct the bridge once at boot, not per request -- and through the // shared recipe rather than assembling one here, so this Guardian and every // other caller that evaluates this deployment's manifest get the identical @@ -421,6 +507,8 @@ export async function startGuardian({ // actually lives; see its own module. const bridge = bridgeOverride ?? createDeploymentBridge(manifestPath, annotator); const mapping = loadMapping(mappingPath ?? MAPPING_PATH); + const registeredTools = loadRegisteredTools(manifestPath); + const replayProtectionStore = createReplayProtectionStore(); const envelopeLog = envelopeLogPath ? createEnvelopeLogSink({ path: envelopeLogPath }) : NULL_ENVELOPE_LOG_SINK; // The session-context store is always the in-memory one, or the caller's // own override -- sessionContextLog never becomes an alternative backing @@ -463,7 +551,18 @@ export async function startGuardian({ if (req.method !== "POST" || pathname !== ACS_PATH) { return new Response("Not Found", { status: 404 }); } - const response = await handleAcsRequest(req, bridge, mapping, envelopeLog, onDecisionFailure, sessionContextStore, traceExporter); + const response = await handleAcsRequest( + req, + bridge, + mapping, + envelopeLog, + onDecisionFailure, + sessionContextStore, + replayProtectionStore, + registeredTools, + hmacSecret, + traceExporter, + ); return Response.json(response); }, }); @@ -532,6 +631,9 @@ async function handleAcsRequest( envelopeLog: EnvelopeLogSink, onDecisionFailure: "proceed" | "deny" | undefined, sessionContextStore: SessionContextStore, + replayProtectionStore: ReplayProtectionStore, + registeredTools: ReadonlySet, + hmacSecret: string | undefined, traceExporter: OtelTraceExporter, ): Promise { const body = await readCappedBody(req); @@ -574,7 +676,16 @@ async function handleAcsRequest( let response: JsonRpcSuccess | JsonRpcFailure; try { - response = await dispatch(raw, bridge, mapping, onDecisionFailure, sessionContextStore); + response = await dispatch( + raw, + bridge, + mapping, + onDecisionFailure, + sessionContextStore, + replayProtectionStore, + registeredTools, + hmacSecret, + ); } catch (error) { // The outer net. Deliberately a bare JSON-RPC error, not an ACS `deny`: // this route is `dispatch` rethrowing entirely past denyOnInvalidEnvelope @@ -587,6 +698,32 @@ async function handleAcsRequest( response = errorResponse(extractId(raw), EVALUATION_FAILED_CODE, `guardian failed to handle the request: ${message}`); } + // §10: sign every addressable response when a key is configured. This + // happens before the outbound schema check so the object checked is exactly + // the one sent. Responses to bodies with no usable session id cannot be + // session-keyed; a configured client rejects those as unauthenticated. + // Liveness is deliberately outside the authenticated decision channel. + // Signing an unsigned ping would let a caller mint a valid `allow` with a + // chosen session/id and substitute it for a governed step response whose + // identifiers collide. Keep both halves of ping signature-exempt. + if ( + hmacSecret && + extractMethod(raw) !== "system/ping" && + requestHasValidSignature(raw, Buffer.from(hmacSecret, "base64")) + ) { + const sessionId = extractSessionId(raw); + if (sessionId) { + const sessionKey = deriveSessionKey(Buffer.from(hmacSecret, "base64"), sessionId); + const sig = signResponseHmacSha256(response as Record, sessionKey); + const carrier = "result" in response ? response.result : response.error; + (carrier as Record).signature = { + algorithm: "HMAC-SHA256", + value: sig, + key_id: sessionId, + }; + } + } + const responseCheck = checkResponse(response); try { // Reported, not thrown, and the response is sent unchanged either way: @@ -718,6 +855,9 @@ async function dispatch( mapping: Mapping, onDecisionFailure: "proceed" | "deny" | undefined, sessionContextStore: SessionContextStore, + replayProtectionStore: ReplayProtectionStore, + registeredTools: ReadonlySet, + hmacSecret: string | undefined, ): Promise { const rpcId = extractId(raw); @@ -732,7 +872,7 @@ async function dispatch( // than a bare JSON-RPC error, since there is an identifiable step to // answer for. A handshake failure and an undispatched method are not // steps/*, so they always fall through to the JSON-RPC error unchanged. - envelope = validateEnvelope(raw); + envelope = validateEnvelopeShape(raw); } catch (error) { if (error instanceof EnvelopeValidationError) { if (isStepMethod(raw)) { @@ -747,9 +887,232 @@ async function dispatch( throw error; } + // Ping alone bypasses signature, replay, and timestamp checks. Its payload + // is still schema-validated before the liveness response is constructed. + if (envelope.method === "system/ping") { + try { + validateMethodPayload(envelope); + } catch (error) { + if (error instanceof EnvelopeValidationError) { + return errorResponse(rpcId, ENVELOPE_INVALID_CODE, error.message, { pointer: error.pointer }); + } + throw error; + } + const echo = (envelope.params.payload as Record).echo; + return successResponse(envelope.id, { + type: "final" as const, + acs_version: envelope.params.acs_version, + request_id: envelope.params.request_id, + decision: "allow" as const, + payload: { + status: "ok", + ...(echo === undefined ? {} : { echo }), + server_timestamp: new Date().toISOString(), + }, + } as unknown as AcsFinalResult); + } + + // §10: verify every non-ping request, including ClientHello, when the + // deployment configures a key. The session id needed for HKDF is already + // present in the handshake envelope; capability negotiation is not key + // exchange, because ACS v0.1 provisions HMAC material out of band. + if (hmacSecret) { + if (!envelope.params.signature) { + return errorResponse(rpcId, SIGNATURE_INVALID_CODE, "SIGNATURE_INVALID", { + reason: "signature required but not present", + }); + } + if (envelope.params.signature.algorithm !== "HMAC-SHA256") { + return errorResponse(rpcId, SIGNATURE_INVALID_CODE, "SIGNATURE_INVALID", { + reason: `unsupported algorithm: ${envelope.params.signature.algorithm}`, + }); + } + if (envelope.params.signature.key_id !== envelope.params.metadata.session_id) { + return errorResponse(rpcId, SIGNATURE_INVALID_CODE, "SIGNATURE_INVALID", { + reason: "signature key_id does not match metadata.session_id", + }); + } + const sessionKey = deriveSessionKey(Buffer.from(hmacSecret, "base64"), envelope.params.metadata.session_id); + const result = verifyHmacSha256( + raw as Record, + envelope.params.signature.value, + sessionKey, + ); + if (!result.valid) { + return errorResponse(rpcId, SIGNATURE_INVALID_CODE, "SIGNATURE_INVALID", { reason: result.reason }); + } + } + + // §10.3: reject timestamps outside the negotiated skew window. + const nowMs = Date.now(); + const requestTimestampMs = Date.parse(envelope.params.timestamp); + const drift = Math.abs(nowMs - requestTimestampMs); + if (drift > SKEW_WINDOW_MS) { + return errorResponse(rpcId, -32006, "TIMESTAMP_OUT_OF_WINDOW", { skew_window_ms: SKEW_WINDOW_MS }); + } + + // §10.3: reject duplicate request_id values within the session. This store + // is separate from SessionContext so audit-chain eviction cannot erase the + // replay memory and undispatched requests are remembered too. + const sessionId = envelope.params.metadata.session_id; + const replayStatus = replayProtectionStore.seenOrRemember( + sessionId, + envelope.params.request_id, + nowMs, + requestTimestampMs + SKEW_WINDOW_MS, + ); + if (replayStatus !== "fresh") { + return errorResponse( + rpcId, + -32005, + replayStatus === "replay" ? "REPLAY_DETECTED" : "REPLAY_PROTECTION_CAPACITY", + ); + } + + // Method-specific validation runs only after authentication and freshness + // checks. This prevents malformed non-ping traffic from bypassing the wire + // security gates and receiving a trusted decision. + try { + validateMethodPayload(envelope); + } catch (error) { + if (error instanceof EnvelopeValidationError) { + if (isStepMethod(raw)) { + const denial = denyOnInvalidEnvelope(raw, { reasonCode: "envelope_invalid", message: error.message }); + const decisionResponse = asDecisionResponse(rpcId, denial); + if (decisionResponse) return decisionResponse; + } + return errorResponse(rpcId, ENVELOPE_INVALID_CODE, error.message, { pointer: error.pointer }); + } + throw error; + } + if (envelope.method === HANDSHAKE_METHOD) { + const clientHello = envelope.params.payload as Record | undefined; + const clientVersions = Array.isArray(clientHello?.acs_versions_supported) + ? (clientHello.acs_versions_supported as string[]) + : []; + + if (!clientVersions.includes("0.1.0")) { + return errorResponse(rpcId, -32001, "UNSUPPORTED_VERSION", { + supported_versions: ["0.1.0"], + offered: clientVersions, + }); + } + + const clientTransports = Array.isArray(clientHello?.transports_supported) + ? (clientHello.transports_supported as string[]) + : []; + if (!clientTransports.includes("http")) { + return errorResponse(rpcId, -32003, "CAPABILITY_NOT_NEGOTIATED", { + capability: "transport", + selected_transport: "http", + offered: clientTransports, + }); + } + const env = onDecisionFailure === undefined ? undefined : { ACS_ON_DECISION_FAILURE: onDecisionFailure }; - return successResponse(envelope.id, buildServerHello(env)); + const clientProfiles = new Set((clientHello?.profiles_supported as string[] | undefined) ?? []); + const hello = buildServerHello(env, { + hmacEnabled: hmacSecret !== undefined, + profilesAccepted: hmacSecret !== undefined && clientProfiles.has("acs-core") ? ["acs-core"] : [], + }); + + const clientMethods = Array.isArray(clientHello?.methods_implemented) + ? new Set(clientHello.methods_implemented as string[]) + : undefined; + if (clientMethods !== undefined) { + hello.methods_evaluated = hello.methods_evaluated.filter(m => clientMethods.has(m)); + } + + return successResponse(envelope.id, hello); + } + + // Conformance taxonomy: the four non-tool hooks that ACS-Core requires a + // Guardian to accept. They remain absent from methods_evaluated because + // they are allow-by-default rather than policy-evaluated. Answering them + // (rather than returning method_not_dispatched) keeps the chain complete: a session whose + // start is invisible is a chain an incident review cannot use. + const ALLOW_BY_DEFAULT_METHODS = new Set([ + "steps/sessionStart", + "steps/sessionEnd", + "steps/userMessage", + "steps/agentResponse", + ]); + + if (ALLOW_BY_DEFAULT_METHODS.has(envelope.method)) { + const entry = appendContextEntry(sessionContextStore, envelope.params.metadata.session_id, { + method: envelope.method, + request_id: envelope.params.request_id, + tool_name: "", + }); + return successResponse(envelope.id, finalResult(envelope.params, { decision: "allow" }, entry.hash)); + } + + // Wrapped MCP: protocols/MCP/tools/call is collapsed into the pre-tool-call + // gate per conformance.md. The payload may carry tool.name in the ACS shape + // or params.name in the native MCP shape; either way, the tool name reaches + // the same policy evaluation as steps/toolCallRequest. + if (envelope.method === "protocols/MCP/tools/call") { + const payload = envelope.params.payload as Record; + const toolObj = payload.tool as Record | undefined; + const mcpParams = payload.params as Record | undefined; + const toolName = (toolObj?.name as string) ?? (mcpParams?.name as string) ?? "mcp_tool"; + + const entry = appendContextEntry(sessionContextStore, envelope.params.metadata.session_id, { + method: envelope.method, + request_id: envelope.params.request_id, + tool_name: toolName, + }); + + try { + assertToolRegistered(registeredTools, toolName); + const point = resolveInterventionPoint("steps/toolCallRequest", mapping); + const policyTargetArgument = resolvePolicyTargetArgument(mapping, point, toolName); + + // Normalize to the ACS tool-call-request shape the assembler expects. + // Native MCP carries params.name and raw arguments; ACS carries + // tool.name and {value}-wrapped arguments. + const sourceArgs = (toolObj + ? payload.arguments + : mcpParams?.arguments) as Record | undefined ?? {}; + const wrappedArgs: Record = {}; + for (const [key, val] of Object.entries(sourceArgs)) { + wrappedArgs[key] = (typeof val === "object" && val !== null && "value" in val) + ? (val as { value: unknown }) + : { value: val }; + } + const normalizedEnvelope = { + ...envelope, + params: { + ...envelope.params, + payload: { tool: { name: toolName }, arguments: wrappedArgs }, + }, + }; + + const snapshot = assemblePreToolCallSnapshot( + normalizedEnvelope as unknown as Parameters[0], + supplySourceLabels(sessionContextStore, envelope.params.metadata.session_id), + policyTargetArgument, + ); + const verdict = await bridge.evaluate(point, snapshot); + const decision = mapVerdict(verdict, mapping, point, policyTargetArgument); + persistIfcLabels(sessionContextStore, envelope.params.metadata.session_id, verdict.result_labels); + return successResponse(envelope.id, finalResult(envelope.params, decision, entry.hash)); + } catch (error) { + // Fail closed: a wrapped MCP call whose evaluation throws is denied, + // not allowed — matching evaluateStep's own contract. + const message = toRepoRelativeMessage(error); + const reasonCode = error instanceof ToolUnregisteredError ? "tool_unregistered" : "evaluation_failed"; + const denial = denyOnInvalidEnvelope(raw, { reasonCode, message }); + const decisionResponse = asDecisionResponse(rpcId, denial); + if (decisionResponse) { + if ("result" in decisionResponse) { + (decisionResponse.result as Record).chain_hash = entry.hash; + } + return decisionResponse; + } + return errorResponse(rpcId, EVALUATION_FAILED_CODE, `${reasonCode}: ${message}`); + } } // Two gated branches, one per ACS method this Guardian assembles a snapshot @@ -773,12 +1136,12 @@ async function dispatch( // shape. Anything neither predicate answers falls past both, to // METHOD_NOT_DISPATCHED_CODE below. if (isToolCallRequest(envelope)) { - return await evaluateStep(raw, envelope, assemblePreToolCallSnapshot, bridge, mapping, sessionContextStore); + return await evaluateStep(raw, envelope, assemblePreToolCallSnapshot, bridge, mapping, sessionContextStore, registeredTools); } // The result gate, beside the request gate rather than merged into it. if (isToolCallResult(envelope)) { - return await evaluateStep(raw, envelope, assemblePostToolCallSnapshot, bridge, mapping, sessionContextStore); + return await evaluateStep(raw, envelope, assemblePostToolCallSnapshot, bridge, mapping, sessionContextStore, registeredTools); } // A well-formed envelope (it passed validateEnvelope: the method matched @@ -857,23 +1220,21 @@ async function evaluateStep( bridge: PolicyBridge, mapping: Mapping, sessionContextStore: SessionContextStore, + registeredTools: ReadonlySet, ): Promise { + // Declared outside try so the catch can publish chain_hash on denials — + // the ContextEntry was already appended before the evaluation threw. + let chainHash: string | undefined; try { - // The chain entry is appended on arrival, before any verdict exists: a - // step that is later denied is still a step this session took, and a - // chain that recorded only permitted steps would be a chain an incident - // review cannot use. - appendContextEntry(sessionContextStore, envelope.params.metadata.session_id, { + const chainEntry = appendContextEntry(sessionContextStore, envelope.params.metadata.session_id, { method: envelope.method, request_id: envelope.params.request_id, tool_name: envelope.params.payload.tool.name, }); + chainHash = chainEntry.hash; + + assertToolRegistered(registeredTools, envelope.params.payload.tool.name); - // Resolved BEFORE the snapshot is assembled, where it used to be resolved - // after: the assembler needs to know which of this tool's arguments the - // policy target is read from, and mapVerdict needs the same answer to key - // any override it has to write back. One resolution, two readers -- asking - // twice would let them differ. const point = resolveInterventionPoint(envelope.method, mapping); const policyTargetArgument = resolvePolicyTargetArgument(mapping, point, envelope.params.payload.tool.name); @@ -885,31 +1246,22 @@ async function evaluateStep( const verdict = await bridge.evaluate(point, snapshot); const decision = mapVerdict(verdict, mapping, point, policyTargetArgument); - // `verdict.result_labels` is `undefined` when the IFC gate did not run - // at all and `[]` when it ran and propagated nothing; `persistIfcLabels` - // keeps those apart deliberately -- see its own doc comment. persistIfcLabels(sessionContextStore, envelope.params.metadata.session_id, verdict.result_labels); - return successResponse(envelope.id, finalResult(envelope.params, decision)); + return successResponse(envelope.id, finalResult(envelope.params, decision, chainEntry.hash)); } catch (error) { - // This produces a parseable JSON-RPC error rather than an HTML 500. - // denyOnInvalidEnvelope goes one step further: AGT's evaluation layer - // fails closed, and this catch is where that failure surfaces, so it is - // delivered as an honoured `deny` decision rather than a bare error, - // keeping it inside §6.4's honoured path. An AGT evaluation failure is a - // deny; a delivery failure is the host's negotiated posture, decided - // elsewhere and never merged with this. const message = toRepoRelativeMessage(error); - // The same best-effort id `dispatch` reads for its own failure paths, off - // the same raw body: a response the client cannot correlate is not a - // decision it can honour (see asDecisionResponse). const rpcId = extractId(raw); - const denial = denyOnInvalidEnvelope(raw, { reasonCode: "evaluation_failed", message }); + const reasonCode = error instanceof ToolUnregisteredError ? "tool_unregistered" : "evaluation_failed"; + const denial = denyOnInvalidEnvelope(raw, { reasonCode, message }); const decisionResponse = asDecisionResponse(rpcId, denial); if (decisionResponse) { + if (chainHash && "result" in decisionResponse) { + (decisionResponse.result as Record).chain_hash = chainHash; + } return decisionResponse; } - return errorResponse(rpcId, EVALUATION_FAILED_CODE, `evaluation failed: ${message}`); + return errorResponse(rpcId, EVALUATION_FAILED_CODE, `${reasonCode}: ${message}`); } } @@ -952,6 +1304,50 @@ function extractMethod(raw: unknown): string | null { return null; } +function extractSessionId(raw: unknown): string | null { + if (typeof raw === "object" && raw !== null && "params" in raw) { + const params = (raw as Record).params; + if (typeof params === "object" && params !== null && "metadata" in params) { + const metadata = (params as Record).metadata; + if (typeof metadata === "object" && metadata !== null && "session_id" in metadata) { + const sid = (metadata as Record).session_id; + if (typeof sid === "string") return sid; + } + } + } + return null; +} + +/** Whether an addressable response may be authenticated as belonging to an + * authenticated request. Rechecked at the signing boundary so a top-level + * validation failure cannot become a chosen-message signing oracle merely by + * carrying a victim session id. */ +function requestHasValidSignature(raw: unknown, masterSecret: Buffer): boolean { + if (typeof raw !== "object" || raw === null || !("params" in raw)) return false; + const params = (raw as Record).params; + if (typeof params !== "object" || params === null) return false; + const metadata = (params as Record).metadata; + const signature = (params as Record).signature; + if (typeof metadata !== "object" || metadata === null || typeof signature !== "object" || signature === null) { + return false; + } + const sessionId = (metadata as Record).session_id; + const fields = signature as Record; + if ( + typeof sessionId !== "string" || + fields.algorithm !== "HMAC-SHA256" || + fields.key_id !== sessionId || + typeof fields.value !== "string" + ) { + return false; + } + return verifyHmacSha256( + raw as Record, + fields.value, + deriveSessionKey(masterSecret, sessionId), + ).valid; +} + /** Whether the raw envelope names a `steps/*` method -- read before * validation, so it is a string test and nothing more. denyOnInvalidEnvelope * only turns a schema-validation failure into a deny decision for steps/*: diff --git a/reference-implementations/agt/packages/guardian/src/session-context.ts b/reference-implementations/agt/packages/guardian/src/session-context.ts index fe114b59..ee0f64e0 100644 --- a/reference-implementations/agt/packages/guardian/src/session-context.ts +++ b/reference-implementations/agt/packages/guardian/src/session-context.ts @@ -33,6 +33,7 @@ * chain's integrity. */ import { createHash } from "node:crypto"; +import { jcsCanonicalise } from "./verify-signature.ts"; /** * AGT's IFC tags: the type of the `ifc_labels` field below, and nothing wider. @@ -94,7 +95,7 @@ export type SessionStep = { method: string; request_id: string; tool_name: strin export type SessionContextEntry = { session_id: string; seq: number; - prev_hash: string; + prev_hash: string | null; hash: string; recorded_at: string; method: string; @@ -126,26 +127,25 @@ export type SessionState = { provenance: SessionProvenance; }; -/** The `prev_hash` of a session's first entry. */ -export const GENESIS_HASH = "0".repeat(64); +/** The `prev_hash` of a session's first entry, as context-entry.json requires. */ +export const GENESIS_HASH: null = null; /** - * The hash covering one entry: its predecessor, its position, and its own - * facts. Key order is fixed by the literal below rather than by - * `JSON.stringify` of a caller's object, so the digest cannot change because - * a field was declared somewhere else. + * The portable ACS v0.1 ContextEntry commitment. The store keeps a richer + * implementation-specific record, so this function first projects it onto + * the schema's canonical export shape. `entry_hash` and `previous_hash` are + * excluded from the JCS content; the predecessor is appended as raw digest + * bytes (or no bytes for genesis), exactly as context-entry.json specifies. */ export function hashEntry(input: Omit): string { - const canonical = JSON.stringify([ - input.prev_hash, - input.session_id, - input.seq, - input.recorded_at, - input.method, - input.request_id, - input.tool_name, - ]); - return createHash("sha256").update(canonical).digest("hex"); + const content = { + entry_id: `${input.session_id}:${input.seq}`, + step_id: input.request_id, + step_type: input.method, + timestamp: input.recorded_at, + }; + const previousBytes = input.prev_hash === null ? Buffer.alloc(0) : Buffer.from(input.prev_hash, "hex"); + return createHash("sha256").update(jcsCanonicalise(content), "utf8").update(previousBytes).digest("hex"); } /** A session with no history yet: no entries, no intent, and its labels at the lattice floor. */ diff --git a/reference-implementations/agt/packages/guardian/src/validate-envelope.ts b/reference-implementations/agt/packages/guardian/src/validate-envelope.ts index ca2e9c91..6fbf024e 100644 --- a/reference-implementations/agt/packages/guardian/src/validate-envelope.ts +++ b/reference-implementations/agt/packages/guardian/src/validate-envelope.ts @@ -1,10 +1,9 @@ /** * validateEnvelope checks an incoming ACS request envelope against the * v0.1.0 JSON Schemas at the root of this repository - * (specification/v0.1.0/request-envelope.json), and -- for - * `steps/toolCallRequest` and `steps/toolCallResult` -- additionally against - * that method's own hook payload schema (hooks/tool-call-request.json, - * hooks/tool-call-result.json). + * (specification/v0.1.0/request-envelope.json), and -- for each implemented + * ACS-Core native hook, `system/ping`, and `handshake/hello` -- additionally + * against that method's own payload schema. * * It returns an `AcsRequestEnvelope`: a request of ANY method, named the * same thing the host's own builder names it @@ -191,6 +190,17 @@ const TOOL_CALL_REQUEST_SCHEMA_ID = `${SCHEMA_BASE}hooks/tool-call-request.json` const TOOL_CALL_REQUEST_METHOD = "steps/toolCallRequest"; const TOOL_CALL_RESULT_SCHEMA_ID = `${SCHEMA_BASE}hooks/tool-call-result.json`; const TOOL_CALL_RESULT_METHOD = "steps/toolCallResult"; +const HANDSHAKE_METHOD = "handshake/hello"; +const CLIENT_HELLO_SCHEMA_ID = `${SCHEMA_BASE}handshake.json#/$defs/ClientHello`; +const PING_METHOD = "system/ping"; +const PING_SCHEMA_ID = `${SCHEMA_BASE}hooks/system-ping.json`; + +const HOOK_SCHEMA_BY_METHOD: Record = { + "steps/sessionStart": `${SCHEMA_BASE}hooks/session-start.json`, + "steps/sessionEnd": `${SCHEMA_BASE}hooks/session-end.json`, + "steps/userMessage": `${SCHEMA_BASE}hooks/user-message.json`, + "steps/agentResponse": `${SCHEMA_BASE}hooks/agent-response.json`, +}; function listSchemaFiles(dir: string): string[] { const out: string[] = []; @@ -295,20 +305,34 @@ function checkHookPayload(envelope: AcsRequestEnvelope, schemaId: string): void * condition under which it may be read are the same condition, written once * per method, in this file. */ -export function validateEnvelope(input: unknown): AcsRequestEnvelope { +export function validateEnvelopeShape(input: unknown): AcsRequestEnvelope { const validateTopLevel = getValidator(REQUEST_ENVELOPE_SCHEMA_ID); if (!validateTopLevel(input)) { throw toValidationError(validateTopLevel.errors, ""); } - const envelope = input as AcsRequestEnvelope; + return input as AcsRequestEnvelope; +} +/** Validates the method-specific payload after the wire-security gates have + * authenticated the general envelope. */ +export function validateMethodPayload(envelope: AcsRequestEnvelope): void { if (envelope.method === TOOL_CALL_REQUEST_METHOD) { checkHookPayload(envelope, TOOL_CALL_REQUEST_SCHEMA_ID); } else if (envelope.method === TOOL_CALL_RESULT_METHOD) { checkHookPayload(envelope, TOOL_CALL_RESULT_SCHEMA_ID); + } else if (envelope.method in HOOK_SCHEMA_BY_METHOD) { + checkHookPayload(envelope, HOOK_SCHEMA_BY_METHOD[envelope.method]!); + } else if (envelope.method === HANDSHAKE_METHOD) { + checkHookPayload(envelope, CLIENT_HELLO_SCHEMA_ID); + } else if (envelope.method === PING_METHOD) { + checkHookPayload(envelope, PING_SCHEMA_ID); } +} +export function validateEnvelope(input: unknown): AcsRequestEnvelope { + const envelope = validateEnvelopeShape(input); + validateMethodPayload(envelope); return envelope; } diff --git a/reference-implementations/agt/packages/guardian/src/verify-signature.ts b/reference-implementations/agt/packages/guardian/src/verify-signature.ts new file mode 100644 index 00000000..4f312f34 --- /dev/null +++ b/reference-implementations/agt/packages/guardian/src/verify-signature.ts @@ -0,0 +1,95 @@ +import { createHmac, hkdfSync, timingSafeEqual } from "node:crypto"; + +/** + * RFC 8785 JSON Canonicalization Scheme. Deterministic serialization: + * object keys sorted lexicographically at every depth, no whitespace, + * numbers via ES2015 Number::toString (which JSON.stringify already uses). + */ +export function jcsCanonicalise(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (typeof value === "boolean" || typeof value === "number") return JSON.stringify(value); + if (typeof value === "string") return JSON.stringify(value); + if (Array.isArray(value)) return "[" + value.map(jcsCanonicalise).join(",") + "]"; + const obj = value as Record; + const keys = Object.keys(obj).sort(); + return "{" + keys.map(k => JSON.stringify(k) + ":" + jcsCanonicalise(obj[k])).join(",") + "}"; +} + +/** + * §10: per-session HMAC key is HKDF-derived from deployment-provided IKM + * together with the session_id. + */ +export function deriveSessionKey(masterSecret: Buffer, sessionId: string): Buffer { + return Buffer.from(hkdfSync("sha256", masterSecret, Buffer.alloc(0), sessionId, 32)); +} + +/** + * Builds the canonical signing input for an ACS request envelope: the full + * JSON-RPC envelope with params.signature removed, then JCS-canonicalised. + */ +export function signingInput(envelope: Record): string { + const params = envelope.params as Record | undefined; + if (!params) return jcsCanonicalise(envelope); + const { signature: _, ...paramsWithoutSig } = params; + return jcsCanonicalise({ ...envelope, params: paramsWithoutSig }); +} + +/** + * Builds the canonical signing input for an ACS response envelope: the full + * JSON-RPC response with result.signature removed, then JCS-canonicalised. + */ +export function responseSigningInput(response: Record): string { + const result = response.result as Record | undefined; + if (result) { + const { signature: _, ...resultWithoutSig } = result; + return jcsCanonicalise({ ...response, result: resultWithoutSig }); + } + const error = response.error as Record | undefined; + if (error) { + const { signature: _, ...errorWithoutSig } = error; + return jcsCanonicalise({ ...response, error: errorWithoutSig }); + } + return jcsCanonicalise(response); +} + +export type SignatureVerificationResult = + | { valid: true } + | { valid: false; reason: string }; + +/** + * Verifies an HMAC-SHA256 signature on an ACS request envelope. + */ +export function verifyHmacSha256( + envelope: Record, + signatureValue: string, + secret: Buffer, +): SignatureVerificationResult { + const input = signingInput(envelope); + const computed = createHmac("sha256", secret).update(input).digest(); + + let expected: Buffer; + try { + expected = Buffer.from(signatureValue, "base64"); + } catch { + return { valid: false, reason: "signature value is not valid base64" }; + } + + if (computed.length !== expected.length) { + return { valid: false, reason: "signature length mismatch" }; + } + + if (!timingSafeEqual(computed, expected)) { + return { valid: false, reason: "signature mismatch" }; + } + + return { valid: true }; +} + +/** + * Computes an HMAC-SHA256 signature over a response envelope, returning + * the base64-encoded value. + */ +export function signResponseHmacSha256(response: Record, secret: Buffer): string { + const input = responseSigningInput(response); + return createHmac("sha256", secret).update(input).digest("base64"); +} diff --git a/reference-implementations/agt/packages/guardian/test/envelope-log-sink-wiring.test.ts b/reference-implementations/agt/packages/guardian/test/envelope-log-sink-wiring.test.ts index f21bbc41..3250e1d0 100644 --- a/reference-implementations/agt/packages/guardian/test/envelope-log-sink-wiring.test.ts +++ b/reference-implementations/agt/packages/guardian/test/envelope-log-sink-wiring.test.ts @@ -33,6 +33,14 @@ function toolCallEnvelope(command: string, overrides: { id?: number } = {}) { ); } +const CLIENT_HELLO = { + acs_versions_supported: ["0.1.0"], + methods_implemented: ["steps/toolCallRequest", "steps/toolCallResult"], + transports_supported: ["http"], + provenance_producer: "none", + profiles_supported: ["acs-core"], +}; + function readEntries(path: string): EnvelopeLogEntry[] { if (!existsSync(path)) { return []; @@ -94,7 +102,7 @@ describe("Guardian envelope log wiring", () => { it("records handshake/hello in both directions", async () => { await withGuardian(logIn, async (url, logPath) => { - await postRaw(url, JSON.stringify(makeEnvelope("handshake/hello", {}, { id: 42 }))); + await postRaw(url, JSON.stringify(makeEnvelope("handshake/hello", CLIENT_HELLO, { id: 42 }))); const entries = readEntries(logPath); expect(entries.map((e) => e.direction)).toEqual(["request", "response"]); diff --git a/reference-implementations/agt/packages/guardian/test/handshake.test.ts b/reference-implementations/agt/packages/guardian/test/handshake.test.ts index f20173e6..dc38b1c9 100644 --- a/reference-implementations/agt/packages/guardian/test/handshake.test.ts +++ b/reference-implementations/agt/packages/guardian/test/handshake.test.ts @@ -78,9 +78,25 @@ describe("buildServerHello — negotiated posture", () => { it("still declares every ServerHello field handshake.json requires", () => { const hello = buildServerHello({}); expect(Object.keys(hello).sort()).toEqual( - ["methods_evaluated", "negotiated_version", "on_decision_failure", "selected_transport", "timeout_config"], + [ + "methods_evaluated", + "negotiated_version", + "on_decision_failure", + "profiles_accepted", + "selected_transport", + "signature_algorithms_supported", + "skew_window_ms", + "timeout_config", + ], ); expect(hello.timeout_config.default_ms).toBe(5000); + expect(hello.skew_window_ms).toBe(300_000); + expect(hello.profiles_accepted).toEqual([]); + expect(hello.signature_algorithms_supported).toEqual([]); + }); + + it("advertises HMAC only when the deployment has key material", () => { + expect(buildServerHello({}, { hmacEnabled: true }).signature_algorithms_supported).toEqual(["HMAC-SHA256"]); }); }); diff --git a/reference-implementations/agt/packages/guardian/test/map-verdict.test.ts b/reference-implementations/agt/packages/guardian/test/map-verdict.test.ts index 5bdd52b0..307a2c00 100644 --- a/reference-implementations/agt/packages/guardian/test/map-verdict.test.ts +++ b/reference-implementations/agt/packages/guardian/test/map-verdict.test.ts @@ -60,8 +60,14 @@ describe("mapVerdict", () => { }); it("maps escalate to ask and transform to modify", () => { - expect(mapVerdict({ decision: "escalate", reason: "approval_required" }, m, "pre_tool_call", "command").decision) - .toBe("ask"); + const ask = mapVerdict({ decision: "escalate", reason: "approval_required" }, m, "pre_tool_call", "command"); + expect(ask.decision).toBe("ask"); + expect(ask.ask_details).toMatchObject({ + approver: { type: "human", id: "deployment-default" }, + timeout_seconds: 300, + timeout_disposition: "deny", + }); + expect(ask.ask_details?.question).toContain("human approval"); expect( mapVerdict( { decision: "transform", reason: "redacted", transform: { path: "$policy_target", value: "x" } }, diff --git a/reference-implementations/agt/packages/guardian/test/server.test.ts b/reference-implementations/agt/packages/guardian/test/server.test.ts index 60f0be35..e8a1de4c 100644 --- a/reference-implementations/agt/packages/guardian/test/server.test.ts +++ b/reference-implementations/agt/packages/guardian/test/server.test.ts @@ -3,6 +3,7 @@ import { copyFileSync, mkdirSync, readFileSync, readdirSync, rmSync, rmdirSync, import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { join } from "node:path"; +import { createHmac } from "node:crypto"; import Ajv2020 from "ajv/dist/2020.js"; import addFormats from "ajv-formats"; import type { AgtVerdict, PolicyBridge } from "agt-bridge"; @@ -11,8 +12,11 @@ import { createMemorySessionContextStore, loadSessionContext, supplySourceLabels, + deriveSessionKey, + signingInput, + responseSigningInput, } from "../src/index.ts"; -import { toRepoRelativeMessage } from "../src/server.ts"; +import { createReplayProtectionStore, toRepoRelativeMessage } from "../src/server.ts"; import { dispatchGuardianAnnotator } from "../src/deployment-bridge.ts"; import type { AcsFinalResult } from "../src/acs-result.ts"; @@ -56,6 +60,32 @@ function makeEnvelope( }; } +function clientHello(overrides: Record = {}): Record { + return { + acs_versions_supported: ["0.1.0"], + methods_implemented: ["steps/toolCallRequest", "steps/toolCallResult"], + transports_supported: ["http"], + provenance_producer: "none", + profiles_supported: ["acs-core"], + ...overrides, + }; +} + +function signRequest(envelope: Record, masterSecret: Buffer): Record { + const params = envelope.params as Record; + const metadata = params.metadata as Record; + const sessionId = metadata.session_id as string; + const key = deriveSessionKey(masterSecret, sessionId); + const value = createHmac("sha256", key).update(signingInput(envelope)).digest("base64"); + return { + ...envelope, + params: { + ...params, + signature: { algorithm: "HMAC-SHA256", value, key_id: sessionId }, + }, + }; +} + function toolCallEnvelope(command: string, overrides: { id?: number; requestId?: string; sessionId?: string } = {}) { return makeEnvelope( "steps/toolCallRequest", @@ -92,7 +122,7 @@ type JsonRpcResponse = { jsonrpc: "2.0"; id: string | number | null; result?: Record; - error?: { code: number; message: string; data?: unknown }; + error?: { code: number; message: string; data?: unknown; signature?: { algorithm: string; value: string; key_id: string } }; }; async function postAcs(url: string, body: unknown): Promise { @@ -184,7 +214,7 @@ afterAll(async () => { describe("startGuardian POST /acs", () => { it("handshake/hello returns a schema-valid ServerHello with timeout_config.default_ms and on_decision_failure: proceed", async () => { - const response = await postAcs(url, makeEnvelope("handshake/hello", {}, { id: 42 })); + const response = await postAcs(url, makeEnvelope("handshake/hello", clientHello(), { id: 42 })); expect(response.error).toBeUndefined(); expect(response.id).toBe(42); @@ -194,6 +224,7 @@ describe("startGuardian POST /acs", () => { validateServerHello(serverHello); expect((serverHello.timeout_config as { default_ms: number }).default_ms).toBeGreaterThan(0); expect(serverHello.on_decision_failure).toBe("proceed"); + expect(serverHello.profiles_accepted).toEqual([]); }); it("steps/toolCallRequest carrying rm -rf / denies, with non-empty reasoning and reason_codes, echoing request_id", async () => { @@ -219,10 +250,10 @@ describe("startGuardian POST /acs", () => { }); it("an unknown method returns a JSON-RPC error in the ACS-reserved -32000..-32099 range, not a decision", async () => { - // Well-formed envelope (matches the method-prefix pattern, real ACS hook - // name) but not one this Guardian dispatches -- distinct from a - // malformed envelope, which fails schema validation instead. - const response = await postAcs(url, makeEnvelope("steps/sessionStart", {}, { id: 7 })); + // Well-formed envelope (matches the method-prefix pattern) but not one + // this Guardian dispatches -- distinct from a malformed envelope, which + // fails schema validation instead. + const response = await postAcs(url, makeEnvelope("steps/unknownHook", {}, { id: 7 })); expect(response.result).toBeUndefined(); expect(response.id).toBe(7); @@ -231,6 +262,16 @@ describe("startGuardian POST /acs", () => { expect(response.error?.code).toBeLessThanOrEqual(-32000); }); + it("steps/sessionStart returns an allow decision with a chain_hash (D7)", async () => { + const requestId = crypto.randomUUID(); + const response = await postAcs(url, makeEnvelope("steps/sessionStart", {}, { requestId })); + + expect(response.error).toBeUndefined(); + expect(response.result?.decision).toBe("allow"); + expect(response.result?.request_id).toBe(requestId); + expect(typeof response.result?.chain_hash).toBe("string"); + }); + // A steps/* envelope that fails schema validation is a governance outcome, // not a transport accident -- denyOnInvalidEnvelope turns it into an // honoured deny decision instead of a bare JSON-RPC error. @@ -325,7 +366,7 @@ describe("startGuardian POST /acs -- denyOnInvalidEnvelope's boundary: what stay }); it("keeps an undispatched method a JSON-RPC error", async () => { - const response = await postAcs(url, makeEnvelope("steps/sessionStart", {}, { id: 7 })); + const response = await postAcs(url, makeEnvelope("steps/unknownHook", {}, { id: 7 })); expect(response.result).toBeUndefined(); expect(response.id).toBe(7); @@ -631,12 +672,18 @@ const THROWING_MESSAGE_ACCESSOR_SCRATCH_DIR = join(GUARDIAN_PKG, "tmp-throwing-m const UNDEFINED_MESSAGE_VALIDATE_ENVELOPE_SOURCE = ` export class EnvelopeValidationError extends Error {} -export function validateEnvelope(_input) { +export function validateEnvelopeShape(_input) { const error = new Error("this message is about to be erased"); error.message = undefined; throw error; } +export function validateMethodPayload(_envelope) {} + +export function validateEnvelope(input) { + return validateEnvelopeShape(input); +} + // Must track validate-envelope.ts's real export surface, not just the three // symbols this double overrides: server.ts imports BOTH method predicates from // the same module -- one per assembling gate -- so a double that omits either @@ -696,7 +743,7 @@ export function isToolCallResult(envelope) { const THROWING_MESSAGE_ACCESSOR_VALIDATE_ENVELOPE_SOURCE = ` export class EnvelopeValidationError extends Error {} -export function validateEnvelope(_input) { +export function validateEnvelopeShape(_input) { const error = new Error("real message, about to be hidden behind a throwing getter"); Object.defineProperty(error, "message", { get() { @@ -706,6 +753,12 @@ export function validateEnvelope(_input) { throw error; } +export function validateMethodPayload(_envelope) {} + +export function validateEnvelope(input) { + return validateEnvelopeShape(input); +} + // Must track validate-envelope.ts's real export surface, not just the three // symbols this double overrides: server.ts imports BOTH method predicates from // the same module -- one per assembling gate -- so a double that omits either @@ -1095,9 +1148,9 @@ describe("startGuardian POST /acs -- the intervention point comes from mapping.y }); // PR #10 review, Critical: Bun.serve with no `hostname` binds `*` -- every -// interface, dual-stack -- and this endpoint has no auth, no origin check and -// no request signing, so every host that could route to the port was a policy -// oracle and a policy sink. The observable that separates the two binds is +// interface, dual-stack -- and at the time this endpoint had no auth, origin +// check, or request signing, so every host that could route to the port was a +// policy oracle and a policy sink. The observable that separates the two binds is // reachability, so that is what is asserted, rather than the label Bun prints // for the socket (`server.hostname` reads "localhost" for a wildcard bind, // which is exactly the reading that hid this). @@ -1253,17 +1306,26 @@ describe("startGuardian POST /acs -- the result gate (steps/toolCallResult)", () expect(response.result?.reason_codes).toEqual(["destructive_shell_command_blocked"]); }); - // The fall-through, unchanged: a method this Guardian cannot assemble a - // snapshot for must not be answered with a snapshot it can. This is the - // assertion that fails if the two predicates are ever replaced by a method - // switch or by a branch keyed on the resolved point. - it("still answers steps/sessionStart with method-not-dispatched, from neither branch", async () => { - const response = await postAcs(url, makeEnvelope("steps/sessionStart", {}, { id: 7 })); + // The fall-through: a method this Guardian cannot assemble a snapshot for + // and is not in the allow-by-default set must not be answered with a + // snapshot it can. + it("still answers an unknown method with method-not-dispatched, from neither branch", async () => { + const response = await postAcs(url, makeEnvelope("steps/unknownHook", {}, { id: 7 })); expect(response.result).toBeUndefined(); expect(response.id).toBe(7); expect(response.error?.code).toBe(-32011); - expect(response.error?.data).toEqual({ method: "steps/sessionStart" }); + expect(response.error?.data).toEqual({ method: "steps/unknownHook" }); + }); + + // D7: steps/sessionStart is now dispatched as allow-by-default rather than + // method-not-dispatched, per the ACS-Core minimum hook taxonomy. + it("answers steps/sessionStart with an allow decision, not method-not-dispatched", async () => { + const response = await postAcs(url, makeEnvelope("steps/sessionStart", {}, { id: 7 })); + + expect(response.error).toBeUndefined(); + expect(response.id).toBe(7); + expect(response.result?.decision).toBe("allow"); }); }); @@ -1605,3 +1667,235 @@ describe("the drift demo's manifest, on a tool that sends no command", () => { } }); }); + +describe("ACS-Core wire security and negotiation", () => { + const masterSecret = Buffer.alloc(32, 0x5a); + const encodedSecret = masterSecret.toString("base64"); + + it("rejects unsigned and incorrectly signed requests when HMAC is configured", async () => { + const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: encodedSecret }); + try { + const unsigned = toolCallEnvelope("ls -la"); + expect((await postAcs(secured.url, unsigned)).error?.code).toBe(-32004); + + const wronglySigned = signRequest(toolCallEnvelope("ls -la"), Buffer.alloc(32, 0x33)); + expect((await postAcs(secured.url, wronglySigned)).error?.code).toBe(-32004); + + const unsignedHello = makeEnvelope("handshake/hello", clientHello()); + expect((await postAcs(secured.url, unsignedHello)).error?.code).toBe(-32004); + + const wrongKeyId = signRequest(toolCallEnvelope("ls -la"), masterSecret); + const wrongKeyParams = wrongKeyId.params as Record; + wrongKeyParams.signature = { + ...(wrongKeyParams.signature as Record), + key_id: crypto.randomUUID(), + }; + expect((await postAcs(secured.url, wrongKeyId)).error?.code).toBe(-32004); + } finally { + await secured.close(); + } + }); + + it("checks authentication before method-specific payload validation", async () => { + const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: encodedSecret }); + try { + const malformedUnsigned = makeEnvelope("steps/toolCallRequest", {}); + expect((await postAcs(secured.url, malformedUnsigned)).error?.code).toBe(-32004); + + const malformedSigned = signRequest(makeEnvelope("steps/toolCallRequest", {}), masterSecret); + expect((await postAcs(secured.url, malformedSigned)).result?.decision).toBe("deny"); + } finally { + await secured.close(); + } + }); + + it("rejects stale non-ping requests but leaves system/ping signature-exempt and out of the chain", async () => { + const store = createMemorySessionContextStore(); + const secured = await startGuardian({ + port: 0, + manifestPath: "policy/manifest.yaml", + hmacSecret: encodedSecret, + sessionContextStore: store, + }); + try { + const sessionId = crypto.randomUUID(); + const stale = makeEnvelope("steps/toolCallRequest", { + tool: { name: "Bash" }, + arguments: { command: { value: "ls" } }, + }, { sessionId }); + (stale.params as Record).timestamp = "2000-01-01T00:00:00.000Z"; + expect((await postAcs(secured.url, signRequest(stale, masterSecret))).error?.code).toBe(-32006); + + const future = makeEnvelope("steps/toolCallRequest", { + tool: { name: "Bash" }, + arguments: { command: { value: "ls" } }, + }, { sessionId }); + (future.params as Record).timestamp = new Date(Date.now() + 3_600_000).toISOString(); + expect((await postAcs(secured.url, signRequest(future, masterSecret))).error?.code).toBe(-32006); + + const ping = makeEnvelope("system/ping", { echo: "probe" }, { sessionId }); + (ping.params as Record).timestamp = "2000-01-01T00:00:00.000Z"; + const response = await postAcs(secured.url, ping); + expect(response.result?.decision).toBe("allow"); + expect((response.result?.payload as { echo?: string }).echo).toBe("probe"); + expect(response.result?.chain_hash).toBeUndefined(); + expect(response.result?.signature).toBeUndefined(); + expect(loadSessionContext(store, sessionId).entries).toEqual([]); + } finally { + await secured.close(); + } + }); + + it("refuses key material too short to support the documented HMAC baseline", async () => { + await expect( + startGuardian({ + port: 0, + manifestPath: "policy/manifest.yaml", + hmacSecret: Buffer.alloc(16).toString("base64"), + }), + ).rejects.toThrow(/at least 32 bytes/); + }); + + it("accepts a valid signature and signs the exact response it sends", async () => { + const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: encodedSecret }); + try { + const request = signRequest(toolCallEnvelope("ls -la"), masterSecret); + const response = await postAcs(secured.url, request); + expect(response.result?.decision).toBe("allow"); + + const signature = response.result?.signature as { algorithm: string; value: string; key_id: string }; + const sessionId = ((request.params as Record).metadata as Record).session_id as string; + expect(signature.algorithm).toBe("HMAC-SHA256"); + expect(signature.key_id).toBe(sessionId); + const key = deriveSessionKey(masterSecret, sessionId); + const expected = createHmac("sha256", key) + .update(responseSigningInput(response as unknown as Record)) + .digest("base64"); + expect(signature.value).toBe(expected); + } finally { + await secured.close(); + } + }); + + it("signs addressable JSON-RPC errors", async () => { + const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: encodedSecret }); + try { + const request = signRequest(makeEnvelope("steps/unknownHook", {}), masterSecret); + const response = await postAcs(secured.url, request); + expect(response.error?.code).toBe(-32011); + const signature = response.error?.signature; + expect(signature?.algorithm).toBe("HMAC-SHA256"); + const sessionId = ((request.params as Record).metadata as Record).session_id as string; + const expected = createHmac("sha256", deriveSessionKey(masterSecret, sessionId)) + .update(responseSigningInput(response as unknown as Record)) + .digest("base64"); + expect(signature?.value).toBe(expected); + } finally { + await secured.close(); + } + }); + + it("remembers request ids independently of dispatch and audit-chain eviction", async () => { + const store = createMemorySessionContextStore({ maxSessions: 1, onEvict: () => {} }); + const secured = await startGuardian({ + port: 0, + manifestPath: "policy/manifest.yaml", + sessionContextStore: store, + }); + try { + const sessionA = crypto.randomUUID(); + const replayedId = crypto.randomUUID(); + const unknown = makeEnvelope("steps/unknownHook", {}, { sessionId: sessionA, requestId: replayedId }); + expect((await postAcs(secured.url, unknown)).error?.code).toBe(-32011); + expect((await postAcs(secured.url, unknown)).error?.code).toBe(-32005); + + await postAcs(secured.url, toolCallEnvelope("ls -la", { sessionId: crypto.randomUUID() })); + const recorded = toolCallEnvelope("ls -la", { sessionId: sessionA, requestId: crypto.randomUUID() }); + await postAcs(secured.url, recorded); + await postAcs(secured.url, toolCallEnvelope("ls -la", { sessionId: crypto.randomUUID() })); + expect((await postAcs(secured.url, recorded)).error?.code).toBe(-32005); + } finally { + await secured.close(); + } + }); + + it("negotiates profiles, advertised signature support, and validates ClientHello", async () => { + const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: encodedSecret }); + try { + const response = await postAcs( + secured.url, + signRequest( + makeEnvelope("handshake/hello", clientHello({ profiles_supported: ["acs-core", "acs-trace"] })), + masterSecret, + ), + ); + expect(response.result?.profiles_accepted).toEqual(["acs-core"]); + expect(response.result?.signature_algorithms_supported).toEqual(["HMAC-SHA256"]); + + const unsupportedEnvelope = makeEnvelope( + "handshake/hello", + clientHello({ acs_versions_supported: ["9.9.9"] }), + ); + (unsupportedEnvelope.params as Record).acs_version = "9.9.9"; + const unsupported = await postAcs(secured.url, signRequest(unsupportedEnvelope, masterSecret)); + expect(unsupported.error?.code).toBe(-32001); + + const invalid = await postAcs( + secured.url, + signRequest(makeEnvelope("handshake/hello", { acs_versions_supported: ["0.1.0"] }), masterSecret), + ); + expect(invalid.error?.code).toBe(-32010); + expect((invalid.error?.data as { pointer?: string }).pointer).toContain("/params/payload/"); + + const noHttp = await postAcs( + secured.url, + signRequest(makeEnvelope("handshake/hello", clientHello({ transports_supported: ["stdio"] })), masterSecret), + ); + expect(noHttp.error?.code).toBe(-32003); + } finally { + await secured.close(); + } + }); + + it("returns tool_unregistered and governs wrapped MCP tools/call", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + const unknown = await postAcs( + guardian.url, + makeEnvelope("steps/toolCallRequest", { + tool: { name: "records.lookup" }, + arguments: { command: { value: "lookup" } }, + }), + ); + expect(unknown.result?.decision).toBe("deny"); + expect(unknown.result?.reason_codes).toEqual(["tool_unregistered"]); + + const wrapped = await postAcs( + guardian.url, + makeEnvelope("protocols/MCP/tools/call", { + params: { name: "Bash", arguments: { command: "ls -la" } }, + }), + ); + expect(wrapped.result?.decision).toBe("allow"); + expect(typeof wrapped.result?.chain_hash).toBe("string"); + } finally { + await guardian.close(); + } + }); +}); + +describe("replay protection expiry", () => { + it("retains future-dated request ids until their timestamp window closes", () => { + const replay = createReplayProtectionStore(); + expect(replay.seenOrRemember("session", "request", 0, 600_000)).toBe("fresh"); + expect(replay.seenOrRemember("session", "request", 300_001, 600_000)).toBe("replay"); + expect(replay.seenOrRemember("session", "request", 600_001, 900_001)).toBe("fresh"); + }); + + it("fails closed at capacity rather than evicting a still-valid replay record", () => { + const replay = createReplayProtectionStore(1, 2); + expect(replay.seenOrRemember("session-a", "request-1", 0, 600_000)).toBe("fresh"); + expect(replay.seenOrRemember("session-b", "request-2", 1, 600_001)).toBe("capacity"); + expect(replay.seenOrRemember("session-a", "request-1", 2, 600_000)).toBe("replay"); + }); +}); diff --git a/reference-implementations/agt/packages/guardian/test/session-context.test.ts b/reference-implementations/agt/packages/guardian/test/session-context.test.ts index 71ecb2fb..f06a600e 100644 --- a/reference-implementations/agt/packages/guardian/test/session-context.test.ts +++ b/reference-implementations/agt/packages/guardian/test/session-context.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; import { GENESIS_HASH, appendContextEntry, @@ -36,12 +37,30 @@ describe("SessionContext — the hash chain", () => { expect(loadSessionContext(store, "sess-a").entries).toHaveLength(1); }); - it("gives two entries at the same position different hashes when only tool_name differs", () => { + it("does not put implementation-only tool_name into the portable ACS commitment", () => { const store = createMemorySessionContextStore({ now: at("2026-08-14T00:00:00.000Z") }); const honest = appendContextEntry(store, "sess-a", step(1)); const other = createMemorySessionContextStore({ now: at("2026-08-14T00:00:00.000Z") }); const forged = appendContextEntry(other, "sess-a", { ...step(1), tool_name: "Write" }); - expect(forged.hash).not.toBe(honest.hash); + expect(forged.hash).toBe(honest.hash); + }); + + it("uses the normative JCS(content) plus raw previous-hash bytes algorithm", () => { + const store = createMemorySessionContextStore({ now: at("2026-08-14T00:00:00.000Z") }); + const first = appendContextEntry(store, "sess-a", step(1)); + const firstContent = + '{"entry_id":"sess-a:1","step_id":"req-1","step_type":"steps/toolCallRequest","timestamp":"2026-08-14T00:00:00.000Z"}'; + expect(first.prev_hash).toBeNull(); + expect(first.hash).toBe(createHash("sha256").update(firstContent, "utf8").digest("hex")); + + const second = appendContextEntry(store, "sess-a", step(2)); + const secondContent = + '{"entry_id":"sess-a:2","step_id":"req-2","step_type":"steps/toolCallRequest","timestamp":"2026-08-14T00:00:00.000Z"}'; + const expected = createHash("sha256") + .update(secondContent, "utf8") + .update(Buffer.from(first.hash, "hex")) + .digest("hex"); + expect(second.hash).toBe(expected); }); it("loads an unknown session as an empty chain rather than throwing", () => { @@ -109,7 +128,7 @@ describe("the JSONL appender the Inspector tails", () => { appendContextEntry(store, "sess-a", step(1)); appendContextEntry(store, "sess-a", step(2)); expect(lines).toHaveLength(2); - const parsed = lines.map((l) => JSON.parse(l) as { seq: number; prev_hash: string; hash: string }); + const parsed = lines.map((l) => JSON.parse(l) as { seq: number; prev_hash: string | null; hash: string }); expect(parsed.map((p) => p.seq)).toEqual([1, 2]); expect(parsed[1]!.prev_hash).toBe(parsed[0]!.hash); }); diff --git a/reference-implementations/agt/packages/guardian/test/validate-envelope.test.ts b/reference-implementations/agt/packages/guardian/test/validate-envelope.test.ts index 76c4acd6..f71deb82 100644 --- a/reference-implementations/agt/packages/guardian/test/validate-envelope.test.ts +++ b/reference-implementations/agt/packages/guardian/test/validate-envelope.test.ts @@ -133,7 +133,13 @@ describe("isToolCallRequest -- the method discrimination the narrow type depends metadata: { agent_id: "agent-1", session_id: "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed" }, // No tool, no arguments -- a handshake carries neither, which is // exactly why typing it as a tool call was wrong rather than untidy. - payload: {}, + payload: { + acs_versions_supported: ["0.1.0"], + methods_implemented: ["steps/toolCallRequest"], + transports_supported: ["http"], + provenance_producer: "none", + profiles_supported: ["acs-core"], + }, }, }; diff --git a/reference-implementations/agt/packages/host-adapter/src/failure-posture.ts b/reference-implementations/agt/packages/host-adapter/src/failure-posture.ts index 9de89c63..2e1c4104 100644 --- a/reference-implementations/agt/packages/host-adapter/src/failure-posture.ts +++ b/reference-implementations/agt/packages/host-adapter/src/failure-posture.ts @@ -157,7 +157,23 @@ const TRANSPORT_ERROR_CODES = new Set([ * preferred to guessing, because widening this to every error object would * also fail closed on an intermediary's error the Guardian never sent. */ -const REFUSAL_RPC_CODES = new Set([-32700, -32010, -32011, -32020]); +const REFUSAL_RPC_CODES = new Set([ + -32700, + // ACS §17.1's complete mandated-refusal registry. None of these is a + // transport accident or permission to apply the availability posture. + -32000, + -32001, + -32002, + -32003, + -32004, + -32005, + -32006, + -32007, + // Reference-implementation refusal codes. + -32010, + -32011, + -32020, +]); /** * Names what came back INSTEAD of a decision, for the audit entry. Total: an diff --git a/reference-implementations/agt/packages/host-adapter/src/govern-step.ts b/reference-implementations/agt/packages/host-adapter/src/govern-step.ts index 49862c86..a9177368 100644 --- a/reference-implementations/agt/packages/host-adapter/src/govern-step.ts +++ b/reference-implementations/agt/packages/host-adapter/src/govern-step.ts @@ -77,6 +77,7 @@ * different wire shape (see render-decision.ts). */ import { buildEnvelope, modificationDocumentOf, type AcsRequestEnvelope, type Hookmap } from "./build-envelope.ts"; +import { signEnvelope } from "./sign-envelope.ts"; import type { AuditSink } from "./audit-sink.ts"; import { applyFailurePosture, @@ -167,6 +168,9 @@ export type GovernStepInput = { * into a silent skip any more. */ scopedTool?: string; + /** Base64-encoded IKM for HMAC-SHA256 request signing (§10). When set, + * every envelope is signed with an HKDF-derived per-session key. */ + hmacSecret?: Buffer; }; /** @@ -433,6 +437,7 @@ export async function governStep({ sessionId, audit, scopedTool, + hmacSecret, }: GovernStepInput): Promise { // Every render below -- the arriving decision's and the posture's -- goes // through the hook's own decisions block, so a hook this hookmap does not map @@ -657,6 +662,9 @@ export async function governStep({ let envelope: AcsRequestEnvelope; try { envelope = buildEnvelope(hookEventName, payload, hookmap); + if (hmacSecret) { + envelope = signEnvelope(envelope, hmacSecret); + } } catch (failure) { return resolveByPosture(failure, "request", undefined); } diff --git a/reference-implementations/agt/packages/host-adapter/src/guardian-client.ts b/reference-implementations/agt/packages/host-adapter/src/guardian-client.ts index 2695d66e..575c060d 100644 --- a/reference-implementations/agt/packages/host-adapter/src/guardian-client.ts +++ b/reference-implementations/agt/packages/host-adapter/src/guardian-client.ts @@ -30,8 +30,8 @@ * a Guardian that echoes back the wrong id, not a lookup table for concurrent * requests. The ACS layer is a separate claim: `result.request_id` names the * step the decision is ABOUT, and matching transport ids say nothing about - * it -- a Guardian, or anything else that reaches an unauthenticated socket, - * can answer with the right `id` and another step's decision. Both are + * it -- a buggy or compromised Guardian can answer with the right `id` and + * another step's decision. Both are * checked, and both are checked in `post`, so no caller can hold a response * that was never correlated. * @@ -47,6 +47,7 @@ */ import type { AcsRequestEnvelope } from "./build-envelope.ts"; import type { AcsDecision } from "./decision-message.ts"; +import { verifyResponseSignature } from "./sign-envelope.ts"; /** * The minimal JSON-RPC 2.0 request shape this client sends -- the transport @@ -74,7 +75,12 @@ export type JsonRpcErrorResponse = { jsonrpc: "2.0"; id: string | number | null; result?: undefined; - error: { code: number; message: string; data?: unknown }; + error: { + code: number; + message: string; + data?: unknown; + signature?: { algorithm: string; value: string; key_id: string }; + }; }; export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse; @@ -105,6 +111,15 @@ export class GuardianResultCorrelationError extends Error { } } +/** Thrown when a configured signed session receives a result whose Guardian + * signature is missing or invalid. */ +export class GuardianSignatureError extends Error { + constructor(reason: string) { + super(`GuardianClient.post: Guardian response signature is invalid: ${reason}`); + this.name = "GuardianSignatureError"; + } +} + /** * Thrown when the negotiated timeout elapses with no response (§6.4). * @@ -190,8 +205,12 @@ export type GuardianClient = { post(envelope: JsonRpcRequest, options?: PostOptions): Promise; }; -/** Binds a Guardian's ACS endpoint and returns the client role for it. */ -export function createGuardianClient(url: string): GuardianClient { +/** Binds a Guardian's ACS endpoint and returns the client role for it. + * Supplying `hmacSecret` makes every non-ping result or error require a valid + * per-session HMAC. The protocol's liveness ping is exempt in both directions. */ +export type GuardianClientOptions = { hmacSecret?: Buffer }; + +export function createGuardianClient(url: string, clientOptions: GuardianClientOptions = {}): GuardianClient { async function post(envelope: JsonRpcRequest, options: PostOptions = {}): Promise { const { timeoutMs } = options; let response: JsonRpcResponse; @@ -221,6 +240,25 @@ export function createGuardianClient(url: string): GuardianClient { throw error; } + // `system/ping` is the protocol's signature-exempt liveness channel. + // Exempt only a request whose own method is ping; deciding from the + // response shape would let a substituted ping bypass verification. + if (clientOptions.hmacSecret && envelope.method !== "system/ping") { + const metadata = envelope.params.metadata as Record | undefined; + const sessionId = metadata?.session_id; + if (typeof sessionId !== "string") { + throw new GuardianSignatureError("request metadata carries no string session_id"); + } + const verified = verifyResponseSignature( + response as unknown as Record, + clientOptions.hmacSecret, + sessionId, + ); + if (!verified.valid) { + throw new GuardianSignatureError(verified.reason); + } + } + // An error the Guardian could not address, let through to the caller // rather than thrown away by the correlation check below. A JSON-RPC // response MUST carry `id: null` when the request's id could not be diff --git a/reference-implementations/agt/packages/host-adapter/src/handshake.ts b/reference-implementations/agt/packages/host-adapter/src/handshake.ts index e00769d6..5855fd8c 100644 --- a/reference-implementations/agt/packages/host-adapter/src/handshake.ts +++ b/reference-implementations/agt/packages/host-adapter/src/handshake.ts @@ -61,6 +61,7 @@ */ import { randomUUID } from "node:crypto"; import type { GuardianClient, JsonRpcRequest } from "./guardian-client.ts"; +import { signEnvelope } from "./sign-envelope.ts"; import { isSessionConfig, type SessionConfig, type SessionConfigStore } from "./session-config.ts"; const HANDSHAKE_METHOD = "handshake/hello"; @@ -76,6 +77,10 @@ export type HandshakeOptions = { agentId: string; /** This session's identity on the wire (ACS metadata.session_id, a uuid). */ sessionId: string; + /** Shared HMAC input keying material. ACS-Core signs the ClientHello too: + * the handshake negotiates capabilities, not the already-provisioned + * deployment secret. */ + hmacSecret?: Buffer; /** Bounds this handshake round trip (§6.4). There is no negotiated * `timeout_config` yet -- negotiating it is what this call is for -- so a * caller with nothing better should pass the ACS default @@ -200,8 +205,9 @@ export async function negotiateSessionConfig( store: SessionConfigStore, ): Promise { const requestId = randomUUID(); + const profilesSupported = options.hmacSecret ? ["acs-core"] : []; - const envelope: JsonRpcRequest = { + const unsignedEnvelope = { jsonrpc: "2.0", method: HANDSHAKE_METHOD, id: requestId, @@ -210,14 +216,13 @@ export async function negotiateSessionConfig( request_id: requestId, timestamp: new Date().toISOString(), metadata: { agent_id: options.agentId, session_id: options.sessionId }, - // ClientHello shape (handshake.json's $defs.ClientHello). Not - // schema-enforced on this method by the Guardian's own validateEnvelope - // today, but supplied honestly rather than left empty. + // ClientHello shape (handshake.json's $defs.ClientHello), validated by + // the Guardian before it negotiates the session. // - // "Honestly" is the whole point of the field: this adapter builds both - // step envelopes -- `buildEnvelope` has a request-shaped and a - // result-shaped hookmap entry, and the shipped Claude Code hookmap maps - // a hook to each -- so this list has to name both methods. + // "Honestly" is the whole point of the field: the shipped host + // integrations currently emit the two tool-call step envelopes. The + // Guardian can accept more methods, but this client must not claim it + // emits lifecycle or wrapped-MCP traffic until an integration does. // Under-declaring here is the direction that breaks the exchange rather // than merely misdescribing it: handshake.json defines the Guardian's // `methods_evaluated` as a "Subset of the client's methods_implemented", @@ -237,9 +242,13 @@ export async function negotiateSessionConfig( methods_implemented: ["steps/toolCallRequest", "steps/toolCallResult"], transports_supported: ["http"], provenance_producer: "none", + profiles_supported: profilesSupported, }, }, - }; + } satisfies JsonRpcRequest; + const envelope = options.hmacSecret + ? signEnvelope(unsignedEnvelope, options.hmacSecret) + : unsignedEnvelope; // `post`, not `requestDecision`: this method's result is a ServerHello, not // a decision, and a handshake failure is a different incident from a step @@ -271,6 +280,21 @@ export async function negotiateSessionConfig( ); } + if (options.hmacSecret) { + const coreProfiles = serverHello.profiles_accepted; + const signatureAlgorithms = serverHello.signature_algorithms_supported; + if (!Array.isArray(coreProfiles) || !coreProfiles.includes("acs-core")) { + throw new ServerHelloInvalidError( + "handshake: the Guardian did not accept the acs-core profile required by this signed client", + ); + } + if (!Array.isArray(signatureAlgorithms) || !signatureAlgorithms.includes("HMAC-SHA256")) { + throw new ServerHelloInvalidError( + "handshake: the Guardian did not advertise HMAC-SHA256 request verification for this acs-core session", + ); + } + } + const sessionConfig: SessionConfig = serverHello; try { store.set(sessionConfig); diff --git a/reference-implementations/agt/packages/host-adapter/src/index.ts b/reference-implementations/agt/packages/host-adapter/src/index.ts index 43c2ce40..46305ad9 100644 --- a/reference-implementations/agt/packages/host-adapter/src/index.ts +++ b/reference-implementations/agt/packages/host-adapter/src/index.ts @@ -20,6 +20,7 @@ export { createGuardianClient, GuardianResponseMismatchError, GuardianResultCorrelationError, + GuardianSignatureError, GuardianTimeoutError, type DecisionOrFailure, type GuardianClient, @@ -28,6 +29,7 @@ export { type JsonRpcSuccessResponse, type JsonRpcErrorResponse, type PostOptions, + type GuardianClientOptions, } from "./guardian-client.ts"; export { renderDecision, type HostOutput } from "./render-decision.ts"; // The ACS decision message every seam below speaks, and the one refinement of @@ -135,3 +137,9 @@ export { validateDecision, type ValidateDecisionContext } from "./validate-decis // underlying Set -- see reserved-segments.ts's own header for why the Set // itself stays module-private. export { isReservedSegment, findReservedKey } from "./reserved-segments.ts"; +export { + signEnvelope, + verifyResponseSignature, + type SignedAcsRequestEnvelope, + type ResponseSignatureVerificationResult, +} from "./sign-envelope.ts"; diff --git a/reference-implementations/agt/packages/host-adapter/src/sign-envelope.ts b/reference-implementations/agt/packages/host-adapter/src/sign-envelope.ts new file mode 100644 index 00000000..088da2e6 --- /dev/null +++ b/reference-implementations/agt/packages/host-adapter/src/sign-envelope.ts @@ -0,0 +1,110 @@ +/** + * Request signing for the host side: HKDF key derivation, JCS + * canonicalization, and HMAC-SHA256 — the mirror of the Guardian's + * verify-signature.ts. Duplicated here because the host adapter must not + * import from the Guardian package (test/invariants.test.ts enforces + * that boundary). + */ +import { createHmac, hkdfSync, timingSafeEqual } from "node:crypto"; +import type { AcsRequestEnvelope } from "./build-envelope.ts"; + +function jcsCanonicalise(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (typeof value === "boolean" || typeof value === "number") return JSON.stringify(value); + if (typeof value === "string") return JSON.stringify(value); + if (Array.isArray(value)) return "[" + value.map(jcsCanonicalise).join(",") + "]"; + const obj = value as Record; + const keys = Object.keys(obj).sort(); + return "{" + keys.map(k => JSON.stringify(k) + ":" + jcsCanonicalise(obj[k])).join(",") + "}"; +} + +function deriveSessionKey(masterSecret: Buffer, sessionId: string): Buffer { + return Buffer.from(hkdfSync("sha256", masterSecret, Buffer.alloc(0), sessionId, 32)); +} + +type SignableRequest = { + params: { metadata: { session_id: string } }; +}; + +export type SignedRequestEnvelope = T & { + params: T["params"] & { + signature: { algorithm: string; value: string; key_id: string }; + }; +}; + +export type SignedAcsRequestEnvelope = SignedRequestEnvelope; + +export type ResponseSignatureVerificationResult = + | { valid: true } + | { valid: false; reason: string }; + +/** + * Signs an ACS request envelope with HMAC-SHA256 using an HKDF-derived + * per-session key (§10). Returns a new envelope with params.signature + * populated. The original is not mutated. + */ +export function signEnvelope( + envelope: T, + masterSecret: Buffer, +): SignedRequestEnvelope { + const sessionId = envelope.params.metadata.session_id; + const sessionKey = deriveSessionKey(masterSecret, sessionId); + + const { signature: _, ...paramsWithoutSig } = envelope.params as T["params"] & { + signature?: unknown; + }; + const signingInput = jcsCanonicalise({ ...envelope, params: paramsWithoutSig }); + const value = createHmac("sha256", sessionKey).update(signingInput).digest("base64"); + + return { + ...envelope, + params: { + ...envelope.params, + signature: { algorithm: "HMAC-SHA256", value, key_id: sessionId }, + }, + }; +} + +/** Verifies the Guardian's HMAC over a JSON-RPC result or error response. */ +export function verifyResponseSignature( + response: Record, + masterSecret: Buffer, + sessionId: string, +): ResponseSignatureVerificationResult { + const result = response.result; + const error = response.error; + const carrier = typeof result === "object" && result !== null + ? result as Record + : typeof error === "object" && error !== null + ? error as Record + : undefined; + if (!carrier) { + return { valid: false, reason: "response carries no result or error to verify" }; + } + const signature = carrier.signature; + if (typeof signature !== "object" || signature === null) { + return { valid: false, reason: "response signature is missing" }; + } + const fields = signature as Record; + if (fields.algorithm !== "HMAC-SHA256") { + return { valid: false, reason: `unsupported response signature algorithm: ${String(fields.algorithm)}` }; + } + if (fields.key_id !== sessionId) { + return { valid: false, reason: "response signature key_id does not match the session" }; + } + if (typeof fields.value !== "string") { + return { valid: false, reason: "response signature value is not a string" }; + } + + const { signature: _, ...carrierWithoutSignature } = carrier; + const input = result === carrier + ? jcsCanonicalise({ ...response, result: carrierWithoutSignature }) + : jcsCanonicalise({ ...response, error: carrierWithoutSignature }); + const sessionKey = deriveSessionKey(masterSecret, sessionId); + const computed = createHmac("sha256", sessionKey).update(input).digest(); + const supplied = Buffer.from(fields.value, "base64"); + if (supplied.length !== computed.length || !timingSafeEqual(supplied, computed)) { + return { valid: false, reason: "response signature mismatch" }; + } + return { valid: true }; +} diff --git a/reference-implementations/agt/packages/host-adapter/test/client.test.ts b/reference-implementations/agt/packages/host-adapter/test/client.test.ts index 299219c3..66bcf08e 100644 --- a/reference-implementations/agt/packages/host-adapter/test/client.test.ts +++ b/reference-implementations/agt/packages/host-adapter/test/client.test.ts @@ -11,8 +11,10 @@ import { createGuardianClient, GuardianResponseMismatchError, GuardianResultCorrelationError, + GuardianSignatureError, GuardianTimeoutError, } from "../src/guardian-client.ts"; +import { signEnvelope } from "../src/sign-envelope.ts"; import { negotiateSessionConfig, resolveSessionConfig, @@ -205,6 +207,132 @@ describe("GuardianClient.post", () => { expect(response.error).toBeUndefined(); expect(response.result?.decision).toBe("allow"); }); + + it("verifies a signed Guardian response before returning its decision", async () => { + const secret = Buffer.alloc(32, 0x44); + const secured = await startGuardian({ + port: 0, + manifestPath: "policy/manifest.yaml", + hmacSecret: secret.toString("base64"), + }); + try { + const envelope = signEnvelope(buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap), secret); + const response = await createGuardianClient(secured.url, { hmacSecret: secret }).post(envelope); + expect(response.result?.decision).toBe("allow"); + } finally { + await secured.close(); + } + }); + + it("rejects a Guardian result that cannot be verified with this session's key", async () => { + const secret = Buffer.alloc(32, 0x55); + const secured = await startGuardian({ + port: 0, + manifestPath: "policy/manifest.yaml", + hmacSecret: secret.toString("base64"), + }); + try { + const envelope = signEnvelope(buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap), secret); + await expect( + createGuardianClient(secured.url, { hmacSecret: Buffer.alloc(32, 0x66) }).post(envelope), + ).rejects.toThrow(GuardianSignatureError); + } finally { + await secured.close(); + } + }); + + it("verifies a signed Guardian error before exposing its refusal code", async () => { + const secret = Buffer.alloc(32, 0x57); + const secured = await startGuardian({ + port: 0, + manifestPath: "policy/manifest.yaml", + hmacSecret: secret.toString("base64"), + }); + try { + const ordinary = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + const envelope = signEnvelope({ ...ordinary, method: "steps/unknownHook" }, secret); + const response = await createGuardianClient(secured.url, { hmacSecret: secret }).post(envelope); + expect(response.error?.code).toBe(-32011); + expect(response.error?.signature?.algorithm).toBe("HMAC-SHA256"); + } finally { + await secured.close(); + } + }); + + it("rejects an unsigned same-id error when response authentication is configured", async () => { + const secret = Buffer.alloc(32, 0x58); + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as { id: string | number }; + return Response.json({ jsonrpc: "2.0", id: body.id, error: { code: -32020, message: "forged refusal" } }); + }, + }); + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + await expect( + createGuardianClient(`http://localhost:${mock.port}/acs`, { hmacSecret: secret }).post(envelope), + ).rejects.toThrow(GuardianSignatureError); + } finally { + mock.stop(true); + } + }); + + it("rejects a colliding ping allow substituted for a signed tool decision", async () => { + const secret = Buffer.alloc(32, 0x59); + const secured = await startGuardian({ + port: 0, + manifestPath: "policy/manifest.yaml", + hmacSecret: secret.toString("base64"), + }); + let mock: ReturnType | undefined; + try { + const tool = signEnvelope(buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap), secret); + const { signature: _signature, ...unsignedParams } = tool.params; + const ping = { + ...tool, + method: "system/ping", + params: { ...unsignedParams, payload: { echo: "collision" } }, + }; + const pingResponse = await fetch(secured.url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(ping), + }).then((response) => response.json()); + + mock = Bun.serve({ + port: 0, + fetch: () => Response.json(pingResponse), + }); + const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`, { + hmacSecret: secret, + }).requestDecision(tool); + expect(outcome.decisionArrived).toBe(false); + expect(!outcome.decisionArrived && outcome.failure).toBeInstanceOf(GuardianSignatureError); + } finally { + mock?.stop(true); + await secured.close(); + } + }); + + it("accepts the intentionally unsigned no-echo ping only for an actual ping request", async () => { + const secret = Buffer.alloc(32, 0x5a); + const secured = await startGuardian({ + port: 0, + manifestPath: "policy/manifest.yaml", + hmacSecret: secret.toString("base64"), + }); + try { + const ordinary = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + const ping = { ...ordinary, method: "system/ping", params: { ...ordinary.params, payload: {} } }; + const response = await createGuardianClient(secured.url, { hmacSecret: secret }).post(ping); + expect(response.result?.decision).toBe("allow"); + expect(response.result?.signature).toBeUndefined(); + expect(response.result?.payload).not.toHaveProperty("echo"); + } finally { + await secured.close(); + } + }); }); // requestDecision owns every branch a shim would otherwise have to work out @@ -223,7 +351,7 @@ describe("GuardianClient.requestDecision", () => { it("answers 'no decision' with the Guardian's own JSON-RPC error as the failure, rather than throwing", async () => { // A method this Guardian dispatches no handler for. const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); - const unknownMethod = { ...envelope, method: "steps/sessionStart" }; + const unknownMethod = { ...envelope, method: "steps/unknownHook" }; const outcome = await createGuardianClient(guardian.url).requestDecision(unknownMethod); @@ -241,7 +369,7 @@ describe("GuardianClient.requestDecision", () => { // between this deployment's hookmap and its Guardian -- not a permission. it("hands the error through so a refusal is classified as one, not as a delivery failure", async () => { const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); - const unknownMethod = { ...envelope, method: "steps/sessionStart" }; + const unknownMethod = { ...envelope, method: "steps/unknownHook" }; const outcome = await createGuardianClient(guardian.url).requestDecision(unknownMethod); @@ -257,7 +385,7 @@ describe("GuardianClient.requestDecision", () => { // against the unfixed code, which is why the posture here is `proceed`. it("denies a refused step under a proceed posture, with the guardian up", async () => { const envelope = buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap); - const unknownMethod = { ...envelope, method: "steps/sessionStart" }; + const unknownMethod = { ...envelope, method: "steps/unknownHook" }; const outcome = await createGuardianClient(guardian.url).requestDecision(unknownMethod); expect(outcome.decisionArrived).toBe(false); @@ -492,6 +620,33 @@ describe("negotiateSessionConfig", () => { expect(stored?.on_decision_failure).toBe("proceed"); }); + it("signs ClientHello and verifies ServerHello in an ACS-Core session", async () => { + const secret = Buffer.alloc(32, 0x6a); + const secured = await startGuardian({ + port: 0, + manifestPath: "policy/manifest.yaml", + hmacSecret: secret.toString("base64"), + }); + try { + const store = createMemorySessionConfigStore(); + const sessionConfig = await negotiateSessionConfig( + { + guardian: createGuardianClient(secured.url, { hmacSecret: secret }), + agentId: "claude-code", + sessionId: crypto.randomUUID(), + hmacSecret: secret, + }, + store, + ); + + expect(sessionConfig.profiles_accepted).toEqual(["acs-core"]); + expect(sessionConfig.signature_algorithms_supported).toEqual(["HMAC-SHA256"]); + expect((sessionConfig.signature as { algorithm?: string }).algorithm).toBe("HMAC-SHA256"); + } finally { + await secured.close(); + } + }); + // The ServerHello becomes a SessionConfig by being checked, not by // `as unknown as SessionConfig` -- so a Guardian emitting the wrong shape // fails at the handshake instead of writing an unusable config that every diff --git a/reference-implementations/agt/packages/host-adapter/test/failure-posture.test.ts b/reference-implementations/agt/packages/host-adapter/test/failure-posture.test.ts index c9562692..8be6938b 100644 --- a/reference-implementations/agt/packages/host-adapter/test/failure-posture.test.ts +++ b/reference-implementations/agt/packages/host-adapter/test/failure-posture.test.ts @@ -418,11 +418,11 @@ describe("classifyDeliveryFailure — what came back instead of a decision", () expect(message).toContain("-32601"); }); - // The four codes this deployment's Guardian mints for a request it will not + // The codes ACS and this deployment reserve for a request the Guardian will not // decide (packages/guardian/src/server.ts). Asserted per code rather than // as a set, so a failure names which route regressed. client.test.ts drives // three of them against a real Guardian; this is the unit half. - it.each([-32700, -32010, -32011, -32020])("classifies the guardian's %i as a refusal, not a delivery failure", (code) => { + it.each([-32700, -32000, -32001, -32002, -32003, -32004, -32005, -32006, -32007, -32010, -32011, -32020])("classifies the guardian's %i as a refusal, not a delivery failure", (code) => { const { kind, message } = classifyDeliveryFailure({ code, message: "no" }); expect(kind).toBe("refused"); // The code an incident reviewer needs, in the field that reaches the @@ -462,7 +462,7 @@ describe("applyFailurePosture — a guardian that was alive and refused the enve ).toBe("deny"); }); - it.each([-32700, -32010, -32011, -32020])("denies on %i, which the guardian mints for an envelope it will not decide", (code) => { + it.each([-32700, -32000, -32001, -32002, -32003, -32004, -32005, -32006, -32007, -32010, -32011, -32020])("denies on %i, which ACS reserves for an envelope the guardian will not decide", (code) => { const { sink } = recordingSink(); expect( applyFailurePosture({ failure: { code, message: "no" }, session: SESSION("proceed"), audit: sink, ...CALL }) diff --git a/reference-implementations/agt/packages/inspector/src/tail-session-context.ts b/reference-implementations/agt/packages/inspector/src/tail-session-context.ts index 00cc6066..4f6b514e 100644 --- a/reference-implementations/agt/packages/inspector/src/tail-session-context.ts +++ b/reference-implementations/agt/packages/inspector/src/tail-session-context.ts @@ -49,7 +49,7 @@ import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs"; export type SessionContextLogEntry = { session_id: string; seq: number; - prev_hash: string; + prev_hash: string | null; hash: string; recorded_at: string; method: string; @@ -92,7 +92,7 @@ function isSessionContextLogEntryShape(value: unknown): value is SessionContextL return ( typeof candidate.session_id === "string" && typeof candidate.seq === "number" && - typeof candidate.prev_hash === "string" && + (candidate.prev_hash === null || typeof candidate.prev_hash === "string") && typeof candidate.hash === "string" && typeof candidate.recorded_at === "string" && typeof candidate.method === "string" && diff --git a/reference-implementations/agt/test/dispositions.test.ts b/reference-implementations/agt/test/dispositions.test.ts index 168ff9e6..04e3dff3 100644 --- a/reference-implementations/agt/test/dispositions.test.ts +++ b/reference-implementations/agt/test/dispositions.test.ts @@ -57,7 +57,16 @@ describe("all five AGT verdicts arrive as ACS decisions, from the pinned bundle" it("escalate arrives as ask", async () => { const { result } = await decide({ ...PATTERNS, approval: { required: true, approvers: ["security-team"] } }, "ls -la"); - expect(result).toMatchObject({ decision: "ask", reason_codes: ["approval_required"] }); + expect(result).toMatchObject({ + decision: "ask", + reason_codes: ["approval_required"], + ask_details: { + approver: { type: "human", id: "deployment-default" }, + timeout_seconds: 300, + timeout_disposition: "deny", + }, + }); + expect((result?.ask_details as { question?: unknown }).question).toEqual(expect.any(String)); }); it("transform arrives as modify, carrying the rewritten argument", async () => { diff --git a/reference-implementations/agt/test/handshake-declares-what-it-evaluates.test.ts b/reference-implementations/agt/test/handshake-declares-what-it-evaluates.test.ts index fbeab0c9..60e0d6de 100644 --- a/reference-implementations/agt/test/handshake-declares-what-it-evaluates.test.ts +++ b/reference-implementations/agt/test/handshake-declares-what-it-evaluates.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { fileURLToPath } from "node:url"; -import { buildServerHello, loadMapping, startGuardian, type StartedGuardian } from "guardian"; +import { buildServerHello, startGuardian, type StartedGuardian } from "guardian"; import { createGuardianClient, createMemorySessionConfigStore, @@ -44,7 +44,6 @@ import { */ const MANIFEST = fileURLToPath(new URL("../policy/manifest.yaml", import.meta.url)); -const MAPPING = fileURLToPath(new URL("../mapping.yaml", import.meta.url)); const HOOKMAP = fileURLToPath(new URL("../hosts/claude-code/claude-code.hookmap.yaml", import.meta.url)); /** server.ts's own code for "well-formed envelope, no handler here". */ @@ -62,11 +61,11 @@ afterAll(async () => { /** * A payload that satisfies whatever schema `validateEnvelope` applies to this - * method, so the probe below measures dispatch rather than validation. Only the - * two dispatched methods have a hook payload schema today; every other method - * is checked against the generic request-envelope shape alone, which an empty - * object satisfies. If that stops being true the probe says so by name rather - * than quietly reclassifying the method (see `dispatches`). + * method, so the probe below measures dispatch rather than validation. The + * native tool gates and four lifecycle hooks have method-specific schemas; + * wrapped MCP is validated by its generic envelope plus the Guardian's + * normalization path. If that changes, the probe says so by name rather than + * quietly reclassifying the method (see `dispatches`). */ function payloadFor(method: string): Record { if (method === "steps/toolCallRequest") { @@ -75,6 +74,18 @@ function payloadFor(method: string): Record { if (method === "steps/toolCallResult") { return { tool: { name: "Bash" }, exit_status: "success", outputs: [{ value: "nothing to redact" }] }; } + if (method === "protocols/MCP/tools/call") { + return { tool: { name: "mcp_probe" }, arguments: {} }; + } + if (method === "steps/sessionEnd") { + return { reason: "completed" }; + } + if (method === "steps/userMessage") { + return { content: [{ type: "text", value: "probe" }] }; + } + if (method === "steps/agentResponse") { + return { content: [{ type: "text", value: "probe" }] }; + } return {}; } @@ -125,48 +136,25 @@ async function dispatches(method: string): Promise { throw new Error(`unclassifiable answer for ${method}: ${JSON.stringify(answer)}`); } -describe("the ServerHello declares exactly the methods this Guardian dispatches", () => { - it("names every dispatched method and no undispatched one", async () => { +describe("the ServerHello declares exactly the methods this Guardian evaluates", () => { + it("does not misrepresent allow-by-default lifecycle hooks as policy-evaluated", async () => { const declared = buildServerHello({}).methods_evaluated; - - // The candidate set: every ACS method this deployment maps to an AGT - // intervention point, plus whatever the hello claims. The first half is - // sound as a universe because a dispatched method must resolve a point out - // of this same table -- `resolveInterventionPoint` throws otherwise and the - // step comes back as an honoured deny, never as enforcement. The second - // half is what catches a hello naming a method the mapping does not even - // have. - const mapping = loadMapping(MAPPING); - const candidates = new Set(declared); - for (const point of Object.values(mapping.intervention_points)) { - if (typeof point.acs_method === "string") { - candidates.add(point.acs_method); - } + expect([...declared].sort()).toEqual([ + "protocols/MCP/tools/call", + "steps/toolCallRequest", + "steps/toolCallResult", + ]); + for (const method of ["steps/sessionStart", "steps/sessionEnd", "steps/userMessage", "steps/agentResponse"]) { + expect(declared).not.toContain(method); + expect(await dispatches(method)).toBe(true); } - // The probe is only meaningful over a candidate set wider than the answer. - expect(candidates.size).toBeGreaterThan(declared.length); - - const dispatched: string[] = []; - for (const method of [...candidates].sort()) { - if (await dispatches(method)) { - dispatched.push(method); - } - } - - // Equality, in both directions, deliberately: `toEqual` on sorted arrays - // rather than a subset check, because over-declaring is the worse failure - // and a subset assertion would pass through it. - expect(dispatched).toEqual([...declared].sort()); }); - it("declares only methods the client said it implements, per handshake.json", async () => { + it("does not claim client methods the shipped integrations cannot emit", async () => { const implemented = await capturedClientHello(); - // handshake.json: methods_evaluated is a "Subset of the client's - // methods_implemented". Under-declaring on the client side is therefore not - // a cosmetic omission -- it makes the Guardian's own answer unstateable. - for (const method of buildServerHello({}).methods_evaluated) { - expect(implemented).toContain(method); - } + const hookmap = loadHookmap(HOOKMAP); + const emitted = [...new Set(Object.values(hookmap.hooks).map((entry) => entry.acs_method))]; + expect([...implemented].sort()).toEqual(emitted.sort()); }); it("implements every ACS method the shipped hookmap can fire", async () => { diff --git a/reference-implementations/agt/test/readme-captures.test.ts b/reference-implementations/agt/test/readme-captures.test.ts index 62eca0e5..de603f67 100644 --- a/reference-implementations/agt/test/readme-captures.test.ts +++ b/reference-implementations/agt/test/readme-captures.test.ts @@ -204,7 +204,11 @@ describe("README.md's quickstart captures are what the processes actually print" const documented = captureAfter(FENCES, "bun run guardian"); const observed = await bannerOf( GUARDIAN, - { ACS_GUARDIAN_PORT: "0", ACS_MANIFEST_PATH: MANIFEST }, + { + ACS_GUARDIAN_PORT: "0", + ACS_MANIFEST_PATH: MANIFEST, + ACS_HMAC_SECRET: Buffer.alloc(32, 7).toString("base64"), + }, documented.split("\n").length, ); expect(normalisePort(observed, documented)).toBe(documented); From a08542cdfaa2752a838fd8996587109b24199ce9 Mon Sep 17 00:00:00 2001 From: Jason Crittenden Date: Wed, 7 Oct 2026 02:16:23 +0000 Subject: [PATCH 02/12] fix(agt): address review on ACS-Core wire safeguards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reject non-finite timestamp drift so a leap-second timestamp (23:59:60, which date-time accepts and Date.parse returns NaN for) no longer passes the §10.3 skew window or lands in the replay store with a NaN expiry. - Commit each ContextEntry to its request content via request_hash, the lowercase-hex SHA-256 of the JCS-canonicalized params (context-entry.json, RECOMMENDED). - README: mark the handshake as not yet enforced (-32003) and the hook set as partial (subagentStart and agentTrigger return -32011). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jason Crittenden --- reference-implementations/agt/README.md | 4 ++-- .../agt/packages/guardian/src/server.ts | 7 ++++++- .../packages/guardian/src/session-context-store.ts | 2 ++ .../agt/packages/guardian/src/session-context.ts | 9 ++++++++- .../agt/packages/guardian/test/server.test.ts | 8 ++++++++ .../packages/guardian/test/session-context.test.ts | 14 +++++++++++--- .../packages/inspector/src/tail-session-context.ts | 4 +++- .../inspector/test/tail-session-context.test.ts | 5 +++++ .../agt/test/session-context-roundtrip.test.ts | 2 ++ 9 files changed, 47 insertions(+), 8 deletions(-) diff --git a/reference-implementations/agt/README.md b/reference-implementations/agt/README.md index b777f26a..e93bb9d6 100644 --- a/reference-implementations/agt/README.md +++ b/reference-implementations/agt/README.md @@ -69,9 +69,9 @@ Each part has its own README that says how to start it. | ACS-Core requirement | Status in this tree | |---|---| -| Handshake, `handshake/hello` | Served. The Guardian validates ClientHello, rejects unsupported versions (`-32001`), narrows `methods_evaluated` to the intersection with the client's `methods_implemented`, and negotiates `profiles_accepted`. | +| Handshake, `handshake/hello` | Served, not yet enforced. The Guardian validates ClientHello, rejects unsupported versions (`-32001`), narrows `methods_evaluated` to the intersection with the client's `methods_implemented`, and negotiates `profiles_accepted`. Open Core gap: later steps are not checked against the negotiated session, so a step with no prior handshake, or a method the handshake did not negotiate, is evaluated rather than refused with `-32003`. | | Request and response envelopes, JSON-RPC 2.0 | Implemented. Inbound envelopes are validated against the schemas. Outbound responses are checked and the check is logged, never thrown. | -| Hook taxonomy, minimum of six hooks | All six ACS-Core hooks are dispatched: `steps/toolCallRequest` and `steps/toolCallResult` are policy-evaluated; `steps/sessionStart`, `steps/sessionEnd`, `steps/userMessage` and `steps/agentResponse` are accepted with allow-by-default and recorded in the session chain. | +| Hook taxonomy, minimum hook set | Partial. `steps/toolCallRequest` and `steps/toolCallResult` are policy-evaluated; `steps/sessionStart`, `steps/sessionEnd`, `steps/userMessage` and `steps/agentResponse` are accepted with allow-by-default and recorded in the session chain. Open Core gap: `steps/subagentStart` (required for subagent-capable clients) and `steps/agentTrigger` (the alternative to `userMessage`) return `-32011`. | | Five dispositions | `allow`, `deny`, `modify`, and `ask` are emitted as schema-valid decisions. Both hosts validate and safely render `defer`, but no pinned AGT verdict maps to it. | | SessionContext with published `chain_hash` | The Guardian keeps a per-session chain using ACS v0.1's JCS-content-plus-raw-predecessor algorithm, writes it to a log, and publishes `chain_hash` on every response for a chain-recorded step, including denial responses when evaluation throws. | | Replay protection | Implemented. Duplicate `request_id` values within a session are rejected (`-32005`) from a replay store independent of the bounded audit chain. Timestamps outside the negotiated `skew_window_ms` (default 5 minutes) are rejected (`-32006`). Only the liveness ping is exempt. | diff --git a/reference-implementations/agt/packages/guardian/src/server.ts b/reference-implementations/agt/packages/guardian/src/server.ts index 9ddc1c21..f3b58bad 100644 --- a/reference-implementations/agt/packages/guardian/src/server.ts +++ b/reference-implementations/agt/packages/guardian/src/server.ts @@ -98,6 +98,7 @@ import { createOtelTraceExporter, NULL_OTEL_TRACE_EXPORTER, type OtelTraceExport import { appendContextEntry, createMemorySessionContextStore, + requestHash, type IfcLabels, type SessionContextStore, } from "./session-context-store.ts"; @@ -947,7 +948,8 @@ async function dispatch( const nowMs = Date.now(); const requestTimestampMs = Date.parse(envelope.params.timestamp); const drift = Math.abs(nowMs - requestTimestampMs); - if (drift > SKEW_WINDOW_MS) { + // Date.parse returns NaN for leap seconds (23:59:60), which date-time accepts. + if (!Number.isFinite(drift) || drift > SKEW_WINDOW_MS) { return errorResponse(rpcId, -32006, "TIMESTAMP_OUT_OF_WINDOW", { skew_window_ms: SKEW_WINDOW_MS }); } @@ -1043,6 +1045,7 @@ async function dispatch( const entry = appendContextEntry(sessionContextStore, envelope.params.metadata.session_id, { method: envelope.method, request_id: envelope.params.request_id, + request_hash: requestHash(envelope.params), tool_name: "", }); return successResponse(envelope.id, finalResult(envelope.params, { decision: "allow" }, entry.hash)); @@ -1061,6 +1064,7 @@ async function dispatch( const entry = appendContextEntry(sessionContextStore, envelope.params.metadata.session_id, { method: envelope.method, request_id: envelope.params.request_id, + request_hash: requestHash(envelope.params), tool_name: toolName, }); @@ -1229,6 +1233,7 @@ async function evaluateStep( const chainEntry = appendContextEntry(sessionContextStore, envelope.params.metadata.session_id, { method: envelope.method, request_id: envelope.params.request_id, + request_hash: requestHash(envelope.params), tool_name: envelope.params.payload.tool.name, }); chainHash = chainEntry.hash; diff --git a/reference-implementations/agt/packages/guardian/src/session-context-store.ts b/reference-implementations/agt/packages/guardian/src/session-context-store.ts index 11e0ca9a..7ad2e1da 100644 --- a/reference-implementations/agt/packages/guardian/src/session-context-store.ts +++ b/reference-implementations/agt/packages/guardian/src/session-context-store.ts @@ -88,6 +88,7 @@ import { export { GENESIS_HASH, + requestHash, type IfcLabels, type Intent, type SessionContext, @@ -237,6 +238,7 @@ export function createMemorySessionContextStore( method: step.method, request_id: step.request_id, tool_name: step.tool_name, + request_hash: step.request_hash, }; const entry: SessionContextEntry = { ...withoutHash, hash: hashEntry(withoutHash) }; // Pushed onto the array this session already owns, rather than rebuilt diff --git a/reference-implementations/agt/packages/guardian/src/session-context.ts b/reference-implementations/agt/packages/guardian/src/session-context.ts index ee0f64e0..a31ec4ea 100644 --- a/reference-implementations/agt/packages/guardian/src/session-context.ts +++ b/reference-implementations/agt/packages/guardian/src/session-context.ts @@ -90,7 +90,12 @@ export type SessionProvenance = { export type Intent = { readonly text: string; readonly recorded_at: string }; /** What a step contributes to the chain. Deliberately not the whole envelope. */ -export type SessionStep = { method: string; request_id: string; tool_name: string }; +export type SessionStep = { method: string; request_id: string; tool_name: string; request_hash: string }; + +/** context-entry.json `request_hash`: lowercase-hex SHA-256 of the JCS-canonicalized request params. */ +export function requestHash(params: unknown): string { + return createHash("sha256").update(jcsCanonicalise(params), "utf8").digest("hex"); +} export type SessionContextEntry = { session_id: string; @@ -101,6 +106,7 @@ export type SessionContextEntry = { method: string; request_id: string; tool_name: string; + request_hash: string; }; /** One session's hash chain, and nothing else the session happens to own. */ @@ -143,6 +149,7 @@ export function hashEntry(input: Omit): string { step_id: input.request_id, step_type: input.method, timestamp: input.recorded_at, + request_hash: input.request_hash, }; const previousBytes = input.prev_hash === null ? Buffer.alloc(0) : Buffer.from(input.prev_hash, "hex"); return createHash("sha256").update(jcsCanonicalise(content), "utf8").update(previousBytes).digest("hex"); diff --git a/reference-implementations/agt/packages/guardian/test/server.test.ts b/reference-implementations/agt/packages/guardian/test/server.test.ts index e8a1de4c..d5204d65 100644 --- a/reference-implementations/agt/packages/guardian/test/server.test.ts +++ b/reference-implementations/agt/packages/guardian/test/server.test.ts @@ -1733,6 +1733,14 @@ describe("ACS-Core wire security and negotiation", () => { (future.params as Record).timestamp = new Date(Date.now() + 3_600_000).toISOString(); expect((await postAcs(secured.url, signRequest(future, masterSecret))).error?.code).toBe(-32006); + // date-time accepts a leap second, but Date.parse returns NaN for it. + const leap = makeEnvelope("steps/toolCallRequest", { + tool: { name: "Bash" }, + arguments: { command: { value: "ls" } }, + }, { sessionId }); + (leap.params as Record).timestamp = "2020-12-31T23:59:60Z"; + expect((await postAcs(secured.url, signRequest(leap, masterSecret))).error?.code).toBe(-32006); + const ping = makeEnvelope("system/ping", { echo: "probe" }, { sessionId }); (ping.params as Record).timestamp = "2000-01-01T00:00:00.000Z"; const response = await postAcs(secured.url, ping); diff --git a/reference-implementations/agt/packages/guardian/test/session-context.test.ts b/reference-implementations/agt/packages/guardian/test/session-context.test.ts index f06a600e..c551b09e 100644 --- a/reference-implementations/agt/packages/guardian/test/session-context.test.ts +++ b/reference-implementations/agt/packages/guardian/test/session-context.test.ts @@ -8,7 +8,7 @@ import { } from "../src/session-context-store.ts"; const at = (iso: string) => () => new Date(iso); -const step = (n: number) => ({ method: "steps/toolCallRequest", request_id: `req-${n}`, tool_name: "Bash" }); +const step = (n: number) => ({ method: "steps/toolCallRequest", request_id: `req-${n}`, tool_name: "Bash", request_hash: "a".repeat(64) }); describe("SessionContext — the hash chain", () => { it("starts a session at the genesis hash and seq 1", () => { @@ -45,17 +45,25 @@ describe("SessionContext — the hash chain", () => { expect(forged.hash).toBe(honest.hash); }); + it("commits to the request content through request_hash", () => { + const store = createMemorySessionContextStore({ now: at("2026-08-14T00:00:00.000Z") }); + const honest = appendContextEntry(store, "sess-a", step(1)); + const other = createMemorySessionContextStore({ now: at("2026-08-14T00:00:00.000Z") }); + const rewritten = appendContextEntry(other, "sess-a", { ...step(1), request_hash: "b".repeat(64) }); + expect(rewritten.hash).not.toBe(honest.hash); + }); + it("uses the normative JCS(content) plus raw previous-hash bytes algorithm", () => { const store = createMemorySessionContextStore({ now: at("2026-08-14T00:00:00.000Z") }); const first = appendContextEntry(store, "sess-a", step(1)); const firstContent = - '{"entry_id":"sess-a:1","step_id":"req-1","step_type":"steps/toolCallRequest","timestamp":"2026-08-14T00:00:00.000Z"}'; + `{"entry_id":"sess-a:1","request_hash":"${"a".repeat(64)}","step_id":"req-1","step_type":"steps/toolCallRequest","timestamp":"2026-08-14T00:00:00.000Z"}`; expect(first.prev_hash).toBeNull(); expect(first.hash).toBe(createHash("sha256").update(firstContent, "utf8").digest("hex")); const second = appendContextEntry(store, "sess-a", step(2)); const secondContent = - '{"entry_id":"sess-a:2","step_id":"req-2","step_type":"steps/toolCallRequest","timestamp":"2026-08-14T00:00:00.000Z"}'; + `{"entry_id":"sess-a:2","request_hash":"${"a".repeat(64)}","step_id":"req-2","step_type":"steps/toolCallRequest","timestamp":"2026-08-14T00:00:00.000Z"}`; const expected = createHash("sha256") .update(secondContent, "utf8") .update(Buffer.from(first.hash, "hex")) diff --git a/reference-implementations/agt/packages/inspector/src/tail-session-context.ts b/reference-implementations/agt/packages/inspector/src/tail-session-context.ts index 4f6b514e..94892388 100644 --- a/reference-implementations/agt/packages/inspector/src/tail-session-context.ts +++ b/reference-implementations/agt/packages/inspector/src/tail-session-context.ts @@ -55,6 +55,7 @@ export type SessionContextLogEntry = { method: string; request_id: string; tool_name: string; + request_hash: string; }; export type TailSessionContextLogOptions = { @@ -97,7 +98,8 @@ function isSessionContextLogEntryShape(value: unknown): value is SessionContextL typeof candidate.recorded_at === "string" && typeof candidate.method === "string" && typeof candidate.request_id === "string" && - typeof candidate.tool_name === "string" + typeof candidate.tool_name === "string" && + typeof candidate.request_hash === "string" ); } diff --git a/reference-implementations/agt/packages/inspector/test/tail-session-context.test.ts b/reference-implementations/agt/packages/inspector/test/tail-session-context.test.ts index 354d477d..37e3023f 100644 --- a/reference-implementations/agt/packages/inspector/test/tail-session-context.test.ts +++ b/reference-implementations/agt/packages/inspector/test/tail-session-context.test.ts @@ -30,6 +30,7 @@ function entryLine(seq: number, overrides: Partial = {}) method: "steps/toolCallRequest", request_id: `req-${seq}`, tool_name: "run_shell", + request_hash: "a".repeat(64), ...overrides, }; return `${JSON.stringify(entry)}\n`; @@ -48,6 +49,7 @@ function sessionEntry(overrides: Partial = {}): SessionC method: "steps/toolCallRequest", request_id: "req-1", tool_name: "run_shell", + request_hash: "a".repeat(64), ...overrides, }; } @@ -188,6 +190,7 @@ describe("renderSessionChain", () => { method: "steps/toolCallRequest", request_id: "req-1", tool_name: "run_shell", + request_hash: "a".repeat(64), }; const rendered = renderSessionChain([entry]); @@ -209,6 +212,7 @@ describe("renderSessionChain", () => { method: "steps/toolCallRequest", request_id: "req-1", tool_name: "run_shell", + request_hash: "a".repeat(64), }; const second: SessionContextLogEntry = { session_id: "sess-1", @@ -220,6 +224,7 @@ describe("renderSessionChain", () => { method: "steps/toolCallRequest", request_id: "req-2", tool_name: "run_shell", + request_hash: "a".repeat(64), }; const rendered = renderSessionChain([first, second]); diff --git a/reference-implementations/agt/test/session-context-roundtrip.test.ts b/reference-implementations/agt/test/session-context-roundtrip.test.ts index 4bbcb93f..049fba7d 100644 --- a/reference-implementations/agt/test/session-context-roundtrip.test.ts +++ b/reference-implementations/agt/test/session-context-roundtrip.test.ts @@ -166,6 +166,7 @@ describe("a write through the store and a read through the Inspector: every fiel method: "steps/toolCallRequest", request_id: "req-1", tool_name: "run_shell", + request_hash: "a".repeat(64), }); // toEqual, not toMatchObject: an extra field on either side is drift, @@ -183,6 +184,7 @@ describe("an evicted session that comes back reads as a broken chain, not as a f method: "steps/toolCallRequest", request_id: `req-${n}`, tool_name: "run_shell", + request_hash: "a".repeat(64), }); const store = createMemorySessionContextStore({ now: () => new Date("2026-08-10T12:00:00.000Z"), From 2b5f081449cc6a8a9e351a29f4fc4be99e478b53 Mon Sep 17 00:00:00 2001 From: Jason Crittenden Date: Wed, 7 Oct 2026 11:17:23 +0000 Subject: [PATCH 03/12] fix(agt): enforce the negotiated handshake on hook traffic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guardian: record each session's ClientHello methods_implemented and refuse with -32003 CAPABILITY_NOT_NEGOTIATED any step from a session that never sent handshake/hello, or for a method it did not declare (§4, §17.1). system/ping stays exempt. The check runs before the replay store remembers the request_id, so a retry after re-handshaking is not a false replay. The record is bounded (LRU, 4096 sessions). - Hosts: on -32003, governStep repeats the handshake once and retries with a fresh request, so a Guardian restart does not deny every step until the session ends. Only a decision replaces the refusal; a failed re-handshake or an undelivered retry keeps the -32003. - Conformance probes and tests now handshake before posting steps. - README: handshake row is implemented; replay protection names the remaining gap that the in-memory store does not survive a restart. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jason Crittenden --- reference-implementations/agt/README.md | 4 +- .../agt/hosts/claude-code/acs-hook.ts | 22 +-- .../agt/hosts/opencode/acs-plugin.ts | 21 ++- .../conformance/src/failure-domains.ts | 38 ++-- .../conformance/test/failure-domains.test.ts | 3 +- .../agt/packages/guardian/README.md | 2 +- .../agt/packages/guardian/src/server.ts | 40 +++- .../test/envelope-log-sink-wiring.test.ts | 7 +- .../agt/packages/guardian/test/server.test.ts | 69 ++++++- .../agt/packages/host-adapter/README.md | 2 +- .../packages/host-adapter/src/govern-step.ts | 65 ++++++- .../packages/host-adapter/test/client.test.ts | 9 + .../host-adapter/test/govern-step.test.ts | 173 +++++++++++++++++- .../agt/test/dispositions.test.ts | 3 +- .../test/envelope-log-sink-roundtrip.test.ts | 10 +- ...ndshake-declares-what-it-evaluates.test.ts | 3 +- .../agt/test/helpers/handshake.ts | 55 ++++++ 17 files changed, 464 insertions(+), 62 deletions(-) create mode 100644 reference-implementations/agt/test/helpers/handshake.ts diff --git a/reference-implementations/agt/README.md b/reference-implementations/agt/README.md index e93bb9d6..a01e22ad 100644 --- a/reference-implementations/agt/README.md +++ b/reference-implementations/agt/README.md @@ -69,12 +69,12 @@ Each part has its own README that says how to start it. | ACS-Core requirement | Status in this tree | |---|---| -| Handshake, `handshake/hello` | Served, not yet enforced. The Guardian validates ClientHello, rejects unsupported versions (`-32001`), narrows `methods_evaluated` to the intersection with the client's `methods_implemented`, and negotiates `profiles_accepted`. Open Core gap: later steps are not checked against the negotiated session, so a step with no prior handshake, or a method the handshake did not negotiate, is evaluated rather than refused with `-32003`. | +| Handshake, `handshake/hello` | Implemented. The Guardian validates ClientHello, rejects unsupported versions (`-32001`), narrows `methods_evaluated` to the intersection with the client's `methods_implemented`, and negotiates `profiles_accepted`. A step from a session with no handshake, or for a method its ClientHello did not list in `methods_implemented`, is refused with `-32003`. Both hosts answer `-32003` by repeating the handshake once and retrying the step. | | Request and response envelopes, JSON-RPC 2.0 | Implemented. Inbound envelopes are validated against the schemas. Outbound responses are checked and the check is logged, never thrown. | | Hook taxonomy, minimum hook set | Partial. `steps/toolCallRequest` and `steps/toolCallResult` are policy-evaluated; `steps/sessionStart`, `steps/sessionEnd`, `steps/userMessage` and `steps/agentResponse` are accepted with allow-by-default and recorded in the session chain. Open Core gap: `steps/subagentStart` (required for subagent-capable clients) and `steps/agentTrigger` (the alternative to `userMessage`) return `-32011`. | | Five dispositions | `allow`, `deny`, `modify`, and `ask` are emitted as schema-valid decisions. Both hosts validate and safely render `defer`, but no pinned AGT verdict maps to it. | | SessionContext with published `chain_hash` | The Guardian keeps a per-session chain using ACS v0.1's JCS-content-plus-raw-predecessor algorithm, writes it to a log, and publishes `chain_hash` on every response for a chain-recorded step, including denial responses when evaluation throws. | -| Replay protection | Implemented. Duplicate `request_id` values within a session are rejected (`-32005`) from a replay store independent of the bounded audit chain. Timestamps outside the negotiated `skew_window_ms` (default 5 minutes) are rejected (`-32006`). Only the liveness ping is exempt. | +| Replay protection | Implemented. Duplicate `request_id` values within a session are rejected (`-32005`) from a replay store independent of the bounded audit chain. Timestamps outside the negotiated `skew_window_ms` (default 5 minutes) are rejected (`-32006`). Only the liveness ping is exempt. Open Core gap: the replay store is held in memory, so a Guardian restart forgets it and accepts a replay of a request whose timestamp is still inside the window. | | Baseline HMAC-SHA256 signature | Implemented. The runnable Guardian requires `ACS_HMAC_SECRET`, derives per-session HMAC keys via HKDF (§10), verifies requests using JCS canonicalization, rejects unsigned requests with `-32004`, and signs every addressable non-ping result or error. Both shipped hosts sign requests—including ClientHello—and verify non-ping response signatures. `system/ping` is signature-exempt in both directions. | | Decision honoring and `on_decision_failure` | Implemented on both hosts. Every fail-open proceed is written to the audit log. | | Liveness, `system/ping` | Implemented. Always returns `allow` with an echo of the client's payload and a server timestamp. No chain entry, no signature required. | diff --git a/reference-implementations/agt/hosts/claude-code/acs-hook.ts b/reference-implementations/agt/hosts/claude-code/acs-hook.ts index fb860ca8..0ff22546 100644 --- a/reference-implementations/agt/hosts/claude-code/acs-hook.ts +++ b/reference-implementations/agt/hosts/claude-code/acs-hook.ts @@ -89,6 +89,7 @@ import { DEFAULT_TIMEOUT_MS, governStep, loadHookmap, + negotiateSessionConfig, resolveSessionConfig, toSessionUuid, withholdsAtResultGate, @@ -557,16 +558,15 @@ async function main(): Promise { // without a bound, a Guardian that accepts the connection and never answers // would hang this hook until Claude Code's own hook timeout kills the // process. - const session = await resolveSessionConfig( - { - guardian, - agentId: hookmap.host, - sessionId: toSessionUuid(sessionId), - timeoutMs: DEFAULT_TIMEOUT_MS, - hmacSecret, - }, - store, - ); + const handshake = { + guardian, + agentId: hookmap.host, + sessionId: toSessionUuid(sessionId), + timeoutMs: DEFAULT_TIMEOUT_MS, + hmacSecret, + }; + const session = await resolveSessionConfig(handshake, store); + const renegotiate = () => negotiateSessionConfig(handshake, store); // Step 5: resolve the attempt. One call, one answer, and this shim branches // on nothing: `governStep` builds the ACS request, asks the Guardian for a @@ -578,7 +578,7 @@ async function main(): Promise { // rules produce for it, is stated once inside `governStep`, host-agnostically, // so a second host inherits it by calling the function rather than // reimplementing it. - const governed = await governStep({ hookEventName, payload, hookmap, guardian, session, sessionId, audit, hmacSecret }); + const governed = await governStep({ hookEventName, payload, hookmap, guardian, session, sessionId, audit, hmacSecret, renegotiate }); process.stdout.write(JSON.stringify(asClaudeCodeOutput(governed.output, hookEventName))); } diff --git a/reference-implementations/agt/hosts/opencode/acs-plugin.ts b/reference-implementations/agt/hosts/opencode/acs-plugin.ts index 94a41298..f6935854 100644 --- a/reference-implementations/agt/hosts/opencode/acs-plugin.ts +++ b/reference-implementations/agt/hosts/opencode/acs-plugin.ts @@ -129,6 +129,7 @@ import { // and would still audit. governsTool, loadHookmap, + negotiateSessionConfig, resolveSessionConfig, toSessionUuid, // `AuditSink`/`GuardianClient`/`SessionConfigStore` -- the three @@ -1318,16 +1319,15 @@ async function runExchange( // mixed. const { payload, live } = assemble(input.tool, input.sessionID); - const session = await resolveSessionConfig( - { - guardian: deployment.guardian, - agentId: deployment.hookmap.host, - sessionId: toSessionUuid(input.sessionID), - timeoutMs: DEFAULT_TIMEOUT_MS, - hmacSecret: deployment.hmacSecret, - }, - deployment.store, - ); + const handshake = { + guardian: deployment.guardian, + agentId: deployment.hookmap.host, + sessionId: toSessionUuid(input.sessionID), + timeoutMs: DEFAULT_TIMEOUT_MS, + hmacSecret: deployment.hmacSecret, + }; + const session = await resolveSessionConfig(handshake, deployment.store); + const renegotiate = () => negotiateSessionConfig(handshake, deployment.store); const governed = await governStep({ hookEventName, @@ -1352,6 +1352,7 @@ async function runExchange( // here, so this one line is both of its call sites. scopedTool: input.tool, hmacSecret: deployment.hmacSecret, + renegotiate, }); applyOpenCodeOutput(governed.output, live); diff --git a/reference-implementations/agt/packages/conformance/src/failure-domains.ts b/reference-implementations/agt/packages/conformance/src/failure-domains.ts index b54e2009..bd5eabca 100644 --- a/reference-implementations/agt/packages/conformance/src/failure-domains.ts +++ b/reference-implementations/agt/packages/conformance/src/failure-domains.ts @@ -152,6 +152,26 @@ function envelopeMissingMetadata(method: string): Record { }; } +/** + * Posts `envelope` the way a conformant host does: after a `handshake/hello` + * that declares its method for its session (§4). Without one, a Guardian + * answers every probe with -32003 and none of them measures what it means to. + */ +async function postAfterHandshake(guardianUrl: string, envelope: Record): Promise { + const params = envelope.params as { metadata: { session_id: string } }; + const hello = wellFormedEnvelope("handshake/hello", { + acs_versions_supported: ["0.1.0"], + methods_implemented: [envelope.method], + transports_supported: ["http"], + provenance_producer: "none", + }); + (hello.params as { metadata: { session_id: string } }).metadata.session_id = params.metadata.session_id; + const post = (body: unknown) => + fetch(guardianUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + await post(hello); + return post(envelope); +} + /** * The domain (1) probe: a `steps/toolCallRequest` whose payload is empty, * which fails `hooks/tool-call-request.json` (it requires `tool` and @@ -174,11 +194,7 @@ function envelopeMissingMetadata(method: string): Record { * would be worse than a failed check. */ async function assertEvaluationFailsClosed(guardianUrl: string): Promise { - const response = await fetch(guardianUrl, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(wellFormedEnvelope("steps/toolCallRequest", {})), - }); + const response = await postAfterHandshake(guardianUrl, wellFormedEnvelope("steps/toolCallRequest", {})); const body = (await response.json()) as DenyProbeResponse; if (body.error !== undefined || body.result?.decision !== "deny") { @@ -200,11 +216,7 @@ async function assertEvaluationFailsClosed(guardianUrl: string): Promise { * steps/toolCallRequest (which fails closed into a deny). */ async function assertAllowByDefaultMappedHook(guardianUrl: string): Promise { - const response = await fetch(guardianUrl, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(wellFormedEnvelope(ALLOW_BY_DEFAULT_MAPPED_METHOD, {})), - }); + const response = await postAfterHandshake(guardianUrl, wellFormedEnvelope(ALLOW_BY_DEFAULT_MAPPED_METHOD, {})); const body = (await response.json()) as DispatchProbeResponse; const result = body.result as Record | undefined; @@ -226,11 +238,7 @@ async function assertAllowByDefaultMappedHook(guardianUrl: string): Promise { - const response = await fetch(guardianUrl, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(wellFormedEnvelope(UNMAPPED_METHOD, {})), - }); + const response = await postAfterHandshake(guardianUrl, wellFormedEnvelope(UNMAPPED_METHOD, {})); const body = (await response.json()) as DispatchProbeResponse; if (body.error?.code !== METHOD_NOT_DISPATCHED_CODE) { diff --git a/reference-implementations/agt/packages/conformance/test/failure-domains.test.ts b/reference-implementations/agt/packages/conformance/test/failure-domains.test.ts index da0f5e0a..e8f3ea30 100644 --- a/reference-implementations/agt/packages/conformance/test/failure-domains.test.ts +++ b/reference-implementations/agt/packages/conformance/test/failure-domains.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { loadMapping, startGuardian, type Mapping, type StartedGuardian } from "guardian"; import { checkDenyFailsClosed } from "../src/failure-domains.ts"; +import { fetchWithHandshake } from "../../../test/helpers/handshake.ts"; const mapping: Mapping = loadMapping("mapping.yaml"); @@ -39,7 +40,7 @@ describe("the two failure domains stay apart", () => { }); it("gets a decision rather than a JSON-RPC error when the envelope cannot be validated", async () => { - const response = await fetch(guardian.url, { + const response = await fetchWithHandshake(guardian.url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ diff --git a/reference-implementations/agt/packages/guardian/README.md b/reference-implementations/agt/packages/guardian/README.md index a7be9290..bf31468b 100644 --- a/reference-implementations/agt/packages/guardian/README.md +++ b/reference-implementations/agt/packages/guardian/README.md @@ -47,7 +47,7 @@ To see a decision without an agent client, pipe a hook payload into the Claude C 6. One entry is appended to the session's hash chain. Information-flow labels AGT emitted are stored, so the next step of the session sends them back as input. 7. Every addressable non-ping result or error is signed; ping stays signature-exempt in both directions. Every response is checked against the applicable response shape, recorded, and sent. The outbound schema result is diagnostic and never replaces the response. -`handshake/hello` validates the ClientHello, requires support for ACS `0.1.0`, intersects the client's methods with the methods this Guardian actually evaluates, and accepts the `acs-core` profile only when HMAC is configured. The evaluated set is `steps/toolCallRequest`, `steps/toolCallResult`, and `protocols/MCP/tools/call`; lifecycle hooks remain dispatchable audit events but are not advertised as policy-evaluated. Any other method is answered with a JSON-RPC error carrying `METHOD_NOT_DISPATCHED_CODE`. +`handshake/hello` validates the ClientHello, requires support for ACS `0.1.0`, intersects the client's methods with the methods this Guardian actually evaluates, and accepts the `acs-core` profile only when HMAC is configured. The evaluated set is `steps/toolCallRequest`, `steps/toolCallResult`, and `protocols/MCP/tools/call`; lifecycle hooks remain dispatchable audit events but are not advertised as policy-evaluated. The Guardian records each session's `methods_implemented` and refuses, with `-32003`, any step from a session that has not sent `handshake/hello` or for a method that session did not list. `system/ping` needs no handshake. Any other method is answered with a JSON-RPC error carrying `METHOD_NOT_DISPATCHED_CODE`. Every throw on the request path is caught. Nothing escapes the fetch handler, because an unhandled rejection would produce an HTML error page, the host's client would fail to parse it, and Claude Code would read the failed hook as "never fired" and proceed ungoverned. diff --git a/reference-implementations/agt/packages/guardian/src/server.ts b/reference-implementations/agt/packages/guardian/src/server.ts index f3b58bad..c8e91528 100644 --- a/reference-implementations/agt/packages/guardian/src/server.ts +++ b/reference-implementations/agt/packages/guardian/src/server.ts @@ -126,6 +126,22 @@ const ACS_PATH = "/acs"; * spec's own example and matches the skew_window_ms the ServerHello declares. */ const SKEW_WINDOW_MS = 300_000; +/** + * §4: each session's ClientHello `methods_implemented`, recorded at + * `handshake/hello`. A step from a session with no entry, or for a method the + * session did not declare, is refused with -32003. Bounded and least recently + * used first: an evicted session is refused the same way and its host + * re-handshakes, which is the -32003 recovery action in §17.1. + */ +export type NegotiatedSessions = Map>; +const MAX_NEGOTIATED_SESSIONS = 4096; + +function rememberNegotiated(sessions: NegotiatedSessions, sessionId: string, methods: ReadonlySet): void { + sessions.delete(sessionId); + sessions.set(sessionId, methods); + if (sessions.size > MAX_NEGOTIATED_SESSIONS) sessions.delete(sessions.keys().next().value!); +} + type ReplayProtectionStore = { seenOrRemember( sessionId: string, @@ -510,6 +526,7 @@ export async function startGuardian({ const mapping = loadMapping(mappingPath ?? MAPPING_PATH); const registeredTools = loadRegisteredTools(manifestPath); const replayProtectionStore = createReplayProtectionStore(); + const negotiatedSessions: NegotiatedSessions = new Map(); const envelopeLog = envelopeLogPath ? createEnvelopeLogSink({ path: envelopeLogPath }) : NULL_ENVELOPE_LOG_SINK; // The session-context store is always the in-memory one, or the caller's // own override -- sessionContextLog never becomes an alternative backing @@ -560,6 +577,7 @@ export async function startGuardian({ onDecisionFailure, sessionContextStore, replayProtectionStore, + negotiatedSessions, registeredTools, hmacSecret, traceExporter, @@ -633,6 +651,7 @@ async function handleAcsRequest( onDecisionFailure: "proceed" | "deny" | undefined, sessionContextStore: SessionContextStore, replayProtectionStore: ReplayProtectionStore, + negotiatedSessions: NegotiatedSessions, registeredTools: ReadonlySet, hmacSecret: string | undefined, traceExporter: OtelTraceExporter, @@ -684,6 +703,7 @@ async function handleAcsRequest( onDecisionFailure, sessionContextStore, replayProtectionStore, + negotiatedSessions, registeredTools, hmacSecret, ); @@ -857,6 +877,7 @@ async function dispatch( onDecisionFailure: "proceed" | "deny" | undefined, sessionContextStore: SessionContextStore, replayProtectionStore: ReplayProtectionStore, + negotiatedSessions: NegotiatedSessions, registeredTools: ReadonlySet, hmacSecret: string | undefined, ): Promise { @@ -953,10 +974,26 @@ async function dispatch( return errorResponse(rpcId, -32006, "TIMESTAMP_OUT_OF_WINDOW", { skew_window_ms: SKEW_WINDOW_MS }); } + // §4 and §17.1: hook traffic only after a handshake, and only for methods + // the handshake declared. Checked before the replay store remembers the + // request_id, so a host that re-handshakes can retry without a false replay. + const sessionId = envelope.params.metadata.session_id; + if (envelope.method !== HANDSHAKE_METHOD) { + const negotiated = negotiatedSessions.get(sessionId); + if (!negotiated?.has(envelope.method)) { + return errorResponse(rpcId, -32003, "CAPABILITY_NOT_NEGOTIATED", { + method: envelope.method, + reason: negotiated ? "method not in this session's methods_implemented" : "no handshake/hello for this session", + }); + } + // Map.get does not touch insertion order, so refresh it here: eviction + // follows a session's last step, not its handshake. + rememberNegotiated(negotiatedSessions, sessionId, negotiated); + } + // §10.3: reject duplicate request_id values within the session. This store // is separate from SessionContext so audit-chain eviction cannot erase the // replay memory and undispatched requests are remembered too. - const sessionId = envelope.params.metadata.session_id; const replayStatus = replayProtectionStore.seenOrRemember( sessionId, envelope.params.request_id, @@ -1025,6 +1062,7 @@ async function dispatch( if (clientMethods !== undefined) { hello.methods_evaluated = hello.methods_evaluated.filter(m => clientMethods.has(m)); } + rememberNegotiated(negotiatedSessions, sessionId, clientMethods ?? new Set()); return successResponse(envelope.id, hello); } diff --git a/reference-implementations/agt/packages/guardian/test/envelope-log-sink-wiring.test.ts b/reference-implementations/agt/packages/guardian/test/envelope-log-sink-wiring.test.ts index 3250e1d0..6284283a 100644 --- a/reference-implementations/agt/packages/guardian/test/envelope-log-sink-wiring.test.ts +++ b/reference-implementations/agt/packages/guardian/test/envelope-log-sink-wiring.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { startGuardian } from "../src/index.ts"; import type { EnvelopeLogEntry } from "../src/envelope-log-sink.ts"; +import { fetchWithHandshake } from "../../../test/helpers/handshake.ts"; function makeEnvelope( method: string, @@ -48,7 +49,9 @@ function readEntries(path: string): EnvelopeLogEntry[] { return readFileSync(path, "utf8") .split("\n") .filter((line) => line.trim() !== "") - .map((line) => JSON.parse(line) as EnvelopeLogEntry); + .map((line) => JSON.parse(line) as EnvelopeLogEntry) + // fetchWithHandshake's own pair; these tests are about the step's. + .filter((entry) => entry.rpc_id !== "handshake"); } /** Non-recursive cleanup, as in envelope-log-sink.test.ts. */ @@ -77,7 +80,7 @@ async function withGuardian( } async function postRaw(url: string, body: string): Promise { - const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body }); + const res = await fetchWithHandshake(url, { method: "POST", headers: { "content-type": "application/json" }, body }); return await res.json(); } diff --git a/reference-implementations/agt/packages/guardian/test/server.test.ts b/reference-implementations/agt/packages/guardian/test/server.test.ts index d5204d65..6fa3c153 100644 --- a/reference-implementations/agt/packages/guardian/test/server.test.ts +++ b/reference-implementations/agt/packages/guardian/test/server.test.ts @@ -19,6 +19,7 @@ import { import { createReplayProtectionStore, toRepoRelativeMessage } from "../src/server.ts"; import { dispatchGuardianAnnotator } from "../src/deployment-bridge.ts"; import type { AcsFinalResult } from "../src/acs-result.ts"; +import { handshakeFor } from "../../../test/helpers/handshake.ts"; // The repository's own handshake schema, five directories up from this // file: this tree sits at reference-implementations/agt. @@ -71,10 +72,14 @@ function clientHello(overrides: Record = {}): Record(); + function signRequest(envelope: Record, masterSecret: Buffer): Record { const params = envelope.params as Record; const metadata = params.metadata as Record; const sessionId = metadata.session_id as string; + SIGNING_SECRETS.set(sessionId, masterSecret); const key = deriveSessionKey(masterSecret, sessionId); const value = createHmac("sha256", key).update(signingInput(envelope)).digest("base64"); return { @@ -126,6 +131,9 @@ type JsonRpcResponse = { }; async function postAcs(url: string, body: unknown): Promise { + const sessionId = (body as { params?: { metadata?: { session_id?: string } } } | null)?.params?.metadata?.session_id; + const secret = sessionId === undefined ? undefined : SIGNING_SECRETS.get(sessionId); + await handshakeFor(url, body, secret === undefined ? undefined : hello => signRequest(hello, secret)); const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, @@ -394,7 +402,10 @@ describe("startGuardian POST /acs -- denyOnInvalidEnvelope's boundary: what stay expect(response.result?.decision).toBe("deny"); const lines = readFileSync(logPath, "utf8").trim().split("\n"); - const entries = lines.map((line) => JSON.parse(line) as { direction: string; rpc_id: unknown }); + // postAcs handshakes first; this test is about the step's own pair. + const entries = lines + .map((line) => JSON.parse(line) as { direction: string; rpc_id: unknown }) + .filter((e) => e.rpc_id !== "handshake"); expect(entries.map((e) => e.direction)).toEqual(["request", "response"]); expect(entries.map((e) => e.rpc_id)).toEqual([21, 21]); } finally { @@ -933,7 +944,10 @@ describe("startGuardian POST /acs -- the outer net around dispatch", () => { await postAcs(url, toolCallEnvelope("ls -la", { id: 11 })); const lines = readFileSync(logPath, "utf8").trim().split("\n"); - const entries = lines.map((line) => JSON.parse(line) as { direction: string; rpc_id: unknown }); + // postAcs handshakes first; this test is about the step's own pair. + const entries = lines + .map((line) => JSON.parse(line) as { direction: string; rpc_id: unknown }) + .filter((e) => e.rpc_id !== "handshake"); expect(entries.map((e) => e.direction)).toEqual(["request", "response"]); // Paired by JSON-RPC id, which is what lets the Inspector show the // failure beside the request that caused it. @@ -1668,6 +1682,57 @@ describe("the drift demo's manifest, on a tool that sends no command", () => { }); }); +describe("hook traffic needs a handshake that declared the method (§4, §17.1)", () => { + // Raw fetch, not postAcs: postAcs handshakes first, and these tests are about what happens without one. + async function postRaw(url: string, body: unknown): Promise { + const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + return (await res.json()) as JsonRpcResponse; + } + + it("refuses a step from a session that never sent handshake/hello", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + const response = await postRaw(guardian.url, toolCallEnvelope("ls -la")); + expect(response.error?.code).toBe(-32003); + expect(response.error?.data).toMatchObject({ method: "steps/toolCallRequest" }); + } finally { + await guardian.close(); + } + }); + + it("refuses a method the handshake did not declare, and answers one it did", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + const sessionId = crypto.randomUUID(); + const hello = await postRaw( + guardian.url, + makeEnvelope("handshake/hello", clientHello({ methods_implemented: ["steps/toolCallResult"] }), { sessionId }), + ); + expect(hello.error).toBeUndefined(); + + const step = toolCallEnvelope("ls -la", { sessionId }); + expect((await postRaw(guardian.url, step)).error?.code).toBe(-32003); + + // Refused before the replay store remembers its request_id, so the same + // request is answered once the session re-handshakes. + await postRaw(guardian.url, makeEnvelope("handshake/hello", clientHello(), { sessionId })); + expect((await postRaw(guardian.url, step)).result?.decision).toBe("allow"); + } finally { + await guardian.close(); + } + }); + + it("answers system/ping without a handshake", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + const response = await postRaw(guardian.url, makeEnvelope("system/ping", { echo: "probe" })); + expect(response.result?.decision).toBe("allow"); + } finally { + await guardian.close(); + } + }); +}); + describe("ACS-Core wire security and negotiation", () => { const masterSecret = Buffer.alloc(32, 0x5a); const encodedSecret = masterSecret.toString("base64"); diff --git a/reference-implementations/agt/packages/host-adapter/README.md b/reference-implementations/agt/packages/host-adapter/README.md index ca66a38e..686f8c2b 100644 --- a/reference-implementations/agt/packages/host-adapter/README.md +++ b/reference-implementations/agt/packages/host-adapter/README.md @@ -9,7 +9,7 @@ It contains no policy-runtime vocabulary and names no host output field. Both bo A shim is thin because this package does the work. The two shipped shims are the worked examples. [`hosts/claude-code/acs-hook.ts`](../../hosts/claude-code/README.md) runs as a fresh subprocess per hook. [`hosts/opencode/acs-plugin.ts`](../../hosts/opencode/README.md) runs as one long-lived plugin object. Both call the same sequence, imported from the package root: 1. `loadHookmap` reads the host's hookmap and validates it. A hookmap whose `decisions` block cannot render both `allow` and `deny` is refused at load, so the failure posture always has an output it can express. -2. `resolveSessionConfig` returns the negotiated ServerHello for the session. The first hook of a session sends `handshake/hello` through `negotiateSessionConfig`. Later hooks read the stored answer. The Claude Code shim stores it in a file with `createFileSessionConfigStore`, because each hook is a new process. The OpenCode plugin uses `createMemorySessionConfigStore`. +2. `resolveSessionConfig` returns the negotiated ServerHello for the session. The first hook of a session sends `handshake/hello` through `negotiateSessionConfig`. Later hooks read the stored answer. When the Guardian refuses a step with `-32003` because it holds no handshake for the session, as after a Guardian restart, `governStep` repeats the handshake once and retries the step. The Claude Code shim stores it in a file with `createFileSessionConfigStore`, because each hook is a new process. The OpenCode plugin uses `createMemorySessionConfigStore`. 3. `governStep` builds the envelope, posts it through the client `createGuardianClient` returns, validates the decision, and resolves a missing decision through the negotiated posture. Every fail-open proceed is written to the audit sink before it is returned. 4. `renderDecision` maps the decision onto the host's output shape, as the hookmap's `decisions` block declares it. The shim applies that output: printed to stdout, or written into the live tool call object. diff --git a/reference-implementations/agt/packages/host-adapter/src/govern-step.ts b/reference-implementations/agt/packages/host-adapter/src/govern-step.ts index a9177368..8284ca20 100644 --- a/reference-implementations/agt/packages/host-adapter/src/govern-step.ts +++ b/reference-implementations/agt/packages/host-adapter/src/govern-step.ts @@ -88,7 +88,8 @@ import { import type { GuardianClient } from "./guardian-client.ts"; import type { AcsDecision, ValidatedAcsDecision } from "./decision-message.ts"; import { renderDecision, type HostOutput } from "./render-decision.ts"; -import type { ResolvedSessionConfig } from "./handshake.ts"; +import { SessionConfigNotStoredError, type ResolvedSessionConfig } from "./handshake.ts"; +import type { SessionConfig } from "./session-config.ts"; import { assertOutputIsReplaceable, withResultOutput, type HostOutputLocation } from "./result-output.ts"; import { validateDecision } from "./validate-decision.ts"; @@ -171,8 +172,18 @@ export type GovernStepInput = { /** Base64-encoded IKM for HMAC-SHA256 request signing (§10). When set, * every envelope is signed with an HKDF-derived per-session key. */ hmacSecret?: Buffer; + /** + * Repeats this session's handshake. When the Guardian answers a step with + * -32003 (no handshake on record, as after a Guardian restart), §17.1's + * recovery action is to re-handshake: `governStep` calls this once and + * retries the step with a fresh request under the config it returns. + * Without it, -32003 is a refusal. + */ + renegotiate?: () => Promise; }; +const CAPABILITY_NOT_NEGOTIATED = -32003; + /** * What a host is told once a step has been governed: the output to write, the * decision it renders, and what became of that decision. @@ -433,11 +444,12 @@ export async function governStep({ payload, hookmap, guardian, - session, + session: initialSession, sessionId, audit, scopedTool, hmacSecret, + renegotiate, }: GovernStepInput): Promise { // Every render below -- the arriving decision's and the posture's -- goes // through the hook's own decisions block, so a hook this hookmap does not map @@ -555,6 +567,8 @@ export async function governStep({ return { output: {}, decision: null, stage: "ungoverned" }; } + // `let`: a re-handshake after -32003 replaces it for the retried step. + let session = initialSession; const timeoutMs = session.config?.timeout_config.default_ms ?? DEFAULT_TIMEOUT_MS; // Which kind of gate this hook is, read off the entry's own shape and never @@ -648,7 +662,7 @@ export async function governStep({ return { output: render(decision), decision, stage }; } - const startedAt = performance.now(); + let startedAt = performance.now(); // Stage "request": nothing has been asked of anything yet. A payload the // hookmap's paths do not resolve against, or an entry naming no single payload @@ -659,12 +673,13 @@ export async function governStep({ // at the top of this function throws on it and audits nothing, because the // posture's answer to it could not be rendered either. Every other way // `buildEnvelope` can fail still lands here. + const buildRequest = (): AcsRequestEnvelope => { + const built = buildEnvelope(hookEventName, payload, hookmap); + return hmacSecret ? signEnvelope(built, hmacSecret) : built; + }; let envelope: AcsRequestEnvelope; try { - envelope = buildEnvelope(hookEventName, payload, hookmap); - if (hmacSecret) { - envelope = signEnvelope(envelope, hmacSecret); - } + envelope = buildRequest(); } catch (failure) { return resolveByPosture(failure, "request", undefined); } @@ -722,6 +737,40 @@ export async function governStep({ // would still be a request that produced no decision if it ever did. let decision: ValidatedAcsDecision; try { + let answer = await guardian.requestDecision(envelope, { timeoutMs }); + if ( + answer.decisionArrived === false && + renegotiate !== undefined && + (answer.failure as { code?: unknown } | null)?.code === CAPABILITY_NOT_NEGOTIATED + ) { + // Only a decision replaces the refusal. A failed re-handshake, or a + // retry that gets no decision, leaves the -32003 standing, so this path + // cannot turn a refusal into a posture fail-open. + // A config negotiated but not persisted still governs this step, with + // the persistence failure kept for the audit, as resolveSessionConfig does. + const { config: renegotiated, failure: renegotiationFailure } = await renegotiate().then( + config => ({ config, failure: undefined as unknown }), + (error: unknown) => ({ + config: error instanceof SessionConfigNotStoredError ? error.config : undefined, + failure: error, + }), + ); + if (renegotiated !== undefined) { + // The retry runs under what the Guardian just negotiated, and its + // decision's expiry is measured from the retry, not the refused request. + const retryTimeoutMs = renegotiated.timeout_config.default_ms; + const retryStartedAt = performance.now(); + const retryEnvelope = buildRequest(); + const retried = await guardian.requestDecision(retryEnvelope, { timeoutMs: retryTimeoutMs }); + if (retried.decisionArrived) { + session = { config: renegotiated, failure: renegotiationFailure }; + startedAt = retryStartedAt; + envelope = retryEnvelope; + answer = retried; + } + } + } + // The same document that just went out on the wire -- so a `modify` // decision's §6.3 pointers apply against exactly the structure the Guardian // saw and addressed. At a gate that decides whether a step runs that is the @@ -731,8 +780,6 @@ export async function governStep({ // and there are no arguments at that step). `modificationDocumentOf` reads which // from the envelope it just built. const modificationDocument = modificationDocumentOf(envelope); - - const answer = await guardian.requestDecision(envelope, { timeoutMs }); const elapsedMs = performance.now() - startedAt; if (answer.decisionArrived === false) { diff --git a/reference-implementations/agt/packages/host-adapter/test/client.test.ts b/reference-implementations/agt/packages/host-adapter/test/client.test.ts index 66bcf08e..c8b580ad 100644 --- a/reference-implementations/agt/packages/host-adapter/test/client.test.ts +++ b/reference-implementations/agt/packages/host-adapter/test/client.test.ts @@ -15,6 +15,7 @@ import { GuardianTimeoutError, } from "../src/guardian-client.ts"; import { signEnvelope } from "../src/sign-envelope.ts"; +import { handshakeFor } from "../../../test/helpers/handshake.ts"; import { negotiateSessionConfig, resolveSessionConfig, @@ -84,6 +85,7 @@ describe("GuardianClient.post", () => { it("correlates the response to the request by JSON-RPC id -- buildEnvelope's id equals params.request_id, and that round-trips fine", async () => { const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + await handshakeFor(guardian.url, envelope); const response = await createGuardianClient(guardian.url).post(envelope); expect(response.id).toBe(envelope.id); @@ -202,6 +204,7 @@ describe("GuardianClient.post", () => { it("against a real Guardian: steps/toolCallRequest for ls -la allows, echoing the request id", async () => { const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + await handshakeFor(guardian.url, envelope); const response = await createGuardianClient(guardian.url).post(envelope); expect(response.error).toBeUndefined(); @@ -217,6 +220,7 @@ describe("GuardianClient.post", () => { }); try { const envelope = signEnvelope(buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap), secret); + await handshakeFor(secured.url, envelope, hello => signEnvelope(hello as never, secret)); const response = await createGuardianClient(secured.url, { hmacSecret: secret }).post(envelope); expect(response.result?.decision).toBe("allow"); } finally { @@ -251,6 +255,7 @@ describe("GuardianClient.post", () => { try { const ordinary = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); const envelope = signEnvelope({ ...ordinary, method: "steps/unknownHook" }, secret); + await handshakeFor(secured.url, envelope, hello => signEnvelope(hello as never, secret)); const response = await createGuardianClient(secured.url, { hmacSecret: secret }).post(envelope); expect(response.error?.code).toBe(-32011); expect(response.error?.signature?.algorithm).toBe("HMAC-SHA256"); @@ -342,6 +347,7 @@ describe("GuardianClient.requestDecision", () => { it("answers with the decision when one arrives", async () => { const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + await handshakeFor(guardian.url, envelope); const outcome = await createGuardianClient(guardian.url).requestDecision(envelope); expect(outcome.decisionArrived).toBe(true); @@ -370,6 +376,7 @@ describe("GuardianClient.requestDecision", () => { it("hands the error through so a refusal is classified as one, not as a delivery failure", async () => { const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); const unknownMethod = { ...envelope, method: "steps/unknownHook" }; + await handshakeFor(guardian.url, unknownMethod); const outcome = await createGuardianClient(guardian.url).requestDecision(unknownMethod); @@ -934,6 +941,7 @@ describe("host -> wire -> policy -> host, end to end", () => { it("a real rm -rf / tool call denies through the real Guardian, and renders with the reasoning in permissionDecisionReason", async () => { const envelope = buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap); + await handshakeFor(guardian.url, envelope); const response = await createGuardianClient(guardian.url).post(envelope); expect(response.error).toBeUndefined(); @@ -951,6 +959,7 @@ describe("host -> wire -> policy -> host, end to end", () => { it("a real ls -la tool call allows through the real Guardian, and renders as a plain allow", async () => { const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + await handshakeFor(guardian.url, envelope); const response = await createGuardianClient(guardian.url).post(envelope); expect(response.error).toBeUndefined(); diff --git a/reference-implementations/agt/packages/host-adapter/test/govern-step.test.ts b/reference-implementations/agt/packages/host-adapter/test/govern-step.test.ts index 5134115a..40c86b4d 100644 --- a/reference-implementations/agt/packages/host-adapter/test/govern-step.test.ts +++ b/reference-implementations/agt/packages/host-adapter/test/govern-step.test.ts @@ -3,7 +3,7 @@ import type { AuditEvent, AuditSink } from "../src/audit-sink.ts"; import type { Hookmap, HookmapRequestHookEntry, HookmapResultHookEntry } from "../src/build-envelope.ts"; import { governStep, governsTool, type GovernedStep } from "../src/govern-step.ts"; import { GuardianTimeoutError, type DecisionOrFailure, type GuardianClient } from "../src/guardian-client.ts"; -import type { ResolvedSessionConfig } from "../src/handshake.ts"; +import { SessionConfigStoreFailedError, type ResolvedSessionConfig } from "../src/handshake.ts"; import type { SessionConfig } from "../src/session-config.ts"; /** @@ -1188,3 +1188,174 @@ describe("governsTool — the rule both hosts and governStep share", () => { expect(governsTool(scopedHookmap, "toString", "bash")).toBe(true); }); }); + +describe("governStep — re-handshakes once when the Guardian has no handshake on record (-32003)", () => { + /** Refuses with -32003 until `renegotiate` runs, then allows. Records each request_id it was asked about. */ + function forgetfulGuardian(): { guardian: GuardianClient; renegotiate: () => Promise; requestIds: string[] } { + let negotiated = false; + const requestIds: string[] = []; + return { + guardian: { + requestDecision: (envelope) => { + requestIds.push(envelope.params.request_id); + return Promise.resolve( + negotiated + ? ({ decisionArrived: true, decision: { decision: "allow" } } satisfies DecisionOrFailure) + : ({ decisionArrived: false, failure: { code: -32003, message: "CAPABILITY_NOT_NEGOTIATED" } } satisfies DecisionOrFailure), + ); + }, + post: () => Promise.reject(new Error("governStep must not use the wire primitive")), + }, + renegotiate: () => { + negotiated = true; + return Promise.resolve(NEGOTIATED("proceed")); + }, + requestIds, + }; + } + + const governWith = ( + guardian: GuardianClient, + audit: AuditSink, + renegotiate?: () => Promise, + withHookmap: Hookmap = hookmap, + ) => + governStep({ + hookEventName: "OnStep", + payload, + hookmap: withHookmap, + guardian, + session: { config: NEGOTIATED("proceed"), failure: undefined }, + sessionId: "sess-1", + audit, + renegotiate, + }); + + it("retries the step with a fresh request after re-handshaking, and honours the decision", async () => { + const { sink } = recordingSink(); + const { guardian, renegotiate, requestIds } = forgetfulGuardian(); + + const governed = await governWith(guardian, sink, renegotiate); + + expect(governed.stage).toBe("honoured"); + expect(governed.decision?.decision).toBe("allow"); + expect(requestIds).toHaveLength(2); + expect(requestIds[1]).not.toBe(requestIds[0]); + }); + + it("keeps the refusal when the re-handshake fails, so a proceed posture cannot fail open", async () => { + const { sink, events } = recordingSink(); + const { guardian, requestIds } = forgetfulGuardian(); + + const governed = await governWith(guardian, sink, () => Promise.reject(new Error("guardian unreachable"))); + + expect(governed.stage).not.toBe("honoured"); + expect(requestIds).toHaveLength(1); + expect(postureEventAt(events, 0).outcome).toBe("blocked"); + }); + + it("keeps the refusal when the retry gets no decision, so a proceed posture cannot fail open", async () => { + const { sink, events } = recordingSink(); + let asks = 0; + const guardian: GuardianClient = { + requestDecision: () => { + asks += 1; + return Promise.resolve( + asks === 1 + ? ({ decisionArrived: false, failure: { code: -32003, message: "CAPABILITY_NOT_NEGOTIATED" } } satisfies DecisionOrFailure) + : ({ decisionArrived: false, failure: new TypeError("fetch failed") } satisfies DecisionOrFailure), + ); + }, + post: () => Promise.reject(new Error("governStep must not use the wire primitive")), + }; + + await governWith(guardian, sink, () => Promise.resolve(NEGOTIATED("proceed"))); + + expect(asks).toBe(2); + expect(postureEventAt(events, 0).outcome).toBe("blocked"); + }); + + it("sends the retry under the timeout the re-handshake negotiated", async () => { + const { sink } = recordingSink(); + const timeouts: (number | undefined)[] = []; + const guardian: GuardianClient = { + requestDecision: (_envelope, options) => { + timeouts.push(options?.timeoutMs); + return Promise.resolve( + timeouts.length === 1 + ? ({ decisionArrived: false, failure: { code: -32003, message: "CAPABILITY_NOT_NEGOTIATED" } } satisfies DecisionOrFailure) + : ({ decisionArrived: true, decision: { decision: "allow" } } satisfies DecisionOrFailure), + ); + }, + post: () => Promise.reject(new Error("governStep must not use the wire primitive")), + }; + + await governWith(guardian, sink, () => + Promise.resolve({ timeout_config: { default_ms: 1234 }, on_decision_failure: "deny" } satisfies SessionConfig), + ); + + expect(timeouts).toEqual([5000, 1234]); + }); + + it("measures an ask's expiry from the retry, not from the refused request", async () => { + const { sink } = recordingSink(); + const { guardian: refusing } = forgetfulGuardian(); + let asks = 0; + const guardian: GuardianClient = { + ...refusing, + requestDecision: (envelope, options) => + ++asks === 1 + ? refusing.requestDecision(envelope, options) + : Promise.resolve({ + decisionArrived: true, + decision: { + decision: "ask", + reasoning: "approval required", + ask_details: { approver: { type: "user" }, question: "ok?", timeout_seconds: 1, timeout_disposition: "allow" }, + }, + } satisfies DecisionOrFailure), + }; + + const askingHookmap: Hookmap = { + host: "test-host", + hooks: { OnStep: { ...ON_STEP, decisions: { ...ON_STEP.decisions, ask: { output: { outcome: { value: "ask" } } } } } }, + }; + + // A re-handshake slower than the ask's whole one-second window. + const governed = await governWith( + guardian, + sink, + async () => { + await Bun.sleep(1_100); + return NEGOTIATED("proceed"); + }, + askingHookmap, + ); + + expect(governed.decision?.decision).toBe("ask"); + }); + + it("retries under a config the re-handshake negotiated but could not store", async () => { + const { sink } = recordingSink(); + const { guardian, renegotiate, requestIds } = forgetfulGuardian(); + + const governed = await governWith(guardian, sink, async () => { + await renegotiate(); + throw new SessionConfigStoreFailedError("disk full", { config: NEGOTIATED("proceed") }); + }); + + expect(governed.stage).toBe("honoured"); + expect(governed.decision?.decision).toBe("allow"); + expect(requestIds).toHaveLength(2); + }); + + it("treats -32003 as a refusal when the caller gave no way to re-handshake", async () => { + const { sink, events } = recordingSink(); + const { guardian, requestIds } = forgetfulGuardian(); + + await governWith(guardian, sink); + + expect(requestIds).toHaveLength(1); + expect(postureEventAt(events, 0).outcome).toBe("blocked"); + }); +}); diff --git a/reference-implementations/agt/test/dispositions.test.ts b/reference-implementations/agt/test/dispositions.test.ts index 04e3dff3..76d2ed8d 100644 --- a/reference-implementations/agt/test/dispositions.test.ts +++ b/reference-implementations/agt/test/dispositions.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; import { startGuardian } from "guardian"; import { buildConfigBundle, buildManifest } from "./helpers/config-bundle.ts"; +import { fetchWithHandshake } from "./helpers/handshake.ts"; const DESTRUCTIVE = ["(?i)rm\\s+-[a-z]*r[a-z]*f[a-z]*\\s+/(?:\\s|$)"]; const cleanups: (() => void)[] = []; @@ -15,7 +16,7 @@ async function decide(config: unknown, command: string, annotator?: () => unknow const guardian = await startGuardian({ port: 0, manifestPath, annotator }); try { const requestId = crypto.randomUUID(); - const res = await fetch(guardian.url, { + const res = await fetchWithHandshake(guardian.url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ diff --git a/reference-implementations/agt/test/envelope-log-sink-roundtrip.test.ts b/reference-implementations/agt/test/envelope-log-sink-roundtrip.test.ts index e60d297b..ce9063b9 100644 --- a/reference-implementations/agt/test/envelope-log-sink-roundtrip.test.ts +++ b/reference-implementations/agt/test/envelope-log-sink-roundtrip.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { startGuardian } from "../packages/guardian/src/index.ts"; import { tailEnvelopeLog, type EnvelopeLogEntry } from "../packages/inspector/src/tail-envelope-log.ts"; import { outcomeMessageOf, renderOutcome, type OutcomeMessage } from "../packages/inspector/src/render.ts"; +import { fetchWithHandshake } from "./helpers/handshake.ts"; /** * The contract test for the envelope log. The Guardian writes it; the @@ -61,19 +62,20 @@ describe("envelope log round trip: Guardian sink -> Inspector tail -> decision b const controller = new AbortController(); try { - await fetch(guardian.url, { + await fetchWithHandshake(guardian.url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(toolCallEnvelope("rm -rf /", 77)), }); const tail = tailEnvelopeLog({ path: logPath, fromStart: true, pollMs: 10, signal: controller.signal }); - const [request, response] = await take(tail, 2, controller); + // The first pair is fetchWithHandshake's handshake/hello. + const [, , request, response] = await take(tail, 4, controller); // Every field the Inspector's EnvelopeLogEntry declares must actually // be present and correctly typed on what the Guardian wrote. - expect(request?.seq).toBe(1); - expect(response?.seq).toBe(2); + expect(request?.seq).toBe(3); + expect(response?.seq).toBe(4); expect(typeof request?.recorded_at).toBe("string"); expect(typeof response?.recorded_at).toBe("string"); expect(request?.direction).toBe("request"); diff --git a/reference-implementations/agt/test/handshake-declares-what-it-evaluates.test.ts b/reference-implementations/agt/test/handshake-declares-what-it-evaluates.test.ts index 60e0d6de..478c7384 100644 --- a/reference-implementations/agt/test/handshake-declares-what-it-evaluates.test.ts +++ b/reference-implementations/agt/test/handshake-declares-what-it-evaluates.test.ts @@ -7,6 +7,7 @@ import { loadHookmap, negotiateSessionConfig, } from "host-adapter"; +import { fetchWithHandshake } from "./helpers/handshake.ts"; /** * The handshake has to describe the Guardian that sent it. @@ -98,7 +99,7 @@ type RpcAnswer = { * answered for it at all. */ async function dispatches(method: string): Promise { const id = crypto.randomUUID(); - const res = await fetch(guardian.url, { + const res = await fetchWithHandshake(guardian.url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ diff --git a/reference-implementations/agt/test/helpers/handshake.ts b/reference-implementations/agt/test/helpers/handshake.ts new file mode 100644 index 00000000..c0712a9d --- /dev/null +++ b/reference-implementations/agt/test/helpers/handshake.ts @@ -0,0 +1,55 @@ +/** + * §4: a Guardian answers hook traffic only after `handshake/hello` declares the + * method for that session. Tests that post steps straight to a Guardian call + * this first. It handshakes on every call rather than caching per session, + * because a port freed by one test's Guardian can be reused by the next one's. + * + * The hello declares exactly the method of the envelope about to be sent, so + * it never widens what a test exercises. `sign` signs the hello when the + * Guardian under test requires signatures. + */ +export async function handshakeFor( + url: string, + envelope: unknown, + sign: (hello: Record) => Record = hello => hello, +): Promise { + const step = envelope as { method?: unknown; params?: { metadata?: { session_id?: unknown } } } | null | undefined; + const sessionId = step?.params?.metadata?.session_id; + if (typeof sessionId !== "string" || typeof step?.method !== "string") return; + if (step.method === "handshake/hello" || step.method === "system/ping") return; + const hello = { + jsonrpc: "2.0", + id: "handshake", + method: "handshake/hello", + params: { + acs_version: "0.1.0", + request_id: crypto.randomUUID(), + timestamp: new Date().toISOString(), + metadata: { agent_id: "agent-1", session_id: sessionId }, + payload: { + acs_versions_supported: ["0.1.0"], + methods_implemented: [step.method], + transports_supported: ["http"], + provenance_producer: "none", + profiles_supported: ["acs-core"], + }, + }, + }; + await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(sign(hello)), + }); +} + +/** `fetch` for a test that writes its step inline: handshakes for the body's session first. */ +export async function fetchWithHandshake(url: string, init: RequestInit & { body: string }): Promise { + let envelope: unknown; + try { + envelope = JSON.parse(init.body); + } catch { + // An unparseable body names no session to handshake for. + } + await handshakeFor(url, envelope); + return fetch(url, init); +} From 7437f3e97f52bdd7281a35b8736d8bf770705260 Mon Sep 17 00:00:00 2001 From: Jason Crittenden Date: Mon, 12 Oct 2026 02:26:14 +0000 Subject: [PATCH 04/12] fix(agt): refuse with -32000 when the replay cache is full MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full replay-protection cache answered a brand-new request with -32005 REPLAY_DETECTED. §17.1 recovers from -32005 by regenerating request_id, which cannot succeed while the cache is full. Return -32000 SESSION_REFUSED with data.reason "replay_protection_capacity" instead: it is in the §17.1 registry, so every ACS client fails closed on it. startGuardian takes an optional replayProtectionStore so a server test can fill the cache. Reported by @shivang-sec on #229. Signed-off-by: Jason Crittenden --- .../agt/packages/guardian/src/server.ts | 19 +++++++++++-------- .../agt/packages/guardian/test/server.test.ts | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/reference-implementations/agt/packages/guardian/src/server.ts b/reference-implementations/agt/packages/guardian/src/server.ts index c8e91528..5d998891 100644 --- a/reference-implementations/agt/packages/guardian/src/server.ts +++ b/reference-implementations/agt/packages/guardian/src/server.ts @@ -142,7 +142,7 @@ function rememberNegotiated(sessions: NegotiatedSessions, sessionId: string, met if (sessions.size > MAX_NEGOTIATED_SESSIONS) sessions.delete(sessions.keys().next().value!); } -type ReplayProtectionStore = { +export type ReplayProtectionStore = { seenOrRemember( sessionId: string, requestId: string, @@ -437,6 +437,8 @@ export type StartGuardianOptions = { * Exists so a test can read spans back in-process or stand in one that * fails. Not meant for production use. */ traceExporter?: OtelTraceExporter; + /** The replay-protection store, for tests. Omitted means a fresh default one. */ + replayProtectionStore?: ReplayProtectionStore; /** Base64-encoded shared HMAC input keying material. Omission is supported * only for in-process compatibility tests and advertises no `acs-core` * profile; the standalone deployment requires ACS_HMAC_SECRET. */ @@ -511,6 +513,7 @@ export async function startGuardian({ otelTraceFile, otelTraceEndpoint, traceExporter: traceExporterOverride, + replayProtectionStore: replayProtectionStoreOverride, hmacSecret, }: StartGuardianOptions): Promise { if (hmacSecret !== undefined && Buffer.from(hmacSecret, "base64").length < 32) { @@ -525,7 +528,7 @@ export async function startGuardian({ const bridge = bridgeOverride ?? createDeploymentBridge(manifestPath, annotator); const mapping = loadMapping(mappingPath ?? MAPPING_PATH); const registeredTools = loadRegisteredTools(manifestPath); - const replayProtectionStore = createReplayProtectionStore(); + const replayProtectionStore = replayProtectionStoreOverride ?? createReplayProtectionStore(); const negotiatedSessions: NegotiatedSessions = new Map(); const envelopeLog = envelopeLogPath ? createEnvelopeLogSink({ path: envelopeLogPath }) : NULL_ENVELOPE_LOG_SINK; // The session-context store is always the in-memory one, or the caller's @@ -1000,12 +1003,12 @@ async function dispatch( nowMs, requestTimestampMs + SKEW_WINDOW_MS, ); - if (replayStatus !== "fresh") { - return errorResponse( - rpcId, - -32005, - replayStatus === "replay" ? "REPLAY_DETECTED" : "REPLAY_PROTECTION_CAPACITY", - ); + if (replayStatus === "replay") return errorResponse(rpcId, -32005, "REPLAY_DETECTED"); + // A full cache is not a replay: §17.1 recovers from -32005 by regenerating + // request_id, which cannot succeed here. -32000 is the registry code every + // client already treats as a refusal. §17.1 has no retryable code for this. + if (replayStatus === "capacity") { + return errorResponse(rpcId, -32000, "SESSION_REFUSED", { reason: "replay_protection_capacity" }); } // Method-specific validation runs only after authentication and freshness diff --git a/reference-implementations/agt/packages/guardian/test/server.test.ts b/reference-implementations/agt/packages/guardian/test/server.test.ts index 6fa3c153..af5ed2cd 100644 --- a/reference-implementations/agt/packages/guardian/test/server.test.ts +++ b/reference-implementations/agt/packages/guardian/test/server.test.ts @@ -1892,6 +1892,22 @@ describe("ACS-Core wire security and negotiation", () => { } }); + it("refuses a fresh request with -32000, not -32005, when the replay cache is full", async () => { + // One slot: postAcs's handshake/hello takes it, so the step finds the cache full. + const secured = await startGuardian({ + port: 0, + manifestPath: "policy/manifest.yaml", + replayProtectionStore: createReplayProtectionStore(1, 1), + }); + try { + const error = (await postAcs(secured.url, toolCallEnvelope("ls -la"))).error; + expect(error?.code).toBe(-32000); + expect(error?.data).toEqual({ reason: "replay_protection_capacity" }); + } finally { + await secured.close(); + } + }); + it("negotiates profiles, advertised signature support, and validates ClientHello", async () => { const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: encodedSecret }); try { From b5c976eac25a6d286a3e4783774cf32cbd22fb7e Mon Sep 17 00:00:00 2001 From: Jason Crittenden Date: Mon, 12 Oct 2026 03:06:08 +0000 Subject: [PATCH 05/12] fix(agt): keep the signing block total and label the evaluator The HMAC signing block in handleAcsRequest ran outside any try, on unauthenticated input. A session_id over 1024 bytes makes HKDF throw, and a deeply nested body overflows the stack in JCS canonicalization. Either escaped handleAcsRequest as an HTML 500, which a host reads as a hook that never fired. The block is now wrapped. A request that throws there cannot carry a valid signature, so its response goes out unsigned, like any other unauthenticated request's, and the failure is reported on stderr. The envelope log records the same response it did before, minus the signature it could never have had. Every AcsResult now carries metadata.evaluator "deterministic", so the trace exporter in #244 reads the evaluator from the wire instead of assuming it. Both raised by @rocklambros on #229. Signed-off-by: Jason Crittenden --- .../agt/packages/guardian/src/acs-result.ts | 4 ++ .../agt/packages/guardian/src/server.ts | 45 ++++++++++++------- .../test/deny-on-invalid-envelope.test.ts | 1 + .../agt/packages/guardian/test/server.test.ts | 25 +++++++++++ 4 files changed, 60 insertions(+), 15 deletions(-) diff --git a/reference-implementations/agt/packages/guardian/src/acs-result.ts b/reference-implementations/agt/packages/guardian/src/acs-result.ts index d883c846..c389d08c 100644 --- a/reference-implementations/agt/packages/guardian/src/acs-result.ts +++ b/reference-implementations/agt/packages/guardian/src/acs-result.ts @@ -26,6 +26,9 @@ export type AcsFinalResult = { request_id: string; /** §8.6: the chain head after this step was recorded. */ chain_hash?: string; + /** Every decision this Guardian sends comes from its own policy engine or + * its own fail-closed code, so the evaluator is always deterministic. */ + metadata: { evaluator: "deterministic" }; /** §10: HMAC signature over the response, present when key is configured. */ signature?: { algorithm: string; value: string; key_id: string }; } & AcsDecision; @@ -48,6 +51,7 @@ export function finalResult( acs_version: params.acs_version, request_id: params.request_id, ...decision, + metadata: { evaluator: "deterministic" }, }; if (chainHash !== undefined) { result.chain_hash = chainHash; diff --git a/reference-implementations/agt/packages/guardian/src/server.ts b/reference-implementations/agt/packages/guardian/src/server.ts index 5d998891..de6d3f44 100644 --- a/reference-implementations/agt/packages/guardian/src/server.ts +++ b/reference-implementations/agt/packages/guardian/src/server.ts @@ -730,21 +730,36 @@ async function handleAcsRequest( // Signing an unsigned ping would let a caller mint a valid `allow` with a // chosen session/id and substitute it for a governed step response whose // identifiers collide. Keep both halves of ping signature-exempt. - if ( - hmacSecret && - extractMethod(raw) !== "system/ping" && - requestHasValidSignature(raw, Buffer.from(hmacSecret, "base64")) - ) { - const sessionId = extractSessionId(raw); - if (sessionId) { - const sessionKey = deriveSessionKey(Buffer.from(hmacSecret, "base64"), sessionId); - const sig = signResponseHmacSha256(response as Record, sessionKey); - const carrier = "result" in response ? response.result : response.error; - (carrier as Record).signature = { - algorithm: "HMAC-SHA256", - value: sig, - key_id: sessionId, - }; + // + // Wrapped because the request half runs on unauthenticated input: HKDF + // throws on a session id over 1024 bytes, and canonicalization recurses + // once per nesting level. A throw here would escape as an HTML 500, which a + // host reads as a hook that never fired. A request that throws here cannot + // carry a valid signature, so its answer goes out unsigned, exactly like + // any other unauthenticated request's. + try { + if ( + hmacSecret && + extractMethod(raw) !== "system/ping" && + requestHasValidSignature(raw, Buffer.from(hmacSecret, "base64")) + ) { + const sessionId = extractSessionId(raw); + if (sessionId) { + const sessionKey = deriveSessionKey(Buffer.from(hmacSecret, "base64"), sessionId); + const sig = signResponseHmacSha256(response as Record, sessionKey); + const carrier = "result" in response ? response.result : response.error; + (carrier as Record).signature = { + algorithm: "HMAC-SHA256", + value: sig, + key_id: sessionId, + }; + } + } + } catch (error) { + try { + console.error(`guardian sent an unsigned response: signing failed (${toRepoRelativeMessage(error)})`); + } catch { + // Total means total, as for the checkResponse reporter below. } } diff --git a/reference-implementations/agt/packages/guardian/test/deny-on-invalid-envelope.test.ts b/reference-implementations/agt/packages/guardian/test/deny-on-invalid-envelope.test.ts index c7b0fc1b..e617e897 100644 --- a/reference-implementations/agt/packages/guardian/test/deny-on-invalid-envelope.test.ts +++ b/reference-implementations/agt/packages/guardian/test/deny-on-invalid-envelope.test.ts @@ -19,6 +19,7 @@ describe("denyOnInvalidEnvelope", () => { reasoning: "params.payload.tool is required", reason_codes: ["envelope_invalid"], policy_references: [], + metadata: { evaluator: "deterministic" }, }, }); }); diff --git a/reference-implementations/agt/packages/guardian/test/server.test.ts b/reference-implementations/agt/packages/guardian/test/server.test.ts index af5ed2cd..58e40f1c 100644 --- a/reference-implementations/agt/packages/guardian/test/server.test.ts +++ b/reference-implementations/agt/packages/guardian/test/server.test.ts @@ -255,6 +255,7 @@ describe("startGuardian POST /acs", () => { expect(response.error).toBeUndefined(); expect(response.result?.decision).toBe("allow"); expect(response.result?.request_id).toBe(requestId); + expect(response.result?.metadata).toEqual({ evaluator: "deterministic" }); }); it("an unknown method returns a JSON-RPC error in the ACS-reserved -32000..-32099 range, not a decision", async () => { @@ -1737,6 +1738,30 @@ describe("ACS-Core wire security and negotiation", () => { const masterSecret = Buffer.alloc(32, 0x5a); const encodedSecret = masterSecret.toString("base64"); + it("answers JSON-RPC, never an HTML 500, when the signing boundary cannot process a request", async () => { + const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: encodedSecret }); + const signedLooking = (envelope: Record) => { + const params = envelope.params as Record; + const sessionId = (params.metadata as Record).session_id; + params.signature = { algorithm: "HMAC-SHA256", key_id: sessionId, value: "AAAA" }; + return envelope; + }; + // HKDF caps its info input at 1024 bytes, and the session id is that input. + const longSession = signedLooking(toolCallEnvelope("ls -la", { sessionId: "s".repeat(2048) })); + // Canonicalization recurses once per nesting level. + const deep = signedLooking(toolCallEnvelope("ls -la")); + const deepBody = JSON.stringify(deep).replace('"ls -la"', `${"[".repeat(200_000)}${"]".repeat(200_000)}`); + try { + for (const body of [JSON.stringify(longSession), deepBody]) { + const res = await fetch(secured.url, { method: "POST", headers: { "content-type": "application/json" }, body }); + expect(res.status).toBe(200); + expect(((await res.json()) as JsonRpcResponse).jsonrpc).toBe("2.0"); + } + } finally { + await secured.close(); + } + }); + it("rejects unsigned and incorrectly signed requests when HMAC is configured", async () => { const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: encodedSecret }); try { From 7a02825e2356d05454377b15d96c620e04c0acb3 Mon Sep 17 00:00:00 2001 From: Jason Crittenden Date: Tue, 13 Oct 2026 02:48:14 +0000 Subject: [PATCH 06/12] fix(agt): answer an uncanonicalisable signed request with -32004 Request-signature verification canonicalises the unauthenticated body before computing the HMAC. A signed body nested past the stack made that throw, and the throw fell through dispatch to the outer net, which answers -32020. That code is outside the section 17.1 registry, so a client branching on registered codes could fail open on it. verifyHmacSha256 now treats an input it cannot canonicalise as an invalid signature, so the request gets -32004 SIGNATURE_INVALID like any other unauthenticated one. The signing-boundary test now asserts the answer for each of its two inputs. Raised by @astrogilda on #229. Signed-off-by: Jason Crittenden --- .../agt/packages/guardian/src/verify-signature.ts | 10 +++++++++- .../agt/packages/guardian/test/server.test.ts | 7 ++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/reference-implementations/agt/packages/guardian/src/verify-signature.ts b/reference-implementations/agt/packages/guardian/src/verify-signature.ts index 4f312f34..795e9ba9 100644 --- a/reference-implementations/agt/packages/guardian/src/verify-signature.ts +++ b/reference-implementations/agt/packages/guardian/src/verify-signature.ts @@ -64,7 +64,15 @@ export function verifyHmacSha256( signatureValue: string, secret: Buffer, ): SignatureVerificationResult { - const input = signingInput(envelope); + // Canonicalization recurses once per nesting level, and this runs on + // unauthenticated input: a body nested past the stack is unverifiable, so + // it is a SIGNATURE_INVALID like any other, not a Guardian failure. + let input: string; + try { + input = signingInput(envelope); + } catch { + return { valid: false, reason: "signature input cannot be canonicalised" }; + } const computed = createHmac("sha256", secret).update(input).digest(); let expected: Buffer; diff --git a/reference-implementations/agt/packages/guardian/test/server.test.ts b/reference-implementations/agt/packages/guardian/test/server.test.ts index 58e40f1c..76ef57bb 100644 --- a/reference-implementations/agt/packages/guardian/test/server.test.ts +++ b/reference-implementations/agt/packages/guardian/test/server.test.ts @@ -1752,11 +1752,16 @@ describe("ACS-Core wire security and negotiation", () => { const deep = signedLooking(toolCallEnvelope("ls -la")); const deepBody = JSON.stringify(deep).replace('"ls -la"', `${"[".repeat(200_000)}${"]".repeat(200_000)}`); try { + const answers: JsonRpcResponse[] = []; for (const body of [JSON.stringify(longSession), deepBody]) { const res = await fetch(secured.url, { method: "POST", headers: { "content-type": "application/json" }, body }); expect(res.status).toBe(200); - expect(((await res.json()) as JsonRpcResponse).jsonrpc).toBe("2.0"); + answers.push((await res.json()) as JsonRpcResponse); } + expect(answers[0]?.result?.decision).toBe("deny"); + // A code in the §17.1 registry rather than -32020, so a client that + // branches on registered codes cannot fail open on it. + expect(answers[1]?.error?.code).toBe(-32004); } finally { await secured.close(); } From 3e17980045e23cf8d61c7c364c60cfc76308d051 Mon Sep 17 00:00:00 2001 From: Jason Crittenden Date: Wed, 14 Oct 2026 06:52:01 +0000 Subject: [PATCH 07/12] fix(agt): fail closed on an unsigned refusal or an unsignable request With HMAC on, two host paths turned a deny into an allow. The client threw on any unverifiable response before reading its code, so a refusal the Guardian cannot sign (oversize body, -32700, a bad request signature) became an "unknown" failure and the proceed posture ran the step. A refusal-coded error with no result is now passed through unverified: it can only fail the step closed. The refusal codes move to failure-kinds.ts so the client can read them without an import cycle. A request too deep to canonicalise threw inside request building and the posture answered it. Signing is now its own "sign" stage whose failure, kind "unsignable", blocks regardless of posture. Signed-off-by: Jason Crittenden --- .../hosts/claude-code/test/posture.test.ts | 81 +++++++++++++++++ .../host-adapter/src/failure-kinds.ts | 90 +++++++++++++++++-- .../host-adapter/src/failure-posture.ts | 88 ++++-------------- .../packages/host-adapter/src/govern-step.ts | 19 ++-- .../host-adapter/src/guardian-client.ts | 15 +++- .../packages/host-adapter/test/client.test.ts | 37 ++++++-- 6 files changed, 243 insertions(+), 87 deletions(-) diff --git a/reference-implementations/agt/hosts/claude-code/test/posture.test.ts b/reference-implementations/agt/hosts/claude-code/test/posture.test.ts index 3b8b5971..ad1c9b12 100644 --- a/reference-implementations/agt/hosts/claude-code/test/posture.test.ts +++ b/reference-implementations/agt/hosts/claude-code/test/posture.test.ts @@ -1229,3 +1229,84 @@ describe("acs-hook — the negotiated posture, end to end", () => { } }); }); + +/** + * Turning HMAC on must never turn a deny into an allow. The Guardian cannot + * sign a refusal it issues before any session key exists, so these pin what + * the host DECIDES when such a refusal arrives unsigned, not merely which + * error the client raised on the way. + */ +describe("acs-hook — an unsigned refusal under HMAC still refuses", () => { + const GUARDIAN_KEY = Buffer.alloc(32, 0x61).toString("base64"); + + it("withholds a keyed tool result over the Guardian's 1 MiB request cap, as guardian_refused", async () => { + const dir = scratch(); + const guardian = await startGuardian({ port: 0, manifestPath: MANIFEST, hmacSecret: GUARDIAN_KEY }); + try { + const env = { + ACS_GUARDIAN_URL: guardian.url, + ACS_SESSION_DIR: join(dir, "sessions"), + ACS_AUDIT_LOG: join(dir, "audit.jsonl"), + ACS_HMAC_SECRET: GUARDIAN_KEY, + }; + const secret = "TOKEN=ghp_ABCDEF123456\n"; + const out = await runShim(resultPayload(secret + "x".repeat(2 * 1024 * 1024)), env); + const decision = JSON.parse(out.stdout) as Record; + expect(decision.decision).toBe("block"); + expect(out.stdout).not.toContain("ghp_ABCDEF123456"); + const audit = readFileSync(join(dir, "audit.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l)); + expect(audit.at(-1)).toMatchObject({ outcome: "blocked", failure: { kind: "refused" } }); + expect(audit.at(-1).reason_codes ?? []).not.toContain("on_decision_failure_proceed"); + } finally { + await guardian.close(); + } + }); + + it("denies a keyed tool call whose input is too deep to sign, under the proceed posture", async () => { + const dir = scratch(); + const guardian = await startGuardian({ port: 0, manifestPath: MANIFEST, hmacSecret: GUARDIAN_KEY }); + try { + const deep = "[".repeat(20_000) + "]".repeat(20_000); + const stdin = JSON.stringify({ + session_id: "sess-1", + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command: "ls -la" }, + }).replace('"ls -la"}', `"ls -la","extra":${deep}}`); + const out = await runShim(stdin, { + ACS_GUARDIAN_URL: guardian.url, + ACS_SESSION_DIR: join(dir, "sessions"), + ACS_AUDIT_LOG: join(dir, "audit.jsonl"), + ACS_HMAC_SECRET: GUARDIAN_KEY, + }); + expect(expectQuietDecision(out).permissionDecision).toBe("deny"); + const audit = readFileSync(join(dir, "audit.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l)); + expect(audit.at(-1)).toMatchObject({ posture: "proceed", outcome: "blocked", failure: { kind: "unsignable" } }); + } finally { + await guardian.close(); + } + }); + + it("denies rm -rf / from a host whose key does not match a deny-posture Guardian's", async () => { + const dir = scratch(); + const guardian = await startGuardian({ + port: 0, + manifestPath: MANIFEST, + hmacSecret: GUARDIAN_KEY, + onDecisionFailure: "deny", + }); + try { + const out = await runShim(payload("rm -rf /"), { + ACS_GUARDIAN_URL: guardian.url, + ACS_SESSION_DIR: join(dir, "sessions"), + ACS_AUDIT_LOG: join(dir, "audit.jsonl"), + ACS_HMAC_SECRET: Buffer.alloc(32, 0x62).toString("base64"), + }); + expect(expectQuietDecision(out).permissionDecision).toBe("deny"); + const audit = readFileSync(join(dir, "audit.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l)); + expect(audit.at(-1)).toMatchObject({ outcome: "blocked", failure: { kind: "refused" } }); + } finally { + await guardian.close(); + } + }); +}); diff --git a/reference-implementations/agt/packages/host-adapter/src/failure-kinds.ts b/reference-implementations/agt/packages/host-adapter/src/failure-kinds.ts index da9561d2..202aa80b 100644 --- a/reference-implementations/agt/packages/host-adapter/src/failure-kinds.ts +++ b/reference-implementations/agt/packages/host-adapter/src/failure-kinds.ts @@ -43,8 +43,8 @@ * * `error_without_decision` is what is left of the JSON-RPC-error case once * the refusals are taken out of it: an error whose code this host does not - * recognise as a refusal of this envelope. See REFUSAL_RPC_CODES in - * failure-posture.ts for why the recognised set is enumerated rather than + * recognise as a refusal of this envelope. See REFUSAL_RPC_CODES below + * for why the recognised set is enumerated rather than * inferred, and for what that leaves in this bucket. */ export type DeliveryFailureKind = "timeout" | "transport" | "error_without_decision" | "unknown"; @@ -72,7 +72,15 @@ export type HostFailureKind = * guardian" tells an incident reviewer to go and look at a Guardian that * answered correctly, when the fault is in this host's own rendering table. */ - | "decision_unrenderable"; + | "decision_unrenderable" + /** + * The request was built but this host could not sign it -- input nested + * deeply enough to overflow canonicalisation, say. Never sent, and never + * answered by the posture: an ACS-Core Guardian must refuse an unsigned + * request, so this host refuses it first, the same as a refusal. Under + * `proceed` the posture would have run a step that without HMAC is denied. + */ + | "unsignable"; /** * What the Guardian did when it was ALIVE and would not answer this envelope @@ -118,14 +126,16 @@ export type StepFailureKind = DeliveryFailureKind | RefusalFailureKind | HostFai * "request" -- no request was ever built, so nothing was asked of * anything. Host-side configuration, and the reasoning must * not name a Guardian that was never contacted. + * "sign" -- a request was built and this host could not sign it, so + * it was never sent. Fails closed, never by posture. * "render" -- a decision arrived and was honoured in principle; this * host could not express it. * - * A boolean could only express two of the three, and a second boolean would + * A boolean could only express two of these, and a second boolean would * have admitted a combination that means nothing ("nothing was sent, and a * decision arrived"). */ -export type FailureStage = "delivery" | "request" | "render"; +export type FailureStage = "delivery" | "request" | "sign" | "render"; /** What went wrong establishing this session's negotiated config, for * `AuditEntry.session_failure`. */ @@ -156,3 +166,73 @@ export type SessionFailureKind = * emitting the wrong shape, not a host that cannot write to its own disk. */ | "server_hello_invalid"; + +/** + * The JSON-RPC error codes that mean the Guardian was ALIVE and refused this + * envelope, rather than that the wire failed. A response carrying one of + * these is a governance outcome and fails closed regardless of posture (see + * applyFailurePosture). + * + * Declared here by value rather than imported: this package has no runtime + * dependency on the Guardian and is not about to grow one for four integers + * -- the same arrangement DEFAULT_TIMEOUT_MS keeps with the Guardian's + * declared default. The four are the complete set this deployment's Guardian + * mints for a request it will not decide (packages/guardian/src/server.ts): + * + * -32700 Parse error. Nothing the host sent was JSON. Answered with + * `id: null`, since there is no envelope to read an id out of -- + * which is why guardian-client.ts's post() has to let an + * unaddressed error through before its id-correlation check, or + * this code could never reach classifyDeliveryFailure at all. + * -32010 The envelope failed schema validation and the Guardian could not + * address a deny decision to it (no top-level id, or an id it + * cannot put in a response), or the body exceeded the Guardian's + * request cap. denyOnInvalidEnvelope converts the ADDRESSABLE + * steps/* cases into honoured denies Guardian-side; this closes the + * rest from the host's own side, which is what makes the + * Guardian's inability to address every deny non-load-bearing. + * -32011 The Guardian dispatched no handler for the method. + * -32020 Evaluation itself failed -- mapVerdict's own checks, a policy + * runtime error, or the outer net around dispatch. + * + * -32011 is the arguable one, so the reasoning is recorded rather than + * assumed. A host only ever sends methods its own hookmap maps, so a Guardian + * refusing to dispatch one is a misconfiguration of THIS deployment -- the + * hookmap and the Guardian disagree about what is governed -- and that is not + * a permission to proceed. The alternative reading, deliberately rejected: + * handshake.json's `methods_evaluated` says "Methods listed by the client but + * absent here are NOT evaluated ... Clients MAY still emit them for audit but + * MUST treat them as ALLOW-by-default", which would make an undispatched + * method an allow. That rule is about a method the ServerHello DECLARED it + * would not evaluate -- negotiated coverage, known before the step -- not + * about one the Guardian accepted at handshake and then refused at dispatch. + * A host that read the second as the first would fail open on precisely the + * disagreement it should surface. + * + * Enumerated rather than inferred ("any error object is a refusal"), for the + * reason TRANSPORT_ERROR_CODES is: the four above are checkable against a + * live Guardian, and client.test.ts drives each route to prove the list + * matches what the Guardian actually sends rather than what this file + * believes. An unrecognised code therefore stays `error_without_decision` and + * keeps the posture. That is a genuine remaining hole -- a future Guardian + * code would fail open under `proceed` until it is added here -- and it is + * preferred to guessing, because widening this to every error object would + * also fail closed on an intermediary's error the Guardian never sent. + */ +export const REFUSAL_RPC_CODES: ReadonlySet = new Set([ + -32700, + // ACS §17.1's complete mandated-refusal registry. None of these is a + // transport accident or permission to apply the availability posture. + -32000, + -32001, + -32002, + -32003, + -32004, + -32005, + -32006, + -32007, + // Reference-implementation refusal codes. + -32010, + -32011, + -32020, +]); diff --git a/reference-implementations/agt/packages/host-adapter/src/failure-posture.ts b/reference-implementations/agt/packages/host-adapter/src/failure-posture.ts index 2e1c4104..b897fd38 100644 --- a/reference-implementations/agt/packages/host-adapter/src/failure-posture.ts +++ b/reference-implementations/agt/packages/host-adapter/src/failure-posture.ts @@ -49,6 +49,7 @@ import type { StepFailureKind, } from "./failure-kinds.ts"; import type { AcsDecision } from "./decision-message.ts"; +import { REFUSAL_RPC_CODES } from "./failure-kinds.ts"; import { GuardianTimeoutError } from "./guardian-client.ts"; import { SessionConfigNotStoredError, type ResolvedSessionConfig } from "./handshake.ts"; @@ -105,76 +106,6 @@ const TRANSPORT_ERROR_CODES = new Set([ "ERR_TLS_CERT_ALTNAME_INVALID", ]); -/** - * The JSON-RPC error codes that mean the Guardian was ALIVE and refused this - * envelope, rather than that the wire failed. A response carrying one of - * these is a governance outcome and fails closed regardless of posture (see - * applyFailurePosture). - * - * Declared here by value rather than imported: this package has no runtime - * dependency on the Guardian and is not about to grow one for four integers - * -- the same arrangement DEFAULT_TIMEOUT_MS keeps with the Guardian's - * declared default. The four are the complete set this deployment's Guardian - * mints for a request it will not decide (packages/guardian/src/server.ts): - * - * -32700 Parse error. Nothing the host sent was JSON. Answered with - * `id: null`, since there is no envelope to read an id out of -- - * which is why guardian-client.ts's post() has to let an - * unaddressed error through before its id-correlation check, or - * this code could never reach this function at all. - * -32010 The envelope failed schema validation and the Guardian could not - * address a deny decision to it (no top-level id, or an id it - * cannot put in a response), or the body exceeded the Guardian's - * request cap. denyOnInvalidEnvelope converts the ADDRESSABLE - * steps/* cases into honoured denies Guardian-side; this closes the - * rest from the host's own side, which is what makes the - * Guardian's inability to address every deny non-load-bearing. - * -32011 The Guardian dispatched no handler for the method. - * -32020 Evaluation itself failed -- mapVerdict's own checks, a policy - * runtime error, or the outer net around dispatch. - * - * -32011 is the arguable one, so the reasoning is recorded rather than - * assumed. A host only ever sends methods its own hookmap maps, so a Guardian - * refusing to dispatch one is a misconfiguration of THIS deployment -- the - * hookmap and the Guardian disagree about what is governed -- and that is not - * a permission to proceed. The alternative reading, deliberately rejected: - * handshake.json's `methods_evaluated` says "Methods listed by the client but - * absent here are NOT evaluated ... Clients MAY still emit them for audit but - * MUST treat them as ALLOW-by-default", which would make an undispatched - * method an allow. That rule is about a method the ServerHello DECLARED it - * would not evaluate -- negotiated coverage, known before the step -- not - * about one the Guardian accepted at handshake and then refused at dispatch. - * A host that read the second as the first would fail open on precisely the - * disagreement it should surface. - * - * Enumerated rather than inferred ("any error object is a refusal"), for the - * reason TRANSPORT_ERROR_CODES is: the four above are checkable against a - * live Guardian, and client.test.ts drives each route to prove the list - * matches what the Guardian actually sends rather than what this file - * believes. An unrecognised code therefore stays `error_without_decision` and - * keeps the posture. That is a genuine remaining hole -- a future Guardian - * code would fail open under `proceed` until it is added here -- and it is - * preferred to guessing, because widening this to every error object would - * also fail closed on an intermediary's error the Guardian never sent. - */ -const REFUSAL_RPC_CODES = new Set([ - -32700, - // ACS §17.1's complete mandated-refusal registry. None of these is a - // transport accident or permission to apply the availability posture. - -32000, - -32001, - -32002, - -32003, - -32004, - -32005, - -32006, - -32007, - // Reference-implementation refusal codes. - -32010, - -32011, - -32020, -]); - /** * Names what came back INSTEAD of a decision, for the audit entry. Total: an * unrecognised shape is "unknown", never a throw -- this runs while the host @@ -332,9 +263,10 @@ export function applyFailurePosture({ // -- see REFUSAL_RPC_CODES and REFUSAL_RESOLUTION. const classified = classifyStepFailure(stage, failure); const refused = classified.kind === "refused"; + const unsignable = classified.kind === "unsignable"; // One resolution, read once, in the three vocabularies it is expressed in -- // see RESOLUTION_BY_POSTURE. - const { decision, outcome } = refused ? REFUSAL_RESOLUTION : RESOLUTION_BY_POSTURE[posture]; + const { decision, outcome } = refused || unsignable ? REFUSAL_RESOLUTION : RESOLUTION_BY_POSTURE[posture]; // Computed before the audit write, and written into it: "the guardian was // down for this whole session" (default) and "this deployment chose to @@ -412,6 +344,17 @@ export function applyFailurePosture({ // unauditable refusal needs no downgrade for the same reason an unauditable // deny does not: the step is blocked either way, and the failed write is // reported by the sink's own error path. + if (unsignable) { + return { + decision, + reasoning: + `this host could not sign the request for ${method ?? "this step"}, so it was never sent ` + + `(${classified.kind}: ${classified.message});${sessionNote} an ACS-Core Guardian must reject an unsigned ` + + `request, so on_decision_failure=${posture} does not apply and this step was blocked.`, + reason_codes: [REASON_CODE_BY_STAGE.sign], + }; + } + if (refused) { return { decision, @@ -515,6 +458,7 @@ const REFUSAL_RESOLUTION: { decision: "allow" | "deny"; outcome: PostureResolved const REASON_CODE_BY_STAGE: Record = { delivery: "decision_failure", request: "host_configuration", + sign: "request_unsignable", render: "decision_unrenderable", }; @@ -539,6 +483,8 @@ function classifyStepFailure(stage: FailureStage, failure: unknown): { kind: Ste switch (stage) { case "request": return { kind: "host_configuration", message: messageOf(failure) }; + case "sign": + return { kind: "unsignable", message: messageOf(failure) }; case "render": return { kind: "decision_unrenderable", message: messageOf(failure) }; default: diff --git a/reference-implementations/agt/packages/host-adapter/src/govern-step.ts b/reference-implementations/agt/packages/host-adapter/src/govern-step.ts index 8284ca20..ccc8c68f 100644 --- a/reference-implementations/agt/packages/host-adapter/src/govern-step.ts +++ b/reference-implementations/agt/packages/host-adapter/src/govern-step.ts @@ -673,16 +673,25 @@ export async function governStep({ // at the top of this function throws on it and audits nothing, because the // posture's answer to it could not be rendered either. Every other way // `buildEnvelope` can fail still lands here. - const buildRequest = (): AcsRequestEnvelope => { - const built = buildEnvelope(hookEventName, payload, hookmap); - return hmacSecret ? signEnvelope(built, hmacSecret) : built; - }; + // + // Stage "sign" follows it and fails closed: signing canonicalises the whole + // envelope, and input nested deeply enough overflows that. An unsigned + // request is one an ACS-Core Guardian must refuse, so this host refuses it + // rather than letting a `proceed` posture run it. + const buildRequest = (): AcsRequestEnvelope => buildEnvelope(hookEventName, payload, hookmap); + const signRequest = (built: AcsRequestEnvelope): AcsRequestEnvelope => + hmacSecret ? signEnvelope(built, hmacSecret) : built; let envelope: AcsRequestEnvelope; try { envelope = buildRequest(); } catch (failure) { return resolveByPosture(failure, "request", undefined); } + try { + envelope = signRequest(envelope); + } catch (failure) { + return resolveByPosture(failure, "sign", envelope); + } // Established here, before anything is asked of a Guardian or audited: at a // gate that sees what a step produced, a decision has to be able to @@ -760,7 +769,7 @@ export async function governStep({ // decision's expiry is measured from the retry, not the refused request. const retryTimeoutMs = renegotiated.timeout_config.default_ms; const retryStartedAt = performance.now(); - const retryEnvelope = buildRequest(); + const retryEnvelope = signRequest(buildRequest()); const retried = await guardian.requestDecision(retryEnvelope, { timeoutMs: retryTimeoutMs }); if (retried.decisionArrived) { session = { config: renegotiated, failure: renegotiationFailure }; diff --git a/reference-implementations/agt/packages/host-adapter/src/guardian-client.ts b/reference-implementations/agt/packages/host-adapter/src/guardian-client.ts index 575c060d..9b50def8 100644 --- a/reference-implementations/agt/packages/host-adapter/src/guardian-client.ts +++ b/reference-implementations/agt/packages/host-adapter/src/guardian-client.ts @@ -47,6 +47,7 @@ */ import type { AcsRequestEnvelope } from "./build-envelope.ts"; import type { AcsDecision } from "./decision-message.ts"; +import { REFUSAL_RPC_CODES } from "./failure-kinds.ts"; import { verifyResponseSignature } from "./sign-envelope.ts"; /** @@ -254,7 +255,19 @@ export function createGuardianClient(url: string, clientOptions: GuardianClientO clientOptions.hmacSecret, sessionId, ); - if (!verified.valid) { + // An unsigned refusal is still believed. The Guardian cannot sign some + // refusals at all -- an oversize body, a parse error, a request whose + // own signature failed -- because they come before any session key. + // Throwing here made them unclassifiable, so the posture answered and + // under `proceed` the step ran. Trusting one costs nothing: a refusal + // can only fail the step closed. Never with a `result` beside it, + // because requestDecision honours a result first and that would let an + // unsigned decision through. + const unsignedRefusal = + response.result === undefined && + typeof response.error?.code === "number" && + REFUSAL_RPC_CODES.has(response.error.code); + if (!verified.valid && !unsignedRefusal) { throw new GuardianSignatureError(verified.reason); } } diff --git a/reference-implementations/agt/packages/host-adapter/test/client.test.ts b/reference-implementations/agt/packages/host-adapter/test/client.test.ts index c8b580ad..c8cc20fe 100644 --- a/reference-implementations/agt/packages/host-adapter/test/client.test.ts +++ b/reference-implementations/agt/packages/host-adapter/test/client.test.ts @@ -237,6 +237,7 @@ describe("GuardianClient.post", () => { }); try { const envelope = signEnvelope(buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap), secret); + await handshakeFor(secured.url, envelope, hello => signEnvelope(hello as never, secret)); await expect( createGuardianClient(secured.url, { hmacSecret: Buffer.alloc(32, 0x66) }).post(envelope), ).rejects.toThrow(GuardianSignatureError); @@ -264,25 +265,51 @@ describe("GuardianClient.post", () => { } }); - it("rejects an unsigned same-id error when response authentication is configured", async () => { + it("passes an unsigned refusal through, because it can only fail the step closed", async () => { const secret = Buffer.alloc(32, 0x58); const mock = Bun.serve({ port: 0, async fetch(req) { const body = (await req.json()) as { id: string | number }; - return Response.json({ jsonrpc: "2.0", id: body.id, error: { code: -32020, message: "forged refusal" } }); + return Response.json({ jsonrpc: "2.0", id: body.id, error: { code: -32020, message: "unsigned refusal" } }); }, }); try { const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); - await expect( - createGuardianClient(`http://localhost:${mock.port}/acs`, { hmacSecret: secret }).post(envelope), - ).rejects.toThrow(GuardianSignatureError); + const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`, { hmacSecret: secret }) + .requestDecision(envelope); + expect(outcome.decisionArrived).toBe(false); + expect(classifyDeliveryFailure(outcome.decisionArrived ? undefined : outcome.failure).kind).toBe("refused"); } finally { mock.stop(true); } }); + it("still rejects an unsigned error whose code is not a refusal, and an unsigned refusal carrying a result", async () => { + const secret = Buffer.alloc(32, 0x5a); + const answers = [ + { error: { code: -32601, message: "not a refusal" } }, + { error: { code: -32020, message: "refusal" }, result: { decision: "allow" } }, + ]; + for (const answer of answers) { + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as { id: string | number }; + return Response.json({ jsonrpc: "2.0", id: body.id, ...answer }); + }, + }); + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + await expect( + createGuardianClient(`http://localhost:${mock.port}/acs`, { hmacSecret: secret }).post(envelope), + ).rejects.toThrow(GuardianSignatureError); + } finally { + mock.stop(true); + } + } + }); + it("rejects a colliding ping allow substituted for a signed tool decision", async () => { const secret = Buffer.alloc(32, 0x59); const secured = await startGuardian({ From 7f171b1e561a3924e2e1d6d4829dc1973a4e2bd9 Mon Sep 17 00:00:00 2001 From: Jason Crittenden Date: Wed, 14 Oct 2026 06:52:01 +0000 Subject: [PATCH 08/12] fix(agt): evaluate the MCP message that runs, and only string targets A protocols/MCP/tools/call payload carrying both tool/arguments and params was evaluated on the former while a forwarding server runs the latter. Both shapes together are now refused as envelope_invalid, and {value} is unwrapped only in the ACS shape. The stock rules skip a policy target that is not a string, so an object or array command was allowed on any tool-call path. The snapshot assembler now throws on one, which denies with evaluation_failed. Signed-off-by: Jason Crittenden --- .../guardian/src/assemble-snapshot.ts | 8 ++++ .../agt/packages/guardian/src/server.ts | 23 +++++++--- .../agt/packages/guardian/test/server.test.ts | 44 +++++++++++++++++++ 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/reference-implementations/agt/packages/guardian/src/assemble-snapshot.ts b/reference-implementations/agt/packages/guardian/src/assemble-snapshot.ts index 30499fd1..6729d921 100644 --- a/reference-implementations/agt/packages/guardian/src/assemble-snapshot.ts +++ b/reference-implementations/agt/packages/guardian/src/assemble-snapshot.ts @@ -241,6 +241,14 @@ export function assemblePreToolCallSnapshot( `(it sent: ${Object.keys(args).join(", ") || "none"})`, ); } + // The stock rules guard on is_string and skip anything else, so a command + // sent as an object or array would never be checked and would be allowed. + if (typeof args[policyTargetArgument] !== "string") { + throw new Error( + `tool ${JSON.stringify(payload.tool.name)}'s policy target argument ${JSON.stringify(policyTargetArgument)} ` + + `is not a string, so no rule can evaluate it`, + ); + } args[POLICY_TARGET_LEAF] = args[policyTargetArgument]; return { diff --git a/reference-implementations/agt/packages/guardian/src/server.ts b/reference-implementations/agt/packages/guardian/src/server.ts index de6d3f44..278725c3 100644 --- a/reference-implementations/agt/packages/guardian/src/server.ts +++ b/reference-implementations/agt/packages/guardian/src/server.ts @@ -1108,14 +1108,25 @@ async function dispatch( } // Wrapped MCP: protocols/MCP/tools/call is collapsed into the pre-tool-call - // gate per conformance.md. The payload may carry tool.name in the ACS shape - // or params.name in the native MCP shape; either way, the tool name reaches + // gate per conformance.md. The payload carries either tool.name in the ACS + // shape or params.name in the native MCP shape, and the tool name reaches // the same policy evaluation as steps/toolCallRequest. + // + // Never both. hooks.md has a wrapped method carry the MCP message intact, + // and a server that forwards it runs `params`. Evaluating `tool` and + // `arguments` beside it would decide one call while another ran. if (envelope.method === "protocols/MCP/tools/call") { const payload = envelope.params.payload as Record; const toolObj = payload.tool as Record | undefined; const mcpParams = payload.params as Record | undefined; - const toolName = (toolObj?.name as string) ?? (mcpParams?.name as string) ?? "mcp_tool"; + if (toolObj !== undefined && mcpParams !== undefined) { + const message = + "protocols/MCP/tools/call payload carries both the ACS shape (tool) and the MCP message (params); " + + "send exactly one"; + const denial = denyOnInvalidEnvelope(raw, { reasonCode: "envelope_invalid", message }); + return asDecisionResponse(rpcId, denial) ?? errorResponse(rpcId, ENVELOPE_INVALID_CODE, message); + } + const toolName = ((toolObj ?? mcpParams)?.name as string | undefined) ?? "mcp_tool"; const entry = appendContextEntry(sessionContextStore, envelope.params.metadata.session_id, { method: envelope.method, @@ -1131,13 +1142,15 @@ async function dispatch( // Normalize to the ACS tool-call-request shape the assembler expects. // Native MCP carries params.name and raw arguments; ACS carries - // tool.name and {value}-wrapped arguments. + // tool.name and {value}-wrapped arguments. Only the ACS shape is + // unwrapped: a native argument that happens to have a `value` key is + // the argument, and unwrapping it would evaluate part of it. const sourceArgs = (toolObj ? payload.arguments : mcpParams?.arguments) as Record | undefined ?? {}; const wrappedArgs: Record = {}; for (const [key, val] of Object.entries(sourceArgs)) { - wrappedArgs[key] = (typeof val === "object" && val !== null && "value" in val) + wrappedArgs[key] = toolObj && typeof val === "object" && val !== null && "value" in val ? (val as { value: unknown }) : { value: val }; } diff --git a/reference-implementations/agt/packages/guardian/test/server.test.ts b/reference-implementations/agt/packages/guardian/test/server.test.ts index 76ef57bb..d87ea9c8 100644 --- a/reference-implementations/agt/packages/guardian/test/server.test.ts +++ b/reference-implementations/agt/packages/guardian/test/server.test.ts @@ -2001,6 +2001,50 @@ describe("ACS-Core wire security and negotiation", () => { await guardian.close(); } }); + + it("refuses a wrapped MCP call that carries both shapes, rather than evaluating the one that does not run", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + const both = await postAcs( + guardian.url, + makeEnvelope("protocols/MCP/tools/call", { + tool: { name: "Bash" }, + arguments: { command: { value: "ls" } }, + params: { name: "Bash", arguments: { command: "rm -rf /" } }, + }), + ); + expect(both.result?.decision).toBe("deny"); + expect(both.result?.reason_codes).toEqual(["envelope_invalid"]); + } finally { + await guardian.close(); + } + }); + + it("evaluates a native MCP argument whole, and denies a policy target no rule can read", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + const smuggled = await postAcs( + guardian.url, + makeEnvelope("protocols/MCP/tools/call", { + params: { name: "Bash", arguments: { command: { value: "ls", cmd: "rm -rf /" } } }, + }), + ); + expect(smuggled.result?.decision).toBe("deny"); + expect(smuggled.result?.reason_codes).toEqual(["evaluation_failed"]); + + // The same hole without MCP: the stock rules skip a non-string target. + const objectCommand = await postAcs( + guardian.url, + makeEnvelope("steps/toolCallRequest", { + tool: { name: "Bash" }, + arguments: { command: { value: { cmd: "rm -rf /" } } }, + }), + ); + expect(objectCommand.result?.decision).toBe("deny"); + } finally { + await guardian.close(); + } + }); }); describe("replay protection expiry", () => { From 9a0bff802d67056b46575b5dc3c9928f8b5d7db2 Mon Sep 17 00:00:00 2001 From: Jason Crittenden Date: Thu, 15 Oct 2026 21:29:31 +0000 Subject: [PATCH 09/12] fix(agt): treat every server-error code as a refusal The host listed the refusal codes it knew, so a code added later, such as the retryable overload code Discussion #257 proposes, was classified error_without_decision and the proceed posture ran the step. The host now treats -32700 and the whole -32000..-32099 range as a refusal, both when it accepts an unsigned refusal and when it classifies one. Codes outside the range, like -32601 or -32603, still keep the posture. Signed-off-by: Jason Crittenden --- .../host-adapter/src/failure-kinds.ts | 47 ++++++------------- .../host-adapter/src/failure-posture.ts | 13 +++-- .../host-adapter/src/guardian-client.ts | 7 +-- .../packages/host-adapter/test/client.test.ts | 47 +++++++++++++++++++ .../host-adapter/test/failure-posture.test.ts | 21 +++++---- 5 files changed, 82 insertions(+), 53 deletions(-) diff --git a/reference-implementations/agt/packages/host-adapter/src/failure-kinds.ts b/reference-implementations/agt/packages/host-adapter/src/failure-kinds.ts index 202aa80b..d75aab56 100644 --- a/reference-implementations/agt/packages/host-adapter/src/failure-kinds.ts +++ b/reference-implementations/agt/packages/host-adapter/src/failure-kinds.ts @@ -43,9 +43,8 @@ * * `error_without_decision` is what is left of the JSON-RPC-error case once * the refusals are taken out of it: an error whose code this host does not - * recognise as a refusal of this envelope. See REFUSAL_RPC_CODES below - * for why the recognised set is enumerated rather than - * inferred, and for what that leaves in this bucket. + * recognise as a refusal of this envelope. See isRefusalCode below + * for which codes are recognised, and for what that leaves in this bucket. */ export type DeliveryFailureKind = "timeout" | "transport" | "error_without_decision" | "unknown"; @@ -174,10 +173,10 @@ export type SessionFailureKind = * applyFailurePosture). * * Declared here by value rather than imported: this package has no runtime - * dependency on the Guardian and is not about to grow one for four integers + * dependency on the Guardian and is not about to grow one for a few integers * -- the same arrangement DEFAULT_TIMEOUT_MS keeps with the Guardian's - * declared default. The four are the complete set this deployment's Guardian - * mints for a request it will not decide (packages/guardian/src/server.ts): + * declared default. Besides §17.1's own codes, these are the ones this + * deployment's Guardian mints for a request it will not decide (packages/guardian/src/server.ts): * * -32700 Parse error. Nothing the host sent was JSON. Answered with * `id: null`, since there is no envelope to read an id out of -- @@ -209,30 +208,14 @@ export type SessionFailureKind = * A host that read the second as the first would fail open on precisely the * disagreement it should surface. * - * Enumerated rather than inferred ("any error object is a refusal"), for the - * reason TRANSPORT_ERROR_CODES is: the four above are checkable against a - * live Guardian, and client.test.ts drives each route to prove the list - * matches what the Guardian actually sends rather than what this file - * believes. An unrecognised code therefore stays `error_without_decision` and - * keeps the posture. That is a genuine remaining hole -- a future Guardian - * code would fail open under `proceed` until it is added here -- and it is - * preferred to guessing, because widening this to every error object would - * also fail closed on an intermediary's error the Guardian never sent. + * Recognised by range, not by list. Every §17.1 code and every code this + * Guardian mints sits in JSON-RPC's server-error range, -32000 to -32099, which + * ACS uses for nothing but refusals. A list would fail open under `proceed` on + * any code added after it was written, such as a retryable overload code: the + * step would run because the host did not yet know the code. Codes outside + * the range, like an intermediary's -32601 or -32603, stay + * `error_without_decision` and keep the posture. */ -export const REFUSAL_RPC_CODES: ReadonlySet = new Set([ - -32700, - // ACS §17.1's complete mandated-refusal registry. None of these is a - // transport accident or permission to apply the availability posture. - -32000, - -32001, - -32002, - -32003, - -32004, - -32005, - -32006, - -32007, - // Reference-implementation refusal codes. - -32010, - -32011, - -32020, -]); +export function isRefusalCode(code: unknown): boolean { + return typeof code === "number" && (code === -32700 || (code <= -32000 && code >= -32099)); +} diff --git a/reference-implementations/agt/packages/host-adapter/src/failure-posture.ts b/reference-implementations/agt/packages/host-adapter/src/failure-posture.ts index b897fd38..07438422 100644 --- a/reference-implementations/agt/packages/host-adapter/src/failure-posture.ts +++ b/reference-implementations/agt/packages/host-adapter/src/failure-posture.ts @@ -18,7 +18,7 @@ * answering `-32020 evaluation failed` and a dead socket were the same kind, * and under the shipped default posture (`proceed`) both became `allow` -- * a governance tool proceeding on the Guardian's own "no". See - * REFUSAL_RPC_CODES for which codes mean that, and why the set is enumerated. + * isRefusalCode for which codes mean that. * * It is NOT reached when a decision arrived. A `deny` that arrives is * honoured regardless of posture, and that is enforced by the caller never @@ -49,7 +49,7 @@ import type { StepFailureKind, } from "./failure-kinds.ts"; import type { AcsDecision } from "./decision-message.ts"; -import { REFUSAL_RPC_CODES } from "./failure-kinds.ts"; +import { isRefusalCode } from "./failure-kinds.ts"; import { GuardianTimeoutError } from "./guardian-client.ts"; import { SessionConfigNotStoredError, type ResolvedSessionConfig } from "./handshake.ts"; @@ -153,10 +153,9 @@ export function classifyDeliveryFailure( const { code, message } = failure as ErrorLike; // An error object means something answered: the wire worked, and the // question is whether what answered was the Guardian refusing this - // envelope. `typeof code === "number"` is the guard, not a cast -- a - // non-conformant peer can put anything on `code`, and Set.has on a - // string that happens to read "-32020" would miss anyway. - const refused = typeof code === "number" && REFUSAL_RPC_CODES.has(code); + // envelope. isRefusalCode checks the type, not a cast: a + // non-conformant peer can put anything on `code`. + const refused = isRefusalCode(code); return { kind: refused ? "refused" : "error_without_decision", message: refused @@ -260,7 +259,7 @@ export function applyFailurePosture({ // Classified BEFORE the resolution is read, because for one kind it decides // the resolution. Everything else is the wire's business and the posture's; // a refusal is the Guardian's, and a governance tool does not proceed on it - // -- see REFUSAL_RPC_CODES and REFUSAL_RESOLUTION. + // -- see isRefusalCode and REFUSAL_RESOLUTION. const classified = classifyStepFailure(stage, failure); const refused = classified.kind === "refused"; const unsignable = classified.kind === "unsignable"; diff --git a/reference-implementations/agt/packages/host-adapter/src/guardian-client.ts b/reference-implementations/agt/packages/host-adapter/src/guardian-client.ts index 9b50def8..f00439fc 100644 --- a/reference-implementations/agt/packages/host-adapter/src/guardian-client.ts +++ b/reference-implementations/agt/packages/host-adapter/src/guardian-client.ts @@ -47,7 +47,7 @@ */ import type { AcsRequestEnvelope } from "./build-envelope.ts"; import type { AcsDecision } from "./decision-message.ts"; -import { REFUSAL_RPC_CODES } from "./failure-kinds.ts"; +import { isRefusalCode } from "./failure-kinds.ts"; import { verifyResponseSignature } from "./sign-envelope.ts"; /** @@ -263,10 +263,7 @@ export function createGuardianClient(url: string, clientOptions: GuardianClientO // can only fail the step closed. Never with a `result` beside it, // because requestDecision honours a result first and that would let an // unsigned decision through. - const unsignedRefusal = - response.result === undefined && - typeof response.error?.code === "number" && - REFUSAL_RPC_CODES.has(response.error.code); + const unsignedRefusal = response.result === undefined && isRefusalCode(response.error?.code); if (!verified.valid && !unsignedRefusal) { throw new GuardianSignatureError(verified.reason); } diff --git a/reference-implementations/agt/packages/host-adapter/test/client.test.ts b/reference-implementations/agt/packages/host-adapter/test/client.test.ts index c8cc20fe..92d1d621 100644 --- a/reference-implementations/agt/packages/host-adapter/test/client.test.ts +++ b/reference-implementations/agt/packages/host-adapter/test/client.test.ts @@ -285,6 +285,53 @@ describe("GuardianClient.post", () => { } }); + it("denies an unsigned overload code no list knows yet under a proceed posture", async () => { + // -32008 is Discussion #257's proposed retryable overload code. It has + // to pass the unsigned-refusal check AND classify as refused, or the + // step falls through to `proceed`. + const secret = Buffer.alloc(32, 0x5b); + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as { id: string | number }; + return Response.json({ + jsonrpc: "2.0", + id: body.id, + error: { code: -32008, message: "guardian overloaded", data: { retry_after_ms: 1000 } }, + }); + }, + }); + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap); + const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`, { hmacSecret: secret }) + .requestDecision(envelope); + expect(outcome.decisionArrived).toBe(false); + const events: AuditEvent[] = []; + const decision = applyFailurePosture({ + failure: outcome.decisionArrived ? undefined : outcome.failure, + session: { + config: { + negotiated_version: "0.1.0", + methods_evaluated: ["steps/toolCallRequest"], + selected_transport: "http", + timeout_config: { default_ms: 5000 }, + on_decision_failure: "proceed", + }, + failure: undefined, + }, + sessionId: "sess-1", + method: envelope.method, + rpcId: envelope.id, + audit: { path: "test", write: (e) => (events.push(e), true) }, + }); + expect(decision.decision).toBe("deny"); + expect(decision.reason_codes).toEqual(["guardian_refused"]); + expect(events[0]).toMatchObject({ posture: "proceed", outcome: "blocked", failure: { kind: "refused" } }); + } finally { + mock.stop(true); + } + }); + it("still rejects an unsigned error whose code is not a refusal, and an unsigned refusal carrying a result", async () => { const secret = Buffer.alloc(32, 0x5a); const answers = [ diff --git a/reference-implementations/agt/packages/host-adapter/test/failure-posture.test.ts b/reference-implementations/agt/packages/host-adapter/test/failure-posture.test.ts index 8be6938b..696a054c 100644 --- a/reference-implementations/agt/packages/host-adapter/test/failure-posture.test.ts +++ b/reference-implementations/agt/packages/host-adapter/test/failure-posture.test.ts @@ -406,16 +406,19 @@ describe("classifyDeliveryFailure — what came back instead of a decision", () .toBe("transport"); }); - // An error code this host does not recognise as a refusal. It stays a - // delivery failure and keeps the posture, which is the deliberate limit of - // the enumerated refusal set: the shipped Guardian mints none of these - // today, but an intermediary or a non-conformant peer can, and guessing - // that every error object is the Guardian refusing would fail closed on an - // error the Guardian never sent. - it("classifies an error response whose code it does not recognise as a refusal", () => { - const { kind, message } = classifyDeliveryFailure({ code: -32601, message: "Method not found" }); + // An error code outside the server-error range. It stays a delivery + // failure and keeps the posture: an intermediary or a non-conformant peer + // can send one, and the Guardian's refusals all sit inside the range. + it.each([-32601, -32603, -32100, -31999])("keeps %i, outside the server-error range, a delivery failure", (code) => { + const { kind, message } = classifyDeliveryFailure({ code, message: "not a refusal" }); expect(kind).toBe("error_without_decision"); - expect(message).toContain("-32601"); + expect(message).toContain(String(code)); + }); + + // A code no list here knows yet, such as the retryable overload code + // Discussion #257 proposes. A list would let it fall through to `proceed`. + it.each([-32008, -32050, -32099])("classifies the not-yet-registered %i as a refusal by its range", (code) => { + expect(classifyDeliveryFailure({ code, message: "server error" }).kind).toBe("refused"); }); // The codes ACS and this deployment reserve for a request the Guardian will not From d1f883890ac1d7d7a7b6115e6af86ca7a53d6c8b Mon Sep 17 00:00:00 2001 From: Jason Crittenden Date: Fri, 16 Oct 2026 16:28:56 +0000 Subject: [PATCH 10/12] fix(agt): handshake the trace exporter's tests and sample, and list the MCP method handshake/hello, which #229's Guardian refuses with -32003. postRaw and the generator now send one first; the hello gets no span. protocols/MCP/tools/call is evaluated but otel-mapping.json maps only steps/* and agbom/* methods, so it is named in METHODS_WITHOUT_SPAN with that reason until the spec gives it a step_to_span entry. The decision event now carries acs.evaluator, and the response carries a chain_hash that covers each entry's recorded time. The two tests that compare a Guardian with export on and off give both the same clock, and the sample comparison sets the hash's value aside with the timestamps. The committed sample is regenerated. Signed-off-by: Jason Crittenden --- .../agt/packages/guardian/src/otel-mapping.ts | 5 ++++- .../guardian/test/otel-trace-exporter.test.ts | 22 +++++++++++++------ .../agt/samples/trace/acs-trace-sample.jsonl | 4 ++-- .../agt/scripts/make-sample-trace.ts | 14 ++++++++++++ .../agt/test/sample-trace.test.ts | 3 +++ 5 files changed, 38 insertions(+), 10 deletions(-) diff --git a/reference-implementations/agt/packages/guardian/src/otel-mapping.ts b/reference-implementations/agt/packages/guardian/src/otel-mapping.ts index 05d9443b..30db7053 100644 --- a/reference-implementations/agt/packages/guardian/src/otel-mapping.ts +++ b/reference-implementations/agt/packages/guardian/src/otel-mapping.ts @@ -112,7 +112,10 @@ type StepSpanEntry = { * a new method cannot quietly go untraced: it is either mapped or named * with its reason. */ -export const METHODS_WITHOUT_SPAN: Readonly> = {}; +export const METHODS_WITHOUT_SPAN: Readonly> = { + "protocols/MCP/tools/call": + "otel-mapping.json maps only steps/* and agbom/* methods; a span for a protocol method needs a step_to_span entry in the spec first", +}; /** otel-mapping.json is a JSON Schema whose data lives inside `default` * keys, the same three places `packages/conformance/src/trace-pillar.ts` diff --git a/reference-implementations/agt/packages/guardian/test/otel-trace-exporter.test.ts b/reference-implementations/agt/packages/guardian/test/otel-trace-exporter.test.ts index 0d69ca9d..c67e7247 100644 --- a/reference-implementations/agt/packages/guardian/test/otel-trace-exporter.test.ts +++ b/reference-implementations/agt/packages/guardian/test/otel-trace-exporter.test.ts @@ -10,6 +10,8 @@ import { startGuardian, type GuardianSnapshot } from "../src/index.ts"; import { FAILURE_DETAIL_BY_KIND } from "../src/audit-to-otlp.ts"; import { buildServerHello } from "../src/handshake.ts"; import { makeEnvelope, toolCallEnvelope } from "./helpers/envelopes.ts"; +import { createMemorySessionContextStore, type SessionContextStore } from "../src/session-context-store.ts"; +import { handshakeFor } from "../../../test/helpers/handshake.ts"; import { createFileSpanExporter, keepLoopbackOffProxy, @@ -72,12 +74,19 @@ function throwingSpanExporter(): SpanExporter { } async function postRaw(url: string, body: unknown): Promise> { + await handshakeFor(url, body); const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); return (await res.json()) as Record; } const baseOptions = { port: 0, manifestPath: "policy/manifest.yaml" } as const; +/** The response carries `chain_hash`, which covers each entry's recorded time, + * so two Guardians answer identically only on the same clock. */ +function frozenClock(): { sessionContextStore: SessionContextStore } { + return { sessionContextStore: createMemorySessionContextStore({ now: () => new Date(0) }) }; +} + /** The OTLP/JSON `ExportTraceServiceRequest` shape, as far as these tests * read it: resource attributes, and span names and events. */ type OtlpAttribute = { key: string; value: { stringValue?: string; intValue?: string | number } }; @@ -639,9 +648,8 @@ describe("Guardian trace export wiring", () => { const [event] = span?.events ?? []; expect(event?.name).toBe(MAPPING.decisionEventName); expect(event?.attributes?.["acs.decision"]).toBe("deny"); - // This Guardian sends metadata.evaluator once #229 lands; until then - // the event carries none, rather than one the exporter made up. - expect(event?.attributes?.["acs.evaluator"]).toBeUndefined(); + // Taken from the response's metadata.evaluator, not made up by the exporter. + expect(event?.attributes?.["acs.evaluator"]).toBe("deterministic"); expect(typeof event?.attributes?.["acs.reasoning"]).toBe("string"); expect(Array.isArray(event?.attributes?.["acs.reason_codes"])).toBe(true); expect(Array.isArray(event?.attributes?.["acs.policy.ids"])).toBe(true); @@ -794,8 +802,8 @@ describe("Guardian trace export wiring", () => { it("answers identically with a failing exporter and with export off, and reports once", async () => { const errorSpy = spyOn(console, "error").mockImplementation(() => {}); const exporter = createOtelTraceExporter({ spanExporter: throwingSpanExporter(), methodsEvaluated: METHODS_EVALUATED }); - const exporting = await startGuardian({ ...baseOptions, traceExporter: exporter }); - const silent = await startGuardian(baseOptions); + const exporting = await startGuardian({ ...baseOptions, ...frozenClock(), traceExporter: exporter }); + const silent = await startGuardian({ ...baseOptions, ...frozenClock() }); try { const requestId = crypto.randomUUID(); const sessionId = crypto.randomUUID(); @@ -995,8 +1003,8 @@ describe("Guardian trace export wiring", () => { methodsEvaluated: METHODS_EVALUATED, onError: (error) => errors.push(error), }); - const exporting = await startGuardian({ ...baseOptions, traceExporter: exporter }); - const silent = await startGuardian(baseOptions); + const exporting = await startGuardian({ ...baseOptions, ...frozenClock(), traceExporter: exporter }); + const silent = await startGuardian({ ...baseOptions, ...frozenClock() }); let exportingClosed = false; try { // Decisions are the same as with export off, before the endpoint dies. diff --git a/reference-implementations/agt/samples/trace/acs-trace-sample.jsonl b/reference-implementations/agt/samples/trace/acs-trace-sample.jsonl index 437a8f22..9e6cca97 100644 --- a/reference-implementations/agt/samples/trace/acs-trace-sample.jsonl +++ b/reference-implementations/agt/samples/trace/acs-trace-sample.jsonl @@ -1,2 +1,2 @@ -{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"acs-reference-guardian"}}],"droppedAttributesCount":0},"scopeSpans":[{"scope":{"name":"acs-reference-guardian"},"spans":[{"traceId":"9785c34288d27dbd92fcf12e53ac3315","spanId":"31258fb4b343b292","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1792065946937000000","endTimeUnixNano":"1792065947036723875","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b01"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}},{"key":"acs.capability","value":{"stringValue":"process.exec"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.decision","value":{"stringValue":"allow"}}],"name":"acs.decision","timeUnixNano":"1792065947036676541","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":0},"links":[],"droppedLinksCount":0,"flags":257},{"traceId":"112037a108c3893225309fa193536b77","spanId":"46883c95863bb8fb","name":"gen_ai.tool.result","kind":1,"startTimeUnixNano":"1792065947038000000","endTimeUnixNano":"1792065947088187667","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b02"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}},{"key":"acs.exit_status","value":{"stringValue":"success"}},{"key":"acs.request_id_ref","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b01"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.decision","value":{"stringValue":"allow"}}],"name":"acs.decision","timeUnixNano":"1792065947088182292","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":0},"links":[],"droppedLinksCount":0,"flags":257},{"traceId":"981a929f7cea4c999642a38dd3d822b1","spanId":"3b199edb6364b94d","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1792065947089000000","endTimeUnixNano":"1792065947141424750","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b03"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}},{"key":"acs.provenance.origin","value":{"arrayValue":{"values":[{"stringValue":"user_input"},{"stringValue":"agent_generated"}]}}},{"key":"acs.provenance.source_id","value":{"arrayValue":{"values":[{"stringValue":"sample-user-1"},{"stringValue":""}]}}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.decision","value":{"stringValue":"deny"}},{"key":"acs.reasoning","value":{"stringValue":"This command was blocked because it matches a destructive-shell-command pattern. Policy: destructive_shell_command_blocked, from AGT's stock bundle (agt_stock). Matched at offset 0."}},{"key":"acs.reason_codes","value":{"arrayValue":{"values":[{"stringValue":"destructive_shell_command_blocked"}]}}},{"key":"acs.policy.ids","value":{"arrayValue":{"values":[{"stringValue":"agt_stock"}]}}},{"key":"acs.policy.versions","value":{"arrayValue":{"values":[{"stringValue":""}]}}},{"key":"acs.policy.rule_ids","value":{"arrayValue":{"values":[{"stringValue":"destructive_shell_command_blocked"}]}}}],"name":"acs.decision","timeUnixNano":"1792065947141419958","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":0},"links":[],"droppedLinksCount":0,"flags":257},{"traceId":"95c92494c004b23662900ae8c17ea04c","spanId":"02d46f81d8206a0f","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1792065947141000000","endTimeUnixNano":"1792065947141270000","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b04"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.failure.class","value":{"stringValue":"guardian_error"}},{"key":"acs.failure.posture","value":{"stringValue":"fail_open"}},{"key":"acs.failure.reported_by","value":{"stringValue":"guardian"}},{"key":"acs.failure.error_code","value":{"intValue":-32010}},{"key":"acs.failure.elapsed_ms","value":{"intValue":0}},{"key":"acs.failure.payload_bytes","value":{"intValue":340}}],"name":"acs.decision.failure","timeUnixNano":"1792065947141268125","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":2,"message":"The Guardian returned an error instead of a decision."},"links":[],"droppedLinksCount":0,"flags":257},{"traceId":"e5703a6e87d75324de28fd7cc555724b","spanId":"e813670f52f6a811","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1792065947142000000","endTimeUnixNano":"1792065949203024291","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b05"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.decision","value":{"stringValue":"allow"}}],"name":"acs.decision","timeUnixNano":"1792065949202988875","droppedAttributesCount":0},{"attributes":[{"key":"acs.failure.class","value":{"stringValue":"timeout"}},{"key":"acs.failure.posture","value":{"stringValue":"fail_open"}},{"key":"acs.failure.reported_by","value":{"stringValue":"guardian"}},{"key":"acs.failure.timeout_ms","value":{"intValue":1500}},{"key":"acs.failure.elapsed_ms","value":{"intValue":2061}},{"key":"acs.failure.payload_bytes","value":{"intValue":367}}],"name":"acs.decision.failure","timeUnixNano":"1792065949203022666","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":0},"links":[],"droppedLinksCount":0,"flags":257}]}]}]} -{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"acs-reference-audit-converter"}}],"droppedAttributesCount":0},"scopeSpans":[{"scope":{"name":"acs-reference-audit-converter"},"spans":[{"traceId":"9b082079ac3695104b2da1df27340e16","spanId":"99e8d283763d16d3","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1792065949205000000","endTimeUnixNano":"1792065949205000000","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b06"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.failure.class","value":{"stringValue":"unreachable"}},{"key":"acs.failure.posture","value":{"stringValue":"fail_open"}},{"key":"acs.failure.reported_by","value":{"stringValue":"observed_agent"}},{"key":"acs.failure.detail","value":{"stringValue":"The host could not reach the Guardian."}}],"name":"acs.decision.failure","timeUnixNano":"1792065949205000000","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":2,"message":"The host could not reach the Guardian."},"links":[],"droppedLinksCount":0,"flags":257}]}]}]} +{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"acs-reference-guardian"}}],"droppedAttributesCount":0},"scopeSpans":[{"scope":{"name":"acs-reference-guardian"},"spans":[{"traceId":"00b07bed967c3e8c6c2efa388c034f21","spanId":"abd45acbcd813136","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1792168061331000000","endTimeUnixNano":"1792168061390895135","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b01"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}},{"key":"acs.capability","value":{"stringValue":"process.exec"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.decision","value":{"stringValue":"allow"}},{"key":"acs.evaluator","value":{"stringValue":"deterministic"}},{"key":"acs.chain_hash","value":{"stringValue":"e373874e59fa459fe2dda940484aaa24a398fde93b0080392bbdea9e1fb15a2d"}}],"name":"acs.decision","timeUnixNano":"1792168061390776428","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":0},"links":[],"droppedLinksCount":0,"flags":257},{"traceId":"3a51428a9b0e149d1d36462fff306ff5","spanId":"3d02493d14f2eef8","name":"gen_ai.tool.result","kind":1,"startTimeUnixNano":"1792168061392000000","endTimeUnixNano":"1792168061447388933","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b02"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}},{"key":"acs.exit_status","value":{"stringValue":"success"}},{"key":"acs.request_id_ref","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b01"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.decision","value":{"stringValue":"allow"}},{"key":"acs.evaluator","value":{"stringValue":"deterministic"}},{"key":"acs.chain_hash","value":{"stringValue":"c02a175b64f419c6b1961583b43933365cd7f4fec8a4a533d3c1fad9ebabb123"}}],"name":"acs.decision","timeUnixNano":"1792168061447376570","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":0},"links":[],"droppedLinksCount":0,"flags":257},{"traceId":"fec4863f765aa75f58d6b73a73fb1452","spanId":"c9ebadffc7ce3b81","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1792168061449000000","endTimeUnixNano":"1792168061501907988","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b03"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}},{"key":"acs.provenance.origin","value":{"arrayValue":{"values":[{"stringValue":"user_input"},{"stringValue":"agent_generated"}]}}},{"key":"acs.provenance.source_id","value":{"arrayValue":{"values":[{"stringValue":"sample-user-1"},{"stringValue":""}]}}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.decision","value":{"stringValue":"deny"}},{"key":"acs.evaluator","value":{"stringValue":"deterministic"}},{"key":"acs.reasoning","value":{"stringValue":"This command was blocked because it matches a destructive-shell-command pattern. Policy: destructive_shell_command_blocked, from AGT's stock bundle (agt_stock). Matched at offset 0."}},{"key":"acs.reason_codes","value":{"arrayValue":{"values":[{"stringValue":"destructive_shell_command_blocked"}]}}},{"key":"acs.policy.ids","value":{"arrayValue":{"values":[{"stringValue":"agt_stock"}]}}},{"key":"acs.policy.versions","value":{"arrayValue":{"values":[{"stringValue":""}]}}},{"key":"acs.policy.rule_ids","value":{"arrayValue":{"values":[{"stringValue":"destructive_shell_command_blocked"}]}}},{"key":"acs.chain_hash","value":{"stringValue":"b169efd600a813f5bc104a46626934f436d2066dfeb30018de9e2676f9482351"}}],"name":"acs.decision","timeUnixNano":"1792168061501896967","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":0},"links":[],"droppedLinksCount":0,"flags":257},{"traceId":"d48ea780c830d269b25bc7881d42c99f","spanId":"783970ceaa410942","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1792168061502000000","endTimeUnixNano":"1792168061502943636","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b04"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.failure.class","value":{"stringValue":"guardian_error"}},{"key":"acs.failure.posture","value":{"stringValue":"fail_open"}},{"key":"acs.failure.reported_by","value":{"stringValue":"guardian"}},{"key":"acs.failure.error_code","value":{"intValue":-32010}},{"key":"acs.failure.elapsed_ms","value":{"intValue":1}},{"key":"acs.failure.payload_bytes","value":{"intValue":340}}],"name":"acs.decision.failure","timeUnixNano":"1792168061502936050","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":2,"message":"The Guardian returned an error instead of a decision."},"links":[],"droppedLinksCount":0,"flags":257},{"traceId":"1676d7be901aa56b3bae2d90fe34e51f","spanId":"ab3ba1737d9e9c30","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1792168061504000000","endTimeUnixNano":"1792168063555927584","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b05"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.decision","value":{"stringValue":"allow"}},{"key":"acs.evaluator","value":{"stringValue":"deterministic"}},{"key":"acs.chain_hash","value":{"stringValue":"05a3fb216e998bc18df9f217292bc6e4547580dad0a53fc1702bd6f69ca31497"}}],"name":"acs.decision","timeUnixNano":"1792168063555866997","droppedAttributesCount":0},{"attributes":[{"key":"acs.failure.class","value":{"stringValue":"timeout"}},{"key":"acs.failure.posture","value":{"stringValue":"fail_open"}},{"key":"acs.failure.reported_by","value":{"stringValue":"guardian"}},{"key":"acs.failure.timeout_ms","value":{"intValue":1500}},{"key":"acs.failure.elapsed_ms","value":{"intValue":2052}},{"key":"acs.failure.payload_bytes","value":{"intValue":367}}],"name":"acs.decision.failure","timeUnixNano":"1792168063555924076","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":0},"links":[],"droppedLinksCount":0,"flags":257}]}]}]} +{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"acs-reference-audit-converter"}}],"droppedAttributesCount":0},"scopeSpans":[{"scope":{"name":"acs-reference-audit-converter"},"spans":[{"traceId":"1bdc915800255c3436795458b1634a14","spanId":"fc0fc1188ae124de","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1792168063559000000","endTimeUnixNano":"1792168063559000000","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b06"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.failure.class","value":{"stringValue":"unreachable"}},{"key":"acs.failure.posture","value":{"stringValue":"fail_open"}},{"key":"acs.failure.reported_by","value":{"stringValue":"observed_agent"}},{"key":"acs.failure.detail","value":{"stringValue":"The host could not reach the Guardian."}}],"name":"acs.decision.failure","timeUnixNano":"1792168063559000000","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":2,"message":"The host could not reach the Guardian."},"links":[],"droppedLinksCount":0,"flags":257}]}]}]} diff --git a/reference-implementations/agt/scripts/make-sample-trace.ts b/reference-implementations/agt/scripts/make-sample-trace.ts index de4d5e4a..616d059e 100644 --- a/reference-implementations/agt/scripts/make-sample-trace.ts +++ b/reference-implementations/agt/scripts/make-sample-trace.ts @@ -83,6 +83,20 @@ function bash(command: string, extra: Record = {}): Record[] { return [ + // §4: the Guardian answers a session's steps only after its hello declares + // them. The hello gets no span. + envelope( + "handshake/hello", + "1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b00", + { + acs_versions_supported: ["0.1.0"], + methods_implemented: ["steps/toolCallRequest", "steps/toolCallResult"], + transports_supported: ["http"], + provenance_producer: "none", + profiles_supported: ["acs-core"], + }, + 0, + ), envelope("steps/toolCallRequest", requestIdOf("allowed"), bash("ls -la", { capability: "process.exec" }), 1), envelope( "steps/toolCallResult", diff --git a/reference-implementations/agt/test/sample-trace.test.ts b/reference-implementations/agt/test/sample-trace.test.ts index dbc86ab5..14602386 100644 --- a/reference-implementations/agt/test/sample-trace.test.ts +++ b/reference-implementations/agt/test/sample-trace.test.ts @@ -75,6 +75,9 @@ function normalize(spans: LabeledSpan[]): unknown[] { status: span.status ?? {}, events: (span.events ?? []).map((event) => { const { "acs.failure.elapsed_ms": _elapsed, ...rest } = attributes(event); + // The chain hash covers each entry's recorded time, so its value is set + // aside with the timestamps; that it is present is still compared. + if ("acs.chain_hash" in rest) rest["acs.chain_hash"] = ""; return { name: event.name, attributes: rest }; }), })) From bc68cdf22b3b71ef7dc4319b8fd17f45d72387fd Mon Sep 17 00:00:00 2001 From: Jason Crittenden Date: Fri, 16 Oct 2026 19:04:09 +0000 Subject: [PATCH 11/12] docs(trace): copy the regenerated denied span into the OpenTelemetry example The sample trace was regenerated under #229's Guardian, whose decision event now carries acs.evaluator and acs.chain_hash. The example on the page is checked against the sample, so it is copied again, and the sentence that lists the event's contents names both. Signed-off-by: Jason Crittenden --- docs/spec/trace/extend_opentelemetry.md | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/spec/trace/extend_opentelemetry.md b/docs/spec/trace/extend_opentelemetry.md index dfb27c04..6cf38e13 100644 --- a/docs/spec/trace/extend_opentelemetry.md +++ b/docs/spec/trace/extend_opentelemetry.md @@ -20,16 +20,16 @@ This hierarchy is deterministic — implementations that follow the mapping prod This is one span the reference Guardian exported, a `steps/toolCallRequest` the stock policy denied, copied from the committed [sample trace](https://github.com/GenAI-Security-Project/agent-control-standard/blob/main/reference-implementations/agt/samples/trace/acs-trace-sample.jsonl). Every value in it is synthetic. It's the OTLP/JSON shape the exporter writes, so attribute values are typed (`stringValue`, `arrayValue`). -The span carries the join pair (`acs.session.id` and `acs.request_id`), the tool name, and the two Provenance columns. `acs.provenance.source_id` has `""` where the second object has no `source_id`. The decision rides on the span as the `acs.decision` event, with the verdict, the reasoning, the reason codes and the three policy arrays. `acs.policy.versions` is `[""]`, because this Guardian doesn't send a policy version. +The span carries the join pair (`acs.session.id` and `acs.request_id`), the tool name, and the two Provenance columns. `acs.provenance.source_id` has `""` where the second object has no `source_id`. The decision rides on the span as the `acs.decision` event, with the verdict, the evaluator, the reasoning, the reason codes, the three policy arrays and the chain hash. `acs.policy.versions` is `[""]`, because this Guardian doesn't send a policy version. ```json { - "traceId": "85c4878c9bda9e5b4f2fc6aae242d31f", - "spanId": "dea6530ad1681c0e", + "traceId": "fec4863f765aa75f58d6b73a73fb1452", + "spanId": "c9ebadffc7ce3b81", "name": "gen_ai.tool.call", "kind": 1, - "startTimeUnixNano": "1791928279698000000", - "endTimeUnixNano": "1791928279749301083", + "startTimeUnixNano": "1792168061449000000", + "endTimeUnixNano": "1792168061501907988", "attributes": [ { "key": "acs.session.id", @@ -90,6 +90,12 @@ The span carries the join pair (`acs.session.id` and `acs.request_id`), the tool "stringValue": "deny" } }, + { + "key": "acs.evaluator", + "value": { + "stringValue": "deterministic" + } + }, { "key": "acs.reasoning", "value": { @@ -143,10 +149,16 @@ The span carries the join pair (`acs.session.id` and `acs.request_id`), the tool ] } } + }, + { + "key": "acs.chain_hash", + "value": { + "stringValue": "b169efd600a813f5bc104a46626934f436d2066dfeb30018de9e2676f9482351" + } } ], "name": "acs.decision", - "timeUnixNano": "1791928279749295500", + "timeUnixNano": "1792168061501896967", "droppedAttributesCount": 0 } ], From 8fdaf5cad692719989ad559343da15649dc7c052 Mon Sep 17 00:00:00 2001 From: Jason Crittenden Date: Wed, 21 Oct 2026 03:10:35 +0000 Subject: [PATCH 12/12] fix(agt): name the HMAC secret by a configured key_id PR #272 defines key_id for HMAC as an opaque label from the deployment's configuration that names one secret. This tree used the session_id as key_id and rejected any other value. The Guardian now requires ACS_HMAC_KEY_ID beside ACS_HMAC_SECRET, with no default, and verifies a request with the secret its key_id names. An unknown label is SIGNATURE_INVALID, unsigned, and is never tried against another secret. A response is signed with the secret, and names the key_id, of the request it answers. Both hosts read the same pair, sign with the configured label, and reject a response that names another. Setting only one of the two is a configuration error. The OpenCode plugin raises it from each hook rather than from its factory, because a factory throw unloads the plugin and leaves every tool ungoverned. Signed-off-by: Jason Crittenden --- reference-implementations/agt/README.md | 16 ++- .../agt/hosts/claude-code/README.md | 3 +- .../agt/hosts/claude-code/acs-hook.ts | 11 +- .../hosts/claude-code/test/posture.test.ts | 8 +- .../agt/hosts/opencode/README.md | 3 +- .../agt/hosts/opencode/acs-plugin.ts | 33 +++-- .../hosts/opencode/test/acs-plugin.test.ts | 21 ++++ .../agt/packages/guardian/README.md | 3 + .../agt/packages/guardian/src/main.ts | 7 ++ .../agt/packages/guardian/src/server.ts | 113 ++++++++++-------- .../agt/packages/guardian/test/server.test.ts | 45 +++++-- .../packages/host-adapter/src/govern-step.ts | 13 +- .../host-adapter/src/guardian-client.ts | 13 +- .../packages/host-adapter/src/handshake.ts | 16 +-- .../agt/packages/host-adapter/src/index.ts | 2 + .../host-adapter/src/sign-envelope.ts | 41 +++++-- .../packages/host-adapter/test/client.test.ts | 89 +++++++++++--- .../agt/test/readme-captures.test.ts | 1 + 18 files changed, 301 insertions(+), 137 deletions(-) diff --git a/reference-implementations/agt/README.md b/reference-implementations/agt/README.md index a01e22ad..44c8362a 100644 --- a/reference-implementations/agt/README.md +++ b/reference-implementations/agt/README.md @@ -75,7 +75,7 @@ Each part has its own README that says how to start it. | Five dispositions | `allow`, `deny`, `modify`, and `ask` are emitted as schema-valid decisions. Both hosts validate and safely render `defer`, but no pinned AGT verdict maps to it. | | SessionContext with published `chain_hash` | The Guardian keeps a per-session chain using ACS v0.1's JCS-content-plus-raw-predecessor algorithm, writes it to a log, and publishes `chain_hash` on every response for a chain-recorded step, including denial responses when evaluation throws. | | Replay protection | Implemented. Duplicate `request_id` values within a session are rejected (`-32005`) from a replay store independent of the bounded audit chain. Timestamps outside the negotiated `skew_window_ms` (default 5 minutes) are rejected (`-32006`). Only the liveness ping is exempt. Open Core gap: the replay store is held in memory, so a Guardian restart forgets it and accepts a replay of a request whose timestamp is still inside the window. | -| Baseline HMAC-SHA256 signature | Implemented. The runnable Guardian requires `ACS_HMAC_SECRET`, derives per-session HMAC keys via HKDF (§10), verifies requests using JCS canonicalization, rejects unsigned requests with `-32004`, and signs every addressable non-ping result or error. Both shipped hosts sign requests—including ClientHello—and verify non-ping response signatures. `system/ping` is signature-exempt in both directions. | +| Baseline HMAC-SHA256 signature | Implemented. The runnable Guardian requires `ACS_HMAC_SECRET`, derives per-session HMAC keys via HKDF (§10), looks the secret up by the request's `key_id` and requires `ACS_HMAC_KEY_ID` to name it, verifies requests using JCS canonicalization, rejects unsigned requests with `-32004`, and signs every addressable non-ping result or error. Both shipped hosts sign requests—including ClientHello—and verify non-ping response signatures. `system/ping` is signature-exempt in both directions. | | Decision honoring and `on_decision_failure` | Implemented on both hosts. Every fail-open proceed is written to the audit log. | | Liveness, `system/ping` | Implemented. Always returns `allow` with an echo of the client's payload and a server timestamp. No chain entry, no signature required. | | Wrapped MCP, `protocols/MCP/tools/call` | Implemented. It extracts the tool name from the ACS or native MCP payload shape, records a chain entry, evaluates against the pre-tool-call policy, and fails closed on evaluation errors. Declared in `methods_evaluated`. Other `protocols/MCP/*` methods are not claimed. | @@ -109,10 +109,11 @@ This installs the AGT SDK at the pinned version and the OPA binary it bundles. T Open a terminal at `reference-implementations/agt`. Start the Guardian. -First provide at least 32 bytes of base64-encoded key material. Keep this value in the environment of the Guardian and either host: +First provide at least 32 bytes of base64-encoded key material and a label that names it. The label is the `key_id` every signature carries (§10), so it is any name you choose, such as the month the secret was made. Keep both values in the environment of the Guardian and either host: ```bash export ACS_HMAC_SECRET="$(openssl rand -base64 32)" +export ACS_HMAC_KEY_ID="2026-10" ``` ```bash @@ -129,10 +130,11 @@ Signature verification: HMAC-SHA256 (required) Leave it running. The Guardian constructs the AGT runtime once against `policy/lib` and serves ACS on `POST /acs`. The [Guardian README](packages/guardian/README.md) lists what it reads and what it writes. -Every host process must receive that same value. In each terminal where you start Claude Code or OpenCode, export the copied base64 value first: +Every host process must receive those same values. In each terminal where you start Claude Code or OpenCode, export the copied base64 value and its label first: ```bash export ACS_HMAC_SECRET='' +export ACS_HMAC_KEY_ID='' ``` ### Start the Inspector @@ -163,7 +165,7 @@ cp hosts/claude-code/settings.json .claude/settings.json This registers `hosts/claude-code/acs-hook.ts` on two Claude Code hook events. `PreToolUse` fires for the `Bash` and `WebFetch` tools, before the tool runs. `PostToolUse` fires for the `Bash` tool, after the tool runs. Both matchers are anchored, so no other tool is intercepted. The shipped file names the hook through `$CLAUDE_PROJECT_DIR`, which resolves when the project is this tree. From any other project, replace that variable with this tree's absolute path. The [Claude Code host README](hosts/claude-code/README.md) has the details. -2. Confirm this terminal has the same `ACS_HMAC_SECRET` as the Guardian, then start Claude Code. +2. Confirm this terminal has the same `ACS_HMAC_SECRET` and `ACS_HMAC_KEY_ID` as the Guardian, then start Claude Code. ```bash claude @@ -189,7 +191,7 @@ The same Guardian governs OpenCode at the same time. Do not restart anything. } ``` -2. Confirm this terminal has the same `ACS_HMAC_SECRET` as the Guardian, then start `opencode` from that project directory. +2. Confirm this terminal has the same `ACS_HMAC_SECRET` and `ACS_HMAC_KEY_ID` as the Guardian, then start `opencode` from that project directory. The plugin hooks `tool.execute.before` for the `bash` and `webfetch` tools and `tool.execute.after` for the `bash` tool. A deny before the tool runs is a thrown error that carries the Guardian's reason. A deny after the tool runs replaces the result object, because OpenCode discards a throwing hook's changes. The [OpenCode host README](hosts/opencode/README.md) shows how to drive OpenCode against a local stub model with no paid account. @@ -570,8 +572,9 @@ The quickstart requires one shared secret in the Guardian and host environments; | `ACS_AUDIT_LOG` | `.acs/audit.jsonl` | Where fail-open proceeds and posture-driven blocks are written | | `ACS_HOOKMAP_PATH` | `hosts/claude-code/claude-code.hookmap.yaml` | Which hookmap to load. This changes what governance means for the host. Leave it unset in a deployment | | `ACS_HMAC_SECRET` | required with the standalone Guardian | The same base64-encoded secret supplied to the Guardian; signs requests and verifies results and errors. Omission is compatibility mode for an explicitly unsecured in-process Guardian, not ACS-Core | +| `ACS_HMAC_KEY_ID` | required with `ACS_HMAC_SECRET` | The same label supplied to the Guardian. Every request names the secret by it, and a response that names another label is rejected. Setting only one of the two is a configuration error | -**The OpenCode plugin** (`hosts/opencode/acs-plugin.ts`) reads `ACS_GUARDIAN_URL`, `ACS_AUDIT_LOG`, `ACS_HOOKMAP_PATH`, and the shared `ACS_HMAC_SECRET` with the same meanings. The secret is required with the standalone Guardian; omission exists only for compatibility with an explicitly unsecured in-process Guardian and does not negotiate ACS-Core. Its hookmap default is `hosts/opencode/opencode.hookmap.yaml`. It has no `ACS_SESSION_DIR`, because the plugin lives for the whole session and keeps the ServerHello in memory. It also reads `ACS_DEBUG`. When that variable holds any value other than empty or `0`, the plugin prints the decision's reason text on stderr, because OpenCode has no field that reads a reason back. +**The OpenCode plugin** (`hosts/opencode/acs-plugin.ts`) reads `ACS_GUARDIAN_URL`, `ACS_AUDIT_LOG`, `ACS_HOOKMAP_PATH`, and the shared `ACS_HMAC_SECRET` and `ACS_HMAC_KEY_ID` with the same meanings. The secret and its label are required with the standalone Guardian; omission exists only for compatibility with an explicitly unsecured in-process Guardian and does not negotiate ACS-Core. Its hookmap default is `hosts/opencode/opencode.hookmap.yaml`. It has no `ACS_SESSION_DIR`, because the plugin lives for the whole session and keeps the ServerHello in memory. It also reads `ACS_DEBUG`. When that variable holds any value other than empty or `0`, the plugin prints the decision's reason text on stderr, because OpenCode has no field that reads a reason back. **The Guardian** (`bun run guardian`): @@ -584,6 +587,7 @@ The quickstart requires one shared secret in the Guardian and host environments; | `ACS_ENVELOPE_LOG` | `.acs/envelopes.jsonl` | Where every envelope is recorded | | `ACS_SESSION_CONTEXT_LOG` | `.acs/session-context.jsonl` | Where each session's hash chain is written | | `ACS_HMAC_SECRET` | required | Base64-encoded input keying material of at least 32 bytes. The Guardian derives a per-session key via HKDF, verifies requests, and signs addressable non-ping results and errors. The hosts use the same value to sign requests—including ClientHello—and verify responses. `system/ping` is signature-exempt in both directions | +| `ACS_HMAC_KEY_ID` | required | The `key_id` that names `ACS_HMAC_SECRET` (§10). There is no default. A request naming any other label is refused with `-32004 SIGNATURE_INVALID`, unsigned, and never verified against another secret. A response is signed with, and names, the label of the request it answers | **The Inspector** (`bun run inspector`) reads `ACS_ENVELOPE_LOG`, `ACS_AUDIT_LOG` and `ACS_SESSION_CONTEXT_LOG` with the same defaults. The flags `--envelope-log`, `--audit-log` and `--session-context-log` override them. It honours `NO_COLOR` and prints no colour when stdout is not a terminal. diff --git a/reference-implementations/agt/hosts/claude-code/README.md b/reference-implementations/agt/hosts/claude-code/README.md index dfc908ee..67bd32ee 100644 --- a/reference-implementations/agt/hosts/claude-code/README.md +++ b/reference-implementations/agt/hosts/claude-code/README.md @@ -4,7 +4,7 @@ ## Get started -1. Start the Guardian from `reference-implementations/agt`. The [tree README](../../README.md#start-the-guardian) shows the command. Export the same `ACS_HMAC_SECRET` value in the terminal that starts Claude Code. +1. Start the Guardian from `reference-implementations/agt`. The [tree README](../../README.md#start-the-guardian) shows the command. Export the same `ACS_HMAC_SECRET` and `ACS_HMAC_KEY_ID` values in the terminal that starts Claude Code. 2. Register the hook in the project you run Claude Code in. @@ -43,6 +43,7 @@ The first hook of a session sends `handshake/hello` and stores the Guardian's an | `ACS_AUDIT_LOG` | `.acs/audit.jsonl` | Where fail-open proceeds and posture-driven blocks are written | | `ACS_HOOKMAP_PATH` | `claude-code.hookmap.yaml`, beside the shim | Which hookmap to load. This changes what governance means for the host. Leave it unset in a deployment | | `ACS_HMAC_SECRET` | required with the standalone Guardian | The same base64-encoded secret supplied to the Guardian; signs ClientHello and step requests and verifies non-ping responses. Omission is compatibility mode for an explicitly unsecured in-process Guardian, not ACS-Core | +| `ACS_HMAC_KEY_ID` | required with `ACS_HMAC_SECRET` | The same label supplied to the Guardian; names the secret as `key_id` in every request and must match the label on every verified response. Setting only one of the two is a configuration error | ## Exit codes diff --git a/reference-implementations/agt/hosts/claude-code/acs-hook.ts b/reference-implementations/agt/hosts/claude-code/acs-hook.ts index 0ff22546..9a6f0d9f 100644 --- a/reference-implementations/agt/hosts/claude-code/acs-hook.ts +++ b/reference-implementations/agt/hosts/claude-code/acs-hook.ts @@ -86,6 +86,7 @@ import { createAuditSink, createFileSessionConfigStore, createGuardianClient, + hmacKeyFromEnv, DEFAULT_TIMEOUT_MS, governStep, loadHookmap, @@ -534,10 +535,8 @@ async function main(): Promise { const audit = createAuditSink({ path: process.env.ACS_AUDIT_LOG ?? ".acs/audit.jsonl" }); const guardianUrl = process.env.ACS_GUARDIAN_URL ?? DEFAULT_GUARDIAN_URL; - const hmacSecret = process.env.ACS_HMAC_SECRET - ? Buffer.from(process.env.ACS_HMAC_SECRET, "base64") - : undefined; - const guardian = createGuardianClient(guardianUrl, { hmacSecret }); + const hmacKey = hmacKeyFromEnv(); + const guardian = createGuardianClient(guardianUrl, { hmacKey }); // Step 4: this session's negotiated config, and whatever went wrong getting // it -- one call, and this shim is told both rather than running the @@ -563,7 +562,7 @@ async function main(): Promise { agentId: hookmap.host, sessionId: toSessionUuid(sessionId), timeoutMs: DEFAULT_TIMEOUT_MS, - hmacSecret, + hmacKey, }; const session = await resolveSessionConfig(handshake, store); const renegotiate = () => negotiateSessionConfig(handshake, store); @@ -578,7 +577,7 @@ async function main(): Promise { // rules produce for it, is stated once inside `governStep`, host-agnostically, // so a second host inherits it by calling the function rather than // reimplementing it. - const governed = await governStep({ hookEventName, payload, hookmap, guardian, session, sessionId, audit, hmacSecret, renegotiate }); + const governed = await governStep({ hookEventName, payload, hookmap, guardian, session, sessionId, audit, hmacKey, renegotiate }); process.stdout.write(JSON.stringify(asClaudeCodeOutput(governed.output, hookEventName))); } diff --git a/reference-implementations/agt/hosts/claude-code/test/posture.test.ts b/reference-implementations/agt/hosts/claude-code/test/posture.test.ts index ad1c9b12..2bf84255 100644 --- a/reference-implementations/agt/hosts/claude-code/test/posture.test.ts +++ b/reference-implementations/agt/hosts/claude-code/test/posture.test.ts @@ -1241,13 +1241,14 @@ describe("acs-hook — an unsigned refusal under HMAC still refuses", () => { it("withholds a keyed tool result over the Guardian's 1 MiB request cap, as guardian_refused", async () => { const dir = scratch(); - const guardian = await startGuardian({ port: 0, manifestPath: MANIFEST, hmacSecret: GUARDIAN_KEY }); + const guardian = await startGuardian({ port: 0, manifestPath: MANIFEST, hmacSecret: GUARDIAN_KEY, hmacKeyId: "test-key" }); try { const env = { ACS_GUARDIAN_URL: guardian.url, ACS_SESSION_DIR: join(dir, "sessions"), ACS_AUDIT_LOG: join(dir, "audit.jsonl"), ACS_HMAC_SECRET: GUARDIAN_KEY, + ACS_HMAC_KEY_ID: "test-key", }; const secret = "TOKEN=ghp_ABCDEF123456\n"; const out = await runShim(resultPayload(secret + "x".repeat(2 * 1024 * 1024)), env); @@ -1264,7 +1265,7 @@ describe("acs-hook — an unsigned refusal under HMAC still refuses", () => { it("denies a keyed tool call whose input is too deep to sign, under the proceed posture", async () => { const dir = scratch(); - const guardian = await startGuardian({ port: 0, manifestPath: MANIFEST, hmacSecret: GUARDIAN_KEY }); + const guardian = await startGuardian({ port: 0, manifestPath: MANIFEST, hmacSecret: GUARDIAN_KEY, hmacKeyId: "test-key" }); try { const deep = "[".repeat(20_000) + "]".repeat(20_000); const stdin = JSON.stringify({ @@ -1278,6 +1279,7 @@ describe("acs-hook — an unsigned refusal under HMAC still refuses", () => { ACS_SESSION_DIR: join(dir, "sessions"), ACS_AUDIT_LOG: join(dir, "audit.jsonl"), ACS_HMAC_SECRET: GUARDIAN_KEY, + ACS_HMAC_KEY_ID: "test-key", }); expect(expectQuietDecision(out).permissionDecision).toBe("deny"); const audit = readFileSync(join(dir, "audit.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l)); @@ -1293,6 +1295,7 @@ describe("acs-hook — an unsigned refusal under HMAC still refuses", () => { port: 0, manifestPath: MANIFEST, hmacSecret: GUARDIAN_KEY, + hmacKeyId: "test-key", onDecisionFailure: "deny", }); try { @@ -1301,6 +1304,7 @@ describe("acs-hook — an unsigned refusal under HMAC still refuses", () => { ACS_SESSION_DIR: join(dir, "sessions"), ACS_AUDIT_LOG: join(dir, "audit.jsonl"), ACS_HMAC_SECRET: Buffer.alloc(32, 0x62).toString("base64"), + ACS_HMAC_KEY_ID: "test-key", }); expect(expectQuietDecision(out).permissionDecision).toBe("deny"); const audit = readFileSync(join(dir, "audit.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l)); diff --git a/reference-implementations/agt/hosts/opencode/README.md b/reference-implementations/agt/hosts/opencode/README.md index 1ba675fe..d006282f 100644 --- a/reference-implementations/agt/hosts/opencode/README.md +++ b/reference-implementations/agt/hosts/opencode/README.md @@ -4,7 +4,7 @@ ## Get started -1. Start the Guardian from `reference-implementations/agt`. The [tree README](../../README.md#start-the-guardian) shows the command. Export the same `ACS_HMAC_SECRET` value in the terminal that starts OpenCode. +1. Start the Guardian from `reference-implementations/agt`. The [tree README](../../README.md#start-the-guardian) shows the command. Export the same `ACS_HMAC_SECRET` and `ACS_HMAC_KEY_ID` values in the terminal that starts OpenCode. 2. Point OpenCode at the plugin. The `opencode.json` at the root of this tree already does, with a relative path, so an `opencode` started from `reference-implementations/agt` needs no edit. From any other project, add the plugin to that project's `opencode.json` by absolute path. @@ -40,6 +40,7 @@ Because OpenCode has no field that reads a reason back after the tool has run, t | `ACS_HOOKMAP_PATH` | `opencode.hookmap.yaml`, beside the plugin | Which hookmap to load. Leave it unset in a deployment | | `ACS_DEBUG` | unset | Prints each decision's reason on stderr | | `ACS_HMAC_SECRET` | required with the standalone Guardian | The same base64-encoded secret supplied to the Guardian; signs ClientHello and step requests and verifies non-ping responses. Omission is compatibility mode for an explicitly unsecured in-process Guardian, not ACS-Core | +| `ACS_HMAC_KEY_ID` | required with `ACS_HMAC_SECRET` | The same label supplied to the Guardian; names the secret as `key_id` in every request and must match the label on every verified response. Setting only one of the two is a configuration error | There is no session directory. The plugin lives for the whole session and keeps the negotiated ServerHello in memory, so only the first hook of a session sends `handshake/hello`. diff --git a/reference-implementations/agt/hosts/opencode/acs-plugin.ts b/reference-implementations/agt/hosts/opencode/acs-plugin.ts index f6935854..c64b7af5 100644 --- a/reference-implementations/agt/hosts/opencode/acs-plugin.ts +++ b/reference-implementations/agt/hosts/opencode/acs-plugin.ts @@ -118,6 +118,8 @@ import { auditUngovernedStep, createAuditSink, createGuardianClient, + hmacKeyFromEnv, + type HmacKey, createMemorySessionConfigStore, DEFAULT_TIMEOUT_MS, governStep, @@ -1183,7 +1185,10 @@ type Deployment = { readonly guardian: GuardianClient; readonly audit: AuditSink; readonly store: SessionConfigStore; - readonly hmacSecret?: Buffer; + readonly hmacKey?: HmacKey; + /** Why the HMAC key could not be read, if it could not. Thrown from every + * hook rather than from the factory: see `AcsPlugin`. */ + readonly hmacKeyError?: unknown; }; /** @@ -1270,6 +1275,8 @@ async function runExchange( input: { tool: string; sessionID: string; callID: string }, assemble: (tool: string, sessionID: string) => AssembledStep, ): Promise { + if (deployment.hmacKeyError !== undefined) throw deployment.hmacKeyError; + // `tool` first, ahead of the `tools` check below: a list membership test // cannot tell a malformed `tool` from a genuinely out-of-scope one, so a // broken host contract for `tool` has to be refused here, the same @@ -1324,7 +1331,7 @@ async function runExchange( agentId: deployment.hookmap.host, sessionId: toSessionUuid(input.sessionID), timeoutMs: DEFAULT_TIMEOUT_MS, - hmacSecret: deployment.hmacSecret, + hmacKey: deployment.hmacKey, }; const session = await resolveSessionConfig(handshake, deployment.store); const renegotiate = () => negotiateSessionConfig(handshake, deployment.store); @@ -1351,7 +1358,7 @@ async function runExchange( // the actual command through. Both of this shim's gates pass through // here, so this one line is both of its call sites. scopedTool: input.tool, - hmacSecret: deployment.hmacSecret, + hmacKey: deployment.hmacKey, renegotiate, }); @@ -1398,19 +1405,29 @@ export const AcsPlugin: Plugin = async () => { // Built once, here, and handed to `runExchange` on every call -- the four // this deployment runs on, in one object so the exchange both gates // share can take them as one argument. - const hmacSecret = process.env.ACS_HMAC_SECRET - ? Buffer.from(process.env.ACS_HMAC_SECRET, "base64") - : undefined; + // Not thrown here, unlike the hookmap checks above: an HMAC secret set + // without its ACS_HMAC_KEY_ID is the likeliest misconfiguration after an + // upgrade, and a factory throw would unload this plugin and leave every + // tool ungoverned. Thrown from each hook instead, so every tool call is + // refused until the deployment is fixed. + let hmacKey: HmacKey | undefined; + let hmacKeyError: unknown; + try { + hmacKey = hmacKeyFromEnv(); + } catch (error) { + hmacKeyError = error; + } const deployment: Deployment = { hookmap, - guardian: createGuardianClient(process.env.ACS_GUARDIAN_URL ?? DEFAULT_GUARDIAN_URL, { hmacSecret }), + guardian: createGuardianClient(process.env.ACS_GUARDIAN_URL ?? DEFAULT_GUARDIAN_URL, { hmacKey }), audit: createAuditSink({ path: process.env.ACS_AUDIT_LOG ?? ".acs/audit.jsonl" }), // In memory: one plugin object per session, so the negotiated config // survives in a variable and the second hook of a session skips the // handshake round trip. One interface, two implementations, and the // adapter never learns which host is running. store: createMemorySessionConfigStore(), - hmacSecret, + hmacKey, + hmacKeyError, }; // Named as a const rather than returned inline, so `Object.keys` below diff --git a/reference-implementations/agt/hosts/opencode/test/acs-plugin.test.ts b/reference-implementations/agt/hosts/opencode/test/acs-plugin.test.ts index 43f89d80..44a13826 100644 --- a/reference-implementations/agt/hosts/opencode/test/acs-plugin.test.ts +++ b/reference-implementations/agt/hosts/opencode/test/acs-plugin.test.ts @@ -1095,6 +1095,27 @@ describe("AcsPlugin's load-time gate, on the hooks it actually registers", () => } }); + // A secret without its label must not unload the plugin: OpenCode would + // run every tool ungoverned. It registers, and every tool call is refused. + it("registers and refuses every tool when ACS_HMAC_SECRET is set without ACS_HMAC_KEY_ID", async () => { + const previous = { secret: process.env.ACS_HMAC_SECRET, keyId: process.env.ACS_HMAC_KEY_ID }; + process.env.ACS_HMAC_SECRET = Buffer.alloc(32, 0x73).toString("base64"); + delete process.env.ACS_HMAC_KEY_ID; + try { + const hooks = await AcsPlugin({} as never); + const before = hooks["tool.execute.before"]; + expect(before).toBeDefined(); + await expect( + before!({ tool: "bash", sessionID: "s-1", callID: "c-1" }, { args: { command: "ls" } }), + ).rejects.toThrow(/ACS_HMAC_KEY_ID/); + } finally { + for (const [name, value] of [["ACS_HMAC_SECRET", previous.secret], ["ACS_HMAC_KEY_ID", previous.keyId]] as const) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } + }); + // Direction one: a rename applied to the hook METHOD and not to the table. // This is the shape an upstream rename takes while it is half-applied, and // it is the shape that used to register silently -- OpenCode would have diff --git a/reference-implementations/agt/packages/guardian/README.md b/reference-implementations/agt/packages/guardian/README.md index bf31468b..f9d47b2b 100644 --- a/reference-implementations/agt/packages/guardian/README.md +++ b/reference-implementations/agt/packages/guardian/README.md @@ -10,6 +10,7 @@ Run it from `reference-implementations/agt`. Its default paths are relative to t ```bash export ACS_HMAC_SECRET="$(openssl rand -base64 32)" +export ACS_HMAC_KEY_ID="2026-10" bun run guardian ``` @@ -34,6 +35,7 @@ It constructs the AGT runtime once, at startup, against `policy/manifest.yaml`, | `ACS_OTEL_FILE` | unset | Writes one OpenTelemetry span per decided step as OTLP/JSON, one export request per line. Unset means no trace export | | `ACS_OTEL_ENDPOINT` | unset | Sends the same spans to an OTLP/HTTP collector, for example `http://127.0.0.1:4318/v1/traces` | | `ACS_HMAC_SECRET` | required | Base64-encoded input keying material of at least 32 bytes. The Guardian derives per-session keys, verifies requests, and signs addressable non-ping results and errors | +| `ACS_HMAC_KEY_ID` | required | The label that names `ACS_HMAC_SECRET`, carried as `key_id` in every signature (§10). No default. A request naming another label gets an unsigned `-32004 SIGNATURE_INVALID` | To see a decision without an agent client, pipe a hook payload into the Claude Code shim while the Guardian runs. The [tree README](../../README.md#drive-one-hook-by-hand) shows the command and its output. @@ -149,6 +151,7 @@ const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: process.env.ACS_HMAC_SECRET, + hmacKeyId: process.env.ACS_HMAC_KEY_ID, }); // guardian.url is the full endpoint, on the port the OS assigned. await guardian.close(); diff --git a/reference-implementations/agt/packages/guardian/src/main.ts b/reference-implementations/agt/packages/guardian/src/main.ts index de2ca289..7ce41f50 100644 --- a/reference-implementations/agt/packages/guardian/src/main.ts +++ b/reference-implementations/agt/packages/guardian/src/main.ts @@ -76,6 +76,12 @@ const decodedHmacSecret = Buffer.from(hmacSecret, "base64"); if (decodedHmacSecret.length < 32) { throw new Error("ACS_HMAC_SECRET must decode to at least 32 bytes"); } +// §10: key_id is a label the deployment chooses for its secret, so there is +// no default to fall back on. +const hmacKeyId = process.env.ACS_HMAC_KEY_ID; +if (!hmacKeyId) { + throw new Error("ACS_HMAC_KEY_ID is required: set it to the label that names ACS_HMAC_SECRET (Specification §10, key_id for HMAC)"); +} const guardian = await startGuardian({ port, hostname, @@ -85,6 +91,7 @@ const guardian = await startGuardian({ otelTraceFile, otelTraceEndpoint, hmacSecret, + hmacKeyId, }); console.log(`Guardian listening at ${guardian.url}`); console.log(`Envelope log: ${envelopeLogPath}`); diff --git a/reference-implementations/agt/packages/guardian/src/server.ts b/reference-implementations/agt/packages/guardian/src/server.ts index 278725c3..ae842569 100644 --- a/reference-implementations/agt/packages/guardian/src/server.ts +++ b/reference-implementations/agt/packages/guardian/src/server.ts @@ -443,7 +443,15 @@ export type StartGuardianOptions = { * only for in-process compatibility tests and advertises no `acs-core` * profile; the standalone deployment requires ACS_HMAC_SECRET. */ hmacSecret?: string; + /** The label naming `hmacSecret` (§10, `key_id` for HMAC): required with + * it and meaningless without it. A request is verified with the secret its + * `key_id` names, and an unknown label is SIGNATURE_INVALID. */ + hmacKeyId?: string; }; + +/** Configured HMAC secrets by `key_id`. A Map, so a label is only ever a + * key, never a lookup into an object's prototype. */ +type HmacKeyring = ReadonlyMap; export type StartedGuardian = { url: string; close(): Promise }; /** @@ -515,10 +523,17 @@ export async function startGuardian({ traceExporter: traceExporterOverride, replayProtectionStore: replayProtectionStoreOverride, hmacSecret, + hmacKeyId, }: StartGuardianOptions): Promise { if (hmacSecret !== undefined && Buffer.from(hmacSecret, "base64").length < 32) { throw new Error("hmacSecret must decode to at least 32 bytes"); } + if ((hmacSecret === undefined) !== (hmacKeyId === undefined) || hmacKeyId === "") { + throw new Error("hmacSecret and a non-empty hmacKeyId must be given together"); + } + const hmacKeys: HmacKeyring | undefined = hmacSecret === undefined || hmacKeyId === undefined + ? undefined + : new Map([[hmacKeyId, Buffer.from(hmacSecret, "base64")]]); // Construct the bridge once at boot, not per request -- and through the // shared recipe rather than assembling one here, so this Guardian and every @@ -582,7 +597,7 @@ export async function startGuardian({ replayProtectionStore, negotiatedSessions, registeredTools, - hmacSecret, + hmacKeys, traceExporter, ); return Response.json(response); @@ -656,7 +671,7 @@ async function handleAcsRequest( replayProtectionStore: ReplayProtectionStore, negotiatedSessions: NegotiatedSessions, registeredTools: ReadonlySet, - hmacSecret: string | undefined, + hmacKeys: HmacKeyring | undefined, traceExporter: OtelTraceExporter, ): Promise { const body = await readCappedBody(req); @@ -708,7 +723,7 @@ async function handleAcsRequest( replayProtectionStore, negotiatedSessions, registeredTools, - hmacSecret, + hmacKeys, ); } catch (error) { // The outer net. Deliberately a bare JSON-RPC error, not an ACS `deny`: @@ -738,22 +753,21 @@ async function handleAcsRequest( // carry a valid signature, so its answer goes out unsigned, exactly like // any other unauthenticated request's. try { - if ( - hmacSecret && - extractMethod(raw) !== "system/ping" && - requestHasValidSignature(raw, Buffer.from(hmacSecret, "base64")) - ) { - const sessionId = extractSessionId(raw); - if (sessionId) { - const sessionKey = deriveSessionKey(Buffer.from(hmacSecret, "base64"), sessionId); - const sig = signResponseHmacSha256(response as Record, sessionKey); - const carrier = "result" in response ? response.result : response.error; - (carrier as Record).signature = { - algorithm: "HMAC-SHA256", - value: sig, - key_id: sessionId, - }; - } + // §10: a response is signed with the secret, and names the key_id, of + // the request it answers. A request naming an unknown key_id verifies + // under nothing, so its SIGNATURE_INVALID goes out unsigned. + const requestKey = hmacKeys && extractMethod(raw) !== "system/ping" + ? verifiedRequestKey(raw, hmacKeys) + : undefined; + if (requestKey) { + const sessionKey = deriveSessionKey(requestKey.secret, requestKey.sessionId); + const sig = signResponseHmacSha256(response as Record, sessionKey); + const carrier = "result" in response ? response.result : response.error; + (carrier as Record).signature = { + algorithm: "HMAC-SHA256", + value: sig, + key_id: requestKey.keyId, + }; } } catch (error) { try { @@ -897,7 +911,7 @@ async function dispatch( replayProtectionStore: ReplayProtectionStore, negotiatedSessions: NegotiatedSessions, registeredTools: ReadonlySet, - hmacSecret: string | undefined, + hmacKeys: HmacKeyring | undefined, ): Promise { const rpcId = extractId(raw); @@ -956,7 +970,7 @@ async function dispatch( // deployment configures a key. The session id needed for HKDF is already // present in the handshake envelope; capability negotiation is not key // exchange, because ACS v0.1 provisions HMAC material out of band. - if (hmacSecret) { + if (hmacKeys) { if (!envelope.params.signature) { return errorResponse(rpcId, SIGNATURE_INVALID_CODE, "SIGNATURE_INVALID", { reason: "signature required but not present", @@ -967,12 +981,15 @@ async function dispatch( reason: `unsupported algorithm: ${envelope.params.signature.algorithm}`, }); } - if (envelope.params.signature.key_id !== envelope.params.metadata.session_id) { + // §10: key_id names a configured secret. An unknown one is rejected, + // never answered by falling back to another secret. + const secret = hmacKeys.get(envelope.params.signature.key_id); + if (secret === undefined) { return errorResponse(rpcId, SIGNATURE_INVALID_CODE, "SIGNATURE_INVALID", { - reason: "signature key_id does not match metadata.session_id", + reason: "signature key_id names no configured secret", }); } - const sessionKey = deriveSessionKey(Buffer.from(hmacSecret, "base64"), envelope.params.metadata.session_id); + const sessionKey = deriveSessionKey(secret, envelope.params.metadata.session_id); const result = verifyHmacSha256( raw as Record, envelope.params.signature.value, @@ -1070,8 +1087,8 @@ async function dispatch( const env = onDecisionFailure === undefined ? undefined : { ACS_ON_DECISION_FAILURE: onDecisionFailure }; const clientProfiles = new Set((clientHello?.profiles_supported as string[] | undefined) ?? []); const hello = buildServerHello(env, { - hmacEnabled: hmacSecret !== undefined, - profilesAccepted: hmacSecret !== undefined && clientProfiles.has("acs-core") ? ["acs-core"] : [], + hmacEnabled: hmacKeys !== undefined, + profilesAccepted: hmacKeys !== undefined && clientProfiles.has("acs-core") ? ["acs-core"] : [], }); const clientMethods = Array.isArray(clientHello?.methods_implemented) @@ -1378,48 +1395,40 @@ function extractMethod(raw: unknown): string | null { return null; } -function extractSessionId(raw: unknown): string | null { - if (typeof raw === "object" && raw !== null && "params" in raw) { - const params = (raw as Record).params; - if (typeof params === "object" && params !== null && "metadata" in params) { - const metadata = (params as Record).metadata; - if (typeof metadata === "object" && metadata !== null && "session_id" in metadata) { - const sid = (metadata as Record).session_id; - if (typeof sid === "string") return sid; - } - } - } - return null; -} - -/** Whether an addressable response may be authenticated as belonging to an - * authenticated request. Rechecked at the signing boundary so a top-level - * validation failure cannot become a chosen-message signing oracle merely by - * carrying a victim session id. */ -function requestHasValidSignature(raw: unknown, masterSecret: Buffer): boolean { - if (typeof raw !== "object" || raw === null || !("params" in raw)) return false; +/** The configured key an authenticated request was signed under, or + * undefined when the request does not verify. Rechecked at the signing + * boundary so a top-level validation failure cannot become a chosen-message + * signing oracle merely by carrying a victim session id. */ +function verifiedRequestKey( + raw: unknown, + hmacKeys: HmacKeyring, +): { keyId: string; secret: Buffer; sessionId: string } | undefined { + if (typeof raw !== "object" || raw === null || !("params" in raw)) return undefined; const params = (raw as Record).params; - if (typeof params !== "object" || params === null) return false; + if (typeof params !== "object" || params === null) return undefined; const metadata = (params as Record).metadata; const signature = (params as Record).signature; if (typeof metadata !== "object" || metadata === null || typeof signature !== "object" || signature === null) { - return false; + return undefined; } const sessionId = (metadata as Record).session_id; const fields = signature as Record; if ( typeof sessionId !== "string" || fields.algorithm !== "HMAC-SHA256" || - fields.key_id !== sessionId || + typeof fields.key_id !== "string" || typeof fields.value !== "string" ) { - return false; + return undefined; } - return verifyHmacSha256( + const secret = hmacKeys.get(fields.key_id); + if (secret === undefined) return undefined; + const valid = verifyHmacSha256( raw as Record, fields.value, - deriveSessionKey(masterSecret, sessionId), + deriveSessionKey(secret, sessionId), ).valid; + return valid ? { keyId: fields.key_id, secret, sessionId } : undefined; } /** Whether the raw envelope names a `steps/*` method -- read before diff --git a/reference-implementations/agt/packages/guardian/test/server.test.ts b/reference-implementations/agt/packages/guardian/test/server.test.ts index d87ea9c8..d2c77426 100644 --- a/reference-implementations/agt/packages/guardian/test/server.test.ts +++ b/reference-implementations/agt/packages/guardian/test/server.test.ts @@ -72,6 +72,9 @@ function clientHello(overrides: Record = {}): Record(); @@ -86,7 +89,7 @@ function signRequest(envelope: Record, masterSecret: Buffer): R ...envelope, params: { ...params, - signature: { algorithm: "HMAC-SHA256", value, key_id: sessionId }, + signature: { algorithm: "HMAC-SHA256", value, key_id: TEST_KEY_ID }, }, }; } @@ -1737,13 +1740,13 @@ describe("hook traffic needs a handshake that declared the method (§4, §17.1)" describe("ACS-Core wire security and negotiation", () => { const masterSecret = Buffer.alloc(32, 0x5a); const encodedSecret = masterSecret.toString("base64"); + const hmac = { hmacSecret: encodedSecret, hmacKeyId: TEST_KEY_ID }; it("answers JSON-RPC, never an HTML 500, when the signing boundary cannot process a request", async () => { - const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: encodedSecret }); + const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", ...hmac }); const signedLooking = (envelope: Record) => { const params = envelope.params as Record; - const sessionId = (params.metadata as Record).session_id; - params.signature = { algorithm: "HMAC-SHA256", key_id: sessionId, value: "AAAA" }; + params.signature = { algorithm: "HMAC-SHA256", key_id: TEST_KEY_ID, value: "AAAA" }; return envelope; }; // HKDF caps its info input at 1024 bytes, and the session id is that input. @@ -1768,7 +1771,7 @@ describe("ACS-Core wire security and negotiation", () => { }); it("rejects unsigned and incorrectly signed requests when HMAC is configured", async () => { - const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: encodedSecret }); + const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", ...hmac }); try { const unsigned = toolCallEnvelope("ls -la"); expect((await postAcs(secured.url, unsigned)).error?.code).toBe(-32004); @@ -1783,16 +1786,22 @@ describe("ACS-Core wire security and negotiation", () => { const wrongKeyParams = wrongKeyId.params as Record; wrongKeyParams.signature = { ...(wrongKeyParams.signature as Record), - key_id: crypto.randomUUID(), + key_id: "unconfigured-key", }; - expect((await postAcs(secured.url, wrongKeyId)).error?.code).toBe(-32004); + // Signed with the configured secret, but naming a label that is not + // configured: §10 rejects it rather than falling back to that secret, + // and the rejection is unsigned because no secret matches the label. + const unknownKeyId = await postAcs(secured.url, wrongKeyId); + expect(unknownKeyId.error?.code).toBe(-32004); + expect(unknownKeyId.error?.data).toEqual({ reason: "signature key_id names no configured secret" }); + expect(unknownKeyId.error?.signature).toBeUndefined(); } finally { await secured.close(); } }); it("checks authentication before method-specific payload validation", async () => { - const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: encodedSecret }); + const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", ...hmac }); try { const malformedUnsigned = makeEnvelope("steps/toolCallRequest", {}); expect((await postAcs(secured.url, malformedUnsigned)).error?.code).toBe(-32004); @@ -1809,7 +1818,7 @@ describe("ACS-Core wire security and negotiation", () => { const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", - hmacSecret: encodedSecret, + ...hmac, sessionContextStore: store, }); try { @@ -1849,18 +1858,28 @@ describe("ACS-Core wire security and negotiation", () => { } }); + it("refuses a secret without the key_id that names it, and a key_id without a secret", async () => { + await expect( + startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: encodedSecret }), + ).rejects.toThrow(/hmacKeyId/); + await expect( + startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacKeyId: TEST_KEY_ID }), + ).rejects.toThrow(/hmacKeyId/); + }); + it("refuses key material too short to support the documented HMAC baseline", async () => { await expect( startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: Buffer.alloc(16).toString("base64"), + hmacKeyId: TEST_KEY_ID, }), ).rejects.toThrow(/at least 32 bytes/); }); it("accepts a valid signature and signs the exact response it sends", async () => { - const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: encodedSecret }); + const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", ...hmac }); try { const request = signRequest(toolCallEnvelope("ls -la"), masterSecret); const response = await postAcs(secured.url, request); @@ -1869,7 +1888,7 @@ describe("ACS-Core wire security and negotiation", () => { const signature = response.result?.signature as { algorithm: string; value: string; key_id: string }; const sessionId = ((request.params as Record).metadata as Record).session_id as string; expect(signature.algorithm).toBe("HMAC-SHA256"); - expect(signature.key_id).toBe(sessionId); + expect(signature.key_id).toBe(TEST_KEY_ID); const key = deriveSessionKey(masterSecret, sessionId); const expected = createHmac("sha256", key) .update(responseSigningInput(response as unknown as Record)) @@ -1881,7 +1900,7 @@ describe("ACS-Core wire security and negotiation", () => { }); it("signs addressable JSON-RPC errors", async () => { - const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: encodedSecret }); + const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", ...hmac }); try { const request = signRequest(makeEnvelope("steps/unknownHook", {}), masterSecret); const response = await postAcs(secured.url, request); @@ -1939,7 +1958,7 @@ describe("ACS-Core wire security and negotiation", () => { }); it("negotiates profiles, advertised signature support, and validates ClientHello", async () => { - const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: encodedSecret }); + const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", ...hmac }); try { const response = await postAcs( secured.url, diff --git a/reference-implementations/agt/packages/host-adapter/src/govern-step.ts b/reference-implementations/agt/packages/host-adapter/src/govern-step.ts index ccc8c68f..bb754bea 100644 --- a/reference-implementations/agt/packages/host-adapter/src/govern-step.ts +++ b/reference-implementations/agt/packages/host-adapter/src/govern-step.ts @@ -77,7 +77,7 @@ * different wire shape (see render-decision.ts). */ import { buildEnvelope, modificationDocumentOf, type AcsRequestEnvelope, type Hookmap } from "./build-envelope.ts"; -import { signEnvelope } from "./sign-envelope.ts"; +import { signEnvelope, type HmacKey } from "./sign-envelope.ts"; import type { AuditSink } from "./audit-sink.ts"; import { applyFailurePosture, @@ -169,9 +169,10 @@ export type GovernStepInput = { * into a silent skip any more. */ scopedTool?: string; - /** Base64-encoded IKM for HMAC-SHA256 request signing (§10). When set, - * every envelope is signed with an HKDF-derived per-session key. */ - hmacSecret?: Buffer; + /** The deployment's HMAC key for request signing (§10). When set, every + * envelope is signed with an HKDF-derived per-session key and names the + * key's configured `key_id`. */ + hmacKey?: HmacKey; /** * Repeats this session's handshake. When the Guardian answers a step with * -32003 (no handshake on record, as after a Guardian restart), §17.1's @@ -448,7 +449,7 @@ export async function governStep({ sessionId, audit, scopedTool, - hmacSecret, + hmacKey, renegotiate, }: GovernStepInput): Promise { // Every render below -- the arriving decision's and the posture's -- goes @@ -680,7 +681,7 @@ export async function governStep({ // rather than letting a `proceed` posture run it. const buildRequest = (): AcsRequestEnvelope => buildEnvelope(hookEventName, payload, hookmap); const signRequest = (built: AcsRequestEnvelope): AcsRequestEnvelope => - hmacSecret ? signEnvelope(built, hmacSecret) : built; + hmacKey ? signEnvelope(built, hmacKey) : built; let envelope: AcsRequestEnvelope; try { envelope = buildRequest(); diff --git a/reference-implementations/agt/packages/host-adapter/src/guardian-client.ts b/reference-implementations/agt/packages/host-adapter/src/guardian-client.ts index f00439fc..8844f362 100644 --- a/reference-implementations/agt/packages/host-adapter/src/guardian-client.ts +++ b/reference-implementations/agt/packages/host-adapter/src/guardian-client.ts @@ -48,7 +48,7 @@ import type { AcsRequestEnvelope } from "./build-envelope.ts"; import type { AcsDecision } from "./decision-message.ts"; import { isRefusalCode } from "./failure-kinds.ts"; -import { verifyResponseSignature } from "./sign-envelope.ts"; +import { verifyResponseSignature, type HmacKey } from "./sign-envelope.ts"; /** * The minimal JSON-RPC 2.0 request shape this client sends -- the transport @@ -207,9 +207,10 @@ export type GuardianClient = { }; /** Binds a Guardian's ACS endpoint and returns the client role for it. - * Supplying `hmacSecret` makes every non-ping result or error require a valid - * per-session HMAC. The protocol's liveness ping is exempt in both directions. */ -export type GuardianClientOptions = { hmacSecret?: Buffer }; + * Supplying `hmacKey` makes every non-ping result or error require a valid + * per-session HMAC under that key. The protocol's liveness ping is exempt in + * both directions. */ +export type GuardianClientOptions = { hmacKey?: HmacKey }; export function createGuardianClient(url: string, clientOptions: GuardianClientOptions = {}): GuardianClient { async function post(envelope: JsonRpcRequest, options: PostOptions = {}): Promise { @@ -244,7 +245,7 @@ export function createGuardianClient(url: string, clientOptions: GuardianClientO // `system/ping` is the protocol's signature-exempt liveness channel. // Exempt only a request whose own method is ping; deciding from the // response shape would let a substituted ping bypass verification. - if (clientOptions.hmacSecret && envelope.method !== "system/ping") { + if (clientOptions.hmacKey && envelope.method !== "system/ping") { const metadata = envelope.params.metadata as Record | undefined; const sessionId = metadata?.session_id; if (typeof sessionId !== "string") { @@ -252,7 +253,7 @@ export function createGuardianClient(url: string, clientOptions: GuardianClientO } const verified = verifyResponseSignature( response as unknown as Record, - clientOptions.hmacSecret, + clientOptions.hmacKey, sessionId, ); // An unsigned refusal is still believed. The Guardian cannot sign some diff --git a/reference-implementations/agt/packages/host-adapter/src/handshake.ts b/reference-implementations/agt/packages/host-adapter/src/handshake.ts index 5855fd8c..d2a694e9 100644 --- a/reference-implementations/agt/packages/host-adapter/src/handshake.ts +++ b/reference-implementations/agt/packages/host-adapter/src/handshake.ts @@ -61,7 +61,7 @@ */ import { randomUUID } from "node:crypto"; import type { GuardianClient, JsonRpcRequest } from "./guardian-client.ts"; -import { signEnvelope } from "./sign-envelope.ts"; +import { signEnvelope, type HmacKey } from "./sign-envelope.ts"; import { isSessionConfig, type SessionConfig, type SessionConfigStore } from "./session-config.ts"; const HANDSHAKE_METHOD = "handshake/hello"; @@ -77,10 +77,10 @@ export type HandshakeOptions = { agentId: string; /** This session's identity on the wire (ACS metadata.session_id, a uuid). */ sessionId: string; - /** Shared HMAC input keying material. ACS-Core signs the ClientHello too: - * the handshake negotiates capabilities, not the already-provisioned + /** The deployment's HMAC key. ACS-Core signs the ClientHello too: the + * handshake negotiates capabilities, not the already-provisioned * deployment secret. */ - hmacSecret?: Buffer; + hmacKey?: HmacKey; /** Bounds this handshake round trip (§6.4). There is no negotiated * `timeout_config` yet -- negotiating it is what this call is for -- so a * caller with nothing better should pass the ACS default @@ -205,7 +205,7 @@ export async function negotiateSessionConfig( store: SessionConfigStore, ): Promise { const requestId = randomUUID(); - const profilesSupported = options.hmacSecret ? ["acs-core"] : []; + const profilesSupported = options.hmacKey ? ["acs-core"] : []; const unsignedEnvelope = { jsonrpc: "2.0", @@ -246,8 +246,8 @@ export async function negotiateSessionConfig( }, }, } satisfies JsonRpcRequest; - const envelope = options.hmacSecret - ? signEnvelope(unsignedEnvelope, options.hmacSecret) + const envelope = options.hmacKey + ? signEnvelope(unsignedEnvelope, options.hmacKey) : unsignedEnvelope; // `post`, not `requestDecision`: this method's result is a ServerHello, not @@ -280,7 +280,7 @@ export async function negotiateSessionConfig( ); } - if (options.hmacSecret) { + if (options.hmacKey) { const coreProfiles = serverHello.profiles_accepted; const signatureAlgorithms = serverHello.signature_algorithms_supported; if (!Array.isArray(coreProfiles) || !coreProfiles.includes("acs-core")) { diff --git a/reference-implementations/agt/packages/host-adapter/src/index.ts b/reference-implementations/agt/packages/host-adapter/src/index.ts index 46305ad9..9520d5dd 100644 --- a/reference-implementations/agt/packages/host-adapter/src/index.ts +++ b/reference-implementations/agt/packages/host-adapter/src/index.ts @@ -140,6 +140,8 @@ export { isReservedSegment, findReservedKey } from "./reserved-segments.ts"; export { signEnvelope, verifyResponseSignature, + hmacKeyFromEnv, + type HmacKey, type SignedAcsRequestEnvelope, type ResponseSignatureVerificationResult, } from "./sign-envelope.ts"; diff --git a/reference-implementations/agt/packages/host-adapter/src/sign-envelope.ts b/reference-implementations/agt/packages/host-adapter/src/sign-envelope.ts index 088da2e6..98865f9a 100644 --- a/reference-implementations/agt/packages/host-adapter/src/sign-envelope.ts +++ b/reference-implementations/agt/packages/host-adapter/src/sign-envelope.ts @@ -22,6 +22,24 @@ function deriveSessionKey(masterSecret: Buffer, sessionId: string): Buffer { return Buffer.from(hkdfSync("sha256", masterSecret, Buffer.alloc(0), sessionId, 32)); } +/** A deployment's HMAC secret and the label that names it (§10, `key_id` + * for HMAC). The label comes from configuration, never from the secret or + * the session, and it is what the Guardian looks the secret up by. */ +export type HmacKey = { keyId: string; secret: Buffer }; + +/** Reads the HMAC key from ACS_HMAC_SECRET and ACS_HMAC_KEY_ID. Neither set + * means an unsigned deployment; one without the other is a configuration + * error, because a label has no default. */ +export function hmacKeyFromEnv(env: Record = process.env): HmacKey | undefined { + const secret = env.ACS_HMAC_SECRET; + const keyId = env.ACS_HMAC_KEY_ID; + if (!secret && !keyId) return undefined; + if (!secret || !keyId) { + throw new Error("ACS_HMAC_SECRET and ACS_HMAC_KEY_ID must be set together: key_id names the configured secret (Specification §10)"); + } + return { keyId, secret: Buffer.from(secret, "base64") }; +} + type SignableRequest = { params: { metadata: { session_id: string } }; }; @@ -40,15 +58,16 @@ export type ResponseSignatureVerificationResult = /** * Signs an ACS request envelope with HMAC-SHA256 using an HKDF-derived - * per-session key (§10). Returns a new envelope with params.signature - * populated. The original is not mutated. + * per-session key (§10), naming the secret by its configured `key_id`. + * Returns a new envelope with params.signature populated. The original is + * not mutated. */ export function signEnvelope( envelope: T, - masterSecret: Buffer, + key: HmacKey, ): SignedRequestEnvelope { const sessionId = envelope.params.metadata.session_id; - const sessionKey = deriveSessionKey(masterSecret, sessionId); + const sessionKey = deriveSessionKey(key.secret, sessionId); const { signature: _, ...paramsWithoutSig } = envelope.params as T["params"] & { signature?: unknown; @@ -60,15 +79,17 @@ export function signEnvelope( ...envelope, params: { ...envelope.params, - signature: { algorithm: "HMAC-SHA256", value, key_id: sessionId }, + signature: { algorithm: "HMAC-SHA256", value, key_id: key.keyId }, }, }; } -/** Verifies the Guardian's HMAC over a JSON-RPC result or error response. */ +/** Verifies the Guardian's HMAC over a JSON-RPC result or error response. + * §10: a response is signed with the key of the request it answers, so it + * must name the same `key_id` this host signed with. */ export function verifyResponseSignature( response: Record, - masterSecret: Buffer, + key: HmacKey, sessionId: string, ): ResponseSignatureVerificationResult { const result = response.result; @@ -89,8 +110,8 @@ export function verifyResponseSignature( if (fields.algorithm !== "HMAC-SHA256") { return { valid: false, reason: `unsupported response signature algorithm: ${String(fields.algorithm)}` }; } - if (fields.key_id !== sessionId) { - return { valid: false, reason: "response signature key_id does not match the session" }; + if (fields.key_id !== key.keyId) { + return { valid: false, reason: "response signature key_id does not name this host's key" }; } if (typeof fields.value !== "string") { return { valid: false, reason: "response signature value is not a string" }; @@ -100,7 +121,7 @@ export function verifyResponseSignature( const input = result === carrier ? jcsCanonicalise({ ...response, result: carrierWithoutSignature }) : jcsCanonicalise({ ...response, error: carrierWithoutSignature }); - const sessionKey = deriveSessionKey(masterSecret, sessionId); + const sessionKey = deriveSessionKey(key.secret, sessionId); const computed = createHmac("sha256", sessionKey).update(input).digest(); const supplied = Buffer.from(fields.value, "base64"); if (supplied.length !== computed.length || !timingSafeEqual(supplied, computed)) { diff --git a/reference-implementations/agt/packages/host-adapter/test/client.test.ts b/reference-implementations/agt/packages/host-adapter/test/client.test.ts index 92d1d621..e5c9f3ac 100644 --- a/reference-implementations/agt/packages/host-adapter/test/client.test.ts +++ b/reference-implementations/agt/packages/host-adapter/test/client.test.ts @@ -14,7 +14,7 @@ import { GuardianSignatureError, GuardianTimeoutError, } from "../src/guardian-client.ts"; -import { signEnvelope } from "../src/sign-envelope.ts"; +import { hmacKeyFromEnv, signEnvelope, verifyResponseSignature } from "../src/sign-envelope.ts"; import { handshakeFor } from "../../../test/helpers/handshake.ts"; import { negotiateSessionConfig, @@ -213,15 +213,17 @@ describe("GuardianClient.post", () => { it("verifies a signed Guardian response before returning its decision", async () => { const secret = Buffer.alloc(32, 0x44); + const key = { keyId: "test-key", secret }; const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: secret.toString("base64"), + hmacKeyId: key.keyId, }); try { - const envelope = signEnvelope(buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap), secret); - await handshakeFor(secured.url, envelope, hello => signEnvelope(hello as never, secret)); - const response = await createGuardianClient(secured.url, { hmacSecret: secret }).post(envelope); + const envelope = signEnvelope(buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap), key); + await handshakeFor(secured.url, envelope, hello => signEnvelope(hello as never, key)); + const response = await createGuardianClient(secured.url, { hmacKey: key }).post(envelope); expect(response.result?.decision).toBe("allow"); } finally { await secured.close(); @@ -230,16 +232,18 @@ describe("GuardianClient.post", () => { it("rejects a Guardian result that cannot be verified with this session's key", async () => { const secret = Buffer.alloc(32, 0x55); + const key = { keyId: "test-key", secret }; const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: secret.toString("base64"), + hmacKeyId: key.keyId, }); try { - const envelope = signEnvelope(buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap), secret); - await handshakeFor(secured.url, envelope, hello => signEnvelope(hello as never, secret)); + const envelope = signEnvelope(buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap), key); + await handshakeFor(secured.url, envelope, hello => signEnvelope(hello as never, key)); await expect( - createGuardianClient(secured.url, { hmacSecret: Buffer.alloc(32, 0x66) }).post(envelope), + createGuardianClient(secured.url, { hmacKey: { keyId: key.keyId, secret: Buffer.alloc(32, 0x66) } }).post(envelope), ).rejects.toThrow(GuardianSignatureError); } finally { await secured.close(); @@ -248,16 +252,18 @@ describe("GuardianClient.post", () => { it("verifies a signed Guardian error before exposing its refusal code", async () => { const secret = Buffer.alloc(32, 0x57); + const key = { keyId: "test-key", secret }; const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: secret.toString("base64"), + hmacKeyId: key.keyId, }); try { const ordinary = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); - const envelope = signEnvelope({ ...ordinary, method: "steps/unknownHook" }, secret); - await handshakeFor(secured.url, envelope, hello => signEnvelope(hello as never, secret)); - const response = await createGuardianClient(secured.url, { hmacSecret: secret }).post(envelope); + const envelope = signEnvelope({ ...ordinary, method: "steps/unknownHook" }, key); + await handshakeFor(secured.url, envelope, hello => signEnvelope(hello as never, key)); + const response = await createGuardianClient(secured.url, { hmacKey: key }).post(envelope); expect(response.error?.code).toBe(-32011); expect(response.error?.signature?.algorithm).toBe("HMAC-SHA256"); } finally { @@ -267,6 +273,7 @@ describe("GuardianClient.post", () => { it("passes an unsigned refusal through, because it can only fail the step closed", async () => { const secret = Buffer.alloc(32, 0x58); + const key = { keyId: "test-key", secret }; const mock = Bun.serve({ port: 0, async fetch(req) { @@ -276,7 +283,7 @@ describe("GuardianClient.post", () => { }); try { const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); - const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`, { hmacSecret: secret }) + const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`, { hmacKey: key }) .requestDecision(envelope); expect(outcome.decisionArrived).toBe(false); expect(classifyDeliveryFailure(outcome.decisionArrived ? undefined : outcome.failure).kind).toBe("refused"); @@ -290,6 +297,7 @@ describe("GuardianClient.post", () => { // to pass the unsigned-refusal check AND classify as refused, or the // step falls through to `proceed`. const secret = Buffer.alloc(32, 0x5b); + const key = { keyId: "test-key", secret }; const mock = Bun.serve({ port: 0, async fetch(req) { @@ -303,7 +311,7 @@ describe("GuardianClient.post", () => { }); try { const envelope = buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap); - const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`, { hmacSecret: secret }) + const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`, { hmacKey: key }) .requestDecision(envelope); expect(outcome.decisionArrived).toBe(false); const events: AuditEvent[] = []; @@ -334,6 +342,7 @@ describe("GuardianClient.post", () => { it("still rejects an unsigned error whose code is not a refusal, and an unsigned refusal carrying a result", async () => { const secret = Buffer.alloc(32, 0x5a); + const key = { keyId: "test-key", secret }; const answers = [ { error: { code: -32601, message: "not a refusal" } }, { error: { code: -32020, message: "refusal" }, result: { decision: "allow" } }, @@ -349,7 +358,7 @@ describe("GuardianClient.post", () => { try { const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); await expect( - createGuardianClient(`http://localhost:${mock.port}/acs`, { hmacSecret: secret }).post(envelope), + createGuardianClient(`http://localhost:${mock.port}/acs`, { hmacKey: key }).post(envelope), ).rejects.toThrow(GuardianSignatureError); } finally { mock.stop(true); @@ -359,14 +368,16 @@ describe("GuardianClient.post", () => { it("rejects a colliding ping allow substituted for a signed tool decision", async () => { const secret = Buffer.alloc(32, 0x59); + const key = { keyId: "test-key", secret }; const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: secret.toString("base64"), + hmacKeyId: key.keyId, }); let mock: ReturnType | undefined; try { - const tool = signEnvelope(buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap), secret); + const tool = signEnvelope(buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap), key); const { signature: _signature, ...unsignedParams } = tool.params; const ping = { ...tool, @@ -384,7 +395,7 @@ describe("GuardianClient.post", () => { fetch: () => Response.json(pingResponse), }); const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`, { - hmacSecret: secret, + hmacKey: key, }).requestDecision(tool); expect(outcome.decisionArrived).toBe(false); expect(!outcome.decisionArrived && outcome.failure).toBeInstanceOf(GuardianSignatureError); @@ -396,15 +407,17 @@ describe("GuardianClient.post", () => { it("accepts the intentionally unsigned no-echo ping only for an actual ping request", async () => { const secret = Buffer.alloc(32, 0x5a); + const key = { keyId: "test-key", secret }; const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: secret.toString("base64"), + hmacKeyId: key.keyId, }); try { const ordinary = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); const ping = { ...ordinary, method: "system/ping", params: { ...ordinary.params, payload: {} } }; - const response = await createGuardianClient(secured.url, { hmacSecret: secret }).post(ping); + const response = await createGuardianClient(secured.url, { hmacKey: key }).post(ping); expect(response.result?.decision).toBe("allow"); expect(response.result?.signature).toBeUndefined(); expect(response.result?.payload).not.toHaveProperty("echo"); @@ -703,19 +716,21 @@ describe("negotiateSessionConfig", () => { it("signs ClientHello and verifies ServerHello in an ACS-Core session", async () => { const secret = Buffer.alloc(32, 0x6a); + const key = { keyId: "test-key", secret }; const secured = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", hmacSecret: secret.toString("base64"), + hmacKeyId: key.keyId, }); try { const store = createMemorySessionConfigStore(); const sessionConfig = await negotiateSessionConfig( { - guardian: createGuardianClient(secured.url, { hmacSecret: secret }), + guardian: createGuardianClient(secured.url, { hmacKey: key }), agentId: "claude-code", sessionId: crypto.randomUUID(), - hmacSecret: secret, + hmacKey: key, }, store, ); @@ -1050,3 +1065,41 @@ describe("host -> wire -> policy -> host, end to end", () => { expect(hookSpecificOutput).toEqual({ permissionDecision: "allow" }); }); }); + +describe("HMAC key_id (Specification §10)", () => { + it("reads the secret and its label together, and refuses one without the other", () => { + const secret = Buffer.alloc(32, 0x71).toString("base64"); + expect(hmacKeyFromEnv({})).toBeUndefined(); + expect(hmacKeyFromEnv({ ACS_HMAC_SECRET: secret, ACS_HMAC_KEY_ID: "2026-10" })).toEqual({ + keyId: "2026-10", + secret: Buffer.from(secret, "base64"), + }); + expect(() => hmacKeyFromEnv({ ACS_HMAC_SECRET: secret })).toThrow(/ACS_HMAC_KEY_ID/); + expect(() => hmacKeyFromEnv({ ACS_HMAC_KEY_ID: "2026-10" })).toThrow(/ACS_HMAC_KEY_ID/); + }); + + it("signs with the configured label, and rejects a response that names another", async () => { + const key = { keyId: "2026-10", secret: Buffer.alloc(32, 0x72) }; + const secured = await startGuardian({ + port: 0, + manifestPath: "policy/manifest.yaml", + hmacSecret: key.secret.toString("base64"), + hmacKeyId: key.keyId, + }); + try { + const envelope = signEnvelope(buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap), key); + expect(envelope.params.signature.key_id).toBe("2026-10"); + await handshakeFor(secured.url, envelope, hello => signEnvelope(hello as never, key)); + const response = await createGuardianClient(secured.url).post(envelope); + const sessionId = envelope.params.metadata.session_id; + const raw = response as unknown as Record; + expect(verifyResponseSignature(raw, key, sessionId)).toEqual({ valid: true }); + expect(verifyResponseSignature(raw, { ...key, keyId: "2026-09" }, sessionId)).toEqual({ + valid: false, + reason: "response signature key_id does not name this host's key", + }); + } finally { + await secured.close(); + } + }); +}); diff --git a/reference-implementations/agt/test/readme-captures.test.ts b/reference-implementations/agt/test/readme-captures.test.ts index de603f67..c79b79ad 100644 --- a/reference-implementations/agt/test/readme-captures.test.ts +++ b/reference-implementations/agt/test/readme-captures.test.ts @@ -208,6 +208,7 @@ describe("README.md's quickstart captures are what the processes actually print" ACS_GUARDIAN_PORT: "0", ACS_MANIFEST_PATH: MANIFEST, ACS_HMAC_SECRET: Buffer.alloc(32, 7).toString("base64"), + ACS_HMAC_KEY_ID: "test-key", }, documented.split("\n").length, );