Files
openclaw/apps/macos/Sources/OpenClaw/ExecApprovalsStore.swift
Peter Steinberger 1d57a4989b fix(mac): stop repeated exec approvals migration retries (#115502)
* fix(mac): classify exec-approvals migration errors and stop retry storm

* fix(mac): refresh native i18n inventory
2026-07-29 01:09:22 -04:00

1097 lines
45 KiB
Swift

import CryptoKit
import Darwin
import Foundation
import OpenClawKit
import OSLog
import Security
enum ExecApprovalsMigrationLogEvent: Equatable {
case required(ExecApprovalsLegacyMigrationRequiredError)
case recovered(stateDirectoryPath: String)
}
final class ExecApprovalsMigrationRequiredCache: @unchecked Sendable {
struct FileIdentity: Hashable, Sendable {
let device: UInt64
let inode: UInt64
let modificationSeconds: Int64
let modificationNanoseconds: Int64
}
private struct CachedFailure {
let error: ExecApprovalsLegacyMigrationRequiredError
let identity: FileIdentity
}
private struct Condition {
var cachedFailure: CachedFailure?
var loggedIdentities: Set<FileIdentity?> = []
}
private let lock = NSLock()
private var conditions: [String: Condition] = [:]
private let identityReader: (URL) -> FileIdentity?
private let onEvent: (ExecApprovalsMigrationLogEvent) -> Void
init(
identityReader: @escaping (URL) -> FileIdentity? = ExecApprovalsMigrationRequiredCache.fileIdentity,
onEvent: @escaping (ExecApprovalsMigrationLogEvent) -> Void)
{
self.identityReader = identityReader
self.onEvent = onEvent
}
func cachedError(stateDirectoryURL: URL) -> ExecApprovalsLegacyMigrationRequiredError? {
let key = Self.stateDirectoryKey(stateDirectoryURL)
self.lock.lock()
defer { self.lock.unlock() }
guard var condition = self.conditions[key],
let cachedFailure = condition.cachedFailure
else { return nil }
guard self.identityReader(cachedFailure.error.legacyFileURL) == cachedFailure.identity else {
// A changed path may be Doctor's source -> claim rename. Keep the condition
// open until a full SQLite resolve succeeds, so that rename cannot log recovery.
condition.cachedFailure = nil
self.conditions[key] = condition
return nil
}
return cachedFailure.error
}
func record(_ error: ExecApprovalsLegacyMigrationRequiredError) {
let identity = self.identityReader(error.legacyFileURL)
let key = Self.stateDirectoryKey(error.stateDirectoryURL)
self.lock.lock()
var condition = self.conditions[key] ?? Condition()
let shouldLog = condition.loggedIdentities.insert(identity).inserted
condition.cachedFailure = identity.map { CachedFailure(error: error, identity: $0) }
self.conditions[key] = condition
self.lock.unlock()
if shouldLog {
self.onEvent(.required(error))
}
}
func markResolved(stateDirectoryURL: URL) {
let key = Self.stateDirectoryKey(stateDirectoryURL)
self.lock.lock()
let recovered = self.conditions.removeValue(forKey: key) != nil
self.lock.unlock()
if recovered {
self.onEvent(.recovered(stateDirectoryPath: key))
}
}
private static func stateDirectoryKey(_ url: URL) -> String {
url.standardizedFileURL.path
}
private static func fileIdentity(_ url: URL) -> FileIdentity? {
var status = stat()
guard lstat(url.path, &status) == 0 else { return nil }
return FileIdentity(
device: UInt64(truncatingIfNeeded: status.st_dev),
inode: UInt64(truncatingIfNeeded: status.st_ino),
modificationSeconds: Int64(status.st_mtimespec.tv_sec),
modificationNanoseconds: Int64(status.st_mtimespec.tv_nsec))
}
}
enum ExecApprovalsStore {
private static let logger = Logger(subsystem: "ai.openclaw", category: "exec-approvals")
private static let migrationRequiredCache = ExecApprovalsMigrationRequiredCache { event in
switch event {
case let .required(error):
Self.logger.error("exec approvals resolve blocked: \(error.localizedDescription, privacy: .public)")
case let .recovered(stateDirectoryPath):
Self.logger.info(
"exec approvals migration requirement cleared for \(stateDirectoryPath, privacy: .public)")
}
}
private static let defaultAgentId = "main"
// Keep omitted-file behavior aligned with the TypeScript gateway/CLI contract.
private static let defaultSecurity: ExecSecurity = .full
private static let defaultAsk: ExecAsk = .off
private static let defaultAskFallback: ExecSecurity = .deny
private static let defaultAutoAllowSkills = false
static func databaseURL() -> URL {
ExecApprovalsSQLiteStore.databaseURL(stateDirectoryURL: self.stateDirURL())
}
static func socketPath() -> String {
self.stateDirURL().appendingPathComponent("exec-approvals.sock").path
}
private static func homeURL() -> URL {
guard let configured = OpenClawEnv.path("OPENCLAW_HOME") else {
return FileManager().homeDirectoryForCurrentUser
}
return URL(
fileURLWithPath: (configured as NSString).expandingTildeInPath,
isDirectory: true)
}
private static func stateDirURL() -> URL {
guard let configured = OpenClawEnv.path("OPENCLAW_STATE_DIR") else {
return self.homeURL().appendingPathComponent(".openclaw", isDirectory: true)
}
let home = self.homeURL().path
let expanded: String = if configured == "~" {
home
} else if configured.hasPrefix("~/") {
URL(fileURLWithPath: home, isDirectory: true)
.appendingPathComponent(String(configured.dropFirst(2)), isDirectory: true)
.path
} else {
configured
}
return URL(fileURLWithPath: expanded, isDirectory: true).standardizedFileURL
}
private static func failClosedFallbackFile() -> ExecApprovalsFile {
ExecApprovalsFile(
version: 1,
socket: nil,
defaults: ExecApprovalsDefaults(
security: .deny,
ask: .off,
askFallback: .deny,
autoAllowSkills: false),
agents: [:])
}
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[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 {
do {
let record = try ExecApprovalsSQLiteStore.read(stateDirectoryURL: self.stateDirURL())
return self.snapshot(record)
} catch {
self.logger.warning("exec approvals snapshot read failed: \(error.localizedDescription, privacy: .public)")
return ExecApprovalsSnapshot(
path: ExecApprovalsSQLiteStore.locator,
exists: false,
hash: "",
file: self.failClosedFallbackFile())
}
}
private static func snapshot(_ record: ExecApprovalsSQLiteRecord?) -> ExecApprovalsSnapshot {
guard let record else {
return ExecApprovalsSnapshot(
path: ExecApprovalsSQLiteStore.locator,
exists: false,
hash: self.hashRaw(nil),
file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]))
}
return ExecApprovalsSnapshot(
path: ExecApprovalsSQLiteStore.locator,
exists: true,
hash: self.hashRaw(record.rawJSON),
file: self.normalizeIncoming(record.document))
}
static func loadFile() -> ExecApprovalsFile {
do {
return try self.loadFileForMutation(
ExecApprovalsSQLiteStore.read(stateDirectoryURL: self.stateDirURL()))
} catch {
self.logger.warning("exec approvals read failed: \(error.localizedDescription, privacy: .public)")
return self.failClosedFallbackFile()
}
}
private static func loadFileForMutation(
_ record: ExecApprovalsSQLiteRecord?) throws -> ExecApprovalsFile
{
self.normalizeIncoming(
record?.document ?? ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]))
}
static func ensureFile() -> ExecApprovalsFile {
do {
return try ExecApprovalsSQLiteStore.withImmediateTransaction(
stateDirectoryURL: self.stateDirURL())
{ record in
let ensured = self.ensureFile(record)
return ExecApprovalsSQLiteMutation(
value: ensured.file,
documentToWrite: ensured.needsWrite ? ensured.file : nil)
}
} catch {
self.logger.error("exec approvals ensure failed: \(error.localizedDescription, privacy: .public)")
return self.failClosedFallbackFile()
}
}
private static func ensureFile(
_ record: ExecApprovalsSQLiteRecord?) -> (file: ExecApprovalsFile, needsWrite: Bool)
{
var file = self.normalizeIncoming(
record?.document ?? ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]))
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 = [:]
}
let needsCanonicalRewrite = record.map { self.rawNeedsAllowlistRewrite($0.rawJSON) } ?? false
return (file, record?.document != file || needsCanonicalRewrite)
}
private static func rawNeedsAllowlistRewrite(_ rawJSON: String) -> Bool {
guard let data = rawJSON.data(using: .utf8),
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let agents = root["agents"] as? [String: Any]
else { return false }
for case let agent as [String: Any] in agents.values {
guard let allowlist = agent["allowlist"] as? [Any] else { continue }
if allowlist.contains(where: { value in
guard let entry = value as? [String: Any],
let rawID = entry["id"] as? String
else { return true }
return rawID.isEmpty || entry["commandText"] != nil
}) {
return true
}
}
return false
}
static func saveFile(
_ incoming: ExecApprovalsFile,
ifBaseHash baseHash: String?) -> ExecApprovalsConditionalSaveResult
{
do {
return try ExecApprovalsSQLiteStore.withImmediateTransaction(
stateDirectoryURL: self.stateDirURL())
{ record in
// A conditional write must not create or normalize policy state
// before it proves the caller still owns the observed snapshot.
let snapshot = self.snapshot(record)
let expected = baseHash?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if snapshot.exists {
if snapshot.hash.isEmpty {
return ExecApprovalsSQLiteMutation(value: .baseHashUnavailable)
}
if expected.isEmpty {
return ExecApprovalsSQLiteMutation(value: .baseHashRequired)
}
if expected != snapshot.hash {
return ExecApprovalsSQLiteMutation(value: .conflict)
}
} else if !expected.isEmpty, expected != snapshot.hash {
return ExecApprovalsSQLiteMutation(value: .conflict)
}
let current = self.ensureFile(record).file
var normalized = self.normalizeIncoming(incoming)
let socketPath = normalized.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines)
let token = normalized.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedPath = (socketPath?.isEmpty == false)
? socketPath!
: current.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ??
self.socketPath()
let resolvedToken = (token?.isEmpty == false)
? token!
: current.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
normalized.socket = ExecApprovalsSocketConfig(path: resolvedPath, token: resolvedToken)
let rawJSON = try ExecApprovalsSQLiteStore.serialize(normalized)
let saved = self.snapshot(ExecApprovalsSQLiteRecord(
rawJSON: rawJSON,
document: normalized))
return ExecApprovalsSQLiteMutation(value: .saved(saved), documentToWrite: normalized)
}
} catch {
self.logger.error("exec approvals conditional save failed: \(error.localizedDescription, privacy: .public)")
return .unavailable
}
}
static func resolve(agentId: String?) -> ExecApprovalsResolved {
switch self.resolveResult(agentId: agentId) {
case let .success(resolved):
resolved
case .failure:
self.resolveFromFile(self.failClosedFallbackFile(), agentId: agentId)
}
}
static func resolveResult(
agentId: String?) -> Result<ExecApprovalsResolved, ExecApprovalsReadError>
{
let stateDirectoryURL = self.stateDirURL()
if let error = self.migrationRequiredCache.cachedError(stateDirectoryURL: stateDirectoryURL) {
return .failure(.migrationRequired(error))
}
do {
let file = try ExecApprovalsSQLiteStore.withImmediateTransaction(
stateDirectoryURL: stateDirectoryURL)
{ record in
let ensured = self.ensureFile(record)
return ExecApprovalsSQLiteMutation(
value: ensured.file,
documentToWrite: ensured.needsWrite ? ensured.file : nil)
}
self.migrationRequiredCache.markResolved(stateDirectoryURL: stateDirectoryURL)
return .success(self.resolveFromFile(file, agentId: agentId))
} catch let error as ExecApprovalsLegacyMigrationRequiredError {
self.migrationRequiredCache.record(error)
return .failure(.migrationRequired(error))
} catch {
self.logger.warning("exec approvals resolve failed: \(error.localizedDescription, privacy: .public)")
return .failure(.unavailable)
}
}
static func resolveAsyncResult(
agentId: String?) async -> Result<ExecApprovalsResolved, ExecApprovalsReadError>
{
await Task.detached(priority: .userInitiated) {
self.resolveResult(agentId: agentId)
}.value
}
static func resolveDefaults(from file: ExecApprovalsFile) -> ExecApprovalsResolvedDefaults {
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)
}
private static func resolveFromFile(_ file: ExecApprovalsFile, agentId: String?) -> ExecApprovalsResolved {
let resolvedDefaults = self.resolveDefaults(from: file)
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.databaseURL(),
socketPath: socketPath,
token: token,
defaults: resolvedDefaults,
agent: resolvedAgent,
allowlist: allowlist,
file: file)
}
static func resolveDefaultsAsyncResult() async
-> Result<ExecApprovalsResolvedDefaults, ExecApprovalsReadError>
{
await self.resolveAsyncResult(agentId: nil).map(\.defaults)
}
}
extension ExecApprovalsStore {
@discardableResult
static func updateDefaults(
_ mutate: (inout ExecApprovalsDefaults) -> Void) -> Result<Void, ExecApprovalsMutationError>
{
self.updateFile { file in
var defaults = file.defaults ?? ExecApprovalsDefaults()
mutate(&defaults)
file.defaults = defaults
}
}
@discardableResult
static func addAllowlistEntry(
agentId: String?,
pattern: String,
source: String? = nil,
commandText: String? = nil,
argPattern: String? = nil) -> Result<Void, ExecApprovalsMutationError>
{
self.addAllowlistEntries(
agentId: agentId,
entries: [ExecAllowlistEntry(
pattern: pattern,
source: source,
commandText: commandText,
argPattern: argPattern)])
}
@discardableResult
static func addAllowlistEntries(
agentId: String?,
entries: [ExecAllowlistEntry]) -> Result<Void, ExecApprovalsMutationError>
{
var normalizedEntries: [ExecAllowlistEntry] = []
normalizedEntries.reserveCapacity(entries.count)
for var item in entries {
switch ExecApprovalHelpers.validateAllowlistPattern(item.pattern) {
case let .valid(pattern):
item.pattern = pattern
case let .invalid(reason):
return .failure(.invalidPattern(reason))
}
item.commandText = nil
item.argPattern = self.normalizeArgPattern(item.argPattern)
normalizedEntries.append(item)
}
return self.updateFile { file in
let key = self.agentKey(agentId)
var agents = file.agents ?? [:]
var entry = agents[key] ?? ExecApprovalsAgent()
var allowlist = entry.allowlist ?? []
let now = Date().timeIntervalSince1970 * 1000
for incoming in normalizedEntries {
if let index = allowlist.firstIndex(where: {
self.allowlistEntryMatchKey($0) == self.allowlistEntryMatchKey(incoming)
}) {
if let source = incoming.source {
allowlist[index].source = source
}
allowlist[index].lastUsedAt = now
continue
}
allowlist.append(ExecAllowlistEntry(
pattern: incoming.pattern,
source: incoming.source,
argPattern: incoming.argPattern,
lastUsedAt: now))
}
entry.allowlist = allowlist
agents[key] = entry
file.agents = agents
}
}
@discardableResult
static func commitExecution(
_ commit: ExecApprovalExecutionCommit) -> Result<Void, ExecApprovalsMutationError>
{
let grants: [ExecAllowlistUse] = switch commit.authorization {
case let .explicitAlways(_, _, grants):
grants
case .currentPolicy, .askFallback, .autoReview, .explicitOnce:
[]
}
let normalizedGrants: [ExecAllowlistUse]
switch self.normalizeExecutionGrants(grants) {
case let .success(normalized):
normalizedGrants = normalized
case let .failure(error):
return .failure(error)
}
let authorizationUsesByKey = Dictionary(
commit.uses.map { (self.allowlistEntryMatchKey($0.match), $0) },
uniquingKeysWith: { first, _ in first })
let allUsesByKey = Dictionary(
(commit.uses + normalizedGrants).map { (self.allowlistEntryMatchKey($0.match), $0) },
uniquingKeysWith: { first, _ in first })
do {
try ExecApprovalsSQLiteStore.withImmediateTransaction(
stateDirectoryURL: self.stateDirURL())
{ record in
let ensured = self.ensureFile(record)
var file = ensured.file
try self.assertCurrentExecutionAuthorization(
file: file,
agentId: commit.agentId,
usesByKey: authorizationUsesByKey,
authorization: commit.authorization)
let grantsChanged = self.applyAllowlistGrantsUnlocked(
file: &file,
agentId: commit.agentId,
grants: normalizedGrants)
let usesChanged = self.applyAllowlistUsesUnlocked(
file: &file,
agentId: commit.agentId,
usesByKey: allUsesByKey,
command: commit.command)
return ExecApprovalsSQLiteMutation(
value: (),
documentToWrite: ensured.needsWrite || grantsChanged || usesChanged ? file : nil)
}
return .success(())
} catch {
self.logger.error("exec approval execution commit failed: \(error.localizedDescription, privacy: .public)")
return .failure(.unavailable)
}
}
@discardableResult
static func recordAllowlistUses(
agentId: String?,
uses: [ExecAllowlistUse],
command: String,
authorization: ExecApprovalAuthorization? = nil) -> Result<Void, ExecApprovalsMutationError>
{
if let authorization {
return self.commitExecution(ExecApprovalExecutionCommit(
agentId: agentId,
command: command,
authorization: authorization,
uses: uses))
}
guard !uses.isEmpty else { return .success(()) }
let usesByKey = Dictionary(
uses.map { (self.allowlistEntryMatchKey($0.match), $0) },
uniquingKeysWith: { first, _ in first })
do {
try ExecApprovalsSQLiteStore.withImmediateTransaction(
stateDirectoryURL: self.stateDirURL())
{ record in
let ensured = self.ensureFile(record)
var file = ensured.file
let changed = self.applyAllowlistUsesUnlocked(
file: &file,
agentId: agentId,
usesByKey: usesByKey,
command: command)
return ExecApprovalsSQLiteMutation(
value: (),
documentToWrite: ensured.needsWrite || changed ? file : nil)
}
return .success(())
} catch {
self.logger.error("exec approvals usage update failed: \(error.localizedDescription, privacy: .public)")
return .failure(.unavailable)
}
}
private static func normalizeExecutionGrants(
_ grants: [ExecAllowlistUse]) -> Result<[ExecAllowlistUse], ExecApprovalsMutationError>
{
var normalized: [ExecAllowlistUse] = []
normalized.reserveCapacity(grants.count)
for grant in grants {
switch ExecApprovalHelpers.validateAllowlistPattern(grant.match.pattern) {
case let .valid(pattern):
normalized.append(ExecAllowlistUse(
match: ExecAllowlistEntry(
id: grant.match.id,
pattern: pattern,
source: "allow-always",
argPattern: self.normalizeArgPattern(grant.match.argPattern)),
resolvedPath: grant.resolvedPath))
case let .invalid(reason):
return .failure(.invalidPattern(reason))
}
}
return .success(normalized)
}
private static func assertCurrentExecutionAuthorization(
file: ExecApprovalsFile,
agentId: String?,
usesByKey: [ExecAllowlistEntryMatchKey: ExecAllowlistUse],
authorization: ExecApprovalAuthorization) throws
{
let current = self.resolveFromFile(file, agentId: agentId)
let evaluatedSecurity: ExecSecurity
let evaluatedAsk: ExecAsk?
let basis: ExecApprovalAuthorization.Basis?
let appliesFallback: Bool
switch authorization {
case let .autoReview(security, policySnapshot):
guard ExecSecurity.narrower(security, current.agent.security) != .deny,
current.agent.ask != .always,
policySnapshot.isCurrent(ExecApprovalPolicySnapshot(resolved: current))
else {
throw self.executionAuthorizationChangedError()
}
return
case let .explicitOnce(security, policySnapshot):
guard ExecSecurity.narrower(security, current.agent.security) != .deny,
policySnapshot.isCurrent(ExecApprovalPolicySnapshot(resolved: current))
else {
throw self.executionAuthorizationChangedError()
}
return
case let .explicitAlways(security, policySnapshot, _):
guard ExecSecurity.narrower(security, current.agent.security) != .deny,
policySnapshot.isCurrent(ExecApprovalPolicySnapshot(resolved: current))
else {
throw self.executionAuthorizationChangedError()
}
return
case let .currentPolicy(security, ask, authorizationBasis):
evaluatedSecurity = security
evaluatedAsk = ask
basis = authorizationBasis
appliesFallback = false
case let .askFallback(security, authorizationBasis):
evaluatedSecurity = security
evaluatedAsk = nil
basis = authorizationBasis
appliesFallback = true
}
let currentKeys = Set(current.allowlist.map(self.allowlistEntryMatchKey))
let currentSecurity = ExecSecurity.narrower(evaluatedSecurity, current.agent.security)
let authorizationSecurity = appliesFallback
? ExecSecurity.narrower(currentSecurity, current.agent.askFallback)
: currentSecurity
let currentAskAllowsExecution = evaluatedAsk.map {
ExecAsk.stricter($0, current.agent.ask) == $0
} ?? true
let basisIsCurrent: Bool = switch basis {
case .allowlistEntries:
!usesByKey.isEmpty && usesByKey.keys.allSatisfy { currentKeys.contains($0) }
case .autoAllowedSkill:
current.agent.autoAllowSkills
case nil:
false
}
let authorizationIsCurrent: Bool = switch authorizationSecurity {
case .deny:
false
case .full:
currentAskAllowsExecution && authorizationSecurity == evaluatedSecurity
case .allowlist:
currentAskAllowsExecution &&
authorizationSecurity == evaluatedSecurity &&
basisIsCurrent
}
guard authorizationIsCurrent else {
throw self.executionAuthorizationChangedError()
}
}
private static func executionAuthorizationChangedError() -> NSError {
NSError(domain: "ExecApprovals", code: 21, userInfo: [
NSLocalizedDescriptionKey: "exec approval changed before execution",
])
}
private static func applyAllowlistGrantsUnlocked(
file: inout ExecApprovalsFile,
agentId: String?,
grants: [ExecAllowlistUse]) -> Bool
{
guard !grants.isEmpty else { return false }
let key = self.agentKey(agentId)
var agents = file.agents ?? [:]
var entry = agents[key] ?? ExecApprovalsAgent()
var allowlist = entry.allowlist ?? []
let now = Date().timeIntervalSince1970 * 1000
for grant in grants {
let incoming = grant.match
if let index = allowlist.firstIndex(where: {
self.allowlistEntryMatchKey($0) == self.allowlistEntryMatchKey(incoming)
}) {
allowlist[index].source = "allow-always"
allowlist[index].lastUsedAt = now
continue
}
allowlist.append(ExecAllowlistEntry(
pattern: incoming.pattern,
source: "allow-always",
argPattern: incoming.argPattern,
lastUsedAt: now))
}
entry.allowlist = allowlist
agents[key] = entry
file.agents = agents
return true
}
private static func applyAllowlistUsesUnlocked(
file: inout ExecApprovalsFile,
agentId: String?,
usesByKey: [ExecAllowlistEntryMatchKey: ExecAllowlistUse],
command: String) -> Bool
{
guard !usesByKey.isEmpty else { return false }
let key = self.agentKey(agentId)
let targetKeys = key == "*" ? [key] : ["*", key]
let now = Date().timeIntervalSince1970 * 1000
var changed = false
var agents = file.agents ?? [:]
for targetKey in targetKeys {
guard var entry = agents[targetKey], let currentAllowlist = entry.allowlist else { continue }
var entryChanged = false
let allowlist = currentAllowlist.map { item -> ExecAllowlistEntry in
guard let use = usesByKey[self.allowlistEntryMatchKey(item)] else { return item }
entryChanged = true
return ExecAllowlistEntry(
id: item.id,
pattern: item.pattern,
source: item.source,
argPattern: item.argPattern,
lastUsedAt: now,
lastUsedCommand: self.shouldRecordLastUsedCommand(for: item) ? command : nil,
lastResolvedPath: use.resolvedPath)
}
if entryChanged {
changed = true
entry.allowlist = allowlist
agents[targetKey] = entry
}
}
if changed {
file.agents = agents
}
return changed
}
private static func shouldRecordLastUsedCommand(for entry: ExecAllowlistEntry) -> Bool {
!(entry.argPattern?.hasPrefix("sha256:argv:") ?? false)
}
@discardableResult
static func updateAllowlistEntry(
agentId: String?,
id: String,
pattern: String) -> Result<Void, ExecApprovalsMutationError>
{
let normalizedPattern: String
switch ExecApprovalHelpers.validateAllowlistPattern(pattern) {
case let .valid(validPattern):
normalizedPattern = validPattern
case let .invalid(reason):
return .failure(.invalidPattern(reason))
}
return self.updateFile { file in
let key = self.agentKey(agentId)
var agents = file.agents ?? [:]
var agent = agents[key] ?? ExecApprovalsAgent()
var allowlist = agent.allowlist ?? []
guard let index = allowlist.firstIndex(where: { $0.id == id }) else {
if key != "*", agents["*"]?.allowlist?.contains(where: { $0.id == id }) == true {
throw ExecApprovalsMutationError.entryNotOwned
}
return
}
allowlist[index].pattern = normalizedPattern
agent.allowlist = allowlist
agents[key] = agent
file.agents = agents
}
}
@discardableResult
static func removeAllowlistEntry(
agentId: String?,
id: String) -> Result<Void, ExecApprovalsMutationError>
{
self.updateFile { file in
let key = self.agentKey(agentId)
var agents = file.agents ?? [:]
var agent = agents[key] ?? ExecApprovalsAgent()
var allowlist = agent.allowlist ?? []
guard let index = allowlist.firstIndex(where: { $0.id == id }) else {
if key != "*", agents["*"]?.allowlist?.contains(where: { $0.id == id }) == true {
throw ExecApprovalsMutationError.entryNotOwned
}
return
}
allowlist.remove(at: index)
agent.allowlist = allowlist
agents[key] = agent
file.agents = agents
}
}
@discardableResult
static func updateAgentSettings(
agentId: String?,
mutate: (inout ExecApprovalsAgent) -> Void) -> Result<Void, ExecApprovalsMutationError>
{
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) throws -> Void) -> Result<Void, ExecApprovalsMutationError>
{
do {
try ExecApprovalsSQLiteStore.withImmediateTransaction(
stateDirectoryURL: self.stateDirURL())
{ record in
var file = self.ensureFile(record).file
try mutate(&file)
return ExecApprovalsSQLiteMutation(
value: (),
documentToWrite: self.normalizeIncoming(file))
}
return .success(())
} catch let error as ExecApprovalsMutationError {
return .failure(error)
} catch {
self.logger.error("exec approvals update failed: \(error.localizedDescription, privacy: .public)")
return .failure(.unavailable)
}
}
private static func normalizeArgPattern(_ value: String?) -> String? {
guard let value, !value.isEmpty else { return nil }
return value
}
static func allowlistEntryMatchKey(_ entry: ExecAllowlistEntry) -> ExecAllowlistEntryMatchKey {
ExecAllowlistEntryMatchKey(
pattern: entry.pattern,
argPattern: entry.argPattern)
}
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)
let hash = digest.map { String(format: "%02x", $0) }.joined()
return raw == nil ? "missing:\(hash)" : hash
}
static func expandPath(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
let configuredHome = OpenClawEnv.path("OPENCLAW_HOME")
.map { ($0 as NSString).expandingTildeInPath }
let home = configuredHome.map { URL(fileURLWithPath: $0, isDirectory: true) }
?? FileManager().homeDirectoryForCurrentUser
if trimmed == "~" {
return home.path
}
if trimmed.hasPrefix("~/") {
let suffix = trimmed.dropFirst(2)
return home.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
if !ExecApprovalHelpers.patternHasPathSelector(trimmedPattern),
!trimmedResolved.isEmpty,
case let .valid(migratedPattern) = ExecApprovalHelpers.validateAllowlistPattern(trimmedResolved)
{
return ExecAllowlistEntry(
id: entry.id,
pattern: migratedPattern,
source: entry.source,
commandText: entry.commandText,
argPattern: entry.argPattern,
lastUsedAt: entry.lastUsedAt,
lastUsedCommand: entry.lastUsedCommand,
lastResolvedPath: normalizedResolved)
}
switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) {
case let .valid(pattern):
return ExecAllowlistEntry(
id: entry.id,
pattern: pattern,
source: entry.source,
commandText: entry.commandText,
argPattern: entry.argPattern,
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,
source: entry.source,
commandText: entry.commandText,
argPattern: entry.argPattern,
lastUsedAt: entry.lastUsedAt,
lastUsedCommand: entry.lastUsedCommand,
lastResolvedPath: normalizedResolved)
case .invalid:
return ExecAllowlistEntry(
id: entry.id,
pattern: trimmedPattern,
source: entry.source,
commandText: entry.commandText,
argPattern: entry.argPattern,
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 {
var migrated = self.migrateLegacyPattern(entry)
// Command text can contain secrets; it is accepted only for legacy decode.
migrated.commandText = nil
// Regex whitespace and Unicode normalization are semantic policy bytes.
let normalizedArgPattern = self.normalizeArgPattern(migrated.argPattern)
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,
source: migrated.source,
commandText: migrated.commandText,
argPattern: normalizedArgPattern,
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,
source: migrated.source,
commandText: migrated.commandText,
argPattern: normalizedArgPattern,
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<ExecAllowlistEntryMatchKey>()
var allowlist: [ExecAllowlistEntry] = []
func append(_ entry: ExecAllowlistEntry) {
guard let patternKey = normalizedPattern(entry.pattern) else {
return
}
let key = ExecAllowlistEntryMatchKey(
pattern: patternKey,
argPattern: entry.argPattern)
guard !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)
}
}