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  }