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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-30 16:15:57 -05:00
1 parent 5edf55d735
commit e4495f93b6
6 files changed
+233 -2

No files matched your search

+7
View File
@@ -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.
+4
View File
@@ -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.
@@ -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 },
+50
View File
@@ -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,
@@ -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");
});
});
@@ -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<string> {
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;
}