File size: 10,845 Bytes
d2507b5 a2a3348 d2507b5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 | package bundle
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"huggingface.co/turenlabs/Vigil/source/pkg/parser"
)
const (
// maxSniffBytes is the per-file read cap (1 MiB). Large enough that ordinary
// scripts read fully (closing the front-padding tail-hiding hole); anything
// larger sets File.Truncated so analyzers can FLAG ON TRUNCATE rather than
// silently scanning only the prefix.
maxSniffBytes = 1048576
// maxBundleFiles bounds the number of members walked, guarding against a
// pathological directory (or a symlink loop that the inode set somehow misses)
// turning the scan into an unbounded crawl.
maxBundleFiles = 4096
)
// skillMdName is the canonical entry-point filename (matched case-insensitively).
const skillMdName = "skill.md"
// IsBundlePath reports whether path should be scanned as a bundle: true if path
// is a directory, or a SKILL.md (case-insensitive) file inside a directory.
// A plain .md file that is not named SKILL.md is NOT a bundle (preserving
// byte-for-byte single-file behavior).
func IsBundlePath(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
if info.IsDir() {
return true
}
return strings.EqualFold(filepath.Base(path), skillMdName)
}
// ScanPath is the backward-compatible entry point.
//
// - A directory -> full Bundle of that directory.
// - A SKILL.md path -> full Bundle of its parent directory.
// - Any other regular file (.md) -> degenerate single-file Bundle with no
// siblings, so legacy single-file scanning is unchanged.
func ScanPath(path string) (*Bundle, error) {
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("stat %s: %w", path, err)
}
if info.IsDir() {
return Scan(path)
}
if strings.EqualFold(filepath.Base(path), skillMdName) {
return Scan(filepath.Dir(path))
}
// Degenerate single-file bundle: parse only this file, no siblings.
return scanSingleFile(path)
}
// scanSingleFile builds a degenerate Bundle from one markdown file (legacy
// single-file mode). No directory walk, no siblings.
func scanSingleFile(path string) (*Bundle, error) {
abs, err := filepath.Abs(path)
if err != nil {
abs = path
}
b := &Bundle{
Dir: filepath.Dir(abs),
SkillMdPath: abs,
Files: nil,
}
skill, perr := parser.Parse(path)
if perr != nil {
b.ParseErr = perr
} else {
b.Skill = skill
b.AllowedTools = parseAllowedTools(skill.RawContent)
}
if info, serr := os.Stat(path); serr == nil {
b.SkillMdBytes = info.Size()
}
return b, nil
}
// Scan walks the WHOLE skill directory (hidden/dot files included), locates
// SKILL.md case-insensitively, parses it via parser.Parse (unchanged), and
// populates Bundle.Files with magic-sniffed kinds, capped sniff prefixes,
// truncation/newline-ratio flags, and resolved symlink targets. It never panics
// on unreadable or looping entries — such cases append a Note and continue.
func Scan(dir string) (*Bundle, error) {
absDir, err := filepath.Abs(dir)
if err != nil {
absDir = dir
}
b := &Bundle{Dir: absDir}
// Locate SKILL.md (case-insensitive) at the top level of the directory.
skillMdAbs := findSkillMd(absDir)
b.SkillMdPath = skillMdAbs
visited := make(map[uint64]bool) // inode set for symlink-loop safety
fileCount := 0
walkErr := filepath.WalkDir(absDir, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
// Record and skip an unreadable entry; never abort the whole walk.
b.Notes = append(b.Notes, fmt.Sprintf("walk error at %s: %v", relOrPath(absDir, path), walkErr))
if d != nil && d.IsDir() {
return filepath.SkipDir
}
return nil
}
if path == absDir {
return nil // the root directory itself
}
if fileCount >= maxBundleFiles {
b.Notes = append(b.Notes, fmt.Sprintf("file cap %d reached; remaining entries skipped", maxBundleFiles))
return filepath.SkipAll
}
rel := relOrPath(absDir, path)
if d.IsDir() {
// Loop-safety for directory symlinks is handled below in the symlink
// branch; a plain directory is just descended into.
return nil
}
// SKILL.md itself is held on the Bundle, not in Files.
if skillMdAbs != "" && sameAbs(path, skillMdAbs) {
return nil
}
f := &File{
RelPath: rel,
AbsPath: path,
Hidden: pathHasHiddenSegment(rel),
}
// Detect symlinks via the entry type (WalkDir does NOT follow them, so a
// symlinked directory arrives here as a non-dir entry — we never crawl
// into it, only resolve and record its target).
isSymlink := d.Type()&os.ModeSymlink != 0
f.IsSymlink = isSymlink
// readPath is the path we sniff bytes from: the resolved target for a
// symlink (so a symlinked executable reference is first-class), else the
// path itself.
readPath := path
if isSymlink {
target, escapes, isLoop := resolveSymlink(path, absDir)
f.SymlinkTarget = target
f.SymlinkEscapes = escapes
if isLoop {
b.Notes = append(b.Notes, fmt.Sprintf("symlink loop broken at %s", rel))
// Still record the entry (kind unknown) so a looping symlinked
// reference is never silently dropped.
b.Files = append(b.Files, f)
fileCount++
return nil
}
if target != "" {
readPath = target
}
// Inode-dedupe the resolved target so two symlinks to the same file,
// or a symlink pointing back at a real sibling, do not double-count and
// cannot drive an unbounded loop.
if ino, ok := inodeOf(readPath); ok {
if visited[ino] {
b.Notes = append(b.Notes, fmt.Sprintf("symlink target already visited at %s", rel))
} else {
visited[ino] = true
}
}
} else if ino, ok := inodeOf(path); ok {
visited[ino] = true
}
// Size from the resolved target (Lstat-size of a symlink is just the link
// length, which is useless for payload accounting).
if info, serr := os.Stat(readPath); serr == nil {
f.SizeBytes = info.Size()
}
sniff, _, truncated, newlineRatio, rerr := readSniff(readPath)
if rerr != nil {
b.Notes = append(b.Notes, fmt.Sprintf("read error at %s: %v", rel, rerr))
}
f.Sniff = sniff
f.Truncated = truncated
f.NewlineRatio = newlineRatio
f.Kind = classifyKind(filepath.Base(path), sniff)
f.ScriptLang = scriptLangFor(filepath.Base(path), sniff)
b.Files = append(b.Files, f)
fileCount++
b.PayloadBytes += f.SizeBytes
return nil
})
if walkErr != nil {
b.Notes = append(b.Notes, fmt.Sprintf("walk terminated: %v", walkErr))
}
// Parse SKILL.md via the unchanged parser.
if skillMdAbs != "" {
skill, perr := parser.Parse(skillMdAbs)
if perr != nil {
b.ParseErr = perr
} else {
b.Skill = skill
b.AllowedTools = parseAllowedTools(skill.RawContent)
}
if info, serr := os.Stat(skillMdAbs); serr == nil {
b.SkillMdBytes = info.Size()
}
} else {
b.Notes = append(b.Notes, "no SKILL.md found in bundle directory")
}
resolveReferences(b)
return b, nil
}
// findSkillMd returns the absolute path of the top-level SKILL.md
// (case-insensitive) in dir, or "" if none exists. Only the immediate directory
// is consulted; a SKILL.md nested in a subdirectory is treated as a sibling
// markdown payload, not the entry point.
func findSkillMd(dir string) string {
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
for _, e := range entries {
if e.IsDir() {
continue
}
if strings.EqualFold(e.Name(), skillMdName) {
return filepath.Join(dir, e.Name())
}
}
return ""
}
// readSniff reads up to maxSniffBytes from absPath. It reports the full file
// size, whether the file exceeded the cap (Truncated => the tail is NOT in
// data, the flag-on-truncate signal), and the newline ratio of the read prefix
// (newline bytes / len, used for padding-evasion detection). It never reads
// unbounded content, so a symlink to a huge file is safe.
func readSniff(absPath string) (data []byte, size int64, truncated bool, newlineRatio float64, err error) {
f, oerr := os.Open(absPath) // #nosec G304 -- absPath is a bundle member discovered by the walk; reads are capped at maxSniffBytes and never executed. batou:ignore injection -- defensive scanner reads attacker-controlled skill files by design; bounded read, no exec.
if oerr != nil {
return nil, 0, false, 0, oerr
}
defer func() { _ = f.Close() }()
if info, serr := f.Stat(); serr == nil {
size = info.Size()
}
// Read one byte past the cap to detect truncation deterministically even when
// Stat size is unavailable or lies (e.g. a growing/proc-like file).
buf := make([]byte, maxSniffBytes+1)
n, rerr := io.ReadFull(f, buf)
if rerr != nil && rerr != io.EOF && rerr != io.ErrUnexpectedEOF {
return nil, size, false, 0, rerr
}
if n > maxSniffBytes {
truncated = true
n = maxSniffBytes
}
data = buf[:n]
if n > 0 {
newlines := 0
for _, b := range data {
if b == '\n' {
newlines++
}
}
newlineRatio = float64(newlines) / float64(n)
}
return data, size, truncated, newlineRatio, nil
}
// resolveSymlink resolves a symlink at absPath. It returns the fully-resolved
// target (via EvalSymlinks), whether that target lies OUTSIDE bundleRoot
// (SymlinkEscapes), and whether resolution failed in a way consistent with a
// loop (isLoop). A loop or unresolvable target never causes a panic; the caller
// records a Note and keeps the entry so a symlinked executable reference is
// never silently dropped.
func resolveSymlink(absPath, bundleRoot string) (target string, escapes bool, isLoop bool) {
resolved, err := filepath.EvalSymlinks(absPath)
if err != nil {
// EvalSymlinks fails on a loop ("too many links") and on a dangling
// target. Fall back to a single os.Readlink so we can still record where
// the link points (its declared target) without following it.
if link, lerr := os.Readlink(absPath); lerr == nil {
t := link
if !filepath.IsAbs(t) {
t = filepath.Join(filepath.Dir(absPath), t)
}
return filepath.Clean(t), !withinRoot(filepath.Clean(t), bundleRoot), true
}
return "", false, true
}
return resolved, !withinRoot(resolved, bundleRoot), false
}
// withinRoot reports whether target is inside root (or equal to it).
func withinRoot(target, root string) bool {
rel, err := filepath.Rel(root, target)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
}
// relOrPath returns path relative to base, falling back to the absolute path if
// the relative form cannot be computed.
func relOrPath(base, path string) string {
if rel, err := filepath.Rel(base, path); err == nil {
return rel
}
return path
}
// sameAbs reports whether two paths refer to the same absolute location.
func sameAbs(a, b string) bool {
ca, _ := filepath.Abs(a)
cb, _ := filepath.Abs(b)
return filepath.Clean(ca) == filepath.Clean(cb)
}
|