From 8b4f16c1efb8ebe59f39fe70f28a33d546923e84 Mon Sep 17 00:00:00 2001 From: pedrohcgs Date: Fri, 6 Nov 2026 23:51:04 -0500 Subject: [PATCH 1/2] v2.9.1: native deny rules and one small dirty-tree hook replace the per-command guards The owner found the per-command hooks slow and glitchy; the measured cost was not latency (50-90 ms each, mostly Python start-up) but refusals, each costing a model turn and a retry. Removed: git-guardrails.py and root-of-trust-guard.py (PreToolUse on every Bash call, with onFailure: block) and context-monitor.py (PostToolUse on every Bash call). Their files stay at tag v2.9.0. Added: - permissions.deny in .claude/settings.json (applied by the owner): git reset --hard, git clean, push --force / -f (not --force-with-lease), add -A / --all / ., checkout -- . / checkout . / restore ., plus .env, *.pem, *.key and credentials* reads. Verified live in a scratch repo: every rule refused its command, including (git reset --hard) and $(git reset --hard), which were open as #172; every control ran. - .claude/hooks/dirty-tree-guard.py: started only through if filters for git merge / pull / rebase; refuses while tracked files have uncommitted changes; untracked files, resume steps and ALLOW_DIRTY_MERGE=1 pass; fails open except when its own file is missing. Battery section j (16 cases); six mutants each fail their own cases. Battery 322 -> 110 cases, grading-the-grader re-measured for the remaining decision hooks. Portability 245 -> 130 (test_rot.py and the removed hooks' cases gone; the Windows code-page cases moved to dirty-tree-guard). The ledger moves the removed hooks' rows and notes, unedited, to "Retired at v2.9.1", with their counts reading "at the time". The status line keeps only Claude Code's own context percentage. README, guide (re-rendered, restamped), TROUBLESHOOTING, docs/index.html, AGENTS.md, SECURITY.md, confidential-data.md (CLAUDE_STRICT_PATHS is gone) and four skills updated. CHANGELOG v2.9.1 leads with the upgrade steps for forks that changed their settings.json; guide and CITATION at 2.9.1. --- .claude/hooks/context-monitor.py | 237 -- .claude/hooks/dirty-tree-guard.py | 126 + .claude/hooks/git-guardrails.py | 1783 ------------ .claude/hooks/root-of-trust-guard.py | 2186 --------------- .claude/references/research-agent-laws.md | 12 +- .claude/references/scheduled-routines.md | 2 +- .claude/rules/confidential-data.md | 4 +- .claude/scripts/statusline.sh | 19 +- .claude/settings.json | 67 +- .claude/skills/commit/SKILL.md | 25 +- .claude/skills/context-status/SKILL.md | 17 +- .../references/repo-infrastructure-audit.md | 2 +- .claude/skills/diagnose/SKILL.md | 2 +- .github/SECURITY.md | 2 +- .render-stamp | 4 +- AGENTS.md | 12 +- CHANGELOG.md | 56 + CITATION.cff | 2 +- MEMORY.md | 2 +- README.md | 12 +- TROUBLESHOOTING.md | 18 +- docs/index.html | 8 +- docs/workflow-guide.html | 217 +- guide/workflow-guide.html | 217 +- guide/workflow-guide.qmd | 149 +- quality_reports/qualification/LEDGER.md | 368 +-- scripts/check-derived-counts.py | 16 +- scripts/check-repo-hygiene.py | 2 +- scripts/hook-battery.sh | 2450 +---------------- scripts/validate-setup.sh | 2 +- tests/portability/test_gates.py | 19 +- tests/portability/test_harness.py | 9 +- tests/portability/test_hooks.py | 228 +- tests/portability/test_rot.py | 446 --- 34 files changed, 895 insertions(+), 7826 deletions(-) delete mode 100755 .claude/hooks/context-monitor.py create mode 100644 .claude/hooks/dirty-tree-guard.py delete mode 100644 .claude/hooks/git-guardrails.py delete mode 100644 .claude/hooks/root-of-trust-guard.py delete mode 100644 tests/portability/test_rot.py diff --git a/.claude/hooks/context-monitor.py b/.claude/hooks/context-monitor.py deleted file mode 100755 index 826255337..000000000 --- a/.claude/hooks/context-monitor.py +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env python3 -""" -Context Usage Monitor Hook - -Monitors approximate context usage and provides progressive, de-duplicated nudges: -- At 40%, 55%, 65%: suggest /learn for skill extraction -- At 80%: info-level note (auto-compact approaching) -- At 90%: caution-level note (finish the current task at full quality) - -Hook Event: PostToolUse (on common tools). Throttled to 60-second intervals -when below the warning threshold. - -Output contract (PostToolUse, exit 0): emits JSON on stdout with a `systemMessage` -(shown to the user) AND `hookSpecificOutput.additionalContext` (injected into -Claude's context). Plain stdout would reach Claude but NOT the user, and would -carry literal ANSI escape codes as noise β€” so we emit clean structured JSON. -See https://code.claude.com/docs/en/hooks. - -Context %% is a COARSE PROXY. When the hook receives a `transcript_path`, we -estimate tokens from the transcript size against CLAUDE_CONTEXT_WINDOW_TOKENS -(default 1,000,000 β€” the 1M-context default on recent Opus/Sonnet tiers; see model-versions.md). Otherwise we -fall back to a tool-call counter (CLAUDE_CONTEXT_MAX_TOOL_CALLS, default 400). -Neither is exact; treat the percentage as a rough early-warning signal, not a -precise gauge. -""" - -from __future__ import annotations - -import json -import os -import sys -import time -from pathlib import Path - -# Thresholds (effective percentage, where 100% ~ auto-compact) -LEARN_THRESHOLDS = [40, 55, 65] -THRESHOLD_WARN = 80 -THRESHOLD_CRITICAL = 90 - -# Throttle interval in seconds (skip checks if below threshold and recent check) -THROTTLE_INTERVAL = 60 - -# Calibration defaults (both overridable via env) -DEFAULT_CONTEXT_WINDOW_TOKENS = 1_000_000 # 1M default window on recent Opus/Sonnet tiers -DEFAULT_MAX_TOOL_CALLS = 400 # fallback proxy when transcript size is unavailable -APPROX_BYTES_PER_TOKEN = 4.0 - - -def _env_int(name: str, default: int) -> int: - """Read a positive int from the environment, falling back to `default`.""" - try: - value = int(os.environ.get(name, "") or default) - return value if value > 0 else default - except ValueError: - return default - - -def get_session_dir() -> Path: - """Get the session directory for storing cache files.""" - project_dir = os.environ.get("CLAUDE_PROJECT_DIR", "") - if not project_dir: - return Path.home() / ".claude" / "sessions" / "default" - - import hashlib - project_hash = hashlib.md5(project_dir.encode()).hexdigest()[:8] - session_dir = Path.home() / ".claude" / "sessions" / project_hash - session_dir.mkdir(parents=True, exist_ok=True) - return session_dir - - -def read_cache() -> dict: - """Read the context monitor cache.""" - cache_file = get_session_dir() / "context-monitor-cache.json" - if not cache_file.exists(): - return {} - try: - return json.loads(cache_file.read_text(encoding="utf-8")) - except (ValueError, OSError): - return {} - - -def save_cache(data: dict) -> None: - """Save the context monitor cache.""" - cache_file = get_session_dir() / "context-monitor-cache.json" - try: - cache_file.write_text(json.dumps(data, indent=2), encoding="utf-8") - except IOError: - pass - - -def estimate_context_percentage(hook_input: dict) -> float: - """ - Estimate context usage as a percentage (0-100). COARSE PROXY. - - Preferred: a token estimate from the transcript file size against the model's - context window (CLAUDE_CONTEXT_WINDOW_TOKENS). Fallback when no transcript is - available: a tool-call counter (CLAUDE_CONTEXT_MAX_TOOL_CALLS). Neither is exact. - """ - window = _env_int("CLAUDE_CONTEXT_WINDOW_TOKENS", DEFAULT_CONTEXT_WINDOW_TOKENS) - - transcript_path = hook_input.get("transcript_path", "") - if transcript_path: - try: - size_bytes = os.path.getsize(transcript_path) - approx_tokens = size_bytes / APPROX_BYTES_PER_TOKEN - return min(approx_tokens / window * 100, 100) - except OSError: - pass - - # Fallback: tool-call counter (very rough) - cache = read_cache() - tool_calls = cache.get("tool_calls", 0) + 1 - cache["tool_calls"] = tool_calls - save_cache(cache) - max_calls = _env_int("CLAUDE_CONTEXT_MAX_TOOL_CALLS", DEFAULT_MAX_TOOL_CALLS) - return min((tool_calls / max_calls) * 100, 100) - - -def is_throttled(percentage: float) -> bool: - """Check if we should skip this check due to throttling.""" - cache = read_cache() - last_check = cache.get("last_check_time", 0) - now = time.time() - - # If below warning threshold and checked recently, skip - if percentage < THRESHOLD_WARN and (now - last_check) < THROTTLE_INTERVAL: - return True - - # Update last check time - cache["last_check_time"] = now - save_cache(cache) - return False - - -def get_shown_thresholds() -> dict: - """Get which thresholds have already been shown in this session.""" - cache = read_cache() - return { - "learn": cache.get("shown_learn", []), - "warn_80": cache.get("shown_warn_80", False), - "warn_90": cache.get("shown_warn_90", False) - } - - -def mark_threshold_shown(threshold_type: str, value: int | bool = True) -> None: - """Mark a threshold as shown.""" - cache = read_cache() - if threshold_type == "learn": - shown = cache.get("shown_learn", []) - if value not in shown: - shown.append(value) - cache["shown_learn"] = shown - else: - cache[f"shown_{threshold_type}"] = value - save_cache(cache) - - -def emit(system_message: str, claude_context: str) -> None: - """Surface a note to BOTH the user and Claude via the PostToolUse JSON contract.""" - print(json.dumps({ - "systemMessage": system_message, - "hookSpecificOutput": { - "hookEventName": "PostToolUse", - "additionalContext": claude_context, - }, - })) - - -def run_context_monitor() -> int: - """Main monitoring logic.""" - # Read hook input β€” bytes, decoded as UTF-8, which is what Claude Code - # writes. sys.stdin on Windows decodes a pipe with the ANSI code page: a - # transcript under C:\Users\JosΓ©\ came out as mojibake, getsize() failed, - # and the estimate fell back to the tool-call counter (a full transcript - # read as 0%). - try: - hook_input = json.loads(sys.stdin.buffer.read().decode("utf-8", "replace")) - except (ValueError, OSError): - hook_input = {} - - # Estimate current context usage (coarse proxy) - percentage = estimate_context_percentage(hook_input) - - # Persist the latest estimate so the status line can surface it (best-effort). - try: - (get_session_dir() / "context-pct.txt").write_text(f"{percentage:.0f}", encoding="utf-8") - except Exception: - pass - - # Check throttling - if is_throttled(percentage): - return 0 - - shown = get_shown_thresholds() - - # Check /learn thresholds (40%, 55%, 65%) - for threshold in LEARN_THRESHOLDS: - if percentage >= threshold and threshold not in shown["learn"]: - emit( - f"πŸ’‘ Context ~{percentage:.0f}% (approx) β€” if a reusable discovery emerged, consider /learn before auto-compaction.", - f"Context usage is approximately {percentage:.0f}% (coarse proxy). If a non-obvious discovery or reusable workflow emerged this session, suggest that the user run /learn to persist it as a skill before auto-compaction (/learn is user-invoked; you cannot run it yourself).", - ) - mark_threshold_shown("learn", threshold) - return 0 # Only show one message at a time - - # Check 90% threshold (critical) - if percentage >= THRESHOLD_CRITICAL and not shown["warn_90"]: - emit( - f"⚠️ Context ~{percentage:.0f}% (approx) β€” auto-compact approaching. Finish the current task at full quality.", - f"Context ~{percentage:.0f}% (coarse proxy); auto-compaction is approaching. Complete the current task without cutting corners or skipping verification, and make sure the session log and active plan are saved to disk β€” no context is lost, but summarize key decisions now.", - ) - mark_threshold_shown("warn_90", True) - return 0 # Non-blocking note (exit 2 would feed stderr to Claude) - - # Check 80% threshold (info) - if percentage >= THRESHOLD_WARN and not shown["warn_80"]: - emit( - f"πŸ’‘ Context ~{percentage:.0f}% (approx) β€” auto-compact approaching; no rush.", - f"Context ~{percentage:.0f}% (coarse proxy); auto-compaction will trigger soon. Ensure the session log and active plan are current on disk.", - ) - mark_threshold_shown("warn_80", True) - return 0 - - return 0 - - -def main() -> int: - """Main entry point.""" - return run_context_monitor() - - -if __name__ == "__main__": - try: - sys.exit(main()) - except Exception: - # Fail open β€” never block Claude due to a hook bug - sys.exit(0) diff --git a/.claude/hooks/dirty-tree-guard.py b/.claude/hooks/dirty-tree-guard.py new file mode 100644 index 000000000..e73840101 --- /dev/null +++ b/.claude/hooks/dirty-tree-guard.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +""" +Dirty-Tree Guard (PreToolUse, Bash; registered three times, with +`"if": "Bash(git merge *)"`, `"if": "Bash(git pull *)"` and +`"if": "Bash(git rebase *)"`) + +Refuses a merge, pull or rebase while TRACKED files have uncommitted changes, +so uncommitted work is not mixed into the result of a history operation. + +Cost: Claude Code starts this hook only when a subcommand matches one of those +patterns, including after `&&` and inside `$()`, so it does not run on other +commands. One exception, from the hooks docs: a pattern that names more than +the program also starts the hook on ANY command containing `$()`, backticks or +`$VAR`. So the hook first confirms that the command really runs `git merge`, +`git pull` or `git rebase`, and allows everything else without reading the tree. + +What it checks: `git status --porcelain --untracked-files=no` in the event's +`cwd`, with git's repository-scoping variables (GIT_DIR, GIT_INDEX_FILE, ...) +removed, so a parent git process cannot redirect the reading to another +repository. Untracked files are ignored: git itself refuses a merge that would +overwrite one. `--abort`, `--continue`, `--skip`, `--quit` and `--edit-todo` +are allowed, because they run mid-conflict, when the tree is dirty by +definition. + +Escape: ALLOW_DIRTY_MERGE=1 in the session environment. + +Fails open, by design. If the directory is not a repository, git is missing, +the reading times out, the event is malformed or this file has a bug, there is +no decision and the command runs. It is registered WITHOUT +`"onFailure": "block"`, so a missing `python3` lets the command run too. One +case does refuse: if THIS FILE is missing while `python3` is present, Python +exits 2 ("can't open file"), and Claude Code treats exit 2 from a PreToolUse +hook as a refusal. Then `git merge`, `git pull` and `git rebase` are refused +until the file is restored (`git checkout -- .claude/hooks`); nothing else is +affected, because the hook is started only for those three. It is a speed +bump against the ordinary accident, not a boundary. + +What it does not see: + - a repository reached by `cd elsewhere && git pull` or `git -C other merge` + (it reads the session's directory, so it may also refuse there when only + THIS repository is dirty; ALLOW_DIRTY_MERGE=1 covers that case); + - `bash -c '...'`, an alias, a script, or a Makefile target; + - anything the `if` filter misses, which Claude Code calls best-effort. + +Replaces the clean-tree rule of git-guardrails.py, removed in v2.9.1. That +file parsed the whole command line itself; its history is in git (tag v2.9.0). +""" +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys + +# `git`, then any global options, then the operation as a whole word. +# `merge-base` and `merge-file` are other commands, hence the lookahead. +OP = re.compile(r"\bgit(?:\s+(?:-[Cc]\s+\S+|--?[\w-]+(?:=\S+)?))*\s+(merge|pull|rebase)(?=\s|$|[;&|)`])") +# The arguments of that operation run to the next shell separator. +ARGS = re.compile(r"[^;&|)`\n]*") +RESUME = re.compile(r"(?:^|\s)--(?:abort|continue|skip|quit|edit-todo)(?=\s|$)") +STATUS = ("git", "status", "--porcelain", "--untracked-files=no") +TIMEOUT = 5 # strictly below the registered timeout (10 s) in settings.json + +REASON = ("Blocked by dirty-tree-guard: tracked files have uncommitted changes, so " + "this git {op} would mix them into its result. Commit them, or stash them " + "(`git stash push -m \"\"`), then run the {op} as its own " + "command. Changed: {files}. (Override: ALLOW_DIRTY_MERGE=1 in the session " + "environment.)") + + +def deny(reason: str) -> None: + json.dump({"hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + }}, sys.stdout) + + +def history_op(command: str) -> str | None: + """The first merge/pull/rebase in the command that is not a resume step, or None.""" + for m in OP.finditer(command): + args = ARGS.match(command, m.end()).group(0) + if not RESUME.search(args): + return m.group(1) + return None + + +def dirty_files(cwd: str) -> list[str]: + """Tracked files with uncommitted changes; empty when clean or when git cannot answer.""" + env = {k: v for k, v in os.environ.items() + if not k.startswith("GIT_") or k == "GIT_EXEC_PATH"} + try: + r = subprocess.run(STATUS, cwd=cwd, env=env, capture_output=True, timeout=TIMEOUT) + except (OSError, subprocess.SubprocessError): + return [] + if r.returncode != 0: + return [] + return [line[3:] for line in r.stdout.decode("utf-8", "replace").splitlines() if line.strip()] + + +def main() -> int: + if os.environ.get("ALLOW_DIRTY_MERGE", "") == "1": + return 0 + try: + data = json.loads(sys.stdin.buffer.read().decode("utf-8", "replace")) + except ValueError: + return 0 + if not isinstance(data, dict) or data.get("tool_name") != "Bash": + return 0 + command = ((data.get("tool_input") or {}).get("command") or "").replace("\r", "") + op = history_op(command) + if not op: + return 0 + files = dirty_files(str(data.get("cwd") or os.getcwd())) + if files: + shown = ", ".join(files[:5]) + (f" and {len(files) - 5} more" if len(files) > 5 else "") + deny(REASON.format(op=op, files=shown)) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception: + sys.exit(0) # fail open diff --git a/.claude/hooks/git-guardrails.py b/.claude/hooks/git-guardrails.py deleted file mode 100644 index 2e75a736a..000000000 --- a/.claude/hooks/git-guardrails.py +++ /dev/null @@ -1,1783 +0,0 @@ -#!/usr/bin/env python3 -""" -Git & Path Guardrails Hook (PreToolUse) - -Blocks the small set of operations that are destructive or that the -template's own conventions forbid β€” before they run, not after. Adapted -from the `git-guardrails` pattern in mattpocock/skills, scoped so the -normal workflow (`/commit` stages specific files, and pushes only with -`--pr`) still works. - -Two checks, by tool: - - Bash β€” deny destructive / convention-violating git: - - git reset --hard (silently discards work) - - git clean -f / -fd / -fdx (deletes UNTRACKED files β€” incl. data) - - git push --force / -f (clobbers remote history; --force-with-lease is allowed) - - git add -A / --all / git add . (the commit skill forbids blanket staging) - - git checkout -- . / git restore . (mass discard of working changes) - - HOW THE DENY LIST DECIDES, since r10: over the SAME tokenised, unquoted - segments the clean-tree check reads, not over the raw command string. The - command line is split into segments, each segment's leading shell syntax - is dropped, a word whose basename is `git` is found, git's GLOBAL options - are walked, and the rule for that SUBCOMMAND is applied to the remaining - ARGUMENT TOKENS β€” `--hard`, `--force`, `-A`, `.` β€” compared as whole - words. Until r10 these were regexes over the raw string, so quoting one - token defeated them: `git reset "--hard" HEAD~1`, `git 'reset' --hard - HEAD~1`, `git add '-A'`, `git add "--all"` and `git checkout -- '.'` were - all SILENT while the byte-equivalent bare spellings DENIED. Sharing the - tokenizer also means the two checks can no longer disagree about what a - command says, and it fixed a false deny in the other direction: a fully - QUOTED mention (`echo "never run git reset --hard here"`) writes nothing - and is now silent. An UNQUOTED mention still denies β€” see THE COST OF - FAILING TOWARD DENY below; that applies to both checks. - - - git merge / rebase / pull unless the invocation is a STANDALONE SIMPLE - COMMAND whose repository reads clean RIGHT NOW (a resolution that also - swallows uncommitted work is unreviewable afterwards). Stated that way on - purpose: the reading is taken at PreToolUse, so the only honest guarantee - is over a command that cannot itself dirty the tree before git starts β€” - see rule 0. Hatch: ALLOW_DIRTY_MERGE=1. - - WHAT THE RULE IS, SAID WITHOUT OVERCLAIM (r13). This heading used to read - "THE RULE IS A CLOSED ALLOWLIST OVER THE WHOLE COMMAND", while the same - file admitted forty lines below that `bash -c 'git merge main'` is not - inspected. An external referee (2026-08-23) called that what it is: a - best-effort RECOGNIZER wrapped around a closed decision, not a closed - allowlist over the whole command. The truthful statement is: - - For a history operation THIS PARSER IDENTIFIES, the allow paths are - limited to a fixed set of escape tokens, an `--autostash` on a tree - with no untracked files, and a clean live `git status` reading β€” and - the op must be a STANDALONE SIMPLE COMMAND. An invocation the parser - does NOT identify passes outside this rule entirely: it is not allowed - by the rule, it is INVISIBLE to it, and it runs. - - Rules 0-3 below are what happens to an op that IS identified. - - 0. STANDALONE OR NOTHING (r13 β€” the TOCTOU repair). This hook decides at - PreToolUse, BEFORE bash runs the command, so a tree reading taken now - says nothing about the tree the op will actually start from. The - referee's case, on a CLEAN repository: - - printf '\n# local work\n' >> analysis.R && git merge main - - reads clean at hook time and is dirty when git executes. Rounds 4-12 - read that asymmetry backwards: a chain that CLEANED the tree - (`git stash push -m x && git merge`) was DENIED, while a chain that - DIRTIED it was ALLOWED. The whole false-deny cost was being paid for - none of the safety. - - So an identified history op reaches the tree reading ONLY as a - STANDALONE SIMPLE COMMAND. If the command line carries anything else - β€” a second shell segment, a pipeline, a redirection, a command or - process substitution, a grouping, a `cd`, a variable assignment β€” the - op is DENIED, without reading the tree. Over the text the parser - sees, this IS a closed grammar rather than a best-effort search: - there is no ordering of write-then-op that survives it, because the - only accepted shape has nowhere to put a write. The rule-2 escapes - keep working β€” on a standalone op; `git merge --abort && echo done` - is denied like any other chain. - - THE COST, restated honestly. The accepted cost was already "run the - cleaning step as a SEPARATE command"; r13 does not add to that, it - makes it apply in both directions. What r13 DOES newly deny is an op - with an ordinary trailing convenience β€” `git pull | tail -5`, - `git pull > log 2>&1`, `git merge x && npm install`, `git merge - $(cat branchfile)`. Each costs one extra tool call. And that IS a - tool call, a hook cycle and an agent round trip β€” not "one extra - keystroke", as this docstring used to claim. - 1. FIND the history ops this command line invokes, by parsing the - command TEXT. The line is split into segments; each segment's - leading shell syntax is dropped β€” variable assignments, the reserved - words (`if then elif else fi for while until do done case esac in - select function time`), a `!` negation, the grouping tokens - `( ) { }`, a leading redirection β€” and the segment is then searched - for a word whose basename is `git`, followed (after git's GLOBAL - options) by `merge` / `rebase` / `pull`. If no segment yields one, - this check is SILENT. A `git pull` that survives quoting as a single - WORD β€” inside an `echo "…"`, a heredoc body, a `python3 -c '…'` - payload β€” is text, not an invocation, and is not found. - This step is a best-effort parse, and it is the ONLY thing standing - between an op and the tree reading. What it cannot identify, it does - not check: see WHAT IT DOES NOT SEE. - 2. ESCAPE / SELF-MANAGING FLAGS, as ACTUAL ARGUMENT TOKENS. A word that - IS `--abort`, `--continue`, `--skip`, `--quit` or `--edit-todo` - ALLOWS without reading the tree: those five are how you get OUT of a - dirty in-progress operation. The test is EXACT TOKEN EQUALITY over - the words after the subcommand, stopping at a `--` end-of-options - marker and skipping the VALUES of value-taking options (`-m`, `-F`, - `--exec`, `-X`, `-s`, …). So an exempt word that is really part of a - VALUE does not qualify β€” `git merge -m "wip --autostash later"` is - an ordinary merge and faces rule 3 β€” and `--skip-smudge` (a - different flag that merely starts with an exempt one) does not - qualify either. - - `--autostash` IS NO LONGER UNCONDITIONALLY EXEMPT (r13). It used to - be, on the stated ground that "git ESTABLISHES the precondition this - check exists to enforce". That was FALSE for untracked files, and - the external referee reproduced it: in a repository holding an - untracked `note.txt`, `git merge --autostash main` completed and - `git status --porcelain` still reported `?? note.txt` afterwards β€” - because `git stash` does not stash untracked files at all, which is - the very semantic that justified deleting the shell predictor in r8. - The exemption therefore reintroduced the hole it was written beside. - CHOICE MADE, of the referee's two options: `--autostash` now READS - the tree and is allowed only when `git status --porcelain` reports - NO `??` entry. Tracked and staged changes may be git-managed; an - untracked file may not. `--no-autostash` still does not qualify for - anything and faces rule 3. - 3. Otherwise READ THE TREE, live: `git status --porcelain` in the - working directory the invocation itself names (the event cwd, or the - `-C ` options carried by the history-op segment β€” a quoted - `-C` path is honoured, so quoting cannot bypass it, and a RELATIVE - `-C` path is resolved against the event cwd, not against whatever - directory the hook process happens to have been spawned in). - CLEAN β†’ allow. DIRTY β†’ DENY. - - THE QUESTION IS PINNED AGAINST CONFIGURATION (r15). `git status - --porcelain` honours settings that can make a dirty tree report - EMPTY, so the flags are part of the question: - `--untracked-files=normal` (else `status.showUntrackedFiles=no` β€” - the documented remedy for a slow status β€” hides every `??` entry, - which is precisely the dirt rule 2b exists for) and - `--ignore-submodules=none` (else `diff.ignoreSubmodules=all` / - `submodule..ignore` hides a submodule holding modified work). - Both measured on git 2.50.1; see `_GIT_STATUS_CMD`. A reading the - guarded repository can reconfigure is not a reading. - - EVERY `-C` IS COMPOSED, NOT JUST THE LAST (r15). git folds multiple - `-C` options left to right β€” each subsequent non-absolute one is - relative to the preceding one β€” so keeping only the last and - resolving it against the event cwd read a DIFFERENT repository than - git would use: `git -C -C . merge feature`, fired from a - clean checkout, was ALLOWED. `_resolve_dash_c` now folds them git's - way (absolute replaces, relative appends, empty is a no-op). - - AN UNREADABLE STATUS DENIES (r15). `read_status` used to return the - same None for "this is not a repository" and for "git could not - answer" (timeout, non-zero exit, exception), and the no-`-C` caller - read that as the former and ALLOWED. A slow `git status` was - therefore a silent allow on a dirty tree β€” the same unanswered - question the `-C` branch was fixed for at r13. Only the benign cause - (no `.git` at or above the directory, decided from the FILESYSTEM so - a broken git cannot fake it) allows now; everything else denies, and - the timeout bound is configurable via CLAUDE_GIT_STATUS_TIMEOUT - (default 10s) so a genuinely slow worktree can be worked in. - - THE BUDGET MUST FIT INSIDE THE REGISTRATION (r16). That 10s bound - shipped against a 5s `"timeout"` in .claude/settings.json, so on the - very worktree it was raised for the harness killed this hook before - it could write the deny β€” and a killed hook emits nothing, which is - what an ALLOW looks like. The registration is 20s now, the internal - bound is derived from it (`_HOOK_REGISTERED_TIMEOUT` minus margin), - and CLAUDE_GIT_STATUS_TIMEOUT is CLAMPED rather than honoured. - - AN UNRESOLVED REPOSITORY SELECTOR NOW DENIES (r13). If the invocation - names a `-C ` that cannot be read as a repository β€” it does not - exist, it holds an unexpanded `$var`, git cannot answer β€” the check - used to fall back to reading the EVENT cwd, and allow if THAT was - clean. The referee named this correctly: reading a different - repository and allowing because it is clean IS allowing on the - unanswered question. `REPO=/tmp/B` + `git -C "$REPO" merge main` was - judged against `/tmp/A`. It now DENIES, and says to rerun from that - repository or spell the path literally. - - OTHER REPOSITORY SELECTORS ARE DENIED, BY NAME (r13). `-C` is not the only - way to select a repository, and the docstring's old "the event cwd, or a - `-C`" was simply wrong about git. The referee reproduced both native - forms: `git --git-dir=/tmp/B/.git --work-tree=/tmp/B merge feature` - launched from a clean `/tmp/A` fast-forwarded B and left B's untracked - file in place, and `GIT_DIR=… GIT_WORK_TREE=… git merge feature` does the - same with no option at all. CHOICE MADE, of the referee's two options: - rather than bind these into the status query, a history op carrying any of - `--git-dir`, `--work-tree`, `--namespace`, `--bare`, or ANY variable - assignment on its command line (which covers `GIT_DIR=`, `GIT_WORK_TREE=`, - `GIT_INDEX_FILE=`, `GIT_COMMON_DIR=`, `GIT_OBJECT_DIRECTORY=`, - `GIT_NAMESPACE=` and every future sibling, because the ban is on the SHAPE - `NAME=VALUE`, not on a list of names) is DENIED outright. Run the op from - the repository it belongs to. This is named as a mechanism, not left to - the "assume there are more" bullet at the bottom of this file. - - WHY IT NO LONGER SIMULATES THE SHELL. Eight audit rounds tried to - PREDICT, from command text, whether the tree would still be clean by the - time the op ran: stash flags, then stash subcommands, then the segments - in between, then per-repository clean marks, then output redirection β€” - and every round found one more dimension the enumeration had missed. The - last round found three at once: a command substitution inside an - "inert" segment (`echo $(touch f)` writes and was classified inert), a - `cd` into another checkout, and the plain semantic fact that `git stash` - does not stash UNTRACKED files at all β€” so even a "provable full - cleaner" can leave the tree dirty. Simulating shell semantics by - enumeration does not converge. So the machinery that existed only to - model intervening segments β€” the stash-effect classifier, the - inert-segment allowlist, the per-repository cleaned map β€” IS DELETED. - The check reads the tree as it actually is at decision time. Command - substitution, `cd`, untracked-after-stash and every shell form not yet - invented cannot make a live `git status --porcelain` report a dirty tree - as CLEAN β€” the verdict is not derived from the command text at all. - - WHAT THAT SENTENCE DOES NOT COVER, and round 9 proved it: a form can - still keep the op out of the PARSER's sight, so no reading is ever taken. - That is a different failure and it is a real one β€” round 9 executed three - instances (`if true; then git merge x; fi`, `git -P reset --hard` / - `git --no-optional-locks pull`, and `git merge -m "… --autostash …"`). - Those three are closed. The class is NOT closed, so what remains is - disclosed below as residual rather than claimed away. - - THE ACCEPTED COST, stated plainly and corrected at r13: `git stash push -m - x && git merge` is DENIED, even though it would sometimes have worked β€” - and the old text's example was itself overstated, because on a tree whose - only dirt is UNTRACKED files that chain would NOT have worked: plain - `git stash push -m x` leaves `??` entries exactly where they were, and the - merge would have started dirty. (`git stash push -u -m x` is the spelling - that includes them.) The remedy is unchanged: run the cleaning step and - the history op as SEPARATE commands β€” the guard re-reads the real tree - state on the second one β€” or commit/stash first, or set - ALLOW_DIRTY_MERGE=1. Split is also clearer: you see whether the stash - actually cleaned the tree before you merge, instead of asking a hook to - guess. The cost is one extra TOOL CALL, with its own hook cycle and agent - round trip; earlier revisions of this file called it "one extra - keystroke", which understated it. - - Write/Edit/MultiEdit β€” warn on hardcoded machine paths in code: - - absolute home paths (/Users/, /home/, C:\\Users\\ with either - slash, single or doubled, any case) written into .R / .qmd / .do / .py / - .Rmd files (extension in any case) break replication packages. Warn by - default; set CLAUDE_STRICT_PATHS=1 to hard-deny. - -LINE CONTINUATIONS ARE SPLICED (r12), for BOTH checks, before the command line -is split into segments. A backslash-newline JOINS the line in POSIX shell; this -tokenizer used to read it as a separator, so a `git clean` whose `-xfd` sat on a -continued line β€” ordinary multi-line formatting, not an evasion β€” was two -segments and went SILENT while the one-line spelling DENIED. An ESCAPED -backslash (two of them) before a newline is not a continuation and still -separates commands. - -WHAT THE CLEAN-TREE CHECK IS β€” a LIVE reading of `git status --porcelain`, -reached through a best-effort TEXTUAL scan of the Bash command line. The scan -answers one narrow question β€” does this command line invoke a history op, and -in which directory β€” and nothing else is inferred from the text. It is not a -proof and not a sandbox. - -WHAT IT GUARANTEES β€” this much, and no more. For a history op this parser -IDENTIFIES (rule 1), the op must first be a STANDALONE SIMPLE COMMAND (rule 0) -or it is denied outright; then, if it carries no rule-2 escape token, the -verdict is a LIVE `git status --porcelain` reading taken at hook time in the -directory the invocation names β€” resolved since r18 the way git's own `chdir()` -resolves it, through the kernel, so a `-C` whose `..` follows a SYMLINK is read -in the repository git will land in rather than the one the text spells out. No -feature of the command text substitutes for that reading: there is no model of -the shell left to fool, so the ALLOW side -cannot be talked into existing by clever text β€” it can only be reached by a -tree that is actually clean, or by the op never being identified in the first -place. - -WHY RULE 0 IS WHAT MAKES THE READING MEAN ANYTHING. The reading is taken BEFORE -bash runs the command. Until r13 the guarantee was therefore only "the -parser-selected repository was clean when the Bash tool call was authorized" β€” -materially narrower than "history operations start from a clean tree", which -this file's summary and the doctrine's law 20 both asserted. Rule 0 closes the -gap the only way a PreToolUse hook can: by refusing every command shape that -could act between the reading and the op. What remains is the ordinary external -race β€” another process writing to the tree in the milliseconds between the -`git status` and git starting β€” which is disclosed, not claimed away, and is -categorically smaller than predicting an arbitrary shell prefix. - -WHAT IT EXPLICITLY DOES NOT CLAIM: that it identifies every shell form. It does -not, it cannot, and nine rounds of trying to enumerate forms is the reason that -sentence is written this way. Where the parse is ambiguous the code is written -to fall toward FINDING an op (an unknown git global option is consumed rather -than mistaken for the subcommand; a `git` word later in a segment is still -searched for; a `-C` directory that cannot be read as a repository DENIES since -r13, rather than falling back to the event cwd) β€” but "falls toward deny where -the parse permits" is not "denies everything it should". A form the parser -does not identify is not -denied; it is INVISIBLE, and the op runs. That residual is listed next. - -THE COST OF FAILING TOWARD DENY, stated as a cost: because a `git` word is -searched for past an unrecognised leading word, an UNQUOTED mention of a -history op in an ordinary command β€” `echo run git pull later`, `man git merge` -β€” is denied on a dirty tree. Quoting it (`echo "run git pull later"`) makes it -one word again and it is silent. This is the same trade as the r8 accepted -cost: a small, visible over-deny bought a whole class of wrapper bypasses. -Since r10 the deny list shares that parser, so it carries the same cost: -`echo run git push --force later` denies, `man git reset` does not (no -`--hard`), and the quoted spellings of both are silent. - -WHAT IT DOES NOT SEE β€” disclosed residual, BOUNDARY not defect. Since r10 this -block covers BOTH checks, because both reach their subject through the SAME -parser: what the parser cannot identify, neither check sees. (Before r10 it was -scoped to the clean-tree check alone, while the deny list ran on raw text and -had no boundary statement anywhere β€” which is how the quoting hole above went -undisclosed as well as unclosed.) - - a history op carried by an INTERPRETER whose program text is not a command - line β€” `python3 -c '...subprocess.run(["git","merge"])...'`, `perl -e`, an - R `system()` call. The op never appears as a `git` segment here. - - a history op inside a SHELL-WRAPPER payload β€” `bash -c 'git merge main'`, - `env -S 'git pull'`, `sh -c '...'`. Unlike root-of-trust-guard, this check - does NOT unwrap `-c` payloads, so such a form is not inspected. (A payload - that is a single QUOTED word is one word to the tokenizer; the same is true - of any op that survives quoting intact.) - - a history op run by an alias, a script file, a Makefile target, or a git - hook β€” anything whose text is on disk rather than on this command line. - - any dirtying by ANOTHER PROCESS between the hook's decision and git - starting. The `git status` answer is a point-in-time reading; rule 0 closes - the case where the SAME command line does the dirtying, but it cannot close - a concurrent writer. Ordinary accidents, not concurrency, are the threat - model here. -CLOSED at r13, therefore no longer residual: `cd && git merge` -(disclosed since r8 as judged against the session's repository rather than the -one the merge runs in) β€” a `cd` on a history op's command line now DENIES under -rule 0, as does every other multi-segment form. So does an unresolved `-C`, and -so do the `--git-dir` / `--work-tree` / `GIT_DIR=` / `GIT_WORK_TREE=` selectors, -which the pre-r13 text wrongly implied did not exist. - - an op inside a SUBSHELL or a SUBSTITUTION β€” `(git reset --hard)`, - `echo $(git merge main)`, the backtick spelling, the same inside double - quotes or an unquoted heredoc body. `(git` and `--hard)` are single words to - this tokenizer, so no `git` segment is identified. Open as #172; a flat- - separator fix was tried and backed out (see _SEG_TOKEN). - - ANY SHELL FORM THAT PUTS THE OP SOMEWHERE THIS PARSER DOES NOT LOOK. The - list above is the set known on 2026-08-23; it is a report of where the - parser has been probed, not a proof of where it is complete. Round 9 found - five such forms that eight earlier rounds had not (compound-command - keywords, an exempt token inside an option value, the same inside a trailing - comment, unenumerated git global options, a relative `-C`). All five are - closed; the CLASS is not. Assume there are more. -CLOSED at r9, therefore no longer residual: the bare COMMAND WRAPPER -(`timeout 300 git pull`, `nice git merge`, `nohup`, `command`, `env`), disclosed -at r8 because the segment's first word is not `git`. A `git` word is now -searched for along the segment β€” at the over-deny cost stated above. -None of the forms above is closed, and these checks are DEFENCE IN DEPTH over a -deliberate bypass posture, not a sandbox: permissions ship in bypass mode here, -so a determined caller was never the threat model. What actually backstops it -is the same layering root-of-trust-guard relies on β€” the operation stays -visible in the transcript, changes arrive through reviewable Edit/Write diffs -rather than invisibly, and the deliberate hatch (ALLOW_DIRTY_MERGE=1 in the -session environment) exists so the honest way past this check is to SAY SO -rather than to route around it. -DO NOT list reflog or ORIG_HEAD among those backstops, however tempting: they -recover COMMITS. The work this check exists to protect is precisely the work -that is NOT committed β€” uncommitted edits and untracked files β€” and no reflog -entry brings those back. An external referee caught that claim standing in the -docs; it was removed there and must not creep back here. What this hook buys is that the ordinary accident β€” an -agent one-liner that merges over uncommitted work β€” costs a denial instead of -an unreviewable commit. - -Decision protocol (modern PreToolUse): exit 0 + JSON - {"hookSpecificOutput": {"hookEventName": "PreToolUse", - "permissionDecision": "deny", "permissionDecisionReason": "..."}}. -Fail-open: any error β†’ exit 0 with no decision (allow). -""" - -from __future__ import annotations - -import json -import os -import re -import subprocess -import sys -from pathlib import Path - -# ── git's GLOBAL options: ONE table, ONE consumer ────────────────────────── -# -# Options can sit between `git` and the subcommand (`git -C reset --hard`, -# `git -c x=y clean -fd`). Both the deny list and the clean-tree check must walk -# past them to reach the real subcommand β€” and until r9 each had its OWN -# enumeration of which options exist. Both enumerated seven. git documents far -# more, so ONE innocuous unlisted option defeated BOTH at once: `git -P` -# (git's own short spelling of `--no-pager`, whose LONG spelling was listed), -# `git --no-optional-locks`, `--literal-pathspecs`, `--icase-pathspecs`, -# `--no-replace-objects`, `--exec-path=…`, `--bare`. With any of them inserted, -# `reset --hard` / `clean -fd` / `push --force` / `add -A` / `checkout -- .` -# all went SILENT, and so did the dirty-tree check on merge/rebase/pull. -# -# So stop enumerating which options EXIST. Enumerate only the ones that take a -# SEPARATE value token (the one thing a walker cannot guess), and treat every -# other leading `-…` word as a one-token global option. That fails toward -# FINDING the subcommand β€” i.e. toward DENY β€” instead of toward missing it. -# -# r10: there is no longer a regex COPY of this table. `_GO`, the regex-prefix -# spelling that the deny patterns carried, is DELETED along with the raw-string -# deny regexes themselves β€” the deny list now walks the same `_walk_git_globals` -# the clean-tree check walks, so the "two consumers that disagreed" shape that -# produced the r9 defect cannot recur here at all. -_GIT_GLOBAL_VALUE_OPTS = ("-C", "-c", "--git-dir", "--work-tree", "--namespace", - "--exec-path", "--super-prefix", "--attr-source", - "--config-env") - - -# ── the destructive-git deny list, decided over TOKENS ───────────────────── -# -# r10: these five rules used to be REGEXES over the RAW command string, and -# ordinary shell quoting of a SINGLE token defeated every one of them. Executed -# against the shipped guard on a fixture repo: `git reset --hard HEAD~1` DENIED, -# while `git reset "--hard" HEAD~1`, `git 'reset' --hard HEAD~1`, `git add '-A'`, -# `git add "--all"` and `git checkout -- '.'` were all SILENT. The clean-tree -# check had already been rebuilt to tokenise (`_segments` β†’ `_unquote` β†’ -# `_git_segment`); the deny list had not, so the two halves of ONE guard -# disagreed about what a command even says, and the half with no boundary -# statement was the one with the hole. -# -# They now share ONE representation. A rule is -# (subcommand, predicate over the ARGUMENT TOKENS, reason, safe alternative); -# the predicate sees every word after the subcommand, already unquoted, with a -# trailing shell comment already dropped by `_segments`. A quoted flag is -# therefore matched exactly like a bare one, because by the time the predicate -# runs there is no difference between them. -# -# WHAT THE PORT CHANGED, both directions, stated rather than claimed away: -# * quoting no longer evades β€” the six spellings above now DENY (battery -# cases b5-b12 pin them). -# * a MENTION that survives quoting is now correctly SILENT: in -# `echo "never run git reset --hard here"` the quoted span is ONE word whose -# basename is not `git`, so no invocation is found. The old raw regex DENIED -# that string, which wrote nothing β€” a false deny, now gone (case b13). -# * an UNQUOTED mention β€” `echo run git reset --hard later` β€” still denies, -# because `_git_segment` searches for a `git` word ALONG the segment. That is -# the SAME accepted over-deny the clean-tree check already carries and it is -# what closes the bare-wrapper class (`timeout 300 git push --force`, -# `nice git clean -fd`), which the raw regex caught only by the accident of -# matching anywhere in the string. -# -# The predicates deliberately do NOT stop at a `--` end-of-options marker. For -# `add`/`checkout`/`restore` the dangerous operand is a PATHSPEC that -# legitimately follows `--` (`git add -- .`), and for the flag rules a `--hard` -# or `--force` sitting after `--` would be a pathspec by that name, which does -# not exist. Biased toward DENY, like the rest of this file. - -def _has_token(args, *names): - """True when an ACTUAL argument token IS one of `names` (exact equality β€” - `--force-with-lease` is not `--force`).""" - return any(a in names for a in args) - - -def _short_flag(args, letter): - """True when a single-dash SHORT-OPTION GROUP carries `letter`: `-f`, `-fd`, - `-uA`. A long option is never a short group, which is what keeps - `--force-with-lease` and `--force-if-includes` allowed.""" - return any(len(a) > 1 and a[0] == "-" and not a.startswith("--") and letter in a[1:] - for a in args) - - -def _mass_discard(args): - """`git checkout -- .` / `git restore .` β€” a whole-tree discard. - - `--staged`/`-S` alone is NOT a working-tree discard (it unstages, and the - work stays in the tree), so it does not qualify unless `--worktree`/`-W` is - also present. The old regex could not express this: it only looked at the - token immediately after the subcommand, so it neither caught - `git restore --source=HEAD .` nor spared `git restore --staged .`.""" - if not _has_token(args, "."): - return False - staged = _has_token(args, "--staged") or _short_flag(args, "S") - worktree = _has_token(args, "--worktree") or _short_flag(args, "W") - return worktree or not staged - - -# (subcommand, predicate over argument tokens, human reason, safe alternative) -GIT_DENY = [ - ("reset", lambda a: _has_token(a, "--hard"), - "git reset --hard discards uncommitted work irrecoverably.", - "Use `git stash` (recoverable) or reset specific paths."), - ("clean", lambda a: _has_token(a, "--force") or _short_flag(a, "f"), - "git clean -f/--force deletes UNTRACKED files β€” including data not yet committed.", - "Inspect with `git clean -n` first; delete specific paths by hand."), - ("push", lambda a: _has_token(a, "--force") or _short_flag(a, "f"), - "git push --force clobbers remote history.", - "Use `git push --force-with-lease` if you truly must rewrite a branch."), - ("add", lambda a: _has_token(a, "-A", "--all", ".", ":/") or _short_flag(a, "A"), - "Blanket staging (git add -A / . / -- . / :/) can stage data, secrets, or settings.local.json. " - "The /commit skill forbids it.", - "Stage specific files: `git add path/to/file ...`."), - ("checkout", _mass_discard, - "Mass discard of working-tree changes is irreversible.", - "Discard specific files, or `git stash` to keep them recoverable."), - ("restore", _mass_discard, - "Mass discard of working-tree changes is irreversible.", - "Discard specific files, or `git stash` to keep them recoverable."), -] - -# A merge, rebase, or pull started on a dirty tree resolves *two* sets of -# changes at once and records only one of them as a decision β€” afterwards -# nothing distinguishes "I chose this" from "this was in my tree". -# -# The check operates on COMMAND SEGMENTS for ONE reason only: to tell a real -# `git merge` from a `git merge` merely mentioned in an echo, inside a quoted -# string, or in a heredoc body. It does NOT reason about what the other -# segments do β€” that was the simulator, and it is gone (see the docstring). -# -# `--autostash` is exempt for the OPPOSITE reason to `--abort`/`--continue`: it -# is git's own stash-operate-restore. git stashes the dirty tree, runs the -# merge/rebase/pull, and reapplies the stash afterwards, so the uncommitted work -# is never folded into the resolution β€” the precondition this check enforces is -# ESTABLISHED BY GIT ITSELF. It is supported on merge, rebase and pull and is -# switched on implicitly by `rebase.autoStash` / `pull.autoStash`, so denying it -# rejected the git-native spelling of the very remedy the deny message -# recommends. Only the affirmative form is exempt: `--no-autostash` does not -# qualify, so explicitly turning autostash OFF still faces the live check. -# -# r9: the exemption is decided from ARGUMENT TOKENS, not from a substring -# search over the joined segment. It used to be the latter, and rule 2 is the -# ONLY path that skips the live `git status` read β€” so any option VALUE -# containing the literal text of an exempt flag switched the whole check off. -# Executed on a dirty fixture: `git merge -m "wip --autostash later" feature` -# was ALLOWED, as were `git pull -m "after the --abort we retry" origin main`, -# a trailing `# finish the --continue later` comment, a branch called -# `feature--continue`, and `git rebase --skip-smudge main` (the old `\b` was -# satisfied by the following hyphen). None of those is an escape form; each is -# an ordinary history op on a dirty tree, which is precisely what rule 3 exists -# to refuse. -# -# r13: `--autostash` IS SPLIT OUT of this set. The paragraph above was wrong -# about untracked files and an external referee reproduced it β€” `git merge -# --autostash main` in a repo holding `?? note.txt` merged and left the -# untracked file exactly where it was, because `git stash` does not stash -# untracked files. So the affirmative `--autostash` no longer skips the tree -# read; it now takes a NARROWER allow (`_GIT_OP_AUTOSTASH`, applied in -# `dirty_tree_reason`): allowed only when `git status --porcelain` reports no -# `??` entry. The five ESCAPE flags below keep the unconditional allow, because -# they are how you get out of an in-progress operation and reading the tree -# cannot inform that. -GIT_OP_EXEMPT_TOKENS = frozenset( - ("--abort", "--continue", "--skip", "--quit", "--edit-todo")) -_GIT_OP_AUTOSTASH = frozenset(("--autostash",)) - -# Options on a history op that take a SEPARATE value token. Their value must be -# SKIPPED before the exempt test, or the value gets read as a flag. Biased -# toward over-skipping on purpose: skipping one word too many can only cost a -# DENY (rule 3 runs), while skipping one too few is a false ALLOW. -_GIT_OP_VALUE_OPTS = frozenset(( - "-m", "--message", "-F", "--file", "--exec", "-x", "-s", "--strategy", - "-X", "--strategy-option", "-S", "--gpg-sign", "--into-name", "--onto", - "--log", "--depth", "--deepen", "--shallow-since", "--shallow-exclude", - "--refmap", "--upload-pack", "--recurse-submodules", "-j", "--jobs", -)) - -GIT_HISTORY_OPS = {"merge", "rebase", "pull"} -_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") - - -def _prog(word: str) -> str: - """The program a command word names, as Windows and a case-insensitive disk - run it: the basename on EITHER separator, lower-cased, a trailing `.exe` - dropped. `git.exe`, `GIT` and 'C:\\...\\cmd\\git.exe' are all `git` (r21).""" - b = re.split(r"[\\/]", word)[-1].lower() - return b[:-4] if b.endswith(".exe") else b - - -def _op_is_exempt(words: list[str], start: int, tokens=GIT_OP_EXEMPT_TOKENS) -> bool: - """True only when an ACTUAL argument token of this invocation IS one of - `tokens`. `start` is the index of the subcommand. The token set is a - PARAMETER since r13, because the escape flags and `--autostash` no longer - buy the same thing β€” see GIT_OP_EXEMPT_TOKENS.""" - i = start + 1 - while i < len(words): - w = words[i] - if w == "--": # end of options: refs only after - return False - if w in tokens: - return True - if w in _GIT_OP_VALUE_OPTS and i + 1 < len(words): - i += 2 # `-m `: the value is DATA - continue - i += 1 - return False - -# Tokenizer: split a Bash command into per-command segments on shell -# separators, keeping quoted spans intact so a separator inside quotes does not -# split a segment. Open (#172): `(`, `)` and backticks are NOT separators, so -# `(git reset --hard)` reads as the words `(git` … `--hard)` and a subshell or a -# substitution can carry an op past both checks. Making them flat separators was -# tried and backed out (2026-09-27): it cut comments short (false denies on -# `git pull # (daily sync)`) and split a command's own arguments at a -# substitution (a new miss). The fix needs substitutions parsed as nested commands. -_SEG_TOKEN = re.compile( - r"""(?P\|\||&&|;|&|\||\n) - | (?P(?:"[^"]*"|'[^']*'|\\.|[^\s"'|;&\n])+)""", - re.VERBOSE, -) -# r21: the Windows branch used to need `C:\\Users\\` with DOUBLED backslashes β€” -# the escaped spelling inside an R string β€” so Stata's native `cd "C:\Users\me"`, -# a Python raw string `r"C:\Users\me\x.csv"` and lower-case `c:/users/me` were -# never flagged; `C:/Users/me` was, but only by luck, through the `/Users/` -# branch. Measured 2026-09-27 on ae72617 with CLAUDE_STRICT_PATHS=1: the first -# three ALLOWED. One or more of either separator counts now, and `users` in any -# case β€” a SCOPED `(?i:...)`, because a global `(?i)` also flags -# `https://api.github.com/users/` in ordinary .py code. The extension is -# compared lower-cased for the same reason: `a.rmd` and `MASTER.DO` skipped the -# check entirely while `a.Rmd` and `master.do` denied. -# The drive letter must not follow a letter or digit. Without that the scheme's -# last letter reads as a drive: `gs://users/data.csv`, `https://users/x` and -# `{"key:/users/me": 1}` matched as `s:`, `s:` and `y:` (measured on 9d90fc3, a -# deny under CLAUDE_STRICT_PATHS=1). A real drive follows a quote, a space, `(`, -# `=`, `\\?\` or `file:///`, none of which the lookbehind refuses. -HARDCODED_PATH = re.compile(r"(/Users/[^/\s'\")]+|/home/[^/\s'\")]+" - r"|(? .claude/settings.json DENY (control) -# echo X > $'.claude/settings.json' ALLOW (silent) -# echo X > $".claude/settings.json" ALLOW (silent) -# rm -f $'.claude/hooks/git-guardrails.py' ALLOW (silent) -# -# Like the r10 quoting hole this is ORDINARY SHELL, not an evasion, and it -# silenced the deny list and the clean-tree precondition at once. -# -# THE RULE: a `$` immediately before a quote opener is QUOTING SYNTAX, not part -# of the word. `$'…'` additionally has its ANSI-C escapes decoded, because bash -# decodes them before the word ever reaches the command β€” `$'\x2d\x2dhard'` IS -# `--hard` to git, and a guard that compares whole words has to see the same -# word. An escape this decoder does NOT recognise keeps BOTH its characters, -# exactly as bash does for an unrecognised escape: dropping the backslash would -# invent a DIFFERENT word, and a word the guard invented is a word that matches -# nothing. -# -# Disclosed residual, stated as the possibility it is rather than as a measured -# hole: the TOKENIZERS still read `'…'` as a span ending at the next `'`, while -# inside `$'…'` a `\'` is an ESCAPED quote that does not end the span, so a -# `$'…\'…'` spelling could be split into words differently from bash. The -# unquoting below is escape-aware; the tokenizer is not. No spelling in this -# class has been shown to reach a false ALLOW β€” the one probed on 2026-08-24 -# still DENIED β€” so this is a known divergence, not a demonstrated bypass. -# -# THIS BLOCK AND THE FUNCTION BELOW ARE DUPLICATED VERBATIM IN -# `root-of-trust-guard.py` (its copy names the function `unquote`), for the same -# reason the heredoc walker is: that hook's import of this one is best-effort -# and returns None on any failure, and a parser that silently stops running is -# exactly what this defect was. -_ANSI_C_SIMPLE = {"a": "\a", "b": "\b", "e": "\x1b", "E": "\x1b", "f": "\f", - "n": "\n", "r": "\r", "t": "\t", "v": "\v", - "\\": "\\", "'": "'", '"': '"', "?": "?"} -_ANSI_C_HEX = {"x": 2, "u": 4, "U": 8} -_HEXDIGITS = "0123456789abcdefABCDEF" - - -def _ansi_c(body: str) -> str: - """Decode the ANSI-C escapes bash processes inside `$'…'`. - - An UNRECOGNISED escape keeps its backslash AND its character, which is what - bash does with one β€” the failure direction matters here, because a word this - function invents matches no rule at all.""" - out, i, n = [], 0, len(body) - while i < n: - c = body[i] - if c != "\\" or i + 1 >= n: - out.append(c); i += 1; continue - e = body[i + 1] - if e in _ANSI_C_SIMPLE: - out.append(_ANSI_C_SIMPLE[e]); i += 2; continue - if e in _ANSI_C_HEX: # \xHH \uHHHH \UHHHHHHHH - j, stop = i + 2, i + 2 + _ANSI_C_HEX[e] - while j < n and j < stop and body[j] in _HEXDIGITS: - j += 1 - if j > i + 2: - try: - out.append(chr(int(body[i + 2:j], 16))); i = j; continue - except (ValueError, OverflowError): - pass - out.append(c); out.append(e); i += 2; continue - if e in "01234567": # \nnn, up to three octal - j = i + 1 - while j < n and j < i + 4 and body[j] in "01234567": - j += 1 - try: - out.append(chr(int(body[i + 1:j], 8) & 0xFF)); i = j; continue - except (ValueError, OverflowError): - pass - if e == "c" and i + 2 < n: # \cX β€” a control character - x = body[i + 2] - if x.isalpha() and x.isascii(): - out.append(chr(ord(x.upper()) ^ 0x40)); i += 3; continue - out.append(c); out.append(e); i += 2 # unrecognised: keep both - return "".join(out) - - -def _unquote(w: str) -> str: - out, i = [], 0 - while i < len(w): - c = w[i] - if c == "$" and i + 1 < len(w) and w[i + 1] in "\"'": - q = w[i + 1] # `$'…'` / `$"…"`: `$` is SYNTAX - if q == "'": - j, esc = i + 2, False # inside `$'…'` a `\'` does not close - while j < len(w): - if esc: - esc = False - elif w[j] == "\\": - esc = True - elif w[j] == "'": - break - j += 1 - if j >= len(w): - out.append(_ansi_c(w[i + 2:])); break - out.append(_ansi_c(w[i + 2:j])); i = j + 1 - continue - j = w.find(q, i + 2) - if j == -1: - out.append(w[i + 2:]); break - out.append(w[i + 2:j]); i = j + 1 - elif c in "\"'": - j = w.find(c, i + 1) - if j == -1: - out.append(w[i + 1:]); break - out.append(w[i + 1:j]); i = j + 1 - elif c == "\\" and i + 1 < len(w): - out.append(w[i + 1]); i += 2 - else: - out.append(c); i += 1 - return "".join(out) - - -# THE OPENER SCAN IS QUOTE-AWARE AND HERESTRING-AWARE (r16). It used to be one -# quote-blind regex β€” `<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1` β€” run over RAW -# line text with `finditer`. Two ordinary spellings therefore opened a heredoc -# that bash never opens, and every line after them (to the end of the command, -# or to a line spelling the invented terminator) was DELETED before anything was -# tokenised: -# -# * a HERESTRING. To that regex `tr a-z A-Z <<< hello` is `<<` + `\s*` + -# `hello`, so `hello` became the terminator. -# * a `<['"])(?P[A-Za-z_][A-Za-z0-9_]*)(?P=q) - | \\?(?P[A-Za-z_][A-Za-z0-9_]*) )""", - re.VERBOSE, -) - - -def _heredoc_opener(line: str, quote: str = "") -> "tuple[str | None, str]": - """The delimiter of the LAST heredoc this line OPENS (None if it opens - none), and the quote state the line ends in β€” fed back in for the next - line, so a string spanning lines stays quoted. - - An unquoted `#` that BEGINS a word ENDS the line: everything after it is a - shell COMMENT and can open nothing. See the r17 paragraph above.""" - delim = None - i, n = 0, len(line) - while i < n: - c = line[i] - if quote: - if c == "\\" and quote == '"' and i + 1 < n: - i += 2 # only `"` honours a backslash - continue - if c == quote: - quote = "" - i += 1 - continue - if c == "\\" and i + 1 < n: - i += 2 # an escaped char, `\<` included - continue - if c in "'\"": - quote = c - i += 1 - continue - if c == "#" and (i == 0 or line[i - 1] in " \t;&|()"): - break # a COMMENT: no shell text follows - if line.startswith("<<<", i): - i += 3 # HERESTRING: never an opener - continue - if line.startswith("<<", i): - m = _HEREDOC_DELIM.match(line, i + 2) - if m: - delim = m.group("qword") or m.group("word") - i = m.end() - continue - i += 2 - continue - i += 1 - return delim, quote - - -def _strip_heredocs(cmd: str) -> str: - """Drop heredoc BODIES so a `git pull` that merely appears inside one is - prose, not a command β€” the same posture as root-of-trust-guard.""" - if "<<" not in cmd: - return cmd - out, pending, quote = [], None, "" - for line in cmd.split("\n"): - if pending is not None: - if line.strip() == pending: - pending = None - continue # a BODY line: not shell text - out.append(line) - delim, quote = _heredoc_opener(line, quote) - if delim: - pending = delim - return "\n".join(out) - - -# A backslash-newline is a LINE CONTINUATION in POSIX shell: it JOINS the line, -# it does not end the command. `_SEG_TOKEN` lists `\n` in its `sep` alternation -# while its `word` alternation's escape class (`\\.`) cannot match backslash + -# newline β€” `.` excludes newline outside DOTALL β€” so a trailing `\` was consumed -# as an ordinary word character and the newline behind it ENDED the segment. -# Executed against the pre-fix guard on a dirty fixture (r11): `git clean \` + -# newline + ` -xfd` emitted no decision and, run through `bash -c`, deleted -# the untracked file; so did `git push \`+NL+`--force origin main`, `git reset \` -# +NL+`--hard HEAD~1`, `git add \`+NL+`-A`, `git checkout \`+NL+`-- .`, and -# `git \`+NL+`merge feature`. Every one-line spelling DENIED, so the verdict -# turned on where the author had put a newline. The continuation is now spliced -# the way the shell splices it, BEFORE anything is tokenised. -# -# The RUN of backslashes decides. `\\` is an ESCAPED backslash, so `\\` + newline -# is a literal backslash followed by a REAL separator, not a continuation: the -# even-run prefix is consumed and re-emitted, and only an ODD trailing backslash -# joins. A newline with no backslash before it still separates commands, and a -# literal `\` inside a quoted string (not followed by a newline) never matches. -_LINE_CONT = re.compile(r"(? str: - """Splice POSIX line continuations, so a command formatted across several - lines is tokenised as the ONE command bash will actually run. - - THE REPLACEMENT IS EMPTY, NOT A SPACE (r20 fix), and the difference is a - bypass. POSIX REMOVES a backslash-newline; it does not turn it into a word - separator. Substituting a space is invisible where the continuation sits at - a token boundary β€” which is every example r12 was written against, and why - this stood β€” and DECISIVE where it sits inside a word, because a space there - splits one word into two and the split halves match no rule. - - Measured at 727a66c on a dirty fixture repository, guard verdict then what - bash makes of the same string: a continuation inside the SUBCOMMAND - (`git re\\set --hard`) ALLOW(silent), inside the FLAG - (`--ha\\rd`) ALLOW(silent), inside a force push - (`--for\\ce`) ALLOW(silent), and inside the program word itself - (`gi\\t`) ALLOW(silent) β€” while every literal twin DENIED, and - `printf '%s' re\\set` prints the single word `reset`. So the guard - read two words where bash reads one, and every destructive verb was - spellable straight through the deny list. - - Found by the SIBLING hook's r20 audit: the identical substitution was fixed - there first, and the same defect was sitting in this file. Two copies of one - rule drifted apart in the direction neither author checked β€” which is the - argument for the shared-helper routing this pair already uses for the deny - list itself.""" - return _LINE_CONT.sub(r"\1", cmd) - - -def _drop_cr(cmd: str) -> str: - """Remove every carriage return, as the MSYS bash of Git for Windows does - before it splits words (r21). - - `_SEG_TOKEN` counts `\\r` as whitespace, so `git reset --hard` was - three words here and matched no rule, while Git Bash β€” the shell Claude - Code's Bash tool runs through on Windows β€” drops the CR and runs - `git reset --hard`. The same held for a CR inside the program word and - for a `\\` continuation, which the continuation splice never saw. - Unconditional: POSIX bash keeps a CR as an ordinary WORD character, never - a separator, so dropping it can only join text the shell also treats as - one word β€” a false deny at worst, on a command git would reject anyway.""" - return cmd.replace("\r", "") - - -def _segments(cmd: str) -> list[list[str]]: - """Per-command segments as lists of unquoted words. - - A word whose RAW text begins with `#` starts a shell COMMENT: it and the - words after it are not arguments, so they are dropped. The test is on the - raw word, before `_unquote`, so a quoted `"#123 fix"` message stays an - argument. r9: without this, `git pull origin main # finish the --continue - later` put an exempt TOKEN on the invocation and skipped the tree read.""" - segs: list[list[str]] = [[]] - comment = False - for m in _SEG_TOKEN.finditer(cmd): - if m.group("sep") is not None: - segs.append([]) - comment = False - continue - raw = m.group("word") - if raw.startswith("#"): - comment = True - if not comment: - segs[-1].append(_unquote(raw)) - return [s for s in segs if s] - - -# Shell RESERVED WORDS and grouping tokens that can stand in front of a command -# inside one segment. Until r9 a segment was tested for git by its FIRST word -# only, so every one of these hid the op completely: `if true; then git merge x; -# fi`, `( git merge x )`, `{ git merge x; }`, `for d in .; do git -C $d pull; -# done`, `while …; do git pull; …; done`, `else git rebase main`, -# `! git merge x`, `>/dev/null git merge x` β€” all executed SILENT on a dirty -# fixture while the byte-identical bare spelling DENIED. -_SHELL_HEAD_WORDS = frozenset(( - "if", "then", "elif", "else", "fi", "for", "while", "until", "do", "done", - "case", "esac", "in", "select", "function", "time", "coproc", - "!", "(", ")", "{", "}", "[[", "]]", -)) -# `>f` `2>f` `>>f` `&2` `<>f` … as a leading word, attached or bare. -_REDIR_HEAD = re.compile(r"^\d*(?:>>|>&|>\||<>|<&|>|<)") - - -def _strip_shell_head(words: list[str]) -> tuple[list[str], int]: - """Drop leading shell SYNTAX so the command word itself can be seen. - Returns the (possibly rewritten) word list and the index of the first word - that is not shell syntax.""" - words = list(words) - i = 0 - while i < len(words): - w = words[i] - if _ASSIGN.match(w) or w in _SHELL_HEAD_WORDS: - i += 1 - continue - bare = w.lstrip("({!") # `(git`, `{git`, `!git` - if bare != w: - if bare: - words[i] = bare - continue # re-test the rewritten word - i += 1 - continue - m = _REDIR_HEAD.match(w) - if m: - # a BARE operator carries its target in the next word (`> /dev/null`) - i += 2 if m.end() == len(w) else 1 - continue - if w.endswith(")") and "/" not in w: # `*)` case label, `f()` definition - i += 1 - continue - break - return words, i - - -# r13 β€” git's OTHER repository selectors. `-C` changes the working DIRECTORY; -# these change which REPOSITORY git operates on, with no `cd` and no `-C`. The -# referee reproduced native git honouring them: `git --git-dir=/tmp/B/.git -# --work-tree=/tmp/B merge feature`, launched from a clean /tmp/A, fast-forwarded -# B and left B's untracked file in place. The pre-r13 docstring claimed -# repository identity was "the event cwd, or a -C" β€” flatly wrong. They are not -# bound into the status query; a history op carrying one is DENIED (see -# `_standalone_violation`), which is the option the referee allowed for and the -# only one that cannot be wrong about a repository it never read. -_REPO_SELECTOR_GLOBALS = frozenset( - ("--git-dir", "--work-tree", "--namespace", "--bare")) - - -def _walk_git_globals(words: list[str], i: int) -> tuple[int, list[str], list[str]]: - """Consume git's GLOBAL options starting just after the `git` word. Returns - (index of the subcommand β€” or len(words) if there is none, EVERY `-C` path - in the order given, the REPOSITORY-SELECTOR globals seen). - - Only the value-taking names in `_GIT_GLOBAL_VALUE_OPTS` consume a second - token; ANY other leading `-…` word is consumed as a one-token global. See - that table for why enumerating which options exist was the defect. - - r15: this used to keep only the LAST `-C` and throw the rest away. git does - not β€” git(1): "If multiple -C options are given, each subsequent - non-absolute -C is interpreted relative to the preceding -C ." - Measured on git 2.50.1: from a clean checkout, `git -C -C . rev-parse - --show-toplevel` prints , and so does `-C -C sub`. Keeping - the last one and resolving it against the EVENT cwd therefore read a - DIFFERENT repository from the one git would operate on β€” and allowed when - that one was clean. Every value is carried out now and folded by - `_resolve_dash_c` the way git folds them.""" - dash_c: list[str] = [] - selectors: list[str] = [] - while i < len(words): - w = words[i] - if not w.startswith("-") or w == "--": - break - name = w.split("=", 1)[0] - if name in _REPO_SELECTOR_GLOBALS and name not in selectors: - selectors.append(name) - if w == "-C" and i + 1 < len(words): - dash_c.append(words[i + 1]); i += 2; continue - if w.startswith("-C") and len(w) > 2 and "=" not in w: # -Cdir - dash_c.append(w[2:]); i += 1; continue - if "=" in w: # --opt=value - i += 1; continue - if w in _GIT_GLOBAL_VALUE_OPTS and i + 1 < len(words): - i += 2; continue - i += 1 # any other global - return i, dash_c, selectors - - -def _git_segment(words: list[str]) -> tuple[str | None, list[str], int, list[str], list[str]]: - """If a segment invokes git, return (subcommand, EVERY `-C` path in order, - subcommand index, the words the index refers to, repository-selector - globals); else (None, [], -1, words, []). - - The word list is returned because `_strip_shell_head` REWRITES leading words - (`(git` β†’ `git`), so the index is meaningful only against the list this - function actually walked. Both callers β€” the deny list and the clean-tree - check β€” slice arguments with it, and neither may guess. - - Leading shell syntax is dropped first, then a `git` word is searched for - ALONG the segment rather than tested at position 0 only. Searching forward - also closes the r8-disclosed bare-COMMAND-WRAPPER residual (`timeout 300 git - pull`, `nice git merge`, `nohup`, `command`, `env`) β€” at the stated cost - that an UNQUOTED mention of a history op or a destructive op in another - command now denies. A mention that survives quoting is a single WORD and is - still silent, which is what the rule-1 control (c17) and the deny-list - control (b13) pin. - - r19 β€” THE COMMAND WORD IS MATCHED CASE-INSENSITIVELY, UNCONDITIONALLY. This - test was `== "git"`, and on a case-insensitive filesystem β€” APFS, the macOS - default, and HFS+ before it β€” the uppercase spelling really runs git: - `command -v GIT` resolves to /usr/bin/GIT here. Measured 2026-08-24 against - a throwaway fixture repository whose porcelain read ' M f.txt', - `GIT reset --hard HEAD` and `Git merge other` were ALLOWED (silent) while - their lowercase twins DENIED. The fold is UNCONDITIONAL rather than probed - from the filesystem, by the same ruling the r15 glob fix made: the verdict - must not depend on what happens to exist at hook time. The cost is a false - DENY on a case-SENSITIVE filesystem for a program genuinely named `GIT` that - is not git β€” err toward denying, which is this file's standing direction. - - The SUBCOMMAND is deliberately NOT folded. git itself rejects a - case-varied subcommand: measured on git 2.50.1 (Apple Git-155), - `git STATUS --porcelain` and `git Status --porcelain` both exit 128 - ("fatal: cannot handle STATUS as a builtin") while `git status` exits 0. A - spelling git refuses to run cannot reach the tree, so folding it would buy - no coverage and would only add false denies. - - r21 β€” `.exe` AND A BACKSLASH PATH NAME THE SAME PROGRAM. On Windows, Git - Bash starts `cmd\\git.exe` for `git.exe` exactly as for `git`, and a quoted - 'C:\\Program Files\\Git\\cmd\\git.exe' is one word posixpath never splits. - Measured 2026-09-27 against ae72617: `git.exe reset --hard`, `GIT.EXE - clean -fdx` and that quoted path + `reset --hard` were ALLOWED (silent) - while `git reset --hard` DENIED. `_prog` reads the program either way, on - every OS, by the same ruling as the case fold above.""" - words, i = _strip_shell_head(words) - while i < len(words): - if _prog(words[i]) == "git": - j, dash_c, selectors = _walk_git_globals(words, i + 1) - if j < len(words): - return words[j], dash_c, j, words, selectors - return None, [], -1, words, [] - i += 1 - return None, [], -1, words, [] - - -def git_deny_reason(cmd: str) -> str | None: - """The destructive-git deny list, decided over the SAME tokenised, unquoted - segments the clean-tree check reads. Returns the deny message, or None.""" - # Heredoc bodies are dropped FIRST (they are line-oriented: joining a body - # line that ends in `\` could hide the terminator), then continuations are - # spliced, so a multi-line command is tokenised as the one command bash runs. - # Carriage returns go before either, as Git Bash drops them (r21). - for seg in _segments(_join_continuations(_strip_heredocs(_drop_cr(cmd)))): - sub, _dash_c, at, words, _sel = _git_segment(seg) - if sub is None: - continue - args = words[at + 1:] - for name, predicate, reason, alt in GIT_DENY: - if sub == name and predicate(args): - return (f"{reason} {alt} " - f"(override: run it in a terminal yourself, outside Claude Code.)") - return None - - -# ──────────────────────────────────────────────────────────────────────────── -# DELETED IN r8, DELIBERATELY: `_STASH_VALUE_FLAGS`, `_STASH_REDIRTY`, -# `_STASH_NEUTRAL`, `_STASH_CLEANERS`, `_STASH_CLEAN_FLAGS`, `_stash_effect`, -# `_INERT_GIT_SUBS`, `_INERT_COMMANDS`, `_REDIRECTORS`, -# `_segment_writes_via_redirection`, `_segment_is_inert`, `_REPO_KEY_CACHE`, -# `_repo_key`, `_resolve_repo_key`, and the per-repository `cleaned` map. -# -# All of it existed for ONE purpose: to decide whether an earlier segment in -# the same command line had left the tree clean enough to license a later -# history op β€” i.e. to SIMULATE the shell. Rounds 4 through 8 each found one -# more dimension the simulation had not modelled (stash flags, stash -# subcommands, intervening segments, repository identity, output redirection, -# command substitution, `cd`, and untracked-after-stash). The leak lived in the -# complexity itself, so the complexity is gone: the guard now reads the tree -# live and refuses a dirty-tree history op unconditionally. Nothing here may be -# reintroduced without re-opening that hole. -# ──────────────────────────────────────────────────────────────────────────── - -def deny(reason: str) -> None: - json.dump({"hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "deny", - "permissionDecisionReason": reason, - }}, sys.stdout) - - -def _repo_neutral_env() -> dict: - """The process environment with git's REPOSITORY-SCOPING variables removed. - - Found by /blast-radius (2026-08-23): this read passed `cwd=` and no `env=`. - git EXPORTS its repository into every hook it runs β€” GIT_DIR, - GIT_INDEX_FILE, GIT_WORK_TREE, GIT_COMMON_DIR, GIT_OBJECT_DIRECTORY, - GIT_PREFIX, GIT_NAMESPACE and siblings β€” ABSOLUTE when the command came - from a linked worktree. GIT_DIR overrides repository DISCOVERY, so `cwd=` - was advisory: the guard answered about whatever repository the environment - named rather than the directory it was ASKED about. That is a correctness - hole in both directions β€” a dirty tree could read clean, and a clean tree - could read dirty β€” and it silently invalidated the battery cases that - exercise this function, which were satisfied by reading the session's own - repository instead of their fixture (re-qualified after this fix; battery - c27/c28 pin both directions). - - The strip is by PREFIX, not a fixed list, so a git version that adds one - more repository-scoping variable cannot quietly re-open it. GIT_EXEC_PATH - is kept: it locates git's own helper programs, not a repository. - """ - return {k: v for k, v in os.environ.items() - if not k.startswith("GIT_") or k == "GIT_EXEC_PATH"} - - -class _NotARepo: - """Sentinel: git could not answer, and the directory is not a repository at - all β€” the ONE benign cause of an unanswered status. Distinct from None, - which now means the question was asked and went UNANSWERED (see r15).""" - __slots__ = () - - def __repr__(self) -> str: # pragma: no cover - diagnostics only - return "" - - -NOT_A_REPO = _NotARepo() - -# ── r15: THE STATUS QUERY IS PINNED AGAINST CONFIGURATION ────────────────── -# -# `git status --porcelain` is not a fixed question: it honours repository and -# user CONFIGURATION, and two settings can make it report a dirty tree as -# empty. An external referee (2026-08-23) reproduced the first on git 2.50.1: -# -# status.showUntrackedFiles=no β€” the documented remedy for a slow status on -# a large worktree. In a repo carrying an untracked `note.txt`: -# git status --porcelain -> '' -# git status --porcelain --untracked-files=normal -> '?? note.txt' -# The guard read '' and ALLOWED both a plain `git merge` and a `--autostash` -# one β€” reproducing verbatim the hole r13 narrowed `--autostash` to close, -# since untracked files are the whole reason that narrowing exists. -# -# diff.ignoreSubmodules=all (and submodule..ignore) β€” measured on the -# same git: a host repo whose submodule holds modified work reports -# ' M sub' by default, '' with the setting, and ' M sub' again with -# `--ignore-submodules=none`. Same class, same direction, found by auditing -# this call rather than by a referee. -# -# So the flags below are part of the QUESTION, not decoration: the reading a -# guard makes must not be reconfigurable by the thing it is guarding. Nothing -# else in either hook shells out to git β€” `root-of-trust-guard.py` is a purely -# textual scan β€” so this call is the whole of the exposure, and the GIT_* -# environment (`GIT_CONFIG_GLOBAL`, `GIT_CONFIG_COUNT`, `GIT_DIR`, …) is -# already stripped by `_repo_neutral_env()`. -# -# `--untracked-files=normal`, not `=all`: rule 2b asks whether ANY `??` entry -# exists, and `normal` already answers that (it collapses an untracked -# directory into one entry instead of listing its files, which is cheaper on a -# large tree and cannot change the emptiness of the answer). -_GIT_STATUS_CMD = ("git", "status", "--porcelain", - "--untracked-files=normal", "--ignore-submodules=none") - -# THE INVARIANT (r16), and it is not a style preference: -# -# the internal budget must be STRICTLY LESS than this hook's REGISTERED -# timeout in .claude/settings.json, with margin for the rest of the hook's -# work β€” BECAUSE A KILLED HOOK IS INDISTINGUISHABLE FROM AN ALLOW. -# -# A PreToolUse hook says "deny" by writing a decision to stdout. The harness -# kills it at its registered bound; a killed hook has written nothing, and -# nothing is what an allow looks like. So a deny branch that cannot RETURN -# inside the registration does not exist. -# -# That is what r15 shipped. `_STATUS_TIMEOUT_DEFAULT` was raised to 10 s so a -# slow worktree would not wedge a session, while the registration stayed at the -# 5 s it had been since before the branch. Measured at 7b8848d against a dirty -# fixture repo with a `git` shim that sleeps 30: run with no harness bound the -# hook took 10 s, exited 0, and emitted `"permissionDecision": "deny"`; run as -# the harness runs it, `timeout -s KILL 5`, it took 5 s, exited 137, and wrote -# ZERO BYTES. So on the exact case the r15 fix names β€” a large worktree on a -# cold cache, a network filesystem β€” the history op was ALLOWED over -# uncommitted work, and the deny message's own remedy (raise -# CLAUDE_GIT_STATUS_TIMEOUT) moved the user FURTHER from a deny. -# -# Both numbers now derive from `_HOOK_REGISTERED_TIMEOUT`, which states what -# .claude/settings.json registers, and `_status_timeout()` CLAMPS rather than -# honours: a caller who asks for 600 s gets the ceiling, not a killed hook. -# Battery case c54 fires this hook under the registered bound with a slow git -# and requires the deny to still come out; c55 pins the two numbers against -# settings.json itself, so raising one without the other goes red. -# -# At most ONE status is read per invocation β€” rule 0 refuses a history op that -# is not a standalone simple command BEFORE the tree is read β€” so the worst -# case really is one budget, not one per segment. -_HOOK_REGISTERED_TIMEOUT = 20.0 # MUST equal .claude/settings.json's "timeout" -_STATUS_TIMEOUT_MARGIN = 5.0 # json parse, tokenise, write the decision -_STATUS_TIMEOUT_CEILING = _HOOK_REGISTERED_TIMEOUT - _STATUS_TIMEOUT_MARGIN -_STATUS_TIMEOUT_DEFAULT = 10.0 -_STATUS_TIMEOUT_ENV = "CLAUDE_GIT_STATUS_TIMEOUT" - - -def _status_timeout() -> float: - """Seconds to wait for `git status`. Configurable because r15 made an - UNREADABLE status a DENY: on a genuinely slow worktree the bound is the - difference between a working guard and a blocked session, and 3 s (the old - hardcoded value) is reachable on a cold cache over a network filesystem. - - r16: CLAMPED to `_STATUS_TIMEOUT_CEILING`. A bound at or above the - registered timeout cannot produce a deny β€” the hook is killed first β€” so - honouring one would turn the escape hatch into a silent allow.""" - v = _STATUS_TIMEOUT_DEFAULT - try: - env = float(os.environ.get(_STATUS_TIMEOUT_ENV, "")) - if env > 0: - v = env - except (TypeError, ValueError): - pass - return min(v, _STATUS_TIMEOUT_CEILING) - - -def _within_git_repo(cwd: str | None) -> bool: - """Is there a `.git` entry at `cwd` or above it? Answered from the - FILESYSTEM, deliberately not by asking git again: the case this exists to - classify is precisely the one where git could not answer, so a second git - invocation would inherit the same failure and could be shimmed, broken or - slow in the same way. A `.git` FILE (a linked worktree) counts, like a - directory.""" - try: - cur = os.path.realpath(cwd or os.getcwd()) - except OSError: - return False - try: - while True: - if os.path.exists(os.path.join(cur, ".git")): - return True - parent = os.path.dirname(cur) - if parent == cur: - return False - cur = parent - except OSError: - return False - - -def read_status(cwd: str | None) -> "str | _NotARepo | None": - """The RAW porcelain status text, or one of two DIFFERENT non-answers: - - str β€” git answered; this is the tree state - NOT_A_REPO β€” git could not answer AND there is no repository here: - the one benign cause, which the caller may skip - None β€” the question went UNANSWERED (timeout, a non-zero exit - inside a real repository, git missing, any exception). - The caller must DENY on this; see r15 below. - - r13: this used to be `tree_is_dirty()`, returning a bool. The porcelain TEXT - is needed now, because `--autostash` is allowed only when there is no `??` - entry β€” a distinction a boolean cannot carry. Callers derive both answers - from the one reading, so a single subprocess still answers the whole rule. - - r15: it used to return None for all three cases alike, and the caller - treated that as "not a repository at all β†’ git's problem" and CONTINUED β€” - i.e. allowed. A `git status` that merely ran slowly was therefore a silent - ALLOW on a dirty tree, which is the same "allowing on an unanswered - question" the `-C` path was fixed for at r13 and this path was not. The two - causes are now distinguished, and only the benign one allows.""" - try: - # UTF-8, not the locale: on Windows the default is the ANSI code page, - # which cannot decode every name git prints with core.quotePath=off, - # and a decode error here is an unanswered question β€” a false deny. - r = subprocess.run(list(_GIT_STATUS_CMD), cwd=cwd or None, - env=_repo_neutral_env(), - capture_output=True, text=True, - encoding="utf-8", errors="replace", - timeout=_status_timeout()) - except Exception: - return None if _within_git_repo(cwd) else NOT_A_REPO - if r.returncode != 0: - return None if _within_git_repo(cwd) else NOT_A_REPO - return r.stdout - - -def _untracked(status: str) -> list[str]: - """The `??` lines of a porcelain status β€” the entries `git stash` (and - therefore `--autostash`) leaves exactly where they are.""" - return [ln for ln in status.splitlines() if ln.startswith("??")] - - -DIRTY_TREE_REMEDY = ( - "Run the cleaning step and the history op as SEPARATE commands β€” this guard " - "re-reads the real tree state on the second one, so `git stash push -u -m " - "\"\"` (or a commit) and THEN `git {op} ...` is allowed. " - "Note the `-u`: a plain `git stash push` does NOT stash untracked files, so " - "it can leave the tree dirty. Chaining the two in ONE command is refused on " - "purpose: this guard reads the tree BEFORE the command runs, so a chain " - "could dirty it again before git starts. " - "(Override: ALLOW_DIRTY_MERGE=1 in the session environment.)" -) - -STANDALONE_REMEDY = ( - "A history op is accepted only as a STANDALONE SIMPLE COMMAND, because this " - "guard reads `git status` at PreToolUse β€” BEFORE bash runs anything β€” so a " - "command line that can act before git starts (a chain, a pipeline, a " - "redirection, a substitution, a `cd`, an assignment) makes that reading " - "meaningless. Run `git {op} ...` on its own line, and whatever else the " - "command was doing as a separate call. " - "(Override: ALLOW_DIRTY_MERGE=1 in the session environment.)" -) - -# ── rule 0: is this command line a STANDALONE SIMPLE COMMAND? ────────────── -# -# The shell syntax that disqualifies it, found OUTSIDE quotes. This is the ONE -# place in this file that reads raw command TEXT for structure rather than for -# words, and it is deliberately over-inclusive: every character below can put a -# write, a directory change, or another command between the reading and the op. -# -# Quote-awareness is the whole difficulty. `'...'` suppresses everything; -# `"..."` still expands `$(...)` and backticks but not `|`, `&`, `;`, `<`, `>`. -# A `#` at a word boundary starts a COMMENT, which `_segments` already drops, so -# the scan stops there rather than scoring the prose after it. -_CD_WORDS = frozenset(("cd", "chdir", "pushd", "popd")) - - -def _unquoted_shell_syntax(cmd: str) -> list[str]: - """Shell syntax found outside quoting, in order of first appearance.""" - found: list[str] = [] - - def add(tok: str) -> None: - if tok not in found: - found.append(tok) - - i, n, prev_ws = 0, len(cmd), True - while i < n: - c = cmd[i] - if c == "\\": - i += 2 - prev_ws = False - continue - if c == "'": # single quotes: inert - j = cmd.find("'", i + 1) - i = n if j == -1 else j + 1 - prev_ws = False - continue - if c == '"': # double quotes: $( ` live - j = i + 1 - while j < n and cmd[j] != '"': - if cmd[j] == "\\": - j += 2 - continue - if cmd[j] == "`": - add("`") - elif cmd[j] == "$" and j + 1 < n and cmd[j + 1] == "(": - add("$(") - j += 1 - i = n if j >= n else j + 1 - prev_ws = False - continue - if c == "#" and prev_ws: # a comment: stop scanning - break - if c == "$" and i + 1 < n and cmd[i + 1] == "(": - add("$(") - i += 2 - prev_ws = False - continue - if c in "<>" and i + 1 < n and cmd[i + 1] == "(": # process substitution - add(c + "(") - i += 2 - prev_ws = False - continue - if c in "|&;<>()`": - add(c) - i += 1 - prev_ws = False - continue - prev_ws = c.isspace() - i += 1 - return found - - -def _standalone_violation(spliced: str, n_segments: int, - words: list[str], at: int, - selectors: list[str]) -> str | None: - """Why this command line is NOT a standalone simple history op, or None. - - `words`/`at` are the history-op segment's words and its subcommand index, as - `_git_segment` returned them; `selectors` are its repository-selector git - globals. The checks before the subcommand are where a wrapper, an assignment - or a `cd` would sit; after it, a word is an argument (a branch may legally be - called `cd`).""" - if n_segments != 1: - return ("the command line has more than one shell segment β€” a chain, a " - "pipeline or a grouping runs before git does") - syntax = _unquoted_shell_syntax(spliced) - if syntax: - return ("the command line carries unquoted shell syntax (" - + " ".join(syntax) + ") β€” a redirection, a substitution or " - "another command can write to the tree before git starts") - for w in words[:max(at, 0)]: - if w in _CD_WORDS: - return (f"the command line contains `{w}`, which changes which " - "repository the op runs in β€” a directory this guard never read") - m = _ASSIGN.match(w) - if m: - name = w.split("=", 1)[0] - if name.startswith("GIT_"): - return (f"the command line sets `{name}`, which selects a " - "DIFFERENT repository from the one this guard read " - "(git honours GIT_DIR / GIT_WORK_TREE with no -C and no cd)") - return (f"the command line carries a variable assignment (`{name}=`), " - "which can change which repository git operates on and how") - if selectors: - return ("the invocation carries " + " ".join(f"`{s}`" for s in selectors) - + ", which selects a DIFFERENT repository from the one this " - "guard would read β€” git honours it with no -C and no cd") - return None - - -_MSYS_DRIVE = re.compile(r"^/([A-Za-z])(?=/|$)") - - -def _resolve_dash_c(dash_c: list[str], default_cwd: str | None) -> str | None: - """Fold EVERY `-C ` on the invocation, in order, the way git folds - them β€” starting from the INVOCATION's directory. - - r9: the raw string was handed to `subprocess.run(cwd=…)`, which resolves a - relative path against the HOOK PROCESS's cwd β€” not the event cwd the same - function already treats as authoritative. With the two differing (the Bash - tool's cwd drifts; hooks are spawned by the CLI process), `git -C . pull` in - a dirty repo A was judged against whatever repo B the hook happened to sit - in, and went SILENT β€” using the exact spelling the docstring recommends as - the remedy for the `cd` residual. - - r15: the same defect one dimension over. Only the LAST `-C` reached here, - and it was resolved against the event cwd β€” so a TRAILING RELATIVE `-C` - discarded a leading absolute one, and `git -C -C . merge feature` - fired from a clean checkout read the CLEAN one and allowed, while git - merged in the dirty one. git's own rule, quoted in `_walk_git_globals` and - measured on 2.50.1, is a left-to-right fold: an ABSOLUTE value replaces the - accumulator, a RELATIVE one is appended to it, and an EMPTY one is a no-op - ("If is present but empty … the current working directory is left - unchanged"). - - r18: THE FOLD IS KERNEL-FAITHFUL, NOT LEXICAL. The left-to-right rule above - was right and the resolution under it was wrong: each step went through - `os.path.normpath`, which pops a `..` off the path as WRITTEN, while git - folds `-C` by `chdir()` and the kernel pops a `..` off the path RESOLVED β€” - the physical parent of a symlink's TARGET, not of the link. The two diverge - the moment a `..` follows a symlinked component, and this docstring's own - claim ("the directory git will chdir into") was false there. Measured at - e0c4cdb: from a CLEAN repo C holding a tracked symlink `link` -> D/sub, - where D is a second repository whose porcelain reads ' M sub/f.txt', - `git -C link/.. merge feature` resolved to C lexically and to D through the - kernel. The guard read C, said nothing, and real bash then fast-forwarded D - β€” leaving the uncommitted work sitting on top of merged history, which is - exactly what the clean-tree rule exists to refuse. `-C ./link/..`, - `-C link/../`, `-C link/../.` and the composed `-C . -C link/..` all reached - the same false ALLOW, while the byte-equivalent `-C ` DENIED. So the - verdict turned on whether the path was spelled through the symlink. - - CORRECTED at r19: this enumeration also listed the ATTACHED `-Clink/..`, and - it could never have reached that false ALLOW, because git does not accept an - attached `-C` at all. Measured on the same git this file's other - numbers come from (2.50.1, Apple Git-155): `git -C . status --porcelain` - exits 0, `git -C. status --porcelain` exits 129 with "unknown option: -C.". - Counting it inflated the demonstrated recall class by one spelling. - `_walk_git_globals` still parses the attached form and battery case c60 still - pins that it is denied β€” as a DEFENSIVE CONTROL over a spelling git itself - rejects, not as a reproduction of the defect. - - WHY RESOLVING ON DISK IS ACCEPTABLE HERE, and what it costs. The sibling - hook (`root-of-trust-guard.py`) deliberately resolves paths LEXICALLY: it - judges paths that need not exist yet, so its verdict must not depend on what - happens to be on disk at hook time. Its `_spellings()` walks TWO spellings - and takes the union of denials β€” but they are the token AS WRITTEN and the - token its `..` segments resolve to LEXICALLY. The KERNEL-resolved spelling, - which is what this divergence is about, is in NEITHER (r19 correction: this - paragraph used to claim the sibling "handles this same divergence", and it - does not β€” it models `..`, not a symlinked component, and only its project- - SCOPE half, `in_project`, touches the filesystem). Even the union it does - take is not available here β€” this guard must pick ONE directory and read the - tree in it, and a union of two readings is not a tree state. But the objection does not - apply either: rule 3's whole verdict is already a live `git status` run IN - this directory, so this branch depends on the filesystem at hook time by - construction, and a `-C` naming a directory that does not exist cannot be a - merge git will perform. The cost is the ordinary TOCTOU one the module - docstring already discloses for concurrent writers: if the symlink is - repointed between the hook and the exec, the reading was of the old target. - `os.path.realpath` also falls back to a lexical fold for components that do - not exist β€” there the resolved directory is not a repository, and the - unresolvable-`-C` branch in `dirty_tree_reason` DENIES rather than allowing. - - r21: A GIT BASH DRIVE PATH IS READ AS GIT.EXE RECEIVES IT. MSYS converts - `/c/Users/me/proj` to `C:/Users/me/proj` before it starts a native git.exe, - but Windows Python reads `/c/...` as a path on the CURRENT drive (or, on - 3.13+, as relative to the event cwd) β€” `C:\\c\\Users\\...`, which does not - exist. Measured 2026-09-27 under the ntpath simulation: `git -C /c/ - merge feature` on a CLEAN repo was DENIED as "names a repository this - guard cannot resolve" while `-C C:/` allowed. A false deny, not a - hole, but it refused ordinary Git Bash usage. Only on Windows: on POSIX - `/c/...` is a real path. Other MSYS roots (`/tmp`, `/usr`) still fail - closed; mapping those needs `cygpath`. - - Returns None when the fold cannot be anchored or cannot be computed; the - caller must treat that as an unanswered question, never as the event cwd.""" - cur = default_cwd - for raw in dash_c: - if not raw: # `-C ""` β€” git leaves cwd alone - continue - p = os.path.expanduser(raw) - if os.name == "nt": - m = _MSYS_DRIVE.match(p) # `/c/x` β†’ `C:/x`, as MSYS hands it on - if m: - p = f"{m.group(1).upper()}:{p[m.end():] or '/'}" - try: - if os.path.isabs(p): - cur = os.path.realpath(p) # absolute: replaces what came before - continue - if not cur: - return None # unanchored β†’ caller DENIES - cur = os.path.realpath(os.path.join(cur, p)) - except OSError: - return None # unresolvable β†’ caller DENIES - return cur - - -def dirty_tree_reason(cmd: str, event: dict) -> str | None: - if os.environ.get("ALLOW_DIRTY_MERGE", "") == "1": - return None - default_cwd = (event.get("cwd") - or os.environ.get("CLAUDE_PROJECT_DIR") or None) - # One live reading per working directory this command line names. There is - # NO state carried between segments β€” no clean marks, no stash effects, no - # inertness. A segment is looked at for exactly one thing: is it a git - # history op, and does it carry its own `-C`. - seen: dict[str, "str | _NotARepo | None"] = {} - - def read(cwd: str | None) -> "str | _NotARepo | None": - key = cwd or "" - if key not in seen: - seen[key] = read_status(cwd) - return seen[key] - - # Heredoc bodies are dropped FIRST (they are line-oriented: joining a body - # line that ends in `\` could hide the terminator), then continuations are - # spliced, so a multi-line command is tokenised as the one command bash runs. - # Carriage returns go before either, as Git Bash drops them (r21). - spliced = _join_continuations(_strip_heredocs(_drop_cr(cmd))) - segs = _segments(spliced) - for seg in segs: - sub, dash_c, at, words, selectors = _git_segment(seg) - if sub not in GIT_HISTORY_OPS: - continue # rule 1: not a history op β†’ silent - # Rule 0 (r13): STANDALONE OR NOTHING. Decided BEFORE the tree is read, - # because on a non-standalone line the reading proves nothing β€” the - # referee's `printf … >> analysis.R && git merge main` is clean here and - # dirty when git runs. This also subsumes the `cd` residual and both - # non-`-C` repository selectors. - shape = _standalone_violation(spliced, len(segs), words, at, selectors) - if shape: - return (f"git {sub} is refused here: {shape}. " - + STANDALONE_REMEDY.format(op=sub)) - # Rule 2a: the ESCAPE forms need no tree at all β€” decided from actual - # argument TOKENS, never from text anywhere in the segment. - if _op_is_exempt(words, at): - continue - # Rule 3: read the tree, live, in the directory THIS invocation names. - cwd = _resolve_dash_c(dash_c, default_cwd) - # r18: an invocation that CARRIES a `-C` the fold could not anchor or - # resolve must not fall through to `read(None)` β€” that reads the HOOK - # PROCESS's own directory, which is the r9 defect one dimension over. - # No reading is taken; the unresolvable-selector deny below is the answer. - status = None if (dash_c and cwd is None) else read(cwd) - if dash_c and not isinstance(status, str): - # r13 (referee finding 2): an explicit selector that cannot be - # resolved used to fall back to the EVENT cwd and allow if THAT was - # clean β€” which is allowing on the unanswered question. It denies. - spelling = " ".join(f"-C {p}" for p in dash_c) - return (f"git {sub} names a repository this guard cannot resolve " - f"(`{spelling}`): the path does not exist, holds an " - f"unexpanded variable, or is not a git repository. Rerun the " - f"{sub} from that repository, or spell the path literally. " - f"Reading a DIFFERENT repository and allowing because it is " - f"clean would be allowing on an unanswered question. " - f"(Override: ALLOW_DIRTY_MERGE=1 in the session environment.)") - if status is NOT_A_REPO: - continue # not a repository at all β†’ git's problem - if status is None: - # r15: THE UNANSWERED QUESTION, ON THE PATH WITH NO `-C`. This used - # to fall into the `continue` above on the stated ground that the - # only cause was "not a repository at all". It was not: `read_status` - # returned None for a TIMEOUT and a non-zero exit as well, so a - # `git status` that merely ran slowly β€” a large worktree on a cold - # cache, a network filesystem β€” was a silent ALLOW of a history op - # over uncommitted work. That is the same defect the `-C` branch - # above was fixed for at r13 and this branch was not. The two causes - # are separated in `read_status` now, and this one denies. - return (f"git {sub} is refused here: this guard could not determine " - f"the tree state β€” `git status` did not answer in " - f"{_status_timeout():g}s, exited non-zero, or could not be " - f"run, in a directory that IS a git repository. An " - f"unanswered question is not an allow. Check `git status` " - f"yourself, or raise the bound with " - f"{_STATUS_TIMEOUT_ENV}= in the session " - f"environment β€” capped at {_STATUS_TIMEOUT_CEILING:g}s, " - f"because this hook is registered with a " - f"{_HOOK_REGISTERED_TIMEOUT:g}s timeout and a hook killed " - f"before it answers is read as an ALLOW. " - f"(Override: ALLOW_DIRTY_MERGE=1 in the session environment.)") - # Rule 2b (r13): `--autostash` is git's own stash-operate-restore, but - # `git stash` does not stash UNTRACKED files β€” the referee reproduced a - # `--autostash` merge leaving `?? note.txt` untouched. So it is allowed - # only over tracked/staged dirt. - if _op_is_exempt(words, at, _GIT_OP_AUTOSTASH): - untracked = _untracked(status) - if not untracked: - continue - return (f"git {sub} --autostash does NOT establish a clean tree here: " - f"`git status --porcelain` reports {len(untracked)} untracked " - f"entr{'y' if len(untracked) == 1 else 'ies'} " - f"({', '.join(u[3:] for u in untracked[:3])}" - f"{', …' if len(untracked) > 3 else ''}), and git's autostash " - f"does not stash untracked files β€” they stay in the tree " - f"through the {sub}. " + DIRTY_TREE_REMEDY.format(op=sub)) - if not status.strip(): - continue # clean β†’ allow - return (f"git {sub} must start from a clean tree β€” `git status --porcelain` is " - f"non-empty, so this {sub} would resolve your uncommitted work together " - f"with the incoming changes and leave no way to tell them apart. " - + DIRTY_TREE_REMEDY.format(op=sub)) - return None - - -def main() -> int: - # The event is read as BYTES and decoded as UTF-8, which is what Claude Code - # writes. `json.load(sys.stdin)` decoded it with the Windows ANSI code page: - # an accented project path came out as mojibake, the event cwd named a - # directory that does not exist, and a merge over a dirty tree read as - # "not a repository" and was allowed (r21). - try: - data = json.loads(sys.stdin.buffer.read().decode("utf-8", "replace")) - except (json.JSONDecodeError, EOFError): - return 0 - - tool = data.get("tool_name", "") - ti = data.get("tool_input", {}) or {} - - if tool == "Bash": - cmd = ti.get("command", "") or "" - denied = git_deny_reason(cmd) - if denied: - deny(f"Blocked by git-guardrails: {denied}") - return 0 - dirty = dirty_tree_reason(cmd, data) - if dirty: - deny(f"Blocked by git-guardrails: {dirty}") - return 0 - - if tool in ("Write", "Edit", "MultiEdit"): - fp = ti.get("file_path", "") or "" - if Path(fp).suffix.lower() not in CODE_EXT: - return 0 - # Collect every string that becomes file content: Write.content, - # Edit.new_string, and EACH MultiEdit edit's new_string (a list). - candidates = [] - for k in ("content", "new_string"): - v = ti.get(k) - if isinstance(v, str): - candidates.append(v) - for e in ti.get("edits", []) or []: - v = (e or {}).get("new_string") - if isinstance(v, str): - candidates.append(v) - for content in candidates: - m = HARDCODED_PATH.search(content) - if m: - msg = (f"Hardcoded machine path '{m.group(0)}' in {Path(fp).name} " - f"breaks replication packages. Use here::here(), relative paths, " - f"or a config variable.") - if os.environ.get("CLAUDE_STRICT_PATHS", "") == "1": - deny(f"Blocked (CLAUDE_STRICT_PATHS=1): {msg}") - else: - sys.stderr.write(f"[git-guardrails] WARNING: {msg}\n") - break # one finding per call is enough - return 0 - - return 0 - - -if __name__ == "__main__": - try: - sys.exit(main()) - except Exception: - sys.exit(0) # fail open diff --git a/.claude/hooks/root-of-trust-guard.py b/.claude/hooks/root-of-trust-guard.py deleted file mode 100644 index 9cb701155..000000000 --- a/.claude/hooks/root-of-trust-guard.py +++ /dev/null @@ -1,2186 +0,0 @@ -#!/usr/bin/env python3 -r""" -Silent-Shell-Write Tripwire for the gate-defining files (PreToolUse, Bash only) - -NAMING, STATED UP FRONT. The file is called `root-of-trust-guard.py` and the -path set below is referred to as the "gate-defining files". It is NOT a root of -trust and this hook is NOT a guard in the sense that word usually carries. An -independent referee (2026-08-23) was right about this: a repo-local, mutable, -fail-open textual scan cannot be the thing everything else stands on. What it -actually is: - - a best-effort TRIPWIRE against SILENT shell writes into the files that - decide whether any other gate runs. - -Its whole value is a change of CHANNEL, not a prohibition: a modification to a -gate arrives as a reviewable Edit/Write diff instead of an invisible one-line -overwrite. It does not lock the files, and it cannot. - -WHAT CAN REPLACE OR DISABLE IT ANYWAY β€” all four are ordinary, allowed, and -none of them trips this hook: - - 1. an Edit / Write / MultiEdit on these same files (deliberately allowed β€” - see below). That includes an edit removing this hook's registration - from `.claude/settings.json`. The diff is visible AFTERWARDS; under - `bypassPermissions` nothing authorises it BEFOREHAND. - 2. an ordinary branch transition β€” `git switch ` / `git checkout - ` with no pathspec β€” onto a branch whose tree deletes - `.claude/hooks/` or carries an older `.claude/settings.json`. There is - no protected literal path on that command line to test. - 3. a clean merge or pull that imports changes to these files, for the same - reason. - 4. an error in THIS file. It fails open (see "Decision protocol"), so an - allowed edit that introduces a syntax error here silently converts the - tripwire into a no-op. - -So read every sentence below as "raises the cost of one silent path", never as -"cannot be disabled". - -The template ships broad `allow` rules with an empty `deny` list, and many -users run bypass from their user settings (a project-level bypass default is -ignored by current Claude Code), so the hook layer is the only thing watching -the files that decide whether any other gate runs at all: - - .claude/settings.json β€” which hooks fire, and on what - .claude/settings.local.json β€” the same, per machine - .claude/hooks/ β€” the gates themselves - .githooks/ β€” the pre-commit gate suite - .claude/ β€” the directory, because deleting it - deletes the hooks - -This hook denies SHELL writes into those paths β€” the silent path, where one -redirection disables the whole suite and nothing surfaces in review until much -later. - -SCOPE β€” THIS PROJECT, not every tree on the machine. A path is protected -only when it matches the pattern above AND resolves inside the project -directory (CLAUDE_PROJECT_DIR, else the git repository the call runs in, -else the repository this file ships in). A fixture clone under /tmp, a -second checkout, and the user's own `~/.claude/settings.json` are other -trees: denying writes there protected none of the gates this repo runs, -while it DID block the qualification ledger's own gate-9 reproduction, -which seeds a mistyped hook path into a fixture clone's settings file. The -deny message names the project directory it is speaking for, so the claim -"this repository's gate-defining files" is checkable rather than asserted. If -no project directory can be resolved at all, the guard falls back to the plain -text match and says so in the message. "Inside" is decided on the path AS -NAMED and AS RESOLVED (either counts), without regard to case or Unicode form, -and by file identity when the strings disagree (#171; `in_project` has the -measurements). - -Denied, when the target is a protected path: - - - redirection `> f`, `>> f`, `&> f`, `&>> f`, `>| f`, `>& f` β€” - `&>>` is bash's APPEND-both form and `>&` its - csh-style redirect-both, identical in effect to - `&> f` whenever f is not a file descriptor. Both - are carried by `_TOKEN` and by the `_REDIR` - backstop; this list omitted `&>>` until r19, so it - under-reported what the regexes already caught. - FILE-DESCRIPTOR DUPLICATION is NOT a write and - stays allowed: `>&2`, `1>&2`, `2>&1`, `>&-` name a - descriptor, not a path - - tee `... | tee f` - - copy / link cp, install, rsync, scp, ln (DESTINATION only β€” - copying a hook out is a read) - - move / delete mv, rm, unlink, shred (ANY argument β€” moving a - hook away disables it as surely as deleting it) - - in-place edit sed -i, perl -i, truncate, dd of= - - git working-tree write `git rm`, `git checkout [] -- `, - `git restore `, `git clean -f `, - `git stash push -- `, `git mv` β€” git is a - writer program too, and these modify or delete the - gate-defining files exactly as rm/cp/mv do, with the - protected path as a literal argument. Read-only git - (status, log, diff, show, ls-files, rev-parse) is - untouched; a protected `-C ` counts only when - the subcommand is one of the writers above - - find deleter find ... -delete / -exec rm|mv|... β€” - a plain shell deleter of the same class as rm - - tar extraction tar -x ... -C β€” extraction WRITES - files into the destination directory - - shell wrapper sh/bash/zsh/dash/ksh -c '' β€” the -c - payload is UNWRAPPED and re-scanned (depth <= 2), - so a redirection or deleter hidden inside it is - caught rather than swallowed by the tokenizer. The - shell's OWN options are parsed (long options never - count as `-c`; `--rcfile`/`--init-file`/`-o` are - consumed WITH their values), so a value-taking - option cannot make the guard read the wrong word as - the payload and skip the real one - - command wrapper env / sudo / doas / nice / stdbuf / ionice / - timeout / nohup / time / command / exec β€” the - wrapper AND its option arguments (`env -i`, - `env -u FOO`, `nice -n 10`, `sudo -u root`, - `stdbuf -oL`, `ionice -c2`, `timeout 5`) are - consumed so the REAL command behind them β€” a shell - `-c` OR a bare deleter (`nice rm .claude/...`) β€” is - reached and the -c-unwrap / deny logic applies to it - - env split-string env -S '' / env --split-string='' - (the attached `-S`/`--split-string=` forms, - AND the bundled short-flag forms `env -vS`/`env -iS`) - make env SPLIT the string into words and EXECUTE - them, so it is a command-payload carrier exactly like - a shell `-c`. env is located through the SAME - wrapper-skip as everything else, so `nice env -S …` - / `timeout 5 env -S …` reach it; the payload is then - UNWRAPPED and re-scanned (depth <= 2), not skipped as - an opaque value - -Denied REGARDLESS of any protected path β€” the cross-hook rule: - - - destructive git inside an UNWRAPPED PAYLOAD. `bash -c 'git reset --hard'` - and `bash -c 'git clean -fdx'` used to fall between the two hooks and be - denied by neither: this hook's rules all key off a protected LITERAL path - and there is none, while `git-guardrails.py` treats a shell-wrapper - payload as one opaque word. Ordinary execution forms, not evasions. So - the payload this hook already unwraps for its own path rules (shell `-c`, - `env -S`, behind the usual wrappers, depth <= 2) is ALSO handed to - `git-guardrails.py`'s own `git_deny_reason()` β€” the SHARED destructive-git - deny list (`reset --hard`, `clean -f`, `push --force`, `add -A`, - `checkout -- .`, `restore .`), imported rather than copied, so the two - hooks cannot drift into disagreeing about what is destructive. The - unwrapped payload is what is re-scanned, so the direct (unwrapped) - spellings are untouched here and stay `git-guardrails.py`'s to decide β€” - no double denial, no second opinion. - LIMIT, disclosed: only the DENY LIST is shared. The dirty-tree check on - merge/rebase/pull is NOT applied to wrapped payloads, so - `bash -c 'git merge main'` on a dirty tree is still seen by neither hook. - The override for the shared rules stays git-guardrails' own (run it in a - terminal yourself), not this hook's ALLOW_ROOT_OF_TRUST_WRITE. - -Allowed, deliberately: - - - every READ β€” cat, grep, ls, head, diff, shasum, and running a hook - (`python3 .claude/hooks/.py`). - - Edit / Write / MultiEdit on the same files. Those arrive as a diff the - user can read; a shell one-liner does not. This guard closes the - silent path, it does not lock the files. - -When a command is ambiguous the guard ALLOWS it. A false deny that makes -the repository unusable costs more than the narrow case it would have -caught, so every rule keys off a command that can only be writing. - -WHAT THIS TRIPWIRE IS β€” best-effort defense-in-depth, NOT a proof, and NOT a -control that can stop a change. It is a textual scan, not a sandbox. Its single -claim is about CHANNEL: a shell one-liner that would have overwritten a gate -silently gets denied, so the change has to come back through Edit/Write, where -it lands as a diff. Whether anyone READS that diff is outside this file. - -Be precise about what is not here. In the shipped configuration there may be no -protective control elsewhere at all: - - the reviewable Edit/Write diff is RETROSPECTIVE VISIBILITY, not - authorisation. It tells you afterwards; it stops nothing. - - `bypassPermissions` being a deliberate operator posture is a fact about how - the repo is configured. It is not a control. - - a machine-wide guard in `~/.claude/hooks/` is OPTIONAL and lives outside - this repository β€” present on the maintainer's machine, absent in a fresh - fork unless the forker installs one. -A real control would have to sit OUTSIDE the worktree β€” the ordinary permission -system with a non-empty `deny` list, or a machine-level hook β€” and would have -to cover Edit/Write/MultiEdit and branch transitions too. If you want one, that -is where to put it; this file cannot be it. - -`git-guardrails.py` is a sibling of the same kind and carries the same -limitation. Neither hook's audit trail RECOVERS anything: the transcript records -that a loss happened, and `git reflog`/`ORIG_HEAD` recover a moved COMMIT -POINTER β€” neither holds the bytes of an uncommitted edit, an untracked file, or -an ignored one. `git reset --hard` and `git clean -fdx` destroy exactly those. -Call these an audit trail and a commit-history recovery aid; do not call them a -backstop. - -WHAT IT CATCHES: direct writes and deletes (redirection, tee, cp/mv/rm/ln, -sed -i, dd, find -delete, tar -x, and GIT'S OWN working-tree writers β€” -`git rm/checkout -- /restore/clean/stash push -- /mv`) whose protected-path -target appears as a literal argument, and the two known command-payload -carriers β€” a shell -`sh/bash/… -c ''` and `env -S/--split-string`/`-vS` β€” reached after -the known command wrappers (env/sudo/nice/stdbuf/timeout/…). Both carriers are -unwrapped and re-scanned (depth <= 2). A command formatted across several lines -with BACKSLASH-NEWLINE continuations is spliced before tokenising (r12), so the -multi-line spelling of a write is read as the one command bash will run β€” it -used to split into two segments and go silent. A protected path spelled with a -shell GLOB or a BRACE GROUP is caught since r15 (`rm -f .claude/hook?/…`, -`.claude/*/…`, `.clau*/hooks/…`, `settings.jso?`, `hook[s]`, `{hooks,rules}`, -`.githook?/…`): the token's BRACE GROUPS are expanded into their alternatives, -and then each path SEGMENT is compared against the protected names with -`fnmatch`. That test is LEXICAL β€” no `glob`, no `stat`, no filesystem call -anywhere in it (r13 doc fix; `glob` is not even imported) β€” so the verdict never -depends on what happens to exist when the hook runs, and a pattern that would -expand to nothing here is matched all the same. Before r15 every one of those -spellings was ALLOWED while its literal twin was denied β€” a one-character edit -flipped the verdict. The csh-style `>&` redirection joined the caught -set at r18, for the same reason and with the same measurement: it is `&>` by -another name, it truncated `.claude/settings.json` in a throwaway fixture while -`&>`/`>`/`>|` all denied, and it was missing from BOTH redirection surfaces β€” -the tokenizer split it into `>` plus the SEPARATOR `&`, and the backstop's -target class excludes `&`. Descriptor duplication (`>&2`, `1>&2`, `2>&1`, `>&-`) -is excluded from it and stays allowed. ORDINARY QUOTING PLACED INSIDE THE -PROTECTED NAME joined the caught set at r20 β€” `.clau\de/…`, `.cl'aud'e/…`, -`.cl"aud"e/…`, `.githook\s/…`, and the same split across a line continuation. -The scan half always read these correctly; what silenced them was the literal -substring test in front of it, for the third time (`_may_name_a_protected_path` -has the measurements). The trigger is now taken on the line with `\`, `'` and -`"` deleted as well as as written β€” those three are the whole set of QUOTING -characters that can sit inside a word without changing which file it names (a -variable or a substitution can too, and both are disclosed as residual below) β€” -and the -continuation splice was corrected to DELETE the backslash-newline the way the -shell does rather than substitute a space for it. The WINDOWS SPELLINGS joined -the caught set at #171 (2026-09-27), measured under an ntpath simulation of -this file because Git Bash is the Bash tool's shell there: a BACKSLASH as the -separator (`'.claude\hooks'`, `C:\…\.claude\hooks` β€” read so only where it is -one, not on macOS or Linux), an NTFS default-stream suffix -(`settings.json::$DATA`), a Git Bash DRIVE path (`/c/…`, `/proc/cygdrive/c/…`), -two `-C` values composed with `\` (#151), a program named by its real file -name (`rm.exe`, `GIT.EXE`), a CR inside a word (MSYS bash deletes -it), and an event decoded with the ANSI code page instead of UTF-8. So did two -spellings that are not Windows-only: a name APFS folds and `lower()` does not -(`hookΕΏ`), and a project directory spelled in another case, another Unicode -form, or through a link β€” including a `.claude` that is itself a link out of -the tree. - -WHAT IT DOES NOT CATCH β€” disclosed residual, in scope for a future audit as -BOUNDARY, not as a defect: - - a write performed by an INTERPRETER whose program text this scan cannot - read into β€” `python3 -c '...open(path,"w")...'`, `perl -e`, `node -e`, - `ruby -e`, an `awk` program. These are execution forms, not command lines - the tokenizer can see a literal path in. - - a protected path fed to a deleter through a PIPE β€” `... | xargs rm`, - `... | xargs -0 truncate` β€” where the path never appears as a literal - argument on a command line this scanner sees. - - a protected path produced by a COMMAND SUBSTITUTION rather than written as - a literal β€” `echo X > $(echo .claude/settings.json)`, and the same write - spelled with backticks, both measured ALLOWED (silent) on 2026-08-24 in a - throwaway project fixture while the literal spelling DENIED. The - substitution's OUTPUT is the path, and this scan reads command TEXT, so the - token it sees is `$(echo` β€” a word naming nothing protected. Neither the - tokenizer path nor the `_REDIR` backstop has a literal to test. This is - disclosed rather than - half-built: recursing into a substitution would mean predicting what an - arbitrary command prints, which is the shell simulation `git-guardrails.py` - tore out at r8 for making the guard's own complexity the leak. The sibling - hook already discloses the same class for its history ops. - - a VARIABLE OR SUBSTITUTION SITTING INSIDE A PROTECTED SEGMENT'S NAME, which - is a NARROWER residual than `in_project`'s docstring may suggest and is - disclosed here so the two are not confused. That function says an - unresolvable token "fails toward DENYING", and it does β€” for the SCOPE half, - which is the only half it speaks for. Measured 2026-08-24 in a throwaway - project fixture: `rm -f $UNSET/.claude/hooks/…` DENIES, exactly as that - sentence implies, because the segment `.claude` is still there to match. - But `rm -f .clau${NOPE}de/hooks/…` and `rm -f .clau$(echo d)e/hooks/…` are - both ALLOWED (silent), because the PATTERN half never sees a protected name - at all β€” bash expands `.clau${NOPE}de` to `.claude` (an unset variable - expands to nothing), while this scan reads the token as written. Position, - not presence, decides: a variable BEFORE the protected segment leaves the - segment intact and is caught; one INSIDE it destroys the only thing the - pattern half matches on. Closing it means expanding variables and running - substitutions β€” the shell simulation this pair deliberately refuses. - - a git write whose target is NOT a literal path operand β€” `git apply ` - (the paths live inside the patch), `git reset --hard`, `git merge`, - `git checkout ` with no pathspec. These rewrite the tree from - recorded state rather than from a path on this command line, so this scan - has no literal to test. For the DIRECT spellings `git-guardrails.py` is the - layer that governs them; for the spellings hidden in a shell/`env -S` - payload β€” which git-guardrails treats as one opaque word β€” the cross-hook - rule above hands the unwrapped payload to its deny list, and that covers - the deny list ONLY. Still governed by NEITHER hook, in any spelling: `git - apply`, and a branch-switching `git checkout`/`git switch` with no - pathspec β€” the second is one of the four ways, listed at the top, that the - gate files get replaced without this hook seeing anything. - - a write ROUTED THROUGH A SYMLINK whose own name is not protected. The - pattern half is purely lexical β€” `_spellings()` walks the token as written - and the token its `..` segments resolve to LEXICALLY, and neither is the - path the kernel lands on β€” so a link named anything else that points at a - gate-defining directory is invisible to it. Measured 2026-08-24 in a - throwaway project fixture, with `linkhooks -> .claude/hooks` and - `linkdot -> .claude`: `rm -f linkhooks/git-guardrails.py`, - `rm -f linkdot/hooks/git-guardrails.py`, `rm -f .claude/linkin/…` (the link - inside `.claude` itself) and `echo X > linkdot/settings.json` were all - ALLOWED (silent), while the literal twin `rm -f .claude/hooks/…` DENIED. - Only the SCOPE half (`in_project`) resolves anything on disk, and it decides - which tree a path is in, never whether it is protected. Recorded here as an - uncovered class at r19; nothing in this file closes it, and the r16 `..` - walk deliberately does not try to (resolving in place would LOSE denials - that stand today β€” see the block above `resolve_parents`). The OPPOSITE - case is closed since #171: a link that CARRIES the protected name (`.claude` - itself linked to a shared config directory) used to resolve outside the - project and be allowed; the path as named now counts. - - a `cd` EARLIER IN THE SAME COMMAND LINE. Relative tokens resolve against - the tool call's cwd, not against a `cd` inside the command, so - `cd /tmp/fixture && printf '{}' > .claude/settings.json` is scored against - the project β€” it is DENIED though it writes elsewhere. That is the - conservative direction (a false deny, remedied by using the absolute path), - and it is the same residual `git-guardrails.py` discloses for `cd`. - - a path built from a variable this process cannot expand (`$FIXTURE/.claude/ - settings.json` with FIXTURE unset in the hook's environment). It stays - relative, resolves against the project, and is denied β€” again failing - toward the deny. - - an UNKNOWN wrapper or execution form not in the WRAPPERS/SHELLS tables, or - an unlisted wrapper option that takes a SEPARATE value (assumed here to - attach its value, which skips too LITTLE β€” a real command word is still - scanned β€” rather than too much, so it fails toward catching the write). - (A CASE-VARIED or `.exe` spelling of a LISTED wrapper or shell β€” `NICE rm - …`, `BASH -c …`, `bash.exe -c …` β€” was in this bullet until #171; every - table lookup now casefolds the name and drops `.exe`, see `_prog`.) - - WINDOWS, beyond the spellings closed at #171, none of it measured on a real - Windows machine (the #171 work used an ntpath simulation): - * the PowerShell tool, which NOTHING in this repository guards. This hook - is registered for `Bash` and returns at once for any other tool, so on - native Windows a PowerShell `Remove-Item .claude\hooks -Recurse` is - never seen here at all, and `.claude/settings.json` has no rule for - that tool (its `deny` list is empty). In default mode the only barrier - is the permission prompt; bypass and auto mode, the modes sessions here - often run in, do not show it. - * a DRIVE-RELATIVE path (`C:.claude\hooks`): its first segment reads as - `C:.claude`, which is not a protected name. - * Win32 NAME ALIASING β€” an 8.3 short name (`CLAUDE~1`, `GITHOO~1`), a - trailing dot or space that Win32 strips from a component β€” and NTFS's - own upcase table, which is not Unicode casefold (the dotless `Δ±` - uppercases to `I`, so `settΔ±ngs.json` may name `settings.json` there - while casefold leaves it alone) β€” and a stream suffix on a directory in - the MIDDLE of a path (`.claude::$INDEX_ALLOCATION\hooks`; the suffix on - the LAST segment is read, see `matches_root_of_trust`). Each would be a - name this lexical scan does not recognise; none has been probed. - - a GLOB whose expansion depends on a shell OPTION this scan does not model - (r15, the residual left by the glob fix). A bare wildcard segment does not - match a DOT-name because of an EXPLICIT rule in `_segment_matches` β€” a - segment carrying no literal character of its own (`*`, `?`, `[a-z]*`) is - refused against `.claude`/`.githooks` β€” not because of any `glob` module - semantic, since nothing here expands anything. That rule mirrors bash - WITHOUT `dotglob`, so with `dotglob` actually set `rm -rf *` really would - take `.claude` with it and is allowed here; and the rule is load-bearing β€” - delete it as "redundant" and every `rm -f build/*` starts being denied. - `extglob` forms (`@(a|b)`, `!(x)`, `+(…)`) are not modelled either. What - the fix does close is the ordinary case: a glob whose literal characters - already name a protected segment is denied whether or not anything matching - it exists. (Expanding for REAL β€” `glob.glob` against the event's cwd β€” was - implemented first and DROPPED: it made the verdict depend on what happened - to exist at hook time, could not see the not-yet-created - `> .claude/settings.jso?` case, added filesystem work to every command - carrying a `*`, and handed a pathological pattern like `/*/*/*/*` a way to - stall the hook. The inline note above `_segment_matches` keeps the full - reasoning. The cwd residual is real but belongs to PATH RESOLUTION, not to - globbing β€” it is the `cd` bullet above.) - - ANY SPELLING THAT PUTS A PROTECTED PATH SOMEWHERE THIS SCAN DOES NOT LOOK. - The bullets above are the set known on 2026-08-24, extended on 2026-09-27. - They record where this scanner has been PROBED; they do not establish where - it is complete, and reading the list as a closed enumeration is the - mistake. Seven consecutive rounds each turned up a spelling every earlier - round had missed β€” globs (r15), `..` segments (r16), a shell COMMENT naming - a heredoc (r17), `>&` (r18), the `$'…'` openers and case (r19), quoting - inside the protected NAME itself (r20), and the Windows separators, drive - paths, `.exe` names, CRs, code pages, Unicode fold and project spellings - (#171) β€” and every one of them - failed toward ALLOW while its literal twin denied. Those are closed; the - CLASS is not. Assume there are more, and that the next one also looks - ordinary. -These are not closed, and nothing here closes them. The honest statement is that -they are UNCOVERED: the Edit/Write channel makes some of them visible after the -fact, which is not the same as covering them. - -Decision protocol (modern PreToolUse): exit 0 + JSON - {"hookSpecificOutput": {"hookEventName": "PreToolUse", - "permissionDecision": "deny", "permissionDecisionReason": "..."}}. -Fail-open: any error β€” malformed event, unparseable command, an exception -in this file β€” exits 0 with no decision (allow). A guard that hard-fails on -its own bug takes the session down with it; failing open loses coverage for -that one call, which is the cheaper failure for a best-effort deny. That is -also why the event is read as UTF-8 BYTES with replacement (#171): a byte the -machine's code page could not decode used to be exactly such an error, and so -an allow. - -Escape hatch: set ALLOW_ROOT_OF_TRUST_WRITE=1 in the ENVIRONMENT OF THE -SESSION (`ALLOW_ROOT_OF_TRUST_WRITE=1 claude`, or export it in the shell -that launches Claude Code). Prefixing the blocked Bash command with it does -not work and is not meant to: hooks run as separate processes, so the hatch -has to be a deliberate act of the person starting the session. -""" - -from __future__ import annotations - -import fnmatch -import json -import os -import re -import sys -import unicodedata - -# --- protected paths ------------------------------------------------------- - -# Children of `.claude/` that are gate-defining. Everything else under -# `.claude/` (rules, skills, references, agents) is ordinary content. -PROTECTED_UNDER_CLAUDE = {"settings.json", "settings.local.json", "hooks"} - -_SLASHES = re.compile(r"/{2,}") - - -def normalize(path: str) -> str: - """Collapse separator noise so `.claude//hooks` and `.claude/./hooks` - match the same rule as `.claude/hooks`. - - r19 β€” CASE IS FOLDED, and this docstring used to say the opposite - ("Case-sensitive by design"), which was false the moment the repository sat - on a case-insensitive filesystem. It does: APFS is the macOS default (HFS+ - before it), so `.CLAUDE/settings.json` and `.claude/settings.json` are ONE - file. Measured 2026-08-24 against the shipped guard on a throwaway project - fixture, `echo X > .CLAUDE/settings.json` was ALLOWED (silent) while its - lowercase twin DENIED. The comparison itself lives in `_segment_matches`; - this function only collapses separators. The fold is UNCONDITIONAL β€” no - probe of the filesystem β€” by the same ruling the r15 glob fix made: the - verdict must not depend on what happens to exist at hook time. Its cost is a - false DENY on a case-SENSITIVE filesystem for a directory literally spelled - `.CLAUDE` that is not the root of trust. That is the direction this guard - errs in on purpose.""" - p = _SLASHES.sub("/", path) - while "/./" in p: - p = p.replace("/./", "/") - while p.startswith("./"): - p = p[2:] - return p - - -# --- PARENT-DIRECTORY (`..`) SEGMENTS (r16) -------------------------------- -# -# `normalize()` collapsed `//` and `/./` but NOT `/../`, and the segment walk -# below then read the segments AS WRITTEN. So an ordinary relative path that -# climbs back into a protected directory through an UNPROTECTED sibling was not -# recognised, while the shell resolved it to exactly the gate-defining file. -# Measured at 7b8848d, event cwd = the project, guard verdict vs. what the -# shell really does: -# -# rm -f .claude/rules/../hooks/git-guardrails.py SILENT -> file deleted -# echo x > .claude/rules/../settings.json SILENT -> file rewritten -# rm -f .claude/agents/../settings.local.json SILENT -# -# while the literal twins of all three DENIED. `..` was on NEITHER disclosed- -# residual list, and like the r15 glob class it failed toward ALLOW. Any real -# unprotected child of `.claude/` works as the pivot (rules, agents, skills, -# references, state); `.claude/../.claude/hooks/x` already denied, because -# there a second protected segment survives literally. -# -# RESOLUTION IS PURELY LEXICAL β€” no filesystem call, by the same choice the -# glob comment above records: the verdict must not depend on what happens to -# exist at hook time, and `> .claude/rules/../settings.jso?` names a file that -# need not exist yet. `in_project()` is the one half that touches disk, and it -# already realpaths the token, so the SCOPE half needs no change. -# -# BOTH SPELLINGS ARE TESTED, the token as written and the token `..` resolves -# it to (`_spellings`). Resolving in place would be strictly weaker: lexical -# and kernel resolution DIVERGE when a segment is a symlink (the kernel's `..` -# is the physical parent of the link TARGET), so a token that literally names -# `.claude/hooks` must keep being judged as naming it. The union can only add -# denials, never remove one that stands today. -def resolve_parents(path: str) -> str: - """Resolve `..` segments LEXICALLY, the way the shell prints a path. - - A `..` pops the segment before it (`.claude/rules/..` -> `.claude`). Two - edge cases decide the rest: - - * a LEADING `..`, or one that would climb above the start of a RELATIVE - token, has nothing to pop and is KEPT (`../.claude/hooks/x` stays - `../.claude/hooks/x`, which still names the protected directory); - under an ABSOLUTE token it is dropped, because POSIX defines `/..` - as `/`. - * a TRAILING `..` is an ordinary pop (`.claude/hooks/..` -> `.claude`, - which the walk below still recognises as the directory itself). - """ - lead = "/" if path.startswith("/") else "" - out: list[str] = [] - for seg in path.split("/"): - if seg in ("", "."): - continue - if seg != "..": - out.append(seg) - elif out and out[-1] != "..": - out.pop() - elif not lead: - out.append("..") # climbs above the base: keep it as written - return lead + "/".join(out) - - -def _spellings(path: str) -> list[str]: - """The path with separator noise collapsed, plus β€” when they differ β€” the - path its `..` segments resolve to. Both are walked; see the block above.""" - written = normalize(path) - landed = resolve_parents(written) - return [written] if landed == written else [written, landed] - - -# --- shell GLOBS and BRACE GROUPS (r15) ------------------------------------ -# -# Until r15 the segment test below was a LITERAL string comparison, so one -# ordinary metacharacter took a protected path out of the guard's sight while -# the shell expanded it to exactly the protected file. Measured at 7eee6b2, -# event cwd = the project, guard verdict vs. what the shell expands to: -# -# rm -f .claude/hook?/git-guardrails.py ALLOW -> .claude/hooks/git-guardrails.py -# rm -f .claude/*/git-guardrails.py ALLOW -> .claude/hooks/git-guardrails.py -# rm -f .clau*/hooks/git-guardrails.py ALLOW -> .claude/hooks/git-guardrails.py -# echo x > .claude/settings.jso? ALLOW -> .claude/settings.json -# rm -rf .claude/hook[s] ALLOW -# rm -f .claude/{hooks,rules}/… ALLOW -> .claude/hooks/… -# rm -f .githook?/pre-commit ALLOW -> .githooks/pre-commit -# -# while the literal spellings of all three targets DENIED. Globbing was on -# NEITHER disclosed-residual list, and unlike every item that is on them it -# failed toward ALLOW. Tab completion and shorthand globs are how people spell -# paths; this was ordinary usage, not an evasion. -# -# THE TEST IS TEXTUAL, not a real expansion, and that is a choice: a segment -# matches a protected NAME when `fnmatch` says the shell's expansion could -# produce it. Expanding for real (`glob.glob` against the event cwd) was tried -# first and buys nothing this does not β€” see the dotfile rule below β€” while it -# would make the verdict depend on what happens to exist at hook time, add -# filesystem work to every command carrying a `*`, and hand a pathological -# pattern (`/*/*/*/*`) a way to stall the hook. Text also covers the file that -# does not exist YET, which is exactly the `> .claude/settings.jso?` case. -# -# THE DOTFILE RULE is what keeps this from denying everything. A bare wildcard -# segment (`*`, `?`, `[a-z]*` β€” no literal character of its own) fnmatches -# every name, so without a rule `rm -f build/*` would be DENIED. It must not -# be: bash without `dotglob`, and Python's glob likewise, never expand a bare -# wildcard to a DOTFILE, so `*` cannot become `.claude` or `.githooks`. So a -# segment must carry a literal character to match a DOT-name, while against -# the ordinary names under `.claude/` (`hooks`, `settings.json`, -# `settings.local.json`) a bare wildcard matches β€” because there it really -# does expand: `rm -rf .claude/*` deletes the hooks. `dotglob` being set is -# the residual, disclosed in the docstring. -_GLOB_META = re.compile(r"[*?\[]") -_BRACE_LIMIT = 64 # bound the expansion of nested/ganged brace groups - - -def _brace_expand(token: str) -> list[str]: - """Expand shell BRACE GROUPS: `.claude/{hooks,rules}/x` -> two tokens. - Braces are not globs β€” the shell emits every alternative whether or not it - exists β€” so each alternative is judged as its own literal token.""" - out = [token] - for _ in range(8): # bounded nesting - nxt: list[str] = [] - changed = False - for t in out: - i = t.find("{") - j = t.find("}", i + 1) if i != -1 else -1 - if i == -1 or j == -1 or "," not in t[i + 1:j]: - nxt.append(t) - continue - changed = True - head, body, tail = t[:i], t[i + 1:j], t[j + 1:] - for alt in body.split(","): - nxt.append(head + alt + tail) - out = nxt[:_BRACE_LIMIT] - if not changed: - break - return out - - -def _glob_literal(seg: str) -> str: - """The characters of a segment that a glob CANNOT vary β€” metacharacters and - whole `[...]` classes removed. `hook?` -> `hook`, `.clau*` -> `.clau`, - `hook[s]` -> `hook`, `*` -> `` (nothing literal at all).""" - out: list[str] = [] - i, n = 0, len(seg) - while i < n: - c = seg[i] - if c == "[": - j = seg.find("]", i + 1) - i = n if j == -1 else j + 1 - continue - if c in "*?": - i += 1 - continue - out.append(c) - i += 1 - return "".join(out) - - -def _segment_matches(seg: str, name: str) -> bool: - """Does this path SEGMENT spell a protected NAME β€” literally, or as a glob - the shell could expand to it? A segment with no literal character of its - own matches only a NON-dot name: see the dotfile rule above. - - r19 β€” THE COMPARISON IS CASE-INSENSITIVE, UNCONDITIONALLY. See `normalize` - for the measurement and for why the fold is not conditioned on a filesystem - probe. `fnmatchcase` is kept rather than `fnmatch` (which would defer to - `os.path.normcase`, an identity function on POSIX): both sides are folded - here, so the fold is this file's own rule and not the platform's. - - #171 β€” THE FOLD IS UNICODE `casefold()`, NOT `lower()`. APFS folds case the - Unicode way, and U+017F (LONG S) aliases `s` there while `'ΕΏ'.lower()` is - still `ΕΏ`. Measured 2026-09-27 in a throwaway fixture: `rm -rf .claude/hookΕΏ` - and `echo X > .claude/settingΕΏ.json` were ALLOWED (silent) and really - deleted / rewrote the protected files. Every code point that aliased an - ASCII letter on that volume was enumerated: only U+017F and the Kelvin sign - U+212A, and `casefold()` maps both.""" - seg_l, name_l = seg.casefold(), name.casefold() - if seg_l == name_l: - return True - if not _GLOB_META.search(seg): - return False - if name_l.startswith(".") and not _glob_literal(seg_l): - return False - try: - return fnmatch.fnmatchcase(name_l, seg_l) - except Exception: - return False - - -def _matches_one(path: str) -> bool: - for spelling in _spellings(path): - segs = [s for s in spelling.split("/") if s not in ("", ".")] - for i, s in enumerate(segs): - if _segment_matches(s, ".githooks"): - return True - if _segment_matches(s, ".claude"): - if i + 1 == len(segs): - return True # the directory itself - if any(_segment_matches(segs[i + 1], n) for n in PROTECTED_UNDER_CLAUDE): - return True - return False - - -# The unnamed stream of a file (`::$DATA`) or of a directory's index -# (`::$INDEX_ALLOCATION`, `:$I30:$INDEX_ALLOCATION`) β€” the object itself. Read -# only where `\` is a separator; see `matches_root_of_trust`. -_NTFS_DEFAULT_STREAM = re.compile( - r"(?:::\$DATA|:(?:\$I30)?:\$INDEX_ALLOCATION)(?=/*$)", re.IGNORECASE) - - -def matches_root_of_trust(token: str) -> bool: - """PATTERN half: does this token spell a gate-defining path at all? - Says nothing about WHICH tree it is in β€” see in_project(). - - #171 β€” A BACKSLASH IS ALSO A SEPARATOR. Every walk above splits on `/` - only, so `'.claude\\hooks'` was ONE segment that matched nothing. On - Windows it is not one segment: Git Bash runs on the MSYS2/Cygwin runtime, - whose rule is "directory delimiters may be either forward slashes or - backslashes", and git.exe is a Win32 program. Measured 2026-09-27 under an - ntpath simulation of the shipped guard: `rm -rf '.claude\\hooks'`, - `echo x > '.claude\\settings.json'`, `rm -f '.githooks\\pre-commit'` and - `git -C '.claude\\hooks' rm …` were all ALLOWED (silent) while the `/` - spellings DENIED. The token is now ALSO tested with every `\\` read as `/`. - That is a second FORM, not a rewrite of the first, so no deny that stands - today can be lost (a rewrite in `normalize` would have dropped the glob - class in `".claude/hook[\\s]"`). - - ONLY WHERE A BACKSLASH IS A SEPARATOR β€” native Windows Python (`os.sep`) or - the Cygwin/MSYS runtime's own Python β€” NOT unconditionally like the case - fold. The first cut was unconditional, and its docstring said the POSIX - cost was a false DENY for a quoted name that really contains a backslash. - That was wrong: the in-place branch of `scan_segment` tests every operand - of `sed -i` / `perl -pi`, the SCRIPT included, and escaping a dot or a - slash there is the ordinary idiom. Measured 2026-09-27 on macOS: - `sed -i '' 's/\\.githooks/githooks/' docs/x.md` and - `perl -pi -e 's/old\\/dir/.claude\\/settings.json/g' README.md` ALLOWED at - 08a7641 and DENIED under the unconditional form. On Windows the same - scripts still deny; that is the price of `\\` being a separator there, and - it is paid in this guard's direction. - - NTFS DEFAULT STREAMS, on the same platforms. `settings.json::$DATA` is the - file's unnamed data stream β€” its contents β€” and `hooks::$INDEX_ALLOCATION` - (`hooks:$I30:$INDEX_ALLOCATION`) is the directory itself. Measured - 2026-09-27 under the ntpath simulation: `echo x > - '.claude\\settings.json::$DATA'` and `rm -rf - '.claude\\hooks:$I30:$INDEX_ALLOCATION'` were ALLOWED (silent), their plain - twins DENIED. The Windows form drops that suffix from the last segment. - Whether Git Bash's runtime hands such a name to NTFS unchanged has not been - run on a real Windows machine; dropping it errs toward the deny.""" - forms = [token] - if os.sep == "\\" or sys.platform in ("cygwin", "msys"): - win = _NTFS_DEFAULT_STREAM.sub("", token.replace("\\", "/")) - if win != token: - forms.append(win) - return any(_matches_one(alt) for form in forms - for alt in _brace_expand(normalize(form))) - - -# --- project scope --------------------------------------------------------- -# -# The pattern above is a TEXT match, so on its own it protects `.claude/hooks` -# in every tree on the machine: a throwaway fixture clone under /tmp, a second -# checkout, the user's own ~/.claude. That bought this repository nothing (the -# gates it defends live HERE) and cost real work β€” it denied the qualification -# ledger's own gate-9 reproduction, which seeds a mistyped hook path into a -# fixture clone's `.claude/settings.json`, and it contradicted the deny message, -# which asserts the path is one of this repository's gate-defining files. -# -# So a token is protected only when it RESOLVES INSIDE the project directory. -# Precedence: CLAUDE_PROJECT_DIR (what Claude Code sets for hooks) β†’ the git -# repository the call is running in β†’ the repository this hook file ships in. -# If none of those resolves, the scope is unknown and the guard falls back to -# the old text match, which loses no coverage it used to have. - -_PROJECT: str | None = None # resolved project root, or None = unknown -_CWD: str = "" # the cwd relative tokens resolve against - - -def _real(p: str) -> str: - try: - return os.path.realpath(p) - except OSError: - return os.path.abspath(p) - - -def _git_root(start: str) -> str | None: - """Nearest ancestor of `start` that contains a `.git` entry.""" - if not start: - return None - cur = _real(start) - while True: - if os.path.exists(os.path.join(cur, ".git")): - return cur - parent = os.path.dirname(cur) - if parent == cur: - return None - cur = parent - - -# #171 β€” Git Bash / MSYS2 / Cygwin spell drive C: as `/c/…` (`/cygdrive/c/…`), -# and that is what `pwd` prints there, so it is how absolute paths get written. -# Native Windows Python reads `/c/Users/me/proj` as a path ROOTED ON THE CURRENT -# DRIVE β€” `C:\c\Users\me\proj` β€” which lies outside every project, so the scope -# half answered "another tree". Measured 2026-09-27 under an ntpath simulation -# of the shipped guard: `rm -rf /c//.claude/hooks` and -# `echo x > /c//.claude/settings.json` ALLOWED (silent) while the -# `C://…` spelling DENIED. A no-op wherever `\` is not the separator. -# `/proc/cygdrive` is the runtime's own link to the cygdrive prefix, present -# whatever that prefix is (`/` in Git Bash, so `/proc/cygdrive/c/…` is `/c/…`); -# it was ALLOWED (silent) under the same simulation after `/cygdrive/c/…` denied. -_MSYS_DRIVE = re.compile(r"^(?:/proc/cygdrive|/cygdrive)?/([A-Za-z])(?=/|$)") - - -def _native(p: str) -> str: - """`/c/x` (or `/cygdrive/c/x`, `/proc/cygdrive/c/x`) -> `C:/x` on Windows; - unchanged elsewhere.""" - if os.sep != "\\": - return p - m = _MSYS_DRIVE.match(p) - return f"{m.group(1).upper()}:{p[m.end():] or '/'}" if m else p - - -def configure_scope(event_cwd: str = "") -> None: - """Resolve the project root and the cwd relative tokens resolve against. - Called once per event, before any scan.""" - global _PROJECT, _CWD - # A cwd that does not exist tells us nothing, and trusting it would resolve - # every relative token to a directory outside every project β€” i.e. silently - # disarm the guard. Fall back to the hook process's own cwd instead. - event_cwd = _native(event_cwd) - try: - usable = bool(event_cwd) and os.path.isdir(event_cwd) - _CWD = _real(event_cwd) if usable else _real(os.getcwd()) - except OSError: - _CWD = "" - env = _native(os.environ.get("CLAUDE_PROJECT_DIR", "")) - if env and os.path.isdir(env): - _PROJECT = _real(env) - return - _PROJECT = (_git_root(_CWD) - or _git_root(os.path.dirname(os.path.abspath(__file__)))) - - -def _fold(p: str) -> str: - """One spelling per directory name on APFS/HFS+ (and NTFS for case): NFC, - then Unicode casefold β€” the same fold the pattern half applies.""" - return unicodedata.normalize("NFC", p).casefold() - - -def _under(path: str, root: str) -> bool: - """`path` is `root` or lies below it, compared on folded strings. A root - that already ends in a separator (`/`, `D:\\`) is its own prefix.""" - p, r = _fold(path), _fold(root) - return p == r or p.startswith(r if r.endswith(os.sep) else r + os.sep) - - -def in_project(token: str) -> bool: - """SCOPE half: does this token resolve inside the project directory? - - Relative tokens resolve against the TOOL CALL's cwd (the event's `cwd`), - falling back to the hook process's cwd and then the project root. `~` and - `$VAR` are expanded, so `$HOME/.claude/settings.json` is recognised as the - user's own config rather than read as a relative path. A token that still - cannot be resolved to an absolute path is treated as project-relative, - which fails toward DENYING β€” the safe direction for this guard. - - #171 β€” FOUR WAYS THE SAME DIRECTORY WAS JUDGED "ANOTHER TREE". Each was - measured 2026-09-27 as a silent ALLOW of a write the literal spelling - DENIED, and each is closed here: - - * CASE AND UNICODE FORM. The test was a raw string prefix after - `realpath`, which on macOS canonicalises neither case nor NFC/NFD while - APFS ignores both; `rm -rf /.claude/hooks` - really deleted the hooks in a fixture. Both sides are now compared - NFC-normalised and casefolded β€” the scope half's version of the r19 - fold, erring the same way (a false DENY for a sibling that differs only - in case on a case-sensitive disk). - * A LINK THAT CARRIES THE PROTECTED NAME. Only the RESOLVED path was - compared, so a `.claude` (or `.claude/hooks`, `.githooks`) that is a - symlink or junction to a shared config directory resolved outside the - project β€” yet it is still the path settings.json loads every hook from. - The path AS NAMED now counts as well as the path as resolved; either one - inside is inside. (A link with an UNPROTECTED name is still the residual - in the module docstring: there the pattern half sees no protected name.) - * A PROJECT AT A FILESYSTEM ROOT. `_PROJECT + os.sep` was `//` for a - project at `/` (`D:\\` on Windows), which no path starts with, so the - guard was inert. See `_under`. - * ANOTHER NAME FOR THE SAME DIRECTORY that no string test can see β€” the - macOS `/System/Volumes/Data` firmlink, a bind mount, CLAUDE_PROJECT_DIR - spelled through a link. When the strings disagree, each ancestor of the - path (as named, then as resolved) is compared with the project by file - IDENTITY. That costs a few `stat`s, and only for a token the pattern - half has already matched. - - A Git Bash drive path (`/c/…`) is read as the drive it names: `_native`.""" - if _PROJECT is None: - return True # scope unknown: keep the pre-scoping behaviour - t = _native(os.path.expandvars(os.path.expanduser(token.strip()))) - if not t: - return False - if not os.path.isabs(t): - t = os.path.join(_CWD or _PROJECT, t) - named, resolved = os.path.abspath(t), _real(t) - if _under(named, _PROJECT) or _under(resolved, _PROJECT): - return True - for start in dict.fromkeys((named, resolved)): - cur = start - while True: - try: - if os.path.samefile(cur, _PROJECT): - return True - except (OSError, ValueError): # a missing leaf is not an answer - pass - parent = os.path.dirname(cur) - if parent == cur: - break - cur = parent - return False - - -def is_protected(token: str) -> bool: - """A path this hook defends: the gate-defining PATTERN, inside THIS - project. Both halves must hold. - - r15: the PATTERN half now reads a glob or brace group the way the shell - would (see `_segment_matches`). The SCOPE half is unchanged and still - decides on the token as written, so a glob aimed at another tree stays - allowed exactly as its literal spelling is (the r11 scope rule).""" - return matches_root_of_trust(token) and in_project(token) - - -# --- command shape --------------------------------------------------------- - -# r18: `\d?>&` IS AN OPERATOR. It was absent from this alternation, so bash's -# csh-style `>&` β€” which redirects stdout AND stderr to exactly -# like `&>` whenever is not a file descriptor β€” tokenised as `>` -# followed by `&`. `&` is a SEPARATOR, so the segment ENDED there and the write -# target became a bare word at the head of the NEXT segment, out of reach of the -# `idx + 1` lookahead in `scan_segment`. Measured at e0c4cdb, event cwd = the -# project, guard verdict vs. what bash really does: -# -# echo CLOBBERED >& .claude/settings.json ALLOW -> file is 'CLOBBERED' -# echo CLOBBERED >&.claude/settings.json ALLOW -> file is 'CLOBBERED' -# echo CLOBBERED >& .claude/hooks/git-guardrails.py ALLOW -> guard overwritten -# -# while `&>`, `>` and `>|` all DENIED the byte-equivalent write. Same class as -# the r15 glob defect: one character took a protected path out of the guard's -# sight while the shell wrote to exactly that file. -# -# FILE-DESCRIPTOR DUPLICATION IS NOT A WRITE. `>&2`, `1>&2`, `2>&1`, `>&-` name -# a descriptor, not a path; `_is_fd_dup_target` excludes them below so the fix -# does not start denying the most ordinary redirection in any shell script. -_TOKEN = re.compile( - r"""(?P&>>|&>|\d?>&|>\||\d?>>|\d?>|<<-?|<|\|\||\||&&|;|&|\n) - | (?P(?:"[^"]*"|'[^']*'|\\.|[^\s"'|;&<>\n])+)""", - re.VERBOSE, -) - -SEPARATORS = {"||", "|", "&&", ";", "&", "\n"} - -# The word after a `>&` that means a FILE DESCRIPTOR rather than a file: a bare -# descriptor number (`>&2`, `2>&1`), the same with bash's move suffix (`2>&1-`), -# or `-` to close it. Anything else is a path bash truncates. -_FD_DUP_TARGET = re.compile(r"^(?:\d+-?|-)$") - - -def _is_fd_dup_target(tok: str) -> bool: - """Does this `>&` target duplicate a descriptor instead of writing a file?""" - return bool(_FD_DUP_TARGET.match(tok)) - - -# Env-var prefixes (`FOO=bar cmd`) and wrappers that delegate to a real command. -_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") -WRAPPERS = {"sudo", "doas", "env", "command", "builtin", "exec", - "nohup", "time", "nice", "stdbuf", "xargs", "timeout", "ionice"} - -# Per-wrapper short/long options that consume a SEPARATE following value -# (`nice -n 10`, `sudo -u root`, `env -u FOO`, `stdbuf -o L`). An option that -# ATTACHES its value (`-oL`, `--chdir=/x`, `-n10`) is a single token and is -# consumed as an ordinary flag; only these exact tokens also eat the next word. -# `env`'s `-S`/`--split-string` is listed here so skip_wrappers still lands on -# any trailing real command word, but its VALUE is NOT opaque: it is a command -# line env splits and executes, re-scanned separately by env_split_payload(). -_WRAPPER_ARG_OPTS = { - "env": {"-u", "--unset", "-C", "--chdir", "-S", "--split-string", "-P"}, - "nice": {"-n", "--adjustment"}, - "sudo": {"-u", "--user", "-g", "--group", "-C", "--close-from", - "-h", "--host", "-p", "--prompt", "-r", "--role", "-t", "--type", - "-U", "--other-user", "-R", "--chroot", "-D", "--chdir"}, - "doas": {"-u", "-C"}, - "timeout": {"-s", "--signal", "-k", "--kill-after"}, - "stdbuf": {"-i", "--input", "-o", "--output", "-e", "--error"}, - "ionice": {"-c", "--class", "-n", "--classdata", "-p", "--pid"}, - "xargs": {"-n", "--max-args", "-P", "--max-procs", "-I", "-i", "--replace", - "-s", "--max-chars", "-d", "--delimiter", "-E", "-e", "--eof", - "-L", "--max-lines", "-a", "--arg-file"}, -} - -# Wrappers that take a bare POSITIONAL argument before the real command -# (`timeout 5 rm x` β€” DURATION is a positional, not a flag). We skip at most -# this many leading non-option positionals so the loop reaches the command. -_WRAPPER_POSITIONAL = {"timeout": 1} - -# Shells whose `-c ''` argument is a command line in its own right. -# The payload is unwrapped and re-scanned so a write hidden inside it is not -# swallowed as one opaque quoted word. -SHELLS = {"sh", "bash", "zsh", "dash", "ksh"} - - -def _prog(word: str) -> str: - """The name a command word runs, as every table in this file spells it. - - #171 β€” `.exe` AND CASE. The tables were matched on the exact basename, so - in Git Bash `rm.exe`, `/usr/bin/rm.exe`, `git.exe`, `bash.exe -c …` and - `GIT.EXE` β€” the programs' real file names there, looked up on a - case-insensitive disk β€” ran the same writers while matching nothing. - Measured 2026-09-27: `rm.exe -rf .claude/hooks` and - `bash.exe -c 'git reset --hard'` ALLOWED (silent), their bare twins DENIED. - The fold is casefold, so it also closes the case-varied WRAPPER residual - r19 left open (`NICE rm …`, `BASH -c …`).""" - b = os.path.basename(word).casefold() - return b[:-4] if b.endswith(".exe") else b - - -DEST_LAST = {"cp", "install", "rsync", "scp", "ln"} # last non-flag arg -ANY_ARG = {"mv", "rm", "unlink", "shred", "tee", "truncate"} -INPLACE = {"sed", "gsed", "perl", "ruby"} # only with -i - -# GIT'S OWN WORKING-TREE WRITERS. git is a writer program too, and the tables -# above never named it β€” so `git rm .claude/hooks/x`, `git checkout HEAD~1 -- -# .claude/settings.json`, `git restore .claude/settings.json`, `git clean -fd -# .claude/hooks`, `git stash push -- .claude/hooks/x` and `git mv` all -# overwrote or deleted a gate-defining file through a plain shell one-liner while -# the semantically identical `cp`/`rm` spelling was denied. These are NOT part -# of the disclosed residual below: the command word is known, the protected path -# is a LITERAL argument, and the write is direct and unambiguous β€” exactly the -# class this guard claims to catch. -# -# ONLY these subcommands count as writers. Read-only git β€” status, log, diff, -# show, ls-files, rev-parse, cat-file, blame β€” is deliberately untouched, so a -# session can still inspect the gate-defining files freely. -GIT_WRITE_SUBS = {"rm", "checkout", "restore", "clean", "stash", "mv"} - -# git GLOBAL options that sit between `git` and the subcommand and take a -# SEPARATE value; without consuming the value, `git -C checkout` would be -# read as the subcommand `` and the writer missed. -_GIT_GLOBAL_ARG_OPTS = {"-C", "-c", "--git-dir", "--work-tree", "--namespace", - "--exec-path", "--super-prefix"} - -# `git stash push -m `: the value is a MESSAGE, never a pathspec, so it -# must not be read as a path operand. -_GIT_STASH_MSG_OPTS = {"-m", "--message"} - -_SHORT_INPLACE = re.compile(r"^-[A-Za-z]*i") -_TARGET_DIR = re.compile(r"^--target-directory=(.+)$") - - -# ANSI-C QUOTING (`$'…'`) AND LOCALE TRANSLATION (`$"…"`) β€” r19. -# -# The unquoting walk handled `'…'`, `"…"` and backslash escapes, and nothing -# else. Bash has two more quote openers, and both put a `$` IMMEDIATELY in -# front of the quote: `$'…'` (ANSI-C, escapes decoded) and `$"…"` (locale -# translation, which with no message catalogue is the double-quoted string -# itself). The `$` fell through to the walk's ordinary-character branch, so it -# SURVIVED into the unquoted word: `$'--hard'` unquoted to `$--hard`, which is -# not `--hard`, and every whole-word comparison downstream missed. Bash -# executes the byte-identical bare spelling. -# -# Measured 2026-08-24 against the shipped hooks with synthetic PreToolUse -# events, in a throwaway fixture repository whose porcelain read ' M f.txt' and -# a throwaway project fixture holding `.claude/settings.json` and -# `.claude/hooks/`: -# -# git reset --hard HEAD DENY (control) -# git reset $'--hard' HEAD ALLOW (silent) -# git $'merge' other ALLOW (silent) <- the clean-tree -# git clean $'-fd' ALLOW (silent) check too -# git reset $"--hard" HEAD ALLOW (silent) -# echo X > .claude/settings.json DENY (control) -# echo X > $'.claude/settings.json' ALLOW (silent) -# echo X > $".claude/settings.json" ALLOW (silent) -# rm -f $'.claude/hooks/git-guardrails.py' ALLOW (silent) -# -# Like the r10 quoting hole this is ORDINARY SHELL, not an evasion, and it -# silenced the deny list and the clean-tree precondition at once. -# -# THE RULE: a `$` immediately before a quote opener is QUOTING SYNTAX, not part -# of the word. `$'…'` additionally has its ANSI-C escapes decoded, because bash -# decodes them before the word ever reaches the command β€” `$'\x2d\x2dhard'` IS -# `--hard` to git, and a guard that compares whole words has to see the same -# word. An escape this decoder does NOT recognise keeps BOTH its characters, -# exactly as bash does for an unrecognised escape: dropping the backslash would -# invent a DIFFERENT word, and a word the guard invented is a word that matches -# nothing. -# -# Disclosed residual, stated as the possibility it is rather than as a measured -# hole: the TOKENIZERS still read `'…'` as a span ending at the next `'`, while -# inside `$'…'` a `\'` is an ESCAPED quote that does not end the span, so a -# `$'…\'…'` spelling could be split into words differently from bash. The -# unquoting below is escape-aware; the tokenizer is not. No spelling in this -# class has been shown to reach a false ALLOW β€” the one probed on 2026-08-24 -# still DENIED β€” so this is a known divergence, not a demonstrated bypass. -# -# THIS BLOCK AND THE FUNCTION BELOW ARE DUPLICATED VERBATIM IN -# `git-guardrails.py` (its copy names the function `_unquote`), for the same -# reason the heredoc walker is: this hook's import of that one is best-effort -# and returns None on any failure, and a parser that silently stops running is -# exactly what this defect was. -_ANSI_C_SIMPLE = {"a": "\a", "b": "\b", "e": "\x1b", "E": "\x1b", "f": "\f", - "n": "\n", "r": "\r", "t": "\t", "v": "\v", - "\\": "\\", "'": "'", '"': '"', "?": "?"} -_ANSI_C_HEX = {"x": 2, "u": 4, "U": 8} -_HEXDIGITS = "0123456789abcdefABCDEF" - - -def _ansi_c(body: str) -> str: - """Decode the ANSI-C escapes bash processes inside `$'…'`. - - An UNRECOGNISED escape keeps its backslash AND its character, which is what - bash does with one β€” the failure direction matters here, because a word this - function invents matches no rule at all.""" - out, i, n = [], 0, len(body) - while i < n: - c = body[i] - if c != "\\" or i + 1 >= n: - out.append(c); i += 1; continue - e = body[i + 1] - if e in _ANSI_C_SIMPLE: - out.append(_ANSI_C_SIMPLE[e]); i += 2; continue - if e in _ANSI_C_HEX: # \xHH \uHHHH \UHHHHHHHH - j, stop = i + 2, i + 2 + _ANSI_C_HEX[e] - while j < n and j < stop and body[j] in _HEXDIGITS: - j += 1 - if j > i + 2: - try: - out.append(chr(int(body[i + 2:j], 16))); i = j; continue - except (ValueError, OverflowError): - pass - out.append(c); out.append(e); i += 2; continue - if e in "01234567": # \nnn, up to three octal - j = i + 1 - while j < n and j < i + 4 and body[j] in "01234567": - j += 1 - try: - out.append(chr(int(body[i + 1:j], 8) & 0xFF)); i = j; continue - except (ValueError, OverflowError): - pass - if e == "c" and i + 2 < n: # \cX β€” a control character - x = body[i + 2] - if x.isalpha() and x.isascii(): - out.append(chr(ord(x.upper()) ^ 0x40)); i += 3; continue - out.append(c); out.append(e); i += 2 # unrecognised: keep both - return "".join(out) - - -def unquote(w: str) -> str: - out, i = [], 0 - while i < len(w): - c = w[i] - if c == "$" and i + 1 < len(w) and w[i + 1] in "\"'": - q = w[i + 1] # `$'…'` / `$"…"`: `$` is SYNTAX - if q == "'": - j, esc = i + 2, False # inside `$'…'` a `\'` does not close - while j < len(w): - if esc: - esc = False - elif w[j] == "\\": - esc = True - elif w[j] == "'": - break - j += 1 - if j >= len(w): - out.append(_ansi_c(w[i + 2:])); break - out.append(_ansi_c(w[i + 2:j])); i = j + 1 - continue - j = w.find(q, i + 2) - if j == -1: - out.append(w[i + 2:]); break - out.append(w[i + 2:j]); i = j + 1 - elif c in "\"'": - j = w.find(c, i + 1) - if j == -1: - out.append(w[i + 1:]); break - out.append(w[i + 1:j]); i = j + 1 - elif c == "\\" and i + 1 < len(w): - out.append(w[i + 1]); i += 2 - else: - out.append(c); i += 1 - return "".join(out) - - -# A backslash-newline is a LINE CONTINUATION in POSIX shell: it JOINS the line, -# it does not end the command. `_TOKEN` lists `\n` in its separator alternation -# while its word alternation's escape class (`\\.`) cannot match backslash + -# newline β€” `.` excludes newline outside DOTALL β€” so a trailing `\` was consumed -# as an ordinary word character and the newline behind it ENDED the segment. -# Executed against the pre-fix guard (r11): `rm -f \` + newline + -# ` .claude/hooks/git-guardrails.py` emitted no decision, and the same string -# through `bash -c` really deleted the guard file, while the byte-equivalent -# one-line spelling DENIED. Protection depended on where the author had put a -# newline β€” ordinary multi-line formatting, not an evasion. So the continuation -# is spliced the way the shell splices it, BEFORE anything is tokenised. -# -# The RUN of backslashes is what decides. `\\` is an ESCAPED backslash, so -# `\\` + newline is a literal backslash followed by a REAL separator, not a -# continuation. The even-run prefix is consumed first and re-emitted, so only an -# ODD trailing backslash joins; a naive `\\\n -> " "` substitution would splice -# the NEXT command onto this one and could false-deny. A newline with no -# backslash in front of it is untouched and still separates commands, and a -# backslash NOT followed by a newline (a literal `\` inside a quoted string) is -# never matched at all. -# -# r20 β€” THE SPLICE INSERTED A SPACE, AND THE SHELL DOES NOT. POSIX is explicit: -# "the and shall be removed before splitting the input -# into tokens" β€” REMOVED, not replaced. The r11 repair substituted `" "`, which -# is indistinguishable from removal at a token BOUNDARY (its own measured case -# had whitespace on both sides) and wrong INSIDE a word: it split one word bash -# keeps whole, so the halves were scored separately and matched nothing. -# Measured 2026-08-24 at 727a66c, event cwd = the project: -# -# rm -f .claude/hooks/git-guardrails.py DENY (literal control) -# rm -f .clau\ + newline + de/hooks/… ALLOW (silent) -# -# while `printf '%s\n'` on the same string prints ONE word, -# `.claude/hooks/git-guardrails.py`. Same class as the fast-path defect above β€” -# a backslash inside the protected directory name β€” and the same failure -# direction, so it is closed with it rather than disclosed. The substitution -# now emits the even-backslash prefix and NOTHING else. -_LINE_CONT = re.compile(r"(? str: - """Splice POSIX line continuations, so a command formatted across several - lines is tokenised as the ONE command bash will actually run. - - The backslash-newline is DELETED, which is what the shell does with it; a - continuation inside a word therefore joins the word rather than splitting - it. Whitespace that was already there is untouched, so the token-boundary - spelling this function was written for (r11: `rm -f \\` + newline + - ` `) still tokenises exactly as before β€” the leading spaces of the - continued line are what separate the words, and always were.""" - return _LINE_CONT.sub(r"\1", cmd) - - -# THE OPENER SCAN IS QUOTE-AWARE AND HERESTRING-AWARE (r16). It used to be one -# quote-blind regex β€” `<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1` β€” run over RAW -# line text with `finditer`. Two ordinary spellings therefore opened a heredoc -# that bash never opens, and every line after them (to the end of the command, -# or to a line spelling the invented terminator) was DELETED before anything was -# tokenised: -# -# * a HERESTRING. To that regex `tr a-z A-Z <<< hello` is `<<` + `\s*` + -# `hello`, so `hello` became the terminator. -# * a `< .claude/settings.json`, and the sibling hook went SILENT on -# `git reset --hard HEAD~1`, `git clean -fd`, `git push --force origin main`, -# `git add -A` and a dirty-tree `git merge` β€” every one of which DENIES when -# line 1 is `echo hi`, and bash runs line 2 in all of them. Dropping REAL -# command text is a missed deny, the one direction a guard must not fail in, -# and this took the whole deny spine of both hooks with it. -# -# So the line is WALKED, not pattern-matched: quote state is tracked (and -# carried ACROSS lines, because a shell string may span them), `<<<` is consumed -# whole so it can never be read as a `<<`, and a delimiter is taken only from an -# opener found OUTSIDE quotes. An UNBALANCED quote leaves the rest of the -# command in quote state, so no opener is found and NO text is dropped β€” that -# fails toward scanning more, which is the safe direction here. -# -# r17 β€” THE WALK IS ALSO COMMENT-AWARE, and the r16 fix above is what made that -# necessary: it modelled quoting and herestrings but gave the scan NO notion of -# a shell COMMENT, while `strip_heredocs` runs BEFORE the comment-aware -# segmenting the sibling hook performs on the same text. So an ORDINARY `#` -# comment that merely NAMES a heredoc opened one, and every following line was -# DELETED before anything was tokenised. This class was INTRODUCED by r16: its -# disclosed residual named exactly one surviving opener misread (the arithmetic -# shift below), and this was a second one, one character away from the quoted -# spelling r16 had just closed. -# -# Measured 2026-08-23 against throwaway fixtures (a project fixture holding -# `.claude/hooks/` and `.claude/settings.json`; a git fixture with one modified -# tracked file), line 2 carrying the real op. With line 1 = -# `# heredoc <['"])(?P[A-Za-z_][A-Za-z0-9_]*)(?P=q) - | \\?(?P[A-Za-z_][A-Za-z0-9_]*) )""", - re.VERBOSE, -) - - -def _heredoc_opener(line: str, quote: str = "") -> "tuple[str | None, str]": - """The delimiter of the LAST heredoc this line OPENS (None if it opens - none), and the quote state the line ends in β€” fed back in for the next - line, so a string spanning lines stays quoted. - - An unquoted `#` that BEGINS a word ENDS the line: everything after it is a - shell COMMENT and can open nothing. See the r17 paragraph above.""" - delim = None - i, n = 0, len(line) - while i < n: - c = line[i] - if quote: - if c == "\\" and quote == '"' and i + 1 < n: - i += 2 # only `"` honours a backslash - continue - if c == quote: - quote = "" - i += 1 - continue - if c == "\\" and i + 1 < n: - i += 2 # an escaped char, `\<` included - continue - if c in "'\"": - quote = c - i += 1 - continue - if c == "#" and (i == 0 or line[i - 1] in " \t;&|()"): - break # a COMMENT: no shell text follows - if line.startswith("<<<", i): - i += 3 # HERESTRING: never an opener - continue - if line.startswith("<<", i): - m = _HEREDOC_DELIM.match(line, i + 2) - if m: - delim = m.group("qword") or m.group("word") - i = m.end() - continue - i += 2 - continue - i += 1 - return delim, quote - - -def strip_heredocs(cmd: str) -> str: - """Drop heredoc BODIES. `cat <<'EOF' > .claude/settings.json` still denies β€” - the redirection is on the command line β€” but a body that merely *documents* - `rm .claude/hooks/x` is prose, not a command, and must not trip a rule.""" - if "<<" not in cmd: - return cmd - out, pending, quote = [], None, "" - for line in cmd.split("\n"): - if pending is not None: - if line.strip() == pending: - pending = None - continue # a BODY line: not shell text - out.append(line) - delim, quote = _heredoc_opener(line, quote) - if delim: - pending = delim - return "\n".join(out) - - -def segments(cmd: str) -> list[list[tuple[str, str]]]: - """Split into per-command segments of (kind, text) tokens; kind is - 'op' or 'word'. Best-effort β€” quoting oddities degrade to allow.""" - segs: list[list[tuple[str, str]]] = [[]] - for m in _TOKEN.finditer(cmd): - if m.group("op") is not None: - tok = m.group("op") - if tok in SEPARATORS: - segs.append([]) - else: - segs[-1].append(("op", tok)) - else: - segs[-1].append(("word", unquote(m.group("word")))) - return segs - - -def is_inplace(flag: str) -> bool: - return (flag == "--in-place" or flag.startswith("--in-place=") - or (not flag.startswith("--") and bool(_SHORT_INPLACE.match(flag)))) - - -def skip_wrappers(words: list[str], stop_at: set[str] | None = None) -> int: - """Advance past `FOO=bar` env-assignments and command wrappers β€” INCLUDING - each wrapper's own option flags and the value some options take β€” to the - index of the REAL command word. Returns len(words) if the whole segment is - wrapper scaffolding. - - `stop_at` names wrappers to STOP ON rather than skip: with - `stop_at={"env"}` the scan skips any leading `nice`/`timeout`/… but halts - ON `env` itself, so a caller (env_split_payload) can then inspect env's own - `-S` option instead of having it consumed as a skipped wrapper. Without it, - every wrapper β€” env included β€” is skipped. - - The earlier version stopped at the first flag, so `env -i bash -c ...`, - `nice -n 10 rm ...`, `sudo -u root ...`, `stdbuf -oL ...` and `timeout 5 rm` - all halted on the flag and never reached the shell/deleter β€” defeating both - the `-c` unwrap and direct deleter detection. Conservative-but-not-silent: - where a flag's arity is unknown we treat it as attached-value (skip only the - flag token), which risks skipping too LITTLE (a following real command is - still scanned) rather than too much (which would blind the scan).""" - i, n = 0, len(words) - while i < n: - if _ASSIGN.match(words[i]): # FOO=bar prefix - i += 1 - continue - base = _prog(words[i]) - if base not in WRAPPERS: - break - if stop_at and base in stop_at: # halt ON this wrapper, don't skip it - break - i += 1 # consume the wrapper name - arg_opts = _WRAPPER_ARG_OPTS.get(base, set()) - while i < n: # consume this wrapper's options - t = words[i] - if _ASSIGN.match(t): # `env VAR=val` after its flags - i += 1 - continue - if t == "--": # explicit end-of-options - i += 1 - break - if t.startswith("-") and t != "-": - i += 1 - if t in arg_opts and i < n: # separate-value option: eat its arg - i += 1 - continue - break # first non-option word - pos = _WRAPPER_POSITIONAL.get(base, 0) # e.g. `timeout DURATION` - while pos > 0 and i < n and not words[i].startswith("-"): - i += 1 - pos -= 1 - # loop: words[i] may itself be another wrapper (`env sudo nice cmd`). - return i - - -# Shell options that take a SEPARATE value, which must be consumed rather than -# mistaken for the -c payload. `--rcfile ` / `--init-file ` (bash) -# and `-o