diff --git a/server/src/__tests__/issue-recovery-actions.test.ts b/server/src/__tests__/issue-recovery-actions.test.ts index 1bfa9d48beb..5b3f6f33000 100644 --- a/server/src/__tests__/issue-recovery-actions.test.ts +++ b/server/src/__tests__/issue-recovery-actions.test.ts @@ -768,6 +768,48 @@ describeEmbeddedPostgres("issue recovery actions", () => { }); }); + it("rejects stale recovery action IDs before mutating the source issue", async () => { + const { companyId, managerId, sourceIssueId } = await seedCompany(); + const recoveryActionSvc = issueRecoveryActionService(db); + const action = await recoveryActionSvc.upsertSourceScoped({ + companyId, + sourceIssueId, + kind: "issue_graph_liveness", + ownerType: "agent", + ownerAgentId: managerId, + cause: "issue_graph_liveness", + fingerprint: "graph-liveness:stale-action-id", + evidence: { latestIssueStatus: "in_progress" }, + nextAction: "Only the current active recovery action can be resolved.", + wakePolicy: { type: "wake_owner" }, + }); + const app = createApp({ + type: "agent", + agentId: managerId, + companyId, + source: "agent_jwt", + }); + + const rejected = await request(app) + .post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`) + .send({ + actionId: randomUUID(), + outcome: "restored", + sourceIssueStatus: "done", + }) + .expect(404); + + expect(rejected.body.error).toContain("Active recovery action not found"); + + const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId)); + expect(sourceIssue?.status).toBe("in_progress"); + expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toMatchObject({ + id: action.id, + status: "active", + ownerAgentId: managerId, + }); + }); + it("allows the assigned recovery owner to resolve an unassigned recovery action", async () => { const { companyId, managerId, sourceIssueId } = await seedCompany(); await db.update(issues).set({ assigneeAgentId: null, status: "todo" }).where(eq(issues.id, sourceIssueId)); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index cee705e76d5..d44fec20cc4 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1150,13 +1150,25 @@ export function issueRoutes( }, ) { const activeAction = await recoveryActionsSvc.getActiveForIssue(input.issue.companyId, input.issue.id); - if (!activeAction || (input.actionId && activeAction.id !== input.actionId)) { + if (!activeAction) { return { allowed: true, activeAction: null as typeof activeAction }; } - if (req.actor.type === "board") { + if (input.actionId && activeAction.id !== input.actionId) { + res.status(404).json({ + error: "Active recovery action not found", + details: { + issueId: input.issue.id, + requestedRecoveryActionId: input.actionId, + activeRecoveryActionId: activeAction.id, + securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"], + }, + }); + return { allowed: false, activeAction }; + } + if (req.actor.type !== "agent") { return { allowed: true, activeAction }; } - const actorAgentId = req.actor.type === "agent" ? req.actor.agentId : null; + const actorAgentId = req.actor.agentId; if (activeAction.ownerType === "agent" && actorAgentId && activeAction.ownerAgentId === actorAgentId) { return { allowed: true, activeAction }; } diff --git a/ui/src/components/IssueRecoveryActionCard.tsx b/ui/src/components/IssueRecoveryActionCard.tsx index fb147c2e6c2..ccaa39ef29b 100644 --- a/ui/src/components/IssueRecoveryActionCard.tsx +++ b/ui/src/components/IssueRecoveryActionCard.tsx @@ -516,34 +516,32 @@ export function IssueRecoveryActionCard({ Resolve recovery