forked from bai/curriculum-project-hub
fix: confine Agent and MCP data access
This commit is contained in:
+28
-46
@@ -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;
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
|
||||
const PROVIDER_ENV_KEYS = new Set([
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
]);
|
||||
|
||||
const SAFE_HOST_ENV_KEYS = [
|
||||
"PATH",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"TZ",
|
||||
"TERM",
|
||||
"USER",
|
||||
"LOGNAME",
|
||||
"SHELL",
|
||||
"CPH_BIN",
|
||||
] as const;
|
||||
|
||||
const PROVIDER_SECRET_ENV_KEYS = ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] as const;
|
||||
|
||||
export interface AgentSecurityInput {
|
||||
readonly workspaceRoot: string;
|
||||
readonly workspaceDir: string;
|
||||
readonly providerEnv?: Readonly<Record<string, string | undefined>> | undefined;
|
||||
readonly hostEnv?: Readonly<Record<string, string | undefined>> | undefined;
|
||||
}
|
||||
|
||||
export interface AgentSandboxPolicy {
|
||||
readonly enabled: true;
|
||||
readonly failIfUnavailable: true;
|
||||
readonly autoAllowBashIfSandboxed: true;
|
||||
readonly allowUnsandboxedCommands: false;
|
||||
readonly filesystem: {
|
||||
readonly allowWrite: string[];
|
||||
readonly denyRead: string[];
|
||||
readonly allowRead: string[];
|
||||
};
|
||||
readonly credentials: {
|
||||
readonly files: Array<{ readonly path: string; readonly mode: "deny" }>;
|
||||
readonly envVars: Array<{ readonly name: string; readonly mode: "deny" }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentSecurityPolicy {
|
||||
readonly cwd: string;
|
||||
readonly workspaceRoot: string;
|
||||
readonly env: Record<string, string | undefined>;
|
||||
readonly sandbox: AgentSandboxPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the complete Claude subprocess boundary from trusted run context.
|
||||
* This function intentionally replaces, rather than extends, process.env.
|
||||
*/
|
||||
export async function createAgentSecurityPolicy(input: AgentSecurityInput): Promise<AgentSecurityPolicy> {
|
||||
const hostEnv = input.hostEnv ?? process.env;
|
||||
const { workspaceRoot, workspaceDir } = await resolveProjectWorkspace(input.workspaceRoot, input.workspaceDir);
|
||||
|
||||
const runtimeRoot = await ensureDirectoryTree(workspaceDir, [".cph", "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"]);
|
||||
|
||||
const path = hostEnv["PATH"]?.trim();
|
||||
if (path === undefined || path === "") {
|
||||
throw new Error("PATH is required for the Agent subprocess");
|
||||
}
|
||||
|
||||
const env: Record<string, string | undefined> = {};
|
||||
for (const name of SAFE_HOST_ENV_KEYS) {
|
||||
const value = hostEnv[name];
|
||||
if (value !== undefined) env[name] = value;
|
||||
}
|
||||
env.HOME = agentHome;
|
||||
env.XDG_CACHE_HOME = agentCache;
|
||||
env.XDG_CONFIG_HOME = agentConfig;
|
||||
env.XDG_STATE_HOME = agentState;
|
||||
env.TMPDIR = agentTmp;
|
||||
env.TMP = agentTmp;
|
||||
env.TEMP = agentTmp;
|
||||
env.CLAUDE_CONFIG_DIR = agentConfig;
|
||||
env.CLAUDE_CODE_TMPDIR = agentTmp;
|
||||
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
||||
env.DISABLE_TELEMETRY = "1";
|
||||
env.DO_NOT_TRACK = "1";
|
||||
env.CLAUDE_AGENT_SDK_CLIENT_APP = "cph-hub/0.0.0";
|
||||
|
||||
for (const [name, value] of Object.entries(input.providerEnv ?? {})) {
|
||||
if (!PROVIDER_ENV_KEYS.has(name)) {
|
||||
throw new Error(`unsupported provider environment variable: ${name}`);
|
||||
}
|
||||
env[name] = value;
|
||||
}
|
||||
|
||||
const sensitiveReadPaths = hostSensitiveReadPaths(hostEnv);
|
||||
const runtimeReadPaths = hostRuntimeReadPaths(hostEnv);
|
||||
return {
|
||||
cwd: workspaceDir,
|
||||
workspaceRoot,
|
||||
env,
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
failIfUnavailable: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
allowUnsandboxedCommands: false,
|
||||
filesystem: {
|
||||
allowWrite: [workspaceDir],
|
||||
// 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.
|
||||
denyRead: ["/"],
|
||||
allowRead: [workspaceDir, ...runtimeReadPaths],
|
||||
},
|
||||
credentials: {
|
||||
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })),
|
||||
envVars: PROVIDER_SECRET_ENV_KEYS.map((name) => ({ name, mode: "deny" as const })),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
|
||||
const platformPaths = process.platform === "darwin"
|
||||
? [
|
||||
"/bin",
|
||||
"/usr/bin",
|
||||
"/usr/lib",
|
||||
"/usr/share",
|
||||
"/System/Library",
|
||||
"/Library/Fonts",
|
||||
"/private/etc/hosts",
|
||||
"/private/etc/resolv.conf",
|
||||
"/private/etc/ssl",
|
||||
"/dev/null",
|
||||
"/dev/random",
|
||||
"/dev/urandom",
|
||||
]
|
||||
: [
|
||||
"/bin",
|
||||
"/usr/bin",
|
||||
"/usr/local/bin",
|
||||
"/lib",
|
||||
"/lib64",
|
||||
"/usr/lib",
|
||||
"/usr/lib64",
|
||||
"/usr/share",
|
||||
"/etc/fonts",
|
||||
"/etc/hosts",
|
||||
"/etc/localtime",
|
||||
"/etc/nsswitch.conf",
|
||||
"/etc/resolv.conf",
|
||||
"/etc/ssl/certs",
|
||||
"/dev/null",
|
||||
"/dev/random",
|
||||
"/dev/urandom",
|
||||
];
|
||||
const cphBin = env["CPH_BIN"]?.trim();
|
||||
if (cphBin !== undefined && cphBin !== "") {
|
||||
if (!isAbsolute(cphBin)) throw new Error("CPH_BIN must be absolute for the Agent subprocess");
|
||||
platformPaths.push(resolve(cphBin));
|
||||
}
|
||||
return [...new Set(platformPaths.map((path) => resolve(path)))];
|
||||
}
|
||||
|
||||
function hostSensitiveReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
|
||||
const home = homedir();
|
||||
const paths = [
|
||||
join(home, ".ssh"),
|
||||
join(home, ".aws"),
|
||||
join(home, ".config", "gcloud"),
|
||||
join(home, ".gnupg"),
|
||||
];
|
||||
const extra = env["CPH_SANDBOX_EXTRA_DENY_READ"];
|
||||
if (extra !== undefined && extra !== "") {
|
||||
for (const path of extra.split(":")) {
|
||||
if (path === "") continue;
|
||||
if (!isAbsolute(path)) throw new Error("CPH_SANDBOX_EXTRA_DENY_READ entries must be absolute paths");
|
||||
paths.push(resolve(path));
|
||||
}
|
||||
}
|
||||
return [...new Set(paths.map((path) => resolve(path)))];
|
||||
}
|
||||
|
||||
async function requireDirectoryRealpath(path: string, label: string): Promise<string> {
|
||||
if (!isAbsolute(path)) throw new Error(`${label} must be an absolute path`);
|
||||
const configuredMetadata = await lstat(path);
|
||||
if (configuredMetadata.isSymbolicLink() || !configuredMetadata.isDirectory()) {
|
||||
throw new Error(`${label} is not a real directory: ${path}`);
|
||||
}
|
||||
const canonical = await realpath(path);
|
||||
const metadata = await lstat(canonical);
|
||||
if (!metadata.isDirectory()) throw new Error(`${label} is not a directory: ${path}`);
|
||||
return canonical;
|
||||
}
|
||||
|
||||
async function resolveProjectWorkspace(
|
||||
configuredRoot: string,
|
||||
configuredWorkspace: string,
|
||||
): Promise<{ workspaceRoot: string; workspaceDir: string }> {
|
||||
if (!isAbsolute(configuredRoot) || !isAbsolute(configuredWorkspace)) {
|
||||
throw new Error("workspace root and project workspace must be absolute paths");
|
||||
}
|
||||
const rootPath = resolve(configuredRoot);
|
||||
const workspacePath = resolve(configuredWorkspace);
|
||||
const lexicalRelative = relative(rootPath, workspacePath);
|
||||
if (
|
||||
lexicalRelative === "" ||
|
||||
lexicalRelative === ".." ||
|
||||
lexicalRelative.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) ||
|
||||
isAbsolute(lexicalRelative)
|
||||
) {
|
||||
throw new Error(`project workspace escapes configured workspace root: ${configuredWorkspace}`);
|
||||
}
|
||||
|
||||
const workspaceRoot = await requireDirectoryRealpath(rootPath, "workspace root");
|
||||
const components = lexicalRelative.split(process.platform === "win32" ? /[\\/]/ : "/");
|
||||
let current = rootPath;
|
||||
for (const component of components) {
|
||||
current = join(current, component);
|
||||
const metadata = await lstat(current);
|
||||
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
||||
throw new Error(`project workspace contains a symlink or non-directory component: ${current}`);
|
||||
}
|
||||
}
|
||||
const workspaceDir = await realpath(workspacePath);
|
||||
const expectedWorkspace = join(workspaceRoot, ...components);
|
||||
if (workspaceDir !== expectedWorkspace || !isStrictDescendant(workspaceRoot, workspaceDir)) {
|
||||
throw new Error(`project workspace escapes configured workspace root: ${configuredWorkspace}`);
|
||||
}
|
||||
return { workspaceRoot, workspaceDir };
|
||||
}
|
||||
|
||||
async function ensureDirectoryTree(root: string, components: readonly string[]): Promise<string> {
|
||||
let current = root;
|
||||
for (const component of components) {
|
||||
if (component === "" || component === "." || component === ".." || component.includes("/") || component.includes("\\")) {
|
||||
throw new Error(`invalid Agent runtime directory component: ${component}`);
|
||||
}
|
||||
current = join(current, component);
|
||||
try {
|
||||
const metadata = await lstat(current);
|
||||
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
||||
throw new Error(`Agent runtime path is not a real directory: ${current}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isErrno(error, "ENOENT")) throw error;
|
||||
try {
|
||||
await mkdir(current, { mode: 0o700 });
|
||||
} catch (mkdirError) {
|
||||
if (!isErrno(mkdirError, "EEXIST")) throw mkdirError;
|
||||
}
|
||||
const metadata = await lstat(current);
|
||||
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
||||
throw new Error(`Agent runtime path is not a real directory: ${current}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const canonical = await realpath(current);
|
||||
if (canonical !== current || !isStrictDescendant(root, canonical)) {
|
||||
throw new Error(`Agent runtime path escapes project workspace: ${current}`);
|
||||
}
|
||||
await chmod(canonical, 0o700);
|
||||
return canonical;
|
||||
}
|
||||
|
||||
function isStrictDescendant(parent: string, child: string): boolean {
|
||||
const candidate = relative(parent, child);
|
||||
return candidate !== "" && candidate !== ".." && !candidate.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && !isAbsolute(candidate);
|
||||
}
|
||||
|
||||
function isErrno(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error && error.code === code;
|
||||
}
|
||||
Reference in New Issue
Block a user