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:
2026-06-13 16:52:01 -07:00
co-authored by Claude Opus 4.8
parent 6eaef12474
commit 94273b5de5
11 changed files with 366 additions and 49 deletions
+7 -1
View File
@@ -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)
+16 -1
View File
@@ -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()
}
+10 -1
View File
@@ -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")
}
+21 -1
View File
@@ -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).
+13 -4
View File
@@ -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
+3
View File
@@ -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