116 lines
3.1 KiB
Go
116 lines
3.1 KiB
Go
|
|
package sandbox
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"github.com/VictorVargas/rony-llm-agent/pkg/llm"
|
||
|
|
"github.com/VictorVargas/rony-llm-agent/pkg/tools"
|
||
|
|
)
|
||
|
|
|
||
|
|
// ErrSandboxViolation is returned when a tool call violates sandbox rules.
|
||
|
|
var ErrSandboxViolation = fmt.Errorf("sandbox violation")
|
||
|
|
|
||
|
|
// Sandbox wraps os.Root to enforce filesystem boundaries for tool calls.
|
||
|
|
type Sandbox struct {
|
||
|
|
root *os.Root
|
||
|
|
path string
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewSandbox creates a new Sandbox rooted at the given directory.
|
||
|
|
// The directory is created if it does not exist.
|
||
|
|
func NewSandbox(rootDir string) (*Sandbox, error) {
|
||
|
|
if err := os.MkdirAll(rootDir, 0755); err != nil {
|
||
|
|
return nil, fmt.Errorf("creating sandbox root: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
absRoot, err := filepath.Abs(rootDir)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("resolving absolute path: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
root, err := os.OpenRoot(absRoot)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("opening sandbox root: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return &Sandbox{root: root, path: absRoot}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ValidateToolCall checks if a tool call's arguments reference paths outside the sandbox.
|
||
|
|
// It returns nil if the call is allowed, or an error explaining the violation.
|
||
|
|
func (s *Sandbox) ValidateToolCall(tool tools.Tool, call llm.ToolCall) error {
|
||
|
|
args := make(map[string]interface{})
|
||
|
|
if len(call.Arguments) > 0 {
|
||
|
|
if err := json.Unmarshal(call.Arguments, &args); err != nil {
|
||
|
|
return fmt.Errorf("parsing arguments: %w", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, path := range extractPaths(args) {
|
||
|
|
if err := s.validatePath(path); err != nil {
|
||
|
|
return fmt.Errorf("%w: %s", ErrSandboxViolation, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// validatePath checks that the given path resolves inside the sandbox root.
|
||
|
|
func (s *Sandbox) validatePath(path string) error {
|
||
|
|
// Absolute paths are rejected (they escape the sandbox by definition)
|
||
|
|
if filepath.IsAbs(path) {
|
||
|
|
return fmt.Errorf("absolute paths not allowed: %s", path)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Resolve to absolute path relative to sandbox root
|
||
|
|
abs := filepath.Join(s.path, path)
|
||
|
|
|
||
|
|
// Clean the path to normalize it
|
||
|
|
abs = filepath.Clean(abs)
|
||
|
|
|
||
|
|
// Check if the path is inside the root
|
||
|
|
if !strings.HasPrefix(abs, s.path+string(filepath.Separator)) && abs != s.path {
|
||
|
|
return fmt.Errorf("path escapes sandbox: %s (root: %s)", abs, s.path)
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// extractPaths collects all path-like values from the arguments map.
|
||
|
|
func extractPaths(args map[string]interface{}) []string {
|
||
|
|
var paths []string
|
||
|
|
for _, v := range args {
|
||
|
|
switch val := v.(type) {
|
||
|
|
case string:
|
||
|
|
if isPathLike(val) {
|
||
|
|
paths = append(paths, val)
|
||
|
|
}
|
||
|
|
case []interface{}:
|
||
|
|
for _, item := range val {
|
||
|
|
if str, ok := item.(string); ok && isPathLike(str) {
|
||
|
|
paths = append(paths, str)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return paths
|
||
|
|
}
|
||
|
|
|
||
|
|
// isPathLike checks if a string looks like a filesystem path.
|
||
|
|
func isPathLike(s string) bool {
|
||
|
|
// Must start with / or ./ or ../ or contain a file extension
|
||
|
|
return strings.HasPrefix(s, "/") ||
|
||
|
|
strings.HasPrefix(s, "./") ||
|
||
|
|
strings.HasPrefix(s, "../") ||
|
||
|
|
strings.Contains(s, ".") && strings.Contains(s, "/")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Open returns the underlying os.Root for testing.
|
||
|
|
func (s *Sandbox) Open() *os.Root {
|
||
|
|
return s.root
|
||
|
|
}
|