# secret-scanning-hooks.md

> Source: <https://gist.github.com/oajara/3a4d1e71fa727b893a4ffaedd6dd5064>
> Published: 2026-06-18 15:22:39+00:00

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
```


