diff --git a/Sources/NucleicApp/SessionDetailView.swift b/Sources/NucleicApp/SessionDetailView.swift index 4f3c5790..ebdb2118 100644 --- a/Sources/NucleicApp/SessionDetailView.swift +++ b/Sources/NucleicApp/SessionDetailView.swift @@ -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) diff --git a/Sources/NucleicApp/Sheets.swift b/Sources/NucleicApp/Sheets.swift index 14817bcb..3568e5a8 100644 --- a/Sources/NucleicApp/Sheets.swift +++ b/Sources/NucleicApp/Sheets.swift @@ -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() } diff --git a/Sources/NucleicCore/Backend.swift b/Sources/NucleicCore/Backend.swift index 644f92c2..11feca63 100644 --- a/Sources/NucleicCore/Backend.swift +++ b/Sources/NucleicCore/Backend.swift @@ -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 } } diff --git a/Sources/NucleicCore/Claude/ClaudeCodeBackend.swift b/Sources/NucleicCore/Claude/ClaudeCodeBackend.swift index d05c5a0f..dfc65cde 100644 --- a/Sources/NucleicCore/Claude/ClaudeCodeBackend.swift +++ b/Sources/NucleicCore/Claude/ClaudeCodeBackend.swift @@ -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, 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?) { diff --git a/Sources/NucleicCore/Claude/MCPApprovalServer.swift b/Sources/NucleicCore/Claude/MCPApprovalServer.swift index 0bb9c2b1..03434a5c 100644 --- a/Sources/NucleicCore/Claude/MCPApprovalServer.swift +++ b/Sources/NucleicCore/Claude/MCPApprovalServer.swift @@ -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] = [:] 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") } diff --git a/Sources/NucleicCore/Project.swift b/Sources/NucleicCore/Project.swift index f035b057..a1c2aa2e 100644 --- a/Sources/NucleicCore/Project.swift +++ b/Sources/NucleicCore/Project.swift @@ -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). diff --git a/Sources/NucleicCore/SessionController.swift b/Sources/NucleicCore/SessionController.swift index fd19f2f6..87f88d0f 100644 --- a/Sources/NucleicCore/SessionController.swift +++ b/Sources/NucleicCore/SessionController.swift @@ -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 diff --git a/Sources/NucleicProtocol/Approval.swift b/Sources/NucleicProtocol/Approval.swift index a9f6a988..dd132cf7 100644 --- a/Sources/NucleicProtocol/Approval.swift +++ b/Sources/NucleicProtocol/Approval.swift @@ -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 { diff --git a/Tests/NucleicCoreTests/ContainerSandboxTests.swift b/Tests/NucleicCoreTests/ContainerSandboxTests.swift index d07d76f4..7de70e98 100644 --- a/Tests/NucleicCoreTests/ContainerSandboxTests.swift +++ b/Tests/NucleicCoreTests/ContainerSandboxTests.swift @@ -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") diff --git a/Tests/NucleicCoreTests/MCPApprovalServerTests.swift b/Tests/NucleicCoreTests/MCPApprovalServerTests.swift index 484f5fad..d35a15a7 100644 --- a/Tests/NucleicCoreTests/MCPApprovalServerTests.swift +++ b/Tests/NucleicCoreTests/MCPApprovalServerTests.swift @@ -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() diff --git a/ios/NucleicRemote/NucleicRemote/Views/Theme.swift b/ios/NucleicRemote/NucleicRemote/Views/Theme.swift index f1b6e760..9b972c53 100644 --- a/ios/NucleicRemote/NucleicRemote/Views/Theme.swift +++ b/ios/NucleicRemote/NucleicRemote/Views/Theme.swift @@ -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