|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""PreToolUse hook for Bash — forbidden-flag blocker, secret scanner, audit log. |
| 3 | +
|
| 4 | +Invoked by Claude Code on every Bash tool call. Reads the hook payload as JSON |
| 5 | +on stdin and performs three checks in order: |
| 6 | +
|
| 7 | +1. Forbidden-flag blocker — deny `git --no-verify`, `--no-hooks`, `--no-gpg-sign`. |
| 8 | +2. Secret scanner — on `git commit`, scan the staged diff for known |
| 9 | + secret shapes (AWS keys, sk-*, ghp_/ghs_*, PEMs, |
| 10 | + Slack tokens). |
| 11 | +3. Audit log — append a timestamped record of every command the |
| 12 | + agent runs to .claude/bash-log.txt (gitignored). |
| 13 | +
|
| 14 | +Exit codes |
| 15 | +---------- |
| 16 | +0 — command allowed |
| 17 | +2 — command blocked (Claude Code surfaces stderr back to the agent) |
| 18 | +""" |
| 19 | + |
| 20 | +from __future__ import annotations |
| 21 | + |
| 22 | +import json |
| 23 | +import os |
| 24 | +import re |
| 25 | +import subprocess |
| 26 | +import sys |
| 27 | +from datetime import UTC, datetime |
| 28 | +from pathlib import Path |
| 29 | + |
| 30 | +FORBIDDEN_FLAG = re.compile(r"\bgit\b[^\n]*--(?:no-verify|no-hooks|no-gpg-sign)\b") |
| 31 | + |
| 32 | +SECRET_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ |
| 33 | + ("AWS access key id", re.compile(r"AKIA[0-9A-Z]{16}")), |
| 34 | + ( |
| 35 | + "AWS secret access key assignment", |
| 36 | + re.compile(r"aws_secret_access_key\s*=", re.IGNORECASE), |
| 37 | + ), |
| 38 | + ("OpenAI-style API key", re.compile(r"sk-[A-Za-z0-9]{20,}")), |
| 39 | + ("GitHub personal access token", re.compile(r"ghp_[A-Za-z0-9]{36}")), |
| 40 | + ("GitHub server-to-server token", re.compile(r"ghs_[A-Za-z0-9]{36}")), |
| 41 | + ("Slack bot token", re.compile(r"xoxb-[A-Za-z0-9-]+")), |
| 42 | + ("PEM private key block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")), |
| 43 | +] |
| 44 | + |
| 45 | + |
| 46 | +def project_dir() -> Path: |
| 47 | + env = os.environ.get("CLAUDE_PROJECT_DIR") |
| 48 | + if env: |
| 49 | + return Path(env) |
| 50 | + return Path(__file__).resolve().parent.parent.parent |
| 51 | + |
| 52 | + |
| 53 | +def load_payload() -> dict[str, object]: |
| 54 | + try: |
| 55 | + data = json.load(sys.stdin) |
| 56 | + except json.JSONDecodeError: |
| 57 | + return {} |
| 58 | + return data if isinstance(data, dict) else {} |
| 59 | + |
| 60 | + |
| 61 | +def block(reason: str) -> None: |
| 62 | + print(reason, file=sys.stderr) |
| 63 | + sys.exit(2) |
| 64 | + |
| 65 | + |
| 66 | +def scan_staged_diff(cwd: Path) -> str | None: |
| 67 | + """Return matched pattern name when a secret shape appears in staged diff.""" |
| 68 | + try: |
| 69 | + diff = subprocess.check_output( |
| 70 | + ["git", "diff", "--cached", "--no-color"], |
| 71 | + cwd=cwd, |
| 72 | + stderr=subprocess.DEVNULL, |
| 73 | + text=True, |
| 74 | + errors="replace", |
| 75 | + ) |
| 76 | + except subprocess.CalledProcessError, FileNotFoundError: |
| 77 | + return None |
| 78 | + for name, pattern in SECRET_PATTERNS: |
| 79 | + if pattern.search(diff): |
| 80 | + return name |
| 81 | + return None |
| 82 | + |
| 83 | + |
| 84 | +def audit(command: str, base: Path) -> None: |
| 85 | + log = base / ".claude" / "bash-log.txt" |
| 86 | + try: |
| 87 | + log.parent.mkdir(parents=True, exist_ok=True) |
| 88 | + ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") |
| 89 | + with log.open("a", encoding="utf-8") as fh: |
| 90 | + fh.write(f"{ts}\t{command}\n") |
| 91 | + except OSError: |
| 92 | + pass |
| 93 | + |
| 94 | + |
| 95 | +def main() -> None: |
| 96 | + payload = load_payload() |
| 97 | + tool_input = payload.get("tool_input") |
| 98 | + if not isinstance(tool_input, dict): |
| 99 | + return |
| 100 | + command = tool_input.get("command", "") |
| 101 | + if not isinstance(command, str) or not command.strip(): |
| 102 | + return |
| 103 | + |
| 104 | + if FORBIDDEN_FLAG.search(command): |
| 105 | + block( |
| 106 | + "Blocked by PreToolUse hook: forbidden git flag " |
| 107 | + "(--no-verify | --no-hooks | --no-gpg-sign). " |
| 108 | + "Fix the underlying issue instead of bypassing the check." |
| 109 | + ) |
| 110 | + |
| 111 | + base = project_dir() |
| 112 | + stripped = command.lstrip() |
| 113 | + if stripped.startswith(("git commit", '"git commit', "'git commit")): |
| 114 | + match = scan_staged_diff(base) |
| 115 | + if match: |
| 116 | + block( |
| 117 | + "Blocked by PreToolUse hook: " |
| 118 | + f"staged diff matches secret pattern ({match}). " |
| 119 | + "Remove the secret, rotate if already committed, and re-stage." |
| 120 | + ) |
| 121 | + |
| 122 | + audit(command, base) |
| 123 | + |
| 124 | + |
| 125 | +if __name__ == "__main__": |
| 126 | + main() |
0 commit comments