From f4968d6657c5ce13233f01c51b493c6e32f3dfc0 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 10 Jul 2026 16:38:08 +0800 Subject: [PATCH] fix: confine Agent and MCP data access --- .gitea/workflows/hub-check.yml | 10 +- ...6-confine-agent-and-protect-credentials.md | 2 +- .../17-close-mcp-data-egress-escapes.md | 2 +- ...-execution-surface-bounded-by-workspace.md | 39 ++- hub/src/agent/runner.ts | 74 ++--- hub/src/agent/security.ts | 280 ++++++++++++++++ hub/src/feishu/client.ts | 82 ++++- hub/src/feishu/download.ts | 19 +- hub/src/feishu/fileDelivery.ts | 73 ++--- hub/src/feishu/fileDeliveryTool.ts | 74 ++++- hub/src/feishu/read.ts | 23 +- hub/src/feishu/trigger.ts | 46 ++- hub/src/security/workspaceFiles.ts | 301 ++++++++++++++++++ .../integration/agent-sandbox-linux.test.ts | 267 ++++++++++++++++ hub/test/integration/real-model.test.ts | 9 +- hub/test/integration/trigger.test.ts | 28 +- hub/test/unit/agent-security.test.ts | 126 ++++++++ hub/test/unit/feishu-approval.test.ts | 7 + hub/test/unit/feishu-client.test.ts | 59 +++- hub/test/unit/feishu-download.test.ts | 28 +- hub/test/unit/feishu-reactions.test.ts | 1 + hub/test/unit/feishu-read.test.ts | 55 ++++ hub/test/unit/feishu-retry.test.ts | 21 +- hub/test/unit/file-delivery.test.ts | 40 ++- hub/test/unit/runner.test.ts | 76 ++++- 25 files changed, 1521 insertions(+), 221 deletions(-) create mode 100644 hub/src/agent/security.ts create mode 100644 hub/src/security/workspaceFiles.ts create mode 100644 hub/test/integration/agent-sandbox-linux.test.ts create mode 100644 hub/test/unit/agent-security.test.ts diff --git a/.gitea/workflows/hub-check.yml b/.gitea/workflows/hub-check.yml index df4eea7..7f49894 100644 --- a/.gitea/workflows/hub-check.yml +++ b/.gitea/workflows/hub-check.yml @@ -43,6 +43,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Install Linux sandbox dependency + run: sudo apt-get update && sudo apt-get install --yes bubblewrap + - name: Wait for Postgres run: | node <<'NODE' @@ -85,6 +88,9 @@ jobs: cd .. cargo install --path crates/cph-cli --locked + - name: Prove real Claude SDK Bash sandbox boundary + run: /usr/bin/setpriv --no-new-privs npx vitest run test/integration/agent-sandbox-linux.test.ts + - name: Run unit tests run: npx vitest run test/unit @@ -93,7 +99,9 @@ jobs: - name: Run integration tests (mock provider, real prisma + cph) run: | npx prisma migrate deploy --schema prisma/schema.prisma - npx vitest run test/integration --exclude test/integration/real-model.test.ts + npx vitest run test/integration \ + --exclude test/integration/real-model.test.ts \ + --exclude test/integration/agent-sandbox-linux.test.ts env: DATABASE_URL: postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test diff --git a/.scratch/saas-production-readiness/issues/16-confine-agent-and-protect-credentials.md b/.scratch/saas-production-readiness/issues/16-confine-agent-and-protect-credentials.md index b36ea07..b174d7f 100644 --- a/.scratch/saas-production-readiness/issues/16-confine-agent-and-protect-credentials.md +++ b/.scratch/saas-production-readiness/issues/16-confine-agent-and-protect-credentials.md @@ -1,7 +1,7 @@ # Confine the agent runtime and protect service credentials Type: task -Status: open +Status: claimed ## Question diff --git a/.scratch/saas-production-readiness/issues/17-close-mcp-data-egress-escapes.md b/.scratch/saas-production-readiness/issues/17-close-mcp-data-egress-escapes.md index 8a3abf7..ca3a97e 100644 --- a/.scratch/saas-production-readiness/issues/17-close-mcp-data-egress-escapes.md +++ b/.scratch/saas-production-readiness/issues/17-close-mcp-data-egress-escapes.md @@ -1,7 +1,7 @@ # Close MCP context and file-delivery escape paths Type: task -Status: open +Status: claimed ## Question diff --git a/docs/adr/0018-agent-execution-surface-bounded-by-workspace.md b/docs/adr/0018-agent-execution-surface-bounded-by-workspace.md index 01b27fb..9227f70 100644 --- a/docs/adr/0018-agent-execution-surface-bounded-by-workspace.md +++ b/docs/adr/0018-agent-execution-surface-bounded-by-workspace.md @@ -92,12 +92,25 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox - `sandbox.enabled: true` + `failIfUnavailable: true` — the agent process and its Bash subprocesses run sandboxed; if the sandbox can't start, `query()` emits an error and exits rather than running unsandboxed. -- `sandbox.filesystem.allowWrite: [workspaceDir]` — writes confined to the - workspace (the ADR-0007 directory tree). Reads outside are allowed by - default (the agent may read system files for context), but sensitive host - paths are denied via `denyRead` (`/etc`, `/root`, `~/.ssh`, `~/.aws`, …). -- `sandbox.filesystem.denyRead: SENSITIVE_READ_PATHS` — defense-in-depth; - extends via `CPH_SANDBOX_EXTRA_DENY_READ` for deployment-specific secrets. +- `sandbox.allowUnsandboxedCommands: false` — a tool cannot opt out with the + SDK's `dangerouslyDisableSandbox` input. +- `sandbox.filesystem.denyRead: ["/"]` with `allowRead` for the canonical + current workspace and a small named system-runtime set — normal reads stay + in the run's workspace while `/bin`, shared libraries, CA certificates, + fonts and the configured `cph` executable remain available as the external + tool exception described above. +- `sandbox.filesystem.allowWrite: [workspaceDir]` — writes are confined to the + canonical ADR-0007 workspace; SDK temp/config/cache/home paths are relocated + beneath `.cph/agent-runtime/` in that same workspace. +- The SDK subprocess environment replaces rather than spreads `process.env`. + Only provider protocol variables and non-secret runtime variables cross the + boundary; database, Feishu and Hub session credentials never enter it. + `sandbox.credentials` denies provider token variables inside Bash and denies + configured credential files. `CPH_SANDBOX_EXTRA_DENY_READ` names additional + deployment-specific secret paths on the Hub side only. +- `settingSources: []` and strict MCP configuration prevent an untrusted + workspace or service-user config from widening tools, hooks, MCP servers, or + sandbox paths. - Network: open (see Open Questions). `bypassPermissions` is kept (headless server — no interactive prompts); the @@ -146,13 +159,13 @@ as it upholds `AgentFileOp.Authorized`. tradeoff for CLI integration simplicity. This is revisitable — a network allowlist (`sandbox.network.allowedDomains`) can be added without a spec change if the threat model tightens. -- **Sensitive read path coverage.** `SENSITIVE_READ_PATHS` in `runner.ts` - covers `/etc`, `/root`, `/var/log`, `/proc`, `/sys`, and common - credential dirs (`~/.ssh`, `~/.aws`, `~/.config/gcloud`, `~/.gnupg`). - Deployments with additional secret locations extend via - `CPH_SANDBOX_EXTRA_DENY_READ` (colon-separated). Whether this set should be - spec-pinned rather than implementation-chosen is `OPEN` — it's plumbing, not - a semantic divergence, so currently left out of `spec/`. +- **Named system-runtime read set.** The implementation allows only the + platform runtime directories/files needed for shell, dynamic libraries, + fonts, TLS/DNS and `cph` execution in addition to the project workspace. + Expanding that set requires an explicit reviewed code change. Deployments + name credential files with `CPH_SANDBOX_EXTRA_DENY_READ`; these paths are + protected through the SDK credential layer and are not passed to the Agent + environment. The exact runtime set remains plumbing and is not spec-pinned. - **Shell command vocabulary.** Whether the agent's Bash is restricted to a whitelist (e.g. `cph`, `ls`, `grep`) or allowed arbitrary commands whose *effects* are then confined, is a policy choice left to implementation. The diff --git a/hub/src/agent/runner.ts b/hub/src/agent/runner.ts index 82b259d..0414999 100644 --- a/hub/src/agent/runner.ts +++ b/hub/src/agent/runner.ts @@ -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 { const onStream = req.onStream; const cap = req.maxTurns ?? DEFAULT_MAX_TURNS; @@ -140,9 +115,19 @@ export async function runAgent(req: RunRequest): Promise { 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[0]["options"] = { - cwd: req.project.workspaceDir, + type QueryOptions = NonNullable[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 { 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; diff --git a/hub/src/agent/security.ts b/hub/src/agent/security.ts new file mode 100644 index 0000000..f3de580 --- /dev/null +++ b/hub/src/agent/security.ts @@ -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> | undefined; + readonly hostEnv?: Readonly> | 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; + 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 { + 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 = {}; + 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>): 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>): 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 { + 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 { + 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; +} diff --git a/hub/src/feishu/client.ts b/hub/src/feishu/client.ts index 4e3c884..902b21a 100644 --- a/hub/src/feishu/client.ts +++ b/hub/src/feishu/client.ts @@ -6,8 +6,12 @@ * reactions, file download/upload, and the card action callback. */ import * as lark from "@larksuiteoapi/node-sdk"; -import { readFile, mkdir } from "node:fs/promises"; -import { dirname } from "node:path"; +import { readFile } from "node:fs/promises"; +import type { Readable } from "node:stream"; +import { + WorkspaceFileBoundaryError, + writeNewWorkspaceFileNoFollow, +} from "../security/workspaceFiles.js"; import type { FastifyBaseLogger } from "fastify"; import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js"; import type { SenderNameCache } from "./senderCache.js"; @@ -486,20 +490,38 @@ export async function downloadMessageFile( rt: FeishuRuntime, messageId: string, fileKey: string, - savePath: string, + workspaceRoot: string, + workspaceDir: string, + workspaceRelativePath: string, resourceType: "image" | "file", -): Promise { - await mkdir(dirname(savePath), { recursive: true }); +): Promise { try { - await withRetry(async () => { + return await withRetry(async () => { const resource = await rt.client.im.v1.messageResource.get({ params: { type: resourceType }, path: { message_id: messageId, file_key: fileKey }, }); - await resource.writeFile(savePath); + const readableResource = resource as unknown as { getReadableStream: () => Readable }; + const source = readableResource.getReadableStream(); + try { + return await writeNewWorkspaceFileNoFollow( + workspaceRoot, + workspaceDir, + workspaceRelativePath, + source, + ); + } catch (error) { + source.destroy(); + throw error; + } + }, { + shouldRetry: (error) => !(error instanceof WorkspaceFileBoundaryError), }); } catch (e) { - rt.logger.error({ err: e, messageId, fileKey, resourceType, savePath }, "downloadMessageFile failed"); + rt.logger.error( + { err: e, messageId, fileKey, resourceType, workspaceRoot, workspaceDir, workspaceRelativePath }, + "downloadMessageFile failed", + ); throw e; } } @@ -512,33 +534,61 @@ export async function sendFile( fileName: string, options?: SendMessageOptions, ): Promise { + try { + return await sendFileData(rt, chatId, await readFile(filePath), fileName, options); + } catch (error) { + // Legacy callers model delivery as nullable. Keep that API while retaining + // the complete failure object logged by sendFileData (or log read failure). + if (!(error instanceof FeishuFileDeliveryError)) { + rt.logger.error({ chatId, filePath, fileName, err: error }, "sendFile failed before upload"); + } + return null; + } +} + +export class FeishuFileDeliveryError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "FeishuFileDeliveryError"; + } +} + +/** Upload and send an already-opened, no-follow file snapshot to a chat. */ +export async function sendFileData( + rt: FeishuRuntime, + chatId: string, + fileData: Buffer, + fileName: string, + options?: SendMessageOptions, +): Promise { const client = rt.client as unknown as { im: { v1: { file: { create: (p: unknown) => Promise } } }; }; try { - const buf = await readFile(filePath); const ext = fileName.split(".").pop() ?? ""; const fileType = ext === "pdf" ? "pdf" : ext === "doc" ? "doc" : ext === "xls" ? "xls" : ext === "ppt" ? "ppt" : "stream"; const uploadRes = await withRetry(async () => - client.im.v1.file.create({ data: { file_type: fileType, file_name: fileName, file: buf } }), + client.im.v1.file.create({ data: { file_type: fileType, file_name: fileName, file: fileData } }), ); const fileKey = fileKeyFromResponse(uploadRes); if (fileKey === undefined) { - rt.logger.warn({ chatId, filePath }, "sendFile failed: upload response missing file_key"); - return null; + throw new FeishuFileDeliveryError("Feishu file upload response is missing file_key"); } const messageId = await withRetry(async () => sendMessagePayload(rt, chatId, { msgType: "file", content: JSON.stringify({ file_key: fileKey }) }, options), ); if (messageId === null) { - rt.logger.warn({ chatId, filePath }, "sendFile failed: message response missing message_id"); + throw new FeishuFileDeliveryError("Feishu file message response is missing message_id"); } return messageId; - } catch (e) { - rt.logger.warn({ chatId, filePath, err: e instanceof Error ? e.message : String(e) }, "sendFile failed"); - return null; + } catch (error) { + const failure = error instanceof FeishuFileDeliveryError + ? error + : new FeishuFileDeliveryError("Feishu file delivery failed", { cause: error }); + rt.logger.error({ chatId, fileName, err: failure }, "sendFileData failed"); + throw failure; } } diff --git a/hub/src/feishu/download.ts b/hub/src/feishu/download.ts index 71c889f..84cf463 100644 --- a/hub/src/feishu/download.ts +++ b/hub/src/feishu/download.ts @@ -13,7 +13,7 @@ export interface DownloadedFeishuMessageResource extends FeishuMessageResourceAr readonly path: string; } -type DownloadContext = Pick; +type DownloadContext = Pick & { readonly workspaceRoot: string }; interface MessageLookupResult { readonly data?: { @@ -36,18 +36,29 @@ export async function downloadFeishuMessageResource( throw new Error(`Feishu message not found: ${args.messageId}`); } if (message.chat_id !== context.boundChatId) { + rt.logger.warn( + { messageId: args.messageId, boundChatId: context.boundChatId, returnedChatId: message.chat_id }, + "Feishu resource download refused outside bound chat", + ); throw new Error( `refused Feishu resource download: message ${args.messageId} is not in the current project's bound chat`, ); } const extension = args.resourceType === "image" ? ".png" : ".bin"; - const savePath = join( - context.workspaceDir, + const workspaceRelativePath = join( ".cph", "inbox", `feishu-${args.resourceType}-${randomUUID()}${extension}`, ); - await downloadMessageFile(rt, args.messageId, args.fileKey, savePath, args.resourceType); + const savePath = await downloadMessageFile( + rt, + args.messageId, + args.fileKey, + context.workspaceRoot, + context.workspaceDir, + workspaceRelativePath, + args.resourceType, + ); return { ...args, path: savePath }; } diff --git a/hub/src/feishu/fileDelivery.ts b/hub/src/feishu/fileDelivery.ts index a87e674..d48b19d 100644 --- a/hub/src/feishu/fileDelivery.ts +++ b/hub/src/feishu/fileDelivery.ts @@ -1,9 +1,13 @@ -import { access, stat } from "node:fs/promises"; -import { basename, extname, isAbsolute, join, relative, resolve } from "node:path"; +import { extname, isAbsolute, join } from "node:path"; +import { + WorkspaceFileBoundaryError, + readWorkspaceFileNoFollow, +} from "../security/workspaceFiles.js"; export interface DeliverableFile { readonly path: string; readonly name: string; + readonly data: Buffer; } const DELIVERABLE_EXTENSIONS = new Set([ @@ -28,6 +32,7 @@ const DELIVERABLE_EXTENSIONS = new Set([ export async function resolveDeliverableFile( requestedPath: string, + workspaceRoot: string, workspaceDir: string, ): Promise { const token = requestedPath.trim(); @@ -35,61 +40,25 @@ export async function resolveDeliverableFile( return null; } - const roots = await allowedRoots(workspaceDir); - for (const candidate of candidatePaths(token, workspaceDir, roots)) { - if (!isAllowedPath(candidate, roots)) continue; - const existing = await existingFile(candidate); - if (existing !== null) return { path: existing, name: basename(existing) }; + for (const candidate of candidatePaths(token)) { + try { + return await readWorkspaceFileNoFollow(workspaceRoot, workspaceDir, candidate); + } catch (error) { + if (!(error instanceof WorkspaceFileBoundaryError) || error.reason !== "not_found") { + throw error; + } + // Try the explicit workspace/build fallback for a bare filename. No + // candidate outside the project workspace is ever considered. + } } return null; } -async function allowedRoots(workspaceDir: string): Promise { - const roots = [resolve(workspaceDir)]; - const gitRoot = await findGitRoot(workspaceDir); - if (gitRoot !== undefined) roots.push(gitRoot); - return [...new Set(roots)]; -} - -async function findGitRoot(startDir: string): Promise { - let current = resolve(startDir); - while (true) { - try { - await access(join(current, ".git")); - return current; - } catch { - const parent = resolve(current, ".."); - if (parent === current) return undefined; - current = parent; - } - } -} - -function candidatePaths(token: string, workspaceDir: string, roots: readonly string[]): string[] { - if (isAbsolute(token)) return [resolve(token)]; - - const candidates: string[] = []; - for (const root of roots) { - candidates.push(resolve(root, token)); - } +function candidatePaths(token: string): string[] { + if (isAbsolute(token)) return [token]; + const candidates = [token]; if (!token.includes("/") && !token.includes("\\")) { - candidates.push(resolve(workspaceDir, "build", token)); + candidates.push(join("build", token)); } return [...new Set(candidates)]; } - -function isAllowedPath(path: string, roots: readonly string[]): boolean { - return roots.some((root) => { - const rel = relative(root, path); - return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); - }); -} - -async function existingFile(path: string): Promise { - try { - const s = await stat(path); - return s.isFile() ? resolve(path) : null; - } catch { - return null; - } -} diff --git a/hub/src/feishu/fileDeliveryTool.ts b/hub/src/feishu/fileDeliveryTool.ts index 5b309eb..1fc4672 100644 --- a/hub/src/feishu/fileDeliveryTool.ts +++ b/hub/src/feishu/fileDeliveryTool.ts @@ -1,17 +1,19 @@ import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk"; import { z } from "zod"; -import { sendApprovalCard, sendFile, type FeishuRuntime, type SendMessageOptions } from "./client.js"; +import { sendApprovalCard, sendFileData, type FeishuRuntime, type SendMessageOptions } from "./client.js"; import { resolveDeliverableFile } from "./fileDelivery.js"; import { downloadFeishuMessageResource } from "./download.js"; import { readFeishuContext } from "./read.js"; import type { ApprovalManager } from "./approval.js"; import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js"; +import { WorkspaceFileBoundaryError } from "../security/workspaceFiles.js"; export interface FileDeliveryToolOptions { readonly rt: FeishuRuntime; readonly chatId: string; readonly projectId: string; readonly runId: string; + readonly workspaceRoot?: string | undefined; readonly workspaceDir: string; readonly sendOptions?: SendMessageOptions | undefined; readonly approvalManager: ApprovalManager; @@ -27,13 +29,46 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M tools.push( tool( "send_file", - "Upload an existing project/repository file to the current Feishu chat. The path must point to a concrete file, for example build/student.pdf or README.md.", + "Upload an existing file from the current project workspace to the current Feishu chat. The path must point to a concrete no-symlink file, for example build/student.pdf or README.md.", { - path: z.string().describe("Workspace-relative, repository-relative, or absolute path to the existing file."), + path: z.string().describe("Workspace-relative path, or an absolute path physically inside the current workspace."), name: z.string().optional().describe("Optional display filename. Defaults to the file's basename."), }, async (args) => { - const file = await resolveDeliverableFile(args.path, options.workspaceDir); + const workspaceRoot = options.workspaceRoot?.trim(); + if (workspaceRoot === undefined || workspaceRoot === "") { + options.rt.logger.error( + { runId: options.runId, projectId: options.projectId }, + "Agent file delivery missing configured workspace root", + ); + return { + isError: true, + content: [{ type: "text", text: "File delivery is unavailable because workspace isolation is not configured." }], + }; + } + let file; + try { + file = await resolveDeliverableFile(args.path, workspaceRoot, options.workspaceDir); + } catch (error) { + const boundaryViolation = error instanceof WorkspaceFileBoundaryError && error.reason === "boundary"; + const log = boundaryViolation ? options.rt.logger.warn.bind(options.rt.logger) : options.rt.logger.error.bind(options.rt.logger); + log( + { + runId: options.runId, + projectId: options.projectId, + requestedPath: args.path, + err: error, + }, + boundaryViolation + ? "Agent file delivery refused by workspace boundary" + : "Agent file delivery failed during workspace file access", + ); + if (!boundaryViolation) throw error; + return { + isError: true, + content: [{ type: "text", text: "File path is outside the current workspace or uses a symlink." }], + }; + } if (file === null) { return { isError: true, @@ -41,13 +76,13 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M }; } - const messageId = await sendFile(options.rt, options.chatId, file.path, args.name ?? file.name, options.sendOptions); - if (messageId === null) { - return { - isError: true, - content: [{ type: "text", text: `Failed to send file: ${file.path}` }], - }; - } + const messageId = await sendFileData( + options.rt, + options.chatId, + file.data, + args.name ?? file.name, + options.sendOptions, + ); options.onDelivered?.(file.path); return { @@ -97,13 +132,28 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M resource_type: z.enum(["image", "file"]).describe("Use image for image_key and file for file_key."), }, async (args) => { + const workspaceRoot = options.workspaceRoot?.trim(); + if (workspaceRoot === undefined || workspaceRoot === "") { + options.rt.logger.error( + { runId: options.runId, projectId: options.projectId }, + "Feishu resource download missing configured workspace root", + ); + return { + isError: true, + content: [{ type: "text", text: "Resource download is unavailable because workspace isolation is not configured." }], + }; + } const downloaded = await downloadFeishuMessageResource( { messageId: args.message_id, fileKey: args.file_key, resourceType: args.resource_type, }, - { boundChatId: options.chatId, workspaceDir: options.workspaceDir }, + { + boundChatId: options.chatId, + workspaceRoot, + workspaceDir: options.workspaceDir, + }, options.rt, ); return { content: [{ type: "text", text: JSON.stringify(downloaded) }] }; diff --git a/hub/src/feishu/read.ts b/hub/src/feishu/read.ts index a4a732e..77f3ce2 100644 --- a/hub/src/feishu/read.ts +++ b/hub/src/feishu/read.ts @@ -48,12 +48,15 @@ function im(rt: FeishuRuntime): ImV1Message { /** Read on-demand Feishu context for the current run. Entry point for the tool. */ export async function readFeishuContext( args: FeishuContextArgs, - _ctx: ToolContext, + ctx: ToolContext, rt: unknown, ): Promise { const runtime = rt as FeishuRuntime; const api = im(runtime); try { + if (args.chat_id !== ctx.boundChatId) { + throw new Error("refused Feishu context read outside the current project's bound chat"); + } switch (args.anchor) { case "trigger_message": case "status_card": @@ -62,6 +65,7 @@ export async function readFeishuContext( const res = await api.get({ path: { message_id: args.id } }); const msg = res.data?.message ?? res.data?.items?.[0]; if (msg === undefined) return JSON.stringify({ error: "message not found", id: args.id }); + assertBoundChat(msg, ctx.boundChatId); return JSON.stringify(compact(msg)); } case "thread": { @@ -75,16 +79,33 @@ export async function readFeishuContext( }, }); const items = res.data?.items ?? []; + for (const item of items) assertBoundChat(item, ctx.boundChatId); return JSON.stringify(items.map(compact)); } default: return JSON.stringify({ error: `unknown anchor type: ${args.anchor as string}` }); } } catch (e) { + runtime.logger.warn( + { + runId: ctx.runId, + projectId: ctx.projectId, + anchor: args.anchor, + anchorId: args.id, + err: e instanceof Error ? e.message : String(e), + }, + "Feishu context read refused or failed", + ); return JSON.stringify({ error: e instanceof Error ? e.message : String(e) }); } } +function assertBoundChat(message: LarkMessage, boundChatId: string): void { + if (message.chat_id !== boundChatId) { + throw new Error("refused Feishu context result outside the current project's bound chat"); + } +} + /** Project a lark message down to the fields the model actually needs. */ function compact(m: LarkMessage): Record { const parentId = m.parent_id ?? m.parent_message_id; diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index 2048561..fc87517 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -72,7 +72,7 @@ interface TriggerDeps { readonly authorizer?: PermissionAuthorizer | undefined; readonly messageBatcherOptions?: MessageBatcherOptions | undefined; readonly triggerQueue?: TriggerQueue | undefined; - readonly projectWorkspaceRoot?: string | undefined; + readonly projectWorkspaceRoot: string; } interface TriggerActor { @@ -103,6 +103,8 @@ export interface TriggerHandler { * agent run is the long leg); the lock guarantees one run per project at a time. */ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { + const projectWorkspaceRoot = deps.projectWorkspaceRoot.trim(); + if (projectWorkspaceRoot === "") throw new Error("projectWorkspaceRoot is required"); const authorizer = deps.authorizer ?? createPermissionAuthorizer(deps.prisma); const runAgent = deps.runAgent ?? defaultRunAgent; const approvalManager = new ApprovalManager(); @@ -288,6 +290,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { const projectCtx: ProjectContext = { projectId, boundChatId: chatId, // ADR-0003: Feishu reads stay in this chat + workspaceRoot: projectWorkspaceRoot, workspaceDir: project.workspaceDir, }; @@ -314,6 +317,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { chatId, projectId, runId: run.id, + workspaceRoot: projectWorkspaceRoot, workspaceDir: project.workspaceDir, sendOptions, approvalManager, @@ -566,16 +570,13 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { try { if (action.action === "create_project_from_chat") { - if (deps.projectWorkspaceRoot === undefined || deps.projectWorkspaceRoot.trim() === "") { - throw new Error("HUB_PROJECT_WORKSPACE_ROOT is required for Feishu project creation"); - } const name = defaultProjectNameForChat(chatId); const project = await createProjectFromFeishuChat(deps.prisma, { organizationId: action.organization_id, actorFeishuOpenId: operatorOpenId, chatId, name, - workspaceRoot: deps.projectWorkspaceRoot, + workspaceRoot: projectWorkspaceRoot, folderId: action.folder_id, }); await resolveProjectOnboardingCard(rt, messageId, { @@ -771,6 +772,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { const downloadedResources = await downloadPostMessageFiles({ rt, msg, + workspaceRoot: projectWorkspaceRoot, workspaceDir: project.workspaceDir, imageKeys: parsed.imageKeys, fileKeys: parsed.fileKeys, @@ -797,8 +799,15 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { const key = content.file_key ?? content.image_key ?? ""; if (key !== "") { const fileName = `${msg.message_type}-${Date.now()}.${msg.message_type === "image" ? "png" : "bin"}`; - const savePath = `${project.workspaceDir}/.cph/inbox/${fileName}`; - await downloadMessageFile(rt, msg.message_id, key, savePath, msg.message_type); + const savePath = await downloadMessageFile( + rt, + msg.message_id, + key, + projectWorkspaceRoot, + project.workspaceDir, + `.cph/inbox/${fileName}`, + msg.message_type, + ); cleanPrompt = `用户发来一个文件: ${savePath}`; } } @@ -1298,6 +1307,7 @@ interface DownloadedMessageResource { async function downloadPostMessageFiles(args: { readonly rt: FeishuRuntime; readonly msg: MessageReceiveEvent["message"]; + readonly workspaceRoot: string; readonly workspaceDir: string; readonly imageKeys: readonly string[]; readonly fileKeys: readonly string[]; @@ -1305,13 +1315,27 @@ async function downloadPostMessageFiles(args: { const timestamp = Date.now(); const downloadedResources: DownloadedMessageResource[] = []; for (const [index, imageKey] of args.imageKeys.entries()) { - const savePath = `${args.workspaceDir}/.cph/inbox/post-image-${timestamp}-${index}.png`; - await downloadMessageFile(args.rt, args.msg.message_id, imageKey, savePath, "image"); + const savePath = await downloadMessageFile( + args.rt, + args.msg.message_id, + imageKey, + args.workspaceRoot, + args.workspaceDir, + `.cph/inbox/post-image-${timestamp}-${index}.png`, + "image", + ); downloadedResources.push({ resourceType: "image", path: savePath }); } for (const [index, fileKey] of args.fileKeys.entries()) { - const savePath = `${args.workspaceDir}/.cph/inbox/post-file-${timestamp}-${index}.bin`; - await downloadMessageFile(args.rt, args.msg.message_id, fileKey, savePath, "file"); + const savePath = await downloadMessageFile( + args.rt, + args.msg.message_id, + fileKey, + args.workspaceRoot, + args.workspaceDir, + `.cph/inbox/post-file-${timestamp}-${index}.bin`, + "file", + ); downloadedResources.push({ resourceType: "file", path: savePath }); } return downloadedResources; diff --git a/hub/src/security/workspaceFiles.ts b/hub/src/security/workspaceFiles.ts new file mode 100644 index 0000000..806f26c --- /dev/null +++ b/hub/src/security/workspaceFiles.ts @@ -0,0 +1,301 @@ +import { constants } from "node:fs"; +import { lstat, mkdir, open, realpath, unlink, type FileHandle } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import type { Readable } from "node:stream"; + +export class WorkspaceFileBoundaryError extends Error { + constructor( + message: string, + readonly requestedPath: string, + readonly reason: "not_found" | "boundary" | "io" = "boundary", + options?: ErrorOptions, + ) { + super(message, options); + this.name = "WorkspaceFileBoundaryError"; + } +} + +export interface WorkspaceFileSnapshot { + readonly path: string; + readonly name: string; + readonly data: Buffer; +} + +interface OpenDirectory { + readonly handle: FileHandle; + readonly displayPath: string; +} + +/** Read a stable snapshot without following any symlink below the workspace. */ +export async function readWorkspaceFileNoFollow( + workspaceRoot: string, + workspaceDir: string, + requestedPath: string, +): Promise { + const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir); + const components = fileComponents(workspace, requestedPath); + const name = components.at(-1)!; + const directories = components.slice(0, -1); + const parent = await openDirectoryTree(workspace, directories, false, requestedPath); + const openedPath = childPath(parent, name); + let file: FileHandle | undefined; + let result: WorkspaceFileSnapshot | undefined; + let failure: unknown; + try { + file = await open(openedPath, constants.O_RDONLY | constants.O_NOFOLLOW); + const metadata = await file.stat(); + if (!metadata.isFile()) { + throw new WorkspaceFileBoundaryError(`deliverable is not a regular file: ${requestedPath}`, requestedPath); + } + await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath); + const data = await file.readFile(); + result = { path: join(workspace, ...components), name, data }; + } catch (error) { + failure = boundaryError(error, requestedPath, "cannot read workspace file without following symlinks"); + } + await finishWithCleanup( + failure, + [file, parent.handle], + `workspace file read cleanup failed: ${requestedPath}`, + ); + return result!; +} + +/** + * Create one inbound file exclusively below the workspace and stream into its + * already-open descriptor. Linux uses /proc/self/fd-relative traversal so a + * concurrent directory rename/symlink swap cannot redirect the write. + */ +export async function writeNewWorkspaceFileNoFollow( + workspaceRoot: string, + workspaceDir: string, + requestedPath: string, + source: Readable, +): Promise { + const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir); + const components = fileComponents(workspace, requestedPath); + const name = components.at(-1)!; + const directories = components.slice(0, -1); + const parent = await openDirectoryTree(workspace, directories, true, requestedPath); + const openedPath = childPath(parent, name); + let file: FileHandle | undefined; + let device: number | undefined; + let inode: number | undefined; + let result: string | undefined; + let failure: unknown; + const cleanupFailures: unknown[] = []; + try { + file = await open( + openedPath, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ); + const metadata = await file.stat(); + device = metadata.dev; + inode = metadata.ino; + for await (const chunk of source) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array); + let offset = 0; + while (offset < buffer.length) { + const { bytesWritten } = await file.write(buffer, offset, buffer.length - offset, null); + if (bytesWritten === 0) throw new Error("inbound workspace write made no progress"); + offset += bytesWritten; + } + } + await file.sync(); + await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath); + result = join(workspace, ...components); + } catch (error) { + failure = boundaryError(error, requestedPath, "cannot create inbound workspace file safely"); + if (device !== undefined && inode !== undefined) { + try { + await unlinkIfSameFile(parent, name, device, inode); + } catch (cleanupError) { + cleanupFailures.push(cleanupError); + } + } + } + await finishWithCleanup( + failure, + [file, parent.handle], + `inbound workspace file cleanup failed: ${requestedPath}`, + cleanupFailures, + ); + return result!; +} + +async function canonicalWorkspace(workspaceRoot: string, workspaceDir: string): Promise { + if (process.platform !== "linux") { + throw new WorkspaceFileBoundaryError( + "race-safe descriptor-relative workspace access requires Linux", + workspaceDir, + ); + } + if (!isAbsolute(workspaceRoot) || !isAbsolute(workspaceDir)) { + throw new WorkspaceFileBoundaryError("workspace root and project path must be absolute", workspaceDir); + } + const configuredRoot = resolve(workspaceRoot); + const configuredWorkspace = resolve(workspaceDir); + const rel = relative(configuredRoot, configuredWorkspace); + if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new WorkspaceFileBoundaryError("project workspace escapes configured workspace root", workspaceDir); + } + const rootMetadata = await lstat(configuredRoot); + if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) { + throw new WorkspaceFileBoundaryError("configured workspace root is not a real directory", workspaceRoot); + } + const root = await realpath(configuredRoot); + const components = rel.split(sep); + const openedWorkspace = await openDirectoryTree(root, components, false, workspaceDir); + let result: string | undefined; + let failure: unknown; + try { + const openedTarget = await realpath(`/proc/self/fd/${openedWorkspace.handle.fd}`); + const expected = join(root, ...components); + if (openedTarget !== expected) { + throw new WorkspaceFileBoundaryError("project workspace contains a symlink", workspaceDir); + } + result = expected; + } catch (error) { + failure = boundaryError(error, workspaceDir, "project workspace validation failed"); + } + await finishWithCleanup( + failure, + [openedWorkspace.handle], + `project workspace validation cleanup failed: ${workspaceDir}`, + ); + return result!; +} + +function fileComponents(root: string, requestedPath: string): string[] { + const token = requestedPath.trim(); + if (token === "" || token.includes("\0")) { + throw new WorkspaceFileBoundaryError("workspace file path is empty or invalid", requestedPath); + } + const absolute = isAbsolute(token) ? resolve(token) : resolve(root, token); + const rel = relative(root, absolute); + if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new WorkspaceFileBoundaryError(`workspace file path escapes current project: ${requestedPath}`, requestedPath); + } + const components = rel.split(/[\\/]/); + if (components.some((component) => component === "" || component === "." || component === "..")) { + throw new WorkspaceFileBoundaryError(`workspace file path is not normalized: ${requestedPath}`, requestedPath); + } + return components; +} + +async function openDirectoryTree( + root: string, + components: readonly string[], + create: boolean, + requestedPath: string, +): Promise { + let current: OpenDirectory = { + handle: await open(root, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW), + displayPath: root, + }; + try { + for (const component of components) { + const path = childPath(current, component); + let next: FileHandle; + try { + next = await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); + } catch (error) { + if (!create || !isErrno(error, "ENOENT")) throw error; + try { + await mkdir(path, { mode: 0o700 }); + } catch (mkdirError) { + if (!isErrno(mkdirError, "EEXIST")) throw mkdirError; + } + next = await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); + } + const previous = current; + current = { handle: next, displayPath: join(previous.displayPath, component) }; + await previous.handle.close(); + } + return current; + } catch (error) { + try { + await current.handle.close(); + } catch (closeError) { + throw new AggregateError( + [error, closeError], + `workspace path validation and directory close both failed: ${requestedPath}`, + { cause: error }, + ); + } + throw boundaryError(error, requestedPath, "workspace path contains a symlink or non-directory component"); + } +} + +function childPath(parent: OpenDirectory, name: string): string { + return `/proc/self/fd/${parent.handle.fd}/${name}`; +} + +async function assertNameStillReferences( + parent: OpenDirectory, + name: string, + device: number, + inode: number, + requestedPath: string, +): Promise { + const metadata = await lstat(childPath(parent, name)); + if (metadata.isSymbolicLink() || metadata.dev !== device || metadata.ino !== inode) { + throw new WorkspaceFileBoundaryError( + `workspace file changed during no-follow access: ${requestedPath}`, + requestedPath, + ); + } +} + +async function unlinkIfSameFile(parent: OpenDirectory, name: string, device: number, inode: number): Promise { + const path = childPath(parent, name); + try { + const metadata = await lstat(path); + if (!metadata.isSymbolicLink() && metadata.dev === device && metadata.ino === inode) { + await unlink(path); + } + } catch (error) { + if (!isErrno(error, "ENOENT")) throw error; + } +} + +function boundaryError(error: unknown, requestedPath: string, prefix: string): WorkspaceFileBoundaryError { + if (error instanceof WorkspaceFileBoundaryError) return error; + const detail = error instanceof Error ? error.message : String(error); + const reason = isErrno(error, "ENOENT") + ? "not_found" + : isErrno(error, "ELOOP") || isErrno(error, "ENOTDIR") + ? "boundary" + : "io"; + return new WorkspaceFileBoundaryError( + `${prefix}: ${requestedPath} (${detail})`, + requestedPath, + reason, + { cause: error }, + ); +} + +async function finishWithCleanup( + primaryFailure: unknown, + handles: ReadonlyArray, + message: string, + additionalFailures: readonly unknown[] = [], +): Promise { + const settled = await Promise.allSettled( + handles.filter((handle): handle is FileHandle => handle !== undefined).map((handle) => handle.close()), + ); + const cleanupFailures = [ + ...additionalFailures, + ...settled.flatMap((result) => result.status === "rejected" ? [result.reason] : []), + ]; + if (primaryFailure !== undefined && cleanupFailures.length === 0) throw primaryFailure; + if (primaryFailure !== undefined || cleanupFailures.length > 0) { + const failures = primaryFailure === undefined ? cleanupFailures : [primaryFailure, ...cleanupFailures]; + throw new AggregateError(failures, message, { cause: primaryFailure ?? cleanupFailures[0] }); + } +} + +function isErrno(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error && error.code === code; +} diff --git a/hub/test/integration/agent-sandbox-linux.test.ts b/hub/test/integration/agent-sandbox-linux.test.ts new file mode 100644 index 0000000..50747df --- /dev/null +++ b/hub/test/integration/agent-sandbox-linux.test.ts @@ -0,0 +1,267 @@ +import { execFile } from "node:child_process"; +import { constants } from "node:fs"; +import { access, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; +import { runAgent, type StreamEvent } from "../../src/agent/runner.js"; + +const execFileAsync = promisify(execFile); +const originalEnv = new Map(); +const itOnLinux = process.platform === "linux" ? it : it.skip; + +describe("real Claude SDK sandbox boundary", () => { + const roots: string[] = []; + + afterEach(async () => { + for (const [name, value] of originalEnv) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + originalEnv.clear(); + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); + }); + + itOnLinux("denies sibling workspace, service files, credentials, and unsandboxed Bash while cph still works", async () => { + await access("/usr/bin/bwrap", constants.X_OK); + const noNewPrivilegesStatus = await readFile("/proc/self/status", "utf8"); + expect(noNewPrivilegesStatus).toMatch(/^NoNewPrivs:\s+1$/m); + + const { stdout: cphPathOutput } = await execFileAsync("/usr/bin/which", ["cph"]); + const cphBin = cphPathOutput.trim(); + await access(cphBin, constants.X_OK); + + // Keep CLAUDE_CODE_TMPDIR short enough for the SDK's AF_UNIX socket paths; + // otherwise the SDK deliberately falls back to shared /tmp. + const root = await mkdtemp("/tmp/cph-"); + roots.push(root); + const workspaceRoot = join(root, "workspaces"); + const workspace = join(workspaceRoot, "org-a", "project-a"); + const sibling = join(workspaceRoot, "org-b", "project-b"); + const serviceSecret = join(root, "service-secrets", "platform.env"); + await Promise.all([ + mkdir(workspace, { recursive: true }), + mkdir(sibling, { recursive: true }), + mkdir(join(root, "service-secrets"), { recursive: true }), + ]); + await Promise.all([ + writeFile(join(workspace, "allowed.txt"), "allowed\n"), + writeFile(join(sibling, "secret.txt"), "sibling-secret\n"), + writeFile(serviceSecret, "platform-secret\n"), + ]); + // macOS tmpdir is reached through /var -> /private/var. Exercise the + // sandbox with canonical paths, matching the canonical cwd returned by + // createAgentSecurityPolicy rather than relying on a host symlink alias. + const canonicalWorkspace = await realpath(workspace); + const canonicalSibling = await realpath(sibling); + const canonicalServiceSecret = await realpath(serviceSecret); + const resultPath = join(canonicalWorkspace, "sandbox-result.txt"); + const siblingEscapePath = join(canonicalSibling, "escape.txt"); + const unsandboxedEscapePath = join(await realpath(root), "unsandboxed-escape.txt"); + + setHostEnv({ + CPH_BIN: cphBin, + CPH_SANDBOX_EXTRA_DENY_READ: canonicalServiceSecret, + DATABASE_URL: "postgresql://platform-secret", + FEISHU_APP_SECRET: "feishu-secret", + HUB_SESSION_SECRET: "session-secret", + }); + + const bashCommand = [ + "set -eu", + `test "$(cat ${shellQuote(join(canonicalWorkspace, "allowed.txt"))})" = "allowed"`, + `if cat ${shellQuote(join(canonicalSibling, "secret.txt"))} >/dev/null 2>&1; then exit 21; fi`, + `if printf 'escape\\n' > ${shellQuote(siblingEscapePath)} 2>/dev/null; then exit 22; fi`, + `if cat ${shellQuote(canonicalServiceSecret)} >/dev/null 2>&1; then exit 23; fi`, + 'test "${DATABASE_URL-unset}" = unset', + 'test "${FEISHU_APP_SECRET-unset}" = unset', + 'test "${HUB_SESSION_SECRET-unset}" = unset', + 'test "${ANTHROPIC_AUTH_TOKEN-unset}" = unset', + 'test "${ANTHROPIC_API_KEY-unset}" = unset', + "cph --version | grep '^cph '", + `printf 'sandbox-ok\\n' > ${shellQuote(resultPath)}`, + ].join("\n"); + const bypassCommand = `printf 'unsafe\\n' > ${shellQuote(unsandboxedEscapePath)}`; + const stub = await startAnthropicStub(bypassCommand, bashCommand); + const streamEvents: StreamEvent[] = []; + try { + const result = await runAgent({ + prompt: "Run the supplied sandbox boundary probe.", + model: "claude-sonnet-4-20250514", + project: { + projectId: "project-a", + boundChatId: "chat-a", + workspaceRoot, + workspaceDir: workspace, + }, + systemPrompt: "Use the Bash tool exactly once, then report completion.", + providerEnv: { + ANTHROPIC_BASE_URL: stub.baseUrl, + ANTHROPIC_AUTH_TOKEN: "provider-secret", + ANTHROPIC_API_KEY: "provider-api-secret", + }, + tools: ["bash"], + maxTurns: 3, + runId: "sandbox-run", + sessionId: "sandbox-session", + prisma: { + projectAgentLock: { update: async () => ({}) }, + agentMessage: { create: async () => ({}) }, + } as unknown as import("@prisma/client").PrismaClient, + onStream: (event) => streamEvents.push(event), + }); + + expect(result).toMatchObject({ status: "completed" }); + expect(stub.requestCount()).toBeGreaterThanOrEqual(3); + const toolResults = streamEvents.filter((event) => event.type === "tool-result"); + expect(toolResults).toHaveLength(2); + expect(toolResults[0]).toMatchObject({ isError: true }); + const sandboxedResult = toolResults[1]; + expect(sandboxedResult?.type).toBe("tool-result"); + if (sandboxedResult?.type !== "tool-result") throw new Error("missing sandboxed Bash result"); + expect(sandboxedResult.isError, sandboxedResult.result).toBe(false); + await expect(access(unsandboxedEscapePath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(resultPath, "utf8")).resolves.toBe("sandbox-ok\n"); + await expect(access(siblingEscapePath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(join(canonicalSibling, "secret.txt"), "utf8")).resolves.toBe("sibling-secret\n"); + await expect(readFile(canonicalServiceSecret, "utf8")).resolves.toBe("platform-secret\n"); + } finally { + await stub.close(); + } + }, 60_000); +}); + +function setHostEnv(values: Readonly>): void { + for (const [name, value] of Object.entries(values)) { + if (!originalEnv.has(name)) originalEnv.set(name, process.env[name]); + process.env[name] = value; + } +} + +async function startAnthropicStub(bypassCommand: string, sandboxedCommand: string): Promise<{ + readonly baseUrl: string; + readonly requestCount: () => number; + readonly close: () => Promise; +}> { + let requests = 0; + const server = createServer(async (request, response) => { + try { + if (request.method !== "POST" || request.url?.startsWith("/v1/messages") !== true) { + response.writeHead(404).end(); + return; + } + const body = JSON.parse(await requestBody(request)) as { + readonly messages?: ReadonlyArray<{ readonly content?: unknown }>; + }; + requests++; + const events = requests === 1 + ? bashToolEvents(bypassCommand, requests, true) + : requests === 2 + ? bashToolEvents(sandboxedCommand, requests, false) + : finalTextEvents(requests); + writeAnthropicStream(response, events); + } catch (error) { + response.writeHead(500, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) })); + } + }); + server.listen(0, "127.0.0.1"); + await new Promise((resolve, reject) => { + server.once("listening", resolve); + server.once("error", reject); + }); + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("Anthropic stub did not bind an INET port"); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + requestCount: () => requests, + close: () => new Promise((resolve, reject) => { + server.close((error) => error === undefined ? resolve() : reject(error)); + }), + }; +} + +function bashToolEvents(command: string, sequence: number, disableSandbox: boolean): AnthropicEvent[] { + const messageId = `msg_tool_${sequence}`; + const toolUseId = `toolu_sandbox_probe_${sequence}`; + return [ + messageStart(messageId), + { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: toolUseId, name: "Bash", input: {} }, + }, + { + type: "content_block_delta", + index: 0, + delta: { + type: "input_json_delta", + partial_json: JSON.stringify({ command, ...(disableSandbox ? { dangerouslyDisableSandbox: true } : {}) }), + }, + }, + { type: "content_block_stop", index: 0 }, + messageDelta("tool_use"), + { type: "message_stop" }, + ]; +} + +function finalTextEvents(sequence: number): AnthropicEvent[] { + const messageId = `msg_final_${sequence}`; + return [ + messageStart(messageId), + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "sandbox probe complete" } }, + { type: "content_block_stop", index: 0 }, + messageDelta("end_turn"), + { type: "message_stop" }, + ]; +} + +type AnthropicEvent = Record & { readonly type: string }; + +function messageStart(messageId: string): AnthropicEvent { + return { + type: "message_start", + message: { + id: messageId, + type: "message", + role: "assistant", + model: "claude-sonnet-4-20250514", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 0 }, + }, + }; +} + +function messageDelta(stopReason: "tool_use" | "end_turn"): AnthropicEvent { + return { + type: "message_delta", + delta: { stop_reason: stopReason, stop_sequence: null }, + usage: { output_tokens: 1 }, + }; +} + +function writeAnthropicStream(response: ServerResponse, events: readonly AnthropicEvent[]): void { + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + for (const event of events) { + response.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`); + } + response.end(); +} + +async function requestBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} diff --git a/hub/test/integration/real-model.test.ts b/hub/test/integration/real-model.test.ts index a87bbb7..3b6dc34 100644 --- a/hub/test/integration/real-model.test.ts +++ b/hub/test/integration/real-model.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect } from "vitest"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import "dotenv/config"; import { runAgent } from "../../src/agent/runner.js"; import type { PrismaClient } from "@prisma/client"; @@ -47,7 +47,12 @@ describeOrSkip("real model integration (OpenRouter)", () => { sessionId: "real-test-session", prompt: "请只回复: OK", model: MODEL, - project: { projectId: "real-test-project", boundChatId: "chat-test", workspaceDir: workspace }, + project: { + projectId: "real-test-project", + boundChatId: "chat-test", + workspaceRoot: dirname(workspace), + workspaceDir: workspace, + }, prisma: stubPrisma, systemPrompt: "你是一个极简 smoke test 助手。请严格按用户要求回复。", maxTurns: 3, diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index a5306ed..aba7dc3 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -1,19 +1,29 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { Readable } from "node:stream"; import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest"; import { DEFAULT_ORG_ID, prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js"; import { InMemoryModelRegistry } from "../../src/agent/models.js"; -import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js"; +import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js"; import { TriggerQueue } from "../../src/feishu/triggerQueue.js"; import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.js"; import type { RunRequest, RunResult } from "../../src/agent/runner.js"; import type { RuntimeSettings } from "../../src/settings/runtime.js"; const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" }; +const itOnLinux = process.platform === "linux" ? it : it.skip; type TestRunner = (req: RunRequest) => Promise; const workspaceRoots: string[] = []; +type TestTriggerDeps = Omit[0], "projectWorkspaceRoot"> & { + readonly projectWorkspaceRoot?: string; +}; + +function makeTriggerHandler(deps: TestTriggerDeps): ReturnType { + return makeProductionTriggerHandler({ projectWorkspaceRoot: "/tmp", ...deps }); +} + function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings { return { async provider(providerId) { @@ -153,19 +163,15 @@ describe("trigger full lifecycle (integration)", () => { expect(run.costSource).toBe("provider_reported"); }); - it("includes downloaded post image paths in the agent prompt", async () => { + itOnLinux("includes downloaded post image paths in the agent prompt", async () => { const workspaceDir = await tempWorkspaceRoot(); await seedProject("proj-post-image", "chat-post-image"); await prisma.project.update({ where: { id: "proj-post-image" }, data: { workspaceDir }, }); - let downloadedPath: string | undefined; const messageResourceGet = vi.fn(async () => ({ - writeFile: async (filePath: string) => { - downloadedPath = filePath; - await writeFile(filePath, "image bytes"); - }, + getReadableStream: () => Readable.from([Buffer.from("image bytes")]), })); const imV1 = (rt.client as unknown as { im: { v1: { messageResource?: { get: typeof messageResourceGet } } }; @@ -192,6 +198,7 @@ describe("trigger full lifecycle (integration)", () => { settings, logger: silentLogger, runAgent, + projectWorkspaceRoot: dirname(workspaceDir), messageBatcherOptions: { maxMessages: 1 }, }); @@ -200,8 +207,7 @@ describe("trigger full lifecycle (integration)", () => { await vi.waitFor(() => { expect(runAgentCalls).toHaveLength(1); }); - expect(downloadedPath).toBeDefined(); - expect(runAgentCalls[0]?.prompt).toContain(downloadedPath); + expect(runAgentCalls[0]?.prompt).toContain(join(await realpath(workspaceDir), ".cph", "inbox")); expect(messageResourceGet).toHaveBeenCalledWith({ params: { type: "image" }, path: { message_id: event.message.message_id, file_key: "img-key-1" }, diff --git a/hub/test/unit/agent-security.test.ts b/hub/test/unit/agent-security.test.ts new file mode 100644 index 0000000..4574eff --- /dev/null +++ b/hub/test/unit/agent-security.test.ts @@ -0,0 +1,126 @@ +import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createAgentSecurityPolicy } from "../../src/agent/security.js"; + +describe("agent subprocess security policy", () => { + const roots: string[] = []; + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); + }); + + it("passes only provider and safe runtime variables and protects provider credentials from tools", async () => { + const { workspaceRoot, workspace } = await makeWorkspace(); + const policy = await createAgentSecurityPolicy({ + workspaceRoot, + workspaceDir: workspace, + providerEnv: { + ANTHROPIC_BASE_URL: "https://openrouter.ai/api", + ANTHROPIC_AUTH_TOKEN: "provider-secret", + ANTHROPIC_API_KEY: "", + }, + hostEnv: { + PATH: "/usr/local/bin:/usr/bin:/bin", + LANG: "C.UTF-8", + CPH_BIN: "/usr/local/bin/cph", + DATABASE_URL: "postgresql://platform-secret", + FEISHU_APP_SECRET: "feishu-secret", + HUB_SESSION_SECRET: "session-secret", + }, + }); + const canonicalWorkspace = await realpath(workspace); + + expect(policy.cwd).toBe(canonicalWorkspace); + expect(policy.env).toMatchObject({ + PATH: "/usr/local/bin:/usr/bin:/bin", + LANG: "C.UTF-8", + CPH_BIN: "/usr/local/bin/cph", + ANTHROPIC_BASE_URL: "https://openrouter.ai/api", + ANTHROPIC_AUTH_TOKEN: "provider-secret", + ANTHROPIC_API_KEY: "", + }); + expect(policy.env).not.toHaveProperty("DATABASE_URL"); + expect(policy.env).not.toHaveProperty("FEISHU_APP_SECRET"); + expect(policy.env).not.toHaveProperty("HUB_SESSION_SECRET"); + expect(policy.env.HOME).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`)); + expect(policy.env.CLAUDE_CONFIG_DIR).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`)); + expect(policy.env.CLAUDE_CODE_TMPDIR).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`)); + + expect(policy.sandbox).toMatchObject({ + enabled: true, + failIfUnavailable: true, + autoAllowBashIfSandboxed: true, + allowUnsandboxedCommands: false, + filesystem: { + allowWrite: [canonicalWorkspace], + denyRead: ["/"], + allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]), + }, + credentials: { + envVars: expect.arrayContaining([ + { name: "ANTHROPIC_AUTH_TOKEN", mode: "deny" }, + { name: "ANTHROPIC_API_KEY", mode: "deny" }, + ]), + }, + }); + }); + + it("rejects provider environment keys outside the explicit protocol", async () => { + const { workspaceRoot, workspace } = await makeWorkspace(); + + await expect(createAgentSecurityPolicy({ + workspaceRoot, + workspaceDir: workspace, + providerEnv: { + ANTHROPIC_AUTH_TOKEN: "provider-secret", + DATABASE_URL: "must-not-cross-boundary", + }, + hostEnv: { PATH: "/usr/bin:/bin" }, + })).rejects.toThrow("unsupported provider environment variable: DATABASE_URL"); + }); + + it("rejects a project workspace whose real path escapes the configured workspace root", async () => { + const { root, workspaceRoot } = await makeWorkspace(); + const outside = join(root, "outside"); + const linked = join(workspaceRoot, "org", "linked-project"); + await mkdir(outside); + await symlink(outside, linked); + + await expect(createAgentSecurityPolicy({ + workspaceRoot, + workspaceDir: linked, + providerEnv: { ANTHROPIC_AUTH_TOKEN: "provider-secret" }, + hostEnv: { PATH: "/usr/bin:/bin" }, + })).rejects.toThrow("project workspace contains a symlink"); + }); + + it("rejects a project workspace symlink whose target is a sibling under the same root", async () => { + const { workspaceRoot } = await makeWorkspace(); + const sibling = join(workspaceRoot, "org", "sibling-project"); + const linked = join(workspaceRoot, "org", "linked-project"); + await mkdir(sibling); + await symlink(sibling, linked); + + await expect(createAgentSecurityPolicy({ + workspaceRoot, + workspaceDir: linked, + providerEnv: { ANTHROPIC_AUTH_TOKEN: "provider-secret" }, + hostEnv: { PATH: "/usr/bin:/bin" }, + })).rejects.toThrow("symlink"); + }); + + async function makeWorkspace(): Promise<{ root: string; workspaceRoot: string; workspace: string }> { + const root = await mkdtemp(join(tmpdir(), "hub-agent-security-")); + roots.push(root); + const workspaceRoot = join(root, "workspaces"); + const workspace = join(workspaceRoot, "org", "project"); + await mkdir(workspace, { recursive: true }); + return { root, workspaceRoot, workspace }; + } +}); + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/hub/test/unit/feishu-approval.test.ts b/hub/test/unit/feishu-approval.test.ts index 40f05ba..0536740 100644 --- a/hub/test/unit/feishu-approval.test.ts +++ b/hub/test/unit/feishu-approval.test.ts @@ -109,6 +109,13 @@ describe("Feishu approval cards", () => { settings: {} as RuntimeSettings, logger: silentLogger(), authorizer: allowAllAuthorizer(), + projectWorkspaceRoot: "/tmp", + runAgent: async () => ({ + status: "completed", + text: "", + usage: { inputTokens: 0, outputTokens: 0 }, + numTurns: 0, + }), }); const result = trigger.approvalManager.register("message-1", "chat-1"); diff --git a/hub/test/unit/feishu-client.test.ts b/hub/test/unit/feishu-client.test.ts index 8faee93..437b31c 100644 --- a/hub/test/unit/feishu-client.test.ts +++ b/hub/test/unit/feishu-client.test.ts @@ -1,26 +1,31 @@ import { describe, expect, it, vi } from "vitest"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { access, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { Readable } from "node:stream"; import { downloadMessageFile, + FeishuFileDeliveryError, resolveSenderName, sendFile, + sendFileData, sendLongText, sendTextMessage, type FeishuRuntime, } from "../../src/feishu/client.js"; import { SenderNameCache } from "../../src/feishu/senderCache.js"; +const itOnLinux = process.platform === "linux" ? it : it.skip; + describe("Feishu client helpers", () => { - it("downloads an image message resource through the SDK stream helper", async () => { + itOnLinux("downloads an image message resource through the SDK stream helper", async () => { const dir = await mkdtemp(join(tmpdir(), "hub-feishu-client-")); try { - const destination = join(dir, "image.png"); - const resourceWriteFile = vi.fn(async (filePath: string) => { - await writeFile(filePath, "image bytes"); - }); - const messageResourceGet = vi.fn(async () => ({ writeFile: resourceWriteFile })); + const workspace = join(dir, "workspace"); + await mkdir(workspace); + const destination = join(workspace, ".cph", "inbox", "image.png"); + const getReadableStream = vi.fn(() => Readable.from([Buffer.from("image bytes")])); + const messageResourceGet = vi.fn(async () => ({ getReadableStream })); const rawRequest = vi.fn(async () => Buffer.from("image bytes")); const rt = mockRuntime({ fileCreate: vi.fn(), @@ -29,13 +34,15 @@ describe("Feishu client helpers", () => { request: rawRequest, }); - await downloadMessageFile(rt, "message-1", "img-key-1", destination, "image"); + await expect( + downloadMessageFile(rt, "message-1", "img-key-1", dir, workspace, ".cph/inbox/image.png", "image"), + ).resolves.toBe(join(await realpath(workspace), ".cph", "inbox", "image.png")); expect(messageResourceGet).toHaveBeenCalledWith({ params: { type: "image" }, path: { message_id: "message-1", file_key: "img-key-1" }, }); - expect(resourceWriteFile).toHaveBeenCalledWith(destination); + expect(getReadableStream).toHaveBeenCalledTimes(1); await expect(readFile(destination, "utf8")).resolves.toBe("image bytes"); expect(rawRequest).not.toHaveBeenCalled(); } finally { @@ -43,6 +50,27 @@ describe("Feishu client helpers", () => { } }); + itOnLinux("refuses an inbound download through a symlinked workspace directory", async () => { + const root = await mkdtemp(join(tmpdir(), "hub-feishu-client-")); + try { + const workspace = join(root, "workspace"); + const outside = join(root, "outside"); + await Promise.all([mkdir(workspace), mkdir(outside)]); + await symlink(outside, join(workspace, ".cph")); + const messageResourceGet = vi.fn(async () => ({ + getReadableStream: () => Readable.from([Buffer.from("must-not-escape")]), + })); + const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate: vi.fn(), messageResourceGet }); + + await expect( + downloadMessageFile(rt, "message-1", "file-key-1", root, workspace, ".cph/inbox/file.bin", "file"), + ).rejects.toThrow(/workspace|symlink|directory/i); + await expect(access(join(outside, "inbox", "file.bin"))).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("accepts top-level file_key from the Lark SDK file upload response", async () => { const dir = await mkdtemp(join(tmpdir(), "hub-feishu-client-")); try { @@ -62,6 +90,19 @@ describe("Feishu client helpers", () => { } }); + it("rejects malformed file upload responses instead of hiding the failure", async () => { + const rt = mockRuntime({ + fileCreate: vi.fn(async () => ({ data: {} })), + messageCreate: vi.fn(), + }); + + await expect(sendFileData(rt, "chat-1", Buffer.from("bytes"), "student.pdf")) + .rejects.toEqual(expect.objectContaining({ + name: "FeishuFileDeliveryError", + message: "Feishu file upload response is missing file_key", + } satisfies Partial)); + }); + it("accepts top-level message_id from message create responses", async () => { const rt = mockRuntime({ fileCreate: vi.fn(), diff --git a/hub/test/unit/feishu-download.test.ts b/hub/test/unit/feishu-download.test.ts index a822bb7..225d3f4 100644 --- a/hub/test/unit/feishu-download.test.ts +++ b/hub/test/unit/feishu-download.test.ts @@ -1,11 +1,14 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { Readable } from "node:stream"; import { describe, expect, it, vi } from "vitest"; import { claudeSdkToolConfigForRole, cphHubMcpToolsForRole } from "../../src/agent/roleTools.js"; import type { FeishuRuntime } from "../../src/feishu/client.js"; import { downloadFeishuMessageResource } from "../../src/feishu/download.js"; +const itOnLinux = process.platform === "linux" ? it : it.skip; + describe("Feishu message resource download", () => { it("exposes the download tool to default and explicitly configured roles", () => { expect(cphHubMcpToolsForRole(undefined)).toContain("feishu_download_resource"); @@ -15,24 +18,27 @@ describe("Feishu message resource download", () => { ]); }); - it("downloads a bound-chat image into the project inbox", async () => { - const workspaceDir = await mkdtemp(join(tmpdir(), "hub-feishu-download-")); + itOnLinux("downloads a bound-chat image into the project inbox", async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), "hub-feishu-download-")); + const workspaceDir = join(workspaceRoot, "project"); + await mkdir(workspaceDir); try { const messageGet = vi.fn(async () => ({ data: { items: [{ chat_id: "chat-1" }] } })); - const resourceWriteFile = vi.fn(async (filePath: string) => { - await writeFile(filePath, "image bytes"); - }); - const messageResourceGet = vi.fn(async () => ({ writeFile: resourceWriteFile })); + const messageResourceGet = vi.fn(async () => ({ + getReadableStream: () => Readable.from([Buffer.from("image bytes")]), + })); const rt = mockRuntime(messageGet, messageResourceGet); const result = await downloadFeishuMessageResource( { messageId: "message-1", fileKey: "img-key-1", resourceType: "image" }, - { boundChatId: "chat-1", workspaceDir }, + { boundChatId: "chat-1", workspaceRoot, workspaceDir }, rt, ); expect(result).toMatchObject({ resourceType: "image" }); - expect(result.path).toMatch(new RegExp(`^${escapeRegExp(join(workspaceDir, ".cph", "inbox"))}`)); + expect(result.path).toMatch( + new RegExp(`^${escapeRegExp(join(await realpath(workspaceDir), ".cph", "inbox"))}`), + ); expect(result.path).toMatch(/\.png$/); await expect(readFile(result.path, "utf8")).resolves.toBe("image bytes"); expect(messageResourceGet).toHaveBeenCalledWith({ @@ -40,7 +46,7 @@ describe("Feishu message resource download", () => { path: { message_id: "message-1", file_key: "img-key-1" }, }); } finally { - await rm(workspaceDir, { recursive: true, force: true }); + await rm(workspaceRoot, { recursive: true, force: true }); } }); @@ -51,7 +57,7 @@ describe("Feishu message resource download", () => { await expect(downloadFeishuMessageResource( { messageId: "message-other", fileKey: "img-key-other", resourceType: "image" }, - { boundChatId: "chat-1", workspaceDir: "/tmp/project-1" }, + { boundChatId: "chat-1", workspaceRoot: "/tmp", workspaceDir: "/tmp/project-1" }, rt, )).rejects.toThrow("current project's bound chat"); expect(messageResourceGet).not.toHaveBeenCalled(); diff --git a/hub/test/unit/feishu-reactions.test.ts b/hub/test/unit/feishu-reactions.test.ts index 87c3316..c817ee1 100644 --- a/hub/test/unit/feishu-reactions.test.ts +++ b/hub/test/unit/feishu-reactions.test.ts @@ -102,6 +102,7 @@ async function triggerWithRunAgent( settings: mockSettings(), logger: rt.logger, authorizer: allowAllAuthorizer(), + projectWorkspaceRoot: "/tmp", runAgent, messageBatcherOptions: { maxMessages: 1 }, }); diff --git a/hub/test/unit/feishu-read.test.ts b/hub/test/unit/feishu-read.test.ts index 43fc302..d5e62a3 100644 --- a/hub/test/unit/feishu-read.test.ts +++ b/hub/test/unit/feishu-read.test.ts @@ -17,6 +17,7 @@ describe("readFeishuContext", () => { root_id: "m-root", parent_id: "m-parent", thread_id: "thread-1", + chat_id: "chat-1", msg_type: "text", body: { content: JSON.stringify({ text: "parent text" }) }, }, @@ -46,4 +47,58 @@ describe("readFeishuContext", () => { thread_id: "thread-1", }); }); + + it("refuses a single-message result from another chat", async () => { + const rt = runtimeWithMessages({ + get: [{ message_id: "m-other", chat_id: "chat-other", body: { content: "secret" } }], + list: [], + }); + + const raw = await readFeishuContext( + { chat_id: "chat-1", anchor: "trigger_message", id: "m-other" }, + { runId: "run-1", projectId: "proj-1", boundChatId: "chat-1", workspaceDir: "/tmp/proj-1" }, + rt, + ); + + expect(JSON.parse(raw)).toMatchObject({ error: expect.stringContaining("bound chat") }); + expect(raw).not.toContain("secret"); + }); + + it("refuses an entire thread result when any item lacks the bound chat id", async () => { + const rt = runtimeWithMessages({ + get: [], + list: [ + { message_id: "m-allowed", chat_id: "chat-1", body: { content: "allowed" } }, + { message_id: "m-unscoped", body: { content: "must-not-leak" } }, + ], + }); + + const raw = await readFeishuContext( + { chat_id: "chat-1", anchor: "thread", id: "thread-1" }, + { runId: "run-1", projectId: "proj-1", boundChatId: "chat-1", workspaceDir: "/tmp/proj-1" }, + rt, + ); + + expect(JSON.parse(raw)).toMatchObject({ error: expect.stringContaining("bound chat") }); + expect(raw).not.toContain("must-not-leak"); + }); }); + +function runtimeWithMessages(input: { + get: Array>; + list: Array>; +}): FeishuRuntime { + return { + client: { + im: { + v1: { + message: { + get: async () => ({ data: { items: input.get } }), + list: async () => ({ data: { items: input.list } }), + }, + }, + }, + }, + logger: { warn() {}, info() {}, error() {}, debug() {} }, + } as unknown as FeishuRuntime; +} diff --git a/hub/test/unit/feishu-retry.test.ts b/hub/test/unit/feishu-retry.test.ts index dd52d03..08c6f28 100644 --- a/hub/test/unit/feishu-retry.test.ts +++ b/hub/test/unit/feishu-retry.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vite import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { sendFile, withRetry, type FeishuRuntime } from "../../src/feishu/client.js"; +import { sendFile, sendFileData, withRetry, type FeishuRuntime } from "../../src/feishu/client.js"; describe("withRetry", () => { beforeEach(() => { @@ -127,6 +127,25 @@ describe("sendFile retry", () => { await rm(dir, { recursive: true, force: true }); } }); + + it("sendFileData preserves an exhausted upload failure as its cause", async () => { + vi.useFakeTimers(); + const cause = new Error("persistent upload failure"); + const fileCreate = vi.fn(async () => { throw cause; }); + const rt = mockRuntime({ fileCreate, messageCreate: vi.fn() }); + + const result = sendFileData(rt, "chat-1", Buffer.from("pdf bytes"), "student.pdf"); + const assertion = expect(result).rejects.toMatchObject({ + name: "FeishuFileDeliveryError", + cause, + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(2000); + + await assertion; + expect(fileCreate).toHaveBeenCalledTimes(3); + }); }); async function waitForCalls(mock: Mock, expectedCalls: number): Promise { diff --git a/hub/test/unit/file-delivery.test.ts b/hub/test/unit/file-delivery.test.ts index a775a1b..db5a5a0 100644 --- a/hub/test/unit/file-delivery.test.ts +++ b/hub/test/unit/file-delivery.test.ts @@ -1,36 +1,50 @@ import { describe, expect, it } from "vitest"; -import { mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { resolveDeliverableFile } from "../../src/feishu/fileDelivery.js"; +const itOnLinux = process.platform === "linux" ? it : it.skip; + describe("file delivery path resolution", () => { - it("resolves a repo-root file from a project workspace only when explicitly named", async () => { + itOnLinux("never resolves a Git-root file outside the current project workspace", async () => { const root = await makeRepo(); try { const workspace = join(root, "examples", "TH-141"); await writeFile(join(root, "README.md"), "# root readme\n"); - await expect(resolveDeliverableFile("README.md", workspace)).resolves.toEqual({ - path: join(root, "README.md"), - name: "README.md", - }); + await expect(resolveDeliverableFile("README.md", join(root, "examples"), workspace)).resolves.toBeNull(); } finally { await rm(root, { recursive: true, force: true }); } }); - it("resolves a bare build filename against workspace/build", async () => { + itOnLinux("resolves a bare build filename against workspace/build", async () => { const root = await makeRepo(); try { const workspace = join(root, "examples", "TH-141"); const pdf = join(workspace, "build", "student.pdf"); await writeFile(pdf, "pdf bytes"); - await expect(resolveDeliverableFile("student.pdf", workspace)).resolves.toEqual({ - path: pdf, - name: "student.pdf", - }); + const resolved = await resolveDeliverableFile("student.pdf", join(root, "examples"), workspace); + expect(resolved).toMatchObject({ path: await realpath(pdf), name: "student.pdf" }); + expect(resolved?.data.toString("utf8")).toBe("pdf bytes"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + itOnLinux("rejects a deliverable symlink even when its target exists", async () => { + const root = await makeRepo(); + try { + const workspace = join(root, "examples", "TH-141"); + const outside = join(root, "outside.pdf"); + await writeFile(outside, "outside secret"); + await symlink(outside, join(workspace, "build", "student.pdf")); + + await expect( + resolveDeliverableFile("student.pdf", join(root, "examples"), workspace), + ).rejects.toThrow(/symlink|no-follow|directory/i); } finally { await rm(root, { recursive: true, force: true }); } @@ -42,7 +56,9 @@ describe("file delivery path resolution", () => { const workspace = join(root, "examples", "TH-141"); await writeFile(join(workspace, "build", "student.pdf"), "pdf bytes"); - await expect(resolveDeliverableFile("cph build 生成 PDF 给我", workspace)).resolves.toBeNull(); + await expect( + resolveDeliverableFile("cph build 生成 PDF 给我", join(root, "examples"), workspace), + ).resolves.toBeNull(); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/hub/test/unit/runner.test.ts b/hub/test/unit/runner.test.ts index 591d84d..6c78426 100644 --- a/hub/test/unit/runner.test.ts +++ b/hub/test/unit/runner.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { runAgent } from "../../src/agent/runner.js"; const queryMock = vi.hoisted(() => vi.fn()); @@ -49,8 +52,30 @@ function abortableMessages(ac: AbortController, ...items: unknown[]) { } describe("runAgent", () => { - beforeEach(() => { + let root: string; + let workspaceRoot: string; + let workspace: string; + let previousSecrets: Record; + + beforeEach(async () => { queryMock.mockReset(); + root = await mkdtemp(join(tmpdir(), "hub-runner-")); + workspaceRoot = join(root, "workspaces"); + workspace = join(workspaceRoot, "org", "project"); + await mkdir(workspace, { recursive: true }); + workspaceRoot = await realpath(workspaceRoot); + workspace = await realpath(workspace); + previousSecrets = Object.fromEntries( + ["DATABASE_URL", "FEISHU_APP_SECRET", "HUB_SESSION_SECRET"].map((name) => [name, process.env[name]]), + ); + }); + + afterEach(async () => { + for (const [name, value] of Object.entries(previousSecrets)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + await rm(root, { recursive: true, force: true }); }); it("passes the previous Claude SDK session id through resume", async () => { @@ -59,7 +84,7 @@ describe("runAgent", () => { const result = await runAgent({ prompt: "再发一次", model: "anthropic/claude-sonnet-5", - project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, + project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace }, systemPrompt: undefined, resumeSessionId: "sdk-session-old", runId: "run-1", @@ -70,17 +95,22 @@ describe("runAgent", () => { expect(queryMock).toHaveBeenCalledWith({ prompt: "再发一次", options: expect.objectContaining({ - cwd: "/tmp/ws", + cwd: workspace, model: "anthropic/claude-sonnet-5", resume: "sdk-session-old", // ADR-0018: agent execution surface bounded by workspace. permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, + settingSources: [], + strictMcpConfig: true, sandbox: expect.objectContaining({ enabled: true, failIfUnavailable: true, + allowUnsandboxedCommands: false, filesystem: expect.objectContaining({ - allowWrite: ["/tmp/ws"], + allowWrite: [workspace], + denyRead: ["/"], + allowRead: expect.arrayContaining([workspace]), }), }), }), @@ -93,7 +123,7 @@ describe("runAgent", () => { await runAgent({ prompt: "你好", model: undefined, - project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, + project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace }, systemPrompt: undefined, runId: "run-1", sessionId: "hub-session-1", @@ -110,7 +140,7 @@ describe("runAgent", () => { await runAgent({ prompt: "审校一下", model: undefined, - project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, + project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace }, systemPrompt: undefined, tools: ["read_file", "cph_check", "send_file"], runId: "run-1", @@ -132,7 +162,7 @@ describe("runAgent", () => { await runAgent({ prompt: "只回答", model: undefined, - project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, + project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace }, systemPrompt: undefined, tools: [], runId: "run-1", @@ -154,7 +184,7 @@ describe("runAgent", () => { const result = await runAgent({ prompt: "算一下成本", model: undefined, - project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, + project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace }, systemPrompt: undefined, runId: "run-1", sessionId: "hub-session-1", @@ -164,20 +194,21 @@ describe("runAgent", () => { expect(result.costUsd).toBe(0.0042); }); - it("passes provider env per run without mutating process env", async () => { - delete process.env["CPH_TEST_PROVIDER_ENV_MUTATION"]; + it("passes only provider and safe runtime env without mutating process env", async () => { + process.env["DATABASE_URL"] = "postgresql://platform-secret"; + process.env["FEISHU_APP_SECRET"] = "feishu-secret"; + process.env["HUB_SESSION_SECRET"] = "session-secret"; queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1"))); await runAgent({ prompt: "你好", model: undefined, - project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, + project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace }, systemPrompt: undefined, providerEnv: { ANTHROPIC_BASE_URL: "https://openrouter.ai/api", ANTHROPIC_AUTH_TOKEN: "test-token", ANTHROPIC_API_KEY: "", - CPH_TEST_PROVIDER_ENV_MUTATION: "per-run", }, runId: "run-1", sessionId: "hub-session-1", @@ -191,11 +222,22 @@ describe("runAgent", () => { ANTHROPIC_BASE_URL: "https://openrouter.ai/api", ANTHROPIC_AUTH_TOKEN: "test-token", ANTHROPIC_API_KEY: "", - CPH_TEST_PROVIDER_ENV_MUTATION: "per-run", + }, + sandbox: { + credentials: { + envVars: expect.arrayContaining([ + { name: "ANTHROPIC_AUTH_TOKEN", mode: "deny" }, + { name: "ANTHROPIC_API_KEY", mode: "deny" }, + ]), + }, }, }, }); - expect(process.env["CPH_TEST_PROVIDER_ENV_MUTATION"]).toBeUndefined(); + const env = (call as { options: { env: Record } }).options.env; + expect(env).not.toHaveProperty("DATABASE_URL"); + expect(env).not.toHaveProperty("FEISHU_APP_SECRET"); + expect(env).not.toHaveProperty("HUB_SESSION_SECRET"); + expect(process.env["DATABASE_URL"]).toBe("postgresql://platform-secret"); }); it("persists structured user and assistant messages best-effort", async () => { @@ -209,7 +251,7 @@ describe("runAgent", () => { await runAgent({ prompt: "你好", model: undefined, - project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, + project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace }, systemPrompt: undefined, runId: "run-1", sessionId: "hub-session-1", @@ -241,7 +283,7 @@ describe("runAgent", () => { const runPromise = runAgent({ prompt: "长任务", model: undefined, - project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, + project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace }, systemPrompt: undefined, runId: "run-abort", sessionId: "hub-session-abort",