Files
PaperClipAI/tests/runner-e2e/selectors.ts
T
DottaandClaude Opus 5.5 347d21fcbd test(runner-e2e): scope each agent's sandbox to its own run's saved state (#20253)
## 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>
2026-11-09 07:28:12 -06:00

271 lines
9.1 KiB
TypeScript

import { runnerMatrix } from "./catalog.js";
import type { MatrixExecution, MatrixJob } from "./types.js";
export interface RunnerSelectorOptions {
all: boolean;
list: boolean;
matrixJson: boolean;
installedStartupOnly?: boolean;
ids: string[];
suites: string[];
groups: string[];
profiles: string[];
environments: string[];
cases: string[];
headed: boolean;
ui: boolean;
debug: boolean;
maxParallel: number;
maxAutomaticRetries: number;
connectionConfig?: string;
githubReviewConfig?: string;
provisionGitHubRepository?: boolean;
antigravityJourneyConfig?: string;
antigravityJourneyPreflight?: boolean;
antigravityJourneyRecover?: string;
}
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("--"))
throw new RunnerSelectorError(`${flag} requires a value`);
return value;
}
export function parseRunnerSelectors(
rawArgs: readonly string[],
): RunnerSelectorOptions {
const args = rawArgs.filter((value) => value !== "--");
const options: RunnerSelectorOptions = {
all: false,
list: false,
matrixJson: false,
ids: [],
suites: [],
groups: [],
profiles: [],
environments: [],
cases: [],
headed: false,
ui: false,
debug: false,
maxParallel: Number(process.env.PAPERCLIP_E2E_MAX_PARALLEL ?? "1"),
maxAutomaticRetries: 1,
};
for (let index = 0; index < args.length; index += 1) {
const flag = args[index];
if (flag === "--all") options.all = true;
else if (flag === "--list") options.list = true;
else if (flag === "--matrix-json") options.matrixJson = true;
else if (flag === "--installed-startup-only") options.installedStartupOnly = true;
else if (flag === "--headed") options.headed = true;
else if (flag === "--ui") options.ui = true;
else if (flag === "--debug") options.debug = true;
else if (flag === "--connection-config") {
options.connectionConfig = valueFor(args, index, flag);
index += 1;
}
else if (flag === "--github-review-config") {
options.githubReviewConfig = valueFor(args, index, flag);
index += 1;
}
else if (flag === "--provision-github-repository") options.provisionGitHubRepository = true;
else if (flag === "--antigravity-journey-config") {
options.antigravityJourneyConfig = valueFor(args, index, flag);
index += 1;
}
else if (flag === "--antigravity-journey-preflight") options.antigravityJourneyPreflight = true;
else if (flag === "--antigravity-journey-recover") {
options.antigravityJourneyRecover = valueFor(args, index, flag);
index += 1;
}
else if (flag === "--max-parallel") {
const value = valueFor(args, index, flag);
index += 1;
options.maxParallel = Number(value);
} else if (flag === "--max-automatic-retries") {
const value = valueFor(args, index, flag);
index += 1;
options.maxAutomaticRetries = Number(value);
} else if (
[
"--id",
"--suite",
"--group",
"--profile",
"--environment",
"--case",
].includes(flag)
) {
const value = valueFor(args, index, flag);
index += 1;
if (flag === "--id") options.ids.push(value);
else if (flag === "--suite") options.suites.push(value);
else if (flag === "--group") options.groups.push(value);
else if (flag === "--profile") options.profiles.push(value);
else if (flag === "--environment") options.environments.push(value);
else options.cases.push(value);
} else {
throw new RunnerSelectorError(`Unknown runner E2E argument: ${flag}`);
}
}
if (!Number.isInteger(options.maxParallel) || options.maxParallel < 1) {
throw new RunnerSelectorError("--max-parallel must be a positive integer");
}
if (![0, 1].includes(options.maxAutomaticRetries)) {
throw new RunnerSelectorError(
"--max-automatic-retries must be 0 or 1",
);
}
const hasDimensions =
options.suites.length +
options.groups.length +
options.profiles.length +
options.environments.length +
options.cases.length >
0;
if (options.ids.length > 0 && (hasDimensions || options.all)) {
throw new RunnerSelectorError(
"--id is exclusive with --all and dimension filters",
);
}
if (options.all && hasDimensions) {
throw new RunnerSelectorError("--all is exclusive with dimension filters");
}
if (
!options.list &&
!options.matrixJson &&
!options.all &&
options.ids.length === 0 &&
!hasDimensions
) {
throw new RunnerSelectorError(
"Billable runner E2E runs require --all or an explicit selector",
);
}
return options;
}
function assertKnown(
label: string,
selected: readonly string[],
known: Set<string>,
) {
const unknown = selected.filter((value) => !known.has(value));
if (unknown.length > 0)
throw new RunnerSelectorError(`Unknown ${label}: ${unknown.join(", ")}`);
}
export function selectRunnerExecutions(
options: RunnerSelectorOptions,
matrix: readonly MatrixExecution[] = runnerMatrix,
): MatrixExecution[] {
const knownGroups = new Set(matrix.flatMap((execution) => execution.groups));
assertKnown(
"suite",
options.suites,
new Set(matrix.map((execution) => execution.suite.id)),
);
assertKnown("group", options.groups, knownGroups);
assertKnown(
"profile",
options.profiles,
new Set(matrix.map((execution) => execution.profile.id)),
);
assertKnown(
"environment",
options.environments,
new Set(matrix.map((execution) => execution.environment.id)),
);
assertKnown(
"case",
options.cases,
new Set(matrix.map((execution) => execution.task.id)),
);
assertKnown(
"execution id",
options.ids,
new Set(matrix.map((execution) => execution.id)),
);
const selected = matrix.filter((execution) => {
if (options.ids.length > 0) return options.ids.includes(execution.id);
if (execution.suite.manualOnly && !options.suites.includes(execution.suite.id) && !options.list) return false;
const unfilteredList = options.list && options.groups.length + options.suites.length + options.profiles.length
+ options.environments.length + options.cases.length === 0;
const explicitGroup = execution.task.explicitGroup;
// Naming any of the task's own groups (for example `slow`) also opts in.
if (explicitGroup && !unfilteredList && !options.groups.some(group => group === explicitGroup || execution.task.groups.includes(group))
&& !options.cases.includes(execution.task.id)) return false;
if (
options.all ||
(options.list &&
options.groups.length === 0 &&
options.suites.length === 0 &&
options.profiles.length === 0 &&
options.environments.length === 0 &&
options.cases.length === 0)
)
return true;
return (
(options.suites.length === 0 ||
options.suites.includes(execution.suite.id)) &&
options.groups.every((group) => execution.groups.includes(group)) &&
(options.profiles.length === 0 ||
options.profiles.includes(execution.profile.id)) &&
(options.environments.length === 0 ||
options.environments.includes(execution.environment.id)) &&
(options.cases.length === 0 || options.cases.includes(execution.task.id))
);
});
if (selected.length === 0)
throw new RunnerSelectorError(
"Runner E2E selectors matched zero executions",
);
return selected;
}
export function buildMatrixJobs(
executions: readonly MatrixExecution[],
): MatrixJob[] {
if (executions.some(e => ["provider_connection", "github_review_journey", "antigravity_agent_journey"].includes(e.task.flow))) throw new RunnerSelectorError("Attended connection journeys require explicit private setup profiles; they are not admitted to paid CI matrix jobs yet.");
return executions
.map((execution) => ({
executionId: execution.id,
suiteId: execution.suite.id,
profileId: execution.profile.id,
credentialName: execution.profile.credential,
environmentId: execution.environment.id,
caseId: execution.task.id,
timeoutMinutes: Math.max(
execution.environment.id === "daytona" ? 40 : 25,
Math.ceil(
(2 *
(execution.task.attemptTimeoutMs[execution.environment.id] +
90_000) +
5 * 60_000) /
60_000,
),
),
needsDaytona: execution.environment.id === "daytona",
}))
.sort((left, right) => left.executionId.localeCompare(right.executionId));
}