From 580ec411109736e488b66d9c82cdd6c068e398ae Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:37:00 -0600 Subject: [PATCH] fix(workspaces): run private tasks parked in a personal project (#20589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A private task without a project is parked in its owner's "My private tasks" project > - The spec says an issue-level grant can expose a task without exposing its containing project > - Workspace setup for a run also required project read on the task's project > - So an agent that could read its private task still failed at setup, before any work, when the task was in "My private tasks" > - This pull request accepts task access for that personal project when the project supplies no workspace files > - The benefit is that a person can make a chat or task private and the agent can still work on it, without gaining access to the person's other private tasks ## Linked Issues or Issue Description **What happened** A person made their chat with the CEO agent private. The chat moved into the person's "My private tasks" project. The next CEO run failed at execution setup in about one second with "Project is private and the actor is not an access member." **Expected behavior** The CEO can work in a private chat (or on a private task) that it can read. The CEO does not get access to the person's other private tasks. **Steps to reproduce** 1. Open a chat with an agent and make the chat private (the chat has no project). 2. Send a message to the agent. 3. See the run fail at setup with `deny_project_private` on the person's "My private tasks" project. ## What Changed - `assertTaskWorkspaceExecutionAccess` marks the task's own project as a container when it is not also the selected workspace or source project. - `assertTaskWorkspaceSourceProjectAccess` accepts a `deny_project_private` decision for such a container only when it is a personal "My private tasks" project with no project workspaces. Task read is already authorized earlier in the same check. - Shared private projects, personal projects with workspaces, workspace and source projects, and low-trust boundary denials are not changed. ## Verification - Red then green: `cd server && npx vitest run src/__tests__/agent-project-access.test.ts` (new test `runs a private task in its owner's personal project without granting that project` fails on `master` with the eval's error; 12 passed with the change). The test also checks that the personal project stays unreadable, that a shared private project still needs membership, and that a personal project with a workspace still needs membership. - `npx vitest run src/__tests__/workspace-preparation-history.test.ts src/__tests__/project-workspace-root-provenance.test.ts src/__tests__/task-workspace-child-trust.test.ts src/__tests__/task-workspace-source-copy-privacy.test.ts src/__tests__/privacy-production-review.test.ts` (81 passed). - `tsc --noEmit -p server`: no errors in the changed files. ## Risks - The change relaxes one authorization check. It applies only to the task's own personal project, only after task read is allowed, only for the private-project denial reason, and only when that project has no project workspaces. A project read through any other path is not changed. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../__tests__/agent-project-access.test.ts | 35 +++++++++++++++++++ server/src/services/task-workspace-access.ts | 20 ++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) 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),