mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
## 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 <noreply@paperclip.ing>
296 lines
12 KiB
TypeScript
296 lines
12 KiB
TypeScript
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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
},
|
|
);
|
|
});
|