feat(hooks): secret-guard blocks live secret values in tool args (#17)
This commit was merged in pull request #17.
This commit is contained in:
+6
-3
@@ -1,13 +1,14 @@
|
||||
# hooks
|
||||
|
||||
Claude Code hooks. Claude-only — Codex ignores. `bin/link.sh` / `nix/home.nix` symlink these into `~/.claude/hooks/`; **wiring is manual**, see below.
|
||||
Claude Code hooks (`secret-guard.py` also serves Codex). `bin/link.sh` / `nix/home.nix` symlink these into `~/.claude/hooks/`; **wiring is manual**, see below.
|
||||
|
||||
| hook | event | what |
|
||||
|------|-------|------|
|
||||
| `comms-lint.py` | `PreToolUse` / `Bash` | Gates `gh issue\|pr create\|edit\|comment\|review`. Lints body against `claude-md/writing.md` (150-word target / 300 hard cap above fold, no reviewer-addressing opener, plain diction, ≤4 bold spans, no essay headings). Exit 2 blocks, stderr becomes feedback. |
|
||||
| `comment-lint.py` | `PostToolUse` / `Write\|Edit\|MultiEdit` | Lints newly-added comment lines in code files against `claude-md/writing.md`. Exit 2 = revise nudge (edit already applied). Long-comment-run finding (>3 lines) is advisory, delivered via `additionalContext`. |
|
||||
| `secret-guard.py` | `PreToolUse` / `Bash\|Write\|Edit\|MultiEdit\|NotebookEdit` | Blocks tool arguments carrying a live secret: any `~/.env.claude` value of 6+ chars (value-based, so near-zero false positives; 6-7 char values match as standalone tokens) plus literal token shapes (`ghp_`, `sk-`, `AKIA`, private-key headers, credential-bearing URLs). Values under 6 chars are too short to guard — the hook emits a daily rotate warning for them instead. Exit 2 blocks; stderr names the variable, never the value. Also serves Codex via the same entry in `~/.codex/hooks.json`. |
|
||||
|
||||
Both fail open on anything they can't parse. Debug with `COMMS_LINT_DEBUG=1` / `COMMENT_LINT_DEBUG=1`.
|
||||
All fail open on anything they can't parse. Debug the linters with `COMMS_LINT_DEBUG=1` / `COMMENT_LINT_DEBUG=1`.
|
||||
|
||||
## Wiring
|
||||
|
||||
@@ -18,7 +19,9 @@ Both fail open on anything they can't parse. Debug with `COMMS_LINT_DEBUG=1` / `
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{ "matcher": "Bash",
|
||||
"hooks": [{ "type": "command", "command": "~/.claude/hooks/comms-lint.py" }] }
|
||||
"hooks": [{ "type": "command", "command": "~/.claude/hooks/comms-lint.py" }] },
|
||||
{ "matcher": "Bash|Write|Edit|MultiEdit|NotebookEdit",
|
||||
"hooks": [{ "type": "command", "command": "~/.claude/hooks/secret-guard.py" }] }
|
||||
],
|
||||
"PostToolUse": [
|
||||
{ "matcher": "Write|Edit|MultiEdit",
|
||||
|
||||
Executable
+158
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PreToolUse gate: block tool arguments that carry a live secret value.
|
||||
|
||||
Value-based, not entropy-based: reads ~/.env.claude at hook time and blocks
|
||||
when any actual value appears in the tool's arguments, plus a short list of
|
||||
unmistakable literal token shapes (ghp_, sk-, AKIA, private-key headers).
|
||||
Exit 0 = allow. Exit 2 = block; stderr names the variable, never its value.
|
||||
Fails open on anything it cannot parse.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
ENV_FILE = os.environ.get("SECRET_GUARD_ENV") or os.path.expanduser("~/.env.claude")
|
||||
# Values under this are unguardable by matching: even as standalone
|
||||
# tokens they collide with ordinary prose and code (a 4-char password
|
||||
# blocked two unrelated calls in live testing). Rotate any real secret
|
||||
# this short to a longer one instead; then it is covered automatically.
|
||||
MIN_LEN = 6
|
||||
|
||||
# Exact names whose values are identity, location or tool config, not
|
||||
# credentials. Extend deliberately, one name at a time — never by shape.
|
||||
ALLOW_NAMES = {
|
||||
"PATH", "GPG_TTY", "ANDROID_HOME", "ANDROID_SDK_ROOT", "ANTHROPIC_MODEL",
|
||||
"GITEA_USER", "WEBDAV_EMU_USER", "NTFY_ADMIN_USER", "NTFY_BOT_USER",
|
||||
"CRIT_HOST", "SCALEWAY_PROJECT_ID", "CLOUDFLARE_ACCOUNT_ID",
|
||||
"HOURLOG_API",
|
||||
}
|
||||
|
||||
# Deliberately public: the standard dev-chain test mnemonic.
|
||||
ALLOWLIST = {
|
||||
"test test test test test test test test test test test junk",
|
||||
}
|
||||
|
||||
|
||||
def plain_url(val):
|
||||
# Only a bare origin is an address, not a credential. Userinfo, any
|
||||
# path segment, query or fragment can all carry one, so they stay
|
||||
# secret; endpoint vars with real paths go in ALLOW_NAMES instead.
|
||||
m = re.match(r"https?://([^/?#@]+)(/?)$", val)
|
||||
return bool(m)
|
||||
|
||||
TOKEN_SHAPES = [
|
||||
("a GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}")),
|
||||
("a GitHub fine-grained token", re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}")),
|
||||
("an sk- API key", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}")),
|
||||
("an AWS access key id", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
|
||||
("a Slack token", re.compile(r"\bxox[bpoas]-[A-Za-z0-9-]{10,}")),
|
||||
("a private key block",
|
||||
re.compile(r"-----BEGIN (OPENSSH|RSA|EC|DSA|PGP|ENCRYPTED)? ?PRIVATE KEY")),
|
||||
]
|
||||
|
||||
LINE = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
|
||||
|
||||
|
||||
def env_secrets():
|
||||
out, unguardable = {}, []
|
||||
try:
|
||||
with open(ENV_FILE) as f:
|
||||
lines = f.readlines()
|
||||
except OSError:
|
||||
return out, unguardable
|
||||
for line in lines:
|
||||
m = LINE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
name, val = m.group(1), m.group(2).strip()
|
||||
if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'":
|
||||
val = val[1:-1]
|
||||
if name in ALLOW_NAMES:
|
||||
continue
|
||||
# Both forms are classified independently: the inherited value can
|
||||
# be stale after a rotation and the file value fresh (or vice
|
||||
# versa), and guarding old and new together is safe. A literal
|
||||
# that is nothing but a $-reference matches the referencing style
|
||||
# itself, so only its inherited form counts.
|
||||
forms = [os.environ[name]] if name in os.environ else []
|
||||
if not re.fullmatch(r"\$\{?[A-Za-z_][A-Za-z0-9_]*\}?", val):
|
||||
forms.append(val)
|
||||
for v in forms:
|
||||
if (len(v) >= MIN_LEN and v not in ALLOWLIST
|
||||
and not plain_url(v) and not v.startswith(("/", "~"))):
|
||||
out.setdefault(name, []).append(v)
|
||||
elif 0 < len(v) < MIN_LEN and name not in unguardable:
|
||||
unguardable.append(name)
|
||||
return out, unguardable
|
||||
|
||||
|
||||
def warn_unguardable(names):
|
||||
marker = os.path.expanduser("~/.cache/secret-guard-warned")
|
||||
try:
|
||||
import time
|
||||
if os.path.exists(marker) and time.time() - os.path.getmtime(marker) < 86400:
|
||||
return
|
||||
os.makedirs(os.path.dirname(marker), exist_ok=True)
|
||||
open(marker, "w").close()
|
||||
except OSError:
|
||||
return
|
||||
print(
|
||||
f"secret-guard warning (daily): {', '.join('$' + n for n in names)} "
|
||||
f"shorter than {MIN_LEN} chars — too short to guard by value matching, "
|
||||
f"so it can leak undetected. Rotate it to a longer value.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
text = json.dumps(payload.get("tool_input") or {})
|
||||
|
||||
def hit(v):
|
||||
# Short values collide as substrings of ordinary text (a 4-char
|
||||
# password blocked an unrelated command in testing), so they only
|
||||
# match as standalone tokens; long values match anywhere.
|
||||
for form in {v, json.dumps(v)[1:-1]}:
|
||||
if len(v) >= 8:
|
||||
if form in text:
|
||||
return True
|
||||
elif re.search(
|
||||
r"(?<![A-Za-z0-9])" + re.escape(form) + r"(?![A-Za-z0-9])",
|
||||
text):
|
||||
return True
|
||||
return False
|
||||
|
||||
secrets, unguardable = env_secrets()
|
||||
for name, vals in secrets.items():
|
||||
if any(hit(v) for v in vals):
|
||||
print(
|
||||
f"Blocked: the argument contains the value of ${name} from "
|
||||
f"~/.env.claude. Reference the variable (e.g. \"${name}\" via "
|
||||
f"`source ~/.env.claude`) instead of its value.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
for label, pat in TOKEN_SHAPES:
|
||||
m = pat.search(text)
|
||||
if m and m.group(0) not in ALLOWLIST:
|
||||
print(
|
||||
f"Blocked: the argument contains what looks like {label}. "
|
||||
f"Never write live credentials into commands or files; "
|
||||
f"reference an env var or a mounted file instead.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
if unguardable:
|
||||
warn_unguardable(unguardable)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -200,7 +200,9 @@ def scan_claude(root, cutoff):
|
||||
if isinstance(b, dict) and b.get("is_error"):
|
||||
r["tool_errors"] += 1
|
||||
t = clean(flatten(c))
|
||||
if t:
|
||||
# aoe titles sessions through a throwaway haiku session;
|
||||
# that prompt is not a human turn.
|
||||
if t and "Generate a concise 3 to 5 word title" not in t:
|
||||
r["turns"].append(t)
|
||||
if sidechain or not r["turns"]:
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user