Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions .claude/hooks/posttooluse_writeedit.py
Original file line number Diff line number Diff line change
@@ -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()
126 changes: 126 additions & 0 deletions .claude/hooks/pretooluse_bash.py
Original file line number Diff line number Diff line change
@@ -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()
69 changes: 69 additions & 0 deletions .claude/hooks/sessionstart.py
Original file line number Diff line number Diff line change
@@ -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()
39 changes: 39 additions & 0 deletions .claude/settings.local.json.example
Original file line number Diff line number Diff line change
@@ -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\""
}
]
}
]
}
}