mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:14:02 +01:00
## 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>
209 lines
15 KiB
TypeScript
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 };
|
|
});
|
|
}
|