Skip to content

Instantly share code, notes, and snippets.

@joematthews
Created July 27, 2026 16:55
Show Gist options
  • Select an option

  • Save joematthews/5614407241cbde5673065621a7c817f9 to your computer and use it in GitHub Desktop.

Select an option

Save joematthews/5614407241cbde5673065621a7c817f9 to your computer and use it in GitHub Desktop.
Blocking secret leakage in Claude Code: why the permission deny-list isn't enough, and the PreToolUse hook that helps

Blocking secret leakage in Claude Code: why the permission deny-list isn't enough

A short write-up of a guardrail test and the hook that came out of it.

The setup

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 finding: the guardrail is tool-shaped, not content-shaped

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_]*=' .env

So 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.

An important non-leak

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.

Pattern design: substring beats anchoring

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.

The hook

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 0

The hook took effect immediately — no session restart.

What it does and doesn't catch

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 allowedgrep 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 leaks

It 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.

Conclusion

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.

Companion rule

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment