import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mockIssueService = vi.hoisted(() => ({ addComment: vi.fn(), assertCheckoutOwner: vi.fn(), create: vi.fn(), findMentionedAgents: vi.fn(), getByIdentifier: vi.fn(), getById: vi.fn(), getRelationSummaries: vi.fn(), getWakeableParentAfterChildCompletion: vi.fn(), listWakeableBlockedDependents: vi.fn(), update: vi.fn(), })); const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), hasPermission: vi.fn(), })); const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), })); const mockExecutionWorkspaceService = vi.hoisted(() => ({ getById: vi.fn(), })); const mockFeedbackService = vi.hoisted(() => ({ listIssueVotesForUser: vi.fn(), saveIssueVote: vi.fn(), })); const mockHeartbeatService = vi.hoisted(() => ({ wakeup: vi.fn(), reportRunActivity: vi.fn(), getRun: vi.fn(), getActiveRunForAgent: vi.fn(), cancelRun: vi.fn(), })); const mockInstanceSettingsService = vi.hoisted(() => ({ get: vi.fn(), listCompanyIds: vi.fn(), })); const mockLogActivity = vi.hoisted(() => vi.fn()); const mockRoutineService = vi.hoisted(() => ({ syncRunStatusForIssue: vi.fn(), })); function registerRouteMocks() { vi.doMock("../services/access.js", () => ({ accessService: () => mockAccessService, })); vi.doMock("../services/activity-log.js", () => ({ logActivity: mockLogActivity, })); vi.doMock("../services/agents.js", () => ({ agentService: () => mockAgentService, })); vi.doMock("../services/execution-workspaces.js", () => ({ executionWorkspaceService: () => mockExecutionWorkspaceService, })); vi.doMock("../services/feedback.js", () => ({ feedbackService: () => mockFeedbackService, })); vi.doMock("../services/heartbeat.js", () => ({ heartbeatService: () => mockHeartbeatService, })); vi.doMock("../services/instance-settings.js", () => ({ instanceSettingsService: () => mockInstanceSettingsService, })); vi.doMock("../services/issues.js", () => ({ issueService: () => mockIssueService, })); vi.doMock("../services/routines.js", () => ({ routineService: () => mockRoutineService, })); vi.doMock("../services/index.js", () => ({ companyService: () => ({ getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })), }), accessService: () => mockAccessService, agentService: () => mockAgentService, documentService: () => ({}), executionWorkspaceService: () => mockExecutionWorkspaceService, feedbackService: () => mockFeedbackService, goalService: () => ({}), heartbeatService: () => mockHeartbeatService, instanceSettingsService: () => mockInstanceSettingsService, issueApprovalService: () => ({}), issueReferenceService: () => ({ deleteDocumentSource: async () => undefined, diffIssueReferenceSummary: () => ({ addedReferencedIssues: [], removedReferencedIssues: [], currentReferencedIssues: [], }), emptySummary: () => ({ outbound: [], inbound: [] }), listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), syncComment: async () => undefined, syncDocument: async () => undefined, syncIssue: async () => undefined, }), issueService: () => mockIssueService, logActivity: mockLogActivity, projectService: () => ({}), routineService: () => mockRoutineService, workProductService: () => ({}), })); } async function createApp(actor: Record) { const [{ errorHandler }, { issueRoutes }] = await Promise.all([ vi.importActual("../middleware/index.js"), vi.importActual("../routes/issues.js"), ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { (req as any).actor = actor; next(); }); app.use("/api", issueRoutes({} as any, {} as any)); app.use(errorHandler); return app; } function makeIssue(overrides: Record = {}) { return { id: "issue-1", companyId: "company-1", status: "todo", priority: "medium", projectId: null, goalId: null, parentId: null, assigneeAgentId: null, assigneeUserId: null, createdByUserId: "board-user", identifier: "PAP-1000", title: "Workspace authz", executionPolicy: null, executionState: null, executionWorkspaceId: null, hiddenAt: null, ...overrides, }; } describe("issue workspace command authorization", () => { beforeEach(() => { vi.resetModules(); vi.doUnmock("../services/access.js"); vi.doUnmock("../services/activity-log.js"); vi.doUnmock("../services/agents.js"); vi.doUnmock("../services/execution-workspaces.js"); vi.doUnmock("../services/feedback.js"); vi.doUnmock("../services/heartbeat.js"); vi.doUnmock("../services/index.js"); vi.doUnmock("../services/instance-settings.js"); vi.doUnmock("../services/issues.js"); vi.doUnmock("../services/routines.js"); vi.doUnmock("../routes/issues.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerRouteMocks(); vi.clearAllMocks(); mockIssueService.addComment.mockResolvedValue(null); mockIssueService.create.mockResolvedValue(makeIssue()); mockIssueService.findMentionedAgents.mockResolvedValue([]); mockIssueService.getById.mockResolvedValue(makeIssue()); mockIssueService.getByIdentifier.mockResolvedValue(null); mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); mockIssueService.update.mockResolvedValue(makeIssue()); mockAccessService.canUser.mockResolvedValue(true); mockAccessService.hasPermission.mockResolvedValue(true); mockAgentService.getById.mockResolvedValue(null); mockExecutionWorkspaceService.getById.mockResolvedValue(null); mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]); mockFeedbackService.saveIssueVote.mockResolvedValue({ vote: null, consentEnabledNow: false, sharingEnabled: false, }); mockHeartbeatService.wakeup.mockResolvedValue(undefined); mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined); mockHeartbeatService.getRun.mockResolvedValue(null); mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null); mockHeartbeatService.cancelRun.mockResolvedValue(null); mockInstanceSettingsService.get.mockResolvedValue({ id: "instance-settings-1", general: { censorUsernameInLogs: false, feedbackDataSharingPreference: "prompt", }, }); mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); mockLogActivity.mockResolvedValue(undefined); mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined); }); it("rejects agent callers that create issue workspace provision commands", async () => { const app = await createApp({ type: "agent", agentId: "agent-1", companyId: "company-1", source: "agent_key", runId: "run-1", }); const res = await request(app) .post("/api/companies/company-1/issues") .send({ title: "Exploit", executionWorkspaceSettings: { workspaceStrategy: { type: "git_worktree", provisionCommand: "touch /tmp/paperclip-rce", }, }, }); expect(res.status).toBe(403); expect(res.body.error).toContain("host-executed workspace commands"); expect(mockIssueService.create).not.toHaveBeenCalled(); }); it("rejects agent callers that patch assignee adapter workspace teardown commands", async () => { mockIssueService.getById.mockResolvedValue(makeIssue()); const app = await createApp({ type: "agent", agentId: "agent-1", companyId: "company-1", source: "agent_key", runId: "run-1", }); const res = await request(app) .patch("/api/issues/issue-1") .send({ assigneeAdapterOverrides: { adapterConfig: { workspaceStrategy: { type: "git_worktree", teardownCommand: "rm -rf /tmp/paperclip-rce", }, }, }, }); expect(res.status).toBe(403); expect(res.body.error).toContain("host-executed workspace commands"); expect(mockIssueService.update).not.toHaveBeenCalled(); }); });