fix: confine Agent and MCP data access

This commit is contained in:
2026-07-10 16:38:08 +08:00
parent 73fa28a178
commit f4968d6657
25 changed files with 1521 additions and 221 deletions
+28 -46
View File
@@ -20,21 +20,23 @@
* ADR-0018: the agent execution surface is bounded by the run's workspace.
* The SDK sandbox (bubblewrap on Linux, seatbelt on macOS) confines the Claude
* Code process and its subprocesses at the OS level — writes are confined to
* the workspace (`sandbox.filesystem.allowWrite`), sensitive host paths are
* denied reads (`denyRead`), and `failIfUnavailable` hard-fails if the sandbox
* can't start (never run unsandboxed). This upholds `AgentFileOp.Authorized`
* the workspace, host reads are denied by default and re-opened only for the
* workspace plus named system runtimes, and `failIfUnavailable` hard-fails if
* the sandbox can't start. The subprocess gets a minimal environment and SDK
* credential protection removes provider secrets from Bash. This upholds `AgentFileOp.Authorized`
* (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
* is the mechanism, the contract pins the invariant.
*/
import { homedir } from "node:os";
import { query, 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";
export interface ProjectContext {
readonly projectId: string;
readonly boundChatId: string;
readonly workspaceRoot?: string | undefined;
readonly workspaceDir: string;
}
@@ -90,33 +92,6 @@ export interface RunResult {
const DEFAULT_MAX_TURNS = 25;
/**
* Host paths the agent may not read (ADR-0018 defense-in-depth). The sandbox
* confines writes to the workspace; these deny reads of credentials/secrets
* the process could otherwise see (read-only mounts are still readable). The
* workspace itself is never in this list — it's writable by `allowWrite`.
* Extend via `CPH_SANDBOX_EXTRA_DENY_READ` (colon-separated) for deployments
* with additional secret locations.
*/
const SENSITIVE_READ_PATHS: readonly string[] = (() => {
const home = homedir();
const base = [
"/etc",
"/root",
"/var/log",
"/proc",
"/sys",
`${home}/.ssh`,
`${home}/.aws`,
`${home}/.config/gcloud`,
`${home}/.gnupg`,
];
const extra = process.env["CPH_SANDBOX_EXTRA_DENY_READ"];
return extra !== undefined && extra !== ""
? [...base, ...extra.split(":").filter((p) => p !== "")]
: base;
})();
export async function runAgent(req: RunRequest): Promise<RunResult> {
const onStream = req.onStream;
const cap = req.maxTurns ?? DEFAULT_MAX_TURNS;
@@ -140,9 +115,19 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
try {
await persistAgentMessage(req, "user", req.prompt);
const toolConfig = claudeSdkToolConfigForRole(req.tools);
const workspaceRoot = req.project.workspaceRoot?.trim();
if (workspaceRoot === undefined || workspaceRoot === "") {
throw new Error("Agent run requires the configured workspace root");
}
const security = await createAgentSecurityPolicy({
workspaceRoot,
workspaceDir: req.project.workspaceDir,
providerEnv: req.providerEnv,
});
const options: Parameters<typeof query>[0]["options"] = {
cwd: req.project.workspaceDir,
type QueryOptions = NonNullable<Parameters<typeof query>[0]["options"]>;
const options: QueryOptions = {
cwd: security.cwd,
tools: [...toolConfig.tools],
allowedTools: [...toolConfig.allowedTools],
maxTurns: cap,
@@ -152,22 +137,19 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
permissionMode: "bypassPermissions" as const,
allowDangerouslySkipPermissions: true,
// ADR-0018: OS-level sandbox confines the agent process + its Bash
// subprocesses. Writes confined to the workspace; sensitive host paths
// denied reads; network open. failIfUnavailable hard-fails if the
// sandbox can't start — never run unsandboxed.
sandbox: {
enabled: true,
failIfUnavailable: true,
autoAllowBashIfSandboxed: true,
filesystem: {
allowWrite: [req.project.workspaceDir],
denyRead: [...SENSITIVE_READ_PATHS],
},
},
// subprocesses. Writes and ordinary reads are confined to the workspace;
// named system runtime files remain readable so cph can execute. Network
// stays open. failIfUnavailable and allowUnsandboxedCommands=false make
// sandbox loss a hard failure.
sandbox: { ...security.sandbox },
env: security.env,
// The project workspace is untrusted input. Do not load user/project
// settings that could widen tools, hooks, MCP servers, or sandbox paths.
settingSources: [],
strictMcpConfig: true,
};
if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
if (req.model !== undefined) options.model = req.model;
if (req.providerEnv !== undefined) options.env = { ...process.env, ...req.providerEnv };
if (req.resumeSessionId !== undefined) options.resume = req.resumeSessionId;
if (req.mcpServers !== undefined) options.mcpServers = req.mcpServers;
if (req.abortController !== undefined) options.abortController = req.abortController;