文件管理让AI安全操作你的电脑——CogitoAgent开发实战第3篇 本文是专栏的第三篇。上一篇我们讲了工具系统的整体架构给AI装上了一双手。但有了手之后我们要解决两个更根本的问题这双手能伸到哪里伸出去之后怎么保证不碰坏东西这一篇我们深入文件管理工具从最基础的路径解析开始一步步构建一个安全的文件操作体系。 从一个思想实验开始闭上眼睛想象你是一个机器人。有人给你下达指令“把a.txt复制到b.txt。”你能执行这个指令吗不能。因为你不知道a.txt在哪里是当前目录还是桌面还是某个角落如果a.txt不存在怎么办如果b.txt已经存在是覆盖还是报错你有没有权限读a.txt、写b.txt你看一个看似简单的“复制文件”背后藏着这么多问题。编程也是如此。写代码不是写指令而是把人类语言里的“隐含信息”全部显式化。这一篇我们就来拆解这些隐含信息。一、路径文件操作的第一道门槛1.1 什么是路径路径就是文件在电脑里的“地址”。就像你家有门牌号XX省XX市XX区XX路XX号。电脑里的路径也类似D:\my-project\src\index.js路径有两种写法绝对路径从盘符Windows或根目录Mac/Linux开始写完整描述地址。Windows: D:\my-project\src\index.js Mac: /Users/xxx/projects/index.js相对路径以“当前位置”为参考描述相对位置。src/index.js # 当前目录下的 src 文件夹里的 index.js ../docs/readme.md # 上一级目录下的 docs 文件夹里的 readme.md1.2 为什么相对路径有问题我们给AI下的指令通常用的是相对路径“帮我看一下src目录”。问题是“当前目录”是什么在不同场景下“当前目录”的含义不同在命令行里当前目录是你敲命令时所在的目录在程序中当前目录是启动程序时的目录process.cwd()但对AI来说这个概念是模糊的。AI可能以为“当前目录”是它想当然的那个目录。解决方案我们不给AI解释“当前目录”的概念而是约定一个固定的基准点。所有相对路径都基于这个基准点解析。这个基准点就是——工作区根目录。1.3 工作区给AI画一个院子工作区workspace是一个你指定的文件夹。AI的所有操作都必须在这个文件夹里进行。就像你给机器人画了一个院子告诉它“你只能在院子里活动不准出去。”配置示例// config.json{workspace:D:\\my-project}AI执行ls(“src”)时程序会把src拼接到工作区后面得到D:\my-project\src。1.4 路径解析的核心函数现在我们来实现路径解析。这个函数是所有文件工具的基础。先思考需求输入一个路径可能是相对路径也可能是绝对路径输出一个绝对路径确保输出的路径在工作区内第一步处理相对路径functionresolvePath(inputPath){constworkspacegetWorkspace();// 比如 D:\\my-project// 判断是绝对路径还是相对路径if(path.isAbsolute(inputPath)){// 绝对路径直接使用returninputPath;}else{// 相对路径拼接到工作区后面returnpath.join(workspace,inputPath);}}path.isAbsolute()是 Node.js 提供的方法判断一个路径是否是绝对路径。在 Windows 上path.isAbsolute(“D:\\my-project”)→truepath.isAbsolute(“src”)→falsepath.join()的作用是把多个路径片段拼接成一个完整的路径。path.join(“D:\\my-project”, “src”)→D:\my-project\srcpath.join(“D:\\my-project”, “..\\secret”)→D:\my-project\..\secret→D:\secret注意..被解析了等等这里有个问题..表示“上一级目录”。D:\my-project\..\secret解析后是D:\secret跳出了工作区这就是“路径遍历攻击”Path Traversal。恶意输入可以用..跑到工作区外面。1.5 防止路径遍历归一化 边界检查我们需要两步归一化把..和.解析成真实的路径边界检查确认解析后的路径确实在工作区内path.resolve()可以把相对路径转成绝对路径同时自动处理..和.。functionresolvePath(inputPath){constworkspacepath.resolve(getWorkspace());// 先归一化工作区路径constfullPathpath.resolve(workspace,inputPath);// 基于工作区解析输入路径// 边界检查fullPath 必须以 workspace 开头if(!fullPath.startsWith(workspace)){thrownewError(安全限制路径${inputPath}指向了工作区之外);}returnfullPath;}测试一下resolvePath(src)// workspace D:\\my-project// fullPath D:\\my-project\\src// startsWith? true ✅resolvePath(../secret)// workspace D:\\my-project// fullPath D:\\secret// startsWith? false ❌ 抛出错误1.6 符号链接的隐患还有更隐蔽的问题符号链接。假设工作区里有一个符号链接指向外部目录D:\my-project\external_link - C:\WindowsAI 调用ls(“external_link”)我们的路径解析会怎样resolvePath(external_link) // workspace D:\\my-project // fullPath D:\\my-project\\external_link // startsWith? true ✅ 通过检查但当我们读取external_link时Node.js 会跟随这个链接实际上读取的是C:\Windows的内容解决方案用fs.realpath()获取链接的真实路径。constrealPathawaitfs.realpath(fullPath);if(!realPath.startsWith(workspace)){thrownewError(符号链接指向了工作区之外);}这样即使用户创建了指向外部的链接AI 也无法通过它逃逸。二、ls让AI“看见”目录结构2.1 读取目录fs.readdirfs.readdir是 Node.js 读取目录的方法。constentriesawaitfs.readdir(fullPath);console.log(entries);// [agent, api, config.js, ...]它只返回文件名不告诉你这是一个文件还是文件夹。要区分类型需要第二个参数constentriesawaitfs.readdir(fullPath,{withFileTypes:true});// entries[0] 是一个 Dirent 对象console.log(entries[0].isDirectory());// true 或 falsewithFileTypes: true让readdir返回Dirent对象而不是字符串。Dirent对象有isDirectory()、isFile()、isSymbolicLink()等方法。2.2 构建返回数据我们想把每个条目的信息整理成统一格式constresultentries.map(entry({name:entry.name,// agenttype:entry.isDirectory()?dir:file,// dirpath:path.join(fullPath,entry.name)// D:\\my-project\\agent}));2.3 限制数量为什么不能全返回如果一个目录有 10,000 个文件全部返回会怎样上下文占用10,000 个文件名每个平均 20 字符 200,000 字符Token 消耗约 50,000 token按中文估算AI 能处理吗能但没必要。AI 不需要知道每一个文件的名称它只需要知道“大概有什么”。所以我们要做两件事返回数据限制在返回给 AI 的数据上做限制显示格式化让人类阅读时更清晰// 限制返回数量constMAX_ITEMS50;constlimitedresult.slice(0,MAX_ITEMS);return{success:true,data:limited};但这样有个问题AI 不知道还有更多文件被截断了。所以我们可以在返回时加上提示letdatalimited;if(result.lengthMAX_ITEMS){data[...limited,... 还有${result.length-MAX_ITEMS}个条目未显示];}2.4 格式化显示让输出更清晰直接显示 JSON 数组人类看起来费劲[{name:agent,type:dir},{name:api,type:dir},{name:config.js,type:file}]我们把它格式化成树状结构[目录] agent/ api/ [文件] config.js index.js实现代码functionformatLsResult(data){constdirsdata.filter(ii.typedir);constfilesdata.filter(ii.typefile);letlines[];if(dirs.length0){lines.push( [目录]);for(constdirofdirs.slice(0,20)){lines.push(${dir.name}/);}if(dirs.length20){lines.push(... 还有${dirs.length-20}个目录);}}if(files.length0){lines.push( [文件]);for(constfileoffiles.slice(0,20)){lines.push(${file.name});}if(files.length20){lines.push(... 还有${files.length-20}个文件);}}returnlines.length?lines.join(\n): (空目录);}注意这个格式化函数只用于终端显示不用于返回给 AI。AI 看到的是原始数据JSON 数组因为 AI 需要完整信息来做决策。终端显示截断是为了人类阅读AI 的数据不截断。三、read让AI“看懂”文件内容3.1 读取文件fs.readFileconstbufferawaitfs.readFile(fullPath);constcontentbuffer.toString(utf-8);fs.readFile返回的是Buffer二进制数据。调用toString(‘utf-8’)将其转换成文本。3.2 二进制检测读之前先判断如果文件是图片、PDF、可执行文件读成文本会变成乱码浪费 token。怎么判断文件是不是二进制方法一扩展名黑名单constBINARY_EXTENSIONSnewSet([.pdf,.png,.jpg,.jpeg,.gif,.bmp,.ico,.mp3,.wav,.ogg,.flac,.aac,.mp4,.avi,.mkv,.mov,.zip,.rar,.7z,.tar,.gz,.exe,.dll,.so,.dylib,.doc,.docx,.xls,.xlsx,.ppt,.pptx]);这个方法很快但可能漏掉一些文件比如没有扩展名或者扩展名不在列表里。方法二内容检测null 字节检测二进制文件和文本文件的核心区别二进制文件经常包含\0null 字节而文本文件几乎不含。functionhasNullByte(buffer,limit8192){for(leti0;iMath.min(buffer.length,limit);i){if(buffer[i]0)returntrue;}returnfalse;}为什么只检查前 8192 个字节因为对于判断文件类型来说前 8KB 足够了。而且性能更好。组合策略functionisBinaryFile(filePath,buffer){constextpath.extname(filePath).toLowerCase();if(BINARY_EXTENSIONS.has(ext))returntrue;if(hasNullByte(buffer))returntrue;returnfalse;}3.3 大小限制防止上下文爆炸即使文件是文本也可能非常大比如 100MB 的日志文件。我们需要限制读取的大小constMAX_SIZE50000;// 5万字符约 1.5 万 tokenletcontentbuffer.toString(utf-8);lettruncatedfalse;if(content.lengthMAX_SIZE){contentcontent.substring(0,MAX_SIZE);truncatedtrue;}letresultcontent;if(truncated){result\n\n[注意文件内容过长已截断。完整文件共${buffer.length}字符当前显示前${MAX_SIZE}字符];}return{success:true,data:result};5万字符是个经验值。大多数 LLM 的上下文窗口是 8k-32k token5万字符中文约 1.5 万 token是安全的同时足够读入一个中等大小的源文件。3.4 编码问题不只是 UTF-8不是所有文本文件都是 UTF-8 编码。如果读一个 GBK 编码的文件会怎样buffer.toString(utf-8)// 可能输出乱码解决方案检测编码。但为了简化CogitoAgent 只支持 UTF-8。如果遇到乱码AI 会看到一堆奇怪的符号然后可以尝试用其他方式处理比如用fetchPage抓取不适用。这是一个已知限制。3.5 完整实现asyncfunctionread(targetPath){constfullPathresolvePath(targetPath);// 使用安全路径解析try{constbufferawaitfs.readFile(fullPath);// 二进制检测if(isBinaryFile(fullPath,buffer)){constsizeKB(buffer.length/1024).toFixed(1);return{success:true,data:[二进制文件 (${sizeKB}KB)无法显示文本内容]};}// 转成文本letcontentbuffer.toString(utf-8);lettruncatedfalse;// 大小限制if(content.length50000){contentcontent.substring(0,50000);truncatedtrue;}letdatacontent;if(truncated){data\n\n[内容过长已截断];}return{success:true,data};}catch(error){return{success:false,error:error.message};}}3.6 错误处理文件不存在、无权限fs.readFile可能抛出各种错误ENOENT文件不存在EACCES没有权限EISDIR路径是一个目录不是文件我们的try-catch会捕获所有这些错误返回统一的错误信息。AI 看到{ success: false, error: “ENOENT: no such file” }就知道该换个文件试试。四、copy复制文件4.1 基本实现asyncfunctioncopy(src,dest){constfullSrcresolvePath(src);constfullDestresolvePath(dest);try{awaitfs.copyFile(fullSrc,fullDest);return{success:true,data:已复制到:${fullDest}};}catch(error){return{success:false,error:error.message};}}4.2 fs.copyFile 的行为fs.copyFile有几个特点覆盖行为如果目标文件已存在默认会覆盖。这是合理的因为 AI 说“复制到 b.txt”通常意味着覆盖。不复制元数据不保留创建时间、修改时间等。对于大多数场景够用。不复制目录只能复制文件不能复制文件夹。如果需要复制整个目录需要用其他方法。4.3 目标路径是目录怎么办用户可能这样调用copy(“a.txt”, “backup/”)目标是目录不是文件名。程序应该把a.txt复制到backup/a.txt。我们需要判断目标路径是目录还是文件。conststatawaitfs.stat(fullDest).catch(()null);if(statstat.isDirectory()){// 目标是目录把源文件名拼上去constsrcFileNamepath.basename(fullSrc);fullDestpath.join(fullDest,srcFileName);}path.basename()提取路径的最后一部分path.basename(“D:\my-project\a.txt”)→“a.txt”CogitoAgent 当前版本没有实现这个逻辑这是一个可以改进的地方。五、mkdir创建文件夹5.1 基本实现asyncfunctionmkdir(targetPath){constfullPathresolvePath(targetPath);try{awaitfs.mkdir(fullPath,{recursive:true});return{success:true,data:已创建目录:${fullPath}};}catch(error){return{success:false,error:error.message};}}5.2 recursive 选项的作用{ recursive: true }的作用没有这个选项mkdir(“a/b/c”)如果a或a/b不存在会报错有这个选项mkdir(“a/b/c”)会自动创建a、a/b、a/b/c所有层级这类似于mkdir -p命令。5.3 目录已存在的情况如果目录已经存在fs.mkdir会报错。但加上recursive: true后已存在的目录不会报错——这是一个很好的特性。六、create创建并写入文件6.1 基本实现asyncfunctioncreate(targetPath,content){constfullPathresolvePath(targetPath);try{// 确保父目录存在constparentDirpath.dirname(fullPath);awaitfs.mkdir(parentDir,{recursive:true});// 写入文件awaitfs.writeFile(fullPath,content,utf-8);return{success:true,data:已创建:${fullPath}};}catch(error){return{success:false,error:error.message};}}6.2 为什么需要手动创建父目录fs.writeFile不会自动创建父目录。如果父目录不存在会报ENOENT。所以我们先调用fs.mkdir(parentDir, { recursive: true })确保父目录存在。注意这个mkdir和我们写的mkdir工具是不同层的。这里是 Node.js 原生的fs.mkdir。6.3 覆盖行为fs.writeFile如果文件已存在会覆盖内容。这通常是期望的行为——用户说“创建”一个文件如果已经有了可能就是“覆盖”的意思。七、错误处理让AI能理解失败原因7.1 常见错误类型错误码含义AI 应该怎么做ENOENT文件/目录不存在尝试其他路径或者告诉用户EACCES没有权限告诉用户需要权限或者换个文件EISDIR路径是目录但期望是文件检查路径是否正确ENOTDIR路径是文件但期望是目录同上EEXIST文件/目录已存在如果是创建操作可以告知用户7.2 错误信息的可读性直接返回error.messageAI 看到的是ENOENT: no such file or directory, open D:\my-project\notexist.txt这对 AI 来说足够清晰。它知道“no such file or directory”意味着文件不存在。7.3 统一返回格式所有工具函数都返回{ success, data/error }调用方不需要为每个工具单独处理错误。八、安全设计回顾这一篇我们看到了文件工具设计的层层安全防护第一层工作区隔离 ↓ 第二层路径解析 边界检查防止 .. 逃逸 ↓ 第三层符号链接检查防止链接指向外部 ↓ 第四层二进制检测防止无效内容进入上下文 ↓ 第五层大小限制防止上下文爆炸 ↓ 第六层不提供删除操作防止误删每一层都解决一个特定的风险。这不是过度设计——在 AI 安全领域宁可多一道检查也不能留下一个漏洞。九、小结工具核心实现关键注意点resolvePathpath.resolve(workspace, inputPath)边界检查、符号链接lsfs.readdirwithFileTypes: true限制返回数量、格式化输出readfs.readFile 二进制检测 截断防止二进制乱码、防止超大文件copyfs.copyFile处理目标为目录的情况mkdirfs.mkdirrecursive: true自动创建父目录createfs.mkdirfs.writeFile先建父目录后写文件核心设计原则给 AI 画一个院子工作区限制活动范围所有路径经过归一化和边界检查二进制文件不读内容大文件自动截断危险操作删除不给权限下一篇预告联网能力我们将深入web.js看看 AI 如何联网搜索搜索 API 的封装抓取网页内容Cheerio 解析 HTML在浏览器中打开链接如果这篇文章对你有帮助欢迎 ⭐Star 支持一下开源项目 https://gitee.com/cnt-code/cogito-agent
文件管理:让AI安全操作你的电脑 ——CogitoAgent开发实战(三)
发布时间:2026/6/9 10:31:43
文件管理让AI安全操作你的电脑——CogitoAgent开发实战第3篇 本文是专栏的第三篇。上一篇我们讲了工具系统的整体架构给AI装上了一双手。但有了手之后我们要解决两个更根本的问题这双手能伸到哪里伸出去之后怎么保证不碰坏东西这一篇我们深入文件管理工具从最基础的路径解析开始一步步构建一个安全的文件操作体系。 从一个思想实验开始闭上眼睛想象你是一个机器人。有人给你下达指令“把a.txt复制到b.txt。”你能执行这个指令吗不能。因为你不知道a.txt在哪里是当前目录还是桌面还是某个角落如果a.txt不存在怎么办如果b.txt已经存在是覆盖还是报错你有没有权限读a.txt、写b.txt你看一个看似简单的“复制文件”背后藏着这么多问题。编程也是如此。写代码不是写指令而是把人类语言里的“隐含信息”全部显式化。这一篇我们就来拆解这些隐含信息。一、路径文件操作的第一道门槛1.1 什么是路径路径就是文件在电脑里的“地址”。就像你家有门牌号XX省XX市XX区XX路XX号。电脑里的路径也类似D:\my-project\src\index.js路径有两种写法绝对路径从盘符Windows或根目录Mac/Linux开始写完整描述地址。Windows: D:\my-project\src\index.js Mac: /Users/xxx/projects/index.js相对路径以“当前位置”为参考描述相对位置。src/index.js # 当前目录下的 src 文件夹里的 index.js ../docs/readme.md # 上一级目录下的 docs 文件夹里的 readme.md1.2 为什么相对路径有问题我们给AI下的指令通常用的是相对路径“帮我看一下src目录”。问题是“当前目录”是什么在不同场景下“当前目录”的含义不同在命令行里当前目录是你敲命令时所在的目录在程序中当前目录是启动程序时的目录process.cwd()但对AI来说这个概念是模糊的。AI可能以为“当前目录”是它想当然的那个目录。解决方案我们不给AI解释“当前目录”的概念而是约定一个固定的基准点。所有相对路径都基于这个基准点解析。这个基准点就是——工作区根目录。1.3 工作区给AI画一个院子工作区workspace是一个你指定的文件夹。AI的所有操作都必须在这个文件夹里进行。就像你给机器人画了一个院子告诉它“你只能在院子里活动不准出去。”配置示例// config.json{workspace:D:\\my-project}AI执行ls(“src”)时程序会把src拼接到工作区后面得到D:\my-project\src。1.4 路径解析的核心函数现在我们来实现路径解析。这个函数是所有文件工具的基础。先思考需求输入一个路径可能是相对路径也可能是绝对路径输出一个绝对路径确保输出的路径在工作区内第一步处理相对路径functionresolvePath(inputPath){constworkspacegetWorkspace();// 比如 D:\\my-project// 判断是绝对路径还是相对路径if(path.isAbsolute(inputPath)){// 绝对路径直接使用returninputPath;}else{// 相对路径拼接到工作区后面returnpath.join(workspace,inputPath);}}path.isAbsolute()是 Node.js 提供的方法判断一个路径是否是绝对路径。在 Windows 上path.isAbsolute(“D:\\my-project”)→truepath.isAbsolute(“src”)→falsepath.join()的作用是把多个路径片段拼接成一个完整的路径。path.join(“D:\\my-project”, “src”)→D:\my-project\srcpath.join(“D:\\my-project”, “..\\secret”)→D:\my-project\..\secret→D:\secret注意..被解析了等等这里有个问题..表示“上一级目录”。D:\my-project\..\secret解析后是D:\secret跳出了工作区这就是“路径遍历攻击”Path Traversal。恶意输入可以用..跑到工作区外面。1.5 防止路径遍历归一化 边界检查我们需要两步归一化把..和.解析成真实的路径边界检查确认解析后的路径确实在工作区内path.resolve()可以把相对路径转成绝对路径同时自动处理..和.。functionresolvePath(inputPath){constworkspacepath.resolve(getWorkspace());// 先归一化工作区路径constfullPathpath.resolve(workspace,inputPath);// 基于工作区解析输入路径// 边界检查fullPath 必须以 workspace 开头if(!fullPath.startsWith(workspace)){thrownewError(安全限制路径${inputPath}指向了工作区之外);}returnfullPath;}测试一下resolvePath(src)// workspace D:\\my-project// fullPath D:\\my-project\\src// startsWith? true ✅resolvePath(../secret)// workspace D:\\my-project// fullPath D:\\secret// startsWith? false ❌ 抛出错误1.6 符号链接的隐患还有更隐蔽的问题符号链接。假设工作区里有一个符号链接指向外部目录D:\my-project\external_link - C:\WindowsAI 调用ls(“external_link”)我们的路径解析会怎样resolvePath(external_link) // workspace D:\\my-project // fullPath D:\\my-project\\external_link // startsWith? true ✅ 通过检查但当我们读取external_link时Node.js 会跟随这个链接实际上读取的是C:\Windows的内容解决方案用fs.realpath()获取链接的真实路径。constrealPathawaitfs.realpath(fullPath);if(!realPath.startsWith(workspace)){thrownewError(符号链接指向了工作区之外);}这样即使用户创建了指向外部的链接AI 也无法通过它逃逸。二、ls让AI“看见”目录结构2.1 读取目录fs.readdirfs.readdir是 Node.js 读取目录的方法。constentriesawaitfs.readdir(fullPath);console.log(entries);// [agent, api, config.js, ...]它只返回文件名不告诉你这是一个文件还是文件夹。要区分类型需要第二个参数constentriesawaitfs.readdir(fullPath,{withFileTypes:true});// entries[0] 是一个 Dirent 对象console.log(entries[0].isDirectory());// true 或 falsewithFileTypes: true让readdir返回Dirent对象而不是字符串。Dirent对象有isDirectory()、isFile()、isSymbolicLink()等方法。2.2 构建返回数据我们想把每个条目的信息整理成统一格式constresultentries.map(entry({name:entry.name,// agenttype:entry.isDirectory()?dir:file,// dirpath:path.join(fullPath,entry.name)// D:\\my-project\\agent}));2.3 限制数量为什么不能全返回如果一个目录有 10,000 个文件全部返回会怎样上下文占用10,000 个文件名每个平均 20 字符 200,000 字符Token 消耗约 50,000 token按中文估算AI 能处理吗能但没必要。AI 不需要知道每一个文件的名称它只需要知道“大概有什么”。所以我们要做两件事返回数据限制在返回给 AI 的数据上做限制显示格式化让人类阅读时更清晰// 限制返回数量constMAX_ITEMS50;constlimitedresult.slice(0,MAX_ITEMS);return{success:true,data:limited};但这样有个问题AI 不知道还有更多文件被截断了。所以我们可以在返回时加上提示letdatalimited;if(result.lengthMAX_ITEMS){data[...limited,... 还有${result.length-MAX_ITEMS}个条目未显示];}2.4 格式化显示让输出更清晰直接显示 JSON 数组人类看起来费劲[{name:agent,type:dir},{name:api,type:dir},{name:config.js,type:file}]我们把它格式化成树状结构[目录] agent/ api/ [文件] config.js index.js实现代码functionformatLsResult(data){constdirsdata.filter(ii.typedir);constfilesdata.filter(ii.typefile);letlines[];if(dirs.length0){lines.push( [目录]);for(constdirofdirs.slice(0,20)){lines.push(${dir.name}/);}if(dirs.length20){lines.push(... 还有${dirs.length-20}个目录);}}if(files.length0){lines.push( [文件]);for(constfileoffiles.slice(0,20)){lines.push(${file.name});}if(files.length20){lines.push(... 还有${files.length-20}个文件);}}returnlines.length?lines.join(\n): (空目录);}注意这个格式化函数只用于终端显示不用于返回给 AI。AI 看到的是原始数据JSON 数组因为 AI 需要完整信息来做决策。终端显示截断是为了人类阅读AI 的数据不截断。三、read让AI“看懂”文件内容3.1 读取文件fs.readFileconstbufferawaitfs.readFile(fullPath);constcontentbuffer.toString(utf-8);fs.readFile返回的是Buffer二进制数据。调用toString(‘utf-8’)将其转换成文本。3.2 二进制检测读之前先判断如果文件是图片、PDF、可执行文件读成文本会变成乱码浪费 token。怎么判断文件是不是二进制方法一扩展名黑名单constBINARY_EXTENSIONSnewSet([.pdf,.png,.jpg,.jpeg,.gif,.bmp,.ico,.mp3,.wav,.ogg,.flac,.aac,.mp4,.avi,.mkv,.mov,.zip,.rar,.7z,.tar,.gz,.exe,.dll,.so,.dylib,.doc,.docx,.xls,.xlsx,.ppt,.pptx]);这个方法很快但可能漏掉一些文件比如没有扩展名或者扩展名不在列表里。方法二内容检测null 字节检测二进制文件和文本文件的核心区别二进制文件经常包含\0null 字节而文本文件几乎不含。functionhasNullByte(buffer,limit8192){for(leti0;iMath.min(buffer.length,limit);i){if(buffer[i]0)returntrue;}returnfalse;}为什么只检查前 8192 个字节因为对于判断文件类型来说前 8KB 足够了。而且性能更好。组合策略functionisBinaryFile(filePath,buffer){constextpath.extname(filePath).toLowerCase();if(BINARY_EXTENSIONS.has(ext))returntrue;if(hasNullByte(buffer))returntrue;returnfalse;}3.3 大小限制防止上下文爆炸即使文件是文本也可能非常大比如 100MB 的日志文件。我们需要限制读取的大小constMAX_SIZE50000;// 5万字符约 1.5 万 tokenletcontentbuffer.toString(utf-8);lettruncatedfalse;if(content.lengthMAX_SIZE){contentcontent.substring(0,MAX_SIZE);truncatedtrue;}letresultcontent;if(truncated){result\n\n[注意文件内容过长已截断。完整文件共${buffer.length}字符当前显示前${MAX_SIZE}字符];}return{success:true,data:result};5万字符是个经验值。大多数 LLM 的上下文窗口是 8k-32k token5万字符中文约 1.5 万 token是安全的同时足够读入一个中等大小的源文件。3.4 编码问题不只是 UTF-8不是所有文本文件都是 UTF-8 编码。如果读一个 GBK 编码的文件会怎样buffer.toString(utf-8)// 可能输出乱码解决方案检测编码。但为了简化CogitoAgent 只支持 UTF-8。如果遇到乱码AI 会看到一堆奇怪的符号然后可以尝试用其他方式处理比如用fetchPage抓取不适用。这是一个已知限制。3.5 完整实现asyncfunctionread(targetPath){constfullPathresolvePath(targetPath);// 使用安全路径解析try{constbufferawaitfs.readFile(fullPath);// 二进制检测if(isBinaryFile(fullPath,buffer)){constsizeKB(buffer.length/1024).toFixed(1);return{success:true,data:[二进制文件 (${sizeKB}KB)无法显示文本内容]};}// 转成文本letcontentbuffer.toString(utf-8);lettruncatedfalse;// 大小限制if(content.length50000){contentcontent.substring(0,50000);truncatedtrue;}letdatacontent;if(truncated){data\n\n[内容过长已截断];}return{success:true,data};}catch(error){return{success:false,error:error.message};}}3.6 错误处理文件不存在、无权限fs.readFile可能抛出各种错误ENOENT文件不存在EACCES没有权限EISDIR路径是一个目录不是文件我们的try-catch会捕获所有这些错误返回统一的错误信息。AI 看到{ success: false, error: “ENOENT: no such file” }就知道该换个文件试试。四、copy复制文件4.1 基本实现asyncfunctioncopy(src,dest){constfullSrcresolvePath(src);constfullDestresolvePath(dest);try{awaitfs.copyFile(fullSrc,fullDest);return{success:true,data:已复制到:${fullDest}};}catch(error){return{success:false,error:error.message};}}4.2 fs.copyFile 的行为fs.copyFile有几个特点覆盖行为如果目标文件已存在默认会覆盖。这是合理的因为 AI 说“复制到 b.txt”通常意味着覆盖。不复制元数据不保留创建时间、修改时间等。对于大多数场景够用。不复制目录只能复制文件不能复制文件夹。如果需要复制整个目录需要用其他方法。4.3 目标路径是目录怎么办用户可能这样调用copy(“a.txt”, “backup/”)目标是目录不是文件名。程序应该把a.txt复制到backup/a.txt。我们需要判断目标路径是目录还是文件。conststatawaitfs.stat(fullDest).catch(()null);if(statstat.isDirectory()){// 目标是目录把源文件名拼上去constsrcFileNamepath.basename(fullSrc);fullDestpath.join(fullDest,srcFileName);}path.basename()提取路径的最后一部分path.basename(“D:\my-project\a.txt”)→“a.txt”CogitoAgent 当前版本没有实现这个逻辑这是一个可以改进的地方。五、mkdir创建文件夹5.1 基本实现asyncfunctionmkdir(targetPath){constfullPathresolvePath(targetPath);try{awaitfs.mkdir(fullPath,{recursive:true});return{success:true,data:已创建目录:${fullPath}};}catch(error){return{success:false,error:error.message};}}5.2 recursive 选项的作用{ recursive: true }的作用没有这个选项mkdir(“a/b/c”)如果a或a/b不存在会报错有这个选项mkdir(“a/b/c”)会自动创建a、a/b、a/b/c所有层级这类似于mkdir -p命令。5.3 目录已存在的情况如果目录已经存在fs.mkdir会报错。但加上recursive: true后已存在的目录不会报错——这是一个很好的特性。六、create创建并写入文件6.1 基本实现asyncfunctioncreate(targetPath,content){constfullPathresolvePath(targetPath);try{// 确保父目录存在constparentDirpath.dirname(fullPath);awaitfs.mkdir(parentDir,{recursive:true});// 写入文件awaitfs.writeFile(fullPath,content,utf-8);return{success:true,data:已创建:${fullPath}};}catch(error){return{success:false,error:error.message};}}6.2 为什么需要手动创建父目录fs.writeFile不会自动创建父目录。如果父目录不存在会报ENOENT。所以我们先调用fs.mkdir(parentDir, { recursive: true })确保父目录存在。注意这个mkdir和我们写的mkdir工具是不同层的。这里是 Node.js 原生的fs.mkdir。6.3 覆盖行为fs.writeFile如果文件已存在会覆盖内容。这通常是期望的行为——用户说“创建”一个文件如果已经有了可能就是“覆盖”的意思。七、错误处理让AI能理解失败原因7.1 常见错误类型错误码含义AI 应该怎么做ENOENT文件/目录不存在尝试其他路径或者告诉用户EACCES没有权限告诉用户需要权限或者换个文件EISDIR路径是目录但期望是文件检查路径是否正确ENOTDIR路径是文件但期望是目录同上EEXIST文件/目录已存在如果是创建操作可以告知用户7.2 错误信息的可读性直接返回error.messageAI 看到的是ENOENT: no such file or directory, open D:\my-project\notexist.txt这对 AI 来说足够清晰。它知道“no such file or directory”意味着文件不存在。7.3 统一返回格式所有工具函数都返回{ success, data/error }调用方不需要为每个工具单独处理错误。八、安全设计回顾这一篇我们看到了文件工具设计的层层安全防护第一层工作区隔离 ↓ 第二层路径解析 边界检查防止 .. 逃逸 ↓ 第三层符号链接检查防止链接指向外部 ↓ 第四层二进制检测防止无效内容进入上下文 ↓ 第五层大小限制防止上下文爆炸 ↓ 第六层不提供删除操作防止误删每一层都解决一个特定的风险。这不是过度设计——在 AI 安全领域宁可多一道检查也不能留下一个漏洞。九、小结工具核心实现关键注意点resolvePathpath.resolve(workspace, inputPath)边界检查、符号链接lsfs.readdirwithFileTypes: true限制返回数量、格式化输出readfs.readFile 二进制检测 截断防止二进制乱码、防止超大文件copyfs.copyFile处理目标为目录的情况mkdirfs.mkdirrecursive: true自动创建父目录createfs.mkdirfs.writeFile先建父目录后写文件核心设计原则给 AI 画一个院子工作区限制活动范围所有路径经过归一化和边界检查二进制文件不读内容大文件自动截断危险操作删除不给权限下一篇预告联网能力我们将深入web.js看看 AI 如何联网搜索搜索 API 的封装抓取网页内容Cheerio 解析 HTML在浏览器中打开链接如果这篇文章对你有帮助欢迎 ⭐Star 支持一下开源项目 https://gitee.com/cnt-code/cogito-agent