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, interrupting a human or running a model to judge the call. Nothing about that decision lands in the transcript, so the agent cannot notice, count or fix the friction it causes. It is paid per call, in the operator’s attention or in latency and tokens, and compounds as the command surface grows. The kit closes the loop:

  • a PreToolUse guard decides at call time: it blocks with a corrective message, steers to a better form, auto-allows the provably safe and logs every fall-through;
  • a scanner ranks the logged prompt sources;
  • a curation pass keeps the committed allowlist durable and the per-user overlay small;
  • a recurring close-stage triage step makes the loop a habit rather than a one-off cleanup.

The kit carries the shell guard with its harness-generic ruleset and its consumer-rule seam, and the triage tooling; every project-specific rule is consumer content (§Out of scope). Its runtime surfaces are mostly hook members and advisory arms of the gate binary rather than gates.list members. It registers one gate, check-door-binding (§check-door-binding), which holds the provenance seam on the ruleset’s own placeholder spellings, and otherwise follows gate-sdk’s layout and smoke conventions without depending on its registry.

The friction loop

  1. Call time — the shell guard (--hook shell-guard, wired as the PreToolUse(Bash|PowerShell) hook) inspects each command: block, steer, rewrite, auto-allow, or fall through. Every fall-through, exactly the set of commands decided out of band, is appended to the friction log, the only record of those decisions.
  2. Close time — run-gates.sh --emit scan-prompts filters the log against the committed allowlist and ranks what survives by command pattern (§scan-prompts states the local-overlay upper bound). Each recurring pattern is resolved by the triage criterion (below). --emit-compare-settings-allow (§compare-settings-allow) lists the local-overlay entries a committed glob already grants or that name a script that does not exist (the prune sets), and those a declared probe proves too broad (the narrowing set). Then the log is drained, its named reclaim path.
  3. Steady state — friction low and justified: the committed settings.json carries every durable pattern (reviewable, shared), the local settings.local.json stays 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 paid every session, and what pays for it is not that 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 over find, Grep over a working-tree git grep, git rm over rm, a scratch dir over the repo root: each steers to a better tool or a safer form. Each 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 case therefore rests on the second and third, which do not turn on cost: the guard earns its serialization by making the outcome deterministic and the tool choice better, not by dodging an interrupt. So no message names a harness’s consequence: whichever one it named, it would be wrong the same way.

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 around it: one unmatched segment takes the whole line off the match path. So the read-only banner and diagnostic tools an agent habitually chains are themselves legitimate allowlist entries (§The recommended allowlist names them); allowlist them, or run the core command bare.

A row the ranking marks allowlist-unreachable (§scan-prompts) admits two dispositions, not three. An allowlist entry is unavailable for it, not merely unattractive, so resolve it by a guard rule or a habit change, or leave it standing as measured friction. A standing row is an honest outcome, not an unfinished triage.

templates/settings-allow.json is a recommendation to merge by hand, entry by entry, into the committed settings file’s permissions.allow, as a union: an object merge that replaces the array drops every grant the consumer already had. Nothing in the kit writes it, since a grant is the consumer’s security decision (§compare-settings-allow’s impact criterion rates a committed widening high-impact). Each entry is there for a stated reason and reaches a stated set:

  • The gate binary, bare and with arguments, spelled as the placeholder @GATE_SDK_NATIVE_BIN@. This is what a session runs: every gate, every arm and every steer the ruleset prints through the steer door (§The shell guard) are reached through it. It ships as a placeholder rather than a path, for two reasons. GATE_SDK_NATIVE_BIN is per-consumer, so a kit file carrying its resolved value would publish an adopter’s install location as if it were kit mechanism: the provenance seam, held by check-door-binding’s seam assertion (§check-door-binding). And an entry the adopter has not resolved must match nothing. A permission pattern is compared against a literal command and @GATE_SDK_NATIVE_BIN@ is no command, so forgetting the step costs a permission prompt rather than a silent grant. Resolve it to the value your gate-sdk-config.knobs gives the knob, spelled as a command: the binary prints it with --emit knob-values GATE_SDK_NATIVE_BIN, and gate-sdk’s gate_native_bin_spelled is the one owner of that spelling (gate-sdk/SPEC.md §lib/gate.sh).
  • The front-end, bare and with arguments, beside the binary. The binary and the front end are two different programs, and granting one is not granting the other. A clone reaches the front end before its first build, when there is no binary to name, and a session running the front end spawns it (gate-sdk/SPEC.md §run-gates). The kit’s hook wiring is no ground for the pair: a hook the harness starts is not a command a session runs, and the shipped registrations start the binary. Its reach is the whole front-end, including:
    • --scratch-run, which runs a scratch script under that arm’s echo control;
    • --rewrite, which rewrites files inside the repository under its own bounds (§rewrite);
    • --install-hooks, which places the git hooks in the clone;
    • every --emit … --write mode that regenerates a tracked projection.
  • git rm -q, rule rm_tracked’s steer target, spelled as the steer prints it. Its reach is deleting and staging tracked paths, which is recoverable from the last commit. Rule rm_tracked blocks its force flag whether or not a grant matches.
  • The banner and diagnostic tools an agent chains around a core command: echo, wc, grep, ls and command -v. They keep a chained core command on the match path (§The triage criterion), and they read or print. A redirect on them is a file write the harness checks against the target, not against these entries. The criterion for this set is that a utility has no write or execute form of its own, meaning nothing it can do beyond what the harness separately checks. So find (-exec, -delete), awk (system(), print >), xargs, sed and every interpreter stay out however habitually they are chained, and the steer for a rewrite is --rewrite, not a grant. The front-end and git rm -q above are the kit’s own steer targets, each granted for its stated reach, not members of this set. The honest limit is that a Bash read grant reaches paths a harness file-read rule may deny, so a consumer holding such rules drops grep and wc here rather than widening around them.

Deliberately absent, so a reader does not re-derive it:

  • A no-argument twin beside an entry whose only * closes it after a space. The harness grants the bare command on that entry alone, and rules ro_pipeline and allowlist_chain read that entry’s head as its bare form (rule allowlist_chain).
  • The harness’s built-in read-only git subcommands, which need no entry.
  • A write to a gitignored target, a truncation of one, a sanctioned wait and a write bounded to a scratch dir. Rules truncate_scratch, append_scratch, bounded_wait and bounded_write grant them from the hook, where no settings entry can reach them.
  • The harness’s file tools, the steer targets of rules sed_file, find_glob, cat_file, git_grep and emitter_write. The harness’s own permission mode governs them, and an Edit or Write grant is a write widening this recommendation will not make on a consumer’s behalf.
  • Rule git_c_root’s bare-git steer target, which is one subcommand per consumer; a blanket git grant reaches a force push.

The template assumes the kits are vendored at the repository root, and where check-settings-paths is registered it holds the front-end literal once merged. The set is hand-authored, and the one invariant a hand-authored set can break, a rule blocking a form the template grants, is held by the consumer smoke (§Testing). The smoke resolves the placeholder the way an adopter does and then executes the door it granted: a grant that parses is not a grant that spawns, and the union assertion beside it proves only the former.

The shell guard

The PreToolUse guard is a harness-integration member of the gate binary, --hook shell-guard, started by the harness with no shell between, as every shipped hook registration is (gate-sdk/SPEC.md §The harness-integration arm). templates/settings-hooks.json’s Bash|PowerShell matcher group names the binary GATE_SDK_NATIVE_BIN names under <project-dir> as its command, with --hook shell-guard as its args. So an absent binary declines through the harness, and a session that entered a linked worktree starts the main checkout’s binary.

The template spells the binary as the placeholder @GATE_SDK_NATIVE_BIN@, on the allow template’s grounds (§The recommended allowlist): the knob’s value is per consumer, and check-door-binding’s assertion B bars a path to the binary from a kit template. The two templates resolve it by different rules, so each // note states its own file’s:

  • Here, the knob’s value as the binary prints it, minus any executable suffix, under the project-directory prefix, so one committed settings file serves a Windows and a POSIX clone alike. The harness starts a suffix-less path as the file carrying the host’s suffix. A value that is absolute replaces the whole command, the prefix with it.
  • In the allow template, the value spelled as a command, with its relative-command prefix, which a hook command must not carry.

An unresolved placeholder here is a guard that is off, where in an allow entry it only costs a prompt: the harness cannot start the command and lets each call through. check-settings-paths reds it where it is registered (context-kit/SPEC.md §check-settings-paths).

The note names the harness’s onFailure field as the adopter’s own choice for a hook that should refuse when its program cannot start. The field is stated at the tier it was read at: the harness’s published changelog adds onFailure: "block", a hook that can’t start … blocks the action. No session ran under it here, so the note tells the adopter to try it before relying on it.

The path is anchored at the harness’s project directory, because a hook or statusLine command runs in the session’s current directory, which the session’s own cd moves. The harness substitutes the variable into a hook’s command and sets it for a shell-form one. Kit prose writes that variable <project-dir>, since its name is each harness’s own. Claude Code’s is CLAUDE_PROJECT_DIR, the binding the wiring templates and the plugin carry, and a consumer on another harness substitutes its own. So every kit wiring takes this anchor — the guard hooks here, context-kit’s SessionStart template and delegation-kit’s statusLine step. The variable holds the launch directory and a cd leaves it there, and project settings load only in a launch at the root, so there it names the root with no spawn. The member then reads its tree from the toplevel it resolves itself, never from the directory the hook started in.

The member is native/src/hook/shell_guard.rs; the engine, the readers and the rules live under native/src/guard/. Its knob slice is every GUARD_KIT_* knob a rule or the log reads, GUARD_KIT_CONSUMER_RULES_CMD (§Consumer rules), GATE_SDK_NATIVE_BIN for the steer door, and GATE_SDK_WORKFLOW_DIR, which the two log defaults derive from.

The member speaks the harness’s PreToolUse hook protocol, whose exit-status contract grounds every rule below:

  • exit 2 blocks the call and feeds stderr back to the agent;
  • exit 0 is no decision;
  • every other non-zero status, 127 included, is a non-blocking error after which the call proceeds, with a hook error notice on the first run alone; a statusLine command’s non-zero status only blanks the line.

So an absent hook command silently disables a policy hook rather than refusing it, which is why a guard binary may not be allowed to fail open on its own absence.

The call, in order:

  1. The member reads stdin whole, once. An absent, empty or unparseable payload exits 0 with nothing written.
  2. On any payload that parses, the consumer stage runs first, whatever the tool (§Consumer rules), and a consumer decision ends the call.
  3. The payload’s tool_name selects a shell reader: Bash selects the bash reader and PowerShell the PowerShell reader. Any other tool name, or a payload with no tool_input.command string, exits 0 with nothing written and nothing logged. The command the rules read is tool_input.command with its trailing newlines stripped.
  4. A command carrying a C0 control byte other than tab, line feed or carriage return is blocked before any rule runs, with the byte named in hex and the escape that spells it. No shell needs the raw byte, and the reader’s own marks are drawn from that range (§The reader and its views), so passing it would let the byte forge one.
  5. The rules whose rows name the reader’s shell run in table order (§The generic ruleset), and the first rule that decides ends the call.
  6. A call nothing decided appends its fall-through line to the friction log and exits 0.

The verdicts, each rendered through the crate’s hook-JSON serializer (gate-sdk/SPEC.md §The non-gate arm) and never quoted by hand:

  • A block is exit 2 with shell-guard: <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.
  • An advise allows, feeding the message back as additionalContext: steering without blocking.
  • An allow is a silent grant via permissionDecision: allow.
  • A rewrite is a behavior-preserving rewrite via updatedInput, which grants the better spelling of the same command.

The fall-through line is one line per call: \, newline and tab are encoded as \\, \n and \t, and the line is cut to the harness’s analysis bound plus one character (§scan-prompts reads both). A call the PowerShell reader read is written as PowerShell, a tab, then the encoded command. The encoding leaves no raw tab in a line, so the tab marks the tool and a line without one is a Bash line (§scan-prompts). It is appended to GUARD_KIT_LOG best-effort and never affects the decision.

Knobs resolve in process, from the static table and the consumer’s knob file, with no spawn. A refused knob read keeps the guard’s block, with the refusal’s own text: a malformed knob file or a set-but-missing GUARD_KIT_KNOB_FILE blocks every command until the file is repaired. The harness-integration class declines instead, writing its reason to stderr and exiting 0, on one ground: a malformed unrelated knob would wedge every guarded call (gate-sdk/SPEC.md §The harness-integration arm). That ground does not reach here: the refused file is guard-kit’s own, and its repair tool, the Edit tool, is a call this member never sees, so the block wedges nothing it cannot also clear.

The steer door. Every steer that names the binary spells it by gate-sdk’s command-spelling rule for gate_native_bin_spelled (gate-sdk/SPEC.md §lib/gate.sh): GATE_SDK_NATIVE_BIN’s resolved value, ./-prefixed when relative. The steers that name it are rule sed_file’s section extractor and --rewrite, the --scratch-run runner rules script_interpreter and shell_wrapper name, and the read-only arms rule worktree_confinement advertises. The crate spells it through the installer’s own spelling function (native/src/installer/init.rs, which prefixes the placed artifact the same way). A seam case holds that function to gate_native_bin_spelled’s output for a relative and an absolute value: the machine-held clause of gate-sdk/SPEC.md §The port-candidate criteria’s criterion 6 for the second holder. It is steer text, not a dispatch, so from a linked worktree it names the local door, which the worktree may lack. A steer the PowerShell reader decides spells the runner as a PowerShell command, the call operator and then the door single-quoted with any ' doubled, & '<door>' --scratch-run, since a path carrying a blank does not run otherwise.

The reader and its views

The engine sees a command only through a reader, one per shell, and a rule sees it only through the views the reader computes. The seam is one interface: views, segments, statements, pipes, redirect pairs, heredoc terminators, the harness view and the word read. A second shell’s reader is a second implementation of it and does not change the engine. The seam’s methods are the ones the rules call, and none is added ahead of a caller. The bash reader is native/src/guard/bash.rs and the PowerShell reader is native/src/guard/powershell.rs. The bash reader is the one holder of the bash command model: the scan-prompts ranker and --emit-compare-settings-allow call the same functions the rules do, and the ranker keys a PowerShell line through the PowerShell reader (§scan-prompts).

A view is raw, the command as received, or a skeleton named by its inert classes, a subset of sq dq hd hdq written in that order: sq, hdq, sq hdq, sq dq hd, sq dq hdq. There are two derived views: body, a heredoc body extraction, and dequoted, the content-kept view walked in lockstep with the sq dq hd skeleton. It drops a quoted span’s quote characters and holds its blanks, tabs, ;, | and & as sentinel bytes no word split cuts at. A backslash-escaped blank or tab outside a span is held the same way, so x\ y is one word and x\\ y two. A reader pairing the skeleton’s words with the view joins a skeleton word ending in an odd backslash run to the next. A view is spelled as a declaration writes it (§The generic ruleset).

The word read answers a segment’s words with their quoting. The dequoted view keeps an escaping backslash, so in it a\ b and a single-quoted a\ b are one text. A rule that needs a word as the shell passes it, or as a steer must print it, takes the word read instead. For each segment of the compound split it gives each word three faces, index-aligned, and a word the shell joins across an escaped blank is one entry.

  • Its spelling, the bytes the command spells it with, quotes and escapes included. A steer prints this face, so the shell reads the steered word as it read the original.
  • Its value, what the shell passes: quote characters removed and each escape resolved to the character it protects.
  • Its live text, the skeleton’s word, where a quoted span is a mark and an escaped character is still behind its backslash. A character standing bare in it is one the shell still interprets.

It is built from the raw, sq dq hd and dequoted views and mints no view name: a rule calling it declares those three, and the context faults on the read as it faults on an undeclared view. It answers nothing where the dequoted view cannot be aligned, or where its walk and the skeleton’s compound split part, as on a backslash before &&, and each caller states what it does then. The quoting travels with the word and is not recovered from the dequoted view, which is unchanged: stripping that view’s backslashes, as rule grant_path_slot does for its own comparison, would break the single-quoted regex a steer most often carries. Rules rm_tracked, find_exec and git_stash call it.

Reads are declared by construction. Each row of the rule table carries the rule’s name, the shells it applies to, its declared views and its function, and the engine hands a rule its views through a context. Asking the context for a view the running rule does not declare is an internal fault: the member emits a loud advise naming the rule and the view and does not decide the call. So a decision-table row that exercises the read turns red, as an advise against its expected verdict. The rules have no path to a view except the context. A predicate runs under its own declaration: where a rule takes another rule’s test as a predicate, the engine evaluates that test under the other rule’s declared views, so a caller declares only its own reads. Honest limit: the fault catches an undeclared read only where a table row exercises it, and a declared view no path reads (over-declaration) is not caught.

The skeleton is 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, and 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;
  • hdq, the subset of hd whose delimiter is quoted (<<'EOF'), where the shell itself guarantees the body does not expand. That guarantee 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 its declaration. The skeleton is a pure function of its arguments: no config read, no subprocess, no global. A friction-log line decodes to the multi-line command it recorded (§scan-prompts), so the ranker reads the heredoc arm too. The body view prints the body of the command’s <k>th heredoc opener, empty when that opener has none, with the skeleton’s own extent rather than a second copy of it.

The placeholder is the byte NUL followed by the class’s two letters (SQ, DQ or HD). No command the member reads carries a NUL (§The shell guard), so a test for a placeholder cannot fire on command text, and a path or value that spells the letters is read as written. The mark never leaves the process: every printed form drops it (§The shell guard, the verdicts; §Consumer rules, view; §scan-prompts, the ranking key), so a reader outside the member sees only the two letters.

Placeholder, never deletion, as a correctness point. 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. Blanking everything after a << would blind the guard to real commands in the same call, and stripping quotes alone would still refuse 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. First, 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. That is why classes are declared per rule and not fixed per region, and why the rule that cannot take hd takes hdq 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, the conservative direction.

Second, the normalizer models quoting, not shell semantics: a construct that survives its scan and is not an inert class is treated as live, the fail-toward-matching direction a guard should err in. So a rule’s command/process-substitution and backtick tests 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 auto-allow a command substitution it could not see. Those tests are conservative declines, not verdict matching, and each rule that carries one declares raw (§The rule roster).

The PowerShell reader implements the same seam for PowerShell’s grammar and adds no method to it. What it models is quoting, comments, escapes and separators. Everything else is live, as in the bash reader, which errs toward matching.

  • The four inert classes. sq is a single-quoted span '…', inside which a doubled '' is one literal quote. dq is an expandable span "…", inside which a doubled "" and a backtick-escaped character do not end the span. hd is a whole here-string literal, verbatim @'…'@ or expandable @"…"@: its opener ends its line, and its closing '@ or "@ begins a line, optionally after blanks. hdq is the verbatim subset, the one PowerShell guarantees does not expand.
  • A here-string becomes one placeholder, opener to terminator. A bash heredoc is a redirect whose body follows on later lines. A here-string is an ordinary string value inside its statement: @' … '@ | git commit -F - is one statement. Replacing the whole literal keeps that statement on one skeleton line, so no split carries residue: the residue split is the statement split unchanged, the heredoc terminators are none, and body is the kth here-string’s content. An opener that does not end its line, or one with no terminator, opens no here-string, and its bytes read as they fall.
  • Comments. A # that begins a token — at a line start, after a blank, or after ;, | or ( — opens a comment to the end of its line. <# opens a block comment that runs through #> and may span lines. Every skeleton view replaces a comment with one blank, which keeps the tokens on either side apart. The raw view keeps it.
  • Escapes. Outside a single-quoted span, a backtick escapes the character after it. So an escaped quote opens no span, an escaped ; or | separates nothing, and a backtick before a newline continues the line rather than ending a segment.
  • The splits. The compound split cuts on ||, &&, ;, | and newline, taking the two-character separators whole. It is the harness’s documented PowerShell split, applied to a skeleton the way the bash reader applies its own. The statement split cuts on the same set without |, and the pipe split cuts on |. A trailing &, PowerShell 7’s background-job operator, is not a separator, just as bash’s is not.
  • Redirect pairs. An operator [1-6*]?>>?, a blank run, then &[1-6] or a target word. PowerShell reserves <, so it has no read redirect.
  • The dequoted view is the content-kept view aligned with the sq dq hd skeleton, as the bash reader’s is. It undoes a single-quoted span’s '' and an expandable span’s "" and backtick escapes, and holds a quoted span’s blanks and separators as the same sentinels. It refuses to align (None) on a here-string, an unterminated span, a newline inside quotes, or an escape that spells a control character (`n, `t and their kin), which no word of the command carries as written.
  • The word read. A value undoes what the dequoted view undoes, a doubled quote and an expandable span’s backtick escape, and resolves a backtick escape outside quotes to the character it protects, which that view keeps behind its backtick. So trac`ked.md has the value tracked.md. The spelling is the command’s own bytes, and the live text keeps the escape. A backtick-escaped blank joins its word. It answers nothing where the dequoted view refuses, on a backtick-continued line, and on an escape outside quotes that spells a control character.
  • A word’s array elements. A comma standing bare parts a word into elements, and a comma inside a quoted span or behind a backtick is a character of its element. native/src/guard/powershell.rs answers a word’s elements from its spelling, each with the word read’s three faces, and answers nothing where the word read would refuse the word. It is the reader’s function and no method of the seam, since bash has no such split. Rule rm_tracked calls it.
  • The harness view is the identity. No wrapper strip is documented for the PowerShell matcher, and no rule that applies to PowerShell reads this view.
  • The placeholders are the marks every reader emits, above, so the control-byte refusal (§The shell guard), which runs once a reader is selected, covers a PowerShell command too.

The PowerShell reader’s honest limit. The typographic quotes PowerShell also accepts (‘ ’ ‚ ‛ and “ ” „) are not modelled, so their spans read live. An expandable span containing $( ends at the first unescaped ", even inside the subexpression, as the bash reader’s double-quoted span does. Every applied rule carrying an expansion decline declines on that live $ first (§The rule roster), so there a mis-scan decides nothing. Rules cd_compound, scratch_redirect and git_rewrite, which carry none, can only block or advise on one, never grant. A command inside a script block, a subexpression or a parenthesized pipeline is read only where a separator happens to begin its segment. native/src/guard/powershell.rs’s own tests pin every bullet and bound, and where the two grammars differ they read the same text through both readers.

Rule append_scratch reads no raw command, and the exception is the bound above rather than a hole in it. Its decline reads the hdq view: every region that can substitute stays live, and the only region blanked is a quoted-delimiter heredoc body, which the shell guarantees cannot. So the rule still sees every substitution a raw test sees, and stops declining on the one region where the spelling is inert. That is the asymmetry rule expansion draws for hdq against dq, applied to a backtick instead of a $. A rule declining on hd here would be a hole, which is why the view is hdq.

Rule grant_path_slot splits the two tests, and the split is safe in both of its uses. Its backtick test reads the raw command. Its expansion and process-substitution test reads the sq hdq view, because a single-quoted span cannot expand any more than a quoted-delimiter heredoc body can, while dq stays live. As a block the rule loses nothing by declining; where rules abs_script and brace_glyph take its test as a grant predicate, every decline fails the grant. Rule worktree_confinement’s tests split the same way.

Rule pgrep_self_match 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. The quoted operand is the normal spelling, and a skeleton replaces exactly that literal with a placeholder, so a skeleton view would blind the rule to the only thing it tests. It pays for the exception in the conservative direction every other clause takes:

  • it declines outright on any expansion, substitution or backtick: rule expansion blocks the expansion and substitution shapes, and the backtick decline is this rule’s own arm, per the raw-command reading above;
  • 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.

The compound split emits one segment per statement boundary, splitting on the harness’s statement separators (;, &&, ||, |&, |) and at a newline. The two-character ones are taken whole, so |& never leaves an & heading the next segment. It is fed a skeleton view, so a separator inside a quoted argument is never mistaken for a statement break. Nor is an escaped separator: a ; or | after an odd run of backslashes stays in its word, as the shell reads it, so find … -exec cmd '{}' \; is one segment ending in the word \;. A backslash before a newline is dropped with it, joining the lines as the shell does, so no word reader stops at the break. && matches bytewise, a backslash before it included, and the statement and pipe splits share the escape test.

The class is the harness’s, measured. A claude -p run under dontAsk granted touch a |& touch b on the two exact grants Bash(touch a) and Bash(touch b), and refused it with touch c on the right. A lone & is not in the class, because the harness does not split on it: the same run refused touch a & touch b on those two grants, and refused touch a & even under Bash(touch a *). So a backgrounding & is a call no allowlist entry grants rather than a segment boundary, and every grant test reads a segment carrying one as ungranted, on rule background_no_record’s statement-ending-& predicate:

  • rule allowlist_chain counts it as the unmatched segment;
  • rules abs_script and brace_glyph’s rewrite grant withholds its allow;
  • §scan-prompts marks the call allowlist-unreachable.

The escapes were measured too: Bash(touch -c a:*) alone granted touch -c a \; b, touch -c a \| b and touch -c a \, newline, b, and refused each separator bare and after an even backslash run. The escaped && is unmeasured, so the split cuts there, the conservative direction: an extra segment can only withhold a grant. A rule testing for a separator with its own pattern, such as rule cd_compound’s bash test, reads an escaped one as a break, in that direction too.

What a rule loses by the unsplit & was measured too. Ten rule targets, each placed after sleep 1 & and after true &, were every one blocked, by their own rule or by rule bare_sleep’s or background_no_record’s before it. The one backgrounding shape no rule blocks is rule bounded_wait’s recorded launch, and a target after its ; is a segment of its own that its rule reads.

It is the one model of the harness’s per-segment matching surface: every rule that reasons per segment, the scan-prompts ranker’s grant test and the read-compound carve-out of rules find_glob and cat_file call it. What it models is the harness’s matching surface, not shell dataflow, and rule script_interpreter rests its own two-level split on that distinction. The segments carry no record of which separator produced them, so a rule asking whose stdout feeds whose stdin reads the reader’s statement and pipe splits instead: a different question, not a second dialect of this one.

The statement split runs ;, &&, || and newline and carries heredoc residue, as rules append_scratch and emitter_write need, and the pipe split divides one statement into its pipeline members, the two-level split rule script_interpreter runs. The residue split cuts at every newline, continued or not, so a continued statement’s residue stops at its own line.

The harness view is a segment as the harness’s permission matcher reads it: the leading wrappers the matcher strips before it matches a Bash rule, removed from the head repeatedly. It is rostered here because it models harness behavior every reader of it must share. The stripped set is the matcher’s documented one, and each wrapper’s arguments are walked by its own grammar:

  • time, with an optional -p;
  • timeout: --preserve-status, --foreground, -v, --verbose, -s/--signal/-k/--kill-after in their separate and glued spellings, then exactly one duration word — digits with an optional fraction, or a bare fraction, with an optional s/m/h/d unit;
  • nice: -n N, -nN, --adjustment=N and -N, N an optionally signed integer;
  • nohup, builtin and noglob;
  • stdbuf: any run of -i/-o/-e in separate or glued spelling, and --input=/--output=/--error=;
  • command, except when the next word is -v or -V: that is a query, not a wrapper;
  • a leading run of NAME=value words;
  • xargs, only when the next word is not option-shaped.

The strip stops at the wrapper whose walk fails, and the view then begins at that wrapper word. These stop it: an option the walk does not recognize, an argument it cannot read (a duration or an adjustment that is not one), a wrapper followed by an option-shaped word or by nothing, and an assignment whose value carries a quote. That is the ruleset’s bias toward passing: a view that strips less matches fewer grants. sudo, env, git -c and an absolute wrapper spelling are not in the set, because the matcher does not strip them (rule git_c_root steers the last two).

It is a classification view and never a grant view, and no rule grants through it. A wrapper-prefixed command the matcher would grant is granted by the matcher itself, so the guard has nothing to add, and a strip, then re-test through the allow match grant is refused. For the spellings the matcher does not strip, such a grant would bless a program reach.

The assignment arm strips every assignment rather than the undocumented known-safe subset the matcher strips, because any narrower list would be a guess presented as a model. A reader bounding a grant only gains scrutiny from it, while the scan-prompts ranker can under-count prompts on an assignment-prefixed command. The honest limit. The wrapper set is the matcher’s documented one, so a harness revision that widens or narrows it is drift no gate here can self-detect — the footing the payload record below already declares.

The allow match, guard::allow_match in native/src/guard/mod.rs, is the shell-glob match core: true when a string matches a committed allow pattern. It models the harness :* prefix idiom as the harness grants it, word-bounded: Bash(printf:*) matches printf alone or printf, a space, then anything, and never printfx, printf-x or printf.x. A closing :*, bare or before the rule’s own ), is read as the head alone or the head and a space-led *, and any other :* is a plain *. A closing space-led * that is the rule’s only * reads the same way, so Bash(git status *) matches git status alone as well. Beside another * it does not, so Bash(git -C * status *) never matches a command ending at status.

Measured, not read off documentation, with claude -p --safe-mode --setting-sources '' --permission-mode dontAsk --settings <file>, the rule as the file’s one grant, told to run one command verbatim, read off the JSON result’s permission_denials:

  • Bash(touch f:*) granted touch f and touch f g, and refused touch fg, touch f-g and touch f.g;
  • Bash(python3 -:*) refused python3 -c …;
  • Bash(touch f *) granted touch f and refused touch fg;
  • Bash(git status *) granted git status;
  • Bash(git -C * status *) granted git -C . status --short, refused git -C . status, and granted git -C . log --oneline -1 -- status x: an interior * spans words, so a grant mirrored with a wildcard -C target is not narrow.

A model wider than the harness is the failure that matters here: it would let compare-settings-allow call a narrower grant redundant, and rule allowlist_chain read an ungranted spelling as silently granted. It is the one holder. Rule allowlist_chain’s silent-grant guard, the rewrite grant rules abs_script and brace_glyph share, rule bounded_wait’s recorded-launch grant, rule grant_path_slot’s slot parse, the scan-prompts ranker’s grant test and the compare-settings-allow arm all call it. None spells it inline, so an edit cannot change one reader’s verdicts while another’s stay right.

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 or PowerShell call tool_input carries command, description, timeout (documented by the harness for both tools, and read by no rule), and run_in_background — a JSON boolean true on a backgrounded call, and absent on a foreground one. The rules read tool_input.command and tool_input.run_in_background, and the member reads tool_name to pick the reader.

The live permission mode is in that payload, and no rule reads it. permission_mode sits alongside the fields the rules read, so a rule whose rationale is mode-conditional asserts a mode rather than reading one: “no allowlist entry can suppress this prompt” is false under an auto-approving mode and true under the others. Whether a guard should read the mode is a consumer decision not settled here, and so is each of agent_id, agent_type and effort.

The payload is the only reliable reader of the mode, because the configured mode is not the live one. The harness reads permissions.defaultMode at process launch only, and a conversation reset (/clear) resets the conversation but not the process. A mode switched in-session carries across the reset, and a defaultMode written from inside a running session takes effect only at the next launch. So a session can run for hours in a narrower mode than the settings file names, and the settings file is never evidence of the mode a call ran under. The live mode reaches a hook only on the per-tool-call payload above; the session-start hook’s payload does not carry it, so no session-start check can report the mode the session will run in.

The fail-open postures

Fail-open is the default posture: an unparseable payload, a tool no reader serves and a view fault each leave the call to the harness’s own permission path. 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. The refused knob read and the control-byte refusal (§The shell guard) are this member’s two blocks that decide no rule, each on the ground stated there.

A third posture, fail-open-but-loud, covers a deny-guard whose matcher proves the tool but whose rule turns on a payload field. 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. The view fault above and a consumer-command fault (§Consumer rules) are this member’s instances. One delivery constraint comes with the posture: a guard degrading because the binary is unreachable cannot render through the binary, and the front end’s own fail-open path answers that case for every hook member (gate-sdk/SPEC.md §The harness-integration arm).

delegation-kit’s agent-budget-guard and agent-dispatch-guard and lifecycle-kit’s workflow-state-guard are guards in the same class, each a harness-integration arm in its own compiled module (gate-sdk/SPEC.md §The non-gate arm) reproducing these postures and rendering through the same hook-JSON serializer, so the envelope shape has one producer. agent-dispatch-guard is the shipped instance of the fail-open-but-loud posture (delegation-kit/SPEC.md §The delegation model). The kit owning the rule ships the guard: workflow-state-guard reads its path-bearing field in-crate, and 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 and allow rules are a command the shell guard runs first, named by GUARD_KIT_CONSUMER_RULES_CMD (§Layout and configuration). The placement is the contract: a consumer rule runs ahead of the generic ruleset and can refine it. 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. So a consumer’s rules are none of these:

  • a second hook registered beside the guard, whose order against the guard is no contract this kit controls;
  • a declarative pattern table in the knob file (gate-sdk/SPEC.md §The extensibility model’s DSL ground; a command covers every pattern such a table could hold);
  • loaded native code (the same section’s plugin ground).

This is a placement contract and a protocol, not rule content: guard-kit ships no consumer rule and no consumer-rule template, and names none. What a project blocks or steers is its own toolchain knowledge and stays in its command, so no rule content crosses into the kit. Neither side of the seam is shell by ruling: the kit side is compiled, and the consumer side is whatever the consumer’s command is written in.

The protocol is the harness’s own hook protocol, so a consumer rule command is written like any PreToolUse hook command:

  • The member spawns the command’s argv once per call, directly and with no shell, before the generic ruleset. The payload bytes go to its stdin as received, and the working directory and environment are inherited. A command that exits without reading all of its stdin still ran, and its exit status decides.
  • Exit 2 blocks: the child’s stderr is relayed verbatim, the member exits 2, and the generic ruleset does not run.
  • Exit 0 with stdout is the consumer’s decision. The member relays stdout verbatim and exits 0 when it parses as a JSON object; the generic ruleset does not run, and no fall-through is logged.
  • Exit 0 with stdout that is empty or only whitespace is no decision, and the generic ruleset runs.
  • A fault is any other exit, a failed spawn, or stdout that does not parse as a JSON object. It is loud and never silent, and it never stops the generic ruleset: a fault costs the consumer’s rules for that call and nothing else. The member runs the ruleset and carries the fault on its own output:
    • appended to a block’s stderr;
    • as additionalContext, after every other key, on an allow or rewrite envelope;
    • appended to a generic advise’s context on a new line;
    • as an advise naming the command and the fault when the ruleset decides nothing, a call that still logs its fall-through line.

The member reads GUARD_KIT_CONSUMER_RULES_CMD first on every payload that parses, and a refused read blocks, as §The shell guard’s knob posture does. An empty value is no consumer stage.

What a consumer command gets from the kit is --guard-json, a top-level flag of the gate binary that resolves no knob, since every value it needs arrives as argv or stdin. Every mode exits 0 but a usage error, which exits 2, and writes LF on every host; a read that finds nothing prints nothing, which its caller reads as the absent field.

  • field <path> reads a payload on stdin and prints the value at <path>: a string bare, a number or boolean as JSON spells it, and nothing for null, an absent path, an object, an array, or a path outside the grammar. The path is the crate’s pin-path grammar (context-kit/SPEC.md §check-settings-pins) — .name, [N] and ["key"] steps — and a step jq’s own type rules refuse reads as absent. field-or-empty <path> is the same except that false also prints nothing — jq -r '<path> // empty'.
  • view <view> reads a payload on stdin and prints the named view of its tool_input.command through the reader its tool_name selects, the command read as the member reads it, with its trailing newlines stripped. It takes the view spellings the reader carries — raw, sq, hdq, sq hdq, sq dq hd, sq dq hdq and dequoted — as one operand or as separate words; body and any other spelling are a usage error. It prints nothing for a tool no reader serves, a payload that does not parse, an empty command, or a command the member blocks for a control byte (§The shell guard). Each placeholder prints as its two letters. That is the quote- and heredoc-inert match surface §Writing a consumer rule tells a rule to match on, without a JSON parser of its own.
  • allow-entries <settings-file> prints each string member of the file’s permissions.allow, one per line, and nothing when the file is absent, unreadable or does not parse — §compare-settings-allow’s in-crate read.
  • advise <msg>, allow <reason> and rewrite <cmd> <reason> print the envelopes §The shell guard’s verdicts are, every interpolated value serialized by the crate’s hook-JSON serializer and never quoted by hand.

Placement is a verification decision before it is a seam one. 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 the kit can hold: a consumer’s cases are its own table, run by the same arm (§Testing), and no kit check counts them against its rules. A rule that qualifies for either lane therefore belongs in the generic one.

Honest limits. A consumer command that hangs holds the call until the harness’s hook timeout; the member sets no timeout of its own. A consumer grant is bounded by its author and not by the generic ruleset, which rule worktree_confinement’s limits already state.

The hook on native Windows

The guard is a hook member the harness starts directly, so the hook’s floor on that host is the gate binary alone. The committed wiring (templates/settings-hooks.json) is the same on every host, which is what a settings file shared across a team’s machines requires: its command carries no executable suffix, and the harness starts the file that carries one.

What was run, on a native Windows host and on Linux:

  • the exec form started the binary with its args and the project-directory placeholder substituted;
  • a block refused the call and an advisory reached the session;
  • a suffix-less path started the file carrying the executable suffix;
  • with the binary renamed away the call proceeded on a failed direct spawn, so no shell stands between.

On Linux a registration edited in a running session armed in it, and with the binary renamed away no notice reached the model’s channel.

A silent pass witnesses no wiring. A passing member exits 0 with no output, as a call whose hook never fired does. A block, an advisory, or a stand-in at the registered path recording its argument vector shows the start.

Stated limits, each unrun. The Windows runs of the exec form were on a host carrying Git for Windows, where the harness still found its bash for its own shell tool, so a host with no bash at all is not run. Neither is the linked-worktree placeholder on Windows, nor a registration arming in the session that wrote it. The wakeup-guard’s log stamp spawns date there and is empty where PATH does not carry it (context-kit/SPEC.md §bin/env-probe).

The member reads the payload bytes itself, so it absorbs no substrate difference in what it reads: a CR LF inside a command’s own text is read verbatim on every host, and the binary writes LF on every host.

The rules are compiled, and the second shell is a second reader. The ruleset models bash quoting, heredocs and the harness’s per-segment matching of bash commands (§The generic ruleset). The harness’s second shell tool, PowerShell, is read by the PowerShell reader in the binary, beside the bash one (§The reader and its views), and its commands meet the rules whose rows name powershell in their shells clause. A script twin of the guard in a second shell is refused: it would be a second engine, and its rules a second copy.

What a PowerShell call meets. On Windows the harness carries the PowerShell tool, on by default beside Bash and, wherever it is enabled, the primary shell; elsewhere it is opt-in. The shipped Bash|PowerShell matcher hands its calls to the member. Such a call meets the rules whose shells clause names powershell (§The generic ruleset), takes no grant from the guard, and, when nothing decides it, is logged as a tab-marked fall-through line that §scan-prompts counts apart. A PowerShell script run off a scratch-dir body meets rule script_interpreter, whose steer names the runner’s PowerShell path where the project names a host (GUARD_KIT_SCRATCH_POWERSHELL) and a bash body under the runner where it names none (§scratch-run).

The oracle is the decision table (§Testing), run by the --run-guard-tests arm on crate-tests-windows’ x64 entry. Every generic rule’s firing and non-firing case then executes on the substrate this section claims, and the arm spawns the member directly, so the subject needs no Git Bash. A step on the same leg starts the binary as --hook shell-guard from a subdirectory of a scratch repository carrying a malformed knob file and reads the refusal, the arm’s root step on that host (gate-sdk/SPEC.md §The harness-integration arm). The crate’s tests and check-settings-paths’ fixture pair run there too, so a suffix-less candidate is held against the file carrying the suffix. The same leg’s liveness step reads a record naming a Windows process id through the liveness predicate on this host, so the PowerShell form of rule background_no_record steers to a record something can judge. And the leg runs the scratch runner’s seam cases under both of that host’s PowerShells.

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. Each rule is one row of the crate’s rule table, and the shell guard walks the table in order, the first rule that decides ending the call (§The shell guard). Order is load-bearing where noted.

A rule is identified by its name. The roster below lists the rules in dispatch order, and each item carries its name, which is also the identifier of the rule’s function in the crate’s rule table. Nothing cites a rule by position: an insertion moves no citation, and a rename is the one edit that moves them, which check-guard-registration’s arm D reads.

Each half of the clause admits nothing a project owns, and a half is added only with the rule that forces it.

  • Shell-substrate behavior admits rule pgrep_self_match: a -f pattern seeing the matcher’s own argv is a property of the shell every consumer of this kit runs on, neither harness behavior nor any project’s toolchain.
  • An artifact whose grammar a kit owns admits rule git_mutation_under_producer, which reads evidence-kit’s pid=<n> run=<key> record and the OS’s process table and interprets no project vocabulary. A kit’s own artifact grammar is shipped mechanism, which gate-sdk/SPEC.md §The provenance seam distinguishes from private rule content.

A project’s toolchain stays out under both.

Naming one harness’s forms in a corrective costs portability, not privacy. Rule bare_sleep’s block message names a backgrounded condition wait and the harness’s event-stream form, since a corrective naming neither is unfollowable. A harness tool name is public and shared by every consumer of that harness, so the provenance seam, a privacy boundary over private vocabularies (gate-sdk/SPEC.md §The provenance seam), does not reach it. A second harness with other form names is what would force a configurable slot, and none is built before that harness exists.

The item grammar the gate reads. §The rule roster’s body is a bulleted list and nothing else, and no item carries an ordinal. An item opens - **<title>** (`<name>`) — followed by a space, its name [a-z][a-z0-9_]* and distinct from every other. It carries exactly one declaration clause: the word Declares, then one or more backticked views joined by commas or the word and (§The shell guard names the views). The clause names every view the rule reads, raw included, and the item’s prose may still say which view serves which check, outside the clause. Where a rule takes another rule’s test as a predicate, the engine runs that test under the other rule’s declaration, so an item declares only its own reads and names its predicates in prose. An item may carry one shells clause: the word Shells, then one or more backticked shell names, bash or powershell, joined by commas or the word and. An item with none applies to bash alone.

Which rules a PowerShell command meets is ruled by a two-part test. A rule’s shells clause names powershell when both parts hold:

  • its subject is a fact about something other than the shell: git’s own argv, the tracked tree, the liveness records, the checkout boundary, or the harness’s persisted working directory;
  • the only PowerShell vocabulary its test needs is a roster of cmdlet names and their built-in aliases, or the shell’s own invocation grammar: the call and dot-source operators, a path in command position, and its hosts’ command-line options. That grammar is shell-substrate knowledge the clause above admits.

Those rules take the PowerShell reader’s segments, quoting and words, and otherwise run their bash test. Every other rule is bash alone, and each class has its own ground:

  • The grants and rewrites (rules abs_script, brace_glyph, truncate_scratch, append_scratch, ro_pipeline, bounded_wait and bounded_write). Each grant’s safety argument is a model of the Bash permission matcher and of bash’s redirect and emitter grammar. The PowerShell matcher reads a syntax tree and canonicalizes aliases, and nothing here has measured it, so no call the PowerShell tool makes is granted by the guard. A missing grant costs a permission decision, while a wrong one would bless a call.
  • The steers whose ground is the Bash matcher’s own behavior (rules git_c_root, abs_prefix, expansion, allowlist_chain, emitter_write and grant_path_slot). Each steers toward the spelling a Bash(…) entry grants, or away from one the Bash matcher refuses. Neither premise is documented for the PowerShell matcher, and the kit recommends no PowerShell(…) entry.
  • The tool and discipline steers that read a bash utility’s vocabulary (rules sed_file, find_glob, find_exec, cat_file, pgrep_self_match, bare_sleep, wait_no_producer and shell_wrapper). A PowerShell counterpart would read cmdlet spellings no measurement has ranked. The friction log’s PowerShell section is what earns one, through the close-stage triage (§The triage criterion): measure first, then grant, applied to steers.

The expansion decline, read through the reader. Under PowerShell no earlier rule blocks an expansion or a substitution, so an applied rule’s decline on one stands on its own, and it is the reader’s answer rather than bash’s. A $ outside a single-quoted span and a verbatim here-string declines, read on the sq hdq view. A backtick does not, because it is PowerShell’s escape character. The rule reads the running shell from its context, which carries the shell the engine was called with, and its raw declaration covers the read.

The shared directions. Each is stated once here, and an item names only where it departs from one.

  • A decline biases toward passing. A rule declines rather than guesses: on an expansion or a substitution anywhere in the command, whose shapes rule expansion already blocks, and on an option, word or construct its walk does not model. Each such decline errs toward passing rather than toward a false block.
  • A grant bounds the whole call. permissionDecision: allow blesses the whole call, so a grant bounds the command rather than recognizing the shape it keys on.
  • The settings read fails open. A rule reading GUARD_KIT_SETTINGS declines on a failed binary read, a missing settings file or a parse error, so a grant resting on the read never turns a missing file into an allow.
  • The auto-allow band is rules truncate_scratch, append_scratch, ro_pipeline, bounded_wait and bounded_write. A steer whose subject a consumer could add to GUARD_KIT_RO_BINS is placed before the band, so a widened roster never has rule ro_pipeline grant what the steer refuses.

The citation grammar. A citation is the word rule or rules followed by one or more backticked names joined by commas and the word and: rule ro_pipeline, rules abs_script and brace_glyph, rule grant_path_slot’s test. Every joined name is a citation, so other words part a non-rule identifier from one. An arm keeps its letter, since an arm is named inside one item: rule bounded_wait’s arm (B). Outside this kit’s tree and the crate’s guard module a citation is qualified by the kit’s name, guard-kit rule git_mutation_under_producer.

Fall-through logging closes every call and is no rule: once the last rule has declined, the shell guard appends the command, neither blocked nor auto-allowed, to the friction log (§The shell guard). It runs last and never affects the decision.

The rule roster

  • cd in a compound command (cd_compound) — Declares sq dq hd. Shells bash and powershell. Blocked: cwd drift, plus a compound the allowlist cannot match, so the call is decided out of band. Corrective form: absolute paths, or git -C <dir> for git. Under PowerShell the location commands are cd, chdir, sl, Set-Location, pushd, Push-Location, popd and Pop-Location, matched without regard to case. The rule fires when one of them is the first word of a segment of the PowerShell reader’s compound split and that split yields more than one non-empty segment. An escaped separator, which the split does not cut at, makes no compound.
  • A prefix spelling that falls off the match path, steered to the spelling that stays on it (git_c_root) — Declares raw and sq dq hd. Four arms, each blocked with the spelling the matcher resolves. Each arm reads the sq dq hd skeleton, so a quoted mention is not the command, and arm (d) reads the raw command to spell its corrective.
    • (a) git -C <repo-root> when cwd is the root: the absolute -C target matches no allowlist entry and falls off the match path, while the bare git form is allowlisted and resolves on it. Pinned with a trailing space to the exact root, so git -C <root>/subdir and a foreign-repo -C are untouched.
    • (b) git -c <key>=<value> among a git segment’s global options, found by rule git_mutation_under_producer’s git global-option walk, so a global option that walk does not recognize declines. The corrective has three parts. Drop the -c: a pager or color override has no effect without a terminal, and the bare subcommand is the allowlisted spelling. A -c form is never granted, because a -c key can name a program the subcommand runs — core.pager, core.fsmonitor, core.sshCommand, alias.* — and the same reach is why the matcher does not strip it. A genuine config override runs through !<command>.
    • (c) An absolute spelling of a stripped wrapper: a segment’s command word, read past a leading shell keyword as rule pgrep_self_match reads it, whose basename is time, timeout, nice, nohup or stdbuf and which carries a /. It is steered to the bare wrapper name the matcher strips (§The reader and its views, the harness view). The corrective states the one loss: a format option only the binary takes (/usr/bin/time -f) has no stripped spelling, and runs through !<command>.
    • (d) An exported or assigned kit knob whose value is the one it already resolves to: export NAME=value, a leading NAME=value run before a command word, or env NAME=value. NAME is a scalar knob a static kit table owns, and the value is byte-equal to its resolved value or names the same existing file, resolved against the working directory. The binary’s --emit knob-values answers for the knob, spawned only on a segment carrying one of the three shapes. Steered to the command without the prefix, the message naming the resolved value. The resolved path is already in every session’s start context and the prefix recurs regardless, so only a steer at the call reaches it. Falls through:
      • a differing value, which is a configured override (gate-sdk/SPEC.md §The knob file: the environment beats the file on purpose);
      • a name no static table owns, an environment-only execution setting included;
      • an indexed knob, a refused or failed read, and a value carrying a quote or an expansion, which rule expansion owns.

    A steer and not a grant, on rule allowlist_chain’s reasoning, for arms (b), (c) and (d), and a grant of (b) would bless the program reach above. Arm (d)’s honest limit: it reads the knob’s resolution, not the child’s. A child that reads the variable itself, such as a bespoke test suite run by hand outside the runner that exports it, is steered too. Such a suite runs through its runner, or through the --scratch-run arm with the export in its body.

  • Bare-name scratch redirect (scratch_redirect) — Declares sq dq hd. Shells bash and powershell. A >/>> to a slash-free *.err/*.out/*.log target is blocked (it lands in the tracked tree and risks a git 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). Under PowerShell the operator class adds *, and a \ in the target is a path separator, so logs\x.log is not a bare name.
  • Absolute-path execution of a known read-only repo script (abs_script) — Declares raw, sq dq hd and dequoted. Silently rewritten by a rewrite verdict to the repo-relative form, the spelling allowlist globs match. The roster is the GUARD_KIT_RO_SCRIPTS globs (default check-*.sh), and any other absolute repo-script spelling gets a corrective block. The rewrite is a grant, since its envelope carries permissionDecision: allow, so it attaches only when the rewritten command is granted and bounded:
    • every segment of the relative command, split by the reader’s compound split over the dequoted view, is one the allow match finds a committed Bash(…) pattern for, with no backgrounding & ending it, since the harness grants no backgrounded call;
    • the command passes rule worktree_confinement’s test and then rule grant_path_slot’s, each taken as a predicate.

    Otherwise the corrective block fires, and the re-issued relative command meets every later rule: bash <root>/.tmp/check-evil.sh meets rule script_interpreter’s scratch-body refusal rather than being granted ahead of it. Rule brace_glyph’s rewrite grant takes this same test. Placed before rule abs_prefix, which would otherwise steer the same command less precisely.

  • Repo-root absolute prefix (non-script) (abs_prefix) — Declares sq dq hd. Any other command carrying the literal repo-root prefix is steered to the repo-relative spelling. git is excluded — rule git_c_root already owns its -C handling.
  • Shell expansion / assignment (expansion) — Declares sq hdq and sq dq hdq. A residual ${…}/$(…)/<(…)/$NAME in the skeleton is blocked, since the harness’s matcher refuses every expansion before allowlist matching. A standalone NAME=value assignment is caught by a separate check, since the expansion check sees only a used $VAR. Each check’s view is deliberate:
    • The expansion check reads sq hdq. Inside single quotes $ is literal (awk '$1'), but a double-quoted "$x" still expands, so dq stays live. hd is not inert, since an unquoted-delimiter heredoc body expands too. hdq is, because a quoted delimiter makes the shell itself the guarantee that the body cannot expand. So the rule passes prose that merely names a substitution: a journal entry, an appended queue entry, or a commit message written through git commit -F - <<'MSG'.
    • The assignment check reads sq dq hdq, adding dq because a NAME="value" assigns whatever the quotes hold.
  • Unquoted brace glyph (brace_glyph) — Declares raw, sq dq hd and dequoted. The harness’s matcher refuses a bare { before allowlist matching, the behavior class rule expansion pre-empts for $-expansions, so a { surviving in the skeleton is handled by shape. A brace in any inert region passes untouched. Placed before the auto-allow band, so the band’s literal-target premise holds for braces too.
    • The skeleton is sq dq hd, unlike rule expansion’s. { is a matcher glyph, not a shell expansion, so a double-quoted brace is as inert as a single-quoted one, and a heredoc-body brace is inert whatever the delimiter. That keeps a working POSIX quantifier (grep -rnE "^[a-z]{2,}") from a corrective it cannot follow: spell the members means nothing to a quantifier or a heredoc body, and a block owes a followable corrective however right its verdict.
    • A bare {} placeholder (find … -exec cmd {} +, xargs -I{}), when every residual brace is exactly {}, is rewritten by a rewrite verdict, each {} single-quoted to '{}'. The shell passes a literal {} either way, and the quoted spelling is invisible to the matcher on rule expansion’s premise. The rewrite is a grant, so it attaches on rule abs_script’s test, the predicates of rules worktree_confinement and grant_path_slot included. Otherwise it blocks, naming the quoted '{}' spelling for the session to write itself, since the harness documents no updatedInput carried without a decision. So find . -type f -exec rm -rf {} \; and ls | xargs -I{} rm {} block wherever that command is not allowlisted, at the cost of a block and a re-issue.
    • 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 or hash). A list or range ({a,b}, {a..b}) names the spelled-out members or a loop. Any other residual { gets the generic corrective: quote it if literal, as in an unquoted awk or sed program, and write it out if it expands. Every bare { already costs an out-of-band decision no allowlist entry can suppress, so block-and-steer strictly dominates.
  • sed or awk reading a file or a stream’s line range, or sed, perl or an inline python body rewriting one (sed_file) — Declares sq dq hd, dequoted and body. Blocked with the steer to a better tool. The discriminator is the operand and the program’s shape, not the binary, which no allowlist glob can express, so this is a rule and not a deny entry. Segments are analyzed only when they lead with the tool, so a -i flag on any other command (grep -i) is invisible to it. Placed before the auto-allow band: a read with a steer meets it ahead of rule ro_pipeline’s grant, and the in-place arms block whatever the roster declares. The shapes:
    • A sed file operand steers to the Read tool’s offset/limit. A sed fed by a pipe is a text filter and untouched, except the bare range print of arm (ii) below. An option outside rule ro_pipeline’s sed discriminator, GNU’s -l N say, withholds the steer.
    • An in-place rewrite, sed -i or perl -i (any short bundle carrying i) with at least one file operand, steers to the --rewrite arm (§rewrite), with the Edit tool named for an edit a fixed replacement cannot express.
    • An awk line-range read, arms (i) and (ii) below.
    • An inline python literal rewrite, below.

    One walker, not a parser per tool. program_operands separates the program word from the file operands through a per-tool option table. Absent a supplying option, the first bare word is the program and every later one is an operand. cat keeps rule cat_file’s own read test, because it has no program word.

    • sed: -e/-f and --expression=/--file= consume an argument and supply the script; -i in any spelling marks the in-place rewrite; every other short and long option takes no argument.
    • awk: -F and -v consume an argument, separate or glued; -f consumes one and supplies the program; -- ends options. Any other option word declines.
    • perl: -e or -E as a whole word consumes the next word, which supplies the program; in a bundle (-ne, -pe) e/E takes the rest of the word, or the next word when the rest is empty, and the bundle ends there. i anywhere in a bundle marks the in-place rewrite and the rest of that word is its backup extension (-i, -i.bak, -pi, -0pi, -0777pi), ending the bundle. a c n p s t T u U w W X take no argument and the bundle continues; 0 and l take the digits that follow them. -- ends options. Any other option word or bundle letter (-I, -M, -m, -F, -x, -d, -D, -C, -V, -S, -f, a bare -, a long option) declines, as the awk row does.
    • python: -c supplies the program, -W and -X consume one word, -u -B -E -I -s -S -O -q none, a redirect token and its target are skipped, and anything else declines.

    The awk arm fires on two read shapes and nothing else. It splits statements and then pipes, as rule script_interpreter does, because which segment is piped into is dataflow the compound split erases.

    • (i) A line-range read, with exactly one file operand and no pipe into the segment. The program’s pattern is built only from NR comparisons (==, >=, <= and the strict >/<) joined by && or a , range (NR>=a && NR<=b, NR==a,NR==b), with no action or the print-all action ({print}, {print $0}). The steer names the Read tool’s offset/limit and, for a markdown section, the section extractor through the steer door (§The shell guard), <door> --emit md-section <file> "<heading>".
    • (ii) A piped line-range read. The (i) pattern with a pipe into the segment and no operand, and likewise a piped-into sed -n whose one script is a range print (A,Bp or Ap). The steer names head -n <B> | tail -n +<A> (head -n <A> | tail -n 1 for one line), the read-only roster’s spelling of the same lines, which rule ro_pipeline grants with the pipeline’s other segments.

    An action, a program file, a regex range, a second operand, a - stdin operand, or a stream outside arm (ii) all pass, most of them to rule ro_pipeline’s program check. Which steer to emit is decided by the program’s shape and its operand, never by a measured frequency. A range print has an exact equivalent in the Read tool or in two roster members with no write or execute form, while a transform, a filter or a regex range has none. The measured mix of range reads flips with what an iteration reads. A markdown heading range is a regex range: the program check admits it, and the section extractor is advertised rather than forced. The program text comes from the dequoted view (§The reader and its views), because the skeleton blanks it. The arm declines where the view fails to align: over a heredoc body, an unterminated span or a newline inside quotes. A committed awk grant is not argued with: a PreToolUse block is not overridden by an allow, so the steer fires on the two read shapes ahead of any grant, and the grant stays load-bearing for every shape the arm declines.

    Both in-place arms steer to a tool whose effect is its command line. An in-place sed or perl program can read, write elsewhere or execute, and the guard cannot tell which from outside the program. --rewrite replaces fixed text in named files and does nothing else, so the steer trades a program for a statement of intent, and its printed command names the steer door. A sweep over many files is one --rewrite call, so neither arm bounds its operand count. What passes: perl -pi with no file operand, which rewrites no file; an in-place call behind another command word (xargs perl -pi), its lead’s rule’s subject; and a perl option the walker does not model.

    The python arm blocks an inline body that is a literal rewrite: it writes a file through open( with a w or a mode or through .write_text(, calls .replace(, and carries no computed-text construct. Those constructs are an f-string, .format(, % formatting, re., an import, .index(, .find(, .join(, .split(, a slice, input(, sys., or a $ in an unquoted-delimiter body. The body is the -c argument, read off the dequoted view and declining where that view does, or the heredoc feeding the segment’s stdin (the body view). It steers to --rewrite (--expect for a count assertion) or the Edit tool, and names !<command>.

    • Why this shape and no wider one. The inline bodies that recur across sessions are a literal read, replace and write-back, which --rewrite performs with an effect its command line states. Computed replacements, tallies and probes have no such tool, so they pass and stay measured friction.
    • It reads spelling, not semantics, and leans toward passing. A literal rewrite spelled through a listed construct passes, and a computed one hiding behind none is blocked, a case the steer’s !<command> covers. The spellings are python’s, a language literal that mints no knob, and a python revision adding a write or formatting spelling is drift it does not detect.
    • What passes besides: a read-only body, a body writing through shutil, os or pathlib without .write_text(, a python behind another command word, and any other interpreter, since none was found inline. A committed Bash(python3 -*) grant is not argued with, on the awk arm’s ground.

    sed and awk sit on rule ro_pipeline’s roster only behind its program discriminator, since a program can print to a file or call system().

  • Listing-only find (find_glob) — Declares raw and sq dq hd. A bare find that only lists is blocked with the steer to the harness’s Glob tool, which returns paths registered for a later Read (rule sed_file’s read-steer shape). It fires on a conjunction no allowlist glob expresses: every segment leads with find and carries none of rule ro_pipeline’s declared forms for find, so the action roster has one literal, and nothing composes the segments: no pipe, &&/|| chain, background or redirect.
    • A ;-sequence of listings fires as a lone one does, since it is a batch the Glob steer collapses into one call. A literal echo/printf banner segment between them is the batch’s natural separator and is tolerated, and at least one segment must be a listing.
    • What passes: a piped find, a producer rule ro_pipeline may still grant; an action-predicate find, an executor; a redirected find, which has a downstream reader; and a mixed compound, holding a segment that is neither a listing nor a banner.
    • It fires only when Glob is a member of GUARD_KIT_SEARCH_TOOLS. A steer naming a tool the harness build does not carry is unfollowable, and the guard cannot see the toolset (§Layout and configuration). Inert, it leaves a bare listing to rule ro_pipeline’s grant. The firing corrective adds the bare fallback: keep find and pipe the listing into a read-only consumer, which rule ro_pipeline grants.
    • Placed before the auto-allow band, since find is in the default GUARD_KIT_RO_BINS roster. A consumer needing other behavior shadows the rule in its consumer-rules section.
  • A read-only command run by find -exec (find_exec) — Declares raw, sq dq hd and dequoted. A segment led by find whose only execute form is one -exec … '{}' + or -exec … '{}' \; is blocked when the command it runs is one rule ro_pipeline’s segment test counts read-only. That is a GUARD_KIT_RO_BINS member whose words carry no declared write or execute form, clearing that rule’s program discriminator where the member carries one. The steer is find <predicates> -print0 | xargs -0 <command>, with xargs -0 -n 1 for the ; form, which rule ro_pipeline grants through its xargs discriminator. -exec is one of find’s declared execute forms, so the read falls through otherwise although its pipeline spelling is granted. The replacement runs the same command over the same files, and its effect is readable from its command line.
    • The steer prints the words the command spelled. It joins the spellings of the predicate words and of the executed command’s words, through the word read (§The reader and its views), and re-quotes nothing. An escaped blank, an escaped glob and a single-quoted regex reach xargs as they reached -exec.
    • Its tests read values: the -exec word, the terminator, the {} placement and the roster test. The terminator is the word whose value is ; or +, so \;, ';' and ";" are one spelling, and the compound split keeps \; whole. A quoted '\;' is a literal operand and ends nothing, and an escaped command word (\cat) is the command it names.
    • Declines on any other execute or write form in the segment (-execdir, -ok, -delete, -fprint), on an executed command the segment test withholds, and on more than one -exec. It also declines where {} is not the command’s last word or a predicate follows the terminator, since the xargs spelling would then run something else. An unquoted {} declines too, since rule brace_glyph blocks it first, and so does a command the word read answers nothing for.
    • Placed before the auto-allow band, and independent of GUARD_KIT_SEARCH_TOOLS, since its steer names no harness tool.
  • Bare single-file cat (cat_file) — Declares raw and sq dq hd. A cat read is blocked in favor of the harness’s Read tool, which returns numbered lines registered for a later Edit (rule sed_file’s read-steer shape). It fires on a conjunction no allowlist glob expresses: every segment leads with cat and carries exactly one non-flag operand (cat -n <file> included), and nothing composes the segments: no pipe, heredoc, redirect, substitution, &&/|| chain or background. A ;-batch of reads (cat a; cat b) fires as a lone read does, collapsing into successive Read calls, with rule find_glob’s banner tolerance; the Read tool shows each filename itself. Multi-file concatenation, a piped, heredoc’d or redirected cat, an &&-chain, and a mixed or banner-only compound pass untouched. Placed before the auto-allow band, since cat is in the default GUARD_KIT_RO_BINS roster.
  • Working-tree git grep (git_grep) — Declares raw and sq dq hd. Shells bash and powershell. A git grep that searches the working tree is blocked, its steer naming the harness’s Grep tool, which returns matching lines with files registered for a later Read (rule sed_file’s read-steer shape). It fires when the command leads with git grep, names no revision, and does not scope elsewhere: the index (--cached/--staged), a non-repo search (--no-index) or untracked files (--untracked). 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 foo steers, git grep foo HEAD passes, and a -- <path> limiter still steers. A piped git grep is a producer and never reaches this rule. An unrecognized option-with-argument or a glued pattern flag declines, never a false steer of a history search. It fires only when Grep is a member of GUARD_KIT_SEARCH_TOOLS, on rule find_glob’s reasoning, and the firing corrective adds grep -rn <pattern> <path>, which searches the same working tree.
  • Self-matching process-liveness predicate (pgrep_self_match) — Declares raw. A pgrep/pkill -f whose pattern matches the command’s own text is blocked. -f matches against full argv, and the harness runs a command through a wrapper whose argv carries the whole command text, with no exec replacing it, so such a pattern always finds at least the wrapper. until ! pgrep -f '<script>'; do …; done never exits, a one-shot query always answers running, and a pkill -f signals its own wrapper. It reds nothing: the only symptom is the foreground cap absorbing an unbounded loop.
    • What fires: a segment whose command word is pgrep or pkill, read past a leading until/while/if/!, carrying -f bare or in a short cluster. Its pattern operand, a quoted one read whole across its blanks, matches the raw command text as the ERE pgrep compiles, case-insensitively under -i. A pattern that cannot match its own text passes. The bracket trick ('[r]un-smoke.sh') is that case, and passes this rule while staying refused as the sanctioned form.
    • The corrective names both sanctioned forms: wait on the work’s own artifact, or kill -0 against a recorded PID where liveness is the condition, whoever started the producer. The PID is recorded at launch as a <key>.run record under the scratch directory, and the corrective names that home, since rule git_mutation_under_producer reads the same set and a record written elsewhere is one neither reader finds. delegation-kit/SPEC.md §The delegation model owns the clause and the refusal of the bracket trick as the sanctioned form. This rule exists because a correct form one character from an incorrect one will be got wrong.
    • Declines, on the shared directions: a pgrep without -f, which matches process names rather than argv; an option whose argument cannot be walked; a second bare operand, a redirect being none; and a pattern that is no valid ERE. So do -x/--exact, since the pattern must then match the whole argv line, which the wrapper’s always exceeds, and -v/--inverse, which inverts the predicate.
    • Placed before the auto-allow band: pgrep is a plausible member of a widened GUARD_KIT_RO_BINS, and rule ro_pipeline would otherwise bless a waiter that can never exit.
  • Bare foreground sleep (bare_sleep) — Declares raw and sq dq hd. A bare foreground sleep in command position, outside every loop wrapper, is blocked; a blanket sleep block is refused. The sanctioned wait is a condition loop, legitimate in either spelling: until <cond>; do sleep N; done or while <cond>; do sleep N; done.
    • The discriminator. On the skeleton view, where a sleep named in a quoted span or a heredoc body is already inert, the rule walks do … done spans and treats a sleep inside one as inert. It reads the span and never the loop keyword, so correcting a wait loop’s polarity is free at the guard. This is the ruleset’s one shell-keyword walk, which rules background_no_record, wait_no_producer and bounded_wait share. The walk reads a keyword in command position only. do, done or a loop keyword spelled as an argument is a word like any other, so sleep 5; echo done closes no span and blocks, and each sharing rule counts and cuts its span at the keywords the walk read. An unbalanced do/done or a span the rule cannot attribute declines, and a sleep that is an argument rather than a command word never fires.
    • Blocks, on rule allowlist_chain’s reasoning, since no consumer allowlist is presumed to grant a bare sleep.
    • The corrective names the property and both forms. 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. 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. Naming the property and sorting both forms under it teaches the rule, where naming one form teaches a spelling; delegation-kit/SPEC.md §The delegation model owns the discriminator.
    • It names the loop’s polarity, the next mistake and the one with an attested cost. until takes a done predicate and while 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). The polarity example spells the PID as the literal read out of the .run record, since a "$pid" meets rule expansion’s block first, and the literal is the form rule bounded_wait grants.
    • Placed with rule pgrep_self_match, ahead of the auto-allow band. No mechanism outside the consumer’s tree is assumed to enforce the never-poll rule’s sleep half, so the rule is built as though none does.
  • Tracked-tree mutation under a live producer (git_mutation_under_producer) — Declares raw and sq dq hd. Shells bash and powershell. A git command that writes the index, the worktree or a ref is blocked while a *.run record under a GUARD_KIT_SCRATCH_DIRS member 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, and evidence-kit/SPEC.md §The producer-liveness lock owns the pid=<n> run=<key> grammar and the PID predicate.
    • The act set: 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, as does every non-git command. Write/Edit are outside the rule, since a session’s mandated journal write lands through them into a gitignored scratch directory while a wait is in progress.
    • It is the PreToolUse half of the waiting rule’s enforcement, by relocation. The turn end passes no PreToolUse chokepoint (delegation-kit/SPEC.md §Operative residency), but the harm it causes arrives as an ordinary tool call, which this rule fires on. The turn-end hook refuses on its own axis (delegation-kit/SPEC.md §The turn-end liveness hook): it reaches a session that mutates nothing, and this rule reaches mutations the turn-end event never sees.
    • The block over-reaches, and the bias toward passing is not taken on that one point. A read-only producer takes no harm from a commit, the record cannot say which kind it is, and the biased-toward-passing reading is what 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, which retracts a statement that has become false.
    • Declines on the shared directions and on a record that does not parse, since a guard is not where a corruption verdict is taken and check-producer-liveness already exits 2 on one. A git global option the walk does not recognize declines, the walk reaching git -C <dir> commit, and so does an unrecognized git subcommand.
    • The corrupt disposition diverges from the turn-end hook’s, deliberately. This rule declines on a record that does not parse, and the SubagentStop hook refuses on one. 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. The hook reads the whole set through one exit code, so allowing there would let one malformed record suppress every turn-end refusal in the tree. The hook’s exit-2 arm also names an unresolved case, raised over no record at all, which a per-record read cannot meet. delegation-kit/SPEC.md §The turn-end liveness hook states the same divergence from the other side.
    • Placed beside rules pgrep_self_match and bare_sleep on their reasoning, and collisions with rules after the band make it necessary. Rule allowlist_chain would block a chained git commit first and hand back run it bare, a corrective that is wrong under a live producer. Rule git_rewrite advises on git commit --amend, so a rewrite would proceed with a re-verification steer instead of stopping.
    • Its bound is closed at the launch chokepoint for every launch a PreToolUse call can see, since rule background_no_record refuses a launch that records nothing. A command the harness moves to the background on its timeout stays invisible, which is that rule’s stated residue.
  • Backgrounded launch that records no producer (background_no_record) — Declares raw, sq dq hd and dequoted. Shells bash and powershell. A backgrounding Bash call that neither writes a liveness record nor meets an exemption below is blocked. The message names the record, its grammar and its home, and what it buys: rule git_mutation_under_producer’s reach, and the next arrival’s ability to tell whether the producer is still writing. It names one canonical spelling that writes the record:

    <command> [<redirects>] & echo "pid=$! run=<key>" > <scratch-dir>/<key>.run; wait; rm -f <scratch-dir>/<key>.run

    The wait keeps a harness-backgrounded call alive until the child exits, so the completion notification means the producer finished, never that it passed. A bare wait and rm -f both exit 0, so the call’s status is 0 whatever the producer returned, and its verdict is read off its own output by its tail. A producer that redraws a terminal frame per refresh (gh run watch) appends a full frame per tick to a redirected log. The trailing rm -f retracts the record at exactly that moment, delegation-kit’s delete once exited, and not before taken mechanically. The corrective says that rule bounded_wait’s arm (B) grants the spelling for an allowlisted command, and closes on the two shapes that owe no record: an inline wait loop and a read-only pipeline.

    • Two backgrounding forms, one rule. The harness form is .tool_input.run_in_background true on the payload (§The shell guard records the field). The shell form is a statement of the sq dq hd skeleton ending in a bare & that is neither &&, the |& separator (which pipes stderr and backgrounds nothing) nor a redirect’s fd-dup. Every attested firing used the harness form, so a shell arm alone would cover the spelling nobody uses (delegation-kit/SPEC.md §The delegation model).
    • The record-writing test reads the command text, since at PreToolUse no child has started and no record can exist. The call writes a record when its skeleton carries a redirect to a path under a GUARD_KIT_SCRATCH_DIRS member ending in .run, the predicate the canonical spelling’s write is shaped to meet.
    • Exemption (1), the call writes a record, discharging the obligation inline.
    • Exemption (2), the call is a wait loop. A backgrounded until <cond>; do sleep N; done is the waiter rule bare_sleep steers toward, not a producer. It is detected by that rule’s do … done span walk, and holds only where the call does no work its text shows. Every span’s body is sleep statements and nothing else, on rule wait_no_producer’s clause (b) test. Every statement outside a loop, ahead of the first or after it, is another such loop or passes rule bounded_wait’s clause (d) test, a read-only pipeline with the banner tolerance and inert redirect targets. Every condition statement carries inert redirect targets only. No statement but a loop itself ends in a bare &, which launches a child of its own whatever word leads it. And the declared-forms reader clears the whole call. Any other call owes the record. Rule bounded_wait draws the same waiter/producer partition to withhold a grant, so an exempted wait goes on to meet rule wait_no_producer, which asks whether anything can end it, and then that rule’s clauses.
      • Every span, whatever its keyword. Two wait loops in sequence stay exempt, and so does a for loop whose body only sleeps: the test reads bodies, as the walk reads spans and never the loop keyword. A loop nested in a body is not a sleep statement, so a nest is not exempt. A body sleep carries inert redirect targets only, the tail’s redirect test: do sleep 1 > <file>; done writes that file each pass and owes the record, where do sleep 5 2>/dev/null; done stays exempt.
      • A loop opens at a keyword leading a statement. until, while or for as a statement’s command word opens a loop, and as an argument opens none: in echo for; make build; until <cond>; do sleep N; done the build is a statement ahead of the loop, and is read as one. A for header carries no command and is not read.
      • The lead and the tail are the measured wait’s own shape: a banner or a read, the loop, and then what the session reads once it ends, which is why rule bounded_wait grants a read-only tail. One statement test is called for both places, so a repair to either reaches both. The declared-forms reader runs once over the whole call, as that rule’s does, so a call the grant admits is never refused here.
      • The condition is held to the writes its text shows, and not to the read-only roster. A redirect to a named file, a statement-ending & and a roster member’s declared write or execute form each owe the record, as in until sort -c -o <out> <in>; do sleep N; done. Roster membership is not asked, because what a wait lawfully polls reaches past any read-only roster: a client reading a remote state writes nothing here, and a record would hold every commit for its lifetime.
        • A gitignored redirect target in a condition is a write, as the tail reads it. One redirect predicate for the whole call keeps a call exempt here from being one a later reader of that predicate refuses. A wait that caches each poll pipes it instead (<client> | grep -q <state>) or takes the record.
        • A GUARD_KIT_WRITE_BINS member in a condition is not read as a write. until mkdir <lock> 2>/dev/null; do sleep N; done is a lock wait, and a record for it would hold every commit while it waits on the lock’s holder. Reading it would also give that knob a second reader.
      • Each test is the named rule’s own predicate, called, so a later change to either cannot leave a call exempt from the record and unread by the rule that owns the test.
      • The bare & takes a test of this rule’s own. The compound split does not cut on a lone &, the harness’s measured class (§The reader and its views). So sleep 5 & cargo build is one segment led by sleep, and echo waited & make build one the banner tolerance admits. The exemption therefore applies the shell form’s statement-ending-& predicate to every statement of the call but a loop itself. It also has the declared-forms reader cut each segment at a statement-ending &, so a statement behind one is read as it is behind ;: …; done & sort -o <out> <in> owes the record. A segment whose skeleton and dequoted cuts differ in count is withheld, as the reader withholds any pair it cannot align. The two named predicates are not narrowed, and no other rule takes the cut, so no other rule’s verdict moves.
      • A loop’s own closing & stays. In until <cond>; do sleep N; done & the statement the & ends is the loop, the shell form of backgrounding the whole wait, and neither a lead statement, a body statement nor a tail one.
      • A work loop’s lawful spelling is the canonical recorded launch above, the loop standing as its <command>. Rule wait_no_producer declines on such a loop by its own body clause, so the recorded launch meets no second block.
      • The honest limit: a condition led by a command outside the read-only roster is not read, so a loop whose condition is itself the work stays exempt (until make; do sleep 1; done). The text does not tell a build from a remote reader: each is a command word outside the roster with no redirect and no declared form. The roster of names that would tell them is consumer vocabulary, refused on the seam ground below. The pass is not silent: rule bounded_wait’s clause (c) withholds its grant from such a condition and no allow entry matches a loop, so the call falls through to the harness’s permission decision.
    • Exemption (2) sees a wait loop only when it is spelled inline: a wait behind a script or binary name is invisible to the walk and takes the record like any launch. That costs the waiter nothing, since the record only makes rule git_mutation_under_producer hold mutations for the waiter’s lifetime, which its producer already holds. A consumer roster of waiter names is refused on the seam ground below.
    • Exemption (3), the call is a read-only pipeline: every segment passes rule ro_pipeline’s segment test, its declared-forms reader included, and every redirect target is /dev/null or an fd-dup. A child that writes nothing has nothing for a later commit to corrupt.

    Why it blocks. The guard cannot tell a producer from a trivial child, since a backgrounded printf and a backgrounded gate battery are one shape at this chokepoint. The block rests on an attested firing of the recording omission, the same ground rule git_mutation_under_producer’s departure from the bias toward passing rests on. A launch of an allowlisted command complies at no decision cost, through rule bounded_wait’s arm (B). A launch behind a name the exemptions cannot read, or of an ungranted command, costs one re-issue with the record. This rule gives rule git_mutation_under_producer’s block its reach, since that rule reads records and an unrecorded launch leaves it none.

    The honest limit. The block does not reach a command the harness moves to the background on its timeout: that call was a foreground call at PreToolUse, it writes no record, and no PreToolUse payload carries task state. The turn end’s SubagentStop payload lists harness-launched shell tasks with a status. delegation-kit rules that view a supplement to the record set and never a substitute, since it misses a child detached with a shell & (delegation-kit/SPEC.md §What background_tasks carries). Refusing a turn end on a running harness task would widen that hook’s refusal set, which is not this rule’s to take, so the moved-on-timeout launch is this rule’s stated residue.

    Declines on the shared directions and on an unbalanced do/done. A call carrying neither the harness field nor a shell & leaves the rule inert. It sits with rules pgrep_self_match, bare_sleep and git_mutation_under_producer, ahead of the auto-allow band, and that is required: rule ro_pipeline grants a backgrounded read-only pipeline outright, so a rule after the band would never run on the shapes it exists for. Rule bounded_wait’s arm (B) grants only what exemption (1) has let through. No knob is minted: a producer roster, which backgrounded commands are worth a record, is consumer vocabulary the provenance seam keeps out of a kit. A consumer wanting a narrower population narrows the two knobs that already exist.

    The PowerShell reading takes the same obligation through the PowerShell reader, on terms of its own, because the record a PowerShell call can write and the pid it can name differ from bash’s. crate-tests-windows’ x64 liveness step witnesses that the liveness predicate answers a Windows process id on that host (evidence-kit/SPEC.md §The producer-liveness lock), so the record this reading steers to is one something can judge.

    • The backgrounding forms. The harness form is the bash arm’s field, .tool_input.run_in_background true on the PowerShell payload. The shell form is a segment of the reader’s compound split whose first word is Start-Process, saps or start, matched without regard to case. It carries no -Wait parameter by any prefix from -wa (a -Wait:$false is no wait) and no -WhatIf by any prefix from -wh, so it starts a process that outlives the call. The first word is the whole test, so a Start-Process nested inside a script block (try { Start-Process … }) is not read. A job is no backgrounding form: Start-Job, Start-ThreadJob and PowerShell 7’s trailing & start work that belongs to the session and stops when the call’s process exits, which the liveness step measured as a job’s process gone the moment its session exited.
    • Exemption (1), the call writes a record, holds only for the canonical record write, a Set-Content segment carrying no other parameter than these:
      • -NoNewline, or any prefix of it from -n;
      • a literal .run path under a GUARD_KIT_SCRATCH_DIRS member (\ folded to /), as -Path, -LiteralPath or the first positional word;
      • an expandable string ending in `n, as -Value or the second positional word.

      A redirect, a plain Set-Content or an -Encoding does not count: the first two write a CRLF record, and on Windows PowerShell 5.1 a > writes UTF-16. The record grammar reads either as corrupt, and rule git_mutation_under_producer declines on a corrupt record, so the mutation it exists to hold would pass. The value is read off the raw command’s segment whose one expandable string, masked, spells the skeleton’s segment, so a quoted mention of the write is not the write.

    • Exemption (2), the call is a wait loop: the whole sq dq hd skeleton, trimmed, is one while (<cond>) { <sleep> }, do { <sleep> } while (<cond>) or do { <sleep> } until (<cond>), where <sleep> is a single statement led by Start-Sleep or sleep, the keywords matched without regard to case. The bash exemption’s ground holds unchanged: a waiter’s own record could falsify a condition that reads the record set.
    • Exemption (3) has no PowerShell reading. Its test is a roster of bash utilities, and a PowerShell counterpart is unmeasured (§The generic ruleset, the ruled classes), so a backgrounded read-only PowerShell call takes the record like any launch, at the cost of one write.
    • The $ decline is narrowed. Every record-writing spelling carries $PID or a .Id in its value by construction, so under PowerShell the rule does not decline on a $ in the command at large (§The generic ruleset, the expansion decline). It declines only on a $ inside a record write’s path word, whose target it cannot resolve.
    • The corrective names the canonical harness-form spelling, Set-Content -NoNewline -Path <scratch-dir>/<key>.run -Value "pid=$PID run=<key>`n"; try { <command> } finally { Remove-Item <scratch-dir>/<key>.run }. It says that $PID is the process running the command, so the record reads held exactly while the command runs, and that the -NoNewline and the trailing `n make the record parse. It says too that a Start-Process launch takes -Wait inside that spelling rather than staying detached, that an inline wait loop owes no record, and that no guard grant applies under PowerShell, so the spelling costs one permission decision.
    • The honest limit, in both directions. finally removes the record on a normal exit and on a terminating error, and a call killed mid-run leaves it behind. If each call’s PowerShell process ends with the call, the record then names a dead pid and is inert. If the harness keeps one PowerShell host across calls, the record names a live pid and reads held until it is deleted, the fail-closed direction, cleared by rule git_mutation_under_producer’s own corrective. Nothing here measures which process model the harness uses, and the spelling is chosen because neither answer can make it read free while the command runs.
  • Backgrounded wait with no live producer (wait_no_producer) — Declares raw and sq dq hd. A backgrounded wait loop on a file condition is blocked while no liveness record names a live producer. It is the launch-time oracle for the waiting rule’s clause that a wait owes a producer that will make its condition true (delegation-kit/SPEC.md §Operative residency). The call is blocked when every clause holds:
    • (a) The call is backgrounded, in either of rule background_no_record’s two forms, the harness field or a statement-ending shell &.
    • (b) Its first statement is a wait loop: a while/until loop with exactly one balanced do … done span, found by the ruleset’s one shell-keyword walk (rule bare_sleep), its body sleep statements and nothing else. It is the shape rule background_no_record’s exemption (2) exempts from the record.
    • (c) The loop’s condition is shell tests and nothing else: one or more of [ … ], [[ … ]] and test, each read past a leading !, joined by && or ||. Polarity is not read, so until [ -f <m> ], while [ ! -f <m> ] and until [ ! -d <d> ] are one class.
    • (d) No *.run record under a GUARD_KIT_SCRATCH_DIRS member names a live PID, on the per-record read rule git_mutation_under_producer makes, where a malformed record names nothing.

    Why the class is decidable where the clause is not. Whether this producer will write this path is a claim about the future that no chokepoint reads. What the launch shows is narrower and sufficient. A shell test reads local filesystem state, so whatever makes it true is a local writer. The waiting rule gives a session three (delegation-kit/SPEC.md §The delegation model). A shell producer carries a launch record from its launch. An Agent child is awaited by its completion notification and never by a path on disk. The session’s caller is asked, and a question is a turn end and never a wait. Only the first is lawfully awaited by a file condition, and it is visible in the record set. A backgrounded file-test wait over a record set naming no live PID therefore has no lawful producer, whatever its path.

    It blocks, where the bias is toward passing. The departure rests on an attested firing, the ground rules git_mutation_under_producer and background_no_record take. The wait was launched to hold a turn open, rule bounded_wait’s arm (A) granted it, and it held its session until it was killed. The wrong block costs one re-issue: a session whose producer is real launches it with its record first, in the spelling rule background_no_record names, and the wait then passes. The wrong pass costs a held session for as long as nobody reads the process table.

    The corrective names those three exits, and says a wait launched to hold a turn open is the finding itself. For the Agent child it names the turn end that is its wait, the template’s own bound beside it (delegation-kit/templates/agent-execution.md, Background + notification, never poll).

    Beyond the shared directions it declines on each of these:

    • a foreground call, which the foreground ceiling bounds, and which becomes the turn-end hook’s subject where the harness moves it to the background (delegation-kit/SPEC.md §The turn-end liveness hook);
    • a condition carrying any segment that is not a shell test. A kill -0 <pid> condition names its producer and ends when that PID stops answering. A command condition (grep -q, a network client) may read a writer no local record declares, and stays rule bounded_wait’s to grant or withhold;
    • a body running anything besides sleep, which may itself be the writer of the condition it tests, so the decidability argument does not reach it;
    • more than one loop span, or an unbalanced do/done, as rule bounded_wait’s clause (a) declines.

    Placed immediately after rule background_no_record, ahead of the auto-allow band. It must precede rule bounded_wait, whose arm (A) grants exactly this shape, and it follows rule background_no_record so a launch that owes a record meets that block first. It applies to bash alone on rule bare_sleep’s ground, since its test reads bash’s loop grammar, and a PowerShell wait loop is unreached. No knob is minted: a roster of marker paths or of producer names is consumer vocabulary, refused on rule background_no_record’s ground. The rule reads the harness’s background field, bash’s loop grammar and evidence-kit’s record grammar, the classes §The generic ruleset admits.

    The honest limits.

    • A live record proves a producer exists, never that it writes this wait’s condition. A sibling session’s producer in a shared scratch dir satisfies clause (d) for an unsatisfiable wait. The rule narrows the class to no producer at all, the attested one.
    • A producer that dies without writing the marker leaves the wait unsatisfiable after its launch, which no PreToolUse call sees. The sanctioned wait on a producer is its PID’s liveness, which ends with the producer; a marker wait beside a live record still passes here.
    • A condition spelled with a command is unreached, as the declines state, and so is a wait behind a script name, on the shared walk’s limit.
  • git add then a commit of the whole index (commit_only_paths) — Declares sq hdq, sq dq hd and dequoted. Shells bash and powershell. A command whose statements carry a git add and, after it, a git commit that commits the whole index is blocked. The steer is the only-paths form, git commit -o <path>… -m '<msg>', naming every path the commit is for. A new file is git added first, in the same call or its own. The subject is the shared index. Every session in one checkout stages into one index, so a path a concurrent session staged rides a pathless commit under the wrong message. The only-paths form commits the named paths whatever else is staged: git runs the commit, pre-commit hook included, against a temporary index holding those paths alone. -a additionally sweeps a concurrent session’s unstaged edits to tracked files. Block, not advise: the sweep is silent, it lands in history, and the corrective is one spelling away.
    • What takes the whole index. A commit naming no path, or one carrying -a/--all or -i/--include. Rule git_mutation_under_producer’s git global-option walk finds the subcommand and the words after it. A word after -- is a path, and a redirect and its target are none. --pathspec-from-file counts as naming a path. A value-taking option’s value is not a path. Those options are -m, -F, -C, -c and -t, their long forms --message, --file, --reuse-message, --reedit-message and --template, and --author, --date, --fixup, --squash, --cleanup and --trailer. Each is read in its attached and separate spellings, git’s unambiguous long-option prefixes included.
    • The words are read from rule sed_file’s dequoted view, so a quoted option is the option the shell passes. Where that view cannot be aligned, the skeleton is read, and its placeholder for a quoted word reads as a path.
    • Conservative by construction. A live expansion or backtick declines, read on the sq hdq view so a quoted commit message naming one is inert. A git global option the walk does not recognize declines, as do an ambiguous long-option prefix and a short option letter the walk does not model. A commit naming a path without -a or -i passes.
    • Only the compound fires. A lone pathless git commit is the ordinary commit of a single-session tree, and a rule refusing it would steer every consumer’s every commit. A consumer whose sessions share a checkout carries that case in its own always-loaded instructions, and the rule stays silent on it.
    • Placed after rules git_mutation_under_producer and background_no_record, so a mutation under a live producer meets that block first. It sits ahead of the auto-allow band and rule allowlist_chain, whose steer to run a decorated lead bare would otherwise split the add from the commit. That would widen the window rather than close it.
  • git stash on the main checkout (git_stash) — Declares raw, sq dq hd and dequoted. Shells bash and powershell. A git stash that rewrites the working tree or the index is blocked where the session’s working tree is the main checkout. The subject is the shared checkout, rule commit_only_paths’ ground one step wider: a stash takes every uncommitted edit in the tree, a concurrent session’s among them, out from under it and puts them back at a moment that session did not choose.
    • What rewrites. No verb, or a first word that is an option; push and save; pop and apply; branch, which checks a branch out. The verb is the word after the subcommand rule git_mutation_under_producer’s git global-option walk reaches.
    • Every word is read by its value, as the reader’s word read gives it: the command word, each global option, the subcommand and the verb. So git 'stash' push, git stash "pop" and git st\ash block as the plain spelling does, and git stash 'list' passes as list does. A leading shell keyword and a redirect are read off the live text and are no word, a redirect’s own target included, while a word glued ahead of a redirect stays one. A quoted keyword or > is a word like any other. Where the word read answers nothing the skeleton’s words are read, and a quoted word there stays unread.
    • What passes: list, show, create, store, drop and clear, which leave the working tree and the index as they are, and every stash from a linked worktree, which has an index and files of its own (the test rule worktree_confinement applies).
    • The corrective names two exits. To read or build another revision, git worktree add <scratch-dir>/<name> <rev>, removed with git worktree remove when done, the scratch dir being the first GUARD_KIT_SCRATCH_DIRS member. To set the session’s own edits aside, commit them, naming their paths.
    • Block, not advise, since an advise lets the stash run and the loss is silent: the wrong block costs one re-issue, and the wrong pass costs a concurrent session edits that vanish or return under it. That is also why the rule does not take rule commit_only_paths’ silence on the lone form. A pathless commit is every consumer’s ordinary commit, while a tree-rewriting stash is no step of an ordinary session, and both of its uses have an exit the corrective names.
    • A block and not a template instruction, on the Enforcement-first rule (doctrine-kit/DOCTRINE.md): an instruction in delegation-kit’s agent-execution template reaches only a session that loaded it, and the block’s message carries the steer to every session at the call.
    • No knob is minted. The block on the main checkout is the rule’s whole predicate, so off is unregistering it, which for a guard rule is the consumer-rules allow below. An advise selector would leave the stated contract untrue, and the verb set is git’s fact about which verbs rewrite, not a preference.
    • Declines on the shared directions, on a verb the walk does not know, and on a global option that moves the repository (-C, --git-dir, --work-tree), since the tree it names is not the one the rule read.
    • Placed after rule git_mutation_under_producer, so a stash under a live producer meets that block and its corrective first. It precedes the auto-allow band, and rule allowlist_chain, whose steer to run a decorated lead bare would cost a second round.
    • The honest limits. The guard cannot see whether another session shares the checkout, so the block also meets a session alone in its tree; a consumer whose sessions never share one allows the command in its consumer rules, which run first (§Consumer rules). A stash behind an alias or a script is not read. git checkout <rev> -- . and git reset --hard rewrite the same tree and stay rule git_mutation_under_producer’s alone.
  • Auto-allow : > file truncation (truncate_scratch) — Declares sq dq hd. 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, after rule worktree_confinement’s test as a predicate: truncating scratch is safe, and a tracked file still takes the decision. The ignored-target test is git check-ignore, asked of the checkout that holds the target, as rule worktree_confinement states. The git subprocess is gated behind the rare :-redirect match, and rules expansion and brace_glyph have already blocked expansions and braces, so a surviving target is a literal path. Rule append_scratch’s create case rests on this grant: truncating a gitignored target here and appending to one there compose into creating one, so narrowing this rule narrows that one.
  • Auto-allow a write to a gitignored target (append_scratch) — Declares hdq and sq dq hd. The mandated resume-journal append (delegation-kit/SPEC.md §Resume journal — agent writes, scratch reset sweeps) is a redirect, and the harness checks a redirect target as a file write. A Bash(…) allow entry grants the command and never the target, so none can grant it. 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, since a backgrounded append is rule background_no_record’s subject.
    • (a) Either write operator, >> or >, an fd prefix allowed (2>>, 2>). What bounds the grant is (b) and (c), never the operator. The create half composes two standing grants: : > <gitignored> is granted by rule truncate_scratch and <roster emitter> >> <gitignored> here, so <roster emitter> > <gitignored> adds one permission decision rather than one capability. The honest cost: no clause protects a scratch file’s prior content from a mistyped redirect, so a live resume journal or a .run record is clobberable in one call, a door rule truncate_scratch already opens.
    • (b) Every target gitignored, on rule truncate_scratch’s exact git check-ignore --quiet -- predicate and subprocess, reached only once (a) and (c) have matched. /dev/null and an fd-dup (&1, &2) are exempt, the targets rule ro_pipeline already treats as no file. At least one other target must exist, since a call redirecting only to inert targets has no write to grant. Seeing an fd-dup takes this rule’s own operator-and-target scan, because redirect_targets drops an fd-dup target entirely.
    • (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 hdq view, or a redirect target that survives normalization carrying a quote. Its structural tests read sq dq hd and this one reads hdq.
    • (e) Not a call rule worktree_confinement refuses, its test taken as a predicate.

    (c) is the clause the safety argument rests on, and the reason the grant is a hook. A rule keyed on the redirect alone would grant rm -rf .tmp/../.git >> .tmp/j.md: the target is gitignored, the operator is >>, and the command destroys the repository. Bounding the emitter makes the grant safe, and bounding it to stdout-only emitters makes the bound checkable, since cat, printf and echo write nowhere the redirect does not send them. tee is off the roster because it takes a path argument, so tee -a <tracked file> writes with no redirect for (b) to see. An anchored settings rule can express (a) and (b) but no emitter bound, so without (c) the grant would be expressible in settings and unsafe. The honest limit mirrors rule ro_pipeline’s for GUARD_KIT_RO_BINS: the grant is exactly as safe as the roster’s stdout-only property, which the rule asserts and cannot verify of a member.

    One statement is one segment plus its own heredoc residue. The compound split emits per line, and the hd class leaves a heredoc’s body placeholder and terminator on lines of their own, so the mandated cat >> … <<'EOF' form splits into three segments. The test is that every segment past the first is exactly the residue the first segment’s openers produce. That still refuses a second statement on its own line (printf x >> .tmp/j.md, newline, rm -rf …), the hazard the one-statement bound is for. A herestring takes no grant: the terminator scan matches from any <, so a herestring’s <<< reads as a heredoc opener whose terminator no line carries, and the rule declines (measured: cat >> .tmp/j.md <<<"x" falls through). Spell the write as a heredoc.

    (d)’s substitution arm is not redundant with rule expansion, as measured: that rule blocks ${…}, $(…), <(…) and $NAME, but not >(…), which runs its command. The subject is the hdq view, rule expansion’s own ruling applied one rule over, since a backtick inside <<'EOF' is as inert as $x there. An unquoted delimiter (<<EOF) leaves the body live, so the decline still fires on it, and reading hd here would silently grant a body that can run a command. The two fixture rows that assert the narrowing (§Testing) are its whole holder.

    Placed immediately after rule truncate_scratch and before rule ro_pipeline, and so before rule allowlist_chain, which blocks a bare allow entry decorated by a trailing redirect, one plausible spelling of the command this rule grants. The mandated in-turn wait is rule bounded_wait’s subject, and this rule’s clause (0) draws the waiter/producer partition that rule draws beside it.

  • Auto-allow read-only pipeline (ro_pipeline) — Declares raw, sq dq hd and dequoted. Granted silently when every pipe segment leads with a roster binary (GUARD_KIT_RO_BINS, default the grep/head/cat/find/jq family) invoked in none of its declared write and execute forms, and every redirect target is /dev/null or an fd-dup. Command or process substitution, a leftover quote after normalization, a backgrounding &, or any other redirect target refuses and falls through. A compound is read statement by statement, whatever joins it, since the safety argument is per segment and the separator decides only which reads run. A read redirected to a file is a write, which rule bounded_write grants where its target is bounded. Rule worktree_confinement’s test, taken as a predicate, gates the grant.

    xargs executes a command, so it carries a discriminator rather than riding the leads-with test. An xargs segment counts read-only only when the command it runs is itself a roster binary, or absent, since bare xargs defaults to echo. Without it, the roster would grant find . -type f | xargs rm -rf and grep -rln foo src | xargs sed -i s/a/b/. Unrecognized xargs options and a nested xargs decline. The command xargs runs takes its own declaration over its own words, where a pos:N token withholds outright, since xargs appends operands the segment does not show. echo/printf under xargs take none, being stdout-only emitters.

    Every member declares its write and execute forms, since leading with a roster binary does not make an invocation read-only (sort -o <file> overwrites the file). A segment is read-only only when its words carry none of them. A declaration is space-separated tokens, each decidable from its own shape:

    • none — nothing: the member has no write or execute form.
    • --long — --long and --long=…, and any abbreviation of it (--lo, --lo=…), because a getopt-style parser accepts an unambiguous prefix of a long option: sort --outp=<file> writes the file.
    • -x, one letter — -x standalone and any short cluster carrying x (-uo).
    • -word, a dash and two or more letters — that exact word (find’s predicates).
    • pos:N — the segment carries N or more non-option words.

    Option tokens are matched against the words as the shell passes them, read from the dequoted view with backslashes removed, so a quoted '-o' is the option it is. pos:N counts the skeleton’s words less the segment’s redirects, so a quoted word counts as positional whatever it holds, and over-counting withholds rather than grants. Where the dequoted view cannot be aligned with the skeleton, the segment is withheld.

    The lookup reads three tiers: the consumer’s GUARD_KIT_RO_FORMS entry for the binary when one is set; else the kit’s table below; else undeclared, and an undeclared member is withheld, so the call falls through to the harness’s own decision. An empty declaration is undeclared, not none, so a missed form costs a prompt and never a hole. No gate refuses a member added undeclared, since withholding at call time delivers the property without a gate over a consumer config file. The kit’s table for the default roster is a crate literal, tool mechanism of the same class as rule find_glob’s steer targets, so it crosses no seam:

    • sort: -o --output --compress-program.
    • uniq: pos:2, since its second positional is its output file.
    • find: -delete -exec -execdir -ok -okdir -fprint -fprint0 -fprintf -fls.
    • rg: --pre, which runs a program.
    • xargs: none for its own options.
    • sed: -i --in-place, with its program behind the discriminator below.
    • awk: none, with its program behind the discriminator below.
    • Every other default member — grep egrep fgrep head tail cat wc cut tr nl rev tac paste comm column diff jq ls — none.

    sed and awk sit behind a program discriminator, as xargs sits behind its own, since each carries a program whose write and execute forms live in its text rather than its options. A sed or awk segment counts read-only only when program_operands, rule sed_file’s walker, yields every program text from the command line and a static check clears each one. The check reads the program on the dequoted view and declines wherever that view does, and on anything it does not model. A decline withholds the segment, leaving the call to the harness’s own decision. The discriminator rides the one declared-forms reader, so every rule applying that reader applies it, to a sed or awk that xargs runs as well.

    • sed. A program file (-f, --file) withholds, since its text is not on the command line. So does an option outside -n -E -r -s -u -z, their long forms, --posix, --debug and --sandbox. Each script, from -e, --expression or the first bare word, is walked command by command. A command is an optional address or address range, an optional !, then the command letter. An address is a line number, $, /re/ or \cREc with its I and M flags, first~step, addr,+N or addr,~N. The check withholds on the w, W and e commands, and on an s command whose flags carry w or e. It walks the arguments of s and y by their delimiter, the text of a, i and c to the end of the line, and a { block as a sequence. A command letter outside s y p P d D n N g G h H x b t T : = l q Q z F r R a i c { } # withholds. r and R read a file and pass.
    • awk. program_operands’ awk row declines on every option but -F, -v and --, so gawk’s source-loading and extension options never reach the check, and a program file (-f) withholds. The program is tokenized with string literals, regex literals and comments skipped. A / is a divide after an operand and a regex elsewhere, by awk’s lexical rule. The check withholds on a call to system, on an @ directive, and on an unterminated string or regex. It withholds on any | other than ||, which covers a print into a command, a command feeding getline, and |&. And it withholds on a > or >> in a print or printf statement outside parentheses. A plain getline and getline < file read, and pass, and so does a > or < outside a print statement, which is a comparison.
    • The honest limit. The check reads spelling, not semantics, as rule sed_file’s python arm does, and the spellings it reads are POSIX’s and GNU’s. An implementation that runs a command or writes a file through a construct the check reads as inert is a hole until the check models it. A consumer that will not carry that limit drops sed and awk from its GUARD_KIT_RO_BINS, which returns each such read to the harness’s decision. That is the knob’s policy-as-choice, and it mints no name.
    • Why admit a cleared program. A read spelled with a side-effect-capable utility otherwise falls through to the harness’s out-of-band decision, which in auto mode is a classifier with false positives. The ruleset takes such a read off that path in three rungs, in order. A standard spelling with no side effect is steered to (rules sed_file and find_exec). A program with no side effect is admitted here. Only a residue with neither gets a read-only arm of the gate binary (rule worktree_confinement). Standard utilities come first because agents are trained on their spellings, while a custom tool costs tokens to learn. No allowlist entry is added, since the recommended grant of the binary already reaches every arm. Rule worktree_confinement’s program-bearing exclusion keeps both tools out of an isolated child’s admitted read.

    One reader. A single declared-forms reader applies the declaration to every segment whose command word is a roster member. This rule, rule background_no_record’s exemption (3), rule bounded_wait’s clauses (c) and (d), rule find_exec’s executed-command test, rule find_glob’s listing test and rule worktree_confinement’s admitted read each apply it, so they narrow together. sort -T and rg -z are stated non-forms: they write transient temporary files and run fixed decompressors, never a named file or program. The honest limit. The grant is exactly as safe as the member’s option surface as declared. A binary that gains a write option in a later release, or a platform whose spelling differs, is a hole until the declaration is widened.

    Two carve-outs widen the grant, neither past the segment-by-segment safety argument:

    • A literal echo/printf banner segment is skipped, the batched-read tolerance rules find_glob and cat_file carry, read by the same banner test. At least one non-banner roster segment must remain, so a banner-only pipeline is not granted.
    • A segment opening a statement may instead be the bare form of a committed allow entry (rule allowlist_chain defines it). A glob-headed family coexists with allowlisted decorators and would admit far more than the reviewed command, so only the bare form counts. This repairs an allowlisted script piped into head: the lead was reviewed, and every tail segment is read-only. The widened lead applies only where something decorates it, since an undecorated allowlisted command already resolves on the static match, and intercepting it would take it off the friction log. It reads GUARD_KIT_SETTINGS on the settings read, so a failed read leaves the rule as it behaves without the widening.
  • Auto-allow a sanctioned wait (bounded_wait) — Declares raw, sq, sq dq hd and dequoted. Two arms, each granted silently when every one of its clauses holds, the call falling through untouched otherwise, and rule worktree_confinement’s predicate gates both. Arm (A), the bounded in-turn wait. The mandated in-turn wait (delegation-kit/SPEC.md §Operative residency) is a condition loop, decorated by construction, so no Bash(…) allow entry can match it: a glob matches a command word, and this form’s distinguishing text is a loop condition. The measured class is a wait loop whose condition is an arbitrary command, in either polarity (delegation-kit/SPEC.md §bin/wait-probe holds the trials):
    • (0) No statement-ending & anywhere in the command. A compound that launches something beside its wait is a producer, rule background_no_record’s subject. The clause refuses the attested launch-plus-poll form on its own terms rather than by an earlier rule’s ordering. It is indifferent to the harness’s background flag, which says this call runs detached: a backgrounded wait is the mandated form, which rule background_no_record already exempts. A shell & says this text starts something and then does something else, which only arm (B) grants, in its recorded spelling.
    • (a) The first statement is a while/until loop with exactly one balanced do … done span, found by the ruleset’s one shell-keyword walk (rule bare_sleep). More than one span declines: a nested wait is not the measured class, and which span is the body would be a guess.
    • (b) The loop body contains nothing but sleep. This is the rule’s safety core, since a body running anything else is unbounded work under a grant. Each body statement is sleep plus exactly one numeric operand, and anything else declines.
    • (c) The loop condition is read-only, since it runs once per iteration, unboundedly often. It is held to rule ro_pipeline’s pipeline test, every redirect target inert. Two forms are admitted in their own right, each read-only without being a filter: the shell tests ([ … ], [[ … ]], test), and kill -0 <pid>, which sends no signal. A leading ! is read past as rule pgrep_self_match reads a command word, so the negated spelling the correctives of rules bare_sleep and git_mutation_under_producer steer toward is granted.
    • (d) Every statement after the loop passes rule ro_pipeline’s read-only-pipeline test, with the banner tolerance. Every measured call is a compound, the loop and then what the session does once the wait ends (“report only results you hold”), so a grant admitting only a lone loop would cover none of them. Unlike rule ro_pipeline, the tail need carry no read, since the loop is the call’s work. The declared-forms reader runs once over the whole call, so condition and tail are held to it alike.
    • (e) Conservative decline on anything unmodelled: either kind of substitution, or a backtick, anywhere in the raw command, and a quote surviving normalization, in rule ro_pipeline’s blanket spelling, the stricter of the band’s two. Rule append_scratch reads the same tests on the hdq view; this rule keeps the raw command, its grant having no heredoc to narrow onto.

    Two forms are not granted, and keep prompting by design, since the target is the sanctioned class rather than the measured one:

    • pgrep -f <pattern> as a condition, a pattern match the methodology forbids where a recorded PID is available (delegation-kit/SPEC.md §The delegation model, the ruling behind rule pgrep_self_match).
    • gh as a condition. Waiting on a CI run has a sanctioned blocking primitive (gh run watch), and admitting gh would widen a read-only roster into a network tool.

    The honest limit is the shared walk’s. It is command-text-shaped, so a wait loop inside a script body is invisible to clause (a), as it is to rule background_no_record. Such a waiter’s launch meets that rule’s block, and spelled with its record it meets arm (B)’s grant.

    Arm (B), the recorded launch. Rule background_no_record’s corrective names one canonical spelling, and this arm grants it when every clause below holds:

    • (B0) The shape. The call is exactly three or four statements, separated after the launch by ; or a newline: a launch ending in a statement-ending &; echo "pid=$! run=<key>" > <dir>/<key>.run; wait; and optionally rm -f of that same record path. <dir> is a GUARD_KIT_SCRATCH_DIRS member, <key> matches [A-Za-z0-9._-]+, and the filename key equals the run= key. The shape is read on the raw command and again on the sq dq hd skeleton, so the & is live and the record’s argument is a real double-quoted span. The launch’s own skeleton must equal the skeleton’s launch, so no quote is open across the &.
    • (B1) The launched command is granted and bounded. The launch carries no pipe, statement separator, heredoc or second backgrounding &, and no fd-dup onto a named file (>&<file>), which is a write the redirect scan does not read. Its words, read as rule grant_path_slot reads them — the skeleton saying which words are redirects and the dequoted words carrying the content — match a committed allow pattern through the allow match on the settings read’s fail-open read, so a failed read grants nothing. The launch passes rule grant_path_slot’s test as a predicate, and the whole call passes rule rm_tracked’s and rule script_interpreter’s tests as predicates: each rule’s finder answers without blocking, so arm (B) grants nothing a later rule would refuse.
    • (B2) The call writes only gitignored or inert targets, on rule append_scratch’s exact git check-ignore --quiet -- predicate and its inert-target carve-out. The record path takes the test too, so the grant never rests on a scratch dir being ignored.
    • (B3) Conservative decline on a command or process substitution or a backtick anywhere, or on any expansion other than the single $! inside statement 2’s double-quoted argument, counted on the view where only a single-quoted $ is inert.

    Why the grant is owed. The record’s builtin spelling carries $!, which rule expansion’s match does not reach, so the call passes the guard, but the matcher refuses expansions. Without this arm every compliant launch would cost an out-of-band decision, and a floor whose corrective always costs one is the floor sessions learn to skip. The grant adds no capability. The launched command is one the allowlist already grants, and the record write is the gitignored-target write rule append_scratch grants with an emitter it rosters. wait is a builtin, and the rm -f retracts the one file the same call created. delegation-kit’s refusal of a tool to write the record stands (delegation-kit/SPEC.md §The delegation model): the write stays a shell builtin, and naming its one spelling narrows the refusal’s stated cost, a malformed hand-written record, without reversing it. A harness-backgrounded canonical launch carries the same text, and its wait keeps the call alive until the child exits.

    Placed after rule ro_pipeline and before rules bounded_write and allowlist_chain. It sits after rule pgrep_self_match, or the attested launch-plus-poll row flips from block to allow and a never-exiting waiter is blessed; arm (A)’s clause (0) refuses that row independently. It sits after the steers placed before the band, and after rules background_no_record and wait_no_producer, so a launch that owes a record and a wait nothing can end each meet their block before anything grants them. It sits before rule allowlist_chain, which would otherwise block a decorated loop first.

  • Auto-allow a bounded write (bounded_write) — Declares raw, sq hdq, sq dq hd and dequoted. Granted silently when every clause below holds, the call falling through untouched otherwise. Each segment of the compound split is one of three kinds, tried in this order, so a find … -delete the bounds test passes is a write rather than a read its declared forms withhold:
    • a write utility: its command word is a GUARD_KIT_WRITE_BINS member, invoked only in the forms the member’s operand grammar below reads, and every target it names passes the bounds test;
    • a read: it passes rule ro_pipeline’s segment test, banners included;
    • a granted command: its words, with their redirects removed and read as rule grant_path_slot reads them, match a committed allow pattern through the allow match, on the settings read’s fail-open read.

    The clauses:

    • (0) Not a backgrounded launch. A statement-ending & refuses first: a backgrounded write is rule background_no_record’s subject, as in rule append_scratch’s clause (0).
    • (a) Every segment is one of the three kinds, and at least one segment writes. A write is a write-utility segment, or a redirect whose target is neither /dev/null nor an fd-dup. A call with no write is rule ro_pipeline’s or the allowlist’s, so no call takes both grants.
    • (b) Every redirect target is inert or bounded. /dev/null and an fd-dup are inert, on rule append_scratch’s carve-out, and every other output target takes the bounds test. An input redirect is a read. An fd-dup onto a named file, an operator inside a word (x>f) and a heredoc or herestring withhold, since the words do not show what they write.
    • (c) The declared-forms reader clears every read segment. Rule ro_pipeline’s reader runs once over the call, with each write-utility segment left to its grammar, so a read segment’s write forms withhold here as there.
    • (d) Conservative decline on anything unmodelled: a backtick or a command or process substitution in the raw command, a live $ on the sq hdq view, and a quote surviving normalization. An xargs segment naming a write utility or carrying an option its walk cannot read declines too, since xargs appends operands the segment does not show.
    • (e) Not a call a later block refuses. Rules worktree_confinement, grant_path_slot, rm_tracked and script_interpreter each have their test taken as a predicate, on rule bounded_wait’s arm (B) pattern, so the grant reaches nothing a later rule would block.

    The bounds test reads a target word, a write operand or a redirect target, as rule grant_path_slot reads a word: the dequoted word aligned with the sq dq hd skeleton, its backslashes removed, withheld where that alignment fails. A target is bounded when every clause holds:

    • (a) Relative and contained. The word is not rooted by gate-sdk’s gate_path_rooted, does not begin with ~, and carries no .. and no .git component.
    • (b) Strictly inside a scratch dir. The word lies strictly under a relative GUARD_KIT_SCRATCH_DIRS member, compared lexically with the member read as written. The member itself is no target, so neither rm -rf .tmp nor mkdir -p .tmp is bounded. An absolute member bounds no target, since comparing a relative word with it would compose the working directory into a root.
    • (c) Gitignored, on the ignored-target test rule worktree_confinement states. A glob character (*, ?, [) is admitted only in the last component, and clauses (b) to (f) then read its parent directory, which may be the member itself, since every path the glob matches lies strictly under it.
    • (d) No tracked path at or under it (git ls-files --error-unmatch --), so a tracked file force-added under an ignored directory is never removed by the grant.
    • (e) No symbolic link on its path. No existing component of the word, the word included, is a symbolic link. The lexical clauses stop at a link, and a link inside a scratch dir can point anywhere.
    • (f) No live producer record at or under a removed target, a record read as rule git_mutation_under_producer reads the scratch homes’ records and compared lexically. Removing a live record would orphan the producer that rule reads.

    The test composes no root. Every word it hands to git or the filesystem is the relative word as written, against the working directory, and the one root it meets is the main checkout’s, which the ignored-target test and the record read already resolve from a linked worktree. So it adds no crossing to gate-sdk/SPEC.md §The path-dialect contract.

    The operand grammar is a crate literal per member, tool mechanism of the same class as rule ro_pipeline’s declared-forms table. A member the table does not carry is withheld, and so is an option outside the member’s set. A long option matches its full name or an unambiguous prefix of it, and takes a value only where its row gives one. A short cluster carries only no-argument letters, with a valued letter last taking the rest of the word or the next word. -- ends the options. Every target, created, written or removed, takes the bounds test, and a removed target also takes its clause (f):

    • mkdir: -p -v -m --parents --verbose --mode; every operand created.
    • touch: -a -c -h -m -d -t -r --no-create --date --reference; every operand written, and a -r/--reference value read.
    • rm: -f -r -R -d -v --force --recursive --dir --verbose; every operand removed.
    • rmdir: -v --verbose --ignore-fail-on-non-empty; every operand removed. -p and --parents withhold, since they remove the ancestors too.
    • mv: -f -n -v -T -u -t --force --no-clobber --verbose --no-target-directory --update --target-directory; every source removed and the destination written.
    • cp: -r -R -a -p -P -f -n -v -T -u -t --recursive --archive --no-dereference --force --no-clobber --verbose --no-target-directory --update --target-directory --parents, and --preserve with or without its list; the destination written and every source a read. Under --parents every source also passes the bounds test’s clause (a), since its path is rebuilt under the destination.
    • tee: -a -i --append --ignore-interrupts; every operand written.
    • find: at least one start point before the first expression word, each a removed target, and an expression carrying -delete and none of rule ro_pipeline’s declared write and execute forms for find besides it, nor -files0-from, which names start points of its own. A leading -H or -P is admitted, and -L, which follows links, withholds.

    The write ladder. A write the ruleset takes off the out-of-band path takes the read ladder’s order (rule ro_pipeline, Why admit a cleared program), with a bounds test in place of the side-effect test:

    • A standard spelling with its own grant or tool is steered to. A tracked deletion goes to git rm -q (rule rm_tracked), a redirect to a tracked target to the Write or Edit tool (rule emitter_write), and an in-place rewrite to --rewrite (rule sed_file).
    • A bounded write is admitted here.
    • A residue with neither gets a write arm of the gate binary. --rewrite is that arm for an edit a fixed replacement expresses.

    A shell write to the tracked tree is never granted, since the harness’s write tools are its reviewable route.

    Why the grant is safe. A permissionDecision: allow blesses the whole call, so the safety argument is per segment, as it is for rule append_scratch. A read segment is rule ro_pipeline’s grant, and a granted-command segment is one the harness would grant issued alone. A write reaches only a path a scratch dir holds that git ignores, holds nothing git tracks, and reaches through no link. Rule truncate_scratch already grants : > <that same path>, so destroying a scratch file’s content is a door already open. This rung widens the operations through it, a directory removal, a move, a copy or a redirect from a granted command, and adds no path the door did not reach.

    Placed after rule bounded_wait and before rule allowlist_chain. Rule allowlist_chain blocks an allowlisted lead decorated by a redirect, which is the granted-command kind with a bounded target, so this rule decides first. Rule bounded_wait’s arm (B) writes its record through an emitter and a &, which clause (0) refuses here. Rule append_scratch grants an emitter’s lone write to a gitignored target ahead of this rule, and rule emitter_write meets a compounded one only where this rule declined it.

    Honest limits. The operand grammar reads GNU’s and POSIX’s spellings, and a member that gains a write option is a hole until its row is narrowed, which is rule ro_pipeline’s limit. A granted command’s own writes are the allowlist’s, not this rule’s. Clause (e) reads the filesystem at the call, so a link created between the call and its run is not seen.

  • Decorated allowlisted command (allowlist_chain) — Declares sq dq hd. The leading command is exactly the bare form of a committed allow entry, but the command decorates it with &&/;/| chaining, a trailing redirect or 2>&1. That leaves a segment nothing grants, so the 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 decided out of band anyway, so blocking converts that decision into a durable steer at no extra cost, where an advise would grant the decorated command’s unreviewed segments. A steer citing rule allowlist_chain’s reasoning cites this.
    • Bare leads only. An entry’s bare form is the entry itself when it carries no glob, or the head of an entry whose only * is a closing space-led one. Bash(git status *) has the bare form git status, which the harness grants as well (§The reader and its views, the allow match, measured), so an allowlist needs no no-argument twin beside such an entry. A lead carrying any argument past that form is not a bare lead, and neither is the head of a :* family or of an entry with a second *. So a glob family (Bash(git log:*)), which coexists with allowlisted decorators, never qualifies.
    • One exemption: a redirected lead whose bare form comes only from a closing space-led * and whose command word is a GUARD_KIT_APPEND_BINS member declines, so rule emitter_write keeps the write steer that fits it. One bare-form test serves both halves, and rule ro_pipeline’s widened lead shares it.
    • It 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 fires only when a non-leading segment, or a redirect on the lead, matches no committed allow entry through the allow match. A segment a backgrounding & ends matches nothing, since the harness grants no backgrounded call. It reads GUARD_KIT_SETTINGS on the settings read.
    • Placed after the auto-allow band, so a silently granted read-only pipeline never reaches it. That is why the allowlisted-lead grant lives in rule ro_pipeline and not here: a grant here would bless segments the allowlist never reviewed, while rule ro_pipeline is the sanctioned place for a grant of exactly that shape and already precedes this rule.
  • Git history-rewrite advisory (git_rewrite) — Declares sq dq hd. Shells bash and powershell. A git commit carrying --amend, -F or --file, or a git reset --soft, gets an advise steer carrying the checklist from DOCTRINE.md’s Re-verify volatile state before a git history rewrite rule. The checklist: verify HEAD before amend or squash; re-stage and verify staged content after a soft reset; carry the message in the command (-m, or -F - from a heredoc), and read a file-borne message back after the commit. Advisory, not a block, since each is a legitimate command: the rule injects the re-verification context and lets the command proceed, unless rule worktree_confinement’s test, taken as a predicate, refuses the call. The DOCTRINE.md rule is cited by name, since the doctrine’s craft rules renumber as it grows. Placed after the auto-allow band and rule allowlist_chain, so it fires only on a bare rewrite command none of them claimed. A decorated form meets rule allowlist_chain’s block first, and the advisory fires on the re-issued bare command.
  • Bare rm of a tracked path (rm_tracked) — Declares raw, sq dq hd and dequoted. Shells bash and powershell. An rm statement naming a git-tracked path is blocked: each operand is tested as the shell passes it (git ls-files --error-unmatch), a quoted or escaped one literally and a bare one as the glob the shell expands. The steer is git rm -q <path>, the operand printed as the command spelled it, which deletes and stages that one deletion in a single motion. A bare rm leaves the deletion unstaged, so it lands only via a later git add -A, the form the shared-index discipline warns against, since it sweeps a concurrent session’s foreign path into the commit. Blocks, on rule allowlist_chain’s reasoning, since no consumer allowlist is presumed to grant rm.
    • The operands are read through the word read (§The reader and its views), so a quoted or escaped operand reaches the tracked test:
      • A literal operand, one whose live text carries none of *, ? and [ bare, is tested by its value under git’s :(literal) pathspec magic.
      • A glob operand, a word no quote or escape contributed to, is tested under :(glob), where * stops at a / as the shell’s does. It is tested again with /** appended, since a glob the shell expands to a directory holding a tracked file names that file as a literal directory operand does.
      • A word mixing the two, a quoted span or an escape beside a bare glob character, is skipped, on the shared decline.
      • A literal operand whose value carries *, ? or [, or opens with :, is skipped too, since the steer’s git rm would read that value as a pattern and remove more than the rm named. So rm '*.md' passes.
      • Where the word read answers nothing the arm reads the skeleton’s words under a bare pathspec, and a quoted operand there stays unread.
    • A directory holding a tracked file steers to git rm -q -r <path>: git rm refuses a directory without -r, and -r follows -q so the recommended git rm -q grant still matches. The directory test reads the operand’s value.
    • It fires per statement (;/&&/||/| split), so a decorated form steers as the bare one does. A backtick declines, this rule’s own arm beside the shared expansion decline, and a word whose value opens with - is an option and skipped. An untracked or gitignored target never matches, so scratch deletion is untouched.
    • Every rule taking this test as a predicate narrows with it: rule bounded_wait’s arm (B1) and rule bounded_write’s clause (e) withhold a grant from a call deleting a quoted tracked path.
    • The honest limits. git’s glob lets * match a leading dot where the shell’s does not. A tracked file whose own name carries a glob character is not reached when quoted. A PowerShell -LiteralPath value carrying a wildcard character is read as a pattern.
    • A git rm carrying its force flag is blocked too, the steer’s safety half, since the flag is the one spelling of the mandated form that destroys uncommitted work. Rule git_mutation_under_producer’s git global-option walk finds the subcommand and the words after it. Every spelling git’s option parser accepts fires: standalone, in a short cluster (-qf, -rf), and as an abbreviation of --force down to --f. A word after -- is a path and ends the walk. The words are read from the dequoted view, or the skeleton where that view cannot be aligned.
    • The force arm fires whether or not a grant matches, on rule allowlist_chain’s reasoning: ungranted, the call is decided out of band anyway, and granted by a committed git rm glob it is silent data loss. The corrective names three exits: drop the flag, since git rm refuses a file with local modifications and says so; git rm --cached to untrack the file and keep it; and !<command> where the loss is intended.
    • Under PowerShell the deletion commands are rm, del, erase, ri, rd, rmdir and Remove-Item, matched without regard to case, and the corrective names the one the segment used. A word is several paths where a bare comma parts it into the reader’s array elements, 'a,b' and a`,b each naming one path, and a -Name:value word carries its value. A word the element read refuses is one path. A word following a value-taking parameter is not a path, nor is the value such a parameter carries. Those parameters are -Filter, -Include, -Exclude, -Stream, -Credential, and the common parameters -ErrorAction, -ErrorVariable, -WarningAction, -WarningVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable and -ProgressAction. PowerShell takes an unambiguous prefix of a parameter name, and so does this walk, reading a word that prefixes any of them as a value-taking parameter, the direction that passes. Any other - word is skipped, and a word naming -WhatIf by any prefix from -wh declines the segment, since it deletes nothing. The walk reads each word’s value.
      • Every path is tested as a glob by its value, since the deletion commands expand a wildcard in a quoted path as in a bare one. \ folds to / first on rule worktree_confinement’s fold, since git reads a pathspec’s \ as an escape.
      • The steer prints each path as the command spelled it, a comma part and a -Name:value value included. A value whose parameter name a quote covers has no spelling of its own, and is printed bare where PowerShell reads it as written and single-quoted otherwise. The force arm is unchanged, since it reads git.
  • Script execution off a body the command string does not carry (script_interpreter) — Declares raw and sq dq hd. Shells bash and powershell. An invocation of 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_DIRS member. Three arms share the predicate, and the runner each prints is the steer door (§The shell guard), so a consumer whose binary sits anywhere is told a command that runs:
    • (a) The interpreter is bash or sh. The steer is the --scratch-run arm, the allowlistable command that echoes the body as it executes.
    • (b) The interpreter is a GUARD_KIT_SCRIPT_INTERPRETERS member. The message says scratch execution runs in bash, or in PowerShell where the project names a host (§scratch-run), and names the same runner.
    • (c) A PowerShell body, checked before arm (b), so a consumer listing pwsh in GUARD_KIT_SCRIPT_INTERPRETERS still meets it. With GUARD_KIT_SCRATCH_POWERSHELL set, it steers to the runner with the body kept as a .ps1, which echoes it and runs it under the named host. With the knob empty, it says the PowerShell scratch path is off in this project, names the knob, and steers to a bash body under the runner. A Windows host can follow that, because the hook’s floor there is Git for Windows’ bash (§The hook on native Windows).

    Arm (c) fires in two cases. Case 1, a PowerShell host taking its body from a scratch path, under either reader. The head word’s final path component, split on / and under PowerShell on \ too, less a trailing .exe, is pwsh or powershell, compared without regard to case under PowerShell. The host’s options are walked on its own grammar, each matched by a prefix of its name without regard to case:

    • -File (-f) takes the body as its next word, a - there meaning stdin. -Command (-c) and -EncodedCommand (-e) carry it in the command string, a - after -Command meaning stdin.
    • The valued options -ExecutionPolicy (also -ep), -WorkingDirectory (also -wd), -WindowStyle, -OutputFormat, -InputFormat, -ConfigurationName, -ConfigurationFile, -CustomPipeName, -SettingsFile, -PSConsoleFile and -Version skip their value. Where two names share a prefix, a valued option wins over a switch.
    • The switches -NoProfile, -NoLogo, -NonInteractive, -NoExit, -Interactive, -Login, -Sta, -Mta and -NoProfileLoadTime are skipped.
    • Any other option-shaped word declines, which loses no real run because the host exits 64 on one.
    • The first bare word is the body, -File’s operand on pwsh and a command naming the file on Windows PowerShell. No body word at all is stdin, resolved as the bash walk resolves it.

    Case 2, a PowerShell invocation form, under the PowerShell reader only: a segment led by the call operator & or a lone dot-source operator . before a scratch path, or by a scratch path itself. So is a segment led by Invoke-Expression or iex (without regard to case) that names a scratch path or whose pipe producer does. Under PowerShell every path word has \ folded to / first, rule worktree_confinement’s precedent, so .\.tmp\x.ps1 reads as ./.tmp/x.ps1. Arms (a) and (b) read there too, so bash .tmp/x.sh issued through the PowerShell tool takes the verdict it takes through Bash.

    What case 2 does not reach, each falling through:

    • a scratch path inside a -Command string, which the skeleton blanks as it blanks a bash -c body;
    • a form inside a script block or a subexpression, the reader’s own limit;
    • Import-Module, a Start-Process launching a host with -Wait on a scratch body, and a body built with [scriptblock]::Create.

    From a linked worktree the match reads both checkouts. A body source fires when it sits under a GUARD_KIT_SCRATCH_DIRS member of the main checkout or of the session’s own worktree, each compared lexically with .. folded. For a body under the main checkout’s scratch dir, the steer says to write the script under the worktree’s own scratch dir and run it through the runner there, since the runner refuses a main-checkout path from a worktree (§scratch-run).

    The predicate is body visibility, the discriminator that bought the runner its grant. A scratch path is rewritable by any session, so the body the operator approved need not be the body that runs, and the runner’s echo-at-execution restores the correspondence (§scratch-run). A body carried in the command string (python3 -c 'print(1)', a heredoc, a herestring, a literal piped in) is shown to the approver verbatim and recorded in the friction log. A rule firing there would refuse a reviewable act in the name of reviewability. The visibility is the approver’s, not the guard’s: the skeleton this rule declares strips quoted spans and heredoc bodies before the rule sees the command.

    Body source, not operand position. A path operand always implies an invisible body, but an invisible body needs no path operand. So the rule resolves the body source as the interpreters do, a -c/-e/-m argument, else the first bare operand, else stdin, and fires wherever the scratch path sits: operand, < redirect source, the pipe producer’s operand, or a substitution operand. The same resolution keeps it off legitimate tooling: python3 tools/gen.py and bash <root>/gate-sdk/bin/run-gates.sh --scratch-run .tmp/x.sh each name a body outside any scratch dir, the scratch path in the second an argument to the runner.

    Both substitution spellings are read. Rule expansion’s match has no backtick alternative, so it blocks python3 -c "$(cat .tmp/x.py)" and passes the backtick spelling of the same command. This rule reads both spans, and rule expansion pre-empts one of them by dispatch order. It splits statements and then pipes, rather than taking the compound split, because whose stdout is the interpreter’s stdin is dataflow the compound split erases.

    Declines, on the shared directions, with one exception. A ${…}/$NAME/<(…)/>(…) anywhere declines, under PowerShell as the reader’s decline (§The generic ruleset), and so does an interpreter option the walk cannot size. Under Bash a backtick is not declined on, since it is the one body-source spelling rule expansion does not reach, and declining there would ship the hole the rule exists to close. The substitution read is Bash’s alone, because PowerShell’s backtick is its escape character. Rule bounded_wait’s arm (B) takes this test as a predicate. That rule is Bash-only, so arm (c)’s one new refusal there is a canonical recorded launch of a PowerShell host on a scratch body, which arm (c) blocks unbackgrounded anyway.

    Honest limits. A body drawn from outside the scratch dir (python3 - < /elsewhere/x.py) is out of scope, and a body with no path at all (curl … | python3) is a wider hazard than scratch execution. Both fall through. Placed at the tail, immediately before rule grant_path_slot. It grants nothing, and a command reaching it has been declined by every steer and every grant above. Rule grant_path_slot grants nothing either, so the order of the two decides only which block a command meeting both receives.

  • A committed grant matched only by reaching past its path slot (grant_path_slot) — Declares raw, sq hdq, sq dq hd and dequoted. A segment a committed Bash(…) allow pattern matches is blocked when that pattern’s path slot absorbs text reaching outside the path the pattern names. A * in a committed pattern is a path slot when the whitespace-delimited token of the pattern carrying it also carries a /: in rm -rf .tmp/* and bash */checks/check-*.sh every * is one, and in git add * and bash gate-sdk/bin/run-gates.sh * none is. The slot is what the pattern’s author wrote, so no knob and no vocabulary are needed, and a consumer’s Bash(rm -rf build/*) is bounded exactly as a scratch-dir grant is. The test, per segment:
    • (1) Read the harness view. The segment’s dequoted text — rule sed_file’s dequoted view, quote characters removed and content kept, split on the sq dq hd skeleton — with its redirects dropped and its backslashes removed, through the harness view. The skeleton’s words say which words are redirects and the dequoted words carry the content, aligned one for one, so a quoted > is an operand and a quoted or backslash-escaped blank stays inside its word.
    • (2) Parse the matching pattern. Each committed pattern the allow match finds matching the view is translated into an anchored regular expression, each * a capture group, and the leftmost-greedy parse is taken.
    • (3) Test each path slot’s capture. Its first word must be clean, read in the full shell word carrying it; every later non-option word must re-match the slot’s own token and be clean itself. A clean word carries no .. component, is not rooted by gate-sdk’s gate_path_rooted (a leading / or \, or a drive root), and does not begin with ~. At a slot opening its token, an empty capture opens the path with the literal after the *, so bash /checks/check-x.sh reads as absolute.
    • (4) Decide. Any unclean word blocks, and the message names the pattern, the word, and what it reaches past — a .. component, an absolute or home-relative path, or a second operand outside the slot. The corrective is to spell the path inside the pattern’s reach or to run the command through !<command>. A match with every slot clean declines, and the harness grants correctly.

    Declines, on the shared directions: an expansion, substitution or backtick anywhere, any live $ included, since the matcher refuses every expansion; a heredoc-bearing command, whose dequoted view cannot be aligned; and a failed settings read. A pattern carrying ? or a bracket class is skipped, and so is a segment whose dequoted text carries a quoted statement separator. Taken as a predicate the test fails closed: it reads a segment carrying a quoted statement separator whole rather than skipping it, and every other decline fails the grant.

    It closes two reaches without re-spelling a grant. rm -rf .tmp/../<file> and rm -rf .tmp/x <file> block against a scratch-dir rm grant, and bash ../../evil/checks/check-x.sh and bash /elsewhere/checks/check-x.sh block against a script-runner glob, while the committed globs stay as they are.

    Why a guard rule and not a committed deny list. A deny list matches per subcommand and outranks a hook’s allow, which makes it the one harness-enforced layer. It has three disqualifying properties:

    • a deny glob cannot express a second operand outside the directory;
    • it inherits the fragility the harness’s permission reference names for patterns that constrain arguments;
    • it is a permission-settings edit, which a consumer may reserve to its operator, so choosing it can decide that no session lands the fix.

    The rule is derived from the settings it bounds, is testable in the decision table, and lands with the kit. A deny list stays available as a harness-enforced backstop an operator may add independently.

    Placed immediately before rule emitter_write. No auto-allow above it grants a subject of this rule: rule truncate_scratch truncates only, rule append_scratch’s emitter bound refuses rm and bash, rule ro_pipeline’s roster carries neither, and rule bounded_wait’s arm (A) body is sleep only. Its arm (B) and rule bounded_write apply this rule’s test as a predicate. Rules abs_script and brace_glyph run ahead of it and each emit a rewrite whose envelope carries an allow, so both apply it before that allow attaches.

    Honest limits. The test is lexical, so a symlink under the slot reaches past it undetected. The leftmost-greedy parse is one parse of a pattern that may admit several. A wrapper option the harness view declines to strip hides a reach the matcher would grant. A command the rule declines, a heredoc-bearing one among them, is left to the harness’s own decision, grant included.

  • An emitter write the harness cannot grant (emitter_write) — Declares raw, hdq and sq dq hd. Blocked, with a steer per arm. Every arm reads a statement whose leading command is a GUARD_KIT_APPEND_BINS member writing through >> or >, the shape rule append_scratch grants, through the views that rule reads. A statement is what ;, &&, || and a newline separate, carrying its own heredoc residue on rule append_scratch’s reading.
    • (a) Compounded. The command holds more than one statement, and one of them, issued alone, satisfies every clause of rule append_scratch. The steer is to issue that write as its own call, which rule append_scratch grants with no permission decision, and the rest as a separate call.
    • (b) A target git does not ignore. The statement is the whole command, and a target fails rule append_scratch’s git check-ignore test. The steer is the harness’s Write or Edit tool for a file, or the capture arm that owns the surface when the target is one: the tool is reviewable where a redirect is not, and the arm keeps the surface’s grammar.
    • (c) Compounded, to a target git does not ignore. The command holds more than one statement, and one of them is a write both arms above leave. Not every target is gitignored, so arm (a) does not fire, and it is not the whole command, so arm (b) does not. The steer is to write that file with the Write or Edit tool as its own call and issue the rest separately. It is the composition of (a) and (b), so every emitter write statement meets rule append_scratch’s or rule bounded_write’s grant, one of these three arms, or one of the declines below. A statement whose targets are mixed, one gitignored and one not, is arm (c)’s, since arm (a)’s premise that the write alone would be granted is false for it. Statements are read in order, and the first that qualifies blocks.

    Declines, on the shared directions:

    • rule append_scratch’s clause (d), read over the whole command;
    • a backgrounded command, which is rule background_no_record’s subject: its canonical recorded launch writes its record through an emitter to a gitignored target, so arm (a) would otherwise refuse the spelling rule background_no_record mandates;
    • a statement on a line whose heredoc openers sit in more than one statement, whose residue cannot be attributed;
    • for arms (b) and (c), a target under /dev/, which is no file a Write or Edit tool can take.

    The arms are cut from the friction log. Arm (a) is the measured class: every prompting gitignored-target append in the log it was cut from was compounded or ran past the log’s truncation. Arm (b) fired on lone writes to tracked targets once it shipped. Arm (c) was cut from the compounded shape that still fell through beside it, writes led by cat, printf or echo to a repo-relative target outside the scratch set, chained to another statement. The destination test every arm needs is rule append_scratch’s, so no consumer roster is owed. No steer names a particular capture arm or its surface, which would put another kit’s surfaces inside guard-kit as a roster a consumer keeps in step; the agent’s own instructions name the arm.

  • A bash -c or sh -c wrapper (shell_wrapper) — Declares raw and sq dq hd. Blocked, with the steer to run the payload as the command itself, or, for a body that needs a shell of its own, to write it to a scratch script and run it through the --scratch-run arm. Fires when a segment’s first word is bash or sh and its next word is -c. The wrapper puts the whole payload inside one quoted argument, which the skeleton strips, so no allowlist entry and no rule above can see the command inside it. That makes the wrapper a steering target on its own terms, whatever it wraps. What it does not reach. A wrapper behind another command word, xargs bash -c or timeout 5 bash -c, is out of scope: the leading word decides which rule reads the call, and each of those leads is its own rule’s subject. A bash <script> naming a scratch path is rule script_interpreter’s.

    Both rules block, on rule allowlist_chain’s reasoning, where an advise would fall through to the same prompt and teach nothing durable. Both sit at the tail, after rule grant_path_slot, because neither grants anything and a command reaching them has been declined by every grant above.

  • A main-checkout path from a linked worktree (worktree_confinement) — Declares raw, sq hdq, sq dq hd and dequoted. Shells bash and powershell. The rule applies from a session whose working tree is a linked worktree (the test gate_harness_bin applies, gate-sdk/SPEC.md §lib/gate.sh). A command carrying a path word that resolves into the main checkout is blocked, unless every such word lies under a GUARD_KIT_SCRATCH_DIRS member of the main checkout or the command is the admitted read. An isolated session reaches the main checkout for two things. One is its journal, which it appends under the scratch dir by shell, because the harness refuses its file-tool writes there. The other is reads: the harness’s read tool takes a named file, and a search, which a harness build may carry no tool for, takes the admitted read.

    The path word. A path word is a word of rule sed_file’s dequoted view, or the skeleton’s words where it fails to align, or the value of a --name=value word. Redirect targets and a git -C target are included. It resolves lexically against the working directory, with .. components folded. It resolves into the main checkout when it lies under the main checkout’s root and not under the session’s own worktree root, so a sibling worktree nested there counts as the main checkout’s. The roots are resolved once per hook process, and git is spawned for them only where the nearest .git above the working directory is a file, so a session outside a linked worktree pays no spawn. Under PowerShell a path word’s \ is a separator, folded to / before the comparison with the two roots, a *> operator glued to its target is read as a redirect’s, and a drive-letter root is rooted, by gate_path_rooted’s rule.

    The admitted read is the one shell form unable to write where this rule refuses. It must hold three things:

    • Every segment passes rule ro_pipeline’s roster test: it leads with a GUARD_KIT_RO_BINS member invoked in none of its declared write and execute forms, with the literal echo/printf banner tolerated. Or it runs a READ_ONLY_ARMS member on the gate binary or the front end (gate-sdk/SPEC.md §The non-gate arm). The binary counts as the steer door’s spelling of GATE_SDK_NATIVE_BIN, or that value resolved against the session’s own worktree or the main checkout. The front end counts as gate-sdk/bin/run-gates.sh under bash, resolved the same way. The arm’s --emit <name> spelling is normalized to --emit-<name>, as the binary normalizes it.
    • No segment leads with a program-bearing tool, meaning rule sed_file’s walker rows and every GUARD_KIT_SCRIPT_INTERPRETERS member. Such a tool’s write form lives in its program text rather than its options, so no declaration of it can be true, whatever the roster holds.
    • Every redirect target is inert, inside the session’s own worktree, or under a main-checkout scratch dir.

    So a dual-use utility is never admitted on its name. The admission reads the invocation’s forms, and an undeclared member is withheld. GUARD_KIT_WORKTREE_READS selects the admission. read-only, the default, admits it, and off admits none, which leaves the harness’s read tool as the one route to a main-checkout file. One definition of read-only, not a second roster: which utilities are admitted is GUARD_KIT_RO_BINS and GUARD_KIT_RO_FORMS, already consumer-bound, and a roster of this rule’s own would give read-only two owners that drift. The predicate itself — the path-word resolution, the two exemptions, the declaration grammar and the program-bearing exclusion — is kit grammar, since another value breaks the confinement rather than a taste. The admission’s default is read-only on the measured ground that a harness build may carry no search tool, and with none off leaves a search of the untracked corpus no route. Under PowerShell the admitted read is not offered, because no declared-forms reader exists for PowerShell commands: every main-checkout path word outside a scratch dir blocks, and the corrective names the harness’s read tool and the scratch journal and omits the admitted read.

    The corrective is the advertisement. It names the offending word and what it reaches, then the lawful alternatives:

    • the harness’s read tool, for a named file;
    • under read-only, the admitted read, with its roster interpolated from the loaded knob at the call, so the message never carries a copy, and the program-bearing members omitted, since the admitted read excludes them whatever the roster holds;
    • under read-only, the read-only arms, interpolated from the crate’s declaration and printed with the steer door;
    • a history read in the session’s own worktree, which shares the main checkout’s refs and objects;
    • the journal, appended by shell under the main checkout’s scratch dir.

    No rule ends the hook on a call this rule refuses: every rule whose decision is not a block takes this rule’s test as a predicate, on rule grant_path_slot’s pattern. Those are the grants and rewrites of rules abs_script, brace_glyph, truncate_scratch, append_scratch, ro_pipeline, bounded_wait and bounded_write, and rule git_rewrite’s advise. A rule placed last is reached only by the calls every earlier non-block decision declined. The shared ignored-target test asks the checkout that holds the target, for rules truncate_scratch, append_scratch, bounded_wait’s arm (B2), bounded_write and emitter_write. From a linked worktree git answers a main-checkout path as outside the repository, which would refuse the very journal append the scratch exemption exists for. The test is one function, every site’s one spelling, which discards git’s stderr so git’s text never leaks into a block message.

    It keys on the working tree and on nothing a dispatch carries. It reads no agent_type and no model, so it confines a mutating type dispatched isolated exactly as it confines a read-only one. It shares no knob with delegation-kit’s dispatch rules (delegation-kit/SPEC.md §The delegation model, D4 and D5).

    Declines on a backtick span as well as on the shared directions. Under PowerShell it declines on any live $ and never on a backtick, the reader’s answer (§The generic ruleset).

    Honest limits.

    • The test is lexical, so a symlink reaches past it.
    • Under PowerShell the comparison is case-sensitive, so a path word that differs from the root only in case, which NTFS resolves to the same directory, escapes it.
    • A program the session runs can write any path it computes. No rule reading the command sees that write, and, measured, no harness sandbox confines it to the worktree (delegation-kit/SPEC.md §The delegation model).
    • A roster-led read is only as safe as the roster’s declarations, which is rule ro_pipeline’s own limit: a member gaining a write option is a hole until it is declared. A read-only arm closes that for the reads it replaces.
    • The roster reaches the session at its first refusal, not before.
    • A consumer rule dispatches ahead of the generic ruleset, so a consumer grant is bounded by its author rather than by this rule.
    • Where GUARD_KIT_SEARCH_TOOLS names Glob or Grep, rules find_glob and git_grep steer a listing-only find and a working-tree git grep to those tools ahead of this rule. Whether those tools reach a main-checkout path from an isolated child is not measured: the measured build carried neither.

Writing a consumer rule

Project rules — build-cache hygiene, container-build concurrency, test-suite serialization, disk reclaim, tool-specific steering — live in the consumer’s rule command, which GUARD_KIT_CONSUMER_RULES_CMD names and the shell guard runs before the generic ruleset (§Consumer rules). The command reads the payload on stdin, blocks by exiting 2 with its message on stderr, decides by printing an envelope (--guard-json advise, allow or rewrite renders one) and exiting 0, and says nothing by exiting 0 with empty stdout.

Order within the command is its author’s, under three disciplines attested in production use:

  • a command about to be blocked must never first trigger a side-effecting rule, so blocks go before any reclaim or cleanup rule;
  • a steering rule precedes the broader rule that would catch the same command less precisely;
  • a substring rule sees the entire tool input, heredoc bodies included, so prose that merely names a guarded command, such as a git commit -F - message describing one, false-blocks. Match against the skeleton view --guard-json view prints, naming the classes inert for the rule (sq dq hd for any rule that does not itself test for an expansion), and keep longer prose in a quoted -m span or a -F <file> the hook never reads.

Under the Bash|PowerShell matcher a consumer command receives both tools’ payloads, and view reads each through its own reader, so a rule written for one shell’s grammar reads tool_name first and says nothing on the other. Prove a rule with a firing and a non-firing row in the table GUARD_KIT_CONSUMER_CASES names; --run-guard-tests runs them against your command (§Testing).

scratch-run

--scratch-run <script> [args…] runs a script from the consumer’s gitignored scratch dir (GATE_SDK_TMP_DIR) and shows it as it runs. The member is the gate binary’s arm, reached through the front end as bash gate-sdk/bin/run-gates.sh --scratch-run <script> [args…]. The --emit- prefix is load-bearing (gate-sdk/SPEC.md §The non-gate arm) and this member is a runner, so it is spelled bare, as --wait-probe and --upgrade-smoke are.

It closes a loop the steering rules open. The generic ruleset pushes probes and multi-line sweeps into the scratch dir and refuses the harness scratchpad by name. Nothing allowlists executing what lands there, so every run of a steered-to script would be decided out of band. The runner is the fixed, allowlistable path. Given a target inside the scratch dir, it echoes the script’s contents to stdout, a header naming the path and then the body, and then executes those bytes. It passes stdout, stderr and the child’s exit code through and forwards [args…] verbatim.

The echo is the point. A bare scratch-glob allowlist would auto-approve any script at that path, its contents visible only in an earlier write call: a visible command made silent and opaque. Echo-at-execution makes the silent run self-documenting, because the executed lines land in the transcript as they run, the evidence a supervisor’s post-commit review reads. Inlining the script instead serves a short probe, not the multi-line sweeps (loops, arrays, command substitution) that recur.

What the allowlist entry buys, and what it costs. The consumer’s entry names this fixed path with a wildcard tail. A guard inspects only the outer command string, so an allowlisted runner call passes and the script’s contents are never guard-inspected: a generic rule that refuses an expansion-bearing one-liner does not reach the same code inside a scratch file. The echoed body is the compensating control. It relocates review rather than removing it, from before execution, where the permission decision put it, to after, in the transcript.

A consumer cannot decline scratch execution without declining the battery. The runner is an arm-table member, so it is reachable wherever the battery front end is. What a consumer loses is the ability to separate the two decisions; the compensating control lives in the code and never lived in the grant. This is a taken cost. It does not license a port to retire a consumer guarantee whenever a family rule forces its hand: retiring one is an escalation, never a decision inside the cut.

Scratch execution runs in the harness’s own shells: bash always, PowerShell where the consumer names a host. The runner’s interpreter set is the statement of that rule. Rule script_interpreter is the command-line half of the enforcement, and the runner’s refusal of a body whose file states another interpreter is its own half.

Why the set is the harness’s shells and nothing wider. The runner is reached through the front-end grant (§The recommended allowlist), so each interpreter it runs is run on that grant with no settings edit. An interpreter the harness does not run as a shell tool would turn run a reviewed body in the session’s shell into run anything on a reviewed body, and that stays refused. PowerShell is the harness’s second shell tool. The echo covers a PowerShell body as it covers a bash one, and admitting it adds no capability, since a bash body could already call pwsh inline. It does widen the grant’s reach, so it is the consumer’s selection, off by default, made in its own knob file.

The grant is the front end’s, because the runner is an arm-table member (gate-sdk/SPEC.md §The non-gate arm’s forced-family test). A permission set is matched, not versioned, so a relocated arm is a different command string. This one’s is the battery front end’s, which a consumer’s front-end grant buys whole for every non-gate arm. A consumer running lifecycle stages already holds it; one running only the pre-commit hook owes the front-end grant, not one of this tool’s.

What the set costs. A session wanting a Python scratch script writes a shell script invoking Python with the body inline, or works in a shell the control covers. Other-interpreter scratch runs are few, while the silent hole would apply to every one.

The interpreter comes from the file, and the extension decides it. A target whose name ends in .ps1, matched without regard to ASCII case, is a PowerShell body, and a shebang on it must name pwsh or powershell or be absent. Every other target is a bash body, whose shebang must name bash or sh or be absent, and one naming pwsh or powershell is refused with the corrective to name the body .ps1. The /usr/bin/env <interp> spelling resolves as the direct one does, and the extension decides because it is how PowerShell and Windows name a script. A contradiction exits 2 before the echo, so a refusal prints no body and stays distinguishable from a child that exited 2. A target with no shebang is unaffected. This is exact where a roster is approximate, and derivation-first: the file states its own interpreter. Each mechanism is exact for the input it has: the guard has a command string and no file, the runner has a file. So the arm neither reads nor declares GUARD_KIT_SCRIPT_INTERPRETERS, though the knob sits in the kit’s namespace.

A PowerShell body needs a host the consumer named. The arm’s knob roster is GATE_SDK_TMP_DIR and GUARD_KIT_SCRATCH_POWERSHELL (§Layout and configuration). With the knob empty, a PowerShell body is refused before the echo at exit 2, the message saying the PowerShell path is off in this project, naming the knob and giving the bash alternative. With it set, the runner checks that the named host resolves, on PATH for a bare name and as a file for a path, and refuses before the echo naming the knob and its value when it does not. After the echo it spawns the host as a consumer command grounded on the knob, never gate-sdk’s contributor roster members of the same names, and passes the exit code through. The command is -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File <snapshot> [args…], each flag for a reason:

  • -NoProfile, because a profile is code the echo never shows;
  • -NonInteractive, because a prompt would hang a call no one can answer;
  • -ExecutionPolicy Bypass, because it sets the policy of the host’s own process, so a locally configured policy refusing script files does not refuse the snapshot, and a non-Windows pwsh accepts it inertly;
  • -NoLogo, which keeps the banner out of the transcript.

Honest limit: the policy flag sets the process scope only, and Microsoft documents a Group Policy scope, machine or user, as outranking it. On a host whose execution policy Group Policy sets, a PowerShell body can still be refused, and the runner passes the host’s exit status through. That precedence is documented, not measured.

What becomes config and what does not. Which PowerShell host runs a PowerShell body, if any, is a consumer selection with off among its values (doctrine-kit/DOCTRINE.md, Policy-as-choice). The set itself, the echo, and the snapshot are rule: no knob admits an interpreter outside the harness’s shells, and none turns the echo off, which would restore the honour system the rule replaces.

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 bypass for running an arbitrary path unreviewed: its reach is exactly the scratch surface the guard 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 with a child exiting 2; the echo is the discriminator, as a refusal prints no body.

The runner executes the bytes it echoed, never the path it was given. An interpreter handed the target re-reads it, and bash reads a script in pieces as it runs. A rewrite landing between the echo and the read would run a body the transcript never showed. So before the echo the runner copies the bytes it read into a snapshot beside the target. It echoes those bytes, runs the interpreter on the snapshot with [args…] verbatim, and removes the snapshot once the child exits, whatever its code.

  • Where and how it is made. The snapshot is a new file in the target’s resolved directory, so a body’s references to its sibling files still resolve. Its name is .-led, so a listing does not show it as the target, and carries the target’s stem, a token unique to the run and the target’s extension. It is created exclusively, never opened over an existing path, and owner read-write only on unix.
  • How it is named to the reader. The interpreter is handed it as the target’s own directory spelling joined to its name, so a relative target stays relative. A second header names both, === scratch-run: executing <target> as <snapshot> ===, which maps an error line naming the snapshot back to the target.
  • Its failures. A snapshot the runner cannot create or write is refused before the echo at exit 2, naming the directory, so every refusal still prints no body. A failed removal is written to stderr and leaves the exit code alone, because the child’s code is the contract.
  • Why not an argument or stdin. An argument meets the platforms’ per-argument and command-line length limits, and a body on stdin is read by any command in it that reads stdin.

Honest limits. $0, BASH_SOURCE and $PSCommandPath name the snapshot rather than the target. A runner killed mid-run leaves its snapshot behind, where the scratch dir’s own disposal removes it (lifecycle-kit’s iteration-boundary wipe, where that kit runs). The snapshot is as writable by the same user as any file, so the control holds against a concurrent session rewriting the shared scratch path and is no boundary against a process targeting the runner’s own file.

The resolution forces the mechanism, and the cheaper alternative is refused on security. Resolved, never the spelling means the arm resolves through walk::canonicalize, the crate’s only std::fs::canonicalize (gate-sdk/SPEC.md §The crate’s crosser), and inherits its dialect obligation. The comparison runs on two answers from that one producer, never one canonicalized path against one composed string. The crate’s ordinary shape, an absolutise, a lexical normalize and a prefix compare, handles a .. traversal but does not follow a symlink out of the scratch dir. A symlink planted inside would then pass, narrowing a fail-closed control, so the seam test asserts the symlink case.

Content-agnostic generic mechanism: the scratch dir comes from gate-sdk’s GATE_SDK_TMP_DIR, and the one kit knob is the PowerShell host. Both resolve from the kit tables and the consumer’s knob file, and an environment-set value still wins, because a scalar’s environment value outranks every file (gate-sdk/SPEC.md §The knob file). The consumer’s allowlist entry is that consumer’s settings, never a kit literal.

The arm is native/src/emit/scratch_run.rs and its ARMS row, and rule script_interpreter’s steer is spelled by the compiled shell guard. The seam cases live in gate-tests/scratch-run.test.sh, on the shell substrate by their own nature.

rewrite

--rewrite [--regex] [--expect <n>] [--] <find> <replace> <file>… replaces text in the named files and does nothing else. It is reached through the front-end as bash gate-sdk/bin/run-gates.sh --rewrite …, and it is the steer target of rule sed_file’s in-place arms. The --emit- prefix is load-bearing (gate-sdk/SPEC.md §The non-gate arm) and this member writes files, so it is spelled bare, as --scratch-run is. Its module is native/src/emit/rewrite.rs; it spawns no program.

Two richer forms are refused, and both fall to the Edit tool or to !<command>:

  • Capture-group replacement would need a substitution engine inside the matcher, which gate-sdk/SPEC.md §The POSIX ERE matcher sizes out, and a backreference makes the output depend on the matched text — the unpredictability the arm exists to remove.
  • A pattern read from a file would make the program invisible on the command line, the hazard rule script_interpreter blocks for scripts.

What a match is.

  • Without --regex, <find> is a literal matched over each file’s whole content, so a match may span lines. Three escapes are read, left to right, in both <find> and <replace>: \n (newline), \t (tab) and \\ (backslash). Any other backslash is literal. A multi-line edit can therefore stay on one command line. An empty <find> is a refusal, since it matches everywhere.
  • With --regex, <find> is a POSIX extended regular expression (gate-sdk/SPEC.md §The POSIX ERE matcher), applied line by line. A match never spans a newline, and ^ and $ anchor at the line’s ends. A trailing newline ends the last line and opens none, so an empty file holds no line. Matches are leftmost-longest and non-overlapping, scanning on from each match’s end. An empty match advances one byte.
  • <replace> is always literal text after the three escapes. There are no backreferences and nothing is evaluated.

What it reports. For every replaced span the arm prints <file>:<line>:, the original lines the span touched, each prefixed -, and those lines as the file now reads, each prefixed +. A span that deleted whole lines prints no + line, and two spans on one line each print that line’s final form. A file with no match is named on a line of its own (<file>: no match) and left untouched. The arm then prints one summary line: rewrite: <n> replacement(s) in <k> of <m> file(s).

Exit status.

  • 0 — at least one replacement was written.
  • 1 — nothing was written, for one of two reasons: no operand matched, or --expect <n> was given and the total differs from <n>, which prints the matched counts beside <n> and no span. --expect is the Edit tool’s missing-or-ambiguous check carried to a sweep.
  • 2 — a refusal. The usage (on stderr), an unparseable pattern, or an operand refused below. Every refusal happens before the first write.

An unrecognized --prefixed argument before -- is a refusal, wherever it sits among the operands, and -- ends option processing, so a <find> or <replace> beginning with - stays reachable (gate-sdk/SPEC.md §The bin/-tool contract’s shape half). Help lives at the refusal and in guard-kit/README.md, as for §compare-settings-allow.

An operand is refused, and nothing is written, when any of these holds:

  • it is not an existing regular file, or it is a symlink — both read off the operand as given;
  • its canonical path is not inside the canonical working directory, which the front-end sets to the repository root (both sides resolved through walk::canonicalize, gate-sdk/SPEC.md §The crate’s crosser, and compared with §scratch-run’s boundary test);
  • a component of its path is .git, tested on the operand as given and again on its canonical path below the root, so a symlinked directory into .git is refused too;
  • it resolves to the lifecycle state file (lifecycle-kit/SPEC.md §check-stage-evidence, the workflow-state-guard paragraph), tested with that hook’s own predicate, is_state_file. A predicate that cannot resolve LIFECYCLE_KIT_STATE_FILE is a refusal where the hook advises, since a writer that cannot tell whether it is about to write the state file must not write;
  • its content, or the content the rewrite would produce, is not UTF-8 or carries a NUL byte — the matcher is byte-wise, so a match can split a multi-byte character;
  • it names the same canonical file as an earlier operand.

The write. Every operand is read, checked and rewritten in memory before the first byte is written. Each changed file is then written to a temporary file in its own directory and renamed over the original, with the original’s permission bits, and its spans print once it is replaced. An I/O error part-way through is exit 2, and the message names the files already replaced, since a rename already done is not undone.

The rewrite arm’s security posture

Its reach is narrower than what it replaces, and the grant it rides is not widened. The arm is reached through the front-end grant a consumer already holds, so it needs no settings edit. That is the shape §scratch-run refuses for an interpreter outside the harness’s shells, and the difference is what the two widenings would add. Such an interpreter would turn run a reviewed body in the session’s shell into run anything. But the front-end grant already reaches --scratch-run, and a bash body run there reaches sed -i and perl -pi on any path the process can write. So this arm adds no capability that grant lacks. What it adds is a narrower form of one capability the grant already has: bounded to the repository tree, refusing .git and the state file, executing nothing, and deterministic in its effect.

The compensating control is the printed span, and it relocates review rather than removing it. A rewrite granted through the front-end is not decided before it runs. Its before-and-after lines land in the transcript as it runs, and the working tree’s diff stands until commit. That is §scratch-run’s echo posture, applied to an edit rather than a body.

A path guard on the harness’s file tools does not see this arm. A PreToolUse(Write|Edit) hook reads a file-tool call and never a Bash command. That is why the arm refuses the state file itself, through the predicate the kit’s one shipped path guard uses: rule sed_file steers an in-place rewrite here first, and the steer must not open the file that guard exists for. The honest limit: a consumer’s own Write|Edit path guard is not consulted, and a file it protects is rewritable here as it already is by --scratch-run. Such a consumer’s protection is only as wide as its Bash rules.

What the grant now reads as. A front-end grant read as “run the gates” also grants silent, bounded rewrites of tracked files, so declining them means declining the battery: §scratch-run’s taken cost, for this member.

The matcher cannot be made to run away. It is a Thompson-construction matcher with a bounded program (gate-sdk/SPEC.md §The POSIX ERE matcher), so a pattern costs at most linear memory and polynomial time per line, and a construct outside the grammar is a refusal.

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 surface the harness matches on (§The reader and its views): a logged command is granted only when every segment is. So a whole-string glob spanning a compound the harness would split and refuse, Bash(git status:*) against git status && rm -rf x, does not read as allowed and counts as prompting.

The invocation. The ranker is a non-gate arm, reached through the front-end as run-gates.sh --emit scan-prompts [--count] [--] [<log>]. Its declared roster is GUARD_KIT_LOG, GUARD_KIT_SETTINGS and GUARD_KIT_SETTINGS_LOCAL, every one a row of guard-kit’s table (§Layout and configuration). That roster forces arm-table membership over a top-level flag: the knob-file derivation, check-reads-couples and check-gate-substrate-parity read a member’s declared reads off its row, and a hardcoded flag has no row (gate-sdk/SPEC.md §The non-gate arm).

The ranker is composed from the bash reader: the compound split, the skeleton and the redirect scan (§The reader and its views), the functions the rules call, so the ranker and the guard read a command one way.

What a consumer in a tree with no gate binary loses is one advisory ranking, the scan-prompts step of templates/close-triage.md. The kit registers no gate here, so no gates.list row depends on it. The friction loop itself does not move: the guard still blocks, steers and logs, the log accrues, compare-settings-allow runs, and the step’s other four items stand. Such a consumer triages an unranked log by reading it, which is worse than ranking it and is not losing the loop.

The two settings documents are parsed by the arm itself. A settings read delegated to an external program fails open when the program is absent. The allowlist reads empty, every logged command reads as prompting, and the ranking reports a large, plausible, wrong number at exit 0 for a KPI to record as a trend. The arm’s spawned-program set is empty, stated here because nothing mechanical records it: an arm-table row carries no requirement element, and --needs answers for registry members only (gate-sdk/SPEC.md §The non-gate arm). The claim is bounded to this reader: smoke/install.sh still shells to jq for the install recipe’s settings merge, a contributor floor rather than an adopter one.

What crosses into the binary and what stays the consumer’s. Three command vocabularies travel with the ranker: the harness’s built-in read-only git and docker auto-allows, and the common multi-command binaries the key sub-keys on. They are harness behaviour and shell-substrate knowledge, which admit nothing a project owns (§The generic ruleset’s test), so they stay kit literals that mint no knob. Their cost is portability: a second harness with a different built-in set would force a configurable slot. The allowlist itself never crosses. The arm reads the consumer’s own permissions.allow[] through the two settings knobs and ships no default allow entry; templates/settings-allow.json is a recommendation the consumer merges, and this arm never reads it.

What “prompting” names. It counts the calls nothing in the allowlist grants, whose decision the harness 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. Either way 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, and the call is not allowlist-unreachable (below). Silently granted; off every list.
  • Prompting — some segment nothing grants. The headline <n> prompting call(s), grouped and ranked by pattern: the leading binary, plus the subcommand for the common multi-command binaries, plus the write-shape suffix below. Close triages it by the criterion above. Its rows print in two labelled sections, actionable and then allowlist-unreachable (the reachability verdict below), and the headline gains the clause <u> of them allowlist-unreachable. The partition is over rows, never over the count: <n>, <m> and kpi-prompt-friction’s two integers do not move with it.
  • Overlay-covered — granted and not allowlist-unreachable, but at least one segment relies on the uncommitted GUARD_KIT_SETTINGS_LOCAL overlay. It did not prompt, so it is off the headline, which stays a true prompt count rather than an upper bound. It is the promote-or-prune candidate the close step must see, so it is ranked in a separate, visibly-advisory section below the headline.

An allowlist-unreachable row is sectioned too but stays on the headline, because the reasons differ: an overlay row did not prompt, while an unreachable row did and will on every future call of its shape. Moving it off would make the count stop measuring what the KPI reads it for.

A PowerShell line (§The shell guard, the fall-through line) is none of the three. The grant test reads Bash(…) entries alone and would call every such line prompting, so it is counted in its own advisory section. The line is decoded as a Bash line is, and kept off the headline and its logged denominator, the ranking, the overlay and unreachable sections and --count. After every other section the report prints <p> PowerShell fall-through(s), not ranked against the allowlist, then one row per key with its call count. The key is the first segment’s first word as the PowerShell reader splits it (§The reader and its views), lowercased because PowerShell command names ignore case. The section is absent when the log holds no such line.

The allowlist-reachability verdict. A logged call is allowlist-unreachable when it carries a shape the permission matcher refuses outright. Four shapes qualify, each on a ground the ruleset already states:

  • an expansion, substitution or backtick — rule expansion’s “the harness’s matcher refuses every expansion”, with the output process substitution and the backtick that rule does not block;
  • a write redirect — which rule truncate_scratch says “defeats the permission matcher” and rule append_scratch says no Bash(…) entry can grant. The test is the write-shape suffix’s scan over the reader’s redirect pairs with rule append_scratch’s own target test, so a /dev/null target and an fd-dup do not qualify. The suffix still keys them, since the key’s axis is the operator and the verdict’s is the target;
  • a call longer than the harness’s analysis bound, which the harness asks about whatever the allowlist says; a decoded line past the bound is one;
  • a backgrounding & — rule background_no_record’s statement-ending bare & on the structural view, |& excluded as a separator — because the harness grants no backgrounded call whatever its grants cover (§The reader and its views, the compound split, which records the probe).

Grading each row against the allowlist’s own matching rule is refused though it is more exact: it would re-implement that rule in the ranker, where reading the grounds the ruleset states adds no second copy. Chaining is excluded: §The triage criterion’s per-segment rule makes a compound’s segments legitimate allowlist entries, and granted() models a compound as reachable when every segment is, so a chaining arm would contradict the function computing the partition.

How the verdict reads a line. It reads every segment, not the key’s first, since one unmatched segment takes the whole line off the match path. Its view makes a single-quoted span and a backslash-escaped byte inert and keeps a double-quoted span live for expansion. A heredoc body is read the way the harness reads it, as probed: the body is data, the text after its terminator line is shell, and an unquoted-delimiter body still expands. So the expansion test blanks only a quoted-delimiter body, the redirect test blanks every body, and the log line is decoded first (§The shell guard, the fall-through line). The expansion test is the ranker’s own reading of the log rather than a reader view, since it decides a report section, not a call.

A key is reported unreachable only when every call under it is. Both limits lean the same way on purpose: under-retiring leaves an unactionable row on the actionable list, which is visible, while over-retiring hides friction a reader could have fixed, which is silent.

What the section claims is disposition (a) only. It reports the rows an allowlist entry cannot reach and makes no claim about a guard rule or a habit change. Both stay available for every row, and a guard rule reaching shapes no glob can express is that disposition’s reason to exist. The section is advisory on the log’s own terms (§The close-stage triage step) and reds nothing. It moves no call between prompting and granted and re-keys no row, so it is not a definitional step of the kind recorded below.

The grant test reads the verdict, so the two cannot disagree. A glob match on a raw segment has no model of a redirect or an expansion, and a trailing * absorbs both: Bash(echo *) would read echo x > notes.md and echo `date` as granted. So a call the verdict marks is never granted, by the committed file or the overlay, and always lands on the headline and in the unreachable section. The honest limit leans the verdict’s way: a harness may grant a redirect target through a file-write permission rule, which this arm does not read, so such a call reads prompting although it was granted. That over-count is visible on the ranking, where an under-count would be silent. A write rule append_scratch grants never reaches the log, so the common gitignored case is untouched.

The grant test drops each heredoc body and its terminator line from the segments it matches. A body is not a command, and a split inside it would read ; and | in a python body as separators. Three limits ride on the bound and the log line:

  • the analysis bound is the harness’s literal, so a revision that moves it is drift this arm cannot detect;
  • the cut counts characters the way the hook’s shell locale counts them, so under a byte locale a multi-byte command near the bound may read under it;
  • a line logged flattened before a consumer upgraded decodes wrongly where it carries a backslash, until the next close drains the log.

Every segment is read through the harness view before it is matched or keyed (§The reader and its views). A wrapper the permission matcher strips, timeout 30 git log, matches and keys as the command it wraps, while one the matcher does not strip keys as itself: sudo timeout 30 git log keys as sudo. The ranker and the matcher thus agree about what a wrapper covers, where a ranker’s own shorter wrapper list would count a sudo-prefixed call as granted and a time-prefixed one as prompting.

The write-shape suffix. The key appends the segment’s write-redirect operator when it carries one, so cat > <file> <<'EOF' ranks as cat > and cat >> <file> <<'EOF' as cat >>. The operator is normalized to > and >>, dropping which descriptor is redirected (2> f keys as > f): the row’s axis is this segment writes a file, and create-versus-append is the finest distinction a reader of the ranking has wanted. A descriptor-dup (2>&1) writes no file and does not qualify. The suffix is a second token, a shape the ranking already has (git status, python3 -). 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.

The word and the suffix come from the same segment, the first segment of the logged line, split by the compound split from a skeleton view. Pulling a redirect from anywhere in the line would key mkdir -p .tmp && cat > x as mkdir >, attributing a write to a command that performs none. The limit is coverage: a write behind a leading segment, as in that example, keys under the leading word and escapes the write class, and a sweep of a live log found two such writes under a leading mkdir. So the ranking under-counts the write class by however many such calls a corpus holds. Which segment to key when the friction-bearing one is not the first is a separate axis, not settled here.

No parser is minted for the redirect. The detection reuses the reader’s redirect pairs, the operator-and-target scan rule append_scratch performs, and excludes an fd-dup by rule append_scratch’s own target test. It does not use redirect_targets for that exclusion: that helper drops an fd-dup target only as an accident of its target class.

What a cat row means. A read-shaped cat <file> never reaches the log, because rule cat_file blocks it before the fall-through line is written. So a bare cat row is a real finding, a pipe-read or a multi-file read, which rule cat_file leaves as composition. Measured when the suffix landed, cat was the one word it split: awk and grep rows were reads, git was already subcommand-keyed, and rule sed_file blocks sed’s read and in-place forms upstream of the log. The axis is general and near-single-instance.

A granted call is never a log line. The fall-through line is written only after every rule has declined (§The generic ruleset, fall-through logging), and on a rule append_scratch match the rule’s allow ends the call. So a write to a gitignored target, create or append, is invisible here by construction. What still keys as echo > or cat > is a write both rule append_scratch and rule emitter_write decline: a shape clause (d) leaves unmodelled, a backgrounded launch, a line whose heredoc residue cannot be attributed, or a device target. Two consequences follow for a reader of this ranking:

  • How often does the shape rule append_scratch grants occur has no answer in this log: a zero here is zero by design, and sizing that population needs a different instrument.
  • A reading that drops after a grant widens has not measured fewer writes: widening a grant rule’s admitted set shrinks this log’s own corpus, so the next reading falls for a reason other than behaviour.

--count emits the compact <patterns>/<occurrences> prompting token, overlay-covered excluded so a counting reader reads true, and an explicit file argument overrides the log path. The two compose, in either order, because the argument parse is a loop over argv. A single-argument parse would make --count <path> return a real-looking number for the default log: silent, plausible, and reached for first by the measurement session that cannot afford it. The positional selects the rule’s input corpus rather than where configuration comes from, so it arrives as argv (gate-sdk/SPEC.md §The non-gate arm). Its callers are a session through the front-end and gate-tests/scan-prompts.test.sh, which keeps the mode named while kpi-prompt-friction calls the counter in-crate.

An unrecognized --prefixed argument is refused with usage on stderr at exit 2. A free-text positional absorbs whatever it is handed, so a one-character typo of the tool’s own flag would read as a log path and report a clean tree at exit 0. -- ends option processing, so a log path with a leading dash stays reachable. This is gate-sdk/SPEC.md §The bin/-tool contract’s shape half. Its -h/--help half does not apply: usage for a non-gate arm lives in the front-end’s help and in guard-kit/README.md, so --emit scan-prompts --help is a refusal.

What pins it. gate-tests/scan-prompts.test.sh reaches the arm through the front-end and pins:

  • the three-way split, the per-segment matching and the true count;
  • the argument override in both orders, and the argv-shape refusal with its -- escape;
  • the write-shape suffix’s create, append, fd-dup and first-segment cases;
  • the reachability sections and headline clause, and the grant test’s refusal of a call the verdict marks;
  • the heredoc reading and the over-bound shape on an encoded log;
  • a tab-marked PowerShell line ranked apart with no Bash count moving.

The key derivation, the verdict’s shapes and exclusions, the grant test’s reading of it on both passes, the log line’s decode, the body drop and the mixed-key rule are pinned in-crate as well. The key’s granularity beyond those cases is not a contract.

A definitional step is recorded here with its readings, and the KPI is not rebased. A change that re-keys rows or moves calls between granted and prompting steps drift-kit’s kpi-prompt-friction series without any behaviour changing, and its ^[0-9]+/[0-9]+$ contract stays unbroken, so nothing reds. A finer metric is the metric getting better, and rebasing it would trade a legible one-time discontinuity for a permanent lie about granularity. drift-kit reads trend, not level and has no annotation affordance, so this list is the annotation, each reading <patterns> across <occurrences> on one log immediately before and after:

  • The write-shape suffix split one key into two: 38 across 173, then 44 across the same 173.
  • The wrapper strip became the harness view, from a sudo, timeout list: 14 across 68, then 14 across 68; no logged line led with a wrapper either strip removes.
  • The grant test read the verdict, moving a write redirect or expansion a glob matched from granted to prompting: 20 across 69, then 23 across 77.
  • Heredocs read as the harness does, moving a heredoc-bearing call to granted and an over-bound call to prompting: 27 across 43, then 27 across 43, the log being flattened.
  • Allow rules and separators read as the harness does (the word-bounded :*, a sole closing space-led *, |& as one separator, the backgrounding & as an unreachable shape): 27 across 63, then 26 across 62, both binaries reading the same committed settings. The backgrounding shape alone moved nothing.
  • Rule git_c_root’s arm (d), a new block: 41 across 229 at its landing, two lines carrying the arm’s shape. A blocked call never reaches the log, so the series falls from there because the steer works, and an existing log reads unchanged.

Two changes are recorded as no step. Moving the measurement from a spawn-and-parse to an in-crate call left the keying untouched, read with both holders present. Ranking PowerShell lines apart moves no Bash line: 20 across 54 before and after.

check-door-binding

Invariant, three assertions over the tracked tree:

  • A — no kit-shipped surface outside the fail-open set names a front-end stub as a command to run, which holds every adopter-facing surface on the binary;
  • B — no kit template names a path to the gate binary, which holds the provenance seam A’s placeholder depends on;
  • C — over a consumer-configured surface set beyond the kit roots, no door sits on a site that has not declared itself contributor-facing, which reaches surfaces A cannot see, a consumer’s own README.md and documentation tree among them.

The corpora. A and B read every kit root’s README.md and its templates/, lib/ and bin/ trees, minus the fixture and harness corpora gate-tests/ and smoke/. B is scoped to templates/ alone, the only kit surface an adopter copies and fills. C reads GUARD_KIT_DOOR_ROOTS (§Layout and configuration), each entry a tracked file or a directory yielding its tracked members under A’s prune. It is empty by default, and then C is inert. Tracked rather than walked, because a filesystem walk of a documentation tree reaches the local build cache a site generator leaves there, no governed surface, whose copy of a page would red twice over.

A configured member whose name ends .rs is read as Rust source, for its output strings. A gate’s, a hook member’s or an emitter’s printed remedy reaches an adopter as surely as a page does. It is read up to its test module, the first #[cfg(test)] line followed by mod tests {, the cut the crate’s check-reads-couples reader makes, since a test literal is no output string. A line whose first non-blank characters are // is never a door site: a comment is not output, and the crate’s comments cite doors in prose. Every other line is read by the markdown reading’s predicate unchanged, so a string literal carrying bash gate-sdk/bin/run-gates.sh --emit x is a door because an interpreter word precedes it. Its honest limit is the continuation line. A literal ending in the stub name, its arm supplied through a {} argument, carries neither the interpreter word nor a following arm on its line. The reading catches a door only where the door and its arm share a line.

C is a third assertion rather than a widened A, because the corpora differ in kind. A’s kit-shipped surfaces are adopter-facing by construction and need no per-site declaration, which is why A reds on a bare door. A consumer’s own surfaces are mixed-audience by nature: a landing page’s headline try-it command and a contributor battery register live inside one file. One assertion over both would leave its red line ambiguous about which rule was breached and would move every consumer’s A verdict on upgrade. C is additive: with its corpus knob unset, no verdict changes.

C’s discriminator is default-deny plus a site declaration. The token is <!-- door-contributor: <reason> -->, or // door-contributor: <reason> in a Rust member. The reason is mandatory on the comment-tier-exempt: convention (canon-kit/SPEC.md §The shared spec adapters): an empty reason is malformed and reds rather than exempting. The reason is echoed into the red-line detail as an audit trail, [drain-exempt:]’s ground (lifecycle-kit/SPEC.md §check-stage-entry).

  • There is no door-adopter: twin. The unmarked case is adopter-facing, because declaring the complement on every other door would be a roster maintained against itself, as context-kit/SPEC.md §bin/env-probe’s audience axis rules for its own empty value. Default-deny closes the failure this assertion exists for: a new page’s door reds the day it lands, with no roster to remember to update.
  • No pre-build exemption. A door on a configured corpus reds even where the site states its own precondition, such as the binary not being built yet, because a stated precondition is prose and the discriminator is the token. The repairs are exactly two: name the arm on the binary the GATE_SDK_NATIVE_BIN knob resolves, or declare the site contributor-facing.

Two scopes, because a one-site-one-declaration grammar is unaffordable. Site scope is the declaration on the door’s own line or the line above, the shared exempt window this repo’s other valves ride. Span scope is a declaration standing alone on its line, covering every door to the end of its enclosing unit, resolved two ways:

  • the fenced block it immediately precedes, which covers a multi-line command register without HTML comments inside a bash fence;
  • the whole file, when it stands before the file’s first # heading, which lets one emitter edit cover a whole generated page.

A door inside a generated region takes its declaration from the emitter, never from the page, because the next regeneration erases a declaration written into emitted output. A Rust member takes site scope alone. It has no fence, and its #[…] attribute lines would read as the whole-file scope’s first # heading. A door inside a multi-line string literal is split into its own literal so the declaration can stand on the line above it.

Two alternatives are refused. A configured roster of adopter-facing sites is the trivially-true proxy gate-sdk/SPEC.md §When a gate earns its place bars: a page added tomorrow is absent from the roster and reds nowhere, manufacturing confidence exactly where the defect recurs. A gate-side path heuristic, a root surface adopter-facing and a docs tree contributor-facing, measures false on the file that matters, both audiences living inside one README.md.

C reuses A’s predicate rather than copying it. is_door, token_span and names_fail_open_arm are one implementation, so A and C cannot disagree about whether a line is a door. The fail-open arm exemption is corpus-wide and keys on the arm alone.

The door/file discriminator is what makes A decidable. A mention of run-gates.sh or run-gates.ps1 is a door when it is spawned, carried by an interpreter word (bash, sh, or PowerShell’s -File), or when an arm follows it. Every other mention names the program as its subject: `run-gates.sh` runs gates, not tests is a sentence about a file, and a GATE_SDK_PORTABILITY_PATHS entry is a file whose shell dialect is checked. A stub names itself, so a file whose own basename is a door spelling is not swept: the spelling is retired as an adopter-facing door, not as the file’s name.

A permission pattern is not an invocation. A door inside a Bash(…) grant wrapper tells a harness what to allow, not what to run. The recommended allowlist must name every door a session spawns, the binary and the front end a pre-build clone reaches (§The recommended allowlist), so an allowlist naming the front end is required. The wrapper shelters only what it encloses: a Bash(…) closed earlier on the line does not exempt an invocation after it.

The exemption keys on the arm, never on the file. The fail-open arms, --hook and --statusline, name the front end in the shell-form registrations that remain: a value the harness can run only through a shell, and the prose publishing one (gate-sdk/SPEC.md §run-gates). A door carrying one is exempt wherever it sits, a JSON command value included. The set is read from the crate’s FAIL_OPEN_ARMS declaration, the one check-front-end-fail-open holds both stubs to, so neither gate can disagree with the other about which arms those are.

B reads a path to the artifact, never its bare name. It can fail for a real reason, an author pasting their own scripts/checkwright-gates into a shipped template, and no other gate covers it. The bare name is the kit’s own vocabulary, and the basename comes from the crate’s package name, so the assertion and the binary cannot drift apart. An assertion that an install arm resolves every template substitution marker is refused. No arm writes an adopter’s settings and none is to be built (§The recommended allowlist), so it would red on every marker for a reason no build could fix.

Red names the file and the violating line for A, the template and the path literal for B, and the file, line and undeclared door for C. A malformed declaration is its own finding, so the red line says which rule was breached. It exits 2 on a corpus it cannot read and on a configured entry resolving to neither a tracked file nor a directory, never a false clean. The clean line prints all three counts, surfaces swept, templates among them and configured surfaces swept, so a corpus that shrank to nothing is visible on green. The third is a report, not an assertion: it has no floor and no exact-count arm, so a pruned corpus prints a smaller number and stays green.

compare-settings-allow

Advisory: the deterministic prune-candidate set for the close-stage audit, three questions over the local overlay. Read-only: it reports candidates and never mutates, and the operator prunes. It is the detector, not the policy, so a local entry it does not list can still be one-off junk worth pruning by judgment.

  • Redundancy — which local allow entries a glob in the committed settings already grants. A committed pattern subsumes a local entry when the local string matches it under shell-glob semantics, with the harness :* prefix idiom and a sole closing space-led * read word-bounded (§The reader and its views). The match core is guard::allow_match, shared with rule allowlist_chain.
  • Breadth — which local globs would auto-allow a command the consumer called bad, below.
  • Dead path — which local entries name a script absent from the tree, below.

The advisory is the --emit-compare-settings-allow arm-table member, both halves forced. It is a table member on the forced-family test, as --emit-scan-prompts is. Its four declared knobs, GUARD_KIT_SETTINGS, GUARD_KIT_SETTINGS_LOCAL, GUARD_KIT_BREADTH_PROBES and GUARD_KIT_BREADTH_DECLARED, are rows of guard-kit’s table (§Layout and configuration), and the knob-file derivation and the roster checks read a member’s declared roster. It is an Arm::Emit because every report path returns 0, the advisory never rendering a verdict, and the one non-zero path is the operand refusal at exit 2, so the {0, 2} collapse discards nothing. --count does not make it an Arm::Run: answering two integers on stdout is --emit-queue-index extent’s reading of the family, a query tool rather than a generator.

Two knobs are collections, read with existing mechanism. GUARD_KIT_BREADTH_PROBES is an indexed knob read as a list, and GUARD_KIT_BREADTH_DECLARED a keyed one read as a map. This member’s whole declaration contract is the key, so a value-only read would silence nothing and report nothing while passing every element-shape check.

The member spawns no external program. Both allow lists are read in-crate rather than through jq, so an unreadable allowlist never reads as an empty one on a machine that lacks a tool. Rule allowlist_chain’s allowlist read and --guard-json allow-entries (§Consumer rules) take the same reader, so no guard-kit reach an adopter runs needs jq.

The read distinguishes unreadable from absent. An absent file is zero entries. An unparseable or unreadable local overlay takes the no-overlay path, the header and no <path> — nothing to compare, or 0 0 under --count. An unparseable committed file is a refusal naming the path, exit 2: reading it as empty would print no redundant local entries over every local entry a committed glob may already grant.

An operand outside the closed set is refused, and its usage lives here. The one operand is --count. Anything else, --help included, prints usage: --emit compare-settings-allow [--count] on stderr at exit 2. An --emit- member has no front-end case arm, since --emit <name> composes its flag, so its usage lives at its own shape refusal (gate-sdk/SPEC.md §The bin/-tool contract). It is not a reader instance of that contract: its one operand comes from a closed set, so validating membership validates shape.

Breadth. 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 swapping its arguments to match the probe against the local glob, so the word-bounded :* reading is shared. The findings are distinct sets. The breadth report names the local glob and the one probe witnessing its breadth, and the operator disposes: narrow the glob, or record that the breadth is intended.

The disposition’s criterion is security, not tidiness. Breadth is kept wherever it is safe: an allow match short-circuits the permission classifier outright, so a broad rule saves model calls, and narrowing one costs something on every call it stops matching. Narrowing is warranted only where a destructive form sits inside a blanket grant: a hard reset, a working-tree clean, a force push, a bare checkout -- discarding local changes. A glob broad only in a harmless direction is declared intended, never trimmed.

Applying the disposition is the operator’s authority. The session the operator is present in applies it on the operator’s behalf, never the operator’s hand and never a delegated session. A finding here is neither queue work nor build work, and it is not left standing: the allowlist is how the friction the close stage measures is paid down (§The close-stage triage step), so a prepared diff nobody applies leaves that loop open. Impact gates the application: a high-impact edit waits for the operator’s explicit confirmation, and a low-impact edit is applied and then reported, never asked. The file sets the bar, because the two differ in reach:

  • low-impact in either file — an edit that only narrows: pruning a redundant or one-off entry, narrowing a glob, adding a deny or ask entry;
  • high-impact in either file — an edit that relaxes a guard: a removed deny or ask entry, a removed or weakened hook, an env override such as a raised pause threshold;
  • a widening — a new or broader allow entry — is high-impact in the committed settings file, which every clone reads. In the local overlay it is high-impact only when it reaches a write, execute, network or destructive form, and a local widening to a read-only command or a fixed literal is low-impact.

A delegated session may only prepare the edit, deriving the diff, stating its security grounds and recording both, and hand it up. The ground is that the work is not executable where a delegated session runs: a permission classifier denies the edit, and no operator is present to give the confirmation its impact may owe. The refusal that remains is permission laundering, a supervising session applying a delegated session’s diff without the confirmation its impact owes, which turns the permission boundary into a formality. Confirmation is the operator’s act, never the supervisor’s, and it does not bind the harness. A permission classifier may still deny a confirmed edit in the operator’s own session; the session then stops, names the denied edit and lets the operator grant it, never retrying a narrower spelling to slip past the denial. A delegated session that meets the refusal and stops has behaved correctly.

The probe set is probes rather than a roster (GUARD_KIT_BREADTH_PROBES, §Layout and configuration, consumer config). 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 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 because a bare list re-loses the reason. It lives in the committed config the consumer tracks, so the ruling survives in a reviewable surface rather than a session’s memory or a commit message. That fixes where a declaration lives, not what the breadth question reads, which stays the local overlay. An over-broad local glob re-reports 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. Routing it through guard::allow_match is refused: one declaration could then silence globs the operator’s ruling never named, and a ruling about intended breadth is a ruling about one glob. So narrowing or re-spelling a declared glob drops its declaration and the entry re-reports, which 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 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, where no over-broad local entries would be false. With the map empty, the default, the partition is trivial and the declared subsection is omitted. The asymmetry with GUARD_KIT_BREADTH_PROBES is deliberate: an empty probe set omits the whole breadth section because silence there could read as coverage, while an empty declaration set omits only the declared subsection, where 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 report is refused, because it would print every declaration naming a committed glob as stale. A committed glob is never in this report, so a declaration naming one is a durable record the report does not read, the shape a re-derivation of the committed allowlist produces when it rules a glob safe and keeps it broad. The knob does not make a gate of the tool: the placement ruling below turns on exactly the operator intent it records.

This is the criterion’s home rather than a gate, by ruling. The subject is a gitignored per-machine file CI cannot see, and the verdict depends on operator intent (a blanket grant is fine in a throwaway sandbox). That 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.

That ruling is about this subject, not about allow-list checking generally. Both clauses fail on the committed file: it is tracked, so CI sees it, and “does this path exist” is a filesystem fact no operator intent can move. A committed-allow-list predicate of that shape is therefore gated, by the kit that owns the committed settings file as a gate subject: context-kit/SPEC.md §check-settings-paths. This kit holds the three .permissions.allow[] readers in the tree and ships no gate for them; co-location by parsing technique does not outrank placement by governed surface.

Dead path: which overlay entries grant a script that is gone. A grant naming a deleted script breaks nothing, but every reader of the overlay must re-verify which lines still mean anything, and a dead grant beside a renamed tool reads as coverage. The report lists each local entry whose command token is a literal .sh path that does not resolve, as <entry> — no such file: <path>, in a dead-path section between the redundancy and breadth sections. Its clean line carries the checked count, which tells a predicate scoped to the array from one that matched nothing.

  • The token is context-kit/SPEC.md §check-settings-paths’ extraction predicate, called rather than copied, since the scoping is ruled there and a second extraction would drift.
  • Resolution differs. An absolute token resolves as written, because a per-machine grant is routinely absolute; any other resolves against the repository root, where the front-end runs.
  • It is advisory here for the reason the placement ruling gives about the overlay, though that section gates the same subject for the committed file.
  • It needs no vocabulary, existence being a filesystem fact, so it has no probe knob and no opt-in. The no-overlay path prints no dead-path section, and the member’s spawned-program set stays empty.

--count emits two bare counts on one line, redundancy first and breadth second. The breadth number counts the narrowing candidates, not every over-broad entry, because the count answers how much is outstanding and a declared entry is not outstanding. A third number, for the declared or the dead-path set, is refused: no reader needs it, and a consumer may already parse the two-number line.

wakeup-guard

Optional second guard, same --hook dispatch, opposite posture: it 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 drained and reviewed in the same close-stage triage pass as the friction log. Deliberate scheduling stays possible by disabling the hook for a session, since the block is the default, not a capability removal.

Each attempt line is a local-zone stamp in GNU date -Is’s shape, YYYY-MM-DDTHH:MM:SS±HH:MM, then the payload’s session_id, tool_name and tool_input as one JSON object in that key order, null for a field the payload lacks. On unix the binary builds the stamp in-process from the offset the C library reports for the instant. Off unix it spawns date -Is, which rides the userland bash requires (gate-sdk/SPEC.md §The program roster).

The attempt log is a capture-tier surface on the friction log’s terms: gitignored, advisory, reclaimed by the triage pass. It declares itself so whether or not the consumer wires the hook, because the tier has to hold the file the first time the guard fires:

close-surface: .workflow/wakeup-attempts.log advisory reclaim=bash gate-sdk/bin/run-gates.sh –emit capture-drain .workflow/wakeup-attempts.log

escalation-guard

escalation-guard is the wakeup-guard’s sibling, with the same --hook dispatch, a tool-targeted matcher and a separate opt-in hook, but advisory, not fail-closed, and with 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, which a background stage session reaches as main. It 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, and 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. Opt-in is the consumer’s settings registration, the wakeup-guard’s valve; absent it the arm goes undispatched, the intended default. No gate observes the wiring: check-settings-pins and check-memory-off stay green either way, 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 source. It sources no config and writes no log, 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, filling the housekeeping slot of lifecycle-kit’s close template. The step, in order:

  1. Drain the friction log, run scan-prompts over the drain, and resolve each recurring pattern by the triage criterion.
  2. Drain and review the wakeup log, if present.
  3. Run compare-settings-allow and take its three dispositions: prune the listed redundant local entries, and prune the listed dead-path ones. For each narrowing candidate the breadth report names, narrow the glob or record its breadth as intended in a GUARD_KIT_BREADTH_DECLARED entry in the committed config. That moves it to the report’s declared section and stops it re-reporting.
  4. By judgment, prune the remaining one-off exact-string local entries and promote recurring safe patterns to the committed settings as globs.
  5. Remove the drained friction log.

Pruning holds two judgments the reports cannot make. An entry naming a script path rather than a fixed command is not content-pinned, since it grants whatever the file says at run time; its sanctioned form is the --scratch-run arm (§scratch-run), whose echo is the compensating control. And widening the committed set is the consumer’s call: a session does not widen its own auto-allow set on its own say-so. The goal is a small local set, every durable pattern in the committed, reviewable allowlist, and no local glob auto-allowing 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, since 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. The ranking’s allowlist-unreachable section is advisory on those same terms and adds no obligation:

close-surface: .workflow/prompt-friction.log advisory reclaim=bash gate-sdk/bin/run-gates.sh –emit capture-drain .workflow/prompt-friction.log

Layout and configuration

guard-kit/
  guard-tests/cases.tsv     # expected-decision <TAB> command
  guard-tests/escalation-cases.tsv  # expected-decision <TAB> to <TAB> message; read by the escalation guard's crate test
  guard-tests/background-cases.tsv  # expected-decision <TAB> run_in_background <TAB> command
  guard-tests/powershell-cases.tsv  # expected-decision <TAB> command, each fed as a PowerShell payload
  guard-tests/powershell-background-cases.tsv  # expected-decision <TAB> run_in_background <TAB> command, each fed as a PowerShell payload
  gate-tests/scratch-run.test.sh    # the --scratch-run arm's SEAM cases, run by gate-sdk's runner
  gate-tests/rewrite.test.sh  # the --rewrite arm's operand refusals and write, 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
  gate-tests/git-mutation-under-producer.test.sh  # bespoke unit test, run by gate-sdk's runner
  gate-tests/consumer-rules.test.sh  # the consumer stage's protocol, --guard-json's seam cases and the consumer lane's
  gate-tests/door-binding-corpus.test.sh  # assertion C's two fail-closed exits, which a one-pair harness cannot spell
  gate-tests/guard-config-knobs.test.sh  # the knob values a decision-table row cannot vary, each under a sandbox GUARD_KIT_KNOB_FILE, and the knob refusals' block
  gate-tests/worktree-confinement.test.sh  # rules `git_mutation_under_producer`, `background_no_record`, `bounded_wait`'s arm (B), `script_interpreter` and `worktree_confinement` (its read-only arm admission included), and the fall-through log's home, from a scratch linked worktree, the working tree a decision-table row cannot vary
  templates/guard-config.knobs
  templates/settings-hooks.json  # the PreToolUse wiring snippet
  templates/settings-allow.json  # the recommended allow entries, merged by hand
  templates/close-triage.md
  smoke/install.sh
  smoke/violation.sh

The shell guard itself is compiled: the member is native/src/hook/shell_guard.rs and its engine, readers and rules are native/src/guard/ (§The shell guard), shipped in the gate binary rather than in this kit’s tree.

Config is a knob file: copy templates/guard-config.knobs into the gates dir as guard-config.knobs (or point GUARD_KIT_KNOB_FILE elsewhere) and set any knob below; defaults fill what the file leaves unset. guard-kit’s knobs are static: the binary resolves them in process from its own defaults table and the consumer’s knob file; bash gate-sdk/bin/run-gates.sh --emit knob-roster prints each one with its shape and rendered default. A gitignored guard-config.local.knobs in the gates dir is the home for a private value a tracked file cannot carry. The grammar, that .local overlay, the environment-over-file precedence for a scalar, and the refusals — a set GUARD_KIT_KNOB_FILE that does not exist, a left-behind guard-config.sh or guard-config.local.sh, a non-empty file named by the retired GUARD_KIT_CONFIG_FILE — are gate-sdk/SPEC.md §The knob file’s. The shell guard resolves the values in process, and a refusal reaches the hook as a block carrying its text (§The shell guard). A derived default below names the knob it reads as ${NAME}, or as ${NAME:-<default>} so the roster’s rendered literal reads as agreement.

Three knobs hold the consumer’s own vocabulary: GUARD_KIT_BREADTH_PROBES, GUARD_KIT_DOOR_ROOTS and GUARD_KIT_BREADTH_DECLARED. The kit ships no default entry for any of them, because which command is bad, which surface is governed and which breadth is intended are the consumer’s facts, never the kit’s (gate-sdk/SPEC.md §The provenance seam). Each defaults empty, and empty turns its feature off. Knobs, with this repo’s layout as defaults:

  • GUARD_KIT_CONSUMER_RULES_CMD — the consumer’s rule command (§Consumer rules), a command knob: an indexed argv spawned directly, with no shell (gate-sdk/SPEC.md §The knob file); default empty, which runs no consumer stage.
  • GUARD_KIT_CONSUMER_CASES — the consumer’s own decision table (§Testing, the consumer lane), a repository-relative path in cases.tsv’s grammar; default empty, which runs no consumer lane. The kit ships no table, because the rows test the consumer’s rules.
  • GUARD_KIT_LOG — derived default ${GATE_SDK_WORKFLOW_DIR:-.workflow}/prompt-friction.log.
  • GUARD_KIT_WAKEUP_LOG — derived 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); empty omits the breadth report. Entries are full permission-rule strings rather than bare commands, matching the settings vocabulary the tool reads on both sides, so a consumer can probe non-Bash rules with the same mechanism.
  • GUARD_KIT_DOOR_ROOTS — array of repo-relative paths, each a tracked file or a directory, naming the surfaces beyond the kit roots that check-door-binding’s assertion C sweeps; empty leaves C inert. A directory entry yields its tracked members under assertion A’s prune set. A consumer therefore adds a governed tree once, and every tracked page later added under it is swept with no further edit, the derivation-first half of C’s default-deny. A local build artifact under that tree is no governed surface and is never swept. A member whose name ends .rs is read as Rust source (§check-door-binding). An entry resolving to neither a tracked file nor a directory is exit 2, the posture every sibling here takes.
  • GUARD_KIT_BREADTH_DECLARED — keyed knob recording the breadths ruled intended (§compare-settings-allow), one GUARD_KIT_BREADTH_DECLARED[<rule>] = <reason> line each. The key is a permission-rule string and the value the reason that breadth was ruled intended; empty omits the declared subsection. Keyed rather than a delimited indexed knob, because a permission rule may contain any character a command may: every separator an indexed knob would need (|, ::, a tab) is one a legitimate rule can carry. A key holds the rule verbatim, bounded only by the line grammar’s key rule (no ], = or tab, which a live permission rule does not carry), and is unique by construction, so no which reason wins question arises. The tool walks the settings file’s own allow list and looks each entry up, so report order never depends on the map’s iteration order.
  • GUARD_KIT_RO_SCRIPTS — array of globs eligible for the absolute→relative rewrite (rule abs_script); default ("check-*.sh").
  • GUARD_KIT_RO_BINS — read-only pipeline roster (rule ro_pipeline); default the grep/head/cat/find/jq family, plus xargs, sed and awk, whose membership is qualified by a discriminator of rule ro_pipeline’s rather than granting on the leads-with test alone. Every member also takes a declaration of its write and execute forms, from GUARD_KIT_RO_FORMS or the kit’s table, and an undeclared member is withheld.
  • GUARD_KIT_RO_FORMS — keyed knob mapping a roster binary to its declaration of write and execute forms, in rule ro_pipeline’s grammar, one GUARD_KIT_RO_FORMS[<binary>] = <declaration> line each; default empty, which leaves the kit’s table declaring the default roster and nothing else. Its reader is rule ro_pipeline’s declared-forms reader, shared as that item states, plus rule find_glob’s listing test for find, one lookup per roster-led segment. A consumer adding a member to GUARD_KIT_RO_BINS declares it here or the member is withheld; an entry for a default member replaces the kit’s declaration rather than adding to it. Keyed on GUARD_KIT_BREADTH_DECLARED’s ground: the key is a binary name held verbatim and unique by construction.
  • GUARD_KIT_APPEND_BINS — the emitter roster of rule append_scratch’s write grant, which covers the create case as well as the append the name was minted for; default (cat printf echo). A knob, so a consumer whose mandated write rides a different emitter shadows the array instead of forking the rule; three POSIX utility names are shipped mechanism, so defaulting them crosses no provenance seam. The roster’s contract is writes only to its stdout: a member that writes anywhere else widens the grant past rule append_scratch’s arm (c) safety argument, a hazard stated here because this roster’s members bound a write.
  • GUARD_KIT_WRITE_BINS — the write utilities rule bounded_write admits; default (mkdir touch rm rmdir mv cp tee find). A member takes the kit’s operand grammar for its name, and one the grammar does not carry is withheld, so the roster narrows the grant and never widens it past the crate’s table. Empty turns the write-utility kind off and leaves the redirect kind standing. Eight POSIX utility names are shipped mechanism, so defaulting them crosses no seam, on GUARD_KIT_APPEND_BINS’ ground.
  • GUARD_KIT_SEARCH_TOOLS — the dedicated search tools the consumer’s harness build carries; default (Glob Grep). Its readers are rule find_glob’s firing test, which reads Glob, and rule git_grep’s, which reads Grep, one read each per call; with a member absent its rule is inert. A knob, because the guard cannot see the toolset: the PreToolUse payload carries none and agent_type does not determine one, so a build lacking those tools is a property of the harness installation and its value consumer config. The default may name the tools: a harness tool name is public and shared by every consumer of that harness, a cost to portability rather than privacy (§The generic ruleset). A build that carries the tools in one session class and not another makes any single value wrong in one of them, so the toolset is probed in each session class the guard runs under before the value is set.
  • GUARD_KIT_SCRATCH_DIRS — gitignored scratch dirs named in rule scratch_redirect’s corrective message, and the scope of rules git_mutation_under_producer, background_no_record, bounded_wait’s arm (B), bounded_write, script_interpreter and worktree_confinement; default (".tmp"). From a linked worktree a relative member resolves against the main checkout for rules git_mutation_under_producer, background_no_record, bounded_wait’s arm (B) and worktree_confinement, because that is where delegation-kit homes a liveness record and a journal (delegation-kit/templates/agent-execution.md), on the owning-checkout pattern rule worktree_confinement’s ignored-target test takes. Rule git_mutation_under_producer reads the main checkout’s records, rule background_no_record counts a record written there and names that home in its corrective, and arm (B) grants the canonical spelling carrying it, each compared lexically with .. folded. The worktree’s own copy of a member is then no scratch home for those rules, so a record written there is refused rather than steered to a place the next reader never checks. An absolute member is read as written everywhere. Rules scratch_redirect and bounded_write read the member as written, and rule script_interpreter reads it both ways from a worktree.
  • GUARD_KIT_SCRIPT_INTERPRETERS — the non-bash interpreters rule script_interpreter’s arm (b) covers; default (python python3 node deno ruby perl php zsh). A knob, so a missing member is a config edit rather than a kit release. Its members are universal interpreter binaries, none of the classes gate-sdk/SPEC.md §The provenance seam names, so it ships with defaults where GUARD_KIT_BREADTH_PROBES, whose members are private, cannot. It has exactly two readers by design: arm (b)’s trigger, and rule worktree_confinement’s program-bearing exclusion, which reads the same fact — a member’s program lives in text no declaration can describe. The --scratch-run arm deliberately does not read it and does not declare it (§scratch-run), so the knob never becomes a second copy of a fact the target file itself states.
  • GUARD_KIT_SCRATCH_POWERSHELL — the PowerShell host the --scratch-run arm hands a .ps1 body to, and the switch for rule script_interpreter’s PowerShell steer; default empty, which is off. A value is a program named pwsh or powershell, bare or as a path, read as PATH names it (the final component less the host’s executable suffix), and the validator refuses any other, which blocks every guarded call and refuses the runner. Its readers are the arm (§scratch-run) and the rule (§The rule roster). A scalar selector on GUARD_KIT_WORKTREE_READS’ ground: whether the runner’s grant reaches PowerShell bodies is a calibration, and off leaves the command-line block standing.
  • GUARD_KIT_WORKTREE_READS — rule worktree_confinement’s admission selector, read-only or off; default read-only, on the measured ground that a harness build may carry no search tool, so with the admission off a search of an untracked main-checkout corpus from a linked worktree has no route. off admits no shell read of the main checkout and leaves the harness’s read tool as the one route to a named file; the journal append is unaffected. The kit’s validator refuses any other value. A selector rather than a fixed behaviour because whether shell reads of the main checkout are admitted at all is a calibration, and its off leaves the write half standing (doctrine-kit/DOCTRINE.md, Policy-as-choice).

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 and hook JSON, not OK:/FAIL: lines. The kit therefore ships its own decision-table runner instead of gate-tests/. guard-tests/cases.tsv pairs an expected decision (block, advise, allow, rewrite or fallthrough) with a command. The --run-guard-tests arm feeds each through the shell 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@ a newline, without which no heredoc case could be written. The runner has two lanes: the kit’s tables against the member, and the consumer lane below.

The member is an Arm::Run and an arm-table row, both forced. The contract is a three-valued exit: 0 clean, 1 one or more verdict mismatches, 2 a harness precondition it could not meet. The evidence-kit suite reads the verdict, not the report, and an Arm::Emit collapses it to 0-or-2. The arm is a table member rather than a hardcoded top-level flag because it is configured: it needs where guard-kit is vendored, gate-sdk’s GATE_SDK_KIT_DIRS (gate-sdk/SPEC.md §The non-gate arm, the family is forced for any tool that needs configuration). bin/run-gates.sh hands every unrecognised leading --<token> to exec_arm, so the front-end needs no edit. In a tree that does not vendor guard-kit the arm is unresolvable, as --emit-scan-prompts is: a refusal with a message at exit 2, not a defect.

The declared roster is GATE_SDK_KIT_DIRS, GUARD_KIT_CONSUMER_RULES_CMD and GUARD_KIT_CONSUMER_CASES. The arm reads the two consumer knobs itself, since the consumer lane spawns the consumer’s command without the member. GUARD_KIT_LOG is not declared because the arm overrides it with a path inside its own sandbox: declaring it would resolve a consumer’s live friction log into a synthetic run, the pollution the ad-hoc probe rule below prevents. The guard’s other GUARD_KIT_* knobs are not declared because the spawned member resolves every one itself (§The shell guard). The arm instead exports the running binary’s absolute path as GATE_SDK_NATIVE_BIN into each case. The repo-relative default names nothing from inside the sandbox, and the member reads that knob for its steer door and rule git_c_root’s arm (d).

The subject is in-crate, and the arm stays an arm. Each case spawns the running binary as <binary> --hook shell-guard, directly and with no front end. Its environment carries GUARD_KIT_LOG and GATE_SDK_NATIVE_BIN, its working directory is the sandbox, and the hook payload is on its stdin. The consumer’s knob file is not in the sandbox, so no consumer stage runs, which is what the kit’s table needs. The harness:

  • The payload is built in-crate with serde_json, and each of its three fields has a named reader. tool_name selects the reader: Bash on every row but the fourth and fifth tables’, PowerShell on those. Every generic rule reads tool_input.command, and rule background_no_record’s harness arm reads tool_input.run_in_background.
  • The two substitutions apply to the command cell before the payload is built, in a fixed order: @ROOT@ then @NL@.
  • The table grammar is tab-separated. A row whose first field is empty, whitespace or opens # is skipped, and the last field takes the line’s remainder as IFS=$'\t' read -r leaves it. The tables stay on disk rather than Rust literals, on the escalation table’s ground below.
  • The classification is a five-way ladder in exit-code-then-substring order: exit 2 → block; any other non-zero → exit<rc>; "updatedInput" present → rewrite; "additionalContext" present → advise; "permissionDecision":"allow" present → allow; empty output → fallthrough; else unknown. The order is load-bearing: a rewrite payload may also carry an additionalContext, and the ladder makes the first match win.
  • The sandbox is a fresh directory under the host’s temporary directory, made in-crate. It is held in one spelling, the crate’s own, for the filesystem, git, the case’s working directory, its GUARD_KIT_LOG and every @ROOT@ alike. The member reads the path the sandbox was built at, so no shell re-spells it on any host (gate-sdk/SPEC.md §The path-dialect contract). A sandbox the arm could not build is exit 2, the harness-precondition code, never a cascade of verdict mismatches naming the wrong cause.

The sandbox’s five preconditions are each a case’s precondition rather than scenery, so a harness that builds four of them turns real rows green for the wrong reason:

  1. git init.
  2. The three-line .gitignore.
  3. A tracked tracked.md and a tracked two words.md beside an untracked scratch.txt, which rule rm_tracked splits on, the blank-bearing name through a quoted or an escaped operand.
  4. A .tmp/dead-producer.run carrying a dead PID beside a .tmp/notes.txt that is not a record, so a mutating-git row that falls through asserts rule git_mutation_under_producer’s decline arm rather than the vacuous absence of any record. Rule git_stash’s block rows are mutating-git rows a later rule answers.
  5. A .claude/settings.json carrying the nine-entry allowlist:
    • three bare or :* entries, for rules ro_pipeline and allowlist_chain;
    • rule abs_script’s two script globs, so its rewrite rows keep asserting the grant;
    • a scratch rm -rf .tmp/* slot grant and a */checks/check-*.sh runner glob, for rule grant_path_slot’s rows to bound;
    • a find .tmp/* -exec cat {} + pattern, for rule brace_glyph’s granted and unbounded placeholder rows;
    • a git rm -q * grant, so rule bounded_wait’s arm (B) has a granted forced git rm for its rule rm_tracked predicate row to refuse.

Rule ro_pipeline’s allowlisted-lead widening and rule allowlist_chain read the same file, so their rows are re-derived whenever it grows.

The consumer lane runs a consumer’s own rows against its own rule command, after the kit’s tables and only where GUARD_KIT_CONSUMER_CASES names a table:

  • Each row is fed as a Bash payload to the argv GUARD_KIT_CONSUMER_RULES_CMD names, spawned directly with no shell, as the member spawns it (§Consumer rules). A consumer rule written for PowerShell is therefore not reached.
  • The working directory is the arm’s own, the repository root through the front end or a root invocation, since a consumer’s command is written for its repository. @ROOT@ becomes that root, and @NL@ a newline.
  • The environment is inherited, with GATE_SDK_NATIVE_BIN set to the running binary’s absolute path.
  • The verdict is read on the same ladder, so a fault is exit<rc> or unknown, a mismatch whatever the row expected. A mismatch prints in the kit lane’s form, labelled with the command, and either lane’s mismatch makes the exit 1.
  • A table set with no rule command, or naming no readable file, is exit 2.
  • The summary line adds the lane’s row count and table, and names none where the knob is empty.

The subject is the consumer’s command and not the member, which asserts exactly what the consumer owns: its decision, or its silence. Two alternatives are refused:

  • Running the member under the consumer’s knob file would make a non-firing row assert the generic ruleset’s verdict at a live root. That verdict is the tree’s, read off its liveness records, tracked set and settings file, and a row cannot pin it. The member’s placement of the consumer stage is consumer-rules.test.sh’s subject instead.
  • A second table grammar with a column for the expected generic verdict would couple every consumer’s table to the kit’s rules, so a kit release could red a consumer’s lane for a rule the consumer never wrote.

A row writes a path relative unless the case is about an absolute one. The runner drives each case from inside the sandbox, and rule abs_prefix blocks any command carrying the repo-root prefix. An @ROOT@-absolute target therefore never reaches the rule under test, and the miss reads as a rule defect rather than a spelling one. @ROOT@ is for the cases whose subject is the absolute form, rules git_c_root, abs_script and abs_prefix, and for a foreign-repo contrast.

Any ad-hoc invocation of the shell guard must set GUARD_KIT_LOG to a scratch path. The logger’s default is the live friction log, so a probe driving the guard in a loop files its synthetic commands as real friction and skews the next close’s ranking. The kit’s own runners all set it: the decision table into its sandbox, the scan-prompts fixture into its own. 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. The convention is carried by this sentence and by the --scratch-run arm’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. It fails on a verdict mismatch in either direction, so a change that narrows refusal cannot be cleared by inspection. 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 has its expected column re-derived. A new blocking rule likewise re-derives every row its trigger reaches, as rule background_no_record did every row carrying a trailing &. The scan-prompts arm cannot substitute: a block exits 2 before the fall-through line is written, so that report is blind to every block the guard makes. A change converting a block into a grant reads on the scan as a pure improvement, or as nothing at all.

A second table, guard-tests/escalation-cases.tsv (<decision> <TAB> <to> <TAB> <message>), holds the fixture-pair discipline for the escalation advisory: a headerless message to main advises, while a fully shaped block and a non-main recipient fall through. Its driver is the crate test under the escalation guard’s own module, not this runner: that guard is a binary arm, and --run-guard-tests covers the shell guard alone. The table stays on disk rather than becoming Rust literals, because it is kit test data a reviewer reads and a literal would trade that review for a recompile.

The decision table is not moved into a crate test, and the discriminator is reach. A crate test runs only under cargo test, which needs a Rust toolchain. The shipped binary needs none: gate-sdk/SPEC.md §Consumer payload ships a prebuilt artifact per declared target so an adopter with no toolchain has a working battery. The move would narrow who can run the decision table from everyone with the binary to everyone with cargo, a reach narrowing nothing here licenses. Both subjects are compiled. No adopter runs the escalation lane to decide anything, so a crate test alone reads it; this table is the oracle for what the shell guard refuses, so it stays runnable by everyone holding the binary.

A third table, guard-tests/background-cases.tsv (<decision> <TAB> <run_in_background> <TAB> <command>), holds rule background_no_record’s harness arm. It is forced: that arm’s input is a tool parameter, which cases.tsv’s decision-and-command grammar cannot write, so the arm would otherwise ship untested. The runner feeds a Bash-shaped payload carrying the flag. Its rows, each a firing/non-firing pair, are harness-form launches unless stated:

  • in the background with no record → block;
  • in the background writing a .run record → fallthrough;
  • a wait loop in the background → the grant rule bounded_wait’s arm (A) earns it, alone or followed by a banner or a read;
  • a loop that works, under either keyword, a nest, and a body sleep redirecting to a named file → block;
  • a producer or a declared write form ahead of the wait or after it, behind ; or the loop’s own &, and a lead hidden behind a keyword spelled as an argument → block;
  • a condition carrying a write its text shows, and a statement other than a loop ending in a bare & → block;
  • a for loop that only sleeps, a body sleep with an inert redirect, a banner or a read ahead of the wait, and a read behind the loop’s own & → fallthrough, the exemption’s stated reach;
  • a loop whose condition is a command outside the read-only roster, alone or piped into a grep -q → fallthrough, its stated limit;
  • a read-only pipeline in the background → the auto-allow it already earns;
  • a canonical recorded launch of an allowlisted command → rule bounded_wait’s arm (B) grant, the flag changing nothing in its text;
  • a foreground call carrying neither → fallthrough, proving the rule inert off the backgrounding path.

The same table holds rule wait_no_producer’s harness-form rows, over the sandbox’s one dead record, so clause (d) holds on each:

  • a backgrounded file-test wait → block, in either polarity and over tests joined by &&;
  • the same wait in the foreground → rule bounded_wait’s grant;
  • a second loop span → fallthrough;
  • a body running something besides sleep → fallthrough where the loop is launched with its record, in the spelling rule background_no_record’s block prints, since unrecorded it takes that block one rule earlier;
  • a kill -0 condition, and a test joined to a grep -q → rule bounded_wait’s grant.

The shell-& arm is a command, so its firing and non-firing rows stay in cases.tsv, beside arm (B)’s grant rows and its refusing rows, and rule wait_no_producer’s shell-form block sits with them. A wait closed by its own & falls through there, alone or behind a banner, and a work loop so closed, a producer after that & or a producer ahead of the wait blocks.

A fourth table, guard-tests/powershell-cases.tsv, takes cases.tsv’s grammar and substitutions, differing only in the payload’s tool_name, and drives the same member. The arm feeds every row as a PowerShell payload, so the table runs wherever the arm does, crate-tests-windows’ x64 entry included, and the member needs no pwsh to decide. Its rows hold each applied rule firing and not firing, and the Bash-only rules staying inert on a PowerShell call. Rule background_no_record’s shell form sits there too: a detached Start-Process and its aliases block, while -Wait by a prefix, -WhatIf and a Start-Job do not fire. The reader’s grammar rows are each a command an applied rule would block if the reader misread it:

  • a doubled quote inside a single-quoted span, and a backtick-escaped quote inside an expandable one;
  • a verbatim here-string piped into git commit -F -, which advises on rule git_rewrite and nothing else;
  • a line and a block comment, and an escaped separator;
  • the two-character separators, which block.

A fifth table, guard-tests/powershell-background-cases.tsv, takes the third table’s grammar, differing only in the payload’s tool_name. It holds rule background_no_record’s PowerShell harness arm, whose input is the tool parameter the fourth table’s grammar cannot write. Its rows:

  • an unrecorded launch blocks;
  • the canonical record write around it falls through, spelled with the full parameters and with the shortest prefix and positional words;
  • the same write blocks without -NoNewline, without the trailing `n, with an -Encoding, as a > redirect, to a .run path outside the scratch dir, or only quoted in an argument;
  • a write whose path carries a $ falls through on the decline, while a $ elsewhere does not decline;
  • the three inline wait-loop spellings fall through, and a loop body doing more than sleep, or a loop followed by a second statement, blocks;
  • a foreground call falls through, proving the rule inert off the backgrounding path.

Rules emitter_write and shell_wrapper have rows of their own. For rule emitter_write:

  • a journal append followed by a git line, and the same append joined by &&, block, while the append alone allows;
  • cat >> tracked.md alone blocks;
  • a lead off the emitter roster, a device target and a backgrounded launch writing its record do not fire;
  • arm (c): a compounded write to a tracked target, a heredoc create under a new directory and a target outside the repository block, and a compounded device target does not fire.

Arm (c)’s outside-repository and device-target compounds lead with make build rather than git status. The sandbox allowlists git status, so a compound led by it meets rule allowlist_chain’s block first, and a block row would pass for the wrong reason. Rule cat_file’s redirect-composition row writes a gitignored target, so it proves rule cat_file inert rather than reading rule emitter_write’s block. For rule shell_wrapper, bash -c and sh -c block, alone and behind another statement. xargs bash -c, timeout 5 bash -c and the scratch runner taking a script path do not fire, the first two pinning the stated boundary. A row a new rule shares with an older block is re-pointed where the older rule answers first, which only a probe of the message tells apart.

The shell guard’s price is measured on a git status payload over ten calls each: 8 ms per call through the front end with no consumer stage, and 1 ms for the member alone. With this repo’s consumer command it is 13 to 14 ms through the front end and 7 ms for the member alone, the consumer command’s own spawn.

smoke/install.sh installs into the scratch consumer: the config template into the gates dir, hook wiring merged into the settings file at its default path, log paths gitignored. The install then verifies itself:

  • It drives one crafted payload through the wired member, --hook shell-guard on the gate binary, asserting a block carrying rule cd_compound’s steer, since a knob read the binary refuses blocks too.
  • It asserts the merged wiring’s Bash|PowerShell matcher group and drives a PowerShell payload, Set-Location deploy; Get-ChildItem, through the same member, asserting the same rule’s block.
  • It resolves the hook registrations’ placeholder at the merge by the hook template’s rule (§The shell guard) and fails on one that survives.
  • It selects that group’s shell-guard registration by its args and spawns its command with them, no shell between, from a subdirectory it creates, the project-directory variable substituted with the consumer root, and asserts the same block. The other assertions drive the member and this one drives the registration.
  • From the same subdirectory it spawns the registration against a malformed knob file at the consumer’s root and asserts the knob refusal. A member reading its tree from the hook’s working directory misses the file there and allows the call, so this is the oracle for the arm’s root step (gate-sdk/SPEC.md §The harness-integration arm).
  • The merge asserts that every hook event a co-vendored kit wired before it survives. An overwrite would silently drop that kit’s wiring from the composed consumer, and nothing downstream reads the final file.
  • It unions templates/settings-allow.json’s entries into the same file beside a sentinel entry written first, asserting the sentinel survives. It then feeds each template entry’s literal form through the member, Bash( and ) stripped and each * replaced by the word x, failing on any block. A rule later blocking a form the recommendation grants would otherwise ship that steer/grant contradiction to every adopter (§The recommended allowlist).

smoke/violation.sh plants one check-door-binding violation: a line naming the front end as a command to run, appended to the vendored kit’s README.md, which assertion A reads under the shipped defaults. Assertions B and C are held by the gate’s fixture pair.

gate-tests/compare-settings-allow.test.sh holds compare-settings-allow’s breadth criterion in the bespoke lane, a report no decision row can express. It drives the arm through the battery runner’s --emit front-end, with GUARD_KIT_KNOB_FILE pointed at a sandbox knob file and GUARD_KIT_SETTINGS/GUARD_KIT_SETTINGS_LOCAL set in the environment, so the consumer’s own probe array cannot leak in. Its cases:

  • a firing probe, a blanket local glob reported with its witness, and a non-firing one, a narrow entry reporting clean;
  • the empty-knob silence, proving the section omitted rather than printed clean, and with GUARD_KIT_BREADTH_DECLARED empty the declared subsection absent too;
  • 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;
  • exactness: a declaration differing from the local entry by one character leaves that entry in the narrowing set, so the lookup never became a glob match;
  • dead paths: an overlay granting the front-end and a script that does not exist lists the second and not the first, while the unchanged --count assertions pin that the dead-path set never entered the two-integer line.

The in-crate test pins the dead-path set’s exact membership and checked count across a live, a dead relative and a dead absolute literal beside a * pattern and a bare command.

Neither this suite nor --run-guard-tests carries a jq precondition, which is owed only where the subject spawns the tool. The arm reads both allow lists in-crate and the sandbox JSON is written with printf, and the guard --run-guard-tests drives reads its payload and allowlist in-crate. A precondition in either would refuse a runnable suite on a machine that needs the tool for nothing the suite does.

This suite takes no unit/seam split, by the split’s own discriminator. The split moves pure functions over inputs into #[cfg(test)] tests and keeps at the seam the cases that need the front end, the knob files and a real child process. Every case here is the second kind, asserting the report rendered from four knobs a sandbox config supplies. The exactness case is observable only as an entry staying in the narrowing section, and --count’s breadth number is a property of the partition as rendered. The one pure predicate, the allow match, has its cases in the crate’s unit tests beside it (§The reader and its views).

A shipped tool that is not a hook takes neither lane, and the --scratch-run arm (§scratch-run) splits. Its seam cases, at gate-tests/scratch-run.test.sh, are each a property of the front end resolving the arm, the binary resolving its knobs and a real child process, which a crate unit test cannot see:

  • echo-then-exec ordering, and pass-through of args and exit code;
  • the out-of-scratch, traversal and symlink-escape refusals, the absent-target and missing-argument exits, and the refusal-prints-no-body discriminator;
  • the window: a body whose first line rewrites a line of its own target past bash’s read buffer runs the line the echo showed, which reds on a runner handing over the path;
  • the snapshot: $0 names a dot-led file beside the target, and none remains after a run exiting 0 or 7;
  • the shebang-against-extension refusals either way round;
  • the PowerShell path refused while off, on an absent host and on an invalid knob, each unexecuted;
  • per PowerShell host on PATH (pwsh, and powershell where present), with GUARD_KIT_SCRATCH_POWERSHELL set in the environment: the echo precedes the run, a quoted argument arrives whole, exit 7 passes through, $PSCommandPath names the snapshot, and no snapshot remains. A host absent from PATH prints a skip line.

The file runs under Git Bash too: every path it hands the binary is spelled through cygpath -m where that tool is present, and a host whose ln -s copies rather than links prints a skip line for the symlink case. Its unit cases are pure functions over inputs, pinned in the module’s own #[cfg(test)] tests, where check-crate-arms runs them: the shebang classifier across its spellings, the extension test, the snapshot name, the interpreter read’s refusals and the containment predicate.

The suite is not a guard-tests/ row, whose grammar pairs a decision with a command, nor a gate, so no good/+bad/ pair is owed. Shipped mechanism still 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 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, so the line lands in the same commit as the test.

The --rewrite arm (§rewrite) splits the same way. Its seam cases, gate-tests/rewrite.test.sh, need the front end’s root and the binary’s knob resolution, so they run in a git sandbox holding a relocated workflow dir: a two-file sweep through the front end, and the outside, symlink, .git and state-file refusals. Each refusal is listed after a matching valid operand, so exit 2 is seen to write nothing. Its unit cases, the matcher, report, --expect, UTF-8, duplicate and predicate-error cases, are the module’s own #[cfg(test)] tests.

Rule git_mutation_under_producer’s firing arm takes the bespoke lane, because its second conjunct is a live PID: a fixture carrying one would turn every other git row in guard-tests/cases.tsv into a block. The table keeps only the decline arm, a dead record present, and gate-tests/git-mutation-under-producer.test.sh owns the live process. There a tracked-tree mutation blocks while a recorded producer is alive, and so does a PowerShell git commit, while one carrying a $ declines. The same file holds rule bounded_write’s live-record clause under a relative scratch member: a removal of a live record, by name or by glob, is withheld, while a removal beside it and a touch of it are granted. Rule wait_no_producer’s clause (d) varies the record set, which a decision row cannot, so its pair lives here too. A backgrounded file-test wait blocks over an empty record set, a dead record and a malformed one. Beside a record naming a live PID it passes on to rule bounded_wait’s grant. A table run under the default relative scratch member resolves it inside the sandbox, so no ambient record reaches a row.

gate-tests/consumer-rules.test.sh takes the lane because a consumer command is configuration the table’s sandbox never carries. It drives the member under a sandbox knob file naming a scratch command:

  • a block relayed with its stderr;
  • an allow relayed where the generic ruleset would have blocked the same command;
  • an empty result falling through to a generic block;
  • a non-zero, non-2 exit and unparseable stdout, each running the ruleset with the fault on its output;
  • the empty knob running no stage.

--guard-json’s seam cases sit beside them: a field read verbatim, view over a heredoc-bearing and a quoted command, view over a PowerShell payload printing its skeleton, and view printing nothing for a payload of a tool no reader serves (Read). The consumer lane’s seam cases close the file, driving the arm over one inert row per kit table. A matching table passes, its command reading the exported binary and one row proving @ROOT@ the arm’s working directory. A mismatch reds in each direction, an empty knob prints no consumer count, and both exit-2 refusals hold.

gate-tests/guard-config-knobs.test.sh holds a knob’s non-default value: the runner feeds every decision row through one sandbox whose config it never varies. Each case drives the member in a scratch git working directory under a sandbox knob file selected through GUARD_KIT_KNOB_FILE, the consumer’s own selector. Where the ruleset’s first deciding rule is not the one a case isolates, the case asserts the deciding rule’s text, or the absence of the isolated rule’s, never a re-derived verdict. The decision table keeps the default-valued rows, the kit’s own declaration table among them, and arm (d)’s rows over GATE_SDK_WORKFLOW_DIR, which resolves to its default in the sandbox, so the two lanes do not overlap. The cases, by knob:

  • GUARD_KIT_SEARCH_TOOLS: rules find_glob and git_grep firing under the default, each firing on its own member alone, both inert when the knob is empty, and each firing corrective naming its bare fallback.
  • GUARD_KIT_RO_FORMS: a consumer roster addition carrying a declaration granted by rule ro_pipeline; the same addition undeclared, and an empty declaration, withheld; and a consumer entry for a default member replacing the kit’s declaration rather than adding to it.
  • GUARD_KIT_WRITE_BINS: rule bounded_write granting a scratch removal under the default; empty, withholding it while still granting a redirect into the scratch dir; and a member the operand grammar carries no row for withheld.
  • GUARD_KIT_SETTINGS, for rule git_c_root’s arm (d): an assignment at that exact path fires; one respelled through a /./ component, naming the same file, fires with the baked-hook clause; and one naming a different existing file falls through. The table’s sandbox exports the binary knob absolute, so no default spelling there is the resolved value.
  • GUARD_KIT_SETTINGS, for rule allowlist_chain’s bare form, which the table’s fixed allowlist carries only through git rm -q *. Under make build * alone, make build redirected or chained blocks and a make build carrying an argument does not. A make:* family and a two-star entry lend no bare form. echo * leaves a redirected echo to rule emitter_write and blocks a chained one. Rule ro_pipeline grants make build | head -3 but not the argument-carrying lead.
  • GUARD_KIT_SCRATCH_POWERSHELL: set to pwsh, a PowerShell & .tmp/x.ps1 blocks with the runner steer spelled & '<door>' --scratch-run and without the knob-off text; unset, the same payload blocks with the knob-off steer naming the knob; set to python3, every call blocks with the validator’s text.
  • The knob refusal, which no row reaches because the runner never sets a knob file: a missing or a malformed knob file blocks with the refusal’s own text.

gate-tests/door-binding-corpus.test.sh holds what the fixture pair’s shape cannot: a good/+bad/ pair asserts exit 0 against exit 1, so neither half can spell an exit 2. §check-door-binding’s assertion C has two, both the fail-closed posture rather than a finding: a configured GUARD_KIT_DOOR_ROOTS entry resolving to neither a tracked file nor a directory, and a configured surface the gate cannot read. The suite drives the gate through gate_run in a sandbox repository holding one tracked kit root, so assertions A and B pass on a corpus rather than vacuously. It carries the tracked-file control beside the two refusals, without which a refusal proves only that the entry form was rejected, not that the entry was. The unreadable case is skipped, saying so, for a caller that can read a mode-000 file.

gate-tests/worktree-confinement.test.sh holds the session’s working tree. Rule worktree_confinement fires only from a linked worktree, and the decision table’s sandbox is none, so a row there pins only that a path outside the root is not the rule’s (touch ../outside falls through). The suite builds a main checkout, adds a linked worktree under it and removes it, and drives the member from the worktree with the search tools emptied, the measured isolated toolset. Its cases:

  • Allowed: a journal append into the main scratch dir, bare and by heredoc; a grep -rn, a grep … | head and a find … -name over main-checkout paths; and a read redirected into the session’s own worktree. A touch inside it and a git stash fall through, the worktree having an index and files of its own.
  • Blocked with rule worktree_confinement’s refusal: find … -delete and find … -fprint over the main checkout; a touch of the main root; a relative ../ traversal into it; a <main>/.tmp/../ fold; git -C <main> commit --amend; and a read redirected to a main-checkout path. A gitignored main-checkout target outside the scratch dir written by printf >> blocks too, rule append_scratch withholding it on the predicate.
  • PowerShell, which meets the rule with no admitted read: a Set-Content into the main checkout blocks, spelled with / or with \. A grep the Bash call would be admitted for blocks with a corrective that omits the admitted read, and an Add-Content journal append under the main scratch dir falls through. & <main>/.tmp/x.ps1 blocks on rule script_interpreter, the corrective naming the worktree’s own scratch dir.
  • GUARD_KIT_WORKTREE_READS, here rather than in guard-config-knobs.test.sh because it needs the same worktree: under off the search blocks and the journal append is still granted. A consumer roster adding awk with a none declaration still has awk over a main-checkout path blocked by the program-bearing exclusion.
  • The refusal’s text names the loaded roster, with the program-bearing members omitted, and the read-only arms on the door. A read-only arm is admitted over a main-checkout path on the binary, spelled --emit md-section or --emit-md-section, and on the front end under bash, while --emit md-unwrap --write there is blocked.
  • The scratch dirs’ resolution against the main checkout (§Layout and configuration), under a knob file whose settings allowlist make. The canonical recorded launch writing its record under the main scratch dir is granted by rule bounded_wait’s arm (B). The same launch recording there by a relative ../ path, with no wait, passes rule background_no_record and falls through. Recording into the own worktree’s scratch dir is blocked by rule background_no_record, whose corrective names the main home. A live record under the main scratch dir blocks git commit by rule git_mutation_under_producer, while the same record in the own worktree does not.
  • Rule script_interpreter from a worktree: bash <main>/.tmp/x.sh blocks with a corrective naming the worktree’s own scratch dir, and bash .tmp/own.sh stays blocked with the runner steer.
  • The log’s home: with GUARD_KIT_LOG at its relative default, a fall-through from the worktree lands in the main checkout’s log and none in the worktree’s, and one from the main checkout lands in its own (gate-sdk/SPEC.md §The workflow directory).

check-guard-registration

Invariant: §The rule roster, the crate’s rule table and every rule citation agree. The roster and the table are one list, in one order, with one set of declared views and one set of shells per rule, and every citation names a rule the roster carries. It holds four assertions, each a finding at exit 1:

  • A, the roster grammar. Every top-level bullet of §The rule roster carries exactly one (`<name>`) token right after its bold title, and the names are distinct. A name is [a-z][a-z0-9_]*.
  • B, the order. The roster’s names in order equal the table’s names in dispatch order, and a finding prints the diff (< roster, > table).
  • C, the declarations. Every item carries exactly one declaration clause and at most one shells clause (§The generic ruleset’s item grammar). Its views equal the table’s views for that rule, and its shells — bash where it carries no shells clause — equal the table’s shells for that rule, each as sets. A finding names the rule and both sides.
  • D, the citations. Every citation in §The generic ruleset’s citation grammar resolves to a roster name. The tracked files under the SPEC’s own directory, guard-kit’s tree, and under the crate’s guard module (guard/ beneath GATE_SDK_NATIVE_SRC) are read for the bare and the qualified form alike. Every other tracked file is read for the qualified form, outside the workflow directory (GATE_SDK_WORKFLOW_DIR) and the pruned directories (GATE_SDK_PRUNE_DIRS, GATE_SDK_PRUNE_EXTRA_DIRS). A workflow file is a record, and a record answers what happened; a pruned fixture tree carries citations as exemplars, this gate’s own bad case among them. Within guard-kit’s tree and the guard module no rule may be cited by number: a [Rr]ules?[ -][(]?[0-9]+ token is a finding, the hyphenated and the parenthesized spellings included, so an ordinal list in parentheses after the rule noun reds as a bare ordinal does.

The inputs. §The rule roster is read as the section’s own body, from its heading to the next heading of any level. An item runs from its - lead line through every following blank or indented line, and ends at the first unindented non-blank line. The rule table is read in process, from the binary running the gate, because that binary is the one built from this tree (gate-sdk/SPEC.md §check-gate-binary-fresh holds it fresh). A crate that is absent, as in every vendored tree, leaves the guard module out of the corpus and nothing else. The corpus is enumerated by git ls-files from the working directory, so a fixture case reads its own files.

Resolution fails closed, at exit 2:

  • an unreadable SPEC, table file or corpus file;
  • an absent §The rule roster, or one with no item;
  • a declaration clause the grammar cannot parse, a view spelling the reader does not carry included;
  • a shells clause the grammar cannot parse, a shell name the crate does not carry included;
  • a table-file line of any other shape;
  • a failed git ls-files.

The gate refuses to guess at a roster it cannot read, because a guessed roster passes a skipped rule. The clean line is GUARD-REGISTRATION: clean (<n> rule(s): roster, table order, declared views and <m> citation(s) in lockstep).

The gate takes no knob of its own. It finds guard-kit the way --run-guard-tests does, as the kit root whose basename is guard-kit among the derived kit roots, so a relocated GATE_SDK_KIT_DIRS moves it too. The positional form check-guard-registration [spec-file [table-file]] points it at a synthetic pair. A table file is one line per rule in dispatch order, <name><TAB><view>[; <view>…][<TAB><shell>[, <shell>…]], an absent third field reading as bash, and a blank line or a # line is skipped.

The gate is registered from this repo’s gates dir rather than shipped in a kit checks/, because its subject exists only where guard-kit is authored. A consumer’s rules are its own command (§Consumer rules), never a row of the crate’s table, so no consumer tree holds an instance to red on. Its fixture pair holds one roster in lockstep over a synthetic table, a two-shell item among it, and one tree firing all four assertions, a shells mismatch among them. The exit-2 cases a one-pair harness cannot hold, a roster absent, a malformed declaration or shells clause and a malformed table line, are the sibling check-guard-registration.test.sh.

What it does not hold, stated: a bare citation outside guard-kit’s tree and the guard module (rule and a backticked name, with no qualifier) is not read. The qualifier is the discriminator that stops the gate from claiming another kit’s rule vocabulary. An amendment minting a rule cites it bare there until the roster carries the name. Since no rule carries a number, a writer who drops the qualifier still names the rule, and a later rename is the only way such a citation can go stale. A list broken by other words is read up to the break: in rules truncate_scratch, bounded_wait’s arm (B2) and emitter_write, the last name is not resolved.

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 command.