From f65f0b7826fd51d557f7b55a836ea8f0eb856f5f Mon Sep 17 00:00:00 2001 From: Mathias Date: Mon, 5 Oct 2026 23:28:38 +0200 Subject: [PATCH] fix(audit): each ntfy alert source sends its own title (#163) Every alert was titled "brain-capture audit degraded", including the symlink tripwire's and governance-edit alerts. NtfyNotifier.WithTitle returns a titled copy (nil stays nil); buildNotifier(getenv, title) is called per source: capture audit (unchanged title), "brain symlink tripwire", "brain governance edit". Tests assert the Title header. Fixes #163. Co-Authored-By: Claude Opus 5.5 --- ingestion/cmd/server/main.go | 15 ++++++----- ingestion/cmd/server/symlink_check_test.go | 16 ++++++++++++ ingestion/internal/audit/ntfy.go | 21 ++++++++++++++- ingestion/internal/audit/ntfy_title_test.go | 29 +++++++++++++++++++++ 4 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 ingestion/internal/audit/ntfy_title_test.go diff --git a/ingestion/cmd/server/main.go b/ingestion/cmd/server/main.go index db721e4..5b2fbb8 100644 --- a/ingestion/cmd/server/main.go +++ b/ingestion/cmd/server/main.go @@ -179,9 +179,12 @@ func alertingWarnings(getenv func(string) string) []string { return out } -func buildNotifier() audit.Notifier { - if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil { - return n +// buildNotifier returns the ntfy notifier for one alert source, titled so +// the source is recognisable (hyperguild#163), or nil when ntfy is not +// configured. +func buildNotifier(getenv func(string) string, title string) audit.Notifier { + if n := audit.NewNtfyNotifier(getenv("BRAIN_NTFY_URL"), getenv("BRAIN_NTFY_TOKEN")); n != nil { + return n.WithTitle(title) } return nil } @@ -203,7 +206,7 @@ func buildAuditSink(ctx context.Context, brainDir string, logger *slog.Logger) c logger.Error("capture audit buffer init", "err", err) os.Exit(1) } - notifier := buildNotifier() + notifier := buildNotifier(os.Getenv, "brain-capture audit degraded") reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval) logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds())) @@ -366,7 +369,7 @@ func main() { ctx := context.Background() // hyperguild#157: report a symlink that appears in the brain while running. - go symlinkguard.Watch(ctx, brainDir, symlinkScanInterval(os.Getenv), buildNotifier()) + go symlinkguard.Watch(ctx, brainDir, symlinkScanInterval(os.Getenv), buildNotifier(os.Getenv, "brain symlink tripwire")) // Never fatal on an unreachable issuer (#83): on a cold cluster boot this // pod can beat dex to serving and get a 502 from ingress, and exiting meant @@ -546,7 +549,7 @@ func main() { // capture already gets, plus an out-of-band alert. Wired here because // brain_edit_root needs the capture plumbing anyway — that is what // re-derives the static principal its gate checks. - mcpSrv.WithGovernanceObserver(auditSink, buildNotifier()) + mcpSrv.WithGovernanceObserver(auditSink, buildNotifier(os.Getenv, "brain governance edit")) logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign), "governance_observer", true) } else { diff --git a/ingestion/cmd/server/symlink_check_test.go b/ingestion/cmd/server/symlink_check_test.go index 4dcf12c..6ba5acf 100644 --- a/ingestion/cmd/server/symlink_check_test.go +++ b/ingestion/cmd/server/symlink_check_test.go @@ -1,6 +1,9 @@ package main import ( + "context" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -74,3 +77,16 @@ func TestStartupSymlinkCheckRefusesABrainWithAFIFO(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "classification.yaml") } + +// hyperguild#163: each alert source gets its own ntfy title. +func TestBuildNotifierUsesTheGivenTitle(t *testing.T) { + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = r.Header.Get("Title") })) + defer srv.Close() + env := map[string]string{"BRAIN_NTFY_URL": srv.URL} + n := buildNotifier(func(k string) string { return env[k] }, "brain symlink tripwire") + require.NotNil(t, n) + require.NoError(t, n.Notify(context.Background(), "m")) + assert.Equal(t, "brain symlink tripwire", got) + assert.Nil(t, buildNotifier(func(string) string { return "" }, "x"), "no ntfy URL, no notifier") +} diff --git a/ingestion/internal/audit/ntfy.go b/ingestion/internal/audit/ntfy.go index 9208c85..282c506 100644 --- a/ingestion/internal/audit/ntfy.go +++ b/ingestion/internal/audit/ntfy.go @@ -13,9 +13,14 @@ import ( type NtfyNotifier struct { topicURL string token string + title string http *http.Client } +// defaultNtfyTitle is the title of a notifier built without WithTitle: the +// capture audit's degraded-state alert, its first user. +const defaultNtfyTitle = "brain-capture audit degraded" + // NewNtfyNotifier constructs a notifier for the given ntfy topic URL // (e.g. https://ntfy.sh/my-topic). token is an optional bearer for // protected ntfy instances; it is held here and only sent in the @@ -27,17 +32,31 @@ func NewNtfyNotifier(topicURL, token string) *NtfyNotifier { return &NtfyNotifier{ topicURL: strings.TrimRight(topicURL, "/"), token: token, + title: defaultNtfyTitle, http: &http.Client{Timeout: 10 * time.Second}, } } +// WithTitle returns a copy that sends title as the ntfy Title header, so each +// alert source (the capture audit, the symlink tripwire, governance edits) +// is recognisable on the phone (hyperguild#163). A nil notifier (ntfy not +// configured) stays nil. +func (n *NtfyNotifier) WithTitle(title string) *NtfyNotifier { + if n == nil { + return nil + } + c := *n + c.title = title + return &c +} + // Notify posts a message to the ntfy topic. func (n *NtfyNotifier) Notify(ctx context.Context, msg string) error { req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.topicURL, strings.NewReader(msg)) if err != nil { return err } - req.Header.Set("Title", "brain-capture audit degraded") + req.Header.Set("Title", n.title) req.Header.Set("Priority", "high") req.Header.Set("Tags", "warning,brain") if n.token != "" { diff --git a/ingestion/internal/audit/ntfy_title_test.go b/ingestion/internal/audit/ntfy_title_test.go new file mode 100644 index 0000000..d42b1ec --- /dev/null +++ b/ingestion/internal/audit/ntfy_title_test.go @@ -0,0 +1,29 @@ +package audit + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// hyperguild#163: every alert went out titled "brain-capture audit +// degraded", including the symlink tripwire's and governance alerts. +func TestNtfyNotifierSendsItsOwnTitle(t *testing.T) { + var got []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = append(got, r.Header.Get("Title")) + })) + defer srv.Close() + base := NewNtfyNotifier(srv.URL, "") + require.NoError(t, base.Notify(context.Background(), "m")) + require.NoError(t, base.WithTitle("brain symlink tripwire").Notify(context.Background(), "m")) + require.NoError(t, base.Notify(context.Background(), "m")) + assert.Equal(t, []string{"brain-capture audit degraded", "brain symlink tripwire", "brain-capture audit degraded"}, got, + "WithTitle returns a copy; the base keeps its default title") + var nilN *NtfyNotifier + assert.Nil(t, nilN.WithTitle("x"), "nil (ntfy not configured) stays nil") +}