
字节笔记本
2026年10月6日 · 约 12 分钟读完
迷你Claude Code(三):三大工具实现与安全设计
这是「从 0 实现迷你 Claude Code」系列的第三篇。上一篇完成了 Agent 循环,这一篇实现循环里调用的三个真实工具:read_file、edit_file 和 run_command,让 Agent 真的能读文件、改文件、跑命令。对应代码是项目里的 tool.go 和 tools.go。

这一篇你得到什么
读完你能:
- 设计 Tool 接口
- 实现 read_file、edit_file、run_command
- 做路径遍历防护(防 ../../etc/passwd)
- 理解 str_replace 编辑为什么优于全量重写
- 知道命令失败为什么要「回喂」而非「中断」
第一步:Tool 接口
所有工具实现同一个接口(tool.go):
type Tool interface {
Name() string // 工具名
Description() string // 描述(模型看)
Schema() json.RawMessage // 参数 JSON Schema
Execute(argsJSON string) (string, error) // 执行
}四个方法,对应「工具的全部信息 + 执行能力」。前三个发给模型,让它知道有什么工具、怎么用;第四个是真正干活。
argsJSON 是原始 JSON 字符串:模型传来的参数就是字符串,由工具自己 json.Unmarshal 解析。这样设计是因为不同工具参数结构不同,接口不能预定义。
工具注册表
type ToolRegistry struct {
tools map[string]Tool
}
func (r *ToolRegistry) Definitions() []ToolDefinition {
// 生成所有工具的定义,发给模型
}Agent 按名字查找工具,把定义发给模型。注册一次,循环里反复用。
第二步:read_file,读文件
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
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 已经够用。
错误回喂
if err != nil {
return fmt.Sprintf("读取失败: %v", err), nil // 注意返回 nil error
}文件读不到,不返回 error,而是把错误信息当「结果」返回。这样错误信息进历史,模型看到「读取失败」,下一轮会自己调整,比如换个路径,或者先跑 ls 看看目录里有什么。
如果返回 error,Agent 循环会中断。但「文件不存在」不是致命错误,模型完全可以自愈,这就是错误回喂策略:把可恢复的失败当成普通结果,交给模型下一轮处理。
第三步:edit_file,改文件(str_replace)
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 没匹配到怎么办
if newContent == string(content) {
return "未找到要替换的内容", nil
}不报错,回喂信息。模型看到「没找到」,下一轮会重新 read_file 看准确内容,再 edit,这是自愈机制。
第四步:run_command,跑命令
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
}三个关键设计
-
用 sh -c 执行:支持管道、重定向、环境变量等 shell 特性。模型可以传
go test ./... 2>&1 | head这种复合命令。 -
超时防死循环:靠 context.WithTimeout。模型可能生成
while true死循环命令,没有超时会一直卡死,这里默认 60 秒。 -
exit 非 0 不当 error:这是最重要的设计。命令失败(比如测试不过、编译错误)不是「系统错误」,而是「代码有问题」。把 stderr 回喂给模型,模型看到报错能自己修代码。
_ = 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 像真人一样打字。



