Skip to content

fix: the shell guard reads what bash runs, blocks what it used to admit, and names its residuals - #717

Merged
REPPL merged 75 commits into
mainfrom
fix/guard-cluster
Sep 26, 2026
Merged

REPPL merged 75 commits into
mainfrom
fix/guard-cluster

Conversation

@REPPL

@REPPL REPPL commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator

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.

  • The tokenizer follows command substitutions everywhere bash runs them: inside double quotes, inside backticks after bash's own backslash pass, in unquoted here-document bodies (with bash's backslash-newline join), and in a here-document handed to sh -c, bash -c or eval as its payload, read up to the stated depth bound and blocked past it.
  • A word glued to a fixed output is read as bash joins it; a double-quoted ${…} keeps its nested quotes (so "${MSG:-"don't"}" runs again).
  • Unquoted brace groups expand as bash expands them, an alias declared inside a bang alias body resolves, and a kill by name or pattern blocks.
  • New reserved ids: 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).
  • An uncommitted weakening override is refused, and the load posture is decided in core.
  • Every cost bound is asserted as a count of work, not a wall-clock ceiling, and every new path stays linear.
  • Residuals the guard knowingly admits are named on commands/guard.md and in the brief chapter.

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

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
REPPL enabled auto-merge September 26, 2026 02:14
@REPPL
REPPL added this pull request to the merge queue Sep 26, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 26, 2026
@REPPL
REPPL added this pull request to the merge queue Sep 26, 2026
Merged via the queue into main with commit 39ed6fa Sep 26, 2026
13 checks passed
@REPPL
REPPL deleted the fix/guard-cluster branch September 26, 2026 03:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant