diff --git a/.changeset/fn-6481-release-triage-authorization.md b/.changeset/fn-6481-release-triage-authorization.md new file mode 100644 index 0000000000..bfe942802f --- /dev/null +++ b/.changeset/fn-6481-release-triage-authorization.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Block release and publish-class tasks during triage unless they were explicitly authorized by a user-authored source. diff --git a/docs/solutions/architecture-patterns/release-triage-requires-user-authorization.md b/docs/solutions/architecture-patterns/release-triage-requires-user-authorization.md new file mode 100644 index 0000000000..3d506b7eb3 --- /dev/null +++ b/docs/solutions/architecture-patterns/release-triage-requires-user-authorization.md @@ -0,0 +1,33 @@ +--- +category: architecture +module: engine +tags: + - triage + - release-safety + - authorization +problem_type: security +applies_when: + - triage finalizes tasks that mention package release or publish commands + - agents or automation can create follow-up tasks +--- + +# Release-class triage requires explicit user authorization + +## Problem + +Autonomous agents can draft tasks that mention release mechanics such as `pnpm release --yes`, `scripts/release.mjs`, changeset publish, npm publish, semver tags, or release-version commits. Without a triage boundary, an agent-authored release task can be dispatched to execution and reach publish-class commands without a user intentionally authorizing the release. + +## Solution + +Release authorization is enforced as a pure triage gate before finalize dispatch moves work to `todo`: + +1. Classify release-class tasks from the combined title, description, and prompt text. +2. For release-class tasks, require a user-authored source (`dashboard_ui`, `quick_chat`, `chat_session`, or `cli`). +3. Require the prompt marker `**Release Authorized By User:** yes` for those user-authored sources. +4. Fail closed for unknown, internal, API, imported, duplicated, refined, workflow, recovery, research, cron, and agent-authored sources. + +The marker alone is intentionally insufficient. A non-user source that embeds the marker remains blocked because agents and integrations can write prompt text. + +## Verification + +Use the pure classifier tests in `packages/engine/src/__tests__/triage-release-authorization.test.ts` to cover the invariant without store, network, or timer dependencies. The test matrix should include the FN-6469 incident shape, all documented release signal patterns, all user-authored sources, representative non-user sources, marker parsing, and non-release pass-through behavior. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 517e96c4e3..b6273eb6de 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1240,6 +1240,8 @@ export type ActivityEventType = | "task:auto-archived-deterministic-duplicate" | "task:auto-archived-near-duplicate" | "task:near-duplicate-flagged" + /** FNXC:ReleaseAuthorizationGate 2026-06-15-02:44: Release-class tasks parked by triage need a distinct activity so operators can see that explicit user approval is required before dispatch. */ + | "task:release-authorization-required" | "task:auto-archived-ghost-bug" | "task:auto-archived-duplicate" | "task:merge-worktree-reacquired" diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 4fe1b94b39..89784d741f 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -11,7 +11,11 @@ const quarantinedCoreTests = [ FNXC:CoreTests 2026-06-14-02:14: FN-6433 re-ran the core quarantine batch after FN-6430's shared fixture cleanup and rescued all five files without timeout or assertion changes. Keep this array empty unless a future quarantine is mirrored in scripts/lib/test-quarantine.json in the same commit. + + FNXC:CoreTests 2026-06-15-03:13: + FN-6481 observed the disk-backed concurrent write test fail in the changed-package workspace lane with a transient SQLite BEGIN IMMEDIATE lock after the gate had already passed. Quarantine the flaky file instead of widening lock-recovery timeouts or weakening assertions. */ + "src/__tests__/store-concurrent-writes.test.ts", ]; export default defineConfig({ diff --git a/packages/dashboard/app/components/ActivityFeed.tsx b/packages/dashboard/app/components/ActivityFeed.tsx index eedbf309ac..d32c90ece6 100644 --- a/packages/dashboard/app/components/ActivityFeed.tsx +++ b/packages/dashboard/app/components/ActivityFeed.tsx @@ -33,6 +33,11 @@ const TYPE_CONFIG: Record): Record = { "task:deleted": , "task:merged": , "task:failed": , + /* + FNXC:ReleaseAuthorizationGate 2026-06-15-04:00: + The release gate parks unauthorized publish-class tasks; activity logs must expose that blocked state with warning styling so a human can authorize or revise the task. + */ + "task:release-authorization-required": , "task:duplicate-warning-overridden": , "task:auto-archived-ghost-bug": , "task:auto-archived-duplicate": , diff --git a/packages/engine/src/__tests__/triage-release-authorization.test.ts b/packages/engine/src/__tests__/triage-release-authorization.test.ts new file mode 100644 index 0000000000..bd7638eaea --- /dev/null +++ b/packages/engine/src/__tests__/triage-release-authorization.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import { + classifyReleaseTask, + evaluateReleaseAuthorizationGate, + isUserAuthoredSource, + parseReleaseAuthorizationMarker, +} from "../triage-release-authorization.js"; + +const releasePrompt = `# Task: FN-6469 - Release @runfusion/fusion patch + +## Mission +Publish @runfusion/fusion to npm using the release process. + +## Steps +- Run pnpm release --yes +- Verify scripts/release.mjs completed +`; + +const marker = "**Release Authorized By User:** yes"; + +describe("triage release authorization gate", () => { + it("blocks the FN-6469 incident shape before auto-dispatch", () => { + const decision = evaluateReleaseAuthorizationGate({ + sourceType: "agent_heartbeat", + title: "Release @runfusion/fusion patch", + description: "Release the package", + promptText: releasePrompt, + }); + + expect(decision.action).toBe("block"); + expect(decision.isReleaseClass).toBe(true); + expect(decision.signals).toContain("pnpm release"); + }); + + it("blocks agent-authored release tasks even when PROMPT.md contains the marker", () => { + const decision = evaluateReleaseAuthorizationGate({ + sourceType: "agent_heartbeat", + title: "Release @runfusion/fusion patch", + promptText: `${releasePrompt}\n${marker}\n`, + }); + + expect(decision.action).toBe("block"); + expect(decision.reason).toMatch(/non-user-authored source/); + }); + + it("allows user-authored dashboard release tasks with the marker", () => { + expect(evaluateReleaseAuthorizationGate({ + sourceType: "dashboard_ui", + title: "Release @runfusion/fusion patch", + promptText: `${releasePrompt}\n${marker}\n`, + }).action).toBe("allow"); + }); + + it("allows user-authored CLI release tasks with the marker", () => { + expect(evaluateReleaseAuthorizationGate({ + sourceType: "cli", + title: "Release @runfusion/fusion patch", + promptText: `${releasePrompt}\n **Release Authorized By User:** YES \n`, + }).action).toBe("allow"); + }); + + it("blocks user-authored release tasks without the marker", () => { + const decision = evaluateReleaseAuthorizationGate({ + sourceType: "quick_chat", + title: "Release @runfusion/fusion patch", + promptText: releasePrompt, + }); + + expect(decision.action).toBe("block"); + expect(decision.reason).toMatch(/missing/); + }); + + it("blocks api-sourced release tasks even when the marker is present", () => { + const decision = evaluateReleaseAuthorizationGate({ + sourceType: "api", + title: "Release @runfusion/fusion patch", + promptText: `${releasePrompt}\n${marker}\n`, + }); + + expect(decision.action).toBe("block"); + expect(decision.reason).toMatch(/non-user-authored source 'api'/); + }); + + it("blocks derived/internal release tasks even when the marker is present", () => { + for (const sourceType of ["task_refine", "github_import"] as const) { + expect(evaluateReleaseAuthorizationGate({ + sourceType, + title: "Release @runfusion/fusion patch", + promptText: `${releasePrompt}\n${marker}\n`, + }).action).toBe("block"); + } + }); + + it("allows non-release tasks without changing dispatch behavior", () => { + const decision = evaluateReleaseAuthorizationGate({ + sourceType: "agent_heartbeat", + title: "Fix dashboard layout bug", + description: "Adjust CSS for the task card footer.", + promptText: "## Mission\nFix a dashboard layout bug without publishing anything.", + }); + + expect(decision.action).toBe("allow"); + expect(decision.isReleaseClass).toBe(false); + expect(decision.signals).toEqual([]); + }); + + it("classifies all documented release signal surfaces", () => { + const cases = [ + ["pnpm release --yes", "pnpm release"], + ["node scripts/release.mjs --yes", "scripts/release.mjs"], + ["pnpm changeset publish", "changeset publish"], + ["npm publish ./dist for @runfusion/fusion", "npm publish @runfusion/fusion"], + ["pnpm publish @runfusion/fusion", "pnpm publish @runfusion/fusion"], + ["publish the package to npm", "publish to npm"], + ["git tag v1.2.3", "git tag v"], + ["create a version bump release commit for v1.2.3", "version-bump release commit"], + ] as const; + + for (const [promptText, expectedSignal] of cases) { + const classification = classifyReleaseTask({ promptText }); + expect(classification.isReleaseClass, promptText).toBe(true); + expect(classification.signals, promptText).toContain(expectedSignal); + } + }); + + it("handles empty and undefined inputs without throwing or flagging", () => { + expect(classifyReleaseTask({})).toEqual({ isReleaseClass: false, signals: [] }); + expect(evaluateReleaseAuthorizationGate({ sourceType: undefined }).action).toBe("allow"); + expect(parseReleaseAuthorizationMarker("")).toBe(false); + }); + + it("only treats the four explicit user-authored source types as user authored", () => { + const userAuthored = ["dashboard_ui", "quick_chat", "chat_session", "cli"]; + const nonUserAuthored = [ + "agent_heartbeat", + "automation", + "cron", + "workflow_step", + "recovery", + "research", + "unknown", + "github_import", + "task_refine", + "task_duplicate", + "api", + undefined, + null, + "future_source", + ]; + + for (const sourceType of userAuthored) { + expect(isUserAuthoredSource(sourceType), sourceType).toBe(true); + } + for (const sourceType of nonUserAuthored) { + expect(isUserAuthoredSource(sourceType), String(sourceType)).toBe(false); + } + }); +}); diff --git a/packages/engine/src/triage-release-authorization.ts b/packages/engine/src/triage-release-authorization.ts new file mode 100644 index 0000000000..0027b29fa6 --- /dev/null +++ b/packages/engine/src/triage-release-authorization.ts @@ -0,0 +1,100 @@ +/* +FNXC:ReleaseAuthorizationGate 2026-06-15-02:41: +FN-6481 closes the FN-6469 policy gap: release-class triage specs must not auto-dispatch unless the task was created from a user-authored surface and its PROMPT.md carries an explicit user authorization marker. +Agents and automation can write PROMPT.md, so the marker is ignored for every non-user SourceType; unknown or future source values fail closed by being treated as non-user-authored. +*/ + +const USER_AUTHORED_SOURCE_TYPES = new Set(["dashboard_ui", "quick_chat", "chat_session", "cli"]); + +export interface ReleaseTaskClassificationInput { + title?: string; + description?: string; + promptText?: string; +} + +export interface ReleaseTaskClassification { + isReleaseClass: boolean; + signals: string[]; +} + +export interface ReleaseAuthorizationGateInput extends ReleaseTaskClassificationInput { + sourceType: string | null | undefined; +} + +export interface ReleaseAuthorizationGateDecision extends ReleaseTaskClassification { + action: "allow" | "block"; + reason: string; +} + +interface ReleaseSignalPattern { + label: string; + pattern: RegExp; +} + +const RELEASE_SIGNAL_PATTERNS: ReleaseSignalPattern[] = [ + { label: "pnpm release", pattern: /\bpnpm\s+release\b/i }, + { label: "scripts/release.mjs", pattern: /(?:^|[^\w.-])scripts\/release\.mjs\b/i }, + { label: "changeset publish", pattern: /\b(?:pnpm\s+)?changeset\s+publish\b/i }, + { label: "npm publish @runfusion/fusion", pattern: /\bnpm\s+publish\b[\s\S]{0,240}@runfusion\/fusion\b|@runfusion\/fusion\b[\s\S]{0,240}\bnpm\s+publish\b/i }, + { label: "pnpm publish @runfusion/fusion", pattern: /\bpnpm\s+publish\b[\s\S]{0,240}@runfusion\/fusion\b|@runfusion\/fusion\b[\s\S]{0,240}\bpnpm\s+publish\b/i }, + { label: "publish to npm", pattern: /\bpublish\b[\s\S]{0,160}\b(?:to|on)\s+npm\b|\bnpm\b[\s\S]{0,160}\bpublish\b/i }, + { label: "git tag v", pattern: /\b(?:git\s+)?tag\s+v\d+\.\d+\.\d+(?:[-+][0-9a-z.-]+)?\b/i }, + { label: "version-bump release commit", pattern: /\b(?:version\s*bump|bump\s+version|release\s+commit|release\s+version)\b[\s\S]{0,120}\bv\d+\.\d+\.\d+\b|\bv\d+\.\d+\.\d+\b[\s\S]{0,120}\b(?:version\s*bump|bump\s+version|release\s+commit|release\s+version)\b/i }, +]; + +export function isUserAuthoredSource(sourceType: string | null | undefined): boolean { + return typeof sourceType === "string" && USER_AUTHORED_SOURCE_TYPES.has(sourceType); +} + +export function classifyReleaseTask(input: ReleaseTaskClassificationInput): ReleaseTaskClassification { + const text = [input.title, input.description, input.promptText] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join("\n\n"); + + if (!text.trim()) { + return { isReleaseClass: false, signals: [] }; + } + + const signals: string[] = []; + for (const { label, pattern } of RELEASE_SIGNAL_PATTERNS) { + if (pattern.test(text)) { + signals.push(label); + } + } + + return { isReleaseClass: signals.length > 0, signals }; +} + +export function parseReleaseAuthorizationMarker(promptText: string): boolean { + return /^\s*\*\*Release Authorized By User:\*\*\s*yes\s*$/im.test(promptText); +} + +export function evaluateReleaseAuthorizationGate(input: ReleaseAuthorizationGateInput): ReleaseAuthorizationGateDecision { + const classification = classifyReleaseTask(input); + if (!classification.isReleaseClass) { + return { + action: "allow", + ...classification, + reason: "Task does not contain release/publish intent signals.", + }; + } + + const userAuthored = isUserAuthoredSource(input.sourceType); + const hasMarker = parseReleaseAuthorizationMarker(input.promptText ?? ""); + if (userAuthored && hasMarker) { + return { + action: "allow", + ...classification, + reason: "Release-class task was created from a user-authored source and includes an explicit user authorization marker.", + }; + } + + const sourceLabel = input.sourceType ?? "unknown"; + return { + action: "block", + ...classification, + reason: userAuthored + ? `Release-class task from user-authored source '${sourceLabel}' is missing **Release Authorized By User:** yes.` + : `Release-class task from non-user-authored source '${sourceLabel}' requires operator review; PROMPT.md markers are ignored for this source.`, + }; +} diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 21f6d76f14..1f0cdda546 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -89,6 +89,7 @@ import { isResearchToolSurfaceEnabled, } from "./tool-availability.js"; import { runGhostBugPreflight } from "./triage-preflight.js"; +import { evaluateReleaseAuthorizationGate } from "./triage-release-authorization.js"; import { archiveAsGhostBug } from "./self-healing.js"; import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js"; import { resolveAndEmitGoalContext } from "./goal-injection-diagnostics.js"; @@ -2300,6 +2301,56 @@ export class TriageProcessor { planLog.warn(`${task.id}: failed to re-read task before approved-spec transition (${message}); proceeding with original task snapshot`); latestTransitionTask = task; } + try { + /** + * FNXC:ReleaseAuthorizationGate 2026-06-15-02:47: + * FN-6469 showed that agent-authored release specs can otherwise flow from triage directly to execution and publish npm packages. FN-6481 parks release-class tasks before every final triage dispatch branch unless a user-authored source supplied the explicit authorization marker. + */ + const releaseGateDecision = evaluateReleaseAuthorizationGate({ + sourceType: latestTransitionTask?.sourceType ?? task.sourceType, + title: latestTransitionTask?.title ?? task.title ?? "", + description: latestTransitionTask?.description ?? task.description ?? "", + promptText: written, + }); + if (releaseGateDecision.action === "block") { + const approvalUpdates: Record = { status: "awaiting-approval" }; + if (shouldApplyPromptDeclaredTitle && promptDeclaredTitle) { + approvalUpdates.title = promptDeclaredTitle; + } + const signals = releaseGateDecision.signals.length > 0 + ? releaseGateDecision.signals.join(", ") + : "release intent"; + const details = `${releaseGateDecision.reason} Matched signals: ${signals}.`; + await this.store.updateTask(task.id, approvalUpdates); + await this.store.logEntry( + task.id, + "Release authorization required — leaving task in triage awaiting manual approval", + details, + ); + try { + await this.store.recordActivity({ + type: "task:release-authorization-required", + taskId: task.id, + taskTitle: promptDeclaredTitle ?? latestTransitionTask?.title ?? task.title ?? "", + details, + metadata: { + reason: releaseGateDecision.reason, + signals: releaseGateDecision.signals, + sourceType: latestTransitionTask?.sourceType ?? task.sourceType ?? "unknown", + }, + }); + } catch (activityError: unknown) { + const message = activityError instanceof Error ? activityError.message : String(activityError); + planLog.warn(`${task.id}: failed to record release-authorization-required activity (${message})`); + } + planLog.log(`${task.id} release authorization required — leaving in triage awaiting manual approval (${signals})`); + return; + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + planLog.warn(`${task.id}: release-authorization gate failed open: ${message}`); + } + if (latestTransitionTask?.paused === true || latestTransitionTask?.userPaused === true) { const restoreStatus = options.isReplan ? "needs-replan" : null; await this.store.updateTask(task.id, { status: restoreStatus }); diff --git a/scripts/lib/test-quarantine.json b/scripts/lib/test-quarantine.json index 39eac9c428..ae1ac6b426 100644 --- a/scripts/lib/test-quarantine.json +++ b/scripts/lib/test-quarantine.json @@ -1,4 +1,10 @@ { "$comment": "Flaky-test quarantine ledger (deletion ratchet — see AGENTS.md 'Flaky tests: quarantine on sight' and docs/testing.md 'Quarantine ledger and the deletion ratchet'). A test observed failing without a corresponding real bug is quarantined ON SIGHT: add an entry here AND a matching one-line `exclude` entry in that package's vitest config, in the same commit. Every entry needs a non-empty `reason` (link the failing run) and a `quarantinedAt` date — the entry expires 14 days later, at which point the test file is DELETED unless someone rescues it with evidence it catches real regressions plus a root-cause fix (never appeasement). There is deliberately no loader module and no automation around this file: it is a dated record, the vitest config exclude is the mechanism, and the sweep is policy executed by whoever touches the suite.", - "entries": [] + "entries": [ + { + "file": "packages/core/src/__tests__/store-concurrent-writes.test.ts", + "reason": "FN-6481 pnpm test 2026-06-15 failed unrelated core SQLite lock recovery concurrency test with `SQLite BEGIN IMMEDIATE failed after 7 attempts: database is locked`; quarantine on sight per flaky-test policy.", + "quarantinedAt": "2026-06-15" + } + ] }