feat: cli-agent adapter settings, autonomy approval gate, and node editor config (U15)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-05 00:30:27 -07:00
parent e70be3b799
commit 243113a3cf
25 changed files with 1977 additions and 6 deletions

View File

@@ -0,0 +1,32 @@
---
"@runfusion/fusion": minor
---
Add CLI-agent adapter launch settings, an autonomy approval gate, and workflow
node-editor configuration for the CLI Agent Executor (U15).
A new `cliAgents` slice of global settings holds per-adapter operator launch
config — command override, extra args, autonomy mode, and env allowlist
additions — validated and sanitized at the write boundary (unknown adapter ids
and invalid fields are dropped). Shipped defaults are owned by the adapters.
The autonomy gate closes the "adjacent settings" bypass: elevation requested
through ANY channel (the autonomy field, extra args such as
`--dangerously-skip-permissions`, an autonomy-toggling env var, or a non-default
command override) is detected over the FULLY RESOLVED argv + env via per-adapter
elevation markers plus a shared generic env-pattern set. `resolveEffectivePosture`
derives the posture chip from the resolved invocation — never the autonomy field
alone — and the effective posture is denormalized onto the session record at
spawn. An elevated launch without a stored per-project approval fails with a
typed `CliAutonomyNotApprovedError` instead of stalling. Approvals are per-project
+ per-adapter (mirroring the raw workflow-CLI-command approval precedent) and the
approving principal in v1 is the daemon-token holder.
The dashboard adds daemon-token-authed routes
(`/api/cli-agents`, `/api/cli-agents/settings`,
`/api/cli-agents/:adapterId/approve-autonomy` + revoke), a Settings section for
per-adapter launch config with an explicit confirmation flow before elevated
autonomy is approved, and a workflow node-editor block that surfaces an adapter
picker (with native/hybrid/generic tier labels), an autonomy toggle, and the
waiting-on-input notification mode (banner / banner+notify) when a node's executor
is `cli-agent`. All new strings are localized in the `app` i18n catalog.

View File

@@ -0,0 +1,134 @@
/**
* cliAgents global-settings slice (U15): round-trip with defaults merge +
* invalid-dropped-at-the-write-boundary behavior.
*/
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { GlobalSettingsStore } from "../global-settings.js";
import { sanitizeCliAgentsSettings, sanitizeCliAgentSettings } from "../settings-schema.js";
describe("sanitizeCliAgentSettings (write-boundary validation)", () => {
it("keeps valid fields and trims strings", () => {
expect(
sanitizeCliAgentSettings({
commandOverride: " /opt/claude ",
extraArgs: [" --foo ", "", "bar"],
envAdditions: ["MY_VAR", " ", "OTHER"],
autonomyMode: "elevated",
}),
).toEqual({
commandOverride: "/opt/claude",
extraArgs: ["--foo", "bar"],
envAdditions: ["MY_VAR", "OTHER"],
autonomyMode: "elevated",
});
});
it("drops unknown fields and invalid values", () => {
expect(
sanitizeCliAgentSettings({
commandOverride: 42,
extraArgs: "not-an-array",
envAdditions: [1, 2, 3],
autonomyMode: "godmode",
bogus: "x",
}),
).toBeUndefined();
});
it("drops empty-after-trim command override", () => {
expect(sanitizeCliAgentSettings({ commandOverride: " " })).toBeUndefined();
});
});
describe("sanitizeCliAgentsSettings", () => {
it("drops unknown adapter ids", () => {
const out = sanitizeCliAgentsSettings({
"claude-code": { autonomyMode: "elevated" },
"totally-made-up": { autonomyMode: "elevated" },
});
expect(Object.keys(out)).toEqual(["claude-code"]);
});
it("returns empty object for non-objects", () => {
expect(sanitizeCliAgentsSettings(null)).toEqual({});
expect(sanitizeCliAgentsSettings([1, 2])).toEqual({});
expect(sanitizeCliAgentsSettings("x")).toEqual({});
});
it("omits adapter entries that sanitize to nothing", () => {
const out = sanitizeCliAgentsSettings({
codex: { autonomyMode: "garbage" },
pi: { extraArgs: ["--ok"] },
});
expect(out).toEqual({ pi: { extraArgs: ["--ok"] } });
});
});
describe("GlobalSettingsStore cliAgents round-trip", () => {
let dir: string;
let store: GlobalSettingsStore;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "fusion-cli-agents-"));
store = new GlobalSettingsStore(dir);
await store.init();
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("defaults cliAgents to an empty object", async () => {
const settings = await store.getSettings();
expect(settings.cliAgents).toEqual({});
});
it("persists a valid adapter config across a fresh read", async () => {
await store.updateSettings({
cliAgents: {
"claude-code": {
commandOverride: "/usr/local/bin/claude",
extraArgs: ["--verbose"],
autonomyMode: "elevated",
envAdditions: ["HTTP_PROXY"],
},
},
});
store.invalidateCache();
const reread = await store.getSettings();
expect(reread.cliAgents).toEqual({
"claude-code": {
commandOverride: "/usr/local/bin/claude",
extraArgs: ["--verbose"],
autonomyMode: "elevated",
envAdditions: ["HTTP_PROXY"],
},
});
});
it("drops invalid adapter ids and fields at the write boundary", async () => {
await store.updateSettings({
cliAgents: {
// unknown adapter id → dropped
"evil-adapter": { autonomyMode: "elevated" },
// valid adapter, junk autonomyMode dropped, valid extraArgs kept
codex: { autonomyMode: "yolo", extraArgs: ["--model=gpt"] },
} as never,
});
store.invalidateCache();
const reread = await store.getSettings();
expect(reread.cliAgents).toEqual({ codex: { extraArgs: ["--model=gpt"] } });
});
it("merges per-adapter without dropping unrelated global keys", async () => {
await store.updateSettings({ themeMode: "light" });
await store.updateSettings({ cliAgents: { pi: { extraArgs: ["--tools=read"] } } });
store.invalidateCache();
const reread = await store.getSettings();
expect(reread.themeMode).toBe("light");
expect(reread.cliAgents).toEqual({ pi: { extraArgs: ["--tools=read"] } });
});
});

View File

@@ -19,6 +19,7 @@ import { mkdir, readFile, writeFile, rename, chmod } from "node:fs/promises";
import { existsSync, mkdirSync, renameSync } from "node:fs";
import type { GlobalSettings } from "./types.js";
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
import { sanitizeCliAgentsSettings } from "./settings-schema.js";
function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || homedir();
@@ -193,6 +194,11 @@ export class GlobalSettingsStore {
// null → delete this key from the merged object
// This effectively makes it fall through to the default
delete merged[key];
} else if (key === "cliAgents") {
// Validation at the write boundary (U15, Global Settings convention):
// unknown adapter ids and invalid fields are dropped before persist so
// a malformed `cliAgents` payload can never reach launch resolution.
merged[key] = sanitizeCliAgentsSettings(value);
} else {
// normal value → set it
merged[key] = value;

View File

@@ -1,5 +1,5 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext } from "./types.js";
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js";
export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js";
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js";
export {
resolveEntryPointBranchAssignment,

View File

@@ -1,4 +1,4 @@
import type { GlobalSettings, ProjectSettings, Settings } from "./types.js";
import type { CliAgentSettings, GlobalSettings, ProjectSettings, Settings } from "./types.js";
export interface MergeRequestContractShadowSettingsSource {
mergeRequestContractShadowEnabled?: boolean;
@@ -180,6 +180,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
},
owningNodeHandoffPolicy: "reassign-to-local",
experimentalFeatures: {},
cliAgents: {},
} satisfies CompleteSettings<GlobalSettings>;
/** Default values for project-level settings. */
@@ -188,6 +189,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
globalPauseReason: undefined,
defaultWorkflowId: undefined,
approvedWorkflowCliCommands: undefined,
approvedCliAutonomyAdapters: undefined,
enginePaused: false,
maxConcurrent: 2,
maxTriageConcurrent: 2,
@@ -521,3 +523,81 @@ export function resolvePersistAgentThinkingLog(
if (typeof settings?.persistAgentThinkingLog === "boolean") return settings.persistAgentThinkingLog;
return false;
}
// ── CLI-agent settings sanitization (U15) ───────────────────────────────────
/** Adapter ids accepted in `cliAgents`. Unknown ids are dropped at the write
* boundary so a settings file cannot carry config for non-existent adapters. */
export const CLI_AGENT_ADAPTER_IDS = Object.freeze([
"claude-code",
"codex",
"droid",
"pi",
"generic",
] as const);
/** Autonomy modes accepted in a `CliAgentSettings` entry. */
export const CLI_AGENT_AUTONOMY_MODES = Object.freeze(["default", "elevated"] as const);
function sanitizeStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const cleaned = value
.filter((v): v is string => typeof v === "string")
.map((v) => v.trim())
.filter((v) => v.length > 0);
return cleaned.length > 0 ? cleaned : undefined;
}
/**
* Sanitize a single adapter's launch settings (U15). Drops unknown fields and
* invalid values; returns `undefined` when nothing survives (so the caller can
* omit an empty entry). Pure — no I/O.
*
* Validation rules:
* - `commandOverride`: non-empty trimmed string, else dropped.
* - `extraArgs` / `envAdditions`: arrays of non-empty trimmed strings, else dropped.
* - `autonomyMode`: one of CLI_AGENT_AUTONOMY_MODES, else dropped (falls back to
* the adapter baseline at resolution time).
*/
export function sanitizeCliAgentSettings(value: unknown): CliAgentSettings | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const input = value as Record<string, unknown>;
const out: CliAgentSettings = {};
if (typeof input.commandOverride === "string") {
const trimmed = input.commandOverride.trim();
if (trimmed.length > 0) out.commandOverride = trimmed;
}
const extraArgs = sanitizeStringArray(input.extraArgs);
if (extraArgs) out.extraArgs = extraArgs;
const envAdditions = sanitizeStringArray(input.envAdditions);
if (envAdditions) out.envAdditions = envAdditions;
if (
typeof input.autonomyMode === "string" &&
(CLI_AGENT_AUTONOMY_MODES as readonly string[]).includes(input.autonomyMode)
) {
out.autonomyMode = input.autonomyMode as CliAgentSettings["autonomyMode"];
}
return Object.keys(out).length > 0 ? out : undefined;
}
/**
* Sanitize the whole `cliAgents` map at the write boundary (U15). Drops unknown
* adapter ids and any entry that sanitizes to nothing. Returns a fresh object;
* always returns an object (possibly empty) so the field round-trips cleanly.
*/
export function sanitizeCliAgentsSettings(value: unknown): Record<string, CliAgentSettings> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const input = value as Record<string, unknown>;
const out: Record<string, CliAgentSettings> = {};
for (const adapterId of CLI_AGENT_ADAPTER_IDS) {
if (!(adapterId in input)) continue;
const entry = sanitizeCliAgentSettings(input[adapterId]);
if (entry) out[adapterId] = entry;
}
return out;
}

View File

@@ -12985,6 +12985,50 @@ ${stepsSection}`;
} as unknown as Partial<Settings>);
}
/** Whether a CLI-agent adapter has been approved for ELEVATED autonomy in this
* project (CLI Agent Executor, U15). Mirrors the raw-command approval
* precedent; approval is per-project + per-adapter and stored in project
* settings (`approvedCliAutonomyAdapters`). */
async isCliAutonomyApproved(adapterId: string): Promise<boolean> {
const trimmed = adapterId.trim();
if (!trimmed) return false;
const settings = await this.getSettings();
const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters;
return Array.isArray(approved) && approved.includes(trimmed);
}
/** Record approval for elevated CLI-agent autonomy for an adapter. Idempotent.
* The approving principal in v1 is the daemon-token holder (route-level). */
async approveCliAutonomy(adapterId: string): Promise<void> {
const trimmed = adapterId.trim();
if (!trimmed) throw new Error("Adapter id is required");
const settings = await this.getSettings();
const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters ?? [];
if (approved.includes(trimmed)) return;
await this.updateSettings({
approvedCliAutonomyAdapters: [...approved, trimmed],
} as unknown as Partial<Settings>);
}
/** Revoke a previously-granted elevated-autonomy approval. Idempotent. */
async revokeCliAutonomy(adapterId: string): Promise<void> {
const trimmed = adapterId.trim();
if (!trimmed) return;
const settings = await this.getSettings();
const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters ?? [];
if (!approved.includes(trimmed)) return;
await this.updateSettings({
approvedCliAutonomyAdapters: approved.filter((a) => a !== trimmed),
} as unknown as Partial<Settings>);
}
/** List adapters approved for elevated autonomy in this project. */
async listApprovedCliAutonomyAdapters(): Promise<string[]> {
const settings = await this.getSettings();
const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters;
return Array.isArray(approved) ? [...approved] : [];
}
/** Read the workflow currently selected for a task, if any. */
/**
* Synchronously resolve the parsed WorkflowIr that governs a task's columns

View File

@@ -2942,6 +2942,39 @@ export interface GlobalSettings {
*
* Default: {} (empty object — no experimental features enabled). */
experimentalFeatures?: Record<string, boolean>;
/** Per-adapter CLI-agent launch configuration (CLI Agent Executor, U15).
* Keyed by adapter id (e.g. `"claude-code"`, `"codex"`, `"generic"`). Each
* entry carries operator overrides layered over the adapter's shipped
* defaults: a command override, extra args, an autonomy mode, and env
* allowlist additions. Validated + sanitized at the write boundary
* (`sanitizeCliAgentsSettings`); invalid entries/fields are dropped.
*
* Note: elevation expressed through ANY of these channels (autonomy mode,
* extra args, env additions, a non-default command override) is gated by a
* stored per-project approval at launch — see `@fusion/engine`'s
* `resolveEffectivePosture`. These settings only describe *intent*; the
* engine resolves and enforces posture. Default: {} (no overrides). */
cliAgents?: Record<string, CliAgentSettings>;
}
/** Operator launch config for one CLI-agent adapter (U15). Values are layered
* over the adapter's shipped defaults at launch. All fields optional; an empty
* object means "use shipped defaults". */
export interface CliAgentSettings {
/** Override for the binary path/name to invoke. A non-default value is treated
* as privileged (routes through the autonomy approval gate). */
commandOverride?: string;
/** Extra args appended after the adapter's computed base args. Free-form; the
* engine's elevation detector scans these for bypass markers. */
extraArgs?: string[];
/** Autonomy mode above the adapter baseline. `"default"` is the baseline (no
* elevation); `"elevated"` requests bypass-permissions-style autonomy and is
* gated. Kept as a string enum so adapters can map it to their own flags. */
autonomyMode?: "default" | "elevated";
/** Additional env var KEYS to forward from the parent process to the child.
* Names only (never values); the engine copies these from `process.env`.
* Service credentials (`FUSION_*`) are always excluded regardless. */
envAdditions?: string[];
}
export type RemoteAccessProvider = "tailscale" | "cloudflare";
@@ -3031,6 +3064,12 @@ export interface ProjectSettings {
* (trust-on-first-use). A node's command must appear here before it runs;
* named scripts (settings.scripts) never require approval. */
approvedWorkflowCliCommands?: string[];
/** CLI-agent adapter ids the project owner has approved for ELEVATED autonomy
* (CLI Agent Executor, U15). An adapter must appear here before a launch whose
* resolved posture is elevated (bypass-permissions-style) is permitted; an
* unapproved elevation fails the launch with a typed error. Approving
* principal in v1: the daemon-token holder (the single workspace owner). */
approvedCliAutonomyAdapters?: 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
@@ -3891,6 +3930,10 @@ export {
isProjectSettingsKey,
isMergeRequestContractShadowEnabled,
resolvePersistAgentThinkingLog,
sanitizeCliAgentSettings,
sanitizeCliAgentsSettings,
CLI_AGENT_ADAPTER_IDS,
CLI_AGENT_AUTONOMY_MODES,
} from "./settings-schema.js";
export interface BoardConfig {

View File

@@ -263,6 +263,7 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
{ id: "notifications", label: "Notifications", labelKey: "settings.nav.notifications", scope: "global" },
{ id: "node-sync", label: "Node Sync", labelKey: "settings.nav.nodeSync", scope: "global" },
{ id: "global-models", label: "Models", labelKey: "settings.nav.globalModels", scope: "global" },
{ id: "cli-agents", label: "CLI Agents", labelKey: "settings.nav.cliAgents", scope: "global" },
{ id: "research-global", label: "Research Defaults", labelKey: "settings.nav.researchGlobal", scope: "global" },
{ id: "experimental", label: "Experimental Features", labelKey: "settings.nav.experimental", scope: "global" },
{ id: "remote", label: "Remote Access", labelKey: "settings.nav.remote", scope: "global" },
@@ -422,6 +423,233 @@ interface SettingsModalProps {
onOpenApprovals?: (approvalId?: string) => void;
}
/** Adapter descriptor served by GET /api/cli-agents (U15). */
interface CliAdapterDescriptorView {
id: string;
name: string;
tier: "native" | "hybrid" | "generic";
defaultCommand: string | null;
}
interface CliAgentSettingsEntry {
commandOverride?: string;
extraArgs?: string[];
autonomyMode?: "default" | "elevated";
envAdditions?: string[];
}
/**
* Per-adapter CLI-agent launch settings section (U15). Reads the adapter catalog
* + persisted settings + per-project autonomy approval state, and lets the
* operator edit command override / extra args / env additions / autonomy mode.
* Switching an adapter to elevated autonomy goes through an explicit
* confirmation flow before the per-project approval is granted.
*/
function CliAgentsSettingsSection({
projectId,
addToast,
}: {
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
}) {
const { t } = useTranslation("app");
const { confirm } = useConfirm();
const [adapters, setAdapters] = useState<CliAdapterDescriptorView[]>([]);
const [settings, setSettings] = useState<Record<string, CliAgentSettingsEntry>>({});
const [approved, setApproved] = useState<Record<string, boolean>>({});
const [selectedId, setSelectedId] = useState<string>("");
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [catRes, setRes] = await Promise.all([
fetch("/api/cli-agents"),
fetch("/api/cli-agents/settings"),
]);
const cat = catRes.ok ? await catRes.json() : { adapters: [] };
const set = setRes.ok ? await setRes.json() : { cliAgents: {} };
if (cancelled) return;
const list = (cat.adapters ?? []) as CliAdapterDescriptorView[];
setAdapters(list);
setSettings((set.cliAgents ?? {}) as Record<string, CliAgentSettingsEntry>);
if (list.length > 0) setSelectedId((prev) => prev || list[0].id);
// Approval state is per-adapter; fetch lazily per selection below.
} catch {
// Non-fatal: render the static fallback list.
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!selectedId) return;
let cancelled = false;
fetch(`/api/cli-agents/${selectedId}/autonomy`)
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (cancelled || !data) return;
setApproved((prev) => ({ ...prev, [selectedId]: Boolean(data.approved) }));
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [selectedId]);
const current = settings[selectedId] ?? {};
const persist = useCallback(
async (adapterId: string, config: CliAgentSettingsEntry) => {
try {
const res = await fetch("/api/cli-agents/settings", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ adapterId, config }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setSettings((data.cliAgents ?? {}) as Record<string, CliAgentSettingsEntry>);
} catch (err) {
addToast(getErrorMessage(err) || t("settings.cliAgents.saveFailed"), "error");
}
},
[addToast, t],
);
const updateCurrent = useCallback(
(patch: Partial<CliAgentSettingsEntry>) => {
if (!selectedId) return;
const next = { ...current, ...patch };
setSettings((prev) => ({ ...prev, [selectedId]: next }));
void persist(selectedId, next);
},
[selectedId, current, persist],
);
const onAutonomyChange = useCallback(
async (mode: "default" | "elevated") => {
if (!selectedId) return;
if (mode === "elevated") {
const ok = await confirm({
title: t("settings.cliAgents.elevatedConfirmTitle"),
message: t("settings.cliAgents.elevatedConfirmBody"),
confirmLabel: t("settings.cliAgents.elevatedConfirmAction"),
danger: true,
});
if (!ok) return;
// Record the per-project approval first, then persist the mode.
try {
const res = await fetch(`/api/cli-agents/${selectedId}/approve-autonomy`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: true }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setApproved((prev) => ({ ...prev, [selectedId]: true }));
} catch (err) {
addToast(getErrorMessage(err) || t("settings.cliAgents.approveFailed"), "error");
return;
}
}
updateCurrent({ autonomyMode: mode });
},
[selectedId, confirm, t, addToast, updateCurrent],
);
return (
<div data-testid="cli-agents-settings">
<h4 className="settings-section-heading">{t("settings.cliAgents.heading")}</h4>
<p className="settings-section-description">{t("settings.cliAgents.description")}</p>
<div className="form-group">
<label htmlFor="cliAgentAdapter">{t("settings.cliAgents.adapterLabel")}</label>
<select
id="cliAgentAdapter"
value={selectedId}
onChange={(e) => setSelectedId(e.target.value)}
>
{adapters.map((a) => (
<option key={a.id} value={a.id}>
{a.name} ({t(`settings.cliAgents.tier.${a.tier}`)})
</option>
))}
</select>
</div>
{selectedId && (
<>
<div className="form-group">
<label htmlFor="cliAgentCommand">{t("settings.cliAgents.commandLabel")}</label>
<input
id="cliAgentCommand"
type="text"
placeholder={
adapters.find((a) => a.id === selectedId)?.defaultCommand ?? ""
}
value={current.commandOverride ?? ""}
onChange={(e) => updateCurrent({ commandOverride: e.target.value || undefined })}
/>
<p className="settings-field-help">{t("settings.cliAgents.commandHelp")}</p>
</div>
<div className="form-group">
<label htmlFor="cliAgentExtraArgs">{t("settings.cliAgents.extraArgsLabel")}</label>
<input
id="cliAgentExtraArgs"
type="text"
value={(current.extraArgs ?? []).join(" ")}
onChange={(e) =>
updateCurrent({
extraArgs: e.target.value.split(/\s+/).filter((s) => s.length > 0),
})
}
/>
<p className="settings-field-help">{t("settings.cliAgents.extraArgsHelp")}</p>
</div>
<div className="form-group">
<label htmlFor="cliAgentEnv">{t("settings.cliAgents.envLabel")}</label>
<input
id="cliAgentEnv"
type="text"
value={(current.envAdditions ?? []).join(", ")}
onChange={(e) =>
updateCurrent({
envAdditions: e.target.value
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0),
})
}
/>
<p className="settings-field-help">{t("settings.cliAgents.envHelp")}</p>
</div>
<div className="form-group">
<label htmlFor="cliAgentAutonomy">{t("settings.cliAgents.autonomyLabel")}</label>
<select
id="cliAgentAutonomy"
value={current.autonomyMode ?? "default"}
onChange={(e) => void onAutonomyChange(e.target.value as "default" | "elevated")}
>
<option value="default">{t("settings.cliAgents.autonomy.default")}</option>
<option value="elevated">{t("settings.cliAgents.autonomy.elevated")}</option>
</select>
<p className="settings-field-help">
{approved[selectedId]
? t("settings.cliAgents.approvedNote")
: t("settings.cliAgents.autonomyHelp")}
</p>
</div>
</>
)}
</div>
);
}
export function SettingsModal({
onClose,
addToast,
@@ -2328,6 +2556,13 @@ export function SettingsModal({
const renderSectionFields = () => {
switch (activeSection) {
case "cli-agents":
return (
<>
{renderScopeBanner()}
<CliAgentsSettingsSection projectId={projectId} addToast={addToast} />
</>
);
case "general":
return (
<>

View File

@@ -60,7 +60,23 @@ import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel";
import type { WorkflowFieldDefinition } from "../api";
import { CustomModelDropdown } from "./CustomModelDropdown";
type ExecutorKind = "model" | "agent" | "skill" | "cli";
type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
/** Adapter descriptor served by GET /api/cli-agents (U15). */
interface CliAdapterDescriptorView {
id: string;
name: string;
tier: "native" | "hybrid" | "generic";
}
/** Static fallback so the picker renders before/without the API fetch. */
const CLI_AGENT_ADAPTER_FALLBACK: CliAdapterDescriptorView[] = [
{ id: "claude-code", name: "Claude Code", tier: "native" },
{ id: "codex", name: "Codex", tier: "hybrid" },
{ id: "droid", name: "Droid", tier: "hybrid" },
{ id: "pi", name: "Pi", tier: "hybrid" },
{ id: "generic", name: "Generic CLI", tier: "generic" },
];
// Mirror of @fusion/core's isBuiltinWorkflowId / BUILTIN_WORKFLOW_ID_PREFIX.
// Inlined because the dashboard app build aliases "@fusion/core" to its
@@ -551,9 +567,29 @@ function InnerEditor({
const [models, setModels] = useState<ModelInfo[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [skills, setSkills] = useState<DiscoveredSkill[]>([]);
// CLI-agent adapter catalog (U15). Falls back to the static list when the API
// fetch fails so the picker is always usable.
const [cliAdapters, setCliAdapters] = useState<CliAdapterDescriptorView[]>(CLI_AGENT_ADAPTER_FALLBACK);
const currentExecutor = (selectedNode?.data.config?.executor as ExecutorKind | undefined) ?? "model";
useEffect(() => {
if (currentExecutor !== "cli-agent") return;
let cancelled = false;
fetch("/api/cli-agents")
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (cancelled || !data?.adapters) return;
setCliAdapters(data.adapters as CliAdapterDescriptorView[]);
})
.catch(() => {
// Keep the static fallback; the picker stays functional.
});
return () => {
cancelled = true;
};
}, [currentExecutor]);
useEffect(() => {
// step-review offers an optional review model picker (KTD-4).
if (selectedNode?.data.kind === "step-review" && models.length === 0) {
@@ -774,6 +810,7 @@ function InnerEditor({
<option value="agent">Agent</option>
<option value="skill">Skill</option>
<option value="cli">CLI / script</option>
<option value="cli-agent">{t("workflowEditor.cliAgent.executorOption")}</option>
</select>
</label>
@@ -871,6 +908,83 @@ function InnerEditor({
</>
)}
{currentExecutor === "cli-agent" && (
<div data-testid="cli-agent-config">
<label className="wf-field">
<span>{t("workflowEditor.cliAgent.adapterLabel")}</span>
<select
data-testid="cli-agent-adapter"
value={String(selectedNode.data.config?.cliAdapterId ?? "")}
onChange={(e) =>
updateSelectedData({ config: { cliAdapterId: e.target.value || undefined } })
}
>
<option value="">{t("workflowEditor.cliAgent.adapterPlaceholder")}</option>
{cliAdapters.map((a) => (
<option key={a.id} value={a.id}>
{a.name} ({t(`workflowEditor.cliAgent.tier.${a.tier}`)})
</option>
))}
</select>
<span className="wf-inspector-note">
{t("workflowEditor.cliAgent.adapterNote")}
</span>
</label>
<label className="wf-field wf-field--checkbox">
<input
type="checkbox"
data-testid="cli-agent-autonomy"
checked={Boolean(
(selectedNode.data.config?.cliAutonomy as { autoApprove?: boolean } | undefined)
?.autoApprove,
)}
onChange={(e) =>
updateSelectedData({
config: {
cliAutonomy: {
...((selectedNode.data.config?.cliAutonomy as Record<string, unknown>) ?? {}),
autoApprove: e.target.checked,
},
},
})
}
/>
<span>{t("workflowEditor.cliAgent.autonomyLabel")}</span>
</label>
{Boolean(
(selectedNode.data.config?.cliAutonomy as { autoApprove?: boolean } | undefined)
?.autoApprove,
) && (
<p className="wf-inspector-note wf-inspector-note--info">
{t("workflowEditor.cliAgent.autonomyNote")}
</p>
)}
<label className="wf-field">
<span>{t("workflowEditor.cliAgent.notifyLabel")}</span>
<select
data-testid="cli-agent-notify"
value={String(
(selectedNode.data.config?.cliNotify as { mode?: string } | undefined)?.mode ??
"banner",
)}
onChange={(e) =>
updateSelectedData({ config: { cliNotify: { mode: e.target.value } } })
}
>
<option value="banner">{t("workflowEditor.cliAgent.notify.banner")}</option>
<option value="banner+notify">
{t("workflowEditor.cliAgent.notify.bannerNotify")}
</option>
</select>
<span className="wf-inspector-note">
{t("workflowEditor.cliAgent.notifyNote")}
</span>
</label>
</div>
)}
<label className="wf-field wf-field--checkbox">
<input
type="checkbox"

View File

@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
import type { WorkflowDefinition } from "@fusion/core";
vi.mock("../../api", () => ({
fetchWorkflows: vi.fn(),
createWorkflow: vi.fn(),
updateWorkflow: vi.fn(),
deleteWorkflow: vi.fn(),
compileWorkflow: vi.fn(),
fetchTraits: vi.fn(),
fetchStepParsers: vi.fn(),
fetchModels: vi.fn(),
fetchAgents: vi.fn(),
fetchDiscoveredSkills: vi.fn(),
}));
import {
fetchWorkflows,
fetchTraits,
fetchStepParsers,
updateWorkflow,
fetchModels,
} from "../../api";
import type { TraitCatalogEntry } from "../../api";
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
const TRAIT_CATALOG: TraitCatalogEntry[] = [
{ id: "intake", name: "Intake", builtin: true, flags: { intake: true } },
{ id: "complete", name: "Complete", builtin: true, flags: { complete: true } },
];
function promptDef(): WorkflowDefinition {
return {
id: "WF-CLI",
name: "CLI",
description: "",
ir: {
version: "v2",
name: "CLI",
columns: [
{ id: "triage", name: "Triage", traits: [{ trait: "intake" }] },
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "triage" },
{ id: "step", kind: "prompt", column: "triage", config: { prompt: "do" } },
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "step", condition: "success" },
{ from: "step", to: "end", condition: "success" },
],
},
layout: {
start: { x: 0, y: 20 },
step: { x: 120, y: 60 },
end: { x: 360, y: 240 },
},
createdAt: "2026-06-03T00:00:00.000Z",
updatedAt: "2026-06-03T00:00:00.000Z",
};
}
describe("WorkflowNodeEditor — cli-agent executor (U15)", () => {
beforeEach(() => {
vi.mocked(fetchWorkflows).mockResolvedValue([promptDef()]);
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
vi.mocked(fetchStepParsers).mockResolvedValue([]);
vi.mocked(fetchModels).mockResolvedValue({ models: [] });
vi.mocked(updateWorkflow).mockResolvedValue(promptDef());
// Stub the adapter-catalog fetch.
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (typeof url === "string" && url.startsWith("/api/cli-agents")) {
return {
ok: true,
json: async () => ({
adapters: [
{ id: "claude-code", name: "Claude Code", tier: "native" },
{ id: "generic", name: "Generic CLI", tier: "generic" },
],
}),
} as Response;
}
return { ok: false, json: async () => ({}) } as Response;
}),
);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.unstubAllGlobals();
});
async function selectCliAgent() {
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
const node = await screen.findByTestId("wf-node-prompt");
fireEvent.click(node);
const executorSel = (await screen.findByText("Executor")).parentElement!.querySelector(
"select",
)! as HTMLSelectElement;
fireEvent.change(executorSel, { target: { value: "cli-agent" } });
return executorSel;
}
it("surfaces adapter + notification fields when cli-agent is selected", async () => {
await selectCliAgent();
expect(await screen.findByTestId("cli-agent-config")).toBeInTheDocument();
expect(screen.getByTestId("cli-agent-adapter")).toBeInTheDocument();
expect(screen.getByTestId("cli-agent-notify")).toBeInTheDocument();
expect(screen.getByTestId("cli-agent-autonomy")).toBeInTheDocument();
});
it("populates the adapter picker with tier labels from the API", async () => {
await selectCliAgent();
const adapterSel = (await screen.findByTestId("cli-agent-adapter")) as HTMLSelectElement;
await waitFor(() => {
expect(adapterSel.querySelectorAll("option").length).toBeGreaterThan(2);
});
const optionText = Array.from(adapterSel.querySelectorAll("option")).map((o) => o.textContent);
expect(optionText.some((t) => t?.includes("Claude Code") && t.includes("native"))).toBe(true);
expect(optionText.some((t) => t?.includes("Generic CLI") && t.includes("generic"))).toBe(true);
});
it("lands the selected adapter + notify config in the node config", async () => {
await selectCliAgent();
const adapterSel = (await screen.findByTestId("cli-agent-adapter")) as HTMLSelectElement;
fireEvent.change(adapterSel, { target: { value: "claude-code" } });
expect(adapterSel.value).toBe("claude-code");
const notifySel = screen.getByTestId("cli-agent-notify") as HTMLSelectElement;
fireEvent.change(notifySel, { target: { value: "banner+notify" } });
expect(notifySel.value).toBe("banner+notify");
// Save and assert the persisted IR carries the cli-agent node config.
fireEvent.click(screen.getByText("Save").closest("button")!);
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
const savedIr = vi.mocked(updateWorkflow).mock.calls.at(-1)![1] as {
ir: { nodes: Array<{ id: string; config?: Record<string, unknown> }> };
};
const stepNode = savedIr.ir.nodes.find((n) => n.id === "step")!;
expect(stepNode.config?.executor).toBe("cli-agent");
expect(stepNode.config?.cliAdapterId).toBe("claude-code");
expect(stepNode.config?.cliNotify).toEqual({ mode: "banner+notify" });
});
});

View File

@@ -173,6 +173,7 @@ import { registerFnBinaryRoutes } from "./routes/register-fn-binary-routes.js";
import { registerUpdateCheckRoutes } from "./routes/register-update-check-routes.js";
import { registerDiagnosticsRoutes } from "./routes/register-diagnostics-routes.js";
import { registerCliAgentHooksRoute } from "./routes/cli-agent-hooks.js";
import { registerCliAgentSettingsRoutes } from "./routes/cli-agent-settings.js";
import { registerIntegratedRouters, registerIntegratedDevServerRouter } from "./routes/register-integrated-routers.js";
import { registerApprovalRoutes } from "./routes/register-approval-routes.js";
import { registerWorktrunkRoutes } from "./routes/register-worktrunk-routes.js";
@@ -1957,6 +1958,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// the daemon bearer-token middleware (hook scripts only hold the session token).
registerCliAgentHooksRoute(routeContext);
// CLI Agent Executor adapter settings + autonomy approval (U15) — daemon-token
// authed like the rest of /api (the approving principal is the token holder).
registerCliAgentSettingsRoutes(routeContext);
// ── Automation / Scheduled Task Routes ────────────────────────────
//
// Scope-aware endpoints: Accept `scope=global|project` query param or body field.

View File

@@ -0,0 +1,162 @@
// @vitest-environment node
import express from "express";
import { Router } from "express";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { request as performRequest } from "../../test-request.js";
import { rethrowAsApiError } from "../../api-error.js";
import { registerCliAgentSettingsRoutes } from "../cli-agent-settings.js";
import type { ApiRoutesContext } from "../types.js";
/**
* Minimal fake TaskStore covering the methods the route touches. Global settings
* (`cliAgents`) and project autonomy approvals live in-memory; `getSettings`
* returns the merged view (global ∪ project) the route reads from.
*/
function makeFakeStore() {
const state = {
cliAgents: {} as Record<string, unknown>,
approvedCliAutonomyAdapters: [] as string[],
};
return {
state,
async getSettings() {
return {
cliAgents: state.cliAgents,
approvedCliAutonomyAdapters: [...state.approvedCliAutonomyAdapters],
};
},
async updateGlobalSettings(patch: { cliAgents?: Record<string, unknown> }) {
if (patch.cliAgents) state.cliAgents = patch.cliAgents;
return state;
},
async isCliAutonomyApproved(adapterId: string) {
return state.approvedCliAutonomyAdapters.includes(adapterId);
},
async approveCliAutonomy(adapterId: string) {
if (!state.approvedCliAutonomyAdapters.includes(adapterId)) {
state.approvedCliAutonomyAdapters.push(adapterId);
}
},
async revokeCliAutonomy(adapterId: string) {
state.approvedCliAutonomyAdapters = state.approvedCliAutonomyAdapters.filter(
(a) => a !== adapterId,
);
},
};
}
function mount(store: ReturnType<typeof makeFakeStore>) {
const router = Router();
router.use(express.json());
const ctx = {
router,
rethrowAsApiError,
getScopedStore: async () => store as never,
getProjectContext: async () => ({ store: store as never, engine: undefined, projectId: "p1" }),
} as unknown as ApiRoutesContext;
registerCliAgentSettingsRoutes(ctx);
const app = express();
app.use("/api", router);
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(err?.statusCode ?? err?.status ?? 500).json({ error: err?.message ?? String(err) });
});
return app;
}
const JSON_HEADERS = { "content-type": "application/json", host: "127.0.0.1" };
describe("cli-agent-settings routes (U15)", () => {
let store: ReturnType<typeof makeFakeStore>;
let app: express.Express;
beforeEach(() => {
store = makeFakeStore();
app = mount(store);
});
afterEach(() => {});
it("GET /api/cli-agents lists adapter descriptors with tier labels", async () => {
const res = await performRequest(app, "GET", "/api/cli-agents", undefined, JSON_HEADERS);
expect(res.status).toBe(200);
const ids = res.body.adapters.map((a: { id: string }) => a.id);
expect(ids).toContain("claude-code");
expect(ids).toContain("generic");
const claude = res.body.adapters.find((a: { id: string }) => a.id === "claude-code");
expect(claude.tier).toBe("native");
const generic = res.body.adapters.find((a: { id: string }) => a.id === "generic");
expect(generic.tier).toBe("generic");
});
it("PUT /api/cli-agents/settings persists a sanitized adapter config", async () => {
const res = await performRequest(
app,
"PUT",
"/api/cli-agents/settings",
JSON.stringify({
adapterId: "codex",
config: { extraArgs: ["--model=gpt"], autonomyMode: "garbage", bogus: 1 },
}),
JSON_HEADERS,
);
expect(res.status).toBe(200);
// autonomyMode "garbage" + bogus field dropped at the core write boundary.
expect(res.body.cliAgents).toEqual({ codex: { extraArgs: ["--model=gpt"] } });
expect(store.state.cliAgents).toEqual({ codex: { extraArgs: ["--model=gpt"] } });
});
it("PUT rejects an unknown adapter id", async () => {
const res = await performRequest(
app,
"PUT",
"/api/cli-agents/settings",
JSON.stringify({ adapterId: "evil", config: {} }),
JSON_HEADERS,
);
expect(res.status).toBe(400);
});
it("autonomy approval round-trip (approve requires confirm)", async () => {
// Initially unapproved.
let res = await performRequest(app, "GET", "/api/cli-agents/claude-code/autonomy", undefined, JSON_HEADERS);
expect(res.body).toEqual({ adapterId: "claude-code", approved: false });
// Approve without confirm → rejected.
res = await performRequest(
app,
"POST",
"/api/cli-agents/claude-code/approve-autonomy",
JSON.stringify({}),
JSON_HEADERS,
);
expect(res.status).toBe(400);
// Approve with confirm → granted.
res = await performRequest(
app,
"POST",
"/api/cli-agents/claude-code/approve-autonomy",
JSON.stringify({ confirm: true }),
JSON_HEADERS,
);
expect(res.status).toBe(200);
expect(store.state.approvedCliAutonomyAdapters).toContain("claude-code");
// Now reads as approved.
res = await performRequest(app, "GET", "/api/cli-agents/claude-code/autonomy", undefined, JSON_HEADERS);
expect(res.body.approved).toBe(true);
// Revoke.
res = await performRequest(
app,
"POST",
"/api/cli-agents/claude-code/revoke-autonomy",
JSON.stringify({}),
JSON_HEADERS,
);
expect(res.status).toBe(200);
expect(store.state.approvedCliAutonomyAdapters).not.toContain("claude-code");
});
});

View File

@@ -0,0 +1,131 @@
/**
* CLI-agent adapter settings + autonomy-approval routes (CLI Agent Executor, U15).
*
* All routes are daemon-token authed (the standard `/api` middleware — no new
* auth surface; the approving principal in v1 is the daemon-token holder, the
* single workspace owner). Routes:
*
* GET /api/cli-agents — adapter descriptors (tier +
* capability flags) for the
* settings UI + node editor.
* GET /api/cli-agents/settings — per-adapter launch config
* (GlobalSettings.cliAgents).
* PUT /api/cli-agents/settings — replace one adapter's launch
* config (validated at the core
* write boundary).
* GET /api/cli-agents/:adapterId/autonomy — approval state for the project.
* POST /api/cli-agents/:adapterId/approve-autonomy — approve elevated autonomy
* for the adapter in this
* project (idempotent).
* POST /api/cli-agents/:adapterId/revoke-autonomy — revoke approval.
*
* The approval is per-PROJECT + per-adapter, stored in project settings
* (`approvedCliAutonomyAdapters`) and mirrors the raw workflow-CLI-command
* approval precedent (`approveWorkflowCliCommand`).
*/
import { listCliAdapterDescriptors } from "@fusion/engine";
import { sanitizeCliAgentSettings, CLI_AGENT_ADAPTER_IDS } from "@fusion/core";
import { ApiError, badRequest } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js";
/** Static adapter descriptor list (tier + capability flags). Stable per build. */
const ADAPTER_DESCRIPTORS = listCliAdapterDescriptors();
const KNOWN_ADAPTER_IDS = new Set<string>(CLI_AGENT_ADAPTER_IDS);
export function registerCliAgentSettingsRoutes(ctx: ApiRoutesContext): void {
const { router, rethrowAsApiError } = ctx;
// GET /api/cli-agents — adapter catalog (tier labels + capability flags).
router.get("/cli-agents", async (_req, res) => {
res.json({ adapters: ADAPTER_DESCRIPTORS });
});
// GET /api/cli-agents/settings — the per-adapter launch config map.
router.get("/cli-agents/settings", async (req, res) => {
try {
const store = await ctx.getScopedStore(req);
const settings = await store.getSettings();
res.json({ cliAgents: (settings as { cliAgents?: unknown }).cliAgents ?? {} });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
// PUT /api/cli-agents/settings — replace ONE adapter's launch config. The body
// is `{ adapterId, config }`; an empty/invalid config clears the entry. The
// core write boundary sanitizes (`sanitizeCliAgentsSettings`) so invalid fields
// are dropped regardless — this route just scopes the merge to one adapter.
router.put("/cli-agents/settings", async (req, res) => {
try {
const store = await ctx.getScopedStore(req);
const adapterId = String((req.body as { adapterId?: unknown })?.adapterId ?? "").trim();
if (!adapterId || !KNOWN_ADAPTER_IDS.has(adapterId)) {
throw badRequest("Unknown or missing adapterId");
}
const rawConfig = (req.body as { config?: unknown })?.config;
const sanitized = sanitizeCliAgentSettings(rawConfig);
const settings = await store.getSettings();
const prior = { ...(((settings as { cliAgents?: Record<string, unknown> }).cliAgents) ?? {}) };
if (sanitized) {
prior[adapterId] = sanitized;
} else {
delete prior[adapterId];
}
await store.updateGlobalSettings({ cliAgents: prior } as never);
res.json({ cliAgents: prior });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
// GET /api/cli-agents/:adapterId/autonomy — approval state for this project.
router.get("/cli-agents/:adapterId/autonomy", async (req, res) => {
try {
const { store } = await ctx.getProjectContext(req);
const adapterId = req.params.adapterId;
if (!KNOWN_ADAPTER_IDS.has(adapterId)) throw badRequest("Unknown adapterId");
const approved = await store.isCliAutonomyApproved(adapterId);
res.json({ adapterId, approved });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
// POST /api/cli-agents/:adapterId/approve-autonomy — grant elevated autonomy
// for the adapter in this project. Idempotent. Requires an explicit confirm
// flag in the body so a stray POST cannot grant elevation by accident.
router.post("/cli-agents/:adapterId/approve-autonomy", async (req, res) => {
try {
const { store } = await ctx.getProjectContext(req);
const adapterId = req.params.adapterId;
if (!KNOWN_ADAPTER_IDS.has(adapterId)) throw badRequest("Unknown adapterId");
if ((req.body as { confirm?: unknown })?.confirm !== true) {
throw badRequest("Elevated autonomy approval requires explicit confirmation (confirm: true)");
}
await store.approveCliAutonomy(adapterId);
res.json({ adapterId, approved: true });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
// POST /api/cli-agents/:adapterId/revoke-autonomy — revoke approval. Idempotent.
router.post("/cli-agents/:adapterId/revoke-autonomy", async (req, res) => {
try {
const { store } = await ctx.getProjectContext(req);
const adapterId = req.params.adapterId;
if (!KNOWN_ADAPTER_IDS.has(adapterId)) throw badRequest("Unknown adapterId");
await store.revokeCliAutonomy(adapterId);
res.json({ adapterId, approved: false });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
}

View File

@@ -0,0 +1,182 @@
/**
* Autonomy elevation detection + approval gate (U15).
*
* Central invariant under test: the posture chip / gate derive from the FULLY
* RESOLVED argv + env, so elevation smuggled through ANY channel (autonomy
* field, extra args, env additions, command override) trips the gate — not just
* the autonomy field.
*/
import { describe, expect, it } from "vitest";
import {
resolveEffectivePosture,
assertAutonomyApproved,
CliAutonomyNotApprovedError,
type CliAgentResolveSettings,
} from "../autonomy.js";
import { claudeCodeAdapter } from "../adapters/claude-code.js";
import { codexAdapter } from "../adapters/codex.js";
import { genericCliAdapter } from "../adapters/generic.js";
describe("resolveEffectivePosture", () => {
it("returns default (no elevation) for a baseline launch", () => {
const posture = resolveEffectivePosture({ adapter: claudeCodeAdapter });
expect(posture.elevated).toBe(false);
expect(posture.mode).toBe("default");
expect(posture.flags).toEqual([]);
expect(posture.adapterId).toBe("claude-code");
});
it("detects elevation from the autonomy field (autoApprove)", () => {
const posture = resolveEffectivePosture({
adapter: claudeCodeAdapter,
nodeConfig: { cliAutonomy: { autoApprove: true } },
});
expect(posture.elevated).toBe(true);
// Claude's buildLaunch emits --dangerously-skip-permissions for autoApprove,
// so it surfaces as an argv-channel marker (not merely the autonomy field).
expect(posture.flags.some((f) => f.channel === "args")).toBe(true);
expect(
posture.flags.some((f) => f.marker === "--dangerously-skip-permissions"),
).toBe(true);
});
it("BYPASS CLOSURE: --dangerously-skip-permissions via extraArgs (not the field) trips elevation", () => {
const settings: CliAgentResolveSettings = {
extraArgs: ["--dangerously-skip-permissions"],
};
const posture = resolveEffectivePosture({ adapter: claudeCodeAdapter, settings });
expect(posture.elevated).toBe(true);
expect(
posture.flags.some(
(f) => f.channel === "args" && f.marker === "--dangerously-skip-permissions",
),
).toBe(true);
});
it("detects codex -c approval_policy override smuggled via extraArgs", () => {
const posture = resolveEffectivePosture({
adapter: codexAdapter,
settings: { extraArgs: ["-c", "approval_policy=never"] },
});
expect(posture.elevated).toBe(true);
expect(posture.flags.some((f) => f.marker.includes("approval_policy"))).toBe(true);
});
it("detects autonomy-toggling env var via envAdditions", () => {
const posture = resolveEffectivePosture({
adapter: claudeCodeAdapter,
settings: { envAdditions: ["SOME_TOOL_SKIP_PERMISSIONS"] },
});
expect(posture.elevated).toBe(true);
expect(
posture.flags.some(
(f) => f.channel === "env" && f.marker === "SOME_TOOL_SKIP_PERMISSIONS",
),
).toBe(true);
});
it("does NOT flag a benign env addition", () => {
const posture = resolveEffectivePosture({
adapter: claudeCodeAdapter,
settings: { envAdditions: ["HTTP_PROXY", "NO_COLOR"] },
});
expect(posture.elevated).toBe(false);
});
it("treats a non-default command override as privileged", () => {
const posture = resolveEffectivePosture({
adapter: claudeCodeAdapter,
settings: { commandOverride: "/tmp/evil-claude" },
});
expect(posture.elevated).toBe(true);
expect(
posture.flags.some((f) => f.channel === "command" && f.marker === "/tmp/evil-claude"),
).toBe(true);
});
it("does NOT flag a command override equal to the adapter default", () => {
const posture = resolveEffectivePosture({
adapter: claudeCodeAdapter,
settings: { commandOverride: claudeCodeAdapter.defaultCommand },
});
expect(posture.elevated).toBe(false);
});
it("maps autonomyMode:elevated onto the posture even with no field", () => {
const posture = resolveEffectivePosture({
adapter: claudeCodeAdapter,
settings: { autonomyMode: "elevated" },
});
expect(posture.elevated).toBe(true);
});
it("flags generic-tier bypass args (heuristic patterns)", () => {
const posture = resolveEffectivePosture({
adapter: genericCliAdapter,
settings: { commandOverride: "mytool", extraArgs: ["--auto-approve-everything"] },
});
expect(posture.elevated).toBe(true);
});
});
describe("assertAutonomyApproved (gate)", () => {
const elevated: CliAgentResolveSettings = {
extraArgs: ["--dangerously-skip-permissions"],
};
it("permits a non-elevated launch without consulting approval", async () => {
let consulted = false;
const posture = await assertAutonomyApproved({
adapter: claudeCodeAdapter,
projectId: "p1",
isApproved: () => {
consulted = true;
return false;
},
});
expect(posture.elevated).toBe(false);
expect(consulted).toBe(false);
});
it("throws a typed error when elevated and unapproved", async () => {
await expect(
assertAutonomyApproved({
adapter: claudeCodeAdapter,
settings: elevated,
projectId: "p1",
isApproved: () => false,
}),
).rejects.toBeInstanceOf(CliAutonomyNotApprovedError);
});
it("includes the offending flags + scope on the error", async () => {
let err: unknown;
try {
await assertAutonomyApproved({
adapter: claudeCodeAdapter,
settings: elevated,
projectId: "proj-x",
isApproved: () => false,
});
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(CliAutonomyNotApprovedError);
const typed = err as CliAutonomyNotApprovedError;
expect(typed.code).toBe("CLI_AUTONOMY_NOT_APPROVED");
expect(typed.projectId).toBe("proj-x");
expect(typed.adapterId).toBe("claude-code");
expect(typed.flags.length).toBeGreaterThan(0);
});
it("permits an elevated launch once the project has approved", async () => {
const posture = await assertAutonomyApproved({
adapter: claudeCodeAdapter,
settings: elevated,
projectId: "p1",
isApproved: async ({ projectId, adapterId }) =>
projectId === "p1" && adapterId === "claude-code",
});
expect(posture.elevated).toBe(true);
});
});

View File

@@ -129,6 +129,33 @@ export type CliTelemetryWiring = (ctx: {
// ── The adapter interface ─────────────────────────────────────────────────
/**
* Per-adapter declaration of the argument markers that signify *elevated*
* (bypass-permissions / full-auto) autonomy (CLI Agent Executor, U15). The
* autonomy elevation detector scans the FULLY RESOLVED argv for these so an
* elevation smuggled through extra-args (not the autonomy field) is still
* caught. Generic env-pattern detection is shared across all adapters and lives
* in `autonomy.ts`; this only declares the adapter-specific argv side.
*/
export interface CliAdapterElevationMarkers {
/**
* Exact-match argv tokens that always denote elevation (e.g.
* `--dangerously-skip-permissions`).
*/
readonly exactArgs?: readonly string[];
/**
* Regexes tested against each resolved argv token (e.g. Codex's
* `-c approval_policy=...` override, droid `--auto high`). A match denotes
* elevation. Authors keep these conservative — false positives gate launches.
*/
readonly argPatterns?: readonly RegExp[];
/**
* Optional predicate over the whole resolved argv for multi-token markers
* (e.g. `--auto` followed by `high`). Returns the matched token(s) to report.
*/
readonly matchArgv?: (argv: readonly string[]) => string[];
}
export interface CliAgentAdapter {
/** Stable identifier (e.g. "claude-code", "codex", "generic"). */
readonly id: string;
@@ -136,6 +163,17 @@ export interface CliAgentAdapter {
readonly name: string;
/** Capability flags — read honestly by the pipeline and UI. */
readonly capabilities: CliAdapterCapabilities;
/**
* Default binary the adapter invokes when no command override is set (U15).
* Surfaced so the elevation detector can treat a *different* command override
* as privileged without reaching into the adapter's private constants.
*/
readonly defaultCommand?: string;
/**
* Adapter-specific elevated-autonomy argv markers (U15). When omitted the
* detector relies on the shared generic env-pattern set only.
*/
readonly elevationMarkers?: CliAdapterElevationMarkers;
/** Build the launch command/args from settings + autonomy posture. */
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec;

View File

@@ -539,6 +539,22 @@ export const claudeCodeAdapter: CliAgentAdapter = {
id: "claude-code",
name: "Claude Code",
capabilities: CLAUDE_CODE_CAPABILITIES,
defaultCommand: DEFAULT_COMMAND,
elevationMarkers: {
// Claude Code bypass: `--dangerously-skip-permissions` and the
// `--permission-mode bypassPermissions` form.
exactArgs: ["--dangerously-skip-permissions"],
argPatterns: [/^--permission-mode(=|$)/, /bypassPermissions/i],
matchArgv(argv) {
const hits: string[] = [];
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--permission-mode" && argv[i + 1] === "bypassPermissions") {
hits.push("--permission-mode bypassPermissions");
}
}
return hits;
},
},
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec {
const settings = readSettings(ctx);

View File

@@ -579,6 +579,35 @@ export const codexAdapter: CliAgentAdapter = {
id: "codex",
name: "Codex",
capabilities: CODEX_CAPABILITIES,
defaultCommand: DEFAULT_COMMAND,
elevationMarkers: {
// Codex elevation: the sandbox/approval bypass flag, `--full-auto`/`--yolo`
// shorthands, and `-c approval_policy=...` / `-c sandbox=...` config overrides.
exactArgs: [
"--dangerously-bypass-approvals-and-sandbox",
"--full-auto",
"--yolo",
],
argPatterns: [
/^-c\s*approval_policy=/i,
/^approval_policy=/i,
/^-c\s*sandbox(_mode)?=/i,
/^sandbox(_mode)?=/i,
],
matchArgv(argv) {
const hits: string[] = [];
for (let i = 0; i < argv.length; i++) {
// `-c approval_policy=...` (or sandbox=...) passed as two tokens.
if (argv[i] === "-c" && typeof argv[i + 1] === "string") {
const v = argv[i + 1];
if (/^(approval_policy|sandbox|sandbox_mode)=/i.test(v)) {
hits.push(`-c ${v}`);
}
}
}
return hits;
},
},
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec {
const settings = readSettings(ctx);

View File

@@ -471,6 +471,23 @@ export const droidAdapter: CliAgentAdapter = {
id: "droid",
name: "Droid",
capabilities: DROID_CAPABILITIES,
defaultCommand: DEFAULT_COMMAND,
elevationMarkers: {
// Droid elevation: `--skip-permissions-unsafe` and `--auto <high|...>` full
// autonomy levels (per `droid --help`). `--auto low` is NOT treated as
// elevation; only `high`/`medium` levels bypass meaningful approvals.
exactArgs: ["--skip-permissions-unsafe"],
argPatterns: [/^--auto=(high|medium)$/i],
matchArgv(argv) {
const hits: string[] = [];
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--auto" && /^(high|medium)$/i.test(argv[i + 1] ?? "")) {
hits.push(`--auto ${argv[i + 1]}`);
}
}
return hits;
},
},
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec {
const settings = readSettings(ctx);

View File

@@ -347,6 +347,19 @@ export class GenericCliAdapter implements CliAgentAdapter {
readonly id = "generic";
readonly name = "Generic CLI";
readonly capabilities = GENERIC_CAPABILITIES;
// The generic tier has no native autonomy concept, but common bypass flags
// smuggled through args/extraArgs are still caught so the posture chip is
// honest. The shared generic env-pattern detector applies on top of this.
readonly elevationMarkers = {
argPatterns: [
/dangerous/i,
/skip[-_]permissions?/i,
/bypass[-_](approvals?|permissions?|sandbox)/i,
/^--yolo$/i,
/^--full-auto$/i,
/auto[-_]approve/i,
],
};
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec {
const settings = ctx.settings as GenericAdapterSettings & { command?: string };

View File

@@ -0,0 +1,58 @@
/**
* Bundled CLI-agent adapters barrel (U15).
*
* Re-exports the shipped adapters and a small static capability/tier descriptor
* list surfaces (settings UI + node editor) read to render adapter pickers with
* honest tier labels — without each surface reaching into per-adapter modules.
*/
import { tierForCapabilities, type CliAdapterTier } from "../autonomy.js";
import type { CliAgentAdapter } from "../adapter.js";
import { claudeCodeAdapter } from "./claude-code.js";
import { codexAdapter } from "./codex.js";
import { droidAdapter } from "./droid.js";
import { piAdapter } from "./pi.js";
import { genericCliAdapter } from "./generic.js";
export { claudeCodeAdapter, codexAdapter, droidAdapter, piAdapter, genericCliAdapter };
/** All shipped adapters in display order (native → hybrid → generic). */
export const BUNDLED_CLI_ADAPTERS: readonly CliAgentAdapter[] = Object.freeze([
claudeCodeAdapter,
codexAdapter,
droidAdapter,
piAdapter,
genericCliAdapter,
]);
/** A UI-facing descriptor for one adapter: id, name, tier, and capability flags. */
export interface CliAdapterDescriptor {
id: string;
name: string;
tier: CliAdapterTier;
defaultCommand: string | null;
capabilities: {
nativeDone: boolean;
nativeWaiting: boolean;
transcriptSource: string;
supportsResume: boolean;
};
}
/** Build the descriptor list the dashboard serves to settings/node-editor UIs. */
export function listCliAdapterDescriptors(
adapters: readonly CliAgentAdapter[] = BUNDLED_CLI_ADAPTERS,
): CliAdapterDescriptor[] {
return adapters.map((a) => ({
id: a.id,
name: a.name,
tier: tierForCapabilities(a.capabilities),
defaultCommand: a.defaultCommand ?? null,
capabilities: {
nativeDone: a.capabilities.nativeDone,
nativeWaiting: a.capabilities.nativeWaiting,
transcriptSource: a.capabilities.transcriptSource,
supportsResume: a.capabilities.supportsResume,
},
}));
}

View File

@@ -400,6 +400,24 @@ export const piAdapter: CliAgentAdapter = {
id: "pi",
name: "Pi",
capabilities: PI_CAPABILITIES,
defaultCommand: DEFAULT_COMMAND,
elevationMarkers: {
// Pi elevation: widening the tool allowlist to include write-capable tools
// without per-tool confirmation (`--tools read,bash,edit,write`) or a yolo /
// no-confirm flag. The `--tools` form with bash/edit/write is the auto-approve
// equivalent the posture maps to.
exactArgs: ["--yolo", "--no-confirm", "--dangerously-skip-permissions"],
argPatterns: [/^--tools=.*\b(bash|edit|write)\b/i],
matchArgv(argv) {
const hits: string[] = [];
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--tools" && /\b(bash|edit|write)\b/i.test(argv[i + 1] ?? "")) {
hits.push(`--tools ${argv[i + 1]}`);
}
}
return hits;
},
},
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec {
const settings = readSettings(ctx);

View File

@@ -0,0 +1,316 @@
/**
* CLI-agent autonomy posture resolution + elevation approval gate (U15).
*
* The autonomy approval gate's central invariant: **elevation expressed through
* ANY channel must be caught.** Operators can request bypass-permissions-style
* autonomy through four distinct settings channels:
*
* 1. the autonomy field (`nodeConfig.cliAutonomy.autoApprove`, or the
* settings `autonomyMode: "elevated"`),
* 2. extra args (`--dangerously-skip-permissions` smuggled in),
* 3. env additions (`*_DANGEROUS*` / `*_SKIP_PERMISSIONS*` vars),
* 4. a command override (pointing the binary at an arbitrary path).
*
* A posture chip derived from the autonomy *field alone* would be false-safe
* when elevation rides one of the other channels. So `resolveEffectivePosture`
* derives the posture from the **fully resolved argv + env** — it asks the
* adapter to build the actual launch invocation (which already folds in the
* posture flags, extra args, and command override), then scans that argv plus
* the env additions against the adapter's declared elevation markers and a
* shared generic env-pattern set. A non-default command override is itself
* privileged.
*
* Any resolved elevation requires a stored per-project approval (mirroring the
* raw workflow-CLI-command approval precedent). `assertAutonomyApproved` throws
* a typed `CliAutonomyNotApprovedError` when elevation is present but no
* approval is recorded for the project — the caller surfaces this as a launch
* failure, never a stall.
*
* Pure engine policy: no HTTP, no DB. The approval lookup is injected as a
* small async predicate so the dashboard can back it with the project store.
*/
import type { CliAutonomyPosture } from "@fusion/core";
import type {
CliAgentAdapter,
CliAdapterLaunchSettings,
} from "./adapter.js";
// ── Adapter capability tiers (U15 node-editor labels) ───────────────────────
/** UI tier label derived honestly from an adapter's capability flags. */
export type CliAdapterTier = "native" | "hybrid" | "generic";
/**
* Derive the tier label the node editor renders (native / hybrid / generic) from
* an adapter's honest capability flags:
* - native: a native done signal AND a structured transcript (hooks/jsonl/…).
* - hybrid: SOME native signal (done or waiting) but a weaker transcript story.
* - generic: no native signals at all (the heuristic tier).
*/
export function tierForCapabilities(caps: {
nativeDone: boolean;
nativeWaiting: boolean;
transcriptSource: string;
}): CliAdapterTier {
if (caps.nativeDone && caps.transcriptSource !== "none") return "native";
if (caps.nativeDone || caps.nativeWaiting) return "hybrid";
return "generic";
}
// ── Settings shape (mirrors @fusion/core CliAgentSettings) ──────────────────
/** Per-adapter operator launch settings consumed by posture resolution. */
export interface CliAgentResolveSettings {
commandOverride?: string;
extraArgs?: readonly string[];
autonomyMode?: "default" | "elevated";
envAdditions?: readonly string[];
}
/** The cli-agent slice of a workflow node's config relevant to posture. */
export interface CliAgentNodeConfig {
cliAutonomy?: CliAutonomyPosture | null;
}
// ── Effective posture ───────────────────────────────────────────────────────
/** A single resolved elevation marker, for chip rendering + audit. */
export interface CliElevationFlag {
/** Which settings channel surfaced the elevation. */
channel: "autonomy" | "args" | "env" | "command";
/** The concrete marker (argv token, env var name, or command path). */
marker: string;
}
/**
* The effective autonomy posture for a launch, derived from the resolved argv +
* env (NOT the autonomy field alone). This is what the posture chip renders and
* what is denormalized onto the session record at spawn.
*/
export interface EffectivePosture {
/** Adapter the posture was resolved against. */
adapterId: string;
/** Coarse mode: `elevated` iff any elevation marker was detected. */
mode: "default" | "elevated";
/** Whether the resolved invocation is elevated through any channel. */
elevated: boolean;
/** Every detected elevation marker (across all channels). */
flags: CliElevationFlag[];
}
// ── Generic env-pattern detection (shared across all adapters) ──────────────
/**
* Env-var name patterns that toggle autonomy/permission bypass for SOME CLI.
* Applied to every adapter's env additions regardless of adapter-declared
* markers — a bypass-toggling env var is elevation no matter which CLI reads it.
*/
export const GENERIC_ELEVATION_ENV_PATTERNS: readonly RegExp[] = Object.freeze([
/_DANGEROUS/i,
/DANGEROUS_/i,
/SKIP_PERMISSIONS?/i,
/BYPASS_(APPROVALS?|PERMISSIONS?|SANDBOX)/i,
/AUTO_APPROVE/i,
/YOLO/i,
/FULL_AUTO/i,
]);
function envIsElevating(name: string): boolean {
return GENERIC_ELEVATION_ENV_PATTERNS.some((re) => re.test(name));
}
// ── Posture resolution ──────────────────────────────────────────────────────
export interface ResolveEffectivePostureArgs {
/** The adapter the session will be driven by. */
adapter: CliAgentAdapter;
/** Per-adapter operator settings (from GlobalSettings.cliAgents). */
settings?: CliAgentResolveSettings | null;
/** The cli-agent node config (carries the autonomy field). */
nodeConfig?: CliAgentNodeConfig | null;
}
/**
* Build the adapter launch settings the manager would use, folding the operator
* settings + the autonomy field into the shape `buildLaunch` consumes. Keeping
* this here (not in the manager) lets posture resolution scan the EXACT argv the
* child would receive without spawning.
*/
function buildLaunchSettings(
settings: CliAgentResolveSettings | null | undefined,
): CliAdapterLaunchSettings {
const out: CliAdapterLaunchSettings = {};
if (settings?.commandOverride) out.command = settings.commandOverride;
if (settings?.extraArgs && settings.extraArgs.length > 0) {
out.extraArgs = [...settings.extraArgs];
}
return out;
}
/**
* Map the resolved autonomy intent onto the posture the adapter's `buildLaunch`
* keys off (it reads `posture.autoApprove`). Elevation intent comes from EITHER
* the node autonomy field OR `autonomyMode: "elevated"`.
*/
function resolveIntentPosture(
settings: CliAgentResolveSettings | null | undefined,
nodeConfig: CliAgentNodeConfig | null | undefined,
): { posture: CliAutonomyPosture | null; fromField: boolean } {
const fieldAutoApprove = nodeConfig?.cliAutonomy?.autoApprove === true;
const modeElevated = settings?.autonomyMode === "elevated";
const autoApprove = fieldAutoApprove || modeElevated;
const base = nodeConfig?.cliAutonomy ?? null;
if (!autoApprove) {
return { posture: base, fromField: false };
}
return { posture: { ...(base ?? {}), autoApprove: true }, fromField: true };
}
/** Whether a token matches the adapter's declared argv elevation markers. */
function argvElevationHits(
adapter: CliAgentAdapter,
argv: readonly string[],
): string[] {
const markers = adapter.elevationMarkers;
if (!markers) return [];
const hits = new Set<string>();
const exact = new Set(markers.exactArgs ?? []);
for (const tok of argv) {
if (exact.has(tok)) hits.add(tok);
for (const re of markers.argPatterns ?? []) {
if (re.test(tok)) {
hits.add(tok);
break;
}
}
}
for (const m of markers.matchArgv?.(argv) ?? []) hits.add(m);
return [...hits];
}
/**
* Resolve the effective autonomy posture from the fully resolved argv + env.
* Pure; never throws. The gate decision is made by `assertAutonomyApproved`.
*/
export function resolveEffectivePosture(
args: ResolveEffectivePostureArgs,
): EffectivePosture {
const { adapter } = args;
const settings = args.settings ?? null;
const nodeConfig = args.nodeConfig ?? null;
const { posture } = resolveIntentPosture(settings, nodeConfig);
const launchSettings = buildLaunchSettings(settings);
// Build the EXACT argv the child would receive (folds posture flags + extra
// args + command override). A buildLaunch failure (e.g. generic with no
// command) degrades to scanning the operator-supplied channels directly.
let resolvedArgv: string[] = [];
let resolvedCommand: string | undefined;
try {
const spec = adapter.buildLaunch({ settings: launchSettings, posture });
resolvedArgv = spec.args;
resolvedCommand = spec.command;
} catch {
resolvedArgv = [...(settings?.extraArgs ?? [])];
resolvedCommand = settings?.commandOverride;
}
const flags: CliElevationFlag[] = [];
// Channel: argv (covers the autonomy field AND extra args — both land in argv).
for (const marker of argvElevationHits(adapter, resolvedArgv)) {
flags.push({ channel: "args", marker });
}
// Channel: command override to a non-default path is privileged.
if (
typeof resolvedCommand === "string" &&
settings?.commandOverride &&
typeof adapter.defaultCommand === "string" &&
resolvedCommand !== adapter.defaultCommand
) {
flags.push({ channel: "command", marker: resolvedCommand });
}
// Channel: env additions that toggle autonomy/bypass.
for (const name of settings?.envAdditions ?? []) {
if (envIsElevating(name)) flags.push({ channel: "env", marker: name });
}
// If elevation was requested ONLY via the field but the adapter emitted no
// recognizable argv marker (e.g. an adapter that elevates with no flag), still
// record the intent so the gate is never bypassed by an unmarked adapter.
const intentElevated =
nodeConfig?.cliAutonomy?.autoApprove === true ||
settings?.autonomyMode === "elevated";
if (intentElevated && flags.length === 0) {
flags.push({ channel: "autonomy", marker: "autoApprove" });
}
const elevated = flags.length > 0;
return {
adapterId: adapter.id,
mode: elevated ? "elevated" : "default",
elevated,
flags,
};
}
// ── Approval gate ────────────────────────────────────────────────────────────
/** Typed launch error: elevation requested without a stored project approval. */
export class CliAutonomyNotApprovedError extends Error {
readonly code = "CLI_AUTONOMY_NOT_APPROVED";
constructor(
public readonly adapterId: string,
public readonly projectId: string,
public readonly flags: CliElevationFlag[],
) {
const markers = flags.map((f) => `${f.channel}:${f.marker}`).join(", ");
super(
`Elevated CLI autonomy for adapter "${adapterId}" requires approval for ` +
`project "${projectId}" before launch (unapproved elevation: ${markers})`,
);
this.name = "CliAutonomyNotApprovedError";
}
}
/** Predicate the dashboard backs with the project store's approval list. */
export type AutonomyApprovalLookup = (args: {
projectId: string;
adapterId: string;
}) => boolean | Promise<boolean>;
export interface AssertAutonomyApprovedArgs extends ResolveEffectivePostureArgs {
/** Project the launch belongs to (approvals are per-project). */
projectId: string;
/** Whether the project has approved elevated autonomy for this adapter. */
isApproved: AutonomyApprovalLookup;
}
/**
* Resolve the effective posture and enforce the approval gate. Returns the
* resolved posture (to denormalize onto the session record) when the launch is
* permitted. Throws `CliAutonomyNotApprovedError` when the resolved posture is
* elevated and no per-project approval is recorded.
*/
export async function assertAutonomyApproved(
args: AssertAutonomyApprovedArgs,
): Promise<EffectivePosture> {
const posture = resolveEffectivePosture(args);
if (!posture.elevated) return posture;
const approved = await args.isApproved({
projectId: args.projectId,
adapterId: args.adapter.id,
});
if (!approved) {
throw new CliAutonomyNotApprovedError(
args.adapter.id,
args.projectId,
posture.flags,
);
}
return posture;
}

View File

@@ -58,6 +58,12 @@ import {
writeSessionHookScripts,
cleanupSessionHookDir,
} from "./hook-scripts.js";
import {
assertAutonomyApproved,
type AutonomyApprovalLookup,
type CliAgentResolveSettings,
type EffectivePosture,
} from "./autonomy.js";
// ── Outcome ──────────────────────────────────────────────────────────────────
@@ -108,6 +114,12 @@ export interface ResolvedCliExecutorConfig {
cliNotify?: Record<string, unknown> | null;
/** Adapter launch settings (model, command override, extra args, …). */
settings?: Record<string, unknown>;
/**
* Per-adapter operator launch config (U15), resolved from
* `GlobalSettings.cliAgents[adapterId]`. Drives elevation detection + the
* approval gate, and is folded into the adapter launch settings at spawn.
*/
cliAgentSettings?: CliAgentResolveSettings | null;
}
// ── Launch options ─────────────────────────────────────────────────────────────
@@ -140,6 +152,13 @@ export interface LaunchCliTaskSessionOptions {
* dir; production callers may scope it under the engine's runtime dir.
*/
hookDirRoot?: string;
/**
* Per-project autonomy-approval lookup (U15). Backs the elevation approval
* gate: when the resolved effective posture is elevated and this returns false,
* launch fails with a typed `CliAutonomyNotApprovedError` (never a stall).
* When omitted, an elevated posture fails closed (treated as unapproved).
*/
isAutonomyApproved?: AutonomyApprovalLookup;
/**
* Optional logger for lifecycle breadcrumbs. Best-effort; never throws.
*/
@@ -205,6 +224,19 @@ export class CliTaskSession {
const log = opts.log ?? (() => {});
const adapter = opts.registry.get(opts.config.cliAdapterId);
// 0. Autonomy approval gate (U15). Resolve the EFFECTIVE posture from the
// fully resolved argv + env (NOT the autonomy field alone) and enforce the
// per-project approval for any elevation. A missing lookup fails closed.
// Runs BEFORE any side effects (scratch dir / spawn) so an unapproved
// elevation never reserves a concurrency slot or leaves a scratch dir.
const effectivePosture: EffectivePosture = await assertAutonomyApproved({
adapter,
settings: opts.config.cliAgentSettings ?? null,
nodeConfig: { cliAutonomy: opts.config.cliAutonomy ?? null },
projectId: opts.projectId,
isApproved: opts.isAutonomyApproved ?? (() => false),
});
// 1. Scratch dir for the session-scoped hook scripts + settings.
const root = opts.hookDirRoot ?? tmpdir();
const hookDir = await mkdtemp(join(root, "fusion-cli-hooks-"));
@@ -219,11 +251,33 @@ export class CliTaskSession {
const hookScriptPath = join(hookDir, HOOK_SCRIPT_NAMES.hook);
const settingsPath = join(hookDir, "settings.json");
// Fold the per-adapter operator settings (U15) into the launch settings bag
// so they actually reach the child: command override → `command`, extra args
// → `extraArgs`, env additions → `envAllowlist`. Service credentials are
// ALWAYS excluded from the env allowlist regardless of what the operator
// added (a user must never widen the allowlist to leak FUSION_* creds).
const cliAgentSettings = opts.config.cliAgentSettings ?? null;
const operatorEnvAdditions = (cliAgentSettings?.envAdditions ?? []).filter(
(k) => !/^FUSION_/i.test(k),
);
const priorAllowlist = Array.isArray(
(opts.config.settings as Record<string, unknown> | undefined)?.envAllowlist,
)
? ((opts.config.settings as Record<string, unknown>).envAllowlist as string[])
: [];
// Build adapter launch settings carrying the hook-script refs. Claude's
// settings flow reads `hookScripts` + `settingsPath` off ctx.settings; other
// adapters ignore unknown keys.
const settings: Record<string, unknown> = {
...(opts.config.settings ?? {}),
...(cliAgentSettings?.commandOverride
? { command: cliAgentSettings.commandOverride }
: {}),
...(cliAgentSettings?.extraArgs && cliAgentSettings.extraArgs.length > 0
? { extraArgs: [...cliAgentSettings.extraArgs] }
: {}),
envAllowlist: [...new Set([...priorAllowlist, ...operatorEnvAdditions])],
hookScripts: {
stopScript: hookScriptPath,
notificationScript: hookScriptPath,
@@ -244,7 +298,24 @@ export class CliTaskSession {
purpose: "execute",
taskId: opts.taskId,
worktreePath: opts.worktreePath,
posture: opts.config.cliAutonomy ?? null,
// Denormalize the EFFECTIVE posture (derived from resolved argv+env) onto
// the session record so the posture chip reflects the real launch
// posture, not the autonomy field alone. The autonomy intent fields are
// preserved (buildLaunch still reads `autoApprove`).
posture: {
...(opts.config.cliAutonomy ?? {}),
// `autonomyMode: "elevated"` is an alternate channel to the field; map
// it onto `autoApprove` so the adapter's buildLaunch emits the
// privileged flags (it keys off `posture.autoApprove`).
...(cliAgentSettings?.autonomyMode === "elevated"
? { autoApprove: true }
: {}),
effectivePosture: {
mode: effectivePosture.mode,
elevated: effectivePosture.elevated,
flags: effectivePosture.flags,
},
},
settings,
});
} catch (err) {

View File

@@ -676,7 +676,35 @@ export {
DuplicateCliAdapterError,
type CliAgentAdapter,
type CliAdapterCapabilities,
type CliAdapterElevationMarkers,
} from "./cli-agent/adapter.js";
// CLI Agent Executor — autonomy posture resolution + approval gate (U15).
export {
resolveEffectivePosture,
assertAutonomyApproved,
CliAutonomyNotApprovedError,
GENERIC_ELEVATION_ENV_PATTERNS,
tierForCapabilities,
type EffectivePosture,
type CliElevationFlag,
type CliAgentResolveSettings,
type CliAgentNodeConfig,
type AutonomyApprovalLookup,
type ResolveEffectivePostureArgs,
type AssertAutonomyApprovedArgs,
type CliAdapterTier,
} from "./cli-agent/autonomy.js";
// CLI Agent Executor — bundled adapters + UI descriptors (U15).
export {
BUNDLED_CLI_ADAPTERS,
listCliAdapterDescriptors,
claudeCodeAdapter,
codexAdapter,
droidAdapter,
piAdapter,
genericCliAdapter,
type CliAdapterDescriptor,
} from "./cli-agent/adapters/index.js";
// CLI Agent Executor — task ↔ session orchestration (U7).
export {
CliTaskSession,

View File

@@ -5007,7 +5007,8 @@
"tooltip": {
"global": "Shared across all projects",
"project": "Specific to this project"
}
},
"cliAgents": "CLI Agents"
},
"notifications": {
"sending": "Sending…",
@@ -5050,6 +5051,34 @@
"openApprovals": "Open Approvals",
"selectWorktreesDir": "Select worktrees directory",
"tryAgain": "Try again"
},
"cliAgents": {
"heading": "CLI Agents",
"description": "Per-adapter launch configuration for CLI coding agents driven in engine-owned terminals.",
"adapterLabel": "Adapter",
"tier": {
"native": "native",
"hybrid": "hybrid",
"generic": "generic"
},
"commandLabel": "Command override",
"commandHelp": "Path or name of the binary to launch. A non-default value is treated as privileged and requires autonomy approval.",
"extraArgsLabel": "Extra arguments",
"extraArgsHelp": "Appended after the adapter's computed arguments (space-separated). Bypass flags here are detected and gated.",
"envLabel": "Environment variable additions",
"envHelp": "Comma-separated variable NAMES forwarded from the parent process. Service credentials (FUSION_*) are always excluded.",
"autonomyLabel": "Autonomy mode",
"autonomyHelp": "Elevated autonomy requires a per-project approval before the agent can launch.",
"approvedNote": "Elevated autonomy is approved for this project.",
"autonomy": {
"default": "Default (request approvals)",
"elevated": "Elevated (bypass approvals)"
},
"elevatedConfirmTitle": "Approve elevated autonomy?",
"elevatedConfirmBody": "Elevated autonomy lets this CLI agent bypass per-step approvals (e.g. --dangerously-skip-permissions). It can modify files and run commands without pausing. Approve only if you trust this adapter for this project.",
"elevatedConfirmAction": "Approve elevated autonomy",
"saveFailed": "Failed to save CLI agent settings",
"approveFailed": "Failed to record autonomy approval"
}
},
"setup": {
@@ -6832,5 +6861,26 @@
"moreFields": "Additional fields",
"orphaned": "Orphaned fields",
"saveFailed": "Failed to save field"
},
"workflowEditor": {
"cliAgent": {
"executorOption": "CLI agent",
"adapterLabel": "CLI adapter",
"adapterPlaceholder": "— select adapter —",
"adapterNote": "Drives a CLI coding agent in an engine-owned terminal for this step.",
"tier": {
"native": "native",
"hybrid": "hybrid",
"generic": "generic"
},
"autonomyLabel": "Elevated autonomy (bypass approvals)",
"autonomyNote": "Elevated autonomy requires a per-project approval before the agent can launch. Until approved, launches with elevated posture fail.",
"notifyLabel": "Waiting-on-input notification",
"notify": {
"banner": "In-app banner",
"bannerNotify": "Banner + push notification"
},
"notifyNote": "How you are alerted when the agent pauses waiting for input on this step."
}
}
}