Security Playbook

A UserPromptSubmit hook that refuses prompts containing secrets and tells Claude which branch it's on

One 50-line script stops AWS keys, GitHub tokens, private keys and connection strings leaving your terminal, and puts the repo state into every other prompt. About 15 minutes, and the gaps are listed.

Part of the hooks reading guide

Five concrete archways in a row, the middle one bricked shut with a thin pink line of light around its edge

It’s 6pm, a deploy is failing with a 403, and the fastest way to explain it to the agent is to paste the error plus the command you ran. The command has an access key in it. You hit Enter. The key is now in a request to a hosted model, in a transcript file on disk, and possibly in your prompt history.

Nothing in Claude Code’s default setup stops that. A UserPromptSubmit hook does: it runs before the prompt reaches the model, it can refuse the prompt outright, and it can add text alongside the ones it lets through. This Playbook builds one script that does both jobs. It blocks prompts that contain credentials, and it tells Claude the branch, the dirty-file count and the last commit on every turn so you stop typing “we’re on a feature branch, right?” into prompts.

The setup is a script, a settings entry and about 15 minutes. It also has real gaps. The biggest: a blocked prompt still lands on disk. That’s covered below, along with what to do about it.

What the event does

According to the hooks reference , UserPromptSubmit runs when a prompt is submitted, before Claude processes it. The hook receives JSON on stdin, including a prompt field with the text. Pasted content that collapsed to a [Pasted text #N] placeholder in the UI arrives expanded in place, so a secret hidden inside a big paste is still visible to the hook.

Three behaviours matter for what follows.

First, the hook can block. Print JSON with "decision": "block" and a reason, or exit 2, and the prompt never reaches Claude. The reason goes to you, not to the model.

Second, the hook can add context. The additionalContext field in hookSpecificOutput is injected alongside the prompt. The hook can’t rewrite the prompt, only add to it, which is why this script refuses instead of redacting.

Third, it doesn’t only run on prompts you type. The docs list scheduled tasks and /loop iterations, background subagents reporting back, and messages from another session. A gate that only checks keyboard input would be a poor gate, and this one covers those paths too, because they all arrive as prompts.

The script

Save this as .claude/hooks/prompt-gate.py in the repo. It uses only the Python standard library.

#!/usr/bin/env python3
"""UserPromptSubmit hook: refuse prompts that contain credentials, and add repo state to the rest."""
import json
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path

PATTERNS = {
    "AWS access key ID": r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b",
    "GitHub token": r"\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})\b",
    "Private key block": r"-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----",
    "Password in URL": r"\b[a-z][a-z0-9+.-]*://[^\s:/@]+:[^\s/@]{3,}@",
}


def git(*args):
    try:
        out = subprocess.run(["git", *args], capture_output=True, text=True, timeout=5)
    except (OSError, subprocess.TimeoutExpired):
        return ""
    return out.stdout.strip() if out.returncode == 0 else ""


def main():
    event = json.load(sys.stdin)
    prompt = event.get("prompt", "")

    hits = [name for name, rx in PATTERNS.items() if re.search(rx, prompt)]
    if hits:
        log = Path(event.get("cwd", ".")) / ".claude" / "prompt-gate.log"
        with log.open("a") as f:
            stamp = datetime.now(timezone.utc).isoformat(timespec="seconds")
            f.write(json.dumps({"at": stamp, "session": event.get("session_id"), "found": hits}) + "\n")
        print(json.dumps({
            "decision": "block",
            "reason": f"Prompt not sent: it looks like it contains a {', '.join(hits)}. "
                      "Remove the secret and describe it instead (for example, 'the key in .env').",
            "hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "suppressOriginalPrompt": True},
        }))
        return

    if git("rev-parse", "--is-inside-work-tree") != "true":
        return
    branch = git("branch", "--show-current") or "(detached HEAD)"
    dirty = len(git("status", "--porcelain").splitlines())
    last = git("log", "-1", "--format=%h %s") or "(no commits)"
    lines = [f"Repo state: branch {branch}, {dirty} uncommitted file(s), last commit {last}."]
    if branch in ("main", "master"):
        lines.append("This is a protected branch. Create a feature branch before editing files.")
    print(json.dumps({
        "hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": " ".join(lines)}
    }))


main()

Make it executable with chmod +x .claude/hooks/prompt-gate.py, though the settings entry below calls it through python3, so the bit isn’t required.

The pattern list is deliberately short. Each entry is a format with a fixed, documented shape. GitHub’s docs list the token prefixes (ghp_, gho_, ghu_, ghs_, ghr_ and github_pat_), and AWS’s identifier table gives AKIA for access keys and ASIA for temporary STS ones. A PEM private key announces itself with a header line. A password embedded in a URL (scheme://user:password@host) is how most connection strings leak.

The log line records the time, session and which patterns matched. It never records the matched text, because a log of blocked secrets is just a second place for the secrets to live.

Two details in the git half are there because of how the hook runs. Every git call has a timeout and swallows failure, so a hung or missing git can’t stall the prompt: the hook default timeout on this event is 30 seconds, shorter than most, and a stuck hook blocks the model until it finishes. And outside a work tree the script prints nothing at all, instead of claiming a detached HEAD.

Wire it in

Put this in .claude/settings.json, which is the shared project file you can commit:

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/prompt-gate.py\"",
            "timeout": 10,
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}

UserPromptSubmit takes no matcher, so there’s no matcher key; the hook fires on every prompt. The timeout is 10 seconds because the script does a handful of local git calls and nothing else. The onFailure line is the important one, and the section after the demo explains why.

Add the log to .gitignore so it doesn’t get committed:

.claude/prompt-gate.log

Run it

A clean prompt first. The question asks Claude to report the line it was given, because additional context is invisible in the transcript. The docs say neither channel produces a visible entry, and Claude reading it back is the quickest check that it arrived:

claude -p "In one sentence: what branch are we on and is it safe to edit here?" </dev/null

On Claude Code 2.1.299 in a repo on main, this prints:

We're on `main` with a clean working tree, but it's a protected branch, so it isn't safe to edit here until we create a feature branch.

Switch branches with git checkout -b fix-login and ask what the repo-state line says:

The repo-state line says you're on branch `fix-login` with a clean working tree (0 uncommitted files), and the last commit is `f7dbe71` with the message "wip".

Now a prompt with a connection string in it:

claude -p "Deploy fails with postgres://admin:hunter22@db.internal:5432/app, why?" </dev/null
UserPromptSubmit operation blocked by hook:
Prompt not sent: it looks like it contains a Password in URL. Remove the secret and describe it instead (for example, 'the key in .env').

The model never saw the prompt: the JSON result for a blocked prompt has num_turns 0 and total_cost_usd 0. The audit log has one line:

{"at": "2026-10-14T07:05:23+00:00", "session": "d07726c5-0b91-5709-a737-278e88c5e643", "found": ["Password in URL"]}

The same check against the other patterns, fed straight to the script, behaves as expected. A -----BEGIN RSA PRIVATE KEY----- header, a ghp_ token with 36 characters after the prefix and AKIAIOSFODNN7EXAMPLE (AWS’s own documentation example key) all block. postgres://localhost:5432/app and “the AWS key is in .env” both go through, because neither contains a credential.

What the block leaves behind

The docs include a section titled “What a blocked prompt leaves behind”, and it’s worth taking at its word. By default the block message ends with Original prompt: followed by the text you submitted, and that message is written to the session transcript. Run the script without the suppressOriginalPrompt flag and the output shows exactly that:

UserPromptSubmit operation blocked by hook:
Prompt not sent: it looks like it contains a AWS access key ID. Remove the secret and describe it instead (for example, 'the key in .env').

Original prompt: key AKIAIOSFODNN7EXAMPLE

So the script sets "suppressOriginalPrompt": true, which leaves the text out of the block message. That’s one copy removed. It’s not the only copy.

In headless mode, the transcript file under ~/.claude/projects/ for the session still contained the prompt after a suppressed block. It sits in a record of type queue-operation, written when the prompt was queued, before the hook ran. The system record for the block itself doesn’t have it. The docs warn about this: the submitted text can still appear in the session transcript and the prompt history, and a blocking hook “isn’t a way to keep a secret off disk”.

That’s an honest limit, and it shapes what this hook is for. It keeps the secret out of the model request and out of whatever the model could do with it: echo it into a file, put it in a commit message, send it to an MCP server or a tool call. It doesn’t make your local disk clean. If the key matters, rotate it after you’ve pasted it, blocked or not. Interactive sessions were not tested, and the transcript behaviour there may differ, so check your own ~/.claude/projects/ folder after a test block rather than assuming.

Why onFailure matters

A gate that fails open is a gate that works until it doesn’t. By default, a UserPromptSubmit hook that crashes or times out lets the prompt through, without the context. The docs say a timed-out hook’s output is discarded and the prompt still reaches Claude. The earlier piece on fail-open policy hooks covers the general problem, and the onFailure tests cover the option in detail.

Here it is on this hook. Add a raise RuntimeError("boom") right after the json.load line so the script crashes with exit code 1, and run an ordinary prompt with onFailure removed from the settings:

hi

The prompt went through and the model answered. Nothing was gated, and the only sign is a notice that a hook errored. Put "onFailure": "block" back and the same crash gives:

UserPromptSubmit operation blocked by hook:
[python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/prompt-gate.py"]: failed; blocking because onFailure is "block"
Traceback (most recent call last):
  File "/path/to/project/.claude/hooks/prompt-gate.py", line 53, in <module>
    main()
  File "/path/to/project/.claude/hooks/prompt-gate.py", line 25, in main
    raise RuntimeError("boom")
RuntimeError: boom

The option needs Claude Code 2.1.295 or later. On an older version it’s an unknown key, so check claude --version before you rely on it.

One quirk is specific to Python. Delete the script and run a prompt, and the prompt is blocked even without onFailure. Python exits with code 2 when it can’t open the file, and exit code 2 is the block signal on this event. A hook written in shell would exit 127 for a missing file, which is a non-blocking error, so the same typo would let everything through. Relying on that accident isn’t a plan. Keep onFailure set and the outcome is the same for any language.

The cost is that a bug in the script now stops you working. That’s the right trade for a secrets gate, and an annoying one for the repo-state half. If you only want the repo state, drop the blocking half and onFailure, and let it fail open.

Make it yours

The patterns are the part to tune. A few directions:

  • Add the formats your stack leaks. Take the exact format from the vendor’s own documentation before you write each regex, because a wrong pattern gives either false alarms or false confidence.
  • Move it to user level. The same entry in ~/.claude/settings.json applies to every project, which suits a secrets gate. Keep the git context half in the project file, since it’s repo-specific, and split the script in two.
  • Block, don’t scan, on a shared machine. If prompts could include customer data, add patterns for it (emails in bulk, account numbers) and block above a count.
  • Use Node or PowerShell. The logic is a regex loop and a JSON print. The command line changes and nothing else does.

The context half has more room than the example uses. Anything you’d otherwise type into a prompt every time is a candidate: the ticket number parsed from the branch name, the project’s test command, a reminder that a directory is generated code. Keep it to a couple of lines. The text is added on every turn, and it’s saved in the transcript, so the docs warn that values like a commit SHA go stale on resume: the saved line is replayed, not re-run.

What goes wrong

Regexes miss things. The script catches secrets with a recognisable shape and nothing else. A random 40-character string, a password in a sentence (“the password is hunter2”), a token from a vendor you haven’t listed and a key split across two lines all go straight through. High-entropy detection would catch more and false-alarm more. For a broader net, run a dedicated scanner on the prompt from the same hook and keep this script as the fast first pass.

False positives happen, and they’re irritating. postgres://user:pass@localhost in a tutorial snippet blocks, and so does a documentation example key. The reason text tells you what matched, and the fix is to describe the thing instead of pasting it. If a pattern keeps firing on harmless text, narrow it rather than disabling the hook.

The hook only sees prompts. A secret in a file the agent reads, in the output of a command it runs, or in a web page it fetches never passes through UserPromptSubmit. Those paths need PreToolUse and PostToolUse hooks, deny rules for .env files in your permissions, or a sandbox. This gate covers one door of several. The guardrail hook piece covers the tool-call side.

The extra context costs tokens every turn, though not many: the line the script produces is about 40 words. It also tells the model what it can see already if it runs git status, so its value is saving a tool call and stopping the agent from editing on main out of ignorance, not new knowledge.

Don’t bother if your sessions never hold credentials, for example when you work in a throwaway environment with no real secrets in reach. The same goes if your organisation already runs managed hooks or a gateway that scans prompts, since a second gate adds latency for the same coverage.

First 15 minutes

  1. Copy the script into .claude/hooks/prompt-gate.py and the settings block into .claude/settings.json. Run claude --version and confirm it’s 2.1.295 or later.
  2. Send a harmless prompt and ask Claude to quote the repo-state line it was given. If it can’t, the hook isn’t firing; check the path and run claude --debug to see the Hooks: lines.
  3. Paste AKIAIOSFODNN7EXAMPLE into a prompt and confirm it’s refused and logged.
  4. Break the script on purpose, confirm the prompt is blocked, then fix it.
  5. Open the session transcript under ~/.claude/projects/ and search it for the example key, so you know exactly what a blocked prompt leaves on your disk.