github.com/anthropics/anthropic-sdk-go@v1.70.1/tools/agenttoolset/fs_test.go (about)

     1  package agenttoolset
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"fmt"
     7  	"os"
     8  	"path/filepath"
     9  	"runtime"
    10  	"strings"
    11  	"testing"
    12  
    13  	"github.com/stretchr/testify/require"
    14  )
    15  
    16  func mustJSON(t *testing.T, v any) json.RawMessage {
    17  	t.Helper()
    18  	b, err := json.Marshal(v)
    19  	require.NoError(t, err)
    20  	return b
    21  }
    22  
    23  func TestExecRead(t *testing.T) {
    24  	work := t.TempDir()
    25  	require.NoError(t, os.WriteFile(filepath.Join(work, "a.txt"), []byte("line1\nline2\nline3"), 0o644))
    26  	env := &AgentToolContext{Workdir: work}
    27  
    28  	tests := []struct {
    29  		description string
    30  		input       map[string]any
    31  		want        string
    32  		wantErr     bool
    33  	}{
    34  		{
    35  			description: "reading an existing file returns its full contents with no error flag",
    36  			input:       map[string]any{"file_path": "a.txt"},
    37  			want:        "line1\nline2\nline3",
    38  		},
    39  		{
    40  			description: "view_range slices the file by 1-indexed inclusive line numbers like the 1P read tool",
    41  			input:       map[string]any{"file_path": "a.txt", "view_range": []int{2, 2}},
    42  			want:        "line2",
    43  		},
    44  		{
    45  			description: "reading a missing file surfaces the os error and sets is_error so the model can retry",
    46  			input:       map[string]any{"file_path": "missing.txt"},
    47  			wantErr:     true,
    48  		},
    49  		{
    50  			description: "empty file_path is rejected with a clear validation message before touching the filesystem",
    51  			input:       map[string]any{},
    52  			wantErr:     true,
    53  		},
    54  		{
    55  			description: "file_path that escapes the workdir is rejected by the resolvePath jail",
    56  			input:       map[string]any{"file_path": "../outside.txt"},
    57  			wantErr:     true,
    58  		},
    59  		{
    60  			description: "view_range with the wrong arity is rejected so the model gets a clear error",
    61  			input:       map[string]any{"file_path": "a.txt", "view_range": []int{2}},
    62  			wantErr:     true,
    63  		},
    64  		{
    65  			description: "inverted view_range selects nothing and returns empty content rather than an error or a slice-bounds panic",
    66  			input:       map[string]any{"file_path": "a.txt", "view_range": []int{3, 1}},
    67  			want:        "",
    68  		},
    69  		{
    70  			description: "empty view_range reads the whole file like an omitted one",
    71  			input:       map[string]any{"file_path": "a.txt", "view_range": []int{}},
    72  			want:        "line1\nline2\nline3",
    73  		},
    74  		{
    75  			description: "end_line of 0 reads through to the end of the file",
    76  			input:       map[string]any{"file_path": "a.txt", "view_range": []int{2, 0}},
    77  			want:        "line2\nline3",
    78  		},
    79  		{
    80  			description: "start_line past the end of the file returns empty content",
    81  			input:       map[string]any{"file_path": "a.txt", "view_range": []int{10, 12}},
    82  			want:        "",
    83  		},
    84  	}
    85  
    86  	for _, tc := range tests {
    87  		t.Run(tc.description, func(t *testing.T) {
    88  			got, isErr := execRead(context.Background(), mustJSON(t, tc.input), env)
    89  			require.Equal(t, tc.wantErr, isErr, "is_error mismatch; output=%q", got)
    90  			if !tc.wantErr {
    91  				require.Equal(t, tc.want, got)
    92  			}
    93  		})
    94  	}
    95  }
    96  
    97  // The read tool returns file bytes verbatim: CRLF and lone-CR line endings are
    98  // never normalized, and view_range splits on "\n" only, so a trailing "\r"
    99  // stays attached to its line.
   100  func TestExecReadPreservesCRLF(t *testing.T) {
   101  	work := t.TempDir()
   102  	require.NoError(t, os.WriteFile(filepath.Join(work, "crlf.txt"), []byte("line1\r\nline2\r\nline3\r\n"), 0o644))
   103  	tool := BetaReadTool(&AgentToolContext{Workdir: work})
   104  
   105  	out, isErr := runTool(t, tool, mustJSON(t, map[string]any{"file_path": "crlf.txt"}))
   106  	require.False(t, isErr, "output=%q", out)
   107  	require.Equal(t, "line1\r\nline2\r\nline3\r\n", out)
   108  
   109  	out, isErr = runTool(t, tool, mustJSON(t, map[string]any{"file_path": "crlf.txt", "view_range": []int{1, 2}}))
   110  	require.False(t, isErr, "output=%q", out)
   111  	require.Equal(t, "line1\r\nline2\r", out)
   112  }
   113  
   114  func TestExecReadPreservesLoneCR(t *testing.T) {
   115  	work := t.TempDir()
   116  	require.NoError(t, os.WriteFile(filepath.Join(work, "cr.txt"), []byte("a\rb\rc\r"), 0o644))
   117  	tool := BetaReadTool(&AgentToolContext{Workdir: work})
   118  
   119  	out, isErr := runTool(t, tool, mustJSON(t, map[string]any{"file_path": "cr.txt"}))
   120  	require.False(t, isErr, "output=%q", out)
   121  	require.Equal(t, "a\rb\rc\r", out)
   122  
   123  	// A lone "\r" is not a line break, so the file is a single line and line 2
   124  	// is past the end.
   125  	out, isErr = runTool(t, tool, mustJSON(t, map[string]any{"file_path": "cr.txt", "view_range": []int{2, 2}}))
   126  	require.False(t, isErr, "output=%q", out)
   127  	require.Equal(t, "", out)
   128  }
   129  
   130  func TestExecWrite(t *testing.T) {
   131  	work := t.TempDir()
   132  	env := &AgentToolContext{Workdir: work}
   133  
   134  	tests := []struct {
   135  		description string
   136  		input       map[string]any
   137  		wantOnDisk  string
   138  		wantErr     bool
   139  	}{
   140  		{
   141  			description: "writing a new file creates it with the given content",
   142  			input:       map[string]any{"file_path": "new.txt", "content": "hello"},
   143  			wantOnDisk:  "hello",
   144  		},
   145  		{
   146  			description: "writing into a nested directory creates the parent directories automatically",
   147  			input:       map[string]any{"file_path": "deep/nested/f.txt", "content": "x"},
   148  			wantOnDisk:  "x",
   149  		},
   150  		{
   151  			description: "writing outside the workdir via dot-dot is rejected before any IO happens",
   152  			input:       map[string]any{"file_path": "../evil.txt", "content": "x"},
   153  			wantErr:     true,
   154  		},
   155  		{
   156  			description: "missing file_path is rejected with a validation error",
   157  			input:       map[string]any{"content": "x"},
   158  			wantErr:     true,
   159  		},
   160  	}
   161  
   162  	for _, tc := range tests {
   163  		t.Run(tc.description, func(t *testing.T) {
   164  			_, isErr := execWrite(context.Background(), mustJSON(t, tc.input), env)
   165  			require.Equal(t, tc.wantErr, isErr)
   166  			if tc.wantErr {
   167  				return
   168  			}
   169  			data, err := os.ReadFile(filepath.Join(work, tc.input["file_path"].(string)))
   170  			require.NoError(t, err)
   171  			require.Equal(t, tc.wantOnDisk, string(data))
   172  		})
   173  	}
   174  
   175  	t.Run("writing the same path again overwrites rather than appending", func(t *testing.T) {
   176  		_, isErr := execWrite(context.Background(), mustJSON(t, map[string]any{"file_path": "ow.txt", "content": "first"}), env)
   177  		require.False(t, isErr)
   178  		_, isErr = execWrite(context.Background(), mustJSON(t, map[string]any{"file_path": "ow.txt", "content": "second"}), env)
   179  		require.False(t, isErr)
   180  		data, _ := os.ReadFile(filepath.Join(work, "ow.txt"))
   181  		require.Equal(t, "second", string(data))
   182  	})
   183  }
   184  
   185  func TestExecReadRejectsSymlinkLoop(t *testing.T) {
   186  	work, _ := symlinkLoopFixture(t)
   187  	tool := BetaReadTool(&AgentToolContext{Workdir: work})
   188  
   189  	out, isErr := runTool(t, tool, mustJSON(t, map[string]any{"file_path": "loop_a"}))
   190  	require.True(t, isErr)
   191  	require.Equal(t, `read: path "loop_a": too many levels of symbolic links`, out)
   192  
   193  	for _, p := range []string{"loop_a/../evil_link", "L"} {
   194  		out, isErr := runTool(t, tool, mustJSON(t, map[string]any{"file_path": p}))
   195  		require.True(t, isErr, "file_path=%q output=%q", p, out)
   196  		require.NotContains(t, out, "SECRET", "file_path=%q", p)
   197  	}
   198  }
   199  
   200  func TestExecWriteRejectsSymlinkLoop(t *testing.T) {
   201  	work, _ := symlinkLoopFixture(t)
   202  	before, err := os.ReadDir(work)
   203  	require.NoError(t, err)
   204  
   205  	out, isErr := runTool(t, BetaWriteTool(&AgentToolContext{Workdir: work}), mustJSON(t, map[string]any{
   206  		"file_path": "loop_a/child.txt", "content": "x",
   207  	}))
   208  	require.True(t, isErr)
   209  	require.Equal(t, `write: path "loop_a/child.txt": too many levels of symbolic links`, out)
   210  
   211  	after, err := os.ReadDir(work)
   212  	require.NoError(t, err)
   213  	names := func(es []os.DirEntry) []string {
   214  		out := make([]string, len(es))
   215  		for i, e := range es {
   216  			out[i] = e.Name()
   217  		}
   218  		return out
   219  	}
   220  	require.Equal(t, names(before), names(after), "a rejected write must not create anything in the workdir")
   221  }
   222  
   223  func TestExecWriteThroughDanglingInsideSymlink(t *testing.T) {
   224  	work := t.TempDir()
   225  	require.NoError(t, os.Symlink(filepath.Join("newdir", "f.txt"), filepath.Join(work, "d")))
   226  
   227  	out, isErr := runTool(t, BetaWriteTool(&AgentToolContext{Workdir: work}), mustJSON(t, map[string]any{
   228  		"file_path": "d", "content": "via-link",
   229  	}))
   230  	require.False(t, isErr, "output=%q", out)
   231  	data, err := os.ReadFile(filepath.Join(work, "newdir", "f.txt"))
   232  	require.NoError(t, err)
   233  	require.Equal(t, "via-link", string(data))
   234  }
   235  
   236  func TestExecReadFollowsSymlinkChainInsideWorkdir(t *testing.T) {
   237  	work := t.TempDir()
   238  	require.NoError(t, os.WriteFile(filepath.Join(work, "real.txt"), []byte("target"), 0o644))
   239  	require.NoError(t, os.Symlink("real.txt", filepath.Join(work, "c2")))
   240  	require.NoError(t, os.Symlink("c2", filepath.Join(work, "c1")))
   241  	require.NoError(t, os.Symlink("c1", filepath.Join(work, "c0")))
   242  
   243  	out, isErr := runTool(t, BetaReadTool(&AgentToolContext{Workdir: work}), mustJSON(t, map[string]any{"file_path": "c0"}))
   244  	require.False(t, isErr, "output=%q", out)
   245  	require.Equal(t, "target", out)
   246  }
   247  
   248  // Only symlink reads count against the resolver's hop cap, so a new file
   249  // under more missing directories than the cap allows still resolves.
   250  func TestExecWriteDeepMissingPathIsNotASymlinkLoop(t *testing.T) {
   251  	work := t.TempDir()
   252  	parts := make([]string, 0, 51)
   253  	for i := range 50 {
   254  		parts = append(parts, fmt.Sprintf("d%d", i))
   255  	}
   256  	parts = append(parts, "f.txt")
   257  	rel := filepath.Join(parts...)
   258  
   259  	out, isErr := runTool(t, BetaWriteTool(&AgentToolContext{Workdir: work}), mustJSON(t, map[string]any{
   260  		"file_path": rel, "content": "deep",
   261  	}))
   262  	require.False(t, isErr, "output=%q", out)
   263  	data, err := os.ReadFile(filepath.Join(work, rel))
   264  	require.NoError(t, err)
   265  	require.Equal(t, "deep", string(data))
   266  }
   267  
   268  func TestExecReadUnderUnreadableDirReportsPermissionDenied(t *testing.T) {
   269  	if runtime.GOOS == "windows" || os.Geteuid() == 0 {
   270  		t.Skip("directory permission bits are not enforced for this user")
   271  	}
   272  	work := t.TempDir()
   273  	noperm := filepath.Join(work, "noperm")
   274  	require.NoError(t, os.Mkdir(noperm, 0o000))
   275  	t.Cleanup(func() { _ = os.Chmod(noperm, 0o755) })
   276  
   277  	out, isErr := runTool(t, BetaReadTool(&AgentToolContext{Workdir: work}), mustJSON(t, map[string]any{"file_path": "noperm/x"}))
   278  	require.True(t, isErr)
   279  	require.Equal(t, `read: path "noperm/x": permission denied`, out)
   280  }
   281  
   282  func TestExecEdit(t *testing.T) {
   283  	tests := []struct {
   284  		description string
   285  		initial     string
   286  		input       map[string]any
   287  		want        string
   288  		wantErr     bool
   289  	}{
   290  		{
   291  			description: "unique old_string is replaced once leaving the rest of the file untouched",
   292  			initial:     "alpha\nbeta\ngamma",
   293  			input:       map[string]any{"file_path": "f.txt", "old_string": "beta", "new_string": "BETA"},
   294  			want:        "alpha\nBETA\ngamma",
   295  		},
   296  		{
   297  			description: "ambiguous old_string without replace_all errors so the model must disambiguate",
   298  			initial:     "x\nx\n",
   299  			input:       map[string]any{"file_path": "f.txt", "old_string": "x", "new_string": "y"},
   300  			wantErr:     true,
   301  		},
   302  		{
   303  			description: "ambiguous old_string with replace_all true rewrites every occurrence",
   304  			initial:     "x\nx\n",
   305  			input:       map[string]any{"file_path": "f.txt", "old_string": "x", "new_string": "y", "replace_all": true},
   306  			want:        "y\ny\n",
   307  		},
   308  		{
   309  			description: "old_string not present in the file errors rather than silently writing an unchanged file",
   310  			initial:     "abc",
   311  			input:       map[string]any{"file_path": "f.txt", "old_string": "zzz", "new_string": "y"},
   312  			wantErr:     true,
   313  		},
   314  		{
   315  			description: "empty old_string is rejected as a validation error before reading the file",
   316  			initial:     "abc",
   317  			input:       map[string]any{"file_path": "f.txt", "old_string": "", "new_string": "y"},
   318  			wantErr:     true,
   319  		},
   320  	}
   321  
   322  	for _, tc := range tests {
   323  		t.Run(tc.description, func(t *testing.T) {
   324  			work := t.TempDir()
   325  			env := &AgentToolContext{Workdir: work}
   326  			require.NoError(t, os.WriteFile(filepath.Join(work, "f.txt"), []byte(tc.initial), 0o644))
   327  
   328  			_, isErr := execEdit(context.Background(), mustJSON(t, tc.input), env)
   329  			require.Equal(t, tc.wantErr, isErr)
   330  			if tc.wantErr {
   331  				return
   332  			}
   333  			data, err := os.ReadFile(filepath.Join(work, "f.txt"))
   334  			require.NoError(t, err)
   335  			require.Equal(t, tc.want, string(data))
   336  		})
   337  	}
   338  }
   339  
   340  func TestExecEditRejectsOversizedFile(t *testing.T) {
   341  	work := t.TempDir()
   342  	require.NoError(t, os.WriteFile(filepath.Join(work, "big.txt"), make([]byte, defaultMaxFileBytes+1), 0o644))
   343  	out, isErr := runTool(t, BetaEditTool(&AgentToolContext{Workdir: work}), mustJSON(t, map[string]any{
   344  		"file_path": "big.txt", "old_string": "a", "new_string": "b",
   345  	}))
   346  	require.True(t, isErr)
   347  	require.Contains(t, out, "exceeds")
   348  	require.NotContains(t, out, "bash", "the toolset may not include bash, so the error must not point at it")
   349  }
   350  
   351  func TestExecEditRejectsDirectory(t *testing.T) {
   352  	work := t.TempDir()
   353  	require.NoError(t, os.Mkdir(filepath.Join(work, "sub"), 0o755))
   354  	out, isErr := runTool(t, BetaEditTool(&AgentToolContext{Workdir: work}), mustJSON(t, map[string]any{
   355  		"file_path": "sub", "old_string": "a", "new_string": "b",
   356  	}))
   357  	require.True(t, isErr)
   358  	require.Contains(t, out, "not a regular file")
   359  }
   360  
   361  func TestExecEditAllowsNormalFile(t *testing.T) {
   362  	work := t.TempDir()
   363  	require.NoError(t, os.WriteFile(filepath.Join(work, "f.txt"), []byte("alpha\nbeta\ngamma"), 0o644))
   364  	out, isErr := runTool(t, BetaEditTool(&AgentToolContext{Workdir: work}), mustJSON(t, map[string]any{
   365  		"file_path": "f.txt", "old_string": "beta", "new_string": "BETA",
   366  	}))
   367  	require.False(t, isErr, "output=%q", out)
   368  	data, err := os.ReadFile(filepath.Join(work, "f.txt"))
   369  	require.NoError(t, err)
   370  	require.Equal(t, "alpha\nBETA\ngamma", string(data))
   371  }
   372  
   373  func TestExecEditPreservesCRLF(t *testing.T) {
   374  	work := t.TempDir()
   375  	require.NoError(t, os.WriteFile(filepath.Join(work, "crlf.txt"), []byte("line1\r\nline2\r\nline3\r\n"), 0o644))
   376  	out, isErr := runTool(t, BetaEditTool(&AgentToolContext{Workdir: work}), mustJSON(t, map[string]any{
   377  		"file_path": "crlf.txt", "old_string": "line2", "new_string": "LINE2",
   378  	}))
   379  	require.False(t, isErr, "output=%q", out)
   380  	data, err := os.ReadFile(filepath.Join(work, "crlf.txt"))
   381  	require.NoError(t, err)
   382  	require.Equal(t, []byte("line1\r\nLINE2\r\nline3\r\n"), data)
   383  }
   384  
   385  func TestExecReadRejectsOversizedFile(t *testing.T) {
   386  	work := t.TempDir()
   387  	require.NoError(t, os.WriteFile(filepath.Join(work, "big.txt"), make([]byte, defaultMaxFileBytes+1), 0o644))
   388  	out, isErr := runTool(t, BetaReadTool(&AgentToolContext{Workdir: work}), mustJSON(t, map[string]any{"file_path": "big.txt"}))
   389  	require.True(t, isErr)
   390  	require.Contains(t, out, "exceeds")
   391  	require.Contains(t, out, "view_range", "the error should steer the model to a ranged read")
   392  	require.NotContains(t, out, "bash", "the toolset may not include bash, so the error must not point at it")
   393  }
   394  
   395  func TestExecReadRejectsDirectory(t *testing.T) {
   396  	work := t.TempDir()
   397  	require.NoError(t, os.Mkdir(filepath.Join(work, "sub"), 0o755))
   398  	out, isErr := runTool(t, BetaReadTool(&AgentToolContext{Workdir: work}), mustJSON(t, map[string]any{"file_path": "sub"}))
   399  	require.True(t, isErr)
   400  	require.Contains(t, out, "not a regular file")
   401  }
   402  
   403  // markedFile writes a file whose contents are the unique marker "OLD" followed
   404  // by pad zero bytes, so the edit tool has a unique old_string to replace while
   405  // the file's total size is controllable.
   406  func markedFile(t *testing.T, dir string, pad int) {
   407  	t.Helper()
   408  	content := append([]byte("OLD"), make([]byte, pad)...)
   409  	require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), content, 0o644))
   410  }
   411  
   412  func TestExecEditCustomMaxBytesRejectsBelowCap(t *testing.T) {
   413  	work := t.TempDir()
   414  	markedFile(t, work, 2000) // ~2 KiB file
   415  	out, isErr := runTool(t, BetaEditTool(&AgentToolContext{Workdir: work, MaxFileBytes: 1000}), mustJSON(t, map[string]any{
   416  		"file_path": "f.txt", "old_string": "OLD", "new_string": "NEW",
   417  	}))
   418  	require.True(t, isErr)
   419  	require.Contains(t, out, "exceeds")
   420  	require.NotContains(t, out, "bash")
   421  }
   422  
   423  func TestExecEditCustomMaxBytesAllowsAboveDefault(t *testing.T) {
   424  	work := t.TempDir()
   425  	markedFile(t, work, defaultMaxFileBytes) // just over the built-in default
   426  	out, isErr := runTool(t, BetaEditTool(&AgentToolContext{Workdir: work, MaxFileBytes: defaultMaxFileBytes * 2}), mustJSON(t, map[string]any{
   427  		"file_path": "f.txt", "old_string": "OLD", "new_string": "NEW",
   428  	}))
   429  	require.False(t, isErr, "output=%q", out)
   430  	data, err := os.ReadFile(filepath.Join(work, "f.txt"))
   431  	require.NoError(t, err)
   432  	require.True(t, len(data) > defaultMaxFileBytes && string(data[:3]) == "NEW")
   433  }
   434  
   435  func TestExecEditUncappedAllowsOversized(t *testing.T) {
   436  	work := t.TempDir()
   437  	markedFile(t, work, defaultMaxFileBytes) // would exceed the default cap
   438  	out, isErr := runTool(t, BetaEditTool(&AgentToolContext{Workdir: work, MaxFileBytes: -1}), mustJSON(t, map[string]any{
   439  		"file_path": "f.txt", "old_string": "OLD", "new_string": "NEW",
   440  	}))
   441  	require.False(t, isErr, "output=%q", out)
   442  }
   443  
   444  func TestExecEditRejectsDirectoryEvenWhenUncapped(t *testing.T) {
   445  	work := t.TempDir()
   446  	require.NoError(t, os.Mkdir(filepath.Join(work, "sub"), 0o755))
   447  	out, isErr := runTool(t, BetaEditTool(&AgentToolContext{Workdir: work, MaxFileBytes: -1}), mustJSON(t, map[string]any{
   448  		"file_path": "sub", "old_string": "a", "new_string": "b",
   449  	}))
   450  	require.True(t, isErr)
   451  	require.Contains(t, out, "not a regular file")
   452  }
   453  
   454  func TestExecReadCustomMaxBytesRejectsBelowCap(t *testing.T) {
   455  	work := t.TempDir()
   456  	require.NoError(t, os.WriteFile(filepath.Join(work, "f.txt"), make([]byte, 2000), 0o644))
   457  	out, isErr := runTool(t, BetaReadTool(&AgentToolContext{Workdir: work, MaxFileBytes: 1000}), mustJSON(t, map[string]any{"file_path": "f.txt"}))
   458  	require.True(t, isErr)
   459  	require.Contains(t, out, "exceeds")
   460  	require.Contains(t, out, "view_range")
   461  	require.NotContains(t, out, "bash")
   462  }
   463  
   464  func TestExecReadViewRangeStreamsFileOverCap(t *testing.T) {
   465  	work := t.TempDir()
   466  	// 18 bytes, over the 16-byte cap below.
   467  	require.NoError(t, os.WriteFile(filepath.Join(work, "a.txt"), []byte("line1\nline2\nline3\n"), 0o644))
   468  	// A single 100-byte line followed by a short one.
   469  	require.NoError(t, os.WriteFile(filepath.Join(work, "long.txt"), []byte(strings.Repeat("x", 100)+"\nok\n"), 0o644))
   470  	env := &AgentToolContext{Workdir: work, MaxFileBytes: 16}
   471  
   472  	tests := []struct {
   473  		description  string
   474  		input        map[string]any
   475  		want         string
   476  		wantErr      bool
   477  		errContains  string
   478  		errOmitsBash bool
   479  	}{
   480  		{
   481  			description: "a single line of a file over the cap is streamed and returned",
   482  			input:       map[string]any{"file_path": "a.txt", "view_range": []int{2, 2}},
   483  			want:        "line2",
   484  		},
   485  		{
   486  			description: "end_line of 0 streams through to the end of the file and keeps the trailing newline like the whole-file path",
   487  			input:       map[string]any{"file_path": "a.txt", "view_range": []int{2, 0}},
   488  			want:        "line2\nline3\n",
   489  		},
   490  		{
   491  			description:  "a view_range whose selected lines exceed the cap is rejected with advice to narrow it, not to use bash",
   492  			input:        map[string]any{"file_path": "a.txt", "view_range": []int{1, 3}},
   493  			wantErr:      true,
   494  			errContains:  "Narrow the view_range",
   495  			errOmitsBash: true,
   496  		},
   497  		{
   498  			description:  "a single line that alone exceeds the cap is rejected because read cannot return part of a line",
   499  			input:        map[string]any{"file_path": "long.txt", "view_range": []int{1, 1}},
   500  			wantErr:      true,
   501  			errContains:  "cannot return part of a line",
   502  			errOmitsBash: true,
   503  		},
   504  		{
   505  			description: "a short line after an over-cap line is still reachable",
   506  			input:       map[string]any{"file_path": "long.txt", "view_range": []int{2, 2}},
   507  			want:        "ok",
   508  		},
   509  		{
   510  			description: "an inverted view_range on a file over the cap selects nothing rather than erroring",
   511  			input:       map[string]any{"file_path": "a.txt", "view_range": []int{3, 1}},
   512  			want:        "",
   513  		},
   514  		{
   515  			description: "start_line past the end of a file over the cap returns empty content",
   516  			input:       map[string]any{"file_path": "a.txt", "view_range": []int{10, 12}},
   517  			want:        "",
   518  		},
   519  		{
   520  			description: "view_range with the wrong arity is rejected before the size check",
   521  			input:       map[string]any{"file_path": "a.txt", "view_range": []int{2}},
   522  			wantErr:     true,
   523  			errContains: "view_range must be [start_line, end_line]",
   524  		},
   525  	}
   526  
   527  	for _, tc := range tests {
   528  		t.Run(tc.description, func(t *testing.T) {
   529  			got, isErr := execRead(context.Background(), mustJSON(t, tc.input), env)
   530  			require.Equal(t, tc.wantErr, isErr, "is_error mismatch; output=%q", got)
   531  			if !tc.wantErr {
   532  				require.Equal(t, tc.want, got)
   533  				return
   534  			}
   535  			require.Contains(t, got, tc.errContains)
   536  			if tc.errOmitsBash {
   537  				require.NotContains(t, got, "bash")
   538  			}
   539  		})
   540  	}
   541  }
   542  
   543  func TestExecReadViewRangeStreamsAcrossChunkBoundaries(t *testing.T) {
   544  	work := t.TempDir()
   545  	// ~500 KB, over the default cap and several read chunks long, so chunk
   546  	// boundaries land mid-line.
   547  	var sb strings.Builder
   548  	rows := make([]string, 0, 5000)
   549  	for i := 1; i <= 5000; i++ {
   550  		row := fmt.Sprintf("row%04d", i) + strings.Repeat(".", 90)
   551  		rows = append(rows, row)
   552  		sb.WriteString(row)
   553  		sb.WriteByte('\n')
   554  	}
   555  	require.Greater(t, sb.Len(), defaultMaxFileBytes)
   556  	require.Greater(t, sb.Len(), 2*readChunkBytes)
   557  	require.NoError(t, os.WriteFile(filepath.Join(work, "big.txt"), []byte(sb.String()), 0o644))
   558  
   559  	got, isErr := execRead(context.Background(), mustJSON(t, map[string]any{"file_path": "big.txt", "view_range": []int{1000, 1002}}), &AgentToolContext{Workdir: work})
   560  	require.False(t, isErr, "output=%q", got)
   561  	require.Equal(t, strings.Join(rows[999:1002], "\n"), got)
   562  }
   563  
   564  func TestExecReadUncappedAllowsOversized(t *testing.T) {
   565  	work := t.TempDir()
   566  	require.NoError(t, os.WriteFile(filepath.Join(work, "big.txt"), make([]byte, defaultMaxFileBytes+1), 0o644))
   567  	_, isErr := runTool(t, BetaReadTool(&AgentToolContext{Workdir: work, MaxFileBytes: -1}), mustJSON(t, map[string]any{"file_path": "big.txt"}))
   568  	require.False(t, isErr)
   569  }
   570  
   571  func TestExecReadRejectsDirectoryEvenWhenUncapped(t *testing.T) {
   572  	work := t.TempDir()
   573  	require.NoError(t, os.Mkdir(filepath.Join(work, "sub"), 0o755))
   574  	out, isErr := runTool(t, BetaReadTool(&AgentToolContext{Workdir: work, MaxFileBytes: -1}), mustJSON(t, map[string]any{"file_path": "sub"}))
   575  	require.True(t, isErr)
   576  	require.Contains(t, out, "not a regular file")
   577  }
   578  
   579  // TestFileToolsConfinementMatrix drives the full path policy through the real
   580  // tools: the workdir works, a memory folder mounted outside it works, a
   581  // read-only memory folder reads but refuses writes, and everywhere else is
   582  // refused.
   583  func TestFileToolsConfinementMatrix(t *testing.T) {
   584  	tmp := t.TempDir()
   585  	work := filepath.Join(tmp, "work")
   586  	notes := filepath.Join(tmp, "mnt", "notes")
   587  	facts := filepath.Join(tmp, "mnt", "facts")
   588  	outside := filepath.Join(tmp, "outside")
   589  	for _, d := range []string{work, notes, facts, outside} {
   590  		require.NoError(t, os.MkdirAll(d, 0o755))
   591  	}
   592  	require.NoError(t, os.WriteFile(filepath.Join(facts, "facts.md"), []byte("immutable"), 0o644))
   593  	require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0o644))
   594  
   595  	env := &AgentToolContext{Workdir: work, AllowedRoots: []string{notes, facts}, ReadOnlyRoots: []string{facts}}
   596  	read, write, edit := BetaReadTool(env), BetaWriteTool(env), BetaEditTool(env)
   597  	const refused = "is outside the session's working directory and its other permitted directories"
   598  
   599  	msg, isErr := runTool(t, write, mustJSON(t, map[string]any{"file_path": "w.txt", "content": "in workdir"}))
   600  	require.False(t, isErr, msg)
   601  	got, err := os.ReadFile(filepath.Join(work, "w.txt"))
   602  	require.NoError(t, err)
   603  	require.Equal(t, "in workdir", string(got))
   604  
   605  	note := filepath.Join(notes, "note.md")
   606  	msg, isErr = runTool(t, write, mustJSON(t, map[string]any{"file_path": note, "content": "remembered"}))
   607  	require.False(t, isErr, msg)
   608  	msg, isErr = runTool(t, read, mustJSON(t, map[string]any{"file_path": note}))
   609  	require.False(t, isErr, msg)
   610  	require.Equal(t, "remembered", msg)
   611  	msg, isErr = runTool(t, edit, mustJSON(t, map[string]any{"file_path": note, "old_string": "remembered", "new_string": "edited"}))
   612  	require.False(t, isErr, msg)
   613  	got, err = os.ReadFile(note)
   614  	require.NoError(t, err)
   615  	require.Equal(t, "edited", string(got))
   616  
   617  	msg, isErr = runTool(t, read, mustJSON(t, map[string]any{"file_path": filepath.Join(facts, "facts.md")}))
   618  	require.False(t, isErr, msg)
   619  	require.Equal(t, "immutable", msg)
   620  	msg, isErr = runTool(t, write, mustJSON(t, map[string]any{"file_path": filepath.Join(facts, "facts.md"), "content": "x"}))
   621  	require.True(t, isErr)
   622  	require.Contains(t, msg, "read-only")
   623  	got, err = os.ReadFile(filepath.Join(facts, "facts.md"))
   624  	require.NoError(t, err)
   625  	require.Equal(t, "immutable", string(got))
   626  
   627  	msg, isErr = runTool(t, read, mustJSON(t, map[string]any{"file_path": filepath.Join(outside, "secret.txt")}))
   628  	require.True(t, isErr)
   629  	require.Contains(t, msg, refused)
   630  	msg, isErr = runTool(t, write, mustJSON(t, map[string]any{"file_path": filepath.Join(outside, "new.txt"), "content": "x"}))
   631  	require.True(t, isErr)
   632  	require.Contains(t, msg, refused)
   633  	require.NoFileExists(t, filepath.Join(outside, "new.txt"))
   634  }
   635  
   636  func TestWriteAndEditToolsRefuseReadOnlyRoots(t *testing.T) {
   637  	work := t.TempDir()
   638  	ro := filepath.Join(work, "memory", "notes")
   639  	require.NoError(t, os.MkdirAll(ro, 0o755))
   640  	require.NoError(t, os.WriteFile(filepath.Join(ro, "facts.md"), []byte("immutable"), 0o644))
   641  
   642  	env := &AgentToolContext{Workdir: work, ReadOnlyRoots: []string{ro}}
   643  	write := BetaWriteTool(env)
   644  	edit := BetaEditTool(env)
   645  
   646  	// Overwriting an existing file, creating a new one, and editing in place
   647  	// are all refused with an error naming the read-only directory.
   648  	for _, input := range []map[string]any{
   649  		{"file_path": "memory/notes/facts.md", "content": "overwritten"},
   650  		{"file_path": "memory/notes/new.md", "content": "new"},
   651  	} {
   652  		msg, isErr := runTool(t, write, mustJSON(t, input))
   653  		require.True(t, isErr, "write into a read-only root must fail: %v", input)
   654  		require.Contains(t, msg, "read-only")
   655  	}
   656  	msg, isErr := runTool(t, edit, mustJSON(t, map[string]any{
   657  		"file_path": "memory/notes/facts.md", "old_string": "immutable", "new_string": "x",
   658  	}))
   659  	require.True(t, isErr)
   660  	require.Contains(t, msg, "read-only")
   661  
   662  	got, err := os.ReadFile(filepath.Join(ro, "facts.md"))
   663  	require.NoError(t, err)
   664  	require.Equal(t, "immutable", string(got))
   665  	require.NoFileExists(t, filepath.Join(ro, "new.md"))
   666  
   667  	// Paths outside the read-only root are unaffected.
   668  	msg, isErr = runTool(t, write, mustJSON(t, map[string]any{"file_path": "scratch.txt", "content": "ok"}))
   669  	require.False(t, isErr, msg)
   670  	got, err = os.ReadFile(filepath.Join(work, "scratch.txt"))
   671  	require.NoError(t, err)
   672  	require.Equal(t, "ok", string(got))
   673  }