fix(pre-bash,worktree-guard): block filesystem-root wipes and empty-collapse expansions - #10
Conversation
…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.
58ddf0a to
2fe8426
Compare
…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.
Round 2 — adversarial review REJECTED the previous commit; all four bypasses fixed in
|
| # | 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).
… 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.
Round 3 — REJECTED at
|
| 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.
…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.
The incident this prevents
On 2026-07-24 a subagent composed this at inference time and ssh'd it to station02:
bun pm cachewrites its path to stdout on success, but exits 1 with an error on stderrand nothing on stdout when no
package.jsonis found walking up from cwd. Thesubstitution collapsed to empty, so the command ran as
rm -rf /*. Unprivileged, ~5minutes, ~700 GB freed, org repo checkouts on station02 destroyed. One repository's source
(
hasna/cloud) is permanently gone. The agent never saw it: a localtimeout 150killed thessh client, the tool result was
Exit code 143, and it retried without therm.$(bun pm cache)with or without2>/dev/nullis equally dangerous — the redirectdiscards the diagnostic, not the path. The precondition is "no
package.jsonwalking up fromcwd", not any bun version. 13 of 16 stations reproduce it.
Why the existing guard was not the fix
The
pre-bashdestructive-shell guard was scoped to~/.hasna, workspace roots and reporoots. Measured on
origin/main@2e654bc, drivinghooks/pre-bash/src/hook.tswithcommand strings:
The baseline is wider than reported. These were also unguarded:
Enabling that guard unpatched is not remediation. It protects
~/.hasnawhile/stayswide open, and says so in its own message.
Three root causes in
hooks/codewith-native-common.ts:protectedPathContextFor()had no filesystem or system roots at all.threatensRule()checked a glob content-wipe base withmutatesProtectedPath(), whichrequires the target to be inside the root. So
dir/*never matched a protected rootliving under
dir. That asymmetry is exactly whyrm -rf /blocked (via the~/.hasnatree rule) and
rm -rf /*did not.shellWords()/splitShellSegments()did not treat$( )or backticks as atomic, andnothing unwrapped
bash -c/ssh. The realized incident's own text was invisible to thermscanner — 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 inrootmode, so a wholesale wipe of a root or its contents blocks while a targeted delete beneath
one still passes:
/tmpis deliberately excluded — scratch cleanup there is routine and bounded. Extend permachine 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.
Two deliberate non-blocks, both justified rather than incidental:
rm -rf "$(cmd)", no trailing separator — allowed. An empty expansion makes thisrm -rf "", which POSIXrmrejects with "cannot remove ''" and a non-zero exit, deletingnothing. The entire catastrophic class is the trailing separator, which turns the empty
string into
/. Blocking the bare form would break routinerm -rf "$tmpdir"cleanup forzero 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 whenthe 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?}withoutthe colon permits an empty value and is not exempt.
Supporting fixes — each one was an escape found by an adversarial pass
dir/*is now matched against protected rootsnested under
dir. Narrower globs (dir/build-*) keep their previous weaker check, sorm -rf ~/proj*stays allowed whilerm -rf ~/*blocks.bash -cinsidessh: bash/sh/zsh/dash/ksh-c,su -c,runuser -c,eval, andssh host '…', including nested combinations, to depth 3. Remote layers only considerabsolute targets, because a remote relative path cannot be resolved against the local cwd
(so
ssh host 'rm -rf .'is not a false positive).cdtracking —cd / && rm -rf *blocks, and so doescd "$(cmd)"/ && rm -rf ./*,which is the incident's shape moved one command to the left.
for VAR in <root-glob>is followed intorm -rf "$VAR". Only exact$VAR/${VAR}targets bound by a
for … inin the same layer are substituted, so it cannot misfire.$( )and backticks are atomic, with a fallback to the plain tokenizer whena substitution is unterminated so nothing is swallowed.
Evidence — failing on bad input, not just passing on good
Full suite: 972 pass, 0 fail (baseline
main@d8c0e8aalone: 941 pass, 0 fail — +31regression tests).
bun run typecheckandbun run buildclean.The three mandated fixtures are asserted verbatim, both against the classifier
(
hooks/codewith-native-common.test.ts) and end to end against thehooks run pre-bashprocess (
src/hooks/codewith-native.test.ts), because the emitted JSON is what actually stopsthe tool call.
Driving
hooks/pre-bash/src/hook.tswith command strings, before → after:rm -rf /home/hasna/.hasna(control)rm -rf /*rm -rf "$(bun pm cache)"/*bash -c 'rm -rf "$(bun pm cache)"/* ; bun add -g …'(realized incident)rm -fr /*/rm -R -f /*/rm --recursive --force /*sudo rm -rf /*/FOO=bar rm -rf /*//bin/rm -rf /*/rm -rf -- /*true && rm -rf /*/ newline-chainedrm -rf $(bun pm cache)/*/rm -rf `bun pm cache`/*rm -rf "${BUN_CACHE}"/*/rm -rf "$BUN_CACHE"/*/rm -rf "$HOME"/*sh -c "rm -rf \"$(bun pm cache)\"/*"timeout 150 ssh station02 'rm -rf "$(bun pm cache)"/*'rm -rf /usr/*/rm -rf /etc/rm -rf /var/*/rm -rf /home/*rm -rf ./node_modules/rm -rf dist/rm -rf /tmp/scratch-1234rm -rf "$(mktemp -d)"/ls -la //git statusAn 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:
eval '…',su -c '…',su root -c '…',cd "$(cmd)"/ && rm -rf ./*,for d in /*; do rm -rf "$d"; done,ssh host "cd / && rm -rf *"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
rmis executed at any scope by any test or probe. The classifier is driven withcommand strings only.
Known limitations, stated rather than hidden
These are outside the accidental-collapse threat model and are not closed by this PR:
$'\x2f'*(ANSI-C quoting) or a base64-decoded script.echo / | xargs rm -rf.bash <<< 'rm -rf /*'.for … inbindings are followed; an arbitraryd=/; 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 thebare
rmtoken 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:
@hasna/hooks(publish intent to#git-publishingfirst).pre-bashguard 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#announcementsbefore this PR was opened(message 600019).