From 8fb47755035b9192397afee8a4b5e31e11b00d65 Mon Sep 17 00:00:00 2001 From: POWERFULMOVES <142271328+POWERFULMOVES@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:43:16 -0400 Subject: [PATCH 1/2] feat(damage-control): allowlist deletion of orphaned git lockfiles, keep package locks protected The *.lock readOnlyPath rightly blocks editing/deleting package locks, but it also blocked clearing git's OWN internal lockfiles (.git/config.lock, .git/index.lock). Those orphan as 0-byte sentinels when an interrupted config/index write dies before the atomic rename (killed cmd, closed session, OneDrive/AV holding the handle); while present they block every subsequent git config/index op (branch -D, push -u, worktree add, add/commit) and git cannot auto-clear them (can't tell a stale lock from a live one). The guard then forced agents to defer to the operator for a routine, safe cleanup. Add a new bashDeleteAllowlist (data + reason in patterns.yaml) checked AFTER the destructive-pattern block (recursive/force deletes still blocked) and AFTER zeroAccessPaths (never bypassed), BEFORE read-only/no-delete. The single entry is anchored to the WHOLE command, so only a bare delete of git's own lockfiles passes - package locks (yarn.lock/uv.lock/...), flagged deletes, and chained commands all stay blocked. Verified by test_gitlock_allowlist.py (10 cases: 4 allow, 6 block) + existing test_insight_edits.py still 10/10. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bash-tool-damage-control.py | 17 +++++++ .claude/hooks/damage-control/patterns.yaml | 24 ++++++++++ .../damage-control/test_gitlock_allowlist.py | 48 +++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 .claude/hooks/damage-control/test_gitlock_allowlist.py 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..26769d0e3e 100644 --- a/.claude/hooks/damage-control/patterns.yaml +++ b/.claude/hooks/damage-control/patterns.yaml @@ -1029,6 +1029,30 @@ 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. + - pattern: '^\s*rm\s+((\./)?\.git/(config|index)\.lock\s*)+$' + 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..cfaee64919 --- /dev/null +++ b/.claude/hooks/damage-control/test_gitlock_allowlist.py @@ -0,0 +1,48 @@ +"""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 +] + +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") From 1541c02490cc728ae53abf8e581ec27aaf85ae21 Mon Sep 17 00:00:00 2001 From: POWERFULMOVES <142271328+POWERFULMOVES@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:07:10 -0400 Subject: [PATCH 2/2] fix(damage-control): reject newline separators in git-lockfile allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2: the allowlist regex used \s* between operands, and \s matches a newline. A newline terminates a command in Bash, so ` .git/config.lock\n .git/index.lock` matched the pattern but Bash would execute the second line as its own command — breaking the "no chaining" guarantee. Restrict all separators to [ \t] (spaces/tabs) so any newline falls through to the read-only block. Added two newline-injection cases to test_gitlock_allowlist.py (now 12 cases, all pass): newline-as-separator and newline-then-command both blocked. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/hooks/damage-control/patterns.yaml | 6 ++++-- .claude/hooks/damage-control/test_gitlock_allowlist.py | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.claude/hooks/damage-control/patterns.yaml b/.claude/hooks/damage-control/patterns.yaml index 26769d0e3e..a03bee3b3d 100644 --- a/.claude/hooks/damage-control/patterns.yaml +++ b/.claude/hooks/damage-control/patterns.yaml @@ -1049,8 +1049,10 @@ bashDeleteAllowlist: # 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. - - pattern: '^\s*rm\s+((\./)?\.git/(config|index)\.lock\s*)+$' + # 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" # --------------------------------------------------------------------------- diff --git a/.claude/hooks/damage-control/test_gitlock_allowlist.py b/.claude/hooks/damage-control/test_gitlock_allowlist.py index cfaee64919..8096766afb 100644 --- a/.claude/hooks/damage-control/test_gitlock_allowlist.py +++ b/.claude/hooks/damage-control/test_gitlock_allowlist.py @@ -31,6 +31,8 @@ (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 = []