From 4d7f0c8fe6c6389c3e8a33c559df3b3a80c4d60c Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 09:18:15 -0500 Subject: [PATCH] fix: authorize actual workspace sharing and assigned board targets Co-Authored-By: Paperclip --- doc/plans/2026-10-09-task-workspaces.md | 2 +- .../heartbeat-project-repositories.test.ts | 51 ++++++++++++++++++- .../__tests__/task-workspace-binding.test.ts | 32 +++++++++++- server/src/services/execution-workspaces.ts | 18 +++++-- server/src/services/heartbeat.ts | 27 +++++----- server/src/services/issues.ts | 20 +++++--- .../services/task-workspace-source-access.ts | 15 ++++-- 7 files changed, 132 insertions(+), 33 deletions(-) diff --git a/doc/plans/2026-10-09-task-workspaces.md b/doc/plans/2026-10-09-task-workspaces.md index ed152d36ddd..22169f0d18b 100644 --- a/doc/plans/2026-10-09-task-workspaces.md +++ b/doc/plans/2026-10-09-task-workspaces.md @@ -35,7 +35,7 @@ Explicit invalid/unauthorized choices fail with actionable errors, never fall ba Typed selection vocabulary: task_directory; existing execution workspace; configured project source with shared or managed-isolated policy. Operator cwd compatibility is internal and cannot be submitted by an ordinary agent. New repository paths use existing `.paperclip-repositories/` namespace; existing root checkouts retain their paths. -Source inspection and isolated copies require source-project read access. Writable shared roots additionally require the existing source-project task-assignment authority. Check that authority at selection, task creation, repository mutation, and each new admission; revocation blocks future use without changing already-admitted recovery inputs. +Source inspection and isolated copies require source-project read access. Writable shared roots additionally require the existing source-project task-assignment authority. The resolved strategy determines sharing: only `git_worktree` creates a separate checkout; an isolated mode label does not exempt a source-root strategy. Board requests use the task's actual assignee when checking scoped grants. Check that authority at selection, task creation, repository mutation, and each new admission; revocation blocks future use without changing already-admitted recovery inputs. Channel defaults use optional properties (inherit), explicit null (clear), concrete value (override). Resource override beats endpoint. Defaults are captured once at task creation and reauthorized on use. Changing/deleting/revoking configuration cannot relocate existing tasks. Project choice and workspace choice are independent; choosing a source does not silently assign organizational project. Endpoints without configuration work normally. diff --git a/server/src/__tests__/heartbeat-project-repositories.test.ts b/server/src/__tests__/heartbeat-project-repositories.test.ts index b09eaba9223..bf2112421a7 100644 --- a/server/src/__tests__/heartbeat-project-repositories.test.ts +++ b/server/src/__tests__/heartbeat-project-repositories.test.ts @@ -223,7 +223,7 @@ suite("task project repository provisioning", () => { } }, 40_000); - it("blocks shared source adapter admission after its project assignment grant is revoked while read access remains", async () => { + it.each(["shared_workspace", "isolated_workspace"] as const)("blocks shared source adapter admission after grant revocation with retained mode %s", async (retainedMode) => { const companyId = randomUUID(), projectId = randomUUID(), sourceId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(); const cwd = path.join(root, companyId, "protected-shared-source"); await mkdir(cwd, { recursive: true }); @@ -256,6 +256,11 @@ suite("task project repository provisioning", () => { const [bound] = await db.select().from(issues).where(eq(issues.id, issueId)); expect(bound.projectId).toBeNull(); expect(bound.executionWorkspaceId).not.toBeNull(); + if (retainedMode === "isolated_workspace") { + // Legacy or malformed metadata can label the shared physical root isolated. + await db.update(executionWorkspaces).set({ mode: retainedMode }).where(eq(executionWorkspaces.id, bound.executionWorkspaceId!)); + await db.update(issues).set({ executionWorkspaceSettings: { mode: retainedMode, workspaceStrategy: { type: "project_primary" } } }).where(eq(issues.id, issueId)); + } await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, grant.id)); const actor = { type: "agent" as const, agentId, companyId, source: "agent_jwt" as const, onBehalfOfUserId: "responsible-user" }; expect(await accessService(db).decide({ actor, action: "project:read", resource: { type: "project", companyId, projectId } })).toMatchObject({ allowed: true }); @@ -271,6 +276,50 @@ suite("task project repository provisioning", () => { expect(await readFile(path.join(cwd, "work.txt"), "utf8")).toBe("Protected project files"); }, 40_000); + it.each(["project_primary", "adapter_managed", "cloud_sandbox", "git_worktree"] as const)("authorizes isolated source admission by its actual strategy: %s", async (strategy) => { + const companyId = randomUUID(), projectId = randomUUID(), sourceId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(); + const cwd = path.join(root, companyId, "protected-source"); + await mkdir(cwd, { recursive: true }); + const git = (...args: string[]) => execFileSync("git", args, { cwd, stdio: "ignore" }); + git("init", "-b", "main"); + await writeFile(path.join(cwd, "work.txt"), "Protected source content"); + git("add", "."); + git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "seed"); + await db.insert(companies).values({ id: companyId, name: "Strategy authorization", issuePrefix: `S${companyId.slice(0, 6)}`, defaultResponsibleUserId: "responsible-user" }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "owner" }); + await db.insert(projects).values({ id: projectId, companyId, name: "Protected source", executionWorkspacePolicy: { + authorizationPolicy: { assignmentPolicy: { mode: "protected" } }, + } }); + await db.insert(projectWorkspaces).values({ id: sourceId, companyId, projectId, name: "Source", sourceType: "local_path", cwd, isPrimary: true }); + await db.insert(agents).values({ id: agentId, companyId, name: "Reader", status: "idle", adapterType: "codex_local", adapterConfig: {}, permissions: {} }); + await db.insert(issues).values({ id: issueId, companyId, title: "Use an isolated source", status: "todo", assigneeAgentId: agentId, + projectWorkspaceId: sourceId, executionWorkspaceSettings: { mode: "isolated_workspace", workspaceStrategy: { type: strategy } }, + workspaceSelection: { version: 1, source: "explicit", selection: { kind: "configured_source", projectWorkspaceId: sourceId, mode: "managed_isolated" } } }); + const actor = { type: "agent" as const, agentId, companyId, source: "agent_jwt" as const, onBehalfOfUserId: "responsible-user" }; + expect(await accessService(db).decide({ actor, action: "project:read", resource: { type: "project", companyId, projectId } })).toMatchObject({ allowed: true }); + expect(await accessService(db).decide({ actor, action: "tasks:assign", resource: { type: "issue", companyId, projectId, assigneeAgentId: agentId }, + scope: { projectId, assigneeAgentId: agentId } })).toMatchObject({ allowed: false }); + const run = await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", contextSnapshot: { issueId } }); + expect(run).not.toBeNull(); + await vi.waitFor(async () => expect((await heartbeat.getRun(run!.id))?.status).toMatch(/^(succeeded|failed)$/), { timeout: 15_000 }); + await drainHeartbeatRunsToQuiescence(db, heartbeat); + const result = await heartbeat.getRun(run!.id); + const calls = execute.mock.calls.filter(([input]) => input.runId === run!.id); + if (strategy !== "git_worktree") { + expect(result?.status).toBe("failed"); + expect(result?.error).toContain("Target project is protected and requires an explicit assignment grant"); + expect(calls).toHaveLength(0); + } else { + expect(result).toMatchObject({ status: "succeeded", error: null }); + expect(calls).toHaveLength(1); + const workspace = calls[0]![0].context.paperclipWorkspace; + expect(workspace.strategy).toBe("git_worktree"); + expect(await realpath(workspace.cwd)).not.toBe(await realpath(cwd)); + expect(await readFile(path.join(workspace.cwd, "work.txt"), "utf8")).toBe("Protected source content"); + } + expect(await readFile(path.join(cwd, "work.txt"), "utf8")).toBe("Protected source content"); + }, 30_000); + it.each([true, false])("handles a deleted configured source with a retained workspace: %s", async (bound) => { const companyId = randomUUID(), projectId = randomUUID(), sourceId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(); const cwd = path.join(root, companyId, "retained-source"); diff --git a/server/src/__tests__/task-workspace-binding.test.ts b/server/src/__tests__/task-workspace-binding.test.ts index 25a3529befe..f24fc59ee3b 100644 --- a/server/src/__tests__/task-workspace-binding.test.ts +++ b/server/src/__tests__/task-workspace-binding.test.ts @@ -243,8 +243,23 @@ const support = await getEmbeddedPostgresTestSupport(); app.use((req, _res, next) => { req.actor = actor; next(); }); app.use("/api", taskWorkspaceRoutes(db)); app.use(errorHandler); - const selection = { selection: { kind: "task_directory" as const }, expectedBindingRevision: 1, requestKey: "member-selection" }; + const [assignee] = await db.insert(agents).values({ companyId: f.companyId, name: "Assigned worker", status: "idle" }).returning(); + const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected files", + executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); + const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, + name: "Source", cwd: `/tmp/task-${f.issueId}` }).returning(); + await db.update(issues).set({ assigneeAgentId: assignee.id }).where(eq(issues.id, f.issueId)); + await db.update(executionWorkspaces).set({ projectId: project.id, projectWorkspaceId: source.id }).where(eq(executionWorkspaces.id, f.workspaceId)); + const selection = { selection: { kind: "existing" as const, workspaceId: f.workspaceId }, expectedBindingRevision: 1, requestKey: "member-selection" }; const repository = { repository: { kind: "url" as const, url: "https://github.com/public/example" }, requestKey: "member-repository" }; + expect((await request(app).put(`/api/issues/${f.issueId}/workspace`).send(selection)).status).toBe(403); + expect((await request(app).post(`/api/issues/${f.issueId}/workspace/repositories`).send(repository)).status).toBe(403); + await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: userId, + permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: assignee.id } }); + expect(await issueService(db).create(f.companyId, { title: "Board-assigned shared work", createdByUserId: userId, + assigneeAgentId: assignee.id, workspaceSelectionActor: actor, + workspaceSelection: { kind: "configured_source", projectWorkspaceId: source.id, mode: "shared" } })) + .toMatchObject({ assigneeAgentId: assignee.id, projectWorkspaceId: source.id }); const selected = await request(app).put(`/api/issues/${f.issueId}/workspace`).send(selection); expect(selected.status, JSON.stringify(selected.body)).toBe(200); expect(selected.body).toMatchObject({ kind: "scheduled" }); @@ -301,6 +316,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/); + await db.update(executionWorkspaces).set({ mode: "isolated_workspace" }).where(eq(executionWorkspaces.id, shared.id)); + await expect(svc.validateSelection({ ...f, actor, selection: { kind: "existing", workspaceId: shared.id } })).rejects.toThrow(/protected/); + await expect(tasks.create(f.companyId, { title: "Mislabelled isolated source", createdByAgentId: agent.id, + projectWorkspaceId: source.id, workspaceSelectionActor: actor, + executionWorkspaceSettings: { mode: "isolated_workspace", workspaceStrategy: { type: "project_primary" } } })).rejects.toThrow(/protected/); + await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace", + workspaceStrategy: { type: "project_primary" }, authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).where(eq(projects.id, project.id)); + await expect(svc.validateSelection({ ...f, actor, selection: isolatedSelection })).rejects.toThrow(/protected/); + await db.update(projects).set({ executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).where(eq(projects.id, project.id)); + 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", @@ -309,6 +334,11 @@ const support = await getEmbeddedPostgresTestSupport(); // 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/); + const [otherAgent] = await db.insert(agents).values({ companyId: f.companyId, name: "Another assignee", status: "idle" }).returning(); + await db.update(issues).set({ assigneeAgentId: otherAgent.id }).where(eq(issues.id, f.issueId)); + await db.update(principalPermissionGrants).set({ scope: { projectId: project.id, assigneeAgentId: otherAgent.id } }) + .where(eq(principalPermissionGrants.id, grant.id)); + 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" }); diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 1f6afa7d561..fc06f68a824 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -50,7 +50,7 @@ import { normalizeIssueExecutionPolicy, parseIssueExecutionState, } from "./issue-execution-policy.js"; -import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; +import { buildExecutionWorkspaceAdapterConfig, resolveEffectiveWorkspaceStrategyType, parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; import { issueRecoveryActionService } from "./issue-recovery-actions.js"; import { logActivity } from "./activity-log.js"; import { @@ -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; issueId?: string | null; parentIssueId?: string | null }, reader: Db | DbTransaction = db) { + async function validateSelection(input: { companyId: string; actor: AuthorizationActor; selection: TaskWorkspaceSelection; issueId?: string | null; parentIssueId?: string | null; assigneeAgentId?: string | null; assigneeUserId?: 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,18 +1297,26 @@ 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, issueId: input.issueId, parentIssueId: input.parentIssueId }); + await assertTaskWorkspaceAccess(reader, input.actor, input.companyId, workspace.id, { write: true, issueId: input.issueId, parentIssueId: input.parentIssueId, assigneeAgentId: input.assigneeAgentId, assigneeUserId: input.assigneeUserId }); return { executionWorkspaceId: workspace.id, projectWorkspaceId: workspace.projectWorkspaceId, executionWorkspacePreference: "reuse_existing", executionWorkspaceSettings: { mode: workspace.mode } }; } if (selection.kind === "configured_source") { - const [source] = await reader.select({ id: projectWorkspaces.id, projectId: projectWorkspaces.projectId }).from(projectWorkspaces) + const [source] = await reader.select({ id: projectWorkspaces.id, projectId: projectWorkspaces.projectId, executionWorkspacePolicy: projects.executionWorkspacePolicy }).from(projectWorkspaces) .innerJoin(projects, eq(projects.id, projectWorkspaces.projectId)).where(and( eq(projectWorkspaces.id, selection.projectWorkspaceId), eq(projectWorkspaces.companyId, input.companyId), 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", issueId: input.issueId, parentIssueId: input.parentIssueId }); + const mode = selection.mode === "shared" ? "shared_workspace" : "isolated_workspace"; + const sourceConfig = buildExecutionWorkspaceAdapterConfig({ agentConfig: {}, + projectPolicy: parseProjectExecutionWorkspacePolicy(source.executionWorkspacePolicy), + issueSettings: { mode }, mode, legacyUseProjectWorkspace: null }); + await assertTaskWorkspaceSourceProjectAccess(reader, input.actor, input.companyId, source.projectId, { + write: resolveEffectiveWorkspaceStrategyType(mode, sourceConfig) !== "git_worktree", + issueId: input.issueId, parentIssueId: input.parentIssueId, + assigneeAgentId: input.assigneeAgentId, assigneeUserId: input.assigneeUserId, + }); 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 0a20d0f744b..b7833b3f510 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -3200,6 +3200,19 @@ export function heartbeatService( workspaceReuseRequest.existingExecutionWorkspaceAvailable ? existingExecutionWorkspace : null; + const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig({ + agentConfig: config, + projectPolicy: projectExecutionWorkspacePolicy, + issueSettings: issueExecutionWorkspaceSettings, + mode: requestedExecutionWorkspaceMode, + legacyUseProjectWorkspace: + issueAssigneeOverrides?.useProjectWorkspace ?? null, + }); + const sourceWorkspaceConfig = { + ...workspaceManagedConfig, + ...Object.fromEntries(Object.entries(issueAssigneeOverrides?.adapterConfig ?? {}).filter(([key]) => requestedAiBinding?.mode !== "router" || !["provider", "acpxAgent", "model", "modelReasoningEffort", "reasoningEffort", "effort", "variant"].includes(key))), + ...(requestedAiBinding?.mode === "router" ? parseObject(parseObject(context.aiRouterSelection).runtimeConfig) : {}), + }; const workspaceAuthorizationActor = { type: "agent" as const, agentId: agent.id, companyId: agent.companyId, source: "agent_jwt" as const, runId: run.id, onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId }; if (!persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) { @@ -3208,7 +3221,7 @@ export function heartbeatService( 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", issueId }); + await assertTaskWorkspaceSourceProjectAccess(db, workspaceAuthorizationActor, agent.companyId, executionProjectId, { write: resolveEffectiveWorkspaceStrategyType(requestedExecutionWorkspaceMode, sourceWorkspaceConfig) !== "git_worktree", issueId }); } } const requestedReusableExecutionWorkspaceConfig = @@ -3499,18 +3512,8 @@ export function heartbeatService( issueExecutionWorkspaceSettings?.workspaceStrategy, ], }); - const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig({ - agentConfig: config, - projectPolicy: projectExecutionWorkspacePolicy, - issueSettings: issueExecutionWorkspaceSettings, - mode: requestedExecutionWorkspaceMode, - legacyUseProjectWorkspace: - issueAssigneeOverrides?.useProjectWorkspace ?? null, - }); const mergedConfig = { - ...workspaceManagedConfig, - ...Object.fromEntries(Object.entries(issueAssigneeOverrides?.adapterConfig ?? {}).filter(([key]) => requestedAiBinding?.mode !== "router" || !["provider", "acpxAgent", "model", "modelReasoningEffort", "reasoningEffort", "effort", "variant"].includes(key))), - ...(requestedAiBinding?.mode === "router" ? parseObject(parseObject(context.aiRouterSelection).runtimeConfig) : {}), + ...sourceWorkspaceConfig, // The base below is already task-owned. Keep directory transport while // preserving isolated mode and the mandatory sandbox preflight. ...((useIsolatedTaskDirectory || explicitTaskDirectory) ? { workspaceStrategy: { type: "project_primary" } } : {}), diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 63022c30756..c007ffdc2a1 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -122,6 +122,8 @@ import { SUCCESSFUL_RUN_HANDOFF_LIVE_WAKE_STATUSES, } from "./successful-run-handoff-state.js"; import { + buildExecutionWorkspaceAdapterConfig, + resolveEffectiveWorkspaceStrategyType, defaultIssueExecutionWorkspaceSettingsForProject, gateProjectExecutionWorkspacePolicy, issueExecutionWorkspaceModeForPersistedWorkspace, @@ -10166,7 +10168,8 @@ export function issueService(db: Db) { if (workspaceSelection && (workspaceSelectionSource === "explicit" || !inheritedWorkspaceSelection)) { const projection = await executionWorkspaceService(db).validateSelection({ companyId, actor: workspaceActor, - selection: workspaceSelection, parentIssueId: issueData.parentId }, tx); + selection: workspaceSelection, parentIssueId: issueData.parentId, + assigneeAgentId: issueData.assigneeAgentId, assigneeUserId: issueData.assigneeUserId }, tx); projectWorkspaceId = projection.projectWorkspaceId; executionWorkspaceId = projection.executionWorkspaceId; executionWorkspacePreference = projection.executionWorkspacePreference; @@ -10265,20 +10268,21 @@ export function issueService(db: Db) { .innerJoin(projects, eq(projects.id, projectWorkspaces.projectId)) .where(and(eq(projectWorkspaces.id, projectWorkspaceId), eq(projectWorkspaces.companyId, companyId), await projectReadSqlCondition(tx, workspaceActor))); if (!readableSource) throw notFound("Workspace source is unavailable or inaccessible"); + const sourcePolicy = gateProjectExecutionWorkspacePolicy(parseProjectExecutionWorkspacePolicy(readableSource.executionWorkspacePolicy), isolatedWorkspacesEnabled); + const sourceSettings = parseIssueExecutionWorkspaceSettings(executionWorkspaceSettings); + const sourceMode = resolveExecutionWorkspaceMode({ projectPolicy: sourcePolicy, issueSettings: sourceSettings, legacyUseProjectWorkspace: null }); + const sourceConfig = buildExecutionWorkspaceAdapterConfig({ agentConfig: {}, projectPolicy: sourcePolicy, + issueSettings: sourceSettings, mode: sourceMode, legacyUseProjectWorkspace: null }); await assertTaskWorkspaceSourceProjectAccess(tx, workspaceActor, companyId, readableSource.projectId, { - parentIssueId: issueData.parentId, - write: !executionWorkspaceId && resolveExecutionWorkspaceMode({ - projectPolicy: gateProjectExecutionWorkspacePolicy(parseProjectExecutionWorkspacePolicy(readableSource.executionWorkspacePolicy), isolatedWorkspacesEnabled), - issueSettings: parseIssueExecutionWorkspaceSettings(executionWorkspaceSettings), - legacyUseProjectWorkspace: null, - }) === "shared_workspace", + parentIssueId: issueData.parentId, assigneeAgentId: issueData.assigneeAgentId, assigneeUserId: issueData.assigneeUserId, + write: !executionWorkspaceId && resolveEffectiveWorkspaceStrategyType(sourceMode, sourceConfig) !== "git_worktree", }); } if (executionWorkspaceId && workspaceSelectionActor) { 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, parentIssueId: issueData.parentId }); + await assertTaskWorkspaceAccess(tx, workspaceActor, companyId, readableWorkspace.id, { write: true, parentIssueId: issueData.parentId, assigneeAgentId: issueData.assigneeAgentId, assigneeUserId: issueData.assigneeUserId }); } 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 62330d462d5..516faf4637c 100644 --- a/server/src/services/task-workspace-source-access.ts +++ b/server/src/services/task-workspace-source-access.ts @@ -5,7 +5,7 @@ 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 }; +type WorkspaceAccessContext = { write?: boolean; issueId?: string | null; parentIssueId?: string | null; assigneeAgentId?: string | null; assigneeUserId?: string | null }; /** Filesystem source scope is independent of a task's organizational project. */ export async function assertTaskWorkspaceSourceProjectAccess( @@ -19,14 +19,19 @@ export async function assertTaskWorkspaceSourceProjectAccess( if (options.write) { // Sharing a configured root gives the task write access to project files. // Use the same project-scoped authority as assigning work to that project. - const assigneeAgentId = actor.type === "agent" ? actor.agentId ?? null : null; + const [task] = actor.type === "board" && options.issueId + ? await db.select({ assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId }).from(issues) + .where(and(eq(issues.id, options.issueId), eq(issues.companyId, companyId))) + : []; + const assigneeAgentId = actor.type === "agent" ? actor.agentId ?? null : task?.assigneeAgentId ?? options.assigneeAgentId ?? null; + const assigneeUserId = actor.type === "agent" ? null : task?.assigneeUserId ?? options.assigneeUserId ?? 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, assigneeUserId, 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 }, + scope: { projectId, assigneeAgentId, assigneeUserId }, }); if (!assignment.allowed) throw forbidden(assignment.explanation); } @@ -41,7 +46,7 @@ export async function assertTaskWorkspaceAccess( await executionWorkspaceReadSqlCondition(db, actor), )); if (!workspace) throw forbidden("Task workspace access is no longer available"); - await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, workspace.projectId, { ...options, write: options.write && workspace.mode === "shared_workspace" }); + await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, workspace.projectId, { ...options, write: options.write && workspace.strategyType !== "git_worktree" }); 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(