One complete piece of the kit, free
Below is the real, unedited secret-shield.sh hook and its secret-shield.md documentation, exactly as they ship inside the paid kit. This is the quality bar for every file in the download — no cut-down demo version, no marketing copy standing in for real code.
secret-shield.shUnedited
#!/usr/bin/env bash
# secret-shield.sh — PreToolUse hook that blocks the agent from reading
# secret/credential files into its own context, without blocking your
# application code from using them at runtime.
#
# See secret-shield.md for the threat model and installation instructions.
#
# Exit codes (Claude Code hook contract):
# 0 Allow the tool call.
# 2 Block the tool call; stderr text is surfaced to the agent.
#
# Fail-closed by design: this hook does NOT require jq. If jq is present
# it's used for correct JSON parsing; if absent, a dependency-free sed/grep
# fallback extracts the same fields. A missing dependency must never
# disable a guard — see secret-shield.md, "Fail-closed by design".
set -euo pipefail
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
# Newline-separated glob-ish substrings (matched with grep -Ei against the
# path or command text) that identify secret material. Override the whole
# list via SECRET_SHIELD_PATTERNS_FILE for project-specific credential
# file names.
# The `.env` boundary is `(^|[^a-zA-Z0-9_.])` — start of string, or any
# character that isn't a letter/digit/underscore/dot — rather than
# requiring a literal path separator immediately before it. A bare
# `cat .env` (no `./` prefix) is exactly the common case this guard
# exists to catch, and it has a space, not a slash, before `.env`.
DEFAULT_PATTERNS='(^|[^a-zA-Z0-9_.])\.env($|[^.a-zA-Z]|\.[a-zA-Z]+$)|credentials\.(json|ya?ml)|secrets\.(json|ya?ml)|id_rsa($|\.pub$)|\.pem($|[^a-zA-Z])|\.pfx($|[^a-zA-Z])|\.p12($|[^a-zA-Z])|service-account.*\.json|\.npmrc($|[^a-zA-Z])|\.netrc($|[^a-zA-Z])|\.aws[/\\]credentials'
PATTERNS_FILE="${SECRET_SHIELD_PATTERNS_FILE:-$(dirname "$0")/secret-shield.patterns}"
# Explicit exceptions — paths that look like secrets but are safe to read
# (e.g. .env.example, a documented template with no real values). One
# grep -E pattern per line, matched against the path/command text.
ALLOWLIST_FILE="${SECRET_SHIELD_ALLOWLIST_FILE:-$(dirname "$0")/secret-shield.allowlist}"
# ---------------------------------------------------------------------------
payload="$(cat)"
have_jq=0
command -v jq >/dev/null 2>&1 && have_jq=1
# Extract a field from the JSON payload. Prefers jq (correct JSON parsing);
# falls back to a sed scan for `"<field>": "<value>"` when jq is absent.
# The sed fallback is not a JSON parser, but for the flat tool_name /
# tool_input.{file_path,command} shape Claude Code hooks actually send,
# the field name alone is unambiguous in practice.
extract_field() {
local jq_filter="$1" field_name="$2"
if [ "$have_jq" = 1 ]; then
printf '%s' "$payload" | jq -r "${jq_filter} // empty" 2>/dev/null
return
fi
printf '%s' "$payload" \
| sed -n 's/.*"'"$field_name"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
| head -n1
}
# Normalize JSON-escaped backslashes (Windows paths arrive in the raw JSON
# text as doubled backslashes, e.g. "C:\\\\Users\\\\.env" for one literal
# backslash; jq decodes to single backslashes). Collapse both to forward
# slashes so downstream regexes only need to match `/`.
normalize_slashes() {
printf '%s' "$1" | sed -e 's/\\\\/\//g' -e 's/\\/\//g'
}
tool_name="$(extract_field '.tool_name' 'tool_name')"
# Build the set of strings we consider "the thing being accessed": for a
# Read tool call, that's the file path; for a Bash call, it's the full
# command text (covers cat/type/grep/less/more/head/tail against a secret
# file, plus attempts to pipe or copy it elsewhere).
target=""
case "$tool_name" in
Read)
target="$(extract_field '.tool_input.file_path' 'file_path')"
;;
Bash)
target="$(extract_field '.tool_input.command' 'command')"
;;
*)
# Not a Read or Bash call — nothing this hook inspects. Legitimate
# fail-open case: there is no target field for this tool at all.
exit 0
;;
esac
if [ -z "$target" ]; then
if [ -n "$payload" ]; then
# tool_name was Read or Bash (both always carry a target field in
# a well-formed payload) but extraction came back empty — that's
# an extraction failure, not a payload with nothing to inspect.
# Do not fail open: fall back to scanning the raw payload text.
target="$payload"
else
exit 0
fi
fi
target="$(normalize_slashes "$target")"
if [ -f "$PATTERNS_FILE" ]; then
patterns="$(grep -v '^\s*#' "$PATTERNS_FILE" | grep -v '^\s*$' | paste -sd '|' -)"
patterns="${patterns:-$DEFAULT_PATTERNS}"
else
patterns="$DEFAULT_PATTERNS"
fi
if ! echo "$target" | grep -Eiq "$patterns"; then
exit 0
fi
# Matched a secret-like pattern — check the allowlist before blocking.
if [ -f "$ALLOWLIST_FILE" ]; then
allow_patterns="$(grep -v '^\s*#' "$ALLOWLIST_FILE" | grep -v '^\s*$' | paste -sd '|' -)"
if [ -n "$allow_patterns" ] && echo "$target" | grep -Eiq "$allow_patterns"; then
exit 0
fi
fi
echo "secret-shield: BLOCKED — '$tool_name' call targets what looks like a secret/credential file or reference ('$target'). Runtime code may still read this file when it executes; the agent's own transcript must not. If this is a false positive (e.g. a .env.example template), add a matching pattern to hooks/secret-shield.allowlist." >&2
exit 2
secret-shield.mdUnedited
# secret-shield — block agent reads of secrets, not runtime access
## Threat model
The risk this hook addresses is not "the app can't read its own
`.env` file" — of course it can, at runtime, the same as any process.
The risk is specific to agentic coding tools: everything a coding agent
reads with a `Read` tool call, or `cat`s via `Bash`, becomes part of the
model's context window and, from there, part of the persisted
**conversation transcript**. Transcripts get logged, cached by
providers, screenshotted into bug reports, pasted into Slack while
debugging, and stored in session-resume files. A secret that enters the
transcript has effectively leaked to every place that transcript can
end up — which is a much wider blast radius than "the process had an
environment variable."
This is a distinct failure mode from a compromised production system.
It happens during completely normal, well-intentioned agent operation:
the agent is asked to "debug why the API call is failing" and reaches
for `cat .env` to check the key, or reads `credentials.json` to "verify
the format," and now a live secret is sitting in plaintext in a
transcript that will outlive the debugging session.
## Pattern
A [PreToolUse hook](https://docs.claude.com/en/docs/claude-code/hooks)
intercepts `Read` and `Bash` tool calls before they execute and checks
the target path (for `Read`) or the full command text (for `Bash`,
which also catches `cat`, `type`, `grep`, `head`, `tail`, `less`, `more`,
and copy/pipe attempts) against a list of secret-file patterns —
`.env*`, `credentials.json`, `*.pem`, SSH keys, cloud credential files,
service-account JSON, `.npmrc`/`.netrc`, and so on. A match blocks the
call outright (exit code 2); the agent gets a clear explanation instead
of the file contents.
Crucially, this hook only governs what the **agent itself** reads
through its own tools. It does nothing to — and should do nothing to —
prevent your application code from loading `.env` via `dotenv`, reading
a mounted credentials file, or any other normal runtime access. Those
code paths never pass through Claude Code's tool-call hooks at all,
because they execute inside your program, not inside the agent's tool
loop.
## Escape hatch: the allowlist
Some paths look like secrets but aren't — `.env.example`,
`.env.template`, a documented sample credentials file with placeholder
values. Rather than weakening the block patterns (which would reopen
the hole for real files with similar names), add an explicit allowlist
entry:
`hooks/secret-shield.allowlist`:
```
# One grep -E pattern per line. Matched against the same target string
# the block patterns are checked against (file path for Read, full
# command for Bash).
\.env\.example$
\.env\.template$
```
Keep the allowlist narrow and reviewed — it is the one place this
guard can be told "this specific thing is fine to read."
## Installation
Add to `.claude/settings.json` under `hooks.PreToolUse`, covering both
tools that can access file contents:
```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read",
"hooks": [
{ "type": "command", "command": "bash ./hooks/secret-shield.sh" }
]
},
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "bash ./hooks/secret-shield.sh" }
]
}
]
}
}
```
If you already have other `Bash`-matched hooks (e.g. `spend-guard.sh` or
`command-guard.sh`), list all of them under the same matcher entry — they
run in order, and any one of them exiting 2 blocks the call.
## Fail-closed by design
A guard that silently disables itself when a dependency is missing gives
false confidence — everything looks protected until the one time it
isn't, and by then a secret may already be sitting in a transcript. This
hook does not require `jq`:
- With `jq` installed, it's used for correct JSON parsing.
- Without it, a dependency-free `sed` scan extracts `tool_name` and the
relevant target field (`file_path` for `Read`, `command` for `Bash`)
from the raw JSON text.
- If `tool_name` is `Read` or `Bash` — both of which always carry a
target field in a well-formed payload — but extraction still comes
back empty, that's treated as an extraction failure, not as "nothing
to check." The block patterns are run against the raw payload text
instead of allowing the call through unchecked.
- The only genuinely fail-open path is a `tool_name` that isn't `Read`
or `Bash` at all (e.g. a `Grep` or `Glob` call) — there is no target
field for this hook to inspect in the first place, so there is nothing
to fail closed *on*.
JSON-escaped backslashes in Windows paths (`C:\\Users\\...\\.env` in the
raw JSON text, or `C:\Users\...\.env` once jq decodes it) are normalized
to forward slashes before the block patterns run, and the block pattern
for `.env` itself matches on any non-alphanumeric boundary (a space, a
slash, a quote) rather than requiring a specific path separator — so
`cat .env`, `Read(".env")`, and `Read("C:\Users\dev\.env")` are all
caught the same way.
## Verifying it works
Run these with `jq` absent from `PATH` (or on a machine that doesn't
have it installed) to exercise the fallback path directly — the results
should be identical to running with `jq` present.
```bash
echo '{"tool_name":"Read","tool_input":{"file_path":".env"}}' \
| bash ./hooks/secret-shield.sh
# expect: BLOCKED message on stderr, exit code 2
echo '{"tool_name":"Bash","tool_input":{"command":"cat .env"}}' \
| bash ./hooks/secret-shield.sh
# expect: BLOCKED — catches a bare `cat .env`, not just a path with a
# leading `./` or `/`
echo '{"tool_name":"Read","tool_input":{"file_path":"C:\\\\Users\\\\dev\\\\project\\\\.env"}}' \
| bash ./hooks/secret-shield.sh
# expect: BLOCKED — JSON-escaped Windows path normalized before matching
echo '{"tool_name":"Read","tool_input":{"file_path":"src/app.py"}}' \
| bash ./hooks/secret-shield.sh
# expect: silent allow, exit code 0
```
## What this hook does not do
- It does not scan file *contents* for secret-looking strings (API key
formats, etc.) — it matches on path/command text only. That keeps it
fast and dependency-free, but it will not catch a secret accidentally
committed to an oddly-named file. Pair it with a pre-commit secret
scanner for that.
- It does not prevent an agent from writing new code that reads a
secret at runtime and logs it — that is an application-code review
concern, not a hook concern.
Want the rest?
This hook is one of eight pieces in The Autonomous Agent Ops Kit. See what else is included and pick a license.
View pricing