// fs 工具族(提案 2026-09-30-file-preview-and-reserved-capabilities,裁定 2.3): // 实现但不默认提供——Desc.Hidden=true,runtime.fsTools 开启前对模型与 /api/tools 不可见, // registry.Execute 同门禁(fail-closed)。沙箱根 = ///, // 路径规范化后必须仍在会话目录内;写文件同步登记 artifact,保住"工作区文件=工件面板可见"。 package tools import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git" ) const ( fsMaxRead = 1 << 20 // 单次读上限 1MB fsMaxWrite = 2 << 20 // 单次写上限 2MB fsMaxList = 200 // list 条数上限 ) // RegisterFSTools 注册隐藏 fs 工具(read/write/list)。root 由宿主注入(空=未配置,执行时报错)。 func RegisterFSTools(r *Registry) error { if err := r.Register(core.ToolDesc{ Name: "fs_read_file", Description: "读取会话工作区内的文本文件(≤1MB)", Source: core.SourceBuiltin, Hidden: true, ReadOnly: true, Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string","description":"工作区内相对路径"}},"required":["path"]}`), }, execFSRead); err != nil { return err } if err := r.Register(core.ToolDesc{ Name: "fs_write_file", Description: "把文本写入会话工作区文件(≤2MB),并同步登记为可下载工件", Source: core.SourceBuiltin, Hidden: true, Edits: true, Risk: "medium", Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}`), }, execFSWrite); err != nil { return err } return r.Register(core.ToolDesc{ Name: "fs_list_dir", Description: "列出会话工作区目录(一层,≤200 条)", Source: core.SourceBuiltin, Hidden: true, ReadOnly: true, Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string","description":"工作区内相对路径,空=根"}}}`), }, execFSList) } // fsSandbox 返回会话沙箱目录(owner/conversation 隔离);root 未配置报错。 func fsSandbox(rc ToolExecCtx) (string, error) { root := rc.WorkspaceRoot() if root == "" { return "", fmt.Errorf("fs tools: workspace root not configured") } return filepath.Join(root, rc.OwnerID(), rc.ConversationID()), nil } // fsResolve 路径安全解析:拒绝绝对路径、反斜杠与 ..,规范化后必须仍在沙箱内 // (防穿越/逃逸;`\` 一律拒绝——Linux 上虽是合法字符,但会携带 Windows 语义并破坏工件命名)。 func fsResolve(sandbox, rel string) (string, error) { rel = strings.TrimSpace(rel) if rel == "" { rel = "." } if filepath.IsAbs(rel) { return "", fmt.Errorf("fs tools: absolute path not allowed: %s", rel) } if strings.ContainsRune(rel, '\\') { return "", fmt.Errorf("fs tools: backslash not allowed in paths: %s", rel) } abs := filepath.Clean(filepath.Join(sandbox, rel)) if abs != sandbox && !strings.HasPrefix(abs, sandbox+string(filepath.Separator)) { return "", fmt.Errorf("fs tools: path escapes workspace sandbox: %s", rel) } return abs, nil } func execFSRead(rc ToolExecCtx, args json.RawMessage) (string, error) { var a struct { Path string `json:"path"` } if err := json.Unmarshal(args, &a); err != nil { return "", err } sandbox, err := fsSandbox(rc) if err != nil { return "", err } abs, err := fsResolve(sandbox, a.Path) if err != nil { return "", err } st, err := os.Stat(abs) if err != nil { return "", fmt.Errorf("fs_read_file: %w", err) } if st.IsDir() { return "", fmt.Errorf("fs_read_file: %s 是目录(用 fs_list_dir)", a.Path) } if st.Size() > fsMaxRead { return "", fmt.Errorf("fs_read_file: 文件超过 1MB 上限(%d 字节)", st.Size()) } b, err := os.ReadFile(abs) if err != nil { return "", err } if stamps, ok := rc.(fileStamps); ok { _ = stamps.NoteFileRead(abs) } return string(b), nil } type fileStamps interface { NoteFileRead(path string) error CheckFileWrite(path string) error } func execFSWrite(rc ToolExecCtx, args json.RawMessage) (string, error) { var a struct { Path string `json:"path"` Content string `json:"content"` } if err := json.Unmarshal(args, &a); err != nil { return "", err } if a.Path == "" { return "", fmt.Errorf("fs_write_file: path required") } if err := rejectBinaryOfficeWrite(a.Path); err != nil { return "", err } if len(a.Content) > fsMaxWrite { return "", fmt.Errorf("fs_write_file: 内容超过 2MB 上限") } sandbox, err := fsSandbox(rc) if err != nil { return "", err } abs, err := fsResolve(sandbox, a.Path) if err != nil { return "", err } if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { return "", err } // 符号链接逃逸防护:已存在文件若是指向沙箱外的软链则拒绝覆写。 if fi, err := os.Lstat(abs); err == nil && fi.Mode()&os.ModeSymlink != 0 { if real, err := filepath.EvalSymlinks(abs); err != nil || !strings.HasPrefix(real, sandbox+string(filepath.Separator)) { return "", fmt.Errorf("fs_write_file: refusing to follow symlink out of sandbox: %s", a.Path) } } if _, err := os.Stat(abs); err == nil { if stamps, ok := rc.(fileStamps); ok { if err := stamps.CheckFileWrite(abs); err != nil { return "", err } } } before, existed := fsBefore(abs) if err := os.WriteFile(abs, []byte(a.Content), 0o644); err != nil { return "", err } if cp, ok := rc.(interface { NoteFileCheckpoint(rel string, before []byte, existed bool, after []byte) error }); ok { _ = cp.NoteFileCheckpoint(a.Path, before, existed, []byte(a.Content)) } // 不变式:工作区文件 = 工件面板可见(内容同步入对象库,工件自包含可下载)。 base := filepath.Base(abs) art, err := rc.WriteArtifact(base, "file", mimeOfExt(base), []byte(a.Content)) if err != nil { return "", fmt.Errorf("fs_write_file: written %s but artifact registration failed: %w", abs, err) } return fmt.Sprintf("已写入 %s(%d 字节,工件 %s)", a.Path, len(a.Content), art.ID), nil } func fsBefore(abs string) ([]byte, bool) { b, err := os.ReadFile(abs) if err != nil { return nil, false } return b, true } func execFSList(rc ToolExecCtx, args json.RawMessage) (string, error) { var a struct { Path string `json:"path"` } if len(args) > 0 { if err := json.Unmarshal(args, &a); err != nil { return "", err } } sandbox, err := fsSandbox(rc) if err != nil { return "", err } abs, err := fsResolve(sandbox, a.Path) if err != nil { return "", err } ents, err := os.ReadDir(abs) if err != nil { return "", fmt.Errorf("fs_list_dir: %w", err) } sort.Slice(ents, func(i, j int) bool { return ents[i].Name() < ents[j].Name() }) var sb strings.Builder n := 0 for _, e := range ents { if n >= fsMaxList { fmt.Fprintf(&sb, "…(超过 %d 条截断)\n", fsMaxList) break } info, err := e.Info() size := int64(-1) if err == nil { size = info.Size() } tag := "file" if e.IsDir() { tag = "dir" } fmt.Fprintf(&sb, "%s\t%s\t%d\n", tag, e.Name(), size) n++ } if sb.Len() == 0 { return "(空目录)", nil } return sb.String(), nil } func mimeOfExt(name string) string { switch strings.ToLower(filepath.Ext(name)) { case ".md": return "text/markdown" case ".json": return "application/json" case ".csv": return "text/csv" case ".html": return "text/html" case ".docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" case ".xlsx": return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" case ".pptx": return "application/vnd.openxmlformats-officedocument.presentationml.presentation" case ".pdf": return "application/pdf" case ".png": return "image/png" case ".jpg", ".jpeg": return "image/jpeg" default: return "text/plain; charset=utf-8" } } // rejectBinaryOfficeWrite 拒绝把字符串直接写成伪二进制 Office/PDF。 // 带排版的文件由 run_python 生成后再 register_artifact 登记。 func rejectBinaryOfficeWrite(name string) error { switch strings.ToLower(filepath.Ext(name)) { case ".doc", ".docx", ".xls", ".xlsx", ".xlsm", ".ppt", ".pptx", ".pdf": return fmt.Errorf("%s 是二进制文档扩展名,不能把字符串直接写成该文件。请用 run_python 生成到工作区后,用 register_artifact 登记", filepath.Ext(name)) default: return nil } }