mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 23:40:45 +00:00
795 lines
28 KiB
Swift
795 lines
28 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
|
|
}
|
|
|
|
enum ExecAllowlistPatternValidationReason: String, Codable, Sendable, Equatable {
|
|
case empty
|
|
case missingPathComponent
|
|
|
|
var message: String {
|
|
switch self {
|
|
case .empty:
|
|
"Pattern cannot be empty."
|
|
case .missingPathComponent:
|
|
"Path patterns only. Include '/', '~', or '\\\\'."
|
|
}
|
|
}
|
|
}
|
|
|
|
enum ExecAllowlistPatternValidation: Sendable, Equatable {
|
|
case valid(String)
|
|
case invalid(ExecAllowlistPatternValidationReason)
|
|
}
|
|
|
|
struct ExecAllowlistRejectedEntry: Sendable, Equatable {
|
|
let id: UUID
|
|
let pattern: String
|
|
let reason: ExecAllowlistPatternValidationReason
|
|
}
|
|
|
|
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: "ai.openclaw", 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 {
|
|
OpenClawPaths.stateDirURL.appendingPathComponent("exec-approvals.json")
|
|
}
|
|
|
|
static func socketPath() -> String {
|
|
OpenClawPaths.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")
|
|
}
|
|
if !agents.isEmpty {
|
|
var normalizedAgents: [String: ExecApprovalsAgent] = [:]
|
|
normalizedAgents.reserveCapacity(agents.count)
|
|
for (key, var agent) in agents {
|
|
if let allowlist = agent.allowlist {
|
|
let normalized = self.normalizeAllowlistEntries(allowlist, dropInvalid: false).entries
|
|
agent.allowlist = normalized.isEmpty ? nil : normalized
|
|
}
|
|
normalizedAgents[key] = agent
|
|
}
|
|
agents = normalizedAgents
|
|
}
|
|
return ExecApprovalsFile(
|
|
version: 1,
|
|
socket: ExecApprovalsSocketConfig(
|
|
path: socketPath.isEmpty ? nil : socketPath,
|
|
token: token.isEmpty ? nil : token),
|
|
defaults: file.defaults,
|
|
agents: agents.isEmpty ? nil : 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 {
|
|
let url = self.fileURL()
|
|
let existed = FileManager().fileExists(atPath: url.path)
|
|
let loaded = self.loadFile()
|
|
let loadedHash = self.hashFile(loaded)
|
|
|
|
var file = self.normalizeIncoming(loaded)
|
|
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 = [:] }
|
|
if !existed || loadedHash != self.hashFile(file) {
|
|
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 = self.normalizeAllowlistEntries(
|
|
(wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []),
|
|
dropInvalid: true).entries
|
|
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
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
static func addAllowlistEntry(agentId: String?, pattern: String) -> ExecAllowlistPatternValidationReason? {
|
|
let normalizedPattern: String
|
|
switch ExecApprovalHelpers.validateAllowlistPattern(pattern) {
|
|
case let .valid(validPattern):
|
|
normalizedPattern = validPattern
|
|
case let .invalid(reason):
|
|
return reason
|
|
}
|
|
|
|
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 == normalizedPattern }) { return }
|
|
allowlist.append(ExecAllowlistEntry(
|
|
pattern: normalizedPattern,
|
|
lastUsedAt: Date().timeIntervalSince1970 * 1000))
|
|
entry.allowlist = allowlist
|
|
agents[key] = entry
|
|
file.agents = agents
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) -> [ExecAllowlistRejectedEntry] {
|
|
var rejected: [ExecAllowlistRejectedEntry] = []
|
|
self.updateFile { file in
|
|
let key = self.agentKey(agentId)
|
|
var agents = file.agents ?? [:]
|
|
var entry = agents[key] ?? ExecApprovalsAgent()
|
|
let normalized = self.normalizeAllowlistEntries(allowlist, dropInvalid: true)
|
|
rejected = normalized.rejected
|
|
let cleaned = normalized.entries
|
|
entry.allowlist = cleaned
|
|
agents[key] = entry
|
|
file.agents = agents
|
|
}
|
|
return rejected
|
|
}
|
|
|
|
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 hashFile(_ file: ExecApprovalsFile) -> String {
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.sortedKeys]
|
|
let data = (try? encoder.encode(file)) ?? Data()
|
|
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? {
|
|
switch ExecApprovalHelpers.validateAllowlistPattern(pattern) {
|
|
case let .valid(normalized):
|
|
return normalized.lowercased()
|
|
case .invalid(.empty):
|
|
return nil
|
|
case .invalid:
|
|
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return trimmed.isEmpty ? nil : trimmed.lowercased()
|
|
}
|
|
}
|
|
|
|
private static func migrateLegacyPattern(_ entry: ExecAllowlistEntry) -> ExecAllowlistEntry {
|
|
let trimmedPattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let trimmedResolved = entry.lastResolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let normalizedResolved = trimmedResolved.isEmpty ? nil : trimmedResolved
|
|
|
|
switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) {
|
|
case let .valid(pattern):
|
|
return ExecAllowlistEntry(
|
|
id: entry.id,
|
|
pattern: pattern,
|
|
lastUsedAt: entry.lastUsedAt,
|
|
lastUsedCommand: entry.lastUsedCommand,
|
|
lastResolvedPath: normalizedResolved)
|
|
case .invalid:
|
|
switch ExecApprovalHelpers.validateAllowlistPattern(trimmedResolved) {
|
|
case let .valid(migratedPattern):
|
|
return ExecAllowlistEntry(
|
|
id: entry.id,
|
|
pattern: migratedPattern,
|
|
lastUsedAt: entry.lastUsedAt,
|
|
lastUsedCommand: entry.lastUsedCommand,
|
|
lastResolvedPath: normalizedResolved)
|
|
case .invalid:
|
|
return ExecAllowlistEntry(
|
|
id: entry.id,
|
|
pattern: trimmedPattern,
|
|
lastUsedAt: entry.lastUsedAt,
|
|
lastUsedCommand: entry.lastUsedCommand,
|
|
lastResolvedPath: normalizedResolved)
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func normalizeAllowlistEntries(
|
|
_ entries: [ExecAllowlistEntry],
|
|
dropInvalid: Bool) -> (entries: [ExecAllowlistEntry], rejected: [ExecAllowlistRejectedEntry])
|
|
{
|
|
var normalized: [ExecAllowlistEntry] = []
|
|
normalized.reserveCapacity(entries.count)
|
|
var rejected: [ExecAllowlistRejectedEntry] = []
|
|
|
|
for entry in entries {
|
|
let migrated = self.migrateLegacyPattern(entry)
|
|
let trimmedPattern = migrated.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let trimmedResolvedPath = migrated.lastResolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let normalizedResolvedPath = trimmedResolvedPath.isEmpty ? nil : trimmedResolvedPath
|
|
|
|
switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) {
|
|
case let .valid(pattern):
|
|
normalized.append(
|
|
ExecAllowlistEntry(
|
|
id: migrated.id,
|
|
pattern: pattern,
|
|
lastUsedAt: migrated.lastUsedAt,
|
|
lastUsedCommand: migrated.lastUsedCommand,
|
|
lastResolvedPath: normalizedResolvedPath))
|
|
case let .invalid(reason):
|
|
if dropInvalid {
|
|
rejected.append(
|
|
ExecAllowlistRejectedEntry(
|
|
id: migrated.id,
|
|
pattern: trimmedPattern,
|
|
reason: reason))
|
|
} else if reason != .empty {
|
|
normalized.append(
|
|
ExecAllowlistEntry(
|
|
id: migrated.id,
|
|
pattern: trimmedPattern,
|
|
lastUsedAt: migrated.lastUsedAt,
|
|
lastUsedCommand: migrated.lastUsedCommand,
|
|
lastResolvedPath: normalizedResolvedPath))
|
|
}
|
|
}
|
|
}
|
|
|
|
return (normalized, rejected)
|
|
}
|
|
|
|
private static func mergeAgents(
|
|
current: ExecApprovalsAgent,
|
|
legacy: ExecApprovalsAgent) -> ExecApprovalsAgent
|
|
{
|
|
let currentAllowlist = self.normalizeAllowlistEntries(current.allowlist ?? [], dropInvalid: false).entries
|
|
let legacyAllowlist = self.normalizeAllowlistEntries(legacy.allowlist ?? [], dropInvalid: false).entries
|
|
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 currentAllowlist {
|
|
append(entry)
|
|
}
|
|
for entry in legacyAllowlist {
|
|
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)
|
|
}
|
|
}
|
|
|
|
enum ExecApprovalHelpers {
|
|
static func validateAllowlistPattern(_ pattern: String?) -> ExecAllowlistPatternValidation {
|
|
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard !trimmed.isEmpty else { return .invalid(.empty) }
|
|
guard self.containsPathComponent(trimmed) else { return .invalid(.missingPathComponent) }
|
|
return .valid(trimmed)
|
|
}
|
|
|
|
static func isPathPattern(_ pattern: String?) -> Bool {
|
|
switch self.validateAllowlistPattern(pattern) {
|
|
case .valid:
|
|
true
|
|
case .invalid:
|
|
false
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
private static func containsPathComponent(_ pattern: String) -> Bool {
|
|
pattern.contains("/") || pattern.contains("~") || pattern.contains("\\")
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|