From 98d93606583700ddd52629900dab9d09de7fadfe Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:01:19 -0500 Subject: [PATCH] feat(adapters): confine local coding processes (#9504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source control plane people use to manage AI agents for work. > - Local coding adapters currently spawn their CLI processes directly on the Paperclip host. > - CLI-native approval and sandbox flags do not provide a reliable host filesystem or network boundary. > - An agent can therefore inspect unrelated host files or fetch external material when an operator needs stronger isolation. > - The confinement must stay opt-in so existing local adapter behavior does not change unexpectedly. > - This pull request adds a shared Linux Bubblewrap spawn layer for workspace filesystem and deny/allowlist network scopes. > - The benefit is enforceable defense in depth around Codex and Claude local runs while preserving explicit provider connectivity. ## Linked Issues or Issue Description ### What happened? `codex_local` and `claude_local` processes could read arbitrary host paths and make unrestricted outbound network requests because Paperclip did not impose a spawn-level boundary. ### Expected behavior Operators can opt into a workspace-only filesystem view and either deny network egress or allow exact provider/API hosts, independently of CLI approval flags. ### Steps to reproduce 1. Run current `master` on Linux and configure a Codex or Claude local adapter. 2. Ask the agent to read a canary file outside its active workspace. 3. Ask the agent to `curl` a public host. 4. Observe that both operations succeed without a Paperclip-level confinement option. ### Environment - Paperclip commit: `c36f1a4af` / current `master` base. - Deployment mode: Linux local dev or self-hosted server. - Installation: built from source. - Adapters: Codex and Claude Code. - Database: not related. ## What Changed - Added a shared Bubblewrap process wrapper with opt-in `filesystemScope: "workspace"`, managed/extra path mounts, private `/tmp`, and Linux-only validation. - Added `networkScope: "deny" | "allowlist"`; both use a private network namespace, while allowlist mode exposes an exact-host HTTP(S) proxy over a Unix-socket bridge. - Wired Codex and Claude local CLI execution through the wrapper and forced scoped auto runs onto the CLI lane because ACP processes are not covered. - Added unit and gated Bubblewrap canaries for outside-file denial, workspace writes, direct network denial, allowlisted forwarding, and rejected destinations. - Documented both scopes, provider allowlist examples, Bubblewrap requirements, and default-off behavior. ## Verification - `pnpm exec vitest run packages/adapter-utils/src/local-process-sandbox.test.ts packages/adapters/codex-local/src/server/acp.test.ts packages/adapters/claude-local/src/server/acp.test.ts` — 34 passed, 4 gated Bubblewrap tests skipped by default. - `pnpm --filter @paperclipai/adapter-utils typecheck` - `pnpm --filter @paperclipai/adapter-codex-local typecheck` - `pnpm --filter @paperclipai/adapter-claude-local typecheck` - `pnpm --filter @paperclipai/adapter-utils build` - `pnpm --filter @paperclipai/adapter-codex-local build` - `pnpm --filter @paperclipai/adapter-claude-local build` - Attempted the gated tests with a vendored Bubblewrap binary; this container blocks unprivileged namespace setup (`setting up uid map: Permission denied` / loopback `RTM_NEWADDR: Operation not permitted`), so kernel-level execution remains for CI or a namespace-enabled Linux host. ## Risks - Bubblewrap must be installed and unprivileged user/mount/network namespaces must be enabled on the host; scoped runs fail clearly if the prerequisite is missing. - Allowlist mode depends on the coding CLI honoring standard `HTTP_PROXY` / `HTTPS_PROXY` variables; custom providers must list every required exact hostname and port. - Exact-host allowlists intentionally reject wildcards, which is safer but may require operators to enumerate multi-host provider setups. - No behavior changes unless an operator enables `filesystemScope` or `networkScope`. > This aligns with the ROADMAP direction toward safer remote and sandboxed agent environments and does not duplicate an open PR or issue found in the repository search. ## Model Used - OpenAI GPT-5.5 (`gpt-5.5`) via Codex CLI, with reasoning, repository tool use, shell execution, code editing, and test execution. The serving context-window size is not exposed to the agent. ## 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 --------- Co-authored-by: Paperclip --- doc/spec/agent-runs.md | 18 + .../adapter-utils/src/execution-target.ts | 3 + .../src/local-process-sandbox.test.ts | 295 ++++++++++++++ .../src/local-process-sandbox.ts | 383 ++++++++++++++++++ packages/adapter-utils/src/server-utils.ts | 37 +- packages/adapters/claude-local/src/index.ts | 6 + .../claude-local/src/server/acp.test.ts | 35 ++ .../adapters/claude-local/src/server/acp.ts | 18 + .../claude-local/src/server/execute.ts | 38 ++ packages/adapters/codex-local/src/index.ts | 6 + .../codex-local/src/server/acp.test.ts | 35 ++ .../adapters/codex-local/src/server/acp.ts | 18 + .../codex-local/src/server/execute.ts | 32 ++ 13 files changed, 923 insertions(+), 1 deletion(-) create mode 100644 packages/adapter-utils/src/local-process-sandbox.test.ts create mode 100644 packages/adapter-utils/src/local-process-sandbox.ts diff --git a/doc/spec/agent-runs.md b/doc/spec/agent-runs.md index ab98996c32..ed0ced2548 100644 --- a/doc/spec/agent-runs.md +++ b/doc/spec/agent-runs.md @@ -251,6 +251,13 @@ Runs local `claude` CLI directly. "model": "optional-model-id", "maxTurnsPerRun": 1000, "dangerouslySkipPermissions": true, + "filesystemScope": "workspace", + "filesystemExtraPaths": [ + "/opt/toolchains", + {"path": "/var/cache/pnpm", "access": "rw"} + ], + "networkScope": "allowlist", + "networkAllowlist": ["api.anthropic.com"], "env": {"KEY": "VALUE"}, "extraArgs": [], "timeoutSec": 1800, @@ -288,6 +295,13 @@ Runs local `codex` CLI directly. "model": "optional-model-id", "search": false, "dangerouslyBypassApprovalsAndSandbox": true, + "filesystemScope": "workspace", + "filesystemExtraPaths": [ + "/opt/toolchains", + {"path": "/var/cache/pnpm", "access": "rw"} + ], + "networkScope": "allowlist", + "networkAllowlist": ["api.openai.com"], "env": {"KEY": "VALUE"}, "extraArgs": [], "timeoutSec": 1800, @@ -302,6 +316,10 @@ Runs local `codex` CLI directly. - Unsandboxed mode: add `--dangerously-bypass-approvals-and-sandbox` when enabled - Optional search mode: add `--search` +For Linux local coding adapters, `filesystemScope: "workspace"` adds host-level filesystem confinement around the CLI process. It is disabled by default and independent of the CLI's own approval or sandbox flags. Paperclip uses Bubblewrap to expose the active workspace and adapter-managed config/home, creates a private `/tmp`, and hides other host paths. `filesystemExtraPaths` can expose additional absolute paths read-only (string or `access: "ro"`) or writable (`access: "rw"`). + +`networkScope` is also disabled by default and can be enabled independently or together with filesystem confinement. `"deny"` creates a private network namespace with no egress. `"allowlist"` keeps direct sockets blocked and injects an HTTP(S) proxy that accepts only exact `networkAllowlist` hostnames (optionally with a port); list the coding provider endpoint and every other required API origin explicitly. For example, a standard Codex API-key setup normally needs `api.openai.com`, while Claude setups may need `api.anthropic.com` or their configured Bedrock, Vertex, or gateway origins. Wildcards are intentionally unsupported. Install `bwrap` on the Paperclip host before enabling either scope. Auto engine selection uses the CLI lane while a scope is enabled; explicit ACP mode is rejected because ACP processes are not yet covered by the spawn wrapper. + ### Output parsing Codex emits JSONL events. Parse line-by-line and extract: diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index e264ab7698..bd56cafbe9 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -38,6 +38,7 @@ import { import { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js"; import { preferredShellForSandbox, shellCommandArgs } from "./sandbox-shell.js"; import type { RuntimeProgressSink, RuntimeStatusSink } from "./runtime-progress.js"; +import type { LocalProcessSandboxOptions } from "./local-process-sandbox.js"; export type { RuntimeProgressSink } from "./runtime-progress.js"; @@ -108,6 +109,7 @@ export interface AdapterExecutionTargetProcessOptions { * onLog is suppressed and incremental chunks flow through `onLog` instead. */ runLogTail?: SandboxRunLogTailFactory | null; + localProcessSandbox?: LocalProcessSandboxOptions | null; } export interface AdapterExecutionTargetShellOptions { @@ -583,6 +585,7 @@ export async function runAdapterExecutionTargetProcess( onLog: options.onLog, onSpawn: options.onSpawn, terminalResultCleanup: options.terminalResultCleanup, + localProcessSandbox: target?.kind === "local" || !target ? options.localProcessSandbox : null, remoteExecution: adapterExecutionTargetToRemoteSpec(target), }); } diff --git a/packages/adapter-utils/src/local-process-sandbox.test.ts b/packages/adapter-utils/src/local-process-sandbox.test.ts new file mode 100644 index 0000000000..0347529165 --- /dev/null +++ b/packages/adapter-utils/src/local-process-sandbox.test.ts @@ -0,0 +1,295 @@ +import fs from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildLocalProcessSandboxSpawnTarget, + parseLocalProcessFilesystemScope, + parseLocalProcessNetworkAllowlist, + parseLocalProcessNetworkScope, + parseLocalProcessSandboxExtraPaths, +} from "./local-process-sandbox.js"; +import { runChildProcess } from "./server-utils.js"; + +const cleanup: string[] = []; + +afterEach(async () => { + await Promise.all(cleanup.splice(0).map((candidate) => fs.rm(candidate, { recursive: true, force: true }))); +}); + +describe("local process sandbox", () => { + it("parses read-only and writable extra paths", () => { + expect(parseLocalProcessSandboxExtraPaths(["/opt/cache", { path: "/var/lib/tool", access: "rw" }])).toEqual([ + { path: "/opt/cache", access: "ro" }, + { path: "/var/lib/tool", access: "rw" }, + ]); + expect(() => parseLocalProcessSandboxExtraPaths(["relative"])).toThrow("must be an absolute path"); + }); + + it("parses network scopes and exact-host allowlists", () => { + expect(parseLocalProcessFilesystemScope("workspace")).toBe("workspace"); + expect(parseLocalProcessFilesystemScope(undefined)).toBeNull(); + expect(() => parseLocalProcessFilesystemScope("workpace")).toThrow('filesystemScope must be "workspace"'); + expect(parseLocalProcessNetworkScope("deny")).toBe("deny"); + expect(parseLocalProcessNetworkScope("allowlist")).toBe("allowlist"); + expect(parseLocalProcessNetworkScope(undefined)).toBeNull(); + expect(parseLocalProcessNetworkAllowlist(["api.openai.com", "https://api.anthropic.com", "gateway.test:8443"])) + .toEqual(["api.openai.com", "api.anthropic.com", "gateway.test:8443"]); + expect(() => parseLocalProcessNetworkAllowlist(["*.example.com"])).toThrow("exact hostname"); + expect(() => parseLocalProcessNetworkScope("public")).toThrow('"deny" or "allowlist"'); + }); + + it("builds a fresh-root bubblewrap command with workspace access", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fs-sandbox-")); + cleanup.push(root); + const workspace = path.join(root, "workspace"); + const managedHome = path.join(root, "managed-home"); + await fs.mkdir(workspace); + await fs.mkdir(managedHome); + + const target = await buildLocalProcessSandboxSpawnTarget({ + executable: process.execPath, + args: ["-e", "console.log('ok')"], + cwd: workspace, + options: { + workspaceDir: workspace, + filesystemScope: "workspace", + managedPaths: [{ path: managedHome, access: "rw" }], + homeDir: managedHome, + }, + }); + + expect(target.command).toBe("bwrap"); + expect(target.args).toContain("--tmpfs"); + expect(target.args).toContain(workspace); + expect(target.args).toContain(managedHome); + expect(target.args.slice(-3)).toEqual([process.execPath, "-e", "console.log('ok')"]); + }); + + it("builds a network-only namespace without changing filesystem visibility", async () => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-sandbox-")); + cleanup.push(workspace); + const target = await buildLocalProcessSandboxSpawnTarget({ + executable: process.execPath, + args: ["-e", "console.log('ok')"], + cwd: workspace, + options: { workspaceDir: workspace, networkScope: "deny" }, + }); + + expect(target.args).toContain("--unshare-net"); + expect(target.args).toContain("--bind"); + expect(target.args).not.toContain("--tmpfs"); + expect(target.env?.HTTP_PROXY).toBeUndefined(); + }); + + it("forwards allowed proxy targets and rejects other hosts", async () => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-proxy-")); + cleanup.push(workspace); + const server = http.createServer((_request, response) => response.end("allowed-response")); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP test server address."); + const target = await buildLocalProcessSandboxSpawnTarget({ + executable: process.execPath, + args: ["-e", "process.exit(0)"], + cwd: workspace, + options: { + workspaceDir: workspace, + networkScope: "allowlist", + networkAllowlist: [`127.0.0.1:${address.port}`], + }, + }); + const delimiterIndex = target.args.indexOf("--"); + const socketPath = target.args[delimiterIndex + 3]; + const request = (url: string) => new Promise<{ status: number; body: string }>((resolve, reject) => { + const outgoing = http.request({ socketPath, path: url, headers: { host: new URL(url).host } }, (response) => { + let body = ""; + response.on("data", (chunk) => { + body += chunk; + }); + response.on("end", () => resolve({ status: response.statusCode ?? 0, body })); + }); + outgoing.on("error", reject); + outgoing.end(); + }); + + try { + await expect(request(`http://127.0.0.1:${address.port}/canary`)).resolves.toEqual({ + status: 200, + body: "allowed-response", + }); + await expect(request("http://example.com/")).resolves.toEqual({ + status: 403, + body: "Network target denied by Paperclip sandbox policy.\n", + }); + } finally { + await target.cleanup?.(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it("fails clearly when Bubblewrap is unavailable", async () => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fs-sandbox-missing-")); + cleanup.push(workspace); + await expect( + runChildProcess("filesystem-sandbox-missing", process.execPath, ["-e", "process.exit(0)"], { + cwd: workspace, + env: {}, + timeoutSec: 10, + graceSec: 1, + onLog: async () => {}, + localProcessSandbox: { + workspaceDir: workspace, + filesystemScope: "workspace", + command: path.join(workspace, "missing-bwrap"), + }, + }), + ).rejects.toThrow("requires Bubblewrap"); + }); + + it.runIf(Boolean(process.env.PAPERCLIP_TEST_BWRAP))( + "prevents reads outside the workspace while allowing workspace writes", + async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fs-sandbox-integration-")); + cleanup.push(root); + const workspace = path.join(root, "workspace"); + const outside = path.join(root, "canary.txt"); + const allowed = path.join(root, "allowed.txt"); + await fs.mkdir(workspace); + await fs.writeFile(outside, "host-secret", "utf8"); + await fs.writeFile(allowed, "allowed-value", "utf8"); + + const script = [ + "const fs = require('node:fs');", + `try { fs.readFileSync(${JSON.stringify(outside)}, 'utf8'); process.exit(9); } catch (error) {`, + " if (!['ENOENT', 'EACCES'].includes(error.code)) throw error;", + "}", + `if (fs.readFileSync(${JSON.stringify(allowed)}, 'utf8') !== 'allowed-value') process.exit(8);`, + "fs.writeFileSync('workspace-ok.txt', 'ok');", + ].join("\n"); + const result = await runChildProcess("filesystem-sandbox-test", process.execPath, ["-e", script], { + cwd: workspace, + env: {}, + timeoutSec: 10, + graceSec: 1, + onLog: async () => {}, + localProcessSandbox: { + workspaceDir: workspace, + filesystemScope: "workspace", + extraPaths: [{ path: allowed, access: "ro" }], + command: process.env.PAPERCLIP_TEST_BWRAP, + }, + }); + + expect(result.exitCode, result.stderr).toBe(0); + await expect(fs.readFile(path.join(workspace, "workspace-ok.txt"), "utf8")).resolves.toBe("ok"); + }, + ); + + it.runIf(Boolean(process.env.PAPERCLIP_TEST_BWRAP && process.env.PAPERCLIP_TEST_SANDBOX_BUILD))( + "runs the adapter-utils TypeScript build inside the confined workspace", + async () => { + const workspace = process.cwd(); + const result = await runChildProcess( + "filesystem-sandbox-build-test", + path.join(workspace, "node_modules", ".bin", "tsc"), + ["--noEmit", "-p", "packages/adapter-utils/tsconfig.json"], + { + cwd: workspace, + env: {}, + timeoutSec: 60, + graceSec: 2, + onLog: async () => {}, + localProcessSandbox: { + workspaceDir: workspace, + filesystemScope: "workspace", + command: process.env.PAPERCLIP_TEST_BWRAP, + }, + }, + ); + + expect(result.exitCode, result.stderr).toBe(0); + }, + ); + + it.runIf(Boolean(process.env.PAPERCLIP_TEST_BWRAP))( + "denies direct network egress", + async () => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-deny-")); + cleanup.push(workspace); + const server = http.createServer((_request, response) => response.end("host-network")); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP test server address."); + const script = `require("node:http").get("http://127.0.0.1:${address.port}", () => process.exit(9)).on("error", () => process.exit(0));`; + try { + const result = await runChildProcess("network-sandbox-deny-test", process.execPath, ["-e", script], { + cwd: workspace, + env: {}, + timeoutSec: 10, + graceSec: 1, + onLog: async () => {}, + localProcessSandbox: { + workspaceDir: workspace, + networkScope: "deny", + command: process.env.PAPERCLIP_TEST_BWRAP, + }, + }); + expect(result.exitCode, result.stderr).toBe(0); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }, + ); + + it.runIf(Boolean(process.env.PAPERCLIP_TEST_BWRAP))( + "allows only configured network targets through the proxy bridge", + async () => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-allowlist-")); + cleanup.push(workspace); + const server = http.createServer((_request, response) => response.end("allowed-response")); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP test server address."); + const targetUrl = `http://127.0.0.1:${address.port}/canary`; + const deniedUrl = "http://example.com/"; + const script = ` +const http = require("node:http"); +const proxy = new URL(process.env.HTTP_PROXY); +function request(url) { + return new Promise((resolve, reject) => { + http.get({ hostname: proxy.hostname, port: proxy.port, path: url }, (response) => { + let body = ""; + response.on("data", (chunk) => body += chunk); + response.on("end", () => resolve({ status: response.statusCode, body })); + }).on("error", reject); + }); +} +(async () => { + const allowed = await request(${JSON.stringify(targetUrl)}); + const denied = await request(${JSON.stringify(deniedUrl)}); + if (allowed.status !== 200 || allowed.body !== "allowed-response" || denied.status !== 403) process.exit(8); +})().catch((error) => { console.error(error); process.exit(7); }); +`; + try { + const result = await runChildProcess("network-sandbox-allowlist-test", process.execPath, ["-e", script], { + cwd: workspace, + env: {}, + timeoutSec: 10, + graceSec: 1, + onLog: async () => {}, + localProcessSandbox: { + workspaceDir: workspace, + networkScope: "allowlist", + networkAllowlist: [`127.0.0.1:${address.port}`], + command: process.env.PAPERCLIP_TEST_BWRAP, + }, + }); + expect(result.exitCode, result.stderr).toBe(0); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }, + ); +}); diff --git a/packages/adapter-utils/src/local-process-sandbox.ts b/packages/adapter-utils/src/local-process-sandbox.ts new file mode 100644 index 0000000000..d3ad7a9ac9 --- /dev/null +++ b/packages/adapter-utils/src/local-process-sandbox.ts @@ -0,0 +1,383 @@ +import fs from "node:fs/promises"; +import http from "node:http"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +export type LocalProcessSandboxAccess = "ro" | "rw"; +export type LocalProcessNetworkScope = "deny" | "allowlist"; + +export interface LocalProcessSandboxPath { + path: string; + access: LocalProcessSandboxAccess; +} + +export interface LocalProcessSandboxOptions { + workspaceDir: string; + filesystemScope?: "workspace" | null; + managedPaths?: LocalProcessSandboxPath[]; + extraPaths?: LocalProcessSandboxPath[]; + homeDir?: string | null; + networkScope?: LocalProcessNetworkScope | null; + networkAllowlist?: string[]; + command?: string; +} + +export interface LocalProcessSandboxSpawnTarget { + command: string; + args: string[]; + cwd: string; + env?: Record; + cleanup?: () => Promise; +} + +interface NetworkAllowlistRule { + hostname: string; + port: string | null; +} + +interface NetworkAllowlistProxy { + close: () => Promise; +} + +const SYSTEM_READ_PATHS = [ + "/bin", + "/sbin", + "/usr", + "/lib", + "/lib64", + "/etc/ca-certificates", + "/etc/ssl", + "/etc/resolv.conf", + "/etc/hosts", + "/etc/nsswitch.conf", + "/etc/passwd", + "/etc/group", + "/etc/localtime", + "/etc/timezone", + "/etc/gitconfig", +] as const; + +const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"] as const; +const SANDBOX_PROXY_PORT = 31_337; + +function normalizeAbsolutePath(candidate: string, label: string): string { + const trimmed = candidate.trim(); + if (!trimmed || !path.isAbsolute(trimmed)) { + throw new Error(`${label} must be an absolute path.`); + } + return path.resolve(trimmed); +} + +async function pathExists(candidate: string): Promise { + return fs.lstat(candidate).then(() => true).catch(() => false); +} + +function parentDirectories(candidate: string): string[] { + const directories: string[] = []; + let current = path.dirname(candidate); + while (current !== path.dirname(current)) { + directories.push(current); + current = path.dirname(current); + } + return directories.reverse(); +} + +function addParentDirectories(args: string[], created: Set, candidate: string): void { + for (const directory of parentDirectories(candidate)) { + if (created.has(directory)) continue; + args.push("--dir", directory); + created.add(directory); + } +} + +async function nearestPackageRoot(candidate: string): Promise { + let current = path.dirname(candidate); + while (current !== path.dirname(current)) { + if (await pathExists(path.join(current, "package.json"))) return current; + current = path.dirname(current); + } + return path.dirname(candidate); +} + +async function executableReadPaths(command: string): Promise { + const paths = new Set(); + paths.add(path.dirname(command)); + const realCommand = await fs.realpath(command).catch(() => command); + paths.add(await nearestPackageRoot(realCommand)); + return Array.from(paths); +} + +function parseNetworkAllowlistEntry(entry: string, index: number): NetworkAllowlistRule { + const trimmed = entry.trim(); + if (!trimmed) throw new Error(`networkAllowlist[${index}] must not be empty.`); + let hostname: string; + let port: string | null; + try { + const parsed = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`); + if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) { + throw new Error("path"); + } + hostname = parsed.hostname.toLowerCase(); + port = parsed.port || null; + } catch { + throw new Error(`networkAllowlist[${index}] must be a hostname, hostname:port, or origin URL.`); + } + if (!hostname || hostname === "*" || hostname.startsWith("*.")) { + throw new Error(`networkAllowlist[${index}] must use an exact hostname; wildcards are not supported.`); + } + return { hostname, port }; +} + +export function parseLocalProcessNetworkAllowlist(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map((entry, index) => { + if (typeof entry !== "string") throw new Error(`networkAllowlist[${index}] must be a string.`); + const rule = parseNetworkAllowlistEntry(entry, index); + return rule.port ? `${rule.hostname}:${rule.port}` : rule.hostname; + }); +} + +export function parseLocalProcessNetworkScope(value: unknown): LocalProcessNetworkScope | null { + if (value == null || value === "") return null; + if (value === "deny" || value === "allowlist") return value; + throw new Error('networkScope must be "deny" or "allowlist".'); +} + +export function parseLocalProcessFilesystemScope(value: unknown): "workspace" | null { + if (value == null || value === "") return null; + if (value === "workspace") return value; + throw new Error('filesystemScope must be "workspace".'); +} + +function isNetworkTargetAllowed(hostname: string, port: string, rules: NetworkAllowlistRule[]): boolean { + const normalizedHostname = hostname.toLowerCase().replace(/^\[|\]$/g, ""); + return rules.some((rule) => rule.hostname === normalizedHostname && (rule.port === null || rule.port === port)); +} + +async function startNetworkAllowlistProxy(allowlist: string[], socketPath: string): Promise { + const rules = allowlist.map(parseNetworkAllowlistEntry); + if (rules.length === 0) { + throw new Error('networkScope="allowlist" requires at least one networkAllowlist hostname.'); + } + const server = http.createServer((request, response) => { + let target: URL; + try { + target = new URL(request.url ?? ""); + } catch { + response.writeHead(400).end("Paperclip sandbox proxy requires an absolute request URL.\n"); + return; + } + const port = target.port || (target.protocol === "https:" ? "443" : "80"); + if (target.protocol !== "http:") { + response.writeHead(400).end("HTTPS targets must use CONNECT through the Paperclip sandbox proxy.\n"); + return; + } + if (!isNetworkTargetAllowed(target.hostname, port, rules)) { + response.writeHead(403).end("Network target denied by Paperclip sandbox policy.\n"); + return; + } + const upstream = http.request(target, { + method: request.method, + headers: { ...request.headers, host: target.host }, + }, (upstreamResponse) => { + response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); + upstreamResponse.pipe(response); + }); + upstream.on("error", (error) => response.destroy(error)); + request.pipe(upstream); + }); + server.on("connect", (request, clientSocket, head) => { + const separator = request.url?.lastIndexOf(":") ?? -1; + const hostname = separator > 0 ? request.url!.slice(0, separator).replace(/^\[|\]$/g, "") : ""; + const port = separator > 0 ? request.url!.slice(separator + 1) : "443"; + if (!hostname || !/^\d+$/.test(port) || !isNetworkTargetAllowed(hostname, port, rules)) { + clientSocket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); + return; + } + const upstream = net.connect(Number(port), hostname, () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length > 0) upstream.write(head); + upstream.pipe(clientSocket); + clientSocket.pipe(upstream); + }); + upstream.on("error", () => clientSocket.destroy()); + clientSocket.on("close", () => upstream.destroy()); + }); + const sockets = new Set(); + server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, () => { + server.off("error", reject); + resolve(); + }); + }); + return { + close: async () => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +async function createNetworkProxyBridge(): Promise { + const source = ` +const net = require("node:net"); +const { spawn } = require("node:child_process"); +const socketPath = process.argv[2]; +const executable = process.argv[3]; +const args = process.argv.slice(4); +const server = net.createServer((client) => { + const upstream = net.connect(socketPath); + client.pipe(upstream); + upstream.pipe(client); + const close = () => { client.destroy(); upstream.destroy(); }; + client.on("error", close); + upstream.on("error", close); +}); +server.listen(${SANDBOX_PROXY_PORT}, "127.0.0.1", () => { + const child = spawn(executable, args, { stdio: "inherit", env: process.env }); + const forward = (signal) => { if (!child.killed) child.kill(signal); }; + process.on("SIGTERM", () => forward("SIGTERM")); + process.on("SIGINT", () => forward("SIGINT")); + child.on("exit", (code, signal) => server.close(() => { + if (signal) process.kill(process.pid, signal); + else process.exit(code == null ? 1 : code); + })); +}); +`; + return source.trimStart(); +} + +export async function buildLocalProcessSandboxSpawnTarget(input: { + executable: string; + args: string[]; + cwd: string; + options: LocalProcessSandboxOptions; +}): Promise { + if (process.platform !== "linux") { + throw new Error("Local process filesystem and network scopes are currently supported only on Linux."); + } + const filesystemScope = input.options.filesystemScope ?? null; + const networkScope = input.options.networkScope ?? null; + if (!filesystemScope && !networkScope) throw new Error("Local process sandbox requires a filesystem or network scope."); + + const workspaceDir = normalizeAbsolutePath(input.options.workspaceDir, "Sandbox workspaceDir"); + const cwd = normalizeAbsolutePath(input.cwd, "Sandbox cwd"); + if (filesystemScope === "workspace") { + const relativeCwd = path.relative(workspaceDir, cwd); + if (relativeCwd.startsWith("..") || path.isAbsolute(relativeCwd)) { + throw new Error(`Sandbox cwd "${cwd}" must be inside workspaceDir "${workspaceDir}".`); + } + } + + const bwrapCommand = input.options.command?.trim() || "bwrap"; + const args = ["--die-with-parent", "--new-session", "--unshare-pid", "--unshare-ipc", "--unshare-uts"]; + const env: Record = {}; + let cleanup: (() => Promise) | undefined; + let executable = input.executable; + let executableArgs = input.args; + + if (filesystemScope === "workspace") { + args.push("--tmpfs", "/", "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp"); + args.push( + "--symlink", "usr/bin", "/bin", + "--symlink", "usr/sbin", "/sbin", + "--symlink", "usr/lib", "/lib", + "--symlink", "usr/lib64", "/lib64", + ); + const created = new Set(["/", "/proc", "/dev", "/tmp"]); + const mounted = new Set(); + const mount = async (source: string, access: LocalProcessSandboxAccess) => { + const normalized = normalizeAbsolutePath(source, "Sandbox path"); + if (mounted.has(normalized) || !(await pathExists(normalized))) return; + addParentDirectories(args, created, normalized); + args.push(access === "rw" ? "--bind" : "--ro-bind", normalized, normalized); + mounted.add(normalized); + created.add(normalized); + }; + for (const systemPath of SYSTEM_READ_PATHS) await mount(systemPath, "ro"); + for (const executablePath of await executableReadPaths(input.executable)) await mount(executablePath, "ro"); + if (networkScope === "allowlist") { + for (const nodePath of await executableReadPaths(process.execPath)) await mount(nodePath, "ro"); + } + for (const managedPath of input.options.managedPaths ?? []) await mount(managedPath.path, managedPath.access); + for (const extraPath of input.options.extraPaths ?? []) await mount(extraPath.path, extraPath.access); + await mount(workspaceDir, "rw"); + + if (networkScope === "allowlist") { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-sandbox-")); + const socketPath = path.join(tempDir, "proxy.sock"); + const bridgePath = path.join(tempDir, "bridge.cjs"); + await fs.writeFile(bridgePath, await createNetworkProxyBridge(), { mode: 0o500 }); + const proxy = await startNetworkAllowlistProxy(input.options.networkAllowlist ?? [], socketPath).catch(async (error) => { + await fs.rm(tempDir, { recursive: true, force: true }); + throw error; + }); + await mount(tempDir, "rw"); + executable = process.execPath; + executableArgs = [bridgePath, socketPath, input.executable, ...input.args]; + cleanup = async () => { + await proxy.close(); + await fs.rm(tempDir, { recursive: true, force: true }); + }; + } + } else { + args.push("--bind", "/", "/"); + if (networkScope === "allowlist") { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-sandbox-")); + const socketPath = path.join(tempDir, "proxy.sock"); + const bridgePath = path.join(tempDir, "bridge.cjs"); + await fs.writeFile(bridgePath, await createNetworkProxyBridge(), { mode: 0o500 }); + const proxy = await startNetworkAllowlistProxy(input.options.networkAllowlist ?? [], socketPath).catch(async (error) => { + await fs.rm(tempDir, { recursive: true, force: true }); + throw error; + }); + executable = process.execPath; + executableArgs = [bridgePath, socketPath, input.executable, ...input.args]; + cleanup = async () => { + await proxy.close(); + await fs.rm(tempDir, { recursive: true, force: true }); + }; + } + } + + if (networkScope) { + args.push("--unshare-net"); + for (const key of PROXY_ENV_KEYS) env[key] = undefined; + env.NO_PROXY = ""; + env.no_proxy = ""; + } + if (networkScope === "allowlist") { + const proxyUrl = `http://127.0.0.1:${SANDBOX_PROXY_PORT}`; + env.HTTP_PROXY = proxyUrl; + env.HTTPS_PROXY = proxyUrl; + env.http_proxy = proxyUrl; + env.https_proxy = proxyUrl; + } + + args.push("--chdir", cwd, "--", executable, ...executableArgs); + return { command: bwrapCommand, args, cwd: "/", env, cleanup }; +} + +export function parseLocalProcessSandboxExtraPaths(value: unknown): LocalProcessSandboxPath[] { + if (!Array.isArray(value)) return []; + return value.map((entry, index) => { + if (typeof entry === "string") { + return { path: normalizeAbsolutePath(entry, `filesystemExtraPaths[${index}]`), access: "ro" }; + } + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error(`filesystemExtraPaths[${index}] must be an absolute path or { path, access } object.`); + } + const raw = entry as Record; + const access = raw.access === "rw" ? "rw" : raw.access === "ro" || raw.access == null ? "ro" : null; + if (!access || typeof raw.path !== "string") { + throw new Error(`filesystemExtraPaths[${index}] must use access "ro" or "rw" and an absolute path.`); + } + return { path: normalizeAbsolutePath(raw.path, `filesystemExtraPaths[${index}].path`), access }; + }); +} diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 5c5a9c5ee7..69c5757bdc 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -4,6 +4,10 @@ import { constants as fsConstants, promises as fs, type Dirent } from "node:fs"; import os from "node:os"; import path from "node:path"; import { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js"; +import { + buildLocalProcessSandboxSpawnTarget, + type LocalProcessSandboxOptions, +} from "./local-process-sandbox.js"; import { buildSshSpawnTarget, type SshRemoteExecutionSpec } from "./ssh.js"; import { redactCommandText } from "./command-redaction.js"; import type { @@ -51,6 +55,7 @@ interface SpawnTarget { command: string; args: string[]; cwd?: string; + env?: Record; cleanup?: () => Promise; } @@ -2056,6 +2061,7 @@ async function resolveSpawnTarget( options: { remoteExecution?: RemoteExecutionSpec | null; remoteEnv?: Record | null; + localProcessSandbox?: LocalProcessSandboxOptions | null; } = {}, ): Promise { const remote = options.remoteExecution ?? null; @@ -2083,6 +2089,26 @@ async function resolveSpawnTarget( const resolved = await resolveCommandPath(command, cwd, env); const executable = resolved ?? command; + if (options.localProcessSandbox) { + if (!resolved) { + throw new Error(`Command not found in PATH: "${command}"`); + } + const requestedSandboxCommand = options.localProcessSandbox.command?.trim() || "bwrap"; + const sandboxCommand = await resolveCommandPath(requestedSandboxCommand, cwd, env); + if (!sandboxCommand) { + throw new Error( + `Local process confinement requires Bubblewrap, but "${requestedSandboxCommand}" was not found in PATH. Install bwrap or configure filesystemSandboxCommand.`, + ); + } + const sandboxTarget = await buildLocalProcessSandboxSpawnTarget({ + executable, + args, + cwd, + options: options.localProcessSandbox, + }); + return { ...sandboxTarget, command: sandboxCommand }; + } + if (process.platform !== "win32") { return { command: executable, args }; } @@ -2898,6 +2924,7 @@ export async function runChildProcess( terminalResultCleanup?: TerminalResultCleanupOptions; stdin?: string; remoteExecution?: RemoteExecutionSpec | null; + localProcessSandbox?: LocalProcessSandboxOptions | null; }, ): Promise { const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg)); @@ -2923,14 +2950,22 @@ export async function runChildProcess( } const mergedEnv = ensurePathInEnv(rawMerged); + if (opts.localProcessSandbox?.homeDir) { + mergedEnv.HOME = opts.localProcessSandbox.homeDir; + } void resolveSpawnTarget(command, args, opts.cwd, mergedEnv, { remoteExecution: opts.remoteExecution ?? null, remoteEnv: opts.remoteExecution ? opts.env : null, + localProcessSandbox: opts.localProcessSandbox ?? null, }) .then((target) => { + const childEnv = { ...mergedEnv, ...target.env }; + for (const [key, value] of Object.entries(childEnv)) { + if (value === undefined) delete childEnv[key]; + } const child = spawn(target.command, target.args, { cwd: target.cwd ?? opts.cwd, - env: mergedEnv, + env: childEnv, detached: process.platform !== "win32", shell: false, stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"], diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 4f365a7eb0..17297fc9e8 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -49,6 +49,11 @@ Core fields: - env (object, optional): KEY=VALUE environment variables - workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? } - workspaceRuntime (object, optional): reserved for workspace runtime metadata; workspace runtime services are manually controlled from the workspace UI and are not auto-started by heartbeats +- filesystemScope (string, optional): set to "workspace" to confine local CLI filesystem access with Bubblewrap. Off by default. The workspace and Claude config remain writable; other host paths are hidden. +- filesystemExtraPaths (array, optional): additional absolute host paths exposed inside the workspace sandbox. String entries are read-only; object entries use { path: "/absolute/path", access: "ro" | "rw" }. +- filesystemSandboxCommand (string, optional): Bubblewrap executable name or absolute path; defaults to "bwrap". Linux only. +- networkScope (string, optional): "deny" blocks all network egress; "allowlist" permits only networkAllowlist targets through Paperclip's HTTP(S) proxy. Off by default. +- networkAllowlist (string[], optional): exact hostnames, hostname:port entries, or origin URLs. Include the configured Claude provider origin, such as "api.anthropic.com", Bedrock/Vertex endpoints, or a custom gateway. ACP fields (only when engine="acp"): - agentCommand (string, optional): override for the Claude ACP server command; defaults to the package-local claude-agent-acp binary @@ -62,6 +67,7 @@ Operational fields: - graceSec (number, optional): SIGTERM grace period in seconds Notes: +- filesystemScope and networkScope are spawn-level confinement and are orthogonal to Claude permission flags. Both require Bubblewrap on the host and select the CLI engine in auto mode; engine="acp" is rejected because ACP confinement is not yet supported. networkScope="allowlist" injects HTTP_PROXY/HTTPS_PROXY for the CLI while its private network namespace blocks direct sockets, so every required provider/API hostname must be listed explicitly. - The Claude ACP lane requires Node >=22.12.0 and @agentclientprotocol/claude-agent-acp to be installed with this adapter package. Auto engine selection falls back to CLI when those prerequisites are unavailable; explicit engine="acp" fails loudly. - For ACP runs, model selection is passed through ANTHROPIC_MODEL at ACP server startup; Paperclip-managed Claude permissions and ephemeral skill materialization are handled by the shared ACP engine. - When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling. diff --git a/packages/adapters/claude-local/src/server/acp.test.ts b/packages/adapters/claude-local/src/server/acp.test.ts index 6c2a46ab54..19cfcd57e4 100644 --- a/packages/adapters/claude-local/src/server/acp.test.ts +++ b/packages/adapters/claude-local/src/server/acp.test.ts @@ -259,6 +259,41 @@ describe("claude_local ACP lane", () => { ).resolves.toEqual({ engine: "acp", explicit: true }); }); + it("selects the confined CLI lane for local filesystem or network scope", async () => { + await expect( + resolveClaudeExecutionEngineForRun({ + config: { filesystemScope: "workspace" }, + executionTarget: null, + }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("spawn-level confinement"), + }); + await expect( + resolveClaudeExecutionEngineForRun({ + config: { engine: "acp", filesystemScope: "workspace" }, + executionTarget: null, + }), + ).rejects.toThrow("ACP confinement is not supported"); + await expect( + resolveClaudeExecutionEngineForRun({ + config: { networkScope: "deny" }, + executionTarget: null, + }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("network scope"), + }); + await expect( + resolveClaudeExecutionEngineForRun({ + config: { networkScope: "public" }, + executionTarget: null, + }), + ).rejects.toThrow('networkScope must be "deny" or "allowlist"'); + }); + it("uses ACP for bridged sandbox auto runs when the ACP command is configured as a shell command", async () => { setNodeVersion("v22.12.0"); await expect( diff --git a/packages/adapters/claude-local/src/server/acp.ts b/packages/adapters/claude-local/src/server/acp.ts index ef8c4d0c8e..c6316084dd 100644 --- a/packages/adapters/claude-local/src/server/acp.ts +++ b/packages/adapters/claude-local/src/server/acp.ts @@ -9,6 +9,10 @@ import type { AdapterExecutionContext, AdapterExecutionResult, } from "@paperclipai/adapter-utils"; +import { + parseLocalProcessFilesystemScope, + parseLocalProcessNetworkScope, +} from "@paperclipai/adapter-utils/local-process-sandbox"; import { ensureAdapterExecutionTargetCommandResolvable, readAdapterExecutionTarget, @@ -65,6 +69,20 @@ export async function resolveClaudeExecutionEngineForRun( input: ClaudeEngineResolutionInput, ): Promise { const selection = normalizeEngine(input.config.engine); + const filesystemScope = parseLocalProcessFilesystemScope(input.config.filesystemScope); + const networkScope = parseLocalProcessNetworkScope(input.config.networkScope); + if (filesystemScope || networkScope) { + if (selection.explicit && selection.engine === "acp") { + throw new Error("Local filesystem/network confinement requires the Claude CLI engine; ACP confinement is not supported."); + } + return { + engine: "cli", + explicit: selection.explicit, + ...(!selection.explicit + ? { fallbackReason: "Local filesystem/network scope requires spawn-level confinement in the CLI lane." } + : {}), + }; + } if (selection.explicit || selection.engine !== "acp") return selection; const fallbackReason = await defaultClaudeAcpFallbackReason(input); diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 961e0c9eb2..330d635182 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -44,6 +44,13 @@ import { stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, } from "@paperclipai/adapter-utils/server-utils"; +import { + parseLocalProcessFilesystemScope, + parseLocalProcessSandboxExtraPaths, + parseLocalProcessNetworkAllowlist, + parseLocalProcessNetworkScope, + type LocalProcessSandboxOptions, +} from "@paperclipai/adapter-utils/local-process-sandbox"; import { claudeModelUsageTotals, parseClaudeStreamJson, @@ -492,6 +499,36 @@ export async function execute(ctx: AdapterExecutionContext): Promise parseClaudeStreamJson(stdout).resultJson !== null, }, + localProcessSandbox, }); const parsedStream = parseClaudeStreamJson(proc.stdout); diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index c5658d2b05..607667b6a1 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -86,6 +86,11 @@ Core fields: - env (object, optional): KEY=VALUE environment variables - workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? } - workspaceRuntime (object, optional): reserved for workspace runtime metadata; workspace runtime services are manually controlled from the workspace UI and are not auto-started by heartbeats +- filesystemScope (string, optional): set to "workspace" to confine local CLI filesystem access with Bubblewrap. Off by default. The workspace and managed CODEX_HOME remain writable; other host paths are hidden. +- filesystemExtraPaths (array, optional): additional absolute host paths exposed inside the workspace sandbox. String entries are read-only; object entries use { path: "/absolute/path", access: "ro" | "rw" }. +- filesystemSandboxCommand (string, optional): Bubblewrap executable name or absolute path; defaults to "bwrap". Linux only. +- networkScope (string, optional): "deny" blocks all network egress; "allowlist" permits only networkAllowlist targets through Paperclip's HTTP(S) proxy. Off by default. +- networkAllowlist (string[], optional): exact hostnames, hostname:port entries, or origin URLs. Include the configured Codex provider origin, such as "api.openai.com" or a custom model provider gateway. Operational fields: - timeoutSec (number, optional): run timeout in seconds @@ -98,6 +103,7 @@ Operational fields: - warmHandleIdleMs (number, optional): warm ACP process idle timeout when engine="acp"; defaults to 0 Notes: +- filesystemScope and networkScope are spawn-level confinement and are orthogonal to Codex approval/sandbox flags. Both require Bubblewrap on the host and select the CLI engine in auto mode; engine="acp" is rejected because ACP confinement is not yet supported. networkScope="allowlist" injects HTTP_PROXY/HTTPS_PROXY for the CLI while its private network namespace blocks direct sockets, so every required provider/API hostname must be listed explicitly. - Prompts are piped via stdin (Codex receives "-" prompt argument). - If instructionsFilePath is configured, Paperclip prepends that file's contents to the stdin prompt on every run. - Codex exec automatically applies repo-scoped AGENTS.md instructions from the active workspace. Paperclip cannot suppress that discovery in exec mode, so repo AGENTS.md files may still apply even when you only configured an explicit instructionsFilePath. diff --git a/packages/adapters/codex-local/src/server/acp.test.ts b/packages/adapters/codex-local/src/server/acp.test.ts index 2e72298b8f..44d8e2c6a3 100644 --- a/packages/adapters/codex-local/src/server/acp.test.ts +++ b/packages/adapters/codex-local/src/server/acp.test.ts @@ -246,6 +246,41 @@ describe("codex_local ACP lane", () => { ).resolves.toEqual({ engine: "acp", explicit: true }); }); + it("selects the confined CLI lane for local filesystem or network scope", async () => { + await expect( + resolveCodexExecutionEngineForRun({ + config: { filesystemScope: "workspace" }, + executionTarget: null, + }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("spawn-level confinement"), + }); + await expect( + resolveCodexExecutionEngineForRun({ + config: { engine: "acp", filesystemScope: "workspace" }, + executionTarget: null, + }), + ).rejects.toThrow("ACP confinement is not supported"); + await expect( + resolveCodexExecutionEngineForRun({ + config: { networkScope: "allowlist" }, + executionTarget: null, + }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("network scope"), + }); + await expect( + resolveCodexExecutionEngineForRun({ + config: { filesystemScope: "workpace" }, + executionTarget: null, + }), + ).rejects.toThrow('filesystemScope must be "workspace"'); + }); + it("uses ACP for bridged sandbox auto runs when the ACP command is configured as a shell command", async () => { setNodeVersion("v22.13.0"); await expect( diff --git a/packages/adapters/codex-local/src/server/acp.ts b/packages/adapters/codex-local/src/server/acp.ts index 1f317b5557..e55201f168 100644 --- a/packages/adapters/codex-local/src/server/acp.ts +++ b/packages/adapters/codex-local/src/server/acp.ts @@ -9,6 +9,10 @@ import type { AdapterExecutionContext, AdapterExecutionResult, } from "@paperclipai/adapter-utils"; +import { + parseLocalProcessFilesystemScope, + parseLocalProcessNetworkScope, +} from "@paperclipai/adapter-utils/local-process-sandbox"; import { inferOpenAiCompatibleBiller } from "@paperclipai/adapter-utils"; import { ensureAdapterExecutionTargetCommandResolvable, @@ -66,6 +70,20 @@ export async function resolveCodexExecutionEngineForRun( input: CodexEngineResolutionInput, ): Promise { const selection = normalizeEngine(input.config.engine); + const filesystemScope = parseLocalProcessFilesystemScope(input.config.filesystemScope); + const networkScope = parseLocalProcessNetworkScope(input.config.networkScope); + if (filesystemScope || networkScope) { + if (selection.explicit && selection.engine === "acp") { + throw new Error("Local filesystem/network confinement requires the Codex CLI engine; ACP confinement is not supported."); + } + return { + engine: "cli", + explicit: selection.explicit, + ...(!selection.explicit + ? { fallbackReason: "Local filesystem/network scope requires spawn-level confinement in the CLI lane." } + : {}), + }; + } if (selection.explicit || selection.engine !== "acp") return selection; const fallbackReason = await defaultCodexAcpFallbackReason(input); diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index cd9b9f1806..775abead7a 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -38,6 +38,13 @@ import { DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, joinPromptSections, } from "@paperclipai/adapter-utils/server-utils"; +import { + parseLocalProcessFilesystemScope, + parseLocalProcessSandboxExtraPaths, + parseLocalProcessNetworkAllowlist, + parseLocalProcessNetworkScope, + type LocalProcessSandboxOptions, +} from "@paperclipai/adapter-utils/local-process-sandbox"; import { parseCodexJsonl, extractCodexRetryNotBefore, @@ -632,6 +639,30 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", @@ -923,6 +954,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise