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 }