diff --git a/CHANGELOG.md b/CHANGELOG.md index 78e8e0f11..839716f3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ Docs: https://docs.clawd.bot +## 2026.1.18-3 + +### Changes +- Exec: add host/security/ask routing for gateway + node exec. +- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle. +- macOS: add approvals socket UI server + node exec lifecycle events. +- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals + +### Fixes +- Tools: return a companion-app-required message when node exec is requested with no paired node. + ## 2026.1.18-2 ### Changes diff --git a/apps/macos/Sources/Clawdbot/AppState.swift b/apps/macos/Sources/Clawdbot/AppState.swift index 22994bc25..fdb702f17 100644 --- a/apps/macos/Sources/Clawdbot/AppState.swift +++ b/apps/macos/Sources/Clawdbot/AppState.swift @@ -170,8 +170,15 @@ final class AppState { didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } } } - var systemRunPolicy: SystemRunPolicy { - didSet { self.ifNotPreview { MacNodeConfigFile.setSystemRunPolicy(self.systemRunPolicy) } } + var execApprovalMode: ExecApprovalQuickMode { + didSet { + self.ifNotPreview { + ExecApprovalsStore.updateDefaults { defaults in + defaults.security = self.execApprovalMode.security + defaults.ask = self.execApprovalMode.ask + } + } + } } /// Tracks whether the Canvas panel is currently visible (not persisted). @@ -274,7 +281,8 @@ final class AppState { self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true - self.systemRunPolicy = SystemRunPolicy.load() + let execDefaults = ExecApprovalsStore.resolveDefaults() + self.execApprovalMode = ExecApprovalQuickMode.from(security: execDefaults.security, ask: execDefaults.ask) self.peekabooBridgeEnabled = UserDefaults.standard .object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true if !self.isPreview { diff --git a/apps/macos/Sources/Clawdbot/ExecApprovals.swift b/apps/macos/Sources/Clawdbot/ExecApprovals.swift new file mode 100644 index 000000000..03e552bdb --- /dev/null +++ b/apps/macos/Sources/Clawdbot/ExecApprovals.swift @@ -0,0 +1,566 @@ +import Foundation +import OSLog +import Security + +enum ExecSecurity: String, CaseIterable, Codable, Identifiable { + case deny + case allowlist + case full + + var id: String { self.rawValue } + + var title: String { + switch self { + case .deny: "Deny" + case .allowlist: "Allowlist" + case .full: "Always Allow" + } + } +} + +enum ExecApprovalQuickMode: String, CaseIterable, Identifiable { + case deny + case ask + case allow + + var id: String { self.rawValue } + + var title: String { + switch self { + case .deny: "Deny" + case .ask: "Always Ask" + case .allow: "Always Allow" + } + } + + var security: ExecSecurity { + switch self { + case .deny: .deny + case .ask: .allowlist + case .allow: .full + } + } + + var ask: ExecAsk { + switch self { + case .deny: .off + case .ask: .onMiss + case .allow: .off + } + } + + static func from(security: ExecSecurity, ask: ExecAsk) -> ExecApprovalQuickMode { + switch security { + case .deny: + return .deny + case .full: + return .allow + case .allowlist: + return .ask + } + } +} + +enum ExecAsk: String, CaseIterable, Codable, Identifiable { + case off + case onMiss = "on-miss" + case always + + var id: String { self.rawValue } + + var title: String { + switch self { + case .off: "Never Ask" + case .onMiss: "Ask on Allowlist Miss" + case .always: "Always Ask" + } + } +} + +enum ExecApprovalDecision: String, Codable, Sendable { + case allowOnce = "allow-once" + case allowAlways = "allow-always" + case deny +} + +struct ExecAllowlistEntry: Codable, Hashable { + var pattern: String + var lastUsedAt: Double? = nil + var lastUsedCommand: String? = nil + var lastResolvedPath: String? = nil +} + +struct ExecApprovalsDefaults: Codable { + var security: ExecSecurity? + var ask: ExecAsk? + var askFallback: ExecSecurity? + var autoAllowSkills: Bool? +} + +struct ExecApprovalsAgent: Codable { + var security: ExecSecurity? + var ask: ExecAsk? + var askFallback: ExecSecurity? + var autoAllowSkills: Bool? + var allowlist: [ExecAllowlistEntry]? + + var isEmpty: Bool { + security == nil && ask == nil && askFallback == nil && autoAllowSkills == nil && (allowlist?.isEmpty ?? true) + } +} + +struct ExecApprovalsSocketConfig: Codable { + var path: String? + var token: String? +} + +struct ExecApprovalsFile: Codable { + var version: Int + var socket: ExecApprovalsSocketConfig? + var defaults: ExecApprovalsDefaults? + var agents: [String: ExecApprovalsAgent]? +} + +struct ExecApprovalsResolved { + let url: URL + let socketPath: String + let token: String + let defaults: ExecApprovalsResolvedDefaults + let agent: ExecApprovalsResolvedDefaults + let allowlist: [ExecAllowlistEntry] + var file: ExecApprovalsFile +} + +struct ExecApprovalsResolvedDefaults { + var security: ExecSecurity + var ask: ExecAsk + var askFallback: ExecSecurity + var autoAllowSkills: Bool +} + +enum ExecApprovalsStore { + private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals") + private static let defaultSecurity: ExecSecurity = .deny + private static let defaultAsk: ExecAsk = .onMiss + private static let defaultAskFallback: ExecSecurity = .deny + private static let defaultAutoAllowSkills = false + + static func fileURL() -> URL { + ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.json") + } + + static func socketPath() -> String { + ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path + } + + static func loadFile() -> ExecApprovalsFile { + let url = self.fileURL() + guard FileManager.default.fileExists(atPath: url.path) else { + return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) + } + do { + let data = try Data(contentsOf: url) + let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data) + if decoded.version != 1 { + return ExecApprovalsFile(version: 1, socket: decoded.socket, defaults: decoded.defaults, agents: decoded.agents) + } + return decoded + } catch { + self.logger.warning("exec approvals load failed: \(error.localizedDescription, privacy: .public)") + return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) + } + } + + static func saveFile(_ file: ExecApprovalsFile) { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(file) + let url = self.fileURL() + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: url, options: [.atomic]) + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } catch { + self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)") + } + } + + static func ensureFile() -> ExecApprovalsFile { + var file = self.loadFile() + if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) } + let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if path.isEmpty { + file.socket?.path = self.socketPath() + } + let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if token.isEmpty { + file.socket?.token = self.generateToken() + } + if file.agents == nil { file.agents = [:] } + self.saveFile(file) + return file + } + + static func resolve(agentId: String?) -> ExecApprovalsResolved { + var file = self.ensureFile() + let defaults = file.defaults ?? ExecApprovalsDefaults() + let resolvedDefaults = ExecApprovalsResolvedDefaults( + security: defaults.security ?? self.defaultSecurity, + ask: defaults.ask ?? self.defaultAsk, + askFallback: defaults.askFallback ?? self.defaultAskFallback, + autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills) + let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? agentId!.trimmingCharacters(in: .whitespacesAndNewlines) + : "default" + let agentEntry = file.agents?[key] ?? ExecApprovalsAgent() + let resolvedAgent = ExecApprovalsResolvedDefaults( + security: agentEntry.security ?? resolvedDefaults.security, + ask: agentEntry.ask ?? resolvedDefaults.ask, + askFallback: agentEntry.askFallback ?? resolvedDefaults.askFallback, + autoAllowSkills: agentEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills) + let allowlist = (agentEntry.allowlist ?? []) + .map { entry in + ExecAllowlistEntry( + pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines), + lastUsedAt: entry.lastUsedAt, + lastUsedCommand: entry.lastUsedCommand, + lastResolvedPath: entry.lastResolvedPath) + } + .filter { !$0.pattern.isEmpty } + let socketPath = self.expandPath(file.socket?.path ?? self.socketPath()) + let token = file.socket?.token ?? "" + return ExecApprovalsResolved( + url: self.fileURL(), + socketPath: socketPath, + token: token, + defaults: resolvedDefaults, + agent: resolvedAgent, + allowlist: allowlist, + file: file) + } + + static func resolveDefaults() -> ExecApprovalsResolvedDefaults { + let file = self.ensureFile() + let defaults = file.defaults ?? ExecApprovalsDefaults() + return ExecApprovalsResolvedDefaults( + security: defaults.security ?? self.defaultSecurity, + ask: defaults.ask ?? self.defaultAsk, + askFallback: defaults.askFallback ?? self.defaultAskFallback, + autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills) + } + + static func saveDefaults(_ defaults: ExecApprovalsDefaults) { + self.updateFile { file in + file.defaults = defaults + } + } + + static func updateDefaults(_ mutate: (inout ExecApprovalsDefaults) -> Void) { + self.updateFile { file in + var defaults = file.defaults ?? ExecApprovalsDefaults() + mutate(&defaults) + file.defaults = defaults + } + } + + static func saveAgent(_ agent: ExecApprovalsAgent, agentId: String?) { + self.updateFile { file in + var agents = file.agents ?? [:] + let key = self.agentKey(agentId) + if agent.isEmpty { + agents.removeValue(forKey: key) + } else { + agents[key] = agent + } + file.agents = agents.isEmpty ? nil : agents + } + } + + static func addAllowlistEntry(agentId: String?, pattern: String) { + let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + self.updateFile { file in + let key = self.agentKey(agentId) + var agents = file.agents ?? [:] + var entry = agents[key] ?? ExecApprovalsAgent() + var allowlist = entry.allowlist ?? [] + if allowlist.contains(where: { $0.pattern == trimmed }) { return } + allowlist.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: Date().timeIntervalSince1970 * 1000)) + entry.allowlist = allowlist + agents[key] = entry + file.agents = agents + } + } + + static func recordAllowlistUse( + agentId: String?, + pattern: String, + command: String, + resolvedPath: String?) + { + self.updateFile { file in + let key = self.agentKey(agentId) + var agents = file.agents ?? [:] + var entry = agents[key] ?? ExecApprovalsAgent() + let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in + guard item.pattern == pattern else { return item } + return ExecAllowlistEntry( + pattern: item.pattern, + lastUsedAt: Date().timeIntervalSince1970 * 1000, + lastUsedCommand: command, + lastResolvedPath: resolvedPath) + } + entry.allowlist = allowlist + agents[key] = entry + file.agents = agents + } + } + + static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) { + self.updateFile { file in + let key = self.agentKey(agentId) + var agents = file.agents ?? [:] + var entry = agents[key] ?? ExecApprovalsAgent() + let cleaned = allowlist + .map { item in + ExecAllowlistEntry( + pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines), + lastUsedAt: item.lastUsedAt, + lastUsedCommand: item.lastUsedCommand, + lastResolvedPath: item.lastResolvedPath) + } + .filter { !$0.pattern.isEmpty } + entry.allowlist = cleaned + agents[key] = entry + file.agents = agents + } + } + + static func updateAgentSettings(agentId: String?, mutate: (inout ExecApprovalsAgent) -> Void) { + self.updateFile { file in + let key = self.agentKey(agentId) + var agents = file.agents ?? [:] + var entry = agents[key] ?? ExecApprovalsAgent() + mutate(&entry) + if entry.isEmpty { + agents.removeValue(forKey: key) + } else { + agents[key] = entry + } + file.agents = agents.isEmpty ? nil : agents + } + } + + private static func updateFile(_ mutate: (inout ExecApprovalsFile) -> Void) { + var file = self.ensureFile() + mutate(&file) + self.saveFile(file) + } + + private static func generateToken() -> String { + var bytes = [UInt8](repeating: 0, count: 24) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + if status == errSecSuccess { + return Data(bytes) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + return UUID().uuidString + } + + private static func expandPath(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed == "~" { + return FileManager.default.homeDirectoryForCurrentUser.path + } + if trimmed.hasPrefix("~/") { + let suffix = trimmed.dropFirst(2) + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(String(suffix)).path + } + return trimmed + } + + private static func agentKey(_ agentId: String?) -> String { + let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? "default" : trimmed + } +} + +struct ExecCommandResolution: Sendable { + let rawExecutable: String + let resolvedPath: String? + let executableName: String + let cwd: String? + + static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? { + guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + let expanded = raw.hasPrefix("~") ? (raw as NSString).expandingTildeInPath : raw + let hasPathSeparator = expanded.contains("/") || expanded.contains("\\") + let resolvedPath: String? = { + if hasPathSeparator { + if expanded.hasPrefix("/") { + return expanded + } + let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) + let root = (base?.isEmpty == false) ? base! : FileManager.default.currentDirectoryPath + return URL(fileURLWithPath: root).appendingPathComponent(expanded).path + } + let searchPaths = self.searchPaths(from: env) + return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths) + }() + let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded + return ExecCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd) + } + + private static func searchPaths(from env: [String: String]?) -> [String] { + let raw = env?["PATH"] + if let raw, !raw.isEmpty { + return raw.split(separator: ":").map(String.init) + } + return CommandResolver.preferredPaths() + } +} + +enum ExecCommandFormatter { + static func displayString(for argv: [String]) -> String { + argv.map { arg in + let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "\"\"" } + let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" } + if !needsQuotes { return trimmed } + let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + }.joined(separator: " ") + } +} + +enum ExecAllowlistMatcher { + static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? { + guard let resolution, !entries.isEmpty else { return nil } + let rawExecutable = resolution.rawExecutable + let resolvedPath = resolution.resolvedPath + let executableName = resolution.executableName + + for entry in entries { + let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines) + if pattern.isEmpty { continue } + let hasPath = pattern.contains("/") || pattern.contains("~") || pattern.contains("\\") + if hasPath { + let target = resolvedPath ?? rawExecutable + if self.matches(pattern: pattern, target: target) { return entry } + } else if self.matches(pattern: pattern, target: executableName) { + return entry + } + } + return nil + } + + private static func matches(pattern: String, target: String) -> Bool { + let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed + let normalizedPattern = self.normalizeMatchTarget(expanded) + let normalizedTarget = self.normalizeMatchTarget(target) + guard let regex = self.regex(for: normalizedPattern) else { return false } + let range = NSRange(location: 0, length: normalizedTarget.utf16.count) + return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil + } + + private static func normalizeMatchTarget(_ value: String) -> String { + value.replacingOccurrences(of: "\\\\", with: "/").lowercased() + } + + private static func regex(for pattern: String) -> NSRegularExpression? { + var regex = "^" + var idx = pattern.startIndex + while idx < pattern.endIndex { + let ch = pattern[idx] + if ch == "*" { + let next = pattern.index(after: idx) + if next < pattern.endIndex, pattern[next] == "*" { + regex += ".*" + idx = pattern.index(after: next) + } else { + regex += "[^/]*" + idx = next + } + continue + } + if ch == "?" { + regex += "." + idx = pattern.index(after: idx) + continue + } + regex += NSRegularExpression.escapedPattern(for: String(ch)) + idx = pattern.index(after: idx) + } + regex += "$" + return try? NSRegularExpression(pattern: regex, options: [.caseInsensitive]) + } +} + +struct ExecEventPayload: Codable, Sendable { + var sessionKey: String + var runId: String + var host: String + var command: String? + var exitCode: Int? + var timedOut: Bool? + var success: Bool? + var output: String? + var reason: String? + + static func truncateOutput(_ raw: String, maxChars: Int = 20_000) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed.count <= maxChars { return trimmed } + let suffix = trimmed.suffix(maxChars) + return "… (truncated) \(suffix)" + } +} + +actor SkillBinsCache { + static let shared = SkillBinsCache() + + private var bins: Set = [] + private var lastRefresh: Date? + private let refreshInterval: TimeInterval = 90 + + func currentBins(force: Bool = false) async -> Set { + if force || self.isStale() { + await self.refresh() + } + return self.bins + } + + func refresh() async { + do { + let report = try await GatewayConnection.shared.skillsStatus() + var next = Set() + for skill in report.skills { + for bin in skill.requirements.bins { + let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { next.insert(trimmed) } + } + } + self.bins = next + self.lastRefresh = Date() + } catch { + if self.lastRefresh == nil { + self.bins = [] + } + } + } + + private func isStale() -> Bool { + guard let lastRefresh else { return true } + return Date().timeIntervalSince(lastRefresh) > self.refreshInterval + } +} diff --git a/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift b/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift new file mode 100644 index 000000000..e9421cdd5 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift @@ -0,0 +1,359 @@ +import AppKit +import ClawdbotKit +import Darwin +import Foundation +import OSLog + +struct ExecApprovalPromptRequest: Codable, Sendable { + var command: String + var cwd: String? + var host: String? + var security: String? + var ask: String? + var agentId: String? + var resolvedPath: String? +} + +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 +} + +enum ExecApprovalsSocketClient { + private struct TimeoutError: LocalizedError { + var message: String + var errorDescription: String? { message } + } + + static func requestDecision( + socketPath: String, + token: String, + request: ExecApprovalPromptRequest, + timeoutMs: Int = 15_000) 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) + }.value + }) + } catch { + return nil + } + } + + private static func requestDecisionSync( + socketPath: String, + token: String, + request: ExecApprovalPromptRequest) 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", + ]) + } + + 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: true) + + 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 self.readLine(from: handle, maxBytes: 256_000), + let lineData = line.data(using: .utf8) + else { return nil } + let response = try JSONDecoder().decode(ExecApprovalSocketDecision.self, from: lineData) + return response.decision + } + + private static func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? { + var buffer = Data() + while buffer.count < maxBytes { + let chunk = try handle.read(upToCount: 4096) ?? Data() + if chunk.isEmpty { break } + buffer.append(chunk) + 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.. ExecApprovalDecision { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Allow this command?" + + var details = "Clawdbot wants to run:\n\n\(request.command)" + let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedCwd.isEmpty { + details += "\n\nWorking directory:\n\(trimmedCwd)" + } + let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedAgent.isEmpty { + details += "\n\nAgent:\n\(trimmedAgent)" + } + let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedPath.isEmpty { + details += "\n\nExecutable:\n\(trimmedPath)" + } + let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedHost.isEmpty { + details += "\n\nHost:\n\(trimmedHost)" + } + if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty { + details += "\n\nSecurity:\n\(security)" + } + if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty { + details += "\nAsk mode:\n\(ask)" + } + details += "\n\nThis runs on this machine." + alert.informativeText = details + + alert.addButton(withTitle: "Allow Once") + alert.addButton(withTitle: "Always Allow") + alert.addButton(withTitle: "Don't Allow") + + switch alert.runModal() { + case .alertFirstButtonReturn: + return .allowOnce + case .alertSecondButtonReturn: + return .allowAlways + default: + return .deny + } + } +} + +private final class ExecApprovalsSocketServer { + private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket") + private let socketPath: String + private let token: String + private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision + private var socketFD: Int32 = -1 + private var acceptTask: Task? + private var isRunning = false + + init( + socketPath: String, + token: String, + onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision) + { + self.socketPath = socketPath + self.token = token + self.onPrompt = onPrompt + } + + func start() { + guard !self.isRunning else { return } + self.isRunning = true + self.acceptTask = Task.detached { [weak self] in + await self?.runAcceptLoop() + } + } + + func stop() { + self.isRunning = false + self.acceptTask?.cancel() + self.acceptTask = nil + if self.socketFD >= 0 { + close(self.socketFD) + self.socketFD = -1 + } + if !self.socketPath.isEmpty { + unlink(self.socketPath) + } + } + + private func runAcceptLoop() async { + let fd = self.openSocket() + guard fd >= 0 else { + self.isRunning = false + return + } + self.socketFD = fd + while self.isRunning { + 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) + } + } + } + + private func openSocket() -> Int32 { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + self.logger.error("exec approvals socket create failed") + return -1 + } + unlink(self.socketPath) + 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) + return -1 + } + 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) + return -1 + } + if listen(fd, 16) != 0 { + self.logger.error("exec approvals socket listen failed") + close(fd) + return -1 + } + chmod(self.socketPath, 0o600) + self.logger.info("exec approvals socket listening at \(self.socketPath, privacy: .public)") + return fd + } + + private func handleClient(fd: Int32) async { + let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) + do { + guard let line = try self.readLine(from: handle, maxBytes: 256_000), + let data = line.data(using: .utf8) + else { + return + } + let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data) + guard request.type == "request", request.token == self.token else { + let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: .deny) + let data = try JSONEncoder().encode(response) + var payload = data + payload.append(0x0A) + try handle.write(contentsOf: payload) + return + } + let decision = await self.onPrompt(request.request) + let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: decision) + let responseData = try JSONEncoder().encode(response) + var payload = responseData + payload.append(0x0A) + try handle.write(contentsOf: payload) + } catch { + self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? { + var buffer = Data() + while buffer.count < maxBytes { + let chunk = try handle.read(upToCount: 4096) ?? Data() + if chunk.isEmpty { break } + buffer.append(chunk) + 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.. URL { - ClawdbotPaths.stateDirURL.appendingPathComponent("macos-node.json") - } - - static func loadDict() -> [String: Any] { - let url = self.url() - guard FileManager.default.fileExists(atPath: url.path) else { return [:] } - do { - let data = try Data(contentsOf: url) - guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - self.logger.warning("mac node config JSON root invalid") - return [:] - } - return root - } catch { - self.logger.warning("mac node config read failed: \(error.localizedDescription, privacy: .public)") - return [:] - } - } - - static func saveDict(_ dict: [String: Any]) { - do { - let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys]) - let url = self.url() - try FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - try data.write(to: url, options: [.atomic]) - try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) - } catch { - self.logger.error("mac node config save failed: \(error.localizedDescription, privacy: .public)") - } - } - - private static func systemRunSection(from root: [String: Any]) -> [String: Any] { - root["systemRun"] as? [String: Any] ?? [:] - } - - private static func updateSystemRunSection(_ mutate: (inout [String: Any]) -> Void) { - var root = self.loadDict() - var systemRun = self.systemRunSection(from: root) - mutate(&systemRun) - if systemRun.isEmpty { - root.removeValue(forKey: "systemRun") - } else { - root["systemRun"] = systemRun - } - self.saveDict(root) - } - - private static func agentSection(_ systemRun: [String: Any], agentId: String) -> [String: Any]? { - let agents = systemRun["agents"] as? [String: Any] - return agents?[agentId] as? [String: Any] - } - - private static func updateAgentSection( - _ systemRun: inout [String: Any], - agentId: String, - mutate: (inout [String: Any]) -> Void) - { - var agents = systemRun["agents"] as? [String: Any] ?? [:] - var entry = agents[agentId] as? [String: Any] ?? [:] - mutate(&entry) - if entry.isEmpty { - agents.removeValue(forKey: agentId) - } else { - agents[agentId] = entry - } - if agents.isEmpty { - systemRun.removeValue(forKey: "agents") - } else { - systemRun["agents"] = agents - } - } - - static func systemRunPolicy(agentId: String? = nil) -> SystemRunPolicy? { - let root = self.loadDict() - let systemRun = self.systemRunSection(from: root) - if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) { - let raw = agent["policy"] as? String - if let raw, let policy = SystemRunPolicy(rawValue: raw) { return policy } - } - let raw = systemRun["policy"] as? String - guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil } - return policy - } - - static func setSystemRunPolicy(_ policy: SystemRunPolicy, agentId: String? = nil) { - self.updateSystemRunSection { systemRun in - if let agentId { - self.updateAgentSection(&systemRun, agentId: agentId) { entry in - entry["policy"] = policy.rawValue - } - return - } - systemRun["policy"] = policy.rawValue - } - } - - static func systemRunAutoAllowSkills(agentId: String?) -> Bool? { - let root = self.loadDict() - let systemRun = self.systemRunSection(from: root) - if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) { - if let value = agent["autoAllowSkills"] as? Bool { return value } - } - return systemRun["autoAllowSkills"] as? Bool - } - - static func setSystemRunAutoAllowSkills(_ enabled: Bool, agentId: String?) { - self.updateSystemRunSection { systemRun in - if let agentId { - self.updateAgentSection(&systemRun, agentId: agentId) { entry in - entry["autoAllowSkills"] = enabled - } - return - } - systemRun["autoAllowSkills"] = enabled - } - } - - static func systemRunAllowlist(agentId: String?) -> [SystemRunAllowlistEntry]? { - let root = self.loadDict() - let systemRun = self.systemRunSection(from: root) - let raw: [Any]? = { - if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) { - return agent["allowlist"] as? [Any] - } - return systemRun["allowlist"] as? [Any] - }() - guard let raw else { return nil } - - if raw.allSatisfy({ $0 is String }) { - let legacy = raw.compactMap { $0 as? String } - return legacy.compactMap { key in - let pattern = key.trimmingCharacters(in: .whitespacesAndNewlines) - guard !pattern.isEmpty else { return nil } - return SystemRunAllowlistEntry( - pattern: pattern, - enabled: true, - matchKind: .argv, - source: .manual) - } - } - - return raw.compactMap { item in - guard let dict = item as? [String: Any] else { return nil } - return SystemRunAllowlistEntry(dict: dict) - } - } - - static func setSystemRunAllowlist(_ allowlist: [SystemRunAllowlistEntry], agentId: String?) { - let cleaned = allowlist - .map { $0 } - .filter { !$0.pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - let raw = cleaned.map { $0.asDict() } - self.updateSystemRunSection { systemRun in - if let agentId { - self.updateAgentSection(&systemRun, agentId: agentId) { entry in - if raw.isEmpty { - entry.removeValue(forKey: "allowlist") - } else { - entry["allowlist"] = raw - } - } - return - } - if raw.isEmpty { - systemRun.removeValue(forKey: "allowlist") - } else { - systemRun["allowlist"] = raw - } - } - } - - static func systemRunAllowlistStrings() -> [String]? { - let root = self.loadDict() - let systemRun = self.systemRunSection(from: root) - return systemRun["allowlist"] as? [String] - } - - static func setSystemRunAllowlistStrings(_ allowlist: [String]) { - let cleaned = allowlist - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - self.updateSystemRunSection { systemRun in - if cleaned.isEmpty { - systemRun.removeValue(forKey: "allowlist") - } else { - systemRun["allowlist"] = cleaned - } - } - } -} diff --git a/apps/macos/Sources/Clawdbot/MenuBar.swift b/apps/macos/Sources/Clawdbot/MenuBar.swift index 696322648..01c626f0d 100644 --- a/apps/macos/Sources/Clawdbot/MenuBar.swift +++ b/apps/macos/Sources/Clawdbot/MenuBar.swift @@ -256,6 +256,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } TerminationSignalWatcher.shared.start() NodePairingApprovalPrompter.shared.start() + ExecApprovalsPromptServer.shared.start() MacNodeModeCoordinator.shared.start() VoiceWakeGlobalSettingsSync.shared.start() Task { PresenceReporter.shared.start() } @@ -280,6 +281,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { PresenceReporter.shared.stop() NodePairingApprovalPrompter.shared.stop() + ExecApprovalsPromptServer.shared.stop() MacNodeModeCoordinator.shared.stop() TerminationSignalWatcher.shared.stop() VoiceWakeGlobalSettingsSync.shared.stop() diff --git a/apps/macos/Sources/Clawdbot/MenuContentView.swift b/apps/macos/Sources/Clawdbot/MenuContentView.swift index 34773bd8b..049e1de9e 100644 --- a/apps/macos/Sources/Clawdbot/MenuContentView.swift +++ b/apps/macos/Sources/Clawdbot/MenuContentView.swift @@ -31,10 +31,10 @@ struct MenuContent: View { self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled) } - private var systemRunPolicyBinding: Binding { + private var execApprovalModeBinding: Binding { Binding( - get: { self.state.systemRunPolicy }, - set: { self.state.systemRunPolicy = $0 }) + get: { self.state.execApprovalMode }, + set: { self.state.execApprovalMode = $0 }) } var body: some View { @@ -74,12 +74,12 @@ struct MenuContent: View { Toggle(isOn: self.$cameraEnabled) { Label("Allow Camera", systemImage: "camera") } - Picker(selection: self.systemRunPolicyBinding) { - ForEach(SystemRunPolicy.allCases) { policy in - Text(policy.title).tag(policy) + Picker(selection: self.execApprovalModeBinding) { + ForEach(ExecApprovalQuickMode.allCases) { mode in + Text(mode.title).tag(mode) } } label: { - Label("Node Run Commands", systemImage: "terminal") + Label("Exec Approvals", systemImage: "terminal") } Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) { Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis") diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift index 7bfa8c15b..dc2c8c168 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift @@ -43,7 +43,6 @@ final class MacNodeModeCoordinator { private func run() async { var retryDelay: UInt64 = 1_000_000_000 var lastCameraEnabled: Bool? - var lastSystemRunPolicy: SystemRunPolicy? let defaults = UserDefaults.standard while !Task.isCancelled { if await MainActor.run(body: { AppStateStore.shared.isPaused }) { @@ -60,15 +59,6 @@ final class MacNodeModeCoordinator { try? await Task.sleep(nanoseconds: 200_000_000) } - let systemRunPolicy = SystemRunPolicy.load() - if lastSystemRunPolicy == nil { - lastSystemRunPolicy = systemRunPolicy - } else if lastSystemRunPolicy != systemRunPolicy { - lastSystemRunPolicy = systemRunPolicy - await self.session.disconnect() - try? await Task.sleep(nanoseconds: 200_000_000) - } - guard let target = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else { try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000)) retryDelay = min(retryDelay * 2, 10_000_000_000) @@ -89,8 +79,13 @@ final class MacNodeModeCoordinator { if let mainSessionKey { await self?.runtime.updateMainSessionKey(mainSessionKey) } + await self?.runtime.setEventSender { [weak self] event, payload in + guard let self else { return } + try? await self.session.sendEvent(event: event, payloadJSON: payload) + } }, - onDisconnected: { reason in + onDisconnected: { [weak self] reason in + await self?.runtime.setEventSender(nil) await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason) }, onInvoke: { [weak self] req in @@ -161,13 +156,10 @@ final class MacNodeModeCoordinator { ClawdbotCanvasA2UICommand.reset.rawValue, MacNodeScreenCommand.record.rawValue, ClawdbotSystemCommand.notify.rawValue, + ClawdbotSystemCommand.which.rawValue, + ClawdbotSystemCommand.run.rawValue, ] - if SystemRunPolicy.load() != .never { - commands.append(ClawdbotSystemCommand.which.rawValue) - commands.append(ClawdbotSystemCommand.run.rawValue) - } - let capsSet = Set(caps) if capsSet.contains(ClawdbotCapability.camera.rawValue) { commands.append(ClawdbotCameraCommand.list.rawValue) diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift index 3898473f4..710a125b1 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift @@ -8,6 +8,7 @@ actor MacNodeRuntime { private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)? private var mainSessionKey: String = "main" + private var eventSender: (@Sendable (String, String?) async -> Void)? init( makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = { @@ -23,6 +24,10 @@ actor MacNodeRuntime { self.mainSessionKey = trimmed } + func setEventSender(_ sender: (@Sendable (String, String?) async -> Void)?) { + self.eventSender = sender + } + func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { let command = req.command if self.isCanvasCommand(command), !Self.canvasEnabled() { @@ -430,14 +435,19 @@ actor MacNodeRuntime { let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent - let policy = SystemRunPolicy.load(agentId: agentId) - let allowlistEntries = SystemRunAllowlistStore.load(agentId: agentId) - let resolution = SystemRunCommandResolution.resolve(command: command, cwd: params.cwd) - let allowlistMatch = SystemRunAllowlistStore.match( - command: command, - resolution: resolution, - entries: allowlistEntries) - let autoAllowSkills = MacNodeConfigFile.systemRunAutoAllowSkills(agentId: agentId) ?? false + let approvals = ExecApprovalsStore.resolve(agentId: agentId) + let security = approvals.agent.security + let ask = approvals.agent.ask + let askFallback = approvals.agent.askFallback + let autoAllowSkills = approvals.agent.autoAllowSkills + let sessionKey = (params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines) + : self.mainSessionKey + let runId = UUID().uuidString + let resolution = ExecCommandResolution.resolve(command: command, cwd: params.cwd, env: params.env) + let allowlistMatch = security == .allowlist + ? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution) + : nil let skillAllow: Bool if autoAllowSkills, let name = resolution?.executableName { let bins = await SkillBinsCache.shared.currentBins() @@ -446,55 +456,90 @@ actor MacNodeRuntime { skillAllow = false } - let shouldPrompt: Bool = { - if policy == .never { return false } - if allowlistMatch != nil { return false } - if skillAllow { return false } - return policy == .ask - }() - - switch policy { - case .never: + if security == .deny { + await self.emitExecEvent( + "exec.denied", + payload: ExecEventPayload( + sessionKey: sessionKey, + runId: runId, + host: "node", + command: ExecCommandFormatter.displayString(for: command), + reason: "security=deny")) return Self.errorResponse( req, code: .unavailable, - message: "SYSTEM_RUN_DISABLED: policy=never") - case .always: - break - case .ask: - if shouldPrompt { - let services = await self.mainActorServices() - let decision = await services.confirmSystemRun(context: SystemRunPromptContext( - command: SystemRunAllowlist.displayString(for: command), + message: "SYSTEM_RUN_DISABLED: security=deny") + } + + let requiresAsk: Bool = { + if ask == .always { return true } + if ask == .onMiss && security == .allowlist && allowlistMatch == nil && !skillAllow { return true } + return false + }() + + if requiresAsk { + let decision = await ExecApprovalsSocketClient.requestDecision( + socketPath: approvals.socketPath, + token: approvals.token, + request: ExecApprovalPromptRequest( + command: ExecCommandFormatter.displayString(for: command), cwd: params.cwd, + host: "node", + security: security.rawValue, + ask: ask.rawValue, agentId: agentId, - executablePath: resolution?.resolvedPath)) - switch decision { - case .allowOnce: - break - case .allowAlways: - if let resolvedPath = resolution?.resolvedPath, !resolvedPath.isEmpty { - _ = SystemRunAllowlistStore.add(pattern: resolvedPath, agentId: agentId) - } else if let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), - !raw.isEmpty - { - _ = SystemRunAllowlistStore.add(pattern: raw, agentId: agentId) - } - case .deny: + resolvedPath: resolution?.resolvedPath)) + + switch decision { + case .deny?: + await self.emitExecEvent( + "exec.denied", + payload: ExecEventPayload( + sessionKey: sessionKey, + runId: runId, + host: "node", + command: ExecCommandFormatter.displayString(for: command), + reason: "user-denied")) + return Self.errorResponse( + req, + code: .unavailable, + message: "SYSTEM_RUN_DENIED: user denied") + case nil: + if askFallback == .deny || (askFallback == .allowlist && allowlistMatch == nil && !skillAllow) { + await self.emitExecEvent( + "exec.denied", + payload: ExecEventPayload( + sessionKey: sessionKey, + runId: runId, + host: "node", + command: ExecCommandFormatter.displayString(for: command), + reason: "approval-required")) return Self.errorResponse( req, code: .unavailable, - message: "SYSTEM_RUN_DENIED: user denied") + message: "SYSTEM_RUN_DENIED: approval required") } + case .allowAlways?: + if security == .allowlist { + let pattern = resolution?.resolvedPath ?? + resolution?.rawExecutable ?? + command.first?.trimmingCharacters(in: .whitespacesAndNewlines) ?? + "" + if !pattern.isEmpty { + ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern) + } + } + case .allowOnce?: + break } } if let match = allowlistMatch { - SystemRunAllowlistStore.markUsed( - entryId: match.id, - command: command, - resolvedPath: resolution?.resolvedPath, - agentId: agentId) + ExecApprovalsStore.recordAllowlistUse( + agentId: agentId, + pattern: match.pattern, + command: ExecCommandFormatter.displayString(for: command), + resolvedPath: resolution?.resolvedPath) } let env = Self.sanitizedEnv(params.env) @@ -503,6 +548,14 @@ actor MacNodeRuntime { let authorized = await PermissionManager .status([.screenRecording])[.screenRecording] ?? false if !authorized { + await self.emitExecEvent( + "exec.denied", + payload: ExecEventPayload( + sessionKey: sessionKey, + runId: runId, + host: "node", + command: ExecCommandFormatter.displayString(for: command), + reason: "permission:screenRecording")) return Self.errorResponse( req, code: .unavailable, @@ -511,11 +564,30 @@ actor MacNodeRuntime { } let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 } + await self.emitExecEvent( + "exec.started", + payload: ExecEventPayload( + sessionKey: sessionKey, + runId: runId, + host: "node", + command: ExecCommandFormatter.displayString(for: command))) let result = await ShellExecutor.runDetailed( command: command, cwd: params.cwd, env: env, timeout: timeoutSec) + let combined = [result.stdout, result.stderr, result.errorMessage].filter { !$0.isEmpty }.joined(separator: "\n") + await self.emitExecEvent( + "exec.finished", + payload: ExecEventPayload( + sessionKey: sessionKey, + runId: runId, + host: "node", + command: ExecCommandFormatter.displayString(for: command), + exitCode: result.exitCode, + timedOut: result.timedOut, + success: result.success, + output: ExecEventPayload.truncateOutput(combined))) struct RunPayload: Encodable { var exitCode: Int? @@ -563,6 +635,16 @@ actor MacNodeRuntime { return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) } + private func emitExecEvent(_ event: String, payload: ExecEventPayload) async { + guard let sender = self.eventSender else { return } + guard let data = try? JSONEncoder().encode(payload), + let json = String(data: data, encoding: .utf8) + else { + return + } + await sender(event, json) + } + private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON) let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) @@ -629,10 +711,6 @@ actor MacNodeRuntime { UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false } - private nonisolated static func systemRunPolicy() -> SystemRunPolicy { - SystemRunPolicy.load() - } - private static let blockedEnvKeys: Set = [ "PATH", "NODE_OPTIONS", diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift index 7b5ce9b48..ef115b178 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift @@ -1,21 +1,7 @@ -import AppKit import ClawdbotKit import CoreLocation import Foundation -enum SystemRunDecision: Sendable { - case allowOnce - case allowAlways - case deny -} - -struct SystemRunPromptContext: Sendable { - let command: String - let cwd: String? - let agentId: String? - let executablePath: String? -} - @MainActor protocol MacNodeRuntimeMainActorServices: Sendable { func recordScreen( @@ -31,8 +17,6 @@ protocol MacNodeRuntimeMainActorServices: Sendable { desiredAccuracy: ClawdbotLocationAccuracy, maxAgeMs: Int?, timeoutMs: Int?) async throws -> CLLocation - - func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision } @MainActor @@ -74,38 +58,4 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices timeoutMs: timeoutMs) } - func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision { - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = "Allow this command?" - - var details = "Clawdbot wants to run:\n\n\(context.command)" - let trimmedCwd = context.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmedCwd.isEmpty { - details += "\n\nWorking directory:\n\(trimmedCwd)" - } - let trimmedAgent = context.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmedAgent.isEmpty { - details += "\n\nAgent:\n\(trimmedAgent)" - } - let trimmedPath = context.executablePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmedPath.isEmpty { - details += "\n\nExecutable:\n\(trimmedPath)" - } - details += "\n\nThis runs on this Mac via node mode." - alert.informativeText = details - - alert.addButton(withTitle: "Allow Once") - alert.addButton(withTitle: "Always Allow") - alert.addButton(withTitle: "Don't Allow") - - switch alert.runModal() { - case .alertFirstButtonReturn: - return .allowOnce - case .alertSecondButtonReturn: - return .allowAlways - default: - return .deny - } - } } diff --git a/apps/macos/Sources/Clawdbot/SystemRunApprovals.swift b/apps/macos/Sources/Clawdbot/SystemRunApprovals.swift deleted file mode 100644 index b48abed71..000000000 --- a/apps/macos/Sources/Clawdbot/SystemRunApprovals.swift +++ /dev/null @@ -1,267 +0,0 @@ -import Foundation - -enum SystemRunAllowlistMatchKind: String { - case glob - case argv -} - -enum SystemRunAllowlistSource: String { - case manual - case skill -} - -struct SystemRunAllowlistEntry: Identifiable, Hashable { - let id: String - var pattern: String - var enabled: Bool - var matchKind: SystemRunAllowlistMatchKind - var source: SystemRunAllowlistSource? - var skillId: String? - var lastUsedAt: Date? - var lastUsedCommand: String? - var lastUsedPath: String? - - init( - id: String = UUID().uuidString, - pattern: String, - enabled: Bool = true, - matchKind: SystemRunAllowlistMatchKind = .glob, - source: SystemRunAllowlistSource? = .manual, - skillId: String? = nil, - lastUsedAt: Date? = nil, - lastUsedCommand: String? = nil, - lastUsedPath: String? = nil) - { - self.id = id - self.pattern = pattern - self.enabled = enabled - self.matchKind = matchKind - self.source = source - self.skillId = skillId - self.lastUsedAt = lastUsedAt - self.lastUsedCommand = lastUsedCommand - self.lastUsedPath = lastUsedPath - } - - init?(dict: [String: Any]) { - let id = dict["id"] as? String ?? UUID().uuidString - let pattern = (dict["pattern"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if pattern.isEmpty { return nil } - let enabled = dict["enabled"] as? Bool ?? true - let matchRaw = dict["matchKind"] as? String - let matchKind = SystemRunAllowlistMatchKind(rawValue: matchRaw ?? "") ?? .glob - let sourceRaw = dict["source"] as? String - let source = SystemRunAllowlistSource(rawValue: sourceRaw ?? "") - let skillId = (dict["skillId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - let lastUsedAt = (dict["lastUsedAt"] as? Double).map { Date(timeIntervalSince1970: $0) } - let lastUsedCommand = dict["lastUsedCommand"] as? String - let lastUsedPath = dict["lastUsedPath"] as? String - - self.init( - id: id, - pattern: pattern, - enabled: enabled, - matchKind: matchKind, - source: source, - skillId: skillId?.isEmpty == true ? nil : skillId, - lastUsedAt: lastUsedAt, - lastUsedCommand: lastUsedCommand, - lastUsedPath: lastUsedPath) - } - - func asDict() -> [String: Any] { - var dict: [String: Any] = [ - "id": self.id, - "pattern": self.pattern, - "enabled": self.enabled, - "matchKind": self.matchKind.rawValue, - ] - if let source = self.source { dict["source"] = source.rawValue } - if let skillId = self.skillId { dict["skillId"] = skillId } - if let lastUsedAt = self.lastUsedAt { dict["lastUsedAt"] = lastUsedAt.timeIntervalSince1970 } - if let lastUsedCommand = self.lastUsedCommand { dict["lastUsedCommand"] = lastUsedCommand } - if let lastUsedPath = self.lastUsedPath { dict["lastUsedPath"] = lastUsedPath } - return dict - } -} - -struct SystemRunCommandResolution: Sendable { - let rawExecutable: String - let resolvedPath: String? - let executableName: String - let cwd: String? - - static func resolve(command: [String], cwd: String?) -> SystemRunCommandResolution? { - guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { - return nil - } - let expanded = raw.hasPrefix("~") ? (raw as NSString).expandingTildeInPath : raw - let hasPathSeparator = expanded.contains("/") - let resolvedPath: String? = { - if hasPathSeparator { - if expanded.hasPrefix("/") { - return expanded - } - let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) - let root = (base?.isEmpty == false) ? base! : FileManager.default.currentDirectoryPath - return URL(fileURLWithPath: root).appendingPathComponent(expanded).path - } - return CommandResolver.findExecutable(named: expanded) - }() - let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded - return SystemRunCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd) - } -} - -enum SystemRunAllowlistStore { - static func load(agentId: String?) -> [SystemRunAllowlistEntry] { - if let entries = MacNodeConfigFile.systemRunAllowlist(agentId: agentId) { - return entries - } - return [] - } - - static func save(_ entries: [SystemRunAllowlistEntry], agentId: String?) { - MacNodeConfigFile.setSystemRunAllowlist(entries, agentId: agentId) - } - - static func add(pattern: String, agentId: String?, source: SystemRunAllowlistSource = .manual) -> SystemRunAllowlistEntry { - var entries = self.load(agentId: agentId) - let entry = SystemRunAllowlistEntry(pattern: pattern, enabled: true, matchKind: .glob, source: source) - entries.append(entry) - self.save(entries, agentId: agentId) - return entry - } - - static func update(_ entry: SystemRunAllowlistEntry, agentId: String?) { - var entries = self.load(agentId: agentId) - guard let index = entries.firstIndex(where: { $0.id == entry.id }) else { return } - entries[index] = entry - self.save(entries, agentId: agentId) - } - - static func remove(entryId: String, agentId: String?) { - let entries = self.load(agentId: agentId).filter { $0.id != entryId } - self.save(entries, agentId: agentId) - } - - static func markUsed(entryId: String, command: [String], resolvedPath: String?, agentId: String?) { - var entries = self.load(agentId: agentId) - guard let index = entries.firstIndex(where: { $0.id == entryId }) else { return } - entries[index].lastUsedAt = Date() - entries[index].lastUsedCommand = SystemRunAllowlist.displayString(for: command) - entries[index].lastUsedPath = resolvedPath - self.save(entries, agentId: agentId) - } - - static func match( - command: [String], - resolution: SystemRunCommandResolution?, - entries: [SystemRunAllowlistEntry]) -> SystemRunAllowlistEntry? - { - guard !entries.isEmpty else { return nil } - let argvKey = SystemRunAllowlist.legacyKey(for: command) - let resolvedPath = resolution?.resolvedPath - let executableName = resolution?.executableName - let rawExecutable = resolution?.rawExecutable - - for entry in entries { - guard entry.enabled else { continue } - switch entry.matchKind { - case .argv: - if argvKey == entry.pattern { return entry } - case .glob: - let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines) - if pattern.isEmpty { continue } - let hasPath = pattern.contains("/") || pattern.contains("~") - if hasPath { - let target = resolvedPath ?? rawExecutable - if let target, SystemRunGlob.matches(pattern: pattern, target: target) { - return entry - } - } else if let name = executableName, SystemRunGlob.matches(pattern: pattern, target: name) { - return entry - } - } - } - return nil - } -} - -enum SystemRunGlob { - static func matches(pattern rawPattern: String, target: String) -> Bool { - let trimmed = rawPattern.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return false } - let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed - guard let regex = self.regex(for: expanded) else { return false } - let range = NSRange(location: 0, length: target.utf16.count) - return regex.firstMatch(in: target, options: [], range: range) != nil - } - - private static func regex(for pattern: String) -> NSRegularExpression? { - var regex = "^" - var idx = pattern.startIndex - while idx < pattern.endIndex { - let ch = pattern[idx] - if ch == "*" { - let next = pattern.index(after: idx) - if next < pattern.endIndex, pattern[next] == "*" { - regex += ".*" - idx = pattern.index(after: next) - } else { - regex += "[^/]*" - idx = next - } - continue - } - if ch == "?" { - regex += "." - idx = pattern.index(after: idx) - continue - } - regex += NSRegularExpression.escapedPattern(for: String(ch)) - idx = pattern.index(after: idx) - } - regex += "$" - return try? NSRegularExpression(pattern: regex) - } -} - -actor SkillBinsCache { - static let shared = SkillBinsCache() - - private var bins: Set = [] - private var lastRefresh: Date? - private let refreshInterval: TimeInterval = 90 - - func currentBins(force: Bool = false) async -> Set { - if force || self.isStale() { - await self.refresh() - } - return self.bins - } - - func refresh() async { - do { - let report = try await GatewayConnection.shared.skillsStatus() - var next = Set() - for skill in report.skills { - for bin in skill.requirements.bins { - let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { next.insert(trimmed) } - } - } - self.bins = next - self.lastRefresh = Date() - } catch { - if self.lastRefresh == nil { - self.bins = [] - } - } - } - - private func isStale() -> Bool { - guard let lastRefresh else { return true } - return Date().timeIntervalSince(lastRefresh) > self.refreshInterval - } -} diff --git a/apps/macos/Sources/Clawdbot/SystemRunPolicy.swift b/apps/macos/Sources/Clawdbot/SystemRunPolicy.swift deleted file mode 100644 index 17edc0f34..000000000 --- a/apps/macos/Sources/Clawdbot/SystemRunPolicy.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation - -enum SystemRunPolicy: String, CaseIterable, Identifiable { - case never - case ask - case always - - var id: String { self.rawValue } - - var title: String { - switch self { - case .never: - "Never" - case .ask: - "Always Ask" - case .always: - "Always Allow" - } - } - - static func load(agentId: String? = nil, from defaults: UserDefaults = .standard) -> SystemRunPolicy { - if let policy = MacNodeConfigFile.systemRunPolicy(agentId: agentId) { - return policy - } - if let policy = MacNodeConfigFile.systemRunPolicy() { - return policy - } - if let raw = defaults.string(forKey: systemRunPolicyKey), - let policy = SystemRunPolicy(rawValue: raw) - { - MacNodeConfigFile.setSystemRunPolicy(policy) - return policy - } - if let legacy = defaults.object(forKey: systemRunEnabledKey) as? Bool { - let policy: SystemRunPolicy = legacy ? .ask : .never - MacNodeConfigFile.setSystemRunPolicy(policy) - return policy - } - let fallback: SystemRunPolicy = .ask - MacNodeConfigFile.setSystemRunPolicy(fallback) - return fallback - } -} - -enum SystemRunAllowlist { - static func legacyKey(for argv: [String]) -> String { - let trimmed = argv.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - guard !trimmed.isEmpty else { return "" } - if let data = try? JSONEncoder().encode(trimmed), - let json = String(data: data, encoding: .utf8) - { - return json - } - return trimmed.joined(separator: " ") - } - - static func displayString(for argv: [String]) -> String { - argv.map { arg in - let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return "\"\"" } - let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" } - if !needsQuotes { return trimmed } - let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"") - return "\"\(escaped)\"" - }.joined(separator: " ") - } - - static func loadLegacy(from defaults: UserDefaults = .standard) -> Set { - if let allowlist = MacNodeConfigFile.systemRunAllowlistStrings() { - return Set(allowlist) - } - if let legacy = defaults.stringArray(forKey: systemRunAllowlistKey), !legacy.isEmpty { - MacNodeConfigFile.setSystemRunAllowlistStrings(legacy) - return Set(legacy) - } - return [] - } -} diff --git a/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift b/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift index 5b2b41a77..7d95ae2a8 100644 --- a/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift +++ b/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift @@ -3,14 +3,14 @@ import Observation import SwiftUI struct SystemRunSettingsView: View { - @State private var model = SystemRunSettingsModel() - @State private var tab: SystemRunSettingsTab = .policy + @State private var model = ExecApprovalsSettingsModel() + @State private var tab: ExecApprovalsSettingsTab = .policy @State private var newPattern: String = "" var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .center, spacing: 12) { - Text("Node Run Commands") + Text("Exec approvals") .font(.body) Spacer(minLength: 0) if self.model.agentIds.count > 1 { @@ -28,12 +28,12 @@ struct SystemRunSettingsView: View { } Picker("", selection: self.$tab) { - ForEach(SystemRunSettingsTab.allCases) { tab in + ForEach(ExecApprovalsSettingsTab.allCases) { tab in Text(tab.title).tag(tab) } } .pickerStyle(.segmented) - .frame(width: 280) + .frame(width: 320) if self.tab == .policy { self.policyView @@ -48,19 +48,41 @@ struct SystemRunSettingsView: View { } private var policyView: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 8) { Picker("", selection: Binding( - get: { self.model.policy }, - set: { self.model.setPolicy($0) })) + get: { self.model.security }, + set: { self.model.setSecurity($0) })) { - ForEach(SystemRunPolicy.allCases) { policy in - Text(policy.title).tag(policy) + ForEach(ExecSecurity.allCases) { security in + Text(security.title).tag(security) } } .labelsHidden() .pickerStyle(.menu) - Text("Controls remote command execution on this Mac when it is paired as a node. \"Always Ask\" prompts on each command; \"Always Allow\" runs without prompts; \"Never\" disables system.run.") + Picker("", selection: Binding( + get: { self.model.ask }, + set: { self.model.setAsk($0) })) + { + ForEach(ExecAsk.allCases) { ask in + Text(ask.title).tag(ask) + } + } + .labelsHidden() + .pickerStyle(.menu) + + Picker("", selection: Binding( + get: { self.model.askFallback }, + set: { self.model.setAskFallback($0) })) + { + ForEach(ExecSecurity.allCases) { mode in + Text("Fallback: \(mode.title)").tag(mode) + } + } + .labelsHidden() + .pickerStyle(.menu) + + Text("Security controls whether system.run can execute on this Mac when paired as a node. Ask controls prompt behavior; fallback is used when no companion UI is reachable.") .font(.footnote) .foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) @@ -80,7 +102,7 @@ struct SystemRunSettingsView: View { } HStack(spacing: 8) { - TextField("Add allowlist pattern (supports globs)", text: self.$newPattern) + TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern) .textFieldStyle(.roundedBorder) Button("Add") { let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines) @@ -98,12 +120,12 @@ struct SystemRunSettingsView: View { .foregroundStyle(.secondary) } else { VStack(alignment: .leading, spacing: 8) { - ForEach(Array(self.model.entries.enumerated()), id: \.element.id) { index, _ in - SystemRunAllowlistRow( + ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in + ExecAllowlistRow( entry: Binding( get: { self.model.entries[index] }, - set: { self.model.updateEntry($0) }), - onRemove: { self.model.removeEntry($0.id) }) + set: { self.model.updateEntry($0, at: index) }), + onRemove: { self.model.removeEntry(at: index) }) } } } @@ -111,7 +133,7 @@ struct SystemRunSettingsView: View { } } -private enum SystemRunSettingsTab: String, CaseIterable, Identifiable { +private enum ExecApprovalsSettingsTab: String, CaseIterable, Identifiable { case policy case allowlist @@ -119,15 +141,15 @@ private enum SystemRunSettingsTab: String, CaseIterable, Identifiable { var title: String { switch self { - case .policy: "Policy" + case .policy: "Access" case .allowlist: "Allowlist" } } } -struct SystemRunAllowlistRow: View { - @Binding var entry: SystemRunAllowlistEntry - let onRemove: (SystemRunAllowlistEntry) -> Void +struct ExecAllowlistRow: View { + @Binding var entry: ExecAllowlistEntry + let onRemove: () -> Void @State private var draftPattern: String = "" private static let relativeFormatter: RelativeDateTimeFormatter = { @@ -139,20 +161,11 @@ struct SystemRunAllowlistRow: View { var body: some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { - Toggle("", isOn: self.$entry.enabled) - .labelsHidden() - TextField("Pattern", text: self.patternBinding) .textFieldStyle(.roundedBorder) - if self.entry.matchKind == .argv { - Text("Legacy") - .font(.caption) - .foregroundStyle(.secondary) - } - Button(role: .destructive) { - self.onRemove(self.entry) + self.onRemove() } label: { Image(systemName: "trash") } @@ -160,7 +173,8 @@ struct SystemRunAllowlistRow: View { } if let lastUsedAt = self.entry.lastUsedAt { - Text("Last used \(Self.relativeFormatter.localizedString(for: lastUsedAt, relativeTo: Date()))") + let date = Date(timeIntervalSince1970: lastUsedAt / 1000.0) + Text("Last used \(Self.relativeFormatter.localizedString(for: date, relativeTo: Date()))") .font(.caption) .foregroundStyle(.secondary) } else if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty { @@ -180,22 +194,21 @@ struct SystemRunAllowlistRow: View { set: { newValue in self.draftPattern = newValue self.entry.pattern = newValue - if self.entry.matchKind == .argv { - self.entry.matchKind = .glob - } }) } } @MainActor @Observable -final class SystemRunSettingsModel { +final class ExecApprovalsSettingsModel { var agentIds: [String] = [] var selectedAgentId: String = "main" var defaultAgentId: String = "main" - var policy: SystemRunPolicy = .ask + var security: ExecSecurity = .deny + var ask: ExecAsk = .onMiss + var askFallback: ExecSecurity = .deny var autoAllowSkills = false - var entries: [SystemRunAllowlistEntry] = [] + var entries: [ExecAllowlistEntry] = [] var skillBins: [String] = [] func refresh() async { @@ -241,43 +254,63 @@ final class SystemRunSettingsModel { } func loadSettings(for agentId: String) { - self.policy = SystemRunPolicy.load(agentId: agentId) - self.autoAllowSkills = MacNodeConfigFile.systemRunAutoAllowSkills(agentId: agentId) ?? false - self.entries = SystemRunAllowlistStore.load(agentId: agentId) + let resolved = ExecApprovalsStore.resolve(agentId: agentId) + self.security = resolved.agent.security + self.ask = resolved.agent.ask + self.askFallback = resolved.agent.askFallback + self.autoAllowSkills = resolved.agent.autoAllowSkills + self.entries = resolved.allowlist .sorted { $0.pattern.localizedCaseInsensitiveCompare($1.pattern) == .orderedAscending } } - func setPolicy(_ policy: SystemRunPolicy) { - self.policy = policy - MacNodeConfigFile.setSystemRunPolicy(policy, agentId: self.selectedAgentId) - if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 { - AppStateStore.shared.systemRunPolicy = policy + func setSecurity(_ security: ExecSecurity) { + self.security = security + ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in + entry.security = security + } + self.syncQuickMode() + } + + func setAsk(_ ask: ExecAsk) { + self.ask = ask + ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in + entry.ask = ask + } + self.syncQuickMode() + } + + func setAskFallback(_ mode: ExecSecurity) { + self.askFallback = mode + ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in + entry.askFallback = mode } } func setAutoAllowSkills(_ enabled: Bool) { self.autoAllowSkills = enabled - MacNodeConfigFile.setSystemRunAutoAllowSkills(enabled, agentId: self.selectedAgentId) + ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in + entry.autoAllowSkills = enabled + } Task { await self.refreshSkillBins(force: enabled) } } func addEntry(_ pattern: String) { let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } - let entry = SystemRunAllowlistEntry(pattern: trimmed, enabled: true, matchKind: .glob, source: .manual) - self.entries.append(entry) - SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId) + self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil)) + ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) } - func updateEntry(_ entry: SystemRunAllowlistEntry) { - guard let index = self.entries.firstIndex(where: { $0.id == entry.id }) else { return } + func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) { + guard self.entries.indices.contains(index) else { return } self.entries[index] = entry - SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId) + ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) } - func removeEntry(_ id: String) { - self.entries.removeAll { $0.id == id } - SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId) + func removeEntry(at index: Int) { + guard self.entries.indices.contains(index) else { return } + self.entries.remove(at: index) + ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) } func refreshSkillBins(force: Bool = false) async { @@ -288,4 +321,10 @@ final class SystemRunSettingsModel { let bins = await SkillBinsCache.shared.currentBins(force: force) self.skillBins = bins.sorted() } + + private func syncQuickMode() { + if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 { + AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask) + } + } } diff --git a/apps/macos/Tests/ClawdbotIPCTests/ExecAllowlistTests.swift b/apps/macos/Tests/ClawdbotIPCTests/ExecAllowlistTests.swift new file mode 100644 index 000000000..5d03344d9 --- /dev/null +++ b/apps/macos/Tests/ClawdbotIPCTests/ExecAllowlistTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing +@testable import Clawdbot + +struct ExecAllowlistTests { + @Test func matchUsesResolvedPath() { + let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg") + let resolution = ExecCommandResolution( + rawExecutable: "rg", + resolvedPath: "/opt/homebrew/bin/rg", + executableName: "rg", + cwd: nil) + let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) + #expect(match?.pattern == entry.pattern) + } + + @Test func matchUsesBasenameForSimplePattern() { + let entry = ExecAllowlistEntry(pattern: "rg") + let resolution = ExecCommandResolution( + rawExecutable: "rg", + resolvedPath: "/opt/homebrew/bin/rg", + executableName: "rg", + cwd: nil) + let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) + #expect(match?.pattern == entry.pattern) + } + + @Test func matchIsCaseInsensitive() { + let entry = ExecAllowlistEntry(pattern: "RG") + let resolution = ExecCommandResolution( + rawExecutable: "rg", + resolvedPath: "/opt/homebrew/bin/rg", + executableName: "rg", + cwd: nil) + let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) + #expect(match?.pattern == entry.pattern) + } + + @Test func matchSupportsGlobStar() { + let entry = ExecAllowlistEntry(pattern: "/opt/**/rg") + let resolution = ExecCommandResolution( + rawExecutable: "rg", + resolvedPath: "/opt/homebrew/bin/rg", + executableName: "rg", + cwd: nil) + let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) + #expect(match?.pattern == entry.pattern) + } +} diff --git a/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift index 3c4355360..12d03c185 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift @@ -74,10 +74,6 @@ struct MacNodeRuntimeTests { { CLLocation(latitude: 0, longitude: 0) } - - func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision { - .allowOnce - } } let services = await MainActor.run { FakeMainActorServices() } diff --git a/apps/macos/Tests/ClawdbotIPCTests/SystemRunAllowlistTests.swift b/apps/macos/Tests/ClawdbotIPCTests/SystemRunAllowlistTests.swift deleted file mode 100644 index fa0932c22..000000000 --- a/apps/macos/Tests/ClawdbotIPCTests/SystemRunAllowlistTests.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation -import Testing -@testable import Clawdbot - -struct SystemRunAllowlistTests { - @Test func matchUsesResolvedPath() { - let entry = SystemRunAllowlistEntry(pattern: "/opt/homebrew/bin/rg", enabled: true, matchKind: .glob) - let resolution = SystemRunCommandResolution( - rawExecutable: "rg", - resolvedPath: "/opt/homebrew/bin/rg", - executableName: "rg", - cwd: nil) - let match = SystemRunAllowlistStore.match( - command: ["rg"], - resolution: resolution, - entries: [entry]) - #expect(match?.id == entry.id) - } - - @Test func matchUsesBasenameForSimplePattern() { - let entry = SystemRunAllowlistEntry(pattern: "rg", enabled: true, matchKind: .glob) - let resolution = SystemRunCommandResolution( - rawExecutable: "rg", - resolvedPath: "/opt/homebrew/bin/rg", - executableName: "rg", - cwd: nil) - let match = SystemRunAllowlistStore.match( - command: ["rg"], - resolution: resolution, - entries: [entry]) - #expect(match?.id == entry.id) - } - - @Test func matchUsesLegacyArgvKey() { - let key = SystemRunAllowlist.legacyKey(for: ["echo", "hi"]) - let entry = SystemRunAllowlistEntry(pattern: key, enabled: true, matchKind: .argv) - let match = SystemRunAllowlistStore.match( - command: ["echo", "hi"], - resolution: nil, - entries: [entry]) - #expect(match?.id == entry.id) - } -} diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift index dfe19d971..f41f56f13 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift @@ -25,6 +25,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable { public var timeoutMs: Int? public var needsScreenRecording: Bool? public var agentId: String? + public var sessionKey: String? public init( command: [String], @@ -32,7 +33,8 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable { env: [String: String]? = nil, timeoutMs: Int? = nil, needsScreenRecording: Bool? = nil, - agentId: String? = nil) + agentId: String? = nil, + sessionKey: String? = nil) { self.command = command self.cwd = cwd @@ -40,6 +42,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable { self.timeoutMs = timeoutMs self.needsScreenRecording = needsScreenRecording self.agentId = agentId + self.sessionKey = sessionKey } } diff --git a/docs/refactor/exec-host.md b/docs/refactor/exec-host.md new file mode 100644 index 000000000..b51882a38 --- /dev/null +++ b/docs/refactor/exec-host.md @@ -0,0 +1,251 @@ +--- +summary: "Refactor plan: exec host routing, node approvals, and headless runner" +read_when: + - Designing exec host routing or exec approvals + - Implementing node runner + UI IPC + - Adding exec host security modes and slash commands +--- + +# Exec host refactor plan + +## Goals +- Add `exec.host` + `exec.security` to route execution across **sandbox**, **gateway**, and **node**. +- Keep defaults **safe**: no cross-host execution unless explicitly enabled. +- Split execution into a **headless runner service** with optional UI (macOS app) via local IPC. +- Provide **per-agent** policy, allowlist, ask mode, and node binding. +- Support **ask modes** that work *with* or *without* allowlists. +- Cross-platform: Unix socket + token auth (macOS/Linux/Windows parity). + +## Non-goals +- No legacy allowlist migration or legacy schema support. +- No PTY/streaming for node exec (aggregated output only). +- No new network layer beyond the existing Bridge + Gateway. + +## Decisions (locked) +- **Config keys:** `exec.host` + `exec.security` (per-agent override allowed). +- **Elevation:** keep `/elevated` as an alias for gateway full access. +- **Ask default:** `on-miss`. +- **Approvals store:** `~/.clawdbot/exec-approvals.json` (JSON, no legacy migration). +- **Runner:** headless system service; UI app hosts a Unix socket for approvals. +- **Node identity:** use existing `nodeId`. +- **Socket auth:** Unix socket + token (cross-platform); split later if needed. + +## Key concepts +### Host +- `sandbox`: Docker exec (current behavior). +- `gateway`: exec on gateway host. +- `node`: exec on node runner via Bridge (`system.run`). + +### Security mode +- `deny`: always block. +- `allowlist`: allow only matches. +- `full`: allow everything (equivalent to elevated). + +### Ask mode +- `off`: never ask. +- `on-miss`: ask only when allowlist does not match. +- `always`: ask every time. + +Ask is **independent** of allowlist; allowlist can be used with `always` or `on-miss`. + +### Policy resolution (per exec) +1) Resolve `exec.host` (tool param → agent override → global default). +2) Resolve `exec.security` and `exec.ask` (same precedence). +3) If host is `sandbox`, proceed with local sandbox exec. +4) If host is `gateway` or `node`, apply security + ask policy on that host. + +## Default safety +- Default `exec.host = sandbox`. +- Default `exec.security = deny` for `gateway` and `node`. +- Default `exec.ask = on-miss` (only relevant if security allows). +- If no node binding is set, **agent may target any node**, but only if policy allows it. + +## Config surface +### Tool parameters +- `exec.host` (optional): `sandbox | gateway | node`. +- `exec.security` (optional): `deny | allowlist | full`. +- `exec.ask` (optional): `off | on-miss | always`. +- `exec.node` (optional): node id/name to use when `host=node`. + +### Config keys (global) +- `tools.exec.host` +- `tools.exec.security` +- `tools.exec.ask` +- `tools.exec.node` (default node binding) + +### Config keys (per agent) +- `agents.list[].tools.exec.host` +- `agents.list[].tools.exec.security` +- `agents.list[].tools.exec.ask` +- `agents.list[].tools.exec.node` + +### Alias +- `/elevated on` = set `tools.exec.host=gateway`, `tools.exec.security=full` for the agent session. +- `/elevated off` = restore previous exec settings for the agent session. + +## Approvals store (JSON) +Path: `~/.clawdbot/exec-approvals.json` + +Purpose: +- Local policy + allowlists for the **execution host** (gateway or node runner). +- Ask fallback when no UI is available. +- IPC credentials for UI clients. + +Proposed schema (v1): +```json +{ + "version": 1, + "socket": { + "path": "~/.clawdbot/exec-approvals.sock", + "token": "base64-opaque-token" + }, + "defaults": { + "security": "deny", + "ask": "on-miss", + "askFallback": "deny" + }, + "agents": { + "agent-id-1": { + "security": "allowlist", + "ask": "on-miss", + "allowlist": [ + { + "pattern": "~/Projects/**/bin/rg", + "lastUsedAt": 0, + "lastUsedCommand": "rg -n TODO", + "lastResolvedPath": "/Users/user/Projects/.../bin/rg" + } + ] + } + } +} +``` +Notes: +- No legacy allowlist formats. +- `askFallback` applies only when `ask` is required and no UI is reachable. +- File permissions: `0600`. + +## Runner service (headless) +### Role +- Enforce `exec.security` + `exec.ask` locally. +- Execute system commands and return output. +- Emit Bridge events for exec lifecycle (optional but recommended). + +### Service lifecycle +- Launchd/daemon on macOS; system service on Linux/Windows. +- Approvals JSON is local to the execution host. +- UI hosts a local Unix socket; runners connect on demand. + +## UI integration (macOS app) +### IPC +- Unix socket at `~/.clawdbot/exec-approvals.sock`. +- Runner connects and sends an approval request; UI responds with a decision. +- Token stored in `exec-approvals.json`. + +### Ask flow +1) Runner receives `system.run` from gateway. +2) If ask required, runner connects to the socket and sends a prompt request. +3) UI shows dialog; returns decision. +4) Runner enforces decision and proceeds. + +If UI missing: +- Apply `askFallback` (`deny|allowlist|full`). + +## Node identity + binding +- Use existing `nodeId` from Bridge pairing. +- Binding model: + - `tools.exec.node` restricts the agent to a specific node. + - If unset, agent can pick any node (policy still enforces defaults). +- Node selection resolution: + - `nodeId` exact match + - `displayName` (normalized) + - `remoteIp` + - `nodeId` prefix (>= 6 chars) + +## Eventing +### Who sees events +- System events are **per session** and shown to the agent on the next prompt. +- Stored in the gateway in-memory queue (`enqueueSystemEvent`). + +### Event text +- `Exec started (host=node, node=, id=)` +- `Exec finished (exit=, tail=<...>)` +- `Exec denied (policy=<...>, reason=<...>)` + +### Transport +Option A (recommended): +- Runner sends Bridge `event` frames `exec.started` / `exec.finished`. +- Gateway `handleBridgeEvent` maps these into `enqueueSystemEvent`. + +Option B: +- Gateway `exec` tool handles lifecycle directly (synchronous only). + +## Exec flows +### Sandbox host +- Existing `exec` behavior (Docker or host when unsandboxed). +- PTY supported in non-sandbox mode only. + +### Gateway host +- Gateway process executes on its own machine. +- Enforces local `exec-approvals.json` (security/ask/allowlist). + +### Node host +- Gateway calls `node.invoke` with `system.run`. +- Runner enforces local approvals. +- Runner returns aggregated stdout/stderr. +- Optional Bridge events for start/finish/deny. + +## Output caps +- Cap combined stdout+stderr at **200k**; keep **tail 20k** for events. +- Truncate with a clear suffix (e.g., `"… (truncated)"`). + +## Slash commands +- `/exec host= security= ask= node=` +- Per-agent, per-session overrides; non-persistent unless saved via config. +- `/elevated on|off` remains a shortcut for `host=gateway security=full`. + +## Cross-platform story +- The runner service is the portable execution target. +- UI is optional; if missing, `askFallback` applies. +- Windows/Linux support the same approvals JSON + socket protocol. + +## Implementation phases +### Phase 1: config + exec routing +- Add config schema for `exec.host`, `exec.security`, `exec.ask`, `exec.node`. +- Update tool plumbing to respect `exec.host`. +- Add `/exec` slash command and keep `/elevated` alias. + +### Phase 2: approvals store + gateway enforcement +- Implement `exec-approvals.json` reader/writer. +- Enforce allowlist + ask modes for `gateway` host. +- Add output caps. + +### Phase 3: node runner enforcement +- Update node runner to enforce allowlist + ask. +- Add Unix socket prompt bridge to macOS app UI. +- Wire `askFallback`. + +### Phase 4: events +- Add node → gateway Bridge events for exec lifecycle. +- Map to `enqueueSystemEvent` for agent prompts. + +### Phase 5: UI polish +- Mac app: allowlist editor, per-agent switcher, ask policy UI. +- Node binding controls (optional). + +## Testing plan +- Unit tests: allowlist matching (glob + case-insensitive). +- Unit tests: policy resolution precedence (tool param → agent override → global). +- Integration tests: node runner deny/allow/ask flows. +- Bridge event tests: node event → system event routing. + +## Open risks +- UI unavailability: ensure `askFallback` is respected. +- Long-running commands: rely on timeout + output caps. +- Multi-node ambiguity: error unless node binding or explicit node param. + +## Related docs +- [Exec tool](/tools/exec) +- [Exec approvals](/tools/exec-approvals) +- [Nodes](/nodes) +- [Elevated mode](/tools/elevated) diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md index c4f3a7e25..2e74162c5 100644 --- a/docs/tools/elevated.md +++ b/docs/tools/elevated.md @@ -6,9 +6,8 @@ read_when: # Elevated Mode (/elevated directives) ## What it does -- Elevated mode allows the exec tool to run with elevated privileges when the feature is available and the sender is approved. -- The bash chat command (`!`; `/bash` alias) uses the same `tools.elevated` allowlists because it always runs on the host. -- **Optional for sandboxed agents**: elevated only changes behavior when the agent is running in a sandbox. If the agent already runs unsandboxed, elevated is effectively a no-op. +- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full`. +- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host). - Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`. - Only `on|off` are accepted; anything else returns a hint and does not change state. @@ -17,18 +16,9 @@ read_when: - **Per-session state**: `/elevated on|off` sets the elevated level for the current session key. - **Inline directive**: `/elevated on` inside a message applies to that message only. - **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned. -- **Host execution**: elevated runs `exec` on the host (bypasses sandbox). -- **Unsandboxed agents**: when there is no sandbox to bypass, elevated does not change where `exec` runs. +- **Host execution**: elevated forces `exec` onto the gateway host with full security. +- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status. - **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used. -- **Not skill-scoped**: elevated cannot be limited to a specific skill; it only changes `exec` location. - -Note: -- Sandbox on: `/elevated on` runs that `exec` command on the host. -- Sandbox off: `/elevated on` does not change execution (already on host). - -## When elevated matters -- Only impacts `exec` when the agent is running sandboxed (it drops the sandbox for that command). -- For unsandboxed agents, elevated does not change execution; it only affects gating, logging, and status. ## Resolution order 1. Inline directive on the message (applies only to that message). @@ -38,7 +28,7 @@ Note: ## Setting a session default - Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated on`. - Confirmation reply is sent (`Elevated mode enabled.` / `Elevated mode disabled.`). -- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error (runtime sandboxed/direct + failing config key paths) and does not change session state. +- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error and does not change session state. - Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level. ## Availability + allowlists diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 138879cdc..495a0d838 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -1,39 +1,88 @@ --- -summary: "Exec approvals, allowlists, and sandbox escape prompts in the macOS app" +summary: "Exec approvals, allowlists, and sandbox escape prompts" read_when: - Configuring exec approvals or allowlists - Implementing exec approval UX in the macOS app - Reviewing sandbox escape prompts and implications --- -# Exec approvals (macOS app) +# Exec approvals -Exec approvals are the **macOS companion app** guardrail for running host -commands from sandboxed agents. Think of it as a per-agent “run this on my Mac” -approval layer: the agent asks, the app decides, and the command runs (or not). -This is **in addition** to tool policy and elevated gating; all of those checks -must pass before a command can run. +Exec approvals are the **companion app guardrail** for letting a sandboxed agent run +commands on a real host (`gateway` or `node`). Think of it like a safety interlock: +commands are allowed only when policy + allowlist + (optional) user approval all agree. +Exec approvals are **in addition** to tool policy and elevated gating. -If you are **not** running the macOS companion app, exec approvals are -unavailable and `system.run` requests will be rejected with a message that a -companion app is required. +If the companion app UI is **not available**, any request that requires a prompt is +resolved by the **ask fallback** (default: deny). -## Settings +## Where it applies -In the macOS app, each agent has an **Exec approvals** setting: +Exec approvals are enforced locally on the execution host: +- **gateway host** → `clawdbot` process on the gateway machine +- **node host** → node runner (macOS companion app or headless node) -- **Deny**: block all host exec requests from the agent. -- **Always ask**: show a confirmation dialog for each host exec request. -- **Always allow**: run host exec requests without prompting. +## Settings and storage -Optional toggles: -- **Auto-allow skill CLIs**: when enabled, CLIs referenced by known skills are - treated as allowlisted (see below). +Approvals live in a local JSON file: + +`~/.clawdbot/exec-approvals.json` + +Example schema: +```json +{ + "version": 1, + "socket": { + "path": "~/.clawdbot/exec-approvals.sock", + "token": "base64url-token" + }, + "defaults": { + "security": "deny", + "ask": "on-miss", + "askFallback": "deny", + "autoAllowSkills": false + }, + "agents": { + "main": { + "security": "allowlist", + "ask": "on-miss", + "askFallback": "deny", + "autoAllowSkills": true, + "allowlist": [ + { + "pattern": "~/Projects/**/bin/rg", + "lastUsedAt": 1737150000000, + "lastUsedCommand": "rg -n TODO", + "lastResolvedPath": "/Users/user/Projects/.../bin/rg" + } + ] + } + } +} +``` + +## Policy knobs + +### Security (`exec.security`) +- **deny**: block all host exec requests. +- **allowlist**: allow only allowlisted commands. +- **full**: allow everything (equivalent to elevated). + +### Ask (`exec.ask`) +- **off**: never prompt. +- **on-miss**: prompt only when allowlist does not match. +- **always**: prompt on every command. + +### Ask fallback (`askFallback`) +If a prompt is required but no UI is reachable, fallback decides: +- **deny**: block. +- **allowlist**: allow only if allowlist matches. +- **full**: allow. ## Allowlist (per agent) -The allowlist is **per agent**. If multiple agents exist, you can switch which -agent’s allowlist you’re editing. Entries are path-based and support **globs**. +Allowlists are **per agent**. If multiple agents exist, switch which agent you’re +editing in the macOS app. Patterns are **case-insensitive glob matches**. Examples: - `~/Projects/**/bin/bird` @@ -41,66 +90,44 @@ Examples: - `/opt/homebrew/bin/rg` Each allowlist entry tracks: -- **last used** (timestamp) +- **last used** timestamp - **last used command** -- **last used path** (resolved absolute path) -- **last seen metadata** (hash/version/mtime when available) +- **last resolved path** -## How matching works +## Auto-allow skill CLIs -1) Parse the command to determine the executable (first token). -2) Resolve the executable to an absolute path using `PATH`. -3) Match against denylist (if present) → **deny**. -4) Match against allowlist → **allow**. -5) Otherwise follow the Exec approvals policy (deny/ask/allow). - -If **auto-allow skill CLIs** is enabled, each installed skill can contribute one -or more allowlist entries. A skill-based allowlist entry only auto-allows when: -- the resolved path matches, and -- the binary hash/version matches the last approved record (if tracked). - -If the binary changes (new hash/version), the command falls back to **Ask** so -the user can re-approve. +When **Auto-allow skill CLIs** is enabled, executables referenced by known skills +are treated as allowlisted (node hosts only). Disable this if you want strict +manual allowlists. ## Approval flow -When the policy is **Always ask** (or when a binary has changed), the macOS app -shows a confirmation dialog. The dialog should include: +When a prompt is required, the companion app displays a confirmation dialog with: - command + args - cwd -- environment overrides (diff) -- policy + rule that matched (if any) +- agent id +- resolved executable path +- host + policy metadata Actions: - **Allow once** → run now -- **Always allow** → add/update allowlist entry + run +- **Always allow** → add to allowlist + run - **Deny** → block -When approved, the command runs **in the background** and the agent receives -system events as it starts and completes. - ## System events -The agent receives system messages for observability and recovery: +Exec lifecycle is surfaced as system messages: +- `exec.started` +- `exec.finished` +- `exec.denied` -- `exec.started` — command accepted and launched -- `exec.finished` — command completed (exit code + output) -- `exec.denied` — command blocked (policy or denylist) - -These are **system messages**; no extra agent tool call is required to resume. +These are posted to the agent’s session after the node reports the event. ## Implications -- **Always allow** is powerful: the agent can run any host command without a - prompt. Prefer allowlisting trusted CLIs instead. -- **Ask** keeps you in the loop while still allowing fast approvals. -- Per-agent allowlists prevent one agent’s approval set from leaking into others. - -## Storage - -Allowlists and approval settings are stored **locally in the macOS app** (SQLite -is a good fit). The Markdown docs describe behavior; they are not the storage -mechanism. +- **full** is powerful; prefer allowlists when possible. +- **ask** keeps you in the loop while still allowing fast approvals. +- Per-agent allowlists prevent one agent’s approvals from leaking into others. Related: - [Exec tool](/tools/exec) diff --git a/docs/tools/exec.md b/docs/tools/exec.md index d29789586..de4e4ac46 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -14,21 +14,36 @@ Background sessions are scoped per agent; `process` only sees sessions from the ## Parameters - `command` (required) +- `workdir` (defaults to cwd) +- `env` (key/value overrides) - `yieldMs` (default 10000): auto-background after delay - `background` (bool): background immediately - `timeout` (seconds, default 1800): kill on expiry - `pty` (bool): run in a pseudo-terminal when available (TTY-only CLIs, coding agents, terminal UIs) -- `elevated` (bool): run on host if elevated mode is enabled/allowed (only changes behavior when the agent is sandboxed) -- Need a fully interactive session? Use `pty: true` and the `process` tool for stdin/output. -Note: `elevated` is ignored when sandboxing is off (exec already runs on the host). +- `host` (`sandbox | gateway | node`): where to execute +- `security` (`deny | allowlist | full`): enforcement mode for `gateway`/`node` +- `ask` (`off | on-miss | always`): approval prompts for `gateway`/`node` +- `node` (string): node id/name for `host=node` +- `elevated` (bool): alias for `host=gateway` + `security=full` when sandboxed and allowed + +Notes: +- `host` defaults to `sandbox`. +- `elevated` is ignored when sandboxing is off (exec already runs on the host). +- `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`. +- `node` requires a paired node (macOS companion app). +- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one. ## Config - `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit. +- `tools.exec.host` (default: `sandbox`) +- `tools.exec.security` (default: `deny`) +- `tools.exec.ask` (default: `on-miss`) +- `tools.exec.node` (default: unset) ## Exec approvals (macOS app) -Sandboxed agents can require per-request approval before `exec` runs on the host. +Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host. See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow. ## Examples diff --git a/docs/tools/index.md b/docs/tools/index.md index f0099e8bb..7bd954179 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -169,15 +169,19 @@ Core parameters: - `background` (immediate background) - `timeout` (seconds; kills the process if exceeded, default 1800) - `elevated` (bool; run on host if elevated mode is enabled/allowed; only changes behavior when the agent is sandboxed) +- `host` (`sandbox | gateway | node`) +- `security` (`deny | allowlist | full`) +- `ask` (`off | on-miss | always`) +- `node` (node id/name for `host=node`) - Need a real TTY? Set `pty: true`. Notes: - Returns `status: "running"` with a `sessionId` when backgrounded. - Use `process` to poll/log/write/kill/clear background sessions. - If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`. -- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and runs on the host. +- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`. - `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op). -- macOS app approvals/allowlists: [Exec approvals](/tools/exec-approvals). +- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals). ### `process` Manage background exec sessions. diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index fdd02c5f8..237523520 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -1,7 +1,21 @@ +import crypto from "node:crypto"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; +import { + type ExecAsk, + type ExecHost, + type ExecSecurity, + addAllowlistEntry, + matchAllowlist, + maxAsk, + minSecurity, + recordAllowlistUse, + requestExecApprovalViaSocket, + resolveCommandResolution, + resolveExecApprovals, +} from "../infra/exec-approvals.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { logInfo } from "../logger.js"; @@ -28,6 +42,8 @@ import { resolveWorkdir, truncateMiddle, } from "./bash-tools.shared.js"; +import { callGatewayTool } from "./tools/gateway.js"; +import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js"; import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js"; import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js"; @@ -68,6 +84,11 @@ type PtySpawn = ( ) => PtyHandle; export type ExecToolDefaults = { + host?: ExecHost; + security?: ExecSecurity; + ask?: ExecAsk; + node?: string; + agentId?: string; backgroundMs?: number; timeoutSec?: number; sandbox?: BashSandboxConfig; @@ -114,6 +135,26 @@ const execSchema = Type.Object({ description: "Run on the host with elevated permissions (if allowed)", }), ), + host: Type.Optional( + Type.String({ + description: "Exec host (sandbox|gateway|node).", + }), + ), + security: Type.Optional( + Type.String({ + description: "Exec security mode (deny|allowlist|full).", + }), + ), + ask: Type.Optional( + Type.String({ + description: "Exec ask mode (off|on-miss|always).", + }), + ), + node: Type.Optional( + Type.String({ + description: "Node id/name for host=node.", + }), + ), }); export type ExecToolDetails = @@ -133,6 +174,34 @@ export type ExecToolDetails = cwd?: string; }; +function normalizeExecHost(value?: string | null): ExecHost | null { + const normalized = value?.trim().toLowerCase(); + if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") { + return normalized; + } + return null; +} + +function normalizeExecSecurity(value?: string | null): ExecSecurity | null { + const normalized = value?.trim().toLowerCase(); + if (normalized === "deny" || normalized === "allowlist" || normalized === "full") { + return normalized; + } + return null; +} + +function normalizeExecAsk(value?: string | null): ExecAsk | null { + const normalized = value?.trim().toLowerCase(); + if (normalized === "off" || normalized === "on-miss" || normalized === "always") { + return normalized as ExecAsk; + } + return null; +} + +function renderExecHostLabel(host: ExecHost) { + return host === "sandbox" ? "sandbox" : host === "gateway" ? "gateway" : "node"; +} + function normalizeNotifyOutput(value: string) { return value.replace(/\s+/g, " ").trim(); } @@ -189,6 +258,10 @@ export function createExecTool( timeout?: number; pty?: boolean; elevated?: boolean; + host?: string; + security?: string; + ask?: string; + node?: string; }; if (!params.command) { @@ -255,8 +328,33 @@ export function createExecTool( )}`, ); } + const configuredHost = defaults?.host ?? "sandbox"; + const requestedHost = normalizeExecHost(params.host) ?? null; + let host: ExecHost = requestedHost ?? configuredHost; + if (!elevatedRequested && requestedHost && requestedHost !== configuredHost) { + throw new Error( + `exec host not allowed (requested ${renderExecHostLabel(requestedHost)}; ` + + `configure tools.exec.host=${renderExecHostLabel(configuredHost)} to allow).`, + ); + } + if (elevatedRequested) { + host = "gateway"; + } - const sandbox = elevatedRequested ? undefined : defaults?.sandbox; + const configuredSecurity = defaults?.security ?? "deny"; + const requestedSecurity = normalizeExecSecurity(params.security); + let security = minSecurity( + configuredSecurity, + requestedSecurity ?? configuredSecurity, + ); + if (elevatedRequested) { + security = "full"; + } + const configuredAsk = defaults?.ask ?? "on-miss"; + const requestedAsk = normalizeExecAsk(params.ask); + let ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk); + + const sandbox = host === "sandbox" ? defaults?.sandbox : undefined; const rawWorkdir = params.workdir?.trim() || defaults?.cwd || process.cwd(); let workdir = rawWorkdir; let containerWorkdir = sandbox?.containerWorkdir; @@ -283,6 +381,155 @@ export function createExecTool( containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir, }) : mergedEnv; + + if (host === "node") { + if (security === "deny") { + throw new Error("exec denied: host=node security=deny"); + } + const boundNode = defaults?.node?.trim(); + const requestedNode = params.node?.trim(); + if (boundNode && requestedNode && boundNode !== requestedNode) { + throw new Error(`exec node not allowed (bound to ${boundNode})`); + } + const nodeQuery = boundNode || requestedNode; + const nodes = await listNodes({}); + if (nodes.length === 0) { + throw new Error( + "exec host=node requires a paired node (none available). This requires the macOS companion app.", + ); + } + let nodeId: string; + try { + nodeId = resolveNodeIdFromList(nodes, nodeQuery, !nodeQuery); + } catch (err) { + if (!nodeQuery && String(err).includes("node required")) { + throw new Error( + "exec host=node requires a node id when multiple nodes are available (set tools.exec.node or exec.node).", + ); + } + throw err; + } + const nodeInfo = nodes.find((entry) => entry.nodeId === nodeId); + const supportsSystemRun = Array.isArray(nodeInfo?.commands) + ? nodeInfo?.commands?.includes("system.run") + : false; + if (!supportsSystemRun) { + throw new Error("exec host=node requires a node that supports system.run."); + } + const argv = ["/bin/sh", "-lc", params.command]; + const invokeParams: Record = { + nodeId, + command: "system.run", + params: { + command: argv, + cwd: workdir, + env: params.env, + timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined, + agentId: defaults?.agentId, + sessionKey: defaults?.sessionKey, + }, + idempotencyKey: crypto.randomUUID(), + }; + const raw = (await callGatewayTool("node.invoke", {}, invokeParams)) as { + payload?: { + exitCode?: number; + timedOut?: boolean; + success?: boolean; + stdout?: string; + stderr?: string; + error?: string | null; + }; + }; + const payload = raw?.payload ?? {}; + return { + content: [ + { + type: "text", + text: payload.stdout || payload.stderr || payload.error || "", + }, + ], + details: { + status: payload.success ? "completed" : "failed", + exitCode: payload.exitCode ?? null, + durationMs: Date.now() - startedAt, + aggregated: [payload.stdout, payload.stderr, payload.error].filter(Boolean).join("\n"), + cwd: workdir, + } satisfies ExecToolDetails, + }; + } + + if (host === "gateway") { + const approvals = resolveExecApprovals(defaults?.agentId); + const hostSecurity = minSecurity(security, approvals.agent.security); + const hostAsk = maxAsk(ask, approvals.agent.ask); + const askFallback = approvals.agent.askFallback; + if (hostSecurity === "deny") { + throw new Error("exec denied: host=gateway security=deny"); + } + + const resolution = resolveCommandResolution(params.command, workdir, env); + const allowlistMatch = + hostSecurity === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null; + const requiresAsk = + hostAsk === "always" || + (hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch); + + if (requiresAsk) { + const decision = + (await requestExecApprovalViaSocket({ + socketPath: approvals.socketPath, + token: approvals.token, + request: { + command: params.command, + cwd: workdir, + host: "gateway", + security: hostSecurity, + ask: hostAsk, + agentId: defaults?.agentId, + resolvedPath: resolution?.resolvedPath ?? null, + }, + })) ?? null; + + if (decision === "deny") { + throw new Error("exec denied: user denied"); + } + if (!decision) { + if (askFallback === "deny") { + throw new Error( + "exec denied: approval required (companion app approval UI not available)", + ); + } + if (askFallback === "allowlist") { + if (!allowlistMatch) { + throw new Error( + "exec denied: approval required (companion app approval UI not available)", + ); + } + } + } + if (decision === "allow-always" && hostSecurity === "allowlist") { + const pattern = + resolution?.resolvedPath ?? + resolution?.rawExecutable ?? + params.command.split(/\s+/).shift() ?? + ""; + if (pattern) { + addAllowlistEntry(approvals.file, defaults?.agentId, pattern); + } + } + } + + if (allowlistMatch) { + recordAllowlistUse( + approvals.file, + defaults?.agentId, + allowlistMatch, + params.command, + resolution?.resolvedPath, + ); + } + } + const usePty = params.pty === true && !sandbox; let child: ChildProcessWithoutNullStreams | null = null; let pty: PtyHandle | null = null; diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 97bcd9baa..a1b9ebada 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -74,6 +74,22 @@ function isApplyPatchAllowedForModel(params: { }); } +function resolveExecConfig(cfg: ClawdbotConfig | undefined, agentId?: string | null) { + const globalExec = cfg?.tools?.exec; + const agentExec = cfg?.agents?.list?.find((entry) => entry.id === agentId)?.tools?.exec; + return { + host: agentExec?.host ?? globalExec?.host, + security: agentExec?.security ?? globalExec?.security, + ask: agentExec?.ask ?? globalExec?.ask, + node: agentExec?.node ?? globalExec?.node, + backgroundMs: agentExec?.backgroundMs ?? globalExec?.backgroundMs, + timeoutSec: agentExec?.timeoutSec ?? globalExec?.timeoutSec, + cleanupMs: agentExec?.cleanupMs ?? globalExec?.cleanupMs, + notifyOnExit: agentExec?.notifyOnExit ?? globalExec?.notifyOnExit, + applyPatch: agentExec?.applyPatch ?? globalExec?.applyPatch, + }; +} + export const __testing = { cleanToolSchemaForGemini, normalizeToolParams, @@ -146,6 +162,7 @@ export function createClawdbotCodingTools(options?: { sandbox?.tools, subagentPolicy, ]); + const execConfig = resolveExecConfig(options?.config, agentId); const sandboxRoot = sandbox?.workspaceDir; const allowWorkspaceWrites = sandbox?.workspaceAccess !== "ro"; const workspaceRoot = options?.workspaceDir ?? process.cwd(); @@ -184,11 +201,20 @@ export function createClawdbotCodingTools(options?: { }); const execTool = createExecTool({ ...options?.exec, + host: options?.exec?.host ?? execConfig.host, + security: options?.exec?.security ?? execConfig.security, + ask: options?.exec?.ask ?? execConfig.ask, + node: options?.exec?.node ?? execConfig.node, + agentId, cwd: options?.workspaceDir, allowBackground, scopeKey, sessionKey: options?.sessionKey, messageProvider: options?.messageProvider, + backgroundMs: options?.exec?.backgroundMs ?? execConfig.backgroundMs, + timeoutSec: options?.exec?.timeoutSec ?? execConfig.timeoutSec, + cleanupMs: options?.exec?.cleanupMs ?? execConfig.cleanupMs, + notifyOnExit: options?.exec?.notifyOnExit ?? execConfig.notifyOnExit, sandbox: sandbox ? { containerName: sandbox.containerName, diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index f214ddb89..3b4a44d91 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -92,6 +92,7 @@ export function createNodesTool(options?: { agentSessionKey?: string; config?: ClawdbotConfig; }): AnyAgentTool { + const sessionKey = options?.agentSessionKey?.trim() || undefined; const agentId = resolveSessionAgentId({ sessionKey: options?.agentSessionKey, config: options?.config, @@ -430,6 +431,7 @@ export function createNodesTool(options?: { timeoutMs: commandTimeoutMs, needsScreenRecording, agentId, + sessionKey, }, timeoutMs: invokeTimeoutMs, idempotencyKey: crypto.randomUUID(), diff --git a/src/config/schema.ts b/src/config/schema.ts index 66fc49d5f..0c860d086 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -140,6 +140,10 @@ const FIELD_LABELS: Record = { "tools.exec.applyPatch.enabled": "Enable apply_patch", "tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist", "tools.exec.notifyOnExit": "Exec Notify On Exit", + "tools.exec.host": "Exec Host", + "tools.exec.security": "Exec Security", + "tools.exec.ask": "Exec Ask", + "tools.exec.node": "Exec Node Binding", "tools.message.allowCrossContextSend": "Allow Cross-Context Messaging", "tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)", "tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)", diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 55bb26dd7..f4e2438bc 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -339,6 +339,14 @@ export type ToolsConfig = { }; /** Exec tool defaults. */ exec?: { + /** Exec host routing (default: sandbox). */ + host?: "sandbox" | "gateway" | "node"; + /** Exec security mode (default: deny). */ + security?: "deny" | "allowlist" | "full"; + /** Exec ask mode (default: on-miss). */ + ask?: "off" | "on-miss" | "always"; + /** Default node binding for exec.host=node (node id/name). */ + node?: string; /** Default time (ms) before an exec command auto-backgrounds. */ backgroundMs?: number; /** Default timeout (seconds) before auto-killing exec commands. */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index d0cbb323f..48107b88c 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -356,6 +356,10 @@ export const ToolsSchema = z .optional(), exec: z .object({ + host: z.enum(["sandbox", "gateway", "node"]).optional(), + security: z.enum(["deny", "allowlist", "full"]).optional(), + ask: z.enum(["off", "on-miss", "always"]).optional(), + node: z.string().optional(), backgroundMs: z.number().int().positive().optional(), timeoutSec: z.number().int().positive().optional(), cleanupMs: z.number().int().positive().optional(), diff --git a/src/gateway/server-bridge-events.ts b/src/gateway/server-bridge-events.ts index fc0720524..e1703ad36 100644 --- a/src/gateway/server-bridge-events.ts +++ b/src/gateway/server-bridge-events.ts @@ -3,6 +3,8 @@ import { normalizeChannelId } from "../channels/plugins/index.js"; import { agentCommand } from "../commands/agent.js"; import { loadConfig } from "../config/config.js"; import { updateSessionStore } from "../config/sessions.js"; +import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; +import { enqueueSystemEvent } from "../infra/system-events.js"; import { normalizeMainKey } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; import type { BridgeEvent, BridgeHandlersContext } from "./server-bridge-types.js"; @@ -172,6 +174,47 @@ export const handleBridgeEvent = async ( ctx.bridgeUnsubscribe(nodeId, sessionKey); return; } + case "exec.started": + case "exec.finished": + case "exec.denied": { + if (!evt.payloadJSON) return; + let payload: unknown; + try { + payload = JSON.parse(evt.payloadJSON) as unknown; + } catch { + return; + } + const obj = + typeof payload === "object" && payload !== null ? (payload as Record) : {}; + const sessionKey = + typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : `node-${nodeId}`; + if (!sessionKey) return; + const runId = typeof obj.runId === "string" ? obj.runId.trim() : ""; + const command = typeof obj.command === "string" ? obj.command.trim() : ""; + const exitCode = + typeof obj.exitCode === "number" && Number.isFinite(obj.exitCode) ? obj.exitCode : undefined; + const timedOut = obj.timedOut === true; + const success = obj.success === true; + const output = typeof obj.output === "string" ? obj.output.trim() : ""; + const reason = typeof obj.reason === "string" ? obj.reason.trim() : ""; + + let text = ""; + if (evt.event === "exec.started") { + text = `Exec started (node=${nodeId}${runId ? ` id=${runId}` : ""})`; + if (command) text += `: ${command}`; + } else if (evt.event === "exec.finished") { + const exitLabel = timedOut ? "timeout" : `code ${exitCode ?? "?"}`; + text = `Exec finished (node=${nodeId}${runId ? ` id=${runId}` : ""}, ${exitLabel})`; + if (output) text += `\\n${output}`; + } else { + text = `Exec denied (node=${nodeId}${runId ? ` id=${runId}` : ""}${reason ? `, ${reason}` : ""})`; + if (command) text += `: ${command}`; + } + + enqueueSystemEvent(text, { sessionKey, contextKey: runId ? `exec:${runId}` : "exec" }); + requestHeartbeatNow({ reason: "exec-event" }); + return; + } default: return; } diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts new file mode 100644 index 000000000..b20d27be9 --- /dev/null +++ b/src/infra/exec-approvals.ts @@ -0,0 +1,402 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +export type ExecHost = "sandbox" | "gateway" | "node"; +export type ExecSecurity = "deny" | "allowlist" | "full"; +export type ExecAsk = "off" | "on-miss" | "always"; + +export type ExecApprovalsDefaults = { + security?: ExecSecurity; + ask?: ExecAsk; + askFallback?: ExecSecurity; + autoAllowSkills?: boolean; +}; + +export type ExecAllowlistEntry = { + pattern: string; + lastUsedAt?: number; + lastUsedCommand?: string; + lastResolvedPath?: string; +}; + +export type ExecApprovalsAgent = ExecApprovalsDefaults & { + allowlist?: ExecAllowlistEntry[]; +}; + +export type ExecApprovalsFile = { + version: 1; + socket?: { + path?: string; + token?: string; + }; + defaults?: ExecApprovalsDefaults; + agents?: Record; +}; + +export type ExecApprovalsResolved = { + path: string; + socketPath: string; + token: string; + defaults: Required; + agent: Required; + allowlist: ExecAllowlistEntry[]; + file: ExecApprovalsFile; +}; + +const DEFAULT_SECURITY: ExecSecurity = "deny"; +const DEFAULT_ASK: ExecAsk = "on-miss"; +const DEFAULT_ASK_FALLBACK: ExecSecurity = "deny"; +const DEFAULT_AUTO_ALLOW_SKILLS = false; +const DEFAULT_SOCKET = "~/.clawdbot/exec-approvals.sock"; +const DEFAULT_FILE = "~/.clawdbot/exec-approvals.json"; + +function expandHome(value: string): string { + if (!value) return value; + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2)); + return value; +} + +export function resolveExecApprovalsPath(): string { + return expandHome(DEFAULT_FILE); +} + +export function resolveExecApprovalsSocketPath(): string { + return expandHome(DEFAULT_SOCKET); +} + +function ensureDir(filePath: string) { + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); +} + +function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile { + const socketPath = file.socket?.path?.trim(); + const token = file.socket?.token?.trim(); + const normalized: ExecApprovalsFile = { + version: 1, + socket: { + path: socketPath && socketPath.length > 0 ? socketPath : undefined, + token: token && token.length > 0 ? token : undefined, + }, + defaults: { + security: file.defaults?.security, + ask: file.defaults?.ask, + askFallback: file.defaults?.askFallback, + autoAllowSkills: file.defaults?.autoAllowSkills, + }, + agents: file.agents ?? {}, + }; + return normalized; +} + +function generateToken(): string { + return crypto.randomBytes(24).toString("base64url"); +} + +export function loadExecApprovals(): ExecApprovalsFile { + const filePath = resolveExecApprovalsPath(); + try { + if (!fs.existsSync(filePath)) { + return normalizeExecApprovals({ version: 1, agents: {} }); + } + const raw = fs.readFileSync(filePath, "utf8"); + const parsed = JSON.parse(raw) as ExecApprovalsFile; + if (parsed?.version !== 1) { + return normalizeExecApprovals({ version: 1, agents: parsed?.agents ?? {} }); + } + return normalizeExecApprovals(parsed); + } catch { + return normalizeExecApprovals({ version: 1, agents: {} }); + } +} + +export function saveExecApprovals(file: ExecApprovalsFile) { + const filePath = resolveExecApprovalsPath(); + ensureDir(filePath); + fs.writeFileSync(filePath, JSON.stringify(file, null, 2)); +} + +export function ensureExecApprovals(): ExecApprovalsFile { + const loaded = loadExecApprovals(); + const next = normalizeExecApprovals(loaded); + const socketPath = next.socket?.path?.trim(); + const token = next.socket?.token?.trim(); + const updated: ExecApprovalsFile = { + ...next, + socket: { + path: socketPath && socketPath.length > 0 ? socketPath : resolveExecApprovalsSocketPath(), + token: token && token.length > 0 ? token : generateToken(), + }, + }; + saveExecApprovals(updated); + return updated; +} + +function normalizeSecurity(value?: ExecSecurity): ExecSecurity { + if (value === "allowlist" || value === "full" || value === "deny") return value; + return DEFAULT_SECURITY; +} + +function normalizeAsk(value?: ExecAsk): ExecAsk { + if (value === "always" || value === "off" || value === "on-miss") return value; + return DEFAULT_ASK; +} + +export function resolveExecApprovals(agentId?: string): ExecApprovalsResolved { + const file = ensureExecApprovals(); + const defaults = file.defaults ?? {}; + const agentKey = agentId ?? "default"; + const agent = file.agents?.[agentKey] ?? {}; + const resolvedDefaults: Required = { + security: normalizeSecurity(defaults.security), + ask: normalizeAsk(defaults.ask), + askFallback: normalizeSecurity(defaults.askFallback ?? DEFAULT_ASK_FALLBACK), + autoAllowSkills: Boolean(defaults.autoAllowSkills ?? DEFAULT_AUTO_ALLOW_SKILLS), + }; + const resolvedAgent: Required = { + security: normalizeSecurity(agent.security ?? resolvedDefaults.security), + ask: normalizeAsk(agent.ask ?? resolvedDefaults.ask), + askFallback: normalizeSecurity(agent.askFallback ?? resolvedDefaults.askFallback), + autoAllowSkills: Boolean(agent.autoAllowSkills ?? resolvedDefaults.autoAllowSkills), + }; + const allowlist = Array.isArray(agent.allowlist) ? agent.allowlist : []; + return { + path: resolveExecApprovalsPath(), + socketPath: expandHome(file.socket?.path ?? resolveExecApprovalsSocketPath()), + token: file.socket?.token ?? "", + defaults: resolvedDefaults, + agent: resolvedAgent, + allowlist, + file, + }; +} + +type CommandResolution = { + rawExecutable: string; + resolvedPath?: string; + executableName: string; +}; + +function parseFirstToken(command: string): string | null { + const trimmed = command.trim(); + if (!trimmed) return null; + const first = trimmed[0]; + if (first === "\"" || first === "'") { + const end = trimmed.indexOf(first, 1); + if (end > 1) return trimmed.slice(1, end); + return trimmed.slice(1); + } + const match = /^[^\\s]+/.exec(trimmed); + return match ? match[0] : null; +} + +function resolveExecutablePath(rawExecutable: string, cwd?: string, env?: NodeJS.ProcessEnv) { + const expanded = rawExecutable.startsWith("~") ? expandHome(rawExecutable) : rawExecutable; + if (expanded.includes("/") || expanded.includes("\\")) { + if (path.isAbsolute(expanded)) return expanded; + const base = cwd && cwd.trim() ? cwd.trim() : process.cwd(); + return path.resolve(base, expanded); + } + const envPath = env?.PATH ?? process.env.PATH ?? ""; + const entries = envPath.split(path.delimiter).filter(Boolean); + for (const entry of entries) { + const candidate = path.join(entry, expanded); + if (fs.existsSync(candidate)) return candidate; + } + return undefined; +} + +export function resolveCommandResolution( + command: string, + cwd?: string, + env?: NodeJS.ProcessEnv, +): CommandResolution | null { + const rawExecutable = parseFirstToken(command); + if (!rawExecutable) return null; + const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env); + const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable; + return { rawExecutable, resolvedPath, executableName }; +} + +function normalizeMatchTarget(value: string): string { + return value.replace(/\\\\/g, "/").toLowerCase(); +} + +function globToRegExp(pattern: string): RegExp { + let regex = "^"; + let i = 0; + while (i < pattern.length) { + const ch = pattern[i]; + if (ch === "*") { + const next = pattern[i + 1]; + if (next === "*") { + regex += ".*"; + i += 2; + continue; + } + regex += "[^/]*"; + i += 1; + continue; + } + if (ch === "?") { + regex += "."; + i += 1; + continue; + } + regex += ch.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&"); + i += 1; + } + regex += "$"; + return new RegExp(regex, "i"); +} + +function matchesPattern(pattern: string, target: string): boolean { + const trimmed = pattern.trim(); + if (!trimmed) return false; + const expanded = trimmed.startsWith("~") ? expandHome(trimmed) : trimmed; + const normalizedPattern = normalizeMatchTarget(expanded); + const normalizedTarget = normalizeMatchTarget(target); + const regex = globToRegExp(normalizedPattern); + return regex.test(normalizedTarget); +} + +export function matchAllowlist( + entries: ExecAllowlistEntry[], + resolution: CommandResolution | null, +): ExecAllowlistEntry | null { + if (!entries.length || !resolution) return null; + const rawExecutable = resolution.rawExecutable; + const resolvedPath = resolution.resolvedPath; + const executableName = resolution.executableName; + for (const entry of entries) { + const pattern = entry.pattern?.trim(); + if (!pattern) continue; + const hasPath = pattern.includes("/") || pattern.includes("\\") || pattern.includes("~"); + if (hasPath) { + const target = resolvedPath ?? rawExecutable; + if (target && matchesPattern(pattern, target)) return entry; + continue; + } + if (executableName && matchesPattern(pattern, executableName)) return entry; + } + return null; +} + +export function recordAllowlistUse( + approvals: ExecApprovalsFile, + agentId: string | undefined, + entry: ExecAllowlistEntry, + command: string, + resolvedPath?: string, +) { + const target = agentId ?? "default"; + const agents = approvals.agents ?? {}; + const existing = agents[target] ?? {}; + const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : []; + const nextAllowlist = allowlist.map((item) => + item.pattern === entry.pattern + ? { + ...item, + lastUsedAt: Date.now(), + lastUsedCommand: command, + lastResolvedPath: resolvedPath, + } + : item, + ); + agents[target] = { ...existing, allowlist: nextAllowlist }; + approvals.agents = agents; + saveExecApprovals(approvals); +} + +export function addAllowlistEntry( + approvals: ExecApprovalsFile, + agentId: string | undefined, + pattern: string, +) { + const target = agentId ?? "default"; + const agents = approvals.agents ?? {}; + const existing = agents[target] ?? {}; + const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : []; + const trimmed = pattern.trim(); + if (!trimmed) return; + if (allowlist.some((entry) => entry.pattern === trimmed)) return; + allowlist.push({ pattern: trimmed, lastUsedAt: Date.now() }); + agents[target] = { ...existing, allowlist }; + approvals.agents = agents; + saveExecApprovals(approvals); +} + +export function minSecurity(a: ExecSecurity, b: ExecSecurity): ExecSecurity { + const order: Record = { deny: 0, allowlist: 1, full: 2 }; + return order[a] <= order[b] ? a : b; +} + +export function maxAsk(a: ExecAsk, b: ExecAsk): ExecAsk { + const order: Record = { off: 0, "on-miss": 1, always: 2 }; + return order[a] >= order[b] ? a : b; +} + +export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny"; + +export async function requestExecApprovalViaSocket(params: { + socketPath: string; + token: string; + request: Record; + timeoutMs?: number; +}): Promise { + const { socketPath, token, request } = params; + if (!socketPath || !token) return null; + const timeoutMs = params.timeoutMs ?? 15_000; + return await new Promise((resolve) => { + const client = new net.Socket(); + let settled = false; + let buffer = ""; + const finish = (value: ExecApprovalDecision | null) => { + if (settled) return; + settled = true; + try { + client.destroy(); + } catch { + // ignore + } + resolve(value); + }; + + const timer = setTimeout(() => finish(null), timeoutMs); + const payload = JSON.stringify({ + type: "request", + token, + id: crypto.randomUUID(), + request, + }); + + client.on("error", () => finish(null)); + client.connect(socketPath, () => { + client.write(`${payload}\n`); + }); + client.on("data", (data) => { + buffer += data.toString("utf8"); + let idx = buffer.indexOf("\n"); + while (idx !== -1) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + idx = buffer.indexOf("\n"); + if (!line) continue; + try { + const msg = JSON.parse(line) as { type?: string; decision?: ExecApprovalDecision }; + if (msg?.type === "decision" && msg.decision) { + clearTimeout(timer); + finish(msg.decision); + return; + } + } catch { + // ignore + } + } + }); + }); +}