智能体一旦用字符串拼出文件路径,../、绝对路径和符号链接就能把读、写、打补丁带出你以为的工作区。容器能隔离宿主机,却挡不住挂载进来的 .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 上。不要指望模型自己遵守工作区约定——它没有这个义务,你的文件系统也没有义务相信它。