Files
metona-ai-desktop/electron/harness/tools/built-in/file-guard.ts
T
thzxx 4554177db0 feat: 升级至 v0.3.3 — 新增 delete_file 工具 + 文件工具全面优化
## 主要变更

### 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 提前终止,避免大目录全量遍历
2026-07-14 21:42:46 +08:00

180 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;