Fix recovery action resolution guard

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-16 21:18:22 -05:00
1 parent 3b48e4cf58
commit 372e9349de
3 files changed
+83 -31

No files matched your search

@@ -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));
+15 -3
View File
@@ -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 };
}
+26 -28
View File
@@ -516,34 +516,32 @@ export function IssueRecoveryActionCard({
Resolve recovery
</div>
<div className="flex flex-col">
{visibleResolveOptions.map((option) => (
(() => {
const disabled = option.requiresBlocker && !canMarkBlocked;
return (
<button
key={option.outcome}
type="button"
onClick={() => {
if (disabled) return;
onResolve?.(option.outcome);
}}
disabled={disabled}
aria-disabled={disabled}
title={disabled ? "Add a first-class blocker before marking recovery blocked." : undefined}
className={cn(
"flex flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
"hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-55 disabled:hover:bg-transparent",
option.destructive ? "text-destructive" : null,
)}
>
<span className="font-medium leading-5">{option.label}</span>
<span className="text-[11px] leading-4 text-muted-foreground">
{disabled ? "Add a first-class blocker before using this outcome." : option.description}
</span>
</button>
);
})()
))}
{visibleResolveOptions.map((option) => {
const disabled = option.requiresBlocker && !canMarkBlocked;
return (
<button
key={option.outcome}
type="button"
onClick={() => {
if (disabled) return;
onResolve?.(option.outcome);
}}
disabled={disabled}
aria-disabled={disabled}
title={disabled ? "Add a first-class blocker before marking recovery blocked." : undefined}
className={cn(
"flex flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
"hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-55 disabled:hover:bg-transparent",
option.destructive ? "text-destructive" : null,
)}
>
<span className="font-medium leading-5">{option.label}</span>
<span className="text-[11px] leading-4 text-muted-foreground">
{disabled ? "Add a first-class blocker before using this outcome." : option.description}
</span>
</button>
);
})}
</div>
</PopoverContent>
</Popover>