import CryptoKit 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: .deny case .full: .allow case .allowlist: .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, Identifiable { var id: UUID var pattern: String var lastUsedAt: Double? var lastUsedCommand: String? var lastResolvedPath: String? init( id: UUID = UUID(), pattern: String, lastUsedAt: Double? = nil, lastUsedCommand: String? = nil, lastResolvedPath: String? = nil) { self.id = id self.pattern = pattern self.lastUsedAt = lastUsedAt self.lastUsedCommand = lastUsedCommand self.lastResolvedPath = lastResolvedPath } private enum CodingKeys: String, CodingKey { case id case pattern case lastUsedAt case lastUsedCommand case lastResolvedPath } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() self.pattern = try container.decode(String.self, forKey: .pattern) self.lastUsedAt = try container.decodeIfPresent(Double.self, forKey: .lastUsedAt) self.lastUsedCommand = try container.decodeIfPresent(String.self, forKey: .lastUsedCommand) self.lastResolvedPath = try container.decodeIfPresent(String.self, forKey: .lastResolvedPath) } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.id, forKey: .id) try container.encode(self.pattern, forKey: .pattern) try container.encodeIfPresent(self.lastUsedAt, forKey: .lastUsedAt) try container.encodeIfPresent(self.lastUsedCommand, forKey: .lastUsedCommand) try container.encodeIfPresent(self.lastResolvedPath, forKey: .lastResolvedPath) } } 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 { self.security == nil && self.ask == nil && self.askFallback == nil && self .autoAllowSkills == nil && (self.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 ExecApprovalsSnapshot: Codable { var path: String var exists: Bool var hash: String var file: ExecApprovalsFile } 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 defaultAgentId = "main" 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 normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile { let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" var agents = file.agents ?? [:] if let legacyDefault = agents["default"] { if let main = agents[self.defaultAgentId] { agents[self.defaultAgentId] = self.mergeAgents(current: main, legacy: legacyDefault) } else { agents[self.defaultAgentId] = legacyDefault } agents.removeValue(forKey: "default") } return ExecApprovalsFile( version: 1, socket: ExecApprovalsSocketConfig( path: socketPath.isEmpty ? nil : socketPath, token: token.isEmpty ? nil : token), defaults: file.defaults, agents: agents) } static func readSnapshot() -> ExecApprovalsSnapshot { let url = self.fileURL() guard FileManager().fileExists(atPath: url.path) else { return ExecApprovalsSnapshot( path: url.path, exists: false, hash: self.hashRaw(nil), file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])) } let raw = try? String(contentsOf: url, encoding: .utf8) let data = raw.flatMap { $0.data(using: .utf8) } let decoded: ExecApprovalsFile = { if let data, let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data), file.version == 1 { return file } return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) }() return ExecApprovalsSnapshot( path: url.path, exists: true, hash: self.hashRaw(raw), file: decoded) } static func redactForSnapshot(_ file: ExecApprovalsFile) -> ExecApprovalsFile { let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if socketPath.isEmpty { return ExecApprovalsFile( version: file.version, socket: nil, defaults: file.defaults, agents: file.agents) } return ExecApprovalsFile( version: file.version, socket: ExecApprovalsSocketConfig(path: socketPath, token: nil), defaults: file.defaults, agents: file.agents) } static func loadFile() -> ExecApprovalsFile { let url = self.fileURL() guard FileManager().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: nil, defaults: nil, 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().createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try data.write(to: url, options: [.atomic]) try? FileManager().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 { let 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 = self.agentKey(agentId) let agentEntry = file.agents?[key] ?? ExecApprovalsAgent() let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent() let resolvedAgent = ExecApprovalsResolvedDefaults( security: agentEntry.security ?? wildcardEntry.security ?? resolvedDefaults.security, ask: agentEntry.ask ?? wildcardEntry.ask ?? resolvedDefaults.ask, askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback ?? resolvedDefaults.askFallback, autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills) let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? [])) .map { entry in ExecAllowlistEntry( id: entry.id, 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( id: item.id, 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( id: item.id, 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 hashRaw(_ raw: String?) -> String { let data = Data((raw ?? "").utf8) let digest = SHA256.hash(data: data) return digest.map { String(format: "%02x", $0) }.joined() } private static func expandPath(_ raw: String) -> String { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed == "~" { return FileManager().homeDirectoryForCurrentUser.path } if trimmed.hasPrefix("~/") { let suffix = trimmed.dropFirst(2) return FileManager().homeDirectoryForCurrentUser .appendingPathComponent(String(suffix)).path } return trimmed } private static func agentKey(_ agentId: String?) -> String { let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? self.defaultAgentId : trimmed } private static func normalizedPattern(_ pattern: String?) -> String? { let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? nil : trimmed.lowercased() } private static func mergeAgents( current: ExecApprovalsAgent, legacy: ExecApprovalsAgent) -> ExecApprovalsAgent { var seen = Set() var allowlist: [ExecAllowlistEntry] = [] func append(_ entry: ExecAllowlistEntry) { guard let key = self.normalizedPattern(entry.pattern), !seen.contains(key) else { return } seen.insert(key) allowlist.append(entry) } for entry in current.allowlist ?? [] { append(entry) } for entry in legacy.allowlist ?? [] { append(entry) } return ExecApprovalsAgent( security: current.security ?? legacy.security, ask: current.ask ?? legacy.ask, askFallback: current.askFallback ?? legacy.askFallback, autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills, allowlist: allowlist.isEmpty ? nil : allowlist) } } struct ExecCommandResolution: Sendable { let rawExecutable: String let resolvedPath: String? let executableName: String let cwd: String? static func resolve( command: [String], rawCommand: String?, cwd: String?, env: [String: String]?) -> ExecCommandResolution? { let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) { return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env) } return self.resolve(command: command, cwd: cwd, env: env) } static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? { guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env) } private static func resolveExecutable( rawExecutable: String, cwd: String?, env: [String: String]?) -> ExecCommandResolution? { let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable 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().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 parseFirstToken(_ command: String) -> String? { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } guard let first = trimmed.first else { return nil } if first == "\"" || first == "'" { let rest = trimmed.dropFirst() if let end = rest.firstIndex(of: first) { return String(rest[.. [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: " ") } static func displayString(for argv: [String], rawCommand: String?) -> String { let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !trimmed.isEmpty { return trimmed } return self.displayString(for: argv) } } enum ExecApprovalHelpers { static func parseDecision(_ raw: String?) -> ExecApprovalDecision? { let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !trimmed.isEmpty else { return nil } return ExecApprovalDecision(rawValue: trimmed) } static func requiresAsk( ask: ExecAsk, security: ExecSecurity, allowlistMatch: ExecAllowlistEntry?, skillAllow: Bool) -> Bool { if ask == .always { return true } if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true } return false } static func allowlistPattern(command: [String], resolution: ExecCommandResolution?) -> String? { let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? "" return pattern.isEmpty ? nil : pattern } } 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 = 20000) -> 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 } }