Skip to content

第33章 给手装上工具——Tools ​

本章导读: 你会写 src/tools.ts 的 read、write、bash 三个工具,还有 workspacePath 路径边界和 truncateHead 输出截断。这一步要立的规矩:四道闸门(schema、运行时校验、截断、realpath)控制模型能碰什么,但都不是 OS 级隔离。

循环能跑了,但小a发现一件事:模型说要"读文件",它真的读了;可它说要"写文件"的时候,竟然能往工作区外面写。小a吓出一身冷汗,赶紧去找老z。

"我是不是该给工具加权限?"他问。

"加,但先想清楚加在哪。"老z说,"src/tools.ts 的 createWorkspaceTools() 返回 read、write、bash。每个 ToolSpec 都有 properties、required 与 additionalProperties: false;执行前 stringField() 再做运行时检查,因为模型输入仍不可信。"

小a:"这不是重复检查吗?schema 不是已经约束过了?"

"你先记住一句话:**这是路径边界而非 OS 沙箱。**检查与实际打开之间仍有 TOCTOU 窗口,bash 仍能使用进程本身拥有的权限。schema 和运行时检查是两道门,缺一不可。"

小a把"两道门"记下来,又问:"那 createWorkspaceTools() 返回的三个工具,谁给它们定义边界?"

"边界不在工具内部,在 createWorkspaceTools(workspaceRoot) 的根参数里。"老z说,"workspacePath 的所有检查都相对 rootReal 展开——realpath(root) 先求一次根的真实路径,之后 candidate 都以它为基准比较。**根是工具集构造时定死的,不是每次调用时由模型提供的。**模型能指定的是相对工作区的路径,而不是工作区本身。这个‘根从构造来、路径从请求来’的分工,是路径边界成立的先决条件。"

工具声明和运行时是两道门 ​

小a:"那这两道门分别干什么?"

老z打开 tools.ts:

ts
spec: {
  name: "write",
  inputSchema: schema(["path", "content"], {
    path: { type: "string" }, content: { type: "string" },
  }),
}

"schema 用于告诉模型期望形状;执行器仍用 stringField() 检查,因为响应可能绕过 schema。additionalProperties: false 是向 Provider 表达的约束,不是本地 JSON 验证器。"

小a:"Provider 不是会按 schema 校验模型输出吗?为什么还要再查一遍?"

"因为 schema 在这里有两个角色,不能混。"老z说,"第一个角色是‘告诉模型该怎么调’——它出现在发给 Provider 的 input_schema 里,模型据此生成参数。第二个角色是‘Provider 替我们挡住坏参数’——但这件事契约里没保证。Provider 可能某天改了行为,模型可能输出绕过 schema 的 JSON。我们控制不了 Provider,但能控制自己的执行器。所以 stringField() 是兜底:**不管谁给的 input,到了执行这一步都重新验一遍。**这叫纵深防御——不是不信任 schema,是不把安全性押在单一关卡上。"

小a:"那 stringField 为什么返回 undefined 而不是抛错?"

"因为工具失败的处理方式是‘变成消息’,不是‘中断循环’。"老z说,"write 的执行器返回 { content: "invalid write input: ...", isError: true },loop 把它记成 tool message 交给下一轮。模型读到‘path must be a string’,自己决定要不要修正。**抛错是剥夺模型的修正机会,返回 isError 是给它反馈。**这两条路在 agent.ts 里分得很清楚:isError 走‘记录并继续’,抛异常走‘记录失败并 rethrow’。

小a:"那 undefined 和空字符串有区别吗?stringField 会不会把空字符串当合法值?"

"会,而且这是有意的。"老z说,"stringField 只判断‘是不是 string 类型’,不判断‘是不是非空’。空字符串是合法的 content——你要写一个空文件,content 就该是空串。真正的失败信号是 undefined:字段缺失、类型不对、或者 input 根本不是对象。**‘类型对但内容空’和‘形状不对’是两回事,stringField 只拦后者。**如果你想让空字符串也被拒,那是业务规则,应该写在执行器里,而不是塞进这个通用函数。"

小a:"那三个工具都复用同一套 stringField,会不会把错误信息写得太笼统?"

"看执行器的措辞就知道了。"老z说,"read 说 invalid read input: path must be a string,write 说 invalid write input: path and content must be strings,bash 说 invalid bash input: command must be a string——工具名在前,规则在后。**模型读到这条消息,能同时知道‘哪个工具’‘哪个字段’‘什么规则’,修正才有依据。**这是给模型的反馈,反馈的信息密度直接决定它下一轮能不能改对。"

从声明到执行的正常路径 ​

"以 write 为例,模型先看到 ToolSpec 的名字、描述和 schema;若它返回 { path, content },stringField() 逐字段取得字符串。两个字段任一不是字符串,工具直接返回 isError: true 的结果,Agent 可将它交还下一轮。字段形状通过后才进入文件系统。"

ts
function stringField(input: unknown, field: string): string | undefined {
  const value = asObject(input)?.[field];
  return typeof value === "string" ? value : undefined;
}

"asObject 先排除 null、数组和基本类型;可选访问意味着缺字段得到 undefined;最后只接受 string。"老z说,"这里没有把 JSON schema 当作运行时验证器,这正是模型调用边界所需要的重复检查。"

小a把 asObject 的三个排除挨个问了一遍:“为什么 null 要排除?数组为什么也不行?”

“null 的 typeof 是 "object",但访问它的字段会直接炸;数组的 typeof 也是 "object",可它没有你要的具名字段。”老z说,“asObject 的职责是回答一个精确的问题:‘这个值能安全地当对象读字段吗?’只有非 null、非数组、对象类型的值才答‘能’。这三个检查不是防御性装饰,是 input: unknown 这个类型本身要求的安全降级——从 unknown 到 Record,每一步都要显式证明。”

路径决策序列 ​

小a:"那路径越界怎么防?symlink 能骗过吗?"

"问得好。workspacePath() 先 realpath(root),再解析候选路径。"老z敲出一段:

ts
const requested = resolve(rootReal, candidate);
if (creating) {
  try { await lstat(requested); }
  catch (error) { checked = await nearestExistingAncestor(dirname(requested)); }
}
const checkedReal = await realpath(checked);
if (!isInside(rootReal, checkedReal)) throw new Error("path escapes workspace");

“第一行只做词法解析,尚未证明对象位于 workspace。目标已存在时 lstat 让后续 realpath 看见 symlink 最终位置;目标不存在时,最近祖先是唯一可解析的 filesystem 对象。最后才比较 canonical path。真实代码只在 ENOENT 时向上寻找祖先,权限错误会传播,不会伪装成‘文件不存在’。”

小a盯着 creating 参数问:“为什么写文件要单独走一条路?”

“因为写一个不存在的文件,realpath 会失败——它只能解析已存在的路径。”老z说,“所以 workspacePath 在 creating 时先 lstat:目标已存在,正常解析;不存在,就向上找最近存在的祖先,检查它是不是在工作区内。你看 nearestExistingAncestor:”

ts
// examples/mini-agent/src/tools.ts,节选
async function nearestExistingAncestor(path: string): Promise<string> {
  let current = path;
  while (true) {
    try {
      await lstat(current);
      return current;
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
      const parent = dirname(current);
      if (parent === current) throw new Error("no existing ancestor");
      current = parent;
    }
  }
}

“它一路向上 dirname,直到找到存在的目录;**注意 if (parent === current)——到了文件系统根还找不到,才抛错,否则会死循环。**而 isInside 用 relative 判断,还处理了 Windows 的路径分隔符:”

ts
// examples/mini-agent/src/tools.ts,节选
function isInside(root: string, path: string): boolean {
  const value = relative(root, path);
  return (
    value === "" ||
    (!value.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) &&
      value !== ".." &&
      !isAbsolute(value))
  );
}

“relative(root, path) 的结果,如果以 .. 开头或本身是 ..,说明 path 在 root 之外——/work-other/x 这种相似前缀就是这么被拒的。小a看完全段,感叹:‘原来一个路径检查,藏着 lstat、realpath、relative、跨平台四层心思。’”

老z把五个典型场景画成一张表,小a逐行看:

场景检查对象结果
src/a.ts 已存在目标 realpathroot 内才可读写
nested/a.ts 不存在最近存在父目录root 内才允许 mkdir
escape/x 是目录 symlinksymlink 最终目录外部即拒绝
write-link 是文件 symlink目标最终文件外部即拒绝
/work-other/x 相似前缀relative/isAbsolute拒绝

"为什么这个 root 上的 symlink 检查对 bash 不生效?"小a问,"bash 工具不是也调用 workspacePath 吗?"

"bash 调用的是 workspacePath(workspaceRoot, ".", false)——候选路径是 ".",工作区根自己。"老z说,"也就是说 bash 只把 cwd 固定到真实的工作区根,命令本身执行的路径它不检查。cd /etc && cat shadow 能跑通,因为那是 shell 在 cwd 之外的自由行动。**路径边界保护的是 read、write 两个文件工具,不是 bash 的命令内容。**bash 的边界是 timeout、maxBuffer 和调用层的审批,三者没有一个能算路径隔离。"

小a:"那测试能证明它绝对安全吗?"

"不能。测试文件确实覆盖截断、嵌套 write 以及 symlink 读写逃逸;**它不能证明竞争条件不存在。**攻击者若在检查后替换目录,进程内这套路径逻辑没有原子性保证。"

小a:"那这套检查到底拦住了什么?只是 symlink 吗?"

"它拦住了三类常见错误,但拦不住第四类。"老z说,"第一类是直白的越界——path: "/etc/passwd",relative 一算就发现不在 root 内。第二类是相似前缀——/work-other/x,relative 结果以 .. 开头被拒。第三类是静态 symlink——escape 指向工作区外,realpath 解析后比较发现不在 root 内。**这三类都有测试覆盖。**拦不住的第四类是 TOCTOU——检查完到 writeFile 之间,外部进程把目录换成 symlink。这是 race,应用层没有原子操作能消除它,只有 OS 级 sandbox 能。"

"那为什么不做 OS sandbox?"

"因为那不在这一层。"老z说,"OS sandbox 是容器或权限降级——独立工程,要单独运行环境。这一章的产物是一个库,它没法假设自己运行在容器里。路径边界是‘在应用层能做的最多’,不是‘安全的全部’。"

小a把 symlink 的场景在纸上画了一遍,又问了一个更刁钻的问题:“如果工作区里已经有一个指向外部的 symlink,模型用 read 读它,报错信息是什么?”

“realpath 解析出外部路径,isInside 返回 false,抛 path escapes workspace。”老z说,“这个错误会被 read 的 catch 收走,经 failure(cause) 变成 isError: true 的结果——模型读到的是‘path escapes workspace’,不是 Node 的原始 stack。**边界违反和命令失败走同一条返回通道,模型不需要区分‘路径太坏’和‘文件不存在’,它只需要知道‘这个动作没做成,换个方式’。**路径检查的细节留在工具内部,对外只输出一个干净的结果。”

输出和执行边界 ​

小a:"那长文件怎么办?read 一个 10 万行的大文件,不得把上下文撑爆?"

"截断。read 超过 MAX_READ_CHARS 时保留头尾并标记省略量;模型因此知道内容不完整。"老z敲出 read 的截断逻辑:

ts
return {
  content: content.length <= MAX_READ_CHARS
    ? content
    : `${content.slice(0, 8_000)}\n[truncated ${content.length - MAX_READ_CHARS} characters]\n${content.slice(-8_000)}`,
};

小a:"为什么不只取开头?"

"开头常有定义,结尾常有错误或结果;保留两端是一个可解释的取舍,但中间仍会丢失。"老z说,"测试断言标记、首部和尾部存在,因此仅证明当前截断规则,不证明所有大文件都能被模型正确理解。write 只在检查后 mkdir(..., { recursive: true }) 与 writeFile,故嵌套新目录可创建。bash 使用 shell、workspace cwd、十秒 timeout 和 64KiB buffer;timeout 是资源限制,不是命令安全证明。"

小a:"MAX_READ_CHARS 是 16000,但头尾各取 8000——那标记行算在哪边?"

"算超出的部分。"老z说,"content.length - MAX_READ_CHARS 是被省掉的字符数,写进标记行。一个 18000 字符的文件,截断后是 8000 头 + 标记行 + 8000 尾。**这个设计承认‘截断必然丢信息’,所以它把丢了多少明确写出来。**这比静默截断好——模型至少知道‘这里有个洞’,不会把 8000 尾当成全文。"

小a:"那 bash 的 64KiB buffer 和十秒 timeout 是怎么协作的?"

"两个维度,互不替代。"老z说,"timeout 限制‘命令跑多久’,maxBuffer 限制‘输出多大’,任一触发都进 catch 返回 failure(cause)。这两个都是资源限制,不是安全限制。**一条 rm -rf 在一秒内就能跑完,远没触发 timeout。**所以清单里写‘timeout 是资源限制,不是命令安全证明’——防的是‘命令卡死’和‘输出爆炸’,不是‘命令危险’。"

"那 bash 返回的内容为什么还要再 slice(0, 64 * 1024) 截一遍?execFile 的 maxBuffer 不是已经限过了?"

"限的是缓冲,截的是回传。"老z说,"maxBuffer 限制 execFile 内部缓冲积累的输出量,超了会抛错;但即便在限额内,stdout 加 stderr 拼接起来也可能接近 64KiB。${result.stdout}${result.stderr} 拼完再切一刀,是给模型看的消息体积再上一道保险。**两道限额都在,但职责不同:一个是让 execFile 不炸内存,一个是让模型不收到超长结果。**跟 read 的截断同一个思路——宁可让模型知道‘这里被截了’,也不让它吞下失控的体积。"

"那 bash 的失败语义呢?命令找不到、超时、输出爆掉,都是同一个 failure(cause)?"

"同一个函数,三种原因。"老z说,"execFile 的 timeout、maxBuffer 或非零退出都会进入 catch,failure(cause) 把 cause.message 变成结果文本——Command failed: ...、超时信息或 maxBuffer 报错,全都以 isError: true 的消息交给模型。**模型因此能区分‘命令跑了但退出码非零’和‘命令压根没跑成’,但它不需要区分背后的 Node 机制。**跟 read 的边界违反一样,工具层把细节吞掉,把可诊断的文本吐出来。"

bash 的失败语义 ​

小a:"bash 失败了怎么办?命令找不到、超时、输出爆掉?"

"execFile 的 timeout、maxBuffer 或非零退出都会进入 catch,工具返回 failure(cause)。这让 Agent 获得错误文本,而不是让 Node 异常直接终止循环。"老z说,"bash 默认是否批准不在本工具中决定;生产入口通过 BeforeToolCall 决定。shell、网络和环境变量仍继承进程权限,因此这里没有'危险命令黑名单'的安全承诺。"

小a:"那 bash 用的 shell 是哪来的?为什么不是写死 /bin/bash?"

"process.env.SHELL ?? "/bin/sh"——环境变量优先,缺了退回 POSIX 默认。"老z说,"这有取舍:尊重用户配置,让命令在用户熟悉的环境里跑;代价是 shell 不同,行为可能略有差异。**它不假设用户跑的是 bash,也不为‘每台机器行为一致’打包票。**如果你是部署方,想在沙箱里固定 shell,替换掉环境变量就行——这一行是策略,不是硬编码。"

小a:"那 signal 呢?execFile 接收 signal 有什么意义?"

"配合取消。"老z说,"工具接口签名是 execute(input, signal?),bash 把它透传给 execFile 的选项——外部 AbortSignal 触发时,子进程会被终止而不是悬在那里。**但注意:这依然是协作式。**shell 里已启动的子命令、已写出的文件,不会因为 signal 而回滚。signal 能停掉 execFile 等待的那个进程,停不掉它造成的世界变化。这就是为什么取消的文档一直强调‘副作用要调用方兜底’。"

"那 read 和 write 呢?它们的 execute 为什么不接 signal?"小a问。

"因为单次读写的生命周期太短,不值得为它们做取消调度。"老z说,"readFile、writeFile 一调用就等 I/O 完成,取消它们的收益趋近于零。**工具不是非要响应取消——响应取消要有明确代价。**bash 必须接,因为一条命令可能跑很久;文件工具不接,因为它们的执行窗口本来就很窄。接口上 signal 是可选参数,正好容纳这种差异。"

设计收益、代价与未覆盖范围 ​

小a把整章代码看完,长长吐了口气:"所以这一章的取舍是……"

"路径 canonicalization 让常见 symlink 逃逸可见,代价是更多异步 I/O 与不可消除的 race;截断控制上下文,代价是信息丢失;shell 工具能验证修改,代价是最大权限面。"老z说,"**没有实现文件 mutation 队列、输出临时文件、MIME 处理、原子写入、容器或权限降级。**这些都是我们故意不做的。"

小a:"mutation 队列是什么?为什么不做?"

"就是‘先收集所有写操作,验证完再一次提交’的事务模型。"老z说,"要做这个,你得有写操作队列、提交点、回滚机制——每一个都是独立的复杂度。当前产物里 write 是即时的:writeFile 一调,文件就变了。**不做 mutation 队列,是因为还没有失败证据证明它需要。**现在的失败语义是‘写到一半出错,模型读到错误自己决定’,回路闭合。"

"那权限降级呢?"小a追问,"bash 跑在进程权限下,这不是最大权限面吗?"

"是,而且这正是审批层存在的原因。"老z说,"approval.ts 的 denyBashByDefault 在 main 里被装配成 beforeToolCall。bash 在生产入口默认被拒,模型收到‘bash requires an explicit approval policy’。**审批不是在工具内部做的,是在调用层做的。**工具本身没有‘危险命令黑名单’;它假设调用方已经决定了‘这个 bash 该不该跑’。把审批和执行分开,是为了让工具能被测试——测试里传 always-approve 的 hook,生产里传 denyBashByDefault。"

小a把四道闸门在脑子里排了一遍,问:"能不能画一张图,把‘模型输入’到‘文件系统’之间到底穿过了什么?"

老z在纸上画了一条竖线:

text
模型输出(不可信)
    │
    ▼
① schema 声明 ────── 告诉模型期望形状,附加 additionalProperties: false
    │
    ▼
② stringField() ──── 执行器兜底,任何 input 都重新验,错了返回 isError
    │
    ▼
③ workspacePath() ── realpath + nearestExistingAncestor + isInside
    │                   越界抛 path escapes workspace
    ▼
④ truncateHead/TruncateTail、timeout/maxBuffer
    │
    ▼
文件系统 / 子进程(应用层的最后一道边界,之后是 OS)

"四道闸门的共同点是:没有一道在‘拦住之后’提供保证,只有‘在抵达之前’提供拦截。"老z说,"①拦形状认知,②拦形状事实,③拦路径,④拦体积。第四道之后就到真文件系统了——那里没有闸门,所以 TOCTOU、shell 权限、网络访问,都不归这一章管。看这张图就知道为什么清单反复强调‘路径边界而非 OS 沙箱’:闸门再多,也都在同一层。"

小结 ​

工具层用四道闸门控制"模型能碰什么":schema 校验拦住形状不对的参数,运行时解析处理模型给出的实际值,输出截断防止单次结果撑爆上下文,realpath 边界把 symlink 逃逸变得可见。四道闸各管一段,任何一道都不能被其他三道替代——但它们都是应用层的,没有一道能替代 OS 级隔离。workspacePath 用 realpath 加最近存在祖先加相对路径检查来约束工作区,truncateHead 和 truncateTail 限制工具输出的体积,bash 的 execFile 把 timeout、maxBuffer 和非零退出都收进 catch,返回 failure(cause) 给模型而不是让 Node 进程崩溃。

这一层最危险的不是某道闸失效,而是误以为闸门已经足够。路径 canonicalization 有不可消除的 TOCTOU race——检查和使用之间文件可以被外部进程改掉;shell、网络和环境变量仍然继承进程的全部权限,产物里没有"危险命令黑名单"。read 和 write 默认放行,真正的审批由 BeforeToolCall 在调用层决定,不在工具内部。把工具层当成沙箱,是这一章要彻底打掉的错觉。

工具和调用层的分工在这一章被反复强化:工具负责"能不能安全执行",调用层负责"该不该执行"。stringField 只判类型不判语义,审批只在调用层发生,bash 只问 shell 是谁而不问命令危不危险——每一层的边界都窄而清晰。这种分工的直接收益是工具本身可测:测试能直接调用 createWorkspaceTools 的 execute,用真实文件系统验证截断、嵌套写入和 symlink 逃逸,不需要任何网络或审批上下文。工具层不假设自己运行在什么策略之下,所以它能在任何策略下被测试。

这章还有一个容易被忽略的细节:工具的所有失败都收敛到 failure(cause) 这一个函数。read 的权限错误、write 的越界、bash 的超时,最终都以 isError: true 的结果交给模型——模型侧看到的是统一的"这个动作没做成,这是原因"。统一失败形状的意义不在美观,在于下一轮模型只面对一种反馈语法,修正逻辑才简单。至于真实的安全边界,留给调用层的审批、部署方的容器与权限降级去补——它们不在 tools.ts 里,但每个认真用这套产物的人都应该知道它们的存在。

阶段验收 ​

npm test 覆盖截断首尾、嵌套 write 和 symlink 读写逃逸。