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
+66 -16
View File
@@ -6,8 +6,12 @@
* reactions, file download/upload, and the card action callback.
*/
import * as lark from "@larksuiteoapi/node-sdk";
import { readFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import { readFile } from "node:fs/promises";
import type { Readable } from "node:stream";
import {
WorkspaceFileBoundaryError,
writeNewWorkspaceFileNoFollow,
} from "../security/workspaceFiles.js";
import type { FastifyBaseLogger } from "fastify";
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js";
import type { SenderNameCache } from "./senderCache.js";
@@ -486,20 +490,38 @@ export async function downloadMessageFile(
rt: FeishuRuntime,
messageId: string,
fileKey: string,
savePath: string,
workspaceRoot: string,
workspaceDir: string,
workspaceRelativePath: string,
resourceType: "image" | "file",
): Promise<void> {
await mkdir(dirname(savePath), { recursive: true });
): Promise<string> {
try {
await withRetry(async () => {
return await withRetry(async () => {
const resource = await rt.client.im.v1.messageResource.get({
params: { type: resourceType },
path: { message_id: messageId, file_key: fileKey },
});
await resource.writeFile(savePath);
const readableResource = resource as unknown as { getReadableStream: () => Readable };
const source = readableResource.getReadableStream();
try {
return await writeNewWorkspaceFileNoFollow(
workspaceRoot,
workspaceDir,
workspaceRelativePath,
source,
);
} catch (error) {
source.destroy();
throw error;
}
}, {
shouldRetry: (error) => !(error instanceof WorkspaceFileBoundaryError),
});
} catch (e) {
rt.logger.error({ err: e, messageId, fileKey, resourceType, savePath }, "downloadMessageFile failed");
rt.logger.error(
{ err: e, messageId, fileKey, resourceType, workspaceRoot, workspaceDir, workspaceRelativePath },
"downloadMessageFile failed",
);
throw e;
}
}
@@ -512,33 +534,61 @@ export async function sendFile(
fileName: string,
options?: SendMessageOptions,
): Promise<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 {
im: { v1: {
file: { create: (p: unknown) => Promise<FileCreateResponse> }
} };
};
try {
const buf = await readFile(filePath);
const ext = fileName.split(".").pop() ?? "";
const fileType = ext === "pdf" ? "pdf" : ext === "doc" ? "doc" : ext === "xls" ? "xls" : ext === "ppt" ? "ppt" : "stream";
const uploadRes = await withRetry(async () =>
client.im.v1.file.create({ data: { file_type: fileType, file_name: fileName, file: buf } }),
client.im.v1.file.create({ data: { file_type: fileType, file_name: fileName, file: fileData } }),
);
const fileKey = fileKeyFromResponse(uploadRes);
if (fileKey === undefined) {
rt.logger.warn({ chatId, filePath }, "sendFile failed: upload response missing file_key");
return null;
throw new FeishuFileDeliveryError("Feishu file upload response is missing file_key");
}
const messageId = await withRetry(async () =>
sendMessagePayload(rt, chatId, { msgType: "file", content: JSON.stringify({ file_key: fileKey }) }, options),
);
if (messageId === null) {
rt.logger.warn({ chatId, filePath }, "sendFile failed: message response missing message_id");
throw new FeishuFileDeliveryError("Feishu file message response is missing message_id");
}
return messageId;
} catch (e) {
rt.logger.warn({ chatId, filePath, err: e instanceof Error ? e.message : String(e) }, "sendFile failed");
return null;
} catch (error) {
const failure = error instanceof FeishuFileDeliveryError
? error
: new FeishuFileDeliveryError("Feishu file delivery failed", { cause: error });
rt.logger.error({ chatId, fileName, err: failure }, "sendFileData failed");
throw failure;
}
}
+15 -4
View File
@@ -13,7 +13,7 @@ export interface DownloadedFeishuMessageResource extends FeishuMessageResourceAr
readonly path: string;
}
type DownloadContext = Pick<ToolContext, "boundChatId" | "workspaceDir">;
type DownloadContext = Pick<ToolContext, "boundChatId" | "workspaceDir"> & { readonly workspaceRoot: string };
interface MessageLookupResult {
readonly data?: {
@@ -36,18 +36,29 @@ export async function downloadFeishuMessageResource(
throw new Error(`Feishu message not found: ${args.messageId}`);
}
if (message.chat_id !== context.boundChatId) {
rt.logger.warn(
{ messageId: args.messageId, boundChatId: context.boundChatId, returnedChatId: message.chat_id },
"Feishu resource download refused outside bound chat",
);
throw new Error(
`refused Feishu resource download: message ${args.messageId} is not in the current project's bound chat`,
);
}
const extension = args.resourceType === "image" ? ".png" : ".bin";
const savePath = join(
context.workspaceDir,
const workspaceRelativePath = join(
".cph",
"inbox",
`feishu-${args.resourceType}-${randomUUID()}${extension}`,
);
await downloadMessageFile(rt, args.messageId, args.fileKey, savePath, args.resourceType);
const savePath = await downloadMessageFile(
rt,
args.messageId,
args.fileKey,
context.workspaceRoot,
context.workspaceDir,
workspaceRelativePath,
args.resourceType,
);
return { ...args, path: savePath };
}
+21 -52
View File
@@ -1,9 +1,13 @@
import { access, stat } from "node:fs/promises";
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
import { extname, isAbsolute, join } from "node:path";
import {
WorkspaceFileBoundaryError,
readWorkspaceFileNoFollow,
} from "../security/workspaceFiles.js";
export interface DeliverableFile {
readonly path: string;
readonly name: string;
readonly data: Buffer;
}
const DELIVERABLE_EXTENSIONS = new Set([
@@ -28,6 +32,7 @@ const DELIVERABLE_EXTENSIONS = new Set([
export async function resolveDeliverableFile(
requestedPath: string,
workspaceRoot: string,
workspaceDir: string,
): Promise<DeliverableFile | null> {
const token = requestedPath.trim();
@@ -35,61 +40,25 @@ export async function resolveDeliverableFile(
return null;
}
const roots = await allowedRoots(workspaceDir);
for (const candidate of candidatePaths(token, workspaceDir, roots)) {
if (!isAllowedPath(candidate, roots)) continue;
const existing = await existingFile(candidate);
if (existing !== null) return { path: existing, name: basename(existing) };
for (const candidate of candidatePaths(token)) {
try {
return await readWorkspaceFileNoFollow(workspaceRoot, workspaceDir, candidate);
} catch (error) {
if (!(error instanceof WorkspaceFileBoundaryError) || error.reason !== "not_found") {
throw error;
}
// Try the explicit workspace/build fallback for a bare filename. No
// candidate outside the project workspace is ever considered.
}
}
return null;
}
async function allowedRoots(workspaceDir: string): Promise<string[]> {
const roots = [resolve(workspaceDir)];
const gitRoot = await findGitRoot(workspaceDir);
if (gitRoot !== undefined) roots.push(gitRoot);
return [...new Set(roots)];
}
async function findGitRoot(startDir: string): Promise<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));
}
function candidatePaths(token: string): string[] {
if (isAbsolute(token)) return [token];
const candidates = [token];
if (!token.includes("/") && !token.includes("\\")) {
candidates.push(resolve(workspaceDir, "build", token));
candidates.push(join("build", token));
}
return [...new Set(candidates)];
}
function isAllowedPath(path: string, roots: readonly string[]): boolean {
return roots.some((root) => {
const rel = relative(root, path);
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
});
}
async function existingFile(path: string): Promise<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 { z } from "zod";
import { sendApprovalCard, sendFile, type FeishuRuntime, type SendMessageOptions } from "./client.js";
import { sendApprovalCard, sendFileData, type FeishuRuntime, type SendMessageOptions } from "./client.js";
import { resolveDeliverableFile } from "./fileDelivery.js";
import { downloadFeishuMessageResource } from "./download.js";
import { readFeishuContext } from "./read.js";
import type { ApprovalManager } from "./approval.js";
import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js";
import { WorkspaceFileBoundaryError } from "../security/workspaceFiles.js";
export interface FileDeliveryToolOptions {
readonly rt: FeishuRuntime;
readonly chatId: string;
readonly projectId: string;
readonly runId: string;
readonly workspaceRoot?: string | undefined;
readonly workspaceDir: string;
readonly sendOptions?: SendMessageOptions | undefined;
readonly approvalManager: ApprovalManager;
@@ -27,13 +29,46 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
tools.push(
tool(
"send_file",
"Upload an existing project/repository file to the current Feishu chat. The path must point to a concrete file, for example build/student.pdf or README.md.",
"Upload an existing file from the current project workspace to the current Feishu chat. The path must point to a concrete no-symlink file, for example build/student.pdf or README.md.",
{
path: z.string().describe("Workspace-relative, repository-relative, or absolute path to the existing file."),
path: z.string().describe("Workspace-relative path, or an absolute path physically inside the current workspace."),
name: z.string().optional().describe("Optional display filename. Defaults to the file's basename."),
},
async (args) => {
const file = await resolveDeliverableFile(args.path, options.workspaceDir);
const workspaceRoot = options.workspaceRoot?.trim();
if (workspaceRoot === undefined || workspaceRoot === "") {
options.rt.logger.error(
{ runId: options.runId, projectId: options.projectId },
"Agent file delivery missing configured workspace root",
);
return {
isError: true,
content: [{ type: "text", text: "File delivery is unavailable because workspace isolation is not configured." }],
};
}
let file;
try {
file = await resolveDeliverableFile(args.path, workspaceRoot, options.workspaceDir);
} catch (error) {
const boundaryViolation = error instanceof WorkspaceFileBoundaryError && error.reason === "boundary";
const log = boundaryViolation ? options.rt.logger.warn.bind(options.rt.logger) : options.rt.logger.error.bind(options.rt.logger);
log(
{
runId: options.runId,
projectId: options.projectId,
requestedPath: args.path,
err: error,
},
boundaryViolation
? "Agent file delivery refused by workspace boundary"
: "Agent file delivery failed during workspace file access",
);
if (!boundaryViolation) throw error;
return {
isError: true,
content: [{ type: "text", text: "File path is outside the current workspace or uses a symlink." }],
};
}
if (file === null) {
return {
isError: true,
@@ -41,13 +76,13 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
};
}
const messageId = await sendFile(options.rt, options.chatId, file.path, args.name ?? file.name, options.sendOptions);
if (messageId === null) {
return {
isError: true,
content: [{ type: "text", text: `Failed to send file: ${file.path}` }],
};
}
const messageId = await sendFileData(
options.rt,
options.chatId,
file.data,
args.name ?? file.name,
options.sendOptions,
);
options.onDelivered?.(file.path);
return {
@@ -97,13 +132,28 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
resource_type: z.enum(["image", "file"]).describe("Use image for image_key and file for file_key."),
},
async (args) => {
const workspaceRoot = options.workspaceRoot?.trim();
if (workspaceRoot === undefined || workspaceRoot === "") {
options.rt.logger.error(
{ runId: options.runId, projectId: options.projectId },
"Feishu resource download missing configured workspace root",
);
return {
isError: true,
content: [{ type: "text", text: "Resource download is unavailable because workspace isolation is not configured." }],
};
}
const downloaded = await downloadFeishuMessageResource(
{
messageId: args.message_id,
fileKey: args.file_key,
resourceType: args.resource_type,
},
{ boundChatId: options.chatId, workspaceDir: options.workspaceDir },
{
boundChatId: options.chatId,
workspaceRoot,
workspaceDir: options.workspaceDir,
},
options.rt,
);
return { content: [{ type: "text", text: JSON.stringify(downloaded) }] };
+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. */
export async function readFeishuContext(
args: FeishuContextArgs,
_ctx: ToolContext,
ctx: ToolContext,
rt: unknown,
): Promise<string> {
const runtime = rt as FeishuRuntime;
const api = im(runtime);
try {
if (args.chat_id !== ctx.boundChatId) {
throw new Error("refused Feishu context read outside the current project's bound chat");
}
switch (args.anchor) {
case "trigger_message":
case "status_card":
@@ -62,6 +65,7 @@ export async function readFeishuContext(
const res = await api.get({ path: { message_id: args.id } });
const msg = res.data?.message ?? res.data?.items?.[0];
if (msg === undefined) return JSON.stringify({ error: "message not found", id: args.id });
assertBoundChat(msg, ctx.boundChatId);
return JSON.stringify(compact(msg));
}
case "thread": {
@@ -75,16 +79,33 @@ export async function readFeishuContext(
},
});
const items = res.data?.items ?? [];
for (const item of items) assertBoundChat(item, ctx.boundChatId);
return JSON.stringify(items.map(compact));
}
default:
return JSON.stringify({ error: `unknown anchor type: ${args.anchor as string}` });
}
} catch (e) {
runtime.logger.warn(
{
runId: ctx.runId,
projectId: ctx.projectId,
anchor: args.anchor,
anchorId: args.id,
err: e instanceof Error ? e.message : String(e),
},
"Feishu context read refused or failed",
);
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
}
}
function assertBoundChat(message: LarkMessage, boundChatId: string): void {
if (message.chat_id !== boundChatId) {
throw new Error("refused Feishu context result outside the current project's bound chat");
}
}
/** Project a lark message down to the fields the model actually needs. */
function compact(m: LarkMessage): Record<string, unknown> {
const parentId = m.parent_id ?? m.parent_message_id;
+35 -11
View File
@@ -72,7 +72,7 @@ interface TriggerDeps {
readonly authorizer?: PermissionAuthorizer | undefined;
readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
readonly triggerQueue?: TriggerQueue | undefined;
readonly projectWorkspaceRoot?: string | undefined;
readonly projectWorkspaceRoot: string;
}
interface TriggerActor {
@@ -103,6 +103,8 @@ export interface TriggerHandler {
* agent run is the long leg); the lock guarantees one run per project at a time.
*/
export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const projectWorkspaceRoot = deps.projectWorkspaceRoot.trim();
if (projectWorkspaceRoot === "") throw new Error("projectWorkspaceRoot is required");
const authorizer = deps.authorizer ?? createPermissionAuthorizer(deps.prisma);
const runAgent = deps.runAgent ?? defaultRunAgent;
const approvalManager = new ApprovalManager();
@@ -288,6 +290,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const projectCtx: ProjectContext = {
projectId,
boundChatId: chatId, // ADR-0003: Feishu reads stay in this chat
workspaceRoot: projectWorkspaceRoot,
workspaceDir: project.workspaceDir,
};
@@ -314,6 +317,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
chatId,
projectId,
runId: run.id,
workspaceRoot: projectWorkspaceRoot,
workspaceDir: project.workspaceDir,
sendOptions,
approvalManager,
@@ -566,16 +570,13 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
try {
if (action.action === "create_project_from_chat") {
if (deps.projectWorkspaceRoot === undefined || deps.projectWorkspaceRoot.trim() === "") {
throw new Error("HUB_PROJECT_WORKSPACE_ROOT is required for Feishu project creation");
}
const name = defaultProjectNameForChat(chatId);
const project = await createProjectFromFeishuChat(deps.prisma, {
organizationId: action.organization_id,
actorFeishuOpenId: operatorOpenId,
chatId,
name,
workspaceRoot: deps.projectWorkspaceRoot,
workspaceRoot: projectWorkspaceRoot,
folderId: action.folder_id,
});
await resolveProjectOnboardingCard(rt, messageId, {
@@ -771,6 +772,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const downloadedResources = await downloadPostMessageFiles({
rt,
msg,
workspaceRoot: projectWorkspaceRoot,
workspaceDir: project.workspaceDir,
imageKeys: parsed.imageKeys,
fileKeys: parsed.fileKeys,
@@ -797,8 +799,15 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const key = content.file_key ?? content.image_key ?? "";
if (key !== "") {
const fileName = `${msg.message_type}-${Date.now()}.${msg.message_type === "image" ? "png" : "bin"}`;
const savePath = `${project.workspaceDir}/.cph/inbox/${fileName}`;
await downloadMessageFile(rt, msg.message_id, key, savePath, msg.message_type);
const savePath = await downloadMessageFile(
rt,
msg.message_id,
key,
projectWorkspaceRoot,
project.workspaceDir,
`.cph/inbox/${fileName}`,
msg.message_type,
);
cleanPrompt = `用户发来一个文件: ${savePath}`;
}
}
@@ -1298,6 +1307,7 @@ interface DownloadedMessageResource {
async function downloadPostMessageFiles(args: {
readonly rt: FeishuRuntime;
readonly msg: MessageReceiveEvent["message"];
readonly workspaceRoot: string;
readonly workspaceDir: string;
readonly imageKeys: readonly string[];
readonly fileKeys: readonly string[];
@@ -1305,13 +1315,27 @@ async function downloadPostMessageFiles(args: {
const timestamp = Date.now();
const downloadedResources: DownloadedMessageResource[] = [];
for (const [index, imageKey] of args.imageKeys.entries()) {
const savePath = `${args.workspaceDir}/.cph/inbox/post-image-${timestamp}-${index}.png`;
await downloadMessageFile(args.rt, args.msg.message_id, imageKey, savePath, "image");
const savePath = await downloadMessageFile(
args.rt,
args.msg.message_id,
imageKey,
args.workspaceRoot,
args.workspaceDir,
`.cph/inbox/post-image-${timestamp}-${index}.png`,
"image",
);
downloadedResources.push({ resourceType: "image", path: savePath });
}
for (const [index, fileKey] of args.fileKeys.entries()) {
const savePath = `${args.workspaceDir}/.cph/inbox/post-file-${timestamp}-${index}.bin`;
await downloadMessageFile(args.rt, args.msg.message_id, fileKey, savePath, "file");
const savePath = await downloadMessageFile(
args.rt,
args.msg.message_id,
fileKey,
args.workspaceRoot,
args.workspaceDir,
`.cph/inbox/post-file-${timestamp}-${index}.bin`,
"file",
);
downloadedResources.push({ resourceType: "file", path: savePath });
}
return downloadedResources;