diff options
| author | Craig Jennings <c@cjennings.net> | 2026-04-19 17:39:33 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-04-19 17:39:33 -0500 |
| commit | 560c29ade19be74968d8fec26d469913d383f2e8 (patch) | |
| tree | 002fcdc2f28aa3b7a9373975d38be60e80d3a69f /hooks | |
| parent | c96296a30e3f712561b5f05f3f1e9d95588f643e (diff) | |
| download | rulesets-560c29ade19be74968d8fec26d469913d383f2e8.tar.gz rulesets-560c29ade19be74968d8fec26d469913d383f2e8.zip | |
feat(hooks+skills): destructive-bash confirm + architecture suite + problem-solving routing
Three improvements bundled together:
1. New hook — `destructive-bash-confirm.py` (PreToolUse/Bash):
Gates `git push --force`, `git reset --hard`, `git clean -f`,
`git branch -D`, and `rm -rf` behind a confirmation modal with the
command, local context (branch, uncommitted counts, targeted paths),
and a severity banner. Elevates severity when force-pushing protected
branches (main/master/develop/release/prod) or when rm -rf targets
root, home, or wildcard paths. Reuses _common.py.
2. Architecture suite rename — the "Part of the arch-* suite" footer in
arch-design, arch-decide, arch-document, arch-evaluate descriptions
now reads "Part of the architecture suite (arch-design / arch-decide
/ arch-document / arch-evaluate + c4-analyze / c4-diagram for
notation-specific diagramming)." Matching footers added to c4-analyze
and c4-diagram. c4-* keep their framework-specific prefix (C4 is a
notation, arch-* is framework-agnostic workflow) but are now
discoverable as suite members.
3. Problem-solving cluster routing — added YAML frontmatter with
descriptions (including "Do NOT use for X (use Y)" clauses) to
debug/SKILL.md and fix-issue/SKILL.md. Previously both had no
frontmatter at all, which broke skill-router discovery. The four
cluster members (debug, fix-issue, root-cause-trace, five-whys) now
route unambiguously by description alone.
Diffstat (limited to 'hooks')
| -rw-r--r-- | hooks/README.md | 1 | ||||
| -rwxr-xr-x | hooks/destructive-bash-confirm.py | 174 | ||||
| -rw-r--r-- | hooks/settings-snippet.json | 3 |
3 files changed, 177 insertions, 1 deletions
diff --git a/hooks/README.md b/hooks/README.md index 97dd881..5555514 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -9,6 +9,7 @@ Machine-wide Claude Code hooks that install into `~/.claude/hooks/` and apply to | `precompact-priorities.sh` | `PreCompact` | Injects a priority-preservation block into Claude's compaction prompt so the generated summary retains information most expensive to reconstruct (unanswered questions, root causes with `file:line`, subagent findings, exact numbers/IDs, A-vs-B decisions, open TODOs, classified-data handling). | | `git-commit-confirm.py` | `PreToolUse(Bash)` | Gates `git commit` behind a confirmation modal showing the parsed message, staged files, diff stats, and git author. Parses both HEREDOC and `-m`/`--message` forms. | | `gh-pr-create-confirm.py` | `PreToolUse(Bash)` | Gates `gh pr create` behind a confirmation modal showing title, base←head, reviewers, labels, assignees, milestone, draft flag, and body (HEREDOC or quoted). | +| `destructive-bash-confirm.py` | `PreToolUse(Bash)` | Gates destructive commands (`git push --force`, `git reset --hard`, `git clean -f`, `git branch -D`, `rm -rf`) with a modal showing the command, local context (branch, uncommitted file counts, targeted paths), and a warning banner. Elevates severity when force-pushing protected branches or targeting root/home/wildcard paths. | Shared library (not a hook): `_common.py` — `read_payload()`, `respond_ask()`, `scan_attribution()`. Installed as a sibling symlink so the two Python hooks can `from _common import …` at runtime. diff --git a/hooks/destructive-bash-confirm.py b/hooks/destructive-bash-confirm.py new file mode 100755 index 0000000..c1cf5f9 --- /dev/null +++ b/hooks/destructive-bash-confirm.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""PreToolUse hook for Bash: gate destructive commands behind a modal. + +Detects and asks for confirmation before: + - git push --force / -f / --force-with-lease (overwrites remote history) + - git reset --hard (discards working-tree) + - git clean -f (deletes untracked files) + - git branch -D (force-deletes branches) + - rm -rf (any flag combo containing both -r/-R and -f) + +Each pattern emits a modal with the command, local context (current +branch, uncommitted line count, targeted paths, etc.), and a warning +banner via systemMessage. First match wins — a command with multiple +destructive patterns fires on the first detected. + +Non-destructive Bash calls exit 0 silent. +""" + +import re +import subprocess +import sys +from typing import Optional + +from _common import read_payload, respond_ask + + +PROTECTED_BRANCHES = {"main", "master", "develop", "release", "prod", "production"} + + +def main() -> int: + payload = read_payload() + if payload.get("tool_name") != "Bash": + return 0 + + cmd = payload.get("tool_input", {}).get("command", "") + detection = detect_destructive(cmd) + if not detection: + return 0 + + kind, context = detection + reason = format_confirmation(kind, cmd, context) + banner = context.pop("_banner", f"DESTRUCTIVE: {kind}") + + respond_ask(reason, system_message=banner) + return 0 + + +def detect_destructive(cmd: str) -> Optional[tuple[str, dict]]: + """Return (kind, context) for the first destructive pattern matched.""" + + if is_force_push(cmd): + branch = run_git(["rev-parse", "--abbrev-ref", "HEAD"]).strip() + ctx: dict = {"branch": branch or "(detached)"} + if branch in PROTECTED_BRANCHES: + ctx["_banner"] = ( + f"DESTRUCTIVE: force-push to PROTECTED branch '{branch}' — " + f"rewrites shared history." + ) + return "git push --force", ctx + + if re.search(r"(?:^|[\s;&|()])git\s+reset\s+(?:\S+\s+)*--hard\b", cmd): + staged = count_lines(run_git(["diff", "--cached", "--stat"])) + unstaged = count_lines(run_git(["diff", "--stat"])) + return "git reset --hard", { + "staged_files": max(staged - 1, 0), + "unstaged_files": max(unstaged - 1, 0), + } + + if re.search(r"(?:^|[\s;&|()])git\s+clean\s+(?:\S+\s+)*-[a-zA-Z]*f", cmd): + untracked = run_git(["ls-files", "--others", "--exclude-standard"]) + return "git clean -f", { + "untracked_files": len(untracked.splitlines()), + } + + if m := re.search(r"(?:^|[\s;&|()])git\s+branch\s+(?:\S+\s+)*-D\s+(\S+)", cmd): + target = m.group(1) + unmerged = run_git( + ["log", f"main..{target}", "--oneline"] + ).strip() if target else "" + ctx = {"branch_to_delete": target} + if unmerged: + ctx["unmerged_commits"] = len(unmerged.splitlines()) + return "git branch -D", ctx + + rm_targets = detect_rm_rf(cmd) + if rm_targets is not None: + ctx = {"targets": rm_targets or ["(none parsed)"]} + dangerous = [ + t for t in rm_targets + if t in ("/", "~", "$HOME", ".", "..", "*") + or t.startswith("/") + or t.startswith("~") + ] + if dangerous: + ctx["_banner"] = ( + f"DESTRUCTIVE: rm -rf targeting root/home/wildcard paths: " + f"{', '.join(dangerous)}" + ) + return "rm -rf", ctx + + return None + + +def is_force_push(cmd: str) -> bool: + """Match `git push` with any force variant.""" + if not re.search(r"(?:^|[\s;&|()])git\s+(?:\S+\s+)*push\b", cmd): + return False + # Look for --force / --force-with-lease / -f as a standalone flag + # (avoid matching -f inside a longer token that isn't a flag chain) + return bool( + re.search(r"(?:\s|^)--force(?:-with-lease)?\b", cmd) + or re.search(r"(?:\s|^)-[a-zA-Z]*f[a-zA-Z]*\b", cmd[cmd.find("push"):]) + ) + + +def detect_rm_rf(cmd: str) -> Optional[list[str]]: + """If cmd invokes `rm` with both -r/-R and -f flags, return its targets.""" + m = re.search(r"(?:^|[\s;&|()])rm\s+(.+)$", cmd) + if not m: + return None + + rest = m.group(1).split() + flag_chars = "" + i = 0 + while i < len(rest) and rest[i].startswith("-") and rest[i] != "--": + flag_chars += rest[i][1:] + i += 1 + if rest[i:i+1] == ["--"]: + i += 1 + + has_r = bool(re.search(r"[rR]", flag_chars)) + has_f = "f" in flag_chars + if not (has_r and has_f): + return None + + return rest[i:] + + +def run_git(args: list) -> str: + try: + out = subprocess.run( + ["git"] + args, + capture_output=True, + text=True, + timeout=3, + ) + return out.stdout + except (subprocess.SubprocessError, OSError, FileNotFoundError): + return "" + + +def count_lines(text: str) -> int: + return len([ln for ln in text.splitlines() if ln.strip()]) + + +def format_confirmation(kind: str, cmd: str, context: dict) -> str: + lines = [f"Run destructive command — {kind}?", ""] + lines.append("Command:") + lines.append(f" {cmd}") + lines.append("") + + if context: + lines.append("Context:") + for key, val in context.items(): + lines.append(f" {key}: {val}") + lines.append("") + + lines.append("This operation is destructive and typically irreversible.") + lines.append("Confirm before proceeding.") + return "\n".join(lines) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hooks/settings-snippet.json b/hooks/settings-snippet.json index 2a8ac54..11459f2 100644 --- a/hooks/settings-snippet.json +++ b/hooks/settings-snippet.json @@ -12,7 +12,8 @@ "matcher": "Bash", "hooks": [ { "type": "command", "command": "~/.claude/hooks/git-commit-confirm.py" }, - { "type": "command", "command": "~/.claude/hooks/gh-pr-create-confirm.py" } + { "type": "command", "command": "~/.claude/hooks/gh-pr-create-confirm.py" }, + { "type": "command", "command": "~/.claude/hooks/destructive-bash-confirm.py" } ] } ] |
