From a77fc5efee24a0675f9ddecaa7f8eabfbc3fcef0 Mon Sep 17 00:00:00 2001 From: "const.koutsakis@aurecongroup.com" Date: Sun, 26 Apr 2026 20:54:01 +1000 Subject: [PATCH] chore: .claude hooks (pretooluse_bash, posttooluse_writeedit, sessionstart) + settings.local.json.example (#15) Port the three .claude/hooks/*.py scripts and settings.local.json.example verbatim from Teller. Drop .svelte from PRETTIER_EXTENSIONS and add .jsx (template uses React, not Svelte). Strip Teller-specific PR references in comments. Run ruff format on the result. .claude/bash-log.txt and .claude/settings.local.json are already in .gitignore from #5. Closes #15 Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/hooks/posttooluse_writeedit.py | 102 ++++++++++++++++++++ .claude/hooks/pretooluse_bash.py | 126 +++++++++++++++++++++++++ .claude/hooks/sessionstart.py | 69 ++++++++++++++ .claude/settings.local.json.example | 39 ++++++++ 4 files changed, 336 insertions(+) create mode 100644 .claude/hooks/posttooluse_writeedit.py create mode 100644 .claude/hooks/pretooluse_bash.py create mode 100644 .claude/hooks/sessionstart.py create mode 100644 .claude/settings.local.json.example diff --git a/.claude/hooks/posttooluse_writeedit.py b/.claude/hooks/posttooluse_writeedit.py new file mode 100644 index 0000000..11051ce --- /dev/null +++ b/.claude/hooks/posttooluse_writeedit.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""PostToolUse hook for Write | Edit — formatter dispatch. + +After a successful Write or Edit, format the touched file in place: + + - `.py` -> `uv run ruff check --fix` + `uv run ruff format` + - `.ts .tsx .js .jsx .json` -> `npx --no-install prettier -w` (from frontend/) + - `.css .html .md` -> `npx --no-install prettier -w` (from frontend/) + +Silent on failure — the write already landed; formatting is best-effort. Exit code +is always 0 so Claude Code never rolls back the edit over a missing formatter. +""" + +from __future__ import annotations + +import contextlib +import json +import os +import subprocess +import sys +from pathlib import Path + +PY_EXTENSIONS = {".py"} +PRETTIER_EXTENSIONS = { + ".ts", + ".tsx", + ".js", + ".jsx", + ".json", + ".css", + ".html", + ".md", +} + + +def project_dir() -> Path: + env = os.environ.get("CLAUDE_PROJECT_DIR") + if env: + return Path(env) + return Path(__file__).resolve().parent.parent.parent + + +def run_quiet(cmd: list[str], cwd: Path) -> None: + with contextlib.suppress(FileNotFoundError, subprocess.TimeoutExpired): + subprocess.run( + cmd, + cwd=cwd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + timeout=60, + ) + + +def format_python(path: Path, base: Path) -> None: + run_quiet(["uv", "run", "ruff", "check", "--fix", str(path)], cwd=base) + run_quiet(["uv", "run", "ruff", "format", str(path)], cwd=base) + + +def format_prettier(path: Path, base: Path) -> None: + frontend = base / "frontend" + if not frontend.is_dir(): + return + # --no-install so a checkout without prettier installed silently no-ops + # instead of triggering an npx download. + run_quiet( + ["npx", "--no-install", "prettier", "-w", str(path)], + cwd=frontend, + ) + + +def main() -> None: + try: + payload = json.load(sys.stdin) + except json.JSONDecodeError: + return + if not isinstance(payload, dict): + return + tool_input = payload.get("tool_input") + if not isinstance(tool_input, dict): + return + file_path = tool_input.get("file_path", "") + if not isinstance(file_path, str) or not file_path: + return + + path = Path(file_path) + if not path.is_absolute(): + path = project_dir() / path + if not path.exists(): + return + + suffix = path.suffix.lower() + base = project_dir() + + if suffix in PY_EXTENSIONS: + format_python(path, base) + elif suffix in PRETTIER_EXTENSIONS: + format_prettier(path, base) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/pretooluse_bash.py b/.claude/hooks/pretooluse_bash.py new file mode 100644 index 0000000..ad43293 --- /dev/null +++ b/.claude/hooks/pretooluse_bash.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""PreToolUse hook for Bash — forbidden-flag blocker, secret scanner, audit log. + +Invoked by Claude Code on every Bash tool call. Reads the hook payload as JSON +on stdin and performs three checks in order: + +1. Forbidden-flag blocker — deny `git --no-verify`, `--no-hooks`, `--no-gpg-sign`. +2. Secret scanner — on `git commit`, scan the staged diff for known + secret shapes (AWS keys, sk-*, ghp_/ghs_*, PEMs, + Slack tokens). +3. Audit log — append a timestamped record of every command the + agent runs to .claude/bash-log.txt (gitignored). + +Exit codes +---------- +0 — command allowed +2 — command blocked (Claude Code surfaces stderr back to the agent) +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from datetime import UTC, datetime +from pathlib import Path + +FORBIDDEN_FLAG = re.compile(r"\bgit\b[^\n]*--(?:no-verify|no-hooks|no-gpg-sign)\b") + +SECRET_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ + ("AWS access key id", re.compile(r"AKIA[0-9A-Z]{16}")), + ( + "AWS secret access key assignment", + re.compile(r"aws_secret_access_key\s*=", re.IGNORECASE), + ), + ("OpenAI-style API key", re.compile(r"sk-[A-Za-z0-9]{20,}")), + ("GitHub personal access token", re.compile(r"ghp_[A-Za-z0-9]{36}")), + ("GitHub server-to-server token", re.compile(r"ghs_[A-Za-z0-9]{36}")), + ("Slack bot token", re.compile(r"xoxb-[A-Za-z0-9-]+")), + ("PEM private key block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")), +] + + +def project_dir() -> Path: + env = os.environ.get("CLAUDE_PROJECT_DIR") + if env: + return Path(env) + return Path(__file__).resolve().parent.parent.parent + + +def load_payload() -> dict[str, object]: + try: + data = json.load(sys.stdin) + except json.JSONDecodeError: + return {} + return data if isinstance(data, dict) else {} + + +def block(reason: str) -> None: + print(reason, file=sys.stderr) + sys.exit(2) + + +def scan_staged_diff(cwd: Path) -> str | None: + """Return matched pattern name when a secret shape appears in staged diff.""" + try: + diff = subprocess.check_output( + ["git", "diff", "--cached", "--no-color"], + cwd=cwd, + stderr=subprocess.DEVNULL, + text=True, + errors="replace", + ) + except subprocess.CalledProcessError, FileNotFoundError: + return None + for name, pattern in SECRET_PATTERNS: + if pattern.search(diff): + return name + return None + + +def audit(command: str, base: Path) -> None: + log = base / ".claude" / "bash-log.txt" + try: + log.parent.mkdir(parents=True, exist_ok=True) + ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + with log.open("a", encoding="utf-8") as fh: + fh.write(f"{ts}\t{command}\n") + except OSError: + pass + + +def main() -> None: + payload = load_payload() + tool_input = payload.get("tool_input") + if not isinstance(tool_input, dict): + return + command = tool_input.get("command", "") + if not isinstance(command, str) or not command.strip(): + return + + if FORBIDDEN_FLAG.search(command): + block( + "Blocked by PreToolUse hook: forbidden git flag " + "(--no-verify | --no-hooks | --no-gpg-sign). " + "Fix the underlying issue instead of bypassing the check." + ) + + base = project_dir() + stripped = command.lstrip() + if stripped.startswith(("git commit", '"git commit', "'git commit")): + match = scan_staged_diff(base) + if match: + block( + "Blocked by PreToolUse hook: " + f"staged diff matches secret pattern ({match}). " + "Remove the secret, rotate if already committed, and re-stage." + ) + + audit(command, base) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/sessionstart.py b/.claude/hooks/sessionstart.py new file mode 100644 index 0000000..f9b424d --- /dev/null +++ b/.claude/hooks/sessionstart.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""SessionStart hook — inject current branch + working-tree state as session context. + +Claude Code treats stdout as `additionalContext` on SessionStart, so this script +prints the current git branch and `git status --short` — giving the agent the +same orientation a human gets from opening the terminal. Runs once per session. + +Silent and zero-exit on any failure: a missing git directory or a transient +command error must never block session startup. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + + +def project_dir() -> Path: + env = os.environ.get("CLAUDE_PROJECT_DIR") + if env: + return Path(env) + return Path(__file__).resolve().parent.parent.parent + + +def git_output(args: list[str], cwd: Path) -> str: + try: + return subprocess.check_output( + ["git", *args], + cwd=cwd, + stderr=subprocess.DEVNULL, + text=True, + errors="replace", + timeout=5, + ).strip() + except ( + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + FileNotFoundError, + ): + return "" + + +def main() -> None: + base = project_dir() + branch = git_output(["branch", "--show-current"], base) + status = git_output(["status", "--short"], base) + + if not branch and not status: + return + + lines = ["# Repository context (injected by SessionStart hook)"] + if branch: + lines.append(f"- Current branch: `{branch}`") + if status: + lines.append("") + lines.append("Working tree (`git status --short`):") + lines.append("```") + lines.append(status) + lines.append("```") + else: + lines.append("- Working tree: clean") + + sys.stdout.write("\n".join(lines) + "\n") + + +if __name__ == "__main__": + main() diff --git a/.claude/settings.local.json.example b/.claude/settings.local.json.example new file mode 100644 index 0000000..1bebea1 --- /dev/null +++ b/.claude/settings.local.json.example @@ -0,0 +1,39 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "_comment": "Copy this file to .claude/settings.local.json (gitignored) to enable the agent hook stack. See docs/DEVELOPMENT.md#local-agent-hook-setup.", + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse_bash.py\"" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/posttooluse_writeedit.py\"" + } + ] + } + ], + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/sessionstart.py\"" + } + ] + } + ] + } +}