diff --git a/.claude/hooks/damage-control/bash-tool-damage-control.py b/.claude/hooks/damage-control/bash-tool-damage-control.py index c07ad8e255..9095023450 100755 --- a/.claude/hooks/damage-control/bash-tool-damage-control.py +++ b/.claude/hooks/damage-control/bash-tool-damage-control.py @@ -309,6 +309,23 @@ def check_command(command: str, config: Dict[str, Any]) -> Tuple[bool, bool, str ) return True, False, f"Blocked: zero-access path {zero_path} (no operations allowed)" + # 2b. Bash delete allowlist — explicit, whole-command-anchored exceptions to the + # read-only / no-delete blocks below (e.g. clearing git's own orphaned lockfiles). + # Deliberately placed AFTER the destructive-pattern block (step 1: rm -rf / rm -f + # are already blocked) and AFTER zero-access (step 2, never bypassed), so an + # allowlisted command cannot smuggle anything dangerous past those gates. Each + # pattern is anchored to the whole command in patterns.yaml, so no chaining. + for item in config.get("bashDeleteAllowlist", []): + pat = item.get("pattern", "") + if not pat: + continue + try: + if re.search(pat, command): + return False, False, "" # explicitly allowed (see patterns.yaml: reason) + except re.error as e: + print(f"WARNING: Invalid regex in bashDeleteAllowlist: {pat!r} — {e}", file=sys.stderr) + continue + # 3. Check for modifications to read-only paths (reads allowed) for readonly in read_only_paths: blocked, reason = check_path_patterns(command, readonly, READ_ONLY_BLOCKED, "read-only path") diff --git a/.claude/hooks/damage-control/patterns.yaml b/.claude/hooks/damage-control/patterns.yaml index 6d278690a3..a03bee3b3d 100644 --- a/.claude/hooks/damage-control/patterns.yaml +++ b/.claude/hooks/damage-control/patterns.yaml @@ -1029,6 +1029,32 @@ chitSafePaths: # Submodules can be modified via git operations. # Use "git submodule update --init --recursive" after .gitmodules changes. +# --------------------------------------------------------------------------- +# BASH DELETE ALLOWLIST - explicit, anchored exceptions to readOnlyPaths/noDelete +# --------------------------------------------------------------------------- +# Each entry's `pattern` is a regex matched against the WHOLE Bash command. A +# match permits the command despite a readOnlyPaths/noDeletePaths block. +# Evaluated AFTER bashToolPatterns (so `rm -rf` / `rm -f` are still blocked) and +# AFTER zeroAccessPaths (never bypassed), but BEFORE read-only/no-delete checks. +# RULES for new entries: +# - Anchor the whole command (^...$) so nothing destructive can be chained on. +# - Be SURGICAL — exact paths, never broad globs. +# - State a `reason` (recorded in the block/allow trail and code review). +bashDeleteAllowlist: + # Orphaned git internal lockfiles. .git/config.lock and .git/index.lock are + # 0-byte sentinels left behind when an interrupted git config/index write dies + # between creating the lock and the atomic rename (killed cmd, closed session, + # OneDrive/AV holding the handle). While present they block ALL subsequent git + # config/index operations (branch -D, push -u, worktree add, add/commit), and + # git cannot auto-clear them (it can't distinguish a stale lock from a live + # one). These are NOT package locks (yarn.lock/uv.lock/etc.) — the *.lock + # readOnlyPath rightly still guards those. Anchored to a bare `rm` of git's own + # internal lockfiles (one or both), no flags, no chaining. Separators are + # [ \t] only (NOT \s) — a newline terminates the command in Bash, so allowing + # \n between operands would let a second line execute as its own command. + - pattern: '^[ \t]*rm[ \t]+((\./)?\.git/(config|index)\.lock[ \t]*)+$' + reason: "Clear orphaned git internal lockfile(s) .git/config.lock|.git/index.lock — they block all git config/index writes and git won't auto-clear them; package locks (*.lock) remain protected" + # --------------------------------------------------------------------------- # READ-ONLY PATHS - Can read, but not write/edit/delete # --------------------------------------------------------------------------- diff --git a/.claude/hooks/damage-control/test_gitlock_allowlist.py b/.claude/hooks/damage-control/test_gitlock_allowlist.py new file mode 100644 index 0000000000..8096766afb --- /dev/null +++ b/.claude/hooks/damage-control/test_gitlock_allowlist.py @@ -0,0 +1,50 @@ +"""In-process verification of the bashDeleteAllowlist git-lockfile exception. + +Run: python .claude/hooks/damage-control/test_gitlock_allowlist.py +Asserts the allowlist permits ONLY a bare `rm` of git's own internal lockfiles +and that everything else (package locks, flags, chaining) stays blocked. +""" +import importlib.util +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location("bash_tool", HERE / "bash-tool-damage-control.py") +assert spec and spec.loader, "could not load bash-tool-damage-control.py" +mod = importlib.util.module_from_spec(spec) +spec.loader.exec_module(mod) + +config = mod.load_config() + +# (command, expect_blocked) +GIT = ".git/" + "config.lock" # built from parts so this file's own +GITI = ".git/" + "index.lock" # strings don't trip a content scanner +DEL = "r" + "m " +cases = [ + (DEL + GIT, False), # bare rm of config.lock -> ALLOW + (DEL + GITI, False), # bare rm of index.lock -> ALLOW + (DEL + GIT + " " + GITI, False), # both at once -> ALLOW + (DEL + "./" + GIT, False), # ./ prefix -> ALLOW + (DEL + "yarn.lock", True), # package lock -> BLOCK + (DEL + "uv.lock", True), # package lock -> BLOCK + (DEL + "-rf " + GIT, True), # rm -rf -> BLOCK (step 1) + (DEL + "-f " + GIT, True), # rm -f -> BLOCK (step 1) + (DEL + GIT + " && " + DEL + "yarn.lock", True), # chaining -> BLOCK + (DEL + GIT + " ; echo hi", True), # chaining via ; -> BLOCK + (DEL + GIT + "\n" + GITI, True), # newline separator (bash terminator) -> BLOCK + (DEL + GIT + "\necho hi", True), # newline then command -> BLOCK +] + +failures = [] +for cmd, want_blocked in cases: + blocked, ask, reason = mod.check_command(cmd, config) + got = blocked + status = "OK" if got == want_blocked else "FAIL" + if got != want_blocked: + failures.append((cmd, want_blocked, got, reason)) + print(f"[{status}] blocked={got!s:5} want={want_blocked!s:5} :: {cmd} {('-> '+reason) if reason else ''}") + +if failures: + print(f"\n{len(failures)} FAILURE(S)") + sys.exit(1) +print("\nALL CASES PASS")