跳到主要内容

工具系统:目录组装、模糊编辑与 Shell 沙箱

30 秒导读: 这一章讲 agent 的「手脚」。模型只会输出一句话——「把文件里这段文本替换成那段」「跑这条命令」——真正把它精确、可靠、可回滚地落到磁盘和终端上,是 internal/tools/ 这一层的活。最难的三件事:① 模型给的「旧文本」几乎从不和文件一字不差(→ 模糊编辑);② 两次写之间文件可能被人改了(→ 乐观并发);③ 一条命令可能要密码、可能永不退出、可能删库(→ Shell 沙箱)。

本章上游链路见 核心回合循环;权限「允许/审查/拦截」的判定属 安全与权限;tool_search 背后的 MCP 延迟工具属 Provider 与扩展面,本章只把它当作工具组之一。


1. 这是什么(零基础也能懂)

一句话定义: 工具系统是 Whale 暴露给大模型的「一组可调用函数」,以及把模型返回的调用参数安全地执行到真实世界的那套机制。

解决什么问题: 大模型本身只会生成文本。想让它「读某个文件」「改第 42 行」「跑一次测试」,就得给它一批带 JSON schema 的工具;模型按 schema 填参数,Whale 负责校验参数、执行、并把结果再喂回模型。这一层就是模型和你电脑之间的「手」。

它能做什么(功能分组):

工具组代表工具干什么
文件发现read_file list_dir load_skill读文件 / 列目录 / 载入本地 Skill
搜索grep search_files按内容(正则)/ 按文件名找
Webweb_search fetch web_fetch搜网、抓网页
请求输入request_user_input向用户提问并等待
文件改动edit multi_edit write改文件(本章重点)
Shellshell_run shell_wait write_stdin shell_cancel跑命令 / 等待 / 喂 stdin / 取消
计划update_plan更新执行清单
待办todo_add todo_list会话内待办
MCP 检索tool_search按需激活延迟加载的 MCP 工具

用起来什么样: 模型不会「直接调 Go 函数」,它输出一段工具调用 JSON,例如让 edit 把一段代码换掉:

{
"name": "edit",
"input": {
"file_path": "internal/server/http.go",
"search": "port := 8080",
"replace": "port := cfg.Port"
}
}

Whale 收到后:校验参数 → 解析 file_path 是否在允许范围 → 读文件 → 在文件里找 search(找不到还会模糊匹配)→ 替换 → 原子写回 → 把「改了几处 + diff」返回给模型。

一句话直觉: 把工具层想成一个极度谨慎的秘书——模型说的话它照做,但每一步都先核对:这路径我能碰吗?你给我的原文和文件现在的内容对得上吗?这命令会不会卡住或要密码?对不上就不动手,并回一句「这里对不上,你再看看」。

本节不谈实现。目标:知道「工具系统是模型的手,且这只手非常防呆」。


2. 顶层全景(它大概怎么转)

2.1 两层结构:core 定契约,tools 出实现

Whale 把「工具是什么」和「工具怎么做」拆成两层:

  • internal/core —— 定义契约:一个工具最少长什么样(Tool 接口)、注册表怎么存怎么派发(ToolRegistry)、参数怎么校验和修复(tool_input_repair.go)、结果怎么归一成统一信封(normalizeToolContent)。它不认识任何具体工具。
  • internal/tools —— 出实现:Toolset 持有工作区根路径、HTTP 客户端、任务表、文件锁等状态,把 30 多个内置工具装进一个个「目录」(catalog)。

2.2 一次工具调用的主线(高层,不进代码)

模型输出 tool call {name, input(JSON)}


┌──────────────────────────────┐
│ core.ToolRegistry.Dispatch │ ① 按 name 找到工具 + 冻结的 ToolSpec
│ - validateToolInput │ ② 用 schema 校验参数(缺字段/多字段?)
└──────────────┬───────────────┘

┌──────────────────────────────┐
│ tools.Toolset 的具体 fn │ ③ 真正干活:editFile / shellRun / …
│ - safePath 路径守卫 │ 先问「这路径能碰吗」
│ - 读文件 / 起进程 │
│ - commitFilePlans 原子落盘 │ 乐观并发校验后写
└──────────────┬───────────────┘

┌──────────────────────────────┐
│ core.normalizeToolContent │ ④ 把结果套进统一信封 + 截断超长输出
└──────────────┬───────────────┘

结果 ModelText 回到模型

「怎么读这张图」:从上到下是一次调用的时间顺序;①② 在 core 层,③ 在 tools 层,④ 又回 core 层收口。校验在前、执行居中、归一在后——工具作者只写 ③,前后由框架统一保证。

2.3 目录组装:Tools() 把 9 组拼成一份清单

Toolset.Tools() 只是把 9 个分组各自的 []core.Tool 顺序拼接起来——catalog.go:7-19 里一目了然:

真实代码 internal/tools/catalog.go:9-17(Tools):

tools = append(tools, b.fileDiscoveryTools()...)
tools = append(tools, b.searchTools()...)
tools = append(tools, b.webTools()...)
tools = append(tools, b.requestInputTools()...)
tools = append(tools, b.fileMutationTools()...)
tools = append(tools, b.shellTools()...)
tools = append(tools, b.planRuntimeTools()...)
tools = append(tools, b.todoRuntimeTools()...)
tools = append(tools, b.mcpSearchTools()...)

每个 xxxTools() 返回一批 toolFn(见 catalog_files.gocatalog_shell.go 等)。最后一组 mcpSearchTools() 只有在配置了延迟 MCP 目录时才非空(catalog_mcp.go:37-44,mcpSearchTools)——这是 06 章的话题。

部件一句话职责:

部件干什么在哪
Toolset持有工作区状态,产出所有内置工具internal/tools/toolset.go:20 Toolset
toolFn一个工具的具体定义(名字/schema/函数)internal/tools/tool_fn.go:9 toolFn
core.Tool工具的最小契约(只要 Name+Run)internal/core/tool.go:8 Tool
ToolRegistry存工具、冻结 spec、派发调用internal/core/tool_registry.go:19 ToolRegistry
路径守卫判断路径能否读/写toolset.go:276 safePath / :304 safeReadPath
编辑引擎search/replace + 模糊匹配internal/tools/edit.go:147 resolveEditSearch
落盘乐观并发 + 原子写internal/tools/file_mutation.go:76 commitFilePlans
Shell进程会话管理internal/tools/shell.go:14 shellRun
执行边界每次 exec 前查策略internal/execboundary/

3. 核心机制(逐个,由浅入深)

3.1 一个工具是怎么「定义」的:鸭子类型 + 冻结 spec

要解决的小问题: core.Tool 接口故意只要求两个方法——Name()Run()(core/tool.go:8-11)。可一个真实工具还需要描述、JSON schema、是否只读、需要什么能力……这些怎么带上?

思路: 用 Go 的可选接口(鸭子类型)。工具可以「顺便」实现 ToolDescriberToolParamSpecToolReadOnly 等接口;DescribeTool 逐个类型断言,实现了就取值,没实现就用默认。

真实代码 internal/core/tool.go:93-97(DescribeTool):

if d, ok := t.(ToolDescriber); ok {
if v := d.Description(); v != "" {
spec.Description = v
}
}

内置工具全用一个结构体 toolFn 一次性实现所有可选接口(tool_fn.go:20-40)。所以定义一个工具就是填一个 struct 字面量——名字、描述、parameters(JSON schema)、readOnlycapabilitiesfn。看 read_file 的定义 catalog_files.go:20-36,或 edit 的定义 catalog_files.go:73-90,都是这个套路。

冻结(freeze): 注册时 ToolRegistryDescribeTool 把每个工具的 spec 算好、存进 specs map(tool_registry.go:97-102,replaceToolsLocked)。之后对外发出的工具是 frozenSpecTool 包装(tool_registry.go:209),Name()/Parameters() 等一律读冻结快照而非每次重算——保证同一轮里工具面貌稳定、schema 不抖动。

派发: 模型发来调用 → DispatchWithProgress(tool_registry.go:309)按 name 找工具和 spec,先校验参数再执行:

真实代码 internal/core/tool_registry.go:332-340(DispatchWithProgress 内):

if hasSpec {
if err := validateToolInput(spec.Parameters, call.Input); err != nil {
return normalizeRegistryResult(ctx, call, ToolResult{
...
ModelText: invalidToolInputContent(call.Name, spec.Parameters, err),
Code: "invalid_input",
}, ...), nil
}
}

validateToolInput(tool_registry.go:734)做两件事:必填字段在不在、additionalProperties:false 时有没有多余字段。有个体贴的例外——description 字段永远放行(tool_registry.go:766,reservedDescriptionField),因为别的 harness 训出来的模型爱在每个 tool call 上挂个人读标签,为此拒绝调用太蠢。

3.2 参数「修复」:模型填错形状也能救

要解决的小问题: 模型经常把参数形状填错——该给数组给了字符串、该给布尔给了 "true"、可选字段填了 null、把路径写成 Markdown 链接 [x](http://x)。直接报错=浪费一轮往返。

思路: 在校验前先做窄范围、由 schema 指导的自动修复。RepairToolInputForSpec(core/tool_input_repair.go:40)按 schema 遍历参数,发现类型不匹配就尝试补救,并记录每一处「修了什么」。

支持的修复种类(tool_input_repair.go:11-19 常量):

症状修复
"[1,2]"(字符串化的数组)解析成真数组 stringified_array
数组位却给了裸字符串包成单元素数组 bare_string_to_array
可选字段给了 null直接删掉该字段 null_optional_omitted
"true" / "42" 给到 bool/int 位转成真布尔/数字 semantic_boolean_string
[readme](http://README.md) 塞进 file_path还原成 README.md markdown_autolink_path

修复是保守的:修完再校验一遍,若仍有问题就原样退回不猜(tool_input_repair.go:62-64)。

若修不了、校验仍失败,错误信息也不是干巴巴的——ToolInputRecoveryHint(tool_registry.go:390)对高频错配给出人话建议,例如「search_files 没有 include 字段,把 glob 直接写进 pattern,或改用带 includegrep」;通用情况则由 schemaFieldRecoveryHint(tool_registry.go:414)从 schema 自动列出「你填错的字段 + 合法字段清单」。

3.3 路径守卫:模型给的路径能不能碰

要解决的小问题: 模型说「读 ../../etc/passwd」怎么办?工具层必须先判定一个路径是否在允许范围内,再决定读/写。

两把不同严格度的尺子:

  • (edit/write/multi_edit)走 safePathsafeWorkspacePath(toolset.go:276:280):把路径 clean 后要求它必须落在工作区根内,算出的相对路径一旦以 .. 开头或是绝对路径,直接拒绝「path escapes workspace」。写是危险动作,尺子最紧。

真实代码 internal/tools/toolset.go:294-301(safeWorkspacePath):

rel, err := filepath.Rel(b.root, target)
if err != nil {
return "", err
}
if strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
return "", fmt.Errorf("path escapes workspace: %s", raw)
}
return target, nil
  • (read_file/grep/list_dir)走 safeReadPath(toolset.go:304),尺子松一点:工作区内直接放行;工作区外的路径,只有落在下面几类白名单根里才允许——git worktree 的原工作区(isProjectReadPath)、已获批的外部读路径(isApprovedExternalReadPath,由 context 注入)、以及本地发现的 Skill 目录(isDiscoveredSkillReadPath)。~/ 会先被 expandHomePath 展开(toolset.go:344)。

为什么读写不同尺度: 读外部文件(比如让模型看一个它自己产出的、workspace 外的日志)相对安全且常用;写外部则风险高。所以「读」用白名单放宽,「写」死守工作区。真正的「要不要弹窗问用户」是更上层的权限判定(04 章),路径守卫只做最底层的「够不够格连碰都不让碰」。

3.4 招牌:模糊编辑——旧文本对不上也能救

要解决的小问题: edit 要求模型给出文件里当前存在的一段 search 文本,把它换成 replace。但模型是「凭记忆」写 search 的,常常差一个空格、缩进不一致、或把换行写成了字面 \n。若只做严格 strings.Contains,匹配失败率会很高。

思路:三级降级匹配。 resolveEditSearch(edit.go:147)按「越来越宽容」的顺序尝试,命中即停:

search 文本

▼ ① 精确子串命中? strings.Contains(before, search)
├── 命中 → 用它

▼ ② 整行对齐、容忍空白差异? editWhitespaceRelaxed
├── 唯一命中 → 用文件真实字节(repair: whitespace_normalized)

▼ ③ search 里含字面 \n \t \r? 尝试反转义再找一次
├── 唯一命中 → 用反转义版(repair: json_escape_unwrapped)

▼ 都不中 → search_not_found + 「最接近的分歧点」诊断

「怎么读这张图」:从上到下宽容度递增,命中即停;每一级降级都会打一个 repair 标记,好让上层知道「这次是模糊命中的,不是精确命中」。

第二级最见功力。 editWhitespaceRelaxed(edit.go:193)不是简单 TrimSpace 全文,而是按整行对齐做两轮尝试:先只容忍行尾空白差异(rstripLineEqual),再容忍两端空白差异(trimLineEqual)。关键约束是必须唯一命中——多处匹配就放弃,退回 not-found,绝不赌。而且即便行级对齐成功,还要确认这段真实字节是文件里的第一次出现,否则 strings.Replace 会替换错位置(edit.go:231-233)。

真实代码 internal/tools/edit.go:180-186(两级放宽的判等函数):

func rstripLineEqual(a, b string) bool {
return strings.TrimRight(a, " \t") == strings.TrimRight(b, " \t")
}

func trimLineEqual(a, b string) bool {
return strings.TrimSpace(a) == strings.TrimSpace(b)
}

匹配失败时的诊断也不摆烂。 editSearchDivergence(edit.go:271)会以 search 的第一行为锚,在文件里找最接近的候选区域,告诉模型「最接近的匹配从第 N 行开始,前 K 行一致,在第 M 行分岔:文件是 X,你给的是 Y」——让模型一轮就能改对,而不是再整文件读一遍。若失败的 search 块超过 40 行(largeEditSearchLines,edit.go:131),诊断还会追一句「块太大,一行错整块废,拆成几个小 edit 或用 multi_edit」。

原理演示(示意,非源码):

# 演示三级降级的直觉:模型给的 search 缩进和文件差了两个空格
file = " if ready:\n run()\n"
search = "if ready:\n run()\n" # 少了缩进、换行也不同

# ① 精确匹配失败(空白对不上)
# ② 整行对齐:trim 两端空白后,'if ready:' 和 'run()' 都能对上且唯一
# → 于是用文件里的真实字节(带正确缩进)去替换
# 重点看:替换目标永远是文件的真实字节,search 只用来"定位"

3.5 原子多编辑 + 乐观并发:改一半不留半成品

要解决的小问题: multi_edit 一次要按顺序打好几处补丁。如果第 3 处失败,前 2 处已经写进文件了怎么办?另外,从「读文件」到「写回」之间有个时间窗,期间文件可能被别的进程(或用户)改了。

两条防线:

① 全成才写(内存里先跑完)。 applyMultiEditSteps(multi_edit.go:121)在内存字符串上依次应用每一步,每步看到的是上一步的结果;任何一步失败就带错误返回,根本不碰磁盘。全部成功后才把最终字节交给落盘。这样磁盘上永远只有「改动前」或「全部改完」两种状态,没有半成品。非 all 模式下每步还要求 search 恰好唯一命中,否则报 search_not_unique 并把每个命中位置的行号列给模型(describeSearchMatches,multi_edit.go:186)。

② 乐观并发校验(写前再看一眼)。 落盘走 commitFilePlans(file_mutation.go:76)。它带着「我读到时文件长这样」(expectedBytes)去写,写前先重读磁盘对比:

真实代码 internal/tools/file_mutation.go:122-128(verifyFilePlanCurrent):

if !plan.expectedExists {
return fileConflictError{path: plan.path, reason: "file was created before write"}
}
if !bytes.Equal(current, plan.expectedBytes) {
return fileConflictError{path: plan.path, reason: "file changed before write"}
}
return nil

一旦发现「文件在我读之后被改过」,返回 write_conflict 并提示模型「重新读一遍再改」(edit.go:73-74)——绝不用旧认知覆盖新内容。

③ 条带锁(stripe lock)防同进程并发。 同一个 Toolset 里可能有并发工具调用。fileMutationLocks 用 64 把互斥锁,按路径 FNV 哈希取模选锁(file_mutation.go:39-56,uniqueSortedLockIndexes)。多路径写会按锁序号排序后依次加锁——排序是为了避免两个调用以相反顺序拿锁而死锁。这样对同一文件的并发改动被串行化,又不至于用一把全局大锁拖慢无关文件。

写成功后,把新内容的哈希记进 fileStateCache(file_state.go:26,sha256),作为「这个文件当前状态」的记账。

3.6 行尾与编码归一:别让 CRLF 污染整个 diff

要解决的小问题: 模型永远以 \n(LF)思考和输出。但磁盘上的文件可能是 CRLF(Windows)、可能带 UTF-8 BOM、可能是 GB18030 编码。如果直接按模型的 LF 写回,整个文件的行尾都会被改一遍——diff 里冒出几百行「全变了」的噪音,缓存也跟着抖。

思路:读进来时归一到 LF,写回去时还原成原样。 一进一出对称处理:

  • 读: normalizeTextFileBytes(line_endings.go:54)先剥掉 BOM、按编码解码,再 normalizeLineEndings 统一成 LF,同时记住原始风格(CRLF/CR/mixed、有无 BOM、什么编码)存进 lineEndingSnapshot
  • 改: 所有 search/replace 都在纯 LF 的文本上做。
  • 写: restoreTextFileBytes(line_endings.go:69)按 snapshot 把 LF 还原回 CRLF、补回 BOM、编码回 GB18030。

混合行尾最麻烦。 如果文件本来就是 CRLF/LF 混着的,不能简单「全换成 CRLF」。Whale 会逐行记住每行原本的分隔符,写回时用 LCS / Myers diff 把改动后的行和改动前的行对齐,只在真正改过的行上决定新行尾,没动的行原样保留(line_endings.go:234 applyLineEndingMatches,大文件退化到 Myers myersLineEndingMatches)。目的只有一个:diff 里只出现你真改的那几行

新文件的特例: 新建 .bat/.cmd 强制 CRLF(newFileLineEnding,line_endings.go:35)——因为 cmd.exe 解析 LF-only 的批处理会把行接续、标签、if 块切碎。而 .ps1 故意排除,PowerShell 正确解析 LF,强改反而是惊吓。这个判断按扩展名而非按当前系统,所以在 macOS 上给 Windows 写的 .bat 也能拿到对的行尾。

真实代码 internal/tools/line_endings.go:36-41(newFileLineEnding):

switch strings.ToLower(filepath.Ext(path)) {
case ".bat", ".cmd":
return lineEndingCRLF
default:
return lineEndingLF
}

3.7 Shell 沙箱:会卡、会问密码、会删库的命令怎么跑

Shell 是这一章风险最高的工具。三个子问题、三套机制。

(a) 会卡住 → 前台等一会儿,超时就转后台给 task_id

问题: npm installgo test 可能跑几分钟。工具调用不能一直阻塞模型。

机制: shellRun(shell.go:14)不管前台后台,都先在 goroutine 里起进程,前台只是加一个「让出计时器」(yield timer)。等到 yield_time_ms(默认 15 秒,constants.go:4)还没结束,就把进程留在后台、返回一个 task_id,让模型改用 shell_wait 继续等——而不是干等或重跑。

真实代码 internal/tools/shell.go:128-134(前台 select 的超时分支):

case <-timer.C:
if snap := task.snapshot(); snap.Status != "running" {
b.tasks.release(task.ID)
return shellRunForegroundFinalResult(...)
}
snap := task.snapshot()
return marshalToolResult(call, shellRunBackgroundedResult(..., shellYieldTimeoutDiagnosis(shellPol, snap)))

配套的 shell_wait / write_stdin / shell_cancel(shell.go:578/:596/:765)围绕 task_id 操作同一个后台进程。返回里还带一份诊断(shellDiagnosis),告诉模型「这命令为什么还在跑、下一步该干嘛」——比如识别出网络被墙(shellOutputLooksNetworkBlocked)、卡在交互提示(shellOutputLooksInteractivePrompt)、还是纯粹超时。策略判定见 shell_policy.goshellPolicyWithForegroundWait(:86),它把命令拆成片段、逐个识别 make test/go test/brew install/curl 等长跑模式,决定要不要自动转后台。

(b) 会要密码/交互 → PTY + write_stdin

问题: sshsudovim 这类命令需要一个真终端(TTY);用普通管道跑,它们要么报错要么永远挂着。

机制: shell_runmode 参数支持 auto/pipe/pty(catalog_shell.go:28)。auto 模式下,若策略识别出命令是「交互或认证类」(shellCommandPartPolicy 命中 sudo/ssh/vim… ,shell_policy.go:161),就自动选 PTY 传输(shellRunTransport,shell.go:203)。PTY 由 github.com/creack/pty 起(runShellPTY,shell_pty_unix.go:29),模型随后可用 write_stdin 把字符或 enter/ctrl-c 等控制键喂进去(shellStdinInput,shell.go:646)。

(c) 会删库 → 执行边界(exec boundary)

问题: 就算命令能跑,也得有个地方对每一次真实 exec 说「这条允许 / 要审批 / 拦截」。

机制:这是最精巧的一环——把策略检查塞进 shell 本身。 PTY 路径不直接跑用户命令,而是跑一个特制的 zsh,通过环境变量 ExecWrapperEnv 指向一个「wrapper shim」;这个 shell 在每次要 exec 一个外部程序前,都先调用 shim,由 shim 走 Unix domain socket 去问一个决策服务器(execboundary.StartServer,server_unix.go:30)这条命令允不允许。

真实代码 internal/tools/shell_pty_unix.go:73-85(runShellExecBoundaryPTY):

cmd := exec.Command(shellPath, "-lc", command)
cmd.Dir = dir
sessionID, approval := task.execBoundaryApproval()
server, err := execboundary.StartServer(ctx, task.execBoundaryPolicy(), approval, sessionID)
...
cmd.Env = append(os.Environ(),
execenv.ExecWrapperEnv+"="+wrapperShim,
execenv.SocketEnv+"="+server.SocketPath(),
)
return runShellPTY(ctx, cmd, task)

决策服务器收到 {program, argv, cwd},用 RulePolicy.DecideExecBoundary 判定(server_unix.go:103);若判定为「需要审批」,就回调 approval 函数弹给用户,用户批了才放行(server_unix.go:108-125)。判定与审批的规则内容属 04 章,本章只交代它的落点:不是在 Whale 进程里拦命令字符串(那能被 $()、管道绕过),而是在子 shell 每次 exec 的那一刻拦真实的 program+argv——绕不过去。

wrapper 的另一端 RunWrapper(execboundary/wrapper.go:15)在被 exec 的子进程里运行:问到 allowsyscall.Exec 换成真程序(wrapper_unix.go:13),问到拒绝就打印「Whale policy denied」并退出 1。

(d) 顺带:只读命令识别 + 私有路径

  • 只读快判: shell_run 有个 readOnlyCheck(catalog_shell.go:32),用 shellrisk.Classify 判断命令是不是纯只读(如 lscat),只读命令可以少走审批。解析用一个手写的、极保守的 POSIX 分词器 parsePOSIXReadOnlyShellCommand(catalog_shell.go:136),一遇到 $、反引号、;|&、重定向等元字符就直接判「不可静态判定」(posixReadOnlyRejectedRune,:194),宁可保守也不放过。
  • 私有路径: 决策 socket、审批相关的临时文件等敏感物件,用 securefs0700 目录 / 0600 文件权限落地(securefs/private.go:19:26),避免同机其他用户偷看。

4. 巧妙之处(可借鉴的技术)

① 校验/修复/归一都在框架层,工具作者只写业务。 一个新工具就是一个 toolFn 字面量 + 一个 fn;参数校验(validateToolInput)、形状修复(RepairToolInputForSpec)、结果套信封与超长截断(normalizeToolContent)全由 core 统一兜底。见 tool_registry.go:309 DispatchWithProgress

② 模糊编辑「三级降级 + 唯一性铁律」。 宽容匹配但绝不在多义时下手,替换永远针对文件真实字节。失败还给「分歧点」诊断让模型一轮改对。edit.go:147 resolveEditSearch

③ 乐观并发,不加悲观全局锁。 「带着我读到的字节去写,写前对比」是数据库级的乐观并发做法,配 64 条带锁串行化同文件、排序加锁避死锁。file_mutation.go:76 commitFilePlans

④ 行尾/编码「进出对称 + 按扩展名而非按平台」决策。 只让真改的行变行尾,diff 干净;跨平台给 Windows 写 .bat 也对。line_endings.go:35 newFileLineEnding:234 applyLineEndingMatches

⑤ 执行边界拦在「子 shell 的每次 exec」而非命令字符串。 从原理上绕不过 $()/管道,是整个安全模型里最硬的一块。shell_pty_unix.go:63 runShellExecBoundaryPTY + execboundary/

⑥ 错误即引导。 几乎每个失败都带 recovery/diagnosis:该重读文件、该拆小、该用哪个工具、该等还是该取消。把「报错」变成「下一步该干嘛」,省往返。tool_registry.go:390 ToolInputRecoveryHint


5. 边界与局限(诚实)

  • 模糊编辑只在唯一命中时降级。 一旦放宽后有多处匹配,一律退回 search_not_found,不猜。多义场景必须靠模型加上下文行或改用 multi_edit
  • 乐观并发只防「读后被改」,不防 TOCTOU 竞态到极限。verifyFilePlanCurrent 重读到 os.WriteFile 之间仍有极短窗口(条带锁只护同进程,不护外部进程)。设计取舍是「够用且不阻塞」,不是分布式强一致。
  • PTY 与执行边界是 Unix 专属。 shell_pty_unix.go//go:build unix;非 Unix 走 shell_pty_other.go,shellPTYSupported/shellExecBoundaryEnabled 返回 false,PTY 模式直接报「不支持」(shell.go:206-211)。执行边界还依赖一个随发行版打包的特制 zsh(shellExecBoundaryShellCandidates,shell_pty_unix.go:154),找不到就降级。
  • 只读命令识别偏保守。 分词器一见元字符就判「测不了」,于是很多其实无害的复合命令不会被当只读——宁可多问一次审批。
  • 权限「允许/审查/拦截」的规则本身不在本章。 本章只讲工具如何执行、边界拦在哪;判定逻辑见 安全与权限

6. 代码地图(导航索引)

主题文件关键符号
目录组装(9 组拼清单)internal/tools/catalog.goTools
Toolset 与路径守卫internal/tools/toolset.goNewToolset safeWorkspacePath safeReadPathWithToolResults expandHomePath
工具定义(鸭子类型)internal/tools/tool_fn.gotoolFn
文件/搜索工具定义internal/tools/catalog_files.go catalog_search.gofileDiscoveryTools fileMutationTools searchTools
Web / 运行时 / 计划工具internal/tools/catalog_web.go catalog_runtime.go catalog_plan.gowebTools todoRuntimeTools planRuntimeTools
MCP 延迟工具检索internal/tools/catalog_mcp.gomcpSearchTools NewToolSearchTool
工具契约 / 描述internal/core/tool.goTool DescribeTool ToolSpec
注册表 / 派发 / 校验 / 归一internal/core/tool_registry.goToolRegistry DispatchWithProgress validateToolInput frozenSpecTool normalizeToolContent
参数修复internal/core/tool_input_repair.goRepairToolInputForSpec applyToolInputIssueRepair
单点编辑 + 模糊匹配internal/tools/edit.goeditFile resolveEditSearch editWhitespaceRelaxed editSearchDivergence
原子多编辑internal/tools/multi_edit.gomultiEditFile applyMultiEditSteps describeSearchMatches
落盘 / 乐观并发 / 条带锁internal/tools/file_mutation.gocommitFilePlans verifyFilePlanCurrent fileMutationLocks
文件状态记账internal/tools/file_state.gofileStateCache storeFileState
行尾归一internal/tools/line_endings.gonormalizeTextFileBytes restoreTextFileBytes newFileLineEnding applyLineEndingMatches
文本编码internal/tools/text_encoding.gotextEncodingGB18030
写文件internal/tools/write.gowriteFile prepareWriteFileContent
Shell 会话模型internal/tools/shell.goshellRun shellWait writeStdin shellCancel
Shell 策略 / 诊断internal/tools/shell_policy.goshellPolicyWithForegroundWait shellDiagnosisForReason
Shell 工具定义 / 只读判定internal/tools/catalog_shell.goshellTools shellReadOnlyCheckWithRuntime parsePOSIXReadOnlyShellCommand
PTY + 执行边界接线internal/tools/shell_pty_unix.gorunShellExecBoundaryPTY shellExecBoundaryConfig createExecBoundaryWrapperShim
执行边界服务 / wrapperinternal/execboundary/StartServer RunWrapper DecisionRequest DecisionResponse
私有路径隔离internal/securefs/private.goMkdirPrivate WritePrivateFile

同组导航: index · 01 回合循环 · 02 提示词缓存 · 03 工具系统(本章) · 04 安全与权限 · 05 子代理与工作流 · 06 Provider 与扩展面