Files
nucleic/Sources/NucleicCore/Claude/MCPApprovalServer.swift
T
abkslmandClaude Opus 4.8 94273b5de5 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]>
2026-06-13 16:52:01 -07:00

549 lines
24 KiB
Swift

import Foundation
import Network
/// Localhost HTTP MCP server hosted inside the app (ADAPTERS §1.2). Claude Code
/// is pointed at it via `--mcp-config` + `--permission-prompt-tool`; it exposes a
/// single tool `approve` whose call SUSPENDS until a human resolves the approval.
///
/// One server can serve all sessions: requests are authenticated by a per-session
/// bearer token, which keys the registered handler.
public actor MCPApprovalServer {
public struct ApprovalCall: Sendable {
public let toolName: String
public let input: JSONValue
public let toolUseID: String?
}
public enum Reply: Sendable {
case allow(updatedInput: JSONValue)
case deny(message: String)
/// The confirmed wire shape: a JSON-stringified `{behavior,…}` object
/// placed inside an MCP text content block — `behavior`/`message`/
/// `updatedInput`, NOT `decision`/`reason` (ADAPTERS §1.2 ✅).
public func wireJSON() -> String {
switch self {
case .allow(let updatedInput):
return JSONValue.object([
"behavior": .string("allow"), "updatedInput": updatedInput,
]).canonicalString()
case .deny(let message):
return JSONValue.object([
"behavior": .string("deny"), "message": .string(message),
]).canonicalString()
}
}
}
/// A `check_conflict` tool call: the agent's task and the files/areas it expects to
/// touch. The handler suspends (like `approve`) until Nucleic decides + the user
/// answers, then returns a `ConflictReply` the agent reads to know how to proceed.
public struct ConflictCall: Sendable {
public let task: String
public let files: [String]
}
public struct ConflictReply: Sendable {
public let resolution: ConflictResolution
public init(resolution: ConflictResolution) { self.resolution = resolution }
/// Text-block JSON the agent reads. `conflict` is true unless there was no
/// overlap; `action` + `message` tell it exactly what to do.
public func wireJSON() -> String {
switch resolution {
case .noConflict:
return JSONValue.object(["conflict": .bool(false)]).canonicalString()
case .deferred:
return conflictJSON(
"deferred",
"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.")
case .cancelled:
return conflictJSON(
"cancelled",
"The user cancelled this task because it conflicts with another active "
+ "agent. Stop now and do NOT work on it.")
case .proceed:
return conflictJSON(
"proceed",
"This task conflicts with another active agent, but the user chose to "
+ "proceed. You may continue.")
}
}
private func conflictJSON(_ action: String, _ message: String) -> String {
JSONValue.object([
"conflict": .bool(true),
"action": .string(action),
"message": .string(message),
]).canonicalString()
}
}
/// 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`.
public static let qualifiedToolName = "mcp__nucleic__approve"
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
public init() {}
/// 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(host: String = "127.0.0.1") async throws -> UInt16 {
if let _ = listener { return port }
let parameters = NWParameters.tcp
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(
host: NWEndpoint.Host(host), port: .any)
let listener = try NWListener(using: parameters)
self.listener = listener
listener.newConnectionHandler = { [weak self] connection in
guard let self else {
connection.cancel()
return
}
Task { await self.adopt(connection) }
}
let resumeOnce = OnceBox()
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
listener.stateUpdateHandler = { state in
switch state {
case .ready:
if resumeOnce.claim() { cont.resume() }
case .failed(let error), .waiting(let error):
if resumeOnce.claim() { cont.resume(throwing: error) }
default:
break
}
}
listener.start(queue: .global(qos: .userInitiated))
}
port = listener.port?.rawValue ?? 0
return port
}
public func stop() {
listener?.cancel()
listener = nil
for task in connectionTasks.values { task.cancel() }
connectionTasks.removeAll()
handlers.removeAll()
conflictHandlers.removeAll()
hostExecHandlers.removeAll()
}
public func register(token: String, handler: @escaping Handler) {
handlers[token] = handler
}
/// Register the per-session `check_conflict` handler under the same bearer token.
public func registerConflict(token: String, handler: @escaping ConflictHandler) {
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
/// 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://\(host):\(port)/mcp"),
"headers": .object(["Authorization": .string("Bearer \(token)")]),
])
])
]).canonicalString()
}
// MARK: - Connection handling
private func adopt(_ connection: NWConnection) {
let id = nextConnectionID
nextConnectionID += 1
let task = Task {
await self.serve(connection)
self.forget(id)
}
connectionTasks[id] = task
}
private func forget(_ id: Int) {
connectionTasks.removeValue(forKey: id)
}
private func serve(_ connection: NWConnection) async {
connection.start(queue: .global(qos: .userInitiated))
defer { connection.cancel() }
var buffer = Data()
while !Task.isCancelled {
guard let request = await nextRequest(on: connection, buffer: &buffer) else { return }
let response = await handle(request)
do {
try await send(response, on: connection)
} catch {
return
}
if request.headers["connection"]?.lowercased() == "close" { return }
}
}
private struct HTTPRequest {
let method: String
let path: String
let headers: [String: String] // lowercased keys
let body: Data
}
private struct HTTPResponse {
let status: Int
let statusText: String
let contentType: String?
let body: Data
}
private func nextRequest(on connection: NWConnection, buffer: inout Data) async -> HTTPRequest? {
while true {
if let request = Self.parseRequest(from: &buffer) { return request }
do {
guard let chunk = try await receiveChunk(connection) else { return nil }
buffer.append(chunk)
} catch {
return nil
}
}
}
private static func parseRequest(from buffer: inout Data) -> HTTPRequest? {
guard let headerEnd = buffer.range(of: Data("\r\n\r\n".utf8)) else { return nil }
let headerData = buffer.subdata(in: buffer.startIndex..<headerEnd.lowerBound)
guard let headerText = String(data: headerData, encoding: .utf8) else { return nil }
let lines = headerText.components(separatedBy: "\r\n")
guard let requestLine = lines.first else { return nil }
let parts = requestLine.split(separator: " ")
guard parts.count >= 2 else { return nil }
var headers: [String: String] = [:]
for line in lines.dropFirst() {
guard let colon = line.firstIndex(of: ":") else { continue }
let key = line[..<colon].trimmingCharacters(in: .whitespaces).lowercased()
let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
headers[key] = value
}
let contentLength = headers["content-length"].flatMap(Int.init) ?? 0
let bodyStart = headerEnd.upperBound
guard buffer.distance(from: bodyStart, to: buffer.endIndex) >= contentLength else {
return nil
}
let body = buffer.subdata(in: bodyStart..<buffer.index(bodyStart, offsetBy: contentLength))
buffer.removeSubrange(buffer.startIndex..<buffer.index(bodyStart, offsetBy: contentLength))
return HTTPRequest(
method: String(parts[0]), path: String(parts[1]), headers: headers, body: body)
}
private func receiveChunk(_ connection: NWConnection) async throws -> Data? {
try await withCheckedThrowingContinuation { continuation in
connection.receive(minimumIncompleteLength: 1, maximumLength: 1 << 16) {
data, _, isComplete, error in
if let error {
continuation.resume(throwing: error)
} else if let data, !data.isEmpty {
continuation.resume(returning: data)
} else if isComplete {
continuation.resume(returning: nil)
} else {
continuation.resume(returning: Data())
}
}
}
}
private func send(_ response: HTTPResponse, on connection: NWConnection) async throws {
var head = "HTTP/1.1 \(response.status) \(response.statusText)\r\n"
if let contentType = response.contentType {
head += "Content-Type: \(contentType)\r\n"
}
head += "Content-Length: \(response.body.count)\r\nConnection: keep-alive\r\n\r\n"
var payload = Data(head.utf8)
payload.append(response.body)
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
connection.send(
content: payload,
completion: .contentProcessed { error in
if let error { cont.resume(throwing: error) } else { cont.resume() }
})
}
}
// MARK: - MCP / JSON-RPC dispatch
private func handle(_ request: HTTPRequest) async -> HTTPResponse {
guard request.method == "POST" else {
// Streamable-HTTP clients may probe GET (server-push channel) — we
// don't offer one, 405 is the spec-sanctioned answer.
return HTTPResponse(
status: 405, statusText: "Method Not Allowed", contentType: nil, body: Data())
}
let authorization = request.headers["authorization"] ?? ""
let token = authorization.hasPrefix("Bearer ") ? String(authorization.dropFirst(7)) : ""
guard let handler = handlers[token] else {
return HTTPResponse(
status: 401, statusText: "Unauthorized", contentType: "application/json",
body: Data(#"{"error":"invalid bearer token"}"#.utf8))
}
guard let message = try? JSONValue(parsing: request.body) else {
return rpcError(id: .null, code: -32700, message: "Parse error")
}
let id = message["id"] ?? .null
let method = message["method"]?.stringValue ?? ""
// Notifications (no id) are acknowledged with 202 and no body.
if message["id"] == nil {
return HTTPResponse(status: 202, statusText: "Accepted", contentType: nil, body: Data())
}
switch method {
case "initialize":
let requested = message["params"]?["protocolVersion"]?.stringValue ?? "2025-06-18"
return rpcResult(
id: id,
result: .object([
"protocolVersion": .string(requested),
"capabilities": .object(["tools": .object(["listChanged": .bool(false)])]),
"serverInfo": .object([
"name": .string("nucleic-approval"), "version": .string("0.1.0"),
]),
]))
case "ping":
return rpcResult(id: id, result: .object([:]))
case "tools/list":
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")]),
]),
]),
"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"]
let toolName = params?["name"]?.stringValue
let arguments = params?["arguments"]
switch toolName {
case Self.toolName:
let call = ApprovalCall(
toolName: arguments?["tool_name"]?.stringValue ?? "unknown",
input: arguments?["input"] ?? .object([:]),
toolUseID: arguments?["tool_use_id"]?.stringValue)
// This await is the bridge: it suspends the HTTP response until the
// human answers (ApprovalCoordinator), then returns the mapped reply.
let reply = await handler(call)
return toolResult(id: id, text: reply.wireJSON())
case Self.conflictToolName:
let files = (arguments?["files"]?.arrayValue ?? []).compactMap { $0.stringValue }
let call = ConflictCall(
task: arguments?["task"]?.stringValue ?? "", files: files)
// No handler registered (shouldn't happen) → fail open: no conflict.
guard let conflictHandler = conflictHandlers[token] else {
return toolResult(id: id, text: ConflictReply(resolution: .noConflict).wireJSON())
}
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")
}
default:
return rpcError(id: id, code: -32601, message: "Method not found: \(method)")
}
}
/// A successful `tools/call` result wrapping `text` in a single MCP text content block.
private func toolResult(id: JSONValue, text: String) -> HTTPResponse {
rpcResult(
id: id,
result: .object([
"content": .array([
.object(["type": .string("text"), "text": .string(text)])
]),
"isError": .bool(false),
]))
}
private func rpcResult(id: JSONValue, result: JSONValue) -> HTTPResponse {
let body = JSONValue.object(["jsonrpc": .string("2.0"), "id": id, "result": result])
return HTTPResponse(
status: 200, statusText: "OK", contentType: "application/json",
body: (try? body.encodedData()) ?? Data())
}
private func rpcError(id: JSONValue, code: Int, message: String) -> HTTPResponse {
let body = JSONValue.object([
"jsonrpc": .string("2.0"), "id": id,
"error": .object(["code": .number(Double(code)), "message": .string(message)]),
])
return HTTPResponse(
status: 200, statusText: "OK", contentType: "application/json",
body: (try? body.encodedData()) ?? Data())
}
}
/// Resume-once guard for NWListener state callbacks (ready may be followed by
/// failed; the continuation must fire exactly once).
private final class OnceBox: @unchecked Sendable {
private let lock = NSLock()
private var claimed = false
func claim() -> Bool {
lock.lock()
defer { lock.unlock() }
if claimed { return false }
claimed = true
return true
}
}