diff --git a/doc/plans/2026-10-09-task-workspaces.md b/doc/plans/2026-10-09-task-workspaces.md index aaf892757b3..f498b4a204d 100644 --- a/doc/plans/2026-10-09-task-workspaces.md +++ b/doc/plans/2026-10-09-task-workspaces.md @@ -45,6 +45,8 @@ Independent acquisition reserves a company-scoped canonical physical root as eit Shared physical roots also retain source workspace and admitted reader identities separately from acquisition mode. Empty inventories do not bypass peer privacy: admission and recovery record the reader; requests and publication must remain readable to every retained reader. Admission registers the root before preparing configured sibling repositories, whose publication callback rechecks the intersection after staging. The fence uses authoritative workspace/run/task identities and current source privacy, never writable Git ownership markers. Ordinary heartbeat-owned admission to the same task's sole source adds no repository-mutation requirement, while any peer source or task requires the full read intersection. Missing retained identity fails closed; automatic release is deferred. +Repository identity alone does not prove that a workspace published into a particular folder. Available unrelated copies remain independent; retained sources and ambiguous unavailable legacy publishers still constrain access. File listing, previews and downloads use the same read-only physical-source resolution and also check current configured project-folder aliases. Those reads create no policy or reader records. Explicit child source choices, including clearing inherited linkage with null, take precedence over parent workspace inheritance; omitted choices retain inheritance and normal project defaults. + Channel defaults use optional properties (inherit), explicit null (clear), concrete value (override). Resource override beats endpoint. Defaults are captured once at task creation and reauthorized on use. Changing/deleting/revoking configuration cannot relocate existing tasks. Project choice and workspace choice are independent; choosing a source does not silently assign organizational project. Endpoints without configuration work normally. The organizational task project retains task identity, environment, and secret scope regardless of the filesystem source. An organizational project with no usable configured folder or repository is not a filesystem source. Unbound tasks without typed selection still retain that project's explicit workspace requirements; persisted and typed source choices retain precedence. Both task-project and source-project policies constrain run trust; source policy controls workspace realization. Channel task creation separately checks assignment authority for the effective organizational project and actual assignee against locked endpoint values inside its transaction. Saving defaults does not grant future senders that authority. diff --git a/server/src/__tests__/chat-project-tools.test.ts b/server/src/__tests__/chat-project-tools.test.ts index 736fd0e4cfc..157cff77c02 100644 --- a/server/src/__tests__/chat-project-tools.test.ts +++ b/server/src/__tests__/chat-project-tools.test.ts @@ -290,6 +290,10 @@ const support = await getEmbeddedPostgresTestSupport(); const response = await create(path, selection); expect(response.status, JSON.stringify(response.body)).toBe(201); expect(response.body.projectId).toBe(selection.inheritExecutionWorkspaceFromIssueId ? f.projectId : null); + if (selection.projectWorkspaceId) { + expect(response.body.projectWorkspaceId).toBe(selection.projectWorkspaceId); + expect(response.body.executionWorkspaceId).toBeNull(); + } expect(response.body.executionPolicy.authorizationPolicy.trustBoundary.projectIds).toEqual([f.projectId]); } } diff --git a/server/src/__tests__/file-resources.test.ts b/server/src/__tests__/file-resources.test.ts index efc8e597639..9bd7d57e88d 100644 --- a/server/src/__tests__/file-resources.test.ts +++ b/server/src/__tests__/file-resources.test.ts @@ -1,4 +1,5 @@ import express from "express"; +import { createHash } from "node:crypto"; import type { Request } from "express"; import { execFile } from "node:child_process"; import fs from "node:fs/promises"; @@ -7,8 +8,9 @@ import path from "node:path"; import { promisify } from "node:util"; import request from "supertest"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { activityLog, agents, companies, createDb, executionWorkspaces, goals, issues, projects, projectWorkspaces, type Db } from "@paperclipai/db"; +import { activityLog, agents, companies, companyMemberships, createDb, executionWorkspaceRepositoryAccess, executionWorkspaceRepositoryRoots, executionWorkspaceRepositories, executionWorkspaces, goals, issueAccessGrants, issues, projects, projectAccessMembers, projectWorkspaces, type Db } from "@paperclipai/db"; import { eq } from "drizzle-orm"; +import { taskRepositoryRelativePath } from "../services/execution-workspace-repositories.js"; import { errorHandler } from "../middleware/index.js"; import { createFileResourceAvailabilityLimiter, @@ -86,6 +88,7 @@ async function seedGraph(db: Db, input: { { id: companyId, name: `Company ${suffix}`, issuePrefix: `F${prefixSuffix}` }, { id: otherCompanyId, name: `Other ${suffix}`, issuePrefix: `G${prefixSuffix}` }, ]); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "board-user", status: "active", membershipRole: "member" }); await db.insert(goals).values([ { id: goalId, companyId, title: "Goal", level: "company", status: "active" }, { id: otherGoalId, companyId: otherCompanyId, title: "Other goal", level: "company", status: "active" }, @@ -328,6 +331,102 @@ describeEmbeddedPostgres("workspace file resources", () => { } }); + it("checks shared physical-root privacy before every file read and retains moved or deleted source provenance", async () => { + const { root, projectRoot, executionRoot, targetProjectRoot } = await makeWorkspace(); + try { + const graph = await seedGraph(db, { projectRoot, executionRoot, targetProjectRoot }); + const canonicalCwd = await fs.realpath(executionRoot); + const rootKey = createHash("sha256").update(canonicalCwd).digest("hex"); + const privateIssueId = crypto.randomUUID(); + await db.update(issues).set({ projectId: null, projectWorkspaceId: null }).where(eq(issues.id, graph.issueId)); + await db.update(executionWorkspaces).set({ projectId: null, projectWorkspaceId: null, providerType: "local_fs" }).where(eq(executionWorkspaces.sourceIssueId, graph.issueId)); + await db.update(projectWorkspaces).set({ cwd: executionRoot }).where(eq(projectWorkspaces.id, graph.projectWorkspaceId)); + await db.insert(issues).values({ id: privateIssueId, companyId: graph.companyId, title: "Private source task", visibility: "private", responsibleUserId: "private-owner" }); + const [source] = await db.insert(executionWorkspaces).values({ companyId: graph.companyId, sourceIssueId: privateIssueId, + name: "Private source workspace", cwd: executionRoot, mode: "shared_workspace", strategyType: "task_directory", providerType: "local_fs" }).returning(); + await fs.writeFile(path.join(executionRoot, "secret.txt"), "PRIVATE_ROOT_BYTES"); + const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false }); + const targets = [{ workspace: "execution" }, { projectId: graph.projectId, workspaceId: graph.projectWorkspaceId }]; + const reads = () => targets.flatMap(target => [ + request(app).get(`/api/issues/${graph.issueId}/file-resources/list`).query(target), + request(app).get(`/api/issues/${graph.issueId}/file-resources/resolve`).query({ ...target, path: "secret.txt" }), + request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ ...target, path: "secret.txt" }), + request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ ...target, path: "secret.txt", download: true }), + request(app).post(`/api/issues/${graph.issueId}/file-resources/availability`).send({ queries: [{ ...target, path: "secret.txt" }] }), + ]); + const deny = async () => { + for (const pending of reads()) { + const response = await pending; + expect(response.status).toBe(404); + for (const hidden of ["PRIVATE_ROOT_BYTES", source.id, privateIssueId, executionRoot, "Private source workspace"]) expect(JSON.stringify(response.body)).not.toContain(hidden); + } + }; + await deny(); + // A closed local workspace exposes no bytes, but its resource metadata + // still must not bypass the same physical source boundary. + await db.update(executionWorkspaces).set({ status: "archived" }).where(eq(executionWorkspaces.sourceIssueId, graph.issueId)); + await deny(); + await db.update(executionWorkspaces).set({ status: "active" }).where(eq(executionWorkspaces.sourceIssueId, graph.issueId)); + expect(await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, graph.companyId))).toEqual([]); + expect(await db.select().from(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, graph.companyId))).toEqual([]); + const [grant] = await db.insert(issueAccessGrants).values({ issueId: privateIssueId, subjectType: "user", subjectId: "board-user", source: "explicit" }).returning(); + for (const pending of reads()) expect((await pending).status).toBe(200); + const content = await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "execution", path: "secret.txt" }).expect(200); + expect(content.body.content.data).toBe("PRIVATE_ROOT_BYTES"); + // A previous admission retains the source even if its logical cwd moves. + await db.insert(executionWorkspaceRepositoryAccess).values({ companyId: graph.companyId, rootKey, canonicalCwd, kind: "source", scopeId: source.id }); + const provenance = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, graph.companyId)); + await db.update(executionWorkspaces).set({ cwd: targetProjectRoot }).where(eq(executionWorkspaces.id, source.id)); + await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id)); + await deny(); + await db.update(issueAccessGrants).set({ revokedAt: null }).where(eq(issueAccessGrants.id, grant.id)); + await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "execution", path: "secret.txt" }).expect(200); + await db.delete(executionWorkspaces).where(eq(executionWorkspaces.id, source.id)); + await deny(); + expect(await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, graph.companyId))).toEqual(provenance); + expect(await db.select().from(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, graph.companyId))).toEqual([]); + } finally { await fs.rm(root, { recursive: true, force: true }); } + }); + + it("protects a private configured-project alias even without an execution workspace", async () => { + const { root, projectRoot, executionRoot, targetProjectRoot } = await makeWorkspace(); + try { + const graph = await seedGraph(db, { projectRoot, executionRoot, targetProjectRoot }); + await db.update(issues).set({ projectId: null, projectWorkspaceId: null }).where(eq(issues.id, graph.issueId)); + await db.update(executionWorkspaces).set({ projectId: null, projectWorkspaceId: null, providerType: "local_fs" }).where(eq(executionWorkspaces.sourceIssueId, graph.issueId)); + await db.update(projects).set({ visibility: "private" }).where(eq(projects.id, graph.targetProjectId)); + await db.update(projectWorkspaces).set({ cwd: executionRoot }).where(eq(projectWorkspaces.id, graph.targetProjectWorkspaceId)); + await fs.writeFile(path.join(executionRoot, "private-project.txt"), "PRIVATE_CONFIGURED_PROJECT"); + const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false }); + await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "execution", path: "private-project.txt" }).expect(404); + await db.insert(projectAccessMembers).values({ companyId: graph.companyId, projectId: graph.targetProjectId, subjectType: "user", subjectId: "board-user" }); + const response = await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "execution", path: "private-project.txt" }).expect(200); + expect(response.body.content.data).toBe("PRIVATE_CONFIGURED_PROJECT"); + } finally { await fs.rm(root, { recursive: true, force: true }); } + }); + + it("does not confuse identical repositories in unrelated available roots with private file provenance", async () => { + const { root, projectRoot, executionRoot, targetProjectRoot } = await makeWorkspace(); + try { + const graph = await seedGraph(db, { projectRoot, executionRoot, targetProjectRoot }); + await db.update(issues).set({ projectId: null, projectWorkspaceId: null }).where(eq(issues.id, graph.issueId)); + const [publicWorkspace] = await db.update(executionWorkspaces).set({ projectId: null, projectWorkspaceId: null, providerType: "local_fs" }).where(eq(executionWorkspaces.sourceIssueId, graph.issueId)).returning(); + const [privateIssue] = await db.insert(issues).values({ companyId: graph.companyId, title: "Unrelated private repository", visibility: "private", responsibleUserId: "private-owner" }).returning(); + const [privateWorkspace] = await db.insert(executionWorkspaces).values({ companyId: graph.companyId, sourceIssueId: privateIssue.id, + name: "Unrelated source", cwd: targetProjectRoot, mode: "shared_workspace", strategyType: "task_directory", providerType: "local_fs" }).returning(); + const identity = "https://github.com/fixture/same-repo.git", relativePath = taskRepositoryRelativePath(identity); + for (const workspace of [publicWorkspace, privateWorkspace]) { + await fs.mkdir(path.join(workspace.cwd!, relativePath), { recursive: true }); + await fs.writeFile(path.join(workspace.cwd!, relativePath, "report.txt"), workspace.id === publicWorkspace.id ? "PUBLIC_COPY" : "PRIVATE_COPY"); + await db.insert(executionWorkspaceRepositories).values({ companyId: graph.companyId, executionWorkspaceId: workspace.id, + repositoryIdentity: identity, repoUrl: identity, relativePath, requestKey: workspace.id, state: "ready" }); + } + const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false }); + const response = await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "execution", path: `${relativePath}/report.txt` }).expect(200); + expect(response.body.content.data).toBe("PUBLIC_COPY"); + } finally { await fs.rm(root, { recursive: true, force: true }); } + }); + it("reads a selected source independently of task project while retaining source privacy", async () => { const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); diff --git a/server/src/__tests__/task-workspace-binding.test.ts b/server/src/__tests__/task-workspace-binding.test.ts index 83df5d0d36b..1161157cb47 100644 --- a/server/src/__tests__/task-workspace-binding.test.ts +++ b/server/src/__tests__/task-workspace-binding.test.ts @@ -103,6 +103,59 @@ const support = await getEmbeddedPostgresTestSupport(); } }); + it.each([ + { entryPoint: "create", parentSource: "task_directory" }, + { entryPoint: "createChild", parentSource: "task_directory" }, + { entryPoint: "create", parentSource: "configured_source" }, + { entryPoint: "createChild", parentSource: "configured_source" }, + ] as const)("honors a child's explicit source through $entryPoint over its parent's $parentSource binding", async ({ entryPoint, parentSource }) => { + const f = await fixture(), tasks = issueService(db), workspaces = executionWorkspaceService(db); + const [sourceProject] = await db.insert(projects).values({ companyId: f.companyId, name: "Filesystem sources" }).returning(); + const [parentConfigured, childConfigured] = await db.insert(projectWorkspaces).values([ + { companyId: f.companyId, projectId: sourceProject.id, name: "Parent files", cwd: `/tmp/parent-${f.issueId}` }, + { companyId: f.companyId, projectId: sourceProject.id, name: "Child files", cwd: `/tmp/child-${f.issueId}` }, + ]).returning(); + if (parentSource === "configured_source") { + await db.update(executionWorkspaces).set({ projectId: sourceProject.id, projectWorkspaceId: parentConfigured.id }) + .where(eq(executionWorkspaces.id, f.workspaceId)); + } + await workspaces.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId); + const parentBefore = await workspaces.inspectTaskWorkspace(f.companyId, f.issueId, f.actor); + const input = { title: "Use child-specific files", projectWorkspaceId: childConfigured.id, workspaceSelectionActor: f.actor }; + const child = entryPoint === "create" + ? await tasks.create(f.companyId, { ...input, parentId: f.issueId }) + : (await tasks.createChild(f.issueId, input)).issue; + expect(child).toMatchObject({ parentId: f.issueId, projectId: null, projectWorkspaceId: childConfigured.id, + executionWorkspaceId: null, executionWorkspacePreference: null }); + expect((await workspaces.inspectTaskWorkspace(f.companyId, child.id, f.actor)).workspace).toBeNull(); + const parentAfter = await workspaces.inspectTaskWorkspace(f.companyId, f.issueId, f.actor); + expect(parentAfter.workspace).toEqual(parentBefore.workspace); + expect(parentAfter.bindingRevision).toBe(parentBefore.bindingRevision); + }); + + it.each(["create", "createChild", "strategy_only"] as const)("preserves an explicit null source through %s while applying organizational defaults", async entryPoint => { + const f = await fixture(), tasks = issueService(db), workspaces = executionWorkspaceService(db); + const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Organizational defaults" }).returning(); + const [defaultSource, parentSource] = await db.insert(projectWorkspaces).values([ + { companyId: f.companyId, projectId: project.id, name: "Default files", cwd: `/tmp/default-${f.issueId}`, isPrimary: true }, + { companyId: f.companyId, projectId: project.id, name: "Parent files", cwd: `/tmp/parent-${f.issueId}` }, + ]).returning(); + await db.update(issues).set({ projectId: project.id }).where(eq(issues.id, f.issueId)); + await db.update(executionWorkspaces).set({ projectId: project.id, projectWorkspaceId: parentSource.id }) + .where(eq(executionWorkspaces.id, f.workspaceId)); + await workspaces.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId); + const input = { title: "Clear parent workspace choice", projectWorkspaceId: null, workspaceSelectionActor: f.actor }; + const child = entryPoint === "create" + ? await tasks.create(f.companyId, { ...input, parentId: f.issueId }) + : (await tasks.createChild(f.issueId, { ...input, + ...(entryPoint === "strategy_only" ? { executionWorkspaceInheritanceMode: "strategy_only" as const } : {}), + })).issue; + expect(child).toMatchObject({ projectId: project.id, projectWorkspaceId: defaultSource.id, + executionWorkspaceId: null, executionWorkspacePreference: null, workspaceSelection: null }); + expect((await workspaces.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).workspace) + .toMatchObject({ id: f.workspaceId, projectWorkspaceId: parentSource.id }); + }); + it.each(["explicit", "channel", "inherited", "legacy"] as const)("keeps a reused task folder's null source authoritative for %s creation", async (source) => { const f = await fixture(), svc = executionWorkspaceService(db), tasks = issueService(db); const settings = instanceSettingsService(db), previous = await settings.getExperimental(); diff --git a/server/src/__tests__/task-workspace-repositories-trust.test.ts b/server/src/__tests__/task-workspace-repositories-trust.test.ts index 03b989481e4..e9aca0422bf 100644 --- a/server/src/__tests__/task-workspace-repositories-trust.test.ts +++ b/server/src/__tests__/task-workspace-repositories-trust.test.ts @@ -9,7 +9,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest import { eq } from "drizzle-orm"; import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaceRepositoryRoots, executionWorkspaceRepositoryAccess, executionWorkspaceRepositories, executionWorkspaces, heartbeatRuns, issueAccessGrants, issues, projects, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; -import { executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js"; +import { assertExecutionWorkspaceRootReadAccess, executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js"; import { buildNativeExecutionInput } from "../services/native-runtime/native-execution-input.js"; import { nativeRuntimeContextFixture } from "../services/native-runtime/runtime-context.test-fixture.js"; import { buildGitAuthInvocation } from "../services/git-credentials.js"; @@ -154,6 +154,35 @@ const support = await getEmbeddedPostgresTestSupport(); }, ); + it.each(["retained", "legacy"] as const)("keeps independent copies of the same repository separate (%s provenance)", async provenance => { + const f = await fixture(), peer = await standardPeer(f); + const peerCwd = path.join(root, peer.issueId); + await mkdir(peerCwd); + await db.update(executionWorkspaces).set({ cwd: peerCwd }).where(eq(executionWorkspaces.id, peer.workspace.id)); + peer.admission.cwd = peerCwd; + await privateTask(f); + const repository = { kind: "url" as const, url: "https://github.com/public/source" }; + await f.request(repository); + await localPrivateClone(f); + const [privateCheckout] = await f.service.prepareForAdmission(f.admission); + await f.service.request({ companyId: f.companyId, issueId: peer.issueId, actor: peer.actor, + request: { repository, requestKey: randomUUID() } }); + await localPrivateClone({ ...f, issueId: peer.issueId }); + const [peerCheckout] = await f.service.prepareForAdmission(peer.admission); + expect(peerCheckout.cwd).not.toBe(privateCheckout.cwd); + expect(await readFile(path.join(peerCheckout.cwd, "private.txt"), "utf8")).toBe("Private task edits\n"); + if (provenance === "legacy") await db.delete(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); + const actual = await vi.importActual("../services/managed-repository-checkout.js"); + acquisition.materialize.mockImplementationOnce(actual.materializeManagedProjectWorkspace); + await expect(f.service.prepareForAdmission(peer.admission)).resolves.toHaveLength(1); + const beforeRead = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); + await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: peerCwd, + sourceWorkspaceId: peer.workspace.id, actor: peer.actor })).resolves.toBeUndefined(); + await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd, + sourceWorkspaceId: f.workspace.id, actor: peer.actor })).rejects.toThrow(/workspace access|authorization boundary/); + expect(await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId))).toEqual(beforeRead); + }); + it("rechecks retained own-workspace privacy without adding an empty-admission membership gate", async () => { const f = await fixture(); await db.delete(companyMemberships).where(eq(companyMemberships.companyId, f.companyId)); diff --git a/server/src/services/execution-workspace-repositories.ts b/server/src/services/execution-workspace-repositories.ts index c2cb96f6ade..2b0cb9f4b49 100644 --- a/server/src/services/execution-workspace-repositories.ts +++ b/server/src/services/execution-workspace-repositories.ts @@ -5,13 +5,13 @@ import fs from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; import path from "node:path"; import { and, eq, isNotNull, or, sql } from "drizzle-orm"; -import { activityLog, agents, heartbeatRuns, projects, executionWorkspaceRepositoryAccess, executionWorkspaceRepositoryRoots, executionWorkspaceRepositories, executionWorkspaces, issues, type Db } from "@paperclipai/db"; +import { activityLog, agents, heartbeatRuns, projects, projectWorkspaces, executionWorkspaceRepositoryAccess, executionWorkspaceRepositoryRoots, executionWorkspaceRepositories, executionWorkspaces, issues, type Db } from "@paperclipai/db"; import { prepareWorkspaceRepositorySchema, type PrepareWorkspaceRepository, type ProjectRepository } from "@paperclipai/shared"; import { withDirectoryPublicationLock } from "@paperclipai/adapter-utils/workspace-restore-merge"; import { conflict, forbidden, notFound } from "../errors.js"; import { parseNativeExecutionInput } from "../vendor/paperclip-runner/index.js"; -import { assertTaskWorkspaceAccess } from "./task-workspace-source-access.js"; -import { authorizationService, canActorReadExecutionWorkspace, executionWorkspaceReadSqlCondition, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js"; +import { assertTaskWorkspaceAccess, assertTaskWorkspaceSourceProjectAccess } from "./task-workspace-source-access.js"; +import { authorizationService, canActorReadExecutionWorkspace, executionWorkspaceReadSqlCondition, issueReadSqlCondition, projectReadSqlCondition, type AuthorizationActor } from "./authorization.js"; import { resolveCoreTrustPreset } from "./trust-preset-resolver.js"; import { captureRunIdentity } from "./run-identity.js"; import { toolAccessService } from "./tool-access.js"; @@ -94,6 +94,101 @@ export function assertRepositoryRetryMatches(row: Pick entry.canonicalCwd !== canonicalCwd)) throw forbidden("Repository root identity conflicts with its retained access record"); + const sourceIds = new Set(entries.filter(entry => entry.kind === "source").map(entry => entry.scopeId)); + if (input.sourceWorkspaceId) sourceIds.add(input.sourceWorkspaceId); + const inspectCurrentAliases = input.inspectCurrentAliases || !entries.some(entry => entry.kind === "reader"); + if (inspectCurrentAliases) { + // Include current physical aliases as well as sticky historical sources. + // Ordinary task files carry privacy even without acquisition receipts. + const workspaces = await tx.select({ id: executionWorkspaces.id, cwd: executionWorkspaces.cwd }).from(executionWorkspaces) + .where(eq(executionWorkspaces.companyId, input.companyId)); + for (const workspace of workspaces) { + if (!workspace.cwd) continue; + const peerRoot = await canonicalRepositoryRoot(workspace.cwd).catch((error: NodeJS.ErrnoException) => { + if (["ENOTDIR", "EACCES", "EPERM"].includes(error.code ?? "")) return null; + throw error; + }); + if (peerRoot === canonicalCwd) sourceIds.add(workspace.id); + } + } + const inventory = await tx.select({ workspaceId: executionWorkspaces.id, cwd: executionWorkspaces.cwd, + identity: executionWorkspaceRepositories.repositoryIdentity }).from(executionWorkspaceRepositories) + .innerJoin(executionWorkspaces, eq(executionWorkspaces.id, executionWorkspaceRepositories.executionWorkspaceId)) + .where(and(eq(executionWorkspaceRepositories.companyId, input.companyId), eq(executionWorkspaces.companyId, input.companyId))); + const namespace = path.join(canonicalCwd, ".paperclip-repositories"); + const namespaceEntry = await fs.lstat(namespace).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + const publishedNames = new Set(); + if (namespaceEntry) { + if (!namespaceEntry.isDirectory() || namespaceEntry.isSymbolicLink() || await fs.realpath(namespace) !== namespace) { + throw forbidden("Repository namespace has no verifiable contained ownership"); + } + // Configured project siblings have a separate source owner. This child + // aggregate owns only task-repo-* publications and their retained provenance. + for (const name of await fs.readdir(namespace)) if (name.startsWith("task-repo-")) publishedNames.add(name); + } + const recognizedNames = new Set(); + for (const peer of inventory) { + const name = path.basename(taskRepositoryRelativePath(peer.identity)); + const published = publishedNames.has(name); + if (!published && !inspectCurrentAliases) continue; + const peerRoot = peer.cwd ? await fs.realpath(peer.cwd).catch((error: NodeJS.ErrnoException) => { + if (["ENOENT", "ENOTDIR", "EACCES", "EPERM"].includes(error.code ?? "")) return null; + throw error; + }) : null; + // A basename identifies a repository, not its publisher. Independent + // copies in available unrelated roots do not contribute files here. + // Retained sources survive cwd changes; unavailable legacy aliases with + // matching publications remain ambiguous and therefore fail closed. + const matches = sourceIds.has(peer.workspaceId) || peerRoot === canonicalCwd + || (published && peerRoot === null) + || (!entries.some(entry => entry.kind === "reader") && peer.cwd && await legacyRepositoryRootMayMatch(peer.cwd, canonicalCwd)); + if (matches) { + sourceIds.add(peer.workspaceId); + if (published) recognizedNames.add(name); + } + } + if ([...publishedNames].some(name => !recognizedNames.has(name))) throw forbidden("Published repository has no retained workspace provenance"); + return { canonicalCwd, rootKey, entries, sourceIds }; +} + +/** File readers share admission's source closure, without changing root policy. */ +export async function assertExecutionWorkspaceRootReadAccess(db: Db | DbTransaction, input: { + companyId: string; cwd: string; actor: AuthorizationActor; sourceWorkspaceId?: string; +}): Promise { + const { canonicalCwd, sourceIds } = await resolveRepositoryRootSources(db, { ...input, inspectCurrentAliases: true }); + for (const sourceId of sourceIds) await assertTaskWorkspaceAccess(db, input.actor, input.companyId, sourceId); + // Configured folders can contain project files before any task has an + // execution workspace. Read access must include their current aliases too. + const configured = await db.select({ projectId: projectWorkspaces.projectId, cwd: projectWorkspaces.cwd }) + .from(projectWorkspaces).where(eq(projectWorkspaces.companyId, input.companyId)); + const checkedProjects = new Set(); + for (const source of configured) { + if (!source.cwd || checkedProjects.has(source.projectId)) continue; + const sourceRoot = await fs.realpath(source.cwd).catch((error: NodeJS.ErrnoException) => { + if (["ENOENT", "ENOTDIR", "EACCES", "EPERM"].includes(error.code ?? "")) return null; + throw error; + }); + if (sourceRoot !== canonicalCwd) continue; + const [project] = await db.select({ id: projects.id }).from(projects).where(and( + eq(projects.id, source.projectId), eq(projects.companyId, input.companyId), await projectReadSqlCondition(db, input.actor))); + if (!project) throw forbidden("Workspace source project is no longer accessible"); + await assertTaskWorkspaceSourceProjectAccess(db, input.actor, input.companyId, source.projectId); + checkedProjects.add(source.projectId); + } +} + /** Workspace aggregate child. It owns acquisition receipts, never task binding or provider sessions. */ export function executionWorkspaceRepositoryService(db: Db) { async function catalog(companyId: string, userId: string | null, localTrusted: boolean) { @@ -172,57 +267,7 @@ export function executionWorkspaceRepositoryService(db: Db) { async function authorizeRootAccess(tx: DbTransaction, input: { companyId: string; cwd: string; sourceWorkspaceId?: string; reader?: RootReader; actor?: AuthorizationActor; preserveOwnAdmission?: boolean; }) { - const canonicalCwd = await canonicalRepositoryRoot(input.cwd); - const rootKey = createHash("sha256").update(canonicalCwd).digest("hex"); - const entries = await tx.select().from(executionWorkspaceRepositoryAccess).where(and( - eq(executionWorkspaceRepositoryAccess.companyId, input.companyId), eq(executionWorkspaceRepositoryAccess.rootKey, rootKey))); - if (entries.some(entry => entry.canonicalCwd !== canonicalCwd)) throw forbidden("Repository root identity conflicts with its retained access record"); - const sourceIds = new Set(entries.filter(entry => entry.kind === "source").map(entry => entry.scopeId)); - if (input.sourceWorkspaceId) sourceIds.add(input.sourceWorkspaceId); - if (!entries.some(entry => entry.kind === "reader")) { - // Bootstrap ordinary task files as well as acquisition receipts. Later - // admissions retain their sources here before they can write this root. - const workspaces = await tx.select({ id: executionWorkspaces.id, cwd: executionWorkspaces.cwd }).from(executionWorkspaces) - .where(eq(executionWorkspaces.companyId, input.companyId)); - for (const workspace of workspaces) { - if (!workspace.cwd) continue; - const peerRoot = await canonicalRepositoryRoot(workspace.cwd).catch((error: NodeJS.ErrnoException) => { - if (["ENOTDIR", "EACCES", "EPERM"].includes(error.code ?? "")) return null; - throw error; - }); - if (peerRoot === canonicalCwd) sourceIds.add(workspace.id); - } - } - const inventory = await tx.select({ workspaceId: executionWorkspaces.id, cwd: executionWorkspaces.cwd, - identity: executionWorkspaceRepositories.repositoryIdentity }).from(executionWorkspaceRepositories) - .innerJoin(executionWorkspaces, eq(executionWorkspaces.id, executionWorkspaceRepositories.executionWorkspaceId)) - .where(and(eq(executionWorkspaceRepositories.companyId, input.companyId), eq(executionWorkspaces.companyId, input.companyId))); - const namespace = path.join(canonicalCwd, ".paperclip-repositories"); - const namespaceEntry = await fs.lstat(namespace).catch((error: NodeJS.ErrnoException) => { - if (error.code === "ENOENT") return null; - throw error; - }); - const publishedNames = new Set(); - if (namespaceEntry) { - if (!namespaceEntry.isDirectory() || namespaceEntry.isSymbolicLink() || await fs.realpath(namespace) !== namespace) { - throw forbidden("Repository namespace has no verifiable contained ownership"); - } - // Configured project siblings have a separate source owner. This child - // aggregate owns only task-repo-* publications and their retained provenance. - for (const name of await fs.readdir(namespace)) if (name.startsWith("task-repo-")) publishedNames.add(name); - } - const recognizedNames = new Set(); - for (const peer of inventory) { - const name = path.basename(taskRepositoryRelativePath(peer.identity)); - const published = publishedNames.has(name); - if (published) recognizedNames.add(name); - // Once a reader was admitted, future sources must be registered through - // this fence. Still reconcile physical publications from older versions. - if (published || (!entries.some(entry => entry.kind === "reader") && peer.cwd && await legacyRepositoryRootMayMatch(peer.cwd, canonicalCwd))) { - sourceIds.add(peer.workspaceId); - } - } - if ([...publishedNames].some(name => !recognizedNames.has(name))) throw forbidden("Published repository has no retained workspace provenance"); + const { canonicalCwd, rootKey, entries, sourceIds } = await resolveRepositoryRootSources(tx, input); const readers = entries.filter(entry => entry.kind === "reader"); if (input.reader && !readers.some(reader => reader.scopeId === input.reader!.runId)) { const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.reader.runId), eq(heartbeatRuns.companyId, input.companyId))); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index ded4421c3d8..9447ed16e61 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -9475,6 +9475,7 @@ export function issueService(db: Db) { const childProjectId = issueData.projectId ?? parent.projectId; const childInheritsParentProject = childProjectId === parent.projectId; const hasExplicitExecutionWorkspaceOverride = + issueData.projectWorkspaceId !== undefined || issueData.executionWorkspaceId !== undefined || issueData.executionWorkspacePreference !== undefined || issueData.executionWorkspaceSettings !== undefined; @@ -9489,10 +9490,11 @@ export function issueService(db: Db) { parentId: parent.id, projectId: childProjectId, projectWorkspaceId: - issueData.projectWorkspaceId ?? - (inheritStrategyOnly && childInheritsParentProject - ? parent.projectWorkspaceId - : undefined), + issueData.projectWorkspaceId !== undefined + ? issueData.projectWorkspaceId + : (inheritStrategyOnly && childInheritsParentProject + ? parent.projectWorkspaceId + : undefined), goalId: issueData.goalId ?? parent.goalId, actorResponsibleUserId: issueData.actorResponsibleUserId ?? null, trustExplicitResponsibleUserId: @@ -10096,6 +10098,7 @@ export function issueService(db: Db) { null); const hasExplicitExecutionWorkspaceOverride = (workspaceSelection !== undefined && workspaceSelectionSource === "explicit") || + issueData.projectWorkspaceId !== undefined || issueData.executionWorkspaceId !== undefined || issueData.executionWorkspacePreference !== undefined || issueData.executionWorkspaceSettings !== undefined; @@ -10119,6 +10122,7 @@ export function issueService(db: Db) { issueData.projectId === workspaceSource.projectId; if ( inheritsSourceProject && + issueData.projectWorkspaceId === undefined && projectWorkspaceId == null && workspaceSource.projectWorkspaceId ) { diff --git a/server/src/services/workspace-file-resources.ts b/server/src/services/workspace-file-resources.ts index 1ee19ee5917..f37e6675bb1 100644 --- a/server/src/services/workspace-file-resources.ts +++ b/server/src/services/workspace-file-resources.ts @@ -1,5 +1,6 @@ import { issueReadSqlCondition, executionWorkspaceReadSqlCondition, projectReadSqlCondition, type AuthorizationActor } from "./authorization.js"; import fs from "node:fs/promises"; +import { assertExecutionWorkspaceRootReadAccess } from "./execution-workspace-repositories.js"; import path from "node:path"; import { and, desc, eq, inArray, isNull } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; @@ -113,6 +114,7 @@ type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect; type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect; type WorkspaceCandidate = { + companyId: string; workspaceKind: WorkspaceFileWorkspaceKind; workspaceId: string; projectId?: string | null; @@ -474,6 +476,7 @@ function candidateFromExecutionWorkspace(row: ExecutionWorkspaceRow): WorkspaceC const rootPath = row.cwd || row.providerRef || null; const remote = !["local_fs", "git_worktree"].includes(provider) || !rootPath || row.status !== "active" || row.closedAt != null; return { + companyId: row.companyId, workspaceKind: "execution_workspace", workspaceId: row.id, provider, @@ -491,6 +494,7 @@ function candidateFromProjectWorkspace( const rootPath = row.cwd ?? null; const remote = !LOCAL_PROJECT_WORKSPACE_SOURCE_TYPES.has(row.sourceType) || !rootPath; return { + companyId: row.companyId, workspaceKind: "project_workspace", workspaceId: row.id, projectId: project?.id ?? row.projectId, @@ -1039,7 +1043,7 @@ async function listChangedWorkspaceFiles(input: { } export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor) { - async function visibleCandidates(candidates: WorkspaceCandidate[]) { + async function visibleCandidates(candidates: WorkspaceCandidate[], skipDeniedSources = false) { if (!actor) return candidates; const visible: WorkspaceCandidate[] = []; for (const candidate of candidates) { @@ -1049,7 +1053,26 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor) : await db.select({ id: projectWorkspaces.id }).from(projectWorkspaces) .innerJoin(projects, eq(projects.id, projectWorkspaces.projectId)) .where(and(eq(projectWorkspaces.id, candidate.workspaceId), await projectReadSqlCondition(db, actor))); - if (rows.length) visible.push(candidate); + if (!rows.length) continue; + if (candidate.rootPath && (!candidate.remote || candidate.provider === "local_fs" || LOCAL_PROJECT_WORKSPACE_SOURCE_TYPES.has(candidate.provider))) { + try { + // Pin the physical root used by every subsequent file operation. + const canonicalCwd = await fs.realpath(candidate.rootPath); + await assertExecutionWorkspaceRootReadAccess(db, { + companyId: candidate.companyId, cwd: canonicalCwd, actor, + sourceWorkspaceId: candidate.workspaceKind === "execution_workspace" ? candidate.workspaceId : undefined, + }); + candidate.rootPath = canonicalCwd; + } catch (error) { + if (isHttpStatus(error, 403) || isHttpStatus(error, 404) + || ["ENOENT", "ENOTDIR", "EACCES", "EPERM"].includes((error as NodeJS.ErrnoException).code ?? "")) { + if (skipDeniedSources) continue; + throw notFound("Workspace file not found"); + } + throw error; + } + } + visible.push(candidate); } return visible; } @@ -1220,7 +1243,7 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor) seen.add(row.workspace.id); candidates.push(candidateFromProjectWorkspace(row.workspace, row.project)); } - return visibleCandidates(candidates); + return visibleCandidates(candidates, true); } async function discoverUniqueProjectWorkspaceMatch(