cd /news/ai-safety/secret-scanning-hooks-md · home topics ai-safety article
[ARTICLE · art-37936] src=gist.github.com ↗ pub= topic=ai-safety verified=true sentiment=· neutral

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.

read7 min views1 publishedJun 18, 2026

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
── more in #ai-safety 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/secret-scanning-hook…] indexed:0 read:7min 2026-06-18 ·