From f062bb68cb58ba779edc37b67718fafb3b108691 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 17:17:57 +0300 Subject: [PATCH 01/35] V2 scaffold: Envelope Inspector --- slices/v2/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 slices/v2/README.md diff --git a/slices/v2/README.md b/slices/v2/README.md new file mode 100644 index 00000000..8dfc9788 --- /dev/null +++ b/slices/v2/README.md @@ -0,0 +1,9 @@ +# V2: Envelope Inspector + +**Demo:** Watch the ACS request and response JSON stream live while you work. + +**Master doc:** [`docs/shaping/acs-reference-impl-slices.md`](../../docs/shaping/acs-reference-impl-slices.md) §V2 — authoritative for this slice's scope. + +**Affordances:** U20, U21, N26, N50, S6 — defined in [Detail C](../../docs/shaping/acs-reference-impl-shaping.md#detail-c-affordances). + +Implementation goes here. From f485e1f1e3a008712e272e44fd57864365dfe66f Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 22:10:02 +0300 Subject: [PATCH 02/35] Plan V2 (Envelope Inspector), with its slices-doc amendments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes five choices §V2 left open (P1-P5: terminal Inspector, S6 as a gitignored JSONL file, tap opt-in at the library level, pairing by JSON-RPC id, request tapped before validation) and records them where the slice is defined, not only in the plan. Adds three watch-fors, all of them consequences of V1's three fail-opens: the tap must be total, S6 records the wire verbatim, and a schema failure still surfaces as an error rather than a decision until N27 lands in V3. Also: risk row 9 (S6 grows unbounded) and D10 -- the ACS Trace pillar (trace/otel-mapping.json, trace/ocsf-mapping.json) is normative and claimed by no slice, which R5.3 says we have to declare either way. Slice: #3 --- docs/shaping/acs-reference-impl-shaping.md | 5 +- docs/shaping/acs-reference-impl-slices.md | 30 +- .../plans/2026-08-09-v2-envelope-inspector.md | 1825 +++++++++++++++++ 3 files changed, 1856 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-09-v2-envelope-inspector.md diff --git a/docs/shaping/acs-reference-impl-shaping.md b/docs/shaping/acs-reference-impl-shaping.md index 8d1d1076..800ab81a 100644 --- a/docs/shaping/acs-reference-impl-shaping.md +++ b/docs/shaping/acs-reference-impl-shaping.md @@ -261,7 +261,7 @@ All resolved — see `spike-agt-integration.md`. | N23 | P3 | guardian | `assembleSnapshot()` — envelope + session state → AGT snapshot | call | → N30 | — | | N24 | P3 | guardian | `mapVerdict()` — AGT verdict → ACS decision; `warn` → `allow` + `policy_references` | call | → N25, → N26 | → N4, → N13 | | N25 | P3 | guardian | `persistResultLabels()` — AGT `result_labels` into ACS lineage | call | → S5 | — | -| N26 | P3 | guardian | `writeEnvelopeTap()` | call | → S6 | — | +| N26 | P3 | guardian | `writeEnvelopeTap()` — ⚠️ **total**: never throws, never alters a decision. Taps the request *before* validation | call | → S6 | — | | N27 | P3 | guardian | `denyOnInvalidEnvelope()` — schema or bridge failure returns an explicit ACS `deny` **decision**, not a bare error, so the host honors it instead of falling back to posture | call | → N26 | → N4, → N13 | | N28 | P3 | guardian | `buildServerHello()` — ServerHello: `timeout_config`, `on_decision_failure`, `profiles_accepted` | call | → N26 | → N5, → N14 | | N30 | P3.1 | agt-bridge | `evaluateInterventionPoint(point, snapshot)` — Node SDK | call | — | → N24 | @@ -291,7 +291,7 @@ All resolved — see `spike-agt-integration.md`. | S3 | P3 | `sessionContext` | Hash-chained entries per `session_id` | | S4 | P3 | `intent` | Immutable Intent baseline per session | | S5 | P3 | `provenance` | `origin` / `derived_from` lineage, carrying AGT `result_labels` between steps | -| S6 | P3 | `envelope log` | JSONL of every request and response | +| S6 | P3 | `envelope log` | JSONL of every request and response, verbatim, at `.acs/envelopes.jsonl` (gitignored — carries raw tool arguments). Paired by JSON-RPC `id` | | S7 | P3.1 | `manifest.yaml` | Binds the `rego` policy to `data.agt.defaults.verdict`; declares intervention points, tools, approval | | S8 | P3.1 | `data.agt.defaults.config` | Thresholds, allowlists, pattern lists — the only place policy behaviour is authored | | S9 | P3.1 | AGT stock bundle | `policy/lib/*.rego` at the pinned ref. Requires the `opa` CLI on PATH | @@ -527,3 +527,4 @@ flowchart TB | ~~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 | +| D10 | 🔴 **New, from V2 planning.** R5.3 — does this implementation claim the ACS **Trace** pillar? | Open | `specification/v0.1.0/trace/otel-mapping.json` is normative: a deployment emitting OTel for the Trace pillar MUST use its span names and required attributes verbatim, and it maps `steps/toolCallRequest` → `gen_ai.tool.call` by name. `trace/ocsf-mapping.json` is its sibling. V2's envelope tap (S6) is a raw JSONL log, deliberately not an OTel or OCSF export, so today we claim neither pillar. R5.3 requires declaring that either way, which makes this V7's problem at the latest — either the conformance matrix records Trace as an explicit non-claim, or a slice picks it up | diff --git a/docs/shaping/acs-reference-impl-slices.md b/docs/shaping/acs-reference-impl-slices.md index 5c3d30e3..935f1b87 100644 --- a/docs/shaping/acs-reference-impl-slices.md +++ b/docs/shaping/acs-reference-impl-slices.md @@ -79,12 +79,34 @@ Every slice ends in something demo-able. |---|-------|-----------|------------|---------|-----------|------------| | U20 | P4 | inspector | envelope stream, request/response JSON pairs | render | — | — | | U21 | P4 | inspector | decision badge: decision + `policy_references` + `reason_codes` | render | — | — | -| N26 | P3 | guardian | `writeEnvelopeTap()` | call | → S6 | — | +| N26 | P3 | guardian | `writeEnvelopeTap()` — **total**: never throws, never alters a decision | call | → S6 | — | | N50 | P4 | inspector | `tailEnvelopeLog()` | observe | → U20, → U21 | — | -| S6 | P3 | store | `envelope log`, JSONL | — | — | → N50 | +| S6 | P3 | store | `envelope log`, JSONL at `.acs/envelopes.jsonl` (gitignored), one entry per direction | — | — | → N50 | **Why this early.** R5.1 and R5.2 are must-haves, and an ACS-first reader needs to see envelopes before anything more elaborate is worth showing. U21 is also how `warn` becomes visible: a `warn` arrives as `allow` with a non-empty `policy_references`, and the badge is what makes that legible rather than buried. +**Decisions taken at planning.** §V2 left the Inspector's form open; these close it, and are recorded here rather than only in the plan. + +| # | Decision | Rationale | +|---|---|---| +| P1 | The Inspector is a **terminal process** — `bun run inspector`, a third terminal beside `bun run guardian` and `claude`. | Zero new dependencies, works over SSH, matches the repo's one-process-per-command shape. R7.1/R7.2 ask for a laptop and no paid dependency; a browser UI would add a server, a bundler and an asset pipeline without proving anything further about the wire. A browser view later reads the same S6 file. | +| P2 | S6 is a **file** — `.acs/envelopes.jsonl`, overridable with `ACS_ENVELOPE_LOG`. | The file is the seam that lets the Inspector import nothing from the Guardian. `jq` works on it unchanged. An in-process bus or a socket would couple P4 to P3. | +| P3 | The tap is **opt-in at the library level, on by default in the CLI**: `startGuardian` taps only when `envelopeLogPath` is passed; `packages/guardian/src/main.ts` passes it. | V1's tests construct Guardians constantly; a default-on tap would scatter files through the working tree. The demo path still gets the tap with nobody opting in. | +| P4 | Request/response pairing is by **JSON-RPC `id`**, carried as `rpc_id` on every entry. | The only identifier present in both directions. `params.request_id` exists on requests only. Pairing by arrival order breaks the moment two hooks are in flight. | +| P5 | The request is tapped **before validation**. | An envelope that fails the schema is the most useful thing an ACS-first reader can see, and it is exactly what disappears if the tap sits behind the validator. R5.1 says *every* hook firing. | + +**⚠️ Watch-for — the tap must be total.** `writeEnvelopeTap` sits on the decision path. V1 shipped three separate fail-opens before they were caught (the `./` bundle landmine, `tool_unknown` failing closed, and an unhandled Guardian throw reaching the shim as an empty stdout); an observability feature that can turn a governed tool call into an ungoverned one would be the fourth. Every write is wrapped: a failure disables the tap for the process lifetime, reports once, and never propagates. V2 asserts this end to end — `rm -rf /` is still denied when every tap write fails. + +**⚠️ Watch-for — S6 records the wire verbatim.** No reformatting, no field stripping, no redaction, no reordering; pretty-printing happens at render time only. An inspector that shows something other than what was sent is worse than none. The consequence is that S6 carries raw tool arguments, which is why `.acs/` is gitignored and why the runbook says so out loud. + +**⚠️ Watch-for — a schema failure appears as an error, not a decision.** In V2 an invalid envelope is tapped (P5) and then answered with a JSON-RPC error, so the Inspector renders `✖ ERROR -32010`, not a badge. `N27 denyOnInvalidEnvelope()` — the affordance that turns Guardian-side failures into honoured ACS `deny` **decisions** — is V3. The Inspector is where that change will become visible. + +**Unpaired responses are real.** A body that will not parse as JSON produces a response with no preceding request and `rpc_id: null`. The Inspector renders it as `(no method) (unpaired)` rather than hiding it. + +**Scope added at planning** (both amend this slice, both land in V2's PR): +- An **invariant gate** on `packages/inspector/src`: zero AGT vocabulary, zero host vocabulary, and no import of `guardian` or `agt-bridge`. R5.2 is why this slice is early, and V1 established that this project turns architectural claims into grep gates rather than prose. Joins the R3.2/R3.3 gates in `test/invariants.test.ts`. +- A **tap↔tail contract test** (`test/envelope-tap-roundtrip.test.ts`). The Inspector declares its own `TapEntry` instead of importing the Guardian's — that is what makes the gate above meaningful — and the duplication is only safe while something fails when the two drift. + --- ## V3: All five dispositions, and both failure postures @@ -224,6 +246,7 @@ Runs on a schedule in CI. MS-ACS is `0.3.1-beta` and warns of breaking changes b | 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 | +| 9 | ⚠️ S6 grows unbounded — no rotation and no size cap | V2 | Accepted. It is a gitignored local demo artifact; `: > .acs/envelopes.jsonl` truncates it safely mid-run because `tailEnvelopeLog` resets on truncation. Rotation is not built, and the runbook says so | ## Open decisions carried from shaping @@ -235,5 +258,8 @@ Runs on a schedule in CI. MS-ACS is `0.3.1-beta` and warns of breaking changes b | D5 | Determinism of the demo | V1 onward | | ~~D7~~ | ✅ **Closed: Rego.** Cedar's sole advantage was avoiding an external binary; the SDK bundles OPA, so that advantage does not exist. Stock bundle verified 105/105 under the bundled OPA | ~~V1~~ | | 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 | +| D10 | 🔴 **The ACS Trace pillar is unclaimed by any slice.** `specification/v0.1.0/trace/otel-mapping.json` and `trace/ocsf-mapping.json` are *normative* — the OTel mapping states that a deployment emitting OTel for the Trace pillar MUST use its span names and required attributes verbatim, and it names `steps/toolCallRequest` → `gen_ai.tool.call` explicitly. V2's S6 is deliberately a raw envelope log, **not** an OTel or OCSF export, so this implementation currently claims neither. R5.3 says we declare what we claim and what we do not — so either V7 measures the Trace pillar as an explicit non-claim, or a slice picks it up. Surfaced during V2 planning; nothing depends on it yet | V7 scope | **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`. + +V2 planning produced no corrections — §V2 had nothing wrong in it — but it did close five open choices (P1–P5, recorded under §V2), add three watch-fors, add risk row 9, and surface D10. Its plan is `docs/superpowers/plans/2026-08-09-v2-envelope-inspector.md`. diff --git a/docs/superpowers/plans/2026-08-09-v2-envelope-inspector.md b/docs/superpowers/plans/2026-08-09-v2-envelope-inspector.md new file mode 100644 index 00000000..7aeca5dd --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-v2-envelope-inspector.md @@ -0,0 +1,1825 @@ +# V2 — Envelope Inspector + +## Slice Contract + +| Field | Value | +|---|---| +| Slice ID | [#3](https://github.com/afogel/ACS_reference_implementation/issues/3) (epic [#1](https://github.com/afogel/ACS_reference_implementation/issues/1), PR [#11](https://github.com/afogel/ACS_reference_implementation/pull/11), stacked on `slice/v1`) | +| Slices doc | `docs/shaping/acs-reference-impl-slices.md` §V2, line 74 | +| Demo | "Watch the ACS request and response JSON stream live while you work in Claude Code." | +| Components | U20 envelope stream (request/response JSON pairs) · U21 decision badge (decision + `policy_references` + `reason_codes`) · N26 `writeEnvelopeTap()` · N50 `tailEnvelopeLog()` · S6 envelope log, JSONL | +| Parked items | U22 session chain view → **V6** (needs S3/S4/S5, which do not exist yet). U23 posture badge and N51 `tailAuditSinks()` → **V3** (need S14, the audit sink). N27 `denyOnInvalidEnvelope()` → **V3**. | +| Watch-for | None recorded in §V2. Three are **added by this plan** (tap totality, pre-validation request tap, unpaired responses) and amended into the slices doc in this PR. | +| Corrections | None marked in §V2. | +| Requirements | R5.1 (every hook firing inspectable as an ACS envelope), R5.2 (an ACS-first reader traces one action end to end without reading AGT source), 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. (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. (R3.3) +5. **ACS decisions are lowercase on the wire** — `allow`, `deny`, `modify`, `ask`, `defer`. Uppercase appears in spec prose and in *rendered output* only, never on the wire. +6. **The bundle path in `policy/manifest.yaml` must not begin with `./`.** 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) +8. **The tap is total.** `writeEnvelopeTap` must never throw, and a tap failure must never change, delay, or suppress a decision. Observability degrades; governance does not. V1 shipped three separate fail-opens before they were caught — the tap is new code on the decision path and gets this constraint explicitly. +9. **The Inspector contains zero AGT vocabulary and zero host vocabulary.** It reads ACS envelopes as data and knows nothing about AGT or about Claude Code. That is R5.2 stated as a property of the code, and Task 6 enforces it by grep. +10. **The Inspector imports nothing from `guardian` or `agt-bridge`.** It reads the log file. A compile-time dependency would make "inspectable on the wire" (R5.1) a claim about our own type graph rather than about the wire. +11. **S6 records exactly what crossed the wire** — no reformatting, no field stripping, no redaction, no reordering. Pretty-printing happens at render time only. An inspector that shows something other than what was sent is worse than no inspector. +12. **No new runtime dependencies.** Bun and TypeScript `strict` + `noUncheckedIndexedAccess` only, as in V1. + +--- + +## Decisions taken during planning + +These are choices §V2 leaves open. Each is amended into the slices doc in this PR. + +| # | Decision | Why | What it rules out | +|---|---|---|---| +| P1 | **The Inspector is a terminal process** — `bun run inspector`, a third terminal beside `bun run guardian` and `claude`. | Zero new dependencies (constraint 12), works over SSH, and matches the repo's existing one-process-per-command shape. R7.1/R7.2 are about a laptop and no paid dependency; a browser UI would add a server, a bundler, and an asset pipeline without proving anything further about the wire. | A web UI, SSE, WebSockets, and any bundler. If a browser view is wanted later it reads the same S6 file and costs nothing already built here. | +| P2 | **S6 is `.acs/envelopes.jsonl`**, gitignored, one JSON object per line. Overridable with `ACS_ENVELOPE_LOG`. | A file is the seam that makes constraint 10 possible: the Guardian writes, anything at all reads. `jq` works on it unchanged. | An in-process event bus or a socket between Guardian and Inspector — either would couple P3 to P4. | +| P3 | **The Guardian's tap is opt-in at the library level, on by default in the CLI.** `startGuardian` taps only when `envelopeLogPath` is passed; `packages/guardian/src/main.ts` passes it. | Existing V1 tests construct Guardians constantly; a default-on tap would scatter files through the working tree and make test output order-dependent. The demo path still gets the tap without anyone opting in. | A module-level singleton tap, or a default path baked into `startGuardian`. | +| P4 | **Request/response pairing is by JSON-RPC `id`**, carried on every entry as `rpc_id`. | It is the only identifier present on both directions of a JSON-RPC exchange. `params.request_id` exists on requests only. | Pairing by arrival order, which breaks the moment two hooks are in flight. | +| P5 | **The request is tapped *before* validation.** | An envelope that fails schema validation is the single most useful thing an ACS-first reader can see, and it is exactly what disappears if the tap sits after the validator. R5.1 says *every* hook firing. | Tapping only well-formed envelopes. | + +--- + +## Slice accounting + +| From the slice | Handled by | Note | +|---|---|---| +| U20 envelope stream, request/response JSON pairs | Task 4 (`renderEntry`), Task 5 (CLI) | | +| U21 decision badge: decision + `policy_references` + `reason_codes` | Task 4 (`renderDecisionBadge`) | Includes the `warn`-as-`allow`-with-`policy_references` case the slice calls out by name | +| N26 `writeEnvelopeTap()` | Task 1 (module), Task 2 (wiring) | | +| N50 `tailEnvelopeLog()` | Task 3 | | +| S6 envelope log, JSONL | Task 1 (format), Task 2 (path + gitignore) | | +| "Why this early": R5.1 | Tasks 1, 2 | Every firing reaches S6, including envelopes that fail validation | +| "Why this early": R5.2 | Task 6 | Enforced as a grep gate, not asserted in prose | +| "Why this early": `warn` becomes visible through U21 | Task 4 | A dedicated badge state, with its own test | +| Parked → V3: U23 posture badge, N51 `tailAuditSinks()`, N27 | not in this plan | stays V3's | +| Parked → V6: U22 session chain view | not in this plan | stays V6's | +| R7.1 one command on a laptop | Task 6 | Runbook; `bun install` adds nothing | +| R7.2 no paid dependency | Task 6 | Constraint 12 makes it structural | + +--- + +## Cross-slice work in this plan + +| Task | Belongs to | Why it must happen here | +|---|---|---| +| Task 2's refactor of `handleAcsRequest` into parse → tap → `dispatch` → tap | V1's N20 | The tap has to see every response including the JSON-RPC parse error, and V1 left six `return` sites inside one function. Tapping at six sites would guarantee the seventh is missed when V3 adds N27. This is the smallest change that makes the tap total by construction. | + +## Scope added during planning + +| What | Why the slice cannot ship without it | Slices-doc amendment | +|---|---|---| +| An invariant gate on `packages/inspector/src` (zero AGT vocabulary, zero host vocabulary, no import of `guardian`/`agt-bridge`) | R5.2 is the reason §V2 says "why this early". V1 established that this project turns architectural claims into grep gates rather than prose; an Inspector that merely *happens* not to import AGT proves nothing next slice. | New line under §V2 recording the gate | +| A cross-package round-trip test (Guardian tap writes → Inspector tail reads → badge renders) | The Inspector declares its own `TapEntry` rather than importing the Guardian's (constraint 10). That duplication is only safe if something fails when the two drift. | New line under §V2 recording the contract test | +| `.gitignore` entry for `.acs/` | S6 lands in the working tree the first time the demo runs, and it contains raw tool arguments. | Recorded in the S6 amendment | +| Risk row: S6 grows unbounded | Honest limitation of a JSONL tap with no rotation. | New risk row 9 | +| Open decision D10: the ACS Trace pillar is unclaimed | `spec/acs/specification/v0.1.0/trace/otel-mapping.json` and `trace/ocsf-mapping.json` are normative mappings no slice claims. V2's tap is deliberately *not* an OTel/OCSF export, and R5.3 says this implementation declares what it claims and what it does not. Recording it is the honest move; building it is not this slice. | New D10 row | + +--- + +## Tasks + +### Task 1: S6's format and N26 `writeEnvelopeTap()` · slice #3 · N26, S6 + +**Files:** +- Create: `packages/guardian/src/envelope-tap.ts` +- Test: `packages/guardian/test/envelope-tap.test.ts` + +**Interfaces:** +- Produces: `createEnvelopeTap(options): EnvelopeTap`, `NULL_TAP: EnvelopeTap`, `extractRpcId(envelope): string | number | null`, and the types `TapEntry`, `TapDirection`, `EnvelopeTap`, `CreateEnvelopeTapOptions`. Task 2 wires `createEnvelopeTap` and `NULL_TAP` into `server.ts`. Task 3 re-declares `TapEntry` independently in the Inspector (constraint 10); Task 5's round-trip test is what keeps the two honest. + +- [ ] **Step 1: Write the failing test** — `packages/guardian/test/envelope-tap.test.ts` + +```ts +import { describe, expect, it } from "bun:test"; +import { mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createEnvelopeTap, extractRpcId, NULL_TAP, type TapEntry } from "../src/envelope-tap.ts"; + +/** A temp directory per test. Cleanup is deliberately non-recursive -- + * unlink the one file we created, then rmdir -- so a stray file makes the + * test fail loudly instead of being silently blown away. */ +function withTempDir(run: (dir: string) => void): void { + const dir = mkdtempSync(join(tmpdir(), "acs-tap-")); + try { + run(dir); + } finally { + try { + unlinkSync(join(dir, "envelopes.jsonl")); + } catch { + // the test may not have produced a log at all -- that is the point of some of them + } + rmdirSync(dir); + } +} + +function readEntries(path: string): TapEntry[] { + return readFileSync(path, "utf8") + .split("\n") + .filter((line) => line.trim() !== "") + .map((line) => JSON.parse(line) as TapEntry); +} + +const REQUEST = { jsonrpc: "2.0", method: "steps/toolCallRequest", id: 7, params: { acs_version: "0.1.0" } }; +const RESPONSE = { jsonrpc: "2.0", id: 7, result: { decision: "deny" } }; + +describe("createEnvelopeTap (N26) -- S6's JSONL format", () => { + it("writes one line per call, with a monotonic seq starting at 1", () => { + withTempDir((dir) => { + const path = join(dir, "envelopes.jsonl"); + const tap = createEnvelopeTap({ path }); + + tap.write("request", REQUEST, "steps/toolCallRequest"); + tap.write("response", RESPONSE, "steps/toolCallRequest"); + + const entries = readEntries(path); + expect(entries.map((e) => e.seq)).toEqual([1, 2]); + expect(entries.map((e) => e.direction)).toEqual(["request", "response"]); + }); + }); + + it("records the envelope verbatim -- constraint 11, no reformatting or stripping", () => { + withTempDir((dir) => { + const path = join(dir, "envelopes.jsonl"); + createEnvelopeTap({ path }).write("request", REQUEST, "steps/toolCallRequest"); + + expect(readEntries(path)[0]?.envelope).toEqual(REQUEST); + }); + }); + + it("carries rpc_id from both directions, so the Inspector can pair them (P4)", () => { + withTempDir((dir) => { + const path = join(dir, "envelopes.jsonl"); + const tap = createEnvelopeTap({ path }); + + tap.write("request", REQUEST, "steps/toolCallRequest"); + tap.write("response", RESPONSE, "steps/toolCallRequest"); + + expect(readEntries(path).map((e) => e.rpc_id)).toEqual([7, 7]); + }); + }); + + it("stamps recorded_at from the injected clock", () => { + withTempDir((dir) => { + const path = join(dir, "envelopes.jsonl"); + const tap = createEnvelopeTap({ path, now: () => new Date("2026-08-09T12:04:31.221Z") }); + + tap.write("request", REQUEST, "steps/toolCallRequest"); + + expect(readEntries(path)[0]?.recorded_at).toBe("2026-08-09T12:04:31.221Z"); + }); + }); + + it("records method as null when the caller cannot determine one", () => { + withTempDir((dir) => { + const path = join(dir, "envelopes.jsonl"); + createEnvelopeTap({ path }).write("response", { jsonrpc: "2.0", id: null, error: { code: -32700 } }, null); + + const entry = readEntries(path)[0]; + expect(entry?.method).toBeNull(); + expect(entry?.rpc_id).toBeNull(); + }); + }); + + // Global constraint 8. This is the whole reason the tap is a module and + // not three inline appendFileSync calls. + it("never throws when the log path is unwritable, reports once, and goes quiet", () => { + withTempDir((dir) => { + const blocker = join(dir, "envelopes.jsonl"); + writeFileSync(blocker, ""); + // A path *through* a regular file: mkdirSync and appendFileSync both + // fail with ENOTDIR, deterministically, on every platform. + const path = join(blocker, "nested", "envelopes.jsonl"); + const errors: unknown[] = []; + const tap = createEnvelopeTap({ path, onError: (error) => errors.push(error) }); + + expect(() => tap.write("request", REQUEST, "steps/toolCallRequest")).not.toThrow(); + expect(() => tap.write("response", RESPONSE, "steps/toolCallRequest")).not.toThrow(); + expect(errors.length).toBe(1); + }); + }); + + it("never throws on an envelope JSON.stringify cannot serialize", () => { + withTempDir((dir) => { + const path = join(dir, "envelopes.jsonl"); + const errors: unknown[] = []; + const tap = createEnvelopeTap({ path, onError: (error) => errors.push(error) }); + const circular: Record = { id: 1 }; + circular.self = circular; + + expect(() => tap.write("request", circular, "steps/toolCallRequest")).not.toThrow(); + expect(errors.length).toBe(1); + }); + }); + + it("NULL_TAP writes nothing and never throws", () => { + expect(() => NULL_TAP.write("request", REQUEST, "steps/toolCallRequest")).not.toThrow(); + expect(NULL_TAP.path).toBeNull(); + }); +}); + +describe("extractRpcId", () => { + it("reads string and number ids", () => { + expect(extractRpcId({ id: 7 })).toBe(7); + expect(extractRpcId({ id: "abc" })).toBe("abc"); + }); + + it("returns null for a missing, null, or non-scalar id", () => { + expect(extractRpcId({})).toBeNull(); + expect(extractRpcId({ id: null })).toBeNull(); + expect(extractRpcId({ id: { nested: true } })).toBeNull(); + expect(extractRpcId("not an object")).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run it, expect FAIL** + +```bash +bun test packages/guardian/test/envelope-tap.test.ts +``` + +Expected: every test errors on the unresolved import of `../src/envelope-tap.ts`. + +- [ ] **Step 3: Minimal implementation** — `packages/guardian/src/envelope-tap.ts` + +```ts +/** + * writeEnvelopeTap (N26) writes S6: a JSONL record of every ACS envelope + * that crosses this Guardian's wire, in both directions, exactly as it + * crossed (global constraint 11). The Envelope Inspector (P4) reads this + * file and nothing else -- see packages/inspector, which deliberately + * imports nothing from here. + * + * Total by construction (global constraint 8). Every write is wrapped: a + * failure disables the tap for the process lifetime, reports once, and is + * never propagated to the caller. The tap sits on the decision path, and + * V1 shipped three separate fail-opens before they were caught -- an + * observability feature that can turn a governed tool call into an + * ungoverned one would be the fourth. Observability degrades; governance + * does not. + */ +import { appendFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +export type TapDirection = "request" | "response"; + +/** + * One line of S6. `envelope` is the JSON-RPC object verbatim -- request or + * response -- and every other field is Guardian-side context the wire does + * not carry: a sequence number so a reader can detect gaps, a timestamp, the + * direction, the ACS method (JSON-RPC responses carry none, so the Guardian + * supplies the one it dispatched), and the JSON-RPC id that pairs the two + * directions. + */ +export type TapEntry = { + seq: number; + recorded_at: string; + direction: TapDirection; + method: string | null; + rpc_id: string | number | null; + envelope: unknown; +}; + +export type EnvelopeTap = { + write(direction: TapDirection, envelope: unknown, method: string | null): void; + readonly path: string | null; +}; + +export type CreateEnvelopeTapOptions = { + path: string; + /** Injectable clock, so tests can assert recorded_at exactly. */ + now?: () => Date; + /** Called at most once, on the first failure. Defaults to one stderr line. */ + onError?: (error: unknown) => void; +}; + +/** The tap a Guardian gets when no envelopeLogPath was configured (P3). */ +export const NULL_TAP: EnvelopeTap = { + path: null, + write(): void {}, +}; + +/** The JSON-RPC id, when it is a scalar. Both request and response envelopes + * carry `id` at the top level, so one extractor serves both directions. */ +export function extractRpcId(envelope: unknown): string | number | null { + if (typeof envelope === "object" && envelope !== null && "id" in envelope) { + const id = (envelope as { id: unknown }).id; + if (typeof id === "string" || typeof id === "number") { + return id; + } + } + return null; +} + +export function createEnvelopeTap({ path, now = () => new Date(), onError }: CreateEnvelopeTapOptions): EnvelopeTap { + let seq = 0; + let disabled = false; + + const fail = (error: unknown): void => { + disabled = true; + if (onError) { + onError(error); + return; + } + const message = error instanceof Error ? error.message : String(error); + console.error(`envelope tap disabled after failure (${path}): ${message}`); + }; + + try { + mkdirSync(dirname(path), { recursive: true }); + } catch (error) { + fail(error); + } + + return { + path, + write(direction, envelope, method): void { + if (disabled) { + return; + } + try { + const entry: TapEntry = { + seq: seq + 1, + recorded_at: now().toISOString(), + direction, + method, + rpc_id: extractRpcId(envelope), + envelope, + }; + const line = `${JSON.stringify(entry)}\n`; + appendFileSync(path, line); + seq += 1; + } catch (error) { + fail(error); + } + }, + }; +} +``` + +- [ ] **Step 4: Run it, expect PASS** + +```bash +bun test packages/guardian/test/envelope-tap.test.ts +bun run typecheck +``` + +- [ ] **Step 5: Commit** + +``` +Add writeEnvelopeTap and S6's JSONL entry format (N26, S6) + +Slice: #3 +Affordances: N26, S6 +``` + +--- + +### Task 2: Wire the tap into the Guardian · slice #3 · N26, N20 + +**Files:** +- Modify: `packages/guardian/src/server.ts` (lines 85-173 — `startGuardian` and `handleAcsRequest`) +- Modify: `packages/guardian/src/index.ts` (append exports) +- Modify: `packages/guardian/src/main.ts` (pass the log path) +- Modify: `.gitignore` (append) +- Test: `packages/guardian/test/envelope-tap-wiring.test.ts` + +**Interfaces:** +- Consumes: `createEnvelopeTap`, `NULL_TAP`, `EnvelopeTap` from `./envelope-tap.ts` (Task 1). +- Produces: `StartGuardianOptions.envelopeLogPath?: string`. Task 5's round-trip test passes it. + +**Requirements:** + +`handleAcsRequest` currently has six `return` sites. Tapping at each is how the seventh gets missed. Restructure it into exactly three phases, so totality is structural rather than remembered: + +``` +parse → (on failure: tap the error response, return) +tap the request → dispatch() → tap the response → return +``` + +`dispatch()` holds V1's existing logic verbatim — validate, handshake branch, toolCallRequest branch, not-dispatched branch — and returns a response instead of the caller returning it directly. Do not change any decision, any error code, or any message text: this task adds a tap and moves code, nothing else. V1's `packages/guardian/test/server.test.ts` must pass untouched. + +Add a best-effort `extractMethod(raw): string | null` beside the existing `extractId`, used only to label tap entries. + +- [ ] **Step 1: Write the failing test** — `packages/guardian/test/envelope-tap-wiring.test.ts` + +```ts +import { describe, expect, it } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { startGuardian } from "../src/index.ts"; +import type { TapEntry } from "../src/envelope-tap.ts"; + +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 } = {}) { + return makeEnvelope( + "steps/toolCallRequest", + { tool: { name: "run_shell" }, arguments: { command: { value: command } } }, + overrides, + ); +} + +function readEntries(path: string): TapEntry[] { + if (!existsSync(path)) { + return []; + } + return readFileSync(path, "utf8") + .split("\n") + .filter((line) => line.trim() !== "") + .map((line) => JSON.parse(line) as TapEntry); +} + +/** Non-recursive cleanup, as in envelope-tap.test.ts. */ +async function withGuardian( + logPathFor: (dir: string) => string, + run: (url: string, logPath: string) => Promise, +): Promise { + const dir = mkdtempSync(join(tmpdir(), "acs-tap-wiring-")); + const logPath = logPathFor(dir); + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", envelopeLogPath: logPath }); + try { + await run(guardian.url, logPath); + } finally { + await guardian.close(); + try { + unlinkSync(logPath); + } catch { + // some tests deliberately make the path unwritable + } + try { + rmdirSync(dir); + } catch { + // a blocker file may remain; the assertions already covered what matters + } + } +} + +async function postRaw(url: string, body: string): Promise { + const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body }); + return await res.json(); +} + +const logIn = (dir: string) => join(dir, "envelopes.jsonl"); + +describe("Guardian envelope tap wiring (N26 x N20)", () => { + it("taps one request and one response per exchange, paired by rpc_id", async () => { + await withGuardian(logIn, async (url, logPath) => { + await postRaw(url, JSON.stringify(toolCallEnvelope("rm -rf /", { id: 11 }))); + + const entries = readEntries(logPath); + expect(entries.length).toBe(2); + expect(entries[0]?.direction).toBe("request"); + expect(entries[1]?.direction).toBe("response"); + expect(entries[0]?.rpc_id).toBe(11); + expect(entries[1]?.rpc_id).toBe(11); + expect(entries[0]?.method).toBe("steps/toolCallRequest"); + expect(entries[1]?.method).toBe("steps/toolCallRequest"); + expect((entries[1]?.envelope as { result?: { decision?: string } }).result?.decision).toBe("deny"); + }); + }); + + it("taps handshake/hello in both directions", async () => { + await withGuardian(logIn, async (url, logPath) => { + await postRaw(url, JSON.stringify(makeEnvelope("handshake/hello", {}, { id: 42 }))); + + const entries = readEntries(logPath); + expect(entries.map((e) => e.direction)).toEqual(["request", "response"]); + expect(entries.every((e) => e.method === "handshake/hello")).toBe(true); + }); + }); + + // Decision P5. The envelope that fails validation is the most useful + // thing an ACS-first reader can see; tapping after the validator is + // exactly what would hide it. + it("taps a schema-invalid request, then its JSON-RPC error response", async () => { + await withGuardian(logIn, async (url, logPath) => { + const bad = toolCallEnvelope("rm -rf /", { id: 12 }); + delete (bad.params as Record).acs_version; + + await postRaw(url, JSON.stringify(bad)); + + const entries = readEntries(logPath); + expect(entries.length).toBe(2); + expect(entries[0]?.direction).toBe("request"); + expect((entries[0]?.envelope as { params: Record }).params.acs_version).toBeUndefined(); + const error = (entries[1]?.envelope as { error?: { code: number } }).error; + expect(error?.code).toBeLessThanOrEqual(-32000); + expect(error?.code).toBeGreaterThanOrEqual(-32099); + }); + }); + + it("taps an unparseable body as a lone response with rpc_id null -- no request line to pair with", async () => { + await withGuardian(logIn, async (url, logPath) => { + await postRaw(url, "{not json"); + + const entries = readEntries(logPath); + expect(entries.length).toBe(1); + expect(entries[0]?.direction).toBe("response"); + expect(entries[0]?.rpc_id).toBeNull(); + expect(entries[0]?.method).toBeNull(); + expect((entries[0]?.envelope as { error?: { code: number } }).error?.code).toBe(-32700); + }); + }); + + // Global constraint 8, end to end: the tap is on the decision path, so + // this is the test that says a broken tap cannot become a fail-open. + it("still denies rm -rf / when every tap write fails", async () => { + const dir = mkdtempSync(join(tmpdir(), "acs-tap-broken-")); + const blocker = join(dir, "blocker"); + writeFileSync(blocker, ""); + const guardian = await startGuardian({ + port: 0, + manifestPath: "policy/manifest.yaml", + envelopeLogPath: join(blocker, "nested", "envelopes.jsonl"), + }); + try { + const response = (await postRaw(guardian.url, JSON.stringify(toolCallEnvelope("rm -rf /")))) as { + result?: { decision?: string }; + error?: unknown; + }; + expect(response.error).toBeUndefined(); + expect(response.result?.decision).toBe("deny"); + } finally { + await guardian.close(); + unlinkSync(blocker); + rmdirSync(dir); + } + }); + + // Decision P3. + it("writes nothing when envelopeLogPath is omitted", async () => { + const dir = mkdtempSync(join(tmpdir(), "acs-tap-off-")); + const logPath = join(dir, "envelopes.jsonl"); + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + await postRaw(guardian.url, JSON.stringify(toolCallEnvelope("ls -la"))); + expect(existsSync(logPath)).toBe(false); + } finally { + await guardian.close(); + rmdirSync(dir); + } + }); +}); +``` + +- [ ] **Step 2: Run it, expect FAIL** + +```bash +bun test packages/guardian/test/envelope-tap-wiring.test.ts +``` + +Expected: `envelopeLogPath` is not a known option, so nothing is written and the first assertion fails on `entries.length` being 0. + +- [ ] **Step 3: Minimal implementation** + +`packages/guardian/src/server.ts` — add the import, extend the options, create the tap, restructure `handleAcsRequest`: + +```ts +import { createEnvelopeTap, NULL_TAP, type EnvelopeTap } from "./envelope-tap.ts"; +``` + +```ts +export type StartGuardianOptions = { + port: number; + manifestPath: string; + mappingPath?: string; + /** Path to S6, the JSONL envelope log (N26). Omitted means no tap: every + * V1 test constructs Guardians freely and a default-on tap would scatter + * files through the working tree. `packages/guardian/src/main.ts` -- the + * demo path -- passes it. See the plan's decision P3. */ + envelopeLogPath?: string; +}; +``` + +```ts +export async function startGuardian({ + port, + manifestPath, + mappingPath, + envelopeLogPath, +}: StartGuardianOptions): Promise { + const bridge = createBridge(manifestPath); + const mapping = loadMapping(mappingPath ?? MAPPING_PATH); + const tap = envelopeLogPath ? createEnvelopeTap({ path: envelopeLogPath }) : NULL_TAP; + + 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, tap); + return Response.json(response); + }, + }); + + return { + url: `http://localhost:${server.port}${ACS_PATH}`, + async close() { + await server.stop(true); + }, + }; +} +``` + +Replace `handleAcsRequest` with the three-phase version, and move V1's body into `dispatch` unchanged: + +```ts +/** + * Three phases, in order: parse, tap the request, dispatch, tap the + * response. The tap calls live here and only here -- `dispatch` below has + * four return sites and V3 adds a fifth (N27), so tapping inside it would + * make totality something a future task has to remember rather than + * something the structure guarantees. + * + * The tap itself is total (see envelope-tap.ts): these two calls cannot + * throw, so they cannot turn a governed tool call into an ungoverned one. + */ +async function handleAcsRequest( + req: Request, + bridge: ReturnType, + mapping: Mapping, + tap: EnvelopeTap, +): Promise { + let raw: unknown; + try { + raw = await req.json(); + } catch { + // Nothing parseable arrived, so there is no request envelope to tap -- + // the response is deliberately recorded unpaired, which is what the + // Inspector renders when a host sends a malformed body. + const parseError = errorResponse(null, -32700, "Parse error"); + tap.write("response", parseError, null); + return parseError; + } + + // Decision P5: before validation, so an envelope that fails the schema is + // visible to the Inspector rather than invisible. + const method = extractMethod(raw); + tap.write("request", raw, method); + + const response = await dispatch(raw, bridge, mapping); + tap.write("response", response, method); + return response; +} + +async function dispatch( + raw: unknown, + bridge: ReturnType, + mapping: Mapping, +): Promise { + const rpcId = extractId(raw); + + let envelope: ToolCallRequestEnvelope; + try { + 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) { + 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) { + const message = error instanceof Error ? error.message : String(error); + return errorResponse(rpcId, EVALUATION_FAILED_CODE, `evaluation failed: ${message}`); + } + } + + return errorResponse(rpcId, METHOD_NOT_DISPATCHED_CODE, `method not dispatched by this Guardian: ${envelope.method}`, { + method: envelope.method, + }); +} +``` + +Add beside `extractId`: + +```ts +/** Best-effort method name for tap labelling only. Never used to dispatch -- + * `dispatch` reads the schema-validated envelope's own `method`. */ +function extractMethod(raw: unknown): string | null { + if (typeof raw === "object" && raw !== null && "method" in raw) { + const method = (raw as { method: unknown }).method; + if (typeof method === "string") { + return method; + } + } + return null; +} +``` + +`packages/guardian/src/index.ts` — append: + +```ts +export { + createEnvelopeTap, + extractRpcId, + NULL_TAP, + type CreateEnvelopeTapOptions, + type EnvelopeTap, + type TapDirection, + type TapEntry, +} from "./envelope-tap.ts"; +``` + +`packages/guardian/src/main.ts` — add the constant, the env read, and pass it. Also print the log path, so the operator knows what to point the Inspector at: + +```ts +const DEFAULT_ENVELOPE_LOG = ".acs/envelopes.jsonl"; +``` + +```ts +const envelopeLogPath = process.env.ACS_ENVELOPE_LOG ?? DEFAULT_ENVELOPE_LOG; + +const guardian = await startGuardian({ port, manifestPath, envelopeLogPath }); +console.log(`Guardian listening at ${guardian.url}`); +console.log(`Envelope log (S6): ${envelopeLogPath}`); +``` + +`.gitignore` — append: + +``` +# S6, the ACS envelope log (N26). Local demo artifact; carries raw tool +# arguments verbatim, so it is never committed. +.acs/ +``` + +- [ ] **Step 4: Run it, expect PASS** + +```bash +bun test packages/guardian +bun run typecheck +``` + +V1's `packages/guardian/test/server.test.ts` must still pass unmodified — this task moved code and added a tap, it changed no decision and no error code. + +- [ ] **Step 5: Commit** + +``` +Tap every ACS envelope crossing the Guardian's wire (N26, N20, S6) + +Slice: #3 +Affordances: N26, N20, S6 +``` + +--- + +### Task 3: `tailEnvelopeLog()` and the Inspector package · slice #3 · N50 + +**Files:** +- Create: `packages/inspector/package.json` +- Create: `packages/inspector/src/tail-envelope-log.ts` +- Test: `packages/inspector/test/tail-envelope-log.test.ts` + +**Interfaces:** +- Produces: `tailEnvelopeLog(options): AsyncGenerator` and the Inspector's **own** `TapEntry` / `TapDirection` types. Task 4's renderers consume `TapEntry`; Task 5's CLI consumes `tailEnvelopeLog`. +- **Does not consume anything from `guardian` or `agt-bridge`** (global constraint 10). `TapEntry` is re-declared here on purpose — Task 5's round-trip test is what keeps the two declarations in agreement. + +- [ ] **Step 1: Write the failing test** — `packages/inspector/test/tail-envelope-log.test.ts` + +```ts +import { describe, expect, it } from "bun:test"; +import { appendFileSync, mkdtempSync, rmdirSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { tailEnvelopeLog, type TapEntry } from "../src/tail-envelope-log.ts"; + +const POLL_MS = 10; + +function entryLine(seq: number, direction: "request" | "response"): string { + const entry: TapEntry = { + seq, + recorded_at: "2026-08-09T12:04:31.221Z", + direction, + method: "steps/toolCallRequest", + rpc_id: seq, + envelope: { jsonrpc: "2.0", id: seq }, + }; + return `${JSON.stringify(entry)}\n`; +} + +/** Collects `count` entries or rejects after `timeoutMs`, then aborts the + * generator so the test cannot hang the suite. */ +async function collect( + iterable: AsyncGenerator, + count: number, + controller: AbortController, + timeoutMs = 3000, +): Promise { + const out: TapEntry[] = []; + const deadline = setTimeout(() => controller.abort(), timeoutMs); + try { + for await (const entry of iterable) { + out.push(entry); + if (out.length >= count) { + break; + } + } + } finally { + clearTimeout(deadline); + controller.abort(); + } + return out; +} + +function withTempDir(run: (dir: string, path: string) => Promise): Promise { + const dir = mkdtempSync(join(tmpdir(), "acs-tail-")); + const path = join(dir, "envelopes.jsonl"); + return run(dir, path).finally(() => { + try { + unlinkSync(path); + } catch { + // the not-yet-created case never writes one + } + rmdirSync(dir); + }); +} + +describe("tailEnvelopeLog (N50)", () => { + it("yields entries appended after the tail starts, skipping what was already there", async () => { + await withTempDir(async (_dir, path) => { + writeFileSync(path, entryLine(1, "request")); + const controller = new AbortController(); + const tail = tailEnvelopeLog({ path, pollMs: POLL_MS, signal: controller.signal }); + // Give the generator a poll to record its starting offset before the + // append lands, which is the behaviour under test. + await Bun.sleep(POLL_MS * 3); + appendFileSync(path, entryLine(2, "response")); + + const entries = await collect(tail, 1, controller); + expect(entries.map((e) => e.seq)).toEqual([2]); + }); + }); + + it("yields pre-existing entries when fromStart is set", async () => { + await withTempDir(async (_dir, path) => { + writeFileSync(path, entryLine(1, "request") + entryLine(2, "response")); + const controller = new AbortController(); + const tail = tailEnvelopeLog({ path, fromStart: true, pollMs: POLL_MS, signal: controller.signal }); + + const entries = await collect(tail, 2, controller); + expect(entries.map((e) => e.seq)).toEqual([1, 2]); + expect(entries[0]?.direction).toBe("request"); + }); + }); + + it("waits for a log file that does not exist yet", async () => { + await withTempDir(async (_dir, path) => { + const controller = new AbortController(); + const tail = tailEnvelopeLog({ path, fromStart: true, pollMs: POLL_MS, signal: controller.signal }); + await Bun.sleep(POLL_MS * 3); + writeFileSync(path, entryLine(1, "request")); + + const entries = await collect(tail, 1, controller); + expect(entries.map((e) => e.seq)).toEqual([1]); + }); + }); + + it("reassembles a line delivered in two chunks", async () => { + await withTempDir(async (_dir, path) => { + const line = entryLine(1, "request"); + const split = Math.floor(line.length / 2); + writeFileSync(path, ""); + const controller = new AbortController(); + const tail = tailEnvelopeLog({ path, fromStart: true, pollMs: POLL_MS, signal: controller.signal }); + + appendFileSync(path, line.slice(0, split)); + await Bun.sleep(POLL_MS * 3); + appendFileSync(path, line.slice(split)); + + const entries = await collect(tail, 1, controller); + expect(entries.map((e) => e.seq)).toEqual([1]); + }); + }); + + it("restarts from zero when the log is truncated underneath it", async () => { + await withTempDir(async (_dir, path) => { + writeFileSync(path, entryLine(1, "request")); + const controller = new AbortController(); + const tail = tailEnvelopeLog({ path, fromStart: true, pollMs: POLL_MS, signal: controller.signal }); + + const first = await collectOne(tail); + expect(first?.seq).toBe(1); + + truncateSync(path, 0); + await Bun.sleep(POLL_MS * 3); + appendFileSync(path, entryLine(9, "response")); + + const entries = await collect(tail, 1, controller); + expect(entries.map((e) => e.seq)).toEqual([9]); + }); + + async function collectOne(tail: AsyncGenerator): Promise { + const { value } = await tail.next(); + return value ?? undefined; + } + }); + + it("reports a malformed line and keeps streaming", async () => { + await withTempDir(async (_dir, path) => { + const malformed: string[] = []; + writeFileSync(path, "{not json\n" + entryLine(3, "request")); + const controller = new AbortController(); + const tail = tailEnvelopeLog({ + path, + fromStart: true, + pollMs: POLL_MS, + signal: controller.signal, + onMalformedLine: (line) => malformed.push(line), + }); + + const entries = await collect(tail, 1, controller); + expect(entries.map((e) => e.seq)).toEqual([3]); + expect(malformed).toEqual(["{not json"]); + }); + }); + + it("ends when the signal aborts", async () => { + await withTempDir(async (_dir, path) => { + writeFileSync(path, ""); + const controller = new AbortController(); + const tail = tailEnvelopeLog({ path, fromStart: true, pollMs: POLL_MS, signal: controller.signal }); + controller.abort(); + + const entries: TapEntry[] = []; + for await (const entry of tail) { + entries.push(entry); + } + expect(entries).toEqual([]); + }); + }); +}); +``` + +- [ ] **Step 2: Run it, expect FAIL** + +```bash +bun test packages/inspector +``` + +Expected: the module does not exist. + +- [ ] **Step 3: Minimal implementation** + +`packages/inspector/package.json`: + +```json +{ + "name": "inspector", + "private": true, + "type": "module", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts" +} +``` + +No `dependencies` block at all — global constraints 10 and 12. Run `bun install` once so the workspace links it. + +`packages/inspector/src/tail-envelope-log.ts`: + +```ts +/** + * tailEnvelopeLog (N50) streams S6 -- the Guardian's JSONL envelope log -- + * as it grows, the way `tail -f` does. + * + * This package deliberately imports nothing from `guardian` or from + * `agt-bridge` (global constraint 10). The Inspector reads a file that the + * Guardian happens to write; it holds no compile-time knowledge of the + * process that produced it, which is the point of R5.1 -- envelopes are + * inspectable *on the wire*, not through our own type graph. TapEntry is + * therefore re-declared here rather than imported. The round-trip test at + * test/envelope-tap-roundtrip.test.ts is what keeps the two declarations in + * agreement; if they drift, it fails. + * + * Polling rather than fs.watch: appends to a growing file are exactly the + * case where watch semantics differ most across platforms, and a 120ms poll + * on a local demo log costs nothing. + */ +import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs"; + +export type TapDirection = "request" | "response"; + +/** One line of S6, as written by the Guardian's envelope tap. */ +export type TapEntry = { + seq: number; + recorded_at: string; + direction: TapDirection; + method: string | null; + rpc_id: string | number | null; + envelope: unknown; +}; + +export type TailOptions = { + path: string; + /** Replay everything already in the file before following. Default false: + * start at the current end, like `tail -f`. */ + fromStart?: boolean; + pollMs?: number; + signal?: AbortSignal; + /** Called per unparseable line. Defaults to one stderr warning. Streaming + * continues either way -- a corrupt line is not a reason to stop showing + * the ones after it. */ + onMalformedLine?: (line: string, error: unknown) => void; +}; + +const NEWLINE = 0x0a; + +export async function* tailEnvelopeLog({ + path, + fromStart = false, + pollMs = 120, + signal, + onMalformedLine = warnMalformedLine, +}: TailOptions): AsyncGenerator { + let offset = fromStart ? 0 : sizeOf(path); + // Bytes, not a string: a poll can land mid-line and, worse, mid-codepoint. + // Decoding only complete lines keeps multi-byte UTF-8 intact. + let pending = Buffer.alloc(0); + + while (!signal?.aborted) { + const size = sizeOf(path); + + if (size < offset) { + // Truncated or rotated underneath us (`: > .acs/envelopes.jsonl`). + offset = 0; + pending = Buffer.alloc(0); + } + + if (size > offset) { + pending = Buffer.concat([pending, readRange(path, offset, size - offset)]); + offset = size; + + let newline = pending.indexOf(NEWLINE); + while (newline !== -1) { + const line = pending.subarray(0, newline).toString("utf8"); + pending = pending.subarray(newline + 1); + newline = pending.indexOf(NEWLINE); + + if (line.trim() === "") { + continue; + } + let entry: TapEntry; + try { + entry = JSON.parse(line) as TapEntry; + } catch (error) { + onMalformedLine(line, error); + continue; + } + yield entry; + } + } + + if (signal?.aborted) { + return; + } + await sleep(pollMs, signal); + } +} + +function sizeOf(path: string): number { + if (!existsSync(path)) { + return 0; + } + try { + return statSync(path).size; + } catch { + return 0; + } +} + +function readRange(path: string, offset: number, length: number): Buffer { + const buffer = Buffer.alloc(length); + const fd = openSync(path, "r"); + try { + const read = readSync(fd, buffer, 0, length, offset); + return buffer.subarray(0, read); + } finally { + closeSync(fd); + } +} + +function warnMalformedLine(line: string, error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + console.error(`skipping unparseable envelope-log line (${message}): ${line.slice(0, 120)}`); +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + function onAbort(): void { + clearTimeout(timer); + resolve(); + } + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} +``` + +- [ ] **Step 4: Run it, expect PASS** + +```bash +bun install +bun test packages/inspector +bun run typecheck +``` + +- [ ] **Step 5: Commit** + +``` +Add the Inspector package and tailEnvelopeLog (N50) + +Slice: #3 +Affordances: N50 +``` + +--- + +### Task 4: The envelope stream and the decision badge · slice #3 · U20, U21 + +**Files:** +- Create: `packages/inspector/src/render.ts` +- Test: `packages/inspector/test/render.test.ts` + +**Interfaces:** +- Consumes: `TapEntry` from `./tail-envelope-log.ts` (Task 3). +- Produces: `renderEntry(entry, options?): string` (U20) and `renderDecisionBadge(entry, options?): string | null` (U21), plus `type RenderOptions = { color?: boolean; indent?: number }`. Task 5's CLI calls `renderEntry`. + +**Requirements:** + +Both functions are pure — no `process`, no env reads, no clock. `color` defaults to `false` so tests assert plain strings; the CLI decides whether the terminal wants ANSI. + +`renderDecisionBadge` returns `null` for requests and for responses that carry no decision (a ServerHello is the live example). Its states: + +| Response shape | Badge | Colour | +|---|---|---| +| `error` present | `✖ ERROR ` + message | red | +| `result.decision === "deny"` | `● DENY` | red | +| `result.decision === "allow"` with non-empty `policy_references` | `◐ ALLOW (policy fired — ACS "warn")` | yellow | +| `result.decision === "allow"` | `○ ALLOW` | green | +| any other decision (`modify`, `ask`, `defer`) | `◆ ` | cyan | +| neither `result.decision` nor `error` | `null` | — | + +The fourth row against the third is the slice's own reason for U21: §V2 says a `warn` arrives as `allow` with a non-empty `policy_references`, "and the badge is what makes that legible rather than buried". A badge that renders both as `ALLOW` fails this slice. + +`reason_codes` and `policy_references` are appended when present, as `reason_codes=[a, b]` and `policy_references=[policy_id#rule_id]`. + +- [ ] **Step 1: Write the failing test** — `packages/inspector/test/render.test.ts` + +```ts +import { describe, expect, it } from "bun:test"; +import { renderDecisionBadge, renderEntry } from "../src/render.ts"; +import type { TapEntry } from "../src/tail-envelope-log.ts"; + +function entry(overrides: Partial): TapEntry { + return { + seq: 3, + recorded_at: "2026-08-09T12:04:31.221Z", + direction: "response", + method: "steps/toolCallRequest", + rpc_id: 1, + envelope: {}, + ...overrides, + }; +} + +function response(result: Record): TapEntry { + return entry({ envelope: { jsonrpc: "2.0", id: 1, result } }); +} + +describe("renderDecisionBadge (U21)", () => { + it("returns null for requests", () => { + expect(renderDecisionBadge(entry({ direction: "request", envelope: { jsonrpc: "2.0", id: 1 } }))).toBeNull(); + }); + + it("returns null for a response with no decision -- a ServerHello", () => { + expect(renderDecisionBadge(response({ negotiated_version: "0.1.0", on_decision_failure: "proceed" }))).toBeNull(); + }); + + it("badges a deny, with reason_codes and policy_references", () => { + const badge = renderDecisionBadge( + response({ + decision: "deny", + reason_codes: ["destructive_shell_command_blocked"], + policy_references: [{ policy_id: "agt_stock", rule_id: "destructive_shell_command_blocked" }], + }), + ); + + expect(badge).toBe( + "● DENY reason_codes=[destructive_shell_command_blocked] " + + "policy_references=[agt_stock#destructive_shell_command_blocked]", + ); + }); + + it("badges a plain allow", () => { + expect(renderDecisionBadge(response({ decision: "allow" }))).toBe("○ ALLOW"); + }); + + // The reason U21 exists, per the slices doc: an AGT `warn` arrives as an + // ACS `allow` with a non-empty policy_references, and the badge is what + // keeps it from being buried. + it("distinguishes an allow that carries policy_references -- ACS's encoding of warn", () => { + const badge = renderDecisionBadge( + response({ + decision: "allow", + reason_codes: ["drift_detected"], + policy_references: [{ policy_id: "agt_stock", rule_id: "drift_detected" }], + }), + ); + + expect(badge).toBe( + '◐ ALLOW (policy fired — ACS "warn") reason_codes=[drift_detected] ' + + "policy_references=[agt_stock#drift_detected]", + ); + expect(badge).not.toBe(renderDecisionBadge(response({ decision: "allow" }))); + }); + + it("badges modify, ask, and defer", () => { + expect(renderDecisionBadge(response({ decision: "modify" }))).toBe("◆ MODIFY"); + expect(renderDecisionBadge(response({ decision: "ask" }))).toBe("◆ ASK"); + expect(renderDecisionBadge(response({ decision: "defer" }))).toBe("◆ DEFER"); + }); + + it("badges a JSON-RPC error", () => { + const badge = renderDecisionBadge( + entry({ envelope: { jsonrpc: "2.0", id: 1, error: { code: -32010, message: "ACS envelope failed" } } }), + ); + + expect(badge).toBe("✖ ERROR -32010 ACS envelope failed"); + }); + + it("emits ANSI only when colour is asked for", () => { + const plain = renderDecisionBadge(response({ decision: "deny" }), { color: false }); + const coloured = renderDecisionBadge(response({ decision: "deny" }), { color: true }); + + expect(plain).toBe("● DENY"); + expect(coloured).toContain("\u001b["); + expect(coloured).toContain("DENY"); + }); +}); + +describe("renderEntry (U20)", () => { + it("renders a request as a header line plus pretty JSON, with no badge", () => { + const rendered = renderEntry( + entry({ + direction: "request", + seq: 1, + envelope: { jsonrpc: "2.0", method: "steps/toolCallRequest", id: 1 }, + }), + ); + + expect(rendered.split("\n")[0]).toBe("── #1 12:04:31.221 → REQUEST steps/toolCallRequest id=1"); + expect(rendered).toContain('"jsonrpc": "2.0"'); + expect(rendered).not.toContain("●"); + }); + + it("renders a response as a header line, a badge line, then pretty JSON", () => { + const rendered = renderEntry(response({ decision: "deny", reason_codes: ["blocked"] })); + const lines = rendered.split("\n"); + + expect(lines[0]).toBe("── #3 12:04:31.221 ← RESPONSE steps/toolCallRequest id=1"); + expect(lines[1]).toBe("● DENY reason_codes=[blocked]"); + expect(lines[2]).toBe("{"); + }); + + it("labels an unpaired response -- the malformed-body case -- without an id or a method", () => { + const rendered = renderEntry( + entry({ method: null, rpc_id: null, envelope: { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } } }), + ); + + expect(rendered.split("\n")[0]).toBe("── #3 12:04:31.221 ← RESPONSE (no method) (unpaired)"); + }); + + it("keeps the envelope verbatim -- pretty-printing only reshapes whitespace", () => { + const envelope = { jsonrpc: "2.0", id: 1, result: { decision: "allow", nested: { deep: [1, 2] } } }; + const rendered = renderEntry(entry({ envelope })); + const jsonStart = rendered.indexOf("{"); + + expect(JSON.parse(rendered.slice(jsonStart))).toEqual(envelope); + }); +}); +``` + +- [ ] **Step 2: Run it, expect FAIL** + +```bash +bun test packages/inspector/test/render.test.ts +``` + +- [ ] **Step 3: Minimal implementation** — `packages/inspector/src/render.ts` + +```ts +/** + * U20 (envelope stream) and U21 (decision badge). + * + * Both functions are pure: no clock, no env, no process. The CLI decides + * whether the terminal wants ANSI and passes `color`; tests assert exact + * plain strings. Nothing here knows what produced a decision -- the badge + * reads ACS's own `decision`, `reason_codes`, and `policy_references` + * fields and nothing else (global constraint 9). + */ +import type { TapEntry } from "./tail-envelope-log.ts"; + +export type RenderOptions = { color?: boolean; indent?: number }; + +const RESET = "\u001b[0m"; +const RED = "\u001b[31m"; +const GREEN = "\u001b[32m"; +const YELLOW = "\u001b[33m"; +const CYAN = "\u001b[36m"; +const DIM = "\u001b[2m"; + +type PolicyReference = { policy_id?: string; policy_version?: string; rule_id?: string }; +type DecisionResult = { + decision?: unknown; + reason_codes?: unknown; + policy_references?: unknown; +}; +type ResponseEnvelope = { result?: DecisionResult; error?: { code?: unknown; message?: unknown } }; + +function paint(text: string, color: string, enabled: boolean): string { + return enabled ? `${color}${text}${RESET}` : text; +} + +/** `2026-08-09T12:04:31.221Z` -> `12:04:31.221`. Sliced, not parsed: UTC and + * locale-independent, so rendered output is the same everywhere. */ +function clockOf(recordedAt: string): string { + const time = recordedAt.slice(11, 23); + return time.length === 12 ? time : recordedAt; +} + +function stringList(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +} + +function referenceList(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value + .filter((item): item is PolicyReference => typeof item === "object" && item !== null) + .map((ref) => (ref.rule_id ? `${ref.policy_id ?? "?"}#${ref.rule_id}` : `${ref.policy_id ?? "?"}`)); +} + +/** U21. Null when this entry carries no decision and no error: a request, or + * a response such as a ServerHello. */ +export function renderDecisionBadge(entry: TapEntry, options: RenderOptions = {}): string | null { + if (entry.direction !== "response") { + return null; + } + const color = options.color ?? false; + const envelope = (typeof entry.envelope === "object" && entry.envelope !== null ? entry.envelope : {}) as ResponseEnvelope; + + if (envelope.error) { + const code = typeof envelope.error.code === "number" ? envelope.error.code : "?"; + const message = typeof envelope.error.message === "string" ? envelope.error.message : ""; + return paint(`✖ ERROR ${code}${message ? ` ${message}` : ""}`, RED, color); + } + + const result = envelope.result; + if (!result || typeof result.decision !== "string") { + return null; + } + + const reasonCodes = stringList(result.reason_codes); + const references = referenceList(result.policy_references); + + let head: string; + if (result.decision === "deny") { + head = paint("● DENY", RED, color); + } else if (result.decision === "allow" && references.length > 0) { + // ACS has no `warn`; a policy that fired but let the action proceed + // arrives as `allow` with a non-empty policy_references. Rendering it + // identically to a clean allow is exactly what this badge exists to + // prevent (slices doc, §V2). + head = paint('◐ ALLOW (policy fired — ACS "warn")', YELLOW, color); + } else if (result.decision === "allow") { + head = paint("○ ALLOW", GREEN, color); + } else { + head = paint(`◆ ${result.decision.toUpperCase()}`, CYAN, color); + } + + const parts = [head]; + if (reasonCodes.length > 0) { + parts.push(`reason_codes=[${reasonCodes.join(", ")}]`); + } + if (references.length > 0) { + parts.push(`policy_references=[${references.join(", ")}]`); + } + return parts.join(" "); +} + +/** U20. Header line, optional badge line, then the envelope as pretty JSON -- + * the same bytes that crossed the wire, only re-indented. */ +export function renderEntry(entry: TapEntry, options: RenderOptions = {}): string { + const color = options.color ?? false; + const arrow = entry.direction === "request" ? "→ REQUEST " : "← RESPONSE"; + const method = entry.method ?? "(no method)"; + const id = entry.rpc_id === null ? "(unpaired)" : `id=${entry.rpc_id}`; + + const header = paint(`── #${entry.seq} ${clockOf(entry.recorded_at)} ${arrow} ${method} ${id}`, DIM, color); + const badge = renderDecisionBadge(entry, options); + const body = JSON.stringify(entry.envelope, null, options.indent ?? 2); + + return [header, ...(badge === null ? [] : [badge]), body].join("\n"); +} +``` + +- [ ] **Step 4: Run it, expect PASS** + +```bash +bun test packages/inspector +bun run typecheck +``` + +- [ ] **Step 5: Commit** + +``` +Render the envelope stream and the decision badge (U20, U21) + +Slice: #3 +Affordances: U20, U21 +``` + +--- + +### Task 5: The Inspector CLI and the tap↔tail contract test · slice #3 · U20, U21, N50, N26 + +**Files:** +- Create: `packages/inspector/src/main.ts` +- Create: `packages/inspector/src/index.ts` +- Modify: `package.json` (add the `inspector` script) +- Test: `test/envelope-tap-roundtrip.test.ts` + +**Interfaces:** +- Consumes: `tailEnvelopeLog` (Task 3), `renderEntry` (Task 4), `startGuardian` with `envelopeLogPath` (Task 2). +- Produces: `bun run inspector`. + +**Requirements:** + +The round-trip test is the point of this task, not the CLI. Constraint 10 has the Inspector declaring its own `TapEntry` instead of importing the Guardian's; that duplication is only safe while something fails when they drift. This test writes through the real Guardian tap and reads through the real Inspector tail, so a field renamed on either side breaks it. + +- [ ] **Step 1: Write the failing test** — `test/envelope-tap-roundtrip.test.ts` + +```ts +import { describe, expect, it } from "bun:test"; +import { mkdtempSync, rmdirSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { startGuardian } from "../packages/guardian/src/index.ts"; +import { tailEnvelopeLog, type TapEntry } from "../packages/inspector/src/tail-envelope-log.ts"; +import { renderDecisionBadge } from "../packages/inspector/src/render.ts"; + +/** + * The contract test for S6. The Guardian writes the log; the Inspector + * declares its own TapEntry and reads it back (global constraint 10). If + * either side renames a field, adds a required one, or changes a type, this + * is what fails -- nothing else would, because the two never share a type. + */ +function toolCallEnvelope(command: string, id: number): Record { + return { + jsonrpc: "2.0", + method: "steps/toolCallRequest", + id, + params: { + acs_version: "0.1.0", + request_id: crypto.randomUUID(), + timestamp: new Date().toISOString(), + metadata: { agent_id: "agent-1", session_id: crypto.randomUUID() }, + payload: { tool: { name: "run_shell" }, arguments: { command: { value: command } } }, + }, + }; +} + +async function take( + tail: AsyncGenerator, + count: number, + controller: AbortController, +): Promise { + const out: TapEntry[] = []; + const deadline = setTimeout(() => controller.abort(), 5000); + try { + for await (const entry of tail) { + out.push(entry); + if (out.length >= count) { + break; + } + } + } finally { + clearTimeout(deadline); + controller.abort(); + } + return out; +} + +describe("S6 round trip: Guardian tap (N26) -> Inspector tail (N50) -> badge (U21)", () => { + it("a denied tool call arrives as a paired request/response the Inspector can render", async () => { + const dir = mkdtempSync(join(tmpdir(), "acs-roundtrip-")); + const logPath = join(dir, "envelopes.jsonl"); + const guardian = await startGuardian({ + port: 0, + manifestPath: "policy/manifest.yaml", + envelopeLogPath: logPath, + }); + const controller = new AbortController(); + + try { + await fetch(guardian.url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(toolCallEnvelope("rm -rf /", 77)), + }); + + const tail = tailEnvelopeLog({ path: logPath, fromStart: true, pollMs: 10, signal: controller.signal }); + const [request, response] = await take(tail, 2, controller); + + // Every field the Inspector's TapEntry declares must actually be + // present and correctly typed on what the Guardian wrote. + expect(request?.seq).toBe(1); + expect(response?.seq).toBe(2); + expect(typeof request?.recorded_at).toBe("string"); + expect(request?.direction).toBe("request"); + expect(response?.direction).toBe("response"); + expect(request?.method).toBe("steps/toolCallRequest"); + expect(request?.rpc_id).toBe(77); + expect(response?.rpc_id).toBe(77); + + // ...and the badge reads a real AGT-backed decision off it. + expect(renderDecisionBadge(response as TapEntry)).toContain("DENY"); + } finally { + controller.abort(); + await guardian.close(); + unlinkSync(logPath); + rmdirSync(dir); + } + }); +}); +``` + +- [ ] **Step 2: Run it, expect FAIL** + +```bash +bun test test/envelope-tap-roundtrip.test.ts +``` + +- [ ] **Step 3: Minimal implementation** + +`packages/inspector/src/index.ts`: + +```ts +/** Public surface of the inspector package. */ +export { tailEnvelopeLog, type TailOptions, type TapDirection, type TapEntry } from "./tail-envelope-log.ts"; +export { renderDecisionBadge, renderEntry, type RenderOptions } from "./render.ts"; +``` + +`packages/inspector/src/main.ts`: + +```ts +/** + * The Envelope Inspector's entrypoint -- `bun run inspector`. + * + * A third terminal beside `bun run guardian` and `claude`: it tails S6 and + * prints each ACS envelope as it crosses the wire. Not re-exported from + * ./index.ts -- this is a process entrypoint, not a library call. + * + * `ACS_ENVELOPE_LOG` defaults to `.acs/envelopes.jsonl`, the same default + * packages/guardian/src/main.ts writes to, so the two agree without either + * hardcoding the other's value. + */ +import { tailEnvelopeLog } from "./tail-envelope-log.ts"; +import { renderEntry } from "./render.ts"; + +const DEFAULT_ENVELOPE_LOG = ".acs/envelopes.jsonl"; + +const argv = process.argv.slice(2); +const fromStart = argv.includes("--from-start"); +const pathFlag = argv.indexOf("--path"); +const flagValue = pathFlag === -1 ? undefined : argv[pathFlag + 1]; + +if (pathFlag !== -1 && (flagValue === undefined || flagValue.startsWith("--"))) { + console.error("usage: bun run inspector -- [--from-start] [--path ]"); + process.exit(2); +} + +const path = flagValue ?? process.env.ACS_ENVELOPE_LOG ?? DEFAULT_ENVELOPE_LOG; +const color = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR; + +const controller = new AbortController(); +process.on("SIGINT", () => controller.abort()); + +console.log(`Envelope Inspector — tailing ${path}${fromStart ? " (from the start)" : ""}`); +console.log("Ctrl-C to stop.\n"); + +for await (const entry of tailEnvelopeLog({ path, fromStart, signal: controller.signal })) { + console.log(renderEntry(entry, { color })); + console.log(""); +} +``` + +Root `package.json` — add to `scripts`: + +```json +"inspector": "bun run packages/inspector/src/main.ts" +``` + +- [ ] **Step 4: Run it, expect PASS** + +```bash +bun test +bun run typecheck +``` + +Then confirm the CLI itself runs, against the tree's own log path: + +```bash +ACS_ENVELOPE_LOG=/dev/null timeout 2 bun run inspector || true +``` + +Expected: it prints the two header lines and waits — no crash, no stack trace. + +- [ ] **Step 5: Commit** + +``` +Add the Inspector CLI and the S6 round-trip contract test (U20, U21, N50) + +Slice: #3 +Affordances: U20, U21, N50, N26 +``` + +--- + +### Task 6: The R5.2 gate, the runbook, and a live run · slice #3 · R5.1, R5.2, R7.1 + +**Files:** +- Modify: `test/invariants.test.ts` (append two `it` blocks inside the existing `describe`) +- Create: `docs/demos/v2-runbook.md` +- Modify: `slices/v2/README.md` +- Modify: `README.md` (Quickstart, What this proves, Status, Verify) + +**Interfaces:** +- Consumes: `readSourceFiles` and `assertNoVocabulary`, already module-scoped in `test/invariants.test.ts`. + +**Requirements:** + +R5.2 — "an ACS-first reader can trace one action end to end without reading AGT source" — becomes a property of the code: the tool that renders the trace names neither AGT nor any host, and imports neither. V1 established that this project turns its architectural claims into grep gates rather than prose, and the same reasoning applies here. + +Then **run the thing**, all three processes, and write the runbook from what actually appeared — not from what this plan predicts. If the output differs from what is written here, the output is right and the runbook says what happened. + +- [ ] **Step 1: Write the failing test** — append inside `describe("architectural invariants", ...)` in `test/invariants.test.ts` + +```ts + /** + * R5.2 -- "an ACS-first reader can trace one action end to end without + * reading AGT source". The Inspector is that reader's tool, so the claim + * is only real if the tool itself knows nothing about AGT and nothing + * about any particular host: it renders ACS envelopes as data. Both term + * lists from the two gates above apply to it at once. + */ + it("the Envelope Inspector's source contains zero AGT vocabulary and zero host vocabulary", () => { + assertNoVocabulary("packages/inspector/src", [ + "agt", + "AgentControl", + "rego", + "opa", + "intervention_point", + "verdict", + "claude", + "opencode", + "hookSpecificOutput", + "permissionDecision", + "stdin", + ]); + }); + + /** + * R5.1 -- envelopes are inspectable *on the wire*. If the Inspector + * imported the Guardian's types, "inspectable" would be a claim about our + * own type graph instead: any third-party reader of S6 has only the file. + * So does this one. + */ + it("the Envelope Inspector imports nothing from the Guardian or the AGT bridge", () => { + for (const { file, code } of readSourceFiles("packages/inspector/src")) { + for (const spec of ["guardian", "agt-bridge"]) { + const found = new RegExp(`from\\s+["'][^"']*${spec}[^"']*["']`).test(code); + expect({ file, spec, found }).toEqual({ file, spec, found: false }); + } + } + }); +``` + +- [ ] **Step 2: Run it, expect PASS immediately, then prove the gate bites** + +```bash +bun test test/invariants.test.ts +``` + +A gate that has never failed is not known to work. Temporarily add `import { NULL_TAP } from "guardian";` to `packages/inspector/src/render.ts`, re-run, and confirm **both** new tests fail. Then revert it and confirm they pass again. Record both outcomes in the task report. + +- [ ] **Step 3: Run the demo live, then write it down** + +Three terminals, from a clean tree: + +```bash +# 1 +bun run guardian +# 2 +bun run inspector +# 3 +echo '{"session_id":"demo","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \ + | bun run hosts/claude-code/acs-hook.ts +echo '{"session_id":"demo","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls -la"}}' \ + | bun run hosts/claude-code/acs-hook.ts +``` + +Then the same through a real `claude` session, with `hosts/claude-code/settings.json` copied into `.claude/settings.json` as V1's quickstart describes. + +Write `docs/demos/v2-runbook.md` from the captured output. It must contain: +- the three-terminal setup, in order, and why the Guardian starts first (it creates `.acs/`); +- **real** pasted Inspector output for the deny and for the allow — the actual bytes, not a reconstruction; +- the note that `.acs/envelopes.jsonl` carries raw tool arguments verbatim and is gitignored for that reason; +- `: > .acs/envelopes.jsonl` as the way to clear the log mid-demo, and that the Inspector picks up cleanly afterwards because `tailEnvelopeLog` resets on truncation; +- `bun run inspector -- --from-start` to replay a session already recorded; +- the honest boundary: a schema-invalid envelope shows as a JSON-RPC **error**, not a `deny` decision. N27 — the affordance that turns Guardian-side failures into decisions — is V3. Say so, and say where. + +Update `slices/v2/README.md` to V1's shape: demo sentence, master-doc link, affordances, "What this slice delivers", and what is explicitly *not* in it (U22 session chain → V6; U23 posture badge and N51 → V3). + +Update `README.md`: +- **Quickstart**: a third step for `bun run inspector`, and the Guardian's new `Envelope log (S6)` line in its startup output. +- **What this proves → Delivered**: a row for R5.1 — every hook firing is inspectable as an ACS envelope, in both directions, including envelopes that fail validation — pointing at `test/envelope-tap-roundtrip.test.ts` and `packages/guardian/test/envelope-tap-wiring.test.ts`. +- **Verify**: the test count, re-read from a real `bun test` run. Do not carry V1's number forward and do not estimate it. +- **Status**: V2 implemented; V3–V8 shaped but not started. + +- [ ] **Step 4: Run it, expect PASS** + +```bash +bun test +bun run typecheck +``` + +- [ ] **Step 5: Commit** + +``` +Gate the Inspector on R5.1/R5.2, add the V2 runbook and quickstart + +Slice: #3 +Affordances: U20, U21, N26, N50, S6 +``` + +--- + +## Risks + +| # | Risk | Handling | +|---|---|---| +| 1 | A partially-written line is read mid-append and mis-parsed | `tailEnvelopeLog` buffers **bytes** and decodes only up to a newline, so a split line — or a split UTF-8 codepoint — is held until it completes. Task 3 tests it directly. | +| 2 | The tap becomes a fourth fail-open | Global constraint 8, `createEnvelopeTap`'s total-by-construction design, and Task 2's end-to-end test asserting `rm -rf /` is still denied when every tap write fails. | +| 3 | S6 grows without bound | Accepted for V2 and recorded in the slices doc. It is a gitignored local demo artifact; `: > .acs/envelopes.jsonl` truncates it safely mid-run because the tail resets on truncation. Rotation is not built. | +| 4 | The Inspector's duplicated `TapEntry` silently drifts from the Guardian's | Task 5's round-trip test exercises both real implementations against one file. | +| 5 | S6 contains sensitive tool arguments | Constraint 11 says record the wire verbatim — a redacting tap would make the Inspector lie. Handled by gitignoring `.acs/` and stating it in the runbook and the README, not by filtering. | From ce838adead66b714636ffa12ffcd82f6f03da68b Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 22:12:37 +0300 Subject: [PATCH 03/35] Add writeEnvelopeTap and S6's JSONL entry format (N26, S6) Slice: #3 Affordances: N26, S6 --- packages/guardian/src/envelope-tap.ts | 112 ++++++++++++++++ packages/guardian/test/envelope-tap.test.ts | 141 ++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 packages/guardian/src/envelope-tap.ts create mode 100644 packages/guardian/test/envelope-tap.test.ts diff --git a/packages/guardian/src/envelope-tap.ts b/packages/guardian/src/envelope-tap.ts new file mode 100644 index 00000000..788c3ae7 --- /dev/null +++ b/packages/guardian/src/envelope-tap.ts @@ -0,0 +1,112 @@ +/** + * writeEnvelopeTap (N26) writes S6: a JSONL record of every ACS envelope + * that crosses this Guardian's wire, in both directions, exactly as it + * crossed (global constraint 11). The Envelope Inspector (P4) reads this + * file and nothing else -- see packages/inspector, which deliberately + * imports nothing from here. + * + * Total by construction (global constraint 8). Every write is wrapped: a + * failure disables the tap for the process lifetime, reports once, and is + * never propagated to the caller. The tap sits on the decision path, and + * V1 shipped three separate fail-opens before they were caught -- an + * observability feature that can turn a governed tool call into an + * ungoverned one would be the fourth. Observability degrades; governance + * does not. + */ +import { appendFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +export type TapDirection = "request" | "response"; + +/** + * One line of S6. `envelope` is the JSON-RPC object verbatim -- request or + * response -- and every other field is Guardian-side context the wire does + * not carry: a sequence number so a reader can detect gaps, a timestamp, the + * direction, the ACS method (JSON-RPC responses carry none, so the Guardian + * supplies the one it dispatched), and the JSON-RPC id that pairs the two + * directions. + */ +export type TapEntry = { + seq: number; + recorded_at: string; + direction: TapDirection; + method: string | null; + rpc_id: string | number | null; + envelope: unknown; +}; + +export type EnvelopeTap = { + write(direction: TapDirection, envelope: unknown, method: string | null): void; + readonly path: string | null; +}; + +export type CreateEnvelopeTapOptions = { + path: string; + /** Injectable clock, so tests can assert recorded_at exactly. */ + now?: () => Date; + /** Called at most once, on the first failure. Defaults to one stderr line. */ + onError?: (error: unknown) => void; +}; + +/** The tap a Guardian gets when no envelopeLogPath was configured (P3). */ +export const NULL_TAP: EnvelopeTap = { + path: null, + write(): void {}, +}; + +/** The JSON-RPC id, when it is a scalar. Both request and response envelopes + * carry `id` at the top level, so one extractor serves both directions. */ +export function extractRpcId(envelope: unknown): string | number | null { + if (typeof envelope === "object" && envelope !== null && "id" in envelope) { + const id = (envelope as { id: unknown }).id; + if (typeof id === "string" || typeof id === "number") { + return id; + } + } + return null; +} + +export function createEnvelopeTap({ path, now = () => new Date(), onError }: CreateEnvelopeTapOptions): EnvelopeTap { + let seq = 0; + let disabled = false; + + const fail = (error: unknown): void => { + disabled = true; + if (onError) { + onError(error); + return; + } + const message = error instanceof Error ? error.message : String(error); + console.error(`envelope tap disabled after failure (${path}): ${message}`); + }; + + try { + mkdirSync(dirname(path), { recursive: true }); + } catch (error) { + fail(error); + } + + return { + path, + write(direction, envelope, method): void { + if (disabled) { + return; + } + try { + const entry: TapEntry = { + seq: seq + 1, + recorded_at: now().toISOString(), + direction, + method, + rpc_id: extractRpcId(envelope), + envelope, + }; + const line = `${JSON.stringify(entry)}\n`; + appendFileSync(path, line); + seq += 1; + } catch (error) { + fail(error); + } + }, + }; +} diff --git a/packages/guardian/test/envelope-tap.test.ts b/packages/guardian/test/envelope-tap.test.ts new file mode 100644 index 00000000..c06d288c --- /dev/null +++ b/packages/guardian/test/envelope-tap.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "bun:test"; +import { mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createEnvelopeTap, extractRpcId, NULL_TAP, type TapEntry } from "../src/envelope-tap.ts"; + +/** A temp directory per test. Cleanup is deliberately non-recursive -- + * unlink the one file we created, then rmdir -- so a stray file makes the + * test fail loudly instead of being silently blown away. */ +function withTempDir(run: (dir: string) => void): void { + const dir = mkdtempSync(join(tmpdir(), "acs-tap-")); + try { + run(dir); + } finally { + try { + unlinkSync(join(dir, "envelopes.jsonl")); + } catch { + // the test may not have produced a log at all -- that is the point of some of them + } + rmdirSync(dir); + } +} + +function readEntries(path: string): TapEntry[] { + return readFileSync(path, "utf8") + .split("\n") + .filter((line) => line.trim() !== "") + .map((line) => JSON.parse(line) as TapEntry); +} + +const REQUEST = { jsonrpc: "2.0", method: "steps/toolCallRequest", id: 7, params: { acs_version: "0.1.0" } }; +const RESPONSE = { jsonrpc: "2.0", id: 7, result: { decision: "deny" } }; + +describe("createEnvelopeTap (N26) -- S6's JSONL format", () => { + it("writes one line per call, with a monotonic seq starting at 1", () => { + withTempDir((dir) => { + const path = join(dir, "envelopes.jsonl"); + const tap = createEnvelopeTap({ path }); + + tap.write("request", REQUEST, "steps/toolCallRequest"); + tap.write("response", RESPONSE, "steps/toolCallRequest"); + + const entries = readEntries(path); + expect(entries.map((e) => e.seq)).toEqual([1, 2]); + expect(entries.map((e) => e.direction)).toEqual(["request", "response"]); + }); + }); + + it("records the envelope verbatim -- constraint 11, no reformatting or stripping", () => { + withTempDir((dir) => { + const path = join(dir, "envelopes.jsonl"); + createEnvelopeTap({ path }).write("request", REQUEST, "steps/toolCallRequest"); + + expect(readEntries(path)[0]?.envelope).toEqual(REQUEST); + }); + }); + + it("carries rpc_id from both directions, so the Inspector can pair them (P4)", () => { + withTempDir((dir) => { + const path = join(dir, "envelopes.jsonl"); + const tap = createEnvelopeTap({ path }); + + tap.write("request", REQUEST, "steps/toolCallRequest"); + tap.write("response", RESPONSE, "steps/toolCallRequest"); + + expect(readEntries(path).map((e) => e.rpc_id)).toEqual([7, 7]); + }); + }); + + it("stamps recorded_at from the injected clock", () => { + withTempDir((dir) => { + const path = join(dir, "envelopes.jsonl"); + const tap = createEnvelopeTap({ path, now: () => new Date("2026-08-09T12:04:31.221Z") }); + + tap.write("request", REQUEST, "steps/toolCallRequest"); + + expect(readEntries(path)[0]?.recorded_at).toBe("2026-08-09T12:04:31.221Z"); + }); + }); + + it("records method as null when the caller cannot determine one", () => { + withTempDir((dir) => { + const path = join(dir, "envelopes.jsonl"); + createEnvelopeTap({ path }).write("response", { jsonrpc: "2.0", id: null, error: { code: -32700 } }, null); + + const entry = readEntries(path)[0]; + expect(entry?.method).toBeNull(); + expect(entry?.rpc_id).toBeNull(); + }); + }); + + // Global constraint 8. This is the whole reason the tap is a module and + // not three inline appendFileSync calls. + it("never throws when the log path is unwritable, reports once, and goes quiet", () => { + withTempDir((dir) => { + const blocker = join(dir, "envelopes.jsonl"); + writeFileSync(blocker, ""); + // A path *through* a regular file: mkdirSync and appendFileSync both + // fail with ENOTDIR, deterministically, on every platform. + const path = join(blocker, "nested", "envelopes.jsonl"); + const errors: unknown[] = []; + const tap = createEnvelopeTap({ path, onError: (error) => errors.push(error) }); + + expect(() => tap.write("request", REQUEST, "steps/toolCallRequest")).not.toThrow(); + expect(() => tap.write("response", RESPONSE, "steps/toolCallRequest")).not.toThrow(); + expect(errors.length).toBe(1); + }); + }); + + it("never throws on an envelope JSON.stringify cannot serialize", () => { + withTempDir((dir) => { + const path = join(dir, "envelopes.jsonl"); + const errors: unknown[] = []; + const tap = createEnvelopeTap({ path, onError: (error) => errors.push(error) }); + const circular: Record = { id: 1 }; + circular.self = circular; + + expect(() => tap.write("request", circular, "steps/toolCallRequest")).not.toThrow(); + expect(errors.length).toBe(1); + }); + }); + + it("NULL_TAP writes nothing and never throws", () => { + expect(() => NULL_TAP.write("request", REQUEST, "steps/toolCallRequest")).not.toThrow(); + expect(NULL_TAP.path).toBeNull(); + }); +}); + +describe("extractRpcId", () => { + it("reads string and number ids", () => { + expect(extractRpcId({ id: 7 })).toBe(7); + expect(extractRpcId({ id: "abc" })).toBe("abc"); + }); + + it("returns null for a missing, null, or non-scalar id", () => { + expect(extractRpcId({})).toBeNull(); + expect(extractRpcId({ id: null })).toBeNull(); + expect(extractRpcId({ id: { nested: true } })).toBeNull(); + expect(extractRpcId("not an object")).toBeNull(); + }); +}); From dc7e7681fec7163a3f2fb47e382134d0ebcde1f3 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 22:18:33 +0300 Subject: [PATCH 04/35] Fix: guard onError callback to maintain total-by-construction guarantee Wrap onError and console.error in try/catch inside fail() so a throwing callback cannot propagate an exception out of createEnvelopeTap() or write(). Add two covering tests: throwing onError at construction time and at write time. Slice: #3 --- packages/guardian/src/envelope-tap.ts | 15 ++++++--- packages/guardian/test/envelope-tap.test.ts | 36 +++++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/guardian/src/envelope-tap.ts b/packages/guardian/src/envelope-tap.ts index 788c3ae7..36d9ce38 100644 --- a/packages/guardian/src/envelope-tap.ts +++ b/packages/guardian/src/envelope-tap.ts @@ -72,12 +72,17 @@ export function createEnvelopeTap({ path, now = () => new Date(), onError }: Cre const fail = (error: unknown): void => { disabled = true; - if (onError) { - onError(error); - return; + try { + if (onError) { + onError(error); + return; + } + const message = error instanceof Error ? error.message : String(error); + console.error(`envelope tap disabled after failure (${path}): ${message}`); + } catch { + // Silently swallow any error from the callback or console.error + // to maintain the total-by-construction guarantee } - const message = error instanceof Error ? error.message : String(error); - console.error(`envelope tap disabled after failure (${path}): ${message}`); }; try { diff --git a/packages/guardian/test/envelope-tap.test.ts b/packages/guardian/test/envelope-tap.test.ts index c06d288c..0a28238a 100644 --- a/packages/guardian/test/envelope-tap.test.ts +++ b/packages/guardian/test/envelope-tap.test.ts @@ -120,6 +120,42 @@ describe("createEnvelopeTap (N26) -- S6's JSONL format", () => { }); }); + it("never throws when onError itself throws at construction time", () => { + withTempDir((dir) => { + const blocker = join(dir, "envelopes.jsonl"); + writeFileSync(blocker, ""); + const path = join(blocker, "nested", "envelopes.jsonl"); + + expect(() => { + createEnvelopeTap({ + path, + onError: () => { + throw new Error("onError threw"); + }, + }); + }).not.toThrow(); + }); + }); + + it("never throws when onError itself throws at write time, and disables the tap", () => { + withTempDir((dir) => { + const path = join(dir, "envelopes.jsonl"); + const blocker = join(dir, "envelopes.jsonl"); + writeFileSync(blocker, ""); + + const tap = createEnvelopeTap({ + path: join(blocker, "nested", "envelopes.jsonl"), + onError: () => { + throw new Error("onError threw"); + }, + }); + + expect(() => tap.write("request", REQUEST, "steps/toolCallRequest")).not.toThrow(); + // Tap should be disabled, so second write is a silent no-op + expect(() => tap.write("response", RESPONSE, "steps/toolCallRequest")).not.toThrow(); + }); + }); + it("NULL_TAP writes nothing and never throws", () => { expect(() => NULL_TAP.write("request", REQUEST, "steps/toolCallRequest")).not.toThrow(); expect(NULL_TAP.path).toBeNull(); From 1bab5f845614582560349c5d5be6b439e8ce2327 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 22:23:49 +0300 Subject: [PATCH 05/35] Tap every ACS envelope crossing the Guardian's wire (N26, N20, S6) Slice: #3 Affordances: N26, N20, S6 --- .gitignore | 4 + packages/guardian/src/index.ts | 9 + packages/guardian/src/main.ts | 5 +- packages/guardian/src/server.ts | 55 +++++- .../guardian/test/envelope-tap-wiring.test.ts | 176 ++++++++++++++++++ 5 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 packages/guardian/test/envelope-tap-wiring.test.ts diff --git a/.gitignore b/.gitignore index a62f7b72..be21f7df 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ __pycache__/ # Subagent-driven-development scratch workspace (ledger, briefs, review packages) .superpowers/ + +# S6, the ACS envelope log (N26). Local demo artifact; carries raw tool +# arguments verbatim, so it is never committed. +.acs/ diff --git a/packages/guardian/src/index.ts b/packages/guardian/src/index.ts index cd4d2de2..3c512bb4 100644 --- a/packages/guardian/src/index.ts +++ b/packages/guardian/src/index.ts @@ -10,3 +10,12 @@ export { } from "./validate-envelope.ts"; export { assemblePreToolCallSnapshot, type AgtPreToolCallSnapshot } from "./assemble-snapshot.ts"; export { loadMapping, mapVerdict, type Mapping, type AcsDecision } from "./map-verdict.ts"; +export { + createEnvelopeTap, + extractRpcId, + NULL_TAP, + type CreateEnvelopeTapOptions, + type EnvelopeTap, + type TapDirection, + type TapEntry, +} from "./envelope-tap.ts"; diff --git a/packages/guardian/src/main.ts b/packages/guardian/src/main.ts index f46e6b32..6d26476e 100644 --- a/packages/guardian/src/main.ts +++ b/packages/guardian/src/main.ts @@ -22,10 +22,13 @@ import { startGuardian } from "./server.ts"; const DEFAULT_PORT = 8787; const DEFAULT_MANIFEST_PATH = "policy/manifest.yaml"; +const DEFAULT_ENVELOPE_LOG = ".acs/envelopes.jsonl"; 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 envelopeLogPath = process.env.ACS_ENVELOPE_LOG ?? DEFAULT_ENVELOPE_LOG; -const guardian = await startGuardian({ port, hostname, manifestPath }); +const guardian = await startGuardian({ port, hostname, manifestPath, envelopeLogPath }); console.log(`Guardian listening at ${guardian.url}`); +console.log(`Envelope log (S6): ${envelopeLogPath}`); diff --git a/packages/guardian/src/server.ts b/packages/guardian/src/server.ts index 1a9fae27..2ecbd695 100644 --- a/packages/guardian/src/server.ts +++ b/packages/guardian/src/server.ts @@ -48,6 +48,7 @@ import { type AcsRequestEnvelope, } from "./validate-envelope.ts"; import { buildServerHello, type ServerHello } from "./handshake.ts"; +import { createEnvelopeTap, NULL_TAP, type EnvelopeTap } from "./envelope-tap.ts"; /** * Every snapshot message this Guardian can send an intervention point. One @@ -113,6 +114,11 @@ export type StartGuardianOptions = { * handleAcsRequest against a real bridge, without touching the mapping * every other consumer reads. Not meant for production use. */ mappingPath?: string; + /** Path to S6, the JSONL envelope log (N26). Omitted means no tap: every + * V1 test constructs Guardians freely and a default-on tap would scatter + * files through the working tree. `packages/guardian/src/main.ts` -- the + * demo path -- passes it. See the plan's decision P3. */ + envelopeLogPath?: string; }; export type StartedGuardian = { url: string; close(): Promise }; @@ -121,10 +127,12 @@ export async function startGuardian({ hostname, manifestPath, mappingPath, + envelopeLogPath, }: StartGuardianOptions): Promise { // Construct the bridge once at boot, not per request. const bridge = createBridge(manifestPath); const mapping = loadMapping(mappingPath ?? MAPPING_PATH); + const tap = envelopeLogPath ? createEnvelopeTap({ path: envelopeLogPath }) : NULL_TAP; const server = Bun.serve({ hostname: hostname ?? LOOPBACK_ONLY, @@ -134,7 +142,7 @@ export async function startGuardian({ if (req.method !== "POST" || pathname !== ACS_PATH) { return new Response("Not Found", { status: 404 }); } - const response = await handleAcsRequest(req, bridge, mapping); + const response = await handleAcsRequest(req, bridge, mapping, tap); return Response.json(response); }, }); @@ -147,6 +155,16 @@ export async function startGuardian({ }; } +/** + * Three phases, in order: parse, tap the request, dispatch, tap the + * response. The tap calls live here and only here -- `dispatch` below has + * four return sites and V3 adds a fifth (N27), so tapping inside it would + * make totality something a future task has to remember rather than + * something the structure guarantees. + * + * The tap itself is total (see envelope-tap.ts): these two calls cannot + * throw, so they cannot turn a governed tool call into an ungoverned one. + */ async function handleAcsRequest( req: Request, // The role, not `ReturnType`: this handler depends on @@ -154,14 +172,35 @@ async function handleAcsRequest( // factory happens to return. bridge: PolicyBridge, mapping: Mapping, + tap: EnvelopeTap, ): Promise { let raw: unknown; try { raw = await req.json(); } catch { - return errorResponse(null, -32700, "Parse error"); + // Nothing parseable arrived, so there is no request envelope to tap -- + // the response is deliberately recorded unpaired, which is what the + // Inspector renders when a host sends a malformed body. + const parseError = errorResponse(null, -32700, "Parse error"); + tap.write("response", parseError, null); + return parseError; } + // Decision P5: before validation, so an envelope that fails the schema is + // visible to the Inspector rather than invisible. + const method = extractMethod(raw); + tap.write("request", raw, method); + + const response = await dispatch(raw, bridge, mapping); + tap.write("response", response, method); + return response; +} + +async function dispatch( + raw: unknown, + bridge: ReturnType, + mapping: Mapping, +): Promise { const rpcId = extractId(raw); let envelope: AcsRequestEnvelope; @@ -247,3 +286,15 @@ function extractId(raw: unknown): string | number | null { } return null; } + +/** Best-effort method name for tap labelling only. Never used to dispatch -- + * `dispatch` reads the schema-validated envelope's own `method`. */ +function extractMethod(raw: unknown): string | null { + if (typeof raw === "object" && raw !== null && "method" in raw) { + const method = (raw as { method: unknown }).method; + if (typeof method === "string") { + return method; + } + } + return null; +} diff --git a/packages/guardian/test/envelope-tap-wiring.test.ts b/packages/guardian/test/envelope-tap-wiring.test.ts new file mode 100644 index 00000000..6678a3df --- /dev/null +++ b/packages/guardian/test/envelope-tap-wiring.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { startGuardian } from "../src/index.ts"; +import type { TapEntry } from "../src/envelope-tap.ts"; + +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 } = {}) { + return makeEnvelope( + "steps/toolCallRequest", + { tool: { name: "run_shell" }, arguments: { command: { value: command } } }, + overrides, + ); +} + +function readEntries(path: string): TapEntry[] { + if (!existsSync(path)) { + return []; + } + return readFileSync(path, "utf8") + .split("\n") + .filter((line) => line.trim() !== "") + .map((line) => JSON.parse(line) as TapEntry); +} + +/** Non-recursive cleanup, as in envelope-tap.test.ts. */ +async function withGuardian( + logPathFor: (dir: string) => string, + run: (url: string, logPath: string) => Promise, +): Promise { + const dir = mkdtempSync(join(tmpdir(), "acs-tap-wiring-")); + const logPath = logPathFor(dir); + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", envelopeLogPath: logPath }); + try { + await run(guardian.url, logPath); + } finally { + await guardian.close(); + try { + unlinkSync(logPath); + } catch { + // some tests deliberately make the path unwritable + } + try { + rmdirSync(dir); + } catch { + // a blocker file may remain; the assertions already covered what matters + } + } +} + +async function postRaw(url: string, body: string): Promise { + const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body }); + return await res.json(); +} + +const logIn = (dir: string) => join(dir, "envelopes.jsonl"); + +describe("Guardian envelope tap wiring (N26 x N20)", () => { + it("taps one request and one response per exchange, paired by rpc_id", async () => { + await withGuardian(logIn, async (url, logPath) => { + await postRaw(url, JSON.stringify(toolCallEnvelope("rm -rf /", { id: 11 }))); + + const entries = readEntries(logPath); + expect(entries.length).toBe(2); + expect(entries[0]?.direction).toBe("request"); + expect(entries[1]?.direction).toBe("response"); + expect(entries[0]?.rpc_id).toBe(11); + expect(entries[1]?.rpc_id).toBe(11); + expect(entries[0]?.method).toBe("steps/toolCallRequest"); + expect(entries[1]?.method).toBe("steps/toolCallRequest"); + expect((entries[1]?.envelope as { result?: { decision?: string } }).result?.decision).toBe("deny"); + }); + }); + + it("taps handshake/hello in both directions", async () => { + await withGuardian(logIn, async (url, logPath) => { + await postRaw(url, JSON.stringify(makeEnvelope("handshake/hello", {}, { id: 42 }))); + + const entries = readEntries(logPath); + expect(entries.map((e) => e.direction)).toEqual(["request", "response"]); + expect(entries.every((e) => e.method === "handshake/hello")).toBe(true); + }); + }); + + // Decision P5. The envelope that fails validation is the most useful + // thing an ACS-first reader can see; tapping after the validator is + // exactly what would hide it. + it("taps a schema-invalid request, then its JSON-RPC error response", async () => { + await withGuardian(logIn, async (url, logPath) => { + const bad = toolCallEnvelope("rm -rf /", { id: 12 }); + delete (bad.params as Record).acs_version; + + await postRaw(url, JSON.stringify(bad)); + + const entries = readEntries(logPath); + expect(entries.length).toBe(2); + expect(entries[0]?.direction).toBe("request"); + expect((entries[0]?.envelope as { params: Record }).params.acs_version).toBeUndefined(); + const error = (entries[1]?.envelope as { error?: { code: number } }).error; + expect(error?.code).toBeLessThanOrEqual(-32000); + expect(error?.code).toBeGreaterThanOrEqual(-32099); + }); + }); + + it("taps an unparseable body as a lone response with rpc_id null -- no request line to pair with", async () => { + await withGuardian(logIn, async (url, logPath) => { + await postRaw(url, "{not json"); + + const entries = readEntries(logPath); + expect(entries.length).toBe(1); + expect(entries[0]?.direction).toBe("response"); + expect(entries[0]?.rpc_id).toBeNull(); + expect(entries[0]?.method).toBeNull(); + expect((entries[0]?.envelope as { error?: { code: number } }).error?.code).toBe(-32700); + }); + }); + + // Global constraint 8, end to end: the tap is on the decision path, so + // this is the test that says a broken tap cannot become a fail-open. + it("still denies rm -rf / when every tap write fails", async () => { + const dir = mkdtempSync(join(tmpdir(), "acs-tap-broken-")); + const blocker = join(dir, "blocker"); + writeFileSync(blocker, ""); + const guardian = await startGuardian({ + port: 0, + manifestPath: "policy/manifest.yaml", + envelopeLogPath: join(blocker, "nested", "envelopes.jsonl"), + }); + try { + const response = (await postRaw(guardian.url, JSON.stringify(toolCallEnvelope("rm -rf /")))) as { + result?: { decision?: string }; + error?: unknown; + }; + expect(response.error).toBeUndefined(); + expect(response.result?.decision).toBe("deny"); + } finally { + await guardian.close(); + unlinkSync(blocker); + rmdirSync(dir); + } + }); + + // Decision P3. + it("writes nothing when envelopeLogPath is omitted", async () => { + const dir = mkdtempSync(join(tmpdir(), "acs-tap-off-")); + const logPath = join(dir, "envelopes.jsonl"); + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + try { + await postRaw(guardian.url, JSON.stringify(toolCallEnvelope("ls -la"))); + expect(existsSync(logPath)).toBe(false); + } finally { + await guardian.close(); + rmdirSync(dir); + } + }); +}); From 6e4d13e0e1e9bf88f60cf098acedeedc790186f1 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 22:43:43 +0300 Subject: [PATCH 06/35] Add the Inspector package and tailEnvelopeLog (N50) Slice: #3 Affordances: N50 --- bun.lock | 6 + packages/inspector/package.json | 8 + packages/inspector/src/tail-envelope-log.ts | 190 ++++++++++++++++++ .../inspector/test/tail-envelope-log.test.ts | 171 ++++++++++++++++ 4 files changed, 375 insertions(+) create mode 100644 packages/inspector/package.json create mode 100644 packages/inspector/src/tail-envelope-log.ts create mode 100644 packages/inspector/test/tail-envelope-log.test.ts diff --git a/bun.lock b/bun.lock index c6d7d495..925be594 100644 --- a/bun.lock +++ b/bun.lock @@ -45,6 +45,10 @@ "guardian": "workspace:*", }, }, + "packages/inspector": { + "name": "inspector", + "version": "0.1.0", + }, }, "packages": { "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], @@ -129,6 +133,8 @@ "host-adapter": ["host-adapter@workspace:packages/host-adapter"], + "inspector": ["inspector@workspace:packages/inspector"], + "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/inspector/package.json b/packages/inspector/package.json new file mode 100644 index 00000000..4744b90f --- /dev/null +++ b/packages/inspector/package.json @@ -0,0 +1,8 @@ +{ + "name": "inspector", + "private": true, + "type": "module", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts" +} diff --git a/packages/inspector/src/tail-envelope-log.ts b/packages/inspector/src/tail-envelope-log.ts new file mode 100644 index 00000000..03940255 --- /dev/null +++ b/packages/inspector/src/tail-envelope-log.ts @@ -0,0 +1,190 @@ +/** + * tailEnvelopeLog (N50) streams S6 -- the Guardian's JSONL envelope log -- + * as it grows, the way `tail -f` does. + * + * This package deliberately imports nothing from `guardian` or from any of + * its dependencies (global constraint 10). The Inspector reads a file that + * the Guardian happens to write; it holds no compile-time knowledge of the + * process that produced it, which is the point of R5.1 -- envelopes are + * inspectable *on the wire*, not through our own type graph. TapEntry is + * therefore re-declared here rather than imported. The round-trip test at + * test/envelope-tap-roundtrip.test.ts is what keeps the two declarations in + * agreement; if they drift, it fails. + * + * Polling rather than fs.watch: appends to a growing file are exactly the + * case where watch semantics differ most across platforms, and a 120ms poll + * on a local demo log costs nothing. + * + * The poll runs on its own timer, independent of whether anything is + * actively pulling values from the returned generator. That independence is + * load-bearing, not incidental: a consumer can read one entry and then pause + * before asking for the next one, and the file can be truncated and + * rewritten entirely within that pause. A poll folded into the generator's + * own suspend/resume points only ever inspects the file at the instant the + * consumer resumes it -- by then a truncate-and-rewrite can look exactly + * like ordinary growth (a same-length-or-longer rewrite makes the new size + * come out >= the old offset, so a size-only check never fires). A timer + * that keeps ticking regardless of consumption catches the file while it is + * still sitting at zero. + * + * The starting offset is likewise captured synchronously, when this + * function is called -- not lazily inside a generator body, which would not + * run at all until the caller's first `next()`. "Start at the current end" + * has to mean the moment `tailEnvelopeLog` was called, not the moment + * someone first asked it for a value. + */ +import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs"; + +export type TapDirection = "request" | "response"; + +/** One line of S6, as written by the Guardian's envelope tap. */ +export type TapEntry = { + seq: number; + recorded_at: string; + direction: TapDirection; + method: string | null; + rpc_id: string | number | null; + envelope: unknown; +}; + +export type TailOptions = { + path: string; + /** Replay everything already in the file before following. Default false: + * start at the current end, like `tail -f`. */ + fromStart?: boolean; + pollMs?: number; + signal?: AbortSignal; + /** Called per unparseable line. Defaults to one stderr warning. Streaming + * continues either way -- a corrupt line is not a reason to stop showing + * the ones after it. */ + onMalformedLine?: (line: string, error: unknown) => void; +}; + +const NEWLINE = 0x0a; + +export function tailEnvelopeLog({ + path, + fromStart = false, + pollMs = 120, + signal, + onMalformedLine = warnMalformedLine, +}: TailOptions): AsyncGenerator { + let offset = fromStart ? 0 : sizeOf(path); + // Bytes, not a string: a poll can land mid-line and, worse, mid-codepoint. + // Decoding only complete lines keeps multi-byte UTF-8 intact. + let pending = Buffer.alloc(0); + + // Entries the timer has parsed but nobody has consumed yet, and the + // wake-up the drain loop below is currently parked on while that queue is + // empty. The timer and the generator only communicate through these two. + const ready: TapEntry[] = []; + let wake: (() => void) | undefined; + let stopped = false; + + function poll(): void { + if (stopped) { + return; + } + const size = sizeOf(path); + + if (size < offset) { + // Truncated or rotated underneath us (`: > .acs/envelopes.jsonl`). + offset = 0; + pending = Buffer.alloc(0); + } + + if (size > offset) { + pending = Buffer.concat([pending, readRange(path, offset, size - offset)]); + offset = size; + + let added = false; + let newline = pending.indexOf(NEWLINE); + while (newline !== -1) { + const line = pending.subarray(0, newline).toString("utf8"); + pending = pending.subarray(newline + 1); + newline = pending.indexOf(NEWLINE); + + if (line.trim() === "") { + continue; + } + try { + ready.push(JSON.parse(line) as TapEntry); + added = true; + } catch (error) { + onMalformedLine(line, error); + } + } + if (added) { + wake?.(); + } + } + } + + const timer = setInterval(poll, pollMs); + + function stop(): void { + if (stopped) { + return; + } + stopped = true; + clearInterval(timer); + signal?.removeEventListener("abort", stop); + wake?.(); + } + + signal?.addEventListener("abort", stop, { once: true }); + if (signal?.aborted) { + stop(); + } + + return drain(); + + async function* drain(): AsyncGenerator { + try { + while (true) { + while (ready.length === 0 && !stopped) { + await new Promise((resolve) => { + wake = resolve; + }); + wake = undefined; + } + if (ready.length === 0) { + return; + } + const entry = ready.shift(); + if (entry !== undefined) { + yield entry; + } + } + } finally { + stop(); + } + } +} + +function sizeOf(path: string): number { + if (!existsSync(path)) { + return 0; + } + try { + return statSync(path).size; + } catch { + return 0; + } +} + +function readRange(path: string, offset: number, length: number): Buffer { + const buffer = Buffer.alloc(length); + const fd = openSync(path, "r"); + try { + const read = readSync(fd, buffer, 0, length, offset); + return buffer.subarray(0, read); + } finally { + closeSync(fd); + } +} + +function warnMalformedLine(line: string, error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + console.error(`skipping unparseable envelope-log line (${message}): ${line.slice(0, 120)}`); +} diff --git a/packages/inspector/test/tail-envelope-log.test.ts b/packages/inspector/test/tail-envelope-log.test.ts new file mode 100644 index 00000000..98fd0f15 --- /dev/null +++ b/packages/inspector/test/tail-envelope-log.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "bun:test"; +import { appendFileSync, mkdtempSync, rmdirSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { tailEnvelopeLog, type TapEntry } from "../src/tail-envelope-log.ts"; + +const POLL_MS = 10; + +function entryLine(seq: number, direction: "request" | "response"): string { + const entry: TapEntry = { + seq, + recorded_at: "2026-08-09T12:04:31.221Z", + direction, + method: "steps/toolCallRequest", + rpc_id: seq, + envelope: { jsonrpc: "2.0", id: seq }, + }; + return `${JSON.stringify(entry)}\n`; +} + +/** Collects `count` entries or rejects after `timeoutMs`, then aborts the + * generator so the test cannot hang the suite. */ +async function collect( + iterable: AsyncGenerator, + count: number, + controller: AbortController, + timeoutMs = 3000, +): Promise { + const out: TapEntry[] = []; + const deadline = setTimeout(() => controller.abort(), timeoutMs); + try { + for await (const entry of iterable) { + out.push(entry); + if (out.length >= count) { + break; + } + } + } finally { + clearTimeout(deadline); + controller.abort(); + } + return out; +} + +function withTempDir(run: (dir: string, path: string) => Promise): Promise { + const dir = mkdtempSync(join(tmpdir(), "acs-tail-")); + const path = join(dir, "envelopes.jsonl"); + return run(dir, path).finally(() => { + try { + unlinkSync(path); + } catch { + // the not-yet-created case never writes one + } + rmdirSync(dir); + }); +} + +describe("tailEnvelopeLog (N50)", () => { + it("yields entries appended after the tail starts, skipping what was already there", async () => { + await withTempDir(async (_dir, path) => { + writeFileSync(path, entryLine(1, "request")); + const controller = new AbortController(); + const tail = tailEnvelopeLog({ path, pollMs: POLL_MS, signal: controller.signal }); + // Give the generator a poll to record its starting offset before the + // append lands, which is the behaviour under test. + await Bun.sleep(POLL_MS * 3); + appendFileSync(path, entryLine(2, "response")); + + const entries = await collect(tail, 1, controller); + expect(entries.map((e) => e.seq)).toEqual([2]); + }); + }); + + it("yields pre-existing entries when fromStart is set", async () => { + await withTempDir(async (_dir, path) => { + writeFileSync(path, entryLine(1, "request") + entryLine(2, "response")); + const controller = new AbortController(); + const tail = tailEnvelopeLog({ path, fromStart: true, pollMs: POLL_MS, signal: controller.signal }); + + const entries = await collect(tail, 2, controller); + expect(entries.map((e) => e.seq)).toEqual([1, 2]); + expect(entries[0]?.direction).toBe("request"); + }); + }); + + it("waits for a log file that does not exist yet", async () => { + await withTempDir(async (_dir, path) => { + const controller = new AbortController(); + const tail = tailEnvelopeLog({ path, fromStart: true, pollMs: POLL_MS, signal: controller.signal }); + await Bun.sleep(POLL_MS * 3); + writeFileSync(path, entryLine(1, "request")); + + const entries = await collect(tail, 1, controller); + expect(entries.map((e) => e.seq)).toEqual([1]); + }); + }); + + it("reassembles a line delivered in two chunks", async () => { + await withTempDir(async (_dir, path) => { + const line = entryLine(1, "request"); + const split = Math.floor(line.length / 2); + writeFileSync(path, ""); + const controller = new AbortController(); + const tail = tailEnvelopeLog({ path, fromStart: true, pollMs: POLL_MS, signal: controller.signal }); + + appendFileSync(path, line.slice(0, split)); + await Bun.sleep(POLL_MS * 3); + appendFileSync(path, line.slice(split)); + + const entries = await collect(tail, 1, controller); + expect(entries.map((e) => e.seq)).toEqual([1]); + }); + }); + + it("restarts from zero when the log is truncated underneath it", async () => { + await withTempDir(async (_dir, path) => { + writeFileSync(path, entryLine(1, "request")); + const controller = new AbortController(); + const tail = tailEnvelopeLog({ path, fromStart: true, pollMs: POLL_MS, signal: controller.signal }); + + const first = await collectOne(tail); + expect(first?.seq).toBe(1); + + truncateSync(path, 0); + await Bun.sleep(POLL_MS * 3); + appendFileSync(path, entryLine(9, "response")); + + const entries = await collect(tail, 1, controller); + expect(entries.map((e) => e.seq)).toEqual([9]); + }); + + async function collectOne(tail: AsyncGenerator): Promise { + const { value } = await tail.next(); + return value ?? undefined; + } + }); + + it("reports a malformed line and keeps streaming", async () => { + await withTempDir(async (_dir, path) => { + const malformed: string[] = []; + writeFileSync(path, "{not json\n" + entryLine(3, "request")); + const controller = new AbortController(); + const tail = tailEnvelopeLog({ + path, + fromStart: true, + pollMs: POLL_MS, + signal: controller.signal, + onMalformedLine: (line) => malformed.push(line), + }); + + const entries = await collect(tail, 1, controller); + expect(entries.map((e) => e.seq)).toEqual([3]); + expect(malformed).toEqual(["{not json"]); + }); + }); + + it("ends when the signal aborts", async () => { + await withTempDir(async (_dir, path) => { + writeFileSync(path, ""); + const controller = new AbortController(); + const tail = tailEnvelopeLog({ path, fromStart: true, pollMs: POLL_MS, signal: controller.signal }); + controller.abort(); + + const entries: TapEntry[] = []; + for await (const entry of tail) { + entries.push(entry); + } + expect(entries).toEqual([]); + }); + }); +}); From a6a40d0bc481dee785f6c7b00611a5114ea85320 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 23:01:09 +0300 Subject: [PATCH 07/35] Fix review findings: poll errors can't crash the process, timer starts lazily Wrap poll() in try/catch so a read racing a removed file reports and retries next tick instead of throwing out of an unguarded setInterval callback. Move the polling timer's start from tailEnvelopeLog() into drain(), so it only begins on the caller's first next() rather than at construction time, closing a leak for a tail that is built but never iterated or aborted. The eager offset capture stays at call time, unchanged. Adds two covering tests: unlinking the log file underneath a running tail and confirming it survives and recovers when the file reappears, and confirming no poll happens before the first next() call (observed via onMalformedLine staying empty across several poll intervals with no consumer). Slice: #3 --- packages/inspector/src/tail-envelope-log.ts | 133 ++++++++++++------ .../inspector/test/tail-envelope-log.test.ts | 50 +++++++ 2 files changed, 137 insertions(+), 46 deletions(-) diff --git a/packages/inspector/src/tail-envelope-log.ts b/packages/inspector/src/tail-envelope-log.ts index 03940255..f7163019 100644 --- a/packages/inspector/src/tail-envelope-log.ts +++ b/packages/inspector/src/tail-envelope-log.ts @@ -15,23 +15,42 @@ * case where watch semantics differ most across platforms, and a 120ms poll * on a local demo log costs nothing. * - * The poll runs on its own timer, independent of whether anything is - * actively pulling values from the returned generator. That independence is - * load-bearing, not incidental: a consumer can read one entry and then pause - * before asking for the next one, and the file can be truncated and - * rewritten entirely within that pause. A poll folded into the generator's - * own suspend/resume points only ever inspects the file at the instant the - * consumer resumes it -- by then a truncate-and-rewrite can look exactly - * like ordinary growth (a same-length-or-longer rewrite makes the new size - * come out >= the old offset, so a size-only check never fires). A timer - * that keeps ticking regardless of consumption catches the file while it is - * still sitting at zero. + * The poll runs on its own timer once started, independent of whether + * anything is actively pulling values from the returned generator. That + * independence is load-bearing, not incidental: a consumer can read one + * entry and then pause before asking for the next one, and the file can be + * truncated and rewritten entirely within that pause. A poll folded into + * the generator's own suspend/resume points only ever inspects the file at + * the instant the consumer resumes it -- by then a truncate-and-rewrite can + * look exactly like ordinary growth (a same-length-or-longer rewrite makes + * the new size come out >= the old offset, so a size-only check never + * fires). A timer that keeps ticking regardless of consumption catches the + * file while it is still sitting at zero. * - * The starting offset is likewise captured synchronously, when this - * function is called -- not lazily inside a generator body, which would not - * run at all until the caller's first `next()`. "Start at the current end" - * has to mean the moment `tailEnvelopeLog` was called, not the moment - * someone first asked it for a value. + * The timer itself, though, only starts on the first `next()` -- inside the + * returned generator's body, not inside this function. A caller that builds + * a tail and never iterates it (and never aborts) should not leave a timer + * running forever; tying its start to first consumption means an unused + * tail costs nothing. + * + * The starting offset is a separate concern from the timer, and is captured + * synchronously right here, when this function is called -- not lazily + * inside the generator body, which would not run at all until the caller's + * first `next()`. "Start at the current end" has to mean the moment + * `tailEnvelopeLog` was called, not the moment someone first asked it for a + * value; the truncation scenario above still works even though the timer + * itself starts later, because by the time a consumer has read anything at + * all, `next()` has already been called once and the timer is already + * running. + * + * `poll()` runs as a bare timer callback with nothing awaiting it, so + * nothing is in a position to catch a thrown error the way a rejected + * promise would be caught by a consumer's `for await`. A file that + * disappears between the size check and the read -- rotation schemes that + * unlink-and-recreate rather than truncate-in-place can do this -- must + * therefore be handled inside `poll()` itself; letting it throw out of a + * timer callback would crash the whole process instead of merely losing a + * line. */ import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs"; @@ -80,54 +99,66 @@ export function tailEnvelopeLog({ const ready: TapEntry[] = []; let wake: (() => void) | undefined; let stopped = false; + let timer: ReturnType | undefined; function poll(): void { if (stopped) { return; } - const size = sizeOf(path); - - if (size < offset) { - // Truncated or rotated underneath us (`: > .acs/envelopes.jsonl`). - offset = 0; - pending = Buffer.alloc(0); - } - - if (size > offset) { - pending = Buffer.concat([pending, readRange(path, offset, size - offset)]); - offset = size; + try { + const size = sizeOf(path); - let added = false; - let newline = pending.indexOf(NEWLINE); - while (newline !== -1) { - const line = pending.subarray(0, newline).toString("utf8"); - pending = pending.subarray(newline + 1); - newline = pending.indexOf(NEWLINE); + if (size < offset) { + // Truncated or rotated underneath us (`: > .acs/envelopes.jsonl`). + offset = 0; + pending = Buffer.alloc(0); + } - if (line.trim() === "") { - continue; + if (size > offset) { + pending = Buffer.concat([pending, readRange(path, offset, size - offset)]); + offset = size; + + let added = false; + let newline = pending.indexOf(NEWLINE); + while (newline !== -1) { + const line = pending.subarray(0, newline).toString("utf8"); + pending = pending.subarray(newline + 1); + newline = pending.indexOf(NEWLINE); + + if (line.trim() === "") { + continue; + } + try { + ready.push(JSON.parse(line) as TapEntry); + added = true; + } catch (error) { + onMalformedLine(line, error); + } } - try { - ready.push(JSON.parse(line) as TapEntry); - added = true; - } catch (error) { - onMalformedLine(line, error); + if (added) { + wake?.(); } } - if (added) { - wake?.(); - } + } catch (error) { + // A read can lose a race against a file that vanished between the + // size check above and the read itself. There is no promise here for + // that to reject into -- this is a timer callback, not a step inside + // the generator's own call stack -- so an uncaught throw would take + // the whole process down. Skip this tick instead: the next one's + // sizeOf() sees the gap (or the file's return) and resyncs on its + // own. + warnPollError(error); } } - const timer = setInterval(poll, pollMs); - function stop(): void { if (stopped) { return; } stopped = true; - clearInterval(timer); + if (timer !== undefined) { + clearInterval(timer); + } signal?.removeEventListener("abort", stop); wake?.(); } @@ -140,6 +171,11 @@ export function tailEnvelopeLog({ return drain(); async function* drain(): AsyncGenerator { + // Starting the timer here, not above, means a tail nobody ever iterates + // (and nobody ever aborts) never ticks at all. + if (!stopped) { + timer = setInterval(poll, pollMs); + } try { while (true) { while (ready.length === 0 && !stopped) { @@ -188,3 +224,8 @@ function warnMalformedLine(line: string, error: unknown): void { const message = error instanceof Error ? error.message : String(error); console.error(`skipping unparseable envelope-log line (${message}): ${line.slice(0, 120)}`); } + +function warnPollError(error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + console.error(`envelope log poll failed, retrying next tick (${message})`); +} diff --git a/packages/inspector/test/tail-envelope-log.test.ts b/packages/inspector/test/tail-envelope-log.test.ts index 98fd0f15..51ceb125 100644 --- a/packages/inspector/test/tail-envelope-log.test.ts +++ b/packages/inspector/test/tail-envelope-log.test.ts @@ -168,4 +168,54 @@ describe("tailEnvelopeLog (N50)", () => { expect(entries).toEqual([]); }); }); + + it("keeps streaming, without crashing, when the log file is unlinked and later reappears", async () => { + await withTempDir(async (_dir, path) => { + writeFileSync(path, entryLine(1, "request")); + const controller = new AbortController(); + const tail = tailEnvelopeLog({ path, fromStart: true, pollMs: POLL_MS, signal: controller.signal }); + + const first = await collectOne(tail); + expect(first?.seq).toBe(1); + + unlinkSync(path); + await Bun.sleep(POLL_MS * 3); + writeFileSync(path, entryLine(9, "response")); + + const entries = await collect(tail, 1, controller); + expect(entries.map((e) => e.seq)).toEqual([9]); + }); + + async function collectOne(tail: AsyncGenerator): Promise { + const { value } = await tail.next(); + return value ?? undefined; + } + }); + + it("does not start polling until the caller asks for a value", async () => { + await withTempDir(async (_dir, path) => { + writeFileSync(path, ""); + const malformed: string[] = []; + const controller = new AbortController(); + const tail = tailEnvelopeLog({ + path, + fromStart: true, + pollMs: POLL_MS, + signal: controller.signal, + onMalformedLine: (line) => malformed.push(line), + }); + + appendFileSync(path, "{not json\n"); + // Several poll intervals pass with nobody ever calling next(). If a + // timer had started at construction time rather than on first + // consumption, this malformed line would already have been reported. + await Bun.sleep(POLL_MS * 5); + expect(malformed).toEqual([]); + + appendFileSync(path, entryLine(4, "request")); + const entries = await collect(tail, 1, controller); + expect(entries.map((e) => e.seq)).toEqual([4]); + expect(malformed).toEqual(["{not json"]); + }); + }); }); From 4b93726a6acd6026223c472c1cb17f741fea996a Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 23:13:03 +0300 Subject: [PATCH 08/35] Add covering tests for the cross-poll buffer that round 1's fix stopped exercising The lazy timer start (round 1) meant the pre-existing "reassembles a line delivered in two chunks" test no longer exercises cross-poll buffering: consumption there only starts after both chunks are already on disk, so the first poll ever run reads the complete line in one shot. Add two tests that begin consuming before the second chunk lands, so the timer is genuinely running and observes the partial write on its own clock: - an ASCII split, verified to fail if pending stops persisting bytes across polls (mutation-tested: reverting the Buffer.concat to a plain overwrite breaks this test and this test only, all 9 prior tests still pass) - a split placed mid-codepoint inside a multi-byte UTF-8 character, verified to fail if pending decoded each poll's bytes to a string before concatenating (mutation-tested: doing so corrupts the character into U+FFFD and fails this test specifically, while the ASCII test above is unaffected, since ASCII decodes identically regardless of chunk boundaries) Also re-verified, per review request, whether removing poll()'s try/catch makes the unlink-recovery test fail: it does not, across several repeated runs. sizeOf()'s existsSync guard already prevents reaching the read in that scenario, so that test proves the tail survives and recovers from the file disappearing, not the narrower single-tick read-race the try/catch guards against -- consistent with the transparency note already on record from the prior round. No production code changed this round; only test coverage. Slice: #3 --- .../inspector/test/tail-envelope-log.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/inspector/test/tail-envelope-log.test.ts b/packages/inspector/test/tail-envelope-log.test.ts index 51ceb125..22002918 100644 --- a/packages/inspector/test/tail-envelope-log.test.ts +++ b/packages/inspector/test/tail-envelope-log.test.ts @@ -218,4 +218,63 @@ describe("tailEnvelopeLog (N50)", () => { expect(malformed).toEqual(["{not json"]); }); }); + + it("reassembles a line split across two writes when a poll lands on the partial write first", async () => { + await withTempDir(async (_dir, path) => { + const line = entryLine(1, "request"); + const split = Math.floor(line.length / 2); + writeFileSync(path, ""); + const controller = new AbortController(); + const tail = tailEnvelopeLog({ path, fromStart: true, pollMs: POLL_MS, signal: controller.signal }); + + appendFileSync(path, line.slice(0, split)); + // Start consuming now, while only the first chunk is on disk, so the + // (now lazily-started) timer is actually running and gets several + // ticks against a partial line before the second chunk lands. This is + // the case the test above this one no longer exercises: there, + // consumption doesn't begin until after both chunks are already + // written, so the first poll ever run sees the complete line in one + // shot. + const collecting = collect(tail, 1, controller); + await Bun.sleep(POLL_MS * 5); + appendFileSync(path, line.slice(split)); + + const entries = await collecting; + expect(entries.map((e) => e.seq)).toEqual([1]); + }); + }); + + it("reassembles a line whose split lands mid-codepoint, without corrupting the multi-byte character", async () => { + await withTempDir(async (_dir, path) => { + const note = "🎉café"; + const entry: TapEntry = { + seq: 7, + recorded_at: "2026-08-09T12:04:31.221Z", + direction: "request", + method: "steps/toolCallRequest", + rpc_id: 7, + envelope: { jsonrpc: "2.0", id: 7, note }, + }; + const line = `${JSON.stringify(entry)}\n`; + const bytes = Buffer.from(line, "utf8"); + const emojiStart = bytes.indexOf(Buffer.from("🎉", "utf8")); + // Split mid-way through the emoji's 4-byte UTF-8 sequence, so neither + // chunk on its own is valid UTF-8 -- exactly what a buffer that + // decoded each poll's bytes to a string before concatenating would + // corrupt into a replacement character. + const splitAt = emojiStart + 2; + writeFileSync(path, ""); + const controller = new AbortController(); + const tail = tailEnvelopeLog({ path, fromStart: true, pollMs: POLL_MS, signal: controller.signal }); + + appendFileSync(path, bytes.subarray(0, splitAt)); + const collecting = collect(tail, 1, controller); + await Bun.sleep(POLL_MS * 5); + appendFileSync(path, bytes.subarray(splitAt)); + + const entries = await collecting; + expect(entries).toHaveLength(1); + expect(entries[0]?.envelope).toEqual({ jsonrpc: "2.0", id: 7, note }); + }); + }); }); From fa30e82c1955809c031a84e984c3c6a7df87e72e Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 23:21:12 +0300 Subject: [PATCH 09/35] Render the envelope stream and the decision badge (U20, U21) Slice: #3 Affordances: U20, U21 --- packages/inspector/src/render.ts | 114 +++++++++++++++++++++ packages/inspector/test/render.test.ts | 131 +++++++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 packages/inspector/src/render.ts create mode 100644 packages/inspector/test/render.test.ts diff --git a/packages/inspector/src/render.ts b/packages/inspector/src/render.ts new file mode 100644 index 00000000..2b3bded8 --- /dev/null +++ b/packages/inspector/src/render.ts @@ -0,0 +1,114 @@ +/** + * U20 (envelope stream) and U21 (decision badge). + * + * Both functions are pure: no clock, no env, no process. The CLI decides + * whether the terminal wants ANSI and passes `color`; tests assert exact + * plain strings. Nothing here knows what produced a decision -- the badge + * reads ACS's own `decision`, `reason_codes`, and `policy_references` + * fields and nothing else (global constraint 9). + */ +import type { TapEntry } from "./tail-envelope-log.ts"; + +export type RenderOptions = { color?: boolean; indent?: number }; + +const RESET = "\u001b[0m"; +const RED = "\u001b[31m"; +const GREEN = "\u001b[32m"; +const YELLOW = "\u001b[33m"; +const CYAN = "\u001b[36m"; +const DIM = "\u001b[2m"; + +type PolicyReference = { policy_id?: string; policy_version?: string; rule_id?: string }; +type DecisionResult = { + decision?: unknown; + reason_codes?: unknown; + policy_references?: unknown; +}; +type ResponseEnvelope = { result?: DecisionResult; error?: { code?: unknown; message?: unknown } }; + +function paint(text: string, color: string, enabled: boolean): string { + return enabled ? `${color}${text}${RESET}` : text; +} + +/** `2026-08-09T12:04:31.221Z` -> `12:04:31.221`. Sliced, not parsed: UTC and + * locale-independent, so rendered output is the same everywhere. */ +function clockOf(recordedAt: string): string { + const time = recordedAt.slice(11, 23); + return time.length === 12 ? time : recordedAt; +} + +function stringList(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +} + +function referenceList(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value + .filter((item): item is PolicyReference => typeof item === "object" && item !== null) + .map((ref) => (ref.rule_id ? `${ref.policy_id ?? "?"}#${ref.rule_id}` : `${ref.policy_id ?? "?"}`)); +} + +/** U21. Null when this entry carries no decision and no error: a request, or + * a response such as a ServerHello. */ +export function renderDecisionBadge(entry: TapEntry, options: RenderOptions = {}): string | null { + if (entry.direction !== "response") { + return null; + } + const color = options.color ?? false; + const envelope = (typeof entry.envelope === "object" && entry.envelope !== null ? entry.envelope : {}) as ResponseEnvelope; + + if (envelope.error) { + const code = typeof envelope.error.code === "number" ? envelope.error.code : "?"; + const message = typeof envelope.error.message === "string" ? envelope.error.message : ""; + return paint(`✖ ERROR ${code}${message ? ` ${message}` : ""}`, RED, color); + } + + const result = envelope.result; + if (!result || typeof result.decision !== "string") { + return null; + } + + const reasonCodes = stringList(result.reason_codes); + const references = referenceList(result.policy_references); + + let head: string; + if (result.decision === "deny") { + head = paint("● DENY", RED, color); + } else if (result.decision === "allow" && references.length > 0) { + // ACS has no `warn`; a policy that fired but let the action proceed + // arrives as `allow` with a non-empty policy_references. Rendering it + // identically to a clean allow is exactly what this badge exists to + // prevent (slices doc, section V2). + head = paint('◐ ALLOW (policy fired — ACS "warn")', YELLOW, color); + } else if (result.decision === "allow") { + head = paint("○ ALLOW", GREEN, color); + } else { + head = paint(`◆ ${result.decision.toUpperCase()}`, CYAN, color); + } + + const parts = [head]; + if (reasonCodes.length > 0) { + parts.push(`reason_codes=[${reasonCodes.join(", ")}]`); + } + if (references.length > 0) { + parts.push(`policy_references=[${references.join(", ")}]`); + } + return parts.join(" "); +} + +/** U20. Header line, optional badge line, then the envelope as pretty JSON -- + * the same bytes that crossed the wire, only re-indented. */ +export function renderEntry(entry: TapEntry, options: RenderOptions = {}): string { + const color = options.color ?? false; + const arrow = entry.direction === "request" ? "→ REQUEST " : "← RESPONSE"; + const method = entry.method ?? "(no method)"; + const id = entry.rpc_id === null ? "(unpaired)" : `id=${entry.rpc_id}`; + + const header = paint(`── #${entry.seq} ${clockOf(entry.recorded_at)} ${arrow} ${method} ${id}`, DIM, color); + const badge = renderDecisionBadge(entry, options); + const body = JSON.stringify(entry.envelope, null, options.indent ?? 2); + + return [header, ...(badge === null ? [] : [badge]), body].join("\n"); +} diff --git a/packages/inspector/test/render.test.ts b/packages/inspector/test/render.test.ts new file mode 100644 index 00000000..c2c4cf21 --- /dev/null +++ b/packages/inspector/test/render.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "bun:test"; +import { renderDecisionBadge, renderEntry } from "../src/render.ts"; +import type { TapEntry } from "../src/tail-envelope-log.ts"; + +function entry(overrides: Partial): TapEntry { + return { + seq: 3, + recorded_at: "2026-08-09T12:04:31.221Z", + direction: "response", + method: "steps/toolCallRequest", + rpc_id: 1, + envelope: {}, + ...overrides, + }; +} + +function response(result: Record): TapEntry { + return entry({ envelope: { jsonrpc: "2.0", id: 1, result } }); +} + +describe("renderDecisionBadge (U21)", () => { + it("returns null for requests", () => { + expect(renderDecisionBadge(entry({ direction: "request", envelope: { jsonrpc: "2.0", id: 1 } }))).toBeNull(); + }); + + it("returns null for a response with no decision -- a ServerHello", () => { + expect(renderDecisionBadge(response({ negotiated_version: "0.1.0", on_decision_failure: "proceed" }))).toBeNull(); + }); + + it("badges a deny, with reason_codes and policy_references", () => { + const badge = renderDecisionBadge( + response({ + decision: "deny", + reason_codes: ["destructive_shell_command_blocked"], + policy_references: [{ policy_id: "agt_stock", rule_id: "destructive_shell_command_blocked" }], + }), + ); + + expect(badge).toBe( + "● DENY reason_codes=[destructive_shell_command_blocked] " + + "policy_references=[agt_stock#destructive_shell_command_blocked]", + ); + }); + + it("badges a plain allow", () => { + expect(renderDecisionBadge(response({ decision: "allow" }))).toBe("○ ALLOW"); + }); + + // The reason U21 exists, per the slices doc: an AGT `warn` arrives as an + // ACS `allow` with a non-empty policy_references, and the badge is what + // keeps it from being buried. + it("distinguishes an allow that carries policy_references -- ACS's encoding of warn", () => { + const badge = renderDecisionBadge( + response({ + decision: "allow", + reason_codes: ["drift_detected"], + policy_references: [{ policy_id: "agt_stock", rule_id: "drift_detected" }], + }), + ); + + expect(badge).toBe( + '◐ ALLOW (policy fired — ACS "warn") reason_codes=[drift_detected] ' + + "policy_references=[agt_stock#drift_detected]", + ); + expect(badge).not.toBe(renderDecisionBadge(response({ decision: "allow" }))); + }); + + it("badges modify, ask, and defer", () => { + expect(renderDecisionBadge(response({ decision: "modify" }))).toBe("◆ MODIFY"); + expect(renderDecisionBadge(response({ decision: "ask" }))).toBe("◆ ASK"); + expect(renderDecisionBadge(response({ decision: "defer" }))).toBe("◆ DEFER"); + }); + + it("badges a JSON-RPC error", () => { + const badge = renderDecisionBadge( + entry({ envelope: { jsonrpc: "2.0", id: 1, error: { code: -32010, message: "ACS envelope failed" } } }), + ); + + expect(badge).toBe("✖ ERROR -32010 ACS envelope failed"); + }); + + it("emits ANSI only when colour is asked for", () => { + const plain = renderDecisionBadge(response({ decision: "deny" }), { color: false }); + const coloured = renderDecisionBadge(response({ decision: "deny" }), { color: true }); + + expect(plain).toBe("● DENY"); + expect(coloured).toContain("\u001b["); + expect(coloured).toContain("DENY"); + }); +}); + +describe("renderEntry (U20)", () => { + it("renders a request as a header line plus pretty JSON, with no badge", () => { + const rendered = renderEntry( + entry({ + direction: "request", + seq: 1, + envelope: { jsonrpc: "2.0", method: "steps/toolCallRequest", id: 1 }, + }), + ); + + expect(rendered.split("\n")[0]).toBe("── #1 12:04:31.221 → REQUEST steps/toolCallRequest id=1"); + expect(rendered).toContain('"jsonrpc": "2.0"'); + expect(rendered).not.toContain("●"); + }); + + it("renders a response as a header line, a badge line, then pretty JSON", () => { + const rendered = renderEntry(response({ decision: "deny", reason_codes: ["blocked"] })); + const lines = rendered.split("\n"); + + expect(lines[0]).toBe("── #3 12:04:31.221 ← RESPONSE steps/toolCallRequest id=1"); + expect(lines[1]).toBe("● DENY reason_codes=[blocked]"); + expect(lines[2]).toBe("{"); + }); + + it("labels an unpaired response -- the malformed-body case -- without an id or a method", () => { + const rendered = renderEntry( + entry({ method: null, rpc_id: null, envelope: { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } } }), + ); + + expect(rendered.split("\n")[0]).toBe("── #3 12:04:31.221 ← RESPONSE (no method) (unpaired)"); + }); + + it("keeps the envelope verbatim -- pretty-printing only reshapes whitespace", () => { + const envelope = { jsonrpc: "2.0", id: 1, result: { decision: "allow", nested: { deep: [1, 2] } } }; + const rendered = renderEntry(entry({ envelope })); + const jsonStart = rendered.indexOf("{"); + + expect(JSON.parse(rendered.slice(jsonStart))).toEqual(envelope); + }); +}); From fb22f47fb6ad85469ff8aedea70861ab77f170e2 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 23:36:36 +0300 Subject: [PATCH 10/35] Add the Inspector CLI and the S6 round-trip contract test (U20, U21, N50) Also adds a shape check to tailEnvelopeLog between JSON.parse and yield: a line that parses as JSON but doesn't match TapEntry (wrong-typed recorded_at, missing direction, ...) is now routed to onMalformedLine like an unparseable line instead of being cast through unchecked and later crashing renderEntry's clockOf inside the CLI's main loop. Slice: #3 Affordances: U20, U21, N50, N26 --- package.json | 3 +- packages/inspector/src/index.ts | 3 + packages/inspector/src/main.ts | 39 ++++++++ packages/inspector/src/tail-envelope-log.ts | 32 ++++++- .../inspector/test/tail-envelope-log.test.ts | 53 +++++++++++ test/envelope-tap-roundtrip.test.ts | 92 +++++++++++++++++++ 6 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 packages/inspector/src/index.ts create mode 100644 packages/inspector/src/main.ts create mode 100644 test/envelope-tap-roundtrip.test.ts diff --git a/package.json b/package.json index e20357a6..fd6acac4 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "test": "bun test", "typecheck": "tsc -p tsconfig.json --noEmit", "verify:pin": "bash scripts/verify-pin.sh", - "guardian": "bun run packages/guardian/src/main.ts" + "guardian": "bun run packages/guardian/src/main.ts", + "inspector": "bun run packages/inspector/src/main.ts" }, "dependencies": { "agent-control-specification": "0.3.1-beta.0" diff --git a/packages/inspector/src/index.ts b/packages/inspector/src/index.ts new file mode 100644 index 00000000..73e2b1d3 --- /dev/null +++ b/packages/inspector/src/index.ts @@ -0,0 +1,3 @@ +/** Public surface of the inspector package. */ +export { tailEnvelopeLog, type TailOptions, type TapDirection, type TapEntry } from "./tail-envelope-log.ts"; +export { renderDecisionBadge, renderEntry, type RenderOptions } from "./render.ts"; diff --git a/packages/inspector/src/main.ts b/packages/inspector/src/main.ts new file mode 100644 index 00000000..f8956e2c --- /dev/null +++ b/packages/inspector/src/main.ts @@ -0,0 +1,39 @@ +/** + * The Envelope Inspector's entrypoint -- `bun run inspector`. + * + * A third terminal beside `bun run guardian` and the agent host: it tails S6 + * and prints each ACS envelope as it crosses the wire. Not re-exported from + * ./index.ts -- this is a process entrypoint, not a library call. + * + * `ACS_ENVELOPE_LOG` defaults to `.acs/envelopes.jsonl`, the same default + * packages/guardian/src/main.ts writes to, so the two agree without either + * hardcoding the other's value. + */ +import { tailEnvelopeLog } from "./tail-envelope-log.ts"; +import { renderEntry } from "./render.ts"; + +const DEFAULT_ENVELOPE_LOG = ".acs/envelopes.jsonl"; + +const argv = process.argv.slice(2); +const fromStart = argv.includes("--from-start"); +const pathFlag = argv.indexOf("--path"); +const flagValue = pathFlag === -1 ? undefined : argv[pathFlag + 1]; + +if (pathFlag !== -1 && (flagValue === undefined || flagValue.startsWith("--"))) { + console.error("usage: bun run inspector -- [--from-start] [--path ]"); + process.exit(2); +} + +const path = flagValue ?? process.env.ACS_ENVELOPE_LOG ?? DEFAULT_ENVELOPE_LOG; +const color = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR; + +const controller = new AbortController(); +process.on("SIGINT", () => controller.abort()); + +console.log(`Envelope Inspector — tailing ${path}${fromStart ? " (from the start)" : ""}`); +console.log("Ctrl-C to stop.\n"); + +for await (const entry of tailEnvelopeLog({ path, fromStart, signal: controller.signal })) { + console.log(renderEntry(entry, { color })); + console.log(""); +} diff --git a/packages/inspector/src/tail-envelope-log.ts b/packages/inspector/src/tail-envelope-log.ts index f7163019..8ab3bbc6 100644 --- a/packages/inspector/src/tail-envelope-log.ts +++ b/packages/inspector/src/tail-envelope-log.ts @@ -81,6 +81,32 @@ export type TailOptions = { const NEWLINE = 0x0a; +/** + * S6 is a plain file on disk; anything can write a line to it that is valid + * JSON but not a valid TapEntry (a number where recorded_at should be a + * string, a missing direction, ...). `renderEntry`'s `clockOf` calls + * `.slice` on `recorded_at` unconditionally, so an unchecked cast here would + * let such a line reach the renderer and throw -- inside a `for await` loop, + * that kills the whole stream. Checked here instead, right after + * `JSON.parse`, using exactly the fields the renderer depends on. + * `envelope` is deliberately left unconstrained: it is `unknown` by design + * (R5.1 -- see the module doc above), not a shape this function's job to + * police. + */ +function isTapEntryShape(value: unknown): value is TapEntry { + if (typeof value !== "object" || value === null) { + return false; + } + const candidate = value as Record; + return ( + typeof candidate.seq === "number" && + typeof candidate.recorded_at === "string" && + (candidate.direction === "request" || candidate.direction === "response") && + (typeof candidate.method === "string" || candidate.method === null) && + (typeof candidate.rpc_id === "string" || typeof candidate.rpc_id === "number" || candidate.rpc_id === null) + ); +} + export function tailEnvelopeLog({ path, fromStart = false, @@ -129,7 +155,11 @@ export function tailEnvelopeLog({ continue; } try { - ready.push(JSON.parse(line) as TapEntry); + const parsed: unknown = JSON.parse(line); + if (!isTapEntryShape(parsed)) { + throw new Error("line parsed as JSON but does not match the TapEntry shape"); + } + ready.push(parsed); added = true; } catch (error) { onMalformedLine(line, error); diff --git a/packages/inspector/test/tail-envelope-log.test.ts b/packages/inspector/test/tail-envelope-log.test.ts index 22002918..4d196bab 100644 --- a/packages/inspector/test/tail-envelope-log.test.ts +++ b/packages/inspector/test/tail-envelope-log.test.ts @@ -277,4 +277,57 @@ describe("tailEnvelopeLog (N50)", () => { expect(entries[0]?.envelope).toEqual({ jsonrpc: "2.0", id: 7, note }); }); }); + + it("reports a line that parses as JSON but has a non-string recorded_at, and keeps streaming", async () => { + await withTempDir(async (_dir, path) => { + const malformed: string[] = []; + const badLine = JSON.stringify({ + seq: 1, + recorded_at: 12345, + direction: "request", + method: "steps/toolCallRequest", + rpc_id: 1, + envelope: { jsonrpc: "2.0", id: 1 }, + }); + writeFileSync(path, `${badLine}\n${entryLine(3, "request")}`); + const controller = new AbortController(); + const tail = tailEnvelopeLog({ + path, + fromStart: true, + pollMs: POLL_MS, + signal: controller.signal, + onMalformedLine: (line) => malformed.push(line), + }); + + const entries = await collect(tail, 1, controller); + expect(entries.map((e) => e.seq)).toEqual([3]); + expect(malformed).toEqual([badLine]); + }); + }); + + it("reports a line that parses as JSON but is missing direction, and keeps streaming", async () => { + await withTempDir(async (_dir, path) => { + const malformed: string[] = []; + const badLine = JSON.stringify({ + seq: 1, + recorded_at: "2026-08-09T12:04:31.221Z", + method: "steps/toolCallRequest", + rpc_id: 1, + envelope: { jsonrpc: "2.0", id: 1 }, + }); + writeFileSync(path, `${badLine}\n${entryLine(3, "request")}`); + const controller = new AbortController(); + const tail = tailEnvelopeLog({ + path, + fromStart: true, + pollMs: POLL_MS, + signal: controller.signal, + onMalformedLine: (line) => malformed.push(line), + }); + + const entries = await collect(tail, 1, controller); + expect(entries.map((e) => e.seq)).toEqual([3]); + expect(malformed).toEqual([badLine]); + }); + }); }); diff --git a/test/envelope-tap-roundtrip.test.ts b/test/envelope-tap-roundtrip.test.ts new file mode 100644 index 00000000..ff69e7e4 --- /dev/null +++ b/test/envelope-tap-roundtrip.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "bun:test"; +import { mkdtempSync, rmdirSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { startGuardian } from "../packages/guardian/src/index.ts"; +import { tailEnvelopeLog, type TapEntry } from "../packages/inspector/src/tail-envelope-log.ts"; +import { renderDecisionBadge } from "../packages/inspector/src/render.ts"; + +/** + * The contract test for S6. The Guardian writes the log; the Inspector + * declares its own TapEntry and reads it back (global constraint 10). If + * either side renames a field, adds a required one, or changes a type, this + * is what fails -- nothing else would, because the two never share a type. + */ +function toolCallEnvelope(command: string, id: number): Record { + return { + jsonrpc: "2.0", + method: "steps/toolCallRequest", + id, + params: { + acs_version: "0.1.0", + request_id: crypto.randomUUID(), + timestamp: new Date().toISOString(), + metadata: { agent_id: "agent-1", session_id: crypto.randomUUID() }, + payload: { tool: { name: "run_shell" }, arguments: { command: { value: command } } }, + }, + }; +} + +async function take( + tail: AsyncGenerator, + count: number, + controller: AbortController, +): Promise { + const out: TapEntry[] = []; + const deadline = setTimeout(() => controller.abort(), 5000); + try { + for await (const entry of tail) { + out.push(entry); + if (out.length >= count) { + break; + } + } + } finally { + clearTimeout(deadline); + controller.abort(); + } + return out; +} + +describe("S6 round trip: Guardian tap (N26) -> Inspector tail (N50) -> badge (U21)", () => { + it("a denied tool call arrives as a paired request/response the Inspector can render", async () => { + const dir = mkdtempSync(join(tmpdir(), "acs-roundtrip-")); + const logPath = join(dir, "envelopes.jsonl"); + const guardian = await startGuardian({ + port: 0, + manifestPath: "policy/manifest.yaml", + envelopeLogPath: logPath, + }); + const controller = new AbortController(); + + try { + await fetch(guardian.url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(toolCallEnvelope("rm -rf /", 77)), + }); + + const tail = tailEnvelopeLog({ path: logPath, fromStart: true, pollMs: 10, signal: controller.signal }); + const [request, response] = await take(tail, 2, controller); + + // Every field the Inspector's TapEntry declares must actually be + // present and correctly typed on what the Guardian wrote. + expect(request?.seq).toBe(1); + expect(response?.seq).toBe(2); + expect(typeof request?.recorded_at).toBe("string"); + expect(request?.direction).toBe("request"); + expect(response?.direction).toBe("response"); + expect(request?.method).toBe("steps/toolCallRequest"); + expect(request?.rpc_id).toBe(77); + expect(response?.rpc_id).toBe(77); + + // ...and the badge reads a real AGT-backed decision off it. + expect(renderDecisionBadge(response as TapEntry)).toContain("DENY"); + } finally { + controller.abort(); + await guardian.close(); + unlinkSync(logPath); + rmdirSync(dir); + } + }); +}); From 9b3f6c9430abee77ea87c037fdb857d799b42a46 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Sun, 9 Aug 2026 23:56:06 +0300 Subject: [PATCH 11/35] Gate the Inspector on R5.1/R5.2, add the V2 runbook and quickstart Slice: #3 Affordances: U20, U21, N26, N50, S6 --- README.md | 59 ++++- docs/demos/v2-runbook.md | 465 +++++++++++++++++++++++++++++++++++++++ slices/v2/README.md | 53 ++++- test/invariants.test.ts | 38 ++++ 4 files changed, 604 insertions(+), 11 deletions(-) create mode 100644 docs/demos/v2-runbook.md diff --git a/README.md b/README.md index eb344bd8..6a073f82 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 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. 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. +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. V1 wires one host — Claude Code — to AGT's policy engine running unforked, its stock Rego bundle deciding, entirely over the ACS wire. V2 makes that wire visible: every envelope crossing it is tapped to a log and rendered live by `bun run inspector`. ## What this proves @@ -15,6 +15,13 @@ This repository shows the other shape. A host implements [ACS](https://github.co | 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) | +**Delivered in V2** — the Envelope Inspector. + +| Claim | How it is demonstrated | +|---|---| +| R5.1 — every hook firing is inspectable as an ACS envelope, in both directions, including envelopes that fail validation | The Guardian taps every envelope crossing its wire to `.acs/envelopes.jsonl` before validation, and `bun run inspector` renders it live ([`test/envelope-tap-roundtrip.test.ts`](test/envelope-tap-roundtrip.test.ts), [`packages/guardian/test/envelope-tap-wiring.test.ts`](packages/guardian/test/envelope-tap-wiring.test.ts)) | +| R5.2 — an ACS-first reader can trace one action end to end without reading AGT source | The Inspector imports nothing from the Guardian or the AGT bridge and names neither AGT nor any host — enforced by two gates in [`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 | @@ -58,41 +65,67 @@ This constructs the AGT bridge once, against the pinned stock policy bundle (`po ``` Guardian listening at http://localhost:8787/acs +Envelope log (S6): .acs/envelopes.jsonl ``` 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.** +**3. Start the Envelope Inspector.** In a second terminal, after the Guardian (which is what creates `.acs/`): + +```bash +bun run inspector +``` + +``` +Envelope Inspector — tailing .acs/envelopes.jsonl +Ctrl-C to stop. +``` + +Every ACS envelope crossing the Guardian's wire is printed here as it happens — request and response, with a decision badge on responses: + +``` +── #2 20:44:33.130 ← RESPONSE steps/toolCallRequest id=e491180d-60a8-4982-b693-e63771c00e2d +● DENY reason_codes=[destructive_shell_command_blocked] policy_references=[agt_stock#destructive_shell_command_blocked] +``` + +`bun run inspector -- --from-start` replays a session already recorded. **`.acs/envelopes.jsonl` records the wire verbatim, so it carries raw tool arguments** — it is gitignored for that reason and never committed. Full walkthrough, with the real captured output for a deny, an allow, and a schema-invalid envelope: [`docs/demos/v2-runbook.md`](docs/demos/v2-runbook.md). + +**4. 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. +This registers `hosts/claude-code/acs-hook.ts` as a `PreToolUse` hook for the `Bash` tool — the "one hook" of V1's name. -**4. Run Claude Code with the hook.** +**5. 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 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). +Ask it to run a destructive shell command, e.g. *"Use the Bash tool to run exactly this command: `rm -rf /`"*. The tool call is blocked, with the real policy-engine reasoning surfaced in the transcript — not a canned string, the actual text AGT's stock policy engine produces when it evaluates the pattern it matched. That pattern list is this project's own configuration (`policy/lib/data.json`), not something AGT ships — the stock bundle carries no shell/command patterns of its own, only generic PII regexes; what's stock is the *deciding module* (`agt.patterns`) and the priority chain that consults it, per R2.1 (zero Rego authored). See the framing note in [`docs/demos/v1-runbook.md`](docs/demos/v1-runbook.md) before narrating this demo. Ask for something harmless (`ls -la`) in the same session and it runs normally. Full walkthrough and what to watch for: [`docs/demos/v1-runbook.md`](docs/demos/v1-runbook.md); with the Inspector running you also see both envelopes as they cross the wire. -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: +Watch the Inspector, not just the transcript, if the deny does not appear: the model may decline to issue the tool call at all on its own judgment, in which case no hook fires and the envelope log stays empty. And if you are scripting this rather than watching it, use `echo rm -rf /` as the payload — it matches the same pattern at offset 5 and is inert if it ever did execute, whereas an unattended `rm -rf /` is only safe for as long as the hook works, which is the thing under test. + +**What was actually run against this tree to write this quickstart.** Steps 1–3 were run end to end: `bun install` completes clean, `bun run guardian` prints both lines above, and `bun run inspector` rendered every envelope quoted here — the badge line above is pasted from that run, not composed. Steps 4–5 were run twice, two different ways. + +First, by piping a Claude Code–shaped `PreToolUse` payload on stdin straight into the hook shim against a running Guardian — the same way the project's own tests verify it. The shim never executes the command; it only asks the Guardian for a decision: ```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"}} +# {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"matched pattern (?i)rm\\s+-[a-z]*r[a-z]*f[a-z]*\\s+/(?:\\s|$) 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. +Second, through the real `claude` CLI with `.claude/settings.json` installed — Claude Code spawning `acs-hook.ts` as an actual `PreToolUse` subprocess and honouring the decision. That run was **headless** (`claude -p '' --allowedTools Bash`), not an interactive TUI session, and the payload was `echo rm -rf /` rather than `rm -rf /`: the configured pattern matches the raw command string with no argv parse, so it is denied by the same rule at offset 5 while being inert if it ever did execute. An unattended `rm -rf /` is only safe for as long as the hook works, which is the thing under test. The interactive TUI session was not run, so nothing here describes how the TUI renders the block. See [`docs/demos/v2-runbook.md`](docs/demos/v2-runbook.md) for the full captured output of both. ### Verify ```bash -bun test # 97 tests across 12 files (96 pass, 1 skip), including the R3.2/R3.3 gates below +bun test # 112 tests across 16 files (111 pass, 1 skip), including the R3.2/R3.3 + # and R5.1/R5.2 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 +134,13 @@ 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 *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. +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). + +V2 ("Envelope Inspector") is implemented: the Guardian taps every ACS envelope crossing its wire to `.acs/envelopes.jsonl`, and `bun run inspector` tails and renders it live — see [`slices/v2/README.md`](slices/v2/README.md) and [`docs/demos/v2-runbook.md`](docs/demos/v2-runbook.md). One boundary worth stating up front: a schema-invalid envelope surfaces as a JSON-RPC **error**, not a `deny` decision. `N27 denyOnInvalidEnvelope()`, which turns Guardian-side failures into honoured ACS decisions, is V3. + +Four of this project's architectural claims are enforced by [`test/invariants.test.ts`](test/invariants.test.ts) rather than left to inspection: 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), and R5.1 and R5.2 (the Inspector imports nothing from the Guardian or the AGT bridge, and names neither AGT nor any host). + +Slices V3–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/docs/demos/v2-runbook.md b/docs/demos/v2-runbook.md new file mode 100644 index 00000000..7a67996b --- /dev/null +++ b/docs/demos/v2-runbook.md @@ -0,0 +1,465 @@ +# V2 demo runbook: the Envelope Inspector + +**The demo, in the slice's own words** (from `docs/shaping/acs-reference-impl-slices.md`): + +> Watch the ACS request and response JSON stream live while you work in Claude Code. + +This runbook is written from a real run against this tree. Every block below marked +"captured" is pasted from the actual terminal, not reconstructed. + +## What a viewer should watch for + +1. A third terminal, beside the Guardian and the agent host, prints every ACS envelope + as it crosses the wire — request and response, both directions, one entry each. +2. The rendered JSON is the *same bytes that crossed the wire*, only re-indented. The + tap does no reformatting, no field stripping, no redaction, no reordering. +3. The decision badge makes the outcome legible without reading the JSON: `● DENY`, + `○ ALLOW`, or `✖ ERROR`, with `reason_codes` and `policy_references` beside it. +4. Nothing in the Inspector knows what produced the decision. It imports nothing from + the Guardian and names neither AGT nor any host — enforced by two gates in + [`test/invariants.test.ts`](../../test/invariants.test.ts), not left to inspection. + That is what makes R5.2 ("an ACS-first reader can trace one action end to end + without reading AGT source") a property of the code rather than a claim in prose. + +**Read the V1 runbook's two framing notes first.** [`docs/demos/v1-runbook.md`](v1-runbook.md) +records that the destructive-command pattern list is this project's own configuration +supplied as data, not something AGT ships, and that the handshake declares rather than +negotiates. Both still hold here; V2 changes neither. + +## Prerequisites + +- `bun` installed, `bun install` run once at the repo root. +- This repo cloned with its submodule. +- For the last section only: the [Claude Code](https://docs.claude.com/en/docs/claude-code) + CLI on your `PATH` (`claude`). + +## The three terminals, in order + +### Terminal 1 — the Guardian, first + +```bash +bun run guardian +``` + +Captured: + +``` +Guardian listening at http://localhost:8787/acs +Envelope log (S6): .acs/envelopes.jsonl +``` + +**Why this one starts first: it creates `.acs/`.** The tap calls +`mkdirSync(dirname(path), { recursive: true })` when the Guardian constructs it at boot +(`packages/guardian/src/envelope-tap.ts`), so the directory exists from the moment the +Guardian is up. The log file itself does not appear until the first envelope is written +— after the Guardian booted, `.acs/` existed and was empty. + +The Inspector tolerates a file that does not exist yet (`sizeOf` returns 0 for a missing +path), so starting them out of order does not break anything. Guardian-first is still +the order to teach, because it is the order in which the two artifacts come into +existence. + +The tap is opt-in at the library level and on by default in the CLI: `startGuardian` +taps only when `envelopeLogPath` is passed, and `packages/guardian/src/main.ts` passes +it. Override the location with `ACS_ENVELOPE_LOG`. + +### Terminal 2 — the Inspector + +```bash +bun run inspector +``` + +Captured: + +``` +Envelope Inspector — tailing .acs/envelopes.jsonl +Ctrl-C to stop. +``` + +It follows the log the way `tail -f` does: it starts at the current end and prints what +arrives from now on. `ACS_ENVELOPE_LOG` and `--path ` both point it elsewhere; the +default matches the Guardian's default, so neither hardcodes the other's value. + +Output is coloured when stdout is a TTY and `NO_COLOR` is unset. Every capture in this +runbook was taken with stdout redirected to a file, which is why the pasted bytes carry +no ANSI escapes; on a TTY the badge lines carry colour (red, green, and yellow, per the +constants in `packages/inspector/src/render.ts` — read from the source, not captured +here). + +### Terminal 3 — drive a tool call + +The shim reads a Claude Code `PreToolUse` payload on stdin and writes the hook result on +stdout. It never executes the command; it only asks the Guardian for a decision, which +is why piping `rm -rf /` into it is safe. + +```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 +``` + +Captured, from terminal 3: + +``` +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"matched pattern (?i)rm\\s+-[a-z]*r[a-z]*f[a-z]*\\s+/(?:\\s|$) at offset 0"}} +``` + +## The deny, as the Inspector rendered it + +Captured, from terminal 2, verbatim: + +``` +── #1 20:44:32.555 → REQUEST steps/toolCallRequest id=e491180d-60a8-4982-b693-e63771c00e2d +{ + "jsonrpc": "2.0", + "method": "steps/toolCallRequest", + "id": "e491180d-60a8-4982-b693-e63771c00e2d", + "params": { + "acs_version": "0.1.0", + "request_id": "e491180d-60a8-4982-b693-e63771c00e2d", + "timestamp": "2026-08-09T20:44:32.535Z", + "metadata": { + "agent_id": "claude-code", + "session_id": "6ccc72b8-a167-5573-b3ea-a310262ea93f" + }, + "payload": { + "tool": { + "name": "Bash" + }, + "arguments": { + "command": { + "value": "rm -rf /" + } + } + } + } +} + +── #2 20:44:33.130 ← RESPONSE steps/toolCallRequest id=e491180d-60a8-4982-b693-e63771c00e2d +● DENY reason_codes=[destructive_shell_command_blocked] policy_references=[agt_stock#destructive_shell_command_blocked] +{ + "jsonrpc": "2.0", + "id": "e491180d-60a8-4982-b693-e63771c00e2d", + "result": { + "type": "final", + "acs_version": "0.1.0", + "request_id": "e491180d-60a8-4982-b693-e63771c00e2d", + "decision": "deny", + "reasoning": "matched pattern (?i)rm\\s+-[a-z]*r[a-z]*f[a-z]*\\s+/(?:\\s|$) at offset 0", + "reason_codes": [ + "destructive_shell_command_blocked" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "destructive_shell_command_blocked" + } + ] + } +} +``` + +The header line is `── #seq time direction method id=`. `seq` is a counter +scoped to the Guardian process. The `id` is the JSON-RPC id, which is what pairs the two +entries — it is the only identifier present in both directions, so pairing survives two +hooks in flight at once. + +## The allow, for contrast + +```bash +echo '{"session_id":"demo","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls -la"}}' \ + | bun run hosts/claude-code/acs-hook.ts +``` + +Captured, from terminal 3: + +``` +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}} +``` + +Captured, from terminal 2, verbatim: + +``` +── #3 20:44:41.150 → REQUEST steps/toolCallRequest id=0781250c-0f99-43ab-bc66-e79e5fead2d9 +{ + "jsonrpc": "2.0", + "method": "steps/toolCallRequest", + "id": "0781250c-0f99-43ab-bc66-e79e5fead2d9", + "params": { + "acs_version": "0.1.0", + "request_id": "0781250c-0f99-43ab-bc66-e79e5fead2d9", + "timestamp": "2026-08-09T20:44:41.139Z", + "metadata": { + "agent_id": "claude-code", + "session_id": "6ccc72b8-a167-5573-b3ea-a310262ea93f" + }, + "payload": { + "tool": { + "name": "Bash" + }, + "arguments": { + "command": { + "value": "ls -la" + } + } + } + } +} + +── #4 20:44:41.203 ← RESPONSE steps/toolCallRequest id=0781250c-0f99-43ab-bc66-e79e5fead2d9 +○ ALLOW +{ + "jsonrpc": "2.0", + "id": "0781250c-0f99-43ab-bc66-e79e5fead2d9", + "result": { + "type": "final", + "acs_version": "0.1.0", + "request_id": "0781250c-0f99-43ab-bc66-e79e5fead2d9", + "decision": "allow" + } +} +``` + +A clean allow carries no `reasoning`, no `reason_codes`, and no `policy_references`, so +the badge is bare. That absence is the signal: a policy that fired but let the action +proceed arrives as `allow` with a **non-empty** `policy_references`, and the badge +renders that case differently — `◐ ALLOW (policy fired — ACS "warn")` — because ACS has +no `warn` disposition and rendering the two identically is exactly what U21 exists to +prevent. No `warn` case occurred in this run; the rendering above is the deny and the +clean allow, which are what the pinned configuration actually produced. + +## The honest boundary: a schema-invalid envelope is an error, not a deny + +The request is tapped **before** validation, so an envelope that fails the schema is +visible rather than swallowed. What comes back, though, is a JSON-RPC **error** — not an +ACS `deny` decision. + +```bash +curl -s -X POST http://localhost:8787/acs \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","method":"steps/toolCallRequest","id":"1","params":{"acs_version":"0.1.0"}}' +``` + +Captured, from terminal 2, verbatim: + +``` +── #5 20:44:57.829 → REQUEST steps/toolCallRequest id=1 +{ + "jsonrpc": "2.0", + "method": "steps/toolCallRequest", + "id": "1", + "params": { + "acs_version": "0.1.0" + } +} + +── #6 20:44:57.829 ← RESPONSE steps/toolCallRequest id=1 +✖ ERROR -32010 ACS envelope failed schema validation at /params/request_id: must have required property 'request_id' +{ + "jsonrpc": "2.0", + "id": "1", + "error": { + "code": -32010, + "message": "ACS envelope failed schema validation at /params/request_id: must have required property 'request_id'", + "data": { + "pointer": "/params/request_id" + } + } +} +``` + +**This is a real limitation of V2, stated plainly.** `✖ ERROR -32010` is what a +Guardian-side failure looks like today. It is *not* a decision, so it is not something +the host's decision path honours the way it honours a `deny`. +`N27 denyOnInvalidEnvelope()` — the affordance that turns schema and bridge failures +into explicit ACS `deny` **decisions** — is **slice V3**, defined in +[`docs/shaping/acs-reference-impl-slices.md`](../shaping/acs-reference-impl-slices.md) +§V3, alongside `N6 applyFailurePosture()` and `N7 validateDecision()`. The Inspector is +where that change will become visible: the same request will come back with a badge +instead of an error line. + +A body that will not parse as JSON at all goes further: there is no request entry to +pair with, because there was never a parseable request. The Inspector renders the lone +response rather than hiding it. Captured from `curl -d 'this is not json'`, verbatim — +this one was taken against a freshly restarted Guardian, which is why its `seq` is `#1` +rather than continuing the run above: + +``` +── #1 20:51:47.882 ← RESPONSE (no method) (unpaired) +✖ ERROR -32700 Parse error +{ + "jsonrpc": "2.0", + "id": null, + "error": { + "code": -32700, + "message": "Parse error" + } +} +``` + +`(no method)` and `(unpaired)` are what the renderer prints for `method: null` and +`rpc_id: null`. Both are real states on this wire, so both are shown. + +## S6 carries raw tool arguments verbatim + +`.acs/envelopes.jsonl` is the wire, recorded exactly: no reformatting, no field +stripping, no redaction, no reordering. Pretty-printing happens at render time only. An +inspector that showed something other than what was sent would be worse than no +inspector at all. + +The consequence is direct: **the log contains whatever your tool calls contained** — +file paths, command lines, and anything else that rode along in `arguments`. That is why +`.acs/` is in [`.gitignore`](../../.gitignore) and why it is never committed. It is a +local demo artifact. Treat it the way you would treat a shell history file. + +It also grows without bound. V2 builds no rotation; that is recorded as an accepted +limitation in the slices doc. + +## Clearing the log mid-demo + +```bash +: > .acs/envelopes.jsonl +``` + +Truncate in place. The Inspector picks up cleanly afterwards without a restart, because +`tailEnvelopeLog` compares the file size against its read offset on every poll and, when +the size comes out smaller, resets the offset to 0 and drops its partial-line buffer. + +Verified in this run: the log was truncated with `: >` while the Inspector was running, +a further tool call was driven through the shim, and the Inspector rendered it normally +as `── #7` and `── #8`. Note that the sequence numbers **kept counting** — `seq` is +scoped to the Guardian process, not to the file, so truncating the log does not restart +it at `#1`. + +## Replaying a session already recorded + +```bash +bun run inspector -- --from-start +``` + +Instead of starting at the current end, this replays everything already in the file and +then follows. Captured after the truncation above, showing the two entries the file then +held — the request's JSON body is elided at the `...` for length; everything else is +verbatim: + +``` +Envelope Inspector — tailing .acs/envelopes.jsonl (from the start) +Ctrl-C to stop. + +── #7 20:45:19.619 → REQUEST steps/toolCallRequest id=5c50a105-3981-44ac-9622-87b13fd0622c +... +── #8 20:45:20.181 ← RESPONSE steps/toolCallRequest id=5c50a105-3981-44ac-9622-87b13fd0622c +○ ALLOW +``` + +Note the `--` before the flag: `bun run inspector` is a package script, so `--` is what +separates bun's own arguments from the Inspector's. + +## The same thing through a real Claude Code session + +Wire the hook in as V1's quickstart describes: + +```bash +mkdir -p .claude +cp hosts/claude-code/settings.json .claude/settings.json +``` + +**What was actually run to write this section, stated precisely:** this was verified +through the real `claude` CLI in **headless mode** (`claude -p '' --allowedTools Bash`), +not through an interactive TUI session. The interactive session was not run, so nothing +here describes the TUI's rendering of the block. What the headless run does exercise is +the identical hook path — Claude Code loading `.claude/settings.json`, spawning +`acs-hook.ts` as a real `PreToolUse` subprocess, and honouring the returned decision. + +The payload used was `echo rm -rf /`, not `rm -rf /`. The configured pattern is matched +against the raw command string with no argv parse, so `echo rm -rf /` is denied by the +same rule — at offset 5 instead of offset 0 — while being inert if it ever did execute. +That is the payload to use for any unattended run of this demo: an unattended `rm -rf /` +is only safe for as long as the hook works, which is the thing under test. + +Captured, from terminal 2, verbatim: + +``` +── #12 20:47:42.255 ← RESPONSE steps/toolCallRequest id=f36f4b0e-0711-4673-aad0-dd7b38781f39 +● DENY reason_codes=[destructive_shell_command_blocked] policy_references=[agt_stock#destructive_shell_command_blocked] +{ + "jsonrpc": "2.0", + "id": "f36f4b0e-0711-4673-aad0-dd7b38781f39", + "result": { + "type": "final", + "acs_version": "0.1.0", + "request_id": "f36f4b0e-0711-4673-aad0-dd7b38781f39", + "decision": "deny", + "reasoning": "matched pattern (?i)rm\\s+-[a-z]*r[a-z]*f[a-z]*\\s+/(?:\\s|$) at offset 5", + "reason_codes": [ + "destructive_shell_command_blocked" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "destructive_shell_command_blocked" + } + ] + } +} +``` + +A second headless invocation asking for `ls -la` produced `── #13` / `── #14 ○ ALLOW`, +and the command ran normally — the CLI printed the directory listing. (It was a separate +`claude -p` invocation, so a separate `session_id`, not a second turn in the same +session.) + +**Two things the real CLI shows that the piped payload does not.** + +First, the real `tool_input` carries more than `command`. The request envelope from the +live session included a second argument the hand-written payload never has: + +``` + "arguments": { + "command": { + "value": "echo rm -rf /" + }, + "description": { + "value": "Print the literal string \"rm -rf /\"" + } + } +``` + +The hookmap maps every key of `tool_input` into `arguments`, so whatever Claude Code +sends is what appears on the wire. Watching the Inspector is the cheapest way to find +out what a host actually sends, as opposed to what its docs say it sends. + +Second, and worth knowing before you demo this live: **the agent may decline before any +hook fires.** On the first attempt the model refused to issue the Bash tool call at all, +reasoning about the payload on its own. The envelope log stayed at zero bytes — no hook, +no envelope, nothing for the Inspector to show. This is the same trap V1's runbook warns +about when it says to ask directly. Give the model the context that a governance hook is +under test and that the deny is the expected outcome, and it issues the call. If the +Inspector shows nothing at all during a live demo, check this before you check the wiring. + +## If the Inspector shows nothing + +- **The Guardian is not running, or is on another port.** The shim writes an error to + stderr and exits 1, and no envelope is ever written. V1's runbook covers this failure + in full; V2 does not change it. `N6`/`N7`, the considered fail-open/fail-closed + posture, are V3. +- **The agent never issued the tool call.** See the section above. Check the log's size: + `wc -c .acs/envelopes.jsonl`. +- **The Inspector started after the entries were written.** It starts at the current end + by default. Use `--from-start`. +- **The Guardian was constructed without a tap.** Only `packages/guardian/src/main.ts` + passes `envelopeLogPath`; a Guardian started in-process by a test does not tap unless + it asks to. +- **The tap disabled itself.** A write failure disables the tap for the process lifetime + and reports once on stderr — `envelope tap disabled after failure (): `. + It never propagates and never alters a decision: the tap is total by construction, and + the end-to-end test asserts `rm -rf /` is still denied when every tap write fails. + +## Cleaning up + +`.acs/` and `.claude/settings.json` are local artifacts. This repo never deletes +recursively: + +```bash +rm .claude/settings.json && rmdir .claude +rm .acs/envelopes.jsonl && rmdir .acs +``` diff --git a/slices/v2/README.md b/slices/v2/README.md index 8dfc9788..8bd05a87 100644 --- a/slices/v2/README.md +++ b/slices/v2/README.md @@ -6,4 +6,55 @@ **Affordances:** U20, U21, N26, N50, S6 — defined in [Detail C](../../docs/shaping/acs-reference-impl-shaping.md#detail-c-affordances). -Implementation goes here. +## What this slice delivers + +The Guardian taps every ACS envelope crossing its wire into a JSONL log +(`packages/guardian/src/envelope-tap.ts` → `.acs/envelopes.jsonl`, S6/N26), and +`bun run inspector` (`packages/inspector`) tails that log and renders each entry live: +a header line, a decision badge for responses, then the envelope as pretty JSON +(U20/U21, N50). The demo is a third terminal beside `bun run guardian` and the agent +host. [`docs/demos/v2-runbook.md`](../../docs/demos/v2-runbook.md) walks it through with +the real captured output for a deny, an allow, a schema-invalid envelope, and an +unparseable body. + +Three properties make this worth more than a log viewer: + +- **The tap is total by construction.** `writeEnvelopeTap` sits on the decision path. A + write failure disables the tap for the process lifetime, reports once, and never + propagates — an observability feature must not be able to turn a governed tool call + into an ungoverned one. `packages/guardian/test/envelope-tap-wiring.test.ts` asserts + exactly that end to end: *"still denies `rm -rf /` when every tap write fails"*. +- **The request is tapped before validation.** An envelope that fails the schema is the + most useful thing an ACS-first reader can see, and it is exactly what disappears if + the tap sits behind the validator. R5.1 says *every* hook firing. +- **The Inspector imports nothing from the Guardian.** It re-declares `TapEntry` rather + than importing it, so "inspectable on the wire" is a claim about the file rather than + about our own type graph — a third-party reader of S6 has only the file, and so does + this one. Two gates in [`test/invariants.test.ts`](../../test/invariants.test.ts) + enforce it: zero AGT vocabulary and zero host vocabulary in + `packages/inspector/src` (R5.2), and no import of `guardian` or `agt-bridge` (R5.1). + The duplication is kept honest by the round-trip contract test, which exercises both + real implementations against one file. + +## What is explicitly not in this slice + +- **A schema-invalid envelope surfaces as a JSON-RPC error, not a `deny` decision.** + The Inspector renders `✖ ERROR -32010`. `N27 denyOnInvalidEnvelope()` — the affordance + that turns Guardian-side schema and bridge failures into honoured ACS `deny` + **decisions** — is **V3**, alongside `N6 applyFailurePosture()` and + `N7 validateDecision()`. +- **U23, the posture badge**, and **N51 `tailAuditSinks()`** — the negotiated + `on_decision_failure` and the count of audited fail-open proceeds — are **V3**. There + is no considered fail-open/fail-closed posture in this tree yet. +- **U22, the session chain view** — SessionContext entries and lineage — is **V6**, + which is where session state and provenance carriage land. +- **No log rotation.** S6 grows without bound. Accepted for V2 and recorded in the + slices doc; `: > .acs/envelopes.jsonl` truncates it safely mid-run because the tail + resets on truncation. + +`.acs/envelopes.jsonl` records the wire verbatim — no reformatting, stripping, +redaction, or reordering — so it carries raw tool arguments. It is gitignored for that +reason and is never committed. + +The implementation plan this slice followed, task by task, is +[`docs/superpowers/plans/2026-08-09-v2-envelope-inspector.md`](../../docs/superpowers/plans/2026-08-09-v2-envelope-inspector.md). diff --git a/test/invariants.test.ts b/test/invariants.test.ts index 9a17ff13..86253ddb 100644 --- a/test/invariants.test.ts +++ b/test/invariants.test.ts @@ -118,4 +118,42 @@ describe("architectural invariants", () => { "stdin", ]); }); + + /** + * R5.2 -- "an ACS-first reader can trace one action end to end without + * reading AGT source". The Inspector is that reader's tool, so the claim + * is only real if the tool itself knows nothing about AGT and nothing + * about any particular host: it renders ACS envelopes as data. Both term + * lists from the two gates above apply to it at once. + */ + it("the Envelope Inspector's source contains zero AGT vocabulary and zero host vocabulary", () => { + assertNoVocabulary("packages/inspector/src", [ + "agt", + "AgentControl", + "rego", + "opa", + "intervention_point", + "verdict", + "claude", + "opencode", + "hookSpecificOutput", + "permissionDecision", + "stdin", + ]); + }); + + /** + * R5.1 -- envelopes are inspectable *on the wire*. If the Inspector + * imported the Guardian's types, "inspectable" would be a claim about our + * own type graph instead: any third-party reader of S6 has only the file. + * So does this one. + */ + it("the Envelope Inspector imports nothing from the Guardian or the AGT bridge", () => { + for (const { file, code } of readSourceFiles("packages/inspector/src")) { + for (const spec of ["guardian", "agt-bridge"]) { + const found = new RegExp(`from\\s+["'][^"']*${spec}[^"']*["']`).test(code); + expect({ file, spec, found }).toEqual({ file, spec, found: false }); + } + } + }); }); From fa51e4afeb064f45b38748230ff91f62b2a0c07a Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Mon, 10 Aug 2026 00:50:39 +0300 Subject: [PATCH 12/35] Catch every throw out of dispatch, so no request can exit untapped handleAcsRequest had no try around `await dispatch(...)`. dispatch rethrows any non-EnvelopeValidationError, and that rethrow is live rather than theoretical: validateEnvelope builds its Ajv registry lazily, on the first request instead of at boot, so a tree cloned without --recurse-submodules boots `bun run guardian` cleanly and then turns every request into a Bun.serve `text/html` 500. That is a fail-open of the shape V1 shipped three times. guardian-client calls res.json() unconditionally, so an HTML body throws `JSON Parse error: Unrecognized token '<'`; acs-hook.ts's catch-all exits 1 with empty stdout; Claude Code reads that as "the hook didn't fire" and the tool call proceeds ungoverned. It was also the exit S6 did not cover -- the client received a response the envelope log had no line for at all. The outer net returns a well-formed JSON-RPC error in the ACS-reserved band and puts it back on the tapped path. It is deliberately not an ACS `deny`: N27 stays V3's call. The inner catch around assembleSnapshot / bridge.evaluate / mapVerdict is unchanged. The covering test forces a real throw rather than asserting on a mock. It copies packages/guardian/src one directory deeper, where the schema path resolves to packages/spec/... and does not exist, so buildAjv throws a genuine ENOENT at request time through the real, current source. Without the fix all three of its cases fail; the first reports the HTML body it got. Also corrects the return-site count the comment used to justify this structure: dispatch leaves by six routes, not four, so N27 adds a seventh. Slice: #3 --- .gitignore | 9 +- packages/guardian/src/server.ts | 66 +++++++++---- packages/guardian/test/server.test.ts | 130 +++++++++++++++++++++++++- 3 files changed, 184 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index be21f7df..1979b80f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,12 @@ __pycache__/ .superpowers/ # S6, the ACS envelope log (N26). Local demo artifact; carries raw tool -# arguments verbatim, so it is never committed. +# arguments unmodified, so it is never committed. .acs/ + +# Scratch tree for packages/guardian/test/server.test.ts's outer-net test: an +# identical copy of packages/guardian/src, placed one directory deeper so its +# schema path resolves to nothing. The test removes it in a `finally`; this +# line only covers a run killed mid-test, so a stray copy of the Guardian's +# source can never be committed as if it were source. +packages/guardian/tmp-schemaless-*/ diff --git a/packages/guardian/src/server.ts b/packages/guardian/src/server.ts index 2ecbd695..43af06b7 100644 --- a/packages/guardian/src/server.ts +++ b/packages/guardian/src/server.ts @@ -8,18 +8,28 @@ * assemblePreToolCallSnapshot -> bridge.evaluate at the intervention point * resolveInterventionPoint picked -> mapVerdict -> response envelope. * - * 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. + * Every throw on this path is caught, in two places, because nothing may + * escape the fetch handler. Bun.serve would answer an unhandled rejection with + * 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. * - * 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 two catches are: + * - Inside `dispatch`, around assemblePreToolCallSnapshot, bridge.evaluate + * and mapVerdict: the evaluation itself. + * - Around the whole `dispatch` call in `handleAcsRequest`, as the outer net. + * `dispatch` rethrows anything that is not an EnvelopeValidationError, and + * that rethrow is live: validateEnvelope builds its Ajv registry lazily, on + * the first request rather than at boot, so a tree cloned without + * `--recurse-submodules` starts cleanly and then turns every request into + * an HTML 500. The outer net also puts the failure response back on the + * tapped path, so the envelope log records it like any other response. + * + * Neither catch turns the failure into an ACS `deny` decision: which + * disposition a Guardian-side 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 @@ -87,9 +97,10 @@ const LOOPBACK_ONLY = "127.0.0.1"; */ const ENVELOPE_INVALID_CODE = -32010; const METHOD_NOT_DISPATCHED_CODE = -32011; -/** 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. */ +/** Any throw the Guardian did not turn into a response itself: mapVerdict's + * own require_policy_references check, an AGT runtime error, or -- through + * handleAcsRequest's outer net -- a failure to even build the schema registry. + * The module header explains why none of this may become dead code. */ const EVALUATION_FAILED_CODE = -32020; type JsonRpcSuccess = { jsonrpc: "2.0"; id: string | number; result: AcsFinalResult | ServerHello }; @@ -157,10 +168,17 @@ export async function startGuardian({ /** * Three phases, in order: parse, tap the request, dispatch, tap the - * response. The tap calls live here and only here -- `dispatch` below has - * four return sites and V3 adds a fifth (N27), so tapping inside it would - * make totality something a future task has to remember rather than - * something the structure guarantees. + * response. The tap calls live here and only here -- `dispatch` below leaves + * by six routes (five `return`s and one rethrow) and V3's N27 adds a + * seventh, so tapping inside it would make totality something a future task + * has to remember rather than something the structure guarantees. + * + * That guarantee is only as good as its coverage of the throwing route, and + * the whole-branch review's finding 1 found it uncovered: `dispatch`'s + * rethrow used to leave this function without a response at all, so the + * client got a Bun.serve HTML 500 that S6 never recorded. The try/catch + * below closes it -- every route out of `dispatch` now produces a response + * object, and every response object gets tapped. * * The tap itself is total (see envelope-tap.ts): these two calls cannot * throw, so they cannot turn a governed tool call into an ungoverned one. @@ -191,7 +209,17 @@ async function handleAcsRequest( const method = extractMethod(raw); tap.write("request", raw, method); - const response = await dispatch(raw, bridge, mapping); + let response: JsonRpcSuccess | JsonRpcFailure; + try { + response = await dispatch(raw, bridge, mapping); + } catch (error) { + // The outer net (whole-branch review, finding 1). Deliberately a bare + // JSON-RPC error, not an ACS `deny`: N27 stays V3's call. What this + // buys is that the client can parse the answer at all, and that S6 + // holds a response line paired with the request line above it. + const message = error instanceof Error ? error.message : String(error); + response = errorResponse(extractId(raw), EVALUATION_FAILED_CODE, `guardian failed to handle the request: ${message}`); + } tap.write("response", response, method); return response; } diff --git a/packages/guardian/test/server.test.ts b/packages/guardian/test/server.test.ts index 202f2c9a..ce214046 100644 --- a/packages/guardian/test/server.test.ts +++ b/packages/guardian/test/server.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, beforeAll, afterAll } from "bun:test"; -import { readFileSync } from "node:fs"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmdirSync, unlinkSync } 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 { startGuardian } from "../src/index.ts"; @@ -184,6 +186,132 @@ describe("startGuardian POST /acs -- evaluation failure inside handleAcsRequest" }); }); +const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url)); +const GUARDIAN_PKG = join(REPO_ROOT, "packages", "guardian"); + +/** + * Runs `body` against a Guardian whose `validate-envelope.ts` cannot find the + * ACS schemas -- the tree-cloned-without-`--recurse-submodules` case, which is + * what makes the whole-branch review's finding 1 reachable rather than + * theoretical. + * + * It is reproduced by *relocation*, not by mocking and not by touching + * `spec/`. `validate-envelope.ts` derives SCHEMA_ROOT from its own + * `import.meta.url` as `../../../spec/specification/...`, so an identical copy + * of `packages/guardian/src` placed one directory deeper resolves that path to + * `packages/spec/...`, which does not exist. Every line of Guardian code that + * then runs is the real, current source -- the files are copied at test time, + * so they cannot drift from `src/` -- and the failure it produces is a real + * ENOENT out of `readdirSync`, thrown at request time because `buildAjv()` is + * lazy. The copy also gets its own module instance, so it cannot poison the + * Ajv registry the rest of this suite shares. + * + * Deletions here are explicit per file (repo constraint: nothing recursive). + */ +async function withSchemalessGuardian( + body: (guardian: { url: string; logPath: string }) => Promise, +): Promise { + const root = mkdtempSync(join(GUARDIAN_PKG, "tmp-schemaless-")); + const srcDir = join(root, "src"); + const logPath = join(root, "envelopes.jsonl"); + const copied = readdirSync(join(GUARDIAN_PKG, "src")).filter((f) => f.endsWith(".ts")); + let guardian: { close(): Promise } | undefined; + + // Everything after mkdtempSync is inside the try: a failure while copying, + // importing, or booting would otherwise leave a directory of stray .ts + // files sitting inside packages/guardian. + try { + mkdirSync(srcDir); + for (const file of copied) { + copyFileSync(join(GUARDIAN_PKG, "src", file), join(srcDir, file)); + } + + const relocated = (await import(join(srcDir, "index.ts"))) as typeof import("../src/index.ts"); + const started = await relocated.startGuardian({ + port: 0, + manifestPath: join(REPO_ROOT, "policy", "manifest.yaml"), + mappingPath: join(REPO_ROOT, "mapping.yaml"), + envelopeLogPath: logPath, + }); + guardian = started; + + await body({ url: started.url, logPath }); + } finally { + await guardian?.close(); + for (const path of [logPath, ...copied.map((file) => join(srcDir, file))]) { + try { + unlinkSync(path); + } catch { + // a run that failed early never created every one of these + } + } + for (const dir of [srcDir, root]) { + try { + rmdirSync(dir); + } catch { + // same + } + } + } +} + +// Whole-branch review, finding 1 -- the fourth fail-open of V1's shape, and +// the exit the tap's structural-totality claim did not cover. `dispatch` +// rethrows any non-EnvelopeValidationError, and nothing used to catch it: +// Bun.serve answers a rejecting fetch() handler with a `text/html` 500, +// guardian-client's unconditional `res.json()` throws `JSON Parse error: +// Unrecognized token '<'`, acs-hook.ts's catch-all exits 1 with empty stdout, +// and Claude Code reads that as "the hook didn't fire" -- the tool call +// proceeds ungoverned. S6 recorded the request and nothing else, so the +// Inspector could not even show that a response had been sent. +describe("startGuardian POST /acs -- the outer net around dispatch", () => { + it("answers a throw from validateEnvelope itself with parseable JSON-RPC in -32000..-32099, never an HTML 500", async () => { + await withSchemalessGuardian(async ({ url }) => { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(toolCallEnvelope("ls -la", { id: 5 })), + }); + + // Read as text first and parse by hand, so a regression reports the + // HTML body it actually got instead of an opaque SyntaxError from + // res.json() -- which is precisely what guardian-client would throw. + const text = await res.text(); + expect(text.slice(0, 1)).toBe("{"); + const response = JSON.parse(text) as JsonRpcResponse; + + expect(response.jsonrpc).toBe("2.0"); + expect(response.id).toBe(5); + expect(response.error).toBeDefined(); + expect(response.error?.code).toBeGreaterThanOrEqual(-32099); + expect(response.error?.code).toBeLessThanOrEqual(-32000); + }); + }); + + it("carries no decision -- N27 is V3, so a Guardian-side failure is an error, not a synthesized deny", async () => { + await withSchemalessGuardian(async ({ url }) => { + const response = await postAcs(url, toolCallEnvelope("rm -rf /")); + + expect(response.result).toBeUndefined(); + expect((response as Record).decision).toBeUndefined(); + expect(JSON.stringify(response)).not.toContain("deny"); + }); + }); + + it("taps both the request and the response, so S6 has no untapped exit", async () => { + await withSchemalessGuardian(async ({ url, logPath }) => { + await postAcs(url, toolCallEnvelope("ls -la", { id: 11 })); + + const lines = readFileSync(logPath, "utf8").trim().split("\n"); + const entries = lines.map((line) => JSON.parse(line) as { direction: string; rpc_id: unknown }); + expect(entries.map((e) => e.direction)).toEqual(["request", "response"]); + // Paired by JSON-RPC id (decision P4), which is what lets the Inspector + // show the failure beside the request that caused it. + expect(entries.map((e) => e.rpc_id)).toEqual([11, 11]); + }); + }); +}); + // 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 From 73bc2662b4739e2590a333dfe831da914b21e0c2 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Mon, 10 Aug 2026 00:50:53 +0300 Subject: [PATCH 13/35] Stop a throwing malformed-line reporter from stranding parsed entries `if (added) wake?.()` sat inside the scan's try, after onMalformedLine was called. A reporter that threw skipped the wake-up, so an entry already pushed to `ready` sat undelivered with no further writes -- arriving only when an unrelated append, or the abort itself, incidentally fired `wake`. Hoisted into a finally. The throw also abandoned the rest of the batch. `offset` is already at `size` by then, so every complete line still sitting in `pending` was never re-scanned: no later tick had anything new to read. onMalformedLine now gets its own try/catch at its call site, so reporting one bad line cannot cost the good ones behind it. Both tests fail against the previous code. The first races tail.next() against a timer rather than reading through `collect`, because collect's own deadline aborts -- and stop() fires wake?.() on the way out, which is exactly the incidental rescue that made this look benign. poll()'s outer try/catch was carried through the task loop as "possibly unreachable, definitely untested". It was reachable, but via onMalformedLine -- which is the bug above, not a feature -- and that route is now closed. Its one remaining route, a failed read, is covered by a new test that points the tail at a directory: existsSync is true, st_size is non-zero, and readSync throws EISDIR every tick. Deterministic, no race. Removing the catch makes that throw escape a bare timer callback and take neighbouring tests with it. Slice: #3 --- packages/inspector/src/tail-envelope-log.ts | 75 +++++++++---- .../inspector/test/tail-envelope-log.test.ts | 100 +++++++++++++++++- 2 files changed, 155 insertions(+), 20 deletions(-) diff --git a/packages/inspector/src/tail-envelope-log.ts b/packages/inspector/src/tail-envelope-log.ts index 8ab3bbc6..2afd8dc4 100644 --- a/packages/inspector/src/tail-envelope-log.ts +++ b/packages/inspector/src/tail-envelope-log.ts @@ -145,28 +145,43 @@ export function tailEnvelopeLog({ offset = size; let added = false; - let newline = pending.indexOf(NEWLINE); - while (newline !== -1) { - const line = pending.subarray(0, newline).toString("utf8"); - pending = pending.subarray(newline + 1); - newline = pending.indexOf(NEWLINE); + try { + let newline = pending.indexOf(NEWLINE); + while (newline !== -1) { + const line = pending.subarray(0, newline).toString("utf8"); + pending = pending.subarray(newline + 1); + newline = pending.indexOf(NEWLINE); - if (line.trim() === "") { - continue; - } - try { - const parsed: unknown = JSON.parse(line); - if (!isTapEntryShape(parsed)) { - throw new Error("line parsed as JSON but does not match the TapEntry shape"); + if (line.trim() === "") { + continue; + } + try { + const parsed: unknown = JSON.parse(line); + if (!isTapEntryShape(parsed)) { + throw new Error("line parsed as JSON but does not match the TapEntry shape"); + } + ready.push(parsed); + added = true; + } catch (error) { + // `onMalformedLine` is caller-supplied and may throw. Guarded + // here rather than left to poll()'s outer catch, which would + // abandon the rest of this batch: `offset` is already at + // `size`, so any complete line still sitting in `pending` + // would never be re-scanned -- no later tick has anything new + // to read. Reporting one bad line must not cost the good ones + // behind it (whole-branch review, finding 5). + reportMalformedLine(onMalformedLine, line, error); } - ready.push(parsed); - added = true; - } catch (error) { - onMalformedLine(line, error); } - } - if (added) { - wake?.(); + } finally { + // In the `finally`, not after the loop: an entry already pushed to + // `ready` must reach the consumer even if the scan above left by a + // throw. It used to sit undelivered until some unrelated write -- + // or the abort -- happened to fire `wake` (whole-branch review, + // finding 5). + if (added) { + wake?.(); + } } } } catch (error) { @@ -177,6 +192,12 @@ export function tailEnvelopeLog({ // the whole process down. Skip this tick instead: the next one's // sizeOf() sees the gap (or the file's return) and resyncs on its // own. + // + // The only route here is a failed read: a caller-supplied + // `onMalformedLine` is guarded at its own call site (finding 5), so it + // no longer reaches this catch and no longer abandons the rest of a + // batch. The tail-envelope-log tests cover this branch through a + // deterministic EISDIR rather than through a lost race. warnPollError(error); } } @@ -250,6 +271,22 @@ function readRange(path: string, offset: number, length: number): Buffer { } } +/** Calls the caller's malformed-line reporter without letting it break the + * scan. A reporter that throws gets one warning of its own; the line it was + * reporting is still skipped, and the lines after it are still parsed. */ +function reportMalformedLine( + onMalformedLine: (line: string, error: unknown) => void, + line: string, + error: unknown, +): void { + try { + onMalformedLine(line, error); + } catch (callbackError) { + const message = callbackError instanceof Error ? callbackError.message : String(callbackError); + console.error(`onMalformedLine threw while reporting an unparseable envelope-log line (${message})`); + } +} + function warnMalformedLine(line: string, error: unknown): void { const message = error instanceof Error ? error.message : String(error); console.error(`skipping unparseable envelope-log line (${message}): ${line.slice(0, 120)}`); diff --git a/packages/inspector/test/tail-envelope-log.test.ts b/packages/inspector/test/tail-envelope-log.test.ts index 4d196bab..e7233030 100644 --- a/packages/inspector/test/tail-envelope-log.test.ts +++ b/packages/inspector/test/tail-envelope-log.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { appendFileSync, mkdtempSync, rmdirSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { appendFileSync, mkdirSync, mkdtempSync, rmdirSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { tailEnvelopeLog, type TapEntry } from "../src/tail-envelope-log.ts"; @@ -305,6 +305,104 @@ describe("tailEnvelopeLog (N50)", () => { }); }); + // Whole-branch review, finding 5. `if (added) wake?.()` used to sit after + // `onMalformedLine(...)` inside the same `try`, so a reporter that threw + // skipped the wake-up entirely: an entry already pushed to `ready` sat + // undelivered until some later write -- or the abort -- happened to fire + // `wake` for an unrelated reason. Reproduced at 300ms of silence before the + // fix. The good line here is written BEFORE the bad one so that `added` is + // already true when the throw happens. + it("delivers an entry parsed before a throwing onMalformedLine, without waiting for another write", async () => { + await withTempDir(async (_dir, path) => { + let calls = 0; + writeFileSync(path, entryLine(1, "request") + "{not json\n"); + const controller = new AbortController(); + const tail = tailEnvelopeLog({ + path, + fromStart: true, + pollMs: POLL_MS, + signal: controller.signal, + onMalformedLine: () => { + calls += 1; + throw new Error("reporter blew up"); + }, + }); + + // Raced against a timer rather than read through `collect`, because + // `collect`'s own deadline calls `controller.abort()` -- and `stop()` + // fires `wake?.()` on the way out, which is exactly the incidental + // rescue that made this bug look benign. No further appends and no + // abort inside the window: the only thing that can deliver entry #1 + // is the wake-up the poll itself owes the drain loop. + const first = tail.next(); + const outcome = await Promise.race([ + first.then(({ value }) => (value === undefined ? "ended" : `seq:${value.seq}`)), + Bun.sleep(POLL_MS * 20).then(() => "stranded" as const), + ]); + controller.abort(); + await first; + + expect(outcome).toBe("seq:1"); + expect(calls).toBe(1); + }); + }); + + // The other half of finding 5: `offset` is already at `size` by the time a + // line is scanned, so a batch abandoned mid-scan strands every complete + // line still in `pending` -- no later tick has anything new to read, and + // they are never re-scanned. Guarding the reporter at its own call site is + // what keeps the scan going. + it("keeps parsing the lines behind a bad one when onMalformedLine throws", async () => { + await withTempDir(async (_dir, path) => { + writeFileSync(path, "{not json\n" + entryLine(2, "response") + entryLine(3, "request")); + const controller = new AbortController(); + const tail = tailEnvelopeLog({ + path, + fromStart: true, + pollMs: POLL_MS, + signal: controller.signal, + onMalformedLine: () => { + throw new Error("reporter blew up"); + }, + }); + + const entries = await collect(tail, 2, controller, 1000); + expect(entries.map((e) => e.seq)).toEqual([2, 3]); + }); + }); + + // `poll()`'s outer try/catch was carried through the task loop as "possibly + // unreachable, definitely untested". It is reachable, and it does not need + // a lost race to get there: pointing the tail at a directory makes + // `existsSync` true and `statSync().size` non-zero, so the read is + // attempted and `readSync` throws EISDIR every tick. Without the catch, + // that throw leaves a bare timer callback and takes the process down. + it("survives a read that throws every tick, and resyncs once the path becomes a real file", async () => { + await withTempDir(async (_dir, path) => { + const asDirectory = `${path}.d`; + mkdirSync(asDirectory); + // Non-zero st_size for a directory, so `size > offset` and the read is + // actually attempted rather than skipped. + writeFileSync(join(asDirectory, "child"), "x"); + + const controller = new AbortController(); + const tail = tailEnvelopeLog({ path: asDirectory, fromStart: true, pollMs: POLL_MS, signal: controller.signal }); + const collecting = collect(tail, 1, controller, 2000); + + // Several ticks against the unreadable path. The process is still + // alive on the other side of this sleep, which is the assertion. + await Bun.sleep(POLL_MS * 5); + + unlinkSync(join(asDirectory, "child")); + rmdirSync(asDirectory); + writeFileSync(asDirectory, entryLine(6, "response")); + + const entries = await collecting; + expect(entries.map((e) => e.seq)).toEqual([6]); + unlinkSync(asDirectory); + }); + }); + it("reports a line that parses as JSON but is missing direction, and keeps streaming", async () => { await withTempDir(async (_dir, path) => { const malformed: string[] = []; From dd368d3ea624d37d8eece6cf094a94f44e5e4253 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Mon, 10 Aug 2026 00:51:05 +0300 Subject: [PATCH 14/35] Widen the import gate, and stop all four gates passing vacuously The R5.1 import gate matched `from "..."` alone -- the one form nobody reaching for a forbidden import by accident would use. Verified against the real gate, by introducing each form into packages/inspector/src/index.ts: the old regex caught `from "guardian"` and missed `import "guardian"`, `await import("guardian")`, the type-position `import("guardian").TapEntry`, `require("guardian")`, and `from "Guardian"` -- which resolves on macOS. The widened matcher catches all six, plus the agt-bridge forms, and stays quiet on code that merely mentions the word. Two unit tests pin each form so a later simplification of the regex cannot quietly reopen one. Type position matters as much as value position here: `import("guardian").T` is erased at build time but is still a compile-time dependency on the Guardian's type graph, which is precisely what R5.1 forbids. readSourceFiles now asserts it found something. A renamed directory already failed loudly -- Glob.scanSync throws ENOENT -- but a directory that still existed with no non-test .ts under it passed with zero assertions. Proved by moving the Inspector's sources into a nested test/ directory: with the check both Inspector gates fail; without it all six tests pass, and the expect() count drops from 99 to 43. Slice: #3 --- test/invariants.test.ts | 76 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 5 deletions(-) diff --git a/test/invariants.test.ts b/test/invariants.test.ts index 86253ddb..e912a1e9 100644 --- a/test/invariants.test.ts +++ b/test/invariants.test.ts @@ -22,11 +22,20 @@ function stripComments(src: string): string { return noBlockComments.replace(/(? !f.includes("/test/")) - .map((f) => ({ file: f, code: stripComments(readFileSync(`${dir}/${f}`, "utf8")) })); + const files = [...new Glob("**/*.ts").scanSync(dir)].filter((f) => !f.includes("/test/")); + expect({ dir, sourceFiles: files.length > 0 }).toEqual({ dir, sourceFiles: true }); + return files.map((f) => ({ file: f, code: stripComments(readFileSync(`${dir}/${f}`, "utf8")) })); } /** @@ -151,9 +160,66 @@ describe("architectural invariants", () => { it("the Envelope Inspector imports nothing from the Guardian or the AGT bridge", () => { for (const { file, code } of readSourceFiles("packages/inspector/src")) { for (const spec of ["guardian", "agt-bridge"]) { - const found = new RegExp(`from\\s+["'][^"']*${spec}[^"']*["']`).test(code); + const found = importsSpecifier(code, spec); expect({ file, spec, found }).toEqual({ file, spec, found: false }); } } }); }); + +/** + * True when `code` names a module specifier containing `spec` in any position + * that actually creates a dependency on it. + * + * The original gate matched `from "…"` alone (whole-branch review, finding + * 6), which is the one form nobody reaching for a forbidden import by + * accident would use. Each alternative below is a real hole it left: + * + * from "guardian" the static named/default import + * import "guardian" the bare side-effect import, no `from` + * import("guardian") dynamic, and `await import("guardian")` + * import("guardian").TapEntry type position -- erased at build, still a + * compile-time dependency on the Guardian's + * type graph, which is exactly what R5.1 + * forbids + * require("guardian") CJS interop + * + * `\(?` covers the parenthesised and unparenthesised forms in one pass, and + * the `i` flag closes the last hole: module resolution is case-insensitive on + * macOS, so `from "Guardian"` resolves here and the case-sensitive gate said + * nothing about it. + */ +function importsSpecifier(code: string, spec: string): boolean { + return new RegExp(`(?:from|import|require)\\s*\\(?\\s*["'][^"']*${spec}[^"']*["']`, "i").test(code); +} + +describe("the import gate itself", () => { + /** + * A gate is only worth having if it bites. These are the exact forms the + * finding listed as blind spots, asserted directly against the matcher so + * a future simplification of the regex cannot quietly reopen one of them. + */ + it("catches every import form, in any case", () => { + const caught = [ + 'import { TapEntry } from "guardian";', + 'import "guardian";', + 'const g = await import("guardian");', + 'type E = import("guardian").TapEntry;', + 'const g = require("guardian");', + 'import { TapEntry } from "Guardian";', + 'export { x } from "../../guardian/src/index.ts";', + ].map((line) => ({ line, found: importsSpecifier(line, "guardian") })); + + expect(caught).toEqual(caught.map(({ line }) => ({ line, found: true }))); + }); + + it("stays quiet on code that merely mentions the word", () => { + const ignored = [ + 'const label = "guardian";', + "const guardian = startGuardian();", + 'import { renderEntry } from "./render.ts";', + ].map((line) => ({ line, found: importsSpecifier(line, "guardian") })); + + expect(ignored).toEqual(ignored.map(({ line }) => ({ line, found: false }))); + }); +}); From 8099e15fd1baea3a3f8508e45e15f1311b5d5d84 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Mon, 10 Aug 2026 00:51:24 +0300 Subject: [PATCH 15/35] Say what S6 actually records, everywhere the tree overstated it "The same bytes that crossed the wire" was not true. The tap receives `await req.json()`, so it stores the JSON value the Guardian parsed, not the request's bytes: the parse collapses duplicate keys, canonicalises number literals (1.0 -> 1, 1e2 -> 100), and hoists integer-like object keys ahead of the rest. Tool argument names are host-controlled, so an `arguments` object carrying both "0" and "a" is a real shape, not a hypothetical. Corrected in wording, not in code. Storing raw bytes would make the entry's `envelope` field a string rather than a JSON value, which breaks the Inspector's pretty-printing and the round-trip contract test -- a worse trade than an accurate sentence. What stays true, and is now what the tree claims: no field stripping, no redaction, no reordering of anything we control. The over-promise originated in the plan's global constraint 11 and propagated verbatim, so it is fixed at the source as well as at each of its copies: render.ts, the runbook, the slice README, the shaping doc's S6 row, the slices doc's V2 watch-for, and two test titles whose assertions were always about value equality rather than byte identity (assertions unchanged; only the titles were overstating them). Also in this commit: - The Quickstart heading claimed "one command on a laptop" above five steps across three terminals. R7.1's own wording is "starts with one command", and "starts with" carries the whole claim. - renderEntry narrowed its JSON.stringify. It returns undefined for an entry with no `envelope` key -- which isTapEntryShape permits, by design -- and Array.join coerced that to a silent blank body. - Slices-doc risk row 10: the tap's two synchronous appendFileSync calls per request sit on the decision path and Bun.serve is single-threaded, so a slow filesystem blocks every in-flight request, not only the one being tapped. Correct for demo scale; recorded, not fixed. - README's Verify count, from the real number: 121 tests across 16 files. Slice: #3 --- README.md | 6 ++-- docs/demos/v2-runbook.md | 29 ++++++++++++----- docs/shaping/acs-reference-impl-shaping.md | 2 +- docs/shaping/acs-reference-impl-slices.md | 5 ++- .../plans/2026-08-09-v2-envelope-inspector.md | 8 ++++- packages/guardian/src/envelope-tap.ts | 32 +++++++++++++------ packages/guardian/test/envelope-tap.test.ts | 6 +++- packages/inspector/src/render.ts | 25 +++++++++++++-- packages/inspector/test/render.test.ts | 28 +++++++++++++++- slices/v2/README.md | 16 ++++++++-- 10 files changed, 125 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 6a073f82..bea477b9 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ 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) +## Quickstart (R7.1 — starts with 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`. @@ -88,7 +88,7 @@ Every ACS envelope crossing the Guardian's wire is printed here as it happens ● DENY reason_codes=[destructive_shell_command_blocked] policy_references=[agt_stock#destructive_shell_command_blocked] ``` -`bun run inspector -- --from-start` replays a session already recorded. **`.acs/envelopes.jsonl` records the wire verbatim, so it carries raw tool arguments** — it is gitignored for that reason and never committed. Full walkthrough, with the real captured output for a deny, an allow, and a schema-invalid envelope: [`docs/demos/v2-runbook.md`](docs/demos/v2-runbook.md). +`bun run inspector -- --from-start` replays a session already recorded. **`.acs/envelopes.jsonl` records each envelope the Guardian parsed, unmodified: nothing stripped, nothing redacted. So it carries raw tool arguments** — it is gitignored for that reason and never committed. Full walkthrough, with the real captured output for a deny, an allow, and a schema-invalid envelope: [`docs/demos/v2-runbook.md`](docs/demos/v2-runbook.md). **4. Wire the hook into Claude Code.** @@ -124,7 +124,7 @@ Second, through the real `claude` CLI with `.claude/settings.json` installed — ### Verify ```bash -bun test # 112 tests across 16 files (111 pass, 1 skip), including the R3.2/R3.3 +bun test # 121 tests across 16 files (120 pass, 1 skip), including the R3.2/R3.3 # and R5.1/R5.2 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/v2-runbook.md b/docs/demos/v2-runbook.md index 7a67996b..a0fa3a98 100644 --- a/docs/demos/v2-runbook.md +++ b/docs/demos/v2-runbook.md @@ -11,8 +11,10 @@ This runbook is written from a real run against this tree. Every block below mar 1. A third terminal, beside the Guardian and the agent host, prints every ACS envelope as it crosses the wire — request and response, both directions, one entry each. -2. The rendered JSON is the *same bytes that crossed the wire*, only re-indented. The - tap does no reformatting, no field stripping, no redaction, no reordering. +2. The rendered JSON is the envelope the Guardian parsed, printed unmodified and + re-indented. The tap strips no field, redacts nothing, and reorders nothing it + controls. It is not a byte-for-byte replay — see *S6 carries raw tool arguments* + below for what the JSON parse normalises before the tap ever sees it. 3. The decision badge makes the outcome legible without reading the JSON: `● DENY`, `○ ALLOW`, or `✖ ERROR`, with `reason_codes` and `policy_references` beside it. 4. Nothing in the Inspector knows what produced the decision. It imports nothing from @@ -299,12 +301,23 @@ rather than continuing the run above: `(no method)` and `(unpaired)` are what the renderer prints for `method: null` and `rpc_id: null`. Both are real states on this wire, so both are shown. -## S6 carries raw tool arguments verbatim - -`.acs/envelopes.jsonl` is the wire, recorded exactly: no reformatting, no field -stripping, no redaction, no reordering. Pretty-printing happens at render time only. An -inspector that showed something other than what was sent would be worse than no -inspector at all. +## S6 carries raw tool arguments + +`.acs/envelopes.jsonl` records the JSON value the Guardian parsed, unmodified: no field +stripping, no redaction, no reordering of anything we control. Pretty-printing happens at +render time only. An inspector that showed something other than what was sent would be +worse than no inspector at all. + +**One honest qualifier, added by the whole-branch review.** "Unmodified" is a claim about +what *we* do, not a claim of byte identity. The tap is handed `await req.json()`, so a +JSON parse has already happened: duplicate keys are collapsed to the last one, number +literals are canonicalised (`1.0` renders as `1`, `1e2` as `100`), and integer-like +object keys are hoisted ahead of the rest and sorted. Tool argument names come from the +host, so an `arguments` object containing both `"0"` and `"a"` is a real possibility +rather than a contrived one — and it would render with `"0"` first regardless of send +order. If you need the literal bytes a host sent, capture them at the transport; S6 is +the parsed envelope. What S6 does guarantee is that nothing between the parse and the +file removed, rewrote, or hid any part of it. The consequence is direct: **the log contains whatever your tool calls contained** — file paths, command lines, and anything else that rode along in `arguments`. That is why diff --git a/docs/shaping/acs-reference-impl-shaping.md b/docs/shaping/acs-reference-impl-shaping.md index 800ab81a..928d85cd 100644 --- a/docs/shaping/acs-reference-impl-shaping.md +++ b/docs/shaping/acs-reference-impl-shaping.md @@ -291,7 +291,7 @@ All resolved — see `spike-agt-integration.md`. | S3 | P3 | `sessionContext` | Hash-chained entries per `session_id` | | S4 | P3 | `intent` | Immutable Intent baseline per session | | S5 | P3 | `provenance` | `origin` / `derived_from` lineage, carrying AGT `result_labels` between steps | -| S6 | P3 | `envelope log` | JSONL of every request and response, verbatim, at `.acs/envelopes.jsonl` (gitignored — carries raw tool arguments). Paired by JSON-RPC `id` | +| S6 | P3 | `envelope log` | JSONL of every request and response as parsed, unmodified, at `.acs/envelopes.jsonl` (gitignored — carries raw tool arguments). Paired by JSON-RPC `id` | | S7 | P3.1 | `manifest.yaml` | Binds the `rego` policy to `data.agt.defaults.verdict`; declares intervention points, tools, approval | | S8 | P3.1 | `data.agt.defaults.config` | Thresholds, allowlists, pattern lists — the only place policy behaviour is authored | | S9 | P3.1 | AGT stock bundle | `policy/lib/*.rego` at the pinned ref. Requires the `opa` CLI on PATH | diff --git a/docs/shaping/acs-reference-impl-slices.md b/docs/shaping/acs-reference-impl-slices.md index 935f1b87..b5606087 100644 --- a/docs/shaping/acs-reference-impl-slices.md +++ b/docs/shaping/acs-reference-impl-slices.md @@ -97,7 +97,7 @@ Every slice ends in something demo-able. **⚠️ Watch-for — the tap must be total.** `writeEnvelopeTap` sits on the decision path. V1 shipped three separate fail-opens before they were caught (the `./` bundle landmine, `tool_unknown` failing closed, and an unhandled Guardian throw reaching the shim as an empty stdout); an observability feature that can turn a governed tool call into an ungoverned one would be the fourth. Every write is wrapped: a failure disables the tap for the process lifetime, reports once, and never propagates. V2 asserts this end to end — `rm -rf /` is still denied when every tap write fails. -**⚠️ Watch-for — S6 records the wire verbatim.** No reformatting, no field stripping, no redaction, no reordering; pretty-printing happens at render time only. An inspector that shows something other than what was sent is worse than none. The consequence is that S6 carries raw tool arguments, which is why `.acs/` is gitignored and why the runbook says so out loud. +**⚠️ Watch-for — S6 records the parsed envelope, unmodified.** No field stripping, no redaction, no reordering of anything we control; pretty-printing happens at render time only. An inspector that shows something other than what was sent is worse than none. The consequence is that S6 carries raw tool arguments, which is why `.acs/` is gitignored and why the runbook says so out loud. **Corrected by V2's whole-branch review:** this watch-for originally said "records the wire verbatim", and so did the plan's global constraint 11, the slice README, the runbook, and the Inspector's own renderer comment. The tap is handed `await req.json()`, so it stores a JSON *value*, not bytes — the parse collapses duplicate keys, canonicalises number literals, and hoists integer-like object keys, and `arguments` keys are host-controlled. Storing bytes instead would make `envelope` a string rather than JSON, costing the Inspector its pretty-printing and the round-trip contract test its subject. The wording was corrected everywhere rather than the code. **⚠️ Watch-for — a schema failure appears as an error, not a decision.** In V2 an invalid envelope is tapped (P5) and then answered with a JSON-RPC error, so the Inspector renders `✖ ERROR -32010`, not a badge. `N27 denyOnInvalidEnvelope()` — the affordance that turns Guardian-side failures into honoured ACS `deny` **decisions** — is V3. The Inspector is where that change will become visible. @@ -247,6 +247,7 @@ Runs on a schedule in CI. MS-ACS is `0.3.1-beta` and warns of breaking changes b | 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 | | 9 | ⚠️ S6 grows unbounded — no rotation and no size cap | V2 | Accepted. It is a gitignored local demo artifact; `: > .acs/envelopes.jsonl` truncates it safely mid-run because `tailEnvelopeLog` resets on truncation. Rotation is not built, and the runbook says so | +| 10 | ⚠️ The tap's two synchronous `appendFileSync` calls per request sit **on the decision path**, and `Bun.serve` is single-threaded | V2 | Accepted, and correct for demo scale. Surfaced by V2's whole-branch review as the neighbour of row 9: a slow filesystem (a stalled network mount, a full disk) blocks *every* in-flight request, not only the one being tapped, because there is no second thread to run them on. No correctness risk — the tap is total, so a write that fails degrades observability and never a decision (constraint 8) — and no latency budget is claimed for it. Recorded rather than fixed; an async or queued tap is the change if a deployment ever needs one | ## Open decisions carried from shaping @@ -263,3 +264,5 @@ Runs on a schedule in CI. MS-ACS is `0.3.1-beta` and warns of breaking changes b **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`. V2 planning produced no corrections — §V2 had nothing wrong in it — but it did close five open choices (P1–P5, recorded under §V2), add three watch-fors, add risk row 9, and surface D10. Its plan is `docs/superpowers/plans/2026-08-09-v2-envelope-inspector.md`. + +**V2's whole-branch review produced one correction of its own**, recorded at the watch-for it governs: "S6 records the wire verbatim" over-claimed byte identity that the implementation never had, and the over-claim had propagated verbatim from the plan's global constraint 11 into the slice README, the runbook, the shaping doc's S6 row, and the Inspector's renderer. Corrected in wording, not in code — see the watch-for above for why storing raw bytes would be the worse trade. The same review added risk row 10. diff --git a/docs/superpowers/plans/2026-08-09-v2-envelope-inspector.md b/docs/superpowers/plans/2026-08-09-v2-envelope-inspector.md index 7aeca5dd..8272532a 100644 --- a/docs/superpowers/plans/2026-08-09-v2-envelope-inspector.md +++ b/docs/superpowers/plans/2026-08-09-v2-envelope-inspector.md @@ -25,7 +25,11 @@ 8. **The tap is total.** `writeEnvelopeTap` must never throw, and a tap failure must never change, delay, or suppress a decision. Observability degrades; governance does not. V1 shipped three separate fail-opens before they were caught — the tap is new code on the decision path and gets this constraint explicitly. 9. **The Inspector contains zero AGT vocabulary and zero host vocabulary.** It reads ACS envelopes as data and knows nothing about AGT or about Claude Code. That is R5.2 stated as a property of the code, and Task 6 enforces it by grep. 10. **The Inspector imports nothing from `guardian` or `agt-bridge`.** It reads the log file. A compile-time dependency would make "inspectable on the wire" (R5.1) a claim about our own type graph rather than about the wire. -11. **S6 records exactly what crossed the wire** — no reformatting, no field stripping, no redaction, no reordering. Pretty-printing happens at render time only. An inspector that shows something other than what was sent is worse than no inspector. +11. **S6 records the JSON value the Guardian parsed, unmodified** — no field stripping, no redaction, no reordering of anything we control. Pretty-printing happens at render time only. An inspector that shows something other than what was sent is worse than no inspector. + + > **Amended after V2's whole-branch review (finding 2).** This constraint originally read "S6 records exactly what crossed the wire", and that was never true of the design it governed: the tap is handed `await req.json()`, so it stores a JSON *value*, not the request's bytes. The parse collapses duplicate keys, canonicalises number literals (`1.0` → `1`, `1e2` → `100`), and hoists integer-like object keys ahead of the rest — and tool argument names are host-controlled, so an `arguments` object carrying `"0"` alongside `"a"` is a real shape, not a hypothetical. + > + > The over-promise propagated verbatim out of this line into `packages/inspector/src/render.ts`, `docs/demos/v2-runbook.md`, `slices/v2/README.md`, the shaping doc's S6 affordance row, the slices doc's V2 watch-for, and two test titles — every one of them corrected in the same wave, in wording rather than in code. Storing raw bytes would make the entry's `envelope` field a string rather than a JSON value, which breaks the Inspector's pretty-printing and the round-trip contract test: a worse trade than an accurate sentence. The code snippets embedded further down this plan still show the original wording; they are the historical record of what was planned, and this note is what corrects them. 12. **No new runtime dependencies.** Bun and TypeScript `strict` + `noUncheckedIndexedAccess` only, as in V1. --- @@ -651,6 +655,8 @@ export async function startGuardian({ Replace `handleAcsRequest` with the three-phase version, and move V1's body into `dispatch` unchanged: +> **Corrected after V2's whole-branch review (findings 1 and 4).** The comment below miscounted, and the code inherited the miscount verbatim: `dispatch` leaves by **six** routes, not four — five `return`s plus the rethrow of any non-`EnvelopeValidationError` — so V3's N27 adds a seventh. Worse, the rethrow was the route the "structure guarantees totality" argument did not cover: nothing caught it, so `Bun.serve` answered with a `text/html` 500 that S6 never recorded and `res.json()` could not parse. Shipped code wraps the `dispatch` call in a try/catch that turns any escape into a well-formed JSON-RPC error (still not a decision — N27 stays V3), so every route out of `dispatch` now produces a response object and every response object gets tapped. + ```ts /** * Three phases, in order: parse, tap the request, dispatch, tap the diff --git a/packages/guardian/src/envelope-tap.ts b/packages/guardian/src/envelope-tap.ts index 36d9ce38..2b163645 100644 --- a/packages/guardian/src/envelope-tap.ts +++ b/packages/guardian/src/envelope-tap.ts @@ -1,9 +1,21 @@ /** * writeEnvelopeTap (N26) writes S6: a JSONL record of every ACS envelope - * that crosses this Guardian's wire, in both directions, exactly as it - * crossed (global constraint 11). The Envelope Inspector (P4) reads this - * file and nothing else -- see packages/inspector, which deliberately - * imports nothing from here. + * that crosses this Guardian's wire, in both directions. The Envelope + * Inspector (P4) reads this file and nothing else -- see packages/inspector, + * which deliberately imports nothing from here. + * + * What "records the envelope" means here, precisely (global constraint 11, + * as corrected by the whole-branch review's finding 2): the tap is handed + * the JSON *value* the Guardian parsed, and writes it unmodified -- no field + * stripping, no redaction, no reordering of anything we control. It is not a + * byte-for-byte copy of the request body, and V2's documentation claimed it + * was. The parse happens upstream in server.ts (`await req.json()`) and has + * already collapsed duplicate keys, canonicalised number literals, and + * hoisted integer-like object keys -- and `arguments` keys are + * host-controlled, so `{"0": ...}` is a real shape, not a hypothetical. + * Recording raw bytes instead would make `envelope` a string rather than a + * JSON value, costing the Inspector its pretty-printing and the round-trip + * contract test; the accurate sentence is the better trade. * * Total by construction (global constraint 8). Every write is wrapped: a * failure disables the tap for the process lifetime, reports once, and is @@ -19,12 +31,12 @@ import { dirname } from "node:path"; export type TapDirection = "request" | "response"; /** - * One line of S6. `envelope` is the JSON-RPC object verbatim -- request or - * response -- and every other field is Guardian-side context the wire does - * not carry: a sequence number so a reader can detect gaps, a timestamp, the - * direction, the ACS method (JSON-RPC responses carry none, so the Guardian - * supplies the one it dispatched), and the JSON-RPC id that pairs the two - * directions. + * One line of S6. `envelope` is the JSON-RPC object as parsed, unmodified -- + * request or response -- and every other field is Guardian-side context the + * wire does not carry: a sequence number so a reader can detect gaps, a + * timestamp, the direction, the ACS method (JSON-RPC responses carry none, + * so the Guardian supplies the one it dispatched), and the JSON-RPC id that + * pairs the two directions. */ export type TapEntry = { seq: number; diff --git a/packages/guardian/test/envelope-tap.test.ts b/packages/guardian/test/envelope-tap.test.ts index 0a28238a..415c6036 100644 --- a/packages/guardian/test/envelope-tap.test.ts +++ b/packages/guardian/test/envelope-tap.test.ts @@ -46,7 +46,11 @@ describe("createEnvelopeTap (N26) -- S6's JSONL format", () => { }); }); - it("records the envelope verbatim -- constraint 11, no reformatting or stripping", () => { + // Retitled by the whole-branch review (finding 2); the assertion is + // unchanged. It has always checked that the JSON value reaches S6 + // unmodified -- nothing stripped, nothing reordered. "Verbatim" claimed + // byte identity, which the tap never had: it is handed `await req.json()`. + it("records the envelope unmodified -- constraint 11, no reformatting or stripping", () => { withTempDir((dir) => { const path = join(dir, "envelopes.jsonl"); createEnvelopeTap({ path }).write("request", REQUEST, "steps/toolCallRequest"); diff --git a/packages/inspector/src/render.ts b/packages/inspector/src/render.ts index 2b3bded8..cafaa531 100644 --- a/packages/inspector/src/render.ts +++ b/packages/inspector/src/render.ts @@ -98,8 +98,21 @@ export function renderDecisionBadge(entry: TapEntry, options: RenderOptions = {} return parts.join(" "); } -/** U20. Header line, optional badge line, then the envelope as pretty JSON -- - * the same bytes that crossed the wire, only re-indented. */ +/** + * U20. Header line, optional badge line, then the envelope as pretty JSON. + * + * What the body shows is the JSON value S6 recorded, printed unmodified: + * nothing here strips a field, redacts a value, or reorders anything. It is + * not a byte-for-byte replay of the wire, and this comment used to say it + * was (whole-branch review, finding 2). The Guardian taps `await req.json()`, + * so the parse has already collapsed duplicate keys, canonicalised number + * literals (`1.0` -> `1`), and hoisted integer-like object keys ahead of the + * rest -- and tool argument names are host-controlled, so `arguments` really + * can carry a key like `"0"`. Storing raw bytes instead would make + * `entry.envelope` a string rather than a JSON value, which costs the + * pretty-printing below and the round-trip contract test; an accurate + * sentence is the better trade. + */ export function renderEntry(entry: TapEntry, options: RenderOptions = {}): string { const color = options.color ?? false; const arrow = entry.direction === "request" ? "→ REQUEST " : "← RESPONSE"; @@ -108,7 +121,13 @@ export function renderEntry(entry: TapEntry, options: RenderOptions = {}): strin const header = paint(`── #${entry.seq} ${clockOf(entry.recorded_at)} ${arrow} ${method} ${id}`, DIM, color); const badge = renderDecisionBadge(entry, options); - const body = JSON.stringify(entry.envelope, null, options.indent ?? 2); + // `JSON.stringify` returns `undefined` -- not a string -- for an entry + // whose `envelope` key is absent, and `join` would coerce that to an empty + // line indistinguishable from a real blank body. `isTapEntryShape` does + // not require `envelope` (it is `unknown` by design), so a hand-written or + // truncated S6 line reaches here without one. Narrowed the way the badge + // path above narrows (whole-branch review, finding 8). + const body = JSON.stringify(entry.envelope, null, options.indent ?? 2) ?? "(no envelope recorded)"; return [header, ...(badge === null ? [] : [badge]), body].join("\n"); } diff --git a/packages/inspector/test/render.test.ts b/packages/inspector/test/render.test.ts index c2c4cf21..9b49f8ea 100644 --- a/packages/inspector/test/render.test.ts +++ b/packages/inspector/test/render.test.ts @@ -121,11 +121,37 @@ describe("renderEntry (U20)", () => { expect(rendered.split("\n")[0]).toBe("── #3 12:04:31.221 ← RESPONSE (no method) (unpaired)"); }); - it("keeps the envelope verbatim -- pretty-printing only reshapes whitespace", () => { + // Retitled by the whole-branch review (finding 2) -- the assertions are + // unchanged. What this has always checked is that the JSON *value* round + // trips: nothing stripped, nothing reordered, only whitespace reshaped. + // "Verbatim" claimed more than that, since S6 stores the value the + // Guardian parsed rather than the bytes the host sent. + it("changes nothing but whitespace -- the envelope value round trips through the renderer", () => { const envelope = { jsonrpc: "2.0", id: 1, result: { decision: "allow", nested: { deep: [1, 2] } } }; const rendered = renderEntry(entry({ envelope })); const jsonStart = rendered.indexOf("{"); expect(JSON.parse(rendered.slice(jsonStart))).toEqual(envelope); }); + + // Whole-branch review, finding 8. `isTapEntryShape` deliberately does not + // constrain `envelope`, so a hand-written or truncated S6 line reaches the + // renderer with the key missing entirely. `JSON.stringify(undefined)` + // returns `undefined`, which `join` would coerce into a blank line + // indistinguishable from a real empty body. + it("marks an entry whose envelope key is absent, instead of emitting a blank body", () => { + const withoutEnvelope = { + seq: 3, + recorded_at: "2026-08-09T12:04:31.221Z", + direction: "response", + method: "steps/toolCallRequest", + rpc_id: 1, + } as unknown as TapEntry; + + const lines = renderEntry(withoutEnvelope).split("\n"); + + expect(lines).toHaveLength(2); + expect(lines[0]).toBe("── #3 12:04:31.221 ← RESPONSE steps/toolCallRequest id=1"); + expect(lines[1]).toBe("(no envelope recorded)"); + }); }); diff --git a/slices/v2/README.md b/slices/v2/README.md index 8bd05a87..55ef35f5 100644 --- a/slices/v2/README.md +++ b/slices/v2/README.md @@ -52,9 +52,19 @@ Three properties make this worth more than a log viewer: slices doc; `: > .acs/envelopes.jsonl` truncates it safely mid-run because the tail resets on truncation. -`.acs/envelopes.jsonl` records the wire verbatim — no reformatting, stripping, -redaction, or reordering — so it carries raw tool arguments. It is gitignored for that -reason and is never committed. +`.acs/envelopes.jsonl` records the JSON value the Guardian parsed, unmodified — no field +stripping, no redaction, no reordering of anything we control — so it carries raw tool +arguments. It is gitignored for that reason and is never committed. + +The precision matters, and V2 first shipped this claim too strongly. The tap is handed +`await req.json()`, so it stores a JSON *value*, not the request's bytes: the parse has +already collapsed duplicate keys, canonicalised number literals (`1.0` → `1`), and +hoisted integer-like object keys ahead of the rest — and `arguments` keys are +host-controlled, so `{"0": …, "a": …}` is a shape a real host can send. Storing raw bytes +would make the entry's `envelope` field a string rather than a JSON value, which costs +the Inspector its pretty-printing and costs the round-trip contract test its subject. The +accurate sentence is the better trade, and the whole-branch review is what caught the +inaccurate one. The implementation plan this slice followed, task by task, is [`docs/superpowers/plans/2026-08-09-v2-envelope-inspector.md`](../../docs/superpowers/plans/2026-08-09-v2-envelope-inspector.md). From 81ac817cb66df33871c2bcc7113cbbeb4f29cfac Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Mon, 10 Aug 2026 08:32:15 +0300 Subject: [PATCH 16/35] Guardian: redact absolute paths, stabilize test scratch dir, quiet stderr Three deferred findings against the Guardian, closed together since they touch the same two files. server.ts's two catches (the outer net in handleAcsRequest, the evaluation-failure catch in dispatch) echoed a real error's `.message` straight through to the ACS client and into S6 -- and a real error, e.g. the ENOENT a missing schema directory throws, names this machine's absolute filesystem path in full. The fix is not a generic message: this demo's value depends on the text staying diagnostic. `toRepoRelativeMessage`, one shared helper, strips only the repo-root prefix (computed the same way MAPPING_PATH already is) out of the message before it leaves either catch, leaving the repo-relative remainder intact. Covered by a new test against withSchemalessGuardian's real, relocated-source ENOENT: the client-visible message no longer contains this repo's root, and still names the missing path relative to it. withSchemalessGuardian's scratch tree used mkdtempSync, so a killed run left a randomly-named, unignored, un-excluded copy of the Guardian's own source sitting inside packages/guardian -- which the root tsconfig's `packages/**/*.ts` include would then hand to `bun run typecheck` as if it were real source. Given a stable, predictable name instead (tmp-schemaless-scratch), added to .gitignore and to tsconfig.json's exclude, with a comment on why the tree has to live under packages/guardian/ at all (the relative-path trick validate-envelope.ts's own SCHEMA_ROOT depends on, and Bun's workspace resolution for the copy's own imports). Verified directly: a simulated killed-run leftover left `bun run typecheck` clean and did not appear in `git status`. The one test that exercises the tap's default (unsupplied onError) stderr reporter printed a real ENOTDIR line into every `bun test` run. Spied, silenced, and asserted on instead -- "reports once" is now checked at the call site, not only claimed. Also: a doc comment's SCHEMA_ROOT example was missing an `acs/` segment, and extractRpcId/extractId's redundant `as {id: unknown}` casts are gone -- TypeScript's `in`-narrowing on the already-narrowed `object` type gives the same shape without one. Slice: #3 --- .gitignore | 7 +- packages/guardian/src/envelope-tap.ts | 2 +- packages/guardian/src/server.ts | 28 ++++++- .../guardian/test/envelope-tap-wiring.test.ts | 40 ++++++---- packages/guardian/test/server.test.ts | 77 +++++++++++++++---- tsconfig.json | 10 ++- 6 files changed, 131 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index 1979b80f..f1f7d0d6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,5 +19,8 @@ __pycache__/ # identical copy of packages/guardian/src, placed one directory deeper so its # schema path resolves to nothing. The test removes it in a `finally`; this # line only covers a run killed mid-test, so a stray copy of the Guardian's -# source can never be committed as if it were source. -packages/guardian/tmp-schemaless-*/ +# source can never be committed as if it were source. Also excluded from +# tsconfig.json, for the same reason: a killed run's leftover copy must not +# make `bun run typecheck` see a stray duplicate of the Guardian's own +# source. Fixed name (not mkdtemp's random one), so this one line covers it. +packages/guardian/tmp-schemaless-scratch/ diff --git a/packages/guardian/src/envelope-tap.ts b/packages/guardian/src/envelope-tap.ts index 2b163645..94724d5c 100644 --- a/packages/guardian/src/envelope-tap.ts +++ b/packages/guardian/src/envelope-tap.ts @@ -70,7 +70,7 @@ export const NULL_TAP: EnvelopeTap = { * carry `id` at the top level, so one extractor serves both directions. */ export function extractRpcId(envelope: unknown): string | number | null { if (typeof envelope === "object" && envelope !== null && "id" in envelope) { - const id = (envelope as { id: unknown }).id; + const id = envelope.id; if (typeof id === "string" || typeof id === "number") { return id; } diff --git a/packages/guardian/src/server.ts b/packages/guardian/src/server.ts index 43af06b7..745aa832 100644 --- a/packages/guardian/src/server.ts +++ b/packages/guardian/src/server.ts @@ -83,6 +83,28 @@ const ACS_PATH = "/acs"; * 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"; +// This tree's own absolute root -- `packages/guardian/src` is always three +// directories under it, the same relationship MAPPING_PATH above relies on. +// Used only to redact it out of error text before that text leaves the +// Guardian; see toRepoRelativeMessage below. +const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url)).replace(/[/\\]+$/, ""); +const REPO_ROOT_PATTERN = new RegExp(`${REPO_ROOT.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[/\\\\]?`, "g"); + +/** + * Both catches in this module (the outer net in handleAcsRequest and the + * evaluation-failure catch in dispatch) surface a real error's `.message` + * to the ACS client and, via the tap, into S6 -- and a real error's message + * (an ENOENT out of a missing schema directory, say) carries this + * machine's absolute filesystem path, e.g. + * `/Users/you/.../ACS_reference_implementation/packages/spec/acs/...`. + * That is diagnostic in a way this demo's value depends on, so the fix is + * not to replace it with something generic -- it is to remove only the + * part of it that discloses where this tree sits on disk, leaving the + * repo-relative remainder (`packages/spec/acs/...`) intact. + */ +function toRepoRelativeMessage(message: string): string { + return message.replace(REPO_ROOT_PATTERN, ""); +} /** * ACS reserves -32000..-32099 for application errors (Specification §17), @@ -217,7 +239,7 @@ async function handleAcsRequest( // JSON-RPC error, not an ACS `deny`: N27 stays V3's call. What this // buys is that the client can parse the answer at all, and that S6 // holds a response line paired with the request line above it. - const message = error instanceof Error ? error.message : String(error); + const message = toRepoRelativeMessage(error instanceof Error ? error.message : String(error)); response = errorResponse(extractId(raw), EVALUATION_FAILED_CODE, `guardian failed to handle the request: ${message}`); } tap.write("response", response, method); @@ -274,7 +296,7 @@ async function dispatch( // 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); + const message = toRepoRelativeMessage(error instanceof Error ? error.message : String(error)); return errorResponse(rpcId, EVALUATION_FAILED_CODE, `evaluation failed: ${message}`); } } @@ -307,7 +329,7 @@ function errorResponse( * 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; + const id = raw.id; if (typeof id === "string" || typeof id === "number") { return id; } diff --git a/packages/guardian/test/envelope-tap-wiring.test.ts b/packages/guardian/test/envelope-tap-wiring.test.ts index 6678a3df..fd9a12f3 100644 --- a/packages/guardian/test/envelope-tap-wiring.test.ts +++ b/packages/guardian/test/envelope-tap-wiring.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, spyOn } from "bun:test"; import { existsSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -137,24 +137,38 @@ describe("Guardian envelope tap wiring (N26 x N20)", () => { // Global constraint 8, end to end: the tap is on the decision path, so // this is the test that says a broken tap cannot become a fail-open. + // + // No `onError` is passed here, so this exercises the tap's *default* + // reporter -- a single `console.error` line -- rather than the + // onError-captured path envelope-tap.test.ts's "reports once, then goes + // quiet" test covers. Spied and silenced so a deliberately-broken tap + // does not print real stderr into a clean `bun test` run, and asserted + // on so "reports once" is checked at the call site instead of merely + // claimed. it("still denies rm -rf / when every tap write fails", async () => { const dir = mkdtempSync(join(tmpdir(), "acs-tap-broken-")); const blocker = join(dir, "blocker"); writeFileSync(blocker, ""); - const guardian = await startGuardian({ - port: 0, - manifestPath: "policy/manifest.yaml", - envelopeLogPath: join(blocker, "nested", "envelopes.jsonl"), - }); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); try { - const response = (await postRaw(guardian.url, JSON.stringify(toolCallEnvelope("rm -rf /")))) as { - result?: { decision?: string }; - error?: unknown; - }; - expect(response.error).toBeUndefined(); - expect(response.result?.decision).toBe("deny"); + const guardian = await startGuardian({ + port: 0, + manifestPath: "policy/manifest.yaml", + envelopeLogPath: join(blocker, "nested", "envelopes.jsonl"), + }); + try { + const response = (await postRaw(guardian.url, JSON.stringify(toolCallEnvelope("rm -rf /")))) as { + result?: { decision?: string }; + error?: unknown; + }; + expect(response.error).toBeUndefined(); + expect(response.result?.decision).toBe("deny"); + expect(errorSpy).toHaveBeenCalledTimes(1); + } finally { + await guardian.close(); + } } finally { - await guardian.close(); + errorSpy.mockRestore(); unlinkSync(blocker); rmdirSync(dir); } diff --git a/packages/guardian/test/server.test.ts b/packages/guardian/test/server.test.ts index ce214046..a2cab978 100644 --- a/packages/guardian/test/server.test.ts +++ b/packages/guardian/test/server.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeAll, afterAll } from "bun:test"; -import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmdirSync, unlinkSync } from "node:fs"; +import { copyFileSync, mkdirSync, readFileSync, readdirSync, rmdirSync, unlinkSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { join } from "node:path"; import Ajv2020 from "ajv/dist/2020.js"; @@ -189,6 +189,30 @@ describe("startGuardian POST /acs -- evaluation failure inside handleAcsRequest" const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url)); const GUARDIAN_PKG = join(REPO_ROOT, "packages", "guardian"); +/** + * A stable, predictable name rather than an `mkdtempSync` random one + * (backlog item C). This tree has to live *inside* `packages/guardian/` -- + * not under a repo-wide temp directory -- because it is a relative-path + * trick: `validate-envelope.ts` resolves its schema root three directories + * up from its own `import.meta.url`, so the copy has to sit at the same + * depth under `packages/guardian/` for that resolution to land one level + * short, on purpose (see the doc comment below). Bun's workspace module + * resolution for the copy's own `import`s (`agt-bridge`, etc.) also + * resolves relative to where the copy physically sits, which only works + * predictably inside the real package tree. + * + * A fixed name means a killed run's leftover copy is not a fresh, unignored + * directory tsc has never heard of: it is *this* directory, already covered + * by `.gitignore` and by `tsconfig.json`'s `exclude`, so it cannot make + * `bun run typecheck` see a stray duplicate of the Guardian's own source. A + * run that starts while a previous killed run's copy is still here fails + * loudly (`mkdirSync` on an existing directory throws) rather than quietly + * reusing stale files -- the cure for that is deleting the leftover + * directory by hand, not adding recovery logic that would have to guess + * whether stale contents are safe to remove. + */ +const SCHEMALESS_SCRATCH_DIR = join(GUARDIAN_PKG, "tmp-schemaless-scratch"); + /** * Runs `body` against a Guardian whose `validate-envelope.ts` cannot find the * ACS schemas -- the tree-cloned-without-`--recurse-submodules` case, which is @@ -197,29 +221,30 @@ const GUARDIAN_PKG = join(REPO_ROOT, "packages", "guardian"); * * It is reproduced by *relocation*, not by mocking and not by touching * `spec/`. `validate-envelope.ts` derives SCHEMA_ROOT from its own - * `import.meta.url` as `../../../spec/specification/...`, so an identical copy - * of `packages/guardian/src` placed one directory deeper resolves that path to - * `packages/spec/...`, which does not exist. Every line of Guardian code that - * then runs is the real, current source -- the files are copied at test time, - * so they cannot drift from `src/` -- and the failure it produces is a real - * ENOENT out of `readdirSync`, thrown at request time because `buildAjv()` is - * lazy. The copy also gets its own module instance, so it cannot poison the - * Ajv registry the rest of this suite shares. + * `import.meta.url` as `../../../spec/acs/specification/...`, so an identical + * copy of `packages/guardian/src` placed one directory deeper resolves that + * path to `packages/spec/acs/...`, which does not exist. Every line of + * Guardian code that then runs is the real, current source -- the files are + * copied at test time, so they cannot drift from `src/` -- and the failure it + * produces is a real ENOENT out of `readdirSync`, thrown at request time + * because `buildAjv()` is lazy. The copy also gets its own module instance, + * so it cannot poison the Ajv registry the rest of this suite shares. * * Deletions here are explicit per file (repo constraint: nothing recursive). */ async function withSchemalessGuardian( body: (guardian: { url: string; logPath: string }) => Promise, ): Promise { - const root = mkdtempSync(join(GUARDIAN_PKG, "tmp-schemaless-")); + const root = SCHEMALESS_SCRATCH_DIR; const srcDir = join(root, "src"); const logPath = join(root, "envelopes.jsonl"); const copied = readdirSync(join(GUARDIAN_PKG, "src")).filter((f) => f.endsWith(".ts")); let guardian: { close(): Promise } | undefined; - // Everything after mkdtempSync is inside the try: a failure while copying, - // importing, or booting would otherwise leave a directory of stray .ts - // files sitting inside packages/guardian. + // Everything after this mkdirSync is inside the try: a failure while + // copying, importing, or booting would otherwise leave a directory of + // stray .ts files sitting inside packages/guardian. + mkdirSync(root); try { mkdirSync(srcDir); for (const file of copied) { @@ -288,6 +313,32 @@ describe("startGuardian POST /acs -- the outer net around dispatch", () => { }); }); + // Backlog item B. The real ENOENT `withSchemalessGuardian` provokes names + // this machine's absolute path in full (`readdirSync` on a schema + // directory that does not exist at the relocated copy's resolved path): + // before the fix, that absolute path -- this repo's own root, in + // particular -- rode straight through to the client and into S6 + // unredacted. The fix strips only the repo-root prefix, so the + // diagnostic remainder (the ENOENT text and the repo-relative path) is + // still there for a real reader to use. + it("strips this repo's absolute root out of a real error message before it reaches the client", async () => { + await withSchemalessGuardian(async ({ url }) => { + const response = await postAcs(url, toolCallEnvelope("ls -la")); + + expect(response.error).toBeDefined(); + const message = response.error?.message ?? ""; + expect(message).not.toContain(REPO_ROOT); + expect(message).toContain("ENOENT"); + // The diagnostic remainder: which schema directory was missing, + // relative rather than absolute. (Relative to the *relocated* copy's + // own root, one level shallower than this file's REPO_ROOT above -- + // see withSchemalessGuardian's doc comment -- so no "packages/" + // prefix here; that is this test harness's relocation depth, not a + // second absolute-path leak.) + expect(message).toContain("spec/acs/specification/v0.1.0"); + }); + }); + it("carries no decision -- N27 is V3, so a Guardian-side failure is an error, not a synthesized deny", async () => { await withSchemalessGuardian(async ({ url }) => { const response = await postAcs(url, toolCallEnvelope("rm -rf /")); diff --git a/tsconfig.json b/tsconfig.json index f7912d56..8d3ce813 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,5 +5,13 @@ "rootDir": "." }, "include": ["packages/**/*.ts", "hosts/**/*.ts", "test/**/*.ts"], - "exclude": ["**/node_modules"] + "exclude": [ + "**/node_modules", + // server.test.ts's outer-net test copies packages/guardian/src here at + // test time, one directory deeper, to force a real ENOENT (see that + // test's doc comment). A killed run can leave this behind; excluded so + // it can never look like a second, drifted copy of the Guardian's + // source to `bun run typecheck`. + "packages/guardian/tmp-schemaless-scratch" + ] } From 5913ef661ca98a29681a862313bbfea7a4ef4707 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Mon, 10 Aug 2026 08:32:36 +0300 Subject: [PATCH 17/35] Inspector: dim badge segments, pin two unspecified-but-real shapes, quiet stderr renderDecisionBadge painted only the glyph and decision label when color:true, leaving the appended `reason_codes=[...]` and `policy_references=[...]` segments plain -- a coloured badge read as one coloured half and one plain half. Both segments are now painted dim; paint() no-ops when color is false, so the color:false output the existing exact-string tests assert is unchanged. Test written first, confirmed failing against the unmodified renderer, then fixed. Two more render.ts behaviours were true only by construction, never asserted: a policy_reference with no rule_id renders as the bare policy_id (ACS's schemas do not require rule_id, so this is a real shape, not a hypothetical one); and isTapEntryShape never constrains `envelope`, so a line whose envelope is absent, null, or a bare string is still accepted and delivered rather than reported as malformed -- load-bearing, since the Guardian must be able to tap anything that crossed the wire, including a malformed body it never got a real envelope for. Both pinned with tests that pass immediately, as they should for a pin-current-behaviour item; a one-sentence doc comment was added to referenceList for the first. tail-envelope-log.test.ts's three deliberately-adversarial tests (a throwing onMalformedLine, twice, and a read that fails every tick against a directory) printed real stderr into every `bun test` run. Spied, silenced, and asserted on -- "warns and retries" and "reports the throw" are now checked at the call site instead of only inferred from the process surviving. Slice: #3 --- packages/inspector/src/render.ts | 12 +- packages/inspector/test/render.test.ts | 35 +++ .../inspector/test/tail-envelope-log.test.ts | 206 ++++++++++++------ 3 files changed, 185 insertions(+), 68 deletions(-) diff --git a/packages/inspector/src/render.ts b/packages/inspector/src/render.ts index cafaa531..3ba768f2 100644 --- a/packages/inspector/src/render.ts +++ b/packages/inspector/src/render.ts @@ -41,6 +41,10 @@ function stringList(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; } +/** Formats each `policy_references` entry as `policy_id#rule_id`, falling + * back to the bare `policy_id` when `rule_id` is absent. ACS's schemas do + * not require `rule_id` on a policy_reference, so that fallback is a real + * shape this renders deliberately, not a defect. */ function referenceList(value: unknown): string[] { if (!Array.isArray(value)) { return []; @@ -88,12 +92,16 @@ export function renderDecisionBadge(entry: TapEntry, options: RenderOptions = {} head = paint(`◆ ${result.decision.toUpperCase()}`, CYAN, color); } + // Dimmed rather than left plain: with color:true, painting only `head` + // made a coloured badge read as one coloured half and one plain half. + // `paint` no-ops when `color` is false, so this changes nothing about the + // color:false output the exact-string tests above assert byte-for-byte. const parts = [head]; if (reasonCodes.length > 0) { - parts.push(`reason_codes=[${reasonCodes.join(", ")}]`); + parts.push(paint(`reason_codes=[${reasonCodes.join(", ")}]`, DIM, color)); } if (references.length > 0) { - parts.push(`policy_references=[${references.join(", ")}]`); + parts.push(paint(`policy_references=[${references.join(", ")}]`, DIM, color)); } return parts.join(" "); } diff --git a/packages/inspector/test/render.test.ts b/packages/inspector/test/render.test.ts index 9b49f8ea..b16d0294 100644 --- a/packages/inspector/test/render.test.ts +++ b/packages/inspector/test/render.test.ts @@ -65,6 +65,22 @@ describe("renderDecisionBadge (U21)", () => { expect(badge).not.toBe(renderDecisionBadge(response({ decision: "allow" }))); }); + // Pins current behaviour (backlog item H): ACS's schemas do not require + // `rule_id` on a policy_reference, so this is a real shape, not a + // hypothetical one. Dropping to the bare policy_id here is a deliberate + // degradation, not a bug -- this test exists so a future change to it is + // a decision, not an accident. + it("renders a policy_reference with no rule_id as the bare policy_id", () => { + const badge = renderDecisionBadge( + response({ + decision: "deny", + policy_references: [{ policy_id: "agt_stock" }], + }), + ); + + expect(badge).toBe("● DENY policy_references=[agt_stock]"); + }); + it("badges modify, ask, and defer", () => { expect(renderDecisionBadge(response({ decision: "modify" }))).toBe("◆ MODIFY"); expect(renderDecisionBadge(response({ decision: "ask" }))).toBe("◆ ASK"); @@ -87,6 +103,25 @@ describe("renderDecisionBadge (U21)", () => { expect(coloured).toContain("\u001b["); expect(coloured).toContain("DENY"); }); + + // Backlog item I: with color:true, only the glyph and decision label used + // to be painted, so a coloured badge read as one coloured half and one + // plain half. The appended segments are painted dim so the whole badge + // reads as one unit; color:false stays byte-identical (asserted by the + // exact-string tests above, which are unchanged). + it("paints the appended reason_codes/policy_references segments dim when coloured", () => { + const coloured = renderDecisionBadge( + response({ + decision: "deny", + reason_codes: ["destructive_shell_command_blocked"], + policy_references: [{ policy_id: "agt_stock", rule_id: "destructive_shell_command_blocked" }], + }), + { color: true }, + ); + + expect(coloured).toContain("\u001b[2mreason_codes=[destructive_shell_command_blocked]\u001b[0m"); + expect(coloured).toContain("\u001b[2mpolicy_references=[agt_stock#destructive_shell_command_blocked]\u001b[0m"); + }); }); describe("renderEntry (U20)", () => { diff --git a/packages/inspector/test/tail-envelope-log.test.ts b/packages/inspector/test/tail-envelope-log.test.ts index e7233030..621dfe28 100644 --- a/packages/inspector/test/tail-envelope-log.test.ts +++ b/packages/inspector/test/tail-envelope-log.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, spyOn } from "bun:test"; import { appendFileSync, mkdirSync, mkdtempSync, rmdirSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -312,38 +312,50 @@ describe("tailEnvelopeLog (N50)", () => { // `wake` for an unrelated reason. Reproduced at 300ms of silence before the // fix. The good line here is written BEFORE the bad one so that `added` is // already true when the throw happens. + // A throwing onMalformedLine reaches `reportMalformedLine`'s own catch, + // which prints one stderr line of its own (`onMalformedLine threw while + // reporting...`). Spied and silenced so this deliberately-adversarial + // test does not print real stderr into a clean `bun test` run, and + // asserted on so that reporting is checked at the call site rather than + // only through the caller-side `calls` counter. it("delivers an entry parsed before a throwing onMalformedLine, without waiting for another write", async () => { await withTempDir(async (_dir, path) => { - let calls = 0; - writeFileSync(path, entryLine(1, "request") + "{not json\n"); - const controller = new AbortController(); - const tail = tailEnvelopeLog({ - path, - fromStart: true, - pollMs: POLL_MS, - signal: controller.signal, - onMalformedLine: () => { - calls += 1; - throw new Error("reporter blew up"); - }, - }); - - // Raced against a timer rather than read through `collect`, because - // `collect`'s own deadline calls `controller.abort()` -- and `stop()` - // fires `wake?.()` on the way out, which is exactly the incidental - // rescue that made this bug look benign. No further appends and no - // abort inside the window: the only thing that can deliver entry #1 - // is the wake-up the poll itself owes the drain loop. - const first = tail.next(); - const outcome = await Promise.race([ - first.then(({ value }) => (value === undefined ? "ended" : `seq:${value.seq}`)), - Bun.sleep(POLL_MS * 20).then(() => "stranded" as const), - ]); - controller.abort(); - await first; - - expect(outcome).toBe("seq:1"); - expect(calls).toBe(1); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + let calls = 0; + writeFileSync(path, entryLine(1, "request") + "{not json\n"); + const controller = new AbortController(); + const tail = tailEnvelopeLog({ + path, + fromStart: true, + pollMs: POLL_MS, + signal: controller.signal, + onMalformedLine: () => { + calls += 1; + throw new Error("reporter blew up"); + }, + }); + + // Raced against a timer rather than read through `collect`, because + // `collect`'s own deadline calls `controller.abort()` -- and `stop()` + // fires `wake?.()` on the way out, which is exactly the incidental + // rescue that made this bug look benign. No further appends and no + // abort inside the window: the only thing that can deliver entry #1 + // is the wake-up the poll itself owes the drain loop. + const first = tail.next(); + const outcome = await Promise.race([ + first.then(({ value }) => (value === undefined ? "ended" : `seq:${value.seq}`)), + Bun.sleep(POLL_MS * 20).then(() => "stranded" as const), + ]); + controller.abort(); + await first; + + expect(outcome).toBe("seq:1"); + expect(calls).toBe(1); + expect(errorSpy).toHaveBeenCalledTimes(1); + } finally { + errorSpy.mockRestore(); + } }); }); @@ -352,22 +364,31 @@ describe("tailEnvelopeLog (N50)", () => { // line still in `pending` -- no later tick has anything new to read, and // they are never re-scanned. Guarding the reporter at its own call site is // what keeps the scan going. + // Same stray-stderr source as the test above: `reportMalformedLine`'s own + // catch prints when the caller's reporter throws. Spied, silenced, and + // asserted on for the same reason. it("keeps parsing the lines behind a bad one when onMalformedLine throws", async () => { await withTempDir(async (_dir, path) => { - writeFileSync(path, "{not json\n" + entryLine(2, "response") + entryLine(3, "request")); - const controller = new AbortController(); - const tail = tailEnvelopeLog({ - path, - fromStart: true, - pollMs: POLL_MS, - signal: controller.signal, - onMalformedLine: () => { - throw new Error("reporter blew up"); - }, - }); - - const entries = await collect(tail, 2, controller, 1000); - expect(entries.map((e) => e.seq)).toEqual([2, 3]); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + writeFileSync(path, "{not json\n" + entryLine(2, "response") + entryLine(3, "request")); + const controller = new AbortController(); + const tail = tailEnvelopeLog({ + path, + fromStart: true, + pollMs: POLL_MS, + signal: controller.signal, + onMalformedLine: () => { + throw new Error("reporter blew up"); + }, + }); + + const entries = await collect(tail, 2, controller, 1000); + expect(entries.map((e) => e.seq)).toEqual([2, 3]); + expect(errorSpy).toHaveBeenCalledTimes(1); + } finally { + errorSpy.mockRestore(); + } }); }); @@ -377,29 +398,43 @@ describe("tailEnvelopeLog (N50)", () => { // `existsSync` true and `statSync().size` non-zero, so the read is // attempted and `readSync` throws EISDIR every tick. Without the catch, // that throw leaves a bare timer callback and takes the process down. + // `poll()`'s outer catch warns on every failed tick (`envelope log poll + // failed, retrying next tick ...`) -- the "warns and retries" behaviour + // this test's own name claims. Spied and silenced so the several EISDIR + // ticks below do not print real stderr into a clean `bun test` run, and + // asserted on (loosely: the exact tick count is timing-dependent) so the + // warning is checked at the call site instead of only inferred from the + // process surviving. it("survives a read that throws every tick, and resyncs once the path becomes a real file", async () => { await withTempDir(async (_dir, path) => { - const asDirectory = `${path}.d`; - mkdirSync(asDirectory); - // Non-zero st_size for a directory, so `size > offset` and the read is - // actually attempted rather than skipped. - writeFileSync(join(asDirectory, "child"), "x"); - - const controller = new AbortController(); - const tail = tailEnvelopeLog({ path: asDirectory, fromStart: true, pollMs: POLL_MS, signal: controller.signal }); - const collecting = collect(tail, 1, controller, 2000); - - // Several ticks against the unreadable path. The process is still - // alive on the other side of this sleep, which is the assertion. - await Bun.sleep(POLL_MS * 5); - - unlinkSync(join(asDirectory, "child")); - rmdirSync(asDirectory); - writeFileSync(asDirectory, entryLine(6, "response")); - - const entries = await collecting; - expect(entries.map((e) => e.seq)).toEqual([6]); - unlinkSync(asDirectory); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + const asDirectory = `${path}.d`; + mkdirSync(asDirectory); + // Non-zero st_size for a directory, so `size > offset` and the read is + // actually attempted rather than skipped. + writeFileSync(join(asDirectory, "child"), "x"); + + const controller = new AbortController(); + const tail = tailEnvelopeLog({ path: asDirectory, fromStart: true, pollMs: POLL_MS, signal: controller.signal }); + const collecting = collect(tail, 1, controller, 2000); + + // Several ticks against the unreadable path. The process is still + // alive on the other side of this sleep, which is the assertion. + await Bun.sleep(POLL_MS * 5); + expect(errorSpy).toHaveBeenCalled(); + expect(errorSpy.mock.calls[0]?.[0]).toContain("retrying next tick"); + + unlinkSync(join(asDirectory, "child")); + rmdirSync(asDirectory); + writeFileSync(asDirectory, entryLine(6, "response")); + + const entries = await collecting; + expect(entries.map((e) => e.seq)).toEqual([6]); + unlinkSync(asDirectory); + } finally { + errorSpy.mockRestore(); + } }); }); @@ -428,4 +463,43 @@ describe("tailEnvelopeLog (N50)", () => { expect(malformed).toEqual([badLine]); }); }); + + // Backlog item K. `isTapEntryShape` deliberately never constrains + // `envelope` -- it is `unknown` by design (R5.1) -- so a line whose + // `envelope` is absent, `null`, or a bare string is still a valid + // TapEntry as far as this function is concerned, and reaches the consumer + // rather than being reported through `onMalformedLine`. That is + // load-bearing: the Guardian must be able to tap anything that crossed + // the wire, including a malformed body it never got a real envelope for + // (see server.ts's Parse-error path, which taps a response with no + // paired request at all). + it("accepts a line whose envelope is absent, null, or a bare string, without reporting it as malformed", async () => { + await withTempDir(async (_dir, path) => { + const malformed: string[] = []; + const base = { + recorded_at: "2026-08-09T12:04:31.221Z", + direction: "request" as const, + method: "steps/toolCallRequest", + rpc_id: 1, + }; + const missingEnvelope = JSON.stringify({ ...base, seq: 1 }); + const nullEnvelope = JSON.stringify({ ...base, seq: 2, envelope: null }); + const stringEnvelope = JSON.stringify({ ...base, seq: 3, envelope: "not an object" }); + writeFileSync(path, `${missingEnvelope}\n${nullEnvelope}\n${stringEnvelope}\n`); + + const controller = new AbortController(); + const tail = tailEnvelopeLog({ + path, + fromStart: true, + pollMs: POLL_MS, + signal: controller.signal, + onMalformedLine: (line) => malformed.push(line), + }); + + const entries = await collect(tail, 3, controller); + expect(entries.map((e) => e.seq)).toEqual([1, 2, 3]); + expect(entries.map((e) => e.envelope)).toEqual([undefined, null, "not an object"]); + expect(malformed).toEqual([]); + }); + }); }); From 5acac308a10c8ee1e2e1e8538332b3d4a30debd1 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Mon, 10 Aug 2026 08:32:58 +0300 Subject: [PATCH 18/35] Fix a path-fragile invariant filter, assert both sides of the tap contract invariants.test.ts's readSourceFiles filtered test files with `!f.includes("/test/")`, which does not exclude a test/ directory sitting directly under a scanned src/ -- Glob.scanSync returns paths relative to the scanned root, with no leading slash, so such a directory produces a path like `test/foo.ts` with no `/test/` substring to find. No scanned package has that layout today, so this made the four R3.2/R3.3/R5.1/R5.2 gates stricter than intended rather than weaker, but it was wrong as written. Extracted into a named isUnderTestDir() using `/(^|\/)test\//`, which catches a test/ segment at the start of the path as well as nested, without false-positiving on a segment that merely starts with "test" (testing/, latest/). New tests exercise the matcher directly, mirroring the existing "the import gate itself" pattern; confirmed the old substring check would have let invariants.test.ts's own directory through unfiltered. envelope-tap-roundtrip.test.ts -- the contract test that catches drift between the Guardian's TapEntry and the Inspector's independently-declared one -- asserted request?.recorded_at's type and request?.method's value but never response?.recorded_at or response?.method. Added both, so drift on either side of the pairing is caught, not only the request side. Slice: #3 --- test/envelope-tap-roundtrip.test.ts | 2 ++ test/invariants.test.ts | 45 ++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/test/envelope-tap-roundtrip.test.ts b/test/envelope-tap-roundtrip.test.ts index ff69e7e4..2be2c278 100644 --- a/test/envelope-tap-roundtrip.test.ts +++ b/test/envelope-tap-roundtrip.test.ts @@ -74,9 +74,11 @@ describe("S6 round trip: Guardian tap (N26) -> Inspector tail (N50) -> badge (U2 expect(request?.seq).toBe(1); expect(response?.seq).toBe(2); expect(typeof request?.recorded_at).toBe("string"); + expect(typeof response?.recorded_at).toBe("string"); expect(request?.direction).toBe("request"); expect(response?.direction).toBe("response"); expect(request?.method).toBe("steps/toolCallRequest"); + expect(response?.method).toBe("steps/toolCallRequest"); expect(request?.rpc_id).toBe(77); expect(response?.rpc_id).toBe(77); diff --git a/test/invariants.test.ts b/test/invariants.test.ts index e912a1e9..aff04217 100644 --- a/test/invariants.test.ts +++ b/test/invariants.test.ts @@ -22,6 +22,20 @@ function stripComments(src: string): string { return noBlockComments.replace(/(? !f.includes("/test/")); + const files = [...new Glob("**/*.ts").scanSync(dir)].filter((f) => !isUnderTestDir(f)); expect({ dir, sourceFiles: files.length > 0 }).toEqual({ dir, sourceFiles: true }); return files.map((f) => ({ file: f, code: stripComments(readFileSync(`${dir}/${f}`, "utf8")) })); } @@ -223,3 +237,32 @@ describe("the import gate itself", () => { expect(ignored).toEqual(ignored.map(({ line }) => ({ line, found: false }))); }); }); + +describe("the source-file filter itself", () => { + /** + * `Glob.scanSync`'s relative paths never carry a leading slash, so a + * `test/` directory sitting directly under the scanned root -- rather than + * nested deeper -- produces a path with no `/test/` substring at all. No + * scanned package has such a directory today (each puts `test/` as a + * sibling of `src/`, never inside it), so this was stricter-than-intended + * rather than a real hole, but it is still the exact case a bare + * `.includes("/test/")` misses. + */ + it("excludes a test/ segment at the start of the path, not only when nested", () => { + const paths = ["test/invariants.test.ts", "src/test/helper.ts", "packages/foo/test/bar.ts"].map((path) => ({ + path, + excluded: isUnderTestDir(path), + })); + + expect(paths).toEqual(paths.map(({ path }) => ({ path, excluded: true }))); + }); + + it("does not exclude a segment that merely starts with the letters 'test'", () => { + const paths = ["latest/foo.ts", "testing/bar.ts", "src/index.ts"].map((path) => ({ + path, + excluded: isUnderTestDir(path), + })); + + expect(paths).toEqual(paths.map(({ path }) => ({ path, excluded: false }))); + }); +}); From 25e756db33b7b675b006e8068d643f02d0c7d512 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Mon, 10 Aug 2026 08:33:17 +0300 Subject: [PATCH 19/35] Name S6's two stringify-side exceptions; update the Verify count The V2 runbook's "S6 carries raw tool arguments" section already named the parse-side canonicalisations JSON.parse applies (duplicate keys collapsed, number literals canonicalised, integer-like keys hoisted), then claimed "nothing between the parse and the file removed, rewrote, or hid any part of it" -- without naming two more exceptions to that claim, both on the stringify side rather than the parse side, since a stringify happens again every time S6's line is written and every time the Inspector pretty-prints it. A number literal beyond a double's range (1e400) parses fine, as Infinity, but JSON.stringify writes Infinity as null -- the field survives, its value does not. -0 survives the parse with its sign intact and loses it the same way, written as "0". Both verified directly. The adjacent load-bearing sentence -- ".acs/envelopes.jsonl records the JSON value the Guardian parsed, unmodified: no field stripping, no redaction, no reordering of anything we control" -- is exactly true and is unchanged. README's Verify section still said 121 tests from before this fix wave; updated to the real count, 127 tests across 16 files (126 pass, 1 skip). Slice: #3 --- README.md | 2 +- docs/demos/v2-runbook.md | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bea477b9..279280f0 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Second, through the real `claude` CLI with `.claude/settings.json` installed — ### Verify ```bash -bun test # 121 tests across 16 files (120 pass, 1 skip), including the R3.2/R3.3 +bun test # 127 tests across 16 files (126 pass, 1 skip), including the R3.2/R3.3 # and R5.1/R5.2 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/v2-runbook.md b/docs/demos/v2-runbook.md index a0fa3a98..b87dc586 100644 --- a/docs/demos/v2-runbook.md +++ b/docs/demos/v2-runbook.md @@ -317,7 +317,14 @@ host, so an `arguments` object containing both `"0"` and `"a"` is a real possibi rather than a contrived one — and it would render with `"0"` first regardless of send order. If you need the literal bytes a host sent, capture them at the transport; S6 is the parsed envelope. What S6 does guarantee is that nothing between the parse and the -file removed, rewrote, or hid any part of it. +file removed, rewrote, or hid any part of it — with two named exceptions, both on the +*stringify* side rather than the parse side, since a stringify happens every time S6's +line is written and again every time the Inspector pretty-prints it. A number literal +too large for a JS `double` (`1e400`) parses fine, as `Infinity`, but `JSON.stringify` +writes `Infinity` as `null` — the field survives; its value does not. `-0` survives the +parse with its sign intact and loses it the same way: `JSON.stringify(-0)` is `"0"`. +Neither is something this project decided; both are `JSON.stringify`'s own behaviour, +wherever it runs. The consequence is direct: **the log contains whatever your tool calls contained** — file paths, command lines, and anything else that rode along in `arguments`. That is why From 8f94f630a22d9fa8004c2de499ac65cae7b3476e Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Mon, 10 Aug 2026 08:57:48 +0300 Subject: [PATCH 20/35] Guardian: make toRepoRelativeMessage total for any unknown, anchor the root Review finding against the previous wave's fix. toRepoRelativeMessage took a pre-extracted string, and both call sites built it with `error instanceof Error ? error.message : String(error)` -- a guard that exists because a catch clause's binding is `unknown`, but whose *result*, when `error instanceof Error` was true, the helper then assumed was a string. `instanceof Error` does not guarantee that: nothing stops `.message` from being reassigned to `undefined`, a number, or anything else after construction. Such an Error made `message.replace(...)` throw `TypeError: undefined is not an object (evaluating 'message.replace')`. Contained when it happens inside the inner catch (dispatch's own throw is itself caught by the outer net), but not when it happens inside the outer catch itself -- nothing above handleAcsRequest catches it, Bun.serve's fetch handler has no try, and it reproduces exactly the untapped HTML-500 fail-open this module's header exists to prevent. Confirmed reachable by hand against the pre-fix helper before implementing. Fixed by giving the helper the caught value itself, as `unknown`, and coercing with String() on the whole thing -- total for anything a catch clause can hand it. Both call sites now pass `error` directly, dropping the duplicated ternary. Exported (the only exported internal in this module) so its totality is asserted directly across a spread of unknown shapes, not only through one HTTP round trip; a second, real integration test (withNonStringMessageGuardian, structurally identical to withSchemalessGuardian but swapping in a validate-envelope.ts test double) still proves the outer catch itself stays total end to end. Same touch, three more findings against the same fix: - REPO_ROOT_PATTERN matched the repo root as a bare literal with an optional trailing separator, so a sibling directory whose name merely extends the root (a `_old` backup clone, say) had its shared prefix stripped too -- not a disclosure, since it names an unrelated directory, but a misleading diagnostic. Anchored with a lookahead requiring a path separator or end-of-string right after the root. - extractMethod's redundant `as { method: unknown }` cast, flagged in the previous wave's report as intentionally left alone pending confirmation, closed to match extractId/extractRpcId's style. - withSchemalessGuardian's doc comment claimed the copy always gets its own module instance. True for the first call under item C's fixed directory name; a second call importing the same path gets Bun's cached module from the first call instead, and its own freshly copied files go unread. Benign today, corrected to say so precisely. Also corrects this wave's own report: item G's fix makes the *filter* scan strictly fewer files, not the four gates themselves behave differently -- the code comment already had this right; only the report's prose was off. bun test: 132 tests across 16 files, 131 pass, 1 skip, 0 fail -- +5 tests over the previous wave's 127. bun run typecheck: zero errors. README's Verify count updated to the real number. Slice: #3 --- .gitignore | 5 + README.md | 2 +- packages/guardian/src/server.ts | 54 ++++++-- packages/guardian/test/server.test.ts | 190 +++++++++++++++++++++++++- tsconfig.json | 6 +- 5 files changed, 243 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index f1f7d0d6..5383f1bf 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,8 @@ __pycache__/ # make `bun run typecheck` see a stray duplicate of the Guardian's own # source. Fixed name (not mkdtemp's random one), so this one line covers it. packages/guardian/tmp-schemaless-scratch/ + +# Same reasoning, a second scratch tree: server.test.ts's non-string-message +# test double for validate-envelope.ts, alongside a real, unmodified copy of +# every other file in packages/guardian/src. +packages/guardian/tmp-nonstring-message-scratch/ diff --git a/README.md b/README.md index 279280f0..9b15cf18 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Second, through the real `claude` CLI with `.claude/settings.json` installed — ### Verify ```bash -bun test # 127 tests across 16 files (126 pass, 1 skip), including the R3.2/R3.3 +bun test # 132 tests across 16 files (131 pass, 1 skip), including the R3.2/R3.3 # and R5.1/R5.2 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/server.ts b/packages/guardian/src/server.ts index 745aa832..94e06da3 100644 --- a/packages/guardian/src/server.ts +++ b/packages/guardian/src/server.ts @@ -88,21 +88,57 @@ const LOOPBACK_ONLY = "127.0.0.1"; // Used only to redact it out of error text before that text leaves the // Guardian; see toRepoRelativeMessage below. const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url)).replace(/[/\\]+$/, ""); -const REPO_ROOT_PATTERN = new RegExp(`${REPO_ROOT.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[/\\\\]?`, "g"); +// The lookahead requires the root to be followed by a path separator or the +// end of the string, before the optional `[/\\]?` consumes one such +// separator. Without it, a *sibling* directory whose name merely extends +// the root (`ACS_reference_implementation_old`) matched too: the literal +// text of REPO_ROOT is a prefix of that name, so it stripped, leaving a +// misleading `_old/packages/spec` behind -- not a disclosure of this tree's +// own location, since it's a different directory entirely, but a +// diagnostic that then reads as if it were one. The lookahead makes that +// prefix match fail outright, so a message naming the sibling is left +// alone, in full. +const REPO_ROOT_PATTERN = new RegExp(`${REPO_ROOT.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=[/\\\\]|$)[/\\\\]?`, "g"); /** * Both catches in this module (the outer net in handleAcsRequest and the - * evaluation-failure catch in dispatch) surface a real error's `.message` - * to the ACS client and, via the tap, into S6 -- and a real error's message - * (an ENOENT out of a missing schema directory, say) carries this - * machine's absolute filesystem path, e.g. + * evaluation-failure catch in dispatch) surface a real error to the ACS + * client and, via the tap, into S6 -- and a real error's message (an ENOENT + * out of a missing schema directory, say) carries this machine's absolute + * filesystem path, e.g. * `/Users/you/.../ACS_reference_implementation/packages/spec/acs/...`. * That is diagnostic in a way this demo's value depends on, so the fix is * not to replace it with something generic -- it is to remove only the * part of it that discloses where this tree sits on disk, leaving the * repo-relative remainder (`packages/spec/acs/...`) intact. + * + * Takes the caught value itself, as `unknown`, rather than a pre-extracted + * string. Both call sites already had to guard with + * `error instanceof Error ? error.message : String(error)` because a catch + * clause's binding is `unknown` -- but an earlier version of this helper + * took that guard's *result* and assumed it was a string, which is true of + * every message this tree's own code happens to produce, not something + * `instanceof Error` guarantees: nothing stops `.message` from being + * reassigned to `undefined`, a number, or anything else after construction. + * That earlier version threw `TypeError: undefined is not an object + * (evaluating 'message.replace')` on exactly such an Error -- reachable + * only from the outer catch, since the inner catch's own throw is itself + * caught by the outer one, but reachable there with nothing above + * `handleAcsRequest` to catch it: the untapped HTML-500 fail-open this + * module's header exists to prevent. `String()` on the whole caught value + * keeps this helper total for any `unknown`, matching what a catch clause + * can actually hand it. + * + * Exported (unlike this module's other internals -- `dispatch`, + * `extractId`, `extractMethod`, `errorResponse`) so the anchor behaviour + * above and this function's totality for non-`Error`, non-string-message, + * and otherwise-shaped `unknown` values can be asserted directly, rather + * than only through a real Guardian and an HTTP round trip. The blocking + * regression this exists to prevent is still covered end to end, separately + * -- see server.test.ts's `withNonStringMessageGuardian` test. */ -function toRepoRelativeMessage(message: string): string { +export function toRepoRelativeMessage(error: unknown): string { + const message = String(error instanceof Error ? error.message : error); return message.replace(REPO_ROOT_PATTERN, ""); } @@ -239,7 +275,7 @@ async function handleAcsRequest( // JSON-RPC error, not an ACS `deny`: N27 stays V3's call. What this // buys is that the client can parse the answer at all, and that S6 // holds a response line paired with the request line above it. - const message = toRepoRelativeMessage(error instanceof Error ? error.message : String(error)); + const message = toRepoRelativeMessage(error); response = errorResponse(extractId(raw), EVALUATION_FAILED_CODE, `guardian failed to handle the request: ${message}`); } tap.write("response", response, method); @@ -296,7 +332,7 @@ async function dispatch( // 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 = toRepoRelativeMessage(error instanceof Error ? error.message : String(error)); + const message = toRepoRelativeMessage(error); return errorResponse(rpcId, EVALUATION_FAILED_CODE, `evaluation failed: ${message}`); } } @@ -341,7 +377,7 @@ function extractId(raw: unknown): string | number | null { * `dispatch` reads the schema-validated envelope's own `method`. */ function extractMethod(raw: unknown): string | null { if (typeof raw === "object" && raw !== null && "method" in raw) { - const method = (raw as { method: unknown }).method; + const method = raw.method; if (typeof method === "string") { return method; } diff --git a/packages/guardian/test/server.test.ts b/packages/guardian/test/server.test.ts index a2cab978..4608d76d 100644 --- a/packages/guardian/test/server.test.ts +++ b/packages/guardian/test/server.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it, beforeAll, afterAll } from "bun:test"; -import { copyFileSync, mkdirSync, readFileSync, readdirSync, rmdirSync, unlinkSync } from "node:fs"; +import { copyFileSync, mkdirSync, readFileSync, readdirSync, rmdirSync, unlinkSync, writeFileSync } 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 { startGuardian } from "../src/index.ts"; +import { toRepoRelativeMessage } from "../src/server.ts"; const HANDSHAKE_SCHEMA_PATH = "spec/acs/specification/v0.1.0/handshake.json"; @@ -227,8 +228,16 @@ const SCHEMALESS_SCRATCH_DIR = join(GUARDIAN_PKG, "tmp-schemaless-scratch"); * Guardian code that then runs is the real, current source -- the files are * copied at test time, so they cannot drift from `src/` -- and the failure it * produces is a real ENOENT out of `readdirSync`, thrown at request time - * because `buildAjv()` is lazy. The copy also gets its own module instance, - * so it cannot poison the Ajv registry the rest of this suite shares. + * because `buildAjv()` is lazy. The first call to this function gets its own + * module instance, so it cannot poison the Ajv registry the rest of this + * suite shares. A later call importing the same fixed + * `SCHEMALESS_SCRATCH_DIR` path a second time does not get a second fresh + * instance -- Bun's module cache keys by resolved path, so it gets back the + * *first* call's already-loaded module, and the freshly copied files on + * disk for that later call go unread. Benign here (every call copies + * byte-identical source, and this suite's own Ajv registry is never shared + * with the copy either way), but worth being precise about now that the + * directory name is fixed rather than fresh per call (backlog item C). * * Deletions here are explicit per file (repo constraint: nothing recursive). */ @@ -280,6 +289,90 @@ async function withSchemalessGuardian( } } +/** Same reasoning as SCHEMALESS_SCRATCH_DIR: a stable, predictable name, + * ignored and tsconfig-excluded, so a killed run cannot leave `bun run + * typecheck` a stray copy of the Guardian's own source. A distinct name + * from SCHEMALESS_SCRATCH_DIR, since a run of this suite can have both + * scratch trees on disk at once. */ +const NON_STRING_MESSAGE_SCRATCH_DIR = join(GUARDIAN_PKG, "tmp-nonstring-message-scratch"); + +/** + * A test double, not the real `validate-envelope.ts`. It exists to force a + * real `Error` whose `.message` has been overwritten to `undefined` through + * `dispatch`'s one rethrow route -- the same route `withSchemalessGuardian` + * above uses for a real ENOENT, but that route cannot also produce a + * non-string `.message`: nothing in the real schema-validation path does + * that to an error it throws. `EnvelopeValidationError` is redeclared here, + * distinct from the real one, so `dispatch`'s + * `error instanceof EnvelopeValidationError` check -- reading *this* file's + * class, inside the relocated copy -- correctly comes back `false` and + * rethrows, the same way it would for any error the real module didn't + * throw as an `EnvelopeValidationError`. + */ +const NON_STRING_MESSAGE_VALIDATE_ENVELOPE_SOURCE = ` +export class EnvelopeValidationError extends Error {} + +export function validateEnvelope(_input) { + const error = new Error("this message is about to be erased"); + error.message = undefined; + throw error; +} +`; + +/** + * Runs \`body\` against a Guardian whose \`validate-envelope.ts\` has been + * replaced by the test double above -- reproducing the blocking finding + * from this wave's review: a real \`Error\`, thrown on \`dispatch\`'s one + * rethrow route, whose \`.message\` is not a string. Structurally identical + * to \`withSchemalessGuardian\`: a real, unmodified copy of every other file + * in \`packages/guardian/src\`, dynamically imported from its own scratch + * directory so it is a distinct module instance, with only + * \`validate-envelope.ts\` swapped for the double. No \`envelopeLogPath\` -- + * this test needs no tap, and \`NULL_TAP\`'s totality is already covered + * elsewhere. + */ +async function withNonStringMessageGuardian(body: (guardian: { url: string }) => Promise): Promise { + const root = NON_STRING_MESSAGE_SCRATCH_DIR; + const srcDir = join(root, "src"); + const copied = readdirSync(join(GUARDIAN_PKG, "src")).filter((f) => f.endsWith(".ts") && f !== "validate-envelope.ts"); + let guardian: { close(): Promise } | undefined; + + mkdirSync(root); + try { + mkdirSync(srcDir); + for (const file of copied) { + copyFileSync(join(GUARDIAN_PKG, "src", file), join(srcDir, file)); + } + writeFileSync(join(srcDir, "validate-envelope.ts"), NON_STRING_MESSAGE_VALIDATE_ENVELOPE_SOURCE); + + const relocated = (await import(join(srcDir, "index.ts"))) as typeof import("../src/index.ts"); + const started = await relocated.startGuardian({ + port: 0, + manifestPath: join(REPO_ROOT, "policy", "manifest.yaml"), + mappingPath: join(REPO_ROOT, "mapping.yaml"), + }); + guardian = started; + + await body({ url: started.url }); + } finally { + await guardian?.close(); + for (const path of [join(srcDir, "validate-envelope.ts"), ...copied.map((file) => join(srcDir, file))]) { + try { + unlinkSync(path); + } catch { + // a run that failed early never created every one of these + } + } + for (const dir of [srcDir, root]) { + try { + rmdirSync(dir); + } catch { + // same + } + } + } +} + // Whole-branch review, finding 1 -- the fourth fail-open of V1's shape, and // the exit the tap's structural-totality claim did not cover. `dispatch` // rethrows any non-EnvelopeValidationError, and nothing used to catch it: @@ -361,6 +454,97 @@ describe("startGuardian POST /acs -- the outer net around dispatch", () => { expect(entries.map((e) => e.rpc_id)).toEqual([11, 11]); }); }); + + // Blocking finding from this wave's review. Pre-fix, toRepoRelativeMessage + // assumed any `unknown` satisfying `error instanceof Error` also carried a + // string `.message` -- true of the real ENOENT the test above forces, but + // not something `instanceof Error` guarantees. An Error whose `.message` + // has been overwritten to `undefined` throws `TypeError: undefined is not + // an object (evaluating 'message.replace')` out of the helper itself -- + // and unlike the inner catch's own throw (contained by this outer catch), + // a throw *from* the outer catch has nothing above `handleAcsRequest` to + // catch it: Bun.serve's fetch handler has no try, so it answers with the + // untapped HTML 500 the module header exists to prevent. Fails against + // the pre-fix helper (confirmed by hand before implementing the fix: the + // fetch below resolves to an HTML error page, and `res.json()` -- exactly + // guardianClient.post's call -- throws a SyntaxError instead of returning + // a response). + it("does not let a real Error with a non-string .message escape the outer catch as an HTML 500", async () => { + await withNonStringMessageGuardian(async ({ url }) => { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(toolCallEnvelope("ls -la", { id: 9 })), + }); + + const text = await res.text(); + expect(text.slice(0, 1)).toBe("{"); + const response = JSON.parse(text) as JsonRpcResponse; + + expect(response.jsonrpc).toBe("2.0"); + expect(response.id).toBe(9); + expect(response.error).toBeDefined(); + expect(response.error?.code).toBeGreaterThanOrEqual(-32099); + expect(response.error?.code).toBeLessThanOrEqual(-32000); + // The pre-fix behaviour this restores: total, coerced to text, rather + // than thrown. + expect(response.error?.message).toContain("undefined"); + }); + }); +}); + +describe("toRepoRelativeMessage", () => { + // The regression this wave's review found: an earlier version assumed its + // argument's `.message` was a string whenever `error instanceof Error` + // was true. `instanceof Error` says nothing about what `.message` was + // reassigned to after construction, so it wasn't. Exercised directly + // (rather than only through withNonStringMessageGuardian's HTTP round + // trip) so every shape of `unknown` a catch clause can hand it is covered + // without standing up a Guardian for each one. + it("never throws, for an Error whose .message is not a string", () => { + const undefinedMessage = new Error("erased below"); + (undefinedMessage as { message: unknown }).message = undefined; + const numberMessage = new Error("erased below"); + (numberMessage as { message: unknown }).message = 42; + const objectMessage = new Error("erased below"); + (objectMessage as { message: unknown }).message = { nested: true }; + + expect(toRepoRelativeMessage(undefinedMessage)).toBe("undefined"); + expect(toRepoRelativeMessage(numberMessage)).toBe("42"); + expect(toRepoRelativeMessage(objectMessage)).toBe("[object Object]"); + }); + + it("is total for non-Error unknown values too, matching what a catch clause can hand it", () => { + expect(toRepoRelativeMessage("a plain string")).toBe("a plain string"); + expect(toRepoRelativeMessage(42)).toBe("42"); + expect(toRepoRelativeMessage(null)).toBe("null"); + expect(toRepoRelativeMessage(undefined)).toBe("undefined"); + expect(toRepoRelativeMessage({ some: "object" })).toBe("[object Object]"); + }); + + // This file's own REPO_ROOT (above, line 190) keeps the trailing slash + // `fileURLToPath` gives a directory URL -- fine for join()ing against, + // but these two tests need the bare root, with nothing after it, to build + // "root + separator + subpath" and "root + suffix" strings without + // accidentally doubling or misplacing a slash. + const REPO_ROOT_BARE = REPO_ROOT.replace(/[/\\]+$/, ""); + + it("strips this repo's root, with or without a trailing separator", () => { + expect(toRepoRelativeMessage(new Error(`${REPO_ROOT_BARE}/packages/spec/acs`))).toBe("packages/spec/acs"); + expect(toRepoRelativeMessage(new Error(REPO_ROOT_BARE))).toBe(""); + }); + + // Recommended fix, same wave: the un-anchored version matched REPO_ROOT as + // a bare prefix, so a *sibling* directory whose name merely extends the + // root (a `_old` backup clone, say) had its shared prefix stripped too -- + // not a disclosure of this tree's own location, since it names a + // different directory entirely, but a misleading diagnostic that then + // reads as if it were a path under this repo. + it("leaves a sibling directory whose name extends the repo root untouched", () => { + const siblingPath = `${REPO_ROOT_BARE}_old/packages/spec`; + + expect(toRepoRelativeMessage(new Error(siblingPath))).toBe(siblingPath); + }); }); // PR #10 review, Critical: mapping.yaml's intervention_points table is what diff --git a/tsconfig.json b/tsconfig.json index 8d3ce813..350ed5c6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,10 @@ // test's doc comment). A killed run can leave this behind; excluded so // it can never look like a second, drifted copy of the Guardian's // source to `bun run typecheck`. - "packages/guardian/tmp-schemaless-scratch" + "packages/guardian/tmp-schemaless-scratch", + // Same reasoning: server.test.ts's non-string-message test double for + // validate-envelope.ts, copied here alongside the rest of + // packages/guardian/src at test time. + "packages/guardian/tmp-nonstring-message-scratch" ] } From b3b08266c5723a8a982f5d07ccf6e1473d4be238 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Mon, 10 Aug 2026 09:13:47 +0300 Subject: [PATCH 21/35] Guardian: make toRepoRelativeMessage unconditionally total Closes a residual the follow-up review surfaced and correctly parked -- unreachable from any throw site in this repo today, identical exposure existed pre-fix, out of that round's scope -- and the coordinator asked closed anyway: the outer net's stated job is holding for anything, "today" is doing real work in that sentence given V3 adds new routes through this exact path, and this project has already found five fail-opens of this general shape, the last introduced by a fix for a minor. Four shapes still threw out of the shipped function body, reproduced by hand before fixing: an Error whose .message is a throwing accessor; a value whose toString/valueOf both throw; a Proxy throwing on `get`; a Proxy throwing on `getPrototypeOf`, which breaks `instanceof Error` itself before String() ever runs. Fixed by wrapping the whole function body, including the instanceof check, in its own try/catch, returning a fixed "" on any throw -- honest about total unreadability rather than guessing at a partial string. A comment states this is belt-and-braces, not a reaction to a live bug, naming all four shapes so a future reader doesn't go looking for the throw site that motivated it. Covering tests: one unit test constructing all four shapes directly against the exported helper; one new integration test over real HTTP (withFakeValidateEnvelopeGuardian, generalizing the previous wave's withNonStringMessageGuardian to take both a scratch directory and a fake validate-envelope.ts source) proving the outer net itself still returns parseable JSON-RPC rather than an HTML 500. That integration test uses the throwing-.message-accessor shape, not the getPrototypeOf Proxy the review led with -- discovered while wiring it up that dispatch's own `instanceof EnvelopeValidationError` check hits the identical getPrototypeOf trap first, so the trap's own thrown Error replaces the Proxy before it ever reaches toRepoRelativeMessage; the Proxy case stays covered at the unit level, where it is actually exercised. Also discovered while wiring up two different fake validate-envelope.ts sources: reusing one scratch directory for both let the second test silently inherit the first's cached module (Bun's module cache keys by resolved path), so each fake source now gets its own directory, both gitignored and tsconfig-excluded per item C's precedent. bun test: 134 tests across 16 files, 133 pass, 1 skip, 0 fail -- +2 over the previous wave's 132. bun run typecheck: zero errors. README's Verify count updated. Slice: #3 --- .gitignore | 13 +- README.md | 2 +- packages/guardian/src/server.ts | 28 +++- packages/guardian/test/server.test.ts | 190 ++++++++++++++++++++++---- tsconfig.json | 9 +- 5 files changed, 207 insertions(+), 35 deletions(-) diff --git a/.gitignore b/.gitignore index 5383f1bf..f3fd3129 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,12 @@ __pycache__/ # source. Fixed name (not mkdtemp's random one), so this one line covers it. packages/guardian/tmp-schemaless-scratch/ -# Same reasoning, a second scratch tree: server.test.ts's non-string-message -# test double for validate-envelope.ts, alongside a real, unmodified copy of -# every other file in packages/guardian/src. -packages/guardian/tmp-nonstring-message-scratch/ +# Same reasoning, two more scratch trees: server.test.ts's fake +# validate-envelope.ts test doubles (a non-string .message, and a throwing +# .message accessor), each alongside a real, unmodified copy of every other +# file in packages/guardian/src. Two separate directories, not one, because +# Bun's module cache keys by resolved path and these two fake sources differ +# from each other -- sharing one directory would silently hand the second +# test the first's already-loaded module. +packages/guardian/tmp-undefined-message-scratch/ +packages/guardian/tmp-throwing-message-accessor-scratch/ diff --git a/README.md b/README.md index 9b15cf18..e63d7494 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Second, through the real `claude` CLI with `.claude/settings.json` installed — ### Verify ```bash -bun test # 132 tests across 16 files (131 pass, 1 skip), including the R3.2/R3.3 +bun test # 134 tests across 16 files (133 pass, 1 skip), including the R3.2/R3.3 # and R5.1/R5.2 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/server.ts b/packages/guardian/src/server.ts index 94e06da3..f7b59c57 100644 --- a/packages/guardian/src/server.ts +++ b/packages/guardian/src/server.ts @@ -135,11 +135,33 @@ const REPO_ROOT_PATTERN = new RegExp(`${REPO_ROOT.replace(/[.*+?^${}()|[\]\\]/g, * and otherwise-shaped `unknown` values can be asserted directly, rather * than only through a real Guardian and an HTTP round trip. The blocking * regression this exists to prevent is still covered end to end, separately - * -- see server.test.ts's `withNonStringMessageGuardian` test. + * -- see server.test.ts's `withFakeValidateEnvelopeGuardian` tests. + * + * The whole body is wrapped in its own try/catch, including the + * `instanceof` check -- belt-and-braces, not a reaction to a live bug. + * Nothing in this tree throws an `Error` whose `.message` is a + * throwing accessor, a value whose `toString`/`valueOf` throws, or a + * `Proxy` that throws on `get` or on `getPrototypeOf` (which would defeat + * `instanceof Error` itself, since it walks the prototype chain through + * `[[GetPrototypeOf]]`, before `String()` below ever runs) -- all four are + * unreachable from any throw site in this repo today, same as the shape the + * previous fix in this module closed. They are guarded anyway because + * "unreachable today" should not be load-bearing for the one function whose + * entire job is upholding this module's stated contract that nothing + * escapes the outer net: this project has already found five fail-opens of + * this shape, the last one introduced by a fix for a minor, and V3 (N27 + * `denyOnInvalidEnvelope`) adds new routes through this exact path. The + * fallback string names the failure mode rather than guessing at a partial + * message, since the point is that nothing about the original error could + * be read at all. */ export function toRepoRelativeMessage(error: unknown): string { - const message = String(error instanceof Error ? error.message : error); - return message.replace(REPO_ROOT_PATTERN, ""); + try { + const message = String(error instanceof Error ? error.message : error); + return message.replace(REPO_ROOT_PATTERN, ""); + } catch { + return ""; + } } /** diff --git a/packages/guardian/test/server.test.ts b/packages/guardian/test/server.test.ts index 4608d76d..5912f0d1 100644 --- a/packages/guardian/test/server.test.ts +++ b/packages/guardian/test/server.test.ts @@ -289,12 +289,21 @@ async function withSchemalessGuardian( } } -/** Same reasoning as SCHEMALESS_SCRATCH_DIR: a stable, predictable name, - * ignored and tsconfig-excluded, so a killed run cannot leave `bun run - * typecheck` a stray copy of the Guardian's own source. A distinct name - * from SCHEMALESS_SCRATCH_DIR, since a run of this suite can have both - * scratch trees on disk at once. */ -const NON_STRING_MESSAGE_SCRATCH_DIR = join(GUARDIAN_PKG, "tmp-nonstring-message-scratch"); +/** + * One scratch directory *per distinct fake source*, not shared the way + * `SCHEMALESS_SCRATCH_DIR` is shared across `withSchemalessGuardian`'s four + * tests. Those four all copy the *same* real files every time, so whichever + * call's module instance Bun's cache happens to answer with behaves + * identically. These two fake sources differ from each other, and Bun's + * module cache keys by resolved path: reusing one directory for both meant + * the second call's `import()` returned the *first* call's already-loaded + * module -- silently exercising the wrong test double, discovered by this + * test failing with the first double's behaviour instead of the second's + * before this was split out. Two names, so each call gets a path Bun has + * never loaded before. + */ +const UNDEFINED_MESSAGE_SCRATCH_DIR = join(GUARDIAN_PKG, "tmp-undefined-message-scratch"); +const THROWING_MESSAGE_ACCESSOR_SCRATCH_DIR = join(GUARDIAN_PKG, "tmp-throwing-message-accessor-scratch"); /** * A test double, not the real `validate-envelope.ts`. It exists to force a @@ -309,7 +318,7 @@ const NON_STRING_MESSAGE_SCRATCH_DIR = join(GUARDIAN_PKG, "tmp-nonstring-message * rethrows, the same way it would for any error the real module didn't * throw as an `EnvelopeValidationError`. */ -const NON_STRING_MESSAGE_VALIDATE_ENVELOPE_SOURCE = ` +const UNDEFINED_MESSAGE_VALIDATE_ENVELOPE_SOURCE = ` export class EnvelopeValidationError extends Error {} export function validateEnvelope(_input) { @@ -319,20 +328,62 @@ export function validateEnvelope(_input) { } `; +/** + * A second test double, for the residual the follow-up review surfaced: + * `toRepoRelativeMessage`'s own `error instanceof Error ? error.message : + * error` line can itself throw, if `.message` is an accessor that throws on + * get -- a case the plain `undefined`-message double above does not + * exercise, since overwriting `.message` with a value never triggers a + * getter. `EnvelopeValidationError` is redeclared here for the same reason + * as the double above. + * + * Deliberately *not* the getPrototypeOf-trapping Proxy the review also + * named. That shape is real and is covered directly, at the unit level, + * below -- but it cannot reach `toRepoRelativeMessage` unmutated through + * this route: `dispatch`'s own `error instanceof EnvelopeValidationError` + * check runs first, and `instanceof` needs exactly the trapped + * `[[GetPrototypeOf]]` internal method to walk the prototype chain, so the + * *trap's own thrown Error* replaces the Proxy at that point -- a normal, + * well-behaved Error reaches the outer catch instead, and the interesting + * case never arrives. A throwing `.message` accessor has no such problem: + * `instanceof` never touches `.message`, so a real `Error` carrying one + * passes through dispatch's check untouched and reaches + * `toRepoRelativeMessage` exactly as thrown. + */ +const THROWING_MESSAGE_ACCESSOR_VALIDATE_ENVELOPE_SOURCE = ` +export class EnvelopeValidationError extends Error {} + +export function validateEnvelope(_input) { + const error = new Error("real message, about to be hidden behind a throwing getter"); + Object.defineProperty(error, "message", { + get() { + throw new Error("message getter blew up"); + }, + }); + throw error; +} +`; + /** * Runs \`body\` against a Guardian whose \`validate-envelope.ts\` has been - * replaced by the test double above -- reproducing the blocking finding - * from this wave's review: a real \`Error\`, thrown on \`dispatch\`'s one - * rethrow route, whose \`.message\` is not a string. Structurally identical - * to \`withSchemalessGuardian\`: a real, unmodified copy of every other file - * in \`packages/guardian/src\`, dynamically imported from its own scratch - * directory so it is a distinct module instance, with only - * \`validate-envelope.ts\` swapped for the double. No \`envelopeLogPath\` -- - * this test needs no tap, and \`NULL_TAP\`'s totality is already covered - * elsewhere. + * replaced by \`fakeSource\` -- reproducing, through \`dispatch\`'s one + * rethrow route, a pathological value that a real \`validateEnvelope\` + * would never throw. Structurally identical to \`withSchemalessGuardian\`: a + * real, unmodified copy of every other file in \`packages/guardian/src\`, + * dynamically imported from its own scratch directory so it is a distinct + * module instance, with only \`validate-envelope.ts\` swapped for the + * double. No \`envelopeLogPath\` -- these tests need no tap, and + * \`NULL_TAP\`'s totality is already covered elsewhere. + * + * \`root\` is the caller's -- one of the two scratch-dir constants above, + * never shared between two different \`fakeSource\`s (see their doc comment + * for why that matters here specifically). */ -async function withNonStringMessageGuardian(body: (guardian: { url: string }) => Promise): Promise { - const root = NON_STRING_MESSAGE_SCRATCH_DIR; +async function withFakeValidateEnvelopeGuardian( + root: string, + fakeSource: string, + body: (guardian: { url: string }) => Promise, +): Promise { const srcDir = join(root, "src"); const copied = readdirSync(join(GUARDIAN_PKG, "src")).filter((f) => f.endsWith(".ts") && f !== "validate-envelope.ts"); let guardian: { close(): Promise } | undefined; @@ -343,7 +394,7 @@ async function withNonStringMessageGuardian(body: (guardian: { url: string }) => for (const file of copied) { copyFileSync(join(GUARDIAN_PKG, "src", file), join(srcDir, file)); } - writeFileSync(join(srcDir, "validate-envelope.ts"), NON_STRING_MESSAGE_VALIDATE_ENVELOPE_SOURCE); + writeFileSync(join(srcDir, "validate-envelope.ts"), fakeSource); const relocated = (await import(join(srcDir, "index.ts"))) as typeof import("../src/index.ts"); const started = await relocated.startGuardian({ @@ -470,7 +521,7 @@ describe("startGuardian POST /acs -- the outer net around dispatch", () => { // guardianClient.post's call -- throws a SyntaxError instead of returning // a response). it("does not let a real Error with a non-string .message escape the outer catch as an HTML 500", async () => { - await withNonStringMessageGuardian(async ({ url }) => { + await withFakeValidateEnvelopeGuardian(UNDEFINED_MESSAGE_SCRATCH_DIR, UNDEFINED_MESSAGE_VALIDATE_ENVELOPE_SOURCE, async ({ url }) => { const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, @@ -491,14 +542,52 @@ describe("startGuardian POST /acs -- the outer net around dispatch", () => { expect(response.error?.message).toContain("undefined"); }); }); + + // Residual the follow-up review surfaced and the coordinator asked closed + // anyway: an Error whose `.message` is an accessor that throws on get + // defeats `error instanceof Error ? error.message : error` inside + // toRepoRelativeMessage itself. Unreachable from any real throw site in + // this repo today -- belt and braces, not a reaction to a live bug (see + // toRepoRelativeMessage's doc comment) -- but the unit assertions in the + // describe block below only prove the helper itself is total; this + // proves the outer net around it still holds when the value it's handed + // is this pathological. (The getPrototypeOf-trapping Proxy the review + // also named is covered at the unit level only, not here -- see + // THROWING_MESSAGE_ACCESSOR_VALIDATE_ENVELOPE_SOURCE's doc comment for + // why that one specifically cannot reach toRepoRelativeMessage unmutated + // through dispatch's rethrow route.) + it("does not let an Error with a throwing .message accessor escape the outer catch as an HTML 500 either", async () => { + await withFakeValidateEnvelopeGuardian( + THROWING_MESSAGE_ACCESSOR_SCRATCH_DIR, + THROWING_MESSAGE_ACCESSOR_VALIDATE_ENVELOPE_SOURCE, + async ({ url }) => { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(toolCallEnvelope("ls -la", { id: 10 })), + }); + + const text = await res.text(); + expect(text.slice(0, 1)).toBe("{"); + const response = JSON.parse(text) as JsonRpcResponse; + + expect(response.jsonrpc).toBe("2.0"); + expect(response.id).toBe(10); + expect(response.error).toBeDefined(); + expect(response.error?.code).toBeGreaterThanOrEqual(-32099); + expect(response.error?.code).toBeLessThanOrEqual(-32000); + expect(response.error?.message).toContain(""); + }, + ); + }); }); describe("toRepoRelativeMessage", () => { - // The regression this wave's review found: an earlier version assumed its - // argument's `.message` was a string whenever `error instanceof Error` + // The regression an earlier review wave found: an earlier version assumed + // its argument's `.message` was a string whenever `error instanceof Error` // was true. `instanceof Error` says nothing about what `.message` was // reassigned to after construction, so it wasn't. Exercised directly - // (rather than only through withNonStringMessageGuardian's HTTP round + // (rather than only through withFakeValidateEnvelopeGuardian's HTTP round // trip) so every shape of `unknown` a catch clause can hand it is covered // without standing up a Guardian for each one. it("never throws, for an Error whose .message is not a string", () => { @@ -522,6 +611,61 @@ describe("toRepoRelativeMessage", () => { expect(toRepoRelativeMessage({ some: "object" })).toBe("[object Object]"); }); + // Follow-up review residual, parked by that review on correct facts + // (unreachable from any throw site here today, identical exposure existed + // pre-fix, out of that round's scope) and closed anyway: the contract this + // function exists to uphold is that nothing escapes the outer net, and + // "unreachable today" should not be load-bearing for that (see this + // function's doc comment). Four shapes, each defeating a different step + // of `String(error instanceof Error ? error.message : error)`: + // - an Error whose `.message` is a throwing accessor + // - a value whose `toString`/`valueOf` both throw, so `String()` itself + // throws on the non-Error branch + // - a Proxy that throws on `get` (String() needs to read + // Symbol.toPrimitive/toString/valueOf off it) + // - a Proxy that throws on `getPrototypeOf`, defeating `instanceof + // Error` before `String()` is ever reached at all + it("never throws, even for values that defeat message access, stringification, or property/prototype traps", () => { + const throwingAccessor = new Error("real message, about to be hidden behind a throwing getter"); + Object.defineProperty(throwingAccessor, "message", { + get() { + throw new Error("message getter blew up"); + }, + }); + + const throwingToString = { + toString() { + throw new Error("toString blew up"); + }, + valueOf() { + throw new Error("valueOf blew up"); + }, + }; + + const throwingGetProxy = new Proxy( + {}, + { + get() { + throw new Error("get trap blew up"); + }, + }, + ); + + const throwingGetPrototypeOfProxy = new Proxy( + {}, + { + getPrototypeOf() { + throw new Error("getPrototypeOf trap blew up"); + }, + }, + ); + + expect(toRepoRelativeMessage(throwingAccessor)).toBe(""); + expect(toRepoRelativeMessage(throwingToString)).toBe(""); + expect(toRepoRelativeMessage(throwingGetProxy)).toBe(""); + expect(toRepoRelativeMessage(throwingGetPrototypeOfProxy)).toBe(""); + }); + // This file's own REPO_ROOT (above, line 190) keeps the trailing slash // `fileURLToPath` gives a directory URL -- fine for join()ing against, // but these two tests need the bare root, with nothing after it, to build diff --git a/tsconfig.json b/tsconfig.json index 350ed5c6..30cdd7ff 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,9 +13,10 @@ // it can never look like a second, drifted copy of the Guardian's // source to `bun run typecheck`. "packages/guardian/tmp-schemaless-scratch", - // Same reasoning: server.test.ts's non-string-message test double for - // validate-envelope.ts, copied here alongside the rest of - // packages/guardian/src at test time. - "packages/guardian/tmp-nonstring-message-scratch" + // Same reasoning: server.test.ts's two fake validate-envelope.ts test + // doubles, each copied here alongside the rest of packages/guardian/src + // at test time. Two directories, not one -- see .gitignore for why. + "packages/guardian/tmp-undefined-message-scratch", + "packages/guardian/tmp-throwing-message-accessor-scratch" ] } From 23486a536c7c8f34a1937b7e13a3c83961d3ef1a Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Mon, 10 Aug 2026 09:22:43 +0300 Subject: [PATCH 22/35] Record why the Trace pillar isn't emittable from the v0.1.0 wire (D10) Read trace/otel-mapping.json against the pinned schemas. Two attributes it marks required have no wire source: `acs.evaluator` is not a field of AcsResult at all, and `acs.capability` maps to payload.capability, which hooks/tool-call-request.json leaves optional. The consequence is sharper than a missing field. A downstream consumer of the ACS wire cannot emit a conformant trace -- only the Guardian can, from process-local knowledge the contract does not carry. That cuts against R5.1/R5.2 and against V2's whole design, where S6 is readable by anything and the Inspector proves it by importing nothing. An OTel exporter reading S6 hits the same wall. Second place this project becomes a forcing function for v0.2 rather than a consumer of v0.1.0, after steps/modelCall. Slice: #3 --- docs/shaping/acs-reference-impl-shaping.md | 14 +++++++++++++- docs/shaping/acs-reference-impl-slices.md | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/shaping/acs-reference-impl-shaping.md b/docs/shaping/acs-reference-impl-shaping.md index 928d85cd..c66060f4 100644 --- a/docs/shaping/acs-reference-impl-shaping.md +++ b/docs/shaping/acs-reference-impl-shaping.md @@ -527,4 +527,16 @@ flowchart TB | ~~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 | -| D10 | 🔴 **New, from V2 planning.** R5.3 — does this implementation claim the ACS **Trace** pillar? | Open | `specification/v0.1.0/trace/otel-mapping.json` is normative: a deployment emitting OTel for the Trace pillar MUST use its span names and required attributes verbatim, and it maps `steps/toolCallRequest` → `gen_ai.tool.call` by name. `trace/ocsf-mapping.json` is its sibling. V2's envelope tap (S6) is a raw JSONL log, deliberately not an OTel or OCSF export, so today we claim neither pillar. R5.3 requires declaring that either way, which makes this V7's problem at the latest — either the conformance matrix records Trace as an explicit non-claim, or a slice picks it up | +| D10 | 🔴 **New, from V2 planning.** R5.3 — does this implementation claim the ACS **Trace** pillar? | Open | `specification/v0.1.0/trace/otel-mapping.json` is normative: a deployment emitting OTel for the Trace pillar MUST use its span names and required attributes verbatim, and it maps `steps/toolCallRequest` → `gen_ai.tool.call` by name. `trace/ocsf-mapping.json` is its sibling. V2's envelope tap (S6) is a raw JSONL log, deliberately not an OTel or OCSF export, so today we claim neither pillar. R5.3 requires declaring that either way, which makes this V7's problem at the latest — either the conformance matrix records Trace as an explicit non-claim, or a slice picks it up. **⚠️ Two required span attributes have no wire source — see the D10 evidence note below** | + +**⚠️ D10 evidence — the Trace pillar is not emittable from the v0.1.0 wire alone.** Read after V2 shipped, against the pinned schemas: + +| Required by `trace/otel-mapping.json` | Source in v0.1.0 | Status | +|---|---|---| +| `gen_ai.tool.name` (on `gen_ai.tool.call`) | `payload.tool.name` — `required` | ✅ | +| `acs.capability` (on `gen_ai.tool.call`) | `payload.capability` — **optional**; `hooks/tool-call-request.json` requires only `tool` and `arguments` | ⚠️ A fully conformant envelope may omit it, so a fully conformant span cannot always be built | +| `acs.decision` (on the `acs.decision` span event) | `AcsResult.decision` — `required` | ✅ | +| `acs.evaluator` (on the `acs.decision` span event) | **none** — `AcsResult` has no `evaluator` field | ❌ No wire source at all | +| `acs.confidence`, `acs.evaluator_version`, `acs.model_id` (conditional, "required when present in the decision envelope") | **none** — no such fields in `AcsResult` | ❌ Can never be "present in the decision envelope" | + +The consequence is sharper than a missing field: a **downstream consumer of the ACS wire cannot emit a conformant trace**. Only the Guardian can, from process-local knowledge the contract does not carry. That cuts against R5.1/R5.2 — V2's whole design is that S6 is readable by anything, and the Inspector proves it by importing nothing. An OTel exporter reading S6 would hit the same wall. Either v0.2 adds `evaluator` (and friends) to `AcsResult` and promotes `capability` to required for this hook, or the Trace pillar is explicitly a Guardian-side emission, not a wire-derived one, and should say so. This is the second place this project has become a forcing function for v0.2 rather than a consumer of v0.1.0 — see the `steps/modelCall` tension under D4. diff --git a/docs/shaping/acs-reference-impl-slices.md b/docs/shaping/acs-reference-impl-slices.md index b5606087..e12c6e8a 100644 --- a/docs/shaping/acs-reference-impl-slices.md +++ b/docs/shaping/acs-reference-impl-slices.md @@ -259,7 +259,7 @@ Runs on a schedule in CI. MS-ACS is `0.3.1-beta` and warns of breaking changes b | D5 | Determinism of the demo | V1 onward | | ~~D7~~ | ✅ **Closed: Rego.** Cedar's sole advantage was avoiding an external binary; the SDK bundles OPA, so that advantage does not exist. Stock bundle verified 105/105 under the bundled OPA | ~~V1~~ | | 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 | -| D10 | 🔴 **The ACS Trace pillar is unclaimed by any slice.** `specification/v0.1.0/trace/otel-mapping.json` and `trace/ocsf-mapping.json` are *normative* — the OTel mapping states that a deployment emitting OTel for the Trace pillar MUST use its span names and required attributes verbatim, and it names `steps/toolCallRequest` → `gen_ai.tool.call` explicitly. V2's S6 is deliberately a raw envelope log, **not** an OTel or OCSF export, so this implementation currently claims neither. R5.3 says we declare what we claim and what we do not — so either V7 measures the Trace pillar as an explicit non-claim, or a slice picks it up. Surfaced during V2 planning; nothing depends on it yet | V7 scope | +| D10 | 🔴 ⚠️ **Sharpened after V2 shipped — two required span attributes have no wire source.** `acs.evaluator` (required on the `acs.decision` span event) does not exist as a field in `AcsResult` at all, and `acs.capability` (required on `gen_ai.tool.call`) maps to `payload.capability`, which `hooks/tool-call-request.json` leaves optional. So a **downstream consumer of the ACS wire cannot emit a conformant trace** — only the Guardian can, from process-local knowledge the contract does not carry, which cuts against R5.1/R5.2 and V2's whole "S6 is readable by anything" design. Full evidence table in the shaping doc under D10. **The ACS Trace pillar is unclaimed by any slice.** `specification/v0.1.0/trace/otel-mapping.json` and `trace/ocsf-mapping.json` are *normative* — the OTel mapping states that a deployment emitting OTel for the Trace pillar MUST use its span names and required attributes verbatim, and it names `steps/toolCallRequest` → `gen_ai.tool.call` explicitly. V2's S6 is deliberately a raw envelope log, **not** an OTel or OCSF export, so this implementation currently claims neither. R5.3 says we declare what we claim and what we do not — so either V7 measures the Trace pillar as an explicit non-claim, or a slice picks it up. Surfaced during V2 planning; nothing depends on it yet | V7 scope | **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`. From f4b8c45c69aafec1d3afff08745eabbedef20af6 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Mon, 10 Aug 2026 09:28:36 +0300 Subject: [PATCH 23/35] Give V7 the Trace pillar (N49, U33), and close D10 The ACS Trace pillar was normative and unclaimed. V7 now measures it rather than emitting it: N49 resolves every attribute trace/otel-mapping.json marks required against the v0.1.0 wire schemas, U33 renders the result, and the matrix declares Trace as a pillar this implementation does not claim -- with the reason attached rather than left as silence. Two cells are already known red. acs.evaluator has no field in AcsResult at all; acs.capability maps to payload.capability, which the tool-call-request hook leaves optional. So a downstream consumer of the ACS wire cannot emit a conformant trace -- only the Guardian can, from process-local knowledge the contract does not carry. Scope boundary recorded: V7 measures, it does not build an exporter. An exporter would have to live in the Guardian for that same reason, which makes it a slice of its own. Also corrects V7's demo sentence, which promised "all green". This slice's own body has expected two honestly-red model-call cells since shaping, and D10 adds two more, so the sentence was already false against the doc containing it. A matrix that must be all green to count is a matrix under pressure to redefine the claim -- the opposite of what C2 exists for. It now asks for every cell resolved. README's V7 row follows. Slice: #3 --- README.md | 3 ++- docs/shaping/acs-reference-impl-shaping.md | 13 +++++++--- docs/shaping/acs-reference-impl-slices.md | 30 +++++++++++++++++----- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e63d7494..c721f470 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ This repository shows the other shape. A host implements [ACS](https://github.co | 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 | +| A machine-checked mapping of all eight intervention points and five verdicts, with a round-trip conformance case per cell — every cell resolved, green where ACS v0.1.0 expresses AGT and red with a named reason where it does not. Four are already known red: the two model-call points have no v0.1.0 hook, and two attributes the Trace pillar marks required have no source on the wire | V7 | +| Which ACS profiles and pillars this implementation claims, and which it does not — the matrix is the declaration. Trace is a measured non-claim, not a silence | 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 | diff --git a/docs/shaping/acs-reference-impl-shaping.md b/docs/shaping/acs-reference-impl-shaping.md index c66060f4..92f6a0cd 100644 --- a/docs/shaping/acs-reference-impl-shaping.md +++ b/docs/shaping/acs-reference-impl-shaping.md @@ -234,6 +234,7 @@ All resolved — see `spike-agt-integration.md`. | U22 | P4 | inspector | session chain view: SessionContext entries and lineage | render | — | — | | U23 | P4 | inspector | posture badge: negotiated `on_decision_failure`, plus a running count of audited fail-open proceeds | render | — | — | | U30 | P5 | conformance | coverage matrix, 8 intervention points × 5 verdicts | render | — | — | +| U33 | P5 | conformance | trace-pillar row: each required OTel attribute, its v0.1.0 wire source, and whether a wire consumer can emit it | render | — | — | | U31 | P5 | conformance | drift detail: changed point, verdict, or schema field | render | — | — | | U32 | P5 | conformance | rendered ACS ↔ MS-ACS mapping table | render | — | — | @@ -271,6 +272,7 @@ All resolved — see `spike-agt-integration.md`. | N42 | P5 | conformance | verdict round trip: AGT verdict → ACS decision → AGT verdict, assert identity | call | — | → N47 | | N43 | P5 | conformance | `enforced_identity` recomputation check | call | — | → N47 | | N44 | P5 | conformance | failure-domain check: an AGT evaluation error arrives as an honored `deny`; a delivery failure applies the negotiated posture and writes an audit event | call | — | → N47 | +| N49 | P5 | conformance | trace-pillar check: every attribute `trace/otel-mapping.json` marks required, resolved against the v0.1.0 wire schemas — a cell is green only when a *wire consumer* could emit it | call | — | → N47 | | N45 | P5 | conformance | `fetchUpstreamSurfaces()` — AGT wire schemas and enums at `main` | call | → S12 | — | | N46 | P5 | conformance | `diffSurfaces()` — pinned versus upstream | call | — | → N47 | | N47 | P5 | conformance | `renderMatrix()` | call | → U30, → U31 | — | @@ -379,11 +381,13 @@ flowchart TB U30["U30: 8x5 coverage matrix"] U31["U31: drift detail"] U32["U32: mapping table"] + U33["U33: trace-pillar row"] N40["N40: conformance runner"] N41["N41: point round trip"] N42["N42: verdict round trip"] N43["N43: enforced_identity check"] N44["N44: fail-closed check"] + N49["N49: trace-pillar check"] N45["N45: fetchUpstreamSurfaces()"] N46["N46: diffSurfaces()"] N47["N47: renderMatrix()"] @@ -480,12 +484,15 @@ flowchart TB N42 -.-> N47 N43 -.-> N47 N44 -.-> N47 + N40 --> N49 + N49 -.-> N47 N45 --> S12 S11 -.-> N46 S12 -.-> N46 N46 -.-> N47 N47 --> U30 N47 --> U31 + N47 --> U33 S10 -.-> N48 N48 --> U32 @@ -493,8 +500,8 @@ flowchart TB classDef nonui fill:#d3d3d3,stroke:#808080,color:#000 classDef store fill:#e6e6fa,stroke:#9370db,color:#000 - class U1,U2,U3,U10,U11,U12,U20,U21,U22,U23,U30,U31,U32 ui - class N1,N2,N3,N4,N5,N6,N7,N10,N11,N12,N13,N14,N15,N16,N20,N21,N22,N23,N24,N25,N26,N27,N28,N30,N31,N40,N41,N42,N43,N44,N45,N46,N47,N48,N50,N51 nonui + class U1,U2,U3,U10,U11,U12,U20,U21,U22,U23,U30,U31,U32,U33 ui + class N1,N2,N3,N4,N5,N6,N7,N10,N11,N12,N13,N14,N15,N16,N20,N21,N22,N23,N24,N25,N26,N27,N28,N30,N31,N40,N41,N42,N43,N44,N45,N46,N47,N48,N49,N50,N51 nonui class S1,S2,S3,S4,S5,S6,S7,S8,S9,S10,S11,S12,S13,S14,S15,S16 store ``` @@ -527,7 +534,7 @@ flowchart TB | ~~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 | -| D10 | 🔴 **New, from V2 planning.** R5.3 — does this implementation claim the ACS **Trace** pillar? | Open | `specification/v0.1.0/trace/otel-mapping.json` is normative: a deployment emitting OTel for the Trace pillar MUST use its span names and required attributes verbatim, and it maps `steps/toolCallRequest` → `gen_ai.tool.call` by name. `trace/ocsf-mapping.json` is its sibling. V2's envelope tap (S6) is a raw JSONL log, deliberately not an OTel or OCSF export, so today we claim neither pillar. R5.3 requires declaring that either way, which makes this V7's problem at the latest — either the conformance matrix records Trace as an explicit non-claim, or a slice picks it up. **⚠️ Two required span attributes have no wire source — see the D10 evidence note below** | +| ~~D10~~ | R5.3 — does this implementation claim the ACS **Trace** pillar? | ✅ **Decided: no, and V7 measures the non-claim** (N49 → U33). V7 does not build an exporter; per the evidence note below it could only live in the Guardian, which would be a slice of its own | `specification/v0.1.0/trace/otel-mapping.json` is normative: a deployment emitting OTel for the Trace pillar MUST use its span names and required attributes verbatim, and it maps `steps/toolCallRequest` → `gen_ai.tool.call` by name. `trace/ocsf-mapping.json` is its sibling. V2's envelope tap (S6) is a raw JSONL log, deliberately not an OTel or OCSF export, so today we claim neither pillar. R5.3 requires declaring that either way. Settled: the matrix records Trace as an explicit non-claim, and **two of its required span attributes have no wire source at all — evidence note directly below** | **⚠️ D10 evidence — the Trace pillar is not emittable from the v0.1.0 wire alone.** Read after V2 shipped, against the pinned schemas: diff --git a/docs/shaping/acs-reference-impl-slices.md b/docs/shaping/acs-reference-impl-slices.md index e12c6e8a..88eb19f9 100644 --- a/docs/shaping/acs-reference-impl-slices.md +++ b/docs/shaping/acs-reference-impl-slices.md @@ -20,7 +20,7 @@ Every slice ends in something demo-able. | V4 | Output redaction on Claude Code | C3 | "AGT's own package documents that Claude Code cannot redact tool output. Here it is, redacted, by AGT's stock `redact` policy." | | V5 | Second host, zero AGT changes | C3 | "Same Guardian, same manifest, same bundle. OpenCode is now governed. `git diff` shows zero lines changed in the Guardian, the bridge, or AGT." | | V6 | Session state and provenance carriage | C4 | "The SessionContext chain grows per step. AGT emits `result_labels` at one step and gets them back as `input.ifc.source_labels` at the next, carried by ACS provenance." | -| V7 | Conformance matrix | C1, C2, C5 | "Eight intervention points by five verdicts, all green. AGT completely expressed in ACS, case by case." | +| V7 | Conformance matrix | C1, C2, C5 | "Eight intervention points by five verdicts, every cell resolved — green where ACS v0.1.0 expresses AGT, red with a named reason where it cannot. Plus the Trace pillar, measured as an explicit non-claim." | | V8 | Upstream drift watch | C6 | "Point the harness at AGT `main`. A changed enum turns a cell red and names the field." | **Order rationale.** V1–V4 establish credibility on the host AGT already supports best, so the second-host claim in V5 lands against a working baseline rather than a promise. V7 is the deliverable Microsoft reads, but it can only be green once V1–V6 exist to be measured. V8 is what keeps V7 true after upstream moves. @@ -195,25 +195,43 @@ Wire N21 → N22 → N23 in place of V1's direct N21 → N23. ## V7: Conformance matrix -**Demo:** Eight intervention points by five verdicts, all green. AGT completely expressed in ACS, case by case. +**Demo:** Eight intervention points by five verdicts, every cell resolved — green where ACS v0.1.0 expresses AGT, red with a named reason where it cannot. Plus the Trace pillar, measured as an explicit non-claim. + +**⚠️ Demo corrected (was "all green").** The original sentence was already contradicted by this slice's own body, which has expected two honestly-red model-call cells since shaping; D10 adds two more. A matrix that must be all green to count is a matrix under pressure to redefine the claim, which is the opposite of what C2 is for. The demo now asks for every cell *resolved*, which is achievable and is the stronger deliverable. | # | Place | Component | Affordance | Control | Wires Out | Returns To | |---|-------|-----------|------------|---------|-----------|------------| | U30 | P5 | conformance | coverage matrix, 8 intervention points × 5 verdicts | render | — | — | | U32 | P5 | conformance | rendered ACS ↔ MS-ACS mapping table | render | — | — | +| U33 | P5 | conformance | trace-pillar row: each required OTel attribute, its v0.1.0 wire source, and whether a wire consumer can emit it | render | — | — | | 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 | | N43 | P5 | conformance | `enforced_identity` recomputation check | call | — | → N47 | | N44 | P5 | conformance | failure-domain check: an AGT evaluation error arrives as an honored `deny`; a delivery failure applies the negotiated posture and writes an audit event | call | — | → N47 | -| N47 | P5 | conformance | `renderMatrix()` | call | → U30 | — | +| N49 | P5 | conformance | trace-pillar check: every attribute `trace/otel-mapping.json` marks required, resolved against the v0.1.0 wire schemas | call | — | → N47 | +| N47 | P5 | conformance | `renderMatrix()` | call | → U30, → U33 | — | | 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. +**⚠️ The Trace pillar lands here too (D10), and two of its cells are already known red.** `trace/otel-mapping.json` is normative — a deployment emitting OTel for the Trace pillar MUST use its span names and required attributes verbatim. Measured against the pinned schemas after V2 shipped: + +| Required by the mapping | Source in v0.1.0 | Cell | +|---|---|---| +| `gen_ai.tool.name` on `gen_ai.tool.call` | `payload.tool.name`, `required` | 🟢 | +| `acs.capability` on `gen_ai.tool.call` | `payload.capability`, **optional** — `hooks/tool-call-request.json` requires only `tool` and `arguments` | 🔴 a conformant envelope may omit it | +| `acs.decision` on the `acs.decision` span event | `AcsResult.decision`, `required` | 🟢 | +| `acs.evaluator` on the `acs.decision` span event | **none** — `AcsResult` has no such field | 🔴 no wire source | +| `acs.confidence`, `acs.evaluator_version`, `acs.model_id` (required "when present in the decision envelope") | **none** — no such fields in `AcsResult` | 🔴 can never be present | + +`N49` is what turns that table into measured cells rather than this prose, and `U33` renders it. The finding worth publishing is not the missing fields but their consequence: **a downstream consumer of the ACS wire cannot emit a conformant trace** — only the Guardian can, from process-local knowledge the contract does not carry. That cuts directly against R5.1/R5.2 and against V2's design, where S6 is readable by anything and the Inspector proves it by importing nothing. An OTel exporter reading S6 hits the same wall. + +**Scope boundary:** V7 *measures* the Trace pillar. It does not build an exporter. If an exporter is ever wanted it has to live in the Guardian for the reason above, and that is a slice of its own, not V7 scope. + +**Expect two cells to be honestly red — now four.** `pre_model_call` and `post_model_call` have no ACS v0.1.0 target (D4), and the two Trace attributes above have no wire source (D10). 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` and for an `evaluator` field on `AcsResult` in v0.2. -R5.3 lands here: the matrix *is* the profile declaration. +R5.3 lands here: the matrix *is* the profile declaration — including the Trace pillar, which this implementation declares it does **not** claim, with the measured reason attached. --- @@ -259,7 +277,7 @@ Runs on a schedule in CI. MS-ACS is `0.3.1-beta` and warns of breaking changes b | D5 | Determinism of the demo | V1 onward | | ~~D7~~ | ✅ **Closed: Rego.** Cedar's sole advantage was avoiding an external binary; the SDK bundles OPA, so that advantage does not exist. Stock bundle verified 105/105 under the bundled OPA | ~~V1~~ | | 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 | -| D10 | 🔴 ⚠️ **Sharpened after V2 shipped — two required span attributes have no wire source.** `acs.evaluator` (required on the `acs.decision` span event) does not exist as a field in `AcsResult` at all, and `acs.capability` (required on `gen_ai.tool.call`) maps to `payload.capability`, which `hooks/tool-call-request.json` leaves optional. So a **downstream consumer of the ACS wire cannot emit a conformant trace** — only the Guardian can, from process-local knowledge the contract does not carry, which cuts against R5.1/R5.2 and V2's whole "S6 is readable by anything" design. Full evidence table in the shaping doc under D10. **The ACS Trace pillar is unclaimed by any slice.** `specification/v0.1.0/trace/otel-mapping.json` and `trace/ocsf-mapping.json` are *normative* — the OTel mapping states that a deployment emitting OTel for the Trace pillar MUST use its span names and required attributes verbatim, and it names `steps/toolCallRequest` → `gen_ai.tool.call` explicitly. V2's S6 is deliberately a raw envelope log, **not** an OTel or OCSF export, so this implementation currently claims neither. R5.3 says we declare what we claim and what we do not — so either V7 measures the Trace pillar as an explicit non-claim, or a slice picks it up. Surfaced during V2 planning; nothing depends on it yet | V7 scope | +| ~~D10~~ | ✅ **Closed: V7 owns it, as a measured non-claim.** The ACS Trace pillar (`trace/otel-mapping.json`, `trace/ocsf-mapping.json`) is normative and was unclaimed by any slice. It now lands in V7 as `N49`/`U33` — V7 *measures* the pillar rather than emitting it, and the matrix declares it as a pillar this implementation does not claim, with the reason attached. Two required attributes already measure red: `acs.evaluator` has no field in `AcsResult` at all, and `acs.capability` maps to an optional payload field. The consequence is the publishable part — **a downstream consumer of the ACS wire cannot emit a conformant trace**, only the Guardian can, from knowledge the contract does not carry. Building an exporter would be a slice of its own, not V7 scope. Evidence tables in §V7 and in the shaping doc under D10 | `specification/v0.1.0/trace/otel-mapping.json` and `trace/ocsf-mapping.json` are *normative* — the OTel mapping states that a deployment emitting OTel for the Trace pillar MUST use its span names and required attributes verbatim, and it names `steps/toolCallRequest` → `gen_ai.tool.call` explicitly. V2's S6 is deliberately a raw envelope log, **not** an OTel or OCSF export, so this implementation currently claims neither. R5.3 says we declare what we claim and what we do not. Surfaced during V2 planning, measured after V2 shipped, and settled into V7 | V7 (N49, U33) | **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`. From cdeb9605bb801a08289974310398ec8c332e4cf2 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Tue, 11 Aug 2026 11:24:49 +0300 Subject: [PATCH 24/35] Keep V2's test doubles level with V1's module surface The rebase onto V1's review fixes broke two V2 tests, and the cause is worth naming rather than just fixing: both stand up a Guardian whose validate-envelope.ts has been replaced by a hand-written double, copying every other source file verbatim. V1's response to PR #10 added `isToolCallRequest` to that module and server.ts now imports it, so the doubles failed to import at all -- never reaching the pathological throw they exist to exercise. Each double now mirrors the real narrowing. It is unreachable there, since their validateEnvelope always throws before it, but a double that lies about behaviour is worse than one that fails to compile. This is the cost of a hand-written double over a real module, paid exactly where it should be: at the branch that owns the double, when the module it imitates changed underneath. README's count moves with the suite: 157 tests across 17 files. Addresses: #10 #11 review --- README.md | 2 +- packages/guardian/test/server.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c721f470..71594f24 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ Second, through the real `claude` CLI with `.claude/settings.json` installed — ### Verify ```bash -bun test # 134 tests across 16 files (133 pass, 1 skip), including the R3.2/R3.3 +bun test # 157 tests across 17 files (156 pass, 1 skip), including the R3.2/R3.3 # and R5.1/R5.2 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/test/server.test.ts b/packages/guardian/test/server.test.ts index 5912f0d1..407ff90c 100644 --- a/packages/guardian/test/server.test.ts +++ b/packages/guardian/test/server.test.ts @@ -326,6 +326,17 @@ export function validateEnvelope(_input) { error.message = undefined; throw error; } + +// Must track validate-envelope.ts's real export surface, not just the two +// symbols this double overrides: server.ts imports isToolCallRequest from the +// same module, so a double that omits it fails to import rather than +// exercising the pathological throw these tests exist for. Mirrors the real +// narrowing exactly -- it is unreachable here (validateEnvelope always +// throws) but a double that lies about behaviour is worse than one that +// does not compile. +export function isToolCallRequest(envelope) { + return envelope.method === "steps/toolCallRequest"; +} `; /** @@ -362,6 +373,17 @@ export function validateEnvelope(_input) { }); throw error; } + +// Must track validate-envelope.ts's real export surface, not just the two +// symbols this double overrides: server.ts imports isToolCallRequest from the +// same module, so a double that omits it fails to import rather than +// exercising the pathological throw these tests exist for. Mirrors the real +// narrowing exactly -- it is unreachable here (validateEnvelope always +// throws) but a double that lies about behaviour is worse than one that +// does not compile. +export function isToolCallRequest(envelope) { + return envelope.method === "steps/toolCallRequest"; +} `; /** From c4cc8f1edd1ba87d626ca64fb02000da85ab297b Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Tue, 11 Aug 2026 12:14:54 +0300 Subject: [PATCH 25/35] Name the envelope rail for its artifact, not for the writer's mechanism PR #11's naming review, nine of its twelve findings: the writer's own nickname (`Tap`) was the vocabulary on both sides of S6 -- the Guardian that writes it and the Inspector that reads it -- so a reader of either side had to know how the log gets written before they could name what they were looking at. EnvelopeTap -> EnvelopeLogSink createEnvelopeTap -> createEnvelopeLogSink CreateEnvelopeTapOptions -> CreateEnvelopeLogSinkOptions NULL_TAP -> NULL_ENVELOPE_LOG_SINK TapEntry (both sides) -> EnvelopeLogEntry TapDirection -> EnvelopeLogDirection TailOptions -> TailEnvelopeLogOptions renderEntry -> renderEnvelopeLogEntry `tailEnvelopeLog` was already named this way, and it is the template the rest now match: every name on the rail says which log it is about, so a later slice's second stream reads as this one's sibling rather than as an unrelated mechanism. The Inspector still declares its own entry type rather than importing the Guardian's (R5.1) -- two declarations that now share a name are still two declarations, and test/envelope-tap-roundtrip.test.ts is what keeps them honest. Four filenames are deliberately unchanged (`packages/guardian/src/envelope-tap.ts` and the three `envelope-tap*.test.ts`): the findings are about public API vocabulary, and renaming the paths would churn them for no vocabulary gain. Behaviour is unchanged. The only strings that moved are two stderr diagnostics and one thrown message, none of them asserted anywhere: "envelope tap disabled after failure" -> "envelope log sink disabled after failure", and "does not match the TapEntry shape" -> "... the EnvelopeLogEntry shape". Addresses: #11 review --- packages/guardian/src/envelope-tap.ts | 70 +++++++++++++------ packages/guardian/src/index.ts | 12 ++-- packages/guardian/src/server.ts | 47 +++++++------ .../guardian/test/envelope-tap-wiring.test.ts | 28 ++++---- packages/guardian/test/envelope-tap.test.ts | 65 +++++++++-------- packages/guardian/test/server.test.ts | 10 +-- packages/inspector/src/index.ts | 9 ++- packages/inspector/src/main.ts | 4 +- packages/inspector/src/render.ts | 12 ++-- packages/inspector/src/tail-envelope-log.ts | 41 ++++++----- packages/inspector/test/render.test.ts | 24 +++---- .../inspector/test/tail-envelope-log.test.ts | 28 ++++---- test/envelope-tap-roundtrip.test.ts | 23 +++--- test/invariants.test.ts | 11 +-- 14 files changed, 217 insertions(+), 167 deletions(-) diff --git a/packages/guardian/src/envelope-tap.ts b/packages/guardian/src/envelope-tap.ts index 94724d5c..f7e9902c 100644 --- a/packages/guardian/src/envelope-tap.ts +++ b/packages/guardian/src/envelope-tap.ts @@ -1,11 +1,28 @@ /** - * writeEnvelopeTap (N26) writes S6: a JSONL record of every ACS envelope + * createEnvelopeLogSink (N26) writes S6: a JSONL record of every ACS envelope * that crosses this Guardian's wire, in both directions. The Envelope * Inspector (P4) reads this file and nothing else -- see packages/inspector, * which deliberately imports nothing from here. * + * Named for the artifact and the role, not for the mechanism (PR #11's naming + * review). This used to be the `EnvelopeTap` / `createEnvelopeTap` / + * `NULL_TAP` / `TapEntry` family: "tap the wire" is how the writer works, not + * what a reader is looking at. V3 adds the host's audit rail as this rail's + * sibling -- another total JSONL record of something that crossed a boundary, + * which the Inspector will tail beside this one -- and two metaphors for one + * job family would force every reader to translate between a "tap" and a + * "sink". Naming this side for the log it produces is what makes the pair + * read as a pair when the second rail lands. + * + * The affordance tables in docs/shaping/ label N26 `writeEnvelopeTap()`. No + * such function has ever existed here -- the callable names are + * `createEnvelopeLogSink` and the `write` method on the `EnvelopeLogSink` it + * returns. The affordance ID is spelled out in this comment so a reader + * hunting N26 from the slices doc lands here rather than on a name that + * matches nothing. + * * What "records the envelope" means here, precisely (global constraint 11, - * as corrected by the whole-branch review's finding 2): the tap is handed + * as corrected by the whole-branch review's finding 2): the sink is handed * the JSON *value* the Guardian parsed, and writes it unmodified -- no field * stripping, no redaction, no reordering of anything we control. It is not a * byte-for-byte copy of the request body, and V2's documentation claimed it @@ -18,8 +35,8 @@ * contract test; the accurate sentence is the better trade. * * Total by construction (global constraint 8). Every write is wrapped: a - * failure disables the tap for the process lifetime, reports once, and is - * never propagated to the caller. The tap sits on the decision path, and + * failure disables the sink for the process lifetime, reports once, and is + * never propagated to the caller. The sink sits on the decision path, and * V1 shipped three separate fail-opens before they were caught -- an * observability feature that can turn a governed tool call into an * ungoverned one would be the fourth. Observability degrades; governance @@ -28,31 +45,36 @@ import { appendFileSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; -export type TapDirection = "request" | "response"; +/** Which side of the exchange one S6 line recorded. Named after the log, the + * same way `EnvelopeLogEntry` is, so nothing on this rail carries the + * writer's own nickname. */ +export type EnvelopeLogDirection = "request" | "response"; /** - * One line of S6. `envelope` is the JSON-RPC object as parsed, unmodified -- - * request or response -- and every other field is Guardian-side context the - * wire does not carry: a sequence number so a reader can detect gaps, a - * timestamp, the direction, the ACS method (JSON-RPC responses carry none, - * so the Guardian supplies the one it dispatched), and the JSON-RPC id that - * pairs the two directions. + * One line of S6, named for what was recorded rather than for what recorded + * it. `envelope` is the JSON-RPC object as parsed, unmodified -- request or + * response -- and every other field is Guardian-side context the wire does + * not carry: a sequence number so a reader can detect gaps, a timestamp, the + * direction, the ACS method (JSON-RPC responses carry none, so the Guardian + * supplies the one it dispatched), and the JSON-RPC id that pairs the two + * directions. */ -export type TapEntry = { +export type EnvelopeLogEntry = { seq: number; recorded_at: string; - direction: TapDirection; + direction: EnvelopeLogDirection; method: string | null; rpc_id: string | number | null; envelope: unknown; }; -export type EnvelopeTap = { - write(direction: TapDirection, envelope: unknown, method: string | null): void; +/** Where S6 lines go: the Guardian-side writer role. */ +export type EnvelopeLogSink = { + write(direction: EnvelopeLogDirection, envelope: unknown, method: string | null): void; readonly path: string | null; }; -export type CreateEnvelopeTapOptions = { +export type CreateEnvelopeLogSinkOptions = { path: string; /** Injectable clock, so tests can assert recorded_at exactly. */ now?: () => Date; @@ -60,8 +82,10 @@ export type CreateEnvelopeTapOptions = { onError?: (error: unknown) => void; }; -/** The tap a Guardian gets when no envelopeLogPath was configured (P3). */ -export const NULL_TAP: EnvelopeTap = { +/** The sink a Guardian gets when no envelopeLogPath was configured (P3). + * Renamed in lockstep with the role it implements, so a composition root + * disabling observability names the same rail the type does. */ +export const NULL_ENVELOPE_LOG_SINK: EnvelopeLogSink = { path: null, write(): void {}, }; @@ -78,7 +102,11 @@ export function extractRpcId(envelope: unknown): string | number | null { return null; } -export function createEnvelopeTap({ path, now = () => new Date(), onError }: CreateEnvelopeTapOptions): EnvelopeTap { +export function createEnvelopeLogSink({ + path, + now = () => new Date(), + onError, +}: CreateEnvelopeLogSinkOptions): EnvelopeLogSink { let seq = 0; let disabled = false; @@ -90,7 +118,7 @@ export function createEnvelopeTap({ path, now = () => new Date(), onError }: Cre return; } const message = error instanceof Error ? error.message : String(error); - console.error(`envelope tap disabled after failure (${path}): ${message}`); + console.error(`envelope log sink disabled after failure (${path}): ${message}`); } catch { // Silently swallow any error from the callback or console.error // to maintain the total-by-construction guarantee @@ -110,7 +138,7 @@ export function createEnvelopeTap({ path, now = () => new Date(), onError }: Cre return; } try { - const entry: TapEntry = { + const entry: EnvelopeLogEntry = { seq: seq + 1, recorded_at: now().toISOString(), direction, diff --git a/packages/guardian/src/index.ts b/packages/guardian/src/index.ts index 3c512bb4..6ad34b5e 100644 --- a/packages/guardian/src/index.ts +++ b/packages/guardian/src/index.ts @@ -11,11 +11,11 @@ export { export { assemblePreToolCallSnapshot, type AgtPreToolCallSnapshot } from "./assemble-snapshot.ts"; export { loadMapping, mapVerdict, type Mapping, type AcsDecision } from "./map-verdict.ts"; export { - createEnvelopeTap, + createEnvelopeLogSink, extractRpcId, - NULL_TAP, - type CreateEnvelopeTapOptions, - type EnvelopeTap, - type TapDirection, - type TapEntry, + NULL_ENVELOPE_LOG_SINK, + type CreateEnvelopeLogSinkOptions, + type EnvelopeLogDirection, + type EnvelopeLogEntry, + type EnvelopeLogSink, } from "./envelope-tap.ts"; diff --git a/packages/guardian/src/server.ts b/packages/guardian/src/server.ts index f7b59c57..5da69082 100644 --- a/packages/guardian/src/server.ts +++ b/packages/guardian/src/server.ts @@ -58,7 +58,7 @@ import { type AcsRequestEnvelope, } from "./validate-envelope.ts"; import { buildServerHello, type ServerHello } from "./handshake.ts"; -import { createEnvelopeTap, NULL_TAP, type EnvelopeTap } from "./envelope-tap.ts"; +import { createEnvelopeLogSink, NULL_ENVELOPE_LOG_SINK, type EnvelopeLogSink } from "./envelope-tap.ts"; /** * Every snapshot message this Guardian can send an intervention point. One @@ -103,9 +103,9 @@ const REPO_ROOT_PATTERN = new RegExp(`${REPO_ROOT.replace(/[.*+?^${}()|[\]\\]/g, /** * Both catches in this module (the outer net in handleAcsRequest and the * evaluation-failure catch in dispatch) surface a real error to the ACS - * client and, via the tap, into S6 -- and a real error's message (an ENOENT - * out of a missing schema directory, say) carries this machine's absolute - * filesystem path, e.g. + * client and, via the envelope log, into S6 -- and a real error's message + * (an ENOENT out of a missing schema directory, say) carries this machine's + * absolute filesystem path, e.g. * `/Users/you/.../ACS_reference_implementation/packages/spec/acs/...`. * That is diagnostic in a way this demo's value depends on, so the fix is * not to replace it with something generic -- it is to remove only the @@ -205,8 +205,8 @@ export type StartGuardianOptions = { * handleAcsRequest against a real bridge, without touching the mapping * every other consumer reads. Not meant for production use. */ mappingPath?: string; - /** Path to S6, the JSONL envelope log (N26). Omitted means no tap: every - * V1 test constructs Guardians freely and a default-on tap would scatter + /** Path to S6, the JSONL envelope log (N26). Omitted means no sink: every + * V1 test constructs Guardians freely and a default-on sink would scatter * files through the working tree. `packages/guardian/src/main.ts` -- the * demo path -- passes it. See the plan's decision P3. */ envelopeLogPath?: string; @@ -223,7 +223,7 @@ export async function startGuardian({ // Construct the bridge once at boot, not per request. const bridge = createBridge(manifestPath); const mapping = loadMapping(mappingPath ?? MAPPING_PATH); - const tap = envelopeLogPath ? createEnvelopeTap({ path: envelopeLogPath }) : NULL_TAP; + const envelopeLog = envelopeLogPath ? createEnvelopeLogSink({ path: envelopeLogPath }) : NULL_ENVELOPE_LOG_SINK; const server = Bun.serve({ hostname: hostname ?? LOOPBACK_ONLY, @@ -233,7 +233,7 @@ export async function startGuardian({ if (req.method !== "POST" || pathname !== ACS_PATH) { return new Response("Not Found", { status: 404 }); } - const response = await handleAcsRequest(req, bridge, mapping, tap); + const response = await handleAcsRequest(req, bridge, mapping, envelopeLog); return Response.json(response); }, }); @@ -247,20 +247,21 @@ export async function startGuardian({ } /** - * Three phases, in order: parse, tap the request, dispatch, tap the - * response. The tap calls live here and only here -- `dispatch` below leaves - * by six routes (five `return`s and one rethrow) and V3's N27 adds a - * seventh, so tapping inside it would make totality something a future task - * has to remember rather than something the structure guarantees. + * Three phases, in order: parse, record the request, dispatch, record the + * response. The envelope-log writes live here and only here -- `dispatch` + * below leaves by six routes (five `return`s and one rethrow) and V3's N27 + * adds a seventh, so writing S6 inside it would make totality something a + * future task has to remember rather than something the structure + * guarantees. * * That guarantee is only as good as its coverage of the throwing route, and * the whole-branch review's finding 1 found it uncovered: `dispatch`'s * rethrow used to leave this function without a response at all, so the * client got a Bun.serve HTML 500 that S6 never recorded. The try/catch * below closes it -- every route out of `dispatch` now produces a response - * object, and every response object gets tapped. + * object, and every response object reaches the envelope log. * - * The tap itself is total (see envelope-tap.ts): these two calls cannot + * The sink itself is total (see envelope-tap.ts): these two calls cannot * throw, so they cannot turn a governed tool call into an ungoverned one. */ async function handleAcsRequest( @@ -270,24 +271,24 @@ async function handleAcsRequest( // factory happens to return. bridge: PolicyBridge, mapping: Mapping, - tap: EnvelopeTap, + envelopeLog: EnvelopeLogSink, ): Promise { let raw: unknown; try { raw = await req.json(); } catch { - // Nothing parseable arrived, so there is no request envelope to tap -- - // the response is deliberately recorded unpaired, which is what the + // Nothing parseable arrived, so there is no request envelope to record + // -- the response is deliberately recorded unpaired, which is what the // Inspector renders when a host sends a malformed body. const parseError = errorResponse(null, -32700, "Parse error"); - tap.write("response", parseError, null); + envelopeLog.write("response", parseError, null); return parseError; } // Decision P5: before validation, so an envelope that fails the schema is // visible to the Inspector rather than invisible. const method = extractMethod(raw); - tap.write("request", raw, method); + envelopeLog.write("request", raw, method); let response: JsonRpcSuccess | JsonRpcFailure; try { @@ -300,7 +301,7 @@ async function handleAcsRequest( const message = toRepoRelativeMessage(error); response = errorResponse(extractId(raw), EVALUATION_FAILED_CODE, `guardian failed to handle the request: ${message}`); } - tap.write("response", response, method); + envelopeLog.write("response", response, method); return response; } @@ -395,8 +396,8 @@ function extractId(raw: unknown): string | number | null { return null; } -/** Best-effort method name for tap labelling only. Never used to dispatch -- - * `dispatch` reads the schema-validated envelope's own `method`. */ +/** Best-effort method name for envelope-log labelling only. Never used to + * dispatch -- `dispatch` reads the schema-validated envelope's own `method`. */ function extractMethod(raw: unknown): string | null { if (typeof raw === "object" && raw !== null && "method" in raw) { const method = raw.method; diff --git a/packages/guardian/test/envelope-tap-wiring.test.ts b/packages/guardian/test/envelope-tap-wiring.test.ts index fd9a12f3..bdb57dc6 100644 --- a/packages/guardian/test/envelope-tap-wiring.test.ts +++ b/packages/guardian/test/envelope-tap-wiring.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFile import { tmpdir } from "node:os"; import { join } from "node:path"; import { startGuardian } from "../src/index.ts"; -import type { TapEntry } from "../src/envelope-tap.ts"; +import type { EnvelopeLogEntry } from "../src/envelope-tap.ts"; function makeEnvelope( method: string, @@ -33,14 +33,14 @@ function toolCallEnvelope(command: string, overrides: { id?: number } = {}) { ); } -function readEntries(path: string): TapEntry[] { +function readEntries(path: string): EnvelopeLogEntry[] { if (!existsSync(path)) { return []; } return readFileSync(path, "utf8") .split("\n") .filter((line) => line.trim() !== "") - .map((line) => JSON.parse(line) as TapEntry); + .map((line) => JSON.parse(line) as EnvelopeLogEntry); } /** Non-recursive cleanup, as in envelope-tap.test.ts. */ @@ -75,8 +75,8 @@ async function postRaw(url: string, body: string): Promise { const logIn = (dir: string) => join(dir, "envelopes.jsonl"); -describe("Guardian envelope tap wiring (N26 x N20)", () => { - it("taps one request and one response per exchange, paired by rpc_id", async () => { +describe("Guardian envelope log wiring (N26 x N20)", () => { + it("records one request and one response per exchange, paired by rpc_id", async () => { await withGuardian(logIn, async (url, logPath) => { await postRaw(url, JSON.stringify(toolCallEnvelope("rm -rf /", { id: 11 }))); @@ -92,7 +92,7 @@ describe("Guardian envelope tap wiring (N26 x N20)", () => { }); }); - it("taps handshake/hello in both directions", async () => { + it("records handshake/hello in both directions", async () => { await withGuardian(logIn, async (url, logPath) => { await postRaw(url, JSON.stringify(makeEnvelope("handshake/hello", {}, { id: 42 }))); @@ -103,9 +103,9 @@ describe("Guardian envelope tap wiring (N26 x N20)", () => { }); // Decision P5. The envelope that fails validation is the most useful - // thing an ACS-first reader can see; tapping after the validator is + // thing an ACS-first reader can see; recording it after the validator is // exactly what would hide it. - it("taps a schema-invalid request, then its JSON-RPC error response", async () => { + it("records a schema-invalid request, then its JSON-RPC error response", async () => { await withGuardian(logIn, async (url, logPath) => { const bad = toolCallEnvelope("rm -rf /", { id: 12 }); delete (bad.params as Record).acs_version; @@ -122,7 +122,7 @@ describe("Guardian envelope tap wiring (N26 x N20)", () => { }); }); - it("taps an unparseable body as a lone response with rpc_id null -- no request line to pair with", async () => { + it("records an unparseable body as a lone response with rpc_id null -- no request line to pair with", async () => { await withGuardian(logIn, async (url, logPath) => { await postRaw(url, "{not json"); @@ -135,17 +135,17 @@ describe("Guardian envelope tap wiring (N26 x N20)", () => { }); }); - // Global constraint 8, end to end: the tap is on the decision path, so - // this is the test that says a broken tap cannot become a fail-open. + // Global constraint 8, end to end: the sink is on the decision path, so + // this is the test that says a broken sink cannot become a fail-open. // - // No `onError` is passed here, so this exercises the tap's *default* + // No `onError` is passed here, so this exercises the sink's *default* // reporter -- a single `console.error` line -- rather than the // onError-captured path envelope-tap.test.ts's "reports once, then goes - // quiet" test covers. Spied and silenced so a deliberately-broken tap + // quiet" test covers. Spied and silenced so a deliberately-broken sink // does not print real stderr into a clean `bun test` run, and asserted // on so "reports once" is checked at the call site instead of merely // claimed. - it("still denies rm -rf / when every tap write fails", async () => { + it("still denies rm -rf / when every envelope-log write fails", async () => { const dir = mkdtempSync(join(tmpdir(), "acs-tap-broken-")); const blocker = join(dir, "blocker"); writeFileSync(blocker, ""); diff --git a/packages/guardian/test/envelope-tap.test.ts b/packages/guardian/test/envelope-tap.test.ts index 415c6036..fdc531e1 100644 --- a/packages/guardian/test/envelope-tap.test.ts +++ b/packages/guardian/test/envelope-tap.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from "bun:test"; import { mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createEnvelopeTap, extractRpcId, NULL_TAP, type TapEntry } from "../src/envelope-tap.ts"; +import { + createEnvelopeLogSink, + extractRpcId, + NULL_ENVELOPE_LOG_SINK, + type EnvelopeLogEntry, +} from "../src/envelope-tap.ts"; /** A temp directory per test. Cleanup is deliberately non-recursive -- * unlink the one file we created, then rmdir -- so a stray file makes the @@ -21,24 +26,24 @@ function withTempDir(run: (dir: string) => void): void { } } -function readEntries(path: string): TapEntry[] { +function readEntries(path: string): EnvelopeLogEntry[] { return readFileSync(path, "utf8") .split("\n") .filter((line) => line.trim() !== "") - .map((line) => JSON.parse(line) as TapEntry); + .map((line) => JSON.parse(line) as EnvelopeLogEntry); } const REQUEST = { jsonrpc: "2.0", method: "steps/toolCallRequest", id: 7, params: { acs_version: "0.1.0" } }; const RESPONSE = { jsonrpc: "2.0", id: 7, result: { decision: "deny" } }; -describe("createEnvelopeTap (N26) -- S6's JSONL format", () => { +describe("createEnvelopeLogSink (N26) -- S6's JSONL format", () => { it("writes one line per call, with a monotonic seq starting at 1", () => { withTempDir((dir) => { const path = join(dir, "envelopes.jsonl"); - const tap = createEnvelopeTap({ path }); + const sink = createEnvelopeLogSink({ path }); - tap.write("request", REQUEST, "steps/toolCallRequest"); - tap.write("response", RESPONSE, "steps/toolCallRequest"); + sink.write("request", REQUEST, "steps/toolCallRequest"); + sink.write("response", RESPONSE, "steps/toolCallRequest"); const entries = readEntries(path); expect(entries.map((e) => e.seq)).toEqual([1, 2]); @@ -49,11 +54,11 @@ describe("createEnvelopeTap (N26) -- S6's JSONL format", () => { // Retitled by the whole-branch review (finding 2); the assertion is // unchanged. It has always checked that the JSON value reaches S6 // unmodified -- nothing stripped, nothing reordered. "Verbatim" claimed - // byte identity, which the tap never had: it is handed `await req.json()`. + // byte identity, which the sink never had: it is handed `await req.json()`. it("records the envelope unmodified -- constraint 11, no reformatting or stripping", () => { withTempDir((dir) => { const path = join(dir, "envelopes.jsonl"); - createEnvelopeTap({ path }).write("request", REQUEST, "steps/toolCallRequest"); + createEnvelopeLogSink({ path }).write("request", REQUEST, "steps/toolCallRequest"); expect(readEntries(path)[0]?.envelope).toEqual(REQUEST); }); @@ -62,10 +67,10 @@ describe("createEnvelopeTap (N26) -- S6's JSONL format", () => { it("carries rpc_id from both directions, so the Inspector can pair them (P4)", () => { withTempDir((dir) => { const path = join(dir, "envelopes.jsonl"); - const tap = createEnvelopeTap({ path }); + const sink = createEnvelopeLogSink({ path }); - tap.write("request", REQUEST, "steps/toolCallRequest"); - tap.write("response", RESPONSE, "steps/toolCallRequest"); + sink.write("request", REQUEST, "steps/toolCallRequest"); + sink.write("response", RESPONSE, "steps/toolCallRequest"); expect(readEntries(path).map((e) => e.rpc_id)).toEqual([7, 7]); }); @@ -74,9 +79,9 @@ describe("createEnvelopeTap (N26) -- S6's JSONL format", () => { it("stamps recorded_at from the injected clock", () => { withTempDir((dir) => { const path = join(dir, "envelopes.jsonl"); - const tap = createEnvelopeTap({ path, now: () => new Date("2026-08-09T12:04:31.221Z") }); + const sink = createEnvelopeLogSink({ path, now: () => new Date("2026-08-09T12:04:31.221Z") }); - tap.write("request", REQUEST, "steps/toolCallRequest"); + sink.write("request", REQUEST, "steps/toolCallRequest"); expect(readEntries(path)[0]?.recorded_at).toBe("2026-08-09T12:04:31.221Z"); }); @@ -85,7 +90,7 @@ describe("createEnvelopeTap (N26) -- S6's JSONL format", () => { it("records method as null when the caller cannot determine one", () => { withTempDir((dir) => { const path = join(dir, "envelopes.jsonl"); - createEnvelopeTap({ path }).write("response", { jsonrpc: "2.0", id: null, error: { code: -32700 } }, null); + createEnvelopeLogSink({ path }).write("response", { jsonrpc: "2.0", id: null, error: { code: -32700 } }, null); const entry = readEntries(path)[0]; expect(entry?.method).toBeNull(); @@ -93,7 +98,7 @@ describe("createEnvelopeTap (N26) -- S6's JSONL format", () => { }); }); - // Global constraint 8. This is the whole reason the tap is a module and + // Global constraint 8. This is the whole reason the sink is a module and // not three inline appendFileSync calls. it("never throws when the log path is unwritable, reports once, and goes quiet", () => { withTempDir((dir) => { @@ -103,10 +108,10 @@ describe("createEnvelopeTap (N26) -- S6's JSONL format", () => { // fail with ENOTDIR, deterministically, on every platform. const path = join(blocker, "nested", "envelopes.jsonl"); const errors: unknown[] = []; - const tap = createEnvelopeTap({ path, onError: (error) => errors.push(error) }); + const sink = createEnvelopeLogSink({ path, onError: (error) => errors.push(error) }); - expect(() => tap.write("request", REQUEST, "steps/toolCallRequest")).not.toThrow(); - expect(() => tap.write("response", RESPONSE, "steps/toolCallRequest")).not.toThrow(); + expect(() => sink.write("request", REQUEST, "steps/toolCallRequest")).not.toThrow(); + expect(() => sink.write("response", RESPONSE, "steps/toolCallRequest")).not.toThrow(); expect(errors.length).toBe(1); }); }); @@ -115,11 +120,11 @@ describe("createEnvelopeTap (N26) -- S6's JSONL format", () => { withTempDir((dir) => { const path = join(dir, "envelopes.jsonl"); const errors: unknown[] = []; - const tap = createEnvelopeTap({ path, onError: (error) => errors.push(error) }); + const sink = createEnvelopeLogSink({ path, onError: (error) => errors.push(error) }); const circular: Record = { id: 1 }; circular.self = circular; - expect(() => tap.write("request", circular, "steps/toolCallRequest")).not.toThrow(); + expect(() => sink.write("request", circular, "steps/toolCallRequest")).not.toThrow(); expect(errors.length).toBe(1); }); }); @@ -131,7 +136,7 @@ describe("createEnvelopeTap (N26) -- S6's JSONL format", () => { const path = join(blocker, "nested", "envelopes.jsonl"); expect(() => { - createEnvelopeTap({ + createEnvelopeLogSink({ path, onError: () => { throw new Error("onError threw"); @@ -141,28 +146,28 @@ describe("createEnvelopeTap (N26) -- S6's JSONL format", () => { }); }); - it("never throws when onError itself throws at write time, and disables the tap", () => { + it("never throws when onError itself throws at write time, and disables the sink", () => { withTempDir((dir) => { const path = join(dir, "envelopes.jsonl"); const blocker = join(dir, "envelopes.jsonl"); writeFileSync(blocker, ""); - const tap = createEnvelopeTap({ + const sink = createEnvelopeLogSink({ path: join(blocker, "nested", "envelopes.jsonl"), onError: () => { throw new Error("onError threw"); }, }); - expect(() => tap.write("request", REQUEST, "steps/toolCallRequest")).not.toThrow(); - // Tap should be disabled, so second write is a silent no-op - expect(() => tap.write("response", RESPONSE, "steps/toolCallRequest")).not.toThrow(); + expect(() => sink.write("request", REQUEST, "steps/toolCallRequest")).not.toThrow(); + // The sink should be disabled, so the second write is a silent no-op + expect(() => sink.write("response", RESPONSE, "steps/toolCallRequest")).not.toThrow(); }); }); - it("NULL_TAP writes nothing and never throws", () => { - expect(() => NULL_TAP.write("request", REQUEST, "steps/toolCallRequest")).not.toThrow(); - expect(NULL_TAP.path).toBeNull(); + it("NULL_ENVELOPE_LOG_SINK writes nothing and never throws", () => { + expect(() => NULL_ENVELOPE_LOG_SINK.write("request", REQUEST, "steps/toolCallRequest")).not.toThrow(); + expect(NULL_ENVELOPE_LOG_SINK.path).toBeNull(); }); }); diff --git a/packages/guardian/test/server.test.ts b/packages/guardian/test/server.test.ts index 407ff90c..dac625df 100644 --- a/packages/guardian/test/server.test.ts +++ b/packages/guardian/test/server.test.ts @@ -394,8 +394,8 @@ export function isToolCallRequest(envelope) { * real, unmodified copy of every other file in \`packages/guardian/src\`, * dynamically imported from its own scratch directory so it is a distinct * module instance, with only \`validate-envelope.ts\` swapped for the - * double. No \`envelopeLogPath\` -- these tests need no tap, and - * \`NULL_TAP\`'s totality is already covered elsewhere. + * double. No \`envelopeLogPath\` -- these tests need no envelope log, and + * \`NULL_ENVELOPE_LOG_SINK\`'s totality is already covered elsewhere. * * \`root\` is the caller's -- one of the two scratch-dir constants above, * never shared between two different \`fakeSource\`s (see their doc comment @@ -447,7 +447,7 @@ async function withFakeValidateEnvelopeGuardian( } // Whole-branch review, finding 1 -- the fourth fail-open of V1's shape, and -// the exit the tap's structural-totality claim did not cover. `dispatch` +// the exit the sink's structural-totality claim did not cover. `dispatch` // rethrows any non-EnvelopeValidationError, and nothing used to catch it: // Bun.serve answers a rejecting fetch() handler with a `text/html` 500, // guardian-client's unconditional `res.json()` throws `JSON Parse error: @@ -515,7 +515,7 @@ describe("startGuardian POST /acs -- the outer net around dispatch", () => { }); }); - it("taps both the request and the response, so S6 has no untapped exit", async () => { + it("records both the request and the response, so S6 has no unrecorded exit", async () => { await withSchemalessGuardian(async ({ url, logPath }) => { await postAcs(url, toolCallEnvelope("ls -la", { id: 11 })); @@ -537,7 +537,7 @@ describe("startGuardian POST /acs -- the outer net around dispatch", () => { // and unlike the inner catch's own throw (contained by this outer catch), // a throw *from* the outer catch has nothing above `handleAcsRequest` to // catch it: Bun.serve's fetch handler has no try, so it answers with the - // untapped HTML 500 the module header exists to prevent. Fails against + // unrecorded HTML 500 the module header exists to prevent. Fails against // the pre-fix helper (confirmed by hand before implementing the fix: the // fetch below resolves to an HTML error page, and `res.json()` -- exactly // guardianClient.post's call -- throws a SyntaxError instead of returning diff --git a/packages/inspector/src/index.ts b/packages/inspector/src/index.ts index 73e2b1d3..3636ea82 100644 --- a/packages/inspector/src/index.ts +++ b/packages/inspector/src/index.ts @@ -1,3 +1,8 @@ /** Public surface of the inspector package. */ -export { tailEnvelopeLog, type TailOptions, type TapDirection, type TapEntry } from "./tail-envelope-log.ts"; -export { renderDecisionBadge, renderEntry, type RenderOptions } from "./render.ts"; +export { + tailEnvelopeLog, + type EnvelopeLogDirection, + type EnvelopeLogEntry, + type TailEnvelopeLogOptions, +} from "./tail-envelope-log.ts"; +export { renderDecisionBadge, renderEnvelopeLogEntry, type RenderOptions } from "./render.ts"; diff --git a/packages/inspector/src/main.ts b/packages/inspector/src/main.ts index f8956e2c..7867e76f 100644 --- a/packages/inspector/src/main.ts +++ b/packages/inspector/src/main.ts @@ -10,7 +10,7 @@ * hardcoding the other's value. */ import { tailEnvelopeLog } from "./tail-envelope-log.ts"; -import { renderEntry } from "./render.ts"; +import { renderEnvelopeLogEntry } from "./render.ts"; const DEFAULT_ENVELOPE_LOG = ".acs/envelopes.jsonl"; @@ -34,6 +34,6 @@ console.log(`Envelope Inspector — tailing ${path}${fromStart ? " (from the sta console.log("Ctrl-C to stop.\n"); for await (const entry of tailEnvelopeLog({ path, fromStart, signal: controller.signal })) { - console.log(renderEntry(entry, { color })); + console.log(renderEnvelopeLogEntry(entry, { color })); console.log(""); } diff --git a/packages/inspector/src/render.ts b/packages/inspector/src/render.ts index 3ba768f2..fa65dd97 100644 --- a/packages/inspector/src/render.ts +++ b/packages/inspector/src/render.ts @@ -7,7 +7,7 @@ * reads ACS's own `decision`, `reason_codes`, and `policy_references` * fields and nothing else (global constraint 9). */ -import type { TapEntry } from "./tail-envelope-log.ts"; +import type { EnvelopeLogEntry } from "./tail-envelope-log.ts"; export type RenderOptions = { color?: boolean; indent?: number }; @@ -56,7 +56,7 @@ function referenceList(value: unknown): string[] { /** U21. Null when this entry carries no decision and no error: a request, or * a response such as a ServerHello. */ -export function renderDecisionBadge(entry: TapEntry, options: RenderOptions = {}): string | null { +export function renderDecisionBadge(entry: EnvelopeLogEntry, options: RenderOptions = {}): string | null { if (entry.direction !== "response") { return null; } @@ -121,7 +121,7 @@ export function renderDecisionBadge(entry: TapEntry, options: RenderOptions = {} * pretty-printing below and the round-trip contract test; an accurate * sentence is the better trade. */ -export function renderEntry(entry: TapEntry, options: RenderOptions = {}): string { +export function renderEnvelopeLogEntry(entry: EnvelopeLogEntry, options: RenderOptions = {}): string { const color = options.color ?? false; const arrow = entry.direction === "request" ? "→ REQUEST " : "← RESPONSE"; const method = entry.method ?? "(no method)"; @@ -131,9 +131,9 @@ export function renderEntry(entry: TapEntry, options: RenderOptions = {}): strin const badge = renderDecisionBadge(entry, options); // `JSON.stringify` returns `undefined` -- not a string -- for an entry // whose `envelope` key is absent, and `join` would coerce that to an empty - // line indistinguishable from a real blank body. `isTapEntryShape` does - // not require `envelope` (it is `unknown` by design), so a hand-written or - // truncated S6 line reaches here without one. Narrowed the way the badge + // line indistinguishable from a real blank body. `isEnvelopeLogEntryShape` + // does not require `envelope` (it is `unknown` by design), so a hand-written + // or truncated S6 line reaches here without one. Narrowed the way the badge // path above narrows (whole-branch review, finding 8). const body = JSON.stringify(entry.envelope, null, options.indent ?? 2) ?? "(no envelope recorded)"; diff --git a/packages/inspector/src/tail-envelope-log.ts b/packages/inspector/src/tail-envelope-log.ts index 2afd8dc4..dc4c12f3 100644 --- a/packages/inspector/src/tail-envelope-log.ts +++ b/packages/inspector/src/tail-envelope-log.ts @@ -6,11 +6,20 @@ * its dependencies (global constraint 10). The Inspector reads a file that * the Guardian happens to write; it holds no compile-time knowledge of the * process that produced it, which is the point of R5.1 -- envelopes are - * inspectable *on the wire*, not through our own type graph. TapEntry is - * therefore re-declared here rather than imported. The round-trip test at + * inspectable *on the wire*, not through our own type graph. EnvelopeLogEntry + * is therefore re-declared here rather than imported. The round-trip test at * test/envelope-tap-roundtrip.test.ts is what keeps the two declarations in * agreement; if they drift, it fails. * + * The names are the artifact's, not the writer's (PR #11 review). This + * package's public surface used to carry `TapEntry` / `TapDirection` -- + * the Guardian's nickname for its own writing mechanism, on a module whose + * whole job is reading -- and a bare `TailOptions` that named no log at all. + * A later slice gives this package a second stream to follow, and the two + * only read as siblings if each names its own log: `EnvelopeLogEntry` beside + * that stream's entry type, `TailEnvelopeLogOptions` beside its options type, + * the way `tailEnvelopeLog` and its twin verb already do. + * * Polling rather than fs.watch: appends to a growing file are exactly the * case where watch semantics differ most across platforms, and a 120ms poll * on a local demo log costs nothing. @@ -54,19 +63,19 @@ */ import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs"; -export type TapDirection = "request" | "response"; +export type EnvelopeLogDirection = "request" | "response"; -/** One line of S6, as written by the Guardian's envelope tap. */ -export type TapEntry = { +/** One line of S6, as written by the Guardian's envelope log sink. */ +export type EnvelopeLogEntry = { seq: number; recorded_at: string; - direction: TapDirection; + direction: EnvelopeLogDirection; method: string | null; rpc_id: string | number | null; envelope: unknown; }; -export type TailOptions = { +export type TailEnvelopeLogOptions = { path: string; /** Replay everything already in the file before following. Default false: * start at the current end, like `tail -f`. */ @@ -83,9 +92,9 @@ const NEWLINE = 0x0a; /** * S6 is a plain file on disk; anything can write a line to it that is valid - * JSON but not a valid TapEntry (a number where recorded_at should be a - * string, a missing direction, ...). `renderEntry`'s `clockOf` calls - * `.slice` on `recorded_at` unconditionally, so an unchecked cast here would + * JSON but not a valid EnvelopeLogEntry (a number where recorded_at should be + * a string, a missing direction, ...). `renderEnvelopeLogEntry`'s `clockOf` + * calls `.slice` on `recorded_at` unconditionally, so an unchecked cast would * let such a line reach the renderer and throw -- inside a `for await` loop, * that kills the whole stream. Checked here instead, right after * `JSON.parse`, using exactly the fields the renderer depends on. @@ -93,7 +102,7 @@ const NEWLINE = 0x0a; * (R5.1 -- see the module doc above), not a shape this function's job to * police. */ -function isTapEntryShape(value: unknown): value is TapEntry { +function isEnvelopeLogEntryShape(value: unknown): value is EnvelopeLogEntry { if (typeof value !== "object" || value === null) { return false; } @@ -113,7 +122,7 @@ export function tailEnvelopeLog({ pollMs = 120, signal, onMalformedLine = warnMalformedLine, -}: TailOptions): AsyncGenerator { +}: TailEnvelopeLogOptions): AsyncGenerator { let offset = fromStart ? 0 : sizeOf(path); // Bytes, not a string: a poll can land mid-line and, worse, mid-codepoint. // Decoding only complete lines keeps multi-byte UTF-8 intact. @@ -122,7 +131,7 @@ export function tailEnvelopeLog({ // Entries the timer has parsed but nobody has consumed yet, and the // wake-up the drain loop below is currently parked on while that queue is // empty. The timer and the generator only communicate through these two. - const ready: TapEntry[] = []; + const ready: EnvelopeLogEntry[] = []; let wake: (() => void) | undefined; let stopped = false; let timer: ReturnType | undefined; @@ -157,8 +166,8 @@ export function tailEnvelopeLog({ } try { const parsed: unknown = JSON.parse(line); - if (!isTapEntryShape(parsed)) { - throw new Error("line parsed as JSON but does not match the TapEntry shape"); + if (!isEnvelopeLogEntryShape(parsed)) { + throw new Error("line parsed as JSON but does not match the EnvelopeLogEntry shape"); } ready.push(parsed); added = true; @@ -221,7 +230,7 @@ export function tailEnvelopeLog({ return drain(); - async function* drain(): AsyncGenerator { + async function* drain(): AsyncGenerator { // Starting the timer here, not above, means a tail nobody ever iterates // (and nobody ever aborts) never ticks at all. if (!stopped) { diff --git a/packages/inspector/test/render.test.ts b/packages/inspector/test/render.test.ts index b16d0294..dccf1dee 100644 --- a/packages/inspector/test/render.test.ts +++ b/packages/inspector/test/render.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { renderDecisionBadge, renderEntry } from "../src/render.ts"; -import type { TapEntry } from "../src/tail-envelope-log.ts"; +import { renderDecisionBadge, renderEnvelopeLogEntry } from "../src/render.ts"; +import type { EnvelopeLogEntry } from "../src/tail-envelope-log.ts"; -function entry(overrides: Partial): TapEntry { +function entry(overrides: Partial): EnvelopeLogEntry { return { seq: 3, recorded_at: "2026-08-09T12:04:31.221Z", @@ -14,7 +14,7 @@ function entry(overrides: Partial): TapEntry { }; } -function response(result: Record): TapEntry { +function response(result: Record): EnvelopeLogEntry { return entry({ envelope: { jsonrpc: "2.0", id: 1, result } }); } @@ -124,9 +124,9 @@ describe("renderDecisionBadge (U21)", () => { }); }); -describe("renderEntry (U20)", () => { +describe("renderEnvelopeLogEntry (U20)", () => { it("renders a request as a header line plus pretty JSON, with no badge", () => { - const rendered = renderEntry( + const rendered = renderEnvelopeLogEntry( entry({ direction: "request", seq: 1, @@ -140,7 +140,7 @@ describe("renderEntry (U20)", () => { }); it("renders a response as a header line, a badge line, then pretty JSON", () => { - const rendered = renderEntry(response({ decision: "deny", reason_codes: ["blocked"] })); + const rendered = renderEnvelopeLogEntry(response({ decision: "deny", reason_codes: ["blocked"] })); const lines = rendered.split("\n"); expect(lines[0]).toBe("── #3 12:04:31.221 ← RESPONSE steps/toolCallRequest id=1"); @@ -149,7 +149,7 @@ describe("renderEntry (U20)", () => { }); it("labels an unpaired response -- the malformed-body case -- without an id or a method", () => { - const rendered = renderEntry( + const rendered = renderEnvelopeLogEntry( entry({ method: null, rpc_id: null, envelope: { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } } }), ); @@ -163,13 +163,13 @@ describe("renderEntry (U20)", () => { // Guardian parsed rather than the bytes the host sent. it("changes nothing but whitespace -- the envelope value round trips through the renderer", () => { const envelope = { jsonrpc: "2.0", id: 1, result: { decision: "allow", nested: { deep: [1, 2] } } }; - const rendered = renderEntry(entry({ envelope })); + const rendered = renderEnvelopeLogEntry(entry({ envelope })); const jsonStart = rendered.indexOf("{"); expect(JSON.parse(rendered.slice(jsonStart))).toEqual(envelope); }); - // Whole-branch review, finding 8. `isTapEntryShape` deliberately does not + // Whole-branch review, finding 8. `isEnvelopeLogEntryShape` deliberately does not // constrain `envelope`, so a hand-written or truncated S6 line reaches the // renderer with the key missing entirely. `JSON.stringify(undefined)` // returns `undefined`, which `join` would coerce into a blank line @@ -181,9 +181,9 @@ describe("renderEntry (U20)", () => { direction: "response", method: "steps/toolCallRequest", rpc_id: 1, - } as unknown as TapEntry; + } as unknown as EnvelopeLogEntry; - const lines = renderEntry(withoutEnvelope).split("\n"); + const lines = renderEnvelopeLogEntry(withoutEnvelope).split("\n"); expect(lines).toHaveLength(2); expect(lines[0]).toBe("── #3 12:04:31.221 ← RESPONSE steps/toolCallRequest id=1"); diff --git a/packages/inspector/test/tail-envelope-log.test.ts b/packages/inspector/test/tail-envelope-log.test.ts index 621dfe28..8fc1132e 100644 --- a/packages/inspector/test/tail-envelope-log.test.ts +++ b/packages/inspector/test/tail-envelope-log.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it, spyOn } from "bun:test"; import { appendFileSync, mkdirSync, mkdtempSync, rmdirSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { tailEnvelopeLog, type TapEntry } from "../src/tail-envelope-log.ts"; +import { tailEnvelopeLog, type EnvelopeLogEntry } from "../src/tail-envelope-log.ts"; const POLL_MS = 10; function entryLine(seq: number, direction: "request" | "response"): string { - const entry: TapEntry = { + const entry: EnvelopeLogEntry = { seq, recorded_at: "2026-08-09T12:04:31.221Z", direction, @@ -21,12 +21,12 @@ function entryLine(seq: number, direction: "request" | "response"): string { /** Collects `count` entries or rejects after `timeoutMs`, then aborts the * generator so the test cannot hang the suite. */ async function collect( - iterable: AsyncGenerator, + iterable: AsyncGenerator, count: number, controller: AbortController, timeoutMs = 3000, -): Promise { - const out: TapEntry[] = []; +): Promise { + const out: EnvelopeLogEntry[] = []; const deadline = setTimeout(() => controller.abort(), timeoutMs); try { for await (const entry of iterable) { @@ -129,7 +129,7 @@ describe("tailEnvelopeLog (N50)", () => { expect(entries.map((e) => e.seq)).toEqual([9]); }); - async function collectOne(tail: AsyncGenerator): Promise { + async function collectOne(tail: AsyncGenerator): Promise { const { value } = await tail.next(); return value ?? undefined; } @@ -161,7 +161,7 @@ describe("tailEnvelopeLog (N50)", () => { const tail = tailEnvelopeLog({ path, fromStart: true, pollMs: POLL_MS, signal: controller.signal }); controller.abort(); - const entries: TapEntry[] = []; + const entries: EnvelopeLogEntry[] = []; for await (const entry of tail) { entries.push(entry); } @@ -186,7 +186,7 @@ describe("tailEnvelopeLog (N50)", () => { expect(entries.map((e) => e.seq)).toEqual([9]); }); - async function collectOne(tail: AsyncGenerator): Promise { + async function collectOne(tail: AsyncGenerator): Promise { const { value } = await tail.next(); return value ?? undefined; } @@ -247,7 +247,7 @@ describe("tailEnvelopeLog (N50)", () => { it("reassembles a line whose split lands mid-codepoint, without corrupting the multi-byte character", async () => { await withTempDir(async (_dir, path) => { const note = "🎉café"; - const entry: TapEntry = { + const entry: EnvelopeLogEntry = { seq: 7, recorded_at: "2026-08-09T12:04:31.221Z", direction: "request", @@ -464,14 +464,14 @@ describe("tailEnvelopeLog (N50)", () => { }); }); - // Backlog item K. `isTapEntryShape` deliberately never constrains + // Backlog item K. `isEnvelopeLogEntryShape` deliberately never constrains // `envelope` -- it is `unknown` by design (R5.1) -- so a line whose // `envelope` is absent, `null`, or a bare string is still a valid - // TapEntry as far as this function is concerned, and reaches the consumer - // rather than being reported through `onMalformedLine`. That is - // load-bearing: the Guardian must be able to tap anything that crossed + // EnvelopeLogEntry as far as this function is concerned, and reaches the + // consumer rather than being reported through `onMalformedLine`. That is + // load-bearing: the Guardian must be able to record anything that crossed // the wire, including a malformed body it never got a real envelope for - // (see server.ts's Parse-error path, which taps a response with no + // (see server.ts's Parse-error path, which records a response with no // paired request at all). it("accepts a line whose envelope is absent, null, or a bare string, without reporting it as malformed", async () => { await withTempDir(async (_dir, path) => { diff --git a/test/envelope-tap-roundtrip.test.ts b/test/envelope-tap-roundtrip.test.ts index 2be2c278..b96ec6fe 100644 --- a/test/envelope-tap-roundtrip.test.ts +++ b/test/envelope-tap-roundtrip.test.ts @@ -3,14 +3,15 @@ import { mkdtempSync, rmdirSync, unlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { startGuardian } from "../packages/guardian/src/index.ts"; -import { tailEnvelopeLog, type TapEntry } from "../packages/inspector/src/tail-envelope-log.ts"; +import { tailEnvelopeLog, type EnvelopeLogEntry } from "../packages/inspector/src/tail-envelope-log.ts"; import { renderDecisionBadge } from "../packages/inspector/src/render.ts"; /** * The contract test for S6. The Guardian writes the log; the Inspector - * declares its own TapEntry and reads it back (global constraint 10). If - * either side renames a field, adds a required one, or changes a type, this - * is what fails -- nothing else would, because the two never share a type. + * declares its own EnvelopeLogEntry and reads it back (global constraint 10). + * If either side renames a field, adds a required one, or changes a type, + * this is what fails -- nothing else would, because the two never share a + * type. */ function toolCallEnvelope(command: string, id: number): Record { return { @@ -28,11 +29,11 @@ function toolCallEnvelope(command: string, id: number): Record } async function take( - tail: AsyncGenerator, + tail: AsyncGenerator, count: number, controller: AbortController, -): Promise { - const out: TapEntry[] = []; +): Promise { + const out: EnvelopeLogEntry[] = []; const deadline = setTimeout(() => controller.abort(), 5000); try { for await (const entry of tail) { @@ -48,7 +49,7 @@ async function take( return out; } -describe("S6 round trip: Guardian tap (N26) -> Inspector tail (N50) -> badge (U21)", () => { +describe("S6 round trip: Guardian envelope log sink (N26) -> Inspector tail (N50) -> badge (U21)", () => { it("a denied tool call arrives as a paired request/response the Inspector can render", async () => { const dir = mkdtempSync(join(tmpdir(), "acs-roundtrip-")); const logPath = join(dir, "envelopes.jsonl"); @@ -69,8 +70,8 @@ describe("S6 round trip: Guardian tap (N26) -> Inspector tail (N50) -> badge (U2 const tail = tailEnvelopeLog({ path: logPath, fromStart: true, pollMs: 10, signal: controller.signal }); const [request, response] = await take(tail, 2, controller); - // Every field the Inspector's TapEntry declares must actually be - // present and correctly typed on what the Guardian wrote. + // Every field the Inspector's EnvelopeLogEntry declares must actually + // be present and correctly typed on what the Guardian wrote. expect(request?.seq).toBe(1); expect(response?.seq).toBe(2); expect(typeof request?.recorded_at).toBe("string"); @@ -83,7 +84,7 @@ describe("S6 round trip: Guardian tap (N26) -> Inspector tail (N50) -> badge (U2 expect(response?.rpc_id).toBe(77); // ...and the badge reads a real AGT-backed decision off it. - expect(renderDecisionBadge(response as TapEntry)).toContain("DENY"); + expect(renderDecisionBadge(response as EnvelopeLogEntry)).toContain("DENY"); } finally { controller.abort(); await guardian.close(); diff --git a/test/invariants.test.ts b/test/invariants.test.ts index aff04217..95b59e22 100644 --- a/test/invariants.test.ts +++ b/test/invariants.test.ts @@ -192,7 +192,8 @@ describe("architectural invariants", () => { * from "guardian" the static named/default import * import "guardian" the bare side-effect import, no `from` * import("guardian") dynamic, and `await import("guardian")` - * import("guardian").TapEntry type position -- erased at build, still a + * import("guardian").EnvelopeLogEntry + * type position -- erased at build, still a * compile-time dependency on the Guardian's * type graph, which is exactly what R5.1 * forbids @@ -215,12 +216,12 @@ describe("the import gate itself", () => { */ it("catches every import form, in any case", () => { const caught = [ - 'import { TapEntry } from "guardian";', + 'import { EnvelopeLogEntry } from "guardian";', 'import "guardian";', 'const g = await import("guardian");', - 'type E = import("guardian").TapEntry;', + 'type E = import("guardian").EnvelopeLogEntry;', 'const g = require("guardian");', - 'import { TapEntry } from "Guardian";', + 'import { EnvelopeLogEntry } from "Guardian";', 'export { x } from "../../guardian/src/index.ts";', ].map((line) => ({ line, found: importsSpecifier(line, "guardian") })); @@ -231,7 +232,7 @@ describe("the import gate itself", () => { const ignored = [ 'const label = "guardian";', "const guardian = startGuardian();", - 'import { renderEntry } from "./render.ts";', + 'import { renderEnvelopeLogEntry } from "./render.ts";', ].map((line) => ({ line, found: importsSpecifier(line, "guardian") })); expect(ignored).toEqual(ignored.map(({ line }) => ({ line, found: false }))); From 062dccff40e64a82b62054c46d5886efee4eef6b Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Tue, 11 Aug 2026 12:15:37 +0300 Subject: [PATCH 26/35] Guardian barrel: governance verbs only, not S6's plumbing PR #11 review, SRP: the barrel exported `startGuardian` and `mapVerdict` beside `createEnvelopeLogSink`, `NULL_ENVELOPE_LOG_SINK`, `extractRpcId` and the entry types, so observability read as first-class Guardian vocabulary rather than an internal N26 detail of running one. Checked before removing, per the finding's own condition: nothing outside this package imported any of them from the barrel. `./server.ts` imports the sink relatively, and the two test files that exercise N26 on its own import `../src/envelope-tap.ts` directly -- which is what they already did. So this removes a public surface, not a dependency. The one affordance a consumer needs is untouched: `envelopeLogPath` on `StartGuardianOptions`. You tell a Guardian where to write S6; you do not assemble its sink yourself. The Inspector is unaffected in either direction -- it imports nothing from this package at all (R5.1) and re-declares its own entry type. Addresses: #11 review --- packages/guardian/src/index.ts | 37 +++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/packages/guardian/src/index.ts b/packages/guardian/src/index.ts index 6ad34b5e..e208b263 100644 --- a/packages/guardian/src/index.ts +++ b/packages/guardian/src/index.ts @@ -1,4 +1,30 @@ -/** Public surface of the guardian package. */ +/** + * Public surface of the guardian package: the governance verbs, and nothing + * else. + * + * S6's writer (N26, ./envelope-tap.ts) is deliberately NOT re-exported. + * Observability is an internal detail of running a Guardian, not part of the + * vocabulary a consumer of this package speaks -- and a barrel that mixes + * `startGuardian` / `mapVerdict` with `createEnvelopeLogSink` / + * `NULL_ENVELOPE_LOG_SINK` / `extractRpcId` / the entry types makes the + * second look like the first (PR #11 review). The one affordance a consumer + * actually needs is `envelopeLogPath` on `StartGuardianOptions`, which stays + * exactly where it was: you tell a Guardian where to write S6, you do not + * assemble its sink yourself. + * + * Nothing outside this package imported those names, so this removes a + * public surface rather than a dependency: `./server.ts` imports the sink + * directly, and the two test files that exercise N26 on its own + * (`packages/guardian/test/envelope-tap*.test.ts`) import + * `../src/envelope-tap.ts` directly, which is the arrangement they already + * used. If an external writer of S6 ever becomes a real use case, the + * honest answer is a narrow subpath export, not putting it back here. + * + * The Inspector is unaffected either way -- it imports NOTHING from this + * package (R5.1), reads S6 as a file, and re-declares `EnvelopeLogEntry` + * itself; `test/invariants.test.ts` gates that and + * `test/envelope-tap-roundtrip.test.ts` keeps the two declarations honest. + */ export { startGuardian, type StartGuardianOptions, type StartedGuardian } from "./server.ts"; export { buildServerHello, type ServerHello } from "./handshake.ts"; export { @@ -10,12 +36,3 @@ export { } from "./validate-envelope.ts"; export { assemblePreToolCallSnapshot, type AgtPreToolCallSnapshot } from "./assemble-snapshot.ts"; export { loadMapping, mapVerdict, type Mapping, type AcsDecision } from "./map-verdict.ts"; -export { - createEnvelopeLogSink, - extractRpcId, - NULL_ENVELOPE_LOG_SINK, - type CreateEnvelopeLogSinkOptions, - type EnvelopeLogDirection, - type EnvelopeLogEntry, - type EnvelopeLogSink, -} from "./envelope-tap.ts"; From 601789fd338c004373c5eca1d887726d819be214 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Tue, 11 Aug 2026 12:18:44 +0300 Subject: [PATCH 27/35] Tell the decision badge a decision, instead of handing it a log row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #11 review, tell-don't-ask: renderDecisionBadge took a whole EnvelopeLogEntry and dug `entry.envelope.result.decision` out of it, so "render this decision" read as "ask this log line what it contains" -- and it tied U21 to the one artifact that happens to carry a decision today. The digging moves into `decisionMessageOf`, one named translation from an S6 line to the small message U21 speaks: DecisionMessage = | { decision, reason_codes, policy_references } | { error: { code, message } } A union, not one object with an optional `error` beside a `decision`: a response carries exactly one of them, and the other arm's fields would have to be invented. `renderEnvelopeLogEntry` now calls `decisionMessageOf` then the badge, and anything else that can build the message can use the badge without owning an envelope log at all. Behaviour is unchanged. Every rendered string is byte-identical, including the `✖ ERROR ?` fallback for a non-numeric JSON-RPC code, which used to be computed inline in the renderer and is now `code: null` in the message -- pinned by a new test so the equivalence is asserted rather than assumed. Two badge tests that asserted `renderDecisionBadge(...) === null` for a request and for a ServerHello now assert the same claim about `decisionMessageOf`, which is where "there is no outcome to badge" moved: the badge itself no longer has a null return. Same claim, new owner. 161 tests (was 157): the two moved, plus four that the reshape makes possible to state directly -- what the message narrows to, the error arm's null code, and a badge rendered from a hand-built message with no log entry in sight. README's count updated. Addresses: #11 review --- README.md | 2 +- packages/inspector/src/index.ts | 9 +- packages/inspector/src/render.ts | 103 +++++++++++++++----- packages/inspector/test/render.test.ts | 126 +++++++++++++++++-------- test/envelope-tap-roundtrip.test.ts | 12 ++- 5 files changed, 187 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 71594f24..0a77ec40 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ Second, through the real `claude` CLI with `.claude/settings.json` installed — ### Verify ```bash -bun test # 157 tests across 17 files (156 pass, 1 skip), including the R3.2/R3.3 +bun test # 161 tests across 17 files (160 pass, 1 skip), including the R3.2/R3.3 # and R5.1/R5.2 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/inspector/src/index.ts b/packages/inspector/src/index.ts index 3636ea82..5ab26656 100644 --- a/packages/inspector/src/index.ts +++ b/packages/inspector/src/index.ts @@ -5,4 +5,11 @@ export { type EnvelopeLogEntry, type TailEnvelopeLogOptions, } from "./tail-envelope-log.ts"; -export { renderDecisionBadge, renderEnvelopeLogEntry, type RenderOptions } from "./render.ts"; +export { + decisionMessageOf, + renderDecisionBadge, + renderEnvelopeLogEntry, + type DecisionMessage, + type PolicyReference, + type RenderOptions, +} from "./render.ts"; diff --git a/packages/inspector/src/render.ts b/packages/inspector/src/render.ts index fa65dd97..80ef2fd3 100644 --- a/packages/inspector/src/render.ts +++ b/packages/inspector/src/render.ts @@ -1,11 +1,20 @@ /** * U20 (envelope stream) and U21 (decision badge). * - * Both functions are pure: no clock, no env, no process. The CLI decides + * Every function here is pure: no clock, no env, no process. The CLI decides * whether the terminal wants ANSI and passes `color`; tests assert exact * plain strings. Nothing here knows what produced a decision -- the badge * reads ACS's own `decision`, `reason_codes`, and `policy_references` * fields and nothing else (global constraint 9). + * + * `renderDecisionBadge` is TOLD a decision rather than handed a log row to + * interrogate (PR #11 review). It used to take an `EnvelopeLogEntry` and dig + * `entry.envelope.result.decision` out of it, which made "render this + * decision" read as "ask this log line what it contains" and tied U21 to the + * one artifact that happens to carry a decision today. The digging now lives + * in `decisionMessageOf`, one named translation from an S6 line to the small + * `DecisionMessage` the badge actually needs; anything else able to build + * that message can use the badge without owning an envelope log. */ import type { EnvelopeLogEntry } from "./tail-envelope-log.ts"; @@ -18,7 +27,22 @@ const YELLOW = "\u001b[33m"; const CYAN = "\u001b[36m"; const DIM = "\u001b[2m"; -type PolicyReference = { policy_id?: string; policy_version?: string; rule_id?: string }; +export type PolicyReference = { policy_id?: string; policy_version?: string; rule_id?: string }; + +/** + * U21's message: what a caller tells the badge, already narrowed to the ACS + * fields it renders. + * + * A union rather than one object with an optional `error` beside a + * `decision`, because a response carries exactly one of them and the other + * arm's fields would have to be invented. A `steps/*` response either names + * an ACS `decision` or is a JSON-RPC error; a caller cannot hand the badge + * both, and cannot hand it neither. + */ +export type DecisionMessage = + | { decision: string; reason_codes: string[]; policy_references: PolicyReference[] } + | { error: { code: number | null; message: string } }; + type DecisionResult = { decision?: unknown; reason_codes?: unknown; @@ -41,32 +65,47 @@ function stringList(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; } +/** The `policy_references` a response carried, narrowed to objects. Kept + * separate from the formatting below so the message stays structured: a + * caller building a `DecisionMessage` by hand passes references, not + * pre-rendered strings. */ +function policyReferenceList(value: unknown): PolicyReference[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((item): item is PolicyReference => typeof item === "object" && item !== null); +} + /** Formats each `policy_references` entry as `policy_id#rule_id`, falling * back to the bare `policy_id` when `rule_id` is absent. ACS's schemas do * not require `rule_id` on a policy_reference, so that fallback is a real * shape this renders deliberately, not a defect. */ -function referenceList(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - return value - .filter((item): item is PolicyReference => typeof item === "object" && item !== null) - .map((ref) => (ref.rule_id ? `${ref.policy_id ?? "?"}#${ref.rule_id}` : `${ref.policy_id ?? "?"}`)); +function formatReferences(references: PolicyReference[]): string[] { + return references.map((ref) => (ref.rule_id ? `${ref.policy_id ?? "?"}#${ref.rule_id}` : `${ref.policy_id ?? "?"}`)); } -/** U21. Null when this entry carries no decision and no error: a request, or - * a response such as a ServerHello. */ -export function renderDecisionBadge(entry: EnvelopeLogEntry, options: RenderOptions = {}): string | null { +/** + * The badge's message for one S6 line, or null when that line has no outcome + * to badge: a request, or a response such as a ServerHello. + * + * The one place in this module that reads an envelope's shape. It is a + * translation, not a collaboration -- it turns an artifact into the message + * U21 speaks -- and confining it here is what lets `renderDecisionBadge` be + * told a decision instead of interrogating a log row for one. + */ +export function decisionMessageOf(entry: EnvelopeLogEntry): DecisionMessage | null { if (entry.direction !== "response") { return null; } - const color = options.color ?? false; const envelope = (typeof entry.envelope === "object" && entry.envelope !== null ? entry.envelope : {}) as ResponseEnvelope; if (envelope.error) { - const code = typeof envelope.error.code === "number" ? envelope.error.code : "?"; - const message = typeof envelope.error.message === "string" ? envelope.error.message : ""; - return paint(`✖ ERROR ${code}${message ? ` ${message}` : ""}`, RED, color); + return { + error: { + code: typeof envelope.error.code === "number" ? envelope.error.code : null, + message: typeof envelope.error.message === "string" ? envelope.error.message : "", + }, + }; } const result = envelope.result; @@ -74,22 +113,39 @@ export function renderDecisionBadge(entry: EnvelopeLogEntry, options: RenderOpti return null; } - const reasonCodes = stringList(result.reason_codes); - const references = referenceList(result.policy_references); + return { + decision: result.decision, + reason_codes: stringList(result.reason_codes), + policy_references: policyReferenceList(result.policy_references), + }; +} + +/** U21. Renders the decision (or the error) it is given. */ +export function renderDecisionBadge(message: DecisionMessage, options: RenderOptions = {}): string { + const color = options.color ?? false; + + if ("error" in message) { + const code = message.error.code ?? "?"; + const text = message.error.message; + return paint(`✖ ERROR ${code}${text ? ` ${text}` : ""}`, RED, color); + } + + const reasonCodes = message.reason_codes; + const references = formatReferences(message.policy_references); let head: string; - if (result.decision === "deny") { + if (message.decision === "deny") { head = paint("● DENY", RED, color); - } else if (result.decision === "allow" && references.length > 0) { + } else if (message.decision === "allow" && references.length > 0) { // ACS has no `warn`; a policy that fired but let the action proceed // arrives as `allow` with a non-empty policy_references. Rendering it // identically to a clean allow is exactly what this badge exists to // prevent (slices doc, section V2). head = paint('◐ ALLOW (policy fired — ACS "warn")', YELLOW, color); - } else if (result.decision === "allow") { + } else if (message.decision === "allow") { head = paint("○ ALLOW", GREEN, color); } else { - head = paint(`◆ ${result.decision.toUpperCase()}`, CYAN, color); + head = paint(`◆ ${message.decision.toUpperCase()}`, CYAN, color); } // Dimmed rather than left plain: with color:true, painting only `head` @@ -128,7 +184,8 @@ export function renderEnvelopeLogEntry(entry: EnvelopeLogEntry, options: RenderO const id = entry.rpc_id === null ? "(unpaired)" : `id=${entry.rpc_id}`; const header = paint(`── #${entry.seq} ${clockOf(entry.recorded_at)} ${arrow} ${method} ${id}`, DIM, color); - const badge = renderDecisionBadge(entry, options); + const message = decisionMessageOf(entry); + const badge = message === null ? null : renderDecisionBadge(message, options); // `JSON.stringify` returns `undefined` -- not a string -- for an entry // whose `envelope` key is absent, and `join` would coerce that to an empty // line indistinguishable from a real blank body. `isEnvelopeLogEntryShape` diff --git a/packages/inspector/test/render.test.ts b/packages/inspector/test/render.test.ts index dccf1dee..96017aeb 100644 --- a/packages/inspector/test/render.test.ts +++ b/packages/inspector/test/render.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { renderDecisionBadge, renderEnvelopeLogEntry } from "../src/render.ts"; +import { + decisionMessageOf, + renderDecisionBadge, + renderEnvelopeLogEntry, + type DecisionMessage, + type RenderOptions, +} from "../src/render.ts"; import type { EnvelopeLogEntry } from "../src/tail-envelope-log.ts"; function entry(overrides: Partial): EnvelopeLogEntry { @@ -18,23 +24,70 @@ function response(result: Record): EnvelopeLogEntry { return entry({ envelope: { jsonrpc: "2.0", id: 1, result } }); } -describe("renderDecisionBadge (U21)", () => { - it("returns null for requests", () => { - expect(renderDecisionBadge(entry({ direction: "request", envelope: { jsonrpc: "2.0", id: 1 } }))).toBeNull(); +/** The message renderEnvelopeLogEntry would build for this S6 line, for the + * badge tests that assert the rendered string end to end. Throws rather than + * asserting non-null inline, so a line that stopped carrying an outcome fails + * as itself instead of as a confusing `toBe` diff. */ +function messageOf(line: EnvelopeLogEntry): DecisionMessage { + const message = decisionMessageOf(line); + if (message === null) { + throw new Error("expected this entry to carry a decision or an error"); + } + return message; +} + +function badgeFor(result: Record, options?: RenderOptions): string { + return renderDecisionBadge(messageOf(response(result)), options); +} + +describe("decisionMessageOf -- what U21 is told about", () => { + it("has nothing to say about a request", () => { + expect(decisionMessageOf(entry({ direction: "request", envelope: { jsonrpc: "2.0", id: 1 } }))).toBeNull(); + }); + + it("has nothing to say about a response with no decision -- a ServerHello", () => { + expect(decisionMessageOf(response({ negotiated_version: "0.1.0", on_decision_failure: "proceed" }))).toBeNull(); }); - it("returns null for a response with no decision -- a ServerHello", () => { - expect(renderDecisionBadge(response({ negotiated_version: "0.1.0", on_decision_failure: "proceed" }))).toBeNull(); + // The reshape's point (PR #11 review): the badge is handed ACS fields, not + // a log row to dig through, so everything it renders is decided here. + it("narrows the ACS fields the badge renders, and drops the rest of the envelope", () => { + expect( + decisionMessageOf( + response({ + decision: "deny", + reason_codes: ["blocked", 7], + policy_references: [{ policy_id: "agt_stock", rule_id: "blocked" }, "not an object"], + reasoning: "ignored by the badge", + }), + ), + ).toEqual({ + decision: "deny", + reason_codes: ["blocked"], + policy_references: [{ policy_id: "agt_stock", rule_id: "blocked" }], + }); + }); + + it("reports a JSON-RPC error as an error message, with a null code when it is not a number", () => { + expect(decisionMessageOf(entry({ envelope: { jsonrpc: "2.0", id: 1, error: { code: "nope" } } }))).toEqual({ + error: { code: null, message: "" }, + }); + }); +}); + +describe("renderDecisionBadge (U21)", () => { + // The renderer needs no envelope log at all now: anything that can build + // the message can use the badge. + it("renders a message built by hand, with no log entry anywhere in sight", () => { + expect(renderDecisionBadge({ decision: "deny", reason_codes: [], policy_references: [] })).toBe("● DENY"); }); it("badges a deny, with reason_codes and policy_references", () => { - const badge = renderDecisionBadge( - response({ - decision: "deny", - reason_codes: ["destructive_shell_command_blocked"], - policy_references: [{ policy_id: "agt_stock", rule_id: "destructive_shell_command_blocked" }], - }), - ); + const badge = badgeFor({ + decision: "deny", + reason_codes: ["destructive_shell_command_blocked"], + policy_references: [{ policy_id: "agt_stock", rule_id: "destructive_shell_command_blocked" }], + }); expect(badge).toBe( "● DENY reason_codes=[destructive_shell_command_blocked] " + @@ -43,26 +96,24 @@ describe("renderDecisionBadge (U21)", () => { }); it("badges a plain allow", () => { - expect(renderDecisionBadge(response({ decision: "allow" }))).toBe("○ ALLOW"); + expect(badgeFor({ decision: "allow" })).toBe("○ ALLOW"); }); // The reason U21 exists, per the slices doc: an AGT `warn` arrives as an // ACS `allow` with a non-empty policy_references, and the badge is what // keeps it from being buried. it("distinguishes an allow that carries policy_references -- ACS's encoding of warn", () => { - const badge = renderDecisionBadge( - response({ - decision: "allow", - reason_codes: ["drift_detected"], - policy_references: [{ policy_id: "agt_stock", rule_id: "drift_detected" }], - }), - ); + const badge = badgeFor({ + decision: "allow", + reason_codes: ["drift_detected"], + policy_references: [{ policy_id: "agt_stock", rule_id: "drift_detected" }], + }); expect(badge).toBe( '◐ ALLOW (policy fired — ACS "warn") reason_codes=[drift_detected] ' + "policy_references=[agt_stock#drift_detected]", ); - expect(badge).not.toBe(renderDecisionBadge(response({ decision: "allow" }))); + expect(badge).not.toBe(badgeFor({ decision: "allow" })); }); // Pins current behaviour (backlog item H): ACS's schemas do not require @@ -71,33 +122,34 @@ describe("renderDecisionBadge (U21)", () => { // degradation, not a bug -- this test exists so a future change to it is // a decision, not an accident. it("renders a policy_reference with no rule_id as the bare policy_id", () => { - const badge = renderDecisionBadge( - response({ - decision: "deny", - policy_references: [{ policy_id: "agt_stock" }], - }), - ); + const badge = badgeFor({ decision: "deny", policy_references: [{ policy_id: "agt_stock" }] }); expect(badge).toBe("● DENY policy_references=[agt_stock]"); }); it("badges modify, ask, and defer", () => { - expect(renderDecisionBadge(response({ decision: "modify" }))).toBe("◆ MODIFY"); - expect(renderDecisionBadge(response({ decision: "ask" }))).toBe("◆ ASK"); - expect(renderDecisionBadge(response({ decision: "defer" }))).toBe("◆ DEFER"); + expect(badgeFor({ decision: "modify" })).toBe("◆ MODIFY"); + expect(badgeFor({ decision: "ask" })).toBe("◆ ASK"); + expect(badgeFor({ decision: "defer" })).toBe("◆ DEFER"); }); it("badges a JSON-RPC error", () => { const badge = renderDecisionBadge( - entry({ envelope: { jsonrpc: "2.0", id: 1, error: { code: -32010, message: "ACS envelope failed" } } }), + messageOf(entry({ envelope: { jsonrpc: "2.0", id: 1, error: { code: -32010, message: "ACS envelope failed" } } })), ); expect(badge).toBe("✖ ERROR -32010 ACS envelope failed"); }); + // Pins the fallback the old renderer had inline: a code that is not a + // number reaches the message as null and still renders as `?`. + it("badges an error whose code is not a number", () => { + expect(renderDecisionBadge({ error: { code: null, message: "unreadable" } })).toBe("✖ ERROR ? unreadable"); + }); + it("emits ANSI only when colour is asked for", () => { - const plain = renderDecisionBadge(response({ decision: "deny" }), { color: false }); - const coloured = renderDecisionBadge(response({ decision: "deny" }), { color: true }); + const plain = badgeFor({ decision: "deny" }, { color: false }); + const coloured = badgeFor({ decision: "deny" }, { color: true }); expect(plain).toBe("● DENY"); expect(coloured).toContain("\u001b["); @@ -110,12 +162,12 @@ describe("renderDecisionBadge (U21)", () => { // reads as one unit; color:false stays byte-identical (asserted by the // exact-string tests above, which are unchanged). it("paints the appended reason_codes/policy_references segments dim when coloured", () => { - const coloured = renderDecisionBadge( - response({ + const coloured = badgeFor( + { decision: "deny", reason_codes: ["destructive_shell_command_blocked"], policy_references: [{ policy_id: "agt_stock", rule_id: "destructive_shell_command_blocked" }], - }), + }, { color: true }, ); diff --git a/test/envelope-tap-roundtrip.test.ts b/test/envelope-tap-roundtrip.test.ts index b96ec6fe..af340de8 100644 --- a/test/envelope-tap-roundtrip.test.ts +++ b/test/envelope-tap-roundtrip.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { startGuardian } from "../packages/guardian/src/index.ts"; import { tailEnvelopeLog, type EnvelopeLogEntry } from "../packages/inspector/src/tail-envelope-log.ts"; -import { renderDecisionBadge } from "../packages/inspector/src/render.ts"; +import { decisionMessageOf, renderDecisionBadge, type DecisionMessage } from "../packages/inspector/src/render.ts"; /** * The contract test for S6. The Guardian writes the log; the Inspector @@ -83,8 +83,14 @@ describe("S6 round trip: Guardian envelope log sink (N26) -> Inspector tail (N50 expect(request?.rpc_id).toBe(77); expect(response?.rpc_id).toBe(77); - // ...and the badge reads a real AGT-backed decision off it. - expect(renderDecisionBadge(response as EnvelopeLogEntry)).toContain("DENY"); + // ...and the badge reads a real AGT-backed decision off it. Two steps + // now, not one: decisionMessageOf turns the S6 line into U21's message + // and the badge renders that message (PR #11 review). Both halves are + // exercised here deliberately -- the round trip's claim is that a real + // Guardian's real output survives all the way to a rendered badge. + const message = decisionMessageOf(response as EnvelopeLogEntry); + expect(message).not.toBeNull(); + expect(renderDecisionBadge(message as DecisionMessage)).toContain("DENY"); } finally { controller.abort(); await guardian.close(); From 5014c02ec56444649ad60e414ea2edcbdf169b55 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Tue, 11 Aug 2026 12:20:48 +0300 Subject: [PATCH 28/35] Stop the badge teaching a disposition ACS does not have, and make R5.2 say so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #11 review, domain vocabulary. The badge for an allow that carried policy_references read `◐ ALLOW (policy fired — ACS "warn")`. ACS has no `warn` disposition -- the comment directly above the line said so -- so the label was teaching the policy runtime's vocabulary from an ACS-first tool, which is precisely the leak R5.2 exists to prevent. Same colour, same glyph, same trigger; the label is now `◐ ALLOW (policy fired)`, which is what ACS itself says happened. And the gate that should have caught it: R5.2's term list held the word "verdict" but none of the five verdicts, so it looked like coverage while covering nothing -- the badge shipped with a fully green suite. `warn`, `escalate` and `transform` are now on the list, with a comment saying why those three and why `allow`/`deny`/`ask`/`modify`/`defer` are deliberately absent: those five are ACS's own dispositions and the Inspector must name them. Mutation-tested after fixing the label, not before: restoring `— ACS "warn"` to the label fails the widened gate with `{file: "render.ts", term: "warn", found: true}` -- naming both the file and the term -- plus the badge test that now asserts the label carries no such word. Restored byte-identically (sha256 verified) and the suite is green again. This is the one badge-label assertion the fix necessarily updates; the exact-string test for it was rewritten and gained `expect(badge).not.toContain("warn")` so the claim is pinned at the call site as well as by the gate. Addresses: #11 review --- packages/inspector/src/render.ts | 13 ++++++++++--- packages/inspector/test/render.test.ts | 18 ++++++++++++------ test/invariants.test.ts | 14 ++++++++++++++ 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/packages/inspector/src/render.ts b/packages/inspector/src/render.ts index 80ef2fd3..b7d9b8f1 100644 --- a/packages/inspector/src/render.ts +++ b/packages/inspector/src/render.ts @@ -137,11 +137,18 @@ export function renderDecisionBadge(message: DecisionMessage, options: RenderOpt if (message.decision === "deny") { head = paint("● DENY", RED, color); } else if (message.decision === "allow" && references.length > 0) { - // ACS has no `warn`; a policy that fired but let the action proceed - // arrives as `allow` with a non-empty policy_references. Rendering it + // A policy fired and the action still proceeded. ACS carries that as + // `allow` with a non-empty `policy_references`, and rendering it // identically to a clean allow is exactly what this badge exists to // prevent (slices doc, section V2). - head = paint('◐ ALLOW (policy fired — ACS "warn")', YELLOW, color); + // + // The label used to name the policy runtime's own word for this case, + // which ACS does not have -- the comment above it said so in the same + // breath. R5.2 exists so this package carries no policy-runtime + // vocabulary at all, and a string on screen teaches it more effectively + // than an identifier would. What is left is what ACS itself says + // happened (PR #11 review). + head = paint("◐ ALLOW (policy fired)", YELLOW, color); } else if (message.decision === "allow") { head = paint("○ ALLOW", GREEN, color); } else { diff --git a/packages/inspector/test/render.test.ts b/packages/inspector/test/render.test.ts index 96017aeb..95554af8 100644 --- a/packages/inspector/test/render.test.ts +++ b/packages/inspector/test/render.test.ts @@ -99,10 +99,16 @@ describe("renderDecisionBadge (U21)", () => { expect(badgeFor({ decision: "allow" })).toBe("○ ALLOW"); }); - // The reason U21 exists, per the slices doc: an AGT `warn` arrives as an - // ACS `allow` with a non-empty policy_references, and the badge is what - // keeps it from being buried. - it("distinguishes an allow that carries policy_references -- ACS's encoding of warn", () => { + // The reason U21 exists, per the slices doc: a policy that fired and let + // the action proceed arrives as an ACS `allow` with a non-empty + // policy_references, and the badge is what keeps it from being buried. + // + // PR #11 review: the label used to end with the policy runtime's own name + // for that case, a disposition ACS does not have. R5.2 keeps this package + // clear of policy-runtime vocabulary, and rendered text teaches it more + // loudly than an identifier would -- so the label now says only what ACS + // says happened, and the last assertion holds the line. + it("distinguishes an allow that carries policy_references, without naming a disposition ACS lacks", () => { const badge = badgeFor({ decision: "allow", reason_codes: ["drift_detected"], @@ -110,10 +116,10 @@ describe("renderDecisionBadge (U21)", () => { }); expect(badge).toBe( - '◐ ALLOW (policy fired — ACS "warn") reason_codes=[drift_detected] ' + - "policy_references=[agt_stock#drift_detected]", + "◐ ALLOW (policy fired) reason_codes=[drift_detected] policy_references=[agt_stock#drift_detected]", ); expect(badge).not.toBe(badgeFor({ decision: "allow" })); + expect(badge).not.toContain("warn"); }); // Pins current behaviour (backlog item H): ACS's schemas do not require diff --git a/test/invariants.test.ts b/test/invariants.test.ts index 95b59e22..74b3d1cd 100644 --- a/test/invariants.test.ts +++ b/test/invariants.test.ts @@ -157,6 +157,20 @@ describe("architectural invariants", () => { "opa", "intervention_point", "verdict", + // The three AGT VERDICT NAMES, added by PR #11's review response and + // the reason it was worth adding them: this gate listed the word + // "verdict" but none of the verdicts, so the Inspector shipped a badge + // reading `ALLOW (policy fired -- ACS "warn")` with a green suite. ACS + // has no `warn` disposition; that string taught a reader AGT's + // vocabulary from an ACS-first tool, which is the exact leak R5.2 + // exists to prevent, and the gate said nothing. + // + // `allow`/`deny`/`ask`/`modify`/`defer` are deliberately NOT here -- + // they are ACS's own dispositions and the Inspector must name them. + // These three are AGT's alone. + "warn", + "escalate", + "transform", "claude", "opencode", "hookSpecificOutput", From a7140367dbc4ee0d0b554701101b70df8b61c52d Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Tue, 11 Aug 2026 12:23:46 +0300 Subject: [PATCH 29/35] Point N26's docs at names that exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The affordance tables are how a reader finds code from an ID, so a row naming a dead symbol is the same ghost-name defect as the finding that started this. N26's label was `writeEnvelopeTap()` -- a function that never existed under that name in any tree -- and it now reads `createEnvelopeLogSink()` → `sink.write()`, which is what the module exports. The affordance ID is also spelled out in envelope-tap.ts's own header, so the trail runs both ways. Shaping doc: N26's row first, then the mermaid node rendered from it, in that order -- the tables are the source of truth and the ripple hook says so. Slices doc: N26's row in §V2, and the "the tap must be total" watch-for, which named `writeEnvelopeTap` as the thing sitting on the decision path. slices/v2/README.md: the same two names, `writeEnvelopeTap` and `TapEntry`. v2-runbook.md: not a rename -- the badge label it quotes stopped being what the tool prints when `— ACS "warn"` came out, so the captured rendering is corrected and the reason recorded beside it. A runbook that quotes output the tool no longer produces is worse than one that says nothing. `docs/superpowers/plans/2026-08-09-v2-envelope-inspector.md` is left alone on purpose: a plan records what was planned, and rewriting it to match later code makes it a worse record. slice/v1 set that precedent for V1's plan (067fcbd) and this follows it. Addresses: #11 review --- docs/demos/v2-runbook.md | 14 ++++++++---- docs/shaping/acs-reference-impl-shaping.md | 4 ++-- docs/shaping/acs-reference-impl-slices.md | 6 ++--- slices/v2/README.md | 26 ++++++++++++---------- 4 files changed, 29 insertions(+), 21 deletions(-) diff --git a/docs/demos/v2-runbook.md b/docs/demos/v2-runbook.md index b87dc586..8244167e 100644 --- a/docs/demos/v2-runbook.md +++ b/docs/demos/v2-runbook.md @@ -224,10 +224,16 @@ Captured, from terminal 2, verbatim: A clean allow carries no `reasoning`, no `reason_codes`, and no `policy_references`, so the badge is bare. That absence is the signal: a policy that fired but let the action proceed arrives as `allow` with a **non-empty** `policy_references`, and the badge -renders that case differently — `◐ ALLOW (policy fired — ACS "warn")` — because ACS has -no `warn` disposition and rendering the two identically is exactly what U21 exists to -prevent. No `warn` case occurred in this run; the rendering above is the deny and the -clean allow, which are what the pinned configuration actually produced. +renders that case differently — `◐ ALLOW (policy fired)` — because rendering the two +identically is exactly what U21 exists to prevent. No policy-fired allow occurred in +this run; the rendering above is the deny and the clean allow, which are what the pinned +configuration actually produced. + +The label used to end `— ACS "warn"`, and it was retired by PR #11's review: ACS has no +`warn` disposition, so the badge was teaching one. The Inspector is an ACS-first reader +and R5.2 keeps it clear of policy-runtime vocabulary; a string on screen carries that +vocabulary further than an identifier does. The badge now says only what ACS says +happened — a policy fired, and the action was allowed. ## The honest boundary: a schema-invalid envelope is an error, not a deny diff --git a/docs/shaping/acs-reference-impl-shaping.md b/docs/shaping/acs-reference-impl-shaping.md index 92f6a0cd..b11c6bf8 100644 --- a/docs/shaping/acs-reference-impl-shaping.md +++ b/docs/shaping/acs-reference-impl-shaping.md @@ -262,7 +262,7 @@ All resolved — see `spike-agt-integration.md`. | N23 | P3 | guardian | `assembleSnapshot()` — envelope + session state → AGT snapshot | call | → N30 | — | | N24 | P3 | guardian | `mapVerdict()` — AGT verdict → ACS decision; `warn` → `allow` + `policy_references` | call | → N25, → N26 | → N4, → N13 | | N25 | P3 | guardian | `persistResultLabels()` — AGT `result_labels` into ACS lineage | call | → S5 | — | -| N26 | P3 | guardian | `writeEnvelopeTap()` — ⚠️ **total**: never throws, never alters a decision. Taps the request *before* validation | call | → S6 | — | +| N26 | P3 | guardian | `createEnvelopeLogSink()` → `sink.write()` — ⚠️ **total**: never throws, never alters a decision. Records the request *before* validation | call | → S6 | — | | N27 | P3 | guardian | `denyOnInvalidEnvelope()` — schema or bridge failure returns an explicit ACS `deny` **decision**, not a bare error, so the host honors it instead of falling back to posture | call | → N26 | → N4, → N13 | | N28 | P3 | guardian | `buildServerHello()` — ServerHello: `timeout_config`, `on_decision_failure`, `profiles_accepted` | call | → N26 | → N5, → N14 | | N30 | P3.1 | agt-bridge | `evaluateInterventionPoint(point, snapshot)` — Node SDK | call | — | → N24 | @@ -351,7 +351,7 @@ flowchart TB N23["N23: assembleSnapshot()"] N24["N24: mapVerdict()"] N25["N25: persistResultLabels()"] - N26["N26: writeEnvelopeTap()"] + N26["N26: createEnvelopeLogSink()"] N27["N27: denyOnInvalidEnvelope()"] N28["N28: buildServerHello()"] S3["S3: sessionContext chain"] diff --git a/docs/shaping/acs-reference-impl-slices.md b/docs/shaping/acs-reference-impl-slices.md index 88eb19f9..33219c2c 100644 --- a/docs/shaping/acs-reference-impl-slices.md +++ b/docs/shaping/acs-reference-impl-slices.md @@ -79,7 +79,7 @@ Every slice ends in something demo-able. |---|-------|-----------|------------|---------|-----------|------------| | U20 | P4 | inspector | envelope stream, request/response JSON pairs | render | — | — | | U21 | P4 | inspector | decision badge: decision + `policy_references` + `reason_codes` | render | — | — | -| N26 | P3 | guardian | `writeEnvelopeTap()` — **total**: never throws, never alters a decision | call | → S6 | — | +| N26 | P3 | guardian | `createEnvelopeLogSink()` → `sink.write()` — **total**: never throws, never alters a decision | call | → S6 | — | | N50 | P4 | inspector | `tailEnvelopeLog()` | observe | → U20, → U21 | — | | S6 | P3 | store | `envelope log`, JSONL at `.acs/envelopes.jsonl` (gitignored), one entry per direction | — | — | → N50 | @@ -95,7 +95,7 @@ Every slice ends in something demo-able. | P4 | Request/response pairing is by **JSON-RPC `id`**, carried as `rpc_id` on every entry. | The only identifier present in both directions. `params.request_id` exists on requests only. Pairing by arrival order breaks the moment two hooks are in flight. | | P5 | The request is tapped **before validation**. | An envelope that fails the schema is the most useful thing an ACS-first reader can see, and it is exactly what disappears if the tap sits behind the validator. R5.1 says *every* hook firing. | -**⚠️ Watch-for — the tap must be total.** `writeEnvelopeTap` sits on the decision path. V1 shipped three separate fail-opens before they were caught (the `./` bundle landmine, `tool_unknown` failing closed, and an unhandled Guardian throw reaching the shim as an empty stdout); an observability feature that can turn a governed tool call into an ungoverned one would be the fourth. Every write is wrapped: a failure disables the tap for the process lifetime, reports once, and never propagates. V2 asserts this end to end — `rm -rf /` is still denied when every tap write fails. +**⚠️ Watch-for — the envelope log sink must be total.** `sink.write()` sits on the decision path. V1 shipped three separate fail-opens before they were caught (the `./` bundle landmine, `tool_unknown` failing closed, and an unhandled Guardian throw reaching the shim as an empty stdout); an observability feature that can turn a governed tool call into an ungoverned one would be the fourth. Every write is wrapped: a failure disables the tap for the process lifetime, reports once, and never propagates. V2 asserts this end to end — `rm -rf /` is still denied when every tap write fails. **⚠️ Watch-for — S6 records the parsed envelope, unmodified.** No field stripping, no redaction, no reordering of anything we control; pretty-printing happens at render time only. An inspector that shows something other than what was sent is worse than none. The consequence is that S6 carries raw tool arguments, which is why `.acs/` is gitignored and why the runbook says so out loud. **Corrected by V2's whole-branch review:** this watch-for originally said "records the wire verbatim", and so did the plan's global constraint 11, the slice README, the runbook, and the Inspector's own renderer comment. The tap is handed `await req.json()`, so it stores a JSON *value*, not bytes — the parse collapses duplicate keys, canonicalises number literals, and hoists integer-like object keys, and `arguments` keys are host-controlled. Storing bytes instead would make `envelope` a string rather than JSON, costing the Inspector its pretty-printing and the round-trip contract test its subject. The wording was corrected everywhere rather than the code. @@ -105,7 +105,7 @@ Every slice ends in something demo-able. **Scope added at planning** (both amend this slice, both land in V2's PR): - An **invariant gate** on `packages/inspector/src`: zero AGT vocabulary, zero host vocabulary, and no import of `guardian` or `agt-bridge`. R5.2 is why this slice is early, and V1 established that this project turns architectural claims into grep gates rather than prose. Joins the R3.2/R3.3 gates in `test/invariants.test.ts`. -- A **tap↔tail contract test** (`test/envelope-tap-roundtrip.test.ts`). The Inspector declares its own `TapEntry` instead of importing the Guardian's — that is what makes the gate above meaningful — and the duplication is only safe while something fails when the two drift. +- A **write↔tail contract test** (`test/envelope-tap-roundtrip.test.ts`). The Inspector declares its own `EnvelopeLogEntry` instead of importing the Guardian's — that is what makes the gate above meaningful — and the duplication is only safe while something fails when the two drift. --- diff --git a/slices/v2/README.md b/slices/v2/README.md index 55ef35f5..ffefdcc1 100644 --- a/slices/v2/README.md +++ b/slices/v2/README.md @@ -8,7 +8,7 @@ ## What this slice delivers -The Guardian taps every ACS envelope crossing its wire into a JSONL log +The Guardian records every ACS envelope crossing its wire into a JSONL log (`packages/guardian/src/envelope-tap.ts` → `.acs/envelopes.jsonl`, S6/N26), and `bun run inspector` (`packages/inspector`) tails that log and renders each entry live: a header line, a decision badge for responses, then the envelope as pretty JSON @@ -19,18 +19,20 @@ unparseable body. Three properties make this worth more than a log viewer: -- **The tap is total by construction.** `writeEnvelopeTap` sits on the decision path. A - write failure disables the tap for the process lifetime, reports once, and never +- **The envelope log sink is total by construction.** N26 — `createEnvelopeLogSink`, and + the `write` method on the `EnvelopeLogSink` it returns — sits on the decision path. A + write failure disables the sink for the process lifetime, reports once, and never propagates — an observability feature must not be able to turn a governed tool call into an ungoverned one. `packages/guardian/test/envelope-tap-wiring.test.ts` asserts - exactly that end to end: *"still denies `rm -rf /` when every tap write fails"*. -- **The request is tapped before validation.** An envelope that fails the schema is the - most useful thing an ACS-first reader can see, and it is exactly what disappears if - the tap sits behind the validator. R5.1 says *every* hook firing. -- **The Inspector imports nothing from the Guardian.** It re-declares `TapEntry` rather - than importing it, so "inspectable on the wire" is a claim about the file rather than - about our own type graph — a third-party reader of S6 has only the file, and so does - this one. Two gates in [`test/invariants.test.ts`](../../test/invariants.test.ts) + exactly that end to end: *"still denies `rm -rf /` when every envelope-log write + fails"*. +- **The request is recorded before validation.** An envelope that fails the schema is + the most useful thing an ACS-first reader can see, and it is exactly what disappears + if the sink sits behind the validator. R5.1 says *every* hook firing. +- **The Inspector imports nothing from the Guardian.** It re-declares `EnvelopeLogEntry` + rather than importing it, so "inspectable on the wire" is a claim about the file rather + than about our own type graph — a third-party reader of S6 has only the file, and so + does this one. Two gates in [`test/invariants.test.ts`](../../test/invariants.test.ts) enforce it: zero AGT vocabulary and zero host vocabulary in `packages/inspector/src` (R5.2), and no import of `guardian` or `agt-bridge` (R5.1). The duplication is kept honest by the round-trip contract test, which exercises both @@ -56,7 +58,7 @@ Three properties make this worth more than a log viewer: stripping, no redaction, no reordering of anything we control — so it carries raw tool arguments. It is gitignored for that reason and is never committed. -The precision matters, and V2 first shipped this claim too strongly. The tap is handed +The precision matters, and V2 first shipped this claim too strongly. The sink is handed `await req.json()`, so it stores a JSON *value*, not the request's bytes: the parse has already collapsed duplicate keys, canonicalised number literals (`1.0` → `1`), and hoisted integer-like object keys ahead of the rest — and `arguments` keys are From c2d026b4684cfc9eeba8980892c43200fd692d74 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Tue, 11 Aug 2026 12:27:40 +0300 Subject: [PATCH 30/35] Stop N26's module doc describing docs it just corrected The rename commit wrote this comment when the affordance tables still said `writeEnvelopeTap()`; the docs commit two later fixed the tables and left the comment asserting the old state. A module doc that misdescribes the doc it points at is the same ghost the whole wave was closing, one indirection further out. Addresses: #11 review --- packages/guardian/src/envelope-tap.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/guardian/src/envelope-tap.ts b/packages/guardian/src/envelope-tap.ts index f7e9902c..7efca88b 100644 --- a/packages/guardian/src/envelope-tap.ts +++ b/packages/guardian/src/envelope-tap.ts @@ -14,12 +14,12 @@ * "sink". Naming this side for the log it produces is what makes the pair * read as a pair when the second rail lands. * - * The affordance tables in docs/shaping/ label N26 `writeEnvelopeTap()`. No - * such function has ever existed here -- the callable names are - * `createEnvelopeLogSink` and the `write` method on the `EnvelopeLogSink` it - * returns. The affordance ID is spelled out in this comment so a reader - * hunting N26 from the slices doc lands here rather than on a name that - * matches nothing. + * The affordance tables in docs/shaping/ used to label N26 + * `writeEnvelopeTap()`, a function that never existed here in any form; they + * now say `createEnvelopeLogSink()` -> `sink.write()`, which is what this + * module exports. The ID is spelled out in this comment so a reader hunting + * N26 from the slices doc lands here, and so that a future rename has to + * pass through both places at once. * * What "records the envelope" means here, precisely (global constraint 11, * as corrected by the whole-branch review's finding 2): the sink is handed From bc928dcfff05e2152e2cefa59f1e5536ffa5aba9 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Tue, 11 Aug 2026 14:05:03 +0300 Subject: [PATCH 31/35] Correct V2's test count for V1's added coverage V1 gained eight cases (isToolCallRequest, resolveInterventionPoint's table reads, the PolicyBridge role), so this number moved with them. Addresses: #10 review --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0a77ec40..ee4fd6a8 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ Second, through the real `claude` CLI with `.claude/settings.json` installed — ### Verify ```bash -bun test # 161 tests across 17 files (160 pass, 1 skip), including the R3.2/R3.3 +bun test # 169 tests across 17 files (168 pass, 1 skip), including the R3.2/R3.3 # and R5.1/R5.2 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 3a1bab4c857e43d3b98ebbbb25fc8beae5f372c2 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Wed, 12 Aug 2026 14:40:20 +0300 Subject: [PATCH 32/35] Finish V2's rename, and stop calling an error a decision The six second-pass residuals on this PR, which are two pieces of work: a rename that stopped at the type names, and a vocabulary that let U21 render the one thing this slice says it does not produce. ## The rename reached the types and not the filesystem The first wave renamed the artifacts (`EnvelopeLogSink`, `createEnvelopeLogSink`, `EnvelopeLogEntry`, `NULL_ENVELOPE_LOG_SINK`) and left the module path, the test filenames, the temp-directory prefixes and most of the surrounding prose saying "tap". Both vocabularies stayed live, which is the translation the rename existed to end -- and after a deliberate artifact rename the filesystem is the loudest remaining teacher. paths envelope-tap.ts -> envelope-log-sink.ts, and its three tests with it. The roundtrip test is now envelope-log-sink-roundtrip.test.ts, so it and audit-sink-roundtrip.test.ts read as the pair they are. temp dirs "acs-tap-*" -> "acs-envelope-log-*". prose README, both slice READMEs, the slices doc's scope note, P3, P5, both watch-fors and parked row 10, the shaping doc's D10 note, and the V2 runbook. One of those was not vocabulary but a false statement: the runbook told an operator to look for `envelope tap disabled after failure (): ` on stderr. The sink has emitted `envelope log sink disabled after failure (...)` since the rename -- so the one line whose whole job is to be grepped for was the one line that would not match. ## U21 badged errors as though they were decisions `DecisionMessage` was a union whose second arm was a JSON-RPC error, and both arms went through `renderDecisionBadge`. So the affordance whose job is making an ACS decision legible was also what rendered responses carrying no decision -- while this slice's own watch-for says a schema-invalid envelope "is answered with a JSON-RPC error", deliberately not a `deny`, because N27 is V3. A type and a function saying otherwise taught the thing V3 exists to change. OutcomeMessage the discriminated union of what an S6 response line reports: a decision, or the error that stood in for one. `outcomeMessageOf` builds it (was `decisionMessageOf`). DecisionMessage now only ever a decision, which is what its name says. renderDecisionBadge U21, decisions only. It can no longer be handed an error: the type has no arm for one. renderRpcError the error line, named as what it is. renderOutcome dispatches; what the stream renderer calls. The rendered output is unchanged, including the error line's exact text. Two smaller ones in the same area: U21's prose introduced the observe-only case as "how `warn` becomes visible", minting a disposition ACS does not have -- the same leak the badge string itself was fixed for in the first wave, one surface over. R5.2 keeps this package free of policy-runtime vocabulary, and a word in a doc teaches it as effectively as a symbol does. --path -> `--envelope-log`. A sibling audit stream lands in V3, and a generic `--path` would have been the flag for whichever stream came first, leaving the sibling to carry the qualifier. Each flag now rhymes with the env var that overrides the same thing. WHAT DELIBERATELY STILL SAYS "TAP": envelope-log-sink.ts's and tail-envelope-log.ts's own headers, where the old names are the record of what changed and why; docs/superpowers/plans/, which record what was planned (the same rule this branch applied to V1's handshake rename); and the shaping doc's A5/B5 option rows, which are the menu the session chose from, before the affordance existed to be named. 172 tests across 17 files (171 pass, 1 skip); typecheck clean. Addresses: #11 review --- README.md | 6 +- docs/demos/v2-runbook.md | 37 +++--- docs/shaping/acs-reference-impl-shaping.md | 2 +- docs/shaping/acs-reference-impl-slices.md | 18 +-- .../{envelope-tap.ts => envelope-log-sink.ts} | 0 packages/guardian/src/index.ts | 8 +- packages/guardian/src/server.ts | 6 +- ...st.ts => envelope-log-sink-wiring.test.ts} | 12 +- ...-tap.test.ts => envelope-log-sink.test.ts} | 4 +- packages/inspector/src/index.ts | 6 +- packages/inspector/src/main.ts | 15 ++- packages/inspector/src/render.ts | 123 +++++++++++++----- packages/inspector/src/tail-envelope-log.ts | 2 +- packages/inspector/test/render.test.ts | 51 +++++--- slices/v1/README.md | 2 +- slices/v2/README.md | 4 +- ...ts => envelope-log-sink-roundtrip.test.ts} | 17 +-- 17 files changed, 201 insertions(+), 112 deletions(-) rename packages/guardian/src/{envelope-tap.ts => envelope-log-sink.ts} (100%) rename packages/guardian/test/{envelope-tap-wiring.test.ts => envelope-log-sink-wiring.test.ts} (94%) rename packages/guardian/test/{envelope-tap.test.ts => envelope-log-sink.test.ts} (98%) rename test/{envelope-tap-roundtrip.test.ts => envelope-log-sink-roundtrip.test.ts} (83%) diff --git a/README.md b/README.md index ee4fd6a8..3793e486 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 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. V1 wires one host — Claude Code — to AGT's policy engine running unforked, its stock Rego bundle deciding, entirely over the ACS wire. V2 makes that wire visible: every envelope crossing it is tapped to a log and rendered live by `bun run inspector`. +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. V1 wires one host — Claude Code — to AGT's policy engine running unforked, its stock Rego bundle deciding, entirely over the ACS wire. V2 makes that wire visible: every envelope crossing it is recorded to a log and rendered live by `bun run inspector`. ## What this proves @@ -19,7 +19,7 @@ This repository shows the other shape. A host implements [ACS](https://github.co | Claim | How it is demonstrated | |---|---| -| R5.1 — every hook firing is inspectable as an ACS envelope, in both directions, including envelopes that fail validation | The Guardian taps every envelope crossing its wire to `.acs/envelopes.jsonl` before validation, and `bun run inspector` renders it live ([`test/envelope-tap-roundtrip.test.ts`](test/envelope-tap-roundtrip.test.ts), [`packages/guardian/test/envelope-tap-wiring.test.ts`](packages/guardian/test/envelope-tap-wiring.test.ts)) | +| R5.1 — every hook firing is inspectable as an ACS envelope, in both directions, including envelopes that fail validation | The Guardian records every envelope crossing its wire to `.acs/envelopes.jsonl` before validation, and `bun run inspector` renders it live ([`test/envelope-log-sink-roundtrip.test.ts`](test/envelope-log-sink-roundtrip.test.ts), [`packages/guardian/test/envelope-log-sink-wiring.test.ts`](packages/guardian/test/envelope-log-sink-wiring.test.ts)) | | R5.2 — an ACS-first reader can trace one action end to end without reading AGT source | The Inspector imports nothing from the Guardian or the AGT bridge and names neither AGT nor any host — enforced by two gates in [`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. @@ -137,7 +137,7 @@ bun run typecheck # whole-workspace strict TypeScript check, zero errors 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). -V2 ("Envelope Inspector") is implemented: the Guardian taps every ACS envelope crossing its wire to `.acs/envelopes.jsonl`, and `bun run inspector` tails and renders it live — see [`slices/v2/README.md`](slices/v2/README.md) and [`docs/demos/v2-runbook.md`](docs/demos/v2-runbook.md). One boundary worth stating up front: a schema-invalid envelope surfaces as a JSON-RPC **error**, not a `deny` decision. `N27 denyOnInvalidEnvelope()`, which turns Guardian-side failures into honoured ACS decisions, is V3. +V2 ("Envelope Inspector") is implemented: the Guardian records every ACS envelope crossing its wire to `.acs/envelopes.jsonl`, and `bun run inspector` tails and renders it live — see [`slices/v2/README.md`](slices/v2/README.md) and [`docs/demos/v2-runbook.md`](docs/demos/v2-runbook.md). One boundary worth stating up front: a schema-invalid envelope surfaces as a JSON-RPC **error**, not a `deny` decision. `N27 denyOnInvalidEnvelope()`, which turns Guardian-side failures into honoured ACS decisions, is V3. Four of this project's architectural claims are enforced by [`test/invariants.test.ts`](test/invariants.test.ts) rather than left to inspection: 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), and R5.1 and R5.2 (the Inspector imports nothing from the Guardian or the AGT bridge, and names neither AGT nor any host). diff --git a/docs/demos/v2-runbook.md b/docs/demos/v2-runbook.md index 8244167e..8b87eded 100644 --- a/docs/demos/v2-runbook.md +++ b/docs/demos/v2-runbook.md @@ -12,11 +12,14 @@ This runbook is written from a real run against this tree. Every block below mar 1. A third terminal, beside the Guardian and the agent host, prints every ACS envelope as it crosses the wire — request and response, both directions, one entry each. 2. The rendered JSON is the envelope the Guardian parsed, printed unmodified and - re-indented. The tap strips no field, redacts nothing, and reorders nothing it + re-indented. The sink strips no field, redacts nothing, and reorders nothing it controls. It is not a byte-for-byte replay — see *S6 carries raw tool arguments* - below for what the JSON parse normalises before the tap ever sees it. -3. The decision badge makes the outcome legible without reading the JSON: `● DENY`, - `○ ALLOW`, or `✖ ERROR`, with `reason_codes` and `policy_references` beside it. + below for what the JSON parse normalises before the sink ever sees it. +3. The decision badge makes the outcome legible without reading the JSON: `● DENY` or + `○ ALLOW`, with `reason_codes` and `policy_references` beside it. A response that + carried no decision renders as `✖ ERROR` on the same line — deliberately not a + decision badge, because in this slice a schema failure is an error and not a `deny` + (see *the honest boundary* below). 4. Nothing in the Inspector knows what produced the decision. It imports nothing from the Guardian and names neither AGT nor any host — enforced by two gates in [`test/invariants.test.ts`](../../test/invariants.test.ts), not left to inspection. @@ -50,9 +53,9 @@ Guardian listening at http://localhost:8787/acs Envelope log (S6): .acs/envelopes.jsonl ``` -**Why this one starts first: it creates `.acs/`.** The tap calls +**Why this one starts first: it creates `.acs/`.** The sink calls `mkdirSync(dirname(path), { recursive: true })` when the Guardian constructs it at boot -(`packages/guardian/src/envelope-tap.ts`), so the directory exists from the moment the +(`packages/guardian/src/envelope-log-sink.ts`), so the directory exists from the moment the Guardian is up. The log file itself does not appear until the first envelope is written — after the Guardian booted, `.acs/` existed and was empty. @@ -61,8 +64,8 @@ path), so starting them out of order does not break anything. Guardian-first is the order to teach, because it is the order in which the two artifacts come into existence. -The tap is opt-in at the library level and on by default in the CLI: `startGuardian` -taps only when `envelopeLogPath` is passed, and `packages/guardian/src/main.ts` passes +The sink is opt-in at the library level and on by default in the CLI: `startGuardian` +records only when `envelopeLogPath` is passed, and `packages/guardian/src/main.ts` passes it. Override the location with `ACS_ENVELOPE_LOG`. ### Terminal 2 — the Inspector @@ -79,7 +82,7 @@ Ctrl-C to stop. ``` It follows the log the way `tail -f` does: it starts at the current end and prints what -arrives from now on. `ACS_ENVELOPE_LOG` and `--path ` both point it elsewhere; the +arrives from now on. `ACS_ENVELOPE_LOG` and `--envelope-log ` both point it elsewhere; the default matches the Guardian's default, so neither hardcodes the other's value. Output is coloured when stdout is a TTY and `NO_COLOR` is unset. Every capture in this @@ -237,7 +240,7 @@ happened — a policy fired, and the action was allowed. ## The honest boundary: a schema-invalid envelope is an error, not a deny -The request is tapped **before** validation, so an envelope that fails the schema is +The request is recorded **before** validation, so an envelope that fails the schema is visible rather than swallowed. What comes back, though, is a JSON-RPC **error** — not an ACS `deny` decision. @@ -315,7 +318,7 @@ render time only. An inspector that showed something other than what was sent wo worse than no inspector at all. **One honest qualifier, added by the whole-branch review.** "Unmodified" is a claim about -what *we* do, not a claim of byte identity. The tap is handed `await req.json()`, so a +what *we* do, not a claim of byte identity. The sink is handed `await req.json()`, so a JSON parse has already happened: duplicate keys are collapsed to the last one, number literals are canonicalised (`1.0` renders as `1`, `1e2` as `100`), and integer-like object keys are hoisted ahead of the rest and sorted. Tool argument names come from the @@ -472,13 +475,13 @@ Inspector shows nothing at all during a live demo, check this before you check t `wc -c .acs/envelopes.jsonl`. - **The Inspector started after the entries were written.** It starts at the current end by default. Use `--from-start`. -- **The Guardian was constructed without a tap.** Only `packages/guardian/src/main.ts` - passes `envelopeLogPath`; a Guardian started in-process by a test does not tap unless +- **The Guardian was constructed without a sink.** Only `packages/guardian/src/main.ts` + passes `envelopeLogPath`; a Guardian started in-process by a test does not record unless it asks to. -- **The tap disabled itself.** A write failure disables the tap for the process lifetime - and reports once on stderr — `envelope tap disabled after failure (): `. - It never propagates and never alters a decision: the tap is total by construction, and - the end-to-end test asserts `rm -rf /` is still denied when every tap write fails. +- **The sink disabled itself.** A write failure disables the sink for the process lifetime + and reports once on stderr — `envelope log sink disabled after failure (): `. + It never propagates and never alters a decision: the sink is total by construction, and + the end-to-end test asserts `rm -rf /` is still denied when every sink write fails. ## Cleaning up diff --git a/docs/shaping/acs-reference-impl-shaping.md b/docs/shaping/acs-reference-impl-shaping.md index b11c6bf8..a995e34b 100644 --- a/docs/shaping/acs-reference-impl-shaping.md +++ b/docs/shaping/acs-reference-impl-shaping.md @@ -534,7 +534,7 @@ flowchart TB | ~~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 | -| ~~D10~~ | R5.3 — does this implementation claim the ACS **Trace** pillar? | ✅ **Decided: no, and V7 measures the non-claim** (N49 → U33). V7 does not build an exporter; per the evidence note below it could only live in the Guardian, which would be a slice of its own | `specification/v0.1.0/trace/otel-mapping.json` is normative: a deployment emitting OTel for the Trace pillar MUST use its span names and required attributes verbatim, and it maps `steps/toolCallRequest` → `gen_ai.tool.call` by name. `trace/ocsf-mapping.json` is its sibling. V2's envelope tap (S6) is a raw JSONL log, deliberately not an OTel or OCSF export, so today we claim neither pillar. R5.3 requires declaring that either way. Settled: the matrix records Trace as an explicit non-claim, and **two of its required span attributes have no wire source at all — evidence note directly below** | +| ~~D10~~ | R5.3 — does this implementation claim the ACS **Trace** pillar? | ✅ **Decided: no, and V7 measures the non-claim** (N49 → U33). V7 does not build an exporter; per the evidence note below it could only live in the Guardian, which would be a slice of its own | `specification/v0.1.0/trace/otel-mapping.json` is normative: a deployment emitting OTel for the Trace pillar MUST use its span names and required attributes verbatim, and it maps `steps/toolCallRequest` → `gen_ai.tool.call` by name. `trace/ocsf-mapping.json` is its sibling. V2's envelope log (S6) is a raw JSONL log, deliberately not an OTel or OCSF export, so today we claim neither pillar. R5.3 requires declaring that either way. Settled: the matrix records Trace as an explicit non-claim, and **two of its required span attributes have no wire source at all — evidence note directly below** | **⚠️ D10 evidence — the Trace pillar is not emittable from the v0.1.0 wire alone.** Read after V2 shipped, against the pinned schemas: diff --git a/docs/shaping/acs-reference-impl-slices.md b/docs/shaping/acs-reference-impl-slices.md index 33219c2c..b9743078 100644 --- a/docs/shaping/acs-reference-impl-slices.md +++ b/docs/shaping/acs-reference-impl-slices.md @@ -57,7 +57,7 @@ Every slice ends in something demo-able. | 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. +**Scope note.** Only `pre_tool_call` is wired. No session state, no envelope log, no second host. `N23` assembles the snapshot from the envelope alone; it starts reading S3/S4/S5 in V6. **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. @@ -83,7 +83,7 @@ Every slice ends in something demo-able. | N50 | P4 | inspector | `tailEnvelopeLog()` | observe | → U20, → U21 | — | | S6 | P3 | store | `envelope log`, JSONL at `.acs/envelopes.jsonl` (gitignored), one entry per direction | — | — | → N50 | -**Why this early.** R5.1 and R5.2 are must-haves, and an ACS-first reader needs to see envelopes before anything more elaborate is worth showing. U21 is also how `warn` becomes visible: a `warn` arrives as `allow` with a non-empty `policy_references`, and the badge is what makes that legible rather than buried. +**Why this early.** R5.1 and R5.2 are must-haves, and an ACS-first reader needs to see envelopes before anything more elaborate is worth showing. U21 is also what makes an observe-only outcome legible rather than buried: ACS carries it as `allow` with a non-empty `policy_references` — a policy fired and the action still proceeded — and the badge is what keeps that from rendering identically to a clean allow. The upstream disposition that maps to it is deliberately not named here: ACS has no such decision, and R5.2 exists so this package carries no policy-runtime vocabulary at all — in prose as much as in identifiers, since a word in a doc teaches it as effectively as a symbol does. **Decisions taken at planning.** §V2 left the Inspector's form open; these close it, and are recorded here rather than only in the plan. @@ -91,21 +91,21 @@ Every slice ends in something demo-able. |---|---|---| | P1 | The Inspector is a **terminal process** — `bun run inspector`, a third terminal beside `bun run guardian` and `claude`. | Zero new dependencies, works over SSH, matches the repo's one-process-per-command shape. R7.1/R7.2 ask for a laptop and no paid dependency; a browser UI would add a server, a bundler and an asset pipeline without proving anything further about the wire. A browser view later reads the same S6 file. | | P2 | S6 is a **file** — `.acs/envelopes.jsonl`, overridable with `ACS_ENVELOPE_LOG`. | The file is the seam that lets the Inspector import nothing from the Guardian. `jq` works on it unchanged. An in-process bus or a socket would couple P4 to P3. | -| P3 | The tap is **opt-in at the library level, on by default in the CLI**: `startGuardian` taps only when `envelopeLogPath` is passed; `packages/guardian/src/main.ts` passes it. | V1's tests construct Guardians constantly; a default-on tap would scatter files through the working tree. The demo path still gets the tap with nobody opting in. | +| P3 | The sink is **opt-in at the library level, on by default in the CLI**: `startGuardian` records only when `envelopeLogPath` is passed; `packages/guardian/src/main.ts` passes it. | V1's tests construct Guardians constantly; a default-on sink would scatter files through the working tree. The demo path still gets the log with nobody opting in. | | P4 | Request/response pairing is by **JSON-RPC `id`**, carried as `rpc_id` on every entry. | The only identifier present in both directions. `params.request_id` exists on requests only. Pairing by arrival order breaks the moment two hooks are in flight. | -| P5 | The request is tapped **before validation**. | An envelope that fails the schema is the most useful thing an ACS-first reader can see, and it is exactly what disappears if the tap sits behind the validator. R5.1 says *every* hook firing. | +| P5 | The request is recorded **before validation**. | An envelope that fails the schema is the most useful thing an ACS-first reader can see, and it is exactly what disappears if the sink sits behind the validator. R5.1 says *every* hook firing. | -**⚠️ Watch-for — the envelope log sink must be total.** `sink.write()` sits on the decision path. V1 shipped three separate fail-opens before they were caught (the `./` bundle landmine, `tool_unknown` failing closed, and an unhandled Guardian throw reaching the shim as an empty stdout); an observability feature that can turn a governed tool call into an ungoverned one would be the fourth. Every write is wrapped: a failure disables the tap for the process lifetime, reports once, and never propagates. V2 asserts this end to end — `rm -rf /` is still denied when every tap write fails. +**⚠️ Watch-for — the envelope log sink must be total.** `sink.write()` sits on the decision path. V1 shipped three separate fail-opens before they were caught (the `./` bundle landmine, `tool_unknown` failing closed, and an unhandled Guardian throw reaching the shim as an empty stdout); an observability feature that can turn a governed tool call into an ungoverned one would be the fourth. Every write is wrapped: a failure disables the sink for the process lifetime, reports once, and never propagates. V2 asserts this end to end — `rm -rf /` is still denied when every sink write fails. -**⚠️ Watch-for — S6 records the parsed envelope, unmodified.** No field stripping, no redaction, no reordering of anything we control; pretty-printing happens at render time only. An inspector that shows something other than what was sent is worse than none. The consequence is that S6 carries raw tool arguments, which is why `.acs/` is gitignored and why the runbook says so out loud. **Corrected by V2's whole-branch review:** this watch-for originally said "records the wire verbatim", and so did the plan's global constraint 11, the slice README, the runbook, and the Inspector's own renderer comment. The tap is handed `await req.json()`, so it stores a JSON *value*, not bytes — the parse collapses duplicate keys, canonicalises number literals, and hoists integer-like object keys, and `arguments` keys are host-controlled. Storing bytes instead would make `envelope` a string rather than JSON, costing the Inspector its pretty-printing and the round-trip contract test its subject. The wording was corrected everywhere rather than the code. +**⚠️ Watch-for — S6 records the parsed envelope, unmodified.** No field stripping, no redaction, no reordering of anything we control; pretty-printing happens at render time only. An inspector that shows something other than what was sent is worse than none. The consequence is that S6 carries raw tool arguments, which is why `.acs/` is gitignored and why the runbook says so out loud. **Corrected by V2's whole-branch review:** this watch-for originally said "records the wire verbatim", and so did the plan's global constraint 11, the slice README, the runbook, and the Inspector's own renderer comment. The sink is handed `await req.json()`, so it stores a JSON *value*, not bytes — the parse collapses duplicate keys, canonicalises number literals, and hoists integer-like object keys, and `arguments` keys are host-controlled. Storing bytes instead would make `envelope` a string rather than JSON, costing the Inspector its pretty-printing and the round-trip contract test its subject. The wording was corrected everywhere rather than the code. -**⚠️ Watch-for — a schema failure appears as an error, not a decision.** In V2 an invalid envelope is tapped (P5) and then answered with a JSON-RPC error, so the Inspector renders `✖ ERROR -32010`, not a badge. `N27 denyOnInvalidEnvelope()` — the affordance that turns Guardian-side failures into honoured ACS `deny` **decisions** — is V3. The Inspector is where that change will become visible. +**⚠️ Watch-for — a schema failure appears as an error, not a decision.** In V2 an invalid envelope is recorded (P5) and then answered with a JSON-RPC error, so the Inspector renders `✖ ERROR -32010`, not a badge. `N27 denyOnInvalidEnvelope()` — the affordance that turns Guardian-side failures into honoured ACS `deny` **decisions** — is V3. The Inspector is where that change will become visible. **Unpaired responses are real.** A body that will not parse as JSON produces a response with no preceding request and `rpc_id: null`. The Inspector renders it as `(no method) (unpaired)` rather than hiding it. **Scope added at planning** (both amend this slice, both land in V2's PR): - An **invariant gate** on `packages/inspector/src`: zero AGT vocabulary, zero host vocabulary, and no import of `guardian` or `agt-bridge`. R5.2 is why this slice is early, and V1 established that this project turns architectural claims into grep gates rather than prose. Joins the R3.2/R3.3 gates in `test/invariants.test.ts`. -- A **write↔tail contract test** (`test/envelope-tap-roundtrip.test.ts`). The Inspector declares its own `EnvelopeLogEntry` instead of importing the Guardian's — that is what makes the gate above meaningful — and the duplication is only safe while something fails when the two drift. +- A **write↔tail contract test** (`test/envelope-log-sink-roundtrip.test.ts`). The Inspector declares its own `EnvelopeLogEntry` instead of importing the Guardian's — that is what makes the gate above meaningful — and the duplication is only safe while something fails when the two drift. --- @@ -265,7 +265,7 @@ Runs on a schedule in CI. MS-ACS is `0.3.1-beta` and warns of breaking changes b | 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 | | 9 | ⚠️ S6 grows unbounded — no rotation and no size cap | V2 | Accepted. It is a gitignored local demo artifact; `: > .acs/envelopes.jsonl` truncates it safely mid-run because `tailEnvelopeLog` resets on truncation. Rotation is not built, and the runbook says so | -| 10 | ⚠️ The tap's two synchronous `appendFileSync` calls per request sit **on the decision path**, and `Bun.serve` is single-threaded | V2 | Accepted, and correct for demo scale. Surfaced by V2's whole-branch review as the neighbour of row 9: a slow filesystem (a stalled network mount, a full disk) blocks *every* in-flight request, not only the one being tapped, because there is no second thread to run them on. No correctness risk — the tap is total, so a write that fails degrades observability and never a decision (constraint 8) — and no latency budget is claimed for it. Recorded rather than fixed; an async or queued tap is the change if a deployment ever needs one | +| 10 | ⚠️ The sink's two synchronous `appendFileSync` calls per request sit **on the decision path**, and `Bun.serve` is single-threaded | V2 | Accepted, and correct for demo scale. Surfaced by V2's whole-branch review as the neighbour of row 9: a slow filesystem (a stalled network mount, a full disk) blocks *every* in-flight request, not only the one being recorded, because there is no second thread to run them on. No correctness risk — the sink is total, so a write that fails degrades observability and never a decision (constraint 8) — and no latency budget is claimed for it. Recorded rather than fixed; an async or queued sink is the change if a deployment ever needs one | ## Open decisions carried from shaping diff --git a/packages/guardian/src/envelope-tap.ts b/packages/guardian/src/envelope-log-sink.ts similarity index 100% rename from packages/guardian/src/envelope-tap.ts rename to packages/guardian/src/envelope-log-sink.ts diff --git a/packages/guardian/src/index.ts b/packages/guardian/src/index.ts index e208b263..79592e27 100644 --- a/packages/guardian/src/index.ts +++ b/packages/guardian/src/index.ts @@ -2,7 +2,7 @@ * Public surface of the guardian package: the governance verbs, and nothing * else. * - * S6's writer (N26, ./envelope-tap.ts) is deliberately NOT re-exported. + * S6's writer (N26, ./envelope-log-sink.ts) is deliberately NOT re-exported. * Observability is an internal detail of running a Guardian, not part of the * vocabulary a consumer of this package speaks -- and a barrel that mixes * `startGuardian` / `mapVerdict` with `createEnvelopeLogSink` / @@ -15,15 +15,15 @@ * Nothing outside this package imported those names, so this removes a * public surface rather than a dependency: `./server.ts` imports the sink * directly, and the two test files that exercise N26 on its own - * (`packages/guardian/test/envelope-tap*.test.ts`) import - * `../src/envelope-tap.ts` directly, which is the arrangement they already + * (`packages/guardian/test/envelope-log-sink*.test.ts`) import + * `../src/envelope-log-sink.ts` directly, which is the arrangement they already * used. If an external writer of S6 ever becomes a real use case, the * honest answer is a narrow subpath export, not putting it back here. * * The Inspector is unaffected either way -- it imports NOTHING from this * package (R5.1), reads S6 as a file, and re-declares `EnvelopeLogEntry` * itself; `test/invariants.test.ts` gates that and - * `test/envelope-tap-roundtrip.test.ts` keeps the two declarations honest. + * `test/envelope-log-sink-roundtrip.test.ts` keeps the two declarations honest. */ export { startGuardian, type StartGuardianOptions, type StartedGuardian } from "./server.ts"; export { buildServerHello, type ServerHello } from "./handshake.ts"; diff --git a/packages/guardian/src/server.ts b/packages/guardian/src/server.ts index 5da69082..2a5107a7 100644 --- a/packages/guardian/src/server.ts +++ b/packages/guardian/src/server.ts @@ -58,7 +58,7 @@ import { type AcsRequestEnvelope, } from "./validate-envelope.ts"; import { buildServerHello, type ServerHello } from "./handshake.ts"; -import { createEnvelopeLogSink, NULL_ENVELOPE_LOG_SINK, type EnvelopeLogSink } from "./envelope-tap.ts"; +import { createEnvelopeLogSink, NULL_ENVELOPE_LOG_SINK, type EnvelopeLogSink } from "./envelope-log-sink.ts"; /** * Every snapshot message this Guardian can send an intervention point. One @@ -124,7 +124,7 @@ const REPO_ROOT_PATTERN = new RegExp(`${REPO_ROOT.replace(/[.*+?^${}()|[\]\\]/g, * (evaluating 'message.replace')` on exactly such an Error -- reachable * only from the outer catch, since the inner catch's own throw is itself * caught by the outer one, but reachable there with nothing above - * `handleAcsRequest` to catch it: the untapped HTML-500 fail-open this + * `handleAcsRequest` to catch it: the unrecorded HTML-500 fail-open this * module's header exists to prevent. `String()` on the whole caught value * keeps this helper total for any `unknown`, matching what a catch clause * can actually hand it. @@ -261,7 +261,7 @@ export async function startGuardian({ * below closes it -- every route out of `dispatch` now produces a response * object, and every response object reaches the envelope log. * - * The sink itself is total (see envelope-tap.ts): these two calls cannot + * The sink itself is total (see envelope-log-sink.ts): these two calls cannot * throw, so they cannot turn a governed tool call into an ungoverned one. */ async function handleAcsRequest( diff --git a/packages/guardian/test/envelope-tap-wiring.test.ts b/packages/guardian/test/envelope-log-sink-wiring.test.ts similarity index 94% rename from packages/guardian/test/envelope-tap-wiring.test.ts rename to packages/guardian/test/envelope-log-sink-wiring.test.ts index bdb57dc6..2ed7fb05 100644 --- a/packages/guardian/test/envelope-tap-wiring.test.ts +++ b/packages/guardian/test/envelope-log-sink-wiring.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFile import { tmpdir } from "node:os"; import { join } from "node:path"; import { startGuardian } from "../src/index.ts"; -import type { EnvelopeLogEntry } from "../src/envelope-tap.ts"; +import type { EnvelopeLogEntry } from "../src/envelope-log-sink.ts"; function makeEnvelope( method: string, @@ -43,12 +43,12 @@ function readEntries(path: string): EnvelopeLogEntry[] { .map((line) => JSON.parse(line) as EnvelopeLogEntry); } -/** Non-recursive cleanup, as in envelope-tap.test.ts. */ +/** Non-recursive cleanup, as in envelope-log-sink.test.ts. */ async function withGuardian( logPathFor: (dir: string) => string, run: (url: string, logPath: string) => Promise, ): Promise { - const dir = mkdtempSync(join(tmpdir(), "acs-tap-wiring-")); + const dir = mkdtempSync(join(tmpdir(), "acs-envelope-log-wiring-")); const logPath = logPathFor(dir); const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", envelopeLogPath: logPath }); try { @@ -140,13 +140,13 @@ describe("Guardian envelope log wiring (N26 x N20)", () => { // // No `onError` is passed here, so this exercises the sink's *default* // reporter -- a single `console.error` line -- rather than the - // onError-captured path envelope-tap.test.ts's "reports once, then goes + // onError-captured path envelope-log-sink.test.ts's "reports once, then goes // quiet" test covers. Spied and silenced so a deliberately-broken sink // does not print real stderr into a clean `bun test` run, and asserted // on so "reports once" is checked at the call site instead of merely // claimed. it("still denies rm -rf / when every envelope-log write fails", async () => { - const dir = mkdtempSync(join(tmpdir(), "acs-tap-broken-")); + const dir = mkdtempSync(join(tmpdir(), "acs-envelope-log-broken-")); const blocker = join(dir, "blocker"); writeFileSync(blocker, ""); const errorSpy = spyOn(console, "error").mockImplementation(() => {}); @@ -176,7 +176,7 @@ describe("Guardian envelope log wiring (N26 x N20)", () => { // Decision P3. it("writes nothing when envelopeLogPath is omitted", async () => { - const dir = mkdtempSync(join(tmpdir(), "acs-tap-off-")); + const dir = mkdtempSync(join(tmpdir(), "acs-envelope-log-off-")); const logPath = join(dir, "envelopes.jsonl"); const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); try { diff --git a/packages/guardian/test/envelope-tap.test.ts b/packages/guardian/test/envelope-log-sink.test.ts similarity index 98% rename from packages/guardian/test/envelope-tap.test.ts rename to packages/guardian/test/envelope-log-sink.test.ts index fdc531e1..266714cb 100644 --- a/packages/guardian/test/envelope-tap.test.ts +++ b/packages/guardian/test/envelope-log-sink.test.ts @@ -7,13 +7,13 @@ import { extractRpcId, NULL_ENVELOPE_LOG_SINK, type EnvelopeLogEntry, -} from "../src/envelope-tap.ts"; +} from "../src/envelope-log-sink.ts"; /** A temp directory per test. Cleanup is deliberately non-recursive -- * unlink the one file we created, then rmdir -- so a stray file makes the * test fail loudly instead of being silently blown away. */ function withTempDir(run: (dir: string) => void): void { - const dir = mkdtempSync(join(tmpdir(), "acs-tap-")); + const dir = mkdtempSync(join(tmpdir(), "acs-envelope-log-")); try { run(dir); } finally { diff --git a/packages/inspector/src/index.ts b/packages/inspector/src/index.ts index 5ab26656..fd74c909 100644 --- a/packages/inspector/src/index.ts +++ b/packages/inspector/src/index.ts @@ -6,10 +6,14 @@ export { type TailEnvelopeLogOptions, } from "./tail-envelope-log.ts"; export { - decisionMessageOf, + outcomeMessageOf, renderDecisionBadge, renderEnvelopeLogEntry, + renderOutcome, + renderRpcError, type DecisionMessage, + type OutcomeMessage, type PolicyReference, type RenderOptions, + type RpcErrorMessage, } from "./render.ts"; diff --git a/packages/inspector/src/main.ts b/packages/inspector/src/main.ts index 7867e76f..371ac4b8 100644 --- a/packages/inspector/src/main.ts +++ b/packages/inspector/src/main.ts @@ -16,11 +16,18 @@ const DEFAULT_ENVELOPE_LOG = ".acs/envelopes.jsonl"; const argv = process.argv.slice(2); const fromStart = argv.includes("--from-start"); -const pathFlag = argv.indexOf("--path"); -const flagValue = pathFlag === -1 ? undefined : argv[pathFlag + 1]; +// `--envelope-log`, not `--path` (PR #11 review, second pass): this stream is +// the envelope log, and a later slice tails a second one beside it. A generic +// `--path` would have been the flag for whichever stream happened to come +// first, leaving the sibling to carry the qualifier -- the same asymmetry the +// `EnvelopeLogEntry` / `AuditEntry` rename closed one layer down. Named for its +// artifact now, so the pair reads as a pair when the second lands, and so each +// flag rhymes with the env var that overrides the same thing. +const envelopeLogFlag = argv.indexOf("--envelope-log"); +const flagValue = envelopeLogFlag === -1 ? undefined : argv[envelopeLogFlag + 1]; -if (pathFlag !== -1 && (flagValue === undefined || flagValue.startsWith("--"))) { - console.error("usage: bun run inspector -- [--from-start] [--path ]"); +if (envelopeLogFlag !== -1 && (flagValue === undefined || flagValue.startsWith("--"))) { + console.error("usage: bun run inspector -- [--from-start] [--envelope-log ]"); process.exit(2); } diff --git a/packages/inspector/src/render.ts b/packages/inspector/src/render.ts index b7d9b8f1..6abd2b2b 100644 --- a/packages/inspector/src/render.ts +++ b/packages/inspector/src/render.ts @@ -12,9 +12,26 @@ * `entry.envelope.result.decision` out of it, which made "render this * decision" read as "ask this log line what it contains" and tied U21 to the * one artifact that happens to carry a decision today. The digging now lives - * in `decisionMessageOf`, one named translation from an S6 line to the small - * `DecisionMessage` the badge actually needs; anything else able to build - * that message can use the badge without owning an envelope log. + * in `outcomeMessageOf`, one named translation from an S6 line to the small + * message the renderers actually need; anything else able to build that + * message can use them without owning an envelope log. + * + * A DECISION AND AN ERROR ARE TWO OUTCOMES, NOT TWO DECISIONS (PR #11 review, + * second pass). The message used to be one type called `DecisionMessage` whose + * second arm was a JSON-RPC error, and both arms went through + * `renderDecisionBadge` -- so U21, the affordance whose whole job is making an + * ACS decision legible, was also the thing that rendered responses carrying no + * decision at all. The slices doc's own watch-for says the opposite in as many + * words: a schema-invalid envelope "is answered with a JSON-RPC error", and + * that is deliberately not a decision, because turning Guardian-side failures + * into honoured ACS denies is N27 and belongs to a later slice. A type and a + * function that said otherwise taught the reader the thing that slice exists to + * correct. + * + * So the union is `OutcomeMessage`, discriminated, and each arm has its own + * renderer: `renderDecisionBadge` (U21, decisions only) and `renderRpcError` + * (a response that carried no decision). `renderOutcome` dispatches, and is + * what the stream renderer calls. */ import type { EnvelopeLogEntry } from "./tail-envelope-log.ts"; @@ -30,18 +47,37 @@ const DIM = "\u001b[2m"; export type PolicyReference = { policy_id?: string; policy_version?: string; rule_id?: string }; /** - * U21's message: what a caller tells the badge, already narrowed to the ACS - * fields it renders. + * U21's message: what a caller tells the decision badge, already narrowed to + * the ACS fields it renders. Only ever an actual ACS decision -- see + * `OutcomeMessage` for why that is now the type's whole content. + */ +export type DecisionMessage = { + decision: string; + reason_codes: string[]; + policy_references: PolicyReference[]; +}; + +/** What a response carried instead of a decision: the JSON-RPC error object. */ +export type RpcErrorMessage = { code: number | null; message: string }; + +/** + * What one S6 response line reports about its step: a decision, or the + * JSON-RPC error that stood in place of one. * - * A union rather than one object with an optional `error` beside a - * `decision`, because a response carries exactly one of them and the other - * arm's fields would have to be invented. A `steps/*` response either names - * an ACS `decision` or is a JSON-RPC error; a caller cannot hand the badge - * both, and cannot hand it neither. + * A discriminated union rather than one object with an optional `error` beside + * a `decision`, because a response carries exactly one of them and the other + * arm's fields would have to be invented. A `steps/*` response either names an + * ACS `decision` or is a JSON-RPC error; a caller cannot report both, and + * cannot report neither. + * + * `kind` rather than structural narrowing on a key's presence, so the two arms + * stay tellable apart by a caller that does not already know which fields + * belong to which -- and so the union reads as two outcomes rather than as a + * decision with an error mode. */ -export type DecisionMessage = - | { decision: string; reason_codes: string[]; policy_references: PolicyReference[] } - | { error: { code: number | null; message: string } }; +export type OutcomeMessage = + | ({ kind: "decision" } & DecisionMessage) + | ({ kind: "error" } & RpcErrorMessage); type DecisionResult = { decision?: unknown; @@ -85,15 +121,15 @@ function formatReferences(references: PolicyReference[]): string[] { } /** - * The badge's message for one S6 line, or null when that line has no outcome - * to badge: a request, or a response such as a ServerHello. + * What one S6 line reports, or null when it reports no outcome at all: a + * request, or a response such as a ServerHello. * * The one place in this module that reads an envelope's shape. It is a - * translation, not a collaboration -- it turns an artifact into the message - * U21 speaks -- and confining it here is what lets `renderDecisionBadge` be - * told a decision instead of interrogating a log row for one. + * translation, not a collaboration -- it turns an artifact into the message the + * renderers speak -- and confining it here is what lets `renderDecisionBadge` + * be told a decision instead of interrogating a log row for one. */ -export function decisionMessageOf(entry: EnvelopeLogEntry): DecisionMessage | null { +export function outcomeMessageOf(entry: EnvelopeLogEntry): OutcomeMessage | null { if (entry.direction !== "response") { return null; } @@ -101,10 +137,9 @@ export function decisionMessageOf(entry: EnvelopeLogEntry): DecisionMessage | nu if (envelope.error) { return { - error: { - code: typeof envelope.error.code === "number" ? envelope.error.code : null, - message: typeof envelope.error.message === "string" ? envelope.error.message : "", - }, + kind: "error", + code: typeof envelope.error.code === "number" ? envelope.error.code : null, + message: typeof envelope.error.message === "string" ? envelope.error.message : "", }; } @@ -114,21 +149,34 @@ export function decisionMessageOf(entry: EnvelopeLogEntry): DecisionMessage | nu } return { + kind: "decision", decision: result.decision, reason_codes: stringList(result.reason_codes), policy_references: policyReferenceList(result.policy_references), }; } -/** U21. Renders the decision (or the error) it is given. */ -export function renderDecisionBadge(message: DecisionMessage, options: RenderOptions = {}): string { +/** + * A response that carried no decision, rendered as the error it was. + * + * Its own function rather than an arm of the decision badge (PR #11 review, + * second pass): what it renders is the absence of a decision, and in this slice + * that is exactly the boundary the runbook and the slices doc both draw -- + * a schema-invalid envelope comes back as a JSON-RPC error, and N27, which + * turns Guardian-side failures into honoured ACS denies, is a later slice. The + * line looks like the badge beside it on purpose; what changed is that no type + * and no function calls it a decision any more. + */ +export function renderRpcError(message: RpcErrorMessage, options: RenderOptions = {}): string { const color = options.color ?? false; + const code = message.code ?? "?"; + const text = message.message; + return paint(`✖ ERROR ${code}${text ? ` ${text}` : ""}`, RED, color); +} - if ("error" in message) { - const code = message.error.code ?? "?"; - const text = message.error.message; - return paint(`✖ ERROR ${code}${text ? ` ${text}` : ""}`, RED, color); - } +/** U21. Renders the ACS decision it is given. */ +export function renderDecisionBadge(message: DecisionMessage, options: RenderOptions = {}): string { + const color = options.color ?? false; const reasonCodes = message.reason_codes; const references = formatReferences(message.policy_references); @@ -169,13 +217,20 @@ export function renderDecisionBadge(message: DecisionMessage, options: RenderOpt return parts.join(" "); } +/** The line one S6 outcome renders as -- U21's badge for a decision, the error + * line for a response that carried none. One dispatch, so the stream renderer + * does not have to know the arms apart. */ +export function renderOutcome(message: OutcomeMessage, options: RenderOptions = {}): string { + return message.kind === "decision" ? renderDecisionBadge(message, options) : renderRpcError(message, options); +} + /** * U20. Header line, optional badge line, then the envelope as pretty JSON. * * What the body shows is the JSON value S6 recorded, printed unmodified: * nothing here strips a field, redacts a value, or reorders anything. It is * not a byte-for-byte replay of the wire, and this comment used to say it - * was (whole-branch review, finding 2). The Guardian taps `await req.json()`, + * was (whole-branch review, finding 2). The Guardian records `await req.json()`, * so the parse has already collapsed duplicate keys, canonicalised number * literals (`1.0` -> `1`), and hoisted integer-like object keys ahead of the * rest -- and tool argument names are host-controlled, so `arguments` really @@ -191,8 +246,8 @@ export function renderEnvelopeLogEntry(entry: EnvelopeLogEntry, options: RenderO const id = entry.rpc_id === null ? "(unpaired)" : `id=${entry.rpc_id}`; const header = paint(`── #${entry.seq} ${clockOf(entry.recorded_at)} ${arrow} ${method} ${id}`, DIM, color); - const message = decisionMessageOf(entry); - const badge = message === null ? null : renderDecisionBadge(message, options); + const message = outcomeMessageOf(entry); + const outcome = message === null ? null : renderOutcome(message, options); // `JSON.stringify` returns `undefined` -- not a string -- for an entry // whose `envelope` key is absent, and `join` would coerce that to an empty // line indistinguishable from a real blank body. `isEnvelopeLogEntryShape` @@ -201,5 +256,5 @@ export function renderEnvelopeLogEntry(entry: EnvelopeLogEntry, options: RenderO // path above narrows (whole-branch review, finding 8). const body = JSON.stringify(entry.envelope, null, options.indent ?? 2) ?? "(no envelope recorded)"; - return [header, ...(badge === null ? [] : [badge]), body].join("\n"); + return [header, ...(outcome === null ? [] : [outcome]), body].join("\n"); } diff --git a/packages/inspector/src/tail-envelope-log.ts b/packages/inspector/src/tail-envelope-log.ts index dc4c12f3..77b1eeb4 100644 --- a/packages/inspector/src/tail-envelope-log.ts +++ b/packages/inspector/src/tail-envelope-log.ts @@ -8,7 +8,7 @@ * process that produced it, which is the point of R5.1 -- envelopes are * inspectable *on the wire*, not through our own type graph. EnvelopeLogEntry * is therefore re-declared here rather than imported. The round-trip test at - * test/envelope-tap-roundtrip.test.ts is what keeps the two declarations in + * test/envelope-log-sink-roundtrip.test.ts is what keeps the two declarations in * agreement; if they drift, it fails. * * The names are the artifact's, not the writer's (PR #11 review). This diff --git a/packages/inspector/test/render.test.ts b/packages/inspector/test/render.test.ts index 95554af8..63f84990 100644 --- a/packages/inspector/test/render.test.ts +++ b/packages/inspector/test/render.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "bun:test"; import { - decisionMessageOf, + outcomeMessageOf, renderDecisionBadge, renderEnvelopeLogEntry, - type DecisionMessage, + renderOutcome, + renderRpcError, + type OutcomeMessage, type RenderOptions, } from "../src/render.ts"; import type { EnvelopeLogEntry } from "../src/tail-envelope-log.ts"; @@ -28,8 +30,8 @@ function response(result: Record): EnvelopeLogEntry { * badge tests that assert the rendered string end to end. Throws rather than * asserting non-null inline, so a line that stopped carrying an outcome fails * as itself instead of as a confusing `toBe` diff. */ -function messageOf(line: EnvelopeLogEntry): DecisionMessage { - const message = decisionMessageOf(line); +function messageOf(line: EnvelopeLogEntry): OutcomeMessage { + const message = outcomeMessageOf(line); if (message === null) { throw new Error("expected this entry to carry a decision or an error"); } @@ -37,23 +39,23 @@ function messageOf(line: EnvelopeLogEntry): DecisionMessage { } function badgeFor(result: Record, options?: RenderOptions): string { - return renderDecisionBadge(messageOf(response(result)), options); + return renderOutcome(messageOf(response(result)), options); } -describe("decisionMessageOf -- what U21 is told about", () => { +describe("outcomeMessageOf -- what the renderers are told about", () => { it("has nothing to say about a request", () => { - expect(decisionMessageOf(entry({ direction: "request", envelope: { jsonrpc: "2.0", id: 1 } }))).toBeNull(); + expect(outcomeMessageOf(entry({ direction: "request", envelope: { jsonrpc: "2.0", id: 1 } }))).toBeNull(); }); it("has nothing to say about a response with no decision -- a ServerHello", () => { - expect(decisionMessageOf(response({ negotiated_version: "0.1.0", on_decision_failure: "proceed" }))).toBeNull(); + expect(outcomeMessageOf(response({ negotiated_version: "0.1.0", on_decision_failure: "proceed" }))).toBeNull(); }); // The reshape's point (PR #11 review): the badge is handed ACS fields, not // a log row to dig through, so everything it renders is decided here. it("narrows the ACS fields the badge renders, and drops the rest of the envelope", () => { expect( - decisionMessageOf( + outcomeMessageOf( response({ decision: "deny", reason_codes: ["blocked", 7], @@ -62,6 +64,7 @@ describe("decisionMessageOf -- what U21 is told about", () => { }), ), ).toEqual({ + kind: "decision", decision: "deny", reason_codes: ["blocked"], policy_references: [{ policy_id: "agt_stock", rule_id: "blocked" }], @@ -69,10 +72,22 @@ describe("decisionMessageOf -- what U21 is told about", () => { }); it("reports a JSON-RPC error as an error message, with a null code when it is not a number", () => { - expect(decisionMessageOf(entry({ envelope: { jsonrpc: "2.0", id: 1, error: { code: "nope" } } }))).toEqual({ - error: { code: null, message: "" }, + expect(outcomeMessageOf(entry({ envelope: { jsonrpc: "2.0", id: 1, error: { code: "nope" } } }))).toEqual({ + kind: "error", + code: null, + message: "", }); }); + + // PR #11 review, second pass. The discriminant is the point: a caller can + // tell an outcome that IS a decision from one that stood in for the absence + // of one without inspecting which fields happen to be present. + it("discriminates a decision from an error, so nothing has to infer which arm it holds", () => { + expect(outcomeMessageOf(response({ decision: "allow" }))?.kind).toBe("decision"); + expect(outcomeMessageOf(entry({ envelope: { jsonrpc: "2.0", id: 1, error: { code: -32010 } } }))?.kind).toBe( + "error", + ); + }); }); describe("renderDecisionBadge (U21)", () => { @@ -139,18 +154,22 @@ describe("renderDecisionBadge (U21)", () => { expect(badgeFor({ decision: "defer" })).toBe("◆ DEFER"); }); - it("badges a JSON-RPC error", () => { - const badge = renderDecisionBadge( + // Renders through `renderOutcome`, which is what the stream renderer calls: + // an error reaches `renderRpcError`, never U21's decision badge (PR #11 + // review, second pass). `renderDecisionBadge` cannot be handed one at all + // now -- `DecisionMessage` has no error arm to pass it. + it("renders a JSON-RPC error as an error, not as a decision badge", () => { + const line = renderOutcome( messageOf(entry({ envelope: { jsonrpc: "2.0", id: 1, error: { code: -32010, message: "ACS envelope failed" } } })), ); - expect(badge).toBe("✖ ERROR -32010 ACS envelope failed"); + expect(line).toBe("✖ ERROR -32010 ACS envelope failed"); }); // Pins the fallback the old renderer had inline: a code that is not a // number reaches the message as null and still renders as `?`. - it("badges an error whose code is not a number", () => { - expect(renderDecisionBadge({ error: { code: null, message: "unreadable" } })).toBe("✖ ERROR ? unreadable"); + it("renders an error whose code is not a number", () => { + expect(renderRpcError({ code: null, message: "unreadable" })).toBe("✖ ERROR ? unreadable"); }); it("emits ANSI only when colour is asked for", () => { diff --git a/slices/v1/README.md b/slices/v1/README.md index e82f94fb..fcef1fcd 100644 --- a/slices/v1/README.md +++ b/slices/v1/README.md @@ -10,4 +10,4 @@ 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`. +Only `pre_tool_call` is wired; there is no session state, no envelope log, 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/slices/v2/README.md b/slices/v2/README.md index ffefdcc1..8f48fd24 100644 --- a/slices/v2/README.md +++ b/slices/v2/README.md @@ -9,7 +9,7 @@ ## What this slice delivers The Guardian records every ACS envelope crossing its wire into a JSONL log -(`packages/guardian/src/envelope-tap.ts` → `.acs/envelopes.jsonl`, S6/N26), and +(`packages/guardian/src/envelope-log-sink.ts` → `.acs/envelopes.jsonl`, S6/N26), and `bun run inspector` (`packages/inspector`) tails that log and renders each entry live: a header line, a decision badge for responses, then the envelope as pretty JSON (U20/U21, N50). The demo is a third terminal beside `bun run guardian` and the agent @@ -23,7 +23,7 @@ Three properties make this worth more than a log viewer: the `write` method on the `EnvelopeLogSink` it returns — sits on the decision path. A write failure disables the sink for the process lifetime, reports once, and never propagates — an observability feature must not be able to turn a governed tool call - into an ungoverned one. `packages/guardian/test/envelope-tap-wiring.test.ts` asserts + into an ungoverned one. `packages/guardian/test/envelope-log-sink-wiring.test.ts` asserts exactly that end to end: *"still denies `rm -rf /` when every envelope-log write fails"*. - **The request is recorded before validation.** An envelope that fails the schema is diff --git a/test/envelope-tap-roundtrip.test.ts b/test/envelope-log-sink-roundtrip.test.ts similarity index 83% rename from test/envelope-tap-roundtrip.test.ts rename to test/envelope-log-sink-roundtrip.test.ts index af340de8..d941cfae 100644 --- a/test/envelope-tap-roundtrip.test.ts +++ b/test/envelope-log-sink-roundtrip.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { startGuardian } from "../packages/guardian/src/index.ts"; import { tailEnvelopeLog, type EnvelopeLogEntry } from "../packages/inspector/src/tail-envelope-log.ts"; -import { decisionMessageOf, renderDecisionBadge, type DecisionMessage } from "../packages/inspector/src/render.ts"; +import { outcomeMessageOf, renderOutcome, type OutcomeMessage } from "../packages/inspector/src/render.ts"; /** * The contract test for S6. The Guardian writes the log; the Inspector @@ -84,13 +84,14 @@ describe("S6 round trip: Guardian envelope log sink (N26) -> Inspector tail (N50 expect(response?.rpc_id).toBe(77); // ...and the badge reads a real AGT-backed decision off it. Two steps - // now, not one: decisionMessageOf turns the S6 line into U21's message - // and the badge renders that message (PR #11 review). Both halves are - // exercised here deliberately -- the round trip's claim is that a real - // Guardian's real output survives all the way to a rendered badge. - const message = decisionMessageOf(response as EnvelopeLogEntry); - expect(message).not.toBeNull(); - expect(renderDecisionBadge(message as DecisionMessage)).toContain("DENY"); + // now, not one: outcomeMessageOf turns the S6 line into what the step + // reported and the renderer renders that message (PR #11 review). Both + // halves are exercised here deliberately -- the round trip's claim is + // that a real Guardian's real output survives all the way to a rendered + // badge, including that it arrives as a decision rather than an error. + const message = outcomeMessageOf(response as EnvelopeLogEntry); + expect(message?.kind).toBe("decision"); + expect(renderOutcome(message as OutcomeMessage)).toContain("DENY"); } finally { controller.abort(); await guardian.close(); From 105aec43dff973e97807229f3922730c57a6ceb0 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Mon, 17 Aug 2026 10:41:22 +0300 Subject: [PATCH 33/35] Say what the code does now, not how it got here Same pass as the previous slice, over the envelope log and the Inspector: review archaeology out ("PR #11 review, second pass", "Retitled by the whole-branch review", "Backlog item K"), internal identifiers replaced by what they stand for (S6 becomes "the envelope log", U21 becomes the badge it names, R5.1 and R5.2 become the boundaries they assert), and the denser paragraphs rewritten as ordinary sentences. Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr --- packages/guardian/src/envelope-log-sink.ts | 85 +++----- packages/guardian/src/index.ts | 39 ++-- packages/guardian/src/server.ts | 123 +++++------ .../test/envelope-log-sink-wiring.test.ts | 11 +- .../guardian/test/envelope-log-sink.test.ts | 12 +- packages/guardian/test/server.test.ts | 192 ++++++++---------- packages/inspector/src/main.ts | 19 +- packages/inspector/src/render.ts | 117 +++++------ packages/inspector/src/tail-envelope-log.ts | 67 +++--- packages/inspector/test/render.test.ts | 79 ++++--- .../inspector/test/tail-envelope-log.test.ts | 38 ++-- 11 files changed, 350 insertions(+), 432 deletions(-) diff --git a/packages/guardian/src/envelope-log-sink.ts b/packages/guardian/src/envelope-log-sink.ts index 7efca88b..e83d80f2 100644 --- a/packages/guardian/src/envelope-log-sink.ts +++ b/packages/guardian/src/envelope-log-sink.ts @@ -1,63 +1,44 @@ /** - * createEnvelopeLogSink (N26) writes S6: a JSONL record of every ACS envelope - * that crosses this Guardian's wire, in both directions. The Envelope - * Inspector (P4) reads this file and nothing else -- see packages/inspector, - * which deliberately imports nothing from here. + * createEnvelopeLogSink writes a JSONL record of every ACS envelope that + * crosses this Guardian's wire, in both directions. The Envelope Inspector + * reads this file and nothing else -- see packages/inspector, which + * deliberately imports nothing from here. * - * Named for the artifact and the role, not for the mechanism (PR #11's naming - * review). This used to be the `EnvelopeTap` / `createEnvelopeTap` / - * `NULL_TAP` / `TapEntry` family: "tap the wire" is how the writer works, not - * what a reader is looking at. V3 adds the host's audit rail as this rail's - * sibling -- another total JSONL record of something that crossed a boundary, - * which the Inspector will tail beside this one -- and two metaphors for one - * job family would force every reader to translate between a "tap" and a - * "sink". Naming this side for the log it produces is what makes the pair - * read as a pair when the second rail lands. + * Named for the artifact it produces and the role it plays, not for the + * mechanism: it writes a log, it does not "tap a wire". * - * The affordance tables in docs/shaping/ used to label N26 - * `writeEnvelopeTap()`, a function that never existed here in any form; they - * now say `createEnvelopeLogSink()` -> `sink.write()`, which is what this - * module exports. The ID is spelled out in this comment so a reader hunting - * N26 from the slices doc lands here, and so that a future rename has to - * pass through both places at once. - * - * What "records the envelope" means here, precisely (global constraint 11, - * as corrected by the whole-branch review's finding 2): the sink is handed - * the JSON *value* the Guardian parsed, and writes it unmodified -- no field + * What "records the envelope" means here, precisely: the sink is handed the + * JSON *value* the Guardian parsed, and writes it unmodified -- no field * stripping, no redaction, no reordering of anything we control. It is not a - * byte-for-byte copy of the request body, and V2's documentation claimed it - * was. The parse happens upstream in server.ts (`await req.json()`) and has - * already collapsed duplicate keys, canonicalised number literals, and - * hoisted integer-like object keys -- and `arguments` keys are - * host-controlled, so `{"0": ...}` is a real shape, not a hypothetical. - * Recording raw bytes instead would make `envelope` a string rather than a - * JSON value, costing the Inspector its pretty-printing and the round-trip - * contract test; the accurate sentence is the better trade. + * byte-for-byte copy of the request body. The parse happens upstream in + * server.ts (`await req.json()`) and has already collapsed duplicate keys, + * canonicalised number literals, and hoisted integer-like object keys -- and + * `arguments` keys are host-controlled, so `{"0": ...}` is a real shape, not + * a hypothetical. Recording raw bytes instead would make `envelope` a string + * rather than a JSON value, costing the Inspector its pretty-printing and + * the round-trip contract test; the accurate sentence is the better trade. * - * Total by construction (global constraint 8). Every write is wrapped: a - * failure disables the sink for the process lifetime, reports once, and is - * never propagated to the caller. The sink sits on the decision path, and - * V1 shipped three separate fail-opens before they were caught -- an - * observability feature that can turn a governed tool call into an - * ungoverned one would be the fourth. Observability degrades; governance - * does not. + * Total by construction. Every write is wrapped: a failure disables the sink + * for the process lifetime, reports once, and is never propagated to the + * caller. The sink sits on the decision path: an observability feature that + * can turn a governed tool call into an ungoverned one is the one failure + * mode this module must never have. Observability degrades; governance does + * not. */ import { appendFileSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; -/** Which side of the exchange one S6 line recorded. Named after the log, the - * same way `EnvelopeLogEntry` is, so nothing on this rail carries the - * writer's own nickname. */ +/** Which side of the exchange one envelope-log line recorded. */ export type EnvelopeLogDirection = "request" | "response"; /** - * One line of S6, named for what was recorded rather than for what recorded - * it. `envelope` is the JSON-RPC object as parsed, unmodified -- request or - * response -- and every other field is Guardian-side context the wire does - * not carry: a sequence number so a reader can detect gaps, a timestamp, the - * direction, the ACS method (JSON-RPC responses carry none, so the Guardian - * supplies the one it dispatched), and the JSON-RPC id that pairs the two - * directions. + * One line of the envelope log, named for what was recorded rather than for + * what recorded it. `envelope` is the JSON-RPC object as parsed, unmodified + * -- request or response -- and every other field is Guardian-side context + * the wire does not carry: a sequence number so a reader can detect gaps, a + * timestamp, the direction, the ACS method (JSON-RPC responses carry none, + * so the Guardian supplies the one it dispatched), and the JSON-RPC id that + * pairs the two directions. */ export type EnvelopeLogEntry = { seq: number; @@ -68,7 +49,7 @@ export type EnvelopeLogEntry = { envelope: unknown; }; -/** Where S6 lines go: the Guardian-side writer role. */ +/** Where envelope-log lines go: the Guardian-side writer role. */ export type EnvelopeLogSink = { write(direction: EnvelopeLogDirection, envelope: unknown, method: string | null): void; readonly path: string | null; @@ -82,9 +63,9 @@ export type CreateEnvelopeLogSinkOptions = { onError?: (error: unknown) => void; }; -/** The sink a Guardian gets when no envelopeLogPath was configured (P3). - * Renamed in lockstep with the role it implements, so a composition root - * disabling observability names the same rail the type does. */ +/** The sink a Guardian gets when no envelope log path was configured: a + * composition root disabling observability gets a value of the same + * `EnvelopeLogSink` type, not a special case. */ export const NULL_ENVELOPE_LOG_SINK: EnvelopeLogSink = { path: null, write(): void {}, diff --git a/packages/guardian/src/index.ts b/packages/guardian/src/index.ts index 79592e27..022a9ab1 100644 --- a/packages/guardian/src/index.ts +++ b/packages/guardian/src/index.ts @@ -2,28 +2,29 @@ * Public surface of the guardian package: the governance verbs, and nothing * else. * - * S6's writer (N26, ./envelope-log-sink.ts) is deliberately NOT re-exported. - * Observability is an internal detail of running a Guardian, not part of the - * vocabulary a consumer of this package speaks -- and a barrel that mixes - * `startGuardian` / `mapVerdict` with `createEnvelopeLogSink` / - * `NULL_ENVELOPE_LOG_SINK` / `extractRpcId` / the entry types makes the - * second look like the first (PR #11 review). The one affordance a consumer - * actually needs is `envelopeLogPath` on `StartGuardianOptions`, which stays - * exactly where it was: you tell a Guardian where to write S6, you do not - * assemble its sink yourself. + * The envelope log's writer (`./envelope-log-sink.ts`) is deliberately not + * re-exported. Observability is an internal detail of running a Guardian, + * not part of the vocabulary a consumer of this package speaks, and a + * barrel that mixed `startGuardian` / `mapVerdict` with + * `createEnvelopeLogSink` / `NULL_ENVELOPE_LOG_SINK` / `extractRpcId` / the + * entry types would make the second look like the first. The one affordance + * a consumer actually needs is `envelopeLogPath` on `StartGuardianOptions`, + * which stays exactly where it was: you tell a Guardian where to write its + * envelope log, you do not assemble the sink yourself. * - * Nothing outside this package imported those names, so this removes a - * public surface rather than a dependency: `./server.ts` imports the sink - * directly, and the two test files that exercise N26 on its own + * `./server.ts` imports the sink directly, and the two test files that + * exercise it on its own * (`packages/guardian/test/envelope-log-sink*.test.ts`) import - * `../src/envelope-log-sink.ts` directly, which is the arrangement they already - * used. If an external writer of S6 ever becomes a real use case, the - * honest answer is a narrow subpath export, not putting it back here. + * `../src/envelope-log-sink.ts` directly too, so nothing outside this + * package needs it re-exported here. If an external writer of the envelope + * log ever becomes a real need, the answer is a narrow subpath export, not + * putting it back in this barrel. * - * The Inspector is unaffected either way -- it imports NOTHING from this - * package (R5.1), reads S6 as a file, and re-declares `EnvelopeLogEntry` - * itself; `test/invariants.test.ts` gates that and - * `test/envelope-log-sink-roundtrip.test.ts` keeps the two declarations honest. + * The Inspector is unaffected either way: it imports nothing from this + * package, reads the envelope log purely as a file, and re-declares + * `EnvelopeLogEntry` on its own side. `test/invariants.test.ts` enforces + * that boundary, and `test/envelope-log-sink-roundtrip.test.ts` keeps the + * two declarations honest with each other. */ export { startGuardian, type StartGuardianOptions, type StartedGuardian } from "./server.ts"; export { buildServerHello, type ServerHello } from "./handshake.ts"; diff --git a/packages/guardian/src/server.ts b/packages/guardian/src/server.ts index 2a5107a7..94135839 100644 --- a/packages/guardian/src/server.ts +++ b/packages/guardian/src/server.ts @@ -20,13 +20,14 @@ * The two catches are: * - Inside `dispatch`, around assemblePreToolCallSnapshot, bridge.evaluate * and mapVerdict: the evaluation itself. - * - Around the whole `dispatch` call in `handleAcsRequest`, as the outer net. - * `dispatch` rethrows anything that is not an EnvelopeValidationError, and - * that rethrow is live: validateEnvelope builds its Ajv registry lazily, on - * the first request rather than at boot, so a tree cloned without - * `--recurse-submodules` starts cleanly and then turns every request into - * an HTML 500. The outer net also puts the failure response back on the - * tapped path, so the envelope log records it like any other response. + * - Around the whole `dispatch` call in `handleAcsRequest`, as the outer + * net. `dispatch` rethrows anything that is not an + * EnvelopeValidationError, and that rethrow is live: validateEnvelope + * builds its Ajv registry lazily, on the first request rather than at + * boot, so a tree cloned without `--recurse-submodules` starts cleanly + * and then turns every request into an HTML 500. The outer net also + * writes the failure response through the same envelope-log call as any + * other response. * * Neither catch turns the failure into an ACS `deny` decision: which * disposition a Guardian-side failure should carry is a separate question. @@ -101,59 +102,46 @@ const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url)).replace(/ const REPO_ROOT_PATTERN = new RegExp(`${REPO_ROOT.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=[/\\\\]|$)[/\\\\]?`, "g"); /** - * Both catches in this module (the outer net in handleAcsRequest and the - * evaluation-failure catch in dispatch) surface a real error to the ACS - * client and, via the envelope log, into S6 -- and a real error's message + * Both catches in this module -- the outer net in handleAcsRequest and the + * evaluation-failure catch in dispatch -- surface a real error to the ACS + * client and, through the envelope log, onto disk. A real error's message * (an ENOENT out of a missing schema directory, say) carries this machine's * absolute filesystem path, e.g. * `/Users/you/.../ACS_reference_implementation/packages/spec/acs/...`. - * That is diagnostic in a way this demo's value depends on, so the fix is - * not to replace it with something generic -- it is to remove only the - * part of it that discloses where this tree sits on disk, leaving the - * repo-relative remainder (`packages/spec/acs/...`) intact. + * That is diagnostic in a way this demo's value depends on, so rather than + * replace it with something generic, this function removes only the part + * that discloses where this tree sits on disk, leaving the repo-relative + * remainder (`packages/spec/acs/...`) intact. * * Takes the caught value itself, as `unknown`, rather than a pre-extracted - * string. Both call sites already had to guard with - * `error instanceof Error ? error.message : String(error)` because a catch - * clause's binding is `unknown` -- but an earlier version of this helper - * took that guard's *result* and assumed it was a string, which is true of - * every message this tree's own code happens to produce, not something - * `instanceof Error` guarantees: nothing stops `.message` from being - * reassigned to `undefined`, a number, or anything else after construction. - * That earlier version threw `TypeError: undefined is not an object - * (evaluating 'message.replace')` on exactly such an Error -- reachable - * only from the outer catch, since the inner catch's own throw is itself - * caught by the outer one, but reachable there with nothing above - * `handleAcsRequest` to catch it: the unrecorded HTML-500 fail-open this - * module's header exists to prevent. `String()` on the whole caught value - * keeps this helper total for any `unknown`, matching what a catch clause - * can actually hand it. + * string, and calls `String()` on the whole thing. `instanceof Error` does + * not guarantee `.message` is a string -- nothing stops it from being + * reassigned to `undefined`, a number, or anything else after construction + * -- and a throw from this function on the outer-net path has nothing above + * `handleAcsRequest` to catch it. Staying total for any `unknown`, exactly + * what a catch clause can hand it, is what keeps that path from becoming an + * unrecorded HTML 500. * - * Exported (unlike this module's other internals -- `dispatch`, - * `extractId`, `extractMethod`, `errorResponse`) so the anchor behaviour - * above and this function's totality for non-`Error`, non-string-message, - * and otherwise-shaped `unknown` values can be asserted directly, rather - * than only through a real Guardian and an HTTP round trip. The blocking - * regression this exists to prevent is still covered end to end, separately - * -- see server.test.ts's `withFakeValidateEnvelopeGuardian` tests. + * Exported -- unlike this module's other internals (`dispatch`, `extractId`, + * `extractMethod`, `errorResponse`) -- so the redaction above and this + * function's totality for non-`Error`, non-string-message, and + * otherwise-shaped `unknown` values can be asserted directly, rather than + * only through a real Guardian and an HTTP round trip. The same behaviour is + * also covered end to end, separately -- see server.test.ts's + * `withFakeValidateEnvelopeGuardian` tests. * * The whole body is wrapped in its own try/catch, including the * `instanceof` check -- belt-and-braces, not a reaction to a live bug. - * Nothing in this tree throws an `Error` whose `.message` is a + * Nothing in this tree currently throws an `Error` whose `.message` is a * throwing accessor, a value whose `toString`/`valueOf` throws, or a * `Proxy` that throws on `get` or on `getPrototypeOf` (which would defeat * `instanceof Error` itself, since it walks the prototype chain through - * `[[GetPrototypeOf]]`, before `String()` below ever runs) -- all four are - * unreachable from any throw site in this repo today, same as the shape the - * previous fix in this module closed. They are guarded anyway because - * "unreachable today" should not be load-bearing for the one function whose - * entire job is upholding this module's stated contract that nothing - * escapes the outer net: this project has already found five fail-opens of - * this shape, the last one introduced by a fix for a minor, and V3 (N27 - * `denyOnInvalidEnvelope`) adds new routes through this exact path. The - * fallback string names the failure mode rather than guessing at a partial - * message, since the point is that nothing about the original error could - * be read at all. + * `[[GetPrototypeOf]]`, before `String()` below ever runs). They are guarded + * anyway because "unreachable today" should not be load-bearing for the one + * function whose entire job is upholding this module's contract that + * nothing escapes the outer net. The fallback string names the failure mode + * rather than guessing at a partial message, since the point is that + * nothing about the original error could be read at all. */ export function toRepoRelativeMessage(error: unknown): string { try { @@ -205,10 +193,10 @@ export type StartGuardianOptions = { * handleAcsRequest against a real bridge, without touching the mapping * every other consumer reads. Not meant for production use. */ mappingPath?: string; - /** Path to S6, the JSONL envelope log (N26). Omitted means no sink: every - * V1 test constructs Guardians freely and a default-on sink would scatter - * files through the working tree. `packages/guardian/src/main.ts` -- the - * demo path -- passes it. See the plan's decision P3. */ + /** Path to the JSONL envelope log. Omitted means no sink: tests construct + * Guardians freely, and a default-on sink would scatter files through the + * working tree. `packages/guardian/src/main.ts` -- the demo path -- + * passes it. */ envelopeLogPath?: string; }; export type StartedGuardian = { url: string; close(): Promise }; @@ -248,18 +236,16 @@ export async function startGuardian({ /** * Three phases, in order: parse, record the request, dispatch, record the - * response. The envelope-log writes live here and only here -- `dispatch` - * below leaves by six routes (five `return`s and one rethrow) and V3's N27 - * adds a seventh, so writing S6 inside it would make totality something a - * future task has to remember rather than something the structure - * guarantees. + * response. The envelope-log writes live here and only here: `dispatch` + * below leaves by six routes (five `return`s and one rethrow), and future + * dispatch outcomes will add more, so writing to the envelope log inside it + * would make totality something a future change has to remember rather than + * something the structure guarantees. * - * That guarantee is only as good as its coverage of the throwing route, and - * the whole-branch review's finding 1 found it uncovered: `dispatch`'s - * rethrow used to leave this function without a response at all, so the - * client got a Bun.serve HTML 500 that S6 never recorded. The try/catch - * below closes it -- every route out of `dispatch` now produces a response - * object, and every response object reaches the envelope log. + * That guarantee only holds if every route out of `dispatch` is covered, + * including the one that throws: the try/catch below ensures every route + * out of `dispatch` produces a response object, and every response object + * reaches the envelope log. * * The sink itself is total (see envelope-log-sink.ts): these two calls cannot * throw, so they cannot turn a governed tool call into an ungoverned one. @@ -285,7 +271,7 @@ async function handleAcsRequest( return parseError; } - // Decision P5: before validation, so an envelope that fails the schema is + // Recorded before validation, so an envelope that fails the schema is // visible to the Inspector rather than invisible. const method = extractMethod(raw); envelopeLog.write("request", raw, method); @@ -294,10 +280,11 @@ async function handleAcsRequest( try { response = await dispatch(raw, bridge, mapping); } catch (error) { - // The outer net (whole-branch review, finding 1). Deliberately a bare - // JSON-RPC error, not an ACS `deny`: N27 stays V3's call. What this - // buys is that the client can parse the answer at all, and that S6 - // holds a response line paired with the request line above it. + // The outer net. Deliberately a bare JSON-RPC error, not an ACS `deny` + // decision -- turning a validation failure into an explicit deny is a + // separate concern. What this buys is that the client can parse the + // answer at all, and that the envelope log holds a response line paired + // with the request line above it. const message = toRepoRelativeMessage(error); response = errorResponse(extractId(raw), EVALUATION_FAILED_CODE, `guardian failed to handle the request: ${message}`); } diff --git a/packages/guardian/test/envelope-log-sink-wiring.test.ts b/packages/guardian/test/envelope-log-sink-wiring.test.ts index 2ed7fb05..2278a2b3 100644 --- a/packages/guardian/test/envelope-log-sink-wiring.test.ts +++ b/packages/guardian/test/envelope-log-sink-wiring.test.ts @@ -102,9 +102,9 @@ describe("Guardian envelope log wiring (N26 x N20)", () => { }); }); - // Decision P5. The envelope that fails validation is the most useful - // thing an ACS-first reader can see; recording it after the validator is - // exactly what would hide it. + // The envelope that fails validation is the most useful thing an + // ACS-first reader can see; recording it after the validator is exactly + // what would hide it. it("records a schema-invalid request, then its JSON-RPC error response", async () => { await withGuardian(logIn, async (url, logPath) => { const bad = toolCallEnvelope("rm -rf /", { id: 12 }); @@ -135,8 +135,8 @@ describe("Guardian envelope log wiring (N26 x N20)", () => { }); }); - // Global constraint 8, end to end: the sink is on the decision path, so - // this is the test that says a broken sink cannot become a fail-open. + // End to end: the sink is on the decision path, so this is the test that + // says a broken sink cannot become a fail-open. // // No `onError` is passed here, so this exercises the sink's *default* // reporter -- a single `console.error` line -- rather than the @@ -174,7 +174,6 @@ describe("Guardian envelope log wiring (N26 x N20)", () => { } }); - // Decision P3. it("writes nothing when envelopeLogPath is omitted", async () => { const dir = mkdtempSync(join(tmpdir(), "acs-envelope-log-off-")); const logPath = join(dir, "envelopes.jsonl"); diff --git a/packages/guardian/test/envelope-log-sink.test.ts b/packages/guardian/test/envelope-log-sink.test.ts index 266714cb..a37b42db 100644 --- a/packages/guardian/test/envelope-log-sink.test.ts +++ b/packages/guardian/test/envelope-log-sink.test.ts @@ -51,10 +51,10 @@ describe("createEnvelopeLogSink (N26) -- S6's JSONL format", () => { }); }); - // Retitled by the whole-branch review (finding 2); the assertion is - // unchanged. It has always checked that the JSON value reaches S6 - // unmodified -- nothing stripped, nothing reordered. "Verbatim" claimed - // byte identity, which the sink never had: it is handed `await req.json()`. + // Checks that the JSON value reaches the envelope log unmodified -- + // nothing stripped, nothing reordered. This is not byte identity with the + // wire: the sink is handed the already-parsed result of `await + // req.json()`. it("records the envelope unmodified -- constraint 11, no reformatting or stripping", () => { withTempDir((dir) => { const path = join(dir, "envelopes.jsonl"); @@ -98,8 +98,8 @@ describe("createEnvelopeLogSink (N26) -- S6's JSONL format", () => { }); }); - // Global constraint 8. This is the whole reason the sink is a module and - // not three inline appendFileSync calls. + // The whole reason the sink is a module rather than three inline + // appendFileSync calls. it("never throws when the log path is unwritable, reports once, and goes quiet", () => { withTempDir((dir) => { const blocker = join(dir, "envelopes.jsonl"); diff --git a/packages/guardian/test/server.test.ts b/packages/guardian/test/server.test.ts index dac625df..9056c512 100644 --- a/packages/guardian/test/server.test.ts +++ b/packages/guardian/test/server.test.ts @@ -11,7 +11,7 @@ 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. */ + * drifts from the schema. */ function validateServerHello(candidate: unknown): void { const handshakeSchema = JSON.parse(readFileSync(HANDSHAKE_SCHEMA_PATH, "utf8")) as { $defs: { ServerHello: Record }; @@ -147,18 +147,17 @@ describe("startGuardian POST /acs", () => { }); }); -// Fix wave finding 1 -- a real fail-open bug: an unhandled throw from -// 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 -// 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. +// An unhandled throw from assemblePreToolCallSnapshot, bridge.evaluate, or +// mapVerdict inside handleAcsRequest must never escape uncaught: Bun.serve's +// default error page for a rejected fetch() is `text/html`, not JSON, so +// guardianClient.post's `res.json()` would throw a SyntaxError instead of +// surfacing a JSON-RPC error, acs-hook.ts's catch-all would exit 1 with +// nothing on stdout, and Claude Code would read that as "the hook never +// fired" and let the tool call proceed ungoverned. This test exercises that +// guard 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({ @@ -191,10 +190,10 @@ const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url)); const GUARDIAN_PKG = join(REPO_ROOT, "packages", "guardian"); /** - * A stable, predictable name rather than an `mkdtempSync` random one - * (backlog item C). This tree has to live *inside* `packages/guardian/` -- - * not under a repo-wide temp directory -- because it is a relative-path - * trick: `validate-envelope.ts` resolves its schema root three directories + * A stable, predictable name rather than an `mkdtempSync` random one. This + * tree has to live *inside* `packages/guardian/` -- not under a repo-wide + * temp directory -- because it is a relative-path trick: + * `validate-envelope.ts` resolves its schema root three directories * up from its own `import.meta.url`, so the copy has to sit at the same * depth under `packages/guardian/` for that resolution to land one level * short, on purpose (see the doc comment below). Bun's workspace module @@ -216,9 +215,8 @@ const SCHEMALESS_SCRATCH_DIR = join(GUARDIAN_PKG, "tmp-schemaless-scratch"); /** * Runs `body` against a Guardian whose `validate-envelope.ts` cannot find the - * ACS schemas -- the tree-cloned-without-`--recurse-submodules` case, which is - * what makes the whole-branch review's finding 1 reachable rather than - * theoretical. + * ACS schemas -- the tree-cloned-without-`--recurse-submodules` case, which + * is exactly the scenario handleAcsRequest's outer net exists to catch. * * It is reproduced by *relocation*, not by mocking and not by touching * `spec/`. `validate-envelope.ts` derives SCHEMA_ROOT from its own @@ -237,7 +235,7 @@ const SCHEMALESS_SCRATCH_DIR = join(GUARDIAN_PKG, "tmp-schemaless-scratch"); * disk for that later call go unread. Benign here (every call copies * byte-identical source, and this suite's own Ajv registry is never shared * with the copy either way), but worth being precise about now that the - * directory name is fixed rather than fresh per call (backlog item C). + * directory name is fixed rather than fresh per call. * * Deletions here are explicit per file (repo constraint: nothing recursive). */ @@ -295,12 +293,10 @@ async function withSchemalessGuardian( * tests. Those four all copy the *same* real files every time, so whichever * call's module instance Bun's cache happens to answer with behaves * identically. These two fake sources differ from each other, and Bun's - * module cache keys by resolved path: reusing one directory for both meant - * the second call's `import()` returned the *first* call's already-loaded - * module -- silently exercising the wrong test double, discovered by this - * test failing with the first double's behaviour instead of the second's - * before this was split out. Two names, so each call gets a path Bun has - * never loaded before. + * module cache keys by resolved path: reusing one directory for both would + * make the second call's `import()` return the *first* call's + * already-loaded module, silently exercising the wrong test double. Two + * names, so each call gets a path Bun has never loaded before. */ const UNDEFINED_MESSAGE_SCRATCH_DIR = join(GUARDIAN_PKG, "tmp-undefined-message-scratch"); const THROWING_MESSAGE_ACCESSOR_SCRATCH_DIR = join(GUARDIAN_PKG, "tmp-throwing-message-accessor-scratch"); @@ -340,16 +336,15 @@ export function isToolCallRequest(envelope) { `; /** - * A second test double, for the residual the follow-up review surfaced: - * `toRepoRelativeMessage`'s own `error instanceof Error ? error.message : - * error` line can itself throw, if `.message` is an accessor that throws on - * get -- a case the plain `undefined`-message double above does not - * exercise, since overwriting `.message` with a value never triggers a - * getter. `EnvelopeValidationError` is redeclared here for the same reason - * as the double above. + * A second test double, covering the case where `toRepoRelativeMessage`'s + * own `error instanceof Error ? error.message : error` line can itself + * throw: `.message` as an accessor that throws on get -- which the plain + * `undefined`-message double above does not exercise, since overwriting + * `.message` with a value never triggers a getter. `EnvelopeValidationError` + * is redeclared here for the same reason as the double above. * - * Deliberately *not* the getPrototypeOf-trapping Proxy the review also - * named. That shape is real and is covered directly, at the unit level, + * Deliberately *not* a getPrototypeOf-trapping Proxy. That shape is real and + * is covered directly, at the unit level, * below -- but it cannot reach `toRepoRelativeMessage` unmutated through * this route: `dispatch`'s own `error instanceof EnvelopeValidationError` * check runs first, and `instanceof` needs exactly the trapped @@ -446,15 +441,15 @@ async function withFakeValidateEnvelopeGuardian( } } -// Whole-branch review, finding 1 -- the fourth fail-open of V1's shape, and -// the exit the sink's structural-totality claim did not cover. `dispatch` -// rethrows any non-EnvelopeValidationError, and nothing used to catch it: -// Bun.serve answers a rejecting fetch() handler with a `text/html` 500, -// guardian-client's unconditional `res.json()` throws `JSON Parse error: -// Unrecognized token '<'`, acs-hook.ts's catch-all exits 1 with empty stdout, -// and Claude Code reads that as "the hook didn't fire" -- the tool call -// proceeds ungoverned. S6 recorded the request and nothing else, so the -// Inspector could not even show that a response had been sent. +// `dispatch` rethrows any non-EnvelopeValidationError, and the outer net in +// handleAcsRequest is what catches it: an uncaught rethrow would leave +// Bun.serve answering with its default `text/html` 500, guardian-client's +// unconditional `res.json()` would throw `JSON Parse error: Unrecognized +// token '<'`, acs-hook.ts's catch-all would exit 1 with empty stdout, and +// Claude Code would read that as "the hook didn't fire" and let the tool +// call proceed ungoverned. Without this net, the envelope log would also +// record only the request, leaving the Inspector unable to show that a +// response was ever sent. describe("startGuardian POST /acs -- the outer net around dispatch", () => { it("answers a throw from validateEnvelope itself with parseable JSON-RPC in -32000..-32099, never an HTML 500", async () => { await withSchemalessGuardian(async ({ url }) => { @@ -479,14 +474,12 @@ describe("startGuardian POST /acs -- the outer net around dispatch", () => { }); }); - // Backlog item B. The real ENOENT `withSchemalessGuardian` provokes names - // this machine's absolute path in full (`readdirSync` on a schema - // directory that does not exist at the relocated copy's resolved path): - // before the fix, that absolute path -- this repo's own root, in - // particular -- rode straight through to the client and into S6 - // unredacted. The fix strips only the repo-root prefix, so the - // diagnostic remainder (the ENOENT text and the repo-relative path) is - // still there for a real reader to use. + // The real ENOENT `withSchemalessGuardian` provokes names this machine's + // absolute path in full (`readdirSync` on a schema directory that does + // not exist at the relocated copy's resolved path). toRepoRelativeMessage + // strips only the repo-root prefix from it, so the diagnostic remainder + // (the ENOENT text and the repo-relative path) is still there for a real + // reader to use, without disclosing where this tree sits on disk. it("strips this repo's absolute root out of a real error message before it reaches the client", async () => { await withSchemalessGuardian(async ({ url }) => { const response = await postAcs(url, toolCallEnvelope("ls -la")); @@ -522,26 +515,22 @@ describe("startGuardian POST /acs -- the outer net around dispatch", () => { const lines = readFileSync(logPath, "utf8").trim().split("\n"); const entries = lines.map((line) => JSON.parse(line) as { direction: string; rpc_id: unknown }); expect(entries.map((e) => e.direction)).toEqual(["request", "response"]); - // Paired by JSON-RPC id (decision P4), which is what lets the Inspector - // show the failure beside the request that caused it. + // Paired by JSON-RPC id, which is what lets the Inspector show the + // failure beside the request that caused it. expect(entries.map((e) => e.rpc_id)).toEqual([11, 11]); }); }); - // Blocking finding from this wave's review. Pre-fix, toRepoRelativeMessage - // assumed any `unknown` satisfying `error instanceof Error` also carried a - // string `.message` -- true of the real ENOENT the test above forces, but - // not something `instanceof Error` guarantees. An Error whose `.message` - // has been overwritten to `undefined` throws `TypeError: undefined is not - // an object (evaluating 'message.replace')` out of the helper itself -- - // and unlike the inner catch's own throw (contained by this outer catch), - // a throw *from* the outer catch has nothing above `handleAcsRequest` to - // catch it: Bun.serve's fetch handler has no try, so it answers with the - // unrecorded HTML 500 the module header exists to prevent. Fails against - // the pre-fix helper (confirmed by hand before implementing the fix: the - // fetch below resolves to an HTML error page, and `res.json()` -- exactly - // guardianClient.post's call -- throws a SyntaxError instead of returning - // a response). + // `instanceof Error` does not guarantee `.message` is a string -- true of + // the real ENOENT the test above forces, but not something + // toRepoRelativeMessage can assume in general. An Error whose `.message` + // has been overwritten to `undefined` would throw `TypeError: undefined + // is not an object (evaluating 'message.replace')` out of the helper + // itself if it made that assumption -- and unlike the inner catch's own + // throw (contained by this outer catch), a throw *from* the outer catch + // has nothing above `handleAcsRequest` to catch it: Bun.serve's fetch + // handler has no try, so it would answer with the unrecorded HTML 500 the + // module header exists to prevent. it("does not let a real Error with a non-string .message escape the outer catch as an HTML 500", async () => { await withFakeValidateEnvelopeGuardian(UNDEFINED_MESSAGE_SCRATCH_DIR, UNDEFINED_MESSAGE_VALIDATE_ENVELOPE_SOURCE, async ({ url }) => { const res = await fetch(url, { @@ -559,22 +548,20 @@ describe("startGuardian POST /acs -- the outer net around dispatch", () => { expect(response.error).toBeDefined(); expect(response.error?.code).toBeGreaterThanOrEqual(-32099); expect(response.error?.code).toBeLessThanOrEqual(-32000); - // The pre-fix behaviour this restores: total, coerced to text, rather - // than thrown. + // Total, coerced to text, rather than thrown. expect(response.error?.message).toContain("undefined"); }); }); - // Residual the follow-up review surfaced and the coordinator asked closed - // anyway: an Error whose `.message` is an accessor that throws on get - // defeats `error instanceof Error ? error.message : error` inside + // An Error whose `.message` is an accessor that throws on get defeats + // `error instanceof Error ? error.message : error` inside // toRepoRelativeMessage itself. Unreachable from any real throw site in // this repo today -- belt and braces, not a reaction to a live bug (see // toRepoRelativeMessage's doc comment) -- but the unit assertions in the // describe block below only prove the helper itself is total; this // proves the outer net around it still holds when the value it's handed - // is this pathological. (The getPrototypeOf-trapping Proxy the review - // also named is covered at the unit level only, not here -- see + // is this pathological. (A getPrototypeOf-trapping Proxy is covered at + // the unit level only, not here -- see // THROWING_MESSAGE_ACCESSOR_VALIDATE_ENVELOPE_SOURCE's doc comment for // why that one specifically cannot reach toRepoRelativeMessage unmutated // through dispatch's rethrow route.) @@ -605,13 +592,12 @@ describe("startGuardian POST /acs -- the outer net around dispatch", () => { }); describe("toRepoRelativeMessage", () => { - // The regression an earlier review wave found: an earlier version assumed - // its argument's `.message` was a string whenever `error instanceof Error` - // was true. `instanceof Error` says nothing about what `.message` was - // reassigned to after construction, so it wasn't. Exercised directly - // (rather than only through withFakeValidateEnvelopeGuardian's HTTP round - // trip) so every shape of `unknown` a catch clause can hand it is covered - // without standing up a Guardian for each one. + // `instanceof Error` says nothing about what `.message` was reassigned to + // after construction, so this function must not assume it is a string. + // Exercised directly (rather than only through + // withFakeValidateEnvelopeGuardian's HTTP round trip) so every shape of + // `unknown` a catch clause can hand it is covered without standing up a + // Guardian for each one. it("never throws, for an Error whose .message is not a string", () => { const undefinedMessage = new Error("erased below"); (undefinedMessage as { message: unknown }).message = undefined; @@ -633,13 +619,11 @@ describe("toRepoRelativeMessage", () => { expect(toRepoRelativeMessage({ some: "object" })).toBe("[object Object]"); }); - // Follow-up review residual, parked by that review on correct facts - // (unreachable from any throw site here today, identical exposure existed - // pre-fix, out of that round's scope) and closed anyway: the contract this - // function exists to uphold is that nothing escapes the outer net, and - // "unreachable today" should not be load-bearing for that (see this - // function's doc comment). Four shapes, each defeating a different step - // of `String(error instanceof Error ? error.message : error)`: + // The contract this function exists to uphold is that nothing escapes the + // outer net, and "unreachable today" should not be load-bearing for that + // (see this function's doc comment). Four shapes, each defeating a + // different step of `String(error instanceof Error ? error.message : + // error)`: // - an Error whose `.message` is a throwing accessor // - a value whose `toString`/`valueOf` both throw, so `String()` itself // throws on the non-Error branch @@ -688,11 +672,11 @@ describe("toRepoRelativeMessage", () => { expect(toRepoRelativeMessage(throwingGetPrototypeOfProxy)).toBe(""); }); - // This file's own REPO_ROOT (above, line 190) keeps the trailing slash - // `fileURLToPath` gives a directory URL -- fine for join()ing against, - // but these two tests need the bare root, with nothing after it, to build - // "root + separator + subpath" and "root + suffix" strings without - // accidentally doubling or misplacing a slash. + // This file's own REPO_ROOT keeps the trailing slash `fileURLToPath` + // gives a directory URL -- fine for join()ing against, but these two + // tests need the bare root, with nothing after it, to build "root + + // separator + subpath" and "root + suffix" strings without accidentally + // doubling or misplacing a slash. const REPO_ROOT_BARE = REPO_ROOT.replace(/[/\\]+$/, ""); it("strips this repo's root, with or without a trailing separator", () => { @@ -700,12 +684,11 @@ describe("toRepoRelativeMessage", () => { expect(toRepoRelativeMessage(new Error(REPO_ROOT_BARE))).toBe(""); }); - // Recommended fix, same wave: the un-anchored version matched REPO_ROOT as - // a bare prefix, so a *sibling* directory whose name merely extends the - // root (a `_old` backup clone, say) had its shared prefix stripped too -- - // not a disclosure of this tree's own location, since it names a - // different directory entirely, but a misleading diagnostic that then - // reads as if it were a path under this repo. + // An un-anchored match on REPO_ROOT as a bare prefix would also strip a + // *sibling* directory whose name merely extends the root (a `_old` backup + // clone, say) -- not a disclosure of this tree's own location, since it + // names a different directory entirely, but a misleading diagnostic that + // would then read as if it were a path under this repo. it("leaves a sibling directory whose name extends the repo root untouched", () => { const siblingPath = `${REPO_ROOT_BARE}_old/packages/spec`; @@ -713,10 +696,9 @@ describe("toRepoRelativeMessage", () => { }); }); -// 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. +// The intervention point comes from mapping.yaml's own `intervention_points` +// table rather than from a hardcoded "pre_tool_call", so the table cannot +// drift away from what the runtime actually does without a test catching it. 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 diff --git a/packages/inspector/src/main.ts b/packages/inspector/src/main.ts index 371ac4b8..401735ac 100644 --- a/packages/inspector/src/main.ts +++ b/packages/inspector/src/main.ts @@ -1,9 +1,10 @@ /** * The Envelope Inspector's entrypoint -- `bun run inspector`. * - * A third terminal beside `bun run guardian` and the agent host: it tails S6 - * and prints each ACS envelope as it crosses the wire. Not re-exported from - * ./index.ts -- this is a process entrypoint, not a library call. + * A third terminal beside `bun run guardian` and the agent host: it tails + * the envelope log and prints each ACS envelope as it crosses the wire. Not + * re-exported from ./index.ts -- this is a process entrypoint, not a + * library call. * * `ACS_ENVELOPE_LOG` defaults to `.acs/envelopes.jsonl`, the same default * packages/guardian/src/main.ts writes to, so the two agree without either @@ -16,13 +17,11 @@ const DEFAULT_ENVELOPE_LOG = ".acs/envelopes.jsonl"; const argv = process.argv.slice(2); const fromStart = argv.includes("--from-start"); -// `--envelope-log`, not `--path` (PR #11 review, second pass): this stream is -// the envelope log, and a later slice tails a second one beside it. A generic -// `--path` would have been the flag for whichever stream happened to come -// first, leaving the sibling to carry the qualifier -- the same asymmetry the -// `EnvelopeLogEntry` / `AuditEntry` rename closed one layer down. Named for its -// artifact now, so the pair reads as a pair when the second lands, and so each -// flag rhymes with the env var that overrides the same thing. +// `--envelope-log`, not `--path`: this stream is the envelope log, and a +// generic `--path` would be the flag for whichever stream happened to come +// first, leaving any sibling stream to carry the qualifier. Named for its +// own artifact, so the flag rhymes with the env var that overrides the same +// thing. const envelopeLogFlag = argv.indexOf("--envelope-log"); const flagValue = envelopeLogFlag === -1 ? undefined : argv[envelopeLogFlag + 1]; diff --git a/packages/inspector/src/render.ts b/packages/inspector/src/render.ts index 6abd2b2b..16614a9b 100644 --- a/packages/inspector/src/render.ts +++ b/packages/inspector/src/render.ts @@ -1,37 +1,26 @@ /** - * U20 (envelope stream) and U21 (decision badge). + * Renders the two things the Inspector prints: the envelope stream and the + * ACS decision badge. * * Every function here is pure: no clock, no env, no process. The CLI decides * whether the terminal wants ANSI and passes `color`; tests assert exact - * plain strings. Nothing here knows what produced a decision -- the badge - * reads ACS's own `decision`, `reason_codes`, and `policy_references` - * fields and nothing else (global constraint 9). + * plain strings. The badge reads only ACS's own `decision`, `reason_codes`, + * and `policy_references` fields -- nothing here knows what policy runtime + * produced a decision. * - * `renderDecisionBadge` is TOLD a decision rather than handed a log row to - * interrogate (PR #11 review). It used to take an `EnvelopeLogEntry` and dig - * `entry.envelope.result.decision` out of it, which made "render this - * decision" read as "ask this log line what it contains" and tied U21 to the - * one artifact that happens to carry a decision today. The digging now lives - * in `outcomeMessageOf`, one named translation from an S6 line to the small - * message the renderers actually need; anything else able to build that - * message can use them without owning an envelope log. + * `renderDecisionBadge` is told a decision rather than handed a log row to + * interrogate: `outcomeMessageOf` is the one place that reads an envelope's + * shape, translating a log line into the small message the renderers need, + * so a caller can build and render that message without owning an envelope + * log at all. * - * A DECISION AND AN ERROR ARE TWO OUTCOMES, NOT TWO DECISIONS (PR #11 review, - * second pass). The message used to be one type called `DecisionMessage` whose - * second arm was a JSON-RPC error, and both arms went through - * `renderDecisionBadge` -- so U21, the affordance whose whole job is making an - * ACS decision legible, was also the thing that rendered responses carrying no - * decision at all. The slices doc's own watch-for says the opposite in as many - * words: a schema-invalid envelope "is answered with a JSON-RPC error", and - * that is deliberately not a decision, because turning Guardian-side failures - * into honoured ACS denies is N27 and belongs to a later slice. A type and a - * function that said otherwise taught the reader the thing that slice exists to - * correct. - * - * So the union is `OutcomeMessage`, discriminated, and each arm has its own - * renderer: `renderDecisionBadge` (U21, decisions only) and `renderRpcError` - * (a response that carried no decision). `renderOutcome` dispatches, and is - * what the stream renderer calls. + * A decision and an error are two outcomes, not two decisions: a + * schema-invalid envelope comes back as a JSON-RPC error, which is + * deliberately not the same thing as an ACS `deny`. `OutcomeMessage` is a + * discriminated union over the two, with its own renderer per arm -- + * `renderDecisionBadge` for a decision, `renderRpcError` for a response that + * carried none -- and `renderOutcome` dispatches between them for the + * stream renderer. */ import type { EnvelopeLogEntry } from "./tail-envelope-log.ts"; @@ -47,9 +36,9 @@ const DIM = "\u001b[2m"; export type PolicyReference = { policy_id?: string; policy_version?: string; rule_id?: string }; /** - * U21's message: what a caller tells the decision badge, already narrowed to - * the ACS fields it renders. Only ever an actual ACS decision -- see - * `OutcomeMessage` for why that is now the type's whole content. + * What a caller tells the decision badge, already narrowed to the ACS + * fields it renders. Only ever an actual ACS decision -- see + * `OutcomeMessage` for why this type covers only decisions. */ export type DecisionMessage = { decision: string; @@ -61,8 +50,8 @@ export type DecisionMessage = { export type RpcErrorMessage = { code: number | null; message: string }; /** - * What one S6 response line reports about its step: a decision, or the - * JSON-RPC error that stood in place of one. + * What one envelope-log response line reports about its step: a decision, + * or the JSON-RPC error that stood in place of one. * * A discriminated union rather than one object with an optional `error` beside * a `decision`, because a response carries exactly one of them and the other @@ -121,8 +110,8 @@ function formatReferences(references: PolicyReference[]): string[] { } /** - * What one S6 line reports, or null when it reports no outcome at all: a - * request, or a response such as a ServerHello. + * What one envelope-log line reports, or null when it reports no outcome at + * all: a request, or a response such as a ServerHello. * * The one place in this module that reads an envelope's shape. It is a * translation, not a collaboration -- it turns an artifact into the message the @@ -159,13 +148,11 @@ export function outcomeMessageOf(entry: EnvelopeLogEntry): OutcomeMessage | null /** * A response that carried no decision, rendered as the error it was. * - * Its own function rather than an arm of the decision badge (PR #11 review, - * second pass): what it renders is the absence of a decision, and in this slice - * that is exactly the boundary the runbook and the slices doc both draw -- - * a schema-invalid envelope comes back as a JSON-RPC error, and N27, which - * turns Guardian-side failures into honoured ACS denies, is a later slice. The - * line looks like the badge beside it on purpose; what changed is that no type - * and no function calls it a decision any more. + * Its own function rather than an arm of the decision badge: a + * schema-invalid envelope comes back as a JSON-RPC error, which this module + * treats as a distinct outcome from an ACS decision, never as an implicit + * `deny`. The line looks like the badge beside it on purpose, but no type or + * function here calls it a decision. */ export function renderRpcError(message: RpcErrorMessage, options: RenderOptions = {}): string { const color = options.color ?? false; @@ -174,7 +161,7 @@ export function renderRpcError(message: RpcErrorMessage, options: RenderOptions return paint(`✖ ERROR ${code}${text ? ` ${text}` : ""}`, RED, color); } -/** U21. Renders the ACS decision it is given. */ +/** Renders the ACS decision it is given. */ export function renderDecisionBadge(message: DecisionMessage, options: RenderOptions = {}): string { const color = options.color ?? false; @@ -188,14 +175,10 @@ export function renderDecisionBadge(message: DecisionMessage, options: RenderOpt // A policy fired and the action still proceeded. ACS carries that as // `allow` with a non-empty `policy_references`, and rendering it // identically to a clean allow is exactly what this badge exists to - // prevent (slices doc, section V2). + // prevent. // - // The label used to name the policy runtime's own word for this case, - // which ACS does not have -- the comment above it said so in the same - // breath. R5.2 exists so this package carries no policy-runtime - // vocabulary at all, and a string on screen teaches it more effectively - // than an identifier would. What is left is what ACS itself says - // happened (PR #11 review). + // This package carries no policy-runtime vocabulary: the label says only + // what ACS itself reports, not the policy engine's own name for the case. head = paint("◐ ALLOW (policy fired)", YELLOW, color); } else if (message.decision === "allow") { head = paint("○ ALLOW", GREEN, color); @@ -217,27 +200,26 @@ export function renderDecisionBadge(message: DecisionMessage, options: RenderOpt return parts.join(" "); } -/** The line one S6 outcome renders as -- U21's badge for a decision, the error - * line for a response that carried none. One dispatch, so the stream renderer - * does not have to know the arms apart. */ +/** The line one envelope-log outcome renders as -- the decision badge for a + * decision, the error line for a response that carried none. One dispatch, + * so the stream renderer does not have to know the arms apart. */ export function renderOutcome(message: OutcomeMessage, options: RenderOptions = {}): string { return message.kind === "decision" ? renderDecisionBadge(message, options) : renderRpcError(message, options); } /** - * U20. Header line, optional badge line, then the envelope as pretty JSON. + * Header line, optional badge line, then the envelope as pretty JSON. * - * What the body shows is the JSON value S6 recorded, printed unmodified: - * nothing here strips a field, redacts a value, or reorders anything. It is - * not a byte-for-byte replay of the wire, and this comment used to say it - * was (whole-branch review, finding 2). The Guardian records `await req.json()`, - * so the parse has already collapsed duplicate keys, canonicalised number - * literals (`1.0` -> `1`), and hoisted integer-like object keys ahead of the - * rest -- and tool argument names are host-controlled, so `arguments` really - * can carry a key like `"0"`. Storing raw bytes instead would make - * `entry.envelope` a string rather than a JSON value, which costs the - * pretty-printing below and the round-trip contract test; an accurate - * sentence is the better trade. + * What the body shows is the JSON value the envelope log recorded, printed + * unmodified: nothing here strips a field, redacts a value, or reorders + * anything. It is not a byte-for-byte replay of the wire -- the Guardian + * records `await req.json()`, so the parse has already collapsed duplicate + * keys, canonicalised number literals (`1.0` -> `1`), and hoisted + * integer-like object keys ahead of the rest, and tool argument names are + * host-controlled, so `arguments` really can carry a key like `"0"`. Storing + * raw bytes instead would make `entry.envelope` a string rather than a JSON + * value, which costs the pretty-printing below and the round-trip contract + * test. */ export function renderEnvelopeLogEntry(entry: EnvelopeLogEntry, options: RenderOptions = {}): string { const color = options.color ?? false; @@ -251,9 +233,8 @@ export function renderEnvelopeLogEntry(entry: EnvelopeLogEntry, options: RenderO // `JSON.stringify` returns `undefined` -- not a string -- for an entry // whose `envelope` key is absent, and `join` would coerce that to an empty // line indistinguishable from a real blank body. `isEnvelopeLogEntryShape` - // does not require `envelope` (it is `unknown` by design), so a hand-written - // or truncated S6 line reaches here without one. Narrowed the way the badge - // path above narrows (whole-branch review, finding 8). + // does not require `envelope` (it is `unknown` by design), so a + // hand-written or truncated envelope-log line can reach here without one. const body = JSON.stringify(entry.envelope, null, options.indent ?? 2) ?? "(no envelope recorded)"; return [header, ...(outcome === null ? [] : [outcome]), body].join("\n"); diff --git a/packages/inspector/src/tail-envelope-log.ts b/packages/inspector/src/tail-envelope-log.ts index 77b1eeb4..7abc01f1 100644 --- a/packages/inspector/src/tail-envelope-log.ts +++ b/packages/inspector/src/tail-envelope-log.ts @@ -1,24 +1,20 @@ /** - * tailEnvelopeLog (N50) streams S6 -- the Guardian's JSONL envelope log -- - * as it grows, the way `tail -f` does. + * tailEnvelopeLog streams the Guardian's JSONL envelope log as it grows, the + * way `tail -f` does. * * This package deliberately imports nothing from `guardian` or from any of - * its dependencies (global constraint 10). The Inspector reads a file that - * the Guardian happens to write; it holds no compile-time knowledge of the - * process that produced it, which is the point of R5.1 -- envelopes are - * inspectable *on the wire*, not through our own type graph. EnvelopeLogEntry - * is therefore re-declared here rather than imported. The round-trip test at - * test/envelope-log-sink-roundtrip.test.ts is what keeps the two declarations in - * agreement; if they drift, it fails. + * its dependencies. The Inspector reads a file that the Guardian happens to + * write, and holds no compile-time knowledge of the process that produced + * it: envelopes are inspectable on the wire, not through a shared type + * graph. `EnvelopeLogEntry` is therefore re-declared here rather than + * imported. The round-trip test at test/envelope-log-sink-roundtrip.test.ts + * keeps the two declarations in agreement; if they drift, it fails. * - * The names are the artifact's, not the writer's (PR #11 review). This - * package's public surface used to carry `TapEntry` / `TapDirection` -- - * the Guardian's nickname for its own writing mechanism, on a module whose - * whole job is reading -- and a bare `TailOptions` that named no log at all. - * A later slice gives this package a second stream to follow, and the two - * only read as siblings if each names its own log: `EnvelopeLogEntry` beside - * that stream's entry type, `TailEnvelopeLogOptions` beside its options type, - * the way `tailEnvelopeLog` and its twin verb already do. + * The names are the artifact's, not the writer's: `EnvelopeLogEntry` and + * `TailEnvelopeLogOptions` name the log they describe, not the Guardian's + * own writing mechanism, so a second log stream in this package would read + * as this one's sibling rather than force a shared, ambiguous vocabulary + * between them. * * Polling rather than fs.watch: appends to a growing file are exactly the * case where watch semantics differ most across platforms, and a 120ms poll @@ -65,7 +61,7 @@ import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs"; export type EnvelopeLogDirection = "request" | "response"; -/** One line of S6, as written by the Guardian's envelope log sink. */ +/** One line of the envelope log the Guardian writes. */ export type EnvelopeLogEntry = { seq: number; recorded_at: string; @@ -91,16 +87,16 @@ export type TailEnvelopeLogOptions = { const NEWLINE = 0x0a; /** - * S6 is a plain file on disk; anything can write a line to it that is valid - * JSON but not a valid EnvelopeLogEntry (a number where recorded_at should be - * a string, a missing direction, ...). `renderEnvelopeLogEntry`'s `clockOf` - * calls `.slice` on `recorded_at` unconditionally, so an unchecked cast would - * let such a line reach the renderer and throw -- inside a `for await` loop, - * that kills the whole stream. Checked here instead, right after - * `JSON.parse`, using exactly the fields the renderer depends on. - * `envelope` is deliberately left unconstrained: it is `unknown` by design - * (R5.1 -- see the module doc above), not a shape this function's job to - * police. + * The envelope log is a plain file on disk; anything can write a line to it + * that is valid JSON but not a valid EnvelopeLogEntry (a number where + * recorded_at should be a string, a missing direction, ...). + * `renderEnvelopeLogEntry`'s `clockOf` calls `.slice` on `recorded_at` + * unconditionally, so an unchecked cast would let such a line reach the + * renderer and throw -- inside a `for await` loop, that kills the whole + * stream. Checked here instead, right after `JSON.parse`, using exactly the + * fields the renderer depends on. `envelope` is deliberately left + * unconstrained: it is `unknown` by design, not a shape this function's job + * to police. */ function isEnvelopeLogEntryShape(value: unknown): value is EnvelopeLogEntry { if (typeof value !== "object" || value === null) { @@ -178,16 +174,15 @@ export function tailEnvelopeLog({ // `size`, so any complete line still sitting in `pending` // would never be re-scanned -- no later tick has anything new // to read. Reporting one bad line must not cost the good ones - // behind it (whole-branch review, finding 5). + // behind it. reportMalformedLine(onMalformedLine, line, error); } } } finally { // In the `finally`, not after the loop: an entry already pushed to // `ready` must reach the consumer even if the scan above left by a - // throw. It used to sit undelivered until some unrelated write -- - // or the abort -- happened to fire `wake` (whole-branch review, - // finding 5). + // throw, rather than sit undelivered until some unrelated write or + // the abort happens to fire `wake`. if (added) { wake?.(); } @@ -203,10 +198,10 @@ export function tailEnvelopeLog({ // own. // // The only route here is a failed read: a caller-supplied - // `onMalformedLine` is guarded at its own call site (finding 5), so it - // no longer reaches this catch and no longer abandons the rest of a - // batch. The tail-envelope-log tests cover this branch through a - // deterministic EISDIR rather than through a lost race. + // `onMalformedLine` is guarded at its own call site above, so it never + // reaches this catch and never abandons the rest of a batch. The + // tail-envelope-log tests cover this branch through a deterministic + // EISDIR rather than through a lost race. warnPollError(error); } } diff --git a/packages/inspector/test/render.test.ts b/packages/inspector/test/render.test.ts index 63f84990..af5cd960 100644 --- a/packages/inspector/test/render.test.ts +++ b/packages/inspector/test/render.test.ts @@ -26,10 +26,11 @@ function response(result: Record): EnvelopeLogEntry { return entry({ envelope: { jsonrpc: "2.0", id: 1, result } }); } -/** The message renderEnvelopeLogEntry would build for this S6 line, for the - * badge tests that assert the rendered string end to end. Throws rather than - * asserting non-null inline, so a line that stopped carrying an outcome fails - * as itself instead of as a confusing `toBe` diff. */ +/** The message renderEnvelopeLogEntry would build for this envelope-log + * line, for the badge tests that assert the rendered string end to end. + * Throws rather than asserting non-null inline, so a line that stopped + * carrying an outcome fails as itself instead of as a confusing `toBe` + * diff. */ function messageOf(line: EnvelopeLogEntry): OutcomeMessage { const message = outcomeMessageOf(line); if (message === null) { @@ -51,8 +52,8 @@ describe("outcomeMessageOf -- what the renderers are told about", () => { expect(outcomeMessageOf(response({ negotiated_version: "0.1.0", on_decision_failure: "proceed" }))).toBeNull(); }); - // The reshape's point (PR #11 review): the badge is handed ACS fields, not - // a log row to dig through, so everything it renders is decided here. + // The badge is handed ACS fields, not a log row to dig through, so + // everything it renders is decided here. it("narrows the ACS fields the badge renders, and drops the rest of the envelope", () => { expect( outcomeMessageOf( @@ -79,9 +80,9 @@ describe("outcomeMessageOf -- what the renderers are told about", () => { }); }); - // PR #11 review, second pass. The discriminant is the point: a caller can - // tell an outcome that IS a decision from one that stood in for the absence - // of one without inspecting which fields happen to be present. + // The discriminant is the point: a caller can tell an outcome that IS a + // decision from one that stood in for the absence of one without + // inspecting which fields happen to be present. it("discriminates a decision from an error, so nothing has to infer which arm it holds", () => { expect(outcomeMessageOf(response({ decision: "allow" }))?.kind).toBe("decision"); expect(outcomeMessageOf(entry({ envelope: { jsonrpc: "2.0", id: 1, error: { code: -32010 } } }))?.kind).toBe( @@ -114,15 +115,13 @@ describe("renderDecisionBadge (U21)", () => { expect(badgeFor({ decision: "allow" })).toBe("○ ALLOW"); }); - // The reason U21 exists, per the slices doc: a policy that fired and let - // the action proceed arrives as an ACS `allow` with a non-empty - // policy_references, and the badge is what keeps it from being buried. + // The reason this badge exists: a policy that fired and let the action + // proceed arrives as an ACS `allow` with a non-empty policy_references, + // and the badge is what keeps it from being buried. // - // PR #11 review: the label used to end with the policy runtime's own name - // for that case, a disposition ACS does not have. R5.2 keeps this package - // clear of policy-runtime vocabulary, and rendered text teaches it more - // loudly than an identifier would -- so the label now says only what ACS - // says happened, and the last assertion holds the line. + // This package carries no policy-runtime vocabulary, so the label says + // only what ACS itself reports -- not the policy engine's own name for + // the case -- and the last assertion holds that line. it("distinguishes an allow that carries policy_references, without naming a disposition ACS lacks", () => { const badge = badgeFor({ decision: "allow", @@ -137,11 +136,11 @@ describe("renderDecisionBadge (U21)", () => { expect(badge).not.toContain("warn"); }); - // Pins current behaviour (backlog item H): ACS's schemas do not require - // `rule_id` on a policy_reference, so this is a real shape, not a - // hypothetical one. Dropping to the bare policy_id here is a deliberate - // degradation, not a bug -- this test exists so a future change to it is - // a decision, not an accident. + // Pins current behaviour: ACS's schemas do not require `rule_id` on a + // policy_reference, so this is a real shape, not a hypothetical one. + // Dropping to the bare policy_id here is a deliberate degradation, not a + // bug -- this test exists so a future change to it is a decision, not an + // accident. it("renders a policy_reference with no rule_id as the bare policy_id", () => { const badge = badgeFor({ decision: "deny", policy_references: [{ policy_id: "agt_stock" }] }); @@ -154,10 +153,10 @@ describe("renderDecisionBadge (U21)", () => { expect(badgeFor({ decision: "defer" })).toBe("◆ DEFER"); }); - // Renders through `renderOutcome`, which is what the stream renderer calls: - // an error reaches `renderRpcError`, never U21's decision badge (PR #11 - // review, second pass). `renderDecisionBadge` cannot be handed one at all - // now -- `DecisionMessage` has no error arm to pass it. + // Renders through `renderOutcome`, which is what the stream renderer + // calls: an error reaches `renderRpcError`, never the decision badge. + // `renderDecisionBadge` cannot be handed one at all -- `DecisionMessage` + // has no error arm to pass it. it("renders a JSON-RPC error as an error, not as a decision badge", () => { const line = renderOutcome( messageOf(entry({ envelope: { jsonrpc: "2.0", id: 1, error: { code: -32010, message: "ACS envelope failed" } } })), @@ -181,11 +180,10 @@ describe("renderDecisionBadge (U21)", () => { expect(coloured).toContain("DENY"); }); - // Backlog item I: with color:true, only the glyph and decision label used - // to be painted, so a coloured badge read as one coloured half and one - // plain half. The appended segments are painted dim so the whole badge - // reads as one unit; color:false stays byte-identical (asserted by the - // exact-string tests above, which are unchanged). + // With color:true, painting only the glyph and decision label would make + // a coloured badge read as one coloured half and one plain half. The + // appended segments are painted dim so the whole badge reads as one unit; + // color:false stays byte-identical to the exact-string tests above. it("paints the appended reason_codes/policy_references segments dim when coloured", () => { const coloured = badgeFor( { @@ -233,11 +231,10 @@ describe("renderEnvelopeLogEntry (U20)", () => { expect(rendered.split("\n")[0]).toBe("── #3 12:04:31.221 ← RESPONSE (no method) (unpaired)"); }); - // Retitled by the whole-branch review (finding 2) -- the assertions are - // unchanged. What this has always checked is that the JSON *value* round - // trips: nothing stripped, nothing reordered, only whitespace reshaped. - // "Verbatim" claimed more than that, since S6 stores the value the - // Guardian parsed rather than the bytes the host sent. + // What this checks is that the JSON *value* round trips: nothing stripped, + // nothing reordered, only whitespace reshaped. Not that the bytes round trip + // -- the envelope log stores the value the Guardian parsed, not the bytes + // the host sent. it("changes nothing but whitespace -- the envelope value round trips through the renderer", () => { const envelope = { jsonrpc: "2.0", id: 1, result: { decision: "allow", nested: { deep: [1, 2] } } }; const rendered = renderEnvelopeLogEntry(entry({ envelope })); @@ -246,11 +243,11 @@ describe("renderEnvelopeLogEntry (U20)", () => { expect(JSON.parse(rendered.slice(jsonStart))).toEqual(envelope); }); - // Whole-branch review, finding 8. `isEnvelopeLogEntryShape` deliberately does not - // constrain `envelope`, so a hand-written or truncated S6 line reaches the - // renderer with the key missing entirely. `JSON.stringify(undefined)` - // returns `undefined`, which `join` would coerce into a blank line - // indistinguishable from a real empty body. + // `isEnvelopeLogEntryShape` deliberately does not constrain `envelope`, so + // a hand-written or truncated log line reaches the renderer with the key + // missing entirely. `JSON.stringify(undefined)` returns `undefined`, + // which `join` would coerce into a blank line indistinguishable from a + // real empty body. it("marks an entry whose envelope key is absent, instead of emitting a blank body", () => { const withoutEnvelope = { seq: 3, diff --git a/packages/inspector/test/tail-envelope-log.test.ts b/packages/inspector/test/tail-envelope-log.test.ts index 8fc1132e..b8ee871c 100644 --- a/packages/inspector/test/tail-envelope-log.test.ts +++ b/packages/inspector/test/tail-envelope-log.test.ts @@ -305,13 +305,11 @@ describe("tailEnvelopeLog (N50)", () => { }); }); - // Whole-branch review, finding 5. `if (added) wake?.()` used to sit after - // `onMalformedLine(...)` inside the same `try`, so a reporter that threw - // skipped the wake-up entirely: an entry already pushed to `ready` sat - // undelivered until some later write -- or the abort -- happened to fire - // `wake` for an unrelated reason. Reproduced at 300ms of silence before the - // fix. The good line here is written BEFORE the bad one so that `added` is - // already true when the throw happens. + // `if (added) wake?.()` must run even when a reporter throws mid-scan: an + // entry already pushed to `ready` must not sit undelivered until some + // later write -- or the abort -- happens to fire `wake` for an unrelated + // reason. The good line here is written BEFORE the bad one so that + // `added` is already true when the throw happens. // A throwing onMalformedLine reaches `reportMalformedLine`'s own catch, // which prints one stderr line of its own (`onMalformedLine threw while // reporting...`). Spied and silenced so this deliberately-adversarial @@ -359,11 +357,10 @@ describe("tailEnvelopeLog (N50)", () => { }); }); - // The other half of finding 5: `offset` is already at `size` by the time a - // line is scanned, so a batch abandoned mid-scan strands every complete - // line still in `pending` -- no later tick has anything new to read, and - // they are never re-scanned. Guarding the reporter at its own call site is - // what keeps the scan going. + // `offset` is already at `size` by the time a line is scanned, so a batch + // abandoned mid-scan strands every complete line still in `pending` -- no + // later tick has anything new to read, and they are never re-scanned. + // Guarding the reporter at its own call site is what keeps the scan going. // Same stray-stderr source as the test above: `reportMalformedLine`'s own // catch prints when the caller's reporter throws. Spied, silenced, and // asserted on for the same reason. @@ -392,13 +389,12 @@ describe("tailEnvelopeLog (N50)", () => { }); }); - // `poll()`'s outer try/catch was carried through the task loop as "possibly - // unreachable, definitely untested". It is reachable, and it does not need - // a lost race to get there: pointing the tail at a directory makes - // `existsSync` true and `statSync().size` non-zero, so the read is - // attempted and `readSync` throws EISDIR every tick. Without the catch, - // that throw leaves a bare timer callback and takes the process down. - // `poll()`'s outer catch warns on every failed tick (`envelope log poll + // `poll()`'s outer try/catch is reachable, and does not need a lost race + // to get there: pointing the tail at a directory makes `existsSync` true + // and `statSync().size` non-zero, so the read is attempted and `readSync` + // throws EISDIR every tick. Without the catch, that throw would leave a + // bare timer callback and take the process down. `poll()`'s outer catch + // warns on every failed tick (`envelope log poll // failed, retrying next tick ...`) -- the "warns and retries" behaviour // this test's own name claims. Spied and silenced so the several EISDIR // ticks below do not print real stderr into a clean `bun test` run, and @@ -464,8 +460,8 @@ describe("tailEnvelopeLog (N50)", () => { }); }); - // Backlog item K. `isEnvelopeLogEntryShape` deliberately never constrains - // `envelope` -- it is `unknown` by design (R5.1) -- so a line whose + // `isEnvelopeLogEntryShape` deliberately never constrains `envelope` -- it + // is `unknown` by design -- so a line whose // `envelope` is absent, `null`, or a bare string is still a valid // EnvelopeLogEntry as far as this function is concerned, and reaches the // consumer rather than being reported through `onMalformedLine`. That is From 3da02a030bc9438f81b03f5e3c0a34a218876fa3 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Mon, 17 Aug 2026 11:24:09 +0300 Subject: [PATCH 34/35] Drop the shaping identifiers from test names too Same rule as the previous slice: a test name is read in test output by someone with no access to the shaping documents, so it says what it checks rather than citing an affordance number for it. Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr --- packages/guardian/test/envelope-log-sink-wiring.test.ts | 2 +- packages/guardian/test/envelope-log-sink.test.ts | 4 ++-- packages/guardian/test/server.test.ts | 4 ++-- packages/inspector/test/render.test.ts | 4 ++-- packages/inspector/test/tail-envelope-log.test.ts | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/guardian/test/envelope-log-sink-wiring.test.ts b/packages/guardian/test/envelope-log-sink-wiring.test.ts index 2278a2b3..ed908627 100644 --- a/packages/guardian/test/envelope-log-sink-wiring.test.ts +++ b/packages/guardian/test/envelope-log-sink-wiring.test.ts @@ -75,7 +75,7 @@ async function postRaw(url: string, body: string): Promise { const logIn = (dir: string) => join(dir, "envelopes.jsonl"); -describe("Guardian envelope log wiring (N26 x N20)", () => { +describe("Guardian envelope log wiring", () => { it("records one request and one response per exchange, paired by rpc_id", async () => { await withGuardian(logIn, async (url, logPath) => { await postRaw(url, JSON.stringify(toolCallEnvelope("rm -rf /", { id: 11 }))); diff --git a/packages/guardian/test/envelope-log-sink.test.ts b/packages/guardian/test/envelope-log-sink.test.ts index a37b42db..1424bd8c 100644 --- a/packages/guardian/test/envelope-log-sink.test.ts +++ b/packages/guardian/test/envelope-log-sink.test.ts @@ -36,7 +36,7 @@ function readEntries(path: string): EnvelopeLogEntry[] { const REQUEST = { jsonrpc: "2.0", method: "steps/toolCallRequest", id: 7, params: { acs_version: "0.1.0" } }; const RESPONSE = { jsonrpc: "2.0", id: 7, result: { decision: "deny" } }; -describe("createEnvelopeLogSink (N26) -- S6's JSONL format", () => { +describe("createEnvelopeLogSink -- the envelope log's JSONL format", () => { it("writes one line per call, with a monotonic seq starting at 1", () => { withTempDir((dir) => { const path = join(dir, "envelopes.jsonl"); @@ -55,7 +55,7 @@ describe("createEnvelopeLogSink (N26) -- S6's JSONL format", () => { // nothing stripped, nothing reordered. This is not byte identity with the // wire: the sink is handed the already-parsed result of `await // req.json()`. - it("records the envelope unmodified -- constraint 11, no reformatting or stripping", () => { + it("records the envelope unmodified -- no reformatting or stripping", () => { withTempDir((dir) => { const path = join(dir, "envelopes.jsonl"); createEnvelopeLogSink({ path }).write("request", REQUEST, "steps/toolCallRequest"); diff --git a/packages/guardian/test/server.test.ts b/packages/guardian/test/server.test.ts index 9056c512..33791346 100644 --- a/packages/guardian/test/server.test.ts +++ b/packages/guardian/test/server.test.ts @@ -498,7 +498,7 @@ describe("startGuardian POST /acs -- the outer net around dispatch", () => { }); }); - it("carries no decision -- N27 is V3, so a Guardian-side failure is an error, not a synthesized deny", async () => { + it("carries no decision -- a Guardian-side failure is an error here, not a synthesized deny", async () => { await withSchemalessGuardian(async ({ url }) => { const response = await postAcs(url, toolCallEnvelope("rm -rf /")); @@ -508,7 +508,7 @@ describe("startGuardian POST /acs -- the outer net around dispatch", () => { }); }); - it("records both the request and the response, so S6 has no unrecorded exit", async () => { + it("records both the request and the response, so the envelope log has no unrecorded exit", async () => { await withSchemalessGuardian(async ({ url, logPath }) => { await postAcs(url, toolCallEnvelope("ls -la", { id: 11 })); diff --git a/packages/inspector/test/render.test.ts b/packages/inspector/test/render.test.ts index af5cd960..a91bad65 100644 --- a/packages/inspector/test/render.test.ts +++ b/packages/inspector/test/render.test.ts @@ -91,7 +91,7 @@ describe("outcomeMessageOf -- what the renderers are told about", () => { }); }); -describe("renderDecisionBadge (U21)", () => { +describe("renderDecisionBadge", () => { // The renderer needs no envelope log at all now: anything that can build // the message can use the badge. it("renders a message built by hand, with no log entry anywhere in sight", () => { @@ -199,7 +199,7 @@ describe("renderDecisionBadge (U21)", () => { }); }); -describe("renderEnvelopeLogEntry (U20)", () => { +describe("renderEnvelopeLogEntry", () => { it("renders a request as a header line plus pretty JSON, with no badge", () => { const rendered = renderEnvelopeLogEntry( entry({ diff --git a/packages/inspector/test/tail-envelope-log.test.ts b/packages/inspector/test/tail-envelope-log.test.ts index b8ee871c..b4d4de64 100644 --- a/packages/inspector/test/tail-envelope-log.test.ts +++ b/packages/inspector/test/tail-envelope-log.test.ts @@ -55,7 +55,7 @@ function withTempDir(run: (dir: string, path: string) => Promise): Promise }); } -describe("tailEnvelopeLog (N50)", () => { +describe("tailEnvelopeLog", () => { it("yields entries appended after the tail starts, skipping what was already there", async () => { await withTempDir(async (_dir, path) => { writeFileSync(path, entryLine(1, "request")); From 2514816dab4bfc223b388148c1450c2cebd449b6 Mon Sep 17 00:00:00 2001 From: Ariel Fogel Date: Mon, 17 Aug 2026 12:01:21 +0300 Subject: [PATCH 35/35] Extend the same pass to the integration tests and the startup banner The envelope-log round trip and the invariant gates still cited requirement and affordance numbers, and the Guardian's startup banner printed one to the operator's terminal, where it is least resolvable of all. Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr --- packages/guardian/src/main.ts | 2 +- test/envelope-log-sink-roundtrip.test.ts | 14 ++++++------ test/invariants.test.ts | 29 ++++++++++++------------ 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/packages/guardian/src/main.ts b/packages/guardian/src/main.ts index 6d26476e..809ac15e 100644 --- a/packages/guardian/src/main.ts +++ b/packages/guardian/src/main.ts @@ -31,4 +31,4 @@ const envelopeLogPath = process.env.ACS_ENVELOPE_LOG ?? DEFAULT_ENVELOPE_LOG; const guardian = await startGuardian({ port, hostname, manifestPath, envelopeLogPath }); console.log(`Guardian listening at ${guardian.url}`); -console.log(`Envelope log (S6): ${envelopeLogPath}`); +console.log(`Envelope log: ${envelopeLogPath}`); diff --git a/test/envelope-log-sink-roundtrip.test.ts b/test/envelope-log-sink-roundtrip.test.ts index d941cfae..e60d297b 100644 --- a/test/envelope-log-sink-roundtrip.test.ts +++ b/test/envelope-log-sink-roundtrip.test.ts @@ -7,8 +7,8 @@ import { tailEnvelopeLog, type EnvelopeLogEntry } from "../packages/inspector/sr import { outcomeMessageOf, renderOutcome, type OutcomeMessage } from "../packages/inspector/src/render.ts"; /** - * The contract test for S6. The Guardian writes the log; the Inspector - * declares its own EnvelopeLogEntry and reads it back (global constraint 10). + * The contract test for the envelope log. The Guardian writes it; the + * Inspector declares its own EnvelopeLogEntry and reads it back. * If either side renames a field, adds a required one, or changes a type, * this is what fails -- nothing else would, because the two never share a * type. @@ -49,7 +49,7 @@ async function take( return out; } -describe("S6 round trip: Guardian envelope log sink (N26) -> Inspector tail (N50) -> badge (U21)", () => { +describe("envelope log round trip: Guardian sink -> Inspector tail -> decision badge", () => { it("a denied tool call arrives as a paired request/response the Inspector can render", async () => { const dir = mkdtempSync(join(tmpdir(), "acs-roundtrip-")); const logPath = join(dir, "envelopes.jsonl"); @@ -83,10 +83,10 @@ describe("S6 round trip: Guardian envelope log sink (N26) -> Inspector tail (N50 expect(request?.rpc_id).toBe(77); expect(response?.rpc_id).toBe(77); - // ...and the badge reads a real AGT-backed decision off it. Two steps - // now, not one: outcomeMessageOf turns the S6 line into what the step - // reported and the renderer renders that message (PR #11 review). Both - // halves are exercised here deliberately -- the round trip's claim is + // ...and the badge reads a real AGT-backed decision off it. Two steps, + // not one: outcomeMessageOf turns the log line into what the step + // reported, and the renderer renders that message. Both halves are + // exercised here deliberately -- the round trip's claim is // that a real Guardian's real output survives all the way to a rendered // badge, including that it arrives as a decision rather than an error. const message = outcomeMessageOf(response as EnvelopeLogEntry); diff --git a/test/invariants.test.ts b/test/invariants.test.ts index 74b3d1cd..04d5387c 100644 --- a/test/invariants.test.ts +++ b/test/invariants.test.ts @@ -143,8 +143,8 @@ describe("architectural invariants", () => { }); /** - * R5.2 -- "an ACS-first reader can trace one action end to end without - * reading AGT source". The Inspector is that reader's tool, so the claim + * An ACS-first reader must be able to trace one action end to end without + * reading AGT source. The Inspector is that reader's tool, so the claim * is only real if the tool itself knows nothing about AGT and nothing * about any particular host: it renders ACS envelopes as data. Both term * lists from the two gates above apply to it at once. @@ -157,13 +157,12 @@ describe("architectural invariants", () => { "opa", "intervention_point", "verdict", - // The three AGT VERDICT NAMES, added by PR #11's review response and - // the reason it was worth adding them: this gate listed the word - // "verdict" but none of the verdicts, so the Inspector shipped a badge - // reading `ALLOW (policy fired -- ACS "warn")` with a green suite. ACS - // has no `warn` disposition; that string taught a reader AGT's - // vocabulary from an ACS-first tool, which is the exact leak R5.2 - // exists to prevent, and the gate said nothing. + // The three AGT verdict names. Listing the word "verdict" without the + // verdicts themselves is not enough: it let a badge reading + // `ALLOW (policy fired -- ACS "warn")` pass with a green suite. ACS has + // no `warn` disposition, so that string taught a reader AGT's + // vocabulary from an ACS-first tool -- exactly the leak this gate + // exists to prevent. // // `allow`/`deny`/`ask`/`modify`/`defer` are deliberately NOT here -- // they are ACS's own dispositions and the Inspector must name them. @@ -180,10 +179,10 @@ describe("architectural invariants", () => { }); /** - * R5.1 -- envelopes are inspectable *on the wire*. If the Inspector - * imported the Guardian's types, "inspectable" would be a claim about our - * own type graph instead: any third-party reader of S6 has only the file. - * So does this one. + * Envelopes are inspectable *on the wire*. If the Inspector imported the + * Guardian's types, "inspectable" would be a claim about our own type graph + * instead: any third-party reader of the log has only the file, and so does + * this one. */ it("the Envelope Inspector imports nothing from the Guardian or the AGT bridge", () => { for (const { file, code } of readSourceFiles("packages/inspector/src")) { @@ -209,8 +208,8 @@ describe("architectural invariants", () => { * import("guardian").EnvelopeLogEntry * type position -- erased at build, still a * compile-time dependency on the Guardian's - * type graph, which is exactly what R5.1 - * forbids + * type graph, which is exactly what the gate + * above forbids * require("guardian") CJS interop * * `\(?` covers the parenthesised and unparenthesised forms in one pass, and