aboutsummaryrefslogtreecommitdiff
path: root/hooks
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-19 17:39:33 -0500
committerCraig Jennings <c@cjennings.net>2026-04-19 17:39:33 -0500
commit560c29ade19be74968d8fec26d469913d383f2e8 (patch)
tree002fcdc2f28aa3b7a9373975d38be60e80d3a69f /hooks
parentc96296a30e3f712561b5f05f3f1e9d95588f643e (diff)
downloadrulesets-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.md1
-rwxr-xr-xhooks/destructive-bash-confirm.py174
-rw-r--r--hooks/settings-snippet.json3
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" }
]
}
]