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; +}