From 647c2c487e48559285a87d7c4986b2b1f24fd13d Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 28 Apr 2026 11:02:27 -0500 Subject: [PATCH] Tighten shared project workspace runtime stop/restart auth Co-Authored-By: Paperclip --- .../workspace-runtime-routes-authz.test.ts | 49 +++++++++++++++++++ server/src/routes/projects.ts | 16 ++++-- ui/src/pages/ProjectWorkspaceDetail.tsx | 4 +- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/server/src/__tests__/workspace-runtime-routes-authz.test.ts b/server/src/__tests__/workspace-runtime-routes-authz.test.ts index 34aaf872f74..ac2488c38aa 100644 --- a/server/src/__tests__/workspace-runtime-routes-authz.test.ts +++ b/server/src/__tests__/workspace-runtime-routes-authz.test.ts @@ -320,6 +320,55 @@ describe.sequential("workspace runtime service route authorization", () => { expect(mockAssertCanManageProjectWorkspaceRuntimeServices).toHaveBeenCalled(); }, 15000); + it("blocks shared-project stop/restart requests from agents", async () => { + mockProjectService.getById.mockResolvedValue(buildProject({ + id: projectId, + workspaces: [{ + id: workspaceId, + companyId: "company-1", + projectId, + name: "Workspace", + sourceType: "local_path", + cwd: "/tmp/project", + repoUrl: null, + repoRef: null, + defaultRef: null, + visibility: "default", + setupCommand: null, + cleanupCommand: null, + remoteProvider: null, + remoteWorkspaceRef: null, + sharedWorkspaceKey: "shared-key", + metadata: null, + runtimeConfig: null, + isPrimary: false, + runtimeServices: [], + createdAt: new Date(), + updatedAt: new Date(), + }], + })); + const app = await createProjectApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const responses = await Promise.all([ + request(app).post(`/api/projects/${projectId}/workspaces/${workspaceId}/runtime-services/stop`).send({}), + request(app).post(`/api/projects/${projectId}/workspaces/${workspaceId}/runtime-services/restart`).send({}), + ]); + + for (const res of responses) { + expect(res.status).toBe(403); + expect(res.body.error).toContain("Missing permission"); + expect(mockProjectService.getById).toHaveBeenCalledWith(projectId); + expect(mockAssertCanManageProjectWorkspaceRuntimeServices).not.toHaveBeenCalled(); + } + + }, 15000); + it("rejects agent callers that create project execution workspace commands", async () => { const app = await createProjectApp({ type: "agent", diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index fb7f61e5a8a..ea9debea5bd 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -14,7 +14,7 @@ import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } fr import { trackProjectCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { projectService, logActivity, workspaceOperationService } from "../services/index.js"; -import { conflict } from "../errors.js"; +import { conflict, forbidden } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { buildWorkspaceRuntimeDesiredStatePatch, @@ -36,6 +36,7 @@ import { environmentService } from "../services/environments.js"; import { secretService } from "../services/secrets.js"; const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024; +const SHARED_WORKSPACE_STOP_AND_RESTART_ACTIONS = new Set(["stop", "restart"]); export function projectRoutes(db: Db) { const router = Router(); @@ -346,6 +347,15 @@ export function projectRoutes(db: Db) { return; } + const isSharedWorkspace = Boolean(workspace.sharedWorkspaceKey); + if ( + req.actor.type === "agent" + && isSharedWorkspace + && SHARED_WORKSPACE_STOP_AND_RESTART_ACTIONS.has(action) + ) { + throw forbidden("Missing permission to manage workspace runtime services"); + } + await assertCanManageProjectWorkspaceRuntimeServices(db, req, { companyId: project.companyId, projectWorkspaceId: workspace.id, @@ -549,9 +559,9 @@ export function projectRoutes(db: Db) { stderr, system: action === "stop" - ? "Stopped project workspace runtime services.\n" + ? "Stopped project workspace runtime services.\nThis does not pause issue work or held wake scheduling." : action === "restart" - ? "Restarted project workspace runtime services.\n" + ? "Restarted project workspace runtime services.\nThis does not pause issue work or held wake scheduling." : "Started project workspace runtime services.\n", metadata: { runtimeServiceCount, diff --git a/ui/src/pages/ProjectWorkspaceDetail.tsx b/ui/src/pages/ProjectWorkspaceDetail.tsx index 2245799c5da..cfb68265d3a 100644 --- a/ui/src/pages/ProjectWorkspaceDetail.tsx +++ b/ui/src/pages/ProjectWorkspaceDetail.tsx @@ -317,9 +317,9 @@ export function ProjectWorkspaceDetail() { request.action === "run" ? "Workspace job completed." : request.action === "stop" - ? "Workspace service stopped." + ? "Workspace service stopped. Issue execution is not paused." : request.action === "restart" - ? "Workspace service restarted." + ? "Workspace service restarted. Issue execution is not paused." : "Workspace service started.", ); },