第 4 章 · 02 bash 工具与 mvdan.cc/sh shell 解析


文档摘要

第 4 章 · 02 bash 工具与 mvdan.cc/sh shell 解析 本节摘要:本节精读 bash 工具。bash 工具不是直接把命令字符串丢给 或 ——那既不安全也不可控。Reasonix 用 v3 的 包做 POSIX shell 语法树分析:先把命令解析成 AST,再遍历 AST 做语义判断(有没有后台进程、有没有 disown/nohup、命令名是什么、有没有参数展开/命令替换/重定向),最后才决定怎么执行。本节讲清这条"解析→分析→执行"流水线、它带来的安全收益(命令注入防护、危险命令检测、动态 bash 严格审查)、工作目录与超时管理、输出截断(防爆炸输出破坏上下文),以及"为什么用 shell 解析而非直接 exec"这个根本设计问题。

第 4 章 · 02 bash 工具与 mvdan.cc/sh shell 解析

本节摘要:本节精读 bash 工具。bash 工具不是直接把命令字符串丢给 system()exec.Command("sh", "-c", cmd)——那既不安全也不可控。Reasonix 用 mvdan.cc/sh v3 的 syntax 包做 POSIX shell 语法树分析:先把命令解析成 AST,再遍历 AST 做语义判断(有没有后台进程、有没有 disown/nohup、命令名是什么、有没有参数展开/命令替换/重定向),最后才决定怎么执行。本节讲清这条"解析→分析→执行"流水线、它带来的安全收益(命令注入防护、危险命令检测、动态 bash 严格审查)、工作目录与超时管理、输出截断(防爆炸输出破坏上下文),以及"为什么用 shell 解析而非直接 exec"这个根本设计问题。

内容来源:原项目源码 internal/tool/builtin/bash.gointernal/shellparse/bash.gointernal/shellsafe/shellsafe.gointernal/tool/builtin/confine.godocs/SPEC.md §3.7 Permissions 与 §5 Sandbox。

⚠️ 注意:bash 工具的 ReadOnly() 永远返回 false——因为命令副作用无法从参数静态推断(一句 rm -rf tmp 与一句 ls tmp 参数结构类似)。这决定了 bash 永远不能进并行 batch,永远走权限审批路径。

学习目标

阅读完本节,你应当能够:

  1. 说清 bash 工具"解析→分析→执行"的三段流水线。
  2. 解释 syntax.Walk 遍历 AST 能识别哪些语义(后台/disown/参数展开/命令替换)。
  3. 区分"静态 bash"(可走 prefix allow)与"动态 bash"(参数展开/heredoc/命令替换,严格审查)。
  4. 描述 OS 沙箱(Seatbelt/bubblewrap)如何包裹 bash。
  5. 解释输出截断为何对 prefix cache 与上下文维护都关键。
  6. 回答"为什么用 shell 解析而非直接 exec"。

一、为什么不直接 exec

最朴素的 bash 工具实现是一行:exec.Command("bash", "-c", command).Output()。Reasonix 不这么做,原因有四:

  1. 安全审查需要语义:直接 exec 把命令字符串整体丢给 shell,你不知道里面藏了什么——ls; rm -rf /ls 在字符串层面都是合法命令。要做安全审查,必须先解析成 AST,看到底有几条语句、每条语句的命令名是什么、有没有危险重定向。
  2. 可控执行需要语义:OS 沙箱、工作目录、超时、后台任务管理,都需要知道命令的语义结构——比如命令是否后台运行(&)、是否用 disown/nohup/setsid 脱离父进程,这些决定了进程生命周期管理策略。
  3. 权限规则需要语义:SPEC §3.7 的权限规则是 Claude Code 风格的 Bash(npm run test:*),要匹配这种前缀规则,必须先把命令解析出"命令名 + 参数",再判断前缀。生成的 prefix 规则还会拒绝后续引入 shell 操作符的命令(Bash(go test:*) 不覆盖 go test ./... && rm -rf tmp)。
  4. 跨平台一致性:Windows 上没有 bash,bash 工具会退化成 PowerShell;syntax 分析能识别命令结构,在不同 shell 下给出一致的语义判断。

所以 bash 工具的设计是:先用 mvdan.cc/sh v3 解析成 AST,遍历 AST 做语义分析,再决定执行策略

二、mvdan.cc/sh v3 syntax 解析

internal/shellparse/bash.go:10-12 是解析入口:

// ParseBash parses command using Bash syntax. func ParseBash(command string) (*syntax.File, error) { return syntax.NewParser(syntax.Variant(syntax.LangBash)).Parse(strings.NewReader(command), "") }

mvdan.cc/sh/v3/syntax 是一个纯 Go 的 POSIX shell/bash 语法解析器(满足 SPEC §1.3 纯 Go 依赖要求)。syntax.NewParser(syntax.Variant(syntax.LangBash)) 创建一个 Bash 方言解析器,Parse 把命令字符串解析成 *syntax.File——AST 根节点。

AST 节点类型都是 Go struct,通过类型断言识别:

  • *syntax.Stmt:一条语句(可能带 & 后台标记、重定向)。
  • *syntax.CallExpr:命令调用(ls -l /tmp)。
  • *syntax.CmdSubst:命令替换 $(...)
  • *syntax.ProcSubst:进程替换 <(...)
  • *syntax.ParamExp:参数展开 $VAR
  • *syntax.ArithmExp:算术展开 $((...))
  • *syntax.Assign:赋值 FOO=bar
  • *syntax.Redirect:重定向 > file2>&1

这些节点类型在 SPEC §3.7 的"动态 bash"判定里被点名(docs/SPEC.md:367-379):参数/算术展开、赋值、heredoc、未证明的重定向、shell glob,不能复用裸 bash/prefix/glob allow;命令替换、进程替换、动态命令名、解析失败、evalsourceshell -c、PowerShell/cmd 命令字符串、运行时内联代码标志,在交互 Ask/Auto 下需要人工——guardian、allowing hooks、approved-plan 窗口都不能代答。

三、syntax.Walk:AST 遍历做语义判断

bash.go 里有一段典型的 AST 遍历(internal/tool/builtin/bash.go:570-585),判断命令是否"后台 + 脱离父进程":

syntax.Walk(file, func(node syntax.Node) bool { switch n := node.(type) { case *syntax.Stmt: if n.Background { hasBackground = true } case *syntax.CallExpr: name, ok := staticShellCallName(n) if !ok { break } switch name { case "disown", "nohup", "setsid": hasKeepaliveCommand = true } } return !(hasBackground && hasKeepaliveCommand) })

这段代码做的事:

  • syntax.Walk 深度优先遍历整个 AST,对每个节点调用回调。
  • 遇到 *syntax.Stmt 检查 Background 字段(命令末尾的 &)。
  • 遇到 *syntax.CallExprstaticShellCallName 提取命令名,判断是不是 disown/nohup/setsid(这三个命令会让进程脱离父 shell)。
  • 一旦两个条件都满足,提前终止遍历(return false)。

这种"AST 遍历做语义判断"的模式在 bash.go、shellparse、shellsafe 里反复出现。staticShellCallName(internal/tool/builtin/bash.go:651)从 CallExpr 静态提取命令名——只认字面量命令,遇到变量/替换就返回 false(动态命令名,严格审查)。

四、静态 bash vs 动态 bash

SPEC §3.7 把 bash 命令分两类,审查严格度不同:

静态 bash(可走 prefix allow):

  • 字面量命令 + 字面量参数,如 go test ./internal/toolnpm run build
  • 可提取"命令名 + 参数前缀",匹配 Bash(go test:*) 这类 prefix 规则。
  • Auto 模式下,已批准的 prefix 规则可复用,不重复提示。

动态 bash(严格审查,不能复用 allow):

  • 参数/算术展开($VAR$((...)))。
  • 赋值(FOO=bar cmd)。
  • heredoc(cat <<EOF)。
  • 未证明的重定向、shell glob(*.go)。
  • 命令替换($(...))、进程替换(<(...))。
  • 动态命令名、解析失败、eval/source/shell -c

internal/shellparse/bash.go:220-232 展示了动态特征的检测:

syntax.Walk(file, func(node syntax.Node) bool { switch node.(type) { case *syntax.CmdSubst, *syntax.ProcSubst: // 命令替换/进程替换 case *syntax.ParamExp, *syntax.ArithmExp, *syntax.ExtGlob: // 参数/算术展开/扩展 glob case *syntax.Assign: // 赋值 case *syntax.Redirect: // 重定向 } return true })

记住的批准是精确的 Bash=<literal> 规则(SPEC §3.7,docs/SPEC.md:368-371):动态 bash 不能复用裸 bash/prefix/glob allow,记忆下来的批准是精确字面量规则。它们仍跟随正常姿态回退,所以 Auto 与 approved-plan 窗口可在不提示时执行它们。嵌套或间接执行更严:命令/进程替换、动态命令名、解析失败、eval/source/shell -c/PowerShell/cmd 命令字符串、运行时内联代码标志,在交互 Ask/Auto 下需要人工——guardian、allowing hooks、approved-plan 窗口都不能代答;只有相同的精确字面量授权或 YOLO 默认能绕过。

💡 契约要点:静态/动态的区分不是性能优化,而是安全策略。静态命令语义明确,可放宽审批;动态命令语义不确定(可能藏任意副作用),必须收紧。这套判定全靠 AST 分析,字符串匹配做不到。

五、OS 沙箱:Seatbelt 与 bubblewrap

bash 工具的执行受 OS 沙箱包裹。SPEC §5(docs/SPEC.md:828-833):

bash is itself jailed by default when an OS sandbox is available ([sandbox] bash = "enforce": Seatbelt on macOS and bubblewrap on Linux): each command is allowed to write only the same roots plus platform-specific command temp/cache roots, denied reads under forbid_read, and allowed to reach the network only when network = true.

  • macOS:Seatbelt(sandbox-exec)。
  • Linux:bubblewrap(bwrap)。
  • Windows:无 OS 级 bash 沙箱,bash = "enforce" 解析为 off,bash 在 Windows 上不受 OS 沙箱约束(进程内文件工具仍约束 workspace_root/allow_write/forbid_read)。

internal/tool/builtin/confine.go:28ConfineBash 把沙箱 spec 绑到 bash 工具上:

func ConfineBash(spec sandbox.Spec, guard SessionDataGuard, timeout ...time.Duration) tool.Tool { // ...返回一个绑了沙箱的 bash 实例,覆盖 init 注册的零值实例... }

沙箱是执行层约束,权限 Policy 是策略层约束(SPEC §3.7)。两者正交:权限决定"要不要问用户",沙箱决定"问了之后命令能在多大范围内搞破坏"。SPEC §5(docs/SPEC.md:818):[sandbox] is the enforcement layer beneath permissions (which are policy).

六、工作目录、超时与输出截断

工作目录:bash 结构体有 workDir 字段(internal/tool/builtin/bash.go:85),非空时作为 cmd.Dir。默认空,用进程 cwd。子代理隔离时可通过 Manager 覆盖。

超时:timeout 字段(bash.go:84),foregroundTimeout() 返回前台命令的超时(bash_timeout_seconds,默认 120s,0 表示无工具级上限,但父 context 取消仍杀进程树)。SPEC §5(docs/SPEC.md:719):bash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap

输出截断:这是 prefix cache 与上下文维护的关键。bash 输出可能爆炸(一句 cat huge.log 可能输出几 MB),若全塞进上下文:

  1. 撑爆上下文窗口,触发过早压缩。
  2. 工具输出进入历史,后续每轮都计入 prompt,token 成本飙升。
  3. 破坏 prefix cache(虽然输出在 turn tail,但过长会推高整体 prompt)。

所以 bash 输出有字节上限,超出部分截断,带 head/tail 标记保留头尾(internal/tool/builtin/clientio.go 负责客户端 IO 与截断)。这与第 6 章"snip/prune 旧工具输出"的策略呼应——工具自己先截断,maintainer 再 snip/prune。

bash 还实现了 SnipHinter(internal/tool/tool.go:226-233),告诉上下文维护"我的输出 head/tail 都有意义",所以裁剪时头尾都保留(不像 read_file 头部更有意义)。SPEC §3.6 与 TOOL_CONTRACT 反复强调工具输出的形状由工具自己声明,不能套通用默认。

七、为什么用 shell 解析:三重收益

回到本节标题的问题——为什么用 shell 解析而非直接 exec?三重收益:

  1. 语义分析:能识别后台/disown/替换/展开/重定向,做精细化安全审查与生命周期管理。字符串匹配做不到。
  2. 安全审查:静态/动态判定决定审批严格度;prefix 规则匹配需要命令名+参数;危险命令(rm -rfgit push)检测需要语义。
  3. 可控执行:OS 沙箱、工作目录、超时、后台任务、进程树管理,都需要语义结构支撑。

代价是引入 mvdan.cc/sh 依赖——但它是纯 Go(SPEC §1.3 合规)、轻量、活跃维护。这是 Reasonix 工程文化的典型权衡:为了安全与可控,接受一个高质量纯 Go 依赖。

⚠️ 注意:Windows 上没有 bash,bash 工具退化成 PowerShell。Description() 会动态返回不同文本(internal/tool/builtin/bash.go:103-120),告诉模型"现在跑的是 PowerShell,要写 PowerShell 语法"。这是少数会变的工具描述——但因为它在系统提示里,这个变化也是 prefix cache reset 点(切换 shell 时)。正常运行中 shell 不变,所以 cache 稳定。

本节要点回顾

  1. 不直接 exec:bash 工具用 mvdan.cc/sh v3 解析成 AST,遍历分析,再执行;internal/shellparse/bash.go:11ParseBash 是入口。
  2. AST 节点:Stmt/CallExpr/CmdSubst/ProcSubst/ParamExp/ArithmExp/Assign/Redirect;syntax.Walk 深度优先遍历。
  3. 静态 vs 动态 bash:静态(字面量命令+参数)可走 prefix allow;动态(展开/赋值/heredoc/替换/glob/eval)严格审查;记忆批准是 Bash=<literal> 精确规则。
  4. OS 沙箱:Seatbelt(macOS)/bubblewrap(Linux)/off(Windows);ConfineBash 绑 spec;沙箱是执行层,权限是策略层,正交。
  5. 工作目录/超时/截断:workDir/bash_timeout_seconds(默认 120s)/输出字节上限截断(防爆炸破坏上下文);bash 实现 SnipHinter,裁剪时头尾都保留。
  6. 三重收益:语义分析、安全审查、可控执行;代价是接受一个纯 Go 高质量依赖。

下一节,我们看 Reasonix 的另一个 heavyweight 内置工具——codeindex,它用 tree-sitter 对 Go/JavaScript/Python/Rust/TypeScript 五种语言做语法树解析,提供"语义级"代码符号索引,与文本 grep 形成互补。


作者与出处
原作者: 灏天文库
整理: 灏天文库整理
本站整理收录,版权归原作者/开源协议所有;欢迎通过原文链接访问源仓库。
发布者: 作者: 灏天文库 转发
评论区 (0)
U