## 主要变更 ### 1. 新增 delete_file 工具(26 → 27 个) - 支持:删除文件/空目录(默认)/递归删除非空目录(recursive: true) - 5 层安全防护: * isPathWithinWorkspace — 路径遍历防护 * isProtectedWorkspaceFile — MEMORY.md 拦截 * 工作空间根目录保护 — 禁止删除 workspace 本身 * 递归删除需显式开启 — 默认仅删空目录 * riskLevel: HIGH + requiresPermission — 破坏性操作必须确认 - 友好错误处理:ENOTEMPTY 时提示设置 recursive: true ### 2. 文件工具全面优化(6 个工具) - 抽取共享代码到 file-guard.ts: * safeResolvePath — 合并路径遍历 + MEMORY.md 校验 * matchGlob — 简易通配符匹配 * extractErrorMessage — 统一错误提取 * MAX_FILE_SIZE_BYTES (10MB) / FILE_TOOL_TIMEOUT_MS (15s) / MAX_LINE_LENGTH (10000) - read_file:stat 预检 + 超长行截断 + 二进制检测 + 大小上限 - write_file:原子写入(临时文件+rename)+ 内容大小上限 + append 返回 new_file_size - list_directory:1000 结果上限 + modified time + include_hidden 参数 + 提前终止优化 - search_files:regex lastIndex 修复 + context_lines + include_hidden + 大文件跳过 - file_editor:dry_run 预览模式 + 文件大小上限 ### 3. 审计修复(1 FAIL + 3 WARN) - FAIL: isBinaryFile 用 bytesRead 限制循环,修复 < 8KB 文本误判为二进制 - WARN-1: file-editor dry_run preview 分模式计算,修复 insert 范围过大 - WARN-2: write_file 允许空字符串创建空文件 - WARN-3: list_directory listDir 提前终止,避免大目录全量遍历
180 lines
6.4 KiB
TypeScript
180 lines
6.4 KiB
TypeScript
/**
|
||
* File Guard — 受保护文件守卫
|
||
*
|
||
* 确保工作空间根目录的 MEMORY.md 只能由系统内部(WorkspaceService)管理,
|
||
* 任何工具(read_file / write_file / search_files / run_command 等)均禁止直接读写。
|
||
*
|
||
* 注意:仅保护工作空间根目录的 MEMORY.md,
|
||
* 子目录或其他位置的同名文件不受限制。
|
||
*/
|
||
|
||
import { resolve, sep } from 'path';
|
||
import { realpathSync } from 'fs';
|
||
|
||
/**
|
||
* 受保护文件名列表(工作空间根目录)
|
||
*/
|
||
const PROTECTED_FILES = ['MEMORY.md'];
|
||
|
||
/**
|
||
* 检查目标路径是否为工作空间根目录的受保护文件
|
||
*
|
||
* @param filePath 用户传入的文件路径(绝对或相对)
|
||
* @param workspacePath 当前工作空间根路径
|
||
* @returns true 如果路径指向受保护文件
|
||
*/
|
||
export function isProtectedWorkspaceFile(
|
||
filePath: string,
|
||
workspacePath: string,
|
||
): boolean {
|
||
const resolved = resolve(workspacePath, filePath);
|
||
const workspaceRoot = resolve(workspacePath);
|
||
|
||
for (const protectedName of PROTECTED_FILES) {
|
||
const protectedPath = resolve(workspaceRoot, protectedName);
|
||
if (resolved === protectedPath) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 检查路径是否在工作空间内(防止路径遍历攻击)
|
||
*
|
||
* 修复前缀碰撞漏洞:`/home/user/app-evil` 不应被误判为在 `/home/user/app` 内。
|
||
*
|
||
* M-22 修复: 添加 realpathSync 二次校验防止符号链接逃逸
|
||
* 攻击场景:工作空间内创建符号链接 `ln -s /etc/passwd workspace/leak.txt`,
|
||
* 字符串校验会通过(leak.txt 在 workspace 内),但实际读取的是 /etc/passwd。
|
||
*
|
||
* 注意:realpathSync 在路径不存在时会抛 ENOENT,此时降级为字符串校验
|
||
* (write_file 的目标文件可能尚不存在,无法 realpath)。
|
||
*
|
||
* @see project_memory.md — sandbox validatePath must perform realpathSync secondary check
|
||
* @param filePath 用户传入的文件路径
|
||
* @param workspacePath 当前工作空间根路径
|
||
* @returns true 如果路径在工作空间内
|
||
*/
|
||
export function isPathWithinWorkspace(
|
||
filePath: string,
|
||
workspacePath: string,
|
||
): boolean {
|
||
const resolved = resolve(workspacePath, filePath);
|
||
const workspaceRoot = resolve(workspacePath);
|
||
|
||
// 第一层:字符串前缀校验(快速路径)
|
||
const stringCheck = resolved === workspaceRoot || resolved.startsWith(workspaceRoot + sep);
|
||
if (!stringCheck) return false;
|
||
|
||
// 第二层:realpathSync 二次校验(防范符号链接逃逸)
|
||
// 仅对实际存在的路径做 realpath 校验;不存在的路径(如 write_file 目标)降级为字符串校验
|
||
try {
|
||
const realResolved = realpathSync(resolved);
|
||
const realWorkspaceRoot = realpathSync(workspaceRoot);
|
||
return realResolved === realWorkspaceRoot || realResolved.startsWith(realWorkspaceRoot + sep);
|
||
} catch {
|
||
// 路径不存在(ENOENT)或 realpath 失败 → 降级为字符串校验结果
|
||
return stringCheck;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查命令字符串是否尝试访问工作空间根目录的受保护文件
|
||
*
|
||
* 用于 run_command 工具的命令校验。
|
||
* 仅匹配直接引用的 MEMORY.md(前面是命令起始/空白/引号/分号/管道),
|
||
* 不拦截子目录路径中的同名文件(如 subdir/MEMORY.md 或 subdir\MEMORY.md)。
|
||
*
|
||
* 注意:run_command 的工作目录固定为 workspacePath,因此裸引用 MEMORY.md
|
||
* 等价于工作空间根目录的 MEMORY.md。
|
||
*
|
||
* @param command Shell 命令字符串
|
||
* @returns true 如果命令直接引用了受保护文件名
|
||
*/
|
||
export function commandTouchesProtectedFile(command: string): boolean {
|
||
const lowerCmd = command.toLowerCase();
|
||
for (const protectedName of PROTECTED_FILES) {
|
||
const lowerName = protectedName.toLowerCase();
|
||
// 前面是起始/空白/引号/分号/管道/&/>;后面是结束/空白/引号/分号/管道/&/</>
|
||
// 这样 subdir/MEMORY.md 和 subdir\MEMORY.md 不会被匹配(前面是 / 或 \)
|
||
const escaped = lowerName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
const regex = new RegExp(`(?:^|[\\s"'|;&>])${escaped}(?:$|[\\s"'|;&<])`, 'i');
|
||
if (regex.test(lowerCmd)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ===== 共享工具函数(v0.3.2 抽取,消除 filesystem.ts 与 file-editor.ts 的重复)=====
|
||
|
||
/**
|
||
* 共享路径解析 + 安全校验
|
||
*
|
||
* 合并两层安全检查:
|
||
* 1. isPathWithinWorkspace — 路径遍历防护(含符号链接 realpathSync 二次校验)
|
||
* 2. isProtectedWorkspaceFile — MEMORY.md 拦截
|
||
*
|
||
* @param filePath 用户传入的文件路径(绝对或相对)
|
||
* @param workspacePath 当前工作空间根路径
|
||
* @returns 解析后的绝对路径
|
||
* @throws Error 路径越界或访问受保护文件时抛出
|
||
*/
|
||
export function safeResolvePath(filePath: string, workspacePath: string): string {
|
||
const resolved = resolve(workspacePath, filePath);
|
||
// 安全检查:路径遍历防护(修复前缀碰撞漏洞)
|
||
if (!isPathWithinWorkspace(filePath, workspacePath)) {
|
||
throw new Error(`Path traversal detected: ${filePath}`);
|
||
}
|
||
// 受保护文件检查:MEMORY.md 仅由系统内部管理
|
||
if (isProtectedWorkspaceFile(filePath, workspacePath)) {
|
||
throw new Error('Access denied: MEMORY.md is managed by the memory system and cannot be accessed via file tools');
|
||
}
|
||
return resolved;
|
||
}
|
||
|
||
/**
|
||
* 共享 glob 匹配(简易通配符 → 正则)
|
||
*
|
||
* 支持 `*`(任意字符序列)和 `?`(单字符),大小写不敏感。
|
||
* 其他正则元字符会被转义。
|
||
*
|
||
* @param name 待匹配的文件名
|
||
* @param glob 通配符模式(如 "*.ts"、"test?.js")
|
||
* @returns 是否匹配
|
||
*/
|
||
export function matchGlob(name: string, glob: string): boolean {
|
||
const pattern = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
|
||
return new RegExp(`^${pattern}$`, 'i').test(name);
|
||
}
|
||
|
||
/**
|
||
* 共享错误提取
|
||
*
|
||
* 统一从 unknown 错误对象中提取 message 字符串
|
||
*
|
||
* @param error catch 块中的 unknown 错误
|
||
* @returns 错误消息字符串
|
||
*/
|
||
export function extractErrorMessage(error: unknown): string {
|
||
return error instanceof Error ? error.message : String(error);
|
||
}
|
||
|
||
/**
|
||
* 共享文件大小限制常量
|
||
*
|
||
* read_file/write_file/file_editor 共用,防止 OOM
|
||
*/
|
||
export const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB
|
||
|
||
/**
|
||
* 共享超时常量(v0.3.2 统一文件工具 timeoutMs)
|
||
*/
|
||
export const FILE_TOOL_TIMEOUT_MS = 15_000;
|
||
|
||
/**
|
||
* 共享单行最大长度(防止超长行爆 token)
|
||
*/
|
||
export const MAX_LINE_LENGTH = 10_000;
|