fix(workspaces): validate prospective child source trust

Pass server-owned creation context before the child is saved. Validate the
creator run, sponsor and parent ancestry against the immutable boundary,
then verify current parent and prospective child policies. Preserve exact
saved task identity for ordinary selection and writes.

Add real database and authenticated HTTP regressions, including omitted
client policy, and update the plan and paired workspace maps.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-12 18:27:03 -05:00
1 parent bc6e216629
commit 17c9b1fd62
8 files changed
+160 -9

No files matched your search

@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { agents, executionWorkspaceRepositories, executionWorkspaces, issues, heartbeatRuns, projects, projectWorkspaces, principalPermissionGrants, companyMemberships } from "@paperclipai/db";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js";
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
@@ -14,6 +15,30 @@ const support = await getEmbeddedPostgresTestSupport();
const originalSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
beforeAll(async () => { process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID(); server = await startRunnerApiTestServer(); }, 60_000);
afterAll(async () => { await server?.close(); if (originalSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET; else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalSecret; });
it("creates an explicit-workspace child through the authenticated low-trust API without repeating its saved policy", async () => {
const f = await server.fixture({ connectionScenario: "fresh", disableWakeOnDemand: true });
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustPreset: "low_trust_review",
trustBoundary: { mode: "low_trust_review", companyId: f.companyId, rootIssueId: f.issueId, projectIds: [f.projectId] } } };
const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: f.projectId,
projectWorkspaceId: f.projectWorkspaceId, sourceIssueId: f.issueId, name: "Review files", cwd: f.workspace,
mode: "isolated_workspace", strategyType: "task_directory" }).returning();
await server.db.update(projects).set({ executionWorkspacePolicy: policy }).where(eq(projects.id, f.projectId));
await server.db.update(issues).set({ executionPolicy: policy, executionWorkspaceId: workspace.id }).where(eq(issues.id, f.issueId));
await server.db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: policy } }).where(eq(heartbeatRuns.id, f.runId));
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.userId);
const create = (parentId: string) => fetch(`${server.apiUrl}/api/companies/${f.companyId}/issues`, {
method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ title: `Review child ${randomUUID()}`, parentId, projectId: f.projectId,
assigneeAgentId: f.agentId, status: "backlog", workspaceSelection: { kind: "existing", workspaceId: workspace.id } }),
});
const response = await create(f.issueId), body = await response.json();
expect({ status: response.status, body }).toMatchObject({ status: 201, body: {
parentId: f.issueId, executionWorkspaceId: workspace.id,
executionPolicy: { authorizationPolicy: { trustBoundary: policy.authorizationPolicy.trustBoundary } },
} });
const denied = await create(f.blockerId);
expect(denied.status).toBe(403);
});
it("inspects an idle admitted workspace, requests a repository, and stages root selection without interrupting", async () => {
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: null, sourceIssueId: f.issueId, name: "Task files", cwd: f.workspace, mode: "shared_workspace", strategyType: "task_directory", status: "idle" }).returning();
@@ -0,0 +1,81 @@
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { agents, authUsers, companyMemberships, createDb, executionWorkspaces, heartbeatRuns, issues, projects, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { executionWorkspaceService } from "../services/execution-workspaces.js";
import { issueService } from "../services/issues.js";
import { createRepositoryTrustFixture } from "./helpers/repository-trust-fixture.js";
import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("child task workspace trust", () => {
const context = createRepositoryTrustFixture({ managedAuth: vi.fn(), materialize: vi.fn(), catalog: vi.fn() });
let db: ReturnType<typeof createDb>;
beforeAll(async () => { ({ db } = await context.setup()); }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterAll(context.cleanup);
async function fixture() {
const f = await context.fixture();
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Review source" }).returning();
const policy = { ...f.policy, authorizationPolicy: { ...f.policy.authorizationPolicy,
trustBoundary: { ...f.policy.authorizationPolicy.trustBoundary, projectIds: [project.id] } } };
await db.update(projects).set({ executionWorkspacePolicy: policy }).where(eq(projects.id, project.id));
await db.update(executionWorkspaces).set({ projectId: project.id }).where(eq(executionWorkspaces.id, f.workspace.id));
await db.update(issues).set({ executionPolicy: policy }).where(eq(issues.id, f.issueId));
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: policy } }).where(eq(heartbeatRuns.id, f.runId));
const input = { title: `Child ${randomUUID()}`, parentId: f.issueId, assigneeAgentId: f.agentId,
executionPolicy: policy, createdByAgentId: f.agentId, workspaceSelectionActor: f.actor,
workspaceSelection: { kind: "existing" as const, workspaceId: f.workspace.id } };
return { ...f, project, policy, input };
}
it.each(["direct", "descendant", "omitted_policy"] as const)("creates a matching low-trust child with an explicit existing workspace (%s)", async scenario => {
const f = await fixture();
if (scenario === "descendant") {
const [parent] = await db.insert(issues).values({ companyId: f.companyId, parentId: f.issueId,
title: "Intermediate task", assigneeAgentId: f.agentId, executionPolicy: f.policy }).returning();
f.input.parentId = parent.id;
}
const input = scenario === "omitted_policy" ? { ...f.input, executionPolicy: undefined } : f.input;
await expect(issueService(db).create(f.companyId, input)).resolves.toMatchObject({
parentId: f.input.parentId, executionWorkspaceId: f.workspace.id,
});
});
it.each(["child_policy", "unrelated_parent", "missing_parent", "missing_run", "cross_company_run", "sponsor", "live_parent_policy", "absent_saved_policy", "child_agent_policy"] as const)(
"rejects incompatible child creation (%s)", async scenario => {
const f = await fixture();
const different = { ...f.policy, authorizationPolicy: { ...f.policy.authorizationPolicy,
trustBoundary: { ...f.policy.authorizationPolicy.trustBoundary, rootIssueId: randomUUID() } } };
if (scenario === "child_policy") f.input.executionPolicy = different;
if (scenario === "unrelated_parent") {
const [parent] = await db.insert(issues).values({ companyId: f.companyId, title: "Other root" }).returning();
f.input.parentId = parent.id;
}
if (scenario === "missing_parent") f.input.parentId = randomUUID();
if (scenario === "missing_run") f.input.workspaceSelectionActor = { ...f.actor, runId: randomUUID() };
if (scenario === "cross_company_run") f.input.workspaceSelectionActor = { ...f.actor, runId: (await fixture()).runId };
if (scenario === "sponsor") {
const id = randomUUID();
await db.insert(authUsers).values({ id, name: "Other operator", email: `${id}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: id, membershipRole: "operator", status: "active" });
f.input.workspaceSelectionActor = { ...f.actor, onBehalfOfUserId: id };
}
if (scenario === "live_parent_policy") await db.update(issues).set({ executionPolicy: different }).where(eq(issues.id, f.issueId));
if (scenario === "absent_saved_policy") await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId } }).where(eq(heartbeatRuns.id, f.runId));
if (scenario === "child_agent_policy") {
const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "Other boundary", adapterType: "process", permissions: different }).returning();
f.input.assigneeAgentId = agent.id;
}
const before = await db.select().from(issues).where(eq(issues.companyId, f.companyId));
await expect(issueService(db).create(f.companyId, f.input)).rejects.toThrow();
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toEqual(before);
},
);
it("does not infer child creation from an arbitrary issue-less workspace selection", async () => {
const f = await fixture();
await expect(executionWorkspaceService(db).validateSelection({ companyId: f.companyId, actor: f.actor,
selection: f.input.workspaceSelection, parentIssueId: f.issueId, executionPolicy: f.policy,
assigneeAgentId: f.agentId })).rejects.toThrow(/Physical workspace source trust/);
});
});
@@ -12,7 +12,7 @@ import { conflict, forbidden, notFound } from "../errors.js";
import { parseNativeExecutionInput } from "../vendor/paperclip-runner/index.js";
import { assertTaskWorkspaceAccess, assertTaskWorkspaceSourceProjectAccess } from "./task-workspace-source-access.js";
import { authorizationService, canActorReadExecutionWorkspace, executionWorkspaceReadSqlCondition, issueReadSqlCondition, projectReadSqlCondition, type AuthorizationActor } from "./authorization.js";
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
import { LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH, resolveCoreTrustPreset } from "./trust-preset-resolver.js";
import { taskWorkspaceSelectableCondition } from "./task-workspace-selection.js";
import { captureRunIdentity } from "./run-identity.js";
import { toolAccessService } from "./tool-access.js";
@@ -370,9 +370,13 @@ export async function retainIsolatedWorkspaceSourceAccess(db: Db, input: {
if (denied) throw denied;
}
export type TaskWorkspaceCreationTrustContext = {
parentIssueId?: string | null; projectId?: string | null; assigneeAgentId?: string | null; executionPolicy?: unknown;
};
async function assertRootProjectTrust(db: Db | DbTransaction, input: {
companyId: string; projectIds: Set<string>; actor: AuthorizationActor;
issueId?: string | null; sourceWorkspaceId?: string; assigneeAgentId?: string | null;
issueId?: string | null; sourceWorkspaceId?: string; assigneeAgentId?: string | null; taskCreation?: TaskWorkspaceCreationTrustContext;
}) {
if (!input.projectIds.size) return;
const owners = await db.select().from(projects).where(and(eq(projects.companyId, input.companyId),
@@ -380,15 +384,18 @@ async function assertRootProjectTrust(db: Db | DbTransaction, input: {
const restricted = owners.filter(owner => resolveCoreTrustPreset({ companyId: input.companyId, workspaceSourceProject: owner }).kind !== "standard");
if (!restricted.length) return;
const incompatible = () => forbidden("Physical workspace source trust is incompatible with the task's execution policy");
const [issue] = input.issueId ? await db.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId))) : [];
const runId = input.actor.runId;
const [run] = runId ? await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, input.companyId))) : [];
const creation = !input.issueId && input.actor.type === "agent" && runId ? input.taskCreation : undefined;
const runIssueId = run?.nativeIssueId ?? run?.contextSnapshot?.issueId ?? run?.contextSnapshot?.taskId ?? run?.issueId;
const issueId = input.issueId ?? (creation && typeof runIssueId === "string" ? runIssueId : null);
const [issue] = issueId ? await db.select().from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId))) : [];
const agentId = input.actor.type === "agent" ? input.actor.agentId : issue?.assigneeAgentId ?? input.assigneeAgentId;
const [agent] = agentId ? await db.select().from(agents).where(and(eq(agents.id, agentId), eq(agents.companyId, input.companyId))) : [];
const [project] = issue?.projectId ? await db.select().from(projects).where(and(eq(projects.id, issue.projectId), eq(projects.companyId, input.companyId))) : [];
const workspaceId = input.sourceWorkspaceId ?? issue?.executionWorkspaceId;
const [workspace] = workspaceId ? await db.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, workspaceId), eq(executionWorkspaces.companyId, input.companyId))) : [];
const [source] = workspace?.projectId ? await db.select().from(projects).where(and(eq(projects.id, workspace.projectId), eq(projects.companyId, input.companyId))) : [];
const runId = input.actor.runId;
const [run] = runId ? await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, input.companyId))) : [];
if ((agentId && !agent) || (runId && (!run || run.agentId !== agentId || !issue
|| (run.nativeIssueId ?? run.contextSnapshot?.issueId ?? run.contextSnapshot?.taskId ?? run.issueId) !== issue.id))) throw incompatible();
const retainedPolicy = run?.contextSnapshot?.executionPolicy;
@@ -399,6 +406,37 @@ async function assertRootProjectTrust(db: Db | DbTransaction, input: {
: current;
if (selected.kind !== "low_trust_review" || current.kind !== "low_trust_review"
|| !isDeepStrictEqual(selected.boundary, current.boundary)) throw incompatible();
if (creation) {
const sponsor = run!.responsibleUserId === "local-board" ? null : run!.responsibleUserId;
if ((input.actor.onBehalfOfUserId ?? null) !== sponsor) throw incompatible();
let parentId = creation.parentIssueId;
const ancestors: Array<typeof issues.$inferSelect> = [];
for (let depth = 0; parentId && depth < LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH; depth++) {
const [parent] = await db.select().from(issues).where(and(eq(issues.id, parentId), eq(issues.companyId, input.companyId)));
if (!parent || ancestors.some(ancestor => ancestor.id === parent.id)) throw incompatible();
ancestors.push(parent);
if (parent.id === issue!.id) break;
parentId = parent.parentId;
}
if (ancestors.at(-1)?.id !== issue!.id) throw incompatible();
const decision = await authorizationService(db).decide({ actor: input.actor, action: "issue:mutate",
resource: { type: "issue", companyId: input.companyId, parentIssueId: creation.parentIssueId,
projectId: creation.projectId, assigneeAgentId: creation.assigneeAgentId } });
if (!decision.allowed) throw incompatible();
const [childAgent] = creation.assigneeAgentId ? await db.select().from(agents).where(and(
eq(agents.id, creation.assigneeAgentId), eq(agents.companyId, input.companyId))) : [];
if (creation.assigneeAgentId && !childAgent) throw incompatible();
for (const candidate of [...ancestors, { companyId: input.companyId, projectId: creation.projectId,
executionPolicy: creation.executionPolicy }]) {
const [candidateProject] = candidate.projectId ? await db.select().from(projects).where(and(
eq(projects.id, candidate.projectId), eq(projects.companyId, input.companyId))) : [];
if (candidate.projectId && !candidateProject) throw incompatible();
const prospective = resolveCoreTrustPreset({ companyId: input.companyId,
agent: "id" in candidate ? agent : childAgent, project: candidateProject, workspaceSourceProject: source,
issue: candidate, run: { companyId: input.companyId, executionPolicy: retainedPolicy } });
if (prospective.kind !== "low_trust_review" || !isDeepStrictEqual(prospective.boundary, selected.boundary)) throw incompatible();
}
}
const selectedPolicy = { trustPreset: selected.preset, authorizationPolicy: {
trustPreset: selected.preset, trustBoundary: selected.boundary,
} };
@@ -410,7 +448,7 @@ async function assertRootProjectTrust(db: Db | DbTransaction, input: {
}
export async function assertExecutionWorkspaceRootWriteAccess(db: Db | DbTransaction, input: {
companyId: string; cwd: string; actor: AuthorizationActor; sourceWorkspaceId?: string;
companyId: string; cwd: string; actor: AuthorizationActor; sourceWorkspaceId?: string; taskCreation?: TaskWorkspaceCreationTrustContext;
} & Omit<NonNullable<Parameters<typeof assertTaskWorkspaceSourceProjectAccess>[4]>, "write">): Promise<void> {
const { sourceIds, sourceProjectIds, sharedProjectIds } = await resolveRepositoryRootSources(db, { ...input, inspectCurrentAliases: true });
for (const sourceId of sourceIds) await assertTaskWorkspaceAccess(db, input.actor, input.companyId, sourceId);
+3 -3
View File
@@ -3,7 +3,7 @@ import { parseObject } from "../adapters/utils.js";
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
import { instanceSettingsService } from "./instance-settings.js";
import { taskWorkspaceSelectableCondition } from "./task-workspace-selection.js";
import { executionWorkspaceRepositoryService, lockExecutionWorkspaceRootProvenance, preserveExecutionWorkspaceRootProvenance, assertExecutionWorkspaceRootWriteAccess, assertExecutionWorkspaceRootReadAccess, assertIsolatedWorkspaceSourceReadAccess, retainIsolatedWorkspaceSourceAccess, canonicalRepositoryRoot, type IsolatedWorkspaceSourceAccess } from "./execution-workspace-repositories.js";
import { executionWorkspaceRepositoryService, lockExecutionWorkspaceRootProvenance, preserveExecutionWorkspaceRootProvenance, assertExecutionWorkspaceRootWriteAccess, assertExecutionWorkspaceRootReadAccess, assertIsolatedWorkspaceSourceReadAccess, retainIsolatedWorkspaceSourceAccess, canonicalRepositoryRoot, type IsolatedWorkspaceSourceAccess, type TaskWorkspaceCreationTrustContext } from "./execution-workspace-repositories.js";
import { forbidden } from "../errors.js";
import { taskWorkspaceSelectionSchema, type TaskWorkspaceSelection, type TaskWorkspaceIntent } from "@paperclipai/shared";
import { authorizationService, executionWorkspaceReadSqlCondition, projectReadSqlCondition, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js";
@@ -1393,7 +1393,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
return resolveEffectiveWorkspaceStrategyType(mode, config);
}
async function validateSelection(input: { companyId: string; actor: AuthorizationActor; selection: TaskWorkspaceSelection; issueId?: string | null; parentIssueId?: string | null; assigneeAgentId?: string | null; assigneeUserId?: string | null; assigneeAdapterOverrides?: unknown; projectId?: string | null; executionPolicy?: unknown }, reader: Db | DbTransaction = db) {
async function validateSelection(input: { taskCreation?: TaskWorkspaceCreationTrustContext; companyId: string; actor: AuthorizationActor; selection: TaskWorkspaceSelection; issueId?: string | null; parentIssueId?: string | null; assigneeAgentId?: string | null; assigneeUserId?: string | null; assigneeAdapterOverrides?: unknown; projectId?: string | null; executionPolicy?: unknown }, reader: Db | DbTransaction = db) {
const selection = taskWorkspaceSelectionSchema.parse(input.selection);
if (selection.kind === "existing") {
const [workspace] = await reader.select().from(executionWorkspaces).where(and(
@@ -1404,7 +1404,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
if (!workspace) throw notFound("Workspace is unavailable or inaccessible");
await assertTaskWorkspaceAccess(reader, input.actor, input.companyId, workspace.id, { write: true, issueId: input.issueId, parentIssueId: input.parentIssueId, assigneeAgentId: input.assigneeAgentId, assigneeUserId: input.assigneeUserId });
if (workspace.cwd) await assertExecutionWorkspaceRootWriteAccess(reader, {
companyId: input.companyId, cwd: workspace.cwd, sourceWorkspaceId: workspace.id, actor: input.actor,
companyId: input.companyId, cwd: workspace.cwd, sourceWorkspaceId: workspace.id, actor: input.actor, taskCreation: input.taskCreation,
issueId: input.issueId, parentIssueId: input.parentIssueId, assigneeAgentId: input.assigneeAgentId, assigneeUserId: input.assigneeUserId,
});
return { executionWorkspaceId: workspace.id, projectWorkspaceId: workspace.projectWorkspaceId,
+2
View File
@@ -10169,6 +10169,8 @@ export function issueService(db: Db) {
const projection = await executionWorkspaceService(db).validateSelection({ companyId,
actor: workspaceActor,
selection: workspaceSelection, parentIssueId: issueData.parentId,
taskCreation: { parentIssueId: issueData.parentId, projectId: issueData.projectId,
assigneeAgentId: issueData.assigneeAgentId, executionPolicy: issueData.executionPolicy },
assigneeAgentId: issueData.assigneeAgentId, assigneeUserId: issueData.assigneeUserId, assigneeAdapterOverrides: issueData.assigneeAdapterOverrides, projectId: issueData.projectId, executionPolicy: issueData.executionPolicy }, tx);
projectWorkspaceId = projection.projectWorkspaceId;
executionWorkspaceId = projection.executionWorkspaceId;