Files
PaperClipAI/packages/adapter-utils/src/local-process-sandbox.test.ts
DottaandPaperclip 2568bdecc4 fix(adapter-utils): keep sandbox proxy sockets within Linux path limit (#10221)
## Thinking Path

> - Paperclip is the open source control plane people use to manage AI
agents for work
> - Local process adapters can enforce network allowlists through a
Unix-socket proxy inside the Linux sandbox
> - The proxy socket was created under `os.tmpdir()`, which can be a
deeply nested run-specific directory
> - Linux Unix-domain socket paths are limited to 107 usable bytes, so
deep temporary paths can be silently truncated during bind
> - A truncated proxy socket resets every confined outbound connection,
including the model provider API, before the agent can do work
> - This pull request creates proxy sockets under the short `/tmp` path
when possible and validates the path length before bind
> - The benefit is reliable sandboxed egress under deep `TMPDIR` values
and an explicit error instead of an opaque connection-reset storm

## Linked Issues or Issue Description

### Pre-submission checklist

- [x] I searched existing open and closed issues and this is not a
duplicate.
- [x] I can reproduce this on upstream `master`.
- [x] I confirmed the error originates in Paperclip's local-process
sandbox proxy.

### What happened?

Sandboxed local-process agents using `networkScope: "allowlist"` could
lose all proxied egress when `TMPDIR` was deeply nested because
`proxy.sock` exceeded Linux's Unix socket path limit. The truncated bind
surfaced as repeated connection resets, including for provider API
traffic.

### Expected behavior

Proxy creation should use a Linux-safe short path and fail explicitly if
no safe socket path can be created.

### Steps to reproduce

1. Set `TMPDIR` to a path long enough that
`<TMPDIR>/paperclip-network-sandbox-XXXXXX/proxy.sock` exceeds 107
bytes.
2. Build or run a local-process sandbox with `networkScope:
"allowlist"`.
3. Make an HTTP or HTTPS request through the generated sandbox proxy.
4. Observe connection resets from the overlong Unix socket path on
affected code.

### Paperclip version or commit

Upstream `master` before this change.

### Deployment mode

Other — Linux local-process adapters using Bubblewrap network
allowlisting.

### Installation method

Built from source.

### Agent adapter(s) involved

Codex and any other local-process adapter using the shared sandbox
utility.

### Operating system

Linux.

### Additional context

GitHub search found no duplicate public issue or pull request for this
defect.

## What Changed

- Add a Linux Unix-socket byte-length guard that reports the unsafe path
before binding.
- Create network proxy temporary directories under `/tmp` first, with
`os.tmpdir()` as a validated fallback.
- Use the safe temporary-directory helper at both allowlist proxy
creation sites while preserving trusted URL handling.
- Add regression coverage that sets a deliberately deep `TMPDIR` and
verifies the working socket remains within 107 bytes under `/tmp`.

## Verification

- `node
/srv/paperclip/home/paperclipai/paperclip/node_modules/vitest/vitest.mjs
run packages/adapter-utils/src/local-process-sandbox.test.ts` — 8
passed, 4 environment-gated tests skipped.
- `git diff --check origin/master...HEAD` — passed.
- Greptile Review — passed after reviewing 2 files with 0 comments and
no unresolved threads; this repository integration did not emit a
separate numeric confidence score.
- Full GitHub CI matrix — passed after one rerun of an unrelated ACPX
`ENOTEMPTY` cleanup flake.
- Package typecheck was attempted, but the existing shared install
cannot resolve `acpx/runtime`; the failure is outside the changed files
and is expected to be covered by CI's clean dependency install.

## Risks

- Low risk: the change is limited to Linux sandbox proxy
temporary-directory selection and validation.
- Systems without a writable `/tmp` fall back to `os.tmpdir()` only when
the resulting socket path is safe; otherwise startup now fails loudly
instead of producing connection resets.
- No schema, API, UI, migration, or documentation behavior changes.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, `gpt-5.3-codex`, tool-enabled coding agent with managed
reasoning and code execution; context-window size is not exposed by the
runtime.

## 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
- [x] All Paperclip CI gates are green
- [x] 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>
2026-07-26 10:52:16 -05:00

447 lines
18 KiB
TypeScript

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";
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[] = [];
async function withTmpDir<T>(tmpDir: string, run: () => Promise<T>): Promise<T> {
const previousTmpDir = process.env.TMPDIR;
process.env.TMPDIR = tmpDir;
try {
return await run();
} finally {
if (previousTmpDir === undefined) delete process.env.TMPDIR;
else process.env.TMPDIR = previousTmpDir;
}
}
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("describes every valid allowlist input when no proxy rules remain", async () => {
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-rules-"));
cleanup.push(workspace);
await expect(buildLocalProcessSandboxSpawnTarget({
executable: process.execPath,
args: ["-e", "process.exit(0)"],
cwd: workspace,
options: {
workspaceDir: workspace,
networkScope: "allowlist",
networkAllowlist: [],
networkTrustedUrls: ["file:///not-a-network-target"],
},
})).rejects.toThrow("valid networkAllowlist hostname or HTTP(S) networkTrustedUrl");
});
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("binds a confined absolute alias to the synchronized workspace", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fs-alias-"));
cleanup.push(root);
const workspace = path.join(root, "workspace");
await fs.mkdir(workspace);
const target = await buildLocalProcessSandboxSpawnTarget({
executable: process.execPath,
args: ["-e", "process.exit(0)"],
cwd: workspace,
options: {
workspaceDir: workspace,
filesystemScope: "workspace",
pathAliases: [{ path: "/app", target: workspace }],
},
});
expect(target.args).toEqual(expect.arrayContaining(["--bind", workspace, "/app"]));
});
it("rejects writable out-of-tree paths without an outbound restore mapping", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fs-outbound-"));
cleanup.push(root);
const workspace = path.join(root, "workspace");
const outside = path.join(root, "outside");
await fs.mkdir(workspace);
await fs.mkdir(outside);
await expect(buildLocalProcessSandboxSpawnTarget({
executable: process.execPath,
args: ["-e", "process.exit(0)"],
cwd: workspace,
options: {
workspaceDir: workspace,
filesystemScope: "workspace",
extraPaths: [{ path: outside, access: "rw" }],
},
})).rejects.toThrow("has no outbound restore mapping");
});
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 with a deep TMPDIR and rejects other hosts", async () => {
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-proxy-"));
cleanup.push(workspace);
const deepTmpDir = path.join(workspace, ...Array.from({ length: 6 }, () => "deep-temporary-directory-segment"));
await fs.mkdir(deepTmpDir, { recursive: true });
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 withTmpDir(deepTmpDir, () =>
buildLocalProcessSandboxSpawnTarget({
executable: process.execPath,
args: ["-e", "process.exit(0)"],
cwd: workspace,
options: {
workspaceDir: workspace,
filesystemScope: "workspace",
networkScope: "allowlist",
networkAllowlist: [`127.0.0.1:${address.port}`],
},
}),
);
const delimiterIndex = target.args.indexOf("--");
const socketPath = target.args[delimiterIndex + 3];
expect(Buffer.byteLength(path.join(deepTmpDir, "paperclip-network-sandbox-XXXXXX", "proxy.sock"))).toBeGreaterThan(107);
expect(Buffer.byteLength(socketPath)).toBeLessThanOrEqual(107);
expect(socketPath).toMatch(/^\/tmp\/paperclip-network-sandbox-/);
expect(target.args).toContain(path.dirname(socketPath));
const request = (url: string) => new Promise<{ status: number; contentType: string | null; 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,
contentType: typeof response.headers["content-type"] === "string" ? response.headers["content-type"] : null,
body,
}));
});
outgoing.on("error", reject);
outgoing.end();
});
try {
await expect(request(`http://127.0.0.1:${address.port}/canary`)).resolves.toEqual({
status: 200,
contentType: null,
body: "allowed-response",
});
await expect(request("http://example.com/")).resolves.toEqual({
status: 403,
contentType: "application/json; charset=utf-8",
body: '{"error":{"code":"network_target_denied","message":"Network target denied by Paperclip sandbox policy."}}\n',
});
const connectResponse = await new Promise<string>((resolve, reject) => {
const socket = net.createConnection(socketPath, () => {
socket.end("CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n");
});
let response = "";
socket.setEncoding("utf8");
socket.on("data", (chunk) => { response += chunk; });
socket.on("end", () => resolve(response));
socket.on("error", reject);
});
expect(connectResponse).toContain("HTTP/1.1 403 Forbidden\r\n");
expect(connectResponse).toContain("Content-Type: application/json; charset=utf-8\r\n");
expect(connectResponse).toContain(
'{"error":{"code":"network_target_denied","message":"Network target denied by Paperclip sandbox policy."}}\n',
);
} finally {
await target.cleanup?.();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("always permits trusted Paperclip control-plane URLs", async () => {
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-trusted-"));
cleanup.push(workspace);
const server = http.createServer((_request, response) => response.end("control-plane-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: ["api.openai.com"],
networkTrustedUrls: [`http://127.0.0.1:${address.port}/api/issues/issue-1`],
},
});
const delimiterIndex = target.args.indexOf("--");
const socketPath = target.args[delimiterIndex + 3];
try {
const response = await new Promise<{ status: number; body: string }>((resolve, reject) => {
const outgoing = http.request({
socketPath,
path: `http://127.0.0.1:${address.port}/api/issues/issue-1`,
headers: { host: `127.0.0.1:${address.port}` },
}, (incoming) => {
let body = "";
incoming.on("data", (chunk) => { body += chunk; });
incoming.on("end", () => resolve({ status: incoming.statusCode ?? 0, body }));
});
outgoing.on("error", reject);
outgoing.end();
});
expect(response).toEqual({ status: 200, body: "control-plane-response" });
} 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 deepTmpDir = path.join(workspace, ...Array.from({ length: 6 }, () => "deep-temporary-directory-segment"));
await fs.mkdir(deepTmpDir, { recursive: true });
const result = await withTmpDir(deepTmpDir, () =>
runChildProcess(
"network-sandbox-allowlist-test",
process.execPath,
["-e", script],
{
cwd: workspace,
env: {},
timeoutSec: 10,
graceSec: 1,
onLog: async () => {},
localProcessSandbox: {
workspaceDir: workspace,
filesystemScope: "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()));
}
},
);
});