第 4 章 · 02 bash 工具与 mvdan.cc/sh shell 解析 本节摘要:本节精读 bash 工具。bash 工具不是直接把命令字符串丢给 或 ——那既不安全也不可控。Reasonix 用 v3 的 包做 POSIX shell 语法树分析:先把命令解析成 AST,再遍历 AST 做语义判断(有没有后台进程、有没有 disown/nohup、命令名是什么、有没有参数展开/命令替换/重定向),最后才决定怎么执行。本节讲清这条"解析→分析→执行"流水线、它带来的安全收益(命令注入防护、危险命令检测、动态 bash 严格审查)、工作目录与超时管理、输出截断(防爆炸输出破坏上下文),以及"为什么用 shell 解析而非直接 exec"这个根本设计问题。
本节摘要:本节精读 bash 工具。bash 工具不是直接把命令字符串丢给
system()或exec.Command("sh", "-c", cmd)——那既不安全也不可控。Reasonix 用mvdan.cc/shv3 的syntax包做 POSIX shell 语法树分析:先把命令解析成 AST,再遍历 AST 做语义判断(有没有后台进程、有没有 disown/nohup、命令名是什么、有没有参数展开/命令替换/重定向),最后才决定怎么执行。本节讲清这条"解析→分析→执行"流水线、它带来的安全收益(命令注入防护、危险命令检测、动态 bash 严格审查)、工作目录与超时管理、输出截断(防爆炸输出破坏上下文),以及"为什么用 shell 解析而非直接 exec"这个根本设计问题。
内容来源:原项目源码
internal/tool/builtin/bash.go、internal/shellparse/bash.go、internal/shellsafe/shellsafe.go、internal/tool/builtin/confine.go、docs/SPEC.md§3.7 Permissions 与 §5 Sandbox。
⚠️ 注意:bash 工具的
ReadOnly()永远返回 false——因为命令副作用无法从参数静态推断(一句rm -rf tmp与一句ls tmp参数结构类似)。这决定了 bash 永远不能进并行 batch,永远走权限审批路径。
阅读完本节,你应当能够:
syntax.Walk 遍历 AST 能识别哪些语义(后台/disown/参数展开/命令替换)。最朴素的 bash 工具实现是一行:exec.Command("bash", "-c", command).Output()。Reasonix 不这么做,原因有四:
ls; rm -rf / 与 ls 在字符串层面都是合法命令。要做安全审查,必须先解析成 AST,看到底有几条语句、每条语句的命令名是什么、有没有危险重定向。&)、是否用 disown/nohup/setsid 脱离父进程,这些决定了进程生命周期管理策略。Bash(npm run test:*),要匹配这种前缀规则,必须先把命令解析出"命令名 + 参数",再判断前缀。生成的 prefix 规则还会拒绝后续引入 shell 操作符的命令(Bash(go test:*) 不覆盖 go test ./... && rm -rf tmp)。syntax 分析能识别命令结构,在不同 shell 下给出一致的语义判断。所以 bash 工具的设计是:先用 mvdan.cc/sh v3 解析成 AST,遍历 AST 做语义分析,再决定执行策略。
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:重定向 > file、2>&1。这些节点类型在 SPEC §3.7 的"动态 bash"判定里被点名(docs/SPEC.md:367-379):参数/算术展开、赋值、heredoc、未证明的重定向、shell glob,不能复用裸 bash/prefix/glob allow;命令替换、进程替换、动态命令名、解析失败、eval、source、shell -c、PowerShell/cmd 命令字符串、运行时内联代码标志,在交互 Ask/Auto 下需要人工——guardian、allowing hooks、approved-plan 窗口都不能代答。
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.CallExpr 用 staticShellCallName 提取命令名,判断是不是 disown/nohup/setsid(这三个命令会让进程脱离父 shell)。return false)。这种"AST 遍历做语义判断"的模式在 bash.go、shellparse、shellsafe 里反复出现。staticShellCallName(internal/tool/builtin/bash.go:651)从 CallExpr 静态提取命令名——只认字面量命令,遇到变量/替换就返回 false(动态命令名,严格审查)。
SPEC §3.7 把 bash 命令分两类,审查严格度不同:
静态 bash(可走 prefix allow):
go test ./internal/tool、npm run build。Bash(go test:*) 这类 prefix 规则。动态 bash(严格审查,不能复用 allow):
$VAR、$((...)))。FOO=bar cmd)。cat <<EOF)。*.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 分析,字符串匹配做不到。
bash 工具的执行受 OS 沙箱包裹。SPEC §5(docs/SPEC.md:828-833):
bashis 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 underforbid_read, and allowed to reach the network only whennetwork = true.
sandbox-exec)。bwrap)。bash = "enforce" 解析为 off,bash 在 Windows 上不受 OS 沙箱约束(进程内文件工具仍约束 workspace_root/allow_write/forbid_read)。internal/tool/builtin/confine.go:28 的 ConfineBash 把沙箱 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),若全塞进上下文:
所以 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 解析而非直接 exec?三重收益:
rm -rf、git push)检测需要语义。代价是引入 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 稳定。
mvdan.cc/sh v3 解析成 AST,遍历分析,再执行;internal/shellparse/bash.go:11 的 ParseBash 是入口。Stmt/CallExpr/CmdSubst/ProcSubst/ParamExp/ArithmExp/Assign/Redirect;syntax.Walk 深度优先遍历。Bash=<literal> 精确规则。ConfineBash 绑 spec;沙箱是执行层,权限是策略层,正交。workDir/bash_timeout_seconds(默认 120s)/输出字节上限截断(防爆炸破坏上下文);bash 实现 SnipHinter,裁剪时头尾都保留。下一节,我们看 Reasonix 的另一个 heavyweight 内置工具——codeindex,它用 tree-sitter 对 Go/JavaScript/Python/Rust/TypeScript 五种语言做语法树解析,提供"语义级"代码符号索引,与文本 grep 形成互补。