Files
PaperClipAI/server/src/services/task-workspace-access.ts
T
DottaandClaude Opus 5.5 580ec41110 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>
2026-11-10 11:37:00 -06:00

209 lines
15 KiB
TypeScript

import { measureRunPhase } from "./execution-preparation-timing.js";
import { agents, executionWorkspaces, heartbeatRuns, issues, projects, projectWorkspaces, type Db } from "@paperclipai/db";
import { and, eq, inArray, or, sql } from "drizzle-orm";
import { forbidden } from "../errors.js";
import { accessService } from "./access.js";
import { executionWorkspaceReadSqlCondition, type AuthorizationActor } from "./authorization.js";
import { canonicalExecutionUuid } from "./execution-identity.js";
import { assertSelectedWorkspaceTrust, runRetainsIssueIdentity, taskRepositoryRunColumns, taskRepositoryResponsibleUserMatches } from "./task-repository-acquisition.js";
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
import { isDeepStrictEqual } from "node:util";
import { ProjectAccessDeniedError } from "./project-access-denial.js";
import { taskWorkspaceSelectionSchema, type TaskWorkspaceSelection } from "@paperclipai/shared";
import { taskWorkspaceSelectableCondition } from "./task-workspace-selection.js";
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
/** Read the project requirements of the next selected workspace, without
* admitting execution or moving files. Batch only the tasks needing a denial
* presentation; this adds at most three bounded reads, not queries per task. */
export async function taskWorkspaceProjectRequirements(db: Db, companyId: string, issueIds: string[]) {
const result = new Map<string, { projectIds: string[]; available: boolean }>();
if (!issueIds.length) return result;
const tasks = await db.select({ id: issues.id, projectId: issues.projectId,
executionWorkspaceId: issues.executionWorkspaceId, projectWorkspaceId: issues.projectWorkspaceId,
workspaceSelection: issues.workspaceSelection, workspacePendingSelection: issues.workspacePendingSelection,
workspaceBindingRevision: issues.workspaceBindingRevision,
}).from(issues).where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
const selections = tasks.map(task => {
const pending = task.workspacePendingSelection;
const intent = pending?.intent ?? (!task.executionWorkspaceId ? task.workspaceSelection : null);
const parsed = intent ? taskWorkspaceSelectionSchema.safeParse(intent.selection) : null;
const selection = parsed?.success ? parsed.data : null;
const available = (!pending || pending.expectedBindingRevision === task.workspaceBindingRevision)
&& (!parsed || parsed.success);
const workspaceId = selection ? selection.kind === "existing" ? selection.workspaceId : null : task.executionWorkspaceId;
return { task, selection, available, workspaceId };
});
const workspaceIds = [...new Set(selections.flatMap(row => row.workspaceId ? [row.workspaceId] : []))];
const workspaces = workspaceIds.length ? await db.select({ id: executionWorkspaces.id,
projectId: executionWorkspaces.projectId, projectWorkspaceId: executionWorkspaces.projectWorkspaceId,
selectable: sql<boolean>`${taskWorkspaceSelectableCondition()}`,
}).from(executionWorkspaces).where(and(eq(executionWorkspaces.companyId, companyId), inArray(executionWorkspaces.id, workspaceIds))) : [];
const workspaceMap = new Map(workspaces.map(workspace => [workspace.id, workspace]));
const sources = selections.map(row => {
const workspace = row.workspaceId ? workspaceMap.get(row.workspaceId) : undefined;
const sourceId = row.selection
? row.selection.kind === "configured_source" ? row.selection.projectWorkspaceId : workspace?.projectWorkspaceId
: workspace?.projectWorkspaceId ?? row.task.projectWorkspaceId;
return { ...row, workspace, sourceId };
});
const sourceIds = [...new Set(sources.flatMap(row => row.sourceId ? [row.sourceId] : []))];
const sourceRows = sourceIds.length ? await db.select({ id: projectWorkspaces.id, projectId: projectWorkspaces.projectId })
.from(projectWorkspaces).where(and(eq(projectWorkspaces.companyId, companyId), inArray(projectWorkspaces.id, sourceIds))) : [];
const sourceMap = new Map(sourceRows.map(source => [source.id, source]));
for (const row of sources) {
const source = row.sourceId ? sourceMap.get(row.sourceId) : undefined;
result.set(row.task.id, {
projectIds: [...new Set([row.task.projectId, row.workspace?.projectId, source?.projectId].filter((id): id is string => Boolean(id)))],
available: row.available && (!row.workspaceId || Boolean(row.workspace)) && (!row.sourceId || Boolean(source))
&& (row.selection?.kind !== "existing" || row.workspace?.selectable === true),
});
}
return result;
}
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);
}
throw forbidden(decision.explanation);
}
}
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,
) {
const [workspace] = await measureRunPhase("workspace.access.inventory", async () => db.select().from(executionWorkspaces).where(and(
eq(executionWorkspaces.id, workspaceId), eq(executionWorkspaces.companyId, companyId),
await measureRunPhase("workspace.access.privacy", () => executionWorkspaceReadSqlCondition(db, actor)),
)));
if (!workspace) throw forbidden("Task workspace access is no longer available");
await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, workspace.projectId);
const retainedIssueIds = Object.keys(workspace.metadata?._issuePrivacySources ?? {});
if (workspace.sourceIssueId) retainedIssueIds.push(workspace.sourceIssueId);
const sources = await measureRunPhase("workspace.access.inventory", async () => db.select({ id: issues.id }).from(issues).where(and(
eq(issues.companyId, companyId),
or(eq(issues.executionWorkspaceId, workspaceId), retainedIssueIds.length > 0
? inArray(sql`${issues.id}`, retainedIssueIds.map(id => canonicalExecutionUuid(sql`${id}::text`))) : undefined),
)));
const sourceIds = new Set(sources.map(source => source.id));
if (retainedIssueIds.some(id => !sourceIds.has(id))) throw forbidden("Task workspace source is no longer accessible");
const access = accessService(db as Db);
if (sources.length === 0 && !workspace.projectId) {
const decision = await measureRunPhase("workspace.access.authorization", () => access.decide({ actor, action: "company_scope:read", resource: { type: "company", companyId } }));
if (!decision.allowed) throw forbidden("An unscoped workspace is outside this actor's authorization boundary");
}
for (const source of sources) {
const decision = await measureRunPhase("workspace.access.authorization", () => access.decide({ actor, action: "issue:read", resource: { type: "issue", companyId, issueId: source.id } }));
if (!decision.allowed) throw forbidden("Workspace record contains task data outside this actor's authorization boundary");
}
}
/** Authorize this task's selected execution inputs without classifying disk contents. */
export async function assertTaskWorkspaceExecutionAccess(
db: Db | DbTransaction,
actor: AuthorizationActor,
companyId: string,
input: { issueId: string; workspaceId?: string | null; projectWorkspaceId?: string | null; persistedSelection?: TaskWorkspaceSelection },
) {
return measureRunPhase("workspace.access.execution", async () => {
if (actor.companyId && actor.companyId !== companyId) throw forbidden("Task workspace belongs to another company");
const [task] = await measureRunPhase("workspace.access.task_read", async () => db.select({
id: issues.id, companyId: issues.companyId, projectId: issues.projectId,
executionPolicy: issues.executionPolicy, executionWorkspaceId: issues.executionWorkspaceId,
projectWorkspaceId: issues.projectWorkspaceId, workspaceSelection: issues.workspaceSelection,
workspacePendingSelection: issues.workspacePendingSelection, workspaceBindingRevision: issues.workspaceBindingRevision,
}).from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, companyId))));
if (!task) throw forbidden("Task workspace task is unavailable");
const access = accessService(db as Db);
const [agent] = actor.type === "agent" && actor.agentId
? await measureRunPhase("workspace.access.agent_read", async () => db.select({ id: agents.id, companyId: agents.companyId, permissions: agents.permissions })
.from(agents).where(and(eq(agents.id, actor.agentId!), eq(agents.companyId, companyId)))) : [];
if (actor.type === "agent" && !agent) throw forbidden("Task workspace agent is unavailable");
const [run] = actor.runId ? await measureRunPhase("workspace.access.run_read", async () => db.select(taskRepositoryRunColumns).from(heartbeatRuns)
.where(and(eq(heartbeatRuns.id, actor.runId!), eq(heartbeatRuns.companyId, companyId)))) : [];
if ((actor.source === "agent_jwt" && !actor.runId) || (actor.runId && (!run
|| run.agentId !== actor.agentId || !runRetainsIssueIdentity(run, task.id)))) {
throw forbidden("Task workspace run identity is unavailable");
}
if (run && !taskRepositoryResponsibleUserMatches(actor.onBehalfOfUserId, run.responsibleUserId)) {
throw forbidden("Task workspace responsible user changed");
}
const decision = await measureRunPhase("workspace.access.task_authorization", () => access.decide({ actor, action: "issue:read", resource: { type: "issue", companyId, issueId: task.id } }));
if (!decision.allowed) throw forbidden(decision.explanation);
const selection = task.workspaceSelection?.selection;
const pending = task.workspacePendingSelection;
const pendingSelection = pending?.expectedBindingRevision === task.workspaceBindingRevision ? pending.intent.selection : undefined;
const effectiveSelection = input.persistedSelection !== undefined
? taskWorkspaceSelectionSchema.parse(input.persistedSelection) : undefined;
if (effectiveSelection && (input.workspaceId != null || input.projectWorkspaceId != null
|| (!isDeepStrictEqual(effectiveSelection, selection) && !isDeepStrictEqual(effectiveSelection, pendingSelection)))) {
throw forbidden("Task workspace selection is not a current persisted intent");
}
const workspaceId = effectiveSelection
? effectiveSelection.kind === "existing" ? effectiveSelection.workspaceId : null
: input.workspaceId;
const [workspace] = workspaceId ? await measureRunPhase("workspace.access.workspace_read", async () => db.select({
id: executionWorkspaces.id, companyId: executionWorkspaces.companyId,
projectId: executionWorkspaces.projectId, projectWorkspaceId: executionWorkspaces.projectWorkspaceId,
}).from(executionWorkspaces).where(and(eq(executionWorkspaces.id, workspaceId), eq(executionWorkspaces.companyId, companyId)))) : [];
if (workspaceId && (!workspace || (task.executionWorkspaceId !== workspace.id
&& !(selection?.kind === "existing" && selection.workspaceId === workspace.id)
&& !(pendingSelection?.kind === "existing" && pendingSelection.workspaceId === workspace.id)))) {
throw forbidden("Task workspace binding is unavailable");
}
const selectedSourceId = effectiveSelection
? effectiveSelection.kind === "configured_source" ? effectiveSelection.projectWorkspaceId : workspace?.projectWorkspaceId ?? null
: input.projectWorkspaceId ?? workspace?.projectWorkspaceId ?? task.projectWorkspaceId;
if (input.projectWorkspaceId && input.projectWorkspaceId !== task.projectWorkspaceId
&& input.projectWorkspaceId !== workspace?.projectWorkspaceId
&& !(selection?.kind === "configured_source" && selection.projectWorkspaceId === input.projectWorkspaceId)
&& !(pendingSelection?.kind === "configured_source" && pendingSelection.projectWorkspaceId === input.projectWorkspaceId)) {
throw forbidden("Task workspace source binding is unavailable");
}
const [source] = selectedSourceId ? await measureRunPhase("workspace.access.source_read", async () => db.select({ id: projectWorkspaces.id, projectId: projectWorkspaces.projectId })
.from(projectWorkspaces).where(and(eq(projectWorkspaces.id, selectedSourceId), eq(projectWorkspaces.companyId, companyId)))) : [];
if (selectedSourceId && !source) throw forbidden("Task workspace source is unavailable");
const projectIds = [...new Set([task.projectId, workspace?.projectId, source?.projectId].filter((id): id is string => Boolean(id)))];
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, {
// 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),
workspaceSourceProject: selectedProjects.find(project => project.id === sourceProjectId),
run: run ? { companyId: run.companyId, executionPolicy: run.contextSnapshot?.executionPolicy } : null });
if (trust.kind === "denied") throw forbidden(trust.detail);
await measureRunPhase("workspace.access.trust", () => assertSelectedWorkspaceTrust(db, { companyId, actor, issueId: task.id,
sourceProjectId: sourceProjectId ?? null, sourceWorkspaceId: effectiveSelection ? workspace?.id ?? null : workspace?.id }));
return { task, workspace, source };
});
}