diff --git a/CHANGELOG.md b/CHANGELOG.md index 380ae3692..932a07810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,13 @@ Docs: https://docs.clawd.bot - Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm. - Memory: add `--verbose` logging for memory status + batch indexing details. - Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). +- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI. +- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals ### Fixes - Memory: apply OpenAI batch defaults even without explicit remote config. - macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006) - +- Tools: return a companion-app-required message when `system.run` is requested without a supporting node. ## 2026.1.17-3 ### Changes diff --git a/apps/macos/Sources/Clawdbot/CommandResolver.swift b/apps/macos/Sources/Clawdbot/CommandResolver.swift index f1d36a9bc..9e8ae1c41 100644 --- a/apps/macos/Sources/Clawdbot/CommandResolver.swift +++ b/apps/macos/Sources/Clawdbot/CommandResolver.swift @@ -214,9 +214,10 @@ enum CommandResolver { subcommand: String, extraArgs: [String] = [], defaults: UserDefaults = .standard, + configRoot: [String: Any]? = nil, searchPaths: [String]? = nil) -> [String] { - let settings = self.connectionSettings(defaults: defaults) + let settings = self.connectionSettings(defaults: defaults, configRoot: configRoot) if settings.mode == .remote, let ssh = self.sshNodeCommand( subcommand: subcommand, extraArgs: extraArgs, @@ -264,12 +265,14 @@ enum CommandResolver { subcommand: String, extraArgs: [String] = [], defaults: UserDefaults = .standard, + configRoot: [String: Any]? = nil, searchPaths: [String]? = nil) -> [String] { self.clawdbotNodeCommand( subcommand: subcommand, extraArgs: extraArgs, defaults: defaults, + configRoot: configRoot, searchPaths: searchPaths) } @@ -384,8 +387,11 @@ enum CommandResolver { let cliPath: String } - static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings { - let root = ClawdbotConfigFile.loadDict() + static func connectionSettings( + defaults: UserDefaults = .standard, + configRoot: [String: Any]? = nil) -> RemoteSettings + { + let root = configRoot ?? ClawdbotConfigFile.loadDict() let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode let target = defaults.string(forKey: remoteTargetKey) ?? "" let identity = defaults.string(forKey: remoteIdentityKey) ?? "" diff --git a/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift b/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift index 032275bba..e66ff2e04 100644 --- a/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift +++ b/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift @@ -27,7 +27,11 @@ struct Semver: Comparable, CustomStringConvertible, Sendable { else { return nil } // Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5") let patchRaw = String(parts[2]) - let patchNumeric = patchRaw.split { $0 == "-" || $0 == "+" }.first.flatMap { Int($0) } ?? 0 + guard let patchToken = patchRaw.split(whereSeparator: { $0 == "-" || $0 == "+" }).first, + let patchNumeric = Int(patchToken) + else { + return nil + } return Semver(major: major, minor: minor, patch: patchNumeric) } diff --git a/apps/macos/Sources/Clawdbot/GeneralSettings.swift b/apps/macos/Sources/Clawdbot/GeneralSettings.swift index 5a5dc05d8..5f144864b 100644 --- a/apps/macos/Sources/Clawdbot/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdbot/GeneralSettings.swift @@ -83,27 +83,7 @@ struct GeneralSettings: View { subtitle: "Allow the agent to capture a photo or short video via the built-in camera.", binding: self.$cameraEnabled) - VStack(alignment: .leading, spacing: 6) { - Text("Node Run Commands") - .font(.body) - - Picker("", selection: self.$state.systemRunPolicy) { - ForEach(SystemRunPolicy.allCases) { policy in - Text(policy.title).tag(policy) - } - } - .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`. - """) - .font(.footnote) - .foregroundStyle(.tertiary) - .fixedSize(horizontal: false, vertical: true) - } + SystemRunSettingsView() VStack(alignment: .leading, spacing: 6) { Text("Location Access") diff --git a/apps/macos/Sources/Clawdbot/MacNodeConfigFile.swift b/apps/macos/Sources/Clawdbot/MacNodeConfigFile.swift index 7b5f9934f..aeed45ed3 100644 --- a/apps/macos/Sources/Clawdbot/MacNodeConfigFile.swift +++ b/apps/macos/Sources/Clawdbot/MacNodeConfigFile.swift @@ -38,39 +38,14 @@ enum MacNodeConfigFile { } } - static func systemRunPolicy() -> SystemRunPolicy? { - let root = self.loadDict() - let systemRun = root["systemRun"] as? [String: Any] - let raw = systemRun?["policy"] as? String - guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil } - return policy + private static func systemRunSection(from root: [String: Any]) -> [String: Any] { + root["systemRun"] as? [String: Any] ?? [:] } - static func setSystemRunPolicy(_ policy: SystemRunPolicy) { + private static func updateSystemRunSection(_ mutate: (inout [String: Any]) -> Void) { var root = self.loadDict() - var systemRun = root["systemRun"] as? [String: Any] ?? [:] - systemRun["policy"] = policy.rawValue - root["systemRun"] = systemRun - self.saveDict(root) - } - - static func systemRunAllowlist() -> [String]? { - let root = self.loadDict() - let systemRun = root["systemRun"] as? [String: Any] - return systemRun?["allowlist"] as? [String] - } - - static func setSystemRunAllowlist(_ allowlist: [String]) { - let cleaned = allowlist - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - var root = self.loadDict() - var systemRun = root["systemRun"] as? [String: Any] ?? [:] - if cleaned.isEmpty { - systemRun.removeValue(forKey: "allowlist") - } else { - systemRun["allowlist"] = cleaned - } + var systemRun = self.systemRunSection(from: root) + mutate(&systemRun) if systemRun.isEmpty { root.removeValue(forKey: "systemRun") } else { @@ -78,4 +53,147 @@ enum MacNodeConfigFile { } 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/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift index e0bcbfae3..3898473f4 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift @@ -428,8 +428,32 @@ actor MacNodeRuntime { return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required") } - let wasAllowlisted = SystemRunAllowlist.contains(command) - switch Self.systemRunPolicy() { + 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 skillAllow: Bool + if autoAllowSkills, let name = resolution?.executableName { + let bins = await SkillBinsCache.shared.currentBins() + skillAllow = bins.contains(name) + } else { + 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: return Self.errorResponse( req, @@ -438,16 +462,24 @@ actor MacNodeRuntime { case .always: break case .ask: - if !wasAllowlisted { + if shouldPrompt { let services = await self.mainActorServices() - let decision = await services.confirmSystemRun( + let decision = await services.confirmSystemRun(context: SystemRunPromptContext( command: SystemRunAllowlist.displayString(for: command), - cwd: params.cwd) + cwd: params.cwd, + agentId: agentId, + executablePath: resolution?.resolvedPath)) switch decision { case .allowOnce: break case .allowAlways: - SystemRunAllowlist.add(command) + 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: return Self.errorResponse( req, @@ -457,6 +489,14 @@ actor MacNodeRuntime { } } + if let match = allowlistMatch { + SystemRunAllowlistStore.markUsed( + entryId: match.id, + command: command, + resolvedPath: resolution?.resolvedPath, + agentId: agentId) + } + let env = Self.sanitizedEnv(params.env) if params.needsScreenRecording == true { diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift index 5f5008713..7b5ce9b48 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift @@ -9,6 +9,13 @@ enum SystemRunDecision: Sendable { case deny } +struct SystemRunPromptContext: Sendable { + let command: String + let cwd: String? + let agentId: String? + let executablePath: String? +} + @MainActor protocol MacNodeRuntimeMainActorServices: Sendable { func recordScreen( @@ -25,7 +32,7 @@ protocol MacNodeRuntimeMainActorServices: Sendable { maxAgeMs: Int?, timeoutMs: Int?) async throws -> CLLocation - func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision + func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision } @MainActor @@ -67,16 +74,24 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices timeoutMs: timeoutMs) } - func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision { + 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\(command)" - let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + 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 diff --git a/apps/macos/Sources/Clawdbot/SystemRunApprovals.swift b/apps/macos/Sources/Clawdbot/SystemRunApprovals.swift new file mode 100644 index 000000000..b48abed71 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/SystemRunApprovals.swift @@ -0,0 +1,267 @@ +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 index b38734bc3..17edc0f34 100644 --- a/apps/macos/Sources/Clawdbot/SystemRunPolicy.swift +++ b/apps/macos/Sources/Clawdbot/SystemRunPolicy.swift @@ -18,7 +18,10 @@ enum SystemRunPolicy: String, CaseIterable, Identifiable { } } - static func load(from defaults: UserDefaults = .standard) -> SystemRunPolicy { + 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 } @@ -40,7 +43,7 @@ enum SystemRunPolicy: String, CaseIterable, Identifiable { } enum SystemRunAllowlist { - static func key(for argv: [String]) -> String { + 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), @@ -62,28 +65,14 @@ enum SystemRunAllowlist { }.joined(separator: " ") } - static func load(from defaults: UserDefaults = .standard) -> Set { - if let allowlist = MacNodeConfigFile.systemRunAllowlist() { + 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.setSystemRunAllowlist(legacy) + MacNodeConfigFile.setSystemRunAllowlistStrings(legacy) return Set(legacy) } return [] } - - static func contains(_ argv: [String], defaults: UserDefaults = .standard) -> Bool { - let key = key(for: argv) - return self.load(from: defaults).contains(key) - } - - static func add(_ argv: [String], defaults: UserDefaults = .standard) { - let key = key(for: argv) - guard !key.isEmpty else { return } - var allowlist = self.load(from: defaults) - if allowlist.insert(key).inserted { - MacNodeConfigFile.setSystemRunAllowlist(Array(allowlist).sorted()) - } - } } diff --git a/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift b/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift new file mode 100644 index 000000000..5b2b41a77 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift @@ -0,0 +1,291 @@ +import Foundation +import Observation +import SwiftUI + +struct SystemRunSettingsView: View { + @State private var model = SystemRunSettingsModel() + @State private var tab: SystemRunSettingsTab = .policy + @State private var newPattern: String = "" + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 12) { + Text("Node Run Commands") + .font(.body) + Spacer(minLength: 0) + if self.model.agentIds.count > 1 { + Picker("Agent", selection: Binding( + get: { self.model.selectedAgentId }, + set: { self.model.selectAgent($0) })) + { + ForEach(self.model.agentIds, id: \.self) { id in + Text(id).tag(id) + } + } + .pickerStyle(.menu) + .frame(width: 160, alignment: .trailing) + } + } + + Picker("", selection: self.$tab) { + ForEach(SystemRunSettingsTab.allCases) { tab in + Text(tab.title).tag(tab) + } + } + .pickerStyle(.segmented) + .frame(width: 280) + + if self.tab == .policy { + self.policyView + } else { + self.allowlistView + } + } + .task { await self.model.refresh() } + .onChange(of: self.tab) { _, _ in + Task { await self.model.refreshSkillBins() } + } + } + + private var policyView: some View { + VStack(alignment: .leading, spacing: 6) { + Picker("", selection: Binding( + get: { self.model.policy }, + set: { self.model.setPolicy($0) })) + { + ForEach(SystemRunPolicy.allCases) { policy in + Text(policy.title).tag(policy) + } + } + .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.") + .font(.footnote) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var allowlistView: some View { + VStack(alignment: .leading, spacing: 10) { + Toggle("Auto-allow skill CLIs", isOn: Binding( + get: { self.model.autoAllowSkills }, + set: { self.model.setAutoAllowSkills($0) })) + + if self.model.autoAllowSkills, !self.model.skillBins.isEmpty { + Text("Skill CLIs: \(self.model.skillBins.joined(separator: ", "))") + .font(.footnote) + .foregroundStyle(.secondary) + } + + HStack(spacing: 8) { + TextField("Add allowlist pattern (supports globs)", text: self.$newPattern) + .textFieldStyle(.roundedBorder) + Button("Add") { + let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines) + guard !pattern.isEmpty else { return } + self.model.addEntry(pattern) + self.newPattern = "" + } + .buttonStyle(.bordered) + .disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + if self.model.entries.isEmpty { + Text("No allowlisted commands yet.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(self.model.entries.enumerated()), id: \.element.id) { index, _ in + SystemRunAllowlistRow( + entry: Binding( + get: { self.model.entries[index] }, + set: { self.model.updateEntry($0) }), + onRemove: { self.model.removeEntry($0.id) }) + } + } + } + } + } +} + +private enum SystemRunSettingsTab: String, CaseIterable, Identifiable { + case policy + case allowlist + + var id: String { self.rawValue } + + var title: String { + switch self { + case .policy: "Policy" + case .allowlist: "Allowlist" + } + } +} + +struct SystemRunAllowlistRow: View { + @Binding var entry: SystemRunAllowlistEntry + let onRemove: (SystemRunAllowlistEntry) -> Void + @State private var draftPattern: String = "" + + private static let relativeFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter + }() + + 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) + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + } + + if let lastUsedAt = self.entry.lastUsedAt { + Text("Last used \(Self.relativeFormatter.localizedString(for: lastUsedAt, relativeTo: Date()))") + .font(.caption) + .foregroundStyle(.secondary) + } else if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty { + Text("Last used: \(lastUsedCommand)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .onAppear { + self.draftPattern = self.entry.pattern + } + } + + private var patternBinding: Binding { + Binding( + get: { self.draftPattern.isEmpty ? self.entry.pattern : self.draftPattern }, + set: { newValue in + self.draftPattern = newValue + self.entry.pattern = newValue + if self.entry.matchKind == .argv { + self.entry.matchKind = .glob + } + }) + } +} + +@MainActor +@Observable +final class SystemRunSettingsModel { + var agentIds: [String] = [] + var selectedAgentId: String = "main" + var defaultAgentId: String = "main" + var policy: SystemRunPolicy = .ask + var autoAllowSkills = false + var entries: [SystemRunAllowlistEntry] = [] + var skillBins: [String] = [] + + func refresh() async { + await self.refreshAgents() + self.loadSettings(for: self.selectedAgentId) + await self.refreshSkillBins() + } + + func refreshAgents() async { + let root = await ConfigStore.load() + let agents = root["agents"] as? [String: Any] + let list = agents?["list"] as? [[String: Any]] ?? [] + var ids: [String] = [] + var seen = Set() + var defaultId: String? + for entry in list { + guard let raw = entry["id"] as? String else { continue } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + if !seen.insert(trimmed).inserted { continue } + ids.append(trimmed) + if (entry["default"] as? Bool) == true, defaultId == nil { + defaultId = trimmed + } + } + if ids.isEmpty { + ids = ["main"] + defaultId = "main" + } else if defaultId == nil { + defaultId = ids.first + } + self.agentIds = ids + self.defaultAgentId = defaultId ?? "main" + if !self.agentIds.contains(self.selectedAgentId) { + self.selectedAgentId = self.defaultAgentId + } + } + + func selectAgent(_ id: String) { + self.selectedAgentId = id + self.loadSettings(for: id) + Task { await self.refreshSkillBins() } + } + + func loadSettings(for agentId: String) { + self.policy = SystemRunPolicy.load(agentId: agentId) + self.autoAllowSkills = MacNodeConfigFile.systemRunAutoAllowSkills(agentId: agentId) ?? false + self.entries = SystemRunAllowlistStore.load(agentId: agentId) + .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 setAutoAllowSkills(_ enabled: Bool) { + self.autoAllowSkills = enabled + MacNodeConfigFile.setSystemRunAutoAllowSkills(enabled, agentId: self.selectedAgentId) + 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) + } + + func updateEntry(_ entry: SystemRunAllowlistEntry) { + guard let index = self.entries.firstIndex(where: { $0.id == entry.id }) else { return } + self.entries[index] = entry + SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId) + } + + func removeEntry(_ id: String) { + self.entries.removeAll { $0.id == id } + SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId) + } + + func refreshSkillBins(force: Bool = false) async { + guard self.autoAllowSkills else { + self.skillBins = [] + return + } + let bins = await SkillBinsCache.shared.currentBins(force: force) + self.skillBins = bins.sorted() + } +} diff --git a/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift b/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift index 2830f0929..ddd94b4d0 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift @@ -34,7 +34,7 @@ import Testing let clawdbotPath = tmp.appendingPathComponent("node_modules/.bin/clawdbot") try self.makeExec(at: clawdbotPath) - let cmd = CommandResolver.clawdbotCommand(subcommand: "gateway", defaults: defaults) + let cmd = CommandResolver.clawdbotCommand(subcommand: "gateway", defaults: defaults, configRoot: [:]) #expect(cmd.prefix(2).elementsEqual([clawdbotPath.path, "gateway"])) } @@ -55,6 +55,7 @@ import Testing let cmd = CommandResolver.clawdbotCommand( subcommand: "rpc", defaults: defaults, + configRoot: [:], searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path]) #expect(cmd.count >= 3) @@ -75,7 +76,7 @@ import Testing let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm") try self.makeExec(at: pnpmPath) - let cmd = CommandResolver.clawdbotCommand(subcommand: "rpc", defaults: defaults) + let cmd = CommandResolver.clawdbotCommand(subcommand: "rpc", defaults: defaults, configRoot: [:]) #expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "rpc"])) } @@ -93,7 +94,8 @@ import Testing let cmd = CommandResolver.clawdbotCommand( subcommand: "health", extraArgs: ["--json", "--timeout", "5"], - defaults: defaults) + defaults: defaults, + configRoot: [:]) #expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "health", "--json"])) #expect(cmd.suffix(2).elementsEqual(["--timeout", "5"])) @@ -114,7 +116,11 @@ import Testing defaults.set("/tmp/id_ed25519", forKey: remoteIdentityKey) defaults.set("/srv/clawdbot", forKey: remoteProjectRootKey) - let cmd = CommandResolver.clawdbotCommand(subcommand: "status", extraArgs: ["--json"], defaults: defaults) + let cmd = CommandResolver.clawdbotCommand( + subcommand: "status", + extraArgs: ["--json"], + defaults: defaults, + configRoot: [:]) #expect(cmd.first == "/usr/bin/ssh") #expect(cmd.contains("clawd@example.com")) diff --git a/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift index 45943904c..3c4355360 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift @@ -75,7 +75,7 @@ struct MacNodeRuntimeTests { CLLocation(latitude: 0, longitude: 0) } - func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision { + func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision { .allowOnce } } diff --git a/apps/macos/Tests/ClawdbotIPCTests/SystemRunAllowlistTests.swift b/apps/macos/Tests/ClawdbotIPCTests/SystemRunAllowlistTests.swift new file mode 100644 index 000000000..fa0932c22 --- /dev/null +++ b/apps/macos/Tests/ClawdbotIPCTests/SystemRunAllowlistTests.swift @@ -0,0 +1,43 @@ +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/macos/Tests/ClawdbotIPCTests/UtilitiesTests.swift b/apps/macos/Tests/ClawdbotIPCTests/UtilitiesTests.swift index 5033cb9cc..4a01e8aee 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/UtilitiesTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/UtilitiesTests.swift @@ -37,7 +37,7 @@ import Testing defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey) defaults.set("ssh alice@example.com", forKey: remoteTargetKey) - let settings = CommandResolver.connectionSettings(defaults: defaults) + let settings = CommandResolver.connectionSettings(defaults: defaults, configRoot: [:]) #expect(settings.mode == .remote) #expect(settings.target == "alice@example.com") } diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift index ca35a25b3..dfe19d971 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift @@ -24,19 +24,22 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable { public var env: [String: String]? public var timeoutMs: Int? public var needsScreenRecording: Bool? + public var agentId: String? public init( command: [String], cwd: String? = nil, env: [String: String]? = nil, timeoutMs: Int? = nil, - needsScreenRecording: Bool? = nil) + needsScreenRecording: Bool? = nil, + agentId: String? = nil) { self.command = command self.cwd = cwd self.env = env self.timeoutMs = timeoutMs self.needsScreenRecording = needsScreenRecording + self.agentId = agentId } } diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md new file mode 100644 index 000000000..138879cdc --- /dev/null +++ b/docs/tools/exec-approvals.md @@ -0,0 +1,108 @@ +--- +summary: "Exec approvals, allowlists, and sandbox escape prompts in the macOS app" +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 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. + +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. + +## Settings + +In the macOS app, each agent has an **Exec approvals** setting: + +- **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. + +Optional toggles: +- **Auto-allow skill CLIs**: when enabled, CLIs referenced by known skills are + treated as allowlisted (see below). + +## 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**. + +Examples: +- `~/Projects/**/bin/bird` +- `~/.local/bin/*` +- `/opt/homebrew/bin/rg` + +Each allowlist entry tracks: +- **last used** (timestamp) +- **last used command** +- **last used path** (resolved absolute path) +- **last seen metadata** (hash/version/mtime when available) + +## How matching works + +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. + +## 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: +- command + args +- cwd +- environment overrides (diff) +- policy + rule that matched (if any) + +Actions: +- **Allow once** → run now +- **Always allow** → add/update allowlist entry + 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.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. + +## 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. + +Related: +- [Exec tool](/tools/exec) +- [Elevated mode](/tools/elevated) +- [Skills](/tools/skills) diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 4a6c3d372..d29789586 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -26,6 +26,11 @@ Note: `elevated` is ignored when sandboxing is off (exec already runs on the hos - `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit. +## Exec approvals (macOS app) + +Sandboxed agents can require per-request approval before `exec` runs on the host. +See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow. + ## Examples Foreground: diff --git a/docs/tools/index.md b/docs/tools/index.md index a3c4c68ac..f0099e8bb 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -177,6 +177,7 @@ Notes: - 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` only changes behavior when the agent is sandboxed (otherwise it’s a no-op). +- macOS app approvals/allowlists: [Exec approvals](/tools/exec-approvals). ### `process` Manage background exec sessions. diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index 60a7a918d..1e3e970e4 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -74,7 +74,10 @@ export function createClawdbotTools(options?: { allowedControlPorts: options?.allowedControlPorts, }), createCanvasTool(), - createNodesTool(), + createNodesTool({ + agentSessionKey: options?.agentSessionKey, + config: options?.config, + }), createCronTool({ agentSessionKey: options?.agentSessionKey, }), diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index 074165672..f214ddb89 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -17,12 +17,14 @@ import { writeScreenRecordToFile, } from "../../cli/nodes-screen.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; +import type { ClawdbotConfig } from "../../config/config.js"; import { imageMimeFromFormat } from "../../media/mime.js"; +import { resolveSessionAgentId } from "../agent-scope.js"; import { optionalStringEnum, stringEnum } from "../schema/typebox.js"; import { sanitizeToolResultImages } from "../tool-images.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; -import { resolveNodeId } from "./nodes-utils.js"; +import { listNodes, resolveNodeIdFromList, resolveNodeId } from "./nodes-utils.js"; const NODES_TOOL_ACTIONS = [ "status", @@ -86,7 +88,14 @@ const NodesToolSchema = Type.Object({ needsScreenRecording: Type.Optional(Type.Boolean()), }); -export function createNodesTool(): AnyAgentTool { +export function createNodesTool(options?: { + agentSessionKey?: string; + config?: ClawdbotConfig; +}): AnyAgentTool { + const agentId = resolveSessionAgentId({ + sessionKey: options?.agentSessionKey, + config: options?.config, + }); return { label: "Nodes", name: "nodes", @@ -375,7 +384,22 @@ export function createNodesTool(): AnyAgentTool { } case "run": { const node = readStringParam(params, "node", { required: true }); - const nodeId = await resolveNodeId(gatewayOpts, node); + const nodes = await listNodes(gatewayOpts); + if (nodes.length === 0) { + throw new Error( + "system.run requires a paired macOS companion app (no nodes available).", + ); + } + const nodeId = resolveNodeIdFromList(nodes, node); + 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( + "system.run requires the macOS companion app; the selected node does not support system.run.", + ); + } const commandRaw = params.command; if (!commandRaw) { throw new Error("command required (argv array, e.g. ['echo', 'Hello'])"); @@ -405,6 +429,7 @@ export function createNodesTool(): AnyAgentTool { env, timeoutMs: commandTimeoutMs, needsScreenRecording, + agentId, }, timeoutMs: invokeTimeoutMs, idempotencyKey: crypto.randomUUID(), diff --git a/src/agents/tools/nodes-utils.ts b/src/agents/tools/nodes-utils.ts index 2430a00dd..4e9ffe014 100644 --- a/src/agents/tools/nodes-utils.ts +++ b/src/agents/tools/nodes-utils.ts @@ -1,6 +1,6 @@ import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; -type NodeListNode = { +export type NodeListNode = { nodeId: string; displayName?: string; platform?: string; @@ -99,12 +99,15 @@ function pickDefaultNode(nodes: NodeListNode[]): NodeListNode | null { return null; } -export async function resolveNodeId( - opts: GatewayCallOptions, +export async function listNodes(opts: GatewayCallOptions): Promise { + return loadNodes(opts); +} + +export function resolveNodeIdFromList( + nodes: NodeListNode[], query?: string, allowDefault = false, -) { - const nodes = await loadNodes(opts); +): string { const q = String(query ?? "").trim(); if (!q) { if (allowDefault) { @@ -138,3 +141,12 @@ export async function resolveNodeId( .join(", ")})`, ); } + +export async function resolveNodeId( + opts: GatewayCallOptions, + query?: string, + allowDefault = false, +) { + const nodes = await loadNodes(opts); + return resolveNodeIdFromList(nodes, query, allowDefault); +}