文件管理:让AI安全操作你的电脑 ——CogitoAgent开发实战(三)
文件管理:让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){constworkspace=getWorkspace();// 比如 "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”)→false
path.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){constworkspace=path.resolve(getWorkspace());// 先归一化工作区路径constfullPath=path.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()获取链接的真实路径。
constrealPath=awaitfs.realpath(fullPath);if(!realPath.startsWith(workspace)){thrownewError(`符号链接指向了工作区之外`);}这样,即使用户创建了指向外部的链接,AI 也无法通过它逃逸。
二、ls:让AI“看见”目录结构
2.1 读取目录:fs.readdir
fs.readdir是 Node.js 读取目录的方法。
constentries=awaitfs.readdir(fullPath);console.log(entries);// ['agent', 'api', 'config.js', ...]它只返回文件名,不告诉你这是一个文件还是文件夹。
要区分类型,需要第二个参数:
constentries=awaitfs.readdir(fullPath,{withFileTypes:true});// entries[0] 是一个 Dirent 对象console.log(entries[0].isDirectory());// true 或 falsewithFileTypes: true让readdir返回Dirent对象,而不是字符串。Dirent对象有isDirectory()、isFile()、isSymbolicLink()等方法。
2.2 构建返回数据
我们想把每个条目的信息整理成统一格式:
constresult=entries.map(entry=>({name:entry.name,// "agent"type:entry.isDirectory()?'dir':'file',// "dir"path: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_ITEMS=50;constlimited=result.slice(0,MAX_ITEMS);return{success:true,data:limited};但这样有个问题:AI 不知道还有更多文件被截断了。所以我们可以在返回时加上提示:
letdata=limited;if(result.length>MAX_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){constdirs=data.filter(i=>i.type==='dir');constfiles=data.filter(i=>i.type==='file');letlines=[];if(dirs.length>0){lines.push(' [目录]');for(constdirofdirs.slice(0,20)){lines.push(`${dir.name}/`);}if(dirs.length>20){lines.push(`... 还有${dirs.length-20}个目录`);}}if(files.length>0){lines.push(' [文件]');for(constfileoffiles.slice(0,20)){lines.push(`${file.name}`);}if(files.length>20){lines.push(`... 还有${files.length-20}个文件`);}}returnlines.length?lines.join('\n'):' (空目录)';}注意:这个格式化函数只用于终端显示,不用于返回给 AI。AI 看到的是原始数据(JSON 数组),因为 AI 需要完整信息来做决策。终端显示截断是为了人类阅读,AI 的数据不截断。
三、read:让AI“看懂”文件内容
3.1 读取文件:fs.readFile
constbuffer=awaitfs.readFile(fullPath);constcontent=buffer.toString('utf-8');fs.readFile返回的是Buffer(二进制数据)。调用toString(‘utf-8’)将其转换成文本。
3.2 二进制检测:读之前先判断
如果文件是图片、PDF、可执行文件,读成文本会变成乱码,浪费 token。
怎么判断文件是不是二进制?
方法一:扩展名黑名单
constBINARY_EXTENSIONS=newSet(['.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 字节检测)
二进制文件和文本文件的核心区别:二进制文件经常包含\0(null 字节),而文本文件几乎不含。
functionhasNullByte(buffer,limit=8192){for(leti=0;i<Math.min(buffer.length,limit);i++){if(buffer[i]===0)returntrue;}returnfalse;}为什么只检查前 8192 个字节?因为对于判断文件类型来说,前 8KB 足够了。而且性能更好。
组合策略:
functionisBinaryFile(filePath,buffer){constext=path.extname(filePath).toLowerCase();if(BINARY_EXTENSIONS.has(ext))returntrue;if(hasNullByte(buffer))returntrue;returnfalse;}3.3 大小限制:防止上下文爆炸
即使文件是文本,也可能非常大(比如 100MB 的日志文件)。
我们需要限制读取的大小:
constMAX_SIZE=50000;// 5万字符,约 1.5 万 tokenletcontent=buffer.toString('utf-8');lettruncated=false;if(content.length>MAX_SIZE){content=content.substring(0,MAX_SIZE);truncated=true;}letresult=content;if(truncated){result+=`\n\n[注意:文件内容过长,已截断。完整文件共${buffer.length}字符,当前显示前${MAX_SIZE}字符]`;}return{success:true,data:result};5万字符是个经验值。大多数 LLM 的上下文窗口是 8k-32k token,5万字符(中文约 1.5 万 token)是安全的,同时足够读入一个中等大小的源文件。
3.4 编码问题:不只是 UTF-8
不是所有文本文件都是 UTF-8 编码。如果读一个 GBK 编码的文件会怎样?
buffer.toString('utf-8')// 可能输出乱码解决方案:检测编码。但为了简化,CogitoAgent 只支持 UTF-8。如果遇到乱码,AI 会看到一堆奇怪的符号,然后可以尝试用其他方式处理(比如用fetchPage抓取?不适用)。这是一个已知限制。
3.5 完整实现
asyncfunctionread(targetPath){constfullPath=resolvePath(targetPath);// 使用安全路径解析try{constbuffer=awaitfs.readFile(fullPath);// 二进制检测if(isBinaryFile(fullPath,buffer)){constsizeKB=(buffer.length/1024).toFixed(1);return{success:true,data:`[二进制文件 (${sizeKB}KB),无法显示文本内容]`};}// 转成文本letcontent=buffer.toString('utf-8');lettruncated=false;// 大小限制if(content.length>50000){content=content.substring(0,50000);truncated=true;}letdata=content;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){constfullSrc=resolvePath(src);constfullDest=resolvePath(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。
我们需要判断目标路径是目录还是文件。
conststat=awaitfs.stat(fullDest).catch(()=>null);if(stat&&stat.isDirectory()){// 目标是目录,把源文件名拼上去constsrcFileName=path.basename(fullSrc);fullDest=path.join(fullDest,srcFileName);}path.basename()提取路径的最后一部分:path.basename(“D:\my-project\a.txt”)→“a.txt”
CogitoAgent 当前版本没有实现这个逻辑,这是一个可以改进的地方。
五、mkdir:创建文件夹
5.1 基本实现
asyncfunctionmkdir(targetPath){constfullPath=resolvePath(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){constfullPath=resolvePath(targetPath);try{// 确保父目录存在constparentDir=path.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.message,AI 看到的是:
ENOENT: no such file or directory, open 'D:\my-project\notexist.txt'这对 AI 来说足够清晰。它知道“no such file or directory”意味着文件不存在。
7.3 统一返回格式
所有工具函数都返回{ success, data/error },调用方不需要为每个工具单独处理错误。
八、安全设计回顾
这一篇,我们看到了文件工具设计的层层安全防护:
第一层:工作区隔离 ↓ 第二层:路径解析 + 边界检查(防止 .. 逃逸) ↓ 第三层:符号链接检查(防止链接指向外部) ↓ 第四层:二进制检测(防止无效内容进入上下文) ↓ 第五层:大小限制(防止上下文爆炸) ↓ 第六层:不提供删除操作(防止误删)每一层都解决一个特定的风险。这不是过度设计——在 AI 安全领域,宁可多一道检查,也不能留下一个漏洞。
九、小结
| 工具 | 核心实现 | 关键注意点 |
|---|---|---|
resolvePath | path.resolve(workspace, inputPath) | 边界检查、符号链接 |
ls | fs.readdir+withFileTypes: true | 限制返回数量、格式化输出 |
read | fs.readFile+ 二进制检测 + 截断 | 防止二进制乱码、防止超大文件 |
copy | fs.copyFile | 处理目标为目录的情况 |
mkdir | fs.mkdir+recursive: true | 自动创建父目录 |
create | fs.mkdir+fs.writeFile | 先建父目录,后写文件 |
核心设计原则:
- 给 AI 画一个院子(工作区),限制活动范围
- 所有路径经过归一化和边界检查
- 二进制文件不读内容
- 大文件自动截断
- 危险操作(删除)不给权限
下一篇预告:联网能力
我们将深入web.js,看看 AI 如何:
- 联网搜索(搜索 API 的封装)
- 抓取网页内容(Cheerio 解析 HTML)
- 在浏览器中打开链接
如果这篇文章对你有帮助,欢迎 ⭐Star 支持一下开源项目!
👉 https://gitee.com/cnt-code/cogito-agent 👈
