Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
513 lines
16 KiB
Go
513 lines
16 KiB
Go
package service
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/Tencent/WeKnora/internal/agent/skills"
|
|
)
|
|
|
|
// ErrSkillBundleInvalid marks every rejection of an uploaded archive, so the
|
|
// handler can map the whole class to 400 without matching on message text.
|
|
var ErrSkillBundleInvalid = errors.New("skill bundle is invalid")
|
|
|
|
// The skill bundle limits bound decompression so a zip bomb cannot exhaust
|
|
// memory before we ever reach the sandbox.
|
|
const (
|
|
// maxSkillBundleFiles is the cap on files kept as the skill itself
|
|
// (after dropping GitHub zipball extras and directory entries).
|
|
// Real skills vendor assets: hugohe3/ppt-master's skills/ppt-master
|
|
// tree is ~13k files, almost all under templates/.
|
|
maxSkillBundleFiles = 20_000
|
|
// maxSkillBundleZipEntries bounds central-directory iteration on the
|
|
// raw archive. GitHub zipballs include the whole repository, so this
|
|
// is higher than maxSkillBundleFiles.
|
|
maxSkillBundleZipEntries = 100_000
|
|
maxSkillBundleFileBytes = 32 << 20 // 32 MiB per entry
|
|
maxSkillBundleTotalBytes = 512 << 20 // 512 MiB across the kept skill files
|
|
)
|
|
|
|
// SkillBundle is a validated, in-memory skill archive.
|
|
type SkillBundle struct {
|
|
Name string
|
|
Version string
|
|
Description string
|
|
Instructions string
|
|
// SHA256 is over the uploaded bytes, so re-uploading the same archive is
|
|
// recognisable in the UI and in the ledger, and a ready skill with this
|
|
// digest can skip a billed snapshot rebuild.
|
|
SHA256 string
|
|
// Files maps skill-root-relative paths to contents, SKILL.md included.
|
|
Files map[string][]byte
|
|
// FrontmatterRepaired is true when SKILL.md YAML had to be repaired
|
|
// before it would parse. The original file is unchanged; the install
|
|
// prompt tells the agent to mention this so the user can fix it.
|
|
FrontmatterRepaired bool
|
|
}
|
|
|
|
// SkillBundleParseOptions relaxes the uploaded-zip rules for archives pulled
|
|
// from a git host or registry, which wrap the skill in a repo directory and
|
|
// ship README/LICENSE next to it.
|
|
type SkillBundleParseOptions struct {
|
|
// Subdir is a skill-root path inside the archive (after any single wrap
|
|
// directory). Empty means "find SKILL.md by the usual rules".
|
|
Subdir string
|
|
// AllowExtraFiles keeps files under the skill root and drops the rest,
|
|
// instead of rejecting a GitHub zip that also contains the repo README.
|
|
AllowExtraFiles bool
|
|
// AllowNestedSkill accepts a unique SKILL.md nested deeper than one
|
|
// directory, which is how monorepos and GitHub zipballs arrive.
|
|
AllowNestedSkill bool
|
|
}
|
|
|
|
// ParseSkillBundle validates an uploaded zip and extracts everything the
|
|
// install flow needs. It accepts both a flat archive and one wrapped in a
|
|
// single top-level directory, because both are what people actually upload.
|
|
func ParseSkillBundle(archive []byte) (*SkillBundle, error) {
|
|
return ParseSkillBundleWithOptions(archive, SkillBundleParseOptions{})
|
|
}
|
|
|
|
// ParseSkillBundleWithOptions is ParseSkillBundle with the extra knobs remote
|
|
// installs need. SHA256 is still over the input bytes, not the re-rooted view.
|
|
func ParseSkillBundleWithOptions(archive []byte, opts SkillBundleParseOptions) (*SkillBundle, error) {
|
|
raw, err := unzipSkillArchive(archive, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
files, err := stripSkillRootPrefix(raw, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return skillBundleFromFiles(archive, files)
|
|
}
|
|
|
|
func unzipSkillArchive(archive []byte, opts SkillBundleParseOptions) (map[string][]byte, error) {
|
|
entries, err := skillZipEntries(archive, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
raw := make(map[string][]byte, len(entries))
|
|
var totalBytes int64
|
|
for _, item := range entries {
|
|
entryBytes := item.size
|
|
if totalBytes+entryBytes > maxSkillBundleTotalBytes {
|
|
return nil, fmt.Errorf("%w: archive is too large", ErrSkillBundleInvalid)
|
|
}
|
|
totalBytes += entryBytes
|
|
content, err := readLimitedZipEntry(item.file)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if int64(len(content)) > entryBytes {
|
|
actualExcess := int64(len(content)) - entryBytes
|
|
if totalBytes+actualExcess > maxSkillBundleTotalBytes {
|
|
return nil, fmt.Errorf("%w: archive is too large", ErrSkillBundleInvalid)
|
|
}
|
|
totalBytes += actualExcess
|
|
}
|
|
raw[item.name] = content
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
// errSkillFileMissing means the zip is a valid skill archive but the requested
|
|
// path is not in it. The browser maps this to 404; every other zip problem is
|
|
// still ErrSkillBundleInvalid.
|
|
var errSkillFileMissing = errors.New("skill file not found")
|
|
|
|
type skillZipEntry struct {
|
|
file *zip.File
|
|
name string
|
|
size int64
|
|
}
|
|
|
|
func skillZipEntries(archive []byte, opts SkillBundleParseOptions) ([]skillZipEntry, error) {
|
|
reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: not a readable zip archive: %v", ErrSkillBundleInvalid, err)
|
|
}
|
|
if len(reader.File) > maxSkillBundleZipEntries {
|
|
return nil, fmt.Errorf("%w: archive has more than %d zip entries",
|
|
ErrSkillBundleInvalid, maxSkillBundleZipEntries)
|
|
}
|
|
|
|
pending := make([]skillZipEntry, 0, len(reader.File))
|
|
names := make(map[string][]byte, len(reader.File))
|
|
for _, entry := range reader.File {
|
|
name, skip, err := inspectSkillZipPath(entry)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if skip {
|
|
continue
|
|
}
|
|
pending = append(pending, skillZipEntry{
|
|
file: entry,
|
|
name: name,
|
|
size: entry.FileInfo().Size(),
|
|
})
|
|
names[name] = nil
|
|
}
|
|
|
|
prefix, err := skillRootPrefix(names, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]skillZipEntry, 0, len(pending))
|
|
var totalBytes int64
|
|
for _, item := range pending {
|
|
if prefix != "" && !strings.HasPrefix(item.name, prefix+"/") {
|
|
if opts.AllowExtraFiles {
|
|
continue
|
|
}
|
|
return nil, fmt.Errorf("%w: archive holds files outside the skill directory %q",
|
|
ErrSkillBundleInvalid, prefix)
|
|
}
|
|
if err := inspectKeptSkillZipEntry(item); err != nil {
|
|
return nil, err
|
|
}
|
|
if totalBytes+item.size > maxSkillBundleTotalBytes {
|
|
return nil, fmt.Errorf("%w: archive is too large", ErrSkillBundleInvalid)
|
|
}
|
|
totalBytes += item.size
|
|
out = append(out, item)
|
|
if len(out) > maxSkillBundleFiles {
|
|
return nil, fmt.Errorf("%w: skill directory holds more than %d files",
|
|
ErrSkillBundleInvalid, maxSkillBundleFiles)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func inspectSkillZipPath(entry *zip.File) (name string, skip bool, err error) {
|
|
if entry.FileInfo().IsDir() {
|
|
return "", true, nil
|
|
}
|
|
name = path.Clean(entry.Name)
|
|
if name == "." || strings.HasPrefix(name, "..") ||
|
|
path.IsAbs(name) || strings.Contains(name, "../") {
|
|
return "", false, fmt.Errorf("%w: entry %q escapes the archive root",
|
|
ErrSkillBundleInvalid, entry.Name)
|
|
}
|
|
return name, false, nil
|
|
}
|
|
|
|
func inspectKeptSkillZipEntry(item skillZipEntry) error {
|
|
if item.file.FileInfo().Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("%w: entry %q is a symlink", ErrSkillBundleInvalid, item.file.Name)
|
|
}
|
|
if err := validateSkillEntryName(item.name); err != nil {
|
|
return err
|
|
}
|
|
if item.size > maxSkillBundleFileBytes {
|
|
return fmt.Errorf("%w: entry %q is too large", ErrSkillBundleInvalid, item.file.Name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readLimitedZipEntry(entry *zip.File) ([]byte, error) {
|
|
rc, err := entry.Open()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: cannot read %q: %v", ErrSkillBundleInvalid, entry.Name, err)
|
|
}
|
|
content, err := io.ReadAll(io.LimitReader(rc, maxSkillBundleFileBytes+1))
|
|
_ = rc.Close()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: cannot read %q: %v", ErrSkillBundleInvalid, entry.Name, err)
|
|
}
|
|
if len(content) > maxSkillBundleFileBytes {
|
|
return nil, fmt.Errorf("%w: entry %q is too large", ErrSkillBundleInvalid, entry.Name)
|
|
}
|
|
return content, nil
|
|
}
|
|
|
|
// listSkillZipFiles returns skill-root-relative paths and declared uncompressed
|
|
// sizes from the zip central directory. It does not inflate file bodies.
|
|
func listSkillZipFiles(archive []byte) ([]SkillFileEntry, error) {
|
|
index, err := skillZipFileIndex(archive)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
names := make([]string, 0, len(index))
|
|
for name := range index {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
out := make([]SkillFileEntry, 0, len(names))
|
|
for _, name := range names {
|
|
out = append(out, SkillFileEntry{Path: name, Size: index[name].size})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// readSkillZipFile inflates one skill-root-relative path and leaves the rest
|
|
// of the archive compressed.
|
|
func readSkillZipFile(archive []byte, rel string) ([]byte, error) {
|
|
index, err := skillZipFileIndex(archive)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
item, ok := index[rel]
|
|
if !ok {
|
|
return nil, errSkillFileMissing
|
|
}
|
|
return readLimitedZipEntry(item.file)
|
|
}
|
|
|
|
func skillZipFileIndex(archive []byte) (map[string]skillZipEntry, error) {
|
|
entries, err := skillZipEntries(archive, SkillBundleParseOptions{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raw := make(map[string][]byte, len(entries))
|
|
byArchiveName := make(map[string]skillZipEntry, len(entries))
|
|
for _, item := range entries {
|
|
raw[item.name] = nil
|
|
byArchiveName[item.name] = item
|
|
}
|
|
opts := SkillBundleParseOptions{}
|
|
stripped, err := stripSkillRootPrefix(raw, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
prefix, err := skillRootPrefix(raw, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make(map[string]skillZipEntry, len(stripped))
|
|
for rel := range stripped {
|
|
archiveName := rel
|
|
if prefix != "" {
|
|
archiveName = prefix + "/" + rel
|
|
}
|
|
item, ok := byArchiveName[archiveName]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: SKILL.md is missing", ErrSkillBundleInvalid)
|
|
}
|
|
out[rel] = item
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func skillBundleFromFiles(archive []byte, files map[string][]byte) (*SkillBundle, error) {
|
|
manifest, ok := files["SKILL.md"]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: SKILL.md is missing", ErrSkillBundleInvalid)
|
|
}
|
|
skill, err := skills.ParseSkillFile(string(manifest))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrSkillBundleInvalid, err)
|
|
}
|
|
version, err := parseSkillBundleVersion(string(manifest))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrSkillBundleInvalid, err)
|
|
}
|
|
|
|
return &SkillBundle{
|
|
Name: skill.Name,
|
|
Version: version,
|
|
Description: skill.Description,
|
|
Instructions: skill.Instructions,
|
|
SHA256: skillArchiveSHA256(archive),
|
|
Files: files,
|
|
FrontmatterRepaired: skill.FrontmatterRepaired,
|
|
}, nil
|
|
}
|
|
|
|
func skillArchiveSHA256(archive []byte) string {
|
|
sum := sha256.Sum256(archive)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// archiveMatchesSHA reports whether archive is the digest the row claims.
|
|
// An empty expected digest is treated as unknown, not as a match against
|
|
// whatever happens to be on disk — callers that want a fallback must opt in.
|
|
func archiveMatchesSHA(archive []byte, want string) bool {
|
|
want = strings.TrimSpace(want)
|
|
if want == "" || len(archive) == 0 {
|
|
return false
|
|
}
|
|
return skillArchiveSHA256(archive) == want
|
|
}
|
|
|
|
// zipSkillFiles writes a deterministic skill-root zip so a remote archive that
|
|
// had to be re-rooted can go through the same InstallSkill path as an upload.
|
|
func zipSkillFiles(files map[string][]byte) ([]byte, error) {
|
|
names := make([]string, 0, len(files))
|
|
for name := range files {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
|
|
var buf bytes.Buffer
|
|
writer := zip.NewWriter(&buf)
|
|
for _, name := range names {
|
|
entry, err := writer.Create(name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("write skill zip entry %q: %w", name, err)
|
|
}
|
|
if _, err := entry.Write(files[name]); err != nil {
|
|
return nil, fmt.Errorf("write skill zip entry %q: %w", name, err)
|
|
}
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
return nil, fmt.Errorf("close skill zip: %w", err)
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// validateSkillEntryName bans control characters and nothing else.
|
|
//
|
|
// Shell safety is not this function's job: every interpolation of a bundle
|
|
// path goes through sandbox.ShellQuote, which makes any name inert. What
|
|
// quoting cannot fix is a name that rewrites the lines it is printed into —
|
|
// the entry names also reach log lines, error messages and the image
|
|
// manifest, and a newline or an escape sequence there forges a second record.
|
|
// So control characters (NUL included) are refused and ordinary punctuation is
|
|
// not: real bundles vendor node_modules/@scope/pkg and wheels named
|
|
// numpy-1.26.4+cpu.whl, and a charset that rejected '@' or '+' broke them for
|
|
// no security gain. Traversal and symlinks are checked separately by the
|
|
// caller.
|
|
func validateSkillEntryName(name string) error {
|
|
for _, r := range name {
|
|
if unicode.IsControl(r) || r == 0 {
|
|
return fmt.Errorf("%w: entry %q holds unsupported character %q",
|
|
ErrSkillBundleInvalid, name, r)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseSkillBundleVersion(manifest string) (string, error) {
|
|
lines := strings.Split(manifest, "\n")
|
|
frontmatterStart := -1
|
|
for i, line := range lines {
|
|
if strings.TrimSpace(line) == "---" {
|
|
frontmatterStart = i
|
|
break
|
|
}
|
|
if strings.TrimSpace(line) != "" {
|
|
break
|
|
}
|
|
}
|
|
if frontmatterStart < 0 {
|
|
return "", nil
|
|
}
|
|
|
|
frontmatterEnd := -1
|
|
for i := frontmatterStart + 1; i < len(lines); i++ {
|
|
if strings.TrimSpace(lines[i]) == "---" {
|
|
frontmatterEnd = i
|
|
break
|
|
}
|
|
}
|
|
if frontmatterEnd < 0 {
|
|
return "", nil
|
|
}
|
|
|
|
var metadata struct {
|
|
Version string `yaml:"version"`
|
|
}
|
|
frontmatter := strings.Join(lines[frontmatterStart+1:frontmatterEnd], "\n")
|
|
if _, err := skills.UnmarshalSkillFrontmatter(frontmatter, &metadata); err != nil {
|
|
return "", err
|
|
}
|
|
return metadata.Version, nil
|
|
}
|
|
|
|
// stripSkillRootPrefix re-roots the archive at the directory holding SKILL.md.
|
|
func stripSkillRootPrefix(raw map[string][]byte, opts SkillBundleParseOptions) (map[string][]byte, error) {
|
|
prefix, err := skillRootPrefix(raw, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if prefix == "" {
|
|
return raw, nil
|
|
}
|
|
out := make(map[string][]byte, len(raw))
|
|
for name, content := range raw {
|
|
if !strings.HasPrefix(name, prefix+"/") {
|
|
if opts.AllowExtraFiles {
|
|
continue
|
|
}
|
|
return nil, fmt.Errorf("%w: archive holds files outside the skill directory %q",
|
|
ErrSkillBundleInvalid, prefix)
|
|
}
|
|
out[strings.TrimPrefix(name, prefix+"/")] = content
|
|
}
|
|
if _, ok := out["SKILL.md"]; !ok {
|
|
return nil, fmt.Errorf("%w: SKILL.md is missing", ErrSkillBundleInvalid)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func skillRootPrefix(raw map[string][]byte, opts SkillBundleParseOptions) (string, error) {
|
|
subdir := path.Clean(strings.Trim(opts.Subdir, "/"))
|
|
if subdir == "." {
|
|
subdir = ""
|
|
}
|
|
// A SKILL.md at the zip root is the skill. Nested SKILL.md files are then
|
|
// just extra files, the same way an uploaded bundle vendors them.
|
|
if subdir == "" {
|
|
if _, ok := raw["SKILL.md"]; ok {
|
|
return "", nil
|
|
}
|
|
}
|
|
|
|
var matches []string
|
|
for name := range raw {
|
|
if path.Base(name) != "SKILL.md" {
|
|
continue
|
|
}
|
|
dir := path.Dir(name)
|
|
if dir == "." {
|
|
dir = ""
|
|
}
|
|
if subdir != "" {
|
|
if dir == subdir || strings.HasSuffix(dir, "/"+subdir) {
|
|
matches = append(matches, dir)
|
|
}
|
|
continue
|
|
}
|
|
if dir == "" || !strings.Contains(dir, "/") || opts.AllowNestedSkill {
|
|
matches = append(matches, dir)
|
|
}
|
|
}
|
|
if len(matches) == 0 {
|
|
if subdir != "" {
|
|
return "", fmt.Errorf("%w: SKILL.md is missing under %q", ErrSkillBundleInvalid, subdir)
|
|
}
|
|
return "", fmt.Errorf("%w: SKILL.md is missing", ErrSkillBundleInvalid)
|
|
}
|
|
uniq := uniqueStrings(matches)
|
|
if len(uniq) > 1 {
|
|
return "", fmt.Errorf("%w: archive holds more than one skill", ErrSkillBundleInvalid)
|
|
}
|
|
return uniq[0], nil
|
|
}
|
|
|
|
func uniqueStrings(in []string) []string {
|
|
seen := make(map[string]struct{}, len(in))
|
|
out := make([]string, 0, len(in))
|
|
for _, v := range in {
|
|
if _, ok := seen[v]; ok {
|
|
continue
|
|
}
|
|
seen[v] = struct{}{}
|
|
out = append(out, v)
|
|
}
|
|
return out
|
|
}
|