diff --git a/CHANGELOG.md b/CHANGELOG.md index d9b07c888..ddc935494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.clawd.bot - TUI: include Gateway slash commands in autocomplete and `/help`. - Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. - Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467) +- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman. ## 2026.1.22 diff --git a/apps/macos/Sources/Clawdbot/ExecApprovals.swift b/apps/macos/Sources/Clawdbot/ExecApprovals.swift index 537ceeaad..c6f413922 100644 --- a/apps/macos/Sources/Clawdbot/ExecApprovals.swift +++ b/apps/macos/Sources/Clawdbot/ExecApprovals.swift @@ -84,11 +84,52 @@ enum ExecApprovalDecision: String, Codable, Sendable { case deny } -struct ExecAllowlistEntry: Codable, Hashable { +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 { @@ -295,6 +336,7 @@ enum ExecApprovalsStore { 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, @@ -379,6 +421,7 @@ enum ExecApprovalsStore { 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, @@ -398,6 +441,7 @@ enum ExecApprovalsStore { let cleaned = allowlist .map { item in ExecAllowlistEntry( + id: item.id, pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines), lastUsedAt: item.lastUsedAt, lastUsedCommand: item.lastUsedCommand, diff --git a/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift b/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift index 0ac799e6d..eef826c3f 100644 --- a/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift +++ b/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift @@ -123,12 +123,12 @@ struct SystemRunSettingsView: View { .foregroundStyle(.secondary) } else { VStack(alignment: .leading, spacing: 8) { - ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in + ForEach(self.model.entries, id: \.id) { entry in ExecAllowlistRow( entry: Binding( - get: { self.model.entries[index] }, - set: { self.model.updateEntry($0, at: index) }), - onRemove: { self.model.removeEntry(at: index) }) + get: { self.model.entry(for: entry.id) ?? entry }, + set: { self.model.updateEntry($0, id: entry.id) }), + onRemove: { self.model.removeEntry(id: entry.id) }) } } } @@ -373,20 +373,24 @@ final class ExecApprovalsSettingsModel { ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) } - func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) { + func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) { guard !self.isDefaultsScope else { return } - guard self.entries.indices.contains(index) else { return } + guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return } self.entries[index] = entry ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) } - func removeEntry(at index: Int) { + func removeEntry(id: UUID) { guard !self.isDefaultsScope else { return } - guard self.entries.indices.contains(index) 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) } + func entry(for id: UUID) -> ExecAllowlistEntry? { + self.entries.first(where: { $0.id == id }) + } + func refreshSkillBins(force: Bool = false) async { guard self.autoAllowSkills else { self.skillBins = [] diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index fc657a74f..2ab96695c 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -54,6 +54,7 @@ Example schema: "autoAllowSkills": true, "allowlist": [ { + "id": "B0C8C0B3-2C2D-4F8A-9A3C-5A4B3C2D1E0F", "pattern": "~/Projects/**/bin/rg", "lastUsedAt": 1737150000000, "lastUsedCommand": "rg -n TODO", @@ -96,6 +97,7 @@ Examples: - `/opt/homebrew/bin/rg` Each allowlist entry tracks: +- **id** stable UUID used for UI identity (optional) - **last used** timestamp - **last used command** - **last resolved path** diff --git a/src/gateway/protocol/schema/exec-approvals.ts b/src/gateway/protocol/schema/exec-approvals.ts index 9f1a25d38..d58e74ab2 100644 --- a/src/gateway/protocol/schema/exec-approvals.ts +++ b/src/gateway/protocol/schema/exec-approvals.ts @@ -4,6 +4,7 @@ import { NonEmptyString } from "./primitives.js"; export const ExecApprovalsAllowlistEntrySchema = Type.Object( { + id: Type.Optional(NonEmptyString), pattern: Type.String(), lastUsedAt: Type.Optional(Type.Integer({ minimum: 0 })), lastUsedCommand: Type.Optional(Type.String()), diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 65dc4f024..0830ed89a 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -18,6 +18,7 @@ export type ExecApprovalsDefaults = { }; export type ExecAllowlistEntry = { + id?: string; pattern: string; lastUsedAt?: number; lastUsedCommand?: string; @@ -120,6 +121,19 @@ function ensureDir(filePath: string) { fs.mkdirSync(dir, { recursive: true }); } +function ensureAllowlistIds( + allowlist: ExecAllowlistEntry[] | undefined, +): ExecAllowlistEntry[] | undefined { + if (!Array.isArray(allowlist) || allowlist.length === 0) return allowlist; + let changed = false; + const next = allowlist.map((entry) => { + if (entry.id) return entry; + changed = true; + return { ...entry, id: crypto.randomUUID() }; + }); + return changed ? next : allowlist; +} + export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile { const socketPath = file.socket?.path?.trim(); const token = file.socket?.token?.trim(); @@ -130,6 +144,12 @@ export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFi agents[DEFAULT_AGENT_ID] = main ? mergeLegacyAgent(main, legacyDefault) : legacyDefault; delete agents.default; } + for (const [key, agent] of Object.entries(agents)) { + const allowlist = ensureAllowlistIds(agent.allowlist); + if (allowlist !== agent.allowlist) { + agents[key] = { ...agent, allowlist }; + } + } const normalized: ExecApprovalsFile = { version: 1, socket: { @@ -1145,6 +1165,7 @@ export function recordAllowlistUse( item.pattern === entry.pattern ? { ...item, + id: item.id ?? crypto.randomUUID(), lastUsedAt: Date.now(), lastUsedCommand: command, lastResolvedPath: resolvedPath, @@ -1168,7 +1189,7 @@ export function addAllowlistEntry( const trimmed = pattern.trim(); if (!trimmed) return; if (allowlist.some((entry) => entry.pattern === trimmed)) return; - allowlist.push({ pattern: trimmed, lastUsedAt: Date.now() }); + allowlist.push({ id: crypto.randomUUID(), pattern: trimmed, lastUsedAt: Date.now() }); agents[target] = { ...existing, allowlist }; approvals.agents = agents; saveExecApprovals(approvals); diff --git a/ui/src/ui/controllers/exec-approvals.ts b/ui/src/ui/controllers/exec-approvals.ts index 4f59caae2..ba938b9f3 100644 --- a/ui/src/ui/controllers/exec-approvals.ts +++ b/ui/src/ui/controllers/exec-approvals.ts @@ -9,6 +9,7 @@ export type ExecApprovalsDefaults = { }; export type ExecApprovalsAllowlistEntry = { + id?: string; pattern: string; lastUsedAt?: number; lastUsedCommand?: string;