mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:39:44 +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>
248 lines
8.6 KiB
Python
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()
|