docs/plans/sandbox-runtime.md
Integrate @anthropic-ai/sandbox-runtime into happy-cli to sandbox both Claude Code and Codex sessions with OS-level filesystem and network restrictions. The sandbox wraps agent subprocesses, enforcing configurable restrictions without requiring containers.
Key features:
happy sandbox configure - Interactive CLI wizard (using inquirer) to set up sandbox ruleshappy sandbox status - Show current sandbox configurationhappy sandbox disable - Turn off sandboxing--no-sandbox)~/.happy/settings.json alongside existing settingspackages/happy-cli/src/claude/claudeLocal.ts:241 - spawn('node', [claudeCliPath, ...args], {env, ...})packages/happy-cli/src/codex/codexMcpClient.ts:107 - StdioClientTransport({ command: 'codex', args: ['mcp-server'], env }) which internally calls cross-spawn('codex', ['mcp-server'])packages/happy-cli/src/persistence.ts - Settings interface + Zod schemas + atomic updateSettings()packages/happy-cli/src/index.ts - manual if/else if routing on args[0]packages/happy-cli/src/commands/connect.ts - exported handleXxxCommand(args) functions.test.ts files using vitest (e.g., claudeLocal.test.ts)import { SandboxManager, type SandboxRuntimeConfig } from '@anthropic-ai/sandbox-runtime'
const config: SandboxRuntimeConfig = {
network: {
allowedDomains: undefined,
deniedDomains: [],
},
filesystem: {
denyRead: ['~/.ssh', '~/.aws'],
allowWrite: ['.', '/tmp'],
denyWrite: ['.env'],
},
}
await SandboxManager.initialize(config)
const wrappedCmd = await SandboxManager.wrapWithSandbox('node script.js')
// wrappedCmd is a string with OS-level sandbox wrapping
spawn(wrappedCmd, { shell: true })
await SandboxManager.reset()
Claude is spawned directly via spawn('node', [claudeCliPath, ...args]) in claudeLocal.ts. We wrap the full command with SandboxManager.wrapWithSandbox() and spawn with shell: true.
When sandbox is enabled, automatically add --dangerously-skip-permissions to Claude's args. The sandbox provides OS-level enforcement, so Claude's built-in permission prompts become redundant friction.
Codex spawns via MCP SDK's StdioClientTransport, which calls cross-spawn('codex', ['mcp-server']) internally with shell: false. Since we can't modify the SDK's spawn call, we:
SandboxManager before creating the transportSandboxManager.wrapWithSandbox('codex mcp-server')command: 'sh', args: ['-c', wrappedCommand] to StdioClientTransport instead of command: 'codex', args: ['mcp-server']This way the MCP SDK spawns sh -c "<sandbox-wrapped codex mcp-server>", which achieves the same OS-level sandboxing.
When sandbox is enabled, force Codex to approval-policy: 'never' and sandbox: 'danger-full-access'. The OS-level sandbox already enforces restrictions, so Codex's own permission checks become redundant.
The sandbox provides a strict OS-level security boundary (filesystem + network). With these hard restrictions enforced at the OS level, the agents' built-in permission prompts are unnecessary - they can only operate within what the sandbox allows. This gives the user a seamless "full auto" experience while maintaining real security.
claudeLocal.ts and codexMcpClient.ts (mock SandboxManager)[x] immediately when done@anthropic-ai/sandbox-runtime and inquirer dependenciesyarn add @anthropic-ai/sandbox-runtime inquirer in packages/happy-cliyarn add -D @types/inquirer in packages/happy-cliSandboxConfigSchema to persistence.ts with the following shape:
const SandboxConfigSchema = z.object({
enabled: z.boolean().default(false),
workspaceRoot: z.string().optional(), // e.g. "~/projects"
sessionIsolation: z.enum(['strict', 'workspace', 'custom']).default('workspace'),
// 'strict' = only session cwd, 'workspace' = full workspaceRoot, 'custom' = user-defined paths
customWritePaths: z.array(z.string()).default([]), // extra paths for 'custom' mode
denyReadPaths: z.array(z.string()).default(['~/.ssh', '~/.aws', '~/.gnupg']),
extraWritePaths: z.array(z.string()).default(['/tmp']), // always allowed beyond workspace
denyWritePaths: z.array(z.string()).default(['.env']), // denied even within allowed dirs
networkMode: z.enum(['blocked', 'allowed', 'custom']).default('allowed'),
allowedDomains: z.array(z.string()).default([]), // for 'custom' network mode
deniedDomains: z.array(z.string()).default([]), // for 'custom' network mode
allowLocalBinding: z.boolean().default(true), // for dev servers
})
sandboxConfig?: z.infer<typeof SandboxConfigSchema> to the Settings interfacedefaultSettings (undefined by default)SandboxConfig type and the schema for external useSandboxConfigSchema validation (valid configs, invalid configs, defaults)packages/happy-cli/src/sandbox/config.tsbuildSandboxRuntimeConfig(sandboxConfig, sessionPath) function that converts our SandboxConfig into SandboxRuntimeConfig:
~ in all pathssessionIsolation:
'strict' → allowWrite: [sessionPath, ...extraWritePaths]'workspace' → allowWrite: [workspaceRoot || sessionPath, ...extraWritePaths]'custom' → allowWrite: [...customWritePaths, ...extraWritePaths]networkMode:
'blocked' → allowedDomains: [] (block all)'allowed' → allowedDomains: undefined (no network isolation)
enableWeakerNetworkIsolation: true to allow com.apple.trustd.agent on macOS, which Codex needs for stable TLS in seatbelt mode'custom' → use allowedDomains and deniedDomains from configdenyReadPaths → filesystem.denyReaddenyWritePaths → filesystem.denyWriteallowLocalBinding → network.allowLocalBindingbuildSandboxRuntimeConfig covering all isolation modes and network modespackages/happy-cli/src/sandbox/manager.tsinitializeSandbox(sandboxConfig, sessionPath):
buildSandboxRuntimeConfig()SandboxManager.initialize(runtimeConfig)SandboxManager.reset()wrapCommand(command):
SandboxManager.wrapWithSandbox(command)wrapForMcpTransport(command, args):
SandboxManager.wrapWithSandbox(command + ' ' + args.join(' ')){ command: 'sh', args: ['-c', wrappedCommand] } for use with StdioClientTransportSandboxManager)happy sandbox configure interactive wizardpackages/happy-cli/src/commands/sandbox.tshandleSandboxCommand(args: string[]) with subcommand dispatch (configure, status, disable, help)handleSandboxConfigure() using inquirer prompts:
input prompt - "Where is your workspace root? (e.g. ~/projects)" with default ~/projectslist prompt - "How should file access be scoped per session?"
strict - "Only the session directory (most restrictive)"workspace - "Full workspace root directory"custom - "Let me specify custom paths"custom): input prompt - "Enter writable paths (comma-separated):"checkbox prompt - "Which sensitive directories should be blocked from reading?" with defaults checked: ~/.ssh, ~/.aws, ~/.gnupg, plus option to add custominput prompt - "Additional writable directories beyond workspace (comma-separated):" with default /tmpinput prompt - "Files/dirs to deny writing even within allowed areas (comma-separated):" with default .envlist prompt - "How should network access be handled?"
allowed - "Allow all network access (default)"blocked - "Block all network access (most secure)"custom - "Allow specific domains only"custom): input prompt - "Enter allowed domains (comma-separated, supports wildcards like *.github.com):"confirm prompt - "Allow binding to localhost ports? (for dev servers)" with default trueupdateSettings() with sandboxConfig: { enabled: true, ...answers }--no-sandbox flaghandleSandboxCommand argument routing (unit test the dispatch logic)happy sandbox status and happy sandbox disablehandleSandboxStatus() - reads settings, prints formatted sandbox config or "not configured"handleSandboxDisable() - sets sandboxConfig.enabled = false via updateSettings()handleSandboxHelp() - prints usage informationclaudeLocal.ts to accept sandboxConfig?: SandboxConfig in optsspawn() call (around line 233), if sandboxConfig is present and enabled:
initializeSandbox(sandboxConfig, opts.path) to get cleanup function--dangerously-skip-permissions to args (sandbox enforces security at OS level, so Claude's permission prompts are redundant)wrapCommand('node ' + claudeCliPath + ' ' + args.join(' ')) to get wrapped commandspawn('node', [claudeCliPath, ...args]) with spawn(wrappedCommand, { shell: true, ... })shell: true changes stdio behavior - keep ['inherit', 'inherit', 'inherit', 'pipe'] but verify fd3 pipe still works through shellfinally block after process exitsclaudeLocal.test.ts to cover sandbox wrapping (mock SandboxManager)codexMcpClient.ts to accept sandboxConfig?: SandboxConfig in constructor or connect() methodconnect(), if sandboxConfig is present and enabled:
initializeSandbox(sandboxConfig, process.cwd()) to get cleanup functionwrapForMcpTransport('codex', [mcpCommand]) to get { command: 'sh', args: ['-c', wrappedCmd] }StdioClientTransport instead of command: 'codex', args: [mcpCommand]sandboxEnabled flag on CodexMcpClient so runCodex.ts can check itrunCodex.ts, when sandbox is enabled, force approval-policy: 'never' and sandbox: 'danger-full-access' in startSession() config (OS-level sandbox enforces security, so Codex's permission prompts are redundant)disconnect() to call SandboxManager.reset()SandboxManager and StdioClientTransport)claudeLocalLauncher.ts: accept and pass through sandboxConfig to claudeLocal()runClaude.ts / loop.ts: read sandboxConfig from settings, pass through to launcherrunCodex.ts: read sandboxConfig from settings, pass to CodexMcpClient constructorindex.ts: add --no-sandbox flag parsing (sets options.noSandbox = true)--no-sandbox to both Claude and Codex flowssandbox command in index.ts command dispatch (alongside auth, connect, etc.)--no-sandbox flag parsinghappy sandbox to help text and polishhappy sandbox to the help text in index.ts (alongside auth, connect, daemon, etc.)SandboxManager.initialize() fails, warn user and continue without sandboxhappy sandbox configure walks through all questions and saves config (automated command tests)happy sandbox status shows current config (automated command tests)happy sandbox disable turns off sandbox (automated command tests)--dangerously-skip-permissions auto-added when sandbox is active (claudeLocal sandbox tests)approval-policy: 'never' and sandbox: 'danger-full-access' when sandbox is active (execution policy tests)--no-sandbox bypasses sandbox for both agents (and does NOT auto-add permission bypass flags) (flag parsing + fallback tests)packages/happy-cli has no eslint.config.* / .eslintrc*, so ESLint 9 cannot run in this package.Settings (persistence.ts)
→ sandboxConfig?: SandboxConfig
→ buildSandboxRuntimeConfig(config, sessionPath)
→ SandboxRuntimeConfig (from @anthropic-ai/sandbox-runtime)
→ SandboxManager.initialize(runtimeConfig)
→ SandboxManager.wrapWithSandbox(command)
index.ts (parse --no-sandbox)
→ runClaude(credentials, options) // options.noSandbox
→ readSettings() → sandboxConfig
→ loop() → claudeLocalLauncher() → claudeLocal()
→ if sandbox enabled: initializeSandbox() + wrapCommand()
→ spawn(wrappedCommand, { shell: true })
→ finally: cleanup()
index.ts (parse --no-sandbox for codex subcommand too)
→ runCodex({credentials, startedBy, noSandbox})
→ readSettings() → sandboxConfig
→ CodexMcpClient(sandboxConfig)
→ connect():
→ if sandbox enabled: initializeSandbox() + wrapForMcpTransport()
→ StdioClientTransport({ command: 'sh', args: ['-c', wrappedCmd] })
→ disconnect(): cleanup()
{
"enabled": true,
"workspaceRoot": "~/projects",
"sessionIsolation": "workspace",
"denyReadPaths": ["~/.ssh", "~/.aws", "~/.gnupg"],
"extraWritePaths": ["/tmp"],
"denyWritePaths": [".env"],
"networkMode": "allowed",
"allowedDomains": [],
"deniedDomains": [],
"allowLocalBinding": true
}
Manual verification:
happy sandbox configure end-to-end on macOS~/.ssh)~/.ssh)--no-sandbox flag works correctly for both agentsshell: true spawn mode doesn't cause issues with argument quotingPlatform considerations:
sandbox-exec with Seatbelt profiles (fully supported)bubblewrap + socat (document prerequisites)