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]>
549 lines
24 KiB
Swift
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
|
|
}
|
|
}
|