feat(exec): 本地执行 Provider + 放权终端沙箱(白名单/工作目录/超时/输出截断/默认关)+ 文件沙箱;config 增 ExecSandbox(默认关)

This commit is contained in:
Aska committed 2026-09-06 06:59:23 +08:00
1 parent 88dc12466d
commit 430fc4666b
11 files changed
+1199 -20

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
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
}