Compare commits

...

1 Commits

Author SHA1 Message Date
hongjr03 035c264179 fix: keep agent sandbox sockets on short paths 2026-07-11 02:22:19 +08:00
7 changed files with 72 additions and 44 deletions
@@ -99,16 +99,17 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox
in the run's workspace while `/bin`, shared libraries, CA certificates, in the run's workspace while `/bin`, shared libraries, CA certificates,
fonts and the configured `cph` executable remain available as the external fonts and the configured `cph` executable remain available as the external
tool exception described above. tool exception described above.
- `sandbox.filesystem.allowWrite: [workspaceDir]` confines every write to the - `sandbox.filesystem.allowWrite` confines deliverable writes to the canonical
canonical ADR-0007 workspace. Config/cache/home stay beneath ADR-0007 workspace and opens one service-owned SDK scratch directory under
`.cph/agent-runtime/`. `TMPDIR`, `TMP`, and `TEMP` use the absolute `/tmp/cph-agent-<uid>/<project-hash>`. Config/cache/home stay beneath
workspace-local `.cph/t/` path so tools remain anchored after `cd`; `.cph/agent-runtime/`; `TMPDIR`, `TMP`, `TEMP`, and `CLAUDE_CODE_TMPDIR` all
`CLAUDE_CODE_TMPDIR` uses the short relative `.cph/t` prefix, resolved from point at the project-keyed scratch directory. Its canonical prefix is capped
the canonical workspace cwd, because the SDK's socat bridge otherwise falls at 64 bytes so the SDK can append randomized `socat` bridge socket names
back to a host temp directory when its Unix-socket prefix is too long. without exceeding Linux `sockaddr_un.sun_path`. The per-Organization OS UID,
Root is denied for writes and only the canonical workspace is re-opened, so project hash, mode `0700`, symlink rejection, and exact sandbox allowlist keep
`/tmp`, `/var/tmp`, sibling projects and every other host path are rejected. that scratch isolated; sibling scratch paths and ordinary `/tmp` remain
There is no writable scratch exception outside the project directory. denied. Root is denied for writes and only the workspace plus that exact
internal scratch directory are re-opened.
- The SDK subprocess environment replaces rather than spreads `process.env`. - The SDK subprocess environment replaces rather than spreads `process.env`.
Only provider protocol variables and non-secret runtime variables cross the Only provider protocol variables and non-secret runtime variables cross the
boundary; database, Feishu and Hub session credentials never enter it. boundary; database, Feishu and Hub session credentials never enter it.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.4", "version": "0.0.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.4", "version": "0.0.5",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.202", "@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.4", "version": "0.0.5",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
+34 -11
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { chmod, lstat, mkdir, realpath } from "node:fs/promises"; import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path"; import { isAbsolute, join, relative, resolve } from "node:path";
@@ -26,6 +27,11 @@ const SANDBOX_HIDDEN_ENV_KEYS = [
"ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY",
] as const; ] as const;
// Linux sockaddr_un.sun_path is 108 bytes including the terminator. Claude's
// sandbox appends its own user directory and randomized bridge socket names,
// so keep our prefix well below that hard limit.
const MAX_AGENT_TMP_PREFIX_BYTES = 64;
export interface AgentSecurityInput { export interface AgentSecurityInput {
readonly workspaceRoot: string; readonly workspaceRoot: string;
readonly workspaceDir: string; readonly workspaceDir: string;
@@ -72,10 +78,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
const agentCache = await ensureDirectoryTree(runtimeRoot, ["cache"]); const agentCache = await ensureDirectoryTree(runtimeRoot, ["cache"]);
const agentConfig = await ensureDirectoryTree(runtimeRoot, ["config"]); const agentConfig = await ensureDirectoryTree(runtimeRoot, ["config"]);
const agentState = await ensureDirectoryTree(runtimeRoot, ["state"]); const agentState = await ensureDirectoryTree(runtimeRoot, ["state"]);
// Keep the SDK temp root both short enough for Linux AF_UNIX sockets and const agentTmp = await resolveShortAgentTemp(workspaceDir);
// physically inside the project boundary pinned by AgentFileOp.Authorized.
const agentTmp = await ensureDirectoryTree(cphRoot, ["t"]);
const claudeCodeTmp = join(".cph", "t");
const path = hostEnv["PATH"]?.trim(); const path = hostEnv["PATH"]?.trim();
if (path === undefined || path === "") { if (path === undefined || path === "") {
@@ -91,14 +94,13 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
env.XDG_CACHE_HOME = agentCache; env.XDG_CACHE_HOME = agentCache;
env.XDG_CONFIG_HOME = agentConfig; env.XDG_CONFIG_HOME = agentConfig;
env.XDG_STATE_HOME = agentState; env.XDG_STATE_HOME = agentState;
// General subprocess temp paths stay absolute so tools continue to work // All four variables must use the short path. Claude's sandbox bridge uses
// after `cd`. Only the SDK's socket prefix is relative: Claude resolves it // the ordinary temp variables, while other SDK paths use CLAUDE_CODE_TMPDIR.
// from the canonical workspace cwd before Bash commands can change cwd.
env.TMPDIR = agentTmp; env.TMPDIR = agentTmp;
env.TMP = agentTmp; env.TMP = agentTmp;
env.TEMP = agentTmp; env.TEMP = agentTmp;
env.CLAUDE_CONFIG_DIR = agentConfig; env.CLAUDE_CONFIG_DIR = agentConfig;
env.CLAUDE_CODE_TMPDIR = claudeCodeTmp; env.CLAUDE_CODE_TMPDIR = agentTmp;
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1"; env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
env.DISABLE_TELEMETRY = "1"; env.DISABLE_TELEMETRY = "1";
env.DO_NOT_TRACK = "1"; env.DO_NOT_TRACK = "1";
@@ -123,7 +125,10 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
autoAllowBashIfSandboxed: true, autoAllowBashIfSandboxed: true,
allowUnsandboxedCommands: false, allowUnsandboxedCommands: false,
filesystem: { filesystem: {
allowWrite: [workspaceDir], // The temp directory is service-owned, project-keyed, mode 0700, and
// contains only SDK sockets/scratch. User deliverables remain confined
// to workspaceDir.
allowWrite: [workspaceDir, agentTmp],
// Reject every write path by default, then re-open only the canonical // Reject every write path by default, then re-open only the canonical
// workspace. This prevents bubblewrap's ordinary temp exceptions from // workspace. This prevents bubblewrap's ordinary temp exceptions from
// turning an unauthorized path into a successful ephemeral write. // turning an unauthorized path into a successful ephemeral write.
@@ -132,7 +137,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
// workspace plus the named system runtime needed to execute tools. // workspace plus the named system runtime needed to execute tools.
// SDK allowRead takes precedence over matching denyRead paths. // SDK allowRead takes precedence over matching denyRead paths.
denyRead: ["/"], denyRead: ["/"],
allowRead: [workspaceDir, ...runtimeReadPaths], allowRead: [workspaceDir, agentTmp, ...runtimeReadPaths],
}, },
credentials: { credentials: {
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })), files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })),
@@ -142,6 +147,24 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
}; };
} }
async function resolveShortAgentTemp(workspaceDir: string): Promise<string> {
// Do not use os.tmpdir() on macOS: its per-user path can itself exceed the
// Unix socket budget. /tmp canonicalizes to /private/tmp there and /tmp on
// Linux. The UID boundary prevents Organizations' service accounts sharing
// a writable runtime root; the workspace hash separates Projects.
const systemTmp = await realpath(process.platform === "win32" ? homedir() : "/tmp");
const uid = process.getuid?.() ?? "user";
const workspaceKey = createHash("sha256").update(workspaceDir).digest("hex").slice(0, 12);
const agentTmp = await ensureDirectoryTree(systemTmp, [`cph-agent-${uid}`, workspaceKey]);
const prefixBytes = Buffer.byteLength(agentTmp);
if (prefixBytes > MAX_AGENT_TMP_PREFIX_BYTES) {
throw new Error(
`Agent temp path is too long for sandbox bridge sockets (${prefixBytes} > ${MAX_AGENT_TMP_PREFIX_BYTES} bytes): ${agentTmp}`,
);
}
return agentTmp;
}
function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>): string[] { function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
const platformPaths = process.platform === "darwin" const platformPaths = process.platform === "darwin"
? [ ? [
@@ -282,7 +305,7 @@ async function ensureDirectoryTree(root: string, components: readonly string[]):
} }
const canonical = await realpath(current); const canonical = await realpath(current);
if (canonical !== current || !isStrictDescendant(root, canonical)) { if (canonical !== current || !isStrictDescendant(root, canonical)) {
throw new Error(`Agent runtime path escapes project workspace: ${current}`); throw new Error(`Agent runtime path escapes its configured root: ${current}`);
} }
await chmod(canonical, 0o700); await chmod(canonical, 0o700);
return canonical; return canonical;
@@ -70,8 +70,7 @@ describe("real Claude SDK sandbox boundary", () => {
const canonicalServiceSecret = await realpath(serviceSecret); const canonicalServiceSecret = await realpath(serviceSecret);
const resultPath = join(canonicalWorkspace, "sandbox-result.txt"); const resultPath = join(canonicalWorkspace, "sandbox-result.txt");
const cphVersionPath = join(canonicalWorkspace, "cph-version.txt"); const cphVersionPath = join(canonicalWorkspace, "cph-version.txt");
const effectiveTempProbePath = join(canonicalWorkspace, ".cph", "t", "effective-temp.txt"); const denialLogPath = join(canonicalWorkspace, ".cph", "denials.log");
const denialLogPath = join(canonicalWorkspace, ".cph", "t", "denials.log");
const siblingEscapePath = join(canonicalSibling, "escape.txt"); const siblingEscapePath = join(canonicalSibling, "escape.txt");
const unsandboxedEscapePath = join(root, `unsandboxed-escape-${nonce}`); const unsandboxedEscapePath = join(root, `unsandboxed-escape-${nonce}`);
const hostTmpEscapePath = `/tmp/cph-host-temp-${nonce}`; const hostTmpEscapePath = `/tmp/cph-host-temp-${nonce}`;
@@ -95,12 +94,12 @@ describe("real Claude SDK sandbox boundary", () => {
`if service_value=$(cat ${shellQuote(canonicalServiceSecret)} 2>> ${shellQuote(denialLogPath)}); then exit 23; fi`, `if service_value=$(cat ${shellQuote(canonicalServiceSecret)} 2>> ${shellQuote(denialLogPath)}); then exit 23; fi`,
`mkdir ${shellQuote(join(canonicalWorkspace, "subdir"))}`, `mkdir ${shellQuote(join(canonicalWorkspace, "subdir"))}`,
`cd ${shellQuote(join(canonicalWorkspace, "subdir"))}`, `cd ${shellQuote(join(canonicalWorkspace, "subdir"))}`,
`test "\${TMPDIR-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, 'test "${TMPDIR-unset}" = "${TMP-unset}"',
`test "\${TMP-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, 'test "${TMPDIR-unset}" = "${TEMP-unset}"',
`test "\${TEMP-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, 'test "${TMPDIR-unset}" = "${CLAUDE_CODE_TMPDIR-unset}"',
'test "${CLAUDE_CODE_TMPDIR-unset}" = ".cph/t"', 'test "${#TMPDIR}" -le 64',
`test "$(realpath "$TMPDIR")" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, `case "$TMPDIR" in ${shellQuote(canonicalWorkspace)}/*) exit 35;; esac`,
`printf 'workspace-temp\\n' > "$TMPDIR/effective-temp.txt"`, `printf 'sdk-temp\\n' > "$TMPDIR/effective-temp.txt"`,
`if printf 'host-temp\\n' > ${shellQuote(hostTmpEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 33; fi`, `if printf 'host-temp\\n' > ${shellQuote(hostTmpEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 33; fi`,
`if printf 'host-var-temp\\n' > ${shellQuote(hostVarTmpEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 34; fi`, `if printf 'host-var-temp\\n' > ${shellQuote(hostVarTmpEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 34; fi`,
'test "${DATABASE_URL-unset}" = unset', 'test "${DATABASE_URL-unset}" = unset',
@@ -159,7 +158,6 @@ describe("real Claude SDK sandbox boundary", () => {
}); });
expect(resultContents).toBe("sandbox-ok\n"); expect(resultContents).toBe("sandbox-ok\n");
await expect(readFile(cphVersionPath, "utf8")).resolves.toMatch(/^cph /); await expect(readFile(cphVersionPath, "utf8")).resolves.toMatch(/^cph /);
await expect(readFile(effectiveTempProbePath, "utf8")).resolves.toBe("workspace-temp\n");
const denialLog = await readFile(denialLogPath, "utf8"); const denialLog = await readFile(denialLogPath, "utf8");
expect(denialLog).not.toContain("sibling-secret"); expect(denialLog).not.toContain("sibling-secret");
expect(denialLog).not.toContain("platform-secret"); expect(denialLog).not.toContain("platform-secret");
+16 -10
View File
@@ -46,7 +46,11 @@ describe("agent subprocess security policy", () => {
expect(policy.env).not.toHaveProperty("HUB_SESSION_SECRET"); expect(policy.env).not.toHaveProperty("HUB_SESSION_SECRET");
expect(policy.env.HOME).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`)); expect(policy.env.HOME).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
expect(policy.env.CLAUDE_CONFIG_DIR).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`)); expect(policy.env.CLAUDE_CONFIG_DIR).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(join(".cph", "t")); expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(policy.env.TMPDIR);
expect(policy.env.TMP).toBe(policy.env.TMPDIR);
expect(policy.env.TEMP).toBe(policy.env.TMPDIR);
expect(policy.env.TMPDIR).not.toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
expect(Buffer.byteLength(policy.env.TMPDIR!)).toBeLessThanOrEqual(64);
expect(policy.sandbox).toMatchObject({ expect(policy.sandbox).toMatchObject({
enabled: true, enabled: true,
@@ -54,7 +58,7 @@ describe("agent subprocess security policy", () => {
autoAllowBashIfSandboxed: true, autoAllowBashIfSandboxed: true,
allowUnsandboxedCommands: false, allowUnsandboxedCommands: false,
filesystem: { filesystem: {
allowWrite: [canonicalWorkspace], allowWrite: expect.arrayContaining([canonicalWorkspace, policy.env.TMPDIR]),
denyRead: ["/"], denyRead: ["/"],
allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]), allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]),
}, },
@@ -81,7 +85,7 @@ describe("agent subprocess security policy", () => {
})).rejects.toThrow("unsupported provider environment variable: DATABASE_URL"); })).rejects.toThrow("unsupported provider environment variable: DATABASE_URL");
}); });
it("keeps the short SDK socket directory inside the project workspace", async () => { it("keeps every SDK temp variable on a short isolated path outside a long project workspace", async () => {
const { workspaceRoot, workspace } = await makeWorkspace(); const { workspaceRoot, workspace } = await makeWorkspace();
const policy = await createAgentSecurityPolicy({ const policy = await createAgentSecurityPolicy({
workspaceRoot, workspaceRoot,
@@ -89,14 +93,16 @@ describe("agent subprocess security policy", () => {
hostEnv: { PATH: "/usr/bin:/bin" }, hostEnv: { PATH: "/usr/bin:/bin" },
}); });
expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(join(".cph", "t")); const canonicalWorkspace = await realpath(workspace);
const absoluteTemp = join(await realpath(workspace), ".cph", "t"); const temp = policy.env.TMPDIR!;
expect(policy.env.TMPDIR).toBe(absoluteTemp); expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(temp);
expect(policy.env.TMP).toBe(absoluteTemp); expect(policy.env.TMP).toBe(temp);
expect(policy.env.TEMP).toBe(absoluteTemp); expect(policy.env.TEMP).toBe(temp);
expect(policy.sandbox.filesystem.allowWrite).toEqual([await realpath(workspace)]); expect(temp).not.toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
expect(Buffer.byteLength(temp)).toBeLessThanOrEqual(64);
expect(policy.sandbox.filesystem.allowWrite).toEqual([canonicalWorkspace, temp]);
expect(policy.sandbox.filesystem.denyWrite).toEqual(["/"]); expect(policy.sandbox.filesystem.denyWrite).toEqual(["/"]);
expect(policy.sandbox.filesystem.allowRead).not.toContain(expect.stringMatching(/^\/run\//)); expect(policy.sandbox.filesystem.allowRead).toContain(temp);
}); });
it("rejects a project workspace whose real path escapes the configured workspace root", async () => { it("rejects a project workspace whose real path escapes the configured workspace root", async () => {
+1 -1
View File
@@ -108,7 +108,7 @@ describe("runAgent", () => {
failIfUnavailable: true, failIfUnavailable: true,
allowUnsandboxedCommands: false, allowUnsandboxedCommands: false,
filesystem: expect.objectContaining({ filesystem: expect.objectContaining({
allowWrite: [workspace], allowWrite: expect.arrayContaining([workspace]),
denyRead: ["/"], denyRead: ["/"],
allowRead: expect.arrayContaining([workspace]), allowRead: expect.arrayContaining([workspace]),
}), }),