diff --git a/server/src/__tests__/agent-project-access.test.ts b/server/src/__tests__/agent-project-access.test.ts index 5b7b26baa34..8f4bf50eb38 100644 --- a/server/src/__tests__/agent-project-access.test.ts +++ b/server/src/__tests__/agent-project-access.test.ts @@ -194,4 +194,39 @@ describe("additive project access", () => { expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] })).status).toBe(403); expect((await request(api).get(`/agents/${other.agentId}/project-access`)).status).toBe(404); }); + + it("runs a private task in its owner's personal project without granting that project", async () => { + const companyId = randomUUID(), agentId = randomUUID(), ownerId = `owner-${randomUUID()}`; + await db.insert(companies).values({ id: companyId, name: "Personal private tasks", issuePrefix: `P${companyId.slice(0, 6)}` }); + await db.insert(agents).values({ id: agentId, companyId, name: "CEO", adapterType: "process", status: "idle" }); + await db.insert(companyMemberships).values({ companyId, principalType: "agent", principalId: agentId, membershipRole: "member", status: "active" }); + const [personal, shared] = await db.insert(projects).values([ + { companyId, name: "My private tasks", visibility: "private", personalOwnerUserId: ownerId, privacyOwnerUserId: ownerId }, + { companyId, name: "Private team project", visibility: "private", privacyOwnerUserId: ownerId }, + ]).returning(); + const task = async (projectId: string) => (await db.insert(issues).values({ companyId, title: "Private chat", status: "todo", + assigneeAgentId: agentId, projectId, visibility: "private", responsibleUserId: ownerId }).returning())[0]!; + const actor = { type: "agent" as const, source: "agent_key" as const, agentId, companyId }; + const personalTask = await task(personal!.id); + await db.update(issues).set({ privacyRootIssueId: personalTask.id }).where(eq(issues.id, personalTask.id)); + + // The agent can work the task it was given; the personal project stays unreadable. + await expect(assertTaskWorkspaceExecutionAccess(db, actor, companyId, { issueId: personalTask.id })).resolves.toMatchObject({ task: { id: personalTask.id } }); + await expect(assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, personal!.id)).rejects.toThrow(/not an access member/); + + // An execution workspace from the personal project is not a bare container. + const [ownerFiles] = await db.insert(executionWorkspaces).values({ companyId, projectId: personal!.id, name: "Owner workspace", + cwd: "/tmp/personal-owner-workspace", mode: "isolated_workspace", strategyType: "git_worktree" }).returning(); + await db.update(issues).set({ executionWorkspaceId: ownerFiles!.id }).where(eq(issues.id, personalTask.id)); + await expect(assertTaskWorkspaceExecutionAccess(db, actor, companyId, { issueId: personalTask.id, workspaceId: ownerFiles!.id })) + .rejects.toThrow(/not an access member/); + await db.update(issues).set({ executionWorkspaceId: null }).where(eq(issues.id, personalTask.id)); + + // A shared private project, or a personal project that provides files, still needs membership. + const sharedTask = await task(shared!.id); + await db.update(issues).set({ privacyRootIssueId: sharedTask.id }).where(eq(issues.id, sharedTask.id)); + await expect(assertTaskWorkspaceExecutionAccess(db, actor, companyId, { issueId: sharedTask.id })).rejects.toThrow(/not an access member/); + await db.insert(projectWorkspaces).values({ companyId, projectId: personal!.id, name: "Owner files", cwd: "/tmp/personal-owner-files" }); + await expect(assertTaskWorkspaceExecutionAccess(db, actor, companyId, { issueId: personalTask.id })).rejects.toThrow(/not an access member/); + }); }); diff --git a/server/src/services/task-workspace-access.ts b/server/src/services/task-workspace-access.ts index 0c4599982e1..eb5476b727e 100644 --- a/server/src/services/task-workspace-access.ts +++ b/server/src/services/task-workspace-access.ts @@ -65,12 +65,18 @@ export async function taskWorkspaceProjectRequirements(db: Db, companyId: string export async function assertTaskWorkspaceSourceProjectAccess( db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectId: string | null, + options: { taskContainerOnly?: boolean } = {}, ) { if (!projectId) return; const decision = await measureRunPhase("workspace.access.authorization", () => accessService(db as Db).decide({ actor, action: "project:read", resource: { type: "project", companyId, projectId }, })); if (!decision.allowed) { + // A person's "My private tasks" project only parks their private tasks. An + // issue-level grant exposes the task without its project, so when that + // project supplies no workspace files, task access is enough to run it. + if (options.taskContainerOnly && decision.reason === "deny_project_private" + && await personalProjectHasNoWorkspaceFiles(db, companyId, projectId)) return; if (actor.type === "agent" && decision.reason === "deny_low_trust_boundary") { throw new ProjectAccessDeniedError(projectId); } @@ -78,6 +84,15 @@ export async function assertTaskWorkspaceSourceProjectAccess( } } +async function personalProjectHasNoWorkspaceFiles(db: Db | DbTransaction, companyId: string, projectId: string) { + const [project] = await db.select({ personalOwnerUserId: projects.personalOwnerUserId }).from(projects) + .where(and(eq(projects.id, projectId), eq(projects.companyId, companyId))); + if (!project?.personalOwnerUserId) return false; + const [source] = await db.select({ id: projectWorkspaces.id }).from(projectWorkspaces) + .where(and(eq(projectWorkspaces.projectId, projectId), eq(projectWorkspaces.companyId, companyId))).limit(1); + return !source; +} + export async function assertExecutionWorkspaceRecordAccess( db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, workspaceId: string, ) { @@ -176,7 +191,10 @@ export async function assertTaskWorkspaceExecutionAccess( const selectedProjects = projectIds.length ? await measureRunPhase("workspace.access.projects_read", async () => db.select({ id: projects.id, companyId: projects.companyId, executionWorkspacePolicy: projects.executionWorkspacePolicy }) .from(projects).where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds)))) : []; if (selectedProjects.length !== projectIds.length) throw forbidden("Task workspace project is unavailable"); - for (const projectId of projectIds) await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, projectId); + for (const projectId of projectIds) await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, projectId, { + // issue:read was authorized above; the containing project gives no files. + taskContainerOnly: projectId === task.projectId && projectId !== workspace?.projectId && projectId !== source?.projectId, + }); const sourceProjectId = source?.projectId ?? workspace?.projectId; const trust = resolveCoreTrustPreset({ companyId, agent, issue: task, project: selectedProjects.find(project => project.id === task.projectId),