From a5f2d64e2717ad0677a02923d31381752cd12177 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 4 Jun 2026 22:30:25 +0000 Subject: [PATCH] fix: enforce low-trust runtime issue context Co-Authored-By: Paperclip --- .../workspace-runtime-service-authz.test.ts | 41 ++++++++++++++++++ .../routes/workspace-runtime-service-authz.ts | 43 +++++++++++++++---- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/server/src/__tests__/workspace-runtime-service-authz.test.ts b/server/src/__tests__/workspace-runtime-service-authz.test.ts index 2537d34b093..a64e5dd8974 100644 --- a/server/src/__tests__/workspace-runtime-service-authz.test.ts +++ b/server/src/__tests__/workspace-runtime-service-authz.test.ts @@ -198,6 +198,47 @@ describeEmbeddedPostgres("workspace runtime service authz helper", () => { }); }); + it("rejects CEO runtime service mutations for low-trust workspace issues without runtime.manage", async () => { + const companyId = await seedCompany(); + const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId); + const ceoAgentId = await seedAgent(companyId, { role: "ceo", name: "CEO" }); + + await db.insert(issues).values({ + id: randomUUID(), + companyId, + projectId, + projectWorkspaceId, + title: "Issue-scoped low-trust workspace", + status: "todo", + priority: "medium", + assigneeAgentId: ceoAgentId, + executionPolicy: { + authorizationPolicy: { + trustBoundary: { + mode: LOW_TRUST_REVIEW_PRESET, + companyId, + projectIds: [projectId], + }, + }, + }, + }); + + await expect(assertCanManageProjectWorkspaceRuntimeServices(db, { + actor: { + type: "agent", + agentId: ceoAgentId, + companyId, + source: "agent_key", + }, + } as any, { + companyId, + projectWorkspaceId, + })).rejects.toMatchObject({ + status: 403, + message: "Low-trust runs cannot manage workspace runtime services unless the boundary grants runtime.manage", + }); + }); + it("allows agents with a non-terminal assigned issue in the target project workspace", async () => { const companyId = await seedCompany(); const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId); diff --git a/server/src/routes/workspace-runtime-service-authz.ts b/server/src/routes/workspace-runtime-service-authz.ts index 39559fe271b..11975982e21 100644 --- a/server/src/routes/workspace-runtime-service-authz.ts +++ b/server/src/routes/workspace-runtime-service-authz.ts @@ -117,11 +117,6 @@ async function assertAgentCanManageRuntimeServicesForWorkspace( }); } - if (actorAgent.role === "ceo") { - return; - } - - const eligibleAgentIds = await listReportingSubtreeAgentIds(db, input.companyId, actorAgent.id); const workspaceScopeConditions = [ input.projectWorkspaceId ? eq(issues.projectWorkspaceId, input.projectWorkspaceId) : null, input.executionWorkspaceId ? eq(issues.executionWorkspaceId, input.executionWorkspaceId) : null, @@ -132,6 +127,40 @@ async function assertAgentCanManageRuntimeServicesForWorkspace( throw forbidden("Missing permission to manage workspace runtime services"); } + const workspaceScopeCondition = workspaceScopeConditions.length === 1 + ? workspaceScopeConditions[0]! + : or(...workspaceScopeConditions); + + const linkedScopeIssues = await db + .select({ + id: issues.id, + companyId: issues.companyId, + projectId: issues.projectId, + executionPolicy: issues.executionPolicy, + projectExecutionWorkspacePolicy: projects.executionWorkspacePolicy, + }) + .from(issues) + .leftJoin(projects, and(eq(projects.id, issues.projectId), eq(projects.companyId, issues.companyId))) + .where(and( + eq(issues.companyId, input.companyId), + isNull(issues.hiddenAt), + inArray(issues.status, WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES), + workspaceScopeCondition, + )); + + for (const linkedScopeIssue of linkedScopeIssues) { + assertLowTrustCanManageRuntimeForIssue({ + actorAgent, + issue: linkedScopeIssue, + projectExecutionWorkspacePolicy: linkedScopeIssue.projectExecutionWorkspacePolicy, + }); + } + + if (actorAgent.role === "ceo") { + return; + } + + const eligibleAgentIds = await listReportingSubtreeAgentIds(db, input.companyId, actorAgent.id); const linkedIssue = await db .select({ id: issues.id, @@ -147,9 +176,7 @@ async function assertAgentCanManageRuntimeServicesForWorkspace( isNull(issues.hiddenAt), inArray(issues.status, WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES), inArray(issues.assigneeAgentId, eligibleAgentIds), - workspaceScopeConditions.length === 1 - ? workspaceScopeConditions[0]! - : or(...workspaceScopeConditions), + workspaceScopeCondition, )) .then((rows) => rows[0] ?? null);