Files
Cyrene_v2/plugins/tools/io/file.go
T
AskaEth 3589f4b14f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(tools): 真实工具声明文件目标(让文件级互斥真正生效)
新增 plugins/tools/io/file_targets.go:fileTool 与 cropTool 实现 tool.FileTargeter
(编译期钉住 var _ tool.FileTargeter,声明集中一处可审计)。
file:read/list/search ⇒ 读;其余(含 delete / 未知 / 缺失 action)⇒ 写(保守侧,宁可多挡一次)。
路径取沙箱解析前的声明值(绝对、clean、已确认在 roots 内,纯词法不触盘);沙箱会拒的路径不声明。
search 的缺省 base 与执行路径共用 f.searchBase(避免声明目录 ≠ 实际走查目录)。
image-crop:仅本地路径声明为读(URL/data URI 不落盘);分类与 load 共用一份。
体检清单:file ✅ / image-crop ✅ / document-read ❌(sidecar 走「临时文件 + rename」原子替换,
无撕读风险;且布局属未导出的第二份真相)/ basic、http、knowledge 等不碰盘;
vision·ocr(internal 越界)、terminal(cmd 越界 + 目标不可知)、MCP 外部工具 未做 —— 如实登记为遗留。
测试:单元(action→Op 全表 / 路径归一 / 不声明场景)+ 机制层 6 条(真插件 + 真 Dispatcher + 真占用表,
用闸门工具把并发变确定性事件,不靠 sleep)。
变异 5 条(一切按写 / 未知按读 / 用原始相对串 / crop 不声明 / file 声明被抹掉)。
注:本切片声明正确但生效依赖 ef3dcb6(网关给每条在途路挂互斥身份)。
2026-10-05 09:55:29 +08:00

400 lines
13 KiB
Go

package io
import (
"context"
"errors"
"fmt"
stdio "io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// Compile-time assertion that fileTool implements sdk.Tool.
var _ sdk.Tool = (*fileTool)(nil)
// fileTool reads, writes, lists and searches files, confined to the configured
// data directory plus any explicit whitelist. Paths are cleaned and re-checked
// against those roots, so ".." traversal and symlink escapes are rejected.
//
// 本工具是文件访问沙箱的唯一实现处(裁决 13.4.4、design/04 §4):roots 收敛、
// symlink 逃逸、NUL 字节与 ".." 穿越都在此判定,写入前先按真实落点校验父链。
type fileTool struct {
roots []string // absolute, cleaned allowed directories
maxRead int64
maxWrite int64
maxList int
log *slog.Logger
}
func newFileTool(t *tools) *fileTool {
roots := make([]string, 0, 1+len(t.fileWhitelist))
if t.cfg != nil && t.cfg.DataDir != "" {
roots = append(roots, absoluteClean(t.cfg.DataDir))
}
for _, w := range t.fileWhitelist {
roots = append(roots, absoluteClean(w))
}
return &fileTool{
roots: roots,
maxRead: t.maxReadBytes,
maxWrite: t.maxWriteBytes,
maxList: t.maxList,
log: t.log,
}
}
// Name implements sdk.Tool.
func (f *fileTool) Name() string { return "file" }
// Description implements sdk.Tool.
func (f *fileTool) Description() string {
return "Read, write, list or search files, restricted to the configured data directory and explicit whitelist."
}
// Parameters implements sdk.Tool.
func (f *fileTool) Parameters() (map[string]any, error) {
return map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{"type": "string", "enum": []any{actionRead, actionWrite, actionList, actionSearch}, "description": "Operation to perform."},
"path": map[string]any{"type": "string", "description": "Absolute or data-directory-relative file path."},
"content": map[string]any{"type": "string", "description": "Body to write."},
"query": map[string]any{"type": "string", "description": "Substring filter for search."},
},
"required": []any{"action", "path"},
}, nil
}
// Execute implements sdk.Tool.
func (f *fileTool) Execute(ctx context.Context, args any) (any, error) {
m, ok := args.(map[string]any)
if !ok {
return nil, fmt.Errorf("io: file: args must be an object")
}
switch action := argString(m, "action"); action {
case actionRead:
return f.read(ctx, argString(m, "path"))
case actionWrite:
return f.write(ctx, argString(m, "path"), argString(m, "content"))
case actionList:
return f.list(ctx, argString(m, "path"))
case actionSearch:
return f.search(ctx, argString(m, "path"), argString(m, "query"))
default:
return nil, fmt.Errorf("io: file: unknown action %q", action)
}
}
// read returns a file's content, capped at maxRead bytes.
func (f *fileTool) read(ctx context.Context, p string) (any, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
real, err := f.resolveExisting(p)
if err != nil {
return nil, err
}
info, err := os.Stat(real)
if err != nil {
return nil, fmt.Errorf("io: file: stat: %w", err)
}
if info.IsDir() {
return nil, fmt.Errorf("io: file: %q is a directory", real)
}
if info.Size() > f.maxRead {
return nil, fmt.Errorf("io: file: %q exceeds the %d-byte read limit", real, f.maxRead)
}
fh, err := os.Open(real)
if err != nil {
return nil, fmt.Errorf("io: file: open: %w", err)
}
defer fh.Close()
data, err := stdio.ReadAll(stdio.LimitReader(fh, f.maxRead+1))
if err != nil {
return nil, fmt.Errorf("io: file: read: %w", err)
}
if int64(len(data)) > f.maxRead {
return nil, fmt.Errorf("io: file: %q exceeds the %d-byte read limit", real, f.maxRead)
}
return map[string]any{"path": real, "size": len(data), "content": string(data)}, nil
}
// write 创建或覆盖文件,内容上限 maxWrite 字节。流程:
// 1. 写入前先算出目标的真实落点,确认仍在 roots 内(防 roots 内的 symlink 逃逸);
// 2. 建目录;
// 3. 建目录之后再核一遍父目录的真实落点——兜住建目录期间新出现的 symlink,以及
// Windows 目录 junction 这类 filepath.EvalSymlinks 不解析的再解析点(此时解析
// 报错即拒),保证严格度不低于收敛前的实现(裁决 13.4.4)。
func (f *fileTool) write(ctx context.Context, p, content string) (any, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if int64(len(content)) > f.maxWrite {
return nil, fmt.Errorf("io: file: content exceeds the %d-byte write limit", f.maxWrite)
}
target, err := f.resolveForCreate(p)
if err != nil {
return nil, err
}
parent := filepath.Dir(target)
if err := os.MkdirAll(parent, dirPerm); err != nil {
return nil, fmt.Errorf("io: file: mkdir: %w", err)
}
realParent, err := filepath.EvalSymlinks(parent)
if err != nil {
return nil, fmt.Errorf("io: file: resolve parent: %w", err)
}
if !f.withinReal(realParent) {
f.log.Warn("io file: rejected write escaping allowed directories", "path", p, "resolved", realParent)
return nil, fmt.Errorf("io: file: %q escapes the allowed directories", p)
}
if err := os.WriteFile(target, []byte(content), filePerm); err != nil {
return nil, fmt.Errorf("io: file: write: %w", err)
}
return map[string]any{"path": target, "size": len(content)}, nil
}
// list returns the entries of a directory, capped at maxList.
func (f *fileTool) list(ctx context.Context, p string) (any, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
real, err := f.resolveExisting(p)
if err != nil {
return nil, err
}
info, err := os.Stat(real)
if err != nil {
return nil, fmt.Errorf("io: file: stat: %w", err)
}
if !info.IsDir() {
return nil, fmt.Errorf("io: file: %q is not a directory", real)
}
entries, err := os.ReadDir(real)
if err != nil {
return nil, fmt.Errorf("io: file: readdir: %w", err)
}
if len(entries) > f.maxList {
entries = entries[:f.maxList]
}
out := make([]map[string]any, 0, len(entries))
for _, e := range entries {
ei, err := e.Info()
if err != nil {
continue
}
out = append(out, map[string]any{"name": e.Name(), "size": ei.Size(), "dir": ei.IsDir()})
}
return map[string]any{"path": real, "entries": out, "count": len(out)}, nil
}
// search walks a base directory (default the primary root) and returns files
// whose names contain query, capped at maxList results.
func (f *fileTool) search(ctx context.Context, base, query string) (any, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if strings.TrimSpace(query) == "" {
return nil, fmt.Errorf("io: file: search query is required")
}
base, err := f.searchBase(base)
if err != nil {
return nil, err
}
real, err := f.resolveExisting(base)
if err != nil {
return nil, err
}
info, err := os.Stat(real)
if err != nil {
return nil, fmt.Errorf("io: file: stat: %w", err)
}
if !info.IsDir() {
return nil, fmt.Errorf("io: file: %q is not a directory", real)
}
q := strings.ToLower(query)
hits := make([]map[string]any, 0)
walkErr := filepath.WalkDir(real, func(pp string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if !strings.Contains(strings.ToLower(d.Name()), q) {
return nil
}
di, err := d.Info()
if err != nil {
return nil
}
rel, rerr := filepath.Rel(real, pp)
if rerr != nil {
rel = pp
}
hits = append(hits, map[string]any{"path": rel, "size": di.Size()})
if len(hits) >= f.maxList {
return fs.SkipAll
}
return nil
})
if walkErr != nil && !errors.Is(walkErr, fs.SkipAll) {
return nil, fmt.Errorf("io: file: walk: %w", walkErr)
}
return map[string]any{"base": real, "query": query, "hits": hits, "count": len(hits)}, nil
}
// searchBase 解析 search 的基准目录:显式给定就原样返回(由 resolveExisting 归一),
// 缺省时用主 root。执行路径与目标声明(file_targets.go 的 declaredPath)必须用同一
// 份缺省口径,否则声明出来的路径会与真正走查的目录不是同一个。
func (f *fileTool) searchBase(base string) (string, error) {
if strings.TrimSpace(base) != "" {
return base, nil
}
if len(f.roots) == 0 {
return "", fmt.Errorf("io: file: no base directory configured")
}
return f.roots[0], nil
}
// ErrNullByte 表示路径中含 NUL 字节:在触达文件系统之前就被拒绝(收敛自原 exec 文件
// 沙箱,裁决 13.4.4)。调用方与测试一律用 errors.Is(err, ErrNullByte) 判定——不要断言
// 错误文本;此处只存不带来源前缀的文本,前缀由返回值处的 fmt.Errorf 补上,保证对外错误
// 文本与收敛前逐字节一致。
var ErrNullByte = errors.New("path contains a NUL byte")
// resolve 清洗路径并确认其词法上留在允许的 roots 之内:拒绝空路径与含 NUL 字节的
// 路径(NUL 收敛自原 exec 文件沙箱,裁决 13.4.4),拒绝 ".." 穿越与越界的绝对路径;
// 返回绝对、clean 的词法路径。
func (f *fileTool) resolve(p string) (string, error) {
if strings.TrimSpace(p) == "" {
return "", fmt.Errorf("io: file: path is required")
}
if strings.IndexByte(p, 0) >= 0 {
return "", fmt.Errorf("io: file: %w", ErrNullByte)
}
if len(f.roots) == 0 {
return "", fmt.Errorf("io: file: no base directory configured")
}
clean := filepath.Clean(p)
if !filepath.IsAbs(clean) {
clean = filepath.Join(f.roots[0], clean)
}
clean = filepath.Clean(clean)
if !f.within(clean) {
return "", fmt.Errorf("io: file: %q is outside the allowed directories", p)
}
return clean, nil
}
// resolveExisting 解析一个必须已存在的路径:要求其 symlink 解析后的真实落点仍在
// roots 的真实路径之内,防住经由 roots 内 symlink 的逃逸。目标不存在时报错。
func (f *fileTool) resolveExisting(p string) (string, error) {
clean, err := f.resolve(p)
if err != nil {
return "", err
}
real, err := filepath.EvalSymlinks(clean)
if err != nil {
return "", fmt.Errorf("io: file: resolve %q: %w", p, err)
}
if !f.withinReal(real) {
f.log.Warn("io file: rejected path escaping allowed directories", "path", p, "resolved", real)
return "", fmt.Errorf("io: file: %q escapes the allowed directories", p)
}
return real, nil
}
// resolveForCreate 解析一个可能尚不存在(即将创建)的写入目标:先推出最深存在祖先
// 的真实落点,确认仍在 roots 内后返回真实路径(真实祖先 + 尚未创建的分量)。
func (f *fileTool) resolveForCreate(p string) (string, error) {
clean, err := f.resolve(p)
if err != nil {
return "", err
}
real, err := resolveReal(clean)
if err != nil {
return "", fmt.Errorf("io: file: resolve %q: %w", p, err)
}
if !f.withinReal(real) {
f.log.Warn("io file: rejected write escaping allowed directories", "path", p, "resolved", real)
return "", fmt.Errorf("io: file: %q escapes the allowed directories", p)
}
return real, nil
}
// within 报告 p 是否词法上位于(或等于)某个 root 之内。
func (f *fileTool) within(p string) bool { return withinRoots(p, f.roots) }
// withinReal 报告真实落点 p 是否位于某个 root 的真实路径之内。
func (f *fileTool) withinReal(p string) bool { return withinRoots(p, f.realRoots()) }
// realRoots 返回各 root 的真实路径:存在则解析 symlink,尚未创建则按最深存在祖先
// 推导。把「真实落点」与 roots 放进同一(真实)路径空间比较,既拦得住 roots 内
// symlink 的逃逸,也不会把「DataDir 自身是 symlink」的正常部署误判为逃逸。
func (f *fileTool) realRoots() []string {
out := make([]string, 0, len(f.roots))
for _, r := range f.roots {
real, err := resolveReal(r)
if err != nil {
real = r
}
out = append(out, real)
}
return out
}
// withinRoots 报告 p 是否位于(或等于)roots 中的某一个之内:按 filepath.Rel 判定,
// 相对路径为 "." 或不以 ".." 开头即为包含。
func withinRoots(p string, roots []string) bool {
for _, root := range roots {
rel, err := filepath.Rel(root, p)
if err != nil {
continue
}
if rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) {
return true
}
}
return false
}
// resolveReal 返回 p 的真实(symlink 解析后)路径:p 自身存在时直接解析;不存在时
// 沿父级上溯到最深的存在祖先,再把尚未创建的分量拼回。这样「即将创建」的路径也能
// 在落盘前判定真实落点,避免 MkdirAll 沿 roots 内的 symlink 逃逸。
func resolveReal(p string) (string, error) {
cur := filepath.Clean(p)
remaining := ""
for {
if real, err := filepath.EvalSymlinks(cur); err == nil {
if remaining == "" {
return real, nil
}
return filepath.Join(real, remaining), nil
}
base := filepath.Base(cur)
parent := filepath.Dir(cur)
if parent == cur {
return "", fmt.Errorf("no existing ancestor for %s", p)
}
if remaining == "" {
remaining = base
} else {
remaining = filepath.Join(base, remaining)
}
cur = parent
}
}
// absoluteClean absolutizes and cleans p, returning the best-effort absolute
// form used as an allowed root.
func absoluteClean(p string) string {
a, err := filepath.Abs(p)
if err != nil {
return filepath.Clean(p)
}
return filepath.Clean(a)
}