当前位置: 首页 > news >正文

文件管理:让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.md

1.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 路径解析的核心函数

现在我们来实现路径解析。这个函数是所有文件工具的基础。

先思考需求:

  1. 输入一个路径(可能是相对路径,也可能是绝对路径)
  2. 输出一个绝对路径
  3. 确保输出的路径在工作区内

第一步:处理相对路径

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”)true
  • path.isAbsolute(“src”)false

path.join()的作用是把多个路径片段拼接成一个完整的路径。

  • path.join(“D:\\my-project”, “src”)D:\my-project\src
  • path.join(“D:\\my-project”, “..\\secret”)D:\my-project\..\secretD:\secret(注意..被解析了)

等等,这里有个问题!..表示“上一级目录”。D:\my-project\..\secret解析后是D:\secret,跳出了工作区!

这就是“路径遍历攻击”(Path Traversal)。恶意输入可以用..跑到工作区外面。

1.5 防止路径遍历:归一化 + 边界检查

我们需要两步:

  1. 归一化:把...解析成真实的路径
  2. 边界检查:确认解析后的路径确实在工作区内

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:\Windows

AI 调用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 或 false

withFileTypes: truereaddir返回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 不需要知道每一个文件的名称,它只需要知道“大概有什么”。

所以我们要做两件事:

  1. 返回数据限制:在返回给 AI 的数据上做限制
  2. 显示格式化:让人类阅读时更清晰
// 限制返回数量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有几个特点:

  1. 覆盖行为:如果目标文件已存在,默认会覆盖。这是合理的,因为 AI 说“复制到 b.txt”,通常意味着覆盖。
  2. 不复制元数据:不保留创建时间、修改时间等。对于大多数场景够用。
  3. 不复制目录:只能复制文件,不能复制文件夹。如果需要复制整个目录,需要用其他方法。

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”)如果aa/b不存在,会报错
  • 有这个选项:mkdir(“a/b/c”)会自动创建aa/ba/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 安全领域,宁可多一道检查,也不能留下一个漏洞。


九、小结

工具核心实现关键注意点
resolvePathpath.resolve(workspace, inputPath)边界检查、符号链接
lsfs.readdir+withFileTypes: true限制返回数量、格式化输出
readfs.readFile+ 二进制检测 + 截断防止二进制乱码、防止超大文件
copyfs.copyFile处理目标为目录的情况
mkdirfs.mkdir+recursive: true自动创建父目录
createfs.mkdir+fs.writeFile先建父目录,后写文件

核心设计原则

  1. 给 AI 画一个院子(工作区),限制活动范围
  2. 所有路径经过归一化和边界检查
  3. 二进制文件不读内容
  4. 大文件自动截断
  5. 危险操作(删除)不给权限

下一篇预告:联网能力

我们将深入web.js,看看 AI 如何:

  • 联网搜索(搜索 API 的封装)
  • 抓取网页内容(Cheerio 解析 HTML)
  • 在浏览器中打开链接

如果这篇文章对你有帮助,欢迎 ⭐Star 支持一下开源项目!

👉 https://gitee.com/cnt-code/cogito-agent 👈

http://www.jsqmd.com/news/980625/

相关文章:

  • 西北工业大学考研辅导班推荐,优质定向培训机构盘点 - 推荐优选师
  • 别再只盯着TPM了!从国产TPCM的静/动态度量链,聊聊可信启动的实战落地
  • Pluto SDR实战避坑:OFDM系统同步与信道估计的那些‘坑’及MATLAB调试技巧
  • WELearn网课助手:终极指南,5分钟实现英语学习自由
  • 如何快速批量下载网易云音乐歌单的FLAC无损音乐:技术实现与实用指南
  • 2026Q3花都工商注册机构排名|权威持证著书行业龙头正规靠谱 - 品牌智鉴榜
  • 立创EDA手动拼板实战:什么时候必须自己动手?复制粘贴整板的正确姿势
  • 模型训练全景指南:从核心术语到实战技巧的深度解析
  • 社交媒体从社交转向娱乐,广告收入增长但用户活跃度下降?
  • 告别客户端束缚:wechat-need-web插件让你在浏览器中畅享微信网页版
  • 安阳市2026年市民高频选择的5家实体黄金回收白银回收铂金回收门店实地测评整理 - 嵩山路大王
  • SerialPlot隐藏功能实战:除了看波形,还能这样玩转串口数据记录与自动化分析
  • 邯郸劳动争议律师石娜:深耕多领域的专业法律服务者 邯郸工伤赔偿律师 - 律界观察
  • 懿光文化传媒创始人王倩雯:“叛酷仔崽团”IP让孩子体会 “叛而向善,酷而有温,抱团相守”的情谊 - 博客万
  • 如何在10分钟内搭建个人游戏云:Sunshine开源串流服务器完整指南
  • 2026 年宁波长途搬家服务 TOP5 测评 跨城搬家怎么选不踩坑 - LYL仔仔
  • FPGA出租车计价器全套实现资料:原理图+VHDL源码+仿真截图+操作指南
  • 别再死记硬背了!用‘放回抽球’和‘不放回抽球’搞懂马尔可夫链到底在说啥
  • 人工智能AI专业详解及未来发展全景
  • 别再死记硬背Modbus帧格式了!用STM32CubeMX+FreeRTOS实战RTU通信(附避坑点)
  • 东莞三程电子商务有限公司:让天下没有难做的电商
  • 2026 年广州天河区靠谱工商注册公司推荐|资质过硬 行业权威 一站式服务 - 品牌智鉴榜
  • Adult数据集上跑通收入预测全流程:逻辑回归到XGBoost,带注释代码和运行指南
  • 2026防渗土工布厂家排名参考:5家实力服务商综合分析 - 资讯焦点
  • 告别卡顿!用Clumsy在Windows上5分钟搞定App弱网模拟测试(附保姆级配置)
  • 深入解析wxappUnpacker:微信小程序逆向工程的必备神器 [特殊字符]
  • 泉州鲤城区金价高位,市民变现黄金上门回收攻略 - 上门黄金回收
  • 机器学习入门避坑指南:从数学直觉到工程规范的筑基路径
  • RAG 项目瓶颈竟在文档解析?掌握这5大技巧,知识库效果飙升10倍!
  • 2026 十大智能马桶品牌质量售后选购指南(高定定制 低水压适配测评) - 博客万