fix: confine Agent and MCP data access

This commit is contained in:
2026-07-10 16:38:08 +08:00
parent 73fa28a178
commit f4968d6657
25 changed files with 1521 additions and 221 deletions
+9 -1
View File
@@ -43,6 +43,9 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Install Linux sandbox dependency
run: sudo apt-get update && sudo apt-get install --yes bubblewrap
- name: Wait for Postgres - name: Wait for Postgres
run: | run: |
node <<'NODE' node <<'NODE'
@@ -85,6 +88,9 @@ jobs:
cd .. cd ..
cargo install --path crates/cph-cli --locked 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 - name: Run unit tests
run: npx vitest run test/unit run: npx vitest run test/unit
@@ -93,7 +99,9 @@ jobs:
- name: Run integration tests (mock provider, real prisma + cph) - name: Run integration tests (mock provider, real prisma + cph)
run: | run: |
npx prisma migrate deploy --schema prisma/schema.prisma 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: env:
DATABASE_URL: postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test DATABASE_URL: postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test
@@ -1,7 +1,7 @@
# Confine the agent runtime and protect service credentials # Confine the agent runtime and protect service credentials
Type: task Type: task
Status: open Status: claimed
## Question ## Question
@@ -1,7 +1,7 @@
# Close MCP context and file-delivery escape paths # Close MCP context and file-delivery escape paths
Type: task Type: task
Status: open Status: claimed
## Question ## Question
@@ -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 - `sandbox.enabled: true` + `failIfUnavailable: true` — the agent process and
its Bash subprocesses run sandboxed; if the sandbox can't start, `query()` its Bash subprocesses run sandboxed; if the sandbox can't start, `query()`
emits an error and exits rather than running unsandboxed. emits an error and exits rather than running unsandboxed.
- `sandbox.filesystem.allowWrite: [workspaceDir]` — writes confined to the - `sandbox.allowUnsandboxedCommands: false` — a tool cannot opt out with the
workspace (the ADR-0007 directory tree). Reads outside are allowed by SDK's `dangerouslyDisableSandbox` input.
default (the agent may read system files for context), but sensitive host - `sandbox.filesystem.denyRead: ["/"]` with `allowRead` for the canonical
paths are denied via `denyRead` (`/etc`, `/root`, `~/.ssh`, `~/.aws`, …). current workspace and a small named system-runtime set — normal reads stay
- `sandbox.filesystem.denyRead: SENSITIVE_READ_PATHS` — defense-in-depth; in the run's workspace while `/bin`, shared libraries, CA certificates,
extends via `CPH_SANDBOX_EXTRA_DENY_READ` for deployment-specific secrets. 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). - Network: open (see Open Questions).
`bypassPermissions` is kept (headless server — no interactive prompts); the `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 tradeoff for CLI integration simplicity. This is revisitable — a network
allowlist (`sandbox.network.allowedDomains`) can be added without a spec allowlist (`sandbox.network.allowedDomains`) can be added without a spec
change if the threat model tightens. change if the threat model tightens.
- **Sensitive read path coverage.** `SENSITIVE_READ_PATHS` in `runner.ts` - **Named system-runtime read set.** The implementation allows only the
covers `/etc`, `/root`, `/var/log`, `/proc`, `/sys`, and common platform runtime directories/files needed for shell, dynamic libraries,
credential dirs (`~/.ssh`, `~/.aws`, `~/.config/gcloud`, `~/.gnupg`). fonts, TLS/DNS and `cph` execution in addition to the project workspace.
Deployments with additional secret locations extend via Expanding that set requires an explicit reviewed code change. Deployments
`CPH_SANDBOX_EXTRA_DENY_READ` (colon-separated). Whether this set should be name credential files with `CPH_SANDBOX_EXTRA_DENY_READ`; these paths are
spec-pinned rather than implementation-chosen is `OPEN` — it's plumbing, not protected through the SDK credential layer and are not passed to the Agent
a semantic divergence, so currently left out of `spec/`. environment. The exact runtime set remains plumbing and is not spec-pinned.
- **Shell command vocabulary.** Whether the agent's Bash is restricted to a - **Shell command vocabulary.** Whether the agent's Bash is restricted to a
whitelist (e.g. `cph`, `ls`, `grep`) or allowed arbitrary commands whose whitelist (e.g. `cph`, `ls`, `grep`) or allowed arbitrary commands whose
*effects* are then confined, is a policy choice left to implementation. The *effects* are then confined, is a policy choice left to implementation. The
+28 -46
View File
@@ -20,21 +20,23 @@
* ADR-0018: the agent execution surface is bounded by the run's workspace. * 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 * 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 * Code process and its subprocesses at the OS level — writes are confined to
* the workspace (`sandbox.filesystem.allowWrite`), sensitive host paths are * the workspace, host reads are denied by default and re-opened only for the
* denied reads (`denyRead`), and `failIfUnavailable` hard-fails if the sandbox * workspace plus named system runtimes, and `failIfUnavailable` hard-fails if
* can't start (never run unsandboxed). This upholds `AgentFileOp.Authorized` * 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 * (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox * `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
* is the mechanism, the contract pins the invariant. * 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 { 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 type { PrismaClient } from "@prisma/client";
import { claudeSdkToolConfigForRole } from "./roleTools.js"; import { claudeSdkToolConfigForRole } from "./roleTools.js";
import { createAgentSecurityPolicy } from "./security.js";
export interface ProjectContext { export interface ProjectContext {
readonly projectId: string; readonly projectId: string;
readonly boundChatId: string; readonly boundChatId: string;
readonly workspaceRoot?: string | undefined;
readonly workspaceDir: string; readonly workspaceDir: string;
} }
@@ -90,33 +92,6 @@ export interface RunResult {
const DEFAULT_MAX_TURNS = 25; const DEFAULT_MAX_TURNS = 25;
/**
* Host paths the agent may not read (ADR-0018 defense-in-depth). The sandbox
* confines writes to the workspace; these deny reads of credentials/secrets
* the process could otherwise see (read-only mounts are still readable). The
* workspace itself is never in this list — it's writable by `allowWrite`.
* Extend via `CPH_SANDBOX_EXTRA_DENY_READ` (colon-separated) for deployments
* with additional secret locations.
*/
const SENSITIVE_READ_PATHS: readonly string[] = (() => {
const home = homedir();
const base = [
"/etc",
"/root",
"/var/log",
"/proc",
"/sys",
`${home}/.ssh`,
`${home}/.aws`,
`${home}/.config/gcloud`,
`${home}/.gnupg`,
];
const extra = process.env["CPH_SANDBOX_EXTRA_DENY_READ"];
return extra !== undefined && extra !== ""
? [...base, ...extra.split(":").filter((p) => p !== "")]
: base;
})();
export async function runAgent(req: RunRequest): Promise<RunResult> { export async function runAgent(req: RunRequest): Promise<RunResult> {
const onStream = req.onStream; const onStream = req.onStream;
const cap = req.maxTurns ?? DEFAULT_MAX_TURNS; const cap = req.maxTurns ?? DEFAULT_MAX_TURNS;
@@ -140,9 +115,19 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
try { try {
await persistAgentMessage(req, "user", req.prompt); await persistAgentMessage(req, "user", req.prompt);
const toolConfig = claudeSdkToolConfigForRole(req.tools); const toolConfig = claudeSdkToolConfigForRole(req.tools);
const workspaceRoot = req.project.workspaceRoot?.trim();
if (workspaceRoot === undefined || workspaceRoot === "") {
throw new Error("Agent run requires the configured workspace root");
}
const security = await createAgentSecurityPolicy({
workspaceRoot,
workspaceDir: req.project.workspaceDir,
providerEnv: req.providerEnv,
});
const options: Parameters<typeof query>[0]["options"] = { type QueryOptions = NonNullable<Parameters<typeof query>[0]["options"]>;
cwd: req.project.workspaceDir, const options: QueryOptions = {
cwd: security.cwd,
tools: [...toolConfig.tools], tools: [...toolConfig.tools],
allowedTools: [...toolConfig.allowedTools], allowedTools: [...toolConfig.allowedTools],
maxTurns: cap, maxTurns: cap,
@@ -152,22 +137,19 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
permissionMode: "bypassPermissions" as const, permissionMode: "bypassPermissions" as const,
allowDangerouslySkipPermissions: true, allowDangerouslySkipPermissions: true,
// ADR-0018: OS-level sandbox confines the agent process + its Bash // ADR-0018: OS-level sandbox confines the agent process + its Bash
// subprocesses. Writes confined to the workspace; sensitive host paths // subprocesses. Writes and ordinary reads are confined to the workspace;
// denied reads; network open. failIfUnavailable hard-fails if the // named system runtime files remain readable so cph can execute. Network
// sandbox can't start — never run unsandboxed. // stays open. failIfUnavailable and allowUnsandboxedCommands=false make
sandbox: { // sandbox loss a hard failure.
enabled: true, sandbox: { ...security.sandbox },
failIfUnavailable: true, env: security.env,
autoAllowBashIfSandboxed: true, // The project workspace is untrusted input. Do not load user/project
filesystem: { // settings that could widen tools, hooks, MCP servers, or sandbox paths.
allowWrite: [req.project.workspaceDir], settingSources: [],
denyRead: [...SENSITIVE_READ_PATHS], strictMcpConfig: true,
},
},
}; };
if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt; if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
if (req.model !== undefined) options.model = req.model; 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.resumeSessionId !== undefined) options.resume = req.resumeSessionId;
if (req.mcpServers !== undefined) options.mcpServers = req.mcpServers; if (req.mcpServers !== undefined) options.mcpServers = req.mcpServers;
if (req.abortController !== undefined) options.abortController = req.abortController; if (req.abortController !== undefined) options.abortController = req.abortController;
+280
View File
@@ -0,0 +1,280 @@
import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path";
const PROVIDER_ENV_KEYS = new Set([
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY",
]);
const SAFE_HOST_ENV_KEYS = [
"PATH",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TZ",
"TERM",
"USER",
"LOGNAME",
"SHELL",
"CPH_BIN",
] as const;
const PROVIDER_SECRET_ENV_KEYS = ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] as const;
export interface AgentSecurityInput {
readonly workspaceRoot: string;
readonly workspaceDir: string;
readonly providerEnv?: Readonly<Record<string, string | undefined>> | undefined;
readonly hostEnv?: Readonly<Record<string, string | undefined>> | undefined;
}
export interface AgentSandboxPolicy {
readonly enabled: true;
readonly failIfUnavailable: true;
readonly autoAllowBashIfSandboxed: true;
readonly allowUnsandboxedCommands: false;
readonly filesystem: {
readonly allowWrite: string[];
readonly denyRead: string[];
readonly allowRead: string[];
};
readonly credentials: {
readonly files: Array<{ readonly path: string; readonly mode: "deny" }>;
readonly envVars: Array<{ readonly name: string; readonly mode: "deny" }>;
};
}
export interface AgentSecurityPolicy {
readonly cwd: string;
readonly workspaceRoot: string;
readonly env: Record<string, string | undefined>;
readonly sandbox: AgentSandboxPolicy;
}
/**
* Build the complete Claude subprocess boundary from trusted run context.
* This function intentionally replaces, rather than extends, process.env.
*/
export async function createAgentSecurityPolicy(input: AgentSecurityInput): Promise<AgentSecurityPolicy> {
const hostEnv = input.hostEnv ?? process.env;
const { workspaceRoot, workspaceDir } = await resolveProjectWorkspace(input.workspaceRoot, input.workspaceDir);
const runtimeRoot = await ensureDirectoryTree(workspaceDir, [".cph", "agent-runtime"]);
const agentHome = await ensureDirectoryTree(runtimeRoot, ["home"]);
const agentCache = await ensureDirectoryTree(runtimeRoot, ["cache"]);
const agentConfig = await ensureDirectoryTree(runtimeRoot, ["config"]);
const agentState = await ensureDirectoryTree(runtimeRoot, ["state"]);
const agentTmp = await ensureDirectoryTree(runtimeRoot, ["tmp"]);
const path = hostEnv["PATH"]?.trim();
if (path === undefined || path === "") {
throw new Error("PATH is required for the Agent subprocess");
}
const env: Record<string, string | undefined> = {};
for (const name of SAFE_HOST_ENV_KEYS) {
const value = hostEnv[name];
if (value !== undefined) env[name] = value;
}
env.HOME = agentHome;
env.XDG_CACHE_HOME = agentCache;
env.XDG_CONFIG_HOME = agentConfig;
env.XDG_STATE_HOME = agentState;
env.TMPDIR = agentTmp;
env.TMP = agentTmp;
env.TEMP = agentTmp;
env.CLAUDE_CONFIG_DIR = agentConfig;
env.CLAUDE_CODE_TMPDIR = agentTmp;
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
env.DISABLE_TELEMETRY = "1";
env.DO_NOT_TRACK = "1";
env.CLAUDE_AGENT_SDK_CLIENT_APP = "cph-hub/0.0.0";
for (const [name, value] of Object.entries(input.providerEnv ?? {})) {
if (!PROVIDER_ENV_KEYS.has(name)) {
throw new Error(`unsupported provider environment variable: ${name}`);
}
env[name] = value;
}
const sensitiveReadPaths = hostSensitiveReadPaths(hostEnv);
const runtimeReadPaths = hostRuntimeReadPaths(hostEnv);
return {
cwd: workspaceDir,
workspaceRoot,
env,
sandbox: {
enabled: true,
failIfUnavailable: true,
autoAllowBashIfSandboxed: true,
allowUnsandboxedCommands: false,
filesystem: {
allowWrite: [workspaceDir],
// Deny host reads by default, then re-open exactly this run's
// workspace plus the named system runtime needed to execute tools.
// SDK allowRead takes precedence over matching denyRead paths.
denyRead: ["/"],
allowRead: [workspaceDir, ...runtimeReadPaths],
},
credentials: {
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })),
envVars: PROVIDER_SECRET_ENV_KEYS.map((name) => ({ name, mode: "deny" as const })),
},
},
};
}
function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
const platformPaths = process.platform === "darwin"
? [
"/bin",
"/usr/bin",
"/usr/lib",
"/usr/share",
"/System/Library",
"/Library/Fonts",
"/private/etc/hosts",
"/private/etc/resolv.conf",
"/private/etc/ssl",
"/dev/null",
"/dev/random",
"/dev/urandom",
]
: [
"/bin",
"/usr/bin",
"/usr/local/bin",
"/lib",
"/lib64",
"/usr/lib",
"/usr/lib64",
"/usr/share",
"/etc/fonts",
"/etc/hosts",
"/etc/localtime",
"/etc/nsswitch.conf",
"/etc/resolv.conf",
"/etc/ssl/certs",
"/dev/null",
"/dev/random",
"/dev/urandom",
];
const cphBin = env["CPH_BIN"]?.trim();
if (cphBin !== undefined && cphBin !== "") {
if (!isAbsolute(cphBin)) throw new Error("CPH_BIN must be absolute for the Agent subprocess");
platformPaths.push(resolve(cphBin));
}
return [...new Set(platformPaths.map((path) => resolve(path)))];
}
function hostSensitiveReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
const home = homedir();
const paths = [
join(home, ".ssh"),
join(home, ".aws"),
join(home, ".config", "gcloud"),
join(home, ".gnupg"),
];
const extra = env["CPH_SANDBOX_EXTRA_DENY_READ"];
if (extra !== undefined && extra !== "") {
for (const path of extra.split(":")) {
if (path === "") continue;
if (!isAbsolute(path)) throw new Error("CPH_SANDBOX_EXTRA_DENY_READ entries must be absolute paths");
paths.push(resolve(path));
}
}
return [...new Set(paths.map((path) => resolve(path)))];
}
async function requireDirectoryRealpath(path: string, label: string): Promise<string> {
if (!isAbsolute(path)) throw new Error(`${label} must be an absolute path`);
const configuredMetadata = await lstat(path);
if (configuredMetadata.isSymbolicLink() || !configuredMetadata.isDirectory()) {
throw new Error(`${label} is not a real directory: ${path}`);
}
const canonical = await realpath(path);
const metadata = await lstat(canonical);
if (!metadata.isDirectory()) throw new Error(`${label} is not a directory: ${path}`);
return canonical;
}
async function resolveProjectWorkspace(
configuredRoot: string,
configuredWorkspace: string,
): Promise<{ workspaceRoot: string; workspaceDir: string }> {
if (!isAbsolute(configuredRoot) || !isAbsolute(configuredWorkspace)) {
throw new Error("workspace root and project workspace must be absolute paths");
}
const rootPath = resolve(configuredRoot);
const workspacePath = resolve(configuredWorkspace);
const lexicalRelative = relative(rootPath, workspacePath);
if (
lexicalRelative === "" ||
lexicalRelative === ".." ||
lexicalRelative.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) ||
isAbsolute(lexicalRelative)
) {
throw new Error(`project workspace escapes configured workspace root: ${configuredWorkspace}`);
}
const workspaceRoot = await requireDirectoryRealpath(rootPath, "workspace root");
const components = lexicalRelative.split(process.platform === "win32" ? /[\\/]/ : "/");
let current = rootPath;
for (const component of components) {
current = join(current, component);
const metadata = await lstat(current);
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
throw new Error(`project workspace contains a symlink or non-directory component: ${current}`);
}
}
const workspaceDir = await realpath(workspacePath);
const expectedWorkspace = join(workspaceRoot, ...components);
if (workspaceDir !== expectedWorkspace || !isStrictDescendant(workspaceRoot, workspaceDir)) {
throw new Error(`project workspace escapes configured workspace root: ${configuredWorkspace}`);
}
return { workspaceRoot, workspaceDir };
}
async function ensureDirectoryTree(root: string, components: readonly string[]): Promise<string> {
let current = root;
for (const component of components) {
if (component === "" || component === "." || component === ".." || component.includes("/") || component.includes("\\")) {
throw new Error(`invalid Agent runtime directory component: ${component}`);
}
current = join(current, component);
try {
const metadata = await lstat(current);
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
throw new Error(`Agent runtime path is not a real directory: ${current}`);
}
} catch (error) {
if (!isErrno(error, "ENOENT")) throw error;
try {
await mkdir(current, { mode: 0o700 });
} catch (mkdirError) {
if (!isErrno(mkdirError, "EEXIST")) throw mkdirError;
}
const metadata = await lstat(current);
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
throw new Error(`Agent runtime path is not a real directory: ${current}`);
}
}
}
const canonical = await realpath(current);
if (canonical !== current || !isStrictDescendant(root, canonical)) {
throw new Error(`Agent runtime path escapes project workspace: ${current}`);
}
await chmod(canonical, 0o700);
return canonical;
}
function isStrictDescendant(parent: string, child: string): boolean {
const candidate = relative(parent, child);
return candidate !== "" && candidate !== ".." && !candidate.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && !isAbsolute(candidate);
}
function isErrno(error: unknown, code: string): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === code;
}
+66 -16
View File
@@ -6,8 +6,12 @@
* reactions, file download/upload, and the card action callback. * reactions, file download/upload, and the card action callback.
*/ */
import * as lark from "@larksuiteoapi/node-sdk"; import * as lark from "@larksuiteoapi/node-sdk";
import { readFile, mkdir } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { dirname } from "node:path"; import type { Readable } from "node:stream";
import {
WorkspaceFileBoundaryError,
writeNewWorkspaceFileNoFollow,
} from "../security/workspaceFiles.js";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js"; import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js";
import type { SenderNameCache } from "./senderCache.js"; import type { SenderNameCache } from "./senderCache.js";
@@ -486,20 +490,38 @@ export async function downloadMessageFile(
rt: FeishuRuntime, rt: FeishuRuntime,
messageId: string, messageId: string,
fileKey: string, fileKey: string,
savePath: string, workspaceRoot: string,
workspaceDir: string,
workspaceRelativePath: string,
resourceType: "image" | "file", resourceType: "image" | "file",
): Promise<void> { ): Promise<string> {
await mkdir(dirname(savePath), { recursive: true });
try { try {
await withRetry(async () => { return await withRetry(async () => {
const resource = await rt.client.im.v1.messageResource.get({ const resource = await rt.client.im.v1.messageResource.get({
params: { type: resourceType }, params: { type: resourceType },
path: { message_id: messageId, file_key: fileKey }, 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) { } 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; throw e;
} }
} }
@@ -512,33 +534,61 @@ export async function sendFile(
fileName: string, fileName: string,
options?: SendMessageOptions, options?: SendMessageOptions,
): Promise<string | null> { ): Promise<string | null> {
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<string> {
const client = rt.client as unknown as { const client = rt.client as unknown as {
im: { v1: { im: { v1: {
file: { create: (p: unknown) => Promise<FileCreateResponse> } file: { create: (p: unknown) => Promise<FileCreateResponse> }
} }; } };
}; };
try { try {
const buf = await readFile(filePath);
const ext = fileName.split(".").pop() ?? ""; const ext = fileName.split(".").pop() ?? "";
const fileType = ext === "pdf" ? "pdf" : ext === "doc" ? "doc" : ext === "xls" ? "xls" : ext === "ppt" ? "ppt" : "stream"; const fileType = ext === "pdf" ? "pdf" : ext === "doc" ? "doc" : ext === "xls" ? "xls" : ext === "ppt" ? "ppt" : "stream";
const uploadRes = await withRetry(async () => 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); const fileKey = fileKeyFromResponse(uploadRes);
if (fileKey === undefined) { if (fileKey === undefined) {
rt.logger.warn({ chatId, filePath }, "sendFile failed: upload response missing file_key"); throw new FeishuFileDeliveryError("Feishu file upload response is missing file_key");
return null;
} }
const messageId = await withRetry(async () => const messageId = await withRetry(async () =>
sendMessagePayload(rt, chatId, { msgType: "file", content: JSON.stringify({ file_key: fileKey }) }, options), sendMessagePayload(rt, chatId, { msgType: "file", content: JSON.stringify({ file_key: fileKey }) }, options),
); );
if (messageId === null) { 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; return messageId;
} catch (e) { } catch (error) {
rt.logger.warn({ chatId, filePath, err: e instanceof Error ? e.message : String(e) }, "sendFile failed"); const failure = error instanceof FeishuFileDeliveryError
return null; ? error
: new FeishuFileDeliveryError("Feishu file delivery failed", { cause: error });
rt.logger.error({ chatId, fileName, err: failure }, "sendFileData failed");
throw failure;
} }
} }
+15 -4
View File
@@ -13,7 +13,7 @@ export interface DownloadedFeishuMessageResource extends FeishuMessageResourceAr
readonly path: string; readonly path: string;
} }
type DownloadContext = Pick<ToolContext, "boundChatId" | "workspaceDir">; type DownloadContext = Pick<ToolContext, "boundChatId" | "workspaceDir"> & { readonly workspaceRoot: string };
interface MessageLookupResult { interface MessageLookupResult {
readonly data?: { readonly data?: {
@@ -36,18 +36,29 @@ export async function downloadFeishuMessageResource(
throw new Error(`Feishu message not found: ${args.messageId}`); throw new Error(`Feishu message not found: ${args.messageId}`);
} }
if (message.chat_id !== context.boundChatId) { 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( throw new Error(
`refused Feishu resource download: message ${args.messageId} is not in the current project's bound chat`, `refused Feishu resource download: message ${args.messageId} is not in the current project's bound chat`,
); );
} }
const extension = args.resourceType === "image" ? ".png" : ".bin"; const extension = args.resourceType === "image" ? ".png" : ".bin";
const savePath = join( const workspaceRelativePath = join(
context.workspaceDir,
".cph", ".cph",
"inbox", "inbox",
`feishu-${args.resourceType}-${randomUUID()}${extension}`, `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 }; return { ...args, path: savePath };
} }
+21 -52
View File
@@ -1,9 +1,13 @@
import { access, stat } from "node:fs/promises"; import { extname, isAbsolute, join } from "node:path";
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path"; import {
WorkspaceFileBoundaryError,
readWorkspaceFileNoFollow,
} from "../security/workspaceFiles.js";
export interface DeliverableFile { export interface DeliverableFile {
readonly path: string; readonly path: string;
readonly name: string; readonly name: string;
readonly data: Buffer;
} }
const DELIVERABLE_EXTENSIONS = new Set([ const DELIVERABLE_EXTENSIONS = new Set([
@@ -28,6 +32,7 @@ const DELIVERABLE_EXTENSIONS = new Set([
export async function resolveDeliverableFile( export async function resolveDeliverableFile(
requestedPath: string, requestedPath: string,
workspaceRoot: string,
workspaceDir: string, workspaceDir: string,
): Promise<DeliverableFile | null> { ): Promise<DeliverableFile | null> {
const token = requestedPath.trim(); const token = requestedPath.trim();
@@ -35,61 +40,25 @@ export async function resolveDeliverableFile(
return null; return null;
} }
const roots = await allowedRoots(workspaceDir); for (const candidate of candidatePaths(token)) {
for (const candidate of candidatePaths(token, workspaceDir, roots)) { try {
if (!isAllowedPath(candidate, roots)) continue; return await readWorkspaceFileNoFollow(workspaceRoot, workspaceDir, candidate);
const existing = await existingFile(candidate); } catch (error) {
if (existing !== null) return { path: existing, name: basename(existing) }; 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; return null;
} }
async function allowedRoots(workspaceDir: string): Promise<string[]> { function candidatePaths(token: string): string[] {
const roots = [resolve(workspaceDir)]; if (isAbsolute(token)) return [token];
const gitRoot = await findGitRoot(workspaceDir); const candidates = [token];
if (gitRoot !== undefined) roots.push(gitRoot);
return [...new Set(roots)];
}
async function findGitRoot(startDir: string): Promise<string | undefined> {
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));
}
if (!token.includes("/") && !token.includes("\\")) { if (!token.includes("/") && !token.includes("\\")) {
candidates.push(resolve(workspaceDir, "build", token)); candidates.push(join("build", token));
} }
return [...new Set(candidates)]; 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<string | null> {
try {
const s = await stat(path);
return s.isFile() ? resolve(path) : null;
} catch {
return null;
}
}
+62 -12
View File
@@ -1,17 +1,19 @@
import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk"; import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod"; 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 { resolveDeliverableFile } from "./fileDelivery.js";
import { downloadFeishuMessageResource } from "./download.js"; import { downloadFeishuMessageResource } from "./download.js";
import { readFeishuContext } from "./read.js"; import { readFeishuContext } from "./read.js";
import type { ApprovalManager } from "./approval.js"; import type { ApprovalManager } from "./approval.js";
import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js"; import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js";
import { WorkspaceFileBoundaryError } from "../security/workspaceFiles.js";
export interface FileDeliveryToolOptions { export interface FileDeliveryToolOptions {
readonly rt: FeishuRuntime; readonly rt: FeishuRuntime;
readonly chatId: string; readonly chatId: string;
readonly projectId: string; readonly projectId: string;
readonly runId: string; readonly runId: string;
readonly workspaceRoot?: string | undefined;
readonly workspaceDir: string; readonly workspaceDir: string;
readonly sendOptions?: SendMessageOptions | undefined; readonly sendOptions?: SendMessageOptions | undefined;
readonly approvalManager: ApprovalManager; readonly approvalManager: ApprovalManager;
@@ -27,13 +29,46 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
tools.push( tools.push(
tool( tool(
"send_file", "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."), name: z.string().optional().describe("Optional display filename. Defaults to the file's basename."),
}, },
async (args) => { 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) { if (file === null) {
return { return {
isError: true, 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); const messageId = await sendFileData(
if (messageId === null) { options.rt,
return { options.chatId,
isError: true, file.data,
content: [{ type: "text", text: `Failed to send file: ${file.path}` }], args.name ?? file.name,
}; options.sendOptions,
} );
options.onDelivered?.(file.path); options.onDelivered?.(file.path);
return { 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."), resource_type: z.enum(["image", "file"]).describe("Use image for image_key and file for file_key."),
}, },
async (args) => { 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( const downloaded = await downloadFeishuMessageResource(
{ {
messageId: args.message_id, messageId: args.message_id,
fileKey: args.file_key, fileKey: args.file_key,
resourceType: args.resource_type, resourceType: args.resource_type,
}, },
{ boundChatId: options.chatId, workspaceDir: options.workspaceDir }, {
boundChatId: options.chatId,
workspaceRoot,
workspaceDir: options.workspaceDir,
},
options.rt, options.rt,
); );
return { content: [{ type: "text", text: JSON.stringify(downloaded) }] }; return { content: [{ type: "text", text: JSON.stringify(downloaded) }] };
+22 -1
View File
@@ -48,12 +48,15 @@ function im(rt: FeishuRuntime): ImV1Message {
/** Read on-demand Feishu context for the current run. Entry point for the tool. */ /** Read on-demand Feishu context for the current run. Entry point for the tool. */
export async function readFeishuContext( export async function readFeishuContext(
args: FeishuContextArgs, args: FeishuContextArgs,
_ctx: ToolContext, ctx: ToolContext,
rt: unknown, rt: unknown,
): Promise<string> { ): Promise<string> {
const runtime = rt as FeishuRuntime; const runtime = rt as FeishuRuntime;
const api = im(runtime); const api = im(runtime);
try { try {
if (args.chat_id !== ctx.boundChatId) {
throw new Error("refused Feishu context read outside the current project's bound chat");
}
switch (args.anchor) { switch (args.anchor) {
case "trigger_message": case "trigger_message":
case "status_card": case "status_card":
@@ -62,6 +65,7 @@ export async function readFeishuContext(
const res = await api.get({ path: { message_id: args.id } }); const res = await api.get({ path: { message_id: args.id } });
const msg = res.data?.message ?? res.data?.items?.[0]; const msg = res.data?.message ?? res.data?.items?.[0];
if (msg === undefined) return JSON.stringify({ error: "message not found", id: args.id }); if (msg === undefined) return JSON.stringify({ error: "message not found", id: args.id });
assertBoundChat(msg, ctx.boundChatId);
return JSON.stringify(compact(msg)); return JSON.stringify(compact(msg));
} }
case "thread": { case "thread": {
@@ -75,16 +79,33 @@ export async function readFeishuContext(
}, },
}); });
const items = res.data?.items ?? []; const items = res.data?.items ?? [];
for (const item of items) assertBoundChat(item, ctx.boundChatId);
return JSON.stringify(items.map(compact)); return JSON.stringify(items.map(compact));
} }
default: default:
return JSON.stringify({ error: `unknown anchor type: ${args.anchor as string}` }); return JSON.stringify({ error: `unknown anchor type: ${args.anchor as string}` });
} }
} catch (e) { } 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) }); 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. */ /** Project a lark message down to the fields the model actually needs. */
function compact(m: LarkMessage): Record<string, unknown> { function compact(m: LarkMessage): Record<string, unknown> {
const parentId = m.parent_id ?? m.parent_message_id; const parentId = m.parent_id ?? m.parent_message_id;
+35 -11
View File
@@ -72,7 +72,7 @@ interface TriggerDeps {
readonly authorizer?: PermissionAuthorizer | undefined; readonly authorizer?: PermissionAuthorizer | undefined;
readonly messageBatcherOptions?: MessageBatcherOptions | undefined; readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
readonly triggerQueue?: TriggerQueue | undefined; readonly triggerQueue?: TriggerQueue | undefined;
readonly projectWorkspaceRoot?: string | undefined; readonly projectWorkspaceRoot: string;
} }
interface TriggerActor { 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. * agent run is the long leg); the lock guarantees one run per project at a time.
*/ */
export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { 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 authorizer = deps.authorizer ?? createPermissionAuthorizer(deps.prisma);
const runAgent = deps.runAgent ?? defaultRunAgent; const runAgent = deps.runAgent ?? defaultRunAgent;
const approvalManager = new ApprovalManager(); const approvalManager = new ApprovalManager();
@@ -288,6 +290,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const projectCtx: ProjectContext = { const projectCtx: ProjectContext = {
projectId, projectId,
boundChatId: chatId, // ADR-0003: Feishu reads stay in this chat boundChatId: chatId, // ADR-0003: Feishu reads stay in this chat
workspaceRoot: projectWorkspaceRoot,
workspaceDir: project.workspaceDir, workspaceDir: project.workspaceDir,
}; };
@@ -314,6 +317,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
chatId, chatId,
projectId, projectId,
runId: run.id, runId: run.id,
workspaceRoot: projectWorkspaceRoot,
workspaceDir: project.workspaceDir, workspaceDir: project.workspaceDir,
sendOptions, sendOptions,
approvalManager, approvalManager,
@@ -566,16 +570,13 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
try { try {
if (action.action === "create_project_from_chat") { 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 name = defaultProjectNameForChat(chatId);
const project = await createProjectFromFeishuChat(deps.prisma, { const project = await createProjectFromFeishuChat(deps.prisma, {
organizationId: action.organization_id, organizationId: action.organization_id,
actorFeishuOpenId: operatorOpenId, actorFeishuOpenId: operatorOpenId,
chatId, chatId,
name, name,
workspaceRoot: deps.projectWorkspaceRoot, workspaceRoot: projectWorkspaceRoot,
folderId: action.folder_id, folderId: action.folder_id,
}); });
await resolveProjectOnboardingCard(rt, messageId, { await resolveProjectOnboardingCard(rt, messageId, {
@@ -771,6 +772,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const downloadedResources = await downloadPostMessageFiles({ const downloadedResources = await downloadPostMessageFiles({
rt, rt,
msg, msg,
workspaceRoot: projectWorkspaceRoot,
workspaceDir: project.workspaceDir, workspaceDir: project.workspaceDir,
imageKeys: parsed.imageKeys, imageKeys: parsed.imageKeys,
fileKeys: parsed.fileKeys, fileKeys: parsed.fileKeys,
@@ -797,8 +799,15 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const key = content.file_key ?? content.image_key ?? ""; const key = content.file_key ?? content.image_key ?? "";
if (key !== "") { if (key !== "") {
const fileName = `${msg.message_type}-${Date.now()}.${msg.message_type === "image" ? "png" : "bin"}`; const fileName = `${msg.message_type}-${Date.now()}.${msg.message_type === "image" ? "png" : "bin"}`;
const savePath = `${project.workspaceDir}/.cph/inbox/${fileName}`; const savePath = await downloadMessageFile(
await downloadMessageFile(rt, msg.message_id, key, savePath, msg.message_type); rt,
msg.message_id,
key,
projectWorkspaceRoot,
project.workspaceDir,
`.cph/inbox/${fileName}`,
msg.message_type,
);
cleanPrompt = `用户发来一个文件: ${savePath}`; cleanPrompt = `用户发来一个文件: ${savePath}`;
} }
} }
@@ -1298,6 +1307,7 @@ interface DownloadedMessageResource {
async function downloadPostMessageFiles(args: { async function downloadPostMessageFiles(args: {
readonly rt: FeishuRuntime; readonly rt: FeishuRuntime;
readonly msg: MessageReceiveEvent["message"]; readonly msg: MessageReceiveEvent["message"];
readonly workspaceRoot: string;
readonly workspaceDir: string; readonly workspaceDir: string;
readonly imageKeys: readonly string[]; readonly imageKeys: readonly string[];
readonly fileKeys: readonly string[]; readonly fileKeys: readonly string[];
@@ -1305,13 +1315,27 @@ async function downloadPostMessageFiles(args: {
const timestamp = Date.now(); const timestamp = Date.now();
const downloadedResources: DownloadedMessageResource[] = []; const downloadedResources: DownloadedMessageResource[] = [];
for (const [index, imageKey] of args.imageKeys.entries()) { for (const [index, imageKey] of args.imageKeys.entries()) {
const savePath = `${args.workspaceDir}/.cph/inbox/post-image-${timestamp}-${index}.png`; const savePath = await downloadMessageFile(
await downloadMessageFile(args.rt, args.msg.message_id, imageKey, savePath, "image"); 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 }); downloadedResources.push({ resourceType: "image", path: savePath });
} }
for (const [index, fileKey] of args.fileKeys.entries()) { for (const [index, fileKey] of args.fileKeys.entries()) {
const savePath = `${args.workspaceDir}/.cph/inbox/post-file-${timestamp}-${index}.bin`; const savePath = await downloadMessageFile(
await downloadMessageFile(args.rt, args.msg.message_id, fileKey, savePath, "file"); 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 }); downloadedResources.push({ resourceType: "file", path: savePath });
} }
return downloadedResources; return downloadedResources;
+301
View File
@@ -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<WorkspaceFileSnapshot> {
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<string> {
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<string> {
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<OpenDirectory> {
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<void> {
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<void> {
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<FileHandle | undefined>,
message: string,
additionalFailures: readonly unknown[] = [],
): Promise<void> {
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;
}
@@ -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<string, string | undefined>();
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<Record<string, string>>): 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<void>;
}> {
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<void>((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<void>((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<string, unknown> & { 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<string> {
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("'", `'"'"'`)}'`;
}
+7 -2
View File
@@ -9,7 +9,7 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { mkdtemp, rm } from "node:fs/promises"; import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { dirname, join } from "node:path";
import "dotenv/config"; import "dotenv/config";
import { runAgent } from "../../src/agent/runner.js"; import { runAgent } from "../../src/agent/runner.js";
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
@@ -47,7 +47,12 @@ describeOrSkip("real model integration (OpenRouter)", () => {
sessionId: "real-test-session", sessionId: "real-test-session",
prompt: "请只回复: OK", prompt: "请只回复: OK",
model: MODEL, 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, prisma: stubPrisma,
systemPrompt: "你是一个极简 smoke test 助手。请严格按用户要求回复。", systemPrompt: "你是一个极简 smoke test 助手。请严格按用户要求回复。",
maxTurns: 3, maxTurns: 3,
+17 -11
View File
@@ -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 { 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 { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
import { DEFAULT_ORG_ID, prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js"; import { DEFAULT_ORG_ID, prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js";
import { InMemoryModelRegistry } from "../../src/agent/models.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 { TriggerQueue } from "../../src/feishu/triggerQueue.js";
import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.js"; import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.js";
import type { RunRequest, RunResult } from "../../src/agent/runner.js"; import type { RunRequest, RunResult } from "../../src/agent/runner.js";
import type { RuntimeSettings } from "../../src/settings/runtime.js"; import type { RuntimeSettings } from "../../src/settings/runtime.js";
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" }; 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<RunResult>; type TestRunner = (req: RunRequest) => Promise<RunResult>;
const workspaceRoots: string[] = []; const workspaceRoots: string[] = [];
type TestTriggerDeps = Omit<Parameters<typeof makeProductionTriggerHandler>[0], "projectWorkspaceRoot"> & {
readonly projectWorkspaceRoot?: string;
};
function makeTriggerHandler(deps: TestTriggerDeps): ReturnType<typeof makeProductionTriggerHandler> {
return makeProductionTriggerHandler({ projectWorkspaceRoot: "/tmp", ...deps });
}
function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings { function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings {
return { return {
async provider(providerId) { async provider(providerId) {
@@ -153,19 +163,15 @@ describe("trigger full lifecycle (integration)", () => {
expect(run.costSource).toBe("provider_reported"); 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(); const workspaceDir = await tempWorkspaceRoot();
await seedProject("proj-post-image", "chat-post-image"); await seedProject("proj-post-image", "chat-post-image");
await prisma.project.update({ await prisma.project.update({
where: { id: "proj-post-image" }, where: { id: "proj-post-image" },
data: { workspaceDir }, data: { workspaceDir },
}); });
let downloadedPath: string | undefined;
const messageResourceGet = vi.fn(async () => ({ const messageResourceGet = vi.fn(async () => ({
writeFile: async (filePath: string) => { getReadableStream: () => Readable.from([Buffer.from("image bytes")]),
downloadedPath = filePath;
await writeFile(filePath, "image bytes");
},
})); }));
const imV1 = (rt.client as unknown as { const imV1 = (rt.client as unknown as {
im: { v1: { messageResource?: { get: typeof messageResourceGet } } }; im: { v1: { messageResource?: { get: typeof messageResourceGet } } };
@@ -192,6 +198,7 @@ describe("trigger full lifecycle (integration)", () => {
settings, settings,
logger: silentLogger, logger: silentLogger,
runAgent, runAgent,
projectWorkspaceRoot: dirname(workspaceDir),
messageBatcherOptions: { maxMessages: 1 }, messageBatcherOptions: { maxMessages: 1 },
}); });
@@ -200,8 +207,7 @@ describe("trigger full lifecycle (integration)", () => {
await vi.waitFor(() => { await vi.waitFor(() => {
expect(runAgentCalls).toHaveLength(1); expect(runAgentCalls).toHaveLength(1);
}); });
expect(downloadedPath).toBeDefined(); expect(runAgentCalls[0]?.prompt).toContain(join(await realpath(workspaceDir), ".cph", "inbox"));
expect(runAgentCalls[0]?.prompt).toContain(downloadedPath);
expect(messageResourceGet).toHaveBeenCalledWith({ expect(messageResourceGet).toHaveBeenCalledWith({
params: { type: "image" }, params: { type: "image" },
path: { message_id: event.message.message_id, file_key: "img-key-1" }, path: { message_id: event.message.message_id, file_key: "img-key-1" },
+126
View File
@@ -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, "\\$&");
}
+7
View File
@@ -109,6 +109,13 @@ describe("Feishu approval cards", () => {
settings: {} as RuntimeSettings, settings: {} as RuntimeSettings,
logger: silentLogger(), logger: silentLogger(),
authorizer: allowAllAuthorizer(), 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"); const result = trigger.approvalManager.register("message-1", "chat-1");
+50 -9
View File
@@ -1,26 +1,31 @@
import { describe, expect, it, vi } from "vitest"; 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 { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { Readable } from "node:stream";
import { import {
downloadMessageFile, downloadMessageFile,
FeishuFileDeliveryError,
resolveSenderName, resolveSenderName,
sendFile, sendFile,
sendFileData,
sendLongText, sendLongText,
sendTextMessage, sendTextMessage,
type FeishuRuntime, type FeishuRuntime,
} from "../../src/feishu/client.js"; } from "../../src/feishu/client.js";
import { SenderNameCache } from "../../src/feishu/senderCache.js"; import { SenderNameCache } from "../../src/feishu/senderCache.js";
const itOnLinux = process.platform === "linux" ? it : it.skip;
describe("Feishu client helpers", () => { 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-")); const dir = await mkdtemp(join(tmpdir(), "hub-feishu-client-"));
try { try {
const destination = join(dir, "image.png"); const workspace = join(dir, "workspace");
const resourceWriteFile = vi.fn(async (filePath: string) => { await mkdir(workspace);
await writeFile(filePath, "image bytes"); const destination = join(workspace, ".cph", "inbox", "image.png");
}); const getReadableStream = vi.fn(() => Readable.from([Buffer.from("image bytes")]));
const messageResourceGet = vi.fn(async () => ({ writeFile: resourceWriteFile })); const messageResourceGet = vi.fn(async () => ({ getReadableStream }));
const rawRequest = vi.fn(async () => Buffer.from("image bytes")); const rawRequest = vi.fn(async () => Buffer.from("image bytes"));
const rt = mockRuntime({ const rt = mockRuntime({
fileCreate: vi.fn(), fileCreate: vi.fn(),
@@ -29,13 +34,15 @@ describe("Feishu client helpers", () => {
request: rawRequest, 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({ expect(messageResourceGet).toHaveBeenCalledWith({
params: { type: "image" }, params: { type: "image" },
path: { message_id: "message-1", file_key: "img-key-1" }, 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"); await expect(readFile(destination, "utf8")).resolves.toBe("image bytes");
expect(rawRequest).not.toHaveBeenCalled(); expect(rawRequest).not.toHaveBeenCalled();
} finally { } 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 () => { it("accepts top-level file_key from the Lark SDK file upload response", async () => {
const dir = await mkdtemp(join(tmpdir(), "hub-feishu-client-")); const dir = await mkdtemp(join(tmpdir(), "hub-feishu-client-"));
try { 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<FeishuFileDeliveryError>));
});
it("accepts top-level message_id from message create responses", async () => { it("accepts top-level message_id from message create responses", async () => {
const rt = mockRuntime({ const rt = mockRuntime({
fileCreate: vi.fn(), fileCreate: vi.fn(),
+17 -11
View File
@@ -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 { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { Readable } from "node:stream";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { claudeSdkToolConfigForRole, cphHubMcpToolsForRole } from "../../src/agent/roleTools.js"; import { claudeSdkToolConfigForRole, cphHubMcpToolsForRole } from "../../src/agent/roleTools.js";
import type { FeishuRuntime } from "../../src/feishu/client.js"; import type { FeishuRuntime } from "../../src/feishu/client.js";
import { downloadFeishuMessageResource } from "../../src/feishu/download.js"; import { downloadFeishuMessageResource } from "../../src/feishu/download.js";
const itOnLinux = process.platform === "linux" ? it : it.skip;
describe("Feishu message resource download", () => { describe("Feishu message resource download", () => {
it("exposes the download tool to default and explicitly configured roles", () => { it("exposes the download tool to default and explicitly configured roles", () => {
expect(cphHubMcpToolsForRole(undefined)).toContain("feishu_download_resource"); 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 () => { itOnLinux("downloads a bound-chat image into the project inbox", async () => {
const workspaceDir = await mkdtemp(join(tmpdir(), "hub-feishu-download-")); const workspaceRoot = await mkdtemp(join(tmpdir(), "hub-feishu-download-"));
const workspaceDir = join(workspaceRoot, "project");
await mkdir(workspaceDir);
try { try {
const messageGet = vi.fn(async () => ({ data: { items: [{ chat_id: "chat-1" }] } })); const messageGet = vi.fn(async () => ({ data: { items: [{ chat_id: "chat-1" }] } }));
const resourceWriteFile = vi.fn(async (filePath: string) => { const messageResourceGet = vi.fn(async () => ({
await writeFile(filePath, "image bytes"); getReadableStream: () => Readable.from([Buffer.from("image bytes")]),
}); }));
const messageResourceGet = vi.fn(async () => ({ writeFile: resourceWriteFile }));
const rt = mockRuntime(messageGet, messageResourceGet); const rt = mockRuntime(messageGet, messageResourceGet);
const result = await downloadFeishuMessageResource( const result = await downloadFeishuMessageResource(
{ messageId: "message-1", fileKey: "img-key-1", resourceType: "image" }, { messageId: "message-1", fileKey: "img-key-1", resourceType: "image" },
{ boundChatId: "chat-1", workspaceDir }, { boundChatId: "chat-1", workspaceRoot, workspaceDir },
rt, rt,
); );
expect(result).toMatchObject({ resourceType: "image" }); 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$/); expect(result.path).toMatch(/\.png$/);
await expect(readFile(result.path, "utf8")).resolves.toBe("image bytes"); await expect(readFile(result.path, "utf8")).resolves.toBe("image bytes");
expect(messageResourceGet).toHaveBeenCalledWith({ expect(messageResourceGet).toHaveBeenCalledWith({
@@ -40,7 +46,7 @@ describe("Feishu message resource download", () => {
path: { message_id: "message-1", file_key: "img-key-1" }, path: { message_id: "message-1", file_key: "img-key-1" },
}); });
} finally { } 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( await expect(downloadFeishuMessageResource(
{ messageId: "message-other", fileKey: "img-key-other", resourceType: "image" }, { 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, rt,
)).rejects.toThrow("current project's bound chat"); )).rejects.toThrow("current project's bound chat");
expect(messageResourceGet).not.toHaveBeenCalled(); expect(messageResourceGet).not.toHaveBeenCalled();
+1
View File
@@ -102,6 +102,7 @@ async function triggerWithRunAgent(
settings: mockSettings(), settings: mockSettings(),
logger: rt.logger, logger: rt.logger,
authorizer: allowAllAuthorizer(), authorizer: allowAllAuthorizer(),
projectWorkspaceRoot: "/tmp",
runAgent, runAgent,
messageBatcherOptions: { maxMessages: 1 }, messageBatcherOptions: { maxMessages: 1 },
}); });
+55
View File
@@ -17,6 +17,7 @@ describe("readFeishuContext", () => {
root_id: "m-root", root_id: "m-root",
parent_id: "m-parent", parent_id: "m-parent",
thread_id: "thread-1", thread_id: "thread-1",
chat_id: "chat-1",
msg_type: "text", msg_type: "text",
body: { content: JSON.stringify({ text: "parent text" }) }, body: { content: JSON.stringify({ text: "parent text" }) },
}, },
@@ -46,4 +47,58 @@ describe("readFeishuContext", () => {
thread_id: "thread-1", 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<Record<string, unknown>>;
list: Array<Record<string, unknown>>;
}): 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;
}
+20 -1
View File
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vite
import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; 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", () => { describe("withRetry", () => {
beforeEach(() => { beforeEach(() => {
@@ -127,6 +127,25 @@ describe("sendFile retry", () => {
await rm(dir, { recursive: true, force: true }); 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<void> { async function waitForCalls(mock: Mock, expectedCalls: number): Promise<void> {
+27 -11
View File
@@ -1,36 +1,50 @@
import { describe, expect, it } from "vitest"; 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 { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { resolveDeliverableFile } from "../../src/feishu/fileDelivery.js"; import { resolveDeliverableFile } from "../../src/feishu/fileDelivery.js";
const itOnLinux = process.platform === "linux" ? it : it.skip;
describe("file delivery path resolution", () => { 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(); const root = await makeRepo();
try { try {
const workspace = join(root, "examples", "TH-141"); const workspace = join(root, "examples", "TH-141");
await writeFile(join(root, "README.md"), "# root readme\n"); await writeFile(join(root, "README.md"), "# root readme\n");
await expect(resolveDeliverableFile("README.md", workspace)).resolves.toEqual({ await expect(resolveDeliverableFile("README.md", join(root, "examples"), workspace)).resolves.toBeNull();
path: join(root, "README.md"),
name: "README.md",
});
} finally { } finally {
await rm(root, { recursive: true, force: true }); 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(); const root = await makeRepo();
try { try {
const workspace = join(root, "examples", "TH-141"); const workspace = join(root, "examples", "TH-141");
const pdf = join(workspace, "build", "student.pdf"); const pdf = join(workspace, "build", "student.pdf");
await writeFile(pdf, "pdf bytes"); await writeFile(pdf, "pdf bytes");
await expect(resolveDeliverableFile("student.pdf", workspace)).resolves.toEqual({ const resolved = await resolveDeliverableFile("student.pdf", join(root, "examples"), workspace);
path: pdf, expect(resolved).toMatchObject({ path: await realpath(pdf), name: "student.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 { } finally {
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
} }
@@ -42,7 +56,9 @@ describe("file delivery path resolution", () => {
const workspace = join(root, "examples", "TH-141"); const workspace = join(root, "examples", "TH-141");
await writeFile(join(workspace, "build", "student.pdf"), "pdf bytes"); 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 { } finally {
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
} }
+59 -17
View File
@@ -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"; import { runAgent } from "../../src/agent/runner.js";
const queryMock = vi.hoisted(() => vi.fn()); const queryMock = vi.hoisted(() => vi.fn());
@@ -49,8 +52,30 @@ function abortableMessages(ac: AbortController, ...items: unknown[]) {
} }
describe("runAgent", () => { describe("runAgent", () => {
beforeEach(() => { let root: string;
let workspaceRoot: string;
let workspace: string;
let previousSecrets: Record<string, string | undefined>;
beforeEach(async () => {
queryMock.mockReset(); 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 () => { it("passes the previous Claude SDK session id through resume", async () => {
@@ -59,7 +84,7 @@ describe("runAgent", () => {
const result = await runAgent({ const result = await runAgent({
prompt: "再发一次", prompt: "再发一次",
model: "anthropic/claude-sonnet-5", model: "anthropic/claude-sonnet-5",
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined, systemPrompt: undefined,
resumeSessionId: "sdk-session-old", resumeSessionId: "sdk-session-old",
runId: "run-1", runId: "run-1",
@@ -70,17 +95,22 @@ describe("runAgent", () => {
expect(queryMock).toHaveBeenCalledWith({ expect(queryMock).toHaveBeenCalledWith({
prompt: "再发一次", prompt: "再发一次",
options: expect.objectContaining({ options: expect.objectContaining({
cwd: "/tmp/ws", cwd: workspace,
model: "anthropic/claude-sonnet-5", model: "anthropic/claude-sonnet-5",
resume: "sdk-session-old", resume: "sdk-session-old",
// ADR-0018: agent execution surface bounded by workspace. // ADR-0018: agent execution surface bounded by workspace.
permissionMode: "bypassPermissions", permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true, allowDangerouslySkipPermissions: true,
settingSources: [],
strictMcpConfig: true,
sandbox: expect.objectContaining({ sandbox: expect.objectContaining({
enabled: true, enabled: true,
failIfUnavailable: true, failIfUnavailable: true,
allowUnsandboxedCommands: false,
filesystem: expect.objectContaining({ filesystem: expect.objectContaining({
allowWrite: ["/tmp/ws"], allowWrite: [workspace],
denyRead: ["/"],
allowRead: expect.arrayContaining([workspace]),
}), }),
}), }),
}), }),
@@ -93,7 +123,7 @@ describe("runAgent", () => {
await runAgent({ await runAgent({
prompt: "你好", prompt: "你好",
model: undefined, model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined, systemPrompt: undefined,
runId: "run-1", runId: "run-1",
sessionId: "hub-session-1", sessionId: "hub-session-1",
@@ -110,7 +140,7 @@ describe("runAgent", () => {
await runAgent({ await runAgent({
prompt: "审校一下", prompt: "审校一下",
model: undefined, model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined, systemPrompt: undefined,
tools: ["read_file", "cph_check", "send_file"], tools: ["read_file", "cph_check", "send_file"],
runId: "run-1", runId: "run-1",
@@ -132,7 +162,7 @@ describe("runAgent", () => {
await runAgent({ await runAgent({
prompt: "只回答", prompt: "只回答",
model: undefined, model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined, systemPrompt: undefined,
tools: [], tools: [],
runId: "run-1", runId: "run-1",
@@ -154,7 +184,7 @@ describe("runAgent", () => {
const result = await runAgent({ const result = await runAgent({
prompt: "算一下成本", prompt: "算一下成本",
model: undefined, model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined, systemPrompt: undefined,
runId: "run-1", runId: "run-1",
sessionId: "hub-session-1", sessionId: "hub-session-1",
@@ -164,20 +194,21 @@ describe("runAgent", () => {
expect(result.costUsd).toBe(0.0042); expect(result.costUsd).toBe(0.0042);
}); });
it("passes provider env per run without mutating process env", async () => { it("passes only provider and safe runtime env without mutating process env", async () => {
delete process.env["CPH_TEST_PROVIDER_ENV_MUTATION"]; 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"))); queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({ await runAgent({
prompt: "你好", prompt: "你好",
model: undefined, model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined, systemPrompt: undefined,
providerEnv: { providerEnv: {
ANTHROPIC_BASE_URL: "https://openrouter.ai/api", ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "test-token", ANTHROPIC_AUTH_TOKEN: "test-token",
ANTHROPIC_API_KEY: "", ANTHROPIC_API_KEY: "",
CPH_TEST_PROVIDER_ENV_MUTATION: "per-run",
}, },
runId: "run-1", runId: "run-1",
sessionId: "hub-session-1", sessionId: "hub-session-1",
@@ -191,11 +222,22 @@ describe("runAgent", () => {
ANTHROPIC_BASE_URL: "https://openrouter.ai/api", ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "test-token", ANTHROPIC_AUTH_TOKEN: "test-token",
ANTHROPIC_API_KEY: "", 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<string, string | undefined> } }).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 () => { it("persists structured user and assistant messages best-effort", async () => {
@@ -209,7 +251,7 @@ describe("runAgent", () => {
await runAgent({ await runAgent({
prompt: "你好", prompt: "你好",
model: undefined, model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined, systemPrompt: undefined,
runId: "run-1", runId: "run-1",
sessionId: "hub-session-1", sessionId: "hub-session-1",
@@ -241,7 +283,7 @@ describe("runAgent", () => {
const runPromise = runAgent({ const runPromise = runAgent({
prompt: "长任务", prompt: "长任务",
model: undefined, model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined, systemPrompt: undefined,
runId: "run-abort", runId: "run-abort",
sessionId: "hub-session-abort", sessionId: "hub-session-abort",