Files
agent-skills/hooks/secret-guard.py
T
naps62-yolo ae927736a0
ci / lint (push) Successful in 14s
ci / nix (push) Successful in 11s
feat(hooks): secret-guard blocks live secret values in tool args (#17)
2026-08-21 21:57:58 +01:00

159 lines
5.8 KiB
Python
Executable File

#!/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()