feat: add exec approvals editor in control ui and mac app
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, per‑agent
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
72
src/gateway/protocol/schema/exec-approvals.ts
Normal file
72
src/gateway/protocol/schema/exec-approvals.ts
Normal 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 },
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
157
src/gateway/server-methods/exec-approvals.ts
Normal file
157
src/gateway/server-methods/exec-approvals.ts
Normal 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,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
123
ui/src/ui/controllers/exec-approvals.ts
Normal file
123
ui/src/ui/controllers/exec-approvals.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user