Sandbox: host build/run mode (host_exec tool)
Adds a per-project "Allow host build/run" sandbox capability that lets a containerized agent request to build and run executables on the host machine, escaping the Linux sandbox — e.g. compiling and running a macOS binary the container can't. - New `host_exec` MCP tool on the approval server, advertised only when the session opts in. Pre-allowed via --allowedTools so the call reaches our handler directly rather than Claude's permission path: the handler is the sole gate, so `auto` mode can never auto-approve it. - Every host command surfaces an explicit approval (risk .hostExec) and runs on the host via /bin/zsh -lc in the session worktree only after approval. An explicit "Allow for Session" choice grants the rest of the session; auto-approve never sets that — only a deliberate user choice does. - ProjectSandbox.allowHostExec (off by default) with tolerant decoding so rows persisted before the field default to false instead of dropping the whole sandbox config. - Threaded allowHostExec through RunSpec/ResumeSpec/SessionController; Mac Project Settings toggle; Mac ApprovalBar "Allow for Session" button; iOS risk styling/biometric gate for .hostExec. - Tests: host_exec advertised/served only when registered + refused otherwise; allowHostExec round-trip and legacy-JSON default-to-false. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -453,7 +453,13 @@ struct ApprovalBar: View {
|
||||
}
|
||||
Spacer()
|
||||
Button("Deny") { respond(.deny(reason: "Denied from Nucleic")) }
|
||||
Button("Allow Always") { respond(.allowAlways(.toolName)) }
|
||||
if request.risk == .hostExec {
|
||||
// Host execution escapes the sandbox: the only "remember" option is a
|
||||
// deliberate, session-scoped grant — never a blanket tool allow (HOST_EXEC).
|
||||
Button("Allow for Session") { respond(.allowAlways(.session)) }
|
||||
} else {
|
||||
Button("Allow Always") { respond(.allowAlways(.toolName)) }
|
||||
}
|
||||
Button("Allow") { respond(.allow()) }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
@@ -62,6 +62,7 @@ struct ProjectSettingsSheet: View {
|
||||
@State private var sandboxEnabled: Bool
|
||||
@State private var image: String
|
||||
@State private var idleMinutes: Int
|
||||
@State private var allowHostExec: Bool
|
||||
|
||||
init(project: Project) {
|
||||
self.project = project
|
||||
@@ -69,6 +70,7 @@ struct ProjectSettingsSheet: View {
|
||||
_sandboxEnabled = State(initialValue: sandbox.enabled)
|
||||
_image = State(initialValue: sandbox.image ?? "")
|
||||
_idleMinutes = State(initialValue: max(1, sandbox.idleTimeoutSeconds / 60))
|
||||
_allowHostExec = State(initialValue: sandbox.allowHostExec)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -98,6 +100,18 @@ struct ProjectSettingsSheet: View {
|
||||
Stepper(
|
||||
"Stop container after \(idleMinutes) min idle",
|
||||
value: $idleMinutes, in: 1...240)
|
||||
|
||||
Divider()
|
||||
Toggle(isOn: $allowHostExec) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Allow host build/run")
|
||||
Text("Lets the sandboxed agent request to build and run executables "
|
||||
+ "on the host machine (outside the container) — e.g. compiling "
|
||||
+ "and running a macOS binary. Every host command needs your "
|
||||
+ "explicit approval and is never auto-approved.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
@@ -122,7 +136,8 @@ struct ProjectSettingsSheet: View {
|
||||
updated.sandbox = ProjectSandbox(
|
||||
enabled: sandboxEnabled,
|
||||
image: trimmed.isEmpty ? nil : trimmed,
|
||||
idleTimeoutSeconds: idleMinutes * 60)
|
||||
idleTimeoutSeconds: idleMinutes * 60,
|
||||
allowHostExec: allowHostExec)
|
||||
await store.updateProject(updated)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
@@ -138,6 +138,9 @@ public struct RunSpec: Sendable {
|
||||
public let sandbox: SandboxMode?
|
||||
/// When set, run `claude` inside this Apple `container` instead of on the host.
|
||||
public let container: ContainerSpec?
|
||||
/// Expose the `host_exec` tool so the (sandboxed) agent can request to build/run on the
|
||||
/// host, each call approval-gated and never auto-approved (HOST_EXEC).
|
||||
public let allowHostExec: Bool
|
||||
public let mcpConfigPath: URL?
|
||||
public let appendSystemPrompt: String?
|
||||
public let extraEnv: [String: String]
|
||||
@@ -153,6 +156,7 @@ public struct RunSpec: Sendable {
|
||||
approvalPolicy: ApprovalPolicy = .interactive,
|
||||
sandbox: SandboxMode? = nil,
|
||||
container: ContainerSpec? = nil,
|
||||
allowHostExec: Bool = false,
|
||||
mcpConfigPath: URL? = nil,
|
||||
appendSystemPrompt: String? = nil,
|
||||
extraEnv: [String: String] = [:],
|
||||
@@ -167,6 +171,7 @@ public struct RunSpec: Sendable {
|
||||
self.approvalPolicy = approvalPolicy
|
||||
self.sandbox = sandbox
|
||||
self.container = container
|
||||
self.allowHostExec = allowHostExec
|
||||
self.mcpConfigPath = mcpConfigPath
|
||||
self.appendSystemPrompt = appendSystemPrompt
|
||||
self.extraEnv = extraEnv
|
||||
@@ -186,11 +191,14 @@ public struct ResumeSpec: Sendable {
|
||||
public let fork: Bool
|
||||
/// When set, resume `claude` inside this Apple `container` instead of on the host.
|
||||
public let container: ContainerSpec?
|
||||
/// Expose the `host_exec` tool on this resumed turn (HOST_EXEC). See `RunSpec`.
|
||||
public let allowHostExec: Bool
|
||||
|
||||
public init(
|
||||
sessionID: SessionID, backendSessionID: String, worktree: WorktreePath,
|
||||
prompt: AgentInput? = nil, model: String? = nil, effort: String? = nil,
|
||||
autoApprove: Bool = false, fork: Bool = false, container: ContainerSpec? = nil
|
||||
autoApprove: Bool = false, fork: Bool = false, container: ContainerSpec? = nil,
|
||||
allowHostExec: Bool = false
|
||||
) {
|
||||
self.sessionID = sessionID
|
||||
self.backendSessionID = backendSessionID
|
||||
@@ -201,6 +209,7 @@ public struct ResumeSpec: Sendable {
|
||||
self.autoApprove = autoApprove
|
||||
self.fork = fork
|
||||
self.container = container
|
||||
self.allowHostExec = allowHostExec
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,10 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
/// Set once the user chooses "Proceed Anyway" on a conflict this run, so subsequent edits
|
||||
/// in the same turn don't re-prompt for the same overridden conflict.
|
||||
private var conflictOverridden = false
|
||||
/// Set once the user picks "Allow for this session" on a `host_exec` prompt, so further host
|
||||
/// commands this session run without re-prompting (HOST_EXEC). Auto-approve never sets this —
|
||||
/// only an explicit user choice does. Persists across turns (actor lives for the session).
|
||||
private var hostExecSessionGranted = false
|
||||
public private(set) var backendSessionID: String?
|
||||
|
||||
public init(
|
||||
@@ -126,7 +130,8 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
model: resume.model,
|
||||
effort: resume.effort,
|
||||
autoApprove: resume.autoApprove,
|
||||
container: resume.container)
|
||||
container: resume.container,
|
||||
allowHostExec: resume.allowHostExec)
|
||||
return makeStream(run: run, resumeArgs: args)
|
||||
}
|
||||
|
||||
@@ -216,6 +221,16 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
return MCPApprovalServer.ConflictReply(resolution: resolution)
|
||||
}
|
||||
}
|
||||
// Host-exec bridge (HOST_EXEC): when this run opts in, expose the `host_exec`
|
||||
// tool. Its handler is the sole gate — every call surfaces an approval (never
|
||||
// auto-approved) and, once granted, runs on the host outside the container.
|
||||
if run.allowHostExec {
|
||||
let workdir = run.container?.workdir ?? run.worktree
|
||||
await approvalServer.registerHostExec(token: token) { [weak self] call in
|
||||
guard let self else { return .denied(message: "Session terminated") }
|
||||
return await self.handleHostExecCall(call, workdir: workdir)
|
||||
}
|
||||
}
|
||||
let mcpConfig = approvalServer.mcpConfigJSON(host: mcpHost, port: port, token: token)
|
||||
|
||||
// 2. Spawn.
|
||||
@@ -255,6 +270,12 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
if conflictCoordinator != nil {
|
||||
allowedTools.append(MCPApprovalServer.qualifiedConflictToolName)
|
||||
}
|
||||
// Pre-allow `host_exec` so the call reaches our handler directly instead of
|
||||
// Claude's permission path — our handler is the sole gate, so `auto` mode can
|
||||
// never auto-approve it (HOST_EXEC).
|
||||
if run.allowHostExec {
|
||||
allowedTools.append(MCPApprovalServer.qualifiedHostExecToolName)
|
||||
}
|
||||
if !allowedTools.isEmpty {
|
||||
args += ["--allowedTools", allowedTools.joined(separator: ",")]
|
||||
}
|
||||
@@ -425,6 +446,101 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
return ClaudeDecisionMapping.reply(for: resolved.decision, originalInput: call.input)
|
||||
}
|
||||
|
||||
// MARK: - Host exec bridge (HOST_EXEC)
|
||||
|
||||
/// Gate + run a `host_exec` call. This path NEVER consults the auto-approve blanket or
|
||||
/// the always-rule cache: it surfaces an explicit approval every time, unless the user
|
||||
/// previously chose "Allow for this session" (which only an explicit choice can set).
|
||||
private func handleHostExecCall(_ call: MCPApprovalServer.HostExecCall, workdir: String) async
|
||||
-> MCPApprovalServer.HostExecReply
|
||||
{
|
||||
if !hostExecSessionGranted {
|
||||
let request = ApprovalRequest(
|
||||
id: .generate(),
|
||||
sessionID: sessionID ?? SessionID(rawValue: "unknown"),
|
||||
toolCallID: nil,
|
||||
toolName: MCPApprovalServer.qualifiedHostExecToolName,
|
||||
input: .object(["command": .string(call.command)]),
|
||||
title: "Run on host: \(Self.oneLine(call.command))",
|
||||
risk: .hostExec,
|
||||
createdAt: configuration.now())
|
||||
|
||||
emit(.approvalRequested(request), nativeType: "mcp/tools/call")
|
||||
let resolved = await approvals.waitForDecision(request)
|
||||
emit(.approvalResolved(resolved), nativeType: nil)
|
||||
|
||||
switch resolved.decision {
|
||||
case .deny(let reason):
|
||||
return .denied(message: reason ?? "The user denied host execution.")
|
||||
case .cancelRun:
|
||||
interruptRequested = true
|
||||
handle?.sendSignal(SIGINT)
|
||||
return .denied(message: "The user cancelled the run.")
|
||||
case .allowAlways:
|
||||
// "Allow for this session" — grant the rest of the session without re-prompting.
|
||||
hostExecSessionGranted = true
|
||||
case .allow:
|
||||
break
|
||||
}
|
||||
}
|
||||
return await runOnHost(command: call.command, workdir: workdir)
|
||||
}
|
||||
|
||||
/// Run `command` on the host via the login shell in the session's worktree, capturing
|
||||
/// stdout/stderr (each capped) and the exit code. Runs OUTSIDE the container — this is
|
||||
/// the deliberate sandbox escape, reached only after explicit approval.
|
||||
private func runOnHost(command: String, workdir: String) async
|
||||
-> MCPApprovalServer.HostExecReply
|
||||
{
|
||||
let spec = ProcessSpec(
|
||||
executable: "/bin/zsh",
|
||||
args: ["-lc", command],
|
||||
cwd: workdir,
|
||||
env: [:],
|
||||
stdinMode: .closed)
|
||||
do {
|
||||
let handle = try await processHost.launch(spec)
|
||||
// Drain both pipes concurrently so a full stderr buffer can't deadlock stdout.
|
||||
async let out = Self.collect(handle.stdoutLines)
|
||||
async let err = Self.collect(handle.stderrLines)
|
||||
let code = await handle.wait()
|
||||
let (stdout, stderr) = await (out, err)
|
||||
return .ran(exitCode: code, stdout: stdout, stderr: stderr)
|
||||
} catch {
|
||||
return .ran(
|
||||
exitCode: -1, stdout: "",
|
||||
stderr: "Failed to launch host command: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect a line stream into one string, capped so a runaway build log can't blow up
|
||||
/// the tool result the agent reads back.
|
||||
private static func collect(_ stream: AsyncThrowingStream<Data, Error>, cap: Int = 64 * 1024)
|
||||
async -> String
|
||||
{
|
||||
var data = Data()
|
||||
var truncated = false
|
||||
do {
|
||||
for try await line in stream {
|
||||
if data.count < cap {
|
||||
data.append(line)
|
||||
data.append(0x0A)
|
||||
} else {
|
||||
truncated = true
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
var text = String(decoding: data, as: UTF8.self)
|
||||
if truncated { text += "\n…(output truncated)" }
|
||||
return text
|
||||
}
|
||||
|
||||
private static func oneLine(_ command: String, max: Int = 80) -> String {
|
||||
let flat = command.replacingOccurrences(of: "\n", with: " ")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
return flat.count <= max ? flat : String(flat.prefix(max - 1)) + "…"
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func emit(_ kind: AgentEvent.Kind, nativeType: String?) {
|
||||
|
||||
@@ -81,8 +81,39 @@ public actor MCPApprovalServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `host_exec` tool call: the agent asks to run a shell command on the *host* machine,
|
||||
/// outside the sandbox container (e.g. to build/run a macOS binary the Linux container
|
||||
/// can't). The handler suspends (like `approve`) until the user approves and the host
|
||||
/// command finishes, then returns its output. Always gated; never auto-approved.
|
||||
public struct HostExecCall: Sendable {
|
||||
public let command: String
|
||||
public init(command: String) { self.command = command }
|
||||
}
|
||||
|
||||
public enum HostExecReply: Sendable {
|
||||
case denied(message: String)
|
||||
case ran(exitCode: Int32, stdout: String, stderr: String)
|
||||
|
||||
/// Text-block JSON the agent reads back from the tool result.
|
||||
public func wireJSON() -> String {
|
||||
switch self {
|
||||
case .denied(let message):
|
||||
return JSONValue.object([
|
||||
"denied": .bool(true), "message": .string(message),
|
||||
]).canonicalString()
|
||||
case .ran(let exitCode, let stdout, let stderr):
|
||||
return JSONValue.object([
|
||||
"exit_code": .number(Double(exitCode)),
|
||||
"stdout": .string(stdout),
|
||||
"stderr": .string(stderr),
|
||||
]).canonicalString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public typealias Handler = @Sendable (ApprovalCall) async -> Reply
|
||||
public typealias ConflictHandler = @Sendable (ConflictCall) async -> ConflictReply
|
||||
public typealias HostExecHandler = @Sendable (HostExecCall) async -> HostExecReply
|
||||
|
||||
public static let toolName = "approve"
|
||||
/// Fully-qualified name passed to `--permission-prompt-tool`.
|
||||
@@ -90,10 +121,16 @@ public actor MCPApprovalServer {
|
||||
public static let conflictToolName = "check_conflict"
|
||||
/// Fully-qualified name — pre-allowed so the agent calls it without an approval round-trip.
|
||||
public static let qualifiedConflictToolName = "mcp__nucleic__check_conflict"
|
||||
public static let hostExecToolName = "host_exec"
|
||||
/// Fully-qualified name — pre-allowed via `--allowedTools` so the call reaches our handler
|
||||
/// directly (the handler is the sole gate); never routed through Claude's permission path,
|
||||
/// so `auto` mode can't auto-approve it. Only advertised when a handler is registered.
|
||||
public static let qualifiedHostExecToolName = "mcp__nucleic__host_exec"
|
||||
|
||||
private var listener: NWListener?
|
||||
private var handlers: [String: Handler] = [:]
|
||||
private var conflictHandlers: [String: ConflictHandler] = [:]
|
||||
private var hostExecHandlers: [String: HostExecHandler] = [:]
|
||||
private var connectionTasks: [Int: Task<Void, Never>] = [:]
|
||||
private var nextConnectionID = 0
|
||||
public private(set) var port: UInt16 = 0
|
||||
@@ -145,6 +182,7 @@ public actor MCPApprovalServer {
|
||||
connectionTasks.removeAll()
|
||||
handlers.removeAll()
|
||||
conflictHandlers.removeAll()
|
||||
hostExecHandlers.removeAll()
|
||||
}
|
||||
|
||||
public func register(token: String, handler: @escaping Handler) {
|
||||
@@ -156,9 +194,16 @@ public actor MCPApprovalServer {
|
||||
conflictHandlers[token] = handler
|
||||
}
|
||||
|
||||
/// Register the per-session `host_exec` handler. Registering it both advertises the tool
|
||||
/// in `tools/list` for this token and routes calls to the host runner (HOST_EXEC).
|
||||
public func registerHostExec(token: String, handler: @escaping HostExecHandler) {
|
||||
hostExecHandlers[token] = handler
|
||||
}
|
||||
|
||||
public func unregister(token: String) {
|
||||
handlers.removeValue(forKey: token)
|
||||
conflictHandlers.removeValue(forKey: token)
|
||||
hostExecHandlers.removeValue(forKey: token)
|
||||
}
|
||||
|
||||
/// The MCP server entry for `--mcp-config` (inline JSON). `host` is the address the
|
||||
@@ -346,47 +391,68 @@ public actor MCPApprovalServer {
|
||||
return rpcResult(id: id, result: .object([:]))
|
||||
|
||||
case "tools/list":
|
||||
return rpcResult(
|
||||
id: id,
|
||||
result: .object([
|
||||
"tools": .array([
|
||||
.object([
|
||||
"name": .string(Self.toolName),
|
||||
"description": .string(
|
||||
"Ask the Nucleic user to approve or deny a gated tool call."),
|
||||
"inputSchema": .object([
|
||||
"type": .string("object"),
|
||||
"properties": .object([
|
||||
"tool_name": .object(["type": .string("string")]),
|
||||
"input": .object(["type": .string("object")]),
|
||||
"tool_use_id": .object(["type": .string("string")]),
|
||||
]),
|
||||
"required": .array([.string("tool_name"), .string("input")]),
|
||||
var tools: [JSONValue] = [
|
||||
.object([
|
||||
"name": .string(Self.toolName),
|
||||
"description": .string(
|
||||
"Ask the Nucleic user to approve or deny a gated tool call."),
|
||||
"inputSchema": .object([
|
||||
"type": .string("object"),
|
||||
"properties": .object([
|
||||
"tool_name": .object(["type": .string("string")]),
|
||||
"input": .object(["type": .string("object")]),
|
||||
"tool_use_id": .object(["type": .string("string")]),
|
||||
]),
|
||||
"required": .array([.string("tool_name"), .string("input")]),
|
||||
]),
|
||||
]),
|
||||
.object([
|
||||
"name": .string(Self.conflictToolName),
|
||||
"description": .string(
|
||||
"Before starting a distinct task that edits files, check whether it "
|
||||
+ "conflicts with another currently-active Nucleic agent. Pass a "
|
||||
+ "one-line task description and the repo-relative files/areas you "
|
||||
+ "expect to change. If the result says the task was deferred or "
|
||||
+ "cancelled, STOP and do not edit; if it says proceed (or there is "
|
||||
+ "no conflict), continue."),
|
||||
"inputSchema": .object([
|
||||
"type": .string("object"),
|
||||
"properties": .object([
|
||||
"task": .object(["type": .string("string")]),
|
||||
"files": .object([
|
||||
"type": .string("array"),
|
||||
"items": .object(["type": .string("string")]),
|
||||
]),
|
||||
]),
|
||||
.object([
|
||||
"name": .string(Self.conflictToolName),
|
||||
"description": .string(
|
||||
"Before starting a distinct task that edits files, check whether it "
|
||||
+ "conflicts with another currently-active Nucleic agent. Pass a "
|
||||
+ "one-line task description and the repo-relative files/areas you "
|
||||
+ "expect to change. If the result says the task was deferred or "
|
||||
+ "cancelled, STOP and do not edit; if it says proceed (or there is "
|
||||
+ "no conflict), continue."),
|
||||
"inputSchema": .object([
|
||||
"type": .string("object"),
|
||||
"properties": .object([
|
||||
"task": .object(["type": .string("string")]),
|
||||
"files": .object([
|
||||
"type": .string("array"),
|
||||
"items": .object(["type": .string("string")]),
|
||||
]),
|
||||
]),
|
||||
"required": .array([.string("task")]),
|
||||
"required": .array([.string("task")]),
|
||||
]),
|
||||
]),
|
||||
]
|
||||
// Only advertise host_exec when this session opted into host build/run.
|
||||
if hostExecHandlers[token] != nil {
|
||||
tools.append(
|
||||
.object([
|
||||
"name": .string(Self.hostExecToolName),
|
||||
"description": .string(
|
||||
"Build and/or run a command on the HOST machine (macOS), OUTSIDE this "
|
||||
+ "Linux sandbox container. Use ONLY when you must compile or run an "
|
||||
+ "executable the sandbox cannot — e.g. building and running a macOS "
|
||||
+ "app or binary. Pass a single shell `command`; it runs in this "
|
||||
+ "session's worktree on the host (combine build + run into one "
|
||||
+ "command, e.g. \"swift build && swift run MyTool\"). Returns "
|
||||
+ "{exit_code, stdout, stderr}. Every call needs explicit user "
|
||||
+ "approval and is NEVER auto-approved, so only call it when host "
|
||||
+ "execution is genuinely required."),
|
||||
"inputSchema": .object([
|
||||
"type": .string("object"),
|
||||
"properties": .object([
|
||||
"command": .object(["type": .string("string")])
|
||||
]),
|
||||
"required": .array([.string("command")]),
|
||||
]),
|
||||
])
|
||||
]))
|
||||
]))
|
||||
}
|
||||
return rpcResult(id: id, result: .object(["tools": .array(tools)]))
|
||||
|
||||
case "tools/call":
|
||||
let params = message["params"]
|
||||
@@ -414,6 +480,19 @@ public actor MCPApprovalServer {
|
||||
let reply = await conflictHandler(call)
|
||||
return toolResult(id: id, text: reply.wireJSON())
|
||||
|
||||
case Self.hostExecToolName:
|
||||
let call = HostExecCall(command: arguments?["command"]?.stringValue ?? "")
|
||||
// No handler (tool shouldn't be visible) → refuse rather than run anything.
|
||||
guard let hostExecHandler = hostExecHandlers[token] else {
|
||||
return toolResult(
|
||||
id: id,
|
||||
text: HostExecReply.denied(message: "Host execution is not enabled.")
|
||||
.wireJSON())
|
||||
}
|
||||
// Suspends until the user approves and the host command finishes (HOST_EXEC).
|
||||
let reply = await hostExecHandler(call)
|
||||
return toolResult(id: id, text: reply.wireJSON())
|
||||
|
||||
default:
|
||||
return rpcError(id: id, code: -32602, message: "Unknown tool")
|
||||
}
|
||||
|
||||
@@ -31,6 +31,11 @@ public struct ProjectSandbox: Sendable, Codable, Equatable {
|
||||
/// Stop the per-session container after this many seconds of inactivity; the next
|
||||
/// turn transparently restarts it.
|
||||
public var idleTimeoutSeconds: Int
|
||||
/// Allow the sandboxed agent to build and run executables on the *host* machine via the
|
||||
/// `host_exec` MCP tool, escaping the Linux container (e.g. to compile/run a macOS binary
|
||||
/// the container can't). Off by default; every host command is approval-gated and is
|
||||
/// never auto-approved (HOST_EXEC). Only meaningful when `enabled` is true.
|
||||
public var allowHostExec: Bool
|
||||
|
||||
/// The bundled default image tag, used when `image` is nil.
|
||||
public static let defaultImage = "nucleic-sandbox:latest"
|
||||
@@ -39,11 +44,26 @@ public struct ProjectSandbox: Sendable, Codable, Equatable {
|
||||
public init(
|
||||
enabled: Bool = false,
|
||||
image: String? = nil,
|
||||
idleTimeoutSeconds: Int = ProjectSandbox.defaultIdleTimeoutSeconds
|
||||
idleTimeoutSeconds: Int = ProjectSandbox.defaultIdleTimeoutSeconds,
|
||||
allowHostExec: Bool = false
|
||||
) {
|
||||
self.enabled = enabled
|
||||
self.image = image
|
||||
self.idleTimeoutSeconds = idleTimeoutSeconds
|
||||
self.allowHostExec = allowHostExec
|
||||
}
|
||||
|
||||
// Tolerant decode: rows persisted before `allowHostExec` existed decode with the
|
||||
// default rather than failing the whole blob — the store decodes sandbox config with
|
||||
// `try?`, so a strict miss would silently drop the entire sandbox config.
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? false
|
||||
image = try c.decodeIfPresent(String.self, forKey: .image)
|
||||
idleTimeoutSeconds =
|
||||
try c.decodeIfPresent(Int.self, forKey: .idleTimeoutSeconds)
|
||||
?? ProjectSandbox.defaultIdleTimeoutSeconds
|
||||
allowHostExec = try c.decodeIfPresent(Bool.self, forKey: .allowHostExec) ?? false
|
||||
}
|
||||
|
||||
/// The image actually used to run containers (resolved override or bundled default).
|
||||
|
||||
@@ -103,6 +103,13 @@ public actor SessionController {
|
||||
/// The session's worktree path (live worktree, else the persisted path).
|
||||
private var worktreePath: String { session.worktreePath ?? worktree?.path ?? "" }
|
||||
|
||||
/// Host build/run opt-in (HOST_EXEC): only when the project's sandbox is enabled AND host
|
||||
/// execution is allowed. Drives whether the `host_exec` tool is exposed for the run.
|
||||
private var allowsHostExec: Bool {
|
||||
guard let sandbox = project?.sandbox, sandbox.enabled else { return false }
|
||||
return sandbox.allowHostExec
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -200,7 +207,8 @@ public actor SessionController {
|
||||
effort: session.effort,
|
||||
autoApprove: session.auto,
|
||||
approvalPolicy: .interactive,
|
||||
container: containerSpec())
|
||||
container: containerSpec(),
|
||||
allowHostExec: allowsHostExec)
|
||||
consume(backend.start(run), injectingUserText: prompt.plainText)
|
||||
}
|
||||
|
||||
@@ -214,7 +222,8 @@ public actor SessionController {
|
||||
sessionID: session.id,
|
||||
backendSessionID: backendSessionID,
|
||||
worktree: worktreePath,
|
||||
container: containerSpec())
|
||||
container: containerSpec(),
|
||||
allowHostExec: allowsHostExec)
|
||||
consume(backend.resume(spec), injectingUserText: nil)
|
||||
}
|
||||
|
||||
@@ -259,7 +268,7 @@ public actor SessionController {
|
||||
sessionID: session.id, backendSessionID: backendSessionID,
|
||||
worktree: worktreePath, prompt: input,
|
||||
model: session.model, effort: session.effort, autoApprove: session.auto,
|
||||
container: container)
|
||||
container: container, allowHostExec: allowsHostExec)
|
||||
consume(backend.resume(spec), injectingUserText: nil)
|
||||
} else {
|
||||
// No turn has run yet → this message starts the session.
|
||||
@@ -267,7 +276,7 @@ public actor SessionController {
|
||||
sessionID: session.id, worktree: worktreePath, prompt: input,
|
||||
model: session.model, effort: session.effort,
|
||||
autoApprove: session.auto, approvalPolicy: .interactive,
|
||||
container: container)
|
||||
container: container, allowHostExec: allowsHostExec)
|
||||
consume(backend.start(run), injectingUserText: nil)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -4,6 +4,9 @@ import Foundation
|
||||
|
||||
public enum Risk: String, Sendable, Codable {
|
||||
case readOnly, write, execute, network, destructive, unknown
|
||||
/// Run a command on the host machine, outside the sandbox container (HOST_EXEC).
|
||||
/// The highest-trust gate: always surfaced, never auto-approved.
|
||||
case hostExec
|
||||
}
|
||||
|
||||
public enum AlwaysScope: String, Sendable, Codable {
|
||||
|
||||
@@ -84,6 +84,20 @@ struct ContainerSandboxTests {
|
||||
#expect(loaded.first?.sandbox?.idleTimeoutSeconds == 600)
|
||||
}
|
||||
|
||||
@Test func hostExecRoundTripsAndLegacyJSONDefaultsToFalse() async throws {
|
||||
// New field survives a round-trip.
|
||||
let sandbox = ProjectSandbox(enabled: true, idleTimeoutSeconds: 600, allowHostExec: true)
|
||||
let data = try JSONEncoder().encode(sandbox)
|
||||
#expect(try JSONDecoder().decode(ProjectSandbox.self, from: data).allowHostExec == true)
|
||||
|
||||
// Rows persisted before `allowHostExec` existed decode with the default (false) rather
|
||||
// than failing the whole blob (the store decodes sandbox config with `try?`).
|
||||
let legacy = Data(#"{"enabled":true,"idleTimeoutSeconds":900}"#.utf8)
|
||||
let decoded = try JSONDecoder().decode(ProjectSandbox.self, from: legacy)
|
||||
#expect(decoded.enabled == true)
|
||||
#expect(decoded.allowHostExec == false)
|
||||
}
|
||||
|
||||
@Test func projectWithoutSandboxLoadsAsNil() async throws {
|
||||
let store = try GRDBMetadataStore(path: nil)
|
||||
let project = Project(name: "plain", rootPath: "/repos/plain", defaultBranch: "main")
|
||||
|
||||
@@ -105,6 +105,52 @@ import Testing
|
||||
#expect(text == #"{"action":"deferred","conflict":true,"message":"This task was added to the Nucleic to-do list because it conflicts with another active agent. Stop now and do NOT work on it."}"#)
|
||||
}
|
||||
|
||||
@Test func advertisesAndServesHostExecOnlyWhenRegistered() async throws {
|
||||
let server = MCPApprovalServer()
|
||||
let port = try await server.start()
|
||||
defer { Task { await server.stop() } }
|
||||
|
||||
// Token without a host-exec handler: tool is hidden, and a direct call is refused.
|
||||
await server.register(token: "plain") { _ in .deny(message: "n/a") }
|
||||
let plainList = try await post(
|
||||
["jsonrpc": "2.0", "id": 1, "method": "tools/list"], port: port, token: "plain")
|
||||
let plainNames = (plainList.body?["result"]?["tools"]?.arrayValue ?? [])
|
||||
.compactMap { $0["name"]?.stringValue }
|
||||
#expect(!plainNames.contains("host_exec"))
|
||||
|
||||
let refused = try await post(
|
||||
[
|
||||
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
||||
"params": ["name": "host_exec", "arguments": ["command": "echo hi"]],
|
||||
], port: port, token: "plain")
|
||||
#expect(refused.body?["result"]?["content"]?[0]?["text"]?.stringValue
|
||||
== #"{"denied":true,"message":"Host execution is not enabled."}"#)
|
||||
|
||||
// Token with a host-exec handler: tool is advertised and routed to the handler.
|
||||
await server.register(token: "host") { _ in .deny(message: "n/a") }
|
||||
await server.registerHostExec(token: "host") { call in
|
||||
#expect(call.command == "swift build && swift run Tool")
|
||||
return .ran(exitCode: 0, stdout: "built\n", stderr: "")
|
||||
}
|
||||
let hostList = try await post(
|
||||
["jsonrpc": "2.0", "id": 3, "method": "tools/list"], port: port, token: "host")
|
||||
let hostNames = (hostList.body?["result"]?["tools"]?.arrayValue ?? [])
|
||||
.compactMap { $0["name"]?.stringValue }
|
||||
#expect(hostNames.contains("host_exec"))
|
||||
|
||||
let call = try await post(
|
||||
[
|
||||
"jsonrpc": "2.0", "id": 4, "method": "tools/call",
|
||||
"params": [
|
||||
"name": "host_exec",
|
||||
"arguments": ["command": "swift build && swift run Tool"],
|
||||
],
|
||||
], port: port, token: "host")
|
||||
#expect(call.status == 200)
|
||||
let text = call.body?["result"]?["content"]?[0]?["text"]?.stringValue
|
||||
#expect(text == #"{"exit_code":0,"stderr":"","stdout":"built\n"}"#)
|
||||
}
|
||||
|
||||
@Test func rejectsBadBearerToken() async throws {
|
||||
let server = MCPApprovalServer()
|
||||
let port = try await server.start()
|
||||
|
||||
@@ -84,11 +84,11 @@ extension SessionStatus {
|
||||
}
|
||||
|
||||
extension Risk {
|
||||
var isHigh: Bool { self == .destructive || self == .network }
|
||||
var label: String { rawValue }
|
||||
var isHigh: Bool { self == .destructive || self == .network || self == .hostExec }
|
||||
var label: String { self == .hostExec ? "host machine" : rawValue }
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .destructive, .network: return Palette.danger
|
||||
case .destructive, .network, .hostExec: return Palette.danger
|
||||
case .execute: return Palette.attention
|
||||
case .write: return Color(red: 0.85, green: 0.75, blue: 0.2)
|
||||
case .readOnly, .unknown: return .secondary
|
||||
|
||||
Reference in New Issue
Block a user