Skip to content

fix(pre-bash,worktree-guard): block filesystem-root wipes and empty-collapse expansions - #10

Merged
andrei-hasna merged 14 commits into
mainfrom
fix/destructive-guard-root-and-empty-collapse
Jul 27, 2026
Merged

fix(pre-bash,worktree-guard): block filesystem-root wipes and empty-collapse expansions#10
andrei-hasna merged 14 commits into
mainfrom
fix/destructive-guard-root-and-empty-collapse

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

The incident this prevents

On 2026-07-24 a subagent composed this at inference time and ssh'd it to station02:

bash -c 'rm -rf "$(bun pm cache)"/* ; … bun add -g @hasna/connectors@1.3.45'

bun pm cache writes its path to stdout on success, but exits 1 with an error on stderr
and nothing on stdout when no package.json is found walking up from cwd. The
substitution collapsed to empty, so the command ran as rm -rf /*. Unprivileged, ~5
minutes, ~700 GB freed, org repo checkouts on station02 destroyed. One repository's source
(hasna/cloud) is permanently gone. The agent never saw it: a local timeout 150 killed the
ssh client, the tool result was Exit code 143, and it retried without the rm.

$(bun pm cache) with or without 2>/dev/null is equally dangerous — the redirect
discards the diagnostic, not the path. The precondition is "no package.json walking up from
cwd", not any bun version. 13 of 16 stations reproduce it.

Why the existing guard was not the fix

The pre-bash destructive-shell guard was scoped to ~/.hasna, workspace roots and repo
roots. Measured on origin/main @ 2e654bc, driving hooks/pre-bash/src/hook.ts with
command strings:

rm -rf /home/hasna/.hasna    -> {"decision":"block"}   <- control: classifier works
rm -rf /*                    -> {"continue":true}      <- NOT BLOCKED
rm -rf "$(bun pm cache)"/*   -> {"continue":true}      <- NOT BLOCKED

The baseline is wider than reported. These were also unguarded:

rm -rf /usr/*   rm -rf /etc   rm -rf /var/*   rm -rf /home/*   rm -rf "$HOME"/*
rm -fr /*   rm -R -f /*   sudo rm -rf /*   /bin/rm -rf /*   rm -rf -- /*
rm -rf $(bun pm cache)/*   rm -rf `bun pm cache`/*   rm -rf "${VAR}"/*
bash -c 'rm -rf "$(bun pm cache)"/* ; …'      <- the realized incident, verbatim
timeout 150 ssh station02 'rm -rf "$(bun pm cache)"/*'

Enabling that guard unpatched is not remediation. It protects ~/.hasna while / stays
wide open, and says so in its own message.

Three root causes in hooks/codewith-native-common.ts:

  1. protectedPathContextFor() had no filesystem or system roots at all.
  2. threatensRule() checked a glob content-wipe base with mutatesProtectedPath(), which
    requires the target to be inside the root. So dir/* never matched a protected root
    living under dir. That asymmetry is exactly why rm -rf / blocked (via the ~/.hasna
    tree rule) and rm -rf /* did not.
  3. shellWords() / splitShellSegments() did not treat $( ) or backticks as atomic, and
    nothing unwrapped bash -c / ssh. The realized incident's own text was invisible to the
    rm scanner — the whole script was one opaque token.

What this PR implements

Rule 1 — system roots are protected

SYSTEM_PROTECTED_ROOTS: / plus the FHS and macOS system directories. All in root
mode
, so a wholesale wipe of a root or its contents blocks while a targeted delete beneath
one still passes:

rm -rf /usr                       -> block
rm -rf /usr/*                     -> block
rm -rf /usr/local/lib/my-build    -> allowed

/tmp is deliberately excluded — scratch cleanup there is routine and bounded. Extend per
machine with HASNA_PROTECTED_SYSTEM_ROOTS (additive only; it cannot remove a root).

Rule 2 — targets that can collapse to empty, blocked by shape

Every destructive target containing a command substitution, backtick substitution or variable
expansion is checked twice: as written, and as the shell would render it if that expansion
returned empty. If the collapsed form hits a protected root, it blocks — regardless of what
the expansion is. This is the rule that catches the next variant nobody has thought of yet.

rm -rf "$(bun pm cache)"/*                              -> block
rm -rf "$(some-command-nobody-has-written-yet)"/*       -> block
rm -rf `cmd`/*    rm -rf "$VAR"/*    rm -rf "${VAR}"/*  -> block
rm -rf /opt/"$APP"/*                                    -> block

Two deliberate non-blocks, both justified rather than incidental:

  • Bare rm -rf "$(cmd)", no trailing separator — allowed. An empty expansion makes this
    rm -rf "", which POSIX rm rejects with "cannot remove ''" and a non-zero exit, deleting
    nothing. The entire catastrophic class is the trailing separator, which turns the empty
    string into /. Blocking the bare form would break routine rm -rf "$tmpdir" cleanup for
    zero safety gain — and a guard that blocks routine work gets switched off, which is how this
    class recurs.
  • ${VAR:?} / ${VAR:?message} — allowed. POSIX guarantees these abort the shell when
    the variable is unset or empty, so they cannot collapse. This is the idiom the block
    message itself recommends; refusing it would teach agents to drop it. ${VAR?} without
    the colon permits an empty value and is not exempt.

Supporting fixes — each one was an escape found by an adversarial pass

  • Content-glob asymmetry. A wholesale glob dir/* is now matched against protected roots
    nested under dir. Narrower globs (dir/build-*) keep their previous weaker check, so
    rm -rf ~/proj* stays allowed while rm -rf ~/* blocks.
  • Wrapper unwrapping — required, not optional, since the realized incident lived inside
    bash -c inside ssh: bash/sh/zsh/dash/ksh -c, su -c, runuser -c, eval, and
    ssh host '…', including nested combinations, to depth 3. Remote layers only consider
    absolute targets, because a remote relative path cannot be resolved against the local cwd
    (so ssh host 'rm -rf .' is not a false positive).
  • cd trackingcd / && rm -rf * blocks, and so does cd "$(cmd)"/ && rm -rf ./*,
    which is the incident's shape moved one command to the left.
  • for VAR in <root-glob> is followed into rm -rf "$VAR". Only exact $VAR / ${VAR}
    targets bound by a for … in in the same layer are substituted, so it cannot misfire.
  • Tokenizer$( ) and backticks are atomic, with a fallback to the plain tokenizer when
    a substitution is unterminated so nothing is swallowed.
  • Block messages name the safe alternative instead of only refusing.

Evidence — failing on bad input, not just passing on good

Full suite: 972 pass, 0 fail (baseline main @ d8c0e8a alone: 941 pass, 0 fail — +31
regression tests). bun run typecheck and bun run build clean.

Rebased onto d8c0e8a ("worktree-guard aligns to canonical 2-segment worktree paths") after
it landed on main and conflicted with this branch. Conflicts were confined to CHANGELOG.md
and two import blocks in hooks/codewith-native-common.test.ts, resolved as a union;
hooks/codewith-native-common.ts auto-merged. Every number and verdict below was re-measured
after the rebase, not carried over.

That commit widens which paths count as managed worktrees, which widens the ~/.hasna
carve-out — so the seam was probed explicitly. Running the classifier with cwd inside a
canonical managed worktree (carve-out live), rm -rf /*, rm -rf /, rm -rf /usr/*,
rm -rf /home/*, rm -rf "$(bun pm cache)"/*, rm -rf ~/.hasna and
rm -rf ~/.hasna/repos/worktrees all still block, while rm -rf dist and
rm -rf ./node_modules stay allowed — 0 mismatches. It holds structurally:
shouldSkipHasnaTreeRule gates on rule.label === "Hasna state root ~/.hasna" before
consulting currentManagedRepoRoot, so the carve-out can only ever suppress that one rule.
System roots are unreachable through it no matter how permissive managedWorktreeInfo becomes.

The three mandated fixtures are asserted verbatim, both against the classifier
(hooks/codewith-native-common.test.ts) and end to end against the hooks run pre-bash
process (src/hooks/codewith-native.test.ts), because the emitted JSON is what actually stops
the tool call.

Driving hooks/pre-bash/src/hook.ts with command strings, before → after:

command before after
rm -rf /home/hasna/.hasna (control) block block
rm -rf /* continue block
rm -rf "$(bun pm cache)"/* continue block
bash -c 'rm -rf "$(bun pm cache)"/* ; bun add -g …' (realized incident) continue block
rm -fr /* / rm -R -f /* / rm --recursive --force /* continue block
sudo rm -rf /* / FOO=bar rm -rf /* / /bin/rm -rf /* / rm -rf -- /* continue block
true && rm -rf /* / newline-chained continue block
rm -rf $(bun pm cache)/* / rm -rf `bun pm cache`/* continue block
rm -rf "${BUN_CACHE}"/* / rm -rf "$BUN_CACHE"/* / rm -rf "$HOME"/* continue block
sh -c "rm -rf \"$(bun pm cache)\"/*" continue block
timeout 150 ssh station02 'rm -rf "$(bun pm cache)"/*' continue block
rm -rf /usr/* / rm -rf /etc / rm -rf /var/* / rm -rf /home/* continue block
rm -rf ./node_modules / rm -rf dist / rm -rf /tmp/scratch-1234 continue continue
rm -rf "$(mktemp -d)" / ls -la / / git status continue continue

An adversarial pass then tried to defeat the new guard. Six escapes and two false positives
were found and fixed, and every case is now a committed regression test:

  • escapes fixed: eval '…', su -c '…', su root -c '…', cd "$(cmd)"/ && rm -rf ./*,
    for d in /*; do rm -rf "$d"; done, ssh host "cd / && rm -rf *"
  • false positives fixed: rm -rf "${CACHE:?cache path required}"/*, rm -rf "${BUN_CACHE:?}"/*

Final adversarial run: escapes=0, falsePositives=0 across 32 attack strings and 12
legitimate-cleanup strings.

No rm is executed at any scope by any test or probe. The classifier is driven with
command strings only.

Known limitations, stated rather than hidden

These are outside the accidental-collapse threat model and are not closed by this PR:

  • Deliberate obfuscation such as $'\x2f'* (ANSI-C quoting) or a base64-decoded script.
  • Targets arriving on stdin: echo / | xargs rm -rf.
  • Heredoc-fed interpreters: bash <<< 'rm -rf /*'.
  • Full dataflow. Only for … in bindings are followed; an arbitrary d=/; rm -rf "$d"/*
    chain is not traced (though that one blocks anyway via the collapse rule).
  • ssh host rm -rf /* unquoted blocks, but is reported as a local target, because the
    bare rm token is visible to the outer scan. Same verdict, less precise wording.

What remains armed after this lands

Merging this PR changes nothing on any machine by itself. Still required:

  1. Publish @hasna/hooks (publish intent to #git-publishing first).
  2. Install the published version on all 16 stations.
  3. Ensure the hook is actually enabled. The pre-bash guard is currently disabled in
    ~/.codewith/config.toml. Patched-but-disabled protects nothing.

Until all three are done, the 13 stations that reproduce the precondition stay exposed.

A [BREAKING] heads-up was posted to #announcements before this PR was opened
(message 600019).

…ollapse expansions

`rm -rf /*` and `rm -rf "$(cmd)"/*` both returned {"continue":true} before this
change. Only `rm -rf /` blocked, and only incidentally, because ~/.hasna sits
under it. Remediates the 2026-07-24 data-destruction incident where
`rm -rf "$(bun pm cache)"/*`, sent over ssh inside `bash -c`, ran as `rm -rf /*`,
freed ~700 GB and permanently destroyed one repository's only source copy.
`bun pm cache` exits non-zero with an empty stdout when no package.json is found
walking up from cwd; `2>/dev/null` does not help, it discards the diagnostic and
not the path.

Two complementary rules:

1. System roots are protected. `/` plus the FHS and macOS system directories are
   protected roots in `root` mode, so a wholesale wipe of a root or its contents
   blocks while a targeted delete beneath one still passes. `/tmp` is excluded
   deliberately. Extend per machine with HASNA_PROTECTED_SYSTEM_ROOTS.

2. Targets that can collapse to empty are blocked by shape. Any destructive
   target containing a command substitution, backtick substitution or variable
   expansion is re-checked as the shell would render it if that expansion came
   back empty, so `rm -rf "$(anything)"/*` and `rm -rf "$VAR"/*` are refused
   whatever the expansion is. `${VAR:?}` is exempt, POSIX guarantees it
   non-empty. Bare `rm -rf "$(cmd)"` with no trailing separator stays allowed: it
   degrades to `rm -rf ""`, which rm rejects without deleting anything.

Supporting fixes, each of which was an escape found by an adversarial pass:

- A wholesale content glob `dir/*` is matched against protected roots nested
  under `dir`, not only against `dir` itself. That asymmetry is precisely why
  `rm -rf /` blocked and `rm -rf /*` did not. Narrower globs (`dir/build-*`)
  keep their previous weaker check, so `~/proj*` stays allowed.
- Wrappers are unwrapped before scanning: bash/sh/zsh `-c`, `su -c`,
  `runuser -c`, `eval`, and `ssh host '...'`, including nested combinations.
  The realized incident was inside `bash -c` inside `ssh`, so its own text was
  invisible to the previous rm scanner. Remote layers only consider absolute
  targets, since a remote relative path cannot be resolved locally.
- `cd` is tracked within a command: `cd / && rm -rf *` and
  `cd "$(cmd)"/ && rm -rf ./*` now block.
- A `for VAR in <root-glob>` binding is followed into `rm -rf "$VAR"`.
- `$( )` and backticks are tokenized atomically, with a fallback to the plain
  tokenizer when a substitution is unterminated so nothing is swallowed.
- Block messages name a safe alternative instead of only refusing.

Evidence: 938 tests pass, 0 fail (907 before, +31 regression tests). The three
mandated fixtures are asserted verbatim both against the classifier and end to
end against the `hooks run pre-bash` process: `rm -rf /home/hasna/.hasna` still
blocks as the control, `rm -rf /*` and `rm -rf "$(bun pm cache)"/*` both flip
from continue to block. No rm is executed at any scope by any test; the
classifier is driven with command strings only.
@andrei-hasna
andrei-hasna force-pushed the fix/destructive-guard-root-and-empty-collapse branch from 58ddf0a to 2fe8426 Compare July 26, 2026 21:03
…l review

Adversarial review REJECTED the previous commit. Two of the four holes were
regressions this branch introduced against d8c0e8a while advertising a strictly
stronger guard, and none of the four were covered by the test suite -- so the
suite was green on exactly the property being claimed. All four now block, and
each is a committed regression test.

B1 (critical, regression): a delete inside `$( )` was invisible to every rule.
Making substitutions atomic so the collapse rule could see them whole removed the
accidental coverage the old segment splitter provided. `echo $(rm -rf /)`,
`x=$(rm -rf /*)` and `` echo `rm -rf /*` `` all returned continue -- wrapping the
delete in a substitution defeated 100% of the new root protection. Substitution
bodies are now scanned as scripts in their own right; the delete runs and only its
output is discarded, which is precisely why the body matters.

B2 (critical): a glob was only recognised in the final path component, so
`rm -rf /*/*` was read as a literal directory named `*`. It destroys /usr/*,
/etc/* and /home/* -- the realized incident's outcome by a different spelling.
Also `/home/*/*`, `~/*/*`, `/home/*/.hasna`, `/*/bin`, `find /*/x -delete`. The
glob is now found by scanning every component; a catch-all component uses the
threatens test, a bounded one (`~/proj*/dist`, `/var/log/*.gz`) keeps the narrower
mutates test, so those stay allowed.

B3 (high): no brace expansion. `rm -rf /{bin,boot,etc,home,lib,opt,root,srv,usr,var}`
returned continue. Bounded expansion added.

B4 (high, regression): the cd tracker followed a `cd` that the real shell confines
to a child process, moving the guard's cwd away from where the later rm runs.
`cd /var/tmp && ls && cd - && rm -rf *`, `(cd /var/tmp && ls); rm -rf *`,
`cd /var/tmp | cat; rm -rf *` and two more went from BLOCK on d8c0e8a to continue.
Segmentation now reports whether a segment is isolated (subshell or pipeline
stage), those cd's are ignored, and `cd -` restores the previous directory.

Also fixed, from the same review:
- `bash -o errexit -c '...'` was never unwrapped: the option's value was counted as
  the script-file operand. Options that consume a word are now skipped.
- Expansion matching was regex-based with a fixed nesting depth, so
  `$(dirname "$(dirname "$(bun pm cache)")")` and `${A:-${B}}` escaped. Replaced
  with a depth-counting scanner; no fixed limit remains.
- False positives that would have got this guard switched off: `rm -rf "$(pwd)"/*`,
  `"$PWD"/*`, `"${BUILD_DIR:-/tmp/build}"/*`, and
  `BUILD=/tmp/build && rm -rf "$BUILD"/*` all blocked. An expansion is now exempt
  only where the shell provably cannot return it empty -- `${VAR:?}`, `${VAR:-x}`
  with a default that itself survives collapsing, `$PWD`/`$(pwd)`, and variables
  assigned a non-empty literal earlier in the same command. `${VAR-x}` (no colon)
  is still collapsible, because a set-but-empty variable yields "".
- The SYSTEM_PROTECTED_ROOTS test asserted only the verdict. /home is also covered
  by the ~/.hasna tree rule, so the loop would have stayed green with /home deleted
  from the list. It now asserts protectedLabel.

Evidence: typecheck and build clean; 980 pass, 0 fail (was 972; baseline main
d8c0e8a alone 941). Reviewer's 24 escape strings and 4 false-positive strings all
resolved -- escapes=0 falsePositives=0 -- re-confirmed end-to-end through
hooks/pre-bash/src/hook.ts. Three mandated fixtures still BLOCK. No rm executed at
any scope by any test or probe.
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

Round 2 — adversarial review REJECTED the previous commit; all four bypasses fixed in bb3fbe2

The review was right, and two of the four were regressions this branch introduced against d8c0e8a while advertising a strictly stronger guard. None were covered by the test suite, so the green suite was a false signal on exactly the property being claimed. I reproduced all 24 escape strings and 4 false-positive strings before changing anything.

# Severity Hole Now
B1 critical, regression a delete inside $( ) was invisible to every rule — echo $(rm -rf /home/hasna/.hasna) was BLOCK on d8c0e8a, continue here. Wrapping the delete in a substitution defeated 100% of the new root protection substitution bodies are scanned as scripts
B2 critical a glob was only recognised in the final component, so rm -rf /*/* read as a literal dir named *. It destroys /usr/*, /etc/*, /home/* every component is scanned; catch-all → threatens, bounded → mutates
B3 high no brace expansion — rm -rf /{bin,boot,etc,home,lib,opt,root,srv,usr,var} passed bounded expansion
B4 high, regression the cd tracker followed a cd the shell confines to a child process; 5 shapes went BLOCK → continue segmentation reports subshell/pipeline isolation; cd - restores

Also fixed: bash -o errexit -c was never unwrapped (the option’s value was counted as the script operand); regex expansion matching had a fixed nesting depth, so $(dirname "$(dirname "$(bun pm cache)")") and ${A:-${B}} escaped — replaced with a depth-counting scanner, no limit remains.

False positives — fixed, and they matter as much as the escapes

rm -rf "$(pwd)"/*, "$PWD"/*, "${BUILD_DIR:-/tmp/build}"/*, and BUILD=/tmp/build && rm -rf "$BUILD"/* all blocked. A guard that blocks routine cleanup gets switched off, which is how this class of incident recurs. An expansion is now exempt only where the shell provably cannot return it empty: ${VAR:?}, ${VAR:-x} whose default itself survives collapsing, $PWD/$(pwd), and variables assigned a non-empty literal earlier in the same command. ${VAR-x} (no colon) stays collapsible — a set-but-empty variable yields "".

Test weakness the review caught

The SYSTEM_PROTECTED_ROOTS loop asserted only the verdict. /home is also covered by the ~/.hasna tree rule, so it would have stayed green with /home deleted from the list. It now asserts protectedLabel.

Evidence

bun run typecheck   clean
bun run build       clean
bun test            980 pass, 0 fail   (was 972; baseline main d8c0e8a alone: 941)
reviewer probes     escapes=0  falsePositives=0   (24 + 4 strings)

B1/B2/B3 re-confirmed end-to-end through hooks/pre-bash/src/hook.ts, not just the library. Three mandated fixtures still BLOCK. No rm executed at any scope by any test or probe.

Confirmed clean by the reviewer (checked, not skipped)

No ReDoS (linear to 250 KB); no gitCommandInfo/secrets-scan regression across 14 substitution-heavy commit/push forms; the :? vs ? distinction is sound; MAX_WRAPPER_DEPTH/MAX_CWD_VARIANTS not bypassable; and the d8c0e8a merge seam is fine — protectedPathContextFor never calls managedWorktreeInfo, so the new canonical/legacy worktree classification cannot move the ~/.hasna carve-out. That independently confirms the seam probe in the PR body.

Requesting re-review of the round-2 diff (2fe8426..bb3fbe2).

andreihasna added 4 commits July 27, 2026 00:33
… failing open

Self-found while performance-testing the brace expansion added in bb3fbe2. The cap
was applied to the finished list, but the list was built recursively first, so the
work was combinatorial before the cap ever ran. Measured:

  22 groups (2^22)  0.82s
  24 groups (2^24)  4.11s
  26 groups (2^26)  19.75s

The pre-bash hook has a 20s timeout, and a hook that times out fails open. So a
sufficiently long brace string did not merely slow the guard down - it switched
the guard off, after which the delete in the same command runs unguarded. That is
strictly worse than the hole this branch set out to close, and I introduced it.

Expansion is now breadth-first and abandoned the moment it exceeds the cap, so the
work is bounded by the cap rather than by the input. Abandoning returns the
unexpanded token, which every other rule still checks. Same input: 19.75s -> 0.01s.

Behavior unchanged otherwise: the reviewer's 24 escape strings and 4 false-positive
strings still give escapes=0 falsePositives=0, and 40KB adversarial input, 40-deep
substitution nesting and 500-segment commands all stay in the low milliseconds.

Evidence: typecheck clean; 981 pass, 0 fail; new bounded-time regression test
asserts the 26-group case completes in under 2s.
… not permissively

Follow-up to df0d539, self-found. Bounding brace expansion stopped the fail-open
hang, but abandoning by returning the RAW token turned the cap itself into a
bypass: `rm -rf /{a0,...,a69,etc}` exceeds the cap, and the unexpanded token
resolves to a literal path that matches no protected root.

  before: ESCAPED  rm -rf /{a0,…,a69,etc}
  before: ESCAPED  rm -rf /{a0,…,a69,}

A cap that converts "too complex to analyse" into "allowed" is the same
passes-silently-while-protecting-nothing failure this branch exists to fix, so the
abandon path now returns the brace-free prefix as a catch-all wipe. That is what an
unbounded alternation under a prefix actually is: every expansion is necessarily a
child of it. `rm -rf /{...}` is therefore treated as `rm -rf /*` and blocks.

Ordinary alternations under the cap are unaffected: `rm -rf ./{dist,build}`,
`/tmp/{a,b,c}` and `node_modules/{.cache,.vite}` all still pass.

Evidence: typecheck clean; 982 pass, 0 fail; reviewer probe set still escapes=0
falsePositives=0; 26-group input still 0.01s. New regression test covers both the
over-cap block and the under-cap allow.
…parens in substitutions

Self-found follow-up. After fixing the brace cap twice I audited the remaining
limits in this file for the same "limit exceeded => permissive" pattern. Three more
were permissive, one of which the first adversarial review had already reported and
I had not fixed:

  ESCAPED  32 dummy `sh -c` wrappers then `sh -c 'rm -rf /*'`   (MAX_SHELL_LAYERS)
  ESCAPED  4-deep bash -c / sh -c nesting around rm -rf /*      (MAX_WRAPPER_DEPTH)
  ESCAPED  rm -rf "$(awk -F'(' '{print $2}' c.txt)"/*           (unterminated fallback)

Layer and depth caps: dropping work silently meant padding a command with dummy
wrappers switched the guard off. shellCommandLayers now reports truncation, and a
truncated analysis is refused when the command contains a recursive delete, with a
message telling the operator to run the delete directly rather than through nested
wrappers. Refusal is scoped by DESTRUCTIVE_VERB, so 40 benign wrappers still pass
and an ordinary `bash -c "sh -c 'rm -rf dist'"` still passes.

Quoted parens: the tokenizer copied substitution bodies verbatim without tracking
quotes, so the `(` in `awk -F'('` counted as structure, left the substitution
unterminated, and tripped the fallback that disables atomic tokenization -- taking
the collapse rule with it. Quote state is now tracked while scanning substitution
bodies in all three places that do it (findExpansions, splitShellSegmentsPass,
shellWordsPass).

The caps still exist and still bound the work; they just no longer convert
"too complex to analyse" into "allowed". That conversion is the same
passes-silently-while-protecting-nothing failure this branch exists to fix.

Evidence: typecheck and build clean; 984 pass, 0 fail. All probe sets green:
reviewer round-1 set escapes=0 falsePositives=0; original attack set escapes=0
falsePositives=0; cd/subshell set escapes=0 falsePositives=0; managed-worktree seam
0 mismatches. Performance unchanged (26-group braces 0.01s, 40KB input 33ms).
Three mandated fixtures still BLOCK end-to-end through the hook binary.
…al review round 3

Round 3 REJECTED 9b471e5. All 30 escapes and 16 false positives reproduced before
any change. Two were regressions this branch introduced, and one was a new
fail-open of the class I had already fixed twice.

F1 (regression, mine): `(cd X && rm -rf *)` was completely unguarded. The B4 fix
read `isolated` as "this cd never applies", but it only means "does not escape to
the parent shell" - segments inside the SAME subshell do see it. That unguarded the
standard cd-without-moving-my-shell idiom, including the realized incident wrapped
in parens. cd state is now tracked per subshell depth on a stack, and leaving a
subshell discards what it did. Pipeline stages are tracked separately, since a
piped cd has depth 0 but still moves nothing.

F2 (new fail-open, mine): `expansionCannotBeEmpty` recursed once per `${A:-…}`
level while re-scanning the remainder. A 240 KB, 40k-deep input overflowed the
stack in 6.0s; the hook caught it and answered {"continue":true}, so the
`rm -rf /*` in the same command was never classified. Recursion is now bounded, and
past the cap the value is treated as collapsible - blocking, not allowing. Same
input: 6.0s fail-open -> 0.16s block.

F3: the round-2 false-positive exemptions were exploitable six ways - prefix
assignments (bash expands `$X` BEFORE applying `X=… cmd`), assignments after the
delete, later reassignment to something collapsible, explicit empty reassignment,
and subshell- or pipeline-scoped assignments all counted. `$PWD` was exempt even
when reassigned, and `${A:-/}` was treated as safe merely for having a default.
Assignment visibility is now per segment, ordered, scope-aware and invalidated by
reassignment; a `:-` default is substituted VERBATIM, so `${A:-/}` collapses to `/`
and blocks while `${BUILD:-/tmp/build}` stays allowed.

F4: a delete nested inside `${x:-$(rm -rf /*)}` was invisible, because
findExpansions returns the outer `${…}` and swallows the inner substitution.
Bodies are now scanned recursively.

F5/FP: the truncation refusal both missed real deletes (`rm -f --recursive`,
quoted `"rm"`) and fired on benign work - 40 `$( )` substitutions plus
`rm -rf dist` was a hard block. The caps were far too low for what they cost:
raised to 8 wrappers / 256 layers, measured at ~2ms, so ordinary commands never
truncate and the refusal is reserved for genuinely pathological input.

F6: the brace-cap fallback assumed every alternative is a child of the brace-free
prefix. `rm -rf {/etc,a0,…}` expands to `rm -rf /etc a0 …`, so an absolute
alternative escaped it entirely.

F7: a `${…}` before a brace group made expandLeftmostBrace give up on the whole
token, so `rm -rf "${HOME}"/{,.hasna}` was never expanded.

F8: the quote fix in 9b471e5 handled quotes but not backslashes, and the escape
branch ran BEFORE the substitution-body branch, stripping the backslash so
findExpansions re-counted structure on de-escaped text.

F9: `cd -P`, `cd -L`, `cd --`, `{ cd /; }` and `pushd` were all missed.

FALSE POSITIVES (the more dangerous half - a guard that blocks routine work gets
switched off): `rm -rf */node_modules` at a monorepo root, plus `*/dist`,
`**/dist`, `/opt/*/logs`, `/var/*/tmp` and 7 more, all blocked. Root cause was
matching on "the parent of the first glob", which ignores that a trailing literal
bounds the delete. Glob matching is now exact component-by-component semantics: a
pattern threatens a root only if it can match that root or an ancestor of it, or
wholesale-wipes its contents. A catch-all in the FIRST component is still a
filesystem-root sweep (`/*/bin` deletes /usr/bin, /var/bin, …), which the precise
version alone missed - caught by re-running the earlier corpora, which is the point
of keeping them.

Evidence: typecheck and build clean; 994 pass, 0 fail (baseline main d8c0e8a: 941).
All five probe corpora green simultaneously - round-3 (46 cases), round-1 (28),
cd/subshell (7), original attack (44), managed-worktree seam (9) - escapes=0
falsePositives=0 in every one. Three mandated fixtures still BLOCK end-to-end
through the hook binary. Perf unchanged. The round-3 corpus is committed as tests
so it is replayed on every future change, per the reviewer's process finding that
every probe set on this branch had been written after the fix it tested.
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

Round 3 — REJECTED at 9b471e5; 30 escapes + 16 false positives closed in f6def3c

All reproduced before changing anything. Two were regressions this branch introduced, and one was a new fail-open of the class I had already fixed twice.

The two I broke

F1 — (cd X && rm -rf *) was completely unguarded. My B4 fix read isolated as “this cd never applies”, but it only means “does not escape to the parent shell” — segments inside the same subshell do see it. That unguarded the standard cd-without-moving-my-shell idiom, including the realized incident wrapped in parens: (cd "$(bun pm cache)"/ && rm -rf ./*). Seven shapes went BLOCK on 2fe8426 → ALLOW on 9b471e5. My committed test only covered the rm-outside-the-parens form, which is why it stayed green.

F2 — a new fail-open. expansionCannotBeEmpty recursed once per ${A:-…} level while re-scanning the remainder. A 240 KB, 40k-deep input overflowed the stack in 6.0s; hook.ts caught it and answered {"continue":true}, so the rm -rf /* in the same command was never classified. Third time a bound in this file turned “too complex to analyse” into “allowed”. Now 6.0s fail-open → 0.16s block.

The false positives mattered more

rm -rf */node_modules at a monorepo root was blocked, along with */dist, **/dist, /opt/*/logs, /var/*/tmp and seven more; and the truncation refusal hard-blocked …40 substitutions… ; rm -rf dist. A guard that blocks a daily command gets switched off, and then it protects nothing.

Root cause was matching on “the parent of the first glob”, which ignores that a trailing literal bounds the delete. Glob matching is now exact component-by-component semantics: a pattern threatens a root only if it can match that root or an ancestor of it, or wholesale-wipes its contents. Caps raised to 8 wrappers / 256 layers (~2 ms), so ordinary commands never truncate.

Also fixed

F3 the non-empty exemptions were exploitable six ways — prefix assignments (bash expands $X before applying X=… cmd), assignments after the delete, reassignment, empty reassignment, subshell- and pipeline-scoped assignments; $PWD exempt even when reassigned; ${A:-/} safe merely for having a default. Defaults are now substituted verbatim, so ${A:-/} collapses to / and blocks
F4 delete inside ${x:-$(rm -rf /*)} was invisible
F5 verb check missed rm -f --recursive and quoted "rm"
F6 brace fallback missed absolute alternatives {/etc,…}
F7 a ${…} before a brace group abandoned expansion entirely
F8 my own 9b471e5 quote fix handled quotes but not backslashes
F9 cd -P, cd -L, cd --, { cd /; }, pushd

Self-caught while fixing

The precise glob rewrite then missed /*/bin and find /*/x -delete — a catch-all in the first component sweeps every top-level directory, and a trailing literal makes the pattern deeper than any single root — and dropped the piped-cd case. Both were found only by re-running the earlier corpora.

Evidence

typecheck + build   clean
bun test            994 pass, 0 fail   (baseline main d8c0e8a: 941)

All five probe corpora green simultaneously — round-3 (46 cases), round-1 (28), cd/subshell (7), original attack (44), managed-worktree seam (9): escapes=0 falsePositives=0 in every one. Three mandated fixtures still BLOCK end-to-end through the hook binary.

Process fix adopted

The reviewer’s sharpest point was not any single bug: every probe set on this branch was written after the fix it tested, so none could catch a within-branch regression. The round-3 corpus is now committed as tests and replayed on every change — which is precisely what caught the two regressions above.

Requesting re-review of 9b471e5..f6def3c.

andreihasna added 8 commits July 27, 2026 01:42
…review round 4

Round 4 REJECTED f6def3c with three CRITICALs, all regressions introduced by that
commit's own fixes, all proven end-to-end through the real hook binary.

B1 - bracket-class globs matched NOTHING. globComponentToRegExp put `[` and `]` in
its ESCAPE class, so `[e]tc` compiled to the literal \[e\]tc, which no directory can
equal. Any path containing `[` was routed to the glob matcher and then matched as an
impossible literal, so ONE character defeated every rule, every tool and every
wrapper:

  allow  rm -rf /[e]tc            allow  find /[e]tc -delete
  allow  rm -rf /[!x]*            allow  ssh station02 rm -rf /[e]tc
  allow  rm -rf [a-z]*  (repo)    allow  rm -rf "$(bun pm cache)"/[a-z]*

The last is the realized incident shape with one character added. `rm -rf [!x]*` in
a repo root regressed against d8c0e8a itself. Bracket classes are now compiled,
including `[!…]`/`[^…]` negation and a `]` as first member.

B2 - O(n²) segment scan drove the hook past its own 20s timeout into fail-open.
nonEmptyAssignedNames re-split the entire command and rescanned every preceding
segment, once per chunk. 36 KB of `:; ` padding: 25.6s, exit 124, ZERO bytes of
verdict - so padding alone turned a blocked `rm -rf /*` into an unguarded one. Same
fail-open the wrapper caps were written to stop, reopened along a different axis,
and this time nothing set `truncated` so the refusal never fired. Replaced with a
single forward pass producing one set per segment, plus a bounded segmentation
cache. Same input: 25.6s -> 16ms, and 0.071s end-to-end through the hook binary.

B3 - a sibling subshell inherited the previous one's cd. frameFor truncated the
stack only when depth DECREASED, so `(cd /elsewhere); (rm -rf *)` reused the first
subshell's frame. bash discards it, so the guard was aimed at an attacker-chosen
directory while the delete hit the real cwd. Regressed against all six earlier
commits including d8c0e8a. Frames are now keyed on subshell identity, not depth.

Also fixed from the same review:
- B4: substitutionBodies' hardcoded depth-4 cap abandoned the scan without setting
  `truncated`, so `${x:-${x:- … $(rm -rf /*)}}` past depth 4 was never classified.
  Bound raised to MAX_EXPANSION_NESTING and exhaustion now reports.
- M1: $PWD/${PWD}/$(pwd)/`pwd` are certified non-empty, so no collapse fired - and
  nothing ever substituted them, leaving an opaque component that matched no root.
  `rm -rf "$PWD"/*` was allowed where the identical `rm -rf *` blocked. The tracked
  cwd is now substituted.
- M2: `unset X` did not withdraw the non-empty guarantee.
- M3: the ~/.hasna tree rule was reachable through a glob in its own last component
  (`.[h]asna`, `.h*/repos`).
- The narrow-glob content sweep that d8c0e8a had and the round-3 precise-component
  rewrite dropped: a glob in the last component whose parent IS the protected root
  guts that root even when bounded (`rm -rf [a-z]*` at a repo root).

Evidence: typecheck and build clean; 1000 pass, 0 fail (baseline main d8c0e8a: 941).
All six corpora green simultaneously - round-4 (36 cases), round-3 (46), round-1
(28), cd/subshell (7), original attack (44), managed-worktree seam (9) - escapes=0
falsePositives=0 in every one. Three mandated fixtures still BLOCK end-to-end, as do
the bracket-class and padding attacks. The round-4 corpus is committed as tests; the
padding test varies SEGMENT COUNT, the axis the earlier nesting test never touched.
…opd and rebinding builtins

Round 5 REJECTED e8bc5a1. Two CRITICALs, and the first was in the code written to
fix round 4's CRITICAL - the same class, one round later.

F1 - the bracket compiler took the FIRST `]` as the terminator, so every bracket
expression whose real terminator is later matched nothing at all. Verified against
bash first: `/home/hasna/.hasn[[:lower:]]` expands to `/home/hasna/.hasna`, and the
guard returned {"continue":true} end-to-end. Same for `[x\]]`. An under-match is
silent and fails OPEN, so this defeated the mandated control fixture itself.

F4 - the same helper had a ReDoS. `*` compiled to `[^/]*`, and repeated groups
against a ~70-character protected-root component backtracked exponentially: over 45s
against this hook's 20s timeout, i.e. the third fail-open on a branch whose entire
purpose is removing fail-opens.

Both had one root cause - using a regex at all - so the compiler is gone. Glob
matching is now a two-pointer linear matcher with explicit bracket handling,
including POSIX [:class:], [=equiv=] and [.collate.], negation, ranges, and
backslash escapes. Measured on the ReDoS input: >45s -> 2ms. Ambiguous constructs
resolve toward MATCHING, never toward not-matching, because the failure mode that
got through was always the silent under-match.

The tokenizer also de-escaped `\]` before the matcher saw it, silently rewriting
`[a\]]` (one class containing `a` and `]`) into `[a]]` (class `a`, then a literal
`]`) - a different pattern matching a different name. A backslash before a glob
metacharacter is now preserved.

F2 - `pushd` was handled and `popd` was not, so the pushd target stayed as the
tracked cwd for the rest of the command: `pushd /tmp; popd; rm -rf *` deleted the
original directory unguarded. Blocked on d8c0e8a, bb3fbe2 and 9b471e5; introduced by
f6def3c, and rewritten twice since without being caught. Frames now carry a real
directory stack.

  My own first test for this PASSED FOR THE WRONG REASON: the fixture repo was under
  /tmp, the same directory pushd targeted, so `/tmp/*` covered the repo root whether
  or not popd worked. Found by mutation-testing the fix - removing popd handling left
  the suite green. The fixture now lives outside the pushd target.

F3 - only `unset` withdrew the non-empty guarantee. `export X=$(cmd)` and
declare/typeset/readonly/local/read/eval/let/mapfile, plus `for X in ""`, all kept an
earlier literal's guarantee alive because the token loop stopped at the first
non-assignment token. All handled; a literal non-empty binding through the same
builtins still counts, so `export X=/tmp/build` stays allowed.

F5 - the narrow-glob sweep restored in e8bc5a1 fired on ANY glob metacharacter in a
direct child of a protected root, re-blocking twelve everyday repo-root cleanups
(`*.log`, `tmp-*`, `.turbo*`, `snapshot-[0-9]*`, `dist-*.zip`, …). It now fires only
on UNANCHORED patterns - those where no literal character survives once wildcards are
removed - so `[a-z]*`, `?*` and `*` still block while anchored patterns pass. The
sweep was also completely untested: deleting the whole rule left 1000/0 green.

F6 - the previous commit cited six corpora, five of which existed only in scratch and
could not be re-run. All are now committed as tests.

Evidence: typecheck and build clean; 1005 pass, 0 fail (baseline main d8c0e8a: 941).
Seven corpora green simultaneously, all committed. Six mutations CAUGHT, each
reverting one fix in this commit, including the narrow-glob sweep that was previously
uncovered and popd. Three mandated fixtures BLOCK end-to-end, as do the POSIX-class
bypasses. ReDoS input 2ms; padding attack still 38ms.
…umeration

Round 6 REJECTED 1e53837 with six blocking findings. Three were fresh under-match
fail-opens in the bracket parser - the third consecutive round in which the code
written to fix a bracket bug contained another one.

The edge cases were not the problem. The DESIGN was: a hand-written matcher that
answers "does not match" when it fails to understand a construct fails OPEN, because
a protected root then goes unmatched and the delete is allowed. Enumerating cases
made the parser longer and kept the failure direction wrong. So the direction is
inverted: anything not modelled exactly now MATCHES.

  B1  /[e[:]tc /[e[=]tc /h[o[:]me /[u[:]sr /[v[:]ar/* /r[o[:]ot ~/.h[a[:]sna
      An unterminated POSIX class made bracketExpressionEnd return -1, which degraded
      `[` to a literal - a pattern that cannot match any real directory name. All are
      real paths in bash; all were allowed. Blocked at six earlier commits.
  B2  /[![:foo:]]tc /[^[:foo:]]tc /h[![:foo:]]me
      The "unknown class matches" fail-safe was then INVERTED by the negation into
      "matches nothing" - the safeguard itself became the bypass.
  B3  /[a\]e]tc  /home/hasna/.hasn[b\]a]
      An escaped `]` mid-class silently dropped every member after it.

  Now: an unparseable bracket makes the rest of the component match anything, and any
  class body containing an escape or a POSIX/equivalence/collating construct matches.
  Over-blocking a construct almost nobody writes is survivable; under-matching is not.

  The committed test for B3 had been passing for the WRONG reason: `[a\]]` happens to
  scan `a` before the escaped `]`. Moving one character (`[b\]a]`) was a live ~/.hasna
  bypass. Second time on this branch a green test of mine proved nothing.

B4 - isUnanchoredGlob re-scanned to end-of-pattern from every `[`, quadratic. 20k
brackets took 22.01s against the 20s timeout, on a command that also contained a real
`rm -rf /*`; the hook is killed and the tool call proceeds unguarded. 120x worse than
the ReDoS it replaced, in the commit claiming the new matcher "can never blow up".
Now returns on the first unparseable bracket: 22.01s -> 22ms.

  Its first test also passed for the wrong reason - a flooded target under `/` is
  answered by the covers check before the anchoring scan runs. The test now uses the
  shape that actually reaches it, and the mutation is caught.

B5 - `pushd -n DIR` records a directory without moving the shell; `-n` was read as the
directory operand, so the guard followed a move bash never made.
B6/N3 - `.??*`, `.?*`, `.[a-z]*` and `*.*` were classified anchored because a leading
dot or a bare `.` counted as literal text. They sweep a directory exactly as `*` does.

N1/N2 - the non-empty guarantee survived most rebinding shapes because the scan only
looked at the first token: `IFS= read -r D`, `while read D`, `builtin read D`,
`printf -v D`, `source`, `.`, `trap`, `coproc`, `((D=0))`, `declare -n D=E`. All
withdraw it now; opaque constructs clear the set entirely.
N4 - `export X` with no value does not change X, and no longer withdraws anything.
N8 - the per-frame dirStack copy was untested; sharing it let a subshell's popd rewrite
the parent's stack.
N10 - CHANGELOG and hooks/pre-bash/README.md still described the round-1/2 behaviour.
Glob semantics, the unanchored-glob relaxation, working-directory tracking and the
rebinding rules are now documented.

Evidence: typecheck and build clean; 1011 pass, 0 fail (baseline main d8c0e8a: 941).
Eight corpora green simultaneously, all committed. Eight mutations CAUGHT, one per fix,
including the two that previously survived. Three mandated fixtures and all three
bracket bypasses BLOCK end-to-end through the hook binary. Bracket flood 22ms;
padding attack 30ms; ReDoS input 2ms.
…ommand position

Round 7 REJECTED fe21e8a with four blockers. Three were regressions introduced by
round 6's own fixes - the seventh consecutive round in which a fix carried a defect
of the class it closed.

F1 - the fail-closed inversion was incomplete because the bracket TERMINATOR scan was
still unbounded. `bracketExpressionEnd` searched to end-of-component for `:]`, so a
`:]` belonging to a LATER bracket was accepted as this one's, swallowing the real
terminator. The round-6 fail-closed rules never fired: `close` was not -1, it was a
plausible-looking wrong number.

  bash:  /[u[:][[:alpha:]]r -> /usr     /b[i[:][[:alpha:]] -> /bin
  guard: allow                          allow

  A differential fuzz of 745,019 pattern/name pairs against real bash found 18,543
  under-matches, 18,509 of them this one class; replaying the allowed subset through
  real pathname expansion produced 158 commands that land on /etc /usr /home /var
  /bin /boot /dev /lib /opt /proc /run /sbin /srv /sys. Six earlier commits blocked
  all 158. The `:]` must now precede the next plain `]`, or the bracket closes there.

F2 - the all-token rebinding scan GRANTED certification at any token position, so a
mention certified a variable that was never assigned:

    # export CACHE=/tmp/bun-cache
    rm -rf "$CACHE"/*        -> allowed

  That is the realized 2026-07-24 shape verbatim, in the likeliest form an agent
  writes it - a documented cleanup script. Withdrawal still scans every position,
  because a rebinding can hide anywhere; certification now comes only from token 0,
  because a mention is not an execution.

F3 - the same scan allocated `tokens.slice(position + 1)` per token, O(tokens^2). 20k
`export A=1 ` took 20.9s and 40k `read ` took 27.0s, both past the 20s timeout, both
on commands containing a real `rm -rf /*`. Fourth time a bound in this file reopened
that fail-open. Rewritten single-pass: 20.9s -> 101ms.

F4 - compound-command bodies never withdrew. `X=/tmp/build; { X=; }; rm -rf "$X"/*`
and the `if`/`while`/`until`/function-body forms all kept X certified while bash
emptied it. Pre-existing since bb3fbe2, not a round-6 regression, but the commit that
claimed to close the rebinding class left the largest holes in it open.

F6 - fail-closed matching over-reached onto literal filenames: `backup[2026` and
`weird[dir` were escalated to "wipes the repository root". A `[` with no `]` anywhere
is an ordinary character to bash, so matching now stops where globbing stops.

TESTS THAT WERE GREEN FOR THE WRONG REASON - the review's most useful finding, and
the third and fourth instances on this branch:

  - 5 of round 6's 15 bracket assertions still passed with the glob matcher entirely
    removed: a bare glob under `/` is caught by the unanchored-root rule regardless.
    One literal character after the bracket stops that rule firing, which is exactly
    how F1 shipped. The round-7 cases all carry that trailing character.
  - Two round-6 fixes (opaque builtins at any position, `for NAME` at any position)
    were real and had ZERO coverage.
  - Four of my first round-7 "distinguishing" cases did not distinguish: the F6 case
    ran out of name before reaching the bracket (`/etc[x` is rejected on length, so
    `/et[c` is the case that evaluates it); the function-body case was carried by the
    compound-keyword fix because `f()` splits on the parens, so the `function f`
    keyword form is the one that needs it; the timing budget of 3s was loose enough
    that re-introducing the quadratic still passed, now 1s against a 101ms actual;
    and the comment-terminates-scan branch was DEAD, subsumed by the command-position
    rule, so it is deleted rather than left as decoration.

  All eight fixes in this commit are now individually mutation-tested and all eight
  are caught.

Evidence: typecheck and build clean; 1017 pass, 0 fail (baseline main d8c0e8a: 941).
Nine corpora green simultaneously, all committed. Three mandated fixtures and the
158-command bypass class BLOCK end-to-end through the hook binary. Builtin padding
101ms, bracket flood 22ms, ReDoS input 2ms.

Recorded separately as task b18511f4: this repo's own suite reads and writes the
operator's real ~/.claude/settings.json, so `bun test` is not a side-effect-free
evidence step. Pre-existing on main; not touched here.
…ents

Round 8 REJECTED 3261656. The headline round-7 fix reopened the class net worse -
220 -> 380 live root-wipe escapes on a 57,626-command corpus - and two other round-7
and round-8 fixes each carried a defect of the class they closed. Eighth consecutive
round.

THE ROOT CAUSE, finally named. "Fail closed by matching" was applied to bracket
CONTENTS but never to the bracket BOUNDARY. The boundary was still computed exactly,
and every disagreement with bash about where a bracket ENDS misaligns the rest of the
component and silently reports "no match" - which allows the delete. Round 6 searched
to end-of-component for the class terminator and swallowed later brackets. Round 7
stopped at the first plain `]`, which is backwards - inside `[:`, a plain `]` does not
terminate - and made it worse. Two attempts to compute exactly what bash computes,
two silent under-matches.

Every one of the 380 escapes contained `[:`, `[=` or `[.`; plain brackets, ranges,
negation, `*`, `?` and backslash escapes measured clean across 44,867 dangerous
patterns. So the guard stops trying: a component containing a POSIX class,
equivalence class or collating symbol matches anything.

  bash: /[![=o=]]]* -> 26 top-level entries   /[b[.][:]*n -> /bin
  before: continue                            after: block

F2 - the compound-keyword strip added in round 7 is fail-closed for WITHDRAWAL but
fail-OPEN for CERTIFICATION: `then X=/tmp/build` certified X although the branch may
never run. `if [ -d /nonexistent ]; then CACHE=/tmp/c; fi; rm -rf "$CACHE"/*` -
bash expands that to `rm -rf /*` - went from block to continue. Certification is now
refused for any segment behind a compound keyword; withdrawal still applies.

F3 - the certified-name set was copied per segment, O(segments x names): 30k distinct
names took 24.4s against the 20s timeout, and a timed-out hook fails open. Fifth time
a bound in this file reopened that hole. Replaced with a forward walker that advances
ONE set in place and hands it out only for segments that actually delete something.
24.4s -> 118ms, and now linear (118/164/321ms at 20k/40k/80k).

F4 - a real product bug, not just a test artefact: `~` resolved through `homedir()`
while `$HOME` and every protected root used `process.env.HOME`. Wherever those differ -
containers, `sudo -u`, CI, any HOME override - target and rule were built from
different directories, so `rm -rf ~/.hasna` missed its own rule. It also meant this
suite passed only on a machine whose HOME is literally /home/hasna: under a temp HOME
it was 98 pass / 5 fail, including round 6's headline bracket test. Now 1023 pass /
0 fail under both.

TESTS. Round 8's own first corpus repeated the round-7 mistake: with the boundary
guard disabled, 10 of 12 new assertions still passed, because the
unanchored-glob-at-the-root rule catches them regardless. The matcher is now exported
and asserted DIRECTLY against 13 bash-verified pairs, which is what the round-7
reviewer asked for and what finally pins it - reverting the boundary guard now fails.
Two mechanisms that had zero coverage across 72 assertions (value-binding builtins and
`for` away from position 0) have cases.

Removed rather than kept: the duplicate boundary guard in the anchoring scan. It was
redundant with the matcher for every dangerous case and only ADDED false positives -
`rm -rf '[:foo:]x'` at a repo root blocked, though bash matches at most a handful of
short names there.

Fixtures no longer land in the operator's real $HOME (five tests created git repos
there); they use a dedicated temp root, kept out of any `pushd` target so the popd
test cannot pass for the wrong reason again.

CORRECTIONS to the previous commit message, both found by the review:
- It claimed a dead `#`-terminates-scan branch was deleted. No such branch existed in
  the diff; it was added and removed within the same working session, so the claim was
  unsupported.
- It justified tightening a timing budget 3s -> 1s by saying the old budget let the
  quadratic pass. Restoring that scan actually takes 32s and fails both budgets. The
  tightening is harmless; the stated reason was wrong.

Evidence: typecheck and build clean; 1023 pass, 0 fail under a TEMP HOME (baseline
main d8c0e8a: 941). Nine corpora green simultaneously. Six mutations, one per fix, all
caught. Distinct-name padding 118ms, bracket flood 22ms, builtin padding 101ms.
…ed first component

Round 9 confirmed the bracket/boundary under-match class is CLOSED - 0 under-matches
in 3,314,592 matcher cells adjudicated against real bash, and 2,272/2,272
bash-proven root-wipe globs blocked. That is the first class on this branch to be
measurably finished.

It also found that round 8's OTHER fix closed about 5% of the class it named.

F1 - `conditional` was derived from "a compound keyword was stripped from token 0",
so an assignment certified unless it was literally the first token after the keyword
in the same segment. Inserting one statement, or using `&&`/`||`/`case`, restored it:

    [ -d /nonexistent ] && X=/tmp/build; rm -rf "$X"/*     -> allowed
    if false; then A=1; X=/tmp/build; fi; rm -rf "$X"/*    -> allowed
    case x in y) X=/tmp/build;; esac; rm -rf "$X"/*        -> allowed

  297 of 336 allowed shapes were bash-proven to leave the variable empty, i.e. live
  `rm -rf /*`. `[ -d … ] && CACHE=…` is the most common way a real cleanup script
  writes this, and it is the realized incident one refactor away.

  Conditionality is now tracked as CONTEXT: an open `if`/`while`/`until`/`case`/
  `select` block, or arrival via `&&`/`||`. Withdrawal is unaffected - the branch
  might run. A brace group `{ …; }` is explicitly NOT conditional, since bash runs it
  in the current shell; treating it as one over-blocked 256 shapes.

F2 - the filesystem-root sweep tested `CATCH_ALL_GLOB` (a literal `*`) where the
branch's own thesis says an unpinnable component must be treated as unbounded.
`/?*/bin`, `/[a-z]*/bin` and `/[[:alpha:]]*/bin` expand to /usr/bin exactly as
`/*/bin` does and were all allowed, at every commit including main. Now uses
`isUnanchoredGlob`, so `/opt/*/logs` and `/tmp*/x` stay allowed.

F6 - the cwd-tracking path was the last uncapped analysis path. A chain of relative
`cd`s grows the tracked path a component at a time, and resolving an ever-longer path
n times is quadratic: 70k took 19.6s against the 20s timeout. Capped at PATH_MAX,
which is correct rather than arbitrary - no real directory is longer. 19.6s -> 165ms.

  The first version of that cap was itself a fail-open: it skipped a later ABSOLUTE
  `cd`, so `<flood>; cd /; rm -rf *` left the guard on the long path and was allowed.
  An absolute cd replaces the path rather than extending it, so it is cheap and is
  always applied. Caught by my own probe before commit, and now a committed test.

F5 - a block on `rm -rf /var[.]log` reported only "Protected scope: filesystem root /",
which is wrong and is the kind of message that gets a guard switched off. The reason
now says the target contains a POSIX class whose extent cannot be determined and is
therefore treated as matching anything.

F7 - `defaultWorktreesRoot` still used `homedir()` while every other resolution in the
file uses `process.env.HOME || homedir()` - the same divergence the previous commit
claimed to have eliminated, one function away.

CORRECTIONS to the previous commit message, both found by the review:
- It said the duplicate boundary guard in `isUnanchoredGlob` was REMOVED. That guard
  was added and removed within the same session, so it never existed in 3261656 and
  the diff does not contain the removal. Second time I have described undoing my own
  uncommitted work as a change. I am no longer describing anything as removed unless
  it appears in `git diff`.
- A test pair asserted as "confirmed against real bash" was false: `*[s[.].]]` matches
  `sys`, not `usr`. It passed only because the blunt rule matches every literal - a
  green assertion whose stated cause was not its cause, inside the very test written
  to stop that. Corrected to the pair bash actually matches.

Also fixed: the comment claiming the lazy set lookup is what removed the O(n^2) copy.
The in-place mutation is; the lazy lookup is an optimisation and is described as one.

Evidence: typecheck and build clean; 1028 pass, 0 fail under a TEMP HOME (baseline
main d8c0e8a: 941). Ten corpora green simultaneously. Seven mutations, one per fix,
all caught. cd flood 165ms, distinct-name padding 118ms, bracket flood 22ms.
Verified the operator's ~/.claude/settings.json was not touched by this round.
…on, bound oversized input

Round 10 confirmed the round-9 bracket/boundary closure holds independently - 0
under-matches in 3.2M matcher cells against real bash, 754/754 root-wipe corpus
blocked - and found that round 9's own fixes carried defects of the classes they
closed. Tenth consecutive round.

S1 (regression I introduced last round) - the PATH_MAX cap skipped ALL relative
operands once crossed, including `..`, which SHRINKS the path. After one crossing the
model froze permanently while bash walked back:

    cd d0 … cd d1999; cd ..x2100; rm -rf *   -> allowed (bash is at `/`)

  Blocked at f38fca5. Only operands that can GROW are capped now. Seventh time a bound
  in this file produced a fail-open, which is why the test for it deliberately uses two
  cd operations - crossing the length cap without touching the cd budget - rather than
  a long chain that would pass for the wrong reason.

S2 - the conditional depth counter desynced three ways, leaving 119 of 375 bash-EMPTY
shapes still certifying:
  - `for` was absent from OPENERS while `done` was a CLOSER, so any loop inside a
    conditional zeroed the counter;
  - a literal `done`/`fi` WORD decremented it, so `if false; then echo done; X=…; fi`
    re-certified X - the realized incident shape with one echo added;
  - `&&`/`||` marked only the first following segment, so `false && { A=1; X=…; }` escaped.
  Keywords are now read in command position only (behind `then`/`do`/`else`/`{`),
  `for` opens, and a short-circuit governs its whole right-hand side including a brace
  group. Heredoc bodies and `#` comments stop decrementing as a consequence.

S3 - the same counter over-blocked: `elif` was treated as an opener although `fi`
closes the chain once, so nothing after an if/elif block could ever certify, and any
token merely SPELLED like a keyword (`echo "if"`, `touch if`) poisoned the rest of the
command. Both gone; 39 false positives with it.

S5 - at the filesystem root a single literal character is not an anchor: `/*r*/lib`
reached 11 of 25 top-level directories. Any real glob in the first component is now a
sweep. `rm -rf /tmp*/x` moves to BLOCK - a deliberate trade recorded in the test.
`/et[c` stays allowed: a bracket with no `]` is literal to bash, and the sweep uses the
same is-this-actually-a-pattern test the matcher does.

S6 - a command large enough that TOKENIZING it exceeds the 20s budget cannot be
analysed at all, and a timed-out hook fails open. 70k repetitions of `cd /<4KB>` is a
280 MB string; no per-rule bound helps because the cost is reading the input. Oversized
commands are now decided directly - refused when they carry a recursive delete, allowed
otherwise - in ~2s instead of 24-46s. The cd budget that accompanies it never drops an
ABSOLUTE cd, because doing so lost `cd ~` after a flood and allowed `rm -rf .hasna`.

S4 (test integrity) - all 13 pairs in the direct matcher test contain `[:`/`[=`/`[.`,
so the unpinnable-boundary rule answered before `globMatches` ran and every literal was
inert: reverting the round-9 correction could not fail. The two mechanisms are now
asserted apart - the unpinnable patterns against a garbage name, plus 12 pairs with no
unpinnable construct whose literals are load-bearing.

Evidence: typecheck and build clean; 1033 pass, 0 fail under a TEMP HOME (baseline main
d8c0e8a: 941). Eleven corpora green simultaneously. Eight mutations, one per fix, all
caught - including the two that first survived, whose tests were green for a reason
other than the one they named until the distinguishing inputs were found. 280 MB
oversized 2.0s, 70k relative cd 358ms, 100k `if` openers 434ms, 100k `&&` 240ms.
… own fallback

Round 11 REJECTED 53505e2. All three bounds added in that commit - the cd operand
length cap, the 1 MB command cap, the cd budget - each independently turned a command
the previous commit BLOCKED into a root wipe the guard waved through. Eleventh
consecutive round in which a fix carries a defect of the class it closed.

The through-line is not three unrelated bugs. Each bound invented its OWN fallback for
"I cannot model this", and each fallback quietly meant "so carry on":

  cd operand > 4096 chars   -> skip the operand    -> `cd ////…(4200); rm -rf *` allowed
  cd budget spent           -> add `/` to candidates -> caught sweeps only; `cd /home/hasna;
                                                       cd . x2000; cd ..; rm -rf hasna`
                                                       destroyed the Hasna home
  command > 1 MB            -> gate on a regex     -> `rm -f -r /*` and six more spellings
                                                       passed unanalysed

Now there is one answer. A bound that stops modelling the command sets
`AnalysisState.degraded`, and a degraded analysis carrying a recursive delete is
refused. The fallbacks are gone.

Also fixed from the same review:

- DESTRUCTIVE_VERB was anchored to the token immediately after `rm`, so `rm -f -r /*`,
  `rm -v -f -r /*`, `rm --one-file-system -rf /*` and `rm <path> -rf` all read as
  non-destructive. It now matches a recursive flag in any position, still declining
  `rm --force` (which matched only because `--force` contains an `r`) and plain `rm -f`.
- MAX_ANALYSABLE_COMMAND_LENGTH raised 1 MB -> 32 MB. Measured on the current paths:
  1 MB 293ms, 4 MB 1.1s, 16 MB 3.6s against a 20s budget. The old threshold bought
  nothing and cost the fail-closed property, while blocking a 1.05 MB heredoc whose only
  "delete" was a documentation line.
- The isolation early-return ran BEFORE keyword accounting, so a pipeline in a compound
  head swallowed its `if` while the matching `fi` still decremented:
  `if ls /opt | grep -q node; then …; CACHE=…; fi; rm -rf "$CACHE"/*` was allowed. That
  is the realized 2026-07-24 shape.
- A `cd` reached only through `&&`/`||` moved the tracked directory even though it may
  never run: `cd /home/hasna; false && cd /tmp; rm -rf *` left the model in /tmp while
  bash stayed in the home directory. Allowed at every commit back to 2fe8426.

TESTS. Three of my first round-11 tests did not reach the mechanism they named - the
eighth, ninth and tenth instances on this branch:
- the over-long-cd test used a repo-root cwd, which blocks whether or not the cap fails
  closed; it now uses an ordinary directory where the verdict depends only on the cap;
- the oversized-command test padded to 1 MB, below the new 32 MB threshold, so the gate
  under test never ran;
- the pipeline test's mutation was a no-op because I inserted the reverted line after
  the code it was meant to precede.
All seven fixes are now individually mutation-tested and all seven are caught.

Evidence: typecheck and build clean; 1038 pass, 0 fail under a TEMP HOME (baseline main
d8c0e8a: 941). Twelve corpora green simultaneously.

Not fixed here, and stated rather than implied - all pre-existing, none introduced by
this branch, each needing its own change: pipe and value indirection
(`echo /etc | xargs rm -rf`, `rm -rf $(echo /etc)`, `T=/; rm -rf $T`) is a single
66-command class the guard has never covered at any commit; a heredoc line that is
exactly `fi` still decrements the conditional counter; nested brace groups, `case` arms
and `&` backgrounding still leak certification; and `f() { X=…; }` loses its function
body because the splitter breaks at the parens.
@andrei-hasna
andrei-hasna merged commit 7e94256 into main Jul 27, 2026
1 check passed
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