forked from EduCraft/curriculum-project-hub
fix: confine Agent and MCP data access
This commit is contained in:
@@ -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) }] };
|
||||
|
||||
Reference in New Issue
Block a user