This commit is contained in:
218
packages/server/src/local/held-action-executor.ts
Normal file
218
packages/server/src/local/held-action-executor.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Held-action approval queue — the enqueue boundary + the deferred executor.
|
||||
*
|
||||
* L2 "assisted" automations: a headless run (e.g. an assist-mode Loop) proposes
|
||||
* a discrete tool call as a self-contained {tool, args, summary} descriptor. It
|
||||
* is NOT executed inline — it is HELD (cron-store `pending_actions`) until a
|
||||
* human approves, then THIS executor re-materializes and runs that single tool
|
||||
* call. Self-contained descriptor + execute-on-approve, never mid-run
|
||||
* suspend/resume (a headless tick must finish; the only "suspend" primitive in
|
||||
* the codebase is request-bound and restart-fatal).
|
||||
*
|
||||
* enqueueHeldAction — the single seam both the v0 producer and a future v1
|
||||
* ConfirmationGate.promptFn call to hold an action.
|
||||
* executeHeldAction — runs the real tool on approval, idempotent + re-validated.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { PendingActionRow, PendingActionStatus } from '@waggle/core';
|
||||
import { scanForInjection, isCriticalNeverAutopass, classifyGatedToolRisk } from '@waggle/agent';
|
||||
import { emitNotification } from './routes/notifications.js';
|
||||
|
||||
const nowIso = (): string => new Date().toISOString();
|
||||
|
||||
/** Held actions expire from the queue after this long if never decided. */
|
||||
const HELD_ACTION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* v0 proposable-tool predicate (fork F2): the narrow set an assist-mode Loop may
|
||||
* propose. Deliberately small — it bounds the blast radius of one-click
|
||||
* approval. The canonical case is `send_email` (assistant drafts, human
|
||||
* approves). Founder-editable.
|
||||
*/
|
||||
export function isProposableTool(tool: string): boolean {
|
||||
if (tool === 'send_email' || tool === 'write_file' || tool === 'edit_file' || tool === 'generate_docx') {
|
||||
return true;
|
||||
}
|
||||
// create_skill is the self-evolution proposal vehicle (session-reviewer). Held
|
||||
// here so a headless review turn can never write a skill to disk without human
|
||||
// approval; on approve, executeHeldAction runs the real create_skill tool from
|
||||
// the workspace pool, which persists through the sanctioned, backup-protected
|
||||
// writeSkill path (skill-tools.ts → skill-write-service.ts).
|
||||
if (tool === 'create_skill') return true;
|
||||
// Connector WRITE actions (connector_<id>_<action> where action mutates).
|
||||
if (tool.startsWith('connector_') && /_(create|update|delete|send|post|transition|remove|add|set|put)(_|$)/.test(tool)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface EnqueueInput {
|
||||
workspaceId: string | null;
|
||||
source: string; // e.g. 'loop:<scheduleId>'
|
||||
tool: string;
|
||||
args: Record<string, unknown>;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export type EnqueueResult = { id: string } | { refused: 'not_proposable' | 'critical' | 'injection' };
|
||||
|
||||
/**
|
||||
* Validate + persist a held action awaiting approval. Refuses up front:
|
||||
* - non-proposable tools (F2 allowlist),
|
||||
* - critical / never-autopass actions (F3 — these may NEVER be a one-click
|
||||
* button; a force-push-to-main can't become a single tap),
|
||||
* - args that trip the injection scanner.
|
||||
* On success: persists 'held' with stamped risk + emits an approval notification.
|
||||
*/
|
||||
export function enqueueHeldAction(server: FastifyInstance, input: EnqueueInput): EnqueueResult {
|
||||
const { tool, args } = input;
|
||||
if (!isProposableTool(tool)) return { refused: 'not_proposable' };
|
||||
if (isCriticalNeverAutopass(tool, args)) return { refused: 'critical' };
|
||||
const argsJson = JSON.stringify(args ?? {});
|
||||
if (!scanForInjection(argsJson, 'tool_output').safe) return { refused: 'injection' };
|
||||
|
||||
const { riskLevel, approvalClass } = classifyGatedToolRisk(tool, args);
|
||||
const id = randomUUID();
|
||||
server.cronStore.savePendingAction({
|
||||
id,
|
||||
workspaceId: input.workspaceId,
|
||||
source: input.source,
|
||||
toolName: tool,
|
||||
argsJson,
|
||||
summary: input.summary,
|
||||
riskLevel,
|
||||
approvalClass,
|
||||
expiresAt: new Date(Date.now() + HELD_ACTION_TTL_MS).toISOString(),
|
||||
});
|
||||
emitNotification(server, {
|
||||
title: 'Action awaiting your approval',
|
||||
body: input.summary || `${tool} proposed by ${input.source}`,
|
||||
category: 'approval',
|
||||
actionUrl: '/approvals',
|
||||
});
|
||||
return { id };
|
||||
}
|
||||
|
||||
export interface ReviewTurnDecision {
|
||||
/** Audit step surfaced to the (headless) review stream. */
|
||||
step: string;
|
||||
/** Cancel reason for the pre:tool hook — a review turn never runs a tool inline. */
|
||||
reason: string;
|
||||
/** Enqueue outcome when the tool was proposable; null when the tool was denied. */
|
||||
enqueued: EnqueueResult | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what a self-evolution review turn does with a gated tool call. A
|
||||
* proposable tool (create_skill, send_email, …) becomes a DURABLE held action
|
||||
* awaiting human approval; any other gated tool is denied. Either way the tool
|
||||
* is cancelled — a headless review turn never executes a tool inline, so the
|
||||
* reviewer can never persist a skill (or send an email) without explicit
|
||||
* human approval.
|
||||
*
|
||||
* Extracted from routes/chat.ts so this trust boundary is unit-testable: the
|
||||
* chat.ts pre:tool hook that hosts this branch is only registered when
|
||||
* `!hasCustomRunner`, and route-test harnesses inject a custom runner, so the
|
||||
* branch is otherwise unreachable in a route test (same rationale as
|
||||
* persona-tool-filter.ts).
|
||||
*/
|
||||
export function decideReviewTurnTool(server: FastifyInstance, input: EnqueueInput): ReviewTurnDecision {
|
||||
const { tool } = input;
|
||||
const reason = `Review turn: ${tool} held for approval`;
|
||||
if (isProposableTool(tool)) {
|
||||
const enq = enqueueHeldAction(server, input);
|
||||
return {
|
||||
step: 'refused' in enq
|
||||
? `⚠ ${tool} proposal refused (${enq.refused})`
|
||||
: `📋 ${tool} held for your approval`,
|
||||
reason,
|
||||
enqueued: enq,
|
||||
};
|
||||
}
|
||||
return {
|
||||
step: `✖ ${tool} not permitted for review turns`,
|
||||
reason,
|
||||
enqueued: null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExecuteResult {
|
||||
ok: boolean;
|
||||
status: PendingActionStatus;
|
||||
result?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a held action ON APPROVAL. Idempotent (atomic claim gate), re-validates
|
||||
* the LLM-proposed args at execute time (defense-in-depth), invokes the REAL tool
|
||||
* via the workspace tool pool, and records the terminal outcome on the row.
|
||||
*/
|
||||
export async function executeHeldAction(server: FastifyInstance, row: PendingActionRow): Promise<ExecuteResult> {
|
||||
const store = server.cronStore;
|
||||
|
||||
// Idempotency: atomically claim 'held' → 'approved'. If we didn't win, the
|
||||
// action was already decided (double-approve / approve-after-deny) — no-op.
|
||||
const claimed = store.claimPendingAction(row.id, 'approved', nowIso());
|
||||
if (!claimed) return { ok: false, status: row.status, error: 'already decided' };
|
||||
|
||||
// Expiry guard: never run a proposal that sat past its TTL (stale context).
|
||||
if (row.expires_at && Date.parse(row.expires_at) < Date.now()) {
|
||||
store.updatePendingActionResult(row.id, { status: 'failed', error: 'held action expired', executedAt: nowIso() });
|
||||
return { ok: false, status: 'failed', error: 'expired' };
|
||||
}
|
||||
|
||||
let args: Record<string, unknown>;
|
||||
try {
|
||||
args = JSON.parse(row.args_json) as Record<string, unknown>;
|
||||
} catch {
|
||||
store.updatePendingActionResult(row.id, { status: 'failed', error: 'corrupt args_json', executedAt: nowIso() });
|
||||
return { ok: false, status: 'failed', error: 'corrupt args_json' };
|
||||
}
|
||||
|
||||
// Re-validate at execute — the args came from an LLM proposal, and time has
|
||||
// passed since enqueue. A critical action or injection-tripping args must not
|
||||
// run even though a human clicked approve.
|
||||
if (isCriticalNeverAutopass(row.tool_name, args) || !scanForInjection(row.args_json, 'tool_output').safe) {
|
||||
store.updatePendingActionResult(row.id, { status: 'failed', error: 'failed execute-time re-validation', executedAt: nowIso() });
|
||||
return { ok: false, status: 'failed', error: 'failed re-validation' };
|
||||
}
|
||||
|
||||
try {
|
||||
const wsId = row.workspace_id && row.workspace_id !== '*' ? row.workspace_id : 'default';
|
||||
const wsPath = path.join(server.localConfig.dataDir, 'workspaces', wsId, 'files');
|
||||
const tools = server.agentState.buildToolsForWorkspace(wsPath, undefined, row.workspace_id ?? undefined);
|
||||
// The maker proposes the friendly bare name `send_email`; the real tool is a
|
||||
// connector (connector_<id>_send_email). Resolve the alias against the LIVE
|
||||
// pool at execute time (connection state can change between propose + approve).
|
||||
let tool = tools.find(t => t.name === row.tool_name);
|
||||
if (!tool && row.tool_name === 'send_email') {
|
||||
tool = tools.find(t => /^connector_[^_]+_send_email$/.test(t.name))
|
||||
?? tools.find(t => /^connector_[^_]+_send(_|$)/.test(t.name));
|
||||
}
|
||||
if (!tool) {
|
||||
const error = row.tool_name === 'send_email'
|
||||
? 'no email connector connected — connect Gmail/Outlook to send'
|
||||
: `unknown tool: ${row.tool_name}`;
|
||||
store.updatePendingActionResult(row.id, { status: 'failed', error, executedAt: nowIso() });
|
||||
return { ok: false, status: 'failed', error };
|
||||
}
|
||||
const result = await tool.execute(args);
|
||||
const summary = result.length > 280 ? `${result.slice(0, 277)}...` : result;
|
||||
store.updatePendingActionResult(row.id, { status: 'executed', resultSummary: summary, executedAt: nowIso() });
|
||||
emitNotification(server, {
|
||||
title: 'Approved action executed',
|
||||
body: summary || row.tool_name,
|
||||
category: 'approval',
|
||||
actionUrl: '/approvals',
|
||||
});
|
||||
return { ok: true, status: 'executed', result };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
store.updatePendingActionResult(row.id, { status: 'failed', error: msg, executedAt: nowIso() });
|
||||
return { ok: false, status: 'failed', error: msg };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user