moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View 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 };
}
}