import AppKit import CryptoKit import Darwin import Foundation import OpenClawKit import OSLog private let execApprovalsSocketTimeoutMs = 15000 struct ExecApprovalPromptRequest: Codable { var command: String var cwd: String? var host: String? var security: String? var ask: String? var agentId: String? var resolvedPath: String? var sessionKey: String? var allowedDecisions: [ExecApprovalDecision]? init( command: String, cwd: String? = nil, host: String? = nil, security: String? = nil, ask: String? = nil, agentId: String? = nil, resolvedPath: String? = nil, sessionKey: String? = nil, allowedDecisions: [ExecApprovalDecision]? = nil) { self.command = command self.cwd = cwd self.host = host self.security = security self.ask = ask self.agentId = agentId self.resolvedPath = resolvedPath self.sessionKey = sessionKey self.allowedDecisions = allowedDecisions } private enum CodingKeys: String, CodingKey { case command case cwd case host case security case ask case agentId case resolvedPath case sessionKey case allowedDecisions } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.command = try container.decode(String.self, forKey: .command) self.cwd = try container.decodeIfPresent(String.self, forKey: .cwd) self.host = try container.decodeIfPresent(String.self, forKey: .host) self.security = try container.decodeIfPresent(String.self, forKey: .security) self.ask = try container.decodeIfPresent(String.self, forKey: .ask) self.agentId = try container.decodeIfPresent(String.self, forKey: .agentId) self.resolvedPath = try container.decodeIfPresent(String.self, forKey: .resolvedPath) self.sessionKey = try container.decodeIfPresent(String.self, forKey: .sessionKey) let decodedDecisions = (try? container.decodeIfPresent( [DecodedExecApprovalDecision].self, forKey: .allowedDecisions)) ?? [] self.allowedDecisions = decodedDecisions.compactMap(\.decision) } static func allowedDecisions( forAsk ask: String?, allowAlwaysEligible: Bool = true) -> [ExecApprovalDecision] { // Older payloads did not carry ask/allowedDecisions. Preserve their durable // approval option; explicit ask=always and allowedDecisions payloads are the // policy-carrying shapes that remove it. guard allowAlwaysEligible else { return [.allowOnce, .deny] } return ask == ExecAsk.always.rawValue ? [.allowOnce, .deny] : [.allowOnce, .allowAlways, .deny] } } private struct DecodedExecApprovalDecision: Decodable { var decision: ExecApprovalDecision? init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() guard let raw = try? container.decode(String.self) else { self.decision = nil return } self.decision = ExecApprovalDecision(rawValue: raw) } } private struct ExecApprovalSocketRequest: Codable { var type: String var token: String var id: String var request: ExecApprovalPromptRequest } private struct ExecApprovalSocketDecision: Codable { var type: String var id: String var decision: ExecApprovalDecision } private struct ExecHostSocketRequest: Codable { var type: String var id: String var nonce: String var ts: Int var hmac: String var requestJson: String } struct ExecHostRequest: Codable { var command: [String] var rawCommand: String? var cwd: String? var env: [String: String]? var timeoutMs: Int? var needsScreenRecording: Bool? var agentId: String? var sessionKey: String? var approvalDecision: ExecApprovalDecision? var approvalSource: String? var policySnapshot: OpenClawSystemRunApprovalPolicySnapshot? } private struct ExecHostRunResult: Codable { var exitCode: Int? var timedOut: Bool var success: Bool var stdout: String var stderr: String var error: String? } enum ExecHostOutputLimiter { static let maxJsonlResponseBytes = 16 * 1024 * 1024 static let maxOutputFieldBytes = 1024 * 1024 private static let truncationMarker = "... (truncated) " static func truncate(_ value: String) -> String { let bytes = value.utf8 guard bytes.count > self.maxOutputFieldBytes else { return value } let tailBudget = self.maxOutputFieldBytes - self.truncationMarker.utf8.count var start = bytes.index(bytes.endIndex, offsetBy: -tailBudget) while start < bytes.endIndex, (bytes[start] & 0xC0) == 0x80 { start = bytes.index(after: start) } let tail = String(bytes: bytes[start...], encoding: .utf8) ?? "" return self.truncationMarker + tail } } struct ExecHostError: Codable, Error { var code: String var message: String var reason: String? } private struct ExecHostResponse: Codable { var type: String var id: String var ok: Bool var payload: ExecHostRunResult? var error: ExecHostError? } private func configureSocketTimeouts(_ fd: Int32, timeoutMs: Int) throws { guard timeoutMs > 0 else { return } var timeout = timeval( tv_sec: timeoutMs / 1000, tv_usec: Int32((timeoutMs % 1000) * 1000)) let timeoutSize = socklen_t(MemoryLayout.size(ofValue: timeout)) guard setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, timeoutSize) == 0, setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, timeoutSize) == 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } } private func readLineFromSocket(_ fd: Int32, maxBytes: Int) throws -> String? { // Foundation can wait for the full requested byte count on sockets. POSIX // recv returns short JSONL frames; the socket timeout bounds idle peers. var buffer = Data() while buffer.count < maxBytes { var chunk = [UInt8](repeating: 0, count: min(4096, maxBytes - buffer.count)) let count = chunk.withUnsafeMutableBytes { bytes in recv(fd, bytes.baseAddress, bytes.count, 0) } if count == 0 { break } if count < 0 { if errno == EINTR { continue } throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } buffer.append(contentsOf: chunk.prefix(count)) if buffer.contains(0x0A) { break } } guard let newlineIndex = buffer.firstIndex(of: 0x0A) else { guard !buffer.isEmpty else { return nil } return String(data: buffer, encoding: .utf8) } let lineData = buffer.subdata(in: 0.. Bool { let lhsBytes = Array(lhs.utf8) let rhsBytes = Array(rhs.utf8) guard lhsBytes.count == rhsBytes.count else { return false } var diff: UInt8 = 0 for index in lhsBytes.indices { diff |= lhsBytes[index] ^ rhsBytes[index] } return diff == 0 } func execHostTimestampIsFresh( nowMs: Int, requestMs: Int, toleranceMs: Int = 10000) -> Bool { guard toleranceMs >= 0 else { return false } let (lowerBound, lowerOverflow) = nowMs.subtractingReportingOverflow(toleranceMs) if !lowerOverflow, requestMs < lowerBound { return false } let (upperBound, upperOverflow) = nowMs.addingReportingOverflow(toleranceMs) if !upperOverflow, requestMs > upperBound { return false } return true } enum ExecApprovalsSocketClient { private struct TimeoutError: LocalizedError { var message: String var errorDescription: String? { self.message } } static func requestDecision( socketPath: String, token: String, request: ExecApprovalPromptRequest, timeoutMs: Int = 15000) async -> ExecApprovalDecision? { let trimmedPath = socketPath.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedPath.isEmpty, !trimmedToken.isEmpty else { return nil } do { return try await AsyncTimeout.withTimeoutMs( timeoutMs: timeoutMs, onTimeout: { TimeoutError(message: "exec approvals socket timeout") }, operation: { try await Task.detached { try self.requestDecisionSync( socketPath: trimmedPath, token: trimmedToken, request: request, timeoutMs: timeoutMs) }.value }) } catch { return nil } } private static func requestDecisionSync( socketPath: String, token: String, request: ExecApprovalPromptRequest, timeoutMs: Int) throws -> ExecApprovalDecision? { let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { throw NSError(domain: "ExecApprovals", code: 1, userInfo: [ NSLocalizedDescriptionKey: "socket create failed", ]) } defer { close(fd) } try configureSocketTimeouts(fd, timeoutMs: timeoutMs) var addr = sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) let maxLen = MemoryLayout.size(ofValue: addr.sun_path) if socketPath.utf8.count >= maxLen { throw NSError(domain: "ExecApprovals", code: 2, userInfo: [ NSLocalizedDescriptionKey: "socket path too long", ]) } socketPath.withCString { cstr in withUnsafeMutablePointer(to: &addr.sun_path) { ptr in let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self) strncpy(raw, cstr, maxLen - 1) } } let size = socklen_t(MemoryLayout.size(ofValue: addr)) let result = withUnsafePointer(to: &addr) { ptr in ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in connect(fd, rebound, size) } } if result != 0 { throw NSError(domain: "ExecApprovals", code: 3, userInfo: [ NSLocalizedDescriptionKey: "socket connect failed", ]) } let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: false) let message = ExecApprovalSocketRequest( type: "request", token: token, id: UUID().uuidString, request: request) let data = try JSONEncoder().encode(message) var payload = data payload.append(0x0A) try handle.write(contentsOf: payload) guard let line = try readLineFromSocket(fd, maxBytes: 256_000), let lineData = line.data(using: .utf8) else { return nil } let response = try JSONDecoder().decode(ExecApprovalSocketDecision.self, from: lineData) return response.decision } } @MainActor final class ExecApprovalsPromptServer { static let shared = ExecApprovalsPromptServer() private let retryDelay: Duration private let resolveSocketCredentials: @Sendable () -> (socketPath: String, token: String) private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision? private var server: ExecApprovalsSocketServer? private var retryTask: Task? private var previousStartupTask: Task? private var startupGeneration: UInt64 = 0 init( retryDelay: Duration = .seconds(1), resolveSocketCredentials: @escaping @Sendable () -> (socketPath: String, token: String) = { let approvals = ExecApprovalsStore.resolve(agentId: nil) return (approvals.socketPath, approvals.token) }, onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision? = { request in await ExecApprovalsPromptPresenter.prompt( request, timeoutMs: execApprovalsSocketTimeoutMs) }) { self.retryDelay = retryDelay self.resolveSocketCredentials = resolveSocketCredentials self.onPrompt = onPrompt } func start() { guard self.server == nil, self.retryTask == nil else { return } self.startupGeneration &+= 1 let generation = self.startupGeneration let retryDelay = self.retryDelay let resolveSocketCredentials = self.resolveSocketCredentials let onPrompt = self.onPrompt let previousStartupTask = self.previousStartupTask // Keep one lifecycle-owned retry loop. Blocking lock acquisition stays // off MainActor, while generation checks prevent post-stop installation. self.retryTask = Task { @MainActor [weak self] in // A canceled startup may still be unwinding socket-path cleanup. // Never let a replacement generation race that cleanup. if let previousStartupTask { await previousStartupTask.value } guard !Task.isCancelled, self?.startupGeneration == generation else { return } var isFirstAttempt = true while !Task.isCancelled { if isFirstAttempt { isFirstAttempt = false } else { do { try await Task.sleep(for: retryDelay) } catch { return } } let credentials = await Task.detached(priority: .utility) { resolveSocketCredentials() }.value guard !Task.isCancelled, let self, self.startupGeneration == generation else { return } let token = credentials.token.trimmingCharacters(in: .whitespacesAndNewlines) guard !token.isEmpty else { continue } let server = ExecApprovalsSocketServer( socketPath: credentials.socketPath, token: token, onPrompt: onPrompt, onExec: { request in await ExecHostExecutor.handle(request) }, onUnexpectedStop: { [weak self] stoppedServer in Task { @MainActor [weak self] in self?.handleUnexpectedStop(stoppedServer, generation: generation) } }) let ready = await withTaskCancellationHandler { await server.start() } onCancel: { server.stop() } guard !Task.isCancelled, self.startupGeneration == generation else { server.stop() return } guard ready else { server.stop() continue } // The accept loop can fail after signaling readiness but before // this task resumes. Do not install an already-dead listener. guard server.isListening else { server.stop() continue } self.server = server self.retryTask = nil return } } } @discardableResult func stop() -> Task? { self.startupGeneration &+= 1 let pendingRetry = self.retryTask pendingRetry?.cancel() if let pendingRetry { self.previousStartupTask = pendingRetry } self.retryTask = nil self.server?.stop() self.server = nil return pendingRetry } private func handleUnexpectedStop( _ stoppedServer: ExecApprovalsSocketServer, generation: UInt64) { guard self.startupGeneration == generation, self.server === stoppedServer else { return } self.server = nil self.start() } #if DEBUG func _testFailActiveSocket() { self.server?.failForTesting() } static func _testPrecancelledSocketStart( socketPath: String) async -> (ready: Bool, preservedExistingListener: Bool) { let sentinel = ExecApprovalsSocketServer( socketPath: socketPath, token: "sentinel-token", onPrompt: { _ in nil }, onExec: { request in await ExecHostExecutor.handle(request) }, onUnexpectedStop: { _ in }) guard await sentinel.start() else { sentinel.stop() return (false, false) } let socketServer = ExecApprovalsSocketServer( socketPath: socketPath, token: "test-token", onPrompt: { _ in nil }, onExec: { request in await ExecHostExecutor.handle(request) }, onUnexpectedStop: { _ in }) let startup = Task.detached { withUnsafeCurrentTask { task in task?.cancel() } return await socketServer.start() } let ready = await startup.value socketServer.stop() let preservedExistingListener = sentinel.isListening && (try? ExecApprovalsSocketPathGuard.pathKind(at: socketPath)) == .socket sentinel.stop() return (ready, preservedExistingListener) } static func _testSocketLeaseHandoff( socketPath: String) async -> ( replacementBlockedWhileOwned: Bool, replacementStartedAfterRelease: Bool, replacementHasDistinctIdentity: Bool, replacementPreserved: Bool) { let first = ExecApprovalsSocketServer( socketPath: socketPath, token: "first-token", onPrompt: { _ in nil }, onExec: { request in await ExecHostExecutor.handle(request) }, onUnexpectedStop: { _ in }) guard await first.start() else { first.stop() return (false, false, false, false) } let firstIdentity = try? ExecApprovalsSocketPathGuard.socketIdentity(at: socketPath) let replacement = ExecApprovalsSocketServer( socketPath: socketPath, token: "replacement-token", onPrompt: { _ in nil }, onExec: { request in await ExecHostExecutor.handle(request) }, onUnexpectedStop: { _ in }) let replacementBlockedWhileOwned = await !replacement.start() first.stop() let replacementStartedAfterRelease = await replacement.start() let replacementIdentity = try? ExecApprovalsSocketPathGuard.socketIdentity(at: socketPath) let currentIdentity = try? ExecApprovalsSocketPathGuard.socketIdentity(at: socketPath) let result = ( replacementBlockedWhileOwned: replacementBlockedWhileOwned, replacementStartedAfterRelease: replacementStartedAfterRelease, replacementHasDistinctIdentity: firstIdentity != nil && replacementIdentity != nil && firstIdentity != replacementIdentity, replacementPreserved: replacement.isListening && currentIdentity == replacementIdentity) replacement.stop() return result } static func _testExecHostTimestampFailureReason(_ timestamp: Int) async -> String? { let server = ExecApprovalsSocketServer( socketPath: "", token: "test-token", onPrompt: { _ in nil }, onExec: { _ in ExecHostResponse( type: "exec-res", id: "unexpected-execution", ok: true, payload: nil, error: nil) }, onUnexpectedStop: { _ in }) return await server.testExecHostTimestampFailureReason(timestamp) } #endif } enum ExecApprovalsPromptPresenter { private struct PendingPrompt { let id: UUID let continuation: CheckedContinuation } @MainActor private static var activePrompt: (id: UUID, alert: NSAlert?, cancelled: Bool)? @MainActor private static var pendingPrompts: [PendingPrompt] = [] @MainActor static func prompt( _ request: ExecApprovalPromptRequest, timeoutMs: Int? = nil) async -> ExecApprovalDecision? { if let timeoutMs, timeoutMs <= 0 { return nil } let promptID = UUID() let timeoutWorkItem = timeoutMs.map { _ in DispatchWorkItem { MainActor.assumeIsolated { self.cancelPrompt(id: promptID) } } } if let timeoutMs, let timeoutWorkItem { DispatchQueue.main.asyncAfter( deadline: .now() + .milliseconds(timeoutMs), execute: timeoutWorkItem) } defer { timeoutWorkItem?.cancel() } return await withTaskCancellationHandler { guard !Task.isCancelled, await self.acquirePrompt(id: promptID) else { return nil } guard !Task.isCancelled, self.activePrompt?.cancelled != true else { self.releasePrompt(id: promptID) return nil } let decision = self.runPrompt(request, id: promptID) let cancelled = self.activePrompt?.id == promptID && self.activePrompt?.cancelled == true self.releasePrompt(id: promptID) return Task.isCancelled || cancelled ? nil : decision } onCancel: { // Caller deadlines cancel the prompt task. Abort the matching modal // session so an expired approval cannot outlive or block later requests. DispatchQueue.main.async { MainActor.assumeIsolated { self.cancelPrompt(id: promptID) } } } } @MainActor private static func runPrompt( _ request: ExecApprovalPromptRequest, id: UUID) -> ExecApprovalDecision? { NSApp.activate(ignoringOtherApps: true) let alert = NSAlert() alert.alertStyle = .warning alert.messageText = "Allow this command?" alert.informativeText = "Review the command details before allowing." alert.accessoryView = self.buildAccessoryView(request) let decisions = self.allowedPromptDecisions(request) for decision in decisions { alert.addButton(withTitle: self.buttonTitle(for: decision)) } if #available(macOS 11.0, *), let denyIndex = decisions.firstIndex(of: .deny), alert.buttons.indices.contains(denyIndex) { alert.buttons[denyIndex].hasDestructiveAction = true } guard self.activePrompt?.id == id else { return nil } self.activePrompt?.alert = alert defer { self.activePrompt?.alert = nil } return self.decision(forModalResponse: alert.runModal(), decisions: decisions) } @MainActor private static func acquirePrompt(id: UUID) async -> Bool { // AppKit cannot cancel nested modal loops independently. Queue behind one // active alert; caller cancellation and deadlines remove expired waiters. if self.activePrompt == nil { self.activePrompt = (id: id, alert: nil, cancelled: false) return true } return await withCheckedContinuation { continuation in self.pendingPrompts.append(PendingPrompt(id: id, continuation: continuation)) } } @MainActor private static func releasePrompt(id: UUID) { guard self.activePrompt?.id == id else { return } self.activePrompt = nil guard !self.pendingPrompts.isEmpty else { return } let next = self.pendingPrompts.removeFirst() self.activePrompt = (id: next.id, alert: nil, cancelled: false) next.continuation.resume(returning: true) } @MainActor private static func cancelPrompt(id: UUID) { if self.activePrompt?.id == id { self.activePrompt?.cancelled = true guard let alert = self.activePrompt?.alert else { return } if NSApp.modalWindow === alert.window { NSApp.abortModal() } alert.window.close() return } guard let index = self.pendingPrompts.firstIndex(where: { $0.id == id }) else { return } let pending = self.pendingPrompts.remove(at: index) pending.continuation.resume(returning: false) } static func decision( forModalResponse response: NSApplication.ModalResponse, decisions: [ExecApprovalDecision]) -> ExecApprovalDecision? { let selectedIndex = response.rawValue - NSApplication.ModalResponse.alertFirstButtonReturn.rawValue if decisions.indices.contains(selectedIndex) { return decisions[selectedIndex] } return decisions.contains(.deny) ? .deny : nil } static func allowedPromptDecisions(_ request: ExecApprovalPromptRequest) -> [ExecApprovalDecision] { if let allowedDecisions = request.allowedDecisions, !allowedDecisions.isEmpty { return allowedDecisions } return ExecApprovalPromptRequest.allowedDecisions(forAsk: request.ask) } private static func buttonTitle(for decision: ExecApprovalDecision) -> String { switch decision { case .allowOnce: "Allow Once" case .allowAlways: "Always Allow" case .deny: "Don't Allow" } } @MainActor static func buildAccessoryView(_ request: ExecApprovalPromptRequest) -> NSView { let stack = NSStackView() stack.orientation = .vertical stack.spacing = 8 stack.alignment = .leading stack.widthAnchor.constraint(greaterThanOrEqualToConstant: 380).isActive = true let commandTitle = NSTextField(labelWithString: "Command") commandTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize) stack.addArrangedSubview(commandTitle) let commandText = NSTextView() commandText.isEditable = false commandText.isSelectable = true commandText.drawsBackground = true commandText.backgroundColor = NSColor.textBackgroundColor commandText.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) commandText.string = ExecApprovalCommandDisplaySanitizer.sanitize(request.command) commandText.textContainerInset = NSSize(width: 6, height: 6) commandText.textContainer?.lineFragmentPadding = 0 commandText.textContainer?.widthTracksTextView = true commandText.isHorizontallyResizable = false commandText.isVerticallyResizable = true let commandScroll = NSScrollView() commandScroll.borderType = .lineBorder commandScroll.hasVerticalScroller = true commandScroll.hasHorizontalScroller = false commandScroll.autohidesScrollers = true commandScroll.documentView = commandText commandScroll.translatesAutoresizingMaskIntoConstraints = false commandScroll.widthAnchor.constraint(greaterThanOrEqualToConstant: 380).isActive = true commandScroll.widthAnchor.constraint(lessThanOrEqualToConstant: 440).isActive = true commandScroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 56).isActive = true commandScroll.heightAnchor.constraint(lessThanOrEqualToConstant: 120).isActive = true stack.addArrangedSubview(commandScroll) let contextTitle = NSTextField(labelWithString: "Context") contextTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize) stack.addArrangedSubview(contextTitle) let contextStack = NSStackView() contextStack.orientation = .vertical contextStack.spacing = 4 contextStack.alignment = .leading if let cwd = self.sanitizedContextValue(request.cwd) { self.addDetailRow(title: "Working directory", value: cwd, to: contextStack) } if let agent = self.sanitizedContextValue(request.agentId) { self.addDetailRow(title: "Agent", value: agent, to: contextStack) } if let path = self.sanitizedContextValue(request.resolvedPath) { self.addDetailRow(title: "Executable", value: path, to: contextStack) } if let host = self.sanitizedContextValue(request.host) { self.addDetailRow(title: "Host", value: host, to: contextStack) } if let security = self.sanitizedContextValue(request.security) { self.addDetailRow(title: "Security", value: security, to: contextStack) } if let ask = self.sanitizedContextValue(request.ask) { self.addDetailRow(title: "Ask mode", value: ask, to: contextStack) } if contextStack.arrangedSubviews.isEmpty { let empty = NSTextField(labelWithString: "No additional context provided.") empty.textColor = NSColor.secondaryLabelColor empty.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) contextStack.addArrangedSubview(empty) } stack.addArrangedSubview(contextStack) let footer = NSTextField(labelWithString: "This runs on this machine.") footer.textColor = NSColor.secondaryLabelColor footer.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) stack.addArrangedSubview(footer) // NSAlert reserves accessory space from the view frame, not from Auto Layout constraints. // Give the top-level accessory an explicit frame so its subviews do not paint over the // alert title, message, and buttons while the frame remains zero-sized. stack.frame = NSRect(origin: .zero, size: stack.fittingSize) return stack } static func sanitizedContextValue(_ value: String?) -> String? { let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !trimmed.isEmpty else { return nil } return ExecApprovalCommandDisplaySanitizer.sanitize(trimmed) } @MainActor private static func addDetailRow(title: String, value: String, to stack: NSStackView) { let row = NSStackView() row.orientation = .horizontal row.spacing = 6 row.alignment = .firstBaseline let titleLabel = NSTextField(labelWithString: "\(title):") titleLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold) titleLabel.textColor = NSColor.secondaryLabelColor let valueLabel = NSTextField(labelWithString: value) valueLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) valueLabel.lineBreakMode = .byTruncatingMiddle valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) row.addArrangedSubview(titleLabel) row.addArrangedSubview(valueLabel) stack.addArrangedSubview(row) } } #if DEBUG extension ExecApprovalsPromptPresenter { @MainActor static func reservePromptForTesting() -> UUID? { guard self.activePrompt == nil else { return nil } let id = UUID() self.activePrompt = (id: id, alert: nil, cancelled: false) return id } @MainActor static func releasePromptForTesting(id: UUID) { self.releasePrompt(id: id) } @MainActor static var pendingPromptCountForTesting: Int { self.pendingPrompts.count } } #endif @MainActor private enum ExecHostExecutor { static func handle(_ request: ExecHostRequest) async -> ExecHostResponse { let validatedRequest: ExecHostValidatedRequest switch ExecHostRequestEvaluator.validateRequest(request) { case let .success(request): validatedRequest = request case let .failure(error): return self.errorResponse(error) } let context = await self.buildContext( request: request, command: validatedRequest.command, rawCommand: validatedRequest.evaluationRawCommand, displayCommand: validatedRequest.displayCommand) let approvalSource = validatedRequest.approvalSource let security = ExecHostRequestEvaluator.effectiveSecurity( context: context, approvalSource: approvalSource) var explicitlyApproved = approvalSource == .autoReview || request.approvalDecision == .allowOnce || request.approvalDecision == .allowAlways var persistAllowlist = request.approvalDecision == .allowAlways switch ExecHostRequestEvaluator.evaluate( context: context, approvalDecision: request.approvalDecision, approvalSource: approvalSource) { case let .deny(error): return self.errorResponse(error) case .allow: break case .requiresPrompt: guard let decision = await ExecApprovalsPromptPresenter.prompt( ExecApprovalPromptRequest( command: context.displayCommand, cwd: request.cwd, host: "node", security: context.security.rawValue, ask: context.ask.rawValue, agentId: context.agentId, resolvedPath: context.resolution?.resolvedPath, sessionKey: request.sessionKey, allowedDecisions: ExecApprovalPromptRequest.allowedDecisions( forAsk: context.ask.rawValue, allowAlwaysEligible: context.canPersistAllowAlways)), timeoutMs: execApprovalsSocketTimeoutMs) else { return self.errorResponse( code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: approval prompt closed without decision", reason: "approval-cancelled") } let followupDecision: ExecApprovalDecision switch decision { case .deny: followupDecision = .deny case .allowAlways: explicitlyApproved = true followupDecision = .allowAlways case .allowOnce: explicitlyApproved = true followupDecision = .allowOnce } persistAllowlist = followupDecision == .allowAlways switch ExecHostRequestEvaluator.evaluate( context: context, approvalDecision: followupDecision, approvalSource: approvalSource) { case let .deny(error): return self.errorResponse(error) case .allow: break case .requiresPrompt: return self.errorResponse( code: "INVALID_REQUEST", message: "unexpected approval state", reason: "invalid") } } let authorizationBasis = context.authorizationBasis let reusableAuthorization = security == .allowlist && !explicitlyApproved && authorizationBasis != nil let executionCommand: [String] if reusableAuthorization { guard let boundCommand = context.boundCommand else { return self.errorResponse( code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: reusable approval could not bind executable", reason: "allowlist-unbound") } executionCommand = boundCommand } else { executionCommand = validatedRequest.command } if let errorResponse = await self.ensureScreenRecordingAccess(request.needsScreenRecording) { return errorResponse } let executionCommit = ExecApprovalExecutionCommit.build( context: context, effectiveSecurity: security, approvalSource: approvalSource, explicitlyApproved: explicitlyApproved, persistAllowlist: persistAllowlist, delayedPolicySnapshot: validatedRequest.delayedPolicySnapshot) let timeoutSec = request.timeoutMs.flatMap { Double($0) / 1000.0 } let cwd = request.cwd let env = context.env if case .failure = ExecApprovalsStore.commitExecution(executionCommit) { return self.approvalStoreErrorResponse() } // The store commit linearizes authorization. Enqueue before the next // suspension so no unrelated MainActor work sits between those steps. let execution = Task.detached { () -> ShellExecutor.ShellResult in await ShellExecutor.runDetailed( command: executionCommand, cwd: cwd, env: env, timeout: timeoutSec) } return await self.commandResponse(execution: execution) } private static func buildContext( request: ExecHostRequest, command: [String], rawCommand: String?, displayCommand: String) async -> ExecApprovalEvaluation { await ExecApprovalEvaluator.evaluate( command: command, rawCommand: rawCommand, displayCommand: displayCommand, cwd: request.cwd, envOverrides: request.env, agentId: request.agentId) } private static func approvalStoreErrorResponse() -> ExecHostResponse { self.errorResponse( code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: exec approvals update unavailable", reason: "approval-store-unavailable") } private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? { guard needsScreenRecording == true else { return nil } let authorized = await PermissionManager .status([.screenRecording])[.screenRecording] ?? false if authorized { return nil } return self.errorResponse( code: "UNAVAILABLE", message: "PERMISSION_MISSING: screenRecording", reason: "permission:screenRecording") } private static func commandResponse( execution: Task) async -> ExecHostResponse { let result = await execution.value let payload = ExecHostRunResult( exitCode: result.exitCode, timedOut: result.timedOut, success: result.success, stdout: ExecHostOutputLimiter.truncate(result.stdout), stderr: ExecHostOutputLimiter.truncate(result.stderr), error: result.errorMessage) return self.successResponse(payload) } private static func errorResponse( _ error: ExecHostError) -> ExecHostResponse { ExecHostResponse( type: "response", id: UUID().uuidString, ok: false, payload: nil, error: error) } private static func errorResponse( code: String, message: String, reason: String?) -> ExecHostResponse { ExecHostResponse( type: "exec-res", id: UUID().uuidString, ok: false, payload: nil, error: ExecHostError(code: code, message: message, reason: reason)) } private static func successResponse(_ payload: ExecHostRunResult) -> ExecHostResponse { ExecHostResponse( type: "exec-res", id: UUID().uuidString, ok: true, payload: payload, error: nil) } } private final class ExecApprovalsSocketLifecycleLease: @unchecked Sendable { private static let processLock = NSLock() private nonisolated(unsafe) static var reservedPaths = Set() private let descriptor: Int32 private let path: String private let stateLock = NSLock() private var released = false private init(descriptor: Int32, path: String) { self.descriptor = descriptor self.path = path } static func acquire(for socketPath: String) throws -> ExecApprovalsSocketLifecycleLease { let socketURL = URL(fileURLWithPath: socketPath).standardizedFileURL let canonicalSocketPath = socketURL.deletingLastPathComponent() .resolvingSymlinksInPath() .appendingPathComponent(socketURL.lastPathComponent) .path let lockPath = "\(canonicalSocketPath).lifecycle.lock" let reserved = self.processLock.withLock { () -> Bool in guard !self.reservedPaths.contains(lockPath) else { return false } self.reservedPaths.insert(lockPath) return true } guard reserved else { throw ExecApprovalsSocketPathGuardError.lifecycleLockBusy(path: lockPath) } let descriptor = open( lockPath, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, S_IRUSR | S_IWUSR) guard descriptor >= 0 else { self.releaseProcessReservation(lockPath) throw ExecApprovalsSocketPathGuardError.lifecycleLockOpenFailed( path: lockPath, code: errno) } do { var descriptorStatus = stat() var pathStatus = stat() guard fstat(descriptor, &descriptorStatus) == 0, lstat(lockPath, &pathStatus) == 0, descriptorStatus.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG), descriptorStatus.st_uid == geteuid(), descriptorStatus.st_nlink == 1, descriptorStatus.st_mode & mode_t(0o022) == 0, descriptorStatus.st_dev == pathStatus.st_dev, descriptorStatus.st_ino == pathStatus.st_ino else { throw ExecApprovalsSocketPathGuardError.lifecycleLockInvalid(path: lockPath) } guard flock(descriptor, LOCK_EX | LOCK_NB) == 0 else { throw ExecApprovalsSocketPathGuardError.lifecycleLockBusy(path: lockPath) } return ExecApprovalsSocketLifecycleLease( descriptor: descriptor, path: lockPath) } catch { close(descriptor) self.releaseProcessReservation(lockPath) throw error } } func release() { let shouldRelease = self.stateLock.withLock { () -> Bool in guard !self.released else { return false } self.released = true return true } guard shouldRelease else { return } _ = flock(self.descriptor, LOCK_UN) close(self.descriptor) Self.releaseProcessReservation(self.path) } deinit { self.release() } private static func releaseProcessReservation(_ path: String) { _ = self.processLock.withLock { self.reservedPaths.remove(path) } } } private final class ExecApprovalsSocketServer: @unchecked Sendable { private struct OpenedSocket { let fd: Int32 let identity: ExecApprovalsSocketPathIdentity let lifecycleLease: ExecApprovalsSocketLifecycleLease } private let logger = Logger(subsystem: "ai.openclaw", category: "exec-approvals.socket") private let socketPath: String private let token: String private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision? private let onExec: @Sendable (ExecHostRequest) async -> ExecHostResponse private let onUnexpectedStop: @Sendable (ExecApprovalsSocketServer) -> Void private let stateLock = NSLock() private var socketFD: Int32 = -1 private var socketIdentity: ExecApprovalsSocketPathIdentity? private var socketLifecycleLease: ExecApprovalsSocketLifecycleLease? private var acceptTask: Task? private var isRunning = false init( socketPath: String, token: String, onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision?, onExec: @escaping @Sendable (ExecHostRequest) async -> ExecHostResponse, onUnexpectedStop: @escaping @Sendable (ExecApprovalsSocketServer) -> Void) { self.socketPath = socketPath self.token = token self.onPrompt = onPrompt self.onExec = onExec self.onUnexpectedStop = onUnexpectedStop } var isListening: Bool { self.stateLock.withLock { self.isRunning && self.socketFD >= 0 } } func start() async -> Bool { let shouldStart = self.stateLock.withLock { guard !Task.isCancelled, !self.isRunning else { return false } self.isRunning = true return true } guard shouldStart else { return self.stateLock.withLock { self.socketFD >= 0 } } return await withCheckedContinuation { continuation in let task = Task.detached { [weak self] in guard let self else { continuation.resume(returning: false) return } await self.runAcceptLoop { ready in continuation.resume(returning: ready) } } self.stateLock.withLock { self.acceptTask = task if !self.isRunning { task.cancel() } } } } func stop() { let (task, fd, identity, lifecycleLease) = self.stateLock.withLock { self.isRunning = false let task = self.acceptTask self.acceptTask = nil let fd = self.socketFD self.socketFD = -1 let identity = self.socketIdentity self.socketIdentity = nil let lifecycleLease = self.socketLifecycleLease self.socketLifecycleLease = nil return (task, fd, identity, lifecycleLease) } task?.cancel() self.closeOwnedSocket( fd: fd, identity: identity, lifecycleLease: lifecycleLease) } private func runAcceptLoop(onReady: @escaping @Sendable (Bool) -> Void) async { let shouldOpen = self.stateLock.withLock { self.isRunning && !Task.isCancelled } guard shouldOpen else { self.stateLock.withLock { self.isRunning = false self.acceptTask = nil } onReady(false) return } guard let openedSocket = self.openSocket() else { self.stateLock.withLock { self.isRunning = false self.acceptTask = nil } onReady(false) return } let fd = openedSocket.fd let shouldAccept = self.stateLock.withLock { guard self.isRunning, !Task.isCancelled else { return false } self.socketFD = fd self.socketIdentity = openedSocket.identity self.socketLifecycleLease = openedSocket.lifecycleLease return true } guard shouldAccept else { self.closeOwnedSocket( fd: fd, identity: openedSocket.identity, lifecycleLease: openedSocket.lifecycleLease) onReady(false) return } onReady(true) while self.stateLock.withLock({ self.isRunning }), !Task.isCancelled { var addr = sockaddr_un() var len = socklen_t(MemoryLayout.size(ofValue: addr)) let client = withUnsafeMutablePointer(to: &addr) { ptr in ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in accept(fd, rebound, &len) } } if client < 0 { if errno == EINTR { continue } break } Task.detached { [weak self] in await self?.handleClient(fd: client) } } let termination = self.stateLock.withLock { let stoppedUnexpectedly = self.isRunning && !Task.isCancelled let ownsDescriptor = self.socketFD == fd if ownsDescriptor { self.socketFD = -1 } let identity = ownsDescriptor ? self.socketIdentity : nil let lifecycleLease = ownsDescriptor ? self.socketLifecycleLease : nil if ownsDescriptor { self.socketIdentity = nil self.socketLifecycleLease = nil } self.isRunning = false self.acceptTask = nil return ( ownsDescriptor: ownsDescriptor, identity: identity, lifecycleLease: lifecycleLease, stoppedUnexpectedly: stoppedUnexpectedly) } if termination.ownsDescriptor { self.closeOwnedSocket( fd: fd, identity: termination.identity, lifecycleLease: termination.lifecycleLease) } if termination.stoppedUnexpectedly { self.onUnexpectedStop(self) } } private func closeOwnedSocket( fd: Int32, identity: ExecApprovalsSocketPathIdentity?, lifecycleLease: ExecApprovalsSocketLifecycleLease?) { if fd >= 0 { close(fd) } if !self.socketPath.isEmpty, let identity { do { // Keep the cross-process lease through the identity check and // unlink so no replacement can bind between those operations. try ExecApprovalsSocketPathGuard.removeSocket( at: self.socketPath, ifIdentityMatches: identity) } catch { self.logger .warning("exec approvals socket cleanup failed: \(error.localizedDescription, privacy: .public)") } } lifecycleLease?.release() } #if DEBUG fileprivate func failForTesting() { let shutdown: ( task: Task?, fd: Int32, identity: ExecApprovalsSocketPathIdentity?, lifecycleLease: ExecApprovalsSocketLifecycleLease?) = self.stateLock.withLock { guard self.isRunning, self.socketFD >= 0 else { return (task: nil, fd: -1, identity: nil, lifecycleLease: nil) } self.isRunning = false let task = self.acceptTask self.acceptTask = nil let fd = self.socketFD self.socketFD = -1 let identity = self.socketIdentity self.socketIdentity = nil let lifecycleLease = self.socketLifecycleLease self.socketLifecycleLease = nil return (task: task, fd: fd, identity: identity, lifecycleLease: lifecycleLease) } guard shutdown.fd >= 0 else { return } shutdown.task?.cancel() self.closeOwnedSocket( fd: shutdown.fd, identity: shutdown.identity, lifecycleLease: shutdown.lifecycleLease) self.onUnexpectedStop(self) } #endif private func openSocket() -> OpenedSocket? { let lifecycleLease: ExecApprovalsSocketLifecycleLease do { try ExecApprovalsSocketPathGuard.hardenParentDirectory(for: self.socketPath) lifecycleLease = try ExecApprovalsSocketLifecycleLease.acquire(for: self.socketPath) try ExecApprovalsSocketPathGuard.removeExistingSocket(at: self.socketPath) } catch { self.logger .error("exec approvals socket path hardening failed: \(error.localizedDescription, privacy: .public)") return nil } let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { self.logger.error("exec approvals socket create failed") lifecycleLease.release() return nil } var addr = sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) let maxLen = MemoryLayout.size(ofValue: addr.sun_path) if self.socketPath.utf8.count >= maxLen { self.logger.error("exec approvals socket path too long") close(fd) lifecycleLease.release() return nil } self.socketPath.withCString { cstr in withUnsafeMutablePointer(to: &addr.sun_path) { ptr in let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self) memset(raw, 0, maxLen) strncpy(raw, cstr, maxLen - 1) } } let size = socklen_t(MemoryLayout.size(ofValue: addr)) let result = withUnsafePointer(to: &addr) { ptr in ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in bind(fd, rebound, size) } } if result != 0 { self.logger.error("exec approvals socket bind failed") close(fd) lifecycleLease.release() return nil } let identity: ExecApprovalsSocketPathIdentity do { guard let boundIdentity = try ExecApprovalsSocketPathGuard.socketIdentity(at: self.socketPath) else { self.logger.error("exec approvals socket identity unavailable after bind") close(fd) try? ExecApprovalsSocketPathGuard.removeExistingSocket(at: self.socketPath) lifecycleLease.release() return nil } identity = boundIdentity } catch { self.logger.error( "exec approvals socket identity failed: \(error.localizedDescription, privacy: .public)") close(fd) try? ExecApprovalsSocketPathGuard.removeExistingSocket(at: self.socketPath) lifecycleLease.release() return nil } if chmod(self.socketPath, 0o600) != 0 { self.logger.error("exec approvals socket chmod failed") self.closeOwnedSocket( fd: fd, identity: identity, lifecycleLease: lifecycleLease) return nil } if listen(fd, 16) != 0 { self.logger.error("exec approvals socket listen failed") self.closeOwnedSocket( fd: fd, identity: identity, lifecycleLease: lifecycleLease) return nil } self.logger.info("exec approvals socket listening at \(self.socketPath, privacy: .public)") return OpenedSocket( fd: fd, identity: identity, lifecycleLease: lifecycleLease) } private func handleClient(fd: Int32) async { let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) do { guard self.isAllowedPeer(fd: fd) else { try self.sendApprovalResponse(handle: handle, id: UUID().uuidString, decision: .deny) return } try configureSocketTimeouts(fd, timeoutMs: execApprovalsSocketTimeoutMs) guard let line = try readLineFromSocket(fd, maxBytes: 256_000), let data = line.data(using: .utf8) else { return } guard let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any], let type = envelope["type"] as? String else { return } if type == "request" { let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data) guard request.token == self.token else { try self.sendApprovalResponse(handle: handle, id: request.id, decision: .deny) return } guard let decision = await self.onPrompt(request.request) else { return } try self.sendApprovalResponse(handle: handle, id: request.id, decision: decision) return } if type == "exec" { let request = try JSONDecoder().decode(ExecHostSocketRequest.self, from: data) let response = await self.handleExecRequest(request) try self.sendExecResponse(handle: handle, response: response) return } } catch { self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)") } } private func sendApprovalResponse( handle: FileHandle, id: String, decision: ExecApprovalDecision) throws { let response = ExecApprovalSocketDecision(type: "decision", id: id, decision: decision) let data = try JSONEncoder().encode(response) var payload = data payload.append(0x0A) try handle.write(contentsOf: payload) } private func sendExecResponse(handle: FileHandle, response: ExecHostResponse) throws { let data = try JSONEncoder().encode(response) var payload = data payload.append(0x0A) try handle.write(contentsOf: payload) } private func isAllowedPeer(fd: Int32) -> Bool { var uid = uid_t(0) var gid = gid_t(0) if getpeereid(fd, &uid, &gid) != 0 { return false } return uid == geteuid() } private func handleExecRequest(_ request: ExecHostSocketRequest) async -> ExecHostResponse { let nowMs = Int(Date().timeIntervalSince1970 * 1000) if !execHostTimestampIsFresh(nowMs: nowMs, requestMs: request.ts) { return ExecHostResponse( type: "exec-res", id: request.id, ok: false, payload: nil, error: ExecHostError(code: "INVALID_REQUEST", message: "expired request", reason: "ttl")) } let expected = self.hmacHex(nonce: request.nonce, ts: request.ts, requestJson: request.requestJson) if !timingSafeHexStringEquals(expected, request.hmac) { return ExecHostResponse( type: "exec-res", id: request.id, ok: false, payload: nil, error: ExecHostError(code: "INVALID_REQUEST", message: "invalid auth", reason: "hmac")) } guard let requestData = request.requestJson.data(using: .utf8), let payload = try? JSONDecoder().decode(ExecHostRequest.self, from: requestData) else { return ExecHostResponse( type: "exec-res", id: request.id, ok: false, payload: nil, error: ExecHostError(code: "INVALID_REQUEST", message: "invalid payload", reason: "json")) } let response = await self.onExec(payload) return ExecHostResponse( type: "exec-res", id: request.id, ok: response.ok, payload: response.payload, error: response.error) } #if DEBUG fileprivate func testExecHostTimestampFailureReason(_ timestamp: Int) async -> String? { let response = await self.handleExecRequest(ExecHostSocketRequest( type: "exec", id: "timestamp-test", nonce: "nonce", ts: timestamp, hmac: "unauthenticated", requestJson: #"{"command":["/usr/bin/true"]}"#)) return response.error?.reason } #endif private func hmacHex(nonce: String, ts: Int, requestJson: String) -> String { let key = SymmetricKey(data: Data(self.token.utf8)) let message = "\(nonce):\(ts):\(requestJson)" let mac = HMAC.authenticationCode(for: Data(message.utf8), using: key) return mac.map { String(format: "%02x", $0) }.joined() } }