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
(guards/index.ts header). 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 —
and 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”sequenceDiagram
participant M as model
participant P as runtime (tool_call hook)
participant G as guards handler (extensions/guards/index.ts)
participant A as cc-safety-net (library)
participant L as log (.enso/logs)
participant H as host mapper (map-events.ts)
participant B as browser (to-agui.ts)
M->>P: tool call { toolName, input }
P->>G: tool_call — handlers stacked, first block wins
G->>G: canonicalToolName · floor: policy names ⊆ live tools?
alt bash / powershell
G->>A: analyzeBashCommand({ command, cwd }) — bash only · a throw is a deny
A-->>G: { kind, reason, ruleId? }
G->>G: egress deny (per segment · cannot-vouch floor) · environment-dump deny
G->>G: only a PASSED command gets defaultBashTimeoutSeconds
else write / edit
G->>G: write roots → protected fragment → persistence → credential → stale → unknown+exists
else any other tool with a path
G->>G: reader by EXCLUSION: credential pattern
end
G->>L: warn "{toolName} denied: {reason}" · or debug "{toolName} allowed"
G->>P: appendEntry("enso-guard-denial", { toolCallId, rule }) — denials only
G-->>P: { block: true, reason } · or undefined
P->>H: entry_appended { custom }
H-->>B: observation custom-entry — the page's denials store, by toolCallId
P->>H: tool_execution_end { isError }
H-->>B: observation tool-result { complete: true, isError, text }
B->>B: in the denials ⇒ refusal card, `refused` · else isError ⇒ step row, `error`
The invariants it shows
Section titled “The invariants it shows”- The verdict is a denial with its reason, or nothing.
decidereturnsToolCallEventResult | undefined: every refusal is{ block: true, reason }, every pass isundefined. Refusing is final — handlers are additive and the runtime returns on the firstblock, whatever loads after (header, lines 20–25). - 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 — by
variable, never a throw, because a throwing
session_startwedges print mode. It runs lazily on the firsttool_callifsession_starthas not fired (#verifyPolicyToolNames,#decide). - Shell: deny first, patch last; the analyzer is a library. For
bashthe 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 (#shellCommandDenial).powershellskips the analyzer for the coarse raw scan. Only a command that passed all three gets the default timeout, so no denied command has mutated input. - Writes are an inclusion list; readers are everything else.
writeandeditare 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 refuseswriteonly (#10, #11). Every other tool carrying apathis a reader and a credential pattern denies it: exclusion, because an inclusion list naming onlyreadonce letgrepover~/.awsthrough. - Every verdict is a record (#132): a denial at
warnwithtoolCallId,toolName, the path and the reason; an allow atdebug— never silent (#guardsWithDependencies). - Knowledge comes from results, never from calls. The
tool_resulthandler records a file as seen only whenisErroris false; a blocked call produces notool_resultat all. - Two participants, structurally. The manifest loads guards before the mode extension
(
packages/harness/package.json,pi.extensions); the tripwire scans every declared extension tree for atool_callregistration 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. - A refusal is an error result on the wire.
isErroris the only signal pi gives a refused call (map-events.ts,tool_execution_end); the AG-UI derivation addsstate: "output-error"so the state layer marks the call and its resulterrorwith the text as the message — the same shape the transcript rebuild produces on resume, and the same shape a command that ran and failed has. - The guard says it refused, in the log pi keeps (#387). pi discards the block marker and a
blocked call never reaches a
tool_resulthook, so the guard appends a custom entry —ENSO_GUARD_DENIAL_ENTRY,{ toolCallId, rule }— before returning the verdict. Live it crosses as acustom-entryobservation into the thread’sguardDenialsstore; on a cold readtranscript-to-messages.tsjoins it onto its result asenso:guardDenialmetadata, 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 skipscustomentries, so the model’s view is unchanged.
What is pinned where. 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 — which is the vendor’s code, verified by reading it (the mapper’s comment cites it).
What is ours is pinned through the runtime’s hook runner: HostedThread.probeToolCall asks the real
runner, with the real load order and participants, what a call would meet — the guards’ refusal with
its reason, and first block wins: a later extension cannot un-block, and its own block stands.
The tests that pin it
Section titled “The tests that pin it”Each marker below is checked by test/docs-gate.test.ts: the file must declare a test with exactly
that title, so a renamed or deleted pin fails this page.
- Invariant 5 —
guards.test.ts: the log recorder is the file; a denial and an allow each leave their line.
- Invariant 2, the lazy leg — the floor holds through the pre-
session_startwindow.
- Invariant 3, the library’s contract — a throwing analyzer blocks the command.
- Invariant 3, deny first, patch last — a refused command’s input is untouched.
- Invariant 4, exclusion — a tool the guard has never heard of is still a reader.
- Invariant 6 — a result with
isErrorrecords nothing.
- Invariant 4, stale is distinct from unknown — the reason says changed, not never read.
- Invariant 3, the division of labour —
cc-safety-net-contract.test.ts: the real analyzer, pinned against defaults; what it allows is our layers’ job.
- Invariant 7 —
tool-call-participants.test.ts: set equality with the two named participants, in load order.
- Invariant 8, live —
to-agui.test.ts: an errored result carriesoutput-error; a fine one carries no state.
- Invariant 8, resumed —
transcript-to-messages.test.ts: the rebuilt branch agrees with the live derivation.
- Invariant 9, the entry — the guard appends one entry per denial, naming the call and the rule.
- Invariant 9, live —
custom-event-router.test.ts: the entry’s chunk lands in the denials store; another extension’s entry does not.
- Invariant 9, resumed — the same
bashcall refused and failing: only the refusal carries the mark.
- Invariant 9, drawn —
selected-message-part.test.tsx: a refused call is the refusal card, never the worderror.
- Invariant 8, the vendor’s half —
agent-loop-contract.test.ts: the installed runtime’s loop turns a block into an error result carrying the reason; a bump that moves it goes red here.
- Through the real runner —
agent-host.test.ts: the loaded guards, asked through the runtime’s hook runner, refuse with their reason and pass a read.
- Invariant 1, first block wins — a later participant cannot un-block; its own block stands.
Where it is stated
Section titled “Where it is stated”The seam: seams/guards.md — the policy, the dependencies, decide’s order
and the L5 crossing. The handler: packages/harness/extensions/guards/index.ts (header,
#shellCommandDenial, #guardsWithDependencies). The bar, and what it does not cover:
packages/harness/core/network-egress.ts (header). The vocabulary — guard, verdict, policy, rule,
participant, arbiter, boundary vs bar: glossary.md → Permission.