主题
安全文件读取
本课交付结果
你将交付 resolveWorkspacePath()、listFiles() 和带大小上限的 readFile()。正常相对路径可以读取;绝对路径、.. 穿越、符号链接逃逸和超大文件都会被明确拒绝。
这是第一个真正触碰用户仓库的工具。它建立的边界会被后续写文件、补丁和命令工具复用。
岗位问题
Coding Agent 接收的是模型生成路径,不能假设它永远善意或正确。仅执行 resolve(root, input).startsWith(root) 会被相似前缀、平台分隔符和符号链接欺骗;仅拒绝 .. 又挡不住工作区内指向外部敏感文件的 symlink。
岗位安全实现要分两层:词法层拒绝明显危险输入,文件系统层解析真实路径后再次证明目标仍在真实工作区内。
前置检查
前置知识快照
先验证工具合同与 Registry:
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 06
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 07理解 resolve、relative、realpath 和 symlink 的区别。注意 macOS 中 /var 的真实路径可能是 /private/var:安全比较应使用 realpath,但对外返回值应遵守工具合同。
resolve 只做词法规范化,不访问磁盘;realpath 会访问文件系统并展开符号链接;relative 根据平台规则表达两个绝对路径之间的关系。安全判断必须在同一规范化层级比较同类值。拿真实根目录与未展开的候选路径比较,会把系统别名或链接当成越界,也可能漏掉真实逃逸。
还要把路径参数视为不可信数据。它可能来自模型、用户粘贴、仓库内提示注入或第三方插件。提示词里写“只能访问工作区”不是强制边界;只有工具实现拒绝越界,才有可以测试和审计的安全承诺。
原理拆解
安全解析按以下顺序完成:
- 词法检查:拒绝绝对路径、Windows drive path 和任何
..段。 - 用调用方工作区路径构造候选绝对路径。
- 分别取得 root 与候选目标的
realpath,展开符号链接。 - 用
relative(realRoot, realTarget)判断目标是否为真正子路径。 - 读取前再检查文件大小上限。
词法检查提供快速、清晰的错误;realpath 检查才负责阻断 symlink 逃逸。两者解决不同威胁,不能互相替代。
mermaid
flowchart TD
A[模型给出相对路径] --> B{绝对路径或含上跳段?}
B -- 是 --> C[词法拒绝]
B -- 否 --> D[构造候选路径]
D --> E[realpath 工作区]
D --> F[realpath 目标]
E --> G[path.relative 比较]
F --> G
G -- 工作区外 --> H[真实边界拒绝]
G -- 工作区内 --> I[stat 尺寸与类型]
I --> J[读取 UTF-8 文本]第一层为什么还要拒绝 ..,既然后面有 realpath?因为错误应该尽量靠近输入,并且未存在目标无法取得 realpath。显式拒绝任何上跳段,使合同简单:工具只接受工作区相对、向下导航的路径。即使 a/../b 最终仍在根内,也不值得为这种表达增加歧义。
绝对路径要跨平台识别。在 POSIX 主机上,Node 的 path.isAbsolute("C:\\secret") 可能不会按 Windows 路径理解;Lab 因此额外检查 drive path。Agent 生成的字符串不一定服从当前主机风格,跨平台输入验证应覆盖斜杠、反斜杠、盘符和网络共享形式。
父子关系不能用字符串前缀判断。根目录 /repo 与兄弟目录 /repo-secret 共享前缀,却没有包含关系。relative(realRoot, realTarget) 若返回空串表示根本身,返回普通向下路径表示内部,返回 ..、以 ../ 开头或绝对路径表示外部。路径库比手写分隔符判断更能适配平台语义。
符号链接是第二层的核心。仓库中的 docs/latest 可能指向仓库内目录,也可能指向用户主目录。词法字符串完全相同,只有 realpath 能揭示最终目标。测试必须真实创建指向外部临时文件的链接,否则只是在验证字符串规则,没有覆盖威胁模型。
资源边界与路径边界同等重要。读取巨型日志、压缩包或设备文件可能消耗内存并挤爆模型上下文。先 stat 确认普通文件与字节大小,再读取并按 UTF-8 解码。大小上限要写进合同说明,使模型能够主动选择搜索或分段读取,而不是反复撞墙。
代码实验
失败实现
下面实现经常出现在原型里,但相似前缀和 symlink 都能绕过:
ts
function unsafeResolve(root: string, input: string): string {
const target = resolve(root, input);
if (!target.startsWith(root)) throw new Error("越界");
return target;
}安全实现把输入规则和真实目标检查拆开:
ts
export async function resolveWorkspacePath(root: string, input: string): Promise<string> {
if (isAbsolute(input) || /^[a-zA-Z]:[\\/]/.test(input)) {
throw new Error("只允许工作区相对路径");
}
const segments = input.split(/[\\/]+/);
if (segments.includes("..")) throw new Error("路径不得包含上跳段");
const candidate = resolve(root, input);
const [realRoot, realTarget] = await Promise.all([realpath(root), realpath(candidate)]);
const child = relative(realRoot, realTarget);
if (child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child)) {
throw new Error("解析后路径超出工作区");
}
return candidate;
}Lab 读取已有文件,因此可以直接 realpath 目标。总项目写工具面对尚不存在的目标,做法不同:解析父目录的 realpath,证明父目录在根内,再创建文件。读写不能为了复用而强塞进一个不准确的函数。
bash
pnpm --dir bootcamps/coding-agent/labs/08-safe-file-tools/starter test
pnpm --dir bootcamps/coding-agent/labs/08-safe-file-tools/solution testStarter 以 EXERCISE_NOT_IMPLEMENTED:实现 realpath 工作区边界 失败。Solution 应通过 5 项测试,包括正常读列、遍历拒绝、绝对路径拒绝、symlink 逃逸和大小上限。
关键测试要在临时根目录之外创建文件,再从根内建立链接:
ts
it("拒绝工作区内指向外部的符号链接", async () => {
const root = await mkdtemp(join(tmpdir(), "agent-root-"));
const outside = await mkdtemp(join(tmpdir(), "agent-outside-"));
await writeFile(join(outside, "secret.txt"), "secret");
await symlink(join(outside, "secret.txt"), join(root, "link.txt"));
await expect(readWorkspaceFile(root, "link.txt", 1024))
.rejects.toThrow("超出工作区");
});关键实现讲解
不要用字符串 startsWith 判断父子关系,应让路径库解释平台语义:
ts
const child = relative(realRoot, realTarget);
if (child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child)) {
throw new Error("解析后路径超出工作区");
}比较时使用 realpath,返回时保留从原 root 构造的候选路径,可避免 macOS 别名改变公开结果。生产写工具还需防 TOCTOU:检查后目标可能被替换,应采用文件描述符和更强的系统隔离。
listFiles 也必须遵守边界和预算。总项目默认忽略 .git、node_modules 与符号链接,并限制深度、条目数量和取消信号。否则一次列目录就能遍历依赖树、泄漏链接目标或制造巨大响应。排序后返回统一斜杠的相对路径,让相同仓库在不同平台产生确定结果。
错误分类不要暴露工作区外是否存在某个敏感路径。词法越界在访问磁盘前拒绝;真实路径越界只说明“不在工作区”,不回显外部绝对路径。内部 Trace 可以保存经过权限控制的诊断,但给模型的消息应足以改正输入且不成为文件探测接口。
运行与验证
真实运行输出
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 08
pnpm --filter @coding-agent/tools test单课门禁应报告 observedTests: 5。在临时目录中创建一个指向工作区外文件的 symlink,确认输入不含 .. 仍会被 realpath 层拒绝;再把读取上限设为 3 字节验证先检查再读取。
实际运行中 Starter 五项全部失败,Solution 五项全部通过:
text
starter: 5 failed, 0 passed
solution: 5 passed, 0 failed
覆盖: 正常读取与列表 / 路径穿越 / 绝对路径 / symlink 逃逸 / 尺寸上限失败全红并不意味着测试质量差,它说明练习缺口位于所有能力共享的路径解析入口。实现后要逐项阅读测试,确认 symlink 在当前平台实际创建成功,尺寸测试在读取之前通过 stat 拒绝,而不是读完后截断字符串。
常见失败与排查
故障案例 1
相似目录前缀绕过边界。
症状:工作区为 /tmp/repo 时,/tmp/repo-secret/key 被错误认为在仓库内。
根因:使用字符串 startsWith 判断包含关系,没有比较路径段边界。
定位:建立名称以根目录为前缀的兄弟目录并运行最小测试;若被允许,判断逻辑与文件系统语义不一致。
修复:对两个 realpath 使用 relative,拒绝上跳结果和绝对结果;增加兄弟目录回归测试。
故障案例 2
无上跳字符仍读到外部秘密。
症状:输入只是 docs/link.txt,词法检查通过,却返回用户主目录文件内容。
根因:仓库内链接指向外部,系统只规范化字符串,没有展开最终文件目标。
定位:用 lstat 确认路径是链接,分别打印受控的 root 与 target realpath,再比较相对关系。
修复:读操作在访问前解析根和目标真实路径;列表默认不跟随链接,高风险环境叠加只读挂载。
故障案例 3
大小上限形同虚设。
症状:工具最终返回“文件过大”,但进程峰值内存已经显著上升,甚至发生崩溃。
根因:先 readFile 得到完整 Buffer 或字符串,之后才比较长度。
定位:使用远超上限的稀疏文件监测读取调用和内存;若读取函数被执行,检查顺序错误。
修复:先 stat 校验普通文件与字节大小,再读取;对可能变化的文件使用流式上限或受限文件描述符。
课后作业
增加扩展名 allowlist 和二进制文件检测;写测试覆盖嵌套 symlink、名称以工作区前缀开头的兄弟目录,以及恰好等于大小上限的文件。给出一段说明,解释为什么 realpath 校验仍不能消除检查—使用时间差。
进阶要求是把读与写的解析策略分开:写入新文件时验证真实父目录,并拒绝最终名称已经是链接;写入采用临时文件加原子替换。再加入取消信号、最大目录深度与最大条目数测试,证明大型仓库不会让一次工具调用无限占用时间。
验收 Rubric
| 维度 | 通过标准 | 常见扣分 |
|---|---|---|
| 词法边界 | 拒绝绝对路径、drive path 与 .. | 只处理当前操作系统格式 |
| 真实边界 | root 与目标 realpath 后比较 | symlink 可逃出工作区 |
| 资源上限 | 读取前检查文件大小 | 读入内存后才截断 |
| 契约稳定 | 正常路径返回调用方预期形式 | 安全规范化意外改变 API 返回值 |
总项目增量
总项目包:@coding-agent/tools
总项目路径:packages/tools/src/workspace-path.ts、packages/tools/src/file-tools.ts
总项目验证命令:pnpm --filter @coding-agent/tools test
总项目现在能在明确工作区内安全读取上下文。下一课将复用执行边界,通过受限 argv 调用 rg,而不是把模型字符串交给 shell。
延伸阅读
方案对比与工程取舍
纯字符串前缀最快也最短,却不是路径包含关系;只用 resolve 能处理普通上跳,却看不到符号链接;relative 加双方 realpath 能覆盖本课主要威胁,但要求目标存在并仍有检查—使用时间差。基于已打开目录文件描述符的相对操作更强,平台支持与 Node 封装更复杂。容器、沙箱用户和只读挂载提供系统级边界,成本更高却能限制应用层遗漏的后果。
因此生产方案通常采用纵深防御:输入合同只接受相对路径,词法层拒绝明显危险格式,文件层证明真实目标在根内,资源层限制类型与尺寸,操作系统层限制进程本身能看到的文件。任何一层都可能有缺陷,多层独立约束让单点错误不至于直接泄露宿主秘密。
TOCTOU 是本课必须诚实面对的剩余风险。系统先 realpath 检查,再调用 readFile,攻击者可在两步之间替换路径或链接。普通单用户 CLI 仓库中风险较低,但多租户服务与不可信并发进程中不能忽略。更强做法是打开受控目录、通过相对文件描述符访问,并在打开后检查目标元数据;或者把工作区挂载到独立容器,彻底减少可见范围。
读与写有不同的存在性语义。读取需要目标存在,因此可直接取得目标 realpath;创建新文件时目标尚不存在,只能验证最近存在的父目录。若简单要求目标 realpath,合法新建都会失败;若完全跳过真实检查,链接父目录又会逃逸。正式实现必须明确区分读路径、现有写路径与新建路径。
文件类型也属于合同。目录、FIFO、socket、设备文件和普通文件都可能通过路径解析,但读取行为截然不同。对 Coding Agent 的文本工具,默认只接受普通文件;目录交给列表工具,二进制交给专用能力。类型分离让超时、返回格式和权限更容易推理。
编码检测不能只调用 toString("utf8")。无效字节可能被替换字符吞掉,让 Agent 基于损坏内容修改代码。简单策略是拒绝包含零字节的内容并检查 UTF-8 解码,复杂仓库可允许显式编码参数。无论如何,返回结果要告诉模型是否截断或替换,不能把不完整证据伪装成完整文件。
大小上限应以字节为第一道资源保护,以字符或 token 为第二道上下文保护。中文 UTF-8 字节数与字符数不同;一个字节上限合格的文本仍可能超过模型预算。文件工具负责不把进程撑爆,下一课搜索与第十课上下文预算会进一步筛选真正需要发送给模型的片段。
列表工具是常见遗漏点。递归遍历 .git 会碰到大量对象,遍历 node_modules 会产生数万条目,跟随目录链接可能形成循环或走出根目录。生产实现应默认忽略这些目录,不跟随链接,限制深度和条目数,并在达到上限时明确标记截断。确定性排序便于缓存、测试和模型比较。
忽略规则也不能完全信任仓库自身。如果仓库通过忽略文件要求 Agent 显示某个敏感目录,安全边界仍应优先。产品可叠加内置拒绝列表、项目忽略规则与用户显式允许;越靠近安全底线的规则,越不能被项目内容覆盖。
错误信息需要在可用性与信息泄漏间平衡。用户输入工作区外绝对路径时,可以明确说明“只允许相对路径”;symlink 解析到外部时,只说“目标超出工作区”,不要回显真实目标。不存在的内部路径可以返回相对路径和建议列目录。这样的错误足以让 Agent 修正,又不会提供宿主文件探针。
路径大小写在不同文件系统上也有差异。macOS 默认常见大小写不敏感,Linux 通常敏感;手动把字符串转小写可能在敏感系统上错误放行。依赖系统路径操作与真实文件解析,比自制大小写规范化更稳妥。跨平台测试至少应覆盖当前支持矩阵,而不是假设开发机语义就是生产语义。
对写工具,原子性和备份同样重要。先写临时文件、同步必要数据、再在同一目录原子重命名,能降低进程中断留下半文件的概率。替换前验证目标与父目录边界,并结合补丁预览和用户审批。安全路径只回答“写到哪里”,不回答“这次修改是否正确”。
审计记录不要保存完整文件内容,尤其是可能包含密钥的配置。记录规范化相对路径、操作类型、字节数、结果码和内容哈希通常足够追踪;需要调试时通过受控权限访问原始工作区。让 Trace 默认最小化敏感数据,比事后清理日志可靠。
验收可以采用威胁矩阵:普通嵌套文件应通过,空路径和根目录按合同处理,POSIX 与 Windows 绝对路径拒绝,上跳段拒绝,相似前缀兄弟目录拒绝,内外部链接分别通过和拒绝,超限文件拒绝,恰好上限通过,不存在文件返回稳定错误。每个格子都有自动测试,安全承诺才不会随重构退化。
具体看一次正常读取。模型请求 src/index.ts,词法层确认没有盘符、根斜杠和上跳段;候选路径由可信根目录拼接;根与目标分别解析真实路径;相对结果是 src/index.ts,证明仍在根下;stat 显示普通文件且小于上限;最后读取 UTF-8 并返回相对路径、内容和字节数。每一步只消费前一步已经验证的信息,没有直接相信模型给出的绝对位置。
再看链接逃逸。仓库中存在 docs/current,它表面位于根内,却指向外部目录。词法层会通过,这正是预期:字符串本身没有危险。真实路径层得到外部目标,relative 返回以 .. 开头的关系,调用在读取前终止。两层不是重复校验,而是分别针对可见语法与磁盘拓扑。
不存在路径的处理需要避免错误顺序泄漏。若先对任意外部绝对路径调用 realpath,攻击者可根据“不存在”与“无权限”区分宿主状态。正确顺序是先做不接触磁盘的词法拒绝,只有合法相对候选才访问文件系统。即使后续错误不同,可探测范围也已被限制在工作区内。
缓存 realRoot 能减少每次调用开销,但必须确认工作区根在会话期间不会被链接替换。CLI 通常在启动时固定根目录并可缓存;多租户服务应在打开工作区时取得稳定句柄或重新验证。优化安全检查之前,先写清根目录生命周期假设,否则缓存可能把过期信任延续到新目标。
文件尺寸在 stat 与读取之间也可能变化。普通仓库里先检查足以防止大多数误操作;对不可信并发写入,应使用受限流读取:累计字节超过上限立即销毁流,并返回明确错误。这样即使文件在检查后增长,进程最多消费上限附近的数据,而不是无界载入。
符号链接并非一律恶意。monorepo 可能合法链接内部包,完全禁止链接会破坏体验。读取时解析最终目标并允许仍在根内的链接,是安全与兼容的折中;列表时不跟随目录链接,则避免递归循环与重复遍历。不同操作可以有不同链接策略,但都必须证明最终能力不越界。
扩展名 allowlist 也只是资源和产品策略,不是边界本身。攻击者可以给二进制文件改成 .txt,所以仍需检查内容与类型;反过来,仓库可能包含无扩展名脚本,过窄名单会降低 Agent 能力。更适合的默认是拒绝已知危险或巨大二进制类型,允许受限文本探测,并让专门工具处理其他格式。
总项目的取消信号让用户中止长列表,但实现必须在循环中周期检查。只在函数开头检查一次,用户点击停止后仍会走完整个依赖树。每进入目录或处理一批条目就检查 signal,并确保取消返回稳定结果或异常类别,才能与 Runtime 的终止语义对齐。
代码评审时,看到 normalize、resolve 或正则拒绝 .. 不应立即认为安全完成。评审者要追问:双方是否 realpath、目标不存在如何写、链接是否跟随、文件类型与尺寸何时检查、错误是否泄密、并发替换剩余什么风险、操作系统还有哪层限制。这组问题比记住某一段示例代码更可迁移。
最终目标不是证明所有文件系统攻击都被 JavaScript 消灭,而是建立清晰能力边界与残余风险。普通 Coding Agent 通过应用层校验获得安全默认值;处理陌生仓库或自动运行时,再叠加临时副本、只读根、最小权限账户和网络隔离。能力越自动,外部隔离越不应省略。
下一课衔接
文件工具能安全读取一个已知路径,但模型往往不知道目标在哪里。下一课实现代码搜索:用参数数组而不是 shell 字符串调用 rg --json,处理无匹配、超时、异常退出和结果上限,再把有限证据交给 Agent。文件边界解决“能读哪里”,搜索工具解决“先读什么”。
- CWE-22 Path Traversal 与 CWE-59 Link Following。
realpath、符号链接和 mount boundary 的系统语义。- TOCTOU 风险与 capability-based file access。
应用层路径校验是纵深防御的一层;对高风险仓库仍应结合最小权限用户、容器和只读挂载。