Skip to content

feat(spawner): add file permission guardrails via OS-level sandboxing#356

Open
khaliqgant wants to merge 7 commits intomainfrom
claude/add-file-permission-guardrails-dj7zO
Open

feat(spawner): add file permission guardrails via OS-level sandboxing#356
khaliqgant wants to merge 7 commits intomainfrom
claude/add-file-permission-guardrails-dj7zO

Conversation

@khaliqgant
Copy link
Collaborator

@khaliqgant khaliqgant commented Feb 1, 2026

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


Open with Devin

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
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

Insecure creation of file in
the os temp dir
.

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 tmp at the top of packages/spawner/src/sandbox.test.ts.
  • In the cleanupSandboxProfile test "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 (SandboxResult construction and expectations) remains unchanged.

No other functionality or tests need to change.

Suggested changeset 2
packages/spawner/src/sandbox.test.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/spawner/src/sandbox.test.ts b/packages/spawner/src/sandbox.test.ts
--- a/packages/spawner/src/sandbox.test.ts
+++ b/packages/spawner/src/sandbox.test.ts
@@ -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 = {
EOF
@@ -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 = {
packages/spawner/package.json
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/spawner/package.json b/packages/spawner/package.json
--- a/packages/spawner/package.json
+++ b/packages/spawner/package.json
@@ -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",
EOF
@@ -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",
This fix introduces these dependencies
Package Version Security advisories
tmp (npm) 0.2.5 None
Copilot is powered by AI and may make mistakes. Always verify output.
Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

View issues and 6 additional flags in Devin Review.

Open in Devin Review

Comment on lines +308 to +320
// 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('');
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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:

  1. (deny file* (subpath "/project/.env")) - disallowed paths (line 313)
  2. (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/**, *.pem are 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +418 to +459
// 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);
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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):

  1. --tmpfs /project/.env - disallowed paths (line 423)
  2. --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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +488 to +501
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);
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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 .pem files)
  • *.env/project/.env (only matches literal .env, not foo.env)
  • **/credentials*/project/credentials (strips all wildcards)
  • .env.*/project/.env. (trailing dot)

Actual vs Expected

  • Actual: Glob patterns like *.pem resolve 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +93 to +95
'read-only': {
writable: [], // Empty = nothing writable
},

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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-only preset grants full read/write access to project root
  • Expected: read-only preset 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 */ }

Open in Devin Review

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants