Skip to content

Part III · 实现篇 / Ch 04

实现篇第4章 给手装上工具——Tools ​

小A和老Z在工作台上把一个形状块依次送过轮廓孔和夹规检查;绿藤止于透明围栏外,装置吐出纸带,章鱼正用剪刀修剪纸带。

本章导读: 你会写 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 用于告诉模型期望形状;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,工具名在前,规则在后。模型读到这条消息,能同时知道‘哪个工具’‘哪个字段’‘什么规则’,修正才有依据。这是给模型的反馈,反馈的信息密度直接决定它下一轮能不能改对。”

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

老Z接着走了一遍完整流程:“以 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看完全段:“这些检查放到真实场景里,都长什么样?”

老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不放心:“那测试跑绿了,能证明这套检查绝对安全吗?它到底拦住了什么,只是 symlink 吗?”

“不能,测试证明的是它拦住了哪些,证明不了竞争条件不存在。”老Z说,“它拦住三类常见错误,测试文件里也都有对应用例:直白的越界,path: "/etc/passwd",relative 一算就发现不在 root 内;相似前缀,/work-other/x,relative 结果以 .. 开头被拒;静态 symlink,escape 指向工作区外,realpath 解析后比较发现不在 root 内。另外,测试还覆盖截断、嵌套 write 以及 symlink 读写逃逸。拦不住的是第四类 TOCTOU:检查完到 writeFile 之间,外部进程把目录换成 symlink。这是 race,应用层没有原子操作能消除它,只有 OS 级 sandbox 能。”

小A:“那为什么不直接上 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 那两个数我也看懂了,timeout 管命令跑多久,maxBuffer 管输出多大,对吧?”

“对,两个维度,互不替代。”老Z说,“任一触发都进 catch 返回 failure(cause)。不过它们只是资源限制:一条 rm -rf 在一秒内就能跑完,远没触发 timeout,命令危不危险更不在它们的管辖内。清单里写‘timeout 是资源限制,不是命令安全证明’,防的就是‘命令卡死’和‘输出爆炸’。”

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

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

bash 的失败语义 ​

小A:“命令找不到、超时、输出爆掉,这几种失败走的是不是同一条路?”

“是同一条。”老Z说,“execFile 的 timeout、maxBuffer 或非零退出都会进入 catch,工具返回 failure(cause),把 cause.message 变成结果文本:Command failed: ...、超时信息或 maxBuffer 报错,全都以 isError: true 的消息交给模型。这让 Agent 获得错误文本,而不是让 Node 异常直接终止循环。模型因此能区分‘命令跑了但退出码非零’和‘命令压根没跑成’,但它不需要区分背后的 Node 机制。”

小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 触发时,子进程会被终止而不是悬在那里。但取消是协作式的:signal 停得掉 execFile 等待的那个进程,停不掉 shell 里已启动的子命令和已写出的文件,这些不会因为 signal 而回滚。取消的文档一直强调‘副作用要调用方兜底’,原因就在这里。”

“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说,“①拦形状认知,②拦形状事实,③拦路径,④拦体积。四道都排在应用层内,这也是清单反复强调路径边界顶替不了 OS 沙箱的原因。第四道之后就到真文件系统,那里没有闸门,TOCTOU、shell 权限、网络访问,都不归这一章管。”

小结 ​

工具层用四道闸门控制“模型能碰什么”: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 读写逃逸。