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 三处文本断言判定无风险(含空格的串不可能出现在临时目录名里),保留
This commit is contained in:
1 parent
95f97f5df3
commit
09ee561aa7
6 files changed
+344
-222
No files matched your search
@@ -1,99 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// FileSandbox confines file read, write, and list access to a set of allowed
|
||||
// root directories (DevDocs/design/04-运行时.md §8, design/11 §5). Every path is
|
||||
// validated by [Resolve] before use, which rejects absolute paths outside the
|
||||
// roots, relative paths that escape them, and symlinks that resolve outside
|
||||
// them.
|
||||
type FileSandbox struct {
|
||||
mu sync.RWMutex
|
||||
roots []string
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewFileSandbox builds a FileSandbox confined to roots. Raw roots are made
|
||||
// absolute and cleaned. The logger is required.
|
||||
func NewFileSandbox(roots []string, log *slog.Logger) (*FileSandbox, error) {
|
||||
if log == nil {
|
||||
return nil, ErrNilLogger
|
||||
}
|
||||
return &FileSandbox{
|
||||
roots: absCleaners(roots),
|
||||
log: logger.WithSource(log, "runtime/exec"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetRoots atomically replaces the allowed roots. It enables a live config
|
||||
// reload without racing in-flight Resolve calls.
|
||||
func (s *FileSandbox) SetRoots(roots []string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.roots = absCleaners(roots)
|
||||
}
|
||||
|
||||
// Resolve validates path against the current allowed roots and returns its
|
||||
// absolute, symlink-resolved location, or an error if it would escape. A
|
||||
// relative path is resolved against the first allowed root.
|
||||
func (s *FileSandbox) Resolve(path string) (string, error) {
|
||||
roots := s.rootsLocked()
|
||||
if len(roots) == 0 {
|
||||
return "", ErrNoAllowedRoot
|
||||
}
|
||||
return resolveInsideRoot(path, roots)
|
||||
}
|
||||
|
||||
// ReadFile reads a file confined to the allowed roots.
|
||||
func (s *FileSandbox) ReadFile(path string) ([]byte, error) {
|
||||
abs, err := s.Resolve(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("exec: read %s: %w", abs, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// WriteFile writes data to a path confined to the allowed roots.
|
||||
func (s *FileSandbox) WriteFile(path string, data []byte, perm os.FileMode) error {
|
||||
abs, err := s.Resolve(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(abs, data, perm); err != nil {
|
||||
return fmt.Errorf("exec: write %s: %w", abs, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns the directory entries of a directory confined to the allowed
|
||||
// roots.
|
||||
func (s *FileSandbox) List(dir string) ([]os.DirEntry, error) {
|
||||
abs, err := s.Resolve(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(abs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("exec: list %s: %w", abs, err)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *FileSandbox) rootsLocked() []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
roots := make([]string, len(s.roots))
|
||||
copy(roots, s.roots)
|
||||
return roots
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newFileSandbox(t *testing.T, roots ...string) *FileSandbox {
|
||||
t.Helper()
|
||||
fs, err := NewFileSandbox(roots, discardLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileSandbox: %v", err)
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
func TestFileSandbox_Resolve_Allowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
realRoot := root
|
||||
if r, err := filepath.EvalSymlinks(root); err == nil {
|
||||
realRoot = r
|
||||
}
|
||||
fs := newFileSandbox(t, root)
|
||||
|
||||
got, err := fs.Resolve(filepath.Join(root, "a.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if want := filepath.Join(realRoot, "a.txt"); got != want {
|
||||
t.Errorf("Resolve = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSandbox_Resolve_OutsideRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs := newFileSandbox(t, t.TempDir())
|
||||
outside := filepath.Join(os.TempDir(), "cyrene-outside-file.txt")
|
||||
_, err := fs.Resolve(outside)
|
||||
if !errors.Is(err, ErrPathOutsideRoot) {
|
||||
t.Fatalf("err = %v, want ErrPathOutsideRoot", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSandbox_Resolve_SymlinkEscape(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
link := filepath.Join(root, "escape")
|
||||
if err := os.Symlink(outside, link); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
fs := newFileSandbox(t, root)
|
||||
|
||||
_, err := fs.Resolve(filepath.Join(link, "secret.txt"))
|
||||
if !errors.Is(err, ErrSymlinkEscape) {
|
||||
t.Fatalf("err = %v, want ErrSymlinkEscape", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSandbox_WriteReadList(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs := newFileSandbox(t, t.TempDir())
|
||||
|
||||
if err := fs.WriteFile("a.txt", []byte("content"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
data, err := fs.ReadFile("a.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if string(data) != "content" {
|
||||
t.Errorf("ReadFile = %q, want content", data)
|
||||
}
|
||||
entries, err := fs.List(".")
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("len(entries) = %d, want 1", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSandbox_NoRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs := newFileSandbox(t, "")
|
||||
_, err := fs.Resolve("a.txt")
|
||||
if !errors.Is(err, ErrNoAllowedRoot) {
|
||||
t.Fatalf("err = %v, want ErrNoAllowedRoot", err)
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,15 @@
|
||||
// Cyrene agent runtime (DevDocs/design/04-运行时.md §8 and design/02-会话与上下文模型.md §4).
|
||||
//
|
||||
// A Provider is the grant-based, sandboxed entry point through which the agent
|
||||
// may run code on its own host. This milestone ships two concrete pieces:
|
||||
// may run code on its own host. This milestone ships one concrete piece:
|
||||
//
|
||||
// - [TerminalExecutor], the default command provider, which runs allowlisted
|
||||
// commands inside a locked-down terminal sandbox (command allowlist, working
|
||||
// directory confinement, environment allowlist, timeout, output truncation).
|
||||
// - [FileSandbox], which confines file read/write/list access to a set of
|
||||
// allowed root directories.
|
||||
//
|
||||
// 文件读写的路径沙箱不在本包:按裁决 13.4.4(design/04 §4),文件访问的 roots
|
||||
// 收敛、symlink 逃逸与 NUL 拒绝统一由 plugins/tools/io 文件工具承载;本包内
|
||||
// sandbox.go 的收敛原语只服务于终端沙箱的工作目录 / roots 守卫。
|
||||
//
|
||||
// GUI operations and a real virtual machine are reserved for later milestones;
|
||||
// they surface only through the [Provider] interface and are not implemented
|
||||
|
||||
@@ -6,6 +6,11 @@ import (
|
||||
"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.
|
||||
|
||||
Reference in new issue
Block a user