// Package symlinkguard is the hyperguild#157 tripwire. The brain directory is // a Syncthing sendreceive folder and Syncthing syncs symlinks; several brain // readers (search, vectorstore, context, graph) follow a symlink out of the // brain and would index or return whatever it points to. Until every reader // reads through an os.Root, any symlink inside the brain is treated as an // incident: the server refuses to start on one (main), and Watch reports one // that appears while it runs. package symlinkguard import ( "context" "fmt" "io/fs" "log/slog" "os" "path/filepath" "sort" "strings" "time" ) // Notifier is the out-of-band alert sink (audit.Notifier satisfies it). type Notifier interface { Notify(ctx context.Context, msg string) error } // Scan returns the brain-relative paths of every symlink inside brainDir, // skipping .git. brainDir itself may be a symlink (a volume mount); it is // resolved first, and only entries inside it are checked. WalkDir never // descends a symlinked directory, so each link is reported, not followed. func Scan(brainDir string) ([]string, error) { root, err := filepath.EvalSymlinks(brainDir) if err != nil { return nil, fmt.Errorf("resolve brain dir: %w", err) } var found []string err = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() && d.Name() == ".git" { return filepath.SkipDir } if d.Type()&os.ModeSymlink != 0 { rel, _ := filepath.Rel(root, p) found = append(found, filepath.ToSlash(rel)) } return nil }) if err != nil { return nil, fmt.Errorf("scan brain dir: %w", err) } sort.Strings(found) return found, nil } // Watch rescans every interval and notifies once per distinct set of // symlinks found, until ctx ends. A scan error is logged, not fatal. func Watch(ctx context.Context, brainDir string, interval time.Duration, n Notifier) { t := time.NewTicker(interval) defer t.Stop() last := "" for { select { case <-ctx.Done(): return case <-t.C: } found, err := Scan(brainDir) if err != nil { slog.Warn("symlink tripwire scan failed", "err", err) continue } key := strings.Join(found, "\n") if key == last { continue } last = key if len(found) == 0 { continue } msg := fmt.Sprintf("brain symlink tripwire (hyperguild#157): %d symlink(s) in the brain dir; "+ "readers may follow them out of the brain. Remove them: %s", len(found), strings.Join(found, ", ")) slog.Error("brain symlink tripwire", "count", len(found), "paths", found) if n != nil { if err := n.Notify(ctx, msg); err != nil { slog.Warn("symlink tripwire notify failed", "err", err) } } } }