diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 211c952c908..76585768f8f 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -387,6 +387,31 @@ describe("agent issue mutation checkout ownership", () => { expect(mockStorageService.deleteObject).not.toHaveBeenCalled(); }); + it.each([ + ["patch", (app: express.Express) => request(app).patch(`/api/issues/${issueId}`).send({ title: "Blocked" })], + ["comment", (app: express.Express) => request(app).post(`/api/issues/${issueId}/comments`).send({ body: "blocked" })], + [ + "document upsert", + (app: express.Express) => + request(app).put(`/api/issues/${issueId}/documents/plan`).send({ format: "markdown", body: "# blocked" }), + ], + ])("rejects peer agent %s mutations on user-assigned issues", async (_name, sendRequest) => { + mockIssueService.getById.mockResolvedValue(makeIssue({ + status: "in_review", + assigneeAgentId: null, + assigneeUserId: "board-user", + })); + + const res = await sendRequest(await createApp(peerActor())); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toBe("Agent cannot mutate a user-assigned issue"); + expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled(); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockIssueService.addComment).not.toHaveBeenCalled(); + expect(mockDocumentService.upsertIssueDocument).not.toHaveBeenCalled(); + }); + it("allows the checked-out owner with the matching run id to patch and update documents", async () => { const app = await createApp(ownerActor()); diff --git a/server/src/__tests__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts index f21e7b95c4d..9ee101a2df8 100644 --- a/server/src/__tests__/issue-execution-policy-routes.test.ts +++ b/server/src/__tests__/issue-execution-policy-routes.test.ts @@ -25,7 +25,7 @@ const mockHeartbeatService = vi.hoisted(() => ({ function registerModuleMocks() { vi.doMock("../services/index.js", () => ({ companyService: () => ({ - getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })), + getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024, issuePrefix: "PAP" })), }), accessService: () => ({ canUser: vi.fn(async () => false), @@ -76,7 +76,27 @@ function registerModuleMocks() { })); } -async function createApp() { +function boardActor() { + return { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; +} + +function agentActor() { + return { + type: "agent", + agentId: "22222222-2222-4222-8222-222222222222", + companyId: "company-1", + source: "agent_key", + runId: "33333333-3333-4333-8333-333333333333", + }; +} + +async function createApp(actor: Record = boardActor()) { const [{ errorHandler }, { issueRoutes }] = await Promise.all([ import("../middleware/index.js"), import("../routes/issues.js"), @@ -84,13 +104,7 @@ async function createApp() { const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).actor = { - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }; + (req as any).actor = actor; next(); }); app.use("/api", issueRoutes({} as any, {} as any)); @@ -162,4 +176,98 @@ describe("issue execution policy routes", () => { expect(updatePatch.executionState).toBeUndefined(); expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); + + it("rejects agent-authored execution policies with user participants", async () => { + const policy = normalizeIssueExecutionPolicy({ + stages: [ + { + id: "11111111-1111-4111-8111-111111111111", + type: "approval", + participants: [{ type: "user", userId: "local-board" }], + }, + ], + })!; + const issue = { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + companyId: "company-1", + status: "in_progress", + assigneeAgentId: "22222222-2222-4222-8222-222222222222", + assigneeUserId: null, + createdByUserId: "local-board", + identifier: "PAP-999", + title: "Execution policy escalation", + executionPolicy: null, + executionState: null, + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...issue, + ...patch, + updatedAt: new Date(), + })); + + const res = await request(await createApp(agentActor())) + .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + .send({ + executionPolicy: policy, + status: "in_review", + comment: "Escalate to the board.", + }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toBe("Agents cannot author execution policies with user participants"); + expect(mockIssueService.assertCheckoutOwner).toHaveBeenCalledWith( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "22222222-2222-4222-8222-222222222222", + "33333333-3333-4333-8333-333333333333", + ); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }); + + it("rejects agent-created issues with user-participant execution policies", async () => { + const policy = normalizeIssueExecutionPolicy({ + stages: [ + { + id: "11111111-1111-4111-8111-111111111111", + type: "approval", + participants: [{ type: "user", userId: "local-board" }], + }, + ], + })!; + + const res = await request(await createApp(agentActor())) + .post("/api/companies/company-1/issues") + .send({ + title: "Escalation issue", + executionPolicy: policy, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toBe("Agents cannot author execution policies with user participants"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }); + + it("rejects agent PATCH attempts to assign issues directly to users", async () => { + const issue = { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + companyId: "company-1", + status: "in_progress", + assigneeAgentId: "22222222-2222-4222-8222-222222222222", + assigneeUserId: null, + createdByUserId: "local-board", + identifier: "PAP-999", + title: "Direct user assignment", + executionPolicy: null, + executionState: null, + }; + mockIssueService.getById.mockResolvedValue(issue); + + const res = await request(await createApp(agentActor())) + .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + .send({ assigneeAgentId: null, assigneeUserId: "local-board" }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toBe("Agents cannot assign issues to users"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index ab6a4232665..dde8049e4aa 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -110,6 +110,10 @@ type ExecutionStageWakeContext = { allowedActions: string[]; }; +function executionPolicyHasUserParticipants(policy: NormalizedExecutionPolicy | null): boolean { + return Boolean(policy?.stages.some((stage) => stage.participants.some((participant) => participant.type === "user"))); +} + function executionPrincipalsEqual( left: ParsedExecutionState["currentParticipant"] | null, right: ParsedExecutionState["currentParticipant"] | null, @@ -595,7 +599,7 @@ export function issueRoutes( async function assertAgentIssueMutationAllowed( req: Request, res: Response, - issue: { id: string; companyId: string; status: string; assigneeAgentId: string | null }, + issue: { id: string; companyId: string; status: string; assigneeAgentId: string | null; assigneeUserId: string | null }, ) { if (req.actor.type !== "agent") return true; const actorAgentId = req.actor.agentId; @@ -603,6 +607,19 @@ export function issueRoutes( res.status(403).json({ error: "Agent authentication required" }); return false; } + if (issue.assigneeUserId) { + res.status(403).json({ + error: "Agent cannot mutate a user-assigned issue", + details: { + issueId: issue.id, + assigneeUserId: issue.assigneeUserId, + actorAgentId, + status: issue.status, + securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"], + }, + }); + return false; + } if (issue.assigneeAgentId === null) { return true; } @@ -1812,6 +1829,16 @@ export function issueRoutes( const actor = getActorInfo(req); const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy); + if (req.actor.type === "agent" && executionPolicyHasUserParticipants(executionPolicy)) { + res.status(403).json({ + error: "Agents cannot author execution policies with user participants", + details: { + companyId, + securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"], + }, + }); + return; + } const issue = await svc.create(companyId, { ...req.body, executionPolicy, @@ -1879,6 +1906,16 @@ export function issueRoutes( const actor = getActorInfo(req); const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy); + if (req.actor.type === "agent" && executionPolicyHasUserParticipants(executionPolicy)) { + res.status(403).json({ + error: "Agents cannot author execution policies with user participants", + details: { + parentIssueId: parent.id, + securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"], + }, + }); + return; + } const { issue, parentBlockerAdded } = await svc.createChild(parent.id, { ...req.body, executionPolicy, @@ -2049,6 +2086,20 @@ export function issueRoutes( updateFields.executionPolicy !== undefined ? (updateFields.executionPolicy as NormalizedExecutionPolicy | null) : previousExecutionPolicy; + if ( + req.actor.type === "agent" && + req.body.executionPolicy !== undefined && + executionPolicyHasUserParticipants(nextExecutionPolicy) + ) { + res.status(403).json({ + error: "Agents cannot author execution policies with user participants", + details: { + issueId: existing.id, + securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"], + }, + }); + return; + } if (normalizedAssigneeAgentId !== undefined) { updateFields.assigneeAgentId = normalizedAssigneeAgentId; } @@ -2081,6 +2132,22 @@ export function issueRoutes( }; } Object.assign(updateFields, transition.patch); + if ( + req.actor.type === "agent" && + req.body.assigneeUserId !== undefined && + req.body.assigneeUserId !== existing.assigneeUserId && + !transition.workflowControlledAssignment + ) { + res.status(403).json({ + error: "Agents cannot assign issues to users", + details: { + issueId: existing.id, + assigneeUserId: req.body.assigneeUserId ?? null, + securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"], + }, + }); + return; + } if (reviewRequest !== undefined && transition.patch.executionState === undefined) { const existingExecutionState = parseIssueExecutionState(existing.executionState); if (!existingExecutionState || existingExecutionState.status !== "pending") {