04|手脚①:工具的注册、调度与文件安全边界

0 阅读7分钟

这是《Agent全栈开发实战》的第 4 篇。整个系列以 catbuddy为案例,由浅入深拆 harness 的设计。前面我们手写过一个最小循环,它的灵魂是那行 runTool(call)——模型说要调工具,我们就真的去调。这一篇就把 runTool 撑开:真实的工具系统怎么管这十几个工具?以及,最危险、也最常被低估的一个工具——文件读写,凭什么不是一句 fs.readFile 就完事?读这篇你不需要追前几篇,开头我会把背景补齐。


  1. 从那行 runTool 说起

回忆一下最小 harness 的内核。模型返回一个 tool_use,我们大概率会写成一个 switch:

switch (call.name) {
  case "read_file":  return readFile(call.args)
  case "write_file": return writeFile(call.args)
  case "exec":       return exec(call.args)
  // ……每加一个工具,这里加一个 case
}

跑 demo 没问题。但你只要往真实产品走一步,这个 switch 就开始硌人:

  • catbuddy 有 15 个内置工具,还可能挂上几个外部工具——一个巨大的 switch 谁都不想维护;
  • 模型不光要「调用」工具,它在调用之前还得看见工具:每个工具长什么样、收什么参数、干什么用——这份「说明书」得有地方统一产出;
  • 最要命的是 read_file / write_file 这一组。模型可以在文本里大大方方地说「我要读 /etc/passwd」「我要把 ~/.ssh/id_rsa 打印出来」。switch 照单全收,等于把整台机器的文件系统裸奔交给了一个会被你的输入随意引导的模型。

所以真实的手脚不是一个 switch,而是两层东西:一层是把工具管起来的注册表(谁在、叫什么、怎么调),另一层是把文件操作关进笼子的安全边界(能碰哪、不能碰哪、改之前读过没)。这篇就讲这两层。

先看一张全景,建立坐标——一条 tool_call 进来,到底走了哪几个关卡:

image.png

记住这条主轴:模型只管喊名字,Registry 负责找人,PathGuard + FileStates 负责在真正动文件之前把关。 下面逐段拆。


  1. ToolRegistry:一个 Map 顶掉那个 switch

那个 switch 的本质问题是——它把「有哪些工具」和「怎么调工具」焊死在了一起。catbuddy 的做法是把它俩拆开,核心数据结构简单到只有一行:

// apps/desktop/src/main/agent/tools/registry.ts
export class ToolRegistry {
  private readonly _tools = new Map<string, Tool>()
  // ...
}

一个 Map<string, Tool>,key 是工具名,value 是 Tool。这里要先讲清一个新概念——Tool(工具) :在 catbuddy 里,一个工具就是两样东西的捆绑,一份 schema + 一段 execute。schema 是给模型看的「说明书」(叫什么、收哪些参数、干什么用),execute 是真正干活的那段代码。模型读 schema 决定要不要调、怎么调;harness 拿到调用就去跑 execute。

围绕这个 Map,Registry 干三件事:

职责一:注册中心。 进程启动时,15 个内置工具被批量注入。注意它们不是 new 出来的类,而是一组工厂函数的产物——挨个调用工厂、塞进 Map:

registerBuiltinTools(): void {
  const ctx = this.createToolContext()      // 一份共享上下文
  for (const factory of builtinToolFactories) {
    this.register(factory(ctx))             // 工厂吃下 ctx,吐出一个能用的 Tool
  }
}

为什么是工厂函数而不是类继承?因为工具之间根本没有 is-a 关系——read_file 不是 write_file 的子类,硬套继承只会别扭。工厂函数 + 接口注入更轻:加一个新工具,只要写一个函数文件,再去数组里加一行,注册、查找、执行的代码一个字都不用改。

那行 factory(ctx) 里的 ctx 是关键——它叫 ToolContext,是所有工具共享的「插头」。工具自己不持有路径、不持有文件状态,这些全从 ctx 里拿:

createToolContext(): ToolContext {
  const thisRegistry = this
  return {
    get workRoot() { return thisRegistry._pathGuard!.workRoot },  // 注意是 getter
    fileStates: this._defaultFileStates,                          // 文件读写追踪器
    resolvePath: (input) => this.resolvePath(input),              // → PathGuard
    notifyFileEdit: (edit) => this.notifyFileEdit(edit),          // → UI 通知
    // ...
  }
}

workRootgetter 而不是直接给值,这个细节很重要:用户切换项目目录时,背后的 PathGuard 会被重建,新路径通过 getter 自动生效——不用重启 Registry,不用重新注册一遍工具。

职责二:查找引擎。 execute(call) 是整个系统唯一的工具调用入口,内部就是 Map.get 一把命中:

async execute(call: ToolCallRequest): Promise<string> {
  const tool = this._tools.get(call.name)
  if (!tool) return `Error: unknown tool "${call.name}"`   // 没有就明确报错
  try {
    return await tool.execute(call)
  } catch (err) {
    return `Error executing ${call.name}: ${...}`           // 异常包成字符串喂回模型
  }
}

注意这里是 O(1) 精确匹配,不做任何模糊搜索——没有别名、没有近似、没有「你是不是想调 grep」。模型必须吐出精确的工具名,对不上就直接返回 unknown tool。为什么这么死板?因为模糊匹配会引入不确定性:模型本来想调 A,被「猜」成了 B,那才是真正的灾难。宁可明确报错让模型重试,也不替它瞎猜。

还有个细节:tool.execute 抛了异常,这里 catch 住、包成一句 Error: ... 字符串喂回给模型,而不是让整个循环崩掉。对模型来说,工具报错和工具返回内容一样,都只是一条「观察」——它看到错误,自己决定下一步换个姿势再试。

职责三:安全门面。 Registry 不自己做路径校验,但它是所有工具访问文件系统的统一收口——resolvePath() 一律委托给 PathGuard。这就把第 2 节的主角引出来了。

这里先按下一个伏笔:那个 Map<string, Tool> 不只装内置工具。catbuddy 还能用 mcp_ 前缀把外部工具塞进同一个 Map——一个叫 filesystem 的外部服务暴露的 read_file,进来就叫 mcp_filesystem_read_file,和内置的 read_file 同台共存、互不打架。对模型来说这层完全透明:它根本分不清自己调的 grep 是内置的还是外部接进来的,反正都是 registry.execute 一个入口。外部生态怎么接、怎么管生命周期,是下一篇(第 05 篇)的主题,这里点到为止。


  1. 为什么文件读写远不止 fs.readFile

好,工具能被注册、被查找、被执行了。现在把镜头怼到最危险的那个工具上。

你大概会觉得 read_file 平平无奇——打开、读、返回,三行 fs.readFileSync 的事。但放在 Agent 里,它至少藏着三个非加不可的机制:

  1. PathGuard——模型说要读哪,不归它自己说了算。每条路径都得先过安检,越界一律拒绝;
  2. FileStates——这一轮里「读过 / 改过哪些文件」得有人记账,否则模型会在循环里反复读同一个文件、白烧 token;
  3. 而正是这两件事,撑起了第三个工具 edit_file 的「精确字符串替换」——它的整个设计假设模型改文件之前已经读过

一个个来。

2.1 PathGuard:Agent 能碰哪些文件,不归它自己说了算

这是整个文件系统里最硬的一道闸。先讲它要解决的根本矛盾:模型会被你的输入引导。你跟它说「帮我看看系统配置」,它完全可能「热心」地去读 /etc/passwd;你说「清理一下密钥」,它可能伸手去摸 ~/.ssh/id_rsa。它不是恶意——它只是没有「这里不能碰」的概念。所以这个概念必须由 harness 用代码强加进去。

catbuddy 的策略分两种模式:

  • project 模式:Agent 能访问整个项目根目录(也就是 .catbuddy 元数据目录的父目录)。正常开发场景就是它。
  • internal 模式:Agent 只能在 .catbuddy/workspace 这个内部目录里活动。当用户加载的是主目录这种敏感路径时,自动收紧到这一档。

边界由一个 getter 决定,干净利落:

// apps/desktop/src/main/security/path-guard.ts
get boundary(): string {
  return this.mode === "internal" ? this.workspace : this.projectRoot
}

每一次文件操作之前,路径都得先过 resolve()。这里藏着最关键的一招——path.resolve()

resolve(inputPath: string): string {
  const resolved = path.isAbsolute(inputPath)
    ? path.resolve(inputPath)
    : path.resolve(this.workRoot, inputPath)   // 相对路径,相对 workRoot 展开
  this.assertAllowed(resolved, inputPath)      // 展开后再校验
  return resolved
}

为什么必须先 resolve 再校验?因为路径遍历攻击全靠伪装。模型(或者更现实地说,被诱导生成的路径)可能丢给你一个 ../../../../etc/passwd。你要是直接拿字符串去判断「它在不在项目里」,.. 这种相对片段会让你看走眼。但 path.resolve() 会把所有 .. 实打实地展开、算出真实的绝对路径——/etc/passwd 在这一步原形毕露,藏不住了。先展开真身,再对真身校验,这是路径安全的命门。

assertAllowed 做两重检查,越界就抛 Access denied

assertAllowed(resolved: string, label = resolved): void {
  const boundary = this.boundary
  // 第一重:解析后的路径必须落在 boundary 之内
  if (!resolved.startsWith(boundary + path.sep) && resolved !== boundary) {
    throw new Error(`Access denied: "${label}" is outside ${scope}`)
  }
  // 第二重:.catbuddy 元数据目录是禁区,哪怕它就在项目里
  if (insideCatbuddy && (this.mode !== "internal" || !insideInternalWorkspace)) {
    throw new Error(`Access denied: "${label}" is inside ${CATBUDDY_DIR_NAME}`)
  }
}

第一重是边界:真实路径必须在那个「圈」里,圈外一律拒。第二重是「猫窝禁区」:.catbuddy 存着 session、记忆、skill 这些内部状态,即使它就躺在项目根目录下,Agent 也绝对不许碰——不然模型一个手滑改坏了自己的记忆文件,那就魔幻了。

整条校验链画成图是这样:

image.png

一句话记住 PathGuard:Agent 看得见的文件系统是一个「圈」,圈多大由安全模式定,但任何路径先 resolve 成真身才校验,而圈里的 .catbuddy 永远是禁区。 顺带一提,exec 跑命令、web_fetch 拉网页这类能「绕过」文件 API 去摸系统的工具,还各自配了危险命令黑名单和内网 URL(SSRF)拦截——这块属于外部能力的安全话题,留到第 05 篇一起展开。

2.2 FileStates:这一轮读过、改过哪些文件,得记账

PathGuard 管「能不能碰」,FileStates 管「碰过没、变没变」。

为什么要记这本账?两个真实的痛点:

  • 模型在一个长循环里特别容易反复读同一个文件——刚读完 app.ts,过两轮思考又来读一遍。内容根本没变,白白烧一遍 token。
  • 模型可能没读就想改——直接对一个它从没看过的文件下手编辑,这往往意味着它在凭空猜内容,危险。

这两件事都需要一个「跨工具调用的记忆」:read_file 记下来读过谁,edit_file 才能问「这文件读过吗」。问题是——这两个工具是分别独立调用的,怎么共享状态?catbuddy 用了 Node.js 的 AsyncLocalStorage

// apps/desktop/src/main/agent/tools/file_state.ts
const _currentFileStates = new AsyncLocalStorage<FileStates | undefined>()

export function currentFileStates(defaultStates: FileStates): FileStates {
  return _currentFileStates.getStore() ?? defaultStates
}

AsyncLocalStorage 你可以理解成「请求级的全局变量」——在同一轮 Agent Loop 的多次工具调用里,它保证大家拿到的 FileStates同一个实例。于是 read_file 写进去的记录,edit_file 立刻能读到,中间不用层层传参。

FileStates 本身就是个 Map<string, ReadState>,每个文件记着 mtime、内容 hash、读取的 offset/limit,还有一个去重开关 canDedup。对外就三个核心动作:

方法谁来调干什么
recordRead(path)read_file 成功后记下 mtime + hash,开启去重
recordWrite(path)write_file / edit_file 成功后更新 mtime + hash,关闭去重
isUnchanged(path)read_file 读之前mtime 和 hash 都没变?那就别读了
checkRead(path)edit_file 改之前读过没?没读过就返回一句警告

read_file 里因此多了一道去重闸——发现「同样参数、内容没变」,直接回一句别读了:

// apps/desktop/src/main/agent/tools/read-file.ts
if (states.isUnchanged(resolved, off, lim)) {
  return 'File unchanged since last read at the same offset/limit.'
}
// ……真正读取,然后 states.recordRead(resolved, off, lim)

这里有个特别拧巴但特别对的细节:recordWritecanDedup 被置成 false——文件一旦被自己改过,就不再享受去重优化。道理很直白:万一模型先写了文件 A,又在同一轮想读 A,它必须拿到最新内容,而不是一句虚假的「文件没变」。宁可多读一次,绝不能给模型喂过时的信息。

2.3 收口:这两个机制怎么撑起 edit_file 的「精确替换」

现在把前面所有伏笔收回来。edit_file 是这三个工具里最「龟毛」的一个,它不用行号、不用范围,只收三个参数:pathold_string(要被替换的精确原文)、new_string(替换后的文本)。核心就一条铁律——old_string 在文件里必须恰好匹配一次

const count = content.split(old).length - 1
if (count === 0) return 'Error: old_string not found in file'
if (count > 1)  return `Error: old_string matches ${count} times — must be unique.`
const updated = content.replace(old, neu)

零次不行,两次也不行。为什么这么轴?为了防止模型误伤。模型很容易甩给你一个含糊的 old_string,比如只写一行 import React;要是文件里有好几处 import React,不报错的话就全被替换——一次手滑改崩半个文件。逼它必须唯一匹配,等于逼它给出足够的上下文(一般给三行就够唯一了)。

但你有没有发现,这套「精确替换」能成立,背后压着一个隐含假设:模型手里得有这个文件的准确原文,它才可能写出一个能恰好命中一次的 old_string。原文从哪来?只能来自它之前读过。这就是 2.2 那本账的用武之地——edit_file 动手前会先 checkRead

const warning = currentFileStates(ctx.fileStates).checkRead(resolved)
// 没读过:'Warning: file has not been read yet. Read it first ...'

注意,这个警告不会拦住编辑,它只是作为提示塞回给模型,让模型自己掂量要不要先去读。但配合「精确唯一匹配」的硬约束,效果是闭环的:你没读过 → 多半写不出能唯一命中的 old_string → 编辑要么 not found 要么 matches N times 直接失败。软提示 + 硬约束两头一夹,模型被自然而然地推上「先读、后改」的正道。

把三个工具的协作画成一条时序,这篇的核心就齐了:

image.png

write_file(整文件创建 / 覆盖)这里没展开讲,逻辑是同一套:写完一样 recordWrite 关掉去重,并通过 notifyFileEdit 把「正在写 / 写完 +N -M / 出错」三个阶段实时推给 UI——所以你在界面上能立刻看到那个跳动的 +5 -2 气泡,而不是干等到改完才知道发生了啥。


  1. 复盘:这套手脚为什么这么设计

把这篇捋一遍,你会发现它处处是「把不确定性关进确定性的笼子」:

  • 模型会喊错工具名 → Registry O(1) 精确匹配,对不上就明确报错,绝不模糊猜测;
  • 模型会被引导去碰不该碰的文件 → PathGuard resolve 成真身再校验边界.catbuddy 永远禁区;
  • 模型会反复读、会没读就改 → FileStates 用 AsyncLocalStorage 跨工具记账,读去重、改之前查读
  • 而这些机制最终互相成就:正因为有 PathGuard 兜底、有 FileStates 逼着「先读后改」,edit_file 才敢只认「精确唯一匹配」这一条规则,把误伤的可能压到最低。

一句话:真实的「手脚」不是给模型更多自由,而是给它划好边界。 边界画对了,模型反而能在里面放心大胆地干活。


这篇讲了什么?

  1. 那个 runTool 的 switch,在真实系统里是 ToolRegistry——一个 Map<string, Tool>,15 个内置工具靠工厂函数批量注册,registry.execute(name, args)唯一入口,O(1) 精确查找、绝不模糊匹配。工具 = 一份给模型看的 schema + 一段真干活的 execute。
  2. 文件读写远不止 fs.readFilePathGuard 在每次操作前先 path.resolve() 把路径展开成真身,再校验是否落在 workspacePath/internalPath 的圈内(../../.. 在 resolve 后原形毕露一律拒),且 .catbuddy 永远是禁区。
  3. FileStatesAsyncLocalStorage 跨工具调用记账:读过没变的再读会被去重,没读过就编辑会收到警告——正是这两个机制,让 edit_file 的「精确字符串替换」(必须恰好命中一次)能成立,把模型自然推上「先读后改」的正道。

下一篇预告:内置工具有了安全边界,但 catbuddy 不想为每个外部服务手写胶水——下一篇看它怎么用 MCP 一套协议把外部生态透明接进同一个 Registry(连同那块绕过文件 API 的安全防护),再用 Skill 告诉 Agent「这些能力什么时候用、怎么用」。