feat(engine,core,api): CLI nodes run arbitrary commands with trust-on-first-use approval

CLI prompt nodes now accept a raw cliCommand (any command + args), not just
named scripts. A raw command must be explicitly approved by the user before it
runs: an unapproved command pauses the task (status awaiting-cli-approval) with
the command shown; the user approves via POST /tasks/:id/workflow/approve-cli,
which records the exact command string in settings.approvedWorkflowCliCommands
and resumes. Named scripts (settings.scripts) still never require approval.
Adds POST /tasks/:id/workflow/input to answer await-input nodes (records a
steering comment + resumes).
This commit is contained in:
gsxdsm
2026-06-03 13:44:13 -07:00
parent 5a318bfe57
commit ddde3c5b57
6 changed files with 150 additions and 2 deletions

View File

@@ -186,6 +186,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
globalPause: false,
globalPauseReason: undefined,
defaultWorkflowId: undefined,
approvedWorkflowCliCommands: undefined,
enginePaused: false,
maxConcurrent: 2,
maxTriageConcurrent: 2,

View File

@@ -11163,6 +11163,28 @@ ${stepsSection}`;
await this.updateSettings({ defaultWorkflowId: workflowId } as unknown as Partial<Settings>);
}
/** Whether a raw workflow CLI command has been approved (trust-on-first-use).
* Comparison is on the exact trimmed command string. */
async isWorkflowCliCommandApproved(command: string): Promise<boolean> {
const trimmed = command.trim();
if (!trimmed) return false;
const settings = await this.getSettings();
const approved = (settings as { approvedWorkflowCliCommands?: string[] }).approvedWorkflowCliCommands;
return Array.isArray(approved) && approved.includes(trimmed);
}
/** Record approval for a raw workflow CLI command. Idempotent. */
async approveWorkflowCliCommand(command: string): Promise<void> {
const trimmed = command.trim();
if (!trimmed) throw new Error("CLI command is required");
const settings = await this.getSettings();
const approved = (settings as { approvedWorkflowCliCommands?: string[] }).approvedWorkflowCliCommands ?? [];
if (approved.includes(trimmed)) return;
await this.updateSettings({
approvedWorkflowCliCommands: [...approved, trimmed],
} as unknown as Partial<Settings>);
}
/** Read the workflow currently selected for a task, if any. */
getTaskWorkflowSelection(taskId: string): { workflowId: string; stepIds: string[] } | undefined {
const row = this.db

View File

@@ -2892,6 +2892,10 @@ export interface ProjectSettings {
/** Default custom workflow (WF-…) applied to newly created tasks when the
* caller does not specify enabledWorkflowSteps. Overridable per task. */
defaultWorkflowId?: string;
/** Raw CLI commands a user has explicitly approved for workflow CLI nodes
* (trust-on-first-use). A node's command must appear here before it runs;
* named scripts (settings.scripts) never require approval. */
approvedWorkflowCliCommands?: string[];
/** Engine pause (soft pause): when true, the scheduler and triage
* processor stop dispatching **new** work (scheduling, triage
* specification, and auto-merge), but currently running agent sessions

View File

@@ -4975,6 +4975,21 @@ export function selectTaskWorkflow(
);
}
/** Approve the raw CLI command a task is paused on, and resume it. */
export function approveTaskWorkflowCli(taskId: string, projectId?: string): Promise<{ approved: string }> {
return api<{ approved: string }>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/workflow/approve-cli`, projectId), {
method: "POST",
});
}
/** Submit the user's answer to an await-input node and resume the task. */
export function submitTaskWorkflowInput(taskId: string, text: string, projectId?: string): Promise<{ ok: true }> {
return api<{ ok: true }>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/workflow/input`, projectId), {
method: "POST",
body: JSON.stringify({ text }),
});
}
/** Read the project default workflow. */
export function fetchProjectDefaultWorkflow(projectId?: string): Promise<{ workflowId: string | null }> {
return api<{ workflowId: string | null }>(withProjectId("/project/default-workflow", projectId));

View File

@@ -161,6 +161,41 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
}
});
// POST /api/tasks/:taskId/workflow/approve-cli — approve the raw CLI command
// the task is currently paused on (trust-on-first-use) and resume the run.
router.post("/tasks/:taskId/workflow/approve-cli", async (req, res) => {
try {
const { store } = await getProjectContext(req);
const task = await store.getTask(req.params.taskId);
const reason = task.pausedReason ?? "";
const match = /^workflow-cli-approval:[^:]+:\s*(.*)$/s.exec(reason);
const command = (req.body?.command as string | undefined) ?? (match ? match[1].trim() : "");
if (!command) throw badRequest("No pending CLI command to approve for this task");
await store.approveWorkflowCliCommand(command);
await store.updateTask(req.params.taskId, { status: null, paused: false, pausedReason: null });
res.json({ approved: command });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
// POST /api/tasks/:taskId/workflow/input — submit the user's answer to an
// await-input node (records a steering comment and resumes the task).
router.post("/tasks/:taskId/workflow/input", async (req, res) => {
try {
const { store } = await getProjectContext(req);
const text = (req.body?.text as string | undefined)?.trim();
if (!text) throw badRequest("Input text is required");
await store.addSteeringComment(req.params.taskId, text);
await store.updateTask(req.params.taskId, { status: null, paused: false, pausedReason: null });
res.json({ ok: true });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
// GET /api/project/default-workflow
router.get("/project/default-workflow", async (req, res) => {
try {

View File

@@ -3378,6 +3378,59 @@ export class TaskExecutor {
return { outcome: "failure", value: "awaiting-user-input" };
}
/** Pause the task for explicit user approval of a raw CLI command. The user
* approves via the dashboard, which records the command and unpauses; on the
* next run isWorkflowCliCommandApproved returns true and the node executes. */
private async pauseForCliApproval(node: WorkflowIrNode, live: TaskDetail, command: string): Promise<WorkflowNodeResult> {
const marker = `workflow-cli-approval:${node.id}`;
await this.store.logEntry(live.id, `Workflow paused for CLI command approval: ${command}`, undefined, this.getRunContextFor(live.id));
await this.store.updateTask(
live.id,
{ status: "awaiting-cli-approval", paused: true, pausedReason: `${marker}: ${command}` },
this.getRunContextFor(live.id),
);
return { outcome: "failure", value: "awaiting-cli-approval" };
}
/** Run an arbitrary (approved) CLI command in the task worktree, supervised. */
private async runRawCliCommand(
task: TaskDetail,
label: string,
command: string,
worktreePath: string,
extraEnv?: NodeJS.ProcessEnv,
): Promise<{ success: boolean; output?: string; error?: string }> {
executorLog.log(`${task.id}: workflow node '${label}' executing approved CLI command: ${command}`);
await this.store.logEntry(task.id, `Workflow node '${label}' executing CLI command: ${command}`, undefined, this.getRunContextFor(task.id));
const abort = new AbortController();
this.registerConfiguredCommandController(task.id, abort);
try {
const result = await runConfiguredCommand(
command,
worktreePath,
120_000,
extraEnv,
createRunAuditor(this.store, {
runId: this.getRunContextFor(task.id)?.runId ?? generateSyntheticRunId("exec-cli", task.id),
agentId: this.getRunContextFor(task.id)?.agentId ?? (task.assignedAgentId ?? "executor"),
taskId: task.id,
phase: "execute",
}),
abort.signal,
);
if (abort.signal.aborted) throw this.createConfiguredCommandAbortError(task.id, command);
if (result.spawnError || result.timedOut || result.exitCode !== 0) {
return { success: false, error: configuredCommandErrorMessage(result) };
}
return { success: true, output: `CLI command completed successfully` };
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") throw err;
return { success: false, error: err instanceof Error ? err.message : String(err) };
} finally {
this.unregisterConfiguredCommandController(task.id, abort);
}
}
/** Run a custom (non-seam) graph node on the proven WorkflowStep machinery. */
private async runGraphCustomNode(
node: WorkflowIrNode,
@@ -3424,9 +3477,27 @@ export class TaskExecutor {
} else if (executorKind === "skill" && typeof cfg.skillName === "string" && cfg.skillName.trim()) {
prompt = `Invoke the "${cfg.skillName}" skill with the following input, following the skill's instructions exactly:\n\n${prompt}`;
} else if (executorKind === "cli") {
// CLI execution routes through script mode with the prompt in the env.
const rawCommand = typeof cfg.cliCommand === "string" && cfg.cliCommand.trim() ? cfg.cliCommand.trim() : undefined;
if (rawCommand) {
// Arbitrary command: gated by trust-on-first-use approval. The exact
// command string must have been explicitly approved by the user.
if (!(await this.store.isWorkflowCliCommandApproved(rawCommand))) {
return this.pauseForCliApproval(node, live, rawCommand);
}
const env = prompt ? { ...process.env, FUSION_NODE_PROMPT: prompt } : undefined;
const out = await this.runRawCliCommand(
live,
typeof cfg.name === "string" && cfg.name.trim() ? cfg.name : node.id,
rawCommand,
worktreePath,
env,
);
const blocking = node.kind === "gate" || cfg.gateMode === "gate";
return { outcome: out.success || !blocking ? "success" : "failure", value: out.success ? "passed" : "failed" };
}
// No raw command: fall back to a named script (still required).
if (!scriptName) {
return { outcome: "failure", value: "cli-script-missing" };
return { outcome: "failure", value: "cli-command-missing" };
}
}