From 711a29338dc039d9fe7578d539a672ac74339f5f Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 4 Jun 2026 23:18:24 +0000 Subject: [PATCH] fix: include run policy in low-trust self checks Co-Authored-By: Paperclip --- .../low-trust-red-team-routes.test.ts | 39 ++++++++++- .../workspace-runtime-service-authz.test.ts | 55 +++++++++++++++ server/src/routes/agents.ts | 50 +++++++++++--- .../routes/workspace-runtime-service-authz.ts | 69 +++++++++++++++---- 4 files changed, 191 insertions(+), 22 deletions(-) diff --git a/server/src/__tests__/low-trust-red-team-routes.test.ts b/server/src/__tests__/low-trust-red-team-routes.test.ts index 6b6d662da5c..58652f345a8 100644 --- a/server/src/__tests__/low-trust-red-team-routes.test.ts +++ b/server/src/__tests__/low-trust-red-team-routes.test.ts @@ -588,9 +588,46 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () => expect(lowTrustRes.body).not.toHaveProperty("access"); expectNoCanary(lowTrustRes.body, fixture.canaries.agentConfig); - const standardRes = await request(createApp(db, agentActor(fixture, fixture.agents.standard.id))).get("/api/agents/me"); + const standardActor = agentActor(fixture, fixture.agents.standard.id); + const standardRes = await request(createApp(db, { ...standardActor, runId: null })).get("/api/agents/me"); expect(standardRes.status, JSON.stringify(standardRes.body)).toBe(200); expect(JSON.stringify(standardRes.body)).toContain(fixture.canaries.agentConfig); + + const issueScopedLowTrustRes = await request(createApp(db, standardActor)).get("/api/agents/me"); + expect(issueScopedLowTrustRes.status, JSON.stringify(issueScopedLowTrustRes.body)).toBe(200); + expect(issueScopedLowTrustRes.body).toMatchObject({ + id: fixture.agents.standard.id, + companyId: fixture.company.id, + trustPreset: LOW_TRUST_REVIEW_PRESET, + }); + expect(issueScopedLowTrustRes.body).not.toHaveProperty("adapterConfig"); + expect(issueScopedLowTrustRes.body).not.toHaveProperty("runtimeConfig"); + expectNoCanary(issueScopedLowTrustRes.body, fixture.canaries.agentConfig); + + await db.update(issues).set({ executionPolicy: null }).where(eq(issues.id, fixture.issues.assignedReview.id)); + + await db.update(projects).set({ + executionWorkspacePolicy: { + authorizationPolicy: { + trustBoundary: { + mode: LOW_TRUST_REVIEW_PRESET, + companyId: fixture.company.id, + projectIds: [fixture.projects.allowed.id], + }, + }, + }, + }).where(eq(projects.id, fixture.projects.allowed.id)); + + const projectScopedLowTrustRes = await request(createApp(db, agentActor(fixture, fixture.agents.standard.id))).get("/api/agents/me"); + expect(projectScopedLowTrustRes.status, JSON.stringify(projectScopedLowTrustRes.body)).toBe(200); + expect(projectScopedLowTrustRes.body).toMatchObject({ + id: fixture.agents.standard.id, + companyId: fixture.company.id, + trustPreset: LOW_TRUST_REVIEW_PRESET, + }); + expect(projectScopedLowTrustRes.body).not.toHaveProperty("adapterConfig"); + expect(projectScopedLowTrustRes.body).not.toHaveProperty("runtimeConfig"); + expectNoCanary(projectScopedLowTrustRes.body, fixture.canaries.agentConfig); }); it("denies out-of-bound and control-plane attempts without leaking canaries or creating durable side effects", async () => { diff --git a/server/src/__tests__/workspace-runtime-service-authz.test.ts b/server/src/__tests__/workspace-runtime-service-authz.test.ts index a64e5dd8974..cd676660ae5 100644 --- a/server/src/__tests__/workspace-runtime-service-authz.test.ts +++ b/server/src/__tests__/workspace-runtime-service-authz.test.ts @@ -6,6 +6,7 @@ import { companies, createDb, executionWorkspaces, + heartbeatRuns, issues, projectWorkspaces, projects, @@ -43,6 +44,7 @@ describeEmbeddedPostgres("workspace runtime service authz helper", () => { await db.delete(executionWorkspaces); await db.delete(projectWorkspaces); await db.delete(projects); + await db.delete(heartbeatRuns); await db.delete(agents); await db.delete(companies); }); @@ -239,6 +241,59 @@ describeEmbeddedPostgres("workspace runtime service authz helper", () => { }); }); + it("rejects runtime service mutations when only the run policy is low-trust without runtime.manage", async () => { + const companyId = await seedCompany(); + const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId); + const ceoAgentId = await seedAgent(companyId, { role: "ceo", name: "CEO" }); + const issueId = randomUUID(); + const runId = randomUUID(); + + await db.insert(issues).values({ + id: issueId, + companyId, + projectId, + projectWorkspaceId, + title: "Run-scoped low-trust workspace", + status: "in_progress", + priority: "medium", + assigneeAgentId: ceoAgentId, + }); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId: ceoAgentId, + status: "running", + contextSnapshot: { + issueId, + executionPolicy: { + authorizationPolicy: { + trustBoundary: { + mode: LOW_TRUST_REVIEW_PRESET, + companyId, + projectIds: [projectId], + }, + }, + }, + }, + }); + + await expect(assertCanManageProjectWorkspaceRuntimeServices(db, { + actor: { + type: "agent", + agentId: ceoAgentId, + companyId, + runId, + 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/agents.ts b/server/src/routes/agents.ts index 9bbe5e43b30..3cc21c50b82 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2,7 +2,7 @@ import { Router, type Request, type Response } from "express"; import { generateKeyPairSync, randomUUID } from "node:crypto"; import path from "node:path"; import type { Db } from "@paperclipai/db"; -import { agents as agentsTable, companies, heartbeatRuns, issues as issuesTable } from "@paperclipai/db"; +import { agents as agentsTable, companies, heartbeatRuns, issues as issuesTable, projects as projectsTable } from "@paperclipai/db"; import { and, desc, eq, inArray, not, sql } from "drizzle-orm"; import { agentSkillSyncSchema, @@ -118,6 +118,20 @@ function readLiveRunsQueryInt(value: unknown, max: number, fallback = 0) { return Math.min(max, Math.trunc(parsed)); } +function readObject(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : null; +} + +function readRunIssueId(context: Record | null) { + const directIssueId = context?.issueId; + if (typeof directIssueId === "string" && isUuidLike(directIssueId)) return directIssueId; + const paperclipIssue = readObject(context?.paperclipIssue); + const nestedIssueId = paperclipIssue?.id; + return typeof nestedIssueId === "string" && isUuidLike(nestedIssueId) ? nestedIssueId : null; +} + export function agentRoutes( db: Db, options: { pluginWorkerManager?: PluginWorkerManager } = {}, @@ -577,18 +591,38 @@ export function agentRoutes( .where(and(eq(heartbeatRuns.id, req.actor.runId), eq(heartbeatRuns.companyId, agent.companyId))) .then((rows) => rows[0] ?? null) : null; - const runContext = run?.agentId === agent.id && run.contextSnapshot && typeof run.contextSnapshot === "object" - ? run.contextSnapshot as Record - : null; - const runExecutionPolicy = runContext?.executionPolicy && typeof runContext.executionPolicy === "object" - ? runContext.executionPolicy as Record + const runContext = run?.agentId === agent.id ? readObject(run.contextSnapshot) : null; + const runExecutionPolicy = readObject(runContext?.executionPolicy); + const runIssueId = readRunIssueId(runContext); + const runScopedIssue = runIssueId + ? await db + .select({ + companyId: issuesTable.companyId, + projectId: issuesTable.projectId, + executionPolicy: issuesTable.executionPolicy, + projectExecutionWorkspacePolicy: projectsTable.executionWorkspacePolicy, + }) + .from(issuesTable) + .leftJoin(projectsTable, and(eq(projectsTable.id, issuesTable.projectId), eq(projectsTable.companyId, issuesTable.companyId))) + .where(and(eq(issuesTable.id, runIssueId), eq(issuesTable.companyId, agent.companyId))) + .then((rows) => rows[0] ?? null) : null; return resolveCoreTrustPreset({ companyId: agent.companyId, agent, - project: null, - issue: null, + project: runScopedIssue?.projectId + ? { + companyId: runScopedIssue.companyId, + executionWorkspacePolicy: runScopedIssue.projectExecutionWorkspacePolicy, + } + : null, + issue: runScopedIssue + ? { + companyId: runScopedIssue.companyId, + executionPolicy: runScopedIssue.executionPolicy, + } + : null, run: runExecutionPolicy ? { companyId: agent.companyId, executionPolicy: runExecutionPolicy } : null, }); } diff --git a/server/src/routes/workspace-runtime-service-authz.ts b/server/src/routes/workspace-runtime-service-authz.ts index 11975982e21..3258f43416c 100644 --- a/server/src/routes/workspace-runtime-service-authz.ts +++ b/server/src/routes/workspace-runtime-service-authz.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm"; +import { and, eq, inArray, isNull, ne, or } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, heartbeatRuns, issues, projects } from "@paperclipai/db"; import type { Request } from "express"; @@ -16,6 +16,20 @@ const WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES: string[] = [ "blocked", ]; +function readObject(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : null; +} + +function readRunIssueId(context: Record | null) { + const directIssueId = context?.issueId; + if (typeof directIssueId === "string" && directIssueId.length > 0) return directIssueId; + const paperclipIssue = readObject(context?.paperclipIssue); + const nestedIssueId = paperclipIssue?.id; + return typeof nestedIssueId === "string" && nestedIssueId.length > 0 ? nestedIssueId : null; +} + async function listReportingSubtreeAgentIds(db: Db, companyId: string, actorAgentId: string) { const companyAgents = await db .select({ @@ -78,12 +92,32 @@ async function assertAgentCanManageRuntimeServicesForWorkspace( throw forbidden("Agent key cannot access another company"); } + const actorRun = req.actor.runId + ? await db + .select({ + companyId: heartbeatRuns.companyId, + agentId: heartbeatRuns.agentId, + contextSnapshot: heartbeatRuns.contextSnapshot, + }) + .from(heartbeatRuns) + .where(and( + eq(heartbeatRuns.id, req.actor.runId), + eq(heartbeatRuns.companyId, input.companyId), + eq(heartbeatRuns.agentId, actorAgent.id), + )) + .then((rows) => rows[0] ?? null) + : null; + const runContext = readObject(actorRun?.contextSnapshot); + const runExecutionPolicy = readObject(runContext?.executionPolicy); + assertLowTrustCanManageRuntimeForActor({ companyId: input.companyId, actorAgent, + runExecutionPolicy, }); - const runScopedIssue = req.actor.runId + const runIssueId = readRunIssueId(runContext); + const runScopedIssue = runIssueId ? await db .select({ id: issues.id, @@ -92,19 +126,11 @@ async function assertAgentCanManageRuntimeServicesForWorkspace( executionPolicy: issues.executionPolicy, projectExecutionWorkspacePolicy: projects.executionWorkspacePolicy, }) - .from(heartbeatRuns) - .innerJoin( - issues, - and( - eq(issues.companyId, heartbeatRuns.companyId), - eq(issues.id, sql`coalesce(${heartbeatRuns.contextSnapshot}->>'issueId', ${heartbeatRuns.contextSnapshot}->'paperclipIssue'->>'id')::uuid`), - ), - ) + .from(issues) .leftJoin(projects, and(eq(projects.id, issues.projectId), eq(projects.companyId, issues.companyId))) .where(and( - eq(heartbeatRuns.id, req.actor.runId), - eq(heartbeatRuns.companyId, input.companyId), - eq(heartbeatRuns.agentId, actorAgent.id), + eq(issues.id, runIssueId), + eq(issues.companyId, input.companyId), )) .then((rows) => rows[0] ?? null) : null; @@ -114,6 +140,7 @@ async function assertAgentCanManageRuntimeServicesForWorkspace( actorAgent, issue: runScopedIssue, projectExecutionWorkspacePolicy: runScopedIssue.projectExecutionWorkspacePolicy, + runExecutionPolicy, }); } @@ -153,6 +180,7 @@ async function assertAgentCanManageRuntimeServicesForWorkspace( actorAgent, issue: linkedScopeIssue, projectExecutionWorkspacePolicy: linkedScopeIssue.projectExecutionWorkspacePolicy, + runExecutionPolicy, }); } @@ -185,6 +213,7 @@ async function assertAgentCanManageRuntimeServicesForWorkspace( actorAgent, issue: linkedIssue, projectExecutionWorkspacePolicy: linkedIssue.projectExecutionWorkspacePolicy, + runExecutionPolicy, }); return; } @@ -199,6 +228,7 @@ function assertLowTrustCanManageRuntimeForActor(input: { companyId: string; permissions: unknown; }; + runExecutionPolicy?: unknown; }) { const resolution = resolveCoreTrustPreset({ companyId: input.companyId, @@ -206,6 +236,12 @@ function assertLowTrustCanManageRuntimeForActor(input: { companyId: input.actorAgent.companyId, permissions: input.actorAgent.permissions, }, + run: input.runExecutionPolicy + ? { + companyId: input.companyId, + executionPolicy: input.runExecutionPolicy, + } + : null, }); if (resolution.kind === "denied") { throw forbidden(`Low-trust runtime service access denied: ${resolution.detail}`); @@ -228,6 +264,7 @@ function assertLowTrustCanManageRuntimeForIssue(input: { executionPolicy: unknown; }; projectExecutionWorkspacePolicy: unknown; + runExecutionPolicy?: unknown; }) { const resolution = resolveCoreTrustPreset({ companyId: input.issue.companyId, @@ -245,6 +282,12 @@ function assertLowTrustCanManageRuntimeForIssue(input: { companyId: input.issue.companyId, executionPolicy: input.issue.executionPolicy, }, + run: input.runExecutionPolicy + ? { + companyId: input.issue.companyId, + executionPolicy: input.runExecutionPolicy, + } + : null, }); if (resolution.kind === "denied") { throw forbidden(`Low-trust runtime service access denied: ${resolution.detail}`);