Agent guardrails — shielding secrets from Claude Code
An agent CLI such as Claude Code is a process that reads and writes your filesystem with your own permissions. For an audit, the relevant question is not "what did I ask the agent to do" but "what can the agent do, and where is that enforced".
This layer answers that with three parts: a deny-list in
~/.claude/settings.json for the file tools, a PreToolUse hook
(common/claude-pre-tool-use.sh) for the shell, and a checker
(common/check-claude-guardrails.sh) that verifies both are actually in place
and still match the canonical rule set in this repository
(common/templates/claude-deny-secrets.json).
The trap that made this necessary
A deny-list that looks right but does nothing is more dangerous than no deny-list at all, because it stops the search. This rule is exactly that:
"Write(**/.env)"
Claude Code matches file-permission rules on Edit(...) — that pattern covers
every file-editing tool (Edit, Write, NotebookEdit). A Write(...) rule is
not considered during the file-permission check. It sits in your config, it
reads as though it protects .env files, and it blocks nothing.
The CLI does warn about this at session start, but that warning scrolls away. Hence the checker: one command that says it out loud, every time.
Which files, and why
| Category | Read | Write | Reason |
|---|---|---|---|
.env, .env.* |
no | no | credentials in plain text, by definition |
*.key, *_rsa |
no | no | private key material |
~/.ssh/, ~/.kube/, ~/.aws/, ~/.gnupg/ |
no | no | credential stores; a kubeconfig holds client certs and tokens |
*.pem |
yes | no | often a cert chain, just as often a private key — the extension says nothing about the contents |
*.crt, *.csr, public keys |
yes | yes | public by definition; blocking them only creates friction |
The *.pem split is deliberately asymmetric. Inspecting a certificate is
ordinary work; creating or modifying key material is not. Anyone who knows a
specific .pem holds a private key should not have an agent read it — but a
glob cannot make that judgement, and an over-tight pattern leads to
turn-it-off-and-forget.
Two pattern forms per rule
Every rule appears twice:
Edit(**/*.key)
Edit(//**/*.key)
Patterns without a prefix match relative to the project directory. The //
prefix matches absolute paths. In a session with multiple working directories
(a cockpit repository with sibling repos next to it, say), the relative form
covers only the main directory. The double notation is redundant; a deny that
fires twice costs nothing, while a deny that silently fails to match is the bug
described above.
The shell layer
A deny-list governs the Read/Edit tools. It says nothing about what a command
does: cat ~/.env is a Bash call and slips straight past it. That gap is closed
by common/claude-pre-tool-use.sh, a PreToolUse hook that receives every tool
call as JSON on stdin before it runs and answers with an exit code — 2 blocks,
0 allows.
The hook grades in two tiers, deliberately unequal:
- Tier 1 — secret files (
.env,*.key,*_rsa,*.p12,*.pfx): any Bash call mentioning one is blocked. Such paths have no legitimate reason to appear in an agent's command. - Tier 2 — credential directories (
~/.ssh,~/.aws,~/.gnupg,~/.kube): blocked only in combination with a reading or copying verb (cat,base64,cp,scp,curl, …). Without that distinctionkubectl --kubeconfig ~/.kube/config get podswould die, and that is exactly the legitimate case: the process reads its own config, and the contents never enter the model's context.
The hook also refuses to let the agent edit its own guard files — the hook
directory and any .claude/*allowlist. A fence the agent can move is not a
fence.
Wire it up in ~/.claude/settings.json:
"hooks": {
"PreToolUse": [
{ "matcher": "", "hooks": [
{ "type": "command", "command": "/path/to/common/claude-pre-tool-use.sh" }
]}
]
}
bash common/claude-pre-tool-use.sh --self-test runs 21 fixtures through the
same decision function a live call takes — both directions, because a hook that
blocks everything is as broken as one that blocks nothing.
check-claude-guardrails.sh reports whether the hook is registered at all and
whether the registered copy still matches the one in this repository. It has a
--self-test of its own: seven fabricated settings files (complete, missing a
rule, a dead Write(...), no hook, a foreign hook, invalid JSON, absent file)
run through the real script, asserting both exit code and message. The fixtures
are derived from the canonical rule set, so the test moves with that list
instead of testing a private copy of it. Both self-tests run in CI.
What this layer does not cover
- A determined agent. The hook sees the command string, so it catches the obvious forms. Variable indirection, a path assembled at runtime, or a helper script that reads the file will pass. This is a guardrail against accident and drift, not a sandbox: the agent runs with your permissions, and only the OS can change that.
- Destructive commands.
git push --force,kubectl delete,terraform destroy— deliberately out of scope here. That is workstation policy, not a security baseline; put it in a second hook next to this one if you want it. - What the agent legitimately executes.
kubectl getkeeps working with a blocked~/.kube/: kubectl reads its own config as a process. That is intended — the block only stops the credential file itself from ending up in the model's context. - Policy written as prose. Rules in
CLAUDE.mdare instructions to the model, not enforcement. They are useful as explanation, but an auditor asking "where is that enforced" must be pointed at the deny-list.
That last point is the key maintenance rule: prose and deny-list must say the same thing. Change one, change the other, in the same commit. Drifted apart is worse than both being wrong — then the reader believes the wrong half.
Usage
bash common/check-claude-guardrails.sh # audit the current user
bash common/check-claude-guardrails.sh --settings /path/x.json # a different settings file
bash common/check-claude-guardrails.sh --rules /path/r.json # your own rule set
bash common/claude-pre-tool-use.sh --self-test # verify the hook's decisions
bash common/check-claude-guardrails.sh --self-test # verify the checker's own findings
Exit code 0 means fine, 1 means one problem, 2 means two or more (capped, so
cron/CI can work with it). A missing jq or an unreadable settings file also
yields 2: "could not verify" must never be reported as "fine" in an audit.
The script is read-only and installs nothing. Adding a missing rule is manual
work in ~/.claude/settings.json — deliberately so, because that file also
holds machine-local settings (plugins, theme, hook paths) that an installer has
no business overwriting.