From e4495f93b6455d0de56b18d07f7cce36e6fcd2f7 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 30 Sep 2026 16:15:57 -0500 Subject: [PATCH] fix: isolate repository-free low-trust tasks in private directories Preserve sandbox containment without requiring Git for tasks whose project has no configured workspace. Keep explicit workspace strategies and reusable task ownership checks intact. Add the failing startup regression and directory isolation tests. Co-Authored-By: Paperclip --- doc/LOW-TRUST-PRESETS.md | 7 ++ doc/connections/AGENTMAIL.md | 4 + .../heartbeat-project-repositories.test.ts | 50 +++++++++++- server/src/services/heartbeat.ts | 50 ++++++++++++ .../services/isolated-task-directory.test.ts | 76 +++++++++++++++++++ .../src/services/isolated-task-directory.ts | 48 ++++++++++++ 6 files changed, 233 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 91ac628904b..25636c44c31 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, +agent, and task. The directory persists across turns 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 3c589d84f94..5b332241e64 100644 --- a/doc/connections/AGENTMAIL.md +++ b/doc/connections/AGENTMAIL.md @@ -29,6 +29,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 354bb549ecb..22c1d0e3274 100644 --- a/server/src/__tests__/heartbeat-project-repositories.test.ts +++ b/server/src/__tests__/heartbeat-project-repositories.test.ts @@ -6,11 +6,11 @@ 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 { createWorkspaceGitOperationScheduler, WorkspaceGitScanError } from "../services/workspace-git-operation-scheduler.js"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; -import { heartbeatService } from "../services/heartbeat.ts"; +import { heartbeatService, type HeartbeatEnvironmentRuntime } from "../services/heartbeat.ts"; import { instanceSettingsService } from "../services/instance-settings.ts"; import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js"; @@ -56,6 +56,52 @@ suite("task project repository provisioning", () => { }); }); + it("gives repository-free low-trust email tasks separate directories before sandbox acquisition", async () => { + const companyId = randomUUID(), projectId = randomUUID(), agentId = randomUUID(), environmentId = randomUUID(); + 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" }); + 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] }, + } }, + }); + // Stop at the external provider boundary. Everything before it, including + // trust preflight, workspace validation and persistence, uses production code. + const acquireRunLease = vi.fn(async () => { throw new Error("test sandbox acquisition reached"); }); + const sandboxHeartbeat = heartbeatService(db, { environmentRuntime: { + acquireRunLease, releaseRunLeases: async () => [], + } as unknown as HeartbeatEnvironmentRuntime }); + const directories: string[] = []; + for (let index = 0; index < 2; index += 1) { + const issueId = randomUUID(); + await db.insert(issues).values({ + id: issueId, companyId, projectId, title: "Incoming email", originKind: "chat_channel", status: "todo", assigneeAgentId: agentId, + 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(latest?.status).toBe("failed"); + expect(latest?.error).toContain("test sandbox acquisition reached"); + }, { 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}/${agentId}/${issueId}`); + expect(execute.mock.calls.filter(([input]) => input.runId === run!.id)).toHaveLength(0); + directories.push(workspace.cwd!); + await writeFile(path.join(workspace.cwd!, "email.txt"), `private email ${index}`); + } + expect(acquireRunLease).toHaveBeenCalledTimes(2); + expect(directories[0]).not.toBe(directories[1]); + 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 6f569def35c..fcf79ecaf86 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -323,6 +323,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 { @@ -21432,6 +21433,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, @@ -21443,6 +21457,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, @@ -21724,6 +21741,39 @@ export function heartbeatService( return preflightEnvironment.driver; }, resolveWorkspace: async () => { + if (useIsolatedTaskDirectory && issueRef) { + const cwd = await materializeIsolatedTaskDirectory({ + companyId: agent.companyId, + agentId: agent.id, + 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 { + cwd, + source: "task_session" as const, + projectId: issueRef.projectId, + workspaceId: null, + repoUrl: null, + repoRef: null, + workspaceHints: [], + warnings: [], + baseCwdFallback: false, + materializationFailures: [], + additionalWorkspaces: [], + referencedProjectFailures: [], + }; + } 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 00000000000..5deaf3c86af --- /dev/null +++ b/server/src/services/isolated-task-directory.test.ts @@ -0,0 +1,76 @@ +import { mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +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"; + +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", agentId: "agent-1", issueId: "issue-1" }; + } + + it("retains a task's files across turns without sharing them with another task, agent, 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" }, { agentId: "agent-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("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 00000000000..26496b29e19 --- /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; + agentId: string; + issueId: string; +}): Promise { + const identities = [input.companyId, input.agentId, 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; +}