Add per-project sandboxing via Apple's container

Opt-in, per-project execution sandbox: when enabled, a project's sessions run
`claude` inside an isolated Linux VM (Apple `container`) with the worktree
bind-mounted, instead of directly on the host. Off by default — existing
behavior is unchanged.

- Domain: ProjectSandbox (enabled/image/idleTimeout) on Project; ContainerSpec
  on RunSpec/ResumeSpec.
- Persistence: migration v7 adds project.sandbox_config (JSON).
- ContainerRuntime: thin `container` CLI wrapper (preflight, default-image build,
  run/exec/stop/delete/list, host-gateway discovery).
- ContainerManager: app-level per-session lifecycle — ensureRunning, idle
  auto-stop, teardown, orphan reconcile.
- ClaudeCodeBackend: wraps the claude invocation in `container exec` when a
  ContainerSpec is present; binds the approval MCP server on 0.0.0.0 and rewrites
  its URL to the VM gateway so the containerized child can reach it.
- Repo root + worktree base mounted at identical paths (git links + cwd-hash
  resolve); host ~/.claude mounted read-only and seeded into a writable
  claude-home so credentials are never mutated but native resume still works.
- UI: ProjectSettingsSheet (toggle/image/idle) + "Sandboxed" badge; AppStore
  gains updateProject.
- Tests: 9 new (arg construction, mount formatting, name parsing/derivation,
  sandbox JSON round-trip, MCP host rewrite). 112 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-06-12 18:26:37 -07:00
co-authored by Claude Opus 4.8
parent 8559698d2f
commit ff10d440a9
13 changed files with 825 additions and 26 deletions
@@ -108,6 +108,9 @@ public final class GRDBMetadataStore: SessionMetadataStore {
migrator.registerMigration("v6-todo-summary") { db in
try db.execute(sql: "ALTER TABLE todo ADD COLUMN summary TEXT;")
}
migrator.registerMigration("v7-sandbox") { db in
try db.execute(sql: "ALTER TABLE project ADD COLUMN sandbox_config TEXT;")
}
return migrator
}
@@ -235,6 +238,7 @@ private struct ProjectRow: Codable, FetchableRecord, PersistableRecord {
var worktree_base: String?
var setup_script: String?
var setup_policy: String
var sandbox_config: String?
var created_at: Date
init(_ p: Project) {
@@ -246,6 +250,9 @@ private struct ProjectRow: Codable, FetchableRecord, PersistableRecord {
worktree_base = p.worktreeBase
setup_script = p.setupScript
setup_policy = p.setupPolicy.rawValue
sandbox_config = p.sandbox.flatMap { sandbox in
(try? JSONEncoder().encode(sandbox)).map { String(decoding: $0, as: UTF8.self) }
}
created_at = p.createdAt
}
@@ -259,6 +266,9 @@ private struct ProjectRow: Codable, FetchableRecord, PersistableRecord {
worktreeBase: worktree_base,
setupScript: setup_script,
setupPolicy: SetupPolicy(rawValue: setup_policy) ?? .block,
sandbox: sandbox_config.flatMap { json in
try? JSONDecoder().decode(ProjectSandbox.self, from: Data(json.utf8))
},
createdAt: created_at)
}
}