diff --git a/doc/plans/2026-10-09-task-workspaces.md b/doc/plans/2026-10-09-task-workspaces.md index 8b5d3bc0624..ec46ebcd484 100644 --- a/doc/plans/2026-10-09-task-workspaces.md +++ b/doc/plans/2026-10-09-task-workspaces.md @@ -41,7 +41,7 @@ Independent repository acquisition does not inherit private-repository authority Admission with no repository requests remains heartbeat-owned and requires no additional repository-mutation permission. Low-trust admission still rejects managed acquisition bytes in the same physical folder. Immutable recovery uses its admitted folder and skips new repository acquisition even if the task's current binding has changed. A reservation-only recovery hook fences legacy admitted host roots before input capture or provider dispatch. Local recovery uses its immutable local root; sandbox recovery obtains the host path from its verified immutable sync descriptor. SSH recovery uses its remote filesystem directly and does not require a host snapshot descriptor. Older projectless native runs may use the exact persisted run-ID workspace binding without a workspace row. -Independent acquisition reserves a company-scoped canonical physical root as either `anonymous_only` or `managed` in an internal durable record. A short database fence serializes these reservations with repository requests and credential promotion. Low-trust use rejects every managed intent, including a clone still in staging, and reserves anonymous use before capture. Future managed requests cannot publish into that reserved root. The reservation survives controller restart, workspace aliases, metadata replacement, relocation and archival; changing the acquisition mode requires a separate physical root. No database lock remains held through cloning, input capture or provider execution. Known reservations do not rescan historical workspace paths. Initial reservations compare legacy managed intents through accessible canonical ancestors, conservatively rejecting overlapping inaccessible paths while unavailable unrelated historical folders do not block admission. Arbitrary external file writers remain subject to the existing shared-folder constraints. +Independent acquisition reserves a company-scoped canonical physical root as either `anonymous_only` or `managed` in an internal durable record. A short database fence serializes these reservations with repository requests and credential promotion. Low-trust use rejects every managed intent, including a clone still in staging, and reserves anonymous use before capture. Future managed requests cannot publish into that reserved root. The reservation survives controller restart, workspace aliases, metadata replacement, relocation and archival; changing the acquisition mode requires a separate physical root. No database lock remains held through cloning, input capture or provider execution. Anonymous admission also checks the readable candidate repository namespace against company-managed receipt identities, including on an existing reservation. A published identity collision is denied even when the old receipt path is unreadable; mutable ownership markers cannot authorize it. This conservatively requires a fresh root when anonymous and managed publications share an identity. Known reservations do not rescan historical workspace paths. Initial reservations compare legacy managed intents through accessible canonical ancestors, conservatively rejecting overlapping inaccessible paths while unavailable unrelated historical folders do not block admission. Arbitrary external file writers remain subject to the existing shared-folder constraints. 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. diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 194509b621d..4e9492a8e51 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -4577,7 +4577,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { changes: { body: { from: "No mention" } }, }; await f.service.handleWebhook(f.endpoint.publicId, "github", signedGitHubWebhookRequest({ event, delivery: randomUUID(), payload, webhookSecret: f.webhookSecret })); - await expect.poll(async () => (await db.select().from(chatConversations).where(eq(chatConversations.endpointId, f.endpoint.id))).length).toBe(1); + await expect.poll(async () => (await db.select().from(chatConversations).where(eq(chatConversations.endpointId, f.endpoint.id))).length, { timeout: 10_000 }).toBe(1); const deliveries = await db.select().from(chatDeliveries).where(eq(chatDeliveries.endpointId, f.endpoint.id)); expect(deliveries).toEqual(expect.arrayContaining([ expect.objectContaining({ eventKind: "message_updated" }), diff --git a/server/src/__tests__/task-workspace-repositories-trust.test.ts b/server/src/__tests__/task-workspace-repositories-trust.test.ts index c616fdbe6ac..4f8ed67cb13 100644 --- a/server/src/__tests__/task-workspace-repositories-trust.test.ts +++ b/server/src/__tests__/task-workspace-repositories-trust.test.ts @@ -1,6 +1,6 @@ import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { mkdtemp, mkdir, writeFile, readFile, realpath, rm, symlink } from "node:fs/promises"; import fs from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -198,6 +198,55 @@ const support = await getEmbeddedPostgresTestSupport(); }, ); + it.each([ + { marker: "valid", reserved: false }, { marker: "malformed", reserved: false }, { marker: "symlink", reserved: false }, + { marker: "valid", reserved: true }, { marker: "malformed", reserved: true }, { marker: "symlink", reserved: true }, + ])("rejects readable private publication behind an unreadable alias ($marker marker, reserved=$reserved)", async ({ marker, reserved }) => { + const f = await fixture(); + const receipt = await f.request({ kind: "catalog", id: "private-repo" }); + // Emulate a pre-fence managed publication and, optionally, a reservation + // created by the older unreadable-alias bug. + await db.delete(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, f.companyId)); + const checkout = path.join(f.admission.cwd, receipt.repository.relativePath); + await mkdir(checkout, { recursive: true }); + const exec = promisify(execFileCallback); + await exec("git", ["init", "--initial-branch=main", checkout]); + await writeFile(path.join(checkout, "private.txt"), "Private published bytes must never enter low-trust capture\n"); + await exec("git", ["-C", checkout, "add", "."]); + await exec("git", ["-C", checkout, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "private source"]); + const pin = (await exec("git", ["-C", checkout, "rev-parse", "HEAD"])).stdout.trim(); + const owner = path.join(checkout, ".git", "paperclip-workspace-owner.json"); + const ownerJson = JSON.stringify({ version: 1, repositoryId: receipt.operationId, pinnedCommit: pin }); + if (marker === "symlink") { + const target = path.join(root, `${f.issueId}-untrusted-owner`); + await writeFile(target, ownerJson); + await symlink(target, owner); + } else await writeFile(owner, marker === "valid" ? ownerJson : "{unverifiable owner"); + await db.update(executionWorkspaceRepositories).set({ state: "ready", pinnedCommit: pin }).where(eq(executionWorkspaceRepositories.id, receipt.operationId)); + const aliasParent = path.join(root, `${f.issueId}-unreadable`); + await mkdir(aliasParent); + const alias = path.join(aliasParent, "workspace"); + await symlink(f.admission.cwd, alias); + await db.update(executionWorkspaces).set({ cwd: alias }).where(eq(executionWorkspaces.id, f.workspace.id)); + const peer = await lowTrustPeer(f); + const canonicalCwd = await realpath(f.admission.cwd); + if (reserved) await db.insert(executionWorkspaceRepositoryRoots).values({ companyId: f.companyId, canonicalCwd, + rootKey: createHash("sha256").update(canonicalCwd).digest("hex"), mode: "anonymous_only" }); + const actual = fs.realpath.bind(fs); + vi.spyOn(fs, "realpath").mockImplementation(async pathname => { + if (String(pathname).startsWith(aliasParent)) throw Object.assign(new Error("Unreadable legacy alias"), { code: "EACCES" }); + return actual(pathname); + }); + await expect(f.service.prepareForAdmission(peer.admission)).rejects.toThrow(/anonymous public URLs only/); + await expect(f.service.request({ companyId: f.companyId, issueId: peer.admission.issueId, actor: peer.actor, + request: { repository: { kind: "url", url: "https://github.com/public/unrelated" }, requestKey: randomUUID() } })) + .rejects.toThrow(/anonymous public URLs only/); + expect(await f.service.list(f.companyId, peer.workspace.id)).toEqual([]); + expect(acquisition.materialize).not.toHaveBeenCalled(); + expect(acquisition.managedAuth).not.toHaveBeenCalled(); + expect(await readFile(path.join(checkout, "private.txt"), "utf8")).toContain("Private published bytes"); + }); + it.each(["standard", "retained_low_trust", "current_low_trust"] as const)("recovers an exact legacy run-ID binding with %s trust", async trust => { const f = await fixture(); const execution = buildNativeExecutionInput({ diff --git a/server/src/services/execution-workspace-repositories.ts b/server/src/services/execution-workspace-repositories.ts index f7d3f56a762..304c3f64ed2 100644 --- a/server/src/services/execution-workspace-repositories.ts +++ b/server/src/services/execution-workspace-repositories.ts @@ -130,15 +130,38 @@ export function executionWorkspaceRepositoryService(db: Db) { const [existing] = await tx.select().from(executionWorkspaceRepositoryRoots).where(and( eq(executionWorkspaceRepositoryRoots.companyId, companyId), eq(executionWorkspaceRepositoryRoots.rootKey, rootKey))); if (existing && (existing.canonicalCwd !== canonicalCwd || existing.mode !== mode)) throw forbidden(lowTrustRepositoryMessage); - if (existing) return canonicalCwd; if (mode === "anonymous_only") { // Legacy receipts predate root reservations. Even pending/staging work // can publish after admission, so no managed intent in this root is safe. - const managed = await tx.select({ cwd: executionWorkspaces.cwd }).from(executionWorkspaceRepositories) + const managed = await tx.select({ cwd: executionWorkspaces.cwd, repositoryIdentity: executionWorkspaceRepositories.repositoryIdentity }).from(executionWorkspaceRepositories) .innerJoin(executionWorkspaces, eq(executionWorkspaces.id, executionWorkspaceRepositories.executionWorkspaceId)) .where(and(eq(executionWorkspaceRepositories.companyId, companyId), eq(executionWorkspaces.companyId, companyId), isNotNull(executionWorkspaceRepositories.catalogRepositoryId))); - for (const peer of managed) { + if (managed.length) { + 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; + }); + if (namespaceEntry) { + if (!namespaceEntry.isDirectory() || namespaceEntry.isSymbolicLink() || await fs.realpath(namespace) !== namespace) { + throw forbidden(lowTrustRepositoryMessage); + } + // An unreadable/moved legacy alias cannot disprove published private + // bytes in this readable root. Markers are agent-writable, so none can + // authorize this collision: choose a fresh root even if these bytes + // were acquired anonymously. Check existing reservations too, since + // older versions may have admitted this root before fencing it. + for (const identity of new Set(managed.map(peer => peer.repositoryIdentity))) { + const entry = await fs.lstat(path.join(canonicalCwd, taskRepositoryRelativePath(identity))).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (entry) throw forbidden(lowTrustRepositoryMessage); + } + } + } + if (!existing) for (const peer of managed) { if (peer.cwd && await legacyRepositoryRootMayMatch(peer.cwd, canonicalCwd)) throw forbidden(lowTrustRepositoryMessage); } }