fix(macos): unify exec allowlist validation pipeline

This commit is contained in:
Peter Steinberger
2026-02-21 23:09:03 +01:00
parent 61dc7ac679
commit 2028ca4428
6 changed files with 322 additions and 93 deletions

View File

@@ -7,12 +7,12 @@ enum ExecAllowlistMatcher {
let resolvedPath = resolution.resolvedPath
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 {
switch ExecApprovalHelpers.validateAllowlistPattern(entry.pattern) {
case .valid(let pattern):
let target = resolvedPath ?? rawExecutable
if self.matches(pattern: pattern, target: target) { return entry }
case .invalid:
continue
}
}
return nil

View File

@@ -90,6 +90,31 @@ enum ExecApprovalDecision: String, Codable, Sendable {
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
@@ -222,13 +247,25 @@ enum ExecApprovalsStore {
}
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)
agents: agents.isEmpty ? nil : agents)
}
static func readSnapshot() -> ExecApprovalsSnapshot {
@@ -306,7 +343,12 @@ enum ExecApprovalsStore {
}
static func ensureFile() -> ExecApprovalsFile {
var file = self.normalizeIncoming(self.loadFile())
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 {
@@ -316,20 +358,10 @@ enum ExecApprovalsStore {
if token.isEmpty {
file.socket?.token = self.generateToken()
}
if var agents = file.agents {
for (key, entry) in agents {
guard let allowlist = entry.allowlist else { continue }
let migrated = allowlist.map { self.migrateLegacyPattern($0) }
if migrated != allowlist {
var next = entry
next.allowlist = migrated
agents[key] = next
}
}
file.agents = agents.isEmpty ? nil : agents
}
if file.agents == nil { file.agents = [:] }
self.saveFile(file)
if !existed || loadedHash != self.hashFile(file) {
self.saveFile(file)
}
return file
}
@@ -351,16 +383,9 @@ enum ExecApprovalsStore {
?? 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 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(
@@ -410,20 +435,30 @@ enum ExecApprovalsStore {
}
}
static func addAllowlistEntry(agentId: String?, pattern: String) {
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, self.isPathPattern(trimmed) else { return }
@discardableResult
static func addAllowlistEntry(agentId: String?, pattern: String) -> ExecAllowlistPatternValidationReason? {
let normalizedPattern: String
switch ExecApprovalHelpers.validateAllowlistPattern(pattern) {
case .valid(let validPattern):
normalizedPattern = validPattern
case .invalid(let 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 == trimmed }) { return }
allowlist.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: Date().timeIntervalSince1970 * 1000))
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(
@@ -451,25 +486,21 @@ enum ExecApprovalsStore {
}
}
static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) {
@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 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 && self.isPathPattern($0.pattern) }
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) {
@@ -512,6 +543,14 @@ enum ExecApprovalsStore {
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 == "~" {
@@ -531,45 +570,101 @@ enum ExecApprovalsStore {
}
private static func normalizedPattern(_ pattern: String?) -> String? {
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed.lowercased()
}
private static func isPathPattern(_ pattern: String) -> Bool {
pattern.contains("/") || pattern.contains("~") || pattern.contains("\\")
switch ExecApprovalHelpers.validateAllowlistPattern(pattern) {
case .valid(let 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) ?? ""
guard !trimmedPattern.isEmpty else {
let normalizedResolved = trimmedResolved.isEmpty ? nil : trimmedResolved
switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) {
case .valid(let pattern):
return ExecAllowlistEntry(
id: entry.id,
pattern: trimmedPattern,
pattern: pattern,
lastUsedAt: entry.lastUsedAt,
lastUsedCommand: entry.lastUsedCommand,
lastResolvedPath: entry.lastResolvedPath)
lastResolvedPath: normalizedResolved)
case .invalid:
switch ExecApprovalHelpers.validateAllowlistPattern(trimmedResolved) {
case .valid(let 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)
}
}
if self.isPathPattern(trimmedPattern) || trimmedResolved.isEmpty || !self.isPathPattern(trimmedResolved) {
return ExecAllowlistEntry(
id: entry.id,
pattern: trimmedPattern,
lastUsedAt: entry.lastUsedAt,
lastUsedCommand: entry.lastUsedCommand,
lastResolvedPath: entry.lastResolvedPath)
}
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 .valid(let pattern):
normalized.append(
ExecAllowlistEntry(
id: migrated.id,
pattern: pattern,
lastUsedAt: migrated.lastUsedAt,
lastUsedCommand: migrated.lastUsedCommand,
lastResolvedPath: normalizedResolvedPath))
case .invalid(let 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 ExecAllowlistEntry(
id: entry.id,
pattern: trimmedResolved,
lastUsedAt: entry.lastUsedAt,
lastUsedCommand: entry.lastUsedCommand,
lastResolvedPath: entry.lastResolvedPath)
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) {
@@ -579,10 +674,10 @@ enum ExecApprovalsStore {
seen.insert(key)
allowlist.append(entry)
}
for entry in current.allowlist ?? [] {
for entry in currentAllowlist {
append(entry)
}
for entry in legacy.allowlist ?? [] {
for entry in legacyAllowlist {
append(entry)
}
@@ -596,6 +691,22 @@ enum ExecApprovalsStore {
}
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 }
@@ -617,6 +728,10 @@ enum ExecApprovalHelpers {
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 {

View File

@@ -108,10 +108,9 @@ struct SystemRunSettingsView: View {
TextField("Add allowlist path pattern (case-insensitive globs)", text: self.$newPattern)
.textFieldStyle(.roundedBorder)
Button("Add") {
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard self.model.isPathPattern(pattern) else { return }
self.model.addEntry(pattern)
self.newPattern = ""
if self.model.addEntry(self.newPattern) == nil {
self.newPattern = ""
}
}
.buttonStyle(.bordered)
.disabled(!self.model.isPathPattern(self.newPattern))
@@ -120,6 +119,11 @@ struct SystemRunSettingsView: View {
Text("Path patterns only. Basename entries like \"echo\" are ignored.")
.font(.footnote)
.foregroundStyle(.secondary)
if let validationMessage = self.model.allowlistValidationMessage {
Text(validationMessage)
.font(.footnote)
.foregroundStyle(.orange)
}
if self.model.entries.isEmpty {
Text("No allowlisted commands yet.")
@@ -238,6 +242,7 @@ final class ExecApprovalsSettingsModel {
var autoAllowSkills = false
var entries: [ExecAllowlistEntry] = []
var skillBins: [String] = []
var allowlistValidationMessage: String?
var agentPickerIds: [String] {
[Self.defaultsScopeId] + self.agentIds
@@ -293,6 +298,7 @@ final class ExecApprovalsSettingsModel {
func selectAgent(_ id: String) {
self.selectedAgentId = id
self.allowlistValidationMessage = nil
self.loadSettings(for: id)
Task { await self.refreshSkillBins() }
}
@@ -305,6 +311,7 @@ final class ExecApprovalsSettingsModel {
self.askFallback = defaults.askFallback
self.autoAllowSkills = defaults.autoAllowSkills
self.entries = []
self.allowlistValidationMessage = nil
return
}
let resolved = ExecApprovalsStore.resolve(agentId: agentId)
@@ -314,6 +321,7 @@ final class ExecApprovalsSettingsModel {
self.autoAllowSkills = resolved.agent.autoAllowSkills
self.entries = resolved.allowlist
.sorted { $0.pattern.localizedCaseInsensitiveCompare($1.pattern) == .orderedAscending }
self.allowlistValidationMessage = nil
}
func setSecurity(_ security: ExecSecurity) {
@@ -371,30 +379,45 @@ final class ExecApprovalsSettingsModel {
Task { await self.refreshSkillBins(force: enabled) }
}
func addEntry(_ pattern: String) {
guard !self.isDefaultsScope else { return }
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard self.isPathPattern(trimmed) else { return }
self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil))
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
@discardableResult
func addEntry(_ pattern: String) -> ExecAllowlistPatternValidationReason? {
guard !self.isDefaultsScope else { return nil }
switch ExecApprovalHelpers.validateAllowlistPattern(pattern) {
case .valid(let normalizedPattern):
self.entries.append(ExecAllowlistEntry(pattern: normalizedPattern, lastUsedAt: nil))
let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
self.allowlistValidationMessage = rejected.first?.reason.message
return rejected.first?.reason
case .invalid(let reason):
self.allowlistValidationMessage = reason.message
return reason
}
}
func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) {
guard !self.isDefaultsScope else { return }
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
@discardableResult
func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) -> ExecAllowlistPatternValidationReason? {
guard !self.isDefaultsScope else { return nil }
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return nil }
var next = entry
let trimmed = next.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard self.isPathPattern(trimmed) else { return }
next.pattern = trimmed
switch ExecApprovalHelpers.validateAllowlistPattern(next.pattern) {
case .valid(let normalizedPattern):
next.pattern = normalizedPattern
case .invalid(let reason):
self.allowlistValidationMessage = reason.message
return reason
}
self.entries[index] = next
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
self.allowlistValidationMessage = rejected.first?.reason.message
return rejected.first?.reason
}
func removeEntry(id: UUID) {
guard !self.isDefaultsScope else { return }
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
self.entries.remove(at: index)
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
self.allowlistValidationMessage = rejected.first?.reason.message
}
func entry(for id: UUID) -> ExecAllowlistEntry? {
@@ -402,9 +425,7 @@ final class ExecApprovalsSettingsModel {
}
func isPathPattern(_ pattern: String) -> Bool {
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
return trimmed.contains("/") || trimmed.contains("~") || trimmed.contains("\\")
ExecApprovalHelpers.isPathPattern(pattern)
}
func refreshSkillBins(force: Bool = false) async {

View File

@@ -39,7 +39,7 @@ struct ExecAllowlistTests {
}
@Test func matchIsCaseInsensitive() {
let entry = ExecAllowlistEntry(pattern: "RG")
let entry = ExecAllowlistEntry(pattern: "/OPT/HOMEBREW/BIN/RG")
let resolution = ExecCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
@@ -138,12 +138,12 @@ struct ExecAllowlistTests {
let resolutions = [first, second]
let partial = ExecAllowlistMatcher.matchAll(
entries: [ExecAllowlistEntry(pattern: "echo")],
entries: [ExecAllowlistEntry(pattern: "/usr/bin/echo")],
resolutions: resolutions)
#expect(partial.isEmpty)
let full = ExecAllowlistMatcher.matchAll(
entries: [ExecAllowlistEntry(pattern: "echo"), ExecAllowlistEntry(pattern: "touch")],
entries: [ExecAllowlistEntry(pattern: "/USR/BIN/ECHO"), ExecAllowlistEntry(pattern: "/usr/bin/touch")],
resolutions: resolutions)
#expect(full.count == 2)
}

View File

@@ -29,6 +29,24 @@ import Testing
#expect(ExecApprovalHelpers.allowlistPattern(command: [], resolution: nil) == nil)
}
@Test func validateAllowlistPatternReturnsReasons() {
#expect(ExecApprovalHelpers.isPathPattern("/usr/bin/rg"))
#expect(ExecApprovalHelpers.isPathPattern(" ~/bin/rg "))
#expect(!ExecApprovalHelpers.isPathPattern("rg"))
if case .invalid(let reason) = ExecApprovalHelpers.validateAllowlistPattern(" ") {
#expect(reason == .empty)
} else {
Issue.record("Expected empty pattern rejection")
}
if case .invalid(let reason) = ExecApprovalHelpers.validateAllowlistPattern("echo") {
#expect(reason == .missingPathComponent)
} else {
Issue.record("Expected basename pattern rejection")
}
}
@Test func requiresAskMatchesPolicy() {
let entry = ExecAllowlistEntry(pattern: "/bin/ls", lastUsedAt: nil, lastUsedCommand: nil, lastResolvedPath: nil)
#expect(ExecApprovalHelpers.requiresAsk(

View File

@@ -0,0 +1,75 @@
import Foundation
import Testing
@testable import OpenClaw
@Suite(.serialized)
struct ExecApprovalsStoreRefactorTests {
@Test
func ensureFileSkipsRewriteWhenUnchanged() async throws {
let stateDir = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: stateDir) }
try await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) {
_ = ExecApprovalsStore.ensureFile()
let url = ExecApprovalsStore.fileURL()
let firstWriteDate = try Self.modificationDate(at: url)
try await Task.sleep(nanoseconds: 1_100_000_000)
_ = ExecApprovalsStore.ensureFile()
let secondWriteDate = try Self.modificationDate(at: url)
#expect(firstWriteDate == secondWriteDate)
}
}
@Test
func updateAllowlistReportsRejectedBasenamePattern() async throws {
let stateDir = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: stateDir) }
await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) {
let rejected = ExecApprovalsStore.updateAllowlist(
agentId: "main",
allowlist: [
ExecAllowlistEntry(pattern: "echo"),
ExecAllowlistEntry(pattern: "/bin/echo"),
])
#expect(rejected.count == 1)
#expect(rejected.first?.reason == .missingPathComponent)
#expect(rejected.first?.pattern == "echo")
let resolved = ExecApprovalsStore.resolve(agentId: "main")
#expect(resolved.allowlist.map(\.pattern) == ["/bin/echo"])
}
}
@Test
func updateAllowlistMigratesLegacyPatternFromResolvedPath() async throws {
let stateDir = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: stateDir) }
await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) {
let rejected = ExecApprovalsStore.updateAllowlist(
agentId: "main",
allowlist: [
ExecAllowlistEntry(pattern: "echo", lastUsedAt: nil, lastUsedCommand: nil, lastResolvedPath: " /usr/bin/echo "),
])
#expect(rejected.isEmpty)
let resolved = ExecApprovalsStore.resolve(agentId: "main")
#expect(resolved.allowlist.map(\.pattern) == ["/usr/bin/echo"])
}
}
private static func modificationDate(at url: URL) throws -> Date {
let attributes = try FileManager().attributesOfItem(atPath: url.path)
guard let date = attributes[.modificationDate] as? Date else {
struct MissingDateError: Error {}
throw MissingDateError()
}
return date
}
}