All Articles
CategoryAI
Reading Time
16 min read
Published
2025-12-10
Word Count
4,039words

Grab a coffee — this one is a deep dive!

Terminal AI Agent Permissions: Allowlist & Sandbox

Summary

A practical, sourced guide to AI agent permission modes, allowlist/deny rule syntax, common leak patterns, and sandbox isolation — step by step.

  • The permission system has three independent layers: mode (when you're asked), allowlist/deny rules (which exceptions pass automatically), and sandbox (the physical limit of access outside the rule).
  • Bash rules match by prefix (`Bash(command:*)`); file rules follow four gitignore-style pattern types — a leading `/path` is relative to the settings file's directory, not the disk root.
  • Deny rules block a command's name but may miss a file path or socket access hidden in an argument; `allowUnixSockets`, `$PATH` writes, and `enableWeakerNestedSandbox` are three concrete examples of this leak.
  • By default the sandbox only allows writes to the working directory and read-only access to the rest of the disk, and was observed to cut permission prompts by 84 percent in internal measurement.
Terminal AI Agent Permissions: Allowlist & Sandbox

Giving a terminal-based AI agent access to your file system and command line is a lot like handing a stranger the keys to your house: the problem isn't the key itself, it's which doors it can open. Claude Code's permission system exists to solve exactly this — it decides which tools ask for approval, which rules pass automatically, and which commands take the agent outside its own sandbox. This article builds up the permission layers, the most common rule-writing mistakes, and how sandbox isolation actually works in practice, step by step.

💡 Pro Tip: Write a permission rule by testing "which concrete command does this rule let through, and which does it block" — not by whether it "looks generally safe." The gap between those two questions can be as wide as the gap between rm and rm -rf /.

Table of Contents

Thinking about agent permissions as a threat model

Granting a terminal agent permissions isn't a single on/off switch — it's a layered chain of decisions. The question isn't "how much do I trust this agent," it's "what do I lose if this agent produces a bad command." Three things operate independently here: which tools ask for approval (permission mode), how that approval can be automated (allowlist/deny rules), and whether the file system and network are physically isolated outside approval altogether (sandbox). It's easy to conflate the three: a developer writes an allowlist, decides they're "safe," and never turns on the sandbox — but the allowlist only determines _which command passes without being asked_, not _what that command can reach_.

In practice the threat model reduces to three questions: (1) If the agent runs this command, is there something I can't undo? (2) Does the file/network surface it can reach extend beyond its actual job? (3) Am I making this decision, or is a rule making it for me? The third matters most, because the real purpose of the permission system is to direct your attention not to "everything," but to "what's risky." The sections below show how mode, rule, and isolation complement one another.

Permission layers: ask every time, session-scoped approval, persistent allowlist

Claude Code uses a layered permission system to balance power with safety. Tools fall into three categories: read-only tools (reading files, LS, Grep) never ask; Bash commands and file-modifying tools (Edit, Write) do ask — but the lifetime of your "don't ask again" answer differs: for Bash, approval persists per project directory and per command; for file-modifying tools, it only lasts until session end. This three-way split is the skeleton of the permission system — everything else (modes, rules, sandbox) builds on top of it.

Modes determine how you use that skeleton. In default mode, every new tool use (a new Bash command, a new file edit) asks for approval; in acceptEdits mode, file edits are auto-accepted but Bash still asks; in plan mode, the agent only reads — no change is ever applied; in bypassPermissions mode, every approval prompt is skipped — you should only use this in an isolated, reversible environment (a container, a sandboxed VM).

Mode
What it does
When to use it
default
Asks for approval on every new tool/command use
Early days on a new project, before you trust the agent yet
acceptEdits
File edits auto-approve, Bash still asks
Multi-file but low-risk work, like refactors
plan
Agent only reads/plans, no change is ever applied
Architectural exploration before a decision, risky-task preview
bypassPermissions
Every approval prompt is skipped
Only in an isolated environment (container/VM), never on the host

I usually start a new repo in plan mode, read what the agent proposes, and only then switch to default — this stops you from hitting approve before answering "what is the agent about to touch." A persistent allowlist is a different layer: regardless of mode, it means "don't ask me about this specific pattern again." So the mode defines _default behavior_, the allowlist defines _exceptions_ — conflating the two produces confusion like "acceptEdits is on but every Bash call still asks."

Anatomy of rule writing and the three most common mistakes

Allowlist and deny rules for Bash commands work by prefix matching. A rule written as Bash(npm run test:*) matches every command that starts with npm run test — using :* as a suffix means "whatever comes after this, match it." File rules (Read/Edit) use a different syntax: there are four distinct pattern types, following the gitignore spec.

Pattern type
Example
Meaning
Absolute path
//abs/path
An exact, fixed location on disk
Home directory
~/path
Relative to the user's home directory
Settings-file-relative
/path
Relative to the directory containing the settings file that holds the rule
Working-directory-relative
path or ./path
Relative to the current working directory (cwd)
json
1{
2 "permissions": {
3 "allow": ["Bash(npm run test:*)", "Bash(git diff:*)", "Read(./src/**)"],
4 "deny": ["Bash(curl:*)", "Edit(/docs/**)"]
5 }
6}

The first of the three mistakes hides in exactly this example: Edit(/docs/**) doesn't target the disk root, like /docs/; it targets the docs/ directory inside the folder holding the settings file — this only means <project-root>/docs/ if that file lives at the project root. The documentation makes a related warning: /Users/alice/file is _not_ an absolute path — for that you have to write //Users/alice/file.

The second mistake is confusing the server name with the tool name when restricting MCP tools. MCP rules are written as mcp__server-name__tool-namemcp__puppeteer__puppeteer_navigate only matches that single tool, not the whole server. To disable all of a server's tools you have to write the server name alone; otherwise you'll hit "I allowed this but it's still asking," because a sibling tool was left out of the rule.

The third mistake is misjudging _when_ rules kick in. PreToolUse hooks run before the permission system, and a hook's output can directly determine the approve/deny decision — so even a written allowlist rule can be overridden by a hook that runs first. Settings also don't come from one file: there's a five-level hierarchy (enterprise policies, command-line arguments, local project settings, shared project settings, user settings), and a higher level overrides a lower one. Most of the time a rule "isn't working," a conflicting rule at a different level is actually winning.

  • Prefix matching: Bash(rule:*) only matches commands that begin with that prefix — not a different argument in the middle or at the end of the command.
  • Settings-relative path: a leading single / in file rules means relative to the directory holding the settings file where the rule was written — not the root of the disk.
  • Hierarchy priority: enterprise policy > command line > local project > shared project > user settings; the higher level always wins.

Why deny rules leak — file paths and compound commands hidden in arguments

Writing a deny rule doesn't mean that command becomes entirely harmless — the documentation itself lists each of the three risks below under its security limitations heading. If you grant an agent inside a sandbox access to specific sockets via allowUnixSockets, and that access accidentally covers /var/run/docker.sock, that single line opens an effective path to the host through the docker socket — the sandbox documentation itself warns about this. Similarly, broad write access to $PATH directories can become a privilege-escalation path: the agent thinks it's writing a harmless helper script, but it may be replacing a system tool that runs from PATH. The enableWeakerNestedSandbox setting noticeably weakens isolation when nesting sandboxes and should only be turned on when genuinely required.

What these share: a deny rule and sandbox isolation can't evaluate the _intent hidden inside an argument_ — only the command's _name_. Bash(curl:*) can block curl entirely, but a behavior embedded in an argument instead of the command name (a socket path, a $PATH write) can easily slip past the rule-writer. So ask: "does this rule block the command's name, or the resource it can reach?" The second question is harder, but far more reliable.

A subtler leak is a rule seeing only part of the command. The documentation gives a clear assurance: Claude Code is aware of shell operators (like &&), so a prefix rule like Bash(safe-cmd:*) won't let safe-cmd && other-cmd through. In practice this didn't hold for every rule: a hole let some wildcard rules match compound commands containing shell operators; it was later closed (see Update). Keep wildcard rules narrow, and run compound commands by hand.

The settings snippet below shows what a narrow scope looks like: when a single socket path is written instead of a wildcard like /var/run/*.sock, /var/run/docker.sock never falls within the rule's scope at all.

json
1{
2 "sandbox": {
3 "allowUnixSockets": ["/var/run/app-metrics.sock"]
4 }
5}

Practical rule: always write settings like allowUnixSockets, $PATH write access, and enableWeakerNestedSandbox at the narrowest possible scope, and widen them only as a real need arises — doing the opposite, starting broad and narrowing later, means leaving an unnoticed leak open in the window in between.

Irreversible commands: rm -rf, git push --force, applying a migration

rm -rf, git push --force, and applying a migration to production aren't three commands in the same category, but they share one trait: once they run, there's usually no "undo." Today's permission system has no separately named "dangerous command" classification — the Bash tool is subject to a single approval layer: either a rule passes it automatically, or it's asked about every time. The system makes no distinction between rm -rf ./tmp and rm -rf / — you have to make that distinction yourself, when writing the rule.

In practice, this means never put irreversible commands into your allowlist with a broad wildcard (like Bash(rm:*)). Instead, either leave them entirely to default mode's approval, or put the risky command fully into the deny list and run it by hand only when genuinely needed. The same applies to git push --force — never bury it in an allowlist rule; a single wrong inference by the agent can permanently corrupt a branch's history.

For migrations I'd recommend similar discipline: let the agent _generate_ the migration, but _apply_ it by hand — read the generated file yourself first, then run it yourself. The system doesn't enforce this for you; build it as a habit. The safest approach today: (1) never put irreversible commands in the allowlist, (2) where possible, read "what the agent is about to do" first in plan mode, (3) trigger the apply step yourself, by hand, outside the sandbox.

Isolation: running with a sandbox, a container, and a separate user

Approval rules are a behavioral layer; the sandbox is a physical boundary layer — the two answer different questions. With the sandbox on, the default behavior is: the agent has read and write access to the current working directory; for the rest of the disk (aside from specific blocked directories) it only has read access. A similar logic applies to the network — access is controlled through a proxy server running outside the sandbox, and the first request to a new domain triggers an approval prompt.

At the OS level, this isolation is implemented differently per platform: bubblewrap on Linux, the Seatbelt framework on macOS. Turning the sandbox on is a single command:

bash
1# Enable sandbox mode
2/sandbox

Running with a separate user or a container is a second isolation layer stacked on top of the sandbox — the documentation, under integration with existing security tooling, directly recommends a devcontainer: running the agent inside a development container layers the sandbox's file-system/network isolation with the container's own process and user isolation. In any repo close to production I prefer using both together — the sandbox alone says "don't step outside this directory," the container says "there's already nothing outside this directory to reach"; the second is a far stronger guarantee.

Inside organizations this has produced a measured, concrete benefit: sandboxing was observed to reduce permission prompts by 84 percent — commands that stay inside the sandbox can often proceed without waiting for approval, and the only thing that stays risky is a request to step outside it.

This discipline matters even more when agents call on-device models or local inference tools; an agent running on-device machine learning models with access to both files and compute gets an extra margin of safety from the sandbox's "only write to cwd" rule.

Team permission policy: who approves what, and how an audit trail is kept

In a single-user project, permission decisions are a personal preference; in a team, they need to be consistent and auditable. Two mechanisms work together here. The first is the five-level settings hierarchy: enterprise policies sit at the top and no developer can override them locally; below that come, in order, command-line arguments, local project settings (not checked into git, personal), shared project settings (checked into git, shared by the team), and at the bottom, user settings. This ties "who can forbid risky commands" to a clear hierarchy: a deny rule can only be overridden by a setting at a higher level, and deny rules take precedence over allow and ask rules at the same level.

The second mechanism is the audit trail: because PreToolUse hooks run before the permission system and their output can determine the approve/deny decision, a team can use this hook as a centralized logging point — recording every Bash call, its decision, and its rationale, independently of anyone's memory.

json
1{
2 "hooks": {
3 "PreToolUse": [
4 {
5 "matcher": "Bash",
6 "hooks": [
7 { "type": "command", "command": "./scripts/log-bash-call.sh" }
8 ]
9 }
10 ]
11 }
12}

A practical rule for teams: write only low-risk, frequently repeated commands (like npm run test:*, git diff:*) into shared project settings; manage anything needing network access, or reaching outside the file system, centrally at the enterprise-policy level. This stops every developer from inventing their own security interpretation, and lets teams running privacy and compliance audits audit requirements from a single place.

GOLDEN TIP

The most valuable insight in this article

This tip holds the article's most important takeaway.

Easter Egg

You found a hidden gem!

There's a hidden detail in this section. Want to uncover it?

Reader Reward

I've condensed every decision from this article into a single checklist — one you can walk through step by step when granting an agent permissions in a new repo, so you don't skip a step. Use the items below when setting up a new project or auditing an existing allowlist.

FAQ

What permissions is it safe to give an AI coding agent?

The safe starting point is default or plan mode without writing a single persistent allowlist rule — this puts every Bash command and file change in front of you for approval. Only add genuinely frequent, low-risk commands (running tests, git diff) to the allowlist; never auto-approve anything requiring network access, reaching outside the file system, or irreversible. Turn on the sandbox too, and even commands with no permission rule stay inside a physically isolated area.

How do you write allowlist and deny rules, and where do they leak?

Bash rules work by prefix matching (Bash(command:*)), while file rules follow four gitignore-style pattern types (absolute, home directory, settings-relative, working-directory-relative). The most common leak is a deny rule blocking a command's name while leaving a file path or socket access hidden in an argument untouched — the documentation explicitly names this with the allowUnixSockets-to-docker.sock example. After writing a deny rule, always test it by asking "does this also cover the resource this command can reach?"

When is it acceptable to skip all permissions?

bypassPermissions mode skips every approval prompt — acceptable only in an isolated, reversible environment: a container, a disposable VM, or a CI job running inside a sandbox. Turning it on on your main development machine, with real files and real credentials, risks a single wrong inference turning into an unrecoverable outcome. As a rule: only use it where you can say "if I lost this machine, I'd lose nothing."

What's the most practical way to run an agent in isolation?

The most practical, lowest-setup path is turning on sandbox mode with the /sandbox command — by default, this gives the agent write access only to the working directory, read-only access to the rest of the disk, and network access mediated through a proxy. If you want stronger isolation, you can add a devcontainer or a separate user account on top of this; the two layers aren't mutually exclusive — they work together. The sandbox alone says "don't step outside this directory"; the container says "there's already nothing outside this directory you could reach anyway."

I wrote a deny rule and it's still running — why?

First check the settings hierarchy: enterprise policy sits at the top, then command line, local project, shared project, user settings — if the rule you wrote sits at a lower level, it may be overridden by an allow rule at a higher level (or vice versa). The second thing to check is PreToolUse hooks: because these run before the permission system, a hook can grant approval through its own logic, independent of your deny rule. Third, if you wrote a file rule, check that a /path-style rule is relative to the directory holding the settings file, not an absolute path — an absolute path requires a double slash (//path).

Update (September 2026)

The body of this article describes the permission system as of 2025-12-10; it has changed significantly since. The biggest change is a new mode, "auto mode": a second model (a classifier) reviews every action instead of the user, and this is now the default starting mode on Pro, Max, and Team plans. As of September 19, 2026, this classifier also runs server-side by default for Claude API/Enterprise/Bedrock/Vertex/Foundry users, at no additional charge.

A separate mode was added for locked-down CI and script scenarios: dontAsk only allows read-only and previously-approved tools to run, and auto-rejects anything requiring a prompt. The old default mode is now labeled "Manual" in the CLI, editor plugins, and the desktop app (the CLI accepts manual as an alias for default).

The compound-command leak I described in this article's "Why deny rules leak" section was also closed during this period: the security hole where wildcard permission rules could match compound commands containing shell operators (like ;, &&) was fixed in January 2026 (v2.1.7). A similar tightening happened on the sandbox side — the sandbox.excludedCommands glob pattern previously exempted an entire command from the sandbox when only part of a compound command matched; with the September 2026 fix, every part of the command now has to match for the exemption to apply.

bash
1# In unattended sessions, the dangerous rm command is now automatically
2# rejected if it goes unanswered for 2 minutes; to disable this:
3export CLAUDE_CODE_DISABLE_DANGEROUS_RM_TIMEOUT=1

A silent-bypass bug in the sandbox's dangerouslyDisableSandbox escape hatch — a command run outside the sandbox passing without a prompt — was fixed in April 2026 (v2.1.113); a similar hole involving writes through a symlink closed on September 22, 2026: a write that actually landed outside the tree via a symlink could be mistakenly auto-approved by acceptEdits or auto mode. The "don't ask again" rule-saving behavior from "Permission layers" also changed: before v2.1.211, a worktree approval was only written to the directory where the session started; now the rule resolves to the main checkout's git root and applies to the whole repository, including subdirectories and worktrees.

Conclusion

Granting a terminal agent permissions means putting three layers in the right order: the mode (when you'll be asked), the rule (which exceptions pass automatically), then the sandbox (how far anything left outside the rule can physically reach). This is an extension of the same "start narrow, widen as needed" principle found in secure credential storage and performance profiling. A team that combines it with spec-driven development alongside the sandbox closes off most of the "I granted permission but there's still a leak" problem.

Sources

Tags

#AI agent#permission management#allowlist#sandbox#Claude Code#security#terminal
Muhittin Çamdalı

Muhittin Çamdalı

Lead Mobile Engineer

Lead Mobile Engineer with 12+ years of experience. Expert in iOS, Android and cross-platform architectures with Swift, SwiftUI, Kotlin and Flutter. I build performant, user-friendly mobile apps.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

We respect your privacy. You can unsubscribe at any time.

Share