Files
PaperClipAI/tests/runner-e2e/agent-filesystem-sandbox.test.ts
T
DottaandClaude Opus 5.5 347d21fcbd test(runner-e2e): scope each agent's sandbox to its own run's saved state (#20253)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The runner E2E chat evals run real agents against a local Paperclip
instance on a CI host
> - PR #20044 hides the eval harness from local native agents with a
Linux Landlock domain around each runnerd launch
> - That domain is the same for every run in an attempt, so an eval
agent can still read another run's saved state
> - Recent chat-eval runs show agents that read other runs' OpenCode
session databases, another run's run log, the instance `pg_wal`,
`control-plane-state.json`, and `/proc/<pid>/cmdline` of harness
processes
> - This pull request scopes each runnerd launch to its own run and
moves the selected case id off the command line
> - The benefit is that an eval agent cannot learn about the case or
other runs from files outside its own run

## Linked Issues or Issue Description

Refs #20044

**What happened**

In local native OpenCode chat-eval cells, worker agents read files that
belong to other runs or to the harness:

- other runs' OpenCode sqlite session databases under
`runtime/paperclip-runner/durable-sessions/` (a status-cards run)
- `/proc/<pid>/cmdline` of harness processes, which shows harness paths
and the case id (`routines.pause-owned`)
- another run's run-log ndjson, and a listing of the instance
`db/pg_wal` (`routines.pause-owned`)
- a listing of `control-plane-state.json`
(`runs-adapters.list-model-options`)

The Landlock sandbox from #20044 hides only the harness (`tests/`, the
Git store, private attempt directories). All runnerd launches share one
hidden set, so the instance's per-run directories stay readable.

**Expected behavior**

An agent process sees only its own run's durable session, OpenCode
session and provider trace. Other runs' sessions, run logs and traces,
the instance database files, secrets and the shared control-plane state
in the attempt instance are denied. The case id is not on any harness
command line.

**Steps to reproduce**

1. Dispatch a local native OpenCode chat-features cell that has two
agents (for example a status-cards case).
2. In the worker's shell, run `ls
<instance>/runtime/paperclip-runner/durable-sessions/*/` and `ls
<instance>/db/pg_wal`.
3. Run `cat /proc/<launcher pid>/cmdline`.
4. Before this change, all three succeed and show other runs' files and
`--id chat-features...`.

## What Changed

- `agent-filesystem-sandbox.py`: new `--expose PATH` option. It grants a
path beneath a hidden directory. Landlock does not check traversal, so
the run uses its own entry while the parent stays unlistable. The helper
refuses a missing exposure, an exposure outside every hidden path, or
one that would reveal a hidden path. It fails the launch unless every
exposed path is readable.
- `agent-filesystem-sandbox.ts`: the launcher passes the instance root
in the sandbox configuration. `runnerdLaunchSandbox` adds per-launch
hidden paths: `durable-sessions/`, `runner/`, `control-plane/`,
`opencode/` under `runtime/paperclip-runner/`, and `data/run-logs/`,
`data/provider-traces/`, `data/backups/`, `db/`, `secrets/`. It exposes
only the launch's own durable-session directory (from `--state-dir`),
Codex runner state directory, OpenCode session directory (from
`--session-id`, named as the OpenCode driver names it) and provider
trace file.
- The wrapper creates the exposed entries, and the shared instance
directories that the product creates on first use, before runnerd
starts. It logs the run-scoped hidden and exposed paths for each launch.
- New `probeRunScopedSandbox`: at server start, a sandboxed shell runs
against a fixture instance. Another run's session, OpenCode database,
runner state, run log, trace, `pg_wal`, secrets, control-plane state and
the server's `/proc/<pid>/environ` must be denied. The run's own
session, OpenCode directory, ACPX directory and trace must be writable.
In CI any failure stops the attempt. The result goes to `server.log`.
- `selectors.ts` and `launch.ts`: the launcher reads the selected cell
from `PAPERCLIP_RUNNER_E2E_SELECTED_ID` and removes it from its
environment.
- `runner-full-stack-e2e.yml` (Run paid cell) and
`chat-catalog-testops-case.yml`: pass the cell in that variable instead
of `--id`. A target branch that does not read the variable still gets
`--id`.
- `SECURITY.md`: documents the run scope, the `/proc` limit and what
stays readable.

## Verification

- `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts
tests/runner-e2e/agent-filesystem-sandbox.test.ts
tests/runner-e2e/workflow-security.test.ts
tests/runner-e2e/manual-private-workflow.test.ts
tests/runner-e2e/private-trust-workflow-security.test.ts` passes on
macOS. The Landlock host tests (including the new fixture-instance probe
test) run only on Linux CI.
- `npx tsc --noEmit -p tests/runner-e2e` shows no errors in the changed
files.
- Paid run
[42611358428](https://github.com/paperclipai/paperclip/actions/runs/42611358428):
`chat-features.runner-opencode.local.agents.change-manager` on the same
commit applied to `chat-eval/master-candidate` (fca4bb7ce3). Result:
passed. In `server.log` the setup probe reports `failures: []` and
`run:otherDurableSession`, `run:durableSessionListing`,
`run:otherRunnerState`, `run:controlPlaneState`,
`run:otherOpencodeSession`, `run:otherRunLog`, `run:databaseWal`,
`run:secrets`, `run:otherProviderTrace` and `run:harnessEnviron` all
`denied`; `run:ownDurableSession`, `run:ownOpencodeSession`,
`run:acpxRuntime` and `run:ownProviderTrace` `allowed`;
`run:harnessCmdline` `allowed` (recorded, Landlock limit). The runnerd
launch line lists the nine run-scoped hidden instance paths and exposes
only its own durable-session and OpenCode session directories. The agent
finished the case inside that sandbox.

## Risks

- A sandboxed runnerd cannot create new direct entries in the instance
root, `runtime/`, `runtime/paperclip-runner/` or `data/`. The wrapper
creates the known ones first (`acpx`, `companies`, `projects`,
`workspaces`, `runtime-context-assets`, `skills`,
`runtime/provider-packs`). A provider that creates a different new
directory there fails in a sandboxed local cell. The paid run covers
OpenCode only. ACPX cells keep their runtime directory readable.
- A run still reads its own session's control-plane state, and other
agents' homes and task workspaces in the same instance.
- Landlock cannot hide `/proc/<pid>/cmdline`. Harness paths stay visible
there. Only the case id moves to the environment. Scheduled multi-cell
campaigns still pass `--id`.
- Harness-only change. No product code changes.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), extended thinking, tool
use (shell, file edits, GitHub CLI).

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 07:28:12 -06:00

394 lines
23 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
import { mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
HARNESS_PRIVATE_TEMPORARY_CHILDREN,
agentFilesystemSandboxConfig,
agentTemporaryParent,
defaultPlaywrightCacheDirectory,
playwrightCacheDirectory,
isRunnerdLaunch,
parseAgentFilesystemSandboxConfig,
probeAgentFilesystemSandbox,
pythonExecutable,
sandboxedInvocation,
agentFilesystemSandboxHelper,
wrapRunnerdSpawn,
wrapRunnerdLaunch,
gitStorePaths,
RUNNERD_LAUNCH_APIS,
instanceRunStatePaths,
opencodeSessionDirectoryName,
prepareRunnerdLaunchSandbox,
probeRunScopedSandbox,
runnerdLaunchSandbox,
} from "./agent-filesystem-sandbox.js";
import { RUNNER_SELECTED_ID_ENV, parseRunnerSelectors, selectorArgumentsWithEnvironment } from "./selectors.js";
import { runnerMatrix } from "./catalog.js";
const repositoryRoot = "/work/paperclip";
const temporaryRoot = "/var/tmp/paperclip-runner-e2e-abc";
const cell = (id: string) => {
const execution = runnerMatrix.find((candidate) => candidate.id === id);
if (!execution) throw new Error(`missing ${id}`);
return execution;
};
const gitStores = ["/work/paperclip/.git"];
const opencodeLocal = cell("task-workspaces.runner-opencode.local.task-directory-repository-resume");
const base = {
platform: "linux" as const,
env: { GITHUB_ACTIONS: "true", RUNNER_TEMP: "/work/_temp" },
executions: [opencodeLocal],
repositoryRoot,
temporaryRoot,
privateDir: "/work/_temp/manual-private-evidence-x/capture",
evidenceRoot: "/work/_temp/manual-private-evidence-x",
installedRelease: false,
sharedTemporary: "/tmp",
gitStores,
};
describe("agent filesystem sandbox selection", () => {
it("hides the harness, the trusted copy and captured evidence from local native agents", () => {
expect(agentFilesystemSandboxConfig(base)).toEqual({
hidden: [
"/work/paperclip/tests",
"/work/paperclip/.git",
"/work/paperclip/.manual-private-trusted",
`${temporaryRoot}/artifacts-private`,
`${temporaryRoot}/control`,
`${temporaryRoot}/selected-private-inputs`,
`${temporaryRoot}/playwright-transform-cache`,
"/work/_temp/manual-private-evidence-x/capture",
"/work/_temp/manual-private-evidence-x",
"/work/_temp",
],
required: true,
});
});
it("hides the attempt's private children and Playwright cache when its root is outside the shared temporary directory", () => {
const config = agentFilesystemSandboxConfig(base);
expect(config?.hidden).toContain(playwrightCacheDirectory(temporaryRoot));
expect(config?.hidden).not.toContain(temporaryRoot);
expect(HARNESS_PRIVATE_TEMPORARY_CHILDREN).toContain("playwright-transform-cache");
expect(defaultPlaywrightCacheDirectory("/tmp")).toMatch(/^\/tmp\/playwright-transform-cache-/);
});
it("fails closed in CI when harness material sits where it cannot be hidden", () => {
const underShared = { ...base, temporaryRoot: "/tmp/paperclip-runner-e2e-abc", privateDir: "/tmp/paperclip-runner-e2e-abc/artifacts-private" };
expect(() => agentFilesystemSandboxConfig(underShared)).toThrow(/agent_filesystem_sandbox_incomplete: .*artifacts-private/);
expect(() => agentFilesystemSandboxConfig({ ...base, repositoryRoot: "/tmp/checkout" })).toThrow(/\/tmp\/checkout\/tests/);
});
it("keeps a sandboxed attempt's root out of the shared temporary directory", () => {
const parent = agentTemporaryParent(true);
if (process.platform === "linux") expect(parent).toBe(realpathSync("/var/tmp"));
expect(agentTemporaryParent(false)).toBe(realpathSync(os.tmpdir()));
});
it("never hides the agent's own state or the checkout root, and only warns outside CI", () => {
const config = agentFilesystemSandboxConfig({
...base,
env: { RUNNER_TEMP: "/tmp" },
temporaryRoot: "/tmp/paperclip-runner-e2e-abc",
privateDir: "/tmp/paperclip-runner-e2e-abc/artifacts-private",
evidenceRoot: "/work",
});
expect(config).toEqual({ hidden: ["/work/paperclip/tests", "/work/paperclip/.git", "/work/paperclip/.manual-private-trusted"], required: false });
});
it.each([
["macOS", { platform: "darwin" as const }],
["an installed release", { installedRelease: true }],
["remote-only cells", { executions: [cell("rolling-web-deployment.runner-opencode.daytona.recover-controller")] }],
["legacy adapters", { executions: [cell("blocker-guidance.legacy-codex.local.human-authority")] }],
["Codex's own mount sandbox", { executions: [opencodeLocal, cell("necessary-question.runner-codex.local.necessary-free-text")] }],
])("does not apply to %s", (_label, override) => {
expect(agentFilesystemSandboxConfig({ ...base, ...override })).toBeNull();
});
it("round-trips through the server environment and rejects malformed values", () => {
const config = agentFilesystemSandboxConfig(base)!;
expect(parseAgentFilesystemSandboxConfig(JSON.stringify(config))).toEqual(config);
expect(parseAgentFilesystemSandboxConfig(undefined)).toBeNull();
expect(() => parseAgentFilesystemSandboxConfig(JSON.stringify({ hidden: ["relative"], required: true }))).toThrow(/malformed/);
});
});
describe("run-scoped runnerd sandbox", () => {
const instanceRoot = `${temporaryRoot}/paperclip-home/instances/runner-e2e-1`;
const runner = `${instanceRoot}/runtime/paperclip-runner`;
const config = { hidden: ["/work/paperclip/tests"], required: true, instanceRoot };
it("keeps the instance root in the attempt's sandbox configuration", () => {
expect(agentFilesystemSandboxConfig({ ...base, instanceRoot })?.instanceRoot).toBe(instanceRoot);
expect(agentFilesystemSandboxConfig({ ...base, instanceRoot: "/tmp/home/instances/x" })?.instanceRoot).toBeUndefined();
const parsed = parseAgentFilesystemSandboxConfig(JSON.stringify(config));
expect(parsed).toEqual(config);
expect(() => parseAgentFilesystemSandboxConfig(JSON.stringify({ ...config, instanceRoot: "relative" }))).toThrow(/malformed/);
});
it("hides every run's state and keeps only the launch's own durable session, OpenCode session and trace", () => {
const trace = `${instanceRoot}/data/provider-traces/4b1c.ndjson`;
const launch = runnerdLaunchSandbox(config, [
"--connect-url", "ws://127.0.0.1:1/durableRecovery/connect",
"--state-dir", `${runner}/durable-sessions/abc123/runner`,
"--run-id", "run-1", "--session-id", "ses:own/1",
], { PAPERCLIP_OPENCODE_RUNTIME_DIR: `${runner}/opencode`, PAPERCLIP_PROVIDER_TRACE_PATH: trace });
expect(launch.hidden).toEqual([
"/work/paperclip/tests",
`${runner}/durable-sessions`,
`${runner}/runner`,
`${runner}/control-plane`,
`${runner}/opencode`,
`${instanceRoot}/data/run-logs`,
`${instanceRoot}/data/provider-traces`,
`${instanceRoot}/data/backups`,
`${instanceRoot}/db`,
`${instanceRoot}/secrets`,
]);
expect(launch.exposed).toEqual([`${runner}/durable-sessions/abc123`, `${runner}/opencode/ses_own_1`, trace]);
expect(launch.exposedFiles).toEqual([trace]);
expect(launch.precreated).toEqual([`${runner}/acpx`, ...["companies", "projects", "workspaces", "runtime-context-assets", "skills", "runtime/provider-packs"].map((name) => `${instanceRoot}/${name}`)]);
expect(launch.required).toBe(true);
});
it("keeps a Codex runner's own state directory and exposes nothing it was not given", () => {
const launch = runnerdLaunchSandbox(config, ["--state-dir", `${runner}/runner/run-9`, "--run-id", "run-9"], {
PAPERCLIP_PROVIDER_TRACE_PATH: `${instanceRoot}/elsewhere/trace.ndjson`,
});
expect(launch.exposed).toEqual([`${runner}/runner/run-9`]);
// A state directory naming the shared root itself, or escaping it, exposes nothing.
expect(runnerdLaunchSandbox(config, ["--state-dir", `${runner}/durable-sessions`], {}).exposed).toEqual([]);
expect(runnerdLaunchSandbox(config, ["--state-dir", `${runner}/durable-sessions/../control-plane`], {}).exposed).toEqual([]);
expect(runnerdLaunchSandbox(config, ["--state-dir", "relative/runner"], {}).exposed).toEqual([]);
});
it("adds nothing without an instance root", () => {
const launch = runnerdLaunchSandbox({ hidden: ["/h"], required: false }, ["--state-dir", `${runner}/durable-sessions/a/runner`], {});
expect(launch).toEqual({ hidden: ["/h"], exposed: [], exposedFiles: [], precreated: [], required: false });
});
it("names the OpenCode session directory as the driver does", () => {
expect(opencodeSessionDirectoryName("ses_1:x/y")).toBe("ses_1_x_y");
expect(opencodeSessionDirectoryName("a".repeat(200))).toBe("a".repeat(120));
expect(opencodeSessionDirectoryName("..")).toBeNull();
const driver = readFileSync(path.join(import.meta.dirname, "../../packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts"), "utf8");
const body = /export function opencodeRuntimeSessionDirectoryName\([^)]*\)[^{]*\{([\s\S]*?)\n\}/.exec(driver)?.[1] ?? "";
expect(body.replace(/\s+/g, "")).toContain('.replace(/[^a-zA-Z0-9._-]/g,"_").slice(0,120)');
});
it("creates the launch's own entries before runnerd starts and passes them as exposures", async () => {
const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "agent-sandbox-run-")));
try {
const instance = path.join(root, "home", "instances", "i");
const paths = instanceRunStatePaths(instance);
const trace = path.join(paths.providerTraces, "t.ndjson");
const calls: unknown[][] = [];
const fake = ((...args: unknown[]) => { calls.push(args); return { pid: 3 }; }) as unknown as typeof import("node:child_process").spawn;
const spawn = wrapRunnerdSpawn(fake, { hidden: ["/work/paperclip/tests"], required: true, instanceRoot: instance }, "/usr/bin/python3");
const state = path.join(paths.durableSessions, "scope", "runner");
spawn("/r/paperclip-runnerd", ["--state-dir", state, "--session-id", "own"], { env: { PAPERCLIP_PROVIDER_TRACE_PATH: trace } });
expect(statSync(path.join(paths.durableSessions, "scope")).isDirectory()).toBe(true);
expect(statSync(path.join(paths.opencode, "own")).mode & 0o777).toBe(0o700);
expect(statSync(trace).isFile()).toBe(true);
expect(existsSync(paths.acpx)).toBe(true);
const args = calls[0]![1] as string[];
expect(args).toEqual(expect.arrayContaining(["--hide", paths.durableSessions, "--expose", path.join(paths.durableSessions, "scope"), "--expose", trace]));
expect(args.slice(args.indexOf("--"))).toEqual(["--", "/r/paperclip-runnerd", "--state-dir", state, "--session-id", "own"]);
// Creating an exposed trace never truncates one the run already wrote.
const launch = runnerdLaunchSandbox({ hidden: [], required: true, instanceRoot: instance }, [], { PAPERCLIP_PROVIDER_TRACE_PATH: trace });
await writeFile(trace, "frame\n");
prepareRunnerdLaunchSandbox(launch);
expect(await readFile(trace, "utf8")).toBe("frame\n");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("rejects exposures that are missing, outside every hidden path, or that would reveal one", async () => {
const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "agent-sandbox-expose-")));
try {
const hidden = path.join(root, "sessions");
await mkdir(path.join(hidden, "own", "inner"), { recursive: true });
const run = (...args: string[]) => {
try {
execFileSync(pythonExecutable(), ["-I", "-S", agentFilesystemSandboxHelper, ...args, "--", "/bin/true"], { stdio: "pipe" });
return 0;
} catch (error) {
return (error as { status: number }).status;
}
};
expect(run("--hide", hidden, "--expose", path.join(hidden, "missing"))).toBe(2);
expect(run("--hide", hidden, "--expose", root)).toBe(2);
expect(run("--hide", hidden, "--expose", hidden)).toBe(2);
expect(run("--hide", path.join(hidden, "own", "inner"), "--hide", hidden, "--expose", path.join(hidden, "own"))).toBe(2);
expect(run("--hide", hidden, "--expose", "relative")).toBe(2);
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
describe("selected cell outside the command line", () => {
it("reads the selected cell from the environment and removes it", () => {
const env: NodeJS.ProcessEnv = { [RUNNER_SELECTED_ID_ENV]: " chat-features.runner-opencode.local.agents.change-manager " };
const args = selectorArgumentsWithEnvironment([], env);
expect(args).toEqual(["--id", "chat-features.runner-opencode.local.agents.change-manager"]);
expect(env[RUNNER_SELECTED_ID_ENV]).toBeUndefined();
expect(parseRunnerSelectors(args).ids).toEqual(["chat-features.runner-opencode.local.agents.change-manager"]);
expect(selectorArgumentsWithEnvironment(["--", "--id", "x"], {})).toEqual(["--", "--id", "x"]);
});
it("is what CI passes instead of --id", async () => {
for (const workflow of ["runner-full-stack-e2e.yml", "chat-catalog-testops-case.yml"]) {
const source = await readFile(path.join(import.meta.dirname, "../../.github/workflows", workflow), "utf8");
expect(source).toContain(`${RUNNER_SELECTED_ID_ENV}: \${{`);
expect(source).not.toMatch(/test:e2e:runner -- --id "\$\{\{/);
}
});
});
describe("runnerd launch rewriting", () => {
it("wraps only the absolute runnerd executable and execs it with the original argv", () => {
const runnerd = "/work/paperclip/packages/paperclip-runner/runner/target/debug/paperclip-runnerd";
expect(isRunnerdLaunch(runnerd)).toBe(true);
expect(isRunnerdLaunch("paperclip-runnerd")).toBe(true);
expect(isRunnerdLaunch("/usr/bin/git")).toBe(false);
const invocation = sandboxedInvocation({ hidden: ["/work/paperclip/tests"], required: true }, "/usr/bin/python3", runnerd, ["--run-id", "r"], {});
expect(invocation).toEqual({
command: "/usr/bin/python3",
args: ["-I", "-S", agentFilesystemSandboxHelper, "--required", "--unset", "LC_CTYPE", "--hide", "/work/paperclip/tests", "--", runnerd, "--run-id", "r"],
});
expect(sandboxedInvocation({ hidden: [], required: false }, "p", runnerd, [], { LC_CTYPE: "C" }).args).toContain("LC_CTYPE=C");
});
it("rewrites runnerd launches through the wrapped spawn and passes other launches through", () => {
const calls: unknown[][] = [];
const fake = ((...args: unknown[]) => { calls.push(args); return { pid: 7 }; }) as unknown as typeof import("node:child_process").spawn;
const spawn = wrapRunnerdSpawn(fake, { hidden: ["/work/paperclip/tests"], required: true }, "/usr/bin/python3");
const runnerd = "/work/paperclip/packages/paperclip-runner/runner/target/debug/paperclip-runnerd";
spawn(runnerd, ["--run-id", "r"], { cwd: "/work", env: { LC_CTYPE: "C.UTF-8" }, detached: true });
spawn("/usr/bin/git", ["status"], { cwd: "/work" });
expect(calls[0]).toEqual(["/usr/bin/python3", expect.arrayContaining(["--hide", "/work/paperclip/tests", "--", runnerd, "--run-id", "r"]),
{ cwd: "/work", env: { LC_CTYPE: "C.UTF-8" }, detached: true }]);
expect(calls[1]).toEqual(["/usr/bin/git", ["status"], { cwd: "/work" }]);
});
it("rewrites runnerd for every direct launch API and refuses a shell launch", () => {
const calls: unknown[][] = [];
const fake = (...args: unknown[]) => { calls.push(args); return { pid: 9 }; };
const config = { hidden: ["/work/paperclip/tests"], required: true };
const callback = () => undefined;
wrapRunnerdLaunch(fake, config, "/usr/bin/python3", "execFile")("paperclip-runnerd", ["--x"], { cwd: "/w" }, callback);
wrapRunnerdLaunch(fake, config, "/usr/bin/python3", "spawnSync")("/r/paperclip-runnerd", { cwd: "/w" });
expect(calls[0]).toEqual(["/usr/bin/python3", expect.arrayContaining(["--", "paperclip-runnerd", "--x"]), { cwd: "/w" }, callback]);
expect(calls[1]).toEqual(["/usr/bin/python3", expect.arrayContaining(["--", "/r/paperclip-runnerd"]), { cwd: "/w" }]);
expect(() => wrapRunnerdLaunch(fake, config, "/usr/bin/python3")("/r/paperclip-runnerd", [], { shell: true })).toThrow(/refusing/);
expect(RUNNERD_LAUNCH_APIS).toEqual(["spawn", "spawnSync", "execFile", "execFileSync"]);
});
it("keeps promisify(execFile) resolving { stdout, stderr } after wrapping", async () => {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const wrapped = wrapRunnerdLaunch(execFile, { hidden: ["/nowhere"], required: false }, "/usr/bin/python3", "execFile");
const result = await promisify(wrapped)(process.execPath, ["-e", "process.stdout.write('ok')"]);
expect(result).toEqual({ stdout: "ok", stderr: "" });
});
it("hides a linked worktree's gitdir and the shared object store", async () => {
const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "agent-sandbox-git-")));
try {
const common = path.join(root, "main", ".git");
const gitDir = path.join(common, "worktrees", "wt");
await mkdir(gitDir, { recursive: true });
await writeFile(path.join(gitDir, "commondir"), "../..\n");
const checkout = path.join(root, "wt");
await mkdir(checkout);
await writeFile(path.join(checkout, ".git"), `gitdir: ${gitDir}\n`);
expect(gitStorePaths(checkout)).toEqual([path.join(checkout, ".git"), gitDir, common]);
expect(gitStorePaths(path.join(root, "main"))).toEqual([common]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("installs the sandbox in the E2E server entry before the product starts", async () => {
const entry = await readFile(path.join(import.meta.dirname, "server-entry.ts"), "utf8");
const install = entry.indexOf("installAgentFilesystemSandbox(");
expect(install).toBeGreaterThan(0);
expect(install).toBeLessThan(entry.indexOf('await import("../../cli/src/index.js")'));
});
});
const landlock = process.platform === "linux" && (() => {
const probe = mkdtempSync(path.join(os.tmpdir(), "agent-sandbox-abi-"));
try {
execFileSync(pythonExecutable(), ["-I", "-S", agentFilesystemSandboxHelper, "--required", "--hide", probe, "--", "/bin/true"]);
return true;
} catch {
return false;
} finally {
rmSync(probe, { recursive: true, force: true });
}
})();
describe.skipIf(!landlock)("Landlock agent sandbox on this host", () => {
const roots: string[] = [];
afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); });
it("denies a sandboxed process the harness while keeping the product and temporary files usable", async () => {
const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "agent-sandbox-")));
roots.push(root);
const repository = path.join(root, "checkout");
await mkdir(path.join(repository, "tests", "runner-e2e", "chat-scenarios"), { recursive: true });
await mkdir(path.join(repository, "packages", "paperclip-runner"), { recursive: true });
await mkdir(path.join(root, "scratch"));
await writeFile(path.join(repository, "tests", "runner-e2e", "agent-filesystem-sandbox.ts"), "grader");
await writeFile(path.join(repository, "tests", "runner-e2e", "chat-scenarios", "access.yaml"), "expected_outcome: secret");
await writeFile(path.join(repository, "packages", "paperclip-runner", "package.json"), "{}");
const attempt = path.join(root, "attempt");
await mkdir(path.join(attempt, "artifacts-private"), { recursive: true });
await mkdir(path.join(attempt, "workspace"));
await writeFile(path.join(attempt, "artifacts-private", "server.log"), "secret");
const config = { hidden: [path.join(repository, "tests"), path.join(attempt, "artifacts-private"), path.join(attempt, "control")], required: true };
// The fixture lives under the shared temporary directory, so point mktemp at a granted sibling.
const probe = probeAgentFilesystemSandbox(config, repository, pythonExecutable(), { ...process.env, TMPDIR: path.join(root, "scratch") }, path.join(root, "no-playwright-cache"));
expect(probe.failures).toEqual([]);
expect(probe.result).toMatchObject({ playwrightDefaultCache: "denied", harnessFile: "denied", harnessDirectory: "denied", productFile: "readable", temporaryWrite: "allowed" });
// The exact read from the reported run: grep over the checkout's scenarios.
const invocation = sandboxedInvocation(config, pythonExecutable(), "/bin/sh", ["-c", `grep -rn expected_outcome ${repository}/tests; echo "exit=$?"`], process.env);
const output = execFileSync(invocation.command, invocation.args, { encoding: "utf8" });
expect(output).not.toContain("secret");
expect(output).toMatch(/exit=2/);
const attemptCheck = sandboxedInvocation(config, pythonExecutable(), "/bin/sh", ["-c", `cat ${attempt}/artifacts-private/server.log; ls ${attempt}/workspace >/dev/null && echo workspace=ok; mkdir ${attempt}/control 2>/dev/null || echo control=blocked`], process.env);
const attemptOutput = execFileSync(attemptCheck.command, attemptCheck.args, { encoding: "utf8" });
expect(attemptOutput).not.toContain("secret");
expect(attemptOutput).toContain("workspace=ok");
expect(attemptOutput).toContain("control=blocked");
});
it("keeps a run's own session and trace and denies every other run's state, the database and secrets", async () => {
const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "agent-sandbox-run-scope-")));
roots.push(root);
const home = path.join(root, "attempt", "paperclip-home");
await mkdir(path.join(home, "instances", "i"), { recursive: true });
await mkdir(path.join(root, "attempt", "artifacts-private"));
const config = { hidden: [path.join(root, "attempt", "artifacts-private")], required: true, instanceRoot: path.join(home, "instances", "i") };
const probe = probeRunScopedSandbox(config, pythonExecutable(), process.env);
expect(probe.failures).toEqual([]);
expect(probe.result).toMatchObject({
"run:otherDurableSession": "denied", "run:durableSessionListing": "denied", "run:ownDurableSession": "allowed",
"run:otherOpencodeSession": "denied", "run:ownOpencodeSession": "allowed", "run:otherRunLog": "denied",
"run:databaseWal": "denied", "run:secrets": "denied", "run:controlPlaneState": "denied",
"run:otherProviderTrace": "denied", "run:ownProviderTrace": "allowed", "run:harnessEnviron": "denied",
});
// The fixture instance is removed after the probe.
expect((await readdir(home)).filter((name) => name.startsWith(".agent-sandbox-probe-"))).toEqual([]);
});
});