diff --git a/docs/adr/0018-agent-execution-surface-bounded-by-workspace.md b/docs/adr/0018-agent-execution-surface-bounded-by-workspace.md index e85a22b..86a299b 100644 --- a/docs/adr/0018-agent-execution-surface-bounded-by-workspace.md +++ b/docs/adr/0018-agent-execution-surface-bounded-by-workspace.md @@ -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, fonts and the configured `cph` executable remain available as the external tool exception described above. -- `sandbox.filesystem.allowWrite: [workspaceDir]` confines every write to the - canonical ADR-0007 workspace. Config/cache/home stay beneath - `.cph/agent-runtime/`. `TMPDIR`, `TMP`, and `TEMP` use the absolute - workspace-local `.cph/t/` path so tools remain anchored after `cd`; - `CLAUDE_CODE_TMPDIR` uses the short relative `.cph/t` prefix, resolved from - the canonical workspace cwd, because the SDK's socat bridge otherwise falls - back to a host temp directory when its Unix-socket prefix is too long. - Root is denied for writes and only the canonical workspace is re-opened, so - `/tmp`, `/var/tmp`, sibling projects and every other host path are rejected. - There is no writable scratch exception outside the project directory. +- `sandbox.filesystem.allowWrite` confines deliverable writes to the canonical + ADR-0007 workspace and opens one service-owned SDK scratch directory under + `/tmp/cph-agent-/`. Config/cache/home stay beneath + `.cph/agent-runtime/`; `TMPDIR`, `TMP`, `TEMP`, and `CLAUDE_CODE_TMPDIR` all + point at the project-keyed scratch directory. Its canonical prefix is capped + at 64 bytes so the SDK can append randomized `socat` bridge socket names + without exceeding Linux `sockaddr_un.sun_path`. The per-Organization OS UID, + project hash, mode `0700`, symlink rejection, and exact sandbox allowlist keep + that scratch isolated; sibling scratch paths and ordinary `/tmp` remain + 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`. Only provider protocol variables and non-secret runtime variables cross the boundary; database, Feishu and Hub session credentials never enter it. diff --git a/hub/package-lock.json b/hub/package-lock.json index e400dc7..9ea95f0 100644 --- a/hub/package-lock.json +++ b/hub/package-lock.json @@ -1,12 +1,12 @@ { "name": "@paradigm/hub", - "version": "0.0.4", + "version": "0.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@paradigm/hub", - "version": "0.0.4", + "version": "0.0.5", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.202", "@fastify/cookie": "^11.0.2", diff --git a/hub/package.json b/hub/package.json index f379ac6..c9e3531 100644 --- a/hub/package.json +++ b/hub/package.json @@ -1,6 +1,6 @@ { "name": "@paradigm/hub", - "version": "0.0.4", + "version": "0.0.5", "private": true, "type": "module", "engines": { diff --git a/hub/src/agent/security.ts b/hub/src/agent/security.ts index 6ab5ce5..6b31dc9 100644 --- a/hub/src/agent/security.ts +++ b/hub/src/agent/security.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { chmod, lstat, mkdir, realpath } from "node:fs/promises"; import { homedir } from "node:os"; import { isAbsolute, join, relative, resolve } from "node:path"; @@ -26,6 +27,11 @@ const SANDBOX_HIDDEN_ENV_KEYS = [ "ANTHROPIC_API_KEY", ] 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 { readonly workspaceRoot: string; readonly workspaceDir: string; @@ -72,10 +78,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom const agentCache = await ensureDirectoryTree(runtimeRoot, ["cache"]); const agentConfig = await ensureDirectoryTree(runtimeRoot, ["config"]); const agentState = await ensureDirectoryTree(runtimeRoot, ["state"]); - // Keep the SDK temp root both short enough for Linux AF_UNIX sockets and - // physically inside the project boundary pinned by AgentFileOp.Authorized. - const agentTmp = await ensureDirectoryTree(cphRoot, ["t"]); - const claudeCodeTmp = join(".cph", "t"); + const agentTmp = await resolveShortAgentTemp(workspaceDir); const path = hostEnv["PATH"]?.trim(); if (path === undefined || path === "") { @@ -91,14 +94,13 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom env.XDG_CACHE_HOME = agentCache; env.XDG_CONFIG_HOME = agentConfig; env.XDG_STATE_HOME = agentState; - // General subprocess temp paths stay absolute so tools continue to work - // after `cd`. Only the SDK's socket prefix is relative: Claude resolves it - // from the canonical workspace cwd before Bash commands can change cwd. + // All four variables must use the short path. Claude's sandbox bridge uses + // the ordinary temp variables, while other SDK paths use CLAUDE_CODE_TMPDIR. env.TMPDIR = agentTmp; env.TMP = agentTmp; env.TEMP = agentTmp; env.CLAUDE_CONFIG_DIR = agentConfig; - env.CLAUDE_CODE_TMPDIR = claudeCodeTmp; + env.CLAUDE_CODE_TMPDIR = agentTmp; env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1"; env.DISABLE_TELEMETRY = "1"; env.DO_NOT_TRACK = "1"; @@ -123,7 +125,10 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom autoAllowBashIfSandboxed: true, allowUnsandboxedCommands: false, 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 // workspace. This prevents bubblewrap's ordinary temp exceptions from // 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. // SDK allowRead takes precedence over matching denyRead paths. denyRead: ["/"], - allowRead: [workspaceDir, ...runtimeReadPaths], + allowRead: [workspaceDir, agentTmp, ...runtimeReadPaths], }, credentials: { 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 { + // 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>): string[] { const platformPaths = process.platform === "darwin" ? [ @@ -282,7 +305,7 @@ async function ensureDirectoryTree(root: string, components: readonly string[]): } const canonical = await realpath(current); 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); return canonical; diff --git a/hub/test/integration/agent-sandbox-linux.test.ts b/hub/test/integration/agent-sandbox-linux.test.ts index 87aeec8..4ca7ec4 100644 --- a/hub/test/integration/agent-sandbox-linux.test.ts +++ b/hub/test/integration/agent-sandbox-linux.test.ts @@ -70,8 +70,7 @@ describe("real Claude SDK sandbox boundary", () => { const canonicalServiceSecret = await realpath(serviceSecret); const resultPath = join(canonicalWorkspace, "sandbox-result.txt"); const cphVersionPath = join(canonicalWorkspace, "cph-version.txt"); - const effectiveTempProbePath = join(canonicalWorkspace, ".cph", "t", "effective-temp.txt"); - const denialLogPath = join(canonicalWorkspace, ".cph", "t", "denials.log"); + const denialLogPath = join(canonicalWorkspace, ".cph", "denials.log"); const siblingEscapePath = join(canonicalSibling, "escape.txt"); const unsandboxedEscapePath = join(root, `unsandboxed-escape-${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`, `mkdir ${shellQuote(join(canonicalWorkspace, "subdir"))}`, `cd ${shellQuote(join(canonicalWorkspace, "subdir"))}`, - `test "\${TMPDIR-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, - `test "\${TMP-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, - `test "\${TEMP-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, - 'test "${CLAUDE_CODE_TMPDIR-unset}" = ".cph/t"', - `test "$(realpath "$TMPDIR")" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, - `printf 'workspace-temp\\n' > "$TMPDIR/effective-temp.txt"`, + 'test "${TMPDIR-unset}" = "${TMP-unset}"', + 'test "${TMPDIR-unset}" = "${TEMP-unset}"', + 'test "${TMPDIR-unset}" = "${CLAUDE_CODE_TMPDIR-unset}"', + 'test "${#TMPDIR}" -le 64', + `case "$TMPDIR" in ${shellQuote(canonicalWorkspace)}/*) exit 35;; esac`, + `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-var-temp\\n' > ${shellQuote(hostVarTmpEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 34; fi`, 'test "${DATABASE_URL-unset}" = unset', @@ -159,7 +158,6 @@ describe("real Claude SDK sandbox boundary", () => { }); expect(resultContents).toBe("sandbox-ok\n"); await expect(readFile(cphVersionPath, "utf8")).resolves.toMatch(/^cph /); - await expect(readFile(effectiveTempProbePath, "utf8")).resolves.toBe("workspace-temp\n"); const denialLog = await readFile(denialLogPath, "utf8"); expect(denialLog).not.toContain("sibling-secret"); expect(denialLog).not.toContain("platform-secret"); diff --git a/hub/test/unit/agent-security.test.ts b/hub/test/unit/agent-security.test.ts index 972069b..3ba23ef 100644 --- a/hub/test/unit/agent-security.test.ts +++ b/hub/test/unit/agent-security.test.ts @@ -46,7 +46,11 @@ describe("agent subprocess security policy", () => { expect(policy.env).not.toHaveProperty("HUB_SESSION_SECRET"); expect(policy.env.HOME).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({ enabled: true, @@ -54,7 +58,7 @@ describe("agent subprocess security policy", () => { autoAllowBashIfSandboxed: true, allowUnsandboxedCommands: false, filesystem: { - allowWrite: [canonicalWorkspace], + allowWrite: expect.arrayContaining([canonicalWorkspace, policy.env.TMPDIR]), denyRead: ["/"], allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]), }, @@ -81,7 +85,7 @@ describe("agent subprocess security policy", () => { })).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 policy = await createAgentSecurityPolicy({ workspaceRoot, @@ -89,14 +93,16 @@ describe("agent subprocess security policy", () => { hostEnv: { PATH: "/usr/bin:/bin" }, }); - expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(join(".cph", "t")); - const absoluteTemp = join(await realpath(workspace), ".cph", "t"); - expect(policy.env.TMPDIR).toBe(absoluteTemp); - expect(policy.env.TMP).toBe(absoluteTemp); - expect(policy.env.TEMP).toBe(absoluteTemp); - expect(policy.sandbox.filesystem.allowWrite).toEqual([await realpath(workspace)]); + const canonicalWorkspace = await realpath(workspace); + const temp = policy.env.TMPDIR!; + expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(temp); + expect(policy.env.TMP).toBe(temp); + expect(policy.env.TEMP).toBe(temp); + 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.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 () => { diff --git a/hub/test/unit/runner.test.ts b/hub/test/unit/runner.test.ts index 059ae89..47e8ee0 100644 --- a/hub/test/unit/runner.test.ts +++ b/hub/test/unit/runner.test.ts @@ -108,7 +108,7 @@ describe("runAgent", () => { failIfUnavailable: true, allowUnsandboxedCommands: false, filesystem: expect.objectContaining({ - allowWrite: [workspace], + allowWrite: expect.arrayContaining([workspace]), denyRead: ["/"], allowRead: expect.arrayContaining([workspace]), }),