mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
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:
1 parent
b54be5c7a9
commit
0740a07ffb
4 files changed
+103
-26
No files matched your search
@@ -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
|
||||
|
||||
@@ -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(");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user