// Package bundle implements bundle-aware scanning of Claude Code skills. // // A skill is a DIRECTORY ("Bundle"): a SKILL.md plus sibling files it // references (scripts, compiled artifacts, archives, binaries, images, data). // The malicious payload frequently lives in the sibling files, not in the // SKILL.md itself. This package walks the WHOLE directory (hidden/dot files // included), classifies every sibling by SNIFFED MAGIC (not extension alone), // resolves symlink targets, and resolves which siblings the SKILL.md // references. // // bundle.go holds the pure data model (Bundle/File + FileKind) and pure helper // functions with NO I/O, so the per-kind Analyzers (in sibling files) and the // Scan walker can import it cycle-free. package bundle import ( "path/filepath" "strings" "huggingface.co/turenlabs/Vigil/source/pkg/types" ) // FileKind classifies a bundle member by what it actually is (sniffed magic // first, then extension, then by-name special files). type FileKind int const ( // KindSkillMd is the SKILL.md entry point itself. KindSkillMd FileKind = iota // KindMarkdown is a non-SKILL.md markdown file. KindMarkdown // KindShell is a .sh/.bash script or a shebang-detected shell script. KindShell // KindPythonSource is a .py source file. KindPythonSource // KindScriptOther covers .mjs/.cjs/.ts/.rb/.ps1/.bat/.lua/.pl and by-name // Makefile/Dockerfile/justfile: executable text the threat model says // scanners ignore. KindScriptOther // KindPyc is compiled Python bytecode (.pyc). KindPyc // KindArchive is a zip-family container (.zip/.docx/.xlsx/.pptx or any // PK-sniffed file). KindArchive // KindNativeBinary is an ELF/Mach-O object or .so/.dylib/.node. KindNativeBinary // KindWasm is a WebAssembly module (\0asm magic). KindWasm // KindImage is a raster image (.png/.jpg/.jpeg/.gif/.webp). KindImage // KindData is structured data: json/yaml/csv/.ipynb. KindData // KindText is plain text not otherwise classified. KindText // KindUnknown is a member whose kind could not be determined. KindUnknown ) // String returns the stable lowercase token for a FileKind. These tokens are // part of the manifest schema (FileKind.String() set) consumed by the trainer, // so they must not change without a corpus migration. func (k FileKind) String() string { switch k { case KindSkillMd: return "skill_md" case KindMarkdown: return "markdown" case KindShell: return "shell" case KindPythonSource: return "python" case KindScriptOther: return "script_other" case KindPyc: return "pyc" case KindArchive: return "archive" case KindNativeBinary: return "native_binary" case KindWasm: return "wasm" case KindImage: return "image" case KindData: return "data" case KindText: return "text" default: return "unknown" } } // File is a single member of a Bundle (a sibling of SKILL.md). SKILL.md itself // is held on Bundle.Skill/SkillMdPath, not in Bundle.Files. type File struct { RelPath string // path relative to Bundle.Dir, e.g. ".hidden/sync.sh" AbsPath string // absolute path on disk Kind FileKind // sniffed-magic-first classification SizeBytes int64 // full on-disk size (not the capped Sniff len) Hidden bool // any path segment begins with '.' Referenced bool // SKILL.md names this file RefSources []string // "body"|"frontmatter"|"allowed-tools"|"markdown-link"|"code-span" Sniff []byte // first <= maxSniffBytes bytes, for analyzers Truncated bool // file exceeded maxSniffBytes; tail not in Sniff (flag-on-truncate) NewlineRatio float64 // newline bytes / Sniff len, for padding-evasion detection IsSymlink bool // entry is a symlink SymlinkTarget string // EvalSymlinks result (resolved), empty if not a symlink SymlinkEscapes bool // resolved target lies outside Bundle.Dir ScriptLang string // "sh"|"python"|"node"|"ruby"|"powershell"|"make"|... when a script kind } // Bundle is a parsed skill directory: the SKILL.md plus every sibling. type Bundle struct { Dir string SkillMdPath string Skill *types.SkillFile // parsed via parser.Parse (unchanged); nil if ParseErr != nil Files []*File // every sibling incl. hidden + symlinks, excludes SKILL.md itself SkillMdBytes int64 PayloadBytes int64 // sum of non-SKILL.md file sizes AllowedTools []string // parsed from raw frontmatter (parser does not capture this) ParseErr error Notes []string // skipped-loop/unreadable notes; never a panic } // isHiddenName reports whether a single path segment is a hidden/dot file. // The root "." or ".." segments are never themselves payload files. func isHiddenName(name string) bool { return len(name) > 1 && name[0] == '.' && name != ".." && name != "." } // pathHasHiddenSegment reports whether any segment of a relative path is hidden. func pathHasHiddenSegment(relPath string) bool { for _, seg := range strings.Split(filepath.ToSlash(relPath), "/") { if isHiddenName(seg) { return true } } return false } // sniffMagicKind classifies a file by leading magic bytes ALONE. Magic is // authoritative over extension (a notes.txt that is really an ELF must classify // as KindNativeBinary; an image1.png that is really a script must not classify // as KindImage). The second return is false when no magic matched, leaving the // caller to fall back to extension / by-name classification. // // Recognized magic: // - PK\x03\x04 / PK\x05\x06 / PK\x07\x08 -> zip family (KindArchive) // - \x7fELF -> ELF (KindNativeBinary) // - Mach-O (fat + thin, both endians) -> Mach-O (KindNativeBinary) // - \0asm -> wasm (KindWasm) // - PNG / JPEG / GIF / WEBP(RIFF....WEBP) -> image (KindImage) // - CPython .pyc magic (low 2 bytes + \r\n at [2:4]) -> KindPyc func sniffMagicKind(sniff []byte) (FileKind, bool) { if len(sniff) < 4 { return KindUnknown, false } // Zip family (also docx/xlsx/pptx, which are zips). if hasPrefix(sniff, []byte("PK\x03\x04")) || hasPrefix(sniff, []byte("PK\x05\x06")) || hasPrefix(sniff, []byte("PK\x07\x08")) { return KindArchive, true } // ELF. if hasPrefix(sniff, []byte("\x7fELF")) { return KindNativeBinary, true } // Mach-O: thin (0xFEEDFACE / 0xFEEDFACF, both byte orders) and fat // (0xCAFEBABE / 0xBEBAFECA). 0xCAFEBABE also collides with Java .class, but // .class is not in this threat model and is still opaque native-ish code, so // classifying it as KindNativeBinary is the safe (non-skip) choice. switch { case hasPrefix(sniff, []byte{0xFE, 0xED, 0xFA, 0xCE}), hasPrefix(sniff, []byte{0xFE, 0xED, 0xFA, 0xCF}), hasPrefix(sniff, []byte{0xCE, 0xFA, 0xED, 0xFE}), hasPrefix(sniff, []byte{0xCF, 0xFA, 0xED, 0xFE}), hasPrefix(sniff, []byte{0xCA, 0xFE, 0xBA, 0xBE}), hasPrefix(sniff, []byte{0xBE, 0xBA, 0xFE, 0xCA}): return KindNativeBinary, true } // WebAssembly. if hasPrefix(sniff, []byte{0x00, 0x61, 0x73, 0x6D}) { // \0asm return KindWasm, true } // Images. if hasPrefix(sniff, []byte{0x89, 0x50, 0x4E, 0x47}) { // PNG \x89PNG return KindImage, true } if hasPrefix(sniff, []byte{0xFF, 0xD8, 0xFF}) { // JPEG return KindImage, true } if hasPrefix(sniff, []byte("GIF87a")) || hasPrefix(sniff, []byte("GIF89a")) { return KindImage, true } if len(sniff) >= 12 && hasPrefix(sniff, []byte("RIFF")) && string(sniff[8:12]) == "WEBP" { return KindImage, true } // CPython .pyc: 4-byte magic where bytes [2:4] are \r\n (0x0D 0x0A). The low // two bytes are a version-specific number that changes per release, so we key // on the stable \r\n pair plus a non-zero first byte (avoids matching a file // that merely starts with \0\0\r\n). if len(sniff) >= 4 && sniff[2] == 0x0D && sniff[3] == 0x0A && !(sniff[0] == 0x00 && sniff[1] == 0x00) { return KindPyc, true } return KindUnknown, false } // hasPrefix reports whether data begins with prefix. func hasPrefix(data, prefix []byte) bool { if len(data) < len(prefix) { return false } for i := range prefix { if data[i] != prefix[i] { return false } } return true } // hasShebang reports whether sniff begins with a "#!" shebang line. func hasShebang(sniff []byte) bool { return len(sniff) >= 2 && sniff[0] == '#' && sniff[1] == '!' } // firstLine returns the first line of sniff (without the trailing newline), // capped at 256 bytes so a pathological no-newline blob cannot be scanned whole. func firstLine(sniff []byte) string { limit := len(sniff) if limit > 256 { limit = 256 } for i := 0; i < limit; i++ { if sniff[i] == '\n' { return string(sniff[:i]) } } return string(sniff[:limit]) } // classifyKind classifies a file by SNIFFED MAGIC FIRST, then by extension, // then by-name special files (Makefile/Dockerfile/justfile). name is the // basename; sniff is the leading bytes (may be empty for unreadable files). func classifyKind(name string, sniff []byte) FileKind { base := filepath.Base(name) lower := strings.ToLower(base) // 1. Magic sniff is authoritative. if k, ok := sniffMagicKind(sniff); ok { // SKILL.md is decided by the walker, never by magic; but a markdown file // that happens to begin with bytes resembling magic is vanishingly rare // and would already be handled below by extension. Magic wins for the // concrete binary/archive/image/pyc kinds returned by sniffMagicKind. return k } // 2. Shebang beats a misleading EXTENSION. A file with no binary/archive/image // magic that nonetheless begins with "#!" is an executable script regardless // of how its name is dressed (the threat model's "image1.png that is really a // script" case). This must run BEFORE extension classification so a .png/.txt // disguise cannot demote a real script to image/text. (A genuine PNG carries // PNG magic and was already returned in step 1, so this never mis-tags a real // image.) if hasShebang(sniff) { return shebangKind(sniff) } // 3. By-name special files (no extension, but executable-by-convention). switch { case lower == "makefile" || lower == "gnumakefile" || strings.HasPrefix(lower, "makefile."): return KindScriptOther case lower == "dockerfile" || strings.HasPrefix(lower, "dockerfile.") || strings.HasSuffix(lower, ".dockerfile"): return KindScriptOther case lower == "justfile" || lower == ".justfile": return KindScriptOther } // 4. Extension-based classification. ext := strings.ToLower(filepath.Ext(base)) switch ext { case ".md", ".markdown": return KindMarkdown case ".sh", ".bash", ".zsh", ".ksh": return KindShell case ".py", ".pyw": return KindPythonSource case ".pyc", ".pyo": return KindPyc case ".mjs", ".cjs", ".js", ".ts", ".rb", ".ps1", ".psm1", ".bat", ".cmd", ".lua", ".pl", ".pm": return KindScriptOther case ".zip", ".docx", ".xlsx", ".pptx", ".jar", ".odt", ".ods", ".odp": return KindArchive case ".so", ".dylib", ".node", ".o", ".a": return KindNativeBinary case ".wasm": return KindWasm case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff": return KindImage case ".json", ".yaml", ".yml", ".csv", ".tsv", ".ipynb", ".toml": return KindData case ".txt", ".text", ".rst", ".log", ".cfg", ".ini", ".env": return KindText } // 5. Printable text with no other signal -> KindText; otherwise KindUnknown. // (A shebang was already handled in step 2, so any remaining script-shaped // file here is plain text.) if looksTextual(sniff) { return KindText } return KindUnknown } // shebangKind maps a "#!..." interpreter line to a script FileKind. func shebangKind(sniff []byte) FileKind { line := firstLine(sniff) switch { case strings.Contains(line, "python"): return KindPythonSource case strings.Contains(line, "node"): return KindScriptOther case strings.Contains(line, "ruby"): return KindScriptOther case strings.Contains(line, "perl"): return KindScriptOther case strings.Contains(line, "sh"): // bash, sh, dash, zsh, ksh return KindShell default: return KindScriptOther } } // looksTextual reports whether a sniff prefix is mostly printable text. Empty // input is treated as non-textual (unknown), so an unreadable file does not get // misclassified as text. func looksTextual(sniff []byte) bool { if len(sniff) == 0 { return false } probe := sniff if len(probe) > 512 { probe = probe[:512] } nonPrintable := 0 for _, b := range probe { if b == 0 { return false // NUL byte => binary } if b < 0x09 || (b > 0x0D && b < 0x20) { nonPrintable++ } } return nonPrintable*100/len(probe) < 10 } // scriptLangFor returns a coarse language tag for a script-bearing kind, used by // analyzers to pick the right indicator vocab. Empty string for non-script // kinds. Resolution order matches classifyKind: by-name, then extension, then // shebang. func scriptLangFor(name string, sniff []byte) string { base := filepath.Base(name) lower := strings.ToLower(base) switch { case lower == "makefile" || lower == "gnumakefile" || strings.HasPrefix(lower, "makefile."): return "make" case lower == "dockerfile" || strings.HasPrefix(lower, "dockerfile.") || strings.HasSuffix(lower, ".dockerfile"): return "docker" case lower == "justfile" || lower == ".justfile": return "just" } switch strings.ToLower(filepath.Ext(base)) { case ".sh", ".bash", ".zsh", ".ksh": return "sh" case ".py", ".pyw": return "python" case ".pyc", ".pyo": return "python" case ".mjs", ".cjs", ".js", ".ts": return "node" case ".rb": return "ruby" case ".ps1", ".psm1": return "powershell" case ".bat", ".cmd": return "batch" case ".lua": return "lua" case ".pl", ".pm": return "perl" } if hasShebang(sniff) { line := strings.ToLower(firstLine(sniff)) switch { case strings.Contains(line, "python"): return "python" case strings.Contains(line, "node"): return "node" case strings.Contains(line, "ruby"): return "ruby" case strings.Contains(line, "perl"): return "perl" case strings.Contains(line, "sh"): return "sh" default: return "sh" } } return "" }