Files
PaperClipAI/tests/runner-e2e/agent-filesystem-sandbox.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

532 lines
27 KiB
TypeScript

import childProcess, { execFileSync, type SpawnOptions } from "node:child_process";
import { accessSync, closeSync, constants, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { syncBuiltinESMExports } from "node:module";
import os from "node:os";
import { promisify } from "node:util";
import path from "node:path";
import { requiresCodexCiSandbox } from "./codex-ci-sandbox.js";
import type { MatrixExecution } from "./types.js";
/**
* The agent under test must not read the eval: its scenarios, fixtures,
* graders or captured evidence. A local cell runs the product from the same
* checkout and as the same OS user as the harness, so file modes cannot
* separate them. The server launches every native agent through runnerd; the
* E2E server entry starts runnerd inside a Linux Landlock domain that hides
* these paths from runnerd and all of its descendants (provider, tools, shell).
* The product server itself stays unrestricted because it loads its E2E entry
* from the harness.
*/
export const AGENT_FILESYSTEM_SANDBOX_ENV = "PAPERCLIP_RUNNER_E2E_AGENT_FS_SANDBOX";
export const RUNNERD_EXECUTABLE_NAME = "paperclip-runnerd";
export interface AgentFilesystemSandboxConfig {
hidden: string[];
required: boolean;
/**
* The product instance root. Each runnerd launch then also hides every run's
* saved sessions, run logs, database files and secrets, except its own session.
*/
instanceRoot?: string;
}
/** One runnerd launch's sandbox: the attempt's hidden set plus its run scope. */
export interface RunnerdLaunchSandbox {
hidden: string[];
/** The launch's own entries beneath hidden directories, created before launch. */
exposed: string[];
/** Exposed paths that are files rather than directories. */
exposedFiles: string[];
/** Directories created before launch because their parent is hidden. */
precreated: string[];
required: boolean;
}
function within(candidate: string, root: string) {
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
}
/** Harness-private directories inside the attempt's temporary root. */
export const HARNESS_PRIVATE_TEMPORARY_CHILDREN = [
"artifacts-private",
"control",
"selected-private-inputs",
// Playwright's compiled copy of the harness and graders, with source maps.
"playwright-transform-cache",
] as const;
/** Playwright otherwise caches compiled harness code in the shared temporary directory. */
export function playwrightCacheDirectory(temporaryRoot: string) {
return path.join(temporaryRoot, "playwright-transform-cache");
}
/** Playwright's default cache location for this user, which the launcher removes. */
export function defaultPlaywrightCacheDirectory(temporaryDirectory = os.tmpdir()) {
return path.join(temporaryDirectory, `playwright-transform-cache-${process.geteuid?.() ?? ""}`);
}
export function agentFilesystemSandboxApplies(input: {
platform: NodeJS.Platform;
executions: readonly MatrixExecution[];
installedRelease: boolean;
}) {
if (input.platform !== "linux" || input.installedRelease) return false;
if (!input.executions.some((execution) => execution.environment.id === "local" && execution.profile.generation === "native")) return false;
// Codex's own bubblewrap sandbox mounts, which Landlock forbids. Those cells
// keep their existing isolation until Codex permission profiles hide the eval.
return !input.executions.some(requiresCodexCiSandbox);
}
/**
* Landlock can only hide a path by leaving its ancestors ungranted, and an
* ungranted directory accepts no new entries. Hiding the attempt's private
* children of a root under /tmp would stop agents creating files in /tmp, so a
* sandboxed attempt keeps its root in /var/tmp instead.
*/
export function agentTemporaryParent(applies: boolean, sharedTemporary = os.tmpdir()) {
if (applies) {
try {
accessSync("/var/tmp", constants.W_OK | constants.X_OK);
return realpathSync("/var/tmp");
} catch {
// Fall back to the shared directory; its private children stay visible.
}
}
return realpathSync(sharedTemporary);
}
/** Selects the paths hidden from local native agents, or null when not applicable. */
export function agentFilesystemSandboxConfig(input: {
platform: NodeJS.Platform;
env: NodeJS.ProcessEnv;
executions: readonly MatrixExecution[];
repositoryRoot: string;
temporaryRoot: string;
privateDir: string;
evidenceRoot?: string;
installedRelease: boolean;
sharedTemporary?: string;
gitStores?: readonly string[];
instanceRoot?: string;
}): AgentFilesystemSandboxConfig | null {
if (!agentFilesystemSandboxApplies(input)) return null;
const sharedTemporary = path.resolve(input.sharedTemporary ?? (input.env.TMPDIR?.trim() || os.tmpdir()));
const required = input.env.GITHUB_ACTIONS === "true";
// Harness material that must be hidden. Git holds the committed scenarios
// and graders, so `git show HEAD:tests/...` would otherwise bypass `tests/`.
const essential = [
path.join(input.repositoryRoot, "tests"),
...(input.gitStores ?? gitStorePaths(input.repositoryRoot)),
path.join(input.repositoryRoot, ".manual-private-trusted"),
...HARNESS_PRIVATE_TEMPORARY_CHILDREN.map((child) => path.join(input.temporaryRoot, child)),
input.privateDir,
];
const optional = [input.evidenceRoot, input.env.RUNNER_TEMP];
const hidden: string[] = [];
const exposed: string[] = [];
for (const [candidate, mustHide] of [...essential.map((value) => [value, true] as const), ...optional.map((value) => [value, false] as const)]) {
const value = candidate?.trim();
if (!value || !path.isAbsolute(value)) continue;
const resolved = path.resolve(value);
// Never hide a path that holds the agent's own state, or one beneath the
// shared temporary directory: its ancestors would lose new entries.
if (within(input.temporaryRoot, resolved) || within(input.repositoryRoot, resolved)
|| within(resolved, sharedTemporary) || within(sharedTemporary, resolved)) {
if (mustHide) exposed.push(resolved);
continue;
}
if (!hidden.includes(resolved)) hidden.push(resolved);
}
if (exposed.length) {
const message = `agent_filesystem_sandbox_incomplete: cannot hide ${exposed.join(", ")}; keep the checkout and attempt root outside ${sharedTemporary}`;
// A required sandbox fails closed rather than run an eval agents can read.
if (required) throw new Error(message);
console.warn(`[agent-filesystem-sandbox] ${message}`);
}
if (!hidden.length) return null;
const instanceRoot = input.instanceRoot?.trim();
// The instance root lives in the attempt root; never in the shared directory.
if (instanceRoot && path.isAbsolute(instanceRoot) && !within(path.resolve(instanceRoot), sharedTemporary)) {
return { hidden, required, instanceRoot: path.resolve(instanceRoot) };
}
return { hidden, required };
}
function flagValue(args: readonly string[], flag: string) {
const index = args.indexOf(flag);
const value = index >= 0 ? args[index + 1] : undefined;
return typeof value === "string" && value.trim() ? value : undefined;
}
/** Mirrors opencodeRuntimeSessionDirectoryName in the OpenCode driver. */
export function opencodeSessionDirectoryName(normalizedSessionId: string) {
const safe = normalizedSessionId.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120);
return safe && safe !== "." && safe !== ".." ? safe : null;
}
/** The attempt instance's directories that hold every run's state. */
export function instanceRunStatePaths(instanceRoot: string) {
const runner = path.join(instanceRoot, "runtime", "paperclip-runner");
return {
durableSessions: path.join(runner, "durable-sessions"),
runnerStates: path.join(runner, "runner"),
controlPlane: path.join(runner, "control-plane"),
opencode: path.join(runner, "opencode"),
acpx: path.join(runner, "acpx"),
runLogs: path.join(instanceRoot, "data", "run-logs"),
providerTraces: path.join(instanceRoot, "data", "provider-traces"),
backups: path.join(instanceRoot, "data", "backups"),
database: path.join(instanceRoot, "db"),
secrets: path.join(instanceRoot, "secrets"),
};
}
/** Instance directories an agent may use, created on first use beside hidden ones. */
const INSTANCE_SHARED_DIRECTORIES = ["companies", "projects", "workspaces", "runtime-context-assets", "skills", "runtime/provider-packs"] as const;
/**
* Selects one runnerd launch's sandbox. Runnerd is launched per session with
* `--state-dir` inside that session's directory, so the launch keeps exactly
* its own durable session, OpenCode session and provider trace and loses every
* other run's.
*/
export function runnerdLaunchSandbox(config: AgentFilesystemSandboxConfig, args: readonly string[], env: NodeJS.ProcessEnv): RunnerdLaunchSandbox {
const launch: RunnerdLaunchSandbox = { hidden: [...config.hidden], exposed: [], exposedFiles: [], precreated: [], required: config.required };
if (!config.instanceRoot) return launch;
const paths = instanceRunStatePaths(config.instanceRoot);
const hide = (value: string) => { if (!launch.hidden.includes(value)) launch.hidden.push(value); };
const expose = (value: string, file = false) => {
if (!launch.exposed.includes(value)) launch.exposed.push(value);
if (file && !launch.exposedFiles.includes(value)) launch.exposedFiles.push(value);
};
for (const value of [paths.durableSessions, paths.runnerStates, paths.controlPlane, paths.opencode, paths.runLogs, paths.providerTraces, paths.backups, paths.database, paths.secrets]) hide(value);
// Hiding those leaves their parents (the instance root, its runtime and data
// directories) without new entries. Create the directories the product makes
// there on first use, so a long-lived runnerd still sees later additions.
launch.precreated.push(paths.acpx, ...INSTANCE_SHARED_DIRECTORIES.map((name) => path.join(config.instanceRoot!, ...name.split("/"))));
const stateDirectory = flagValue(args, "--state-dir");
if (stateDirectory && path.isAbsolute(stateDirectory)) {
const state = path.resolve(stateDirectory);
for (const root of [paths.durableSessions, paths.runnerStates]) {
if (!within(state, root) || state === root) continue;
// The session's own directory: `<root>/<scope>`, whatever lies below it.
expose(path.join(root, path.relative(root, state).split(path.sep)[0]!));
}
}
const sessionId = flagValue(args, "--session-id");
const configuredOpencode = env.PAPERCLIP_OPENCODE_RUNTIME_DIR?.trim();
const opencodeRoots = [paths.opencode, ...(configuredOpencode && path.isAbsolute(configuredOpencode) ? [path.resolve(configuredOpencode)] : [])];
for (const root of opencodeRoots) {
if (root !== paths.opencode && (within(root, paths.durableSessions) || within(root, paths.runnerStates))) continue;
hide(root);
const name = sessionId ? opencodeSessionDirectoryName(sessionId) : null;
if (name) expose(path.join(root, name));
}
const trace = env.PAPERCLIP_PROVIDER_TRACE_PATH?.trim();
if (trace && path.isAbsolute(trace) && path.dirname(path.resolve(trace)) === paths.providerTraces) expose(path.resolve(trace), true);
return launch;
}
/** Creates the launch's own entries, which a process inside the sandbox could not create. */
export function prepareRunnerdLaunchSandbox(launch: RunnerdLaunchSandbox) {
for (const directory of launch.precreated) mkdirSync(directory, { recursive: true, mode: 0o700 });
for (const exposed of launch.exposed) {
if (launch.exposedFiles.includes(exposed)) {
mkdirSync(path.dirname(exposed), { recursive: true, mode: 0o700 });
// Append mode never truncates a trace the run already started.
closeSync(openSync(exposed, "a", 0o600));
} else {
mkdirSync(exposed, { recursive: true, mode: 0o700 });
}
}
}
export function parseAgentFilesystemSandboxConfig(value: string | undefined): AgentFilesystemSandboxConfig | null {
if (!value?.trim()) return null;
const parsed = JSON.parse(value) as Partial<AgentFilesystemSandboxConfig>;
if (!Array.isArray(parsed.hidden) || parsed.hidden.some((entry) => typeof entry !== "string" || !path.isAbsolute(entry)) || typeof parsed.required !== "boolean"
|| (parsed.instanceRoot !== undefined && (typeof parsed.instanceRoot !== "string" || !path.isAbsolute(parsed.instanceRoot)))) {
throw new Error(`${AGENT_FILESYSTEM_SANDBOX_ENV} is malformed`);
}
return { hidden: parsed.hidden, required: parsed.required, ...(parsed.instanceRoot ? { instanceRoot: parsed.instanceRoot } : {}) };
}
export const agentFilesystemSandboxHelper = path.join(import.meta.dirname, "agent-filesystem-sandbox.py");
/** Rewrites one launch so the helper applies the domain and then execs it unchanged. */
export function sandboxedInvocation(
config: Pick<AgentFilesystemSandboxConfig, "hidden" | "required"> & { exposed?: readonly string[] },
python: string,
command: string,
args: readonly string[],
env: NodeJS.ProcessEnv,
): { command: string; args: string[] } {
// Python may coerce a C locale by exporting LC_CTYPE; restore the caller's value.
const locale = env.LC_CTYPE === undefined ? ["--unset", "LC_CTYPE"] : ["--set", `LC_CTYPE=${env.LC_CTYPE}`];
return {
command: python,
args: [
"-I", "-S", agentFilesystemSandboxHelper,
...(config.required ? ["--required"] : []),
...locale,
...config.hidden.flatMap((hidden) => ["--hide", hidden]),
...(config.exposed ?? []).flatMap((exposed) => ["--expose", exposed]),
"--", command, ...args,
],
};
}
/** Matches runnerd by executable name, whether absolute or resolved through PATH. */
export function isRunnerdLaunch(command: unknown) {
return typeof command === "string" && path.basename(command) === RUNNERD_EXECUTABLE_NAME;
}
/**
* The checkout's Git store holds the committed scenarios and graders. A linked
* worktree's `.git` is a file that points into the main repository, so hide the
* worktree's gitdir and the shared common directory as well.
*/
export function gitStorePaths(repositoryRoot: string): string[] {
const dotGit = path.join(repositoryRoot, ".git");
const stores = [dotGit];
try {
if (!statSync(dotGit).isFile()) return stores;
const pointer = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, "utf8"))?.[1]?.trim();
if (!pointer) return stores;
const gitDir = path.resolve(repositoryRoot, pointer);
stores.push(gitDir);
const common = readFileSync(path.join(gitDir, "commondir"), "utf8").trim();
if (common) stores.push(path.resolve(gitDir, common));
} catch {
// A missing .git or commondir leaves only the paths found so far.
}
return stores;
}
export function pythonExecutable() {
for (const candidate of ["/usr/bin/python3", "/usr/local/bin/python3"]) {
if (existsSync(candidate)) return candidate;
}
return "python3";
}
/** Proves, before any agent starts, that a sandboxed process cannot read the eval. */
export function probeAgentFilesystemSandbox(
config: AgentFilesystemSandboxConfig,
repositoryRoot: string,
python = pythonExecutable(),
env: NodeJS.ProcessEnv = process.env,
playwrightCache = defaultPlaywrightCacheDirectory(),
) {
const harness = path.join(repositoryRoot, "tests", "runner-e2e");
const script = [
'out=""; add() { out="$out$1=$2;"; }',
'if cat "$1" >/dev/null 2>&1; then add harnessFile readable; else add harnessFile denied; fi',
'if ls "$2" >/dev/null 2>&1; then add harnessDirectory listable; else add harnessDirectory denied; fi',
'if test -e "$2"; then add harnessDirectoryStat visible; else add harnessDirectoryStat absent; fi',
'if cat "$3" >/dev/null 2>&1; then add productFile readable; else add productFile denied; fi',
'probe=$(mktemp) && rm -f "$probe" && add temporaryWrite allowed || add temporaryWrite denied',
'if ls "$4" >/dev/null 2>&1; then add playwrightDefaultCache listable; else add playwrightDefaultCache denied; fi',
'shift 4; for hidden in "$@"; do if ls "$hidden" >/dev/null 2>&1; then add "hidden:$hidden" listable; else add "hidden:$hidden" denied; fi; done',
'printf "%s\\n" "$out"',
].join("\n");
const invocation = sandboxedInvocation(config, python, "/bin/sh", [
"-c", script, "sh",
path.join(harness, "agent-filesystem-sandbox.ts"),
harness,
path.join(repositoryRoot, "packages", "paperclip-runner", "package.json"),
playwrightCache,
...config.hidden,
], env);
const output = execFileSync(invocation.command, invocation.args, { encoding: "utf8", cwd: "/", env, timeout: 30_000 });
const result = Object.fromEntries(output.trim().split(";").filter(Boolean).map((entry) => {
const separator = entry.lastIndexOf("=");
return [entry.slice(0, separator), entry.slice(separator + 1)];
}));
const failures = [
result.harnessFile !== "denied" && "harness file is readable",
result.harnessDirectory !== "denied" && "harness directory is listable",
result.productFile !== "readable" && "product package is unreadable",
result.temporaryWrite !== "allowed" && "temporary directory is not writable",
result.playwrightDefaultCache !== "denied" && "Playwright's default transform cache is listable",
...config.hidden.map((hidden) => result[`hidden:${hidden}`] === "listable" && `${hidden} is listable`),
].filter((failure): failure is string => Boolean(failure));
return { result, failures };
}
/** What a sandboxed run must be able to do with each probe fixture path. */
const RUN_SCOPE_EXPECTATIONS = {
otherDurableSession: "denied",
durableSessionListing: "denied",
ownDurableSession: "allowed",
otherRunnerState: "denied",
controlPlaneState: "denied",
otherOpencodeSession: "denied",
ownOpencodeSession: "allowed",
acpxRuntime: "allowed",
otherRunLog: "denied",
databaseWal: "denied",
secrets: "denied",
otherProviderTrace: "denied",
ownProviderTrace: "allowed",
harnessEnviron: "denied",
} as const;
/**
* Proves the run scope on a fixture instance laid out like the real one: a
* sandboxed run keeps its own session and trace and loses every other run's
* state, the instance database and its secrets.
*/
export function probeRunScopedSandbox(
config: AgentFilesystemSandboxConfig,
python = pythonExecutable(),
env: NodeJS.ProcessEnv = process.env,
harnessPid = process.pid,
) {
if (!config.instanceRoot) return { result: {} as Record<string, string>, failures: [] as string[] };
// A sibling of the instances directory: inside the attempt root, never in /tmp.
const fixture = mkdtempSync(path.join(path.dirname(path.dirname(config.instanceRoot)), ".agent-sandbox-probe-"));
try {
const instance = path.join(fixture, "instance");
const paths = instanceRunStatePaths(instance);
const write = (file: string) => { mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); writeFileSync(file, "other run\n", { mode: 0o600 }); };
const ownState = path.join(paths.durableSessions, "own-scope", "runner");
mkdirSync(ownState, { recursive: true, mode: 0o700 });
const fixtures = {
otherDurableSession: path.join(paths.durableSessions, "other-scope", "opencode", "data", "opencode.db"),
otherRunnerState: path.join(paths.runnerStates, "other-run", "state.json"),
controlPlaneState: path.join(paths.controlPlane, "control-plane-state.json"),
otherOpencodeSession: path.join(paths.opencode, "other-session", "data", "opencode", "opencode.db"),
otherRunLog: path.join(paths.runLogs, "company", "agent", "other-run.ndjson"),
databaseWal: path.join(paths.database, "pg_wal", "000000010000000000000001"),
secrets: path.join(paths.secrets, "master.key"),
otherProviderTrace: path.join(paths.providerTraces, "other-run.ndjson"),
};
for (const file of Object.values(fixtures)) write(file);
const ownTrace = path.join(paths.providerTraces, "own-run.ndjson");
const launch = runnerdLaunchSandbox({ ...config, instanceRoot: instance },
["--state-dir", ownState, "--session-id", "own-session"], { PAPERCLIP_PROVIDER_TRACE_PATH: ownTrace });
prepareRunnerdLaunchSandbox(launch);
const checks = [
["otherDurableSession", "read", fixtures.otherDurableSession],
["durableSessionListing", "list", paths.durableSessions],
["ownDurableSession", "write", ownState],
["otherRunnerState", "read", fixtures.otherRunnerState],
["controlPlaneState", "read", fixtures.controlPlaneState],
["otherOpencodeSession", "read", fixtures.otherOpencodeSession],
["ownOpencodeSession", "write", path.join(paths.opencode, "own-session")],
["acpxRuntime", "write", paths.acpx],
["otherRunLog", "read", fixtures.otherRunLog],
["databaseWal", "list", path.dirname(fixtures.databaseWal)],
["secrets", "read", fixtures.secrets],
["otherProviderTrace", "read", fixtures.otherProviderTrace],
["ownProviderTrace", "append", ownTrace],
["harnessEnviron", "read", `/proc/${harnessPid}/environ`],
// Recorded, not required: Landlock cannot hide another process's argv.
["harnessCmdline", "read", `/proc/${harnessPid}/cmdline`],
] as const;
const script = [
'out=""',
'for spec in "$@"; do name=${spec%%|*}; rest=${spec#*|}; mode=${rest%%|*}; target=${rest#*|}',
' case "$mode" in',
' read) cat "$target" >/dev/null 2>&1 ;;',
' list) ls "$target" >/dev/null 2>&1 ;;',
' write) f="$target/.agent-sandbox-probe" && : > "$f" 2>/dev/null && cat "$f" >/dev/null 2>&1 && rm -f "$f" ;;',
' append) printf "" >> "$target" 2>/dev/null && cat "$target" >/dev/null 2>&1 ;;',
' esac',
' if [ $? -eq 0 ]; then out="$out$name=allowed;"; else out="$out$name=denied;"; fi',
'done',
'printf "%s\\n" "$out"',
].join("\n");
const invocation = sandboxedInvocation(launch, python, "/bin/sh", ["-c", script, "sh", ...checks.map(([name, mode, target]) => `${name}|${mode}|${target}`)], env);
const output = execFileSync(invocation.command, invocation.args, { encoding: "utf8", cwd: "/", env, timeout: 30_000 });
const result = Object.fromEntries(output.trim().split(";").filter(Boolean).map((entry) => {
const separator = entry.lastIndexOf("=");
return [`run:${entry.slice(0, separator)}`, entry.slice(separator + 1)];
})) as Record<string, string>;
const failures = Object.entries(RUN_SCOPE_EXPECTATIONS)
.filter(([name, expected]) => result[`run:${name}`] !== expected)
.map(([name, expected]) => `run scope: ${name} is ${result[`run:${name}`] ?? "unprobed"}, expected ${expected}`);
return { result, failures };
} finally {
rmSync(fixture, { recursive: true, force: true });
}
}
/**
* Wraps runnerd launches made by this process. Runs only in the E2E server
* entry; the wrapper execs runnerd, so its PID, argv, cwd and environment are
* the ones the product requested.
*/
export function installAgentFilesystemSandbox(repositoryRoot: string, env: NodeJS.ProcessEnv = process.env) {
const config = parseAgentFilesystemSandboxConfig(env[AGENT_FILESYSTEM_SANDBOX_ENV]);
delete env[AGENT_FILESYSTEM_SANDBOX_ENV];
if (!config) return null;
const python = pythonExecutable();
let probe: ReturnType<typeof probeAgentFilesystemSandbox>;
try {
probe = probeAgentFilesystemSandbox(config, repositoryRoot, python);
const runScope = probeRunScopedSandbox(config, python);
probe = { result: { ...probe.result, ...runScope.result }, failures: [...probe.failures, ...runScope.failures] };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (config.required) throw new Error(`agent_filesystem_sandbox_unavailable: ${message}`);
console.warn(`[agent-filesystem-sandbox] disabled: ${message}`);
return null;
}
console.log(`[agent-filesystem-sandbox] probe ${JSON.stringify({ hidden: config.hidden, required: config.required, ...probe })}`);
if (probe.failures.length) {
if (config.required) throw new Error(`agent_filesystem_sandbox_ineffective: ${probe.failures.join("; ")}`);
console.warn(`[agent-filesystem-sandbox] disabled: ${probe.failures.join("; ")}`);
return null;
}
const launchers = childProcess as unknown as Record<RunnerdLaunchApi, (...args: unknown[]) => unknown>;
for (const api of RUNNERD_LAUNCH_APIS) launchers[api] = wrapRunnerdLaunch(launchers[api], config, python, api);
syncBuiltinESMExports();
return config;
}
/** Every child_process API that can start an executable directly. */
export const RUNNERD_LAUNCH_APIS = ["spawn", "spawnSync", "execFile", "execFileSync"] as const;
export type RunnerdLaunchApi = typeof RUNNERD_LAUNCH_APIS[number];
/**
* Wraps one launch API so runnerd starts inside the sandbox and other launches
* pass through. A runnerd launch the wrapper cannot rewrite fails closed.
*/
export function wrapRunnerdLaunch<T extends (...args: any[]) => unknown>(original: T, config: AgentFilesystemSandboxConfig, python: string, api: RunnerdLaunchApi = "spawn", wrapCustom = true): T {
const wrapped = function (this: unknown, command: unknown, ...rest: unknown[]) {
if (!isRunnerdLaunch(command)) return Reflect.apply(original, this, [command, ...rest]);
const hasArgs = Array.isArray(rest[0]);
const args = hasArgs ? rest[0] as string[] : [];
const afterArgs = hasArgs ? rest.slice(1) : rest;
const options = afterArgs[0] && typeof afterArgs[0] === "object" ? afterArgs[0] as SpawnOptions : undefined;
if (options?.shell) throw new Error("agent_filesystem_sandbox: refusing to start runnerd through a shell, outside the sandbox");
const env = options?.env ?? process.env;
const launch = runnerdLaunchSandbox(config, args, env);
// The server creates the launch's own entries; inside the sandbox their
// hidden parents accept no new entries. A failure here fails the launch.
prepareRunnerdLaunchSandbox(launch);
const invocation = sandboxedInvocation(launch, python, command as string, args, env);
const result = Reflect.apply(original, this, [invocation.command, invocation.args, ...afterArgs]);
const pid = (result as { pid?: number } | undefined)?.pid;
const scope = config.instanceRoot ? ` ${JSON.stringify({ runScopedHidden: launch.hidden.filter((value) => !config.hidden.includes(value)), exposed: launch.exposed })}` : "";
console.log(`[agent-filesystem-sandbox] runnerd launched inside the sandbox via ${api}${pid ? ` (pid ${pid})` : ""}${scope}`);
return result;
} as T;
// execFile's promisified form resolves { stdout, stderr } through this hook;
// keep it, wrapped the same way, so promisify(execFile) keeps its shape.
const custom = (original as unknown as Record<symbol, unknown>)[promisify.custom];
if (wrapCustom && typeof custom === "function") {
Object.defineProperty(wrapped, promisify.custom, {
value: wrapRunnerdLaunch(custom as (...args: any[]) => unknown, config, python, api, false),
configurable: true,
});
}
return wrapped;
}
/** Back-compatible name for the spawn wrapper. */
export function wrapRunnerdSpawn(original: typeof childProcess.spawn, config: AgentFilesystemSandboxConfig, python: string) {
return wrapRunnerdLaunch(original, config, python, "spawn");
}