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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-08 19:37:19 -06:00
1 parent b54be5c7a9
commit 0740a07ffb
4 files changed
+103 -26

No files matched your search

+6 -3
View File
@@ -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
+3 -1
View File
@@ -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)
@@ -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(");
+56 -18
View File
@@ -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<RunnerdLaunchApi, (...args: unknown[]) => 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<T extends (...args: any[]) => 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<typeof original>;
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");
}