feat: add exec approvals editor in control ui and mac app

This commit is contained in:
Peter Steinberger
2026-01-18 08:54:34 +00:00
parent b739a3897f
commit 4de3c3a028
18 changed files with 1116 additions and 45 deletions

View File

@@ -13,18 +13,16 @@ struct SystemRunSettingsView: View {
Text("Exec approvals")
.font(.body)
Spacer(minLength: 0)
if self.model.agentIds.count > 1 {
Picker("Agent", selection: Binding(
get: { self.model.selectedAgentId },
set: { self.model.selectAgent($0) }))
{
ForEach(self.model.agentIds, id: \.self) { id in
Text(id).tag(id)
}
Picker("Agent", selection: Binding(
get: { self.model.selectedAgentId },
set: { self.model.selectAgent($0) }))
{
ForEach(self.model.agentPickerIds, id: \.self) { id in
Text(self.model.label(for: id)).tag(id)
}
.pickerStyle(.menu)
.frame(width: 160, alignment: .trailing)
}
.pickerStyle(.menu)
.frame(width: 180, alignment: .trailing)
}
Picker("", selection: self.$tab) {
@@ -82,7 +80,9 @@ struct SystemRunSettingsView: View {
.labelsHidden()
.pickerStyle(.menu)
Text("Security controls whether system.run can execute on this Mac when paired as a node. Ask controls prompt behavior; fallback is used when no companion UI is reachable.")
Text(self.model.isDefaultsScope
? "Defaults apply when an agent has no overrides. Ask controls prompt behavior; fallback is used when no companion UI is reachable."
: "Security controls whether system.run can execute on this Mac when paired as a node. Ask controls prompt behavior; fallback is used when no companion UI is reachable.")
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
@@ -101,31 +101,37 @@ struct SystemRunSettingsView: View {
.foregroundStyle(.secondary)
}
HStack(spacing: 8) {
TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern)
.textFieldStyle(.roundedBorder)
Button("Add") {
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !pattern.isEmpty else { return }
self.model.addEntry(pattern)
self.newPattern = ""
}
.buttonStyle(.bordered)
.disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
if self.model.entries.isEmpty {
Text("No allowlisted commands yet.")
if self.model.isDefaultsScope {
Text("Allowlists are per-agent. Select an agent to edit its allowlist.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
ExecAllowlistRow(
entry: Binding(
get: { self.model.entries[index] },
set: { self.model.updateEntry($0, at: index) }),
onRemove: { self.model.removeEntry(at: index) })
HStack(spacing: 8) {
TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern)
.textFieldStyle(.roundedBorder)
Button("Add") {
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !pattern.isEmpty else { return }
self.model.addEntry(pattern)
self.newPattern = ""
}
.buttonStyle(.bordered)
.disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
if self.model.entries.isEmpty {
Text("No allowlisted commands yet.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
ExecAllowlistRow(
entry: Binding(
get: { self.model.entries[index] },
set: { self.model.updateEntry($0, at: index) }),
onRemove: { self.model.removeEntry(at: index) })
}
}
}
}
@@ -177,8 +183,16 @@ struct ExecAllowlistRow: View {
Text("Last used \(Self.relativeFormatter.localizedString(for: date, relativeTo: Date()))")
.font(.caption)
.foregroundStyle(.secondary)
} else if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty {
Text("Last used: \(lastUsedCommand)")
}
if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty {
Text("Last command: \(lastUsedCommand)")
.font(.caption)
.foregroundStyle(.secondary)
}
if let lastResolvedPath = self.entry.lastResolvedPath, !lastResolvedPath.isEmpty {
Text("Resolved path: \(lastResolvedPath)")
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -201,6 +215,7 @@ struct ExecAllowlistRow: View {
@MainActor
@Observable
final class ExecApprovalsSettingsModel {
private static let defaultsScopeId = "__defaults__"
var agentIds: [String] = []
var selectedAgentId: String = "main"
var defaultAgentId: String = "main"
@@ -211,6 +226,19 @@ final class ExecApprovalsSettingsModel {
var entries: [ExecAllowlistEntry] = []
var skillBins: [String] = []
var agentPickerIds: [String] {
[Self.defaultsScopeId] + self.agentIds
}
var isDefaultsScope: Bool {
self.selectedAgentId == Self.defaultsScopeId
}
func label(for id: String) -> String {
if id == Self.defaultsScopeId { return "Defaults" }
return id
}
func refresh() async {
await self.refreshAgents()
self.loadSettings(for: self.selectedAgentId)
@@ -242,6 +270,9 @@ final class ExecApprovalsSettingsModel {
}
self.agentIds = ids
self.defaultAgentId = defaultId ?? "main"
if self.selectedAgentId == Self.defaultsScopeId {
return
}
if !self.agentIds.contains(self.selectedAgentId) {
self.selectedAgentId = self.defaultAgentId
}
@@ -254,6 +285,15 @@ final class ExecApprovalsSettingsModel {
}
func loadSettings(for agentId: String) {
if agentId == Self.defaultsScopeId {
let defaults = ExecApprovalsStore.resolveDefaults()
self.security = defaults.security
self.ask = defaults.ask
self.askFallback = defaults.askFallback
self.autoAllowSkills = defaults.autoAllowSkills
self.entries = []
return
}
let resolved = ExecApprovalsStore.resolve(agentId: agentId)
self.security = resolved.agent.security
self.ask = resolved.agent.ask
@@ -265,36 +305,61 @@ final class ExecApprovalsSettingsModel {
func setSecurity(_ security: ExecSecurity) {
self.security = security
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.security = security
if self.isDefaultsScope {
ExecApprovalsStore.updateDefaults { defaults in
defaults.security = security
}
} else {
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.security = security
}
}
self.syncQuickMode()
}
func setAsk(_ ask: ExecAsk) {
self.ask = ask
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.ask = ask
if self.isDefaultsScope {
ExecApprovalsStore.updateDefaults { defaults in
defaults.ask = ask
}
} else {
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.ask = ask
}
}
self.syncQuickMode()
}
func setAskFallback(_ mode: ExecSecurity) {
self.askFallback = mode
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.askFallback = mode
if self.isDefaultsScope {
ExecApprovalsStore.updateDefaults { defaults in
defaults.askFallback = mode
}
} else {
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.askFallback = mode
}
}
}
func setAutoAllowSkills(_ enabled: Bool) {
self.autoAllowSkills = enabled
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.autoAllowSkills = enabled
if self.isDefaultsScope {
ExecApprovalsStore.updateDefaults { defaults in
defaults.autoAllowSkills = enabled
}
} else {
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.autoAllowSkills = enabled
}
}
Task { await self.refreshSkillBins(force: enabled) }
}
func addEntry(_ pattern: String) {
guard !self.isDefaultsScope else { return }
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil))
@@ -302,12 +367,14 @@ final class ExecApprovalsSettingsModel {
}
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
guard !self.isDefaultsScope else { return }
guard self.entries.indices.contains(index) else { return }
self.entries[index] = entry
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func removeEntry(at index: Int) {
guard !self.isDefaultsScope else { return }
guard self.entries.indices.contains(index) else { return }
self.entries.remove(at: index)
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
@@ -323,6 +390,10 @@ final class ExecApprovalsSettingsModel {
}
private func syncQuickMode() {
if self.isDefaultsScope {
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
return
}
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
}

View File

@@ -100,6 +100,16 @@ When **Auto-allow skill CLIs** is enabled, executables referenced by known skill
are treated as allowlisted on nodes (macOS node or headless node host). This uses the Bridge RPC to ask the
gateway for the skill bin list. Disable this if you want strict manual allowlists.
## Control UI editing
Use the **Control UI → Nodes → Exec approvals** card to edit defaults, peragent
overrides, and allowlists. Pick a scope (Defaults or an agent), tweak the policy,
add/remove allowlist patterns, then **Save**. The UI shows **last used** metadata
per pattern so you can keep the list tidy.
Note: the Control UI edits the approvals file on the **Gateway host**. For a
headless node host, edit its local `~/.clawdbot/exec-approvals.json` directly.
## Approval flow
When a prompt is required, the companion app displays a confirmation dialog with:

View File

@@ -36,6 +36,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
- Skills: status, enable/disable, install, API key updates (`skills.*`)
- Nodes: list + caps (`node.list`)
- Exec approvals: edit allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`)
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
- Config: apply + restart with validation (`config.apply`) and wake the last active session
- Config writes include a base-hash guard to prevent clobbering concurrent edits

View File

@@ -56,6 +56,11 @@ import {
CronStatusParamsSchema,
type CronUpdateParams,
CronUpdateParamsSchema,
type ExecApprovalsGetParams,
ExecApprovalsGetParamsSchema,
type ExecApprovalsSetParams,
ExecApprovalsSetParamsSchema,
type ExecApprovalsSnapshot,
ErrorCodes,
type ErrorShape,
ErrorShapeSchema,
@@ -230,6 +235,12 @@ export const validateCronUpdateParams = ajv.compile<CronUpdateParams>(CronUpdate
export const validateCronRemoveParams = ajv.compile<CronRemoveParams>(CronRemoveParamsSchema);
export const validateCronRunParams = ajv.compile<CronRunParams>(CronRunParamsSchema);
export const validateCronRunsParams = ajv.compile<CronRunsParams>(CronRunsParamsSchema);
export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams>(
ExecApprovalsGetParamsSchema,
);
export const validateExecApprovalsSetParams = ajv.compile<ExecApprovalsSetParams>(
ExecApprovalsSetParamsSchema,
);
export const validateLogsTailParams = ajv.compile<LogsTailParams>(LogsTailParamsSchema);
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
@@ -388,6 +399,9 @@ export type {
CronRunParams,
CronRunsParams,
CronRunLogEntry,
ExecApprovalsGetParams,
ExecApprovalsSetParams,
ExecApprovalsSnapshot,
LogsTailParams,
LogsTailResult,
PollParams,

View File

@@ -4,6 +4,7 @@ export * from "./schema/channels.js";
export * from "./schema/config.js";
export * from "./schema/cron.js";
export * from "./schema/error-codes.js";
export * from "./schema/exec-approvals.js";
export * from "./schema/frames.js";
export * from "./schema/logs-chat.js";
export * from "./schema/nodes.js";

View File

@@ -0,0 +1,72 @@
import { Type } from "@sinclair/typebox";
import { NonEmptyString } from "./primitives.js";
export const ExecApprovalsAllowlistEntrySchema = Type.Object(
{
pattern: Type.String(),
lastUsedAt: Type.Optional(Type.Integer({ minimum: 0 })),
lastUsedCommand: Type.Optional(Type.String()),
lastResolvedPath: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export const ExecApprovalsDefaultsSchema = Type.Object(
{
security: Type.Optional(Type.String()),
ask: Type.Optional(Type.String()),
askFallback: Type.Optional(Type.String()),
autoAllowSkills: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
export const ExecApprovalsAgentSchema = Type.Object(
{
security: Type.Optional(Type.String()),
ask: Type.Optional(Type.String()),
askFallback: Type.Optional(Type.String()),
autoAllowSkills: Type.Optional(Type.Boolean()),
allowlist: Type.Optional(Type.Array(ExecApprovalsAllowlistEntrySchema)),
},
{ additionalProperties: false },
);
export const ExecApprovalsFileSchema = Type.Object(
{
version: Type.Literal(1),
socket: Type.Optional(
Type.Object(
{
path: Type.Optional(Type.String()),
token: Type.Optional(Type.String()),
},
{ additionalProperties: false },
),
),
defaults: Type.Optional(ExecApprovalsDefaultsSchema),
agents: Type.Optional(Type.Record(Type.String(), ExecApprovalsAgentSchema)),
},
{ additionalProperties: false },
);
export const ExecApprovalsSnapshotSchema = Type.Object(
{
path: NonEmptyString,
exists: Type.Boolean(),
hash: NonEmptyString,
file: ExecApprovalsFileSchema,
},
{ additionalProperties: false },
);
export const ExecApprovalsGetParamsSchema = Type.Object({}, { additionalProperties: false });
export const ExecApprovalsSetParamsSchema = Type.Object(
{
file: ExecApprovalsFileSchema,
baseHash: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);

View File

@@ -47,6 +47,11 @@ import {
CronStatusParamsSchema,
CronUpdateParamsSchema,
} from "./cron.js";
import {
ExecApprovalsGetParamsSchema,
ExecApprovalsSetParamsSchema,
ExecApprovalsSnapshotSchema,
} from "./exec-approvals.js";
import {
ConnectParamsSchema,
ErrorShapeSchema,
@@ -170,6 +175,9 @@ export const ProtocolSchemas: Record<string, TSchema> = {
CronRunLogEntry: CronRunLogEntrySchema,
LogsTailParams: LogsTailParamsSchema,
LogsTailResult: LogsTailResultSchema,
ExecApprovalsGetParams: ExecApprovalsGetParamsSchema,
ExecApprovalsSetParams: ExecApprovalsSetParamsSchema,
ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema,
ChatHistoryParams: ChatHistoryParamsSchema,
ChatSendParams: ChatSendParamsSchema,
ChatAbortParams: ChatAbortParamsSchema,

View File

@@ -45,6 +45,11 @@ import type {
CronStatusParamsSchema,
CronUpdateParamsSchema,
} from "./cron.js";
import type {
ExecApprovalsGetParamsSchema,
ExecApprovalsSetParamsSchema,
ExecApprovalsSnapshotSchema,
} from "./exec-approvals.js";
import type {
ConnectParamsSchema,
ErrorShapeSchema,
@@ -163,6 +168,9 @@ export type CronRunsParams = Static<typeof CronRunsParamsSchema>;
export type CronRunLogEntry = Static<typeof CronRunLogEntrySchema>;
export type LogsTailParams = Static<typeof LogsTailParamsSchema>;
export type LogsTailResult = Static<typeof LogsTailResultSchema>;
export type ExecApprovalsGetParams = Static<typeof ExecApprovalsGetParamsSchema>;
export type ExecApprovalsSetParams = Static<typeof ExecApprovalsSetParamsSchema>;
export type ExecApprovalsSnapshot = Static<typeof ExecApprovalsSnapshotSchema>;
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
export type ChatEvent = Static<typeof ChatEventSchema>;

View File

@@ -12,6 +12,8 @@ const BASE_METHODS = [
"config.apply",
"config.patch",
"config.schema",
"exec.approvals.get",
"exec.approvals.set",
"wizard.start",
"wizard.next",
"wizard.cancel",

View File

@@ -6,6 +6,7 @@ import { chatHandlers } from "./server-methods/chat.js";
import { configHandlers } from "./server-methods/config.js";
import { connectHandlers } from "./server-methods/connect.js";
import { cronHandlers } from "./server-methods/cron.js";
import { execApprovalsHandlers } from "./server-methods/exec-approvals.js";
import { healthHandlers } from "./server-methods/health.js";
import { logsHandlers } from "./server-methods/logs.js";
import { modelsHandlers } from "./server-methods/models.js";
@@ -30,6 +31,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
...channelsHandlers,
...chatHandlers,
...cronHandlers,
...execApprovalsHandlers,
...webHandlers,
...modelsHandlers,
...configHandlers,

View File

@@ -0,0 +1,157 @@
import {
ensureExecApprovals,
normalizeExecApprovals,
readExecApprovalsSnapshot,
resolveExecApprovalsSocketPath,
saveExecApprovals,
type ExecApprovalsFile,
type ExecApprovalsSnapshot,
} from "../../infra/exec-approvals.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateExecApprovalsGetParams,
validateExecApprovalsSetParams,
} from "../protocol/index.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
function resolveBaseHash(params: unknown): string | null {
const raw = (params as { baseHash?: unknown })?.baseHash;
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
return trimmed ? trimmed : null;
}
function requireApprovalsBaseHash(
params: unknown,
snapshot: ExecApprovalsSnapshot,
respond: RespondFn,
): boolean {
if (!snapshot.exists) return true;
if (!snapshot.hash) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"exec approvals base hash unavailable; re-run exec.approvals.get and retry",
),
);
return false;
}
const baseHash = resolveBaseHash(params);
if (!baseHash) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"exec approvals base hash required; re-run exec.approvals.get and retry",
),
);
return false;
}
if (baseHash !== snapshot.hash) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"exec approvals changed since last load; re-run exec.approvals.get and retry",
),
);
return false;
}
return true;
}
function redactExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
const socketPath = file.socket?.path?.trim();
return {
...file,
socket: socketPath ? { path: socketPath } : undefined,
};
}
export const execApprovalsHandlers: GatewayRequestHandlers = {
"exec.approvals.get": ({ params, respond }) => {
if (!validateExecApprovalsGetParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid exec.approvals.get params: ${formatValidationErrors(validateExecApprovalsGetParams.errors)}`,
),
);
return;
}
ensureExecApprovals();
const snapshot = readExecApprovalsSnapshot();
respond(
true,
{
path: snapshot.path,
exists: snapshot.exists,
hash: snapshot.hash,
file: redactExecApprovals(snapshot.file),
},
undefined,
);
},
"exec.approvals.set": ({ params, respond }) => {
if (!validateExecApprovalsSetParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid exec.approvals.set params: ${formatValidationErrors(validateExecApprovalsSetParams.errors)}`,
),
);
return;
}
ensureExecApprovals();
const snapshot = readExecApprovalsSnapshot();
if (!requireApprovalsBaseHash(params, snapshot, respond)) {
return;
}
const incoming = (params as { file?: unknown }).file;
if (!incoming || typeof incoming !== "object") {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "exec approvals file is required"),
);
return;
}
const normalized = normalizeExecApprovals(incoming as ExecApprovalsFile);
const currentSocketPath = snapshot.file.socket?.path?.trim();
const currentToken = snapshot.file.socket?.token?.trim();
const socketPath =
normalized.socket?.path?.trim() ??
currentSocketPath ??
resolveExecApprovalsSocketPath();
const token = normalized.socket?.token?.trim() ?? currentToken ?? "";
const next: ExecApprovalsFile = {
...normalized,
socket: {
path: socketPath,
token,
},
};
saveExecApprovals(next);
const nextSnapshot = readExecApprovalsSnapshot();
respond(
true,
{
path: nextSnapshot.path,
exists: nextSnapshot.exists,
hash: nextSnapshot.hash,
file: redactExecApprovals(nextSnapshot.file),
},
undefined,
);
},
};

View File

@@ -36,6 +36,14 @@ export type ExecApprovalsFile = {
agents?: Record<string, ExecApprovalsAgent>;
};
export type ExecApprovalsSnapshot = {
path: string;
exists: boolean;
raw: string | null;
file: ExecApprovalsFile;
hash: string;
};
export type ExecApprovalsResolved = {
path: string;
socketPath: string;
@@ -53,6 +61,13 @@ const DEFAULT_AUTO_ALLOW_SKILLS = false;
const DEFAULT_SOCKET = "~/.clawdbot/exec-approvals.sock";
const DEFAULT_FILE = "~/.clawdbot/exec-approvals.json";
function hashExecApprovalsRaw(raw: string | null): string {
return crypto
.createHash("sha256")
.update(raw ?? "")
.digest("hex");
}
function expandHome(value: string): string {
if (!value) return value;
if (value === "~") return os.homedir();
@@ -73,7 +88,7 @@ function ensureDir(filePath: string) {
fs.mkdirSync(dir, { recursive: true });
}
function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
const socketPath = file.socket?.path?.trim();
const token = file.socket?.token?.trim();
const normalized: ExecApprovalsFile = {
@@ -97,6 +112,38 @@ function generateToken(): string {
return crypto.randomBytes(24).toString("base64url");
}
export function readExecApprovalsSnapshot(): ExecApprovalsSnapshot {
const filePath = resolveExecApprovalsPath();
if (!fs.existsSync(filePath)) {
const file = normalizeExecApprovals({ version: 1, agents: {} });
return {
path: filePath,
exists: false,
raw: null,
file,
hash: hashExecApprovalsRaw(null),
};
}
const raw = fs.readFileSync(filePath, "utf8");
let parsed: ExecApprovalsFile | null = null;
try {
parsed = JSON.parse(raw) as ExecApprovalsFile;
} catch {
parsed = null;
}
const file =
parsed?.version === 1
? normalizeExecApprovals(parsed)
: normalizeExecApprovals({ version: 1, agents: {} });
return {
path: filePath,
exists: true,
raw,
file,
hash: hashExecApprovalsRaw(raw),
};
}
export function loadExecApprovals(): ExecApprovalsFile {
const filePath = resolveExecApprovalsPath();
try {

View File

@@ -61,6 +61,12 @@ import {
updateConfigFormValue,
removeConfigFormValue,
} from "./controllers/config";
import {
loadExecApprovals,
removeExecApprovalsFormValue,
saveExecApprovals,
updateExecApprovalsFormValue,
} from "./controllers/exec-approvals";
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
import { loadDebug, callDebugMethod } from "./controllers/debug";
import { loadLogs } from "./controllers/logs";
@@ -298,8 +304,15 @@ export function renderApp(state: AppViewState) {
configSaving: state.configSaving,
configDirty: state.configFormDirty,
configFormMode: state.configFormMode,
execApprovalsLoading: state.execApprovalsLoading,
execApprovalsSaving: state.execApprovalsSaving,
execApprovalsDirty: state.execApprovalsDirty,
execApprovalsSnapshot: state.execApprovalsSnapshot,
execApprovalsForm: state.execApprovalsForm,
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
onRefresh: () => loadNodes(state),
onLoadConfig: () => loadConfig(state),
onLoadExecApprovals: () => loadExecApprovals(state),
onBindDefault: (nodeId) => {
if (nodeId) {
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
@@ -316,6 +329,14 @@ export function renderApp(state: AppViewState) {
}
},
onSaveBindings: () => saveConfig(state),
onExecApprovalsSelectAgent: (agentId) => {
state.execApprovalsSelectedAgent = agentId;
},
onExecApprovalsPatch: (path, value) =>
updateExecApprovalsFormValue(state, path, value),
onExecApprovalsRemove: (path) =>
removeExecApprovalsFormValue(state, path),
onSaveExecApprovals: () => saveExecApprovals(state),
})
: nothing}

View File

@@ -4,6 +4,7 @@ import { loadChannels } from "./controllers/channels";
import { loadDebug } from "./controllers/debug";
import { loadLogs } from "./controllers/logs";
import { loadNodes } from "./controllers/nodes";
import { loadExecApprovals } from "./controllers/exec-approvals";
import { loadPresence } from "./controllers/presence";
import { loadSessions } from "./controllers/sessions";
import { loadSkills } from "./controllers/skills";
@@ -133,6 +134,7 @@ export async function refreshActiveTab(host: SettingsHost) {
if (host.tab === "nodes") {
await loadNodes(host as unknown as ClawdbotApp);
await loadConfig(host as unknown as ClawdbotApp);
await loadExecApprovals(host as unknown as ClawdbotApp);
}
if (host.tab === "chat") {
await refreshChat(host as unknown as Parameters<typeof refreshChat>[0]);

View File

@@ -20,6 +20,10 @@ import type {
import type { ChatQueueItem, CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events";
import type { SkillMessage } from "./controllers/skills";
import type {
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "./controllers/exec-approvals";
export type AppViewState = {
settings: UiSettings;
@@ -44,6 +48,12 @@ export type AppViewState = {
chatQueue: ChatQueueItem[];
nodesLoading: boolean;
nodes: Array<Record<string, unknown>>;
execApprovalsLoading: boolean;
execApprovalsSaving: boolean;
execApprovalsDirty: boolean;
execApprovalsSnapshot: ExecApprovalsSnapshot | null;
execApprovalsForm: ExecApprovalsFile | null;
execApprovalsSelectedAgent: string | null;
configLoading: boolean;
configRaw: string;
configValid: boolean | null;
@@ -160,4 +170,3 @@ export type AppViewState = {
handleLogsAutoFollowToggle: (next: boolean) => void;
handleCallDebugMethod: (method: string, params: string) => Promise<void>;
};

View File

@@ -24,6 +24,10 @@ import type {
import { type ChatQueueItem, type CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events";
import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults";
import type {
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "./controllers/exec-approvals";
import {
resetToolStream as resetToolStreamInternal,
toggleToolOutput as toggleToolOutputInternal,
@@ -104,6 +108,12 @@ export class ClawdbotApp extends LitElement {
@state() nodesLoading = false;
@state() nodes: Array<Record<string, unknown>> = [];
@state() execApprovalsLoading = false;
@state() execApprovalsSaving = false;
@state() execApprovalsDirty = false;
@state() execApprovalsSnapshot: ExecApprovalsSnapshot | null = null;
@state() execApprovalsForm: ExecApprovalsFile | null = null;
@state() execApprovalsSelectedAgent: string | null = null;
@state() configLoading = false;
@state() configRaw = "{\n}\n";

View File

@@ -0,0 +1,123 @@
import type { GatewayBrowserClient } from "../gateway";
import { cloneConfigObject, removePathValue, setPathValue } from "./config/form-utils";
export type ExecApprovalsDefaults = {
security?: string;
ask?: string;
askFallback?: string;
autoAllowSkills?: boolean;
};
export type ExecApprovalsAllowlistEntry = {
pattern: string;
lastUsedAt?: number;
lastUsedCommand?: string;
lastResolvedPath?: string;
};
export type ExecApprovalsAgent = ExecApprovalsDefaults & {
allowlist?: ExecApprovalsAllowlistEntry[];
};
export type ExecApprovalsFile = {
version?: number;
socket?: { path?: string };
defaults?: ExecApprovalsDefaults;
agents?: Record<string, ExecApprovalsAgent>;
};
export type ExecApprovalsSnapshot = {
path: string;
exists: boolean;
hash: string;
file: ExecApprovalsFile;
};
export type ExecApprovalsState = {
client: GatewayBrowserClient | null;
connected: boolean;
execApprovalsLoading: boolean;
execApprovalsSaving: boolean;
execApprovalsDirty: boolean;
execApprovalsSnapshot: ExecApprovalsSnapshot | null;
execApprovalsForm: ExecApprovalsFile | null;
execApprovalsSelectedAgent: string | null;
lastError: string | null;
};
export async function loadExecApprovals(state: ExecApprovalsState) {
if (!state.client || !state.connected) return;
if (state.execApprovalsLoading) return;
state.execApprovalsLoading = true;
state.lastError = null;
try {
const res = (await state.client.request(
"exec.approvals.get",
{},
)) as ExecApprovalsSnapshot;
applyExecApprovalsSnapshot(state, res);
} catch (err) {
state.lastError = String(err);
} finally {
state.execApprovalsLoading = false;
}
}
export function applyExecApprovalsSnapshot(
state: ExecApprovalsState,
snapshot: ExecApprovalsSnapshot,
) {
state.execApprovalsSnapshot = snapshot;
if (!state.execApprovalsDirty) {
state.execApprovalsForm = cloneConfigObject(snapshot.file ?? {});
}
}
export async function saveExecApprovals(state: ExecApprovalsState) {
if (!state.client || !state.connected) return;
state.execApprovalsSaving = true;
state.lastError = null;
try {
const baseHash = state.execApprovalsSnapshot?.hash;
if (!baseHash) {
state.lastError = "Exec approvals hash missing; reload and retry.";
return;
}
const file =
state.execApprovalsForm ??
state.execApprovalsSnapshot?.file ??
{};
await state.client.request("exec.approvals.set", { file, baseHash });
state.execApprovalsDirty = false;
await loadExecApprovals(state);
} catch (err) {
state.lastError = String(err);
} finally {
state.execApprovalsSaving = false;
}
}
export function updateExecApprovalsFormValue(
state: ExecApprovalsState,
path: Array<string | number>,
value: unknown,
) {
const base = cloneConfigObject(
state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {},
);
setPathValue(base, path, value);
state.execApprovalsForm = base;
state.execApprovalsDirty = true;
}
export function removeExecApprovalsFormValue(
state: ExecApprovalsState,
path: Array<string | number>,
) {
const base = cloneConfigObject(
state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {},
);
removePathValue(base, path);
state.execApprovalsForm = base;
state.execApprovalsDirty = true;
}

View File

@@ -1,5 +1,12 @@
import { html, nothing } from "lit";
import { clampText, formatAgo } from "../format";
import type {
ExecApprovalsAllowlistEntry,
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "../controllers/exec-approvals";
export type NodesProps = {
loading: boolean;
nodes: Array<Record<string, unknown>>;
@@ -8,16 +15,29 @@ export type NodesProps = {
configSaving: boolean;
configDirty: boolean;
configFormMode: "form" | "raw";
execApprovalsLoading: boolean;
execApprovalsSaving: boolean;
execApprovalsDirty: boolean;
execApprovalsSnapshot: ExecApprovalsSnapshot | null;
execApprovalsForm: ExecApprovalsFile | null;
execApprovalsSelectedAgent: string | null;
onRefresh: () => void;
onLoadConfig: () => void;
onLoadExecApprovals: () => void;
onBindDefault: (nodeId: string | null) => void;
onBindAgent: (agentIndex: number, nodeId: string | null) => void;
onSaveBindings: () => void;
onExecApprovalsSelectAgent: (agentId: string) => void;
onExecApprovalsPatch: (path: Array<string | number>, value: unknown) => void;
onExecApprovalsRemove: (path: Array<string | number>) => void;
onSaveExecApprovals: () => void;
};
export function renderNodes(props: NodesProps) {
const bindingState = resolveBindingsState(props);
const approvalsState = resolveExecApprovalsState(props);
return html`
${renderExecApprovals(approvalsState)}
${renderBindings(bindingState)}
<section class="card">
<div class="row" style="justify-content: space-between;">
@@ -67,6 +87,55 @@ type BindingState = {
formMode: "form" | "raw";
};
type ExecSecurity = "deny" | "allowlist" | "full";
type ExecAsk = "off" | "on-miss" | "always";
type ExecApprovalsResolvedDefaults = {
security: ExecSecurity;
ask: ExecAsk;
askFallback: ExecSecurity;
autoAllowSkills: boolean;
};
type ExecApprovalsAgentOption = {
id: string;
name?: string;
isDefault?: boolean;
};
type ExecApprovalsState = {
ready: boolean;
disabled: boolean;
dirty: boolean;
loading: boolean;
saving: boolean;
form: ExecApprovalsFile | null;
defaults: ExecApprovalsResolvedDefaults;
selectedScope: string;
selectedAgent: Record<string, unknown> | null;
agents: ExecApprovalsAgentOption[];
allowlist: ExecApprovalsAllowlistEntry[];
onSelectScope: (agentId: string) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
onRemove: (path: Array<string | number>) => void;
onLoad: () => void;
onSave: () => void;
};
const EXEC_APPROVALS_DEFAULT_SCOPE = "__defaults__";
const SECURITY_OPTIONS: Array<{ value: ExecSecurity; label: string }> = [
{ value: "deny", label: "Deny" },
{ value: "allowlist", label: "Allowlist" },
{ value: "full", label: "Full" },
];
const ASK_OPTIONS: Array<{ value: ExecAsk; label: string }> = [
{ value: "off", label: "Off" },
{ value: "on-miss", label: "On miss" },
{ value: "always", label: "Always" },
];
function resolveBindingsState(props: NodesProps): BindingState {
const config = props.configForm;
const nodes = resolveExecNodes(props.nodes);
@@ -90,6 +159,114 @@ function resolveBindingsState(props: NodesProps): BindingState {
};
}
function normalizeSecurity(value?: string): ExecSecurity {
if (value === "allowlist" || value === "full" || value === "deny") return value;
return "deny";
}
function normalizeAsk(value?: string): ExecAsk {
if (value === "always" || value === "off" || value === "on-miss") return value;
return "on-miss";
}
function resolveExecApprovalsDefaults(
form: ExecApprovalsFile | null,
): ExecApprovalsResolvedDefaults {
const defaults = form?.defaults ?? {};
return {
security: normalizeSecurity(defaults.security),
ask: normalizeAsk(defaults.ask),
askFallback: normalizeSecurity(defaults.askFallback ?? "deny"),
autoAllowSkills: Boolean(defaults.autoAllowSkills ?? false),
};
}
function resolveConfigAgents(config: Record<string, unknown> | null): ExecApprovalsAgentOption[] {
const agentsNode = (config?.agents ?? {}) as Record<string, unknown>;
const list = Array.isArray(agentsNode.list) ? agentsNode.list : [];
const agents: ExecApprovalsAgentOption[] = [];
list.forEach((entry) => {
if (!entry || typeof entry !== "object") return;
const record = entry as Record<string, unknown>;
const id = typeof record.id === "string" ? record.id.trim() : "";
if (!id) return;
const name = typeof record.name === "string" ? record.name.trim() : undefined;
const isDefault = record.default === true;
agents.push({ id, name: name || undefined, isDefault });
});
return agents;
}
function resolveExecApprovalsAgents(
config: Record<string, unknown> | null,
form: ExecApprovalsFile | null,
): ExecApprovalsAgentOption[] {
const configAgents = resolveConfigAgents(config);
const approvalsAgents = Object.keys(form?.agents ?? {});
const merged = new Map<string, ExecApprovalsAgentOption>();
configAgents.forEach((agent) => merged.set(agent.id, agent));
approvalsAgents.forEach((id) => {
if (merged.has(id)) return;
merged.set(id, { id });
});
const agents = Array.from(merged.values());
if (agents.length === 0) {
agents.push({ id: "main", isDefault: true });
}
agents.sort((a, b) => {
if (a.isDefault && !b.isDefault) return -1;
if (!a.isDefault && b.isDefault) return 1;
const aLabel = a.name?.trim() ? a.name : a.id;
const bLabel = b.name?.trim() ? b.name : b.id;
return aLabel.localeCompare(bLabel);
});
return agents;
}
function resolveExecApprovalsScope(
selected: string | null,
agents: ExecApprovalsAgentOption[],
): string {
if (selected === EXEC_APPROVALS_DEFAULT_SCOPE) return EXEC_APPROVALS_DEFAULT_SCOPE;
if (selected && agents.some((agent) => agent.id === selected)) return selected;
return EXEC_APPROVALS_DEFAULT_SCOPE;
}
function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState {
const form = props.execApprovalsForm ?? props.execApprovalsSnapshot?.file ?? null;
const ready = Boolean(form);
const defaults = resolveExecApprovalsDefaults(form);
const agents = resolveExecApprovalsAgents(props.configForm, form);
const selectedScope = resolveExecApprovalsScope(props.execApprovalsSelectedAgent, agents);
const selectedAgent =
selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE
? ((form?.agents ?? {})[selectedScope] as Record<string, unknown> | undefined) ??
null
: null;
const allowlist = Array.isArray((selectedAgent as { allowlist?: unknown })?.allowlist)
? ((selectedAgent as { allowlist?: ExecApprovalsAllowlistEntry[] }).allowlist ??
[])
: [];
return {
ready,
disabled: props.execApprovalsSaving || props.execApprovalsLoading,
dirty: props.execApprovalsDirty,
loading: props.execApprovalsLoading,
saving: props.execApprovalsSaving,
form,
defaults,
selectedScope,
selectedAgent,
agents,
allowlist,
onSelectScope: props.onExecApprovalsSelectAgent,
onPatch: props.onExecApprovalsPatch,
onRemove: props.onExecApprovalsRemove,
onLoad: props.onLoadExecApprovals,
onSave: props.onSaveExecApprovals,
};
}
function renderBindings(state: BindingState) {
const supportsBinding = state.nodes.length > 0;
const defaultValue = state.defaultBinding ?? "";
@@ -171,6 +348,342 @@ function renderBindings(state: BindingState) {
`;
}
function renderExecApprovals(state: ExecApprovalsState) {
const ready = state.ready;
return html`
<section class="card">
<div class="row" style="justify-content: space-between; align-items: center;">
<div>
<div class="card-title">Exec approvals</div>
<div class="card-sub">
Allowlist and approval policy for <span class="mono">exec host=gateway/node</span>.
</div>
</div>
<button
class="btn"
?disabled=${state.disabled || !state.dirty}
@click=${state.onSave}
>
${state.saving ? "Saving…" : "Save"}
</button>
</div>
${!ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
<div class="muted">Load exec approvals to edit allowlists.</div>
<button class="btn" ?disabled=${state.loading} @click=${state.onLoad}>
${state.loading ? "Loading…" : "Load approvals"}
</button>
</div>`
: html`
${renderExecApprovalsTabs(state)}
${renderExecApprovalsPolicy(state)}
${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE
? nothing
: renderExecApprovalsAllowlist(state)}
`}
</section>
`;
}
function renderExecApprovalsTabs(state: ExecApprovalsState) {
return html`
<div class="row" style="margin-top: 12px; gap: 8px; flex-wrap: wrap;">
<span class="label">Scope</span>
<div class="row" style="gap: 8px; flex-wrap: wrap;">
<button
class="btn btn--sm ${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE ? "active" : ""}"
@click=${() => state.onSelectScope(EXEC_APPROVALS_DEFAULT_SCOPE)}
>
Defaults
</button>
${state.agents.map((agent) => {
const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id;
return html`
<button
class="btn btn--sm ${state.selectedScope === agent.id ? "active" : ""}"
@click=${() => state.onSelectScope(agent.id)}
>
${label}
</button>
`;
})}
</div>
</div>
`;
}
function renderExecApprovalsPolicy(state: ExecApprovalsState) {
const isDefaults = state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE;
const defaults = state.defaults;
const agent = state.selectedAgent ?? {};
const basePath = isDefaults ? ["defaults"] : ["agents", state.selectedScope];
const agentSecurity = typeof agent.security === "string" ? agent.security : undefined;
const agentAsk = typeof agent.ask === "string" ? agent.ask : undefined;
const agentAskFallback =
typeof agent.askFallback === "string" ? agent.askFallback : undefined;
const securityValue = isDefaults ? defaults.security : agentSecurity ?? "__default__";
const askValue = isDefaults ? defaults.ask : agentAsk ?? "__default__";
const askFallbackValue = isDefaults
? defaults.askFallback
: agentAskFallback ?? "__default__";
const autoOverride =
typeof agent.autoAllowSkills === "boolean" ? agent.autoAllowSkills : undefined;
const autoEffective = autoOverride ?? defaults.autoAllowSkills;
const autoIsDefault = autoOverride == null;
return html`
<div class="list" style="margin-top: 16px;">
<div class="list-item">
<div class="list-main">
<div class="list-title">Security</div>
<div class="list-sub">
${isDefaults
? "Default security mode."
: `Default: ${defaults.security}.`}
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Mode</span>
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value;
if (!isDefaults && value === "__default__") {
state.onRemove([...basePath, "security"]);
} else {
state.onPatch([...basePath, "security"], value);
}
}}
>
${!isDefaults
? html`<option value="__default__" ?selected=${securityValue === "__default__"}>
Use default (${defaults.security})
</option>`
: nothing}
${SECURITY_OPTIONS.map(
(option) =>
html`<option
value=${option.value}
?selected=${securityValue === option.value}
>
${option.label}
</option>`,
)}
</select>
</label>
</div>
</div>
<div class="list-item">
<div class="list-main">
<div class="list-title">Ask</div>
<div class="list-sub">
${isDefaults ? "Default prompt policy." : `Default: ${defaults.ask}.`}
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Mode</span>
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value;
if (!isDefaults && value === "__default__") {
state.onRemove([...basePath, "ask"]);
} else {
state.onPatch([...basePath, "ask"], value);
}
}}
>
${!isDefaults
? html`<option value="__default__" ?selected=${askValue === "__default__"}>
Use default (${defaults.ask})
</option>`
: nothing}
${ASK_OPTIONS.map(
(option) =>
html`<option
value=${option.value}
?selected=${askValue === option.value}
>
${option.label}
</option>`,
)}
</select>
</label>
</div>
</div>
<div class="list-item">
<div class="list-main">
<div class="list-title">Ask fallback</div>
<div class="list-sub">
${isDefaults
? "Applied when the UI prompt is unavailable."
: `Default: ${defaults.askFallback}.`}
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Fallback</span>
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value;
if (!isDefaults && value === "__default__") {
state.onRemove([...basePath, "askFallback"]);
} else {
state.onPatch([...basePath, "askFallback"], value);
}
}}
>
${!isDefaults
? html`<option value="__default__" ?selected=${askFallbackValue === "__default__"}>
Use default (${defaults.askFallback})
</option>`
: nothing}
${SECURITY_OPTIONS.map(
(option) =>
html`<option
value=${option.value}
?selected=${askFallbackValue === option.value}
>
${option.label}
</option>`,
)}
</select>
</label>
</div>
</div>
<div class="list-item">
<div class="list-main">
<div class="list-title">Auto-allow skill CLIs</div>
<div class="list-sub">
${isDefaults
? "Allow skill executables listed by the Gateway."
: autoIsDefault
? `Using default (${defaults.autoAllowSkills ? "on" : "off"}).`
: `Override (${autoEffective ? "on" : "off"}).`}
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Enabled</span>
<input
type="checkbox"
?disabled=${state.disabled}
.checked=${autoEffective}
@change=${(event: Event) => {
const target = event.target as HTMLInputElement;
state.onPatch([...basePath, "autoAllowSkills"], target.checked);
}}
/>
</label>
${!isDefaults && !autoIsDefault
? html`<button
class="btn btn--sm"
?disabled=${state.disabled}
@click=${() => state.onRemove([...basePath, "autoAllowSkills"])}
>
Use default
</button>`
: nothing}
</div>
</div>
</div>
`;
}
function renderExecApprovalsAllowlist(state: ExecApprovalsState) {
const allowlistPath = ["agents", state.selectedScope, "allowlist"];
const entries = state.allowlist;
return html`
<div class="row" style="margin-top: 18px; justify-content: space-between;">
<div>
<div class="card-title">Allowlist</div>
<div class="card-sub">Case-insensitive glob patterns.</div>
</div>
<button
class="btn btn--sm"
?disabled=${state.disabled}
@click=${() => {
const next = [...entries, { pattern: "" }];
state.onPatch(allowlistPath, next);
}}
>
Add pattern
</button>
</div>
<div class="list" style="margin-top: 12px;">
${entries.length === 0
? html`<div class="muted">No allowlist entries yet.</div>`
: entries.map((entry, index) =>
renderAllowlistEntry(state, entry, index),
)}
</div>
`;
}
function renderAllowlistEntry(
state: ExecApprovalsState,
entry: ExecApprovalsAllowlistEntry,
index: number,
) {
const lastUsed = entry.lastUsedAt ? formatAgo(entry.lastUsedAt) : "never";
const lastCommand = entry.lastUsedCommand
? clampText(entry.lastUsedCommand, 120)
: null;
const lastPath = entry.lastResolvedPath
? clampText(entry.lastResolvedPath, 120)
: null;
return html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${entry.pattern?.trim() ? entry.pattern : "New pattern"}</div>
<div class="list-sub">Last used: ${lastUsed}</div>
${lastCommand ? html`<div class="list-sub mono">${lastCommand}</div>` : nothing}
${lastPath ? html`<div class="list-sub mono">${lastPath}</div>` : nothing}
</div>
<div class="list-meta">
<label class="field">
<span>Pattern</span>
<input
type="text"
.value=${entry.pattern ?? ""}
?disabled=${state.disabled}
@input=${(event: Event) => {
const target = event.target as HTMLInputElement;
state.onPatch(
["agents", state.selectedScope, "allowlist", index, "pattern"],
target.value,
);
}}
/>
</label>
<button
class="btn btn--sm danger"
?disabled=${state.disabled}
@click=${() => {
if (state.allowlist.length <= 1) {
state.onRemove(["agents", state.selectedScope, "allowlist"]);
return;
}
state.onRemove(["agents", state.selectedScope, "allowlist", index]);
}}
>
Remove
</button>
</div>
</div>
`;
}
function renderAgentBinding(agent: BindingAgent, state: BindingState) {
const bindingValue = agent.binding ?? "__default__";
const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id;