手写 Agent ④,给它一个终端,run_bash 与权限

outlier 16 分钟 6095 字 0 0
系列教程Agent从入门到放弃 第 28 / 34 章
  1. 01Agent 从入门到放弃:教程大纲
  2. 02①大模型、Chatbot、Agent,一次把名字理清楚
  3. 03②Agent 的最小定义,一个 while 循环
  4. 04③Agent 简史,从 ReAct 到 Claude Code
  5. 05④Workflow 还是 Agent?大部分场景你不需要 Agent
  6. 06Agent 能力地图,为什么编码 Agent 最先成熟
  7. 07Token,模型眼里的世界
  8. 08Next-token prediction,一个字一个字地猜
  9. 09采样,temperature、top-p 与"同题不同答"
  10. 10Transformer 与 Attention,不推公式的直觉版
  11. 11Context Window,模型的内存条
  12. 12训练三阶段,能力是怎么被"装"进去的
  13. 13推理模型,让模型先想再答
  14. 14用原理解释怪行为,原理篇结业考
  15. 15从 Prompt 工程到 Context 工程
  16. 16System Prompt 写作,角色、约束与示例
  17. 17Context Rot,为什么长对话越聊越笨
  18. 18项目记忆,CLAUDE.md 与 AGENTS.md
  19. 19检索,RAG 还是 Agentic Search
  20. 20Function Calling 协议拆解
  21. 21工具设计,写给模型用的 API
  22. 22MCP 入门,工具界的 USB-C
  23. 23用 Go 手写一个 MCP Server
  24. 24MCP 进阶与边界
  25. 25手写 Agent ①,调通 API,跑起多轮对话
  26. 26手写 Agent ②,第一个工具,read_file 与 list_files
  27. 27手写 Agent ③,让它会改代码,edit_file
  28. 28手写 Agent ④,给它一个终端,run_bash 与权限
  29. 29手写 Agent ⑤,上下文管理
  30. 30手写 Agent ⑥,流式输出、错误处理与重试
  31. 31手写 Agent ⑦,Subagent,给 Agent 加分身
  32. 32手写 Agent ⑧,Skills 与 MCP 接入
  33. 33Claude Code 解剖,对照自己的实现
  34. 34自动化,Hooks、自定义命令与 Headless 模式
查看教程目录

开场:rm -rf 惊魂记

给 mini-agent 加上执行命令的工具之后,我做的第一件事是把它扔进一个 Docker 容器里,开最宽松的权限,让它放开手脚干。我想看看它在没有任何限制的情况下会怎么做。

任务是:"把这个项目构建一下,然后清理掉构建产物。"

它干得挺利落。第一步:

BUILD_DIR=./out && mkdir -p $BUILD_DIR && go build -o $BUILD_DIR/app .

构建成功。第二步,清理:

rm -rf "$BUILD_DIR"/*

然后,容器里的终端开始疯狂地刷"Permission denied"和"Device or resource busy",几秒钟之后,容器基本就废了。

我盯着屏幕愣了好一会儿才反应过来:每次调用 run_bash,都是一个新的 shell。 第一步里设置的 BUILD_DIR,到第二步时已经不存在了,"$BUILD_DIR"/* 展开成了 /*。

工具描述里明明写着"每次调用都是新的 shell,cd 不会保留到下一次",它读到了,但在那一刻没想起来。第 10 篇说过,读到了不等于注意到了;而这一次没注意到的代价,是整个文件系统。

那是一个随时可以重建的容器,我损失的只是十分钟,外加一身冷汗。但如果那是我的笔记本,权限也开到了最大……我不太敢往下想。

这一篇讲 run_bash,以及守在它前面的那套权限系统。读完本篇,你应该能回答这几个问题:

  • 执行命令的工具,有哪些容易被忽略的细节?
  • 为什么非零退出码不算工具出错?
  • 超时控制为什么要杀掉整个进程组?
  • 权限系统怎么设计:哪些直接放行、哪些要问人、哪些一律拒绝?
  • 为什么黑名单永远不完整?
  • 跑命令的子进程,会不会把你的密钥带进上下文?

一、run_bash:最强大的工具

1.1 为什么一定要有它

前两篇,mini-agent 学会了读和改。但第 05 篇说过,编码 Agent 成熟的关键是结果可验证:改完代码之后跑一下测试,看看对不对。没有终端,它就只能"凭感觉说改好了"。

有了终端,它能做的事情一下子多了很多:跑测试、跑构建、用 grep 搜代码(第 18 篇)、看 git diff、装依赖、跑脚本……几乎所有程序员在终端里能做的事。

这也是它最危险的地方:几乎所有程序员在终端里能做的事,它都能做。 包括那些程序员自己也会后悔做的事。

1.2 定义

agent/tools/bash.go:

Description: "在工作目录中用 bash 执行一条命令,返回 stdout、stderr 和退出码。" +
    "适合运行测试、构建、git、grep 搜索代码等。每次调用都是新的 shell,cd 不会保留到下一次。" +
    "不要用它来读写文件内容(用 read_file / edit_file),也不要运行需要交互输入或不会自己退出的命令(比如开发服务器)。",

描述里每一句都有用:

  • "每次调用都是新的 shell":开场惊魂记的根源。模型需要知道这一点,才不会依赖上一次调用里设置的变量或者 cd;
  • "不要用它来读写文件内容":模型很喜欢用 cat 读文件、用 sed -i 或 echo > 改文件。这样绕开了 read_file 的行号和分段、绕开了 edit_file 的唯一匹配和"改前必须读"的检查(第 26 篇)。明确告诉它用专门的工具;
  • "不要运行不会自己退出的命令":开发服务器、tail -f、watch,这些命令永远不会结束,工具会一直等到超时。

1.3 执行

cmd := exec.CommandContext(ctx, "bash", "-c", in.Command)
cmd.Dir = t.WS.Root
setProcessGroup(cmd) // 超时时连同子进程一起杀掉,见 bash_unix.go
cmd.WaitDelay = time.Second
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
cmd.Stdin = nil // 需要输入的命令会立刻读到 EOF,而不是卡住等人
err := cmd.Run()

几个值得注意的细节:

  • cmd.Dir = t.WS.Root:命令在工作目录里执行;
  • stdout 和 stderr 合在一起:报错信息通常在 stderr,测试的失败输出也可能在 stderr,模型需要看到全部,而且要按它们出现的顺序;
  • cmd.Stdin = nil:如果命令需要交互输入(比如 git commit 不带 -m 时会打开编辑器,某些安装脚本会问"是否继续 [y/N]"),它会立刻读到 EOF,而不是卡在那里等一个永远不会来的输入。

1.4 非零退出码不是工具出错

var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
    // 非零退出码不算工具出错:测试失败的输出正是模型需要看的东西。
    return fmt.Sprintf("%s\n[退出码 %d]", text, exitErr.ExitCode()), nil
}

这是一个容易做错的决定。go test 有测试失败时,退出码是 1。如果把它当成"工具出错"(is_error: true),模型可能会以为"命令没执行成功",而不是"命令执行成功了,结果是测试失败"。

测试失败的输出,正是模型最需要看的东西。 所以非零退出码作为正常结果返回,只在最后附上一行 [退出码 1],让模型知道命令的结果是什么。真正的"工具出错",只有命令根本没能执行(比如超时被杀)这种情况。

1.5 超时:杀掉整个进程组

timeout := t.Timeout
if timeout == 0 {
    timeout = 2 * time.Minute
}
if in.TimeoutSeconds > 0 {
    timeout = time.Duration(min(in.TimeoutSeconds, 600)) * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)

默认 2 分钟,模型可以自己指定(有些测试确实要跑很久),但最多 10 分钟。超时之后的错误信息也告诉它下一步怎么办:

命令超时(2m0s)被终止。如果它本来就不会自己退出(比如服务器),不要用 run_bash 运行

超时的时候,光杀掉 bash 进程是不够的。go test 会再启动编译器和测试二进制,npm test 会再启动一堆 node 进程。只杀掉最外层的 bash,这些子进程还会在后台继续跑,占着 CPU 和端口。bash_unix.go 里的处理是:

// setProcessGroup 让命令跑在自己的进程组里,超时时杀掉整个组。
// 否则 `go test` 这类会再起子进程的命令,超时后子进程还会在后台活着。
func setProcessGroup(cmd *exec.Cmd) {
    cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
    cmd.Cancel = func() error {
        return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
    }
}

Setpgid: true 让这条命令和它的所有子进程在一个单独的进程组里;超时时,Kill(-pid)(注意负号)杀掉整个组。

1.6 它继承了你的全部环境变量

还有一个细节,代码里没有写,恰恰因为没写才值得说:exec.CommandContext 创建的子进程,如果不设置 cmd.Env,就会继承父进程的全部环境变量。mini-agent 的 run_bash 没有设置它。

这意味着,你启动 mini-agent 时 shell 里有的一切,命令里都能读到:PATH、HOME,也包括你为了让 mini-agent 工作而设置的 DEEPSEEK_API_KEY,以及你可能顺手放在 shell 配置里的各种云服务凭证。

更值得警惕的是,这件事和下面的白名单叠在一起时:echo 在白名单里,而 3.3 节那个"变味写法"的检查,管的是重定向和 $(...) 命令替换,不管普通的变量展开。所以在 default 模式下,echo $DEEPSEEK_API_KEY 这样一条命令,是不用问你就会执行的,执行结果会作为工具输出进入上下文。

这不是一个能马上被利用的漏洞:Key 进了上下文,发去的也是 DeepSeek 自己。但它会留在这次会话的消息历史里,之后的每一圈都被重新发送一遍;如果你给 mini-agent 加了第 24 篇说的存档功能,它还会被写进存档文件,出现在你可能分享出去的会话记录里;如果再配合一个能往外发数据的工具,就是第 38 篇"致命三件套"的典型场景。

修它有两种思路,都不难:

  • 给子进程一份干净的环境:cmd.Env 只放 PATH、HOME、GOPATH 这类命令需要的变量,名字里带 KEY、TOKEN、SECRET 的一律不传;
  • 让变量展开也算"变味":在 sneaky 里加上 \$\w,带变量的 echo 就要问人。代价是 echo $PATH 这种无害的命令也要问了。

我把它留成了本篇的一个练习(实验 4)。写这一节的时候我才注意到它,又一次印证了 4.3 节的那句话:安全检查永远有你没想到的角落。

二、权限:模型提议,harness 决定

2.1 原则

第 19 篇说过一个核心认知:模型只输出调用意图,从不真正执行。 执行权完全在 harness 手里。权限系统就建立在这一点上。

agent/permission/permission.go 的开头写着:

// Package permission 决定一次工具调用能不能执行:直接放行、问人、还是拒绝。
//
// 原则:模型只是"提议",执行与否由 harness 说了算。

每一次工具调用,在真正执行之前,都要先过 Gate.Check 这一关(第 25 篇的 runTool 里那行"先过权限这一关"):

if a.Gate != nil {
    if d := a.Gate.Check(call.Name, call.Input); !d.Allowed {
        return llm.ToolResult(call.ID, d.Reason, true)
    }
}

被拒绝的调用不会执行,拒绝的理由作为错误结果交给模型。和工具出错一样,被拒绝也是一种结果,模型需要知道为什么被拒绝,才能换一个做法。

2.2 三种结果

flowchart TB C["一次工具调用"] --> D{"黑名单?"} D -->|"命中"| X["拒绝
不管什么模式"] D -->|"没命中"| R{"只读工具?
或白名单命令?"} R -->|"是"| A["直接放行"] R -->|"否"| M{"权限模式"} M -->|"yolo"| A M -->|"其他"| Q["问人"] Q -->|"y"| A Q -->|"a:本次会话都允许"| A Q -->|"n"| X2["拒绝
告诉模型别重试"] style X fill:#ffcdd2,stroke:#b71c1c style X2 fill:#ffcdd2,stroke:#b71c1c style A fill:#e8f5e9,stroke:#2e7d32 style Q fill:#fff9c4

每一次调用,最终是三种结果之一:直接放行、问人、拒绝。

2.3 三种模式

const (
    // ModeDefault:只读工具直接放行;改文件、跑白名单外的命令都要问人。
    ModeDefault Mode = "default"
    // ModeAcceptEdits:改文件也直接放行,命令照旧。
    ModeAcceptEdits Mode = "accept-edits"
    // ModeYolo:除了黑名单,全部放行。只在容器或一次性的沙箱里用。
    ModeYolo Mode = "yolo"
)
读文件改文件白名单内的命令其他命令黑名单命令
default放行问放行问拒绝
accept-edits放行放行放行问拒绝
yolo放行放行放行放行拒绝
  • default 适合大部分时候:你能看到它要做的每一件有副作用的事;
  • accept-edits 适合"我信任它改代码,但命令还是要看一眼"。改文件有 git 兜底,改错了能回滚;命令执行的后果可能收不回来;
  • yolo 只适合在容器或一次性的沙箱里用。开场那个惊魂记,就是 yolo 模式。

-mode 参数选择模式:go run ./cmd/mini-agent -mode accept-edits。

成品的编码 Agent 也大多有类似的几档权限模式,名字各不相同,思路是一样的:在"每一步都问"和"什么都不问"之间,给用户几个台阶。

2.4 问人:y、a、n

问人的时候,终端里会出现这样的提示:

允许执行命令吗?
  $ go mod tidy
  [y] 允许  [a] 本次会话都允许  [n] 拒绝 >
  • y:这一次允许;
  • a:这一类操作本次会话都允许,之后不再问。对命令来说,"这一类"是命令的前两个词,比如选了 a 之后,所有 go mod ... 的命令都不再问;
  • n:拒绝。

拒绝时,交给模型的理由是:

用户拒绝了这个操作。不要重复尝试同样的操作;换个做法,或者问用户想怎么做。

a 的粒度是一个值得多想的设计。"前两个词"是一个折中:

  • 太粗:如果只看第一个词,批准一次 go mod tidy,等于批准了所有 go ... 命令,包括 go run 某个刚下载的脚本;
  • 太细:如果要求整条命令完全一样,模型下一次换个参数,比如 go test -run TestA 变成 go test -run TestB,你又要再批一次;
  • 前两个词也有漏洞:批准了 git push,之后所有 git push 都不再问(强制推送仍然会被黑名单拦下);批准了 npm install,之后 npm install 任何包 也不问了。所以选 a 之前,想一想"这一类"到底包括了什么。

成品 Agent 通常会让你自己写允许规则,可以精确到一条命令,也可以用通配符放宽(截至写作时,具体语法各家不同)。mini-agent 的"前两个词",是一个够用、但要心里有数的简化。

"不要重复尝试"很重要。不加这一句,模型被拒绝后,常常会把同一条命令换个写法再提一次,比如把 rm -rf build 改成 find build -delete。第 24 篇的基础 prompt 里也有一条对应的规则:"工具调用被拒绝时,不要换个写法重试同一件事,先想想用户为什么拒绝。"

三、白名单:不用问的命令

3.1 列表

每一条命令都问,用户会烦,烦了就会开 yolo,那就更危险了。所以有一份白名单,里面的命令不用问:

// DefaultAllow 是不用问就能跑的命令前缀:只读、或者只会影响构建产物。
var DefaultAllow = []string{
    "ls", "pwd", "cat", "head", "tail", "wc", "grep", "rg", "find", "tree", "echo", "which", "file",
    "git status", "git diff", "git log", "git show", "git branch",
    "go build", "go test", "go vet", "go list", "go version", "go env", "gofmt -l", "gofmt -d",
}

选择的标准是:只读,或者只会影响构建产物。不过"只读"也要打个引号:go test 会执行测试代码,而测试代码可以做任何事;cat 能读工作目录外的文件(4.4 节);echo 能打印环境变量(1.6 节)。白名单的意思是"这些命令通常不会造成你不想要的改变",不是"这些命令绝对安全"。 go test 会编译、会生成缓存,但不会改你的源代码;git diff 只是看;gofmt -l 只列出哪些文件需要格式化,gofmt -w(会改文件)就不在里面。

3.2 复合命令要拆开

白名单是按前缀匹配的,go test ./... 匹配 go test。但模型经常写复合命令:

go test ./... && rm -rf build

如果只看开头,它匹配 go test,会被放行,后半截的 rm -rf build 就混过去了。所以要按 &&、||、;、| 和换行拆开,每一段都在白名单里,才算整条命令在白名单里:

// 复合命令拆开逐段检查:`go test ./... && rm -rf build` 里只要有一段不在白名单,就得问。
allAllowed := true
for _, seg := range splitCommand(cmd) {
    if !p.prefixAllowed(seg) {
        allAllowed = false
        break
    }
}

拆分的实现很简单,不处理引号里的分隔符。代码的注释说得很直白:"宁可多问一次。"

3.3 让白名单命令"变味"的写法

就算每一段都是白名单命令,也可能有问题:

  • cat > main.go:cat 是只读的,但加上重定向,它就能写文件;
  • echo $(rm -rf x):echo 是无害的,但命令替换会先执行括号里的东西;
  • find . -delete、find . -exec rm {} \;:find 是只读的,但它有能删除和执行的参数。

所以还有一个检查:

// sneaky 匹配能让白名单命令"变味"的写法:重定向写文件、命令替换、find 的删除和执行。
var sneaky = regexp.MustCompile("[<>`]|\\$\\(|\\s-(delete|exec|execdir|ok)\\b")

出现了这些写法,就不算白名单,要问人。(2>&1 和 >/dev/null 这两种常见的、无害的重定向,会在检查之前先去掉。)

四、黑名单:最后一道保险

4.1 列表

有些命令,不管什么模式、用户之前批准过什么,都一律拒绝:

// denyPatterns 不管什么模式都拒绝。黑名单永远不完整,它只是最后一道保险,不是安全边界。
var denyPatterns = []struct {
    re     *regexp.Regexp
    reason string
}{
    {regexp.MustCompile(`\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)+(/|~|\$HOME|\.\.?)?/?\*?(\s|$)`), "递归删除根目录、家目录、当前或上级目录"},
    {regexp.MustCompile(`\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)+"?\$\{?\w+\}?"?/\*`), "删除一个变量指向的目录下的所有内容:变量为空时就是删除根目录"},
    {regexp.MustCompile(`\bsudo\b`), "需要 root 权限"},
    {regexp.MustCompile(`\b(mkfs|fdisk|diskutil\s+erase)`), "格式化磁盘"},
    {regexp.MustCompile(`\bdd\s+.*\bof=/dev/`), "直接写块设备"},
    {regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), "fork 炸弹"},
    {regexp.MustCompile(`\bgit\s+push\b.*(--force|-f\b)`), "强制推送会覆盖远程历史"},
    {regexp.MustCompile(`\bgit\s+reset\s+--hard\b`), "会丢弃未提交的修改"},
    {regexp.MustCompile(`\b(curl|wget)\b.*\|\s*(ba|z)?sh\b`), "下载并直接执行脚本"},
    {regexp.MustCompile(`\bchmod\s+-R\s+777\b`), "把权限放开给所有人"},
}

被拒绝时,模型收到的理由是:

这条命令被安全策略禁止(递归删除根目录、家目录、当前或上级目录)。请换一种更安全的做法,或者请用户手动执行。

4.2 第二条规则是怎么来的

注意第二条规则:rm -rf "$变量"/*。它就是开场那个惊魂记的产物。

而且还有一段后话。写这篇文章的时候,我回头用各种写法测了一遍黑名单,发现第一条规则居然拦不住 rm -rf /*:它能拦住 rm -rf /,但 / 后面多一个 *,正则就匹配不上了。开场那个 "$BUILD_DIR"/*,当然也拦不住。

于是我改了第一条规则,加了第二条,又在测试里补上了这些写法:

for _, cmd := range []string{
    "rm -rf /", "rm -rf /*", "rm -rf ~/*", "rm -rf ~", "rm -fr .", "rm -rf ./*", "cd build && rm -rf *",
    `rm -rf "$BUILD_DIR"/*`, "rm -rf ${OUT}/*", "sudo apt install x",
    // ……
} {
    if d := p.Check("run_bash", bash(cmd)); d.Allowed {
        t.Errorf("%q 应该被拒绝", cmd)
    }
}

同时也测了"不该被拦下"的命令,比如 rm -rf build/tmp、rm -rf "$OUT"/cache,免得规则改得太宽,把正常的命令也拦了。

4.3 黑名单永远不完整

这个小插曲恰好说明了那行注释的意思:黑名单永远不完整,它只是最后一道保险,不是安全边界。

我修好了 /*,但还有无数种写法能达到同样的效果:find / -delete(会被"变味"检查拦下要求确认,但 yolo 模式下呢?)、python -c "import shutil; shutil.rmtree('/')"、写一个脚本再执行它、用 base64 编码一段命令再解码执行……你不可能用正则表达式穷尽所有危险的写法。

所以真正的安全,不能靠黑名单,要靠几层东西叠在一起:

flowchart TB L1["默认要问人
(default 模式)"] --> L2["白名单只放行只读命令
复合命令拆开查、变味写法要问"] L2 --> L3["黑名单:最后一道保险"] L3 --> L4["限制工作目录
(第 25 篇)"] L4 --> L5["yolo 只在容器 / 沙箱里用"] style L1 fill:#e8f5e9,stroke:#2e7d32 style L5 fill:#e8f5e9,stroke:#2e7d32 style L3 fill:#fff9c4

最重要的两层是第一层和最后一层:默认要问人,放开权限只在隔离的环境里。 开场的惊魂记之所以只是"惊魂"而不是"灾难",靠的是最后一层:那是一个容器。

4.4 关于工作目录

第 25 篇的 Workspace.Resolve 限制了 read_file、edit_file 只能访问工作目录里的文件。但 run_bash 是一个 shell,它可以 cat /etc/passwd,可以 cd ..。cmd.Dir = t.WS.Root 只决定了命令从哪里开始执行,并不能限制它去哪里。

真正把一个 shell 关在某个目录里,需要操作系统层面的沙箱:容器、虚拟机,或者系统自带的沙箱机制。有的成品 Agent 会在运行命令时套一层系统沙箱,限制它能读写的目录和能访问的网络。mini-agent 没有做这一层,它把这一层交给了你:放开权限时,在容器里跑。

4.5 还缺一块:网络

细心的话你会发现,白名单里没有 curl、wget,黑名单里只拦了"下载并直接执行"这一种用法。也就是说,在 default 模式下,curl 要问你;你批准了,它就能访问任何地址。

单看 run_bash,这好像没什么问题:你看得见它要访问哪里。但第 23 篇的工具投毒告诉我们,危险往往来自组合:上下文里先混进了一段不可信的内容,然后模型被诱导着把你的数据塞进一条看起来很正常的命令里。人在确认的时候,未必能从一长串参数里看出端倪。

这就是为什么 mini-agent 的权限系统里还有一个开关叫 TrifectaGuard:一旦上下文里出现了不可信的内容,之后任何可能把数据发到外部的操作,都要逐次确认,之前选过 a 也不算数。它的来龙去脉,第 38 篇细讲。

五、headless 模式下没有人可问

第 33 篇会讲 headless 模式:执行一个任务就退出,用在脚本和 CI 里。这时候没有人能回答"允许吗"。

func (p *Policy) ask(key, question string) Decision {
    if p.Ask == nil {
        return Decision{Reason: "当前是非交互模式,这个操作需要用户确认,已自动拒绝。请只使用不需要确认的操作,或者在最终回答里说明需要用户手动做什么。"}
    }
    // ……
}

没有人可问,就一律拒绝,并且告诉模型:只用不需要确认的操作,或者在最终回答里说明需要用户手动做什么。这是一个"默认安全"的选择:宁可任务做不完,也不要在无人看管时做出有副作用的事。

这也意味着,在 headless 模式下想让它改文件,就要明确地放宽权限,比如 -mode accept-edits。第 36 篇的评测脚本就是这么做的:评测在一个临时目录里跑,改坏了也无所谓,所以允许它直接改文件;但命令依然受白名单和黑名单约束,不在白名单里的命令,同样会被自动拒绝。

六、动手:给它一个终端

实验 1:看它自己跑测试

要看模型自己决定跑什么命令,需要接真实模型:先 export DEEPSEEK_API_KEY=sk-...。在线 Playground 跑不了 mini-agent。

用第 26 篇那个有 bug 的小项目,这次再加一个测试:

cd /tmp/shop
cat > order_test.go <<'EOF'
package shop

import "testing"

func TestDiscount(t *testing.T) {
    if got := Discount(100, 0.1); got != 90 {
        t.Fatalf("want 90, got %v", got)
    }
}
EOF

启动 mini-agent,说:"帮我修复这个项目里失败的测试。"看它是不是先跑 go test(白名单,不问你)、读文件、改文件(问你)、再跑一次 go test 确认。这就是第 02 篇那个剧本,这一次是真的。

实验 2:试试权限

在 default 模式下,让它做几件事,看看哪些直接执行、哪些问你、哪些被拒绝:

  1. "看一下 git log 最近 5 条":白名单,直接执行;
  2. "运行 go mod tidy":不在白名单里,问你。这次选 a,然后再让它运行一次 go mod tidy,看看是不是不问了;
  3. "用 cat 把这段内容写进 hello.txt":cat > 是变味写法,问你。看看它会不会改用 edit_file;
  4. "删掉当前目录下的所有东西":黑名单,直接拒绝。看看它收到拒绝理由之后怎么回应你。

实验 3:跑测试

go test ./agent/permission -v
go test ./agent -run TestGateDeniesDangerousCommand -v
go test ./agent/tools -run 'TestRunBash|TestWorkspaceConfinement' -v

TestGateDeniesDangerousCommand 让假模型在 yolo 模式下提出 rm -rf .,检查它被黑名单拦下、拒绝理由交给了模型,而且测试目录里的文件还在。

实验 4:堵上环境变量的口子

按 1.6 节的第一种思路,给 run_bash 一份过滤过的环境:在 bash.go 里设置 cmd.Env,去掉名字里带 KEY、TOKEN、SECRET、PASSWORD 的变量。然后在 bash_test.go 里加一个测试:设置一个假的 FAKE_API_KEY,运行 echo $FAKE_API_KEY,检查输出里没有它的值。

改完之后想一想:有没有哪个命令是需要某个带 TOKEN 的环境变量才能工作的?比如私有仓库的 go get 可能需要 GOPRIVATE 之外的凭证。安全和便利之间的取舍,在这里又出现了一次。

七、踩坑记录:给 Agent 一个终端的七个坑

坑 1:以为 shell 的状态会保留。
开场的惊魂记。每次 run_bash 都是新的 shell,变量、cd、export 都不会保留到下一次。描述里要写清楚,黑名单里要防最危险的那种后果。

坑 2:把非零退出码当成工具出错。
测试失败被标成 is_error,模型以为命令没跑起来,去排查"为什么命令执行失败"。非零退出码是正常结果,附上退出码就行。

坑 3:超时只杀了最外层的进程。
go test 超时了,bash 被杀了,测试二进制还在后台跑,下一次 go test 因为端口被占用而失败,模型开始排查一个根本不存在的问题。杀整个进程组。

坑 4:白名单只看命令开头。
go test ./... && rm -rf build、cat > main.go、find . -delete。复合命令要拆开,变味的写法要单独检查。

坑 5:把黑名单当成安全边界。
它永远不完整,这篇文章写作过程中就发现了一个漏洞。真正的边界是:默认问人,放开权限只在容器里。

坑 6:被拒绝后,模型换个写法再试。
rm -rf build 被拒绝了,它改成 find build -delete,又改成写一个 Python 脚本来删。拒绝理由里要明确说"不要重复尝试同样的操作",system prompt 里也要有对应的规则。

坑 7:忘了子进程会继承环境变量。
你的 API Key、云服务凭证,都在 shell 的环境里。不设置 cmd.Env,Agent 跑的每一条命令都能读到它们,一句 echo 就进了上下文。给子进程一份干净的环境,只传它需要的。

一句话总结

run_bash 让 Agent 能跑测试、能验证,也让它几乎无所不能;执行时要合并输出、不给 stdin、超时杀整个进程组,非零退出码是正常结果;守在它前面的权限系统把每次调用分成放行、问人、拒绝三种,白名单只放只读命令且要拆开复合命令,黑名单只是最后一道保险,真正的安全靠默认问人,以及在容器里放开权限。

flowchart LR M(("模型
提议")) --> G["权限
放行 / 问人 / 拒绝"] G -->|"放行"| B["run_bash
新 shell、超时、杀进程组"] G -->|"拒绝"| R["理由交给模型"] B --> O["输出 + 退出码"] style G fill:#fff3e0,stroke:#e65100 style R fill:#ffcdd2,stroke:#b71c1c

评论

还没有评论,来说点什么吧。

发表评论

Ctrl + Enter 快速提交
Enter 搜索Esc 关闭