Files
PaperClipAI/tests/runner-e2e/agent-filesystem-sandbox.py
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

248 lines
8.6 KiB
Python

"""Execute a command whose process tree cannot read the eval harness.
Runner E2E starts the product from a repository checkout that also holds the
scenarios, fixtures and graders. This helper applies a Linux Landlock domain
that grants filesystem access everywhere except the given hidden paths, then
replaces itself with the command (same PID, argv and environment). Every
descendant inherits the domain, including an agent's shell commands.
Usage:
agent-filesystem-sandbox.py [--required] [--unset NAME]... [--set NAME=VALUE]...
--hide PATH [--hide PATH]... [--expose PATH]... -- COMMAND [ARG]...
An exposed path lies beneath a hidden directory and stays usable: a run sees its
own saved session while the directory holding every run's sessions stays hidden.
Without Landlock, --required exits 125 instead of running the command.
"""
import ctypes
import os
import stat
import struct
import sys
SYS_LANDLOCK_CREATE_RULESET = 444
SYS_LANDLOCK_ADD_RULE = 445
SYS_LANDLOCK_RESTRICT_SELF = 446
LANDLOCK_CREATE_RULESET_VERSION = 1
LANDLOCK_RULE_PATH_BENEATH = 1
PR_SET_NO_NEW_PRIVS = 38
FILE_RIGHTS = (1 << 0) | (1 << 1) | (1 << 2) # execute, write, read
ABI_1_RIGHTS = (1 << 13) - 1
REFER = 1 << 13
TRUNCATE = 1 << 14
IOCTL_DEV = 1 << 15
class RulesetAttr(ctypes.Structure):
_fields_ = [("handled_access_fs", ctypes.c_uint64)]
def fail(message, code=125):
sys.stderr.write("agent_filesystem_sandbox: %s\n" % message)
sys.stderr.flush()
os._exit(code)
def parse(argv):
required = False
hidden = []
exposed = []
unset = []
assignments = []
index = 0
while index < len(argv):
token = argv[index]
if token == "--":
command = argv[index + 1:]
if not command:
fail("missing command", 2)
return required, hidden, exposed, unset, assignments, command
if token == "--required":
required = True
elif token in ("--hide", "--expose", "--unset", "--set") and index + 1 < len(argv):
index += 1
value = argv[index]
if token in ("--hide", "--expose"):
if not os.path.isabs(value):
fail("%s path must be absolute: %s" % (token[2:], value), 2)
(hidden if token == "--hide" else exposed).append(value)
elif token == "--unset":
unset.append(value)
else:
name, separator, assigned = value.partition("=")
if not separator or not name:
fail("--set requires NAME=VALUE", 2)
assignments.append((name, assigned))
else:
fail("unexpected argument: %s" % token, 2)
index += 1
fail("missing -- before the command", 2)
def within(candidate, root):
return candidate == root or candidate.startswith(root.rstrip("/") + "/")
def landlock():
libc = ctypes.CDLL(None, use_errno=True)
libc.syscall.restype = ctypes.c_long
def syscall(*args):
result = libc.syscall(*args)
if result < 0:
raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno()))
return result
return libc, syscall
def restrict(hidden, exposed=()):
libc, syscall = landlock()
try:
abi = syscall(
ctypes.c_long(SYS_LANDLOCK_CREATE_RULESET),
ctypes.c_void_p(None),
ctypes.c_long(0),
ctypes.c_long(LANDLOCK_CREATE_RULESET_VERSION),
)
except OSError as error:
raise RuntimeError("Landlock is unavailable on this kernel (%s)" % error)
handled = ABI_1_RIGHTS
if abi >= 2:
handled |= REFER
if abi >= 3:
handled |= TRUNCATE
if abi >= 5:
handled |= IOCTL_DEV
attr = RulesetAttr(handled)
ruleset = syscall(
ctypes.c_long(SYS_LANDLOCK_CREATE_RULESET),
ctypes.byref(attr),
ctypes.c_long(ctypes.sizeof(attr)),
ctypes.c_long(0),
)
def allow(path):
try:
descriptor = os.open(path, os.O_PATH | os.O_CLOEXEC)
except OSError:
return
try:
mode = os.fstat(descriptor).st_mode
access = handled if stat.S_ISDIR(mode) else handled & (FILE_RIGHTS | TRUNCATE | IOCTL_DEV)
# struct landlock_path_beneath_attr is packed: u64 allowed_access, s32 parent_fd.
rule = ctypes.create_string_buffer(struct.pack("=Qi", access, descriptor), 12)
syscall(
ctypes.c_long(SYS_LANDLOCK_ADD_RULE),
ctypes.c_long(ruleset),
ctypes.c_long(LANDLOCK_RULE_PATH_BENEATH),
rule,
ctypes.c_long(0),
)
finally:
os.close(descriptor)
# Landlock only grants. Grant every entry except the hidden paths, walking
# down only through their ancestors; ancestors themselves get no rule.
def grant(directory):
try:
names = sorted(os.listdir(directory))
except OSError:
return
for name in names:
child = os.path.join(directory, name)
if any(within(child, path) for path in hidden):
continue
try:
is_link = os.path.islink(child)
real = os.path.realpath(child)
except OSError:
continue
if any(within(real, path) or within(path, real) for path in hidden):
if not is_link and any(within(path, child) for path in hidden):
grant(child)
# A link into, or onto an ancestor of, a hidden path stays ungranted.
continue
allow(child)
grant("/")
# Landlock does not check path traversal, so a rule on an exposed path
# grants it even though the hidden directory above it has no rule.
for path in exposed:
allow(path)
if libc.prctl(ctypes.c_int(PR_SET_NO_NEW_PRIVS), ctypes.c_ulong(1), ctypes.c_ulong(0), ctypes.c_ulong(0), ctypes.c_ulong(0)) != 0:
raise RuntimeError("prctl(PR_SET_NO_NEW_PRIVS) failed")
syscall(ctypes.c_long(SYS_LANDLOCK_RESTRICT_SELF), ctypes.c_long(ruleset), ctypes.c_long(0))
os.close(ruleset)
# Fail closed unless this process (which becomes the command) is denied.
for path in hidden:
try:
if os.path.isdir(path):
os.listdir(path)
else:
os.close(os.open(path, os.O_RDONLY | os.O_CLOEXEC))
except PermissionError:
continue
except OSError:
continue
raise RuntimeError("sandbox ineffective: %s is still readable" % path)
for path in exposed:
try:
if os.path.isdir(path):
os.listdir(path)
else:
os.close(os.open(path, os.O_RDONLY | os.O_CLOEXEC))
except OSError as error:
raise RuntimeError("sandbox unusable: %s is not readable (%s)" % (path, error))
return abi
def main():
required, hidden_inputs, exposed_inputs, unset, assignments, command = parse(sys.argv[1:])
hidden = []
for path in hidden_inputs:
# Keep paths that do not exist yet: their ancestors stay ungranted, so
# a later mkdir there is unreachable too.
real = os.path.realpath(path)
if real == "/":
fail("refusing to hide the filesystem root", 2)
hidden.append(real)
exposed = []
for path in exposed_inputs:
real = os.path.realpath(path)
if not os.path.exists(real):
fail("exposed path must exist before the sandbox starts: %s" % real, 2)
if not any(within(real, root) and real != root for root in hidden):
fail("exposed path must lie beneath a hidden directory: %s" % real, 2)
if any(within(root, real) for root in hidden):
fail("exposed path would reveal a hidden path: %s" % real, 2)
exposed.append(real)
for name in unset:
os.environ.pop(name, None)
for name, value in assignments:
os.environ[name] = value
if hidden:
if not sys.platform.startswith("linux"):
if required:
fail("Landlock requires Linux")
else:
try:
restrict(hidden, exposed)
except (OSError, RuntimeError) as error:
if required:
fail(str(error))
sys.stderr.write("agent_filesystem_sandbox: continuing without isolation: %s\n" % error)
try:
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)
if __name__ == "__main__":
main()