mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
fix(runner): keep the controller's network decision for external-sandbox planning
Planning in an external sandbox keeps full-disk read so Codex skips its network-isolated filesystem helper, but its network now follows PAPERCLIP_RUNNER_NETWORK_ACCESS. The session context reports the real root access, and the e2e grader only accepts a sandbox refusal of the requested write, checked before the UI step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
65460ca99f
commit
f30aeb3e72
7 files changed
+74
-35
No files matched your search
@@ -91,15 +91,20 @@ the `paperclip-runner-workspace-read-only` profile on `thread/start` and
|
||||
`turn/start` everywhere, because Codex's plan collaboration mode is a prompt
|
||||
and does not block writes or commands. Inside an external sandbox that profile
|
||||
reads the whole filesystem (`":root"="read"`), writes nowhere except a
|
||||
brokered GitHub CLI config directory, and enables network. Codex then reports
|
||||
a `readOnly` sandbox with network access, so it loads `AGENTS.md` and other
|
||||
files directly instead of through its filesystem helper, and runs commands
|
||||
under bubblewrap without `--unshare-net`. The helper always isolates the
|
||||
network, and a container without `CAP_NET_ADMIN` cannot bring up loopback in a
|
||||
new network namespace (`bwrap: loopback: Failed RTM_NEWADDR`), which made
|
||||
thread creation fail. Writes from commands and patches are still refused. The
|
||||
broader read access matches what default-mode turns already have in the same
|
||||
sandbox. Local and SSH planning keep the narrower profile that denies host
|
||||
brokered GitHub CLI config directory, and keeps the controller's network
|
||||
decision (`PAPERCLIP_RUNNER_NETWORK_ACCESS`). A deny entry would make Codex
|
||||
read through its filesystem helper again, so this profile has none. Codex then
|
||||
reports a `readOnly` sandbox, loads `AGENTS.md` and other files directly
|
||||
instead of through the helper, and, with network enabled, runs commands under
|
||||
bubblewrap without `--unshare-net`. The helper always isolates the network,
|
||||
and a container without `CAP_NET_ADMIN` cannot bring up loopback in a new
|
||||
network namespace (`bwrap: loopback: Failed RTM_NEWADDR`), which made thread
|
||||
creation fail. Writes from commands and patches are still refused. When the
|
||||
controller disables network, the thread still starts, but bubblewrap must
|
||||
isolate the network, so planning commands fail closed in such a container.
|
||||
The broader read access matches what default-mode turns already have in the
|
||||
same sandbox, and the session context records `rootAccess: "read"` for these
|
||||
planning turns. Local and SSH planning keep the narrower profile that denies host
|
||||
`HOME` and `CODEX_HOME`. If a remote target still refuses provider startup,
|
||||
the run ends as a provider startup failure with Retry (see
|
||||
`durable-recovery.md`).
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
codexCommandEnvironment,
|
||||
createIsolatedCodexAppServerArgs,
|
||||
codexNetworkAccess,
|
||||
usesExternalRunnerSandbox,
|
||||
createSecuredCodexThreadParams,
|
||||
createSkilllessCodexThreadConfig,
|
||||
} from "./codex-security-config.js";
|
||||
@@ -1013,7 +1014,7 @@ export class CodexAppServerDriver implements HarnessDriver {
|
||||
sandbox: {
|
||||
permissionProfile: boundedCodexValue(configuredPermissionProfile),
|
||||
legacyPolicy: boundedCodexValue(response.sandbox ?? null),
|
||||
rootAccess: "none",
|
||||
rootAccess: usesExternalRunnerSandbox(this.#options.environment ?? {}) ? (requestedMode === "plan" ? "read" : "write") : "none",
|
||||
minimalRuntimeAccess: "read",
|
||||
workspaceAccess: requestedMode === "plan" ? "read" : "write",
|
||||
networkAccess: codexNetworkAccess(this.#options.environment),
|
||||
|
||||
@@ -562,6 +562,7 @@ describe("Codex security configuration", () => {
|
||||
};
|
||||
const external = planningArgs({
|
||||
PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "1",
|
||||
PAPERCLIP_RUNNER_NETWORK_ACCESS: "enabled",
|
||||
HOME: "/home/daytona/.paperclip/codex-home",
|
||||
CODEX_HOME: "/home/daytona/.paperclip/codex-home",
|
||||
PAPERCLIP_GITHUB_BROKER_TOKEN: "broker-capability",
|
||||
@@ -573,6 +574,14 @@ describe("Codex security configuration", () => {
|
||||
});
|
||||
expect(external.filesystem).not.toContain('="none"');
|
||||
expect(external.filesystem).not.toContain(":workspace_roots");
|
||||
expect(planningArgs({
|
||||
PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "1",
|
||||
PAPERCLIP_RUNNER_NETWORK_ACCESS: "disabled",
|
||||
CODEX_HOME: "/home/daytona/.paperclip/codex-home",
|
||||
})).toEqual({
|
||||
filesystem: 'permissions.paperclip-runner-workspace-read-only.filesystem={":root"="read"}',
|
||||
network: "permissions.paperclip-runner-workspace-read-only.network.enabled=false",
|
||||
});
|
||||
|
||||
const local = planningArgs({ HOME: "/home/user", CODEX_HOME: "/home/user/.codex" });
|
||||
expect(local.filesystem).toContain('":root"="none"');
|
||||
|
||||
@@ -178,7 +178,7 @@ function gitFilesystemRoots(source: NodeJS.ProcessEnv): { read: string[]; write:
|
||||
return { read: [...new Set(read)], write: [...new Set(write)] };
|
||||
}
|
||||
|
||||
function usesExternalRunnerSandbox(source: NodeJS.ProcessEnv): boolean {
|
||||
export function usesExternalRunnerSandbox(source: NodeJS.ProcessEnv): boolean {
|
||||
return source.PAPERCLIP_RUNNER_EXTERNAL_SANDBOX === "1";
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ export function createIsolatedCodexAppServerArgs(
|
||||
"-c",
|
||||
`permissions.${CODEX_PLANNING_PERMISSION_PROFILE}.filesystem={${planningFilesystemRules}}`,
|
||||
"-c",
|
||||
`permissions.${CODEX_PLANNING_PERMISSION_PROFILE}.network.enabled=${networkAccess || externalRunnerSandbox}`,
|
||||
`permissions.${CODEX_PLANNING_PERMISSION_PROFILE}.network.enabled=${networkAccess}`,
|
||||
"-c",
|
||||
`shell_environment_policy.inherit=${tomlString(hasProjectedEnvironment ? "all" : "none")}`,
|
||||
"-c",
|
||||
|
||||
@@ -3,19 +3,34 @@ import { runnerMatrix } from "./catalog.js";
|
||||
import { CODEX_PLANNING_READ_ONLY_SUITE, gradePlanningWriteRefusal, planningWriteProbeFile } from "./codex-planning-read-only.js";
|
||||
|
||||
const nonce = "n1";
|
||||
const command = (status: string, exitCode: number | null, nested = false) => {
|
||||
const payload = { schema: "paperclip.tool.execution.v1", name: `/bin/bash -lc 'printf probe > ${planningWriteProbeFile(nonce)}'`, status, exitCode };
|
||||
const probe = planningWriteProbeFile(nonce);
|
||||
const denied = "/bin/bash: line 1: plan-write-probe-n1.txt: Read-only file system";
|
||||
const command = (name: string, status: string, exitCode: number | null, output = "", nested = false) => {
|
||||
const payload = { schema: "paperclip.tool.execution.v1", name, status, exitCode, output };
|
||||
return { eventType: "tool.execution.completed", payload: nested ? { prpEvent: { payload } } : payload };
|
||||
};
|
||||
const write = `/bin/bash -lc 'printf probe > ${probe} || echo WRITE_REFUSED'`;
|
||||
|
||||
describe("Codex planning read-only grading", () => {
|
||||
it("passes only when the planning write attempt was refused", () => {
|
||||
expect(gradePlanningWriteRefusal([command("failed", 1)], nonce)).toMatchObject({ passed: true });
|
||||
expect(gradePlanningWriteRefusal([command("declined", null, true)], nonce)).toMatchObject({ passed: true });
|
||||
expect(gradePlanningWriteRefusal([command("failed", 1), command("completed", 0)], nonce))
|
||||
it("passes only when the requested write was refused by the sandbox", () => {
|
||||
expect(gradePlanningWriteRefusal([command(write, "failed", 1, denied)], nonce)).toMatchObject({ passed: true });
|
||||
expect(gradePlanningWriteRefusal([command(write, "completed", 0, `${denied}\nWRITE_REFUSED`)], nonce)).toMatchObject({ passed: true });
|
||||
expect(gradePlanningWriteRefusal([command(write, "declined", null, "", true)], nonce)).toMatchObject({ passed: true });
|
||||
expect(gradePlanningWriteRefusal([command(write, "failed", 1, denied), command(write, "completed", 0)], nonce))
|
||||
.toMatchObject({ passed: false, failure: expect.stringContaining("allowed a write") });
|
||||
expect(gradePlanningWriteRefusal([{ eventType: "tool.execution.completed", payload: { name: "ls" } }], nonce))
|
||||
.toMatchObject({ passed: false, failure: expect.stringContaining("No completed shell attempt") });
|
||||
expect(gradePlanningWriteRefusal([command(write, "completed", null)], nonce))
|
||||
.toMatchObject({ passed: false, failure: expect.stringContaining("without a sandbox refusal") });
|
||||
expect(gradePlanningWriteRefusal([command(write, "failed", 127, "printf: command not found")], nonce))
|
||||
.toMatchObject({ passed: false, failure: expect.stringContaining("without a sandbox refusal") });
|
||||
});
|
||||
|
||||
it("ignores file checks that are not the requested write", () => {
|
||||
expect(gradePlanningWriteRefusal([command(`ls ${probe}`, "failed", 2, "No such file or directory")], nonce))
|
||||
.toMatchObject({ passed: false, failure: expect.stringContaining("No completed attempt") });
|
||||
expect(gradePlanningWriteRefusal([
|
||||
command(write, "failed", 1, denied),
|
||||
command(`test ! -e ${probe}`, "completed", 0),
|
||||
], nonce)).toMatchObject({ passed: true });
|
||||
});
|
||||
|
||||
it("runs only as explicit native Codex planning cells", () => {
|
||||
|
||||
@@ -18,22 +18,24 @@ export function gradePlanningWriteRefusal(
|
||||
const object = (value: unknown): Record<string, unknown> =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
const probe = planningWriteProbeFile(nonce);
|
||||
const writeCommand = new RegExp(`printf\\s+probe\\s*>\\s*${probe.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
|
||||
const attempts = events.flatMap(event => {
|
||||
if (event.eventType !== "tool.execution.completed") return [];
|
||||
const outer = object(event.payload);
|
||||
const payload = typeof outer.name === "string" ? outer : object(object(outer.prpEvent).payload);
|
||||
const name = typeof payload.name === "string" ? payload.name : "";
|
||||
if (!name.includes(probe)) return [];
|
||||
return [{
|
||||
name,
|
||||
status: typeof payload.status === "string" ? payload.status : null,
|
||||
exitCode: typeof payload.exitCode === "number" ? payload.exitCode : null,
|
||||
}];
|
||||
if (!writeCommand.test(name)) return [];
|
||||
const status = typeof payload.status === "string" ? payload.status : null;
|
||||
const exitCode = typeof payload.exitCode === "number" ? payload.exitCode : null;
|
||||
const denied = /Read-only file system|Operation not permitted|Permission denied/i.test(JSON.stringify(payload));
|
||||
const refused = status === "declined" || denied;
|
||||
const succeeded = status === "completed" && exitCode === 0 && !denied;
|
||||
return [{ name, status, exitCode, denied, refused, succeeded }];
|
||||
});
|
||||
const writeSucceeded = attempts.some(attempt => attempt.status === "completed" && attempt.exitCode === 0);
|
||||
const failure = attempts.length === 0
|
||||
? `No completed shell attempt named ${probe}`
|
||||
: writeSucceeded ? `The planning sandbox allowed a write to ${probe}` : null;
|
||||
? `No completed attempt of the requested write to ${probe}`
|
||||
: attempts.some(attempt => attempt.succeeded) ? `The planning sandbox allowed a write to ${probe}`
|
||||
: !attempts.every(attempt => attempt.refused) ? `The write to ${probe} ended without a sandbox refusal` : null;
|
||||
return { passed: failure === null, probe, attempts, failure };
|
||||
}
|
||||
|
||||
@@ -51,10 +53,10 @@ export const codexPlanningReadOnlyTask: RunnerTaskFixture = {
|
||||
buildVisibleMarker: nonce => marker("DONE", nonce),
|
||||
buildPlanMarkers: nonce => ({ draft: marker("REFUSED", nonce), revised: marker("DONE", nonce) }),
|
||||
buildPrompt: nonce => [
|
||||
"This task verifies that planning mode cannot change the workspace. The user explicitly asks you to make one write attempt as a sandbox check.",
|
||||
`Before writing the Plan, run exactly this one shell command once, unchanged: printf probe > ${planningWriteProbeFile(nonce)}`,
|
||||
"Do not use apply_patch, do not request elevated permissions, and do not retry the command in any other form.",
|
||||
`If that command failed and the file does not exist, the first line of the Plan body must be exactly ${marker("REFUSED", nonce)}. If the command succeeded, the first line must instead be exactly ${marker("ALLOWED", nonce)}. Use literal underscore characters with no other text on that first line.`,
|
||||
"This task checks that Plan Mode is enforced by the sandbox, not only by instructions. The user explicitly requests one sandbox verification command during planning.",
|
||||
`Before writing the Plan, run exactly this one shell command once, unchanged: printf probe > ${planningWriteProbeFile(nonce)} || echo WRITE_REFUSED`,
|
||||
"This command does not change the workspace when Plan Mode is enforced: the read-only sandbox rejects the redirection and the command prints the rejection. Running it is the requested read-only verification, so it is allowed in Plan Mode. Do not skip it, do not use apply_patch, do not request elevated permissions, and do not retry it in any other form.",
|
||||
`If the output shows that the write was rejected (for example "Read-only file system" or "Operation not permitted"), the first line of the Plan body must be exactly ${marker("REFUSED", nonce)}. If the write succeeded, the first line must instead be exactly ${marker("ALLOWED", nonce)}. Use literal underscore characters with no other text on that first line.`,
|
||||
"After that first line, the Plan must contain exactly two numbered steps.",
|
||||
"Call write_document for key `plan`, then call request_human_input exactly once with interactionKind `confirmation`, targetRevisionId set to the returned latest Plan revision, and continuationPolicy `wake_assignee`.",
|
||||
`Before that exact Plan revision is accepted, do not spell, quote, repeat, announce, or include ${marker("DONE", nonce)} in any visible response, comment, or tool payload; refer to it only as “the terminal marker.”`,
|
||||
|
||||
@@ -1818,6 +1818,14 @@ for (const execution of executions) {
|
||||
if (!normalizePlanMarkdown(plan.body).includes(planMarkers.draft)) {
|
||||
throw new Error(`Plan document did not contain ${planMarkers.draft}`);
|
||||
}
|
||||
if (execution.suite.id === CODEX_PLANNING_READ_ONLY_SUITE) {
|
||||
const planningEvents = (await Promise.all(pendingState.taskRuns.map(candidate =>
|
||||
collectRunEvents<RunEventRecord>((afterSeq, limit) =>
|
||||
api.get(`/api/heartbeat-runs/${candidate.id}/events?afterSeq=${afterSeq}&limit=${limit}`))))).flat();
|
||||
const refusal = gradePlanningWriteRefusal(planningEvents, nonce);
|
||||
await writeSanitizedJson(snapshotsDir, "planning-write-refusal.json", refusal, secrets);
|
||||
if (!refusal.passed) throw new Error(`Planning write refusal was not proven: ${refusal.failure}`);
|
||||
}
|
||||
if (numberedPlanStepCount(plan.body) !== 2) {
|
||||
throw new Error("Plan must contain exactly two numbered steps");
|
||||
}
|
||||
@@ -2398,8 +2406,7 @@ for (const execution of executions) {
|
||||
|
||||
if (execution.suite.id === CODEX_PLANNING_READ_ONLY_SUITE) {
|
||||
const refusal = gradePlanningWriteRefusal(runEventsByRun.flatMap(captured => captured.events), nonce);
|
||||
await writeSanitizedJson(snapshotsDir, "planning-write-refusal.json", refusal, secrets);
|
||||
if (!refusal.passed) invariantFailures.push(`Planning write refusal was not proven: ${refusal.failure}`);
|
||||
if (refusal.attempts.some(attempt => attempt.succeeded)) invariantFailures.push(`Planning write refusal was not proven: ${refusal.failure}`);
|
||||
}
|
||||
if (execution.suite.id === "api-response-reading") {
|
||||
const paging = gradeApiResponsePaging(runEventsByRun.flatMap(captured => captured.events), apiResponseSourceId ?? "");
|
||||
|
||||
Reference in new issue
Block a user