mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
fix(macos): unify exec allowlist validation pipeline
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user