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 }