fix(exec-approvals): stabilize allowlist ids (#1521)
This commit is contained in:
@@ -13,6 +13,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- TUI: include Gateway slash commands in autocomplete and `/help`.
|
- 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.
|
- 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)
|
- 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
|
## 2026.1.22
|
||||||
|
|
||||||
|
|||||||
@@ -84,11 +84,52 @@ enum ExecApprovalDecision: String, Codable, Sendable {
|
|||||||
case deny
|
case deny
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ExecAllowlistEntry: Codable, Hashable {
|
struct ExecAllowlistEntry: Codable, Hashable, Identifiable {
|
||||||
|
var id: UUID
|
||||||
var pattern: String
|
var pattern: String
|
||||||
var lastUsedAt: Double?
|
var lastUsedAt: Double?
|
||||||
var lastUsedCommand: String?
|
var lastUsedCommand: String?
|
||||||
var lastResolvedPath: 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 {
|
struct ExecApprovalsDefaults: Codable {
|
||||||
@@ -295,6 +336,7 @@ enum ExecApprovalsStore {
|
|||||||
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
|
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
|
||||||
.map { entry in
|
.map { entry in
|
||||||
ExecAllowlistEntry(
|
ExecAllowlistEntry(
|
||||||
|
id: entry.id,
|
||||||
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
lastUsedAt: entry.lastUsedAt,
|
lastUsedAt: entry.lastUsedAt,
|
||||||
lastUsedCommand: entry.lastUsedCommand,
|
lastUsedCommand: entry.lastUsedCommand,
|
||||||
@@ -379,6 +421,7 @@ enum ExecApprovalsStore {
|
|||||||
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
|
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
|
||||||
guard item.pattern == pattern else { return item }
|
guard item.pattern == pattern else { return item }
|
||||||
return ExecAllowlistEntry(
|
return ExecAllowlistEntry(
|
||||||
|
id: item.id,
|
||||||
pattern: item.pattern,
|
pattern: item.pattern,
|
||||||
lastUsedAt: Date().timeIntervalSince1970 * 1000,
|
lastUsedAt: Date().timeIntervalSince1970 * 1000,
|
||||||
lastUsedCommand: command,
|
lastUsedCommand: command,
|
||||||
@@ -398,6 +441,7 @@ enum ExecApprovalsStore {
|
|||||||
let cleaned = allowlist
|
let cleaned = allowlist
|
||||||
.map { item in
|
.map { item in
|
||||||
ExecAllowlistEntry(
|
ExecAllowlistEntry(
|
||||||
|
id: item.id,
|
||||||
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
lastUsedAt: item.lastUsedAt,
|
lastUsedAt: item.lastUsedAt,
|
||||||
lastUsedCommand: item.lastUsedCommand,
|
lastUsedCommand: item.lastUsedCommand,
|
||||||
|
|||||||
@@ -123,12 +123,12 @@ struct SystemRunSettingsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
|
ForEach(self.model.entries, id: \.id) { entry in
|
||||||
ExecAllowlistRow(
|
ExecAllowlistRow(
|
||||||
entry: Binding(
|
entry: Binding(
|
||||||
get: { self.model.entries[index] },
|
get: { self.model.entry(for: entry.id) ?? entry },
|
||||||
set: { self.model.updateEntry($0, at: index) }),
|
set: { self.model.updateEntry($0, id: entry.id) }),
|
||||||
onRemove: { self.model.removeEntry(at: index) })
|
onRemove: { self.model.removeEntry(id: entry.id) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,20 +373,24 @@ final class ExecApprovalsSettingsModel {
|
|||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
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.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
|
self.entries[index] = entry
|
||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeEntry(at index: Int) {
|
func removeEntry(id: UUID) {
|
||||||
guard !self.isDefaultsScope else { return }
|
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)
|
self.entries.remove(at: index)
|
||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
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 {
|
func refreshSkillBins(force: Bool = false) async {
|
||||||
guard self.autoAllowSkills else {
|
guard self.autoAllowSkills else {
|
||||||
self.skillBins = []
|
self.skillBins = []
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ Example schema:
|
|||||||
"autoAllowSkills": true,
|
"autoAllowSkills": true,
|
||||||
"allowlist": [
|
"allowlist": [
|
||||||
{
|
{
|
||||||
|
"id": "B0C8C0B3-2C2D-4F8A-9A3C-5A4B3C2D1E0F",
|
||||||
"pattern": "~/Projects/**/bin/rg",
|
"pattern": "~/Projects/**/bin/rg",
|
||||||
"lastUsedAt": 1737150000000,
|
"lastUsedAt": 1737150000000,
|
||||||
"lastUsedCommand": "rg -n TODO",
|
"lastUsedCommand": "rg -n TODO",
|
||||||
@@ -96,6 +97,7 @@ Examples:
|
|||||||
- `/opt/homebrew/bin/rg`
|
- `/opt/homebrew/bin/rg`
|
||||||
|
|
||||||
Each allowlist entry tracks:
|
Each allowlist entry tracks:
|
||||||
|
- **id** stable UUID used for UI identity (optional)
|
||||||
- **last used** timestamp
|
- **last used** timestamp
|
||||||
- **last used command**
|
- **last used command**
|
||||||
- **last resolved path**
|
- **last resolved path**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { NonEmptyString } from "./primitives.js";
|
|||||||
|
|
||||||
export const ExecApprovalsAllowlistEntrySchema = Type.Object(
|
export const ExecApprovalsAllowlistEntrySchema = Type.Object(
|
||||||
{
|
{
|
||||||
|
id: Type.Optional(NonEmptyString),
|
||||||
pattern: Type.String(),
|
pattern: Type.String(),
|
||||||
lastUsedAt: Type.Optional(Type.Integer({ minimum: 0 })),
|
lastUsedAt: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||||
lastUsedCommand: Type.Optional(Type.String()),
|
lastUsedCommand: Type.Optional(Type.String()),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type ExecApprovalsDefaults = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ExecAllowlistEntry = {
|
export type ExecAllowlistEntry = {
|
||||||
|
id?: string;
|
||||||
pattern: string;
|
pattern: string;
|
||||||
lastUsedAt?: number;
|
lastUsedAt?: number;
|
||||||
lastUsedCommand?: string;
|
lastUsedCommand?: string;
|
||||||
@@ -120,6 +121,19 @@ function ensureDir(filePath: string) {
|
|||||||
fs.mkdirSync(dir, { recursive: true });
|
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 {
|
export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
|
||||||
const socketPath = file.socket?.path?.trim();
|
const socketPath = file.socket?.path?.trim();
|
||||||
const token = file.socket?.token?.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;
|
agents[DEFAULT_AGENT_ID] = main ? mergeLegacyAgent(main, legacyDefault) : legacyDefault;
|
||||||
delete agents.default;
|
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 = {
|
const normalized: ExecApprovalsFile = {
|
||||||
version: 1,
|
version: 1,
|
||||||
socket: {
|
socket: {
|
||||||
@@ -1145,6 +1165,7 @@ export function recordAllowlistUse(
|
|||||||
item.pattern === entry.pattern
|
item.pattern === entry.pattern
|
||||||
? {
|
? {
|
||||||
...item,
|
...item,
|
||||||
|
id: item.id ?? crypto.randomUUID(),
|
||||||
lastUsedAt: Date.now(),
|
lastUsedAt: Date.now(),
|
||||||
lastUsedCommand: command,
|
lastUsedCommand: command,
|
||||||
lastResolvedPath: resolvedPath,
|
lastResolvedPath: resolvedPath,
|
||||||
@@ -1168,7 +1189,7 @@ export function addAllowlistEntry(
|
|||||||
const trimmed = pattern.trim();
|
const trimmed = pattern.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
if (allowlist.some((entry) => entry.pattern === 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 };
|
agents[target] = { ...existing, allowlist };
|
||||||
approvals.agents = agents;
|
approvals.agents = agents;
|
||||||
saveExecApprovals(approvals);
|
saveExecApprovals(approvals);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type ExecApprovalsDefaults = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ExecApprovalsAllowlistEntry = {
|
export type ExecApprovalsAllowlistEntry = {
|
||||||
|
id?: string;
|
||||||
pattern: string;
|
pattern: string;
|
||||||
lastUsedAt?: number;
|
lastUsedAt?: number;
|
||||||
lastUsedCommand?: string;
|
lastUsedCommand?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user