test(runner-e2e): scope each agent's sandbox to its own run's saved state

Every agent in a local instance runs as the same OS user, so an agent's
shell could read other runs' OpenCode session databases, run logs, provider
traces, the instance's pg_wal and control-plane state. Each runnerd launch
now also hides those instance directories and exposes only its own durable
session, OpenCode session and trace. The setup probe proves this on a
fixture instance and stops the attempt in CI on any failure.

Landlock cannot hide /proc/<pid>/cmdline, so CI passes the selected cell in
PAPERCLIP_RUNNER_E2E_SELECTED_ID instead of --id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-09 05:43:52 -06:00
1 parent 97ad08935d
commit fca4bb7ce3
8 files changed
+493 -25

No files matched your search

@@ -165,7 +165,12 @@ jobs:
PAPERCLIP_RUNNER_E2E_SOURCE_SHA: ${{ inputs.target_sha }} PAPERCLIP_RUNNER_E2E_SOURCE_SHA: ${{ inputs.target_sha }}
PAPERCLIP_RUNNER_E2E_SOURCE_REF: refs/heads/codex/chat-catalog-master-integration PAPERCLIP_RUNNER_E2E_SOURCE_REF: refs/heads/codex/chat-catalog-master-integration
PAPERCLIP_PLAYWRIGHT_CHANNEL: chrome PAPERCLIP_PLAYWRIGHT_CHANNEL: chrome
run: pnpm test:e2e:runner -- --id "${{ fromJSON(inputs.cell).executionId }}" --max-parallel 1 --max-automatic-retries 0 > "$PRIVATE_EVIDENCE_ROOT/launcher-output.log" 2>&1 PAPERCLIP_RUNNER_E2E_SELECTED_ID: ${{ fromJSON(inputs.cell).executionId }}
run: |
# Agents can read any process's argv in /proc; keep the case id off the command line.
selector=(--id "$PAPERCLIP_RUNNER_E2E_SELECTED_ID")
if grep -q PAPERCLIP_RUNNER_E2E_SELECTED_ID tests/runner-e2e/selectors.ts 2>/dev/null; then selector=(); fi
pnpm test:e2e:runner -- "${selector[@]}" --max-parallel 1 --max-automatic-retries 0 > "$PRIVATE_EVIDENCE_ROOT/launcher-output.log" 2>&1
- name: Retain original evidence after success or failure - name: Retain original evidence after success or failure
if: always() && steps.cell_credentials.outcome == 'success' if: always() && steps.cell_credentials.outcome == 'success'
env: *cell_session env: *cell_session
+7 -2
View File
@@ -1435,11 +1435,16 @@ jobs:
PAPERCLIP_RUNNER_E2E_SOURCE_SHA: ${{ needs.authorize.outputs.target_sha }} PAPERCLIP_RUNNER_E2E_SOURCE_SHA: ${{ needs.authorize.outputs.target_sha }}
PAPERCLIP_RUNNER_E2E_SOURCE_REF: ${{ needs.authorize.outputs.target_ref }} PAPERCLIP_RUNNER_E2E_SOURCE_REF: ${{ needs.authorize.outputs.target_ref }}
PAPERCLIP_PLAYWRIGHT_CHANNEL: ${{ needs.authorize.outputs.playwright_channel }} PAPERCLIP_PLAYWRIGHT_CHANNEL: ${{ needs.authorize.outputs.playwright_channel }}
PAPERCLIP_RUNNER_E2E_SELECTED_ID: ${{ matrix.executionId }}
run: | run: |
# Agents can read any process's argv in /proc. A target that reads the
# selected cell from the environment keeps the case id off the command line.
selector=(-- --id "$PAPERCLIP_RUNNER_E2E_SELECTED_ID")
if grep -q PAPERCLIP_RUNNER_E2E_SELECTED_ID tests/runner-e2e/selectors.ts 2>/dev/null; then selector=(); fi
if [ -n "$PAPERCLIP_RUNNER_E2E_PRIVATE_PREFLIGHT" ]; then if [ -n "$PAPERCLIP_RUNNER_E2E_PRIVATE_PREFLIGHT" ]; then
pnpm test:e2e:runner -- --id "${{ matrix.executionId }}" > "$PRIVATE_EVIDENCE_ROOT/launcher-output.log" 2>&1 pnpm test:e2e:runner "${selector[@]}" > "$PRIVATE_EVIDENCE_ROOT/launcher-output.log" 2>&1
else else
pnpm test:e2e:runner -- --id "${{ matrix.executionId }}" pnpm test:e2e:runner "${selector[@]}"
fi fi
- name: Stage bounded campaign evidence - name: Stage bounded campaign evidence
+34 -2
View File
@@ -363,11 +363,43 @@ each wrapped runnerd launch. In CI a missing or ineffective sandbox stops the
attempt. Landlock does not hide that a path exists (`stat` still works); it attempt. Landlock does not hide that a path exists (`stat` still works); it
denies reading and listing. denies reading and listing.
Each runnerd launch is also scoped to its own run. Every agent in a local
instance runs as the same OS user, so without this one agent's shell can read
another run's saved session (its OpenCode database and configuration), run log
or provider trace, the instance's database files (including `pg_wal`) and its
secrets. The launcher passes the instance root to the server entry, and each
runnerd launch additionally hides the instance's `durable-sessions/`,
`runner/`, `control-plane/` and `opencode/` directories under
`runtime/paperclip-runner/`, and `data/run-logs/`, `data/provider-traces/`,
`data/backups/`, `db/` and `secrets/`. It then exposes only the launch's own
entries, found from its `--state-dir`, `--session-id` and provider trace path:
its durable session directory (or Codex runner state directory), its OpenCode
session directory and its trace file. Landlock does not check path traversal,
so a rule on an exposed path grants it although the directory above it has
none; listing that directory stays denied, and entries created there later
(another run's new session) stay unreadable. The server creates the exposed
entries, and the shared directories the product creates beside them on first
use, before the launch, because their hidden or ungranted parents accept no new
entries from inside the sandbox. The helper fails the launch unless every
hidden path is denied and every exposed path is readable. The setup probe
repeats this on a fixture instance (another run's session, OpenCode database,
run log, trace, `pg_wal`, secrets and control-plane state denied; the run's own
session, OpenCode directory and trace writable) and stops the attempt in CI on
any failure. A run still sees its own session's control-plane state.
Landlock cannot hide `/proc/<pid>/cmdline`. Same-user `hidepid` does not help
either: it hides only other users' processes. Landlock does deny reading a
process's environment, memory and file descriptors outside the sandbox, so CI
passes the selected cell in `PAPERCLIP_RUNNER_E2E_SELECTED_ID` instead of
`--id`; the launcher reads and removes it. The workflow keeps `--id` for target
branches that predate this.
Limits: Codex cells keep Codex's own bubblewrap sandbox, which cannot mount Limits: Codex cells keep Codex's own bubblewrap sandbox, which cannot mount
inside Landlock. Legacy adapters, installed-release launches and macOS run inside Landlock. Legacy adapters, installed-release launches and macOS run
without this sandbox. A process that can reach the Docker daemon can still without this sandbox. A process that can reach the Docker daemon can still
mount the checkout into a container, and `ps` shows the launcher's command mount the checkout into a container, and `ps` shows every harness process's
line. Case fixtures that create their own private directories directly in the command line (paths, but no longer the selected cell in CI). Other agents' homes
and task workspaces in the same instance stay readable. Case fixtures that create their own private directories directly in the
shared temporary directory stay readable. The boundary is filesystem only: the shared temporary directory stay readable. The boundary is filesystem only: the
scenarios and graders are public on GitHub, and agents have network access for scenarios and graders are public on GitHub, and agents have network access for
their model provider. Daytona cells run the agent in a remote sandbox that holds only the their model provider. Daytona cells run the agent in a remote sandbox that holds only the
+35 -9
View File
@@ -8,7 +8,10 @@ descendant inherits the domain, including an agent's shell commands.
Usage: Usage:
agent-filesystem-sandbox.py [--required] [--unset NAME]... [--set NAME=VALUE]... agent-filesystem-sandbox.py [--required] [--unset NAME]... [--set NAME=VALUE]...
--hide PATH [--hide PATH]... -- COMMAND [ARG]... --hide PATH [--hide PATH]... [--expose PATH]... -- COMMAND [ARG]...
An exposed path lies beneath a hidden directory and stays usable: a run sees its
own saved session while the directory holding every run's sessions stays hidden.
Without Landlock, --required exits 125 instead of running the command. Without Landlock, --required exits 125 instead of running the command.
""" """
@@ -46,6 +49,7 @@ def fail(message, code=125):
def parse(argv): def parse(argv):
required = False required = False
hidden = [] hidden = []
exposed = []
unset = [] unset = []
assignments = [] assignments = []
index = 0 index = 0
@@ -55,16 +59,16 @@ def parse(argv):
command = argv[index + 1:] command = argv[index + 1:]
if not command: if not command:
fail("missing command", 2) fail("missing command", 2)
return required, hidden, unset, assignments, command return required, hidden, exposed, unset, assignments, command
if token == "--required": if token == "--required":
required = True required = True
elif token in ("--hide", "--unset", "--set") and index + 1 < len(argv): elif token in ("--hide", "--expose", "--unset", "--set") and index + 1 < len(argv):
index += 1 index += 1
value = argv[index] value = argv[index]
if token == "--hide": if token in ("--hide", "--expose"):
if not os.path.isabs(value): if not os.path.isabs(value):
fail("hidden path must be absolute: %s" % value, 2) fail("%s path must be absolute: %s" % (token[2:], value), 2)
hidden.append(value) (hidden if token == "--hide" else exposed).append(value)
elif token == "--unset": elif token == "--unset":
unset.append(value) unset.append(value)
else: else:
@@ -95,7 +99,7 @@ def landlock():
return libc, syscall return libc, syscall
def restrict(hidden): def restrict(hidden, exposed=()):
libc, syscall = landlock() libc, syscall = landlock()
try: try:
abi = syscall( abi = syscall(
@@ -165,6 +169,10 @@ def restrict(hidden):
allow(child) allow(child)
grant("/") grant("/")
# Landlock does not check path traversal, so a rule on an exposed path
# grants it even though the hidden directory above it has no rule.
for path in exposed:
allow(path)
if libc.prctl(ctypes.c_int(PR_SET_NO_NEW_PRIVS), ctypes.c_ulong(1), ctypes.c_ulong(0), ctypes.c_ulong(0), ctypes.c_ulong(0)) != 0: if libc.prctl(ctypes.c_int(PR_SET_NO_NEW_PRIVS), ctypes.c_ulong(1), ctypes.c_ulong(0), ctypes.c_ulong(0), ctypes.c_ulong(0)) != 0:
raise RuntimeError("prctl(PR_SET_NO_NEW_PRIVS) failed") raise RuntimeError("prctl(PR_SET_NO_NEW_PRIVS) failed")
syscall(ctypes.c_long(SYS_LANDLOCK_RESTRICT_SELF), ctypes.c_long(ruleset), ctypes.c_long(0)) syscall(ctypes.c_long(SYS_LANDLOCK_RESTRICT_SELF), ctypes.c_long(ruleset), ctypes.c_long(0))
@@ -181,11 +189,19 @@ def restrict(hidden):
except OSError: except OSError:
continue continue
raise RuntimeError("sandbox ineffective: %s is still readable" % path) raise RuntimeError("sandbox ineffective: %s is still readable" % path)
for path in exposed:
try:
if os.path.isdir(path):
os.listdir(path)
else:
os.close(os.open(path, os.O_RDONLY | os.O_CLOEXEC))
except OSError as error:
raise RuntimeError("sandbox unusable: %s is not readable (%s)" % (path, error))
return abi return abi
def main(): def main():
required, hidden_inputs, unset, assignments, command = parse(sys.argv[1:]) required, hidden_inputs, exposed_inputs, unset, assignments, command = parse(sys.argv[1:])
hidden = [] hidden = []
for path in hidden_inputs: for path in hidden_inputs:
# Keep paths that do not exist yet: their ancestors stay ungranted, so # Keep paths that do not exist yet: their ancestors stay ungranted, so
@@ -194,6 +210,16 @@ def main():
if real == "/": if real == "/":
fail("refusing to hide the filesystem root", 2) fail("refusing to hide the filesystem root", 2)
hidden.append(real) hidden.append(real)
exposed = []
for path in exposed_inputs:
real = os.path.realpath(path)
if not os.path.exists(real):
fail("exposed path must exist before the sandbox starts: %s" % real, 2)
if not any(within(real, root) and real != root for root in hidden):
fail("exposed path must lie beneath a hidden directory: %s" % real, 2)
if any(within(root, real) for root in hidden):
fail("exposed path would reveal a hidden path: %s" % real, 2)
exposed.append(real)
for name in unset: for name in unset:
os.environ.pop(name, None) os.environ.pop(name, None)
for name, value in assignments: for name, value in assignments:
@@ -204,7 +230,7 @@ def main():
fail("Landlock requires Linux") fail("Landlock requires Linux")
else: else:
try: try:
restrict(hidden) restrict(hidden, exposed)
except (OSError, RuntimeError) as error: except (OSError, RuntimeError) as error:
if required: if required:
fail(str(error)) fail(str(error))
@@ -1,6 +1,6 @@
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { mkdtempSync, realpathSync, rmSync } from "node:fs"; import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
@@ -20,7 +20,13 @@ import {
wrapRunnerdLaunch, wrapRunnerdLaunch,
gitStorePaths, gitStorePaths,
RUNNERD_LAUNCH_APIS, RUNNERD_LAUNCH_APIS,
instanceRunStatePaths,
opencodeSessionDirectoryName,
prepareRunnerdLaunchSandbox,
probeRunScopedSandbox,
runnerdLaunchSandbox,
} from "./agent-filesystem-sandbox.js"; } from "./agent-filesystem-sandbox.js";
import { RUNNER_SELECTED_ID_ENV, parseRunnerSelectors, selectorArgumentsWithEnvironment } from "./selectors.js";
import { runnerMatrix } from "./catalog.js"; import { runnerMatrix } from "./catalog.js";
const repositoryRoot = "/work/paperclip"; const repositoryRoot = "/work/paperclip";
@@ -113,6 +119,140 @@ describe("agent filesystem sandbox selection", () => {
}); });
}); });
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", () => { describe("runnerd launch rewriting", () => {
it("wraps only the absolute runnerd executable and execs it with the original argv", () => { 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"; const runnerd = "/work/paperclip/packages/paperclip-runner/runner/target/debug/paperclip-runnerd";
@@ -231,4 +371,23 @@ describe.skipIf(!landlock)("Landlock agent sandbox on this host", () => {
expect(attemptOutput).toContain("workspace=ok"); expect(attemptOutput).toContain("workspace=ok");
expect(attemptOutput).toContain("control=blocked"); 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([]);
});
}); });
+233 -7
View File
@@ -1,5 +1,5 @@
import childProcess, { execFileSync, type SpawnOptions } from "node:child_process"; import childProcess, { execFileSync, type SpawnOptions } from "node:child_process";
import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from "node:fs"; import { accessSync, closeSync, constants, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { syncBuiltinESMExports } from "node:module"; import { syncBuiltinESMExports } from "node:module";
import os from "node:os"; import os from "node:os";
import { promisify } from "node:util"; import { promisify } from "node:util";
@@ -23,8 +23,24 @@ export const RUNNERD_EXECUTABLE_NAME = "paperclip-runnerd";
export interface AgentFilesystemSandboxConfig { export interface AgentFilesystemSandboxConfig {
hidden: string[]; hidden: string[];
required: boolean; 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) { function within(candidate: string, root: string) {
return candidate === root || candidate.startsWith(`${root}${path.sep}`); return candidate === root || candidate.startsWith(`${root}${path.sep}`);
} }
@@ -90,6 +106,7 @@ export function agentFilesystemSandboxConfig(input: {
installedRelease: boolean; installedRelease: boolean;
sharedTemporary?: string; sharedTemporary?: string;
gitStores?: readonly string[]; gitStores?: readonly string[];
instanceRoot?: string;
}): AgentFilesystemSandboxConfig | null { }): AgentFilesystemSandboxConfig | null {
if (!agentFilesystemSandboxApplies(input)) return null; if (!agentFilesystemSandboxApplies(input)) return null;
const sharedTemporary = path.resolve(input.sharedTemporary ?? (input.env.TMPDIR?.trim() || os.tmpdir())); const sharedTemporary = path.resolve(input.sharedTemporary ?? (input.env.TMPDIR?.trim() || os.tmpdir()));
@@ -125,23 +142,124 @@ export function agentFilesystemSandboxConfig(input: {
if (required) throw new Error(message); if (required) throw new Error(message);
console.warn(`[agent-filesystem-sandbox] ${message}`); console.warn(`[agent-filesystem-sandbox] ${message}`);
} }
return hidden.length ? { hidden, required } : null; 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 instance directories that hold every run's state, keyed by what the
* launch may keep. A local instance shares one OS user between all agents, so
* without this one agent's shell reads another run's provider database, run
* log or the instance's database files.
*/
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 { export function parseAgentFilesystemSandboxConfig(value: string | undefined): AgentFilesystemSandboxConfig | null {
if (!value?.trim()) return null; if (!value?.trim()) return null;
const parsed = JSON.parse(value) as Partial<AgentFilesystemSandboxConfig>; 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") { 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`); throw new Error(`${AGENT_FILESYSTEM_SANDBOX_ENV} is malformed`);
} }
return { hidden: parsed.hidden, required: parsed.required }; return { hidden: parsed.hidden, required: parsed.required, ...(parsed.instanceRoot ? { instanceRoot: parsed.instanceRoot } : {}) };
} }
export const agentFilesystemSandboxHelper = path.join(import.meta.dirname, "agent-filesystem-sandbox.py"); 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. */ /** Rewrites one launch so the helper applies the domain and then execs it unchanged. */
export function sandboxedInvocation( export function sandboxedInvocation(
config: AgentFilesystemSandboxConfig, config: Pick<AgentFilesystemSandboxConfig, "hidden" | "required"> & { exposed?: readonly string[] },
python: string, python: string,
command: string, command: string,
args: readonly string[], args: readonly string[],
@@ -156,6 +274,7 @@ export function sandboxedInvocation(
...(config.required ? ["--required"] : []), ...(config.required ? ["--required"] : []),
...locale, ...locale,
...config.hidden.flatMap((hidden) => ["--hide", hidden]), ...config.hidden.flatMap((hidden) => ["--hide", hidden]),
...(config.exposed ?? []).flatMap((exposed) => ["--expose", exposed]),
"--", command, ...args, "--", command, ...args,
], ],
}; };
@@ -239,6 +358,105 @@ export function probeAgentFilesystemSandbox(
return { result, failures }; 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 * 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 * entry; the wrapper execs runnerd, so its PID, argv, cwd and environment are
@@ -252,6 +470,8 @@ export function installAgentFilesystemSandbox(repositoryRoot: string, env: NodeJ
let probe: ReturnType<typeof probeAgentFilesystemSandbox>; let probe: ReturnType<typeof probeAgentFilesystemSandbox>;
try { try {
probe = probeAgentFilesystemSandbox(config, repositoryRoot, python); probe = probeAgentFilesystemSandbox(config, repositoryRoot, python);
const runScope = probeRunScopedSandbox(config, python);
probe = { result: { ...probe.result, ...runScope.result }, failures: [...probe.failures, ...runScope.failures] };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if (config.required) throw new Error(`agent_filesystem_sandbox_unavailable: ${message}`); if (config.required) throw new Error(`agent_filesystem_sandbox_unavailable: ${message}`);
@@ -286,10 +506,16 @@ export function wrapRunnerdLaunch<T extends (...args: any[]) => unknown>(origina
const afterArgs = hasArgs ? rest.slice(1) : rest; const afterArgs = hasArgs ? rest.slice(1) : rest;
const options = afterArgs[0] && typeof afterArgs[0] === "object" ? afterArgs[0] as SpawnOptions : undefined; 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"); if (options?.shell) throw new Error("agent_filesystem_sandbox: refusing to start runnerd through a shell, outside the sandbox");
const invocation = sandboxedInvocation(config, python, command as string, args, options?.env ?? process.env); 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 result = Reflect.apply(original, this, [invocation.command, invocation.args, ...afterArgs]);
const pid = (result as { pid?: number } | undefined)?.pid; const pid = (result as { pid?: number } | undefined)?.pid;
console.log(`[agent-filesystem-sandbox] runnerd launched inside the sandbox via ${api}${pid ? ` (pid ${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; return result;
} as T; } as T;
// execFile's promisified form resolves { stdout, stderr } through this hook; // execFile's promisified form resolves { stdout, stderr } through this hook;
+4 -2
View File
@@ -87,6 +87,7 @@ import {
buildMatrixJobs, buildMatrixJobs,
parseRunnerSelectors, parseRunnerSelectors,
RunnerSelectorError, RunnerSelectorError,
selectorArgumentsWithEnvironment,
selectRunnerExecutions, selectRunnerExecutions,
} from "./selectors.js"; } from "./selectors.js";
import { resolveRunnerE2ESource } from "./source.js"; import { resolveRunnerE2ESource } from "./source.js";
@@ -597,6 +598,7 @@ async function runAttempt(input: {
const agentFilesystemSandbox = agentFilesystemSandboxConfig({ const agentFilesystemSandbox = agentFilesystemSandboxConfig({
platform: process.platform, env: process.env, executions, repositoryRoot, temporaryRoot, privateDir, platform: process.platform, env: process.env, executions, repositoryRoot, temporaryRoot, privateDir,
evidenceRoot: manualPrivateHandoff?.root, installedRelease: Boolean(installedCli), evidenceRoot: manualPrivateHandoff?.root, installedRelease: Boolean(installedCli),
instanceRoot: path.join(paperclipHome, "instances", instanceId),
}); });
// Playwright's default cache holds compiled graders in the shared /tmp. // Playwright's default cache holds compiled graders in the shared /tmp.
if (sandboxedAgents) await rm(defaultPlaywrightCacheDirectory(), { recursive: true, force: true }); if (sandboxedAgents) await rm(defaultPlaywrightCacheDirectory(), { recursive: true, force: true });
@@ -1202,14 +1204,14 @@ async function runWithConcurrency<T, R>(
} }
async function main() { async function main() {
const args = process.argv.slice(2); const args = selectorArgumentsWithEnvironment(process.argv.slice(2));
if (args.includes("--target-config") || args.includes("product-journeys")) { if (args.includes("--target-config") || args.includes("product-journeys")) {
const { launchProductJourneys } = await import("./product-journeys/launch.js"); const { launchProductJourneys } = await import("./product-journeys/launch.js");
return launchProductJourneys(args, repositoryRoot); return launchProductJourneys(args, repositoryRoot);
} }
let options: ReturnType<typeof parseRunnerSelectors>; let options: ReturnType<typeof parseRunnerSelectors>;
try { try {
options = parseRunnerSelectors(process.argv.slice(2)); options = parseRunnerSelectors(args);
} catch (error) { } catch (error) {
if (error instanceof RunnerSelectorError) if (error instanceof RunnerSelectorError)
throw new Error(`${error.message}\nUse --list to inspect valid cells.`); throw new Error(`${error.message}\nUse --list to inspect valid cells.`);
+13
View File
@@ -27,6 +27,19 @@ export interface RunnerSelectorOptions {
export class RunnerSelectorError extends Error {} export class RunnerSelectorError extends Error {}
/**
* CI passes the selected cell here rather than as `--id`. Every agent can read
* another process's argv in /proc, and the cell id names the case under test.
*/
export const RUNNER_SELECTED_ID_ENV = "PAPERCLIP_RUNNER_E2E_SELECTED_ID";
/** Adds the environment's selected cell to the arguments and removes it from the environment. */
export function selectorArgumentsWithEnvironment(args: readonly string[], env: NodeJS.ProcessEnv = process.env): string[] {
const selected = env[RUNNER_SELECTED_ID_ENV]?.trim();
delete env[RUNNER_SELECTED_ID_ENV];
return selected ? [...args, "--id", selected] : [...args];
}
function valueFor(args: string[], index: number, flag: string): string { function valueFor(args: string[], index: number, flag: string): string {
const value = args[index + 1]; const value = args[index + 1];
if (!value || value.startsWith("--")) if (!value || value.startsWith("--"))