Files
Cyrene_v2/internal/runtime/exec/sandbox.go
T
AskaEth 09ee561aa7 refactor(exec,io): 文件沙箱单入口收进 io 工具、删 exec.FileSandbox(裁决 13.4.4)+ 修一条假阳性安全断言
- 删除未接线的 exec.FileSandbox(含 5 个用例):文件沙箱从此只有 plugins/tools/io/file.go 一处实现
- io 侧取更严一侧:NUL 拒绝(原 io 缺)、写前按最深存在祖先判定落点(避免越界建目录)、roots 归化到真实路径(修 roots 自身是 symlink 时的全误拒)
- exec/sandbox.go 保留并注释说明:那是**终端工作目录守卫**的原语(terminal.go 在用),不是文件沙箱
- 修假阳性:原 NUL 用例断言错误文本含 "NUL",而 t.TempDir() 目录名自带测试函数名 "…RejectsNULBytePath…"、错误又内嵌整条临时路径 ⇒ 删掉安全检查用例照样绿。改为哨兵错误 ErrNullByte + errors.Is(错误文本逐字节不变),修复后同一变异必红
- 同类排查:file_test.go 三处文本断言判定无风险(含空格的串不可能出现在临时目录名里),保留
2026-10-04 12:29:39 +08:00

111 lines
3.2 KiB
Go

package exec
import (
"fmt"
"path/filepath"
"strings"
)
// 本文件是终端沙箱的路径收敛原语(roots 清洗、词法包含判定、symlink 逃逸解析),
// 由 terminal.go 用于工作目录守卫。**它不是文件沙箱**:文件读写的沙箱已按裁决
// 13.4.4 收敛到 plugins/tools/io 文件工具(design/04 §4)。两侧保持同一收敛口径:
// 词法 roots 包含 + 最深存在祖先的 symlink 解析 + NUL 拒绝。
// absCleaners returns a slice of absolute, clean paths from raw roots. Dirty or
// empty entries are dropped; non-absolute roots are made absolute against the
// process working directory.
func absCleaners(raw []string) []string {
out := make([]string, 0, len(raw))
for _, r := range raw {
r = strings.TrimSpace(r)
if r == "" {
continue
}
if !filepath.IsAbs(r) {
r = filepath.Join(".", r)
}
out = append(out, filepath.Clean(r))
}
return out
}
// withinAnyRoot reports whether path is lexically confined to one of roots.
// Containment is judged by filepath.Rel: the path is inside a root when the
// relative path is "." or does not begin with "..".
func withinAnyRoot(path string, roots []string) bool {
for _, root := range roots {
rel, err := filepath.Rel(root, path)
if err != nil {
continue
}
if rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) {
return true
}
}
return false
}
// resolveReal returns the real (symlink-free) path for p, resolving the deepest
// existing ancestor when p itself does not exist (for example a file about to be
// created). This prevents a symlink inside a root from pointing at a location
// outside it.
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
}
}
// resolveInsideRoot validates a caller-supplied path and returns its absolute,
// symlink-resolved path, guaranteeing that both the lexical path and its real
// target stay inside one of roots. A relative path is resolved against
// roots[0]. The returned path is absolute and clean.
func resolveInsideRoot(candidate string, roots []string) (string, error) {
if candidate == "" {
return "", ErrEmptyPath
}
if strings.IndexByte(candidate, 0) >= 0 {
return "", ErrNullByte
}
var abs string
if filepath.IsAbs(candidate) {
abs = filepath.Clean(candidate)
} else {
if len(roots) == 0 {
return "", ErrNoAllowedRoot
}
abs = filepath.Join(roots[0], candidate)
}
if !withinAnyRoot(abs, roots) {
return "", fmt.Errorf("%w: %s", ErrPathOutsideRoot, candidate)
}
real, err := resolveReal(abs)
if err != nil {
return "", fmt.Errorf("%w: %s: %w", ErrPathOutsideRoot, candidate, err)
}
if !withinAnyRoot(real, roots) {
return "", fmt.Errorf("%w: %s (real target %s)", ErrSymlinkEscape, candidate, real)
}
return real, nil
}