From ab45588d1370abcd14f78fe00615b748585f86d6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 09:09:33 -0500 Subject: [PATCH] fix: retain task context in shared source authorization Co-Authored-By: Paperclip --- server/src/__tests__/task-workspace-binding.test.ts | 9 ++++++++- .../src/services/execution-workspace-repositories.ts | 10 +++++----- server/src/services/execution-workspaces.ts | 6 +++--- server/src/services/heartbeat.ts | 6 +++--- server/src/services/issues.ts | 5 +++-- server/src/services/task-workspace-source-access.ts | 12 ++++++++---- 6 files changed, 30 insertions(+), 18 deletions(-) diff --git a/server/src/__tests__/task-workspace-binding.test.ts b/server/src/__tests__/task-workspace-binding.test.ts index 283c030fdd5..25a3529befe 100644 --- a/server/src/__tests__/task-workspace-binding.test.ts +++ b/server/src/__tests__/task-workspace-binding.test.ts @@ -301,9 +301,16 @@ const support = await getEmbeddedPostgresTestSupport(); await expect(tasks.create(f.companyId, { title: "Denied legacy binding", createdByAgentId: agent.id, executionWorkspaceId: shared.id, workspaceSelectionActor: actor })).rejects.toThrow(/protected/); + const [organization] = await db.insert(projects).values({ companyId: f.companyId, name: "Task organization" }).returning(); + await db.update(issues).set({ projectId: organization.id }).where(eq(issues.id, f.issueId)); const [grant] = await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent", - principalId: agent.id, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: agent.id } }).returning(); + principalId: agent.id, permissionKey: "tasks:assign_scope", scope: { projectId: organization.id, assigneeAgentId: agent.id } }).returning(); const selectionRequest = { ...f, actor, selection: sharedSelection, expectedBindingRevision: 0, requestKey: "authorized-shared" }; + // Task lineage and organizational-project authority cannot grant writes + // to an independently protected source project's folder. + await expect(svc.selectTaskWorkspace(selectionRequest)).rejects.toThrow(/protected/); + await db.update(principalPermissionGrants).set({ scope: { projectId: project.id, assigneeAgentId: agent.id } }) + .where(eq(principalPermissionGrants.id, grant.id)); await expect(svc.selectTaskWorkspace(selectionRequest)).resolves.toMatchObject({ kind: "scheduled" }); expect(await tasks.create(f.companyId, { title: "Authorized shared source", createdByAgentId: agent.id, workspaceSelection: sharedSelection, workspaceSelectionActor: actor })).toMatchObject({ projectId: null, projectWorkspaceId: source.id }); diff --git a/server/src/services/execution-workspace-repositories.ts b/server/src/services/execution-workspace-repositories.ts index e0fe49ef445..dd8fdb93bc5 100644 --- a/server/src/services/execution-workspace-repositories.ts +++ b/server/src/services/execution-workspace-repositories.ts @@ -76,7 +76,7 @@ export function executionWorkspaceRepositoryService(db: Db) { // Serialize receipts across tasks without blocking unchanged-ID foreign-key reads. const [workspace] = await tx.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, issue.executionWorkspaceId), eq(executionWorkspaces.companyId, input.companyId))).for("no key update"); if (!workspace || workspace.status !== "active") throw conflict("Execution workspace is unavailable"); - await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, workspace.id, { write: true }); + await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, workspace.id, { write: true, issueId: input.issueId }); const existing = await tx.select().from(executionWorkspaceRepositories).where(and(eq(executionWorkspaceRepositories.executionWorkspaceId, workspace.id), or(eq(executionWorkspaceRepositories.repositoryIdentity, source.repositoryIdentity), sql`${executionWorkspaceRepositories.requestKeys} ? ${parsed.requestKey}`))); for (const row of existing) assertRepositoryRetryMatches(row, source.repositoryIdentity, requestedRef); if (existing[0]) { @@ -99,7 +99,7 @@ export function executionWorkspaceRepositoryService(db: Db) { } /** Logical rows may deliberately name one physical folder. Reuse bytes only * after validating every published owner's retained privacy and source intent. */ - async function sharedPublicationPin(input: { row: RepositoryRow; root: string; actor: AuthorizationActor; ownership: { repositoryId?: string; pinnedCommit?: string } | null }) { + async function sharedPublicationPin(input: { issueId: string; row: RepositoryRow; root: string; actor: AuthorizationActor; ownership: { repositoryId?: string; pinnedCommit?: string } | null }) { const candidates = await db.select({ repository: executionWorkspaceRepositories, workspace: executionWorkspaces }) .from(executionWorkspaceRepositories).innerJoin(executionWorkspaces, eq(executionWorkspaces.id, executionWorkspaceRepositories.executionWorkspaceId)) .where(and(eq(executionWorkspaceRepositories.companyId, input.row.companyId), eq(executionWorkspaces.companyId, input.row.companyId), @@ -113,7 +113,7 @@ export function executionWorkspaceRepositoryService(db: Db) { if (await fs.realpath(workspace.cwd).catch(() => null) !== input.root) continue; assertRepositoryRetryMatches(repository, input.row.repositoryIdentity, input.row.requestedRef); if (!(await canActorReadExecutionWorkspace(db, input.actor, workspace.id))) throw forbidden("The shared repository retains files from a workspace outside this actor's access"); - await assertTaskWorkspaceAccess(db, input.actor, input.row.companyId, workspace.id, { write: true }); + await assertTaskWorkspaceAccess(db, input.actor, input.row.companyId, workspace.id, { write: true, issueId: input.issueId }); if (pin && pin !== candidatePin) throw conflict("Shared repository publication receipts disagree; repair is required before reuse"); pin = candidatePin; } @@ -129,7 +129,7 @@ export function executionWorkspaceRepositoryService(db: Db) { // local-board is a server identity, not a membership record. Preserve the // agent's own authority rather than promoting it to a board actor. onBehalfOfUserId: input.responsibleUserId === "local-board" ? null : input.responsibleUserId }; - await assertTaskWorkspaceAccess(db, actor, input.companyId, input.workspaceId, { write: true }); + await assertTaskWorkspaceAccess(db, actor, input.companyId, input.workspaceId, { write: true, issueId: input.issueId }); const root = await fs.realpath(input.cwd); const repositoryRoot = path.join(root, ".paperclip-repositories"); await fs.mkdir(repositoryRoot, { recursive: true }); @@ -194,7 +194,7 @@ export function executionWorkspaceRepositoryService(db: Db) { } catch { return null; } finally { await handle.close(); } }; const initialOwnership = await readOwnership(); - const authorizedPin = await sharedPublicationPin({ row, root, actor, ownership: initialOwnership }); + const authorizedPin = await sharedPublicationPin({ issueId: input.issueId, row, root, actor, ownership: initialOwnership }); if (existing && !authorizedPin && initialOwnership?.repositoryId !== row.id) { throw conflict("Repository directory has no matching preparation receipt; repair it before retrying"); } diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index e4fb716fb7c..1f6afa7d561 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -1288,7 +1288,7 @@ const inspectGitForDisplay = createWorkspaceGitInspectionCache(inspectGitCloseRe export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServiceOptions = {}) { /** Validate intent without allocating files or changing a task binding. */ - async function validateSelection(input: { companyId: string; actor: AuthorizationActor; selection: TaskWorkspaceSelection }, reader: Db | DbTransaction = db) { + async function validateSelection(input: { companyId: string; actor: AuthorizationActor; selection: TaskWorkspaceSelection; issueId?: string | null; parentIssueId?: string | null }, reader: Db | DbTransaction = db) { const selection = taskWorkspaceSelectionSchema.parse(input.selection); if (selection.kind === "existing") { const [workspace] = await reader.select().from(executionWorkspaces).where(and( @@ -1297,7 +1297,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic await executionWorkspaceReadSqlCondition(reader, input.actor), )); if (!workspace) throw notFound("Workspace is unavailable or inaccessible"); - await assertTaskWorkspaceAccess(reader, input.actor, input.companyId, workspace.id, { write: true }); + await assertTaskWorkspaceAccess(reader, input.actor, input.companyId, workspace.id, { write: true, issueId: input.issueId, parentIssueId: input.parentIssueId }); return { executionWorkspaceId: workspace.id, projectWorkspaceId: workspace.projectWorkspaceId, executionWorkspacePreference: "reuse_existing", executionWorkspaceSettings: { mode: workspace.mode } }; } @@ -1308,7 +1308,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic await projectReadSqlCondition(reader, input.actor), )); if (!source) throw notFound("Workspace source is unavailable or inaccessible"); - await assertTaskWorkspaceSourceProjectAccess(reader, input.actor, input.companyId, source.projectId, { write: selection.mode === "shared" }); + await assertTaskWorkspaceSourceProjectAccess(reader, input.actor, input.companyId, source.projectId, { write: selection.mode === "shared", issueId: input.issueId, parentIssueId: input.parentIssueId }); return { executionWorkspaceId: null, projectWorkspaceId: source.id, executionWorkspacePreference: null, executionWorkspaceSettings: { mode: selection.mode === "shared" ? "shared_workspace" : "isolated_workspace" } }; } diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 9c8c93afb4c..0a20d0f744b 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -3205,10 +3205,10 @@ export function heartbeatService( if (!persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) { const workspaceRequiringAccess = reusableExistingExecutionWorkspace ?? boundSourceWorkspace; if (workspaceRequiringAccess) { - await assertTaskWorkspaceAccess(db, workspaceAuthorizationActor, agent.companyId, workspaceRequiringAccess.id, { write: true }); + await assertTaskWorkspaceAccess(db, workspaceAuthorizationActor, agent.companyId, workspaceRequiringAccess.id, { write: true, issueId }); } else if (!isDotRun && executionProjectId && (selectedWorkspaceSource || projectContext?.hasWorkspace)) { // Check source authority before resolution can clone or expose files. - await assertTaskWorkspaceSourceProjectAccess(db, workspaceAuthorizationActor, agent.companyId, executionProjectId, { write: requestedExecutionWorkspaceMode === "shared_workspace" }); + await assertTaskWorkspaceSourceProjectAccess(db, workspaceAuthorizationActor, agent.companyId, executionProjectId, { write: requestedExecutionWorkspaceMode === "shared_workspace", issueId }); } } const requestedReusableExecutionWorkspaceConfig = @@ -3483,7 +3483,7 @@ export function heartbeatService( if (taskWorkspaceIntentRow?.intent && !boundSourceWorkspace && !nativeRecoveryExecutionWorkspaceId) { await executionWorkspacesSvc.validateSelection({ companyId: agent.companyId, actor: { type: "agent", agentId: agent.id, companyId: agent.companyId, runId: run.id, source: "agent_jwt", onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId }, - selection: taskWorkspaceIntentRow.intent.selection }); + selection: taskWorkspaceIntentRow.intent.selection, issueId }); } const explicitTaskDirectory = taskWorkspaceIntentRow?.intent?.selection.kind === "task_directory"; const useIsolatedTaskDirectory = issueRef !== null && shouldUseIsolatedTaskDirectory({ diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 54692fae516..63022c30756 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -10166,7 +10166,7 @@ export function issueService(db: Db) { if (workspaceSelection && (workspaceSelectionSource === "explicit" || !inheritedWorkspaceSelection)) { const projection = await executionWorkspaceService(db).validateSelection({ companyId, actor: workspaceActor, - selection: workspaceSelection }, tx); + selection: workspaceSelection, parentIssueId: issueData.parentId }, tx); projectWorkspaceId = projection.projectWorkspaceId; executionWorkspaceId = projection.executionWorkspaceId; executionWorkspacePreference = projection.executionWorkspacePreference; @@ -10266,6 +10266,7 @@ export function issueService(db: Db) { .where(and(eq(projectWorkspaces.id, projectWorkspaceId), eq(projectWorkspaces.companyId, companyId), await projectReadSqlCondition(tx, workspaceActor))); if (!readableSource) throw notFound("Workspace source is unavailable or inaccessible"); await assertTaskWorkspaceSourceProjectAccess(tx, workspaceActor, companyId, readableSource.projectId, { + parentIssueId: issueData.parentId, write: !executionWorkspaceId && resolveExecutionWorkspaceMode({ projectPolicy: gateProjectExecutionWorkspacePolicy(parseProjectExecutionWorkspacePolicy(readableSource.executionWorkspacePolicy), isolatedWorkspacesEnabled), issueSettings: parseIssueExecutionWorkspaceSettings(executionWorkspaceSettings), @@ -10277,7 +10278,7 @@ export function issueService(db: Db) { const [readableWorkspace] = await tx.select({ id: executionWorkspaces.id, projectId: executionWorkspaces.projectId }).from(executionWorkspaces) .where(and(eq(executionWorkspaces.id, executionWorkspaceId), eq(executionWorkspaces.companyId, companyId), await executionWorkspaceReadSqlCondition(tx, workspaceActor))); if (!readableWorkspace) throw notFound("Workspace is unavailable or inaccessible"); - await assertTaskWorkspaceAccess(tx, workspaceActor, companyId, readableWorkspace.id, { write: true }); + await assertTaskWorkspaceAccess(tx, workspaceActor, companyId, readableWorkspace.id, { write: true, parentIssueId: issueData.parentId }); } if (projectWorkspaceId) { await assertValidProjectWorkspace( diff --git a/server/src/services/task-workspace-source-access.ts b/server/src/services/task-workspace-source-access.ts index 34a0e39a479..62330d462d5 100644 --- a/server/src/services/task-workspace-source-access.ts +++ b/server/src/services/task-workspace-source-access.ts @@ -5,10 +5,11 @@ import { accessService } from "./access.js"; import { executionWorkspaceReadSqlCondition, type AuthorizationActor } from "./authorization.js"; type DbTransaction = Parameters[0]>[0]; +type WorkspaceAccessContext = { write?: boolean; issueId?: string | null; parentIssueId?: string | null }; /** Filesystem source scope is independent of a task's organizational project. */ export async function assertTaskWorkspaceSourceProjectAccess( - db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectId: string | null, options: { write?: boolean } = {}, + db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectId: string | null, options: WorkspaceAccessContext = {}, ) { if (!projectId) return; const decision = await accessService(db as Db).decide({ @@ -21,7 +22,10 @@ export async function assertTaskWorkspaceSourceProjectAccess( const assigneeAgentId = actor.type === "agent" ? actor.agentId ?? null : null; const assignment = await accessService(db as Db).decide({ actor, action: "tasks:assign", - resource: { type: "issue", companyId, projectId, assigneeAgentId }, + resource: { type: "issue", companyId, projectId, assigneeAgentId, + issueId: options.issueId, parentIssueId: options.parentIssueId }, + // Lineage proves low-trust containment; grants must still cover the source + // project independently of the task's organizational project. scope: { projectId, assigneeAgentId }, }); if (!assignment.allowed) throw forbidden(assignment.explanation); @@ -30,14 +34,14 @@ export async function assertTaskWorkspaceSourceProjectAccess( /** Retained files keep every task's authorization boundary after rebinding. */ export async function assertTaskWorkspaceAccess( - db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, workspaceId: string, options: { write?: boolean } = {}, + db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, workspaceId: string, options: WorkspaceAccessContext = {}, ) { const [workspace] = await db.select().from(executionWorkspaces).where(and( eq(executionWorkspaces.id, workspaceId), eq(executionWorkspaces.companyId, companyId), await executionWorkspaceReadSqlCondition(db, actor), )); if (!workspace) throw forbidden("Task workspace access is no longer available"); - await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, workspace.projectId, { write: options.write && workspace.mode === "shared_workspace" }); + await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, workspace.projectId, { ...options, write: options.write && workspace.mode === "shared_workspace" }); const retainedIssueIds = Object.keys(workspace.metadata?._issuePrivacySources ?? {}); if (workspace.sourceIssueId) retainedIssueIds.push(workspace.sourceIssueId); const sources = await db.select({ id: issues.id }).from(issues).where(and(