fix(workspaces): run private tasks parked in a personal project (#20589)

## 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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-10 11:37:00 -06:00
1 parent accf22076f
commit 580ec41110
2 files changed
+54 -1

No files matched your search

@@ -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/);
});
});
+19 -1
View File
@@ -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),