From 3d2cf8fa7142b0afa23d09cf82b1124dc8e6285c Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 5 Jun 2026 00:05:59 +0000 Subject: [PATCH] fix: align low-trust workspace boundary ancestry Co-Authored-By: Paperclip --- .../heartbeat-workspace-session.test.ts | 32 ++++++++++++++ server/src/routes/issues.ts | 1 + server/src/services/heartbeat.ts | 7 +++- .../services/low-trust-runtime-containment.ts | 42 ++++++++++++++++++- 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 967f071663c..d6be8c26ccd 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -111,6 +111,20 @@ function standardTrustResolution(): TrustPresetResolution { }; } +function buildIssueAncestryDb(rows: Array<{ id: string; companyId: string; parentId: string | null }>) { + const queue = [...rows]; + return { + select: () => ({ + from: () => ({ + where: () => { + const row = queue.shift(); + return Promise.resolve(row ? [row] : []); + }, + }), + }), + }; +} + describe("stripHostWorkspaceProvisionForLowTrustSandbox", () => { it("removes only the host-side provision command for sandbox-backed low-trust runs", () => { const config = { @@ -212,6 +226,24 @@ describe("preflightLowTrustWorkspaceIsolation", () => { resolveSelectedEnvironmentDriver: async () => "sandbox", })).resolves.toBe("sandbox"); }); + + it("allows child issues inside a rootIssueId low-trust boundary during workspace preflight", async () => { + await expect(preflightLowTrustWorkspaceIsolation({ + db: buildIssueAncestryDb([ + { id: "issue-child", companyId: "company-1", parentId: "issue-1" }, + { id: "issue-1", companyId: "company-1", parentId: null }, + ]) as any, + trustPreset: lowTrustResolution(), + isolatedWorkspacesEnabled: true, + effectiveExecutionWorkspaceMode: "isolated_workspace", + issue: { + companyId: "company-1", + id: "issue-child", + projectId: null, + }, + resolveSelectedEnvironmentDriver: async () => "sandbox", + })).resolves.toBe("sandbox"); + }); }); describe("resolveWorkspaceAfterLowTrustPreflight", () => { diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 0350027a6a6..7cc79c73d03 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1039,6 +1039,7 @@ export function issueRoutes( issue: { id: string; companyId: string; projectId?: string | null; executionPolicy?: unknown }, actor: ReturnType, ) { + // Board users are trusted reviewers and intentionally receive raw quarantined output for promotion decisions. if (actor.actorType !== "agent") return false; const sourceTrust = await sourceTrustForActorWrite(issue, actor); return !sourceTrust; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 780cdb7b70b..da106d4fc89 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -775,6 +775,7 @@ export function stripHostWorkspaceProvisionForLowTrustSandbox(input: { } export async function preflightLowTrustWorkspaceIsolation(input: { + db?: Db; trustPreset: TrustPresetResolution; isolatedWorkspacesEnabled: boolean; effectiveExecutionWorkspaceMode: string | null | undefined; @@ -790,7 +791,8 @@ export async function preflightLowTrustWorkspaceIsolation(input: { ? await input.resolveSelectedEnvironmentDriver() : null; - assertLowTrustWorkspaceIsolation({ + await assertLowTrustWorkspaceIsolation({ + db: input.db, resolution: input.trustPreset, isolatedWorkspacesEnabled: input.isolatedWorkspacesEnabled, effectiveExecutionWorkspaceMode: input.effectiveExecutionWorkspaceMode, @@ -802,6 +804,7 @@ export async function preflightLowTrustWorkspaceIsolation(input: { } export async function resolveWorkspaceAfterLowTrustPreflight(input: { + db?: Db; trustPreset: TrustPresetResolution; isolatedWorkspacesEnabled: boolean; effectiveExecutionWorkspaceMode: string | null | undefined; @@ -810,6 +813,7 @@ export async function resolveWorkspaceAfterLowTrustPreflight(input: resolveWorkspace: () => Promise; }): Promise<{ selectedEnvironmentDriver: string | null; workspace: TWorkspace }> { const selectedEnvironmentDriver = await preflightLowTrustWorkspaceIsolation({ + db: input.db, trustPreset: input.trustPreset, isolatedWorkspacesEnabled: input.isolatedWorkspacesEnabled, effectiveExecutionWorkspaceMode: input.effectiveExecutionWorkspaceMode, @@ -7666,6 +7670,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) selectedEnvironmentDriver: lowTrustPreflightEnvironmentDriver, workspace: resolvedWorkspace, } = await resolveWorkspaceAfterLowTrustPreflight({ + db, trustPreset, isolatedWorkspacesEnabled, effectiveExecutionWorkspaceMode, diff --git a/server/src/services/low-trust-runtime-containment.ts b/server/src/services/low-trust-runtime-containment.ts index a6873bfb7c4..a1013e8f830 100644 --- a/server/src/services/low-trust-runtime-containment.ts +++ b/server/src/services/low-trust-runtime-containment.ts @@ -1,15 +1,46 @@ +import { eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { issues } from "@paperclipai/db"; import { unprocessable } from "../errors.js"; import type { TrustPresetResolution } from "./trust-preset-resolver.js"; import { isIssueWithinLowTrustBoundary } from "./trust-preset-resolver.js"; export const LOW_TRUST_RUNTIME_MANAGEMENT_TOOL_CLASS = "runtime.manage"; +const LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH = 12; export function isLowTrustRuntimeManagementAllowed(resolution: TrustPresetResolution) { return resolution.kind === "low_trust_review" && (resolution.boundary.allowedToolClasses ?? []).includes(LOW_TRUST_RUNTIME_MANAGEMENT_TOOL_CLASS); } -export function assertLowTrustWorkspaceIsolation(input: { +async function issueIdIsDescendantOf(db: Db, issueId: string, rootIssueId: string, companyId: string) { + let cursor: string | null = issueId; + // Keep the runtime preflight aligned with authorization while bounding DB work. + for (let depth = 0; cursor && depth < LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH; depth += 1) { + if (cursor === rootIssueId) return true; + const row: { id: string; companyId: string; parentId: string | null } | null = await db + .select({ id: issues.id, companyId: issues.companyId, parentId: issues.parentId }) + .from(issues) + .where(eq(issues.id, cursor)) + .then((rows) => rows[0] ?? null); + if (!row || row.companyId !== companyId) return false; + cursor = row.parentId; + } + return false; +} + +async function workspaceIssueWithinLowTrustBoundary(input: { + db?: Db; + boundary: Extract["boundary"]; + issue: { companyId: string; id?: string | null; projectId?: string | null }; +}) { + if (isIssueWithinLowTrustBoundary(input.boundary, input.issue)) return true; + if (!input.db || !input.issue.id || !input.boundary.rootIssueId) return false; + return issueIdIsDescendantOf(input.db, input.issue.id, input.boundary.rootIssueId, input.boundary.companyId); +} + +export async function assertLowTrustWorkspaceIsolation(input: { + db?: Db; resolution: TrustPresetResolution; isolatedWorkspacesEnabled: boolean; effectiveExecutionWorkspaceMode: string | null | undefined; @@ -34,7 +65,14 @@ export function assertLowTrustWorkspaceIsolation(input: { code: "low_trust_requires_isolated_workspace", }); } - if (!input.issue || !isIssueWithinLowTrustBoundary(input.resolution.boundary, input.issue)) { + if ( + !input.issue || + !(await workspaceIssueWithinLowTrustBoundary({ + db: input.db, + boundary: input.resolution.boundary, + issue: input.issue, + })) + ) { throw unprocessable("Low-trust execution issue is outside the active trust boundary.", { code: "low_trust_boundary_mismatch", });