Back to Happy

Add Anthropic Sandbox Runtime to CLI

docs/plans/sandbox-runtime.md

318.2 KB
Original Source

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 (using inquirer) to set up sandbox rules
  • happy sandbox status - Show current sandbox configuration
  • happy 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.json alongside 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 calls cross-spawn('codex', ['mcp-server'])
  • Config storage: packages/happy-cli/src/persistence.ts - Settings interface + Zod schemas + atomic updateSettings()
  • Command dispatch: packages/happy-cli/src/index.ts - manual if/else if routing on args[0]
  • Existing command pattern: packages/happy-cli/src/commands/connect.ts - exported handleXxxCommand(args) functions
  • Test pattern: Co-located .test.ts files using vitest (e.g., claudeLocal.test.ts)

Sandbox Runtime API

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

  1. Initialize SandboxManager before creating the transport
  2. Get the wrapped command via SandboxManager.wrapWithSandbox('codex mcp-server')
  3. Pass 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.

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.ts and codexMcpClient.ts (mock SandboxManager)

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 inquirer in packages/happy-cli
  • Run yarn add -D @types/inquirer in packages/happy-cli
  • Verify packages install and build succeeds

Task 2: Define sandbox config Zod schema and persistence

  • Add SandboxConfigSchema to persistence.ts with the following shape:
    typescript
    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 the Settings interface
  • Add sandbox field to defaultSettings (undefined by default)
  • Export SandboxConfig type and the schema for external use
  • Write tests for SandboxConfigSchema validation (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 our SandboxConfig into SandboxRuntimeConfig:
    • 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: true to allow com.apple.trustd.agent on macOS, which Codex needs for stable TLS in seatbelt mode
      • 'custom' → use allowedDomains and deniedDomains from config
    • Maps denyReadPathsfilesystem.denyRead
    • Maps denyWritePathsfilesystem.denyWrite
    • Maps allowLocalBindingnetwork.allowLocalBinding
  • Write tests for buildSandboxRuntimeConfig covering 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()
  • Implement wrapCommand(command):
    • Calls SandboxManager.wrapWithSandbox(command)
    • Returns the wrapped command string
  • Implement wrapForMcpTransport(command, args):
    • Calls SandboxManager.wrapWithSandbox(command + ' ' + args.join(' '))
    • Returns { command: 'sh', args: ['-c', wrappedCommand] } for use with StdioClientTransport
  • 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() using inquirer prompts:
    1. Workspace root: input prompt - "Where is your workspace root? (e.g. ~/projects)" with default ~/projects
    2. Session isolation: list 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"
    3. (If custom): input prompt - "Enter writable paths (comma-separated):"
    4. Deny read paths: checkbox prompt - "Which sensitive directories should be blocked from reading?" with defaults checked: ~/.ssh, ~/.aws, ~/.gnupg, plus option to add custom
    5. Extra write paths: input prompt - "Additional writable directories beyond workspace (comma-separated):" with default /tmp
    6. Deny write paths: input prompt - "Files/dirs to deny writing even within allowed areas (comma-separated):" with default .env
    7. Network mode: list 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"
    8. (If custom): input prompt - "Enter allowed domains (comma-separated, supports wildcards like *.github.com):"
    9. Allow localhost: confirm prompt - "Allow binding to localhost ports? (for dev servers)" with default true
    10. Show summary of configuration, ask for confirmation
  • Save config via updateSettings() with sandboxConfig: { enabled: true, ...answers }
  • Print success message with note about --no-sandbox flag
  • Write tests for handleSandboxCommand argument 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() - sets sandboxConfig.enabled = false via updateSettings()
  • 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.ts to accept sandboxConfig?: SandboxConfig in opts
  • Before the spawn() call (around line 233), if sandboxConfig is present and enabled:
    1. Call initializeSandbox(sandboxConfig, opts.path) to get cleanup function
    2. Append --dangerously-skip-permissions to args (sandbox enforces security at OS level, so Claude's permission prompts are redundant)
    3. Call wrapCommand('node ' + claudeCliPath + ' ' + args.join(' ')) to get wrapped command
    4. Replace spawn('node', [claudeCliPath, ...args]) with spawn(wrappedCommand, { shell: true, ... })
    5. Note: shell: true changes stdio behavior - keep ['inherit', 'inherit', 'inherit', 'pipe'] but verify fd3 pipe still works through shell
  • Add cleanup: call the cleanup function in the finally block after process exits
  • Update existing claudeLocal.test.ts to cover sandbox wrapping (mock SandboxManager)
  • 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.ts to accept sandboxConfig?: SandboxConfig in constructor or connect() method
  • In connect(), if sandboxConfig is present and enabled:
    1. Call initializeSandbox(sandboxConfig, process.cwd()) to get cleanup function
    2. Call wrapForMcpTransport('codex', [mcpCommand]) to get { command: 'sh', args: ['-c', wrappedCmd] }
    3. Use the wrapped command/args in StdioClientTransport instead of command: 'codex', args: [mcpCommand]
  • Add a sandboxEnabled flag on CodexMcpClient so runCodex.ts can check it
  • In runCodex.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)
  • Add cleanup method or handle in disconnect() to call SandboxManager.reset()
  • Write tests for Codex sandbox wrapping and permission bypass (mock SandboxManager and StdioClientTransport)
  • Run tests - must pass before next task

Task 9: Thread sandbox config through both launch chains

  • Claude chain:
    • In claudeLocalLauncher.ts: accept and pass through sandboxConfig to claudeLocal()
    • In runClaude.ts / loop.ts: read sandboxConfig from settings, pass through to launcher
  • Codex chain:
    • In runCodex.ts: read sandboxConfig from settings, pass to CodexMcpClient constructor
  • CLI flags:
    • In index.ts: add --no-sandbox flag parsing (sets options.noSandbox = true)
    • Apply --no-sandbox to both Claude and Codex flows
  • Command registration:
    • Register sandbox command in index.ts command dispatch (alongside auth, connect, etc.)
  • Write tests for --no-sandbox flag parsing
  • Run tests - must pass before next task

Task 10: Add happy sandbox to help text and polish

  • Add happy sandbox to the help text in index.ts (alongside auth, 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 configure walks through all questions and saves config (automated command tests)
  • Verify happy sandbox status shows current config (automated command tests)
  • Verify happy sandbox disable turns off sandbox (automated command tests)
  • Verify Claude launches with sandbox wrapping when configured (claudeLocal sandbox tests)
  • Verify Claude gets --dangerously-skip-permissions auto-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' and sandbox: 'danger-full-access' when sandbox is active (execution policy tests)
  • Verify --no-sandbox bypasses 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-cli has no eslint.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)

json
{
  "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 configure end-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-sandbox flag 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: true spawn mode doesn't cause issues with argument quoting
  • Verify network is unrestricted by default (can reach any domain)

Platform considerations:

  • macOS: Uses sandbox-exec with Seatbelt profiles (fully supported)
  • Linux: Requires bubblewrap + socat (document prerequisites)
  • Windows: Not supported by sandbox-runtime (skip gracefully)