From a967b8d982d261fbbc651f7796cd497d513ce64a Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 17:17:56 +0300 Subject: [PATCH 01/39] V1 scaffold: One host, one hook, a real AGT decision --- slices/v1/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 slices/v1/README.md diff --git a/slices/v1/README.md b/slices/v1/README.md new file mode 100644 index 00000000..70b6abba --- /dev/null +++ b/slices/v1/README.md @@ -0,0 +1,9 @@ +# V1: One host, one hook, a real AGT decision + +**Demo:** Ask Claude Code for a destructive shell command. AGT's stock policy denies it, and the reason lands in the transcript. + +**Master doc:** [`docs/shaping/acs-reference-impl-slices.md`](../../docs/shaping/acs-reference-impl-slices.md) §V1 — authoritative for this slice's scope. + +**Affordances:** U1, U2, N1-N5, N20, N21, N23, N24, N28, N30, N31, S1, S7-S11, S13 — defined in [Detail C](../../docs/shaping/acs-reference-impl-shaping.md#detail-c-affordances). + +Implementation goes here. From b0746a2ca13eec5ddbecd394e85ade8d9d2ebdc0 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 17:57:30 +0300 Subject: [PATCH 02/39] V1 plan, with ten corrections amended into the shaped docs Plans V1 (#2) as a projection of the slices doc, and writes back everything planning discovered. Each correction was verified by running AGT, not by reading its docs. Load-bearing findings: - The bridge embeds the Node SDK, not Python. The PyO3 binding surfaces only action_identity; the Node binding serializes input_identity and enforced_identity distinctly. On Python, R1.4 is unverifiable and V7's N43 is impossible. Amends shaping A4. - A "./"-prefixed bundle: path silently voids all policy. AGT joins the manifest dir to the literal value, OPA mis-derives the data mount path from the "/./" segment and drops data.json, and every decision becomes allow with no error -- a fail-open in a governance tool. - data_paths cannot deliver data.agt.defaults.config while bundle: is set; the bundle owns the data tree, so S8 ships as policy/lib/data.json. - The stock bundle ships no shell patterns, only PII regexes. The deciding module is stock; the destructive-command list is our config. R2.1 holds, but the demo narration is corrected so it does not overclaim (R4.4). D7 closes as Rego: the SDK bundles OPA 0.70.0, so Cedar's only advantage never existed. Stock bundle passes 105/105 under both that and OPA 1.18.2. Slice: #2 --- docs/shaping/acs-reference-impl-shaping.md | 18 +- docs/shaping/acs-reference-impl-slices.md | 30 +- .../plans/2026-08-09-v1-one-host-one-hook.md | 645 ++++++++++++++++++ 3 files changed, 677 insertions(+), 16 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-09-v1-one-host-one-hook.md diff --git a/docs/shaping/acs-reference-impl-shaping.md b/docs/shaping/acs-reference-impl-shaping.md index ebcdecd4..a4ccbd8b 100644 --- a/docs/shaping/acs-reference-impl-shaping.md +++ b/docs/shaping/acs-reference-impl-shaping.md @@ -127,7 +127,7 @@ The literal reading of the spec. Hosts are ACS clients; a Guardian is a server; | A1.2 | OpenCode: same shape, driven from `tool.execute.before` / `.after` in-process plugin hooks | | | **A2** | ACS Guardian service: JSON-RPC 2.0 over HTTP, validates every envelope against the v0.1.0 schemas | | | **A3** | Session layer in the Guardian: SessionContext hash chain, Intent, provenance lineage; persists AGT `result_labels` and re-supplies as `input.ifc.source_labels` | | -| **A4** | AGT bridge: embeds the AGT Python SDK; ACS envelope → 5-member policy input; `evaluate_intervention_point`; verdict → ACS decision | | +| **A4** | AGT bridge: embeds the AGT **Node** SDK ⚠️ *amended, was Python*; ACS envelope → 5-member policy input; `evaluateInterventionPoint`; verdict → ACS decision | | | **A5** | Envelope tap: every request and response rendered as pretty JSON in a live viewer | | | **A6** | Demo runbook: one AGT policy bundle, both hosts, side by side with AGT's native packages | | @@ -181,6 +181,7 @@ Post-spike. All flags cleared, so the check now discriminates. - R5 fails B: envelopes that are never serialized are not inspectable on the wire, which is what R5.1 asks for. - R6 fails B: an in-process Guardian sharing a heap with the host adapter makes the stateless/stateful split an assertion rather than an observable property. - **C is selected.** It carries every requirement A does and is the only shape that proves R1. +- ⚠️ **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. --- @@ -201,7 +202,7 @@ All resolved — see `spike-agt-integration.md`. |---|------| | F1 | Confirm by hand that Claude Code `PostToolUse.updatedToolOutput` rewrites tool results as documented | | F2 | Confirm an OpenCode plugin can express deny and modify through `tool.execute.before` / `.after` | -| F3 | Decide Rego (canonical, needs `opa` CLI) versus Cedar (zero extra binary) for the demo bundle | +| ~~F3~~ | ✅ **Resolved: Rego.** The premise was wrong — the SDK bundles OPA 0.70.0 as a platform package, so Rego needs no external binary and Cedar's only advantage disappears. Stock bundle verified 105/105 under the bundled OPA and system OPA 1.18.2. Closes D7 | --- @@ -263,8 +264,8 @@ All resolved — see `spike-agt-integration.md`. | N26 | P3 | guardian | `writeEnvelopeTap()` | 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 | `handshakeResponder()` — ServerHello: `timeout_config`, `on_decision_failure`, `profiles_accepted` | call | → N26 | → N5, → N14 | -| N30 | P3.1 | agt-bridge | `evaluate_intervention_point(point, snapshot)` | call | — | → N24 | -| N31 | P3.1 | agt-bridge | `AgentControl.from_path(manifest.yaml)` at boot | 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 | `acs-agt-conformance` runner | call | → N41, → N42, → N43, → N44 | — | | N41 | P5 | conformance | intervention-point round trip, validated against `policy-input.schema.json` | call | — | → N47 | | N42 | P5 | conformance | verdict round trip: AGT verdict → ACS decision → AGT verdict, assert identity | call | — | → N47 | @@ -357,8 +358,8 @@ flowchart TB S6["S6: envelope log"] subgraph P31["P3.1: AGT bridge"] - N30["N30: evaluate_intervention_point()"] - N31["N31: AgentControl.from_path()"] + N30["N30: evaluateInterventionPoint()"] + N31["N31: AgentControl.fromPath()"] S7["S7: manifest.yaml"] S8["S8: data.agt.defaults.config"] S9["S9: AGT stock bundle (pinned)"] @@ -523,5 +524,6 @@ flowchart TB | 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 | -| D7 | F3 — Rego or Cedar for the demo bundle | Open | Rego is the canonical default binding; Cedar removes an external binary from setup | -| D8 | 🟡 Which `on_decision_failure` the reference ships as its default | Open | The spec default is `proceed` (fail-open). Shipping the spec default is the honest choice, but a security-facing demo that fails open needs the audit trail on screen (U23) to read correctly | +| ~~D7~~ | F3 — Rego or Cedar for the demo bundle | ✅ **Decided: Rego** | The deciding factor was wrong. Cedar's advantage was removing an external binary, but the SDK ships OPA 0.70.0 as a platform package — so Rego, the canonical binding, costs nothing extra. Verified: stock bundle 105/105 under the bundled OPA | +| D8 | 🟡 Which `on_decision_failure` the reference ships as its default | Open, leaning `proceed` | The spec default is `proceed` (fail-open). Shipping the spec default is the honest choice, but a security-facing demo that fails open needs the audit trail on screen (U23) to read correctly. V1 negotiates and stores it (N5/N28/S13); V3 applies it (N6), so the decision is only needed by V3 | +| D9 | ⚠️ **New.** Report the `./` bundle-path fail-open upstream to AGT? | Open | A `./`-prefixed `bundle:` silently voids all policy and returns `allow` with no error. It is a fail-open in a governance tool and affects any AGT host, not just us. Reporting is the good-citizen move and consistent with R4.3's non-adversarial framing; it is also unattributed outbound traffic, so it needs an explicit decision before anything is sent | diff --git a/docs/shaping/acs-reference-impl-slices.md b/docs/shaping/acs-reference-impl-slices.md index cf02681a..6dc13c46 100644 --- a/docs/shaping/acs-reference-impl-slices.md +++ b/docs/shaping/acs-reference-impl-slices.md @@ -31,6 +31,8 @@ Every slice ends in something demo-able. **Demo:** In Claude Code, ask for a destructive shell command. AGT's stock policy denies it; the deny reason appears in the transcript. +**⚠️ Framing correction (R4.4).** The stock bundle ships **no** shell or command patterns — `patterns.rego` carries generic PII regexes only. What is stock is the *deciding module* (`agt.patterns`) and the priority chain in `agt_default.rego`; the destructive-command regex list is ours, supplied as configuration. R2.1 still holds exactly — zero Rego authored, behaviour driven only through `data.agt.defaults.config` — but the demo must be narrated as "AGT's stock policy engine, configured", never as "Microsoft ships an `rm -rf` deny-list". Overclaiming here would breach R4.4. + | # | Place | Component | Affordance | Control | Wires Out | Returns To | |---|-------|-----------|------------|---------|-----------|------------| | U1 | P1 | claude-code | prompt input | type | → N1 | — | @@ -46,18 +48,24 @@ Every slice ends in something demo-able. | N21 | P3 | guardian | `validateEnvelope()` against v0.1.0 schemas | call | → N23 | — | | N23 | P3 | guardian | `assembleSnapshot()` — envelope → AGT snapshot | call | → N30 | — | | N24 | P3 | guardian | `mapVerdict()` — AGT verdict → ACS decision | call | — | → N4 | -| N30 | P3.1 | agt-bridge | `evaluate_intervention_point(point, snapshot)` | call | — | → N24 | -| N31 | P3.1 | agt-bridge | `AgentControl.from_path(manifest.yaml)` at boot | call | — | → N30 | +| N30 | P3.1 | agt-bridge | `evaluateInterventionPoint(point, snapshot)` | call | — | → N24 | +| N31 | P3.1 | agt-bridge | `AgentControl.fromPath(manifest.yaml)` at boot | call | — | → N30 | | S1 | P1 | store | `claude-code.hookmap.yaml` | — | — | → N2, N3 | -| S7 | P3.1 | store | `manifest.yaml`, binding `rego` → `data.agt.defaults.verdict` | — | — | → N31 | -| S8 | P3.1 | store | `data.agt.defaults.config` | — | — | → N31 | -| S9 | P3.1 | store | AGT stock bundle at pinned ref | — | — | → N31 | +| S7 | P3.1 | store | `manifest.yaml`, binding `rego` → `data.agt.defaults.verdict`; `policy_target` **must** resolve to a leaf string (`$.tool_call.args.command`) | — | — | → N31 | +| S8 | P3.1 | store | `data.agt.defaults.config`, shipped as `policy/lib/data.json` **inside** the bundle directory | — | — | → N31 | +| S9 | P3.1 | store | AGT stock bundle at pinned ref, every `.rego` byte-identical | — | — | → N31 | | S10 | shared | store | `mapping.yaml` | — | — | → N23, N24 | | S11 | shared | store | `agt.lock` | — | — | → N31 | **Scope note.** Only `pre_tool_call` is wired. No session state, no tap, no second host. `N23` assembles the snapshot from the envelope alone; it starts reading S3/S4/S5 in V6. -**Setup cost this slice absorbs:** the `opa` CLI on PATH (S9 needs it), the pinned AGT checkout, and the first cut of `mapping.yaml`. +**Setup cost this slice absorbs:** ⚠️ *amended* — the `opa` CLI is **no longer a setup cost*. The npm package pulls `agent-control-specification-opa-darwin-arm64`, which ships OPA 0.70.0, overridable via `ACS_OPA_PATH` / `ACS_OPA_NO_BUNDLE`. The stock bundle passes 105/105 under both it and system OPA 1.18.2. What remains: the pinned AGT checkout and the first cut of `mapping.yaml`. **This closes D7 as Rego** — Cedar's only advantage was removing an external binary, and there is no external binary. + +**⚠️ Watch-for — the `./` landmine.** `policies..bundle` must **not** begin with `./`. AGT joins the manifest directory to the literal value, yielding `/./policy/lib`; OPA's bundle loader mis-derives the data mount path from the `/./` segment and silently drops `data.json`. The policy then matches nothing and **every decision becomes `allow`** — a fail-open with no error, in a governance tool. Measured: `./policy/lib` loads, `/abs/policy/lib` loads, `/abs/./policy/lib` is UNDEFINED. `createBridge` throws on `/./`, and V1's deny test is the backstop. + +**⚠️ Amendment — the config lives inside the bundle.** `data_paths` cannot deliver `data.agt.defaults.config` while `bundle:` is set: the stock bundle ships no `.manifest`, so its roots default to `""`, it owns the whole data tree, and the `--data` document is discarded. S8 therefore ships as `policy/lib/data.json`. Every stock `.rego` stays byte-identical; `data.json` is the only added file, so R2.1/R2.3 hold — we author a data document, not policy. + +**⚠️ Amendment — the bridge embeds the Node SDK, not the Python SDK.** The PyO3 binding sets only `action_identity`; the Node binding sets `input_identity` and `enforced_identity` distinctly (`sdk/node/native/lib.rs:191-204`). On Python, R1.4 is unverifiable and V7's N43 is impossible. This also makes the whole repo one TypeScript toolchain. Amends shaping A4. --- @@ -204,9 +212,12 @@ Runs on a schedule in CI. MS-ACS is `0.3.1-beta` and warns of breaking changes b |---|------|-------|----------| | 1 | `updatedToolOutput` does not behave as documented | V4 | Confirm early (F1). V4 drops cleanly if it fails | | 2 | OpenCode plugin cannot express modify | V5 | Confirm early (F2). Falls back to deny-only, weakening but not breaking V5 | -| 3 | `opa` CLI dependency raises setup friction | V1 | Cedar is the built-in fallback with a parity library — that is D7 | +| 3 | ~~`opa` CLI dependency raises setup friction~~ | ~~V1~~ | ✅ **Retired.** The SDK ships OPA 0.70.0 in `agent-control-specification-opa-`. No external binary, so D7 closes as Rego | | 4 | Two model-call cells cannot go green on v0.1.0 | V7 | Ship red with a stated reason; drive `steps/modelCall` into v0.2 | | 5 | Upstream AGT breaks the contract mid-project | all | V8 exists for this, but lands late — consider pulling N45/N46 forward if upstream churn shows up during V1 | +| 6 | ⚠️ A `./`-prefixed `bundle:` path silently disables policy — every decision becomes `allow`, with no error | V1 | `createBridge` throws on `/./`; V1's deny test is the backstop. Worth reporting upstream: a fail-open in a governance tool | +| 7 | ⚠️ `enforced_identity` bisection is unavailable over AGT's Python binding | V7 | Resolved by embedding the **Node** SDK, which serializes `input_identity` and `enforced_identity` distinctly. Had we stayed on Python, R1.4 would be unverifiable and N43 impossible | +| 8 | ⚠️ AGT's verdict carries no `rule_id` / `reason_codes` / `reasoning` | V1, V7 | `mapVerdict` synthesizes them from `reason` / `message`, and `mapping.yaml` is where that synthesis is declared — so V7 measures it rather than assuming it | ## Open decisions carried from shaping @@ -216,4 +227,7 @@ Runs on a schedule in CI. MS-ACS is `0.3.1-beta` and warns of breaking changes b | D3 | Hook coverage beyond AGT's eight | V7 scope | | D4 | Spec `steps/modelCall` for v0.2 as part of this work | V7 red cells | | D5 | Determinism of the demo | V1 onward | -| D7 | Rego versus Cedar for the demo bundle | V1 | +| ~~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~~ | +| D8 | 🟡 Which `on_decision_failure` ships as default — V1 negotiates and stores it (N5/N28/S13); V3 applies it (N6). Leaning to the spec default `proceed`, paired with U23's audit count | V3 | + +**Correction log.** V1 planning verified the AGT surface by running it rather than reading it, and produced ten corrections — the SDK choice, the `./` landmine, config-inside-the-bundle, the absent stock shell patterns, the leaf `policy_target`, AGT's missing `rule_id`/`reason_codes`/`reasoning`, lowercase wire decisions, `steps/toolCallRequest` and the 19-hook count, the retired `opa` setup cost, and the Python identity collapse. Each is recorded above at the row it governs, with its evidence, in `docs/superpowers/plans/2026-08-09-v1-one-host-one-hook.md`. diff --git a/docs/superpowers/plans/2026-08-09-v1-one-host-one-hook.md b/docs/superpowers/plans/2026-08-09-v1-one-host-one-hook.md new file mode 100644 index 00000000..3f3aad85 --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-v1-one-host-one-hook.md @@ -0,0 +1,645 @@ +# V1 — One host, one hook, a real AGT decision + +## Slice Contract + +| Field | Value | +|---|---| +| Slice ID | [#2](https://github.com/afogel/ACS_reference_implementation/issues/2) (epic [#1](https://github.com/afogel/ACS_reference_implementation/issues/1), PR [#10](https://github.com/afogel/ACS_reference_implementation/pull/10)) | +| Slices doc | `docs/shaping/acs-reference-impl-slices.md` §V1, line 30 | +| Demo | "In Claude Code, ask for a destructive shell command. AGT's stock policy denies it; the deny reason appears in the transcript." | +| Components | U1, U2 (Claude Code surfaces) · N1 (hook shim) · N2 `buildEnvelope` · N3 `renderDecision` · N4 `guardianClient.post` · N5 `handshake` · N20 `POST /acs` · N21 `validateEnvelope` · N23 `assembleSnapshot` · N24 `mapVerdict` · N28 `handshakeResponder` · N30 `evaluateInterventionPoint` · N31 `AgentControl.fromPath` · S1 hookmap · S7 manifest · S8 config · S9 stock bundle · S10 `mapping.yaml` · S11 `agt.lock` · S13 negotiated session config | +| Parked items | Session state (S3/S4/S5) → **V6**. Envelope tap (N26/S6) and Inspector (P4) → **V2**. Failure posture application (N6/N7/S14) → **V3**. `denyOnInvalidEnvelope` (N27) → **V3**. Output redaction (U3) → **V4**. Second host (P2) → **V5**. Conformance harness (P5) → **V7**. | +| Watch-for | "Only `pre_tool_call` is wired. No session state, no tap, no second host. N23 assembles the snapshot from the envelope alone; it starts reading S3/S4/S5 in V6." (slices doc line 58) | +| Corrections | Ten, all discovered during planning and all verified empirically — see **Corrections discovered during planning** below. Each is amended into the slices doc in this PR. | +| Requirements | R0, R1.3, R2.1, R2.2, R2.3, R3.2, R3.3, R5.2, R6.1, R7.1, R7.2 | + +**Global Constraints** (bind every task; copy verbatim into reviewer dispatches): + +1. **Zero Rego authored.** Policy behaviour is configured only through `data.agt.defaults.config`. No `.rego` file is written or edited by us. (R2.1) +2. **Stock bundle byte-identical.** Every `.rego` under `policy/lib/` matches AGT at the pinned ref exactly. The only permitted addition to that directory is `data.json`. (R2.2, R2.3) +3. **The host adapter contains zero AGT-specific code.** No file under `packages/host-adapter/` may mention AGT, Rego, OPA, verdicts, or intervention points. Verifiable by grep. (R3.2) +4. **The AGT bridge contains zero host-specific code.** No file under `packages/agt-bridge/` may mention Claude Code, hooks, OpenCode, or stdin/stdout hook protocols. Verifiable by grep. (R3.3) +5. **ACS decisions are lowercase on the wire** — `allow`, `deny`, `modify`, `ask`, `defer`. Uppercase appears in spec prose only and must never be emitted. +6. **The bundle path in `policy/manifest.yaml` must not begin with `./`.** See Correction C2 — a `./` prefix silently disables the entire policy and every decision becomes `allow`. +7. **AGT is stateless.** Nothing under `packages/agt-bridge/` persists anything between calls. (R6.1) + +--- + +## Corrections discovered during planning + +Every one of these was verified by running code, not by reading docs. They are amended into the slices doc in this PR. + +| # | Correction | Evidence | Amends | +|---|---|---|---| +| C1 | **The bridge embeds the Node SDK, not the Python SDK.** The PyO3 binding sets only `action_identity`; the Node binding sets `input_identity` and `enforced_identity` distinctly. Python would make R1.4 unverifiable and V7's N43 impossible. | `sdk/python/src/lib.rs:152-154` vs `sdk/node/native/lib.rs:191-204`; confirmed on the installed package — both fields present on the result | Shaping A4; slices §V1 N30/N31 | +| C2 | **`bundle:` must not start with `./`.** AGT joins the manifest dir to the literal value, producing `/./policy/lib`; OPA's bundle loader mis-derives the data mount path from a `/./` segment and drops `data.json`. The policy then matches nothing and returns `allow` — a silent fail-open. | Measured: `./policy/lib` → config loads; `/abs/policy/lib` → loads; `/abs/./policy/lib` → UNDEFINED | New watch-for + risk row | +| C3 | **Config must live inside the bundle directory as `data.json`.** `data_paths` cannot deliver `data.agt.defaults.config` when `bundle:` is set: the bundle ships no `.manifest`, so its roots default to `""`, it owns the whole data tree, and the `--data` document is discarded. | Traced argv: `opa eval --bundle --data ` → config UNDEFINED; same with `-d` → config loads | slices §V1 S8 | +| C4 | **The stock bundle ships no shell/command patterns** — only generic PII regexes. `agt.patterns` is the module that denies, but the destructive-command regex list is authored by us as config. R2.1 still holds (zero Rego), but the demo must not claim Microsoft ships an `rm -rf` deny-list. | `policy/lib/patterns.rego:17-33` | slices §V1 demo framing | +| C5 | **`policy_target` must resolve to a leaf string.** `pattern_text()` falls back to `input.policy_target.value` and requires `is_string`. Binding `$.tool_call.args` (an object) makes the check silently never fire. | `agt_default.rego:86-92` | slices §V1 S7 | +| C6 | **AGT's verdict has no `rule_id`, no `reason_codes`, no `reasoning`.** Its shape is `{decision, reason, message, transform?, evidence?, result_labels?}`. ACS's richer fields must be synthesized by `mapVerdict`, and that synthesis is `mapping.yaml`'s job. | `core/src/verdict.rs:99-121`; `grep -rn rule_id policy-engine/spec/` → zero matches | slices §V1 N24/S10 | +| C7 | **ACS decisions are lowercase on the wire.** `docs/acs.md`'s `response.action == "DENY"` pseudocode contradicts `response-envelope.json`. | `specification/v0.1.0/response-envelope.json` | Shaping N7 (writes `DENY`) | +| C8 | **The method is `steps/toolCallRequest`**, and ACS v0.1.0 defines **19** `steps/*` hooks, not the 16 in `specification.md` §5's stale table. | `hooks.md`; 19 payload schemas on disk | slices §V1 N2; D3 framing | +| C9 | **`opa` on PATH is no longer a setup cost.** The npm package pulls `agent-control-specification-opa-darwin-arm64`, shipping OPA 0.70.0, with `ACS_OPA_PATH` / `ACS_OPA_NO_BUNDLE` overrides. The stock bundle passes 105/105 under both that and system OPA 1.18.2. | `bun add` output; `opa test` under both binaries | slices §V1 setup-cost note; **closes D7** | +| C10 | **`enforced_identity` bisection is unavailable over the Python binding** and available over Node. Recorded because it is the reason C1 matters beyond convenience, and because V7's N43 depends on it. | as C1 | New risk row | + +--- + +## Slice accounting + +| From the slice | Handled by | Note | +|---|---|---| +| U1 prompt input | Task 9 | Claude Code's own surface; exercised by the runbook | +| U2 tool permission outcome in transcript | Task 9 | `permissionDecisionReason` carries the deny reason | +| N1 hook shim | Task 9 | | +| N2 `buildEnvelope` | Task 7 | | +| N3 `renderDecision` | Task 8 | | +| N4 `guardianClient.post` | Task 8 | | +| N5 `handshake` | Task 8 | | +| N20 `POST /acs` | Task 6 | | +| N21 `validateEnvelope` | Task 5 | | +| N23 `assembleSnapshot` | Task 4 | Envelope-only, per the watch-for | +| N24 `mapVerdict` | Task 3 | | +| N28 `handshakeResponder` | Task 6 | | +| N30 `evaluateInterventionPoint` | Task 2 | | +| N31 `AgentControl.fromPath` | Task 2 | | +| S1 `claude-code.hookmap.yaml` | Task 7 | | +| S7 `manifest.yaml` | Task 2 | | +| S8 `data.agt.defaults.config` | Task 2 | Ships as `policy/lib/data.json` per C3 | +| S9 AGT stock bundle | Task 1 | | +| S10 `mapping.yaml` | Task 3 | | +| S11 `agt.lock` | Task 1 | | +| S13 negotiated session config | Task 8 | Stored; *applied* in V3 | +| Watch-for: only `pre_tool_call` wired | Tasks 2, 4 honour it | | +| Watch-for: N23 envelope-only | Task 4 honours it | | +| R2.1 policy used as shipped | Task 1 (byte-identity test), Task 2 | | +| R3.2 adapter has zero AGT code | Task 10 (grep gate) | | +| R3.3 bridge has zero host code | Task 10 (grep gate) | | +| R7.1 one command on a laptop | Task 10 | | +| Setup cost: `opa` CLI | **obsolete** — see C9 | | + +--- + +## Tasks + +### Task 1: Workspace, vendored bundle, and the pin · slice #2 · S9, S11 + +**Files:** +- Create: `package.json`, `tsconfig.base.json`, `.gitignore` (append), `agt.lock` +- Create: `policy/lib/**` (vendored from AGT `81955d48025c6b11deb3fc9dabf89f74f4145775`, path `policy-engine/policy/lib`) +- Test: `test/pin.test.ts` + +**Interfaces:** +- Produces: `agt.lock` — `{ "agt_ref": "<40-hex>", "agt_repo": "https://github.com/microsoft/agent-governance-toolkit", "sdk_package": "agent-control-specification", "sdk_version": "0.3.1-beta.0", "bundle_path": "policy-engine/policy/lib" }`. Read by Task 2 and, in V8, by `diffSurfaces()`. + +- [ ] **Step 1: Write the failing test** — `test/pin.test.ts` + +```ts +import { describe, expect, it } from "bun:test"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +const lock = JSON.parse(readFileSync("agt.lock", "utf8")); + +describe("AGT pin", () => { + it("records a full 40-character commit ref", () => { + expect(lock.agt_ref).toMatch(/^[0-9a-f]{40}$/); + }); + + it("pins the SDK version the bridge installs", () => { + const pkg = JSON.parse(readFileSync("package.json", "utf8")); + const dep = + pkg.dependencies?.["agent-control-specification"] ?? + pkg.devDependencies?.["agent-control-specification"]; + expect(dep).toBeDefined(); + expect(dep.replace(/^[^0-9]*/, "")).toBe(lock.sdk_version); + }); + + it("vendors the stock bundle with every stock module present", () => { + const files = readdirSync("policy/lib").filter((f) => f.endsWith(".rego")); + for (const mod of [ + "agt_default.rego", "agt_ifc.rego", "approval.rego", "budgets.rego", + "confidence.rego", "content_hash.rego", "drift.rego", "egress.rego", + "ifc.rego", "patterns.rego", "redact.rego", + ]) { + expect(files).toContain(mod); + } + }); + + it("adds nothing to the bundle except data.json", () => { + const extra = readdirSync("policy/lib").filter( + (f) => !f.endsWith(".rego") && f !== "run_tests.sh" && f !== "data.json", + ); + expect(extra).toEqual([]); + }); + + it("authors no Rego of our own — every .rego is byte-identical to upstream", () => { + // UPSTREAM_BUNDLE is set by `bun run verify:pin`, which clones the pinned ref. + const upstream = process.env.UPSTREAM_BUNDLE; + if (!upstream) return; // skipped in the fast unit run; enforced by verify:pin in CI + for (const f of readdirSync("policy/lib").filter((f) => f.endsWith(".rego"))) { + expect(readFileSync(join("policy/lib", f), "utf8")).toBe( + readFileSync(join(upstream, f), "utf8"), + ); + } + }); +}); +``` + +- [ ] **Step 2: Run it, expect FAIL** — `bun test test/pin.test.ts` → fails on missing `agt.lock`. +- [ ] **Step 3: Minimal implementation** — create the workspace and vendor the bundle. + +`package.json`: +```json +{ + "name": "acs-reference-implementation", + "private": true, + "type": "module", + "workspaces": ["packages/*"], + "dependencies": { "agent-control-specification": "0.3.1-beta.0" }, + "scripts": { + "test": "bun test", + "verify:pin": "bash scripts/verify-pin.sh" + } +} +``` + +`agt.lock`: +```json +{ + "agt_repo": "https://github.com/microsoft/agent-governance-toolkit", + "agt_ref": "81955d48025c6b11deb3fc9dabf89f74f4145775", + "bundle_path": "policy-engine/policy/lib", + "sdk_package": "agent-control-specification", + "sdk_version": "0.3.1-beta.0" +} +``` + +`scripts/verify-pin.sh` clones the pinned ref into a temp dir, exports `UPSTREAM_BUNDLE`, and re-runs the pin test so the byte-identity assertion actually executes. + +- [ ] **Step 4: Run it, expect PASS** — `bun test test/pin.test.ts` and `bun run verify:pin`. +- [ ] **Step 5: Commit** — `Slice: #2` / `Affordances: S9, S11` + +--- + +### Task 2: The AGT bridge · slice #2 · N30, N31, S7, S8 + +The single highest-risk task, and the one whose behaviour is already measured — the code below is the verified probe, not a sketch. + +**Files:** +- Create: `packages/agt-bridge/package.json`, `packages/agt-bridge/src/index.ts` +- Create: `policy/manifest.yaml` (S7), `policy/lib/data.json` (S8) +- Test: `packages/agt-bridge/test/bridge.test.ts` + +**Interfaces:** +- Produces: + ```ts + export type AgtVerdict = { + decision: "allow" | "deny" | "warn" | "escalate" | "transform"; + reason?: string; message?: string; + transform?: { path: string; value: unknown }; + result_labels?: string[]; + }; + export type BridgeResult = { + verdict: AgtVerdict; + inputIdentity?: string; + enforcedIdentity?: string; + transformedPolicyTarget?: unknown; + }; + export function createBridge(manifestPath: string): { + evaluate(point: string, snapshot: Record): Promise; + }; + ``` + Consumed by Task 3 (`mapVerdict`) and Task 6 (the Guardian). + +**Constraint:** no file in this package may name a host. It receives an assembled snapshot and returns a verdict. + +- [ ] **Step 1: Write the failing test** — `packages/agt-bridge/test/bridge.test.ts` + +```ts +import { describe, expect, it, beforeAll } from "bun:test"; +import { createBridge } from "../src/index.ts"; + +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" }, +}); + +let bridge: ReturnType; +beforeAll(() => { bridge = createBridge("policy/manifest.yaml"); }); + +describe("agt-bridge", () => { + it("denies a destructive shell command using the stock bundle", async () => { + const r = await bridge.evaluate("pre_tool_call", snapshotFor("rm -rf /")); + expect(r.verdict.decision).toBe("deny"); + expect(r.verdict.reason).toBe("destructive_shell_command_blocked"); + expect(r.verdict.message).toContain("matched pattern"); + }); + + it("denies the -fr spelling too", async () => { + const r = await bridge.evaluate("pre_tool_call", snapshotFor("rm -fr / --no-preserve-root")); + expect(r.verdict.decision).toBe("deny"); + }); + + it("allows benign commands", async () => { + for (const cmd of ["ls -la", "git status"]) { + expect((await bridge.evaluate("pre_tool_call", snapshotFor(cmd))).verdict.decision).toBe("allow"); + } + }); + + // Guards Correction C2 — the failure mode this catches is a SILENT fail-open. + it("surfaces the policy config to Rego (guards the ./ bundle-path landmine)", async () => { + const r = await bridge.evaluate("pre_tool_call", snapshotFor("rm -rf /")); + expect(r.verdict.decision).not.toBe("allow"); + }); + + // Guards Correction C1 — this is why the bridge is Node, not Python. + it("returns input and enforced identity as distinct fields", async () => { + const r = await bridge.evaluate("pre_tool_call", snapshotFor("ls -la")); + expect(r.inputIdentity).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(r.enforcedIdentity).toMatch(/^sha256:[0-9a-f]{64}$/); + }); +}); +``` + +- [ ] **Step 2: Run it, expect FAIL** — `bun test packages/agt-bridge` → module not found. +- [ ] **Step 3: Minimal implementation** + +`policy/manifest.yaml` (S7) — note `bundle: policy/lib` with **no** `./`, per C2, and the leaf `policy_target`, per C5: +```yaml +agent_control_specification_version: "0.3.1-beta" +metadata: + name: "acs-reference-implementation" +policies: + agt_stock: + type: rego + bundle: policy/lib + query: data.agt.defaults.verdict +intervention_points: + pre_tool_call: + policy_target: "$.tool_call.args.command" + policy_target_kind: tool_args + tool_name_from: "$.tool_call.name" + policy: + id: agt_stock +tools: + run_shell: + type: Tool + id: run_shell + security_labels: [shell] +``` + +`policy/lib/data.json` (S8) — the only file we add to the bundle, per C3: +```json +{ + "agt": { + "defaults": { + "config": { + "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|$)" + ], + "reason": "destructive_shell_command_blocked" + } + } + } + } +} +``` + +`packages/agt-bridge/src/index.ts`: +```ts +import { AgentControl } from "agent-control-specification"; + +export type AgtVerdict = { + decision: "allow" | "deny" | "warn" | "escalate" | "transform"; + reason?: string; + message?: string; + transform?: { path: string; value: unknown }; + result_labels?: string[]; +}; + +export type BridgeResult = { + verdict: AgtVerdict; + inputIdentity?: string; + enforcedIdentity?: string; + transformedPolicyTarget?: unknown; +}; + +/** + * N31 — construct once at boot. N30 — evaluate per decision. + * Stateless: nothing is retained between evaluate() calls (R6.1). + */ +export function createBridge(manifestPath: string) { + if (manifestPath.includes("/./")) { + throw new Error( + `manifest path contains "/./": ${manifestPath}. AGT joins this verbatim and OPA ` + + `then drops the bundle's data document, silently disabling policy. See C2.`, + ); + } + const control = AgentControl.fromPath(manifestPath); + + return { + async evaluate(point: string, snapshot: Record): Promise { + const result = await control.evaluateInterventionPoint(point, snapshot as never); + return { + verdict: result.verdict as AgtVerdict, + inputIdentity: result.inputIdentity, + enforcedIdentity: result.enforcedIdentity, + transformedPolicyTarget: result.transformedPolicyTarget, + }; + }, + }; +} +``` + +- [ ] **Step 4: Run it, expect PASS** — `bun test packages/agt-bridge`. +- [ ] **Step 5: Commit** — `Slice: #2` / `Affordances: N30, N31, S7, S8` + +--- + +### Task 3: `mapping.yaml` and `mapVerdict` · slice #2 · S10, N24 + +S10 is the load-bearing artifact: the same file drives the runtime here and the conformance harness in V7. If they diverge, V7 goes red. + +**Files:** +- Create: `mapping.yaml`, `packages/guardian/src/map-verdict.ts` +- Test: `packages/guardian/test/map-verdict.test.ts` + +**Interfaces:** +- Consumes: `AgtVerdict` from Task 2. +- Produces: `mapVerdict(v: AgtVerdict, mapping: Mapping): AcsDecision` where + ```ts + export type AcsDecision = { + decision: "allow" | "deny" | "modify" | "ask" | "defer"; + reasoning?: string; + reason_codes?: string[]; + policy_references?: { policy_id: string; policy_version?: string; rule_id: string }[]; + }; + ``` + +**Mapping rules** (C6 — AGT carries no `rule_id`/`reason_codes`/`reasoning`, so these are synthesized): +`allow`→`allow` · `deny`→`deny` · `warn`→`allow` **with non-empty `policy_references`** (R1.2) · `escalate`→`ask` · `transform`→`modify`. +`reasoning` ← `verdict.message` · `reason_codes` ← `[verdict.reason]` · `policy_references[0].rule_id` ← `verdict.reason`. + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, it } from "bun:test"; +import { loadMapping, mapVerdict } from "../src/map-verdict.ts"; + +const m = loadMapping("mapping.yaml"); + +describe("mapVerdict", () => { + it("maps allow to allow", () => { + expect(mapVerdict({ decision: "allow" }, m).decision).toBe("allow"); + }); + + it("maps deny, carrying reason and message into ACS fields", () => { + const d = mapVerdict( + { decision: "deny", reason: "destructive_shell_command_blocked", message: "matched pattern X" }, + m, + ); + expect(d.decision).toBe("deny"); + expect(d.reasoning).toBe("matched pattern X"); + expect(d.reason_codes).toEqual(["destructive_shell_command_blocked"]); + expect(d.policy_references?.[0].rule_id).toBe("destructive_shell_command_blocked"); + }); + + // R1.2 — the whole warn round trip rests on this. + it("maps warn to allow WITH non-empty policy_references", () => { + const d = mapVerdict({ decision: "warn", reason: "drift_detected", message: "drift 0.8" }, m); + expect(d.decision).toBe("allow"); + expect(d.policy_references?.length).toBeGreaterThan(0); + expect(d.policy_references?.[0].rule_id).toBe("drift_detected"); + }); + + it("distinguishes warn-allow from clean allow by policy_references", () => { + expect(mapVerdict({ decision: "allow" }, m).policy_references ?? []).toHaveLength(0); + }); + + it("maps escalate to ask and transform to modify", () => { + expect(mapVerdict({ decision: "escalate", reason: "approval_required" }, m).decision).toBe("ask"); + expect(mapVerdict({ decision: "transform", reason: "redacted" }, m).decision).toBe("modify"); + }); + + it("emits only lowercase decisions (C7)", () => { + for (const dec of ["allow", "deny", "warn", "escalate", "transform"] as const) { + const out = mapVerdict({ decision: dec, reason: "r" }, m).decision; + expect(out).toBe(out.toLowerCase()); + } + }); +}); +``` + +- [ ] **Step 2: Run it, expect FAIL.** +- [ ] **Step 3: Minimal implementation** — `mapping.yaml` holds the verdict table and the point table (the latter read by V7): + +```yaml +acs_version: "0.1.0" +agt_version: "0.3.1-beta" + +intervention_points: + pre_tool_call: { acs_method: "steps/toolCallRequest" } + post_tool_call: { acs_method: "steps/toolCallResult" } + agent_startup: { acs_method: "steps/sessionStart" } + agent_shutdown: { acs_method: "steps/sessionEnd" } + input: { acs_method: "steps/userMessage" } + output: { acs_method: "steps/agentResponse" } + pre_model_call: { acs_method: null, note: "no ACS v0.1.0 target — D4, V7 red cell" } + post_model_call: { acs_method: null, note: "no ACS v0.1.0 target — D4, V7 red cell" } + +verdicts: + allow: { decision: allow } + deny: { decision: deny } + warn: { decision: allow, require_policy_references: true } + escalate: { decision: ask } + transform: { decision: modify } + +# AGT carries no rule_id / reason_codes / reasoning (C6). These are synthesized. +field_synthesis: + reasoning: from: verdict.message + reason_codes: from: [verdict.reason] + policy_references[].rule_id: from: verdict.reason + policy_references[].policy_id: literal: agt_stock +``` + +`packages/guardian/src/map-verdict.ts` implements exactly that table — no behaviour that is not expressed in `mapping.yaml`. + +- [ ] **Step 4: Run it, expect PASS.** +- [ ] **Step 5: Commit** — `Slice: #2` / `Affordances: S10, N24` + +--- + +### Task 4: `assembleSnapshot` · slice #2 · N23 + +**Files:** Create `packages/guardian/src/assemble-snapshot.ts`; Test `packages/guardian/test/assemble-snapshot.test.ts` + +**Interfaces:** +- Consumes: a validated ACS request envelope (Task 5's shape). +- Produces: `assembleSnapshot(envelope): Record` — the AGT snapshot for `pre_tool_call`, shaped per `AGT-SNAPSHOT-1.0.md` §2.5. + +**Watch-for (honoured):** envelope-only. No S3/S4/S5 reads — those arrive in V6. + +- [ ] **Step 1: Write the failing test** — asserts that `params.payload.tool.name` → `tool_call.name`, that each `arguments..value` unwraps to `tool_call.args.` (dropping the ACS provenance wrapper), that `tool_call.args.command` is a **string** (C5), that `envelope.budgets` is always present with four zeroed counters, and that no session-derived key appears in the output. +- [ ] **Step 2: Run it, expect FAIL.** +- [ ] **Step 3: Minimal implementation** — unwrap `{value, provenance}` argument envelopes; emit `{ envelope: { budgets: {...} }, tool_call: { name, args, id } }`. +- [ ] **Step 4: Run it, expect PASS.** +- [ ] **Step 5: Commit** — `Slice: #2` / `Affordances: N23` + +--- + +### Task 5: `validateEnvelope` · slice #2 · N21 + +**Files:** Create `packages/guardian/src/validate-envelope.ts`; Test `packages/guardian/test/validate-envelope.test.ts` + +Validates against the v0.1.0 schemas in the `spec/acs` submodule — `specification/v0.1.0/request-envelope.json` plus `hooks/tool-call-request.json` — with Ajv, resolving the modular `$ref`s by `$id`. + +**Scope note:** V1 *rejects* invalid envelopes. Turning that rejection into an explicit ACS `deny` **decision** is N27, which belongs to **V3**. Task 5 throws a typed error; Task 6 returns a JSON-RPC error. Do not implement N27 here. + +- [ ] **Step 1: Write the failing test** — a valid `steps/toolCallRequest` envelope passes; each of a missing `params.metadata.session_id`, a missing `payload.tool.name`, and a bad `method` prefix fails with the offending JSON pointer named. +- [ ] **Step 2–5** as above. Commit — `Slice: #2` / `Affordances: N21` + +--- + +### Task 6: Guardian JSON-RPC endpoint and handshake · slice #2 · N20, N28 + +**Files:** Create `packages/guardian/src/server.ts`, `packages/guardian/src/handshake.ts`, `packages/guardian/src/index.ts`; Test `packages/guardian/test/server.test.ts` + +**Interfaces:** +- Produces: `startGuardian({ port, manifestPath }): Promise<{ url: string; close(): Promise }>`, serving `POST /acs`. +- Dispatch is by the JSON-RPC `method` field. The spec mandates no URL path; `/acs` is our convention and is recorded as such in the slices doc. + +`handshakeResponder` (N28) returns a ServerHello with the required `negotiated_version`, `methods_evaluated`, `selected_transport`, `timeout_config`, plus `on_decision_failure`. **D8 note:** the reference ships the spec default `proceed`. V1 only negotiates and stores it; V3 applies it. + +- [ ] **Step 1: Write the failing test** — `handshake/hello` returns a schema-valid ServerHello with `timeout_config.default_ms` present and `on_decision_failure: "proceed"`; `steps/toolCallRequest` carrying `rm -rf /` returns `result.decision === "deny"` with `reasoning` and non-empty `reason_codes`; the same carrying `ls -la` returns `allow`; an unknown method returns a JSON-RPC error in `-32000..-32099`; and every response echoes `params.request_id`. +- [ ] **Step 2–5** as above. Commit — `Slice: #2` / `Affordances: N20, N28` + +--- + +### Task 7: `buildEnvelope` and the hookmap · slice #2 · N2, S1 + +**Files:** Create `packages/host-adapter/package.json`, `packages/host-adapter/src/build-envelope.ts`, `hosts/claude-code/claude-code.hookmap.yaml`; Test `packages/host-adapter/test/build-envelope.test.ts` + +**Constraint:** this package must contain zero AGT-specific code (R3.2). It knows ACS and hookmaps, nothing else. + +S1 maps Claude Code hook names to ACS methods and ACS decisions to Claude Code outputs: +```yaml +host: claude-code +hooks: + PreToolUse: + acs_method: steps/toolCallRequest + tool_name: $.tool_name + arguments: $.tool_input +decisions: + allow: { permissionDecision: allow } + deny: { permissionDecision: deny, reason_from: reasoning } + ask: { permissionDecision: ask } + defer: { permissionDecision: defer } + modify: { permissionDecision: allow, updatedInput_from: modifications } +``` + +- [ ] **Step 1: Write the failing test** — a real Claude Code `PreToolUse` payload produces an envelope that validates against `request-envelope.json`; `method` is `steps/toolCallRequest`; `arguments` are wrapped as `{value}`; `request_id` is a UUID; `session_id` is carried; and `buildEnvelope` is driven by the hookmap (an unmapped hook name throws rather than defaulting). +- [ ] **Step 2–5** as above. Commit — `Slice: #2` / `Affordances: N2, S1` + +--- + +### Task 8: `guardianClient.post`, `handshake`, `renderDecision` · slice #2 · N3, N4, N5, S13 + +**Files:** Create `packages/host-adapter/src/guardian-client.ts`, `packages/host-adapter/src/render-decision.ts`, `packages/host-adapter/src/session-config.ts`; Test `packages/host-adapter/test/client.test.ts`, `packages/host-adapter/test/render-decision.test.ts` + +`renderDecision` (N3) turns an ACS decision into Claude Code's `hookSpecificOutput` **using S1** — no hardcoded dispatch. A `deny` must place the ACS `reasoning` into `permissionDecisionReason`; that string is what U2 renders, and it is the demo's payoff. + +S13 stores the negotiated config. V1 stores it only; N6/N7 consume it in V3. + +- [ ] **Step 1: Write the failing test** — `deny` renders `{hookSpecificOutput:{hookEventName:"PreToolUse", permissionDecision:"deny", permissionDecisionReason:""}}`; `allow` renders `permissionDecision:"allow"`; a `warn`-derived allow (allow + non-empty `policy_references`) still renders `allow`; `handshake()` stores `timeout_config` and `on_decision_failure` into S13; and the client posts JSON-RPC 2.0 with a matching `id`. +- [ ] **Step 2–5** as above. Commit — `Slice: #2` / `Affordances: N3, N4, N5, S13` + +--- + +### Task 9: The Claude Code hook shim and the demo · slice #2 · N1, U1, U2 + +**Files:** Create `hosts/claude-code/acs-hook.ts`, `hosts/claude-code/settings.json`, `docs/demos/v1-runbook.md`; Test `hosts/claude-code/test/hook.test.ts` + +N1 is deliberately thin: read hook JSON on stdin, call `buildEnvelope` → `guardianClient.post` → `renderDecision`, write `hookSpecificOutput` to stdout. All logic lives in the adapter so V5 can reuse it unchanged. + +- [ ] **Step 1: Write the failing test** — spawn the shim as a subprocess against a live Guardian, feed a real `PreToolUse` payload for `rm -rf /` on stdin, and assert stdout parses to `permissionDecision: "deny"` with `permissionDecisionReason` containing the deny reason; repeat with `ls -la` for `allow`; assert exit code 0 in both cases. +- [ ] **Step 2–5** as above. The runbook states the demo in the slice's own words and names what a viewer should watch for. Commit — `Slice: #2` / `Affordances: N1, U1, U2` + +--- + +### Task 10: The invariant gates · slice #2 · R3.2, R3.3, R7.1 + +The claims V1 exists to support, made mechanical so they cannot rot. + +**Files:** Create `test/invariants.test.ts`, `README.md` (quickstart section) + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { Glob } from "bun"; + +const read = (dir: string) => + [...new Glob("**/*.ts").scanSync(dir)] + .filter((f) => !f.includes("/test/")) + .map((f) => ({ f, src: readFileSync(`${dir}/${f}`, "utf8") })); + +describe("architectural invariants", () => { + // R3.2 — this is the claim the whole M×N argument rests on. + it("host adapter contains zero AGT-specific code", () => { + for (const { f, src } of read("packages/host-adapter/src")) { + for (const term of ["agt", "AgentControl", "rego", "opa", "intervention_point", "verdict"]) { + expect({ file: f, term, found: new RegExp(term, "i").test(src) }) + .toEqual({ file: f, term, found: false }); + } + } + }); + + // R3.3 — and this is what makes V5 cost zero AGT code. + it("AGT bridge contains zero host-specific code", () => { + for (const { f, src } of read("packages/agt-bridge/src")) { + for (const term of ["claude", "opencode", "hookSpecificOutput", "permissionDecision", "stdin"]) { + expect({ file: f, term, found: new RegExp(term, "i").test(src) }) + .toEqual({ file: f, term, found: false }); + } + } + }); +}); +``` + +- [ ] **Step 2: Run it, expect FAIL** (or pass trivially if earlier tasks were disciplined — either way it is now enforced). +- [ ] **Step 3: Implementation** — fix any leak the gate catches; write the README quickstart proving R7.1 (`bun install && bun run guardian` then launch Claude Code with the hook). +- [ ] **Step 4: Run it, expect PASS** — plus a full `bun test`. +- [ ] **Step 5: Commit** — `Slice: #2` / `Affordances: R3.2, R3.3, R7.1` + +--- + +## Cross-slice work in this plan + +| Task | Belongs to | Why it must happen here | +|---|---|---| +| Task 3 — `mapping.yaml` point table for all 8 AGT points | V7 (C1/N41) | S10 is one file read by both runtime and harness. Writing only the `pre_tool_call` row would mean rewriting the file's shape in V7, and the shaping doc names S10 as the design choice that makes C1 a contract. The rows are data; only `pre_tool_call` is exercised in V1. | +| Task 6 — `on_decision_failure` in ServerHello | V3 (N6) | The handshake is a V1 affordance (N5/N28) and the field is part of ServerHello. V1 negotiates and stores; V3 applies. Storing without applying is the slice boundary, not a gap. | + +## Scope added during planning + +| What | Why the slice cannot ship without it | Slices-doc amendment | +|---|---|---| +| `policy/lib/data.json` inside the bundle | `data_paths` cannot deliver `data.agt.defaults.config` when `bundle:` is set (C3). Without this the policy is inert and the demo silently allows. | S8 row rewritten | +| Bundle-path guard in `createBridge` + a test asserting deny | The `/./` landmine (C2) fails open silently. An assertion that "deny is not allow" is the only thing standing between the demo and a policy that quietly does nothing. | New watch-for + risk row | +| Our own destructive-command regex list | The stock bundle ships none (C4). | Demo framing corrected | +| Task 10 invariant gates | R3.2/R3.3 are "verifiable by inspection" in the shaping doc. Inspection rots; a test does not. | Noted under V1 | +| `scripts/verify-pin.sh` | R2.2/R2.3 claim an unforked pinned engine. Nothing enforced it. | S9/S11 rows | + +--- + +## Risks carried into execution + +| # | Risk | Handling | +|---|---|---| +| 1 | The `/./` landmine reappears via a different path join | `createBridge` throws on `/./`; Task 2's deny test is the backstop | +| 2 | Upstream AGT moves and `data.json`-in-bundle stops working | `agt.lock` pins the ref; V8 watches the surface | +| 3 | Bun's napi support regresses on the AGT addon | Verified loading under both bun and node; pnpm + node is the fallback and costs only the package manager | +| 4 | `enforced_identity` bisection unverified end-to-end | Task 2 asserts both fields are present and well-formed; true bisection needs a `transform` verdict and lands in V3/V7 | From 04007c08d43b0a3f45761947ed93f272fd32bd93 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 18:05:21 +0300 Subject: [PATCH 03/39] Task 1: workspace, vendored AGT bundle, and the pin - bun workspace root (package.json, tsconfig.base.json), agent-control-specification@0.3.1-beta.0 installed for real via `bun add` - agt.lock pins AGT at 81955d48025c6b11deb3fc9dabf89f74f4145775 - policy/lib vendored byte-identical from AGT policy-engine/policy/lib - test/pin.test.ts and scripts/verify-pin.sh enforce the pin and byte-identity Slice: #2 Affordances: S9, S11 --- .gitignore | 3 + agt.lock | 7 ++ bun.lock | 35 ++++++ package.json | 13 +++ policy/lib/agt_default.rego | 174 ++++++++++++++++++++++++++++++ policy/lib/agt_default_test.rego | 135 +++++++++++++++++++++++ policy/lib/agt_ifc.rego | 145 +++++++++++++++++++++++++ policy/lib/agt_ifc_test.rego | 90 ++++++++++++++++ policy/lib/approval.rego | 41 +++++++ policy/lib/approval_test.rego | 39 +++++++ policy/lib/budgets.rego | 98 +++++++++++++++++ policy/lib/budgets_test.rego | 98 +++++++++++++++++ policy/lib/confidence.rego | 31 ++++++ policy/lib/confidence_test.rego | 34 ++++++ policy/lib/content_hash.rego | 49 +++++++++ policy/lib/content_hash_test.rego | 50 +++++++++ policy/lib/drift.rego | 32 ++++++ policy/lib/drift_test.rego | 36 +++++++ policy/lib/egress.rego | 75 +++++++++++++ policy/lib/egress_test.rego | 56 ++++++++++ policy/lib/ifc.rego | 113 +++++++++++++++++++ policy/lib/ifc_test.rego | 73 +++++++++++++ policy/lib/patterns.rego | 108 +++++++++++++++++++ policy/lib/patterns_test.rego | 88 +++++++++++++++ policy/lib/redact.rego | 48 +++++++++ policy/lib/redact_test.rego | 46 ++++++++ policy/lib/run_tests.sh | 24 +++++ scripts/verify-pin.sh | 54 ++++++++++ test/pin.test.ts | 49 +++++++++ tsconfig.base.json | 24 +++++ 30 files changed, 1868 insertions(+) create mode 100644 agt.lock create mode 100644 bun.lock create mode 100644 package.json create mode 100644 policy/lib/agt_default.rego create mode 100644 policy/lib/agt_default_test.rego create mode 100644 policy/lib/agt_ifc.rego create mode 100644 policy/lib/agt_ifc_test.rego create mode 100644 policy/lib/approval.rego create mode 100644 policy/lib/approval_test.rego create mode 100644 policy/lib/budgets.rego create mode 100644 policy/lib/budgets_test.rego create mode 100644 policy/lib/confidence.rego create mode 100644 policy/lib/confidence_test.rego create mode 100644 policy/lib/content_hash.rego create mode 100644 policy/lib/content_hash_test.rego create mode 100644 policy/lib/drift.rego create mode 100644 policy/lib/drift_test.rego create mode 100644 policy/lib/egress.rego create mode 100644 policy/lib/egress_test.rego create mode 100644 policy/lib/ifc.rego create mode 100644 policy/lib/ifc_test.rego create mode 100644 policy/lib/patterns.rego create mode 100644 policy/lib/patterns_test.rego create mode 100644 policy/lib/redact.rego create mode 100644 policy/lib/redact_test.rego create mode 100755 policy/lib/run_tests.sh create mode 100755 scripts/verify-pin.sh create mode 100644 test/pin.test.ts create mode 100644 tsconfig.base.json diff --git a/.gitignore b/.gitignore index 8647ed5d..a62f7b72 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ build/ *.log .venv/ __pycache__/ + +# Subagent-driven-development scratch workspace (ledger, briefs, review packages) +.superpowers/ diff --git a/agt.lock b/agt.lock new file mode 100644 index 00000000..3367f316 --- /dev/null +++ b/agt.lock @@ -0,0 +1,7 @@ +{ + "agt_repo": "https://github.com/microsoft/agent-governance-toolkit", + "agt_ref": "81955d48025c6b11deb3fc9dabf89f74f4145775", + "bundle_path": "policy-engine/policy/lib", + "sdk_package": "agent-control-specification", + "sdk_version": "0.3.1-beta.0" +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 00000000..5bc36a20 --- /dev/null +++ b/bun.lock @@ -0,0 +1,35 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "acs-reference-implementation", + "dependencies": { + "agent-control-specification": "0.3.1-beta.0", + }, + }, + }, + "packages": { + "agent-control-specification": ["agent-control-specification@0.3.1-beta.0", "", { "optionalDependencies": { "agent-control-specification-darwin-arm64": "0.3.1-beta.0", "agent-control-specification-darwin-x64": "0.3.1-beta.0", "agent-control-specification-linux-arm64-gnu": "0.3.1-beta.0", "agent-control-specification-linux-x64-gnu": "0.3.1-beta.0", "agent-control-specification-opa-darwin-arm64": "0.3.1-beta.0", "agent-control-specification-opa-darwin-x64": "0.3.1-beta.0", "agent-control-specification-opa-linux-arm64": "0.3.1-beta.0", "agent-control-specification-opa-linux-x64": "0.3.1-beta.0", "agent-control-specification-opa-win32-x64": "0.3.1-beta.0", "agent-control-specification-win32-x64-msvc": "0.3.1-beta.0" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.100.0", "@langchain/core": ">=1.1.0", "@openai/agents": ">=0.11.0", "langchain": ">=1.0.0" }, "optionalPeers": ["@anthropic-ai/sdk", "@langchain/core", "@openai/agents", "langchain"] }, "sha512-vD1N169F2YhTCLbF4u7hEV5xfmAY1vziq4bHL2xhBa5QNOhdeEzrTdOIshWZw46fxxPJzEYDjQyjSmI2NnHCuQ=="], + + "agent-control-specification-darwin-arm64": ["agent-control-specification-darwin-arm64@0.3.1-beta.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6kr1DAeFOBf/8Q7eNFqLtUoUfRqIDTqZ2QvkIvSKxDYh3iY2/8cizn0bzbJJofuba02aDV1oLEd5vvgcYkbWAg=="], + + "agent-control-specification-darwin-x64": ["agent-control-specification-darwin-x64@0.3.1-beta.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFLEv/u77ZnErjcfLPCExL+ZeNRlOQ1Gg7l1VZjuTH9VQyIrTBXDHVb5czH6T9WFyozHRtt/NPud1XANeKGk+g=="], + + "agent-control-specification-linux-arm64-gnu": ["agent-control-specification-linux-arm64-gnu@0.3.1-beta.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-y1eKUzmkuPBr4HymQXdVImQA/opjuwv+I1nYkH1TTnkZf9Yvo9PWePbrhDTNH/ZFE/FvUtGLZfUIPU5PJceVEw=="], + + "agent-control-specification-linux-x64-gnu": ["agent-control-specification-linux-x64-gnu@0.3.1-beta.0", "", { "os": "linux", "cpu": "x64" }, "sha512-AaC7jPQe2RX56nGdnrcqL2JmjLMw4bP+J6rJyrNTr8YGgZtAL5R+hcbHZdqNnsUssoD57mJLOjl8OdnOJ07+SA=="], + + "agent-control-specification-opa-darwin-arm64": ["agent-control-specification-opa-darwin-arm64@0.3.1-beta.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sdqgve222Y+aq20Rfa4yOWnD+u1SygeFWLD+N0dsrFzSKQCkW3aRg3EJkYBmgzGXvHi6qheL9zWRYkaxqaypww=="], + + "agent-control-specification-opa-darwin-x64": ["agent-control-specification-opa-darwin-x64@0.3.1-beta.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-6hZCNloScj0RlwvkzOUNcnYEpkvObcy9P88a1tKZA7vztcKfTsKCwAjq1rPUWAvI48BG3bldOLdLaHXbyvutdw=="], + + "agent-control-specification-opa-linux-arm64": ["agent-control-specification-opa-linux-arm64@0.3.1-beta.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-7n/0kqzuXVmX/eccpw6yEjD4n5aFd0aRqpXs0EtDZS5P1DhIORKR/w1flrr1FOvVv0UJoFDM7p78STwO8IdIHw=="], + + "agent-control-specification-opa-linux-x64": ["agent-control-specification-opa-linux-x64@0.3.1-beta.0", "", { "os": "linux", "cpu": "x64" }, "sha512-UgvO8h8c3cEVht/Nkflx0BbA5vKFatX3B5Q1D/g6pD+tXidZzs4HmZaKcazaniPuuqzbrz70eAYF92yoyjP4OA=="], + + "agent-control-specification-opa-win32-x64": ["agent-control-specification-opa-win32-x64@0.3.1-beta.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hNHRXGxQ3pnagMdeowu3WgskHrF3QqX/gclesv3fesTq6xn0IKDW2/4iKdAq5lLYFk49f/fQYPV/ybtpaVwmow=="], + + "agent-control-specification-win32-x64-msvc": ["agent-control-specification-win32-x64-msvc@0.3.1-beta.0", "", { "os": "win32", "cpu": "x64" }, "sha512-+9/DAvNb1bGPQwYOppaiVctxDs0i8RalbZnUuobJLxghqmR4GN9JpqGKBwdk4Du9EIRwRkP5C4/AjmPj5vr+RA=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..df209750 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "acs-reference-implementation", + "private": true, + "type": "module", + "workspaces": ["packages/*"], + "scripts": { + "test": "bun test", + "verify:pin": "bash scripts/verify-pin.sh" + }, + "dependencies": { + "agent-control-specification": "0.3.1-beta.0" + } +} diff --git a/policy/lib/agt_default.rego b/policy/lib/agt_default.rego new file mode 100644 index 00000000..5aed2735 --- /dev/null +++ b/policy/lib/agt_default.rego @@ -0,0 +1,174 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT default policy. Hosts that do not author Rego of their own bind their +# manifest's `rego` policy to `data.agt.defaults.verdict`. The library +# imports every AGT stock helper and consults each one in priority order: +# IFC deny > confidence deny > budget deny > content_hash deny > egress +# deny > pattern deny > drift warn > allow. Configuration travels through +# the host-supplied `data.agt.defaults.config` document so a manifest only +# has to set thresholds, allowlists, and pattern lists in YAML without +# touching Rego. The M6 GovernancePolicy migration tool emits this exact +# binding for hosts coming from the legacy declarative policy. + +package agt.defaults + +import data.agt.approval +import data.agt.budgets +import data.agt.confidence +import data.agt.content_hash +import data.agt.drift +import data.agt.egress +import data.agt.ifc +import data.agt.patterns +import data.agt.redact +import rego.v1 + +# Host-supplied configuration lives at data.agt.defaults.config (loaded +# from a JSON or YAML data document or pushed by the SDK). Referencing +# it via the cfg helper avoids a self-recursive rule named `config` in +# this package. +cfg := value if { + value := data.agt.defaults.config + is_object(value) +} else := {} + +# --------------------------------------------------------------------------- +# Per-class verdict shortcuts. Each is `undefined` (no verdict) when its +# configuration is absent or the rule it gates does not match. + +ifc_verdict := value if { + clearance := cfg.ifc.sink_clearance + is_string(clearance) + labels := ifc_labels + value := ifc.verdict_propagating(clearance, labels) +} + +ifc_labels := labels if { + input.intervention_point == "output" + labels := ifc.result_labels +} else := labels if { + labels := ifc.source_labels +} + +confidence_verdict := value if { + threshold := cfg.confidence.min_score + value := confidence.deny_if_low_confidence(threshold) +} + +budgets_verdict := value if { + thresholds := cfg.budgets + is_object(thresholds) + value := budgets.deny_if_budget_exceeded(thresholds) +} + +content_hash_verdict := value if { + cfg.content_hash.enforce == true + value := content_hash.deny_if_mismatch +} + +egress_verdict := value if { + rules := cfg.egress + is_object(rules) + value := egress.deny_egress(rules) +} + +pattern_verdict := value if { + rules := cfg.patterns + is_object(rules) + pats := rules.patterns + is_array(pats) + text := pattern_text(rules) + reason := pattern_reason(rules) + value := patterns.deny_if_pattern(text, pats, reason) +} + +pattern_text(rules) := value if { + is_string(rules.text) + value := rules.text +} else := value if { + value := input.policy_target.value + is_string(value) +} else := "" + +pattern_reason(rules) := value if { + is_string(rules.reason) + value := rules.reason +} else := "pattern_blocked" + +redact_verdict := value if { + rules := cfg.redact + is_object(rules) + pats := rules.patterns + is_array(pats) + text := input.policy_target.value + is_string(text) + replacement := redact_replacement(rules) + value := redact.redact_text(text, pats, replacement) +} + +redact_replacement(rules) := value if { + is_string(rules.replacement) + value := rules.replacement +} else := redact.default_replacement + +drift_verdict := value if { + threshold := cfg.drift.warn_threshold + value := drift.warn_if_drift(threshold) +} + +approval_verdict := value if { + required := cfg.approval.required + required == true + approvers := cfg.approval.approvers + value := approval.escalate_if_approver_required(approvers) +} + +# --------------------------------------------------------------------------- +# Final verdict. Highest-severity decision wins; transform short-circuits +# above warn but below deny, matching the AGT verdict severity (deny > +# escalate > transform > warn > allow). The order below encodes that +# ranking. + +default verdict := {"decision": "allow"} + +verdict := ifc_verdict if { + ifc_verdict.decision == "deny" +} + +else := confidence_verdict if { + confidence_verdict.decision == "deny" +} + +else := budgets_verdict if { + budgets_verdict.decision == "deny" +} + +else := content_hash_verdict if { + content_hash_verdict.decision == "deny" +} + +else := egress_verdict if { + egress_verdict.decision == "deny" +} + +else := pattern_verdict if { + pattern_verdict.decision == "deny" +} + +else := approval_verdict if { + approval_verdict.decision == "escalate" +} + +else := redact_verdict if { + redact_verdict.decision == "transform" +} + +else := drift_verdict if { + drift_verdict.decision == "warn" +} + +else := ifc_verdict if { + ifc_verdict.decision == "allow" + ifc_verdict.result_labels +} diff --git a/policy/lib/agt_default_test.rego b/policy/lib/agt_default_test.rego new file mode 100644 index 00000000..537ace02 --- /dev/null +++ b/policy/lib/agt_default_test.rego @@ -0,0 +1,135 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +package agt.defaults_test + +import data.agt.defaults +import data.agt.patterns +import rego.v1 + +base_snapshot := { + "intervention_point": "pre_tool_call", + "snapshot": {"envelope": {"budgets": {"tool_call_count": 0, "token_count": 0, "elapsed_seconds": 0, "cost_usd": 0}}}, + "annotations": {}, + "tool": {}, + "policy_target": {"value": "benign text"}, +} + +test_default_allows_with_empty_config if { + defaults.verdict == {"decision": "allow"} with input as base_snapshot + with data.agt.defaults.config as {} +} + +test_budget_exceeded_denies if { + verdict := defaults.verdict with input as object.union(base_snapshot, {"snapshot": {"envelope": {"budgets": {"tool_call_count": 20, "token_count": 0, "elapsed_seconds": 0, "cost_usd": 0}}}}) + with data.agt.defaults.config as {"budgets": {"tool_call_count": 10, "token_count": 99999, "elapsed_seconds": 9999, "cost_usd": 9999}} + verdict.decision == "deny" + verdict.reason == "budget_tool_calls_exceeded" +} + +test_malformed_budget_counter_denies if { + verdict := defaults.verdict with input as object.union(base_snapshot, {"snapshot": {"envelope": {"budgets": {"tool_call_count": 0, "token_count": "999999", "elapsed_seconds": 0, "cost_usd": 0}}}}) + with data.agt.defaults.config as {"budgets": {"tool_call_count": 99999, "token_count": 1, "elapsed_seconds": 9999, "cost_usd": 9999}} + verdict.decision == "deny" + verdict.reason == "budget_counter_invalid" +} + +test_confidence_low_denies if { + pi := object.union(base_snapshot, {"annotations": {"confidence": {"score": 0.2}}}) + verdict := defaults.verdict with input as pi + with data.agt.defaults.config as {"confidence": {"min_score": 0.5}} + verdict.decision == "deny" + verdict.reason == "confidence_below_threshold" +} + +test_content_hash_mismatch_denies if { + pi := object.union(base_snapshot, { + "tool": {"content_hash": "sha256:abc"}, + "snapshot": { + "envelope": base_snapshot.snapshot.envelope, + "tool_call": {"content_hash": "sha256:zzz"}, + }, + }) + verdict := defaults.verdict with input as pi + with data.agt.defaults.config as {"content_hash": {"enforce": true}} + verdict.decision == "deny" + verdict.reason == "tool_content_hash_mismatch" +} + +test_egress_denies if { + pi := object.union(base_snapshot, { + "tool": {"security_labels": ["allowed.com"]}, + "snapshot": { + "envelope": base_snapshot.snapshot.envelope, + "tool_call": {"args": {"url": "https://attacker.com/x"}}, + }, + }) + verdict := defaults.verdict with input as pi + with data.agt.defaults.config as {"egress": {}} + verdict.decision == "deny" + verdict.reason == "egress_destination_not_allowed" +} + +test_pattern_denies if { + pi := object.union(base_snapshot, {"policy_target": {"value": "ssn 111-22-3333"}}) + verdict := defaults.verdict with input as pi + with data.agt.defaults.config as {"patterns": {"patterns": [patterns.pii_ssn], "reason": "pii_detected"}} + verdict.decision == "deny" + verdict.reason == "pii_detected" +} + +test_redact_transforms if { + pi := object.union(base_snapshot, {"policy_target": {"value": "ssn 111-22-3333"}}) + verdict := defaults.verdict with input as pi + with data.agt.defaults.config as {"redact": {"patterns": [patterns.pii_ssn]}} + verdict.decision == "transform" + verdict.transform.path == "$policy_target" + verdict.transform.value == "ssn [REDACTED]" +} + +test_drift_warns if { + pi := object.union(base_snapshot, {"annotations": {"drift_score": 0.8}}) + verdict := defaults.verdict with input as pi + with data.agt.defaults.config as {"drift": {"warn_threshold": 0.5}} + verdict.decision == "warn" + verdict.reason == "drift_detected" +} + +test_approval_escalates if { + verdict := defaults.verdict with input as base_snapshot + with data.agt.defaults.config as {"approval": {"required": true, "approvers": ["alice"]}} + verdict.decision == "escalate" + verdict.reason == "approval_required" +} + +test_ifc_violation_denies_at_input_intervention_point if { + pi := { + "intervention_point": "input", + "snapshot": { + "envelope": base_snapshot.snapshot.envelope, + "input": {"ifc": {"source_labels": ["secret"]}}, + }, + "annotations": {}, + "tool": {}, + "policy_target": {"value": ""}, + } + verdict := defaults.verdict with input as pi + with data.agt.defaults.config as {"ifc": {"sink_clearance": "internal"}} + verdict.decision == "deny" + verdict.reason == "ifc_clearance_violation" +} + +test_severity_ranking_deny_beats_transform if { + pi := object.union(base_snapshot, {"policy_target": {"value": "ssn 111-22-3333"}, "annotations": {"confidence": {"score": 0.1}}}) + verdict := defaults.verdict with input as pi + with data.agt.defaults.config as { + "confidence": {"min_score": 0.5}, + "redact": {"patterns": [patterns.pii_ssn]}, + } + verdict.decision == "deny" + verdict.reason == "confidence_below_threshold" +} + +test_missing_config_falls_through_to_allow if { + defaults.verdict == {"decision": "allow"} with input as base_snapshot +} diff --git a/policy/lib/agt_ifc.rego b/policy/lib/agt_ifc.rego new file mode 100644 index 00000000..4557f8c2 --- /dev/null +++ b/policy/lib/agt_ifc.rego @@ -0,0 +1,145 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT stock IFC label-flow library. This file replaces the upstream +# `agent_control_specification.lib.ifc` library for AGT users authoring +# manifests against the AGT snapshot shape. The function surface +# (`dominates`, `max_sensitivity`, `flow_allowed`, `allow`, `deny`, +# `verdict`, `verdict_propagating`, and their `_with_lattice` variants) +# matches the upstream library so policies authored against the AGT helpers +# remain familiar. The snapshot paths are AGT-correct per +# AGT-SNAPSHOT-1.0.md §2.2 (input) and §2.7 (output): +# `input.snapshot.input.ifc.source_labels` at the `input` intervention +# point, and `input.snapshot.response.ifc.result_labels` at `output`. The +# upstream `agent_control_specification.lib.ifc` library reads +# `input.snapshot.ifc.*`, which the AGT host SDKs do not populate and which +# would therefore fail closed on every call. AGT users MUST import +# `data.agt.ifc` rather than the upstream package. + +package agt.ifc + +import rego.v1 + +default_lattice := {"dominates": { + "public": ["public"], + "internal": ["public", "internal"], + "confidential": ["public", "internal", "confidential"], + "secret": ["public", "internal", "confidential", "secret"], +}} + +dominates(clearance, label) if { + dominates_with_lattice(default_lattice, clearance, label) +} + +dominates_with_lattice(lattice, clearance, label) if { + is_string(clearance) + is_string(label) + dominance := object.get(lattice, "dominates", {}) + labels := object.get(dominance, clearance, []) + some dominated in labels + dominated == label +} + +max_sensitivity(labels) := label if { + label := max_sensitivity_with_lattice(default_lattice, labels) +} + +max_sensitivity_with_lattice(lattice, labels) := label if { + count(labels) > 0 + label := labels[_] + every other in labels { + dominates_with_lattice(lattice, label, other) + } +} + +flow_allowed(clearance, labels) if { + flow_allowed_with_lattice(default_lattice, clearance, labels) +} + +flow_allowed_with_lattice(lattice, clearance, labels) if { + is_string(clearance) + is_array(labels) + count(labels) > 0 + sensitivity := max_sensitivity_with_lattice(lattice, labels) + dominates_with_lattice(lattice, clearance, sensitivity) +} + +allow(clearance, labels) := {"decision": "allow"} if { + flow_allowed(clearance, labels) +} + +allow_with_lattice(lattice, clearance, labels) := {"decision": "allow"} if { + flow_allowed_with_lattice(lattice, clearance, labels) +} + +deny(clearance, labels) := verdict if { + not flow_allowed(clearance, labels) + verdict := violation(clearance, labels) +} + +deny_with_lattice(lattice, clearance, labels) := verdict if { + not flow_allowed_with_lattice(lattice, clearance, labels) + verdict := violation(clearance, labels) +} + +verdict(clearance, labels) := value if { + not flow_allowed(clearance, labels) + value := violation(clearance, labels) +} else := {"decision": "allow"} if { + flow_allowed(clearance, labels) +} + +verdict_with_lattice(lattice, clearance, labels) := value if { + not flow_allowed_with_lattice(lattice, clearance, labels) + value := violation(clearance, labels) +} else := {"decision": "allow"} if { + flow_allowed_with_lattice(lattice, clearance, labels) +} + +violation(clearance, labels) := { + "decision": "deny", + "reason": "ifc_clearance_violation", + "message": sprintf("IFC clearance violation for sink clearance %v and data labels %v.", [clearance, labels]), +} + +propagated_labels(labels) := [max_sensitivity(labels)] if { + count(labels) > 0 +} else := [] + +propagated_labels_with_lattice(lattice, labels) := [max_sensitivity_with_lattice(lattice, labels)] if { + count(labels) > 0 +} else := [] + +verdict_propagating(clearance, labels) := value if { + not flow_allowed(clearance, labels) + value := violation(clearance, labels) +} else := {"decision": "allow", "result_labels": propagated_labels(labels)} if { + flow_allowed(clearance, labels) +} + +verdict_propagating_with_lattice(lattice, clearance, labels) := value if { + not flow_allowed_with_lattice(lattice, clearance, labels) + value := violation(clearance, labels) +} else := {"decision": "allow", "result_labels": propagated_labels_with_lattice(lattice, labels)} if { + flow_allowed_with_lattice(lattice, clearance, labels) +} + +# Convenience helper that reads AGT snapshot source labels at the input +# intervention point. +source_labels := value if { + value := input.snapshot.input.ifc.source_labels + is_array(value) +} else := [] + +# Convenience helper that reads AGT snapshot result labels at the output +# intervention point. +result_labels := value if { + value := input.snapshot.response.ifc.result_labels + is_array(value) +} else := [] + +# allow_if_dominates is the AGT shorthand for the no-write-down policy. +# The sink clearance must dominate every source label on the AGT snapshot. +allow_if_dominates(sink_clearance, labels) := value if { + value := verdict(sink_clearance, labels) +} diff --git a/policy/lib/agt_ifc_test.rego b/policy/lib/agt_ifc_test.rego new file mode 100644 index 00000000..9c5fc6cb --- /dev/null +++ b/policy/lib/agt_ifc_test.rego @@ -0,0 +1,90 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +package agt.ifc_test + +import data.agt.ifc +import rego.v1 + +test_clearance_dominates_data_allows if { + ifc.flow_allowed("secret", ["confidential"]) + ifc.max_sensitivity(["confidential"]) == "confidential" + ifc.verdict("secret", ["confidential"]).decision == "allow" +} + +test_data_exceeds_clearance_denies if { + not ifc.flow_allowed("internal", ["confidential"]) + verdict := ifc.deny("internal", ["confidential"]) + verdict.decision == "deny" + verdict.reason == "ifc_clearance_violation" +} + +test_incomparable_labels_deny_fail_closed if { + lattice := {"dominates": { + "public": ["public"], + "pii": ["public", "pii"], + "pci": ["public", "pci"], + }} + not ifc.flow_allowed_with_lattice(lattice, "pii", ["pci"]) + verdict := ifc.deny_with_lattice(lattice, "pii", ["pci"]) + verdict.reason == "ifc_clearance_violation" +} + +test_missing_and_empty_labels_deny_fail_closed if { + not ifc.flow_allowed("secret", []) + verdict := ifc.deny("secret", []) + verdict.decision == "deny" + verdict.reason == "ifc_clearance_violation" +} + +test_multi_label_uses_max_sensitivity if { + labels := ["public", "confidential", "internal"] + ifc.max_sensitivity(labels) == "confidential" + ifc.flow_allowed("secret", labels) + not ifc.flow_allowed("internal", labels) +} + +test_verdict_propagating_returns_joined_label if { + verdict := ifc.verdict_propagating("secret", ["public", "confidential", "internal"]) + verdict.decision == "allow" + verdict.result_labels == ["confidential"] +} + +test_verdict_propagating_deny_omits_result_labels if { + verdict := ifc.verdict_propagating("internal", ["confidential"]) + verdict.decision == "deny" + verdict.reason == "ifc_clearance_violation" + not verdict.result_labels +} + +test_source_labels_reads_agt_input_path if { + ifc.source_labels == ["confidential"] with input as {"snapshot": {"input": {"ifc": {"source_labels": ["confidential"]}}}} +} + +test_source_labels_defaults_to_empty if { + ifc.source_labels == [] with input as {"snapshot": {}} + ifc.source_labels == [] with input as {} +} + +test_result_labels_reads_agt_output_path if { + ifc.result_labels == ["internal"] with input as {"snapshot": {"response": {"ifc": {"result_labels": ["internal"]}}}} +} + +test_result_labels_defaults_to_empty if { + ifc.result_labels == [] with input as {"snapshot": {}} +} + +test_allow_if_dominates_returns_verdict if { + allow := ifc.allow_if_dominates("secret", ["internal"]) + allow.decision == "allow" + deny := ifc.allow_if_dominates("public", ["secret"]) + deny.decision == "deny" + deny.reason == "ifc_clearance_violation" +} + +test_does_not_read_upstream_ifc_path if { + # The upstream library reads `input.snapshot.ifc.source_labels`. AGT + # hosts put source labels under `input.snapshot.input.ifc.source_labels`. + # Confirm the AGT helper sees nothing at the upstream path. + ifc.source_labels == [] with input as {"snapshot": {"ifc": {"source_labels": ["secret"]}}} +} diff --git a/policy/lib/approval.rego b/policy/lib/approval.rego new file mode 100644 index 00000000..017cec1a --- /dev/null +++ b/policy/lib/approval.rego @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT stock approval helpers. These rules produce `escalate` verdicts per +# SPECIFICATION.md §13.1; the host approval path (§17.1) resolves +# them through the resolver declared in the `approval` manifest section +# (§D5). Approver-list helpers let manifests express "this action requires +# named approvers" without authoring a custom Rego rule. + +package agt.approval + +import rego.v1 + +escalate_if(condition, reason) := verdict if { + condition + is_string(reason) + verdict := { + "decision": "escalate", + "reason": reason, + } +} + +escalate_if_approver_required(approvers) := verdict if { + is_array(approvers) + count(approvers) > 0 + verdict := { + "decision": "escalate", + "reason": "approval_required", + "message": sprintf("requires approval from %v", [approvers]), + } +} + +escalate_with_message(reason, message) := verdict if { + is_string(reason) + is_string(message) + verdict := { + "decision": "escalate", + "reason": reason, + "message": message, + } +} diff --git a/policy/lib/approval_test.rego b/policy/lib/approval_test.rego new file mode 100644 index 00000000..168282a0 --- /dev/null +++ b/policy/lib/approval_test.rego @@ -0,0 +1,39 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +package agt.approval_test + +import data.agt.approval +import rego.v1 + +test_escalate_if_true_condition_emits_verdict if { + verdict := approval.escalate_if(true, "needs_review") + verdict.decision == "escalate" + verdict.reason == "needs_review" +} + +test_escalate_if_false_condition_emits_nothing if { + not approval.escalate_if(false, "needs_review") +} + +test_escalate_if_approver_required_lists_approvers if { + verdict := approval.escalate_if_approver_required(["alice", "bob"]) + verdict.decision == "escalate" + verdict.reason == "approval_required" + contains(verdict.message, "alice") +} + +test_escalate_if_approver_required_empty_emits_nothing if { + not approval.escalate_if_approver_required([]) +} + +test_escalate_with_message_carries_message if { + verdict := approval.escalate_with_message("high_value_action", "spend exceeds 1000") + verdict.decision == "escalate" + verdict.reason == "high_value_action" + verdict.message == "spend exceeds 1000" +} + +test_escalate_if_non_string_reason_emits_nothing if { + not approval.escalate_if(true, 123) +} diff --git a/policy/lib/budgets.rego b/policy/lib/budgets.rego new file mode 100644 index 00000000..3ae6cbc4 --- /dev/null +++ b/policy/lib/budgets.rego @@ -0,0 +1,98 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT stock budget helpers. These rules read the +# `input.snapshot.envelope.budgets` block per AGT-SNAPSHOT-1.0.md §1 and emit +# AGT verdicts per SPECIFICATION.md §13.1. A budget is exceeded when +# the host-tracked counter has already reached the configured limit. The +# helpers fail safe when a counter or threshold is absent. Present malformed +# counters fail closed instead of being coerced to zero. + +package agt.budgets + +import rego.v1 + +budget_counter_names := {"tool_call_count", "token_count", "elapsed_seconds", "cost_usd"} + +budget_counter_present(name) if { + _ := input.snapshot.envelope.budgets[name] +} + +malformed_budget_counter(name) if { + budget_counter_names[name] + value := input.snapshot.envelope.budgets[name] + not is_number(value) +} + +budget_counter(name) := value if { + value := input.snapshot.envelope.budgets[name] + is_number(value) +} else := 0 if { + not budget_counter_present(name) +} + +tool_call_count := budget_counter("tool_call_count") + +token_count := budget_counter("token_count") + +elapsed_seconds := budget_counter("elapsed_seconds") + +cost_usd := budget_counter("cost_usd") + +max_tool_calls_exceeded(limit) if { + is_number(limit) + tool_call_count >= limit +} + +max_tokens_exceeded(limit) if { + is_number(limit) + token_count >= limit +} + +timeout_exceeded(limit) if { + is_number(limit) + elapsed_seconds >= limit +} + +max_cost_exceeded(limit) if { + is_number(limit) + cost_usd >= limit +} + +deny_if_budget_exceeded(thresholds) := verdict if { + some name in budget_counter_names + malformed_budget_counter(name) + verdict := { + "decision": "deny", + "reason": "budget_counter_invalid", + "message": sprintf("budget counter %s must be a number", [name]), + } +} else := verdict if { + max_tool_calls_exceeded(thresholds.tool_call_count) + verdict := { + "decision": "deny", + "reason": "budget_tool_calls_exceeded", + "message": sprintf("tool_call_count %v reached limit %v", [tool_call_count, thresholds.tool_call_count]), + } +} else := verdict if { + max_tokens_exceeded(thresholds.token_count) + verdict := { + "decision": "deny", + "reason": "budget_tokens_exceeded", + "message": sprintf("token_count %v reached limit %v", [token_count, thresholds.token_count]), + } +} else := verdict if { + timeout_exceeded(thresholds.elapsed_seconds) + verdict := { + "decision": "deny", + "reason": "budget_timeout_exceeded", + "message": sprintf("elapsed_seconds %v reached limit %v", [elapsed_seconds, thresholds.elapsed_seconds]), + } +} else := verdict if { + max_cost_exceeded(thresholds.cost_usd) + verdict := { + "decision": "deny", + "reason": "budget_cost_exceeded", + "message": sprintf("cost_usd %v reached limit %v", [cost_usd, thresholds.cost_usd]), + } +} diff --git a/policy/lib/budgets_test.rego b/policy/lib/budgets_test.rego new file mode 100644 index 00000000..5ab02746 --- /dev/null +++ b/policy/lib/budgets_test.rego @@ -0,0 +1,98 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +package agt.budgets_test + +import data.agt.budgets +import rego.v1 + +snapshot_with(values) := {"snapshot": {"envelope": {"budgets": values}}} + +test_tool_call_count_reads_envelope if { + budgets.tool_call_count == 7 with input as snapshot_with({"tool_call_count": 7}) +} + +test_token_count_reads_envelope if { + budgets.token_count == 1024 with input as snapshot_with({"token_count": 1024}) +} + +test_missing_budgets_defaults_to_zero if { + budgets.tool_call_count == 0 with input as {"snapshot": {"envelope": {}}} + budgets.token_count == 0 with input as {"snapshot": {"envelope": {}}} + budgets.elapsed_seconds == 0 with input as {"snapshot": {"envelope": {}}} + budgets.cost_usd == 0 with input as {"snapshot": {"envelope": {}}} +} + +test_present_malformed_counter_is_not_coerced_to_zero if { + not budgets.token_count with input as snapshot_with({"token_count": "999999"}) + budgets.malformed_budget_counter("token_count") with input as snapshot_with({"token_count": "999999"}) + budgets.malformed_budget_counter("elapsed_seconds") with input as snapshot_with({"elapsed_seconds": null}) +} + +test_max_tool_calls_under_limit_does_not_match if { + not budgets.max_tool_calls_exceeded(10) with input as snapshot_with({"tool_call_count": 3}) +} + +test_max_tool_calls_at_limit_matches if { + budgets.max_tool_calls_exceeded(10) with input as snapshot_with({"tool_call_count": 10}) +} + +test_max_tokens_exceeded_matches if { + budgets.max_tokens_exceeded(1000) with input as snapshot_with({"token_count": 1200}) +} + +test_timeout_exceeded_matches if { + budgets.timeout_exceeded(60) with input as snapshot_with({"elapsed_seconds": 90.5}) +} + +test_max_cost_exceeded_matches if { + budgets.max_cost_exceeded(1.5) with input as snapshot_with({"cost_usd": 2.0}) +} + +test_deny_if_budget_exceeded_returns_no_verdict_when_under_limits if { + not budgets.deny_if_budget_exceeded({ + "tool_call_count": 100, + "token_count": 10000, + "elapsed_seconds": 600, + "cost_usd": 5, + }) with input as snapshot_with({ + "tool_call_count": 1, + "token_count": 50, + "elapsed_seconds": 1.5, + "cost_usd": 0.01, + }) +} + +test_deny_if_budget_exceeded_malformed_counter if { + verdict := budgets.deny_if_budget_exceeded({"tool_call_count": 9999, "token_count": 1, "elapsed_seconds": 9999, "cost_usd": 9999}) with input as snapshot_with({"token_count": "999999"}) + verdict.decision == "deny" + verdict.reason == "budget_counter_invalid" +} + +test_deny_if_budget_exceeded_tool_calls if { + verdict := budgets.deny_if_budget_exceeded({"tool_call_count": 5, "token_count": 99999, "elapsed_seconds": 9999, "cost_usd": 9999}) with input as snapshot_with({"tool_call_count": 5}) + verdict.decision == "deny" + verdict.reason == "budget_tool_calls_exceeded" +} + +test_deny_if_budget_exceeded_tokens if { + verdict := budgets.deny_if_budget_exceeded({"tool_call_count": 9999, "token_count": 100, "elapsed_seconds": 9999, "cost_usd": 9999}) with input as snapshot_with({"token_count": 200}) + verdict.decision == "deny" + verdict.reason == "budget_tokens_exceeded" +} + +test_deny_if_budget_exceeded_timeout if { + verdict := budgets.deny_if_budget_exceeded({"tool_call_count": 9999, "token_count": 99999, "elapsed_seconds": 30, "cost_usd": 9999}) with input as snapshot_with({"elapsed_seconds": 45.2}) + verdict.decision == "deny" + verdict.reason == "budget_timeout_exceeded" +} + +test_deny_if_budget_exceeded_cost if { + verdict := budgets.deny_if_budget_exceeded({"tool_call_count": 9999, "token_count": 99999, "elapsed_seconds": 9999, "cost_usd": 1.0}) with input as snapshot_with({"cost_usd": 2.5}) + verdict.decision == "deny" + verdict.reason == "budget_cost_exceeded" +} + +test_missing_snapshot_does_not_emit_verdict if { + not budgets.deny_if_budget_exceeded({"tool_call_count": 10, "token_count": 10, "elapsed_seconds": 10, "cost_usd": 10}) with input as {} +} diff --git a/policy/lib/confidence.rego b/policy/lib/confidence.rego new file mode 100644 index 00000000..acab99ff --- /dev/null +++ b/policy/lib/confidence.rego @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT stock confidence gate. Hosts attach a self-assessed confidence score +# under `input.annotations.confidence.score` (range 0..1) and this library +# denies the action with `confidence_below_threshold` when the score falls +# under the manifest-configured minimum. + +package agt.confidence + +import rego.v1 + +score := value if { + value := input.annotations.confidence.score + is_number(value) +} + +below(threshold) if { + is_number(threshold) + value := score + value < threshold +} + +deny_if_low_confidence(threshold) := verdict if { + below(threshold) + verdict := { + "decision": "deny", + "reason": "confidence_below_threshold", + "message": sprintf("confidence %v below threshold %v", [score, threshold]), + } +} diff --git a/policy/lib/confidence_test.rego b/policy/lib/confidence_test.rego new file mode 100644 index 00000000..362ee5ff --- /dev/null +++ b/policy/lib/confidence_test.rego @@ -0,0 +1,34 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +package agt.confidence_test + +import data.agt.confidence +import rego.v1 + +test_score_reads_annotation if { + confidence.score == 0.92 with input as {"annotations": {"confidence": {"score": 0.92}}} +} + +test_missing_annotation_does_not_deny if { + not confidence.deny_if_low_confidence(0.5) with input as {"annotations": {}} + not confidence.deny_if_low_confidence(0.5) with input as {} +} + +test_score_above_threshold_does_not_deny if { + not confidence.deny_if_low_confidence(0.5) with input as {"annotations": {"confidence": {"score": 0.9}}} +} + +test_score_at_threshold_does_not_deny if { + not confidence.deny_if_low_confidence(0.5) with input as {"annotations": {"confidence": {"score": 0.5}}} +} + +test_score_below_threshold_denies if { + verdict := confidence.deny_if_low_confidence(0.5) with input as {"annotations": {"confidence": {"score": 0.2}}} + verdict.decision == "deny" + verdict.reason == "confidence_below_threshold" +} + +test_non_numeric_score_does_not_deny if { + not confidence.deny_if_low_confidence(0.5) with input as {"annotations": {"confidence": {"score": "high"}}} +} diff --git a/policy/lib/content_hash.rego b/policy/lib/content_hash.rego new file mode 100644 index 00000000..365dcadb --- /dev/null +++ b/policy/lib/content_hash.rego @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT stock tool content-hash gate. The manifest tool catalog MAY declare a +# `content_hash` per AGT-MANIFEST §9. AGT-SNAPSHOT-1.0.md §2.5 lets the host +# attach `tool_call.content_hash` to the snapshot. This library denies the +# tool invocation when the declared hash and the observed hash disagree or +# when the manifest required a hash that the snapshot did not carry. + +package agt.content_hash + +import rego.v1 + +declared_hash := value if { + value := input.tool.content_hash + is_string(value) +} + +observed_hash := value if { + value := input.snapshot.tool_call.content_hash + is_string(value) +} + +declared_present if { + is_string(input.tool.content_hash) +} + +observed_present if { + is_string(input.snapshot.tool_call.content_hash) +} + +deny_if_mismatch := verdict if { + declared_present + not observed_present + verdict := { + "decision": "deny", + "reason": "tool_content_hash_mismatch", + "message": "manifest declared tool.content_hash but snapshot.tool_call.content_hash was missing", + } +} else := verdict if { + declared_present + observed_present + declared_hash != observed_hash + verdict := { + "decision": "deny", + "reason": "tool_content_hash_mismatch", + "message": sprintf("declared %v but observed %v", [declared_hash, observed_hash]), + } +} diff --git a/policy/lib/content_hash_test.rego b/policy/lib/content_hash_test.rego new file mode 100644 index 00000000..4e0b439a --- /dev/null +++ b/policy/lib/content_hash_test.rego @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +package agt.content_hash_test + +import data.agt.content_hash +import rego.v1 + +test_matching_hashes_emit_no_verdict if { + not content_hash.deny_if_mismatch with input as { + "tool": {"content_hash": "sha256:abc"}, + "snapshot": {"tool_call": {"content_hash": "sha256:abc"}}, + } +} + +test_missing_observed_hash_when_declared_denies if { + verdict := content_hash.deny_if_mismatch with input as { + "tool": {"content_hash": "sha256:abc"}, + "snapshot": {"tool_call": {}}, + } + verdict.decision == "deny" + verdict.reason == "tool_content_hash_mismatch" +} + +test_mismatch_denies if { + verdict := content_hash.deny_if_mismatch with input as { + "tool": {"content_hash": "sha256:abc"}, + "snapshot": {"tool_call": {"content_hash": "sha256:def"}}, + } + verdict.decision == "deny" + verdict.reason == "tool_content_hash_mismatch" +} + +test_manifest_did_not_declare_emits_no_verdict if { + not content_hash.deny_if_mismatch with input as { + "tool": {}, + "snapshot": {"tool_call": {"content_hash": "sha256:abc"}}, + } +} + +test_neither_present_emits_no_verdict if { + not content_hash.deny_if_mismatch with input as {"tool": {}, "snapshot": {"tool_call": {}}} +} + +test_non_string_declared_hash_treated_as_undeclared if { + not content_hash.deny_if_mismatch with input as { + "tool": {"content_hash": 123}, + "snapshot": {"tool_call": {"content_hash": "sha256:abc"}}, + } +} diff --git a/policy/lib/drift.rego b/policy/lib/drift.rego new file mode 100644 index 00000000..7943728e --- /dev/null +++ b/policy/lib/drift.rego @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT stock drift gate. Hosts run a behaviour-drift detector outside the +# policy engine and attach its score under `input.annotations.drift_score` +# (a host annotator name agreed in the manifest). This library issues a +# warn verdict per SPECIFICATION.md §13.1 when the score crosses the +# configured threshold so a host can flag the run without blocking. + +package agt.drift + +import rego.v1 + +score := value if { + value := input.annotations.drift_score + is_number(value) +} + +drift_exceeds(threshold) if { + is_number(threshold) + value := score + value >= threshold +} + +warn_if_drift(threshold) := verdict if { + drift_exceeds(threshold) + verdict := { + "decision": "warn", + "reason": "drift_detected", + "message": sprintf("drift_score %v reached threshold %v", [score, threshold]), + } +} diff --git a/policy/lib/drift_test.rego b/policy/lib/drift_test.rego new file mode 100644 index 00000000..327cc334 --- /dev/null +++ b/policy/lib/drift_test.rego @@ -0,0 +1,36 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +package agt.drift_test + +import data.agt.drift +import rego.v1 + +test_score_reads_annotation if { + drift.score == 0.7 with input as {"annotations": {"drift_score": 0.7}} +} + +test_missing_score_does_not_match if { + not drift.warn_if_drift(0.5) with input as {"annotations": {}} + not drift.warn_if_drift(0.5) with input as {} +} + +test_score_below_threshold_does_not_warn if { + not drift.warn_if_drift(0.5) with input as {"annotations": {"drift_score": 0.2}} +} + +test_score_at_threshold_warns if { + verdict := drift.warn_if_drift(0.5) with input as {"annotations": {"drift_score": 0.5}} + verdict.decision == "warn" + verdict.reason == "drift_detected" +} + +test_score_above_threshold_warns if { + verdict := drift.warn_if_drift(0.4) with input as {"annotations": {"drift_score": 0.9}} + verdict.decision == "warn" + verdict.reason == "drift_detected" +} + +test_non_numeric_score_does_not_warn if { + not drift.warn_if_drift(0.5) with input as {"annotations": {"drift_score": "high"}} +} diff --git a/policy/lib/egress.rego b/policy/lib/egress.rego new file mode 100644 index 00000000..6feac017 --- /dev/null +++ b/policy/lib/egress.rego @@ -0,0 +1,75 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT stock egress gate. Hosts use `input.tool.security_labels` to record the +# domains a tool sink is permitted to reach. When the host annotates the +# snapshot with the destination of a tool call, this library denies any +# destination that the allowlist does not cover. Wildcards follow glob.match +# semantics so callers can list `*.example.com` style patterns. +# `rules.destination_paths` lets callers specify where to look for the +# destination in the policy input. + +package agt.egress + +import rego.v1 + +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"], +] + +destination(rules) := value if { + paths := destination_paths(rules) + some path in paths + value := resolve(input, path) + is_string(value) +} + +destination_paths(rules) := paths if { + paths := rules.destination_paths + is_array(paths) + count(paths) > 0 +} else := default_destination_paths + +resolve(obj, path) := value if { + walk(obj, [path, value]) +} + +host_of(url) := value if { + is_string(url) + contains(url, "://") + after_scheme := split(url, "://")[1] + value := split(split(after_scheme, "/")[0], ":")[0] +} else := value if { + is_string(url) + not contains(url, "://") + value := split(split(url, "/")[0], ":")[0] +} + +allowlist(rules) := value if { + value := rules.allowlist + is_array(value) +} else := value if { + value := input.tool.security_labels + is_array(value) +} else := [] + +allowed(dest, patterns) if { + host := host_of(dest) + some pattern in patterns + glob.match(pattern, [".", "/"], host) +} + +deny_egress(rules) := verdict if { + dest := destination(rules) + patterns := allowlist(rules) + not allowed(dest, patterns) + verdict := { + "decision": "deny", + "reason": "egress_destination_not_allowed", + "message": sprintf("destination %v not in allowlist %v", [host_of(dest), patterns]), + } +} diff --git a/policy/lib/egress_test.rego b/policy/lib/egress_test.rego new file mode 100644 index 00000000..88930d74 --- /dev/null +++ b/policy/lib/egress_test.rego @@ -0,0 +1,56 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +package agt.egress_test + +import data.agt.egress +import rego.v1 + +input_with_dest(host) := { + "tool": {"security_labels": ["api.example.com", "*.trusted.com"]}, + "snapshot": {"tool_call": {"args": {"url": host}}}, +} + +test_host_of_extracts_hostname if { + egress.host_of("https://api.example.com/path") == "api.example.com" + egress.host_of("http://api.example.com:8080") == "api.example.com" + egress.host_of("api.example.com") == "api.example.com" + egress.host_of("api.example.com/v1/foo") == "api.example.com" +} + +test_exact_match_in_allowlist_emits_no_verdict if { + not egress.deny_egress({}) with input as input_with_dest("https://api.example.com/v1") +} + +test_glob_wildcard_matches if { + not egress.deny_egress({}) with input as input_with_dest("https://web.trusted.com/x") +} + +test_off_allowlist_denies if { + verdict := egress.deny_egress({}) with input as input_with_dest("https://attacker.example.org/x") + verdict.decision == "deny" + verdict.reason == "egress_destination_not_allowed" +} + +test_explicit_allowlist_overrides_tool_security_labels if { + verdict := egress.deny_egress({"allowlist": ["allowed.com"]}) with input as input_with_dest("https://api.example.com/v1") + verdict.decision == "deny" +} + +test_destination_paths_override if { + rules := {"destination_paths": [["annotations", "egress", "destination"]]} + host_input := { + "tool": {"security_labels": ["allowed.com"]}, + "annotations": {"egress": {"destination": "https://allowed.com/x"}}, + } + not egress.deny_egress(rules) with input as host_input +} + +test_missing_destination_emits_no_verdict if { + not egress.deny_egress({}) with input as {"tool": {"security_labels": ["x"]}, "snapshot": {}} +} + +test_missing_allowlist_treats_as_empty_and_denies if { + verdict := egress.deny_egress({}) with input as {"tool": {}, "snapshot": {"tool_call": {"args": {"url": "https://x.com/"}}}} + verdict.decision == "deny" +} diff --git a/policy/lib/ifc.rego b/policy/lib/ifc.rego new file mode 100644 index 00000000..d969c682 --- /dev/null +++ b/policy/lib/ifc.rego @@ -0,0 +1,113 @@ +package agent_control_specification.lib.ifc + +import rego.v1 + +default_lattice := {"dominates": { + "public": ["public"], + "internal": ["public", "internal"], + "confidential": ["public", "internal", "confidential"], + "secret": ["public", "internal", "confidential", "secret"], +}} + +dominates(clearance, label) if { + dominates_with_lattice(default_lattice, clearance, label) +} + +dominates_with_lattice(lattice, clearance, label) if { + is_string(clearance) + is_string(label) + dominance := object.get(lattice, "dominates", {}) + labels := object.get(dominance, clearance, []) + some dominated in labels + dominated == label +} + +max_sensitivity(labels) := label if { + label := max_sensitivity_with_lattice(default_lattice, labels) +} + +max_sensitivity_with_lattice(lattice, labels) := label if { + count(labels) > 0 + label := labels[_] + every other in labels { + dominates_with_lattice(lattice, label, other) + } +} + +flow_allowed(clearance, labels) if { + flow_allowed_with_lattice(default_lattice, clearance, labels) +} + +flow_allowed_with_lattice(lattice, clearance, labels) if { + is_string(clearance) + is_array(labels) + count(labels) > 0 + sensitivity := max_sensitivity_with_lattice(lattice, labels) + dominates_with_lattice(lattice, clearance, sensitivity) +} + +allow(clearance, labels) := {"decision": "allow"} if { + flow_allowed(clearance, labels) +} + +allow_with_lattice(lattice, clearance, labels) := {"decision": "allow"} if { + flow_allowed_with_lattice(lattice, clearance, labels) +} + +deny(clearance, labels) := verdict if { + not flow_allowed(clearance, labels) + verdict := violation(clearance, labels) +} + +deny_with_lattice(lattice, clearance, labels) := verdict if { + not flow_allowed_with_lattice(lattice, clearance, labels) + verdict := violation(clearance, labels) +} + +verdict(clearance, labels) := verdict if { + not flow_allowed(clearance, labels) + verdict := violation(clearance, labels) +} else := {"decision": "allow"} if { + flow_allowed(clearance, labels) +} + +verdict_with_lattice(lattice, clearance, labels) := verdict if { + not flow_allowed_with_lattice(lattice, clearance, labels) + verdict := violation(clearance, labels) +} else := {"decision": "allow"} if { + flow_allowed_with_lattice(lattice, clearance, labels) +} + +violation(clearance, labels) := { + "decision": "deny", + "reason": "ifc_clearance_violation", + "message": sprintf("IFC clearance violation for sink clearance %v and data labels %v.", [clearance, labels]), +} + +# Propagated labels describe the data flowing OUT of a sink. Under a join +# semilattice the label of the produced data is the least upper bound of the +# incoming source labels, which `max_sensitivity` computes. The host persists +# these with the produced data and re-supplies them as source labels later. +propagated_labels(labels) := [max_sensitivity(labels)] if { + count(labels) > 0 +} else := [] + +propagated_labels_with_lattice(lattice, labels) := [max_sensitivity_with_lattice(lattice, labels)] if { + count(labels) > 0 +} else := [] + +# Like `verdict`, but an allow also returns `result_labels` so the core can hand +# the propagated label back to the host for use on subsequent evaluations. +verdict_propagating(clearance, labels) := verdict if { + not flow_allowed(clearance, labels) + verdict := violation(clearance, labels) +} else := {"decision": "allow", "result_labels": propagated_labels(labels)} if { + flow_allowed(clearance, labels) +} + +verdict_propagating_with_lattice(lattice, clearance, labels) := verdict if { + not flow_allowed_with_lattice(lattice, clearance, labels) + verdict := violation(clearance, labels) +} else := {"decision": "allow", "result_labels": propagated_labels_with_lattice(lattice, labels)} if { + flow_allowed_with_lattice(lattice, clearance, labels) +} diff --git a/policy/lib/ifc_test.rego b/policy/lib/ifc_test.rego new file mode 100644 index 00000000..ad410aba --- /dev/null +++ b/policy/lib/ifc_test.rego @@ -0,0 +1,73 @@ +package agent_control_specification.lib.ifc_test + +import data.agent_control_specification.lib.ifc +import rego.v1 + +test_clearance_dominates_data_allows if { + ifc.flow_allowed("secret", ["confidential"]) + ifc.max_sensitivity(["confidential"]) == "confidential" + ifc.verdict("secret", ["confidential"]).decision == "allow" +} + +test_data_exceeds_clearance_denies if { + not ifc.flow_allowed("internal", ["confidential"]) + verdict := ifc.deny("internal", ["confidential"]) + verdict.decision == "deny" + verdict.reason == "ifc_clearance_violation" +} + +test_incomparable_labels_deny_fail_closed if { + lattice := {"dominates": { + "public": ["public"], + "pii": ["public", "pii"], + "pci": ["public", "pci"], + }} + not ifc.flow_allowed_with_lattice(lattice, "pii", ["pci"]) + verdict := ifc.deny_with_lattice(lattice, "pii", ["pci"]) + verdict.reason == "ifc_clearance_violation" +} + +test_missing_and_empty_labels_deny_fail_closed if { + not ifc.flow_allowed("secret", []) + verdict := ifc.deny("secret", []) + verdict.decision == "deny" + verdict.reason == "ifc_clearance_violation" +} + +test_unrecognized_labels_deny_fail_closed if { + not ifc.flow_allowed("secret", ["unknown"]) + verdict := ifc.deny("secret", ["unknown"]) + verdict.decision == "deny" +} + +test_multi_label_inflow_uses_maximum_sensitivity if { + labels := ["public", "confidential", "internal"] + ifc.max_sensitivity(labels) == "confidential" + ifc.flow_allowed("secret", labels) + not ifc.flow_allowed("internal", labels) +} + +test_propagating_allow_returns_joined_label if { + verdict := ifc.verdict_propagating("secret", ["public", "confidential", "internal"]) + verdict.decision == "allow" + verdict.result_labels == ["confidential"] +} + +test_propagating_deny_omits_result_labels if { + verdict := ifc.verdict_propagating("internal", ["confidential"]) + verdict.decision == "deny" + verdict.reason == "ifc_clearance_violation" + not verdict.result_labels +} + +test_propagating_with_lattice_returns_joined_label if { + lattice := {"dominates": { + "public": ["public"], + "internal": ["public", "internal"], + "confidential": ["public", "internal", "confidential"], + "secret": ["public", "internal", "confidential", "secret"], + }} + verdict := ifc.verdict_propagating_with_lattice(lattice, "secret", ["internal", "public"]) + verdict.decision == "allow" + verdict.result_labels == ["internal"] +} diff --git a/policy/lib/patterns.rego b/policy/lib/patterns.rego new file mode 100644 index 00000000..2a515729 --- /dev/null +++ b/policy/lib/patterns.rego @@ -0,0 +1,108 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT stock pattern helpers. The PII regex set tracks the canonical Python +# source list in agent-os/src/agent_os/integrations/base.py::PII_PATTERNS. +# Helpers expose the first match span and an AGT deny verdict per +# SPECIFICATION.md §13.1 for callers that want a simple block on PII. + +package agt.patterns + +import rego.v1 + +# Per agent_os.integrations.base.PII_PATTERNS. Patterns are anchored with +# word boundaries where the source uses them; the secrets pattern is case +# insensitive via the inline (?i) flag accepted by the Go RE2 engine. + +pii_ssn := `\b\d{3}[\s.\-]?\d{2}[\s.\-]?\d{4}\b` + +pii_email := `\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b` + +pii_phone := `\b(?:\+?1[\s.\-]?)?\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}\b` + +pii_credit_card := `\b(?:4\d{12}(?:\d{3})?|5[1-5]\d{14})\b` + +pii_secret := `(?i)\b(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+` + +pii_patterns := [ + pii_ssn, + pii_email, + pii_phone, + pii_credit_card, + pii_secret, +] + +matches_any(text, patterns) if { + is_string(text) + is_array(patterns) + some pattern in patterns + regex.match(pattern, text) +} + +# ``indexof(text, "")`` is undefined in OPA, so a pattern whose leftmost match +# is zero-length (``[0-9]*``, a bare ``*`` glob) would drop out of the scored +# comprehension and leave first_match undefined. matches_any still reports a +# match, so a caller that gates on matches_any and then reads a verdict from +# first_match would fall through to allow. +# +# OPA's regex builtins return the matched text, never its position, so a +# zero-length match has no recoverable offset: ``(?m)$`` matches at the end of +# the subject, not at 0. Report 0 as a deterministic placeholder. The span is +# diagnostic only -- it feeds the deny message and the earliest() tie-break, +# and nothing outside this file reads it -- so the decision is unaffected, but +# a deny whose only matching pattern is zero-width may name offset 0 and, where +# a longer match also exists, may name the zero-width pattern instead. +match_start(_, matched) := 0 if { + matched == "" +} + +match_start(text, matched) := start if { + matched != "" + start := indexof(text, matched) +} + +first_match(text, patterns) := match if { + is_string(text) + is_array(patterns) + scored := [hit | + some idx, pattern in patterns + found := regex.find_n(pattern, text, 1) + count(found) > 0 + span_start := match_start(text, found[0]) + span_start >= 0 + hit := { + "pattern": pattern, + "pattern_index": idx, + "match": found[0], + "span_start": span_start, + "span_end": span_start + count(found[0]), + } + ] + count(scored) > 0 + match := earliest(scored) +} + +earliest(hits) := winner if { + count(hits) > 0 + some i + winner := hits[i] + every other in hits { + not earlier_than(other, winner) + } +} + +earlier_than(a, b) if { + a.span_start < b.span_start +} else if { + a.span_start == b.span_start + a.pattern_index < b.pattern_index +} + +deny_if_pattern(text, patterns, reason) := verdict if { + hit := first_match(text, patterns) + verdict := { + "decision": "deny", + "reason": reason, + "message": sprintf("matched pattern %v at offset %v", [hit.pattern, hit.span_start]), + } +} diff --git a/policy/lib/patterns_test.rego b/policy/lib/patterns_test.rego new file mode 100644 index 00000000..40a75480 --- /dev/null +++ b/policy/lib/patterns_test.rego @@ -0,0 +1,88 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +package agt.patterns_test + +import data.agt.patterns +import rego.v1 + +test_ssn_regex_matches if { + patterns.matches_any("My ssn is 123-45-6789 please", [patterns.pii_ssn]) +} + +test_email_regex_matches if { + patterns.matches_any("ping me at alice@example.com tomorrow", [patterns.pii_email]) +} + +test_credit_card_regex_matches if { + patterns.matches_any("card 4111111111111111 expires soon", [patterns.pii_credit_card]) +} + +test_secret_regex_matches_case_insensitive if { + patterns.matches_any("api_key=abc123def", [patterns.pii_secret]) + patterns.matches_any("API_KEY = sk-deadbeef", [patterns.pii_secret]) +} + +test_phone_regex_matches if { + patterns.matches_any("call (415) 555-1212 now", [patterns.pii_phone]) +} + +test_no_match_returns_nothing if { + not patterns.matches_any("nothing sensitive here", patterns.pii_patterns) + not patterns.first_match("nothing sensitive here", patterns.pii_patterns) +} + +test_first_match_returns_match_metadata if { + hit := patterns.first_match("ssn 111-22-3333 in plain text", [patterns.pii_ssn]) + hit.pattern == patterns.pii_ssn + hit.match == "111-22-3333" + hit.span_start == 4 + hit.span_end == 15 +} + +test_first_match_picks_earliest_span if { + hit := patterns.first_match("alice@x.com then 111-22-3333", patterns.pii_patterns) + hit.match == "alice@x.com" + hit.span_start == 0 +} + +test_deny_if_pattern_emits_deny_verdict if { + verdict := patterns.deny_if_pattern("ssn 123-45-6789 detected", patterns.pii_patterns, "pii_detected") + verdict.decision == "deny" + verdict.reason == "pii_detected" +} + +test_deny_if_pattern_no_match_returns_nothing if { + not patterns.deny_if_pattern("benign text", patterns.pii_patterns, "pii_detected") +} + +test_non_string_input_does_not_match if { + not patterns.matches_any(123, patterns.pii_patterns) + not patterns.first_match({"x": 1}, patterns.pii_patterns) +} + +# A zero-length leftmost match must still produce a verdict. matches_any and +# first_match have to agree: a caller that gates on the former and reads a +# verdict from the latter would otherwise fall through to allow. +test_zero_length_leftmost_match_still_denies if { + patterns.matches_any("card 4111", ["[0-9]*"]) + verdict := patterns.deny_if_pattern("card 4111", ["[0-9]*"], "r") + verdict.decision == "deny" +} + +test_empty_text_with_zero_width_pattern_denies if { + patterns.matches_any("", ["x*"]) + patterns.deny_if_pattern("", ["x*"], "r").decision == "deny" +} + +test_match_start_of_empty_match_is_zero if { + patterns.first_match("card 4111", ["[0-9]*"]).span_start == 0 +} + +# A zero-width match reports offset 0 because OPA gives no position for it. +# Pinned so the placeholder is a recorded property rather than an accident. +test_zero_width_match_reports_placeholder_offset if { + hit := patterns.first_match("ab 12", ["(?m)$"]) + hit.match == "" + hit.span_start == 0 +} diff --git a/policy/lib/redact.rego b/policy/lib/redact.rego new file mode 100644 index 00000000..17583335 --- /dev/null +++ b/policy/lib/redact.rego @@ -0,0 +1,48 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT stock redaction transform. Combines `agt.patterns` regex matching with +# the AGT transform verdict shape (SPECIFICATION.md §14). The +# returned verdict carries `transform.path = "$policy_target"` and a fully +# replaced value, so the dispatcher can apply the substitution without +# additional logic on the host side. The substitution runs in Rego via +# `regex.replace` over a combined alternation, keeping it deterministic +# across SDKs and avoiding recursive Rego rules. + +package agt.redact + +import data.agt.patterns +import rego.v1 + +default_replacement := "[REDACTED]" + +combined_pattern(pats) := pattern if { + is_array(pats) + count(pats) > 0 + parts := [sprintf("(?:%s)", [p]) | some p in pats] + pattern := concat("|", parts) +} + +apply_patterns(text, pats, replacement) := result if { + is_string(text) + pattern := combined_pattern(pats) + result := regex.replace(text, pattern, replacement) +} + +effective_replacement(replacement) := replacement if { + is_string(replacement) +} else := default_replacement + +redact_text(text, pats, replacement) := verdict if { + patterns.matches_any(text, pats) + rep := effective_replacement(replacement) + new_value := apply_patterns(text, pats, rep) + verdict := { + "decision": "transform", + "reason": "redaction_applied", + "transform": { + "path": "$policy_target", + "value": new_value, + }, + } +} diff --git a/policy/lib/redact_test.rego b/policy/lib/redact_test.rego new file mode 100644 index 00000000..d5619b32 --- /dev/null +++ b/policy/lib/redact_test.rego @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +package agt.redact_test + +import data.agt.patterns +import data.agt.redact +import rego.v1 + +test_redact_text_returns_transform_verdict if { + verdict := redact.redact_text("ssn 111-22-3333 in plain text", [patterns.pii_ssn], "[REDACTED]") + verdict.decision == "transform" + verdict.reason == "redaction_applied" + verdict.transform.path == "$policy_target" + verdict.transform.value == "ssn [REDACTED] in plain text" +} + +test_redact_text_handles_multiple_patterns if { + text := "alice@x.com then ssn 111-22-3333 here" + verdict := redact.redact_text(text, patterns.pii_patterns, "[X]") + verdict.decision == "transform" + verdict.transform.value == "[X] then ssn [X] here" +} + +test_redact_text_no_match_returns_nothing if { + not redact.redact_text("benign", patterns.pii_patterns, "[X]") +} + +test_redact_text_default_replacement if { + verdict := redact.redact_text("ssn 111-22-3333", [patterns.pii_ssn], null) + verdict.transform.value == "ssn [REDACTED]" +} + +test_redact_text_path_rooted_at_policy_target if { + verdict := redact.redact_text("email a@b.com here", [patterns.pii_email], "[X]") + startswith(verdict.transform.path, "$policy_target") +} + +test_redact_text_handles_non_string_input if { + not redact.redact_text(123, patterns.pii_patterns, "[X]") +} + +test_combined_pattern_joins_with_alternation if { + pattern := redact.combined_pattern(["a", "b", "c"]) + pattern == "(?:a)|(?:b)|(?:c)" +} diff --git a/policy/lib/run_tests.sh b/policy/lib/run_tests.sh new file mode 100755 index 00000000..076aa215 --- /dev/null +++ b/policy/lib/run_tests.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT stock Rego library test runner. Invokes `opa test` against every +# library file and its sibling _test.rego in this directory. Returns a +# non-zero exit code when any test fails so CI can gate on the result. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +OPA_BIN="${OPA_BIN:-opa}" +if ! command -v "$OPA_BIN" >/dev/null 2>&1; then + if [ -x "$HOME/.local/bin/opa" ]; then + OPA_BIN="$HOME/.local/bin/opa" + else + echo "error: opa executable not found on PATH" >&2 + exit 127 + fi +fi + +exec "$OPA_BIN" test . -v diff --git a/scripts/verify-pin.sh b/scripts/verify-pin.sh new file mode 100755 index 00000000..3d2205d2 --- /dev/null +++ b/scripts/verify-pin.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Verifies the vendored policy/lib bundle is byte-identical to AGT at the +# pinned ref recorded in agt.lock. Clones the pinned commit into a scratch +# temp dir, points UPSTREAM_BUNDLE at its policy-engine/policy/lib, and +# re-runs the pin test so the byte-identity assertion (self-skipped without +# that env var) actually executes. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +lock_file="agt.lock" +if [ ! -f "$lock_file" ]; then + echo "verify-pin: missing $lock_file" >&2 + exit 1 +fi + +agt_repo="$(jq -r '.agt_repo' "$lock_file")" +agt_ref="$(jq -r '.agt_ref' "$lock_file")" +bundle_path="$(jq -r '.bundle_path' "$lock_file")" + +if [ -z "$agt_repo" ] || [ -z "$agt_ref" ] || [ -z "$bundle_path" ]; then + echo "verify-pin: agt.lock is missing agt_repo/agt_ref/bundle_path" >&2 + exit 1 +fi + +tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/verify-pin.XXXXXX")" +cleanup() { + if command -v trash >/dev/null 2>&1; then + trash "$tmp_dir" >/dev/null 2>&1 || true + else + rm -rf "$tmp_dir" + fi +} +trap cleanup EXIT + +# No identifying information on outbound git traffic: no credential helper, +# no terminal credential prompt, and git's default HTTP User-Agent carries +# no contact field. +export GIT_TERMINAL_PROMPT=0 + +git -c credential.helper= -c credential.useHttpPath=false \ + init --quiet "$tmp_dir" +git -C "$tmp_dir" -c credential.helper= remote add origin "$agt_repo" +git -C "$tmp_dir" -c credential.helper= fetch --quiet --depth 1 origin "$agt_ref" +git -C "$tmp_dir" -c credential.helper= checkout --quiet FETCH_HEAD + +upstream_bundle="$tmp_dir/$bundle_path" +if [ ! -d "$upstream_bundle" ]; then + echo "verify-pin: expected upstream bundle at $upstream_bundle" >&2 + exit 1 +fi + +UPSTREAM_BUNDLE="$upstream_bundle" bun test test/pin.test.ts diff --git a/test/pin.test.ts b/test/pin.test.ts new file mode 100644 index 00000000..7674e3b1 --- /dev/null +++ b/test/pin.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +const lock = JSON.parse(readFileSync("agt.lock", "utf8")); + +describe("AGT pin", () => { + it("records a full 40-character commit ref", () => { + expect(lock.agt_ref).toMatch(/^[0-9a-f]{40}$/); + }); + + it("pins the SDK version the bridge installs", () => { + const pkg = JSON.parse(readFileSync("package.json", "utf8")); + const dep = + pkg.dependencies?.["agent-control-specification"] ?? + pkg.devDependencies?.["agent-control-specification"]; + expect(dep).toBeDefined(); + expect(dep.replace(/^[^0-9]*/, "")).toBe(lock.sdk_version); + }); + + it("vendors the stock bundle with every stock module present", () => { + const files = readdirSync("policy/lib").filter((f) => f.endsWith(".rego")); + for (const mod of [ + "agt_default.rego", "agt_ifc.rego", "approval.rego", "budgets.rego", + "confidence.rego", "content_hash.rego", "drift.rego", "egress.rego", + "ifc.rego", "patterns.rego", "redact.rego", + ]) { + expect(files).toContain(mod); + } + }); + + it("adds nothing to the bundle except data.json", () => { + const extra = readdirSync("policy/lib").filter( + (f) => !f.endsWith(".rego") && f !== "run_tests.sh" && f !== "data.json", + ); + expect(extra).toEqual([]); + }); + + it("authors no Rego of our own — every .rego is byte-identical to upstream", () => { + // UPSTREAM_BUNDLE is set by `bun run verify:pin`, which clones the pinned ref. + const upstream = process.env.UPSTREAM_BUNDLE; + if (!upstream) return; // skipped in the fast unit run; enforced by verify:pin in CI + for (const f of readdirSync("policy/lib").filter((f) => f.endsWith(".rego"))) { + expect(readFileSync(join("policy/lib", f), "utf8")).toBe( + readFileSync(join(upstream, f), "utf8"), + ); + } + }); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 00000000..cec95e84 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "Bundler", + "moduleDetection": "force", + "allowJs": false, + "declaration": true, + "composite": false, + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "types": ["bun-types"] + } +} From cbe24b872cd20699c685d77b5547cf20e6db047e Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 18:10:24 +0300 Subject: [PATCH 04/39] Fix: verify-pin.sh cleanup must not fall back to rm -rf trash is now a hard dependency for scratch-dir cleanup; the script fails loudly before creating the temp dir if trash is unavailable, instead of degrading to rm -rf on cleanup. Slice: #2 Affordances: S9, S11 --- scripts/verify-pin.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/verify-pin.sh b/scripts/verify-pin.sh index 3d2205d2..e3dab003 100755 --- a/scripts/verify-pin.sh +++ b/scripts/verify-pin.sh @@ -24,13 +24,14 @@ if [ -z "$agt_repo" ] || [ -z "$agt_ref" ] || [ -z "$bundle_path" ]; then exit 1 fi +if ! command -v trash >/dev/null 2>&1; then + echo "verify-pin: 'trash' is required for scratch-dir cleanup (rm -rf is not permitted in this repo) — install it and re-run" >&2 + exit 1 +fi + tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/verify-pin.XXXXXX")" cleanup() { - if command -v trash >/dev/null 2>&1; then - trash "$tmp_dir" >/dev/null 2>&1 || true - else - rm -rf "$tmp_dir" - fi + trash "$tmp_dir" } trap cleanup EXIT From 774fb98c0b0f0f0fff4833e670ba115e9e66f4b7 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 18:26:36 +0300 Subject: [PATCH 05/39] Add the AGT bridge: stateless snapshot-to-verdict decision function Embeds AGT's engine once at boot (AgentControl.fromPath) and exposes a single async evaluate(point, snapshot) that returns a typed verdict plus inputIdentity/enforcedIdentity. No state is retained between calls. policy/manifest.yaml (S7) binds pre_tool_call to the stock Rego bundle at policy/lib via data.agt.defaults.verdict, with policy_target resolving to the leaf command string. policy/lib/data.json (S8) supplies the pattern config that blocks destructive rm invocations, and is the only non-.rego file added to the vendored bundle. One value in the manifest deviates from the spec's literal text: bundle: lib, not bundle: policy/lib. Traced empirically (ACS_OPA_PATH pointed at a logging wrapper around the real opa binary): AGT resolves a manifest's bundle path relative to the manifest file's own directory, not process cwd. With the manifest at policy/manifest.yaml, "policy/lib" joins to policy/policy/lib, which doesn't exist, and every evaluation hard-fails with runtime_error:policy_invocation_failed instead of allow/deny. "lib" is the correct manifest-relative reference to the same directory, with no leading "./" per the OPA data-mount-path bug this task also guards against. Slice: #2 Affordances: N30, N31, S7, S8 --- bun.lock | 9 ++++++ packages/agt-bridge/package.json | 11 +++++++ packages/agt-bridge/src/index.ts | 42 ++++++++++++++++++++++++ packages/agt-bridge/test/bridge.test.ts | 43 +++++++++++++++++++++++++ policy/lib/data.json | 15 +++++++++ policy/manifest.yaml | 27 ++++++++++++++++ 6 files changed, 147 insertions(+) create mode 100644 packages/agt-bridge/package.json create mode 100644 packages/agt-bridge/src/index.ts create mode 100644 packages/agt-bridge/test/bridge.test.ts create mode 100644 policy/lib/data.json create mode 100644 policy/manifest.yaml diff --git a/bun.lock b/bun.lock index 5bc36a20..3f9ab2ec 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,13 @@ "agent-control-specification": "0.3.1-beta.0", }, }, + "packages/agt-bridge": { + "name": "agt-bridge", + "version": "0.1.0", + "dependencies": { + "agent-control-specification": "0.3.1-beta.0", + }, + }, }, "packages": { "agent-control-specification": ["agent-control-specification@0.3.1-beta.0", "", { "optionalDependencies": { "agent-control-specification-darwin-arm64": "0.3.1-beta.0", "agent-control-specification-darwin-x64": "0.3.1-beta.0", "agent-control-specification-linux-arm64-gnu": "0.3.1-beta.0", "agent-control-specification-linux-x64-gnu": "0.3.1-beta.0", "agent-control-specification-opa-darwin-arm64": "0.3.1-beta.0", "agent-control-specification-opa-darwin-x64": "0.3.1-beta.0", "agent-control-specification-opa-linux-arm64": "0.3.1-beta.0", "agent-control-specification-opa-linux-x64": "0.3.1-beta.0", "agent-control-specification-opa-win32-x64": "0.3.1-beta.0", "agent-control-specification-win32-x64-msvc": "0.3.1-beta.0" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.100.0", "@langchain/core": ">=1.1.0", "@openai/agents": ">=0.11.0", "langchain": ">=1.0.0" }, "optionalPeers": ["@anthropic-ai/sdk", "@langchain/core", "@openai/agents", "langchain"] }, "sha512-vD1N169F2YhTCLbF4u7hEV5xfmAY1vziq4bHL2xhBa5QNOhdeEzrTdOIshWZw46fxxPJzEYDjQyjSmI2NnHCuQ=="], @@ -31,5 +38,7 @@ "agent-control-specification-opa-win32-x64": ["agent-control-specification-opa-win32-x64@0.3.1-beta.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hNHRXGxQ3pnagMdeowu3WgskHrF3QqX/gclesv3fesTq6xn0IKDW2/4iKdAq5lLYFk49f/fQYPV/ybtpaVwmow=="], "agent-control-specification-win32-x64-msvc": ["agent-control-specification-win32-x64-msvc@0.3.1-beta.0", "", { "os": "win32", "cpu": "x64" }, "sha512-+9/DAvNb1bGPQwYOppaiVctxDs0i8RalbZnUuobJLxghqmR4GN9JpqGKBwdk4Du9EIRwRkP5C4/AjmPj5vr+RA=="], + + "agt-bridge": ["agt-bridge@workspace:packages/agt-bridge"], } } diff --git a/packages/agt-bridge/package.json b/packages/agt-bridge/package.json new file mode 100644 index 00000000..89dd8140 --- /dev/null +++ b/packages/agt-bridge/package.json @@ -0,0 +1,11 @@ +{ + "name": "agt-bridge", + "private": true, + "type": "module", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "dependencies": { + "agent-control-specification": "0.3.1-beta.0" + } +} diff --git a/packages/agt-bridge/src/index.ts b/packages/agt-bridge/src/index.ts new file mode 100644 index 00000000..bd7058be --- /dev/null +++ b/packages/agt-bridge/src/index.ts @@ -0,0 +1,42 @@ +import { AgentControl } from "agent-control-specification"; + +export type AgtVerdict = { + decision: "allow" | "deny" | "warn" | "escalate" | "transform"; + reason?: string; + message?: string; + transform?: { path: string; value: unknown }; + result_labels?: string[]; +}; + +export type BridgeResult = { + verdict: AgtVerdict; + inputIdentity?: string; + enforcedIdentity?: string; + transformedPolicyTarget?: unknown; +}; + +/** + * N31 — construct once at boot. N30 — evaluate per decision. + * Stateless: nothing is retained between evaluate() calls (R6.1). + */ +export function createBridge(manifestPath: string) { + if (manifestPath.includes("/./")) { + throw new Error( + `manifest path contains "/./": ${manifestPath}. AGT joins this verbatim and OPA ` + + `then drops the bundle's data document, silently disabling policy. See C2.`, + ); + } + const control = AgentControl.fromPath(manifestPath); + + return { + async evaluate(point: string, snapshot: Record): Promise { + const result = await control.evaluateInterventionPoint(point as never, snapshot as never); + return { + verdict: result.verdict as AgtVerdict, + inputIdentity: result.inputIdentity, + enforcedIdentity: result.enforcedIdentity, + transformedPolicyTarget: result.transformedPolicyTarget, + }; + }, + }; +} diff --git a/packages/agt-bridge/test/bridge.test.ts b/packages/agt-bridge/test/bridge.test.ts new file mode 100644 index 00000000..512ea187 --- /dev/null +++ b/packages/agt-bridge/test/bridge.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, beforeAll } from "bun:test"; +import { createBridge } from "../src/index.ts"; + +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" }, +}); + +let bridge: ReturnType; +beforeAll(() => { bridge = createBridge("policy/manifest.yaml"); }); + +describe("agt-bridge", () => { + it("denies a destructive shell command using the stock bundle", async () => { + const r = await bridge.evaluate("pre_tool_call", snapshotFor("rm -rf /")); + expect(r.verdict.decision).toBe("deny"); + expect(r.verdict.reason).toBe("destructive_shell_command_blocked"); + expect(r.verdict.message).toContain("matched pattern"); + }); + + it("denies the -fr spelling too", async () => { + const r = await bridge.evaluate("pre_tool_call", snapshotFor("rm -fr / --no-preserve-root")); + expect(r.verdict.decision).toBe("deny"); + }); + + it("allows benign commands", async () => { + for (const cmd of ["ls -la", "git status"]) { + expect((await bridge.evaluate("pre_tool_call", snapshotFor(cmd))).verdict.decision).toBe("allow"); + } + }); + + // Guards Correction C2 — the failure mode this catches is a SILENT fail-open. + it("surfaces the policy config to Rego (guards the ./ bundle-path landmine)", async () => { + const r = await bridge.evaluate("pre_tool_call", snapshotFor("rm -rf /")); + expect(r.verdict.decision).not.toBe("allow"); + }); + + // Guards Correction C1 — this is why the bridge is Node, not Python. + it("returns input and enforced identity as distinct fields", async () => { + const r = await bridge.evaluate("pre_tool_call", snapshotFor("ls -la")); + expect(r.inputIdentity).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(r.enforcedIdentity).toMatch(/^sha256:[0-9a-f]{64}$/); + }); +}); diff --git a/policy/lib/data.json b/policy/lib/data.json new file mode 100644 index 00000000..2530d81e --- /dev/null +++ b/policy/lib/data.json @@ -0,0 +1,15 @@ +{ + "agt": { + "defaults": { + "config": { + "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|$)" + ], + "reason": "destructive_shell_command_blocked" + } + } + } + } +} diff --git a/policy/manifest.yaml b/policy/manifest.yaml new file mode 100644 index 00000000..e74dc577 --- /dev/null +++ b/policy/manifest.yaml @@ -0,0 +1,27 @@ +agent_control_specification_version: "0.3.1-beta" +metadata: + name: "acs-reference-implementation" +policies: + agt_stock: + type: rego + # Relative to this manifest's own directory (policy/), NOT the process + # cwd — confirmed empirically by tracing the opa subprocess AGT spawns. + # "policy/lib" here would join to ".../policy/policy/lib" and every + # evaluation would hard-fail with runtime_error:policy_invocation_failed + # (opa: no such file or directory) instead of allow/deny. No leading + # "./" either — see C2: OPA's bundle loader mis-derives the data mount + # path from a literal "/./ " segment and silently drops data.json. + bundle: lib + query: data.agt.defaults.verdict +intervention_points: + pre_tool_call: + policy_target: "$.tool_call.args.command" + policy_target_kind: tool_args + tool_name_from: "$.tool_call.name" + policy: + id: agt_stock +tools: + run_shell: + type: Tool + id: run_shell + security_labels: [shell] From 0572d3146c97fe4a4281b827f47ad87f4f6b71f5 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 18:36:40 +0300 Subject: [PATCH 06/39] Amend: bundle: resolves against the manifest's own directory Found while implementing Task 2. The plan specified `bundle: policy/lib`, but AGT resolves that field relative to the directory holding manifest.yaml, not the process cwd. From policy/manifest.yaml it becomes policy/policy/lib and every evaluation hard-fails runtime_error:policy_invocation_failed. Correct value is `bundle: lib`. Independently reproduced by the task reviewer before accepting the deviation. Recorded next to the "./" landmine because the two are easily confused: both come from how AGT joins this one field, but they fail in opposite directions. A "./" prefix fails OPEN and silently -- policy goes inert and everything is allowed. A wrong relative base fails CLOSED and loudly. The silent one is the dangerous one, and is why V1's deny assertions exist as a backstop. Slice: #2 --- docs/shaping/acs-reference-impl-slices.md | 2 ++ docs/superpowers/plans/2026-08-09-v1-one-host-one-hook.md | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/shaping/acs-reference-impl-slices.md b/docs/shaping/acs-reference-impl-slices.md index 6dc13c46..99b80be7 100644 --- a/docs/shaping/acs-reference-impl-slices.md +++ b/docs/shaping/acs-reference-impl-slices.md @@ -61,6 +61,8 @@ Every slice ends in something demo-able. **Setup cost this slice absorbs:** ⚠️ *amended* — the `opa` CLI is **no longer a setup cost*. The npm package pulls `agent-control-specification-opa-darwin-arm64`, which ships OPA 0.70.0, overridable via `ACS_OPA_PATH` / `ACS_OPA_NO_BUNDLE`. The stock bundle passes 105/105 under both it and system OPA 1.18.2. What remains: the pinned AGT checkout and the first cut of `mapping.yaml`. **This closes D7 as Rego** — Cedar's only advantage was removing an external binary, and there is no external binary. +**⚠️ Watch-for — `bundle:` resolves against the manifest's own directory.** Discovered during V1 execution, after the `./` landmine below: `bundle:` is relative to the directory holding `manifest.yaml`, not the process cwd. With the manifest at `policy/manifest.yaml`, `bundle: policy/lib` resolves to `policy/policy/lib` and every call hard-fails `runtime_error:policy_invocation_failed`. The correct value is `bundle: lib`. This failure is *loud* — unlike the `./` landmine, it denies rather than silently allowing — but the two are easily confused because both stem from how AGT joins this one field. + **⚠️ Watch-for — the `./` landmine.** `policies..bundle` must **not** begin with `./`. AGT joins the manifest directory to the literal value, yielding `/./policy/lib`; OPA's bundle loader mis-derives the data mount path from the `/./` segment and silently drops `data.json`. The policy then matches nothing and **every decision becomes `allow`** — a fail-open with no error, in a governance tool. Measured: `./policy/lib` loads, `/abs/policy/lib` loads, `/abs/./policy/lib` is UNDEFINED. `createBridge` throws on `/./`, and V1's deny test is the backstop. **⚠️ Amendment — the config lives inside the bundle.** `data_paths` cannot deliver `data.agt.defaults.config` while `bundle:` is set: the stock bundle ships no `.manifest`, so its roots default to `""`, it owns the whole data tree, and the `--data` document is discarded. S8 therefore ships as `policy/lib/data.json`. Every stock `.rego` stays byte-identical; `data.json` is the only added file, so R2.1/R2.3 hold — we author a data document, not policy. diff --git a/docs/superpowers/plans/2026-08-09-v1-one-host-one-hook.md b/docs/superpowers/plans/2026-08-09-v1-one-host-one-hook.md index 3f3aad85..469cb9d2 100644 --- a/docs/superpowers/plans/2026-08-09-v1-one-host-one-hook.md +++ b/docs/superpowers/plans/2026-08-09-v1-one-host-one-hook.md @@ -272,7 +272,9 @@ metadata: policies: agt_stock: type: rego - bundle: policy/lib + bundle: lib # ⚠️ amended in execution: relative to THIS FILE's directory, not cwd. + # `policy/lib` here resolves to policy/policy/lib and hard-fails + # runtime_error:policy_invocation_failed. Still no leading `./` (C2). query: data.agt.defaults.verdict intervention_points: pre_tool_call: From 465ea3710e7988f5c9e7e680c3299ffd8890f398 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 18:42:36 +0300 Subject: [PATCH 07/39] Add mapping.yaml and mapVerdict: AGT verdict to ACS decision mapping.yaml is the single source of truth for the ACS<->AGT translation, read by both the runtime (here) and, later, the conformance harness (V7) that publishes a machine-checked mapping table. It declares two tables: - verdicts: AGT decision -> ACS decision (allow->allow, deny->deny, warn->allow with require_policy_references: true, escalate->ask, transform->modify). ACS decisions are lowercase on the wire (C7). - field_synthesis: AGT carries no rule_id / reason_codes / reasoning (C6) -- its verdict is just {decision, reason, message, ...}, where reason is a single low-cardinality string and message is the human-facing text. Each ACS field is synthesized from one of those two, declared as a leaf that is either {source: "verdict."} (optionally wrap: array) or {literal: }, so a harness can walk the same structure the runtime does. packages/guardian/src/map-verdict.ts implements exactly that table: mapVerdict looks up the AGT decision in mapping.yaml's verdicts table and applies field_synthesis generically by reading the declared source path off the verdict -- no verdict name or ACS field name is hardcoded in the function body. require_policy_references is checked against the table's own flag, not against a hardcoded "warn" case; this is what lets a later consumer tell an observe-only allow (AGT warn) from a clean allow (R1.2) by policy_references alone. packages/guardian/package.json depends on agt-bridge via the workspace protocol and imports AgtVerdict from it rather than redeclaring the type. Slice: #2 Affordances: S10, N24 --- bun.lock | 9 +++ mapping.yaml | 62 +++++++++++++++++ packages/guardian/package.json | 11 ++++ packages/guardian/src/map-verdict.ts | 77 ++++++++++++++++++++++ packages/guardian/test/map-verdict.test.ts | 45 +++++++++++++ 5 files changed, 204 insertions(+) create mode 100644 mapping.yaml create mode 100644 packages/guardian/package.json create mode 100644 packages/guardian/src/map-verdict.ts create mode 100644 packages/guardian/test/map-verdict.test.ts diff --git a/bun.lock b/bun.lock index 3f9ab2ec..d6fa5aae 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,13 @@ "agent-control-specification": "0.3.1-beta.0", }, }, + "packages/guardian": { + "name": "guardian", + "version": "0.1.0", + "dependencies": { + "agt-bridge": "workspace:*", + }, + }, }, "packages": { "agent-control-specification": ["agent-control-specification@0.3.1-beta.0", "", { "optionalDependencies": { "agent-control-specification-darwin-arm64": "0.3.1-beta.0", "agent-control-specification-darwin-x64": "0.3.1-beta.0", "agent-control-specification-linux-arm64-gnu": "0.3.1-beta.0", "agent-control-specification-linux-x64-gnu": "0.3.1-beta.0", "agent-control-specification-opa-darwin-arm64": "0.3.1-beta.0", "agent-control-specification-opa-darwin-x64": "0.3.1-beta.0", "agent-control-specification-opa-linux-arm64": "0.3.1-beta.0", "agent-control-specification-opa-linux-x64": "0.3.1-beta.0", "agent-control-specification-opa-win32-x64": "0.3.1-beta.0", "agent-control-specification-win32-x64-msvc": "0.3.1-beta.0" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.100.0", "@langchain/core": ">=1.1.0", "@openai/agents": ">=0.11.0", "langchain": ">=1.0.0" }, "optionalPeers": ["@anthropic-ai/sdk", "@langchain/core", "@openai/agents", "langchain"] }, "sha512-vD1N169F2YhTCLbF4u7hEV5xfmAY1vziq4bHL2xhBa5QNOhdeEzrTdOIshWZw46fxxPJzEYDjQyjSmI2NnHCuQ=="], @@ -40,5 +47,7 @@ "agent-control-specification-win32-x64-msvc": ["agent-control-specification-win32-x64-msvc@0.3.1-beta.0", "", { "os": "win32", "cpu": "x64" }, "sha512-+9/DAvNb1bGPQwYOppaiVctxDs0i8RalbZnUuobJLxghqmR4GN9JpqGKBwdk4Du9EIRwRkP5C4/AjmPj5vr+RA=="], "agt-bridge": ["agt-bridge@workspace:packages/agt-bridge"], + + "guardian": ["guardian@workspace:packages/guardian"], } } diff --git a/mapping.yaml b/mapping.yaml new file mode 100644 index 00000000..7c86a655 --- /dev/null +++ b/mapping.yaml @@ -0,0 +1,62 @@ +# mapping.yaml — the ACS↔AGT translation table. +# +# This file is the single source of truth for how an AGT verdict becomes an +# ACS decision. It is read by two consumers that must never disagree: +# 1. The runtime (packages/guardian/src/map-verdict.ts), at decision time. +# 2. The conformance harness (V7), which publishes a machine-checked +# ACS↔AGT mapping table from this same file. +# +# Any change to the verdict/field_synthesis tables below is a behavior +# change for both consumers. mapVerdict must implement exactly this table — +# no verdict names, decisions, or field derivations hardcoded in code that +# aren't declared here. + +acs_version: "0.1.0" +agt_version: "0.3.1-beta" + +# ACS intervention points AGT can be wired to. acs_method: null marks a +# point AGT supports that ACS v0.1.0 has no equivalent for (D4) — read by +# V7, not by the runtime. +intervention_points: + pre_tool_call: { acs_method: "steps/toolCallRequest" } + post_tool_call: { acs_method: "steps/toolCallResult" } + agent_startup: { acs_method: "steps/sessionStart" } + agent_shutdown: { acs_method: "steps/sessionEnd" } + input: { acs_method: "steps/userMessage" } + output: { acs_method: "steps/agentResponse" } + pre_model_call: { acs_method: null, note: "no ACS v0.1.0 target — D4, V7 red cell" } + post_model_call: { acs_method: null, note: "no ACS v0.1.0 target — D4, V7 red cell" } + +# AGT decision -> ACS decision. decision values are lowercase on the wire +# (C7). require_policy_references marks a verdict for which a non-empty +# policy_references is load-bearing (R1.2): it is what distinguishes an +# observe-only allow (AGT warn) from a clean allow (AGT allow). +verdicts: + allow: { decision: allow } + deny: { decision: deny } + warn: { decision: allow, require_policy_references: true } + escalate: { decision: ask } + transform: { decision: modify } + +# AGT carries no rule_id / reason_codes / reasoning (C6) — its verdict is +# just {decision, reason, message, transform?, evidence?, result_labels?}, +# where `reason` is a single low-cardinality string and `message` is the +# human-facing text. ACS's richer fields are synthesized from those two. +# +# Each leaf is either: +# { source: "verdict." } — copy the field verbatim +# { source: "verdict.", wrap: array } — wrap the field in a +# single-element array +# { literal: } — a fixed value, not read +# from the verdict at all +field_synthesis: + reasoning: + source: verdict.message + reason_codes: + source: verdict.reason + wrap: array + policy_references: + rule_id: + source: verdict.reason + policy_id: + literal: agt_stock diff --git a/packages/guardian/package.json b/packages/guardian/package.json new file mode 100644 index 00000000..ee69914f --- /dev/null +++ b/packages/guardian/package.json @@ -0,0 +1,11 @@ +{ + "name": "guardian", + "private": true, + "type": "module", + "version": "0.1.0", + "main": "src/map-verdict.ts", + "types": "src/map-verdict.ts", + "dependencies": { + "agt-bridge": "workspace:*" + } +} diff --git a/packages/guardian/src/map-verdict.ts b/packages/guardian/src/map-verdict.ts new file mode 100644 index 00000000..3955e2e6 --- /dev/null +++ b/packages/guardian/src/map-verdict.ts @@ -0,0 +1,77 @@ +import { readFileSync } from "node:fs"; +import type { AgtVerdict } from "agt-bridge"; + +export type AcsDecision = { + decision: "allow" | "deny" | "modify" | "ask" | "defer"; + reasoning?: string; + reason_codes?: string[]; + policy_references?: { policy_id: string; policy_version?: string; rule_id: string }[]; +}; + +type VerdictRule = { + decision: AcsDecision["decision"]; + require_policy_references?: boolean; +}; + +/** A leaf of field_synthesis: either copy a verdict field, or emit a fixed value. */ +type FieldSource = { source: string; wrap?: "array" }; +type FieldLiteral = { literal: string }; + +export type Mapping = { + acs_version: string; + agt_version: string; + intervention_points: Record; + verdicts: Record; + field_synthesis: { + reasoning: FieldSource; + reason_codes: FieldSource; + policy_references: { + rule_id: FieldSource; + policy_id: FieldLiteral; + }; + }; +}; + +export function loadMapping(path: string): Mapping { + return Bun.YAML.parse(readFileSync(path, "utf8")) as Mapping; +} + +/** Resolves a field_synthesis `source: "verdict."` path against a verdict. */ +function readVerdictField(verdict: AgtVerdict, source: FieldSource): unknown { + const field = source.source.slice("verdict.".length) as keyof AgtVerdict; + return verdict[field]; +} + +export function mapVerdict(verdict: AgtVerdict, mapping: Mapping): AcsDecision { + const rule = mapping.verdicts[verdict.decision]; + if (!rule) { + throw new Error(`mapping.yaml has no verdict rule for AGT decision "${verdict.decision}"`); + } + + const fs = mapping.field_synthesis; + const out: AcsDecision = { decision: rule.decision }; + + const reasoning = readVerdictField(verdict, fs.reasoning); + if (typeof reasoning === "string") { + out.reasoning = reasoning; + } + + const reasonForCodes = readVerdictField(verdict, fs.reason_codes); + if (typeof reasonForCodes === "string") { + out.reason_codes = fs.reason_codes.wrap === "array" ? [reasonForCodes] : (reasonForCodes as unknown as string[]); + } + + const ruleId = readVerdictField(verdict, fs.policy_references.rule_id); + if (typeof ruleId === "string") { + out.policy_references = [{ policy_id: fs.policy_references.policy_id.literal, rule_id: ruleId }]; + } + + if (rule.require_policy_references && !(out.policy_references && out.policy_references.length > 0)) { + throw new Error( + `mapping.yaml declares require_policy_references for AGT decision "${verdict.decision}", ` + + `but no policy_references could be synthesized (verdict.reason was empty)`, + ); + } + + return out; +} diff --git a/packages/guardian/test/map-verdict.test.ts b/packages/guardian/test/map-verdict.test.ts new file mode 100644 index 00000000..c4bc3c01 --- /dev/null +++ b/packages/guardian/test/map-verdict.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "bun:test"; +import { loadMapping, mapVerdict } from "../src/map-verdict.ts"; + +const m = loadMapping("mapping.yaml"); + +describe("mapVerdict", () => { + it("maps allow to allow", () => { + expect(mapVerdict({ decision: "allow" }, m).decision).toBe("allow"); + }); + + it("maps deny, carrying reason and message into ACS fields", () => { + const d = mapVerdict( + { decision: "deny", reason: "destructive_shell_command_blocked", message: "matched pattern X" }, + m, + ); + expect(d.decision).toBe("deny"); + expect(d.reasoning).toBe("matched pattern X"); + expect(d.reason_codes).toEqual(["destructive_shell_command_blocked"]); + expect(d.policy_references?.[0].rule_id).toBe("destructive_shell_command_blocked"); + }); + + // R1.2 — the whole warn round trip rests on this. + it("maps warn to allow WITH non-empty policy_references", () => { + const d = mapVerdict({ decision: "warn", reason: "drift_detected", message: "drift 0.8" }, m); + expect(d.decision).toBe("allow"); + expect(d.policy_references?.length).toBeGreaterThan(0); + expect(d.policy_references?.[0].rule_id).toBe("drift_detected"); + }); + + it("distinguishes warn-allow from clean allow by policy_references", () => { + expect(mapVerdict({ decision: "allow" }, m).policy_references ?? []).toHaveLength(0); + }); + + it("maps escalate to ask and transform to modify", () => { + expect(mapVerdict({ decision: "escalate", reason: "approval_required" }, m).decision).toBe("ask"); + expect(mapVerdict({ decision: "transform", reason: "redacted" }, m).decision).toBe("modify"); + }); + + it("emits only lowercase decisions (C7)", () => { + for (const dec of ["allow", "deny", "warn", "escalate", "transform"] as const) { + const out = mapVerdict({ decision: dec, reason: "r" }, m).decision; + expect(out).toBe(out.toLowerCase()); + } + }); +}); From ccebb525dfe708966f5f2a7902d614a7006d16be Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 19:02:29 +0300 Subject: [PATCH 08/39] Add assembleSnapshot: envelope to AGT pre_tool_call snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assembleSnapshot(envelope) converts a validated ACS request envelope for steps/toolCallRequest into the AGT snapshot shape for pre_tool_call, per AGT-SNAPSHOT-1.0.md §2.5: { envelope: { budgets: {...} }, tool_call: { name, args, id } } params.payload.tool.name maps to tool_call.name and params.request_id to tool_call.id. Each arguments. is unwrapped from the ACS {value, provenance} wrapper down to its raw value at args. -- provenance is dropped here; carrying it into lineage is V6's job (C5: AGT's stock pattern check reads input.policy_target.value and requires is_string, so a surviving wrapper or non-string value would make the check silently never fire). envelope.budgets is always emitted with all four counters zeroed, never undefined/null, since budgets.rego fails closed on a present-but-wrong-typed counter. Envelope-only, per the V1 watch-for: the function reads nothing but its argument. A dedicated test builds a realistic envelope with session_id/session_state/chain_hash/agent_id and asserts none of those keys (or their values) survive into the snapshot -- that boundary is for V6 to cross via S3/S4/S5, not this task. The last test is the first point in the build where guardian's snapshot assembly and agt-bridge's real OPA evaluation meet: a realistic rm -rf / envelope, assembled here, fed through createBridge(...).evaluate("pre_tool_call", ...), and denied. Slice: #2 Affordances: N23 --- packages/guardian/src/assemble-snapshot.ts | 53 ++++++++ .../guardian/test/assemble-snapshot.test.ts | 116 ++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 packages/guardian/src/assemble-snapshot.ts create mode 100644 packages/guardian/test/assemble-snapshot.test.ts diff --git a/packages/guardian/src/assemble-snapshot.ts b/packages/guardian/src/assemble-snapshot.ts new file mode 100644 index 00000000..be8fd0fa --- /dev/null +++ b/packages/guardian/src/assemble-snapshot.ts @@ -0,0 +1,53 @@ +/** + * assembleSnapshot converts a validated ACS request envelope (method + * `steps/toolCallRequest`) into the AGT snapshot for `pre_tool_call`, + * shaped per AGT-SNAPSHOT-1.0.md §2.5. + * + * Envelope-only (per the V1 watch-for): this reads nothing but the + * envelope handed to it -- no session state, no chain hash, no prior + * decisions, no intent. Those arrive in V6 via S3/S4/S5. + */ + +/** An ACS argument wrapper: `{value, provenance?}`. The provenance is + * dropped here -- carrying it into lineage is V6's job, not this one's. */ +type AcsArgument = { value: unknown; provenance?: unknown }; + +export type ToolCallRequestEnvelope = { + params: { + request_id: string; + payload: { + tool: { name: string }; + arguments: Record; + }; + }; +}; + +export function assembleSnapshot(envelope: ToolCallRequestEnvelope): Record { + const { payload, request_id } = envelope.params; + + // Unwrap every argument. AGT reads raw values (e.g. args.command must be + // a plain string for the stock pattern check's is_string guard -- C5); + // 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; + } + + return { + // budgets.rego fails closed on a present-but-wrong-typed counter, so + // these are always real zeros, never undefined/null. + envelope: { + budgets: { + tool_call_count: 0, + token_count: 0, + elapsed_seconds: 0, + cost_usd: 0, + }, + }, + tool_call: { + name: payload.tool.name, + args, + id: request_id, + }, + }; +} diff --git a/packages/guardian/test/assemble-snapshot.test.ts b/packages/guardian/test/assemble-snapshot.test.ts new file mode 100644 index 00000000..8aacdcaa --- /dev/null +++ b/packages/guardian/test/assemble-snapshot.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "bun:test"; +import { createBridge } from "agt-bridge"; +import { assembleSnapshot, type ToolCallRequestEnvelope } from "../src/assemble-snapshot.ts"; + +function makeEnvelope(overrides: { + toolName?: string; + args?: Record; + requestId?: string; +} = {}): ToolCallRequestEnvelope { + const { + toolName = "run_shell", + args = { command: { value: "ls -la", provenance: { source: "user" } } }, + requestId = "8f14e45f-ceea-467e-bd5f-1d4d9a4e0c8f", + } = overrides; + + return { + jsonrpc: "2.0", + method: "steps/toolCallRequest", + id: 1, + params: { + acs_version: "0.1.0", + request_id: requestId, + timestamp: "2026-08-09T12:00:00Z", + metadata: { + agent_id: "agent-1", + session_id: "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed", + session_state: { chain_hash: "deadbeef" }, + }, + payload: { + tool: { name: toolName }, + arguments: args, + }, + }, + } as unknown as ToolCallRequestEnvelope; +} + +describe("assembleSnapshot", () => { + it("maps params.payload.tool.name to tool_call.name, and unwraps each argument's .value into tool_call.args, dropping provenance", () => { + const envelope = makeEnvelope({ + toolName: "run_shell", + args: { + command: { value: "rm -rf /", provenance: { source: "user", timestamp: "2026-08-09T12:00:00Z" } }, + }, + }); + + const snapshot = assembleSnapshot(envelope) as { tool_call: { name: string; args: Record } }; + + expect(snapshot.tool_call.name).toBe("run_shell"); + expect(snapshot.tool_call.args).toEqual({ command: "rm -rf /" }); + }); + + // C5 — AGT's stock pattern check reads input.policy_target.value and + // requires is_string. A surviving {value:...} wrapper (or a non-string) + // makes the check silently never fire, and everything is allowed. + it("keeps tool_call.args.command a STRING, not a nested wrapper or object", () => { + const envelope = makeEnvelope({ args: { command: { value: "rm -rf /" } } }); + + const snapshot = assembleSnapshot(envelope) as { tool_call: { args: { command: unknown } } }; + + expect(typeof snapshot.tool_call.args.command).toBe("string"); + expect(snapshot.tool_call.args.command).toBe("rm -rf /"); + }); + + it("always emits envelope.budgets with all four counters zeroed, even though the envelope says nothing about budgets", () => { + const envelope = makeEnvelope(); + + const snapshot = assembleSnapshot(envelope) as { envelope: { budgets: Record } }; + + expect(snapshot.envelope).toEqual({ + budgets: { tool_call_count: 0, token_count: 0, elapsed_seconds: 0, cost_usd: 0 }, + }); + }); + + it("carries params.request_id onto tool_call.id", () => { + const envelope = makeEnvelope({ requestId: "2c3e4f50-1234-4abc-9def-000000000000" }); + + const snapshot = assembleSnapshot(envelope) as { tool_call: { id: string } }; + + expect(snapshot.tool_call.id).toBe("2c3e4f50-1234-4abc-9def-000000000000"); + }); + + // Envelope-only (the V1 watch-for): no S3/S4/S5 reads. Session id, chain + // hash, and every other session-derived key must not survive into the + // snapshot -- this is the boundary the later V6 slice will cross, on + // purpose, somewhere else. + it("reads nothing but the envelope: no session-derived key appears anywhere in the output", () => { + const envelope = makeEnvelope(); + + const snapshot = assembleSnapshot(envelope) as Record; + + expect(Object.keys(snapshot).sort()).toEqual(["envelope", "tool_call"]); + expect(Object.keys(snapshot.envelope as object)).toEqual(["budgets"]); + expect(Object.keys(snapshot.tool_call as object).sort()).toEqual(["args", "id", "name"]); + + const serialized = JSON.stringify(snapshot); + for (const forbidden of ["session_id", "session_state", "chain_hash", "agent_id", "metadata", "intent", "1b9d6bcd"]) { + expect(serialized).not.toContain(forbidden); + } + }); + + // The high-value integration test: this is the first point in the build + // where the two halves (guardian's snapshot assembly, agt-bridge's real + // OPA evaluation) meet. + it("feeds a realistic rm -rf / envelope through the real AGT bridge and gets denied", async () => { + const envelope = makeEnvelope({ + toolName: "run_shell", + args: { command: { value: "rm -rf /", provenance: { source: "user" } } }, + }); + + const snapshot = assembleSnapshot(envelope); + const bridge = createBridge("policy/manifest.yaml"); + const result = await bridge.evaluate("pre_tool_call", snapshot); + + expect(result.verdict.decision).toBe("deny"); + }); +}); From 0e73fd686992e7fef983a8d5c52a0924d2702d1d Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 19:45:02 +0300 Subject: [PATCH 09/39] Add validateEnvelope: ACS envelope schema validation via Ajv packages/guardian/src/validate-envelope.ts exports validateEnvelope(input), which validates an incoming envelope against the v0.1.0 JSON Schemas pinned in the spec/acs submodule: - The whole envelope against specification/v0.1.0/request-envelope.json (jsonrpc/method/id shape, the method prefix pattern, and AcsParams -> Metadata, which is where request_id/timestamp/metadata.{agent_id, session_id} are required). - params.payload against hooks/tool-call-request.json, but only when method === "steps/toolCallRequest" -- the one hook this slice handles. The schemas are modular and $ref each other by $id (https://acs.org/schema/v0.1.0/.json), not by file path -- e.g. tool-call-request.json's arguments.*.provenance refs "../provenance.json". So on first use the module recursively loads every .json under spec/acs/specification/v0.1.0/ (agbom/, hooks/, inspect/, trace/ included -- 43 files) and registers each with Ajv under its own $id up front, letting Ajv's own resolution do the rest; nothing is inlined or rewritten. Uses Ajv2020 (ajv/dist/2020) since every schema declares $schema: .../draft/2020-12/schema, plus ajv-formats so the "uuid" and "date-time" formats the schemas declare have real validators. With those in place all 43 schemas compile and validate cleanly under strict: true -- nothing needed disabling. Failure is a thrown, typed EnvelopeValidationError, never a returned decision -- turning a rejection into an explicit ACS deny is N27, which belongs to V3, not here. The error's .pointer names the offending JSON pointer (synthesized from Ajv's instancePath + missingProperty for `required` failures, since Ajv reports those against the parent object, not the missing child) so a human, or Task 6's JSON-RPC error mapping, can find the field without re-deriving it from .errors. Reconciles the seam Task 4 flagged: assemble-snapshot.ts's local, narrower ToolCallRequestEnvelope is gone. validate-envelope.ts's fuller type (the full request-envelope.json + hooks/tool-call-request.json shape) is now the single canonical one; assemble-snapshot.ts imports and re-exports it so its existing import path keeps working, and its body needed no changes since the fuller type is a structural superset of what it destructures. Added ajv + ajv-formats to packages/guardian via `bun add`. Slice: #2 Affordances: N21 --- bun.lock | 14 ++ packages/guardian/package.json | 4 +- packages/guardian/src/assemble-snapshot.ts | 22 +- packages/guardian/src/validate-envelope.ts | 188 ++++++++++++++++++ .../guardian/test/validate-envelope.test.ts | 114 +++++++++++ 5 files changed, 328 insertions(+), 14 deletions(-) create mode 100644 packages/guardian/src/validate-envelope.ts create mode 100644 packages/guardian/test/validate-envelope.test.ts diff --git a/bun.lock b/bun.lock index d6fa5aae..c951e6ef 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,8 @@ "version": "0.1.0", "dependencies": { "agt-bridge": "workspace:*", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", }, }, }, @@ -48,6 +50,18 @@ "agt-bridge": ["agt-bridge@workspace:packages/agt-bridge"], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="], + "guardian": ["guardian@workspace:packages/guardian"], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], } } diff --git a/packages/guardian/package.json b/packages/guardian/package.json index ee69914f..cf5697d2 100644 --- a/packages/guardian/package.json +++ b/packages/guardian/package.json @@ -6,6 +6,8 @@ "main": "src/map-verdict.ts", "types": "src/map-verdict.ts", "dependencies": { - "agt-bridge": "workspace:*" + "agt-bridge": "workspace:*", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1" } } diff --git a/packages/guardian/src/assemble-snapshot.ts b/packages/guardian/src/assemble-snapshot.ts index be8fd0fa..0241954f 100644 --- a/packages/guardian/src/assemble-snapshot.ts +++ b/packages/guardian/src/assemble-snapshot.ts @@ -8,19 +8,15 @@ * decisions, no intent. Those arrive in V6 via S3/S4/S5. */ -/** An ACS argument wrapper: `{value, provenance?}`. The provenance is - * dropped here -- carrying it into lineage is V6's job, not this one's. */ -type AcsArgument = { value: unknown; provenance?: unknown }; - -export type ToolCallRequestEnvelope = { - params: { - request_id: string; - payload: { - tool: { name: string }; - arguments: Record; - }; - }; -}; +/** + * The envelope type is validate-envelope.ts's (Task 5) -- re-exported here + * so existing imports of `ToolCallRequestEnvelope` from this module keep + * working. Task 4 had declared a local, narrower type as a temporary seam; + * this closes it so there's exactly one envelope shape, not two that could + * silently diverge. + */ +import type { ToolCallRequestEnvelope } from "./validate-envelope.ts"; +export type { ToolCallRequestEnvelope }; export function assembleSnapshot(envelope: ToolCallRequestEnvelope): Record { const { payload, request_id } = envelope.params; diff --git a/packages/guardian/src/validate-envelope.ts b/packages/guardian/src/validate-envelope.ts new file mode 100644 index 00000000..b988885b --- /dev/null +++ b/packages/guardian/src/validate-envelope.ts @@ -0,0 +1,188 @@ +/** + * validateEnvelope checks an incoming ACS request envelope against the + * v0.1.0 JSON Schemas pinned in the spec/acs submodule + * (spec/acs/specification/v0.1.0/request-envelope.json), and -- for + * `steps/toolCallRequest` -- additionally against the hook-specific + * payload schema (hooks/tool-call-request.json). + * + * Failure is a THROWN, typed EnvelopeValidationError -- never a returned + * decision. Turning a rejection into an explicit ACS "deny" decision is + * N27, which belongs to a later slice (V3). The caller (Task 6, the + * Guardian server) is responsible for turning a thrown error into a + * JSON-RPC error response. + */ +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import Ajv2020 from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import type { ErrorObject, ValidateFunction } from "ajv"; + +/** An ACS argument wrapper: `{value, provenance?}`. */ +export type AcsArgument = { value: unknown; provenance?: unknown }; + +/** + * The validated shape of a `steps/toolCallRequest` envelope, per + * request-envelope.json's AcsParams/Metadata $defs plus + * hooks/tool-call-request.json's payload shape. + * + * This is the canonical envelope type for the project: assemble-snapshot.ts + * re-exports it rather than declaring its own -- Task 4 left a local, + * narrower `ToolCallRequestEnvelope` there as a temporary seam explicitly + * flagged for reconciliation with this task. + */ +export type ToolCallRequestEnvelope = { + jsonrpc: "2.0"; + method: string; + id: string | number; + params: { + acs_version: string; + request_id: string; + timestamp: string; + nonce?: string; + tenant_id?: string; + metadata: { + agent_id: string; + agent_name?: string; + session_id: string; + turn_id?: string; + parent_turn_id?: string; + session_state?: { chain_hash?: string }; + environment?: "development" | "staging" | "production"; + platform?: string; + platform_version?: string; + user_context?: { user_id?: string; roles?: string[]; authentication_method?: string }; + }; + payload: { + tool: { name: string; version?: string; provider?: string }; + operation?: string; + capability?: string; + arguments: Record; + raw_command?: string; + intent?: { description?: string; goal?: string }; + }; + signature?: { algorithm: string; value: string; key_id: string }; + }; +}; + +/** + * Thrown when an envelope fails schema validation. `pointer` is the JSON + * pointer (relative to the envelope root) of the first failing location -- + * synthesized from Ajv's instancePath + missingProperty for `required` + * failures, since Ajv itself reports those against the parent object, not + * the missing child -- so a human (or Task 6's JSON-RPC error mapping) can + * find the offending field without re-deriving it from `errors`. + */ +export class EnvelopeValidationError extends Error { + readonly pointer: string; + readonly errors: ErrorObject[]; + + constructor(pointer: string, errors: ErrorObject[]) { + const detail = errors[0]?.message ?? "invalid"; + super(`ACS envelope failed schema validation at ${pointer}: ${detail}`); + this.name = "EnvelopeValidationError"; + this.pointer = pointer; + this.errors = errors; + } +} + +const SCHEMA_ROOT = fileURLToPath(new URL("../../../spec/acs/specification/v0.1.0/", import.meta.url)); +const REQUEST_ENVELOPE_SCHEMA_ID = "https://acs.org/schema/v0.1.0/request-envelope.json"; +const TOOL_CALL_REQUEST_SCHEMA_ID = "https://acs.org/schema/v0.1.0/hooks/tool-call-request.json"; +const TOOL_CALL_REQUEST_METHOD = "steps/toolCallRequest"; + +function listSchemaFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...listSchemaFiles(full)); + } else if (entry.isFile() && entry.name.endsWith(".json")) { + out.push(full); + } + } + return out; +} + +/** + * Builds one Ajv instance with every v0.1.0 schema registered under its + * own $id (agbom/, hooks/, inspect/, trace/ included), so the modular + * $refs between them -- e.g. hooks/tool-call-request.json's + * argument.provenance ref to "../provenance.json" -- resolve the way the + * spec authors intended: against $id, not file path. Ajv resolves a + * relative $ref against the referencing schema's own $id as base URI, so + * registering every schema up front is sufficient; nothing needs + * inlining or rewriting. + * + * strict:true is left ON. Every v0.1.0 schema compiles cleanly under it + * once ajv-formats supplies the "uuid" and "date-time" format validators + * the schemas declare (Ajv core recognizes no formats on its own, and + * strict mode would otherwise reject them as unknown) -- verified against + * all 43 schema files under v0.1.0/. Nothing else needed disabling. + */ +function buildAjv() { + const ajv = new Ajv2020({ strict: true, allErrors: true }); + addFormats(ajv); + for (const file of listSchemaFiles(SCHEMA_ROOT)) { + const schema = JSON.parse(readFileSync(file, "utf8")) as { $id?: string }; + if (schema.$id && !ajv.getSchema(schema.$id)) { + ajv.addSchema(schema); + } + } + return ajv; +} + +let ajv: ReturnType | undefined; + +function getValidator(schemaId: string): ValidateFunction { + if (!ajv) { + ajv = buildAjv(); + } + const validate = ajv.getSchema(schemaId); + if (!validate) { + throw new Error(`validate-envelope: schema not registered: ${schemaId}`); + } + return validate; +} + +/** The JSON pointer an Ajv error names, honoring `required`'s + * missingProperty (Ajv reports the parent's instancePath there, not the + * missing child's). */ +function pointerOf(error: ErrorObject): string { + if (error.keyword === "required") { + const missingProperty = (error.params as { missingProperty: string }).missingProperty; + return `${error.instancePath}/${missingProperty}`; + } + return error.instancePath || "/"; +} + +function toValidationError(errors: ErrorObject[] | null | undefined, prefix: string): EnvelopeValidationError { + const list = errors ?? []; + const firstPointer = list[0] ? pointerOf(list[0]) : "/"; + return new EnvelopeValidationError(`${prefix}${firstPointer}`, list); +} + +/** + * Validates an incoming envelope against request-envelope.json, and -- + * only when `method` is `steps/toolCallRequest` -- additionally validates + * `params.payload` against hooks/tool-call-request.json. Returns the + * envelope, typed, on success. Throws EnvelopeValidationError on any + * failure; never returns a decision. + */ +export function validateEnvelope(input: unknown): ToolCallRequestEnvelope { + const validateTopLevel = getValidator(REQUEST_ENVELOPE_SCHEMA_ID); + if (!validateTopLevel(input)) { + throw toValidationError(validateTopLevel.errors, ""); + } + + const envelope = input as ToolCallRequestEnvelope; + + if (envelope.method === TOOL_CALL_REQUEST_METHOD) { + const validatePayload = getValidator(TOOL_CALL_REQUEST_SCHEMA_ID); + if (!validatePayload(envelope.params.payload)) { + throw toValidationError(validatePayload.errors, "/params/payload"); + } + } + + return envelope; +} diff --git a/packages/guardian/test/validate-envelope.test.ts b/packages/guardian/test/validate-envelope.test.ts new file mode 100644 index 00000000..fc3ff6f8 --- /dev/null +++ b/packages/guardian/test/validate-envelope.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "bun:test"; +import { EnvelopeValidationError, validateEnvelope } from "../src/validate-envelope.ts"; + +function makeEnvelope(overrides: { + method?: string; + toolName?: string; + args?: Record; + omitSessionId?: boolean; + omitToolName?: boolean; +} = {}): unknown { + const { + method = "steps/toolCallRequest", + toolName = "run_shell", + args = { command: { value: "ls -la", provenance: { provenance_id: "p1", origin: "user_input" } } }, + omitSessionId = false, + omitToolName = false, + } = overrides; + + const metadata: Record = { agent_id: "agent-1" }; + if (!omitSessionId) { + metadata.session_id = "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed"; + } + + const tool: Record = {}; + if (!omitToolName) { + tool.name = toolName; + } + + return { + jsonrpc: "2.0", + method, + id: 1, + params: { + acs_version: "0.1.0", + request_id: "8f14e45f-ceea-467e-bd5f-1d4d9a4e0c8f", + timestamp: "2026-08-09T12:00:00Z", + metadata, + payload: { + tool, + arguments: args, + }, + }, + }; +} + +describe("validateEnvelope", () => { + it("passes a well-formed steps/toolCallRequest envelope", () => { + const envelope = makeEnvelope(); + + const validated = validateEnvelope(envelope) as { params: { payload: { tool: { name: string } } } }; + + expect(validated.params.payload.tool.name).toBe("run_shell"); + }); + + it("throws EnvelopeValidationError naming /params/metadata/session_id when session_id is missing", () => { + const envelope = makeEnvelope({ omitSessionId: true }); + + expect(() => validateEnvelope(envelope)).toThrow(EnvelopeValidationError); + try { + validateEnvelope(envelope); + throw new Error("expected validateEnvelope to throw"); + } catch (error) { + expect(error).toBeInstanceOf(EnvelopeValidationError); + expect((error as EnvelopeValidationError).pointer).toBe("/params/metadata/session_id"); + expect((error as EnvelopeValidationError).message).toContain("/params/metadata/session_id"); + } + }); + + it("throws EnvelopeValidationError naming /params/payload/tool/name when the tool name is missing", () => { + const envelope = makeEnvelope({ omitToolName: true }); + + expect(() => validateEnvelope(envelope)).toThrow(EnvelopeValidationError); + try { + validateEnvelope(envelope); + throw new Error("expected validateEnvelope to throw"); + } catch (error) { + expect(error).toBeInstanceOf(EnvelopeValidationError); + expect((error as EnvelopeValidationError).pointer).toBe("/params/payload/tool/name"); + expect((error as EnvelopeValidationError).message).toContain("/params/payload/tool/name"); + } + }); + + it("throws EnvelopeValidationError naming /method when method does not match the required prefix pattern", () => { + const envelope = makeEnvelope({ method: "notARealPrefix/toolCallRequest" }); + + expect(() => validateEnvelope(envelope)).toThrow(EnvelopeValidationError); + try { + validateEnvelope(envelope); + throw new Error("expected validateEnvelope to throw"); + } catch (error) { + expect(error).toBeInstanceOf(EnvelopeValidationError); + expect((error as EnvelopeValidationError).pointer).toBe("/method"); + expect((error as EnvelopeValidationError).message).toContain("/method"); + } + }); + + // Scope boundary (N27 is V3, not this task): a rejection is a thrown + // error, never a returned {decision: "deny"} object. + it("throws rather than returning a decision object", () => { + const envelope = makeEnvelope({ omitSessionId: true }); + + let thrown: unknown; + let returned: unknown; + try { + returned = validateEnvelope(envelope); + } catch (error) { + thrown = error; + } + + expect(returned).toBeUndefined(); + expect(thrown).toBeInstanceOf(EnvelopeValidationError); + expect((thrown as Record).decision).toBeUndefined(); + }); +}); From 6fbba1d16420db2bc087591cca4dfcd42449b6c1 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 19:57:01 +0300 Subject: [PATCH 10/39] Add the Guardian JSON-RPC endpoint and handshake responder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startGuardian({ port, manifestPath }) serves POST /acs on bun's built-in HTTP server (Bun.serve). Dispatch is by the JSON-RPC `method` field, not URL path -- the spec mandates no path convention; /acs is this project's own. Binding port: 0 gets an ephemeral port so tests never collide; the returned url reports the actual bound port. The bridge (agt-bridge's createBridge, N31) and mapping.yaml are both built/loaded once at startGuardian() call time, not per request, per R6.1 and N31's "construct once at boot" contract. Two methods: - handshake/hello -> handshakeResponder() (N28, src/handshake.ts) returns a ServerHello with negotiated_version, methods_evaluated (hardcoded to ["steps/toolCallRequest"] -- the only intervention point V1 wires), selected_transport, timeout_config.default_ms (5000, a deployment choice -- handshake.json mandates no number), and on_decision_failure. D8: ships the spec default "proceed". V1 only negotiates and stores this value on the wire; applying the fail-open posture (N6/N7, the audit path) is V3 -- nothing here reads or acts on it. - steps/toolCallRequest -> validateEnvelope (N21) -> assembleSnapshot (N23) -> bridge.evaluate("pre_tool_call", snapshot) (N30) -> mapVerdict (N24) -> a response envelope whose result carries type: "final", acs_version and request_id echoed from the request, and the mapped decision. Error handling keeps the boundary the project is built around: an EnvelopeValidationError (schema failure) or a well-formed-but-undispatched method both return a bare JSON-RPC error, never {decision: "deny"} -- converting Guardian-side failures into explicit deny decisions is N27, which belongs to V3. Both cases stay inside the -32000..-32099 ACS- reserved range; since neither condition has a named code in the Specification §17.1 registry (-32000..-32007 cover SESSION_REFUSED, UNSUPPORTED_VERSION, etc., not "envelope invalid" or "method not dispatched"), this module mints two codes from the unused part of that band (-32010, -32011) rather than reaching for the generic JSON-RPC codes (-32602, -32601) that would sit outside it. Tests (packages/guardian/test/server.test.ts) run real HTTP round trips against a started server: handshake/hello's result validates against handshake.json's ServerHello $def compiled straight from the pinned schema file; steps/toolCallRequest with "rm -rf /" denies with non-empty reasoning and reason_codes and echoes request_id; "ls -la" allows; a well-formed steps/sessionStart (undispatched) and a toolCallRequest missing acs_version (schema-invalid) both return JSON-RPC errors in the reserved range with no result. beforeAll/afterAll start and close the server so the suite doesn't hang. index.ts becomes the package's public surface (re-exporting server.ts, handshake.ts, and the earlier tasks' modules); package.json's main/types now point there instead of src/map-verdict.ts. Slice: #2 Affordances: N20, N28 --- packages/guardian/package.json | 4 +- packages/guardian/src/handshake.ts | 45 ++++++++ packages/guardian/src/index.ts | 6 + packages/guardian/src/server.ts | 160 ++++++++++++++++++++++++++ packages/guardian/test/server.test.ts | 145 +++++++++++++++++++++++ 5 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 packages/guardian/src/handshake.ts create mode 100644 packages/guardian/src/index.ts create mode 100644 packages/guardian/src/server.ts create mode 100644 packages/guardian/test/server.test.ts diff --git a/packages/guardian/package.json b/packages/guardian/package.json index cf5697d2..6ecffc55 100644 --- a/packages/guardian/package.json +++ b/packages/guardian/package.json @@ -3,8 +3,8 @@ "private": true, "type": "module", "version": "0.1.0", - "main": "src/map-verdict.ts", - "types": "src/map-verdict.ts", + "main": "src/index.ts", + "types": "src/index.ts", "dependencies": { "agt-bridge": "workspace:*", "ajv": "^8.20.0", diff --git a/packages/guardian/src/handshake.ts b/packages/guardian/src/handshake.ts new file mode 100644 index 00000000..667479ca --- /dev/null +++ b/packages/guardian/src/handshake.ts @@ -0,0 +1,45 @@ +/** + * handshakeResponder builds the Guardian's ServerHello (N28) -- the + * response to `handshake/hello` -- per handshake.json's ServerHello $def + * (spec/acs/specification/v0.1.0/handshake.json). + * + * V1 scope: + * - `methods_evaluated` is exactly the set this Guardian actually wires + * up, per the V1 watch-for ("only pre_tool_call is wired"). + * - `on_decision_failure` ships the spec default, "proceed" (fail-open). + * D8: V1 only NEGOTIATES and STORES this value on the wire -- applying + * the posture (the fail-open audit path, N6/N7) is V3. Nothing here + * reads or acts on it beyond returning it. + */ + +export type ServerHello = { + negotiated_version: string; + methods_evaluated: string[]; + selected_transport: "http" | "https" | "stdio"; + timeout_config: { default_ms: number; per_method_ms?: Record }; + on_decision_failure: "proceed" | "deny"; +}; + +/** The ACS spec version every schema and mapping in this repo is pinned to. */ +const NEGOTIATED_VERSION = "0.1.0"; + +/** Only intervention point wired in V1 (mapping.yaml's pre_tool_call). */ +const METHODS_EVALUATED = ["steps/toolCallRequest"]; + +/** + * Deployment-chosen default; handshake.json's timeout_config.default_ms + * carries no spec-mandated number. 5s bounds worst-case added latency on a + * synchronous pre-tool-call decision without being so tight that a + * momentarily slow Guardian trips it. + */ +const DEFAULT_TIMEOUT_MS = 5000; + +export function handshakeResponder(): ServerHello { + return { + negotiated_version: NEGOTIATED_VERSION, + methods_evaluated: METHODS_EVALUATED, + selected_transport: "http", + timeout_config: { default_ms: DEFAULT_TIMEOUT_MS }, + on_decision_failure: "proceed", + }; +} diff --git a/packages/guardian/src/index.ts b/packages/guardian/src/index.ts new file mode 100644 index 00000000..d7d32287 --- /dev/null +++ b/packages/guardian/src/index.ts @@ -0,0 +1,6 @@ +/** Public surface of the guardian package. */ +export { startGuardian, type StartGuardianOptions, type StartedGuardian } from "./server.ts"; +export { handshakeResponder, type ServerHello } from "./handshake.ts"; +export { validateEnvelope, EnvelopeValidationError, type ToolCallRequestEnvelope } from "./validate-envelope.ts"; +export { assembleSnapshot } from "./assemble-snapshot.ts"; +export { loadMapping, mapVerdict, type Mapping, type AcsDecision } from "./map-verdict.ts"; diff --git a/packages/guardian/src/server.ts b/packages/guardian/src/server.ts new file mode 100644 index 00000000..c424092e --- /dev/null +++ b/packages/guardian/src/server.ts @@ -0,0 +1,160 @@ +/** + * startGuardian (N20) serves the ACS wire boundary: a single JSON-RPC 2.0 + * endpoint, POST /acs. The spec mandates no URL path convention -- "/acs" + * is this project's own, recorded as such in the slices doc -- so dispatch + * inside the handler is by the JSON-RPC `method` field, never by URL path. + * + * Composes every earlier task, in order, for `steps/toolCallRequest`: + * validateEnvelope (Task 5) -> assembleSnapshot (Task 4) -> + * bridge.evaluate("pre_tool_call", snapshot) (Task 2) -> + * mapVerdict(verdict, mapping) (Task 3) -> response envelope. + * + * The bridge (N31: AgentControl.fromPath) and the mapping table are both + * constructed/loaded exactly once, at startGuardian() call time -- not per + * request -- since AGT is meant to be built at boot and evaluated + * statelessly (R6.1). + */ +import { fileURLToPath } from "node:url"; +import { createBridge } from "agt-bridge"; +import { assembleSnapshot } from "./assemble-snapshot.ts"; +import { loadMapping, mapVerdict, type Mapping } from "./map-verdict.ts"; +import { EnvelopeValidationError, validateEnvelope, type ToolCallRequestEnvelope } from "./validate-envelope.ts"; +import { handshakeResponder } from "./handshake.ts"; + +const MAPPING_PATH = fileURLToPath(new URL("../../../mapping.yaml", import.meta.url)); + +const HANDSHAKE_METHOD = "handshake/hello"; +const TOOL_CALL_REQUEST_METHOD = "steps/toolCallRequest"; +const ACS_PATH = "/acs"; + +/** + * ACS reserves -32000..-32099 for application errors (Specification §17), + * but only enumerates named codes -32000..-32007 in the §17.1 registry + * (SESSION_REFUSED, UNSUPPORTED_VERSION, ...) -- none of which names "the + * envelope failed schema validation" or "this method isn't dispatched by + * this Guardian". Rather than reach for the generic JSON-RPC codes that + * would otherwise fit (-32602 Invalid params, -32601 Method not found), + * this module mints two codes from the unused part of the reserved band, + * keeping every application-level failure inside -32000..-32099 per this + * task's explicit instruction. + */ +const ENVELOPE_INVALID_CODE = -32010; +const METHOD_NOT_DISPATCHED_CODE = -32011; + +type JsonRpcSuccess = { jsonrpc: "2.0"; id: string | number; result: Record }; +type JsonRpcFailure = { + jsonrpc: "2.0"; + id: string | number | null; + error: { code: number; message: string; data?: unknown }; +}; + +export type StartGuardianOptions = { port: number; manifestPath: string }; +export type StartedGuardian = { url: string; close(): Promise }; + +export async function startGuardian({ port, manifestPath }: StartGuardianOptions): Promise { + // N31 -- construct the bridge once at boot, not per request. + const bridge = createBridge(manifestPath); + const mapping = loadMapping(MAPPING_PATH); + + const server = Bun.serve({ + port, + async fetch(req) { + const { pathname } = new URL(req.url); + if (req.method !== "POST" || pathname !== ACS_PATH) { + return new Response("Not Found", { status: 404 }); + } + const response = await handleAcsRequest(req, bridge, mapping); + return Response.json(response); + }, + }); + + return { + url: `http://localhost:${server.port}${ACS_PATH}`, + async close() { + await server.stop(true); + }, + }; +} + +async function handleAcsRequest( + req: Request, + bridge: ReturnType, + mapping: Mapping, +): Promise { + let raw: unknown; + try { + raw = await req.json(); + } catch { + return errorResponse(null, -32700, "Parse error"); + } + + const rpcId = extractId(raw); + + let envelope: ToolCallRequestEnvelope; + try { + // validateEnvelope (N21) checks the general request-envelope.json shape + // for every method, plus -- only for steps/toolCallRequest -- the + // hook-specific payload schema. Failure is a THROWN typed error, never + // a decision (N27, the deny-on-invalid-envelope affordance, is V3): we + // turn it into a bare JSON-RPC error below, not {decision: "deny"}. + envelope = validateEnvelope(raw); + } catch (error) { + if (error instanceof EnvelopeValidationError) { + return errorResponse(rpcId, ENVELOPE_INVALID_CODE, error.message, { pointer: error.pointer }); + } + throw error; + } + + if (envelope.method === HANDSHAKE_METHOD) { + return successResponse(envelope.id, handshakeResponder()); + } + + if (envelope.method === TOOL_CALL_REQUEST_METHOD) { + const snapshot = assembleSnapshot(envelope); + const { verdict } = await bridge.evaluate("pre_tool_call", snapshot); + const decision = mapVerdict(verdict, mapping); + + const result: Record = { + type: "final", + acs_version: envelope.params.acs_version, + request_id: envelope.params.request_id, + ...decision, + }; + return successResponse(envelope.id, result); + } + + // A well-formed envelope (it passed validateEnvelope: the method matched + // the required prefix pattern) naming a method this Guardian has no + // handler for -- distinct from a malformed envelope, which never reaches + // here because validateEnvelope already threw above. + return errorResponse(rpcId, METHOD_NOT_DISPATCHED_CODE, `method not dispatched by this Guardian: ${envelope.method}`, { + method: envelope.method, + }); +} + +function successResponse(id: string | number, result: Record): JsonRpcSuccess { + return { jsonrpc: "2.0", id, result }; +} + +function errorResponse( + id: string | number | null, + code: number, + message: string, + data?: unknown, +): JsonRpcFailure { + return { jsonrpc: "2.0", id, error: data === undefined ? { code, message } : { code, message, data } }; +} + +/** Best-effort extraction of the request's JSON-RPC id for error responses, + * per response-envelope.json: id MUST be null when it cannot be determined, + * otherwise it matches the request id. Used only on failure paths -- a + * successful response always uses the schema-validated envelope's id. */ +function extractId(raw: unknown): string | number | null { + if (typeof raw === "object" && raw !== null && "id" in raw) { + const id = (raw as { id: unknown }).id; + if (typeof id === "string" || typeof id === "number") { + return id; + } + } + return null; +} diff --git a/packages/guardian/test/server.test.ts b/packages/guardian/test/server.test.ts new file mode 100644 index 00000000..a94fd1ec --- /dev/null +++ b/packages/guardian/test/server.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, beforeAll, afterAll } from "bun:test"; +import { readFileSync } from "node:fs"; +import Ajv2020 from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import { startGuardian } from "../src/index.ts"; + +const HANDSHAKE_SCHEMA_PATH = "spec/acs/specification/v0.1.0/handshake.json"; + +/** Compiles the ServerHello $def straight out of the pinned handshake.json -- + * not a hand-copied shape -- so this test fails the moment our ServerHello + * drifts from the schema, per the task's "read the schema yourself" note. */ +function validateServerHello(candidate: unknown): void { + const handshakeSchema = JSON.parse(readFileSync(HANDSHAKE_SCHEMA_PATH, "utf8")) as { + $defs: { ServerHello: Record }; + }; + const ajv = new Ajv2020({ strict: true, allErrors: true }); + addFormats(ajv); + const validate = ajv.compile(handshakeSchema.$defs.ServerHello); + const valid = validate(candidate); + if (!valid) { + throw new Error(`ServerHello failed schema validation: ${JSON.stringify(validate.errors)}`); + } +} + +function makeEnvelope( + method: string, + payload: Record, + overrides: { id?: number; requestId?: string } = {}, +): Record { + const { id = 1, requestId = crypto.randomUUID() } = overrides; + return { + jsonrpc: "2.0", + method, + id, + params: { + acs_version: "0.1.0", + request_id: requestId, + timestamp: new Date().toISOString(), + metadata: { agent_id: "agent-1", session_id: crypto.randomUUID() }, + payload, + }, + }; +} + +function toolCallEnvelope(command: string, overrides: { id?: number; requestId?: string } = {}) { + return makeEnvelope( + "steps/toolCallRequest", + { tool: { name: "run_shell" }, arguments: { command: { value: command } } }, + overrides, + ); +} + +type JsonRpcResponse = { + jsonrpc: "2.0"; + id: string | number | null; + result?: Record; + error?: { code: number; message: string; data?: unknown }; +}; + +async function postAcs(url: string, body: unknown): Promise { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return (await res.json()) as JsonRpcResponse; +} + +let url: string; +let close: () => Promise; + +beforeAll(async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + url = guardian.url; + close = guardian.close; +}); + +afterAll(async () => { + await close(); +}); + +describe("startGuardian POST /acs", () => { + it("handshake/hello returns a schema-valid ServerHello with timeout_config.default_ms and on_decision_failure: proceed", async () => { + const response = await postAcs(url, makeEnvelope("handshake/hello", {}, { id: 42 })); + + expect(response.error).toBeUndefined(); + expect(response.id).toBe(42); + expect(response.result).toBeDefined(); + + const serverHello = response.result as Record; + validateServerHello(serverHello); + expect((serverHello.timeout_config as { default_ms: number }).default_ms).toBeGreaterThan(0); + expect(serverHello.on_decision_failure).toBe("proceed"); + }); + + it("steps/toolCallRequest carrying rm -rf / denies, with non-empty reasoning and reason_codes, echoing request_id", async () => { + const requestId = crypto.randomUUID(); + const response = await postAcs(url, toolCallEnvelope("rm -rf /", { requestId })); + + expect(response.error).toBeUndefined(); + expect(response.result?.decision).toBe("deny"); + expect(response.result?.request_id).toBe(requestId); + expect(typeof response.result?.reasoning).toBe("string"); + expect((response.result?.reasoning as string).length).toBeGreaterThan(0); + expect(Array.isArray(response.result?.reason_codes)).toBe(true); + expect((response.result?.reason_codes as unknown[]).length).toBeGreaterThan(0); + }); + + it("steps/toolCallRequest carrying ls -la allows, echoing request_id", async () => { + const requestId = crypto.randomUUID(); + const response = await postAcs(url, toolCallEnvelope("ls -la", { requestId })); + + expect(response.error).toBeUndefined(); + expect(response.result?.decision).toBe("allow"); + expect(response.result?.request_id).toBe(requestId); + }); + + it("an unknown method returns a JSON-RPC error in the ACS-reserved -32000..-32099 range, not a decision", async () => { + // Well-formed envelope (matches the method-prefix pattern, real ACS hook + // name) but not one this Guardian dispatches -- distinct from a + // malformed envelope, which fails schema validation instead. + const response = await postAcs(url, makeEnvelope("steps/sessionStart", {}, { id: 7 })); + + expect(response.result).toBeUndefined(); + expect(response.id).toBe(7); + expect(response.error).toBeDefined(); + expect(response.error?.code).toBeGreaterThanOrEqual(-32099); + expect(response.error?.code).toBeLessThanOrEqual(-32000); + }); + + // Scope boundary (N27 is V3, not this task): a Guardian-side validation + // failure must surface as a bare JSON-RPC error, never as an explicit ACS + // "deny" decision -- see validate-envelope.test.ts's identical guard. + it("an envelope that fails schema validation returns a JSON-RPC error in -32000..-32099, never a deny decision", async () => { + const bad = toolCallEnvelope("rm -rf /"); + delete (bad.params as Record).acs_version; + + const response = await postAcs(url, bad); + + expect(response.result).toBeUndefined(); + expect(response.error).toBeDefined(); + expect(response.error?.code).toBeGreaterThanOrEqual(-32099); + expect(response.error?.code).toBeLessThanOrEqual(-32000); + }); +}); From 6f8238985103f1925c3b6ad348353116e025d361 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 20:11:35 +0300 Subject: [PATCH 11/39] Add buildEnvelope (N2) and the Claude Code hookmap (S1) hosts/claude-code/claude-code.hookmap.yaml is the S1 artifact exactly per the brief's starting shape: a `hooks` block mapping Claude Code's PreToolUse onto steps/toolCallRequest plus the JSONPath-lite locations of the tool name and argument bag in the raw hook payload, and a `decisions` block (allow/deny/ask/defer/modify -> hookSpecificOutput) that Task 8's renderDecision will consume -- included so the file is complete, not exercised by this task's tests. packages/host-adapter/src/build-envelope.ts's buildEnvelope(event, payload, hookmap) is driven entirely by the hookmap: an event name absent from hookmap.hooks throws rather than defaulting or emitting a partial envelope, and `method` is read from hookmap.hooks[event].acs_method -- never hardcoded -- proven by a test that points acs_method at a different ACS method in a second hookmap and checks the output follows. tool_name and arguments are resolved via the hookmap's `$.foo` paths against the raw payload; each argument is wrapped as {value: ...} per ACS (tool_input.command -> arguments.command.value). request_id is a fresh uuid per call (node:crypto randomUUID); timestamp is ISO 8601. ACS's metadata.session_id is schema-typed "uuid", but Claude Code's own session_id is host-assigned free-form text with no uuid guarantee (the brief's example: "abc123"). toSessionUuid() carries an already-valid uuid through unchanged, and otherwise derives a stable RFC 4122 v5 uuid (namespace + SHA-1, node:crypto createHash) from the raw session_id -- deterministic, so the same host session always maps to the same wire session_id, rather than a random uuid being minted and silently discarding the host's real session identity. agent_id is read from hookmap.host ("claude-code"), so the one host-specific literal that must exist somewhere lives in the YAML, not in this function's code. R3.2: this package has zero runtime dependency on the Guardian or the policy-bridge package behind it -- host-adapter/package.json declares no such dependency at all. The test file imports guardian's own validateEnvelope (Task 5) to prove the produced envelope genuinely validates against the real v0.1.0 request-envelope.json + tool-call-request.json schemas, exactly as the brief's "strongly recommended test" describes; that import is devDependencies-only and confined to packages/host-adapter/test/, which Task 10's grep gate does not scan. Self-checked src/*.ts, test/*.ts, package.json, and the hookmap yaml against all six forbidden terms (agt, AgentControl, rego, opa, intervention_point, verdict) by hand ahead of Task 10 -- one violation found and fixed (a comment naming the sibling bridge package by its literal name) before this commit. 12 new tests in packages/host-adapter/test/build-envelope.test.ts; full suite (44 tests, 7 files) green. Slice: #2 Affordances: N2, S1 --- bun.lock | 9 + hosts/claude-code/claude-code.hookmap.yaml | 22 +++ packages/host-adapter/package.json | 11 ++ packages/host-adapter/src/build-envelope.ts | 174 ++++++++++++++++++ .../host-adapter/test/build-envelope.test.ts | 137 ++++++++++++++ 5 files changed, 353 insertions(+) create mode 100644 hosts/claude-code/claude-code.hookmap.yaml create mode 100644 packages/host-adapter/package.json create mode 100644 packages/host-adapter/src/build-envelope.ts create mode 100644 packages/host-adapter/test/build-envelope.test.ts diff --git a/bun.lock b/bun.lock index c951e6ef..e328ea2d 100644 --- a/bun.lock +++ b/bun.lock @@ -24,6 +24,13 @@ "ajv-formats": "^3.0.1", }, }, + "packages/host-adapter": { + "name": "host-adapter", + "version": "0.1.0", + "devDependencies": { + "guardian": "workspace:*", + }, + }, }, "packages": { "agent-control-specification": ["agent-control-specification@0.3.1-beta.0", "", { "optionalDependencies": { "agent-control-specification-darwin-arm64": "0.3.1-beta.0", "agent-control-specification-darwin-x64": "0.3.1-beta.0", "agent-control-specification-linux-arm64-gnu": "0.3.1-beta.0", "agent-control-specification-linux-x64-gnu": "0.3.1-beta.0", "agent-control-specification-opa-darwin-arm64": "0.3.1-beta.0", "agent-control-specification-opa-darwin-x64": "0.3.1-beta.0", "agent-control-specification-opa-linux-arm64": "0.3.1-beta.0", "agent-control-specification-opa-linux-x64": "0.3.1-beta.0", "agent-control-specification-opa-win32-x64": "0.3.1-beta.0", "agent-control-specification-win32-x64-msvc": "0.3.1-beta.0" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.100.0", "@langchain/core": ">=1.1.0", "@openai/agents": ">=0.11.0", "langchain": ">=1.0.0" }, "optionalPeers": ["@anthropic-ai/sdk", "@langchain/core", "@openai/agents", "langchain"] }, "sha512-vD1N169F2YhTCLbF4u7hEV5xfmAY1vziq4bHL2xhBa5QNOhdeEzrTdOIshWZw46fxxPJzEYDjQyjSmI2NnHCuQ=="], @@ -60,6 +67,8 @@ "guardian": ["guardian@workspace:packages/guardian"], + "host-adapter": ["host-adapter@workspace:packages/host-adapter"], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], diff --git a/hosts/claude-code/claude-code.hookmap.yaml b/hosts/claude-code/claude-code.hookmap.yaml new file mode 100644 index 00000000..dc2dda14 --- /dev/null +++ b/hosts/claude-code/claude-code.hookmap.yaml @@ -0,0 +1,22 @@ +# S1 -- the ONLY per-host artifact besides the hook shim (N1, Task 9). +# +# Pure data: maps Claude Code's own hook names and payload shape onto ACS +# methods/arguments (the `hooks` block, read by buildEnvelope / N2), and +# maps ACS decisions onto Claude Code's `hookSpecificOutput` shape (the +# `decisions` block, read by renderDecision / N3, Task 8). +# +# `tool_name` and `arguments` are JSONPath-lite references (`$.`) +# into the raw hook payload Claude Code delivers on stdin for this event -- +# see the real PreToolUse payload shape in the Task 7 brief. +host: claude-code +hooks: + PreToolUse: + acs_method: steps/toolCallRequest + tool_name: $.tool_name + arguments: $.tool_input +decisions: + allow: { permissionDecision: allow } + deny: { permissionDecision: deny, reason_from: reasoning } + ask: { permissionDecision: ask } + defer: { permissionDecision: defer } + modify: { permissionDecision: allow, updatedInput_from: modifications } diff --git a/packages/host-adapter/package.json b/packages/host-adapter/package.json new file mode 100644 index 00000000..ae4548a0 --- /dev/null +++ b/packages/host-adapter/package.json @@ -0,0 +1,11 @@ +{ + "name": "host-adapter", + "private": true, + "type": "module", + "version": "0.1.0", + "main": "src/build-envelope.ts", + "types": "src/build-envelope.ts", + "devDependencies": { + "guardian": "workspace:*" + } +} diff --git a/packages/host-adapter/src/build-envelope.ts b/packages/host-adapter/src/build-envelope.ts new file mode 100644 index 00000000..a0cbd818 --- /dev/null +++ b/packages/host-adapter/src/build-envelope.ts @@ -0,0 +1,174 @@ +/** + * buildEnvelope (N2) turns a host's own hook invocation into an ACS v0.1.0 + * request envelope (steps/* methods), driven entirely by a hookmap (S1) -- + * never by host-specific literals baked into this function. + * + * R3.2: this package knows ACS and hookmaps, nothing else. No file under + * packages/host-adapter/ may name a policy runtime, its rule language, or + * its decision vocabulary -- verified mechanically by Task 10's grep gate. + * This module has no runtime dependency on the Guardian package or the + * policy-bridge package that sits behind it; it talks to the Guardian + * over the wire (Task 8), never in-process. + */ +import { createHash, randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; + +/** One hook's mapping onto an ACS method: S1's `hooks.` entry. */ +export type HookmapHookEntry = { + /** The ACS `steps/*` method this hook fires. Never hardcoded here. */ + acs_method: string; + /** JSONPath-lite (`$.foo.bar`) into the raw hook payload for the tool/step name. */ + tool_name: string; + /** JSONPath-lite (`$.foo.bar`) into the raw hook payload for the argument bag. */ + arguments: string; +}; + +/** + * S1 in full: hook-name -> ACS method mapping (consumed here) plus + * decision -> host-output mapping (consumed by Task 8's renderDecision, + * not by this module -- present on the type only so a hookmap loaded + * whole, as `loadHookmap` does, round-trips without loss). + */ +export type Hookmap = { + host: string; + hooks: Record; + decisions?: Record; +}; + +/** An ACS argument wrapper: every hook argument is `{value, provenance?}`. */ +type AcsArgument = { value: unknown }; + +/** The ACS v0.1.0 request envelope this module produces. */ +export type AcsRequestEnvelope = { + jsonrpc: "2.0"; + method: string; + id: string; + params: { + acs_version: string; + request_id: string; + timestamp: string; + metadata: { + agent_id: string; + session_id: string; + }; + payload: { + tool: { name: string }; + arguments: Record; + }; + }; +}; + +const ACS_VERSION = "0.1.0"; + +/** Loads and parses a hookmap YAML file (e.g. S1's claude-code.hookmap.yaml). */ +export function loadHookmap(path: string): Hookmap { + return Bun.YAML.parse(readFileSync(path, "utf8")) as Hookmap; +} + +/** + * Resolves a JSONPath-lite reference (`$.foo.bar`, or `$` alone) against a + * raw hook payload. Only dotted field access is supported -- every S1 + * path in this slice is a single top-level field, and nothing here needs + * array indexing or filters. + */ +function resolvePath(payload: Record, path: string): unknown { + const segments = path.replace(/^\$\.?/, "").split(".").filter(Boolean); + let current: unknown = payload; + for (const segment of segments) { + if (current === null || typeof current !== "object") { + return undefined; + } + current = (current as Record)[segment]; + } + return current; +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +// Fixed, arbitrary namespace UUID used only to derive a stable v5 UUID from +// a host session id that is not itself a UUID -- Claude Code's session_id +// is host-assigned free-form text with no uuid guarantee, but ACS's +// metadata.session_id schema field requires format "uuid". Never +// regenerate this constant: doing so changes every derived session_id. +const SESSION_ID_NAMESPACE = "11c79ebd-8469-4b4e-8de0-72674add484c"; + +/** + * Maps a host's raw session id onto a schema-valid uuid. If the host + * already hands us a uuid, it is carried through unchanged (lowercased). + * Otherwise a RFC 4122 version-5 (namespace + SHA-1) uuid is derived + * deterministically -- the same raw session id always derives the same + * uuid, so per-session correlation on the Guardian side survives the + * translation honestly, rather than a random uuid being minted and + * silently discarding the host's real session identity. + */ +export function toSessionUuid(rawSessionId: string): string { + if (UUID_RE.test(rawSessionId)) { + return rawSessionId.toLowerCase(); + } + + const namespaceBytes = Buffer.from(SESSION_ID_NAMESPACE.replace(/-/g, ""), "hex"); + const hash = createHash("sha1") + .update(Buffer.concat([namespaceBytes, Buffer.from(rawSessionId, "utf8")])) + .digest(); + + hash[6] = (hash[6]! & 0x0f) | 0x50; // version 5 + hash[8] = (hash[8]! & 0x3f) | 0x80; // RFC 4122 variant + + const hex = hash.subarray(0, 16).toString("hex"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + +/** + * Builds an ACS v0.1.0 request envelope from a raw hook invocation. + * `hookmap` drives every host-shape decision: the ACS method, and where + * the tool/step name and argument bag live in the raw payload. An event + * name absent from `hookmap.hooks` throws -- there is no default method + * and no partial envelope. + */ +export function buildEnvelope(event: string, payload: Record, hookmap: Hookmap): AcsRequestEnvelope { + const entry = hookmap.hooks[event]; + if (!entry) { + throw new Error(`buildEnvelope: hookmap has no entry for hook "${event}"`); + } + + const toolName = resolvePath(payload, entry.tool_name); + if (typeof toolName !== "string") { + throw new Error( + `buildEnvelope: hookmap path "${entry.tool_name}" for hook "${event}" did not resolve to a string`, + ); + } + + const rawArguments = resolvePath(payload, entry.arguments); + const args: Record = {}; + if (rawArguments !== null && typeof rawArguments === "object") { + for (const [key, value] of Object.entries(rawArguments as Record)) { + args[key] = { value }; + } + } + + const rawSessionId = payload.session_id; + if (typeof rawSessionId !== "string" || rawSessionId.length === 0) { + throw new Error(`buildEnvelope: hook payload for "${event}" is missing a "session_id" string field`); + } + + const requestId = randomUUID(); + + return { + jsonrpc: "2.0", + method: entry.acs_method, + id: requestId, + params: { + acs_version: ACS_VERSION, + request_id: requestId, + timestamp: new Date().toISOString(), + metadata: { + agent_id: hookmap.host, + session_id: toSessionUuid(rawSessionId), + }, + payload: { + tool: { name: toolName }, + arguments: args, + }, + }, + }; +} diff --git a/packages/host-adapter/test/build-envelope.test.ts b/packages/host-adapter/test/build-envelope.test.ts new file mode 100644 index 00000000..5bd94da9 --- /dev/null +++ b/packages/host-adapter/test/build-envelope.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "bun:test"; +import { validateEnvelope } from "guardian"; +import { buildEnvelope, loadHookmap, toSessionUuid, type Hookmap } from "../src/build-envelope.ts"; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +// The real PreToolUse payload shape Claude Code delivers on stdin, per the +// Task 7 brief -- not a sketch. +const preToolUsePayload = { + session_id: "abc123", + transcript_path: "/path/to/transcript.jsonl", + cwd: "/current/dir", + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command: "rm -rf /", description: "clean up" }, +}; + +const hookmap: Hookmap = { + host: "claude-code", + hooks: { + PreToolUse: { + acs_method: "steps/toolCallRequest", + tool_name: "$.tool_name", + arguments: "$.tool_input", + }, + }, + decisions: { + allow: { permissionDecision: "allow" }, + deny: { permissionDecision: "deny", reason_from: "reasoning" }, + ask: { permissionDecision: "ask" }, + defer: { permissionDecision: "defer" }, + modify: { permissionDecision: "allow", updatedInput_from: "modifications" }, + }, +}; + +describe("buildEnvelope", () => { + it("produces an envelope that validates against the real ACS v0.1.0 request-envelope + tool-call-request schemas", () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload, hookmap); + + // guardian's own validateEnvelope (Task 5) -- imported here, in the + // TEST file only, so the runtime adapter stays dependency-free while + // this proves the two sides genuinely agree on the wire format. + expect(() => validateEnvelope(envelope)).not.toThrow(); + }); + + it("takes method from the hookmap's acs_method, never hardcoded", () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload, hookmap); + + expect(envelope.method).toBe("steps/toolCallRequest"); + }); + + it("wraps each tool_input argument as {value: ...} per ACS", () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload, hookmap); + + expect(envelope.params.payload.arguments.command).toEqual({ value: "rm -rf /" }); + expect(envelope.params.payload.arguments.description).toEqual({ value: "clean up" }); + }); + + it("carries tool_name into params.payload.tool.name", () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload, hookmap); + + expect(envelope.params.payload.tool.name).toBe("Bash"); + }); + + it("assigns a fresh uuid request_id per call", () => { + const first = buildEnvelope("PreToolUse", preToolUsePayload, hookmap); + const second = buildEnvelope("PreToolUse", preToolUsePayload, hookmap); + + expect(first.params.request_id).toMatch(UUID_RE); + expect(second.params.request_id).toMatch(UUID_RE); + expect(first.params.request_id).not.toBe(second.params.request_id); + }); + + it("stamps an ISO 8601 timestamp", () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload, hookmap); + + expect(new Date(envelope.params.timestamp).toISOString()).not.toBe("Invalid Date"); + expect(envelope.params.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + }); + + it("carries the host's session_id, derived deterministically into a uuid (session_id must be a uuid on the wire)", () => { + const first = buildEnvelope("PreToolUse", preToolUsePayload, hookmap); + const second = buildEnvelope("PreToolUse", preToolUsePayload, hookmap); + + expect(first.params.metadata.session_id).toMatch(UUID_RE); + // Deterministic: the same host session_id always derives the same uuid, + // even though request_id (above) is fresh every call. + expect(first.params.metadata.session_id).toBe(second.params.metadata.session_id); + }); + + it("preserves an already-valid uuid session_id rather than re-deriving it", () => { + const realUuid = "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed"; + const payload = { ...preToolUsePayload, session_id: realUuid }; + + const envelope = buildEnvelope("PreToolUse", payload, hookmap); + + expect(envelope.params.metadata.session_id).toBe(realUuid); + }); + + it("toSessionUuid is deterministic and produces a valid uuid for a non-uuid input", () => { + expect(toSessionUuid("abc123")).toMatch(UUID_RE); + expect(toSessionUuid("abc123")).toBe(toSessionUuid("abc123")); + expect(toSessionUuid("abc123")).not.toBe(toSessionUuid("xyz789")); + }); + + it("throws on an unmapped hook name, rather than defaulting or producing a partial envelope", () => { + expect(() => buildEnvelope("PostToolUse", preToolUsePayload, hookmap)).toThrow(); + }); + + it("is hookmap-driven: changing the hookmap's acs_method changes the envelope's method", () => { + const alternateHookmap: Hookmap = { + host: "claude-code", + hooks: { + PreToolUse: { + acs_method: "steps/toolCallResult", + tool_name: "$.tool_name", + arguments: "$.tool_input", + }, + }, + }; + + const original = buildEnvelope("PreToolUse", preToolUsePayload, hookmap); + const changed = buildEnvelope("PreToolUse", preToolUsePayload, alternateHookmap); + + expect(original.method).toBe("steps/toolCallRequest"); + expect(changed.method).toBe("steps/toolCallResult"); + }); + + it("loadHookmap parses the real claude-code.hookmap.yaml and buildEnvelope drives off it end to end", () => { + const parsed = loadHookmap("hosts/claude-code/claude-code.hookmap.yaml"); + + const envelope = buildEnvelope("PreToolUse", preToolUsePayload, parsed); + + expect(envelope.method).toBe("steps/toolCallRequest"); + expect(() => validateEnvelope(envelope)).not.toThrow(); + }); +}); From 603882ba666f184eed7b1ee97e0a779a69769cba Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 20:30:44 +0300 Subject: [PATCH 12/39] Add guardianClient.post, handshake, renderDecision, session config store (N3, N4, N5, S13) renderDecision reads Claude Code's hookSpecificOutput shape entirely off the hookmap's decisions block (S1) -- no hardcoded decision dispatch. A deny carries the ACS reasoning into permissionDecisionReason; an allow-with-policy_references (the warn-derived allow, R1.2) still renders as a plain allow. guardianClient.post sends JSON-RPC 2.0 and correlates by id -- confirmed buildEnvelope's id == params.request_id round-trips fine, since fetch already pairs one HTTP request to one HTTP response. handshake (N5) sends handshake/hello and stores the ServerHello into a session config store (S13). V1 scope: stores only -- applying the posture (timeout fallback, fail-open audit) is N6/N7, slice V3. Also fixes policy/manifest.yaml (S7): registers Claude Code's real tool name ("Bash") alongside the existing "run_shell" fixture entry. Found by this task's own real host -> wire -> policy -> host test: an unregistered tool_call.name fails AGT's evaluation closed with a generic runtime_error:tool_unknown deny before the destructive-command pattern check ever runs, which would have silently broken Task 9's demo. Slice: #2 Affordances: N3, N4, N5, S13 --- packages/host-adapter/src/guardian-client.ts | 78 +++++++++ packages/host-adapter/src/handshake.ts | 71 +++++++++ packages/host-adapter/src/render-decision.ts | 87 ++++++++++ packages/host-adapter/src/session-config.ts | 45 ++++++ packages/host-adapter/test/client.test.ts | 148 ++++++++++++++++++ .../host-adapter/test/render-decision.test.ts | 127 +++++++++++++++ policy/manifest.yaml | 14 ++ 7 files changed, 570 insertions(+) create mode 100644 packages/host-adapter/src/guardian-client.ts create mode 100644 packages/host-adapter/src/handshake.ts create mode 100644 packages/host-adapter/src/render-decision.ts create mode 100644 packages/host-adapter/src/session-config.ts create mode 100644 packages/host-adapter/test/client.test.ts create mode 100644 packages/host-adapter/test/render-decision.test.ts diff --git a/packages/host-adapter/src/guardian-client.ts b/packages/host-adapter/src/guardian-client.ts new file mode 100644 index 00000000..52d2bfc4 --- /dev/null +++ b/packages/host-adapter/src/guardian-client.ts @@ -0,0 +1,78 @@ +/** + * guardianClient.post (N4) is the adapter's one wire primitive: POST a + * JSON-RPC 2.0 envelope to the Guardian's ACS endpoint and return the + * parsed JSON-RPC response. + * + * Correlation note (Task 7 -> Task 8): buildEnvelope sets the envelope's + * top-level `id` equal to `params.request_id` (a fresh uuid per call). + * That is sufficient here: `id` is schema-legal as a string (per + * request-envelope.json, `id` is `oneOf` string/number, with no format + * constraint of its own), and since `fetch` already pairs this exact HTTP + * request with this exact HTTP response one-to-one, there is no + * multiplexing problem to solve. The id check below is a defensive + * assertion -- it catches a Guardian bug that echoes back the wrong id -- + * not a lookup table matching concurrent in-flight requests. Nothing about + * Task 7's choice needs to change. + * + * R3.2: this module knows JSON-RPC and HTTP, nothing else. It has no + * runtime dependency on the Guardian or policy-bridge packages -- it talks + * to the Guardian only over the wire, at whatever `url` the caller gives. + */ + +/** The minimal JSON-RPC 2.0 request shape this client sends. Loose on + * `params` on purpose: buildEnvelope's ACS request params and handshake's + * ClientHello params are different shapes, and this module doesn't need + * to know either -- it moves whatever `params` object it's given. */ +export type JsonRpcRequest = { + jsonrpc: "2.0"; + method: string; + id: string | number; + params: Record; +}; + +export type JsonRpcSuccessResponse = { + jsonrpc: "2.0"; + id: string | number | null; + result: Record; + error?: undefined; +}; + +export type JsonRpcErrorResponse = { + jsonrpc: "2.0"; + id: string | number | null; + result?: undefined; + error: { code: number; message: string; data?: unknown }; +}; + +export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse; + +/** Thrown when the Guardian's response id doesn't match the request's -- + * i.e. the response cannot be trusted to belong to this request. */ +export class GuardianResponseMismatchError extends Error { + constructor(requestId: string | number, responseId: string | number | null) { + super( + `guardianClient.post: response id ${JSON.stringify(responseId)} does not correlate with request id ${JSON.stringify(requestId)}`, + ); + this.name = "GuardianResponseMismatchError"; + } +} + +export const guardianClient = { + /** POSTs `envelope` to `url` as JSON, parses the JSON-RPC response, and + * returns it once its `id` is confirmed to match the request's `id`. */ + async post(url: string, envelope: JsonRpcRequest): Promise { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(envelope), + }); + + const response = (await res.json()) as JsonRpcResponse; + + if (response.id !== envelope.id) { + throw new GuardianResponseMismatchError(envelope.id, response.id); + } + + return response; + }, +}; diff --git a/packages/host-adapter/src/handshake.ts b/packages/host-adapter/src/handshake.ts new file mode 100644 index 00000000..2610f971 --- /dev/null +++ b/packages/host-adapter/src/handshake.ts @@ -0,0 +1,71 @@ +/** + * handshake (N5) sends `handshake/hello` to the Guardian and stores the + * returned ServerHello into a session config store (S13). + * + * V1 SCOPE -- stores only. Applying the negotiated posture (falling back + * to `timeout_config` when the Guardian is slow or silent; recording a + * fail-open audit event per `on_decision_failure: "proceed"`) is N6/N7, + * and belongs to slice V3. That boundary is deliberate: this project keeps + * a policy runtime's own evaluation-layer fail-closed behaviour distinct + * from wire-delivery failure, and V3 is where the wire-delivery half + * lands. This module does not retry, does not time out, and does not + * write to an audit sink -- it sends one request, stores one response. + * + * R3.2: this module knows ACS handshake vocabulary and JSON-RPC, nothing + * else. It has no runtime dependency on the Guardian package -- it talks + * to the Guardian only through guardianClient.post, over the wire. + */ +import { randomUUID } from "node:crypto"; +import { guardianClient, type JsonRpcRequest } from "./guardian-client.ts"; +import type { SessionConfig, SessionConfigStore } from "./session-config.ts"; + +const HANDSHAKE_METHOD = "handshake/hello"; +const ACS_VERSION = "0.1.0"; + +export type HandshakeOptions = { + /** The Guardian's ACS endpoint, as returned by startGuardian (test-only) or configured for the deployment. */ + url: string; + /** This Observed Agent's identity on the wire (ACS metadata.agent_id). */ + agentId: string; + /** This session's identity on the wire (ACS metadata.session_id, a uuid). */ + sessionId: string; +}; + +/** + * Sends `handshake/hello`, waits for the Guardian's ServerHello, stores it + * into `store`, and returns it. Throws if the Guardian responds with a + * JSON-RPC error rather than a result. + */ +export async function handshake(options: HandshakeOptions, store: SessionConfigStore): Promise { + const requestId = randomUUID(); + + const envelope: JsonRpcRequest = { + jsonrpc: "2.0", + method: HANDSHAKE_METHOD, + id: requestId, + params: { + acs_version: ACS_VERSION, + request_id: requestId, + timestamp: new Date().toISOString(), + metadata: { agent_id: options.agentId, session_id: options.sessionId }, + // ClientHello shape (handshake.json's $defs.ClientHello). Not schema- + // enforced on this method by the Guardian's own validateEnvelope + // (Task 5) today, but supplied honestly rather than left empty. + payload: { + acs_versions_supported: [ACS_VERSION], + methods_implemented: ["steps/toolCallRequest"], + transports_supported: ["http"], + provenance_producer: "none", + }, + }, + }; + + const response = await guardianClient.post(options.url, envelope); + if (response.error) { + throw new Error(`handshake: Guardian rejected handshake/hello: ${response.error.message}`); + } + + const serverHello = response.result as unknown as SessionConfig; + store.set(serverHello); + return serverHello; +} diff --git a/packages/host-adapter/src/render-decision.ts b/packages/host-adapter/src/render-decision.ts new file mode 100644 index 00000000..66788e88 --- /dev/null +++ b/packages/host-adapter/src/render-decision.ts @@ -0,0 +1,87 @@ +/** + * renderDecision (N3) turns an ACS decision result into Claude Code's + * `hookSpecificOutput`, driven entirely by the hookmap's `decisions` block + * (S1) -- never by a hardcoded decision -> output dispatch table in this + * module. Which host output field a decision maps to, and which decision + * field (if any) feeds a human-readable reason or an updated-input payload, + * are both read off `hookmap.decisions[decision]`; changing the YAML + * changes the render with no code change here. + * + * The single load-bearing behaviour: a `deny` decision's `reasoning` + * string must land in `permissionDecisionReason` -- that's what a human + * reads in the Claude Code transcript, and it's the entire payoff of the + * slice's demo. This module gets there generically: `deny`'s hookmap entry + * names `reason_from: reasoning`, and renderDecision copies whatever field + * that names -- it never assumes "reasoning" is the field in code. + * + * R3.2: this module knows ACS decisions and Claude Code's hookSpecificOutput + * shape, nothing else. No policy-runtime vocabulary appears here -- an + * observe-only upstream signal has already become an ACS `allow` (with + * policy_references) by the time a decision result reaches this module, + * and it renders as a plain allow like any other, per R1.2: there is no + * separate rendering path for it to invent. + */ +import type { Hookmap } from "./build-envelope.ts"; + +/** One decision's hookmap-declared rendering rule (S1's `decisions.` entry). */ +type DecisionRenderRule = { + permissionDecision: string; + reason_from?: string; + updatedInput_from?: string; +}; + +/** + * The ACS decision-result shape renderDecision reads from. Deliberately + * loose: only `decision` is required, because everything else this module + * touches is named by the hookmap (`reason_from`, `updatedInput_from`), not + * assumed to exist under a fixed key. + */ +export type AcsDecisionResult = { decision: string } & Record; + +export type HookSpecificOutput = { hookEventName: string; permissionDecision: string } & Record; + +/** + * Renders `decisionResult` per `hookmap.decisions[decisionResult.decision]`. + * `hookEventName` is the raw host hook name that produced the original + * request (e.g. "PreToolUse", the same string passed to buildEnvelope), + * carried through unchanged into the shape Claude Code expects. + * + * Throws if the hookmap has no `decisions` block, or no entry for this + * decision -- there is no default rendering and no partial output. + */ +export function renderDecision( + hookEventName: string, + decisionResult: AcsDecisionResult, + hookmap: Hookmap, +): { hookSpecificOutput: HookSpecificOutput } { + const rules = hookmap.decisions as Record | undefined; + if (!rules) { + throw new Error("renderDecision: hookmap has no decisions block"); + } + + const rule = rules[decisionResult.decision]; + if (!rule) { + throw new Error(`renderDecision: hookmap has no decisions entry for ACS decision "${decisionResult.decision}"`); + } + + const hookSpecificOutput: HookSpecificOutput = { + hookEventName, + permissionDecision: rule.permissionDecision, + }; + + if (rule.reason_from) { + const reason = decisionResult[rule.reason_from]; + if (typeof reason === "string") { + hookSpecificOutput.permissionDecisionReason = reason; + } + } + + if (rule.updatedInput_from) { + const updatedInput = decisionResult[rule.updatedInput_from]; + if (updatedInput !== undefined) { + hookSpecificOutput.updatedInput = updatedInput; + } + } + + return { hookSpecificOutput }; +} diff --git a/packages/host-adapter/src/session-config.ts b/packages/host-adapter/src/session-config.ts new file mode 100644 index 00000000..b792a4d4 --- /dev/null +++ b/packages/host-adapter/src/session-config.ts @@ -0,0 +1,45 @@ +/** + * The negotiated session config store (S13): where handshake() (N5) writes + * the Guardian's ServerHello once a session's capability negotiation + * completes. + * + * V1 SCOPE: storage only. Nothing in this module reads or acts on the + * stored config -- applying it (falling back to timeout_config when the + * Guardian is silent, honoring on_decision_failure's fail-open/fail-closed + * posture) is N6/N7, which belongs to slice V3. + * + * A factory rather than a module-level singleton, matching this package's + * existing style (loadHookmap, buildEnvelope take every dependency as an + * argument; nothing here relies on hidden shared state) and so a test can + * hold its own store without one test's handshake bleeding into another's + * assertions. + */ + +/** The fields of the Guardian's ServerHello this store holds. Loose on + * purpose: a later slice's negotiation may return fields this slice never + * names -- `Record` lets those round-trip through the + * store untouched rather than being silently dropped. */ +export type SessionConfig = { + timeout_config: { default_ms: number; per_method_ms?: Record }; + on_decision_failure: "proceed" | "deny"; +} & Record; + +export type SessionConfigStore = { + /** The most recently stored config, or undefined before any handshake completes. */ + get(): SessionConfig | undefined; + /** Overwrites the stored config. Called by handshake() (N5) with the Guardian's ServerHello. */ + set(config: SessionConfig): void; +}; + +/** Creates a fresh, empty session config store. */ +export function createSessionConfigStore(): SessionConfigStore { + let current: SessionConfig | undefined; + return { + get(): SessionConfig | undefined { + return current; + }, + set(config: SessionConfig): void { + current = config; + }, + }; +} diff --git a/packages/host-adapter/test/client.test.ts b/packages/host-adapter/test/client.test.ts new file mode 100644 index 00000000..b1ad3f91 --- /dev/null +++ b/packages/host-adapter/test/client.test.ts @@ -0,0 +1,148 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +// Test-only import: stands up a real Guardian so these tests prove the +// wire contract for real, not against a hand-copied shape. Never imported +// by packages/host-adapter/src (R3.2) -- see build-envelope.test.ts for +// the precedent (Task 7). +import { startGuardian, type StartedGuardian } from "guardian"; +import { buildEnvelope, loadHookmap, type Hookmap } from "../src/build-envelope.ts"; +import { guardianClient } from "../src/guardian-client.ts"; +import { handshake } from "../src/handshake.ts"; +import { renderDecision } from "../src/render-decision.ts"; +import { createSessionConfigStore } from "../src/session-config.ts"; + +const hookmap: Hookmap = loadHookmap("hosts/claude-code/claude-code.hookmap.yaml"); + +function preToolUsePayload(command: string): Record { + return { + session_id: "abc123", + transcript_path: "/path/to/transcript.jsonl", + cwd: "/current/dir", + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command }, + }; +} + +let guardian: StartedGuardian; + +beforeAll(async () => { + guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); +}); + +afterAll(async () => { + await guardian.close(); +}); + +describe("guardianClient.post", () => { + it("posts the envelope as well-formed JSON-RPC 2.0 (jsonrpc, method, id, params) over HTTP", async () => { + let capturedBody: Record | undefined; + let capturedContentType: string | null = null; + const mock = Bun.serve({ + port: 0, + async fetch(req) { + capturedContentType = req.headers.get("content-type"); + capturedBody = (await req.json()) as Record; + return Response.json({ jsonrpc: "2.0", id: (capturedBody as { id: unknown }).id, result: { decision: "allow" } }); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + const response = await guardianClient.post(`http://localhost:${mock.port}/acs`, envelope); + + expect(capturedBody).toEqual(envelope as unknown as Record); + expect(capturedContentType).toContain("application/json"); + expect(response.result?.decision).toBe("allow"); + } finally { + mock.stop(true); + } + }); + + it("correlates the response to the request by JSON-RPC id -- buildEnvelope's id equals params.request_id, and that round-trips fine", async () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + + const response = await guardianClient.post(guardian.url, envelope); + + expect(response.id).toBe(envelope.id); + expect(envelope.id).toBe(envelope.params.request_id); + }); + + it("throws when the response id does not correlate with the request id", async () => { + const mock = Bun.serve({ + port: 0, + fetch() { + return Response.json({ jsonrpc: "2.0", id: "not-the-request-id", result: { decision: "allow" } }); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + await expect(guardianClient.post(`http://localhost:${mock.port}/acs`, envelope)).rejects.toThrow(); + } finally { + mock.stop(true); + } + }); + + it("against a real Guardian: steps/toolCallRequest for ls -la allows, echoing the request id", async () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + + const response = await guardianClient.post(guardian.url, envelope); + + expect(response.error).toBeUndefined(); + expect(response.result?.decision).toBe("allow"); + }); +}); + +describe("handshake (N5)", () => { + it("sends handshake/hello and stores timeout_config and on_decision_failure into the session config store (S13)", async () => { + const store = createSessionConfigStore(); + expect(store.get()).toBeUndefined(); + + const serverHello = await handshake( + { url: guardian.url, agentId: "claude-code", sessionId: crypto.randomUUID() }, + store, + ); + + expect(serverHello.on_decision_failure).toBe("proceed"); + expect(serverHello.timeout_config.default_ms).toBeGreaterThan(0); + + const stored = store.get(); + expect(stored).toBeDefined(); + expect(stored?.timeout_config).toEqual(serverHello.timeout_config); + expect(stored?.on_decision_failure).toBe("proceed"); + }); +}); + +describe("host -> wire -> policy -> host, end to end", () => { + it("a real rm -rf / tool call denies through the real Guardian, and renders with the reasoning in permissionDecisionReason", async () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap); + + const response = await guardianClient.post(guardian.url, envelope); + expect(response.error).toBeUndefined(); + + const { hookSpecificOutput } = renderDecision( + "PreToolUse", + response.result as { decision: string } & Record, + hookmap, + ); + + expect(hookSpecificOutput.permissionDecision).toBe("deny"); + expect(typeof hookSpecificOutput.permissionDecisionReason).toBe("string"); + expect((hookSpecificOutput.permissionDecisionReason as string).length).toBeGreaterThan(0); + }); + + it("a real ls -la tool call allows through the real Guardian, and renders as a plain allow", async () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + + const response = await guardianClient.post(guardian.url, envelope); + expect(response.error).toBeUndefined(); + + const { hookSpecificOutput } = renderDecision( + "PreToolUse", + response.result as { decision: string } & Record, + hookmap, + ); + + expect(hookSpecificOutput).toEqual({ hookEventName: "PreToolUse", permissionDecision: "allow" }); + }); +}); diff --git a/packages/host-adapter/test/render-decision.test.ts b/packages/host-adapter/test/render-decision.test.ts new file mode 100644 index 00000000..8ea09253 --- /dev/null +++ b/packages/host-adapter/test/render-decision.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "bun:test"; +import { loadHookmap, type Hookmap } from "../src/build-envelope.ts"; +import { renderDecision } from "../src/render-decision.ts"; + +// Same decisions block as S1's real hosts/claude-code/claude-code.hookmap.yaml +// (Task 7's own fixture) -- kept local so these tests don't depend on the +// real file staying byte-identical, except for the one test below that +// deliberately does load the real file. +const hookmap: Hookmap = { + host: "claude-code", + hooks: { + PreToolUse: { + acs_method: "steps/toolCallRequest", + tool_name: "$.tool_name", + arguments: "$.tool_input", + }, + }, + decisions: { + allow: { permissionDecision: "allow" }, + deny: { permissionDecision: "deny", reason_from: "reasoning" }, + ask: { permissionDecision: "ask" }, + defer: { permissionDecision: "defer" }, + modify: { permissionDecision: "allow", updatedInput_from: "modifications" }, + }, +}; + +describe("renderDecision", () => { + it("renders a deny with the ACS reasoning carried into permissionDecisionReason -- the demo's payoff", () => { + const { hookSpecificOutput } = renderDecision( + "PreToolUse", + { decision: "deny", reasoning: "blocked: destructive command matched a stock rule" }, + hookmap, + ); + + expect(hookSpecificOutput).toEqual({ + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "blocked: destructive command matched a stock rule", + }); + }); + + it("renders an allow with permissionDecision allow and no reason", () => { + const { hookSpecificOutput } = renderDecision("PreToolUse", { decision: "allow" }, hookmap); + + expect(hookSpecificOutput).toEqual({ hookEventName: "PreToolUse", permissionDecision: "allow" }); + }); + + it("a warn-derived allow (allow + non-empty policy_references) still renders as a plain allow -- no separate rendering (R1.2)", () => { + const { hookSpecificOutput } = renderDecision( + "PreToolUse", + { + decision: "allow", + policy_references: [{ policy_id: "stock_policy_bundle", rule_id: "drift_detected" }], + }, + hookmap, + ); + + expect(hookSpecificOutput).toEqual({ hookEventName: "PreToolUse", permissionDecision: "allow" }); + }); + + it("renders modify with updatedInput carried from the hookmap-named field", () => { + const { hookSpecificOutput } = renderDecision( + "PreToolUse", + { + decision: "modify", + reasoning: "redacted a secret", + modifications: { arguments: { command: { value: "echo [REDACTED]" } } }, + }, + hookmap, + ); + + expect(hookSpecificOutput.permissionDecision).toBe("allow"); + expect(hookSpecificOutput.updatedInput).toEqual({ arguments: { command: { value: "echo [REDACTED]" } } }); + }); + + it("is hookmap-driven: mutating decisions.allow changes the rendered output with no code change", () => { + const mutated: Hookmap = { + ...hookmap, + decisions: { ...hookmap.decisions, allow: { permissionDecision: "ask" } }, + }; + + const original = renderDecision("PreToolUse", { decision: "allow" }, hookmap); + const changed = renderDecision("PreToolUse", { decision: "allow" }, mutated); + + expect(original.hookSpecificOutput.permissionDecision).toBe("allow"); + expect(changed.hookSpecificOutput.permissionDecision).toBe("ask"); + }); + + it("is hookmap-driven: mutating deny's reason_from changes which field feeds permissionDecisionReason", () => { + const mutated: Hookmap = { + ...hookmap, + decisions: { ...hookmap.decisions, deny: { permissionDecision: "deny", reason_from: "reason_codes" } }, + }; + + const { hookSpecificOutput } = renderDecision( + "PreToolUse", + { decision: "deny", reasoning: "human text", reason_codes: "machine_code" }, + mutated, + ); + + expect(hookSpecificOutput.permissionDecisionReason).toBe("machine_code"); + }); + + it("throws on an ACS decision absent from the hookmap's decisions block, rather than defaulting", () => { + const noDeny: Hookmap = { ...hookmap, decisions: { allow: hookmap.decisions!.allow! } }; + + expect(() => renderDecision("PreToolUse", { decision: "deny", reasoning: "x" }, noDeny)).toThrow(); + }); + + it("throws when the hookmap has no decisions block at all", () => { + const noDecisions: Hookmap = { host: "claude-code", hooks: hookmap.hooks }; + + expect(() => renderDecision("PreToolUse", { decision: "allow" }, noDecisions)).toThrow(); + }); + + it("loads the real claude-code.hookmap.yaml and renders a deny end to end", () => { + const real = loadHookmap("hosts/claude-code/claude-code.hookmap.yaml"); + + const { hookSpecificOutput } = renderDecision("PreToolUse", { decision: "deny", reasoning: "blocked" }, real); + + expect(hookSpecificOutput).toEqual({ + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "blocked", + }); + }); +}); diff --git a/policy/manifest.yaml b/policy/manifest.yaml index e74dc577..79109e79 100644 --- a/policy/manifest.yaml +++ b/policy/manifest.yaml @@ -25,3 +25,17 @@ tools: type: Tool id: run_shell security_labels: [shell] + # Task 8 addition: an unregistered tool_call.name fails AGT's own + # evaluation closed with a generic runtime_error:tool_unknown deny -- + # discovered by Task 8's real host -> wire -> policy -> host test, which + # posts a real Claude Code PreToolUse envelope (tool.name "Bash", per + # Task 7's own fixture) through a live Guardian. "run_shell" above is + # AGT's own stock example name and stays for the existing bridge/guardian + # tests that fixture against it; "Bash" is Claude Code's actual tool name + # for shell execution and must be registered too for the real demo (Task + # 9) to evaluate real Claude Code payloads rather than fail closed on an + # unknown tool before the destructive-command pattern check ever runs. + Bash: + type: Tool + id: Bash + security_labels: [shell] From 826754dfdc4346cd5b3ddf49d7947adb913bded1 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 20:51:43 +0300 Subject: [PATCH 13/39] Add acs-hook.ts shim, Claude Code hook wiring, and the V1 demo runbook (N1, U1, U2) - hosts/claude-code/acs-hook.ts (N1): reads a PreToolUse hook payload on stdin, calls buildEnvelope -> guardianClient.post -> renderDecision (all from host-adapter's public surface) and writes hookSpecificOutput JSON to stdout, always exiting 0 for a real decision. All logic stays in the adapter; this file is only stdin/stdout/exit-code wiring plus the hookmap path, so slice V5 can add a second host with an equally thin shim against the same, unchanged adapter. - hosts/claude-code/settings.json: registers the shim against PreToolUse, scoped to the Bash matcher (matching policy/manifest.yaml's registered tools), via $CLAUDE_PROJECT_DIR so the command is portable. - docs/demos/v1-runbook.md: how to run the demo end to end -- start the Guardian, wire the hook, ask Claude Code for a destructive shell command, watch the deny reason land in the transcript -- stated in the slice's own words, with the R4.4 framing correction (AGT's stock policy *engine*, configured with our own destructive-command patterns, not a Microsoft-shipped rm -rf deny-list) and a note on the V1 Guardian- unreachable behaviour below. - packages/host-adapter/src/index.ts: a barrel re-exporting the package's public surface (buildEnvelope, loadHookmap, guardianClient, renderDecision, handshake, createSessionConfigStore) so a host shim imports one package rather than reaching into individual src files; package.json main/types updated to point at it. - packages/guardian/src/main.ts: a standalone CLI entrypoint (`bun run guardian`) so the runbook's "start the Guardian" step is a real command -- every earlier task only started the Guardian in-process inside a test. Default port 8787 matches the shim's own ACS_GUARDIAN_URL default so the two agree without either hardcoding the other. - hosts/claude-code/package.json + root workspaces: hosts/* added alongside packages/* so the shim can depend on host-adapter (runtime) and guardian (test-only) as real workspace packages. Tests (hosts/claude-code/test/hook.test.ts): spawns the real shim as a subprocess against a live, test-instance Guardian (startGuardian, test-only import). rm -rf / denies, exit 0, clean JSON on stdout, and permissionDecisionReason is asserted equal to the same Guardian's independently-obtained reasoning for the identical tool call -- proving the shim relays the policy's actual text rather than a placeholder a green-but-useless suite would miss. ls -la allows, exit 0, clean JSON, plain allow with no reason. Both assert stderr is empty. V1 SCOPE note: the shim's Guardian-unreachable handling (network error or a JSON-RPC error response) is a placeholder -- write to stderr, exit 1, nothing on stdout -- not a considered fail-open/fail-closed posture. That negotiation is N6/N7, deliberately deferred to slice V3. Full suite: 62 pass / 0 fail across 10 files. R3.2 grep gate (agt, AgentControl, rego, opa, intervention_point, verdict) clean across packages/host-adapter/src; R3.3 gate clean across packages/agt-bridge/src. Slice: #2 Affordances: N1, U1, U2 --- bun.lock | 12 +++ docs/demos/v1-runbook.md | 102 +++++++++++++++++++++++++ hosts/claude-code/acs-hook.ts | 81 ++++++++++++++++++++ hosts/claude-code/package.json | 12 +++ hosts/claude-code/settings.json | 15 ++++ hosts/claude-code/test/hook.test.ts | 114 ++++++++++++++++++++++++++++ package.json | 5 +- packages/guardian/src/main.ts | 26 +++++++ packages/host-adapter/package.json | 4 +- packages/host-adapter/src/index.ts | 28 +++++++ 10 files changed, 395 insertions(+), 4 deletions(-) create mode 100644 docs/demos/v1-runbook.md create mode 100644 hosts/claude-code/acs-hook.ts create mode 100644 hosts/claude-code/package.json create mode 100644 hosts/claude-code/settings.json create mode 100644 hosts/claude-code/test/hook.test.ts create mode 100644 packages/guardian/src/main.ts create mode 100644 packages/host-adapter/src/index.ts diff --git a/bun.lock b/bun.lock index e328ea2d..485c067e 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,16 @@ "agent-control-specification": "0.3.1-beta.0", }, }, + "hosts/claude-code": { + "name": "claude-code-host", + "version": "0.1.0", + "dependencies": { + "host-adapter": "workspace:*", + }, + "devDependencies": { + "guardian": "workspace:*", + }, + }, "packages/agt-bridge": { "name": "agt-bridge", "version": "0.1.0", @@ -61,6 +71,8 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "claude-code-host": ["claude-code-host@workspace:hosts/claude-code"], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="], diff --git a/docs/demos/v1-runbook.md b/docs/demos/v1-runbook.md new file mode 100644 index 00000000..8d91862b --- /dev/null +++ b/docs/demos/v1-runbook.md @@ -0,0 +1,102 @@ +# V1 demo runbook: one host, one hook, a real AGT decision + +**The demo, in the slice's own words** (from `docs/shaping/acs-reference-impl-slices.md`): + +> In Claude Code, ask for a destructive shell command. AGT's stock policy denies it; the deny reason appears in the transcript. + +**Framing correction (R4.4) — read before you narrate this.** The AGT stock bundle ships **no** shell or command patterns of its own — `patterns.rego` carries generic PII regexes only. What is stock is the *deciding module* (`agt.patterns`) and the priority chain in `agt_default.rego`; the destructive-command regex list this demo denies against is this project's own configuration, supplied as data (`policy/lib/data.json`), not authored Rego. Narrate this as "AGT's stock policy engine, configured" — never as "Microsoft ships an `rm -rf` deny-list." R2.1 still holds exactly: zero Rego authored, behaviour driven only through `data.agt.defaults.config`. + +## What a viewer should watch for + +1. You ask Claude Code to run a destructive shell command. +2. Claude Code's transcript shows the tool call **blocked**, with a human-readable deny reason — not a generic "permission denied," but the actual text the policy engine produced (something like *"matched pattern `(?i)rm\s+-[a-z]*r[a-z]*f[a-z]*\s+/(?:\s|$)` at offset 0"*). +3. That reason did not come from Claude Code, and it did not come from a hardcoded string in the hook shim — it travelled from the AGT bridge's Rego evaluation, through the Guardian's `POST /acs` endpoint, over HTTP, through `guardianClient.post` → `renderDecision`, and into `permissionDecisionReason`. The payoff is that this text is real, not stubbed. +4. Ask for something harmless (e.g. `ls -la`) in the same session and it runs normally — the hook only interrupts what the policy actually denies. + +## Prerequisites + +- `bun` installed. +- This repo cloned with its submodule: `git clone --recurse-submodules ...` (or `git submodule update --init` after a plain clone). +- `bun install` run once at the repo root. +- The [Claude Code](https://docs.claude.com/en/docs/claude-code) CLI installed and on your `PATH` (`claude`). + +## Step 1 — start the Guardian + +From the repo root, in its own terminal: + +```bash +bun run guardian +``` + +This starts the Guardian's `POST /acs` JSON-RPC endpoint (`packages/guardian/src/main.ts`), constructing the AGT bridge once at boot against the pinned stock bundle (`policy/lib`, per `agt.lock`) and `policy/manifest.yaml`. It prints the URL it's listening on: + +``` +Guardian listening at http://localhost:8787/acs +``` + +Leave this running for the rest of the demo. `hosts/claude-code/acs-hook.ts` defaults to exactly this URL (`http://localhost:8787/acs`); set `ACS_GUARDIAN_URL` if you need the Guardian on a different port. + +**What happens if you skip this step, or the Guardian dies mid-demo:** V1 does not implement a considered fail-open/fail-closed posture for an unreachable Guardian (that negotiation is N6/N7, slice V3). The hook shim writes an error to stderr and exits 1 — Claude Code's "non-blocking error" — with nothing on stdout, so the tool call proceeds **ungoverned**, exactly as if the hook had never fired. If the demo's `rm -rf /` unexpectedly seems to go through unblocked, this is the first thing to check. + +## Step 2 — wire the hook into Claude Code + +`hosts/claude-code/settings.json` registers the shim against `PreToolUse` for the `Bash` tool only (matching `policy/manifest.yaml`'s registered tools — this is the "one hook" of V1's name, not a general-purpose interception of every tool call). Install it as this repo's project-level Claude Code settings: + +```bash +mkdir -p .claude +cp hosts/claude-code/settings.json .claude/settings.json +``` + +(If you already have a `.claude/settings.json` here, merge the `hooks.PreToolUse` block in rather than overwriting.) + +The registered command is: + +``` +bun run "$CLAUDE_PROJECT_DIR/hosts/claude-code/acs-hook.ts" +``` + +`$CLAUDE_PROJECT_DIR` is Claude Code's own substitution for the project root, so the hook resolves correctly regardless of what directory you're in when you launch `claude`. + +## Step 3 — launch Claude Code and ask for the destructive command + +From the repo root: + +```bash +claude +``` + +Then ask directly, so Claude actually issues the Bash tool call rather than declining on its own judgment before any hook runs — e.g.: + +> Use the Bash tool to run exactly this command: `rm -rf /` + +## Step 4 — watch the deny land in the transcript + +Claude Code will show the tool call as blocked, with `permissionDecisionReason` surfaced in the transcript UI. It should read close to: + +``` +matched pattern (?i)rm\s+-[a-z]*r[a-z]*f[a-z]*\s+/(?:\s|$) at offset 0 +``` + +This is `data.json`'s `destructive_shell_command_blocked` pattern rule, evaluated for real — not a canned response. (The exact offset/pattern text will track whatever `policy/lib/data.json` configures; the point to watch is that *some* real, specific reasoning text arrives, not a placeholder.) + +## Step 5 — contrast with an allowed command + +Ask Claude Code to run something harmless with the Bash tool, e.g. `ls -la`. It runs normally with no interruption — the hook fired, the Guardian returned `allow`, and `renderDecision` rendered a plain allow with no reason attached (there is nothing to show, by design — an allow is not a warning). + +## Verifying the pieces independently + +If the live demo doesn't behave as expected, each layer can be checked in isolation: + +```bash +# Guardian reachable and evaluating for real (id and request_id must be +# schema-valid uuids -- the Guardian rejects "1" with a schema error): +curl -s -X POST http://localhost:8787/acs \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","method":"steps/toolCallRequest","id":"22222222-2222-2222-2222-222222222222","params":{"acs_version":"0.1.0","request_id":"22222222-2222-2222-2222-222222222222","timestamp":"2026-01-01T00:00:00Z","metadata":{"agent_id":"claude-code","session_id":"11111111-1111-1111-1111-111111111111"},"payload":{"tool":{"name":"Bash"},"arguments":{"command":{"value":"rm -rf /"}}}}}' + +# The shim itself, fed a real PreToolUse payload directly on stdin: +echo '{"session_id":"demo","transcript_path":"/tmp/t.jsonl","cwd":"'"$PWD"'","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \ + | bun run hosts/claude-code/acs-hook.ts +``` + +The second command is exactly what `hosts/claude-code/test/hook.test.ts` automates: it spawns `acs-hook.ts` as a real subprocess against a real (test-instance) Guardian and asserts `permissionDecision: "deny"` with a non-empty, policy-sourced `permissionDecisionReason` for `rm -rf /`, and `permissionDecision: "allow"` for `ls -la` — both exiting 0. diff --git a/hosts/claude-code/acs-hook.ts b/hosts/claude-code/acs-hook.ts new file mode 100644 index 00000000..12db2727 --- /dev/null +++ b/hosts/claude-code/acs-hook.ts @@ -0,0 +1,81 @@ +/** + * acs-hook.ts (N1) -- Claude Code's PreToolUse hook shim. + * + * Deliberately thin: read the hook JSON Claude Code sends on stdin, call + * buildEnvelope -> guardianClient.post -> renderDecision (all three from + * `host-adapter`, packages/host-adapter), and write the resulting + * `hookSpecificOutput` JSON to stdout. All logic lives in the adapter -- + * this file is only the wiring a Claude Code hook process needs (stdin, + * stdout, exit code, and which hookmap file to load). Slice V5 adds a + * second host by writing another shim this thin against the same, + * unchanged adapter; any logic added here is logic V5 would have to + * duplicate. + * + * This file is host-specific by definition (it may name Claude Code + * freely) but must not reach into AGT -- it never imports `agt-bridge` or + * `guardian`'s server-side pieces, only `host-adapter`'s public surface, + * and talks to the Guardian only over HTTP via `guardianClient.post`. + * + * Claude Code's hook protocol (see the task brief and + * docs/demos/v1-runbook.md): stdin is one JSON object + * `{ session_id, transcript_path, cwd, hook_event_name, tool_name, + * tool_input }`; stdout is one JSON object + * `{ hookSpecificOutput: { hookEventName, permissionDecision, ... } }`; + * the process **always exits 0** for a real decision -- "deny" travels in + * the JSON body, not the exit code. Exit 2 means "blocking error" to + * Claude Code and exit 1 means "non-blocking error"; neither is how a + * policy deny is expressed, so getting this wrong would make a deny look + * like a crash. + * + * V1 SCOPE -- Guardian-unreachable handling: if anything above throws (the + * Guardian is down, returns a JSON-RPC error, or the stdin payload is + * malformed), this shim does NOT implement a fail-open/fail-closed + * posture. That negotiation is N6/N7, deliberately deferred to slice V3. + * It writes the error to stderr only, and exits 1 ("non-blocking error" + * per the hook protocol) with nothing on stdout -- Claude Code proceeds as + * though the hook had not fired. This is a placeholder, not a considered + * posture; V3 should replace it deliberately. + */ +import { fileURLToPath } from "node:url"; +import { buildEnvelope, guardianClient, loadHookmap, renderDecision } from "host-adapter"; + +const HOOKMAP_PATH = fileURLToPath(new URL("./claude-code.hookmap.yaml", import.meta.url)); + +// Matches packages/guardian/src/main.ts's own default port -- the runbook +// and this shim agree on 8787 without either hardcoding the other's value. +// Override with ACS_GUARDIAN_URL when the Guardian runs on a different +// host/port (e.g. in tests, which start a Guardian on an ephemeral port). +const DEFAULT_GUARDIAN_URL = "http://localhost:8787/acs"; + +async function main(): Promise { + const input = await Bun.stdin.text(); + const payload = JSON.parse(input) as Record; + + const hookEventName = payload.hook_event_name; + if (typeof hookEventName !== "string") { + throw new Error('acs-hook: stdin payload is missing a string "hook_event_name" field'); + } + + const hookmap = loadHookmap(HOOKMAP_PATH); + const envelope = buildEnvelope(hookEventName, payload, hookmap); + + const guardianUrl = process.env.ACS_GUARDIAN_URL ?? DEFAULT_GUARDIAN_URL; + const response = await guardianClient.post(guardianUrl, envelope); + + if (response.error) { + throw new Error(`acs-hook: Guardian returned a JSON-RPC error: ${response.error.message}`); + } + + const { hookSpecificOutput } = renderDecision( + hookEventName, + response.result as { decision: string } & Record, + hookmap, + ); + + process.stdout.write(JSON.stringify({ hookSpecificOutput })); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/hosts/claude-code/package.json b/hosts/claude-code/package.json new file mode 100644 index 00000000..b6e64607 --- /dev/null +++ b/hosts/claude-code/package.json @@ -0,0 +1,12 @@ +{ + "name": "claude-code-host", + "private": true, + "type": "module", + "version": "0.1.0", + "dependencies": { + "host-adapter": "workspace:*" + }, + "devDependencies": { + "guardian": "workspace:*" + } +} diff --git a/hosts/claude-code/settings.json b/hosts/claude-code/settings.json new file mode 100644 index 00000000..18016bea --- /dev/null +++ b/hosts/claude-code/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bun run \"$CLAUDE_PROJECT_DIR/hosts/claude-code/acs-hook.ts\"" + } + ] + } + ] + } +} diff --git a/hosts/claude-code/test/hook.test.ts b/hosts/claude-code/test/hook.test.ts new file mode 100644 index 00000000..ae1d4dbf --- /dev/null +++ b/hosts/claude-code/test/hook.test.ts @@ -0,0 +1,114 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { fileURLToPath } from "node:url"; +// Test-only import: stands up a real Guardian so this test proves the wire +// contract for real, not against a hand-copied shape -- same precedent as +// packages/host-adapter/test/client.test.ts (Task 8). `host-adapter` here +// is used the same way the shim itself uses it, so this test also proves +// what a subprocess sees is what the adapter would have produced directly. +import { startGuardian, type StartedGuardian } from "guardian"; +import { buildEnvelope, guardianClient, loadHookmap, renderDecision, type Hookmap } from "host-adapter"; + +const SHIM_PATH = fileURLToPath(new URL("../acs-hook.ts", import.meta.url)); +const HOOKMAP_PATH = fileURLToPath(new URL("../claude-code.hookmap.yaml", import.meta.url)); + +/** The real PreToolUse payload shape Claude Code delivers on stdin (per the + * Task 7 brief), with `tool_name: "Bash"` -- what Claude Code actually + * sends, and what `policy/manifest.yaml` registers (Task 8's fix). */ +function preToolUsePayload(command: string): Record { + return { + session_id: "abc123", + transcript_path: "/path/to/transcript.jsonl", + cwd: "/current/dir", + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command }, + }; +} + +/** Spawns the real shim as a subprocess -- exactly how Claude Code invokes + * it -- feeds it `payload` on stdin, and returns what it wrote once it has + * run to completion. */ +async function runHook( + payload: Record, + guardianUrl: string, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn({ + cmd: ["bun", "run", SHIM_PATH], + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, ACS_GUARDIAN_URL: guardianUrl }, + }); + proc.stdin.write(JSON.stringify(payload)); + proc.stdin.end(); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { stdout, stderr, exitCode }; +} + +let guardian: StartedGuardian; + +beforeAll(async () => { + guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); +}); + +afterAll(async () => { + await guardian.close(); +}); + +describe("acs-hook.ts (N1) -- the Claude Code hook shim, run as a real subprocess", () => { + it("denies a real rm -rf / tool call: exit 0, clean JSON on stdout, and the policy's own reasoning in permissionDecisionReason", async () => { + const payload = preToolUsePayload("rm -rf /"); + + const { stdout, stderr, exitCode } = await runHook(payload, guardian.url); + + expect(exitCode).toBe(0); + expect(() => JSON.parse(stdout)).not.toThrow(); + const parsed = JSON.parse(stdout) as { hookSpecificOutput: Record }; + + expect(parsed.hookSpecificOutput.hookEventName).toBe("PreToolUse"); + expect(parsed.hookSpecificOutput.permissionDecision).toBe("deny"); + const reason = parsed.hookSpecificOutput.permissionDecisionReason; + expect(typeof reason).toBe("string"); + expect((reason as string).length).toBeGreaterThan(0); + + // Cross-check against the real Guardian's own decision for the + // identical tool call, obtained independently of the subprocess (the + // exact composition the shim itself performs, called directly rather + // than through stdin/stdout) -- proves the shim relays the policy's + // actual text rather than a hardcoded placeholder, which a suite that + // only checked "reason is a non-empty string" would miss. This is the + // demo's entire payoff: what a human reads in the transcript. + const hookmap: Hookmap = loadHookmap(HOOKMAP_PATH); + const envelope = buildEnvelope("PreToolUse", payload, hookmap); + const response = await guardianClient.post(guardian.url, envelope); + expect(response.error).toBeUndefined(); + const expected = renderDecision( + "PreToolUse", + response.result as { decision: string } & Record, + hookmap, + ); + + expect(reason).toBe(expected.hookSpecificOutput.permissionDecisionReason); + expect(stderr).toBe(""); + }); + + it("allows a real ls -la tool call: exit 0, and clean JSON on stdout with a plain allow", async () => { + const payload = preToolUsePayload("ls -la"); + + const { stdout, stderr, exitCode } = await runHook(payload, guardian.url); + + expect(exitCode).toBe(0); + expect(() => JSON.parse(stdout)).not.toThrow(); + const parsed = JSON.parse(stdout) as { hookSpecificOutput: Record }; + + expect(parsed.hookSpecificOutput).toEqual({ + hookEventName: "PreToolUse", + permissionDecision: "allow", + }); + expect(stderr).toBe(""); + }); +}); diff --git a/package.json b/package.json index df209750..e33bb84c 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,11 @@ "name": "acs-reference-implementation", "private": true, "type": "module", - "workspaces": ["packages/*"], + "workspaces": ["packages/*", "hosts/*"], "scripts": { "test": "bun test", - "verify:pin": "bash scripts/verify-pin.sh" + "verify:pin": "bash scripts/verify-pin.sh", + "guardian": "bun run packages/guardian/src/main.ts" }, "dependencies": { "agent-control-specification": "0.3.1-beta.0" diff --git a/packages/guardian/src/main.ts b/packages/guardian/src/main.ts new file mode 100644 index 00000000..0c9ff0c2 --- /dev/null +++ b/packages/guardian/src/main.ts @@ -0,0 +1,26 @@ +/** + * The Guardian's standalone CLI entrypoint -- `bun run guardian`. + * + * Every earlier task's Guardian usage is in-process (`startGuardian` called + * directly by a test's `beforeAll`). Task 9's demo is the first thing that + * needs the Guardian as a long-running process a hook subprocess can reach + * over HTTP, so this file exists to make `docs/demos/v1-runbook.md`'s + * "start the Guardian" step a real command rather than an unstated gap. + * Not re-exported from `./index.ts`: this is a process entrypoint, not a + * library call. + * + * `ACS_GUARDIAN_PORT` defaults to 8787 -- the same default port + * `hosts/claude-code/acs-hook.ts` assumes for `ACS_GUARDIAN_URL` when that + * env var is unset, so the runbook and the shim agree without either + * hardcoding the other's value. + */ +import { startGuardian } from "./server.ts"; + +const DEFAULT_PORT = 8787; +const DEFAULT_MANIFEST_PATH = "policy/manifest.yaml"; + +const port = Number(process.env.ACS_GUARDIAN_PORT ?? DEFAULT_PORT); +const manifestPath = process.env.ACS_MANIFEST_PATH ?? DEFAULT_MANIFEST_PATH; + +const guardian = await startGuardian({ port, manifestPath }); +console.log(`Guardian listening at ${guardian.url}`); diff --git a/packages/host-adapter/package.json b/packages/host-adapter/package.json index ae4548a0..2b426330 100644 --- a/packages/host-adapter/package.json +++ b/packages/host-adapter/package.json @@ -3,8 +3,8 @@ "private": true, "type": "module", "version": "0.1.0", - "main": "src/build-envelope.ts", - "types": "src/build-envelope.ts", + "main": "src/index.ts", + "types": "src/index.ts", "devDependencies": { "guardian": "workspace:*" } diff --git a/packages/host-adapter/src/index.ts b/packages/host-adapter/src/index.ts new file mode 100644 index 00000000..06b739f5 --- /dev/null +++ b/packages/host-adapter/src/index.ts @@ -0,0 +1,28 @@ +/** + * Public surface of the host-adapter package (packages/host-adapter). + * + * Every host shim (N1 -- Task 9's Claude Code shim, and any later host from + * slice V5) imports from here, not from individual src files, so this list + * is the whole contract a shim relies on. R3.2 still binds every file this + * barrel re-exports: no policy-runtime vocabulary of any kind anywhere + * under packages/host-adapter/. + */ +export { + buildEnvelope, + loadHookmap, + toSessionUuid, + type Hookmap, + type HookmapHookEntry, + type AcsRequestEnvelope, +} from "./build-envelope.ts"; +export { + guardianClient, + GuardianResponseMismatchError, + type JsonRpcRequest, + type JsonRpcResponse, + type JsonRpcSuccessResponse, + type JsonRpcErrorResponse, +} from "./guardian-client.ts"; +export { renderDecision, type AcsDecisionResult, type HookSpecificOutput } from "./render-decision.ts"; +export { handshake, type HandshakeOptions } from "./handshake.ts"; +export { createSessionConfigStore, type SessionConfig, type SessionConfigStore } from "./session-config.ts"; From 243bcdf7436af04aeeb8c4624806200a45296441 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 21:15:32 +0300 Subject: [PATCH 14/39] Task 10: architectural invariant gates, workspace typecheck, quickstart README Makes R3.2 and R3.3 mechanical instead of "verifiable by inspection": - test/invariants.test.ts: two grep gates, comment-stripped and whole-word matched so they assert real code leaks (imports, identifiers) rather than tripping on doc-comment prose (e.g. hosts/claude-code/acs-hook.ts's "must not reach into AGT") or on substring collisions (e.g. "opa" inside "opaque"). Scoped to packages/host-adapter/src and packages/agt-bridge/src only, matching what R3.2/R3.3 actually claim -- host shims are host-specific by definition, and host-adapter's test-only devDependency on `guardian` (used to spin up a real Guardian for integration tests) is out of scope by construction, since the gate reads src/ .ts files, never package.json or test/. Verified both gates genuinely fail by temporarily injecting a real code-level leak in each package (a `rego` / `stdin` string literal), watching the assertion fail with the file and term named in the diff, then reverting. - tsconfig.base.json + tsconfig.json + bun-types/typescript devDeps: first working `bun run typecheck` for the whole workspace. Fixed the real errors it surfaced: an unreachable, unsound `wrap !== "array"` branch in packages/guardian/src/map-verdict.ts that cast a bare string to string[] (replaced by making `wrap: "array"` a required part of reason_codes' field-source type, so there is no second case to mis-handle); two noUncheckedIndexedAccess "possibly undefined" array-index accesses in map-verdict.test.ts; one literal-narrowing overload mismatch in the same test's lowercase-decision assertion; and one closure-narrowing gotcha in host-adapter's client.test.ts where TS's generic inference for expect() doesn't pick up flow narrowing on a `let` a nested closure can still reassign, fixed by rebinding to a plain const after the null check. No `any`, no blanket @ts-ignore. Zero errors remain. - README.md: a Quickstart section proving R7.1 -- install, start the Guardian, wire the hook, run Claude Code -- with every command actually run against this tree (bun install, bun run guardian, and the hook shim piped a real PreToolUse payload against a live Guardian, producing a real deny with reasoning and a real allow). Status section updated: V1 is implemented, not "not started". bun test: 64 pass, 0 fail. bun run typecheck: 0 errors. Slice: #2 Affordances: R3.2, R3.3, R7.1 --- README.md | 62 +++++++++++++- bun.lock | 52 ++++++++++++ package.json | 5 ++ packages/guardian/src/map-verdict.ts | 16 ++-- packages/guardian/test/map-verdict.test.ts | 6 +- packages/host-adapter/test/client.test.ts | 11 ++- test/invariants.test.ts | 98 ++++++++++++++++++++++ tsconfig.base.json | 2 + tsconfig.json | 9 ++ 9 files changed, 251 insertions(+), 10 deletions(-) create mode 100644 test/invariants.test.ts create mode 100644 tsconfig.json diff --git a/README.md b/README.md index eb861824..dc3177de 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,69 @@ The shaping doc is authoritative for requirements, shapes, and the breadboard. T git clone --recurse-submodules https://github.com/afogel/ACS_reference_implementation ``` +## Quickstart (R7.1 — one command on a laptop) + +Requires [`bun`](https://bun.sh) and the [Claude Code](https://docs.claude.com/en/docs/claude-code) CLI (`claude`) on your `PATH`. + +**1. Install.** + +```bash +bun install +``` + +**2. Start the Guardian.** In its own terminal, from the repo root: + +```bash +bun run guardian +``` + +This constructs the AGT bridge once, against the pinned stock policy bundle (`policy/lib`, per `agt.lock`), and serves ACS's `POST /acs` JSON-RPC endpoint: + +``` +Guardian listening at http://localhost:8787/acs +``` + +Leave it running. `hosts/claude-code/acs-hook.ts` defaults to exactly this URL; override with `ACS_GUARDIAN_URL` if it's listening elsewhere. + +**3. Wire the hook into Claude Code.** + +```bash +mkdir -p .claude +cp hosts/claude-code/settings.json .claude/settings.json +``` + +This registers `hosts/claude-code/acs-hook.ts` as a `PreToolUse` hook for the `Bash` tool — the "one hook" of this slice's name. + +**4. Run Claude Code with the hook.** + +```bash +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 produced by AGT's stock Rego evaluation for the pattern it matched. 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). + +Steps 1–2 were run against this exact tree to write this README: `bun install` completes clean, and `bun run guardian` prints the line above. Steps 3–4 were verified the same way the project's own tests verify them — piping a Claude Code–shaped `PreToolUse` payload on stdin straight into the hook shim against a running Guardian: + +```bash +echo '{"session_id":"demo","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \ + | bun run hosts/claude-code/acs-hook.ts +# {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"matched pattern ... at offset 0"}} +``` + +which is exactly the JSON Claude Code's own hook protocol sends and expects back; running `claude` interactively for step 4 exercises the identical path through the real CLI. + +### Verify + +```bash +bun test # 64 tests across 11 files, including the R3.2/R3.3 architectural gates below +bun run typecheck # whole-workspace strict TypeScript check, zero errors +``` + +`bun run verify:pin` additionally re-clones AGT at the pinned ref and byte-diffs the vendored bundle against it (R2.2/R2.3) — it needs network access to GitHub, so it isn't part of the offline quickstart above. + ## Status -Shaped and sliced; implementation has not started. Slices V1–V8 are tracked as issues on the project board, each with a stacked pull request. +V1 ("one host, one hook") is implemented: a Claude Code `PreToolUse` hook, a Guardian process serving ACS over HTTP, and AGT's unforked stock policy bundle deciding behind it — see the quickstart above and [`slices/v1/README.md`](slices/v1/README.md). R3.2 and R3.3 (no AGT vocabulary in the host adapter, no host vocabulary in the AGT bridge) are enforced by [`test/invariants.test.ts`](test/invariants.test.ts), not left to inspection. Slices V2–V8 are shaped and sliced but not started; they are tracked as issues on the project board, each with a stacked pull request. ## License diff --git a/bun.lock b/bun.lock index 485c067e..1f80236f 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,10 @@ "dependencies": { "agent-control-specification": "0.3.1-beta.0", }, + "devDependencies": { + "bun-types": "^1.3.14", + "typescript": "^7.0.2", + }, }, "hosts/claude-code": { "name": "claude-code-host", @@ -43,6 +47,48 @@ }, }, "packages": { + "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "agent-control-specification": ["agent-control-specification@0.3.1-beta.0", "", { "optionalDependencies": { "agent-control-specification-darwin-arm64": "0.3.1-beta.0", "agent-control-specification-darwin-x64": "0.3.1-beta.0", "agent-control-specification-linux-arm64-gnu": "0.3.1-beta.0", "agent-control-specification-linux-x64-gnu": "0.3.1-beta.0", "agent-control-specification-opa-darwin-arm64": "0.3.1-beta.0", "agent-control-specification-opa-darwin-x64": "0.3.1-beta.0", "agent-control-specification-opa-linux-arm64": "0.3.1-beta.0", "agent-control-specification-opa-linux-x64": "0.3.1-beta.0", "agent-control-specification-opa-win32-x64": "0.3.1-beta.0", "agent-control-specification-win32-x64-msvc": "0.3.1-beta.0" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.100.0", "@langchain/core": ">=1.1.0", "@openai/agents": ">=0.11.0", "langchain": ">=1.0.0" }, "optionalPeers": ["@anthropic-ai/sdk", "@langchain/core", "@openai/agents", "langchain"] }, "sha512-vD1N169F2YhTCLbF4u7hEV5xfmAY1vziq4bHL2xhBa5QNOhdeEzrTdOIshWZw46fxxPJzEYDjQyjSmI2NnHCuQ=="], "agent-control-specification-darwin-arm64": ["agent-control-specification-darwin-arm64@0.3.1-beta.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6kr1DAeFOBf/8Q7eNFqLtUoUfRqIDTqZ2QvkIvSKxDYh3iY2/8cizn0bzbJJofuba02aDV1oLEd5vvgcYkbWAg=="], @@ -71,6 +117,8 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "claude-code-host": ["claude-code-host@workspace:hosts/claude-code"], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -84,5 +132,9 @@ "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], } } diff --git a/package.json b/package.json index e33bb84c..e20357a6 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,15 @@ "workspaces": ["packages/*", "hosts/*"], "scripts": { "test": "bun test", + "typecheck": "tsc -p tsconfig.json --noEmit", "verify:pin": "bash scripts/verify-pin.sh", "guardian": "bun run packages/guardian/src/main.ts" }, "dependencies": { "agent-control-specification": "0.3.1-beta.0" + }, + "devDependencies": { + "bun-types": "^1.3.14", + "typescript": "^7.0.2" } } diff --git a/packages/guardian/src/map-verdict.ts b/packages/guardian/src/map-verdict.ts index 3955e2e6..206b3365 100644 --- a/packages/guardian/src/map-verdict.ts +++ b/packages/guardian/src/map-verdict.ts @@ -13,8 +13,14 @@ type VerdictRule = { require_policy_references?: boolean; }; -/** A leaf of field_synthesis: either copy a verdict field, or emit a fixed value. */ -type FieldSource = { source: string; wrap?: "array" }; +/** A leaf of field_synthesis that copies a verdict field verbatim. */ +type FieldSource = { source: string }; +/** A leaf of field_synthesis that wraps a scalar verdict field into a + * single-element array -- the only shape reason_codes' string verdict + * field (verdict.reason) can take to satisfy ACS's `string[]`. `wrap` is + * required here (not optional) precisely so there is no third case to + * handle: a string source is always wrapped, never cast unsound. */ +type WrappedFieldSource = { source: string; wrap: "array" }; type FieldLiteral = { literal: string }; export type Mapping = { @@ -24,7 +30,7 @@ export type Mapping = { verdicts: Record; field_synthesis: { reasoning: FieldSource; - reason_codes: FieldSource; + reason_codes: WrappedFieldSource; policy_references: { rule_id: FieldSource; policy_id: FieldLiteral; @@ -37,7 +43,7 @@ export function loadMapping(path: string): Mapping { } /** Resolves a field_synthesis `source: "verdict."` path against a verdict. */ -function readVerdictField(verdict: AgtVerdict, source: FieldSource): unknown { +function readVerdictField(verdict: AgtVerdict, source: { source: string }): unknown { const field = source.source.slice("verdict.".length) as keyof AgtVerdict; return verdict[field]; } @@ -58,7 +64,7 @@ export function mapVerdict(verdict: AgtVerdict, mapping: Mapping): AcsDecision { const reasonForCodes = readVerdictField(verdict, fs.reason_codes); if (typeof reasonForCodes === "string") { - out.reason_codes = fs.reason_codes.wrap === "array" ? [reasonForCodes] : (reasonForCodes as unknown as string[]); + out.reason_codes = [reasonForCodes]; } const ruleId = readVerdictField(verdict, fs.policy_references.rule_id); diff --git a/packages/guardian/test/map-verdict.test.ts b/packages/guardian/test/map-verdict.test.ts index c4bc3c01..a302c25d 100644 --- a/packages/guardian/test/map-verdict.test.ts +++ b/packages/guardian/test/map-verdict.test.ts @@ -16,7 +16,7 @@ describe("mapVerdict", () => { expect(d.decision).toBe("deny"); expect(d.reasoning).toBe("matched pattern X"); expect(d.reason_codes).toEqual(["destructive_shell_command_blocked"]); - expect(d.policy_references?.[0].rule_id).toBe("destructive_shell_command_blocked"); + expect(d.policy_references?.[0]?.rule_id).toBe("destructive_shell_command_blocked"); }); // R1.2 — the whole warn round trip rests on this. @@ -24,7 +24,7 @@ describe("mapVerdict", () => { const d = mapVerdict({ decision: "warn", reason: "drift_detected", message: "drift 0.8" }, m); expect(d.decision).toBe("allow"); expect(d.policy_references?.length).toBeGreaterThan(0); - expect(d.policy_references?.[0].rule_id).toBe("drift_detected"); + expect(d.policy_references?.[0]?.rule_id).toBe("drift_detected"); }); it("distinguishes warn-allow from clean allow by policy_references", () => { @@ -39,7 +39,7 @@ describe("mapVerdict", () => { it("emits only lowercase decisions (C7)", () => { for (const dec of ["allow", "deny", "warn", "escalate", "transform"] as const) { const out = mapVerdict({ decision: dec, reason: "r" }, m).decision; - expect(out).toBe(out.toLowerCase()); + expect(out.toLowerCase()).toBe(out); } }); }); diff --git a/packages/host-adapter/test/client.test.ts b/packages/host-adapter/test/client.test.ts index b1ad3f91..9b98b6ea 100644 --- a/packages/host-adapter/test/client.test.ts +++ b/packages/host-adapter/test/client.test.ts @@ -51,7 +51,16 @@ describe("guardianClient.post", () => { const response = await guardianClient.post(`http://localhost:${mock.port}/acs`, envelope); expect(capturedBody).toEqual(envelope as unknown as Record); - expect(capturedContentType).toContain("application/json"); + if (capturedContentType === null) { + throw new Error("mock server captured no content-type header"); + } + // Rebind to a plain (non-closure-mutated) const: `capturedContentType` + // is reassigned inside the fetch handler above, and TS's generic + // inference for expect() doesn't pick up the flow-narrowing on a + // variable a closure can still write to, even though the narrowing + // itself is sound here (the closure has already run by this point). + const contentType: string = capturedContentType; + expect(contentType).toContain("application/json"); expect(response.result?.decision).toBe("allow"); } finally { mock.stop(true); diff --git a/test/invariants.test.ts b/test/invariants.test.ts new file mode 100644 index 00000000..d2e7372d --- /dev/null +++ b/test/invariants.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { Glob } from "bun"; + +/** + * Strips comments before matching, so these gates assert what R3.2/R3.3 + * actually claim -- no AGT/host vocabulary *used in code* (imports, types, + * calls) -- rather than the much weaker, much more brittle claim "never + * mentioned anywhere, including prose". Doc comments in this codebase + * routinely explain a module's boundary by naming the exact vocabulary it + * must stay clear of (see hosts/claude-code/acs-hook.ts: "must not reach + * into AGT -- it never imports `agt-bridge`..."). A gate that fires on + * that sentence is a gate the next person deletes rather than fixes, + * which is worse than no gate at all. + * + * Line comments are stripped to end-of-line unless the "//" is + * immediately preceded by ":" -- an `https://`-style URL that happens to + * sit inside a comment or string, not a line-comment delimiter. + */ +function stripComments(src: string): string { + const noBlockComments = src.replace(/\/\*[\s\S]*?\*\//g, ""); + return noBlockComments.replace(/(? !f.includes("/test/")) + .map((f) => ({ file: f, code: stripComments(readFileSync(`${dir}/${f}`, "utf8")) })); +} + +/** + * Asserts none of `terms` appears as a whole word (case-insensitive) in + * any non-test `.ts` file's code under `dir`. Whole-word matching, not + * bare substring: "opa" as a substring would false-positive on ordinary + * English words like "opaque"; a gate that fails on that is exactly the + * "annoyingly false" failure mode this suite exists to avoid. Each + * assertion diffs `{file, term, found}` against `{file, term, found: + * false}` on failure, so the failing file and the offending term are + * both in the test output -- no separate message plumbing needed. + */ +function assertNoVocabulary(dir: string, terms: string[]): void { + for (const { file, code } of readSourceFiles(dir)) { + for (const term of terms) { + const found = new RegExp(`\\b${term}\\b`, "i").test(code); + expect({ file, term, found }).toEqual({ file, term, found: false }); + } + } +} + +describe("architectural invariants", () => { + /** + * R3.2 -- the claim the whole M×N argument rests on: a host implements + * ACS once (packages/host-adapter) and is governable by any conformant + * runtime. That collapse is only real if the adapter never leaks + * policy-runtime (AGT) vocabulary into its own code. + * + * Scope, deliberately: packages/host-adapter/src only. + * - hosts/claude-code/ is NOT scanned here. A host shim is + * host-specific by definition, and its own doc comment is allowed + * to name AGT in prose (acs-hook.ts's header does, explaining what + * it must NOT import). The invariant that actually matters for that + * file is an import-graph one ("never imports agt-bridge or + * guardian's server-side pieces"), not "never mentions the word" -- + * a different claim this suite doesn't make for host shims. + * - host-adapter/package.json's devDependency on `guardian` is a + * test-only wiring choice: packages/host-adapter/test/*.test.ts spin + * up a real Guardian for end-to-end coverage (see client.test.ts's + * and build-envelope.test.ts's own comments). This gate reads only + * `.ts` source under src/, never package.json and never test/, so + * that dependency is correctly out of scope. + */ + it("host adapter's source contains zero AGT-specific code", () => { + assertNoVocabulary("packages/host-adapter/src", [ + "agt", + "AgentControl", + "rego", + "opa", + "intervention_point", + "verdict", + ]); + }); + + /** + * R3.3 -- what makes slice V5's second host cost zero AGT code: the + * bridge that knows AGT must never learn a specific host's wire shape, + * or adding a host would mean touching this package too. + */ + it("AGT bridge's source contains zero host-specific code", () => { + assertNoVocabulary("packages/agt-bridge/src", [ + "claude", + "opencode", + "hookSpecificOutput", + "permissionDecision", + "stdin", + ]); + }); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index cec95e84..359050a4 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -6,7 +6,9 @@ "module": "ESNext", "moduleResolution": "Bundler", "moduleDetection": "force", + "allowImportingTsExtensions": true, "allowJs": false, + "noEmit": true, "declaration": true, "composite": false, "strict": true, diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..f7912d56 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.base.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["packages/**/*.ts", "hosts/**/*.ts", "test/**/*.ts"], + "exclude": ["**/node_modules"] +} From 45a9a2f8f8befcce41fa0036c6504bed3871e923 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 21:41:45 +0300 Subject: [PATCH 15/39] Fix wave: close Guardian fail-open, README overclaims, hookmap bugs, honest test skip - server.ts: wrap assembleSnapshot/bridge.evaluate/mapVerdict in try/catch, returning a JSON-RPC error (-32020) in the ACS-reserved range instead of letting an unhandled throw become a text/html 500 that made the tool call proceed ungoverned. Not turned into an ACS deny decision (that's N27, V3). Adds coverage for mapVerdict's previously-untested require_policy_references throw path, and an integration test forcing a real Guardian-side throw. - README.md: rewrite "What this proves" to state only what V1 delivers today, with the conformance matrix / two-host / scheduled-drift claims clearly marked planned (V5/V7/V8, no CI yet). Fix the destructive-command-pattern framing to match docs/demos/v1-runbook.md's R4.4 correction. - test/pin.test.ts: replace the silent early-return skip with it.skipIf, so bun test visibly reports the byte-identity assertion as skipped rather than passing while asserting nothing. - claude-code.hookmap.yaml: map ACS defer to permissionDecision: deny (Claude Code has no defer value; matches defer-details.json's own deny default), and give ask a reason_from so an escalate reaches the transcript explained. - slices/v1/README.md: replace the placeholder with an actual summary and pointers to the runbook and plan. - handshake.ts / v1-runbook.md: note plainly that V1's handshake declares fixed ServerHello fields rather than negotiating against the ClientHello. Slice: #2 --- README.md | 20 +++-- docs/demos/v1-runbook.md | 2 + hosts/claude-code/claude-code.hookmap.yaml | 17 +++- packages/guardian/src/handshake.ts | 11 +++ packages/guardian/src/server.ts | 80 ++++++++++++++----- ...ng.require-policy-references-on-allow.yaml | 41 ++++++++++ packages/guardian/test/map-verdict.test.ts | 10 +++ packages/guardian/test/server.test.ts | 40 ++++++++++ slices/v1/README.md | 6 +- test/pin.test.ts | 32 +++++--- 10 files changed, 220 insertions(+), 39 deletions(-) create mode 100644 packages/guardian/test/fixtures/mapping.require-policy-references-on-allow.yaml diff --git a/README.md b/README.md index dc3177de..f139f641 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,24 @@ One wire contract between agent hosts and policy runtimes, so governance integra Today every policy vendor writes a module per agent, and every agent waits for a module per vendor. Microsoft's [Agent Governance Toolkit](https://github.com/microsoft/agent-governance-toolkit) ships four host packages with four different architectures — a Copilot CLI extension, subprocess hooks for Claude Code and Antigravity, an in-process plugin for OpenCode — and documents the capability divergence between them in its own READMEs. -This repository shows the other shape. A host implements [ACS](https://github.com/Agent-Control-Standard/ACS) once and is governable by any conformant runtime. A runtime implements ACS once and governs any conformant host. AGT's policy engine runs unforked, with its stock Rego bundle deciding, across two structurally different coding agents — and adding the second host costs zero AGT code. +This repository shows the other shape. A host implements [ACS](https://github.com/Agent-Control-Standard/ACS) once and is governable by any conformant runtime. A runtime implements ACS once and governs any conformant host. This slice (V1) wires one host — Claude Code — to AGT's policy engine running unforked, its stock Rego bundle deciding, entirely over the ACS wire. ## What this proves +**Delivered in V1** — true of this tree today; verifiable by running the commands in Quickstart below. + | Claim | How it is demonstrated | |---|---| -| AGT is completely expressible in ACS | A machine-checked mapping of all eight intervention points and five verdicts, with a round-trip conformance case per cell | -| Interop is real | AGT's published policy library decides, used as shipped, at a pinned upstream commit, with no source changes | -| The collapse is structural | The host adapter contains no AGT-specific code and the AGT bridge contains no host-specific code — verifiable by reading the file list | -| It stays true | The same harness runs against AGT `main` on a schedule, so upstream drift surfaces as a named failing case | +| AGT's policy engine runs unforked, over the ACS wire | AGT's published policy library decides, used as shipped, at a pinned upstream commit, with no source changes (`agt.lock`, [`test/pin.test.ts`](test/pin.test.ts)) | +| The collapse is structural, not incidental | The host adapter contains no AGT-specific code and the AGT bridge contains no host-specific code — verifiable by reading the file list, and enforced by [`test/invariants.test.ts`](test/invariants.test.ts) | + +**Planned, not yet built** — the rest of the claim this project is working toward. None of the following exists yet, and there is no CI in this repository at all. + +| Claim | Slice | +|---|---| +| AGT is completely expressible in ACS: a machine-checked mapping of all eight intervention points and five verdicts, with a round-trip conformance case per cell | V7 | +| The same policy governs two structurally different coding agents, with the second host costing zero added AGT code | V5 | +| A scheduled harness run against AGT `main` catches upstream drift automatically | V8 | ## Layout @@ -69,7 +77,7 @@ This registers `hosts/claude-code/acs-hook.ts` as a `PreToolUse` hook for the `B 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 produced by AGT's stock Rego evaluation for the pattern it matched. 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). +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). Steps 1–2 were run against this exact tree to write this README: `bun install` completes clean, and `bun run guardian` prints the line above. Steps 3–4 were verified the same way the project's own tests verify them — piping a Claude Code–shaped `PreToolUse` payload on stdin straight into the hook shim against a running Guardian: diff --git a/docs/demos/v1-runbook.md b/docs/demos/v1-runbook.md index 8d91862b..b1b4b18a 100644 --- a/docs/demos/v1-runbook.md +++ b/docs/demos/v1-runbook.md @@ -6,6 +6,8 @@ **Framing correction (R4.4) — read before you narrate this.** The AGT stock bundle ships **no** shell or command patterns of its own — `patterns.rego` carries generic PII regexes only. What is stock is the *deciding module* (`agt.patterns`) and the priority chain in `agt_default.rego`; the destructive-command regex list this demo denies against is this project's own configuration, supplied as data (`policy/lib/data.json`), not authored Rego. Narrate this as "AGT's stock policy engine, configured" — never as "Microsoft ships an `rm -rf` deny-list." R2.1 still holds exactly: zero Rego authored, behaviour driven only through `data.agt.defaults.config`. +**Framing correction (fix wave finding 7) — the handshake declares, it does not negotiate.** Before `steps/toolCallRequest`, the adapter sends a real `handshake/hello` ClientHello (`packages/host-adapter/src/handshake.ts`), and the Guardian answers with a ServerHello (`packages/guardian/src/handshake.ts`). In V1 that ServerHello's `negotiated_version` and `selected_transport` are constants the Guardian returns unconditionally — it never reads what the ClientHello proposed. So don't narrate this as version/transport negotiation actually happening; it is a fixed declaration on both ends, correct for this slice's single Guardian/single host pairing, but not the negotiation the field names imply. Real negotiation (picking a mutually-supported version or transport, refusing when none exists) is future work, not built here. + ## What a viewer should watch for 1. You ask Claude Code to run a destructive shell command. diff --git a/hosts/claude-code/claude-code.hookmap.yaml b/hosts/claude-code/claude-code.hookmap.yaml index dc2dda14..d4f5aaba 100644 --- a/hosts/claude-code/claude-code.hookmap.yaml +++ b/hosts/claude-code/claude-code.hookmap.yaml @@ -14,9 +14,22 @@ hooks: acs_method: steps/toolCallRequest tool_name: $.tool_name arguments: $.tool_input +# Fix wave finding 5: Claude Code's PreToolUse hookSpecificOutput only +# accepts permissionDecision "allow" | "deny" | "ask" (see Claude Code's own +# hook docs) -- there is no "defer" value on the wire, so `defer: +# { permissionDecision: defer }` was never a legal Claude Code output. It +# was unreachable in V1 (no verdict in mapping.yaml produces an ACS +# "defer" decision today) but would have broken the moment one did. +# ACS's own defer-details.json defaults an unresolved defer's +# `timeout_decision` to "deny", so `deny` is the spec-consistent fail-closed +# choice for a host (like Claude Code) with no native defer/pending state -- +# NOT a considered posture, just the least-wrong mapping until a host that +# can actually represent deferral exists. V3, which owns posture and +# disposition decisions (N27, N6/N7), should treat this as a known gap +# rather than rediscovering it. decisions: allow: { permissionDecision: allow } deny: { permissionDecision: deny, reason_from: reasoning } - ask: { permissionDecision: ask } - defer: { permissionDecision: defer } + ask: { permissionDecision: ask, reason_from: reasoning } + defer: { permissionDecision: deny, reason_from: reasoning } modify: { permissionDecision: allow, updatedInput_from: modifications } diff --git a/packages/guardian/src/handshake.ts b/packages/guardian/src/handshake.ts index 667479ca..0779a1fd 100644 --- a/packages/guardian/src/handshake.ts +++ b/packages/guardian/src/handshake.ts @@ -10,6 +10,17 @@ * D8: V1 only NEGOTIATES and STORES this value on the wire -- applying * the posture (the fail-open audit path, N6/N7) is V3. Nothing here * reads or acts on it beyond returning it. + * + * Fix wave finding 7 (honesty, not a scope increase): this responder never + * reads the incoming ClientHello -- the client (host-adapter's `handshake`) + * genuinely sends one, but every field below is a constant, returned + * unconditionally. So "negotiated_version" and "selected_transport" are + * DECLARED by this Guardian, not actually negotiated against what the + * client proposed. That distinction matters in a reference implementation + * of a wire *contract*. Real negotiation (reading ClientHello, picking a + * mutually-supported version/transport, rejecting what isn't) is future + * work, not attempted here -- see the matching note in + * docs/demos/v1-runbook.md. */ export type ServerHello = { diff --git a/packages/guardian/src/server.ts b/packages/guardian/src/server.ts index c424092e..84a0a877 100644 --- a/packages/guardian/src/server.ts +++ b/packages/guardian/src/server.ts @@ -9,6 +9,22 @@ * bridge.evaluate("pre_tool_call", snapshot) (Task 2) -> * mapVerdict(verdict, mapping) (Task 3) -> response envelope. * + * assembleSnapshot / bridge.evaluate / mapVerdict are wrapped in a try/catch + * (fix wave finding 1): an unhandled throw here -- e.g. mapVerdict's own + * require_policy_references check, or a genuine AGT runtime error -- would + * otherwise escape this handler, and Bun.serve's default error page for an + * unhandled fetch() rejection is `text/html`, not JSON-RPC. The client + * (packages/host-adapter/src/guardian-client.ts) calls `res.json()` + * unconditionally, so an HTML body throws a SyntaxError there instead of + * surfacing a JSON-RPC error -- and hosts/claude-code/acs-hook.ts's catch-all + * then exits 1 with nothing on stdout, which Claude Code treats as "the hook + * didn't fire": the tool call proceeds **ungoverned**. That is a fail-open in + * a governance tool. The catch below only guarantees a well-formed JSON-RPC + * error reaches the client -- it deliberately does NOT turn the failure into + * an ACS `deny` decision. That is N27 (denyOnInvalidEnvelope), scoped to V3; + * deciding what disposition an evaluation failure carries is V3's call, not + * this fix's. + * * The bridge (N31: AgentControl.fromPath) and the mapping table are both * constructed/loaded exactly once, at startGuardian() call time -- not per * request -- since AGT is meant to be built at boot and evaluated @@ -31,15 +47,20 @@ const ACS_PATH = "/acs"; * ACS reserves -32000..-32099 for application errors (Specification §17), * but only enumerates named codes -32000..-32007 in the §17.1 registry * (SESSION_REFUSED, UNSUPPORTED_VERSION, ...) -- none of which names "the - * envelope failed schema validation" or "this method isn't dispatched by - * this Guardian". Rather than reach for the generic JSON-RPC codes that - * would otherwise fit (-32602 Invalid params, -32601 Method not found), - * this module mints two codes from the unused part of the reserved band, - * keeping every application-level failure inside -32000..-32099 per this - * task's explicit instruction. + * envelope failed schema validation", "this method isn't dispatched by this + * Guardian", or "evaluation itself failed". Rather than reach for the + * generic JSON-RPC codes that would otherwise fit (-32602 Invalid params, + * -32601 Method not found), this module mints three codes from the unused + * part of the reserved band, keeping every application-level failure inside + * -32000..-32099 per this task's explicit instruction. */ const ENVELOPE_INVALID_CODE = -32010; const METHOD_NOT_DISPATCHED_CODE = -32011; +/** A throw from assembleSnapshot, bridge.evaluate, or mapVerdict -- e.g. + * mapVerdict's own require_policy_references check, or any AGT runtime + * error. See fix wave finding 1's comment above handleAcsRequest's + * TOOL_CALL_REQUEST_METHOD branch for why this must never be dead code. */ +const EVALUATION_FAILED_CODE = -32020; type JsonRpcSuccess = { jsonrpc: "2.0"; id: string | number; result: Record }; type JsonRpcFailure = { @@ -48,13 +69,23 @@ type JsonRpcFailure = { error: { code: number; message: string; data?: unknown }; }; -export type StartGuardianOptions = { port: number; manifestPath: string }; +export type StartGuardianOptions = { + port: number; + manifestPath: string; + /** Overrides the mapping.yaml path this Guardian loads. Defaults to the + * repo's real mapping.yaml; exists so tests can inject a deliberately + * misconfigured mapping (e.g. require_policy_references on a decision + * AGT emits with no reason) to exercise the evaluation-failure catch in + * handleAcsRequest against a real bridge, without touching the mapping + * every other consumer reads. Not meant for production use. */ + mappingPath?: string; +}; export type StartedGuardian = { url: string; close(): Promise }; -export async function startGuardian({ port, manifestPath }: StartGuardianOptions): Promise { +export async function startGuardian({ port, manifestPath, mappingPath }: StartGuardianOptions): Promise { // N31 -- construct the bridge once at boot, not per request. const bridge = createBridge(manifestPath); - const mapping = loadMapping(MAPPING_PATH); + const mapping = loadMapping(mappingPath ?? MAPPING_PATH); const server = Bun.serve({ port, @@ -110,17 +141,26 @@ async function handleAcsRequest( } if (envelope.method === TOOL_CALL_REQUEST_METHOD) { - const snapshot = assembleSnapshot(envelope); - const { verdict } = await bridge.evaluate("pre_tool_call", snapshot); - const decision = mapVerdict(verdict, mapping); - - const result: Record = { - type: "final", - acs_version: envelope.params.acs_version, - request_id: envelope.params.request_id, - ...decision, - }; - return successResponse(envelope.id, result); + try { + const snapshot = assembleSnapshot(envelope); + const { verdict } = await bridge.evaluate("pre_tool_call", snapshot); + const decision = mapVerdict(verdict, mapping); + + const result: Record = { + type: "final", + acs_version: envelope.params.acs_version, + request_id: envelope.params.request_id, + ...decision, + }; + return successResponse(envelope.id, result); + } catch (error) { + // Fix wave finding 1 -- see the module-level comment above. This is + // deliberately a bare JSON-RPC error, not an ACS `deny` decision + // (N27 stays V3's call): the fix here is only that the client gets a + // parseable envelope back instead of an HTML 500. + const message = error instanceof Error ? error.message : String(error); + return errorResponse(rpcId, EVALUATION_FAILED_CODE, `evaluation failed: ${message}`); + } } // A well-formed envelope (it passed validateEnvelope: the method matched diff --git a/packages/guardian/test/fixtures/mapping.require-policy-references-on-allow.yaml b/packages/guardian/test/fixtures/mapping.require-policy-references-on-allow.yaml new file mode 100644 index 00000000..38dc50a3 --- /dev/null +++ b/packages/guardian/test/fixtures/mapping.require-policy-references-on-allow.yaml @@ -0,0 +1,41 @@ +# Test fixture only -- NOT read by production code. Identical to the real +# mapping.yaml except `allow` is (wrongly) marked require_policy_references, +# so a real AGT "allow" verdict for a benign command (which carries no +# `reason`/`message`, per bridge.test.ts) makes mapVerdict throw for real. +# +# Used by packages/guardian/test/server.test.ts (fix wave finding 1) to +# prove the Guardian's evaluation-failure catch turns a genuine +# assembleSnapshot/bridge.evaluate/mapVerdict throw into a well-formed +# JSON-RPC error in the ACS-reserved range, not an unhandled exception that +# Bun.serve would otherwise turn into a text/html 500. +acs_version: "0.1.0" +agt_version: "0.3.1-beta" + +intervention_points: + pre_tool_call: { acs_method: "steps/toolCallRequest" } + post_tool_call: { acs_method: "steps/toolCallResult" } + agent_startup: { acs_method: "steps/sessionStart" } + agent_shutdown: { acs_method: "steps/sessionEnd" } + input: { acs_method: "steps/userMessage" } + output: { acs_method: "steps/agentResponse" } + pre_model_call: { acs_method: null, note: "no ACS v0.1.0 target — D4, V7 red cell" } + post_model_call: { acs_method: null, note: "no ACS v0.1.0 target — D4, V7 red cell" } + +verdicts: + allow: { decision: allow, require_policy_references: true } + deny: { decision: deny } + warn: { decision: allow, require_policy_references: true } + escalate: { decision: ask } + transform: { decision: modify } + +field_synthesis: + reasoning: + source: verdict.message + reason_codes: + source: verdict.reason + wrap: array + policy_references: + rule_id: + source: verdict.reason + policy_id: + literal: agt_stock diff --git a/packages/guardian/test/map-verdict.test.ts b/packages/guardian/test/map-verdict.test.ts index a302c25d..010b3b9f 100644 --- a/packages/guardian/test/map-verdict.test.ts +++ b/packages/guardian/test/map-verdict.test.ts @@ -42,4 +42,14 @@ describe("mapVerdict", () => { expect(out.toLowerCase()).toBe(out); } }); + + // Fix wave finding 1 -- previously-deferred coverage gap: this throw path + // (require_policy_references marked true, but no policy_references could + // be synthesized) had no test. It's real: a "warn" verdict with no + // `reason` hits it directly, and it's exactly what the Guardian's + // evaluation-failure catch (server.test.ts) now 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 (R1.2's load-bearing check)", () => { + expect(() => mapVerdict({ decision: "warn" }, m)).toThrow(/require_policy_references/); + }); }); diff --git a/packages/guardian/test/server.test.ts b/packages/guardian/test/server.test.ts index a94fd1ec..fed03aa4 100644 --- a/packages/guardian/test/server.test.ts +++ b/packages/guardian/test/server.test.ts @@ -143,3 +143,43 @@ describe("startGuardian POST /acs", () => { expect(response.error?.code).toBeLessThanOrEqual(-32000); }); }); + +// Fix wave finding 1 -- a real fail-open bug: an unhandled throw from +// assembleSnapshot/bridge.evaluate/mapVerdict inside handleAcsRequest used +// to escape uncaught, and Bun.serve's default error page for a rejected +// fetch() is `text/html`, not JSON. guardianClient.post's `res.json()` would +// then throw a SyntaxError instead of surfacing a JSON-RPC error, and +// acs-hook.ts's catch-all exits 1 with nothing on stdout -- Claude Code +// treats that as "the hook never fired" and the tool call proceeds +// ungoverned. This guards the fix, against a real (not mocked) AGT +// evaluation -- only mapping.yaml is swapped for a fixture that marks +// `allow` require_policy_references, so a genuine AGT "allow" verdict for a +// benign command (which carries no reason/message) makes mapVerdict throw +// inside handleAcsRequest for real. +describe("startGuardian POST /acs -- evaluation failure inside handleAcsRequest", () => { + it("a real mapVerdict throw (require_policy_references unmet) still returns a parseable JSON-RPC error in -32000..-32099, not an HTML 500", async () => { + const guardian = await startGuardian({ + port: 0, + manifestPath: "policy/manifest.yaml", + mappingPath: "packages/guardian/test/fixtures/mapping.require-policy-references-on-allow.yaml", + }); + + try { + // res.json() below is exactly guardianClient.post's call. Before the + // fix, Bun.serve's unhandled-rejection page is text/html and this + // throws a SyntaxError instead of resolving -- the same failure mode + // the finding describes at guardian-client.ts:70. + const response = await postAcs(guardian.url, toolCallEnvelope("ls -la")); + + expect(response.result).toBeUndefined(); + expect(response.error).toBeDefined(); + expect(response.error?.code).toBeGreaterThanOrEqual(-32099); + expect(response.error?.code).toBeLessThanOrEqual(-32000); + // Never a decision -- same scope boundary as the schema-validation + // guard above (N27 is V3's call, not this fix's). + expect((response as Record).decision).toBeUndefined(); + } finally { + await guardian.close(); + } + }); +}); diff --git a/slices/v1/README.md b/slices/v1/README.md index 70b6abba..e82f94fb 100644 --- a/slices/v1/README.md +++ b/slices/v1/README.md @@ -6,4 +6,8 @@ **Affordances:** U1, U2, N1-N5, N20, N21, N23, N24, N28, N30, N31, S1, S7-S11, S13 — defined in [Detail C](../../docs/shaping/acs-reference-impl-shaping.md#detail-c-affordances). -Implementation goes here. +## What this slice delivers + +A single Claude Code `PreToolUse` hook (`hosts/claude-code/acs-hook.ts`) that speaks ACS over the wire to a Guardian process (`packages/guardian`), which evaluates every `steps/toolCallRequest` through AGT's unforked stock policy engine (`packages/agt-bridge`, `policy/lib`) and returns a real decision. The demo above is not staged: `docs/demos/v1-runbook.md` walks through starting the Guardian, wiring the hook into a real `claude` session, and watching a destructive shell command get denied with the policy engine's own reasoning text in the transcript — then contrasts it with a harmless command running normally. + +Only `pre_tool_call` is wired; there is no session state, no envelope tap, and no second host in this slice (see the watch-for and parked-items rows in the slices doc §V1). The implementation plan this slice followed, task by task, is `docs/superpowers/plans/2026-08-09-v1-one-host-one-hook.md`. diff --git a/test/pin.test.ts b/test/pin.test.ts index 7674e3b1..84dd4f72 100644 --- a/test/pin.test.ts +++ b/test/pin.test.ts @@ -36,14 +36,26 @@ describe("AGT pin", () => { expect(extra).toEqual([]); }); - it("authors no Rego of our own — every .rego is byte-identical to upstream", () => { - // UPSTREAM_BUNDLE is set by `bun run verify:pin`, which clones the pinned ref. - const upstream = process.env.UPSTREAM_BUNDLE; - if (!upstream) return; // skipped in the fast unit run; enforced by verify:pin in CI - for (const f of readdirSync("policy/lib").filter((f) => f.endsWith(".rego"))) { - expect(readFileSync(join("policy/lib", f), "utf8")).toBe( - readFileSync(join(upstream, f), "utf8"), - ); - } - }); + // UPSTREAM_BUNDLE is set by `bun run verify:pin`, which clones the pinned + // ref and needs network access to GitHub -- unavailable in the fast unit + // run. Fix wave finding 4: a bare early `return` here used to make this + // report green while asserting nothing, silently un-guarding the + // project's central "AGT runs unforked" claim. `it.skipIf` instead makes + // bun report this test as SKIPPED, not passed -- unmistakable in output -- + // without failing when the env var is legitimately absent (offline dev), + // and without deleting the assertion `bun run verify:pin` still enforces. + it.skipIf(!process.env.UPSTREAM_BUNDLE)( + "authors no Rego of our own — every .rego is byte-identical to upstream", + () => { + const upstream = process.env.UPSTREAM_BUNDLE; + if (!upstream) { + throw new Error("UPSTREAM_BUNDLE must be set to run this assertion — see `bun run verify:pin`"); + } + for (const f of readdirSync("policy/lib").filter((f) => f.endsWith(".rego"))) { + expect(readFileSync(join("policy/lib", f), "utf8")).toBe( + readFileSync(join(upstream, f), "utf8"), + ); + } + }, + ); }); From b9d54c65996ab3c5f12e42814cd0502f5ceab616 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 21:44:58 +0300 Subject: [PATCH 16/39] Correct the README test count after the fix wave The final-review fix wave added two tests, so the documented count was stale at 64 and made no mention of the skip. Both now stated accurately. Caught by the scoped re-review of that fix wave -- a README corrected for honesty should not itself carry a wrong number. Slice: #2 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f139f641..9da15ebe 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,8 @@ which is exactly the JSON Claude Code's own hook protocol sends and expects back ### Verify ```bash -bun test # 64 tests across 11 files, including the R3.2/R3.3 architectural gates below +bun test # 66 tests across 11 files (65 pass, 1 skip), including the R3.2/R3.3 gates below + # the skip is the byte-identity check, which needs UPSTREAM_BUNDLE — see verify:pin bun run typecheck # whole-workspace strict TypeScript check, zero errors ``` From 2d4fb824142d3d6254bec1e8bda45c84d44c6ae3 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 21:52:18 +0300 Subject: [PATCH 17/39] Record two cross-slice findings V1 execution surfaced Both are things a task-scoped review could not see, found by the final whole-branch review. V3 -- S13 has no home across processes. V1 built the negotiated session config as an in-process store, but the Claude Code shim is a fresh subprocess per hook, so it can never survive to the next invocation. N6 applyFailurePosture reads S13, so V3 is blocked until this is decided: persist per session, or run a session-scoped daemon. V5's in-process host would not share the constraint, and V6 sits on the same seam. V7 -- the Guardian's outbound envelopes are validated by nothing. Inbound gets Ajv over all 43 v0.1.0 schemas; responses are hand-built and unchecked. The conformance harness would be measuring a wire format that was never itself contract-checked, which weakens the very claim it exists to prove. Also noted for V7: response-envelope.json's result unconditionally $refs AcsResult, which requires `decision` -- a ServerHello has no such field, so a handshake response cannot satisfy it. That reads as a real v0.1.0 gap rather than an implementation choice. Slice: #2 --- docs/shaping/acs-reference-impl-slices.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/shaping/acs-reference-impl-slices.md b/docs/shaping/acs-reference-impl-slices.md index 99b80be7..58e5bbc4 100644 --- a/docs/shaping/acs-reference-impl-slices.md +++ b/docs/shaping/acs-reference-impl-slices.md @@ -102,6 +102,8 @@ Every slice ends in something demo-able. **Two failure domains, kept separate.** AGT fails closed on *evaluation* — bad policy output, invalid transform, missing paths — and that produces a `deny` **verdict**, which §6.4 says the host MUST honor regardless of posture. N27 exists so Guardian-side failures also arrive as decisions rather than bare errors, keeping them in that honored path. `on_decision_failure` only governs *delivery*: Guardian silent, transport dead, error with no decision. Conflating the two would either break AGT's invariant or halt production on a network blip. +**⚠️ Blocker discovered in V1 — S13 has no home across processes.** V1 built `S13` as an in-process store, but the Claude Code shim is a **fresh subprocess per hook invocation**, so an in-memory negotiated session config can never survive to the next hook. `handshake()` is also not called on the real path in V1 at all. `N6 applyFailurePosture()` reads S13, so V3 cannot work until this is resolved: either persist the negotiated config (a session-keyed file), or have the shim talk to a session-scoped daemon. The choice ripples — V5's second host is in-process and would not share the constraint, and V6's session chain sits on the same seam. Decide this before V3 starts. + **Rest of the slice is data, not structure.** Disposition coverage lives in S1 (every ACS decision → `permissionDecision` / `updatedInput`) and S8 (stock rules configured to actually fire allow, deny, escalate, transform, and drift-warn). Once the adapter is generic, coverage is configuration. Wire N21's error branch to N27, and N4's return through N7 here. @@ -185,6 +187,8 @@ Wire N21 → N22 → N23 in place of V1's direct N21 → N23. | N47 | P5 | conformance | `renderMatrix()` | call | → U30 | — | | N48 | P5 | conformance | `renderMappingTable()` | call | → U32 | — | +**⚠️ Gap discovered in V1 — the Guardian's outbound envelopes are validated by nothing.** Inbound requests get Ajv against all 43 v0.1.0 schemas (N21), but responses are hand-built objects checked by no schema. The conformance harness would therefore measure a wire format that was never itself contract-checked — which quietly weakens exactly the claim C2 exists to prove. Add response validation before the matrix is published. Related: V1 found that `response-envelope.json`'s `result` unconditionally `$ref`s `AcsResult`, which requires `decision` — a ServerHello has no such field, so a handshake response cannot satisfy it. That looks like a genuine v0.1.0 spec gap (no discriminated union for non-decision methods) and is worth an upstream ACS issue, not just a red cell. + **Expect two cells to be honestly red.** `pre_model_call` and `post_model_call` have no ACS v0.1.0 target — see D4. Red cells with a stated reason are worth more than a green matrix that quietly redefines the claim, and they are the forcing function for `steps/modelCall` in v0.2. R5.3 lands here: the matrix *is* the profile declaration. From 104f29f412b8a65913c62e898e2831a515d16cf1 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Tue, 11 Aug 2026 10:52:39 +0300 Subject: [PATCH 18/39] Pin the shim's exact stdout for every decision the hookmap declares Before any of PR #10's rendering findings move a field name, pin what a Claude Code process actually reads back: the whole JSON object, as a literal, for all five decisions hosts/claude-code/claude-code.hookmap.yaml declares -- allow, deny, ask, defer, modify -- written against the real shim as a subprocess and a stub Guardian, so the pin depends on the wire contract and not on how the rendering is factored behind it. The two renderings V1 gets arguably wrong (an allow or a modify that carries reasoning says nothing in the transcript; a modify hands Claude Code ACS's `modifications` shape rather than a tool input) are pinned as they ARE, with a comment saying so. Changing either is a behaviour change and now has to be a deliberate edit to a literal here. Addresses: #10 review --- README.md | 2 +- hosts/claude-code/test/wire-shape.test.ts | 174 ++++++++++++++++++++++ 2 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 hosts/claude-code/test/wire-shape.test.ts diff --git a/README.md b/README.md index 9da15ebe..163b454f 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ which is exactly the JSON Claude Code's own hook protocol sends and expects back ### Verify ```bash -bun test # 66 tests across 11 files (65 pass, 1 skip), including the R3.2/R3.3 gates below +bun test # 72 tests across 12 files (71 pass, 1 skip), including the R3.2/R3.3 gates below # the skip is the byte-identity check, which needs UPSTREAM_BUNDLE — see verify:pin bun run typecheck # whole-workspace strict TypeScript check, zero errors ``` diff --git a/hosts/claude-code/test/wire-shape.test.ts b/hosts/claude-code/test/wire-shape.test.ts new file mode 100644 index 00000000..59abee5f --- /dev/null +++ b/hosts/claude-code/test/wire-shape.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "bun:test"; +import { fileURLToPath } from "node:url"; + +/** + * The regression pin for what this deployment actually puts on stdout. + * + * Every other test in this directory asserts one field at a time ("the + * decision is deny", "the reason is a non-empty string"), which is exactly the + * shape of assertion that survives an output quietly gaining, losing or moving + * a field. This file asserts the WHOLE JSON object, as a literal, for every + * decision the shipped hookmap declares -- allow, deny, ask, defer and modify + * -- so that any change to the rendered wire shape has to be a deliberate edit + * to the literals below rather than something a refactor can do by accident. + * + * It is written against the shim as a subprocess, the way Claude Code invokes + * it, and against the real hosts/claude-code/claude-code.hookmap.yaml. That is + * deliberate: it makes this pin independent of how the rendering is factored + * internally -- which module names the host's fields, which layer assembles + * the wrapper -- and dependent only on what a host process reads back. It is + * the contract; the factoring behind it is not. + * + * The Guardian here is a stub rather than the real one. The five decisions + * below are the five the hookmap knows how to render, and the shipped policy + * bundle only ever produces two of them; a pin that could only cover `allow` + * and `deny` would leave the three rarest renderings -- the ones nobody looks + * at, and the ones a careless change breaks first -- unpinned. + * + * V1 SCOPE: the shim makes exactly one call, the step call. It does not + * handshake (N5 is exercised from packages/host-adapter/test/client.test.ts at + * this slice, not from the shim), so the stub answers one method and needs no + * ServerHello. When a later slice makes the shim negotiate first, this stub + * grows a `handshake/hello` branch -- the assertions below should not move. + */ +const SHIM = fileURLToPath(new URL("../acs-hook.ts", import.meta.url)); + +/** + * Runs the real shim as a subprocess against a stub Guardian that answers the + * step call with `decision`, and returns the parsed JSON it wrote to stdout. + * + * Asserts exit 0 and a silent stderr first: a decision that renders correctly + * while the process also prints a stack trace, or exits non-zero, is not a + * pass -- and a failure there prints what was actually on stderr rather than + * an opaque JSON.parse error. + */ +async function renderedBy(decision: Record, command: string): Promise { + const stub = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as { id: string | number }; + return Response.json({ jsonrpc: "2.0", id: body.id, result: decision }); + }, + }); + try { + const proc = Bun.spawn(["bun", "run", SHIM], { + stdin: new TextEncoder().encode( + JSON.stringify({ + session_id: "sess-1", + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command }, + }), + ), + env: { ...process.env, ACS_GUARDIAN_URL: `http://localhost:${stub.port}/acs` }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + expect({ exitCode: await proc.exited, stderr }).toEqual({ exitCode: 0, stderr: "" }); + return JSON.parse(stdout); + } finally { + stub.stop(true); + } +} + +describe("the wire shape this host writes to stdout, pinned decision by decision", () => { + it("renders a plain allow as a bare allow with no other field", async () => { + expect(await renderedBy({ decision: "allow", reason_codes: [], policy_references: [] }, "ls -la")).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + }, + }); + }); + + it("renders an allow that carries reasoning as a bare allow too -- this hookmap's allow names no reason field", async () => { + // The shape an observe-only upstream signal produces (R1.2): an ACS allow + // with a synthesized explanation and non-empty policy_references. At V1 + // the hookmap's `allow` entry declares no reason source, so neither the + // explanation nor the references reach the transcript, and this rendering + // is indistinguishable from a plain allow. Pinned as it IS, not as it + // arguably should be: giving `allow` a reason is a behaviour change, and + // whichever slice decides to make it has to edit this literal to say so. + expect( + await renderedBy( + { + decision: "allow", + reasoning: "drift_score 0.9 reached threshold 0.5", + reason_codes: ["drift_detected"], + policy_references: [{ policy_id: "agt_stock", rule_id: "drift_detected" }], + }, + "ls -la", + ), + ).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + }, + }); + }); + + it("renders a deny with the policy's own reasoning -- the demo's entire payoff", async () => { + expect( + await renderedBy({ decision: "deny", reasoning: "blocked by policy", reason_codes: ["destructive"] }, "rm -rf /"), + ).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "blocked by policy", + }, + }); + }); + + it("renders an ask as an ask, carrying its reasoning", async () => { + expect( + await renderedBy({ decision: "ask", reasoning: "this needs a human" }, "curl example.com"), + ).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "ask", + permissionDecisionReason: "this needs a human", + }, + }); + }); + + it("renders a defer as a deny -- this host has no deferral state", async () => { + expect( + await renderedBy({ decision: "defer", reasoning: "waiting on an out-of-band approval" }, "curl example.com"), + ).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "waiting on an out-of-band approval", + }, + }); + }); + + it("renders a modify as an allow carrying the ACS modifications object verbatim as updatedInput", async () => { + // Verbatim, and that is a known V1 gap rather than a target: Claude Code's + // `updatedInput` is a tool-input object, and what lands here is ACS's + // `modifications` shape. The hookmap's `modify` entry names no reason + // source either, so a policy-ordered rewrite says nothing in the + // transcript. Both are pinned as they are; V3 owns the disposition work + // that would change them, and changing them means editing this literal. + expect( + await renderedBy( + { + decision: "modify", + reasoning: "redaction_applied", + modifications: { parameter_overrides: { command: "echo [REDACTED]" } }, + }, + "echo ghp_SECRET123456", + ), + ).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + updatedInput: { parameter_overrides: { command: "echo [REDACTED]" } }, + }, + }); + }); +}); From 03a4cd1ec01a3e50009dc0feb7a86544678efdea Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Tue, 11 Aug 2026 10:55:19 +0300 Subject: [PATCH 19/39] Name the validated envelope for what it is: an ACS request of any method `validateEnvelope` validates every ACS method against request-envelope.json and only `steps/toolCallRequest` against the hook-specific payload schema -- but it returned all of them, handshake traffic included, typed as `ToolCallRequestEnvelope`. The name claimed a check the module had not performed, and it was also the consumer half of a message the host already builds as `AcsRequestEnvelope`: one wire message, two nouns, and the consumer's the misleading one. The general type now carries the host's noun, and the tool-call shape is a method-narrowed view of it reachable only through `isToolCallRequest` -- which lives beside the payload check it stands for, so the two cannot come to disagree about which method carries a tool-call payload. The Guardian's own dispatch narrows through it, which is what lets `assembleSnapshot` keep taking the tool-call view rather than any request at all. Addresses: #10 review --- packages/guardian/src/assemble-snapshot.ts | 5 + packages/guardian/src/index.ts | 8 +- packages/guardian/src/server.ts | 20 ++- packages/guardian/src/validate-envelope.ts | 145 +++++++++++++----- .../guardian/test/validate-envelope.test.ts | 11 +- 5 files changed, 143 insertions(+), 46 deletions(-) diff --git a/packages/guardian/src/assemble-snapshot.ts b/packages/guardian/src/assemble-snapshot.ts index 0241954f..c420a327 100644 --- a/packages/guardian/src/assemble-snapshot.ts +++ b/packages/guardian/src/assemble-snapshot.ts @@ -14,6 +14,11 @@ * working. Task 4 had declared a local, narrower type as a temporary seam; * this closes it so there's exactly one envelope shape, not two that could * silently diverge. + * + * Since the PR #10 review that name means what it says: the tool-call view of + * a validated ACS request, reachable only through `isToolCallRequest`. This + * function takes exactly that, so its signature no longer accepts a + * `handshake/hello` it would read `params.payload.tool.name` off. */ import type { ToolCallRequestEnvelope } from "./validate-envelope.ts"; export type { ToolCallRequestEnvelope }; diff --git a/packages/guardian/src/index.ts b/packages/guardian/src/index.ts index d7d32287..b5c5c4fe 100644 --- a/packages/guardian/src/index.ts +++ b/packages/guardian/src/index.ts @@ -1,6 +1,12 @@ /** Public surface of the guardian package. */ export { startGuardian, type StartGuardianOptions, type StartedGuardian } from "./server.ts"; export { handshakeResponder, type ServerHello } from "./handshake.ts"; -export { validateEnvelope, EnvelopeValidationError, type ToolCallRequestEnvelope } from "./validate-envelope.ts"; +export { + validateEnvelope, + isToolCallRequest, + EnvelopeValidationError, + type AcsRequestEnvelope, + type ToolCallRequestEnvelope, +} from "./validate-envelope.ts"; export { assembleSnapshot } from "./assemble-snapshot.ts"; export { loadMapping, mapVerdict, type Mapping, type AcsDecision } from "./map-verdict.ts"; diff --git a/packages/guardian/src/server.ts b/packages/guardian/src/server.ts index 84a0a877..52080873 100644 --- a/packages/guardian/src/server.ts +++ b/packages/guardian/src/server.ts @@ -34,13 +34,17 @@ import { fileURLToPath } from "node:url"; import { createBridge } from "agt-bridge"; import { assembleSnapshot } from "./assemble-snapshot.ts"; import { loadMapping, mapVerdict, type Mapping } from "./map-verdict.ts"; -import { EnvelopeValidationError, validateEnvelope, type ToolCallRequestEnvelope } from "./validate-envelope.ts"; +import { + EnvelopeValidationError, + isToolCallRequest, + validateEnvelope, + type AcsRequestEnvelope, +} from "./validate-envelope.ts"; import { handshakeResponder } from "./handshake.ts"; const MAPPING_PATH = fileURLToPath(new URL("../../../mapping.yaml", import.meta.url)); const HANDSHAKE_METHOD = "handshake/hello"; -const TOOL_CALL_REQUEST_METHOD = "steps/toolCallRequest"; const ACS_PATH = "/acs"; /** @@ -59,7 +63,7 @@ const METHOD_NOT_DISPATCHED_CODE = -32011; /** A throw from assembleSnapshot, bridge.evaluate, or mapVerdict -- e.g. * mapVerdict's own require_policy_references check, or any AGT runtime * error. See fix wave finding 1's comment above handleAcsRequest's - * TOOL_CALL_REQUEST_METHOD branch for why this must never be dead code. */ + * tool-call branch for why this must never be dead code. */ const EVALUATION_FAILED_CODE = -32020; type JsonRpcSuccess = { jsonrpc: "2.0"; id: string | number; result: Record }; @@ -121,7 +125,7 @@ async function handleAcsRequest( const rpcId = extractId(raw); - let envelope: ToolCallRequestEnvelope; + let envelope: AcsRequestEnvelope; try { // validateEnvelope (N21) checks the general request-envelope.json shape // for every method, plus -- only for steps/toolCallRequest -- the @@ -140,7 +144,13 @@ async function handleAcsRequest( return successResponse(envelope.id, handshakeResponder()); } - if (envelope.method === TOOL_CALL_REQUEST_METHOD) { + // `isToolCallRequest`, not a method comparison spelled out again here: the + // predicate lives beside the payload check it stands for + // (validate-envelope.ts), so this branch cannot come to disagree with the + // module that decided whether `params.payload` was validated as a tool + // call. It also narrows the envelope, which is what lets assembleSnapshot + // take the tool-call view rather than any request at all. + if (isToolCallRequest(envelope)) { try { const snapshot = assembleSnapshot(envelope); const { verdict } = await bridge.evaluate("pre_tool_call", snapshot); diff --git a/packages/guardian/src/validate-envelope.ts b/packages/guardian/src/validate-envelope.ts index b988885b..363a3b90 100644 --- a/packages/guardian/src/validate-envelope.ts +++ b/packages/guardian/src/validate-envelope.ts @@ -5,6 +5,13 @@ * `steps/toolCallRequest` -- additionally against the hook-specific * payload schema (hooks/tool-call-request.json). * + * It returns an `AcsRequestEnvelope`: a request of ANY method, named the + * same thing the host's own builder names it + * (packages/host-adapter/src/build-envelope.ts). Narrowing to the tool-call + * view is a separate, explicit step (`isToolCallRequest`), so a method this + * module validated only generically can never be read as one whose + * hook-specific payload was checked. + * * Failure is a THROWN, typed EnvelopeValidationError -- never a returned * decision. Turning a rejection into an explicit ACS "deny" decision is * N27, which belongs to a later slice (V3). The caller (Task 6, the @@ -21,48 +28,93 @@ import type { ErrorObject, ValidateFunction } from "ajv"; /** An ACS argument wrapper: `{value, provenance?}`. */ export type AcsArgument = { value: unknown; provenance?: unknown }; +/** request-envelope.json's Metadata $def -- method-independent. */ +export type AcsRequestMetadata = { + agent_id: string; + agent_name?: string; + session_id: string; + turn_id?: string; + parent_turn_id?: string; + session_state?: { chain_hash?: string }; + environment?: "development" | "staging" | "production"; + platform?: string; + platform_version?: string; + user_context?: { user_id?: string; roles?: string[]; authentication_method?: string }; +}; + /** - * The validated shape of a `steps/toolCallRequest` envelope, per - * request-envelope.json's AcsParams/Metadata $defs plus - * hooks/tool-call-request.json's payload shape. + * request-envelope.json's AcsParams $def. `payload` is deliberately as open + * here as the schema itself leaves it -- "Hook-specific payload. Schema + * depends on method." -- because that is the honest shape of a request whose + * method has not been discriminated yet. + */ +export type AcsRequestParams = { + acs_version: string; + request_id: string; + timestamp: string; + nonce?: string; + tenant_id?: string; + metadata: AcsRequestMetadata; + payload: Record; + signature?: { algorithm: string; value: string; key_id: string }; +}; + +/** + * A validated ACS request of ANY method -- `handshake/hello`, + * `steps/toolCallRequest`, or anything else request-envelope.json's method + * pattern admits. This is what `validateEnvelope` returns, and it is the + * consumer half of the host's `AcsRequestEnvelope` + * (packages/host-adapter/src/build-envelope.ts): one wire message, one noun, + * on both sides of the seam. * - * This is the canonical envelope type for the project: assemble-snapshot.ts - * re-exports it rather than declaring its own -- Task 4 left a local, - * narrower `ToolCallRequestEnvelope` there as a temporary seam explicitly - * flagged for reconciliation with this task. + * It used to be called `ToolCallRequestEnvelope` (PR #10 review, Critical, + * and its naming-symmetry companion): every method was typed as a tool call, + * so handshake traffic arrived under a name that lied about it, and the + * consumer's noun was the narrower and more misleading of the two. The + * tool-call shape now lives in `ToolCallRequestEnvelope` below and is + * reachable only after the method has been checked. */ -export type ToolCallRequestEnvelope = { +export type AcsRequestEnvelope = { jsonrpc: "2.0"; method: string; id: string | number; - params: { - acs_version: string; - request_id: string; - timestamp: string; - nonce?: string; - tenant_id?: string; - metadata: { - agent_id: string; - agent_name?: string; - session_id: string; - turn_id?: string; - parent_turn_id?: string; - session_state?: { chain_hash?: string }; - environment?: "development" | "staging" | "production"; - platform?: string; - platform_version?: string; - user_context?: { user_id?: string; roles?: string[]; authentication_method?: string }; - }; - payload: { - tool: { name: string; version?: string; provider?: string }; - operation?: string; - capability?: string; - arguments: Record; - raw_command?: string; - intent?: { description?: string; goal?: string }; - }; - signature?: { algorithm: string; value: string; key_id: string }; - }; + params: AcsRequestParams; +}; + +/** + * hooks/tool-call-request.json's payload shape -- the payload this module + * additionally validates when, and only when, the method is + * `steps/toolCallRequest`. + */ +export type ToolCallRequestPayload = { + tool: { name: string; version?: string; provider?: string }; + operation?: string; + capability?: string; + arguments: Record; + raw_command?: string; + intent?: { description?: string; goal?: string }; +}; + +/** + * The method-narrowed view of an `AcsRequestEnvelope`: same envelope, with + * `method` pinned to the one method whose payload has actually been validated + * against hooks/tool-call-request.json, and `payload` narrowed to that + * schema's shape. + * + * Reachable only through `isToolCallRequest` below, never returned by + * `validateEnvelope` directly. That is the whole point: the narrow type is + * the *conclusion* of a method check, not the type every request is handed + * back as. + * + * Spelled with `Omit` rather than an intersection so `params.payload` is + * exactly `ToolCallRequestPayload`, not `ToolCallRequestPayload & + * Record` -- the intersection type-checks but leaves every + * property lookup resolving against an index signature too, which is how a + * typo silently becomes `unknown` instead of an error. + */ +export type ToolCallRequestEnvelope = Omit & { + method: typeof TOOL_CALL_REQUEST_METHOD; + params: Omit & { payload: ToolCallRequestPayload }; }; /** @@ -169,13 +221,13 @@ function toValidationError(errors: ErrorObject[] | null | undefined, prefix: str * envelope, typed, on success. Throws EnvelopeValidationError on any * failure; never returns a decision. */ -export function validateEnvelope(input: unknown): ToolCallRequestEnvelope { +export function validateEnvelope(input: unknown): AcsRequestEnvelope { const validateTopLevel = getValidator(REQUEST_ENVELOPE_SCHEMA_ID); if (!validateTopLevel(input)) { throw toValidationError(validateTopLevel.errors, ""); } - const envelope = input as ToolCallRequestEnvelope; + const envelope = input as AcsRequestEnvelope; if (envelope.method === TOOL_CALL_REQUEST_METHOD) { const validatePayload = getValidator(TOOL_CALL_REQUEST_SCHEMA_ID); @@ -186,3 +238,20 @@ export function validateEnvelope(input: unknown): ToolCallRequestEnvelope { return envelope; } + +/** + * The one way to get from a validated ACS request to the tool-call view of + * it. Narrows on `method`, which is exactly the condition under which + * `validateEnvelope` above validated `params.payload` against + * hooks/tool-call-request.json -- so the narrowing is not a convenient cast, + * it is a claim the same module already checked. + * + * Kept next to that check on purpose: the two must agree about which method + * carries a tool-call payload, and they can only be trusted to agree while + * they read the same constant in the same file. A second ACS method adds its + * own predicate and its own narrow type beside these, rather than widening + * either. + */ +export function isToolCallRequest(envelope: AcsRequestEnvelope): envelope is ToolCallRequestEnvelope { + return envelope.method === TOOL_CALL_REQUEST_METHOD; +} diff --git a/packages/guardian/test/validate-envelope.test.ts b/packages/guardian/test/validate-envelope.test.ts index fc3ff6f8..ba412e1a 100644 --- a/packages/guardian/test/validate-envelope.test.ts +++ b/packages/guardian/test/validate-envelope.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { EnvelopeValidationError, validateEnvelope } from "../src/validate-envelope.ts"; +import { EnvelopeValidationError, isToolCallRequest, validateEnvelope } from "../src/validate-envelope.ts"; function makeEnvelope(overrides: { method?: string; @@ -47,7 +47,14 @@ describe("validateEnvelope", () => { it("passes a well-formed steps/toolCallRequest envelope", () => { const envelope = makeEnvelope(); - const validated = validateEnvelope(envelope) as { params: { payload: { tool: { name: string } } } }; + // Narrowed through the predicate rather than cast: `validateEnvelope` + // returns a request of any method, and `params.payload.tool` only exists + // on the tool-call view of one. A `throw` rather than an expect, so the + // assertion below stays the assertion this test is about. + const validated = validateEnvelope(envelope); + if (!isToolCallRequest(validated)) { + throw new Error(`validateEnvelope returned a non-tool-call request: ${validated.method}`); + } expect(validated.params.payload.tool.name).toBe("run_shell"); }); From 5a374facc993f171cd112f057a62830bf6f5e7a8 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Tue, 11 Aug 2026 10:59:51 +0300 Subject: [PATCH 20/39] Resolve the AGT intervention point from mapping.yaml instead of hardcoding it mapping.yaml declared the ACS-method-to-AGT-intervention-point table and server.ts passed the literal "pre_tool_call" to bridge.evaluate, so the table was documentation V7's conformance matrix was asked to trust while the runtime ignored it. The two could disagree and nothing would fail. `resolveInterventionPoint(acsMethod, mapping)` inverts the table (the wire hands us a method; AGT wants a point) and throws rather than defaulting: an unmapped method, or two rows claiming one method, has no right answer, and evaluating some other point's policy while calling the result a decision is worse than a reported failure. The gate that makes this more than a refactor is a mapping fixture that answers steps/toolCallRequest with `output` -- a point the manifest does not register. Honouring the table makes AGT fail closed; ignoring it allows a benign command. Verified by mutation: restoring the literal turns that test from deny to allow. Addresses: #10 review --- README.md | 2 +- mapping.yaml | 14 ++++- packages/guardian/src/map-verdict.ts | 57 +++++++++++++++++++ packages/guardian/src/server.ts | 16 ++++-- ...apping.tool-call-at-a-different-point.yaml | 42 ++++++++++++++ packages/guardian/test/map-verdict.test.ts | 34 ++++++++++- packages/guardian/test/server.test.ts | 28 +++++++++ 7 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 packages/guardian/test/fixtures/mapping.tool-call-at-a-different-point.yaml diff --git a/README.md b/README.md index 163b454f..b2411a39 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ which is exactly the JSON Claude Code's own hook protocol sends and expects back ### Verify ```bash -bun test # 72 tests across 12 files (71 pass, 1 skip), including the R3.2/R3.3 gates below +bun test # 76 tests across 12 files (75 pass, 1 skip), including the R3.2/R3.3 gates below # the skip is the byte-identity check, which needs UPSTREAM_BUNDLE — see verify:pin bun run typecheck # whole-workspace strict TypeScript check, zero errors ``` diff --git a/mapping.yaml b/mapping.yaml index 7c86a655..9e711685 100644 --- a/mapping.yaml +++ b/mapping.yaml @@ -15,8 +15,18 @@ acs_version: "0.1.0" agt_version: "0.3.1-beta" # ACS intervention points AGT can be wired to. acs_method: null marks a -# point AGT supports that ACS v0.1.0 has no equivalent for (D4) — read by -# V7, not by the runtime. +# point AGT supports that ACS v0.1.0 has no equivalent for (D4). +# +# READ BY THE RUNTIME, not only by V7 (PR #10 review, Critical). The Guardian +# resolves the AGT intervention point for an arriving ACS method out of this +# table (map-verdict.ts's resolveInterventionPoint, called from server.ts) -- +# it used to hardcode "pre_tool_call" while this table sat here claiming to be +# the mapping, so the two could disagree and nothing would fail. Changing a +# row below now changes which policy actually evaluates that method, which is +# the only condition under which V7's published matrix can be trusted. +# +# One method must map to at most one point: two rows naming the same +# acs_method is a throw, not a first-match win. intervention_points: pre_tool_call: { acs_method: "steps/toolCallRequest" } post_tool_call: { acs_method: "steps/toolCallResult" } diff --git a/packages/guardian/src/map-verdict.ts b/packages/guardian/src/map-verdict.ts index 206b3365..15c084ef 100644 --- a/packages/guardian/src/map-verdict.ts +++ b/packages/guardian/src/map-verdict.ts @@ -42,6 +42,63 @@ export function loadMapping(path: string): Mapping { return Bun.YAML.parse(readFileSync(path, "utf8")) as Mapping; } +/** + * Resolves an ACS method to the AGT intervention point that answers it, from + * mapping.yaml's `intervention_points` table -- the same table V7's + * conformance matrix publishes. + * + * This exists because the table used to be a claim nobody checked: the + * Guardian hardcoded `"pre_tool_call"` at its one call site, so the + * declaration was documentation V7 was asked to trust while the runtime + * ignored it (PR #10 review, Critical, twice -- once against the call site + * and once against the table). The two could disagree without anything + * failing. Now the runtime reads it, so a wrong row is a wrong decision, + * which is the only kind of claim a conformance matrix can safely publish. + * + * The direction is deliberately method -> point, not point -> method, even + * though the YAML is keyed the other way: the runtime is handed an ACS method + * by the wire and needs the AGT point, and inverting a small declaration here + * is cheaper than duplicating it in the other order. + * + * Every failure is a THROW, and none of them is recoverable-by-guessing. + * Returning a default point, or falling back to `pre_tool_call`, would + * evaluate the wrong policy and call the result a decision; that is the + * fail-open this function is shaped to make impossible. What the Guardian + * does with the throw is its own concern -- at this slice it becomes a + * JSON-RPC error in the ACS-reserved range, and turning an evaluation failure + * into an explicit ACS `deny` is N27, scoped to V3. + */ +export function resolveInterventionPoint(acsMethod: string, mapping: Mapping): string { + const table = mapping.intervention_points; + if (typeof table !== "object" || table === null) { + throw new Error( + `mapping.yaml declares no intervention_points table, so ACS method "${acsMethod}" ` + + `cannot be resolved to an AGT intervention point`, + ); + } + + const points = Object.entries(table) + .filter(([, entry]) => typeof entry === "object" && entry !== null && entry.acs_method === acsMethod) + .map(([point]) => point); + + const [point, ...ambiguous] = points; + if (point === undefined) { + throw new Error( + `mapping.yaml's intervention_points table maps no AGT intervention point to ACS method "${acsMethod}"`, + ); + } + if (ambiguous.length > 0) { + // A table that answers one method with two points has no single right + // answer, and picking the first would make the choice depend on YAML key + // order. Loud beats arbitrary. + throw new Error( + `mapping.yaml's intervention_points table maps ACS method "${acsMethod}" to more than one ` + + `AGT intervention point: ${[point, ...ambiguous].join(", ")}`, + ); + } + return point; +} + /** 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; diff --git a/packages/guardian/src/server.ts b/packages/guardian/src/server.ts index 52080873..819317a7 100644 --- a/packages/guardian/src/server.ts +++ b/packages/guardian/src/server.ts @@ -6,8 +6,8 @@ * * Composes every earlier task, in order, for `steps/toolCallRequest`: * validateEnvelope (Task 5) -> assembleSnapshot (Task 4) -> - * bridge.evaluate("pre_tool_call", snapshot) (Task 2) -> - * mapVerdict(verdict, mapping) (Task 3) -> response envelope. + * bridge.evaluate(resolveInterventionPoint(method, mapping), snapshot) + * (Task 2) -> mapVerdict(verdict, mapping) (Task 3) -> response envelope. * * assembleSnapshot / bridge.evaluate / mapVerdict are wrapped in a try/catch * (fix wave finding 1): an unhandled throw here -- e.g. mapVerdict's own @@ -33,7 +33,7 @@ import { fileURLToPath } from "node:url"; import { createBridge } from "agt-bridge"; import { assembleSnapshot } from "./assemble-snapshot.ts"; -import { loadMapping, mapVerdict, type Mapping } from "./map-verdict.ts"; +import { loadMapping, mapVerdict, resolveInterventionPoint, type Mapping } from "./map-verdict.ts"; import { EnvelopeValidationError, isToolCallRequest, @@ -153,7 +153,15 @@ async function handleAcsRequest( if (isToolCallRequest(envelope)) { try { const snapshot = assembleSnapshot(envelope); - const { verdict } = await bridge.evaluate("pre_tool_call", snapshot); + // The intervention point comes from mapping.yaml's own + // `intervention_points` table, not from a literal here (PR #10 review, + // Critical): that table is what V7's conformance matrix publishes, and + // a declaration the runtime does not consult is a claim nobody checks. + // An unresolvable method throws into the catch below rather than + // defaulting to a point -- evaluating the wrong policy and calling the + // result a decision is the one outcome worse than a reported failure. + const point = resolveInterventionPoint(envelope.method, mapping); + const { verdict } = await bridge.evaluate(point, snapshot); const decision = mapVerdict(verdict, mapping); const result: Record = { 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 new file mode 100644 index 00000000..29d4e713 --- /dev/null +++ b/packages/guardian/test/fixtures/mapping.tool-call-at-a-different-point.yaml @@ -0,0 +1,42 @@ +# Test fixture only -- NOT read by production code. Identical to the real +# mapping.yaml except one row moves: "steps/toolCallRequest" is answered by the +# `output` intervention point instead of `pre_tool_call`. +# +# Used by packages/guardian/test/server.test.ts (PR #10 review, Critical) to +# prove the runtime resolves the AGT intervention point out of THIS table +# rather than hardcoding "pre_tool_call". `output` is a point AGT knows and +# policy/manifest.yaml does not register, so honouring the table makes AGT fail +# 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. +acs_version: "0.1.0" +agt_version: "0.3.1-beta" + +intervention_points: + output: { acs_method: "steps/toolCallRequest" } + post_tool_call: { acs_method: "steps/toolCallResult" } + agent_startup: { acs_method: "steps/sessionStart" } + agent_shutdown: { acs_method: "steps/sessionEnd" } + input: { acs_method: "steps/userMessage" } + pre_tool_call: { acs_method: null, note: "deliberately unmapped for this fixture" } + pre_model_call: { acs_method: null, note: "no ACS v0.1.0 target — D4, V7 red cell" } + post_model_call: { acs_method: null, note: "no ACS v0.1.0 target — D4, V7 red cell" } + +verdicts: + allow: { decision: allow } + deny: { decision: deny } + warn: { decision: allow, require_policy_references: true } + escalate: { decision: ask } + transform: { decision: modify } + +field_synthesis: + reasoning: + source: verdict.message + reason_codes: + source: verdict.reason + wrap: array + policy_references: + rule_id: + source: verdict.reason + policy_id: + literal: agt_stock diff --git a/packages/guardian/test/map-verdict.test.ts b/packages/guardian/test/map-verdict.test.ts index 010b3b9f..7550cc83 100644 --- a/packages/guardian/test/map-verdict.test.ts +++ b/packages/guardian/test/map-verdict.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { loadMapping, mapVerdict } from "../src/map-verdict.ts"; +import { loadMapping, mapVerdict, resolveInterventionPoint } from "../src/map-verdict.ts"; const m = loadMapping("mapping.yaml"); @@ -53,3 +53,35 @@ describe("mapVerdict", () => { expect(() => mapVerdict({ decision: "warn" }, m)).toThrow(/require_policy_references/); }); }); + +// PR #10 review, Critical: the intervention_points table used to be a claim +// nobody checked -- declared here, hardcoded in server.ts. These read the real +// mapping.yaml, so a row edited there without a matching runtime change fails +// somewhere rather than nowhere. +describe("resolveInterventionPoint", () => { + it("answers the ACS method the shipped mapping wires, from the table rather than a literal", () => { + expect(resolveInterventionPoint("steps/toolCallRequest", m)).toBe("pre_tool_call"); + expect(resolveInterventionPoint("steps/agentResponse", m)).toBe("output"); + }); + + it("throws for a method no row names, rather than defaulting to a point", () => { + // The fail-open this function is shaped against: any default here would + // evaluate one intervention point's policy for a different method's + // snapshot and call the result a decision. + expect(() => resolveInterventionPoint("steps/userMessage", { ...m, intervention_points: {} })).toThrow( + /maps no AGT intervention point/, + ); + }); + + it("throws when two rows name the same ACS method, rather than letting YAML key order pick", () => { + const ambiguous = { + ...m, + intervention_points: { + pre_tool_call: { acs_method: "steps/toolCallRequest" }, + pre_model_call: { acs_method: "steps/toolCallRequest" }, + }, + }; + + expect(() => resolveInterventionPoint("steps/toolCallRequest", ambiguous)).toThrow(/more than one/); + }); +}); diff --git a/packages/guardian/test/server.test.ts b/packages/guardian/test/server.test.ts index fed03aa4..0b4a5a09 100644 --- a/packages/guardian/test/server.test.ts +++ b/packages/guardian/test/server.test.ts @@ -183,3 +183,31 @@ describe("startGuardian POST /acs -- evaluation failure inside handleAcsRequest" } }); }); + +// PR #10 review, Critical: mapping.yaml's intervention_points table is what +// V7's conformance matrix publishes, and the runtime used to hardcode +// "pre_tool_call" instead of consulting it, so the two could disagree without +// anything failing. +describe("startGuardian POST /acs -- the intervention point comes from mapping.yaml", () => { + it("evaluates the point the table names, not pre_tool_call: a moved row changes the decision", async () => { + // The fixture answers steps/toolCallRequest with `output`, which + // policy/manifest.yaml does not register -- so honouring the table makes + // AGT fail closed, while ignoring it would allow this benign command. The + // two outcomes are opposite: this cannot pass against a hardcoded point. + const guardian = await startGuardian({ + port: 0, + manifestPath: "policy/manifest.yaml", + mappingPath: "packages/guardian/test/fixtures/mapping.tool-call-at-a-different-point.yaml", + }); + + try { + const response = await postAcs(guardian.url, toolCallEnvelope("ls -la")); + + expect(response.error).toBeUndefined(); + expect(response.result?.decision).toBe("deny"); + expect(response.result?.reason_codes).toEqual(["runtime_error:intervention_point_unknown"]); + } finally { + await guardian.close(); + } + }); +}); From 6d559d64ef199a8c459fa41a4b94e25b9683d04b Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Tue, 11 Aug 2026 11:01:27 +0300 Subject: [PATCH 21/39] One noun for the ACS decision message on both sides of the seam The Guardian produced `AcsDecision`; the adapter consumed `AcsDecisionResult`, a deliberately untyped bag declared beside the render that reads it. One collaboration, two nouns, and the second one named a processing stage rather than a different message -- so a reader following a decision across the wire learned a new name at the hop where nothing about the role changed. The adapter's half now lives in decision-message.ts under the stem the Guardian already uses. It stays structurally looser than the Guardian's strict five-disposition union -- this side reads a decision off HTTP, the other side builds one -- and it shares the name rather than an import, because R3.2 forbids this package depending on the Guardian's type graph. Addresses: #10 review --- packages/host-adapter/src/decision-message.ts | 33 +++++++++++++++++++ packages/host-adapter/src/index.ts | 3 +- packages/host-adapter/src/render-decision.ts | 11 ++----- 3 files changed, 37 insertions(+), 10 deletions(-) create mode 100644 packages/host-adapter/src/decision-message.ts diff --git a/packages/host-adapter/src/decision-message.ts b/packages/host-adapter/src/decision-message.ts new file mode 100644 index 00000000..c9f214f9 --- /dev/null +++ b/packages/host-adapter/src/decision-message.ts @@ -0,0 +1,33 @@ +/** + * The ACS decision message this adapter speaks. Kept in a module of its own so + * every module that touches one -- render-decision.ts (which renders one), + * guardian-client.ts (which receives one off the wire), and whatever later + * slices add between them -- speaks the same message rather than each + * declaring its own shape of it. + * + * ONE STEM (PR #10 review, Important and its naming-symmetry companion). There + * is one public noun for "the ACS decision for this step". The Guardian + * produced `AcsDecision` and this side consumed `AcsDecisionResult`, so a + * reader following one decision across the seam learned a second name for a + * role that had not changed -- `Result` describes a processing stage, not a + * different message. Any later refinement of it should stay an adjective that + * still contains this stem (`ValidatedAcsDecision`), never a parallel noun. + * + * WHY THIS IS NOT AN IMPORT OF THE GUARDIAN'S OWN `AcsDecision`. The Guardian + * declares the same message (packages/guardian/src/map-verdict.ts) as a strict + * union of the five ACS dispositions, because it is the side that *builds* + * one. This side reads one off HTTP, so it is deliberately loose: only + * `decision` is required, and everything else a caller touches is named by the + * hookmap rather than assumed to exist under a fixed key. The two are + * structurally compatible in the direction that matters (the Guardian's is + * assignable to this one), and they share the NAME rather than an import, + * because R3.2 forbids this package depending on the Guardian's type graph. + * + * R3.2: ACS's decision vocabulary, nothing else. + */ + +/** + * The ACS decision for one step, as this adapter reads it. Loose on purpose -- + * see the module header: only `decision` is required. + */ +export type AcsDecision = { decision: string } & Record; diff --git a/packages/host-adapter/src/index.ts b/packages/host-adapter/src/index.ts index 06b739f5..2eae1a06 100644 --- a/packages/host-adapter/src/index.ts +++ b/packages/host-adapter/src/index.ts @@ -23,6 +23,7 @@ export { type JsonRpcSuccessResponse, type JsonRpcErrorResponse, } from "./guardian-client.ts"; -export { renderDecision, type AcsDecisionResult, type HookSpecificOutput } from "./render-decision.ts"; +export { renderDecision, type HookSpecificOutput } from "./render-decision.ts"; +export { type AcsDecision } from "./decision-message.ts"; export { handshake, type HandshakeOptions } from "./handshake.ts"; export { createSessionConfigStore, type SessionConfig, type SessionConfigStore } from "./session-config.ts"; diff --git a/packages/host-adapter/src/render-decision.ts b/packages/host-adapter/src/render-decision.ts index 66788e88..7a92296a 100644 --- a/packages/host-adapter/src/render-decision.ts +++ b/packages/host-adapter/src/render-decision.ts @@ -22,6 +22,7 @@ * separate rendering path for it to invent. */ import type { Hookmap } from "./build-envelope.ts"; +import type { AcsDecision } from "./decision-message.ts"; /** One decision's hookmap-declared rendering rule (S1's `decisions.` entry). */ type DecisionRenderRule = { @@ -30,14 +31,6 @@ type DecisionRenderRule = { updatedInput_from?: string; }; -/** - * The ACS decision-result shape renderDecision reads from. Deliberately - * loose: only `decision` is required, because everything else this module - * touches is named by the hookmap (`reason_from`, `updatedInput_from`), not - * assumed to exist under a fixed key. - */ -export type AcsDecisionResult = { decision: string } & Record; - export type HookSpecificOutput = { hookEventName: string; permissionDecision: string } & Record; /** @@ -51,7 +44,7 @@ export type HookSpecificOutput = { hookEventName: string; permissionDecision: st */ export function renderDecision( hookEventName: string, - decisionResult: AcsDecisionResult, + decisionResult: AcsDecision, hookmap: Hookmap, ): { hookSpecificOutput: HookSpecificOutput } { const rules = hookmap.decisions as Record | undefined; From 921356e9edd0cd0150cf8a14c642fedab204dcb2 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Tue, 11 Aug 2026 11:06:24 +0300 Subject: [PATCH 22/39] Move Claude Code's output field names out of the shared adapter The adapter's public API was one host's wire shape: `HookSpecificOutput` with a mandatory `permissionDecision`, `permissionDecisionReason` and `updatedInput` assigned by name, and an outer `{ hookSpecificOutput }` return. Slice V5 promises a second host this module unchanged plus a shim and a hookmap; that was false while three of Claude Code's field names were baked into these types. The hookmap was data-driven and the TypeScript around it was not. Each `decisions.` entry now declares an `output` block: dotted paths into the object the host reads, mapped to a literal (`value:`) or a field of the arriving decision (`from:`, with an optional `type:` guard -- which is where the old rule's hardcoded `typeof === "string"` check went). `renderDecision` walks it and assembles an object naming nothing; the shim wraps and adds `hookEventName`, the one field that is not a function of the decision. Two shapes the old types could not express are now tested against a synthetic non-Claude-Code hookmap: a field alongside the wrapper rather than inside it, and a decision with no permission-style field at all. test/invariants.test.ts gains the gate that keeps it this way. Verified by mutation: reintroducing `permissionDecision` in the adapter fails the gate naming both the file and the term. No host name now appears under packages/host-adapter/src at all, comments included. Rendering is unchanged: hosts/claude-code/test/wire-shape.test.ts, the regression pin over the real shim's stdout for all five decisions, is byte-identical to the commit before this one and still passes. Addresses: #10 review --- README.md | 4 +- hosts/claude-code/acs-hook.ts | 70 +++++- hosts/claude-code/claude-code.hookmap.yaml | 75 +++++- hosts/claude-code/test/hook.test.ts | 3 +- packages/host-adapter/src/index.ts | 2 +- packages/host-adapter/src/render-decision.ts | 229 +++++++++++++----- packages/host-adapter/test/client.test.ts | 12 +- .../host-adapter/test/render-decision.test.ts | 205 ++++++++++------ test/invariants.test.ts | 24 ++ 9 files changed, 461 insertions(+), 163 deletions(-) diff --git a/README.md b/README.md index b2411a39..92b1afc4 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ which is exactly the JSON Claude Code's own hook protocol sends and expects back ### Verify ```bash -bun test # 76 tests across 12 files (75 pass, 1 skip), including the R3.2/R3.3 gates below +bun test # 83 tests across 12 files (82 pass, 1 skip), including the R3.2/R3.3 gates below # the skip is the byte-identity check, which needs UPSTREAM_BUNDLE — see verify:pin bun run typecheck # whole-workspace strict TypeScript check, zero errors ``` @@ -101,7 +101,7 @@ bun run typecheck # whole-workspace strict TypeScript check, zero errors ## Status -V1 ("one host, one hook") is implemented: a Claude Code `PreToolUse` hook, a Guardian process serving ACS over HTTP, and AGT's unforked stock policy bundle deciding behind it — see the quickstart above and [`slices/v1/README.md`](slices/v1/README.md). R3.2 and R3.3 (no AGT vocabulary in the host adapter, no host vocabulary in the AGT bridge) are enforced by [`test/invariants.test.ts`](test/invariants.test.ts), not left to inspection. Slices V2–V8 are shaped and sliced but not started; they are tracked as issues on the project board, each with a stacked pull request. +V1 ("one host, one hook") is implemented: a Claude Code `PreToolUse` hook, a Guardian process serving ACS over HTTP, and AGT's unforked stock policy bundle deciding behind it — see the quickstart above and [`slices/v1/README.md`](slices/v1/README.md). R3.2 and R3.3 (no AGT vocabulary in the host adapter, no host *output* vocabulary in it either, and no host vocabulary in the AGT bridge) are enforced by [`test/invariants.test.ts`](test/invariants.test.ts), not left to inspection. Slices V2–V8 are shaped and sliced but not started; they are tracked as issues on the project board, each with a stacked pull request. ## License diff --git a/hosts/claude-code/acs-hook.ts b/hosts/claude-code/acs-hook.ts index 12db2727..99c74a6d 100644 --- a/hosts/claude-code/acs-hook.ts +++ b/hosts/claude-code/acs-hook.ts @@ -3,11 +3,12 @@ * * Deliberately thin: read the hook JSON Claude Code sends on stdin, call * buildEnvelope -> guardianClient.post -> renderDecision (all three from - * `host-adapter`, packages/host-adapter), and write the resulting - * `hookSpecificOutput` JSON to stdout. All logic lives in the adapter -- - * this file is only the wiring a Claude Code hook process needs (stdin, - * stdout, exit code, and which hookmap file to load). Slice V5 adds a - * second host by writing another shim this thin against the same, + * `host-adapter`, packages/host-adapter), wrap the output that comes back + * into the JSON Claude Code expects, and write it to stdout. All logic lives + * in the adapter -- this file is only the wiring a Claude Code hook process + * needs (stdin, stdout, exit code, which hookmap file to load) plus the one + * thing the adapter must not know: this host's own output shape. Slice V5 + * adds a second host by writing another shim this thin against the same, * unchanged adapter; any logic added here is logic V5 would have to * duplicate. * @@ -37,7 +38,7 @@ * posture; V3 should replace it deliberately. */ import { fileURLToPath } from "node:url"; -import { buildEnvelope, guardianClient, loadHookmap, renderDecision } from "host-adapter"; +import { buildEnvelope, guardianClient, loadHookmap, renderDecision, type HostOutput } from "host-adapter"; const HOOKMAP_PATH = fileURLToPath(new URL("./claude-code.hookmap.yaml", import.meta.url)); @@ -47,6 +48,55 @@ const HOOKMAP_PATH = fileURLToPath(new URL("./claude-code.hookmap.yaml", import. // host/port (e.g. in tests, which start a Guardian on an ephemeral port). const DEFAULT_GUARDIAN_URL = "http://localhost:8787/acs"; +/** + * Claude Code's own output shape, which lives here and nowhere else (PR #10 + * review, Critical). + * + * The adapter renders into a shape it does not name: `renderDecision` reads + * the hookmap's dotted output paths and assembles the object they describe, + * knowing ACS decisions and nothing about this host. That is what lets slice + * V5 add a second host with a shim and a hookmap instead of a fork of the + * shared module -- and it is only true while these names appear on this side + * of the seam. The two inside the wrapper appear as data in + * claude-code.hookmap.yaml's output paths; here the wrapper itself is the one + * name this shim needs, because it is the shim that wraps. + */ +const HOOK_SPECIFIC_OUTPUT = "hookSpecificOutput"; + +/** + * Wraps the adapter's host-agnostic output into the exact JSON Claude Code + * expects, adding the one field that is not a function of the decision: the + * name of the hook that asked. + * + * `hookEventName` is deliberately not in the hookmap. Every output field + * declared there is copied from the decision or is a literal; this one is + * neither -- it is the raw host event name that produced the original + * request, the same string passed to buildEnvelope, carried through + * unchanged. Adding it here keeps the adapter's contract exactly "the output + * is a function of the decision and the hookmap". + * + * It goes first, and any field the hookmap declared under the wrapper + * follows; a field the hookmap declared OUTSIDE the wrapper (a top-level key + * alongside it) travels untouched, which is the second half of what the + * generic output shape bought. + * + * A rendered output with no wrapper object in it is a throw rather than a + * repair: writing `{"hookSpecificOutput":{"hookEventName":"PreToolUse"}}` + * would hand Claude Code JSON it reads as no decision at all, and it would + * then let the tool call proceed. Half an output is the one thing this hook + * must never write. + */ +function asClaudeCodeOutput(rendered: HostOutput, hookEventName: string): HostOutput { + const wrapper = rendered[HOOK_SPECIFIC_OUTPUT]; + if (typeof wrapper !== "object" || wrapper === null || Array.isArray(wrapper)) { + throw new Error( + `acs-hook: the rendered output has no "${HOOK_SPECIFIC_OUTPUT}" object for Claude Code to read a ` + + `decision from, so there is no output this host could honestly write`, + ); + } + return { ...rendered, [HOOK_SPECIFIC_OUTPUT]: { hookEventName, ...(wrapper as Record) } }; +} + async function main(): Promise { const input = await Bun.stdin.text(); const payload = JSON.parse(input) as Record; @@ -66,13 +116,9 @@ async function main(): Promise { throw new Error(`acs-hook: Guardian returned a JSON-RPC error: ${response.error.message}`); } - const { hookSpecificOutput } = renderDecision( - hookEventName, - response.result as { decision: string } & Record, - hookmap, - ); + const rendered = renderDecision(response.result as { decision: string } & Record, hookmap); - process.stdout.write(JSON.stringify({ hookSpecificOutput })); + process.stdout.write(JSON.stringify(asClaudeCodeOutput(rendered, hookEventName))); } main().catch((error: unknown) => { diff --git a/hosts/claude-code/claude-code.hookmap.yaml b/hosts/claude-code/claude-code.hookmap.yaml index d4f5aaba..a0f3551f 100644 --- a/hosts/claude-code/claude-code.hookmap.yaml +++ b/hosts/claude-code/claude-code.hookmap.yaml @@ -2,8 +2,8 @@ # # Pure data: maps Claude Code's own hook names and payload shape onto ACS # methods/arguments (the `hooks` block, read by buildEnvelope / N2), and -# maps ACS decisions onto Claude Code's `hookSpecificOutput` shape (the -# `decisions` block, read by renderDecision / N3, Task 8). +# maps ACS decisions onto the output Claude Code reads back (the `decisions` +# block, read by renderDecision / N3, Task 8). # # `tool_name` and `arguments` are JSONPath-lite references (`$.`) # into the raw hook payload Claude Code delivers on stdin for this event -- @@ -14,12 +14,47 @@ hooks: acs_method: steps/toolCallRequest tool_name: $.tool_name arguments: $.tool_input -# Fix wave finding 5: Claude Code's PreToolUse hookSpecificOutput only -# accepts permissionDecision "allow" | "deny" | "ask" (see Claude Code's own -# hook docs) -- there is no "defer" value on the wire, so `defer: -# { permissionDecision: defer }` was never a legal Claude Code output. It -# was unreachable in V1 (no verdict in mapping.yaml produces an ACS -# "defer" decision today) but would have broken the moment one did. +# PR #10 review, Critical: the `decisions` block below is now where Claude +# Code's output field names live, and they appear nowhere in the adapter. +# Each entry declares an `output` block whose keys are dotted paths into the +# JSON object this host writes on stdout, and whose values say where each +# field's content comes from -- `value:` for a literal, `from:` for a field +# of the arriving ACS decision (copied only when the decision carries it, +# and only when it has the declared `type:`). +# +# The adapter assembles that object without knowing one name in it, which is +# what makes slice V5's second host a shim and a hookmap rather than a fork +# of the shared module. A host that nests its decision differently, or names +# a field alongside the wrapper rather than inside it, or has no +# permission-style field at all, writes different paths here and changes no +# code. +# +# `hookEventName` is deliberately NOT declared here: it is not derived from +# the decision, it is the name of the hook that asked, and acs-hook.ts adds +# it as it wraps. Everything below is a function of the decision alone. +# +# `type: string` on every reason field is what keeps a Guardian that puts a +# non-string in `reasoning` from producing an output Claude Code cannot read: +# the field is left off instead, exactly as it is for a decision that carries +# no reasoning at all. It is where the old `reason_from` rule's own +# `typeof === "string"` check went, now declared rather than hardcoded. +# +# WHAT IS DELIBERATELY UNCHANGED HERE. `allow` and `modify` still name no +# reason field, so an allow or a policy-ordered rewrite that carries +# `reasoning` says nothing in the transcript a human reads, and `modify` +# still hands `updatedInput` ACS's raw `modifications` object rather than a +# tool input Claude Code can apply. Both are real gaps and both belong to the +# slice that owns dispositions (V3, N27/N6/N7); this change moves where the +# field names live and changes no rendering. +# hosts/claude-code/test/wire-shape.test.ts pins every one of these outputs +# as a literal, so whichever slice fixes them has to say so there. +# +# Fix wave finding 5: Claude Code's PreToolUse output only accepts +# permissionDecision "allow" | "deny" | "ask" (see Claude Code's own hook +# docs) -- there is no "defer" value on the wire, so a `defer` entry +# declaring `permissionDecision: defer` was never a legal Claude Code +# output. It was unreachable in V1 (no verdict in mapping.yaml produces an +# ACS "defer" decision today) but would have broken the moment one did. # ACS's own defer-details.json defaults an unresolved defer's # `timeout_decision` to "deny", so `deny` is the spec-consistent fail-closed # choice for a host (like Claude Code) with no native defer/pending state -- @@ -28,8 +63,22 @@ hooks: # disposition decisions (N27, N6/N7), should treat this as a known gap # rather than rediscovering it. decisions: - allow: { permissionDecision: allow } - deny: { permissionDecision: deny, reason_from: reasoning } - ask: { permissionDecision: ask, reason_from: reasoning } - defer: { permissionDecision: deny, reason_from: reasoning } - modify: { permissionDecision: allow, updatedInput_from: modifications } + allow: + output: + hookSpecificOutput.permissionDecision: { value: allow } + deny: + output: + hookSpecificOutput.permissionDecision: { value: deny } + hookSpecificOutput.permissionDecisionReason: { from: reasoning, type: string } + ask: + output: + hookSpecificOutput.permissionDecision: { value: ask } + hookSpecificOutput.permissionDecisionReason: { from: reasoning, type: string } + defer: + output: + hookSpecificOutput.permissionDecision: { value: deny } + hookSpecificOutput.permissionDecisionReason: { from: reasoning, type: string } + modify: + output: + hookSpecificOutput.permissionDecision: { value: allow } + hookSpecificOutput.updatedInput: { from: modifications } diff --git a/hosts/claude-code/test/hook.test.ts b/hosts/claude-code/test/hook.test.ts index ae1d4dbf..f3117852 100644 --- a/hosts/claude-code/test/hook.test.ts +++ b/hosts/claude-code/test/hook.test.ts @@ -87,10 +87,9 @@ describe("acs-hook.ts (N1) -- the Claude Code hook shim, run as a real subproces const response = await guardianClient.post(guardian.url, envelope); expect(response.error).toBeUndefined(); const expected = renderDecision( - "PreToolUse", response.result as { decision: string } & Record, hookmap, - ); + ) as { hookSpecificOutput: Record }; expect(reason).toBe(expected.hookSpecificOutput.permissionDecisionReason); expect(stderr).toBe(""); diff --git a/packages/host-adapter/src/index.ts b/packages/host-adapter/src/index.ts index 2eae1a06..306b3f29 100644 --- a/packages/host-adapter/src/index.ts +++ b/packages/host-adapter/src/index.ts @@ -23,7 +23,7 @@ export { type JsonRpcSuccessResponse, type JsonRpcErrorResponse, } from "./guardian-client.ts"; -export { renderDecision, type HookSpecificOutput } from "./render-decision.ts"; +export { renderDecision, type HostOutput } from "./render-decision.ts"; export { type AcsDecision } from "./decision-message.ts"; export { handshake, type HandshakeOptions } from "./handshake.ts"; export { createSessionConfigStore, type SessionConfig, type SessionConfigStore } from "./session-config.ts"; diff --git a/packages/host-adapter/src/render-decision.ts b/packages/host-adapter/src/render-decision.ts index 7a92296a..798b2600 100644 --- a/packages/host-adapter/src/render-decision.ts +++ b/packages/host-adapter/src/render-decision.ts @@ -1,80 +1,195 @@ /** - * renderDecision (N3) turns an ACS decision result into Claude Code's - * `hookSpecificOutput`, driven entirely by the hookmap's `decisions` block - * (S1) -- never by a hardcoded decision -> output dispatch table in this - * module. Which host output field a decision maps to, and which decision - * field (if any) feeds a human-readable reason or an updated-input payload, - * are both read off `hookmap.decisions[decision]`; changing the YAML - * changes the render with no code change here. + * renderDecision (N3) turns an ACS decision into the output its host expects + * -- without naming one field of that output anywhere in this module. * - * The single load-bearing behaviour: a `deny` decision's `reasoning` - * string must land in `permissionDecisionReason` -- that's what a human - * reads in the Claude Code transcript, and it's the entire payoff of the - * slice's demo. This module gets there generically: `deny`'s hookmap entry - * names `reason_from: reasoning`, and renderDecision copies whatever field - * that names -- it never assumes "reasoning" is the field in code. + * The hookmap (S1) declares the whole shape. Each `decisions.` entry + * carries an `output` block whose keys are dotted paths into the object the + * host reads, and whose values say where each field's content comes from: a + * literal (`value:`) or a field of the arriving ACS decision (`from:`, with an + * optional `type:` the arriving value must have). This module walks that block + * and assembles the object. It knows ACS decisions, dotted paths, and nothing + * else; the field names, their nesting, and which of them a given decision + * even has are all data. * - * R3.2: this module knows ACS decisions and Claude Code's hookSpecificOutput - * shape, nothing else. No policy-runtime vocabulary appears here -- an - * observe-only upstream signal has already become an ACS `allow` (with - * policy_references) by the time a decision result reaches this module, - * and it renders as a plain allow like any other, per R1.2: there is no - * separate rendering path for it to invent. + * WHY THIS IS NOT MERELY TIDY (PR #10 review, Critical). The project's claim is + * that governance integration collapses from M*N to M+N: a host implements ACS + * once and any conformant policy runtime can govern it. Slice V5's second host + * is promised *this same module*, unchanged, plus a shim and a hookmap. Until + * this rewrite that promise was false -- three of one host's field names were + * baked into these types, and one of them was mandatory, so a second host + * would have had to fork the adapter or inherit the first host's vocabulary. + * The hookmap was data-driven; the TypeScript around it was not. + * + * Two things the previous shape could not express, and this one can: + * + * - A field that sits OUTSIDE whatever wrapper the host nests its decision + * in -- a top-level key alongside it. A path with no dot puts it there. + * - A decision that carries no permission-style field at all. Nothing here + * is mandatory. + * + * The wrapper itself, and the one field that is not a function of the decision + * (the name of the hook that asked), belong to the host shim: it wraps what + * this returns. That keeps this module's contract exactly "the output is a + * function of the decision and the hookmap". + * + * The single load-bearing behaviour, unchanged: a `deny` decision's + * `reasoning` string must reach the human reading the host's transcript. That + * happens here generically -- `deny`'s hookmap entry names the decision field + * to copy and the host path to copy it to, and this module copies whatever + * those two say. + * + * R3.2: no policy-runtime vocabulary here, and now no host vocabulary either + * -- test/invariants.test.ts gates both. An observe-only upstream signal has + * already become an ACS `allow` (with policy_references) by the time it + * reaches this module, per R1.2, and it is dispatched through the exact same + * `decisions.allow` entry a plain allow is: still one dispatch path, still + * driven by the hookmap alone. */ import type { Hookmap } from "./build-envelope.ts"; import type { AcsDecision } from "./decision-message.ts"; -/** One decision's hookmap-declared rendering rule (S1's `decisions.` entry). */ -type DecisionRenderRule = { - permissionDecision: string; - reason_from?: string; - updatedInput_from?: string; +/** + * A rendered host output: an ordinary JSON object whose keys this module never + * chose. A host shim receives one of these and hands it to its host -- it is + * the shim, not the adapter, that knows what the keys mean. + */ +export type HostOutput = Record; + +/** + * One field of a host output, as S1 declares it: exactly one source, plus an + * optional type the arriving value must have. + * + * `type` is a `typeof` string, and it is not decoration. A host field declared + * to hold prose ("the reason a human reads") must not be handed an object + * because some Guardian put one in the decision field it names: the host would + * either display a shape it cannot render or, worse, reject the whole output + * as malformed and treat the hook as having produced no decision -- a + * fail-open, from a decision that arrived perfectly well. A value of the wrong + * type leaves the field off, which is the same thing that happens when the + * decision does not carry the field at all. + */ +type HostOutputField = { + /** A literal, copied through as-is. Mutually exclusive with `from`. */ + value?: unknown; + /** The name of the ACS decision field whose value to copy. */ + from?: string; + /** `typeof` the value must satisfy for a `from` field to be copied. */ + type?: string; }; -export type HookSpecificOutput = { hookEventName: string; permissionDecision: string } & Record; +/** One decision's hookmap-declared rendering rule (S1's `decisions.` entry). */ +type DecisionRenderRule = { output: Record }; + +/** + * Path segments no output field may name. `__proto__` is the one that matters + * -- assigning to it through a plain object mutates the prototype instead of + * adding a key, so a hookmap naming it would produce an output missing the + * field it declared while changing something else entirely. The other two are + * rejected beside it rather than reasoned about individually. + */ +const RESERVED_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]); + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} /** - * Renders `decisionResult` per `hookmap.decisions[decisionResult.decision]`. - * `hookEventName` is the raw host hook name that produced the original - * request (e.g. "PreToolUse", the same string passed to buildEnvelope), - * carried through unchanged into the shape Claude Code expects. + * Writes `value` into `output` at a dotted `path`, creating the objects along + * the way. * - * Throws if the hookmap has no `decisions` block, or no entry for this - * decision -- there is no default rendering and no partial output. + * Every failure here is a throw rather than a skip or an overwrite. A hookmap + * that declares two fields at the same key, or one field nested underneath + * another, describes an output nobody can render as written -- and a renderer + * that silently picked one of the two would hand the host something that + * merely looks like a decision. */ -export function renderDecision( - hookEventName: string, - decisionResult: AcsDecision, - hookmap: Hookmap, -): { hookSpecificOutput: HookSpecificOutput } { - const rules = hookmap.decisions as Record | undefined; - if (!rules) { - throw new Error("renderDecision: hookmap has no decisions block"); +function place(output: HostOutput, path: string, value: unknown): void { + const segments = path.split("."); + for (const segment of segments) { + if (segment.length === 0 || RESERVED_SEGMENTS.has(segment)) { + throw new Error(`renderDecision: output path "${path}" names the segment ${JSON.stringify(segment)}, which addresses no field`); + } } - const rule = rules[decisionResult.decision]; - if (!rule) { - throw new Error(`renderDecision: hookmap has no decisions entry for ACS decision "${decisionResult.decision}"`); + const leaf = segments[segments.length - 1] as string; + let cursor = output; + for (const segment of segments.slice(0, -1)) { + const existing = cursor[segment]; + if (existing === undefined) { + cursor[segment] = {}; + } else if (!isPlainObject(existing)) { + throw new Error( + `renderDecision: output path "${path}" nests under "${segment}", which another field already holds a value at`, + ); + } + cursor = cursor[segment] as HostOutput; } + if (Object.prototype.hasOwnProperty.call(cursor, leaf)) { + throw new Error(`renderDecision: output path "${path}" is declared twice, or collides with a field nested under it`); + } + cursor[leaf] = value; +} - const hookSpecificOutput: HookSpecificOutput = { - hookEventName, - permissionDecision: rule.permissionDecision, - }; +/** + * Renders `decision` per `hookmap.decisions[decision.decision]`, returning the + * host output that entry declares. + * + * Throws if the hookmap has no `decisions` block, has no entry for this + * decision, or has an entry this module cannot render -- there is no default + * rendering and no partial output. A caller that cannot render a decision + * still has a decision it must answer; answering it with half an output is the + * one thing this function will not do. + */ +export function renderDecision(decision: AcsDecision, hookmap: Hookmap): HostOutput { + const decisions = hookmap.decisions; + if (!isPlainObject(decisions)) { + throw new Error("renderDecision: hookmap has no decisions block"); + } + if (!Object.prototype.hasOwnProperty.call(decisions, decision.decision)) { + throw new Error(`renderDecision: hookmap has no decisions entry for ACS decision "${decision.decision}"`); + } - if (rule.reason_from) { - const reason = decisionResult[rule.reason_from]; - if (typeof reason === "string") { - hookSpecificOutput.permissionDecisionReason = reason; - } + const rule = decisions[decision.decision]; + if (!isPlainObject(rule) || !isPlainObject(rule.output) || Object.keys(rule.output).length === 0) { + // An entry that is null, or carries no `output` block, satisfies a bare + // presence check and then renders nothing -- an output with no decision in + // it, which a host reads as "the hook produced nothing" exactly as surely + // as a missing entry does, just more quietly. + throw new Error( + `renderDecision: hookmap's "decisions.${decision.decision}" entry needs a non-empty "output" block, ` + + `got ${JSON.stringify((rule as { output?: unknown } | null)?.output)}`, + ); } - if (rule.updatedInput_from) { - const updatedInput = decisionResult[rule.updatedInput_from]; - if (updatedInput !== undefined) { - hookSpecificOutput.updatedInput = updatedInput; + const output: HostOutput = {}; + for (const [path, field] of Object.entries((rule as DecisionRenderRule).output)) { + if (!isPlainObject(field)) { + throw new Error( + `renderDecision: hookmap's "decisions.${decision.decision}" output field "${path}" must be an object ` + + `naming "value" or "from", got ${JSON.stringify(field)}`, + ); + } + if (Object.prototype.hasOwnProperty.call(field, "value")) { + place(output, path, field.value); + continue; } + if (typeof field.from !== "string" || field.from.length === 0) { + // Not skipped: a field naming neither source is a hookmap typo, and + // rendering around it would produce an output missing a field its author + // believes is there. + throw new Error( + `renderDecision: hookmap's "decisions.${decision.decision}" output field "${path}" must name a literal ` + + `"value" or a non-empty string "from", got ${JSON.stringify(field.from)}`, + ); + } + const carried = decision[field.from]; + if (carried === undefined) { + continue; + } + if (field.type !== undefined && typeof carried !== field.type) { + continue; + } + place(output, path, carried); } - - return { hookSpecificOutput }; + return output; } diff --git a/packages/host-adapter/test/client.test.ts b/packages/host-adapter/test/client.test.ts index 9b98b6ea..0e7e8c3d 100644 --- a/packages/host-adapter/test/client.test.ts +++ b/packages/host-adapter/test/client.test.ts @@ -130,10 +130,9 @@ describe("host -> wire -> policy -> host, end to end", () => { expect(response.error).toBeUndefined(); const { hookSpecificOutput } = renderDecision( - "PreToolUse", response.result as { decision: string } & Record, hookmap, - ); + ) as { hookSpecificOutput: Record }; expect(hookSpecificOutput.permissionDecision).toBe("deny"); expect(typeof hookSpecificOutput.permissionDecisionReason).toBe("string"); @@ -147,11 +146,14 @@ describe("host -> wire -> policy -> host, end to end", () => { expect(response.error).toBeUndefined(); const { hookSpecificOutput } = renderDecision( - "PreToolUse", response.result as { decision: string } & Record, hookmap, - ); + ) as { hookSpecificOutput: Record }; - expect(hookSpecificOutput).toEqual({ hookEventName: "PreToolUse", permissionDecision: "allow" }); + // No `hookEventName` here: it is not a function of the decision, so the + // shim adds it as it wraps (PR #10 review, Critical). What a Claude Code + // process actually reads back, with that field in place, is pinned in + // hosts/claude-code/test/wire-shape.test.ts against the real shim. + expect(hookSpecificOutput).toEqual({ permissionDecision: "allow" }); }); }); diff --git a/packages/host-adapter/test/render-decision.test.ts b/packages/host-adapter/test/render-decision.test.ts index 8ea09253..edf9f560 100644 --- a/packages/host-adapter/test/render-decision.test.ts +++ b/packages/host-adapter/test/render-decision.test.ts @@ -2,126 +2,189 @@ import { describe, expect, it } from "bun:test"; import { loadHookmap, type Hookmap } from "../src/build-envelope.ts"; import { renderDecision } from "../src/render-decision.ts"; -// Same decisions block as S1's real hosts/claude-code/claude-code.hookmap.yaml -// (Task 7's own fixture) -- kept local so these tests don't depend on the -// real file staying byte-identical, except for the one test below that -// deliberately does load the real file. +/** + * A hookmap for a host that is not Claude Code, deliberately. + * + * The old version of this file used the real hookmap's decisions block, which + * meant every assertion here was also an assertion about one host's field + * names -- and that was the shape of the problem PR #10's Critical finding + * named: the adapter's own tests could not tell "renders what the hookmap + * says" apart from "renders permissionDecision". This host nests its decision + * two levels deep under different names, carries a field OUTSIDE that wrapper, + * and has no permission-style field at all. Nothing in the module under test + * knows any of it. + * + * The one test at the bottom does load the real file, so the shipped hookmap + * is still exercised end to end. + */ const hookmap: Hookmap = { - host: "claude-code", + host: "some-other-host", hooks: { - PreToolUse: { + BeforeTool: { acs_method: "steps/toolCallRequest", tool_name: "$.tool_name", arguments: "$.tool_input", }, }, decisions: { - allow: { permissionDecision: "allow" }, - deny: { permissionDecision: "deny", reason_from: "reasoning" }, - ask: { permissionDecision: "ask" }, - defer: { permissionDecision: "defer" }, - modify: { permissionDecision: "allow", updatedInput_from: "modifications" }, + allow: { + output: { + "gate.verdict": { value: "pass" }, + "gate.note": { from: "reasoning", type: "string" }, + }, + }, + deny: { + output: { + "gate.verdict": { value: "fail" }, + "gate.note": { from: "reasoning", type: "string" }, + blocked: { value: true }, + }, + }, + modify: { + output: { + "gate.verdict": { value: "pass" }, + "gate.rewritten": { from: "modifications" }, + }, + }, }, }; describe("renderDecision", () => { - it("renders a deny with the ACS reasoning carried into permissionDecisionReason -- the demo's payoff", () => { - const { hookSpecificOutput } = renderDecision( - "PreToolUse", - { decision: "deny", reasoning: "blocked: destructive command matched a stock rule" }, - hookmap, - ); - - expect(hookSpecificOutput).toEqual({ - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: "blocked: destructive command matched a stock rule", + it("renders exactly the object the hookmap's output paths describe, nesting as declared", () => { + expect(renderDecision({ decision: "deny", reasoning: "blocked: destructive command" }, hookmap)).toEqual({ + gate: { verdict: "fail", note: "blocked: destructive command" }, + blocked: true, }); }); - it("renders an allow with permissionDecision allow and no reason", () => { - const { hookSpecificOutput } = renderDecision("PreToolUse", { decision: "allow" }, hookmap); + it("leaves a `from` field off entirely when the decision does not carry it", () => { + expect(renderDecision({ decision: "allow" }, hookmap)).toEqual({ gate: { verdict: "pass" } }); + }); - expect(hookSpecificOutput).toEqual({ hookEventName: "PreToolUse", permissionDecision: "allow" }); + it("leaves a `from` field off when the decision carries the wrong type for it", () => { + // A Guardian putting an object where the hookmap declared prose would + // otherwise produce an output the host cannot render -- and a host that + // rejects the whole output treats the hook as having produced no decision, + // which is a fail-open from a decision that arrived perfectly well. + expect(renderDecision({ decision: "allow", reasoning: { text: "not a string" } }, hookmap)).toEqual({ + gate: { verdict: "pass" }, + }); }); it("a warn-derived allow (allow + non-empty policy_references) still renders as a plain allow -- no separate rendering (R1.2)", () => { - const { hookSpecificOutput } = renderDecision( - "PreToolUse", - { - decision: "allow", - policy_references: [{ policy_id: "stock_policy_bundle", rule_id: "drift_detected" }], - }, - hookmap, - ); - - expect(hookSpecificOutput).toEqual({ hookEventName: "PreToolUse", permissionDecision: "allow" }); + expect( + renderDecision( + { + decision: "allow", + policy_references: [{ policy_id: "stock_policy_bundle", rule_id: "drift_detected" }], + }, + hookmap, + ), + ).toEqual({ gate: { verdict: "pass" } }); }); - it("renders modify with updatedInput carried from the hookmap-named field", () => { - const { hookSpecificOutput } = renderDecision( - "PreToolUse", - { - decision: "modify", - reasoning: "redacted a secret", - modifications: { arguments: { command: { value: "echo [REDACTED]" } } }, - }, - hookmap, - ); - - expect(hookSpecificOutput.permissionDecision).toBe("allow"); - expect(hookSpecificOutput.updatedInput).toEqual({ arguments: { command: { value: "echo [REDACTED]" } } }); + it("copies a whole object through a `from` field with no declared type", () => { + expect( + renderDecision( + { + decision: "modify", + reasoning: "redacted a secret", + modifications: { parameter_overrides: { command: "echo [REDACTED]" } }, + }, + hookmap, + ), + ).toEqual({ + gate: { verdict: "pass", rewritten: { parameter_overrides: { command: "echo [REDACTED]" } } }, + }); }); - it("is hookmap-driven: mutating decisions.allow changes the rendered output with no code change", () => { + it("is hookmap-driven: mutating a decision's literal changes the rendered output with no code change", () => { const mutated: Hookmap = { ...hookmap, - decisions: { ...hookmap.decisions, allow: { permissionDecision: "ask" } }, + decisions: { ...hookmap.decisions, allow: { output: { "gate.verdict": { value: "review" } } } }, }; - const original = renderDecision("PreToolUse", { decision: "allow" }, hookmap); - const changed = renderDecision("PreToolUse", { decision: "allow" }, mutated); - - expect(original.hookSpecificOutput.permissionDecision).toBe("allow"); - expect(changed.hookSpecificOutput.permissionDecision).toBe("ask"); + expect(renderDecision({ decision: "allow" }, hookmap)).toEqual({ gate: { verdict: "pass" } }); + expect(renderDecision({ decision: "allow" }, mutated)).toEqual({ gate: { verdict: "review" } }); }); - it("is hookmap-driven: mutating deny's reason_from changes which field feeds permissionDecisionReason", () => { + it("is hookmap-driven: mutating a `from` changes which decision field feeds the host field", () => { const mutated: Hookmap = { ...hookmap, - decisions: { ...hookmap.decisions, deny: { permissionDecision: "deny", reason_from: "reason_codes" } }, + decisions: { + ...hookmap.decisions, + deny: { output: { "gate.verdict": { value: "fail" }, "gate.note": { from: "reason_codes" } } }, + }, }; - const { hookSpecificOutput } = renderDecision( - "PreToolUse", - { decision: "deny", reasoning: "human text", reason_codes: "machine_code" }, - mutated, + expect(renderDecision({ decision: "deny", reasoning: "human text", reason_codes: "machine_code" }, mutated)).toEqual( + { gate: { verdict: "fail", note: "machine_code" } }, ); + }); - expect(hookSpecificOutput.permissionDecisionReason).toBe("machine_code"); + it("is hookmap-driven: a path with no dot puts the field alongside the wrapper, not inside it", () => { + // The shape the old, host-named types could not express at all. + const flat: Hookmap = { + ...hookmap, + decisions: { allow: { output: { verdict: { value: "pass" } } } }, + }; + + expect(renderDecision({ decision: "allow" }, flat)).toEqual({ verdict: "pass" }); }); it("throws on an ACS decision absent from the hookmap's decisions block, rather than defaulting", () => { const noDeny: Hookmap = { ...hookmap, decisions: { allow: hookmap.decisions!.allow! } }; - expect(() => renderDecision("PreToolUse", { decision: "deny", reasoning: "x" }, noDeny)).toThrow(); + expect(() => renderDecision({ decision: "deny", reasoning: "x" }, noDeny)).toThrow(/no decisions entry/); }); it("throws when the hookmap has no decisions block at all", () => { - const noDecisions: Hookmap = { host: "claude-code", hooks: hookmap.hooks }; + const noDecisions: Hookmap = { host: "some-other-host", hooks: hookmap.hooks }; + + expect(() => renderDecision({ decision: "allow" }, noDecisions)).toThrow(/no decisions block/); + }); + + it("throws on an entry with an empty or missing output block, rather than rendering nothing", () => { + // An output with no decision in it is read by a host as "the hook produced + // nothing", which is the same bypass a missing entry is, only quieter. + for (const broken of [{}, { output: {} }, null]) { + const bad: Hookmap = { ...hookmap, decisions: { allow: broken } }; + expect(() => renderDecision({ decision: "allow" }, bad)).toThrow(/non-empty "output" block/); + } + }); + + it("throws on an output field naming neither a literal nor a source", () => { + const bad: Hookmap = { ...hookmap, decisions: { allow: { output: { "gate.verdict": { tpye: "string" } } } } }; + + expect(() => renderDecision({ decision: "allow" }, bad)).toThrow(/must name a literal/); + }); - expect(() => renderDecision("PreToolUse", { decision: "allow" }, noDecisions)).toThrow(); + it("throws on two output paths that collide, rather than silently picking one", () => { + const collides: Hookmap = { + ...hookmap, + decisions: { allow: { output: { gate: { value: "pass" }, "gate.verdict": { value: "pass" } } } }, + }; + + expect(() => renderDecision({ decision: "allow" }, collides)).toThrow(/nests under/); + }); + + it("throws on an output path naming a reserved segment, which would mutate a prototype instead of adding a key", () => { + const reserved: Hookmap = { + ...hookmap, + decisions: { allow: { output: { "__proto__.verdict": { value: "pass" } } } }, + }; + + expect(() => renderDecision({ decision: "allow" }, reserved)).toThrow(/addresses no field/); }); it("loads the real claude-code.hookmap.yaml and renders a deny end to end", () => { const real = loadHookmap("hosts/claude-code/claude-code.hookmap.yaml"); - const { hookSpecificOutput } = renderDecision("PreToolUse", { decision: "deny", reasoning: "blocked" }, real); - - expect(hookSpecificOutput).toEqual({ - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: "blocked", + expect(renderDecision({ decision: "deny", reasoning: "blocked" }, real)).toEqual({ + hookSpecificOutput: { + permissionDecision: "deny", + permissionDecisionReason: "blocked", + }, }); }); }); diff --git a/test/invariants.test.ts b/test/invariants.test.ts index d2e7372d..f151455f 100644 --- a/test/invariants.test.ts +++ b/test/invariants.test.ts @@ -81,6 +81,30 @@ describe("architectural invariants", () => { ]); }); + /** + * The other half of R3.2, and the one PR #10's review found false + * (Critical): the adapter is promised to slice V5's second host + * *unchanged*, so it must not name the FIRST host's output fields either. + * `renderDecision` used to declare `HookSpecificOutput` with a mandatory + * `permissionDecision`, and returned `{ hookSpecificOutput }` -- Claude + * Code's wire shape as the shared module's public API. A second host would + * have inherited that vocabulary or forked the module. + * + * All four names now live in hosts/claude-code/: two as data in + * claude-code.hookmap.yaml's output paths, and the wrapper in acs-hook.ts, + * which is what wraps. Same scope note as the gate above -- non-test `.ts` + * under packages/host-adapter/src only, with comments stripped, so a doc + * comment may still explain the boundary it must not cross in code. + */ + it("the host adapter's source names no host output field", () => { + assertNoVocabulary("packages/host-adapter/src", [ + "permissionDecision", + "permissionDecisionReason", + "updatedInput", + "hookSpecificOutput", + ]); + }); + /** * R3.3 -- what makes slice V5's second host cost zero AGT code: the * bridge that knows AGT must never learn a specific host's wire shape, From 3a9f6b4ec1c32b0ed8e5a0d46a8285a7ec0e797f Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Tue, 11 Aug 2026 11:10:26 +0300 Subject: [PATCH 23/39] Give the Guardian client a role, and answer with a decision-or-failure `guardianClient` was a one-method namespace returning a raw JSON-RPC response, so the host shim did the interrogating: read `.error`, throw if present, cast `.result` into a decision shape and hope. That inspection is where "a decision that arrived outranks anything else" lives, and leaving it in a host shim meant slice V5's second host would copy it -- and every way of copying it wrong ends with a tool call proceeding ungoverned. `createGuardianClient(url)` now returns the role. `requestDecision` is told to fetch the decision for an envelope and answers with a discriminated message: `{decisionArrived: true, decision}` or `{decisionArrived: false, failure}`. It never throws, so a caller cannot forget one of the four ways of not getting a decision. A union rather than optional fields, so reading `decision` without checking does not compile. `post` stays as the wire primitive for the one caller whose result is not a decision -- the handshake, which now takes the client rather than a URL, because this module knows the handshake and not how to reach a Guardian. Five tests cover the branches the shim no longer owns, including the one a careless copy gets wrong: a response carrying both a decision and a malformed `error` honours the decision. The shim's failure handling is otherwise unchanged and still the documented V1 placeholder -- stderr and exit 1, with the posture that should replace it belonging to V3 (N6/N7). Addresses: #10 review --- README.md | 2 +- docs/demos/v1-runbook.md | 2 +- hosts/claude-code/acs-hook.ts | 43 +++++-- hosts/claude-code/test/hook.test.ts | 4 +- packages/host-adapter/src/guardian-client.ts | 129 +++++++++++++++++-- packages/host-adapter/src/handshake.ts | 16 ++- packages/host-adapter/src/index.ts | 4 +- packages/host-adapter/test/client.test.ts | 102 +++++++++++++-- 8 files changed, 261 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 92b1afc4..b6b11d46 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ which is exactly the JSON Claude Code's own hook protocol sends and expects back ### Verify ```bash -bun test # 83 tests across 12 files (82 pass, 1 skip), including the R3.2/R3.3 gates below +bun test # 88 tests across 12 files (87 pass, 1 skip), including the R3.2/R3.3 gates below # the skip is the byte-identity check, which needs UPSTREAM_BUNDLE — see verify:pin bun run typecheck # whole-workspace strict TypeScript check, zero errors ``` diff --git a/docs/demos/v1-runbook.md b/docs/demos/v1-runbook.md index b1b4b18a..60224db7 100644 --- a/docs/demos/v1-runbook.md +++ b/docs/demos/v1-runbook.md @@ -12,7 +12,7 @@ 1. You ask Claude Code to run a destructive shell command. 2. Claude Code's transcript shows the tool call **blocked**, with a human-readable deny reason — not a generic "permission denied," but the actual text the policy engine produced (something like *"matched pattern `(?i)rm\s+-[a-z]*r[a-z]*f[a-z]*\s+/(?:\s|$)` at offset 0"*). -3. That reason did not come from Claude Code, and it did not come from a hardcoded string in the hook shim — it travelled from the AGT bridge's Rego evaluation, through the Guardian's `POST /acs` endpoint, over HTTP, through `guardianClient.post` → `renderDecision`, and into `permissionDecisionReason`. The payoff is that this text is real, not stubbed. +3. That reason did not come from Claude Code, and it did not come from a hardcoded string in the hook shim — it travelled from the AGT bridge's Rego evaluation, through the Guardian's `POST /acs` endpoint, over HTTP, through `createGuardianClient(...).requestDecision` → `renderDecision`, and into `permissionDecisionReason`. The payoff is that this text is real, not stubbed. 4. Ask for something harmless (e.g. `ls -la`) in the same session and it runs normally — the hook only interrupts what the policy actually denies. ## Prerequisites diff --git a/hosts/claude-code/acs-hook.ts b/hosts/claude-code/acs-hook.ts index 99c74a6d..f3a1ff99 100644 --- a/hosts/claude-code/acs-hook.ts +++ b/hosts/claude-code/acs-hook.ts @@ -2,9 +2,9 @@ * acs-hook.ts (N1) -- Claude Code's PreToolUse hook shim. * * Deliberately thin: read the hook JSON Claude Code sends on stdin, call - * buildEnvelope -> guardianClient.post -> renderDecision (all three from - * `host-adapter`, packages/host-adapter), wrap the output that comes back - * into the JSON Claude Code expects, and write it to stdout. All logic lives + * buildEnvelope -> createGuardianClient(...).requestDecision -> renderDecision + * (all three from `host-adapter`, packages/host-adapter), wrap what comes + * back into the JSON Claude Code expects, and write it to stdout. All logic lives * in the adapter -- this file is only the wiring a Claude Code hook process * needs (stdin, stdout, exit code, which hookmap file to load) plus the one * thing the adapter must not know: this host's own output shape. Slice V5 @@ -15,7 +15,8 @@ * This file is host-specific by definition (it may name Claude Code * freely) but must not reach into AGT -- it never imports `agt-bridge` or * `guardian`'s server-side pieces, only `host-adapter`'s public surface, - * and talks to the Guardian only over HTTP via `guardianClient.post`. + * and talks to the Guardian only over HTTP, through the client role + * `createGuardianClient` returns. * * Claude Code's hook protocol (see the task brief and * docs/demos/v1-runbook.md): stdin is one JSON object @@ -38,7 +39,7 @@ * posture; V3 should replace it deliberately. */ import { fileURLToPath } from "node:url"; -import { buildEnvelope, guardianClient, loadHookmap, renderDecision, type HostOutput } from "host-adapter"; +import { buildEnvelope, createGuardianClient, loadHookmap, renderDecision, type HostOutput } from "host-adapter"; const HOOKMAP_PATH = fileURLToPath(new URL("./claude-code.hookmap.yaml", import.meta.url)); @@ -97,6 +98,19 @@ function asClaudeCodeOutput(rendered: HostOutput, hookEventName: string): HostOu return { ...rendered, [HOOK_SPECIFIC_OUTPUT]: { hookEventName, ...(wrapper as Record) } }; } +/** + * Turns whatever stood in for a decision into one line of stderr. `failure` is + * `unknown` by design -- it is a throw from the wire, a JSON-RPC `error` + * object, or an Error this shim never constructed -- so this formats all three + * rather than assuming any one of them. + */ +function describeFailure(failure: unknown): string { + if (failure instanceof Error) { + return failure.message; + } + return typeof failure === "string" ? failure : JSON.stringify(failure); +} + async function main(): Promise { const input = await Bun.stdin.text(); const payload = JSON.parse(input) as Record; @@ -109,14 +123,23 @@ async function main(): Promise { const hookmap = loadHookmap(HOOKMAP_PATH); const envelope = buildEnvelope(hookEventName, payload, hookmap); - const guardianUrl = process.env.ACS_GUARDIAN_URL ?? DEFAULT_GUARDIAN_URL; - const response = await guardianClient.post(guardianUrl, envelope); + const guardian = createGuardianClient(process.env.ACS_GUARDIAN_URL ?? DEFAULT_GUARDIAN_URL); - if (response.error) { - throw new Error(`acs-hook: Guardian returned a JSON-RPC error: ${response.error.message}`); + // Told whether a decision arrived, rather than handed a JSON-RPC bag to + // interrogate (PR #10 review, Important). This shim no longer reads + // `.error`, casts `.result`, or decides which of those means "no decision" -- + // getting that branch wrong is a fail-open, and a second host would have + // inherited it by copying this file. + const outcome = await guardian.requestDecision(envelope); + if (!outcome.decisionArrived) { + // V1 placeholder, unchanged: this throw lands in main().catch below, which + // still exits 1. Deciding what a delivery failure means -- the negotiated + // fail-open/fail-closed posture, and auditing a bypass when one is taken -- + // is N6/N7 and belongs to V3. See this file's header. + throw new Error(`acs-hook: no decision arrived from the Guardian: ${describeFailure(outcome.failure)}`); } - const rendered = renderDecision(response.result as { decision: string } & Record, hookmap); + const rendered = renderDecision(outcome.decision, hookmap); process.stdout.write(JSON.stringify(asClaudeCodeOutput(rendered, hookEventName))); } diff --git a/hosts/claude-code/test/hook.test.ts b/hosts/claude-code/test/hook.test.ts index f3117852..55e17034 100644 --- a/hosts/claude-code/test/hook.test.ts +++ b/hosts/claude-code/test/hook.test.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; // is used the same way the shim itself uses it, so this test also proves // what a subprocess sees is what the adapter would have produced directly. import { startGuardian, type StartedGuardian } from "guardian"; -import { buildEnvelope, guardianClient, loadHookmap, renderDecision, type Hookmap } from "host-adapter"; +import { buildEnvelope, createGuardianClient, loadHookmap, renderDecision, type Hookmap } from "host-adapter"; const SHIM_PATH = fileURLToPath(new URL("../acs-hook.ts", import.meta.url)); const HOOKMAP_PATH = fileURLToPath(new URL("../claude-code.hookmap.yaml", import.meta.url)); @@ -84,7 +84,7 @@ describe("acs-hook.ts (N1) -- the Claude Code hook shim, run as a real subproces // demo's entire payoff: what a human reads in the transcript. const hookmap: Hookmap = loadHookmap(HOOKMAP_PATH); const envelope = buildEnvelope("PreToolUse", payload, hookmap); - const response = await guardianClient.post(guardian.url, envelope); + const response = await createGuardianClient(guardian.url).post(envelope); expect(response.error).toBeUndefined(); const expected = renderDecision( response.result as { decision: string } & Record, diff --git a/packages/host-adapter/src/guardian-client.ts b/packages/host-adapter/src/guardian-client.ts index 52d2bfc4..0bb605d2 100644 --- a/packages/host-adapter/src/guardian-client.ts +++ b/packages/host-adapter/src/guardian-client.ts @@ -1,7 +1,35 @@ /** - * guardianClient.post (N4) is the adapter's one wire primitive: POST a - * JSON-RPC 2.0 envelope to the Guardian's ACS endpoint and return the - * parsed JSON-RPC response. + * The Guardian client (N4): the adapter's wire seam. `createGuardianClient` + * binds one ACS endpoint and returns the role a caller collaborates with. + * + * TWO METHODS, AND WHY THE SECOND EXISTS (PR #10 review, Important). This used + * to be a one-method namespace returning a raw JSON-RPC response, and the host + * shim then dug through it: read `.error`, throw if present, cast `.result` + * into a decision shape and hope. That inspection is where "a decision that + * arrived outranks anything else" lives, so leaving it in a host shim meant + * the next host (slice V5) would copy it -- and copying it wrong is a + * fail-open, because every branch that gets a decision wrong ends with the + * tool call proceeding ungoverned. + * + * So `requestDecision` is *told* to fetch the decision for an envelope and + * answers with a message -- `DecisionOrFailure` -- rather than a bag. It never + * throws: every way of not getting a decision (a dead transport, an + * uncorrelated response, a JSON-RPC error, a result naming no decision) is the + * same one answer, "no decision arrived, and here is what stands in its + * place". A caller cannot forget to handle one. + * + * `post` stays as the wire primitive underneath it, for the one caller whose + * method's result is not a decision: the handshake, whose result is a + * ServerHello (handshake.ts). It throws on every delivery failure, which is + * what that caller wants -- a handshake failure is not a step's failure and + * travels separately. + * + * V1 SCOPE: what a host DOES with `decisionArrived: false` is not decided + * here and is not decided at this slice. Negotiating and applying a + * fail-open/fail-closed posture is N6/N7, deliberately deferred to V3; the + * shim's current handling is a placeholder its own header documents as one. + * This module's contribution is only that the failure arrives as one answer + * instead of a bag every caller re-interprets. * * Correlation note (Task 7 -> Task 8): buildEnvelope sets the envelope's * top-level `id` equal to `params.request_id` (a fresh uuid per call). @@ -14,10 +42,12 @@ * not a lookup table matching concurrent in-flight requests. Nothing about * Task 7's choice needs to change. * - * R3.2: this module knows JSON-RPC and HTTP, nothing else. It has no - * runtime dependency on the Guardian or policy-bridge packages -- it talks + * R3.2: this module knows JSON-RPC, HTTP and ACS's decision vocabulary, + * nothing else -- no policy-runtime vocabulary and no host vocabulary. It has + * no runtime dependency on the Guardian or policy-bridge packages -- it talks * to the Guardian only over the wire, at whatever `url` the caller gives. */ +import type { AcsDecision } from "./decision-message.ts"; /** The minimal JSON-RPC 2.0 request shape this client sends. Loose on * `params` on purpose: buildEnvelope's ACS request params and handshake's @@ -51,16 +81,56 @@ export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse; export class GuardianResponseMismatchError extends Error { constructor(requestId: string | number, responseId: string | number | null) { super( - `guardianClient.post: response id ${JSON.stringify(responseId)} does not correlate with request id ${JSON.stringify(requestId)}`, + `GuardianClient.post: response id ${JSON.stringify(responseId)} does not correlate with request id ${JSON.stringify(requestId)}`, ); this.name = "GuardianResponseMismatchError"; } } -export const guardianClient = { - /** POSTs `envelope` to `url` as JSON, parses the JSON-RPC response, and - * returns it once its `id` is confirmed to match the request's `id`. */ - async post(url: string, envelope: JsonRpcRequest): Promise { +/** + * What came back when a decision was asked for. Exactly one of the two cases, + * discriminated by the only question that matters at this seam: did a decision + * arrive? + * + * - `decisionArrived: true` -- one did. What the Guardian sent, unvalidated. + * - `decisionArrived: false` -- none did, and `failure` is whatever stands + * in its place: the throw from the wire, the JSON-RPC `error` object the + * Guardian answered with, or (for a response carrying neither) an Error + * saying so. + * + * A union rather than a bag with optional fields, so a caller that reads + * `decision` without checking does not compile. + */ +export type DecisionOrFailure = + | { readonly decisionArrived: true; readonly decision: AcsDecision } + | { readonly decisionArrived: false; readonly failure: unknown }; + +/** + * The Guardian, as the adapter's callers depend on it: a role bound to one ACS + * endpoint, not a URL passed around and a namespace of free functions. + */ +export type GuardianClient = { + /** + * Asks for the decision on `envelope` and answers with a message. Never + * throws -- see DecisionOrFailure. + */ + requestDecision(envelope: JsonRpcRequest): Promise; + /** + * The wire primitive: POSTs `envelope` as JSON, parses the JSON-RPC + * response, and returns it once its `id` is confirmed to match the + * request's. Throws for every delivery failure. + * + * For a method whose result is not a decision -- today only + * `handshake/hello`, whose result is a ServerHello. A caller after a + * decision uses `requestDecision` instead, and no caller in this package + * inspects a response for one. + */ + post(envelope: JsonRpcRequest): Promise; +}; + +/** Binds a Guardian's ACS endpoint and returns the client role for it. */ +export function createGuardianClient(url: string): GuardianClient { + async function post(envelope: JsonRpcRequest): Promise { const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, @@ -74,5 +144,40 @@ export const guardianClient = { } return response; - }, -}; + } + + return { + post, + + async requestDecision(envelope: JsonRpcRequest): Promise { + let response: JsonRpcResponse; + try { + response = await post(envelope); + } catch (failure) { + // A dead transport, an uncorrelated response, a body that is not JSON. + // None of them carry a decision, so all of them are the same answer -- + // and turning the throw into that answer here is what stops a caller's + // catch block from having to work out which stage of its own sequence + // threw. + return { decisionArrived: false, failure }; + } + + // An arriving decision is checked for FIRST. A JSON-RPC response + // carrying both `error` and `result` is malformed per JSON-RPC, but if + // the `result` names a decision then a decision did arrive, and an + // arriving `deny` must be honoured -- answering it with a failure + // instead would let a delivery-failure rule overrule a policy decision. + const arrived = response.result as AcsDecision | undefined; + if (typeof arrived?.decision === "string") { + return { decisionArrived: true, decision: arrived }; + } + + // Anything with no decision in it is a delivery failure, and the `error` + // is used as the failure when there is one. + return { + decisionArrived: false, + failure: response.error ?? new Error("guardian's response carried neither a decision nor an error"), + }; + }, + }; +} diff --git a/packages/host-adapter/src/handshake.ts b/packages/host-adapter/src/handshake.ts index 2610f971..53e8d794 100644 --- a/packages/host-adapter/src/handshake.ts +++ b/packages/host-adapter/src/handshake.ts @@ -13,18 +13,21 @@ * * R3.2: this module knows ACS handshake vocabulary and JSON-RPC, nothing * else. It has no runtime dependency on the Guardian package -- it talks - * to the Guardian only through guardianClient.post, over the wire. + * to the Guardian only through the client role it is given, over the wire. */ import { randomUUID } from "node:crypto"; -import { guardianClient, type JsonRpcRequest } from "./guardian-client.ts"; +import type { GuardianClient, JsonRpcRequest } from "./guardian-client.ts"; import type { SessionConfig, SessionConfigStore } from "./session-config.ts"; const HANDSHAKE_METHOD = "handshake/hello"; const ACS_VERSION = "0.1.0"; export type HandshakeOptions = { - /** The Guardian's ACS endpoint, as returned by startGuardian (test-only) or configured for the deployment. */ - url: string; + /** The Guardian to negotiate with -- `createGuardianClient(url)` for the + * endpoint startGuardian returned (test-only) or the one this deployment is + * configured for. A client rather than a URL because that is the seam: this + * module knows the handshake, not how to reach a Guardian. */ + guardian: GuardianClient; /** This Observed Agent's identity on the wire (ACS metadata.agent_id). */ agentId: string; /** This session's identity on the wire (ACS metadata.session_id, a uuid). */ @@ -60,7 +63,10 @@ export async function handshake(options: HandshakeOptions, store: SessionConfigS }, }; - const response = await guardianClient.post(options.url, envelope); + // `post`, not `requestDecision`: this method's result is a ServerHello, not + // a decision, and a handshake failure is a different incident from a step + // that got no decision. It travels as a throw, separately. + const response = await options.guardian.post(envelope); if (response.error) { throw new Error(`handshake: Guardian rejected handshake/hello: ${response.error.message}`); } diff --git a/packages/host-adapter/src/index.ts b/packages/host-adapter/src/index.ts index 306b3f29..8b6ac850 100644 --- a/packages/host-adapter/src/index.ts +++ b/packages/host-adapter/src/index.ts @@ -16,8 +16,10 @@ export { type AcsRequestEnvelope, } from "./build-envelope.ts"; export { - guardianClient, + createGuardianClient, GuardianResponseMismatchError, + type DecisionOrFailure, + type GuardianClient, type JsonRpcRequest, type JsonRpcResponse, type JsonRpcSuccessResponse, diff --git a/packages/host-adapter/test/client.test.ts b/packages/host-adapter/test/client.test.ts index 0e7e8c3d..30629f4d 100644 --- a/packages/host-adapter/test/client.test.ts +++ b/packages/host-adapter/test/client.test.ts @@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; // the precedent (Task 7). import { startGuardian, type StartedGuardian } from "guardian"; import { buildEnvelope, loadHookmap, type Hookmap } from "../src/build-envelope.ts"; -import { guardianClient } from "../src/guardian-client.ts"; +import { createGuardianClient } from "../src/guardian-client.ts"; import { handshake } from "../src/handshake.ts"; import { renderDecision } from "../src/render-decision.ts"; import { createSessionConfigStore } from "../src/session-config.ts"; @@ -33,7 +33,7 @@ afterAll(async () => { await guardian.close(); }); -describe("guardianClient.post", () => { +describe("GuardianClient.post", () => { it("posts the envelope as well-formed JSON-RPC 2.0 (jsonrpc, method, id, params) over HTTP", async () => { let capturedBody: Record | undefined; let capturedContentType: string | null = null; @@ -48,7 +48,7 @@ describe("guardianClient.post", () => { try { const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); - const response = await guardianClient.post(`http://localhost:${mock.port}/acs`, envelope); + const response = await createGuardianClient(`http://localhost:${mock.port}/acs`).post(envelope); expect(capturedBody).toEqual(envelope as unknown as Record); if (capturedContentType === null) { @@ -70,7 +70,7 @@ describe("guardianClient.post", () => { it("correlates the response to the request by JSON-RPC id -- buildEnvelope's id equals params.request_id, and that round-trips fine", async () => { const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); - const response = await guardianClient.post(guardian.url, envelope); + const response = await createGuardianClient(guardian.url).post(envelope); expect(response.id).toBe(envelope.id); expect(envelope.id).toBe(envelope.params.request_id); @@ -86,7 +86,7 @@ describe("guardianClient.post", () => { try { const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); - await expect(guardianClient.post(`http://localhost:${mock.port}/acs`, envelope)).rejects.toThrow(); + await expect(createGuardianClient(`http://localhost:${mock.port}/acs`).post(envelope)).rejects.toThrow(); } finally { mock.stop(true); } @@ -95,20 +95,104 @@ describe("guardianClient.post", () => { it("against a real Guardian: steps/toolCallRequest for ls -la allows, echoing the request id", async () => { const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); - const response = await guardianClient.post(guardian.url, envelope); + const response = await createGuardianClient(guardian.url).post(envelope); expect(response.error).toBeUndefined(); expect(response.result?.decision).toBe("allow"); }); }); +// PR #10 review, Important: the shim used to receive a raw JSON-RPC response +// and work out for itself whether a decision was in it. These are the branches +// it no longer owns -- and the last of them is the one a copy of that +// inspection would get wrong. +describe("GuardianClient.requestDecision", () => { + it("answers with the decision when one arrives", async () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + + const outcome = await createGuardianClient(guardian.url).requestDecision(envelope); + + expect(outcome.decisionArrived).toBe(true); + expect(outcome.decisionArrived && outcome.decision.decision).toBe("allow"); + }); + + it("answers 'no decision' with the Guardian's own JSON-RPC error as the failure, rather than throwing", async () => { + // A method this Guardian dispatches no handler for. + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + const unknownMethod = { ...envelope, method: "steps/sessionStart" }; + + const outcome = await createGuardianClient(guardian.url).requestDecision(unknownMethod); + + expect(outcome.decisionArrived).toBe(false); + expect((outcome.decisionArrived === false ? outcome.failure : undefined) as { code?: number }).toHaveProperty( + "code", + ); + }); + + it("answers 'no decision' when the transport itself fails, rather than throwing", async () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + + // Port 1 with nothing listening: a refused connection, not a slow one. + const outcome = await createGuardianClient("http://localhost:1/acs").requestDecision(envelope); + + expect(outcome.decisionArrived).toBe(false); + }); + + it("honours a decision that arrives alongside a malformed `error`, never letting the failure outrank it", async () => { + // A response carrying both is malformed per JSON-RPC, but a decision is in + // it -- and a caller that checked `error` first would answer a real deny + // with a delivery-failure path instead of honouring it. + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as { id: string | number }; + return Response.json({ + jsonrpc: "2.0", + id: body.id, + result: { decision: "deny", reasoning: "blocked" }, + error: { code: -32020, message: "something also went wrong" }, + }); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap); + const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`).requestDecision(envelope); + + expect(outcome.decisionArrived).toBe(true); + expect(outcome.decisionArrived && outcome.decision.decision).toBe("deny"); + } finally { + mock.stop(true); + } + }); + + it("answers 'no decision' for a result naming none, rather than passing an undecided bag on to be rendered", async () => { + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as { id: string | number }; + return Response.json({ jsonrpc: "2.0", id: body.id, result: { type: "final" } }); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`).requestDecision(envelope); + + expect(outcome.decisionArrived).toBe(false); + } finally { + mock.stop(true); + } + }); +}); + describe("handshake (N5)", () => { it("sends handshake/hello and stores timeout_config and on_decision_failure into the session config store (S13)", async () => { const store = createSessionConfigStore(); expect(store.get()).toBeUndefined(); const serverHello = await handshake( - { url: guardian.url, agentId: "claude-code", sessionId: crypto.randomUUID() }, + { guardian: createGuardianClient(guardian.url), agentId: "claude-code", sessionId: crypto.randomUUID() }, store, ); @@ -126,7 +210,7 @@ describe("host -> wire -> policy -> host, end to end", () => { it("a real rm -rf / tool call denies through the real Guardian, and renders with the reasoning in permissionDecisionReason", async () => { const envelope = buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap); - const response = await guardianClient.post(guardian.url, envelope); + const response = await createGuardianClient(guardian.url).post(envelope); expect(response.error).toBeUndefined(); const { hookSpecificOutput } = renderDecision( @@ -142,7 +226,7 @@ describe("host -> wire -> policy -> host, end to end", () => { it("a real ls -la tool call allows through the real Guardian, and renders as a plain allow", async () => { const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); - const response = await guardianClient.post(guardian.url, envelope); + const response = await createGuardianClient(guardian.url).post(envelope); expect(response.error).toBeUndefined(); const { hookSpecificOutput } = renderDecision( From a75ddaa1e146afbd3c44185c2279c8384b22f89f Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Tue, 11 Aug 2026 11:13:01 +0300 Subject: [PATCH 24/39] Name each half of the handshake once, and check the config instead of casting Four names sat on one negotiation: host `handshake`, Guardian `handshakeResponder`, stored `SessionConfig`, wire `ServerHello` -- with the last two joined by `as unknown as SessionConfig`, a rename dressed as a type that checked nothing. Each side now names the message it owns, with the same `` morphology: `buildServerHello` on the Guardian, `negotiateSessionConfig` on the host. The asymmetry between "build" and "negotiate" is the truth about the code -- the host performs an exchange, and the Guardian returns constants, which is also why "responder" had to go: nothing there reads the ClientHello. `SessionConfig` stays the one noun for the stored value, and the cast is replaced by `isSessionConfig`, which requires the two fields this host actually reads. Deliberately not handshake.json's five: a predicate named for the wire message would over-claim in exactly the way the cast did. That leaves "ServerHello" scoped to what the Guardian sent before this host has confirmed it can use it -- no stored value, field or type carries that name. A malformed hello now fails at the handshake with nothing stored, instead of being written and then rejected unremarked by every later read. Addresses: #10 review --- README.md | 2 +- packages/guardian/src/handshake.ts | 36 +++++++----- packages/guardian/src/index.ts | 2 +- packages/guardian/src/server.ts | 4 +- packages/host-adapter/src/handshake.ts | 61 +++++++++++++++++---- packages/host-adapter/src/index.ts | 9 ++- packages/host-adapter/src/session-config.ts | 49 ++++++++++++++--- packages/host-adapter/test/client.test.ts | 46 ++++++++++++++-- 8 files changed, 167 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index b6b11d46..5a1bc46d 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ which is exactly the JSON Claude Code's own hook protocol sends and expects back ### Verify ```bash -bun test # 88 tests across 12 files (87 pass, 1 skip), including the R3.2/R3.3 gates below +bun test # 89 tests across 12 files (88 pass, 1 skip), including the R3.2/R3.3 gates below # the skip is the byte-identity check, which needs UPSTREAM_BUNDLE — see verify:pin bun run typecheck # whole-workspace strict TypeScript check, zero errors ``` diff --git a/packages/guardian/src/handshake.ts b/packages/guardian/src/handshake.ts index 0779a1fd..312670a2 100644 --- a/packages/guardian/src/handshake.ts +++ b/packages/guardian/src/handshake.ts @@ -1,6 +1,6 @@ /** - * handshakeResponder builds the Guardian's ServerHello (N28) -- the - * response to `handshake/hello` -- per handshake.json's ServerHello $def + * buildServerHello builds the Guardian's ServerHello (N28) -- the answer to + * `handshake/hello` -- per handshake.json's ServerHello $def * (spec/acs/specification/v0.1.0/handshake.json). * * V1 scope: @@ -11,16 +11,26 @@ * the posture (the fail-open audit path, N6/N7) is V3. Nothing here * reads or acts on it beyond returning it. * - * Fix wave finding 7 (honesty, not a scope increase): this responder never - * reads the incoming ClientHello -- the client (host-adapter's `handshake`) - * genuinely sends one, but every field below is a constant, returned - * unconditionally. So "negotiated_version" and "selected_transport" are - * DECLARED by this Guardian, not actually negotiated against what the - * client proposed. That distinction matters in a reference implementation - * of a wire *contract*. Real negotiation (reading ClientHello, picking a - * mutually-supported version/transport, rejecting what isn't) is future - * work, not attempted here -- see the matching note in - * docs/demos/v1-runbook.md. + * WHY THIS IS NOT CALLED `handshakeResponder` (PR #10 review, Important, and + * fix wave finding 7 before it -- honesty, not a scope increase): this + * function never reads the incoming ClientHello. The client (host-adapter's + * `negotiateSessionConfig`) genuinely sends one, but every field below is a + * constant, returned unconditionally. So "negotiated_version" and + * "selected_transport" are DECLARED by this Guardian, not actually negotiated + * against what the client proposed, and a name containing "responder" claimed + * a negotiation the body does not perform. `build` says exactly what + * happens: it assembles the one message this side of the handshake owns. That + * distinction matters in a reference implementation of a wire *contract*. + * Real negotiation (reading ClientHello, picking a mutually-supported + * version/transport, rejecting what isn't) is future work, not attempted here + * -- see the matching note in docs/demos/v1-runbook.md. + * + * Naming symmetry with the host side: each side of this exchange now names its + * own message once, with the same `` morphology -- + * `buildServerHello` here, `negotiateSessionConfig` in + * packages/host-adapter/src/handshake.ts. The asymmetry between "build" and + * "negotiate" is deliberate and is the truth about the code: the host really + * does perform an exchange, and this side really does return constants. */ export type ServerHello = { @@ -45,7 +55,7 @@ const METHODS_EVALUATED = ["steps/toolCallRequest"]; */ const DEFAULT_TIMEOUT_MS = 5000; -export function handshakeResponder(): ServerHello { +export function buildServerHello(): ServerHello { return { negotiated_version: NEGOTIATED_VERSION, methods_evaluated: METHODS_EVALUATED, diff --git a/packages/guardian/src/index.ts b/packages/guardian/src/index.ts index b5c5c4fe..d3c7f9d1 100644 --- a/packages/guardian/src/index.ts +++ b/packages/guardian/src/index.ts @@ -1,6 +1,6 @@ /** Public surface of the guardian package. */ export { startGuardian, type StartGuardianOptions, type StartedGuardian } from "./server.ts"; -export { handshakeResponder, type ServerHello } from "./handshake.ts"; +export { buildServerHello, type ServerHello } from "./handshake.ts"; export { validateEnvelope, isToolCallRequest, diff --git a/packages/guardian/src/server.ts b/packages/guardian/src/server.ts index 819317a7..9383be1f 100644 --- a/packages/guardian/src/server.ts +++ b/packages/guardian/src/server.ts @@ -40,7 +40,7 @@ import { validateEnvelope, type AcsRequestEnvelope, } from "./validate-envelope.ts"; -import { handshakeResponder } from "./handshake.ts"; +import { buildServerHello } from "./handshake.ts"; const MAPPING_PATH = fileURLToPath(new URL("../../../mapping.yaml", import.meta.url)); @@ -141,7 +141,7 @@ async function handleAcsRequest( } if (envelope.method === HANDSHAKE_METHOD) { - return successResponse(envelope.id, handshakeResponder()); + return successResponse(envelope.id, buildServerHello()); } // `isToolCallRequest`, not a method comparison spelled out again here: the diff --git a/packages/host-adapter/src/handshake.ts b/packages/host-adapter/src/handshake.ts index 53e8d794..2095885d 100644 --- a/packages/host-adapter/src/handshake.ts +++ b/packages/host-adapter/src/handshake.ts @@ -1,6 +1,30 @@ /** - * handshake (N5) sends `handshake/hello` to the Guardian and stores the - * returned ServerHello into a session config store (S13). + * N5, the host half of `handshake/hello`: send a ClientHello, keep what comes + * back as this session's config (S13). + * + * ONE NAME FOR THE STORED MESSAGE (PR #10 review, Important and naming + * symmetry). This module speaks `SessionConfig` throughout -- the message a + * host stores and reads its posture and timeout from -- and not `ServerHello`, + * which is the Guardian's noun for what it emits + * (packages/guardian/src/handshake.ts's `buildServerHello`). The two used to be + * used interchangeably here for the same value, joined by + * `as unknown as SessionConfig`: a rename dressed as a type, checking nothing. + * + * `SessionConfig` is the honest name on this side, because `isSessionConfig` + * requires the two fields this host actually needs, not the five + * handshake.json's ServerHello $def requires -- so naming the stored type after + * the wire message would over-claim what this host validates, which is the same + * defect as a type that claims a check it does not perform. That leaves + * `ServerHello` a scoped noun rather than a second name for one message, and + * the scope is exactly one thing: what the Guardian sent, before this host has + * confirmed it can use it. No stored value, field, or type is called a + * ServerHello anywhere. + * + * `negotiateSessionConfig`, not `handshake`: the old name was one of four for a + * single negotiation (`handshake` / `handshakeResponder` / `SessionConfig` / + * `ServerHello`), and the least informative of them -- a bare wire verb that + * said nothing about what the caller gets. This one names the message it + * produces, matching the `` shape of the Guardian's own half. * * V1 SCOPE -- stores only. Applying the negotiated posture (falling back * to `timeout_config` when the Guardian is slow or silent; recording a @@ -17,7 +41,7 @@ */ import { randomUUID } from "node:crypto"; import type { GuardianClient, JsonRpcRequest } from "./guardian-client.ts"; -import type { SessionConfig, SessionConfigStore } from "./session-config.ts"; +import { isSessionConfig, type SessionConfig, type SessionConfigStore } from "./session-config.ts"; const HANDSHAKE_METHOD = "handshake/hello"; const ACS_VERSION = "0.1.0"; @@ -35,11 +59,15 @@ export type HandshakeOptions = { }; /** - * Sends `handshake/hello`, waits for the Guardian's ServerHello, stores it - * into `store`, and returns it. Throws if the Guardian responds with a - * JSON-RPC error rather than a result. + * Sends `handshake/hello`, waits for the Guardian's ServerHello, stores the + * session config it validates out of that arrival, and returns that config. + * Throws if the Guardian responds with a JSON-RPC error rather than a result, + * or if what arrived is not a usable session config. */ -export async function handshake(options: HandshakeOptions, store: SessionConfigStore): Promise { +export async function negotiateSessionConfig( + options: HandshakeOptions, + store: SessionConfigStore, +): Promise { const requestId = randomUUID(); const envelope: JsonRpcRequest = { @@ -71,7 +99,20 @@ export async function handshake(options: HandshakeOptions, store: SessionConfigS throw new Error(`handshake: Guardian rejected handshake/hello: ${response.error.message}`); } - const serverHello = response.result as unknown as SessionConfig; - store.set(serverHello); - return serverHello; + // Validated BEFORE storing, not cast and hoped for. This is the single point + // where the Guardian's ServerHello becomes this host's SessionConfig, and it + // becomes one by being checked -- named for the wire while it is still only + // an arrival, and for the store once it is one. + const serverHello: unknown = response.result; + if (!isSessionConfig(serverHello)) { + throw new Error( + `handshake: the Guardian's ServerHello is not a usable session config -- expected an object with ` + + `on_decision_failure "proceed" or "deny" and a numeric timeout_config.default_ms, got ` + + `${JSON.stringify(serverHello)}`, + ); + } + + const sessionConfig: SessionConfig = serverHello; + store.set(sessionConfig); + return sessionConfig; } diff --git a/packages/host-adapter/src/index.ts b/packages/host-adapter/src/index.ts index 8b6ac850..14d2ba4b 100644 --- a/packages/host-adapter/src/index.ts +++ b/packages/host-adapter/src/index.ts @@ -27,5 +27,10 @@ export { } from "./guardian-client.ts"; export { renderDecision, type HostOutput } from "./render-decision.ts"; export { type AcsDecision } from "./decision-message.ts"; -export { handshake, type HandshakeOptions } from "./handshake.ts"; -export { createSessionConfigStore, type SessionConfig, type SessionConfigStore } from "./session-config.ts"; +export { negotiateSessionConfig, type HandshakeOptions } from "./handshake.ts"; +export { + createSessionConfigStore, + isSessionConfig, + type SessionConfig, + type SessionConfigStore, +} from "./session-config.ts"; diff --git a/packages/host-adapter/src/session-config.ts b/packages/host-adapter/src/session-config.ts index b792a4d4..bee2c2c0 100644 --- a/packages/host-adapter/src/session-config.ts +++ b/packages/host-adapter/src/session-config.ts @@ -1,8 +1,13 @@ /** - * The negotiated session config store (S13): where handshake() (N5) writes - * the Guardian's ServerHello once a session's capability negotiation + * The negotiated session config store (S13): where negotiateSessionConfig (N5) + * writes this session's config once a session's capability negotiation * completes. * + * `SessionConfig` is the one noun for that stored message, here as in + * handshake.ts -- "ServerHello" names what the Guardian sent, and appears below + * only where the subject genuinely is the arrival rather than the stored value + * (PR #10 review, naming symmetry; see handshake.ts's header for the rule). + * * V1 SCOPE: storage only. Nothing in this module reads or acts on the * stored config -- applying it (falling back to timeout_config when the * Guardian is silent, honoring on_decision_failure's fail-open/fail-closed @@ -15,10 +20,11 @@ * assertions. */ -/** The fields of the Guardian's ServerHello this store holds. Loose on - * purpose: a later slice's negotiation may return fields this slice never - * names -- `Record` lets those round-trip through the - * store untouched rather than being silently dropped. */ +/** The two fields of the Guardian's answer this host actually reads, which is + * what makes this the stored config rather than a claim about the wire message. + * Loose on purpose beyond them: a later slice's negotiation may return fields + * this slice never names -- `Record` lets those round-trip + * through the store untouched rather than being silently dropped. */ export type SessionConfig = { timeout_config: { default_ms: number; per_method_ms?: Record }; on_decision_failure: "proceed" | "deny"; @@ -27,10 +33,39 @@ export type SessionConfig = { export type SessionConfigStore = { /** The most recently stored config, or undefined before any handshake completes. */ get(): SessionConfig | undefined; - /** Overwrites the stored config. Called by handshake() (N5) with the Guardian's ServerHello. */ + /** Overwrites the stored config. Called by negotiateSessionConfig (N5) with + * the config it validated out of the Guardian's ServerHello. */ set(config: SessionConfig): void; }; +/** + * A `SessionConfig` must at minimum carry the two fields this host reads. The + * predicate exists so `negotiateSessionConfig` (N5) can ASK that question of + * the Guardian's ServerHello before storing one, instead of casting the + * arrival into this type and calling it a config (PR #10 review, Important): + * `as unknown as SessionConfig` made the name a claim nothing checked, which + * is the same defect as a type named for a validation it does not perform. + * + * Deliberately NOT the five fields handshake.json's ServerHello $def requires. + * This checks what this host needs, so `SessionConfig` is the honest name for + * what it certifies; naming the stored type after the wire message would + * over-claim in exactly the way the cast did. + */ +export function isSessionConfig(value: unknown): value is SessionConfig { + if (typeof value !== "object" || value === null) { + return false; + } + const candidate = value as Record; + const posture = candidate.on_decision_failure; + const timeout = candidate.timeout_config; + return ( + (posture === "proceed" || posture === "deny") && + typeof timeout === "object" && + timeout !== null && + typeof (timeout as Record).default_ms === "number" + ); +} + /** Creates a fresh, empty session config store. */ export function createSessionConfigStore(): SessionConfigStore { let current: SessionConfig | undefined; diff --git a/packages/host-adapter/test/client.test.ts b/packages/host-adapter/test/client.test.ts index 30629f4d..dcc50355 100644 --- a/packages/host-adapter/test/client.test.ts +++ b/packages/host-adapter/test/client.test.ts @@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { startGuardian, type StartedGuardian } from "guardian"; import { buildEnvelope, loadHookmap, type Hookmap } from "../src/build-envelope.ts"; import { createGuardianClient } from "../src/guardian-client.ts"; -import { handshake } from "../src/handshake.ts"; +import { negotiateSessionConfig } from "../src/handshake.ts"; import { renderDecision } from "../src/render-decision.ts"; import { createSessionConfigStore } from "../src/session-config.ts"; @@ -186,24 +186,58 @@ describe("GuardianClient.requestDecision", () => { }); }); -describe("handshake (N5)", () => { +describe("negotiateSessionConfig (N5)", () => { it("sends handshake/hello and stores timeout_config and on_decision_failure into the session config store (S13)", async () => { const store = createSessionConfigStore(); expect(store.get()).toBeUndefined(); - const serverHello = await handshake( + const sessionConfig = await negotiateSessionConfig( { guardian: createGuardianClient(guardian.url), agentId: "claude-code", sessionId: crypto.randomUUID() }, store, ); - expect(serverHello.on_decision_failure).toBe("proceed"); - expect(serverHello.timeout_config.default_ms).toBeGreaterThan(0); + expect(sessionConfig.on_decision_failure).toBe("proceed"); + expect(sessionConfig.timeout_config.default_ms).toBeGreaterThan(0); const stored = store.get(); expect(stored).toBeDefined(); - expect(stored?.timeout_config).toEqual(serverHello.timeout_config); + expect(stored?.timeout_config).toEqual(sessionConfig.timeout_config); expect(stored?.on_decision_failure).toBe("proceed"); }); + + // PR #10 review, Important: the ServerHello used to become a SessionConfig by + // `as unknown as SessionConfig` -- a rename dressed as a type. Now it becomes + // one by being checked, so a Guardian emitting the wrong shape fails at the + // handshake instead of writing an unusable config that every later read + // silently rejects. + it("refuses a ServerHello that is not a usable session config, rather than casting it into one", async () => { + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as { id: string | number }; + return Response.json({ jsonrpc: "2.0", id: body.id, result: { negotiated_version: "0.1.0" } }); + }, + }); + + try { + const store = createSessionConfigStore(); + + await expect( + negotiateSessionConfig( + { + guardian: createGuardianClient(`http://localhost:${mock.port}/acs`), + agentId: "claude-code", + sessionId: crypto.randomUUID(), + }, + store, + ), + ).rejects.toThrow(/not a usable session config/); + + expect(store.get()).toBeUndefined(); + } finally { + mock.stop(true); + } + }); }); describe("host -> wire -> policy -> host, end to end", () => { From c872d1195178f9db15a0b74e2c7e940fd00ed388 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Tue, 11 Aug 2026 11:14:35 +0300 Subject: [PATCH 25/39] Depend on a bridge role, and make the snapshot a message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createBridge` returned an anonymous object, so the Guardian's own signature read `ReturnType` -- a dependency on what one factory happens to return rather than on a role. `PolicyBridge` names it: something you can tell to evaluate a snapshot at an intervention point. The noun rhymes with the factory verb on the same principle as `loadHookmap` ∥ `loadMapping`, and it keeps AGT out of the type, because the Guardian depends on this role and not on AGT. The snapshot at that seam was `Record` on both sides: the bridge could be handed anything, and the only description of a pre_tool_call snapshot was the object literal that built one. `AgtPreToolCallSnapshot` says what it is, named for the intervention point that fixes its shape, so a later slice's post_tool_call snapshot is a sibling rather than a widening. The bridge keeps an open `InterventionSnapshot` alias, since which shape a point takes is the assembling caller's knowledge, not the bridge's. Four casts in assemble-snapshot.test.ts go away as a consequence -- the assertions read the declared type instead of a re-asserted shape. Addresses: #10 review --- packages/agt-bridge/src/index.ts | 39 ++++++++++++++++++- packages/agt-bridge/test/bridge.test.ts | 4 +- packages/guardian/src/assemble-snapshot.ts | 33 +++++++++++++++- packages/guardian/src/index.ts | 2 +- packages/guardian/src/server.ts | 7 +++- .../guardian/test/assemble-snapshot.test.ts | 16 ++++---- 6 files changed, 86 insertions(+), 15 deletions(-) diff --git a/packages/agt-bridge/src/index.ts b/packages/agt-bridge/src/index.ts index bd7058be..3f3911ce 100644 --- a/packages/agt-bridge/src/index.ts +++ b/packages/agt-bridge/src/index.ts @@ -15,11 +15,46 @@ export type BridgeResult = { transformedPolicyTarget?: unknown; }; +/** + * The snapshot a caller hands an intervention point: the policy input + * document, as JSON. Deliberately as open as the wire it models -- each + * intervention point has its own snapshot shape (AGT-SNAPSHOT-1.0.md), and the + * shape for a given point is the assembling caller's knowledge, not this + * bridge's. What the alias buys is that the seam names the message rather than + * passing an anonymous dict: a caller reading `evaluate(point, snapshot: + * InterventionSnapshot)` is told what to build. + */ +export type InterventionSnapshot = Record; + +/** + * The role the Guardian actually depends on: something you can tell to + * evaluate a snapshot at an intervention point and that answers with a + * verdict. `createBridge` returned an anonymous object before, so the + * Guardian's own signatures read `ReturnType` -- a + * dependency on a factory's implementation rather than on a role (PR #10 + * review). + * + * Named `PolicyBridge`, not `InterventionEvaluator`, on the same principle as + * `loadHookmap` ∥ `loadMapping`: the factory verb and the role noun should + * rhyme. `createBridge` makes a bridge, every call site already calls its + * variable `bridge`, and the package is `agt-bridge` -- a third noun for the + * same collaborator would cost a reader a translation at every seam it appears + * at. `Policy` says which bridge, since "bridge" alone would not survive a + * second one. + * + * The AGT half of the name stays out of the type, which matters: the Guardian + * depends on this role, not on AGT, and R3.3's gate is what keeps that honest + * in the other direction. + */ +export type PolicyBridge = { + evaluate(point: string, snapshot: InterventionSnapshot): Promise; +}; + /** * N31 — construct once at boot. N30 — evaluate per decision. * Stateless: nothing is retained between evaluate() calls (R6.1). */ -export function createBridge(manifestPath: string) { +export function createBridge(manifestPath: string): PolicyBridge { if (manifestPath.includes("/./")) { throw new Error( `manifest path contains "/./": ${manifestPath}. AGT joins this verbatim and OPA ` + @@ -29,7 +64,7 @@ export function createBridge(manifestPath: string) { const control = AgentControl.fromPath(manifestPath); return { - async evaluate(point: string, snapshot: Record): Promise { + async evaluate(point: string, snapshot: InterventionSnapshot): Promise { const result = await control.evaluateInterventionPoint(point as never, snapshot as never); return { verdict: result.verdict as AgtVerdict, diff --git a/packages/agt-bridge/test/bridge.test.ts b/packages/agt-bridge/test/bridge.test.ts index 512ea187..51c30897 100644 --- a/packages/agt-bridge/test/bridge.test.ts +++ b/packages/agt-bridge/test/bridge.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it, beforeAll } from "bun:test"; -import { createBridge } from "../src/index.ts"; +import { createBridge, type PolicyBridge } from "../src/index.ts"; 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" }, }); -let bridge: ReturnType; +let bridge: PolicyBridge; beforeAll(() => { bridge = createBridge("policy/manifest.yaml"); }); describe("agt-bridge", () => { diff --git a/packages/guardian/src/assemble-snapshot.ts b/packages/guardian/src/assemble-snapshot.ts index c420a327..ba04cf2e 100644 --- a/packages/guardian/src/assemble-snapshot.ts +++ b/packages/guardian/src/assemble-snapshot.ts @@ -23,7 +23,38 @@ import type { ToolCallRequestEnvelope } from "./validate-envelope.ts"; export type { ToolCallRequestEnvelope }; -export function assembleSnapshot(envelope: ToolCallRequestEnvelope): Record { +/** + * The AGT `pre_tool_call` snapshot, as a message rather than an anonymous dict + * (PR #10 review, Important). This module used to return + * `Record`: the bridge could be handed anything at all, and + * the only description of what a snapshot contains was the literal below. + * + * Named for the intervention point it is the snapshot FOR, because that is what + * fixes its shape -- AGT-SNAPSHOT-1.0.md §2.5 gives each point its own. A later + * slice's `post_tool_call` snapshot is a sibling type beside this one, not a + * widening of it. + * + * `args` stays `Record` on purpose: those are the tool's own + * arguments, unwrapped from ACS's `{value, provenance}` shape, and their keys + * are the tool's business rather than this project's. + */ +export type AgtPreToolCallSnapshot = { + envelope: { + budgets: { + tool_call_count: number; + token_count: number; + elapsed_seconds: number; + cost_usd: number; + }; + }; + tool_call: { + name: string; + args: Record; + id: string; + }; +}; + +export function assembleSnapshot(envelope: ToolCallRequestEnvelope): AgtPreToolCallSnapshot { const { payload, request_id } = envelope.params; // Unwrap every argument. AGT reads raw values (e.g. args.command must be diff --git a/packages/guardian/src/index.ts b/packages/guardian/src/index.ts index d3c7f9d1..a399854a 100644 --- a/packages/guardian/src/index.ts +++ b/packages/guardian/src/index.ts @@ -8,5 +8,5 @@ export { type AcsRequestEnvelope, type ToolCallRequestEnvelope, } from "./validate-envelope.ts"; -export { assembleSnapshot } from "./assemble-snapshot.ts"; +export { assembleSnapshot, type AgtPreToolCallSnapshot } from "./assemble-snapshot.ts"; export { loadMapping, mapVerdict, type Mapping, type AcsDecision } from "./map-verdict.ts"; diff --git a/packages/guardian/src/server.ts b/packages/guardian/src/server.ts index 9383be1f..5257e2f4 100644 --- a/packages/guardian/src/server.ts +++ b/packages/guardian/src/server.ts @@ -31,7 +31,7 @@ * statelessly (R6.1). */ import { fileURLToPath } from "node:url"; -import { createBridge } from "agt-bridge"; +import { createBridge, type PolicyBridge } from "agt-bridge"; import { assembleSnapshot } from "./assemble-snapshot.ts"; import { loadMapping, mapVerdict, resolveInterventionPoint, type Mapping } from "./map-verdict.ts"; import { @@ -113,7 +113,10 @@ export async function startGuardian({ port, manifestPath, mappingPath }: StartGu async function handleAcsRequest( req: Request, - bridge: ReturnType, + // The role, not `ReturnType` (PR #10 review): this + // handler depends on something it can tell to evaluate a snapshot, not on + // the shape one factory happens to return. + bridge: PolicyBridge, mapping: Mapping, ): Promise { let raw: unknown; diff --git a/packages/guardian/test/assemble-snapshot.test.ts b/packages/guardian/test/assemble-snapshot.test.ts index 8aacdcaa..9d9796a4 100644 --- a/packages/guardian/test/assemble-snapshot.test.ts +++ b/packages/guardian/test/assemble-snapshot.test.ts @@ -43,7 +43,7 @@ describe("assembleSnapshot", () => { }, }); - const snapshot = assembleSnapshot(envelope) as { tool_call: { name: string; args: Record } }; + const snapshot = assembleSnapshot(envelope); expect(snapshot.tool_call.name).toBe("run_shell"); expect(snapshot.tool_call.args).toEqual({ command: "rm -rf /" }); @@ -55,7 +55,7 @@ describe("assembleSnapshot", () => { it("keeps tool_call.args.command a STRING, not a nested wrapper or object", () => { const envelope = makeEnvelope({ args: { command: { value: "rm -rf /" } } }); - const snapshot = assembleSnapshot(envelope) as { tool_call: { args: { command: unknown } } }; + const snapshot = assembleSnapshot(envelope); expect(typeof snapshot.tool_call.args.command).toBe("string"); expect(snapshot.tool_call.args.command).toBe("rm -rf /"); @@ -64,7 +64,7 @@ describe("assembleSnapshot", () => { it("always emits envelope.budgets with all four counters zeroed, even though the envelope says nothing about budgets", () => { const envelope = makeEnvelope(); - const snapshot = assembleSnapshot(envelope) as { envelope: { budgets: Record } }; + const snapshot = assembleSnapshot(envelope); expect(snapshot.envelope).toEqual({ budgets: { tool_call_count: 0, token_count: 0, elapsed_seconds: 0, cost_usd: 0 }, @@ -74,7 +74,7 @@ describe("assembleSnapshot", () => { it("carries params.request_id onto tool_call.id", () => { const envelope = makeEnvelope({ requestId: "2c3e4f50-1234-4abc-9def-000000000000" }); - const snapshot = assembleSnapshot(envelope) as { tool_call: { id: string } }; + const snapshot = assembleSnapshot(envelope); expect(snapshot.tool_call.id).toBe("2c3e4f50-1234-4abc-9def-000000000000"); }); @@ -86,11 +86,13 @@ describe("assembleSnapshot", () => { it("reads nothing but the envelope: no session-derived key appears anywhere in the output", () => { const envelope = makeEnvelope(); - const snapshot = assembleSnapshot(envelope) as Record; + // No cast: assembleSnapshot returns a named snapshot message now, so what + // these read is the type it declares rather than an anonymous dict. + const snapshot = assembleSnapshot(envelope); expect(Object.keys(snapshot).sort()).toEqual(["envelope", "tool_call"]); - expect(Object.keys(snapshot.envelope as object)).toEqual(["budgets"]); - expect(Object.keys(snapshot.tool_call as object).sort()).toEqual(["args", "id", "name"]); + expect(Object.keys(snapshot.envelope)).toEqual(["budgets"]); + expect(Object.keys(snapshot.tool_call).sort()).toEqual(["args", "id", "name"]); const serialized = JSON.stringify(snapshot); for (const forbidden of ["session_id", "session_state", "chain_hash", "agent_id", "metadata", "intent", "1b9d6bcd"]) { From 7d7fa1eb1c3bc8f9be7ac6f917392aeb3d21a694 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Tue, 11 Aug 2026 11:57:18 +0300 Subject: [PATCH 26/39] Name V1's affordances what V1's code actually calls them The affordance tables are the reference a reader uses to find code from an ID, so a row naming a symbol that no longer exists is the same defect PR #11 files against N26: someone hunting N28 finds nothing. PR #10's fixes renamed four of V1's affordances and left every table row and mermaid node naming the old symbol. N3/N12 renderDecision no longer produces `hookSpecificOutput` -- the hookmap names every output field now, which is the whole point of the change, so the row saying otherwise contradicts the gate that enforces it N4/N13 guardianClient.post() -> createGuardianClient().requestDecision() N5/N14 handshake() -> negotiateSessionConfig() N28 handshakeResponder() -> buildServerHello() Tables first, then the mermaid rendered from them, per the shaping doc's own ripple rule. N26's row still says writeEnvelopeTap(): that symbol is real at this branch and is PR #11's finding, so it is renamed on slice/v2 where the code it names is introduced. Addresses: #10 review --- docs/shaping/acs-reference-impl-shaping.md | 22 +++++++++++----------- docs/shaping/acs-reference-impl-slices.md | 10 +++++----- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/shaping/acs-reference-impl-shaping.md b/docs/shaping/acs-reference-impl-shaping.md index a4ccbd8b..8d1d1076 100644 --- a/docs/shaping/acs-reference-impl-shaping.md +++ b/docs/shaping/acs-reference-impl-shaping.md @@ -243,16 +243,16 @@ All resolved — see `spike-agt-integration.md`. |---|-------|-----------|------------|---------|-----------|------------| | N1 | P1 | acs-hook shim | generic hook entrypoint, reads hook JSON on stdin | call | → N2 | — | | N2 | P1 | `@acs/host-adapter` | `buildEnvelope(event, payload, hookmap)` | call | → N4 | — | -| N3 | P1 | `@acs/host-adapter` | `renderDecision(decision, hookmap)` → `hookSpecificOutput` on stdout | call | → U2, → U3 | — | -| N4 | P1 | `@acs/host-adapter` | `guardianClient.post()` JSON-RPC over HTTP | call | → N20 | → N7 | -| N5 | P1 | `@acs/host-adapter` | `handshake()` — `handshake/hello`; negotiates `timeout_config`, `on_decision_failure`, profiles | call | → N28 | → S13 | +| N3 | P1 | `@acs/host-adapter` | `renderDecision(decision, hookmap)` → the host's output object on stdout, every field name read from the hookmap | call | → U2, → U3 | — | +| N4 | P1 | `@acs/host-adapter` | `createGuardianClient(url).requestDecision()` JSON-RPC over HTTP | call | → N20 | → N7 | +| N5 | P1 | `@acs/host-adapter` | `negotiateSessionConfig()` — `handshake/hello`; negotiates `timeout_config`, `on_decision_failure`, profiles | call | → N28 | → S13 | | N6 | P1 | `@acs/host-adapter` | `applyFailurePosture()` — no decision within timeout → negotiated posture (default `proceed`); writes an audit event on every fail-open proceed | call | → S14, → N3 | — | | N7 | P1 | `@acs/host-adapter` | `validateDecision()` — malformed `modifications` → `DENY`; `ASK`/`DEFER` expiry → their `timeout_*` defaults | call | → N3, → N6 | — | | N10 | P2 | acs-plugin shim | OpenCode plugin hooks: `session.start`, `event`, `tool.execute.before/after/error` | call | → N11 | — | | N11 | P2 | `@acs/host-adapter` | `buildEnvelope()` — **same module as N2** | call | → N13 | — | | N12 | P2 | `@acs/host-adapter` | `renderDecision()` — **same module as N3** | call | → U11, → U12 | — | -| N13 | P2 | `@acs/host-adapter` | `guardianClient.post()` — **same module as N4** | call | → N20 | → N16 | -| N14 | P2 | `@acs/host-adapter` | `handshake()` — **same module as N5** | call | → N28 | → S15 | +| N13 | P2 | `@acs/host-adapter` | `createGuardianClient().requestDecision()` — **same module as N4** | call | → N20 | → N16 | +| N14 | P2 | `@acs/host-adapter` | `negotiateSessionConfig()` — **same module as N5** | call | → N28 | → S15 | | N15 | P2 | `@acs/host-adapter` | `applyFailurePosture()` — **same module as N6** | call | → S16, → N12 | — | | N16 | P2 | `@acs/host-adapter` | `validateDecision()` — **same module as N7** | call | → N12, → N15 | — | | N20 | P3 | guardian | `POST /acs` JSON-RPC 2.0 endpoint | call | → N21 | — | @@ -263,7 +263,7 @@ All resolved — see `spike-agt-integration.md`. | N25 | P3 | guardian | `persistResultLabels()` — AGT `result_labels` into ACS lineage | call | → S5 | — | | N26 | P3 | guardian | `writeEnvelopeTap()` | 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 | `handshakeResponder()` — ServerHello: `timeout_config`, `on_decision_failure`, `profiles_accepted` | call | → N26 | → N5, → N14 | +| N28 | P3 | guardian | `buildServerHello()` — ServerHello: `timeout_config`, `on_decision_failure`, `profiles_accepted` | call | → N26 | → N5, → N14 | | 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 | `acs-agt-conformance` runner | call | → N41, → N42, → N43, → N44 | — | @@ -317,8 +317,8 @@ flowchart TB N1["N1: acs-hook shim"] N2["N2: buildEnvelope()"] N3["N3: renderDecision()"] - N4["N4: guardianClient.post()"] - N5["N5: handshake()"] + N4["N4: createGuardianClient().requestDecision()"] + N5["N5: negotiateSessionConfig()"] N6["N6: applyFailurePosture()"] N7["N7: validateDecision()"] S1["S1: claude-code.hookmap.yaml"] @@ -333,8 +333,8 @@ flowchart TB N10["N10: acs-plugin shim"] N11["N11: buildEnvelope() — same module as N2"] N12["N12: renderDecision() — same as N3"] - N13["N13: guardianClient.post() — same as N4"] - N14["N14: handshake() — same as N5"] + N13["N13: createGuardianClient().requestDecision() — same as N4"] + N14["N14: negotiateSessionConfig() — same as N5"] N15["N15: applyFailurePosture() — same as N6"] N16["N16: validateDecision() — same as N7"] S2["S2: opencode.hookmap.yaml"] @@ -351,7 +351,7 @@ flowchart TB N25["N25: persistResultLabels()"] N26["N26: writeEnvelopeTap()"] N27["N27: denyOnInvalidEnvelope()"] - N28["N28: handshakeResponder()"] + N28["N28: buildServerHello()"] S3["S3: sessionContext chain"] S4["S4: intent"] S5["S5: provenance + result_labels"] diff --git a/docs/shaping/acs-reference-impl-slices.md b/docs/shaping/acs-reference-impl-slices.md index 58e5bbc4..8498a96c 100644 --- a/docs/shaping/acs-reference-impl-slices.md +++ b/docs/shaping/acs-reference-impl-slices.md @@ -40,10 +40,10 @@ Every slice ends in something demo-able. | N1 | P1 | acs-hook shim | generic hook entrypoint, reads hook JSON on stdin | call | → N2 | — | | N2 | P1 | `@acs/host-adapter` | `buildEnvelope(event, payload, hookmap)` | call | → N4 | — | | N3 | P1 | `@acs/host-adapter` | `renderDecision(decision, hookmap)` | call | → U2 | — | -| N4 | P1 | `@acs/host-adapter` | `guardianClient.post()` JSON-RPC over HTTP | call | → N20 | → N3 | -| N5 | P1 | `@acs/host-adapter` | `handshake()` — negotiates `timeout_config`, `on_decision_failure`, profiles | call | → N28 | → S13 | +| N4 | P1 | `@acs/host-adapter` | `createGuardianClient(url).requestDecision()` JSON-RPC over HTTP | call | → N20 | → N3 | +| N5 | P1 | `@acs/host-adapter` | `negotiateSessionConfig()` — negotiates `timeout_config`, `on_decision_failure`, profiles | call | → N28 | → S13 | | N20 | P3 | guardian | `POST /acs` JSON-RPC 2.0 endpoint | call | → N21 | — | -| N28 | P3 | guardian | `handshakeResponder()` — ServerHello | call | — | → N5 | +| N28 | P3 | guardian | `buildServerHello()` — ServerHello | call | — | → N5 | | S13 | P1 | store | `negotiated session config` | — | — | → N6 (V3) | | N21 | P3 | guardian | `validateEnvelope()` against v0.1.0 schemas | call | → N23 | — | | N23 | P3 | guardian | `assembleSnapshot()` — envelope → AGT snapshot | call | → N30 | — | @@ -138,8 +138,8 @@ New entries in S1 for `PostToolUse` → `steps/toolCallResult`, and in S8 for th | N10 | P2 | acs-plugin shim | OpenCode plugin hooks: `session.start`, `event`, `tool.execute.before/after/error` | call | → N11 | — | | N11 | P2 | `@acs/host-adapter` | `buildEnvelope()` — same module as N2 | call | → N13 | — | | N12 | P2 | `@acs/host-adapter` | `renderDecision()` — same module as N3 | call | → U11, → U12 | — | -| N13 | P2 | `@acs/host-adapter` | `guardianClient.post()` — same module as N4 | call | → N20 | → N16 | -| N14 | P2 | `@acs/host-adapter` | `handshake()` — same module as N5 | call | → N28 | → S15 | +| N13 | P2 | `@acs/host-adapter` | `createGuardianClient().requestDecision()` — same module as N4 | call | → N20 | → N16 | +| N14 | P2 | `@acs/host-adapter` | `negotiateSessionConfig()` — same module as N5 | call | → N28 | → S15 | | N15 | P2 | `@acs/host-adapter` | `applyFailurePosture()` — same module as N6 | call | → S16, → N12 | — | | N16 | P2 | `@acs/host-adapter` | `validateDecision()` — same module as N7 | call | → N12, → N15 | — | | S2 | P2 | store | `opencode.hookmap.yaml` | — | — | → N11, N12 | From 067fcbd2a27a00620b2277b1be23d4a87aa9dfa6 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Tue, 11 Aug 2026 11:58:35 +0300 Subject: [PATCH 27/39] Fix the last V1 rename left behind in live prose The V3 blocker note still named `handshake()`. Same defect as the affordance rows in the previous commit, in the one place a grep for the old symbol still landed: a live paragraph rather than a table cell. The historical plan docs under docs/superpowers/plans/ keep the old names deliberately -- a plan records what was planned, and rewriting one to match later code would make it a worse record, not a better one. Addresses: #10 review --- docs/shaping/acs-reference-impl-slices.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/shaping/acs-reference-impl-slices.md b/docs/shaping/acs-reference-impl-slices.md index 8498a96c..5c3d30e3 100644 --- a/docs/shaping/acs-reference-impl-slices.md +++ b/docs/shaping/acs-reference-impl-slices.md @@ -102,7 +102,7 @@ Every slice ends in something demo-able. **Two failure domains, kept separate.** AGT fails closed on *evaluation* — bad policy output, invalid transform, missing paths — and that produces a `deny` **verdict**, which §6.4 says the host MUST honor regardless of posture. N27 exists so Guardian-side failures also arrive as decisions rather than bare errors, keeping them in that honored path. `on_decision_failure` only governs *delivery*: Guardian silent, transport dead, error with no decision. Conflating the two would either break AGT's invariant or halt production on a network blip. -**⚠️ Blocker discovered in V1 — S13 has no home across processes.** V1 built `S13` as an in-process store, but the Claude Code shim is a **fresh subprocess per hook invocation**, so an in-memory negotiated session config can never survive to the next hook. `handshake()` is also not called on the real path in V1 at all. `N6 applyFailurePosture()` reads S13, so V3 cannot work until this is resolved: either persist the negotiated config (a session-keyed file), or have the shim talk to a session-scoped daemon. The choice ripples — V5's second host is in-process and would not share the constraint, and V6's session chain sits on the same seam. Decide this before V3 starts. +**⚠️ Blocker discovered in V1 — S13 has no home across processes.** V1 built `S13` as an in-process store, but the Claude Code shim is a **fresh subprocess per hook invocation**, so an in-memory negotiated session config can never survive to the next hook. `negotiateSessionConfig()` is also not called on the real path in V1 at all. `N6 applyFailurePosture()` reads S13, so V3 cannot work until this is resolved: either persist the negotiated config (a session-keyed file), or have the shim talk to a session-scoped daemon. The choice ripples — V5's second host is in-process and would not share the constraint, and V6's session chain sits on the same seam. Decide this before V3 starts. **Rest of the slice is data, not structure.** Disposition coverage lives in S1 (every ACS decision → `permissionDecision` / `updatedInput`) and S8 (stock rules configured to actually fire allow, deny, escalate, transform, and drift-warn). Once the adapter is generic, coverage is configuration. From d66512fe72c97a4f43b449f693f7ac40b9f86039 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Tue, 11 Aug 2026 14:03:48 +0300 Subject: [PATCH 28/39] Cover the three V1 fixes that landed with less test than they need Comparing the redistributed stack against the side branch it replaced found a seven-case coverage gap, all of it in files V1 owns. Each of these fixes answered a PR #10 finding and then shipped with fewer cases than the claim it makes: isToolCallRequest had NO direct test at all. It is the narrowing the whole AcsRequestEnvelope split rests on, so "handshake traffic is not typed as a tool call" was asserted nowhere. Three cases now: a real handshake/hello validates and answers false, a tool call answers true, and a second steps/* method answers false -- that last one is the point, since validating is not the same as being a tool call. resolveInterventionPoint had one resolving row, which a hardcoded literal would also satisfy. Now: the other three rows mapping.yaml ships, a table of only `acs_method: null` rows (mapping.yaml really carries two of those -- the model-call points have no v0.1.0 target, D4), and no table at all, which loadMapping's unchecked cast lets through. PolicyBridge nothing checked the role is satisfiable apart from its factory, which is the entire property the rename bought: the Guardian depends on something it can be told to evaluate, not on `ReturnType`. A hand-written stand-in with no AGT in it now type-checks and answers. 97 tests across 12 files (96 pass, 1 skip); README updated to match. Addresses: #10 review --- README.md | 2 +- packages/agt-bridge/test/bridge.test.ts | 26 +++++++++++++ packages/guardian/test/map-verdict.test.ts | 37 ++++++++++++++++++- .../guardian/test/validate-envelope.test.ts | 36 ++++++++++++++++++ 4 files changed, 99 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5a1bc46d..eb344bd8 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ which is exactly the JSON Claude Code's own hook protocol sends and expects back ### Verify ```bash -bun test # 89 tests across 12 files (88 pass, 1 skip), including the R3.2/R3.3 gates below +bun test # 97 tests across 12 files (96 pass, 1 skip), including the R3.2/R3.3 gates below # the skip is the byte-identity check, which needs UPSTREAM_BUNDLE — see verify:pin bun run typecheck # whole-workspace strict TypeScript check, zero errors ``` diff --git a/packages/agt-bridge/test/bridge.test.ts b/packages/agt-bridge/test/bridge.test.ts index 51c30897..18d74f07 100644 --- a/packages/agt-bridge/test/bridge.test.ts +++ b/packages/agt-bridge/test/bridge.test.ts @@ -40,4 +40,30 @@ describe("agt-bridge", () => { expect(r.inputIdentity).toMatch(/^sha256:[0-9a-f]{64}$/); expect(r.enforcedIdentity).toMatch(/^sha256:[0-9a-f]{64}$/); }); + + it("createBridge's result satisfies the role", async () => { + // 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"); + + expect(typeof asRole.evaluate).toBe("function"); + expect((await asRole.evaluate("pre_tool_call", snapshotFor("rm -rf /"))).verdict.decision).toBe("deny"); + }); + + it("a caller that never touches createBridge can satisfy the role too", async () => { + // The property that matters for V5 and V7: the Guardian depends on + // something it can be told to evaluate, not on this package's factory. A + // stand-in written by hand type-checks and answers, with no AGT in it -- + // which is what makes `PolicyBridge` a role rather than a synonym for + // `ReturnType` (PR #10 review). + const standIn: PolicyBridge = { + async evaluate(point, snapshot) { + return { verdict: { decision: "deny", reason: `${point}:${Object.keys(snapshot).sort().join(",")}` } }; + }, + }; + + const r = await standIn.evaluate("pre_tool_call", snapshotFor("ls -la")); + expect(r.verdict.reason).toBe("pre_tool_call:envelope,tool_call"); + }); }); diff --git a/packages/guardian/test/map-verdict.test.ts b/packages/guardian/test/map-verdict.test.ts index 7550cc83..d3506388 100644 --- a/packages/guardian/test/map-verdict.test.ts +++ b/packages/guardian/test/map-verdict.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { loadMapping, mapVerdict, resolveInterventionPoint } from "../src/map-verdict.ts"; +import { loadMapping, mapVerdict, resolveInterventionPoint, type Mapping } from "../src/map-verdict.ts"; const m = loadMapping("mapping.yaml"); @@ -84,4 +84,39 @@ describe("resolveInterventionPoint", () => { expect(() => resolveInterventionPoint("steps/toolCallRequest", ambiguous)).toThrow(/more than one/); }); + + it("resolves every other declared ACS method from the same table", () => { + // One row resolving is not evidence the table is being read -- a literal + // would satisfy that. These are the rest of the rows mapping.yaml ships. + expect(resolveInterventionPoint("steps/toolCallResult", m)).toBe("post_tool_call"); + expect(resolveInterventionPoint("steps/sessionStart", m)).toBe("agent_startup"); + expect(resolveInterventionPoint("steps/agentResponse", m)).toBe("output"); + }); + + it("never resolves to a row whose acs_method is null", () => { + // mapping.yaml really does ship two of these: the model-call points have + // no ACS v0.1.0 target (D4). A resolver comparing loosely would match a + // null row and evaluate the wrong intervention point's policy. + const allNull = { + ...m, + intervention_points: { + pre_model_call: { acs_method: null }, + post_model_call: { acs_method: null }, + }, + } as unknown as Mapping; + + expect(() => resolveInterventionPoint("steps/toolCallRequest", allNull)).toThrow( + /maps no AGT intervention point/, + ); + }); + + it("throws when the mapping declares no intervention_points table at all", () => { + // loadMapping's `as Mapping` is unchecked, so a mapping.yaml edited to + // drop the table type-checks and reaches here. Standing in for that. + const tableless = { ...m, intervention_points: undefined } as unknown as Mapping; + + expect(() => resolveInterventionPoint("steps/toolCallRequest", tableless)).toThrow( + /no intervention_points table/, + ); + }); }); diff --git a/packages/guardian/test/validate-envelope.test.ts b/packages/guardian/test/validate-envelope.test.ts index ba412e1a..7105c341 100644 --- a/packages/guardian/test/validate-envelope.test.ts +++ b/packages/guardian/test/validate-envelope.test.ts @@ -119,3 +119,39 @@ describe("validateEnvelope", () => { expect((thrown as Record).decision).toBeUndefined(); }); }); + +describe("isToolCallRequest -- the method discrimination the narrow type depends on", () => { + it("validates a handshake/hello envelope without claiming it is a tool call", () => { + const handshake = { + jsonrpc: "2.0", + method: "handshake/hello", + id: 7, + params: { + acs_version: "0.1.0", + request_id: "8f14e45f-ceea-467e-bd5f-1d4d9a4e0c8f", + timestamp: "2026-08-09T12:00:00Z", + metadata: { agent_id: "agent-1", session_id: "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed" }, + // No tool, no arguments -- a handshake carries neither, which is + // exactly why typing it as a tool call was wrong rather than untidy. + payload: {}, + }, + }; + + const validated = validateEnvelope(handshake); + + expect(validated.method).toBe("handshake/hello"); + expect(isToolCallRequest(validated)).toBe(false); + }); + + it("says yes to steps/toolCallRequest", () => { + expect(isToolCallRequest(validateEnvelope(makeEnvelope()))).toBe(true); + }); + + it("says no to any other steps/* method", () => { + // Nothing dispatches this method today. The point is that a second + // steps/* method is NOT silently a tool call just because it validated. + const validated = validateEnvelope(makeEnvelope({ method: "steps/sessionStart" })); + + expect(isToolCallRequest(validated)).toBe(false); + }); +}); From 14765af2c231fe48f66d253aef37c28eefb099eb Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Wed, 12 Aug 2026 14:31:10 +0300 Subject: [PATCH 29/39] Read the wrap mode the mapping declares, instead of one written in code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `field_synthesis.reason_codes.wrap` was required on `Mapping`, written in mapping.yaml, and published by V7's matrix as part of the table -- while `mapVerdict` built `[reasonForCodes]` from a literal and never looked at it. That is the same defect class as the hardcoded `pre_tool_call` this slice already closed, one table over: editing the declaration changed nothing, so the mapping could claim a synthesis the code did not perform and no test, and no reader, would find the disagreement. A conformance matrix can only publish rows the runtime actually consults. `applyWrap` reads the declared mode and throws for one this mapping cannot express. A default would be the fail-open version of the same bug -- loadMapping casts the parsed YAML and validates nothing, so `wrap` at runtime is whatever the file says, and array-wrapping an unrecognised mode would synthesize a `reason_codes` the mapping never asked for and hand it to a host as a decision's machine-readable half. The Guardian's evaluation catch turns the throw into an honoured deny (§6.4, R1.5). Mutation-tested: restoring the literal fails the new "throws for a wrap mode this mapping cannot express" case, and only that one. 99 tests across 12 files (98 pass, 1 skip); typecheck clean. Addresses: #10 review --- packages/guardian/src/map-verdict.ts | 40 ++++++++++++++++++++-- packages/guardian/test/map-verdict.test.ts | 26 ++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/guardian/src/map-verdict.ts b/packages/guardian/src/map-verdict.ts index 15c084ef..aff835bb 100644 --- a/packages/guardian/src/map-verdict.ts +++ b/packages/guardian/src/map-verdict.ts @@ -20,9 +20,14 @@ type FieldSource = { source: string }; * field (verdict.reason) can take to satisfy ACS's `string[]`. `wrap` is * required here (not optional) precisely so there is no third case to * handle: a string source is always wrapped, never cast unsound. */ -type WrappedFieldSource = { source: string; wrap: "array" }; +type WrappedFieldSource = { source: string; wrap: WrapMode }; type FieldLiteral = { literal: string }; +/** The wrap modes this mapping can express. One member today; the point of + * naming the set is that `applyWrap` below refuses everything outside it + * rather than silently doing the one thing it knows. */ +type WrapMode = "array"; + export type Mapping = { acs_version: string; agt_version: string; @@ -105,6 +110,37 @@ function readVerdictField(verdict: AgtVerdict, source: { source: string }): unkn return verdict[field]; } +/** + * Applies a field_synthesis leaf's declared `wrap` to the string it read. + * + * This exists because the declaration used to be one the runtime ignored (PR + * #10 review, second pass): `wrap: array` was required on the type, written in + * mapping.yaml, and published by V7's matrix as part of the table -- while + * `mapVerdict` built `[value]` from a hardcoded literal and never looked. That + * is the same defect class as the `pre_tool_call` hardcode `resolveInterventionPoint` + * closed, one table row over: editing the declaration changed nothing, so the + * mapping could claim a synthesis the code did not perform and nothing would + * fail. + * + * An unrecognised mode is a THROW, and the alternative is worse than a reported + * failure for the same reason every other failure in this module is. `loadMapping` + * casts the parsed YAML with `as Mapping` and validates nothing, so `wrap` at + * runtime is whatever the file says; defaulting an unknown mode to array-wrapping + * would synthesize a `reason_codes` the mapping did not ask for and hand it to a + * host as a decision's machine-readable half. The Guardian's evaluation catch + * turns this into an honoured `deny` (§6.4, R1.5), which is the honest answer to + * a mapping this code cannot carry out. + */ +function applyWrap(value: string, wrap: WrapMode, leaf: string): string[] { + if (wrap !== "array") { + throw new Error( + `mapping.yaml declares field_synthesis.${leaf}.wrap as ${JSON.stringify(wrap)}, but this mapping can ` + + `only express "array"`, + ); + } + return [value]; +} + export function mapVerdict(verdict: AgtVerdict, mapping: Mapping): AcsDecision { const rule = mapping.verdicts[verdict.decision]; if (!rule) { @@ -121,7 +157,7 @@ export function mapVerdict(verdict: AgtVerdict, mapping: Mapping): AcsDecision { const reasonForCodes = readVerdictField(verdict, fs.reason_codes); if (typeof reasonForCodes === "string") { - out.reason_codes = [reasonForCodes]; + out.reason_codes = applyWrap(reasonForCodes, fs.reason_codes.wrap, "reason_codes"); } const ruleId = readVerdictField(verdict, fs.policy_references.rule_id); diff --git a/packages/guardian/test/map-verdict.test.ts b/packages/guardian/test/map-verdict.test.ts index d3506388..2e5a677c 100644 --- a/packages/guardian/test/map-verdict.test.ts +++ b/packages/guardian/test/map-verdict.test.ts @@ -52,6 +52,32 @@ describe("mapVerdict", () => { it("throws when require_policy_references is set but verdict.reason is empty (R1.2's load-bearing check)", () => { expect(() => mapVerdict({ decision: "warn" }, m)).toThrow(/require_policy_references/); }); + + // PR #10 review, second pass: `field_synthesis.reason_codes.wrap` was + // required on the type and written in mapping.yaml while mapVerdict built + // `[value]` from a literal -- the same "declared but unread" defect the + // hardcoded `pre_tool_call` was. These read the declaration rather than the + // literal, so an edit to 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).reason_codes).toEqual(["r"]); + }); + + it("throws for a wrap mode this mapping cannot express, rather than array-wrapping anyway", () => { + // loadMapping casts the parsed YAML and validates nothing, so a mapping + // declaring an unknown mode type-checks and reaches mapVerdict. Silently + // array-wrapping it would synthesize a reason_codes the mapping never + // asked for and hand it to a host as a decision's machine-readable half. + const unknownMode = { + ...m, + field_synthesis: { ...m.field_synthesis, reason_codes: { source: "verdict.reason", wrap: "csv" } }, + } as unknown as Mapping; + + expect(() => mapVerdict({ decision: "deny", reason: "r" }, unknownMode)).toThrow( + /field_synthesis\.reason_codes\.wrap as "csv"/, + ); + }); + }); }); // PR #10 review, Critical: the intervention_points table used to be a claim From b032dea919f997cbb3dd63ada273062a4f4a5db7 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Wed, 12 Aug 2026 14:31:20 +0300 Subject: [PATCH 30/39] Ask for a decision in ACS's nouns, not the transport's `requestDecision` took a `JsonRpcRequest` while its producer speaks `AcsRequestEnvelope` (buildEnvelope) and its consumer speaks `AcsDecision`. So the one seam whose whole job is "ask ACS for the decision on this step" was also the one place the ACS noun evaporated and JSON-RPC's took its place, for no reason the caller could see: every call site already holds an `AcsRequestEnvelope` and always did. It now takes the ACS request message. `JsonRpcRequest` stays exactly where it belongs -- on `post`, the wire primitive underneath, whose one non-decision caller is the handshake and whose ClientHello params are not an ACS request at all. No behaviour change: `AcsRequestEnvelope` was already what every `requestDecision` call site passed, so this narrows a type that was wider than its only use. 99 tests across 12 files (98 pass, 1 skip); typecheck clean. Addresses: #10 review --- packages/host-adapter/src/guardian-client.ts | 27 +++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/host-adapter/src/guardian-client.ts b/packages/host-adapter/src/guardian-client.ts index 0bb605d2..52214e33 100644 --- a/packages/host-adapter/src/guardian-client.ts +++ b/packages/host-adapter/src/guardian-client.ts @@ -47,12 +47,23 @@ * no runtime dependency on the Guardian or policy-bridge packages -- it talks * to the Guardian only over the wire, at whatever `url` the caller gives. */ +import type { AcsRequestEnvelope } from "./build-envelope.ts"; import type { AcsDecision } from "./decision-message.ts"; -/** The minimal JSON-RPC 2.0 request shape this client sends. Loose on - * `params` on purpose: buildEnvelope's ACS request params and handshake's - * ClientHello params are different shapes, and this module doesn't need - * to know either -- it moves whatever `params` object it's given. */ +/** + * The minimal JSON-RPC 2.0 request shape this client sends -- the TRANSPORT + * shape, used by `post` alone. + * + * Loose on `params` on purpose: `post` carries whatever a caller hands it, and + * its one non-decision caller is the handshake, whose ClientHello params are + * not an ACS request at all. + * + * NOT the shim-facing vocabulary (PR #10 review, second pass). `requestDecision` + * used to take one of these, so the seam between a producer speaking + * `AcsRequestEnvelope` and a consumer speaking `AcsDecision` was the one place + * the ACS noun evaporated and JSON-RPC's took its place. It now takes the ACS + * request message, and this stays the internal transport type underneath it. + */ export type JsonRpcRequest = { jsonrpc: "2.0"; method: string; @@ -113,8 +124,12 @@ export type GuardianClient = { /** * Asks for the decision on `envelope` and answers with a message. Never * throws -- see DecisionOrFailure. + * + * Takes the ACS request message and answers with an ACS decision: both ends of + * this method are ACS's vocabulary, and JSON-RPC is the transport it happens to + * travel over (see JsonRpcRequest). */ - requestDecision(envelope: JsonRpcRequest): Promise; + requestDecision(envelope: AcsRequestEnvelope): Promise; /** * The wire primitive: POSTs `envelope` as JSON, parses the JSON-RPC * response, and returns it once its `id` is confirmed to match the @@ -149,7 +164,7 @@ export function createGuardianClient(url: string): GuardianClient { return { post, - async requestDecision(envelope: JsonRpcRequest): Promise { + async requestDecision(envelope: AcsRequestEnvelope): Promise { let response: JsonRpcResponse; try { response = await post(envelope); From ed274c7c3491b2e014347fe9a2264692582b95f6 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Wed, 12 Aug 2026 14:31:41 +0300 Subject: [PATCH 31/39] Answer with the verdict, keep the snapshot named, and name the result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three seams where a message this stack had already built died on the way across, all of them on the Guardian's own path. PolicyBridge.evaluate returned `BridgeResult` -- the verdict plus `inputIdentity`, `enforcedIdentity` and `transformedPolicyTarget` -- and its only production caller destructured `{ verdict }` and dropped the rest. Three fields nothing read, on every answer, so the collaboration read as "here is a result, ask it what you wanted" rather than "here is the verdict you asked for". It now answers with `AgtVerdict`. Correction C1's evidence moves with it, onto the SDK it is a claim about: C1 says the Node SDK computes distinct input and enforced identities, so the test now calls `evaluateInterventionPoint` directly instead of asserting this package's pass-through of it. Stronger, not weaker -- it fails if the SDK stops returning them, where the old one could also fail for a change in a field nothing consumes. the snapshot `AgtPreToolCallSnapshot` was built, named, and then widened straight back to `Record` at `evaluate`, which is the one seam it exists to cross. AGT-SNAPSHOT-1.0.md §2.5 gives each point its own shape and that shape is the assembling caller's knowledge, so agt-bridge cannot name the messages -- but it can decline to erase them. `PolicyBridge` is now parameterised by the snapshot its holder sends, `InterventionSnapshot` is the bound rather than the parameter, and the Guardian declares `GuardianSnapshot` for what it actually assembles. Checked: handing `bridge.evaluate` an anonymous `Record` is now a typecheck failure naming the two missing members. It compiled before. the response `mapVerdict` built a real `AcsDecision` and the next line spread it into a bare `Record` with three correlation fields, handed to `successResponse` as an anonymous dict -- so the one direction this Guardian is solely responsible for was the one with no type on it. `AcsFinalResult` (acs-result.ts) names it, `finalResult` reads the correlation fields off the envelope's own params in one place, and `JsonRpcSuccess.result` is now that message or a ServerHello rather than any object. `assembleSnapshot` -> `assemblePreToolCallSnapshot` in the same commit because it is server.ts's other seam-naming edit: the type was point-specific and the function was generic, and a later slice's sibling assembler would have had nothing to rhyme with. 99 tests across 12 files (98 pass, 1 skip); typecheck clean. Addresses: #10 review --- packages/agt-bridge/src/index.ts | 56 +++++++++++-------- packages/agt-bridge/test/bridge.test.ts | 47 ++++++++++------ packages/guardian/src/acs-result.ts | 54 ++++++++++++++++++ packages/guardian/src/assemble-snapshot.ts | 4 +- packages/guardian/src/index.ts | 2 +- packages/guardian/src/server.ts | 44 +++++++++------ .../guardian/test/assemble-snapshot.test.ts | 22 ++++---- packages/guardian/test/server.test.ts | 2 +- 8 files changed, 158 insertions(+), 73 deletions(-) create mode 100644 packages/guardian/src/acs-result.ts diff --git a/packages/agt-bridge/src/index.ts b/packages/agt-bridge/src/index.ts index 3f3911ce..fb200bab 100644 --- a/packages/agt-bridge/src/index.ts +++ b/packages/agt-bridge/src/index.ts @@ -8,21 +8,19 @@ export type AgtVerdict = { result_labels?: string[]; }; -export type BridgeResult = { - verdict: AgtVerdict; - inputIdentity?: string; - enforcedIdentity?: string; - transformedPolicyTarget?: unknown; -}; - /** - * The snapshot a caller hands an intervention point: the policy input - * document, as JSON. Deliberately as open as the wire it models -- each - * intervention point has its own snapshot shape (AGT-SNAPSHOT-1.0.md), and the - * shape for a given point is the assembling caller's knowledge, not this - * bridge's. What the alias buys is that the seam names the message rather than - * passing an anonymous dict: a caller reading `evaluate(point, snapshot: - * InterventionSnapshot)` is told what to build. + * What any snapshot is, structurally: the policy input document, as JSON. + * + * A CONSTRAINT, NOT A MESSAGE, and the distinction is this alias's whole job + * (PR #10 review, second pass). It used to be `evaluate`'s parameter type, so + * an assembler's named snapshot -- `AgtPreToolCallSnapshot` -- widened back + * into an anonymous dict at the one seam it was built to cross, and the seam + * would then accept literally any object. Each intervention point has its own + * snapshot shape (AGT-SNAPSHOT-1.0.md §2.5) and that shape is the assembling + * caller's knowledge rather than this bridge's, so this package cannot name + * the messages -- but it can refuse to erase them: `PolicyBridge` below is + * parameterised by the snapshot its holder actually sends, and this alias is + * the bound that parameter satisfies. */ export type InterventionSnapshot = Record; @@ -45,9 +43,26 @@ export type InterventionSnapshot = Record; * The AGT half of the name stays out of the type, which matters: the Guardian * depends on this role, not on AGT, and R3.3's gate is what keeps that honest * in the other direction. + * + * ANSWERS WITH A VERDICT, not with a bag carrying one (PR #10 review, second + * pass). `evaluate` used to return a `BridgeResult` -- the verdict plus + * `inputIdentity`, `enforcedIdentity` and `transformedPolicyTarget` -- and its + * only production caller destructured `{ verdict }` off it and dropped the + * rest. Three fields nothing read, on every answer, so the collaboration read + * as "here is a result, go and ask it what you wanted" rather than "here is the + * verdict you asked for". Those three are facts about the SDK's own evaluation + * rather than about this step's outcome, and the correction that made them + * worth confirming (C1) is confirmed where it belongs, against the SDK + * directly, in this package's own test. + * + * PARAMETERISED BY THE SNAPSHOT, so a holder's named message survives the seam. + * `createBridge` answers with the general `PolicyBridge`, since it can evaluate + * any point; a holder that assembles particular snapshots declares which ones + * it sends (`PolicyBridge`), and its own call sites are + * then checked against that message instead of against "any object at all". */ -export type PolicyBridge = { - evaluate(point: string, snapshot: InterventionSnapshot): Promise; +export type PolicyBridge = { + evaluate(point: string, snapshot: S): Promise; }; /** @@ -64,14 +79,9 @@ export function createBridge(manifestPath: string): PolicyBridge { const control = AgentControl.fromPath(manifestPath); return { - async evaluate(point: string, snapshot: InterventionSnapshot): Promise { + async evaluate(point: string, snapshot: InterventionSnapshot): Promise { const result = await control.evaluateInterventionPoint(point as never, snapshot as never); - return { - verdict: result.verdict as AgtVerdict, - inputIdentity: result.inputIdentity, - enforcedIdentity: result.enforcedIdentity, - transformedPolicyTarget: result.transformedPolicyTarget, - }; + return result.verdict as AgtVerdict; }, }; } diff --git a/packages/agt-bridge/test/bridge.test.ts b/packages/agt-bridge/test/bridge.test.ts index 18d74f07..ee2d56e9 100644 --- a/packages/agt-bridge/test/bridge.test.ts +++ b/packages/agt-bridge/test/bridge.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, beforeAll } from "bun:test"; +import { AgentControl } from "agent-control-specification"; import { createBridge, type PolicyBridge } from "../src/index.ts"; const snapshotFor = (command: string) => ({ @@ -11,34 +12,46 @@ beforeAll(() => { bridge = createBridge("policy/manifest.yaml"); }); describe("agt-bridge", () => { it("denies a destructive shell command using the stock bundle", async () => { - const r = await bridge.evaluate("pre_tool_call", snapshotFor("rm -rf /")); - expect(r.verdict.decision).toBe("deny"); - expect(r.verdict.reason).toBe("destructive_shell_command_blocked"); - expect(r.verdict.message).toContain("matched pattern"); + const verdict = await bridge.evaluate("pre_tool_call", snapshotFor("rm -rf /")); + expect(verdict.decision).toBe("deny"); + expect(verdict.reason).toBe("destructive_shell_command_blocked"); + expect(verdict.message).toContain("matched pattern"); }); it("denies the -fr spelling too", async () => { - const r = await bridge.evaluate("pre_tool_call", snapshotFor("rm -fr / --no-preserve-root")); - expect(r.verdict.decision).toBe("deny"); + const verdict = await bridge.evaluate("pre_tool_call", snapshotFor("rm -fr / --no-preserve-root")); + expect(verdict.decision).toBe("deny"); }); it("allows benign commands", async () => { for (const cmd of ["ls -la", "git status"]) { - expect((await bridge.evaluate("pre_tool_call", snapshotFor(cmd))).verdict.decision).toBe("allow"); + expect((await bridge.evaluate("pre_tool_call", snapshotFor(cmd))).decision).toBe("allow"); } }); // Guards Correction C2 — the failure mode this catches is a SILENT fail-open. it("surfaces the policy config to Rego (guards the ./ bundle-path landmine)", async () => { - const r = await bridge.evaluate("pre_tool_call", snapshotFor("rm -rf /")); - expect(r.verdict.decision).not.toBe("allow"); + const verdict = await bridge.evaluate("pre_tool_call", snapshotFor("rm -rf /")); + expect(verdict.decision).not.toBe("allow"); }); // Guards Correction C1 — this is why the bridge is Node, not Python. - it("returns input and enforced identity as distinct fields", async () => { - const r = await bridge.evaluate("pre_tool_call", snapshotFor("ls -la")); - expect(r.inputIdentity).toMatch(/^sha256:[0-9a-f]{64}$/); - expect(r.enforcedIdentity).toMatch(/^sha256:[0-9a-f]{64}$/); + // + // Asserted against the SDK DIRECTLY, not through `evaluate`. C1 is a claim + // about what the Node SDK computes, and `PolicyBridge.evaluate` answers with + // a verdict rather than a bag carrying it (PR #10 review, second pass), so + // routing this through the bridge would have meant keeping three fields on + // every answer that nothing reads in order to assert one of them here. The + // subject of the correction is the SDK, so the subject of the test is too -- + // which is a stronger test, not a weaker one: it fails if the SDK stops + // 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 result = await control.evaluateInterventionPoint("pre_tool_call" as never, snapshotFor("ls -la") as never); + + expect(result.inputIdentity).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(result.enforcedIdentity).toMatch(/^sha256:[0-9a-f]{64}$/); }); it("createBridge's result satisfies the role", async () => { @@ -48,7 +61,7 @@ describe("agt-bridge", () => { const asRole: PolicyBridge = createBridge("policy/manifest.yaml"); expect(typeof asRole.evaluate).toBe("function"); - expect((await asRole.evaluate("pre_tool_call", snapshotFor("rm -rf /"))).verdict.decision).toBe("deny"); + expect((await asRole.evaluate("pre_tool_call", snapshotFor("rm -rf /"))).decision).toBe("deny"); }); it("a caller that never touches createBridge can satisfy the role too", async () => { @@ -59,11 +72,11 @@ describe("agt-bridge", () => { // `ReturnType` (PR #10 review). const standIn: PolicyBridge = { async evaluate(point, snapshot) { - return { verdict: { decision: "deny", reason: `${point}:${Object.keys(snapshot).sort().join(",")}` } }; + return { decision: "deny", reason: `${point}:${Object.keys(snapshot).sort().join(",")}` }; }, }; - const r = await standIn.evaluate("pre_tool_call", snapshotFor("ls -la")); - expect(r.verdict.reason).toBe("pre_tool_call:envelope,tool_call"); + const verdict = await standIn.evaluate("pre_tool_call", snapshotFor("ls -la")); + expect(verdict.reason).toBe("pre_tool_call:envelope,tool_call"); }); }); diff --git a/packages/guardian/src/acs-result.ts b/packages/guardian/src/acs-result.ts new file mode 100644 index 00000000..fc8a9102 --- /dev/null +++ b/packages/guardian/src/acs-result.ts @@ -0,0 +1,54 @@ +/** + * The message this Guardian sends back: an ACS final result, as + * response-envelope.json's `AcsResult` defines it. + * + * A NAMED MESSAGE RATHER THAN A RE-BAGGING (PR #10 review, second pass). The + * inbound half of this seam is method-narrowed -- `validateEnvelope` answers + * with an `AcsRequestEnvelope` and each gate's predicate narrows it further -- + * while the outbound half was assembled into a bare `Record` + * immediately after `mapVerdict` had built a real `AcsDecision`, and handed to + * `successResponse` as an anonymous dict. So the one direction the Guardian is + * solely responsible for was the one direction with no type on it: nothing + * checked that the three envelope fields ACS requires beside the decision were + * present, or that they were the ones this request carried. + * + * Structured as the decision PLUS the correlation fields rather than as a flat + * list of its own, because that is what it is: §17's result is one ACS decision + * addressed to one request. Spreading `AcsDecision` in keeps a single source + * for the decision's own shape (map-verdict.ts), so a field added there cannot + * fail to be sendable from here. + * + * `request_id` is the ACS correlation id from `params.request_id`, NOT the + * JSON-RPC `id`. The two are equal for every envelope `buildEnvelope` sends, + * and they are still different fields: the transport correlates by one and ACS + * correlates by the other, and response-envelope.json requires this one on the + * result object itself. + */ +import type { AcsDecision } from "./map-verdict.ts"; + +export type AcsFinalResult = { + /** response-envelope.json's discriminator. v0.1 emits only "final". */ + type: "final"; + acs_version: string; + request_id: string; +} & AcsDecision; + +/** + * Builds the final result for one request from the decision reached for it. + * + * Here rather than inline at the call site so the three correlation fields are + * read off the envelope's own params in exactly one place: an answer carrying + * some other request's `request_id` is a decision the host will either fail to + * correlate or, worse, correlate to the wrong step. + */ +export function finalResult( + params: { acs_version: string; request_id: string }, + decision: AcsDecision, +): AcsFinalResult { + return { + type: "final", + acs_version: params.acs_version, + request_id: params.request_id, + ...decision, + }; +} diff --git a/packages/guardian/src/assemble-snapshot.ts b/packages/guardian/src/assemble-snapshot.ts index ba04cf2e..67d6a553 100644 --- a/packages/guardian/src/assemble-snapshot.ts +++ b/packages/guardian/src/assemble-snapshot.ts @@ -1,5 +1,5 @@ /** - * assembleSnapshot converts a validated ACS request envelope (method + * assemblePreToolCallSnapshot converts a validated ACS request envelope (method * `steps/toolCallRequest`) into the AGT snapshot for `pre_tool_call`, * shaped per AGT-SNAPSHOT-1.0.md §2.5. * @@ -54,7 +54,7 @@ export type AgtPreToolCallSnapshot = { }; }; -export function assembleSnapshot(envelope: ToolCallRequestEnvelope): AgtPreToolCallSnapshot { +export function assemblePreToolCallSnapshot(envelope: ToolCallRequestEnvelope): AgtPreToolCallSnapshot { const { payload, request_id } = envelope.params; // Unwrap every argument. AGT reads raw values (e.g. args.command must be diff --git a/packages/guardian/src/index.ts b/packages/guardian/src/index.ts index a399854a..cd4d2de2 100644 --- a/packages/guardian/src/index.ts +++ b/packages/guardian/src/index.ts @@ -8,5 +8,5 @@ export { type AcsRequestEnvelope, type ToolCallRequestEnvelope, } from "./validate-envelope.ts"; -export { assembleSnapshot, type AgtPreToolCallSnapshot } from "./assemble-snapshot.ts"; +export { assemblePreToolCallSnapshot, type AgtPreToolCallSnapshot } from "./assemble-snapshot.ts"; export { loadMapping, mapVerdict, type Mapping, type AcsDecision } from "./map-verdict.ts"; diff --git a/packages/guardian/src/server.ts b/packages/guardian/src/server.ts index 5257e2f4..27233de6 100644 --- a/packages/guardian/src/server.ts +++ b/packages/guardian/src/server.ts @@ -5,11 +5,11 @@ * inside the handler is by the JSON-RPC `method` field, never by URL path. * * Composes every earlier task, in order, for `steps/toolCallRequest`: - * validateEnvelope (Task 5) -> assembleSnapshot (Task 4) -> + * validateEnvelope (Task 5) -> assemblePreToolCallSnapshot (Task 4) -> * bridge.evaluate(resolveInterventionPoint(method, mapping), snapshot) * (Task 2) -> mapVerdict(verdict, mapping) (Task 3) -> response envelope. * - * assembleSnapshot / bridge.evaluate / mapVerdict are wrapped in a try/catch + * assemblePreToolCallSnapshot / bridge.evaluate / mapVerdict are wrapped in a try/catch * (fix wave finding 1): an unhandled throw here -- e.g. mapVerdict's own * require_policy_references check, or a genuine AGT runtime error -- would * otherwise escape this handler, and Bun.serve's default error page for an @@ -32,7 +32,8 @@ */ import { fileURLToPath } from "node:url"; import { createBridge, type PolicyBridge } from "agt-bridge"; -import { assembleSnapshot } from "./assemble-snapshot.ts"; +import { assemblePreToolCallSnapshot, type AgtPreToolCallSnapshot } from "./assemble-snapshot.ts"; +import { finalResult, type AcsFinalResult } from "./acs-result.ts"; import { loadMapping, mapVerdict, resolveInterventionPoint, type Mapping } from "./map-verdict.ts"; import { EnvelopeValidationError, @@ -40,7 +41,20 @@ import { validateEnvelope, type AcsRequestEnvelope, } from "./validate-envelope.ts"; -import { buildServerHello } from "./handshake.ts"; +import { buildServerHello, type ServerHello } from "./handshake.ts"; + +/** + * Every snapshot message this Guardian can send an intervention point. One + * member at this slice; each gate this Guardian learns to assemble adds its + * own point-specific type here. + * + * Declared so the bridge seam carries the message rather than erasing it (PR + * #10 review, second pass). `PolicyBridge` is parameterised by the snapshot + * its holder sends, and this is what this holder sends -- so `bridge.evaluate` + * below is checked against the assemblers' own output types instead of against + * "any object at all", which is what `Record` had made of it. + */ +type GuardianSnapshot = AgtPreToolCallSnapshot; const MAPPING_PATH = fileURLToPath(new URL("../../../mapping.yaml", import.meta.url)); @@ -60,13 +74,13 @@ const ACS_PATH = "/acs"; */ const ENVELOPE_INVALID_CODE = -32010; const METHOD_NOT_DISPATCHED_CODE = -32011; -/** A throw from assembleSnapshot, bridge.evaluate, or mapVerdict -- e.g. +/** A throw from assemblePreToolCallSnapshot, bridge.evaluate, or mapVerdict -- e.g. * mapVerdict's own require_policy_references check, or any AGT runtime * error. See fix wave finding 1's comment above handleAcsRequest's * tool-call branch for why this must never be dead code. */ const EVALUATION_FAILED_CODE = -32020; -type JsonRpcSuccess = { jsonrpc: "2.0"; id: string | number; result: Record }; +type JsonRpcSuccess = { jsonrpc: "2.0"; id: string | number; result: AcsFinalResult | ServerHello }; type JsonRpcFailure = { jsonrpc: "2.0"; id: string | number | null; @@ -116,7 +130,7 @@ async function handleAcsRequest( // The role, not `ReturnType` (PR #10 review): this // handler depends on something it can tell to evaluate a snapshot, not on // the shape one factory happens to return. - bridge: PolicyBridge, + bridge: PolicyBridge, mapping: Mapping, ): Promise { let raw: unknown; @@ -151,11 +165,11 @@ async function handleAcsRequest( // predicate lives beside the payload check it stands for // (validate-envelope.ts), so this branch cannot come to disagree with the // module that decided whether `params.payload` was validated as a tool - // call. It also narrows the envelope, which is what lets assembleSnapshot + // call. It also narrows the envelope, which is what lets assemblePreToolCallSnapshot // take the tool-call view rather than any request at all. if (isToolCallRequest(envelope)) { try { - const snapshot = assembleSnapshot(envelope); + const snapshot = assemblePreToolCallSnapshot(envelope); // The intervention point comes from mapping.yaml's own // `intervention_points` table, not from a literal here (PR #10 review, // Critical): that table is what V7's conformance matrix publishes, and @@ -164,16 +178,10 @@ async function handleAcsRequest( // defaulting to a point -- evaluating the wrong policy and calling the // result a decision is the one outcome worse than a reported failure. const point = resolveInterventionPoint(envelope.method, mapping); - const { verdict } = await bridge.evaluate(point, snapshot); + const verdict = await bridge.evaluate(point, snapshot); const decision = mapVerdict(verdict, mapping); - const result: Record = { - type: "final", - acs_version: envelope.params.acs_version, - request_id: envelope.params.request_id, - ...decision, - }; - return successResponse(envelope.id, result); + return successResponse(envelope.id, finalResult(envelope.params, decision)); } catch (error) { // Fix wave finding 1 -- see the module-level comment above. This is // deliberately a bare JSON-RPC error, not an ACS `deny` decision @@ -193,7 +201,7 @@ async function handleAcsRequest( }); } -function successResponse(id: string | number, result: Record): JsonRpcSuccess { +function successResponse(id: string | number, result: AcsFinalResult | ServerHello): JsonRpcSuccess { return { jsonrpc: "2.0", id, result }; } diff --git a/packages/guardian/test/assemble-snapshot.test.ts b/packages/guardian/test/assemble-snapshot.test.ts index 9d9796a4..e55a72c4 100644 --- a/packages/guardian/test/assemble-snapshot.test.ts +++ b/packages/guardian/test/assemble-snapshot.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { createBridge } from "agt-bridge"; -import { assembleSnapshot, type ToolCallRequestEnvelope } from "../src/assemble-snapshot.ts"; +import { assemblePreToolCallSnapshot, type ToolCallRequestEnvelope } from "../src/assemble-snapshot.ts"; function makeEnvelope(overrides: { toolName?: string; @@ -34,7 +34,7 @@ function makeEnvelope(overrides: { } as unknown as ToolCallRequestEnvelope; } -describe("assembleSnapshot", () => { +describe("assemblePreToolCallSnapshot", () => { it("maps params.payload.tool.name to tool_call.name, and unwraps each argument's .value into tool_call.args, dropping provenance", () => { const envelope = makeEnvelope({ toolName: "run_shell", @@ -43,7 +43,7 @@ describe("assembleSnapshot", () => { }, }); - const snapshot = assembleSnapshot(envelope); + const snapshot = assemblePreToolCallSnapshot(envelope); expect(snapshot.tool_call.name).toBe("run_shell"); expect(snapshot.tool_call.args).toEqual({ command: "rm -rf /" }); @@ -55,7 +55,7 @@ describe("assembleSnapshot", () => { it("keeps tool_call.args.command a STRING, not a nested wrapper or object", () => { const envelope = makeEnvelope({ args: { command: { value: "rm -rf /" } } }); - const snapshot = assembleSnapshot(envelope); + const snapshot = assemblePreToolCallSnapshot(envelope); expect(typeof snapshot.tool_call.args.command).toBe("string"); expect(snapshot.tool_call.args.command).toBe("rm -rf /"); @@ -64,7 +64,7 @@ describe("assembleSnapshot", () => { it("always emits envelope.budgets with all four counters zeroed, even though the envelope says nothing about budgets", () => { const envelope = makeEnvelope(); - const snapshot = assembleSnapshot(envelope); + const snapshot = assemblePreToolCallSnapshot(envelope); expect(snapshot.envelope).toEqual({ budgets: { tool_call_count: 0, token_count: 0, elapsed_seconds: 0, cost_usd: 0 }, @@ -74,7 +74,7 @@ describe("assembleSnapshot", () => { it("carries params.request_id onto tool_call.id", () => { const envelope = makeEnvelope({ requestId: "2c3e4f50-1234-4abc-9def-000000000000" }); - const snapshot = assembleSnapshot(envelope); + const snapshot = assemblePreToolCallSnapshot(envelope); expect(snapshot.tool_call.id).toBe("2c3e4f50-1234-4abc-9def-000000000000"); }); @@ -86,9 +86,9 @@ describe("assembleSnapshot", () => { it("reads nothing but the envelope: no session-derived key appears anywhere in the output", () => { const envelope = makeEnvelope(); - // No cast: assembleSnapshot returns a named snapshot message now, so what + // 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 = assembleSnapshot(envelope); + const snapshot = assemblePreToolCallSnapshot(envelope); expect(Object.keys(snapshot).sort()).toEqual(["envelope", "tool_call"]); expect(Object.keys(snapshot.envelope)).toEqual(["budgets"]); @@ -109,10 +109,10 @@ describe("assembleSnapshot", () => { args: { command: { value: "rm -rf /", provenance: { source: "user" } } }, }); - const snapshot = assembleSnapshot(envelope); + const snapshot = assemblePreToolCallSnapshot(envelope); const bridge = createBridge("policy/manifest.yaml"); - const result = await bridge.evaluate("pre_tool_call", snapshot); + const verdict = await bridge.evaluate("pre_tool_call", snapshot); - expect(result.verdict.decision).toBe("deny"); + expect(verdict.decision).toBe("deny"); }); }); diff --git a/packages/guardian/test/server.test.ts b/packages/guardian/test/server.test.ts index 0b4a5a09..5d3b7c7f 100644 --- a/packages/guardian/test/server.test.ts +++ b/packages/guardian/test/server.test.ts @@ -145,7 +145,7 @@ describe("startGuardian POST /acs", () => { }); // Fix wave finding 1 -- a real fail-open bug: an unhandled throw from -// assembleSnapshot/bridge.evaluate/mapVerdict inside handleAcsRequest used +// assemblePreToolCallSnapshot/bridge.evaluate/mapVerdict inside handleAcsRequest used // to escape uncaught, and Bun.serve's default error page for a rejected // fetch() is `text/html`, not JSON. guardianClient.post's `res.json()` would // then throw a SyntaxError instead of surfacing a JSON-RPC error, and From a72c718a227baa4dd482c964124c4d027859ed48 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Mon, 17 Aug 2026 10:04:56 +0300 Subject: [PATCH 32/39] Say what the code does now, not how it got here Comments across this slice carried three things a reader cannot use: review archaeology ("this used to be", "PR #10 review, Critical"), internal shaping identifiers (N6, R3.2, S1, C5, D4) that resolve only against documents not in front of them, and prose compressed to the point of being cryptic. Every comment here now stands on its own: what the code does, and the reasoning behind it that the code itself cannot show. Where a constraint came from an identifier, the constraint is stated instead of named. Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr --- packages/agt-bridge/src/index.ts | 67 ++++-------- packages/agt-bridge/test/bridge.test.ts | 15 +-- packages/guardian/src/acs-result.ts | 21 ++-- packages/guardian/src/assemble-snapshot.ts | 35 +++--- packages/guardian/src/handshake.ts | 41 ++----- packages/guardian/src/main.ts | 12 +- packages/guardian/src/map-verdict.ts | 67 ++++-------- packages/guardian/src/server.ts | 103 ++++++++---------- packages/guardian/src/validate-envelope.ts | 66 +++++------ .../guardian/test/assemble-snapshot.test.ts | 7 +- packages/guardian/test/map-verdict.test.ts | 4 +- packages/guardian/test/server.test.ts | 8 +- .../guardian/test/validate-envelope.test.ts | 4 +- packages/host-adapter/src/build-envelope.ts | 34 +++--- packages/host-adapter/src/decision-message.ts | 23 ++-- packages/host-adapter/src/guardian-client.ts | 75 +++++-------- packages/host-adapter/src/handshake.ts | 53 +++------ packages/host-adapter/src/index.ts | 9 +- packages/host-adapter/src/render-decision.ts | 62 ++++------- packages/host-adapter/src/session-config.ts | 38 +++---- packages/host-adapter/test/client.test.ts | 4 +- 21 files changed, 290 insertions(+), 458 deletions(-) diff --git a/packages/agt-bridge/src/index.ts b/packages/agt-bridge/src/index.ts index fb200bab..43cbb1a8 100644 --- a/packages/agt-bridge/src/index.ts +++ b/packages/agt-bridge/src/index.ts @@ -11,69 +11,46 @@ export type AgtVerdict = { /** * What any snapshot is, structurally: the policy input document, as JSON. * - * A CONSTRAINT, NOT A MESSAGE, and the distinction is this alias's whole job - * (PR #10 review, second pass). It used to be `evaluate`'s parameter type, so - * an assembler's named snapshot -- `AgtPreToolCallSnapshot` -- widened back - * into an anonymous dict at the one seam it was built to cross, and the seam - * would then accept literally any object. Each intervention point has its own - * snapshot shape (AGT-SNAPSHOT-1.0.md §2.5) and that shape is the assembling - * caller's knowledge rather than this bridge's, so this package cannot name - * the messages -- but it can refuse to erase them: `PolicyBridge` below is - * parameterised by the snapshot its holder actually sends, and this alias is - * the bound that parameter satisfies. + * A constraint, not a message. Each intervention point has its own snapshot + * shape (AGT-SNAPSHOT-1.0.md §2.5), which is the assembling caller's knowledge + * rather than this bridge's, so `PolicyBridge` is parameterised by the + * snapshot its holder actually sends and this alias is only the bound on that + * parameter. Used directly as a parameter type it would widen a named snapshot + * back to "any object" at the one seam it was built to cross. */ export type InterventionSnapshot = Record; /** - * The role the Guardian actually depends on: something you can tell to - * evaluate a snapshot at an intervention point and that answers with a - * verdict. `createBridge` returned an anonymous object before, so the - * Guardian's own signatures read `ReturnType` -- a - * dependency on a factory's implementation rather than on a role (PR #10 - * review). + * The role the Guardian depends on: something you can tell to evaluate a + * snapshot at an intervention point, which answers with a verdict. * - * Named `PolicyBridge`, not `InterventionEvaluator`, on the same principle as - * `loadHookmap` ∥ `loadMapping`: the factory verb and the role noun should - * rhyme. `createBridge` makes a bridge, every call site already calls its - * variable `bridge`, and the package is `agt-bridge` -- a third noun for the - * same collaborator would cost a reader a translation at every seam it appears - * at. `Policy` says which bridge, since "bridge" alone would not survive a - * second one. + * AGT stays out of the name deliberately -- the Guardian depends on this role, + * not on AGT, and an invariant test keeps that honest in the other direction + * by holding AGT's vocabulary out of the Guardian. `Policy` says which bridge, + * since "bridge" alone would not survive a second one. * - * The AGT half of the name stays out of the type, which matters: the Guardian - * depends on this role, not on AGT, and R3.3's gate is what keeps that honest - * in the other direction. + * `evaluate` answers with the verdict itself, not a result object carrying it: + * the SDK's `inputIdentity`, `enforcedIdentity` and `transformedPolicyTarget` + * describe its own evaluation rather than this step's outcome, and are + * confirmed against the SDK in this package's own test. * - * ANSWERS WITH A VERDICT, not with a bag carrying one (PR #10 review, second - * pass). `evaluate` used to return a `BridgeResult` -- the verdict plus - * `inputIdentity`, `enforcedIdentity` and `transformedPolicyTarget` -- and its - * only production caller destructured `{ verdict }` off it and dropped the - * rest. Three fields nothing read, on every answer, so the collaboration read - * as "here is a result, go and ask it what you wanted" rather than "here is the - * verdict you asked for". Those three are facts about the SDK's own evaluation - * rather than about this step's outcome, and the correction that made them - * worth confirming (C1) is confirmed where it belongs, against the SDK - * directly, in this package's own test. - * - * PARAMETERISED BY THE SNAPSHOT, so a holder's named message survives the seam. - * `createBridge` answers with the general `PolicyBridge`, since it can evaluate - * any point; a holder that assembles particular snapshots declares which ones - * it sends (`PolicyBridge`), and its own call sites are - * then checked against that message instead of against "any object at all". + * The snapshot type parameter lets a holder declare which messages it sends + * (`PolicyBridge`) and be checked against that shape. + * `createBridge` answers with the general form, since it can evaluate any point. */ export type PolicyBridge = { evaluate(point: string, snapshot: S): Promise; }; /** - * N31 — construct once at boot. N30 — evaluate per decision. - * Stateless: nothing is retained between evaluate() calls (R6.1). + * Construct once at boot, evaluate per decision. Stateless: nothing is + * retained between evaluate() calls. */ export function createBridge(manifestPath: string): PolicyBridge { if (manifestPath.includes("/./")) { throw new Error( `manifest path contains "/./": ${manifestPath}. AGT joins this verbatim and OPA ` + - `then drops the bundle's data document, silently disabling policy. See C2.`, + `then drops the bundle's data document, silently disabling policy.`, ); } const control = AgentControl.fromPath(manifestPath); diff --git a/packages/agt-bridge/test/bridge.test.ts b/packages/agt-bridge/test/bridge.test.ts index ee2d56e9..bcc25a1d 100644 --- a/packages/agt-bridge/test/bridge.test.ts +++ b/packages/agt-bridge/test/bridge.test.ts @@ -29,20 +29,21 @@ describe("agt-bridge", () => { } }); - // Guards Correction C2 — the failure mode this catches is a SILENT fail-open. + // The failure mode this catches is a SILENT fail-open: a manifest path + // containing "/./" makes OPA drop the bundle's data document. it("surfaces the policy config to Rego (guards the ./ bundle-path landmine)", async () => { const verdict = await bridge.evaluate("pre_tool_call", snapshotFor("rm -rf /")); expect(verdict.decision).not.toBe("allow"); }); - // Guards Correction C1 — this is why the bridge is Node, not Python. + // Why the bridge is Node, not Python. // - // Asserted against the SDK DIRECTLY, not through `evaluate`. C1 is a claim + // Asserted against the SDK DIRECTLY, not through `evaluate`. The claim is // about what the Node SDK computes, and `PolicyBridge.evaluate` answers with - // a verdict rather than a bag carrying it (PR #10 review, second pass), so - // routing this through the bridge would have meant keeping three fields on - // every answer that nothing reads in order to assert one of them here. The - // subject of the correction is the SDK, so the subject of the test is too -- + // a verdict rather than a bag carrying it, so routing this through the + // bridge would mean keeping three fields on every answer that nothing reads, + // just to assert one of them here. The subject of the claim is the SDK, so + // the subject of the test is too -- // which is a stronger test, not a weaker one: it fails if the SDK stops // returning distinct identities, where the old one could also fail for a // change in this package's own pass-through. diff --git a/packages/guardian/src/acs-result.ts b/packages/guardian/src/acs-result.ts index fc8a9102..fa275147 100644 --- a/packages/guardian/src/acs-result.ts +++ b/packages/guardian/src/acs-result.ts @@ -2,21 +2,14 @@ * The message this Guardian sends back: an ACS final result, as * response-envelope.json's `AcsResult` defines it. * - * A NAMED MESSAGE RATHER THAN A RE-BAGGING (PR #10 review, second pass). The - * inbound half of this seam is method-narrowed -- `validateEnvelope` answers - * with an `AcsRequestEnvelope` and each gate's predicate narrows it further -- - * while the outbound half was assembled into a bare `Record` - * immediately after `mapVerdict` had built a real `AcsDecision`, and handed to - * `successResponse` as an anonymous dict. So the one direction the Guardian is - * solely responsible for was the one direction with no type on it: nothing - * checked that the three envelope fields ACS requires beside the decision were - * present, or that they were the ones this request carried. - * * Structured as the decision PLUS the correlation fields rather than as a flat - * list of its own, because that is what it is: §17's result is one ACS decision - * addressed to one request. Spreading `AcsDecision` in keeps a single source - * for the decision's own shape (map-verdict.ts), so a field added there cannot - * fail to be sendable from here. + * list of its own, because that is what it is: one ACS decision addressed to + * one request. Spreading `AcsDecision` in keeps a single source for the + * decision's own shape (map-verdict.ts), so a field added there cannot fail to + * be sendable from here. Typing it at all is what makes the outbound half of + * this seam as checked as the inbound one, where `validateEnvelope` narrows + * each arrival: without it, nothing confirms the envelope fields ACS requires + * beside the decision are present and belong to this request. * * `request_id` is the ACS correlation id from `params.request_id`, NOT the * JSON-RPC `id`. The two are equal for every envelope `buildEnvelope` sends, diff --git a/packages/guardian/src/assemble-snapshot.ts b/packages/guardian/src/assemble-snapshot.ts index 67d6a553..4d0c0849 100644 --- a/packages/guardian/src/assemble-snapshot.ts +++ b/packages/guardian/src/assemble-snapshot.ts @@ -3,35 +3,26 @@ * `steps/toolCallRequest`) into the AGT snapshot for `pre_tool_call`, * shaped per AGT-SNAPSHOT-1.0.md §2.5. * - * Envelope-only (per the V1 watch-for): this reads nothing but the - * envelope handed to it -- no session state, no chain hash, no prior - * decisions, no intent. Those arrive in V6 via S3/S4/S5. + * Envelope-only: this reads nothing but the envelope handed to it -- no + * session state, no chain hash, no prior decisions, no intent. */ /** - * The envelope type is validate-envelope.ts's (Task 5) -- re-exported here - * so existing imports of `ToolCallRequestEnvelope` from this module keep - * working. Task 4 had declared a local, narrower type as a temporary seam; - * this closes it so there's exactly one envelope shape, not two that could - * silently diverge. - * - * Since the PR #10 review that name means what it says: the tool-call view of - * a validated ACS request, reachable only through `isToolCallRequest`. This - * function takes exactly that, so its signature no longer accepts a - * `handshake/hello` it would read `params.payload.tool.name` off. + * Re-exported from validate-envelope.ts so there is exactly one envelope shape + * rather than two that could silently diverge. The name means what it says: + * the tool-call view of a validated ACS request, reachable only through + * `isToolCallRequest`. So this function cannot be handed a `handshake/hello`, + * off which it would read a `params.payload.tool.name` that is not there. */ import type { ToolCallRequestEnvelope } from "./validate-envelope.ts"; export type { ToolCallRequestEnvelope }; /** - * The AGT `pre_tool_call` snapshot, as a message rather than an anonymous dict - * (PR #10 review, Important). This module used to return - * `Record`: the bridge could be handed anything at all, and - * the only description of what a snapshot contains was the literal below. + * The AGT `pre_tool_call` snapshot. * * Named for the intervention point it is the snapshot FOR, because that is what - * fixes its shape -- AGT-SNAPSHOT-1.0.md §2.5 gives each point its own. A later - * slice's `post_tool_call` snapshot is a sibling type beside this one, not a + * fixes its shape -- AGT-SNAPSHOT-1.0.md §2.5 gives each point its own. A + * `post_tool_call` snapshot would be a sibling type beside this one, not a * widening of it. * * `args` stays `Record` on purpose: those are the tool's own @@ -57,9 +48,9 @@ export type AgtPreToolCallSnapshot = { export function assemblePreToolCallSnapshot(envelope: ToolCallRequestEnvelope): AgtPreToolCallSnapshot { const { payload, request_id } = envelope.params; - // Unwrap every argument. AGT reads raw values (e.g. args.command must be - // a plain string for the stock pattern check's is_string guard -- C5); - // the ACS {value, provenance} wrapper does not survive into the snapshot. + // 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; diff --git a/packages/guardian/src/handshake.ts b/packages/guardian/src/handshake.ts index 312670a2..d06388a6 100644 --- a/packages/guardian/src/handshake.ts +++ b/packages/guardian/src/handshake.ts @@ -1,36 +1,19 @@ /** - * buildServerHello builds the Guardian's ServerHello (N28) -- the answer to - * `handshake/hello` -- per handshake.json's ServerHello $def + * buildServerHello builds the Guardian's answer to `handshake/hello`, per + * handshake.json's ServerHello $def * (spec/acs/specification/v0.1.0/handshake.json). * - * V1 scope: - * - `methods_evaluated` is exactly the set this Guardian actually wires - * up, per the V1 watch-for ("only pre_tool_call is wired"). - * - `on_decision_failure` ships the spec default, "proceed" (fail-open). - * D8: V1 only NEGOTIATES and STORES this value on the wire -- applying - * the posture (the fail-open audit path, N6/N7) is V3. Nothing here - * reads or acts on it beyond returning it. + * `build`, not `negotiate`: this function never reads the incoming + * ClientHello. The host really does send one, but every field below is a + * constant returned unconditionally, so `negotiated_version` and + * `selected_transport` are DECLARED by this Guardian rather than agreed + * against what the client proposed. Real negotiation -- reading the + * ClientHello, picking a mutually supported version and transport, rejecting + * what isn't -- is future work; see docs/demos/v1-runbook.md. * - * WHY THIS IS NOT CALLED `handshakeResponder` (PR #10 review, Important, and - * fix wave finding 7 before it -- honesty, not a scope increase): this - * function never reads the incoming ClientHello. The client (host-adapter's - * `negotiateSessionConfig`) genuinely sends one, but every field below is a - * constant, returned unconditionally. So "negotiated_version" and - * "selected_transport" are DECLARED by this Guardian, not actually negotiated - * against what the client proposed, and a name containing "responder" claimed - * a negotiation the body does not perform. `build` says exactly what - * happens: it assembles the one message this side of the handshake owns. That - * distinction matters in a reference implementation of a wire *contract*. - * Real negotiation (reading ClientHello, picking a mutually-supported - * version/transport, rejecting what isn't) is future work, not attempted here - * -- see the matching note in docs/demos/v1-runbook.md. - * - * Naming symmetry with the host side: each side of this exchange now names its - * own message once, with the same `` morphology -- - * `buildServerHello` here, `negotiateSessionConfig` in - * packages/host-adapter/src/handshake.ts. The asymmetry between "build" and - * "negotiate" is deliberate and is the truth about the code: the host really - * does perform an exchange, and this side really does return constants. + * `methods_evaluated` is exactly the set this Guardian wires up. + * `on_decision_failure` ships the spec default, "proceed" (fail-open). This + * side only declares it on the wire; nothing here reads or acts on it. */ export type ServerHello = { diff --git a/packages/guardian/src/main.ts b/packages/guardian/src/main.ts index 0c9ff0c2..1f0a68b4 100644 --- a/packages/guardian/src/main.ts +++ b/packages/guardian/src/main.ts @@ -1,13 +1,11 @@ /** * The Guardian's standalone CLI entrypoint -- `bun run guardian`. * - * Every earlier task's Guardian usage is in-process (`startGuardian` called - * directly by a test's `beforeAll`). Task 9's demo is the first thing that - * needs the Guardian as a long-running process a hook subprocess can reach - * over HTTP, so this file exists to make `docs/demos/v1-runbook.md`'s - * "start the Guardian" step a real command rather than an unstated gap. - * Not re-exported from `./index.ts`: this is a process entrypoint, not a - * library call. + * The tests use the Guardian in-process, calling `startGuardian` directly. The + * demo needs it as a long-running process a hook subprocess can reach over + * HTTP, so this file makes the runbook's "start the Guardian" step a real + * command. Not re-exported from `./index.ts`: this is a process entrypoint, + * not a library call. * * `ACS_GUARDIAN_PORT` defaults to 8787 -- the same default port * `hosts/claude-code/acs-hook.ts` assumes for `ACS_GUARDIAN_URL` when that diff --git a/packages/guardian/src/map-verdict.ts b/packages/guardian/src/map-verdict.ts index aff835bb..3b7a7fec 100644 --- a/packages/guardian/src/map-verdict.ts +++ b/packages/guardian/src/map-verdict.ts @@ -16,16 +16,14 @@ type VerdictRule = { /** A leaf of field_synthesis that copies a verdict field verbatim. */ type FieldSource = { source: string }; /** A leaf of field_synthesis that wraps a scalar verdict field into a - * single-element array -- the only shape reason_codes' string verdict - * field (verdict.reason) can take to satisfy ACS's `string[]`. `wrap` is - * required here (not optional) precisely so there is no third case to - * handle: a string source is always wrapped, never cast unsound. */ + * single-element array -- the only shape `verdict.reason` can take to satisfy + * ACS's `string[]`. `wrap` is required, not optional, so a string source is + * always wrapped and never cast. */ type WrappedFieldSource = { source: string; wrap: WrapMode }; type FieldLiteral = { literal: string }; -/** The wrap modes this mapping can express. One member today; the point of - * naming the set is that `applyWrap` below refuses everything outside it - * rather than silently doing the one thing it knows. */ +/** The wrap modes this mapping can express. Named as a set so `applyWrap` + * can refuse everything outside it. */ type WrapMode = "array"; export type Mapping = { @@ -48,30 +46,17 @@ export function loadMapping(path: string): Mapping { } /** - * Resolves an ACS method to the AGT intervention point that answers it, from - * mapping.yaml's `intervention_points` table -- the same table V7's - * conformance matrix publishes. + * Resolves an ACS method to the AGT intervention point that answers it. * - * This exists because the table used to be a claim nobody checked: the - * Guardian hardcoded `"pre_tool_call"` at its one call site, so the - * declaration was documentation V7 was asked to trust while the runtime - * ignored it (PR #10 review, Critical, twice -- once against the call site - * and once against the table). The two could disagree without anything - * failing. Now the runtime reads it, so a wrong row is a wrong decision, - * which is the only kind of claim a conformance matrix can safely publish. + * mapping.yaml lists these the other way round: each entry is keyed by the AGT + * intervention point and names the ACS method it answers. This searches that + * table backwards, because a request arriving off the wire tells us its ACS + * method, and what we need in order to evaluate it is the AGT point. * - * The direction is deliberately method -> point, not point -> method, even - * though the YAML is keyed the other way: the runtime is handed an ACS method - * by the wire and needs the AGT point, and inverting a small declaration here - * is cheaper than duplicating it in the other order. - * - * Every failure is a THROW, and none of them is recoverable-by-guessing. - * Returning a default point, or falling back to `pre_tool_call`, would - * evaluate the wrong policy and call the result a decision; that is the - * fail-open this function is shaped to make impossible. What the Guardian - * does with the throw is its own concern -- at this slice it becomes a - * JSON-RPC error in the ACS-reserved range, and turning an evaluation failure - * into an explicit ACS `deny` is N27, scoped to V3. + * If the table names no point for the method, or names more than one, this + * throws rather than picking one. Falling back to a default would mean + * evaluating the wrong policy and then returning that answer as this step's + * decision. */ export function resolveInterventionPoint(acsMethod: string, mapping: Mapping): string { const table = mapping.intervention_points; @@ -113,23 +98,13 @@ function readVerdictField(verdict: AgtVerdict, source: { source: string }): unkn /** * Applies a field_synthesis leaf's declared `wrap` to the string it read. * - * This exists because the declaration used to be one the runtime ignored (PR - * #10 review, second pass): `wrap: array` was required on the type, written in - * mapping.yaml, and published by V7's matrix as part of the table -- while - * `mapVerdict` built `[value]` from a hardcoded literal and never looked. That - * is the same defect class as the `pre_tool_call` hardcode `resolveInterventionPoint` - * closed, one table row over: editing the declaration changed nothing, so the - * mapping could claim a synthesis the code did not perform and nothing would - * fail. - * - * An unrecognised mode is a THROW, and the alternative is worse than a reported - * failure for the same reason every other failure in this module is. `loadMapping` - * casts the parsed YAML with `as Mapping` and validates nothing, so `wrap` at - * runtime is whatever the file says; defaulting an unknown mode to array-wrapping - * would synthesize a `reason_codes` the mapping did not ask for and hand it to a - * host as a decision's machine-readable half. The Guardian's evaluation catch - * turns this into an honoured `deny` (§6.4, R1.5), which is the honest answer to - * a mapping this code cannot carry out. + * An unrecognised mode throws rather than falling back to array-wrapping. + * `loadMapping` parses the YAML without validating it, so `wrap` can be + * anything the file happens to say; wrapping it anyway would build a + * `reason_codes` list the mapping never asked for and hand it to the host as + * the machine-readable half of the decision. The Guardian catches the throw + * and denies the step instead, which is the honest answer when it cannot carry + * out the mapping it was given. */ function applyWrap(value: string, wrap: WrapMode, leaf: string): string[] { if (wrap !== "array") { diff --git a/packages/guardian/src/server.ts b/packages/guardian/src/server.ts index 27233de6..c4487e13 100644 --- a/packages/guardian/src/server.ts +++ b/packages/guardian/src/server.ts @@ -1,34 +1,29 @@ /** - * startGuardian (N20) serves the ACS wire boundary: a single JSON-RPC 2.0 - * endpoint, POST /acs. The spec mandates no URL path convention -- "/acs" - * is this project's own, recorded as such in the slices doc -- so dispatch - * inside the handler is by the JSON-RPC `method` field, never by URL path. + * startGuardian serves the ACS wire boundary: a single JSON-RPC 2.0 endpoint, + * POST /acs. The spec mandates no URL path convention -- "/acs" is this + * project's own choice -- so dispatch inside the handler is by the JSON-RPC + * `method` field, never by URL path. * - * Composes every earlier task, in order, for `steps/toolCallRequest`: - * validateEnvelope (Task 5) -> assemblePreToolCallSnapshot (Task 4) -> - * bridge.evaluate(resolveInterventionPoint(method, mapping), snapshot) - * (Task 2) -> mapVerdict(verdict, mapping) (Task 3) -> response envelope. + * The sequence for `steps/toolCallRequest` is: validateEnvelope -> + * assemblePreToolCallSnapshot -> bridge.evaluate at the intervention point + * resolveInterventionPoint picked -> mapVerdict -> response envelope. * - * assemblePreToolCallSnapshot / bridge.evaluate / mapVerdict are wrapped in a try/catch - * (fix wave finding 1): an unhandled throw here -- e.g. mapVerdict's own - * require_policy_references check, or a genuine AGT runtime error -- would - * otherwise escape this handler, and Bun.serve's default error page for an - * unhandled fetch() rejection is `text/html`, not JSON-RPC. The client - * (packages/host-adapter/src/guardian-client.ts) calls `res.json()` - * unconditionally, so an HTML body throws a SyntaxError there instead of - * surfacing a JSON-RPC error -- and hosts/claude-code/acs-hook.ts's catch-all - * then exits 1 with nothing on stdout, which Claude Code treats as "the hook - * didn't fire": the tool call proceeds **ungoverned**. That is a fail-open in - * a governance tool. The catch below only guarantees a well-formed JSON-RPC - * error reaches the client -- it deliberately does NOT turn the failure into - * an ACS `deny` decision. That is N27 (denyOnInvalidEnvelope), scoped to V3; - * deciding what disposition an evaluation failure carries is V3's call, not - * this fix's. + * The middle three run inside a try/catch, and that catch is load-bearing. + * Were a throw to escape this handler, Bun.serve would answer with its default + * error page, which is HTML rather than JSON-RPC. The host's client calls + * res.json() unconditionally, so an HTML body raises a SyntaxError there + * instead of surfacing a JSON-RPC error; the hook's catch-all then exits 1 + * with nothing on stdout, which Claude Code reads as "the hook never fired" + * and allows the tool call through ungoverned. That is a fail-open in a + * governance tool, so the catch must stay. * - * The bridge (N31: AgentControl.fromPath) and the mapping table are both - * constructed/loaded exactly once, at startGuardian() call time -- not per - * request -- since AGT is meant to be built at boot and evaluated - * statelessly (R6.1). + * What the catch guarantees is only that a well-formed JSON-RPC error reaches + * the client. It deliberately does not turn the failure into an ACS `deny`: + * which disposition an evaluation failure should carry is a separate question. + * + * The bridge and the mapping table are both built once, when startGuardian is + * called, rather than per request -- AGT is meant to be constructed at boot + * and evaluated statelessly. */ import { fileURLToPath } from "node:url"; import { createBridge, type PolicyBridge } from "agt-bridge"; @@ -45,14 +40,13 @@ import { buildServerHello, type ServerHello } from "./handshake.ts"; /** * Every snapshot message this Guardian can send an intervention point. One - * member at this slice; each gate this Guardian learns to assemble adds its - * own point-specific type here. + * member today; each gate this Guardian learns to assemble adds its own + * point-specific type here. * - * Declared so the bridge seam carries the message rather than erasing it (PR - * #10 review, second pass). `PolicyBridge` is parameterised by the snapshot - * its holder sends, and this is what this holder sends -- so `bridge.evaluate` - * below is checked against the assemblers' own output types instead of against - * "any object at all", which is what `Record` had made of it. + * Declared so the bridge seam carries the message rather than erasing it. + * `PolicyBridge` is parameterised by the snapshot its holder sends, and this + * is what this holder sends, so the `bridge.evaluate` calls below are checked + * against the assemblers' own output types instead of against any object at all. */ type GuardianSnapshot = AgtPreToolCallSnapshot; @@ -69,15 +63,14 @@ const ACS_PATH = "/acs"; * Guardian", or "evaluation itself failed". Rather than reach for the * generic JSON-RPC codes that would otherwise fit (-32602 Invalid params, * -32601 Method not found), this module mints three codes from the unused - * part of the reserved band, keeping every application-level failure inside - * -32000..-32099 per this task's explicit instruction. + * part of the reserved band, so every application-level failure stays inside + * -32000..-32099. */ const ENVELOPE_INVALID_CODE = -32010; const METHOD_NOT_DISPATCHED_CODE = -32011; -/** A throw from assemblePreToolCallSnapshot, bridge.evaluate, or mapVerdict -- e.g. - * mapVerdict's own require_policy_references check, or any AGT runtime - * error. See fix wave finding 1's comment above handleAcsRequest's - * tool-call branch for why this must never be dead code. */ +/** A throw from assemblePreToolCallSnapshot, bridge.evaluate, or mapVerdict -- + * mapVerdict's own require_policy_references check, say, or any AGT runtime + * error. The module header explains why this must never become dead code. */ const EVALUATION_FAILED_CODE = -32020; type JsonRpcSuccess = { jsonrpc: "2.0"; id: string | number; result: AcsFinalResult | ServerHello }; @@ -101,7 +94,7 @@ export type StartGuardianOptions = { export type StartedGuardian = { url: string; close(): Promise }; export async function startGuardian({ port, manifestPath, mappingPath }: StartGuardianOptions): Promise { - // N31 -- construct the bridge once at boot, not per request. + // Construct the bridge once at boot, not per request. const bridge = createBridge(manifestPath); const mapping = loadMapping(mappingPath ?? MAPPING_PATH); @@ -127,9 +120,9 @@ export async function startGuardian({ port, manifestPath, mappingPath }: StartGu async function handleAcsRequest( req: Request, - // The role, not `ReturnType` (PR #10 review): this - // handler depends on something it can tell to evaluate a snapshot, not on - // the shape one factory happens to return. + // The role, not `ReturnType`: this handler depends on + // something it can tell to evaluate a snapshot, not on the shape one + // factory happens to return. bridge: PolicyBridge, mapping: Mapping, ): Promise { @@ -144,11 +137,11 @@ async function handleAcsRequest( let envelope: AcsRequestEnvelope; try { - // validateEnvelope (N21) checks the general request-envelope.json shape - // for every method, plus -- only for steps/toolCallRequest -- the - // hook-specific payload schema. Failure is a THROWN typed error, never - // a decision (N27, the deny-on-invalid-envelope affordance, is V3): we - // turn it into a bare JSON-RPC error below, not {decision: "deny"}. + // validateEnvelope checks the general request-envelope.json shape for + // every method, plus -- only for steps/toolCallRequest -- the + // hook-specific payload schema. Failure is a thrown typed error, never a + // decision: we turn it into a bare JSON-RPC error below, rather than into + // {decision: "deny"}. envelope = validateEnvelope(raw); } catch (error) { if (error instanceof EnvelopeValidationError) { @@ -171,9 +164,8 @@ async function handleAcsRequest( try { const snapshot = assemblePreToolCallSnapshot(envelope); // The intervention point comes from mapping.yaml's own - // `intervention_points` table, not from a literal here (PR #10 review, - // Critical): that table is what V7's conformance matrix publishes, and - // a declaration the runtime does not consult is a claim nobody checks. + // `intervention_points` table rather than from a literal here, so the + // table cannot drift away from what the runtime actually does. // An unresolvable method throws into the catch below rather than // defaulting to a point -- evaluating the wrong policy and calling the // result a decision is the one outcome worse than a reported failure. @@ -183,10 +175,9 @@ async function handleAcsRequest( return successResponse(envelope.id, finalResult(envelope.params, decision)); } catch (error) { - // Fix wave finding 1 -- see the module-level comment above. This is - // deliberately a bare JSON-RPC error, not an ACS `deny` decision - // (N27 stays V3's call): the fix here is only that the client gets a - // parseable envelope back instead of an HTML 500. + // See the module header. Deliberately a bare JSON-RPC error rather than + // an ACS `deny` decision -- all this guarantees is that the client gets + // a parseable envelope back instead of an HTML 500. const message = error instanceof Error ? error.message : String(error); return errorResponse(rpcId, EVALUATION_FAILED_CODE, `evaluation failed: ${message}`); } diff --git a/packages/guardian/src/validate-envelope.ts b/packages/guardian/src/validate-envelope.ts index 363a3b90..d43243d4 100644 --- a/packages/guardian/src/validate-envelope.ts +++ b/packages/guardian/src/validate-envelope.ts @@ -12,11 +12,9 @@ * module validated only generically can never be read as one whose * hook-specific payload was checked. * - * Failure is a THROWN, typed EnvelopeValidationError -- never a returned - * decision. Turning a rejection into an explicit ACS "deny" decision is - * N27, which belongs to a later slice (V3). The caller (Task 6, the - * Guardian server) is responsible for turning a thrown error into a - * JSON-RPC error response. + * Failure is a thrown, typed EnvelopeValidationError -- never a returned + * decision. The Guardian server is the caller, and it is responsible for + * turning a thrown error into a JSON-RPC error response. */ import { readFileSync, readdirSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -67,12 +65,9 @@ export type AcsRequestParams = { * (packages/host-adapter/src/build-envelope.ts): one wire message, one noun, * on both sides of the seam. * - * It used to be called `ToolCallRequestEnvelope` (PR #10 review, Critical, - * and its naming-symmetry companion): every method was typed as a tool call, - * so handshake traffic arrived under a name that lied about it, and the - * consumer's noun was the narrower and more misleading of the two. The - * tool-call shape now lives in `ToolCallRequestEnvelope` below and is - * reachable only after the method has been checked. + * The narrower tool-call shape lives in `ToolCallRequestEnvelope` below, and + * is reachable only once the method has been checked -- so handshake traffic + * can never arrive under a name that claims it is a tool call. */ export type AcsRequestEnvelope = { jsonrpc: "2.0"; @@ -102,15 +97,14 @@ export type ToolCallRequestPayload = { * schema's shape. * * Reachable only through `isToolCallRequest` below, never returned by - * `validateEnvelope` directly. That is the whole point: the narrow type is - * the *conclusion* of a method check, not the type every request is handed - * back as. + * `validateEnvelope` directly: the narrow type is the conclusion of a method + * check, not the type every request is handed back as. * - * Spelled with `Omit` rather than an intersection so `params.payload` is - * exactly `ToolCallRequestPayload`, not `ToolCallRequestPayload & - * Record` -- the intersection type-checks but leaves every - * property lookup resolving against an index signature too, which is how a - * typo silently becomes `unknown` instead of an error. + * Spelled with `Omit` rather than an intersection so that `params.payload` is + * exactly `ToolCallRequestPayload`. An intersection would type-check, but it + * would leave `Record`'s index signature in play, so a + * mistyped property name would quietly resolve to `unknown` instead of + * becoming an error. */ export type ToolCallRequestEnvelope = Omit & { method: typeof TOOL_CALL_REQUEST_METHOD; @@ -119,11 +113,12 @@ export type ToolCallRequestEnvelope = Omit { expect(snapshot.tool_call.args).toEqual({ command: "rm -rf /" }); }); - // C5 — AGT's stock pattern check reads input.policy_target.value and + // AGT's stock pattern check reads input.policy_target.value and // requires is_string. A surviving {value:...} wrapper (or a non-string) // makes the check silently never fire, and everything is allowed. it("keeps tool_call.args.command a STRING, not a nested wrapper or object", () => { @@ -79,10 +79,9 @@ describe("assemblePreToolCallSnapshot", () => { expect(snapshot.tool_call.id).toBe("2c3e4f50-1234-4abc-9def-000000000000"); }); - // Envelope-only (the V1 watch-for): no S3/S4/S5 reads. Session id, chain + // Envelope-only: nothing is read from session state. Session id, chain // hash, and every other session-derived key must not survive into the - // snapshot -- this is the boundary the later V6 slice will cross, on - // purpose, somewhere else. + // snapshot. it("reads nothing but the envelope: no session-derived key appears anywhere in the output", () => { const envelope = makeEnvelope(); diff --git a/packages/guardian/test/map-verdict.test.ts b/packages/guardian/test/map-verdict.test.ts index 2e5a677c..8a7a47ad 100644 --- a/packages/guardian/test/map-verdict.test.ts +++ b/packages/guardian/test/map-verdict.test.ts @@ -19,7 +19,7 @@ describe("mapVerdict", () => { expect(d.policy_references?.[0]?.rule_id).toBe("destructive_shell_command_blocked"); }); - // R1.2 — the whole warn round trip rests on this. + // The whole warn round trip rests on this. it("maps warn to allow WITH non-empty policy_references", () => { const d = mapVerdict({ decision: "warn", reason: "drift_detected", message: "drift 0.8" }, m); expect(d.decision).toBe("allow"); @@ -121,7 +121,7 @@ describe("resolveInterventionPoint", () => { it("never resolves to a row whose acs_method is null", () => { // mapping.yaml really does ship two of these: the model-call points have - // no ACS v0.1.0 target (D4). A resolver comparing loosely would match a + // no ACS v0.1.0 target. A resolver comparing loosely would match a // null row and evaluate the wrong intervention point's policy. const allNull = { ...m, diff --git a/packages/guardian/test/server.test.ts b/packages/guardian/test/server.test.ts index 5d3b7c7f..f7ca6328 100644 --- a/packages/guardian/test/server.test.ts +++ b/packages/guardian/test/server.test.ts @@ -128,9 +128,9 @@ describe("startGuardian POST /acs", () => { expect(response.error?.code).toBeLessThanOrEqual(-32000); }); - // Scope boundary (N27 is V3, not this task): a Guardian-side validation - // failure must surface as a bare JSON-RPC error, never as an explicit ACS - // "deny" decision -- see validate-envelope.test.ts's identical guard. + // Scope boundary: a Guardian-side validation failure must surface as a bare + // JSON-RPC error, never as an explicit ACS "deny" decision -- see + // validate-envelope.test.ts's identical guard. it("an envelope that fails schema validation returns a JSON-RPC error in -32000..-32099, never a deny decision", async () => { const bad = toolCallEnvelope("rm -rf /"); delete (bad.params as Record).acs_version; @@ -176,7 +176,7 @@ describe("startGuardian POST /acs -- evaluation failure inside handleAcsRequest" expect(response.error?.code).toBeGreaterThanOrEqual(-32099); expect(response.error?.code).toBeLessThanOrEqual(-32000); // Never a decision -- same scope boundary as the schema-validation - // guard above (N27 is V3's call, not this fix's). + // guard above. expect((response as Record).decision).toBeUndefined(); } finally { await guardian.close(); diff --git a/packages/guardian/test/validate-envelope.test.ts b/packages/guardian/test/validate-envelope.test.ts index 7105c341..76c4acd6 100644 --- a/packages/guardian/test/validate-envelope.test.ts +++ b/packages/guardian/test/validate-envelope.test.ts @@ -101,8 +101,8 @@ describe("validateEnvelope", () => { } }); - // Scope boundary (N27 is V3, not this task): a rejection is a thrown - // error, never a returned {decision: "deny"} object. + // Scope boundary: a rejection is a thrown error, never a returned + // {decision: "deny"} object. it("throws rather than returning a decision object", () => { const envelope = makeEnvelope({ omitSessionId: true }); diff --git a/packages/host-adapter/src/build-envelope.ts b/packages/host-adapter/src/build-envelope.ts index a0cbd818..4da3120e 100644 --- a/packages/host-adapter/src/build-envelope.ts +++ b/packages/host-adapter/src/build-envelope.ts @@ -1,19 +1,18 @@ /** - * buildEnvelope (N2) turns a host's own hook invocation into an ACS v0.1.0 - * request envelope (steps/* methods), driven entirely by a hookmap (S1) -- - * never by host-specific literals baked into this function. + * buildEnvelope turns a host's own hook invocation into an ACS v0.1.0 request + * envelope (a `steps/*` method), driven entirely by a hookmap -- never by + * host-specific literals baked into this function. * - * R3.2: this package knows ACS and hookmaps, nothing else. No file under - * packages/host-adapter/ may name a policy runtime, its rule language, or - * its decision vocabulary -- verified mechanically by Task 10's grep gate. - * This module has no runtime dependency on the Guardian package or the - * policy-bridge package that sits behind it; it talks to the Guardian - * over the wire (Task 8), never in-process. + * This package knows ACS and hookmaps, nothing else. No file under + * packages/host-adapter/ may name a policy runtime, its rule language, or its + * decision vocabulary, and a grep gate in the test suite checks that. This + * module has no runtime dependency on the Guardian package or the policy + * bridge behind it: it talks to the Guardian over the wire, never in-process. */ import { createHash, randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; -/** One hook's mapping onto an ACS method: S1's `hooks.` entry. */ +/** One hook's mapping onto an ACS method: the hookmap's `hooks.` entry. */ export type HookmapHookEntry = { /** The ACS `steps/*` method this hook fires. Never hardcoded here. */ acs_method: string; @@ -24,10 +23,10 @@ export type HookmapHookEntry = { }; /** - * S1 in full: hook-name -> ACS method mapping (consumed here) plus - * decision -> host-output mapping (consumed by Task 8's renderDecision, - * not by this module -- present on the type only so a hookmap loaded - * whole, as `loadHookmap` does, round-trips without loss). + * A hookmap in full: the hook-name -> ACS method mapping this module consumes, + * plus the decision -> host-output mapping renderDecision consumes. The second + * is on the type only so that a hookmap loaded whole, as `loadHookmap` does, + * round-trips without losing it. */ export type Hookmap = { host: string; @@ -60,16 +59,15 @@ export type AcsRequestEnvelope = { const ACS_VERSION = "0.1.0"; -/** Loads and parses a hookmap YAML file (e.g. S1's claude-code.hookmap.yaml). */ +/** Loads and parses a hookmap YAML file, e.g. claude-code.hookmap.yaml. */ export function loadHookmap(path: string): Hookmap { return Bun.YAML.parse(readFileSync(path, "utf8")) as Hookmap; } /** * Resolves a JSONPath-lite reference (`$.foo.bar`, or `$` alone) against a - * raw hook payload. Only dotted field access is supported -- every S1 - * path in this slice is a single top-level field, and nothing here needs - * array indexing or filters. + * raw hook payload. Only dotted field access is supported: every hookmap path + * is a single top-level field, and nothing here needs array indexing or filters. */ function resolvePath(payload: Record, path: string): unknown { const segments = path.replace(/^\$\.?/, "").split(".").filter(Boolean); diff --git a/packages/host-adapter/src/decision-message.ts b/packages/host-adapter/src/decision-message.ts index c9f214f9..b594f179 100644 --- a/packages/host-adapter/src/decision-message.ts +++ b/packages/host-adapter/src/decision-message.ts @@ -5,25 +5,20 @@ * slices add between them -- speaks the same message rather than each * declaring its own shape of it. * - * ONE STEM (PR #10 review, Important and its naming-symmetry companion). There - * is one public noun for "the ACS decision for this step". The Guardian - * produced `AcsDecision` and this side consumed `AcsDecisionResult`, so a - * reader following one decision across the seam learned a second name for a - * role that had not changed -- `Result` describes a processing stage, not a - * different message. Any later refinement of it should stay an adjective that - * still contains this stem (`ValidatedAcsDecision`), never a parallel noun. - * - * WHY THIS IS NOT AN IMPORT OF THE GUARDIAN'S OWN `AcsDecision`. The Guardian + * Deliberately not an import of the Guardian's own `AcsDecision`. The Guardian * declares the same message (packages/guardian/src/map-verdict.ts) as a strict * union of the five ACS dispositions, because it is the side that *builds* * one. This side reads one off HTTP, so it is deliberately loose: only * `decision` is required, and everything else a caller touches is named by the - * hookmap rather than assumed to exist under a fixed key. The two are - * structurally compatible in the direction that matters (the Guardian's is - * assignable to this one), and they share the NAME rather than an import, - * because R3.2 forbids this package depending on the Guardian's type graph. + * hookmap rather than assumed to exist under a fixed key. The Guardian's type + * is assignable to this one; the two share the NAME rather than an import, + * because this package must not depend on the Guardian's type graph. + * + * One noun for "the ACS decision for this step", across both sides of the + * seam. Any later refinement should stay an adjective containing this stem + * (`ValidatedAcsDecision`), never a parallel noun. * - * R3.2: ACS's decision vocabulary, nothing else. + * ACS's decision vocabulary, nothing else. */ /** diff --git a/packages/host-adapter/src/guardian-client.ts b/packages/host-adapter/src/guardian-client.ts index 52214e33..2cbf5821 100644 --- a/packages/host-adapter/src/guardian-client.ts +++ b/packages/host-adapter/src/guardian-client.ts @@ -1,68 +1,43 @@ /** - * The Guardian client (N4): the adapter's wire seam. `createGuardianClient` - * binds one ACS endpoint and returns the role a caller collaborates with. + * The Guardian client: the adapter's wire seam. `createGuardianClient` binds + * one ACS endpoint and returns the role a caller collaborates with. * - * TWO METHODS, AND WHY THE SECOND EXISTS (PR #10 review, Important). This used - * to be a one-method namespace returning a raw JSON-RPC response, and the host - * shim then dug through it: read `.error`, throw if present, cast `.result` - * into a decision shape and hope. That inspection is where "a decision that - * arrived outranks anything else" lives, so leaving it in a host shim meant - * the next host (slice V5) would copy it -- and copying it wrong is a - * fail-open, because every branch that gets a decision wrong ends with the - * tool call proceeding ungoverned. + * Two methods, because two callers want opposite things from a failure: * - * So `requestDecision` is *told* to fetch the decision for an envelope and - * answers with a message -- `DecisionOrFailure` -- rather than a bag. It never - * throws: every way of not getting a decision (a dead transport, an - * uncorrelated response, a JSON-RPC error, a result naming no decision) is the - * same one answer, "no decision arrived, and here is what stands in its - * place". A caller cannot forget to handle one. + * - `requestDecision` never throws. Every way of not getting a decision -- + * a dead transport, an uncorrelated response, a JSON-RPC error, a result + * naming no decision -- becomes the same answer, so a caller cannot forget + * to handle one. Getting that wrong is a fail-open, because every branch + * that mishandles a decision ends with the tool call proceeding ungoverned. + * - `post` is the wire primitive underneath it, for the one caller whose + * result is not a decision: the handshake, whose result is a ServerHello + * (handshake.ts). It throws on every delivery failure, which is what that + * caller wants -- a handshake failure is not a step's failure and travels + * separately. * - * `post` stays as the wire primitive underneath it, for the one caller whose - * method's result is not a decision: the handshake, whose result is a - * ServerHello (handshake.ts). It throws on every delivery failure, which is - * what that caller wants -- a handshake failure is not a step's failure and - * travels separately. + * What a host DOES with `decisionArrived: false` -- negotiating and applying a + * fail-open or fail-closed posture -- is not decided here. * - * V1 SCOPE: what a host DOES with `decisionArrived: false` is not decided - * here and is not decided at this slice. Negotiating and applying a - * fail-open/fail-closed posture is N6/N7, deliberately deferred to V3; the - * shim's current handling is a placeholder its own header documents as one. - * This module's contribution is only that the failure arrives as one answer - * instead of a bag every caller re-interprets. + * Correlation: buildEnvelope sets the envelope's top-level `id` equal to + * `params.request_id`. `fetch` already pairs one HTTP request with one + * response, so the id check below is a defensive assertion against a Guardian + * that echoes back the wrong id, not a lookup table for concurrent requests. * - * Correlation note (Task 7 -> Task 8): buildEnvelope sets the envelope's - * top-level `id` equal to `params.request_id` (a fresh uuid per call). - * That is sufficient here: `id` is schema-legal as a string (per - * request-envelope.json, `id` is `oneOf` string/number, with no format - * constraint of its own), and since `fetch` already pairs this exact HTTP - * request with this exact HTTP response one-to-one, there is no - * multiplexing problem to solve. The id check below is a defensive - * assertion -- it catches a Guardian bug that echoes back the wrong id -- - * not a lookup table matching concurrent in-flight requests. Nothing about - * Task 7's choice needs to change. - * - * R3.2: this module knows JSON-RPC, HTTP and ACS's decision vocabulary, - * nothing else -- no policy-runtime vocabulary and no host vocabulary. It has - * no runtime dependency on the Guardian or policy-bridge packages -- it talks - * to the Guardian only over the wire, at whatever `url` the caller gives. + * This module knows JSON-RPC, HTTP and ACS's decision vocabulary, nothing else + * -- no policy-runtime vocabulary and no host vocabulary. It has no runtime + * dependency on the Guardian or policy-bridge packages: it talks to the + * Guardian only over the wire, at whatever `url` the caller gives. */ import type { AcsRequestEnvelope } from "./build-envelope.ts"; import type { AcsDecision } from "./decision-message.ts"; /** - * The minimal JSON-RPC 2.0 request shape this client sends -- the TRANSPORT - * shape, used by `post` alone. + * The minimal JSON-RPC 2.0 request shape this client sends -- the transport + * shape, used by `post` alone, not the shim-facing vocabulary. * * Loose on `params` on purpose: `post` carries whatever a caller hands it, and * its one non-decision caller is the handshake, whose ClientHello params are * not an ACS request at all. - * - * NOT the shim-facing vocabulary (PR #10 review, second pass). `requestDecision` - * used to take one of these, so the seam between a producer speaking - * `AcsRequestEnvelope` and a consumer speaking `AcsDecision` was the one place - * the ACS noun evaporated and JSON-RPC's took its place. It now takes the ACS - * request message, and this stays the internal transport type underneath it. */ export type JsonRpcRequest = { jsonrpc: "2.0"; diff --git a/packages/host-adapter/src/handshake.ts b/packages/host-adapter/src/handshake.ts index 2095885d..139c92b8 100644 --- a/packages/host-adapter/src/handshake.ts +++ b/packages/host-adapter/src/handshake.ts @@ -1,43 +1,24 @@ /** - * N5, the host half of `handshake/hello`: send a ClientHello, keep what comes - * back as this session's config (S13). + * The host half of `handshake/hello`: send a ClientHello, keep what comes back + * as this session's config. * - * ONE NAME FOR THE STORED MESSAGE (PR #10 review, Important and naming - * symmetry). This module speaks `SessionConfig` throughout -- the message a - * host stores and reads its posture and timeout from -- and not `ServerHello`, - * which is the Guardian's noun for what it emits - * (packages/guardian/src/handshake.ts's `buildServerHello`). The two used to be - * used interchangeably here for the same value, joined by - * `as unknown as SessionConfig`: a rename dressed as a type, checking nothing. + * The stored message is a `SessionConfig`, never a `ServerHello`. + * `isSessionConfig` requires the two fields this host actually needs, not the + * five handshake.json's ServerHello $def requires, so naming the stored type + * after the wire message would over-claim what this host validates. + * `ServerHello` stays scoped to one thing: what the Guardian sent, before this + * host has confirmed it can use it. * - * `SessionConfig` is the honest name on this side, because `isSessionConfig` - * requires the two fields this host actually needs, not the five - * handshake.json's ServerHello $def requires -- so naming the stored type after - * the wire message would over-claim what this host validates, which is the same - * defect as a type that claims a check it does not perform. That leaves - * `ServerHello` a scoped noun rather than a second name for one message, and - * the scope is exactly one thing: what the Guardian sent, before this host has - * confirmed it can use it. No stored value, field, or type is called a - * ServerHello anywhere. + * Stores only. Applying the negotiated posture -- falling back to + * `timeout_config` when the Guardian is slow or silent, recording a fail-open + * audit event per `on_decision_failure: "proceed"` -- happens elsewhere, which + * keeps a policy runtime's own evaluation-layer fail-closed behaviour distinct + * from wire-delivery failure. This module does not retry, does not time out, + * and does not write to an audit sink: it sends one request, stores one response. * - * `negotiateSessionConfig`, not `handshake`: the old name was one of four for a - * single negotiation (`handshake` / `handshakeResponder` / `SessionConfig` / - * `ServerHello`), and the least informative of them -- a bare wire verb that - * said nothing about what the caller gets. This one names the message it - * produces, matching the `` shape of the Guardian's own half. - * - * V1 SCOPE -- stores only. Applying the negotiated posture (falling back - * to `timeout_config` when the Guardian is slow or silent; recording a - * fail-open audit event per `on_decision_failure: "proceed"`) is N6/N7, - * and belongs to slice V3. That boundary is deliberate: this project keeps - * a policy runtime's own evaluation-layer fail-closed behaviour distinct - * from wire-delivery failure, and V3 is where the wire-delivery half - * lands. This module does not retry, does not time out, and does not - * write to an audit sink -- it sends one request, stores one response. - * - * R3.2: this module knows ACS handshake vocabulary and JSON-RPC, nothing - * else. It has no runtime dependency on the Guardian package -- it talks - * to the Guardian only through the client role it is given, over the wire. + * This module knows ACS handshake vocabulary and JSON-RPC, nothing else. It has + * no runtime dependency on the Guardian package -- it talks to the Guardian + * only through the client role it is given, over the wire. */ import { randomUUID } from "node:crypto"; import type { GuardianClient, JsonRpcRequest } from "./guardian-client.ts"; diff --git a/packages/host-adapter/src/index.ts b/packages/host-adapter/src/index.ts index 14d2ba4b..0ab136ae 100644 --- a/packages/host-adapter/src/index.ts +++ b/packages/host-adapter/src/index.ts @@ -1,11 +1,10 @@ /** * Public surface of the host-adapter package (packages/host-adapter). * - * Every host shim (N1 -- Task 9's Claude Code shim, and any later host from - * slice V5) imports from here, not from individual src files, so this list - * is the whole contract a shim relies on. R3.2 still binds every file this - * barrel re-exports: no policy-runtime vocabulary of any kind anywhere - * under packages/host-adapter/. + * Every host shim imports from here rather than from individual src files, + * so this list is the whole contract a shim relies on. The package boundary + * still binds every file this barrel re-exports: no policy-runtime vocabulary + * of any kind anywhere under packages/host-adapter/. */ export { buildEnvelope, diff --git a/packages/host-adapter/src/render-decision.ts b/packages/host-adapter/src/render-decision.ts index 798b2600..ccbbfe59 100644 --- a/packages/host-adapter/src/render-decision.ts +++ b/packages/host-adapter/src/render-decision.ts @@ -1,8 +1,8 @@ /** - * renderDecision (N3) turns an ACS decision into the output its host expects - * -- without naming one field of that output anywhere in this module. + * renderDecision turns an ACS decision into the output its host expects -- + * without naming one field of that output anywhere in this module. * - * The hookmap (S1) declares the whole shape. Each `decisions.` entry + * The hookmap declares the whole shape. Each `decisions.` entry * carries an `output` block whose keys are dotted paths into the object the * host reads, and whose values say where each field's content comes from: a * literal (`value:`) or a field of the arriving ACS decision (`from:`, with an @@ -11,39 +11,27 @@ * else; the field names, their nesting, and which of them a given decision * even has are all data. * - * WHY THIS IS NOT MERELY TIDY (PR #10 review, Critical). The project's claim is - * that governance integration collapses from M*N to M+N: a host implements ACS - * once and any conformant policy runtime can govern it. Slice V5's second host - * is promised *this same module*, unchanged, plus a shim and a hookmap. Until - * this rewrite that promise was false -- three of one host's field names were - * baked into these types, and one of them was mandatory, so a second host - * would have had to fork the adapter or inherit the first host's vocabulary. - * The hookmap was data-driven; the TypeScript around it was not. - * - * Two things the previous shape could not express, and this one can: - * - * - A field that sits OUTSIDE whatever wrapper the host nests its decision - * in -- a top-level key alongside it. A path with no dot puts it there. - * - A decision that carries no permission-style field at all. Nothing here - * is mandatory. + * That is what lets one adapter serve many hosts rather than one adapter per + * host: a second host gets this module unchanged, plus a shim and a hookmap. + * So nothing here is mandatory -- a host with no permission-style field at all + * still renders -- and a path with no dot places a field outside whatever + * wrapper the host nests its decision in, rather than inside it. * * The wrapper itself, and the one field that is not a function of the decision * (the name of the hook that asked), belong to the host shim: it wraps what * this returns. That keeps this module's contract exactly "the output is a * function of the decision and the hookmap". * - * The single load-bearing behaviour, unchanged: a `deny` decision's - * `reasoning` string must reach the human reading the host's transcript. That - * happens here generically -- `deny`'s hookmap entry names the decision field - * to copy and the host path to copy it to, and this module copies whatever - * those two say. + * The load-bearing behaviour: a `deny` decision's `reasoning` string must + * reach the human reading the host's transcript. It happens generically -- + * `deny`'s hookmap entry names the decision field to copy and the host path to + * copy it to, and this module copies whatever those two say. * - * R3.2: no policy-runtime vocabulary here, and now no host vocabulary either - * -- test/invariants.test.ts gates both. An observe-only upstream signal has + * No policy-runtime vocabulary here and no host vocabulary either; + * test/invariants.test.ts gates both. An observe-only upstream signal has * already become an ACS `allow` (with policy_references) by the time it - * reaches this module, per R1.2, and it is dispatched through the exact same - * `decisions.allow` entry a plain allow is: still one dispatch path, still - * driven by the hookmap alone. + * reaches this module, and dispatches through the same `decisions.allow` entry + * a plain allow does. */ import type { Hookmap } from "./build-envelope.ts"; import type { AcsDecision } from "./decision-message.ts"; @@ -56,17 +44,15 @@ import type { AcsDecision } from "./decision-message.ts"; export type HostOutput = Record; /** - * One field of a host output, as S1 declares it: exactly one source, plus an - * optional type the arriving value must have. + * One field of a host output, as the hookmap declares it: exactly one source, + * plus an optional type the arriving value must have. * * `type` is a `typeof` string, and it is not decoration. A host field declared - * to hold prose ("the reason a human reads") must not be handed an object - * because some Guardian put one in the decision field it names: the host would - * either display a shape it cannot render or, worse, reject the whole output - * as malformed and treat the hook as having produced no decision -- a - * fail-open, from a decision that arrived perfectly well. A value of the wrong - * type leaves the field off, which is the same thing that happens when the - * decision does not carry the field at all. + * to hold prose must not be handed an object because some Guardian put one in + * the decision field it names: the host would either display a shape it cannot + * render or reject the whole output as malformed and treat the hook as having + * produced no decision -- a fail-open, from a decision that arrived perfectly + * well. A wrong-typed value leaves the field off, exactly as a missing one does. */ type HostOutputField = { /** A literal, copied through as-is. Mutually exclusive with `from`. */ @@ -77,7 +63,7 @@ type HostOutputField = { type?: string; }; -/** One decision's hookmap-declared rendering rule (S1's `decisions.` entry). */ +/** One decision's hookmap-declared rendering rule (its `decisions.` entry). */ type DecisionRenderRule = { output: Record }; /** diff --git a/packages/host-adapter/src/session-config.ts b/packages/host-adapter/src/session-config.ts index bee2c2c0..e48a66a4 100644 --- a/packages/host-adapter/src/session-config.ts +++ b/packages/host-adapter/src/session-config.ts @@ -1,17 +1,14 @@ /** - * The negotiated session config store (S13): where negotiateSessionConfig (N5) - * writes this session's config once a session's capability negotiation - * completes. + * The negotiated session config store: where `negotiateSessionConfig` writes + * this session's config once capability negotiation completes. * * `SessionConfig` is the one noun for that stored message, here as in - * handshake.ts -- "ServerHello" names what the Guardian sent, and appears below - * only where the subject genuinely is the arrival rather than the stored value - * (PR #10 review, naming symmetry; see handshake.ts's header for the rule). + * handshake.ts. "ServerHello" names what the Guardian sent, and appears below + * only where the subject genuinely is the arrival rather than the stored value. * - * V1 SCOPE: storage only. Nothing in this module reads or acts on the - * stored config -- applying it (falling back to timeout_config when the - * Guardian is silent, honoring on_decision_failure's fail-open/fail-closed - * posture) is N6/N7, which belongs to slice V3. + * Storage only. Nothing in this module reads or acts on the stored config -- + * falling back to timeout_config when the Guardian is silent, and honouring + * on_decision_failure's fail-open or fail-closed posture, happen elsewhere. * * A factory rather than a module-level singleton, matching this package's * existing style (loadHookmap, buildEnvelope take every dependency as an @@ -22,9 +19,9 @@ /** The two fields of the Guardian's answer this host actually reads, which is * what makes this the stored config rather than a claim about the wire message. - * Loose on purpose beyond them: a later slice's negotiation may return fields - * this slice never names -- `Record` lets those round-trip - * through the store untouched rather than being silently dropped. */ + * Loose on purpose beyond them: a Guardian may return fields this host never + * names, and `Record` lets those round-trip through the store + * untouched rather than being silently dropped. */ export type SessionConfig = { timeout_config: { default_ms: number; per_method_ms?: Record }; on_decision_failure: "proceed" | "deny"; @@ -33,23 +30,22 @@ export type SessionConfig = { export type SessionConfigStore = { /** The most recently stored config, or undefined before any handshake completes. */ get(): SessionConfig | undefined; - /** Overwrites the stored config. Called by negotiateSessionConfig (N5) with - * the config it validated out of the Guardian's ServerHello. */ + /** Overwrites the stored config. Called by negotiateSessionConfig with the + * config it validated out of the Guardian's ServerHello. */ set(config: SessionConfig): void; }; /** * A `SessionConfig` must at minimum carry the two fields this host reads. The - * predicate exists so `negotiateSessionConfig` (N5) can ASK that question of - * the Guardian's ServerHello before storing one, instead of casting the - * arrival into this type and calling it a config (PR #10 review, Important): - * `as unknown as SessionConfig` made the name a claim nothing checked, which - * is the same defect as a type named for a validation it does not perform. + * predicate exists so `negotiateSessionConfig` can ASK that question of the + * Guardian's ServerHello before storing one, rather than casting the arrival + * into this type and calling it a config -- a cast would make the name a claim + * nothing checked. * * Deliberately NOT the five fields handshake.json's ServerHello $def requires. * This checks what this host needs, so `SessionConfig` is the honest name for * what it certifies; naming the stored type after the wire message would - * over-claim in exactly the way the cast did. + * over-claim in exactly the way a cast would. */ export function isSessionConfig(value: unknown): value is SessionConfig { if (typeof value !== "object" || value === null) { diff --git a/packages/host-adapter/test/client.test.ts b/packages/host-adapter/test/client.test.ts index dcc50355..2c2743e2 100644 --- a/packages/host-adapter/test/client.test.ts +++ b/packages/host-adapter/test/client.test.ts @@ -1,8 +1,8 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; // Test-only import: stands up a real Guardian so these tests prove the // wire contract for real, not against a hand-copied shape. Never imported -// by packages/host-adapter/src (R3.2) -- see build-envelope.test.ts for -// the precedent (Task 7). +// by packages/host-adapter/src, which must not depend on the Guardian -- +// see build-envelope.test.ts for the same arrangement. import { startGuardian, type StartedGuardian } from "guardian"; import { buildEnvelope, loadHookmap, type Hookmap } from "../src/build-envelope.ts"; import { createGuardianClient } from "../src/guardian-client.ts"; From 55b1d56d75ffda5772f180eb62950860ec07462b Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Mon, 17 Aug 2026 11:23:30 +0300 Subject: [PATCH 33/39] Drop the shaping identifiers from test names too A test name is read in test output by someone with no more access to the shaping documents than a reader of the code, so it obeys the same rule the comments now do. Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr --- packages/guardian/test/map-verdict.test.ts | 4 ++-- packages/host-adapter/test/client.test.ts | 4 ++-- packages/host-adapter/test/render-decision.test.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/guardian/test/map-verdict.test.ts b/packages/guardian/test/map-verdict.test.ts index 8a7a47ad..25da9ebf 100644 --- a/packages/guardian/test/map-verdict.test.ts +++ b/packages/guardian/test/map-verdict.test.ts @@ -36,7 +36,7 @@ describe("mapVerdict", () => { expect(mapVerdict({ decision: "transform", reason: "redacted" }, m).decision).toBe("modify"); }); - it("emits only lowercase decisions (C7)", () => { + it("emits only lowercase decisions", () => { for (const dec of ["allow", "deny", "warn", "escalate", "transform"] as const) { const out = mapVerdict({ decision: dec, reason: "r" }, m).decision; expect(out.toLowerCase()).toBe(out); @@ -49,7 +49,7 @@ describe("mapVerdict", () => { // `reason` hits it directly, and it's exactly what the Guardian's // evaluation-failure catch (server.test.ts) now 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 (R1.2's load-bearing check)", () => { + it("throws when require_policy_references is set but verdict.reason is empty", () => { expect(() => mapVerdict({ decision: "warn" }, m)).toThrow(/require_policy_references/); }); diff --git a/packages/host-adapter/test/client.test.ts b/packages/host-adapter/test/client.test.ts index 2c2743e2..6196a2a0 100644 --- a/packages/host-adapter/test/client.test.ts +++ b/packages/host-adapter/test/client.test.ts @@ -186,8 +186,8 @@ describe("GuardianClient.requestDecision", () => { }); }); -describe("negotiateSessionConfig (N5)", () => { - it("sends handshake/hello and stores timeout_config and on_decision_failure into the session config store (S13)", async () => { +describe("negotiateSessionConfig", () => { + it("sends handshake/hello and stores timeout_config and on_decision_failure into the session config store", async () => { const store = createSessionConfigStore(); expect(store.get()).toBeUndefined(); diff --git a/packages/host-adapter/test/render-decision.test.ts b/packages/host-adapter/test/render-decision.test.ts index edf9f560..643d3f35 100644 --- a/packages/host-adapter/test/render-decision.test.ts +++ b/packages/host-adapter/test/render-decision.test.ts @@ -71,7 +71,7 @@ describe("renderDecision", () => { }); }); - it("a warn-derived allow (allow + non-empty policy_references) still renders as a plain allow -- no separate rendering (R1.2)", () => { + it("a warn-derived allow (allow + non-empty policy_references) still renders as a plain allow -- no separate rendering", () => { expect( renderDecision( { From 46394c740ee6b0454620519aa4f8b910d8c5bec7 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Mon, 17 Aug 2026 12:00:36 +0300 Subject: [PATCH 34/39] Extend the same pass to the host shim and the integration tests The earlier sweep was scoped to packages/, which left the Claude Code shim, its tests and the top-level invariant and wire-shape tests still carrying review references and shaping identifiers. Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr --- hosts/claude-code/acs-hook.ts | 46 +++++++++++------------ hosts/claude-code/test/hook.test.ts | 10 ++--- hosts/claude-code/test/wire-shape.test.ts | 16 ++++---- test/invariants.test.ts | 35 +++++++++-------- 4 files changed, 51 insertions(+), 56 deletions(-) diff --git a/hosts/claude-code/acs-hook.ts b/hosts/claude-code/acs-hook.ts index f3a1ff99..d14fc9b7 100644 --- a/hosts/claude-code/acs-hook.ts +++ b/hosts/claude-code/acs-hook.ts @@ -1,5 +1,5 @@ /** - * acs-hook.ts (N1) -- Claude Code's PreToolUse hook shim. + * acs-hook.ts -- Claude Code's PreToolUse hook shim. * * Deliberately thin: read the hook JSON Claude Code sends on stdin, call * buildEnvelope -> createGuardianClient(...).requestDecision -> renderDecision @@ -7,10 +7,9 @@ * back into the JSON Claude Code expects, and write it to stdout. All logic lives * in the adapter -- this file is only the wiring a Claude Code hook process * needs (stdin, stdout, exit code, which hookmap file to load) plus the one - * thing the adapter must not know: this host's own output shape. Slice V5 - * adds a second host by writing another shim this thin against the same, - * unchanged adapter; any logic added here is logic V5 would have to - * duplicate. + * thing the adapter must not know: this host's own output shape. A second + * host is another shim this thin against the same, unchanged adapter, so any + * logic added here is logic that host would have to duplicate. * * This file is host-specific by definition (it may name Claude Code * freely) but must not reach into AGT -- it never imports `agt-bridge` or @@ -29,14 +28,13 @@ * policy deny is expressed, so getting this wrong would make a deny look * like a crash. * - * V1 SCOPE -- Guardian-unreachable handling: if anything above throws (the - * Guardian is down, returns a JSON-RPC error, or the stdin payload is - * malformed), this shim does NOT implement a fail-open/fail-closed - * posture. That negotiation is N6/N7, deliberately deferred to slice V3. - * It writes the error to stderr only, and exits 1 ("non-blocking error" - * per the hook protocol) with nothing on stdout -- Claude Code proceeds as - * though the hook had not fired. This is a placeholder, not a considered - * posture; V3 should replace it deliberately. + * Guardian-unreachable handling is a placeholder, not a considered posture. + * If anything above throws -- the Guardian is down, it returns a JSON-RPC + * error, or the stdin payload is malformed -- this shim writes the error to + * stderr and exits 1 ("non-blocking error" per the hook protocol) with + * nothing on stdout, so Claude Code proceeds as though the hook had not + * fired. Negotiating a fail-open or fail-closed posture, and auditing a + * bypass when one is taken, is not implemented here yet. */ import { fileURLToPath } from "node:url"; import { buildEnvelope, createGuardianClient, loadHookmap, renderDecision, type HostOutput } from "host-adapter"; @@ -50,13 +48,12 @@ const HOOKMAP_PATH = fileURLToPath(new URL("./claude-code.hookmap.yaml", import. const DEFAULT_GUARDIAN_URL = "http://localhost:8787/acs"; /** - * Claude Code's own output shape, which lives here and nowhere else (PR #10 - * review, Critical). + * Claude Code's own output shape, which lives here and nowhere else. * * The adapter renders into a shape it does not name: `renderDecision` reads * the hookmap's dotted output paths and assembles the object they describe, - * knowing ACS decisions and nothing about this host. That is what lets slice - * V5 add a second host with a shim and a hookmap instead of a fork of the + * knowing ACS decisions and nothing about this host. That is what lets a + * second host arrive as a shim and a hookmap rather than a fork of the * shared module -- and it is only true while these names appear on this side * of the seam. The two inside the wrapper appear as data in * claude-code.hookmap.yaml's output paths; here the wrapper itself is the one @@ -126,16 +123,15 @@ async function main(): Promise { const guardian = createGuardianClient(process.env.ACS_GUARDIAN_URL ?? DEFAULT_GUARDIAN_URL); // Told whether a decision arrived, rather than handed a JSON-RPC bag to - // interrogate (PR #10 review, Important). This shim no longer reads - // `.error`, casts `.result`, or decides which of those means "no decision" -- - // getting that branch wrong is a fail-open, and a second host would have - // inherited it by copying this file. + // interrogate. This shim never reads `.error`, casts `.result`, or decides + // which of those means "no decision" -- getting that branch wrong is a + // fail-open, and a second host would inherit it by copying this file. const outcome = await guardian.requestDecision(envelope); if (!outcome.decisionArrived) { - // V1 placeholder, unchanged: this throw lands in main().catch below, which - // still exits 1. Deciding what a delivery failure means -- the negotiated - // fail-open/fail-closed posture, and auditing a bypass when one is taken -- - // is N6/N7 and belongs to V3. See this file's header. + // Placeholder: this throw lands in main().catch below, which exits 1. + // Deciding what a delivery failure means -- the negotiated fail-open or + // fail-closed posture, and auditing a bypass when one is taken -- is not + // implemented here. See this file's header. throw new Error(`acs-hook: no decision arrived from the Guardian: ${describeFailure(outcome.failure)}`); } diff --git a/hosts/claude-code/test/hook.test.ts b/hosts/claude-code/test/hook.test.ts index 55e17034..85203d32 100644 --- a/hosts/claude-code/test/hook.test.ts +++ b/hosts/claude-code/test/hook.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { fileURLToPath } from "node:url"; // Test-only import: stands up a real Guardian so this test proves the wire // contract for real, not against a hand-copied shape -- same precedent as -// packages/host-adapter/test/client.test.ts (Task 8). `host-adapter` here +// packages/host-adapter/test/client.test.ts. `host-adapter` here // is used the same way the shim itself uses it, so this test also proves // what a subprocess sees is what the adapter would have produced directly. import { startGuardian, type StartedGuardian } from "guardian"; @@ -11,9 +11,9 @@ import { buildEnvelope, createGuardianClient, loadHookmap, renderDecision, type const SHIM_PATH = fileURLToPath(new URL("../acs-hook.ts", import.meta.url)); const HOOKMAP_PATH = fileURLToPath(new URL("../claude-code.hookmap.yaml", import.meta.url)); -/** The real PreToolUse payload shape Claude Code delivers on stdin (per the - * Task 7 brief), with `tool_name: "Bash"` -- what Claude Code actually - * sends, and what `policy/manifest.yaml` registers (Task 8's fix). */ +/** The real PreToolUse payload shape Claude Code delivers on stdin, with + * `tool_name: "Bash"` -- what Claude Code actually sends, and what + * `policy/manifest.yaml` registers. */ function preToolUsePayload(command: string): Record { return { session_id: "abc123", @@ -59,7 +59,7 @@ afterAll(async () => { await guardian.close(); }); -describe("acs-hook.ts (N1) -- the Claude Code hook shim, run as a real subprocess", () => { +describe("acs-hook.ts -- the Claude Code hook shim, run as a real subprocess", () => { it("denies a real rm -rf / tool call: exit 0, clean JSON on stdout, and the policy's own reasoning in permissionDecisionReason", async () => { const payload = preToolUsePayload("rm -rf /"); diff --git a/hosts/claude-code/test/wire-shape.test.ts b/hosts/claude-code/test/wire-shape.test.ts index 59abee5f..35220df7 100644 --- a/hosts/claude-code/test/wire-shape.test.ts +++ b/hosts/claude-code/test/wire-shape.test.ts @@ -25,11 +25,11 @@ import { fileURLToPath } from "node:url"; * and `deny` would leave the three rarest renderings -- the ones nobody looks * at, and the ones a careless change breaks first -- unpinned. * - * V1 SCOPE: the shim makes exactly one call, the step call. It does not - * handshake (N5 is exercised from packages/host-adapter/test/client.test.ts at - * this slice, not from the shim), so the stub answers one method and needs no - * ServerHello. When a later slice makes the shim negotiate first, this stub - * grows a `handshake/hello` branch -- the assertions below should not move. + * The shim makes exactly one call, the step call. It does not handshake -- + * that is exercised from packages/host-adapter/test/client.test.ts instead -- + * so the stub answers one method and needs no ServerHello. If the shim is ever + * made to negotiate first, this stub grows a `handshake/hello` branch, and the + * assertions below should not move. */ const SHIM = fileURLToPath(new URL("../acs-hook.ts", import.meta.url)); @@ -86,9 +86,9 @@ describe("the wire shape this host writes to stdout, pinned decision by decision }); it("renders an allow that carries reasoning as a bare allow too -- this hookmap's allow names no reason field", async () => { - // The shape an observe-only upstream signal produces (R1.2): an ACS allow - // with a synthesized explanation and non-empty policy_references. At V1 - // the hookmap's `allow` entry declares no reason source, so neither the + // The shape an observe-only upstream signal produces: an ACS allow with a + // synthesized explanation and non-empty policy_references. This hookmap's + // `allow` entry declares no reason source, so neither the // explanation nor the references reach the transcript, and this rendering // is indistinguishable from a plain allow. Pinned as it IS, not as it // arguably should be: giving `allow` a reason is a behaviour change, and diff --git a/test/invariants.test.ts b/test/invariants.test.ts index f151455f..9a17ff13 100644 --- a/test/invariants.test.ts +++ b/test/invariants.test.ts @@ -3,10 +3,10 @@ import { readFileSync } from "node:fs"; import { Glob } from "bun"; /** - * Strips comments before matching, so these gates assert what R3.2/R3.3 - * actually claim -- no AGT/host vocabulary *used in code* (imports, types, - * calls) -- rather than the much weaker, much more brittle claim "never - * mentioned anywhere, including prose". Doc comments in this codebase + * Strips comments before matching, so these gates assert what the package + * boundaries actually claim -- no AGT or host vocabulary *used in code* + * (imports, types, calls) -- rather than the much weaker and much more + * brittle claim "never mentioned anywhere, including prose". Doc comments * routinely explain a module's boundary by naming the exact vocabulary it * must stay clear of (see hosts/claude-code/acs-hook.ts: "must not reach * into AGT -- it never imports `agt-bridge`..."). A gate that fires on @@ -50,10 +50,10 @@ function assertNoVocabulary(dir: string, terms: string[]): void { describe("architectural invariants", () => { /** - * R3.2 -- the claim the whole M×N argument rests on: a host implements - * ACS once (packages/host-adapter) and is governable by any conformant - * runtime. That collapse is only real if the adapter never leaks - * policy-runtime (AGT) vocabulary into its own code. + * The claim the whole M×N argument rests on: a host implements ACS once + * (packages/host-adapter) and is governable by any conformant runtime. That + * collapse is only real if the adapter never leaks policy-runtime (AGT) + * vocabulary into its own code. * * Scope, deliberately: packages/host-adapter/src only. * - hosts/claude-code/ is NOT scanned here. A host shim is @@ -82,13 +82,12 @@ describe("architectural invariants", () => { }); /** - * The other half of R3.2, and the one PR #10's review found false - * (Critical): the adapter is promised to slice V5's second host - * *unchanged*, so it must not name the FIRST host's output fields either. - * `renderDecision` used to declare `HookSpecificOutput` with a mandatory - * `permissionDecision`, and returned `{ hookSpecificOutput }` -- Claude - * Code's wire shape as the shared module's public API. A second host would - * have inherited that vocabulary or forked the module. + * The other half of that boundary: the adapter is promised to a second + * host *unchanged*, so it must not name the FIRST host's output fields + * either. Declaring Claude Code's wire shape here -- a mandatory + * `permissionDecision`, a returned `{ hookSpecificOutput }` -- would make + * one host's vocabulary the shared module's public API, and a second host + * would have to inherit it or fork the module. * * All four names now live in hosts/claude-code/: two as data in * claude-code.hookmap.yaml's output paths, and the wrapper in acs-hook.ts, @@ -106,9 +105,9 @@ describe("architectural invariants", () => { }); /** - * R3.3 -- what makes slice V5's second host cost zero AGT code: the - * bridge that knows AGT must never learn a specific host's wire shape, - * or adding a host would mean touching this package too. + * What makes a second host cost zero AGT code: the bridge that knows AGT + * must never learn a specific host's wire shape, or adding a host would + * mean touching this package too. */ it("AGT bridge's source contains zero host-specific code", () => { assertNoVocabulary("packages/agt-bridge/src", [ From 176c181c89e25ffc11d6b4d2a83510dbf27517b5 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Mon, 17 Aug 2026 14:53:41 +0300 Subject: [PATCH 35/39] Drop a fix-wave citation a reader cannot resolve The comment explains why this test uses it.skipIf rather than an early return. The finding number it opened with named nothing findable from this repository. --- test/pin.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/pin.test.ts b/test/pin.test.ts index 84dd4f72..787d271b 100644 --- a/test/pin.test.ts +++ b/test/pin.test.ts @@ -38,7 +38,7 @@ describe("AGT pin", () => { // UPSTREAM_BUNDLE is set by `bun run verify:pin`, which clones the pinned // ref and needs network access to GitHub -- unavailable in the fast unit - // run. Fix wave finding 4: a bare early `return` here used to make this + // run. A bare early `return` here used to make this // report green while asserting nothing, silently un-guarding the // project's central "AGT runs unforked" claim. `it.skipIf` instead makes // bun report this test as SKIPPED, not passed -- unmistakable in output -- From e2639ca2ba90aba04c4bfa5374e7d07eb34fcaa2 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Fri, 21 Aug 2026 14:24:18 +0300 Subject: [PATCH 36/39] Bind the Guardian to loopback, not to every interface Bun.serve with no `hostname` listens on `*` -- dual-stack, every interface -- and lsof confirmed it: `TCP *:8787 (LISTEN)`. The ACS endpoint has no authentication, no origin check and no request signing, so reachability was the only access control it had, and it was not exercising it. Anything that could route to the port was both a policy oracle, able to ask what would be allowed, and a policy sink, able to feed the engine envelopes it would evaluate as though a governed host had sent them. The default is now 127.0.0.1, spelled as the address rather than "localhost" so which interfaces listen is a property of this line and not of the machine's resolver. A deployment that genuinely needs a routable bind -- a Guardian in its own container -- says so through the new `hostname` option, threaded from ACS_GUARDIAN_HOST in main.ts because that is where this package reads env and server.ts reads none. The test asserts reachability rather than the label Bun prints for the socket: `server.hostname` reads "localhost" for a wildcard bind, which is exactly the reading that made this invisible. A wildcard bind answers on ::1 and a 127.0.0.1 bind refuses there, so the test drives both and uses the wildcard case as a control -- on a machine with no IPv6 loopback the control fails and says so, rather than letting an unreachable address masquerade as a narrow bind. --- packages/guardian/src/main.ts | 9 +++++- packages/guardian/src/server.ts | 31 +++++++++++++++++++- packages/guardian/test/server.test.ts | 42 +++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/packages/guardian/src/main.ts b/packages/guardian/src/main.ts index 1f0a68b4..f46e6b32 100644 --- a/packages/guardian/src/main.ts +++ b/packages/guardian/src/main.ts @@ -11,6 +11,12 @@ * `hosts/claude-code/acs-hook.ts` assumes for `ACS_GUARDIAN_URL` when that * env var is unset, so the runbook and the shim agree without either * hardcoding the other's value. + * + * `ACS_GUARDIAN_HOST` is left unset by default, which leaves startGuardian's + * loopback bind in place -- see server.ts's header for why an unauthenticated + * endpoint defaults to the narrowest bind. This is where a deployment that + * needs a routable one says so, since env is this process's configuration + * surface and server.ts reads none itself. */ import { startGuardian } from "./server.ts"; @@ -18,7 +24,8 @@ const DEFAULT_PORT = 8787; const DEFAULT_MANIFEST_PATH = "policy/manifest.yaml"; const port = Number(process.env.ACS_GUARDIAN_PORT ?? DEFAULT_PORT); +const hostname = process.env.ACS_GUARDIAN_HOST; const manifestPath = process.env.ACS_MANIFEST_PATH ?? DEFAULT_MANIFEST_PATH; -const guardian = await startGuardian({ port, manifestPath }); +const guardian = await startGuardian({ port, hostname, manifestPath }); console.log(`Guardian listening at ${guardian.url}`); diff --git a/packages/guardian/src/server.ts b/packages/guardian/src/server.ts index c4487e13..1a9fae27 100644 --- a/packages/guardian/src/server.ts +++ b/packages/guardian/src/server.ts @@ -24,6 +24,17 @@ * The bridge and the mapping table are both built once, when startGuardian is * called, rather than per request -- AGT is meant to be constructed at boot * and evaluated statelessly. + * + * The listening socket defaults to loopback (127.0.0.1). Bun.serve with no + * `hostname` binds `*` -- every interface, dual-stack -- and this endpoint has + * no authentication, no origin check and no request signing, so anything that + * can open the port is both a policy oracle (ask it what would be allowed) + * and a policy sink (feed it envelopes it evaluates as if a governed host had + * sent them). Until the wire is authenticated, reachability IS the access + * control, so the default is the narrowest bind a host shim on the same + * machine can still reach. A deployment that genuinely needs a routable bind + * -- a Guardian in its own container, say -- opts in explicitly through the + * `hostname` option (main.ts reads ACS_GUARDIAN_HOST for it). */ import { fileURLToPath } from "node:url"; import { createBridge, type PolicyBridge } from "agt-bridge"; @@ -55,6 +66,13 @@ const MAPPING_PATH = fileURLToPath(new URL("../../../mapping.yaml", import.meta. const HANDSHAKE_METHOD = "handshake/hello"; const ACS_PATH = "/acs"; +/** The default bind address -- see the module header for why it is loopback + * and not `*`. Spelled as the literal address rather than "localhost": the + * name resolves to both ::1 and 127.0.0.1, and Bun binds only one of them, + * so the name would make which interfaces are listening a property of the + * machine's resolver rather than of this line. */ +const LOOPBACK_ONLY = "127.0.0.1"; + /** * ACS reserves -32000..-32099 for application errors (Specification §17), * but only enumerates named codes -32000..-32007 in the §17.1 registry @@ -83,6 +101,11 @@ type JsonRpcFailure = { export type StartGuardianOptions = { port: number; manifestPath: string; + /** The address to bind. Defaults to loopback; set it only to widen the + * bind deliberately, and read the module header first -- the endpoint is + * unauthenticated, so widening it hands the policy decision to whoever can + * reach the port. main.ts threads ACS_GUARDIAN_HOST into this. */ + hostname?: string; /** Overrides the mapping.yaml path this Guardian loads. Defaults to the * repo's real mapping.yaml; exists so tests can inject a deliberately * misconfigured mapping (e.g. require_policy_references on a decision @@ -93,12 +116,18 @@ export type StartGuardianOptions = { }; export type StartedGuardian = { url: string; close(): Promise }; -export async function startGuardian({ port, manifestPath, mappingPath }: StartGuardianOptions): Promise { +export async function startGuardian({ + port, + hostname, + manifestPath, + mappingPath, +}: StartGuardianOptions): Promise { // Construct the bridge once at boot, not per request. const bridge = createBridge(manifestPath); const mapping = loadMapping(mappingPath ?? MAPPING_PATH); const server = Bun.serve({ + hostname: hostname ?? LOOPBACK_ONLY, port, async fetch(req) { const { pathname } = new URL(req.url); diff --git a/packages/guardian/test/server.test.ts b/packages/guardian/test/server.test.ts index f7ca6328..202f2c9a 100644 --- a/packages/guardian/test/server.test.ts +++ b/packages/guardian/test/server.test.ts @@ -211,3 +211,45 @@ describe("startGuardian POST /acs -- the intervention point comes from mapping.y } }); }); + +// PR #10 review, Critical: Bun.serve with no `hostname` binds `*` -- every +// interface, dual-stack -- and this endpoint has no auth, no origin check and +// no request signing, so every host that could route to the port was a policy +// oracle and a policy sink. The observable that separates the two binds is +// reachability, so that is what is asserted, rather than the label Bun prints +// for the socket (`server.hostname` reads "localhost" for a wildcard bind, +// which is exactly the reading that hid this). +describe("startGuardian binds loopback only", () => { + async function reachable(url: string): Promise { + try { + await fetch(url, { method: "POST", body: "{}", signal: AbortSignal.timeout(2000) }); + return true; + } catch { + return false; + } + } + + it("refuses a connection to ::1, which a wildcard bind accepts", async () => { + // `hostname: "::"` reproduces the pre-fix bind exactly: dual-stack + // wildcard, ::1 and 127.0.0.1 both answering. It is the control, and it + // is what stops the assertion below from passing vacuously on a machine + // with no IPv6 loopback -- there, this expectation fails first and says + // so, rather than letting an unreachable address look like a narrow bind. + const wildcard = await startGuardian({ port: 0, hostname: "::", manifestPath: "policy/manifest.yaml" }); + const loopback = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + + try { + // Path and port both come from the url the Guardian reported, so the + // probe cannot drift from what it actually serves. + const wildcardEndpoint = new URL(wildcard.url); + const loopbackEndpoint = new URL(loopback.url); + + expect(await reachable(`http://[::1]:${wildcardEndpoint.port}${wildcardEndpoint.pathname}`)).toBe(true); + expect(await reachable(`http://127.0.0.1:${loopbackEndpoint.port}${loopbackEndpoint.pathname}`)).toBe(true); + expect(await reachable(`http://[::1]:${loopbackEndpoint.port}${loopbackEndpoint.pathname}`)).toBe(false); + } finally { + await wildcard.close(); + await loopback.close(); + } + }); +}); From 44edf980f7039690f708dbda0fd7093a9dc68ef4 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Fri, 21 Aug 2026 14:24:32 +0300 Subject: [PATCH 37/39] Correlate the decision to the request, not just the transport post() enforced JSON-RPC id equality and stopped there. Nothing ever compared the ACS-layer `result.request_id` back to the one sent, even though the Guardian populates it faithfully. A Guardian -- or anything else reaching an unauthenticated socket -- could echo the right transport id while carrying some other step's decision, and the adapter would hand that decision to the shim as this step's answer. The dangerous shape is `allow`: a benign step's verdict standing in for one the policy never issued here. Matching ids and matching request_ids are different claims, so this is a sibling error class rather than a widening of the existing one; a reader of the message needs to know which of the two the Guardian broke. Both checks live in post(), so no caller can hold a response that was never correlated. The check fires only when a result carries a request_id. A ServerHello and a JSON-RPC error carry none, and `undefined` there means "this result is not about a step", not "this result is about someone else's step" -- so the guard is on the result's field, not on the envelope's. The failure is an ordinary throw. requestDecision already turns every way of not getting a decision into the same answer, so it resolves through the existing "no decision" path and needs no vocabulary of its own. --- packages/host-adapter/src/guardian-client.ts | 42 ++++++++-- packages/host-adapter/src/index.ts | 1 + packages/host-adapter/test/client.test.ts | 80 +++++++++++++++++++- 3 files changed, 117 insertions(+), 6 deletions(-) diff --git a/packages/host-adapter/src/guardian-client.ts b/packages/host-adapter/src/guardian-client.ts index 2cbf5821..fbfdf80d 100644 --- a/packages/host-adapter/src/guardian-client.ts +++ b/packages/host-adapter/src/guardian-client.ts @@ -18,10 +18,17 @@ * What a host DOES with `decisionArrived: false` -- negotiating and applying a * fail-open or fail-closed posture -- is not decided here. * - * Correlation: buildEnvelope sets the envelope's top-level `id` equal to + * Correlation happens at two layers, because the two say different things. + * buildEnvelope sets the envelope's top-level `id` equal to * `params.request_id`. `fetch` already pairs one HTTP request with one - * response, so the id check below is a defensive assertion against a Guardian - * that echoes back the wrong id, not a lookup table for concurrent requests. + * response, so the transport-id check below is a defensive assertion against + * a Guardian that echoes back the wrong id, not a lookup table for concurrent + * requests. The ACS layer is a separate claim: `result.request_id` names the + * step the decision is ABOUT, and matching transport ids say nothing about + * it -- a Guardian, or anything else that reaches an unauthenticated socket, + * can answer with the right `id` and another step's decision. Both are + * checked, and both are checked in `post`, so no caller can hold a response + * that was never correlated. * * This module knows JSON-RPC, HTTP and ACS's decision vocabulary, nothing else * -- no policy-runtime vocabulary and no host vocabulary. It has no runtime @@ -73,6 +80,21 @@ export class GuardianResponseMismatchError extends Error { } } +/** Thrown when the Guardian's response correlates at the transport layer but + * its ACS result names a different request -- i.e. a real decision, about + * some other step. A sibling of GuardianResponseMismatchError rather than the + * same error: the transport id matching and the ACS request_id matching are + * different claims, and a reader of the message needs to know which one the + * Guardian broke. */ +export class GuardianResultCorrelationError extends Error { + constructor(requestId: unknown, resultRequestId: unknown) { + super( + `GuardianClient.post: result.request_id ${JSON.stringify(resultRequestId)} does not correlate with the request's request_id ${JSON.stringify(requestId)}`, + ); + this.name = "GuardianResultCorrelationError"; + } +} + /** * What came back when a decision was asked for. Exactly one of the two cases, * discriminated by the only question that matters at this seam: did a decision @@ -107,8 +129,9 @@ export type GuardianClient = { requestDecision(envelope: AcsRequestEnvelope): Promise; /** * The wire primitive: POSTs `envelope` as JSON, parses the JSON-RPC - * response, and returns it once its `id` is confirmed to match the - * request's. Throws for every delivery failure. + * response, and returns it once it is confirmed to answer this request -- + * at the transport layer by `id`, and, when the result carries one, at the + * ACS layer by `request_id`. Throws for every delivery failure. * * For a method whose result is not a decision -- today only * `handshake/hello`, whose result is a ServerHello. A caller after a @@ -133,6 +156,15 @@ export function createGuardianClient(url: string): GuardianClient { throw new GuardianResponseMismatchError(envelope.id, response.id); } + // The ACS-layer claim, checked only when a result makes it: a ServerHello + // and a JSON-RPC error carry no request_id, and neither is broken here. + // `undefined !== envelope.params.request_id` would fire on a handshake, + // so the guard is on the result's field, not on the envelope's. + const resultRequestId = (response.result as { request_id?: unknown } | undefined)?.request_id; + if (resultRequestId !== undefined && resultRequestId !== envelope.params.request_id) { + throw new GuardianResultCorrelationError(envelope.params.request_id, resultRequestId); + } + return response; } diff --git a/packages/host-adapter/src/index.ts b/packages/host-adapter/src/index.ts index 0ab136ae..ba68fecf 100644 --- a/packages/host-adapter/src/index.ts +++ b/packages/host-adapter/src/index.ts @@ -17,6 +17,7 @@ export { export { createGuardianClient, GuardianResponseMismatchError, + GuardianResultCorrelationError, type DecisionOrFailure, type GuardianClient, type JsonRpcRequest, diff --git a/packages/host-adapter/test/client.test.ts b/packages/host-adapter/test/client.test.ts index 6196a2a0..ab7ef612 100644 --- a/packages/host-adapter/test/client.test.ts +++ b/packages/host-adapter/test/client.test.ts @@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; // see build-envelope.test.ts for the same arrangement. import { startGuardian, type StartedGuardian } from "guardian"; import { buildEnvelope, loadHookmap, type Hookmap } from "../src/build-envelope.ts"; -import { createGuardianClient } from "../src/guardian-client.ts"; +import { createGuardianClient, GuardianResultCorrelationError } from "../src/guardian-client.ts"; import { negotiateSessionConfig } from "../src/handshake.ts"; import { renderDecision } from "../src/render-decision.ts"; import { createSessionConfigStore } from "../src/session-config.ts"; @@ -92,6 +92,55 @@ describe("GuardianClient.post", () => { } }); + it("throws when the transport id correlates but result.request_id names another request", async () => { + // The one a transport-only check cannot catch: `id` is echoed faithfully, + // so `fetch`'s own pairing and the JSON-RPC id check both pass, and what + // comes back is a real, well-formed decision -- about a different step. + // Answering this tool call with it is answering a question nobody asked. + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as { id: string | number }; + return Response.json({ + jsonrpc: "2.0", + id: body.id, + result: { decision: "allow", request_id: crypto.randomUUID() }, + }); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap); + await expect(createGuardianClient(`http://localhost:${mock.port}/acs`).post(envelope)).rejects.toThrow( + GuardianResultCorrelationError, + ); + } finally { + mock.stop(true); + } + }); + + it("leaves a ServerHello alone -- a result carrying no request_id is not an uncorrelated one", async () => { + // The check must not fire on the handshake, whose result is a ServerHello + // and has no request_id at all: `undefined` there means "this result is + // not about a step", not "this result is about someone else's step". + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as { id: string | number }; + return Response.json({ jsonrpc: "2.0", id: body.id, result: { negotiated_version: "0.1.0" } }); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + const response = await createGuardianClient(`http://localhost:${mock.port}/acs`).post(envelope); + + expect(response.result?.negotiated_version).toBe("0.1.0"); + } finally { + mock.stop(true); + } + }); + it("against a real Guardian: steps/toolCallRequest for ls -la allows, echoing the request id", async () => { const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); @@ -184,6 +233,35 @@ describe("GuardianClient.requestDecision", () => { mock.stop(true); } }); + + it("answers 'no decision' for another request's decision, however well-formed it is", async () => { + // A decision that names a foreign request_id is not this step's decision, + // and the dangerous shape of it is `allow`: honouring it would let a + // benign step's verdict stand in for one this Guardian never ruled on. + // The throw from post lands in the same "no decision" answer as a dead + // transport, so the caller's posture path resolves it and nothing here + // has to invent a taxonomy for it. + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as { id: string | number }; + return Response.json({ + jsonrpc: "2.0", + id: body.id, + result: { decision: "allow", request_id: crypto.randomUUID() }, + }); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap); + const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`).requestDecision(envelope); + + expect(outcome.decisionArrived).toBe(false); + } finally { + mock.stop(true); + } + }); }); describe("negotiateSessionConfig", () => { From 546be942df81e16fb1ca2aed0fd7b1c64981c68e Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Fri, 21 Aug 2026 14:24:32 +0300 Subject: [PATCH 38/39] Pin Ajv exactly, since Ajv is what decides envelope validity `^8.20.0` and `^3.0.1` floated a dependency that sits on the decision path: Ajv is what makes an ACS envelope schema-valid or not, so a minor release that changes how a schema compiles changes what this Guardian accepts. That is the same class of drift `agt.lock` exists to prevent for the policy bundle, and the root manifest already pins agent-control-specification exactly for the same reason. Both are pinned to the versions bun.lock already resolved, read out of the lockfile rather than chosen, so nothing about what is installed changes -- `bun install` reports no changes and the lockfile moves only where it mirrors the declared range. --- bun.lock | 4 ++-- packages/guardian/package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index 1f80236f..c6d7d495 100644 --- a/bun.lock +++ b/bun.lock @@ -34,8 +34,8 @@ "version": "0.1.0", "dependencies": { "agt-bridge": "workspace:*", - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1", + "ajv": "8.20.0", + "ajv-formats": "3.0.1", }, }, "packages/host-adapter": { diff --git a/packages/guardian/package.json b/packages/guardian/package.json index 6ecffc55..dba7e8ab 100644 --- a/packages/guardian/package.json +++ b/packages/guardian/package.json @@ -7,7 +7,7 @@ "types": "src/index.ts", "dependencies": { "agt-bridge": "workspace:*", - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1" + "ajv": "8.20.0", + "ajv-formats": "3.0.1" } } From 6786fbf92f028f2a1a676ea791ace7144b995a7d Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Fri, 21 Aug 2026 14:24:45 +0300 Subject: [PATCH 39/39] Give a bypass somewhere private to go, and own the paths it would hide in The repository had no SECURITY.md, no CODEOWNERS and no dependabot config, which for a governance tool means a bypass had nowhere to go but a public issue -- where the report is a working recipe for evading a policy engine and reaches every reader before it reaches a fix. SECURITY.md routes reports through GitHub's private vulnerability reporting instead, says plainly what a bypass looks like in this codebase rather than reciting generic advice, and states the supported-version position honestly: there is no tag, no release and no CI, so there is no support matrix and no backport -- a fix lands on main or it does not exist. CODEOWNERS covers only the paths where an unread diff is a policy bypass rather than a regression: the two sides of the wire, the host shims whose exit codes an agent reads as permission, and the policy bundle, mapping table and upstream pin that change what is allowed without changing a line of TypeScript. Everything else fails loudly and needs no gate. Dependabot is configured for the `bun` ecosystem, not `npm`. Dependabot separated the two, and `bun` is keyed on the text-based bun.lock this repo uses; pointing `npm` at this tree would move a declared range and leave the lockfile behind, so the two would disagree about what is installed -- precisely the drift the exact pins are for. `directories` with globs covers the workspace packages explicitly, since the dependency that matters most is declared in packages/guardian rather than at the root. Bun gets version updates but not security updates, which the comment records so nobody assumes a CVE will open its own PR. The github-actions entry is ahead of its subject on purpose: no workflow exists yet, so it is a no-op that goes live the day the first one lands. --- .github/CODEOWNERS | 23 +++++++++++++++ .github/dependabot.yml | 43 ++++++++++++++++++++++++++++ SECURITY.md | 64 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 SECURITY.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..3227c4a0 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,23 @@ +# Review is required where a silent change is a policy bypass rather than a +# regression. That is a narrower set than "the whole repo": these are the paths +# where a diff nobody read can make a governed tool call proceed ungoverned, +# and nothing here will fail a test to tell you. +# +# Not a claim that everything else is unimportant — it is a claim that +# everything else fails loudly. + +# The two sides of the wire. The Guardian decides; the adapter is the only +# thing a host shim is allowed to trust about that decision. +/packages/guardian/ @afogel +/packages/host-adapter/ @afogel + +# Host shims. Each one is a process whose exit code an agent reads as +# permission, so its failure modes are the fail-open surface. +/hosts/ @afogel + +# The policy bundle itself, the verdict-to-ACS translation table, and the +# upstream pin. A change to any of these changes what is allowed without +# changing a line of TypeScript. +/policy/ @afogel +/mapping.yaml @afogel +/agt.lock @afogel diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..433e820e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,43 @@ +# Dependency updates for a repository whose decision path runs through third +# party code: Ajv decides whether an ACS envelope is schema-valid, and AGT's +# published policy library decides everything after that. A silent drift there +# is a change in what this project allows. +# +# Ecosystem is `bun`, not `npm`. Dependabot separated the two: `bun` is its own +# ecosystem, keyed on the text-based `bun.lock` (the legacy binary `bun.lockb` +# is unsupported), requiring bun >= 1.1.39 — this repo uses 1.3.x and the +# text-based lockfile. Pointing `npm` at this tree would read `package.json`, +# find no npm or yarn lockfile, and open PRs that move a declared range while +# leaving `bun.lock` behind — the two would then disagree about what is +# installed, which is the exact failure the exact pins in +# packages/guardian/package.json exist to prevent. +# +# One caveat worth knowing rather than discovering: Dependabot supports +# *version* updates for bun but not *security* updates. A published CVE in Ajv +# will not open its own PR here; it arrives with the ordinary weekly version +# bump, or not at all. +# +# The github-actions entry is deliberately ahead of its subject: this +# repository has no workflows yet (V8 adds the drift harness). It is a no-op +# until then, and live the day the first workflow lands, which is better than +# remembering to add it that day. +version: 2 +updates: + - package-ecosystem: "bun" + # Listed explicitly rather than relying on workspace traversal from the + # root manifest. `bun.lock` is at the root, but the dependency that matters + # most — Ajv — is declared in packages/guardian, and `directories` (which, + # unlike `directory`, takes globs) covers both without assuming how + # Dependabot resolves a workspace. + directories: + - "/" + - "/packages/*" + - "/hosts/*" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..0cba85cd --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,64 @@ +# Security Policy + +## Supported versions + +None, and that is not a placeholder. This repository has no tagged release, no +published package, and no CI. `main` is the only thing that exists, and it is a +reference implementation under construction — the README's own "Planned, not +yet built" table is the honest inventory. + +So there is no support matrix and no backport path: a fix lands on `main` or it +does not exist. Anything here that is running in front of real traffic is doing +so ahead of every claim this project makes. + +## What a vulnerability looks like here + +This is a governance tool, so the interesting failures are not the usual ones. +The bug that matters is a **bypass**: any way to make a tool call proceed when +the policy would have stopped it, or to make a host act on a decision the +policy runtime never issued. + +Concretely, reports are wanted for: + +- A host shim (`hosts/`) that can be made to fail in a way its agent reads as + "the hook never fired", so the tool call proceeds ungoverned. +- A Guardian (`packages/guardian/`) that can be induced to allow, or to answer + one request with another request's decision. +- Anything that reaches the Guardian's socket and changes an outcome. The ACS + wire is unauthenticated at every slice in this tree — the endpoint binds + loopback precisely because reachability is the only access control it has, so + a way around that bind is in scope. +- A crafted envelope, policy bundle, or `mapping.yaml` that turns a `deny` into + anything else, including an error the host treats as permission. + +Out of scope, because they are known and written down rather than hidden: the +absence of wire authentication, described in the header of +`packages/guardian/src/server.ts`, and the fail-open on an unreachable Guardian +at the slices that have not built the posture negotiation yet, described in +`docs/demos/v1-runbook.md`. A way to *trigger* either one against a host that +believes it is governed is very much in scope. + +## Reporting + +Report privately, through GitHub's private vulnerability reporting on this +repository: + +**** + +Please do not open a public issue, discussion, or pull request for a bypass. +A public report is a working recipe for evading a policy engine, and it reaches +every reader before it reaches a fix. + +Include what you have of: + +- The shortest sequence that reproduces it — ideally the hook payload or JSON-RPC + envelope, verbatim, and the commit you ran it against. +- Which side you believe is at fault: the host shim, the Guardian, the mapping, + or the policy bundle. +- What the correct outcome would have been, and what happened instead. +- Whether it needs anything beyond reaching the Guardian's port — a particular + policy, a particular host, a race, local file access. + +A report with a reproducer and no analysis is more useful than analysis with no +reproducer. Expect an acknowledgement rather than a schedule: there is no +release process here to promise a fix window against.