智能体一旦用字符串拼出文件路径,
../、绝对路径和符号链接就能把读、写、打补丁带出你以为的工作区。容器能隔离宿主机,却挡不住挂载进来的.git、密钥和钩子。把 realpath 对齐、允许根校验和读写分离收成一层可单测的 Node.js 文件工具,让执行层守门,而不是指望模型遵守「只改业务代码」。
问题不在模型,在路径字符串
给智能体接 read_file、write_file、apply_patch 时,最常见的写法是把工作区根和模型给出的路径拼起来,然后直接交给 fs。演示里路径都是 src/index.ts 这种老实值,问题不会出现。一旦输入里带上 ../../../.ssh/id_rsa、以 / 开头的绝对路径,或是工作区内部指向外部的符号链接,拼接结果就不再落在你以为的根上。
这和「模型是否听话」几乎无关。提示词里写「禁止访问工作区以外的文件」,对字符串拼接没有约束力;提示注入或一次幻觉就可以生成逃逸路径。执行层若不做硬校验,后面所有策略都是软的。
容器、非特权用户、只读系统盘能缩小爆炸半径,但智能体的工作区通常是整仓挂载。.git/config、.git/hooks/pre-commit、.env、部署脚本仍在同一棵树里,权限上也往往可写。逃逸不一定表现为打到容器外,更常见的是在工作区里改掉不该改的东西。路径工具要防的是「超出允许根」,不只是「超出容器」。
Node.js 的 path.join(root, p) 只统一分隔符,不会挡住 ..。path.normalize 对 ../x 的结果仍是 ../x。path.resolve(root, p) 在 p 已经是绝对路径时会丢弃 root——这是文档写明的语义,不是漏洞。当 p 来自模型时,它就是一条现成的逃逸通道。不要在工具层用字符串拼接制造路径。
规范化做不到的事:realpath 与根校验
词法规范化(去掉 .、折叠 ..)只处理字符串,不处理符号链接。工作区里若有 src/leak -> /var/secret,用户路径 src/leak/key 在 path.resolve 之后仍看起来像工作区内部;真正打开时内核会跟过去。因此校验必须对 realpath 之后的路径做。
对已存在的路径,直接用 fs.promises.realpath。对还没创建的写入目标,realpath 会因 ENOENT 失败。正确做法是从候选路径向上走,找到最近的已存在祖先,对其 realpath,再把缺失的 basename 接回去。这样「目录是指向根外的链接、文件名尚不存在」也会被展开到真实位置。
根校验不要用 resolved.startsWith(root):/workspace 会放行 /workspace-evil。用 path.relative(root, resolved),并同时满足:相对路径不是绝对路径(Windows 跨盘符时 path.relative 会返回绝对路径)、不等于 ..、也不以 .. 加 path.sep 开头。不要写成 startsWith('..'),否则合法目录名 ..foo 会被误拒绝。
上述流程仍存在校验与 open 之间的 TOCTOU:两次系统调用之间,链接可以被替换。对本地单进程的智能体工具,先堵住确定性的 ..、绝对路径和已有符号链接,已经能去掉最常见的事故。更严的模型需要基于目录文件描述符的 openat 语义,Node.js 的 fs 对此支持有限,不要宣称一层 JavaScript 封装等于操作系统级沙箱。下文示例以 POSIX 语义为主;Windows 还要额外考虑盘符、大小写折叠,不能把同一套字符串前缀检查直接搬过去。
只读与可写分成两道闸
读和写的允许集不应相同。读可以覆盖整个工作区,便于模型看测试、配置和文档;写应收口到 src、lib 这类目录。仓库根下的 README 可读不可写,往往比「整个仓可写」更符合「只改业务代码」的本意。
即便路径已在工作区内,.git/hooks 被改写成恶意脚本,效果和逃出工作区类似。第二道闸用路径分段黑名单即可:相对工作区根拆段后,任何一段等于 .git 就拒绝。它不是保密设计,只是缩小模型改仓库元数据的面积,并且很容易写成断言。
返回给模型的错误应短、稳定、不要带回真实绝对路径,避免把宿主机布局泄漏进上下文,也避免模型根据报错改写下一跳路径。工作区根本身只在进程内持有,不要放进工具的 schema 枚举,也不要在成功结果里回显。
可单测的文件工具与逃逸夹具
下面示例只用 Node.js 内置模块,保存为 workspace-fs.mjs,用 Node 18 及以上执行 node workspace-fs.mjs。它把工作区根、可写根和分段黑名单收进一个类:读走 resolveForRead,写走 resolveForWrite。文件末尾是一组专门打逃逸的夹具,覆盖相对路径穿越、绝对路径、可写根外的仓内文件、.git、合法名 ..foo,以及符号链接指向根外。
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import assert from 'node:assert/strict';
export class PathEscapeError extends Error {
constructor(message) {
super(message);
this.name = 'PathEscapeError';
}
}
function isInside(rootReal, resolved) {
const rel = path.relative(rootReal, resolved);
return (
!path.isAbsolute(rel) &&
rel !== '..' &&
!rel.startsWith(`..${path.sep}`)
);
}
function hasBlockedSegment(rel, blocked) {
if (rel === '') return false;
return rel.split(path.sep).some((seg) => blocked.has(seg));
}
export class WorkspaceFs {
constructor({ workspaceRoot, writeRoot, blockedSegments = ['.git'] }) {
this.workspaceRoot = workspaceRoot;
this.writeRoot = writeRoot;
this.blocked = new Set(blockedSegments);
}
async init() {
this.workspaceReal = await fs.realpath(this.workspaceRoot);
this.writeReal = await fs.realpath(this.writeRoot);
if (!isInside(this.workspaceReal, this.writeReal)) {
throw new PathEscapeError('write root outside workspace');
}
}
deny(kind) {
throw new PathEscapeError(kind);
}
async resolveCandidate(userPath) {
if (typeof userPath !== 'string' || userPath.length === 0) {
this.deny('invalid path');
}
if (userPath.includes('\0')) {
this.deny('invalid path');
}
if (path.isAbsolute(userPath)) {
this.deny('absolute path');
}
return path.resolve(this.workspaceReal, userPath);
}
async alignReal(candidate) {
let current = candidate;
const missing = [];
for (;;) {
try {
const real = await fs.realpath(current);
return path.resolve(real, ...missing.reverse());
} catch (err) {
if (err.code !== 'ENOENT') throw err;
const parent = path.dirname(current);
if (parent === current) throw err;
missing.push(path.basename(current));
current = parent;
}
}
}
async resolveForRead(userPath) {
const candidate = await this.resolveCandidate(userPath);
const aligned = await this.alignReal(candidate);
if (!isInside(this.workspaceReal, aligned)) {
this.deny('outside workspace');
}
const rel = path.relative(this.workspaceReal, aligned);
if (hasBlockedSegment(rel, this.blocked)) {
this.deny('blocked path');
}
return aligned;
}
async resolveForWrite(userPath) {
const aligned = await this.resolveForRead(userPath);
if (!isInside(this.writeReal, aligned)) {
this.deny('outside write root');
}
return aligned;
}
async readFile(userPath) {
const real = await this.resolveForRead(userPath);
return fs.readFile(real, 'utf8');
}
async writeFile(userPath, content) {
const real = await this.resolveForWrite(userPath);
await fs.mkdir(path.dirname(real), { recursive: true });
await fs.writeFile(real, content);
}
}
async function main() {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-fs-'));
const workspace = path.join(tmp, 'repo');
const src = path.join(workspace, 'src');
const outside = path.join(tmp, 'outside');
await fs.mkdir(src, { recursive: true });
await fs.mkdir(outside);
await fs.mkdir(path.join(workspace, '.git'));
await fs.writeFile(path.join(src, 'app.js'), 'ok');
await fs.writeFile(path.join(workspace, 'README.md'), 'doc');
await fs.writeFile(path.join(outside, 'secret.txt'), 'secret');
await fs.writeFile(path.join(workspace, '.git', 'config'), 'git');
const tool = new WorkspaceFs({
workspaceRoot: workspace,
writeRoot: src,
});
await tool.init();
assert.equal(await tool.readFile('src/app.js'), 'ok');
assert.equal(await tool.readFile('README.md'), 'doc');
await tool.writeFile('src/deep/n.js', 'n');
assert.equal(await tool.readFile('src/deep/n.js'), 'n');
await assert.rejects(() => tool.readFile('../outside/secret.txt'), PathEscapeError);
await assert.rejects(() => tool.readFile('/etc/passwd'), PathEscapeError);
await assert.rejects(() => tool.writeFile('README.md', 'x'), PathEscapeError);
await assert.rejects(() => tool.writeFile('src/../README.md', 'x'), PathEscapeError);
await assert.rejects(() => tool.writeFile('.git/hooks/pre-commit', 'x'), PathEscapeError);
await fs.mkdir(path.join(workspace, '..foo'));
await fs.writeFile(path.join(workspace, '..foo', 'a.txt'), 'a');
assert.equal(await tool.readFile('..foo/a.txt'), 'a');
try {
await fs.symlink(outside, path.join(src, 'leak'));
await assert.rejects(
() => tool.readFile('src/leak/secret.txt'),
PathEscapeError,
);
} catch (err) {
if (err instanceof PathEscapeError) throw err;
console.warn('跳过符号链接夹具:', err.code || err.message);
}
await fs.rm(tmp, { recursive: true, force: true });
console.log('fixtures ok');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
夹具里创建符号链接在部分 Windows 环境会失败,示例选择跳过而不是把整组测试标红。init() 必须先做:构造函数里不能 await,工作区根和可写根都要先对齐到真实目录,并确认可写根本身落在工作区内。把这一文件放进 CI,比在系统提示里重复「不要用 ../」硬得多。
接到现有执行层时要注意什么
现有工具执行层通常已经按 JSON schema 把 path、content 解析出来。接入时不要让模型传入工作区绝对路径,也不要把 workspaceRoot 回显给模型;schema 里写明「相对工作区的路径」即可。进程启动时调用一次 init(),后续每次工具调用只做 resolve 与读写。
打补丁不要把 unified diff 头部的文件名直接丢给系统 patch 或 git apply。应先抽出路径,走同一套 resolveForWrite,确认每个目标都在可写根内,再在对齐后的真实路径上应用。否则读文件工具守住了,补丁工具仍会从侧门写出去。应用补丁前把「旧路径 / 新路径」两条都送进校验,rename 类补丁尤其容易漏。
这一层解决的是路径逃逸,不是内容安全。可写根里的业务代码仍可能被改坏;那是 diff 审查和测试的责任。路径层的目标只有一个:模型给出的字符串不能自己选择文件系统上的哪棵子树。若你还要把密钥文件放在工作区里,黑名单可以再加分段名,但那只是减小面积,不能代替把密钥移出仓库。
总结
智能体文件工具的边界必须落在执行层。对用户路径禁止绝对路径和空字节,在已 realpath 的工作区根上做词法 resolve,再对真实位置做 path.relative 根校验;写入还要额外落在可写根内,并用分段黑名单挡住 .git 这类目录。用夹具覆盖 ../、绝对路径、符号链接和仓内越权写入,然后把现有 read_file / write_file / apply_patch 接到这层 API 上。不要指望模型自己遵守工作区约定——它没有这个义务,你的文件系统也没有义务相信它。
评论