feat: route exec approvals via gateway

This commit is contained in:
Peter Steinberger
2026-01-20 12:03:18 +00:00
parent 67be9aed28
commit 87343c374e
12 changed files with 359 additions and 5 deletions

View File

@@ -46,6 +46,7 @@ private struct ExecHostRequest: Codable {
var needsScreenRecording: Bool?
var agentId: String?
var sessionKey: String?
var approvalDecision: ExecApprovalDecision?
}
private struct ExecHostRunResult: Codable {
@@ -328,8 +329,21 @@ private enum ExecHostExecutor {
return false
}()
var approvedByAsk = false
if requiresAsk {
let approvalDecision = request.approvalDecision
if approvalDecision == .deny {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: user denied",
reason: "user-denied"))
}
var approvedByAsk = approvalDecision != nil
if requiresAsk, approvalDecision == nil {
let decision = ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: displayCommand,
@@ -364,6 +378,13 @@ private enum ExecHostExecutor {
}
}
if approvalDecision == .allowAlways, security == .allowlist {
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
if !pattern.isEmpty {
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
}
}
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
return ExecHostResponse(
type: "exec-res",

View File

@@ -477,6 +477,7 @@ export function createExecTool(
(hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch);
let approvedByAsk = false;
let approvalDecision: "allow-once" | "allow-always" | null = null;
if (requiresAsk) {
const decisionResult = (await callGatewayTool(
"exec.approval.request",
@@ -504,20 +505,24 @@ export function createExecTool(
if (!decision) {
if (askFallback === "full") {
approvedByAsk = true;
approvalDecision = "allow-once";
} else if (askFallback === "allowlist") {
if (!allowlistMatch) {
throw new Error("exec denied: approval required (approval UI not available)");
}
approvedByAsk = true;
approvalDecision = "allow-once";
} else {
throw new Error("exec denied: approval required (approval UI not available)");
}
}
if (decision === "allow-once") {
approvedByAsk = true;
approvalDecision = "allow-once";
}
if (decision === "allow-always") {
approvedByAsk = true;
approvalDecision = "allow-always";
if (hostSecurity === "allowlist") {
const pattern =
resolution?.resolvedPath ??
@@ -556,6 +561,7 @@ export function createExecTool(
agentId: defaults?.agentId,
sessionKey: defaults?.sessionKey,
approved: approvedByAsk,
approvalDecision: approvalDecision ?? undefined,
},
idempotencyKey: crypto.randomUUID(),
};

View File

@@ -10,6 +10,7 @@ export type ExecHostRequest = {
needsScreenRecording?: boolean | null;
agentId?: string | null;
sessionKey?: string | null;
approvalDecision?: "allow-once" | "allow-always" | null;
};
export type ExecHostRunResult = {

View File

@@ -50,6 +50,7 @@ type SystemRunParams = {
agentId?: string | null;
sessionKey?: string | null;
approved?: boolean | null;
approvalDecision?: string | null;
};
type SystemWhichParams = {
@@ -561,6 +562,10 @@ async function handleInvoke(
const useMacAppExec =
process.platform === "darwin" && (execHostEnforced || !execHostFallbackAllowed);
if (useMacAppExec) {
const approvalDecision =
params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"
? params.approvalDecision
: null;
const execRequest: ExecHostRequest = {
command: argv,
rawCommand: rawCommand || null,
@@ -570,6 +575,7 @@ async function handleInvoke(
needsScreenRecording: params.needsScreenRecording ?? null,
agentId: agentId ?? null,
sessionKey: sessionKey ?? null,
approvalDecision,
};
const response = await runViaMacAppExecHost({ approvals, request: execRequest });
if (!response) {
@@ -660,7 +666,11 @@ async function handleInvoke(
ask === "always" ||
(ask === "on-miss" && security === "allowlist" && !allowlistMatch && !skillAllow);
const approvedByAsk = params.approved === true;
const approvalDecision =
params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"
? params.approvalDecision
: null;
const approvedByAsk = approvalDecision !== null || params.approved === true;
if (requiresAsk && !approvedByAsk) {
await sendNodeEvent(
client,
@@ -679,7 +689,7 @@ async function handleInvoke(
});
return;
}
if (approvedByAsk && security === "allowlist") {
if (approvalDecision === "allow-always" && security === "allowlist") {
const pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? argv[0] ?? "";
if (pattern) addAllowlistEntry(approvals.file, agentId, pattern);
}

View File

@@ -1181,3 +1181,96 @@
border-radius: 10px;
image-rendering: pixelated;
}
.exec-approval-overlay {
position: fixed;
inset: 0;
background: rgba(8, 12, 18, 0.7);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 200;
}
.exec-approval-card {
width: min(560px, 100%);
background: var(--panel-strong);
border: 1px solid var(--border-strong);
border-radius: 18px;
padding: 20px;
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.35);
animation: rise 0.25s ease;
}
.exec-approval-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.exec-approval-title {
font-family: var(--font-display);
font-size: 14px;
letter-spacing: 0.8px;
text-transform: uppercase;
}
.exec-approval-sub {
color: var(--muted);
font-size: 12px;
}
.exec-approval-queue {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 999px;
padding: 4px 10px;
}
.exec-approval-command {
margin-top: 12px;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--border);
border-radius: 12px;
word-break: break-word;
white-space: pre-wrap;
}
.exec-approval-meta {
margin-top: 12px;
display: grid;
gap: 6px;
font-size: 12px;
color: var(--muted);
}
.exec-approval-meta-row {
display: flex;
justify-content: space-between;
gap: 12px;
}
.exec-approval-meta-row span:last-child {
color: var(--text);
font-family: var(--mono);
}
.exec-approval-error {
margin-top: 10px;
font-size: 12px;
color: var(--danger);
}
.exec-approval-actions {
margin-top: 16px;
display: flex;
flex-wrap: wrap;
gap: 10px;
}

View File

@@ -16,7 +16,14 @@ import {
setLastActiveSessionKey,
} from "./app-settings";
import { handleChatEvent, type ChatEventPayload } from "./controllers/chat";
import {
addExecApproval,
parseExecApprovalRequested,
parseExecApprovalResolved,
removeExecApproval,
} from "./controllers/exec-approval";
import type { ClawdbotApp } from "./app";
import type { ExecApprovalRequest } from "./controllers/exec-approval";
type GatewayHost = {
settings: UiSettings;
@@ -34,6 +41,8 @@ type GatewayHost = {
debugHealth: HealthSnapshot | null;
sessionKey: string;
chatRunId: string | null;
execApprovalQueue: ExecApprovalRequest[];
execApprovalError: string | null;
};
type SessionDefaultsSnapshot = {
@@ -94,6 +103,8 @@ export function connectGateway(host: GatewayHost) {
host.lastError = null;
host.hello = null;
host.connected = false;
host.execApprovalQueue = [];
host.execApprovalError = null;
host.client?.stop();
host.client = new GatewayBrowserClient({
@@ -175,6 +186,26 @@ export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) {
if (evt.event === "device.pair.requested" || evt.event === "device.pair.resolved") {
void loadDevices(host as unknown as ClawdbotApp, { quiet: true });
}
if (evt.event === "exec.approval.requested") {
const entry = parseExecApprovalRequested(evt.payload);
if (entry) {
host.execApprovalQueue = addExecApproval(host.execApprovalQueue, entry);
host.execApprovalError = null;
const delay = Math.max(0, entry.expiresAtMs - Date.now() + 500);
window.setTimeout(() => {
host.execApprovalQueue = removeExecApproval(host.execApprovalQueue, entry.id);
}, delay);
}
return;
}
if (evt.event === "exec.approval.resolved") {
const resolved = parseExecApprovalResolved(evt.payload);
if (resolved) {
host.execApprovalQueue = removeExecApproval(host.execApprovalQueue, resolved.id);
}
}
}
export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {

View File

@@ -38,6 +38,7 @@ import { renderLogs } from "./views/logs";
import { renderNodes } from "./views/nodes";
import { renderOverview } from "./views/overview";
import { renderSessions } from "./views/sessions";
import { renderExecApprovalPrompt } from "./views/exec-approval";
import {
approveDevicePairing,
loadDevices,
@@ -518,6 +519,7 @@ export function renderApp(state: AppViewState) {
})
: nothing}
</main>
${renderExecApprovalPrompt(state)}
</div>
`;
}

View File

@@ -25,6 +25,7 @@ import type {
ExecApprovalsSnapshot,
} from "./controllers/exec-approvals";
import type { DevicePairingList } from "./controllers/devices";
import type { ExecApprovalRequest } from "./controllers/exec-approval";
export type AppViewState = {
settings: UiSettings;
@@ -60,6 +61,9 @@ export type AppViewState = {
execApprovalsSelectedAgent: string | null;
execApprovalsTarget: "gateway" | "node";
execApprovalsTargetNodeId: string | null;
execApprovalQueue: ExecApprovalRequest[];
execApprovalBusy: boolean;
execApprovalError: string | null;
configLoading: boolean;
configRaw: string;
configValid: boolean | null;
@@ -137,6 +141,7 @@ export type AppViewState = {
handleWhatsAppLogout: () => Promise<void>;
handleChannelConfigSave: () => Promise<void>;
handleChannelConfigReload: () => Promise<void>;
handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise<void>;
handleConfigLoad: () => Promise<void>;
handleConfigSave: () => Promise<void>;
handleConfigApply: () => Promise<void>;

View File

@@ -29,6 +29,7 @@ import type {
ExecApprovalsSnapshot,
} from "./controllers/exec-approvals";
import type { DevicePairingList } from "./controllers/devices";
import type { ExecApprovalRequest } from "./controllers/exec-approval";
import {
resetToolStream as resetToolStreamInternal,
toggleToolOutput as toggleToolOutputInternal,
@@ -120,6 +121,9 @@ export class ClawdbotApp extends LitElement {
@state() execApprovalsSelectedAgent: string | null = null;
@state() execApprovalsTarget: "gateway" | "node" = "gateway";
@state() execApprovalsTargetNodeId: string | null = null;
@state() execApprovalQueue: ExecApprovalRequest[] = [];
@state() execApprovalBusy = false;
@state() execApprovalError: string | null = null;
@state() configLoading = false;
@state() configRaw = "{\n}\n";
@@ -365,6 +369,24 @@ export class ClawdbotApp extends LitElement {
await handleChannelConfigReloadInternal(this);
}
async handleExecApprovalDecision(decision: "allow-once" | "allow-always" | "deny") {
const active = this.execApprovalQueue[0];
if (!active || !this.client || this.execApprovalBusy) return;
this.execApprovalBusy = true;
this.execApprovalError = null;
try {
await this.client.request("exec.approval.resolve", {
id: active.id,
decision,
});
this.execApprovalQueue = this.execApprovalQueue.filter((entry) => entry.id !== active.id);
} catch (err) {
this.execApprovalError = `Exec approval failed: ${String(err)}`;
} finally {
this.execApprovalBusy = false;
}
}
// Sidebar handlers for tool output viewing
handleOpenSidebar(content: string) {
if (this.sidebarCloseTimer != null) {

View File

@@ -0,0 +1,85 @@
export type ExecApprovalRequestPayload = {
command: string;
cwd?: string | null;
host?: string | null;
security?: string | null;
ask?: string | null;
agentId?: string | null;
resolvedPath?: string | null;
sessionKey?: string | null;
};
export type ExecApprovalRequest = {
id: string;
request: ExecApprovalRequestPayload;
createdAtMs: number;
expiresAtMs: number;
};
export type ExecApprovalResolved = {
id: string;
decision?: string | null;
resolvedBy?: string | null;
ts?: number | null;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function parseExecApprovalRequested(payload: unknown): ExecApprovalRequest | null {
if (!isRecord(payload)) return null;
const id = typeof payload.id === "string" ? payload.id.trim() : "";
const request = payload.request;
if (!id || !isRecord(request)) return null;
const command = typeof request.command === "string" ? request.command.trim() : "";
if (!command) return null;
const createdAtMs = typeof payload.createdAtMs === "number" ? payload.createdAtMs : 0;
const expiresAtMs = typeof payload.expiresAtMs === "number" ? payload.expiresAtMs : 0;
if (!createdAtMs || !expiresAtMs) return null;
return {
id,
request: {
command,
cwd: typeof request.cwd === "string" ? request.cwd : null,
host: typeof request.host === "string" ? request.host : null,
security: typeof request.security === "string" ? request.security : null,
ask: typeof request.ask === "string" ? request.ask : null,
agentId: typeof request.agentId === "string" ? request.agentId : null,
resolvedPath: typeof request.resolvedPath === "string" ? request.resolvedPath : null,
sessionKey: typeof request.sessionKey === "string" ? request.sessionKey : null,
},
createdAtMs,
expiresAtMs,
};
}
export function parseExecApprovalResolved(payload: unknown): ExecApprovalResolved | null {
if (!isRecord(payload)) return null;
const id = typeof payload.id === "string" ? payload.id.trim() : "";
if (!id) return null;
return {
id,
decision: typeof payload.decision === "string" ? payload.decision : null,
resolvedBy: typeof payload.resolvedBy === "string" ? payload.resolvedBy : null,
ts: typeof payload.ts === "number" ? payload.ts : null,
};
}
export function pruneExecApprovalQueue(queue: ExecApprovalRequest[]): ExecApprovalRequest[] {
const now = Date.now();
return queue.filter((entry) => entry.expiresAtMs > now);
}
export function addExecApproval(
queue: ExecApprovalRequest[],
entry: ExecApprovalRequest,
): ExecApprovalRequest[] {
const next = pruneExecApprovalQueue(queue).filter((item) => item.id !== entry.id);
next.push(entry);
return next;
}
export function removeExecApproval(queue: ExecApprovalRequest[], id: string): ExecApprovalRequest[] {
return pruneExecApprovalQueue(queue).filter((entry) => entry.id !== id);
}

View File

@@ -127,7 +127,7 @@ export class GatewayBrowserClient {
this.connectTimer = null;
}
const deviceIdentity = await loadOrCreateDeviceIdentity();
const scopes = ["operator.admin"];
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
const role = "operator";
const storedToken = loadDeviceAuthToken({
deviceId: deviceIdentity.deviceId,

View File

@@ -0,0 +1,78 @@
import { html, nothing } from "lit";
import type { AppViewState } from "../app-view-state";
function formatRemaining(ms: number): string {
const remaining = Math.max(0, ms);
const totalSeconds = Math.floor(remaining / 1000);
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
return `${hours}h`;
}
function renderMetaRow(label: string, value?: string | null) {
if (!value) return nothing;
return html`<div class="exec-approval-meta-row"><span>${label}</span><span>${value}</span></div>`;
}
export function renderExecApprovalPrompt(state: AppViewState) {
const active = state.execApprovalQueue[0];
if (!active) return nothing;
const request = active.request;
const remainingMs = active.expiresAtMs - Date.now();
const remaining = remainingMs > 0 ? `expires in ${formatRemaining(remainingMs)}` : "expired";
const queueCount = state.execApprovalQueue.length;
return html`
<div class="exec-approval-overlay" role="dialog" aria-live="polite">
<div class="exec-approval-card">
<div class="exec-approval-header">
<div>
<div class="exec-approval-title">Exec approval needed</div>
<div class="exec-approval-sub">${remaining}</div>
</div>
${queueCount > 1
? html`<div class="exec-approval-queue">${queueCount} pending</div>`
: nothing}
</div>
<div class="exec-approval-command mono">${request.command}</div>
<div class="exec-approval-meta">
${renderMetaRow("Host", request.host)}
${renderMetaRow("Agent", request.agentId)}
${renderMetaRow("Session", request.sessionKey)}
${renderMetaRow("CWD", request.cwd)}
${renderMetaRow("Resolved", request.resolvedPath)}
${renderMetaRow("Security", request.security)}
${renderMetaRow("Ask", request.ask)}
</div>
${state.execApprovalError
? html`<div class="exec-approval-error">${state.execApprovalError}</div>`
: nothing}
<div class="exec-approval-actions">
<button
class="btn primary"
?disabled=${state.execApprovalBusy}
@click=${() => state.handleExecApprovalDecision("allow-once")}
>
Allow once
</button>
<button
class="btn"
?disabled=${state.execApprovalBusy}
@click=${() => state.handleExecApprovalDecision("allow-always")}
>
Always allow
</button>
<button
class="btn danger"
?disabled=${state.execApprovalBusy}
@click=${() => state.handleExecApprovalDecision("deny")}
>
Deny
</button>
</div>
</div>
</div>
`;
}