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

@@ -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,
);
},
};