mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
fix: align low-trust workspace boundary ancestry
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
af9e2503f6
commit
3d2cf8fa71
4 files changed
+79
-3
No files matched your search
@@ -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", () => {
|
||||
|
||||
@@ -1039,6 +1039,7 @@ export function issueRoutes(
|
||||
issue: { id: string; companyId: string; projectId?: string | null; executionPolicy?: unknown },
|
||||
actor: ReturnType<typeof getActorInfo>,
|
||||
) {
|
||||
// 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;
|
||||
|
||||
@@ -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<TWorkspace>(input: {
|
||||
db?: Db;
|
||||
trustPreset: TrustPresetResolution;
|
||||
isolatedWorkspacesEnabled: boolean;
|
||||
effectiveExecutionWorkspaceMode: string | null | undefined;
|
||||
@@ -810,6 +813,7 @@ export async function resolveWorkspaceAfterLowTrustPreflight<TWorkspace>(input:
|
||||
resolveWorkspace: () => Promise<TWorkspace>;
|
||||
}): 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,
|
||||
|
||||
@@ -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<TrustPresetResolution, { kind: "low_trust_review" }>["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",
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user