Files
weichen fec2d16cc6 Close Task 10 renderer delivery gaps
Why:
- Independent review found trusted-boundary, semantic parity, replay-budget,
  comparison, persistence, cancellation, provenance, and routing gaps.
- Storage migration must include the new durable delivery table.

Evidence:
- go test -count=1 ./...
- go test -race ./internal/docgen ./internal/tools ./internal/runmgr
- go test ./... (deps/agent-document-renderer)
- go vet ./...
- go build ./...
- CGO_ENABLED=0 go build -trimpath ./cmd/agent-system
- Python house-style unittest suite and git diff --check

Impact:
- Word delivery now uses host-owned freezes, Markdown-first resumable persistence,
  closed registration boundaries, semantic shared rendering, and real comparison.
- Controlled/project documents still fail closed to RDM and non-Word flows remain
  unchanged.

Refs:
- Unified Document Renderer implementation plan Task 10
- /tmp/task10-backend-review-last.md
- Renderer d5cd8c126045a27d5a5cc5da23f64d3c737e116a
2026-10-15 21:44:03 +00:00

290 lines
9.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// fs 工具族(提案 2026-09-30-file-preview-and-reserved-capabilities,裁定 2.3):
// 实现但不默认提供——Desc.Hidden=true,runtime.fsTools 开启前对模型与 /api/tools 不可见,
// registry.Execute 同门禁(fail-closed)。沙箱根 = <root>/<owner>/<conversationID>/,
// 路径规范化后必须仍在会话目录内;写文件同步登记 artifact,保住"工作区文件=工件面板可见"。
package tools
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
core "agent-system-core"
)
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 {
if err := checkToolExternalEffect(rc); err != nil {
return "", err
}
_ = 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 := checkToolExternalEffect(rc); 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 := checkToolExternalEffect(rc); err != nil {
return "", err
}
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 {
if err := checkToolExternalEffect(rc); err != nil {
return "", err
}
_ = cp.NoteFileCheckpoint(a.Path, before, existed, []byte(a.Content))
}
// 不变式:工作区文件 = 工件面板可见(内容同步入对象库,工件自包含可下载)。
base := filepath.Base(abs)
if err := checkToolExternalEffect(rc); err != nil {
return "", err
}
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。
// 普通 Word 由 render_document 生成;其他带排版二进制由受控生成器产出后登记。
func rejectBinaryOfficeWrite(name string) error {
switch strings.ToLower(filepath.Ext(name)) {
case ".doc", ".docx", ".docm", ".dotx", ".dotm":
return fmt.Errorf("%s 是 Word 二进制扩展名,不能写入字符串;普通 Word 请用 render_document,项目或受控 Word 交给 RDM", filepath.Ext(name))
case ".xls", ".xlsx", ".xlsm", ".ppt", ".pptx", ".pdf":
return fmt.Errorf("%s 是二进制文档扩展名,不能把字符串直接写成该文件;请用受控生成器产出后再登记", filepath.Ext(name))
default:
return nil
}
}
func isWordBinaryName(name string) bool {
switch strings.ToLower(filepath.Ext(name)) {
case ".doc", ".docx", ".docm", ".dotx", ".dotm":
return true
default:
return false
}
}