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
+9 -2
View File
@@ -21,15 +21,22 @@ struct NucleicApp: App {
path: support.appendingPathComponent("nucleic.sqlite").path)
let worktrees = GitWorktreeManager()
let transcriptsDir = support.appendingPathComponent("sessions", isDirectory: true)
// Shared sandbox orchestrator: opt-in per project, dormant unless a session's
// project enables it. The same instance is handed to every backend so container
// lifecycle (idle stop, teardown, reconcile) stays consistent.
let containerManager = ContainerManager()
let store = AppStore(
database: database, worktrees: worktrees, transcriptsDir: transcriptsDir
database: database, worktrees: worktrees, transcriptsDir: transcriptsDir,
containerManager: containerManager
) { _ in
// Each chat turn is a single-shot run (stdin closed), with follow-ups
// resumed via --resume. This is the only reliable way to use the
// approval server: Claude's streaming-input mode hangs alongside
// --permission-prompt-tool. Approval server is authoritative
// (--permission-mode default); child is hermetic (--strict-mcp-config).
ClaudeCodeBackend(configuration: .init(closeStdinAfterPrompt: true))
ClaudeCodeBackend(
configuration: .init(closeStdinAfterPrompt: true),
containerManager: containerManager)
}
store.intelligence = AppleIntelligenceProvider()
store.defaultModel = ModelCatalog.storedDefaultModel
+15
View File
@@ -13,6 +13,7 @@ struct ProjectView: View {
/// Full session records (with cached summary blurbs), most-recent first.
@State private var sessions: [Session] = []
@State private var showingSettings = false
private var active: [Session] { sessions.filter { !$0.archived } }
/// Chats blocked on the user what to deal with first.
@@ -42,16 +43,25 @@ struct ProjectView: View {
.scrollContentBackground(.hidden)
.task(id: project.id) { sessions = await store.sessions(for: project.id) }
.task { await store.loadTodos() }
.sheet(isPresented: $showingSettings) { ProjectSettingsSheet(project: project) }
}
// MARK: - Header
private var sandboxEnabled: Bool { project.sandbox?.enabled == true }
private var header: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline) {
Text(project.name)
.font(.system(size: 34, weight: .bold))
Spacer()
Button {
showingSettings = true
} label: {
Label("Settings", systemImage: "gearshape")
}
.buttonStyle(.bordered)
Button {
Task { await store.newSession(in: project) }
} label: {
@@ -67,6 +77,11 @@ struct ProjectView: View {
Text("·")
Image(systemName: "arrow.triangle.branch")
Text(project.defaultBranch.value)
if sandboxEnabled {
Text("·")
Label("Sandboxed", systemImage: "shield.lefthalf.filled")
.foregroundStyle(.tint)
}
}
.font(.caption)
.foregroundStyle(.secondary)
+78
View File
@@ -49,3 +49,81 @@ struct AddProjectSheet: View {
dismiss()
}
}
/// Per-project settings currently the execution sandbox. When enabled, the project's
/// sessions run `claude` inside an isolated Apple `container` (a Linux VM) with the
/// worktree bind-mounted, instead of directly on the host.
struct ProjectSettingsSheet: View {
@Environment(AppStore.self) private var store
@Environment(\.dismiss) private var dismiss
let project: Project
@State private var sandboxEnabled: Bool
@State private var image: String
@State private var idleMinutes: Int
init(project: Project) {
self.project = project
let sandbox = project.sandbox ?? ProjectSandbox()
_sandboxEnabled = State(initialValue: sandbox.enabled)
_image = State(initialValue: sandbox.image ?? "")
_idleMinutes = State(initialValue: max(1, sandbox.idleTimeoutSeconds / 60))
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("\(project.name) Settings").font(.title2).bold()
VStack(alignment: .leading, spacing: 10) {
Toggle(isOn: $sandboxEnabled) {
VStack(alignment: .leading, spacing: 2) {
Text("Run sessions in a sandbox container")
Text("Executes Claude inside an isolated Linux VM (Apple `container`) "
+ "with this repo's worktree mounted. Requires Apple Silicon + the "
+ "`container` tool installed.")
.font(.caption).foregroundStyle(.secondary)
}
}
if sandboxEnabled {
Divider()
HStack {
Text("Image")
TextField(ProjectSandbox.defaultImage, text: $image)
.textFieldStyle(.roundedBorder)
}
Text("Leave blank to use the bundled default image (built on first use).")
.font(.caption).foregroundStyle(.secondary)
Stepper(
"Stop container after \(idleMinutes) min idle",
value: $idleMinutes, in: 1...240)
}
}
.padding(14)
.background(AppTheme.surface, in: .rect(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1))
HStack {
Spacer()
Button("Cancel") { dismiss() }
Button("Save") { Task { await save() } }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
}
}
.padding(20)
.frame(width: 480)
}
private func save() async {
var updated = project
let trimmed = image.trimmingCharacters(in: .whitespacesAndNewlines)
updated.sandbox = ProjectSandbox(
enabled: sandboxEnabled,
image: trimmed.isEmpty ? nil : trimmed,
idleTimeoutSeconds: idleMinutes * 60)
await store.updateProject(updated)
dismiss()
}
}
+20
View File
@@ -144,6 +144,9 @@ public final class AppStore {
private let backendFactory: BackendFactory
private let transcriptsDir: URL
private let now: @Sendable () -> Date
/// Shared sandbox orchestrator (nil in tests / non-sandbox builds). Same instance the
/// backend factory hands to each `ClaudeCodeBackend`, so lifecycle stays consistent.
private let containerManager: ContainerManager?
private var controllers: [SessionID: SessionController] = [:]
private var projectsByID: [ProjectID: Project] = [:]
@@ -153,12 +156,14 @@ public final class AppStore {
database: any SessionMetadataStore,
worktrees: any WorktreeManaging,
transcriptsDir: URL,
containerManager: ContainerManager? = nil,
now: @escaping @Sendable () -> Date = { Date() },
backendFactory: @escaping BackendFactory
) {
self.database = database
self.worktrees = worktrees
self.transcriptsDir = transcriptsDir
self.containerManager = containerManager
self.now = now
self.backendFactory = backendFactory
}
@@ -189,6 +194,8 @@ public final class AppStore {
upsertSummary(SessionSummary(session, pendingApprovalCount: 0))
}
}
// Clean up any sandbox containers orphaned by a previous run/crash.
await containerManager?.reconcile(activeSessions: Array(controllers.keys))
}
private func reconstructController(for session: Session, in project: Project) -> SessionController? {
@@ -229,6 +236,17 @@ public final class AppStore {
}
}
/// Persist edits to a project's configuration (e.g. sandbox settings) and refresh the
/// in-memory list. Sandbox changes take effect on the next turn started in the project.
public func updateProject(_ project: Project) async {
do {
try await database.saveProject(project)
await loadProjects()
} catch {
lastError = "Update project failed: \(error)"
}
}
public func renameProject(_ id: ProjectID, to name: String) async {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, var project = projectsByID[id] else { return }
@@ -254,6 +272,7 @@ public final class AppStore {
observers[session.id] = nil
controllers[session.id] = nil
}
await containerManager?.teardown(session.id)
if let path = session.worktreePath, let branch = session.branch, let baseSHA = session.baseSHA {
let worktree = Worktree(
sessionID: session.id, path: path, branch: branch,
@@ -725,6 +744,7 @@ public final class AppStore {
do { try await controller.discard(force: true) }
catch { lastError = "Delete failed: \(error)" }
}
await containerManager?.teardown(id)
observers[id]?.cancel()
observers[id] = nil
controllers[id] = nil
+65 -1
View File
@@ -86,6 +86,63 @@ public enum SandboxMode: String, Sendable, Codable {
case fullAccess = "danger-full-access"
}
/// Request to run a session's `claude` process inside an Apple `container` (an isolated
/// Linux VM) instead of on the host. Built by `SessionController` from `Project.sandbox`
/// and carried on `RunSpec`/`ResumeSpec`; consumed by `ClaudeCodeBackend` (which wraps the
/// invocation in `container exec`) and `ContainerManager` (lifecycle). `nil` host-spawned.
public struct ContainerSpec: Sendable, Equatable {
/// One bind mount: a host path made visible inside the container.
public struct Mount: Sendable, Equatable {
public let host: String
public let container: String
public let readOnly: Bool
public init(host: String, container: String, readOnly: Bool) {
self.host = host
self.container = container
self.readOnly = readOnly
}
}
/// Stable container name for this session (e.g. `nucleic-<sessionID.short>`).
public let name: String
/// Image reference to run (already resolved to the bundled default if no override).
public let image: String
/// Bind mounts. Repo root + worktree base are mounted at their identical host paths so
/// git worktree links and the cwd-hash resolve; `~/.claude` is mounted read-only at a
/// staging path and seeded into a writable claude-home.
public let mounts: [Mount]
/// Working directory inside the container (the session's worktree path).
public let workdir: String
/// Environment passed into the `claude` process (e.g. ANTHROPIC_API_KEY).
public let env: [String: String]
/// Stop the container after this much inactivity.
public let idleTimeout: TimeInterval
/// Read-only staging path where the host `~/.claude` is mounted inside the container.
public let claudeHomeStaging: String
/// Writable path seeded from the staging copy and used as the container's `~/.claude`.
public let claudeHomeWritable: String
public init(
name: String,
image: String,
mounts: [Mount],
workdir: String,
env: [String: String],
idleTimeout: TimeInterval,
claudeHomeStaging: String,
claudeHomeWritable: String
) {
self.name = name
self.image = image
self.mounts = mounts
self.workdir = workdir
self.env = env
self.idleTimeout = idleTimeout
self.claudeHomeStaging = claudeHomeStaging
self.claudeHomeWritable = claudeHomeWritable
}
}
// MARK: - Run / resume / input specs (BACKEND_PROTOCOL §2.2)
public enum ApprovalPolicy: Sendable {
@@ -119,6 +176,8 @@ public struct RunSpec: Sendable {
public let autoApprove: Bool
public let approvalPolicy: ApprovalPolicy
public let sandbox: SandboxMode?
/// When set, run `claude` inside this Apple `container` instead of on the host.
public let container: ContainerSpec?
public let mcpConfigPath: URL?
public let appendSystemPrompt: String?
public let extraEnv: [String: String]
@@ -133,6 +192,7 @@ public struct RunSpec: Sendable {
autoApprove: Bool = false,
approvalPolicy: ApprovalPolicy = .interactive,
sandbox: SandboxMode? = nil,
container: ContainerSpec? = nil,
mcpConfigPath: URL? = nil,
appendSystemPrompt: String? = nil,
extraEnv: [String: String] = [:],
@@ -146,6 +206,7 @@ public struct RunSpec: Sendable {
self.autoApprove = autoApprove
self.approvalPolicy = approvalPolicy
self.sandbox = sandbox
self.container = container
self.mcpConfigPath = mcpConfigPath
self.appendSystemPrompt = appendSystemPrompt
self.extraEnv = extraEnv
@@ -163,11 +224,13 @@ public struct ResumeSpec: Sendable {
public let effort: String?
public let autoApprove: Bool
public let fork: Bool
/// When set, resume `claude` inside this Apple `container` instead of on the host.
public let container: ContainerSpec?
public init(
sessionID: SessionID, backendSessionID: String, worktree: WorktreePath,
prompt: AgentInput? = nil, model: String? = nil, effort: String? = nil,
autoApprove: Bool = false, fork: Bool = false
autoApprove: Bool = false, fork: Bool = false, container: ContainerSpec? = nil
) {
self.sessionID = sessionID
self.backendSessionID = backendSessionID
@@ -177,6 +240,7 @@ public struct ResumeSpec: Sendable {
self.effort = effort
self.autoApprove = autoApprove
self.fork = fork
self.container = container
}
}
@@ -69,8 +69,13 @@ public actor ClaudeCodeBackend: AgentBackend {
private let processHost: ProcessHost
private let approvalServer: MCPApprovalServer
public let approvals: ApprovalCoordinator
/// Optional sandbox orchestrator. `nil` always spawn on the host (default). When a
/// `RunSpec.container` is present and this is set, the run executes inside the container.
private let containerManager: ContainerManager?
private var handle: (any ProcessHandle)?
/// Name of the container the current run executes in (for the `finished` callback).
private var activeContainerName: String?
private var continuation: AsyncThrowingStream<AgentEvent, Error>.Continuation?
private var sessionID: SessionID?
private var serverToken: String?
@@ -84,12 +89,14 @@ public actor ClaudeCodeBackend: AgentBackend {
configuration: Configuration = Configuration(),
processHost: ProcessHost = ProcessHost(),
approvalServer: MCPApprovalServer = MCPApprovalServer(),
approvals: ApprovalCoordinator = ApprovalCoordinator()
approvals: ApprovalCoordinator = ApprovalCoordinator(),
containerManager: ContainerManager? = nil
) {
self.configuration = configuration
self.processHost = processHost
self.approvalServer = approvalServer
self.approvals = approvals
self.containerManager = containerManager
}
// MARK: - AgentBackend
@@ -107,7 +114,8 @@ public actor ClaudeCodeBackend: AgentBackend {
prompt: resume.prompt ?? AgentInput(parts: []),
model: resume.model,
effort: resume.effort,
autoApprove: resume.autoApprove)
autoApprove: resume.autoApprove,
container: resume.container)
return makeStream(run: run, resumeArgs: args)
}
@@ -165,15 +173,27 @@ public actor ClaudeCodeBackend: AgentBackend {
self.interruptRequested = false
do {
// 1. Approval bridge: per-session bearer token handler.
// 0. Sandbox: if this run is containerized, bring the per-session container up
// first so we know the host-gateway address the child must use to reach us.
let sandbox = (run.container != nil) ? containerManager : nil
var mcpHost = "127.0.0.1"
if let sandbox, let cspec = run.container {
let (name, gateway) = try await sandbox.ensureRunning(cspec)
activeContainerName = name
mcpHost = gateway
}
// 1. Approval bridge: per-session bearer token handler. For a containerized
// child we bind on all interfaces (gated by the bearer token) so it can reach
// us over the VM gateway; otherwise loopback as before.
let token = UUID().uuidString
serverToken = token
let port = try await approvalServer.start()
let port = try await approvalServer.start(host: sandbox != nil ? "0.0.0.0" : "127.0.0.1")
await approvalServer.register(token: token) { [weak self] call in
guard let self else { return .deny(message: "Session terminated") }
return await self.handleApprovalCall(call)
}
let mcpConfig = approvalServer.mcpConfigJSON(port: port, token: token)
let mcpConfig = approvalServer.mcpConfigJSON(host: mcpHost, port: port, token: token)
// 2. Spawn.
var args = [
@@ -214,12 +234,21 @@ public actor ClaudeCodeBackend: AgentBackend {
args += resumeArgs
args += run.extraArgs
let spec = ProcessSpec(
executable: configuration.executable,
args: args,
cwd: run.worktree,
env: run.extraEnv,
stdinMode: .pipe)
// Spawn on the host, or wrap the same `claude` invocation in `container exec`.
let spec: ProcessSpec
if let sandbox, let cspec = run.container, let name = activeContainerName {
let env = cspec.env.merging(run.extraEnv) { _, new in new }
spec = sandbox.execProcessSpec(
name: name, workdir: cspec.workdir, env: env,
argv: [configuration.executable] + args)
} else {
spec = ProcessSpec(
executable: configuration.executable,
args: args,
cwd: run.worktree,
env: run.extraEnv,
stdinMode: .pipe)
}
let handle = try await processHost.launch(spec)
self.handle = handle
@@ -290,6 +319,11 @@ public actor ClaudeCodeBackend: AgentBackend {
if let serverToken {
await approvalServer.unregister(token: serverToken)
}
// Release the sandbox: the turn is done, so the container's idle timer can arm.
if let name = activeContainerName {
await containerManager?.finished(name: name)
activeContainerName = nil
}
self.handle = nil
}
@@ -49,12 +49,15 @@ public actor MCPApprovalServer {
public init() {}
/// Returns the bound ephemeral port.
/// Returns the bound ephemeral port. `host` is the local interface to bind to;
/// default loopback for host runs, `0.0.0.0` for sandbox runs so the containerized
/// `claude` can reach the approval server over the VM gateway (bearer-token gated).
@discardableResult
public func start() async throws -> UInt16 {
public func start(host: String = "127.0.0.1") async throws -> UInt16 {
if let _ = listener { return port }
let parameters = NWParameters.tcp
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: .any)
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(
host: NWEndpoint.Host(host), port: .any)
let listener = try NWListener(using: parameters)
self.listener = listener
@@ -100,13 +103,17 @@ public actor MCPApprovalServer {
handlers.removeValue(forKey: token)
}
/// The MCP server entry for `--mcp-config` (inline JSON).
public nonisolated func mcpConfigJSON(port: UInt16, token: String) -> String {
/// The MCP server entry for `--mcp-config` (inline JSON). `host` is the address the
/// child reaches the server at loopback for host runs, the VM gateway IP for a
/// containerized child (which can't see the host's `127.0.0.1`).
public nonisolated func mcpConfigJSON(
host: String = "127.0.0.1", port: UInt16, token: String
) -> String {
JSONValue.object([
"mcpServers": .object([
"nucleic": .object([
"type": .string("http"),
"url": .string("http://127.0.0.1:\(port)/mcp"),
"url": .string("http://\(host):\(port)/mcp"),
"headers": .object(["Authorization": .string("Bearer \(token)")]),
])
])
@@ -0,0 +1,109 @@
import Foundation
/// App-level coordinator for per-session sandbox containers (injected like
/// `GitWorktreeManager`). Owns the lifecycle the `ContainerRuntime` is too low-level to
/// track: keeping one long-lived container per session, an idle timer that stops it after
/// inactivity, teardown on session end, and orphan reconciliation on launch.
///
/// Activity is reference-counted: `ensureRunning` (called at the start of every turn)
/// marks the container busy and disarms the idle timer; `finished` marks the turn done and
/// re-arms it. The container is only stopped when no turn is in flight, so a single
/// long-running turn is never killed mid-flight.
public actor ContainerManager {
private let runtime: ContainerRuntime
/// Live per-container bookkeeping, keyed by container name.
private var active: [String: Int] = [:] // in-flight turn count
private var idleTimeouts: [String: TimeInterval] = [:]
private var idleTimers: [String: Task<Void, Never>] = [:]
public init(runtime: ContainerRuntime = ContainerRuntime()) {
self.runtime = runtime
}
/// Stable container name for a session. Lowercased because `UUID().uuidString` (hence
/// `SessionID.short`) is uppercase, while container names are conventionally lowercase
/// and our reconcile parser matches lowercase hex.
public nonisolated static func containerName(for session: SessionID) -> String {
"nucleic-\(session.short.lowercased())"
}
/// Ensure the session's container is up and return its name plus the host-gateway IP
/// (the container's view of the Mac, for routing the approval MCP server). Marks a turn
/// as in-flight; pair with `finished(name:)`.
public func ensureRunning(_ spec: ContainerSpec) async throws
-> (name: String, hostGateway: String)
{
cancelIdleTimer(spec.name)
active[spec.name, default: 0] += 1
idleTimeouts[spec.name] = spec.idleTimeout
do {
try await runtime.ensureRunning(spec)
let gateway = try await runtime.hostGateway(name: spec.name)
return (spec.name, gateway)
} catch {
// Roll back the activity bump so a failed start doesn't pin the container busy.
finishBookkeeping(spec.name)
throw error
}
}
/// Build the `ProcessSpec` that runs `argv` inside the named container (forwards to the
/// runtime; pure, no lifecycle effect).
public nonisolated func execProcessSpec(
name: String, workdir: String, env: [String: String], argv: [String]
) -> ProcessSpec {
runtime.execProcessSpec(name: name, workdir: workdir, env: env, argv: argv)
}
/// Mark a turn finished. When the last in-flight turn ends, arm the idle timer.
public func finished(name: String) {
finishBookkeeping(name)
}
private func finishBookkeeping(_ name: String) {
let remaining = max(0, (active[name] ?? 0) - 1)
active[name] = remaining
if remaining == 0 { armIdleTimer(name) }
}
/// Stop and remove the session's container (on shutdown/discard).
public func teardown(_ session: SessionID) async {
let name = Self.containerName(for: session)
cancelIdleTimer(name)
active[name] = nil
idleTimeouts[name] = nil
await runtime.remove(name: name)
}
/// On app launch, remove `nucleic-*` containers that don't correspond to a live session.
public func reconcile(activeSessions: [SessionID]) async {
let keep = Set(activeSessions.map { Self.containerName(for: $0) })
for name in await runtime.list() where !keep.contains(name) {
await runtime.remove(name: name)
}
}
// MARK: - Idle timer
private func armIdleTimer(_ name: String) {
cancelIdleTimer(name)
let timeout = idleTimeouts[name] ?? TimeInterval(ProjectSandbox.defaultIdleTimeoutSeconds)
idleTimers[name] = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
guard !Task.isCancelled else { return }
await self?.stopIfIdle(name)
}
}
private func cancelIdleTimer(_ name: String) {
idleTimers[name]?.cancel()
idleTimers[name] = nil
}
private func stopIfIdle(_ name: String) async {
idleTimers[name] = nil
guard (active[name] ?? 0) == 0 else { return } // a turn snuck in; leave it running
await runtime.stop(name: name)
}
}
@@ -0,0 +1,257 @@
import Foundation
/// Errors surfaced from the `container` CLI wrapper.
public enum ContainerError: Error, Sendable, CustomStringConvertible {
/// The `container` tool isn't installed / not on PATH.
case notInstalled
/// A `container` subcommand exited non-zero. Carries the joined stderr tail.
case commandFailed(command: String, status: Int32, stderr: String)
/// Couldn't determine the host gateway address from inside the container.
case gatewayUnavailable
public var description: String {
switch self {
case .notInstalled:
return "Apple's `container` tool isn't installed or isn't on PATH. "
+ "Install it from https://github.com/apple/container and run `container system start`."
case let .commandFailed(command, status, stderr):
return "`container \(command)` failed (status \(status)): \(stderr)"
case .gatewayUnavailable:
return "Couldn't determine the host gateway address from inside the container."
}
}
}
/// Thin async wrapper over Apple's `container` CLI. Stateless except for a cached
/// gateway-IP lookup per container name. Each method shells out via `ProcessHost`
/// (so executable resolution and stdio handling match the rest of the app) and
/// surfaces failures as `ContainerError`. Lifecycle orchestration (idle timers,
/// per-session bookkeeping) lives one layer up in `ContainerManager`.
public actor ContainerRuntime {
/// The `container` executable name (resolved via PATH) or an absolute path.
private let executable: String
private let processHost: ProcessHost
/// Cached host-gateway IP per running container (the container's view of the Mac).
private var gatewayCache: [String: String] = [:]
public init(executable: String = "container", processHost: ProcessHost = ProcessHost()) {
self.executable = executable
self.processHost = processHost
}
// MARK: - Preflight
/// True if the `container` tool can be invoked at all.
public func isAvailable() async -> Bool {
do {
_ = try await run(["--version"])
return true
} catch {
return false
}
}
/// Ensure the container subsystem is up. Idempotent; safe to call before every run.
public func ensureSystemStarted() async throws {
guard await isAvailable() else { throw ContainerError.notInstalled }
// `system start` is a no-op if already running; tolerate a non-zero exit so we
// don't wedge on "already started" diagnostics from some builds.
_ = try? await run(["system", "start"])
}
// MARK: - Images
/// True if an image with this reference already exists locally.
public func imageExists(_ reference: String) async -> Bool {
do {
_ = try await run(["image", "inspect", reference])
return true
} catch {
return false
}
}
/// Build the bundled default sandbox image (`ProjectSandbox.defaultImage`) from an
/// embedded Dockerfile. No-op if it already exists.
public func ensureDefaultImage() async throws {
let reference = ProjectSandbox.defaultImage
if await imageExists(reference) { return }
let context = try writeDefaultBuildContext()
defer { try? FileManager.default.removeItem(at: context) }
_ = try await run(["build", "--tag", reference, context.path])
}
/// Default image: a Node base with git + the Claude Code CLI preinstalled.
private func writeDefaultBuildContext() throws -> URL {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("nucleic-sandbox-build-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let dockerfile = """
FROM node:22-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates iproute2 \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g @anthropic-ai/claude-code
WORKDIR /workspace
"""
try dockerfile.write(
to: dir.appendingPathComponent("Dockerfile"), atomically: true, encoding: .utf8)
return dir
}
// MARK: - Lifecycle
/// Ensure the named container is running. Strategy avoids parsing `inspect` output:
/// 1. `exec true` succeeds already running.
/// 2. else `start` succeeds existed but stopped.
/// 3. else `run -d ` to create it fresh, then seed the writable claude-home.
public func ensureRunning(_ spec: ContainerSpec) async throws {
try await ensureSystemStarted()
if spec.image == ProjectSandbox.defaultImage {
try await ensureDefaultImage()
}
if (try? await run(["exec", spec.name, "true"])) != nil { return }
if (try? await run(["start", spec.name])) != nil { return }
var args = ["run", "--detach", "--name", spec.name]
for mount in spec.mounts { args += mountArgs(mount) }
args += [spec.image, "sleep", "infinity"]
_ = try await run(args)
try await seedClaudeHome(spec)
}
/// Copy the read-only host `~/.claude` (mounted at the staging path) into the writable
/// claude-home so `claude` can persist native session/transcript files without ever
/// mutating the user's real credentials. Best-effort; missing source is tolerated.
private func seedClaudeHome(_ spec: ContainerSpec) async throws {
let script = "mkdir -p \(shellQuote(spec.claudeHomeWritable)); "
+ "cp -a \(shellQuote(spec.claudeHomeStaging))/. \(shellQuote(spec.claudeHomeWritable))/ "
+ "2>/dev/null || true"
_ = try? await run(["exec", spec.name, "sh", "-c", script])
}
/// Build the `ProcessSpec` that runs `argv` inside the container via `container exec`.
/// The caller launches it through its own `ProcessHost` so stdin/stdout streaming
/// (NDJSON) behaves exactly as a host spawn.
public nonisolated func execProcessSpec(
name: String, workdir: String, env: [String: String], argv: [String]
) -> ProcessSpec {
var args = ["exec", "--interactive", "--workdir", workdir]
for key in env.keys.sorted() {
args += ["--env", "\(key)=\(env[key]!)"]
}
args.append(name)
args += argv
// cwd here is the *host* working dir for the `container` client itself; the
// container's cwd is controlled by --workdir. Use the host's temp dir, always valid.
return ProcessSpec(
executable: executable,
args: args,
cwd: NSTemporaryDirectory(),
env: [:],
stdinMode: .pipe)
}
/// The container's view of the host (the default-route gateway, e.g. 192.168.64.1),
/// discovered by reading the routing table inside the container. Cached per name.
public func hostGateway(name: String) async throws -> String {
if let cached = gatewayCache[name] { return cached }
let out = try await run(
["exec", name, "sh", "-c", "ip route | awk '/default/ {print $3; exit}'"])
let ip = out.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
guard !ip.isEmpty else { throw ContainerError.gatewayUnavailable }
gatewayCache[name] = ip
return ip
}
public func stop(name: String) async {
gatewayCache[name] = nil
_ = try? await run(["stop", name])
}
public func remove(name: String) async {
gatewayCache[name] = nil
_ = try? await run(["stop", name])
_ = try? await run(["delete", name])
}
/// All container names known to the runtime (running or stopped). Best-effort; an
/// empty list on error so reconcile never throws.
public func list() async -> [String] {
guard let out = try? await run(["list", "--all"]) else { return [] }
return parseContainerNames(out.stdout)
}
// MARK: - Helpers
/// `--volume host:container[:ro]`. Centralized so the exact mount syntax is one edit
/// away and unit-testable.
nonisolated func mountArgs(_ mount: ContainerSpec.Mount) -> [String] {
let suffix = mount.readOnly ? ":ro" : ""
return ["--volume", "\(mount.host):\(mount.container)\(suffix)"]
}
/// Pull our container names out of `container list --all` tabular output. Defensive
/// against column ordering (scans every token) but strict about shape only
/// `nucleic-<8 hex>` (the session-short naming) so the `nucleic-sandbox` *image* tag in
/// the IMAGE column is never mistaken for a container.
nonisolated func parseContainerNames(_ output: String) -> [String] {
var names: Set<String> = []
for line in output.split(separator: "\n") {
for token in line.split(whereSeparator: { $0 == " " || $0 == "\t" }) {
if Self.isContainerName(String(token)) { names.insert(String(token)) }
}
}
return Array(names)
}
/// True for a `nucleic-<8 lowercase-hex>` container name.
nonisolated static func isContainerName(_ token: String) -> Bool {
let prefix = "nucleic-"
guard token.hasPrefix(prefix) else { return false }
let suffix = token.dropFirst(prefix.count)
return suffix.count == 8 && suffix.allSatisfy { $0.isHexDigit && !$0.isUppercase }
}
private func shellQuote(_ path: String) -> String {
"'" + path.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
private struct CommandResult { let status: Int32; let stdout: String; let stderr: String }
/// Run a `container` subcommand to completion, capturing stdout/stderr. Throws
/// `ContainerError` on spawn failure or non-zero exit.
@discardableResult
private func run(_ args: [String]) async throws -> CommandResult {
let spec = ProcessSpec(
executable: executable, args: args, cwd: NSTemporaryDirectory(),
env: [:], stdinMode: .closed)
let handle: any ProcessHandle
do {
handle = try await processHost.launch(spec)
} catch {
throw ContainerError.notInstalled
}
async let outData = collect(handle.stdoutLines)
async let errData = collect(handle.stderrLines)
let status = await handle.wait()
let stdout = await outData
let stderr = await errData
guard status == 0 else {
throw ContainerError.commandFailed(
command: args.joined(separator: " "), status: status, stderr: stderr)
}
return CommandResult(status: status, stdout: stdout, stderr: stderr)
}
private func collect(_ stream: AsyncThrowingStream<Data, Error>) async -> String {
var lines: [String] = []
do {
for try await line in stream { lines.append(String(decoding: line, as: UTF8.self)) }
} catch {
// Partial output is fine for our diagnostics.
}
return lines.joined(separator: "\n")
}
}
@@ -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)
}
}
+37
View File
@@ -35,6 +35,39 @@ public enum SetupPolicy: String, Sendable, Codable {
case warn
}
/// Per-project execution-sandbox settings. When enabled, the project's sessions run
/// `claude` inside an isolated Linux VM via Apple's `container` tool instead of on the
/// host, with the worktree bind-mounted (CONTAINER_SANDBOX). `nil` on a Project means
/// "off" the default, host-spawned behavior.
public struct ProjectSandbox: Sendable, Codable, Equatable {
public var enabled: Bool
/// Image reference to run. `nil` the bundled default image (built on first use).
public var image: String?
/// Stop the per-session container after this many seconds of inactivity; the next
/// turn transparently restarts it.
public var idleTimeoutSeconds: Int
/// The bundled default image tag, used when `image` is nil.
public static let defaultImage = "nucleic-sandbox:latest"
public static let defaultIdleTimeoutSeconds = 900
public init(
enabled: Bool = false,
image: String? = nil,
idleTimeoutSeconds: Int = ProjectSandbox.defaultIdleTimeoutSeconds
) {
self.enabled = enabled
self.image = image
self.idleTimeoutSeconds = idleTimeoutSeconds
}
/// The image actually used to run containers (resolved override or bundled default).
public var resolvedImage: String {
if let image, !image.trimmingCharacters(in: .whitespaces).isEmpty { return image }
return ProjectSandbox.defaultImage
}
}
/// A registered git repository plus per-project configuration.
public struct Project: Identifiable, Sendable, Codable, Equatable {
public let id: ProjectID
@@ -47,6 +80,8 @@ public struct Project: Identifiable, Sendable, Codable, Equatable {
public var worktreeBase: String?
public var setupScript: String?
public var setupPolicy: SetupPolicy
/// Execution-sandbox settings. `nil` sessions run on the host (default).
public var sandbox: ProjectSandbox?
public var createdAt: Date
public init(
@@ -58,6 +93,7 @@ public struct Project: Identifiable, Sendable, Codable, Equatable {
worktreeBase: String? = nil,
setupScript: String? = nil,
setupPolicy: SetupPolicy = .block,
sandbox: ProjectSandbox? = nil,
createdAt: Date = Date()
) {
self.id = id
@@ -68,6 +104,7 @@ public struct Project: Identifiable, Sendable, Codable, Equatable {
self.worktreeBase = worktreeBase
self.setupScript = setupScript
self.setupPolicy = setupPolicy
self.sandbox = sandbox
self.createdAt = createdAt
}
+55 -6
View File
@@ -100,6 +100,51 @@ public actor SessionController {
// MARK: - Lifecycle intents
/// The session's worktree path (live worktree, else the persisted path).
private var worktreePath: String { session.worktreePath ?? worktree?.path ?? "" }
/// Build the sandbox `ContainerSpec` for this session when its project opts in;
/// `nil` run `claude` on the host (default). Mounts the repo root and the worktree
/// base at identical paths (so git-worktree links + the cwd-hash resolve), the host
/// `~/.claude` read-only into a staging path, and a per-session writable claude-home.
private func containerSpec() -> ContainerSpec? {
guard let project, let sandbox = project.sandbox, sandbox.enabled else { return nil }
let stagingPath = "/nucleic/host-claude"
let writablePath = "/root/.claude"
let writableHost = ((session.transcriptPath as NSString).deletingLastPathComponent
as NSString).appendingPathComponent("claude-home")
try? FileManager.default.createDirectory(
atPath: writableHost, withIntermediateDirectories: true)
var mounts: [ContainerSpec.Mount] = [
.init(host: project.rootPath, container: project.rootPath, readOnly: false),
.init(
host: project.resolvedWorktreeBase, container: project.resolvedWorktreeBase,
readOnly: false),
.init(host: writableHost, container: writablePath, readOnly: false),
]
let hostClaude = (NSHomeDirectory() as NSString).appendingPathComponent(".claude")
if FileManager.default.fileExists(atPath: hostClaude) {
mounts.append(.init(host: hostClaude, container: stagingPath, readOnly: true))
}
var env: [String: String] = ["HOME": "/root"]
if let key = ProcessInfo.processInfo.environment["ANTHROPIC_API_KEY"] {
env["ANTHROPIC_API_KEY"] = key
}
return ContainerSpec(
name: ContainerManager.containerName(for: session.id),
image: sandbox.resolvedImage,
mounts: mounts,
workdir: worktreePath,
env: env,
idleTimeout: TimeInterval(sandbox.idleTimeoutSeconds),
claudeHomeStaging: stagingPath,
claudeHomeWritable: writablePath)
}
/// Begin a fresh run. Idempotent: a second call while running is a no-op.
public func start(prompt: AgentInput) {
guard runTask == nil else { return }
@@ -107,12 +152,13 @@ public actor SessionController {
session.updatedAt = now()
let run = RunSpec(
sessionID: session.id,
worktree: session.worktreePath ?? worktree?.path ?? "",
worktree: worktreePath,
prompt: prompt,
model: session.model,
effort: session.effort,
autoApprove: session.auto,
approvalPolicy: .interactive)
approvalPolicy: .interactive,
container: containerSpec())
consume(backend.start(run), injectingUserText: prompt.plainText)
}
@@ -124,7 +170,8 @@ public actor SessionController {
let spec = ResumeSpec(
sessionID: session.id,
backendSessionID: backendSessionID,
worktree: session.worktreePath ?? worktree?.path ?? "")
worktree: worktreePath,
container: containerSpec())
consume(backend.resume(spec), injectingUserText: nil)
}
@@ -162,19 +209,21 @@ public actor SessionController {
guard runTask == nil else { return } // a turn is already running
session.status = .running
session.updatedAt = now()
let worktreePath = session.worktreePath ?? worktree?.path ?? ""
let container = containerSpec()
if let backendSessionID = session.backendSessionID {
let spec = ResumeSpec(
sessionID: session.id, backendSessionID: backendSessionID,
worktree: worktreePath, prompt: input,
model: session.model, effort: session.effort, autoApprove: session.auto)
model: session.model, effort: session.effort, autoApprove: session.auto,
container: container)
consume(backend.resume(spec), injectingUserText: nil)
} else {
// No turn has run yet this message starts the session.
let run = RunSpec(
sessionID: session.id, worktree: worktreePath, prompt: input,
model: session.model, effort: session.effort,
autoApprove: session.auto, approvalPolicy: .interactive)
autoApprove: session.auto, approvalPolicy: .interactive,
container: container)
consume(backend.start(run), injectingUserText: nil)
}
return
@@ -0,0 +1,112 @@
import Foundation
import Testing
@testable import NucleicCore
@Suite("Container sandbox")
struct ContainerSandboxTests {
// MARK: - exec argument construction
@Test func execProcessSpecOrdersFlagsAndArgv() {
let runtime = ContainerRuntime(executable: "container")
let spec = runtime.execProcessSpec(
name: "nucleic-abc12345",
workdir: "/Users/me/repos/demo-wt",
env: ["ANTHROPIC_API_KEY": "sk-test", "HOME": "/root"],
argv: ["claude", "-p", "--verbose"])
#expect(spec.executable == "container")
// exec, --interactive, --workdir <wd>, then env (keys sorted), name, argv.
#expect(spec.args == [
"exec", "--interactive", "--workdir", "/Users/me/repos/demo-wt",
"--env", "ANTHROPIC_API_KEY=sk-test",
"--env", "HOME=/root",
"nucleic-abc12345",
"claude", "-p", "--verbose",
])
#expect(spec.stdinMode == .pipe)
}
// MARK: - mount formatting
@Test func mountArgsFormatReadWriteAndReadOnly() {
let runtime = ContainerRuntime()
#expect(runtime.mountArgs(.init(host: "/a", container: "/a", readOnly: false))
== ["--volume", "/a:/a"])
#expect(runtime.mountArgs(.init(host: "/home/.claude", container: "/staging", readOnly: true))
== ["--volume", "/home/.claude:/staging:ro"])
}
// MARK: - container-list parsing (orphan reconcile)
@Test func parseContainerNamesPicksNucleicEntries() {
let runtime = ContainerRuntime()
let output = """
ID IMAGE STATE NAME
abc nucleic-sandbox running nucleic-abc12345
def someother:latest running unrelated-box
ghi nucleic-sandbox stopped nucleic-def67890
"""
let names = Set(runtime.parseContainerNames(output))
#expect(names == ["nucleic-abc12345", "nucleic-def67890"])
}
// MARK: - stable naming
@Test func containerNameDerivesFromSessionShortID() {
let id = SessionID(rawValue: "abcdef12-3456-7890-aaaa-bbbbbbbbbbbb")
#expect(ContainerManager.containerName(for: id) == "nucleic-abcdef12")
}
/// `UUID().uuidString` is uppercase; the container name must be lowercased so it both
/// validates as a container name and matches the reconcile parser.
@Test func containerNameLowercasesUppercaseUUID() {
let id = SessionID(rawValue: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")
let name = ContainerManager.containerName(for: id)
#expect(name == "nucleic-e621e1f8")
#expect(ContainerRuntime.isContainerName(name))
}
// MARK: - sandbox config persistence
@Test func projectSandboxRoundTripsThroughStore() async throws {
let store = try GRDBMetadataStore(path: nil)
let project = Project(
id: .generate(), name: "demo", rootPath: "/repos/demo", defaultBranch: "main",
sandbox: ProjectSandbox(enabled: true, image: "custom:1", idleTimeoutSeconds: 600),
createdAt: Date(timeIntervalSince1970: 1_700_000_000))
try await store.saveProject(project)
let loaded = try await store.loadProjects()
#expect(loaded == [project])
#expect(loaded.first?.sandbox?.enabled == true)
#expect(loaded.first?.sandbox?.image == "custom:1")
#expect(loaded.first?.sandbox?.idleTimeoutSeconds == 600)
}
@Test func projectWithoutSandboxLoadsAsNil() async throws {
let store = try GRDBMetadataStore(path: nil)
let project = Project(name: "plain", rootPath: "/repos/plain", defaultBranch: "main")
try await store.saveProject(project)
let loaded = try await store.loadProjects()
#expect(loaded.first?.sandbox == nil)
}
@Test func resolvedImageFallsBackToBundledDefault() {
#expect(ProjectSandbox(enabled: true, image: nil).resolvedImage == ProjectSandbox.defaultImage)
#expect(ProjectSandbox(enabled: true, image: " ").resolvedImage == ProjectSandbox.defaultImage)
#expect(ProjectSandbox(enabled: true, image: "x:2").resolvedImage == "x:2")
}
// MARK: - approval MCP URL host rewrite
@Test func mcpConfigRewritesHostForContainerGateway() {
let server = MCPApprovalServer()
let host = server.mcpConfigJSON(port: 5050, token: "tok")
#expect(host.contains("http://127.0.0.1:5050/mcp"))
let gateway = server.mcpConfigJSON(host: "192.168.64.1", port: 5050, token: "tok")
#expect(gateway.contains("http://192.168.64.1:5050/mcp"))
#expect(gateway.contains("Bearer tok"))
}
}