From 24b27e8b382a7572f2e2443a5f7a6ba4ffc8461b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 4 Jul 2026 22:35:30 -0700 Subject: [PATCH] FN-7564: block approve/reject-plan API bypass of release-authorization holds Enforce the FN-7559 release-authorization hold in the approve-plan and reject-plan routes, since a direct API call previously bypassed the dashboard's UI-only protection. - Add a guard in register-task-workflow-routes.ts: reject approve-plan and reject-plan requests with 400 when task.awaitingApprovalReason === "release-authorization", instructing the caller to add the **Release Authorized By User:** yes marker instead. - Add regression tests in routes-github.test.ts covering both approve-plan and reject-plan against release-authorization-held tasks. - Add changeset fn-7564-approve-plan-release-authorization-guard.md (patch, security) documenting the fix. Files changed: .changeset/fn-7564-approve-plan-release-authorization-guard.md | 7 ++ packages/dashboard/src/__tests__/routes-github.test.ts | 82 ++++++++++++++++++++++ packages/dashboard/src/routes/register-task-workflow-routes.ts | 23 ++++++ 3 files changed, 112 insertions(+) Fusion-Task-Id: FN-7564 Fusion-Task-Lineage: ccadd492-ee2b-4f0e-af2f-5f37c6351922 Co-authored-by: Fusion (runfusion.ai) --- ...pprove-plan-release-authorization-guard.md | 7 ++ .../src/__tests__/routes-github.test.ts | 82 +++++++++++++++++++ .../routes/register-task-workflow-routes.ts | 23 ++++++ 3 files changed, 112 insertions(+) create mode 100644 .changeset/fn-7564-approve-plan-release-authorization-guard.md diff --git a/.changeset/fn-7564-approve-plan-release-authorization-guard.md b/.changeset/fn-7564-approve-plan-release-authorization-guard.md new file mode 100644 index 0000000000..e225c8e2a9 --- /dev/null +++ b/.changeset/fn-7564-approve-plan-release-authorization-guard.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Plan approve/reject API now blocks release-authorization holds, requiring the authorization marker first. +category: fix +dev: FN-7564 — POST /tasks/:id/approve-plan and /reject-plan now return 400 when task.awaitingApprovalReason === "release-authorization" (FN-7559 discriminator), enforcing the FN-6481 release-authorization gate at the API layer regardless of client. Manual-approval holds are unaffected. diff --git a/packages/dashboard/src/__tests__/routes-github.test.ts b/packages/dashboard/src/__tests__/routes-github.test.ts index 23245b9a79..d5ce7afe51 100644 --- a/packages/dashboard/src/__tests__/routes-github.test.ts +++ b/packages/dashboard/src/__tests__/routes-github.test.ts @@ -2043,6 +2043,48 @@ describe("POST /tasks/:id/approve-plan", () => { expect(res.status).toBe(500); expect(res.body.error).toBe("Database error"); }); + + // FN-7564: a release-authorization hold (FN-7559's awaitingApprovalReason + // discriminator) must reject a direct approve-plan API call with 400 — the + // FN-6481 authorization-marker requirement must not be bypassable outside the UI. + it("returns 400 and does not move the task for a release-authorization hold", async () => { + const releaseHoldTask = { + ...FAKE_TASK_DETAIL, + column: "triage" as const, + status: "awaiting-approval" as const, + awaitingApprovalReason: "release-authorization" as const, + }; + (store.getTask as ReturnType).mockResolvedValue(releaseHoldTask); + + const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/approve-plan"); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("release authorization"); + expect(res.body.error).toContain("Release Authorized By User"); + expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.updateTask).not.toHaveBeenCalled(); + }); + + // Passthrough: an ordinary manual-approval hold (no awaitingApprovalReason) + // must still approve exactly as before — the guard is scoped to release-authorization only. + it("still approves a manual-approval hold with awaitingApprovalReason unset", async () => { + const awaitingTask = { + ...FAKE_TASK_DETAIL, + column: "triage" as const, + status: "awaiting-approval" as const, + awaitingApprovalReason: undefined, + }; + const movedTask = { ...FAKE_TASK_DETAIL, column: "todo" as const }; + + (store.getTask as ReturnType).mockResolvedValue(awaitingTask); + (store.moveTask as ReturnType).mockResolvedValue(movedTask); + (store.updateTask as ReturnType).mockResolvedValue({ ...movedTask, status: undefined }); + + const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/approve-plan"); + + expect(res.status).toBe(200); + expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo"); + }); }); describe("POST /tasks/:id/reject-plan", () => { @@ -2119,6 +2161,46 @@ describe("POST /tasks/:id/reject-plan", () => { expect(res.status).toBe(500); expect(res.body.error).toBe("Database error"); }); + + // FN-7564: a release-authorization hold must reject a direct reject-plan API + // call with 400 too — rejecting must not silently wipe/regenerate the spec + // without the operator ever acknowledging the FN-6481 authorization gate. + it("returns 400 and does not clear status or remove PROMPT.md for a release-authorization hold", async () => { + const releaseHoldTask = { + ...FAKE_TASK_DETAIL, + column: "triage" as const, + status: "awaiting-approval" as const, + awaitingApprovalReason: "release-authorization" as const, + }; + (store.getTask as ReturnType).mockResolvedValue(releaseHoldTask); + + const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/reject-plan"); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("release authorization"); + expect(res.body.error).toContain("Release Authorized By User"); + expect(store.updateTask).not.toHaveBeenCalled(); + }); + + // Passthrough: an ordinary manual-approval hold (no awaitingApprovalReason) + // must still reject exactly as before — the guard is scoped to release-authorization only. + it("still rejects a manual-approval hold with awaitingApprovalReason unset", async () => { + const awaitingTask = { + ...FAKE_TASK_DETAIL, + column: "triage" as const, + status: "awaiting-approval" as const, + awaitingApprovalReason: undefined, + }; + const updatedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: undefined }; + + (store.getTask as ReturnType).mockResolvedValue(awaitingTask); + (store.updateTask as ReturnType).mockResolvedValue(updatedTask); + + const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/reject-plan"); + + expect(res.status).toBe(200); + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined }); + }); }); // --- Task diff route tests --- diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index da1ade20c0..31909c31c4 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -2737,6 +2737,19 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork if (task.status !== "awaiting-approval") { throw badRequest("Task must have status 'awaiting-approval' to approve plan"); } + // FNXC:ReleaseAuthorizationGate 2026-07-04-22:30: + // FN-6481 requires a release-class task to carry an explicit + // "**Release Authorized By User:** yes" marker before it can dispatch. + // FN-7559 parks such tasks with awaitingApprovalReason === "release-authorization" + // (distinct from an ordinary manual plan-approval hold) and hides the + // Approve/Reject Plan buttons in the dashboard UI, but a direct API call bypasses + // that UI protection. Enforce the gate here too so no client can dispatch a + // release-class task without the authorization marker. + if (task.awaitingApprovalReason === "release-authorization") { + throw badRequest( + "This task is held for release authorization. Add the **Release Authorized By User:** yes marker to its PROMPT.md and resubmit the spec instead of approving the plan.", + ); + } // Log the approval await scopedStore.logEntry(task.id, "Plan approved by user"); @@ -2769,6 +2782,16 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork if (task.status !== "awaiting-approval") { throw badRequest("Task must have status 'awaiting-approval' to reject plan"); } + // FNXC:ReleaseAuthorizationGate 2026-07-04-22:30: + // Mirror of the approve-plan guard above: a release-authorization hold + // (FN-7559's awaitingApprovalReason discriminator) must not be rejectable + // through the API either, since rejecting would wipe/regenerate the spec + // without the operator ever acknowledging the FN-6481 authorization gate. + if (task.awaitingApprovalReason === "release-authorization") { + throw badRequest( + "This task is held for release authorization. Add the **Release Authorized By User:** yes marker to its PROMPT.md and resubmit the spec instead of rejecting the plan.", + ); + } // Log the rejection await scopedStore.logEntry(task.id, "Plan rejected by user", "Specification will be regenerated");