From 2fe842662be18d2bf0184c51eed29c875ee2f19d Mon Sep 17 00:00:00 2001 From: andreihasna Date: Sun, 26 Jul 2026 15:52:32 +0300 Subject: [PATCH 01/14] fix(pre-bash,worktree-guard): block filesystem-root wipes and empty-collapse 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 ` 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. --- CHANGELOG.md | 15 + README.md | 19 +- hooks/codewith-native-common.test.ts | 382 ++++++++++++++++++- hooks/codewith-native-common.ts | 525 +++++++++++++++++++++++++-- hooks/pre-bash/README.md | 52 ++- hooks/worktree-guard/README.md | 11 +- src/hooks/codewith-native.test.ts | 56 +++ 7 files changed, 1025 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff4bf2..194801d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **`pre-bash` / `worktree-guard` destructive-shell guard no longer lets a filesystem-root wipe through.** `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. Two complementary rules close the class: + - **System roots are protected.** `/` and the FHS/macOS system directories (`/usr`, `/etc`, `/bin`, `/lib`, `/var`, `/boot`, `/home`, `/Users`, …) are now protected roots in `root` mode, so wiping a root or its contents blocks while a targeted delete beneath one (`rm -rf /usr/local/lib/my-build`) still passes. Extend per machine with `HASNA_PROTECTED_SYSTEM_ROOTS`. `/tmp` is deliberately excluded. + - **Expansions that can collapse to empty are blocked by shape.** Every destructive target containing a command substitution, backtick substitution or variable expansion is re-checked as the shell would render it if the expansion came back empty. `rm -rf "$(anything)"/*`, `` rm -rf `cmd`/* ``, `rm -rf "$VAR"/*` and `rm -rf "${VAR}"/*` all block regardless of what the expansion is. `${VAR:?}` is exempt — POSIX guarantees it non-empty. The bare `rm -rf "$(cmd)"` form (no trailing separator) stays allowed: it degrades to `rm -rf ""`, which rm rejects without deleting anything. + - A wholesale content glob (`dir/*`) is now matched against protected roots nested *under* `dir`, not just against `dir` itself. This is the asymmetry that let `rm -rf /*` through while `rm -rf /` blocked. Narrower globs (`dir/build-*`) keep their previous, weaker check. + - Wrapped and relocated commands are unwrapped before scanning: `bash -c`/`sh -c`/`zsh -c`, `su -c`, `runuser -c`, `eval`, and `ssh host '…'` including nested combinations. Remote layers only consider absolute targets, since a remote relative path cannot be resolved locally. + - `cd` is tracked within a command, so `cd / && rm -rf *` and `cd "$(cmd)"/ && rm -rf ./*` block. + - A `for VAR in ` binding is followed into `rm -rf "$VAR"`. + - Block messages now name a safe alternative instead of only refusing. + + Remediates the 2026-07-24 data-destruction incident in which `rm -rf "$(bun pm cache)"/*`, sent over ssh inside `bash -c`, ran as `rm -rf /*` (`bun pm cache` exits non-zero with empty stdout when no `package.json` is found walking up from cwd), freeing ~700 GB and permanently destroying one repository's only source copy. + ## [0.4.1] - 2026-07-26 ### Fixed diff --git a/README.md b/README.md index 10817dd..6d00266 100644 --- a/README.md +++ b/README.md @@ -67,10 +67,21 @@ hooks install session-start prompt-guard pre-bash worktree-guard stop-sync knowl ``` The scoped destructive-operation guard does not block every cleanup command. It -blocks resolved shell/file-tool targets that threaten `~/.hasna`, configured -workspace roots, Hasna division/scope roots, or active repo/worktree roots, -including recursive `rm`, `rsync --delete`, destructive `find`, and destructive -`git clean` / `git reset --hard` forms. +blocks resolved shell/file-tool targets that threaten `/` or a system root +(`/usr`, `/etc`, `/bin`, `/lib`, `/var`, `/boot`, `/home`, `/Users`, and the +other FHS and macOS equivalents), `~/.hasna`, configured workspace roots, Hasna +division/scope roots, or active repo/worktree roots, including recursive `rm`, +`rsync --delete`, destructive `find`, and destructive `git clean` / `git reset +--hard` forms. + +It also blocks by *shape*: a destructive target containing a command +substitution or variable expansion immediately followed by `/` is checked as the +shell would render it if that expansion returned empty, so +`rm -rf "$(anything)"/*` and `rm -rf "$VAR"/*` are refused whatever the +expansion is. Wrapped forms (`bash -c`, `su -c`, `eval`, `ssh host '…'`) are +unwrapped first. See [`hooks/pre-bash/README.md`](hooks/pre-bash/README.md) for +the full rules, the deliberate exemptions (`${VAR:?}`, bare `"$(cmd)"` with no +trailing separator), and the recommended safe form. Apply that fragment through `open-configs` or the managed config renderer. A direct write path exists only for explicit local/test use: diff --git a/hooks/codewith-native-common.test.ts b/hooks/codewith-native-common.test.ts index 65bb7d8..69cbe5f 100644 --- a/hooks/codewith-native-common.test.ts +++ b/hooks/codewith-native-common.test.ts @@ -1,8 +1,17 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { join } from "path"; import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "fs"; import { tmpdir } from "os"; -import { claimCommand, getAgentName, gitCommandInfo, gitRemoteHostSlug, managedWorktreeInfo } from "./codewith-native-common"; +import { + claimCommand, + classifyDangerousOperation, + emptyExpansionCollapse, + getAgentName, + gitCommandInfo, + gitRemoteHostSlug, + managedWorktreeInfo, + SYSTEM_PROTECTED_ROOTS, +} from "./codewith-native-common"; describe("codewith native common helpers", () => { test("gitCommandInfo detects global option commit/push forms and target cwd", () => { @@ -411,3 +420,372 @@ describe("codewith native common helpers", () => { } }); }); + +/** + * Regression suite for the 2026-07-24 station02 data-destruction incident. + * + * A subagent composed `bash -c 'rm -rf "$(bun pm cache)"/* ; ... bun add -g ...'` and ssh'd + * it to station02. `bun pm cache` exits 1 with an empty stdout when no package.json is found + * walking up from cwd, so the substitution collapsed and the command ran as `rm -rf /*`. It + * ran unprivileged for ~5 minutes, freed ~700 GB, and destroyed the org repo checkouts; + * hasna/cloud's source is permanently gone. + * + * Every case below is a command STRING handed to the classifier. Nothing here executes any + * rm, at any scope, ever. + */ +describe("destructive shell guard - rm -rf /* incident regression", () => { + // The incident machine's HOME. Pinned as an explicit fixture so the three mandated + // regression commands can appear verbatim rather than reconstructed from the runner's env. + const INCIDENT_HOME = "/home/hasna"; + + let scratchCwd: string; + let savedHome: string | undefined; + + beforeAll(() => { + // A cwd outside any git repo and outside every protected root, so a verdict is + // attributable to the command under test rather than to where the suite happens to run. + scratchCwd = mkdtempSync(join(tmpdir(), "hooks-destructive-")); + savedHome = process.env.HOME; + }); + + afterAll(() => { + if (savedHome === undefined) delete process.env.HOME; + else process.env.HOME = savedHome; + try { rmSync(scratchCwd, { recursive: true, force: true }); } catch {} + }); + + async function classify(command: string, options: { home?: string; cwd?: string } = {}) { + process.env.HOME = options.home ?? INCIDENT_HOME; + return classifyDangerousOperation({ + hook_event_name: "PreToolUse", + tool_name: "Bash", + cwd: options.cwd ?? scratchCwd, + tool_input: { command }, + }); + } + + async function expectBlocked(command: string, options: { home?: string; cwd?: string } = {}) { + const result = await classify(command, options); + if (!result.block) throw new Error(`expected BLOCK, got continue for: ${command}`); + return result; + } + + async function expectAllowed(command: string, options: { home?: string; cwd?: string } = {}) { + const result = await classify(command, options); + if (result.block) throw new Error(`expected continue, got BLOCK (${result.reason}) for: ${command}`); + return result; + } + + // --------------------------------------------------------------------------------------- + // The three fixtures mandated by the incident remediation, verbatim. + // --------------------------------------------------------------------------------------- + + test("control: rm -rf /home/hasna/.hasna still blocks (guard is wired up at all)", async () => { + const result = await expectBlocked("rm -rf /home/hasna/.hasna"); + expect(result.protectedLabel).toBe("Hasna state root ~/.hasna"); + expect(result.operation).toBe("rm -rf"); + }); + + test("rm -rf /* blocks (was `continue` before this change)", async () => { + const result = await expectBlocked("rm -rf /*"); + expect(result.protectedLabel).toBe("filesystem root /"); + expect(result.reason).toContain("rm -rf"); + }); + + test('rm -rf "$(bun pm cache)"/* blocks (was `continue` before this change)', async () => { + const result = await expectBlocked('rm -rf "$(bun pm cache)"/*'); + expect(result.targetPath).toBe("/*"); + expect(result.reason).toContain("collapses to /*"); + // A refusal that does not tell the agent what to do instead just gets retried. + expect(result.reason).toContain("Safe alternative"); + }); + + test("the realized incident command blocks", async () => { + await expectBlocked( + `bash -c 'rm -rf "$(bun pm cache)"/* ; bun add -g @hasna/connectors@1.3.45'` + ); + }); + + // --------------------------------------------------------------------------------------- + // Rule 1 - system and filesystem roots. + // --------------------------------------------------------------------------------------- + + test("recursive delete of the filesystem root blocks in bare and glob form", async () => { + for (const command of ["rm -rf /", "rm -rf /*", "rm -rf /.*", "rm -rf /**"]) { + const result = await expectBlocked(command); + expect(result.protectedLabel).toBe("filesystem root /"); + } + }); + + test("recursive delete of every declared system root blocks, bare and wholesale-glob", async () => { + for (const root of SYSTEM_PROTECTED_ROOTS) { + if (root === "/") continue; + await expectBlocked(`rm -rf ${root}`); + await expectBlocked(`rm -rf ${root}/*`); + } + }); + + test("targeted deletes under a system root stay allowed", async () => { + await expectAllowed("rm -rf /usr/local/lib/my-abandoned-build"); + await expectAllowed("rm -rf /var/log/my-app/old"); + await expectAllowed("rm -rf /opt/my-app/cache/*"); + // /tmp is deliberately not a protected root: scratch cleanup there is routine. + await expectAllowed("rm -rf /tmp/scratch-1234"); + await expectAllowed("rm -rf /tmp/*"); + }); + + test("a wholesale glob blocks where a narrower glob over the same directory does not", async () => { + const home = mkdtempSync(join(tmpdir(), "hooks-glob-home-")); + try { + // ~/.hasna lives under `home`, so `home/*` destroys it but `home/proj*` cannot. + await expectBlocked(`rm -rf ${home}/*`, { home }); + await expectAllowed(`rm -rf ${home}/proj*`, { home }); + await expectAllowed(`rm -rf ${home}/.cache`, { home }); + } finally { + try { rmSync(home, { recursive: true, force: true }); } catch {} + } + }); + + // --------------------------------------------------------------------------------------- + // Rule 2 - expansions that can collapse to empty. + // --------------------------------------------------------------------------------------- + + test("any expansion immediately followed by / blocks, whatever the expansion is", async () => { + const commands = [ + 'rm -rf "$(bun pm cache)"/*', + "rm -rf $(bun pm cache)/*", + "rm -rf `bun pm cache`/*", + 'rm -rf "$(some-command-nobody-has-written-yet --json)"/*', + 'rm -rf "${BUN_CACHE}"/*', + 'rm -rf "$BUN_CACHE"/*', + 'rm -rf "$1"/*', + 'rm -rf "$(dirname "$(which bun)")"/*', + 'rm -rf "$CACHE"/', + ]; + for (const command of commands) { + const result = await expectBlocked(command); + expect(result.reason).toContain("collapses to"); + } + }); + + test('rm -rf "$HOME"/* blocks on the expanded path, before the collapse rule is needed', async () => { + // Two independent mechanisms cover this one: $HOME expands to a real home whose wholesale + // glob destroys ~/.hasna, and an empty $HOME would collapse the target to /*. + const result = await expectBlocked('rm -rf "$HOME"/*'); + expect(result.protectedLabel).toBe("Hasna state root ~/.hasna"); + expect(emptyExpansionCollapse("$HOME/*")).toBe("/*"); + }); + + test("stderr redirection does not launder the shape - it discards the diagnostic, not the path", async () => { + await expectBlocked('rm -rf "$(bun pm cache 2>/dev/null)"/*'); + }); + + test("an expansion collapsing onto a system root blocks even mid-path", async () => { + await expectBlocked('rm -rf "$SYSROOT"/usr'); + await expectBlocked('rm -rf /opt/"$APP"/*'); + }); + + test("rsync --delete and find -delete get the same collapse check as rm", async () => { + await expectBlocked('rsync -a --delete empty/ "$(build-dir)"/'); + await expectBlocked('find "$(build-dir)"/ -delete'); + }); + + /** + * Deliberate non-block. `rm -rf "$(cmd)"` with an empty expansion becomes `rm -rf ""`, + * which POSIX rm rejects ("cannot remove ''") with a non-zero exit and no deletion. 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 no gain. + */ + test("the bare expansion form without a trailing separator stays allowed, by decision", async () => { + await expectAllowed('rm -rf "$(mktemp -d)"'); + await expectAllowed('rm -rf "$BUILD_DIR"'); + expect(emptyExpansionCollapse('$(mktemp -d)')).toBeNull(); + }); + + test("an expansion collapsing to a non-protected absolute path stays allowed", async () => { + await expectAllowed('rm -rf "$BUILD_DIR"/dist'); + await expectAllowed('rm -rf "$HOME"/.cache/my-app'); + expect(emptyExpansionCollapse('"$BUILD_DIR"/dist'.replaceAll('"', ""))).toBe("/dist"); + }); + + // --------------------------------------------------------------------------------------- + // Evasion: the guard must not be defeated by spelling. + // --------------------------------------------------------------------------------------- + + test("recursive-flag spellings, privilege prefixes and chaining do not evade the guard", async () => { + const commands = [ + "rm -fr /*", + "rm -R -f /*", + "rm -Rf /*", + "rm --recursive --force /*", + "rm --force --recursive /*", + "rm -r /*", + "sudo rm -rf /*", + "doas rm -rf /*", + "FOO=bar BAZ=qux rm -rf /*", + "/bin/rm -rf /*", + "/usr/bin/rm -rf /*", + "rm -rf -- /*", + "true && rm -rf /*", + "false; rm -rf /*", + "echo starting\nrm -rf /*", + "cd / && rm -rf *", + "nice -n 19 rm -rf /*", + "env FOO=1 rm -rf /*", + ]; + for (const command of commands) await expectBlocked(command); + }); + + // --------------------------------------------------------------------------------------- + // Wrappers. The realized incident was inside `bash -c` inside `ssh`, so this is required. + // --------------------------------------------------------------------------------------- + + test("interpreter wrappers are unwrapped and scanned", async () => { + const commands = [ + `bash -c 'rm -rf /*'`, + `sh -c "rm -rf /*"`, + `zsh -c 'rm -rf /*'`, + `/bin/bash -c 'rm -rf /*'`, + `bash -lc 'rm -rf /*'`, + `bash -euxc 'rm -rf /*'`, + `sudo bash -c 'rm -rf /*'`, + `timeout 150 bash -c 'rm -rf "$(bun pm cache)"/*'`, + `bash -c 'cd /tmp && rm -rf /*'`, + ]; + for (const command of commands) await expectBlocked(command); + }); + + test("ssh remote commands are unwrapped and scanned, including nested interpreters", async () => { + const commands = [ + `ssh station02 'rm -rf /*'`, + `ssh -o BatchMode=yes -p 22 station02 'rm -rf /etc'`, + `ssh -i /home/hasna/.ssh/id_ed25519 hasna@station02 'rm -rf /usr/*'`, + `ssh station02 bash -c 'rm -rf /*'`, + `timeout 150 ssh station02 'rm -rf "$(bun pm cache)"/* ; bun add -g @hasna/connectors@1.3.45'`, + ]; + for (const command of commands) { + const result = await expectBlocked(command); + expect(result.reason).toContain("on a remote host"); + } + }); + + test("an unquoted ssh remote command still blocks, scanned as if it were local", async () => { + // `ssh host rm -rf /*` puts a bare `rm` token in the outer command, so the ordinary local + // scan catches it first. The verdict is the same; only the wording omits the remote host. + await expectBlocked(`ssh station02 rm -rf /*`); + }); + + test("a relative target on a remote host is not resolved against the local cwd", async () => { + // The remote `.` is not this machine's cwd, so guessing would be a false positive. + await expectAllowed(`ssh station02 'rm -rf dist'`); + await expectAllowed(`ssh station02 'rm -rf .'`); + await expectAllowed(`ssh station02 'ls -la /'`); + }); + + // --------------------------------------------------------------------------------------- + // False positives. A guard that blocks routine cleanup gets disabled, which is how this + // class of incident recurs. + // --------------------------------------------------------------------------------------- + + test("routine cleanup stays allowed", async () => { + const commands = [ + "rm -rf dist", + "rm -rf ./node_modules", + "rm -rf dist .turbo build", + "rm -rf ./dist/*", + "rm -f /home/hasna/some-file.txt", + "ls -la /", + "du -sh /*", + "git status", + "find . -name '*.log' -print", + "bun pm cache", + ]; + for (const command of commands) await expectAllowed(command); + }); + + test("an unterminated substitution falls back to the plain tokenizer instead of swallowing the rest", async () => { + await expectBlocked("echo $( ; rm -rf /*"); + await expectBlocked("echo ` ; rm -rf /*"); + }); + + // --------------------------------------------------------------------------------------- + // Adversarial pass. Each of these got a root wipe past an earlier draft of this guard. + // --------------------------------------------------------------------------------------- + + test("quoting tricks that still resolve to the filesystem root are blocked", async () => { + for (const command of [ + `rm -rf "/"*`, + `rm -rf /"*"`, + `rm -rf ''/*`, + `rm -rf /./*`, + `rm -rf /*/`, + `rm -rf //*`, + `rm -rf /home/hasna/../..`, + `rm -rf ~/../*`, + `rm -rf "$HOME/.."/*`, + `rm -rf "$(bun pm cache)"/../*`, + `rm -rf "$(bun pm cache)"//*`, + ]) { + await expectBlocked(command); + } + }); + + test("cd moves the guard with it", async () => { + await expectBlocked("cd / ; rm -rf *"); + await expectBlocked("cd /usr && rm -rf *"); + await expectBlocked(`sh -c 'cd / && rm -rf *'`); + await expectBlocked(`ssh station02 "cd / && rm -rf *"`); + // The incident shape moved one command to the left: an empty substitution leaves cd at /. + await expectBlocked(`cd "$(bun pm cache)"/ && rm -rf ./*`); + await expectBlocked(`bash -c 'cd "$(bun pm cache)"/ && rm -rf ./*'`); + }); + + test("cd into a resolved directory then clearing it stays allowed", async () => { + // No trailing separator, so an empty substitution leaves cwd untouched rather than at /. + await expectAllowed(`bash -c 'cd "$(bun pm cache)" && rm -rf ./*'`); + await expectAllowed(`cd "$HOME/.cache/my-app" && rm -rf ./*`); + await expectAllowed("cd /tmp && rm -rf my-scratch-dir"); + }); + + test("eval and user-switch wrappers are unwrapped", async () => { + await expectBlocked(`eval 'rm -rf /*'`); + await expectBlocked(`su -c 'rm -rf /*'`); + await expectBlocked(`su root -c 'rm -rf /*'`); + await expectBlocked(`runuser -u hasna -c 'rm -rf /*'`); + }); + + test("a for-loop over a root glob is blocked even though the delete target is just $d", async () => { + await expectBlocked(`for d in /*; do rm -rf "$d"; done`); + await expectBlocked(`for p in /usr/* /etc/*; do rm -rf "$p"; done`); + // The binding must actually be a root glob; ordinary loops stay allowed. + await expectAllowed(`for d in ./build/*; do rm -rf "$d"; done`); + }); + + test('${VAR:?} is honoured as the non-empty assertion it is', async () => { + // POSIX `:?` aborts on unset *or* empty, so this cannot collapse. Blocking the idiom the + // guard's own message recommends would teach agents to drop it. + await expectAllowed('rm -rf "${CACHE:?cache path required}"/*'); + await expectAllowed('rm -rf "${BUN_CACHE:?}"/*'); + expect(emptyExpansionCollapse("${BUN_CACHE:?}/*")).toBeNull(); + // `${VAR?}` without the colon permits an empty value, which is the whole hazard. + expect(emptyExpansionCollapse("${BUN_CACHE?}/*")).toBe("/*"); + }); + + test("privilege and scheduling prefixes do not hide the delete", async () => { + for (const command of [ + String.raw`\rm -rf /*`, + `'rm' -rf /*`, + "command rm -rf /*", + "nohup rm -rf /* &", + "setsid rm -rf /*", + ]) { + await expectBlocked(command); + } + }); + + test("non-rm destructive tools targeting the root are blocked", async () => { + await expectBlocked("find / -delete"); + await expectBlocked("find / -exec rm -rf {} \\;"); + await expectBlocked("rsync -a --delete /var/empty/ /"); + }); +}); diff --git a/hooks/codewith-native-common.ts b/hooks/codewith-native-common.ts index e70c3f8..4caadf3 100644 --- a/hooks/codewith-native-common.ts +++ b/hooks/codewith-native-common.ts @@ -134,11 +134,18 @@ export interface GitCommandInfo { workTree?: string; } -function splitShellSegments(command: string): string[] { +// `$( ... )` and backtick substitutions are one operand of the surrounding command: +// their inner `;`, `|` and whitespace are not separators. Tokenizing them atomically is +// what lets the expansion-collapse rule below see `$(cmd)/*` as a single target token. +// If a substitution is left unterminated the command is malformed, so both tokenizers +// re-run with substitution tracking disabled rather than swallow the rest of the input. +function splitShellSegmentsPass(command: string, atomicSubstitutions: boolean): { segments: string[]; unterminated: boolean } { const segments: string[] = []; let current = ""; let quote: "'" | '"' | null = null; let escaped = false; + let substitutionDepth = 0; + let inBacktick = false; for (let i = 0; i < command.length; i += 1) { const ch = command[i]; @@ -152,6 +159,28 @@ function splitShellSegments(command: string): string[] { current += ch; continue; } + if (atomicSubstitutions && substitutionDepth > 0) { + current += ch; + if (ch === "(") substitutionDepth += 1; + else if (ch === ")") substitutionDepth -= 1; + continue; + } + if (atomicSubstitutions && inBacktick) { + current += ch; + if (ch === "`") inBacktick = false; + continue; + } + if (atomicSubstitutions && quote !== "'" && ch === "$" && command[i + 1] === "(") { + current += "$("; + substitutionDepth = 1; + i += 1; + continue; + } + if (atomicSubstitutions && quote !== "'" && ch === "`") { + current += ch; + inBacktick = true; + continue; + } if (quote) { current += ch; if (ch === quote) quote = null; @@ -172,14 +201,22 @@ function splitShellSegments(command: string): string[] { } if (current.trim()) segments.push(current.trim()); - return segments; + return { segments, unterminated: substitutionDepth > 0 || inBacktick }; } -function shellWords(segment: string): string[] { +function splitShellSegments(command: string): string[] { + const atomic = splitShellSegmentsPass(command, true); + if (!atomic.unterminated) return atomic.segments; + return splitShellSegmentsPass(command, false).segments; +} + +function shellWordsPass(segment: string, atomicSubstitutions: boolean): { words: string[]; unterminated: boolean } { const words: string[] = []; let current = ""; let quote: "'" | '"' | null = null; let escaped = false; + let substitutionDepth = 0; + let inBacktick = false; const push = () => { if (current.length > 0) { @@ -195,10 +232,34 @@ function shellWords(segment: string): string[] { escaped = false; continue; } + // Substitution bodies are copied verbatim, quotes and spaces included: the raw text + // is what emptyExpansionCollapse inspects. + if (atomicSubstitutions && substitutionDepth > 0) { + current += ch; + if (ch === "(") substitutionDepth += 1; + else if (ch === ")") substitutionDepth -= 1; + continue; + } + if (atomicSubstitutions && inBacktick) { + current += ch; + if (ch === "`") inBacktick = false; + continue; + } if (ch === "\\" && quote !== "'") { escaped = true; continue; } + if (atomicSubstitutions && quote !== "'" && ch === "$" && segment[i + 1] === "(") { + current += "$("; + substitutionDepth = 1; + i += 1; + continue; + } + if (atomicSubstitutions && quote !== "'" && ch === "`") { + current += ch; + inBacktick = true; + continue; + } if (quote) { if (ch === quote) { quote = null; @@ -218,7 +279,13 @@ function shellWords(segment: string): string[] { current += ch; } push(); - return words; + return { words, unterminated: substitutionDepth > 0 || inBacktick }; +} + +function shellWords(segment: string): string[] { + const atomic = shellWordsPass(segment, true); + if (!atomic.unterminated) return atomic.words; + return shellWordsPass(segment, false).words; } function expandHome(path: string): string { @@ -418,6 +485,59 @@ function activeRootsFor(input: CodewithHookInput, cwd: string): string[] { return uniqueResolved(candidates, cwd); } +/** + * Filesystem roots a recursive delete must never target wholesale: the FHS system + * directories plus their macOS equivalents, and `/` itself. + * + * `/` is here because of the 2026-07-24 station02 incident: `rm -rf "$(bun pm cache)"/*` + * ran as `rm -rf /*` after the substitution collapsed to empty, freed ~700 GB and + * permanently destroyed one repository's only source copy. Every entry is matched in + * "root" mode, so `rm -rf /usr` and `rm -rf /usr/*` block while `rm -rf /usr/local/lib/mine` + * stays allowed - the guard is about wholesale wipes, not targeted deletes. + * + * `/tmp` is deliberately absent: scratch cleanup there is routine and bounded. + * Machine-specific additions come from HASNA_PROTECTED_SYSTEM_ROOTS (colon-separated). + */ +export const SYSTEM_PROTECTED_ROOTS: readonly string[] = [ + "/", + "/bin", + "/boot", + "/dev", + "/etc", + "/home", + "/lib", + "/lib32", + "/lib64", + "/libx32", + "/opt", + "/proc", + "/root", + "/run", + "/sbin", + "/srv", + "/sys", + "/usr", + "/var", + "/Applications", + "/Library", + "/System", + "/Users", + "/Volumes", + "/private", +]; + +function systemProtectedRulesFor(cwd: string): ProtectedPathRule[] { + const roots = uniqueResolved( + [...SYSTEM_PROTECTED_ROOTS, ...splitPathList(process.env.HASNA_PROTECTED_SYSTEM_ROOTS)], + cwd + ); + return roots.map((root) => ({ + root, + label: root === sep ? "filesystem root /" : `system root ${root}`, + mode: "root" as const, + })); +} + function hasnaDivisionRuleFor(target: string, workspaceRoot: string): ProtectedPathRule | null { const rel = relative(resolve(workspaceRoot), resolve(target)); if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null; @@ -435,6 +555,10 @@ function hasnaDivisionRuleFor(target: string, workspaceRoot: string): ProtectedP async function protectedPathContextFor(input: CodewithHookInput, cwd: string): Promise { const home = process.env.HOME || homedir(); const rules: ProtectedPathRule[] = [ + // System roots first so a root wipe is reported as the root wipe it is, rather than as + // whichever Hasna path happened to sit underneath it. Overlapping paths are deduplicated + // below with the Hasna rule's more specific label winning. + ...systemProtectedRulesFor(cwd), { root: join(home, ".hasna"), label: "Hasna state root ~/.hasna", mode: "tree" }, ]; const workspaceRoots = workspaceRootsFor(input, cwd); @@ -479,13 +603,72 @@ function mutatesProtectedPath(targetPath: string, rule: ProtectedPathRule): bool return target === root; } -function broadContentWipeBase(targetPath: string): string | null { +// A trailing glob that matches every entry, so `dir/*` destroys all of `dir`. +const CATCH_ALL_GLOB = /^(?:\*|\*\*|\.\*|\.\[!\.\]\*)$/; + +/** + * Base directory of a wholesale content wipe (`dir/*`). Everything under the base is + * destroyed, so this base is checked with the full threatensProtectedPath test - a + * protected root nested *under* the base is destroyed just as surely as the base itself. + * That asymmetry is why `rm -rf /` blocked before this change but `rm -rf /*` did not. + */ +function catchAllWipeBase(targetPath: string): string | null { + const target = resolve(targetPath); + return CATCH_ALL_GLOB.test(basename(target)) ? dirname(target) : null; +} + +/** + * Base directory of a narrower glob (`dir/build-*`). Such a glob cannot reach an + * arbitrary sibling, so it keeps the weaker mutatesProtectedPath test it always had. + */ +function narrowGlobWipeBase(targetPath: string): string | null { const target = resolve(targetPath); const last = basename(target); if (!/[*?\[]/.test(last)) return null; return dirname(target); } +// Command substitutions, backtick substitutions and variable expansions, in the order +// they must be tried (longest construct first). +const SHELL_EXPANSION = /\$\((?:[^()]|\([^()]*\))*\)|`[^`]*`|\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*|\$[0-9@*?#$!-]/g; + +// `${VAR:?}` / `${VAR:?message}` aborts the shell when VAR is unset *or* empty, so this +// form cannot collapse. It is the POSIX way to assert a path is present, and blocking it +// would punish exactly the defensive code this guard asks for. `${VAR?}` without the colon +// is NOT exempt: it permits an empty value, which is the whole hazard. +const GUARDED_EXPANSION = /^\$\{[A-Za-z_][A-Za-z0-9_]*:\?/; +const NON_EMPTY_PLACEHOLDER = "__hooks_guarded_expansion__"; + +/** + * The shape that destroyed station02 on 2026-07-24. + * + * `bun pm cache` writes its path to stdout on success, but exits 1 with an empty stdout + * when no package.json is found walking up from cwd. `rm -rf "$(bun pm cache)"/*` therefore + * became `rm -rf /*`. Redirecting stderr does not help: the redirect discards the + * diagnostic, not the path. The hazard is not this command - it is any expansion the shell + * may hand back empty, immediately followed by a path separator. + * + * Returns the token with every expansion replaced by the empty string, i.e. the worst case + * the shell can produce. Returns null when: + * - the token contains no expansion; or + * - the collapse is not absolute. A bare `rm -rf "$(cmd)"` collapses to `rm -rf ""`, which + * POSIX rm rejects with "cannot remove ''" and a non-zero exit without deleting anything, + * and blocking it would break routine `rm -rf "$tmpdir"` cleanup for no safety gain. A + * relative collapse stays inside cwd and is already covered by the ordinary target check. + * The whole catastrophic class is the one where the collapse leaves a leading `/`. + */ +export function emptyExpansionCollapse(token: string): string | null { + if (!/[$`]/.test(token)) return null; + let sawCollapsible = false; + const collapsed = token.replace(SHELL_EXPANSION, (match) => { + if (GUARDED_EXPANSION.test(match)) return NON_EMPTY_PLACEHOLDER; + sawCollapsible = true; + return ""; + }); + if (!sawCollapsible || !collapsed.startsWith("/")) return null; + return collapsed; +} + function shouldSkipHasnaTreeRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean { if (rule.label !== "Hasna state root ~/.hasna") return false; if (!currentManagedRepoRoot) return false; @@ -677,8 +860,10 @@ async function verifiedManagedRepoRoot( function threatensRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean { if (shouldSkipHasnaTreeRule(targetPath, rule, currentManagedRepoRoot)) return false; - const contentBase = broadContentWipeBase(targetPath); - if (contentBase && mutatesProtectedPath(contentBase, rule)) return true; + const catchAllBase = catchAllWipeBase(targetPath); + if (catchAllBase && threatensProtectedPath(catchAllBase, rule)) return true; + const narrowBase = narrowGlobWipeBase(targetPath); + if (narrowBase && mutatesProtectedPath(narrowBase, rule)) return true; return threatensProtectedPath(targetPath, rule); } @@ -690,6 +875,17 @@ function mutatesRule(targetPath: string, rule: ProtectedPathRule, currentManaged interface DestructiveShellTarget { path: string; operation: string; + /** Same target with every shell expansion collapsed to empty; see emptyExpansionCollapse. */ + collapsed?: string; + /** Target of a command sent to another host, so relative paths cannot be resolved here. */ + remote?: boolean; + /** Working directory in effect for this target, after any `cd` earlier in the command. */ + baseCwd?: string; +} + +function destructiveTarget(path: string, operation: string): DestructiveShellTarget { + const collapsed = emptyExpansionCollapse(path); + return collapsed === null ? { path, operation } : { path, operation, collapsed }; } function rmCommandTargets(command: string): DestructiveShellTarget[] { @@ -724,7 +920,7 @@ function rmCommandTargets(command: string): DestructiveShellTarget[] { } if (recursive) { - targets.push(...segmentTargets.map((path) => ({ path, operation: force ? "rm -rf" : "rm -r" }))); + targets.push(...segmentTargets.map((path) => destructiveTarget(path, force ? "rm -rf" : "rm -r"))); } } return targets; @@ -785,7 +981,7 @@ function rsyncDeleteTargets(command: string): DestructiveShellTarget[] { } if (hasDelete && operands.length > 0) { - targets.push({ path: operands[operands.length - 1], operation: "rsync --delete" }); + targets.push(destructiveTarget(operands[operands.length - 1], "rsync --delete")); } } return targets; @@ -823,10 +1019,10 @@ function findDestructiveTargets(command: string): DestructiveShellTarget[] { } if (hasDelete || hasExecRm) { - targets.push(...(roots.length > 0 ? roots : ["."]).map((path) => ({ + targets.push(...(roots.length > 0 ? roots : ["."]).map((path) => destructiveTarget( path, - operation: hasDelete ? "find -delete" : "find -exec rm", - }))); + hasDelete ? "find -delete" : "find -exec rm" + ))); } } return targets; @@ -953,13 +1149,248 @@ function gitDestructiveTargets(command: string, baseCwd: string): DestructiveShe return targets; } +const SHELL_INTERPRETERS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "mksh", "busybox"]); + +// Also take a script via `-c`, but with a username operand in front of the flag. +const USER_SWITCH_COMMANDS = new Set(["su", "runuser"]); + +// ssh options that consume the following argument, so the first bare operand really is the host. +const SSH_OPTIONS_WITH_VALUE = new Set([ + "-B", "-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J", "-L", "-l", "-m", + "-O", "-o", "-P", "-p", "-Q", "-R", "-S", "-W", "-w", +]); + +interface ShellCommandLayer { + command: string; + /** True once the layer is being executed on another host via ssh. */ + remote: boolean; +} + +function commandName(token: string): string { + return token.includes("/") ? token.slice(token.lastIndexOf("/") + 1) : token; +} + +function isShellInterpreterToken(token: string): boolean { + return SHELL_INTERPRETERS.has(commandName(token)); +} + +/** + * Script passed via `-c`. For a shell, the first bare operand is the script *file* and the + * scan stops there; `su`/`runuser` take a username operand first, so one is skipped. + */ +function interpreterScriptFrom(tokens: string[], shellIndex: number, allowedOperands = 0): string | null { + let operands = 0; + for (let i = shellIndex + 1; i < tokens.length; i += 1) { + const token = tokens[i]; + // -c, and combined short forms such as -lc / -euxc. + if (/^-[A-Za-z]*c$/.test(token)) return tokens[i + 1] ?? null; + if (!token.startsWith("-")) { + operands += 1; + if (operands > allowedOperands) return null; + } + } + return null; +} + +function sshRemoteCommandFrom(tokens: string[], sshIndex: number): string | null { + for (let i = sshIndex + 1; i < tokens.length; i += 1) { + const token = tokens[i]; + if (token === "--") continue; + if (token.startsWith("-")) { + if (SSH_OPTIONS_WITH_VALUE.has(token)) i += 1; + continue; + } + // First bare operand is [user@]host; everything after it is the remote command. + const remote = tokens.slice(i + 1).join(" ").trim(); + return remote.length > 0 ? remote : null; + } + return null; +} + +/** + * Scripts this command hands to another interpreter or to another host. + * + * Required, not optional: the realized 2026-07-24 incident arrived as + * `ssh station02 bash -c '...'`, and the `rm` token only exists inside the quoted script. + * A scan of the outer command alone sees `ssh`, `bash` and a single opaque operand. + */ +function isSshToken(token: string): boolean { + return token === "ssh" || token.endsWith("/ssh"); +} + +function wrappedShellLayers(command: string, remote: boolean): ShellCommandLayer[] { + const layers: ShellCommandLayer[] = []; + for (const segment of splitShellSegments(command)) { + const tokens = shellWords(segment); + // `ssh host bash -c '...'`: everything after the ssh token executes on the other machine. + let sshSeen = false; + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]; + if (isShellInterpreterToken(token) || USER_SWITCH_COMMANDS.has(commandName(token))) { + const allowedOperands = USER_SWITCH_COMMANDS.has(commandName(token)) ? 1 : 0; + const script = interpreterScriptFrom(tokens, i, allowedOperands); + if (script) layers.push({ command: script, remote: remote || sshSeen }); + continue; + } + if (token === "eval") { + const script = tokens.slice(i + 1).join(" ").trim(); + if (script) layers.push({ command: script, remote: remote || sshSeen }); + continue; + } + if (isSshToken(token)) { + sshSeen = true; + const script = sshRemoteCommandFrom(tokens, i); + if (script) layers.push({ command: script, remote: true }); + } + } + } + return layers; +} + +const MAX_WRAPPER_DEPTH = 3; +const MAX_SHELL_LAYERS = 32; + +function shellCommandLayers(command: string): ShellCommandLayer[] { + const layers: ShellCommandLayer[] = [{ command, remote: false }]; + const seen = new Set([command]); + let frontier: ShellCommandLayer[] = layers; + + for (let depth = 0; depth < MAX_WRAPPER_DEPTH && layers.length < MAX_SHELL_LAYERS; depth += 1) { + const next: ShellCommandLayer[] = []; + for (const layer of frontier) { + for (const inner of wrappedShellLayers(layer.command, layer.remote)) { + if (seen.has(inner.command) || layers.length + next.length >= MAX_SHELL_LAYERS) continue; + seen.add(inner.command); + next.push(inner); + } + } + if (next.length === 0) break; + layers.push(...next); + frontier = next; + } + + return layers; +} + +/** + * Remote layers run against another machine's filesystem, so a relative or cwd-derived + * target here would be a guess. Absolute targets (including `~` / `$HOME` forms, which the + * fleet shares) and empty-collapse targets (always absolute by construction) still apply. + */ +function keepRemoteTarget(target: DestructiveShellTarget): boolean { + return target.collapsed !== undefined || isAbsolute(expandHome(target.path)); +} + +interface CommandChunk { + segment: string; + /** Working directories this segment may run in: the tracked cwd, plus the cwd a `cd` + * whose operand collapsed to empty would have left behind. */ + cwds: string[]; + /** An absolute `cd` inside this layer fixed the directory, so it is known even remotely. */ + explicitCwd: boolean; +} + +const MAX_CWD_VARIANTS = 4; + +/** + * Segments of one layer paired with the working directories in effect when they run. + * + * Without this, `cd / && rm -rf *` reads as a glob over wherever the agent started, which is + * the cheapest possible way around a guard that only inspects the literal target. The + * collapsed variant covers `cd "$(cmd)"/ && rm -rf ./*`, which is the incident's shape moved + * one command to the left. + */ +function cwdTrackedSegments(command: string, baseCwd: string): CommandChunk[] { + const chunks: CommandChunk[] = []; + const home = process.env.HOME || homedir(); + let cwds = [baseCwd]; + let explicitCwd = false; + + for (const segment of splitShellSegments(command)) { + const tokens = shellWords(segment); + if (tokens[0] === "cd") { + const operand = tokens[1]; + // `cd -` returns somewhere this scan cannot know, so the previous cwd is kept. + if (operand === undefined) { + cwds = [home]; + explicitCwd = true; + } else if (operand !== "-" && !operand.startsWith("-")) { + const collapsed = emptyExpansionCollapse(operand); + const next = new Set(); + for (const current of cwds) { + next.add(resolveFrom(current, operand)); + if (collapsed !== null) next.add(resolveFrom(current, collapsed)); + } + cwds = [...next].slice(0, MAX_CWD_VARIANTS); + if (isAbsolute(expandHome(operand)) || collapsed !== null) explicitCwd = true; + } + continue; + } + chunks.push({ segment, cwds, explicitCwd }); + } + return chunks; +} + +/** + * `for d in /*; do rm -rf "$d"; done` deletes the filesystem root one entry at a time while + * the delete's own target is an innocuous `$d`. Only exact `$VAR` / `${VAR}` targets bound by + * a `for ... in` in the same layer are substituted, so this cannot fire on unrelated commands. + */ +function forLoopBindings(command: string): Map { + const bindings = new Map(); + for (const segment of splitShellSegments(command)) { + const tokens = shellWords(segment); + const forIndex = tokens.indexOf("for"); + if (forIndex === -1) continue; + const name = tokens[forIndex + 1]; + if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue; + if (tokens[forIndex + 2] !== "in") continue; + const words = tokens.slice(forIndex + 3).filter((token) => token !== "do"); + if (words.length > 0) bindings.set(name, words); + } + return bindings; +} + +function loopBoundWords(path: string, bindings: Map): string[] | null { + const match = path.match(/^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/); + if (!match) return null; + return bindings.get(match[1]) ?? null; +} + function destructiveShellTargets(command: string, cwd: string): DestructiveShellTarget[] { - return [ - ...rmCommandTargets(command), - ...rsyncDeleteTargets(command), - ...findDestructiveTargets(command), - ...gitDestructiveTargets(command, cwd), - ]; + const targets: DestructiveShellTarget[] = []; + for (const layer of shellCommandLayers(command)) { + const bindings = forLoopBindings(layer.command); + + for (const chunk of cwdTrackedSegments(layer.command, cwd)) { + const raw = [ + ...rmCommandTargets(chunk.segment), + ...rsyncDeleteTargets(chunk.segment), + ...findDestructiveTargets(chunk.segment), + ...gitDestructiveTargets(chunk.segment, chunk.cwds[0]), + ]; + + const expanded = raw.flatMap((target) => { + const words = loopBoundWords(target.path, bindings); + return words === null ? [target] : words.map((word) => destructiveTarget(word, target.operation)); + }); + + const chunkTargets = expanded.flatMap((target) => + chunk.cwds.map((chunkCwd) => ({ ...target, baseCwd: chunkCwd })) + ); + + if (!layer.remote) { + targets.push(...chunkTargets); + continue; + } + targets.push( + ...chunkTargets + .filter((target) => chunk.explicitCwd || keepRemoteTarget(target)) + .map((target) => ({ ...target, remote: true })) + ); + } + } + return targets; } function isApplyPatchTool(toolName: string): boolean { @@ -1006,11 +1437,33 @@ function extractFileToolPaths(input: CodewithHookInput): Array<{ path: string; o return paths; } -function scopedBlockReason(operation: string, targetPath: string, rule: ProtectedPathRule): string { +function scopedBlockReason(operation: string, targetPath: string, rule: ProtectedPathRule, remote?: boolean): string { return [ - `Blocked scoped dangerous operation: ${operation} targets ${targetPath}.`, + `Blocked scoped dangerous operation: ${operation} targets ${targetPath}${remote ? " on a remote host" : ""}.`, `Protected scope: ${rule.label} (${rule.root}).`, "This guard is scoped; destructive commands outside protected roots are not blocked.", + "Delete a specific named subdirectory instead of the root or its contents.", + ].join(" "); +} + +function collapseBlockReason( + operation: string, + rawTarget: string, + collapsedTarget: string, + rule: ProtectedPathRule, + remote?: boolean +): string { + return [ + `Blocked unsafe expansion in a destructive command: ${operation} target ${rawTarget}`, + `collapses to ${collapsedTarget}${remote ? " on a remote host" : ""} when the expansion returns empty`, + "(a command substitution that fails or prints nothing, or an unset variable),", + `which would destroy ${rule.label} (${rule.root}).`, + "This is the 2026-07-24 station02 failure: `bun pm cache` exits non-zero with empty stdout when no", + "package.json is found walking up from cwd, so `rm -rf \"$(bun pm cache)\"/*` ran as `rm -rf /*`.", + "Redirecting stderr does not help - it discards the diagnostic, not the path.", + "Safe alternative: resolve the path first, verify it is non-empty and not a protected root, then delete it,", + 'e.g. `dir="$(bun pm cache)" || exit 1; case "$dir" in /|"") exit 1;; esac; rm -rf -- "$dir"`.', + "This guard blocks the shape, not the command: any expansion immediately followed by `/` can collapse to the filesystem root.", ].join(" "); } @@ -1021,10 +1474,14 @@ export async function classifyDangerousOperation(input: CodewithHookInput): Prom if (input.tool_name === "Bash") { for (const target of destructiveShellTargets(getCommand(input), cwd)) { - const targetPath = resolveFrom(cwd, target.path); - const extraRule = workspaceRoots.map((root) => hasnaDivisionRuleFor(targetPath, root)).find((rule): rule is ProtectedPathRule => Boolean(rule)); - const allRules = extraRule ? [...rules, extraRule] : rules; - for (const rule of allRules) { + const targetCwd = target.baseCwd ?? cwd; + const targetPath = resolveFrom(targetCwd, target.path); + const rulesFor = (path: string) => { + const extraRule = workspaceRoots.map((root) => hasnaDivisionRuleFor(path, root)).find((rule): rule is ProtectedPathRule => Boolean(rule)); + return extraRule ? [...rules, extraRule] : rules; + }; + + for (const rule of rulesFor(targetPath)) { if (threatensRule(targetPath, rule, currentManagedRepoRoot)) { return { block: true, @@ -1032,7 +1489,25 @@ export async function classifyDangerousOperation(input: CodewithHookInput): Prom protectedPath: rule.root, protectedLabel: rule.label, operation: target.operation, - reason: scopedBlockReason(target.operation, targetPath, rule), + reason: scopedBlockReason(target.operation, targetPath, rule, target.remote), + }; + } + } + + // Second pass over the same target as the shell would produce it if every expansion + // came back empty. The managed-worktree escape hatch is not applied here: an empty + // collapse leaves the worktree entirely, so it can never be the intended target. + if (target.collapsed === undefined) continue; + const collapsedPath = resolveFrom(targetCwd, target.collapsed); + for (const rule of rulesFor(collapsedPath)) { + if (threatensRule(collapsedPath, rule, null)) { + return { + block: true, + targetPath: collapsedPath, + protectedPath: rule.root, + protectedLabel: rule.label, + operation: target.operation, + reason: collapseBlockReason(target.operation, target.path, collapsedPath, rule, target.remote), }; } } diff --git a/hooks/pre-bash/README.md b/hooks/pre-bash/README.md index ad30bea..2e10c8c 100644 --- a/hooks/pre-bash/README.md +++ b/hooks/pre-bash/README.md @@ -6,8 +6,56 @@ This hook is OSS-safe: optional Hasna CLIs are best-effort and missing CLIs fail It also blocks scoped destructive shell operations such as recursive `rm`, `rsync --delete`, destructive `find`, and destructive `git clean` / `git reset ---hard` forms only when the resolved target threatens `~/.hasna`, configured -workspace roots, Hasna division/scope roots, or active repo/worktree roots. +--hard` forms when the resolved target threatens a protected root. + +## Protected roots + +- `/` and the system directories (`/usr`, `/etc`, `/bin`, `/lib`, `/var`, `/boot`, + `/home`, `/Users`, and the other FHS and macOS equivalents). Add machine-specific + entries with `HASNA_PROTECTED_SYSTEM_ROOTS` (colon-separated). `/tmp` is not + protected — scratch cleanup there is routine. +- `~/.hasna`, configured workspace roots, Hasna division/scope roots, and active + repo/worktree roots. + +These match in *root* mode: wiping a root or its contents (`rm -rf /usr`, +`rm -rf /usr/*`) blocks, while a targeted delete beneath one +(`rm -rf /usr/local/lib/my-build`) is allowed. + +## Expansions that can collapse to empty + +A destructive target containing a command substitution, backtick substitution or +variable expansion is checked twice: as written, and as the shell would render it +if the expansion returned empty. `rm -rf "$(anything)"/*`, `` rm -rf `cmd`/* ``, +`rm -rf "$VAR"/*` and `rm -rf "${VAR}"/*` are blocked by shape, whatever the +expansion is. + +This exists because of a realized incident: `bun pm cache` exits non-zero with an +empty stdout when no `package.json` is found walking up from cwd, so +`rm -rf "$(bun pm cache)"/*` ran as `rm -rf /*`. Redirecting stderr does not help +— it discards the diagnostic, not the path. + +Two forms are deliberately not blocked: + +- `${VAR:?}` / `${VAR:?message}`, which POSIX guarantees non-empty. (`${VAR?}` + without the colon permits an empty value and is *not* exempt.) +- A bare `rm -rf "$(cmd)"` with no trailing separator, which degrades to + `rm -rf ""` — rejected by `rm` without deleting anything. + +The recommended form is to resolve the path first and assert it: + +```bash +dir="$(bun pm cache)" || exit 1 +case "$dir" in /|"") exit 1;; esac +rm -rf -- "$dir" +``` + +## Wrappers + +Commands are unwrapped before scanning: `bash -c` / `sh -c` / `zsh -c`, `su -c`, +`runuser -c`, `eval`, and `ssh host '…'`, including nested combinations. `cd` is +tracked within a command, and a `for VAR in ` binding is followed into +`rm -rf "$VAR"`. Remote (`ssh`) layers only consider absolute targets, because a +remote relative path cannot be resolved against the local working directory. ## Install for Codewith diff --git a/hooks/worktree-guard/README.md b/hooks/worktree-guard/README.md index 9cce355..2b8c2c7 100644 --- a/hooks/worktree-guard/README.md +++ b/hooks/worktree-guard/README.md @@ -5,8 +5,15 @@ Codewith-native hook installed as `hooks run worktree-guard`. This hook is OSS-safe: optional Hasna CLIs are best-effort and missing CLIs fail open with concise warnings. Security gates only fail closed when a guarded commit/push scan runs successfully and finds possible secrets. It blocks scoped destructive shell operations and file-tool-like payloads when -the resolved target threatens `~/.hasna`, configured workspace roots, Hasna -division/scope roots, or active repo/worktree roots. +the resolved target threatens `/` or a system root (`/usr`, `/etc`, `/var`, +`/home`, …), `~/.hasna`, configured workspace roots, Hasna division/scope roots, +or active repo/worktree roots. + +It shares its classifier with `pre-bash`, so it also blocks destructive targets +whose command substitution or variable expansion could collapse to empty — +`rm -rf "$(cmd)"/*` and `rm -rf "$VAR"/*`. See +[`hooks/pre-bash/README.md`](../pre-bash/README.md) for the full rules and the +recommended safe form. ## Canonical worktree path diff --git a/src/hooks/codewith-native.test.ts b/src/hooks/codewith-native.test.ts index d3dca91..db3ab07 100644 --- a/src/hooks/codewith-native.test.ts +++ b/src/hooks/codewith-native.test.ts @@ -514,6 +514,62 @@ describe("Codewith-native hooks", () => { expect(allWorktrees.json.reason).toContain("Hasna state root ~/.hasna"); }); + /** + * End-to-end form of the 2026-07-24 station02 regression fixtures. The unit matrix in + * hooks/codewith-native-common.test.ts drives the classifier directly; this proves the + * installed `hooks run pre-bash` process emits decision:"block" for the same input, since + * that JSON is what actually stops the tool call. + * + * HOME is pinned to the incident machine's home so the three commands appear verbatim. + * No rm is executed - the hook only ever reads the command as a string. + */ + test("pre-bash blocks the rm -rf /* incident shapes end to end", async () => { + const incidentEnv = { HOME: "/home/hasna", HASNA_HOOKS_CACHE_DIR: tmp }; + + const run = (command: string) => runHook("pre-bash", { + hook_event_name: "PreToolUse", + session_id: "sess-rmrf-incident", + cwd: tmp, + model: "gpt-test", + permission_mode: "default", + tool_name: "Bash", + tool_input: { command }, + tool_use_id: "tool-rmrf-incident", + transcript_path: null, + turn_id: "turn-rmrf-incident", + }, { env: incidentEnv }); + + // Control: the classifier is wired up at all. This blocked before the change and must stay blocking. + const control = await run("rm -rf /home/hasna/.hasna"); + expect(control.exitCode).toBe(0); + expect(control.json.decision).toBe("block"); + expect(control.json.reason).toContain("Hasna state root ~/.hasna"); + + // Was {"continue":true} before the change. + const rootGlob = await run("rm -rf /*"); + expect(rootGlob.exitCode).toBe(0); + expect(rootGlob.json.decision).toBe("block"); + expect(rootGlob.json.reason).toContain("filesystem root /"); + + // Was {"continue":true} before the change. + const substitution = await run('rm -rf "$(bun pm cache)"/*'); + expect(substitution.exitCode).toBe(0); + expect(substitution.json.decision).toBe("block"); + expect(substitution.json.reason).toContain("collapses to /*"); + expect(substitution.json.reason).toContain("Safe alternative"); + + // The command as it was actually sent to station02. + const realized = await run(`bash -c 'rm -rf "$(bun pm cache)"/* ; bun add -g @hasna/connectors@1.3.45'`); + expect(realized.exitCode).toBe(0); + expect(realized.json.decision).toBe("block"); + + // Routine cleanup must still pass, or the guard gets turned off and the class recurs. + const allowed = await run("rm -rf dist .turbo"); + expect(allowed.exitCode).toBe(0); + expect(allowed.json.continue).toBe(true); + expect(allowed.json.decision).toBeUndefined(); + }); + test("pre-bash blocks protected-root content globs but allows nested cleanup globs", async () => { const repo = join(tmp, "repo"); mkdirSync(join(repo, "dist"), { recursive: true }); From bb3fbe2a4566a91aebe6e24020c0f773c6c95004 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Mon, 27 Jul 2026 00:28:03 +0300 Subject: [PATCH 02/14] fix(pre-bash,worktree-guard): close four bypasses found in adversarial 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. --- CHANGELOG.md | 5 + hooks/codewith-native-common.test.ts | 112 ++++++++- hooks/codewith-native-common.ts | 349 +++++++++++++++++++++++---- 3 files changed, 418 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 194801d..14157ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `cd` is tracked within a command, so `cd / && rm -rf *` and `cd "$(cmd)"/ && rm -rf ./*` block. - A `for VAR in ` binding is followed into `rm -rf "$VAR"`. - Block messages now name a safe alternative instead of only refusing. + - A glob in **any** path component counts, not only the last: `rm -rf /*/*` destroys `/usr/*`, `/etc/*` and `/home/*` and is now blocked. A bounded glob (`~/proj*/dist`, `/var/log/*.gz`) keeps its narrower check and stays allowed. + - Brace alternations are expanded, so `rm -rf /{bin,etc,home}` is seen as the root deletes it performs. + - Command-substitution **bodies** are scanned as scripts: `echo $(rm -rf /)` runs the delete and discards only its output. + - `cd` inside `( … )` or a pipeline stage no longer moves the guard's working directory, and `cd -` returns to the previous one. + - Expansion nesting has no depth limit (`$(dirname "$(dirname "$(cmd)")")`, `${A:-${B}}`), and expansions the shell cannot return empty — `$(pwd)`, `$PWD`, `${VAR:-nonempty}`, and variables assigned a non-empty literal earlier in the same command — are not treated as collapsible. Remediates the 2026-07-24 data-destruction incident in which `rm -rf "$(bun pm cache)"/*`, sent over ssh inside `bash -c`, ran as `rm -rf /*` (`bun pm cache` exits non-zero with empty stdout when no `package.json` is found walking up from cwd), freeing ~700 GB and permanently destroying one repository's only source copy. diff --git a/hooks/codewith-native-common.test.ts b/hooks/codewith-native-common.test.ts index 69cbe5f..751fe80 100644 --- a/hooks/codewith-native-common.test.ts +++ b/hooks/codewith-native-common.test.ts @@ -520,8 +520,13 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { test("recursive delete of every declared system root blocks, bare and wholesale-glob", async () => { for (const root of SYSTEM_PROTECTED_ROOTS) { if (root === "/") continue; - await expectBlocked(`rm -rf ${root}`); - await expectBlocked(`rm -rf ${root}/*`); + // The label is asserted, not just the verdict: /home (and /root, /Users on machines + // whose HOME sits under them) is also covered by the ~/.hasna tree rule, so a + // verdict-only assertion would still pass with that entry deleted from the list. + const bare = await expectBlocked(`rm -rf ${root}`); + expect(bare.protectedLabel).toBe(`system root ${root}`); + const glob = await expectBlocked(`rm -rf ${root}/*`); + expect(glob.protectedLabel).toBe(`system root ${root}`); } }); @@ -788,4 +793,107 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { await expectBlocked("find / -exec rm -rf {} \\;"); await expectBlocked("rsync -a --delete /var/empty/ /"); }); + + // ------------------------------------------------------------------------------------- + // Adversarial review round 2. Each of these got a full wipe past the first draft of this + // change, and the first two were REGRESSIONS it introduced against the previous release. + // ------------------------------------------------------------------------------------- + + test("a delete inside a command substitution is still a delete", async () => { + // Regression guard. Making $( ) atomic for the collapse rule removed the accidental + // coverage the old segment splitter gave, so `echo $(rm -rf /)` became invisible: the + // delete runs and only its OUTPUT is discarded. + for (const command of [ + "echo $(rm -rf /home/hasna/.hasna)", + "x=$(rm -rf /home/hasna/.hasna)", + "$(rm -rf /home/hasna/.hasna)", + "$(rm -rf /*)", + "x=$(rm -rf /*)", + 'echo "$(cd / && rm -rf *)"', + "echo `rm -rf /*`", + 'files=$(rm -rf "$(bun pm cache)"/*)', + ]) { + await expectBlocked(command); + } + }); + + test("a glob anywhere in the path counts, not only in the last component", async () => { + // `rm -rf /*/*` destroys /usr/*, /etc/*, /home/* - the incident's outcome, respelled. + for (const command of [ + "rm -rf /*/*", + "rm -rf /*/*/*", + "rm -rf /**/*", + "rm -rf /home/*/*", + "rm -rf ~/*/*", + "rm -rf /home/*/.hasna", + "rm -rf /*/bin", + "find /*/x -delete", + ]) { + await expectBlocked(command); + } + }); + + test("a bounded glob earlier in the path still does not over-block", async () => { + const home = mkdtempSync(join(tmpdir(), "hooks-midglob-")); + try { + await expectAllowed(`rm -rf ${home}/proj*/dist`, { home }); + await expectAllowed("rm -rf /var/log/*.gz", { home }); + await expectAllowed("rm -rf /etc/nginx/sites-enabled/*", { home }); + await expectAllowed("rm -rf /var/lib/docker/*", { home }); + } finally { + try { rmSync(home, { recursive: true, force: true }); } catch {} + } + }); + + test("brace alternations are expanded", async () => { + await expectBlocked("rm -rf /{bin,boot,etc,home,lib,opt,root,srv,usr,var}"); + await expectBlocked("rm -rf /{,usr,etc}"); + await expectBlocked("rm -rf ~/{.hasna,Downloads}"); + await expectAllowed("rm -rf ./{dist,build}"); + }); + + test("cd in a subshell or pipeline does not move the guard, and cd - comes back", async () => { + // Regression guard: tracking `cd` naively made these WEAKER than before the change, + // because the tracker followed a `cd` that the real shell confines to a child process. + const cwd = "/home/hasna/.hasna/projects/workspaces"; + for (const command of [ + "cd /var/tmp && ls && cd - && rm -rf *", + "(cd /var/tmp && ls); rm -rf *", + "cd /var/tmp | cat; rm -rf *", + "cd /var/tmp; cd $OLDPWD; rm -rf *", + "for f in a b; do (cd /var/tmp); done; rm -rf *", + ]) { + await expectBlocked(command, { cwd }); + } + await expectAllowed("cd /var/tmp && rm -rf scratch", { cwd }); + }); + + test("shell options taking a value do not hide the -c script", async () => { + for (const command of [ + "bash -o errexit -c 'rm -rf /*'", + "bash -o pipefail -c 'rm -rf /*'", + "sh -o errexit -c 'rm -rf /*'", + "bash --rcfile /dev/null -c 'rm -rf /*'", + ]) { + await expectBlocked(command); + } + }); + + test("expansion nesting has no depth limit", async () => { + await expectBlocked('rm -rf "$(dirname "$(dirname "$(bun pm cache)")")"/*'); + await expectBlocked('rm -rf "${A:-${B}}"/*'); + expect(emptyExpansionCollapse("${A:-${B}}/*")).toBe("/*"); + }); + + test("expansions the shell cannot return empty are not treated as collapsible", async () => { + // These block routine cleanup if mishandled, and a guard that blocks routine work + // gets switched off - which is how this class of incident recurs. + await expectAllowed('rm -rf "$(pwd)"/*'); + await expectAllowed('rm -rf "$PWD"/*'); + await expectAllowed('rm -rf "${BUILD_DIR:-/tmp/build}"/*'); + await expectAllowed('BUILD=/tmp/build && rm -rf "$BUILD"/*'); + // ...but only where the guarantee is real. + await expectBlocked('rm -rf "${BUILD_DIR-/tmp/build}"/*'); + await expectBlocked('BUILD=$(some-command) && rm -rf "$BUILD"/*'); + }); }); diff --git a/hooks/codewith-native-common.ts b/hooks/codewith-native-common.ts index 4caadf3..851746c 100644 --- a/hooks/codewith-native-common.ts +++ b/hooks/codewith-native-common.ts @@ -139,13 +139,29 @@ export interface GitCommandInfo { // what lets the expansion-collapse rule below see `$(cmd)/*` as a single target token. // If a substitution is left unterminated the command is malformed, so both tokenizers // re-run with substitution tracking disabled rather than swallow the rest of the input. -function splitShellSegmentsPass(command: string, atomicSubstitutions: boolean): { segments: string[]; unterminated: boolean } { +function splitShellSegmentsPass( + command: string, + atomicSubstitutions: boolean +): { segments: string[]; isolation: boolean[]; unterminated: boolean } { const segments: string[] = []; + const isolation: boolean[] = []; let current = ""; let quote: "'" | '"' | null = null; let escaped = false; let substitutionDepth = 0; let inBacktick = false; + let parenDepth = 0; + let pipedFromPrevious = false; + + const flush = (nextSeparator: string | null) => { + if (current.trim()) { + segments.push(current.trim()); + // A stage of a pipeline runs in its own process, as does anything inside `( … )`. + isolation.push(parenDepth > 0 || pipedFromPrevious || nextSeparator === "|"); + } + current = ""; + pipedFromPrevious = nextSeparator === "|"; + }; for (let i = 0; i < command.length; i += 1) { const ch = command[i]; @@ -192,22 +208,43 @@ function splitShellSegmentsPass(command: string, atomicSubstitutions: boolean): continue; } if (ch === ";" || ch === "|" || ch === "&" || ch === "(" || ch === ")" || ch === "\n") { - if (current.trim()) segments.push(current.trim()); - current = ""; - if ((ch === "|" || ch === "&") && command[i + 1] === ch) i += 1; + const doubled = (ch === "|" || ch === "&") && command[i + 1] === ch; + // `||` and `&&` are sequencing, not a pipe. + flush(ch === "|" && !doubled ? "|" : null); + if (ch === "(") parenDepth += 1; + else if (ch === ")") parenDepth = Math.max(0, parenDepth - 1); + if (doubled) i += 1; continue; } current += ch; } - if (current.trim()) segments.push(current.trim()); - return { segments, unterminated: substitutionDepth > 0 || inBacktick }; + flush(null); + return { segments, isolation, unterminated: substitutionDepth > 0 || inBacktick }; } function splitShellSegments(command: string): string[] { - const atomic = splitShellSegmentsPass(command, true); - if (!atomic.unterminated) return atomic.segments; - return splitShellSegmentsPass(command, false).segments; + return splitShellSegmentsDetailed(command).map((segment) => segment.text); +} + +/** A segment plus whether a `cd` in it changes the working directory of later segments. */ +interface ShellSegment { + text: string; + /** + * True when the segment runs in a subshell `( … )` or as a stage of a pipeline. A `cd` + * there affects only that child process, so treating it as persistent silently moves the + * guard's idea of cwd away from the directory the later `rm` actually runs in. + */ + isolated: boolean; +} + +function splitShellSegmentsDetailed(command: string): ShellSegment[] { + const pass = splitShellSegmentsPass(command, true); + const chosen = pass.unterminated ? splitShellSegmentsPass(command, false) : pass; + return chosen.segments.map((text, index) => ({ + text, + isolated: chosen.isolation[index] ?? false, + })); } function shellWordsPass(segment: string, atomicSubstitutions: boolean): { words: string[]; unterminated: boolean } { @@ -607,30 +644,67 @@ function mutatesProtectedPath(targetPath: string, rule: ProtectedPathRule): bool const CATCH_ALL_GLOB = /^(?:\*|\*\*|\.\*|\.\[!\.\]\*)$/; /** - * Base directory of a wholesale content wipe (`dir/*`). Everything under the base is - * destroyed, so this base is checked with the full threatensProtectedPath test - a - * protected root nested *under* the base is destroyed just as surely as the base itself. - * That asymmetry is why `rm -rf /` blocked before this change but `rm -rf /*` did not. + * Where a glob target starts matching, and whether that first glob is a catch-all. + * + * The glob is found by scanning *every* path component, not just the last one. Looking only + * at `basename` treats `rm -rf /*​/*` as a literal directory named `*` and lets it through, + * even though it destroys `/usr/*`, `/etc/*` and `/home/*` - the realized incident's outcome + * by a different spelling. + * + * `catchAll` distinguishes `dir/*` (matches every entry, so the whole of `dir` is destroyed + * and any protected root *under* `dir` goes with it) from `dir/build-*` (bounded by its + * literal prefix, so it cannot reach an arbitrary sibling and keeps the weaker test). */ -function catchAllWipeBase(targetPath: string): string | null { +function globWipeInfo(targetPath: string): { base: string; catchAll: boolean } | null { const target = resolve(targetPath); - return CATCH_ALL_GLOB.test(basename(target)) ? dirname(target) : null; + const parts = target.split(sep); + for (let i = 0; i < parts.length; i += 1) { + if (!/[*?[]/.test(parts[i])) continue; + const base = parts.slice(0, i).join(sep) || sep; + return { base, catchAll: CATCH_ALL_GLOB.test(parts[i]) }; + } + return null; } +const MAX_BRACE_EXPANSIONS = 64; + /** - * Base directory of a narrower glob (`dir/build-*`). Such a glob cannot reach an - * arbitrary sibling, so it keeps the weaker mutatesProtectedPath test it always had. + * Expand `{a,b}` alternations, so `rm -rf /{bin,etc,home}` is seen as the three root deletes + * it performs rather than as one literal path. Bounded, and returns the original token + * unchanged when there is no brace or the expansion would exceed the cap. */ -function narrowGlobWipeBase(targetPath: string): string | null { - const target = resolve(targetPath); - const last = basename(target); - if (!/[*?\[]/.test(last)) return null; - return dirname(target); -} +function expandBraces(token: string): string[] { + const open = token.indexOf("{"); + if (open === -1) return [token]; -// Command substitutions, backtick substitutions and variable expansions, in the order -// they must be tried (longest construct first). -const SHELL_EXPANSION = /\$\((?:[^()]|\([^()]*\))*\)|`[^`]*`|\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*|\$[0-9@*?#$!-]/g; + let depth = 0; + let close = -1; + const parts: string[] = []; + let current = ""; + for (let i = open; i < token.length; i += 1) { + const ch = token[i]; + if (ch === "\\") { current += ch + (token[i + 1] ?? ""); i += 1; continue; } + if (ch === "{") { + depth += 1; + if (depth === 1) continue; + } else if (ch === "}") { + depth -= 1; + if (depth === 0) { close = i; break; } + } else if (ch === "," && depth === 1) { + parts.push(current); + current = ""; + continue; + } + current += ch; + } + if (close === -1 || parts.length === 0) return [token]; + parts.push(current); + + const prefix = token.slice(0, open); + const suffix = token.slice(close + 1); + const expanded = parts.flatMap((part) => expandBraces(`${prefix}${part}${suffix}`)); + return expanded.length > MAX_BRACE_EXPANSIONS ? [token] : expanded; +} // `${VAR:?}` / `${VAR:?message}` aborts the shell when VAR is unset *or* empty, so this // form cannot collapse. It is the POSIX way to assert a path is present, and blocking it @@ -639,6 +713,103 @@ const SHELL_EXPANSION = /\$\((?:[^()]|\([^()]*\))*\)|`[^`]*`|\$\{[^}]*\}|\$[A-Za const GUARDED_EXPANSION = /^\$\{[A-Za-z_][A-Za-z0-9_]*:\?/; const NON_EMPTY_PLACEHOLDER = "__hooks_guarded_expansion__"; +/** One shell expansion found in a token, with its exact source span. */ +interface FoundExpansion { + text: string; + start: number; + end: number; +} + +/** + * Locate shell expansions by scanning with a depth counter rather than by regex. + * + * A regex has to fix a nesting depth, and every fixed depth is a bypass: + * `$(dirname "$(dirname "$(bun pm cache)")")` is three deep, and `${A:-${B}}` nests braces. + */ +function findExpansions(token: string): FoundExpansion[] { + const found: FoundExpansion[] = []; + for (let i = 0; i < token.length; i += 1) { + if (token[i] === "\\") { + i += 1; + continue; + } + if (token[i] === "`") { + const end = token.indexOf("`", i + 1); + if (end === -1) break; + found.push({ text: token.slice(i, end + 1), start: i, end: end + 1 }); + i = end; + continue; + } + if (token[i] !== "$") continue; + + const next = token[i + 1]; + if (next === "(" || next === "{") { + const open = next; + const close = open === "(" ? ")" : "}"; + let depth = 0; + let j = i + 1; + for (; j < token.length; j += 1) { + if (token[j] === "\\") { j += 1; continue; } + if (token[j] === open) depth += 1; + else if (token[j] === close) { + depth -= 1; + if (depth === 0) break; + } + } + if (depth !== 0) break; + found.push({ text: token.slice(i, j + 1), start: i, end: j + 1 }); + i = j; + continue; + } + const simple = token.slice(i).match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*|[0-9@*?#$!-])/); + if (simple) { + found.push({ text: simple[0], start: i, end: i + simple[0].length }); + i += simple[0].length - 1; + } + } + return found; +} + +/** + * True when the shell cannot hand this expansion back empty. + * + * Every entry is a guarantee, not a guess. Getting this wrong in the permissive direction + * reopens the incident; getting it wrong in the strict direction blocks routine cleanup, + * which gets the guard switched off. Both failures are real, so only provable cases qualify. + */ +function expansionCannotBeEmpty(text: string, nonEmptyNames: ReadonlySet): boolean { + // ${VAR:?} / ${VAR:?message} - POSIX aborts on unset or empty. + if (GUARDED_EXPANSION.test(text)) return true; + + // ${VAR:-default} with a non-empty default. `:-` substitutes the default when VAR is unset + // OR empty, so the result is non-empty. Plain `${VAR-default}` does NOT qualify: it only + // covers unset, so a set-but-empty VAR still yields "". + const withDefault = text.match(/^\$\{[A-Za-z_][A-Za-z0-9_]*:-([\s\S]*)\}$/); + if (withDefault) { + // The default can itself be an expansion, so it only guarantees non-emptiness if what + // survives collapsing it is still non-empty. `${A:-${B}}` guarantees nothing. + const fallback = withDefault[1]; + if (fallback.length === 0) return false; + let residue = ""; + let cursor = 0; + for (const inner of findExpansions(fallback)) { + residue += fallback.slice(cursor, inner.start); + if (expansionCannotBeEmpty(inner.text, nonEmptyNames)) residue += NON_EMPTY_PLACEHOLDER; + cursor = inner.end; + } + residue += fallback.slice(cursor); + return residue.length > 0; + } + + // $PWD and $(pwd) are maintained by the shell itself and are never empty. + if (text === "$PWD" || text === "${PWD}") return true; + if (/^\$\(\s*pwd\s*\)$/.test(text) || /^`\s*pwd\s*`$/.test(text)) return true; + + // Assigned a non-empty literal earlier in this same command. + const name = text.match(/^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/); + return name !== null && nonEmptyNames.has(name[1]); +} + /** * The shape that destroyed station02 on 2026-07-24. * @@ -657,18 +828,51 @@ const NON_EMPTY_PLACEHOLDER = "__hooks_guarded_expansion__"; * relative collapse stays inside cwd and is already covered by the ordinary target check. * The whole catastrophic class is the one where the collapse leaves a leading `/`. */ -export function emptyExpansionCollapse(token: string): string | null { +export function emptyExpansionCollapse( + token: string, + nonEmptyNames: ReadonlySet = new Set() +): string | null { if (!/[$`]/.test(token)) return null; + const expansions = findExpansions(token); + if (expansions.length === 0) return null; + let sawCollapsible = false; - const collapsed = token.replace(SHELL_EXPANSION, (match) => { - if (GUARDED_EXPANSION.test(match)) return NON_EMPTY_PLACEHOLDER; - sawCollapsible = true; - return ""; - }); + let collapsed = ""; + let cursor = 0; + for (const expansion of expansions) { + collapsed += token.slice(cursor, expansion.start); + if (expansionCannotBeEmpty(expansion.text, nonEmptyNames)) { + collapsed += NON_EMPTY_PLACEHOLDER; + } else { + sawCollapsible = true; + } + cursor = expansion.end; + } + collapsed += token.slice(cursor); + if (!sawCollapsible || !collapsed.startsWith("/")) return null; return collapsed; } +/** + * Variables assigned a non-empty literal earlier in the same command, e.g. + * `BUILD=/tmp/build && rm -rf "$BUILD"/*`. Without this the collapse rule treats `$BUILD` as + * possibly-empty and blocks a command whose own text proves it is not. + * Only literal values count - `X=$(cmd)` is still collapsible, which is the point. + */ +function nonEmptyAssignedNames(command: string): Set { + const names = new Set(); + for (const segment of splitShellSegments(command)) { + for (const token of shellWords(segment)) { + const assignment = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!assignment) break; + const [, name, value] = assignment; + if (value.length > 0 && !/[$`]/.test(value)) names.add(name); + } + } + return names; +} + function shouldSkipHasnaTreeRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean { if (rule.label !== "Hasna state root ~/.hasna") return false; if (!currentManagedRepoRoot) return false; @@ -860,10 +1064,11 @@ async function verifiedManagedRepoRoot( function threatensRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean { if (shouldSkipHasnaTreeRule(targetPath, rule, currentManagedRepoRoot)) return false; - const catchAllBase = catchAllWipeBase(targetPath); - if (catchAllBase && threatensProtectedPath(catchAllBase, rule)) return true; - const narrowBase = narrowGlobWipeBase(targetPath); - if (narrowBase && mutatesProtectedPath(narrowBase, rule)) return true; + const glob = globWipeInfo(targetPath); + if (glob) { + if (glob.catchAll && threatensProtectedPath(glob.base, rule)) return true; + if (!glob.catchAll && mutatesProtectedPath(glob.base, rule)) return true; + } return threatensProtectedPath(targetPath, rule); } @@ -883,8 +1088,12 @@ interface DestructiveShellTarget { baseCwd?: string; } -function destructiveTarget(path: string, operation: string): DestructiveShellTarget { - const collapsed = emptyExpansionCollapse(path); +function destructiveTarget( + path: string, + operation: string, + nonEmptyNames: ReadonlySet = new Set() +): DestructiveShellTarget { + const collapsed = emptyExpansionCollapse(path, nonEmptyNames); return collapsed === null ? { path, operation } : { path, operation, collapsed }; } @@ -1174,6 +1383,11 @@ function isShellInterpreterToken(token: string): boolean { return SHELL_INTERPRETERS.has(commandName(token)); } +// Shell options that consume the following word, so its value is not mistaken for the script +// operand. Without this, `bash -o errexit -c '...'` reads `errexit` as the script file and the +// `-c` script is never scanned. +const SHELL_OPTIONS_WITH_VALUE = new Set(["-o", "+o", "--rcfile", "--init-file"]); + /** * Script passed via `-c`. For a shell, the first bare operand is the script *file* and the * scan stops there; `su`/`runuser` take a username operand first, so one is skipped. @@ -1184,6 +1398,10 @@ function interpreterScriptFrom(tokens: string[], shellIndex: number, allowedOper const token = tokens[i]; // -c, and combined short forms such as -lc / -euxc. if (/^-[A-Za-z]*c$/.test(token)) return tokens[i + 1] ?? null; + if (SHELL_OPTIONS_WITH_VALUE.has(token)) { + i += 1; + continue; + } if (!token.startsWith("-")) { operands += 1; if (operands > allowedOperands) return null; @@ -1192,6 +1410,23 @@ function interpreterScriptFrom(tokens: string[], shellIndex: number, allowedOper return null; } +/** + * Bodies of `$( … )` and backtick substitutions, as scripts in their own right. + * + * Required because the tokenizer treats substitutions atomically so the collapse rule can see + * them whole. Without feeding the bodies back in, `echo $(rm -rf /*)` contains no `rm` token + * at all and every rule misses it - the delete runs, its output is simply discarded. + */ +function substitutionBodies(segment: string): string[] { + return findExpansions(segment) + .filter((expansion) => expansion.text.startsWith("$(") || expansion.text.startsWith("`")) + .map((expansion) => (expansion.text.startsWith("`") + ? expansion.text.slice(1, -1) + : expansion.text.slice(2, -1))) + .map((body) => body.trim()) + .filter((body) => body.length > 0); +} + function sshRemoteCommandFrom(tokens: string[], sshIndex: number): string | null { for (let i = sshIndex + 1; i < tokens.length; i += 1) { const token = tokens[i]; @@ -1243,6 +1478,12 @@ function wrappedShellLayers(command: string, remote: boolean): ShellCommandLayer if (script) layers.push({ command: script, remote: true }); } } + + // A substitution body executes wherever it appears, including in assignments and in + // arguments to commands that do nothing with the result. + for (const body of substitutionBodies(segment)) { + layers.push({ command: body, remote: remote || sshSeen }); + } } return layers; } @@ -1300,22 +1541,31 @@ const MAX_CWD_VARIANTS = 4; * collapsed variant covers `cd "$(cmd)"/ && rm -rf ./*`, which is the incident's shape moved * one command to the left. */ -function cwdTrackedSegments(command: string, baseCwd: string): CommandChunk[] { +function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: ReadonlySet): CommandChunk[] { const chunks: CommandChunk[] = []; const home = process.env.HOME || homedir(); let cwds = [baseCwd]; + let previousCwds = [baseCwd]; let explicitCwd = false; - for (const segment of splitShellSegments(command)) { + for (const { text: segment, isolated } of splitShellSegmentsDetailed(command)) { const tokens = shellWords(segment); if (tokens[0] === "cd") { + // A `cd` inside `( … )` or in a pipeline stage does not move the parent shell, so + // applying it would move the guard's cwd away from where the later `rm` actually runs. + if (isolated) continue; + const operand = tokens[1]; - // `cd -` returns somewhere this scan cannot know, so the previous cwd is kept. - if (operand === undefined) { + const priorCwds = cwds; + if (operand === undefined || operand === "~") { cwds = [home]; explicitCwd = true; - } else if (operand !== "-" && !operand.startsWith("-")) { - const collapsed = emptyExpansionCollapse(operand); + } else if (operand === "-" || operand === "$OLDPWD" || operand === "${OLDPWD}") { + // `cd -` returns to the directory the shell was in before the last cd. + cwds = previousCwds; + explicitCwd = previousCwds.some((dir) => dir !== baseCwd); + } else if (!operand.startsWith("-")) { + const collapsed = emptyExpansionCollapse(operand, nonEmptyNames); const next = new Set(); for (const current of cwds) { next.add(resolveFrom(current, operand)); @@ -1323,7 +1573,10 @@ function cwdTrackedSegments(command: string, baseCwd: string): CommandChunk[] { } cwds = [...next].slice(0, MAX_CWD_VARIANTS); if (isAbsolute(expandHome(operand)) || collapsed !== null) explicitCwd = true; + } else { + continue; } + previousCwds = priorCwds; continue; } chunks.push({ segment, cwds, explicitCwd }); @@ -1361,8 +1614,9 @@ function destructiveShellTargets(command: string, cwd: string): DestructiveShell const targets: DestructiveShellTarget[] = []; for (const layer of shellCommandLayers(command)) { const bindings = forLoopBindings(layer.command); + const nonEmptyNames = nonEmptyAssignedNames(layer.command); - for (const chunk of cwdTrackedSegments(layer.command, cwd)) { + for (const chunk of cwdTrackedSegments(layer.command, cwd, nonEmptyNames)) { const raw = [ ...rmCommandTargets(chunk.segment), ...rsyncDeleteTargets(chunk.segment), @@ -1372,7 +1626,10 @@ function destructiveShellTargets(command: string, cwd: string): DestructiveShell const expanded = raw.flatMap((target) => { const words = loopBoundWords(target.path, bindings); - return words === null ? [target] : words.map((word) => destructiveTarget(word, target.operation)); + const paths = words ?? expandBraces(target.path); + return paths.length === 1 && paths[0] === target.path && words === null + ? [destructiveTarget(target.path, target.operation, nonEmptyNames)] + : paths.map((word) => destructiveTarget(word, target.operation, nonEmptyNames)); }); const chunkTargets = expanded.flatMap((target) => From df0d539d6bcff59d38fb5ab460dd86d9524afa8c Mon Sep 17 00:00:00 2001 From: andreihasna Date: Mon, 27 Jul 2026 00:33:48 +0300 Subject: [PATCH 03/14] fix(pre-bash): bound brace expansion so it cannot stall the hook into 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. --- hooks/codewith-native-common.test.ts | 10 ++++++ hooks/codewith-native-common.ts | 52 ++++++++++++++++++++++------ 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/hooks/codewith-native-common.test.ts b/hooks/codewith-native-common.test.ts index 751fe80..af84f48 100644 --- a/hooks/codewith-native-common.test.ts +++ b/hooks/codewith-native-common.test.ts @@ -852,6 +852,16 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { await expectAllowed("rm -rf ./{dist,build}"); }); + test("combinatorial brace input cannot stall the hook into failing open", async () => { + // Brace expansion is combinatorial: 26 groups is 2^26 paths. A version that capped only + // the finished list took 19.75s, past this hook's 20s timeout - and a timed-out hook + // fails open, so a long enough brace string would switch the guard off and then delete. + const command = `rm -rf ${Array.from({ length: 26 }, (_, i) => `/{a${i},b${i}}`).join("")}`; + const started = performance.now(); + await classify(command); + expect(performance.now() - started).toBeLessThan(2000); + }); + test("cd in a subshell or pipeline does not move the guard, and cd - comes back", async () => { // Regression guard: tracking `cd` naively made these WEAKER than before the change, // because the tracker followed a `cd` that the real shell confines to a child process. diff --git a/hooks/codewith-native-common.ts b/hooks/codewith-native-common.ts index 851746c..ecc3077 100644 --- a/hooks/codewith-native-common.ts +++ b/hooks/codewith-native-common.ts @@ -667,15 +667,12 @@ function globWipeInfo(targetPath: string): { base: string; catchAll: boolean } | } const MAX_BRACE_EXPANSIONS = 64; +const MAX_BRACE_ROUNDS = 16; -/** - * Expand `{a,b}` alternations, so `rm -rf /{bin,etc,home}` is seen as the three root deletes - * it performs rather than as one literal path. Bounded, and returns the original token - * unchanged when there is no brace or the expansion would exceed the cap. - */ -function expandBraces(token: string): string[] { +/** Expand only the leftmost brace group of a token; null when there is none to expand. */ +function expandLeftmostBrace(token: string): string[] | null { const open = token.indexOf("{"); - if (open === -1) return [token]; + if (open === -1) return null; let depth = 0; let close = -1; @@ -697,13 +694,48 @@ function expandBraces(token: string): string[] { } current += ch; } - if (close === -1 || parts.length === 0) return [token]; + if (close === -1 || parts.length === 0) return null; parts.push(current); const prefix = token.slice(0, open); const suffix = token.slice(close + 1); - const expanded = parts.flatMap((part) => expandBraces(`${prefix}${part}${suffix}`)); - return expanded.length > MAX_BRACE_EXPANSIONS ? [token] : expanded; + return parts.map((part) => `${prefix}${part}${suffix}`); +} + +/** + * Expand `{a,b}` alternations, so `rm -rf /{bin,etc,home}` is seen as the three root deletes + * it performs rather than as one literal path. + * + * Expansion is breadth-first and abandoned the moment it exceeds the cap, because brace + * expansion is combinatorial: `/{a,b}` repeated 26 times is 2^26 paths. A recursive version + * that capped only the finished list took 19.75s on that input, past this hook's 20s timeout + * - and a hook that times out fails open, so a long enough brace string would have switched + * the guard off and then run the delete. Abandoning returns the unexpanded token, which is + * still checked by every other rule. + */ +function expandBraces(token: string): string[] { + if (!token.includes("{")) return [token]; + + let frontier = [token]; + for (let round = 0; round < MAX_BRACE_ROUNDS; round += 1) { + const next: string[] = []; + let expandedAny = false; + for (const item of frontier) { + const parts = expandLeftmostBrace(item); + if (parts === null) { + next.push(item); + continue; + } + expandedAny = true; + for (const part of parts) { + if (next.length >= MAX_BRACE_EXPANSIONS) return [token]; + next.push(part); + } + } + if (!expandedAny) return next; + frontier = next; + } + return [token]; } // `${VAR:?}` / `${VAR:?message}` aborts the shell when VAR is unset *or* empty, so this From e35eb746aaece5232decad323e06c364b99be8e4 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Mon, 27 Jul 2026 00:35:47 +0300 Subject: [PATCH 04/14] fix(pre-bash): make the brace-expansion cap fall back conservatively, not permissively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hooks/codewith-native-common.test.ts | 11 +++++++++++ hooks/codewith-native-common.ts | 20 ++++++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/hooks/codewith-native-common.test.ts b/hooks/codewith-native-common.test.ts index af84f48..39458b3 100644 --- a/hooks/codewith-native-common.test.ts +++ b/hooks/codewith-native-common.test.ts @@ -852,6 +852,17 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { await expectAllowed("rm -rf ./{dist,build}"); }); + test("exceeding the brace cap falls back conservatively rather than becoming a bypass", async () => { + // Abandoning expansion by returning the raw token was itself a hole: the unexpanded + // token resolved to a literal path matching no protected root. + const many = Array.from({ length: 70 }, (_, i) => `a${i}`).join(","); + await expectBlocked(`rm -rf /{${many},etc}`); + await expectBlocked(`rm -rf ~/{${many}}`); + // Ordinary alternations under the cap keep expanding normally. + await expectAllowed("rm -rf ./{dist,build}"); + await expectAllowed("rm -rf /tmp/{a,b,c}"); + }); + test("combinatorial brace input cannot stall the hook into failing open", async () => { // Brace expansion is combinatorial: 26 groups is 2^26 paths. A version that capped only // the finished list took 19.75s, past this hook's 20s timeout - and a timed-out hook diff --git a/hooks/codewith-native-common.ts b/hooks/codewith-native-common.ts index ecc3077..a0060d5 100644 --- a/hooks/codewith-native-common.ts +++ b/hooks/codewith-native-common.ts @@ -710,9 +710,21 @@ function expandLeftmostBrace(token: string): string[] | null { * expansion is combinatorial: `/{a,b}` repeated 26 times is 2^26 paths. A recursive version * that capped only the finished list took 19.75s on that input, past this hook's 20s timeout * - and a hook that times out fails open, so a long enough brace string would have switched - * the guard off and then run the delete. Abandoning returns the unexpanded token, which is - * still checked by every other rule. + * the guard off and then run the delete. + * + * Abandoning does NOT return the raw token. Doing that was itself a bypass: + * `rm -rf /{a0,…,a69,etc}` exceeded the cap and the unexpanded token resolved to a literal + * path matching no protected root. Instead the brace-free prefix is returned as a catch-all + * wipe, which is what an unbounded alternation under that prefix actually is - every + * expansion is necessarily a child of it. */ +function braceAbandonFallback(token: string): string[] { + const open = token.indexOf("{"); + const prefix = open === -1 ? token : token.slice(0, open); + const base = prefix.endsWith(sep) || prefix === "" ? prefix : `${prefix}${sep}`; + return [`${base}*`]; +} + function expandBraces(token: string): string[] { if (!token.includes("{")) return [token]; @@ -728,14 +740,14 @@ function expandBraces(token: string): string[] { } expandedAny = true; for (const part of parts) { - if (next.length >= MAX_BRACE_EXPANSIONS) return [token]; + if (next.length >= MAX_BRACE_EXPANSIONS) return braceAbandonFallback(token); next.push(part); } } if (!expandedAny) return next; frontier = next; } - return [token]; + return braceAbandonFallback(token); } // `${VAR:?}` / `${VAR:?message}` aborts the shell when VAR is unset *or* empty, so this From 9b471e5a23a41acec9237372ea00b5492ee79438 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Mon, 27 Jul 2026 00:39:56 +0300 Subject: [PATCH 05/14] fix(pre-bash): make every analysis limit fail closed, and fix quoted 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. --- hooks/codewith-native-common.test.ts | 26 +++++++++ hooks/codewith-native-common.ts | 82 ++++++++++++++++++++++++---- 2 files changed, 97 insertions(+), 11 deletions(-) diff --git a/hooks/codewith-native-common.test.ts b/hooks/codewith-native-common.test.ts index 39458b3..44c3cd2 100644 --- a/hooks/codewith-native-common.test.ts +++ b/hooks/codewith-native-common.test.ts @@ -863,6 +863,32 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { await expectAllowed("rm -rf /tmp/{a,b,c}"); }); + test("a command too nested to analyse is refused, not waved through", async () => { + // Padding with dummy wrappers pushed the real delete past MAX_SHELL_LAYERS and it + // returned continue. Every cap in this file must fail closed: "too complex to analyse" + // must never mean "allowed". + const pad = (n: number) => Array.from({ length: n }, (_, i) => `sh -c 'a${i}'`).join(" ; "); + await expectBlocked(`${pad(32)} ; sh -c 'rm -rf /*'`); + await expectBlocked(`${pad(40)} ; sh -c 'rm -rf /*'`); + // Deep wrapper nesting, built programmatically so the escaping is not hand-maintained. + const nest = (inner: string, depth: number) => { + let out = inner; + for (let i = 0; i < depth; i += 1) out = `sh -c ${JSON.stringify(out)}`; + return out; + }; + for (const depth of [2, 3, 4, 5]) await expectBlocked(nest("rm -rf /*", depth)); + // Refusal is scoped to commands that actually contain a destructive verb. + await expectAllowed(pad(40)); + await expectAllowed(nest("rm -rf dist", 2)); + }); + + test("a quoted paren inside a substitution does not disable the collapse rule", async () => { + // The unterminated-substitution fallback turned an ordinary awk field separator into a + // bypass, because the quoted "(" was counted as structure. + await expectBlocked(`rm -rf "$(awk -F'(' '{print $2}' conf.txt)"/*`); + await expectBlocked(`rm -rf "$(echo ")")"/*`); + }); + test("combinatorial brace input cannot stall the hook into failing open", async () => { // Brace expansion is combinatorial: 26 groups is 2^26 paths. A version that capped only // the finished list took 19.75s, past this hook's 20s timeout - and a timed-out hook diff --git a/hooks/codewith-native-common.ts b/hooks/codewith-native-common.ts index a0060d5..dd5d58f 100644 --- a/hooks/codewith-native-common.ts +++ b/hooks/codewith-native-common.ts @@ -149,6 +149,7 @@ function splitShellSegmentsPass( let quote: "'" | '"' | null = null; let escaped = false; let substitutionDepth = 0; + let substitutionQuote: "'" | '"' | null = null; let inBacktick = false; let parenDepth = 0; let pipedFromPrevious = false; @@ -177,7 +178,12 @@ function splitShellSegmentsPass( } if (atomicSubstitutions && substitutionDepth > 0) { current += ch; - if (ch === "(") substitutionDepth += 1; + // Quotes inside the body are tracked so a quoted paren is not read as structure. + if (substitutionQuote) { + if (ch === substitutionQuote) substitutionQuote = null; + } else if (ch === "'" || ch === '"') { + substitutionQuote = ch; + } else if (ch === "(") substitutionDepth += 1; else if (ch === ")") substitutionDepth -= 1; continue; } @@ -253,6 +259,7 @@ function shellWordsPass(segment: string, atomicSubstitutions: boolean): { words: let quote: "'" | '"' | null = null; let escaped = false; let substitutionDepth = 0; + let substitutionQuote: "'" | '"' | null = null; let inBacktick = false; const push = () => { @@ -273,7 +280,11 @@ function shellWordsPass(segment: string, atomicSubstitutions: boolean): { words: // is what emptyExpansionCollapse inspects. if (atomicSubstitutions && substitutionDepth > 0) { current += ch; - if (ch === "(") substitutionDepth += 1; + if (substitutionQuote) { + if (ch === substitutionQuote) substitutionQuote = null; + } else if (ch === "'" || ch === '"') { + substitutionQuote = ch; + } else if (ch === "(") substitutionDepth += 1; else if (ch === ")") substitutionDepth -= 1; continue; } @@ -791,11 +802,19 @@ function findExpansions(token: string): FoundExpansion[] { const open = next; const close = open === "(" ? ")" : "}"; let depth = 0; + let quote: "'" | '"' | null = null; let j = i + 1; for (; j < token.length; j += 1) { - if (token[j] === "\\") { j += 1; continue; } - if (token[j] === open) depth += 1; - else if (token[j] === close) { + const ch = token[j]; + if (ch === "\\") { j += 1; continue; } + // A paren inside quotes is data, not structure: `awk -F'(' '{print $2}'`. + if (quote) { + if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === '"') { quote = ch; continue; } + if (ch === open) depth += 1; + else if (ch === close) { depth -= 1; if (depth === 0) break; } @@ -1535,16 +1554,21 @@ function wrappedShellLayers(command: string, remote: boolean): ShellCommandLayer const MAX_WRAPPER_DEPTH = 3; const MAX_SHELL_LAYERS = 32; -function shellCommandLayers(command: string): ShellCommandLayer[] { +function shellCommandLayers(command: string): { layers: ShellCommandLayer[]; truncated: boolean } { const layers: ShellCommandLayer[] = [{ command, remote: false }]; const seen = new Set([command]); let frontier: ShellCommandLayer[] = layers; + let truncated = false; - for (let depth = 0; depth < MAX_WRAPPER_DEPTH && layers.length < MAX_SHELL_LAYERS; depth += 1) { + for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth += 1) { const next: ShellCommandLayer[] = []; for (const layer of frontier) { for (const inner of wrappedShellLayers(layer.command, layer.remote)) { - if (seen.has(inner.command) || layers.length + next.length >= MAX_SHELL_LAYERS) continue; + if (seen.has(inner.command)) continue; + if (layers.length + next.length >= MAX_SHELL_LAYERS) { + truncated = true; + continue; + } seen.add(inner.command); next.push(inner); } @@ -1552,9 +1576,37 @@ function shellCommandLayers(command: string): ShellCommandLayer[] { if (next.length === 0) break; layers.push(...next); frontier = next; + // More wrappers remain below the depth limit. + if (depth === MAX_WRAPPER_DEPTH - 1 && next.some((layer) => wrappedShellLayers(layer.command, layer.remote).length > 0)) { + truncated = true; + } } - return layers; + return { layers, truncated }; +} + +// Verbs whose presence makes an unanalysable command unsafe to wave through. +const DESTRUCTIVE_VERB = /(?:^|[^\w.-])(?:[\w/.-]*\/)?(?:rm\s+(?:-\S*[rR]|--recursive|--dir)|rsync\s[^;&|]*--delete|find\s[^;&|]*(?:-delete|-execdir?\s)|git\s[^;&|]*(?:clean\s+-\S*[fd]|reset\s+--hard))/; + +/** + * A command too deeply wrapped or too wide to analyse within the caps is refused when it + * contains a destructive verb, instead of being allowed by default. + * + * The caps exist so a pathological command cannot stall the hook past its 20s timeout - and + * a timed-out hook fails open. But dropping work silently turns "too complex to analyse" + * into "allowed", which is the same passes-silently-while-protecting-nothing failure this + * guard exists to prevent. Padding with 32 dummy `sh -c` wrappers pushed the real delete + * past the cap and it returned continue. + */ +function truncatedAnalysisBlockReason(command: string): string | null { + if (!DESTRUCTIVE_VERB.test(command)) return null; + return [ + "Blocked: this command nests more shell wrappers than the safety guard can analyse,", + "and it contains a recursive delete. The guard refuses rather than guess, because an", + "unanalysable delete is exactly the shape that destroyed a machine on 2026-07-24.", + "Run the delete directly instead of through nested bash -c / ssh / eval wrappers,", + "with a literal, non-empty target path.", + ].join(" "); } /** @@ -1656,7 +1708,7 @@ function loopBoundWords(path: string, bindings: Map): string[] function destructiveShellTargets(command: string, cwd: string): DestructiveShellTarget[] { const targets: DestructiveShellTarget[] = []; - for (const layer of shellCommandLayers(command)) { + for (const layer of shellCommandLayers(command).layers) { const bindings = forLoopBindings(layer.command); const nonEmptyNames = nonEmptyAssignedNames(layer.command); @@ -1774,7 +1826,15 @@ export async function classifyDangerousOperation(input: CodewithHookInput): Prom const { rules, workspaceRoots, currentManagedRepoRoot } = await protectedPathContextFor(input, cwd); if (input.tool_name === "Bash") { - for (const target of destructiveShellTargets(getCommand(input), cwd)) { + const command = getCommand(input); + if (shellCommandLayers(command).truncated) { + const reason = truncatedAnalysisBlockReason(command); + if (reason) { + return { block: true, operation: "unanalysable nested command", reason }; + } + } + + for (const target of destructiveShellTargets(command, cwd)) { const targetCwd = target.baseCwd ?? cwd; const targetPath = resolveFrom(targetCwd, target.path); const rulesFor = (path: string) => { From f6def3c3d6b1a2eef91c44e6d86b576dcdec4d6e Mon Sep 17 00:00:00 2001 From: andreihasna Date: Mon, 27 Jul 2026 01:08:34 +0300 Subject: [PATCH 06/14] fix(pre-bash): close 30 escapes and 16 false positives from adversarial review round 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hooks/codewith-native-common.test.ts | 133 ++++++++++ hooks/codewith-native-common.ts | 362 ++++++++++++++++++++------- 2 files changed, 399 insertions(+), 96 deletions(-) diff --git a/hooks/codewith-native-common.test.ts b/hooks/codewith-native-common.test.ts index 44c3cd2..713b608 100644 --- a/hooks/codewith-native-common.test.ts +++ b/hooks/codewith-native-common.test.ts @@ -882,6 +882,139 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { await expectAllowed(nest("rm -rf dist", 2)); }); + // ------------------------------------------------------------------------------------- + // Adversarial review round 3. A fixed corpus, kept together deliberately: every entry here + // was a live escape or false positive at some commit ON THIS BRANCH, several of them + // introduced by the fix for the entry above it. Replay the whole block, never a subset. + // ------------------------------------------------------------------------------------- + + test("a cd inside a subshell still guards the rest of that subshell", async () => { + // Regression from the first isolation fix: `isolated` was read as "this cd never + // applies", but it only means "does not escape to the parent". `(cd X && rm -rf *)` is + // THE idiom for cd-without-moving-my-shell, and it was completely unguarded. + for (const command of [ + "(cd / && rm -rf *)", + "(cd /usr && rm -rf *)", + "(cd /home/hasna && rm -rf .hasna)", + `(cd "$(bun pm cache)"/ && rm -rf ./*)`, + `bash -c '(cd / && rm -rf *)'`, + "echo x | (cd / && rm -rf *)", + ]) { + await expectBlocked(command); + } + // ...while a cd that genuinely cannot reach the delete is still ignored. + await expectBlocked("cd /var/tmp | cat; rm -rf *", { cwd: "/home/hasna/.hasna/projects" }); + }); + + test("cd flag forms and brace groups are followed", async () => { + for (const command of [ + "cd -P / && rm -rf *", + "cd -L / && rm -rf *", + "cd -- / && rm -rf *", + "{ cd /; }; rm -rf *", + "pushd / && rm -rf *", + ]) { + await expectBlocked(command); + } + }); + + test("a non-empty assignment only counts where the shell would actually apply it", async () => { + for (const command of [ + // bash expands $X BEFORE applying a prefix assignment, so $X is still empty. + `X=/tmp/build rm -rf "$X"/*`, + `X=/tmp/build; X=$(bun pm cache); rm -rf "$X"/*`, + `rm -rf "$X"/* ; X=/tmp/build`, + `X=/tmp/build; X=; rm -rf "$X"/*`, + `(X=/tmp/build); rm -rf "$X"/*`, + `X=/tmp/build | cat; rm -rf "$X"/*`, + `PWD=$(bun pm cache); rm -rf "$PWD"/*`, + `PWD=; rm -rf "$PWD"/*`, + // The default IS the worst case, so it is substituted verbatim rather than assumed safe. + `rm -rf "\${A:-/}"/*`, + ]) { + await expectBlocked(command); + } + // The genuine guarantees still hold. + await expectAllowed(`X=/tmp/build; rm -rf "$X"/*`); + await expectAllowed(`rm -rf "\${BUILD_DIR:-/tmp/build}"/*`); + }); + + test("a delete nested inside a parameter expansion is still a delete", async () => { + await expectBlocked(`echo "\${x:-$(rm -rf /*)}"`); + await expectBlocked("echo \"\${x:-`rm -rf /*`}\""); + }); + + test("brace fallback covers absolute alternatives, and ${} does not abandon expansion", async () => { + const many = Array.from({ length: 70 }, (_, i) => `a${i}`).join(","); + // An absolute alternative is not a child of the brace-free prefix. + await expectBlocked(`rm -rf {/etc,${many}}`); + await expectBlocked(`rm -rf {/,${many}}`); + // A ${...} before the group is not a brace group; treating it as one abandoned the token. + await expectBlocked(`rm -rf "\${HOME}"/{,.hasna}`); + await expectBlocked(`rm -rf "\${HOME}"/{bin,.hasna}`); + }); + + test("an escaped character inside a substitution is data, not structure", async () => { + // Same root cause as the quoted-paren bug: quotes were fixed, backslashes were not. + await expectBlocked(`rm -rf "$(echo \\')"/*`); + await expectBlocked(`rm -rf "$(grep -c \\( f)"/*`); + }); + + test("a catch-all in the first component sweeps the filesystem root", async () => { + for (const command of ["rm -rf /*/bin", "rm -rf /*/*", "find /*/x -delete"]) { + await expectBlocked(command); + } + }); + + test("everyday monorepo and ops cleanup is not blocked", async () => { + // A guard that blocks `rm -rf */node_modules` at a monorepo root gets switched off, and + // then it protects nothing at all. Matching on "the first glob's parent" caused exactly + // that: the trailing literal bounds the delete, so the repo root is never destroyed. + const repo = mkdtempSync(join(tmpdir(), "hooks-monorepo-")); + try { + for (const command of [ + "rm -rf */node_modules", + "rm -rf */dist", + "rm -rf ./*/dist", + "rm -rf **/dist", + "rm -rf */*.log", + ]) { + await expectAllowed(command, { cwd: repo }); + } + for (const command of [ + "rm -rf /opt/*/logs", + "rm -rf /var/*/tmp", + "rm -rf /home/*/tmp", + "rm -rf /srv/*/cache", + "rm -rf ~/workspace/*/node_modules", + ]) { + await expectAllowed(command); + } + } finally { + try { rmSync(repo, { recursive: true, force: true }); } catch {} + } + }); + + test("ordinary commands never reach the unanalysable-command refusal", async () => { + // Every $( ) counts as a layer, so a low cap made 40 substitutions in one ops line trip a + // hard block on `rm -rf dist`. + const substitutions = Array.from({ length: 40 }, (_, i) => `echo $(echo ${i})`).join(" ; "); + await expectAllowed(`${substitutions} ; rm -rf dist`); + await expectAllowed(`${substitutions} ; rm -rf /tmp/scratch`); + await expectAllowed(`${substitutions} ; find . -name '*.log' -delete`); + await expectAllowed(`bash -c "sh -c 'sh -c \\"sh -c \\\\"rm -rf dist\\\\"\\"'"`); + }); + + test("deeply nested parameter expansion cannot stall the hook into failing open", async () => { + // 40k-deep `${A:-…}` overflowed the stack; the hook caught it and answered + // {"continue":true}, so the `rm -rf /*` in the same command was never classified. + const command = `rm -rf /* "${"${A:-".repeat(2000)}x${"}".repeat(2000)}"`; + const started = performance.now(); + const result = await classify(command); + expect(result.block).toBe(true); + expect(performance.now() - started).toBeLessThan(2000); + }); + test("a quoted paren inside a substitution does not disable the collapse rule", async () => { // The unterminated-substitution fallback turned an ordinary awk field separator into a // bypass, because the quoted "(" was counted as structure. diff --git a/hooks/codewith-native-common.ts b/hooks/codewith-native-common.ts index dd5d58f..5193bcd 100644 --- a/hooks/codewith-native-common.ts +++ b/hooks/codewith-native-common.ts @@ -142,9 +142,11 @@ export interface GitCommandInfo { function splitShellSegmentsPass( command: string, atomicSubstitutions: boolean -): { segments: string[]; isolation: boolean[]; unterminated: boolean } { +): { segments: string[]; isolation: boolean[]; depths: number[]; piped: boolean[]; unterminated: boolean } { const segments: string[] = []; const isolation: boolean[] = []; + const depths: number[] = []; + const pipedFlags: boolean[] = []; let current = ""; let quote: "'" | '"' | null = null; let escaped = false; @@ -159,6 +161,8 @@ function splitShellSegmentsPass( segments.push(current.trim()); // A stage of a pipeline runs in its own process, as does anything inside `( … )`. isolation.push(parenDepth > 0 || pipedFromPrevious || nextSeparator === "|"); + depths.push(parenDepth); + pipedFlags.push(pipedFromPrevious || nextSeparator === "|"); } current = ""; pipedFromPrevious = nextSeparator === "|"; @@ -226,7 +230,7 @@ function splitShellSegmentsPass( } flush(null); - return { segments, isolation, unterminated: substitutionDepth > 0 || inBacktick }; + return { segments, isolation, depths, piped: pipedFlags, unterminated: substitutionDepth > 0 || inBacktick }; } function splitShellSegments(command: string): string[] { @@ -236,6 +240,10 @@ function splitShellSegments(command: string): string[] { /** A segment plus whether a `cd` in it changes the working directory of later segments. */ interface ShellSegment { text: string; + /** Subshell nesting depth of this segment; a `cd` applies to this depth and deeper. */ + depth: number; + /** This segment is a pipeline stage, so its `cd` affects nothing outside the stage. */ + piped: boolean; /** * True when the segment runs in a subshell `( … )` or as a stage of a pipeline. A `cd` * there affects only that child process, so treating it as persistent silently moves the @@ -249,6 +257,8 @@ function splitShellSegmentsDetailed(command: string): ShellSegment[] { const chosen = pass.unterminated ? splitShellSegmentsPass(command, false) : pass; return chosen.segments.map((text, index) => ({ text, + depth: chosen.depths[index] ?? 0, + piped: chosen.piped[index] ?? false, isolated: chosen.isolation[index] ?? false, })); } @@ -276,11 +286,15 @@ function shellWordsPass(segment: string, atomicSubstitutions: boolean): { words: escaped = false; continue; } - // Substitution bodies are copied verbatim, quotes and spaces included: the raw text - // is what emptyExpansionCollapse inspects. + // Substitution bodies are copied verbatim - quotes, spaces AND backslashes. Consuming the + // escape here strips the backslash, and findExpansions then re-counts `\'` or `\(` as + // structure on the de-escaped text, which reopened the bug the quote fix closed. if (atomicSubstitutions && substitutionDepth > 0) { current += ch; - if (substitutionQuote) { + if (ch === "\\") { + current += segment[i + 1] ?? ""; + i += 1; + } else if (substitutionQuote) { if (ch === substitutionQuote) substitutionQuote = null; } else if (ch === "'" || ch === '"') { substitutionQuote = ch; @@ -290,7 +304,8 @@ function shellWordsPass(segment: string, atomicSubstitutions: boolean): { words: } if (atomicSubstitutions && inBacktick) { current += ch; - if (ch === "`") inBacktick = false; + if (ch === "\\") { current += segment[i + 1] ?? ""; i += 1; } + else if (ch === "`") inBacktick = false; continue; } if (ch === "\\" && quote !== "'") { @@ -654,27 +669,78 @@ function mutatesProtectedPath(targetPath: string, rule: ProtectedPathRule): bool // A trailing glob that matches every entry, so `dir/*` destroys all of `dir`. const CATCH_ALL_GLOB = /^(?:\*|\*\*|\.\*|\.\[!\.\]\*)$/; +function globComponentToRegExp(component: string): RegExp { + let source = ""; + for (let i = 0; i < component.length; i += 1) { + const ch = component[i]; + if (ch === "*") source += "[^/]*"; + else if (ch === "?") source += "[^/]"; + else source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + } + return new RegExp(`^${source}$`); +} + +function globComponentMatches(pattern: string, literal: string): boolean { + if (!/[*?[]/.test(pattern)) return pattern === literal; + if (CATCH_ALL_GLOB.test(pattern)) return true; + try { + return globComponentToRegExp(pattern).test(literal); + } catch { + return true; + } +} + /** - * Where a glob target starts matching, and whether that first glob is a catch-all. + * Could this glob pattern match `root` itself, or an ancestor of it? * - * The glob is found by scanning *every* path component, not just the last one. Looking only - * at `basename` treats `rm -rf /*​/*` as a literal directory named `*` and lets it through, - * even though it destroys `/usr/*`, `/etc/*` and `/home/*` - the realized incident's outcome - * by a different spelling. + * If it can, every expansion that lands there takes `root` with it. A pattern DEEPER than + * `root` cannot: `*​/node_modules` from a repo root deletes `/node_modules`, never the + * repo root, which is why matching only on "the first glob's parent directory" wrongly blocked + * `rm -rf *​/node_modules` - a daily monorepo command, and exactly the kind of false positive + * that gets a guard switched off. + */ +function globPatternCovers(patternParts: string[], rootParts: string[]): boolean { + if (patternParts.length > rootParts.length) return false; + return patternParts.every((part, index) => globComponentMatches(part, rootParts[index])); +} + +/** Components of the pattern up to, but not including, its first glob component. */ +function literalPrefixOf(parts: string[]): string { + const globIndex = parts.findIndex((part) => /[*?[]/.test(part)); + return (globIndex === -1 ? parts : parts.slice(0, globIndex)).join(sep) || sep; +} + +function pathHasGlob(targetPath: string): boolean { + return /[*?[]/.test(resolve(targetPath)); +} + +/** + * Does a glob delete threaten this rule? * - * `catchAll` distinguishes `dir/*` (matches every entry, so the whole of `dir` is destroyed - * and any protected root *under* `dir` goes with it) from `dir/build-*` (bounded by its - * literal prefix, so it cannot reach an arbitrary sibling and keeps the weaker test). + * Two ways, and both are needed: + * (a) the pattern can match the protected root or an ancestor of it - `rm -rf /*` matches + * `/home`, `rm -rf /*​/*` matches `/home/hasna`; + * (b) the pattern is a wholesale wipe of the root's own contents - `rm -rf /home/*`, whose + * last component is a catch-all and whose prefix covers `/home`. + * For a tree rule, a pattern sitting inside the tree also threatens it. */ -function globWipeInfo(targetPath: string): { base: string; catchAll: boolean } | null { - const target = resolve(targetPath); - const parts = target.split(sep); - for (let i = 0; i < parts.length; i += 1) { - if (!/[*?[]/.test(parts[i])) continue; - const base = parts.slice(0, i).join(sep) || sep; - return { base, catchAll: CATCH_ALL_GLOB.test(parts[i]) }; - } - return null; +function globThreatensRule(targetPath: string, rule: ProtectedPathRule): boolean { + const parts = resolve(targetPath).split(sep); + const rootParts = resolve(rule.root).split(sep); + + if (rule.mode === "tree" && isInsidePath(literalPrefixOf(parts), rule.root)) return true; + if (globPatternCovers(parts, rootParts)) return true; + + const last = parts[parts.length - 1]; + if (CATCH_ALL_GLOB.test(last) && globPatternCovers(parts.slice(0, -1), rootParts)) return true; + + // A catch-all in the FIRST component sweeps every top-level directory: `/*/bin` deletes + // /usr/bin, /var/bin and the rest, and a trailing literal makes the pattern deeper than any + // single root, so component matching alone misses it. Scoped to the filesystem root so + // ordinary sweeps deeper down - `/opt/*/logs`, `/var/*/tmp`, `*/node_modules` - stay allowed. + if (rule.root === sep && parts.length > 1 && CATCH_ALL_GLOB.test(parts[1])) return true; + + return false; } const MAX_BRACE_EXPANSIONS = 64; @@ -682,7 +748,23 @@ const MAX_BRACE_ROUNDS = 16; /** Expand only the leftmost brace group of a token; null when there is none to expand. */ function expandLeftmostBrace(token: string): string[] | null { - const open = token.indexOf("{"); + // Skip `${…}` parameter expansions when looking for an alternation: their brace is not a + // brace group, and treating it as one abandoned expansion for the whole token, so + // `rm -rf "${HOME}"/{,.hasna}` was never expanded at all. + let open = -1; + for (let i = 0; i < token.length; i += 1) { + if (token[i] !== "{") continue; + if (i > 0 && token[i - 1] === "$") { + let depth = 0; + for (; i < token.length; i += 1) { + if (token[i] === "{") depth += 1; + else if (token[i] === "}") { depth -= 1; if (depth === 0) break; } + } + continue; + } + open = i; + break; + } if (open === -1) return null; let depth = 0; @@ -733,7 +815,11 @@ function braceAbandonFallback(token: string): string[] { const open = token.indexOf("{"); const prefix = open === -1 ? token : token.slice(0, open); const base = prefix.endsWith(sep) || prefix === "" ? prefix : `${prefix}${sep}`; - return [`${base}*`]; + const fallback = [`${base}*`]; + // An alternative that is itself absolute is NOT a child of the prefix: `rm -rf {/etc,a0,…}` + // expands to `rm -rf /etc a0 …`, so the prefix-based fallback would miss `/etc` entirely. + if (/[{,]\s*\//.test(token)) fallback.push(`${sep}*`); + return fallback; } function expandBraces(token: string): string[] { @@ -767,6 +853,7 @@ function expandBraces(token: string): string[] { // is NOT exempt: it permits an empty value, which is the whole hazard. const GUARDED_EXPANSION = /^\$\{[A-Za-z_][A-Za-z0-9_]*:\?/; const NON_EMPTY_PLACEHOLDER = "__hooks_guarded_expansion__"; +const MAX_EXPANSION_NESTING = 32; /** One shell expansion found in a token, with its exact source span. */ interface FoundExpansion { @@ -806,6 +893,7 @@ function findExpansions(token: string): FoundExpansion[] { let j = i + 1; for (; j < token.length; j += 1) { const ch = token[j]; + // An escaped character is data whether or not a quote is open: `$(echo \')`. if (ch === "\\") { j += 1; continue; } // A paren inside quotes is data, not structure: `awk -F'(' '{print $2}'`. if (quote) { @@ -847,32 +935,55 @@ function expansionCannotBeEmpty(text: string, nonEmptyNames: ReadonlySet // ${VAR:-default} with a non-empty default. `:-` substitutes the default when VAR is unset // OR empty, so the result is non-empty. Plain `${VAR-default}` does NOT qualify: it only // covers unset, so a set-but-empty VAR still yields "". - const withDefault = text.match(/^\$\{[A-Za-z_][A-Za-z0-9_]*:-([\s\S]*)\}$/); - if (withDefault) { - // The default can itself be an expansion, so it only guarantees non-emptiness if what - // survives collapsing it is still non-empty. `${A:-${B}}` guarantees nothing. - const fallback = withDefault[1]; - if (fallback.length === 0) return false; - let residue = ""; - let cursor = 0; - for (const inner of findExpansions(fallback)) { - residue += fallback.slice(cursor, inner.start); - if (expansionCannotBeEmpty(inner.text, nonEmptyNames)) residue += NON_EMPTY_PLACEHOLDER; - cursor = inner.end; - } - residue += fallback.slice(cursor); - return residue.length > 0; + // $PWD and $(pwd) are maintained by the shell, but only while nothing reassigns PWD. + if (text === "$PWD" || text === "${PWD}" || /^\$\(\s*pwd\s*\)$/.test(text) || /^`\s*pwd\s*`$/.test(text)) { + return !nonEmptyNames.has("\u0000PWD-REASSIGNED"); } - // $PWD and $(pwd) are maintained by the shell itself and are never empty. - if (text === "$PWD" || text === "${PWD}") return true; - if (/^\$\(\s*pwd\s*\)$/.test(text) || /^`\s*pwd\s*`$/.test(text)) return true; - // Assigned a non-empty literal earlier in this same command. const name = text.match(/^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/); return name !== null && nonEmptyNames.has(name[1]); } +/** + * Value an expansion is guaranteed to take when the variable is unset or empty, or null when + * there is no such guarantee. + * + * `${VAR:-default}` substitutes the default whenever VAR is unset OR empty, so the worst case + * is the default itself - and the default is used verbatim rather than assumed harmless. + * `${A:-/}` therefore collapses to `/` and blocks, where treating "has a default" as "is safe" + * let it through. Plain `${VAR-default}` does NOT qualify: it only covers unset, so a + * set-but-empty VAR still yields "". + */ +function expansionFallbackValue( + text: string, + nonEmptyNames: ReadonlySet, + depth = 0 +): string | null { + const withDefault = text.match(/^\$\{[A-Za-z_][A-Za-z0-9_]*:-([\s\S]*)\}$/); + if (!withDefault) return null; + // Bounded because this recurses once per nesting level while re-scanning the remainder: + // `${A:-${A:- … }}` 40k deep overflowed the stack, the hook caught it and answered + // {"continue":true}, and the `rm -rf /*` in the same command was never classified at all. + // Past the cap there is no guarantee left to prove, so the value is treated as collapsible, + // which blocks rather than allows. + if (depth >= MAX_EXPANSION_NESTING) return ""; + const fallback = withDefault[1]; + if (fallback.length === 0) return ""; + + let value = ""; + let cursor = 0; + for (const inner of findExpansions(fallback)) { + value += fallback.slice(cursor, inner.start); + const nested = expansionFallbackValue(inner.text, nonEmptyNames, depth + 1); + if (nested !== null) value += nested; + else if (expansionCannotBeEmpty(inner.text, nonEmptyNames)) value += NON_EMPTY_PLACEHOLDER; + cursor = inner.end; + } + value += fallback.slice(cursor); + return value; +} + /** * The shape that destroyed station02 on 2026-07-24. * @@ -904,7 +1015,13 @@ export function emptyExpansionCollapse( let cursor = 0; for (const expansion of expansions) { collapsed += token.slice(cursor, expansion.start); - if (expansionCannotBeEmpty(expansion.text, nonEmptyNames)) { + const fallback = expansionFallbackValue(expansion.text, nonEmptyNames); + if (fallback !== null) { + // The default IS the worst case, so the resulting path still has to be checked - + // `${A:-/}` yields `/`, which is the whole hazard, not a reason to skip the check. + collapsed += fallback; + sawCollapsible = true; + } else if (expansionCannotBeEmpty(expansion.text, nonEmptyNames)) { collapsed += NON_EMPTY_PLACEHOLDER; } else { sawCollapsible = true; @@ -918,21 +1035,44 @@ export function emptyExpansionCollapse( } /** - * Variables assigned a non-empty literal earlier in the same command, e.g. - * `BUILD=/tmp/build && rm -rf "$BUILD"/*`. Without this the collapse rule treats `$BUILD` as - * possibly-empty and blocks a command whose own text proves it is not. - * Only literal values count - `X=$(cmd)` is still collapsible, which is the point. + * Variables that are provably non-empty at the point `segmentIndex` runs. + * + * Every relaxation here is a way to get a delete past the guard, so each condition is a + * guarantee rather than a heuristic. Recomputed per segment because the naive version - one + * set for the whole command - was defeated six different ways: + * + * X=/tmp/build rm -rf "$X"/* a PREFIX assignment applies to the command's own + * environment, not to the expansion, which bash performs + * first; `$X` is still empty + * rm -rf "$X"/* ; X=/tmp/build an assignment AFTER the delete counted + * X=/tmp/build; X=$(cmd); rm … a later reassignment to something collapsible did not + * invalidate the earlier literal + * X=/tmp/build; X=; rm … an explicit empty reassignment did not either + * (X=/tmp/build); rm … a subshell-scoped assignment escaped its subshell + * X=/tmp/build | cat; rm … a pipeline-stage assignment did the same */ -function nonEmptyAssignedNames(command: string): Set { +function nonEmptyAssignedNames(command: string, segmentIndex: number): Set { const names = new Set(); - for (const segment of splitShellSegments(command)) { - for (const token of shellWords(segment)) { - const assignment = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + const segments = splitShellSegmentsDetailed(command); + + segments.forEach(({ text, depth, isolated }, index) => { + // Only assignments that already ran, in the parent shell, in their own right. + if (index >= segmentIndex || depth > 0 || isolated) return; + + const tokens = shellWords(text); + for (const [position, token] of tokens.entries()) { + const assignment = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/); if (!assignment) break; const [, name, value] = assignment; + const isPrefixAssignment = position < tokens.length - 1 && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[position + 1] ?? ""); + // A later assignment always invalidates an earlier guarantee. + names.delete(name); + if (name === "PWD") names.add("\u0000PWD-REASSIGNED"); + if (isPrefixAssignment) continue; if (value.length > 0 && !/[$`]/.test(value)) names.add(name); } - } + }); + return names; } @@ -1127,11 +1267,7 @@ async function verifiedManagedRepoRoot( function threatensRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean { if (shouldSkipHasnaTreeRule(targetPath, rule, currentManagedRepoRoot)) return false; - const glob = globWipeInfo(targetPath); - if (glob) { - if (glob.catchAll && threatensProtectedPath(glob.base, rule)) return true; - if (!glob.catchAll && mutatesProtectedPath(glob.base, rule)) return true; - } + if (pathHasGlob(targetPath)) return globThreatensRule(targetPath, rule); return threatensProtectedPath(targetPath, rule); } @@ -1481,13 +1617,27 @@ function interpreterScriptFrom(tokens: string[], shellIndex: number, allowedOper * at all and every rule misses it - the delete runs, its output is simply discarded. */ function substitutionBodies(segment: string): string[] { - return findExpansions(segment) - .filter((expansion) => expansion.text.startsWith("$(") || expansion.text.startsWith("`")) - .map((expansion) => (expansion.text.startsWith("`") - ? expansion.text.slice(1, -1) - : expansion.text.slice(2, -1))) - .map((body) => body.trim()) - .filter((body) => body.length > 0); + const bodies: string[] = []; + const visit = (text: string, depth: number): void => { + if (depth > 4) return; + for (const expansion of findExpansions(text)) { + if (expansion.text.startsWith("$(") || expansion.text.startsWith("`")) { + const body = (expansion.text.startsWith("`") + ? expansion.text.slice(1, -1) + : expansion.text.slice(2, -1)).trim(); + if (body.length > 0) { + bodies.push(body); + visit(body, depth + 1); + } + continue; + } + // `${x:-$(rm -rf /*)}` runs the substitution when x is unset. findExpansions returns + // the outer ${...} and swallows the inner one, so the body has to be re-scanned. + if (expansion.text.startsWith("${")) visit(expansion.text.slice(2, -1), depth + 1); + } + }; + visit(segment, 0); + return bodies; } function sshRemoteCommandFrom(tokens: string[], sshIndex: number): string | null { @@ -1551,8 +1701,8 @@ function wrappedShellLayers(command: string, remote: boolean): ShellCommandLayer return layers; } -const MAX_WRAPPER_DEPTH = 3; -const MAX_SHELL_LAYERS = 32; +const MAX_WRAPPER_DEPTH = 8; +const MAX_SHELL_LAYERS = 256; function shellCommandLayers(command: string): { layers: ShellCommandLayer[]; truncated: boolean } { const layers: ShellCommandLayer[] = [{ command, remote: false }]; @@ -1620,6 +1770,8 @@ function keepRemoteTarget(target: DestructiveShellTarget): boolean { interface CommandChunk { segment: string; + /** Index of this segment in the layer, so assignment visibility can be ordered. */ + segmentIndex: number; /** Working directories this segment may run in: the tracked cwd, plus the cwd a `cd` * whose operand collapsed to empty would have left behind. */ cwds: string[]; @@ -1640,43 +1792,61 @@ const MAX_CWD_VARIANTS = 4; function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: ReadonlySet): CommandChunk[] { const chunks: CommandChunk[] = []; const home = process.env.HOME || homedir(); - let cwds = [baseCwd]; - let previousCwds = [baseCwd]; - let explicitCwd = false; + // One entry per subshell nesting depth. A `cd` inside `( … )` DOES apply to the rest of + // that subshell - it just does not escape to the parent - so skipping isolated `cd` + // outright left `(cd / && rm -rf *)`, the standard "cd without moving my shell" idiom, + // completely unguarded. Depth 0 is the parent shell. + let stack: Array<{ cwds: string[]; previous: string[]; explicit: boolean }> = [ + { cwds: [baseCwd], previous: [baseCwd], explicit: false }, + ]; - for (const { text: segment, isolated } of splitShellSegmentsDetailed(command)) { - const tokens = shellWords(segment); - if (tokens[0] === "cd") { - // A `cd` inside `( … )` or in a pipeline stage does not move the parent shell, so - // applying it would move the guard's cwd away from where the later `rm` actually runs. - if (isolated) continue; + const frameFor = (depth: number) => { + while (stack.length <= depth) { + const parent = stack[stack.length - 1]; + stack.push({ cwds: parent.cwds, previous: parent.previous, explicit: parent.explicit }); + } + // Leaving a subshell discards everything it did. + if (stack.length > depth + 1) stack = stack.slice(0, depth + 1); + return stack[depth]; + }; + + splitShellSegmentsDetailed(command).forEach(({ text: segment, depth, piped }, segmentIndex) => { + const frame = frameFor(depth); + // A leading `{` from a brace group is not part of the command. + const tokens = shellWords(segment).filter((token, index) => !(index === 0 && (token === "{" || token === "}"))); + const verb = tokens[0]; + + if (verb === "cd" || verb === "pushd") { + // A `cd` in a pipeline stage runs in its own process and moves nothing else. + if (piped) return; + // Skip cd's own flags (-P, -L, --) to reach the directory operand. + let i = 1; + while (i < tokens.length && (tokens[i] === "-P" || tokens[i] === "-L" || tokens[i] === "-e" || tokens[i] === "-@" || tokens[i] === "--")) i += 1; + const operand = tokens[i]; + const priorCwds = frame.cwds; - const operand = tokens[1]; - const priorCwds = cwds; if (operand === undefined || operand === "~") { - cwds = [home]; - explicitCwd = true; + frame.cwds = [home]; + frame.explicit = true; } else if (operand === "-" || operand === "$OLDPWD" || operand === "${OLDPWD}") { - // `cd -` returns to the directory the shell was in before the last cd. - cwds = previousCwds; - explicitCwd = previousCwds.some((dir) => dir !== baseCwd); - } else if (!operand.startsWith("-")) { + frame.cwds = frame.previous; + frame.explicit = frame.previous.some((dir) => dir !== baseCwd); + } else { const collapsed = emptyExpansionCollapse(operand, nonEmptyNames); const next = new Set(); - for (const current of cwds) { + for (const current of frame.cwds) { next.add(resolveFrom(current, operand)); if (collapsed !== null) next.add(resolveFrom(current, collapsed)); } - cwds = [...next].slice(0, MAX_CWD_VARIANTS); - if (isAbsolute(expandHome(operand)) || collapsed !== null) explicitCwd = true; - } else { - continue; + frame.cwds = [...next].slice(0, MAX_CWD_VARIANTS); + if (isAbsolute(expandHome(operand)) || collapsed !== null) frame.explicit = true; } - previousCwds = priorCwds; - continue; + frame.previous = priorCwds; + return; } - chunks.push({ segment, cwds, explicitCwd }); - } + + chunks.push({ segment, segmentIndex, cwds: frame.cwds, explicitCwd: frame.explicit }); + }); return chunks; } @@ -1710,9 +1880,9 @@ function destructiveShellTargets(command: string, cwd: string): DestructiveShell const targets: DestructiveShellTarget[] = []; for (const layer of shellCommandLayers(command).layers) { const bindings = forLoopBindings(layer.command); - const nonEmptyNames = nonEmptyAssignedNames(layer.command); - for (const chunk of cwdTrackedSegments(layer.command, cwd, nonEmptyNames)) { + for (const chunk of cwdTrackedSegments(layer.command, cwd, new Set())) { + const nonEmptyNames = nonEmptyAssignedNames(layer.command, chunk.segmentIndex); const raw = [ ...rmCommandTargets(chunk.segment), ...rsyncDeleteTargets(chunk.segment), From e8bc5a1d0d06fa512a1870e28308902939655831 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Mon, 27 Jul 2026 01:42:44 +0300 Subject: [PATCH 07/14] fix(pre-bash): close three critical regressions found in adversarial review round 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hooks/codewith-native-common.test.ts | 100 +++++++++++++ hooks/codewith-native-common.ts | 213 +++++++++++++++++++++------ 2 files changed, 268 insertions(+), 45 deletions(-) diff --git a/hooks/codewith-native-common.test.ts b/hooks/codewith-native-common.test.ts index 713b608..364a7f8 100644 --- a/hooks/codewith-native-common.test.ts +++ b/hooks/codewith-native-common.test.ts @@ -1015,6 +1015,106 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { expect(performance.now() - started).toBeLessThan(2000); }); + // ------------------------------------------------------------------------------------- + // Adversarial review round 4. Three of these were regressions introduced by round 3's own + // fixes. Kept as one block and replayed whole - subsetting is how the last four rounds of + // regressions got through. + // ------------------------------------------------------------------------------------- + + test("bracket-class globs are compiled, not escaped", async () => { + // `[e]tc` was escaped to the literal \[e\]tc, which no directory can equal, so the + // component was classified as a glob and then matched as an impossible literal. One + // character defeated every rule, every tool and every wrapper. + for (const command of [ + "rm -rf /[e]tc", + "rm -rf /[!x]*", + "rm -rf /[^x]*", + "rm -rf /[a-z]*", + "rm -rf /home/[h]asna", + "rm -rf /?tc", + "find /[e]tc -delete", + "rsync -a --delete src/ /[e]tc", + "ssh station02 rm -rf /[e]tc", + `bash -c "rm -rf /[!x]*"`, + `rm -rf "$(bun pm cache)"/[a-z]*`, + "rm -rf /home/hasna/.[h]asna", + "rm -rf /home/hasna/.h*/repos", + ]) { + await expectBlocked(command); + } + // A bounded class deeper than any protected root stays allowed. + await expectAllowed("rm -rf /var/log/[0-9]*.gz"); + }); + + test("a sibling subshell does not inherit the previous subshell's cd", async () => { + // Regression from the per-depth frame stack: two siblings are both depth 1, so the second + // inherited the first's `cd`. bash discards it, so the guard was aimed at an + // attacker-chosen directory while the delete hit the real cwd. + const repo = mkdtempSync(join(tmpdir(), "hooks-sibling-")); + const elsewhere = mkdtempSync(join(tmpdir(), "hooks-elsewhere-")); + try { + mkdirSync(join(repo, "src"), { recursive: true }); + Bun.spawnSync(["git", "init", "-q", repo]); + for (const command of [ + `(cd ${elsewhere}); (rm -rf *)`, + `(cd ${elsewhere}) && (rm -rf *)`, + `(cd ${elsewhere}); (cd src); (rm -rf *)`, + `echo hi | (cd ${elsewhere}); (rm -rf *)`, + `(cd ${elsewhere}); (rm -rf .)`, + `(cd ${elsewhere}); (git clean -xfd)`, + `(cd ${elsewhere}); (find . -delete)`, + `true; (cd ${elsewhere}); (rm -rf *)`, + ]) { + await expectBlocked(command, { cwd: repo }); + } + // The same-subshell case must keep working. + await expectBlocked("(cd / && rm -rf *)", { cwd: repo }); + } finally { + for (const dir of [repo, elsewhere]) { + try { rmSync(dir, { recursive: true, force: true }); } catch {} + } + } + }); + + test("a delete nested past the parameter-expansion cap is refused, not dropped", async () => { + // The hardcoded depth-4 cap abandoned the scan without setting `truncated`, so the + // refusal path never fired and the delete was never classified at all. + for (const depth of [1, 4, 5, 8, 40]) { + await expectBlocked(`echo ${"${x:-".repeat(depth)}$(rm -rf /*)${"}".repeat(depth)}`); + } + }); + + test("$PWD stands for the directory the guard is already tracking", async () => { + const repo = mkdtempSync(join(tmpdir(), "hooks-pwd-")); + try { + Bun.spawnSync(["git", "init", "-q", repo]); + for (const command of [`rm -rf "$PWD"/*`, `rm -rf "$(pwd)"/*`, "rm -rf ${PWD}/*"]) { + await expectBlocked(command, { cwd: repo }); + } + await expectBlocked(`cd / && rm -rf "$PWD"/*`); + } finally { + try { rmSync(repo, { recursive: true, force: true }); } catch {} + } + }); + + test("unset withdraws a non-empty guarantee", async () => { + await expectBlocked(`X=/tmp; unset X; rm -rf "$X"/*`); + await expectBlocked(`X=/tmp; unset X; rm -rf "$X"/etc`); + await expectAllowed(`X=/tmp/build; rm -rf "$X"/*`); + }); + + test("segment-count padding cannot stall the hook into failing open", async () => { + // The per-segment assignment scan re-split the whole command on every chunk - O(n²). + // 36 KB of `:; ` padding took 25.6s against a 20s timeout, and a timed-out hook fails + // open, so padding alone unguarded the delete. Varies SEGMENT COUNT, which the earlier + // "deeply nested expansion" test never did. + const command = `${":; ".repeat(12000)}rm -rf /*`; + const started = performance.now(); + const result = await classify(command); + expect(result.block).toBe(true); + expect(performance.now() - started).toBeLessThan(3000); + }); + test("a quoted paren inside a substitution does not disable the collapse rule", async () => { // The unterminated-substitution fallback turned an ordinary awk field separator into a // bypass, because the quoted "(" was counted as structure. diff --git a/hooks/codewith-native-common.ts b/hooks/codewith-native-common.ts index 5193bcd..1470d13 100644 --- a/hooks/codewith-native-common.ts +++ b/hooks/codewith-native-common.ts @@ -142,10 +142,11 @@ export interface GitCommandInfo { function splitShellSegmentsPass( command: string, atomicSubstitutions: boolean -): { segments: string[]; isolation: boolean[]; depths: number[]; piped: boolean[]; unterminated: boolean } { +): { segments: string[]; isolation: boolean[]; depths: number[]; groups: number[]; piped: boolean[]; unterminated: boolean } { const segments: string[] = []; const isolation: boolean[] = []; const depths: number[] = []; + const groups: number[] = []; const pipedFlags: boolean[] = []; let current = ""; let quote: "'" | '"' | null = null; @@ -155,6 +156,10 @@ function splitShellSegmentsPass( let inBacktick = false; let parenDepth = 0; let pipedFromPrevious = false; + // Every `(` opens a NEW shell. Two siblings are both depth 1 but are different processes, + // so depth alone cannot identify a frame. + let groupCounter = 0; + const groupStack: number[] = [0]; const flush = (nextSeparator: string | null) => { if (current.trim()) { @@ -162,6 +167,7 @@ function splitShellSegmentsPass( // A stage of a pipeline runs in its own process, as does anything inside `( … )`. isolation.push(parenDepth > 0 || pipedFromPrevious || nextSeparator === "|"); depths.push(parenDepth); + groups.push(groupStack[groupStack.length - 1] ?? 0); pipedFlags.push(pipedFromPrevious || nextSeparator === "|"); } current = ""; @@ -221,8 +227,14 @@ function splitShellSegmentsPass( const doubled = (ch === "|" || ch === "&") && command[i + 1] === ch; // `||` and `&&` are sequencing, not a pipe. flush(ch === "|" && !doubled ? "|" : null); - if (ch === "(") parenDepth += 1; - else if (ch === ")") parenDepth = Math.max(0, parenDepth - 1); + if (ch === "(") { + parenDepth += 1; + groupCounter += 1; + groupStack.push(groupCounter); + } else if (ch === ")") { + parenDepth = Math.max(0, parenDepth - 1); + if (groupStack.length > 1) groupStack.pop(); + } if (doubled) i += 1; continue; } @@ -230,7 +242,7 @@ function splitShellSegmentsPass( } flush(null); - return { segments, isolation, depths, piped: pipedFlags, unterminated: substitutionDepth > 0 || inBacktick }; + return { segments, isolation, depths, groups, piped: pipedFlags, unterminated: substitutionDepth > 0 || inBacktick }; } function splitShellSegments(command: string): string[] { @@ -242,6 +254,8 @@ interface ShellSegment { text: string; /** Subshell nesting depth of this segment; a `cd` applies to this depth and deeper. */ depth: number; + /** Identity of the subshell this segment runs in; siblings at one depth differ. */ + group: number; /** This segment is a pipeline stage, so its `cd` affects nothing outside the stage. */ piped: boolean; /** @@ -252,12 +266,28 @@ interface ShellSegment { isolated: boolean; } +const segmentCache = new Map(); +const MAX_SEGMENT_CACHE = 16; + function splitShellSegmentsDetailed(command: string): ShellSegment[] { + const cached = segmentCache.get(command); + if (cached) return cached; + const computed = splitShellSegmentsUncached(command); + if (segmentCache.size >= MAX_SEGMENT_CACHE) { + const oldest = segmentCache.keys().next().value; + if (oldest !== undefined) segmentCache.delete(oldest); + } + segmentCache.set(command, computed); + return computed; +} + +function splitShellSegmentsUncached(command: string): ShellSegment[] { const pass = splitShellSegmentsPass(command, true); const chosen = pass.unterminated ? splitShellSegmentsPass(command, false) : pass; return chosen.segments.map((text, index) => ({ text, depth: chosen.depths[index] ?? 0, + group: chosen.groups[index] ?? 0, piped: chosen.piped[index] ?? false, isolated: chosen.isolation[index] ?? false, })); @@ -669,13 +699,43 @@ function mutatesProtectedPath(targetPath: string, rule: ProtectedPathRule): bool // A trailing glob that matches every entry, so `dir/*` destroys all of `dir`. const CATCH_ALL_GLOB = /^(?:\*|\*\*|\.\*|\.\[!\.\]\*)$/; +/** + * Compile one glob path component to a regular expression. + * + * Bracket classes must be COMPILED, not escaped. Escaping them made `[e]tc` compile to the + * literal `\[e\]tc`, which can never equal a real directory name - so `rm -rf /[e]tc`, which + * bash expands to `/etc`, matched no protected root and was allowed. That one character + * defeated every rule, every tool and every wrapper, including the realized incident shape + * `rm -rf "$(bun pm cache)"/[a-z]*`. + */ function globComponentToRegExp(component: string): RegExp { let source = ""; for (let i = 0; i < component.length; i += 1) { const ch = component[i]; - if (ch === "*") source += "[^/]*"; - else if (ch === "?") source += "[^/]"; - else source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + + if (ch === "*") { source += "[^/]*"; continue; } + if (ch === "?") { source += "[^/]"; continue; } + + if (ch === "[") { + // A `]` immediately after `[`, `[!` or `[^` is a literal member, not the terminator. + let end = i + 1; + if (component[end] === "!" || component[end] === "^") end += 1; + if (component[end] === "]") end += 1; + while (end < component.length && component[end] !== "]") end += 1; + if (end >= component.length) { + // Unterminated class: bash treats the `[` literally. + source += "\\["; + continue; + } + const body = component.slice(i + 1, end); + const negated = body.startsWith("!") || body.startsWith("^"); + const members = (negated ? body.slice(1) : body).replace(/\\/g, "\\\\"); + source += `[${negated ? "^" : ""}${members}]`; + i = end; + continue; + } + + source += ch.replace(/[.+^${}()|\]\\]/g, "\\$&"); } return new RegExp(`^${source}$`); } @@ -728,12 +788,25 @@ function globThreatensRule(targetPath: string, rule: ProtectedPathRule): boolean const parts = resolve(targetPath).split(sep); const rootParts = resolve(rule.root).split(sep); - if (rule.mode === "tree" && isInsidePath(literalPrefixOf(parts), rule.root)) return true; + if (rule.mode === "tree") { + if (isInsidePath(literalPrefixOf(parts), rule.root)) return true; + // A pattern deeper than the root can still land inside it: `~/.h*/repos` matches + // ~/.hasna/repos. literalPrefixOf stops before the first glob, so it misses this. + if (parts.length > rootParts.length && globPatternCovers(parts.slice(0, rootParts.length), rootParts)) { + return true; + } + } if (globPatternCovers(parts, rootParts)) return true; const last = parts[parts.length - 1]; if (CATCH_ALL_GLOB.test(last) && globPatternCovers(parts.slice(0, -1), rootParts)) return true; + // A glob in the last component sweeps the contents of its own parent. When that parent IS + // the protected root, the sweep guts the root even though the glob is bounded: + // `rm -rf [a-z]*` at a repo root deletes almost all of it. d8c0e8a blocked this via the + // narrow-glob check; the precise-component rewrite dropped it. + if (/[*?[]/.test(last) && mutatesProtectedPath(parts.slice(0, -1).join(sep) || sep, rule)) return true; + // A catch-all in the FIRST component sweeps every top-level directory: `/*/bin` deletes // /usr/bin, /var/bin and the rest, and a trailing literal makes the pattern deeper than any // single root, so component matching alone misses it. Scoped to the filesystem root so @@ -1035,45 +1108,66 @@ export function emptyExpansionCollapse( } /** - * Variables that are provably non-empty at the point `segmentIndex` runs. + * One set per segment: the variables provably non-empty at the moment that segment runs. * - * Every relaxation here is a way to get a delete past the guard, so each condition is a - * guarantee rather than a heuristic. Recomputed per segment because the naive version - one - * set for the whole command - was defeated six different ways: + * Built in a SINGLE forward pass. The previous version recomputed the whole segmentation and + * rescanned every preceding segment on each call, and was called once per chunk - O(segments²). + * 36 KB of `:; ` padding took 25.6s against this hook's 20s timeout, and a timed-out hook fails + * open, so padding alone turned a blocked `rm -rf /*` into an unguarded one. That is the same + * fail-open the wrapper caps were written to stop, reopened along a different axis. + * + * Every relaxation here is a way past the guard, so each condition is a guarantee: * * X=/tmp/build rm -rf "$X"/* a PREFIX assignment applies to the command's own * environment, not to the expansion, which bash performs * first; `$X` is still empty * rm -rf "$X"/* ; X=/tmp/build an assignment AFTER the delete counted - * X=/tmp/build; X=$(cmd); rm … a later reassignment to something collapsible did not - * invalidate the earlier literal - * X=/tmp/build; X=; rm … an explicit empty reassignment did not either - * (X=/tmp/build); rm … a subshell-scoped assignment escaped its subshell - * X=/tmp/build | cat; rm … a pipeline-stage assignment did the same + * X=/tmp/build; X=$(cmd); rm … a later reassignment to something collapsible + * X=/tmp/build; X=; rm … an explicit empty reassignment + * X=/tmp/build; unset X; rm … an unset + * (X=/tmp/build); rm … a subshell-scoped assignment escaping its subshell + * X=/tmp/build | cat; rm … a pipeline-stage assignment doing the same */ -function nonEmptyAssignedNames(command: string, segmentIndex: number): Set { - const names = new Set(); - const segments = splitShellSegmentsDetailed(command); +function assignmentTimeline(command: string): Array> { + const timeline: Array> = []; + let current = new Set(); + + for (const { text, depth, isolated } of splitShellSegmentsDetailed(command)) { + // The set as it stands BEFORE this segment runs. + timeline.push(current); - segments.forEach(({ text, depth, isolated }, index) => { - // Only assignments that already ran, in the parent shell, in their own right. - if (index >= segmentIndex || depth > 0 || isolated) return; + // Only assignments in the parent shell, in their own right, change it. + if (depth > 0 || isolated) continue; const tokens = shellWords(text); + if (tokens.length === 0) continue; + + if (tokens[0] === "unset") { + const next = new Set(current); + for (const name of tokens.slice(1)) { + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) next.delete(name); + } + current = next; + continue; + } + + let next: Set | null = null; for (const [position, token] of tokens.entries()) { const assignment = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/); if (!assignment) break; const [, name, value] = assignment; - const isPrefixAssignment = position < tokens.length - 1 && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[position + 1] ?? ""); - // A later assignment always invalidates an earlier guarantee. - names.delete(name); - if (name === "PWD") names.add("\u0000PWD-REASSIGNED"); + const isPrefixAssignment = position < tokens.length - 1 + && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[position + 1] ?? ""); + next ??= new Set(current); + next.delete(name); + if (name === "PWD") next.add("\u0000PWD-REASSIGNED"); if (isPrefixAssignment) continue; - if (value.length > 0 && !/[$`]/.test(value)) names.add(name); + if (value.length > 0 && !/[$`]/.test(value)) next.add(name); } - }); + if (next) current = next; + } - return names; + return timeline; } function shouldSkipHasnaTreeRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean { @@ -1287,6 +1381,16 @@ interface DestructiveShellTarget { baseCwd?: string; } +// `$PWD`, `${PWD}`, `$(pwd)` and `` `pwd` `` all stand for the working directory the guard is +// already tracking. They are certified non-empty, so no collapse fires - which left them as +// opaque path components matching no protected root, and `rm -rf "$PWD"/*` was allowed where +// the identical `rm -rf *` blocked. +const PWD_EXPANSION = /\$\{PWD\}|\$PWD|\$\(\s*pwd\s*\)|`\s*pwd\s*`/g; + +function substituteWorkingDirectory(path: string, cwd: string): string { + return PWD_EXPANSION.test(path) ? path.replace(PWD_EXPANSION, cwd) : path; +} + function destructiveTarget( path: string, operation: string, @@ -1616,10 +1720,15 @@ function interpreterScriptFrom(tokens: string[], shellIndex: number, allowedOper * them whole. Without feeding the bodies back in, `echo $(rm -rf /*)` contains no `rm` token * at all and every rule misses it - the delete runs, its output is simply discarded. */ -function substitutionBodies(segment: string): string[] { +function substitutionBodies(segment: string, onTruncated?: () => void): string[] { const bodies: string[] = []; const visit = (text: string, depth: number): void => { - if (depth > 4) return; + // Exhausting this bound must not silently drop a delete: `${x:-${x:- … $(rm -rf /*)}}` + // nested past the old hardcoded 4 was never classified at all. + if (depth > MAX_EXPANSION_NESTING) { + onTruncated?.(); + return; + } for (const expansion of findExpansions(text)) { if (expansion.text.startsWith("$(") || expansion.text.startsWith("`")) { const body = (expansion.text.startsWith("`") @@ -1666,7 +1775,7 @@ function isSshToken(token: string): boolean { return token === "ssh" || token.endsWith("/ssh"); } -function wrappedShellLayers(command: string, remote: boolean): ShellCommandLayer[] { +function wrappedShellLayers(command: string, remote: boolean, onTruncated?: () => void): ShellCommandLayer[] { const layers: ShellCommandLayer[] = []; for (const segment of splitShellSegments(command)) { const tokens = shellWords(segment); @@ -1694,7 +1803,7 @@ function wrappedShellLayers(command: string, remote: boolean): ShellCommandLayer // A substitution body executes wherever it appears, including in assignments and in // arguments to commands that do nothing with the result. - for (const body of substitutionBodies(segment)) { + for (const body of substitutionBodies(segment, onTruncated)) { layers.push({ command: body, remote: remote || sshSeen }); } } @@ -1713,7 +1822,7 @@ function shellCommandLayers(command: string): { layers: ShellCommandLayer[]; tru for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth += 1) { const next: ShellCommandLayer[] = []; for (const layer of frontier) { - for (const inner of wrappedShellLayers(layer.command, layer.remote)) { + for (const inner of wrappedShellLayers(layer.command, layer.remote, () => { truncated = true; })) { if (seen.has(inner.command)) continue; if (layers.length + next.length >= MAX_SHELL_LAYERS) { truncated = true; @@ -1796,22 +1905,30 @@ function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: Rea // that subshell - it just does not escape to the parent - so skipping isolated `cd` // outright left `(cd / && rm -rf *)`, the standard "cd without moving my shell" idiom, // completely unguarded. Depth 0 is the parent shell. - let stack: Array<{ cwds: string[]; previous: string[]; explicit: boolean }> = [ - { cwds: [baseCwd], previous: [baseCwd], explicit: false }, + let stack: Array<{ group: number; cwds: string[]; previous: string[]; explicit: boolean }> = [ + { group: 0, cwds: [baseCwd], previous: [baseCwd], explicit: false }, ]; - const frameFor = (depth: number) => { + const frameFor = (depth: number, group: number) => { + // Leaving a subshell discards everything it did. + if (stack.length > depth + 1) stack = stack.slice(0, depth + 1); while (stack.length <= depth) { const parent = stack[stack.length - 1]; - stack.push({ cwds: parent.cwds, previous: parent.previous, explicit: parent.explicit }); + stack.push({ group, cwds: parent.cwds, previous: parent.previous, explicit: parent.explicit }); + } + // A DIFFERENT group at the same depth is a sibling subshell - a separate process that + // never saw the previous one's `cd`. Reusing the frame let `(cd /elsewhere); (rm -rf *)` + // point the guard at an attacker-chosen directory while bash deleted the real cwd. + const frame = stack[depth]; + if (frame.group !== group) { + const parent = stack[depth - 1] ?? stack[0]; + stack[depth] = { group, cwds: parent.cwds, previous: parent.previous, explicit: parent.explicit }; } - // Leaving a subshell discards everything it did. - if (stack.length > depth + 1) stack = stack.slice(0, depth + 1); return stack[depth]; }; - splitShellSegmentsDetailed(command).forEach(({ text: segment, depth, piped }, segmentIndex) => { - const frame = frameFor(depth); + splitShellSegmentsDetailed(command).forEach(({ text: segment, depth, group, piped }, segmentIndex) => { + const frame = frameFor(depth, group); // A leading `{` from a brace group is not part of the command. const tokens = shellWords(segment).filter((token, index) => !(index === 0 && (token === "{" || token === "}"))); const verb = tokens[0]; @@ -1881,8 +1998,10 @@ function destructiveShellTargets(command: string, cwd: string): DestructiveShell for (const layer of shellCommandLayers(command).layers) { const bindings = forLoopBindings(layer.command); + const timeline = assignmentTimeline(layer.command); + for (const chunk of cwdTrackedSegments(layer.command, cwd, new Set())) { - const nonEmptyNames = nonEmptyAssignedNames(layer.command, chunk.segmentIndex); + const nonEmptyNames = timeline[chunk.segmentIndex] ?? new Set(); const raw = [ ...rmCommandTargets(chunk.segment), ...rsyncDeleteTargets(chunk.segment), @@ -1899,7 +2018,11 @@ function destructiveShellTargets(command: string, cwd: string): DestructiveShell }); const chunkTargets = expanded.flatMap((target) => - chunk.cwds.map((chunkCwd) => ({ ...target, baseCwd: chunkCwd })) + chunk.cwds.map((chunkCwd) => ({ + ...target, + path: substituteWorkingDirectory(target.path, chunkCwd), + baseCwd: chunkCwd, + })) ); if (!layer.remote) { From 1e538370506a4eebc4db85d23b2cfeecf6320b26 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Mon, 27 Jul 2026 02:24:33 +0300 Subject: [PATCH 08/14] fix(pre-bash): replace the glob regex with a linear matcher; handle popd and rebinding builtins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hooks/codewith-native-common.test.ts | 115 ++++++++++- hooks/codewith-native-common.ts | 275 ++++++++++++++++++++++----- 2 files changed, 346 insertions(+), 44 deletions(-) diff --git a/hooks/codewith-native-common.test.ts b/hooks/codewith-native-common.test.ts index 364a7f8..8eec6ef 100644 --- a/hooks/codewith-native-common.test.ts +++ b/hooks/codewith-native-common.test.ts @@ -1,7 +1,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { join } from "path"; import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "fs"; -import { tmpdir } from "os"; +import { homedir, tmpdir } from "os"; import { claimCommand, classifyDangerousOperation, @@ -1115,6 +1115,119 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { expect(performance.now() - started).toBeLessThan(3000); }); + // ------------------------------------------------------------------------------------- + // Adversarial review round 5. + // ------------------------------------------------------------------------------------- + + test("bracket expressions whose terminator is not the first ] still match", async () => { + // The compiler stopped at the first `]`, so `[[:lower:]]` compiled to a pattern requiring + // a literal `]` and matched nothing - a silent under-match, which fails OPEN. Verified + // against bash: /home/hasna/.hasn[[:lower:]] expands to /home/hasna/.hasna. + for (const command of [ + "rm -rf /home/hasna/.hasn[[:lower:]]", + "rm -rf /home/hasna/.[[:lower:]]asna", + "rm -rf /home/hasna/.[[:lower:]]asna/repos", + "rm -rf /home/hasna/.hasn[a\\]]", + "rm -rf /[[:lower:]]tc", + "rm -rf /[[:alpha:]][[:alpha:]][[:alpha:]]", + `ssh station02 "rm -rf /home/hasna/.hasn[[:lower:]]"`, + `bash -c "rm -rf /home/hasna/.hasn[[:lower:]]"`, + "find /home/hasna/.hasn[[:lower:]] -delete", + ]) { + await expectBlocked(command); + } + }); + + test("popd returns the guard to where the shell returns", async () => { + // `pushd` was handled and `popd` was not, so the pushd target stayed as the tracked cwd + // for the rest of the command. Blocked on d8c0e8a; allowed from f6def3c until now. + // Deliberately NOT under the pushd target: a repo inside /tmp made this pass for the + // wrong reason, because `/tmp/*` covers the repo root whether or not popd is handled. + const repo = mkdtempSync(join(homedir(), ".hooks-popd-")); + try { + Bun.spawnSync(["git", "init", "-q", repo]); + for (const command of [ + "pushd /var/tmp >/dev/null; popd >/dev/null; rm -rf *", + "pushd /var/tmp; popd; git clean -xfd", + "pushd /var/tmp; popd; find . -delete", + ]) { + await expectBlocked(command, { cwd: repo }); + } + // pushd without popd still moves it. + await expectAllowed("pushd /var/tmp; rm -rf scratch-dir", { cwd: repo }); + } finally { + try { rmSync(repo, { recursive: true, force: true }); } catch {} + } + }); + + test("any construct that can rebind a name withdraws the non-empty guarantee", async () => { + for (const rebind of [ + "export X=$(bun pm cache)", + "declare X=$(cmd)", + "readonly X=$(cmd)", + "local X=$(cmd)", + "typeset X=$(cmd)", + "read X < /dev/null", + "eval 'X='", + 'for X in ""; do :; done', + ]) { + await expectBlocked(`X=/tmp/build; ${rebind}; rm -rf "$X"/*`); + } + // A literal, non-empty binding through the same builtins is still a guarantee. + await expectAllowed(`export X=/tmp/build; rm -rf "$X"/*`); + await expectAllowed(`declare X=/tmp/build; rm -rf "$X"/*`); + }); + + test("glob matching is linear, so a long protected root cannot stall the hook", async () => { + // `*` compiled to `[^/]*` backtracked exponentially against a ~200-char root component: + // over 45s against a 20s timeout, and a timed-out hook fails open. + const base = mkdtempSync(join(tmpdir(), "hooks-redos-")); + const repo = join(base, "ab".repeat(100)); + mkdirSync(repo, { recursive: true }); + try { + Bun.spawnSync(["git", "init", "-q", repo]); + const started = performance.now(); + await classify(`rm -rf ${base}/${"*b".repeat(15)}c`, { cwd: repo }); + expect(performance.now() - started).toBeLessThan(2000); + } finally { + try { rmSync(base, { recursive: true, force: true }); } catch {} + } + }); + + test("the narrow-glob sweep fires only on unanchored patterns", async () => { + // Previously untested: deleting the whole rule left the suite green. It is also the + // rule most able to over-block, so both directions are asserted here. + const repo = mkdtempSync(join(tmpdir(), "hooks-sweep-")); + try { + Bun.spawnSync(["git", "init", "-q", repo]); + // Unanchored - nothing literal survives, so it takes essentially the whole root. + for (const command of ["rm -rf [a-z]*", "rm -rf [!x]*", "rm -rf ?*", "rm -rf *"]) { + await expectBlocked(command, { cwd: repo }); + } + // Anchored by literal text - cannot take the root, and blocking these got the guard + // switched off last round. + for (const command of [ + "rm -rf *.log", + "rm -rf *.tsbuildinfo", + "rm -rf .turbo* .next* .cache*", + "rm -rf tmp-*", + "rm -rf test-output-*", + "rm -rf dist-*.zip", + "rm -rf report-2026-*", + "rm -rf ./*.tgz", + "rm -rf out?/", + "rm -rf .venv*", + "rm -rf build-cache-*", + "rm -rf snapshot-[0-9]*", + "rm -rf [s]rc", + ]) { + await expectAllowed(command, { cwd: repo }); + } + } finally { + try { rmSync(repo, { recursive: true, force: true }); } catch {} + } + }); + test("a quoted paren inside a substitution does not disable the collapse rule", async () => { // The unterminated-substitution fallback turned an ordinary awk field separator into a // bypass, because the quoted "(" was counted as structure. diff --git a/hooks/codewith-native-common.ts b/hooks/codewith-native-common.ts index 1470d13..5f278c3 100644 --- a/hooks/codewith-native-common.ts +++ b/hooks/codewith-native-common.ts @@ -312,7 +312,8 @@ function shellWordsPass(segment: string, atomicSubstitutions: boolean): { words: for (let i = 0; i < segment.length; i += 1) { const ch = segment[i]; if (escaped) { - current += ch; + // A backslash before a glob metacharacter is part of the pattern, not shell quoting. + current += /[[\]*?]/.test(ch) ? `\\${ch}` : ch; escaped = false; continue; } @@ -700,54 +701,191 @@ function mutatesProtectedPath(targetPath: string, rule: ProtectedPathRule): bool const CATCH_ALL_GLOB = /^(?:\*|\*\*|\.\*|\.\[!\.\]\*)$/; /** - * Compile one glob path component to a regular expression. + * Match one glob path component against one literal name, without a regular expression. + * + * Written as a linear matcher on purpose, for two reasons that both bit this branch: * - * Bracket classes must be COMPILED, not escaped. Escaping them made `[e]tc` compile to the - * literal `\[e\]tc`, which can never equal a real directory name - so `rm -rf /[e]tc`, which - * bash expands to `/etc`, matched no protected root and was allowed. That one character - * defeated every rule, every tool and every wrapper, including the realized incident shape - * `rm -rf "$(bun pm cache)"/[a-z]*`. + * - Regex ESCAPING of `[`/`]` made `[e]tc` compile to a literal no directory can equal, so + * `rm -rf /[e]tc` - which bash expands to `/etc` - matched no protected root. + * - Regex COMPILATION of `*` as `[^/]*` backtracked exponentially: a ~70-character protected + * root component with a dozen `*b` groups took over 45s against this hook's 20s timeout, + * and a timed-out hook fails open. Two fail-opens in the same helper. + * + * A two-pointer wildcard match is O(pattern x name) worst case with no backtracking blowup, + * and bracket handling is explicit rather than delegated to regex syntax that does not mean + * the same thing. Unmatched constructs fall back to "matches", never to "does not match": + * an under-match is silent and fails open, which is exactly how `[e]tc` got through. */ -function globComponentToRegExp(component: string): RegExp { - let source = ""; - for (let i = 0; i < component.length; i += 1) { - const ch = component[i]; +function bracketExpressionEnd(pattern: string, open: number): number { + let i = open + 1; + if (pattern[i] === "!" || pattern[i] === "^") i += 1; + // A `]` in first position is a literal member, not the terminator. + if (pattern[i] === "]") i += 1; + while (i < pattern.length) { + const ch = pattern[i]; + if (ch === "\\") { i += 2; continue; } + // POSIX [:class:], [=equiv=] and [.collate.] contain a `]` that does NOT terminate the + // expression. Stopping at the first `]` made `[[:lower:]]` match nothing at all. + if (ch === "[" && (pattern[i + 1] === ":" || pattern[i + 1] === "=" || pattern[i + 1] === ".")) { + const kind = pattern[i + 1]; + const close = pattern.indexOf(`${kind}]`, i + 2); + if (close === -1) return -1; + i = close + 2; + continue; + } + if (ch === "]") return i; + i += 1; + } + return -1; +} + +const POSIX_CLASS_MATCHERS: Record boolean> = { + alpha: (ch) => /\p{L}/u.test(ch), + digit: (ch) => /\p{Nd}/u.test(ch), + alnum: (ch) => /[\p{L}\p{Nd}]/u.test(ch), + lower: (ch) => /\p{Ll}/u.test(ch), + upper: (ch) => /\p{Lu}/u.test(ch), + space: (ch) => /\s/.test(ch), + punct: (ch) => /[!-\/:-@[-`{-~]/.test(ch), + xdigit: (ch) => /[0-9A-Fa-f]/.test(ch), + word: (ch) => /[\w]/.test(ch), + blank: (ch) => ch === " " || ch === "\t", + print: (ch) => ch >= " " && ch !== "\u007f", + graph: (ch) => ch > " " && ch !== "\u007f", + cntrl: (ch) => ch < " " || ch === "\u007f", +}; + +/** Does `ch` satisfy the bracket expression `pattern[open..close]`? */ +function bracketMatches(pattern: string, open: number, close: number, ch: string): boolean { + let i = open + 1; + let negated = false; + if (pattern[i] === "!" || pattern[i] === "^") { negated = true; i += 1; } + + let matched = false; + let first = true; + while (i < close) { + if (pattern[i] === "[" && (pattern[i + 1] === ":" || pattern[i + 1] === "=" || pattern[i + 1] === ".")) { + const kind = pattern[i + 1]; + const end = pattern.indexOf(`${kind}]`, i + 2); + if (end === -1 || end >= close) break; + const name = pattern.slice(i + 2, end); + if (kind === ":") { + const test = POSIX_CLASS_MATCHERS[name]; + // An unknown class must not silently match nothing. + if (test === undefined ? true : test(ch)) matched = true; + } else if (name === ch) { + matched = true; + } + i = end + 2; + first = false; + continue; + } - if (ch === "*") { source += "[^/]*"; continue; } - if (ch === "?") { source += "[^/]"; continue; } + let member = pattern[i]; + if (member === "\\" && i + 1 < close) { i += 1; member = pattern[i]; } + // `]` is a literal only in first position; `-` is a literal at either end. + if (member === "]" && !first) break; - if (ch === "[") { - // A `]` immediately after `[`, `[!` or `[^` is a literal member, not the terminator. - let end = i + 1; - if (component[end] === "!" || component[end] === "^") end += 1; - if (component[end] === "]") end += 1; - while (end < component.length && component[end] !== "]") end += 1; - if (end >= component.length) { - // Unterminated class: bash treats the `[` literally. - source += "\\["; + if (pattern[i + 1] === "-" && i + 2 < close && pattern[i + 2] !== "]") { + let upper = pattern[i + 2]; + let step = 3; + if (upper === "\\" && i + 3 < close) { upper = pattern[i + 3]; step = 4; } + if (ch >= member && ch <= upper) matched = true; + i += step; + } else { + if (ch === member) matched = true; + i += 1; + } + first = false; + } + + return negated ? !matched : matched; +} + +/** + * Two-pointer wildcard match with backtracking limited to the last `*`, so it is linear in + * practice and can never blow up the way the compiled regex did. + */ +function globMatches(pattern: string, name: string): boolean { + let p = 0; + let n = 0; + let starPattern = -1; + let starName = 0; + + while (n < name.length) { + const ch = pattern[p]; + + if (p < pattern.length && ch === "*") { + starPattern = p; + starName = n; + p += 1; + continue; + } + if (p < pattern.length && ch === "?") { + p += 1; + n += 1; + continue; + } + if (p < pattern.length && ch === "[") { + const close = bracketExpressionEnd(pattern, p); + if (close !== -1) { + if (bracketMatches(pattern, p, close, name[n])) { + p = close + 1; + n += 1; + continue; + } + } else if (name[n] === "[") { + // Unterminated: bash treats the `[` literally. + p += 1; + n += 1; + continue; + } + } else if (p < pattern.length) { + const literal = ch === "\\" && p + 1 < pattern.length ? pattern[p + 1] : ch; + const width = ch === "\\" && p + 1 < pattern.length ? 2 : 1; + if (literal === name[n]) { + p += width; + n += 1; continue; } - const body = component.slice(i + 1, end); - const negated = body.startsWith("!") || body.startsWith("^"); - const members = (negated ? body.slice(1) : body).replace(/\\/g, "\\\\"); - source += `[${negated ? "^" : ""}${members}]`; - i = end; - continue; } - source += ch.replace(/[.+^${}()|\]\\]/g, "\\$&"); + if (starPattern === -1) return false; + starName += 1; + n = starName; + p = starPattern + 1; } - return new RegExp(`^${source}$`); + + while (pattern[p] === "*") p += 1; + return p >= pattern.length; +} + +/** + * Does this glob keep no literal text at all, so it can match essentially any name? + * `[a-z]*` and `?*` are unanchored; `*.log` and `tmp-*` are anchored by their literals. + */ +function isUnanchoredGlob(pattern: string): boolean { + if (!/[*?[]/.test(pattern)) return false; + let residue = ""; + for (let i = 0; i < pattern.length; i += 1) { + const ch = pattern[i]; + if (ch === "\\" && i + 1 < pattern.length) { residue += pattern[i + 1]; i += 1; continue; } + if (ch === "*" || ch === "?") continue; + if (ch === "[") { + const close = bracketExpressionEnd(pattern, i); + if (close === -1) { residue += ch; continue; } + i = close; + continue; + } + residue += ch; + } + return residue.length === 0; } function globComponentMatches(pattern: string, literal: string): boolean { if (!/[*?[]/.test(pattern)) return pattern === literal; if (CATCH_ALL_GLOB.test(pattern)) return true; - try { - return globComponentToRegExp(pattern).test(literal); - } catch { - return true; - } + return globMatches(pattern, literal); } /** @@ -802,10 +940,15 @@ function globThreatensRule(targetPath: string, rule: ProtectedPathRule): boolean if (CATCH_ALL_GLOB.test(last) && globPatternCovers(parts.slice(0, -1), rootParts)) return true; // A glob in the last component sweeps the contents of its own parent. When that parent IS - // the protected root, the sweep guts the root even though the glob is bounded: - // `rm -rf [a-z]*` at a repo root deletes almost all of it. d8c0e8a blocked this via the - // narrow-glob check; the precise-component rewrite dropped it. - if (/[*?[]/.test(last) && mutatesProtectedPath(parts.slice(0, -1).join(sep) || sep, rule)) return true; + // the protected root AND the pattern is unanchored, the sweep guts the root: `rm -rf [a-z]*` + // or `?*` at a repo root take almost everything. + // + // "Unanchored" means no literal character survives once wildcards are removed. That + // distinction is the whole point: `*.log`, `tmp-*`, `.turbo*` and `snapshot-[0-9]*` are + // anchored by their literal text and cannot take the root, and blocking them - which the + // blunt any-metacharacter version did - re-broke twelve everyday repo-root cleanups. A + // guard that blocks routine work gets switched off. + if (isUnanchoredGlob(last) && mutatesProtectedPath(parts.slice(0, -1).join(sep) || sep, rule)) return true; // A catch-all in the FIRST component sweeps every top-level directory: `/*/bin` deletes // /usr/bin, /var/bin and the rest, and a trailing literal makes the pattern deeper than any @@ -928,6 +1071,12 @@ const GUARDED_EXPANSION = /^\$\{[A-Za-z_][A-Za-z0-9_]*:\?/; const NON_EMPTY_PLACEHOLDER = "__hooks_guarded_expansion__"; const MAX_EXPANSION_NESTING = 32; +// Builtins that can bind or rebind a variable name. `eval` is here because its argument is +// opaque, so nothing after it can be guaranteed. +const REBINDING_BUILTINS = new Set([ + "export", "declare", "typeset", "readonly", "local", "read", "eval", "let", "mapfile", "readarray", +]); + /** One shell expansion found in a token, with its exact source span. */ interface FoundExpansion { text: string; @@ -1151,6 +1300,30 @@ function assignmentTimeline(command: string): Array> { continue; } + // Any construct that can rebind a name withdraws the guarantee. The token loop below + // stops at the first non-assignment token, so `export X=$(cmd)` was invisible while an + // earlier `X=/tmp/build` kept certifying X as non-empty. + if (REBINDING_BUILTINS.has(tokens[0])) { + const next = new Set(current); + for (const token of tokens.slice(1)) { + const name = token.match(/^([A-Za-z_][A-Za-z0-9_]*)(?:=([\s\S]*))?$/); + if (!name) continue; + next.delete(name[1]); + // A literal, non-empty value re-establishes it; anything expandable does not. + if (name[2] !== undefined && name[2].length > 0 && !/[$`]/.test(name[2])) next.add(name[1]); + } + current = next; + continue; + } + + // `for NAME in …` rebinds NAME to each word, any of which may be empty. + if (tokens[0] === "for" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(tokens[1] ?? "")) { + const next = new Set(current); + next.delete(tokens[1]); + current = next; + continue; + } + let next: Set | null = null; for (const [position, token] of tokens.entries()) { const assignment = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/); @@ -1905,8 +2078,8 @@ function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: Rea // that subshell - it just does not escape to the parent - so skipping isolated `cd` // outright left `(cd / && rm -rf *)`, the standard "cd without moving my shell" idiom, // completely unguarded. Depth 0 is the parent shell. - let stack: Array<{ group: number; cwds: string[]; previous: string[]; explicit: boolean }> = [ - { group: 0, cwds: [baseCwd], previous: [baseCwd], explicit: false }, + let stack: Array<{ group: number; cwds: string[]; previous: string[]; dirStack: string[][]; explicit: boolean }> = [ + { group: 0, cwds: [baseCwd], previous: [baseCwd], dirStack: [], explicit: false }, ]; const frameFor = (depth: number, group: number) => { @@ -1914,7 +2087,7 @@ function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: Rea if (stack.length > depth + 1) stack = stack.slice(0, depth + 1); while (stack.length <= depth) { const parent = stack[stack.length - 1]; - stack.push({ group, cwds: parent.cwds, previous: parent.previous, explicit: parent.explicit }); + stack.push({ group, cwds: parent.cwds, previous: parent.previous, dirStack: [...parent.dirStack], explicit: parent.explicit }); } // A DIFFERENT group at the same depth is a sibling subshell - a separate process that // never saw the previous one's `cd`. Reusing the frame let `(cd /elsewhere); (rm -rf *)` @@ -1922,7 +2095,7 @@ function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: Rea const frame = stack[depth]; if (frame.group !== group) { const parent = stack[depth - 1] ?? stack[0]; - stack[depth] = { group, cwds: parent.cwds, previous: parent.previous, explicit: parent.explicit }; + stack[depth] = { group, cwds: parent.cwds, previous: parent.previous, dirStack: [...parent.dirStack], explicit: parent.explicit }; } return stack[depth]; }; @@ -1933,9 +2106,25 @@ function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: Rea const tokens = shellWords(segment).filter((token, index) => !(index === 0 && (token === "{" || token === "}"))); const verb = tokens[0]; + // `popd` returns the shell to where `pushd` came from. It was unhandled, so the pushd + // target stayed as the tracked cwd for the rest of the command and + // `pushd /tmp; popd; rm -rf *` deleted the original directory unguarded. + if (verb === "popd") { + if (piped) return; + const restored = frame.dirStack.pop(); + if (restored) { + frame.previous = frame.cwds; + frame.cwds = restored; + frame.explicit = restored.some((dir) => dir !== baseCwd); + } + return; + } + if (verb === "cd" || verb === "pushd") { // A `cd` in a pipeline stage runs in its own process and moves nothing else. if (piped) return; + // `pushd` saves the current directory before moving. + if (verb === "pushd") frame.dirStack.push(frame.cwds); // Skip cd's own flags (-P, -L, --) to reach the directory operand. let i = 1; while (i < tokens.length && (tokens[i] === "-P" || tokens[i] === "-L" || tokens[i] === "-e" || tokens[i] === "-@" || tokens[i] === "--")) i += 1; From fe21e8a26a91811129f6d137ff1410ae2d8b34bb Mon Sep 17 00:00:00 2001 From: andreihasna Date: Mon, 27 Jul 2026 03:02:10 +0300 Subject: [PATCH 09/14] fix(pre-bash): make bracket handling fail closed by design, not by enumeration 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. --- CHANGELOG.md | 4 + hooks/codewith-native-common.test.ts | 118 ++++++++++++++++ hooks/codewith-native-common.ts | 202 +++++++++++++++------------ hooks/pre-bash/README.md | 22 +++ 4 files changed, 253 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14157ff..c451fde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `cd` is tracked within a command, so `cd / && rm -rf *` and `cd "$(cmd)"/ && rm -rf ./*` block. - A `for VAR in ` binding is followed into `rm -rf "$VAR"`. - Block messages now name a safe alternative instead of only refusing. + - Glob targets are matched per path component, so a trailing literal bounds the delete: `rm -rf */node_modules` at a monorepo root is allowed while `rm -rf /*/*`, `rm -rf /*/bin` and `rm -rf /home/*/.hasna` are not. A glob directly under a protected root is refused only when *unanchored* — no literal text survives once wildcards are removed — so `[a-z]*`, `?*`, `.??*` and `*.*` block while `*.log`, `tmp-*`, `.turbo*` and `snapshot-[0-9]*` are allowed. + - Bracket expressions the matcher does not model exactly (POSIX `[:class:]`, `[=equiv=]`, `[.collate.]`, backslash escapes, unterminated) are treated as matching rather than as not-matching. An under-match leaves a protected root unmatched and allows the delete. + - Working-directory tracking covers `cd`, `cd -`, `pushd`, `pushd -n`, `popd` and a per-subshell directory stack; a `cd` in a subshell or pipeline stage no longer escapes it. + - The non-empty guarantee used by the expansion rule is withdrawn by `unset`, by `export`/`declare`/`typeset`/`readonly`/`local` assignments, by `read`/`getopts`/`mapfile`/`printf -v`, by `for NAME in`, by `declare -n` namerefs, and entirely by `eval`/`source`/`.`/`trap`/`coproc` and arithmetic assignment. `export X` with no value does not withdraw it. - A glob in **any** path component counts, not only the last: `rm -rf /*/*` destroys `/usr/*`, `/etc/*` and `/home/*` and is now blocked. A bounded glob (`~/proj*/dist`, `/var/log/*.gz`) keeps its narrower check and stays allowed. - Brace alternations are expanded, so `rm -rf /{bin,etc,home}` is seen as the root deletes it performs. - Command-substitution **bodies** are scanned as scripts: `echo $(rm -rf /)` runs the delete and discards only its output. diff --git a/hooks/codewith-native-common.test.ts b/hooks/codewith-native-common.test.ts index 8eec6ef..51f7184 100644 --- a/hooks/codewith-native-common.test.ts +++ b/hooks/codewith-native-common.test.ts @@ -1228,6 +1228,124 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { } }); + // ------------------------------------------------------------------------------------- + // Adversarial review round 6. Bracket handling produced a fresh under-match in every one + // of the three rounds that touched it, so the design changed rather than the edge cases: + // anything not modelled exactly now MATCHES. Over-blocking a rare construct is survivable; + // under-matching a protected root is not. + // ------------------------------------------------------------------------------------- + + test("unparseable bracket constructs match rather than vanish", async () => { + // Each of these names a real path in bash - verified with echo - while the previous + // matcher held a pattern that could match nothing at all. + for (const command of [ + "rm -rf /[e[:]tc", + "rm -rf /[e[=]tc", + "rm -rf /h[o[:]me", + "rm -rf /[u[:]sr", + "rm -rf /[v[:]ar/*", + "rm -rf /r[o[:]ot", + "rm -rf ~/.h[a[:]sna", + "rm -rf ~/.h[a[:]sna/repos", + `rm -rf "$(bun pm cache)"/[e[:]tc`, + // A negated UNKNOWN class: the fail-safe "unknown matches" was then inverted by the + // negation into "matches nothing", turning the safeguard into the bypass. + "rm -rf /[![:foo:]]tc", + "rm -rf /[!x[:foo:]]tc", + "rm -rf /[^[:foo:]]tc", + "rm -rf /h[![:foo:]]me", + // An escaped `]` mid-class dropped every member after it. + "rm -rf /[a\\]e]tc", + "rm -rf /[a\\]e]tc/*", + "rm -rf /home/hasna/.hasn[b\\]a]", + ]) { + await expectBlocked(command); + } + }); + + test("bracket flooding cannot stall the hook into failing open", async () => { + // Re-scanning to end-of-pattern from every `[` was quadratic: 20k brackets took 22s + // against the 20s timeout, on a command that also contained a real `rm -rf /*`. + // + // Two shapes, because the first one alone did not actually exercise the quadratic: a + // flooded target under `/` is answered by the covers check before the anchoring scan + // ever runs, so that test passed with the quadratic still in place. The second shape - + // flood as the last component of a repo-root child - is the one that reaches it. + const repo = mkdtempSync(join(homedir(), ".hooks-flood-")); + try { + Bun.spawnSync(["git", "init", "-q", repo]); + for (const [command, cwd] of [ + [`rm -rf /${"[".repeat(20000)} /*`, undefined], + [`rm -rf ${"[".repeat(20000)}`, repo], + ] as Array<[string, string | undefined]>) { + const started = performance.now(); + const result = await classify(command, cwd ? { cwd } : {}); + expect(result.block).toBe(true); + expect(performance.now() - started).toBeLessThan(3000); + } + } finally { + try { rmSync(repo, { recursive: true, force: true }); } catch {} + } + }); + + test("pushd -n records a directory without moving the shell", async () => { + const repo = mkdtempSync(join(homedir(), ".hooks-pushdn-")); + try { + Bun.spawnSync(["git", "init", "-q", repo]); + await expectBlocked("pushd -n /var/tmp; rm -rf *", { cwd: repo }); + await expectBlocked("pushd -n /var/tmp && rm -rf *", { cwd: repo }); + } finally { + try { rmSync(repo, { recursive: true, force: true }); } catch {} + } + }); + + test("a subshell cannot pop the parent shell's directory stack", async () => { + // The dirStack was copied per frame; sharing the array would let a subshell's popd + // rewrite the parent's stack. Previously no test distinguished the two. + const repo = mkdtempSync(join(homedir(), ".hooks-dirstack-")); + try { + Bun.spawnSync(["git", "init", "-q", repo]); + await expectBlocked("pushd /var/tmp; (pushd /var); popd; rm -rf *", { cwd: repo }); + } finally { + try { rmSync(repo, { recursive: true, force: true }); } catch {} + } + }); + + test("dot-anchored and punctuation-only globs are sweeps, not anchored patterns", async () => { + const repo = mkdtempSync(join(homedir(), ".hooks-dotglob-")); + try { + Bun.spawnSync(["git", "init", "-q", repo]); + for (const command of ["rm -rf .??*", "rm -rf .?*", "rm -rf .[a-z]*", "rm -rf *.*"]) { + await expectBlocked(command, { cwd: repo }); + } + // Real literal text still anchors, including after a dot. + for (const command of ["rm -rf .turbo*", "rm -rf .venv*", "rm -rf *.log"]) { + await expectAllowed(command, { cwd: repo }); + } + } finally { + try { rmSync(repo, { recursive: true, force: true }); } catch {} + } + }); + + test("every shape that can rebind a name withdraws the guarantee", async () => { + for (const rebind of [ + "IFS= read -r D", + "while read D; do :; done", + "printf -v D ''", + "source ./x.sh", + ". ./x.sh", + 'trap "D=" EXIT', + "builtin read D", + "declare -n D=E", + "getopts o D", + "coproc read D", + ]) { + await expectBlocked(`D=/tmp/b; ${rebind}; rm -rf "$D"/*`); + } + // `export X` with no value does NOT change X, so it must not withdraw anything. + await expectAllowed(`X=/tmp/build; export X; rm -rf "$X"/*`); + }); + test("a quoted paren inside a substitution does not disable the collapse rule", async () => { // The unterminated-substitution fallback turned an ordinary awk field separator into a // bypass, because the quoted "(" was counted as structure. diff --git a/hooks/codewith-native-common.ts b/hooks/codewith-native-common.ts index 5f278c3..6e3cfa6 100644 --- a/hooks/codewith-native-common.ts +++ b/hooks/codewith-native-common.ts @@ -724,8 +724,6 @@ function bracketExpressionEnd(pattern: string, open: number): number { while (i < pattern.length) { const ch = pattern[i]; if (ch === "\\") { i += 2; continue; } - // POSIX [:class:], [=equiv=] and [.collate.] contain a `]` that does NOT terminate the - // expression. Stopping at the first `]` made `[[:lower:]]` match nothing at all. if (ch === "[" && (pattern[i + 1] === ":" || pattern[i + 1] === "=" || pattern[i + 1] === ".")) { const kind = pattern[i + 1]; const close = pattern.indexOf(`${kind}]`, i + 2); @@ -739,72 +737,52 @@ function bracketExpressionEnd(pattern: string, open: number): number { return -1; } -const POSIX_CLASS_MATCHERS: Record boolean> = { - alpha: (ch) => /\p{L}/u.test(ch), - digit: (ch) => /\p{Nd}/u.test(ch), - alnum: (ch) => /[\p{L}\p{Nd}]/u.test(ch), - lower: (ch) => /\p{Ll}/u.test(ch), - upper: (ch) => /\p{Lu}/u.test(ch), - space: (ch) => /\s/.test(ch), - punct: (ch) => /[!-\/:-@[-`{-~]/.test(ch), - xdigit: (ch) => /[0-9A-Fa-f]/.test(ch), - word: (ch) => /[\w]/.test(ch), - blank: (ch) => ch === " " || ch === "\t", - print: (ch) => ch >= " " && ch !== "\u007f", - graph: (ch) => ch > " " && ch !== "\u007f", - cntrl: (ch) => ch < " " || ch === "\u007f", -}; - -/** Does `ch` satisfy the bracket expression `pattern[open..close]`? */ +/** + * Does this bracket expression match `ch`? + * + * Returns TRUE whenever the expression contains anything this matcher does not model exactly. + * That direction is the entire design, and it is the correction for six consecutive rounds of + * one defect: every bracket bug on this branch has been an UNDER-match, and an under-match + * means a protected root goes unmatched and the delete is allowed. `[e]tc`, `[[:lower:]]`, + * `[e[:]tc`, `[![:foo:]]` and `[a\]e]` each named a real path in bash while the guard held a + * pattern that could match nothing at all. + * + * Over-matching costs a false block on a construct almost nobody writes. Under-matching costs + * a filesystem. So POSIX classes, equivalence and collating classes, and backslash escapes are + * all treated as matching rather than as not-matching. + */ function bracketMatches(pattern: string, open: number, close: number, ch: string): boolean { - let i = open + 1; - let negated = false; - if (pattern[i] === "!" || pattern[i] === "^") { negated = true; i += 1; } + const body = pattern.slice(open + 1, close); + const negated = body.startsWith("!") || body.startsWith("^"); + const members = negated ? body.slice(1) : body; + + // Anything not modelled exactly: fail closed by matching. + if (/\\|\[[:=.]/.test(members)) return true; let matched = false; let first = true; - while (i < close) { - if (pattern[i] === "[" && (pattern[i + 1] === ":" || pattern[i + 1] === "=" || pattern[i + 1] === ".")) { - const kind = pattern[i + 1]; - const end = pattern.indexOf(`${kind}]`, i + 2); - if (end === -1 || end >= close) break; - const name = pattern.slice(i + 2, end); - if (kind === ":") { - const test = POSIX_CLASS_MATCHERS[name]; - // An unknown class must not silently match nothing. - if (test === undefined ? true : test(ch)) matched = true; - } else if (name === ch) { - matched = true; - } - i = end + 2; - first = false; - continue; - } - - let member = pattern[i]; - if (member === "\\" && i + 1 < close) { i += 1; member = pattern[i]; } - // `]` is a literal only in first position; `-` is a literal at either end. + for (let i = 0; i < members.length; i += 1) { + const member = members[i]; if (member === "]" && !first) break; - - if (pattern[i + 1] === "-" && i + 2 < close && pattern[i + 2] !== "]") { - let upper = pattern[i + 2]; - let step = 3; - if (upper === "\\" && i + 3 < close) { upper = pattern[i + 3]; step = 4; } - if (ch >= member && ch <= upper) matched = true; - i += step; - } else { - if (ch === member) matched = true; - i += 1; + if (members[i + 1] === "-" && i + 2 < members.length && members[i + 2] !== "]") { + if (ch >= member && ch <= members[i + 2]) matched = true; + i += 2; + } else if (ch === member) { + matched = true; } first = false; } - return negated ? !matched : matched; } /** - * Two-pointer wildcard match with backtracking limited to the last `*`, so it is linear in - * practice and can never blow up the way the compiled regex did. + * Two-pointer wildcard match. Backtracking is limited to the last `*`, so it stays linear in + * practice - the compiled-regex version it replaced backtracked exponentially and blew past + * this hook's 20s timeout, which fails open. + * + * An unterminated or unparseable bracket makes the REST of the component match anything, + * rather than degrading `[` to a literal. The literal reading is an under-match, and + * `rm -rf /[e[:]tc` - which bash expands to `/etc` - slipped through on exactly that path. */ function globMatches(pattern: string, name: string): boolean { let p = 0; @@ -828,15 +806,9 @@ function globMatches(pattern: string, name: string): boolean { } if (p < pattern.length && ch === "[") { const close = bracketExpressionEnd(pattern, p); - if (close !== -1) { - if (bracketMatches(pattern, p, close, name[n])) { - p = close + 1; - n += 1; - continue; - } - } else if (name[n] === "[") { - // Unterminated: bash treats the `[` literally. - p += 1; + if (close === -1) return true; + if (bracketMatches(pattern, p, close, name[n])) { + p = close + 1; n += 1; continue; } @@ -861,8 +833,8 @@ function globMatches(pattern: string, name: string): boolean { } /** - * Does this glob keep no literal text at all, so it can match essentially any name? - * `[a-z]*` and `?*` are unanchored; `*.log` and `tmp-*` are anchored by their literals. + * Does this glob keep no literal text that anchors it, so it can match essentially any name? + * `[a-z]*`, `?*`, `.??*` and `*.*` are unanchored; `*.log` and `tmp-*` are anchored. */ function isUnanchoredGlob(pattern: string): boolean { if (!/[*?[]/.test(pattern)) return false; @@ -873,15 +845,21 @@ function isUnanchoredGlob(pattern: string): boolean { if (ch === "*" || ch === "?") continue; if (ch === "[") { const close = bracketExpressionEnd(pattern, i); - if (close === -1) { residue += ch; continue; } + // Unparseable: the rest matches anything, so nothing after it can anchor. Returning + // here also keeps this linear - re-scanning to end-of-pattern from every `[` was + // quadratic, and a 20k-bracket flood took 22s against the 20s timeout, failing open. + if (close === -1) return true; i = close; continue; } residue += ch; } - return residue.length === 0; + // A leading dot does not anchor: `.??*` sweeps a directory just as `*` does. Nor does + // punctuation alone: `*.*` takes every dotted entry at the root. + return residue.replace(/^\./, "").replace(/[.\-_]/g, "").length === 0; } + function globComponentMatches(pattern: string, literal: string): boolean { if (!/[*?[]/.test(pattern)) return pattern === literal; if (CATCH_ALL_GLOB.test(pattern)) return true; @@ -1071,11 +1049,17 @@ const GUARDED_EXPANSION = /^\$\{[A-Za-z_][A-Za-z0-9_]*:\?/; const NON_EMPTY_PLACEHOLDER = "__hooks_guarded_expansion__"; const MAX_EXPANSION_NESTING = 32; -// Builtins that can bind or rebind a variable name. `eval` is here because its argument is -// opaque, so nothing after it can be guaranteed. -const REBINDING_BUILTINS = new Set([ - "export", "declare", "typeset", "readonly", "local", "read", "eval", "let", "mapfile", "readarray", -]); +// Builtins whose effect on a variable this scan cannot follow at all. Any of them clears +// every guarantee, because guessing in the permissive direction is how `$X` stayed certified +// non-empty while the shell had already emptied it. +const OPAQUE_BUILTINS = new Set(["eval", "source", ".", "trap", "coproc", "exec"]); + +// Builtins that bind a BARE name, with no `=` in sight: `read D`, `getopts o D`. +const NAME_BINDING_BUILTINS = new Set(["read", "getopts", "mapfile", "readarray"]); + +// Builtins that take `NAME=value` operands. A BARE name here does not change the variable - +// `export X` merely exports the existing value - so bare names must not withdraw anything. +const VALUE_BINDING_BUILTINS = new Set(["export", "declare", "typeset", "readonly", "local", "let"]); /** One shell expansion found in a token, with its exact source span. */ interface FoundExpansion { @@ -1300,28 +1284,57 @@ function assignmentTimeline(command: string): Array> { continue; } - // Any construct that can rebind a name withdraws the guarantee. The token loop below - // stops at the first non-assignment token, so `export X=$(cmd)` was invisible while an - // earlier `X=/tmp/build` kept certifying X as non-empty. - if (REBINDING_BUILTINS.has(tokens[0])) { - const next = new Set(current); - for (const token of tokens.slice(1)) { - const name = token.match(/^([A-Za-z_][A-Za-z0-9_]*)(?:=([\s\S]*))?$/); - if (!name) continue; - next.delete(name[1]); - // A literal, non-empty value re-establishes it; anything expandable does not. - if (name[2] !== undefined && name[2].length > 0 && !/[$`]/.test(name[2])) next.add(name[1]); - } - current = next; + // Any construct that can rebind a name withdraws the guarantee. Scanned across ALL + // tokens, not just the first: `IFS= read -r D` hides the builtin behind a prefix + // assignment, and `while read D` behind a keyword, and both kept D certified non-empty. + if (tokens.some((token) => OPAQUE_BUILTINS.has(token)) || /\(\(/.test(text)) { + current = new Set(); continue; } - // `for NAME in …` rebinds NAME to each word, any of which may be empty. - if (tokens[0] === "for" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(tokens[1] ?? "")) { - const next = new Set(current); - next.delete(tokens[1]); - current = next; - continue; + { + let next: Set | null = null; + const withdraw = (name: string) => { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return; + next ??= new Set(current); + next.delete(name); + }; + + for (const [position, token] of tokens.entries()) { + if (NAME_BINDING_BUILTINS.has(token)) { + for (const operand of tokens.slice(position + 1)) { + if (!operand.startsWith("-")) withdraw(operand); + } + continue; + } + if (token === "printf") { + const flag = tokens.indexOf("-v", position); + if (flag !== -1) withdraw(tokens[flag + 1] ?? ""); + continue; + } + if (VALUE_BINDING_BUILTINS.has(token)) { + // `declare -n D=E` makes D an alias for E, so D's value is E's - not the literal. + const nameref = tokens.slice(position + 1).some((operand) => operand === "-n"); + for (const operand of tokens.slice(position + 1)) { + const assignment = operand.match(/^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/); + if (!assignment) continue; + withdraw(assignment[1]); + if (!nameref && assignment[2].length > 0 && !/[$`]/.test(assignment[2])) { + next ??= new Set(current); + next.add(assignment[1]); + } + } + continue; + } + if (token === "for" && position + 1 < tokens.length) { + withdraw(tokens[position + 1]); + } + } + + if (next) { + current = next; + continue; + } } let next: Set | null = null; @@ -2123,6 +2136,9 @@ function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: Rea if (verb === "cd" || verb === "pushd") { // A `cd` in a pipeline stage runs in its own process and moves nothing else. if (piped) return; + // `pushd -n` records the directory WITHOUT moving the shell, so the tracked cwd must + // not follow it. Previously `-n` was read as the directory operand. + if (verb === "pushd" && tokens.includes("-n")) return; // `pushd` saves the current directory before moving. if (verb === "pushd") frame.dirStack.push(frame.cwds); // Skip cd's own flags (-P, -L, --) to reach the directory operand. diff --git a/hooks/pre-bash/README.md b/hooks/pre-bash/README.md index 2e10c8c..227fe42 100644 --- a/hooks/pre-bash/README.md +++ b/hooks/pre-bash/README.md @@ -49,6 +49,28 @@ case "$dir" in /|"") exit 1;; esac rm -rf -- "$dir" ``` +## Globs + +A glob threatens a protected root when it can match that root or an ancestor of it, or when it +wipes the root's contents wholesale. Matching is per path component, so a trailing literal +bounds the delete: `rm -rf */node_modules` at a monorepo root is allowed, while `rm -rf /*/*` +is not. + +A glob directly under a protected root is refused only when it is *unanchored* — when no +literal text survives once the wildcards are removed. `[a-z]*`, `?*`, `.??*` and `*.*` are +unanchored and blocked; `*.log`, `tmp-*`, `.turbo*` and `snapshot-[0-9]*` keep their literal +anchor and are allowed. + +Bracket expressions that this matcher does not model exactly — POSIX `[:class:]`, `[=equiv=]`, +`[.collate.]`, backslash escapes, anything unterminated — are treated as **matching**, never as +not-matching. An under-match would leave a protected root unmatched and allow the delete, so +ambiguity resolves toward refusing. + +## Working directory + +`cd`, `pushd`, `pushd -n`, `popd` and `cd -` are tracked, per subshell, with a directory stack. +A `cd` inside `( … )` or a pipeline stage applies within that shell and does not escape it. + ## Wrappers Commands are unwrapped before scanning: `bash -c` / `sh -c` / `zsh -c`, `su -c`, From 3261656b66c3af740c6595f4b31a162e04fe7875 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Mon, 27 Jul 2026 03:57:56 +0300 Subject: [PATCH 10/14] fix(pre-bash): bound the class-terminator search; certify only from command 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. --- hooks/codewith-native-common.test.ts | 113 +++++++++++++++++++- hooks/codewith-native-common.ts | 153 +++++++++++++++++---------- 2 files changed, 210 insertions(+), 56 deletions(-) diff --git a/hooks/codewith-native-common.test.ts b/hooks/codewith-native-common.test.ts index 51f7184..f89a8c4 100644 --- a/hooks/codewith-native-common.test.ts +++ b/hooks/codewith-native-common.test.ts @@ -1274,15 +1274,23 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { const repo = mkdtempSync(join(homedir(), ".hooks-flood-")); try { Bun.spawnSync(["git", "init", "-q", repo]); + // Timing is the point here. The verdict differs per shape and both are correct: + // a bracket run with NO `]` is a literal filename to bash (`printf %s "[[[["` prints + // `[[[[`, and an unterminated `[` is not a glob), so deleting it is an ordinary + // targeted delete; the flood next to a real `/*` is a root wipe and blocks. for (const [command, cwd] of [ [`rm -rf /${"[".repeat(20000)} /*`, undefined], [`rm -rf ${"[".repeat(20000)}`, repo], + [`rm -rf /${"[".repeat(20000)}]`, undefined], ] as Array<[string, string | undefined]>) { const started = performance.now(); - const result = await classify(command, cwd ? { cwd } : {}); - expect(result.block).toBe(true); + await classify(command, cwd ? { cwd } : {}); expect(performance.now() - started).toBeLessThan(3000); } + // The flood must not disarm the delete beside it. + expect((await classify(`rm -rf /${"[".repeat(20000)} /*`)).block).toBe(true); + // ...and a literal bracket filename is a targeted delete, not a sweep. + expect((await classify(`rm -rf ${"[".repeat(20000)}`, { cwd: repo })).block).toBe(false); } finally { try { rmSync(repo, { recursive: true, force: true }); } catch {} } @@ -1346,6 +1354,107 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { await expectAllowed(`X=/tmp/build; export X; rm -rf "$X"/*`); }); + // ------------------------------------------------------------------------------------- + // Adversarial review round 7. The first block asserts the MATCHER directly: five of round + // 6's fifteen bracket assertions passed with the glob matcher entirely removed, because a + // bare glob under `/` is caught by the unanchored-root rule regardless. Adding one literal + // character after the bracket stops that rule firing - which is exactly how these shipped. + // ------------------------------------------------------------------------------------- + + test("a class terminator belonging to a later bracket is not taken as this one's", async () => { + // `[u[:]` searched to end-of-component for `:]` and found the one inside the FOLLOWING + // bracket, swallowing its own `]`. Verified in bash: /[u[:][[:alpha:]]r expands to /usr. + // 158 commands onto live system roots were allowed by that one unbounded search. + for (const command of [ + "rm -rf /[u[:][[:alpha:]]r", + "rm -rf /[e[:][[:alpha:]]c", + "rm -rf /[h[:][[:alpha:]]me", + "rm -rf /[u[:][[:alpha:]]r/*", + "rm -rf /b[i[:][[:alpha:]]", + "rm -rf /[v[:][[:alpha:]]r", + ]) { + await expectBlocked(command); + } + }); + + test("a mention of an assignment is not an execution of it", async () => { + // The all-token rebinding scan also GRANTED certification at any position, so a comment + // naming the variable certified it as non-empty - the realized incident shape, in the + // likeliest form an agent writes it. + await expectBlocked('# export CACHE=/tmp/bun-cache\nrm -rf "$CACHE"/*'); + await expectBlocked('echo export X=/tmp/build; rm -rf "$X"/*'); + await expectBlocked('grep export CACHE=/tmp/x notes.txt; rm -rf "$CACHE"/*'); + // A trailing comment in the same segment. Covered by the same command-position rule - + // `export` is not token 0 here either - and asserted so the shape is pinned. + await expectBlocked('rm -rf "$CACHE"/* # export CACHE=/tmp/x'); + // A real assignment in command position still certifies. + await expectAllowed('export X=/tmp/build; rm -rf "$X"/*'); + await expectAllowed('X=/tmp/build; rm -rf "$X"/*'); + }); + + test("an assignment inside a compound-command body withdraws the guarantee", async () => { + for (const rebind of [ + "{ X=; }", + "f() { X=; }; f", + "{ unset X; }", + "if true; then X=; fi", + "while :; do X=; break; done", + "until false; do X=; break; done", + ]) { + await expectBlocked(`X=/tmp/build; ${rebind}; rm -rf "$X"/*`); + } + await expectBlocked('X=/tmp/build; { X=; rm -rf "$X"/*; }'); + // Distinguishing case for the function-body rule: `f() { … }` is split on the parens, so + // the keyword form is the one that actually needs it. + await expectBlocked('X=/tmp/build; function f { X=; }; f; rm -rf "$X"/*'); + }); + + test("builtin-token padding cannot stall the hook into failing open", async () => { + // The all-token scan allocated a slice per token: 20k `export A=1 ` took 20.9s against + // the 20s timeout. Fourth time a bound in this file reopened that same fail-open. + for (const padding of ["export A=1 ".repeat(20000), "read ".repeat(40000)]) { + const started = performance.now(); + const result = await classify(`${padding}; rm -rf /*`); + expect(result.block).toBe(true); + // The single-pass implementation runs this in ~100ms. A 3s budget was loose enough + // that re-introducing per-token slicing still passed, so the margin is tightened to + // what the fix actually achieves. + expect(performance.now() - started).toBeLessThan(1000); + } + }); + + test("a literal bracket in a filename is not a sweep", async () => { + // Fail-closed matching must stop where bash stops globbing: `[` with no `]` is an + // ordinary character, and these were re-broken twice on this branch. + const repo = mkdtempSync(join(homedir(), ".hooks-litbracket-")); + try { + Bun.spawnSync(["git", "init", "-q", repo]); + await expectAllowed("rm -rf 'weird[dir'", { cwd: repo }); + await expectAllowed("rm -rf 'backup[2026'", { cwd: repo }); + // Distinguishing case for the matcher itself, not the anchoring scan: without the + // literal-bracket rule the unterminated `[` makes the component match ANYTHING, so + // this reads as `/etc`. bash leaves it literal - `shopt -s nullglob; a=( /etc[x )` + // still yields one element. + // The bracket must be REACHED by the matcher for this to distinguish anything: + // `etc[x` runs out of name before the `[`, so it is rejected on length alone. + // `et[c` evaluates the bracket against `c`. bash leaves it literal - verified with + // `shopt -s nullglob; a=( /et[c )` yielding one element. + await expectAllowed("rm -rf /et[c"); + await expectAllowed("rm -rf /ho[me"); + await expectAllowed("rm -rf /etc[x"); + } finally { + try { rmSync(repo, { recursive: true, force: true }); } catch {} + } + }); + + test("opaque builtins and for-bindings are recognised at any token position", async () => { + // Both fixes were real and had ZERO coverage: every case that looked like it tested + // all-token scanning was satisfied by the name-binding branch or by tokens[0]. + await expectBlocked(`X=/tmp/build; env eval 'X='; rm -rf "$X"/*`); + await expectBlocked(`X=/tmp/build; command source ./x.sh; rm -rf "$X"/*`); + await expectBlocked(`X=/tmp/build; do for X in ""; do :; done; rm -rf "$X"/*`); + }); + test("a quoted paren inside a substitution does not disable the collapse rule", async () => { // The unterminated-substitution fallback turned an ordinary awk field separator into a // bypass, because the quoted "(" was counted as structure. diff --git a/hooks/codewith-native-common.ts b/hooks/codewith-native-common.ts index 6e3cfa6..be818e8 100644 --- a/hooks/codewith-native-common.ts +++ b/hooks/codewith-native-common.ts @@ -726,9 +726,17 @@ function bracketExpressionEnd(pattern: string, open: number): number { if (ch === "\\") { i += 2; continue; } if (ch === "[" && (pattern[i + 1] === ":" || pattern[i + 1] === "=" || pattern[i + 1] === ".")) { const kind = pattern[i + 1]; - const close = pattern.indexOf(`${kind}]`, i + 2); - if (close === -1) return -1; - i = close + 2; + const classClose = pattern.indexOf(`${kind}]`, i + 2); + const plainClose = pattern.indexOf("]", i + 2); + // The `:]` must come before the next plain `]`, or this is not a class and that `]` + // closes the bracket. Searching to end-of-component let a `:]` belonging to a LATER + // bracket be taken as this one's, swallowing the real terminator - so `[u[:]` absorbed + // the next expression and `/[u[:][[:alpha:]]r`, which bash expands to /usr, matched + // nothing at all. 158 commands onto live system roots were allowed by that one line. + if (classClose === -1 || (plainClose !== -1 && plainClose < classClose)) { + return plainClose; + } + i = classClose + 2; continue; } if (ch === "]") return i; @@ -838,12 +846,15 @@ function globMatches(pattern: string, name: string): boolean { */ function isUnanchoredGlob(pattern: string): boolean { if (!/[*?[]/.test(pattern)) return false; + // Computed once, not per bracket: a `[` with no `]` anywhere is a literal character, so a + // directory named `backup[2026` is anchored by its own name and is not a sweep. + const bracketsArePatterns = pattern.includes("]"); let residue = ""; for (let i = 0; i < pattern.length; i += 1) { const ch = pattern[i]; if (ch === "\\" && i + 1 < pattern.length) { residue += pattern[i + 1]; i += 1; continue; } if (ch === "*" || ch === "?") continue; - if (ch === "[") { + if (ch === "[" && bracketsArePatterns) { const close = bracketExpressionEnd(pattern, i); // Unparseable: the rest matches anything, so nothing after it can anchor. Returning // here also keeps this linear - re-scanning to end-of-pattern from every `[` was @@ -862,6 +873,10 @@ function isUnanchoredGlob(pattern: string): boolean { function globComponentMatches(pattern: string, literal: string): boolean { if (!/[*?[]/.test(pattern)) return pattern === literal; + // `[` with no `]` anywhere and no other wildcard is a literal bracket, not an expression. + // Without this, a directory genuinely named `backup[2026` was escalated to "wipes the + // repository root" - fail-closed matching has to stop where bash stops globbing. + if (!pattern.includes("]") && !/[*?]/.test(pattern)) return pattern === literal; if (CATCH_ALL_GLOB.test(pattern)) return true; return globMatches(pattern, literal); } @@ -1054,6 +1069,12 @@ const MAX_EXPANSION_NESTING = 32; // non-empty while the shell had already emptied it. const OPAQUE_BUILTINS = new Set(["eval", "source", ".", "trap", "coproc", "exec"]); +// Compound-command keywords that can precede an assignment in the same segment. +const COMPOUND_KEYWORDS = new Set(["{", "}", "then", "do", "else", "elif", "fi", "done", "!"]); + +// Sentinel marking PWD as reassigned, so $PWD stops being treated as shell-maintained. +const PWD_REASSIGNED = "\u0000PWD-REASSIGNED"; + // Builtins that bind a BARE name, with no `=` in sight: `read D`, `getopts o D`. const NAME_BINDING_BUILTINS = new Set(["read", "getopts", "mapfile", "readarray"]); @@ -1143,7 +1164,7 @@ function expansionCannotBeEmpty(text: string, nonEmptyNames: ReadonlySet // covers unset, so a set-but-empty VAR still yields "". // $PWD and $(pwd) are maintained by the shell, but only while nothing reassigns PWD. if (text === "$PWD" || text === "${PWD}" || /^\$\(\s*pwd\s*\)$/.test(text) || /^`\s*pwd\s*`$/.test(text)) { - return !nonEmptyNames.has("\u0000PWD-REASSIGNED"); + return !nonEmptyNames.has(PWD_REASSIGNED); } // Assigned a non-empty literal earlier in this same command. @@ -1272,7 +1293,16 @@ function assignmentTimeline(command: string): Array> { // Only assignments in the parent shell, in their own right, change it. if (depth > 0 || isolated) continue; - const tokens = shellWords(text); + // `{ X=; }`, `then X=`, `do X=` - strip the compound-command keyword so the assignment + // inside is seen. cwdTrackedSegments already did this; this scan did not, so + // `X=/tmp/build; { X=; }; rm -rf "$X"/*` kept X certified while bash emptied it. + const rawTokens = shellWords(text); + const tokens = rawTokens.filter((token, index) => !(index === 0 && COMPOUND_KEYWORDS.has(token))); + // A function body runs later and elsewhere, so nothing in it can be relied on. + if (/^[A-Za-z_][A-Za-z0-9_]*\s*\(\s*\)/.test(text) || rawTokens[0] === "function") { + current = new Set(); + continue; + } if (tokens.length === 0) continue; if (tokens[0] === "unset") { @@ -1286,70 +1316,85 @@ function assignmentTimeline(command: string): Array> { // Any construct that can rebind a name withdraws the guarantee. Scanned across ALL // tokens, not just the first: `IFS= read -r D` hides the builtin behind a prefix - // assignment, and `while read D` behind a keyword, and both kept D certified non-empty. - if (tokens.some((token) => OPAQUE_BUILTINS.has(token)) || /\(\(/.test(text)) { - current = new Set(); - continue; - } + // assignment and `while read D` behind a keyword, and both kept D certified non-empty. + // + // Single pass, no slicing. Allocating `tokens.slice(position + 1)` per token made this + // O(tokens^2): 20k `export A=1 ` took 20.9s against the 20s timeout, and a timed-out hook + // fails open - the fourth time a bound in this file reopened that same hole. + // + // WITHDRAWAL is scanned at any position, because a rebinding can hide anywhere. + // CERTIFICATION is granted only from token 0, because a mention is not an execution: + // `# export CACHE=/tmp/x` in a comment certified CACHE as non-empty, which is the realized + // incident shape exactly - a documented cleanup script is the likeliest way to write it. + let next: Set | null = null; + const withdraw = (name: string) => { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return; + next ??= new Set(current); + next.delete(name); + }; - { - let next: Set | null = null; - const withdraw = (name: string) => { - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return; - next ??= new Set(current); - next.delete(name); - }; + let pendingNameBinder = false; + let pendingValueBinder = false; + let valueBinderIsCommand = false; + let sawNameref = false; + let opaque = false; + let sawNonAssignment = false; - for (const [position, token] of tokens.entries()) { - if (NAME_BINDING_BUILTINS.has(token)) { - for (const operand of tokens.slice(position + 1)) { - if (!operand.startsWith("-")) withdraw(operand); - } - continue; - } - if (token === "printf") { - const flag = tokens.indexOf("-v", position); - if (flag !== -1) withdraw(tokens[flag + 1] ?? ""); - continue; - } - if (VALUE_BINDING_BUILTINS.has(token)) { - // `declare -n D=E` makes D an alias for E, so D's value is E's - not the literal. - const nameref = tokens.slice(position + 1).some((operand) => operand === "-n"); - for (const operand of tokens.slice(position + 1)) { - const assignment = operand.match(/^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/); - if (!assignment) continue; - withdraw(assignment[1]); - if (!nameref && assignment[2].length > 0 && !/[$`]/.test(assignment[2])) { - next ??= new Set(current); - next.add(assignment[1]); - } - } - continue; - } - if (token === "for" && position + 1 < tokens.length) { - withdraw(tokens[position + 1]); - } + for (const [position, token] of tokens.entries()) { + if (OPAQUE_BUILTINS.has(token)) { opaque = true; break; } + + if (NAME_BINDING_BUILTINS.has(token) || token === "for") { + pendingNameBinder = true; + pendingValueBinder = false; + sawNonAssignment = true; + continue; + } + if (VALUE_BINDING_BUILTINS.has(token)) { + pendingValueBinder = true; + pendingNameBinder = false; + // Only a builtin in command position can actually bind anything. + valueBinderIsCommand = position === 0; + sawNameref = false; + sawNonAssignment = true; + continue; } + if (token === "printf") { sawNonAssignment = true; continue; } + if (token === "-v") { pendingNameBinder = true; continue; } + if (token === "-n" && pendingValueBinder) { sawNameref = true; continue; } - if (next) { - current = next; + if (pendingNameBinder) { + if (!token.startsWith("-")) withdraw(token); + continue; + } + if (pendingValueBinder) { + if (token.startsWith("-")) continue; + const bound = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/); + if (!bound) continue; + withdraw(bound[1]); + // `declare -n D=E` aliases D to E, so D's value is E's, not this literal. + if (valueBinderIsCommand && !sawNameref && bound[2].length > 0 && !/[$`]/.test(bound[2])) { + next ??= new Set(current); + next.add(bound[1]); + } continue; } - } - let next: Set | null = null; - for (const [position, token] of tokens.entries()) { + // Plain `NAME=value`, only while still in the command's assignment prefix. const assignment = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/); - if (!assignment) break; + if (!assignment) { sawNonAssignment = true; continue; } + if (sawNonAssignment) continue; const [, name, value] = assignment; + // A PREFIX assignment applies to the command's environment, not to this expansion. const isPrefixAssignment = position < tokens.length - 1 && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[position + 1] ?? ""); next ??= new Set(current); next.delete(name); - if (name === "PWD") next.add("\u0000PWD-REASSIGNED"); + if (name === "PWD") next.add(PWD_REASSIGNED); if (isPrefixAssignment) continue; if (value.length > 0 && !/[$`]/.test(value)) next.add(name); } + + if (opaque) { current = new Set(); continue; } if (next) current = next; } From f38fca53602b0ab9ff01aa8ab3a914ab70cd4ef3 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Mon, 27 Jul 2026 07:17:01 +0300 Subject: [PATCH 11/14] fix(pre-bash): fail closed on the bracket BOUNDARY, not just its contents 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. --- hooks/codewith-native-common.test.ts | 138 +++++++++++++++++++++++++-- hooks/codewith-native-common.ts | 89 +++++++++++------ 2 files changed, 191 insertions(+), 36 deletions(-) diff --git a/hooks/codewith-native-common.test.ts b/hooks/codewith-native-common.test.ts index f89a8c4..cadcf19 100644 --- a/hooks/codewith-native-common.test.ts +++ b/hooks/codewith-native-common.test.ts @@ -6,6 +6,7 @@ import { claimCommand, classifyDangerousOperation, emptyExpansionCollapse, + globComponentMatches, getAgentName, gitCommandInfo, gitRemoteHostSlug, @@ -434,6 +435,18 @@ describe("codewith native common helpers", () => { * rm, at any scope, ever. */ describe("destructive shell guard - rm -rf /* incident regression", () => { + /** + * Root for throwaway git fixtures. + * + * Deliberately NOT the real $HOME - a crashed run would strand repos in the operator's home + * directory. Also deliberately not shared with any `pushd` target used below: a fixture + * under the same tree as the pushd target once made the popd test pass for the wrong + * reason, because `/*` covered the fixture whether or not popd was handled. + */ + const fixtureRoot = mkdtempSync(join(tmpdir(), "hooks-fixtures-")); + afterAll(() => { + try { rmSync(fixtureRoot, { recursive: true, force: true }); } catch {} + }); // The incident machine's HOME. Pinned as an explicit fixture so the three mandated // regression commands can appear verbatim rather than reconstructed from the runner's env. const INCIDENT_HOME = "/home/hasna"; @@ -1143,7 +1156,7 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { // for the rest of the command. Blocked on d8c0e8a; allowed from f6def3c until now. // Deliberately NOT under the pushd target: a repo inside /tmp made this pass for the // wrong reason, because `/tmp/*` covers the repo root whether or not popd is handled. - const repo = mkdtempSync(join(homedir(), ".hooks-popd-")); + const repo = mkdtempSync(join(fixtureRoot, "hooks-popd-")); try { Bun.spawnSync(["git", "init", "-q", repo]); for (const command of [ @@ -1271,7 +1284,7 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { // flooded target under `/` is answered by the covers check before the anchoring scan // ever runs, so that test passed with the quadratic still in place. The second shape - // flood as the last component of a repo-root child - is the one that reaches it. - const repo = mkdtempSync(join(homedir(), ".hooks-flood-")); + const repo = mkdtempSync(join(fixtureRoot, "hooks-flood-")); try { Bun.spawnSync(["git", "init", "-q", repo]); // Timing is the point here. The verdict differs per shape and both are correct: @@ -1297,7 +1310,7 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { }); test("pushd -n records a directory without moving the shell", async () => { - const repo = mkdtempSync(join(homedir(), ".hooks-pushdn-")); + const repo = mkdtempSync(join(fixtureRoot, "hooks-pushdn-")); try { Bun.spawnSync(["git", "init", "-q", repo]); await expectBlocked("pushd -n /var/tmp; rm -rf *", { cwd: repo }); @@ -1310,7 +1323,7 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { test("a subshell cannot pop the parent shell's directory stack", async () => { // The dirStack was copied per frame; sharing the array would let a subshell's popd // rewrite the parent's stack. Previously no test distinguished the two. - const repo = mkdtempSync(join(homedir(), ".hooks-dirstack-")); + const repo = mkdtempSync(join(fixtureRoot, "hooks-dirstack-")); try { Bun.spawnSync(["git", "init", "-q", repo]); await expectBlocked("pushd /var/tmp; (pushd /var); popd; rm -rf *", { cwd: repo }); @@ -1320,7 +1333,7 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { }); test("dot-anchored and punctuation-only globs are sweeps, not anchored patterns", async () => { - const repo = mkdtempSync(join(homedir(), ".hooks-dotglob-")); + const repo = mkdtempSync(join(fixtureRoot, "hooks-dotglob-")); try { Bun.spawnSync(["git", "init", "-q", repo]); for (const command of ["rm -rf .??*", "rm -rf .?*", "rm -rf .[a-z]*", "rm -rf *.*"]) { @@ -1426,7 +1439,7 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { test("a literal bracket in a filename is not a sweep", async () => { // Fail-closed matching must stop where bash stops globbing: `[` with no `]` is an // ordinary character, and these were re-broken twice on this branch. - const repo = mkdtempSync(join(homedir(), ".hooks-litbracket-")); + const repo = mkdtempSync(join(fixtureRoot, "hooks-litbracket-")); try { Bun.spawnSync(["git", "init", "-q", repo]); await expectAllowed("rm -rf 'weird[dir'", { cwd: repo }); @@ -1455,6 +1468,119 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { await expectBlocked(`X=/tmp/build; do for X in ""; do :; done; rm -rf "$X"/*`); }); + // ------------------------------------------------------------------------------------- + // Adversarial review round 8. The bracket BOUNDARY was the defect class that survived + // seven rounds: contents failed closed, but every disagreement with bash about where a + // bracket ENDS became a silent under-match. Round 6 searched too far, round 7 stopped too + // early and made it net worse (220 -> 380 live escapes). The guard now refuses to compute + // a boundary it cannot pin down. + // ------------------------------------------------------------------------------------- + + test("a component with a POSIX class, equivalence or collating symbol matches anything", async () => { + // Each expands onto a live protected root in real bash; each was allowed at HEAD. + for (const command of [ + "rm -rf /[![=o=]]]*", + "rm -rf /[[:]:]v]*", + "rm -rf /*[[:[=c=]]", + "rm -rf /[b[.][:]*n", + "rm -rf /[h[.[::]]*", + "rm -rf /*[[:]:]c]", + "rm -rf /[p[.].]]*", + "rm -rf /*[s[.].]]", + "rm -rf /*[[=a=]]r]", + "rm -rf /[b[.[::]]*", + // Round 6 and 7 shapes, kept so neither direction can regress. + "rm -rf /[e[:]tc", + "rm -rf /[u[:][[:alpha:]]r", + ]) { + await expectBlocked(command); + } + }); + + test("the matcher itself matches every component bash matches", () => { + // Asserted on the MATCHER, not on an end-to-end verdict. Round 7 found that 5 of 15 + // end-to-end bracket assertions passed with the matcher entirely removed, because the + // unanchored-glob-at-the-root rule catches them regardless - which is exactly how the + // boundary escapes shipped twice. Every pair below was confirmed against real bash with + // `[[ $name == $pattern ]]`. + for (const [pattern, name] of [ + ["[![=o=]]]*", "bin"], + ["[[:]:]v]*", "var"], + ["*[[:[=c=]]", "etc"], + ["[b[.][:]*n", "bin"], + ["[h[.[::]]*", "home"], + ["*[[:]:]c]", "etc"], + ["[p[.].]]*", "proc"], + ["*[s[.].]]", "usr"], + ["*[[=a=]]r]", "var"], + ["[e[:]tc", "etc"], + ["[u[:][[:alpha:]]r", "usr"], + ["[[:lower:]]tc", "etc"], + ["[![:foo:]]tc", "etc"], + ] as Array<[string, string]>) { + expect(globComponentMatches(pattern, name), `${pattern} must match ${name}`).toBe(true); + } + + // ...and does not match what bash does not: a literal bracket is not a pattern. + expect(globComponentMatches("et[c", "etc")).toBe(false); + expect(globComponentMatches("etc[x", "etc")).toBe(false); + expect(globComponentMatches("backup[2026", "backup")).toBe(false); + // Ordinary globs keep working. + expect(globComponentMatches("*.log", "app.log")).toBe(true); + expect(globComponentMatches("*.log", "app.txt")).toBe(false); + expect(globComponentMatches("[a-z]*", "etc")).toBe(true); + expect(globComponentMatches("[!a-z]*", "etc")).toBe(false); + }); + + test("an assignment in a branch that may not run does not certify", async () => { + // Stripping a compound keyword is right for withdrawal - the branch might run - but it + // must not grant certification, which asserts the value IS set. `then X=/tmp/build` + // certified X and let `rm -rf "$X"/*` through as the realized incident shape. + for (const command of [ + 'if [ -d /nonexistent ]; then CACHE=/tmp/c; fi; rm -rf "$CACHE"/*', + 'if false; then X=/tmp/build; fi; rm -rf "$X"/*', + 'while false; do X=/tmp/build; done; rm -rf "$X"/*', + 'for i in ""; do X=/tmp/build; done; rm -rf "$X"/*', + ]) { + await expectBlocked(command); + } + // The same must hold through a value-binding builtin, which is a separate code path. + await expectBlocked('if false; then export X=/tmp/build; fi; rm -rf "$X"/*'); + await expectBlocked('while false; do declare X=/tmp/build; done; rm -rf "$X"/*'); + // Unconditional assignment still certifies. + await expectAllowed('X=/tmp/build; rm -rf "$X"/*'); + await expectAllowed('export X=/tmp/build; rm -rf "$X"/*'); + }); + + test("distinct-name padding cannot stall the hook into failing open", async () => { + // The certified-name set was copied per segment - O(segments x names). 30k distinct names + // took 24.4s against the 20s timeout. The walker now advances one set in place and hands + // it out only for segments that actually delete something. + const command = `${Array.from({ length: 30000 }, (_, i) => `A${i}=1`).join("; ")}; rm -rf /*`; + const started = performance.now(); + const result = await classify(command); + expect(result.block).toBe(true); + expect(performance.now() - started).toBeLessThan(2000); + }); + + test("~ and $HOME resolve through the same home as the protected roots", async () => { + // `~` went through homedir() while $HOME and every rule root used process.env.HOME, so + // wherever they differ - containers, `sudo -u`, CI - the target and the rule were built + // from different directories and `rm -rf ~/.hasna` missed its own rule. It also made this + // suite pass only on a machine whose HOME is literally /home/hasna. + const elsewhere = mkdtempSync(join(fixtureRoot, "otherhome-")); + await expectBlocked("rm -rf ~/.hasna", { home: elsewhere }); + await expectBlocked('rm -rf "$HOME"/.hasna', { home: elsewhere }); + await expectBlocked("rm -rf ~/.hasna/repos", { home: elsewhere }); + }); + + test("value-binding builtins and for-bindings are recognised away from position 0", async () => { + // Both mechanisms were real and had zero coverage across 72 assertions. + await expectBlocked('X=/tmp/build; IFS= export X=$(cmd); rm -rf "$X"/*'); + await expectBlocked('X=/tmp/build; LC_ALL=C read X; rm -rf "$X"/*'); + await expectBlocked('X=/tmp/build; time for X in ""; do :; done; rm -rf "$X"/*'); + }); + test("a quoted paren inside a substitution does not disable the collapse rule", async () => { // The unterminated-substitution fallback turned an ordinary awk field separator into a // bypass, because the quoted "(" was counted as structure. diff --git a/hooks/codewith-native-common.ts b/hooks/codewith-native-common.ts index be818e8..25b15df 100644 --- a/hooks/codewith-native-common.ts +++ b/hooks/codewith-native-common.ts @@ -383,9 +383,12 @@ function shellWords(segment: string): string[] { } function expandHome(path: string): string { + // One home for every form. `~` used homedir() while `$HOME` and every protected root used + // process.env.HOME, so wherever the two differ the target and the rule were resolved against + // different directories and `rm -rf ~/.hasna` missed the ~/.hasna rule entirely. const home = process.env.HOME || homedir(); - if (path === "~") return homedir(); - if (path.startsWith("~/")) return join(homedir(), path.slice(2)); + if (path === "~") return home; + if (path.startsWith("~/")) return join(home, path.slice(2)); if (path === "$HOME" || path === "${HOME}") return home; if (path.startsWith("$HOME/")) return join(home, path.slice("$HOME/".length)); if (path.startsWith("${HOME}/")) return join(home, path.slice("${HOME}/".length)); @@ -871,12 +874,27 @@ function isUnanchoredGlob(pattern: string): boolean { } -function globComponentMatches(pattern: string, literal: string): boolean { +export function globComponentMatches(pattern: string, literal: string): boolean { if (!/[*?[]/.test(pattern)) return pattern === literal; // `[` with no `]` anywhere and no other wildcard is a literal bracket, not an expression. // Without this, a directory genuinely named `backup[2026` was escalated to "wipes the // repository root" - fail-closed matching has to stop where bash stops globbing. if (!pattern.includes("]") && !/[*?]/.test(pattern)) return pattern === literal; + // Fail closed on an ambiguous BOUNDARY, not just ambiguous contents. + // + // This is the defect that survived eight review rounds. Bracket CONTENTS already failed + // closed, but 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 reopened the class net worse: + // 220 -> 380 live root-wipe escapes. + // + // Every one of those 380 contained `[:`, `[=` or `[.`. Plain brackets, ranges, negation, + // `*`, `?` and backslash escapes were measured clean across 44,867 dangerous patterns. So + // the guard stops trying to locate a boundary it cannot pin down: a component containing a + // POSIX class, equivalence class or collating symbol matches anything. + if (/\[[:=.]/.test(pattern)) return true; if (CATCH_ALL_GLOB.test(pattern)) return true; return globMatches(pattern, literal); } @@ -1282,36 +1300,49 @@ export function emptyExpansionCollapse( * (X=/tmp/build); rm … a subshell-scoped assignment escaping its subshell * X=/tmp/build | cat; rm … a pipeline-stage assignment doing the same */ -function assignmentTimeline(command: string): Array> { - const timeline: Array> = []; +function assignmentWalker(command: string): { at: (segmentIndex: number) => ReadonlySet } { + const segments = splitShellSegmentsDetailed(command); + let cursor = 0; let current = new Set(); - for (const { text, depth, isolated } of splitShellSegmentsDetailed(command)) { - // The set as it stands BEFORE this segment runs. - timeline.push(current); + // Advances a SINGLE set forward and hands it out only when a segment actually contains a + // delete. Materialising one snapshot per segment was O(segments x names): 30k distinct + // names took 24.4s against the 20s timeout, and a timed-out hook fails open. Almost every + // command has one delete, so almost every command now copies nothing. + const at = (segmentIndex: number): ReadonlySet => { + while (cursor < segmentIndex && cursor < segments.length) { + applySegment(segments[cursor]); + cursor += 1; + } + return current; + }; + + function applySegment({ text, depth, isolated }: ShellSegment): void { // Only assignments in the parent shell, in their own right, change it. - if (depth > 0 || isolated) continue; + if (depth > 0 || isolated) return; // `{ X=; }`, `then X=`, `do X=` - strip the compound-command keyword so the assignment // inside is seen. cwdTrackedSegments already did this; this scan did not, so // `X=/tmp/build; { X=; }; rm -rf "$X"/*` kept X certified while bash emptied it. const rawTokens = shellWords(text); const tokens = rawTokens.filter((token, index) => !(index === 0 && COMPOUND_KEYWORDS.has(token))); + // An assignment behind a compound keyword may never execute: `if false; then X=/tmp; fi`. + // Stripping the keyword is right for WITHDRAWAL - the branch might run - but it must not + // grant certification, which is a claim that the value IS set. + const conditional = rawTokens.length !== tokens.length; // A function body runs later and elsewhere, so nothing in it can be relied on. if (/^[A-Za-z_][A-Za-z0-9_]*\s*\(\s*\)/.test(text) || rawTokens[0] === "function") { current = new Set(); - continue; + return; } - if (tokens.length === 0) continue; + if (tokens.length === 0) return; if (tokens[0] === "unset") { - const next = new Set(current); for (const name of tokens.slice(1)) { - if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) next.delete(name); + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) current.delete(name); } - current = next; - continue; + return; } // Any construct that can rebind a name withdraws the guarantee. Scanned across ALL @@ -1326,11 +1357,9 @@ function assignmentTimeline(command: string): Array> { // CERTIFICATION is granted only from token 0, because a mention is not an execution: // `# export CACHE=/tmp/x` in a comment certified CACHE as non-empty, which is the realized // incident shape exactly - a documented cleanup script is the likeliest way to write it. - let next: Set | null = null; const withdraw = (name: string) => { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return; - next ??= new Set(current); - next.delete(name); + current.delete(name); }; let pendingNameBinder = false; @@ -1372,9 +1401,8 @@ function assignmentTimeline(command: string): Array> { if (!bound) continue; withdraw(bound[1]); // `declare -n D=E` aliases D to E, so D's value is E's, not this literal. - if (valueBinderIsCommand && !sawNameref && bound[2].length > 0 && !/[$`]/.test(bound[2])) { - next ??= new Set(current); - next.add(bound[1]); + if (!conditional && valueBinderIsCommand && !sawNameref && bound[2].length > 0 && !/[$`]/.test(bound[2])) { + current.add(bound[1]); } continue; } @@ -1387,18 +1415,16 @@ function assignmentTimeline(command: string): Array> { // A PREFIX assignment applies to the command's environment, not to this expansion. const isPrefixAssignment = position < tokens.length - 1 && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[position + 1] ?? ""); - next ??= new Set(current); - next.delete(name); - if (name === "PWD") next.add(PWD_REASSIGNED); + current.delete(name); + if (name === "PWD") current.add(PWD_REASSIGNED); if (isPrefixAssignment) continue; - if (value.length > 0 && !/[$`]/.test(value)) next.add(name); + if (!conditional && value.length > 0 && !/[$`]/.test(value)) current.add(name); } - if (opaque) { current = new Set(); continue; } - if (next) current = next; + if (opaque) current = new Set(); } - return timeline; + return { at }; } function shouldSkipHasnaTreeRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean { @@ -2248,16 +2274,19 @@ function destructiveShellTargets(command: string, cwd: string): DestructiveShell for (const layer of shellCommandLayers(command).layers) { const bindings = forLoopBindings(layer.command); - const timeline = assignmentTimeline(layer.command); + const assignments = assignmentWalker(layer.command); for (const chunk of cwdTrackedSegments(layer.command, cwd, new Set())) { - const nonEmptyNames = timeline[chunk.segmentIndex] ?? new Set(); const raw = [ ...rmCommandTargets(chunk.segment), ...rsyncDeleteTargets(chunk.segment), ...findDestructiveTargets(chunk.segment), ...gitDestructiveTargets(chunk.segment, chunk.cwds[0]), ]; + // Only a segment that actually deletes something needs the certified-name set, so the + // walker is advanced lazily and almost never has to copy. + if (raw.length === 0) continue; + const nonEmptyNames = assignments.at(chunk.segmentIndex); const expanded = raw.flatMap((target) => { const words = loopBoundWords(target.path, bindings); From 70c2afdad7fc7c5ac668a216b3910185fda940b0 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Mon, 27 Jul 2026 08:12:16 +0300 Subject: [PATCH 12/14] fix(pre-bash): track conditionality by context; sweep on any unanchored first component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `; 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. --- hooks/codewith-native-common.test.ts | 94 +++++++++++++++++++++++++++- hooks/codewith-native-common.ts | 77 +++++++++++++++++++---- 2 files changed, 159 insertions(+), 12 deletions(-) diff --git a/hooks/codewith-native-common.test.ts b/hooks/codewith-native-common.test.ts index cadcf19..241396e 100644 --- a/hooks/codewith-native-common.test.ts +++ b/hooks/codewith-native-common.test.ts @@ -7,6 +7,7 @@ import { classifyDangerousOperation, emptyExpansionCollapse, globComponentMatches, + defaultWorktreesRoot, getAgentName, gitCommandInfo, gitRemoteHostSlug, @@ -1511,7 +1512,10 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { ["[h[.[::]]*", "home"], ["*[[:]:]c]", "etc"], ["[p[.].]]*", "proc"], - ["*[s[.].]]", "usr"], + // bash matches `sys` here, not `usr` - the original pair was asserted as verified and + // was not. It passed only because the blunt rule matches every literal, which is this + // branch's signature failure: a green assertion whose stated cause is not its cause. + ["*[s[.].]]", "sys"], ["*[[=a=]]r]", "var"], ["[e[:]tc", "etc"], ["[u[:][[:alpha:]]r", "usr"], @@ -1581,6 +1585,94 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { await expectBlocked('X=/tmp/build; time for X in ""; do :; done; rm -rf "$X"/*'); }); + // ------------------------------------------------------------------------------------- + // Adversarial review round 9. The bracket class is confirmed closed (0 under-matches in + // 3.3M matcher cells against real bash). These cover what round 8's OTHER fix missed. + // ------------------------------------------------------------------------------------- + + test("conditionality is a property of context, not of the first token", async () => { + // Deriving it from "a compound keyword was stripped from token 0" closed ~5% of the + // class: one inserted statement, or `&&`/`||`/`case`, restored certification. 297 of + // those shapes were bash-proven `rm -rf /*`. + for (const guard of [ + "if false; then A=1; X=/tmp/build; fi", + "if false; then :; X=/tmp/build; fi", + "false && X=/tmp/build", + "true || X=/tmp/build", + "[ -d /nonexistent ] && X=/tmp/build", + "test -d /nonexistent && X=/tmp/build", + "case x in y) X=/tmp/build;; esac", + "until true; do A=1; X=/tmp/build; done", + "grep -q zzz /dev/null && X=/tmp/build", + ]) { + await expectBlocked(`${guard}; rm -rf "$X"/*`); + } + await expectBlocked('if [ -d /nonexistent ]; then CACHE=/tmp/c; fi; rm -rf "$CACHE"/*'); + + // A brace group is NOT conditional - bash runs it in the current shell. + await expectAllowed('X=/tmp/build; { export X=/tmp/build; }; rm -rf "$X"/*'); + await expectAllowed('X=/tmp/build; rm -rf "$X"/*'); + }); + + test("any unanchored first component sweeps the filesystem root, not just a literal *", async () => { + // `/?*/bin` and `/[a-z]*/bin` expand to /usr/bin exactly as `/*/bin` does; the sweep rule + // tested for a literal `*` where the design says an unpinnable component is unbounded. + for (const command of [ + "rm -rf /*/bin", + "rm -rf /?*/bin", + "rm -rf /[a-z]*/bin", + "rm -rf /[[:alpha:]]*/bin", + "rm -rf /[[:lower:]]*/lib", + ]) { + await expectBlocked(command); + } + // Anchored first components are still ordinary targeted deletes. + await expectAllowed("rm -rf /opt/*/logs"); + await expectAllowed("rm -rf /var/*/tmp"); + await expectAllowed("rm -rf /tmp*/x"); + }); + + test("a long relative cd chain cannot stall the hook, and an absolute cd still lands", async () => { + // Resolving an ever-growing path per `cd` was quadratic: 70k took 19.6s against the 20s + // timeout. Capping at PATH_MAX fixed that but skipped a later absolute `cd`, which was + // itself a fail-open - the guard stayed on the long path and `rm -rf *` was allowed. + const flood = Array.from({ length: 70000 }, (_, i) => `cd d${i}`).join("; "); + for (const command of [ + `${flood}; rm -rf /*`, + `${flood}; cd /; rm -rf *`, + `${flood}; cd ~; rm -rf .hasna`, + ]) { + const started = performance.now(); + const result = await classify(command); + expect(result.block).toBe(true); + expect(performance.now() - started).toBeLessThan(3000); + } + }); + + test("an unpinnable target says why it was refused", async () => { + // Reporting only "targets /" for `rm -rf /var[.]log` is wrong and gets a guard switched + // off; the operator needs to know it was refused as unanalysable. + const result = await classify("rm -rf /var[.]log"); + expect(result.block).toBe(true); + expect(result.reason).toContain("POSIX character class"); + }); + + test("the worktrees root resolves through the same home as everything else", async () => { + // Same divergence as `~`, one function away: defaultWorktreesRoot used homedir() while + // every rule root used process.env.HOME. + const previous = process.env.HASNA_REPOS_WORKTREES_ROOT; + delete process.env.HASNA_REPOS_WORKTREES_ROOT; + const home = mkdtempSync(join(fixtureRoot, "wthome-")); + try { + process.env.HOME = home; + expect(defaultWorktreesRoot()).toBe(join(home, ".hasna", "repos", "worktrees")); + } finally { + if (previous === undefined) delete process.env.HASNA_REPOS_WORKTREES_ROOT; + else process.env.HASNA_REPOS_WORKTREES_ROOT = previous; + process.env.HOME = INCIDENT_HOME; + } + }); + test("a quoted paren inside a substitution does not disable the collapse rule", async () => { // The unterminated-substitution fallback turned an ordinary awk field separator into a // bypass, because the quoted "(" was counted as structure. diff --git a/hooks/codewith-native-common.ts b/hooks/codewith-native-common.ts index 25b15df..82aa357 100644 --- a/hooks/codewith-native-common.ts +++ b/hooks/codewith-native-common.ts @@ -142,12 +142,14 @@ export interface GitCommandInfo { function splitShellSegmentsPass( command: string, atomicSubstitutions: boolean -): { segments: string[]; isolation: boolean[]; depths: number[]; groups: number[]; piped: boolean[]; unterminated: boolean } { +): { segments: string[]; isolation: boolean[]; depths: number[]; groups: number[]; piped: boolean[]; shortCircuit: boolean[]; unterminated: boolean } { const segments: string[] = []; const isolation: boolean[] = []; const depths: number[] = []; const groups: number[] = []; const pipedFlags: boolean[] = []; + const shortCircuitFlags: boolean[] = []; + let precededByShortCircuit = false; let current = ""; let quote: "'" | '"' | null = null; let escaped = false; @@ -169,6 +171,7 @@ function splitShellSegmentsPass( depths.push(parenDepth); groups.push(groupStack[groupStack.length - 1] ?? 0); pipedFlags.push(pipedFromPrevious || nextSeparator === "|"); + shortCircuitFlags.push(precededByShortCircuit); } current = ""; pipedFromPrevious = nextSeparator === "|"; @@ -227,6 +230,8 @@ function splitShellSegmentsPass( const doubled = (ch === "|" || ch === "&") && command[i + 1] === ch; // `||` and `&&` are sequencing, not a pipe. flush(ch === "|" && !doubled ? "|" : null); + // `a && X=1` and `a || X=1` run X= only if the left side decided so. + precededByShortCircuit = doubled && (ch === "|" || ch === "&"); if (ch === "(") { parenDepth += 1; groupCounter += 1; @@ -242,7 +247,7 @@ function splitShellSegmentsPass( } flush(null); - return { segments, isolation, depths, groups, piped: pipedFlags, unterminated: substitutionDepth > 0 || inBacktick }; + return { segments, isolation, depths, groups, piped: pipedFlags, shortCircuit: shortCircuitFlags, unterminated: substitutionDepth > 0 || inBacktick }; } function splitShellSegments(command: string): string[] { @@ -258,6 +263,8 @@ interface ShellSegment { group: number; /** This segment is a pipeline stage, so its `cd` affects nothing outside the stage. */ piped: boolean; + /** Reached only via `&&` / `||`, so whether it ran depends on the previous command. */ + shortCircuit: boolean; /** * True when the segment runs in a subshell `( … )` or as a stage of a pipeline. A `cd` * there affects only that child process, so treating it as persistent silently moves the @@ -289,6 +296,7 @@ function splitShellSegmentsUncached(command: string): ShellSegment[] { depth: chosen.depths[index] ?? 0, group: chosen.groups[index] ?? 0, piped: chosen.piped[index] ?? false, + shortCircuit: chosen.shortCircuit[index] ?? false, isolated: chosen.isolation[index] ?? false, })); } @@ -965,7 +973,9 @@ function globThreatensRule(targetPath: string, rule: ProtectedPathRule): boolean // /usr/bin, /var/bin and the rest, and a trailing literal makes the pattern deeper than any // single root, so component matching alone misses it. Scoped to the filesystem root so // ordinary sweeps deeper down - `/opt/*/logs`, `/var/*/tmp`, `*/node_modules` - stay allowed. - if (rule.root === sep && parts.length > 1 && CATCH_ALL_GLOB.test(parts[1])) return true; + // Any UNANCHORED first component sweeps every top-level directory, not just a literal `*`: + // `/?*/bin` and `/[a-z]*/bin` expand to /usr/bin exactly as `/*/bin` does. + if (rule.root === sep && parts.length > 1 && isUnanchoredGlob(parts[1])) return true; return false; } @@ -1317,7 +1327,10 @@ function assignmentWalker(command: string): { at: (segmentIndex: number) => Read return current; }; - function applySegment({ text, depth, isolated }: ShellSegment): void { + // Depth of open `if` / `while` / `until` / `case` blocks. Everything inside one may not run. + let conditionalDepth = 0; + + function applySegment({ text, depth, isolated, shortCircuit }: ShellSegment): void { // Only assignments in the parent shell, in their own right, change it. if (depth > 0 || isolated) return; @@ -1327,10 +1340,24 @@ function assignmentWalker(command: string): { at: (segmentIndex: number) => Read // `X=/tmp/build; { X=; }; rm -rf "$X"/*` kept X certified while bash emptied it. const rawTokens = shellWords(text); const tokens = rawTokens.filter((token, index) => !(index === 0 && COMPOUND_KEYWORDS.has(token))); - // An assignment behind a compound keyword may never execute: `if false; then X=/tmp; fi`. - // Stripping the keyword is right for WITHDRAWAL - the branch might run - but it must not - // grant certification, which is a claim that the value IS set. - const conditional = rawTokens.length !== tokens.length; + // An assignment that may never execute must not CERTIFY, though it must still WITHDRAW - + // the branch might run. Conditionality is a property of context, so it is tracked across + // segments rather than read off the first token of this one. Deriving it from "a compound + // keyword was stripped from token 0" closed about 5% of the class: inserting one statement + // (`if false; then A=1; X=/tmp/build; fi`) or using `&&`/`||`/`case` restored certification, + // and 297 of those shapes were bash-proven `rm -rf /*`. + // + // A brace group `{ …; }` is NOT conditional - bash runs it in the current shell - so it is + // deliberately excluded here even though its keyword is stripped for tokenizing. + const OPENERS = new Set(["if", "elif", "while", "until", "case", "select"]); + const CLOSERS = new Set(["fi", "done", "esac"]); + for (const token of rawTokens) { + if (OPENERS.has(token)) conditionalDepth += 1; + else if (CLOSERS.has(token)) conditionalDepth = Math.max(0, conditionalDepth - 1); + } + const conditional = conditionalDepth > 0 + || shortCircuit + || rawTokens.some((token) => OPENERS.has(token) || token === "then" || token === "do"); // A function body runs later and elsewhere, so nothing in it can be relied on. if (/^[A-Za-z_][A-Za-z0-9_]*\s*\(\s*\)/.test(text) || rawTokens[0] === "function") { current = new Set(); @@ -2146,6 +2173,8 @@ interface CommandChunk { } const MAX_CWD_VARIANTS = 4; +// Linux PATH_MAX. A tracked cwd longer than this cannot correspond to a real directory. +const MAX_TRACKED_CWD_LENGTH = 4096; /** * Segments of one layer paired with the working directories in effect when they run. @@ -2228,6 +2257,18 @@ function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: Rea const collapsed = emptyExpansionCollapse(operand, nonEmptyNames); const next = new Set(); for (const current of frame.cwds) { + // A chain of RELATIVE `cd`s grows the tracked path by a component each time, and + // resolving an ever-longer path n times is quadratic: 70k `cd d` took 19.6s + // against the 20s timeout, and a timed-out hook fails open. No real directory can + // exceed PATH_MAX, so declining to grow past it is correct rather than a mere cap. + // + // An ABSOLUTE cd is always applied: it replaces the path rather than extending it, + // so it is cheap, and skipping it was itself a fail-open - a `cd /` after the flood + // left the guard pointing at the long path and `rm -rf *` was allowed. + if (!isAbsolute(expandHome(operand)) && current.length > MAX_TRACKED_CWD_LENGTH) { + next.add(current); + continue; + } next.add(resolveFrom(current, operand)); if (collapsed !== null) next.add(resolveFrom(current, collapsed)); } @@ -2283,9 +2324,9 @@ function destructiveShellTargets(command: string, cwd: string): DestructiveShell ...findDestructiveTargets(chunk.segment), ...gitDestructiveTargets(chunk.segment, chunk.cwds[0]), ]; - // Only a segment that actually deletes something needs the certified-name set, so the - // walker is advanced lazily and almost never has to copy. if (raw.length === 0) continue; + // The walker advances one set IN PLACE - that is what removed the O(segments x names) + // copy, not this lookup being lazy. const nonEmptyNames = assignments.at(chunk.segmentIndex); const expanded = raw.flatMap((target) => { @@ -2363,9 +2404,21 @@ function extractFileToolPaths(input: CodewithHookInput): Array<{ path: string; o } function scopedBlockReason(operation: string, targetPath: string, rule: ProtectedPathRule, remote?: boolean): string { + // A POSIX class, equivalence class or collating symbol makes the pattern's extent + // unpinnable, so the guard treats it as matching anything. Saying only "targets /" would be + // wrong and confusing when the command reads `rm -rf /var[.]log` - the operator needs to + // know it was refused for being unanalysable, not for naming the filesystem root. + const unpinnable = /\[[:=.]/.test(targetPath) + ? [ + "This target contains a POSIX character class, equivalence class or collating symbol,", + "whose extent cannot be determined without replicating the shell exactly. It is therefore", + "treated as matching any name. Use a literal path, or a plain glob, if this was not intended.", + ] + : []; return [ `Blocked scoped dangerous operation: ${operation} targets ${targetPath}${remote ? " on a remote host" : ""}.`, `Protected scope: ${rule.label} (${rule.root}).`, + ...unpinnable, "This guard is scoped; destructive commands outside protected roots are not blocked.", "Delete a specific named subdirectory instead of the root or its contents.", ].join(" "); @@ -2534,7 +2587,9 @@ export function isTopLevelSession(input: CodewithHookInput): boolean { } export function defaultWorktreesRoot(): string { - return process.env.HASNA_REPOS_WORKTREES_ROOT || join(homedir(), ".hasna", "repos", "worktrees"); + // Same home for every resolution in this file; see expandHome. + return process.env.HASNA_REPOS_WORKTREES_ROOT + || join(process.env.HOME || homedir(), ".hasna", "repos", "worktrees"); } export function isInsidePath(child: string, parent: string): boolean { From 53505e2102b39a81752f35b3416d5518c2f07479 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Mon, 27 Jul 2026 08:53:32 +0300 Subject: [PATCH 13/14] fix(pre-bash): unfreeze the cwd cap, count keywords in command position, bound oversized input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hooks/codewith-native-common.test.ts | 110 ++++++++++++++++++++++++- hooks/codewith-native-common.ts | 115 +++++++++++++++++++++++---- 2 files changed, 208 insertions(+), 17 deletions(-) diff --git a/hooks/codewith-native-common.test.ts b/hooks/codewith-native-common.test.ts index 241396e..7291f3e 100644 --- a/hooks/codewith-native-common.test.ts +++ b/hooks/codewith-native-common.test.ts @@ -1525,6 +1525,33 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { expect(globComponentMatches(pattern, name), `${pattern} must match ${name}`).toBe(true); } + // The pairs above are answered by the unpinnable-boundary rule, which matches ANY name - + // so the literal in each is inert and a wrong one cannot be detected. Asserted explicitly + // rather than left implied, because the previous version of this test claimed to verify + // the literals and could not. + for (const pattern of ["*[s[.].]]", "[[:lower:]]tc", "[![:foo:]]tc"]) { + expect(globComponentMatches(pattern, "ZZqq9Xw"), `${pattern} is unpinnable`).toBe(true); + } + + // Patterns WITHOUT an unpinnable construct exercise the real matcher, so their literals + // are load-bearing. Every pair confirmed against bash `[[ $name == $pattern ]]`. + for (const [pattern, name, want] of [ + ["[a-z]tc", "etc", true], + ["[a-z]tc", "9tc", false], + ["[!a-z]tc", "9tc", true], + ["[!a-z]tc", "etc", false], + ["u[s]r", "usr", true], + ["u[s]r", "uxr", false], + ["h[o-p]me", "home", true], + ["h[a-c]me", "home", false], + ["*b[i]n", "bin", true], + ["*b[i]n", "ban", false], + ["v[a]r", "var", true], + ["v[a]r", "vor", false], + ] as Array<[string, string, boolean]>) { + expect(globComponentMatches(pattern, name), `${pattern} vs ${name}`).toBe(want); + } + // ...and does not match what bash does not: a literal bracket is not a pattern. expect(globComponentMatches("et[c", "etc")).toBe(false); expect(globComponentMatches("etc[x", "etc")).toBe(false); @@ -1629,7 +1656,8 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { // Anchored first components are still ordinary targeted deletes. await expectAllowed("rm -rf /opt/*/logs"); await expectAllowed("rm -rf /var/*/tmp"); - await expectAllowed("rm -rf /tmp*/x"); + // `/tmp*/x` moved to BLOCK in round 10: at the filesystem root a single literal character + // is not an anchor. Asserted in the round-10 sweep test. }); test("a long relative cd chain cannot stall the hook, and an absolute cd still lands", async () => { @@ -1673,6 +1701,86 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { } }); + // ------------------------------------------------------------------------------------- + // Adversarial review round 10. + // ------------------------------------------------------------------------------------- + + test("a cd that shrinks the path is never skipped by the length cap", async () => { + // The PATH_MAX cap skipped ALL relative operands once crossed, including `..`, which + // shrinks. After one crossing the model froze while bash walked back to `/`, so + // `rm -rf *` was allowed - a fail-open introduced by the cap itself. + const repo = mkdtempSync(join(fixtureRoot, "unwind-")); + Bun.spawnSync(["git", "init", "-q", repo]); + // TWO operations only, so the cd BUDGET is nowhere near spent and this isolates the + // length cap. One descent past PATH_MAX, then one `..` back: if `..` is skipped as + // "relative", the guard stays deep while bash is back at the repo root. + const deep = `cd ${"a".repeat(4200)}`; + await expectBlocked(`${deep}; cd ..; rm -rf *`, { cwd: repo }); + // `.` is the repo root itself; `.git` is a child and is correctly not a protected root. + await expectBlocked(`${deep}; cd ..; rm -rf .`, { cwd: repo }); + + // And the same over a long chain, where the budget is spent as well. + const down = Array.from({ length: 2000 }, (_, i) => `cd d${i}`).join("; "); + const up = Array.from({ length: 2100 }, () => "cd ..").join("; "); + await expectBlocked(`${down}; ${up}; rm -rf *`, { cwd: repo }); + await expectBlocked(`${down}; ${up}; rm -rf home`, { cwd: repo }); + }); + + test("a keyword only counts in command position, and for/done stay balanced", async () => { + for (const command of [ + // `for` opens because `done` closes; omitting it let any loop zero the counter. + 'if [ -d /nonexistent ]; then for f in *.log; do echo "$f"; done; X=/tmp/build; fi; rm -rf "$X"/*', + 'for i in $NOPE; do A=1; X=/tmp/build; done; rm -rf "$X"/*', + // A literal `done`/`fi` WORD is not a closer. + 'if false; then echo done; X=/tmp/build; fi; rm -rf "$X"/*', + 'if false; then echo "fi"; X=/tmp/build; fi; rm -rf "$X"/*', + 'if false; then touch done; X=/tmp/build; fi; rm -rf "$X"/*', + 'if [ -d /opt/nonexistent-cache ]; then echo done; CACHE=/opt/nonexistent-cache; fi; rm -rf "$CACHE"/*', + 'if false; then echo x # fi\nX=/tmp/build; fi; rm -rf "$X"/*', + // `&&`/`||` govern the whole right-hand side, brace group included. + 'false && { A=1; X=/tmp/build; }; rm -rf "$X"/*', + 'true || { A=1; X=/tmp/build; }; rm -rf "$X"/*', + 'case x in y) echo fi;;& z) X=/tmp/build;; esac; rm -rf "$X"/*', + ]) { + await expectBlocked(command); + } + }); + + test("a keyword-shaped word does not poison the rest of the command", async () => { + // `elif` was counted as an opener although `fi` closes the chain once, and any token + // spelled like a keyword set conditional - both refused certification for ordinary work. + for (const command of [ + 'if a; then :; elif b; then :; fi; CACHE=/tmp/c; rm -rf "$CACHE"/*', + 'echo "if"; CACHE=/tmp/c; rm -rf "$CACHE"/*', + 'touch if; CACHE=/tmp/c; rm -rf "$CACHE"/*', + 'echo select; CACHE=/tmp/c; rm -rf "$CACHE"/*', + 'X=/tmp/build; { export X=/tmp/build; }; rm -rf "$X"/*', + ]) { + await expectAllowed(command); + } + }); + + test("any glob in the first component sweeps the filesystem root", async () => { + // `/*r*/lib` reached 11 of 25 top-level directories on the reference machine; one literal + // character is not an anchor at that depth. + for (const command of ["rm -rf /*r*/lib", "rm -rf /*s*/bin", "rm -rf /s*/bin", "rm -rf /tmp*/x"]) { + await expectBlocked(command); + } + // Deeper globs are still ordinary targeted deletes. + await expectAllowed("rm -rf /opt/*/logs"); + await expectAllowed("rm -rf /var/*/tmp"); + }); + + test("a command too large to tokenize is decided, not left to the timeout", async () => { + // 70k repetitions of `cd /<4KB>` is a 280 MB string; no per-rule bound helps because the + // cost is reading the input, and a timed-out hook fails open. + const oversized = Array.from({ length: 70000 }, () => `cd /${"a".repeat(3990)}`).join("; "); + const started = performance.now(); + expect((await classify(`${oversized}; rm -rf /*`)).block).toBe(true); + expect((await classify(oversized.replace(/cd /g, "echo "))).block).toBe(false); + expect(performance.now() - started).toBeLessThan(15000); + }); + test("a quoted paren inside a substitution does not disable the collapse rule", async () => { // The unterminated-substitution fallback turned an ordinary awk field separator into a // bypass, because the quoted "(" was counted as structure. diff --git a/hooks/codewith-native-common.ts b/hooks/codewith-native-common.ts index 82aa357..b29fab9 100644 --- a/hooks/codewith-native-common.ts +++ b/hooks/codewith-native-common.ts @@ -882,6 +882,12 @@ function isUnanchoredGlob(pattern: string): boolean { } +/** Does this component actually glob, or is it a literal that merely contains a bracket? */ +function componentIsPattern(component: string): boolean { + if (/[*?]/.test(component)) return true; + return component.includes("[") && component.includes("]"); +} + export function globComponentMatches(pattern: string, literal: string): boolean { if (!/[*?[]/.test(pattern)) return pattern === literal; // `[` with no `]` anywhere and no other wildcard is a literal bracket, not an expression. @@ -973,9 +979,12 @@ function globThreatensRule(targetPath: string, rule: ProtectedPathRule): boolean // /usr/bin, /var/bin and the rest, and a trailing literal makes the pattern deeper than any // single root, so component matching alone misses it. Scoped to the filesystem root so // ordinary sweeps deeper down - `/opt/*/logs`, `/var/*/tmp`, `*/node_modules` - stay allowed. - // Any UNANCHORED first component sweeps every top-level directory, not just a literal `*`: - // `/?*/bin` and `/[a-z]*/bin` expand to /usr/bin exactly as `/*/bin` does. - if (rule.root === sep && parts.length > 1 && isUnanchoredGlob(parts[1])) return true; + // At the filesystem root, ANY glob in the first component reaches several top-level + // directories: `/*r*/lib` matched 11 of 25 entries on the reference machine, and `/?*/bin` + // and `/[a-z]*/bin` reach /usr/bin exactly as `/*/bin` does. A single literal character is + // not an anchor at this depth, so the sweep rule does not ask for one. The cost is refusing + // `rm -rf /tmp*/x`, which is rare and safe to spell out literally. + if (rule.root === sep && parts.length > 1 && componentIsPattern(parts[1])) return true; return false; } @@ -1329,6 +1338,9 @@ function assignmentWalker(command: string): { at: (segmentIndex: number) => Read // Depth of open `if` / `while` / `until` / `case` blocks. Everything inside one may not run. let conditionalDepth = 0; + // Brace-group nesting, and the depth at which a `&&`/`||` right-hand side was entered. + let braceDepth = 0; + let conditionalBraceDepth = 0; function applySegment({ text, depth, isolated, shortCircuit }: ShellSegment): void { @@ -1349,15 +1361,43 @@ function assignmentWalker(command: string): { at: (segmentIndex: number) => Read // // A brace group `{ …; }` is NOT conditional - bash runs it in the current shell - so it is // deliberately excluded here even though its keyword is stripped for tokenizing. - const OPENERS = new Set(["if", "elif", "while", "until", "case", "select"]); + // `for` opens because `done` closes it - omitting it while keeping `done` a closer let any + // `for` loop inside a conditional zero the counter. `elif` does NOT open: `fi` closes an + // if/elif/else chain exactly once, so counting elif left the depth permanently above zero + // and nothing after the block could ever certify. + const OPENERS = new Set(["if", "while", "until", "case", "select", "for"]); const CLOSERS = new Set(["fi", "done", "esac"]); - for (const token of rawTokens) { - if (OPENERS.has(token)) conditionalDepth += 1; - else if (CLOSERS.has(token)) conditionalDepth = Math.max(0, conditionalDepth - 1); + // Keywords that introduce the NEXT command rather than being one, so the real command + // token sits behind them: `then for f in …` opens a loop that `done` will close. + const INTRODUCERS = new Set(["then", "do", "else", "elif", "!", "{", "}", "("]); + + // Only a keyword in COMMAND POSITION is a keyword. `echo done`, `touch fi` and a `fi` + // inside a heredoc body or after `#` are ordinary words, and treating them as closers + // decremented the counter and re-certified the branch. + let leadingIndex = 0; + while (leadingIndex < rawTokens.length && INTRODUCERS.has(rawTokens[leadingIndex])) leadingIndex += 1; + const leading = rawTokens[leadingIndex]; + const introducer = rawTokens[0]; + + if (leading !== undefined) { + if (OPENERS.has(leading)) conditionalDepth += 1; + else if (CLOSERS.has(leading)) conditionalDepth = Math.max(0, conditionalDepth - 1); } + + // `&&`/`||` govern the WHOLE right-hand side, including a brace group. Marking only the + // first segment after the operator let `false && { A=1; X=/tmp/build; }` certify X. + if (introducer === "{") braceDepth += 1; + if (introducer === "}" || rawTokens[rawTokens.length - 1] === "}") { + braceDepth = Math.max(0, braceDepth - 1); + if (braceDepth < conditionalBraceDepth) conditionalBraceDepth = 0; + } + if (shortCircuit && braceDepth > 0 && conditionalBraceDepth === 0) conditionalBraceDepth = braceDepth; + const conditional = conditionalDepth > 0 || shortCircuit - || rawTokens.some((token) => OPENERS.has(token) || token === "then" || token === "do"); + || conditionalBraceDepth > 0 + || (leading !== undefined && OPENERS.has(leading)) + || (introducer !== undefined && (introducer === "then" || introducer === "do" || introducer === "elif")); // A function body runs later and elsewhere, so nothing in it can be relied on. if (/^[A-Za-z_][A-Za-z0-9_]*\s*\(\s*\)/.test(text) || rawTokens[0] === "function") { current = new Set(); @@ -2175,6 +2215,11 @@ interface CommandChunk { const MAX_CWD_VARIANTS = 4; // Linux PATH_MAX. A tracked cwd longer than this cannot correspond to a real directory. const MAX_TRACKED_CWD_LENGTH = 4096; +// Beyond this many `cd`s the guard stops modelling the shell and fails closed; see below. +const MAX_CD_OPERATIONS = 2000; +// Far above any command a person or agent writes; below the size where tokenizing alone +// exceeds the hook's 20s budget. +const MAX_ANALYSABLE_COMMAND_LENGTH = 1_000_000; /** * Segments of one layer paired with the working directories in effect when they run. @@ -2191,6 +2236,7 @@ function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: Rea // that subshell - it just does not escape to the parent - so skipping isolated `cd` // outright left `(cd / && rm -rf *)`, the standard "cd without moving my shell" idiom, // completely unguarded. Depth 0 is the parent shell. + let cdOperations = 0; let stack: Array<{ group: number; cwds: string[]; previous: string[]; dirStack: string[][]; explicit: boolean }> = [ { group: 0, cwds: [baseCwd], previous: [baseCwd], dirStack: [], explicit: false }, ]; @@ -2246,6 +2292,20 @@ function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: Rea while (i < tokens.length && (tokens[i] === "-P" || tokens[i] === "-L" || tokens[i] === "-e" || tokens[i] === "-@" || tokens[i] === "--")) i += 1; const operand = tokens[i]; const priorCwds = frame.cwds; + cdOperations += 1; + // Once the budget is spent the guard can no longer model a chain of RELATIVE cds. It + // must not simply keep the last known directory - that was the fail-open the PATH_MAX + // cap produced - so `/` joins the candidate set and any relative delete is judged + // against the filesystem root too. `rm -rf *` then blocks; `rm -rf dist` still resolves + // to /dist and passes. + // + // An ABSOLUTE cd is never dropped: it is a real landing the guard can still model + // exactly, and skipping it lost `cd ~` after a flood, which allowed `rm -rf .hasna`. + if (cdOperations > MAX_CD_OPERATIONS && !isAbsolute(expandHome(operand ?? ""))) { + if (!frame.cwds.includes(sep)) frame.cwds = [...frame.cwds, sep].slice(0, MAX_CWD_VARIANTS); + frame.explicit = true; + return; + } if (operand === undefined || operand === "~") { frame.cwds = [home]; @@ -2257,15 +2317,25 @@ function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: Rea const collapsed = emptyExpansionCollapse(operand, nonEmptyNames); const next = new Set(); for (const current of frame.cwds) { - // A chain of RELATIVE `cd`s grows the tracked path by a component each time, and - // resolving an ever-longer path n times is quadratic: 70k `cd d` took 19.6s - // against the 20s timeout, and a timed-out hook fails open. No real directory can - // exceed PATH_MAX, so declining to grow past it is correct rather than a mere cap. + // Only operands that can GROW the path are capped. // - // An ABSOLUTE cd is always applied: it replaces the path rather than extending it, - // so it is cheap, and skipping it was itself a fail-open - a `cd /` after the flood - // left the guard pointing at the long path and `rm -rf *` was allowed. - if (!isAbsolute(expandHome(operand)) && current.length > MAX_TRACKED_CWD_LENGTH) { + // `..` and `.` shrink or hold, and skipping them froze the model permanently: after + // one crossing, `cd d0 … cd d1999; cd ..x2100` left the guard on the long path while + // bash had walked back to `/`, so `rm -rf *` was allowed. That was a fail-open + // introduced by the cap itself - the seventh time a bound in this file produced one. + // + // An absolute operand replaces the path, but resolving a 4KB operand 70k times still + // took 24s against the 20s timeout, so its own length is capped too. No real + // directory exceeds PATH_MAX, which is why this is a correctness bound and not just + // a throttle. + const expanded = expandHome(operand); + const shrinksOnly = /^[./]+$/.test(expanded); + const wouldGrow = !shrinksOnly && !isAbsolute(expanded); + if (wouldGrow && current.length > MAX_TRACKED_CWD_LENGTH) { + next.add(current); + continue; + } + if (expanded.length > MAX_TRACKED_CWD_LENGTH) { next.add(current); continue; } @@ -2452,6 +2522,19 @@ export async function classifyDangerousOperation(input: CodewithHookInput): Prom if (input.tool_name === "Bash") { const command = getCommand(input); + + // A command large enough that merely tokenizing it blows the hook's 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. Refuse it + // when it carries a recursive delete, rather than letting the timeout decide. + if (command.length > MAX_ANALYSABLE_COMMAND_LENGTH) { + // Decided here either way. Falling through to the full scan for a command with no + // delete in it still spent 46s tokenizing, which stalls every Bash call behind the + // hook's timeout for no benefit. + const reason = truncatedAnalysisBlockReason(command); + return reason ? { block: true, operation: "oversized command", reason } : { block: false }; + } + if (shellCommandLayers(command).truncated) { const reason = truncatedAnalysisBlockReason(command); if (reason) { From cc24af0858433c6c96092e55ce570e8824d5982f Mon Sep 17 00:00:00 2001 From: andreihasna Date: Mon, 27 Jul 2026 09:30:23 +0300 Subject: [PATCH 14/14] fix(pre-bash): give every bound ONE fail-closed answer instead of its own fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 -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. --- hooks/codewith-native-common.test.ts | 73 ++++++++++++++++++++++++++++ hooks/codewith-native-common.ts | 68 +++++++++++++++++++++----- 2 files changed, 129 insertions(+), 12 deletions(-) diff --git a/hooks/codewith-native-common.test.ts b/hooks/codewith-native-common.test.ts index 7291f3e..dd5abac 100644 --- a/hooks/codewith-native-common.test.ts +++ b/hooks/codewith-native-common.test.ts @@ -1781,6 +1781,79 @@ describe("destructive shell guard - rm -rf /* incident regression", () => { expect(performance.now() - started).toBeLessThan(15000); }); + // ------------------------------------------------------------------------------------- + // Adversarial review round 11. All three bounds added the round before turned into root + // wipes, each with its own improvised fallback. They now share ONE fail-closed answer: + // a bound that stops modelling the command marks the analysis degraded, and a degraded + // analysis carrying a recursive delete is refused. + // ------------------------------------------------------------------------------------- + + test("a cd operand too long to model fails closed", async () => { + // The operand-length cap dropped the cd entirely, so a single `cd` of 4200 slashes + // followed by `rm -rf *` was allowed - bash lands at `/`, where `*` is 27 entries. + // cwd is deliberately an ORDINARY directory, not a repo root: if the over-long cd is + // skipped the guard stays here, and here is not protected, so the verdict depends only on + // the cap failing closed. A repo-root cwd blocked either way and proved nothing. + const plain = mkdtempSync(join(fixtureRoot, "longcd-")); + for (const command of [ + `cd ${"/".repeat(4200)}; rm -rf *`, + `cd ${"../".repeat(1400)}; rm -rf *`, + `cd /${"./".repeat(2100)}; rm -rf *`, + `cd ${"../".repeat(1400)}; rm -rf .*`, + ]) { + await expectBlocked(command, { cwd: plain }); + } + }); + + test("an exhausted cd budget fails closed for named targets too", async () => { + // Adding `/` to the candidate set only caught targets that resolve to a protected root + // FROM `/`. A named relative target resolving against the real, unmodelled cwd is the + // same hole: this one destroys the entire Hasna home. + const dots = Array.from({ length: 2000 }, () => "cd .").join("; "); + await expectBlocked(`cd /home/hasna; ${dots}; cd ..; rm -rf hasna`); + }); + + test("an oversized command recognises a recursive delete in any flag position", async () => { + // The gate was anchored to the token right after `rm`, so seven ordinary spellings sailed + // past unanalysed once padded beyond the threshold. + // Must exceed MAX_ANALYSABLE_COMMAND_LENGTH, or the command is analysed normally and the + // oversized gate - the thing under test - is never consulted. + const pad = `; # ${"x".repeat(33_000_000)}`; + for (const command of [ + "rm -f -r /*", + "rm -f -R /*", + "rm -v -f -r /*", + "rm -f --recursive /*", + "/bin/rm -f -r /*", + "rm --one-file-system -rf /*", + "rm /home/hasna/.hasna -rf", + ]) { + await expectBlocked(`${command}${pad}`); + } + // ...and does not fire on a non-recursive rm, which is what `--force` used to trip. + await expectAllowed(`rm --force /tmp/x${pad}`); + }); + + test("a pipeline in a compound head does not swallow its opener", async () => { + // The isolation early-return ran BEFORE keyword accounting, so `if … | …; then` lost the + // `if` while its `fi` still decremented, and the assignment after it certified. This is + // the realized incident shape. + await expectBlocked('if ls /opt | grep -q node; then echo yes; CACHE=/var/cache/app; fi; rm -rf "$CACHE"/*'); + await expectBlocked('echo hi | while read -r l; do echo $l; X=/tmp/build; done; rm -rf "$X"/*'); + }); + + test("a cd reached only through && or || does not move the guard", async () => { + // `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. + for (const command of [ + "cd /home/hasna; false && cd /tmp; rm -rf *", + "cd /home/hasna; true || cd /tmp; rm -rf *", + "cd /etc; false && cd /tmp; rm -rf *", + ]) { + await expectBlocked(command); + } + }); + test("a quoted paren inside a substitution does not disable the collapse rule", async () => { // The unterminated-substitution fallback turned an ordinary awk field separator into a // bypass, because the quoted "(" was counted as structure. diff --git a/hooks/codewith-native-common.ts b/hooks/codewith-native-common.ts index b29fab9..a23eeab 100644 --- a/hooks/codewith-native-common.ts +++ b/hooks/codewith-native-common.ts @@ -1344,8 +1344,6 @@ function assignmentWalker(command: string): { at: (segmentIndex: number) => Read function applySegment({ text, depth, isolated, shortCircuit }: ShellSegment): void { - // Only assignments in the parent shell, in their own right, change it. - if (depth > 0 || isolated) return; // `{ X=; }`, `then X=`, `do X=` - strip the compound-command keyword so the assignment // inside is seen. cwdTrackedSegments already did this; this scan did not, so @@ -1393,6 +1391,12 @@ function assignmentWalker(command: string): { at: (segmentIndex: number) => Read } if (shortCircuit && braceDepth > 0 && conditionalBraceDepth === 0) conditionalBraceDepth = braceDepth; + // Keyword accounting happened above, deliberately BEFORE this return: `if ls /opt | grep + // -q node; then …; CACHE=…; fi` marks the `if` segment isolated (it is followed by `|`), + // so returning first swallowed the opener while its `fi` still decremented - and the + // assignment after it certified. That is the realized incident shape. + if (depth > 0 || isolated) return; + const conditional = conditionalDepth > 0 || shortCircuit || conditionalBraceDepth > 0 @@ -2169,7 +2173,10 @@ function shellCommandLayers(command: string): { layers: ShellCommandLayer[]; tru } // Verbs whose presence makes an unanalysable command unsafe to wave through. -const DESTRUCTIVE_VERB = /(?:^|[^\w.-])(?:[\w/.-]*\/)?(?:rm\s+(?:-\S*[rR]|--recursive|--dir)|rsync\s[^;&|]*--delete|find\s[^;&|]*(?:-delete|-execdir?\s)|git\s[^;&|]*(?:clean\s+-\S*[fd]|reset\s+--hard))/; +// `rm` followed ANYWHERE by a recursive flag. Anchoring it to the very next token missed +// `rm -f -r /*`, `rm -v -f -r /*`, `rm --one-file-system -rf /*` and `rm -rf`, each of +// which sailed past the oversized-command gate unanalysed. +const DESTRUCTIVE_VERB = /(?:^|[^\w.-])(?:[\w/.-]*\/)?(?:rm\b[^;&|\n]*?(?:\s-[A-Za-z]*[rR][A-Za-z]*(?=[\s=;&|]|$)|\s--recursive\b|\s--dir\b)|rsync\s[^;&|]*--delete|find\s[^;&|]*(?:-delete|-execdir?\s)|git\s[^;&|]*(?:clean\s+-\S*[fd]|reset\s+--hard))/; /** * A command too deeply wrapped or too wide to analyse within the caps is refused when it @@ -2219,7 +2226,22 @@ const MAX_TRACKED_CWD_LENGTH = 4096; const MAX_CD_OPERATIONS = 2000; // Far above any command a person or agent writes; below the size where tokenizing alone // exceeds the hook's 20s budget. -const MAX_ANALYSABLE_COMMAND_LENGTH = 1_000_000; +// Measured on this file's own paths: 1 MB -> 264ms, 16 MB -> 3.5s, 64 MB -> 14.3s against a +// 20s budget. The previous 1 MB threshold bought nothing and cost the fail-closed property. +const MAX_ANALYSABLE_COMMAND_LENGTH = 32_000_000; + +/** + * Raised whenever the guard stops being able to model the command exactly. + * + * Every bound in this file must funnel through here. Three bounds added in one round each + * invented their own fallback - skip the operand, keep the last directory, add `/` to the + * candidate set - and all three turned into root wipes, because "I cannot model this" was + * quietly answered as "so carry on". A degraded analysis carrying a recursive delete is + * refused instead. + */ +interface AnalysisState { + degraded: boolean; +} /** * Segments of one layer paired with the working directories in effect when they run. @@ -2229,7 +2251,7 @@ const MAX_ANALYSABLE_COMMAND_LENGTH = 1_000_000; * collapsed variant covers `cd "$(cmd)"/ && rm -rf ./*`, which is the incident's shape moved * one command to the left. */ -function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: ReadonlySet): CommandChunk[] { +function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: ReadonlySet, analysis: AnalysisState): CommandChunk[] { const chunks: CommandChunk[] = []; const home = process.env.HOME || homedir(); // One entry per subshell nesting depth. A `cd` inside `( … )` DOES apply to the rest of @@ -2259,7 +2281,7 @@ function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: Rea return stack[depth]; }; - splitShellSegmentsDetailed(command).forEach(({ text: segment, depth, group, piped }, segmentIndex) => { + splitShellSegmentsDetailed(command).forEach(({ text: segment, depth, group, piped, shortCircuit }, segmentIndex) => { const frame = frameFor(depth, group); // A leading `{` from a brace group is not part of the command. const tokens = shellWords(segment).filter((token, index) => !(index === 0 && (token === "{" || token === "}"))); @@ -2280,8 +2302,14 @@ function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: Rea } if (verb === "cd" || verb === "pushd") { - // A `cd` in a pipeline stage runs in its own process and moves nothing else. + // A `cd` in a pipeline stage runs in its own process and moves nothing else. One + // reached via `&&`/`||` may not run at all: `cd /home/hasna; false && cd /tmp; + // rm -rf *` left the guard in /tmp while bash stayed in the home directory. if (piped) return; + if (shortCircuit) { + analysis.degraded = true; + return; + } // `pushd -n` records the directory WITHOUT moving the shell, so the tracked cwd must // not follow it. Previously `-n` was read as the directory operand. if (verb === "pushd" && tokens.includes("-n")) return; @@ -2293,6 +2321,10 @@ function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: Rea const operand = tokens[i]; const priorCwds = frame.cwds; cdOperations += 1; + // Both cd bounds below mark the analysis degraded rather than inventing a fallback. + // Skipping an over-long operand allowed `cd ////…(4200); rm -rf *`, and adding `/` to + // the candidate set caught only sweep targets - `cd /home/hasna; cd .x2000; cd ..; + // rm -rf hasna` still destroyed the Hasna home. // Once the budget is spent the guard can no longer model a chain of RELATIVE cds. It // must not simply keep the last known directory - that was the fail-open the PATH_MAX // cap produced - so `/` joins the candidate set and any relative delete is judged @@ -2302,8 +2334,7 @@ function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: Rea // An ABSOLUTE cd is never dropped: it is a real landing the guard can still model // exactly, and skipping it lost `cd ~` after a flood, which allowed `rm -rf .hasna`. if (cdOperations > MAX_CD_OPERATIONS && !isAbsolute(expandHome(operand ?? ""))) { - if (!frame.cwds.includes(sep)) frame.cwds = [...frame.cwds, sep].slice(0, MAX_CWD_VARIANTS); - frame.explicit = true; + analysis.degraded = true; return; } @@ -2332,10 +2363,12 @@ function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: Rea const shrinksOnly = /^[./]+$/.test(expanded); const wouldGrow = !shrinksOnly && !isAbsolute(expanded); if (wouldGrow && current.length > MAX_TRACKED_CWD_LENGTH) { + analysis.degraded = true; next.add(current); continue; } if (expanded.length > MAX_TRACKED_CWD_LENGTH) { + analysis.degraded = true; next.add(current); continue; } @@ -2380,14 +2413,14 @@ function loopBoundWords(path: string, bindings: Map): string[] return bindings.get(match[1]) ?? null; } -function destructiveShellTargets(command: string, cwd: string): DestructiveShellTarget[] { +function destructiveShellTargets(command: string, cwd: string, analysis: AnalysisState): DestructiveShellTarget[] { const targets: DestructiveShellTarget[] = []; for (const layer of shellCommandLayers(command).layers) { const bindings = forLoopBindings(layer.command); const assignments = assignmentWalker(layer.command); - for (const chunk of cwdTrackedSegments(layer.command, cwd, new Set())) { + for (const chunk of cwdTrackedSegments(layer.command, cwd, new Set(), analysis)) { const raw = [ ...rmCommandTargets(chunk.segment), ...rsyncDeleteTargets(chunk.segment), @@ -2522,6 +2555,7 @@ export async function classifyDangerousOperation(input: CodewithHookInput): Prom if (input.tool_name === "Bash") { const command = getCommand(input); + const analysis: AnalysisState = { degraded: false }; // A command large enough that merely tokenizing it blows the hook's 20s budget cannot be // analysed at all, and a timed-out hook fails open. 70k repetitions of `cd /<4KB>` is a @@ -2542,7 +2576,7 @@ export async function classifyDangerousOperation(input: CodewithHookInput): Prom } } - for (const target of destructiveShellTargets(command, cwd)) { + for (const target of destructiveShellTargets(command, cwd, analysis)) { const targetCwd = target.baseCwd ?? cwd; const targetPath = resolveFrom(targetCwd, target.path); const rulesFor = (path: string) => { @@ -2581,6 +2615,16 @@ export async function classifyDangerousOperation(input: CodewithHookInput): Prom } } } + + // Raised during the scan above by any bound that stopped modelling the command + // exactly. Checked here rather than at each bound so there is ONE fail-closed answer: + // three bounds that each invented their own fallback all became root wipes. + if (analysis.degraded) { + const reason = truncatedAnalysisBlockReason(command); + if (reason) { + return { block: true, operation: "unanalysable command", reason }; + } + } } const managedRepoRootCache = new Map>();