/** * 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 os from 'node:os'; 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'; import { isSafeSegment } from './routes/validate.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__ 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:' tool: string; args: Record; 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 { 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; try { args = JSON.parse(row.args_json) as Record; } 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' }; } if (row.workspace_id !== null && row.workspace_id !== '*' && !isSafeSegment(row.workspace_id)) { store.updatePendingActionResult(row.id, { status: 'failed', error: 'invalid workspace id', executedAt: nowIso() }); return { ok: false, status: 'failed', error: 'invalid workspace id' }; } try { const wsId = row.workspace_id && row.workspace_id !== '*' ? row.workspace_id : 'default'; const wsPath = row.workspace_id === null ? os.homedir() : path.join(server.localConfig.dataDir, 'workspaces', wsId, 'files'); const tools = server.agentState.buildToolsForWorkspace( wsPath, undefined, row.workspace_id === null ? undefined : wsId, ); // The maker proposes the friendly bare name `send_email`; the real tool is a // connector (connector__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 }; } }