Claude Code File System Boundaries in 2026: What `--add-dir`, Allowed Paths, and Sandbox Roots Actually Restrict
Claude Code's filesystem permissions operate on two distinct layers that most teams conflate. Understanding the difference between sandbox roots and allowed paths determines whether your agent can read secrets, overwrite production config, or execute arbitrary binaries.
The Filesystem Permission Model Nobody Reads
Teams enable Claude Code in production repositories with --add-dir flags they copied from documentation, then discover the agent reading .env files or modifying sibling project directories during auto mode. The failure stems from conflating two independent permission layers that operate at different abstraction levels.
%% alt: Problem flow showing Claude Code reading secrets due to misconfigured permissions
flowchart LR
Start("developer runs claude-code")
AddDir("--add-dir ./src added")
AllowRead("allowRead: ['.'] configured")
Secrets("agent reads .env secrets")
Start --> AddDir
AddDir --> AllowRead
AllowRead --> Secrets
style Secrets stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
The correct model separates sandbox roots (which directories the Bash tool can execute commands inside) from allowed paths (which files Claude's read/write tools can access directly). Configuring one layer does not constrain the other.
%% alt: Solution flow showing properly isolated permissions
flowchart LR
Start("developer runs claude-code")
AddDir("--add-dir ./src added")
DenySecrets("denyRead: ['.env', 'secrets/']")
Isolated("agent isolated from secrets")
Start --> AddDir
AddDir --> DenySecrets
DenySecrets --> Isolated
style Isolated stroke:#34d399,fill:#0b3b2e,color:#d1fae5
This post dissects the boundary mechanics teams must internalize before granting auto-approve permissions. The difference between a contained agent and one that leaks credentials sits in understanding these two layers.
Key Takeaways
- Sandbox roots (controlled by
--add-dirandadditionalDirectories) determine where Bash commands execute, while allowed paths (controlled byallowRead,allowEdit,denyRead,denyEdit) govern which files Claude's tools can access directly. - Adding
allowRead: ['.']to re-enable project access also re-exposes every file under the working directory, including.envandsecrets/, unless explicitly denied in the same configuration block. - Bash commands inherit sandbox root permissions, not allowed path rules, meaning a
cat .envcommand succeeds even when.envsits in a denied path. - The
--add-dirflag expands the sandbox perimeter but does not automatically grant file read/write permissions to those directories for Claude's direct tools. - Production configurations must deny secret paths explicitly and constrain sandbox roots to the minimum set of directories where build/test commands legitimately need write access.
Understanding --add-dir and additionalDirectories: What Actually Gets Unlocked
The --add-dir flag controls sandbox root expansion. When a developer starts Claude Code with claude-code --add-dir ../shared-utils, the Bash sandbox gains write access to that directory. Commands like cd ../shared-utils && npm install now succeed because the sandbox boundary extends to include that path.
This permission operates at the process execution layer. The Bash tool can create, modify, and delete files anywhere inside a sandbox root. No allowRead or allowEdit configuration constrains what happens inside a bash_tool invocation.
%% alt: Diagram showing sandbox root expansion with --add-dir flag
flowchart TD
CLI("claude-code --add-dir ../shared-utils")
Sandbox("sandbox roots expanded")
Bash("bash_tool can write to ../shared-utils")
Install("npm install succeeds")
CLI --> Sandbox
Sandbox --> Bash
Bash --> Install
style Install stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The additionalDirectories field in a permission configuration file (.claude/permissions.jsonc) provides the same capability programmatically. Both mechanisms add paths to the sandbox root set, which starts with the working directory and a per-user temp directory by default.
The trap appears when teams assume that adding a directory to the sandbox automatically grants Claude's read_file or write_file tools permission to access those files. It does not. A --add-dir ../secrets flag allows Bash commands to manipulate that directory, but Claude cannot directly read or write files there unless the allowed paths configuration explicitly permits it.
Conversely, granting allowRead: ['../secrets'] without adding that directory as a sandbox root means Claude can read files directly, but any Bash command that attempts to cd ../secrets fails because the sandbox boundary blocks it. The layers operate independently.
Sandbox Roots vs Allowed Paths: The Layer Distinction That Bites
Sandbox roots define where processes can execute. Allowed paths define which files Claude's tools can touch. The confusion costs teams hours when an agent behavior seems inconsistent, reading some files but not others, or executing commands in unexpected directories.
%% alt: Two-layer permission model showing sandbox roots and allowed paths
flowchart TD
Perms("filesystem permissions")
SandboxLayer("layer 1: sandbox roots")
AllowedLayer("layer 2: allowed paths")
BashTool("bash_tool execution")
ReadWrite("read_file / write_file tools")
BashDirs("can write to sandbox roots only")
AllowedFiles("can access allowed paths only")
Perms --> SandboxLayer
Perms --> AllowedLayer
SandboxLayer --> BashTool
AllowedLayer --> ReadWrite
BashTool --> BashDirs
ReadWrite --> AllowedFiles
style SandboxLayer stroke:#7c9cf0,fill:#142544,color:#eaf2ff
style AllowedLayer stroke:#7c9cf0,fill:#142544,color:#eaf2ff
A concrete scenario clarifies the split. A repository contains three directories: src/, tests/, and secrets/. The developer starts Claude Code with --add-dir ./tests and configures:
{
"allowRead": ["./src"],
"allowEdit": ["./src"],
"denyRead": ["./secrets"]
}Claude can directly read and write files under src/ using its read/write tools. When asked to run the test suite, the agent executes bash_tool with cd tests && npm test, which succeeds because tests/ sits inside a sandbox root. If the agent tries to read tests/utils.test.ts using read_file, the operation fails because tests/ does not appear in allowRead.
The secrets/ directory sits outside both layers. Claude cannot read it directly (denied), and no Bash command can access it (not a sandbox root). This configuration achieves the intended isolation.

The failure mode emerges when teams add allowRead: ['.'] to fix a perceived overly restrictive setup. That single line re-exposes every file under the working directory to Claude's read tool, including secrets/, unless an explicit denyRead rule blocks it. The sandbox root layer remains unchanged, but the allowed paths layer just opened wide.
Code Example: Testing Filesystem Boundaries in Practice
A test script proves the layer separation. The setup creates a directory structure with files at different permission levels, then attempts operations through both Bash and direct file tools.
// verify-boundaries.ts
import { execSync } from 'child_process';
import { writeFileSync, readFileSync, mkdirSync } from 'fs';
import { join } from 'path';
// Setup test structure
const testRoot = './boundary-test';
mkdirSync(join(testRoot, 'allowed'), { recursive: true });
mkdirSync(join(testRoot, 'sandbox-only'), { recursive: true });
mkdirSync(join(testRoot, 'blocked'), { recursive: true });
writeFileSync(join(testRoot, 'allowed', 'public.txt'), 'safe content');
writeFileSync(join(testRoot, 'sandbox-only', 'build.log'), 'build output');
writeFileSync(join(testRoot, 'blocked', 'secret.key'), 'credential');
// Test 1: Direct read tool on allowed path
try {
const content = readFileSync(join(testRoot, 'allowed', 'public.txt'), 'utf-8');
console.log('✓ read_file on allowed path:', content);
} catch (err) {
console.log('✗ read_file blocked:', err.message);
}
// Test 2: Direct read tool on sandbox-only path (not in allowRead)
try {
const content = readFileSync(join(testRoot, 'sandbox-only', 'build.log'), 'utf-8');
console.log('✓ read_file on sandbox-only:', content);
} catch (err) {
console.log('✗ read_file blocked on sandbox-only:', err.message);
}
// Test 3: Bash command on sandbox-only path
try {
const output = execSync(`cat ${join(testRoot, 'sandbox-only', 'build.log')}`, {
encoding: 'utf-8',
cwd: testRoot
});
console.log('✓ bash_tool on sandbox-only:', output);
} catch (err) {
console.log('✗ bash_tool blocked:', err.message);
}
// Test 4: Bash command on blocked path (outside sandbox)
try {
const output = execSync(`cat ${join(testRoot, 'blocked', 'secret.key')}`, {
encoding: 'utf-8',
cwd: testRoot
});
console.log('✓ bash_tool on blocked path:', output);
} catch (err) {
console.log('✗ bash_tool blocked on denied path');
}Running this script with the configuration from the previous section demonstrates the boundary enforcement. Test 1 succeeds because the file sits in an allowed path. Test 2 fails because sandbox-only/ does not appear in allowRead, even though it sits inside a sandbox root. Test 3 succeeds because Bash can execute inside sandbox roots regardless of allowed paths. Test 4 fails because blocked/ sits outside the sandbox boundary entirely.
The output proves that adding a directory to sandbox roots does not grant file tool access, and vice versa. Teams that skip this verification step deploy agents with permissions they did not intend to grant.
Why allowRead: ['.'] Re-Exposes Secrets and How to Deny Them Correctly
The pattern allowRead: ['.'] appears in permission files when developers encounter repeated "permission denied" errors during development. The wildcard grants read access to every file under the working directory, which includes configuration files, environment variables, and any secrets stored in the repository.
A repository with this structure demonstrates the risk:
project/
├── src/
├── tests/
├── .env
├── secrets/
│ └── api-keys.json
└── .claude/
└── permissions.jsonc
The following configuration seems safe because it denies the secrets/ directory:
{
"allowRead": ["."],
"denyRead": ["secrets/"]
}Claude cannot read secrets/api-keys.json because the deny rule takes precedence. But .env sits directly in the working directory, fully exposed. The agent can read database credentials, API tokens, and any other environment secrets without restriction.
%% alt: Permission flow showing how allowRead re-exposes secrets
flowchart LR
AllowDot("allowRead: ['.']")
DenySecrets("denyRead: ['secrets/']")
EnvExposed(".env remains readable")
SecretsBlocked("secrets/ blocked")
AllowDot --> DenySecrets
DenySecrets --> EnvExposed
DenySecrets --> SecretsBlocked
style EnvExposed stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style SecretsBlocked stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The fix requires enumerating every secret path and denying it explicitly:
{
"allowRead": ["."],
"denyRead": [
".env",
".env.*",
"secrets/",
"*.key",
"*.pem",
"*.p12"
]
}This approach scales poorly. Every new secret file type requires a configuration update. A safer pattern allows only the directories that Claude legitimately needs to read:
{
"allowRead": [
"src/",
"tests/",
"package.json",
"tsconfig.json"
],
"allowEdit": ["src/"],
"denyRead": ["*"], // explicit default-deny
"denyEdit": ["*"]
}The explicit default-deny rule makes the permission model fail-safe. Adding a new directory to the repository does not automatically grant access. Teams must consciously expand the allowed set, forcing a review of what the agent can reach.
Bash Commands vs Claude Tools: Which Permissions Block What
The permission model enforces different rules depending on whether Claude invokes a Bash command or calls a direct file tool. Understanding which layer blocks which operation prevents the "it worked in the shell but failed in the agent" confusion.
%% alt: Comparison of Bash tool and file tool permission enforcement
flowchart LR
subgraph BashFlow["bash_tool invocation"]
BashCmd("bash_tool: cat file.txt")
SandboxCheck("sandbox root check")
BashResult("succeeds if file in sandbox root")
end
subgraph FileFlow["file tool invocation"]
FileTool("read_file: file.txt")
AllowCheck("allowed path check")
FileResult("succeeds if file in allowRead")
end
BashCmd --> SandboxCheck
SandboxCheck --> BashResult
FileTool --> AllowCheck
AllowCheck --> FileResult
style BashResult stroke:#7c9cf0,fill:#142544,color:#eaf2ff
style FileResult stroke:#7c9cf0,fill:#142544,color:#eaf2ff
| Operation | Layer Checked | Fails When | Succeeds When |
|---|---|---|---|
bash_tool: cat file.txt | Sandbox roots | File outside sandbox boundary | File in any sandbox root |
read_file: file.txt | Allowed paths | File not in allowRead or in denyRead | File in allowRead and not denied |
bash_tool: cd ../other && ls | Sandbox roots | ../other outside sandbox | ../other added via --add-dir |
write_file: file.txt | Allowed paths | File not in allowEdit or in denyEdit | File in allowEdit and not denied |
A practical scenario illustrates the split. A monorepo contains apps/web and packages/shared. The developer starts Claude Code from apps/web with:
claude-code --add-dir ../../packages/sharedThe configuration file specifies:
{
"allowRead": ["./src"],
"allowEdit": ["./src"]
}When Claude needs to build the app, it executes:
bash_tool: cd ../../packages/shared && npm run buildThis command succeeds because packages/shared sits in a sandbox root. The build writes output to packages/shared/dist, which also succeeds because Bash operates inside the sandbox boundary.
If Claude then tries to read the build output directly:
read_file: ../../packages/shared/dist/index.jsThe operation fails. The file sits outside the allowed read paths. The sandbox layer permitted the build, but the allowed paths layer blocks the read. The agent must use bash_tool: cat ../../packages/shared/dist/index.js to access the content, which routes through the sandbox layer instead.

Teams encounter this pattern when CI scripts rely on build artifacts that the agent cannot read directly. The solution either adds the artifact directory to allowRead or restructures the workflow to keep everything inside allowed paths, eliminating the sandbox-only zone.
Production Patterns: Locking Down Your Agent Before Auto Mode
Production deployments require a defense-in-depth model that assumes the agent will attempt operations outside its intended scope. The configuration must fail safely when Claude requests access to an unexpected path.
%% alt: Production permission lockdown flow
flowchart LR
Start("production config")
Minimal("minimal sandbox roots")
ExplicitAllow("explicit allowRead list")
DenyDefault("denyRead: ['*']")
Safe("agent contained")
Start --> Minimal
Minimal --> ExplicitAllow
ExplicitAllow --> DenyDefault
DenyDefault --> Safe
style Safe stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style DenyDefault stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
The baseline configuration for a Next.js application running automated testing looks like:
{
"additionalDirectories": [], // only working directory
"allowRead": [
"app/",
"components/",
"lib/",
"public/",
"package.json",
"next.config.js",
"tsconfig.json",
"playwright.config.ts"
],
"allowEdit": [
"app/",
"components/",
"lib/"
],
"denyRead": [
".env*",
"secrets/",
"*.key",
"*.pem",
"node_modules/",
".next/",
"coverage/"
],
"denyEdit": [
"package.json",
"*.config.*",
".github/",
"README.md"
]
}This setup grants the agent read access to source directories and configuration files it needs to understand the codebase. Write permissions limit to source code only. Build artifacts, dependencies, and configuration files sit in deny lists.
The additionalDirectories field remains empty, constraining Bash commands to the working directory. If the agent needs to run tests that write temporary files, those writes succeed because the working directory sits in the default sandbox root set. External directories remain unreachable.
The explicit deny rules prevent accidental exposure if a developer later adds allowRead: ['.'] during debugging. The deny list takes precedence, maintaining the security boundary even when the allow list expands.
A stricter variant for CI environments removes all write permissions:
{
"additionalDirectories": [],
"allowRead": [
"app/",
"components/",
"lib/",
"package.json",
"playwright.config.ts"
],
"allowEdit": [], // no direct file writes
"denyRead": ["*"],
"denyEdit": ["*"]
}The agent can read source code and configuration but cannot modify files directly. Test runs that need to write output must use bash_tool commands, which remain constrained to the working directory sandbox. This configuration supports agents that analyze codebases and run tests but cannot make changes autonomously.
Teams deploying auto-approve modes must start from this restrictive baseline and expand permissions only when a specific, verified use case demands it. The expansion process requires documenting why each path needs access and what risk it introduces.
For more details on broader permission strategies, see Claude Code Permission Modes, allowedTools, and Sandbox Configuration.
Frequently Asked Questions
Does adding a directory with --add-dir grant Claude read access to files inside it?
No. The --add-dir flag expands the sandbox root set, which controls where Bash commands can execute. Claude's read_file and write_file tools remain governed by the allowed paths configuration. A file can sit inside a sandbox root but still be inaccessible to direct file tools if it does not appear in allowRead.
Can Claude read .env if it sits outside the sandbox roots but inside allowed paths?
Yes. The allowed paths layer operates independently from sandbox roots. If allowRead: ['.env'] appears in the configuration, Claude can read that file directly even if it sits outside every sandbox root. Bash commands attempting to cat .env would fail if .env sits outside the sandbox, but the read_file tool succeeds because it checks a different layer.
What happens when both allowRead and denyRead match the same file?
Deny rules take precedence. If a file matches both allowRead: ['.'] and denyRead: ['.env'], the deny rule blocks access. This precedence enables safe use of broad allow patterns with specific exceptions for sensitive files.
Why does npm install work but read_file: node_modules/package/index.js fail?
The npm install command runs through bash_tool, which operates inside sandbox roots. The working directory sits in the default sandbox root set, so the command succeeds. Reading a file under node_modules/ requires an explicit allowRead entry for that path. The sandbox layer permitted the install, but the allowed paths layer blocks the read.
Should production configurations use allowRead: ['.'] with extensive deny lists?
No. Production configurations should enumerate allowed paths explicitly and use denyRead: ['*'] as a default-deny rule. The allow-with-exceptions pattern scales poorly and introduces risk when new files appear in the repository. Explicit allow lists force conscious permission expansion and make the security model auditable.
The Two-Layer Model You Must Internalize
Sandbox roots control where processes execute. Allowed paths control which files tools can touch directly. Conflating these layers leads to agents that leak secrets or fail mysteriously when permissions seem correctly configured. Production deployments require explicit allow lists, default-deny rules, and documentation of why each path needs access. The boundary between contained and compromised sits in understanding that --add-dir and allowRead operate on different mechanisms entirely. Teams that treat filesystem permissions as a single unified model will spend hours debugging behavior that the two-layer architecture explains immediately.
For guidance on structuring agent workflows within these boundaries, see Claude Code CI: Agentic Review, Test, and Auto-Fix Workflows and Claude Code Cost Control in Production: Rate Limits, Budgets, and Safe Auto-Approve.