Permissions and Hooks: Guardrails in Code
Pick a permission mode, write allow, ask, and deny rules, add hooks that block, ask, and format, and log every decision so a review finding can never recur.
Before this: L3 Driving a session
- Explain the permission modes and pick the right one for a task.
- Write allow, ask, and deny rules in .claude/settings.json for commands and paths.
- Write a PreToolUse hook that blocks or asks, and a PostToolUse hook that formats.
- Turn a review finding into a guardrail so it cannot recur.
- Read a decision log and say what it proves.
Why this matters
Omar is halfway through CR-112, the nightly sales summary email. Quill wants to see the shape of the sales table, so it runs the pending migration against the development database "to check the schema." The migration runs clean. Then Jun mentions, in passing, that the development database is a copy of production from last month, real customer emails included. Nothing bad happened. Quill read a schema and moved on. Priya, who owns customer data, asks a harder question: how do you know nothing bad happened? Omar has a terminal scrollback and a feeling. Jun opens .claude/settings.json, adds three lines and a hook, and from that afternoon on the answer to Priya's question is a line in a log file.
1. Every action is proposed
Quill does not run commands. Quill proposes them. Every read, every edit, and every shell command is a request that goes through permissions before it happens. A permission is the rule that decides what happens to that request: it runs, it pauses to ask you, or it is refused.
The first control is the permission mode, a session-wide setting that decides what Quill may do without asking. In Claude Code, Shift+Tab cycles through the modes.
| Mode | Edits | Commands | Use it for |
|---|---|---|---|
| Default | Ask unless allowed | Ask unless allowed | Unfamiliar work, first sessions |
| Accept edits | Free | Ask unless allowed | Well-briefed feature work in a branch |
| Plan | Not allowed | Read-only | Planning a large change |
| Bypass | Free | Free | Throwaway sandboxes only |
Plan mode, from L3, lets Quill read and search but not change anything. The bypass mode skips every question; it belongs in a container you can delete, never on a laptop with a real database connection.
2. Allow, ask, deny lists
Modes are blunt. The rules in .claude/settings.json are precise. This file is committed, so every session on Shelf gets the same rules. It holds three lists.
The allow list names actions that run without a question. The ask list names actions that always pause for a person. The deny list names actions that are refused outright, with no way for the session to override. Deny wins over ask, and ask wins over allow, so a path that appears in two lists gets the stricter treatment.
Here are Shelf's rules, in the Claude Code shape:
{
"permissions": {
"allow": ["Bash(make test)", "Bash(npm run lint)", "Bash(npm run typecheck)", "Edit(src/**)"],
"ask": ["Bash(git push:*)", "Edit(src/email/**)"],
"deny": ["Read(./.env)", "Bash(rm -rf:*)"]
}
}
Read the shapes. Bash(make test) matches that exact command. Bash(git push:*) matches any command that starts with git push. Edit(src/**) matches any edit under src/, and Read(./.env) matches one file. The same glob shapes work for Write(...).
The sorting rule is short. Allow what is safe and frequent, so the session flows. Deny what must never happen, so no amount of persuasion matters. Ask for everything that is fine in principle but deserves a human glance.
3. Hooks: code that runs around actions
Permission rules match strings. Sometimes you need a decision that depends on more than a string: the current branch, the time of day, the contents of the command. That is a hook. A hook is a small program that Claude Code runs around an action, and the rule it enforces is a guardrail: a check in code rather than a sentence in the brief.
Two hook events matter here. A PreToolUse hook runs before the action and can allow, ask, or block it. A PostToolUse hook runs after the action and can react to what happened. Other events exist, such as UserPromptSubmit, Stop, and SessionStart; this lesson does not use them.
A hook receives JSON on standard input describing the action: tool_name, and tool_input with command for Bash or file_path for Edit and Write. A PostToolUse hook also receives tool_response. A PreToolUse hook answers by exit code, or by printing JSON. Exit 0 allows. Exit 2 blocks, and whatever the hook wrote to standard error is shown to Quill as the reason.
Here is the settings block that wires Shelf's hook, in the Claude Code shape:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash|Edit|Write", "hooks": [{ "type": "command", "command": "./gate.sh hook" }] }
],
"PostToolUse": [
{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "./format.sh" }] }
]
}
}
The matcher names which tools trigger the hook. Bash|Edit|Write covers the three ways Quill changes the world. ./gate.sh hook is the same script the SDD course builds in its L4; here you write the rules inside it.
4. A blocking hook
A block ends the attempt and tells Quill why. Use it when the command itself is wrong, not when it needs a second opinion. The migration incident wants a block: Quill should never run anything under migrations/ unless the work is a migration, which on Shelf means the branch name starts with migration/.
#!/usr/bin/env bash
# gate.sh hook: decide one proposed action. Claude Code sends JSON on stdin.
if [[ "${1:-}" == "hook" ]]; then
input=$(cat)
tool=$(echo "$input" | jq -r '.tool_name')
cmd=$(echo "$input" | jq -r '.tool_input.command // empty')
file=$(echo "$input" | jq -r '.tool_input.file_path // empty')
branch=$(git rev-parse --abbrev-ref HEAD)
# Rule 1: migrations run only on a migration/ branch.
if [[ "$cmd" == *migrations/* || "$cmd" == *"run migrate"* ]] && [[ "$branch" != migration/* ]]; then
./gate.sh log block migration-branch "$tool" "$cmd"
echo "BLOCKED by migration-branch: '$cmd' touches migrations/ on branch '$branch'." >&2
echo "Migrations run only on a branch named migration/<name>. See docs/testing.md." >&2
echo "Instead: read the migration file, or ask Omar to open a migration/ branch." >&2
exit 2
fi
./gate.sh log allow default "$tool" "${file:-$cmd}"
exit 0
fi
Read the three lines on standard error as Quill will read them: what was blocked, which rule, and what to do instead. A block without an alternative sends the agent into a loop of variations. A block with one sends it to the migration file, which was all it wanted.
5. An asking hook
A block is for commands that are wrong. An ask is for actions that are fine in principle but that a named person should see. The permission ask list handles the plain cases. A hook handles the case where the reason should name a person and a document, so that Quill and Omar both know what to do while they wait.
An asking hook prints JSON to standard output and exits 0. The Claude Code shape:
# Rule 2: edits under src/email/ pause for Priya.
if [[ "$file" == src/email/* ]]; then
./gate.sh log ask email-owner "$tool" "$file"
jq -cn --arg why "Edits under src/email/ need Priya. See docs/email.md." \
'{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:$why}}'
exit 0
fi
permissionDecision may be allow, deny, or ask. The reason is shown in the terminal when the action pauses. Omar sees Priya's name and the runbook, pastes the reason into the CR-112 pull request, and asks her to look. This is the same human gate the SDD course teaches: a threshold or a path plus a name means ask.
6. A formatting hook
Not every hook decides. A PostToolUse hook runs after the action and can do work on the result. The most useful one on most teams is a formatter hook: after every Edit or Write, run the formatter on that one file.
#!/usr/bin/env bash
# format.sh: PostToolUse on Edit|Write. Format the file Quill changed.
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path // empty')
[[ -n "$file" && "$file" == *.ts ]] && npx prettier --write "$file" >/dev/null
exit 0
Six lines. The hook reads tool_input.file_path, checks it is a TypeScript file, and runs prettier on it. It exits 0 because a PostToolUse hook has nothing to block; the action already happened.
Why is this better than a line in CLAUDE.md that says "run prettier after every edit"? Because the brief line is advisory. Quill forgets it in a long session, and Jun's diff fills with formatting noise. The hook runs every time, costs no context, and cannot be forgotten.
7. From finding to guardrail
Every review finding can land on one of three rungs. The bottom rung is a line in the brief: cheap, one sentence, advisory. The middle rung is a hook: enforced on every action, cannot be forgotten. The top rung is a test: enforced and proves the behavior is right, not only that an action was stopped. Climb the ladder when the rung below fails.
| Rung | Cost | Enforced | Proves behavior |
|---|---|---|---|
| Brief line | One sentence | No | No |
| Hook | A few lines of bash | Yes | No |
| Test | A test file | Yes | Yes |
Start at the bottom. Most findings never need to climb. Climb when the same finding comes back.
A protected path is any folder a rule fences off: tests/ during fixes, migrations/ off a migration branch, src/email/ without Priya. Name them in the brief so Quill knows the map, and enforce them in settings or a hook so the map holds.
8. The decision log
Every decision the hook makes is a fact worth keeping. The decision log is an append-only file, .gates/log.jsonl, one JSON object per line. Append-only means lines are added and never edited. Each line records the time, the tool, a summary of the input, the decision, and the rule that made it.
# gate.sh log <decision> <rule> <tool> <input>
if [[ "${1:-}" == "log" ]]; then
mkdir -p .gates
jq -cn --arg ts "$(date -u +%FT%TZ)" --arg decision "$2" --arg rule "$3" \
--arg tool "$4" --arg input "$5" \
'{ts:$ts, tool:$tool, input:$input, decision:$decision, rule:$rule}' >> .gates/log.jsonl
exit 0
fi
Log every decision, including allows. A log with only blocks cannot tell "the gate allowed it" from "the gate never ran." Priya does not read bash. She reads log lines.
Summary
- Every action Quill takes is a proposal; the permission mode sets the default answer, and rules in
.claude/settings.jsonoverride it per command or path. - Allow the safe and frequent, deny the never, ask for the rest; deny wins over ask, and ask wins over allow.
- A PreToolUse hook blocks with exit
2and a reason on stderr, or asks with JSON that names a person and a document; a PostToolUse hook formats the file after every edit. - A finding climbs a ladder: brief line, then hook, then test. Climb when the rung below fails, and climb to a test when the finding is about behavior.
- Every decision lands in
.gates/log.jsonlwith a time, a tool, an input, a decision, and a rule, and that file is the answer to "how do you know?"
Key terms in this lesson
Check your understanding
Pick an answer to see why it is right or wrong. These are graded in code, instantly.
Apply it
Write your answer in your own words. Eve grades it against a rubric and gives you feedback, the same way an LLM judge works in Lesson 5.
Name one command you would deny outright in a project you know, and one you would make ask. Explain why the two get different treatment.
Write the settings rules and one hook for CR-112 so that: test commands run freely, .env is never read, edits under src/email/ ask and name Priya, and every decision is logged. Explain each line.
Instructor notes(for whoever is teaching this lesson)
Talking points
- Open with the migration against the development database. Nothing bad happened, and that is the point: "how do you know?" is the question the lesson answers.
- Every action is a proposal. The mode is the default answer; a rule in
.claude/settings.jsonoverrides it per command or path. - The three lists have a fixed priority: deny over ask, ask over allow. The
Edit(src/**)plusEdit(src/email/**)case is the one to draw on the board. - A permission rule matches a string. A hook can look at anything: the branch, the file, the time.
- Block when the command is wrong; ask when the command is fine and a person should decide. An ask reason names a person and a document.
- The ladder: brief line, hook, test. Climb when the rung below fails. A hook proves an action was stopped; only a test proves behavior.
- Log every decision, including allows.
Live demo idea
With gate.sh, format.sh, and the settings from the lesson in a repository, and no agent running, pipe hand-written JSON into ./gate.sh hook three times: a npm run migrate command on a cr-112 branch, the same command after git checkout -b migration/sales, and an Edit under src/email/. Read each answer aloud: exit 2 with the reason, exit 0, and the ask JSON. Then tail .gates/log.jsonl and count the lines. Finish by piping an unformatted TypeScript file's path into ./format.sh and opening it to show prettier ran.
Common misconceptions
- "Deny on
Read(./.env)means the file is safe." It stops the Read tool.cat .envfrom Bash needs the hook or a Bash rule too. - "Exit 1 blocks." Exit 2 blocks and shows stderr to the agent. Exit 1 is an error.
- "Ask means the agent asks itself or the customer." Ask routes to the person named in the reason.
- "A hook is a test." A hook stops an action. A test proves behavior.
- "Log only the interesting decisions." Then the log cannot tell an allow from a hook that was off.
Timing
Fifty-five minutes: story and modes, six; allow, ask, deny lists, eight; how hooks work and the settings block, seven; the blocking hook, eight; the asking hook, seven; the formatting hook, four; the ladder, seven; the decision log and the line that answers Priya, eight.
If you only have 20 minutes
Do the piped-JSON demo with the migration block and the email ask (eight minutes), teach the three lists and their priority with the src/email/** case on screen (five minutes), and close with the three log lines and the question "what does this prove?" (seven minutes). Assign sections 6 and 7 as reading.