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  }