From 0740a07ffb6c9ed2c746e5902eacf363b94f9a24 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 8 Nov 2026 19:36:55 -0600 Subject: [PATCH] test(runner-e2e): hide a worktree's Git store and sandbox every runnerd launch API - A linked worktree's .git file points into the main repository; hide the worktree gitdir and the shared common directory too. - Wrap spawn, spawnSync, execFile and execFileSync, match runnerd by name whether absolute or PATH-resolved, and refuse a shell launch rather than start runnerd outside the sandbox. Co-Authored-By: Claude Opus 5.5 --- tests/runner-e2e/SECURITY.md | 9 ++- tests/runner-e2e/agent-filesystem-sandbox.py | 4 +- .../agent-filesystem-sandbox.test.ts | 42 ++++++++++- tests/runner-e2e/agent-filesystem-sandbox.ts | 74 ++++++++++++++----- 4 files changed, 103 insertions(+), 26 deletions(-) diff --git a/tests/runner-e2e/SECURITY.md b/tests/runner-e2e/SECURITY.md index d0cc06b16cd..14d476aa589 100644 --- a/tests/runner-e2e/SECURITY.md +++ b/tests/runner-e2e/SECURITY.md @@ -331,8 +331,9 @@ file modes cannot separate them. OpenCode's `external_directory` policy guards its own read and grep tools, but not arbitrary shell commands. `agent-filesystem-sandbox.ts` closes that gap for local native cells on Linux. -The launcher selects the hidden paths: `tests/`, the checkout's `.git/` (which -holds the committed scenarios and graders), the trusted +The launcher selects the hidden paths: `tests/`, the checkout's Git store (which +holds the committed scenarios and graders; for a linked worktree, its gitdir +and the shared common directory), the trusted `.manual-private-trusted/` copy, the private evidence directory, `RUNNER_TEMP`, and the attempt root's `artifacts-private/`, `control/`, `selected-private-inputs/` and `playwright-transform-cache/` children. @@ -346,7 +347,9 @@ directory accepts no new entries. Agents must keep creating files in `/tmp`, so a sandboxed attempt creates its root in `/var/tmp` instead of `/tmp`. The agent's workspace, home and Paperclip home stay readable inside that root. If harness material would stay under the shared temporary directory or contain the -agent's state, the launcher fails the attempt in CI and warns elsewhere. The E2E server entry starts each `paperclip-runnerd` through +agent's state, the launcher fails the attempt in CI and warns elsewhere. The E2E server entry starts each `paperclip-runnerd` (through `spawn`, +`spawnSync`, `execFile` or `execFileSync`, by absolute path or PATH; a shell +launch is refused) through `agent-filesystem-sandbox.py`, which applies a Landlock domain and then execs runnerd with its original PID, argv, cwd and environment. Runnerd, the provider, its tools and every shell command inherit the domain. The domain also sets diff --git a/tests/runner-e2e/agent-filesystem-sandbox.py b/tests/runner-e2e/agent-filesystem-sandbox.py index 796db1be69d..121a72f2b21 100644 --- a/tests/runner-e2e/agent-filesystem-sandbox.py +++ b/tests/runner-e2e/agent-filesystem-sandbox.py @@ -210,7 +210,9 @@ def main(): fail(str(error)) sys.stderr.write("agent_filesystem_sandbox: continuing without isolation: %s\n" % error) try: - os.execv(command[0], command) + if os.path.isabs(command[0]): + os.execv(command[0], command) + os.execvp(command[0], command) except OSError as error: fail("cannot execute %s: %s" % (command[0], error), 127) diff --git a/tests/runner-e2e/agent-filesystem-sandbox.test.ts b/tests/runner-e2e/agent-filesystem-sandbox.test.ts index b9b4e4d94b6..a4dc55d47ff 100644 --- a/tests/runner-e2e/agent-filesystem-sandbox.test.ts +++ b/tests/runner-e2e/agent-filesystem-sandbox.test.ts @@ -17,6 +17,9 @@ import { sandboxedInvocation, agentFilesystemSandboxHelper, wrapRunnerdSpawn, + wrapRunnerdLaunch, + gitStorePaths, + RUNNERD_LAUNCH_APIS, } from "./agent-filesystem-sandbox.js"; import { runnerMatrix } from "./catalog.js"; @@ -27,6 +30,7 @@ const cell = (id: string) => { if (!execution) throw new Error(`missing ${id}`); return execution; }; +const gitStores = ["/work/paperclip/.git"]; const opencodeLocal = cell("task-workspaces.runner-opencode.local.task-directory-repository-resume"); const base = { platform: "linux" as const, @@ -38,6 +42,7 @@ const base = { evidenceRoot: "/work/_temp/manual-private-evidence-x", installedRelease: false, sharedTemporary: "/tmp", + gitStores, }; describe("agent filesystem sandbox selection", () => { @@ -111,10 +116,9 @@ describe("agent filesystem sandbox selection", () => { describe("runnerd launch rewriting", () => { it("wraps only the absolute runnerd executable and execs it with the original argv", () => { const runnerd = "/work/paperclip/packages/paperclip-runner/runner/target/debug/paperclip-runnerd"; - expect(isRunnerdLaunch(runnerd, { cwd: "/x" })).toBe(true); - expect(isRunnerdLaunch(runnerd, { shell: true })).toBe(false); - expect(isRunnerdLaunch("paperclip-runnerd", {})).toBe(false); - expect(isRunnerdLaunch("/usr/bin/git", {})).toBe(false); + expect(isRunnerdLaunch(runnerd)).toBe(true); + expect(isRunnerdLaunch("paperclip-runnerd")).toBe(true); + expect(isRunnerdLaunch("/usr/bin/git")).toBe(false); const invocation = sandboxedInvocation({ hidden: ["/work/paperclip/tests"], required: true }, "/usr/bin/python3", runnerd, ["--run-id", "r"], {}); expect(invocation).toEqual({ command: "/usr/bin/python3", @@ -135,6 +139,36 @@ describe("runnerd launch rewriting", () => { expect(calls[1]).toEqual(["/usr/bin/git", ["status"], { cwd: "/work" }]); }); + it("rewrites runnerd for every direct launch API and refuses a shell launch", () => { + const calls: unknown[][] = []; + const fake = (...args: unknown[]) => { calls.push(args); return { pid: 9 }; }; + const config = { hidden: ["/work/paperclip/tests"], required: true }; + const callback = () => undefined; + wrapRunnerdLaunch(fake, config, "/usr/bin/python3", "execFile")("paperclip-runnerd", ["--x"], { cwd: "/w" }, callback); + wrapRunnerdLaunch(fake, config, "/usr/bin/python3", "spawnSync")("/r/paperclip-runnerd", { cwd: "/w" }); + expect(calls[0]).toEqual(["/usr/bin/python3", expect.arrayContaining(["--", "paperclip-runnerd", "--x"]), { cwd: "/w" }, callback]); + expect(calls[1]).toEqual(["/usr/bin/python3", expect.arrayContaining(["--", "/r/paperclip-runnerd"]), { cwd: "/w" }]); + expect(() => wrapRunnerdLaunch(fake, config, "/usr/bin/python3")("/r/paperclip-runnerd", [], { shell: true })).toThrow(/refusing/); + expect(RUNNERD_LAUNCH_APIS).toEqual(["spawn", "spawnSync", "execFile", "execFileSync"]); + }); + + it("hides a linked worktree's gitdir and the shared object store", async () => { + const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "agent-sandbox-git-"))); + try { + const common = path.join(root, "main", ".git"); + const gitDir = path.join(common, "worktrees", "wt"); + await mkdir(gitDir, { recursive: true }); + await writeFile(path.join(gitDir, "commondir"), "../..\n"); + const checkout = path.join(root, "wt"); + await mkdir(checkout); + await writeFile(path.join(checkout, ".git"), `gitdir: ${gitDir}\n`); + expect(gitStorePaths(checkout)).toEqual([path.join(checkout, ".git"), gitDir, common]); + expect(gitStorePaths(path.join(root, "main"))).toEqual([common]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("installs the sandbox in the E2E server entry before the product starts", async () => { const entry = await readFile(path.join(import.meta.dirname, "server-entry.ts"), "utf8"); const install = entry.indexOf("installAgentFilesystemSandbox("); diff --git a/tests/runner-e2e/agent-filesystem-sandbox.ts b/tests/runner-e2e/agent-filesystem-sandbox.ts index a2d7a7d6397..16a3ced79e9 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, realpathSync } from "node:fs"; +import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from "node:fs"; import { syncBuiltinESMExports } from "node:module"; import os from "node:os"; import path from "node:path"; @@ -88,6 +88,7 @@ export function agentFilesystemSandboxConfig(input: { evidenceRoot?: string; installedRelease: boolean; sharedTemporary?: string; + gitStores?: readonly string[]; }): AgentFilesystemSandboxConfig | null { if (!agentFilesystemSandboxApplies(input)) return null; const sharedTemporary = path.resolve(input.sharedTemporary ?? (input.env.TMPDIR?.trim() || os.tmpdir())); @@ -96,7 +97,7 @@ export function agentFilesystemSandboxConfig(input: { // and graders, so `git show HEAD:tests/...` would otherwise bypass `tests/`. const essential = [ path.join(input.repositoryRoot, "tests"), - path.join(input.repositoryRoot, ".git"), + ...(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, @@ -159,9 +160,31 @@ export function sandboxedInvocation( }; } -export function isRunnerdLaunch(command: unknown, options: SpawnOptions | undefined) { - return typeof command === "string" && path.isAbsolute(command) && !options?.shell && - path.basename(command) === RUNNERD_EXECUTABLE_NAME; +/** 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() { @@ -240,22 +263,37 @@ export function installAgentFilesystemSandbox(repositoryRoot: string, env: NodeJ console.warn(`[agent-filesystem-sandbox] disabled: ${probe.failures.join("; ")}`); return null; } - childProcess.spawn = wrapRunnerdSpawn(childProcess.spawn, config, python); + const launchers = childProcess as unknown as Record unknown>; + for (const api of RUNNERD_LAUNCH_APIS) launchers[api] = wrapRunnerdLaunch(launchers[api], config, python, api); syncBuiltinESMExports(); return config; } -/** Returns a `spawn` that starts runnerd inside the sandbox and passes other launches through. */ +/** 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 unknown>(original: T, config: AgentFilesystemSandboxConfig, python: string, api: RunnerdLaunchApi = "spawn"): T { + return 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 invocation = sandboxedInvocation(config, python, command as string, args, options?.env ?? process.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})` : ""}`); + return result; + } as T; +} + +/** Back-compatible name for the spawn wrapper. */ export function wrapRunnerdSpawn(original: typeof childProcess.spawn, config: AgentFilesystemSandboxConfig, python: string) { - return function (this: unknown, command: string, ...rest: unknown[]) { - const args = Array.isArray(rest[0]) ? rest[0] as string[] : []; - const options = (Array.isArray(rest[0]) ? rest[1] : rest[0]) as SpawnOptions | undefined; - if (isRunnerdLaunch(command, options)) { - const invocation = sandboxedInvocation(config, python, command, args, options?.env ?? process.env); - const child = Reflect.apply(original, this, [invocation.command, invocation.args, options ?? {}]) as ReturnType; - console.log(`[agent-filesystem-sandbox] runnerd launched inside the sandbox (pid ${child.pid ?? "unknown"})`); - return child; - } - return Reflect.apply(original, this, [command, ...rest]); - } as typeof childProcess.spawn; + return wrapRunnerdLaunch(original, config, python, "spawn"); }