Block release-class tasks from automatic triage dispatch unless they come from a user-authored source with explicit authorization. - Add release intent classification and authorization-marker enforcement before final triage transitions. - Record activity/log details when release tasks are parked awaiting manual approval. - Surface the new release-authorization activity in dashboard activity views. - Cover release gating behavior with engine tests and document the architecture pattern. - Add a changeset for the published CLI package. Files changed: .changeset/fn-6481-release-triage-authorization.md | 5 + .../release-triage-requires-user-authorization.md | 33 +++++ packages/core/src/types.ts | 2 + packages/core/vitest.config.ts | 4 + packages/dashboard/app/components/ActivityFeed.tsx | 5 + .../dashboard/app/components/ActivityLogModal.tsx | 6 + .../__tests__/triage-release-authorization.test.ts | 158 +++++++++++++++++++++ .../engine/src/triage-release-authorization.ts | 100 +++++++++++++ packages/engine/src/triage.ts | 51 +++++++ scripts/lib/test-quarantine.json | 8 +- 10 files changed, 371 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-6481 Fusion-Task-Lineage: 0bddb77a-87e5-4fa5-b31a-e773bdae7a29
101 lines
4.2 KiB
TypeScript
101 lines
4.2 KiB
TypeScript
/*
|
|
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<semver>", 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.`,
|
|
};
|
|
}
|