forked from EduCraft/curriculum-project-hub
2ee84d9543
This reverts commit 3087132083.
325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
|
|
import { homedir } from "node:os";
|
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
import type { RoleSkillEntry } from "./models.js";
|
|
import { prepareRunSkillPlugin, readSkillStoreRoot } from "./skillStore.js";
|
|
|
|
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;
|
|
|
|
// 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 = 56;
|
|
|
|
export interface AgentSecurityInput {
|
|
readonly runId: string;
|
|
readonly workspaceRoot: string;
|
|
readonly workspaceDir: string;
|
|
readonly skills?: readonly RoleSkillEntry[] | undefined;
|
|
/** Run-scoped loopback proxy capability; customer provider secrets are forbidden here. */
|
|
readonly providerProxyEnv?: 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 denyWrite: 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 skillIds: readonly string[];
|
|
readonly skillPluginRoot?: string | undefined;
|
|
cleanup(): Promise<void>;
|
|
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 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(cphRoot, ["t"]);
|
|
assertShortAgentTemp(agentTmp);
|
|
|
|
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;
|
|
// 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 = 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.providerProxyEnv ?? {})) {
|
|
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);
|
|
const selectedSkills = input.skills ?? [];
|
|
const skillPlugin = selectedSkills.length === 0
|
|
? null
|
|
: await prepareRunSkillPlugin({
|
|
storeRoot: readSkillStoreRoot(hostEnv),
|
|
runId: input.runId,
|
|
skills: selectedSkills,
|
|
});
|
|
return {
|
|
cwd: workspaceDir,
|
|
workspaceRoot,
|
|
env,
|
|
skillIds: skillPlugin?.skillIds ?? [],
|
|
...(skillPlugin !== null ? { skillPluginRoot: skillPlugin.root } : {}),
|
|
cleanup: skillPlugin?.cleanup ?? (async () => {}),
|
|
sandbox: {
|
|
enabled: true,
|
|
failIfUnavailable: true,
|
|
autoAllowBashIfSandboxed: true,
|
|
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.
|
|
denyRead: ["/"],
|
|
allowRead: [workspaceDir, ...(skillPlugin !== null ? [skillPlugin.root] : []), ...runtimeReadPaths],
|
|
},
|
|
credentials: {
|
|
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })),
|
|
// These values are short-lived loopback capabilities, not Organization
|
|
// provider secrets. Denying them also strips Claude's own request auth.
|
|
envVars: [],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
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}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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",
|
|
"/sbin",
|
|
"/usr/bin",
|
|
"/usr/local/bin",
|
|
"/usr/sbin",
|
|
"/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 its configured root: ${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;
|
|
}
|