ByteNoteByteNote
迷你Claude Code(三):三大工具实现与安全设计
字

字节笔记本

2026年10月6日 · 约 12 分钟读完

迷你Claude Code(三):三大工具实现与安全设计

API中转
¥120

这是「从 0 实现迷你 Claude Code」系列的第三篇。上一篇完成了 Agent 循环,这一篇实现循环里调用的三个真实工具:read_file、edit_file 和 run_command,让 Agent 真的能读文件、改文件、跑命令。对应代码是项目里的 tool.go 和 tools.go。

mini-claude-code 工具调用循环

这一篇你得到什么

读完你能:

  • 设计 Tool 接口
  • 实现 read_file、edit_file、run_command
  • 做路径遍历防护(防 ../../etc/passwd)
  • 理解 str_replace 编辑为什么优于全量重写
  • 知道命令失败为什么要「回喂」而非「中断」

第一步:Tool 接口

所有工具实现同一个接口(tool.go):

go
type Tool interface {
	Name() string                          // 工具名
	Description() string                   // 描述(模型看)
	Schema() json.RawMessage               // 参数 JSON Schema
	Execute(argsJSON string) (string, error)  // 执行
}

四个方法,对应「工具的全部信息 + 执行能力」。前三个发给模型,让它知道有什么工具、怎么用;第四个是真正干活。

argsJSON 是原始 JSON 字符串:模型传来的参数就是字符串,由工具自己 json.Unmarshal 解析。这样设计是因为不同工具参数结构不同,接口不能预定义。

工具注册表

go
type ToolRegistry struct {
	tools map[string]Tool
}

func (r *ToolRegistry) Definitions() []ToolDefinition {
	// 生成所有工具的定义,发给模型
}

Agent 按名字查找工具,把定义发给模型。注册一次,循环里反复用。

第二步:read_file,读文件

go
type ReadFileTool struct {
	WorkDir string  // 工作目录,只能读这下面
}

func (t *ReadFileTool) Execute(argsJSON string) (string, error) {
	var args struct {
		Path string `json:"path"`
	}
	json.Unmarshal([]byte(argsJSON), &args)

	safePath, err := safeJoin(t.WorkDir, args.Path)  // 安全检查!
	if err != nil {
		return err.Error(), nil
	}

	data, err := os.ReadFile(safePath)
	if err != nil {
		return fmt.Sprintf("读取失败: %v", err), nil  // 错误回喂
	}
	return string(data), nil
}

安全关键:safeJoin

go
func safeJoin(base, rel string) (string, error) {
	cleaned := filepath.Clean(rel)
	if filepath.IsAbs(cleaned) || strings.HasPrefix(cleaned, "..") {
		return "", fmt.Errorf("路径必须在工作目录内")
	}
	abs := filepath.Join(base, cleaned)
	// 二次检查:绝对路径必须以 base 为前缀
	// ...
}

为什么不能直接 filepath.Join(workDir, path)?因为模型(或恶意输入)可能传 ../../etc/passwd,逃出工作目录去读系统文件,这是经典的路径遍历攻击。

safeJoin 拒绝绝对路径和 .. 开头的路径,确保结果始终落在 WorkDir 内。这是安全底线:Agent 能读什么,必须限制死。

生产级实现可以用 Go 1.24 的 os.Root,把文件访问囚禁在目录之内,更加彻底。MVP 阶段用 safeJoin 已经够用。

错误回喂

go
if err != nil {
	return fmt.Sprintf("读取失败: %v", err), nil  // 注意返回 nil error
}

文件读不到,不返回 error,而是把错误信息当「结果」返回。这样错误信息进历史,模型看到「读取失败」,下一轮会自己调整,比如换个路径,或者先跑 ls 看看目录里有什么。

如果返回 error,Agent 循环会中断。但「文件不存在」不是致命错误,模型完全可以自愈,这就是错误回喂策略:把可恢复的失败当成普通结果,交给模型下一轮处理。

第三步:edit_file,改文件(str_replace)

go
type EditFileTool struct {
	WorkDir string
}

func (t *EditFileTool) Execute(argsJSON string) (string, error) {
	var args struct {
		Path      string `json:"path"`
		OldString string `json:"old_string"`
		NewString string `json:"new_string"`
	}
	json.Unmarshal([]byte(argsJSON), &args)

	safePath, _ := safeJoin(t.WorkDir, args.Path)
	content, _ := os.ReadFile(safePath)

	// str_replace:精确替换第一个匹配
	newContent := strings.Replace(string(content), args.OldString, args.NewString, 1)
	if newContent == string(content) {
		return "未找到要替换的内容", nil  // 回喂,让模型调整
	}
	os.WriteFile(safePath, []byte(newContent), 0644)
	return "修改成功", nil
}

为什么用 str_replace,不全量重写

全量重写,即让模型输出整个文件的新内容,问题有三:

  • 大文件 token 爆炸(1000 行要全输出)
  • 容易丢内容(输出到一半断了)
  • 无法 review(看不出改了哪)

str_replace 则是让模型只输出「改了哪几行」,优势正好相反:

  • token 省(只输出改动部分)
  • 改动精确(old_string 必须精确匹配才替换)
  • 易 review(一眼看出改了什么)

这是 Claude Code、Codex 这类成熟 Agent 共同的设计选择。我们的实现用 strings.Replace(..., 1),只替换第一个匹配,避免意外多处替换。

old_string 没匹配到怎么办

go
if newContent == string(content) {
	return "未找到要替换的内容", nil
}

不报错,回喂信息。模型看到「没找到」,下一轮会重新 read_file 看准确内容,再 edit,这是自愈机制。

第四步:run_command,跑命令

go
type RunCommandTool struct {
	WorkDir string
	Timeout time.Duration  // 默认 60 秒
}

func (t *RunCommandTool) Execute(argsJSON string) (string, error) {
	var args struct {
		Command string `json:"command"`
	}
	json.Unmarshal([]byte(argsJSON), &args)

	ctx, cancel := context.WithTimeout(context.Background(), t.Timeout)
	defer cancel()
	cmd := exec.CommandContext(ctx, "sh", "-c", args.Command)
	cmd.Dir = t.WorkDir

	var stdout, stderr strings.Builder
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	_ = cmd.Run()  // 忽略 error: exit≠0 是正常情况

	return fmt.Sprintf("退出码: %d\n标准输出:\n%s\n标准错误:\n%s",
		cmd.ProcessState.ExitCode(), stdout.String(), stderr.String()), nil
}

三个关键设计

  1. 用 sh -c 执行:支持管道、重定向、环境变量等 shell 特性。模型可以传 go test ./... 2>&1 | head 这种复合命令。

  2. 超时防死循环:靠 context.WithTimeout。模型可能生成 while true 死循环命令,没有超时会一直卡死,这里默认 60 秒。

  3. exit 非 0 不当 error:这是最重要的设计。命令失败(比如测试不过、编译错误)不是「系统错误」,而是「代码有问题」。把 stderr 回喂给模型,模型看到报错能自己修代码。

go
_ = cmd.Run()  // 忽略 error
// 把 stdout + stderr + 退出码一起返回给模型

对比一下返回 error 会怎样:Agent 循环中断,用户只看到「工具执行出错」。但测试失败是常态,不该中断,回喂让模型继续修。

三个工具的安全对比

工具风险防护
read_file读敏感文件safeJoin 路径囚禁
edit_file改坏文件str_replace 精确匹配 + 路径囚禁
run_command执行恶意命令超时 + 工作目录限制

三个工具的安全设计

MVP 的安全是「基础版」:路径限制加超时。生产级还要加 HITL 审批,危险操作之前先问过人;再加沙箱隔离,比如把命令放进 Docker 容器里跑,这里先不展开。

这一篇总结

你有了 Agent 的「手」,三个能干活的工具:

  • Tool 接口设计(Name、Description、Schema、Execute)
  • read_file(路径囚禁 + 错误回喂)
  • edit_file(str_replace + 精确匹配)
  • run_command(超时 + exit 非 0 回喂)
  • 安全设计(safeJoin 防路径遍历)

但调用模型时,响应是「一次性返回」的:用户要等模型把整段话想完才能看到。本系列下一篇会加入流式输出,用 SSE 逐字解析模型的流式响应并实时显示,让 Agent 像真人一样打字。

相关文章

分享: