fix: the shell guard reads what bash runs, blocks what it used to admit, and names its residuals - #717
Merged
Conversation
A command or process substitution ended the enclosing segment, so the argv written after one became a separate command: `cd s && rm $(true) -rf *` read as `rm` plus a command called `-rf` and answered allow, and `git push >(cat) --force origin main` escaped its blocker the same way. The tokenizer now suspends the enclosing command when `$(`, a backtick, `<(` or `>(` opens and resumes it when the substitution closes. The inner command is emitted first, as its own segment, because it runs first. A command substitution contributes no word (the reading under which `$(true)` vanishes and a leading-position substitution never becomes argv[0]); a process substitution contributes one /dev/fd operand, so operand positions stay where the shell puts them. The three regressions the reverted frame design shipped with are pinned: the enclosing chain survives a newline inside the substitution (chains are handed out from a monotonic sequence), only a frame that suspended a command resumes one (a nested bare `(` cannot close the substitution early), and an unterminated substitution still emits every suspended command. The help text, plugin page and brief chapter state what is followed and name the remaining gap, a double-quoted substitution. Refs: iss-148 Refs: iss-2608221126066631 Assisted-by: Claude:claude-opus-5-5
The guard refused every unquoted brace group, so ordinary shorthand an agent
writes (`mkdir -p foo/{a,b}`, `cp x{,.bak}`, `rm -rf dir{1..9}`) was blocked
on the pre-tool-use path. It now expands the group and checks every word it
produces: `mkdir -p foo/{a,b}` allows, and `git push {--force,} origin main`
blocks under git-push-force, the entry that names the hazard.
The expander (braceexpand.go) follows bash 5.3's brace_expand,
brace_gobbler and expand_seqterm over a word whose bytes carry whether they
reached the tokenizer unquoted, so a quoted or escaped `{`, `,` or `}` stays
text. It covers comma groups, nesting, `{x..y[..incr]}` sequences over
integers (zero padding included) and letters, a `}` inside the first
alternative, `${` levels, and the assignment-position word bash does not
expand. A differential run against bash 5.3 on about 10,000 random words
(quotes and escapes included) found no mismatch outside `$` parameter
expansion, which is not brace behaviour.
It is bounded: 4096 words and 1 MiB per command line, and a count of scan
steps. Past a cap the word stays unexpanded and the segment is refused under
brace-expansion-unexpanded, fail-closed on both front doors. The refusal
message names the cap; the help text, plugin page and brief chapter say what
is expanded and what is refused.
Refs: iss-2608282026038930
Assisted-by: Claude:claude-opus-5-5
A `!`-prefixed git alias body is handed to a shell, and the command it holds may be a git command declaring an alias of its own. The body's segments went through the execute-a-string expansion but never back through the alias pre-pass, so the inner alias was never resolved and its rewrite never checked: an allow where the pre-pass one level up would have blocked. The body's segments now re-enter the pre-pass one level deeper. The re-entry carries a depth budget (maxBangAliasDepth, the execute-a-string family's depth of two) and a repeat guard shared across depths, so a body already inspected on the line is not inspected again. Past the budget the body is still checked as written, and an alias rewrite in it is a fail-closed block under git-config-rewrite-unread. Chain ranges stay disjoint across depths. Refs: iss-2609020348038749 Assisted-by: Claude:claude-opus-5-5
… posture in core Two policy defects in how the hazard registry is loaded. An uncommitted `"disabled": true` in .abcd/guard.json switched the guard off on the very next command, although spc-16 and the docs say the only escape is a committed, reviewable edit, and the write itself is one the guard allows. Load now compares the working-tree registry with the one HEAD carries: an edit that switches the guard off, or changes a blocker's tier or pattern, is refused with ErrUncommittedOverride and the committed registry stays in force. An edit that adds or tightens a hazard takes effect uncommitted. Where git cannot say what HEAD carries (no repository, no commit, git refusing the checkout), the weakening edit is refused too: the fail-safe direction. The fail-safe policy for a repo layer that did not load was decided in the CLI by counting entries after a Load error, so the hook, the check verb and ahoy each re-derived it. guard.LoadRepo returns a typed Loaded result whose posture (clean, repo layer dropped, unavailable) is decided once in core; the hook, the check verb and ahoy's health report format it. The hook names a refused uncommitted edit as REFUSED with the committed hazards armed, and a broken file as DROPPED with the bundled hazards armed; the check refuses either with exit 2. Tests that exercise what a weakening override does now commit it first. The git-refuses-the-checkout kill-switch test keeps its point (the repo's own file is the one read from a nested directory) and now expects the switch to be refused, since its commit cannot be confirmed. Refs: iss-147 Refs: iss-2608291814576261 Assisted-by: Claude:claude-opus-5-5
`pkill -f 'make preflight'` and `killall make` answered allow: the bundled registry had no entry for a kill that signals every matching process on the machine. On 2026-09-23 one such kill stopped two peer lanes' gates, which read as an unexplained SIGTERM for half an hour. Two blocker entries, pkill-by-pattern and killall-by-name, name the safe successor: the pid recorded at start, or the process's own group. They need a pattern or name operand to fire, so `pkill -g <pgrp>` and `pkill -P $$` — selectors that carry no pattern and are the own-group route — stay allowed. That constraint is a new optional pattern field, min_operands (at least N non-flag arguments, value_flags stepped over), validated non-negative and merged per field like the others. Both entries pass the admission gate. A kill whose pid list comes from a pattern search (`kill $(pgrep -f x)`) reads as a bare `kill` once the substitution is followed and is not covered. Refs: iss-2609240646538696 Assisted-by: Claude:claude-opus-5-5
git keeps one stash stack per repository, not per worktree, so in a clone
with several worktrees a bare `git stash` in one lane and a bare
`git stash pop` in another hand the second lane the first lane's entry; the
pop succeeds and the work lands in the wrong tree. The guard answered allow.
It now warns, under the reserved id git-stash-shared-stack, on a stash or
push without a message and on a pop or apply that does not name its entry,
when the repository the registry was loaded for has more than one non-bare
worktree. A stash with a message, a pop by `stash@{N}`, the read-only
subcommands and every command in a single-worktree clone are unaffected.
The worktree count is a repository fact the pattern language cannot state,
hence a reserved id rather than an entry; it is read from `git worktree
list` lazily, only when a bare stash is present, and a registry with no
repository behind it (the bundled defaults alone) says nothing. Apply is
included beside pop: it lands the top entry in this tree the same way.
Refs: iss-2609190338340796
Assisted-by: Claude:claude-opus-5-5
… ceilings TestSpeculationIsBoundedAtTheStdinCap (3s) and TestBraceScanStaysLinear (5s) held the guard's DoS bounds with wall-clock ceilings, the shape that failed the scanner's cost guards on loaded gate runs with nothing wrong. The guard now counts its work on the paths the bounds protect (bytes tokenized, bytes the brace look-ahead scans, tokens each pattern match walks) through a test-only tally that is nil in production. Each shape is built at a quarter of the stdin cap and at the cap, and the tests assert the count grows no faster than linear (6x per 4x of input) and stays under 20 units per input byte. The absolute bar is what catches the speculation regressions: its bounds overlap, so dropping any one leaves the cost linear with a constant tens of times larger. Checked by mutation on a scratch copy: removing the brace budget measures 16x growth and 131,071 units per byte; removing the speculation window, the payload-bytes budget or the per-check start budget measures 180, 300 and 51 units per byte. The real code measures one to five. Both skip under -race, where the count is the same. The reading package's project_test ceilings named in the same record are outside this lane and stay open. Refs: iss-2609240046582859 Assisted-by: Claude:claude-opus-5-5
…followed Refs: iss-2609251144159533 Assisted-by: Claude:claude-opus-5-5
`echo "$(gh repo delete owner/repo)"` answered allow while its unquoted twin blocked: the double-quote branch of the tokenizer consumed the whole string as one literal word. Double-quoting a substitution is the idiomatic shell spelling, so this was the common form, not an evasion. A `$( … )` or backtick inside double quotes is now read as a command of its own: its end is found with its own quoting honoured (single and double quotes, nested substitutions, backslashes), its text is tokenized as a segment in the enclosing command's chain, emitted first because it runs first. The quoted word keeps the substitution's text, as before, so an execute-a-string payload carrying one stays uninspectable. A substitution whose end cannot be found stays literal and the scan stops looking in that string, which keeps it linear; nesting is followed eight levels deep. The help text, plugin page and brief chapter drop the gap and name the depth. Refs: iss-2609251144159533 Assisted-by: Claude:claude-opus-5-5
…wed ruling Both records are resolved by shipping itd-148 (worktrees for every change), which waits on a product-thinker ruling owed in run A (theme L): where worktrees live, which record owns their verbs, and whether the block on writes in the main checkout spares a coordinating session. Each record carries deferred_after v0.10.0 and a deferral_reason naming the intent and the ruling; nothing is built ahead of it. Refs: iss-213 Refs: iss-2608230847432285 Assisted-by: Claude:claude-opus-5-5
…the enclosing command whole Resolves: iss-148 Resolves: iss-2608221126066631 Assisted-by: Claude:claude-opus-5-5
…refused Resolves: iss-2608282026038930 Assisted-by: Claude:claude-opus-5-5
…alias pre-pass Resolves: iss-2609020348038749 Assisted-by: Claude:claude-opus-5-5
…ening, posture in core Resolves: iss-147 Resolves: iss-2608291814576261 Assisted-by: Claude:claude-opus-5-5
Resolves: iss-2609240646538696 Assisted-by: Claude:claude-opus-5-5
Resolves: iss-2609190338340796 Assisted-by: Claude:claude-opus-5-5
… followed Resolves: iss-2609251144159533 Assisted-by: Claude:claude-opus-5-5
…records Refs: iss-147 Refs: iss-148 Assisted-by: Claude:claude-opus-5-5
Eight findings from the review of the guard lane, filed before any fix. Findings 1-3 are this lane's own and are fixed on this branch; 4 and the first two halves of 6 are pre-existing and small enough to fix here; 5 and the third half of 6 are recorded for a deferral. Refs: iss-2609251640353993 Refs: iss-2609251640353405 Refs: iss-2609251640353017 Refs: iss-2609251640354925 Refs: iss-2609251640452031 Refs: iss-2609251640464735 Refs: iss-2609251640464212 Refs: iss-2609251640462464 Assisted-by: Claude:claude-opus-5-5
Three allows of registry blockers the guard lane introduced, found by its review. A double-quoted substitution kept its literal text in the word, so a flag glued beside an empty one never matched. The line is now read a second time with every followed quoted substitution removed, the vanish reading the unquoted branch already takes, and each shadow that differs is placed directly after the command it shadows. The literal reading stays, so the execute-a-string family still sees a substitution in its payload. Past maxQuotedSubstitutionDepth, and for a quoted substitution whose text does not tokenize, the tokenizer raises a fail-closed flag on an empty segment (the here-document precedent); Check turns it into a block under the new reserved id substitution-unread. An unquoted substitution standing as a word of its own is recorded where it stood, and the min_operands count reads each one back as an operand of unknown text; a value flag still consumes it, and every positional compare keeps the vanish reading. Tier 2 windows carry the record. The help text, the plugin page, the brief chapter and the generated CLI reference no longer list the depth as an allow gap. Refs: iss-2609251640353993 Refs: iss-2609251640353405 Refs: iss-2609251640353017 Assisted-by: Claude:claude-opus-5-5
…closed Resolves: iss-2609251640353993 Resolves: iss-2609251640353405 Resolves: iss-2609251640353017 Assisted-by: Claude:claude-opus-5-5
git accepts any unambiguous prefix of a long option, so a push or commit flag spelled a few letters short runs as the full flag, and the exact flag compare allowed every such spelling of the no-verify and force blockers. gitLongOptions holds the long-option tables of push and commit, taken from --git-completion-helper-all on git 2.52. A long argument matches a blocked long alternative when it is a strict prefix of it that no option outside the entry's flag group shares. A prefix shared only among blocked alternatives is read as blocked (an older git resolves it), and a table missing a newer git's option can only over-block. A test pins every blocked git long alternative to the table. Refs: iss-2609251640354925 Assisted-by: Claude:claude-opus-5-5
Resolves: iss-2609251640354925 Assisted-by: Claude:claude-opus-5-5
The after_cd constraint knew only cd, so a recursive delete chained after pushd or popd was allowed, though either fails as cd does and leaves the delete running wherever the shell already was. popd is taken with pushd on the same grounds; the review named pushd. Refs: iss-2609251640464735 Assisted-by: Claude:claude-opus-5-5
Resolves: iss-2609251640464735 Assisted-by: Claude:claude-opus-5-5
A commit or push that points core.hooksPath somewhere else for itself, through -c, --config-env or the GIT_CONFIG environment, skips the repository's hooks exactly as the --no-verify flag does, and the matcher stepped the -c value over unread. readGitConfig generalises gitConfigDeclarations to also note the hooks path, whatever its value; a new pre-pass after the alias expansion appends the same command with --no-verify inserted after its subcommand, so the existing no-verify entries match it. Setting the key with git config stays allowed. The brief chapter also names the pushd/popd reading of the commit before. Refs: iss-2609251640464212 Assisted-by: Claude:claude-opus-5-5
Resolves: iss-2609251640464212 Assisted-by: Claude:claude-opus-5-5
Round 1 closed three shapes of one class: a command substitution whose output the guard cannot know. review2-guard found the same class one position over, three times. This closes the class with one rule, read in one place (internal/core/guard/unknown.go). The tokenizer writes a mark (NUL, which no argv word can hold) into a word where a substitution's output goes, for unquoted, backtick and double-quoted substitutions alike, and every reader asks unknown.go what the word can be. An unknown word fails closed in every role it could play: led by a dash it is every flag its known text can still become (`--$(x)`, `-r"$(x)"`, never the `--` terminator); after a value flag it fills the value slot and never consumes the next word (`git -C $(pwd) push`); as an operand it is one operand, and a positional compare matches it, with a second reading in which a bare one vanishes. The flag scan, the flag-value scan, operandIndexes, min_operands, the path constraint and the -c config reader all read it. The double-quote shadow pass is retired: the mark carries both readings in one. Also, from the same review: - closingParen reads grammar: a `#` comment, an arithmetic expansion and a here-document body are skipped, and a case command in the body is refused as substitution-unread rather than guessed (finding 3); a case command inside an unquoted substitution is refused the same way. - An execute-a-string payload the guard cannot read in full still has its segments matched beside the warn, so `bash -c '<blocker> $(true)'` blocks as the top level does (finding 4). - `$(( … ))` and the bare `(( … ))` command are read as expressions; only a command substitution inside one runs (finding 5). - The closing scans are tallied and share one budget per line, past which the substitution is refused; Check refuses a line over 64 KiB under the reserved id command-too-long (finding 6). - `builtin cd` and `builtin pushd` chain as `command cd` does (finding 7). - A shell reading its script from a pipe, a here-document or a here-string is refused under the reserved id interpreter-reads-stream; a shell handed a script file is not (finding 8). The residuals the rule keeps on purpose are recorded in DECISIONS.md in the next commit. Refs: iss-2609251640462464 Assisted-by: Claude:claude-opus-5-5
…e bypass - iss-2609251640462464 (a blocked command piped as text into a bare shell) is re-graded minor -> major: it bypassed every blocker, and minor kept it outside the release guard (review2-guard finding 8). It is fixed in 7e523ba and resolved in the next commit. - iss-2609251640452031 names the selector kills a substitution fills: `pkill -u $(whoami)` and `pkill -g $(cat p)`, allowed by design. - iss-2609251824244354 captures the confirmed residual of the same class: parameter expansions (`--$X`) and a substitution in command position are not yet unknown words. Deferred after v0.10.0 with its reason. - DECISIONS.md records the unknown-word reading, the allows it keeps on purpose (a word wholly a substitution is an operand, a refspec prefix is read from known text, `git config core.hooksPath` then a commit), and the two accepted over-blocks. Refs: iss-2609251640462464, iss-2609251640452031, iss-2609251824244354 Assisted-by: Claude:claude-opus-5-5
…used Resolves: iss-2609251640462464 Assisted-by: Claude:claude-opus-5-5
…t hands a shell sh -c "$(cat <<'EOF' … EOF)" and eval "$(cat <<EOF … EOF)" hand the shell the document's text verbatim, but the tokenizer leaves a substitution's output unknown, so the payload read as uninspectable and the family's WARN was the verdict: a blocked command in the document ran (review5-guard finding 3). Where the output is fixed — cat reading one here-document and nothing else, whose body the shell does not change (a quoted delimiter, or an unquoted one with no $, backtick or backslash in its body) — literalHeredocOutput returns the body with its trailing newlines removed, as bash removes them. A word that is wholly such a double- quoted substitution keeps that text beside it on the segment, and expandPayloads reads every payload twice: as the tokens stand, and with those words read as their text. The reading is additive by construction: the unknown reading and every signal it raises stay, so a document that reads clean keeps the warn it had, and a verdict can only strengthen. The body reader is split out of skipHeredocBodies (readHeredocBody) so both read a body by the same logical lines. TestHereDocumentPayloadIsRead holds sh, bash, eval and su with quoted, unquoted and <<- documents, one nested in another and one inside a substitution (twelve rows warned at the base), and the controls: a clean document still warns, an expanding body or a command after cat stays unknown, and the same document where no shell runs it allows. TestHereDocumentPayloadStaysLinear pins the cost class. Refs: iss-2609252214215409 Assisted-by: Claude:claude-opus-5-5
… as its text Resolves: iss-2609252214215409 Assisted-by: Claude:claude-opus-5-5
…expansion and a document payload
The command page, the CLI reference and the brief's guard chapter state
the round's readings: an unquoted here-document body is read by the
lines bash compares with its delimiter, joined across an odd trailing
run of backslashes; a double-quoted ${…} ends at its own brace, its
nested quotes opening a string of their own; and a wholly
"$(cat <<'EOF' … EOF)" payload is also read as its document's text.
The command page names the exotic over-block review5-guard found
(cat <<$(echo EOF) blocks as heredoc-unterminated), and
TestSubstitutedDelimiterIsARecordedOverBlock keeps that record true.
Assisted-by: Claude:claude-opus-5-5
…y claim The round-4 report held that bash can only end an unquoted here-document body later than the guard, so the guard over-reads and never under-reads; bash's backslash-newline join made that false. The decision log corrects it and records the three rulings of the round (a double-quoted expansion read to its own brace, a document payload read additively beside the unknown reading, and the substituted delimiter as an accepted over-block). Refs: iss-2609252214137586 Refs: iss-2609252214217550 Refs: iss-2609252214215409 Assisted-by: Claude:claude-opus-5-5
…ce is generated from The CLI reference is generated from the guard command's help, so the sentences the previous commit wrote into the page belong in the help text; go generate reproduces the committed page byte for byte. Assisted-by: Claude:claude-opus-5-5
Two captures from fix round 6 of the guard lane. The first is review6-guard finding 1: a command-position fixed-output substitution inside a here-document payload is not read. The second is its backtick twin, found while probing the first. Refs: iss-2609252305404421 Refs: iss-2609252310310823 Assisted-by: Claude:claude-opus-5-5
…yload layer review6-guard finding 1. A document handed to `sh -c`, `bash -c` or `eval` was read one level deep: when its own text was an unquoted `$(cat <<'F' … F)` in command position, the payload re-read saw a bare substitution and warned, and a warn runs. The same substitution with no wrapper round it was a silent allow. The tokenizer now keeps the fixed output of an unquoted `$(cat <<'EOF' … EOF)` word beside the word, as it already did for the double-quoted one, and marks it split. expandPayloads reads each segment holding one as the command bash runs: the output split on blanks and newlines, each word a pattern where it holds a glob byte, at the same layer and in the same chain, with any payload that command carries followed from there. The unknown reading stays, so a verdict only tightens. Decisions taken here: - The words are read as words, never again as a command line. bash 3.2 and 5.3 (probed with a neutral word) split the output and do not re-parse it: a `;` or a `$(` in it is text, a newline separates words, not commands. Re-reading it as a line would under-read the newline case, and a `$(cat` word runs nothing. - The split reading is not a payload layer: it crosses no execute-a-string boundary and its words carry no literal, so it cannot repeat. The bound is the existing maxPayloadDepth, two execute-a-string layers; a payload nested deeper is refused with the fail-closed block, whatever it holds. - literalHeredocOutput counts what its body scan reads rather than the rest of the text, because an unquoted substitution is offered with every byte after its document, and the old count grew with the square of a nesting (130 units a byte at 6.5 KB, 3 now). The plugin page, the help the CLI reference is generated from, and the brief's guard chapter say which layers are read, how the words are split, and that a deeper payload is blocked. Refs: iss-2609252305404421 Assisted-by: Claude:claude-opus-5-5
…re read Resolves: iss-2609252305404421 Assisted-by: Claude:claude-opus-5-5
…ling is A backtick is command substitution in its other spelling, and bash 3.2 and 5.3 (probed with a neutral word) run `` `cat <<'F' … F` `` where they run `$(cat <<'F' … F)`: handed to `sh -c` or `eval` inside double quotes, inside a document payload, and alone in command position. The guard read only the dollar spelling, so the first two warned and the last was a silent allow. The double-quoted branch and the unquoted close both read either spelling through substitutionOutput. Between backticks bash reads a backslash before it reads the command, so a backtick text holding one is left unknown rather than read; without one, the command is the text as written. The surfaces name the backtick spelling beside the dollar one. Refs: iss-2609252310310823 Assisted-by: Claude:claude-opus-5-5
…s read Resolves: iss-2609252310310823 Assisted-by: Claude:claude-opus-5-5
…sidual gaps Four captures from review7-guard, fix round 7 of the guard lane: the backtick pre-pass the tokenizer does not apply (finding 1, pre-existing at f97a751), the default-IFS split of a fixed output (finding 2), the glued fixed output (finding 3), and the residual paragraph's missing postures (finding 4). Refs: iss-2609260115287911 Refs: iss-2609260115387303 Refs: iss-2609260115380561 Refs: iss-2609260115383631 Assisted-by: Claude:claude-opus-5-5
Between backticks bash removes a backslash before `$`, a backtick or a backslash before it parses the command, and directly inside double quotes a backslash before `"` too. The guard followed a backtick's text verbatim, so an escaped `$(…)` or an escaped backtick pair there, in an unquoted here-document body or in a word, was skipped as escaped while bash runs it (review7-guard finding 1, pre-existing at f97a751). backtickText applies the pass. Where a backtick is followed as a whole text (inside double quotes, in an expanded here-document body, in an arithmetic expansion) the text after the pass is followed. A top-level backtick is read in place as before when the pass changes nothing; when it does, its text is found by its naive close, as bash finds it, and the text after the pass is followed as the substitution's command. The help, the reference generated from it, the guard page and the brief say so. Each blocking shape was run under bash 3.2 and 5.3 with a neutral word. Per byte of work at the large size of the linearity shapes: 7.8, 9.0 and 4.7. Refs: iss-2609260115287911 Assisted-by: Claude:claude-opus-5-5
… the pre-pass Resolves: iss-2609260115287911 Assisted-by: Claude:claude-opus-5-5
A fixed `$(cat <<'F' … F)` output was read as its words only where the
word was wholly that substitution. Text glued to it, or a second such
substitution, left the word unknown, and a lone unknown word in command
position allows, while bash joins the output's first and last words to
the text beside them and runs the result (review7-guard finding 3).
Each fixed output in a word is now recorded as a piece at the offset of
its mark, and the word is read as bash builds it (joinedLiteral): an
unquoted output split on the default IFS with its edge words joined to
the text beside it, a quoted one and the written text never split, a
mark no piece names left unknown. A quoted word is one payload text
joined the same way. An assignment in assignment position is not split,
as bash does not split it, and an output inside a `${…}` stays unknown.
The help, the reference generated from it, the guard page and the brief
say what is read.
Each blocking shape was run under bash 3.2 and 5.3 with a neutral word.
Per byte of work at the large size of the linearity shapes: 4.6 (many
glued outputs, 48 KB), 19.2 (one word of many, 22 KB, floor-dominated)
and 17.3 (one long output, 28 KB, floor-dominated).
Refs: iss-2609260115380561
Assisted-by: Claude:claude-opus-5-5
… bash joins it Resolves: iss-2609260115380561 Assisted-by: Claude:claude-opus-5-5
An unquoted fixed output is read as the words the default IFS splits it into. A command line that assigns IFS in a command of its own changes that split for every expansion after it, so `IFS=x;` before a document spelling a hazard joined by x reached ALLOW as one word, while bash runs the hazard (review7-guard finding 2). Modelling which assignment reaches which expansion (a loop's earlier ones, an outer layer an `eval` runs under, `export`, `read`) costs more than refusing, so an unquoted fixed output on a command line where any other segment names IFS, at any payload layer, is a block under the new reserved id ifs-split-unread. The carrier's own words and the segment its words make do not count, so a prefix assignment (`IFS=x $(…)`), which bash does not apply to its own command's expansion, still reads as the default split. An IFS the shell holds before the line starts is named as a residual. The help, the reference generated from it, the guard page and the brief say so. Each blocking shape was run under bash 3.2 and 5.3 with a neutral word. Per byte of work at the large size of the linearity shapes: 5.4 (a fixed output among many commands, 43 KB) and 5.1 (many fixed outputs, IFS named last, 51 KB). Refs: iss-2609260115387303 Assisted-by: Claude:claude-opus-5-5
… IFS is refused Resolves: iss-2609260115387303 Assisted-by: Claude:claude-opus-5-5
…ument read
The residual lists did not name two postures the fixed-output read
makes sharp (review7-guard finding 4). A lone substitution whose output
is unknown, standing as the whole command, allows: it can be any
program, but with no operand after it no entry matches, and an allow
means no entry matched. And what a substitution prints is read only for
the exact shape `cat <<DELIM`, a newline, the body, the delimiter line
and blanks; `/bin/cat`, `command cat`, `cat -`, a redirection after the
delimiter word, a backslash-newline before the `<<` or after a
backtick's document, a command after the document and a `${…}` around
it leave the output unknown, so each allows alone as the command and
warns as an `sh -c` string. The brief, the guard page, the help and the
reference generated from it name both.
A pin test holds each named shape at its verdict, so a change that
closes one fails there and revisits the sentence in the same change. It
passes on arrival: it pins the record, not a new behaviour.
Refs: iss-2609260115383631
Assisted-by: Claude:claude-opus-5-5
…e-substitution allow Resolves: iss-2609260115383631 Assisted-by: Claude:claude-opus-5-5
One branch per byte: a backslash the pass removes starts the copy and yields the byte after it, and every byte is copied once a copy exists. No behaviour changes; the pre-pass tests pin it. Assisted-by: Claude:claude-opus-5-5
…pass drops The final verification (review 8) named two residual sentences. The IFS residual covers an IFS the line gains through a name the guard does not read, not only one the shell holds at the start; the help's backtick paragraph names the `"` bash drops directly inside double quotes, as the page and the brief already do. The reference is regenerated from the help. Assisted-by: Claude:claude-opus-5-5
Assisted-by: Claude:claude-opus-5-5
REPPL
enabled auto-merge
September 26, 2026 02:14
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Sep 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
abcd guard(the shell-command guard behind the PreToolUse hook) reads far more of what bash actually runs, blocks a set of hazards it used to let through, and names every residual it still admits. It is a mistake filter, not a sandbox, and its page and brief chapter say exactly where that line sits.sh -c,bash -corevalas its payload, read up to the stated depth bound and blocked past it.${…}keeps its nested quotes (so"${MSG:-"don't"}"runs again).command-unparsable(a line the guard cannot split blocks),program-name-unknown,ifs-split-unread(an unquoted fixed output on a line that assigns IFS).Review: Fable 5.1, eight rounds (adversarial, security), each fix round a fresh implementer. The final round verified: SHIP, with the two residual sentences it named added before landing. Deferred out loud: iss-2609251824244354 (plain
$X/${X}parameter expansion with no substitution in it).Resolves: iss-147
Resolves: iss-148
Resolves: iss-2608221126066631
Resolves: iss-2608282026038930
Resolves: iss-2608291814576261
Resolves: iss-2609020348038749
Resolves: iss-2609190338340796
Resolves: iss-2609240646538696
Resolves: iss-2609251144159533
Resolves: iss-2609251640353017
Resolves: iss-2609251640353405
Resolves: iss-2609251640353993
Resolves: iss-2609251640354925
Resolves: iss-2609251640462464
Resolves: iss-2609251640464212
Resolves: iss-2609251640464735
Resolves: iss-2609252020432185
Resolves: iss-2609252020505990
Resolves: iss-2609252020507464
Resolves: iss-2609252120204766
Resolves: iss-2609252120211621
Resolves: iss-2609252120212508
Resolves: iss-2609252120212639
Resolves: iss-2609252120215011
Resolves: iss-2609252120215841
Resolves: iss-2609252135151652
Resolves: iss-2609252214137586
Resolves: iss-2609252214215409
Resolves: iss-2609252214217550
Resolves: iss-2609252305404421
Resolves: iss-2609252310310823
Resolves: iss-2609260115287911
Resolves: iss-2609260115380561
Resolves: iss-2609260115383631
Resolves: iss-2609260115387303
Refs: iss-2609251824244354
Assisted-by: Claude:claude-opus-5-5