Files
viettranx 5623f97961 feat(vault): extension whitelist + document docType + media summary
- Add extension_whitelist.go to deterministically skip binary .bin/.exe/.dll
- Add media_summary.go: synthesize docType + generate short summary from vault fields
- Update rescan.go: detect document type for PDF/office files + propagate docType
- Update safe_walk.go test: .bin now filtered by whitelist, use .txt instead
- Update rescan_test.go: add document docType coverage
- Add link_types.go: define AUTO_LINK_TASK, AUTO_LINK_DELEGATION link types
2026-04-11 21:22:23 +07:00

247 lines
6.3 KiB
Go

package vault
import (
"context"
"errors"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
)
// WalkEntry represents an eligible file found during workspace walk.
type WalkEntry struct {
RelPath string // workspace-relative, forward-slash separated
AbsPath string // absolute filesystem path
Size int64 // file size in bytes
ModTime time.Time // last modification time
}
// WalkStats holds metrics from a workspace walk.
type WalkStats struct {
TotalWalked int
Eligible int
SkippedSymlinks int
SkippedExcluded int
SkippedTooLarge int
Truncated bool
}
// WalkOptions configures resource limits for workspace walking.
type WalkOptions struct {
MaxFiles int // max eligible files to return (0 = unlimited)
MaxTotalBytes int64 // max cumulative file size in bytes (0 = unlimited)
MaxFileBytes int64 // skip individual files larger than this (0 = unlimited)
}
// DefaultWalkOptions returns safe defaults for production use.
func DefaultWalkOptions() WalkOptions {
return WalkOptions{
MaxFiles: 5000,
MaxTotalBytes: 500 * 1024 * 1024, // 500MB
MaxFileBytes: 50 * 1024 * 1024, // 50MB per file
}
}
// SafeWalkWorkspace walks root directory collecting eligible files.
// Symlinks are skipped unconditionally. Excluded paths are filtered.
// Resource limits (file count, total size, context deadline) are enforced.
func SafeWalkWorkspace(ctx context.Context, root string, opts WalkOptions) ([]WalkEntry, WalkStats, error) {
root = filepath.Clean(root)
var (
entries []WalkEntry
stats WalkStats
totalBytes int64
walkCount int
)
// Early context check before starting walk.
if ctx.Err() != nil {
return nil, stats, ctx.Err()
}
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // skip unreadable entries
}
// Check context periodically.
walkCount++
if walkCount%100 == 0 {
if ctx.Err() != nil {
return ctx.Err()
}
}
// Skip ALL symlinks unconditionally (both files and dirs).
if d.Type()&os.ModeSymlink != 0 {
stats.SkippedSymlinks++
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
// Compute workspace-relative path (forward-slash).
relPath, relErr := filepath.Rel(root, path)
if relErr != nil || relPath == "." {
return nil
}
relPath = filepath.ToSlash(relPath)
// Boundary check: path must stay inside root.
if strings.HasPrefix(relPath, "..") {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
// Directory-level exclusions: skip entire subtree.
if d.IsDir() {
if isExcludedDir(relPath) {
stats.SkippedExcluded++
return filepath.SkipDir
}
return nil
}
stats.TotalWalked++
// File-level exclusions.
if isExcludedPath(relPath) {
stats.SkippedExcluded++
return nil
}
// Extension whitelist: reject unknown / unsafe extensions (defense in
// depth). strings.ToLower normalizes ASCII (PNG → png); full-width
// unicode chars bypass — acceptable for server-side rescans.
// Double-extension: filepath.Ext returns only the LAST segment, so
// "malicious.txt.exe" → ".exe" → skipped (safe), but
// "malicious.exe.txt" → ".txt" → whitelisted as note. Accepted.
ext := strings.ToLower(filepath.Ext(relPath))
if included, _ := isIncludedExtension(ext); !included {
stats.SkippedExcluded++
slog.Debug("vault.walk: skipped unknown extension", "path", relPath, "ext", ext)
return nil
}
// File info for size check.
info, infoErr := d.Info()
if infoErr != nil {
return nil // skip unreadable
}
// Per-file size limit.
if opts.MaxFileBytes > 0 && info.Size() > opts.MaxFileBytes {
stats.SkippedTooLarge++
return nil
}
// Total size limit.
if opts.MaxTotalBytes > 0 && totalBytes+info.Size() > opts.MaxTotalBytes {
stats.Truncated = true
return filepath.SkipAll
}
// File count limit.
if opts.MaxFiles > 0 && len(entries) >= opts.MaxFiles {
stats.Truncated = true
return filepath.SkipAll
}
totalBytes += info.Size()
stats.Eligible++
entries = append(entries, WalkEntry{
RelPath: relPath,
AbsPath: path,
Size: info.Size(),
ModTime: info.ModTime(),
})
return nil
})
// Context cancellation is expected, not an error for partial results.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return entries, stats, err
}
return entries, stats, err
}
// isExcludedDir returns true if an entire directory subtree should be skipped.
// relPath is the walk-relative path of the directory (e.g. "agents/my-bot/web-fetch").
func isExcludedDir(relPath string) bool {
// Get the directory's own name (last segment).
dirName := filepath.Base(relPath)
// Skip memory/ at any depth.
if dirName == "memory" {
return true
}
// Skip hidden dirs (. prefix) EXCEPT .uploads (user content).
if strings.HasPrefix(dirName, ".") && dirName != ".uploads" {
return true
}
// Skip web-fetch/ at any depth — content from external URLs may be dangerous.
if dirName == "web-fetch" {
return true
}
return false
}
// contextFiles are root-level bootstrap files managed by ContextFileInterceptor.
var contextFiles = map[string]bool{
"SOUL.md": true,
"IDENTITY.md": true,
"USER.md": true,
"BOOTSTRAP.md": true,
"AGENTS.md": true,
"TOOLS.md": true,
"CAPABILITIES.md": true,
"MEMORY.md": true,
"AGENTS_CORE.md": true,
"AGENTS_TASK.md": true,
}
// isExcludedPath returns true if a file should be excluded from vault registration.
// Defense-in-depth: checks ALL parent directory segments for exclusions.
func isExcludedPath(relPath string) bool {
// Check every directory segment in the path.
dir := filepath.Dir(relPath)
for dir != "." && dir != "/" {
seg := filepath.Base(dir)
if seg == "memory" || seg == "web-fetch" {
return true
}
if strings.HasPrefix(seg, ".") && seg != ".uploads" {
return true
}
dir = filepath.Dir(dir)
}
base := filepath.Base(relPath)
// SQLite database files.
if strings.HasSuffix(base, ".db") || strings.HasSuffix(base, ".db-wal") || strings.HasSuffix(base, ".db-shm") {
return true
}
// Root-level context files only (not nested).
if !strings.Contains(relPath, "/") && contextFiles[base] {
return true
}
// Hidden files at root level.
if strings.HasPrefix(base, ".") && !strings.Contains(relPath, "/") {
return true
}
return false
}