mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The runner E2E chat evals run real agents against a local Paperclip
instance on a CI host
> - PR #20044 hides the eval harness from local native agents with a
Linux Landlock domain around each runnerd launch
> - That domain is the same for every run in an attempt, so an eval
agent can still read another run's saved state
> - Recent chat-eval runs show agents that read other runs' OpenCode
session databases, another run's run log, the instance `pg_wal`,
`control-plane-state.json`, and `/proc/<pid>/cmdline` of harness
processes
> - This pull request scopes each runnerd launch to its own run and
moves the selected case id off the command line
> - The benefit is that an eval agent cannot learn about the case or
other runs from files outside its own run
## Linked Issues or Issue Description
Refs #20044
**What happened**
In local native OpenCode chat-eval cells, worker agents read files that
belong to other runs or to the harness:
- other runs' OpenCode sqlite session databases under
`runtime/paperclip-runner/durable-sessions/` (a status-cards run)
- `/proc/<pid>/cmdline` of harness processes, which shows harness paths
and the case id (`routines.pause-owned`)
- another run's run-log ndjson, and a listing of the instance
`db/pg_wal` (`routines.pause-owned`)
- a listing of `control-plane-state.json`
(`runs-adapters.list-model-options`)
The Landlock sandbox from #20044 hides only the harness (`tests/`, the
Git store, private attempt directories). All runnerd launches share one
hidden set, so the instance's per-run directories stay readable.
**Expected behavior**
An agent process sees only its own run's durable session, OpenCode
session and provider trace. Other runs' sessions, run logs and traces,
the instance database files, secrets and the shared control-plane state
in the attempt instance are denied. The case id is not on any harness
command line.
**Steps to reproduce**
1. Dispatch a local native OpenCode chat-features cell that has two
agents (for example a status-cards case).
2. In the worker's shell, run `ls
<instance>/runtime/paperclip-runner/durable-sessions/*/` and `ls
<instance>/db/pg_wal`.
3. Run `cat /proc/<launcher pid>/cmdline`.
4. Before this change, all three succeed and show other runs' files and
`--id chat-features...`.
## What Changed
- `agent-filesystem-sandbox.py`: new `--expose PATH` option. It grants a
path beneath a hidden directory. Landlock does not check traversal, so
the run uses its own entry while the parent stays unlistable. The helper
refuses a missing exposure, an exposure outside every hidden path, or
one that would reveal a hidden path. It fails the launch unless every
exposed path is readable.
- `agent-filesystem-sandbox.ts`: the launcher passes the instance root
in the sandbox configuration. `runnerdLaunchSandbox` adds per-launch
hidden paths: `durable-sessions/`, `runner/`, `control-plane/`,
`opencode/` under `runtime/paperclip-runner/`, and `data/run-logs/`,
`data/provider-traces/`, `data/backups/`, `db/`, `secrets/`. It exposes
only the launch's own durable-session directory (from `--state-dir`),
Codex runner state directory, OpenCode session directory (from
`--session-id`, named as the OpenCode driver names it) and provider
trace file.
- The wrapper creates the exposed entries, and the shared instance
directories that the product creates on first use, before runnerd
starts. It logs the run-scoped hidden and exposed paths for each launch.
- New `probeRunScopedSandbox`: at server start, a sandboxed shell runs
against a fixture instance. Another run's session, OpenCode database,
runner state, run log, trace, `pg_wal`, secrets, control-plane state and
the server's `/proc/<pid>/environ` must be denied. The run's own
session, OpenCode directory, ACPX directory and trace must be writable.
In CI any failure stops the attempt. The result goes to `server.log`.
- `selectors.ts` and `launch.ts`: the launcher reads the selected cell
from `PAPERCLIP_RUNNER_E2E_SELECTED_ID` and removes it from its
environment.
- `runner-full-stack-e2e.yml` (Run paid cell) and
`chat-catalog-testops-case.yml`: pass the cell in that variable instead
of `--id`. A target branch that does not read the variable still gets
`--id`.
- `SECURITY.md`: documents the run scope, the `/proc` limit and what
stays readable.
## Verification
- `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts
tests/runner-e2e/agent-filesystem-sandbox.test.ts
tests/runner-e2e/workflow-security.test.ts
tests/runner-e2e/manual-private-workflow.test.ts
tests/runner-e2e/private-trust-workflow-security.test.ts` passes on
macOS. The Landlock host tests (including the new fixture-instance probe
test) run only on Linux CI.
- `npx tsc --noEmit -p tests/runner-e2e` shows no errors in the changed
files.
- Paid run
[42611358428](https://github.com/paperclipai/paperclip/actions/runs/42611358428):
`chat-features.runner-opencode.local.agents.change-manager` on the same
commit applied to `chat-eval/master-candidate` (fca4bb7ce3). Result:
passed. In `server.log` the setup probe reports `failures: []` and
`run:otherDurableSession`, `run:durableSessionListing`,
`run:otherRunnerState`, `run:controlPlaneState`,
`run:otherOpencodeSession`, `run:otherRunLog`, `run:databaseWal`,
`run:secrets`, `run:otherProviderTrace` and `run:harnessEnviron` all
`denied`; `run:ownDurableSession`, `run:ownOpencodeSession`,
`run:acpxRuntime` and `run:ownProviderTrace` `allowed`;
`run:harnessCmdline` `allowed` (recorded, Landlock limit). The runnerd
launch line lists the nine run-scoped hidden instance paths and exposes
only its own durable-session and OpenCode session directories. The agent
finished the case inside that sandbox.
## Risks
- A sandboxed runnerd cannot create new direct entries in the instance
root, `runtime/`, `runtime/paperclip-runner/` or `data/`. The wrapper
creates the known ones first (`acpx`, `companies`, `projects`,
`workspaces`, `runtime-context-assets`, `skills`,
`runtime/provider-packs`). A provider that creates a different new
directory there fails in a sandboxed local cell. The paid run covers
OpenCode only. ACPX cells keep their runtime directory readable.
- A run still reads its own session's control-plane state, and other
agents' homes and task workspaces in the same instance.
- Landlock cannot hide `/proc/<pid>/cmdline`. Harness paths stay visible
there. Only the case id moves to the environment. Scheduled multi-cell
campaigns still pass `--id`.
- Harness-only change. No product code changes.
## Model Used
- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), extended thinking, tool
use (shell, file edits, GitHub CLI).
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
394 lines
23 KiB
TypeScript
394 lines
23 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
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";
|
|
import {
|
|
HARNESS_PRIVATE_TEMPORARY_CHILDREN,
|
|
agentFilesystemSandboxConfig,
|
|
agentTemporaryParent,
|
|
defaultPlaywrightCacheDirectory,
|
|
playwrightCacheDirectory,
|
|
isRunnerdLaunch,
|
|
parseAgentFilesystemSandboxConfig,
|
|
probeAgentFilesystemSandbox,
|
|
pythonExecutable,
|
|
sandboxedInvocation,
|
|
agentFilesystemSandboxHelper,
|
|
wrapRunnerdSpawn,
|
|
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";
|
|
const temporaryRoot = "/var/tmp/paperclip-runner-e2e-abc";
|
|
const cell = (id: string) => {
|
|
const execution = runnerMatrix.find((candidate) => candidate.id === id);
|
|
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,
|
|
env: { GITHUB_ACTIONS: "true", RUNNER_TEMP: "/work/_temp" },
|
|
executions: [opencodeLocal],
|
|
repositoryRoot,
|
|
temporaryRoot,
|
|
privateDir: "/work/_temp/manual-private-evidence-x/capture",
|
|
evidenceRoot: "/work/_temp/manual-private-evidence-x",
|
|
installedRelease: false,
|
|
sharedTemporary: "/tmp",
|
|
gitStores,
|
|
};
|
|
|
|
describe("agent filesystem sandbox selection", () => {
|
|
it("hides the harness, the trusted copy and captured evidence from local native agents", () => {
|
|
expect(agentFilesystemSandboxConfig(base)).toEqual({
|
|
hidden: [
|
|
"/work/paperclip/tests",
|
|
"/work/paperclip/.git",
|
|
"/work/paperclip/.manual-private-trusted",
|
|
`${temporaryRoot}/artifacts-private`,
|
|
`${temporaryRoot}/control`,
|
|
`${temporaryRoot}/selected-private-inputs`,
|
|
`${temporaryRoot}/playwright-transform-cache`,
|
|
"/work/_temp/manual-private-evidence-x/capture",
|
|
"/work/_temp/manual-private-evidence-x",
|
|
"/work/_temp",
|
|
],
|
|
required: true,
|
|
});
|
|
});
|
|
|
|
it("hides the attempt's private children and Playwright cache when its root is outside the shared temporary directory", () => {
|
|
const config = agentFilesystemSandboxConfig(base);
|
|
expect(config?.hidden).toContain(playwrightCacheDirectory(temporaryRoot));
|
|
expect(config?.hidden).not.toContain(temporaryRoot);
|
|
expect(HARNESS_PRIVATE_TEMPORARY_CHILDREN).toContain("playwright-transform-cache");
|
|
expect(defaultPlaywrightCacheDirectory("/tmp")).toMatch(/^\/tmp\/playwright-transform-cache-/);
|
|
});
|
|
|
|
it("fails closed in CI when harness material sits where it cannot be hidden", () => {
|
|
const underShared = { ...base, temporaryRoot: "/tmp/paperclip-runner-e2e-abc", privateDir: "/tmp/paperclip-runner-e2e-abc/artifacts-private" };
|
|
expect(() => agentFilesystemSandboxConfig(underShared)).toThrow(/agent_filesystem_sandbox_incomplete: .*artifacts-private/);
|
|
expect(() => agentFilesystemSandboxConfig({ ...base, repositoryRoot: "/tmp/checkout" })).toThrow(/\/tmp\/checkout\/tests/);
|
|
});
|
|
|
|
it("keeps a sandboxed attempt's root out of the shared temporary directory", () => {
|
|
const parent = agentTemporaryParent(true);
|
|
if (process.platform === "linux") expect(parent).toBe(realpathSync("/var/tmp"));
|
|
expect(agentTemporaryParent(false)).toBe(realpathSync(os.tmpdir()));
|
|
});
|
|
|
|
it("never hides the agent's own state or the checkout root, and only warns outside CI", () => {
|
|
const config = agentFilesystemSandboxConfig({
|
|
...base,
|
|
env: { RUNNER_TEMP: "/tmp" },
|
|
temporaryRoot: "/tmp/paperclip-runner-e2e-abc",
|
|
privateDir: "/tmp/paperclip-runner-e2e-abc/artifacts-private",
|
|
evidenceRoot: "/work",
|
|
});
|
|
expect(config).toEqual({ hidden: ["/work/paperclip/tests", "/work/paperclip/.git", "/work/paperclip/.manual-private-trusted"], required: false });
|
|
});
|
|
|
|
it.each([
|
|
["macOS", { platform: "darwin" as const }],
|
|
["an installed release", { installedRelease: true }],
|
|
["remote-only cells", { executions: [cell("rolling-web-deployment.runner-opencode.daytona.recover-controller")] }],
|
|
["legacy adapters", { executions: [cell("blocker-guidance.legacy-codex.local.human-authority")] }],
|
|
["Codex's own mount sandbox", { executions: [opencodeLocal, cell("necessary-question.runner-codex.local.necessary-free-text")] }],
|
|
])("does not apply to %s", (_label, override) => {
|
|
expect(agentFilesystemSandboxConfig({ ...base, ...override })).toBeNull();
|
|
});
|
|
|
|
it("round-trips through the server environment and rejects malformed values", () => {
|
|
const config = agentFilesystemSandboxConfig(base)!;
|
|
expect(parseAgentFilesystemSandboxConfig(JSON.stringify(config))).toEqual(config);
|
|
expect(parseAgentFilesystemSandboxConfig(undefined)).toBeNull();
|
|
expect(() => parseAgentFilesystemSandboxConfig(JSON.stringify({ hidden: ["relative"], required: true }))).toThrow(/malformed/);
|
|
});
|
|
});
|
|
|
|
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";
|
|
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",
|
|
args: ["-I", "-S", agentFilesystemSandboxHelper, "--required", "--unset", "LC_CTYPE", "--hide", "/work/paperclip/tests", "--", runnerd, "--run-id", "r"],
|
|
});
|
|
expect(sandboxedInvocation({ hidden: [], required: false }, "p", runnerd, [], { LC_CTYPE: "C" }).args).toContain("LC_CTYPE=C");
|
|
});
|
|
|
|
it("rewrites runnerd launches through the wrapped spawn and passes other launches through", () => {
|
|
const calls: unknown[][] = [];
|
|
const fake = ((...args: unknown[]) => { calls.push(args); return { pid: 7 }; }) as unknown as typeof import("node:child_process").spawn;
|
|
const spawn = wrapRunnerdSpawn(fake, { hidden: ["/work/paperclip/tests"], required: true }, "/usr/bin/python3");
|
|
const runnerd = "/work/paperclip/packages/paperclip-runner/runner/target/debug/paperclip-runnerd";
|
|
spawn(runnerd, ["--run-id", "r"], { cwd: "/work", env: { LC_CTYPE: "C.UTF-8" }, detached: true });
|
|
spawn("/usr/bin/git", ["status"], { cwd: "/work" });
|
|
expect(calls[0]).toEqual(["/usr/bin/python3", expect.arrayContaining(["--hide", "/work/paperclip/tests", "--", runnerd, "--run-id", "r"]),
|
|
{ cwd: "/work", env: { LC_CTYPE: "C.UTF-8" }, detached: true }]);
|
|
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("keeps promisify(execFile) resolving { stdout, stderr } after wrapping", async () => {
|
|
const { execFile } = await import("node:child_process");
|
|
const { promisify } = await import("node:util");
|
|
const wrapped = wrapRunnerdLaunch(execFile, { hidden: ["/nowhere"], required: false }, "/usr/bin/python3", "execFile");
|
|
const result = await promisify(wrapped)(process.execPath, ["-e", "process.stdout.write('ok')"]);
|
|
expect(result).toEqual({ stdout: "ok", stderr: "" });
|
|
});
|
|
|
|
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(");
|
|
expect(install).toBeGreaterThan(0);
|
|
expect(install).toBeLessThan(entry.indexOf('await import("../../cli/src/index.js")'));
|
|
});
|
|
});
|
|
|
|
const landlock = process.platform === "linux" && (() => {
|
|
const probe = mkdtempSync(path.join(os.tmpdir(), "agent-sandbox-abi-"));
|
|
try {
|
|
execFileSync(pythonExecutable(), ["-I", "-S", agentFilesystemSandboxHelper, "--required", "--hide", probe, "--", "/bin/true"]);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
} finally {
|
|
rmSync(probe, { recursive: true, force: true });
|
|
}
|
|
})();
|
|
|
|
describe.skipIf(!landlock)("Landlock agent sandbox on this host", () => {
|
|
const roots: string[] = [];
|
|
afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); });
|
|
|
|
it("denies a sandboxed process the harness while keeping the product and temporary files usable", async () => {
|
|
const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "agent-sandbox-")));
|
|
roots.push(root);
|
|
const repository = path.join(root, "checkout");
|
|
await mkdir(path.join(repository, "tests", "runner-e2e", "chat-scenarios"), { recursive: true });
|
|
await mkdir(path.join(repository, "packages", "paperclip-runner"), { recursive: true });
|
|
await mkdir(path.join(root, "scratch"));
|
|
await writeFile(path.join(repository, "tests", "runner-e2e", "agent-filesystem-sandbox.ts"), "grader");
|
|
await writeFile(path.join(repository, "tests", "runner-e2e", "chat-scenarios", "access.yaml"), "expected_outcome: secret");
|
|
await writeFile(path.join(repository, "packages", "paperclip-runner", "package.json"), "{}");
|
|
const attempt = path.join(root, "attempt");
|
|
await mkdir(path.join(attempt, "artifacts-private"), { recursive: true });
|
|
await mkdir(path.join(attempt, "workspace"));
|
|
await writeFile(path.join(attempt, "artifacts-private", "server.log"), "secret");
|
|
const config = { hidden: [path.join(repository, "tests"), path.join(attempt, "artifacts-private"), path.join(attempt, "control")], required: true };
|
|
// The fixture lives under the shared temporary directory, so point mktemp at a granted sibling.
|
|
const probe = probeAgentFilesystemSandbox(config, repository, pythonExecutable(), { ...process.env, TMPDIR: path.join(root, "scratch") }, path.join(root, "no-playwright-cache"));
|
|
expect(probe.failures).toEqual([]);
|
|
expect(probe.result).toMatchObject({ playwrightDefaultCache: "denied", harnessFile: "denied", harnessDirectory: "denied", productFile: "readable", temporaryWrite: "allowed" });
|
|
// The exact read from the reported run: grep over the checkout's scenarios.
|
|
const invocation = sandboxedInvocation(config, pythonExecutable(), "/bin/sh", ["-c", `grep -rn expected_outcome ${repository}/tests; echo "exit=$?"`], process.env);
|
|
const output = execFileSync(invocation.command, invocation.args, { encoding: "utf8" });
|
|
expect(output).not.toContain("secret");
|
|
expect(output).toMatch(/exit=2/);
|
|
const attemptCheck = sandboxedInvocation(config, pythonExecutable(), "/bin/sh", ["-c", `cat ${attempt}/artifacts-private/server.log; ls ${attempt}/workspace >/dev/null && echo workspace=ok; mkdir ${attempt}/control 2>/dev/null || echo control=blocked`], process.env);
|
|
const attemptOutput = execFileSync(attemptCheck.command, attemptCheck.args, { encoding: "utf8" });
|
|
expect(attemptOutput).not.toContain("secret");
|
|
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([]);
|
|
});
|
|
});
|