fix: prove Linux Agent sandbox boundary

This commit is contained in:
2026-07-10 17:59:56 +08:00
parent f4968d6657
commit f07f280b8f
19 changed files with 381 additions and 55 deletions
+22 -5
View File
@@ -21,7 +21,10 @@ const SAFE_HOST_ENV_KEYS = [
"CPH_BIN",
] as const;
const PROVIDER_SECRET_ENV_KEYS = ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] as const;
const SANDBOX_HIDDEN_ENV_KEYS = [
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY",
] as const;
export interface AgentSecurityInput {
readonly workspaceRoot: string;
@@ -37,6 +40,7 @@ export interface AgentSandboxPolicy {
readonly allowUnsandboxedCommands: false;
readonly filesystem: {
readonly allowWrite: string[];
readonly denyWrite: string[];
readonly denyRead: string[];
readonly allowRead: string[];
};
@@ -61,12 +65,16 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
const hostEnv = input.hostEnv ?? process.env;
const { workspaceRoot, workspaceDir } = await resolveProjectWorkspace(input.workspaceRoot, input.workspaceDir);
const runtimeRoot = await ensureDirectoryTree(workspaceDir, [".cph", "agent-runtime"]);
const cphRoot = await ensureDirectoryTree(workspaceDir, [".cph"]);
const runtimeRoot = await ensureDirectoryTree(cphRoot, ["agent-runtime"]);
const agentHome = await ensureDirectoryTree(runtimeRoot, ["home"]);
const agentCache = await ensureDirectoryTree(runtimeRoot, ["cache"]);
const agentConfig = await ensureDirectoryTree(runtimeRoot, ["config"]);
const agentState = await ensureDirectoryTree(runtimeRoot, ["state"]);
const agentTmp = await ensureDirectoryTree(runtimeRoot, ["tmp"]);
// 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 path = hostEnv["PATH"]?.trim();
if (path === undefined || path === "") {
@@ -82,11 +90,14 @@ 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.
env.TMPDIR = agentTmp;
env.TMP = agentTmp;
env.TEMP = agentTmp;
env.CLAUDE_CONFIG_DIR = agentConfig;
env.CLAUDE_CODE_TMPDIR = agentTmp;
env.CLAUDE_CODE_TMPDIR = claudeCodeTmp;
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
env.DISABLE_TELEMETRY = "1";
env.DO_NOT_TRACK = "1";
@@ -112,6 +123,10 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
allowUnsandboxedCommands: false,
filesystem: {
allowWrite: [workspaceDir],
// 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.
denyWrite: ["/"],
// Deny host reads by default, then re-open exactly this run's
// workspace plus the named system runtime needed to execute tools.
// SDK allowRead takes precedence over matching denyRead paths.
@@ -120,7 +135,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
},
credentials: {
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })),
envVars: PROVIDER_SECRET_ENV_KEYS.map((name) => ({ name, mode: "deny" as const })),
envVars: SANDBOX_HIDDEN_ENV_KEYS.map((name) => ({ name, mode: "deny" as const })),
},
},
};
@@ -144,8 +159,10 @@ function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>)
]
: [
"/bin",
"/sbin",
"/usr/bin",
"/usr/local/bin",
"/usr/sbin",
"/lib",
"/lib64",
"/usr/lib",