fix(ingestion): close the PR #159 review findings on the symlink tripwire
CI / Lint / Test / Vet (pull_request) Successful in 35s
CI / Mirror to GitHub (pull_request) Skipped

- Startup passed on ANY scan error: WalkDir stopped at the first
  unreadable dir and Scan discarded what it had found, while search
  kept walking and served a planted symlink. Scan now records
  unreadable dirs and keeps going; startup refuses on symlinks or
  unreadable dirs and only lets a missing brain dir pass.
- Watch went silent while a scan error persisted, and marked a set as
  reported before Notify, so an ntfy outage lost the alert. It now
  alerts on unreadable dirs and scan errors too, and records a set as
  reported only after Notify succeeds (retried each tick).
- Only the root .git is skipped; a nested .git is scanned.
- A non-positive BRAIN_SYMLINK_SCAN_INTERVAL fell into time.NewTicker
  and crashlooped the server; it now falls back to the default.
- Findings are quoted in alerts, so a newline in a name cannot merge
  two sets; alertingWarnings names the tripwire when ntfy is unset.
- Test names match what they assert (two-set dedupe, symlinked entries
  inside a symlinked root, nested .git).

Flipped: startup ignoring unreadable dirs, .git skipped at any depth,
and no retry each turn a test red. Watch tests stable over -count=10
and under -race.

Refs #157

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 17:11:45 +02:00
co-authored by Claude Opus 5.5
parent 796d226497
commit 956ad8b9fb
5 changed files with 242 additions and 75 deletions
+3 -2
View File
@@ -174,7 +174,8 @@ func alertingWarnings(getenv func(string) string) []string {
var out []string
if getenv("BRAIN_NTFY_URL") == "" {
out = append(out, "alerting DISABLED: BRAIN_NTFY_URL unset — "+
"brain_edit_root governance rewrites and capture audit degradation will not notify")
"brain_edit_root governance rewrites, capture audit degradation and the "+
"brain symlink tripwire (hyperguild#157) will not notify")
}
return out
}
@@ -366,7 +367,7 @@ func main() {
ctx := context.Background()
// hyperguild#157: report a symlink that appears in the brain while running.
go symlinkguard.Watch(ctx, brainDir, time.Duration(envInt("BRAIN_SYMLINK_SCAN_INTERVAL", 300))*time.Second, buildNotifier())
go symlinkguard.Watch(ctx, brainDir, symlinkScanInterval(os.Getenv), buildNotifier())
// Built before the watcher, the graph backfill and session ingestion, and
// retried rather than fatal (#83). Two separate reasons:
+23 -8
View File
@@ -1,24 +1,39 @@
package main
import (
"errors"
"fmt"
"strings"
"os"
"strconv"
"time"
"github.com/mathiasbq/hyperguild/ingestion/internal/symlinkguard"
)
// startupSymlinkCheck is the hyperguild#157 tripwire at boot: a symlink in the
// brain dir would let brain readers serve a file from outside the brain, so
// the server does not start until it is removed. A scan that cannot run (e.g.
// a missing brain dir on first boot) is not treated as an incident.
// the server does not start until it is removed. A directory the scan cannot
// read could hide one, so it refuses on that too. A missing brain dir (first
// boot) is the only case it lets pass.
func startupSymlinkCheck(brainDir string) error {
found, err := symlinkguard.Scan(brainDir)
r, err := symlinkguard.Scan(brainDir)
if err != nil {
return nil
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("refusing to start: cannot scan the brain dir %s (hyperguild#157): %w", brainDir, err)
}
if len(found) > 0 {
return fmt.Errorf("refusing to start: %d symlink(s) in the brain dir %s (hyperguild#157); remove them: %s",
len(found), brainDir, strings.Join(found, ", "))
if !r.Clean() {
return fmt.Errorf("refusing to start: brain dir %s has %s (hyperguild#157); fix them first", brainDir, r)
}
return nil
}
// symlinkScanInterval reads BRAIN_SYMLINK_SCAN_INTERVAL (seconds), falling
// back to the default on an unset, non-numeric or non-positive value.
func symlinkScanInterval(getenv func(string) string) time.Duration {
if s, err := strconv.Atoi(getenv("BRAIN_SYMLINK_SCAN_INTERVAL")); err == nil && s > 0 {
return time.Duration(s) * time.Second
}
return symlinkguard.DefaultInterval
}
+31 -2
View File
@@ -3,6 +3,7 @@ package main
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -10,7 +11,8 @@ import (
)
// hyperguild#157: the server refuses to start while a symlink exists in the
// brain dir (Mathias's chosen tripwire), naming it; a clean brain starts.
// brain dir, or while any part of it cannot be scanned (which could hide
// one); a missing brain dir is the only thing it lets pass.
func TestStartupSymlinkCheckRefusesABrainWithASymlink(t *testing.T) {
brain := t.TempDir()
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "planted")))
@@ -19,8 +21,35 @@ func TestStartupSymlinkCheckRefusesABrainWithASymlink(t *testing.T) {
assert.Contains(t, err.Error(), "planted")
}
func TestStartupSymlinkCheckPassesACleanBrain(t *testing.T) {
func TestStartupSymlinkCheckRefusesABrainWithAnUnreadableDir(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root reads mode-000 directories")
}
brain := t.TempDir()
locked := filepath.Join(brain, "aaa")
require.NoError(t, os.MkdirAll(locked, 0o755))
require.NoError(t, os.Chmod(locked, 0o000))
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
err := startupSymlinkCheck(brain)
require.Error(t, err)
assert.Contains(t, err.Error(), "aaa")
}
func TestStartupSymlinkCheckPassesACleanOrMissingBrain(t *testing.T) {
brain := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(brain, "n.md"), []byte("x"), 0o644))
assert.NoError(t, startupSymlinkCheck(brain))
assert.NoError(t, startupSymlinkCheck(filepath.Join(brain, "missing")))
}
func TestSymlinkScanIntervalFallsBackOnNonPositive(t *testing.T) {
for _, v := range []string{"0", "-5", "", "junk"} {
got := symlinkScanInterval(func(string) string { return v })
assert.Positive(t, got, "env %q", v)
}
}
func TestAlertingWarningsNamesTheSymlinkTripwire(t *testing.T) {
w := strings.Join(alertingWarnings(func(string) string { return "" }), " ")
assert.Contains(t, w, "symlink tripwire")
}