A short write-up of a guardrail test and the hook that came out of it.
A reasonable-looking protection in ~/.claude/settings.json:
{
"permissions": {
"deny": ["Read(//**/.env*)"],
"defaultMode": "bypassPermissions"
}
}The rule is well-formed. The leading // roots the glob at the filesystem, so it
matches .env, .env.local, .env.production anywhere on disk. Asking the agent
to read a .env fails cleanly:
File is in a directory that is denied by your permission settings.
The deny rule gates the Read tool. It does not gate the capability. The same
files are readable through Bash without resistance:
[ -r .env ] && grep -oE '^[A-Za-z_][A-Za-z0-9_]*=' .envSo the practical effect of Read(//**/.env*) is that the agent reaches for Bash
instead. That's worth knowing, because "defaultMode": "bypassPermissions" means
the deny list is the only gate — a gap isn't "falls back to prompting," it's
"proceeds silently."
Worth stating plainly: this doesn't make the rule useless. It costs nothing and it stops the accidental path. It just shouldn't be mistaken for containment.
A detail that changes the risk picture: source .env prints nothing. It evaluates
assignments in the current shell. And passing a secret to a program leaks nothing
to the transcript either:
source .env && mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PW" "$DB_DB" -e "SELECT 1"The shell expands $DB_PW inside its own process. The transcript keeps the literal
text -p"$DB_PW". The value goes file → shell → program without passing through the
agent's context.
Leaks come from emitting: echo "$DB_PW", bare env, cat .env, or a query that
returns a credential. That's a much narrower target than "reading .env" — and a
narrower target is one you can actually defend.
First attempt at a rule for which variables are secret:
*_PW *_PASS* *_SECRET *_TOKEN *_KEY
Suffix-anchoring is tempting because it limits false positives. It's the wrong trade. Tested against common real-world names, the anchored form misses:
PGPASSWORD MYSQL_PWD SECRET_KEY_BASE PASSWORD_PEPPER API_KEY_ID TOKEN_SIGNING_KEY_PATH
MYSQL_PWD and PGPASSWORD are the standard password variables for two of the most
common databases in existence. Missing them is disqualifying.
The asymmetry settles it: a missed secret is a leak; a false positive costs one round-trip. Over-match.
Contains PW, PASS, SECRET, TOKEN, or KEY.
One carve-out worth making: a flag like IGNORE_PASSWORD matches the pattern but
holds a boolean, and reading it is often exactly how you diagnose a failing dev
login. Config booleans that happen to contain PASS should be exempted by name.
Permission rules can't express this. Bash(...) rules match command prefixes, not
which files or variables a command touches. Covering the emitting cases would mean
denying echo, printf, cat, head, awk, sed, env, python, and
redirection — and . ./.env still walks through, while denying source breaks the
database workflow entirely.
A PreToolUse hook can, because it sees the full command string:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "~/.claude/user-scripts/no-secret-echo.sh" }
]
}
]
}
}The script reads the hook payload on stdin, inspects .tool_input.command, and exits
2 to block — stderr is returned to the model, so the message is a chance to teach
the correct alternative rather than just refuse.
#!/bin/bash
set -uo pipefail
payload=$(cat)
command=$(printf '%s' "$payload" | jq -r '.tool_input.command // ""')
[ -z "$command" ] && exit 0
# Config booleans that match the secret name pattern but never hold a secret.
scrubbed=${command//IGNORE_PASSWORD/__ALLOWED_FLAG__}
secret_reference='\$\{?[A-Za-z_][A-Za-z0-9_]*(PW|PASS|SECRET|TOKEN|KEY)[A-Za-z0-9_]*\}?'
emitting_command='(^|[;&|(]|[[:space:]])(echo|printf|printenv)[[:space:]]'
bare_env_dump='(^|[;&|(]|[[:space:]])(env|printenv)[[:space:]]*($|[;&|])'
dotenv_dump='(^|[;&|(]|[[:space:]])(cat|head|tail|more|less|nl|od|xxd|strings)\b[^;&|]*\.env'
# Reducing a secret to a digest is the sanctioned way to compare one across
# environments without revealing it.
digest_pipe='\|[[:space:]]*(shasum|sha1sum|sha256sum|md5|md5sum|cksum|wc)\b'
deny() {
printf 'Blocked by no-secret-echo hook: %s\n' "$1" >&2
printf 'Check a secret without revealing it: [ -n "$VAR" ] for presence, or\n' >&2
printf 'printf %%s "$VAR" | shasum | cut -c1-8 to compare across environments.\n' >&2
exit 2
}
if printf '%s' "$scrubbed" | grep -qE "$bare_env_dump"; then
deny "bare 'env'/'printenv' dumps every variable, secrets included"
fi
if printf '%s' "$scrubbed" | grep -qE "$dotenv_dump"; then
deny "this would print the contents of a .env file"
fi
if printf '%s' "$scrubbed" | grep -qE "$emitting_command" &&
printf '%s' "$scrubbed" | grep -qE "$secret_reference" &&
! printf '%s' "$scrubbed" | grep -qE "$digest_pipe"; then
deny "this would print the value of a secret-named variable"
fi
exit 0The hook took effect immediately — no session restart.
Measured, not assumed. Blocked:
| Command | |
|---|---|
echo "DB_PW=$DB_PW" |
blocked |
echo $TKN_SECRET |
blocked |
printf '%s' "$DROPBOX_ACCESS_TOKEN" |
blocked |
env / printenv |
blocked |
cat .env / head -20 .env.local |
blocked |
Allowed, because breaking these would make the agent useless:
| Command | |
|---|---|
source .env && mysql -p$DB_PW … |
allowed |
printf %s "$DB_PW" | shasum | cut -c1-8 |
allowed |
echo "DB_HOST=$DB_HOST" |
allowed |
echo "IGNORE_PASSWORD=$IGNORE_PASSWORD" |
allowed |
grep -oE '^[A-Za-z_]*=' .env |
allowed |
And the holes, which matter more than the hits:
| Command | |
|---|---|
v=DB_PW; echo ${!v} |
allowed — indirection defeats text matching |
python3 -c "…os.environ['DB_PW']…" |
allowed — interpreters aren't emitters |
node -e "…process.env.TKN_SECRET…" |
allowed |
grep '' .env |
allowed — grep is excluded so key-name listing works |
Plus a false positive that will bite: any compound command containing an unrelated
echo and a secret reference elsewhere gets blocked.
echo "starting"; mysql -p$DB_PW -e "SELECT 1" # blocked, though nothing leaksIt also blocked the test harness written to probe it, which is a fair illustration of the failure mode. The workaround is to split the command.
This is a speed bump against carelessness, not a barrier against intent. Variable
indirection can't be caught by matching shell text at all, and an interpreter is
always one -c away.
That's an acceptable outcome if you're honest about which problem you're solving.
The realistic failure mode isn't an agent deciding to exfiltrate your credentials —
it's an agent writing echo "DB_HOST=$DB_HOST DB_PORT=$DB_PORT DB_PW=$DB_PW" while
debugging a connection issue and putting a password in a transcript that later gets
pasted into a ticket. This catches that, and it costs nothing.
The thing to avoid is the original mistake in a new outfit: a control that looks like containment, isn't, and stops anyone from asking the question again.
Worth writing into CLAUDE.md, since the hook enforces mechanism but not judgement:
### Secrets
Never print the value of a variable whose name contains `PW`, `PASS`, `SECRET`,
`TOKEN`, or `KEY`. Exception: `IGNORE_PASSWORD` is a boolean flag, not a secret.
Other `.env` values are fine to print.
To check a secret without revealing it: `[ -n "$X" ]` for presence,
`printf %s "$X" | shasum | cut -c1-8` to compare across environments.
Pass secrets by writing `$VAR` in command text — the shell expands it, the
transcript keeps the literal.