rony-llm-agent/pkg/tools/sandbox/sandbox_test.go

304 lines
8.4 KiB
Go
Raw Permalink Normal View History

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)
}
}