Flow: A tool call through the guard: one verdict, first block wins, always a record
Before any tool runs, the runtime raises tool_call and every extension’s handler answers in load order; the first { block: true, reason } ends the call, and nothing after it can un-block. Our guard is one handler with an ordered decide inside it: the fail-closed floor, the shell denies, the write gates, then every other path-bearing tool as a reader. The bash analyzer is a library under that handler, not a participant of its own, so the count stays at two (#4, #9). Every answer is a log record. A refused call reaches the browser as a result flagged isError (the runtime’s shape for any failure) and beside it the guard’s own custom entry naming the call and the rule, which is what the page draws a refusal from, live and on resume (#387).
The sequence
Section titled “The sequence”A shell command the analyzer denies
Section titled “A shell command the analyzer denies”Recorded by guards.test.ts: an analyzer deny is blocked with its rule identity in the reason. Arrows are what the test drove and what its doubles received; notes are what the code logged at debug and above, in the order it logged them.
sequenceDiagram
participant R as runtime (tool_call hook)
participant G as guards handler (extensions/guards/index.ts)
participant A as cc-safety-net (library)
R->>G: tool_call { toolName: "bash", command: "git reset --hard HEAD~3" }
G->>A: analyzeBashCommand({ command: "git reset --hard HEAD~3", cwd })
A->>G: { kind: "deny", ruleId: "git.reset-hard" }
Note over G: bash denied: Bash command blocked by cc-safety-net (git.reset-hard): git reset --hard destroys all uncommitted changes permanently.
G->>R: appendEntry("enso-guard-denial", {"toolCallId":"t","rule":"cc-safety-net"})
G->>R: { block: true, reason }
A shell command that passes, patched last
Section titled “A shell command that passes, patched last”Recorded by guards.test.ts: an allowed bash command still gets the timeout patch, after both gates. Arrows are what the test drove and what its doubles received; notes are what the code logged at debug and above, in the order it logged them.
sequenceDiagram
participant R as runtime (tool_call hook)
participant G as guards handler (extensions/guards/index.ts)
participant A as cc-safety-net (library)
R->>G: tool_call { toolName: "bash", command: "git status" }
G->>A: analyzeBashCommand({ command: "git status", cwd })
A->>G: { kind: "allow" }
Note over G: bash allowed
G->>R: undefined (the call runs)
R->>R: the command runs with input.timeout = 120
The invariants it shows
Section titled “The invariants it shows”Two tool_call participants, structurally.
Section titled “Two tool_call participants, structurally.”The manifest loads guards before the mode extension (packages/harness/package.json, pi.extensions), and cc-safety-net is a library under this handler, not a participant, so the count stays at two (#4, #9). The tripwire scans every declared extension tree for a tool_call registration and asserts set equality with those two. A third is #4’s arbiter trigger, and the test says not to add yourself to the list.
Stated at createGuards in packages/harness/extensions/guards/index.ts.
Pinned by:
tool-call-participants.test.ts: ⚠ exactly the two known tool_call participants — a third means #4’s arbiter is due
Shell: deny first, patch last; the analyzer is a library.
Section titled “Shell: deny first, patch last; the analyzer is a library.”For bash the cc-safety-net verdict is read first (a throw is a deny, per its contract), then the network deny by name at command position per segment, with a cannot vouch refusal for a construct it cannot resolve, then the environment-dump deny, then the secret-path deny. powershell skips the analyzer for the coarse raw scan. Only a command that passed them all gets defaultBashTimeoutSeconds, so no denied command has mutated input. What the analyzer allows (a redirect into its own config dir) is our layers’ job: the division of labour its contract test pins against defaults.
Stated at shellCommandDenial in packages/harness/extensions/guards/index.ts.
Pinned by:
cc-safety-net-contract.test.ts: allows a shell redirect into its own config dir — why .cc-safety-net is OUR persistence target
guards.test.ts: an analyzer deny is blocked with its rule identity in the reason
guards.test.ts: an analyzer THROW is a deny, per its contract
guards.test.ts: a DENIED bash command does not receive the timeout patch
guards.test.ts: an allowed bash command still gets the timeout patch, after both gates
The verdict is a denial with its reason, or nothing.
Section titled “The verdict is a denial with its reason, or nothing.”decide returns a GuardDenial or undefined, and the handler hands pi its own shape back: every refusal is { block: true, reason }, every pass is undefined. Refusing is final: handlers are additive and the runtime returns on the first block, whatever loads after (the file header). HostedThread.probeToolCall pins this through the runtime’s own hook runner, with the real load order and participants: the guards’ refusal with its reason, and first block wins, so a later extension cannot un-block and its own block stands.
Stated at guardsWithDependencies in packages/harness/extensions/guards/index.ts.
Pinned by:
agent-host.test.ts: ⚠ through the runtime, the real guards refuse a write outside the workspace with their reason; a read inside passes
agent-host.test.ts: ⚠ first block is final: a later extension cannot un-block the guards, and its own block on an allowed call stands
The floor comes first and fails closed.
Section titled “The floor comes first and fails closed.”A policy naming a tool the runtime does not register, or a tool list that cannot be read, denies EVERY call with the mismatch in the reason (#4 gap 2): such a policy is otherwise a silent no-op, since the guard compiles, loads and never fires. Checked against the LIVE tool list each session_start (extensions can register tools, so the list is per-session state), AND lazily on the first tool_call if session_start has not fired yet, so the floor never depends on pi’s event ordering: a lifecycle that delivered a tool_call first would otherwise fail OPEN through this very window (PR #52 review). The check cannot run at extension load (pi refuses action methods there: “Extension runtime not initialized”), but by the first tool_call the registry is certainly populated.
⚠ Deny-all via a variable, NEVER a throw: a throwing session_start handler wedges print mode outright (probed 2026-09-04: 120s hang, zero output). The unreadable-list case fails closed the same way: “could not check” must not render as “checked, fine”.
Stated at verifyPolicyToolNames in packages/harness/extensions/guards/index.ts.
Pinned by:
guards.test.ts: a mismatch denies the FIRST tool_call even when session_start never fired
Knowledge comes from results, never from calls.
Section titled “Knowledge comes from results, never from calls.”This handler records a file as seen only when isError is false: a blocked call produces no tool_result at all (verified in pi’s agent-loop: the “immediate” path skips afterToolCall), and a failed one arrives with isError, so neither can record.
Stated at guardsWithDependencies in packages/harness/extensions/guards/index.ts.
Pinned by:
guards.test.ts: a failed read confers no knowledge
Every verdict is a record (#132).
Section titled “Every verdict is a record (#132).”A denial at warn with the toolCallId, the tool, the path it named, the RULE that refused (#144: the word a day’s stats count by) and the reason; an allow at debug, never silent. The transcript shows the model what was refused; this says WHEN, on which call, in the file beside every other layer’s line about it.
Stated at guardsWithDependencies in packages/harness/extensions/guards/index.ts.
Pinned by:
guards.test.ts: ⚠ every verdict is a record: a denial at warn with the tool, the path and the reason; an allow at debug — never silent (#132)
The guard says it refused, in the log pi keeps (#387).
Section titled “The guard says it refused, in the log pi keeps (#387).”pi is handed its own shape back, { block, reason }, and never our rule. pi discards the block marker and turns a block into an error result indistinguishable from a failed command, and a blocked call never reaches a tool_result hook, so before returning the verdict the guard appends a custom entry, ENSO_GUARD_DENIAL_ENTRY with { toolCallId, rule }: the one thing of ours pi persists beside it. Live it crosses as a custom-entry observation into the thread’s guardDenials store; on a cold read transcript-to-messages.ts joins it onto its result as enso:guardDenial metadata, and adopting the history seeds the same store. The tool row and the status bar read only that store: a refusal is never recognised by its wording. pi’s context builder skips custom entries, so the model’s view is unchanged.
Stated at guardsWithDependencies in packages/harness/extensions/guards/index.ts.
Pinned by:
guards.test.ts: ⚠ a denial appends one guard-denial entry naming the call and the rule; an allow appends none
guards.test.ts: an analyzer deny is blocked with its rule identity in the reason
custom-event-router.test.ts: a guard-denial custom-entry marks its call refused; any other entry marks nothing (#387)
selected-message-part.test.tsx: ⚠ a refused call renders as the refusal card; the same error result without a denial is an error row
transcript-to-messages.test.ts: ⚠ a guard refusal rides its own result as metadata; a failed call with the same shape carries none (#387)
Writes are an inclusion list; readers are everything else.
Section titled “Writes are an inclusion list; readers are everything else.”write and edit are claimed before the reader fall-through and checked in order: outside the write roots (the cwd when none are configured), a protected fragment, a persistence target, a credential pattern, then the thread’s knowledge of the file. Stale refuses both tools; unknown and existing refuses write only, and so does partial: a read that returned part of the file (pi’s own cut, an offset, or any limit) stamps it for stale but is not the whole-file knowledge a write needs (#10, #11, #57 S2). Stale is distinct from unknown: the reason says changed, not never read.
Every other tool carrying a path is a reader and a credential pattern denies it: exclusion, because an inclusion list naming only read once let grep over ~/.aws through, and a tool the guard has never heard of is still a reader. A reader is also refused when its path holds a credential location beneath it (grep over ~ or / recurses into ~/.aws the same way: #160 §4, secretLocationBeneath), except ls and find, which return names, never contents. The locations are the fragments placed in home, and, where they exist, in the workspace and the harness’s own root (a pre-#421 .enso/ the migration left behind: a .gitignore does not keep ripgrep out, since it is ignored outside a git work tree and the agent may edit it). ~/.enso is in home, so it is always covered; once the checkout’s .enso/ is migrated, grep path=. at the root of the Enso checkout itself passes. Not seen: a fragment’s location anywhere else, and a basename-class file (.env) beneath the path, which only a walk would find.
Stated at decide in packages/harness/extensions/guards/index.ts.
Pinned by:
guards.test.ts: an unknown path-bearing tool is gated as a reader
guards.test.ts: write to a file that changed after the session read it is blocked as CHANGED
A refusal is an error result on the wire.
Section titled “A refusal is an error result on the wire.”⚠ isError has no AG-UI field on a tool result, and it is the only signal a REFUSED or ABORTED call has (a guard block; pi’s abort mid-tool): map-events.ts, tool_execution_end. TanStack’s processor reads state: "output-error" off the chunk (a non-spec extra, legal by BaseEvent’s index signature) and marks the result and its call error, with the content as the error text: the same shape transcript-to-messages builds from the transcript on resume, so a live transcript and a resumed one agree (#130), and the same shape a command that ran and failed has. Before this the flag rode as ensoIsError, which nothing read: live said done, resume said error.
The vendor’s half: a guard’s { block, reason } becomes an error result in the runtime’s own loop (pi-agent-core/dist/agent-loop.js, the beforeResult?.block branch: createErrorToolResult(reason), isError: true), pinned against the installed version by the agent-loop contract test, so a bump that moves it goes red.
Stated at observationToAguiEvents in to-agui.ts.
Pinned by:
to-agui.test.ts: an errored result carries the state TanStack reads, so a denial cannot render as success
agent-loop-contract.test.ts: ⚠ CONTRACT: pi-agent-core@${version} turns a blocking tool_call verdict into an error result carrying the reason
transcript-to-messages.test.ts: a refused tool call (isError) reads as an error on both the call and the result — a denial must not render as success
Where it is stated
Section titled “Where it is stated”guardsWithDependenciesinpackages/harness/extensions/guards/index.ts: the flow, one-verdict, knowledge-from-results, every-verdict-recorded, denial-entrycreateGuardsinpackages/harness/extensions/guards/index.ts: two-participantsshellCommandDenialinpackages/harness/extensions/guards/index.ts: shell-deny-firstverifyPolicyToolNamesinpackages/harness/extensions/guards/index.ts: fail-closed-floordecideinpackages/harness/extensions/guards/index.ts: writes-included-readers-excludedobservationToAguiEventsinto-agui.ts: refusal-is-error-result- the guards seam: the policy, the dependencies, decide’s order and the L5 crossing
- the network bar, and what it does not cover (its header)
- the words: guard, verdict, policy, rule, participant, arbiter, boundary vs bar (Permission)
