feat(spawner): add file permission guardrails via OS-level sandboxing#356
feat(spawner): add file permission guardrails via OS-level sandboxing#356khaliqgant wants to merge 7 commits intomainfrom
Conversation
Implements cross-platform file permission enforcement that wraps any CLI with OS-level sandboxing: - macOS: Uses sandbox-exec (Seatbelt) to enforce file access restrictions - Linux: Uses bubblewrap (bwrap) for filesystem isolation Key features: - FilePermissions schema with allowed, disallowed, readOnly, writable paths - Preset profiles: block-secrets, source-only, read-only, docs-only - Agent config frontmatter support (file-allowed, file-disallowed, etc.) - Automatic cleanup of sandbox profiles on agent exit - CLI-agnostic - works with claude, codex, gemini, cursor, or any CLI The sandbox wrapper is applied before the CLI command is executed, providing kernel-level enforcement regardless of which agent CLI is used. https://claude.ai/code/session_016u5mnEQW63fxT2sAeeywbC
Runtime state directory should not be tracked. https://claude.ai/code/session_016u5mnEQW63fxT2sAeeywbC
Add edge case tests for: - Empty permissions handling - Network isolation (allowNetwork: false) - Path handling (absolute, relative, globs) - File permission parsing from agent config frontmatter - All preset variants - mergePermissions edge cases https://claude.ai/code/session_016u5mnEQW63fxT2sAeeywbC
Add schema validation for file-* frontmatter fields: - file-allowed, file-disallowed, file-readonly, file-writable - file-network, file-preset, allowed-cwd Follows existing kebab-case convention from allowed-tools. https://claude.ai/code/session_016u5mnEQW63fxT2sAeeywbC
File permissions should be specified at spawn time via SpawnRequest, not in agent markdown frontmatter. This change: - Removes parseFilePermissions/parseFilePermissionPreset from agent-config.ts - Removes file-* fields from AgentFrontmatterSchema - Updates spawner to only use SpawnRequest.filePermissions/filePermissionPreset - Removes related tests from agent-config.test.ts The sandbox module and SpawnRequest-based file permissions remain intact. https://claude.ai/code/session_016u5mnEQW63fxT2sAeeywbC
| describe('cleanupSandboxProfile', () => { | ||
| it('cleans up profile file', () => { | ||
| const tempFile = path.join(os.tmpdir(), `test-sandbox-${Date.now()}.sb`); | ||
| fs.writeFileSync(tempFile, '(version 1)'); |
Check failure
Code scanning / CodeQL
Insecure temporary file High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 8 days ago
In general, the issue is fixed by not manually constructing a filename in the OS temp directory and then writing to it. Instead, use a secure temp-file mechanism that atomically creates a new file with restrictive permissions and returns its path, ensuring no prior existence and preventing races.
For this specific test, the best non‑breaking change is to use the tmp library’s synchronous file API (tmp.fileSync()) to create a secure temporary file and then pass its .name to the test. This preserves the behavior (we get a real file on disk that cleanupSandboxProfile should delete) but eliminates the insecure temp-path construction. Concretely:
- Add an import for
tmpat the top ofpackages/spawner/src/sandbox.test.ts. - In the
cleanupSandboxProfiletest"cleans up profile file", replace:const tempFile = path.join(os.tmpdir(), ...);fs.writeFileSync(tempFile, '(version 1)');
with:const tmpFile = tmp.fileSync({ postfix: '.sb' });const tempFile = tmpFile.name;fs.writeFileSync(tempFile, '(version 1)');(optional, but keeps semantics identical).
- The rest of the test (
SandboxResultconstruction and expectations) remains unchanged.
No other functionality or tests need to change.
| @@ -8,6 +8,7 @@ | ||
| import fs from 'node:fs'; | ||
| import os from 'node:os'; | ||
| import path from 'node:path'; | ||
| import tmp from 'tmp'; | ||
| import { | ||
| applySandbox, | ||
| detectCapabilities, | ||
| @@ -161,7 +162,8 @@ | ||
|
|
||
| describe('cleanupSandboxProfile', () => { | ||
| it('cleans up profile file', () => { | ||
| const tempFile = path.join(os.tmpdir(), `test-sandbox-${Date.now()}.sb`); | ||
| const tmpFile = tmp.fileSync({ postfix: '.sb' }); | ||
| const tempFile = tmpFile.name; | ||
| fs.writeFileSync(tempFile, '(version 1)'); | ||
|
|
||
| const result: SandboxResult = { |
| @@ -23,7 +23,8 @@ | ||
| "clean": "rm -rf dist" | ||
| }, | ||
| "dependencies": { | ||
| "zod": "^3.23.8" | ||
| "zod": "^3.23.8", | ||
| "tmp": "^0.2.5" | ||
| }, | ||
| "devDependencies": { | ||
| "typescript": "^5.9.3", |
| Package | Version | Security advisories |
| tmp (npm) | 0.2.5 | None |
| // Disallowed paths (highest priority - deny first) | ||
| if (permissions.disallowed?.length) { | ||
| lines.push('; Explicitly denied paths'); | ||
| for (const pattern of permissions.disallowed) { | ||
| const resolved = resolvePath(pattern, projectRoot); | ||
| lines.push(`(deny file* (subpath "${resolved}"))`); | ||
| // Also deny the literal pattern for globs | ||
| if (pattern !== resolved) { | ||
| lines.push(`(deny file* (literal "${path.join(projectRoot, pattern)}"))`); | ||
| } | ||
| } | ||
| lines.push(''); | ||
| } |
There was a problem hiding this comment.
🔴 macOS Seatbelt sandbox deny rules are overridden by subsequent allow rules
The disallowed paths in file permissions are not actually blocked on macOS. In Seatbelt profiles, the last matching rule wins, but the code places deny rules for disallowed paths BEFORE the allow rules for allowed paths or the project root default.
Click to expand
How the bug is triggered
The generateSeatbeltProfile function builds rules in this order:
(deny file* (subpath "/project/.env"))- disallowed paths (line 313)(allow file-read* (subpath "/project/"))- project root default (lines 355-356)
Since /project/.env is a subpath of /project/, and the allow rule comes after the deny rule, the allow rule wins and .env is accessible.
Actual vs Expected
- Actual: Disallowed paths like
.env,secrets/**,*.pemare still accessible because the project root allow rule overrides the deny rules - Expected: Disallowed paths should be blocked even when inside the project root
Impact
This is a security bypass - the sandbox feature advertised as "kernel-level enforcement" does not actually block access to sensitive files like .env, secrets, and private keys.
Recommendation: Move the deny rules for disallowed paths AFTER all allow rules, so they take precedence. The profile should end with deny rules, not allow rules.
Was this helpful? React with 👍 or 👎 to provide feedback.
| // Disallowed paths - replace with empty tmpfs | ||
| if (permissions.disallowed?.length) { | ||
| for (const pattern of permissions.disallowed) { | ||
| const resolved = resolvePath(pattern, projectRoot); | ||
| if (fs.existsSync(resolved)) { | ||
| bwrapArgs.push('--tmpfs', resolved); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Read-only paths - bind as read-only | ||
| if (permissions.readOnly?.length) { | ||
| for (const pattern of permissions.readOnly) { | ||
| const resolved = resolvePath(pattern, projectRoot); | ||
| if (fs.existsSync(resolved)) { | ||
| bwrapArgs.push('--ro-bind', resolved, resolved); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Writable paths - bind as read-write | ||
| if (permissions.writable?.length) { | ||
| for (const pattern of permissions.writable) { | ||
| const resolved = resolvePath(pattern, projectRoot); | ||
| if (fs.existsSync(resolved)) { | ||
| bwrapArgs.push('--bind', resolved, resolved); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Allowed paths (whitelist) - bind as read-write | ||
| if (permissions.allowed?.length) { | ||
| for (const pattern of permissions.allowed) { | ||
| const resolved = resolvePath(pattern, projectRoot); | ||
| if (fs.existsSync(resolved)) { | ||
| bwrapArgs.push('--bind', resolved, resolved); | ||
| } | ||
| } | ||
| } else { | ||
| // No whitelist = allow project root | ||
| bwrapArgs.push('--bind', projectRoot, projectRoot); | ||
| } |
There was a problem hiding this comment.
🔴 Linux bwrap sandbox disallowed paths overwritten by project root bind mount
The disallowed paths are not actually blocked on Linux with bwrap. The code applies --tmpfs for disallowed paths before --bind for the project root, but in bwrap later mount options override earlier ones for overlapping paths.
Click to expand
How the bug is triggered
The applyBwrapSandbox function builds args in this order (sandbox.ts:418-458):
--tmpfs /project/.env- disallowed paths (line 423)--bind /project /project- project root (line 458)
The --bind /project /project mounts the REAL /project directory, which includes .env, overwriting the tmpfs that was meant to hide it.
Actual vs Expected
- Actual: Disallowed files remain accessible because the project root bind mount overwrites the tmpfs
- Expected: Disallowed files should be hidden/inaccessible
Impact
Same security bypass as macOS - sensitive files are not actually blocked despite the sandbox being applied.
Recommendation: Apply the --tmpfs for disallowed paths AFTER the --bind for project root/allowed paths, so the tmpfs takes precedence.
Was this helpful? React with 👍 or 👎 to provide feedback.
| function resolvePath(pattern: string, projectRoot: string): string { | ||
| // If it's a glob pattern, just join with project root | ||
| if (pattern.includes('*')) { | ||
| return path.join(projectRoot, pattern.replace(/\*\*/g, '').replace(/\*/g, '')); | ||
| } | ||
|
|
||
| // If absolute, use as-is | ||
| if (path.isAbsolute(pattern)) { | ||
| return pattern; | ||
| } | ||
|
|
||
| // Otherwise, resolve relative to project root | ||
| return path.resolve(projectRoot, pattern); | ||
| } |
There was a problem hiding this comment.
🔴 resolvePath incorrectly strips glob characters producing wrong paths
The resolvePath function incorrectly handles glob patterns by stripping all * characters, which produces incorrect file paths that don't match the intended files.
Click to expand
How the bug is triggered
The function at sandbox.ts:488-501 does:
if (pattern.includes('*')) {
return path.join(projectRoot, pattern.replace(/\*\*/g, '').replace(/\*/g, ''));
}This produces incorrect results:
*.pem→/project/.pem(a hidden file named.pem, not all.pemfiles)*.env→/project/.env(only matches literal.env, notfoo.env)**/credentials*→/project/credentials(strips all wildcards).env.*→/project/.env.(trailing dot)
Actual vs Expected
- Actual: Glob patterns like
*.pemresolve to literal paths like/project/.pem - Expected: Glob patterns should either be expanded to match files, or the sandbox should use regex/glob matching instead of subpath rules
Impact
Security bypass - patterns like *.pem, *.key, **/credentials* in the block-secrets preset won't actually block the intended files.
Recommendation: Either enumerate matching files using a glob library and create deny rules for each, or use Seatbelt's regex matching capabilities instead of subpath rules for patterns containing wildcards.
Was this helpful? React with 👍 or 👎 to provide feedback.
| 'read-only': { | ||
| writable: [], // Empty = nothing writable | ||
| }, |
There was a problem hiding this comment.
🔴 read-only preset does not enforce read-only access
The read-only preset is supposed to prevent all writes, but it actually allows full read/write access to the project root.
Click to expand
How the bug is triggered
The read-only preset at sandbox.ts:93-95 is defined as:
'read-only': {
writable: [], // Empty = nothing writable
},However, generateSeatbeltProfile only adds writable paths if permissions.writable?.length is truthy (line 333). An empty array has length 0 (falsy), so this section is skipped.
Then, since allowed is also undefined, the code falls through to the default case (lines 352-357):
} else {
// No whitelist = allow project root by default
lines.push(`(allow file-read* (subpath "${projectRoot}"))`);
lines.push(`(allow file-write* (subpath "${projectRoot})"))`);
}Actual vs Expected
- Actual:
read-onlypreset grants full read/write access to project root - Expected:
read-onlypreset should only allow file reads, blocking all writes
Impact
Users who think they're spawning agents with read-only access are actually giving them full write access.
Recommendation: Check for writable being an empty array (not just falsy) and explicitly deny writes when writable: [] is set. For example: if (permissions.writable !== undefined && permissions.writable.length === 0) { /* deny all writes */ }
Was this helpful? React with 👍 or 👎 to provide feedback.
Keep all trajectories from both branches and use the most recent lastUpdated timestamp. https://claude.ai/code/session_016u5mnEQW63fxT2sAeeywbC
Implements cross-platform file permission enforcement that wraps any CLI
with OS-level sandboxing:
Key features:
The sandbox wrapper is applied before the CLI command is executed,
providing kernel-level enforcement regardless of which agent CLI is used.
https://claude.ai/code/session_016u5mnEQW63fxT2sAeeywbC