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) }