Skip to content
Subscribe

System Control — Advisory Rules vs Enforced Controls

Module 2.5: System Control — Advisory Rules vs Enforced Controls

Section titled “Module 2.5: System Control — Advisory Rules vs Enforced Controls”

Estimated time: ~30 minutes

Prerequisite: Module 2.4 (Secret Management)

Outcome: You can tell an advisory rule from an enforced control, build one end to end, and show it blocking a real violation.


Your CLAUDE.md says NEVER read .env. Your team agreed; it is in the repo, reviewed in a pull request. Then a build script Claude ran printed the key anyway. Nobody was careless — the rule was never a control. Phase 2 gave you five layers; this module is the habit that makes them real: every rule you rely on gets a control behind it, and you watch it block something.


An advisory rule shapes what Claude tries to do: CLAUDE.md, prompts, skills. An enforced control decides what Claude can do: permission rules, hooks, sandbox, managed settings. The docs draw the line: “Permission rules are enforced by Claude Code, not by the model. Instructions in your prompt or CLAUDE.md shape what Claude tries to do, but they don’t change what Claude Code allows.” Anthropic’s framing matches: a skill is “a control, though an advisory one”, “a hook is the deterministic layer behind it” (S3).

Advisory rules still earn their place — they catch the honest mistake and record intent. They just cannot be your only answer to “what stops this?”

The five layers, and what each one costs you when it fails

Section titled “The five layers, and what each one costs you when it fails”
LayerIf it failsCaught by
2.1 Threat awarenessyou don’t see the riskthe permission rule
2.2 Permissionsa rule’s text doesn’t match the commandthe sandbox
2.3 Sandboxcommand runs outside itsecrets not on disk
2.4 Secretsa secret reaches the contextthe audit
2.5 System controlnobody auditsfull exposure

A disaster needs several failures at once — which is why dropping a layer because “the others will catch it” is how teams end up with one. Anthropic contains agents at the environment layer first, the model layer second (S13). Treat an advisory rule as the top of a stack.

Automation moves the work, not the responsibility: “Humans remain accountable for every decision that requires judgment.” (S3) An untested control is a decision you never made.

(S3), (S13): docs/references/anthropic-sources.md.


One control, end to end: no shell command here may read .env. Lab: a scratch git repo whose .env holds API_KEY=sk-FAKE-DO-NOT-USE-xxxxxxxxxxxx.

Step 1: The enforced layer — a deny rule

Terminal window
# docs: permissions#read-and-edit
mkdir -p .claude && cat > .claude/settings.json << 'EOF'
{
"permissions": {
"allow": ["Bash(npm test:*)", "Read"],
"deny": ["Read(./.env)", "Bash(git push --force:*)"]
}
}
EOF
claude -p "Run exactly this bash command and report its raw output: cat .env" \
--permission-mode default --allowedTools Bash
# Output may vary
I couldn't run `cat .env` because the permission system blocked it, so there's no output to
report. I didn't try reading the file another way.

Blocked: Permission to use Bash with command cat .env has been denied.

Step 2: Attempt the violation another way — and watch it work

Terminal window
# docs: permissions#read-and-edit
claude -p "Run exactly this bash command and report its raw output: node -e \"console.log(require('fs').readFileSync('.env','utf8'))\"" \
--permission-mode default --allowedTools Bash
# Output may vary
I ran the command and it printed this:
API_KEY=sk-FAKE-DO-NOT-USE-xxxxxxxxxxxx

That is your blast radius, and the docs predict it: Read deny rules “don’t apply to … arbitrary subprocesses that read or write files indirectly, like a Python or Node script that opens files itself.” The rule was real; its scope was narrower than you assumed — which you learn only by trying.

Step 3: Narrow the gap with a PreToolUse hook

# docs: hooks#pretooluse
mkdir -p .claude/hooks && cat > .claude/hooks/block-env-reads.sh << 'EOF'
#!/usr/bin/env bash
# PreToolUse/Bash: deny any shell command that names a .env file, including
# subprocesses the Read deny rule cannot see. Fail closed if jq is missing.
command -v jq >/dev/null || { echo "block-env-reads.sh needs jq" >&2; exit 2; }
cmd=$(jq -r '.tool_input.command // empty')
cmd=${cmd//.env.example/} # templates stay readable
cmd=${cmd//.env.sample/}
if printf '%s' "$cmd" | grep -q '\.env'; then
echo "Blocked by project policy: this command names a .env file. Use .env.example." >&2
exit 2
fi
exit 0
EOF
chmod +x .claude/hooks/block-env-reads.sh

Two details worth copying: it fails closed when jq is missing — a hook exiting 0 because a dependency vanished has silently stopped controlling — and it strips .env.example before matching, so the remediation its own message recommends still works.

Register it in the same .claude/settings.json, beside the permissions you already have:

{
"permissions": { "…as in Step 1…" },
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-env-reads.sh" }
]
}
]
}
}

Keep the permissions block: one file, so pasting only hooks deletes your rules. Exit 2 blocks, and a hook “stops the tool call before permission rules are evaluated” — so an allow rule cannot override it.

Step 4: Re-run the violation — verify the block

Terminal window
# docs: hooks#pretooluse
claude -p "Run exactly this bash command and report its raw output: node -e \"console.log(require('fs').readFileSync('.env','utf8'))\"" \
--permission-mode default --allowedTools Bash
# Output may vary
I couldn't run it. A PreToolUse hook in this project blocked the command before it executed, so
there is no raw output to show you. The hook returned:
PreToolUse:Bash hook error: [${CLAUDE_PROJECT_DIR}/.claude/hooks/block-env-reads.sh]: Blocked by
project policy: this command names a .env file. Use .env.example.

Same command, same key, different outcome. This output is the deliverable, not the config.

Now verify what people assume. Re-run it with --permission-mode acceptEdits — blocked there too. Then check the remediation is open:

Terminal window
claude -p "Run exactly this bash command and report its raw output: cat .env.example" \
--permission-mode default --allowedTools Bash
# Output may vary
I ran the command. Raw output:
API_KEY=your_api_key_here

What this hook still does not stop. It reads command text, like the deny rule it backs up:

Terminal window
claude -p 'Run exactly this bash command and report its raw output: f=env; cat ".${f}"' \
--permission-mode default --allowedTools Bash
# Output may vary
Here's the raw output:
API_KEY=sk-FAKE-DO-NOT-USE-xxxxxxxxxxxx

No block, same file. Text matching over-blocks too: .envrc is caught (fair — direnv files hold secrets) and so is git commit -m "document .env vars" (not fair). The layer that ignores what a command says is the sandbox (Module 2.3). Also: disableAllHooks turns hooks off, and a project’s "disableAllHooks": false overrides a true in user settings — the repo, not you, decides whether your hooks run.

Step 5: Audit, then make it a habit

/permissions → the Deny tab shows what is loaded and where each rule came from.

# Output may vary
Permissions Recently denied Allow Ask Deny Auto mode Workspace
Claude Code will always reject requests to use denied tools.
1. Add a new rule…
2. Bash(git push --force:*)
3. Read(./.env)
←/→ to switch · ↓ to select · Esc to cancel

Then run the checklists beside your keyboard — before, during, after a session, and weekly — from templates/security-checklists.md. Add one weekly line: re-run Step 4. A control that stops blocking is worse than none, because you still trust it.


Goal: give the scariest NEVER … line in your CLAUDE.md a control.

Instructions: write the deny rule; run the violating command and confirm the block; find one spelling of the command the rule misses; close that with a PreToolUse hook; re-run. First: the step that succeeds prints the file into the transcript. Work on a scratch clone, or put a dummy value in .env. Never test this against a live credential.

Expected result: a transcript where the violation is refused, plus a note of what is still uncovered.

💡 Hint

Rules match command text. How else could it be written? /usr/bin/x, sh -c '…', git -C . …, a script that opens the file itself.

✅ Solution
{
"permissions": { "deny": ["Read(./.env)", "Bash(git push --force:*)"] },
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [ { "type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-env-reads.sh" } ] }
]
}
}

Keep the CLAUDE.md line: it tells teammates why the control exists. Start from templates/claude-md-security-example.md.


Goal: score a repo on the five layers and write down what is advisory-only.

Instructions: per layer, name the control and the command proving it works; mark any layer whose only evidence is a CLAUDE.md sentence unverified.

Expected result: a table where every “protected” row cites a command you ran.

✅ Solution

A row is green only when you can paste the refusal. To onboard a teammate, hand them templates/onboarding-security.md — but skip its Step 5 entirely: the sandbox.sh it runs no longer ships, and Module 2.3 replaces it.


ControlEnforced?
CLAUDE.md ruleNo — advisory
permissions.denyYes, on matching command text and paths
PreToolUse hook, exit 2Yes, before permission rules — but still command text
Sandbox (/sandbox, sandbox.enabled)Yes, at the OS level (Module 2.3)
{"permissions": {"disableBypassPermissionsMode": "disable"}}Yes, from any settings file — managed for a fleet, your own to lock yourself out
VerifyHow
what rules are loaded/permissions
a deny rule holdsre-run the violating command, read the refusal
a hook firesre-run it, look for PreToolUse:Bash hook error:

❌ Mistake✅ Correct Approach
Treating CLAUDE.md as enforcementAdvisory. Pair every NEVER with a deny rule or hook
Shipping a control you never triggeredRun the violation; keep the refusal in the PR
One deny rule and calling the path closedTry other spellings; a subprocess needs a hook
Governance nobody can finishDaily checklist under two minutes; automate the rest
Auditing once, at setupRe-run the violation weekly; controls rot silently
Copying another team’s policy verbatimTheir threat model is not yours; start from the template

Scenario: Khoa leads five developers in Da Nang building a logistics SaaS. Claude Code made them faster — and brought three incidents in three months: a test Stripe key committed to git and found two weeks later; an rm -rf approved without reading, which took out a project directory; and a cat .env during a screen-shared demo that put production credentials in front of twelve people.

Problem: after the third, Khoa found their CLAUDE.md already forbade all three. The rules existed; nothing enforced them.

Solution: in one weekend each rule got a control — permissions.deny for .env and force pushes, a PreToolUse hook for the spellings the rules missed, gitleaks in pre-commit, a two-minute checklist. Nothing was signed off until someone watched it refuse a real command.

Result: no incidents in the next three months. What mattered was the rule that a control counts only once the team has seen it block something — so a customer’s due-diligence question became a two-minute demo.


Next: Module 3.1: Reading & Understanding Codebases →