forked from bai/curriculum-project-hub
fix: restore bounded agent sandbox execution
This commit is contained in:
+23
-1
@@ -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;
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { dirname, relative, resolve } from "node:path";
|
||||
import type { Folder, OrganizationMemberRole, PermissionRole, Prisma, PrismaClient } from "@prisma/client";
|
||||
@@ -595,7 +595,11 @@ function projectWorkspaceDir(input: {
|
||||
readonly projectId: string;
|
||||
}): string {
|
||||
const root = resolve(requireNonEmpty(input.workspaceRoot, "workspace root"));
|
||||
const dir = resolve(root, safePathSegment(input.organizationSlug, "organization slug"), safePathSegment(input.projectId, "project id"));
|
||||
const dir = resolve(
|
||||
root,
|
||||
compactWorkspaceSegment("o", input.organizationSlug, "organization slug"),
|
||||
compactWorkspaceSegment("p", input.projectId, "project id"),
|
||||
);
|
||||
const rel = relative(root, dir);
|
||||
if (rel === "" || rel.startsWith("..")) {
|
||||
throw new Error(`allocated workspace escapes root: ${dir}`);
|
||||
@@ -603,6 +607,12 @@ function projectWorkspaceDir(input: {
|
||||
return dir;
|
||||
}
|
||||
|
||||
function compactWorkspaceSegment(prefix: "o" | "p", value: string, label: string): string {
|
||||
const normalized = safePathSegment(value, label);
|
||||
const digest = createHash("sha256").update(normalized).digest("base64url").slice(0, 16);
|
||||
return `${prefix}_${digest}`;
|
||||
}
|
||||
|
||||
function safePathSegment(value: string, label: string): string {
|
||||
const segment = requireNonEmpty(value, label).replace(/[^A-Za-z0-9._-]+/g, "_");
|
||||
if (segment === "." || segment === ".." || segment === "") {
|
||||
|
||||
Reference in New Issue
Block a user