Secret Block
PreToolUse hook that denies Bash commands containing secret-shaped strings before they execute. Reads the canonical pattern set from hooks/patterns.json (plus patterns.local.json if present). Belt-and-suspenders alongside the secret-handling rules in Emits a JSON permissionDecision deny on detection.
id hook/secret-blockv1.0.0by convergent-systems-key
- Event
PreToolUse- Trigger
tool-name—Bash- Language
python- Side effects
- denies tool call with explanation when secret pattern detected
- Platforms
linuxmacoswindows- Notes
- Logic is cross-platform. Wiring: use 'ai hooks run secret-block' in settings.json — the ai binary discovers Python on each OS. Pure Python regex on stdin JSON. Works on all platforms.
- Depends on
- hook/lib
Script · secret-block.py
#!/usr/bin/env python3
"""hooks/secret-block.py — PreToolUse hook that denies Bash commands
containing secret-shaped strings before they execute.
Reads the canonical pattern set from hooks/patterns.json
(+ patterns.local.json if present). Belt-and-suspenders alongside the
secret-handling rules in Common.md §4. Per SPEC.md §10.1.
Input contract (Claude Code PreToolUse):
- The full tool-use payload arrives on stdin as JSON.
- On detection: emit JSON permissionDecision deny on stdout, exit 0.
- On clean: exit 0, no stdout.
- Stderr is shown to the user.
Output schema:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "<explanation>"
}
}
Self-check:
--self-check Loads patterns.json and compiles every regex.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
# Allow `import _lib` when the hooks dir is on PYTHONPATH OR when this
# script is exec'd directly from ~/.ai/hooks/.
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _lib # noqa: E402
def deny(reason: str) -> None:
"""Emit a permission-deny decision via JSON stdout and exit 0."""
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}))
sys.exit(0)
def extract_command(payload: dict) -> str:
"""Best-effort extraction of the command being run from the
Claude tool-use payload shape. Falls back to stringifying the
whole payload, which still matches patterns."""
if not isinstance(payload, dict):
return json.dumps(payload)
# Common shapes across Claude Code event versions.
cmd = (
payload.get("command")
or payload.get("input", {}).get("command")
or payload.get("params", {}).get("command")
or payload.get("tool_input", {}).get("command")
)
if cmd:
return cmd
return json.dumps(payload)
def main(argv: list) -> int:
if "--self-check" in argv:
return _lib.self_check_ok()
raw = sys.stdin.read()
if not raw.strip():
# No payload to inspect; allow by default.
return 0
try:
payload = json.loads(raw)
except json.JSONDecodeError:
# If we can't parse, still scan the raw text for patterns.
payload = {"raw": raw}
# Only guard PreToolUse events on Bash/shell tools.
if isinstance(payload, dict):
hook_event = (
payload.get("hookEventName")
or payload.get("hook_event_name")
or ""
)
if hook_event and hook_event != "PreToolUse":
return 0
tool_name = payload.get("tool_name") or payload.get("toolName") or ""
if tool_name and tool_name not in ("Bash", "shell", "execute"):
return 0
# Extract the command to scan.
tool_input = (
payload.get("tool_input")
or payload.get("toolInput")
or payload.get("toolArgs")
or {}
)
if isinstance(tool_input, dict):
command = tool_input.get("command") or tool_input.get("cmd") or ""
else:
command = extract_command(payload)
else:
command = raw
if not command:
return 0
patterns = _lib.load_patterns()
hits = _lib.scan_lines(command.splitlines() or [command], patterns)
if not hits:
return 0
# Build a deny reason that does NOT echo the full secret value.
# Take the first hit and construct a truncated/redacted snippet.
hit = hits[0]
pattern_id = hit.get("pattern_id", "unknown")
severity = hit.get("severity", "medium")
snippet = hit.get("snippet", "[redacted]")
extra = ""
if len(hits) > 1:
extra = f" (and {len(hits) - 1} more match(es))"
deny(
f"Possible secret detected in Bash command: pattern={pattern_id} severity={severity}{extra}.\n"
f"Snippet (redacted): {snippet}\n"
"Per Common.md §1.P4 (no secrets in artifacts; non-overridable).\n"
"Use OS clipboard transfer (Common.md §4.2) instead of embedding secrets in commands."
)
# deny() calls sys.exit(0); this return is unreachable but satisfies type checkers.
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
securitysecretsgovernancepatternsclaude-code
Author convergent-systems-key. Catalog data license CC-BY-4.0.