fix: restore bounded agent sandbox execution

This commit is contained in:
2026-07-11 02:27:05 +08:00
parent 035c264179
commit 7fcb57013e
14 changed files with 137 additions and 82 deletions
+23 -1
View File
@@ -29,7 +29,7 @@
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
* is the mechanism, the contract pins the invariant.
*/
import { query, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import { query, type HookCallback, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import type { PrismaClient } from "@prisma/client";
import { claudeSdkToolConfigForRole } from "./roleTools.js";
import { createAgentSecurityPolicy } from "./security.js";
@@ -96,6 +96,22 @@ export interface RunResult {
const DEFAULT_MAX_TURNS = 25;
const denyUnsandboxedBash: HookCallback = async (input) => {
if (input.hook_event_name !== "PreToolUse" || input.tool_name !== "Bash") return {};
const toolInput = input.tool_input;
if (
typeof toolInput !== "object" || toolInput === null ||
!("dangerouslyDisableSandbox" in toolInput) || toolInput.dangerouslyDisableSandbox !== true
) return {};
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "This deployment requires every Bash command to remain sandboxed.",
},
};
};
export async function runAgent(req: RunRequest): Promise<RunResult> {
const onStream = req.onStream;
const cap = req.maxTurns ?? DEFAULT_MAX_TURNS;
@@ -151,6 +167,12 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
// settings that could widen tools, hooks, MCP servers, or sandbox paths.
settingSources: [],
strictMcpConfig: true,
// Claude Code 2.1.202 can honor the per-call opt-out despite
// sandbox.allowUnsandboxedCommands=false. Enforce the invariant again at
// the PreToolUse boundary, before the Bash process can be spawned.
hooks: {
PreToolUse: [{ matcher: "Bash", hooks: [denyUnsandboxedBash] }],
},
};
if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
if (req.model !== undefined) options.model = req.model;
+6 -18
View File
@@ -1,4 +1,3 @@
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";
@@ -30,7 +29,7 @@ const SANDBOX_HIDDEN_ENV_KEYS = [
// 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;
const MAX_AGENT_TMP_PREFIX_BYTES = 56;
export interface AgentSecurityInput {
readonly workspaceRoot: string;
@@ -78,7 +77,8 @@ 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"]);
const agentTmp = await resolveShortAgentTemp(workspaceDir);
const agentTmp = await ensureDirectoryTree(cphRoot, ["t"]);
assertShortAgentTemp(agentTmp);
const path = hostEnv["PATH"]?.trim();
if (path === undefined || path === "") {
@@ -125,10 +125,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
autoAllowBashIfSandboxed: true,
allowUnsandboxedCommands: false,
filesystem: {
// 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],
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.
@@ -137,7 +134,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, agentTmp, ...runtimeReadPaths],
allowRead: [workspaceDir, ...runtimeReadPaths],
},
credentials: {
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })),
@@ -147,22 +144,13 @@ 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]);
function assertShortAgentTemp(agentTmp: string): void {
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[] {