From c07da61bf2eeb8bc56c5fac602a8f64150893b6b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 07:31:31 +0000 Subject: [PATCH 1/6] feat(spawner): add file permission guardrails via OS-level sandboxing 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 --- packages/bridge/src/spawner.ts | 59 ++- packages/bridge/src/types.ts | 12 + packages/config/src/agent-config.ts | 98 +++++ packages/spawner/src/index.ts | 1 + packages/spawner/src/sandbox.test.ts | 229 ++++++++++++ packages/spawner/src/sandbox.ts | 514 +++++++++++++++++++++++++++ packages/spawner/src/types.ts | 75 ++++ 7 files changed, 984 insertions(+), 4 deletions(-) create mode 100644 packages/spawner/src/sandbox.test.ts create mode 100644 packages/spawner/src/sandbox.ts diff --git a/packages/bridge/src/spawner.ts b/packages/bridge/src/spawner.ts index 5d1216e93..571c5bf2e 100644 --- a/packages/bridge/src/spawner.ts +++ b/packages/bridge/src/spawner.ts @@ -37,6 +37,13 @@ import type { SpawnWithShadowResult, SpeakOnTrigger, } from './types.js'; +import { + applySandbox, + mergePermissions, + cleanupSandboxProfile, + detectCapabilities, + type SandboxResult, +} from '@agent-relay/spawner'; // Logger instance for spawner (uses daemon log system instead of console) const log = createLogger('spawner'); @@ -1048,10 +1055,18 @@ export class AgentSpawner { }; } + // Track sandbox result for cleanup (set later if sandbox is applied) + let sandboxResultForCleanup: SandboxResult | undefined; + // Common exit handler for both wrapper types const onExitHandler = (code: number) => { if (debug) log.debug(`Worker ${name} exited with code ${code}`); + // Clean up sandbox profile file if one was created + if (sandboxResultForCleanup) { + cleanupSandboxProfile(sandboxResultForCleanup); + } + // Get the agentId and clean up listeners before removing from active workers const worker = this.activeWorkers.get(name); const agentId = worker?.pty?.getAgentId?.(); @@ -1103,6 +1118,42 @@ export class AgentSpawner { await this.release(workerName); }; + // Apply file permission sandbox if configured + // This wraps the CLI command with platform-specific sandboxing (sandbox-exec on macOS, bwrap on Linux) + const filePermissions = mergePermissions( + request.filePermissionPreset, + request.filePermissions + ); + + // Also check agent config for file permissions + const agentConfigForPerms = findAgentConfig(name, this.projectRoot); + const mergedFilePermissions = agentConfigForPerms?.filePermissions + ? mergePermissions( + agentConfigForPerms.filePermissionPreset, + { ...agentConfigForPerms.filePermissions, ...filePermissions } + ) + : filePermissions; + + let sandboxedCommand = command; + let sandboxedArgs = args; + + if (mergedFilePermissions && Object.keys(mergedFilePermissions).length > 0) { + const sandboxResult = applySandbox(command, args, mergedFilePermissions, agentCwd); + sandboxedCommand = sandboxResult.command; + sandboxedArgs = sandboxResult.args; + // Store for cleanup on exit + sandboxResultForCleanup = sandboxResult; + + if (sandboxResult.sandboxed) { + log.info(`Applied ${sandboxResult.method} sandbox for ${name}`); + if (debug) { + log.debug(`Sandbox permissions: ${JSON.stringify(mergedFilePermissions)}`); + } + } else if (debug) { + log.debug(`No sandbox available for ${name} (platform: ${detectCapabilities().platform})`); + } + } + // Check if we should use OpenCodeWrapper with HTTP API mode if (isOpenCodeCli) { // OpenCodeApi reads OPENCODE_API_URL or OPENCODE_PORT from env, defaults to localhost:4096 @@ -1119,8 +1170,8 @@ export class AgentSpawner { const openCodeConfig: OpenCodeWrapperConfig = { name, - command, - args, + command: sandboxedCommand, + args: sandboxedArgs, socketPath: this.socketPath, cwd: agentCwd, dashboardPort: this.dashboardPort, @@ -1264,8 +1315,8 @@ export class AgentSpawner { // Create RelayPtyOrchestrator (relay-pty Rust binary) const ptyConfig: RelayPtyOrchestratorConfig = { name, - command, - args, + command: sandboxedCommand, + args: sandboxedArgs, socketPath: this.socketPath, cwd: agentCwd, dashboardPort: this.dashboardPort, diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index 7d69057e0..46a839fed 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -3,6 +3,8 @@ * Types for multi-project orchestration */ +import type { FilePermissions, FilePermissionPresetType } from '@agent-relay/spawner'; + export interface ProjectConfig { /** Absolute path to project root */ path: string; @@ -61,6 +63,16 @@ export interface SpawnRequest { userId?: string; /** Include ACK/DONE workflow conventions in agent instructions (default: false) */ includeWorkflowConventions?: boolean; + /** + * File permission guardrails (OS-level sandbox enforcement). + * Wraps the CLI command with platform-specific sandboxing. + */ + filePermissions?: FilePermissions; + /** + * Use a predefined file permission preset instead of custom config. + * Presets: 'block-secrets', 'source-only', 'read-only', 'docs-only' + */ + filePermissionPreset?: FilePermissionPresetType; } /** Policy decision details */ diff --git a/packages/config/src/agent-config.ts b/packages/config/src/agent-config.ts index 9013410f1..bd53f49f8 100644 --- a/packages/config/src/agent-config.ts +++ b/packages/config/src/agent-config.ts @@ -8,6 +8,27 @@ import fs from 'node:fs'; import path from 'node:path'; +/** + * File permission configuration for OS-level sandbox enforcement. + */ +export interface FilePermissions { + /** Paths the agent can access (whitelist mode) */ + allowed?: string[]; + /** Paths the agent cannot access (blacklist) */ + disallowed?: string[]; + /** Paths the agent can only read */ + readOnly?: string[]; + /** Paths the agent can write to */ + writable?: string[]; + /** Whether to allow network access (default: true) */ + allowNetwork?: boolean; +} + +/** + * File permission preset names. + */ +export type FilePermissionPreset = 'block-secrets' | 'source-only' | 'read-only' | 'docs-only'; + export interface AgentConfig { /** Agent name (from filename or frontmatter) */ name: string; @@ -23,6 +44,77 @@ export interface AgentConfig { agentType?: string; /** Agent role for prompt composition (planner, worker, reviewer, lead, shadow) */ role?: string; + /** + * File permission guardrails (OS-level sandbox enforcement). + * Parsed from frontmatter fields: + * file-allowed: src/**,tests/** + * file-disallowed: .env*,secrets/** + * file-readonly: package.json + * file-writable: dist/** + * file-network: true/false + */ + filePermissions?: FilePermissions; + /** + * File permission preset to use. + * Options: block-secrets, source-only, read-only, docs-only + */ + filePermissionPreset?: FilePermissionPreset; + /** + * Allowed working directories for child agents spawned by this agent. + */ + allowedCwd?: string[]; +} + +/** + * Parse file permissions from frontmatter fields. + * + * Supported fields: + * file-allowed: src/**,tests/** + * file-disallowed: .env*,secrets/** + * file-readonly: package.json,tsconfig.json + * file-writable: dist/** + * file-network: true/false + */ +function parseFilePermissions(frontmatter: Record): FilePermissions { + const permissions: FilePermissions = {}; + + if (frontmatter['file-allowed']) { + permissions.allowed = frontmatter['file-allowed'].split(',').map(p => p.trim()); + } + + if (frontmatter['file-disallowed']) { + permissions.disallowed = frontmatter['file-disallowed'].split(',').map(p => p.trim()); + } + + if (frontmatter['file-readonly']) { + permissions.readOnly = frontmatter['file-readonly'].split(',').map(p => p.trim()); + } + + if (frontmatter['file-writable']) { + permissions.writable = frontmatter['file-writable'].split(',').map(p => p.trim()); + } + + if (frontmatter['file-network']) { + permissions.allowNetwork = frontmatter['file-network'].toLowerCase() === 'true'; + } + + return permissions; +} + +/** + * Parse and validate file permission preset from frontmatter. + */ +function parseFilePermissionPreset(value: string | undefined): FilePermissionPreset | undefined { + if (!value) return undefined; + + const validPresets: FilePermissionPreset[] = ['block-secrets', 'source-only', 'read-only', 'docs-only']; + const normalized = value.toLowerCase().trim(); + + if (validPresets.includes(normalized as FilePermissionPreset)) { + return normalized as FilePermissionPreset; + } + + return undefined; } /** @@ -98,6 +190,9 @@ export function findAgentConfig(agentName: string, projectRoot?: string): AgentC const content = fs.readFileSync(configPath, 'utf-8'); const frontmatter = parseFrontmatter(content); + // Parse file permissions from frontmatter + const filePermissions = parseFilePermissions(frontmatter); + return { name: frontmatter.name || baseName, configPath, @@ -106,6 +201,9 @@ export function findAgentConfig(agentName: string, projectRoot?: string): AgentC allowedTools: frontmatter['allowed-tools']?.split(',').map(t => t.trim()), agentType: frontmatter.agentType, role: frontmatter.role, + filePermissions: Object.keys(filePermissions).length > 0 ? filePermissions : undefined, + filePermissionPreset: parseFilePermissionPreset(frontmatter['file-preset']), + allowedCwd: frontmatter['allowed-cwd']?.split(',').map(p => p.trim()), }; } } diff --git a/packages/spawner/src/index.ts b/packages/spawner/src/index.ts index 93bb7213c..62361d1ea 100644 --- a/packages/spawner/src/index.ts +++ b/packages/spawner/src/index.ts @@ -6,3 +6,4 @@ */ export * from './types.js'; +export * from './sandbox.js'; diff --git a/packages/spawner/src/sandbox.test.ts b/packages/spawner/src/sandbox.test.ts new file mode 100644 index 000000000..81192a820 --- /dev/null +++ b/packages/spawner/src/sandbox.test.ts @@ -0,0 +1,229 @@ +/** + * Sandbox Module Tests + * + * Tests for cross-platform file permission sandboxing. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + applySandbox, + detectCapabilities, + resolvePreset, + mergePermissions, + cleanupSandboxProfile, + FILE_PERMISSION_PRESETS, + type SandboxResult, +} from './sandbox.js'; +import type { FilePermissions } from './types.js'; + +describe('sandbox', () => { + const testProjectRoot = '/tmp/test-project'; + + describe('detectCapabilities', () => { + it('returns platform information', () => { + const caps = detectCapabilities(); + expect(caps.platform).toBe(os.platform()); + expect(caps).toHaveProperty('available'); + expect(caps).toHaveProperty('methods'); + expect(Array.isArray(caps.methods)).toBe(true); + }); + + it('caches capabilities', () => { + const caps1 = detectCapabilities(); + const caps2 = detectCapabilities(); + expect(caps1).toBe(caps2); // Same reference + }); + }); + + describe('resolvePreset', () => { + it('resolves block-secrets preset', () => { + const preset = resolvePreset('block-secrets'); + expect(preset.disallowed).toBeDefined(); + expect(preset.disallowed).toContain('.env'); + expect(preset.disallowed).toContain('*.pem'); + }); + + it('resolves source-only preset', () => { + const preset = resolvePreset('source-only'); + expect(preset.allowed).toBeDefined(); + expect(preset.allowed).toContain('src/**'); + expect(preset.readOnly).toContain('package.json'); + }); + + it('resolves read-only preset', () => { + const preset = resolvePreset('read-only'); + expect(preset.writable).toEqual([]); + }); + + it('resolves docs-only preset', () => { + const preset = resolvePreset('docs-only'); + expect(preset.allowed).toContain('docs/**'); + expect(preset.readOnly).toContain('src/**'); + }); + }); + + describe('mergePermissions', () => { + it('returns undefined when both inputs are undefined', () => { + const result = mergePermissions(undefined, undefined); + expect(result).toBeUndefined(); + }); + + it('returns preset when explicit is undefined', () => { + const result = mergePermissions('block-secrets', undefined); + expect(result).toEqual(FILE_PERMISSION_PRESETS['block-secrets']); + }); + + it('returns explicit when preset is undefined', () => { + const explicit: FilePermissions = { allowed: ['src/**'] }; + const result = mergePermissions(undefined, explicit); + // mergePermissions adds empty arrays for arrays fields + expect(result?.allowed).toEqual(['src/**']); + }); + + it('merges preset and explicit permissions', () => { + const explicit: FilePermissions = { + allowed: ['custom/**'], + disallowed: ['extra-secret/**'], + }; + const result = mergePermissions('block-secrets', explicit); + + expect(result?.allowed).toEqual(['custom/**']); + // Should include both preset disallowed and explicit disallowed + expect(result?.disallowed).toContain('.env'); + expect(result?.disallowed).toContain('extra-secret/**'); + }); + + it('explicit allowed overrides preset allowed', () => { + const explicit: FilePermissions = { allowed: ['only-this/**'] }; + const result = mergePermissions('source-only', explicit); + expect(result?.allowed).toEqual(['only-this/**']); + }); + }); + + describe('applySandbox', () => { + const testCommand = 'claude'; + const testArgs = ['--dangerously-skip-permissions']; + const testPermissions: FilePermissions = { + disallowed: ['.env', 'secrets/**'], + allowed: ['src/**'], + }; + + it('returns original command when no sandbox available', () => { + // Force detection of no capabilities (this tests the fallback path) + const caps = detectCapabilities(); + + // If no sandbox methods are available, command should be unchanged + if (!caps.available) { + const result = applySandbox(testCommand, testArgs, testPermissions, testProjectRoot); + expect(result.command).toBe(testCommand); + expect(result.args).toEqual(testArgs); + expect(result.sandboxed).toBe(false); + expect(result.method).toBe('none'); + } + }); + + it('includes profilePath for cleanup when sandboxed', () => { + const caps = detectCapabilities(); + + if (caps.methods.includes('sandbox-exec')) { + const result = applySandbox(testCommand, testArgs, testPermissions, testProjectRoot); + + if (result.sandboxed) { + expect(result.method).toBe('sandbox-exec'); + expect(result.profilePath).toBeDefined(); + expect(fs.existsSync(result.profilePath!)).toBe(true); + + // Cleanup + cleanupSandboxProfile(result); + expect(fs.existsSync(result.profilePath!)).toBe(false); + } + } + }); + + it('wraps command with bwrap on Linux', () => { + const caps = detectCapabilities(); + + if (caps.methods.includes('bwrap')) { + const result = applySandbox(testCommand, testArgs, testPermissions, testProjectRoot); + + expect(result.sandboxed).toBe(true); + expect(result.method).toBe('bwrap'); + expect(result.command).toBe('bwrap'); + expect(result.args).toContain('--die-with-parent'); + expect(result.args).toContain('--'); + expect(result.args).toContain(testCommand); + } + }); + }); + + describe('cleanupSandboxProfile', () => { + it('cleans up profile file', () => { + const tempFile = path.join(os.tmpdir(), `test-sandbox-${Date.now()}.sb`); + fs.writeFileSync(tempFile, '(version 1)'); + + const result: SandboxResult = { + command: 'sandbox-exec', + args: ['-f', tempFile, 'test'], + sandboxed: true, + method: 'sandbox-exec', + profilePath: tempFile, + }; + + expect(fs.existsSync(tempFile)).toBe(true); + cleanupSandboxProfile(result); + expect(fs.existsSync(tempFile)).toBe(false); + }); + + it('handles missing profile gracefully', () => { + const result: SandboxResult = { + command: 'test', + args: [], + sandboxed: false, + method: 'none', + profilePath: '/nonexistent/path.sb', + }; + + // Should not throw + expect(() => cleanupSandboxProfile(result)).not.toThrow(); + }); + + it('handles undefined profilePath', () => { + const result: SandboxResult = { + command: 'test', + args: [], + sandboxed: false, + method: 'none', + }; + + // Should not throw + expect(() => cleanupSandboxProfile(result)).not.toThrow(); + }); + }); + + describe('FILE_PERMISSION_PRESETS', () => { + it('has all expected presets', () => { + expect(FILE_PERMISSION_PRESETS).toHaveProperty('block-secrets'); + expect(FILE_PERMISSION_PRESETS).toHaveProperty('source-only'); + expect(FILE_PERMISSION_PRESETS).toHaveProperty('read-only'); + expect(FILE_PERMISSION_PRESETS).toHaveProperty('docs-only'); + }); + + it('block-secrets includes common sensitive files', () => { + const preset = FILE_PERMISSION_PRESETS['block-secrets']; + expect(preset.disallowed).toContain('.env'); + expect(preset.disallowed).toContain('*.pem'); + expect(preset.disallowed).toContain('*.key'); + expect(preset.disallowed).toContain('secrets/**'); + }); + + it('source-only limits access to code directories', () => { + const preset = FILE_PERMISSION_PRESETS['source-only']; + expect(preset.allowed).toContain('src/**'); + expect(preset.allowed).toContain('tests/**'); + expect(preset.readOnly).toContain('package.json'); + }); + }); +}); diff --git a/packages/spawner/src/sandbox.ts b/packages/spawner/src/sandbox.ts new file mode 100644 index 000000000..c55f599f6 --- /dev/null +++ b/packages/spawner/src/sandbox.ts @@ -0,0 +1,514 @@ +/** + * Cross-Platform File Permission Sandbox + * + * Wraps CLI commands with OS-level sandboxing to enforce file permissions. + * This provides hard enforcement regardless of which CLI is being spawned. + * + * Supported platforms: + * - macOS: Uses sandbox-exec (Seatbelt) + * - Linux: Uses bubblewrap (bwrap) or Landlock + * - Windows: Policy-only (no hard enforcement) + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import type { FilePermissions, FilePermissionPresetType } from './types.js'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface SandboxResult { + /** The command to execute (may be wrapped) */ + command: string; + /** The arguments (may include sandbox args) */ + args: string[]; + /** Whether hard sandbox enforcement is active */ + sandboxed: boolean; + /** Platform-specific sandbox method used */ + method: 'sandbox-exec' | 'bwrap' | 'landlock' | 'none'; + /** Path to temporary profile file (if any, caller should clean up) */ + profilePath?: string; +} + +export interface SandboxCapabilities { + /** Whether any sandboxing is available */ + available: boolean; + /** Available sandbox methods */ + methods: ('sandbox-exec' | 'bwrap' | 'landlock')[]; + /** Current platform */ + platform: NodeJS.Platform; +} + +// ============================================================================= +// Presets +// ============================================================================= + +/** + * Predefined file permission configurations for common use cases. + */ +export const FILE_PERMISSION_PRESETS: Record = { + 'block-secrets': { + disallowed: [ + '.env', + '.env.*', + '*.env', + '.env.local', + '.env.production', + '.env.development', + 'secrets', + 'secrets/**', + '.secrets', + '.secrets/**', + '*.pem', + '*.key', + '*.p12', + '*.pfx', + '*.jks', + '**/credentials*', + '**/password*', + '.git/config', // May contain tokens + '.npmrc', // May contain tokens + '.pypirc', + ], + }, + + 'source-only': { + allowed: ['src/**', 'lib/**', 'tests/**', 'test/**', 'spec/**', 'docs/**'], + readOnly: [ + 'package.json', + 'package-lock.json', + 'yarn.lock', + 'pnpm-lock.yaml', + 'tsconfig.json', + 'tsconfig.*.json', + '*.config.js', + '*.config.ts', + ], + disallowed: ['.env*', 'secrets/**'], + }, + + 'read-only': { + writable: [], // Empty = nothing writable + }, + + 'docs-only': { + allowed: ['docs/**', 'README.md', 'CHANGELOG.md', '*.md', 'LICENSE*'], + readOnly: ['src/**'], // Can read source for reference + disallowed: ['.env*', 'secrets/**'], + }, +}; + +// ============================================================================= +// Capability Detection +// ============================================================================= + +let cachedCapabilities: SandboxCapabilities | null = null; + +/** + * Detect available sandboxing capabilities on the current system. + */ +export function detectCapabilities(): SandboxCapabilities { + if (cachedCapabilities) { + return cachedCapabilities; + } + + const platform = os.platform(); + const methods: SandboxCapabilities['methods'] = []; + + if (platform === 'darwin') { + // macOS: Check for sandbox-exec + if (commandExists('sandbox-exec')) { + methods.push('sandbox-exec'); + } + } else if (platform === 'linux') { + // Linux: Check for bubblewrap + if (commandExists('bwrap')) { + methods.push('bwrap'); + } + // Check for Landlock support (kernel 5.13+) + if (hasLandlockSupport()) { + methods.push('landlock'); + } + } + + cachedCapabilities = { + available: methods.length > 0, + methods, + platform, + }; + + return cachedCapabilities; +} + +/** + * Check if a command exists in PATH. + */ +function commandExists(cmd: string): boolean { + try { + execSync(`which ${cmd}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +/** + * Check if Linux Landlock is supported (kernel 5.13+). + */ +function hasLandlockSupport(): boolean { + try { + // Landlock ABI is exposed via /sys/kernel/security/landlock + return fs.existsSync('/sys/kernel/security/lsm') && + fs.readFileSync('/sys/kernel/security/lsm', 'utf-8').includes('landlock'); + } catch { + return false; + } +} + +// ============================================================================= +// Sandbox Application +// ============================================================================= + +/** + * Apply file permission sandbox to a command. + * + * @param command - The CLI command to execute + * @param args - Command arguments + * @param permissions - File permissions to enforce + * @param projectRoot - Project root for resolving relative paths + * @returns Wrapped command with sandbox enforcement + */ +export function applySandbox( + command: string, + args: string[], + permissions: FilePermissions, + projectRoot: string +): SandboxResult { + const capabilities = detectCapabilities(); + + if (!capabilities.available) { + return { + command, + args, + sandboxed: false, + method: 'none', + }; + } + + // Prefer sandbox-exec on macOS + if (capabilities.methods.includes('sandbox-exec')) { + return applyMacOSSandbox(command, args, permissions, projectRoot); + } + + // Prefer bwrap on Linux (more widely available than Landlock) + if (capabilities.methods.includes('bwrap')) { + return applyBwrapSandbox(command, args, permissions, projectRoot); + } + + // Fallback: no sandbox + return { + command, + args, + sandboxed: false, + method: 'none', + }; +} + +/** + * Resolve a file permission preset to its configuration. + */ +export function resolvePreset(preset: FilePermissionPresetType): FilePermissions { + return FILE_PERMISSION_PRESETS[preset]; +} + +/** + * Merge file permissions, with explicit permissions taking precedence over presets. + */ +export function mergePermissions( + preset: FilePermissionPresetType | undefined, + explicit: FilePermissions | undefined +): FilePermissions | undefined { + if (!preset && !explicit) { + return undefined; + } + + const base = preset ? resolvePreset(preset) : {}; + if (!explicit) { + return base; + } + + return { + allowed: explicit.allowed ?? base.allowed, + disallowed: [...(base.disallowed ?? []), ...(explicit.disallowed ?? [])], + readOnly: [...(base.readOnly ?? []), ...(explicit.readOnly ?? [])], + writable: explicit.writable ?? base.writable, + allowNetwork: explicit.allowNetwork ?? base.allowNetwork, + }; +} + +// ============================================================================= +// macOS Sandbox (Seatbelt) +// ============================================================================= + +/** + * Generate a macOS Seatbelt sandbox profile. + */ +function generateSeatbeltProfile( + permissions: FilePermissions, + projectRoot: string +): string { + const lines: string[] = [ + '(version 1)', + '', + '; Default deny all file operations', + '(deny default)', + '', + '; Allow basic process operations', + '(allow process-fork)', + '(allow process-exec)', + '(allow signal)', + '(allow sysctl-read)', + '(allow mach-lookup)', + '(allow ipc-posix-shm-read-data)', + '(allow ipc-posix-shm-write-data)', + '', + '; Allow system library access (required for any process)', + '(allow file-read* (subpath "/usr/lib"))', + '(allow file-read* (subpath "/usr/share"))', + '(allow file-read* (subpath "/System"))', + '(allow file-read* (subpath "/Library"))', + '(allow file-read* (subpath "/private/var/db"))', + '(allow file-read* (subpath "/dev"))', + '(allow file-write* (subpath "/dev/null"))', + '(allow file-write* (subpath "/dev/tty"))', + '', + '; Allow user library access', + `(allow file-read* (subpath "${os.homedir()}/Library"))`, + `(allow file-read* (subpath "${os.homedir()}/.local"))`, + `(allow file-read* (subpath "${os.homedir()}/.config"))`, + '', + '; Allow temp directory access', + '(allow file-read* (subpath "/private/tmp"))', + '(allow file-write* (subpath "/private/tmp"))', + '(allow file-read* (subpath "/var/folders"))', + '(allow file-write* (subpath "/var/folders"))', + '', + ]; + + // Network access + if (permissions.allowNetwork !== false) { + lines.push('; Allow network access (for API calls)'); + lines.push('(allow network*)'); + lines.push(''); + } + + // 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(''); + } + + // Read-only paths + if (permissions.readOnly?.length) { + lines.push('; Read-only paths'); + for (const pattern of permissions.readOnly) { + const resolved = resolvePath(pattern, projectRoot); + lines.push(`(allow file-read* (subpath "${resolved}"))`); + } + lines.push(''); + } + + // Writable paths + if (permissions.writable?.length) { + lines.push('; Writable paths'); + for (const pattern of permissions.writable) { + const resolved = resolvePath(pattern, projectRoot); + lines.push(`(allow file-read* (subpath "${resolved}"))`); + lines.push(`(allow file-write* (subpath "${resolved}"))`); + } + lines.push(''); + } + + // Allowed paths (if whitelist mode) + if (permissions.allowed?.length) { + lines.push('; Allowed paths (whitelist)'); + for (const pattern of permissions.allowed) { + const resolved = resolvePath(pattern, projectRoot); + lines.push(`(allow file-read* (subpath "${resolved}"))`); + lines.push(`(allow file-write* (subpath "${resolved}"))`); + } + lines.push(''); + } else { + // No whitelist = allow project root by default + lines.push('; Default: allow project root'); + lines.push(`(allow file-read* (subpath "${projectRoot}"))`); + lines.push(`(allow file-write* (subpath "${projectRoot}"))`); + lines.push(''); + } + + return lines.join('\n'); +} + +/** + * Apply macOS sandbox-exec wrapper. + */ +function applyMacOSSandbox( + command: string, + args: string[], + permissions: FilePermissions, + projectRoot: string +): SandboxResult { + const profile = generateSeatbeltProfile(permissions, projectRoot); + + // Write profile to temp file + const profilePath = path.join(os.tmpdir(), `relay-sandbox-${Date.now()}.sb`); + fs.writeFileSync(profilePath, profile, { mode: 0o600 }); + + return { + command: 'sandbox-exec', + args: ['-f', profilePath, command, ...args], + sandboxed: true, + method: 'sandbox-exec', + profilePath, + }; +} + +// ============================================================================= +// Linux Sandbox (bubblewrap) +// ============================================================================= + +/** + * Apply Linux bubblewrap wrapper. + */ +function applyBwrapSandbox( + command: string, + args: string[], + permissions: FilePermissions, + projectRoot: string +): SandboxResult { + const bwrapArgs: string[] = [ + '--die-with-parent', + '--new-session', + ]; + + // Start with read-only root filesystem + bwrapArgs.push('--ro-bind', '/', '/'); + + // Allow /tmp and /var/tmp as writable + bwrapArgs.push('--tmpfs', '/tmp'); + bwrapArgs.push('--tmpfs', '/var/tmp'); + + // Make /dev available + bwrapArgs.push('--dev', '/dev'); + + // Make /proc available (needed for many tools) + bwrapArgs.push('--proc', '/proc'); + + // 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); + } + + // Network isolation (optional) + if (permissions.allowNetwork === false) { + bwrapArgs.push('--unshare-net'); + } + + // Set working directory + bwrapArgs.push('--chdir', projectRoot); + + // Add the actual command + bwrapArgs.push('--', command, ...args); + + return { + command: 'bwrap', + args: bwrapArgs, + sandboxed: true, + method: 'bwrap', + }; +} + +// ============================================================================= +// Utilities +// ============================================================================= + +/** + * Resolve a path pattern relative to project root. + * Handles both absolute paths and relative patterns. + */ +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); +} + +/** + * Clean up temporary sandbox profile file. + */ +export function cleanupSandboxProfile(result: SandboxResult): void { + if (result.profilePath) { + try { + fs.unlinkSync(result.profilePath); + } catch { + // Ignore cleanup errors + } + } +} diff --git a/packages/spawner/src/types.ts b/packages/spawner/src/types.ts index 9504dbf8d..029e02964 100644 --- a/packages/spawner/src/types.ts +++ b/packages/spawner/src/types.ts @@ -41,6 +41,71 @@ export type ShadowMode = z.infer; export const PolicySourceSchema = z.enum(['repo', 'local', 'workspace', 'default']); export type PolicySource = z.infer; +// ============================================================================= +// File Permission Types +// ============================================================================= + +/** + * File permission configuration for sandbox enforcement. + * These permissions are enforced at the OS level, wrapping any CLI. + */ +export const FilePermissionsSchema = z.object({ + /** + * Paths the agent can read and write (whitelist). + * If specified, only these paths are accessible. + * Supports glob patterns: "src/**", "*.ts" + */ + allowed: z.array(z.string()).optional(), + + /** + * Paths the agent cannot access at all (blacklist). + * Takes precedence over allowed paths. + * Common use: ".env*", "secrets/**", "*.pem" + */ + disallowed: z.array(z.string()).optional(), + + /** + * Paths the agent can read but not modify. + * Useful for config files: "package.json", "tsconfig.json" + */ + readOnly: z.array(z.string()).optional(), + + /** + * Paths the agent can write to. + * Used with restrictive defaults to explicitly allow writes. + */ + writable: z.array(z.string()).optional(), + + /** + * Whether to enable network access (default: true). + * Agents typically need network for API calls. + */ + allowNetwork: z.boolean().optional(), +}); +export type FilePermissions = z.infer; + +/** + * Predefined file permission profiles for common use cases. + */ +export const FilePermissionPreset = { + /** Block common sensitive files (.env, secrets, keys) */ + BLOCK_SECRETS: 'block-secrets', + /** Source code only - no config modifications */ + SOURCE_ONLY: 'source-only', + /** Read-only access - no writes allowed */ + READ_ONLY: 'read-only', + /** Documentation agent - docs and markdown only */ + DOCS_ONLY: 'docs-only', +} as const; + +export const FilePermissionPresetSchema = z.enum([ + 'block-secrets', + 'source-only', + 'read-only', + 'docs-only', +]); +export type FilePermissionPresetType = z.infer; + // ============================================================================= // Policy Types // ============================================================================= @@ -89,6 +154,16 @@ export const SpawnRequestSchema = z.object({ shadowSpeakOn: z.array(SpeakOnTriggerSchema).optional(), /** User ID for per-user credential storage in shared workspaces */ userId: z.string().optional(), + /** + * File permission guardrails (OS-level sandbox enforcement). + * Wraps the CLI command with platform-specific sandboxing. + */ + filePermissions: FilePermissionsSchema.optional(), + /** + * Use a predefined file permission preset instead of custom config. + * Presets: 'block-secrets', 'source-only', 'read-only', 'docs-only' + */ + filePermissionPreset: FilePermissionPresetSchema.optional(), }); export type SpawnRequest = z.infer; From 50dcf912218c07fe8b3a7afae31f90166afbd914 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 07:33:39 +0000 Subject: [PATCH 2/6] chore: add trajectory for file permission guardrails implementation https://claude.ai/code/session_016u5mnEQW63fxT2sAeeywbC --- .../completed/2026-02/traj_v5w0nh10we24.json | 77 +++++++++++++++++++ .../completed/2026-02/traj_v5w0nh10we24.md | 41 ++++++++++ .trajectories/index.json | 9 ++- 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 .trajectories/completed/2026-02/traj_v5w0nh10we24.json create mode 100644 .trajectories/completed/2026-02/traj_v5w0nh10we24.md diff --git a/.trajectories/completed/2026-02/traj_v5w0nh10we24.json b/.trajectories/completed/2026-02/traj_v5w0nh10we24.json new file mode 100644 index 000000000..1db2a7541 --- /dev/null +++ b/.trajectories/completed/2026-02/traj_v5w0nh10we24.json @@ -0,0 +1,77 @@ +{ + "id": "traj_v5w0nh10we24", + "version": 1, + "task": { + "title": "Add file permission guardrails as OS-level sandbox" + }, + "status": "completed", + "startedAt": "2026-02-01T07:32:12.261Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-02-01T07:32:27.146Z" + } + ], + "chapters": [ + { + "id": "chap_vjkd4nblwnxd", + "title": "Work", + "agentName": "default", + "startedAt": "2026-02-01T07:32:27.146Z", + "events": [ + { + "ts": 1769931147148, + "type": "decision", + "content": "Used sandbox-exec on macOS for kernel-level file enforcement: Used sandbox-exec on macOS for kernel-level file enforcement", + "raw": { + "question": "Used sandbox-exec on macOS for kernel-level file enforcement", + "chosen": "Used sandbox-exec on macOS for kernel-level file enforcement", + "alternatives": [], + "reasoning": "Built-in, kernel-enforced, works without additional deps" + }, + "significance": "high" + }, + { + "ts": 1769931153746, + "type": "decision", + "content": "Used bubblewrap on Linux for filesystem isolation: Used bubblewrap on Linux for filesystem isolation", + "raw": { + "question": "Used bubblewrap on Linux for filesystem isolation", + "chosen": "Used bubblewrap on Linux for filesystem isolation", + "alternatives": [], + "reasoning": "Widely available, mature, flexible read-only bind mounts" + }, + "significance": "high" + }, + { + "ts": 1769931160596, + "type": "decision", + "content": "CLI-agnostic wrapper approach over CLI-specific flags: CLI-agnostic wrapper approach over CLI-specific flags", + "raw": { + "question": "CLI-agnostic wrapper approach over CLI-specific flags", + "chosen": "CLI-agnostic wrapper approach over CLI-specific flags", + "alternatives": [], + "reasoning": "User wanted consistent behavior across all CLIs, not just Claude" + }, + "significance": "high" + } + ], + "endedAt": "2026-02-01T07:32:53.018Z" + } + ], + "commits": [], + "filesChanged": [], + "projectId": "/home/user/relay", + "tags": [], + "_trace": { + "startRef": "c07da61bf2eeb8bc56c5fac602a8f64150893b6b", + "endRef": "c07da61bf2eeb8bc56c5fac602a8f64150893b6b" + }, + "completedAt": "2026-02-01T07:32:53.018Z", + "retrospective": { + "summary": "Implemented cross-platform file permission guardrails using OS-level sandboxing (sandbox-exec on macOS, bwrap on Linux). Works with any CLI.", + "approach": "Standard approach", + "confidence": 0.85 + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-02/traj_v5w0nh10we24.md b/.trajectories/completed/2026-02/traj_v5w0nh10we24.md new file mode 100644 index 000000000..394cf18dd --- /dev/null +++ b/.trajectories/completed/2026-02/traj_v5w0nh10we24.md @@ -0,0 +1,41 @@ +# Trajectory: Add file permission guardrails as OS-level sandbox + +> **Status:** ✅ Completed +> **Confidence:** 85% +> **Started:** February 1, 2026 at 07:32 AM +> **Completed:** February 1, 2026 at 07:32 AM + +--- + +## Summary + +Implemented cross-platform file permission guardrails using OS-level sandboxing (sandbox-exec on macOS, bwrap on Linux). Works with any CLI. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Used sandbox-exec on macOS for kernel-level file enforcement +- **Chose:** Used sandbox-exec on macOS for kernel-level file enforcement +- **Reasoning:** Built-in, kernel-enforced, works without additional deps + +### Used bubblewrap on Linux for filesystem isolation +- **Chose:** Used bubblewrap on Linux for filesystem isolation +- **Reasoning:** Widely available, mature, flexible read-only bind mounts + +### CLI-agnostic wrapper approach over CLI-specific flags +- **Chose:** CLI-agnostic wrapper approach over CLI-specific flags +- **Reasoning:** User wanted consistent behavior across all CLIs, not just Claude + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Used sandbox-exec on macOS for kernel-level file enforcement: Used sandbox-exec on macOS for kernel-level file enforcement +- Used bubblewrap on Linux for filesystem isolation: Used bubblewrap on Linux for filesystem isolation +- CLI-agnostic wrapper approach over CLI-specific flags: CLI-agnostic wrapper approach over CLI-specific flags diff --git a/.trajectories/index.json b/.trajectories/index.json index 3f864d611..32d5599a2 100644 --- a/.trajectories/index.json +++ b/.trajectories/index.json @@ -1,6 +1,6 @@ { "version": 1, - "lastUpdated": "2026-01-30T12:21:19.597Z", + "lastUpdated": "2026-02-01T07:32:53.138Z", "trajectories": { "traj_1b1dj40sl6jl": { "title": "Revert aggressive retry logic in relay-pty-orchestrator", @@ -190,6 +190,13 @@ "startedAt": "2026-01-30T12:20:59.974Z", "completedAt": "2026-01-30T12:21:19.584Z", "path": "/Users/khaliqgant/Projects/agent-workforce/relay/.trajectories/completed/2026-01/traj_q6n7i3r1xik1.json" + }, + "traj_v5w0nh10we24": { + "title": "Add file permission guardrails as OS-level sandbox", + "status": "completed", + "startedAt": "2026-02-01T07:32:12.261Z", + "completedAt": "2026-02-01T07:32:53.018Z", + "path": "/home/user/relay/.trajectories/completed/2026-02/traj_v5w0nh10we24.json" } } } \ No newline at end of file From 2c803a5bc547b6b087e8deb0ee32ea0c31f5159d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 07:34:58 +0000 Subject: [PATCH 3/6] chore: add .agent-relay/ to gitignore Runtime state directory should not be tracked. https://claude.ai/code/session_016u5mnEQW63fxT2sAeeywbC --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8e827acd2..1bd697084 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ pnpm-debug.log* # Relay runtime artifacts *.sock *.pid +.agent-relay/ # Local test artifacts .agent-relay-test-*/ From d76eb67133782340742edba877ce71b80611d25c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 07:42:00 +0000 Subject: [PATCH 4/6] test: add comprehensive tests for file permission guardrails 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 --- packages/config/src/agent-config.test.ts | 173 +++++++++++++++++++++++ packages/spawner/src/sandbox.test.ts | 113 +++++++++++++++ 2 files changed, 286 insertions(+) diff --git a/packages/config/src/agent-config.test.ts b/packages/config/src/agent-config.test.ts index 0853fd490..582e0feb7 100644 --- a/packages/config/src/agent-config.test.ts +++ b/packages/config/src/agent-config.test.ts @@ -242,4 +242,177 @@ model: haiku expect(args).toContain('opus'); // Original preserved }); }); + + describe('file permissions in agent config', () => { + it('parses file-allowed from frontmatter', () => { + const agentsDir = path.join(tempDir, '.claude', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'worker.md'), `--- +name: worker +file-allowed: src/**, tests/** +--- +`); + + const result = findAgentConfig('worker', tempDir); + expect(result?.filePermissions?.allowed).toEqual(['src/**', 'tests/**']); + }); + + it('parses file-disallowed from frontmatter', () => { + const agentsDir = path.join(tempDir, '.claude', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'secure.md'), `--- +name: secure +file-disallowed: .env*, secrets/**, *.pem +--- +`); + + const result = findAgentConfig('secure', tempDir); + expect(result?.filePermissions?.disallowed).toEqual(['.env*', 'secrets/**', '*.pem']); + }); + + it('parses file-readonly from frontmatter', () => { + const agentsDir = path.join(tempDir, '.claude', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'reader.md'), `--- +name: reader +file-readonly: package.json, tsconfig.json +--- +`); + + const result = findAgentConfig('reader', tempDir); + expect(result?.filePermissions?.readOnly).toEqual(['package.json', 'tsconfig.json']); + }); + + it('parses file-writable from frontmatter', () => { + const agentsDir = path.join(tempDir, '.claude', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'writer.md'), `--- +name: writer +file-writable: dist/**, build/** +--- +`); + + const result = findAgentConfig('writer', tempDir); + expect(result?.filePermissions?.writable).toEqual(['dist/**', 'build/**']); + }); + + it('parses file-network from frontmatter', () => { + const agentsDir = path.join(tempDir, '.claude', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'offline.md'), `--- +name: offline +file-network: false +--- +`); + + const result = findAgentConfig('offline', tempDir); + expect(result?.filePermissions?.allowNetwork).toBe(false); + }); + + it('parses file-network: true from frontmatter', () => { + const agentsDir = path.join(tempDir, '.claude', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'online.md'), `--- +name: online +file-network: true +--- +`); + + const result = findAgentConfig('online', tempDir); + expect(result?.filePermissions?.allowNetwork).toBe(true); + }); + + it('parses file-preset from frontmatter', () => { + const agentsDir = path.join(tempDir, '.claude', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'preset.md'), `--- +name: preset +file-preset: block-secrets +--- +`); + + const result = findAgentConfig('preset', tempDir); + expect(result?.filePermissionPreset).toBe('block-secrets'); + }); + + it('parses all file presets', () => { + const agentsDir = path.join(tempDir, '.claude', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + + const presets = ['block-secrets', 'source-only', 'read-only', 'docs-only']; + for (const preset of presets) { + fs.writeFileSync(path.join(agentsDir, `${preset}-test.md`), `--- +name: ${preset}-test +file-preset: ${preset} +--- +`); + const result = findAgentConfig(`${preset}-test`, tempDir); + expect(result?.filePermissionPreset).toBe(preset); + } + }); + + it('ignores invalid file-preset values', () => { + const agentsDir = path.join(tempDir, '.claude', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'invalid.md'), `--- +name: invalid +file-preset: invalid-preset +--- +`); + + const result = findAgentConfig('invalid', tempDir); + expect(result?.filePermissionPreset).toBeUndefined(); + }); + + it('parses allowed-cwd from frontmatter', () => { + const agentsDir = path.join(tempDir, '.claude', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'cwd.md'), `--- +name: cwd +allowed-cwd: src, tests +--- +`); + + const result = findAgentConfig('cwd', tempDir); + expect(result?.allowedCwd).toEqual(['src', 'tests']); + }); + + it('parses multiple file permission fields together', () => { + const agentsDir = path.join(tempDir, '.claude', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'full.md'), `--- +name: full +file-allowed: src/** +file-disallowed: .env* +file-readonly: package.json +file-writable: dist/** +file-network: false +file-preset: block-secrets +allowed-cwd: src +--- +`); + + const result = findAgentConfig('full', tempDir); + expect(result?.filePermissions?.allowed).toEqual(['src/**']); + expect(result?.filePermissions?.disallowed).toEqual(['.env*']); + expect(result?.filePermissions?.readOnly).toEqual(['package.json']); + expect(result?.filePermissions?.writable).toEqual(['dist/**']); + expect(result?.filePermissions?.allowNetwork).toBe(false); + expect(result?.filePermissionPreset).toBe('block-secrets'); + expect(result?.allowedCwd).toEqual(['src']); + }); + + it('returns undefined filePermissions when no file fields present', () => { + const agentsDir = path.join(tempDir, '.claude', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'nofile.md'), `--- +name: nofile +model: haiku +--- +`); + + const result = findAgentConfig('nofile', tempDir); + expect(result?.filePermissions).toBeUndefined(); + }); + }); }); diff --git a/packages/spawner/src/sandbox.test.ts b/packages/spawner/src/sandbox.test.ts index 81192a820..70d240b6f 100644 --- a/packages/spawner/src/sandbox.test.ts +++ b/packages/spawner/src/sandbox.test.ts @@ -226,4 +226,117 @@ describe('sandbox', () => { expect(preset.readOnly).toContain('package.json'); }); }); + + describe('edge cases', () => { + const testCommand = 'claude'; + const testArgs = ['--help']; + + describe('empty permissions', () => { + it('handles empty permissions object', () => { + const emptyPerms: FilePermissions = {}; + const result = applySandbox(testCommand, testArgs, emptyPerms, testProjectRoot); + // Should still work - empty permissions means default behavior + expect(result).toBeDefined(); + expect(result.command).toBeDefined(); + }); + + it('handles permissions with empty arrays', () => { + const perms: FilePermissions = { + allowed: [], + disallowed: [], + readOnly: [], + writable: [], + }; + const result = applySandbox(testCommand, testArgs, perms, testProjectRoot); + expect(result).toBeDefined(); + }); + }); + + describe('network isolation', () => { + it('respects allowNetwork: false', () => { + const perms: FilePermissions = { + allowNetwork: false, + }; + const caps = detectCapabilities(); + + if (caps.methods.includes('bwrap')) { + const result = applySandbox(testCommand, testArgs, perms, testProjectRoot); + expect(result.args).toContain('--unshare-net'); + } + }); + + it('allows network by default', () => { + const perms: FilePermissions = {}; + const caps = detectCapabilities(); + + if (caps.methods.includes('bwrap')) { + const result = applySandbox(testCommand, testArgs, perms, testProjectRoot); + expect(result.args).not.toContain('--unshare-net'); + } + }); + }); + + describe('mergePermissions edge cases', () => { + it('preserves allowNetwork from explicit', () => { + const explicit: FilePermissions = { allowNetwork: false }; + const result = mergePermissions('block-secrets', explicit); + expect(result?.allowNetwork).toBe(false); + }); + + it('preserves allowNetwork from preset when explicit is undefined', () => { + const explicit: FilePermissions = { allowed: ['src/**'] }; + const result = mergePermissions('block-secrets', explicit); + // block-secrets doesn't set allowNetwork, so it should be undefined + expect(result?.allowNetwork).toBeUndefined(); + }); + + it('handles empty disallowed arrays in merge', () => { + const explicit: FilePermissions = { disallowed: [] }; + const result = mergePermissions('block-secrets', explicit); + // Should still have preset disallowed entries + expect(result?.disallowed).toContain('.env'); + }); + + it('handles empty readOnly arrays in merge', () => { + const explicit: FilePermissions = { readOnly: [] }; + const result = mergePermissions('source-only', explicit); + // Should still have preset readOnly entries + expect(result?.readOnly).toContain('package.json'); + }); + }); + + describe('path handling', () => { + it('handles absolute paths in permissions', () => { + const perms: FilePermissions = { + allowed: ['/absolute/path'], + }; + const result = applySandbox(testCommand, testArgs, perms, testProjectRoot); + expect(result).toBeDefined(); + }); + + it('handles relative paths in permissions', () => { + const perms: FilePermissions = { + allowed: ['relative/path'], + }; + const result = applySandbox(testCommand, testArgs, perms, testProjectRoot); + expect(result).toBeDefined(); + }); + + it('handles glob patterns in permissions', () => { + const perms: FilePermissions = { + allowed: ['**/*.ts', 'src/**/*'], + }; + const result = applySandbox(testCommand, testArgs, perms, testProjectRoot); + expect(result).toBeDefined(); + }); + }); + + describe('read-only preset', () => { + it('has empty writable array indicating no writes allowed', () => { + const preset = FILE_PERMISSION_PRESETS['read-only']; + expect(preset.writable).toBeDefined(); + expect(preset.writable).toHaveLength(0); + }); + }); + }); }); From 82ca5d9ce6f0fc1a54fa8ca5d5bd4625b0999187 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 08:13:02 +0000 Subject: [PATCH 5/6] chore: add file permission fields to AgentFrontmatterSchema 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 --- packages/config/src/schemas.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/config/src/schemas.ts b/packages/config/src/schemas.ts index 679f6418b..68e9ce18d 100644 --- a/packages/config/src/schemas.ts +++ b/packages/config/src/schemas.ts @@ -89,6 +89,14 @@ export const AgentFrontmatterSchema = z.object({ agentType: z.string().optional(), role: z.string().optional(), 'allowed-tools': z.string().optional(), + // File permission guardrails (OS-level sandbox enforcement) + 'file-allowed': z.string().optional(), + 'file-disallowed': z.string().optional(), + 'file-readonly': z.string().optional(), + 'file-writable': z.string().optional(), + 'file-network': z.string().optional(), + 'file-preset': z.string().optional(), + 'allowed-cwd': z.string().optional(), }); export const CLIAuthConfigSchema = z.object({ From 54d548143353ec6cc534c89c193bdda5b58a36e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 10:10:16 +0000 Subject: [PATCH 6/6] refactor: remove file permission parsing from agent frontmatter 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 --- packages/bridge/src/spawner.ts | 17 +-- packages/config/src/agent-config.test.ts | 173 ----------------------- packages/config/src/agent-config.ts | 98 ------------- packages/config/src/schemas.ts | 8 -- 4 files changed, 4 insertions(+), 292 deletions(-) diff --git a/packages/bridge/src/spawner.ts b/packages/bridge/src/spawner.ts index 571c5bf2e..1de3b5a0d 100644 --- a/packages/bridge/src/spawner.ts +++ b/packages/bridge/src/spawner.ts @@ -1118,27 +1118,18 @@ export class AgentSpawner { await this.release(workerName); }; - // Apply file permission sandbox if configured + // Apply file permission sandbox if configured in SpawnRequest // This wraps the CLI command with platform-specific sandboxing (sandbox-exec on macOS, bwrap on Linux) const filePermissions = mergePermissions( request.filePermissionPreset, request.filePermissions ); - // Also check agent config for file permissions - const agentConfigForPerms = findAgentConfig(name, this.projectRoot); - const mergedFilePermissions = agentConfigForPerms?.filePermissions - ? mergePermissions( - agentConfigForPerms.filePermissionPreset, - { ...agentConfigForPerms.filePermissions, ...filePermissions } - ) - : filePermissions; - let sandboxedCommand = command; let sandboxedArgs = args; - if (mergedFilePermissions && Object.keys(mergedFilePermissions).length > 0) { - const sandboxResult = applySandbox(command, args, mergedFilePermissions, agentCwd); + if (filePermissions && Object.keys(filePermissions).length > 0) { + const sandboxResult = applySandbox(command, args, filePermissions, agentCwd); sandboxedCommand = sandboxResult.command; sandboxedArgs = sandboxResult.args; // Store for cleanup on exit @@ -1147,7 +1138,7 @@ export class AgentSpawner { if (sandboxResult.sandboxed) { log.info(`Applied ${sandboxResult.method} sandbox for ${name}`); if (debug) { - log.debug(`Sandbox permissions: ${JSON.stringify(mergedFilePermissions)}`); + log.debug(`Sandbox permissions: ${JSON.stringify(filePermissions)}`); } } else if (debug) { log.debug(`No sandbox available for ${name} (platform: ${detectCapabilities().platform})`); diff --git a/packages/config/src/agent-config.test.ts b/packages/config/src/agent-config.test.ts index 582e0feb7..0853fd490 100644 --- a/packages/config/src/agent-config.test.ts +++ b/packages/config/src/agent-config.test.ts @@ -242,177 +242,4 @@ model: haiku expect(args).toContain('opus'); // Original preserved }); }); - - describe('file permissions in agent config', () => { - it('parses file-allowed from frontmatter', () => { - const agentsDir = path.join(tempDir, '.claude', 'agents'); - fs.mkdirSync(agentsDir, { recursive: true }); - fs.writeFileSync(path.join(agentsDir, 'worker.md'), `--- -name: worker -file-allowed: src/**, tests/** ---- -`); - - const result = findAgentConfig('worker', tempDir); - expect(result?.filePermissions?.allowed).toEqual(['src/**', 'tests/**']); - }); - - it('parses file-disallowed from frontmatter', () => { - const agentsDir = path.join(tempDir, '.claude', 'agents'); - fs.mkdirSync(agentsDir, { recursive: true }); - fs.writeFileSync(path.join(agentsDir, 'secure.md'), `--- -name: secure -file-disallowed: .env*, secrets/**, *.pem ---- -`); - - const result = findAgentConfig('secure', tempDir); - expect(result?.filePermissions?.disallowed).toEqual(['.env*', 'secrets/**', '*.pem']); - }); - - it('parses file-readonly from frontmatter', () => { - const agentsDir = path.join(tempDir, '.claude', 'agents'); - fs.mkdirSync(agentsDir, { recursive: true }); - fs.writeFileSync(path.join(agentsDir, 'reader.md'), `--- -name: reader -file-readonly: package.json, tsconfig.json ---- -`); - - const result = findAgentConfig('reader', tempDir); - expect(result?.filePermissions?.readOnly).toEqual(['package.json', 'tsconfig.json']); - }); - - it('parses file-writable from frontmatter', () => { - const agentsDir = path.join(tempDir, '.claude', 'agents'); - fs.mkdirSync(agentsDir, { recursive: true }); - fs.writeFileSync(path.join(agentsDir, 'writer.md'), `--- -name: writer -file-writable: dist/**, build/** ---- -`); - - const result = findAgentConfig('writer', tempDir); - expect(result?.filePermissions?.writable).toEqual(['dist/**', 'build/**']); - }); - - it('parses file-network from frontmatter', () => { - const agentsDir = path.join(tempDir, '.claude', 'agents'); - fs.mkdirSync(agentsDir, { recursive: true }); - fs.writeFileSync(path.join(agentsDir, 'offline.md'), `--- -name: offline -file-network: false ---- -`); - - const result = findAgentConfig('offline', tempDir); - expect(result?.filePermissions?.allowNetwork).toBe(false); - }); - - it('parses file-network: true from frontmatter', () => { - const agentsDir = path.join(tempDir, '.claude', 'agents'); - fs.mkdirSync(agentsDir, { recursive: true }); - fs.writeFileSync(path.join(agentsDir, 'online.md'), `--- -name: online -file-network: true ---- -`); - - const result = findAgentConfig('online', tempDir); - expect(result?.filePermissions?.allowNetwork).toBe(true); - }); - - it('parses file-preset from frontmatter', () => { - const agentsDir = path.join(tempDir, '.claude', 'agents'); - fs.mkdirSync(agentsDir, { recursive: true }); - fs.writeFileSync(path.join(agentsDir, 'preset.md'), `--- -name: preset -file-preset: block-secrets ---- -`); - - const result = findAgentConfig('preset', tempDir); - expect(result?.filePermissionPreset).toBe('block-secrets'); - }); - - it('parses all file presets', () => { - const agentsDir = path.join(tempDir, '.claude', 'agents'); - fs.mkdirSync(agentsDir, { recursive: true }); - - const presets = ['block-secrets', 'source-only', 'read-only', 'docs-only']; - for (const preset of presets) { - fs.writeFileSync(path.join(agentsDir, `${preset}-test.md`), `--- -name: ${preset}-test -file-preset: ${preset} ---- -`); - const result = findAgentConfig(`${preset}-test`, tempDir); - expect(result?.filePermissionPreset).toBe(preset); - } - }); - - it('ignores invalid file-preset values', () => { - const agentsDir = path.join(tempDir, '.claude', 'agents'); - fs.mkdirSync(agentsDir, { recursive: true }); - fs.writeFileSync(path.join(agentsDir, 'invalid.md'), `--- -name: invalid -file-preset: invalid-preset ---- -`); - - const result = findAgentConfig('invalid', tempDir); - expect(result?.filePermissionPreset).toBeUndefined(); - }); - - it('parses allowed-cwd from frontmatter', () => { - const agentsDir = path.join(tempDir, '.claude', 'agents'); - fs.mkdirSync(agentsDir, { recursive: true }); - fs.writeFileSync(path.join(agentsDir, 'cwd.md'), `--- -name: cwd -allowed-cwd: src, tests ---- -`); - - const result = findAgentConfig('cwd', tempDir); - expect(result?.allowedCwd).toEqual(['src', 'tests']); - }); - - it('parses multiple file permission fields together', () => { - const agentsDir = path.join(tempDir, '.claude', 'agents'); - fs.mkdirSync(agentsDir, { recursive: true }); - fs.writeFileSync(path.join(agentsDir, 'full.md'), `--- -name: full -file-allowed: src/** -file-disallowed: .env* -file-readonly: package.json -file-writable: dist/** -file-network: false -file-preset: block-secrets -allowed-cwd: src ---- -`); - - const result = findAgentConfig('full', tempDir); - expect(result?.filePermissions?.allowed).toEqual(['src/**']); - expect(result?.filePermissions?.disallowed).toEqual(['.env*']); - expect(result?.filePermissions?.readOnly).toEqual(['package.json']); - expect(result?.filePermissions?.writable).toEqual(['dist/**']); - expect(result?.filePermissions?.allowNetwork).toBe(false); - expect(result?.filePermissionPreset).toBe('block-secrets'); - expect(result?.allowedCwd).toEqual(['src']); - }); - - it('returns undefined filePermissions when no file fields present', () => { - const agentsDir = path.join(tempDir, '.claude', 'agents'); - fs.mkdirSync(agentsDir, { recursive: true }); - fs.writeFileSync(path.join(agentsDir, 'nofile.md'), `--- -name: nofile -model: haiku ---- -`); - - const result = findAgentConfig('nofile', tempDir); - expect(result?.filePermissions).toBeUndefined(); - }); - }); }); diff --git a/packages/config/src/agent-config.ts b/packages/config/src/agent-config.ts index bd53f49f8..9013410f1 100644 --- a/packages/config/src/agent-config.ts +++ b/packages/config/src/agent-config.ts @@ -8,27 +8,6 @@ import fs from 'node:fs'; import path from 'node:path'; -/** - * File permission configuration for OS-level sandbox enforcement. - */ -export interface FilePermissions { - /** Paths the agent can access (whitelist mode) */ - allowed?: string[]; - /** Paths the agent cannot access (blacklist) */ - disallowed?: string[]; - /** Paths the agent can only read */ - readOnly?: string[]; - /** Paths the agent can write to */ - writable?: string[]; - /** Whether to allow network access (default: true) */ - allowNetwork?: boolean; -} - -/** - * File permission preset names. - */ -export type FilePermissionPreset = 'block-secrets' | 'source-only' | 'read-only' | 'docs-only'; - export interface AgentConfig { /** Agent name (from filename or frontmatter) */ name: string; @@ -44,77 +23,6 @@ export interface AgentConfig { agentType?: string; /** Agent role for prompt composition (planner, worker, reviewer, lead, shadow) */ role?: string; - /** - * File permission guardrails (OS-level sandbox enforcement). - * Parsed from frontmatter fields: - * file-allowed: src/**,tests/** - * file-disallowed: .env*,secrets/** - * file-readonly: package.json - * file-writable: dist/** - * file-network: true/false - */ - filePermissions?: FilePermissions; - /** - * File permission preset to use. - * Options: block-secrets, source-only, read-only, docs-only - */ - filePermissionPreset?: FilePermissionPreset; - /** - * Allowed working directories for child agents spawned by this agent. - */ - allowedCwd?: string[]; -} - -/** - * Parse file permissions from frontmatter fields. - * - * Supported fields: - * file-allowed: src/**,tests/** - * file-disallowed: .env*,secrets/** - * file-readonly: package.json,tsconfig.json - * file-writable: dist/** - * file-network: true/false - */ -function parseFilePermissions(frontmatter: Record): FilePermissions { - const permissions: FilePermissions = {}; - - if (frontmatter['file-allowed']) { - permissions.allowed = frontmatter['file-allowed'].split(',').map(p => p.trim()); - } - - if (frontmatter['file-disallowed']) { - permissions.disallowed = frontmatter['file-disallowed'].split(',').map(p => p.trim()); - } - - if (frontmatter['file-readonly']) { - permissions.readOnly = frontmatter['file-readonly'].split(',').map(p => p.trim()); - } - - if (frontmatter['file-writable']) { - permissions.writable = frontmatter['file-writable'].split(',').map(p => p.trim()); - } - - if (frontmatter['file-network']) { - permissions.allowNetwork = frontmatter['file-network'].toLowerCase() === 'true'; - } - - return permissions; -} - -/** - * Parse and validate file permission preset from frontmatter. - */ -function parseFilePermissionPreset(value: string | undefined): FilePermissionPreset | undefined { - if (!value) return undefined; - - const validPresets: FilePermissionPreset[] = ['block-secrets', 'source-only', 'read-only', 'docs-only']; - const normalized = value.toLowerCase().trim(); - - if (validPresets.includes(normalized as FilePermissionPreset)) { - return normalized as FilePermissionPreset; - } - - return undefined; } /** @@ -190,9 +98,6 @@ export function findAgentConfig(agentName: string, projectRoot?: string): AgentC const content = fs.readFileSync(configPath, 'utf-8'); const frontmatter = parseFrontmatter(content); - // Parse file permissions from frontmatter - const filePermissions = parseFilePermissions(frontmatter); - return { name: frontmatter.name || baseName, configPath, @@ -201,9 +106,6 @@ export function findAgentConfig(agentName: string, projectRoot?: string): AgentC allowedTools: frontmatter['allowed-tools']?.split(',').map(t => t.trim()), agentType: frontmatter.agentType, role: frontmatter.role, - filePermissions: Object.keys(filePermissions).length > 0 ? filePermissions : undefined, - filePermissionPreset: parseFilePermissionPreset(frontmatter['file-preset']), - allowedCwd: frontmatter['allowed-cwd']?.split(',').map(p => p.trim()), }; } } diff --git a/packages/config/src/schemas.ts b/packages/config/src/schemas.ts index 68e9ce18d..679f6418b 100644 --- a/packages/config/src/schemas.ts +++ b/packages/config/src/schemas.ts @@ -89,14 +89,6 @@ export const AgentFrontmatterSchema = z.object({ agentType: z.string().optional(), role: z.string().optional(), 'allowed-tools': z.string().optional(), - // File permission guardrails (OS-level sandbox enforcement) - 'file-allowed': z.string().optional(), - 'file-disallowed': z.string().optional(), - 'file-readonly': z.string().optional(), - 'file-writable': z.string().optional(), - 'file-network': z.string().optional(), - 'file-preset': z.string().optional(), - 'allowed-cwd': z.string().optional(), }); export const CLIAuthConfigSchema = z.object({