Skip to content

Commit 69de68f

Browse files
constkclaude
andauthored
chore: .claude hooks (pretooluse_bash, posttooluse_writeedit, sessionstart) + settings.local.json.example (#15) (#37)
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) <noreply@anthropic.com>
1 parent 07565ea commit 69de68f

4 files changed

Lines changed: 336 additions & 0 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
#!/usr/bin/env python3
2+
"""PostToolUse hook for Write | Edit — formatter dispatch.
3+
4+
After a successful Write or Edit, format the touched file in place:
5+
6+
- `.py` -> `uv run ruff check --fix` + `uv run ruff format`
7+
- `.ts .tsx .js .jsx .json` -> `npx --no-install prettier -w` (from frontend/)
8+
- `.css .html .md` -> `npx --no-install prettier -w` (from frontend/)
9+
10+
Silent on failure — the write already landed; formatting is best-effort. Exit code
11+
is always 0 so Claude Code never rolls back the edit over a missing formatter.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import contextlib
17+
import json
18+
import os
19+
import subprocess
20+
import sys
21+
from pathlib import Path
22+
23+
PY_EXTENSIONS = {".py"}
24+
PRETTIER_EXTENSIONS = {
25+
".ts",
26+
".tsx",
27+
".js",
28+
".jsx",
29+
".json",
30+
".css",
31+
".html",
32+
".md",
33+
}
34+
35+
36+
def project_dir() -> Path:
37+
env = os.environ.get("CLAUDE_PROJECT_DIR")
38+
if env:
39+
return Path(env)
40+
return Path(__file__).resolve().parent.parent.parent
41+
42+
43+
def run_quiet(cmd: list[str], cwd: Path) -> None:
44+
with contextlib.suppress(FileNotFoundError, subprocess.TimeoutExpired):
45+
subprocess.run(
46+
cmd,
47+
cwd=cwd,
48+
stdout=subprocess.DEVNULL,
49+
stderr=subprocess.DEVNULL,
50+
check=False,
51+
timeout=60,
52+
)
53+
54+
55+
def format_python(path: Path, base: Path) -> None:
56+
run_quiet(["uv", "run", "ruff", "check", "--fix", str(path)], cwd=base)
57+
run_quiet(["uv", "run", "ruff", "format", str(path)], cwd=base)
58+
59+
60+
def format_prettier(path: Path, base: Path) -> None:
61+
frontend = base / "frontend"
62+
if not frontend.is_dir():
63+
return
64+
# --no-install so a checkout without prettier installed silently no-ops
65+
# instead of triggering an npx download.
66+
run_quiet(
67+
["npx", "--no-install", "prettier", "-w", str(path)],
68+
cwd=frontend,
69+
)
70+
71+
72+
def main() -> None:
73+
try:
74+
payload = json.load(sys.stdin)
75+
except json.JSONDecodeError:
76+
return
77+
if not isinstance(payload, dict):
78+
return
79+
tool_input = payload.get("tool_input")
80+
if not isinstance(tool_input, dict):
81+
return
82+
file_path = tool_input.get("file_path", "")
83+
if not isinstance(file_path, str) or not file_path:
84+
return
85+
86+
path = Path(file_path)
87+
if not path.is_absolute():
88+
path = project_dir() / path
89+
if not path.exists():
90+
return
91+
92+
suffix = path.suffix.lower()
93+
base = project_dir()
94+
95+
if suffix in PY_EXTENSIONS:
96+
format_python(path, base)
97+
elif suffix in PRETTIER_EXTENSIONS:
98+
format_prettier(path, base)
99+
100+
101+
if __name__ == "__main__":
102+
main()

.claude/hooks/pretooluse_bash.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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()

.claude/hooks/sessionstart.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#!/usr/bin/env python3
2+
"""SessionStart hook — inject current branch + working-tree state as session context.
3+
4+
Claude Code treats stdout as `additionalContext` on SessionStart, so this script
5+
prints the current git branch and `git status --short` — giving the agent the
6+
same orientation a human gets from opening the terminal. Runs once per session.
7+
8+
Silent and zero-exit on any failure: a missing git directory or a transient
9+
command error must never block session startup.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import os
15+
import subprocess
16+
import sys
17+
from pathlib import Path
18+
19+
20+
def project_dir() -> Path:
21+
env = os.environ.get("CLAUDE_PROJECT_DIR")
22+
if env:
23+
return Path(env)
24+
return Path(__file__).resolve().parent.parent.parent
25+
26+
27+
def git_output(args: list[str], cwd: Path) -> str:
28+
try:
29+
return subprocess.check_output(
30+
["git", *args],
31+
cwd=cwd,
32+
stderr=subprocess.DEVNULL,
33+
text=True,
34+
errors="replace",
35+
timeout=5,
36+
).strip()
37+
except (
38+
subprocess.CalledProcessError,
39+
subprocess.TimeoutExpired,
40+
FileNotFoundError,
41+
):
42+
return ""
43+
44+
45+
def main() -> None:
46+
base = project_dir()
47+
branch = git_output(["branch", "--show-current"], base)
48+
status = git_output(["status", "--short"], base)
49+
50+
if not branch and not status:
51+
return
52+
53+
lines = ["# Repository context (injected by SessionStart hook)"]
54+
if branch:
55+
lines.append(f"- Current branch: `{branch}`")
56+
if status:
57+
lines.append("")
58+
lines.append("Working tree (`git status --short`):")
59+
lines.append("```")
60+
lines.append(status)
61+
lines.append("```")
62+
else:
63+
lines.append("- Working tree: clean")
64+
65+
sys.stdout.write("\n".join(lines) + "\n")
66+
67+
68+
if __name__ == "__main__":
69+
main()
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"$schema": "https://json.schemastore.org/claude-code-settings.json",
3+
"_comment": "Copy this file to .claude/settings.local.json (gitignored) to enable the agent hook stack. See docs/DEVELOPMENT.md#local-agent-hook-setup.",
4+
"hooks": {
5+
"PreToolUse": [
6+
{
7+
"matcher": "Bash",
8+
"hooks": [
9+
{
10+
"type": "command",
11+
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse_bash.py\""
12+
}
13+
]
14+
}
15+
],
16+
"PostToolUse": [
17+
{
18+
"matcher": "Write|Edit",
19+
"hooks": [
20+
{
21+
"type": "command",
22+
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/posttooluse_writeedit.py\""
23+
}
24+
]
25+
}
26+
],
27+
"SessionStart": [
28+
{
29+
"matcher": "startup|resume",
30+
"hooks": [
31+
{
32+
"type": "command",
33+
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/sessionstart.py\""
34+
}
35+
]
36+
}
37+
]
38+
}
39+
}

0 commit comments

Comments
 (0)