- agent/loop.go: Record assistant message with ToolCalls before tool results, and set ToolCallID on tool-result messages so follow-up requests have complete context (prevents models from losing track of already-attempted tools). - llm/providers/llamacpp/client.go: Buffer fragmented tool call deltas during streaming, assemble them into complete calls when finish_reason arrives. Add ToolCalls, ToolCallID, Name fields to request building. - llm/providers/openai/client.go: Send ToolCalls, ToolCallID, Name when building chat requests so messages are wire-format correct. - llm/types.go: Add ToolCalls field to Message struct for serialization back into conversation history. - agent/integration_test.go: Move integration test skip from TestMain to a per-test skipUnlessIntegration() so it doesn't hide other package tests. - sandbox & tools: Add edge-case tests (relative traversal, array paths, non-path strings, zero-value guards, sentinel errors).
303 lines
8.4 KiB
Go
303 lines
8.4 KiB
Go
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)
|
|
}
|
|
}
|