mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 04:14:56 +00:00
- 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
247 lines
6.3 KiB
Go
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
|
|
}
|