Nothing in this module ships to your plugin. It pairs with module 3: a plugin cannot carry a CLAUDE.md and it cannot carry your permission rules. Capabilities are distributable. Policy is not. If you want a rule enforced on a machine, it has to be in a settings file on that machine, or in managed settings pushed there by an administrator.
Module 9 gave you hooks, which run your own code on a tool call and decide the same way every time. It is tempting to read that as a security boundary, meaning a line that holds even when someone is working to get past it. A hook is not one, and the reason is specific rather than philosophical, so this module gets to it directly after the rules that hooks interact with.
The modes
Every permission decision starts from the mode the session is in.
| Mode | What it allows without asking |
|---|---|
default | Reads only. Labelled Manual in the CLI, VS Code, JetBrains, and Desktop; manual is accepted as an alias. |
acceptEdits | File edits, plus mkdir, touch, rm, rmdir, mv, cp, sed inside the working directory and additionalDirectories. |
plan | Read and explore. Edits are blocked until you approve a plan. |
auto | A classifier, an automatic judge of allow or refuse, reviews each action instead of you. |
dontAsk | Nothing that is not pre-approved. Auto-denies rather than prompting. |
bypassPermissions | Everything, including protected-path writes. --dangerously-skip-permissions is the same thing. |
Two of these surprise people. dontAsk never prompts, which sounds permissive and is the opposite: anything without an allow rule is denied outright, and it never appears in the Shift+Tab cycle, so you set it with --permission-mode dontAsk. And acceptEdits includes rm, which reads as an edit mode and quietly is not.
The docs on bypassPermissions are blunt about what it costs: it “offers no protection against prompt injection or unintended actions,” and should be used “only in isolated environments like containers, VMs, or dev containers without internet access.” Prompt injection is text sitting in a file, a web page, or a tool result that the model reads as an instruction from you and acts on. Isolated means the code can reach only what you have handed it and nothing else on the machine. With no prompts left to catch a mistake, whatever you isolated it inside is the entire protection. It refuses to start as root or under sudo on Linux and macOS, and you cannot enter it from a session that did not start with it.
Rule syntax
A rule is either a bare tool name or Tool(specifier). Bash(*) and Bash mean the same thing.
A bare tool name in a deny rule removes the tool from Claude’s context entirely, so Claude cannot see it to reach for it. A scoped rule such as Bash(rm *) leaves the tool in place and blocks the calls that match.
For Bash, an * matches any text at all, spaces included, and everything before the first * has to match character for character. That * is the wildcard, and its position is the whole game. Put it after the subcommand.
| Rule | Matches | Does not match |
|---|---|---|
Bash(npm run build) | npm run build | npm run build --watch |
Bash(npm run *) | npm run build, npm run test --watch, npm run | npm install |
Bash(git * main) | git merge main, git push origin main | git log |
Bash(ls *) | ls -la, ls | lsof |
Bash(ls*) | ls -la, lsof | nothing |
Bash(* --version) | node --version | node -v |
A trailing * also matches the bare command, but only when it is the rule’s only wildcard, and the space before it is part of the rule. Claude Code warns at startup about an allow rule with a * before the subcommand, because such a rule almost always grants more than its author intended.
A compound command is several commands strung together in one line. Those split on &&, ||, ;, |, |&, &, and newlines, and every part has to match independently. A short list of wrappers, commands whose only job is to run another command, is stripped before matching: timeout, time, nice, nohup, stdbuf, the command and builtin shell builtins, zsh’s noglob, and bare xargs with no flags. The list is built in and not configurable, which has a sharp consequence: direnv exec, devbox run, mise exec, npx, and docker exec are not wrappers, so Bash(devbox run *) happily matches devbox run rm -rf ..
Path rules
Read and Edit take patterns written the way .gitignore writes them. Each pattern is measured from somewhere, and that starting point is its anchor. There are four, and the third one is the one people misread.
| Pattern | Anchored to |
|---|---|
//path | the filesystem root: Read(//Users/alice/secrets/**) |
~/path | your home directory |
/path | the settings file the rule is written in, not the filesystem root |
path or ./path | the current directory |
That third row resolves differently per source: a rule in project .claude/settings.json anchors at the project root, one in .claude/settings.local.json anchors at the original working directory, one in ~/.claude/settings.json anchors at ~/.claude/.
A bare filename behaves as it would in a .gitignore file and matches at any depth, so Read(.env) is Read(**/.env). That rule set is what the docs mean by gitignore semantics, semantics being the meaning a piece of syntax carries. Windows paths are rewritten in the Unix style before matching, so C:\Users\alice is /c/Users/alice and you write //c/**/.env.
Only Edit(path) and Read(path) are consulted for file permission checks. Rules written for Write, NotebookEdit, Glob, or MultiEdit are accepted, warned about at startup, and never used. A Read deny also blocks Edit and Write on the same path, though not NotebookEdit. And read and edit deny rules cover Claude’s file tools plus the Bash file commands it recognises, cat, head, tail, sed. They do not cover any other program Claude starts, so a Python script that opens the file directly is outside them.
Deny beats ask beats allow
Rules evaluate in that order, first match wins, and specificity is irrelevant. This is the opposite of CSS, of routing tables, and of most of what your instincts are calibrated on.
A broad Bash(aws *) deny blocks a narrow Bash(aws s3 ls) allow. A matching ask rule prompts even when a more specific allow rule also matches. There is no way to carve an exception out of a deny rule by writing something more precise.
The same holds across settings files. Precedence for everything else runs managed, then --settings, then .claude/settings.local.json, then .claude/settings.json, then ~/.claude/settings.json. But for denials, level does not matter: if a tool is denied at any level, no other level can allow it. A user-level deny blocks a project-level allow, and a project-level deny blocks a user-level allow.
.claude/hooks/guard-rm.sh Exit 2 stops the call before permission rules are evaluated. That is why this beats an allow rule.
#!/usr/bin/env bash
cmd=$(jq -r '.tool_input.command' <<< "$(cat)")
case "$cmd" in
*rm\ * | */bin/rm* | *-delete* | *unlink*)
echo "guard-rm: refusing destructive command" >&2
exit 2
;;
esac
exit 0 .claude/settings.json Committed. The deny rules here cannot be lifted by anything in any other file.
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./secrets/**)",
"Bash(rm *)"
],
"ask": ["Bash(git push *)"],
"allow": ["Bash(npm run test *)"]
}
} Read more
The Bash(rm *) deny in this file is the one the Break section takes apart. It stops the literal string rm, and it is worth writing anyway, but it is a speed bump rather than a boundary.
.claude/settings.local.json Not committed. Where "Yes, and don't ask again" writes its rules.
{
"permissions": {
"allow": ["Bash(gh pr view *)"]
}
} Read more
Standing approvals land in .claude/settings.local.json at the git repository root, resolved through worktrees to the main checkout; outside a repo, or on Windows, they land in the starting directory. Approvals for file modifications are session-only and are never written to disk at all.
Where a hook sits in this, exactly
PreToolUse hooks run before the permission prompt, for every tool except EndConversation. A hook can deny a call, force a prompt, or skip a prompt. Two sentences decide whether it is a boundary.
A hook returning "allow" does not bypass deny or ask rules. They are evaluated regardless. A matching deny still blocks; a matching ask still prompts. The effect is that a managed deny rule cannot be defeated by a hook someone dropped in a repository. A hook "allow" also cannot clear a critical-path rm, and it cannot skip an organisation’s ask on a connector tool or an MCP tool marked as requiring user interaction.
A hook exiting with code 2 stops the call before permission rules are evaluated. That direction does beat allow rules, and it is the documented pattern for “run all Bash without prompting except for a handful of commands”: put "Bash" in allow, then register a PreToolUse hook that rejects the specific ones you care about.
So the hook is strictly stronger at saying no and strictly weaker at saying yes. It can block what the rules would have permitted. It cannot permit what the rules block. It is a filter you control rather than a boundary.
Paths the modes do not cover
Two categories sit outside the mode system, and knowing they exist stops you writing rules that were never going to be consulted.
Protected paths never auto-approve writes except under bypassPermissions. permissions.allow rules do not pre-approve them, because the check runs before allow rules are read. The directory list includes .git, .config/git, .vscode, .idea, .husky, .cargo, .devcontainer, .yarn, .mvn, and .claude itself apart from .claude/worktrees. The file list is longer and follows one theme: anything that executes on someone else’s machine later. That theme has a name. Code and the packages it pulls in travel from machine to machine, and the whole chain of hands a file passes through on the way to running is the supply chain. A file that runs on checkout, or a package your project depends on, is somebody else’s code executing as you. Shell startup files, .envrc, .npmrc, .yarnrc, .pre-commit-config.yaml, lefthook.yml, the Gradle and Maven wrapper properties, .devcontainer.json, .mcp.json, and .claude.json.
Critical paths apply to rm and rmdir and are never auto-approved in any mode, bypassPermissions included. They cover the filesystem root, any direct child of root, your home directory, Windows drive roots and their top-level directories, and your working directory and its parents. A glob or trailing slash directly under a shell variable counts too, because rm -rf "$DIR"/* with an empty $DIR is a root deletion. Hiding the path inside $(...) or <(...), which are the two shell forms that run one command to produce text for another, does not evade the check.
The sandbox, at the altitude you need
A sandbox is a restricted box you run a program inside, where the program can touch only what the box lets through. The built-in one puts Bash in such a box using the operating system’s own machinery: Seatbelt on macOS, bubblewrap plus socat on Linux and WSL2. Native Windows is not supported and neither is WSL1.
Its default boundary writes to the working directory and a session temp directory, and reads the entire computer apart from directories you deny. ~/.aws/credentials and ~/.ssh are readable unless you add them to sandbox.filesystem.denyRead. No domains are allowed for network access until you allow them.
Two limits decide where it belongs in your thinking.
It covers the programs Bash starts and nothing else. Read, Edit, and Write go through the permission system rather than the sandbox, computer use runs on your real desktop, and a subagent shares its parent’s sandbox config rather than getting a fresh one.
The network side works by sending every request through a proxy, a middleman that each connection has to pass through. That proxy decides from the hostname the connecting program says it wants, and it does not open the encrypted traffic to check by default.
The docs state the consequence: a program can claim one host and reach another, which is called domain fronting, so a broad entry such as github.com becomes a route for your data to leave the machine. Security people call that exfiltration. An allowlist of one narrow host is a control. An allowlist containing a large domain that hosts thousands of other people’s content is closer to a suggestion.
You write "deny": ["Bash(rm *)"] and consider deletion handled.
It is not, and the docs name this one specifically. Permission rules match the literal command string. rm -rf build matches. /bin/rm -rf build does not, because the rule’s literal prefix is rm and the string starts with /bin/. find . -name '*.log' -delete does not match either, because the command is find. Nor does a Python script that calls os.remove, nor git clean -fdx, nor a Makefile target that shells out.
The rule is still worth writing. It catches the common spelling and costs nothing. What it is not is a guarantee, and the gap between “I wrote a deny rule” and “deletion is blocked” is where the rest of this module lives.
The documented fixes are the two mechanisms that see the call rather than the string: a PreToolUse hook, which can inspect the whole command and exit 2 on anything it dislikes, or the sandbox, which limits what any program Bash starts can reach no matter how it spells itself. Neither is complete on its own. A hook you wrote can be out-thought; the sandbox does not cover the file tools. Layer them and be honest about the seams.
This module ships nothing, so build it where it will actually run: .claude/settings.json in a real project.
Write three rules. A deny on the secrets in your repo, using a path anchor you can defend: Read(./.env) if you mean the one at the project root, Read(.env) if you mean every .env at any depth. An ask on whichever command you never want running unattended, most likely Bash(git push *). An allow on your test command, narrow enough that it does not double as a licence to run arbitrary commands, meaning anything at all, chosen by whoever wrote the command rather than by you.
Then test each one, because a rule you have not watched fire is a rule you are guessing about. Ask Claude to read the denied file and confirm it refuses. Ask it to push and confirm the prompt appears even though your mode would otherwise allow it. Run the test command and confirm no prompt.
Now the other half. Add Bash(rm *) to the deny list, then ask Claude to delete a scratch file with /bin/rm. Watch it go through. Write a PreToolUse hook that exits 2 on the same command and watch it stop. You have now seen both halves of the claim this module opened with.
Less than anything else in this course.
Tool(specifier) syntax, the deny-ask-allow ordering, the protected and critical path lists, and the sandbox keys are all Claude Code’s own. No other harness surveyed publishes a compatible rule format. The closest neighbours are structural rather than syntactic: OpenCode gives each agent a permission map in opencode.json, and a Codex CLI agent definition accepts sandbox_mode among its TOML keys. Neither takes a rule you wrote here.
What does travel is the reasoning. Every one of these tools layers configuration as managed, then user, then project, then local, with the project layer checked into git and the user layer staying private, so the judgment about what belongs in which layer moves intact even when the filenames do not. And the sentence from module 3 travels furthest of all, because the docs of half these tools say a version of it: instructions persuade, hooks enforce, and neither one is a sandbox.
Check yourself
- A managed settings file allows
Bash(aws *). Your~/.claude/settings.jsondeniesBash(aws s3 rm *). What happens onaws s3 rm s3://bucket/key? - A
PreToolUsehook returns"allow"forrm -rf ~. What does Claude Code do? - Your sandbox allowlist contains
github.com. What can a compromised dependency in your test suite still send, and where?