From f7e36ba3e2e22ef93796042e89795817d57fbc67 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:06:07 -0500 Subject: [PATCH] fix: isolate repository-free low-trust tasks in private directories (#14766) ## Thinking Path > - Paperclip manages work by agents within company boundaries. > - Email tasks can run under the low-trust review preset. > - These tasks must use an isolated workspace and a sandbox. > - The default workspace strategy assumed that the project had a Git repository. > - A project without a configured workspace failed before the agent could start. > - This change gives each such task a private directory and keeps the sandbox requirement. ## Linked Issues or Issue Description **What happened?** An inbound email assigned to a low-trust agent failed with `git_worktree_base_not_git_checkout` when its boundary project had no configured workspace. Setup had accepted the project and sandbox. **Expected behavior** The agent can process email without a repository. Its workspace stays isolated from other tasks and the shared agent home. **Steps to reproduce** 1. Select a low-trust agent with an active sandbox and a project boundary. 2. Leave the project without a configured workspace. 3. Receive an email through AgentMail. 4. Observe that startup fails before provider work starts. **Paperclip version or commit** Reproduced against `5edf55d73`. **Deployment mode** Hosted staging with sandbox execution. Related: #13256 added email tasks. #13636 fixed default isolation for projects without workspaces; the explicit isolation used by low-trust tasks still needed this path. ## What Changed - Select private task directories for low-trust sandbox tasks with no configured workspace or explicit workspace strategy. - Keep each directory scoped to its company and task. Retain files across turns and reassignment and reject symlink paths and mismatched workspace reuse. - Preserve Git validation for configured workspaces and explicit strategies, plus the existing authorization and remote gates for referenced projects. - Add a startup regression and directory isolation tests. Document the supported repository-free path. ## Verification - The startup regression failed before the fix with the same Git validation error. - 260 targeted email, workspace policy, heartbeat, referenced-project and directory tests pass. - Full `pnpm -r typecheck` and `pnpm build` pass on the latest commit. - All CI checks, including the complete sharded test suite and canary dry run, pass on `b4ccd9802b09b2e95499df72d48b4a3906b8c328`. - The final commit also passes the same server shard locally: 60 files, 1,024 passed / 6 skipped tests. The earlier all-groups local run was interrupted during follow-up edits; complete-suite verification comes from CI on the final commit. - Deployed the reviewed commit to staging and independently verified the full serving SHA. Two real Codex runs in Daytona succeeded and finalized the same private company/task workspace. The first wrote a 35-byte marker; the second read the existing file without modifying it and returned the independently verified SHA-256 `ce3bbeb44d07ca6822826d3a5945752a38d30b356d10829f3159a191e5aa92a6`. - Live runtime caveat: Codex reported a nested `bwrap` loopback permission error and used its configured escalated execution inside Daytona. The outer Daytona sandbox remained active for both runs. - The startup regression uses a real database, production trust checks, workspace persistence, sandbox lease acquisition and realization, and a fake provider. It checks reassignment and allows only an authorized referenced project. - The transfer regression runs production archive/sync-back/merge code against distinct filesystem roots: create output in one sandbox, restore it, then read and update it in a fresh sandbox. The provider I/O is emulated; live staging verification is separate. ## Risks - The new default applies only to low-trust sandbox tasks without workspace configuration. Standard agents and explicit Git strategies keep their existing behavior. - Task directories retain work across turns and consume instance storage. The change does not migrate or copy existing shared files. - No database migration or credential changes are required. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository inspection, code execution, and browser tools. The exact runtime model revision and context window are not exposed in this session. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/LOW-TRUST-PRESETS.md | 7 + doc/connections/AGENTMAIL.md | 4 + .../heartbeat-project-repositories.test.ts | 81 ++++++++++- server/src/services/heartbeat.ts | 53 ++++++- .../services/isolated-task-directory.test.ts | 129 ++++++++++++++++++ .../src/services/isolated-task-directory.ts | 48 +++++++ 6 files changed, 320 insertions(+), 2 deletions(-) create mode 100644 server/src/services/isolated-task-directory.test.ts create mode 100644 server/src/services/isolated-task-directory.ts diff --git a/doc/LOW-TRUST-PRESETS.md b/doc/LOW-TRUST-PRESETS.md index 91ac628904..9581e7cfa3 100644 --- a/doc/LOW-TRUST-PRESETS.md +++ b/doc/LOW-TRUST-PRESETS.md @@ -65,6 +65,13 @@ runtime boundary: - workspace runtime-service mutations are denied unless the boundary explicitly grants the `runtime.manage` tool class +When the task's project has no configured workspace and no layer specifies a +workspace strategy, sandbox execution uses a private directory for that company +and task. The directory persists across turns and reassignment and never imports the shared +project directory or agent home. No Git repository is required for this case. +Configured workspaces and explicit Git strategies keep their existing validation; +a missing or broken checkout does not fall back to an empty directory. + The Docker workflow in `doc/UNTRUSTED-PR-REVIEW.md` remains useful for manual local review, but Paperclip-managed low-trust execution requires a sandboxed environment instead of a host-local adapter process. diff --git a/doc/connections/AGENTMAIL.md b/doc/connections/AGENTMAIL.md index 7da4ac7b25..8310cbe846 100644 --- a/doc/connections/AGENTMAIL.md +++ b/doc/connections/AGENTMAIL.md @@ -38,6 +38,10 @@ environment selected for the agent; setup rejects an unavailable runtime. New inbound tasks request isolated execution. The trust preset itself does not sandbox filesystem or network access. Standard agents remain selectable with a warning. +A boundary project without a configured workspace can process email in a private +task directory inside the selected sandbox. It does not need a Git repository. +An explicitly configured workspace strategy still applies and must be usable. + Removing the assigned agent’s saved-connection access or revoking its credential grant stops receiving and sending. Connection creation saves the vaulted binding, human grants, and agent access in one database transaction. diff --git a/server/src/__tests__/heartbeat-project-repositories.test.ts b/server/src/__tests__/heartbeat-project-repositories.test.ts index 354bb549ec..a619a67cef 100644 --- a/server/src/__tests__/heartbeat-project-repositories.test.ts +++ b/server/src/__tests__/heartbeat-project-repositories.test.ts @@ -6,12 +6,14 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { agents, companies, createDb, heartbeatRuns, issues, projects, projectWorkspaces } from "@paperclipai/db"; +import { agents, companies, createDb, environments, executionWorkspaces, heartbeatRuns, issues, projects, projectWorkspaces } from "@paperclipai/db"; import { setExpensiveWorkspaceGitExecutor } from "@paperclipai/adapter-utils/git-workspace-sync"; +import { buildProjectMentionHref } from "@paperclipai/shared"; import { createWorkspaceGitOperationScheduler, WorkspaceGitScanError } from "../services/workspace-git-operation-scheduler.js"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import { heartbeatService } from "../services/heartbeat.ts"; import { instanceSettingsService } from "../services/instance-settings.ts"; +import { environmentRuntimeService } from "../services/environment-runtime.js"; import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js"; const execute = vi.hoisted(() => vi.fn(async (_input: any) => ({ exitCode: 0, signal: null, timedOut: false }))); @@ -50,12 +52,89 @@ suite("task project repository provisioning", () => { afterEach(async () => { await drainHeartbeatRunsToQuiescence(db, heartbeat); setExpensiveWorkspaceGitExecutor(null); + vi.stubEnv("PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC", "false"); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false, enableIsolatedWorkspacesByDefault: false, }); }); + it("isolates repository-free low-trust tasks while preserving reassignment and authorized referenced projects", async () => { + const companyId = randomUUID(), projectId = randomUUID(), agentId = randomUUID(), environmentId = randomUUID(); + const referencedProjectId = randomUUID(), deniedProjectId = randomUUID(); + vi.stubEnv("PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC", "true"); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + await db.insert(companies).values({ id: companyId, name: "Email company", issuePrefix: `E${companyId.slice(0, 6)}`, defaultResponsibleUserId: "responsible-user" }); + await db.insert(projects).values({ id: projectId, companyId, name: "Onboarding" }); + for (const id of [referencedProjectId, deniedProjectId]) { + const source = path.join(root, companyId, id); + await mkdir(source, { recursive: true }); + await writeFile(path.join(source, "reference.txt"), id); + await db.insert(projects).values({ id, companyId, name: id }); + await db.insert(projectWorkspaces).values({ id: randomUUID(), companyId, projectId: id, name: "Reference", sourceType: "local_path", cwd: source, isPrimary: true }); + } + await db.insert(environments).values({ id: environmentId, name: "Email sandbox", driver: "sandbox", status: "active", config: { provider: "fake", image: "fake:test" } }); + await db.insert(agents).values({ + id: agentId, companyId, name: "Email agent", role: "engineer", status: "idle", adapterType: "codex_local", + defaultEnvironmentId: environmentId, adapterConfig: {}, runtimeConfig: {}, + permissions: { trustPreset: "low_trust_review", authorizationPolicy: { + trustPreset: "low_trust_review", trustBoundary: { mode: "low_trust_review", companyId, projectIds: [projectId, referencedProjectId] }, + } }, + }); + // Exercise the real lease and workspace lifecycle with the built-in fake + // provider, executing its setup commands in a disposable filesystem root. + const sandboxHeartbeat = heartbeatService(db, { environmentRuntime: { + ...environmentRuntimeService(db), + execute: async (input) => ({ + exitCode: 0, + stdout: execFileSync(input.command, input.args ?? [], { cwd: root, input: input.stdin, encoding: "utf8", env: { ...process.env, ...input.env } }), + stderr: "", signal: null, timedOut: false, + }), + } }); + const directories: string[] = []; + const issueIds: string[] = []; + for (let index = 0; index < 2; index += 1) { + const issueId = randomUUID(); + issueIds.push(issueId); + await db.insert(issues).values({ + id: issueId, companyId, projectId, title: "Incoming email", originKind: "chat_channel", status: "todo", assigneeAgentId: agentId, + description: [referencedProjectId, deniedProjectId].map((id) => `[@Reference](${buildProjectMentionHref(id)})`).join(" "), + executionWorkspaceSettings: { mode: "isolated_workspace" }, + }); + const run = await sandboxHeartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", contextSnapshot: { issueId, projectId } }); + expect(run).not.toBeNull(); + await vi.waitFor(async () => { + const latest = await sandboxHeartbeat.getRun(run!.id); + expect({ status: latest?.status, error: latest?.error }).toEqual({ status: "succeeded", error: null }); + }, { timeout: 15_000 }); + await drainHeartbeatRunsToQuiescence(db, sandboxHeartbeat); + const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.sourceIssueId, issueId)); + expect(workspace).toMatchObject({ companyId, projectId, mode: "isolated_workspace", strategyType: "project_primary" }); + expect(workspace.cwd).toContain(`/isolated-workspaces/${companyId}/${issueId}`); + expect(execute.mock.calls.filter(([input]) => input.runId === run!.id)).toHaveLength(1); + const call = execute.mock.calls.find(([input]) => input.runId === run!.id)![0]; + expect(call.context.paperclipEnvironment.driver).toBe("sandbox"); + expect(call.context.paperclipWorkspaces.map((workspace: { projectId: string }) => workspace.projectId)).toEqual([referencedProjectId]); + directories.push(workspace.cwd!); + await writeFile(path.join(workspace.cwd!, "email.txt"), `private email ${index}`); + } + expect(directories[0]).not.toBe(directories[1]); + expect(await readFile(path.join(directories[0], "email.txt"), "utf8")).toBe("private email 0"); + const [originalAgent] = await db.select().from(agents).where(eq(agents.id, agentId)); + const reassignedAgentId = randomUUID(); + await db.insert(agents).values({ ...originalAgent, id: reassignedAgentId, name: "Next agent", status: "idle" }); + await db.update(issues).set({ status: "todo", assigneeAgentId: reassignedAgentId }).where(eq(issues.id, issueIds[0])); + const reassignedRun = await sandboxHeartbeat.wakeup(reassignedAgentId, { source: "on_demand", triggerDetail: "manual", contextSnapshot: { issueId: issueIds[0], projectId } }); + await vi.waitFor(async () => { + const latest = await sandboxHeartbeat.getRun(reassignedRun!.id); + expect({ status: latest?.status, error: latest?.error }).toEqual({ status: "succeeded", error: null }); + }, { timeout: 15_000 }); + await drainHeartbeatRunsToQuiescence(db, sandboxHeartbeat); + const reassignedCall = execute.mock.calls.find(([input]) => input.runId === reassignedRun!.id)![0]; + expect(reassignedCall.context.paperclipWorkspace.cwd).toBe(directories[0]); + expect(await readFile(path.join(directories[0], "email.txt"), "utf8")).toBe("private email 0"); + }, 40_000); + it.each([ { scenario: "no configured workspace", configuredWorkspace: false, explicitIsolation: null }, { scenario: "configured Git workspace", configuredWorkspace: true, explicitIsolation: null }, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 388f1583fa..6cce1f365e 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -324,6 +324,7 @@ import { nativeChatWorkspaceCwd, nativeChatWorkspaceMatches, } from "./native-runtime/native-chat-workspace.js"; +import { materializeIsolatedTaskDirectory, shouldUseIsolatedTaskDirectory } from "./isolated-task-directory.js"; import { trackAgentFirstHeartbeat } from "@paperclipai/shared/telemetry"; import { getTelemetryClient } from "../telemetry.js"; import { @@ -12574,9 +12575,10 @@ export function heartbeatService( opts?: { useProjectWorkspace?: boolean | null; executionEnvironmentDriver?: string | null; + anchorWorkspace?: ResolvedAnchorWorkspaceForRun; }, ): Promise { - const anchor = await resolveAnchorWorkspaceForRun( + const anchor = opts?.anchorWorkspace ?? await resolveAnchorWorkspaceForRun( agent, context, previousSessionParams, @@ -21451,6 +21453,19 @@ export function heartbeatService( ); } } + const useIsolatedTaskDirectory = issueRef !== null && shouldUseIsolatedTaskDirectory({ + trustPreset: trustPreset.kind, + environmentDriver: selectedEnvironmentForConfig?.driver ?? null, + mode: requestedExecutionWorkspaceMode, + hasProjectWorkspace: projectContext?.hasWorkspace ?? false, + projectWorkspaceId: issueRef.projectWorkspaceId, + workspaceStrategies: [ + config.workspaceStrategy, + issueAssigneeOverrides?.adapterConfig?.workspaceStrategy, + projectExecutionWorkspacePolicy?.workspaceStrategy, + issueExecutionWorkspaceSettings?.workspaceStrategy, + ], + }); const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig({ agentConfig: config, projectPolicy: projectExecutionWorkspacePolicy, @@ -21462,6 +21477,9 @@ export function heartbeatService( const mergedConfig = { ...workspaceManagedConfig, ...(issueAssigneeOverrides?.adapterConfig ?? {}), + // The base below is already task-owned. Keep directory transport while + // preserving isolated mode and the mandatory sandbox preflight. + ...(useIsolatedTaskDirectory ? { workspaceStrategy: { type: "project_primary" } } : {}), }; const configSnapshot = buildExecutionWorkspaceConfigSnapshot( mergedConfig, @@ -21743,6 +21761,39 @@ export function heartbeatService( return preflightEnvironment.driver; }, resolveWorkspace: async () => { + if (useIsolatedTaskDirectory && issueRef) { + const cwd = await materializeIsolatedTaskDirectory({ + companyId: agent.companyId, + issueId: issueRef.id, + }); + if (reusableExistingExecutionWorkspace && ( + reusableExistingExecutionWorkspace.companyId !== agent.companyId || + reusableExistingExecutionWorkspace.projectId !== issueRef.projectId || + reusableExistingExecutionWorkspace.sourceIssueId !== issueRef.id || + reusableExistingExecutionWorkspace.mode !== "isolated_workspace" || + reusableExistingExecutionWorkspace.strategyType !== "project_primary" || + reusableExistingExecutionWorkspace.cwd !== cwd + )) { + throw new WorkspaceValidationFailure("The existing execution workspace is not this task's isolated directory.", { + workspaceValidation: { reason: "isolated_task_directory_binding_mismatch", issueId: issueRef.id }, + }); + } + return resolveWorkspaceForRun(agent, context, previousSessionParams, { + executionEnvironmentDriver: selectedEnvironmentForConfig?.driver ?? null, + anchorWorkspace: { + cwd, + source: "task_session", + projectId: issueRef.projectId, + workspaceId: null, + repoUrl: null, + repoRef: null, + workspaceHints: [], + warnings: [], + baseCwdFallback: false, + materializationFailures: [], + }, + }); + } if (nativeChatWorkspaceScope && !nativeChatWorkspaceScope.projectId) { const cwd = await materializeNativeChatTaskRoot( nativeChatWorkspaceScope, diff --git a/server/src/services/isolated-task-directory.test.ts b/server/src/services/isolated-task-directory.test.ts new file mode 100644 index 0000000000..bd3d350ff9 --- /dev/null +++ b/server/src/services/isolated-task-directory.test.ts @@ -0,0 +1,129 @@ +import { cp, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import { execFile as execFileCallback } from "node:child_process"; +import { promisify } from "node:util"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { materializeIsolatedTaskDirectory, shouldUseIsolatedTaskDirectory } from "./isolated-task-directory.js"; +import { prepareSandboxManagedRuntime, type SandboxManagedRuntimeClient, type SandboxSyncOperation } from "@paperclipai/adapter-utils/sandbox-managed-runtime"; + +const execFile = promisify(execFileCallback); + +const policy = { + trustPreset: "low_trust_review", + environmentDriver: "sandbox", + mode: "isolated_workspace", + hasProjectWorkspace: false, + projectWorkspaceId: null, + workspaceStrategies: [undefined, null, {}], +}; + +describe("repository-free low-trust workspace selection", () => { + it("selects a private directory for sandbox tasks without a repository", () => { + expect(shouldUseIsolatedTaskDirectory(policy)).toBe(true); + }); + + it.each([ + { trustPreset: "standard" }, + { environmentDriver: "local" }, + { environmentDriver: "ssh" }, + { mode: "shared_workspace" }, + { hasProjectWorkspace: true }, + { projectWorkspaceId: "workspace-1" }, + { workspaceStrategies: [{ type: "git_worktree" }] }, + { workspaceStrategies: [{ existingBranch: "main" }] }, + { workspaceStrategies: [{ provisionCommand: "setup" }] }, + ])("preserves configured workspace requirements: %j", (override) => { + expect(shouldUseIsolatedTaskDirectory({ ...policy, ...override })).toBe(false); + }); +}); + +describe("isolated task directories", () => { + let root: string | undefined; + afterEach(async () => { + vi.unstubAllEnvs(); + if (root) await rm(root, { recursive: true, force: true }); + }); + + async function setup() { + root = await mkdtemp(path.join(os.tmpdir(), "paperclip-isolated-task-")); + vi.stubEnv("PAPERCLIP_HOME", root); + return { companyId: "company-1", issueId: "issue-1" }; + } + + it("retains a task's files across turns without sharing them with another task or company", async () => { + const identity = await setup(); + const cwd = await materializeIsolatedTaskDirectory(identity); + await writeFile(path.join(cwd, "notes.txt"), "private task output"); + expect(await materializeIsolatedTaskDirectory(identity)).toBe(cwd); + expect(await readFile(path.join(cwd, "notes.txt"), "utf8")).toBe("private task output"); + for (const other of [{ issueId: "issue-2" }, { companyId: "company-2" }]) { + const otherCwd = await materializeIsolatedTaskDirectory({ ...identity, ...other }); + expect(otherCwd).not.toBe(cwd); + expect(otherCwd.startsWith(`${cwd}${path.sep}`)).toBe(false); + expect(await readdir(otherCwd)).toEqual([]); + } + }); + + it("rejects a symlink to another task's directory", async () => { + const identity = await setup(); + const cwd = await materializeIsolatedTaskDirectory(identity); + await symlink(cwd, path.join(path.dirname(cwd), "issue-2")); + await expect(materializeIsolatedTaskDirectory({ ...identity, issueId: "issue-2" })) + .rejects.toThrow("not a private directory"); + }); + + it("round-trips sandbox output into the task directory and stages it on the next turn", async () => { + const identity = await setup(); + const cwd = await materializeIsolatedTaskDirectory(identity); + // Only provider I/O is emulated. Production archive, ignore, sync-back and + // merge logic runs against distinct host and remote filesystem roots. + const transfer = async (operations: SandboxSyncOperation[]) => ({ + operations: await Promise.all(operations.map(async (operation) => { + for (const file of operation.files) { + await mkdir(path.dirname(file.targetPath), { recursive: true }); + await cp(file.sourcePath, file.targetPath, { recursive: true, dereference: file.followSymlinks ?? false }); + } + for (const command of operation.postUploadCommands ?? []) { + await execFile("sh", ["-c", command.command]); + } + return { operationId: operation.operationId, filesTransferred: operation.files.length, bytesTransferred: 0 }; + })), + }); + const client: SandboxManagedRuntimeClient = { + makeDir: async (target) => { await mkdir(target, { recursive: true }); }, + writeFile: async (target, bytes) => { await writeFile(target, Buffer.from(bytes)); }, + readFile: async (target) => readFile(target), + listFiles: async (target) => readdir(target), + remove: async (target) => { await rm(target, { recursive: true, force: true }); }, + run: async (command) => { await execFile("sh", ["-c", command]); }, + syncIn: transfer, + syncOut: transfer, + }; + for (let turn = 0; turn < 2; turn += 1) { + const remoteCwd = path.join(root!, `sandbox-${turn}`); + const runtime = await prepareSandboxManagedRuntime({ + spec: { transport: "sandbox", provider: "test", sandboxId: `turn-${turn}`, remoteCwd, timeoutMs: 30_000, apiKey: null }, + adapterKey: "test", + client, + workspaceLocalDir: cwd, + }); + if (turn === 0) { + await writeFile(path.join(remoteCwd, "result.txt"), "created in sandbox"); + } else { + expect(await readFile(path.join(remoteCwd, "result.txt"), "utf8")).toBe("created in sandbox"); + await writeFile(path.join(remoteCwd, "result.txt"), "continued in a new sandbox"); + } + await runtime.restoreWorkspace(); + } + expect(await readFile(path.join(cwd, "result.txt"), "utf8")).toBe("continued in a new sandbox"); + const other = await materializeIsolatedTaskDirectory({ ...identity, issueId: "issue-2" }); + expect(await readdir(other)).toEqual([]); + }); + + it("rejects path traversal identities", async () => { + const identity = await setup(); + await expect(materializeIsolatedTaskDirectory({ ...identity, issueId: "../outside" })) + .rejects.toThrow("Invalid isolated task workspace identity"); + }); +}); diff --git a/server/src/services/isolated-task-directory.ts b/server/src/services/isolated-task-directory.ts new file mode 100644 index 0000000000..be34c930c8 --- /dev/null +++ b/server/src/services/isolated-task-directory.ts @@ -0,0 +1,48 @@ +import { lstat, mkdir, realpath } from "node:fs/promises"; +import path from "node:path"; +import { parseObject } from "../adapters/utils.js"; +import { resolvePaperclipInstanceRoot } from "../home-paths.js"; + +/** A repository-free sandbox task needs isolation, but has no Git base. */ +export function shouldUseIsolatedTaskDirectory(input: { + trustPreset: string; + environmentDriver: string | null; + mode: string; + hasProjectWorkspace: boolean; + projectWorkspaceId: string | null; + workspaceStrategies: unknown[]; +}): boolean { + return input.trustPreset === "low_trust_review" + && input.environmentDriver === "sandbox" + && input.mode === "isolated_workspace" + && !input.hasProjectWorkspace + && !input.projectWorkspaceId + // Never discard an explicit repository/branch requirement or setup hook. + && input.workspaceStrategies.every((value) => Object.keys(parseObject(value)).length === 0); +} + +/** Stable across turns, separate from project roots and shared agent homes. */ +export async function materializeIsolatedTaskDirectory(input: { + companyId: string; + issueId: string; +}): Promise { + // Files belong to the task, so reassignment preserves the same workspace. + const identities = [input.companyId, input.issueId]; + if (identities.some((value) => !/^[a-zA-Z0-9_-]+$/.test(value))) { + throw new Error("Invalid isolated task workspace identity"); + } + const root = resolvePaperclipInstanceRoot(); + await mkdir(root, { recursive: true }); + let cwd = await realpath(root); + for (const segment of ["isolated-workspaces", ...identities]) { + cwd = path.join(cwd, segment); + await mkdir(cwd, { mode: 0o700 }).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "EEXIST") throw error; + }); + const stat = await lstat(cwd); + if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(cwd) !== cwd) { + throw new Error("Isolated task workspace path is not a private directory"); + } + } + return cwd; +}