18 KiB
Add Anthropic Sandbox Runtime to CLI
Overview
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 (usinginquirer) to set up sandbox ruleshappy sandbox status- Show current sandbox configurationhappy sandbox disable- Turn off sandboxing- Automatic enforcement - Once configured, sandbox wraps both Claude and Codex sessions by default (bypass with
--no-sandbox) - Global config - Stored in
~/.happy/settings.jsonalongside existing settings - Dual agent support - Same sandbox config applies to both Claude Code and Codex
Context
- Claude spawn point:
packages/happy-cli/src/claude/claudeLocal.ts:241-spawn('node', [claudeCliPath, ...args], {env, ...}) - Codex spawn point:
packages/happy-cli/src/codex/codexMcpClient.ts:107-StdioClientTransport({ command: 'codex', args: ['mcp-server'], env })which internally callscross-spawn('codex', ['mcp-server']) - Config storage:
packages/happy-cli/src/persistence.ts- Settings interface + Zod schemas + atomicupdateSettings() - Command dispatch:
packages/happy-cli/src/index.ts- manualif/else ifrouting onargs[0] - Existing command pattern:
packages/happy-cli/src/commands/connect.ts- exportedhandleXxxCommand(args)functions - Test pattern: Co-located
.test.tsfiles using vitest (e.g.,claudeLocal.test.ts)
Sandbox Runtime API
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()
Agent Integration Architecture
Claude Code (direct spawn)
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 (MCP SDK spawn)
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:
- Initialize
SandboxManagerbefore creating the transport - Get the wrapped command via
SandboxManager.wrapWithSandbox('codex mcp-server') - Pass
command: 'sh',args: ['-c', wrappedCommand]toStdioClientTransportinstead ofcommand: '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.
Permission bypass rationale
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.
Development Approach
- Testing approach: Regular (code first, then tests)
- Complete each task fully before moving to the next
- Make small, focused changes
- CRITICAL: every task MUST include new/updated tests for code changes in that task
- CRITICAL: all tests must pass before starting next task
- CRITICAL: update this plan file when scope changes during implementation
- Run tests after each change
Testing Strategy
- Unit tests: Required for every task - Zod schema validation, config resolution, command argument parsing, sandbox config builder logic
- Integration tests: Sandbox wrapping in
claudeLocal.tsandcodexMcpClient.ts(mockSandboxManager)
Progress Tracking
- Mark completed items with
[x]immediately when done - Add newly discovered tasks with + prefix
- Document issues/blockers with warning prefix
- Update plan if implementation deviates from original scope
Implementation Steps
Task 1: Add @anthropic-ai/sandbox-runtime and inquirer dependencies
- Run
yarn add @anthropic-ai/sandbox-runtime inquirerinpackages/happy-cli - Run
yarn add -D @types/inquirerinpackages/happy-cli - Verify packages install and build succeeds
Task 2: Define sandbox config Zod schema and persistence
- Add
SandboxConfigSchematopersistence.tswith 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 }) - Add
sandboxConfig?: z.infer<typeof SandboxConfigSchema>to theSettingsinterface - Add sandbox field to
defaultSettings(undefined by default) - Export
SandboxConfigtype and the schema for external use - Write tests for
SandboxConfigSchemavalidation (valid configs, invalid configs, defaults) - Run tests - must pass before next task
Task 3: Create sandbox config builder utility
- Create
packages/happy-cli/src/sandbox/config.ts - Implement
buildSandboxRuntimeConfig(sandboxConfig, sessionPath)function that converts ourSandboxConfigintoSandboxRuntimeConfig:- Resolves
~in all paths - For
sessionIsolation:'strict'→allowWrite: [sessionPath, ...extraWritePaths]'workspace'→allowWrite: [workspaceRoot || sessionPath, ...extraWritePaths]'custom'→allowWrite: [...customWritePaths, ...extraWritePaths]
- For
networkMode:'blocked'→allowedDomains: [](block all)'allowed'→allowedDomains: undefined(no network isolation)- Also set
enableWeakerNetworkIsolation: trueto allowcom.apple.trustd.agenton macOS, which Codex needs for stable TLS in seatbelt mode
- Also set
'custom'→ useallowedDomainsanddeniedDomainsfrom config
- Maps
denyReadPaths→filesystem.denyRead - Maps
denyWritePaths→filesystem.denyWrite - Maps
allowLocalBinding→network.allowLocalBinding
- Resolves
- Write tests for
buildSandboxRuntimeConfigcovering all isolation modes and network modes - Write tests for path resolution (tilde expansion, relative paths)
- Run tests - must pass before next task
Task 4: Create sandbox lifecycle manager
- Create
packages/happy-cli/src/sandbox/manager.ts - Implement
initializeSandbox(sandboxConfig, sessionPath):- Builds runtime config via
buildSandboxRuntimeConfig() - Calls
SandboxManager.initialize(runtimeConfig) - Returns cleanup function that calls
SandboxManager.reset()
- Builds runtime config via
- Implement
wrapCommand(command):- Calls
SandboxManager.wrapWithSandbox(command) - Returns the wrapped command string
- Calls
- Implement
wrapForMcpTransport(command, args):- Calls
SandboxManager.wrapWithSandbox(command + ' ' + args.join(' ')) - Returns
{ command: 'sh', args: ['-c', wrappedCommand] }for use withStdioClientTransport
- Calls
- Write tests for lifecycle manager (mock
SandboxManager) - Run tests - must pass before next task
Task 5: Create happy sandbox configure interactive wizard
- Create
packages/happy-cli/src/commands/sandbox.ts - Implement
handleSandboxCommand(args: string[])with subcommand dispatch (configure,status,disable,help) - Implement
handleSandboxConfigure()usinginquirerprompts:- Workspace root:
inputprompt - "Where is your workspace root? (e.g. ~/projects)" with default~/projects - Session isolation:
listprompt - "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"
- (If
custom):inputprompt - "Enter writable paths (comma-separated):" - Deny read paths:
checkboxprompt - "Which sensitive directories should be blocked from reading?" with defaults checked:~/.ssh,~/.aws,~/.gnupg, plus option to add custom - Extra write paths:
inputprompt - "Additional writable directories beyond workspace (comma-separated):" with default/tmp - Deny write paths:
inputprompt - "Files/dirs to deny writing even within allowed areas (comma-separated):" with default.env - Network mode:
listprompt - "How should network access be handled?"allowed- "Allow all network access (default)"blocked- "Block all network access (most secure)"custom- "Allow specific domains only"
- (If
custom):inputprompt - "Enter allowed domains (comma-separated, supports wildcards like *.github.com):" - Allow localhost:
confirmprompt - "Allow binding to localhost ports? (for dev servers)" with defaulttrue - Show summary of configuration, ask for confirmation
- Workspace root:
- Save config via
updateSettings()withsandboxConfig: { enabled: true, ...answers } - Print success message with note about
--no-sandboxflag - Write tests for
handleSandboxCommandargument routing (unit test the dispatch logic) - Run tests - must pass before next task
Task 6: Implement happy sandbox status and happy sandbox disable
- Implement
handleSandboxStatus()- reads settings, prints formatted sandbox config or "not configured" - Implement
handleSandboxDisable()- setssandboxConfig.enabled = falseviaupdateSettings() - Implement
handleSandboxHelp()- prints usage information - Write tests for status output formatting and disable logic
- Run tests - must pass before next task
Task 7: Integrate sandbox into Claude subprocess spawn
- Modify
claudeLocal.tsto acceptsandboxConfig?: SandboxConfigin opts - Before the
spawn()call (around line 233), ifsandboxConfigis present andenabled:- Call
initializeSandbox(sandboxConfig, opts.path)to get cleanup function - Append
--dangerously-skip-permissionsto args (sandbox enforces security at OS level, so Claude's permission prompts are redundant) - Call
wrapCommand('node ' + claudeCliPath + ' ' + args.join(' '))to get wrapped command - Replace
spawn('node', [claudeCliPath, ...args])withspawn(wrappedCommand, { shell: true, ... }) - Note:
shell: truechanges stdio behavior - keep['inherit', 'inherit', 'inherit', 'pipe']but verify fd3 pipe still works through shell
- Call
- Add cleanup: call the cleanup function in the
finallyblock after process exits - Update existing
claudeLocal.test.tsto cover sandbox wrapping (mockSandboxManager) - Write tests for sandbox initialization, permission bypass, and cleanup lifecycle
- Run tests - must pass before next task
Task 8: Integrate sandbox into Codex subprocess spawn
- Modify
codexMcpClient.tsto acceptsandboxConfig?: SandboxConfigin constructor orconnect()method - In
connect(), ifsandboxConfigis present andenabled:- Call
initializeSandbox(sandboxConfig, process.cwd())to get cleanup function - Call
wrapForMcpTransport('codex', [mcpCommand])to get{ command: 'sh', args: ['-c', wrappedCmd] } - Use the wrapped command/args in
StdioClientTransportinstead ofcommand: 'codex', args: [mcpCommand]
- Call
- Add a
sandboxEnabledflag onCodexMcpClientsorunCodex.tscan check it - In
runCodex.ts, when sandbox is enabled, forceapproval-policy: 'never'andsandbox: 'danger-full-access'instartSession()config (OS-level sandbox enforces security, so Codex's permission prompts are redundant) - Add cleanup method or handle in
disconnect()to callSandboxManager.reset() - Write tests for Codex sandbox wrapping and permission bypass (mock
SandboxManagerandStdioClientTransport) - Run tests - must pass before next task
Task 9: Thread sandbox config through both launch chains
- Claude chain:
- In
claudeLocalLauncher.ts: accept and pass throughsandboxConfigtoclaudeLocal() - In
runClaude.ts/loop.ts: readsandboxConfigfrom settings, pass through to launcher
- In
- Codex chain:
- In
runCodex.ts: readsandboxConfigfrom settings, pass toCodexMcpClientconstructor
- In
- CLI flags:
- In
index.ts: add--no-sandboxflag parsing (setsoptions.noSandbox = true) - Apply
--no-sandboxto both Claude and Codex flows
- In
- Command registration:
- Register
sandboxcommand inindex.tscommand dispatch (alongsideauth,connect, etc.)
- Register
- Write tests for
--no-sandboxflag parsing - Run tests - must pass before next task
Task 10: Add happy sandbox to help text and polish
- Add
happy sandboxto the help text inindex.ts(alongsideauth,connect,daemon, etc.) - Add startup message when sandbox is active for both Claude and Codex (e.g., "Sandbox enabled: workspace=~/projects, network=allowed")
- Handle errors gracefully: if
SandboxManager.initialize()fails, warn user and continue without sandbox - Handle unsupported platforms (Windows): skip sandbox with warning
- Run tests - must pass before next task
Task 11: Verify acceptance criteria
- Verify
happy sandbox configurewalks through all questions and saves config (automated command tests) - Verify
happy sandbox statusshows current config (automated command tests) - Verify
happy sandbox disableturns off sandbox (automated command tests) - Verify Claude launches with sandbox wrapping when configured (claudeLocal sandbox tests)
- Verify Claude gets
--dangerously-skip-permissionsauto-added when sandbox is active (claudeLocal sandbox tests) - Verify Codex launches with sandbox wrapping when configured (codexMcpClient sandbox tests)
- Verify Codex gets
approval-policy: 'never'andsandbox: 'danger-full-access'when sandbox is active (execution policy tests) - Verify
--no-sandboxbypasses sandbox for both agents (and does NOT auto-add permission bypass flags) (flag parsing + fallback tests) - Verify unconfigured state doesn't affect either agent launch (existing + new non-sandbox tests)
- Verify network defaults to "allowed" (unrestricted) (schema default tests)
- Run full test suite (unit tests)
- Run linter - all issues must be fixed
- ⚠️ Lint blocker:
packages/happy-clihas noeslint.config.*/.eslintrc*, so ESLint 9 cannot run in this package.
Task 12: Update documentation
- Update README.md if it documents CLI commands
- Update help text to be comprehensive
Technical Details
Config resolution flow
Settings (persistence.ts)
→ sandboxConfig?: SandboxConfig
→ buildSandboxRuntimeConfig(config, sessionPath)
→ SandboxRuntimeConfig (from @anthropic-ai/sandbox-runtime)
→ SandboxManager.initialize(runtimeConfig)
→ SandboxManager.wrapWithSandbox(command)
Claude launch chain modification
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()
Codex launch chain modification
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()
Default sandbox preset (after configure)
{
"enabled": true,
"workspaceRoot": "~/projects",
"sessionIsolation": "workspace",
"denyReadPaths": ["~/.ssh", "~/.aws", "~/.gnupg"],
"extraWritePaths": ["/tmp"],
"denyWritePaths": [".env"],
"networkMode": "allowed",
"allowedDomains": [],
"deniedDomains": [],
"allowLocalBinding": true
}
Post-Completion
Manual verification:
- Test
happy sandbox configureend-to-end on macOS - Test that Claude Code sessions actually run inside sandbox (try reading
~/.ssh) - Test that Codex sessions actually run inside sandbox (try reading
~/.ssh) - Test that
--no-sandboxflag works correctly for both agents - Verify sandbox doesn't break Claude's PTY/stdin interaction
- Verify sandbox doesn't break Codex's MCP JSON-RPC over stdio
- Test with
shell: truespawn mode doesn't cause issues with argument quoting - Verify network is unrestricted by default (can reach any domain)
Platform considerations:
- macOS: Uses
sandbox-execwith Seatbelt profiles (fully supported) - Linux: Requires
bubblewrap+socat(document prerequisites) - Windows: Not supported by sandbox-runtime (skip gracefully)