secret-scanning-hooks.md A developer documented a secret scanning setup that prevents credentials from entering the context window of Claude Code, an AI coding assistant. The system uses trufflehog with 700+ detectors to intercept secrets at four entry points—user prompts, file reads, tmux panes, and bash commands—before they reach Anthropic's servers. The hooks are designed to fail-closed, blocking access if trufflehog is missing, and use exit code 2 to enforce blocks or notifications. This documents the secret scanning setup that prevents credentials from entering Claude's context window during a Claude Code session. Claude Code reads files and receives user-typed prompts, both of which can contain secrets API keys, tokens, passwords . Once a secret enters the context window, it is sent to Anthropic's servers as part of the model inference request. The goal is to intercept secrets at the boundary — before they reach Claude — rather than relying on the user to never make a mistake. trufflehog https://github.com/trufflesecurity/trufflehog scans text for secrets using 700+ detectors covering structured credential formats: GitHub PATs, AWS keys, Slack tokens, GCP service account keys, and many more. It reads from stdin via trufflehog stdin and outputs one JSON object per finding. It does not catch generic passwords e.g. DB PASSWORD="SuperSecret123" , since those have no recognizable structure. Only format-specific, verifiable credential patterns are detected. --no-verification skips live API calls to check whether a token is still valid — scans run entirely offline. Four hooks intercept secrets at different entry points: User types a prompt ──► UserPromptSubmit hook ──► blocked or passed to Claude Claude reads a file ──► PreToolUse/Read hook ──► blocked or passed to Claude Claude reads a tmux pane ──► PreToolUse/mcp tmux hook ──► blocked or passed to Claude optional, see below Claude runs a Bash cmd ──► PostToolUse/Bash hook ──► cannot block, but notifies user and Claude to rotate All hooks call the same script: .claude/hooks/scan-secrets.sh . "hooks": { "PreToolUse": { "matcher": "Read", "hooks": { "type": "command", "command": "/path/to/.claude/hooks/scan-secrets.sh read", "timeout": 60, "statusMessage": "Scanning for secrets..." } }, { "matcher": "mcp tmux capture-pane|mcp tmux show-buffer|mcp tmux search-buffer|mcp tmux subsearch-buffer", "hooks": { "type": "command", "command": "/path/to/.claude/hooks/scan-secrets.sh tmux", "timeout": 60, "statusMessage": "Scanning tmux pane for secrets..." } } , "PostToolUse": { "matcher": "Bash", "hooks": { "type": "command", "command": "/path/to/.claude/hooks/scan-secrets.sh bash", "timeout": 60, "statusMessage": "Scanning bash output for secrets..." } } , "UserPromptSubmit": { "hooks": { "type": "command", "command": "/path/to/.claude/hooks/scan-secrets.sh prompt", "timeout": 60, "statusMessage": "Scanning prompt for secrets..." } } } A PreToolUse hook exits with code 2 to block the tool from running. A PostToolUse hook exits with code 2 to notify — the tool has already run and the output is in Claude's context, but the hook's reason is surfaced to both the user terminal and Claude error notice , prompting key rotation. Exit code semantics— counterintuitively, exit code 1 doesnotblock in Claude Code hooks; it is treated as a non-blocking error and the tool proceeds. Only exit code 2 enforces a block for PreToolUse or surfaces a notification for PostToolUse . A script that errors e.g. a failed jq parse producing an empty summary must therefore still exit 2, not rely on a non-zero exit from ${var:?} or similar guards. Fail-closed design— if trufflehog is not installed or not on PATH , the script blocks with exit 2 rather than passing through silently. This prevents the hooks from providing a false sense of security on machines where the binary is missing. Install trufflehog with brew install trufflehog . bash /bin/bash Scans for secrets using trufflehog. Reads the Claude Code hook event JSON from stdin. Usage: scan-secrets.sh prompt|read|tmux|bash Exits 2 with JSON blocking output if any secret is found. mode="${1:?usage: scan-secrets.sh prompt|read|tmux|bash}" if command -v trufflehog & /dev/null; then jq -n '{"decision":"block","reason":"trufflehog not found — install it to enable secret scanning"}' exit 2 fi event=$ cat case "$mode" in prompt content=$ echo "$event" | jq -r '.prompt' label="prompt" ;; bash content=$ echo "$event" | jq -r '.tool output' label="bash output" ;; read label=$ echo "$event" | jq -r '.tool input.file path' { -z "$label" || "$label" = "null" || -f "$label" ; } && exit 0 content=$ cat "$label" ;; tmux tool=$ echo "$event" | jq -r '.tool name' socket=$ echo "$event" | jq -r '.tool input.socket // empty' tmux cmd= tmux -n "$socket" && tmux cmd+= -S "$socket" case "$tool" in mcp tmux capture-pane pane id=$ echo "$event" | jq -r '.tool input.paneId' label="tmux pane $pane id" content=$ "${tmux cmd @ }" capture-pane -p -t "$pane id" 2 /dev/null ;; mcp tmux show-buffer buf name=$ echo "$event" | jq -r '.tool input.name // empty' label="tmux buffer ${buf name:-latest}" if -n "$buf name" ; then content=$ "${tmux cmd @ }" show-buffer -b "$buf name" 2 /dev/null else content=$ "${tmux cmd @ }" show-buffer 2 /dev/null fi ;; mcp tmux search-buffer|mcp tmux subsearch-buffer buf name=$ echo "$event" | jq -r '.tool input.buffer // empty' label="tmux buffer ${buf name:-latest}" if -n "$buf name" ; then content=$ "${tmux cmd @ }" show-buffer -b "$buf name" 2 /dev/null else content=$ "${tmux cmd @ }" show-buffer 2 /dev/null fi ;; exit 0 ;; esac -z "$content" && exit 0 ;; echo "Unknown mode: $mode" &2 exit 1 ;; esac th out=$ echo "$content" | trufflehog stdin --no-update --no-verification --json 2 /dev/null if -n "$th out" ; then if "$mode" = "prompt" || "$mode" = "bash" ; then prompt: blocked before Claude sees it — show full secret so user knows what to rotate bash: already in Claude's context — include full secret so Claude knows to warn user to rotate summary=$ echo "$th out" | jq -r '" " + .DetectorName + " " + .Raw' 2 /dev/null | paste -sd ', ' - else read/tmux: tool result sent back to Claude — omit secret value to avoid leaking it via the reason summary=$ echo "$th out" | jq -r '.DetectorName' 2 /dev/null | sort -u | paste -sd ', ' - fi -z "$summary" && summary="unknown jq parse failed " jq -n --arg summary "$summary" --arg label "$label" \ '{"decision":"block","reason": "Secret detected in " + $label + " — " + $summary }' exit 2 fi The block reason is handled differently depending on whether the secret has already reached Claude: | Mode | Hook type | Secret in context? | Reason includes secret? | Why | |---|---|---|---|---| prompt | PreToolUse | No — blocked before Claude sees it | Yes | User needs it to rotate; only shown in terminal | read | PreToolUse | No — blocked before Claude sees it | No | Reason is returned to Claude as the tool result | tmux | PreToolUse | No — blocked before Claude sees it | No | Reason is returned to Claude as the tool result | bash | PostToolUse | Yes — already in Claude's context | Yes | Claude needs it to warn user to rotate | The tmux hook requires the bnomei/tmux-mcp https://github.com/bnomei/tmux-mcp MCP server. This is not part of a standard Claude Code setup — most users won't have it. The tool names in the hook matcher mcp tmux capture-pane , mcp tmux show-buffer , etc. are specific to that implementation. If you are not using tmux-mcp, you have two options — both safe: - Leave everything as-is recommended . The hook matcher only fires when Claude calls a mcp tmux tool, which never happens without the MCP server configured. The tmux case in the script handles a missing tmux binary gracefully — commands fail silently, content is empty, and the script exits 0. No effect on anything. - Remove both the matcher and the Fully stripped down. Also update the usage comment and tmux case from the script. mode validation from prompt|read|tmux|bash to prompt|read|bash . The remaining three hooks prompt , read , bash work without any MCP or tmux dependency. The prefix in Claude Code runs a shell command and injects its stdout directly into the conversation. This bypasses all hooks — there is no UserPromptSubmit event for local command output, and Claude never issues a Read or MCP tool call for it. cat /path/to/file-with-secret.txt ← output goes straight to Claude's context This is a platform limitation. As a workaround, avoid using or bash blocks to print files that might contain credentials. Use Claude's Read tool instead which is protected by the PreToolUse hook . - Install trufflehog: brew install trufflehog - Copy scan-secrets.sh to .claude/hooks/ and make it executable: chmod +x scan-secrets.sh - Add the hook entries to .claude/settings.local.json use absolute paths in command - If using the tmux hook: install bnomei/tmux-mcp https://github.com/bnomei/tmux-mcp and configure it as an MCP server in Claude Code - Restart the Claude Code CLI Should pass no secret echo '{"prompt":"hello world"}' | .claude/hooks/scan-secrets.sh prompt Should block prompt with secret — shows full token in terminal echo '{"prompt":"TOKEN=ghp REPLACE WITH A REAL FORMAT TOKEN"}' | .claude/hooks/scan-secrets.sh prompt Should pass non-existent file echo '{"tool input":{"file path":"/tmp/nonexistent"}}' | .claude/hooks/scan-secrets.sh read Should block file with secret echo 'REPLACE WITH REAL TOKEN' /tmp/test-secret.txt echo '{"tool input":{"file path":"/tmp/test-secret.txt"}}' | .claude/hooks/scan-secrets.sh read Should notify bash output with secret — cannot block, warns user and Claude to rotate echo '{"tool name":"Bash","tool input":{"command":"cat /tmp/test-secret.txt"},"tool output":"REPLACE WITH REAL TOKEN\n"}' \ | .claude/hooks/scan-secrets.sh bash Should block tmux buffer with secret — requires tmux-mcp tmux new-session -d -s test tmux set-buffer 'REPLACE WITH REAL TOKEN' buf=$ tmux list-buffers -F ' {buffer name}' | head -1 echo "{\"tool name\":\"mcp tmux show-buffer\",\"tool input\":{\"name\":\"$buf\"}}" \ | .claude/hooks/scan-secrets.sh tmux tmux kill-session -t test