mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
Fix recovery action resolution guard
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user