-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpretooluse_bash.py
More file actions
126 lines (102 loc) · 3.9 KB
/
Copy pathpretooluse_bash.py
File metadata and controls
126 lines (102 loc) · 3.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
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()