From fca4bb7ce383fb4747cfd8f42051a8514fb8430b Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Nov 2026 05:43:52 -0600 Subject: [PATCH] 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//cmdline, so CI passes the selected cell in PAPERCLIP_RUNNER_E2E_SELECTED_ID instead of --id. Co-Authored-By: Claude Opus 5.5 --- .../workflows/chat-catalog-testops-case.yml | 7 +- .github/workflows/runner-full-stack-e2e.yml | 9 +- tests/runner-e2e/SECURITY.md | 36 ++- tests/runner-e2e/agent-filesystem-sandbox.py | 44 +++- .../agent-filesystem-sandbox.test.ts | 163 +++++++++++- tests/runner-e2e/agent-filesystem-sandbox.ts | 240 +++++++++++++++++- tests/runner-e2e/launch.ts | 6 +- tests/runner-e2e/selectors.ts | 13 + 8 files changed, 493 insertions(+), 25 deletions(-) diff --git a/.github/workflows/chat-catalog-testops-case.yml b/.github/workflows/chat-catalog-testops-case.yml index 38229c9f070..73ca9502d92 100644 --- a/.github/workflows/chat-catalog-testops-case.yml +++ b/.github/workflows/chat-catalog-testops-case.yml @@ -165,7 +165,12 @@ jobs: PAPERCLIP_RUNNER_E2E_SOURCE_SHA: ${{ inputs.target_sha }} PAPERCLIP_RUNNER_E2E_SOURCE_REF: refs/heads/codex/chat-catalog-master-integration 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 if: always() && steps.cell_credentials.outcome == 'success' env: *cell_session diff --git a/.github/workflows/runner-full-stack-e2e.yml b/.github/workflows/runner-full-stack-e2e.yml index 6d216fc600b..297f74da7ae 100644 --- a/.github/workflows/runner-full-stack-e2e.yml +++ b/.github/workflows/runner-full-stack-e2e.yml @@ -1435,11 +1435,16 @@ jobs: PAPERCLIP_RUNNER_E2E_SOURCE_SHA: ${{ needs.authorize.outputs.target_sha }} PAPERCLIP_RUNNER_E2E_SOURCE_REF: ${{ needs.authorize.outputs.target_ref }} PAPERCLIP_PLAYWRIGHT_CHANNEL: ${{ needs.authorize.outputs.playwright_channel }} + PAPERCLIP_RUNNER_E2E_SELECTED_ID: ${{ matrix.executionId }} 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 - 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 - pnpm test:e2e:runner -- --id "${{ matrix.executionId }}" + pnpm test:e2e:runner "${selector[@]}" fi - name: Stage bounded campaign evidence diff --git a/tests/runner-e2e/SECURITY.md b/tests/runner-e2e/SECURITY.md index 14d476aa589..2ab8a727186 100644 --- a/tests/runner-e2e/SECURITY.md +++ b/tests/runner-e2e/SECURITY.md @@ -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 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//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 inside Landlock. Legacy adapters, installed-release launches and macOS run 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 -line. Case fixtures that create their own private directories directly in the +mount the checkout into a container, and `ps` shows every harness process's +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 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 diff --git a/tests/runner-e2e/agent-filesystem-sandbox.py b/tests/runner-e2e/agent-filesystem-sandbox.py index 121a72f2b21..e32665a8018 100644 --- a/tests/runner-e2e/agent-filesystem-sandbox.py +++ b/tests/runner-e2e/agent-filesystem-sandbox.py @@ -8,7 +8,10 @@ descendant inherits the domain, including an agent's shell commands. Usage: 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. """ @@ -46,6 +49,7 @@ def fail(message, code=125): def parse(argv): required = False hidden = [] + exposed = [] unset = [] assignments = [] index = 0 @@ -55,16 +59,16 @@ def parse(argv): command = argv[index + 1:] if not command: fail("missing command", 2) - return required, hidden, unset, assignments, command + return required, hidden, exposed, unset, assignments, command if token == "--required": 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 value = argv[index] - if token == "--hide": + if token in ("--hide", "--expose"): if not os.path.isabs(value): - fail("hidden path must be absolute: %s" % value, 2) - hidden.append(value) + fail("%s path must be absolute: %s" % (token[2:], value), 2) + (hidden if token == "--hide" else exposed).append(value) elif token == "--unset": unset.append(value) else: @@ -95,7 +99,7 @@ def landlock(): return libc, syscall -def restrict(hidden): +def restrict(hidden, exposed=()): libc, syscall = landlock() try: abi = syscall( @@ -165,6 +169,10 @@ def restrict(hidden): allow(child) 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: 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)) @@ -181,11 +189,19 @@ def restrict(hidden): except OSError: continue 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 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 = [] for path in hidden_inputs: # Keep paths that do not exist yet: their ancestors stay ungranted, so @@ -194,6 +210,16 @@ def main(): if real == "/": fail("refusing to hide the filesystem root", 2) 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: os.environ.pop(name, None) for name, value in assignments: @@ -204,7 +230,7 @@ def main(): fail("Landlock requires Linux") else: try: - restrict(hidden) + restrict(hidden, exposed) except (OSError, RuntimeError) as error: if required: fail(str(error)) diff --git a/tests/runner-e2e/agent-filesystem-sandbox.test.ts b/tests/runner-e2e/agent-filesystem-sandbox.test.ts index 233766681c2..d70a4337de1 100644 --- a/tests/runner-e2e/agent-filesystem-sandbox.test.ts +++ b/tests/runner-e2e/agent-filesystem-sandbox.test.ts @@ -1,6 +1,6 @@ import { execFileSync } from "node:child_process"; -import { mkdtempSync, realpathSync, rmSync } from "node:fs"; -import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +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"; @@ -20,7 +20,13 @@ import { 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"; @@ -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", () => { 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"; @@ -231,4 +371,23 @@ describe.skipIf(!landlock)("Landlock agent sandbox on this host", () => { 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([]); + }); }); diff --git a/tests/runner-e2e/agent-filesystem-sandbox.ts b/tests/runner-e2e/agent-filesystem-sandbox.ts index 0822890d495..7992e779b7a 100644 --- a/tests/runner-e2e/agent-filesystem-sandbox.ts +++ b/tests/runner-e2e/agent-filesystem-sandbox.ts @@ -1,5 +1,5 @@ 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 os from "node:os"; import { promisify } from "node:util"; @@ -23,8 +23,24 @@ 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}`); } @@ -90,6 +106,7 @@ export function agentFilesystemSandboxConfig(input: { 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())); @@ -125,23 +142,124 @@ export function agentFilesystemSandboxConfig(input: { if (required) throw new Error(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: `/`, 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; - 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`); } - 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"); /** Rewrites one launch so the helper applies the domain and then execs it unchanged. */ export function sandboxedInvocation( - config: AgentFilesystemSandboxConfig, + config: Pick & { exposed?: readonly string[] }, python: string, command: string, args: readonly string[], @@ -156,6 +274,7 @@ export function sandboxedInvocation( ...(config.required ? ["--required"] : []), ...locale, ...config.hidden.flatMap((hidden) => ["--hide", hidden]), + ...(config.exposed ?? []).flatMap((exposed) => ["--expose", exposed]), "--", command, ...args, ], }; @@ -239,6 +358,105 @@ export function probeAgentFilesystemSandbox( 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, 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; + 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 @@ -252,6 +470,8 @@ export function installAgentFilesystemSandbox(repositoryRoot: string, env: NodeJ let probe: ReturnType; 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}`); @@ -286,10 +506,16 @@ export function wrapRunnerdLaunch unknown>(origina 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 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 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; } as T; // execFile's promisified form resolves { stdout, stderr } through this hook; diff --git a/tests/runner-e2e/launch.ts b/tests/runner-e2e/launch.ts index 7737de4a631..15bdb860bb0 100644 --- a/tests/runner-e2e/launch.ts +++ b/tests/runner-e2e/launch.ts @@ -87,6 +87,7 @@ import { buildMatrixJobs, parseRunnerSelectors, RunnerSelectorError, + selectorArgumentsWithEnvironment, selectRunnerExecutions, } from "./selectors.js"; import { resolveRunnerE2ESource } from "./source.js"; @@ -597,6 +598,7 @@ async function runAttempt(input: { const agentFilesystemSandbox = agentFilesystemSandboxConfig({ platform: process.platform, env: process.env, executions, repositoryRoot, temporaryRoot, privateDir, evidenceRoot: manualPrivateHandoff?.root, installedRelease: Boolean(installedCli), + instanceRoot: path.join(paperclipHome, "instances", instanceId), }); // Playwright's default cache holds compiled graders in the shared /tmp. if (sandboxedAgents) await rm(defaultPlaywrightCacheDirectory(), { recursive: true, force: true }); @@ -1202,14 +1204,14 @@ async function runWithConcurrency( } 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")) { const { launchProductJourneys } = await import("./product-journeys/launch.js"); return launchProductJourneys(args, repositoryRoot); } let options: ReturnType; try { - options = parseRunnerSelectors(process.argv.slice(2)); + options = parseRunnerSelectors(args); } catch (error) { if (error instanceof RunnerSelectorError) throw new Error(`${error.message}\nUse --list to inspect valid cells.`); diff --git a/tests/runner-e2e/selectors.ts b/tests/runner-e2e/selectors.ts index 6e8312ed1d4..149fdbce1ce 100644 --- a/tests/runner-e2e/selectors.ts +++ b/tests/runner-e2e/selectors.ts @@ -27,6 +27,19 @@ export interface RunnerSelectorOptions { 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 { const value = args[index + 1]; if (!value || value.startsWith("--"))