github.com/anthropics/anthropic-sdk-go@v1.70.1/tools/agenttoolset/agenttoolset_test.go (about) 1 package agenttoolset 2 3 import ( 4 "context" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "os" 9 "path/filepath" 10 "testing" 11 "time" 12 13 anthropic "github.com/anthropics/anthropic-sdk-go" 14 "github.com/stretchr/testify/require" 15 ) 16 17 func TestResolvePath(t *testing.T) { 18 work := t.TempDir() 19 // Canonicalise the workdir the way resolvePath does so comparisons hold on 20 // platforms where the temp dir lives behind a symlink (e.g. /var on macOS). 21 absWork := canonicalRoot(work) 22 23 tests := []struct { 24 description string 25 env *AgentToolContext 26 input string 27 want string 28 wantErr string 29 }{ 30 { 31 description: "plain relative file resolves under the workdir", 32 env: &AgentToolContext{Workdir: work}, 33 input: "a.txt", 34 want: filepath.Join(absWork, "a.txt"), 35 }, 36 { 37 description: "nested relative path resolves with intermediate directories joined", 38 env: &AgentToolContext{Workdir: work}, 39 input: filepath.Join("sub", "b.txt"), 40 want: filepath.Join(absWork, "sub", "b.txt"), 41 }, 42 { 43 description: "dot-dot that climbs out of the workdir is rejected to keep tools jailed by default", 44 env: &AgentToolContext{Workdir: work}, 45 input: filepath.Join("..", "escape.txt"), 46 wantErr: `path "../escape.txt" is outside the session's working directory`, 47 }, 48 { 49 description: "dot-dot that stays inside the workdir after normalisation is permitted", 50 env: &AgentToolContext{Workdir: work}, 51 input: filepath.Join("sub", "..", "c.txt"), 52 want: filepath.Join(absWork, "c.txt"), 53 }, 54 { 55 description: "absolute path outside workdir is rejected", 56 env: &AgentToolContext{Workdir: work}, 57 input: "/etc/passwd", 58 wantErr: `path "/etc/passwd" is outside the session's working directory`, 59 }, 60 { 61 description: "absolute path inside workdir is permitted", 62 env: &AgentToolContext{Workdir: work}, 63 input: filepath.Join(absWork, "a.txt"), 64 want: filepath.Join(absWork, "a.txt"), 65 }, 66 { 67 description: "absolute path naming the workdir itself is permitted", 68 env: &AgentToolContext{Workdir: work}, 69 input: absWork, 70 want: absWork, 71 }, 72 { 73 description: "absolute sibling that string-prefixes the workdir is rejected (segment-aware contain)", 74 env: &AgentToolContext{Workdir: work}, 75 input: filepath.Join(absWork+"extra", "secret.txt"), 76 wantErr: "is outside the session's working directory", 77 }, 78 } 79 80 for _, tc := range tests { 81 t.Run(tc.description, func(t *testing.T) { 82 got, err := resolvePath(tc.env, tc.input) 83 if tc.wantErr != "" { 84 require.ErrorContains(t, err, tc.wantErr) 85 return 86 } 87 require.NoError(t, err) 88 require.Equal(t, tc.want, got) 89 }) 90 } 91 } 92 93 // TestResolvePathAllowedRoots covers the second tier of permitted roots: a 94 // path under an AllowedRoots entry resolves, everything outside Workdir and 95 // every entry is refused, and the refusal only mentions "other permitted 96 // directories" when a non-empty entry exists. 97 func TestResolvePathAllowedRoots(t *testing.T) { 98 tmp := canonicalRoot(t.TempDir()) 99 work := filepath.Join(tmp, "work") 100 mount := filepath.Join(tmp, "mount") 101 for _, d := range []string{work, mount, mount + "2", filepath.Join(tmp, "cwd")} { 102 require.NoError(t, os.Mkdir(d, 0o755)) 103 } 104 require.NoError(t, os.WriteFile(filepath.Join(mount, "one.md"), []byte("1"), 0o644)) 105 106 const ( 107 short = "is outside the session's working directory" 108 long = "is outside the session's working directory and its other permitted directories" 109 ) 110 tests := []struct { 111 description string 112 allowedRoots []string 113 input string 114 want string 115 wantErr string 116 }{ 117 { 118 description: "absolute path under an allowed root resolves to its canonical form", 119 allowedRoots: []string{mount}, 120 input: filepath.Join(mount, "note.md"), 121 want: filepath.Join(mount, "note.md"), 122 }, 123 { 124 description: "relative path still resolves under the workdir when allowed roots are set", 125 allowedRoots: []string{mount}, 126 input: "inside.txt", 127 want: filepath.Join(work, "inside.txt"), 128 }, 129 { 130 description: "absolute path outside the workdir and every allowed root is refused with the long message", 131 allowedRoots: []string{mount}, 132 input: filepath.Join(tmp, "elsewhere", "x"), 133 wantErr: long, 134 }, 135 { 136 description: "relative escape outside every root is refused with the long message", 137 allowedRoots: []string{mount}, 138 input: filepath.Join("..", "elsewhere", "x"), 139 wantErr: long, 140 }, 141 { 142 description: "sibling that string-prefixes an allowed root is refused (segment-aware for allowed roots too)", 143 allowedRoots: []string{mount}, 144 input: filepath.Join(mount+"2", "x"), 145 wantErr: long, 146 }, 147 { 148 description: "an allowed root that is a regular file permits exactly that file", 149 allowedRoots: []string{filepath.Join(mount, "one.md")}, 150 input: filepath.Join(mount, "one.md"), 151 want: filepath.Join(mount, "one.md"), 152 }, 153 { 154 description: "an allowed root that is a regular file does not permit its siblings", 155 allowedRoots: []string{filepath.Join(mount, "one.md")}, 156 input: filepath.Join(mount, "two.md"), 157 wantErr: long, 158 }, 159 { 160 description: "the filesystem root as an allowed root permits everything", 161 allowedRoots: []string{"/"}, 162 input: filepath.Join(tmp, "elsewhere", "x"), 163 want: filepath.Join(tmp, "elsewhere", "x"), 164 }, 165 { 166 description: "an empty AllowedRoots list refuses with the short message", 167 allowedRoots: []string{}, 168 input: "/etc/passwd", 169 wantErr: `path "/etc/passwd" ` + short, 170 }, 171 { 172 description: "an empty entry grants nothing and does not count as another permitted directory", 173 allowedRoots: []string{""}, 174 input: filepath.Join(tmp, "cwd", "x"), 175 wantErr: `" ` + short, 176 }, 177 } 178 179 // An empty entry must not silently mean the process working directory. 180 t.Chdir(filepath.Join(tmp, "cwd")) 181 for _, tc := range tests { 182 t.Run(tc.description, func(t *testing.T) { 183 env := &AgentToolContext{Workdir: work, AllowedRoots: tc.allowedRoots} 184 got, err := resolvePath(env, tc.input) 185 if tc.wantErr != "" { 186 require.ErrorContains(t, err, tc.wantErr) 187 if tc.wantErr != long { 188 require.NotContains(t, err.Error(), "other permitted directories") 189 } 190 return 191 } 192 require.NoError(t, err) 193 require.Equal(t, tc.want, got) 194 }) 195 } 196 } 197 198 func TestResolvePathRejectsUnrestrictedPaths(t *testing.T) { 199 env := &AgentToolContext{Workdir: t.TempDir(), UnrestrictedPaths: true} 200 _, err := resolvePath(env, "a.txt") 201 require.ErrorIs(t, err, ErrUnrestrictedPathsUnsupported) 202 } 203 204 // TestResolvePathSymlinkOutOfAllowedRootIsRejected: symlinks are canonicalised 205 // before the containment check, so a link inside an allowed root cannot reach 206 // outside it. 207 func TestResolvePathSymlinkOutOfAllowedRootIsRejected(t *testing.T) { 208 work := t.TempDir() 209 mount := t.TempDir() 210 require.NoError(t, os.Symlink("/etc/passwd", filepath.Join(mount, "leak"))) 211 env := &AgentToolContext{Workdir: work, AllowedRoots: []string{mount}} 212 _, err := resolvePath(env, filepath.Join(mount, "leak")) 213 require.ErrorContains(t, err, "is outside the session's working directory and its other permitted directories") 214 } 215 216 // TestASymlinkedReadOnlyRootStillRefusesWrites: AllowedRoots entries resolve 217 // at check time, so ReadOnlyRoots must too — the same symlinked path must not 218 // grant access on one side while its write protection misses on the other. 219 func TestASymlinkedReadOnlyRootStillRefusesWrites(t *testing.T) { 220 tmp := canonicalRoot(t.TempDir()) 221 work := filepath.Join(tmp, "work") 222 realStore := filepath.Join(tmp, "real-store") 223 link := filepath.Join(tmp, "link-store") 224 require.NoError(t, os.Mkdir(work, 0o755)) 225 require.NoError(t, os.Mkdir(realStore, 0o755)) 226 require.NoError(t, os.Symlink(realStore, link)) 227 228 for _, readOnly := range []string{link, realStore} { 229 env := &AgentToolContext{Workdir: work, AllowedRoots: []string{link}, ReadOnlyRoots: []string{readOnly}} 230 got, err := resolvePath(env, filepath.Join(link, "note.md")) 231 require.NoError(t, err) 232 require.Equal(t, filepath.Join(realStore, "note.md"), got) 233 234 _, err = resolveWritablePath(env, filepath.Join(link, "note.md")) 235 require.EqualError(t, err, filepath.Join(link, "note.md")+" is inside read-only directory "+readOnly) 236 } 237 } 238 239 // Every tool built from a context still carrying the deprecated flag refuses 240 // to run, so the misconfiguration surfaces on the first call rather than as a 241 // silently different confinement policy. 242 func TestToolsRefuseToRunWithUnrestrictedPaths(t *testing.T) { 243 env := &AgentToolContext{Workdir: t.TempDir(), UnrestrictedPaths: true} 244 tools := BetaAgentToolset20260401(env) 245 t.Cleanup(func() { CloseAll(tools) }) 246 require.Len(t, tools, 6) 247 for _, tool := range tools { 248 t.Run(tool.Name(), func(t *testing.T) { 249 _, err := tool.Execute(context.Background(), json.RawMessage(`{}`)) 250 require.ErrorIs(t, err, ErrUnrestrictedPathsUnsupported) 251 var toolErr *ToolError 252 require.False(t, errors.As(err, &toolErr), "a configuration error, not a tool refusal shown to the model as its own mistake") 253 }) 254 } 255 } 256 257 // TestResolvePathConfinesSymlinks verifies the real (non-lexical) confinement: 258 // a symlink that lives inside the workdir but points outside it is resolved 259 // before the workdir check, so the operation is rejected — even when the link 260 // target does not exist (dangling). 261 func TestResolvePathConfinesSymlinks(t *testing.T) { 262 work := t.TempDir() 263 outside := t.TempDir() 264 env := &AgentToolContext{Workdir: work} 265 266 // Existing target outside the workdir. 267 require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("x"), 0o644)) 268 require.NoError(t, os.Symlink(filepath.Join(outside, "secret.txt"), filepath.Join(work, "live"))) 269 _, err := resolvePath(env, "live") 270 require.Error(t, err, "a symlink inside the workdir that points outside it must be rejected") 271 272 // Dangling target outside the workdir. 273 require.NoError(t, os.Symlink(filepath.Join(outside, "nope.txt"), filepath.Join(work, "dangling"))) 274 _, err = resolvePath(env, "dangling") 275 require.Error(t, err, "a dangling symlink inside the workdir that points outside it must be rejected") 276 277 // A symlink whose resolved target stays inside the workdir is fine. 278 require.NoError(t, os.Mkdir(filepath.Join(work, "sub"), 0o755)) 279 require.NoError(t, os.Symlink(filepath.Join(work, "sub"), filepath.Join(work, "inside"))) 280 got, err := resolvePath(env, filepath.Join("inside", "f.txt")) 281 require.NoError(t, err) 282 require.Equal(t, filepath.Join(canonicalRoot(work), "sub", "f.txt"), got) 283 284 // A dangling symlink whose target is inside the workdir resolves to that 285 // target even when the target's parent directory does not exist yet. 286 require.NoError(t, os.Symlink(filepath.Join("newdir", "f.txt"), filepath.Join(work, "d"))) 287 got, err = resolvePath(env, "d") 288 require.NoError(t, err) 289 require.Equal(t, filepath.Join(canonicalRoot(work), "newdir", "f.txt"), got) 290 } 291 292 // symlinkLoopFixture lays out, under a fresh workdir: loop_a <-> loop_b, 293 // self -> self, evil_link -> <outside>/secret.txt, and L -> loop_a/../evil_link. 294 // It returns the workdir and the outside directory. 295 func symlinkLoopFixture(t *testing.T) (work, outside string) { 296 t.Helper() 297 work = t.TempDir() 298 outside = t.TempDir() 299 require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("SECRET"), 0o644)) 300 for name, target := range map[string]string{ 301 "loop_a": "loop_b", 302 "loop_b": "loop_a", 303 "self": "self", 304 "evil_link": filepath.Join(outside, "secret.txt"), 305 "L": "loop_a/../evil_link", 306 } { 307 require.NoError(t, os.Symlink(target, filepath.Join(work, name))) 308 } 309 return work, outside 310 } 311 312 func TestResolvePathRejectsSymlinkLoops(t *testing.T) { 313 work, outside := symlinkLoopFixture(t) 314 env := &AgentToolContext{Workdir: work} 315 absWork := canonicalRoot(work) 316 317 tests := []struct { 318 description string 319 input string 320 wantErr string 321 }{ 322 {"two-link cycle", "loop_a", `path "loop_a": too many levels of symbolic links`}, 323 {"child under a two-link cycle", "loop_a/child.txt", `path "loop_a/child.txt": too many levels of symbolic links`}, 324 {"self-referencing link", "self", `path "self": too many levels of symbolic links`}, 325 {"child under a self-referencing link", "self/x", `path "self/x": too many levels of symbolic links`}, 326 {"dot-dot past a cycle is collapsed lexically and lands on the escaping link", "loop_a/../evil_link", `path "loop_a/../evil_link" is outside the session's working directory`}, 327 {"dot-dot past a self link is collapsed lexically and lands on the escaping link", "self/../evil_link", `path "self/../evil_link" is outside the session's working directory`}, 328 {"dangling link pointing outside the workdir", "dangle_out", `path "dangle_out" is outside the session's working directory`}, 329 } 330 require.NoError(t, os.Symlink(filepath.Join(outside, "nope"), filepath.Join(work, "dangle_out"))) 331 332 for _, tc := range tests { 333 t.Run(tc.description, func(t *testing.T) { 334 errc := make(chan error, 1) 335 go func() { 336 _, err := resolvePath(env, tc.input) 337 errc <- err 338 }() 339 select { 340 case err := <-errc: 341 require.EqualError(t, err, tc.wantErr) 342 require.NotContains(t, err.Error(), absWork) 343 case <-time.After(2 * time.Second): 344 t.Fatal("resolvePath did not return within 2s") 345 } 346 }) 347 } 348 349 t.Run("link whose target text crosses a cycle is denied one way or the other", func(t *testing.T) { 350 _, err := resolvePath(env, "L") 351 require.Error(t, err) 352 require.Regexp(t, `too many levels of symbolic links|is outside the session's working directory`, err.Error()) 353 }) 354 355 t.Run("deep missing path under an outside-pointing link is still an escape", func(t *testing.T) { 356 require.NoError(t, os.Symlink(outside, filepath.Join(work, "outlink"))) 357 parts := []string{"outlink"} 358 for i := range 300 { 359 parts = append(parts, fmt.Sprintf("d%d", i)) 360 } 361 parts = append(parts, "f.txt") 362 _, err := resolvePath(env, filepath.Join(parts...)) 363 require.Error(t, err) 364 require.Contains(t, err.Error(), "is outside the session's working directory") 365 }) 366 } 367 368 func TestBetaAgentToolset(t *testing.T) { 369 ts := BetaAgentToolset20260401(&AgentToolContext{Workdir: t.TempDir()}) 370 defer CloseAll(ts) 371 got := map[string]bool{} 372 for _, tool := range ts { 373 got[tool.Name()] = true 374 require.NotEmpty(t, tool.Description(), "tool %q must have a description", tool.Name()) 375 } 376 for _, name := range []string{"bash", "read", "write", "edit", "glob", "grep"} { 377 require.True(t, got[name], "agent_toolset_20260401 tool %q must be returned by BetaAgentToolset20260401", name) 378 } 379 } 380 381 // closerTool is a BetaTool whose Close behaviour is controlled by onClose. Used 382 // to verify CloseAll's per-tool isolation. 383 type closerTool struct { 384 name string 385 onClose func() error 386 closed bool 387 } 388 389 func (c *closerTool) Name() string { return c.name } 390 func (c *closerTool) Description() string { return c.name } 391 func (c *closerTool) InputSchema() anthropic.BetaToolInputSchemaParam { 392 return anthropic.BetaToolInputSchemaParam{Properties: map[string]any{}} 393 } 394 func (c *closerTool) Execute(context.Context, json.RawMessage) ([]anthropic.BetaToolResultBlockParamContentUnion, error) { 395 return nil, nil 396 } 397 func (c *closerTool) Close() error { 398 c.closed = true 399 if c.onClose != nil { 400 return c.onClose() 401 } 402 return nil 403 } 404 405 func TestCloseAllIsolatesPanicsAndErrors(t *testing.T) { 406 good := &closerTool{name: "good"} 407 panicker := &closerTool{name: "panicker", onClose: func() error { 408 panic("simulated tool close panic") 409 }} 410 errored := &closerTool{name: "errored", onClose: func() error { 411 return errors.New("simulated tool close error") 412 }} 413 414 // Order chosen so the panicking tool sits between two well-behaved ones: 415 // this verifies CloseAll doesn't short-circuit on either side. 416 CloseAll([]anthropic.BetaTool{good, panicker, errored}) 417 418 require.True(t, good.closed, 419 "tool before the panicker must still see Close") 420 require.True(t, errored.closed, 421 "tool after the panicker must still see Close — per-tool recover keeps the loop running") 422 } 423 424 // A tool-input refusal is matchable by name, not only by its wording. 425 func TestToolRefusalIsAToolError(t *testing.T) { 426 env := &AgentToolContext{Workdir: t.TempDir()} 427 tools := []anthropic.BetaTool{BetaReadTool(env), BetaBashTool(env)} 428 defer CloseAll(tools) 429 430 tests := []struct { 431 description string 432 tool anthropic.BetaTool 433 input map[string]any 434 wantContent string 435 }{ 436 { 437 description: "a file path that escapes the workdir is refused as a *ToolError", 438 tool: tools[0], 439 input: map[string]any{"file_path": "../outside.txt"}, 440 wantContent: `read: path "../outside.txt" is outside the session's working directory`, 441 }, 442 { 443 description: "a missing required argument is refused as a *ToolError", 444 tool: tools[1], 445 input: map[string]any{}, 446 wantContent: "bash: command is required", 447 }, 448 } 449 450 for _, tc := range tests { 451 t.Run(tc.description, func(t *testing.T) { 452 _, err := tc.tool.Execute(context.Background(), mustJSON(t, tc.input)) 453 var toolErr *ToolError 454 require.ErrorAs(t, err, &toolErr) 455 require.Equal(t, tc.wantContent, toolErr.Content) 456 require.EqualError(t, err, tc.wantContent, "the typed error keeps the exact message") 457 }) 458 } 459 }