Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .claude/hooks/damage-control/bash-tool-damage-control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
26 changes: 26 additions & 0 deletions .claude/hooks/damage-control/patterns.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
50 changes: 50 additions & 0 deletions .claude/hooks/damage-control/test_gitlock_allowlist.py
Original file line number Diff line number Diff line change
@@ -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")
Loading