package sandbox_test import ( "encoding/json" "os" "path/filepath" "testing" "github.com/VictorVargas/rony-llm-agent/pkg/llm" "github.com/VictorVargas/rony-llm-agent/pkg/tools" "github.com/VictorVargas/rony-llm-agent/pkg/tools/sandbox" ) func TestNewSandbox(t *testing.T) { dir := t.TempDir() sb, err := sandbox.NewSandbox(dir) if err != nil { t.Fatalf("unexpected error: %v", err) } if sb == nil { t.Fatal("expected non-nil sandbox") } } func TestValidatePath_Allowed(t *testing.T) { dir := t.TempDir() sb, err := sandbox.NewSandbox(dir) if err != nil { t.Fatalf("unexpected error: %v", err) } // Create a file inside the sandbox testFile := filepath.Join(dir, "test.txt") if err := os.WriteFile(testFile, []byte("hello"), 0644); err != nil { t.Fatalf("creating test file: %v", err) } call := llm.ToolCall{ Name: "read_file", Arguments: json.RawMessage(`{"path": "test.txt"}`), } err = sb.ValidateToolCall(tools.Tool{}, call) if err != nil { t.Fatalf("expected no error, got: %v", err) } } func TestValidatePath_EscapesSandbox(t *testing.T) { dir := t.TempDir() sb, err := sandbox.NewSandbox(dir) if err != nil { t.Fatalf("unexpected error: %v", err) } call := llm.ToolCall{ Name: "read_file", Arguments: json.RawMessage(`{"path": "/etc/passwd"}`), } err = sb.ValidateToolCall(tools.Tool{}, call) if err == nil { t.Fatal("expected sandbox violation error") } } func TestValidatePath_SymlinkEscape(t *testing.T) { dir := t.TempDir() // Create a symlink that escapes the sandbox escapeDir := t.TempDir() symlinkPath := filepath.Join(dir, "link") if err := os.Symlink(escapeDir, symlinkPath); err != nil { t.Fatalf("creating symlink: %v", err) } sb, err := sandbox.NewSandbox(dir) if err != nil { t.Fatalf("unexpected error: %v", err) } // The sandbox should allow symlinks inside the root call := llm.ToolCall{ Name: "read_file", Arguments: json.RawMessage(`{"path": "link"}`), } err = sb.ValidateToolCall(tools.Tool{}, call) if err != nil { t.Fatalf("expected symlink to be allowed (path is inside sandbox), got: %v", err) } } func TestValidatePath_NonExistentFile(t *testing.T) { dir := t.TempDir() sb, err := sandbox.NewSandbox(dir) if err != nil { t.Fatalf("unexpected error: %v", err) } // Non-existent file inside sandbox should be allowed call := llm.ToolCall{ Name: "read_file", Arguments: json.RawMessage(`{"path": "nonexistent.txt"}`), } err = sb.ValidateToolCall(tools.Tool{}, call) if err != nil { t.Fatalf("expected no error for non-existent file, got: %v", err) } } func TestValidatePath_InvalidJSON(t *testing.T) { dir := t.TempDir() sb, err := sandbox.NewSandbox(dir) if err != nil { t.Fatalf("unexpected error: %v", err) } call := llm.ToolCall{ Name: "read_file", Arguments: json.RawMessage(`not json`), } err = sb.ValidateToolCall(tools.Tool{}, call) if err == nil { t.Fatal("expected error for invalid JSON") } } // TestValidatePath_RelativeTraversalEscapes is the important case the // absolute-path check in TestValidatePath_EscapesSandbox doesn't cover: a // *relative* path that climbs out of the sandbox root with "..". This must // be caught by the join+clean+prefix check in validatePath, not by the // early filepath.IsAbs rejection. func TestValidatePath_RelativeTraversalEscapes(t *testing.T) { dir := t.TempDir() sb, err := sandbox.NewSandbox(dir) if err != nil { t.Fatalf("unexpected error: %v", err) } cases := []string{ "../outside.txt", "../../etc/passwd", "sub/../../outside.txt", } for _, path := range cases { call := llm.ToolCall{ Name: "read_file", Arguments: json.RawMessage(`{"path": "` + path + `"}`), } if err := sb.ValidateToolCall(tools.Tool{}, call); err == nil { t.Errorf("expected %q to be rejected as a sandbox escape, got no error", path) } } } // TestValidatePath_RelativeTraversalStayingInsideIsAllowed makes sure the // traversal check isn't so strict it rejects "../" segments that still // resolve back inside the sandbox root once cleaned. func TestValidatePath_RelativeTraversalStayingInsideIsAllowed(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { t.Fatalf("setup mkdir: %v", err) } sb, err := sandbox.NewSandbox(dir) if err != nil { t.Fatalf("unexpected error: %v", err) } call := llm.ToolCall{ Name: "read_file", Arguments: json.RawMessage(`{"path": "sub/../file.txt"}`), } if err := sb.ValidateToolCall(tools.Tool{}, call); err != nil { t.Fatalf("expected path resolving back inside the sandbox to be allowed, got: %v", err) } } // TestValidatePath_PathInsideStringArray covers extractPaths' handling of // []interface{} arguments (e.g. a tool that takes a list of file paths), // which none of the single-"path"-key tests above exercise. func TestValidatePath_PathInsideStringArray(t *testing.T) { dir := t.TempDir() sb, err := sandbox.NewSandbox(dir) if err != nil { t.Fatalf("unexpected error: %v", err) } call := llm.ToolCall{ Name: "read_many", Arguments: json.RawMessage(`{"paths": ["ok.txt", "../../etc/passwd"]}`), } if err := sb.ValidateToolCall(tools.Tool{}, call); err == nil { t.Fatal("expected the escaping path inside the array to be rejected") } } // TestValidatePath_AllPathsInStringArrayAllowed is the allowed counterpart: // every element of the array stays inside the sandbox. func TestValidatePath_AllPathsInStringArrayAllowed(t *testing.T) { dir := t.TempDir() sb, err := sandbox.NewSandbox(dir) if err != nil { t.Fatalf("unexpected error: %v", err) } call := llm.ToolCall{ Name: "read_many", Arguments: json.RawMessage(`{"paths": ["a.txt", "b/c.txt"]}`), } if err := sb.ValidateToolCall(tools.Tool{}, call); err != nil { t.Fatalf("expected all-inside array to be allowed, got: %v", err) } } // TestValidatePath_OneOfSeveralArgsEscapes verifies that a call with several // argument keys is rejected if *any* of them is a path-like value that // escapes, not just when the single "path" key does. func TestValidatePath_OneOfSeveralArgsEscapes(t *testing.T) { dir := t.TempDir() sb, err := sandbox.NewSandbox(dir) if err != nil { t.Fatalf("unexpected error: %v", err) } call := llm.ToolCall{ Name: "copy_file", Arguments: json.RawMessage(`{"from": "safe.txt", "to": "../../etc/passwd", "note": "hello world"}`), } if err := sb.ValidateToolCall(tools.Tool{}, call); err == nil { t.Fatal("expected the escaping 'to' argument to reject the whole call") } } // TestValidatePath_NonPathStringsIgnored ensures ordinary string arguments // that don't look like paths (no leading ./, ../, /, and no "word.ext" // shape) are never treated as paths and can't accidentally trip the // sandbox check. func TestValidatePath_NonPathStringsIgnored(t *testing.T) { dir := t.TempDir() sb, err := sandbox.NewSandbox(dir) if err != nil { t.Fatalf("unexpected error: %v", err) } call := llm.ToolCall{ Name: "search", Arguments: json.RawMessage(`{"query": "hello world", "count": 5, "enabled": true}`), } if err := sb.ValidateToolCall(tools.Tool{}, call); err != nil { t.Fatalf("expected non-path-like arguments to be ignored, got: %v", err) } } // TestNewSandbox_RootIsAFileNotADirectory exercises the MkdirAll error // branch: passing a path that already exists as a regular file can't be // turned into a sandbox root. func TestNewSandbox_RootIsAFileNotADirectory(t *testing.T) { dir := t.TempDir() filePath := filepath.Join(dir, "not-a-dir") if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { t.Fatalf("setup: %v", err) } if _, err := sandbox.NewSandbox(filePath); err == nil { t.Fatal("expected an error when the sandbox root is an existing file") } } // TestSandboxOpen checks the escape hatch used by tests: it must return a // usable, non-nil os.Root for the sandbox that was created. func TestSandboxOpen(t *testing.T) { dir := t.TempDir() sb, err := sandbox.NewSandbox(dir) if err != nil { t.Fatalf("unexpected error: %v", err) } if sb.Open() == nil { t.Fatal("expected Open() to return a non-nil os.Root") } } func TestValidatePath_NoPathsInArgs(t *testing.T) { dir := t.TempDir() sb, err := sandbox.NewSandbox(dir) if err != nil { t.Fatalf("unexpected error: %v", err) } // Arguments with no paths should not cause errors call := llm.ToolCall{ Name: "math_add", Arguments: json.RawMessage(`{"a": 5, "b": 10}`), } err = sb.ValidateToolCall(tools.Tool{}, call) if err != nil { t.Fatalf("expected no error, got: %v", err) } }