Never send the full nudge body off-box (#368)

The away sinks fell back to the whole Body when Summary was empty. ntfy and
telegram leave the box, and the 0.8B phraser drops fields regularly, so that
fallback could push full detail off the machine.

The dispatcher already strips detail from away sendables. This exports that
one rule as delivery.AwayMessage and has both sinks use it, so a sink can't
leak the body on its own either: empty Summary means a generic line plus the
rule name, never the body.

The two sink tests named TestSendFallsBackToBodyWhenSummaryEmpty asserted the
old, wrong behaviour, so they are rewritten to assert the generic line.
TestSendRejectsEmptyMessage is likewise replaced: an away message can no
longer be empty, so the sink has nothing left to reject.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
This commit is contained in:
kami
2026-07-31 14:23:54 +04:00
parent 3dbf67f8f9
commit e9ff2c4912
5 changed files with 61 additions and 47 deletions
+7
View File
@@ -396,6 +396,13 @@ func messageForChannel(s Sendable) string {
if !isAway(s.Channel) { if !isAway(s.Channel) {
return s.Body return s.Body
} }
return AwayMessage(s)
}
// AwayMessage — the only text an off-box channel may ever carry. Exported so
// the away sinks share this one rule instead of each inventing a fallback: the
// summary if we have one, otherwise a fixed generic line. Never the body.
func AwayMessage(s Sendable) string {
if s.Summary != "" { if s.Summary != "" {
return s.Summary return s.Summary
} }
+9 -13
View File
@@ -2,10 +2,10 @@
// //
// ntfy is the away-channel for sev3 (ops soft) nudges, sev4 (ops hard) // ntfy is the away-channel for sev3 (ops soft) nudges, sev4 (ops hard)
// nudges when present (alongside voice), and reminders when away. the // nudges when present (alongside voice), and reminders when away. the
// message body is the Sendable's Summary — the minimal-body rule from the // message body is delivery.AwayMessage — the minimal-body rule from the
// spec ("disk low on homesrv," not detail; no shoulder-surf exfil through // spec ("disk low on homesrv," not detail; no shoulder-surf exfil through
// the relay). voice gets Body; away channels get Summary, enforced at the // the relay). the dispatcher already strips detail off away sendables; the
// sink so a phraser bug can't exfil. // sink uses the same helper so it can't leak the body on its own either.
// //
// ntfy runs locally (docker, 127.0.0.1:8085, deny-all auth). maven publishes // ntfy runs locally (docker, 127.0.0.1:8085, deny-all auth). maven publishes
// with a dedicated user (write-only to maven-* topics) — the credential is a // with a dedicated user (write-only to maven-* topics) — the credential is a
@@ -69,18 +69,14 @@ func New(cfg Config) (*Sink, error) {
}, nil }, nil
} }
// Send publishes one notification to ntfy. the body is the Sendable's Summary // Send publishes one notification to ntfy. the body is the minimal away
// (minimal body); Title is "maven" (consistent sender identity on the lock // message (never the full body); Title is "maven" (consistent sender identity
// screen — the content is in the body). Priority maps from severity/kind so // on the lock screen — the content is in the body). Priority maps from severity/kind so
// the phone client can ring differently for an alarm vs a soft ops nudge. // the phone client can ring differently for an alarm vs a soft ops nudge.
func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error { func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
body := d.Summary // never fall back to d.Body: ntfy leaves the box, so an empty summary gets
if body == "" { // a generic line instead of the full detail.
body = d.Body // terse full message beats no message body := delivery.AwayMessage(d)
}
if body == "" {
return fmt.Errorf("ntfysink: empty message for %s", d.Channel)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.topicURL(), strings.NewReader(body)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.topicURL(), strings.NewReader(body))
if err != nil { if err != nil {
+16 -9
View File
@@ -147,9 +147,9 @@ func TestSendBodyIsSummaryNotFullBody(t *testing.T) {
} }
} }
func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) { func TestSendNeverSendsTheBodyWhenSummaryEmpty(t *testing.T) {
// a terse full message is better than no message; the phraser should // #368: this used to fall back to the full body. ntfy leaves the box, so
// produce a summary for away-bound severities, but don't silently drop. // an empty summary gets a fixed generic line plus the rule name instead.
rs := newRecordingServer(t, 200, "") rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler()) srv := httptest.NewServer(rs.handler())
defer srv.Close() defer srv.Close()
@@ -160,12 +160,15 @@ func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) {
t.Fatalf("Send: %v", err) t.Fatalf("Send: %v", err)
} }
_, _, body, _, _, _ := rs.snapshot() _, _, body, _, _, _ := rs.snapshot()
if body != s.Body { want := delivery.GenericAwayMessage + ": service_down"
t.Fatalf("fallback body: want %q, got %q", s.Body, body) if body != want {
t.Fatalf("body: want %q, got %q", want, body)
} }
} }
func TestSendRejectsEmptyMessage(t *testing.T) { func TestSendNeverSendsAnEmptyMessage(t *testing.T) {
// with nothing at all to say we still send the generic line — an away
// channel can never carry detail, but it also never goes out blank.
rs := newRecordingServer(t, 200, "") rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler()) srv := httptest.NewServer(rs.handler())
defer srv.Close() defer srv.Close()
@@ -173,9 +176,13 @@ func TestSendRejectsEmptyMessage(t *testing.T) {
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
s := nudgeSendable(loop.Sev3, "") s := nudgeSendable(loop.Sev3, "")
s.Body = "" s.Body = ""
err := sink.Send(context.Background(), s) s.RuleName = ""
if err == nil { if err := sink.Send(context.Background(), s); err != nil {
t.Fatal("want error for empty message") t.Fatalf("Send: %v", err)
}
_, _, body, _, _, _ := rs.snapshot()
if body != delivery.GenericAwayMessage {
t.Fatalf("body: want %q, got %q", delivery.GenericAwayMessage, body)
} }
} }
+12 -16
View File
@@ -2,11 +2,12 @@
// //
// telegram is the away-channel for sev4 (ops hard) nudges — "disk-fire alarm // telegram is the away-channel for sev4 (ops hard) nudges — "disk-fire alarm
// at 2am routes to telegram, repeat til ack." the message body is the // at 2am routes to telegram, repeat til ack." the message body is the
// Sendable's Summary — the minimal-body rule from the spec ("disk low on // delivery.AwayMessage — the minimal-body rule from the spec ("disk low on
// homesrv," not detail; no shoulder-surf exfil through the relay). voice gets // homesrv," not detail; no shoulder-surf exfil through the relay). the
// Body; away channels get Summary, enforced at the sink so a phraser bug can't // dispatcher already strips detail off away sendables; the sink uses the same
// exfil. additionally, protect_content=true is passed on every send so the // helper so it can't leak the body on its own either. additionally,
// message can't be forwarded out of the chat — locks the minimal body further. // protect_content=true is passed on every send so the message can't be
// forwarded out of the chat — locks the minimal body further.
// //
// telegram's bot API is region-restricted for this homesrv — direct egress to // telegram's bot API is region-restricted for this homesrv — direct egress to
// api.telegram.org is unreliable. the spec's "away channels leave the box — // api.telegram.org is unreliable. the spec's "away channels leave the box —
@@ -140,18 +141,13 @@ type telegramResp struct {
} }
// Send publishes one message to the configured telegram chat. the body is the // Send publishes one message to the configured telegram chat. the body is the
// Sendable's Summary (minimal body); empty Summary falls back to Body (terse // minimal away message (never the full body). protect_content=true so even
// full message beats no message). protect_content=true so a phraser bug (Body // that can't be forwarded onward by the user or a chat observer — locks the
// leaking detail through Summary) can't be forwarded onward by the user or a // minimal-body rule at the channel's own last mile.
// chat observer — locks the minimal-body rule at the channel's own last mile.
func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error { func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
body := d.Summary // never fall back to d.Body: telegram leaves the box, so an empty summary
if body == "" { // gets a generic line instead of the full detail.
body = d.Body body := delivery.AwayMessage(d)
}
if body == "" {
return fmt.Errorf("telegramsink: empty message for %s", d.Channel)
}
payload := sendMessageReq{ payload := sendMessageReq{
ChatID: s.cfg.ChatID, ChatID: s.cfg.ChatID,
@@ -173,9 +173,9 @@ func TestSendBodyIsSummaryNotFullBody(t *testing.T) {
} }
} }
func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) { func TestSendNeverSendsTheBodyWhenSummaryEmpty(t *testing.T) {
// terse full message beats none; the phraser should produce a summary for // #368: this used to fall back to the full body. telegram leaves the box,
// away-bound severities, but don't silently drop. // so an empty summary gets a fixed generic line plus the rule name.
rs := newRecordingServer(t, 200, "") rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler()) srv := httptest.NewServer(rs.handler())
defer srv.Close() defer srv.Close()
@@ -188,12 +188,14 @@ func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) {
_, _, body, _, _ := rs.snapshot() _, _, body, _, _ := rs.snapshot()
var req sendMessageReq var req sendMessageReq
_ = json.Unmarshal([]byte(body), &req) _ = json.Unmarshal([]byte(body), &req)
if req.Text != s.Body { want := delivery.GenericAwayMessage + ": service_down"
t.Fatalf("fallback text: want %q, got %q", s.Body, req.Text) if req.Text != want {
t.Fatalf("text: want %q, got %q", want, req.Text)
} }
} }
func TestSendRejectsEmptyMessage(t *testing.T) { func TestSendNeverSendsAnEmptyMessage(t *testing.T) {
// with nothing at all to say we still send the generic line.
rs := newRecordingServer(t, 200, "") rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler()) srv := httptest.NewServer(rs.handler())
defer srv.Close() defer srv.Close()
@@ -201,9 +203,15 @@ func TestSendRejectsEmptyMessage(t *testing.T) {
sink, _ := New(sinkCfg(srv.URL)) sink, _ := New(sinkCfg(srv.URL))
s := nudgeSendable(loop.Sev4, "") s := nudgeSendable(loop.Sev4, "")
s.Body = "" s.Body = ""
err := sink.Send(context.Background(), s) s.RuleName = ""
if err == nil { if err := sink.Send(context.Background(), s); err != nil {
t.Fatal("want error for empty message") t.Fatalf("Send: %v", err)
}
_, _, body, _, _ := rs.snapshot()
var req sendMessageReq
_ = json.Unmarshal([]byte(body), &req)
if req.Text != delivery.GenericAwayMessage {
t.Fatalf("text: want %q, got %q", delivery.GenericAwayMessage, req.Text)
} }
} }