Skip to content

fix(gates): irrecoverable reads $IFS splits, variable-held verbs, mkfs, chmod 777 (GH #336) - #376

Merged
wasikarn merged 3 commits into
developfrom
claude/gate-336-irrecoverable
Oct 3, 2026
Merged

wasikarn merged 3 commits into
developfrom
claude/gate-336-irrecoverable

Conversation

@wasikarn

@wasikarn wasikarn commented Oct 3, 2026

Copy link
Copy Markdown
Owner

Closes #336. Builds on #369 (GH #337 shadow mode).

What

hooks/gates/irrecoverable.py gains three pattern rules, two "views" and an ask floor:

rule id what it matches ships
mkfs mkfs, mkfs.*, mke2fs with an operand (mkfs.ext4 -V stays allowed) enforced
chmod-world mode 777 / 0777 / 1777 / a+rwx / ugo=rwx, with -R/--recursive or a target of /, ~ (and /*, ~/) enforced
rm-no-preserve-root rm with --no-preserve-root or any unambiguous prefix (--n...) enforced
ifs-split (view) the command re-read with $IFS, ${IFS} and ${IFS:..} as a blank, and $9/$@-style empty positionals dropped enforced
var-verb (view) the command re-read with $NAME/${NAME} replaced by a plain value the command assigns to NAME (last value, then first) shadow
opaque-var-verb ask: argv0 is $NAME with no NAME= anywhere in the command shadow
source-file ask: source <file> / . <file> shadow

A view is a second reading of the whole command, checked by every rule. It runs only after the command passed, and only up to 20 KB. Inside a view a verdict belongs to both the matched rule and the view: if either is shadowed, the verdict is shadowed. A structural deny inside a view (an unbalanced quote the substitution created) only ends that view. irrecoverable.sh's fast path now sends mkfs, mke2fs, chmod and source to python.

The issue says the four rows should deny. Three of them do. X=rm; $X -rf build ships as a shadow would_deny, because of the task rule that a rule which denies real transcript commands ships shadow (see the replay below). Promote it by deleting "var-verb" from SHADOW_RULES.

Replay: real false-deny count (55,000 distinct Bash commands from local transcripts, 29,439 of them subagent)

Every new rule shadowed, against a frozen copy of develop: 0 verdict, stdout or stderr diffs, no exit other than 0/2, worst time 0.18 s. Would-be hits per rule:

rule hits verdict
mkfs, chmod-world, rm-no-preserve-root, ifs-split 0 enforce
var-verb 1 a real R=...; git -C $R checkout -- <file>. This is a true discard that develop misses: -C takes the $ token as its value. It still denies a real command, so the rule ships shadow. The general bypass (git -C $VAR <sub>, no assignment needed) is filed as #375.
opaque-var-verb 63 asks shadow
source-file 98 asks shadow

The first replay found 61 var-verb hits. Two fixes brought it to 1. First, values are now read from the raw text (_PLAIN_ASSIGN_RE), not from the tokens, which split RW=$R/x into an empty assignment. Second, a view no longer re-counts an ask that the command already asked, or a structural deny.

Tests (TDD: the new rows were red on develop before the fix)

  • tests/hooks/fixtures/irrecoverable-336-cases.txt, replayed by test-gates.sh: 72 cases (DENY / ALLOW / WOULD_DENY / WOULD_ASK). Each WOULD_* line is also run against a copy with SHADOW_RULES emptied, and it must deny or ask there. 28 benign controls: IFS= read, OLDIFS=$IFS, "$IFS", X=1; echo $X, PY=python3; $PY -m pytest, chmod +x, chmod -R 755, chmod 777 /tmp/x, chmod 644 ~/.ssh/config, and the replay's own former false deny (RW=$R/w; git -C $RW remote add ...). An ALLOW control fails if it journals any would_* row.
  • 4 fast-path rows (mkfs, mke2fs, chmod, source reach python) and 4 timing rows: views after 10 KB, 1,200 variable references, 4,000 chmod mode words.
  • test-gate-journal.sh case 7: with ifs-split / var-verb shadowed, the view's deny is journaled under the view's id and allowed, and a plain rm -rf still denies. Enforced, it denies with a row that names the view.
  • test-gates.sh: 1,247/1,247. test-gate-journal.sh: 28/28. The gauntlet passes (GAUNTLET_JOBS=2). harness-audit reports 0 CRIT.
  • Mutation check, 18/18 killed, each against the committed asserts: ifs view off, var view off, last value only, no ${IFS:..}, positionals kept, chmod without -R / without a+rwx / without //~, mkfs dotted-only, mkfs without the operand check, no-preserve-root off, opaque ignoring assignments, source off, view shadow by matched rule only, structural deny firing in a view, assignments read from tokens, fast-path words dropped, var-verb enforced.

Real shells (argv-logging stubs; bash 3.2, bash 5.3, dash, zsh, /bin/sh)

Every DENY and WOULD_DENY shape in the fixture ran the destructive argv in at least bash and sh. zsh does not split an unquoted $IFS (it runs rm${IFS}-rf as one word), and dash has no ${IFS:0:1}. Every other shape ran in all five shells.

Fuzz (own generator, 4,500 cases x 2 seeds, plus 1,148 recorded test-gates payloads)

  • All new rules shadowed vs develop: 0 diffs on both seeds. The fixture payloads differ only in one fail-closed traceback's file path.
  • As shipped vs develop: no verdict got looser (no ->allow). Tightened: seed 1 has 773 allow->deny, 12 ask->deny and 6 allow->ask; seed 2 has 759, 7 and 5. The rest of the diffs are deny->deny with a different message. Re-run in real shells with stubs, 1,341 of the 1,562 tightened cases really ran a destructive argv. The others are the safe direction: they depend on control flow (a || chmod -R 777 x), use builtin <non-builtin> (develop already treats builtin as a wrapper), or zsh does not split $IFS.

Timing (worst cases, M3 Pro, load ~5)

shape develop branch
9.8 KB eval sudo padding + a late rm${IFS}-rf 0.21 s allow 0.27 s deny
19 KB, one variable referenced 2,400 times 0.14 s 0.17 s
20 KB of chmod -R a+rw ... words 0.01 s (fast path) 0.15 s
25 KB with $IFS, over the view cap 0.10 s 0.11 s

The new regexes are linear. On 20 KB adversarial inputs (unbalanced X=' runs, a 20 KB value ending in $), _PLAIN_ASSIGN_RE takes 1 ms.

Residue

  • A bare . file with no candidate word never reaches python, so source-file cannot see it.
  • ${X} as argv0 is not read as mkfs (it would turn every ${EDITOR} f into a mkfs deny). mk$(true)fs is read as mkfs.
  • A view holds one substitution at a time. A command that needs both $IFS and a variable read back is not checked in one view.

No manifest bump.

…s, chmod 777 (GH #336)

New pattern rules: mkfs (mkfs, mkfs.*, mke2fs with an operand), chmod-world (777 / a+rwx with -R
or on / or ~) and rm-no-preserve-root (any unambiguous prefix). Two views, each a second reading
of the whole command checked by every rule and owned by a rule id of its own: ifs-split ($IFS,
${IFS}, ${IFS:..} read as a blank, empty positionals dropped) and var-verb ($NAME read back where
the command assigns NAME a plain value). Views run after the command passed, up to 20 KB; a
structural deny inside a view only ends that view. An ask floor for a `$NAME` program with no
visible assignment (opaque-var-verb) and for `source`/`.` (source-file). The fast path now sends
mkfs, mke2fs, chmod and source to python.

Shadow first (GH #337): var-verb, opaque-var-verb and source-file. A 55,000-command replay of
real transcripts with every new rule shadowed gave 0 verdict diffs against develop and these
would-be hits: var-verb 1 (a real `git -C $R checkout -- <file>`), opaque-var-verb 63 asks,
source-file 98 asks; mkfs, chmod-world, rm-no-preserve-root and ifs-split 0, so those enforce.
@wasikarn
wasikarn merged commit f671955 into develop Oct 3, 2026
3 checks passed
@wasikarn
wasikarn deleted the claude/gate-336-irrecoverable branch October 3, 2026 12:35
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.

gate: irrecoverable allows IFS splitting, variable-held verbs, mkfs and chmod -R 777

1 participant