743 lines
27 KiB
Swift
743 lines
27 KiB
Swift
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 {
|
|
var pattern: String
|
|
var lastUsedAt: Double?
|
|
var lastUsedCommand: String?
|
|
var lastResolvedPath: String?
|
|
}
|
|
|
|
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(
|
|
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 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<String>()
|
|
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[..<end])
|
|
}
|
|
return String(rest)
|
|
}
|
|
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
|
|
}
|
|
|
|
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: " ")
|
|
}
|
|
|
|
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<String> = []
|
|
private var lastRefresh: Date?
|
|
private let refreshInterval: TimeInterval = 90
|
|
|
|
func currentBins(force: Bool = false) async -> Set<String> {
|
|
if force || self.isStale() {
|
|
await self.refresh()
|
|
}
|
|
return self.bins
|
|
}
|
|
|
|
func refresh() async {
|
|
do {
|
|
let report = try await GatewayConnection.shared.skillsStatus()
|
|
var next = Set<String>()
|
|
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
|
|
}
|
|
}
|