github.com/anthropics/anthropic-sdk-go@v1.70.1/tools/agenttoolset/skillarchive.go (about) 1 package agenttoolset 2 3 import ( 4 "archive/tar" 5 "archive/zip" 6 "compress/bzip2" 7 "compress/gzip" 8 "fmt" 9 "io" 10 "os" 11 "path/filepath" 12 "strings" 13 ) 14 15 // This file holds the skill-archive extraction logic, kept separate from the 16 // skill download/setup flow in skills.go and from the agent_toolset_20260401 17 // tool implementations. 18 19 // Bounds on archive extraction to guard against decompression bombs. These are 20 // vars rather than consts only so tests can lower them and exercise the limits 21 // without building multi-gigabyte archives; the public surface is unchanged. 22 var ( 23 skillArchiveMaxMembers = 10_000 24 skillArchiveMaxBytes int64 = 1 << 30 // 1 GiB 25 ) 26 27 // extractSkillArchive extracts the skill download at archivePath (a zip or 28 // gzip/bzip2/plain tar archive) into dest, confining any member that would 29 // escape dest (zip-slip / tar-slip) and skipping every member that is not a 30 // regular file or directory (symlink, hardlink, device, FIFO, PAX global 31 // header), in zip and tar alike: skill archives come from the API, but skills 32 // can be third-party. The archive is read straight from disk — never buffered 33 // whole into memory — so a large skill bundle cannot OOM the runner. 34 func extractSkillArchive(archivePath, dest string) (retErr error) { 35 root, err := filepath.Abs(dest) 36 if err != nil { 37 return err 38 } 39 if err := os.MkdirAll(root, 0o755); err != nil { 40 return err 41 } 42 // extractSkillArchive creates dest, so it owns cleaning it up on failure: 43 // a half-extracted skill (over the member/byte cap, a corrupt member, 44 // zip-slip rejection, disk full, ...) is worse than none. Best effort — 45 // dest is either a complete extraction or absent. 46 defer func() { 47 if retErr != nil { 48 _ = os.RemoveAll(root) 49 } 50 }() 51 52 f, err := os.Open(archivePath) 53 if err != nil { 54 return err 55 } 56 defer f.Close() 57 58 // Sniff the leading bytes for the zip magic ("PK\x03\x04") without reading 59 // the whole archive, then rewind for the real extractor. 60 var magic [4]byte 61 n, _ := io.ReadFull(f, magic[:]) 62 if _, err := f.Seek(0, io.SeekStart); err != nil { 63 return err 64 } 65 if n >= 4 && magic[0] == 'P' && magic[1] == 'K' && magic[2] == 0x03 && magic[3] == 0x04 { 66 // zip needs random access over the file; hand it the path so it seeks 67 // the file on disk rather than us buffering it into memory. 68 return extractZip(archivePath, root) 69 } 70 return extractTar(f, root) 71 } 72 73 // archiveTopDir returns the single top-level directory shared by every entry 74 // in names, or "" if the entries don't all live under one common directory. 75 // 76 // Skill bundles are packaged wrapped in one directory named after the skill 77 // (e.g. pdf/SKILL.md, pdf/scripts/...). The extractor strips that wrapper so 78 // contents land directly in the skill's destination dir instead of a redundant 79 // nested <skill>/<skill>/ level. A flat or multi-root archive yields "" and is 80 // extracted unchanged. 81 // cleanSegments splits a slash path into its components, dropping empty and 82 // "." segments so a "./pdf/x"-style name (e.g. from `tar -C dir .`) is treated 83 // the same as "pdf/x". 84 func cleanSegments(name string) []string { 85 out := make([]string, 0, 4) 86 for _, p := range strings.Split(filepath.ToSlash(name), "/") { 87 if p != "" && p != "." { 88 out = append(out, p) 89 } 90 } 91 return out 92 } 93 94 func archiveTopDir(names []string) string { 95 top := "" 96 seen := false 97 nested := false 98 for _, n := range names { 99 parts := cleanSegments(n) 100 if len(parts) == 0 { 101 continue 102 } 103 if !seen { 104 top, seen = parts[0], true 105 } else if parts[0] != top { 106 return "" 107 } 108 if len(parts) > 1 { 109 nested = true 110 } 111 } 112 if seen && nested { 113 return top 114 } 115 return "" 116 } 117 118 // stripTopDir drops the leading top component from name. Returns "" for the 119 // bare top-dir entry itself (nothing to write). 120 func stripTopDir(name, top string) string { 121 if top == "" { 122 return name 123 } 124 parts := cleanSegments(name) 125 if len(parts) == 0 || parts[0] != top { 126 return name 127 } 128 return strings.Join(parts[1:], "/") // "" for the bare top-dir entry itself 129 } 130 131 // tarDecompressor returns a reader over f's (optionally gzip/bzip2-compressed) 132 // tar bytes. f must be positioned at the start; the caller closes the returned 133 // closer when done with this pass. 134 func tarDecompressor(f *os.File) (io.Reader, func() error, error) { 135 var magic [3]byte 136 n, _ := io.ReadFull(f, magic[:]) 137 if _, err := f.Seek(0, io.SeekStart); err != nil { 138 return nil, nil, err 139 } 140 switch { 141 case n >= 2 && magic[0] == 0x1f && magic[1] == 0x8b: // gzip 142 gz, err := gzip.NewReader(f) 143 if err != nil { 144 return nil, nil, err 145 } 146 return gz, gz.Close, nil 147 case n >= 3 && magic[0] == 'B' && magic[1] == 'Z' && magic[2] == 'h': // bzip2 148 return bzip2.NewReader(f), func() error { return nil }, nil 149 } 150 return f, func() error { return nil }, nil 151 } 152 153 // safeJoin joins name under root, anchoring at "/" first so a ".." component 154 // is absorbed at the root rather than escaping it. Returns an error if the 155 // result still somehow lands outside root. 156 func safeJoin(root, name string) (string, error) { 157 clean := filepath.Clean("/" + filepath.ToSlash(name)) 158 target := filepath.Join(root, clean) 159 if target != root && !strings.HasPrefix(target, root+string(os.PathSeparator)) { 160 return "", fmt.Errorf("refusing archive member %q", name) 161 } 162 return target, nil 163 } 164 165 func extractZip(archivePath, root string) error { 166 zr, err := zip.OpenReader(archivePath) 167 if err != nil { 168 return err 169 } 170 defer zr.Close() 171 // zr.File is fully materialized at OpenReader; bound it before building the 172 // names list so pass 1 (top-dir detection) is not unbounded on a zip bomb. 173 if len(zr.File) > skillArchiveMaxMembers { 174 return fmt.Errorf("skill archive exceeds %d members", skillArchiveMaxMembers) 175 } 176 names := make([]string, 0, len(zr.File)) 177 for _, f := range zr.File { 178 if zipEntryIsPlain(f) { 179 names = append(names, f.Name) 180 } 181 } 182 top := archiveTopDir(names) 183 members := 0 184 var remaining int64 = skillArchiveMaxBytes 185 for _, f := range zr.File { 186 if !zipEntryIsPlain(f) { 187 continue 188 } 189 nm := stripTopDir(f.Name, top) 190 if nm == "" { 191 continue 192 } 193 members++ 194 if members > skillArchiveMaxMembers { 195 return fmt.Errorf("skill archive exceeds %d members", skillArchiveMaxMembers) 196 } 197 target, err := safeJoin(root, nm) 198 if err != nil { 199 return err 200 } 201 if f.FileInfo().IsDir() { 202 if err := os.MkdirAll(target, 0o755); err != nil { 203 return err 204 } 205 continue 206 } 207 if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { 208 return err 209 } 210 written, err := copyZipEntry(f, target, remaining) 211 if err != nil { 212 return err 213 } 214 remaining -= written 215 } 216 return nil 217 } 218 219 // zipEntryIsPlain reports whether f is a regular file or directory. Unix type 220 // bits are honoured only for entries written by a Unix host, which is exactly 221 // what [zip.FileHeader.Mode] observes; every other entry's bytes are data. 222 func zipEntryIsPlain(f *zip.File) bool { 223 m := f.Mode() 224 return m.IsRegular() || m.IsDir() 225 } 226 227 // tarEntryIsPlain reports whether hdr is a regular file or directory. Every 228 // other header type — symlink, hardlink, device, FIFO, PAX global header, 229 // vendor extensions — is skipped rather than materialised. 230 func tarEntryIsPlain(hdr *tar.Header) bool { 231 switch hdr.Typeflag { 232 case tar.TypeReg, tar.TypeCont, tar.TypeGNUSparse, tar.TypeDir: 233 return true 234 } 235 return false 236 } 237 238 // copyZipEntry decompresses f to target, capped at limit bytes; returns bytes 239 // written. 240 func copyZipEntry(f *zip.File, target string, limit int64) (int64, error) { 241 rc, err := f.Open() 242 if err != nil { 243 return 0, err 244 } 245 defer rc.Close() 246 out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, archiveFileMode(f.Mode())) 247 if err != nil { 248 return 0, err 249 } 250 defer out.Close() 251 return copyBounded(out, rc, limit) 252 } 253 254 // extractTar streams a (optionally gzip/bzip2-compressed) tar archive from f 255 // into root. f must be positioned at the start of the archive; the tar reader 256 // consumes it as a stream, so the archive is never buffered whole in memory. 257 func extractTar(f *os.File, root string) error { 258 // Pass 1: read headers only to detect the skill bundle's wrapper directory. 259 // The archive is on disk, so a second pass just rewinds and re-reads it — 260 // nothing is buffered whole in memory. 261 r, closeR, err := tarDecompressor(f) 262 if err != nil { 263 return err 264 } 265 // Bound pass 1 too: tr.Next() reads through every preceding entry's body to 266 // advance, so an unbounded pass 1 would burn CPU/wall time on a tar bomb 267 // before pass 2's caps ever run. The byte budget is computed from hdr.Size 268 // (the declared size) so we don't have to read bodies to enforce it. 269 var names []string 270 tr := tar.NewReader(r) 271 members := 0 272 var declared int64 273 for { 274 hdr, err := tr.Next() 275 if err == io.EOF { 276 break 277 } 278 if err != nil { 279 _ = closeR() 280 return err 281 } 282 if !tarEntryIsPlain(hdr) { 283 continue 284 } 285 members++ 286 if members > skillArchiveMaxMembers { 287 _ = closeR() 288 return fmt.Errorf("skill archive exceeds %d members", skillArchiveMaxMembers) 289 } 290 declared += hdr.Size 291 if declared > skillArchiveMaxBytes { 292 _ = closeR() 293 return fmt.Errorf("skill archive exceeds %d bytes decompressed", skillArchiveMaxBytes) 294 } 295 names = append(names, hdr.Name) 296 } 297 if err := closeR(); err != nil { 298 return err 299 } 300 top := archiveTopDir(names) 301 302 // Pass 2: extract, stripping the wrapper directory. 303 if _, err := f.Seek(0, io.SeekStart); err != nil { 304 return err 305 } 306 r, closeR, err = tarDecompressor(f) 307 if err != nil { 308 return err 309 } 310 defer closeR() 311 tr = tar.NewReader(r) 312 members = 0 313 var remaining int64 = skillArchiveMaxBytes 314 for { 315 hdr, err := tr.Next() 316 if err == io.EOF { 317 break 318 } 319 if err != nil { 320 return err 321 } 322 if !tarEntryIsPlain(hdr) { 323 continue 324 } 325 nm := stripTopDir(hdr.Name, top) 326 if nm == "" { 327 continue 328 } 329 members++ 330 if members > skillArchiveMaxMembers { 331 return fmt.Errorf("skill archive exceeds %d members", skillArchiveMaxMembers) 332 } 333 target, err := safeJoin(root, nm) 334 if err != nil { 335 return err 336 } 337 if hdr.Typeflag == tar.TypeDir { 338 if err := os.MkdirAll(target, 0o755); err != nil { 339 return err 340 } 341 continue 342 } 343 if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { 344 return err 345 } 346 written, err := copyTarEntry(tr, target, hdr.FileInfo().Mode(), remaining) 347 if err != nil { 348 return err 349 } 350 remaining -= written 351 } 352 return nil 353 } 354 355 // copyTarEntry decompresses tr's current entry to target, capped at limit 356 // bytes; returns bytes written. 357 func copyTarEntry(tr *tar.Reader, target string, entryMode os.FileMode, limit int64) (int64, error) { 358 out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, archiveFileMode(entryMode)) 359 if err != nil { 360 return 0, err 361 } 362 defer out.Close() 363 return copyBounded(out, tr, limit) 364 } 365 366 // archiveFileMode reduces an archive entry's mode to 0o755 if executable, 367 // 0o644 otherwise. 368 func archiveFileMode(entryMode os.FileMode) os.FileMode { 369 if entryMode&0o111 != 0 { 370 return 0o755 371 } 372 return 0o644 373 } 374 375 // copyBounded copies up to limit bytes from src to dst, returning an error if 376 // src exceeds limit. 377 func copyBounded(dst io.Writer, src io.Reader, limit int64) (int64, error) { 378 if limit < 0 { 379 limit = 0 380 } 381 n, err := io.Copy(dst, io.LimitReader(src, limit+1)) 382 if err != nil { 383 return n, err 384 } 385 if n > limit { 386 return n, fmt.Errorf("skill archive exceeds %d bytes decompressed", skillArchiveMaxBytes) 387 } 388 return n, nil 389 }