guard-kit — permission-friction reduction for agent sessions
A command no allowlist entry matches is not refused — it is decided out of
band, by whatever a harness does when nothing matches: interrupt a human, or
run a model to judge the call. Either way that decision is invisible to the
agent — nothing about it lands in the transcript, so the agent cannot notice,
count, or fix the friction it causes — and either way it is paid per call, out
of the operator’s attention or out of latency and tokens, compounding as the
command surface grows. The kit closes the
loop: a PreToolUse guard decides at call time (block with a corrective
message, steer to a better form, auto-allow the provably safe, log every
fall-through), a scanner ranks the logged prompt sources, a curation pass
keeps the committed allowlist durable and the per-user overlay small, and a
recurring close-stage triage step makes the whole loop a habit instead of a
one-off cleanup.
The kit carries the guard framework, the harness-generic ruleset, and the
triage tooling; every project-specific guard rule (build-tool hygiene,
container concurrency, test-suite serialization) is consumer rule content
and never ships. guard-kit registers no
gates: its runtime surfaces are hooks and advisory bin/ tools, so
nothing joins gates.list; it follows gate-sdk’s layout and smoke
conventions without depending on its registry.
The friction loop
- Call time — the consumer’s
bash-guard.sh(copied fromtemplates/, wired as thePreToolUse(Bash)hook) inspects each command: block, steer, rewrite, auto-allow, or fall through. Every fall-through — exactly the set of commands whose decision was made out of band — is appended to the friction log. That decision is invisible to the agent whichever way it went, so this log is the only record. - Close time —
bin/scan-prompts.shfilters the log against the committed allowlist and ranks what survives (§scan-prompts states the local-overlay upper bound), grouped by command pattern. Each recurring pattern is resolved by the triage criterion (below);bin/compare-settings-allow.shlists the local-overlay entries a committed glob already grants (the prune set) and those a declared probe proves too broad (the narrowing set). Then the log is cleared — its named reclaim path. - Steady state — friction low and justified: the committed
settings.jsoncarries every durable pattern (reviewable, shared), the localsettings.local.jsonstays near-empty, and the guard encodes the steering rules no static glob can express.
What the steering is buying
The guard’s blocks serialize: a compound the agent wanted as one call becomes several, and a convenient spelling becomes a written-out one. That cost is real and paid every session, so the rationale is stated here rather than assumed — and it is not “the agent would otherwise be interrupted”. Steering does not stop a decision from being made; it moves the call onto the path where the decision is already made, the static match, and off the path where something else must decide. Three things pay for the serialization, and none of them turns on which thing decides:
- The match short-circuits everything downstream. An allow/ask/deny match resolves the call there. Whatever a harness does with an unmatched command, the matched form skips it — so the bare spelling is the cheaper one on every axis a harness charges, without knowing which axis that is.
- The match verdict is deterministic; the out-of-band one is not. A steered command’s outcome is a property of a reviewed settings file, stable across sessions and auditable without running anything. An unmatched command’s outcome is a judgment made per call. This is the half of the win that survives a harness getting cheap at deciding.
- The tool-hygiene rules are untouched by any of it. Read over
cat, Glob overfind, Grep over a working-treegit grep,git rmoverrm, a scratch dir over the repo root — each steers to a better tool or a safer form, and would earn the guard’s keep with no permission mechanism in the picture at all.
The honest limit. None of this is measured. Whether N matched calls beat one unmatched call in wall-clock is an empirical question the kit does not answer, and a harness whose unmatched path is cheap narrows the first bullet toward nothing. The ruling therefore rests on the second and third, which do not turn on cost: the guard is worth its serialization because it makes the outcome deterministic and the tool choice better — not because it dodges an interrupt. A message that names one harness’s consequence is wrong in the same way whichever consequence it names, which is why none of them do.
The triage criterion
Allowlist and guard are two tools with distinct jobs — resolve each recurring pattern by the criterion, never by defaulting to the allowlist:
- Allowlist (
Bash(...)in the committed settings) when the command is safe and already in the form to reinforce — static, glob-matched, declarative. - Guard rule when a better form exists and the agent should be steered to it, or when the allow/deny needs logic a static glob cannot express.
- Habit change (a noted convention) for true one-offs.
Caution: an allowlist entry can mask a steering opportunity — before blessing a form, confirm it is the one to reinforce; if a better form exists, steer to it in the guard rather than permitting the worse one.
Note: the harness matches the allowlist per segment of a compound
command, so a glob on the core command does not cover the echo banners,
wc, redirects, or ;-chained diagnostics wrapped around it — one
unmatched segment takes the whole line off the match path. The read-only banner/diagnostic
tools an agent habitually chains (echo, wc, grep, ls, command -v)
are therefore themselves legitimate allowlist entries; allowlist them, or
run the core command bare.
The guard framework (lib/guard.sh)
Primitives a consumer guard composes; each emits the harness’s
PreToolUse hook protocol:
guard_read_input— read stdin once into the globalGUARD_INPUT, returning non-zero when stdin yielded nothing. Called directly, never in a command substitution, and before the first field accessor. It exists because the payload was otherwise unreadable past its first field:guard_read_commandis invoked ascmd="$(guard_read_command)", so any cache it set died with the substitution’s subshell and stdin was already consumed — a rule needing a second field could not get one.guard_input_field <jq-path>— the value at<jq-path>inGUARD_INPUT, or nothing whenGUARD_INPUTis unset or empty or the path is absent. The accessor a rule reaching past the headline fields uses.guard_read_command— parse the hook JSON, emit the command; fail-open on any parse problem (exit 0) so a guard can never wedge the agent.guard_read_path— the same accessor for.tool_input.file_path, the field a path-bearing tool call carries where a Bash call carries a command. It returns non-zero, emitting nothing, when the field is absent, empty, or the payload will not parse — so a matcher covering a call without one falls through instead of blocking. That return contract is the whole design: an accessor returning success on a missing field passes every happy path and wedges every other call its matcher covers. Generic over any path-bearing tool; it names no path and no rule, which is what keeps the rule in the kit that owns the file.guard_block <msg>— exit 2 with the message on stderr. Every block message is self-describing: it names the offending pattern and the corrective form, so the why of a rule rides to the agent in the rejection itself, not in a code comment.guard_advise <msg>— allow, but feed the message back asadditionalContext(steering without blocking).guard_allow <reason>— silent grant viapermissionDecision: allow.guard_rewrite <cmd> <reason>— behavior-preserving rewrite viaupdatedInput(grant the better spelling of the same command).guard_log_fallthrough— append the (truncated, newline-flattened) command to the friction log; best-effort, never affects the decision.guard_allow_match <string> <pattern>— the shell-glob match core: true when the string matches a committed allow pattern, with the harness:*prefix idiom (Bash(printf:*)≡ anyprintf …) normalized to a trailing*. Not a hook primitive — a shared helper, one implementation behind compare-settings-allow’s redundancy detection and rule 19’s silent-grant guard so the two never drift.guard_skeleton <cmd> <inert-class>…— the context-aware normalizer, and the only place a rule learns what part of a command is live. It returns the command with every region of the named inert classes replaced by a placeholder token, leaving everything else byte-identical. Four classes:sq(single-quoted spans),dq(double-quoted spans),hd(heredoc bodies, from the line after an opener to its terminator line), andhdq— the subset ofhdwhose delimiter is quoted (<<'EOF'), where the shell itself guarantees the body does not expand.hdqexists because that guarantee is what lets the one rule that must keep heredoc bodies live keep only the bodies that can actually expand. Each rule names the classes inert for it, in one argument, at one call site — the classes were never the disagreement, since every rule agrees that some regions are inert; the disagreement was that each decided privately and none recorded why. Not a hook primitive, and a pure function of its arguments: no config read, no subprocess, no global.guard_split_compound <skeleton>— the compound splitter: emits one segment per line, splitting on the harness’s statement separators (;,&&,||,|). Also not a hook primitive — the single implementation every consumer that reasons per segment shares (rules 8/12/14/15/17/18/19/21, the read-compound carve-out of rules 9/10, and scan-prompts’allowed()), so the harness’s per-segment matching surface is modelled in exactly one place and cannot drift between them. Fed aguard_skeletonview, so a separator inside a quoted argument is never mistaken for a statement break. What it models is the harness’s matching surface, not shell dataflow, and that distinction is what rule 22 rests its own two-level splitter on: the emitted segments carry no record of which separator produced them, so a rule asking whose stdout feeds whose stdin cannot be a caller — a different question, not a second dialect of this one. Its separator class carries no newline, and it does not need one: it emits segments as lines and every consumer reads lines, so a newline already present in the input is already a boundary before the substitution runs.
Placeholder, never deletion, and this is a correctness point rather than taste. Deleting an inert span fuses adjacent tokens: a pattern glued to its flag loses the boundary between them and the residue reads as one word that was never in the command. The normalizer substitutes, so the skeleton has the same token count and the same statement structure as the command it models.
The heredoc class marks the body extent only. The opener line and everything
after the terminator stay fully live, so a heredoc-bearing call is still matched
on its executable text — neither blanking everything after a << (which blinds
the guard to real commands in the same call) nor stripping quotes alone (which
still refuses a double-quoted mention). The extent is decidable without a shell
parser because the terminator is a literal token alone on its line, which is the
property the class rests on.
Two bounds, stated rather than discovered later. A heredoc with an
unquoted delimiter does expand, so hd is not inert for the expansion rule
even though it is inert for every glyph rule — which is why classes are declared
per rule and not fixed per region, and why the rule that cannot take hd takes
hdq instead rather than going without. Only the delimiter’s quoting decides
this, never the body’s content, so the test is decidable at the opener; a
delimiter escaped rather than quoted (<<\EOF) is not recognized and its body
stays live, which is the conservative direction. And the normalizer models quoting, not
shell semantics: a construct that survives its scan and is not one of the three
classes is treated as live, the fail-toward-matching direction and the one a
guard should err in. That bound is why the command/process-substitution and
backtick tests in rules 9, 10, 11, 12, 13, 14, 15, 17, 18 and 21 read the raw
command rather than
a skeleton and must keep doing so — a "$(…)" inside double quotes still
executes, so a rule that declared dq inert for that test would hand an
auto-allow to a command substitution it could not see. Those tests are
conservative declines, not verdict matching. That roster is derived from the
rule bodies, not from memory: it named rules that carry no raw test and
omitted rules that do until it was re-read against them, which is the same
drift the number-bearing cross-references below take and the reason each is
re-derived rather than carried.
Rule 12 is the one rule that reaches its verdict off the raw command, and the
exception is forced rather than chosen. Its predicate is the pattern literal
-f will scan argv for, and a skeleton replaces exactly that literal with a
placeholder — the quoted operand is the normal spelling — so a skeleton view
would blind the rule to the only thing it tests. It pays for the exception in the
same conservative direction every other clause takes: it declines outright on any
expansion, substitution or backtick (rule 6 blocks the expansion and substitution
shapes; the backtick decline is this rule’s own arm, per the raw-command reading
above), and a
pattern operand carrying a stray quote — the evidence that the compound split
landed inside a quoted span — declines rather than guesses at the operand.
GUARD_INPUT first, stdin otherwise, and the fallback is the whole point of
the shape. guard_read_command and guard_read_path parse GUARD_INPUT when
it is set and non-empty and read stdin exactly as they always did otherwise, so
every consumer copy that never opted in keeps working byte-identically without an
edit — it never sets GUARD_INPUT, so both readers take the stdin path. A guard
that wants a second field opts in by adding one line, guard_read_input || exit 0
ahead of the first accessor, which is what the shipped template carries. Making
guard_read_input mandatory was refused: it would break every consumer copy on
upgrade for a field only one rule needs.
What a PreToolUse payload actually carries, recorded so no session re-buys
the probe. Top-level keys: agent_id, agent_type, cwd, effort,
hook_event_name, permission_mode, prompt_id, session_id, tool_input,
tool_name, tool_use_id, transcript_path. On a Bash call tool_input carries
command, description, and run_in_background — a JSON boolean true on a
backgrounded call, and absent on a foreground one. It is a fact about this
framework’s own inputs, so without it each session re-establishes it from the
vendor hook reference.
The live permission mode is in that payload and no rule reads it.
permission_mode sits alongside the fields the accessors pick off, so a rule
whose rationale is mode-conditional — “no allowlist entry can suppress this
prompt” is false under an auto-approving mode and true under the others —
asserts a mode rather than reading one. Whether a guard should read the mode is
a consumer decision and is not settled here; the same holds for agent_id,
agent_type and effort, each of which belongs to a question of its own.
Fail-open is the default posture. The one sanctioned fail-closed shape is a deny-guard whose hook matcher already proves the tool identity (see wakeup-guard): there, a logging or parse failure still denies.
A third posture, fail-open-but-loud, covers a deny-guard whose matcher
proves the tool but whose rule turns on a payload field. The gap is real
rather than a technicality: wakeup-guard’s rule is the tool, so a parse
failure that denies costs nothing, while a rule needing a field cannot say
that — denying on a parse failure would wedge every call its matcher covers.
Passing silently is the opposite failure and the worse one, since it ships an
unenforceable claim that reports success. So the guard allows the call and
emits an advisory naming the rule it could not enforce: a degraded
enforcement is visible, never silently folded. One delivery constraint comes
with the posture and is easy to miss — guard_advise is itself jq-backed, so a
guard degrading because jq is absent cannot reach for it and must emit the
advisory envelope directly, or the loud half is lost in exactly the case it was
written for.
delegation-kit is this framework’s second and third consumer, and they do not
cost the kit alike. agent-budget-guard.sh composes these primitives into a
PreToolUse(Agent) hook that blocks on a PAUSE budget verdict and advises
otherwise — cite-only; no guard-kit mechanism moves for it.
agent-dispatch-guard.sh shares that matcher and enforces delegation-kit’s
dispatch-shape rules; guard-kit mechanism does move for that one, by
exactly one clause — the fail-open-but-loud posture above, which it is the
shipped instance of (delegation-kit/SPEC.md §The delegation model).
lifecycle-kit is the fourth consumer, on the same axis: its
templates/workflow-state-guard.sh is a PreToolUse(Write|Edit) hook refusing a
direct write to the lifecycle state file, and guard-kit mechanism does move
for it, again by exactly one clause — guard_read_path above. Nothing else was
missing: guard_block and guard_advise never read a command, so they were
already tool-agnostic. This is the rule the three consumers now make explicit —
the kit owning the rule ships the guard, riding lib/guard.sh through the
GUARD_KIT_LIB indirection, and guard-kit moves only where the lib lacks a
primitive. The state file’s path lives with lifecycle-kit, never here, so
guard-kit gains no dependency on a kit it does not otherwise know about
(lifecycle-kit/SPEC.md §check-stage-evidence).
Consumer rules
A consumer’s project-specific block/steer/allow rules live in its copy of
templates/bash-guard.sh, composed from the lib/guard.sh primitives above and
placed before the generic ruleset — the template marks the position. The
template’s head reads the payload once (guard_read_input || exit 0) before the
first field accessor, which is what makes a rule reading a tool-input field
beyond the command reachable in a shipped guard rather than only in the tests; a
copy without that line still runs every rule, on the fallback above. Order
is the whole reason the seam sits here: a project rule that fires must fire
ahead of the generic rule it refines, or the generic verdict wins and the
project rule is dead code.
This is a placement contract, not a mechanism: guard-kit ships no consumer rule and names none. What a project blocks or steers is its own toolchain knowledge and stays in its copy, which is why the copy legitimately diverges from the template here and why no rule content crosses into the kit.
Placement is a verification decision before it is a seam one, and this is the sentence that makes the contract usable. The generic lane carries §Testing’s decision-table obligation — every rule there owes a firing and a non-firing case, and the table is the only instrument that reads a block at all. The consumer lane carries none: a rule in a consumer’s copy is reachable by no table in this kit, so it ships hand-verified and its narrowing cannot be measured. A rule that qualifies for either lane therefore belongs in the generic one. That a consumer’s own rules have no verification lane at all is a real gap and a separate one; it is named here rather than solved, since solving it designs a testing lane for consumer copies.
The generic ruleset
Rules that encode harness behavior, shell-substrate behavior, or behavior over
an artifact whose grammar a kit owns, never any project’s toolchain — shipped
as lib/guard.sh functions the template guard invokes. Order is load-bearing
where noted.
The clause has taken two widenings, and each is recorded with the rule that forced it, because a rule slipped in unremarked is what would rot it.
- The shell-substrate half was added for rule 12, and it admits nothing a
project owns. A
-fpattern match seeing the matcher’s own argv is a property of the shell substrate every consumer of this kit runs on — neither harness behavior nor any project’s toolchain — so the narrower sentence left it in a gap it had no reason to leave. - The kit-artifact half was added for rule 14, and it takes the same form and
passes the same test. Rule 14 is neither of the two earlier classes as they
were written: what it reads is evidence-kit’s
pid=<n> run=<key>record and the OS’s process table. It interprets no project vocabulary at all — it reads a PID and asks whether it is alive — so the test rule 12’s widening had to pass is the test this one passes: it admits nothing a project owns. A kit’s own artifact grammar is shipped mechanism, which is exactly what CLAUDE.md §The provenance seam distinguishes from private rule content.
A project’s toolchain stays out on the same words as before, under both.
Naming one harness’s forms in a corrective costs portability, not privacy, and the cost is recorded rather than pre-paid. Rule 13’s block message names a backgrounded condition wait and the harness’s event-stream form, because a corrective that named neither would be unfollowable. A harness tool name is public, documented, and shared by every consumer of that harness, so it is not private rule content and the provenance seam — a privacy boundary over private vocabularies (CLAUDE.md §The provenance seam) — does not reach it. What such a literal does cost is portability: a second harness with different form names is what would force these into a configurable slot, and building that slot before that harness exists would be designing against no case.
cdin a compound command — blocked: cwd drift, plus a compound the allowlist cannot match, so the call is decided out of band. Corrective form: absolute paths, orgit -C <dir>for git.git -C <repo-root>when cwd is the root — blocked: the absolute-Ctarget matches no allowlist entry and falls off the match path, while the baregitform is allowlisted and resolves on it. Pinned with a trailing space to the exact root, sogit -C <root>/subdirand a foreign-repo-Care untouched.- Bare-name scratch redirect — a
>/>>to a slash-free*.err/*.out/*.logtarget is blocked (it lands in the tracked tree and risks agit add -A); the no-slash class lets path-bearing targets,/dev/null, and fd-dups through. The corrective message points at the consumer’s gitignored scratch dirs (GUARD_KIT_SCRATCH_DIRS). - Absolute-path execution of a known read-only repo script — silently
rewritten to the repo-relative form via
guard_rewrite(the relative spelling is what allowlist globs match). The roster is theGUARD_KIT_RO_SCRIPTSglobs (defaultcheck-*.sh). Any other absolute repo-script spelling gets a corrective block. Placed before rule 5, which would otherwise steer the same command less precisely. - Repo-root absolute prefix (non-script) — any other command carrying
the literal repo-root prefix is steered to the repo-relative spelling.
gitis excluded — rule 2 already owns its-Chandling. - Shell expansion / assignment — a residual
${…}/$(…)/<(…)/$NAMEin the skeleton is blocked (the harness’s matcher refuses every expansion before allowlist matching). Declaressq hdqfor the expansion check andsq dq hdqfor the assignment check, and every part of that is deliberate rather than inherited.dqstays live for the expansion check: inside single quotes$is literal (awk '$1'), but a double-quoted"$x"still expands and must stay visible.hdis not declared, since an unquoted-delimiter heredoc body expands too — buthdqis, because a quoted delimiter makes the shell itself the guarantee that the body cannot expand. That is what stops the rule refusing prose that merely names a substitution: a journal entry, a queue entry being appended, or a commit message written throughgit commit -F - <<'MSG'. The assignment check addsdqbecause aNAME="value"assigns whatever the quotes hold. A standaloneNAME=valueassignment is caught separately, since the expansion check only sees a used$VAR. - Unquoted brace glyph — the harness’s matcher refuses the bare
{glyph before allowlist matching, the same behavior class rule 6 pre-empts for$-expansions, so a{surviving in the skeleton is handled by shape. Declaressq dq hd, and the difference from rule 6 is the point rather than an accident of two adjacent lines. Rule 6’s reason to keep double-quoted spans live does not carry here:{is a matcher glyph, not a shell expansion, so a double-quoted brace is exactly as inert as a single-quoted one, and a heredoc-body brace is inert whatever the delimiter. This is what stops a working POSIX quantifier (grep -rnE "^[a-z]{2,}") being refused, and it is equally what stops the wrong corrective: a block message owes the offending pattern and the corrective form, and write out the brace expansion, spell the members is inapplicable to a quantifier that has no members and to a heredoc body that has nothing to respell. A block whose corrective cannot be followed fails that contract however right its verdict. A bare{}placeholder (find … -exec cmd {} +,xargs -I{}), when every residual brace is exactly{}, is rewritten viaguard_rewrite— each{}single-quoted to'{}', behavior-preserving (the shell passes a literal{}either way) and invisible to the matcher on the same premise as rule 6’s strip. Every expanding form is blocked with the written-out corrective: git-ref shorthand (@{u},@{-n},<ref>@{n}) names the explicit spelling (origin/<branch>..HEAD, or the resolved ref/hash); list/range ({a,b},{a..b}) names the spelled-out members or a loop; any other residual{gets the generic corrective (quote it if literal — an unquoted awk/sed program — write it out if it expands). There is no legitimate brace-glob convenience to preserve: since every bare{already costs an out-of-band decision no allowlist entry can suppress, block-and-steer strictly dominates; a brace in any inert region passes untouched. Placed before both auto-allow rules so their literal-target premise holds for braces as well. sedreading or rewriting a file — blocked with the steer to the harness’s file tools:sed -i(or any short bundle carryingi) to the Edit tool, a file operand to the Read tool’s offset/limit. Asedfed by a pipe is a text filter with no tool equivalent and is untouched, so the discriminator is the operand, not the binary — logic no allowlist glob can express, which is why this is a rule and not a deny entry. Segments are analyzed only when they lead withsed, so a-iflag on any other command (grep -i) is invisible to it; within a segment, options are walked so-e/-fconsume their argument and the first bare operand is the script — a second bare operand is the file that fires the rule. Placed before both auto-allow rules: a consumer that widensGUARD_KIT_RO_BINSwithsedwould otherwise have rule 18 silently grant an in-place rewrite.- Listing-only
find— a barefindthat only lists is blocked with the steer to the harness’s Glob tool (the same shape as rule 8’ssedread-steer: a better tool exists, and Glob returns paths registered for a later Read). Fires on the conjunction no allowlist glob can express: every segment leads withfindand carries no action predicate (-exec/-execdir/-ok/-okdir/-delete/-fls/-fprint/-fprint0/-fprintf— find(1) mechanism, a lib literal), with no composition — no pipe,&&/||chain, background, or redirect. A lone listing fires, and so does a;-sequence of listings: a;-compound is not composition but a batch of independent listings, exactly what the Glob steer collapses into one call. A literalecho/printfbanner segment between them is the natural separator of such a batch and is tolerated; at least one segment must be a listing. A pipedfindis a legitimate producer (rule 18 may still auto-allow it), an action-predicatefindis an executor, a redirectedfindhas a downstream reader, and a mixed compound (a segment that is neither a listing nor a banner) all pass untouched. Placed before both auto-allow rules (same reasoning as rule 8): a bare listing meets the steer rather than a silent read-only-pipeline grant, sincefindis in the defaultGUARD_KIT_RO_BINSroster. A consumer needing different behavior shadows the rule in its consumer-rules section. - Bare single-file
cat— acatread is blocked with the steer to the harness’s Read tool (rule 8’s read-steer shape: Read returns numbered lines registered for a later Edit). Fires on the conjunction no allowlist glob expresses: every segment leads withcatand carries exactly one non-flag operand (flags are allowed,cat -n <file>), with no composition — no pipe, heredoc, redirect, substitution,&&/||chain, or background. A lonecat <file>fires, and so does a;-batch of reads (cat a; cat b): a;-compound of single-file reads is a batch, not composition, and collapses into successive Read calls — the ergonomic case the steer exists for. A literalecho/printfbanner segment between reads is the natural separator of such a batch and is tolerated: it is redundant once the batch moves to the Read tool, which shows each filename itself. At least one segment must be a read. Multi-file concatenation, a piped or heredoc’dcat, a redirectedcat, an&&-chain, and a mixed or banner-only compound are composition (or carry no read the Read tool replaces) and pass untouched. Placed before both auto-allow rules (same reasoning as rule 8): a barecat <file>meets the steer rather than a silent read-only-pipeline grant, sincecatis in the defaultGUARD_KIT_RO_BINSroster. - Working-tree
git grep— agit grepthat searches the working tree is blocked with the steer to the harness’s Grep tool (rule 8’s read-steer shape: Grep returns matching lines with files registered for a later Read). Fires on the discriminator no allowlist glob expresses: the command leads withgit grep, names no revision, and does not scope elsewhere — the index (--cached/--staged), a non-repo search (--no-index), or an untracked-file search (--untracked). “Names a revision” is read off the positional grammar: the pattern is one positional (or supplied by-e/-f), so a further bare positional before any--is a tree-ish the Grep tool cannot reach —git grep foosteers whilegit grep foo HEADpasses, and a-- <path>limiter keeps the search in the working tree and still steers. A pipedgit grepis a producer and never reaches this rule (the consumer disqualifies it). Conservative by construction: an unrecognized option-with-argument or a glued pattern flag biases toward passing, never a false steer of a history search. - Self-matching process-liveness predicate — a
pgrep/pkill -fwhose pattern literal the command’s own text repeats is blocked.-fmatches against full argv, and the waiter’s own argv — the harness’s wrapper included — carries that literal, sountil ! pgrep -f '<script>'; do …; donehas a permanently-true condition and never exits. It reds nothing: the work completes correctly and the only symptom is the foreground cap absorbing an unbounded loop, which reads from outside as a fixed cap-length wait. Fires on the conjunction no allowlist glob expresses: a segment whose command word ispgreporpkill— read past a leadinguntil/while/if/!, which do not change which binary runs, so the loop-headed spelling the rule exists for is reached — carrying-f(bare or bundled in a short cluster), whose pattern operand is a literal occurring elsewhere in the same command. The corrective names both sanctioned forms: wait on the work’s own artifact, orkill -0against a recorded PID where liveness genuinely is the condition — whoever started that producer, a child the session backgrounded itself included, whose PID it recorded at launch as a<key>.runrecord under its scratch directory. That clause is owned by delegation-kit/SPEC.md §The delegation model and this text carries it, the record’s naming convention included — rule 14 reads that same set, so a corrective that named the PID but not where it lives would send a blocked session to write the one artifact shape neither reader can find. A PID is an identity; a pattern is a guess about a process table that includes the guesser. The refusal of the bracket-trick repair as the sanctioned form is delegation-kit’s ruling and its grounds live there (delegation-kit/SPEC.md §The delegation model); this rule exists because a correct form one character from an incorrect one will be got wrong. Conservative by construction, in this ruleset’s established directions: an expansion or substitution anywhere in the command declines outright (rule 6 already blocks those shapes); apgrepwithout-fmatches process names rather than argv and is untouched; an unrecognized option, an option whose argument cannot be walked, or a second bare operand declines; and a pattern occurring nowhere else in the command is a genuine query and passes. Each biases toward passing rather than toward a false block. Placed with the read-steer rules and before both auto-allow rules, on rule 8’s stated reasoning:pgrepis a plausible member of a widenedGUARD_KIT_RO_BINS— it is, after all, a read-only query — and a consumer who added it would otherwise have rule 18 silently bless a waiter that can never exit. - Bare foreground
sleep— asleepstanding in for a wait is blocked, and the discriminator is the whole rule: a blanketsleepblock is wrong and is refused. The sanctioned wait is a condition loop, and such a loop is expressly legitimate in either spelling —until <cond>; do sleep N; doneandwhile <cond>; do sleep N; donealike, because the span walk below readsdo … doneand never the loop keyword, so correcting a wait loop’s polarity is free at the guard. So the rule fires on exactly one shape — a bare foregroundsleepin command position, outside every loop wrapper — and never on asleepinside a loop body. The discriminator is new territory: this ruleset carries exactly one shell-keyword parse and this rule is where it came from — rule 15’s wait-loop exemption now shares it rather than copying it, so the ruleset keeps one keyword-parsing dialect. This one takes the skeleton view (so asleepmerely named in a quoted span or a heredoc body is already inert), then walks it fordo … donespans and treats asleepinside one as inert — structurally the carve-out_guard_is_banneralready performs forecho/printfsegments in the batched-read rules, applied to a keyword span instead of a segment. Asleepoutside every such span fires. Where the wrapper cannot be resolved — an unbalanceddo/done, or a span the rule cannot attribute — it declines, the direction every other conservative clause here takes; asleepthat is an argument rather than a command word never fires at all. Blocks rather than advises, on rule 19’s reasoning: no consumer allowlist is presumed to grant a baresleep, so the rule fires on a command that would be decided out of band anyway and converts that decision into a durable steer at no extra cost. The corrective names the property and both forms rather than one spelling: a wait must end when its condition goes true, not when a duration expires; a backgrounded command that exits on the condition fires one notification the moment the condition holds, while the harness’s event-stream form stays armed to its deadline after its event fires when the command it was armed with is unbounded, which makes it the second choice for a single completion. It also names the loop’s polarity, because that is the next mistake and it is the one with an attested cost:untiltakes a done predicate,whiletakes a still-running one such as a PID’s liveness, and inverting them yields a loop that exits at once with the producer still running (delegation-kit/SPEC.md §bin/wait-probe measured it). Naming one form teaches a spelling; naming the property and sorting both under it teaches the rule — the discriminator is owned by delegation-kit/SPEC.md §The delegation model. Placed with rule 12, before both auto-allow rules, for the same reason:sleepis not on the default read-only roster, but the placement argument is about what a consumer may add and the whole family of steer rules already sits there. - Tracked-tree mutation under a live producer — a
gitcommand that writes the index, the worktree or a ref is blocked while a*.runrecord under aGUARD_KIT_SCRATCH_DIRSmember names a live PID. The record is the launch-time liveness record a session writes when it backgrounds a shell child (delegation-kit/SPEC.md §The delegation model owns that rule; evidence-kit/SPEC.md §The producer-liveness lock owns thepid=<n> run=<key>grammar and the PID predicate, and neither is re-decided here). The act set is bounded and named rather than gestured at:add,commit,rm,mv,restore,checkout,switch,reset,stash,merge,rebase,cherry-pick,revert,apply,am,clean. Read-only git —status,log,diff,show,rev-parse,ls-files— passes, and so does every non-gitcommand.Write/Editare deliberately outside the rule: a mutating session’s mandated journal write lands through them and the scratch directory is gitignored, so a rule reaching them would refuse the very mechanic the resume-journal contract requires while a wait is in progress. This is thePreToolUsehalf of the waiting rule’s enforcement, and it works by relocation. The turn-end passes noPreToolUsechokepoint, so it is unreachable from this ruleset (delegation-kit/SPEC.md §Operative residency); the harm it causes arrives as an ordinary tool call at a chokepoint already wired, which is what this rule fires on. The harness’s own turn-end event reaches the act itself on a different axis, measured rather than assumed, and the hook there refuses as well as observes (delegation-kit/SPEC.md §The turn-end liveness hook (template)). This rule’s own behavior is untouched by that and it stays the enforcement on its own axis: it reaches mutations the turn-end event never sees, and that hook reaches a session that mutates nothing. Why the block is right even though it over-reaches, stated rather than softened. A read-only producer takes no harm from a commit, and the record cannot say which kind it is. This ruleset’s established direction is to bias toward passing; that direction is not taken here, and the ground is that the biased-toward-passing reading is what has failed every time this rule fired. The corrective is cheap and names both exits: wait for the producer on its own artifact, or delete the record if the producer is done — and deleting a record whose producer has exited is not a workaround, it is the statement of fact becoming false and being retracted. Conservative in this ruleset’s other established directions, which are kept: an expansion or substitution anywhere in the command declines outright (rule 6 already blocks those shapes); a record that does not parse declines rather than blocks, because a guard is not the place a corruption verdict is taken andcheck-producer-livenessalready exits 2 on one; git’s global options are walked sogit -C <dir> commitis reached, but an option the walk does not recognize declines rather than guess which token is the subcommand; and an unrecognizedgitsubcommand declines. Each biases toward passing rather than toward a false block, which is the calibration the previous paragraph departs from only on the read-only producer. The corrupt disposition diverges from the turn-end hook’s, deliberately, and is recorded from both sides. This rule declines on a record that does not parse; theSubagentStophook refuses on one. The ground is structural rather than a difference of appetite: this rule reads the records one at a time, so a malformed record declines for itself while a sibling naming a live PID still blocks, whereas the hook reads the whole set through a single exit code. Allowing there would let one malformed record anywhere under a scratch dir suppress every turn-end refusal in the tree — a bypass this rule does not have and cannot have. The same divergence is stated from the other side at delegation-kit/SPEC.md §The turn-end liveness hook (template), so neither surface reads as the other’s drift. The hook has since named arecords=0sub-case this rule has no analogue for, and neither side’s decision moves. Reading a whole set through one exit code lets the hook meet an exit 2 raised over no record at all — a reader that could not run rather than a record that does not parse — which it namesunresolvedand still refuses. A per-record read cannot reach that case: with no record there is no call for this rule to be about. So the divergence above is unchanged in substance and gains one clause — the hook’s exit-2 arm now carries two names, and this rule’s decline still answers only the one of them a record exists for. Placed with rules 12 and 13, before every auto-allow rule. It is the third member of the wait-discipline family and inherits their placement argument (gitis not on the defaultGUARD_KIT_RO_BINSroster, but a consumer that widened it would otherwise have the read-only-pipeline rule silently grant the mutation). Two collisions make the placement necessary rather than tidy, and both are with rules that sit after the auto-allows: rule 19 would block a chainedgit commitfirst and hand back run it bare — a corrective that is wrong under a live producer and that the session can follow; and rule 20 advises ongit commit --amend, so a rewrite under a live producer would proceed with a re-verification steer instead of stopping. The bound this rule ships with is narrowed at the launch chokepoint, not closed. A session that backgrounds without recording is still invisible here; rule 15 advises at the launch itself, which makes the omission visible at the moment it is made without refusing it. - Backgrounded launch that records no producer — a backgrounding Bash call
that neither writes a liveness record nor meets an exemption below is
advised: the message names the record, its grammar, its home, and the two
things it buys — rule 14’s reach, and the next arrival’s ability to tell
whether the producer is still writing.
Two backgrounding forms, and both arms ship in one rule. Harness form —
.tool_input.run_in_backgroundistrue, read throughguard_input_field(§The guard framework records the field and its shape). Shell form — the skeleton (sq dq hd) ends a statement with a bare&that is neither the&&operator nor a redirect’s fd-dup. Building only the shell arm was refused as worse than not building it: every attested firing used the harness form, so a shell-only rule would cover the spelling nobody uses and pass the one that fires — coverage in appearance (delegation-kit/SPEC.md §The delegation model). The record-writing test reads the command text, and that is forced rather than chosen. AtPreToolUsethe child has not started and no record can exist yet, so the only thing observable at this chokepoint is whether the launch is going to write one: the call writes a record when its skeleton carries a redirect whose target is a path under aGUARD_KIT_SCRATCH_DIRSmember ending in.run. That is the honest predicate, and it is part of why the rule advises rather than refuses. Three exemptions, each reusing machinery already in the lib. (1) The call writes a record — the obligation is discharged inline. (2) The call is a wait loop — a backgroundeduntil <cond>; do sleep N; doneis the sanctioned primitive rule 13 steers toward, a waiter rather than a producer, and advising there would make the ruleset warn against the form its own corrective recommends; detected by thedo … donespan walk rule 13 already performs. (3) The call is a read-only pipeline — every segment leads with aGUARD_KIT_RO_BINSmember and every redirect target is/dev/nullor an fd-dup, rule 18’s own test, since a child that writes nothing has nothing for a later commit to corrupt. Advises rather than blocks, and the refusal of a block is reasoned rather than hedged. The guard cannot tell a producer from a trivial child — a backgroundedprintfand a backgrounded gate battery are the same shape at this chokepoint — so a block’s false-fire population is every short-lived backgrounded call in the tree, and this ruleset’s established direction is to bias toward passing. Rule 14 departs from that direction on an attested record of failure; this rule has none to depart on: both attested firings (2026-08-19 and 2026-08-21, the second self-disclosed) produced no orphan, so the harm is latent and a block is not warranted by a latent harm when the reminder is what was missing. The harm already has a block — rule 14 refuses the mutation — and this rule covers the omission that hides it, one step upstream, where a second refusal buys a second stop rather than a second catch. Both firings were a session that knew the rule and forgot it at the call, which is exactly what anadditionalContextnote at the call fixes, at the cost of one sentence rather than a stopped turn. The re-opening condition is named rather than left to judgment: a firing of the recording omission after this advisory ships is the attested record rule 14’s own departure required, and the block is then the next step. The honest limit. An advisory does not refuse. A session determined to background without recording still can, so rule 14’s bound — only for a session that recorded — is narrowed rather than closed. What closes at this chokepoint is the silent case: the omission is now visible at the moment it is made, to the only party that can fix it. Conservative in this ruleset’s established directions: an expansion or substitution anywhere in the command declines outright (rule 6 blocks those shapes already), an unbalanceddo/donedeclines, and an absentGUARD_INPUTon a call carrying no shell&simply leaves the harness arm unavailable and the rule inert — the graceful degradation §The guard framework’sGUARD_INPUT-then-stdin fallback buys. Placed with rules 12, 13 and 14 as the fourth member of the wait-discipline family, before both auto-allow rules, and the placement is required rather than tidy: a backgrounded read-only pipeline is granted outright by rule 18, so a rule sitting after the auto-allows would never run on the shapes it exists for. No knob is minted, and that is a seam decision rather than an economy: a producer roster — which backgrounded commands are worth a record — is exactly the consumer vocabulary the provenance seam keeps out of a kit, so the rule recognizes shapes and advises, and a consumer wanting a narrower population narrows the two knobs that already exist. - Auto-allow
: > filetruncation — a leading:plus redirect defeats the permission matcher, so it is always decided out of band. Granted silently when the command is only:followed by redirects and every target is gitignored (git check-ignore): truncating scratch is safe; a tracked file must still take that decision. Thegitsubprocess is gated behind the rare:-redirect match; expansions (rule 6) and brace forms (rule 7) are already blocked, so a surviving target is a literal path. - Auto-allow an append-only write to a gitignored target — the mandated
resume-journal append that
delegation-kit/SPEC.md §Resume journal — agent writes, scratch reset sweeps
obliges is a redirect, and the harness checks a redirect
target as a file write, so no
Bash(…)allow entry can grant it: such a rule grants the command and never the target. Granted silently when every clause below holds, falling through untouched otherwise:- (0) Not a backgrounded launch. A statement-ending
&refuses before any other clause is read: a backgrounded append is rule 15’s subject, and granting it here would bless a launch the liveness-record rule is about. - (a) Append-only. Every redirect operator is
>>(an fd prefix allowed:2>>); a single truncating>anywhere refuses. This clause carries the append-only split — a mistyped redirect must not be able to destroy a journal — and it is the one no settings rule can express, since a redirect-target check is a file-write check and cannot tell>>from>. That is why the grant is a hook rather than an anchoredEditrule, which would reach the target correctly and grant the truncating form with it. - (b) Every target gitignored, on rule 16’s exact
git check-ignore --quiet --predicate and its exact subprocess, gated behind the same rarity:gitis reached only once (a) and (c) have already matched. - (c) The leading command emits to stdout and nothing else, and the
command is one statement. The roster is
GUARD_KIT_APPEND_BINS. - (d) Conservative decline on anything unmodelled — a command or process
substitution or a backtick anywhere in the raw command, or a redirect
target that survives normalization carrying a quote. Declares
sq dq hd.
Why (c) is the clause the safety argument rests on. A
permissionDecision: allowblesses the whole call, so a rule keyed on the redirect alone would grantrm -rf .tmp/../.git >> .tmp/j.md— the target is gitignored, the operator is>>, and the command destroys the repository. Bounding the emitter is what makes the grant safe, and bounding it to stdout-only emitters is what makes the bound checkable:cat,printfandechowrite nowhere the redirect does not send them, so the redirect target is the whole of what the call can touch.teeis deliberately off the roster despite being the obvious fourth member — it takes a path argument, sotee -a <tracked file>writes a tracked file with no redirect at all and (a) and (b) never see it. The honest limit is the mirror of the one rule 18 carries forGUARD_KIT_RO_BINS, reached from the writing side rather than the reading side: the grant is exactly as safe as the roster’s stdout-only property, which the rule asserts of the roster and cannot verify of a member. One statement is one segment plus its own heredoc residue, never one segment.guard_split_compoundemits per line andguard_skeleton’shdclass leaves a heredoc’s body placeholder and terminator on lines of their own, so the mandatedcat >> … <<'EOF'form splits into three segments and a one-segment test would refuse the exact command this rule exists to grant. The test is therefore that every segment past the first is exactly the residue the first segment’s own openers produce — which still refuses a second statement on a line of its own (printf x >> .tmp/j.md, newline,rm -rf …), the hazard the one-statement bound is for. Two inert targets are exempt from (a) and (b), and the carve-out is precedented rather than invented:/dev/nulland an fd-dup (&1,&2) are what rule 18 already treats as targets that are not files. Neither is a file the append-only split protects and neither is a pathgit check-ignorecan answer about. Every other target takes both tests, and at least one such target must exist — a call redirecting only to inert targets has no append to grant. Seeing an fd-dup at all takes this rule’s own operator-and-target scan, because_guard_redirect_targets’ target class excludes&and drops an fd-dup target entirely. The raw-command arm is not redundant with rule 6, and that was measured rather than assumed: rule 6 blocks${…},$(…),<(…)and$NAMEand exits 2 first, but not>(…), which does run its command. An auto-allow may not rest on a coverage claim that is only mostly true, so this rule takes the same full raw-command decline rule 18 takes. Placed immediately after rule 16 and before rule 18, and the position is load-bearing rather than tidy: placing it after the decorated-allowlist rule would be wrong because that rule blocks a bare allow entry decorated by a trailing redirect, which is one plausible spelling of the very command this rule grants. The mandated in-turn wait is a stated non-target, recorded so the omission is not read as an oversight: itskill -0 "$pid"loop is ungrantable by a settings rule for the same structural reason — the mandated form is a loop condition, so it is decorated by construction — but a grant minted around the currently-sanctioned loop form would be shaped to a primitive that measurement may correct, and a guard on the wrong primitive inherits its failure. Measure first, then grant. The measurement has since returned and the precondition is discharged, so what remains is the grant itself rather than the question it waited on: the backgrounded condition loop stands unchanged as the sanctioned form and the correction landed on the loop’s polarity, so a grant is now shaped towhile kill -0 "$pid" …as well asuntil <cond> …(delegation-kit/SPEC.md §bin/wait-probe holds the trials; §Operative residency holds the finding). Recorded here rather than left as a closed refusal because the two read differently: this one is now unblocked work, and rule 6 decides the mandated spelling out of band on every call until it is minted. - (0) Not a backgrounded launch. A statement-ending
- Auto-allow read-only pipeline — granted silently when every pipe
segment leads with a roster binary (
GUARD_KIT_RO_BINS, default the grep/head/cat/find/jq family) and every redirect target is/dev/nullor an fd-dup. Declaressq dq hd. Conservative by construction: command/process substitution, a leftover quote after normalization, any statement separator, a non-/dev/nullredirect, or afindwith a write action all refuse and fall through.xargsis on the roster but is not a text filter — it executes a command — so it carries a discriminator rather than riding the leads-with test: anxargssegment counts read-only only when the command it runs is itself a roster binary (or absent, since barexargsdefaults toecho). Without it, roster membership alone would silently grantfind . -type f | xargs rm -rfandgrep -rln foo src | xargs sed -i s/a/b/. Unrecognizedxargsoptions and a nestedxargsdecline rather than guess. The discriminator makes anxargssegment exactly as safe as the segment it runs and no safer, which is the honest bound: roster membership does not by itself prove an invocation read-only (sort -owrites a file), and that weaker predicate is a separate, already-filed gap this rule inherits rather than introduces. Two carve-outs widen the grant, and neither widens it past what the segment-by-segment safety argument already covers:- A literal
echo/printfbanner segment is skipped, the tolerance rules 9 and 10 already carry on the stated ground that a banner is the natural separator of a batched read;_guard_is_banneris shared and this is its third caller. The asymmetry was the accident, not the tolerance. At least one non-banner roster segment must remain, so a banner-only pipeline is not granted. - The lead segment may instead be a bare committed allow entry — an
exact
Bash(<cmd>)with no glob, on rule 19’s own reasoning that a glob-headed family coexists with allowlisted decorators and would admit far more than the reviewed command. The shape this repairs is an allowlisted script piped intohead: the lead is statically allowlisted, so it was reviewed; every tail segment leads with a roster binary, so it is read-only. The widened lead applies only where something decorates it (more than one segment): an undecorated allowlisted command already resolves on the static match, and intercepting it here would take it off the friction log for no gain. ReadsGUARD_KIT_SETTINGS, and the fail-open contract travels with the read: nojq, no settings file, or a parse error and the lead-widening silently declines, leaving the rule exactly as it behaves without it. A grant that depends on a settings read must never turn a missing settings file into a grant, and declining is the only direction that cannot.
- A literal
- Decorated allowlisted command — the leading command exactly matches a
committed bare allow entry (a
Bash(<cmd>)with no:*/*glob) but the command decorates it —&&/;/|chaining, a trailing redirect, or2>&1— which leaves a segment nothing grants, so the whole call falls off the match path and is decided out of band, and no allowlist entry pre-empts that. Blocked with the steer run it bare — the bare form is statically allowed; the decoration is what costs the decision. Block, not advise: the rule fires only on commands that were going to be decided out of band anyway, so blocking converts that decision into a durable steer at no extra cost, and an advise would grant the decorated command (its extra segments the allowlist never reviewed). Bare leads only: a glob-headed family (Bash(git log:*)) coexists with allowlisted decorators, so only an exact bare entry qualifies as the lead; widening to glob leads is possible later without a new name. Never intercepts a silent grant: the harness matches per segment, so a compound whose every segment matches the committed allowlist resolves on the match and blocking it would regress — the rule therefore fires only when a non-leading segment (or a redirect on the lead) fails to match any committed allow entry, reusingguard_allow_match’s shell-glob semantics. ReadsGUARD_KIT_SETTINGS; fail-open — nojq, no settings file, or a parse error and the rule silently declines and falls through. Placed after the auto-allow rules (16, 17, 18) so a silently granted read-only pipeline never reaches it — which is also why the allowlisted-lead grant lives in rule 18 and not here. Grant, not fall-through, is what removes the friction of an allowlisted command decorated only by a read-only reduction of its own output, and this rule’s own text refuses to grant, correctly: granting here would bless segments the allowlist never reviewed. Rule 18 is already the sanctioned place for a grant of exactly that shape and is already ordered ahead of this rule, so a granted pipeline never arrives and nothing ungranted becomes granted that either half would not have granted alone. Declaressq dq hd. - Git history-rewrite advisory — a
git commitcarrying--amend,-F, or--file, or agit reset --soft, gets aguard_advisesteer carrying the checklist from DOCTRINE.md’s Re-verify volatile state before a git history rewrite rule (verify HEAD before amend/squash; re-stage and verify staged content after a soft reset; write anycommit -Fmessage file fresh this turn). Advisory, not a block: each is a legitimate command, so the rule injects the re-verification context and lets the command proceed. The DOCTRINE.md rule is cited by name, not number — the doctrine’s craft rules renumber as it grows, and this ruleset renumbers on its own account too — this rule’s own number moved when rules 12 and 13 were inserted ahead of it, again when rule 14 was, again when rule 15 was, and again when the append grant landed as rule 17 — so a doc-qualified number would rot on either renumber and read ambiguously against the local numbering. Placed after the auto-allow rules (16, 17, 18) and the decorated-allowlist rule (19), so it fires only on a bare rewrite command none of those claimed; a decorated form meets rule 19’s block first and the advisory fires on the re-issued bare command. - Bare
rmof a tracked path — anrmstatement naming a git-tracked file (git ls-files --error-unmatch) is blocked with the steer togit rm -q <path>, which deletes and stages that one deletion in a single motion. The deletion is the point: a barermleaves it unstaged, so it lands only via a latergit add -A— the form the shared-index discipline warns against, since it sweeps a concurrent session’s foreign path into the commit. Block, not advise, on rule 19’s reasoning: no consumer allowlist is presumed to grantrm, so the rule fires on a command that would be decided out of band anyway and converts that decision into a durable steer. Fires per statement (;/&&/||/|split), so a decorated form steers on the same premise as the bare one. Conservative by construction: expansions and backticks decline outright (rule 6 already blocks the expansions; the backtick arm is this rule’s own), option words are skipped, and a tracked directory underrm -rdoes not match--error-unmatch— each biases toward passing rather than a false steer. An untracked or gitignored target never matches, so scratch deletion is untouched. - Script execution off a body the command string does not carry — a
command that invokes a script interpreter on a program body it takes from
outside the command string is blocked when that body’s source is a
path under a
GUARD_KIT_SCRATCH_DIRSmember. Two arms, one predicate: arm (a), the interpreter isbashorsh, steers tobin/scratch-run.sh— the allowlistable path that echoes the body as it executes; arm (b), the interpreter is aGUARD_KIT_SCRIPT_INTERPRETERSmember, states the bash-only rule (§scratch-run) and names the same runner. Declaressq dq hd. The predicate is body visibility, and the discriminator is the same one that bought the runner its grant. §scratch-run’s whole argument is that a scratch path is rewritable by any session, so the body the operator approved is not necessarily the body that runs, and the runner’s echo-at-execution is what restores the correspondence. A body carried in the command string —python3 -c 'print(1)', a heredoc, a herestring, a literal piped in — is the command string: the permission prompt shows it to the approver verbatim and the friction log records it. There is nothing for a compensating control to compensate for, so a rule firing there would refuse a reviewable act in the name of reviewability. What that argument may not claim, recorded because the obvious phrasing is the wrong way round. The guard’s own matcher does not read those bodies: the skeleton this rule declares strips single- and double-quoted spans and heredoc bodies before the rule sees the command. The visibility carrying the ruling is the approver’s, not the guard’s. Body source, not operand position. “Names a path operand under a scratch dir” is a proxy for “body invisible” and leaks in one direction: a path operand always implies an invisible body, but an invisible body does not need a path operand. So the rule resolves the body source the way the interpreters themselves do — a-c/-e/-margument (in the command string), else the first bare operand, else stdin — and fires wherever the scratch path turns out to sit: operand,<redirect source, the pipe producer’s operand, or a substitution operand. That resolution is also what keeps the rule off legitimate tooling, and it is exact rather than a carve-out:python3 tools/gen.pyandbash <kit>/bin/scratch-run.sh .tmp/x.shboth name a body that is not under a scratch dir — a tracked tool in the first, the runner itself in the second, with the scratch path an argument to it — so neither fires. The scratch-directory scope is not the proxy the previous paragraph drops: the proxy was operand position, and position is what this rule stops caring about. The substitution shape is covered in both spellings, and rule 6 is why it had to be. Rule 6’s match carries no backtick alternative, so it blockspython3 -c "$(cat .tmp/x.py)"and passes the identical command spelled with backticks. A guard that blocks one spelling of a shape and passes the other teaches the spelling rather than the rule, so this rule reads both substitution spans; rule 6 merely pre-empts one of them by dispatch order, which is ordering rather than duplication. It splits statements and then pipes, rather than callingguard_split_compound, and the divergence is forced: what this rule needs is dataflow — whose stdout is the interpreter’s stdin — and the shared splitter erases the separator that tells a pipe from a;. It is not modelling the harness’s per-segment matching surface, which is what that splitter is the single implementation of. Two honest limits, neither closed here. A body drawn from outside the scratch dir (python3 - < /elsewhere/x.py) is out of scope by the paragraph above, and a body with no path at all (curl … | python3) is a wider hazard with nothing to do with scratch execution. Both fall through, and a later reader should meet that recorded rather than infer the rule is complete. Conservative in this ruleset’s established directions, with one named exception: a${…}/$NAME/<(…)/>(…)anywhere in the command declines outright (rule 6 blocks those shapes already), and an interpreter option the walk cannot size declines rather than guess which token is the body — but a backtick is deliberately not declined on, since it is the one body-source spelling rule 6 does not reach and declining there would ship the hole the rule exists to close. Placed last, immediately before fall-through logging. It grants nothing, so it inherits no auto-allow ordering argument; and a command reaching it has already been declined by every steer and every grant above, which is exactly the population whose body source is worth resolving. - Fall-through logging — anything neither blocked nor auto-allowed is appended to the friction log. Always last; never affects the decision.
Nothing above claims the sleep half was already enforced. Before rule 13, no
rule in this ruleset enforced the never-poll rule’s sleep half and rule 13 is
the first; whether a mechanism outside a consumer’s tree blocks a foreground
sleep is an open question, unprobed as to cause, and no clause here rests a
coverage claim on one. The rule is built as though nothing outside the tree
enforced it — the only construction that is correct whichever way that resolves.
Consumer rules
Project rules — build-cache hygiene, container-build concurrency,
test-suite serialization, disk reclaim, tool-specific steering — live in a
marked section of the consumer’s copied bash-guard.sh, before the generic
ruleset. Two ordering disciplines, attested in production use and kept as
guidance: a command about to be blocked must never first trigger a
side-effecting rule (place blocks before any reclaim/cleanup rule), and a
steering rule must precede the broader rule that would catch the same
command less precisely. A third, on the match surface: a substring rule
sees the entire tool input, heredoc bodies included, so prose that merely
names a guarded command — a git commit -F - message describing one —
false-blocks. Match against a guard_skeleton view naming the classes inert
for the rule (sq dq hd for any rule that does not itself test for an
expansion), which neutralizes quoted spans and heredoc bodies alike, and keep
longer prose out of the match surface via a quoted -m span or a -F <file>
the hook never reads.
scratch-run
bin/scratch-run.sh <script> [args…] closes a loop the steering rules
themselves open. The generic ruleset pushes probes and multi-line sweeps
into the consumer’s gitignored scratch dir (GATE_SDK_TMP_DIR) and refuses
the harness scratchpad by name — yet nothing allowlists executing what
lands there, so every run of a steered-to script is decided out of band, forever. The
runner is the fixed, allowlistable path that ends it: given a target inside
the scratch dir it echoes the script’s contents to stdout (a header
naming the path, then the body), then executes it, passing stdout,
stderr and the child’s exit code through and forwarding [args…] verbatim
without interpreting them.
The echo is the point, not a convenience. A bare scratch-glob allowlist would auto-approve executing any script at that path with its contents visible only in an earlier write call — turning a visible command into a silent and opaque one, the opposite of what the permission decision was buying. Echo-at-execution makes the silent run self-documenting: the executed lines land in the transcript at the moment they run, which is the evidence surface a supervisor’s post-commit review reads. Steering the agent to inline the script instead is known-insufficient — it serves a short probe and not the genuine multi-line sweeps (loops, arrays, command substitution) that are exactly the recurring case.
What the allowlist entry buys, and what it costs. The consumer’s entry names this fixed path with a wildcard tail, and a guard only ever inspects the outer command string — so an allowlisted invocation of the runner carries no expansion and no decoration, passes, and the script’s contents are never guard-inspected at all. State that plainly rather than as a convenience: the generic rules that would refuse an expansion-bearing one-liner do not reach the same code once it sits inside a scratch file. The echoed body is the compensating control, and it relocates review rather than removing it — from before execution, where the permission decision put it, to after, in the transcript a supervisor reads. That is the actual posture of the entry, and a consumer unwilling to move review downstream should simply not add it: the tool still runs without it, and its runs still take that decision.
Scratch execution is bash-only, and the runner’s hardcoded interpreter is the statement of that rule rather than an unexamined default. Executing a program body that lives under a scratch directory is bash-only; rule 22 is the command-line half of the enforcement and the shebang refusal below is the runner’s own half.
Why widening the runner is refused outright rather than costed. Teaching it
a second interpreter is not merely more expensive, it is wrong: the runner is
reached through a committed allowlist grant naming this fixed path, and that
grant was bought by one specific compensating control — the runner echoes the
body it is about to execute, and it only ever hands that body to bash. A
second interpreter converts a grant for “run bash on a reviewed body” into one
for “run anything on a reviewed body” with no settings edit, widening a
permission behind the boundary the consumer’s operator owns, through a code
change no permission gate reads. That is a security argument rather than a cost
one, and it stands on its own.
What bash-only costs, stated rather than glossed. It removes a capability: a
session wanting a Python scratch script must write a .sh that invokes Python
with the body inline, or do the work in a language the control covers. The
narrowing is taken deliberately — the population of non-bash scratch runs is
small while the control’s silent hole applied to every one of them.
The runner refuses a non-bash shebang, and it reads the file rather than a
roster. It already cats the target before executing, so it holds the body:
when line one is a #! naming an interpreter that is neither bash nor sh
(resolving the /usr/bin/env <interp> spelling to the same answer) it exits 2
naming the bash-only rule, before the echo — so a refusal still prints no body
and stays distinguishable from a child that exited 2. A target with no
shebang is unaffected, which keeps every .sh the runner handles today working.
That is exact where a roster is approximate, and it is derivation-first: the
file states its own interpreter. Two mechanisms, each exact for the input it
actually has — the guard has a command string and no file, the runner has a
file, and this is why the runner deliberately does not read
GUARD_KIT_SCRIPT_INTERPRETERS.
What deliberately does not become config: the policy. Bash-only is a rule, not a setting, and a knob that turned it off would restore the honour system the rule replaces. The valve is not vendoring the rule.
Fail-closed on reach. A target resolving outside the scratch dir is
refused before any echo or execution, so the tool cannot become a general
“run this arbitrary path unreviewed” bypass: its reach is exactly the
scratch surface the guard already steers into. The test reads the
resolved path, never the spelling, so a traversal out of the dir refuses
like any other outsider. Refusal, an absent target, and a missing argument
are each exit 2 — which the passthrough makes ambiguous by construction,
since a child exiting 2 is indistinguishable by code alone; the echo is the
discriminator, as a refusal prints no body. Content-agnostic generic
mechanism: the scratch dir comes from gate-sdk’s existing GATE_SDK_TMP_DIR
and no kit knob is added, while the consumer’s allowlist entry for the fixed
path is that consumer’s settings, never a kit literal.
scan-prompts
Advisory: surfaces recurring permission-friction sources from the friction
log, filtering against both settings files (GUARD_KIT_SETTINGS and
GUARD_KIT_SETTINGS_LOCAL) and the harness’s built-in read-only git/docker
auto-allows. Matching is per compound segment, the same surface the
harness matches on (§The guard framework, guard_split_compound): a logged
command is granted only when every segment is, so a whole-string glob that
spans a compound the harness would split and refuse — Bash(git status:*)
against git status && rm -rf x — does not read as allowed and is counted as
prompting.
What “prompting” names in this tool’s output. The word is the tool’s shipped vocabulary and stays as it is; what it counts is the set of calls nothing in the allowlist grants, whose decision the harness therefore made out of band. On a harness that interrupts, that set is literally the prompts; on one that decides another way, it is the same set under an inherited name. The count is the same number either way, and it is the number the triage criterion reads.
The friction log’s fall-throughs split three ways:
- Committed-covered — every segment matches the committed allowlist or a harness built-in. Silently granted, reinforced; off every list.
- Prompting — some segment nothing grants. The headline
<n> prompting call(s), grouped and ranked by pattern (leading binary, plus subcommand for the common multi-command binaries, plus the write-shape suffix below), triaged at close by the criterion above. - Overlay-covered — granted, but at least one segment relies on the
uncommitted
GUARD_KIT_SETTINGS_LOCALoverlay. It did not prompt, so it is excluded from the headline (the count is a true prompt count, not an upper bound), yet it is exactly the promote-or-prune candidate the close step must see — so it is ranked in a separate, visibly-advisory section below the headline, never mixed into it.
The write-shape suffix, and why a bare command word was reporting the wrong
thing. The key appends the segment’s write-redirect operator when it
carries one, so cat > <file> <<'EOF' ranks as cat > and cat >> <file>
<<'EOF' ranks as cat >>, while a redirect-free segment keys exactly as it did
before. The operator is normalized to two values, > and >>: which descriptor
is redirected is dropped (2> f keys the same as > f), because the axis the
row has to carry is this segment writes a file, and create-versus-append is the
finest distinction any reader of the ranking has wanted. A descriptor-dup
(2>&1) is not a redirect to a file and does not qualify. The resulting shape is
one the ranking already had — git status and python3 - are two-token keys
today — so the suffix introduces no new output shape, only a new source for the
second token. A write redirect standing where a subcommand would is re-homed into
the suffix rather than doubled into both tokens; a read redirect is not this
axis’s subject and keys where it always did.
The word and the suffix come from the same segment, and without that the
change would be worse than the defect. Both are read off the first segment
of the logged line, split with guard_split_compound from a guard_skeleton
view. The word alone would be unchanged by this — the first token of the whole
line is the first token of its first segment — but pulling a redirect from
anywhere in the line would key mkdir -p .tmp && cat > x as mkdir > and
attribute a write to a command that performs none. Reading both from one segment
makes the key internally consistent by construction. Which segment should be
keyed when the friction-bearing one is not the first is a separate axis — which
segment, not which shape — and is not settled here.
No parser is minted for the redirect, and the composition is stated because
the obvious spelling is the wrong one. The detection reuses
_guard_redirect_pairs, the operator-and-target scan rule 17 already performs,
and excludes an fd-dup by rule 17’s own target test. It deliberately does not
reach for _guard_redirect_targets to do that exclusion: that helper drops an
fd-dup target only as an accident of its target class, which is the very reason
_guard_redirect_pairs exists, so resting the fd-dup rule on it would rest it on
an accident. These are _-prefixed internal helpers rather than the documented
guard_* surface, called from a bin/ tool inside the same kit — a kit-internal
call that widens no consumer contract.
What the top row actually is, recorded because the entry that bought this
change predicted otherwise. A read-shaped cat <file> cannot appear in the log
at all: rule 10 blocks it and guard_block exits 2 before guard_log_fallthrough
runs. Measured on the live log at the landing: of 43 cat-led lines, 33 were
cat >>, 5 cat >, and the 5 remaining bare rows were all pipe-reads
(cat <file> | <consumer>, one of them fd-dup-decorated) — composition, which is
exactly what rule 10 deliberately does not block. So the row was never an
answered steer and an unowned write sharing a key: it was the unowned write
alone, wearing the word that names the answered steer. That is also the change’s
checkable post-condition — after it, a bare cat row is a real finding
(a pipe-read or a multi-file read), not noise.
cat is the only word this bites today, and the measurement that bounds it is
recorded with it. Decomposed across the same live ranking: awk and grep
were reads throughout, python3 - was inline heredoc execution and already
two-token, git is already subcommand-keyed so its reads and writes already
occupied separate rows, and sed was absent because rule 8 blocks its read and
in-place forms upstream of the log. echo/printf’s near-absence is only
partly structural, and the boundary is stated rather than overclaimed: rule 17
matches the >> arm only and, on a match, guard_allow exits the hook directly,
so an append through a roster emitter can never reach the log under any key —
a structural guarantee. A create redirect (echo foo > .tmp/x) matches no
auto-allow rule, falls through like any other command, and keys as echo > the
day one is run. The honest reading is that the axis is general and currently
near-single-instance, and a later reader deciding whether to extend or retire
it needs to know the bite was measured rather than assumed.
--count emits the compact <patterns>/<occurrences> prompting token for a
drift-KPI consumer (overlay-covered excluded, so the KPI reads true); an
explicit file argument overrides the log path (test capability). The two
compose, in either order — the argument parse is a loop over argv, not a
read of $1 — and that is pinned rather than left implicit because the
single-argument parse it replaced made --count <path> return a real-looking
number for the default log, which is the worst failure mode an instrument has:
silent, plausible, and reached for first by exactly the measurement session that
cannot afford it. Its behavior
— the three-way split, the per-segment matching, the true count, the
argument-override in both orders, and the
write-shape suffix’s create/append/fd-dup/first-segment cases — is pinned by
gate-tests/scan-prompts.test.sh. What that test pins is the split, the count
semantics and those four cases; the key’s granularity beyond them is not a
contract, which is why an additive suffix leaves its substring assertions true.
The KPI’s numerator steps at the landing commit, for a definitional reason.
Splitting one key into two raises <patterns> while leaving <occurrences>
unchanged, so drift-kit/kpis/kpi-prompt-friction.sh reads a discontinuity that
is not behavioral. The ^[0-9]+/[0-9]+$ contract it asserts is unbroken, so
nothing reds — which is exactly what makes the step silent and worth stating. The
pre-change reading, so a later trend read can attribute the step rather than
re-derive it: 38 patterns across 173 prompting calls, which the same log read
as 44 patterns across the same 173 calls immediately after. The KPI is not
changed to compensate: a key change that makes the metric finer is the metric
getting better, and rebasing it to hide the step would trade a legible one-time
discontinuity for a permanent lie about granularity. drift-kit reads trend, not
level and carries no annotation affordance, so this sentence is the annotation.
compare-settings-allow
Advisory: lists local-overlay allow entries already granted by a glob in
the committed settings — the deterministic prune-candidate set for the
close-stage audit. A committed pattern subsumes a local entry when the
local string matches it under shell-glob semantics; the harness :* prefix
idiom (Bash(printf:*) ≡ any printf …) is normalized to a trailing *
so one glob test covers both forms — the match core is guard_allow_match
in lib/guard.sh, shared with rule 19. Read-only — reports candidates, never
mutates (the operator prunes). It is the detector, not the policy: a
non-redundant local entry can still be one-off junk worth pruning by
judgment.
The tool asks a second question over the same two files: which local entries
are too broad. Redundancy asks whether a committed glob already grants a local
entry; breadth asks whether a local glob would auto-allow a command the consumer
called bad. Both are one call to guard_allow_match — the breadth question
simply swaps its arguments, guard_allow_match <probe> <local-glob>, so there is
no second matching implementation and the :* normalization is shared. The two
findings are reported as distinct sets, and breadth is advisory in the same sense
as redundancy: the report names the local glob and the one probe that witnesses
its breadth, and the operator disposes — narrow the glob, or record that the
breadth is intended.
The probe set is consumer config (GUARD_KIT_BREADTH_PROBES, §Layout and
configuration), and it is probes rather than a roster: each entry is a single
witness whose auto-allowance would be bad, not a member of a set claimed
complete. A missing probe costs one witness and can never produce a false green,
because no completeness is claimed — and nothing here may come to claim it, since
a report worded as coverage would be the false-confidence proxy gate-sdk/SPEC.md
§When a gate earns its place refuses. With no probes declared the breadth section
is omitted entirely rather than printed clean, so a consumer that declared no
vocabulary cannot read silence as coverage.
The second disposition has a mechanism, and where the ruling lives is what makes
it durable. GUARD_KIT_BREADTH_DECLARED (§Layout and configuration) maps a
permission-rule string to the reason that breadth was ruled intended — a pair,
never a bare list, since a bare list re-loses the reason it exists to keep. It
lives in the committed config the consumer tracks, so the ruling survives in a
reviewable surface rather than in a session’s memory or a commit message, which
spec-over-precedent says is not ground truth. That fixes where a declaration
lives; it does not widen what the breadth question reads, which stays the
local overlay named above. An over-broad local glob keeps re-reporting until a
committed declaration rules it, and a per-clone declaration cannot silence
one.
The declaration match is exact-string, never glob. An entry is declared when
the exact local allow-rule string is a key of the map — a key test and nothing
else. Routing it through guard_allow_match is refused: the kit’s one matcher
would let a single declaration silence globs the operator’s ruling never named,
which is a declaration widening itself, and a durable ruling about intended
breadth is a ruling about one glob. The consequence is stated rather than left
to be discovered — narrowing a declared glob, or re-spelling it, drops its
declaration and the entry re-reports. That is correct, since the ruling was taken
on the old string, and it is what keeps a declaration from outliving the glob it
ruled.
A declared glob is not dropped from the report. The over-broad set partitions in two and both halves print:
- narrowing candidates — over-broad and undeclared, printed with the two-disposition help text above.
- declared intended — over-broad and declared, printed as
<glob> ⊇ <probe> — <reason>under its own heading, with help text naming the committed config as where the ruling lives.
Dropping a declared glob would make the knob a silencer whose contents nobody ever
reads, and a field with no named reader is removed rather than shipped
(canon-kit/SPEC.md §The causal-completeness check, point 4). An over-broad set
that is entirely declared therefore prints the declared section and no
narrowing section: no over-broad local entries would be false there. With no
over-broad entries at all the report is unchanged, and with the map empty — the
default, and the shipped state for a consumer who declares nothing — the partition
is trivial, the declared subsection is omitted, and every byte of output matches
the tool as it behaved before the declaration shipped. The asymmetry with
GUARD_KIT_BREADTH_PROBES is deliberate: an empty probe set omits the whole
breadth section because silence there could be misread as coverage, while an empty
declaration set omits only the declared subsection, where no coverage claim is
available to misread and an absent section says exactly that nothing was declared.
The honest limit: a declaration can outlive its subject and nothing notices.
The knob records a ruling; it does not verify that the glob it names is still in
GUARD_KIT_SETTINGS_LOCAL, still over-broad, or still real. A stale declaration
is silent. A stale-declaration report was weighed and refused, because it would
print every declaration naming a committed glob as stale — and a committed
glob was never in this report to begin with, so a declaration naming one is a
durable record that the report itself does not read. That is exactly the shape a
re-derivation of the committed allowlist produces when it rules a glob safe and
keeps it broad, and it is stated rather than covered; the gap is filed rather than
flagged-and-skipped. Nothing here makes a gate of the tool either: the placement
ruling below is unchanged and unweakened by a knob that records exactly the
operator intent that ruling turns on.
This is the criterion’s home rather than a gate, and the placement is ruled rather than defaulted: the subject is a gitignored per-machine file CI cannot see, and the verdict is operator-intent-dependent (a blanket grant is fine in a throwaway sandbox), which is the high-false-positive shape §When a gate earns its place holds back for an attested miss. What replaces the gate is a scheduled reader — §The close-stage triage step — the same mechanism the redundancy criterion has relied on since it shipped.
That ruling is about this subject, not about allow-list checking generally,
and the boundary is stated because the section otherwise reads as covering the
whole surface and the next reader re-derives it. Both clauses are load-bearing
and both fail on the committed file: it is tracked, so CI sees it, and “does
this path exist” is a binary filesystem fact no operator intent can move. A
committed-allow-list predicate of that shape is therefore gated, and gated
elsewhere — context-kit/SPEC.md §check-settings-paths, with the kit that already
owns the committed settings file as a gate subject. This kit holds the three
.permissions.allow[] readers in the tree and still ships no gates; co-location
by parsing technique does not outrank placement by governed surface.
--count emits both bare counts on one line, redundancy first and breadth
second, and the breadth number counts the narrowing candidates rather than
every over-broad entry: the count’s one purpose is how much is outstanding, and
a declared entry is not outstanding. A third number for the declared count is
refused — no reader needs it, and the two-number line is a shape a consumer may
already parse.
wakeup-guard (template)
Optional second guard, same framework, opposite posture: blocks
self-scheduled wakeups (ScheduleWakeup/CronCreate) unconditionally and
logs each attempt — a stored prompt re-fires in a later session as if the
user typed it, long after its premises are stale, and the scheduling call
is invisible at the moment it matters. Fail-closed (the matcher proves the
tool identity, so a logging failure still denies). The attempt log is
reviewed and deleted in the same close-stage triage pass as the friction
log. Deliberate scheduling stays possible by disabling the hook for a
session — the block is the default, not a capability removal.
escalation-guard (template)
templates/escalation-guard.sh is the wakeup-guard’s sibling — same framework,
a tool-targeted matcher, a separate opt-in hook — but advisory, not
fail-closed, and the opposite failure posture: an advisory never blocks a
message, so any inability to inspect the payload passes silently. Registered as
a PreToolUse(SendMessage) hook, it fires only on a message addressed to the
lead (a background stage session reaches the live lead as main) and advises,
feeding additionalContext, when that message lacks any of the decision-shape
headers Question / Options / Recommendation / Evidence. A downward or
peer-directed message, or one already carrying those headers, passes untouched.
It is the mechanical floor under lifecycle-kit’s lead protocol
(lifecycle-kit/SPEC.md §templates/lead.md): prompts request the escalation
shape, this guard enforces it. The header grammar is the kit’s mechanism; the
ruling-class roster — what a stage session must escalate at all — stays consumer
config in the dispatched agent-definition, never here. Opt-in is the consumer’s
settings registration, the same valve as the wakeup-guard; absent it the
template is inert prose, the intended default (this repo leaves it unwired, as
it does the wakeup-guard). That stated default is the only enforcement of the
wiring: no gate observes it — check-settings-pins and check-memory-off stay
green whether the hook is wired or not — so a session decides whether to wire an
optional guard by reading this section, never by predicting a gate’s verdict or
reading the guard’s own source to infer one. It sources no config and writes no
log — the advisory is transient, so nothing accrues for the close-stage triage.
The close-stage triage step
templates/close-triage.md is the recurring step a consumer splices into
its close-stage skill — it fills the tooling-friction placeholder in
lifecycle-kit’s close template. The step: run scan-prompts, resolve each
recurring pattern by the triage criterion; review and delete the wakeup
log if present; run compare-settings-allow and take its two
dispositions — prune the listed redundant local entries, and for each entry the
breadth report names as a narrowing candidate, either narrow the glob or record
that its breadth is intended as a GUARD_KIT_BREADTH_DECLARED entry in the
committed config, which is what moves it into the report’s declared section and
stops it re-reporting — then by judgment prune the remaining one-off exact-string local
entries and promote recurring safe patterns to the committed settings as
globs; clear the friction log. Two judgments the reports cannot make sit in
that last step. An entry naming a script path rather than a fixed command is
not content-pinned — it grants whatever the file says at run time — and its
sanctioned form is bin/scratch-run.sh (§scratch-run), whose echo is the
compensating control; and widening the committed set is the consumer’s call,
since a session does not widen its own auto-allow set on its own say-so. Goal: the local set stays small, every
durable pattern lives in the committed, reviewable allowlist, and no local glob
auto-allows a command the consumer declared bad. One step, two reports, one
reader — no new invocation point and no new schedule.
The friction log is a capture-tier surface with no forcing function — nothing refuses a close that skips it — so it declares itself advisory on the close-surface roster (lifecycle-kit/SPEC.md §The close-surface roster), naming the clear above as its reclaim path:
close-surface: .workflow/prompt-friction.log advisory reclaim=: > .workflow/prompt-friction.log
Layout and configuration
guard-kit/
lib/guard.sh # primitives + generic ruleset functions
bin/scan-prompts.sh
bin/compare-settings-allow.sh
bin/scratch-run.sh # echo-then-exec runner for scratch scripts
bin/run-guard-tests.sh # decision-table runner
guard-tests/cases.tsv # expected-decision <TAB> command
guard-tests/escalation-cases.tsv # expected-decision <TAB> to <TAB> message
guard-tests/background-cases.tsv # expected-decision <TAB> run_in_background <TAB> command
gate-tests/scratch-run.test.sh # bespoke unit test, run by gate-sdk's runner
gate-tests/scan-prompts.test.sh # bespoke unit test, run by gate-sdk's runner
gate-tests/compare-settings-allow.test.sh # bespoke unit test, run by gate-sdk's runner
templates/bash-guard.sh # consumer copy: generic rules on, marked
# consumer-rules section
templates/wakeup-guard.sh
templates/escalation-guard.sh
templates/guard-config.sh
templates/settings-hooks.json # the PreToolUse wiring snippet
templates/close-triage.md
smoke/install.sh
Config follows the established kit pattern: copy
templates/guard-config.sh into the gates dir (or point
GUARD_KIT_CONFIG_FILE elsewhere) and override any knob; defaults fill
what the consumer left unset, and a set-but-missing GUARD_KIT_CONFIG_FILE
exits 2 rather than silently running on defaults. In a hook that sources the
lib, that exit 2 surfaces as a hook block carrying the not-found message —
loud on the first guarded command, the intended fail-closed (the guards
gate the lib source on file existence only, so the message is never
swallowed; a lib that is not vendored at all stays fail-open). Knobs (this
repo’s layout as defaults):
GUARD_KIT_LIB— the vendoredlib/guard.shpath the copied guards source (the test runner points it at the tree under test); defaultguard-kit/lib/guard.sh. Env or the copied guard’s head only — it resolves before the config file loads, soguard-config.shcannot set it.GUARD_KIT_LOG— default${GATE_SDK_WORKFLOW_DIR:-.workflow}/prompt-friction.log.GUARD_KIT_WAKEUP_LOG— default${GATE_SDK_WORKFLOW_DIR:-.workflow}/wakeup-attempts.log.GUARD_KIT_SETTINGS— default.claude/settings.json.GUARD_KIT_SETTINGS_LOCAL— default.claude/settings.local.json.GUARD_KIT_BREADTH_PROBES— array of permission-rule strings, each a witness that a local glob is too broad (§compare-settings-allow); default empty, in which case the breadth report is absent and the tool behaves exactly as it did before the criterion shipped. Entries are full permission-rule strings rather than bare commands, matching the settings vocabulary the tool already reads on both sides, so a consumer can probe non-Bashrules with the same mechanism. The kit ships no default probes: every string naming a command is the consumer’s vocabulary, never the kit’s (CLAUDE.md §The provenance seam).GUARD_KIT_BREADTH_DECLARED— associative array recording the breadths ruled intended (§compare-settings-allow): the key is a permission-rule string, the value the reason that breadth was ruled intended; default empty, in which case the declared subsection is absent and the tool behaves exactly as it did before the declaration shipped. Associative rather than a delimited indexed array, and the choice is not cosmetic: a permission rule may contain any character a command may, so every single-character separator an indexed array would need (|,::, a tab) is a character a legitimate rule can carry, and the knob would ship a grammar that cannot express part of its own subject. An associative key holds the rule verbatim, and a key is unique by construction, so the which reason wins question the delimited shape would raise cannot arise. Report order never depends on the map’s iteration order: the tool walks the settings file’s own allow list and looks each entry up, so the map is never iterated and the unordered iteration never reaches output. The kit ships no default declarations, on the same seam reasoning as the probes above.GUARD_KIT_RO_SCRIPTS— array of globs eligible for the absolute→relative rewrite (rule 4); default("check-*.sh").GUARD_KIT_RO_BINS— read-only pipeline roster (rule 18); default the grep/head/cat/find/jq family, plusxargs, whose membership is qualified by rule 18’s discriminator rather than granting on the leads-with test alone.GUARD_KIT_APPEND_BINS— the emitter roster of the append grant (rule 17); default(cat printf echo). A knob rather than a kit literal onGUARD_KIT_RO_BINS’s reasoning: a consumer whose mandated write rides a different emitter shadows the array instead of forking the rule, and three POSIX utility names are shipped mechanism rather than a vocabulary, so defaulting them crosses no provenance seam. The roster’s contract is writes only to its stdout, and a consumer adding a command that writes anywhere else widens the grant past rule 17(c)’s safety argument — the same widening hazardGUARD_KIT_RO_BINScarries, stated here because this roster’s members are what bound a write rather than a read.GUARD_KIT_SCRATCH_DIRS— gitignored scratch dirs named in the rule-3 corrective message, and the scope of rules 14, 15 and 22; default(".tmp").GUARD_KIT_SCRIPT_INTERPRETERS— the non-bash interpreters rule 22’s arm (b) covers; default(python python3 node deno ruby perl php zsh). A roster is the shape the three knobs above already use for a set of this kind, and making it a knob is what answers the a roster rots objection: the kit ships a default and the consumer owns the value, so a missing member is a config edit rather than a kit release. Its members are universal interpreter binaries — none of the classes CLAUDE.md §The provenance seam names — so unlikeGUARD_KIT_BREADTH_PROBESit is kit-shippable with defaults; that roster is consumer config because its members are private, so the test is the content and never the shape. It has exactly one reader by design: arm (b)’s trigger.bin/scratch-run.shdeliberately does not read it (§scratch-run), so the knob never becomes a second copy of a fact the target file itself states.
Both logs are per-iteration scratch: a consumer gitignores them even where its workflow dir is otherwise committed.
Testing
The gate contracts do not fit hooks (a guard speaks exit-2 + hook JSON, not
OK:/FAIL: lines), so the kit ships its own decision-table runner
instead of gate-tests/: guard-tests/cases.tsv pairs an expected
decision (block/advise/allow/rewrite/fallthrough) with a command;
bin/run-guard-tests.sh feeds each through the template guard as hook JSON
on stdin and asserts the exit code and output class, failing on any
mismatch. Every generic rule carries at least one firing and one
non-firing case (the fixture-pair discipline, transplanted). Two substitutions
make a command expressible in one tab-separated cell: @ROOT@ becomes the git
sandbox root, and @NL@ becomes a newline — without the second a heredoc case
cannot be written at all, and the heredoc class would ship untested.
The runner drives each case from inside the sandbox, which decides how a
target is spelled and is stated because getting it wrong reads as a rule defect
rather than as a spelling one: a path is written relative unless the case is
about an absolute one, since rule 5 blocks any command carrying the repo-root
prefix and an @ROOT@-absolute target therefore never reaches the rule under
test. @ROOT@ is for the cases whose subject is the absolute form — rules 2, 4
and 5 — and for a foreign-repo contrast.
Any ad-hoc invocation of a consumer’s guard script must set GUARD_KIT_LOG to
a scratch path, because the logger’s default is the live friction log and a
throwaway probe otherwise files its synthetic commands as real friction. The
kit’s own runners all set it — the decision table into its sandbox, the
scan-prompts fixture into its own — so the convention is invisible until a
hand-written latency or behaviour probe drives bash-guard.sh in a loop and
poisons the next close’s ranking with its payload. Measured rather than
hypothetical: one such probe contributed 30 of 237 ranked prompting calls at a
single close, ranking second. No scanner is proposed: the polluting caller is
a one-off script in the gitignored scratch dir, so there is no committed corpus
to scan and the convention is carried by this sentence and by
bin/scratch-run.sh’s echo-at-execution.
The decision table is the instrument for any change to what the guard
refuses, and its red condition is not monotone. A change that narrows
refusal cannot be cleared by inspection, because the table fails on a verdict
mismatch in either direction rather than on a violation count: every
existing case whose command carries a double-quoted brace, a heredoc, a banner
segment, an append redirect to a gitignored target, or an allowlisted lead with
a read-only tail must have its expected
column re-derived, never assumed still correct. Rule 17’s landing is the
worked instance and the cheap one: it narrowed refusal and flipped exactly one
pre-existing row, which the table reported and inspection had not.
bin/scan-prompts.sh cannot
substitute for it, and the reason is structural rather than a matter of
precision: guard_block exits 2 before guard_log_fallthrough runs, so a
blocked command never reaches GUARD_KIT_LOG and that report is blind to every
block the guard makes. A change converting a block into a grant therefore reads
as a pure improvement on the scan and as nothing at all — which is why the
table, not the scan, is what a narrowing change is measured on.
The same runner drives the escalation-guard from a second table,
guard-tests/escalation-cases.tsv (<decision> <TAB> <to> <TAB> <message>),
feeding a SendMessage-shaped payload instead of a command: a firing case (a
headerless message to main → advise) and its non-firing pair (a fully shaped
block, and a non-main recipient → fallthrough) hold the same fixture-pair
discipline for the advisory.
A third table, guard-tests/background-cases.tsv
(<decision> <TAB> <run_in_background> <TAB> <command>), extends that same
precedent to rule 15’s harness arm, and it is forced rather than tidy: the
cases.tsv grammar pairs a decision with a command, and the harness arm’s
input is a tool parameter, so that arm cannot be written there at all and
would otherwise ship untested — the coverage-in-appearance this rule’s own design
refuses. The runner feeds a Bash-shaped payload carrying the flag. Rows, each a
firing/non-firing pair: harness-form background with no record → advise;
harness-form background writing a .run record → fallthrough; harness-form
background of a wait loop → fallthrough; harness-form background of a read-only
pipeline → the auto-allow it already earns; and a foreground call carrying
neither → fallthrough, which is what proves the rule inert off the backgrounding
path. The shell-& arm is a command, so its firing and non-firing rows stay in
cases.tsv. Rule 15 emits an advise, so it converts a fallthrough row into
an advise row wherever it fires: every existing row whose command carries a
trailing & has its expected column re-derived under the non-monotone rule
above, never assumed still correct.
smoke/install.sh copies the templates into the scratch consumer (guard
and config into the gates dir, hook wiring into .claude/settings.json,
log paths gitignored) and then drives one crafted payload directly through
the installed guard, asserting a block — the install is self-verifying.
There is no smoke/violation.sh: the kit registers no gates, so no
battery-reddening violation is craftable (gate-sdk/SPEC.md §Consumer smoke
makes that file conditional on exactly this).
compare-settings-allow’s breadth criterion takes the same bespoke lane for the
same reason, at gate-tests/compare-settings-allow.test.sh: a firing probe
(a blanket local glob reported with its witness), a non-firing one (a narrow
entry reporting clean), and the empty-knob silence that proves the section is
omitted rather than printed clean — which, with GUARD_KIT_BREADTH_DECLARED
empty, also proves the declared subsection absent. The declaration’s own cases sit
beside them: a declared over-broad entry printing in the declared section with its
reason and not in the narrowing set; an all-declared over-broad set printing
the declared section, no narrowing section and no false clean line; --count’s
breadth number excluding the declared entry; and an exactness case — a declaration
differing from the local entry by one character leaves that entry in the narrowing
set, which is the assertion that the lookup never became a glob match. It drives the tool through
GUARD_KIT_CONFIG_FILE pointed at a sandbox config, so the consumer’s own probe
array cannot leak into the fixture.
A shipped bin/ tool that is not a hook takes neither lane. bin/scratch-run.sh
(§scratch-run) is tested by a bespoke unit test at
gate-tests/scratch-run.test.sh, asserting echo-then-exec on an in-scratch
target, pass-through of args and exit code, and the fail-closed refusal of an
out-of-scratch one. It is not a guard-tests/ row: that table’s grammar is a
decision paired with a command, which cannot express any of the three. It is not
a gate either, so no good/+bad/ fixture pair is owed — but shipped mechanism
owes a test, and the bespoke-unit-test lane is gate-sdk’s
<tests-dir>/*.test.sh (gate-sdk/SPEC.md §run-gate-tests), which admits a tests
dir carrying unit tests and no fixture pair. Homing it there is what puts it
under check-test-hermetic, whose assertion A enumerates
<kit-root>/gate-tests/*.test.sh — the enforcement a kit-local runner would
forfeit. A kit’s first gate-tests/ directory also obliges a fixture-runner
line in the consumer’s battery, which check-kit-registration reads from
git ls-files: the line belongs in the same commit as the test, never a
follow-up.
A gateless kit shapes gate-sdk’s discovery rule: gate_kit_roots recognizes a
sibling kit by its checks/ or smoke/ directory. Keying on checks/
alone would leave this kit undiscovered — its smoke/install.sh would never
run under run-consumer-smoke.sh, and its lib/ and bin/ would escape
check-shellcheck’s self-lint sweep.
Out of scope
Every toolchain- and product-coupled guard rule is consumer rule content:
build-cache hygiene, container-build concurrency and restart discipline,
test-suite serialization rosters, proactive disk reclaim, tool-preference
steering, and any read-only script roster beyond check-*.sh. So are
allowlist contents beyond the read-only core, and a memory-policy write
guard (a product ruling, not friction mechanism). The split is framework
and generic rules here, a consumer’s rules in its own copied guard.