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

     1  // Package agenttoolset provides Node-equivalent local executors for the
     2  // `agent_toolset_20260401` tool set — `bash`, `read`, `write`, `edit`, `glob`,
     3  // `grep` — plus the workdir/skills [AgentToolContext] and the skill-download
     4  // helpers ([AgentToolContext.SetupSkillsFromSession], and the deprecated
     5  // [AgentToolContext.SetupSkills] for a caller holding only a session id).
     6  //
     7  // This mirrors the SDK's other first-class tool modules: it is the explicit
     8  // entry point for these implementations. Importing it pulls in os/exec, a PTY
     9  // dependency, etc., so it is kept separate from the rest of the SDK — depending
    10  // on it is opt-in.
    11  //
    12  // The result of [BetaAgentToolset20260401] is a plain []anthropic.BetaTool; hand it to
    13  // any tool runner — client.Beta.Messages.NewToolRunner(tools, …) for the
    14  // Messages API, or client.Beta.Sessions.Events.NewToolRunner(…) for a
    15  // managed-agents session:
    16  //
    17  //	import "github.com/anthropics/anthropic-sdk-go/tools/agenttoolset"
    18  //
    19  //	env := &agenttoolset.AgentToolContext{Workdir: "/work"}
    20  //	tools := agenttoolset.BetaAgentToolset20260401(env)
    21  //
    22  // Trust model — two tiers:
    23  //
    24  //   - The file tools ([BetaReadTool], [BetaWriteTool], [BetaEditTool],
    25  //     [BetaGlobTool], [BetaGrepTool]) confine to Workdir plus any
    26  //     AllowedRoots. resolvePath canonicalizes the target — resolving every
    27  //     symlink, including the leaf, even a dangling one — before the
    28  //     containment check and returns that canonical path for the operation, so
    29  //     a symlink inside a permitted root that points outside it neither passes
    30  //     the check nor gets followed afterwards. This is a real boundary, not a
    31  //     lexical hint (modulo the residual TOCTOU noted on resolvePath).
    32  //   - [BetaBashTool] runs an unrestricted /bin/bash and cannot be confined. Run
    33  //     it — and, for defense in depth, the whole toolset — inside a sandbox the
    34  //     host controls (e.g. a self-hosted environment runner).
    35  package agenttoolset
    36  
    37  import (
    38  	"context"
    39  	"encoding/json"
    40  	"errors"
    41  	"fmt"
    42  	"io"
    43  	"io/fs"
    44  	"os"
    45  	"path/filepath"
    46  	"strings"
    47  	"syscall"
    48  
    49  	anthropic "github.com/anthropics/anthropic-sdk-go"
    50  )
    51  
    52  // AgentToolContext carries per-session configuration the agent_toolset_20260401
    53  // executors need.
    54  //
    55  // See the package-level trust model: the file tools resolve paths against
    56  // Workdir and reject anything outside Workdir and AllowedRoots (symlinks
    57  // resolved); [BetaBashTool] runs an unrestricted /bin/bash regardless.
    58  type AgentToolContext struct {
    59  	// Workdir is the base directory for resolving relative tool paths.
    60  	Workdir string
    61  	// Deprecated: no longer supported and slated for removal. The file tools are
    62  	// always confined to Workdir plus AllowedRoots, so a true value is not
    63  	// reinterpreted: every call to a tool built from this context fails with
    64  	// ErrUnrestrictedPathsUnsupported instead. List extra directories the file
    65  	// tools may reach in AllowedRoots.
    66  	UnrestrictedPaths bool
    67  	// AllowedRoots are directories outside Workdir the file tools may also
    68  	// reach; the environment worker sets this to the session's memory-store
    69  	// folders. Entries are resolved (symlinks followed) on every call, so an
    70  	// entry reaches whatever it points at when the tool runs; empty entries are
    71  	// ignored and relative entries resolve against the process working
    72  	// directory, like Workdir. Does not constrain [BetaBashTool].
    73  	AllowedRoots []string
    74  
    75  	// MaxFileBytes caps the size of a file the read and edit tools will load
    76  	// into memory: it applies to a whole-file read and to edit, which both read
    77  	// the whole file. A read with view_range on a larger file streams it and
    78  	// applies the cap to the selected lines instead. Zero (the default) uses the
    79  	// built-in 256 KiB cap; a positive value sets a custom cap; a negative
    80  	// value disables the size cap entirely. Disabling it reintroduces the OOM
    81  	// risk on a model-controlled path, so set it negative only when the sandbox
    82  	// can absorb arbitrarily large files. The non-regular-file (FIFO/device)
    83  	// guard always applies regardless of this value.
    84  	MaxFileBytes int64
    85  
    86  	// Env sets the bash subprocess environment. When non-nil it fully replaces
    87  	// the inherited environment with exactly these entries; when nil the
    88  	// subprocess inherits the runner's environment with ANTHROPIC_* credentials
    89  	// removed.
    90  	//
    91  	// Env is used verbatim and is not filtered, so a tool whose script calls the
    92  	// Anthropic API can deliberately pass routing or config vars such as
    93  	// ANTHROPIC_BASE_URL. For that same reason, do not build Env by copying
    94  	// os.Environ() and adding a few entries — that leaks the runner's own
    95  	// ANTHROPIC_* credentials into a model-driven shell. Populate Env with only
    96  	// the variables the tools need.
    97  	Env map[string]string
    98  
    99  	// ReadOnlyRoots are directories the write and edit tools refuse to
   100  	// modify, resolved on every call like AllowedRoots. The environment worker
   101  	// sets this to the roots of read-only memory stores so the agent sees the
   102  	// error at write time instead of the change silently never syncing; the
   103  	// mechanism is generic to any directory. Does not constrain [BetaBashTool].
   104  	ReadOnlyRoots []string
   105  
   106  	// downloadedSkillDirs are the per-skill directories
   107  	// [AgentToolContext.SetupSkillsFromSession] created under {Workdir}/skills;
   108  	// [AgentToolContext.Cleanup] removes exactly these and nothing else.
   109  	downloadedSkillDirs []string
   110  }
   111  
   112  // BetaAgentToolset20260401 returns the six built-in agent_toolset_20260401
   113  // implementations bound to env, in the anthropic.BetaTool shape the SDK's tool
   114  // runners accept. The slice is owned by the caller; filter or append before
   115  // passing to a runner.
   116  //
   117  // [BetaBashTool] keeps a persistent shell open until its Close is called;
   118  // [CloseAll] releases every tool in the slice that implements io.Closer.
   119  // Every tool call fails with [ErrUnrestrictedPathsUnsupported] while the
   120  // deprecated env.UnrestrictedPaths is set.
   121  func BetaAgentToolset20260401(env *AgentToolContext) []anthropic.BetaTool {
   122  	return []anthropic.BetaTool{
   123  		BetaBashTool(env),
   124  		BetaReadTool(env),
   125  		BetaWriteTool(env),
   126  		BetaEditTool(env),
   127  		BetaGlobTool(env),
   128  		BetaGrepTool(env),
   129  	}
   130  }
   131  
   132  // ErrUnrestrictedPathsUnsupported is what every tool call returns, resolvePath
   133  // returns, and the environment worker's Run/HandleItem wrap, when the
   134  // deprecated UnrestrictedPaths option is set.
   135  var ErrUnrestrictedPathsUnsupported = errors.New(
   136  	"agenttoolset: UnrestrictedPaths is no longer supported; remove it and list any extra directories the file tools may reach in AllowedRoots")
   137  
   138  func rejectUnrestrictedPaths(env *AgentToolContext) error {
   139  	if env.UnrestrictedPaths {
   140  		return ErrUnrestrictedPathsUnsupported
   141  	}
   142  	return nil
   143  }
   144  
   145  // CloseAll releases resources held by any tools that implement io.Closer. Each
   146  // Close runs under its own recover so one panicking tool cannot skip cleanup
   147  // for the rest of the slice. Errors and panics are swallowed — callers wanting
   148  // visibility should close tools themselves and inspect the return values.
   149  func CloseAll(ts []anthropic.BetaTool) {
   150  	for _, t := range ts {
   151  		func(t anthropic.BetaTool) {
   152  			defer func() { _ = recover() }()
   153  			if c, ok := t.(io.Closer); ok {
   154  				_ = c.Close()
   155  			}
   156  		}(t)
   157  	}
   158  }
   159  
   160  // ToolError is a tool's error result — a refused path, a missing argument,
   161  // a failed command — carrying the text the model sees with is_error set.
   162  type ToolError struct {
   163  	// Content is the error-result text shown to the model.
   164  	Content string
   165  }
   166  
   167  func (e *ToolError) Error() string { return e.Content }
   168  
   169  // funcTool adapts a plain function into an anthropic.BetaTool. Used for
   170  // stateless tools that only need the shared AgentToolContext. Soft tool failures (the
   171  // (string, true) return) become a *[ToolError] so the surrounding tool
   172  // runner surfaces them to the model as an error result.
   173  type funcTool struct {
   174  	name        string
   175  	description string
   176  	schema      anthropic.BetaToolInputSchemaParam
   177  	env         *AgentToolContext
   178  	run         func(ctx context.Context, input json.RawMessage, env *AgentToolContext) (string, bool)
   179  }
   180  
   181  func (t *funcTool) Name() string                                    { return t.name }
   182  func (t *funcTool) Description() string                             { return t.description }
   183  func (t *funcTool) InputSchema() anthropic.BetaToolInputSchemaParam { return t.schema }
   184  func (t *funcTool) Execute(ctx context.Context, input json.RawMessage) ([]anthropic.BetaToolResultBlockParamContentUnion, error) {
   185  	if err := rejectUnrestrictedPaths(t.env); err != nil {
   186  		return nil, err
   187  	}
   188  	content, isErr := t.run(ctx, input, t.env)
   189  	if isErr {
   190  		return nil, &ToolError{Content: content}
   191  	}
   192  	return textResult(content), nil
   193  }
   194  
   195  // textResult wraps a plain string as a single text tool-result block.
   196  func textResult(s string) []anthropic.BetaToolResultBlockParamContentUnion {
   197  	return []anthropic.BetaToolResultBlockParamContentUnion{{OfText: &anthropic.BetaTextBlockParam{Text: s}}}
   198  }
   199  
   200  func objectSchema(properties map[string]any, required ...string) anthropic.BetaToolInputSchemaParam {
   201  	return anthropic.BetaToolInputSchemaParam{Properties: properties, Required: required}
   202  }
   203  
   204  func prop(typ, description string) map[string]any {
   205  	return map[string]any{"type": typ, "description": description}
   206  }
   207  
   208  func errorf(format string, a ...any) (string, bool) {
   209  	return fmt.Sprintf(format, a...), true
   210  }
   211  
   212  // fsErrorMessage maps a filesystem error to a consistent, language-independent
   213  // message so the model sees the same wording regardless of the host runtime's
   214  // raw errno text (e.g. Go's "open x: no such file or directory" vs a bare
   215  // ENOENT from another SDK). It never returns Go's "op /abs/path: ..." text,
   216  // so the host's absolute path does not reach the model.
   217  func fsErrorMessage(err error) string {
   218  	switch {
   219  	case errors.Is(err, fs.ErrNotExist):
   220  		return "no such file or directory"
   221  	case errors.Is(err, fs.ErrPermission):
   222  		return "permission denied"
   223  	case errors.Is(err, syscall.ENOTDIR):
   224  		return "not a directory"
   225  	case errors.Is(err, syscall.EISDIR):
   226  		return "is a directory"
   227  	case errors.Is(err, errSymlinkLoop), errors.Is(err, syscall.ELOOP):
   228  		return errSymlinkLoop.Error()
   229  	}
   230  	var errno syscall.Errno
   231  	if errors.As(err, &errno) {
   232  		return errno.Error()
   233  	}
   234  	return "i/o error"
   235  }
   236  
   237  // resolvePath resolves p against env.Workdir and rejects results outside the
   238  // permitted roots: Workdir plus each non-empty entry of env.AllowedRoots.
   239  // Absolute and relative inputs go through the same canonicalise-then-contain
   240  // check — an absolute path that lands inside a permitted root is accepted,
   241  // only paths that resolve outside all of them are rejected. Every symlink in p
   242  // (including the leaf, even a dangling one) is resolved before the check, the
   243  // roots are resolved the same way on every call, and the resolved path is what
   244  // the tool then operates on, so a symlink inside a permitted root that points
   245  // outside it can neither pass the check nor be followed afterwards. ".." is
   246  // collapsed lexically before any symlink is followed, and a path whose
   247  // symlinks cannot be resolved (a loop, an unreadable component) is rejected
   248  // rather than used as given. See the package-level trust model.
   249  //
   250  // Residual TOCTOU: a component could still be swapped for a symlink between this
   251  // call and the eventual filesystem operation. Closing that fully needs
   252  // per-component O_NOFOLLOW/openat, which Go does not expose ergonomically; the
   253  // same residual exposure exists in the SDK's other file-tool helpers and is why
   254  // a sandbox is still recommended for the toolset as a whole.
   255  func resolvePath(env *AgentToolContext, p string) (string, error) {
   256  	if err := rejectUnrestrictedPaths(env); err != nil {
   257  		return "", err
   258  	}
   259  	root := canonicalRoot(env.Workdir)
   260  	abs := filepath.Clean(p)
   261  	if !filepath.IsAbs(p) {
   262  		abs = filepath.Join(root, p)
   263  	}
   264  	real, err := canonicalize(abs)
   265  	if err != nil {
   266  		return "", fmt.Errorf("path %q: %s", p, fsErrorMessage(err))
   267  	}
   268  	if within(real, root) {
   269  		return real, nil
   270  	}
   271  	hasOtherRoots := false
   272  	for _, r := range env.AllowedRoots {
   273  		if r == "" {
   274  			continue // names no directory; filepath.Abs would turn it into the cwd
   275  		}
   276  		hasOtherRoots = true
   277  		if within(real, canonicalRoot(r)) {
   278  			return real, nil
   279  		}
   280  	}
   281  	if hasOtherRoots {
   282  		return "", fmt.Errorf("path %q is outside the session's working directory and its other permitted directories", p)
   283  	}
   284  	return "", fmt.Errorf("path %q is outside the session's working directory", p)
   285  }
   286  
   287  // resolveWritablePath is resolvePath for the write and edit tools: the
   288  // resolved target must also lie outside every env.ReadOnlyRoots entry.
   289  func resolveWritablePath(env *AgentToolContext, p string) (string, error) {
   290  	real, err := resolvePath(env, p)
   291  	if err != nil {
   292  		return "", err
   293  	}
   294  	if ro := readOnlyRootFor(env, real); ro != "" {
   295  		return "", fmt.Errorf("%s is inside read-only directory %s", p, ro)
   296  	}
   297  	return real, nil
   298  }
   299  
   300  // readOnlyRootFor returns the entry of env.ReadOnlyRoots that target falls
   301  // under, or "". Roots resolve here on every call, symmetric with AllowedRoots
   302  // in resolvePath, so a symlinked store path cannot grant access on one side
   303  // while its write protection misses on the other. target is canonicalized
   304  // again so the comparison also holds for a caller that did not obtain it from
   305  // resolvePath, e.g. under a symlinked prefix such as /tmp on macOS.
   306  func readOnlyRootFor(env *AgentToolContext, target string) string {
   307  	if len(env.ReadOnlyRoots) == 0 {
   308  		return ""
   309  	}
   310  	tgt := filepath.Clean(target)
   311  	if real, err := canonicalize(tgt); err == nil {
   312  		tgt = real
   313  	}
   314  	for _, root := range env.ReadOnlyRoots {
   315  		if within(tgt, canonicalRoot(root)) {
   316  			return root
   317  		}
   318  	}
   319  	return ""
   320  }
   321  
   322  // within reports whether path equals root or lies beneath it, comparing whole
   323  // segments so /work does not contain /workextra. Both must be clean absolute
   324  // paths; root may be the filesystem root.
   325  func within(path, root string) bool {
   326  	sep := string(filepath.Separator)
   327  	return path == root || strings.HasPrefix(path, strings.TrimSuffix(root, sep)+sep)
   328  }
   329  
   330  // canonicalRoot resolves a configured root (Workdir, an AllowedRoots or
   331  // ReadOnlyRoots entry) the way resolvePath resolves targets, so both sides of
   332  // a containment check have had the same symlinks followed. A root whose
   333  // symlinks cannot be resolved is compared as its absolute path, which no
   334  // canonicalized target lies under, so it grants nothing.
   335  func canonicalRoot(r string) string {
   336  	abs := absOrSelf(r)
   337  	if real, err := canonicalize(abs); err == nil {
   338  		return real
   339  	}
   340  	return abs
   341  }
   342  
   343  func absOrSelf(p string) string {
   344  	if abs, err := filepath.Abs(p); err == nil {
   345  		return abs
   346  	}
   347  	return filepath.Clean(p)
   348  }
   349  
   350  var errSymlinkLoop = errors.New("too many levels of symbolic links")
   351  
   352  const maxSymlinkHops = 40
   353  
   354  // canonicalize returns abs with every symlink resolved, or an error — never
   355  // abs itself or a partly resolved path. It EvalSymlinks the longest existing
   356  // ancestor and re-appends the missing remainder so paths being created
   357  // (write/edit) still resolve, but a component that is itself a symlink is read
   358  // and followed rather than re-appended, so a dangling link pointing outside the
   359  // workdir cannot slip through. Only symlink reads count against
   360  // maxSymlinkHops; exceeding it, or an ELOOP from the OS, is a loop.
   361  func canonicalize(abs string) (string, error) {
   362  	var tail []string
   363  	prefix := filepath.Clean(abs)
   364  	hops := 0
   365  	for {
   366  		real, evalErr := filepath.EvalSymlinks(prefix)
   367  		if evalErr == nil {
   368  			parts := make([]string, 0, len(tail)+1)
   369  			parts = append(parts, real)
   370  			for i := len(tail) - 1; i >= 0; i-- {
   371  				parts = append(parts, tail[i])
   372  			}
   373  			return filepath.Join(parts...), nil
   374  		}
   375  		fi, lerr := os.Lstat(prefix)
   376  		switch {
   377  		case lerr == nil && fi.Mode()&os.ModeSymlink != 0:
   378  			hops++
   379  			if hops > maxSymlinkHops {
   380  				return "", errSymlinkLoop
   381  			}
   382  			dest, err := os.Readlink(prefix)
   383  			if err != nil {
   384  				return "", err
   385  			}
   386  			if !filepath.IsAbs(dest) {
   387  				dest = filepath.Join(filepath.Dir(prefix), dest)
   388  			}
   389  			prefix = filepath.Clean(dest)
   390  		case lerr == nil:
   391  			return "", evalErr
   392  		case errors.Is(lerr, fs.ErrNotExist), errors.Is(lerr, syscall.ENOTDIR):
   393  			parent := filepath.Dir(prefix)
   394  			if parent == prefix {
   395  				return "", lerr
   396  			}
   397  			tail = append(tail, filepath.Base(prefix))
   398  			prefix = parent
   399  		default:
   400  			return "", lerr
   401  		}
   402  	}
   403  }