fix: keep agent sandbox sockets on short paths

This commit is contained in:
2026-07-11 02:22:19 +08:00
parent 38d9a9c6cb
commit 035c264179
7 changed files with 72 additions and 44 deletions
+34 -11
View File
@@ -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<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[] {
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;