diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 594fdef5..30693674 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -552,6 +552,83 @@ describe.sequential("issue comment reopen routes", () => { expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); + it("allows mention-granted non-assignee agent POST comments on closed issues without reopening", async () => { + const mentionedAgentId = "33333333-3333-4333-8333-333333333333"; + mockIssueService.getById.mockResolvedValue(makeIssue("done")); + mockIssueService.addComment.mockResolvedValue({ + id: "comment-1", + issueId: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + body: "I can answer the mention without reopening.", + createdAt: new Date(), + updatedAt: new Date(), + authorAgentId: mentionedAgentId, + authorUserId: null, + }); + mockAccessService.decide.mockImplementation(async (input: { action?: string }) => { + const allowed = input.action === "issue:comment"; + return { + allowed, + action: input.action, + reason: allowed ? "allow_issue_mention_grant" : "deny_missing_grant", + explanation: allowed ? "Allowed by a mention-scoped issue comment grant." : "Missing permission.", + }; + }); + + const res = await request(await installActor(createApp(), agentActor(mentionedAgentId))) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "I can answer the mention without reopening." }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockIssueService.addComment).toHaveBeenCalled(); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + expect(mockAccessService.decide).not.toHaveBeenCalledWith(expect.objectContaining({ action: "issue:mutate" })); + }); + + it.each([ + ["resume", { resume: true }], + ["reopen", { reopen: true }], + ])( + // Mention grants are append-only; explicit lifecycle intent still requires mutation authority. + "denies mention-granted non-assignee agent POST comments on closed issues with %s intent", + async (_name, intent) => { + const mentionedAgentId = "33333333-3333-4333-8333-333333333333"; + mockIssueService.getById.mockResolvedValue(makeIssue("done")); + mockIssueService.addComment.mockResolvedValue({ + id: "comment-1", + issueId: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + body: "Please continue this closed issue.", + createdAt: new Date(), + updatedAt: new Date(), + authorAgentId: mentionedAgentId, + authorUserId: null, + }); + mockAccessService.decide.mockImplementation(async (input: { action?: string }) => { + const allowed = input.action === "issue:comment"; + return { + allowed, + action: input.action, + reason: allowed ? "allow_issue_mention_grant" : "deny_missing_grant", + explanation: allowed ? "Allowed by a mention-scoped issue comment grant." : "Missing permission.", + }; + }); + + const res = await request(await installActor(createApp(), agentActor(mentionedAgentId))) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "Please continue this closed issue.", ...intent }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body).toEqual({ error: "Issue is outside this actor's authorization boundary" }); + expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:comment" })); + expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:mutate" })); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockIssueService.addComment).not.toHaveBeenCalled(); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + }, + ); + // POST self-comment from the assignee agent on a done issue with explicit // reopen=true is the same log-class signal — the guard must suppress reopen. it("does not reopen via POST comment+reopen when the assignee agent is the actor on a done issue", async () => { diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 1a4897b0..fbce62cc 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1915,7 +1915,11 @@ export function issueRoutes( res.status(403).json({ error: "Issue is outside this actor's authorization boundary" }); return false; } - return true; + return boundaryDecision; + } + + function isIssueMentionGrantDecision(decision: true | Awaited>) { + return decision !== true && decision.reason === "allow_issue_mention_grant"; } async function filterIssuesForActor[1]>(req: Request, rows: T[]) { @@ -7359,7 +7363,8 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); - if (!(await assertAgentIssueCommentAllowed(req, res, issue))) return; + const commentAccessDecision = await assertAgentIssueCommentAllowed(req, res, issue); + if (!commentAccessDecision) return; if (!assertStructuredCommentFieldsAllowed(req, res, { presentation: req.body.presentation, metadata: req.body.metadata, @@ -7376,19 +7381,30 @@ export function issueRoutes( const interruptRequested = req.body.interrupt === true; const isClosed = isClosedIssueStatus(issue.status); const isBlocked = issue.status === "blocked"; + const mentionGrantedPeerAgentCommentOnly = + isClosed && + req.actor.type === "agent" && + issue.assigneeAgentId !== null && + issue.assigneeAgentId !== req.actor.agentId && + !reopenRequested && + !resumeRequested && + isIssueMentionGrantDecision(commentAccessDecision); + const effectiveReopenRequested = mentionGrantedPeerAgentCommentOnly ? false : reopenRequested; + const effectiveResumeRequested = mentionGrantedPeerAgentCommentOnly ? false : resumeRequested; if ( isClosed && req.actor.type === "agent" && issue.assigneeAgentId !== null && - issue.assigneeAgentId !== req.actor.agentId + issue.assigneeAgentId !== req.actor.agentId && + !mentionGrantedPeerAgentCommentOnly ) { if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; } - if (resumeRequested === true && !(await assertExplicitResumeIntentAllowed(req, res, issue))) return; - if (resumeRequested !== true && reopenRequested === true && req.actor.type === "agent") { + if (effectiveResumeRequested === true && !(await assertExplicitResumeIntentAllowed(req, res, issue))) return; + if (effectiveResumeRequested !== true && effectiveReopenRequested === true && req.actor.type === "agent") { if (!(await assertExplicitResumeIntentAllowed(req, res, issue))) return; } - const explicitMoveToTodoRequested = reopenRequested || resumeRequested === true; + const explicitMoveToTodoRequested = effectiveReopenRequested || effectiveResumeRequested === true; const scheduledRetryForHumanComment = shouldHumanCommentResumeInProgressScheduledRetry({ hasComment: true,