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 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 (forPreToolUse
) or surfaces a notification (forPostToolUse
). A script that errors (e.g. a failedjq
parse producing an emptysummary
) must therefore still exit 2, not rely on a non-zero exit from${var:?}
or similar guards.
Fail-closed design— iftrufflehog
is not installed or not onPATH
, 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 withbrew install trufflehog
.
#!/bin/bash
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
summary=$(echo "$th_out" | jq -r '"[" + .DetectorName + "] " + .Raw' 2>/dev/null | paste -sd ', ' -)
else
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 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 amcp__tmux__*
tool, which never happens without the MCP server configured. Thetmux)
case in the script handles a missingtmux
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 andtmux)
case from the script.mode
validation fromprompt|read|tmux|bash
toprompt|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 incommand
) - If using the tmux hook: install bnomei/tmux-mcpand configure it as an MCP server in Claude Code - Restart the Claude Code CLI
echo '{"prompt":"hello world"}' | .claude/hooks/scan-secrets.sh prompt
echo '{"prompt":"TOKEN=ghp_REPLACE_WITH_A_REAL_FORMAT_TOKEN"}' | .claude/hooks/scan-secrets.sh prompt
echo '{"tool_input":{"file_path":"/tmp/nonexistent"}}' | .claude/hooks/scan-secrets.sh read
echo 'REPLACE_WITH_REAL_TOKEN' > /tmp/test-secret.txt
echo '{"tool_input":{"file_path":"/tmp/test-secret.txt"}}' | .claude/hooks/scan-secrets.sh read
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
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