Guides / Block credential leaks in Claude Code: PreToolUse hook

How to block credential leaks in Claude Code with a PreToolUse hook

2026-10-11 · 764 words · Pack: Credential Guard

Coding agents leak keys in the most boring way possible. Nobody exfiltrates anything. The agent is asked to check that a billing API accepts a new key, it reads .env to find the key, and it pastes the literal value into a curl command. That command is now in your shell history, in the session transcript on disk, in any log collector that watches stdout, and occasionally in a pull request description. The key is burned and nothing in the workflow noticed.

This guide builds a hook that notices. It runs before every shell or HTTP tool call, denies the ones that would send a literal secret somewhere, and tells the agent how to do it properly instead.

How PreToolUse hooks work

Claude Code's hooks documentation defines the contract. A PreToolUse hook is a command that receives one JSON object on stdin describing the pending call — tool_name, tool_input, cwd, session_id — and signals its decision through the exit code. Exit 0 allows the call. Exit 2 blocks it, and whatever the hook wrote to stderr is shown to Claude as the reason. The hook can also print a JSON object with hookSpecificOutput.permissionDecision set to "deny"; the docs note that exit 2 blocks even if the JSON says allow, so emitting both is the belt-and-braces option.

That contract is enough. A hook does not need a framework, a daemon or a network connection — it is a script that reads stdin, decides, and exits.

Deciding what to block

Two conditions have to hold at once. The command must be outbound, and it must contain a literal secret.

For the outbound test, match the clients that move bytes off the machine. The pack's list, as a word-boundary regex:

const OUTBOUND_RE =
  /(?:^|[\s;&|(`])(?:curl|wget|http|https|xh|httpie|aria2c|scp|sftp|rsync|ftp|nc|ncat|telnet|ssh|Invoke-WebRequest|iwr|Invoke-RestMethod|irm|openssl\s+s_client)(?=\s|$)/m;

grep -r AKIA… ./src is not outbound and should pass; nobody wants a hook that fires every time a key-shaped string appears in a local command.

For the secret test, use the shapes that vendors publish. Each entry is labelled so the denial message can say what kind of secret it saw without echoing the value:

export const SECRET_PATTERNS = [
  { kind: "stripe-live-secret", re: /\bsk_live_[A-Za-z0-9]{10,}\b/g },
  { kind: "openai-style-key",   re: /\bsk-(?:proj-|ant-|or-v1-)?[A-Za-z0-9_-]{20,}\b/g },
  { kind: "aws-access-key",     re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
  { kind: "github-token",       re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}\b/g },
  { kind: "slack-token",        re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g },
  { kind: "google-api-key",     re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
  { kind: "private-key-block",  re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----/g },
  { kind: "bearer-token",       re: /\b[Bb]earer\s+(?![$%])[A-Za-z0-9._~+/=-]{20,}/g },
];

The bearer pattern is the interesting one. Authorization: Bearer $API_KEY is exactly how you want the agent to behave, so the negative lookahead (?![$%]) excludes anything that starts with a shell or PowerShell variable reference. The same exclusion applies to the URL-password pattern (https://user:$PASS@host). The allowlist is a feature of the regex, not a special case bolted on afterwards.

The verdict

With both tests in hand, the judgement is a few lines:

if (toolName === "Bash") {
  const command = String(toolInput.command ?? "");
  if (!isOutboundCommand(command)) return { decision: "allow", ... };
  const hits = findSecrets(command, allow);
  if (hits.length === 0) return { decision: "allow", ... };
  return { decision: "deny", reason: denyMessage(hits, "shell command"), hits, outbound: true };
}
if (HTTP_TOOL_RE.test(toolName)) {           // WebFetch, mcp__*__fetch, anything with http/curl/request
  const hits = findSecrets(JSON.stringify(toolInput), allow);
  ...
}

HTTP-style tools get the same treatment with the whole tool_input serialised, because an MCP fetch tool with a key in a header is the same leak with a different shape.

Writing a denial the agent can act on

The reason string is read by the model, so it should say what to do next, not just what went wrong:

credential-guard: blocked — literal secret in shell command (stripe-live-secret sk_liv…(42 chars)). Never paste keys into outbound calls. Put the value in an environment variable and reference it (e.g. -H "Authorization: Bearer $API_KEY"), or add a known-safe test value to .claude/credential-guard.allow.

Note the redaction. The hook shows the first six characters and the length — enough to recognise which key it was, not enough to reconstruct it. The same redacted form goes into a local JSONL audit log so you can see how often the agent reached for a literal key, and in which sessions.

Allowlisting the safe cases

Some key-shaped values are meant to be sent. Stripe's sk_test_ keys go to api.stripe.com in test mode all day. A fixture server might accept a fixed dummy bearer. The pack reads .claude/credential-guard.allow, one literal substring or /regex/ per line, and skips any match that it covers. Keep the allowlist narrow: allow a specific prefix or value, never a whole detector.

Registering the hook

Hooks live in settings.json under event → matcher → handlers. The matcher is a tool-name pattern; this one covers Bash, WebFetch and any MCP tool whose name mentions fetching or HTTP:

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash|WebFetch|.*(fetch|http|curl|request).*",
      "hooks": [{ "type": "command",
                  "command": "bun \"${CLAUDE_PROJECT_DIR}/.claude/hooks/credential-guard.ts\"",
                  "timeout": 10 }]
    }]
  }
}

${CLAUDE_PROJECT_DIR} resolves to the project root, so the same registration works on every machine that checks out the repo.

What it does not do

It does not scan files you write — putting a key into .env is fine; that is where keys live. It does not replace secret scanning in CI, which catches what made it into a commit. It closes the gap before that: the moment between an agent reading a secret and sending it somewhere. Those are different layers, and you want both.

Install

The Credential Guard pack is the hook above plus the allowlist example, the README and the audit log, tested against a dozen stdin payloads (deny and allow paths, PowerShell clients, MCP tools, allowlist).

curl -fsSL https://hookcrate.com/install | bash   # Windows: irm https://hookcrate.com/install.ps1 | iex
hookcrate login hc_YOUR_KEY
hookcrate install credential-guard

$9/month or $99 lifetime; one license covers every pack in the catalog. (bunx hookcrate is coming soon on npm.)

The pack

Credential Guard Hook
Blocks shell and HTTP tool calls that would ship a literal API key or token to an outbound command.
View pack

$9 / month or $99 lifetime — one license covers every pack. Get access.