From a76b0742ffd4bb57a5ecd0a7fa08389fbd73b30e Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 16 Oct 2026 01:57:06 +0200 Subject: [PATCH 1/2] mail_send's ask names recipients and masks addresses so the operator channel can carry it (issue 868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit messenger's content rule (ADR 0253 §3) refuses any message carrying a mail address, so mail_send's ask never reached the operator: no send could ever be approved. A recipient that is one of the operator's own mesh-mail accounts is now named by that account's name; any other loses its address and host; the subject and body's first lines have every address and link masked before they reach the ask. The mail itself is sent unchanged. Tests run messenger's real content rule against the asks mail_send builds (content.go copied verbatim as content_test.go, pinned byte-identical by copies_test.go, since messenger's package main cannot be imported from another module). --- modules/mesh-mail/cmd/mesh-mail/ask_test.go | 135 +++++++++++ .../mesh-mail/cmd/mesh-mail/content_test.go | 215 ++++++++++++++++++ .../mesh-mail/cmd/mesh-mail/copies_test.go | 30 +++ modules/mesh-mail/cmd/mesh-mail/send.go | 146 +++++++++++- modules/mesh-mail/cmd/mesh-mail/send_test.go | 9 +- modules/mesh-mail/cmd/mesh-mail/tools.go | 3 +- 6 files changed, 523 insertions(+), 15 deletions(-) create mode 100644 modules/mesh-mail/cmd/mesh-mail/ask_test.go create mode 100644 modules/mesh-mail/cmd/mesh-mail/content_test.go create mode 100644 modules/mesh-mail/cmd/mesh-mail/copies_test.go diff --git a/modules/mesh-mail/cmd/mesh-mail/ask_test.go b/modules/mesh-mail/cmd/mesh-mail/ask_test.go new file mode 100644 index 00000000..ad7144ac --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/ask_test.go @@ -0,0 +1,135 @@ +package main + +// mail_send raises its ask on the operator channel, and messenger's content rule (ADR 0253 §3) refuses any +// message that carries a mail address, a URL or a bare host name. ADR 0361 §2 needs the ask to show the +// account, the recipients, the subject and the first lines regardless — so the ask must say all of that in +// a form the content rule lets through (novox/hq issue 868, "Decided" note on issue 868). These tests build +// real asks through mail_send and judge them with the real rule, copied verbatim as content_test.go. + +import ( + "encoding/json" + "strings" + "testing" +) + +// wholeAsk is every word the operator sees for one ask, the same text messenger's content rule judges as a +// whole (the asks router hands it Headline, Explanation and Details together). +func wholeAsk(r *sendRouter, id string) string { + a := r.published[id] + return a.Headline + "\n" + a.Explanation + "\n" + a.Details +} + +// mustNotBeRefused fails the test naming what the content rule refused, and why. +func mustNotBeRefused(t *testing.T, whole string) { + t.Helper() + if refusal, ok := Check(whole); !ok { + t.Fatalf("the content rule refuses the ask: %s\n%s", refusal, whole) + } +} + +// (1) A recipient that is one of the operator's own mesh-mail accounts (sendTestBundle's "mesh", address +// jschoubben@novox.be) is named by the account's name alone: the ask carries no mail address at all, and +// passes the real content rule. This is the first test issue 868's decision asked for; it fails today +// because AskMailSend puts the raw address into the ask. +func TestMailSendAskForOwnAccountRecipientCarriesNoAddress(t *testing.T) { + b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + rt := startBundle(t, b.Tools()) + + args := map[string]any{"account": "mesh", "to": []any{"jschoubben@novox.be"}, + "subject": "Testing issue 868", "body": "Does the ask reach the operator now?"} + text, isErr := rt.call(SendTool, "novox.triage", args) + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + if json.Unmarshal([]byte(text), &out) != nil { + t.Fatalf("not JSON: %s", text) + } + id, _ := out["ask"].(string) + if id == "" { + t.Fatalf("%s", text) + } + whole := wholeAsk(router, id) + if strings.Contains(whole, "@") { + t.Errorf("the ask carries a mail address for its own account recipient:\n%s", whole) + } + if !strings.Contains(whole, "your account mesh") { + t.Errorf("the ask does not name the own account:\n%s", whole) + } + mustNotBeRefused(t, whole) +} + +// (2) A recipient outside the operator's own accounts is said without its address or host: its first +// letter, an ellipsis and the domain's labels as words. +func TestMailSendAskForOutsideRecipientCarriesNoAddress(t *testing.T) { + b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + rt := startBundle(t, b.Tools()) + + text, isErr := rt.call(SendTool, "novox.triage", sendArgs) // to: ops@example.org, lead@example.org + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + _ = json.Unmarshal([]byte(text), &out) + id := out["ask"].(string) + whole := wholeAsk(router, id) + if strings.Contains(whole, "@") || strings.Contains(whole, "example.org") { + t.Errorf("the ask carries the recipients' address or host:\n%s", whole) + } + for _, want := range []string{"o… at example org", "l… at example org"} { + if !strings.Contains(whole, want) { + t.Errorf("the ask does not say %q:\n%s", want, whole) + } + } + mustNotBeRefused(t, whole) +} + +// (3) More than three recipients are counted rather than said one by one. +func TestMailSendAskForManyRecipientsCountsTheRest(t *testing.T) { + b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + rt := startBundle(t, b.Tools()) + + args := map[string]any{"account": "mesh", + "to": []any{"a@example.org", "b@example.org", "c@example.org", "d@example.org", "e@example.org"}, + "subject": "Many recipients", "body": "body"} + text, isErr := rt.call(SendTool, "novox.triage", args) + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + _ = json.Unmarshal([]byte(text), &out) + id := out["ask"].(string) + whole := wholeAsk(router, id) + if strings.Contains(whole, "@") { + t.Errorf("the ask carries a mail address among many recipients:\n%s", whole) + } + if !strings.Contains(whole, "and 2 more") { + t.Errorf("the ask does not count the recipients past the first three:\n%s", whole) + } + mustNotBeRefused(t, whole) +} + +// (4) A subject holding a mail address and a link has both masked before the ask carries it. +func TestMailSendAskMasksAnAddressAndALinkInTheSubject(t *testing.T) { + b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + rt := startBundle(t, b.Tools()) + + args := map[string]any{"account": "mesh", "to": []any{"ops@example.org"}, + "subject": "Contact ops@example.org or see https://example.com/issue/42", + "body": "See https://example.com/issue/42 and write to ops@example.org for details."} + text, isErr := rt.call(SendTool, "novox.triage", args) + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + _ = json.Unmarshal([]byte(text), &out) + id := out["ask"].(string) + whole := wholeAsk(router, id) + if strings.Contains(whole, "@") || strings.Contains(whole, "example.com") || strings.Contains(whole, "://") { + t.Errorf("the ask carries an unmasked address or link:\n%s", whole) + } + if !strings.Contains(whole, "[address]") || !strings.Contains(whole, "[link]") { + t.Errorf("the ask does not say [address] and [link] in place of what it masked:\n%s", whole) + } + mustNotBeRefused(t, whole) +} diff --git a/modules/mesh-mail/cmd/mesh-mail/content_test.go b/modules/mesh-mail/cmd/mesh-mail/content_test.go new file mode 100644 index 00000000..2c0b5552 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/content_test.go @@ -0,0 +1,215 @@ +package main + +// What may leave the mesh (novox/hq to-be 45 §5, research 028 Q8, ADR 0227): roles and words. A +// message carrying an address, a path or anything shaped like a secret is refused here, by the holder, +// because Telegram is not end-to-end encrypted for bots and this rule is the only thing between a +// condition's words and someone else's server. It is not trusted to each source. +// +// Deliberately wider than it must be: a commit id or a long random name is refused too. A source +// that wants its message through says it in words; a refusal is said, never silent. + +import ( + "math" + "regexp" + "strings" + "unicode" +) + +// Refusal says why a text may not leave: the class of what it carried, never the text itself. +type Refusal struct { + Class string // address, path or secret + What string // a few words: "an IPv4 address", "a URL", … +} + +func (r Refusal) String() string { return r.Class + " (" + r.What + ")" } + +var ( + reURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://`) + reEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}`) + reIPv4 = regexp.MustCompile(`\b\d{1,3}(\.\d{1,3}){3}\b`) + reIPv6 = regexp.MustCompile(`(?i)(^|[^0-9a-z:])(([0-9a-f]{1,4}:){4,7}[0-9a-f]{1,4}|([0-9a-f]{1,4}:)*[0-9a-f]{0,4}::([0-9a-f]{1,4}:)*[0-9a-f]{0,4})([^0-9a-z:]|$)`) + reMAC = regexp.MustCompile(`(?i)\b([0-9a-f]{2}[:-]){5}[0-9a-f]{2}\b`) + rePEM = regexp.MustCompile(`-----BEGIN [A-Z ]+-----`) + reJWT = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}`) + reBotToken = regexp.MustCompile(`\b\d{6,}:[A-Za-z0-9_-]{30,}`) + reKnown = regexp.MustCompile(`\b(gh[pousr]_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9_-]{16,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})`) + reAssigned = regexp.MustCompile(`(?i)\b(password|passwd|passphrase|secret|token|api[_-]?key|apikey|credential|private[_-]?key)\s*[=:]\s*\S`) + reHex = regexp.MustCompile(`(?i)\b[0-9a-f]{32,}\b`) + reRun = regexp.MustCompile(`[A-Za-z0-9+/=_]{20,}`) + reWinPath = regexp.MustCompile(`(?i)\b[a-z]:\\`) +) + +// topLevel are names that end a host name — the generic and country ones a mesh's names use, and the +// private ones (.internal, .lan, .home, .local) a mesh is likelier to. +var topLevel = map[string]bool{} + +func init() { + for _, t := range strings.Fields(`com net org edu gov mil int io dev app cloud ai co me info biz xyz + site online tech page link + be nl de fr uk lu eu ch at it es pt se no dk fi pl cz us ca au nz jp cn ru in br ie + internal lan home local localdomain corp intranet private arpa test example invalid localhost`) { + topLevel[t] = true + } +} + +// Check says whether a text may leave the mesh, and when not, why. +func Check(text string) (Refusal, bool) { + switch { + case reURL.MatchString(text): + return Refusal{"address", "a URL"}, false + case reEmail.MatchString(text): + return Refusal{"address", "a mail address"}, false + case reIPv4.MatchString(text): + return Refusal{"address", "an IPv4 address"}, false + case reMAC.MatchString(text): + return Refusal{"address", "a hardware address"}, false + case reIPv6.MatchString(text): + return Refusal{"address", "an IPv6 address"}, false + } + if refusal, ok := secretShape(text); !ok { + return refusal, false + } + if reWinPath.MatchString(text) { + return Refusal{"path", "a drive path"}, false + } + if refusal, ok := randomRun(text); !ok { + return refusal, false + } + for _, word := range strings.FieldsFunc(text, func(r rune) bool { + return unicode.IsSpace(r) || strings.ContainsRune("\"'`()[]{}<>,;|", r) + }) { + w := strings.TrimRight(word, ".:!?") + if isPath(w) { + return Refusal{"path", "a file path"}, false + } + if isHostName(w) { + return Refusal{"address", "a host name"}, false + } + } + return Refusal{}, true +} + +// CheckSecret says whether a text carries a secret's shape — the one class that leaves the mesh nowhere. It is +// what an ask's whole words (asks.Ask.Whole, novox/hq issue 383) are held to: on a kind that proves who answers, +// a path or an address is what the operator approves and may be shown, a secret never. A path is read as one: +// a run with a slash in it is judged piece by piece between the slashes, so "/mnt/Photos_2024/Jochen" is a +// path and not a random string (the review of 2026-10-10); the named shapes — a key block, a signed token, a +// bot token, a known token shape, a value given to a secret's name, a long hexadecimal string — hold whatever +// the run holds. +func CheckSecret(text string) (Refusal, bool) { + if refusal, ok := secretShape(text); !ok { + return refusal, false + } + return randomRunOutsidePaths(text) +} + +// randomRunOutsidePaths is randomRun with each run that holds a slash judged by its pieces between the slashes. +func randomRunOutsidePaths(text string) (Refusal, bool) { + for _, run := range reRun.FindAllString(text, -1) { + for _, piece := range strings.Split(run, "/") { + if len(piece) >= 20 && looksRandom(piece) { + return Refusal{"secret", "a long random-looking string"}, false + } + } + } + return Refusal{}, true +} + +// secretShape is the secret shapes a pattern names. +func secretShape(text string) (Refusal, bool) { + switch { + case rePEM.MatchString(text): + return Refusal{"secret", "a key block"}, false + case reJWT.MatchString(text): + return Refusal{"secret", "a signed token"}, false + case reBotToken.MatchString(text): + return Refusal{"secret", "a bot token"}, false + case reKnown.MatchString(text): + return Refusal{"secret", "a known token shape"}, false + case reAssigned.MatchString(text): + return Refusal{"secret", "a value given to a secret's name"}, false + case reHex.MatchString(text): + return Refusal{"secret", "a long hexadecimal string"}, false + } + return Refusal{}, true +} + +// randomRun is a long unbroken run of characters spread as a random string's are. +func randomRun(text string) (Refusal, bool) { + for _, run := range reRun.FindAllString(text, -1) { + if looksRandom(run) { + return Refusal{"secret", "a long random-looking string"}, false + } + } + return Refusal{}, true +} + +// isPath: absolute, home-relative or dot-relative, or two separators deep. A mesh address names one +// machine and one tool (`ace/postgres.query`) and has one; a ratio ("3/4") has digits only. +func isPath(w string) bool { + if w == "" { + return false + } + if strings.HasPrefix(w, "/") && len(w) > 1 { + return true + } + for _, p := range []string{"~/", "./", "../", "$HOME", "${"} { + if strings.HasPrefix(w, p) { + return true + } + } + return strings.Count(w, "/") >= 2 || strings.Contains(w, "\\") +} + +// isHostName: two names or more, the last a top-level one. A condition key's last name is its kind +// (`machine.ace.silent`), which none of these is. +func isHostName(w string) bool { + w = strings.ToLower(w) + if w == "localhost" { + return true + } + parts := strings.Split(w, ".") + if len(parts) < 2 { + return false + } + for _, p := range parts { + if p == "" { + return false + } + } + return topLevel[parts[len(parts)-1]] +} + +// looksRandom: letters and digits mixed, and the characters spread as a random string's are. A +// sentence's words are separated, so only an unbroken run reaches here. +func looksRandom(s string) bool { + var letters, digits int + counts := map[rune]int{} + for _, r := range s { + counts[r]++ + switch { + case unicode.IsLetter(r): + letters++ + case unicode.IsDigit(r): + digits++ + } + } + if letters == 0 || digits == 0 { + // One class only: a word, or a number. A long number of digits alone is a count or a time. + return letters > 0 && hasUpperAndLower(s) && entropy(counts, len(s)) >= 4.0 + } + return entropy(counts, len(s)) >= 3.3 +} + +func hasUpperAndLower(s string) bool { + return strings.IndexFunc(s, unicode.IsUpper) >= 0 && strings.IndexFunc(s, unicode.IsLower) >= 0 +} + +func entropy(counts map[rune]int, n int) float64 { + e := 0.0 + for _, c := range counts { + p := float64(c) / float64(n) + e -= p * math.Log2(p) + } + return e +} diff --git a/modules/mesh-mail/cmd/mesh-mail/copies_test.go b/modules/mesh-mail/cmd/mesh-mail/copies_test.go new file mode 100644 index 00000000..78543ed1 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/copies_test.go @@ -0,0 +1,30 @@ +package main + +// content_test.go is messenger's own content rule (modules/messenger/cmd/messenger/content.go, novox/hq ADR +// 0253 §3), copied here verbatim so mail_send's tests can judge its own asks against the real rule rather +// than a guess at it (novox/hq issue 868). Messenger's content rule stays messenger's: this copy is never +// built into the mesh-mail binary (it is a _test.go file, test-only), and this test pins it to the same +// bytes as its source, the way modules/networkmanager/cmd/uplink-tools/copies_test.go pins uplink.go. + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestContentRuleCopyMatchesMessengers(t *testing.T) { + src := filepath.Join("..", "..", "..", "messenger", "cmd", "messenger", "content.go") + theirs, err := os.ReadFile(src) + if err != nil { + t.Skipf("messenger's content.go is not beside this module (%v); nothing to pin against", err) + } + mine, err := os.ReadFile("content_test.go") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(mine, theirs) { + t.Error("mesh-mail's copy of messenger's content.go (content_test.go) differs from " + src + + ": change this copy to match messenger's content rule, verbatim, whenever it changes") + } +} diff --git a/modules/mesh-mail/cmd/mesh-mail/send.go b/modules/mesh-mail/cmd/mesh-mail/send.go index f05f828a..d4043125 100644 --- a/modules/mesh-mail/cmd/mesh-mail/send.go +++ b/modules/mesh-mail/cmd/mesh-mail/send.go @@ -23,6 +23,7 @@ import ( "errors" "fmt" "net/mail" + "regexp" "strings" "time" @@ -169,6 +170,12 @@ type SendRequest struct { Body string `json:"body"` Caller string `json:"caller"` At time.Time `json:"at"` + // AskTo is To, already composed in a form the operator channel's content rule lets through (novox/hq + // issue 868): a recipient that is one of the operator's own mesh-mail accounts named by that account's + // name, any other said without its address or host. Composed once, from the accounts configured when + // the send was asked, and kept so the ask reads the same way every time it is recomposed (Decide, + // mail_send_status) as it did when the operator was first asked. + AskTo string `json:"ask_to"` // Decided is set once, and never changed again: the ask is acted on once (ADR 0361 §2). Decided *SendDecision `json:"decided,omitempty"` } @@ -205,19 +212,24 @@ func firstChars(s string, n int) string { } // Ask is the ask for this request, composed from it alone: the account, the recipients, the subject and the first -// lines of the body (novox/hq ADR 0361 §2). +// lines of the body (novox/hq ADR 0361 §2), in a form the operator channel's content rule (ADR 0253 §3) lets +// through: AskTo names recipients without an address (composeAskTo, at AskMailSend time), and the subject and +// body have every mail address and link or host name masked (maskText) before they reach the ask. The mail +// itself is sent unchanged: neither masking touches s.To, s.Subject or s.Body (novox/hq issue 868). func (s SendRequest) Ask() asks.Ask { - to := strings.Join(s.To, ", ") + to := s.AskTo + subject := maskText(s.Subject) + body := maskText(firstChars(s.Body, ShownBodyChars)) headline := fmt.Sprintf("Send mail from %s?", s.Account) if len([]rune(headline)) > asks.HeadlineLength { headline = "Send mail?" } - explanation := fmt.Sprintf("From %s to %s: %q.", s.Account, to, s.Subject) + explanation := fmt.Sprintf("From %s to %s: %q.", s.Account, to, subject) if len([]rune(explanation)) > asks.SummaryLength { - explanation = fmt.Sprintf("From %s: %q.", s.Account, s.Subject) + explanation = fmt.Sprintf("From %s: %q.", s.Account, subject) } details := fmt.Sprintf("Asked by %s through the mesh-mail module, at %s UTC.\nAccount: %s\nTo: %s\nSubject: %s\n\n%s", - s.Caller, s.At.UTC().Format("15:04 on 2 Jan"), s.Account, to, s.Subject, firstChars(s.Body, ShownBodyChars)) + s.Caller, s.At.UTC().Format("15:04 on 2 Jan"), s.Account, to, subject, body) a := asks.Ask{ID: s.ID, Headline: headline, Explanation: explanation, Details: details, Who: asks.Operator, Expires: s.At.Add(SendAskLasts), OnExpiry: "nothing is sent"} for _, o := range []struct{ id, label, does string }{ @@ -298,13 +310,16 @@ type routerRecord struct { // ---- asking ---------------------------------------------------------------------------------------------------- // SendInput is one send a caller asks for: the account is already the caller's to use (the grants check, the same -// as mail_read's, is the tool's to make before this is called). +// as mail_read's, is the tool's to make before this is called). Accounts is every account the operator has +// configured (not only the one sending from), read fresh by the tool: composeAskTo needs the whole list to know +// which recipients are the operator's own (novox/hq issue 868). type SendInput struct { - Account Account - To []string - Subject string - Body string - Caller string + Account Account + To []string + Subject string + Body string + Caller string + Accounts []Account } // cleanAddresses trims and drops empty entries. @@ -368,7 +383,7 @@ func AskMailSend(in SendInput, store SendStore, bus AskBus, now time.Time) (map[ return refuse(fmt.Sprintf("the account %s has no outgoing server configured", in.Account.Name)) } req := SendRequest{ID: newSendID(), Account: in.Account.Name, To: to, Subject: subject, Body: in.Body, - Caller: in.Caller, At: now.UTC()} + Caller: in.Caller, At: now.UTC(), AskTo: composeAskTo(to, in.Accounts)} ask := req.Ask() if err := ask.Check(now); err != nil { return refuse(err.Error()) @@ -402,6 +417,113 @@ func onwards(to []string) string { return fmt.Sprintf(" and %d more", len(to)-1) } +// ---- masking the ask for the operator channel's content rule (ADR 0253 §3, novox/hq issue 868) ------------------ +// +// messenger's content rule refuses any message that carries a mail address, a URL or a bare host name, because +// Telegram is not end-to-end encrypted for bots. ADR 0361 §2 still needs the ask to say the recipients, the +// subject and the first lines of the body, so mail_send composes them in a form the rule lets through instead: +// a recipient of the operator's own is named by its account; any other loses its address and host; the subject +// and body have every address and link masked. The mail itself is sent unchanged — only the ask's words differ. + +// mostShownRecipients is how many recipients the ask names one by one before it counts the rest. +const mostShownRecipients = 3 + +// composeAskTo is the recipient line for an ask: a recipient that is one of the accounts given is named "your +// account "; any other is said without its address or host (its first letter, an ellipsis, and its +// domain's labels as words); past the first few, the rest are counted rather than named. +func composeAskTo(to []string, accounts []Account) string { + own := map[string]string{} + for _, a := range accounts { + if a.Address != "" { + own[strings.ToLower(a.Address)] = a.Name + } + } + shown, more := to, 0 + if len(shown) > mostShownRecipients { + shown, more = shown[:mostShownRecipients], len(shown)-mostShownRecipients + } + parts := make([]string, 0, len(shown)) + for _, addr := range shown { + parts = append(parts, oneAskRecipient(addr, own)) + } + line := strings.Join(parts, ", ") + if more > 0 { + line += fmt.Sprintf(" and %d more", more) + } + return line +} + +// oneAskRecipient is one recipient's words in the ask: "your account " for one of the accounts given, or +// its first letter, an ellipsis and its domain's labels as words for any other ("j… at example com"). +func oneAskRecipient(addr string, own map[string]string) string { + if name, ok := own[strings.ToLower(addr)]; ok { + return "your account " + name + } + local, domain, found := strings.Cut(addr, "@") + if !found || local == "" { + return "a recipient" + } + first := string([]rune(local)[:1]) + return first + "… at " + strings.ReplaceAll(domain, ".", " ") +} + +// maskEmail and maskURL mirror messenger's content rule's own patterns (content_test.go's reEmail, reURL), +// so what they catch here is masked before the rule ever judges it. +var ( + maskEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}`) + maskURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://\S+`) + // maskWord splits text the same way the content rule's Check does (its own strings.FieldsFunc separators), + // so a bare host name is found on the same tokens the rule itself would judge. + maskWord = regexp.MustCompile(`[^\s"'` + "`" + `()\[\]{}<>,;|]+`) +) + +// maskTopLevel are the top-level labels a bare host name's last part may be, for masking alone: the same list +// content_test.go's own topLevel carries, so a word this masks is also a word the rule would have refused. +var maskTopLevel = map[string]bool{} + +func init() { + for _, t := range strings.Fields(`com net org edu gov mil int io dev app cloud ai co me info biz xyz + site online tech page link + be nl de fr uk lu eu ch at it es pt se no dk fi pl cz us ca au nz jp cn ru in br ie + internal lan home local localdomain corp intranet private arpa test example invalid localhost`) { + maskTopLevel[t] = true + } +} + +// maskText replaces every mail address with "[address]" and every link or host name with "[link]", so the +// subject and body first lines may reach the operator channel. It never touches s.Subject or s.Body themselves: +// the mail is sent with what was actually written (novox/hq issue 868). +func maskText(s string) string { + s = maskEmail.ReplaceAllString(s, "[address]") + s = maskURL.ReplaceAllString(s, "[link]") + return maskWord.ReplaceAllStringFunc(s, func(word string) string { + trimmed := strings.TrimRight(word, ".,:;!?") + if looksLikeHostName(trimmed) { + return "[link]" + word[len(trimmed):] + } + return word + }) +} + +// looksLikeHostName: two labels or more, the last a top-level one — the same shape content.go's isHostName +// judges a bare host name by. +func looksLikeHostName(w string) bool { + w = strings.ToLower(w) + if w == "localhost" { + return true + } + parts := strings.Split(w, ".") + if len(parts) < 2 { + return false + } + for _, p := range parts { + if p == "" { + return false + } + } + return maskTopLevel[parts[len(parts)-1]] +} + // ---- deciding --------------------------------------------------------------------------------------------------- // AccountLookup resolves an account by name, as the settings currently hold it (not as it stood when it was asked: diff --git a/modules/mesh-mail/cmd/mesh-mail/send_test.go b/modules/mesh-mail/cmd/mesh-mail/send_test.go index 056400c9..176a7287 100644 --- a/modules/mesh-mail/cmd/mesh-mail/send_test.go +++ b/modules/mesh-mail/cmd/mesh-mail/send_test.go @@ -334,7 +334,9 @@ func TestMailSendRefusesAnUngrantedCallerAndRaisesNoAsk(t *testing.T) { } } -// (2) A granted caller raises one ask whose text holds the account, the recipients, the subject and the first lines. +// (2) A granted caller raises one ask whose text holds the account, the recipients, the subject and the first +// lines — the recipients without their addresses, in the form the operator channel's content rule (ADR 0253 §3) +// lets through (novox/hq issue 868; ask_test.go judges the real rule on asks like this one). func TestMailSendAsksWithTheAccountRecipientsSubjectAndFirstLines(t *testing.T) { b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) rt := startBundle(t, b.Tools()) @@ -358,11 +360,14 @@ func TestMailSendAsksWithTheAccountRecipientsSubjectAndFirstLines(t *testing.T) t.Errorf("the ask does not pass the asks contract: %v", err) } whole := a.Headline + "\n" + a.Explanation + "\n" + a.Details - for _, want := range []string{"mesh", "ops@example.org", "lead@example.org", "The nightly gate is red", "The merge gate failed on the resolver change"} { + for _, want := range []string{"mesh", "o… at example org", "l… at example org", "The nightly gate is red", "The merge gate failed on the resolver change"} { if !strings.Contains(whole, want) { t.Errorf("the ask does not say %q:\n%s", want, whole) } } + if strings.Contains(whole, "ops@example.org") || strings.Contains(whole, "lead@example.org") { + t.Errorf("the ask carries a recipient's mail address, which the operator channel's content rule refuses:\n%s", whole) + } for _, o := range a.Options { if o.Level != asks.Approve || o.Binds == "" { t.Errorf("the option %s is %s binding %q: both answers are an approval bound to this send", o.ID, o.Level, o.Binds) diff --git a/modules/mesh-mail/cmd/mesh-mail/tools.go b/modules/mesh-mail/cmd/mesh-mail/tools.go index b957f0c0..095ee3d4 100644 --- a/modules/mesh-mail/cmd/mesh-mail/tools.go +++ b/modules/mesh-mail/cmd/mesh-mail/tools.go @@ -471,7 +471,8 @@ func (b *Bundle) sendTool(args map[string]any) (any, error) { if !found { return nil, fmt.Errorf("no account %q is held; mail_accounts lists the accounts", account) } - return AskMailSend(SendInput{Account: acc, To: to, Subject: subject, Body: body, Caller: caller}, b.sends(), b.askBus(), b.now()) + return AskMailSend(SendInput{Account: acc, To: to, Subject: subject, Body: body, Caller: caller, Accounts: accounts}, + b.sends(), b.askBus(), b.now()) } func (b *Bundle) sendStatusTool(args map[string]any) (any, error) { -- 2.54.0 From b0e4a12c88400f1ac15b03fc4c0940e49d837a39 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 16 Oct 2026 02:04:32 +0200 Subject: [PATCH 2/2] mail_send's ask: judge messenger's real words, carry the content rule at run time, match display-name recipients (issue 868, review round 1) Review round 1: the test judged Headline+Explanation+Details, but messenger refuses an ask as a whole on Headline, Explanation, OnExpiry and each option's Label and Does alone (asks.go's TakeAsk); Details is filtered line by line instead (holder.go's sayOn). send.go's Ask() now builds that exact judged text and checks it with messenger's real content rule, carried at run time as content_rule.go (messenger's content.go, renamed, byte-identical, pinned by copies_test.go) rather than only in a test copy. When a subject or first line the masking does not catch (an IP, a hardware address, a path-shaped reference, a hex hash, ...) would still refuse the whole ask, the Explanation drops the subject and first lines and points at Details instead, so the ask always reaches the operator. A recipient written with a display name ('Name ') is now matched against the operator's own accounts, and masked, by its address alone (net/mail.ParseAddress). --- modules/mesh-mail/cmd/mesh-mail/ask_test.go | 107 ++++++++++++++++-- .../{content_test.go => content_rule.go} | 0 .../mesh-mail/cmd/mesh-mail/copies_test.go | 17 +-- modules/mesh-mail/cmd/mesh-mail/send.go | 48 ++++++-- 4 files changed, 143 insertions(+), 29 deletions(-) rename modules/mesh-mail/cmd/mesh-mail/{content_test.go => content_rule.go} (100%) diff --git a/modules/mesh-mail/cmd/mesh-mail/ask_test.go b/modules/mesh-mail/cmd/mesh-mail/ask_test.go index ad7144ac..41f20759 100644 --- a/modules/mesh-mail/cmd/mesh-mail/ask_test.go +++ b/modules/mesh-mail/cmd/mesh-mail/ask_test.go @@ -3,27 +3,43 @@ package main // mail_send raises its ask on the operator channel, and messenger's content rule (ADR 0253 §3) refuses any // message that carries a mail address, a URL or a bare host name. ADR 0361 §2 needs the ask to show the // account, the recipients, the subject and the first lines regardless — so the ask must say all of that in -// a form the content rule lets through (novox/hq issue 868, "Decided" note on issue 868). These tests build -// real asks through mail_send and judge them with the real rule, copied verbatim as content_test.go. +// a form the content rule lets through (novox/hq issue 868, "Decided" note and review round 1). These tests +// build real asks through mail_send and judge them with the real rule, copied verbatim as content_rule.go. import ( "encoding/json" "strings" "testing" + + "git.novox.be/novox/mesh-sdk/go/asks" ) -// wholeAsk is every word the operator sees for one ask, the same text messenger's content rule judges as a -// whole (the asks router hands it Headline, Explanation and Details together). +// wholeAsk is every word the operator can ever see for one ask: its Headline, Explanation and Details +// together. Useful for a readability check (nothing unmasked is visible anywhere), but never for judging +// whether messenger would refuse the ask as a whole — judgedWords is that (novox/hq issue 868, review +// round 1): Details is filtered line by line, not judged with the rest (holder.go's sayOn). func wholeAsk(r *sendRouter, id string) string { a := r.published[id] return a.Headline + "\n" + a.Explanation + "\n" + a.Details } -// mustNotBeRefused fails the test naming what the content rule refused, and why. -func mustNotBeRefused(t *testing.T, whole string) { +// judgedWords is exactly the text messenger refuses (or lets through) an ask as a whole on (asks.go's +// TakeAsk): its headline, its explanation, what happens at expiry, and each option's label and effect. +// Details is never part of it. +func judgedWords(a asks.Ask) string { + words := []string{a.Headline, a.Explanation, a.OnExpiry} + for _, o := range a.Options { + words = append(words, o.Label, o.Does) + } + return strings.Join(words, "\n") +} + +// mustNotBeRefused fails the test naming what the content rule refused, and why — judged the way messenger +// itself judges an ask as a whole, on judgedWords alone. +func mustNotBeRefused(t *testing.T, a asks.Ask) { t.Helper() - if refusal, ok := Check(whole); !ok { - t.Fatalf("the content rule refuses the ask: %s\n%s", refusal, whole) + if refusal, ok := Check(judgedWords(a)); !ok { + t.Fatalf("the content rule refuses the ask: %s\n%s", refusal, judgedWords(a)) } } @@ -56,7 +72,7 @@ func TestMailSendAskForOwnAccountRecipientCarriesNoAddress(t *testing.T) { if !strings.Contains(whole, "your account mesh") { t.Errorf("the ask does not name the own account:\n%s", whole) } - mustNotBeRefused(t, whole) + mustNotBeRefused(t, router.published[id]) } // (2) A recipient outside the operator's own accounts is said without its address or host: its first @@ -81,7 +97,7 @@ func TestMailSendAskForOutsideRecipientCarriesNoAddress(t *testing.T) { t.Errorf("the ask does not say %q:\n%s", want, whole) } } - mustNotBeRefused(t, whole) + mustNotBeRefused(t, router.published[id]) } // (3) More than three recipients are counted rather than said one by one. @@ -106,7 +122,7 @@ func TestMailSendAskForManyRecipientsCountsTheRest(t *testing.T) { if !strings.Contains(whole, "and 2 more") { t.Errorf("the ask does not count the recipients past the first three:\n%s", whole) } - mustNotBeRefused(t, whole) + mustNotBeRefused(t, router.published[id]) } // (4) A subject holding a mail address and a link has both masked before the ask carries it. @@ -131,5 +147,72 @@ func TestMailSendAskMasksAnAddressAndALinkInTheSubject(t *testing.T) { if !strings.Contains(whole, "[address]") || !strings.Contains(whole, "[link]") { t.Errorf("the ask does not say [address] and [link] in place of what it masked:\n%s", whole) } - mustNotBeRefused(t, whole) + mustNotBeRefused(t, router.published[id]) +} + +// (5) review round 1: a subject the masking does not catch (an IP, a two-slash path-shaped reference, a +// 40-character hex hash) does not refuse the whole ask: the real content rule (content_rule.go) still judges +// the masked explanation as refused, so the subject and first lines are left out of it and pointed at Details +// instead — the ask still names the account and recipients, and still reaches the operator. +func TestMailSendAskFallsBackToDetailsWhenTheSubjectStillRefuses(t *testing.T) { + for _, subject := range []string{ + "Invoice 2026/10/15", + "server 10.0.0.1", + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", // 40 hex characters: reHex's shape + } { + t.Run(subject, func(t *testing.T) { + b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + rt := startBundle(t, b.Tools()) + args := map[string]any{"account": "mesh", "to": []any{"ops@example.org"}, "subject": subject, "body": "body"} + text, isErr := rt.call(SendTool, "novox.triage", args) + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + _ = json.Unmarshal([]byte(text), &out) + id := out["ask"].(string) + a := router.published[id] + mustNotBeRefused(t, a) + if !strings.Contains(a.Explanation, "mesh") { + t.Errorf("the explanation does not name the account:\n%s", a.Explanation) + } + if !strings.Contains(a.Explanation, "o… at example org") { + t.Errorf("the explanation does not name the recipient:\n%s", a.Explanation) + } + if strings.Contains(a.Explanation, subject) { + t.Errorf("the explanation still carries the subject that refused the ask:\n%s", a.Explanation) + } + if !strings.Contains(a.Details, "Details") && !strings.Contains(a.Explanation, "Details") { + t.Errorf("the explanation does not point at Details:\n%s", a.Explanation) + } + if !strings.Contains(a.Details, subject) { + t.Errorf("Details does not carry the subject (filtered line by line by messenger, as the subject's own line): %s", a.Details) + } + }) + } +} + +// (6) review round 1: a recipient written with a display name ("Name ") is matched against the +// operator's own accounts, and masked, by its address alone. +func TestMailSendAskMatchesADisplayNameRecipientByItsAddress(t *testing.T) { + b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + rt := startBundle(t, b.Tools()) + + args := map[string]any{"account": "mesh", "to": []any{"Jochen Schoubben "}, + "subject": "Testing a display name", "body": "body"} + text, isErr := rt.call(SendTool, "novox.triage", args) + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + _ = json.Unmarshal([]byte(text), &out) + id := out["ask"].(string) + whole := wholeAsk(router, id) + if strings.Contains(whole, "@") || strings.Contains(whole, "Jochen Schoubben") { + t.Errorf("the ask carries the display-name recipient's address or name:\n%s", whole) + } + if !strings.Contains(whole, "your account mesh") { + t.Errorf("the ask does not match the display-name recipient to its own account:\n%s", whole) + } + mustNotBeRefused(t, router.published[id]) } diff --git a/modules/mesh-mail/cmd/mesh-mail/content_test.go b/modules/mesh-mail/cmd/mesh-mail/content_rule.go similarity index 100% rename from modules/mesh-mail/cmd/mesh-mail/content_test.go rename to modules/mesh-mail/cmd/mesh-mail/content_rule.go diff --git a/modules/mesh-mail/cmd/mesh-mail/copies_test.go b/modules/mesh-mail/cmd/mesh-mail/copies_test.go index 78543ed1..7597d998 100644 --- a/modules/mesh-mail/cmd/mesh-mail/copies_test.go +++ b/modules/mesh-mail/cmd/mesh-mail/copies_test.go @@ -1,10 +1,13 @@ package main -// content_test.go is messenger's own content rule (modules/messenger/cmd/messenger/content.go, novox/hq ADR -// 0253 §3), copied here verbatim so mail_send's tests can judge its own asks against the real rule rather -// than a guess at it (novox/hq issue 868). Messenger's content rule stays messenger's: this copy is never -// built into the mesh-mail binary (it is a _test.go file, test-only), and this test pins it to the same -// bytes as its source, the way modules/networkmanager/cmd/uplink-tools/copies_test.go pins uplink.go. +// content_rule.go is messenger's own content rule (modules/messenger/cmd/messenger/content.go, novox/hq ADR +// 0253 §3), copied here verbatim — byte for byte, only the file renamed, since Go allows nothing else to +// differ (novox/hq issue 868, review round 1). mesh-mail carries it at run time, not only in its tests: the +// ask it builds is checked against the real rule before it is published (send.go), so a subject or a first +// line the masking does not catch (an IP, a hardware address, a path-shaped reference, a hex hash, …) falls +// back to a safe explanation rather than refusing the whole ask. Messenger's content rule stays messenger's: +// this is a pinned copy, never a dependency on it, the way modules/networkmanager/cmd/uplink-tools/copies_test.go +// pins its copy of uplink.go. import ( "bytes" @@ -19,12 +22,12 @@ func TestContentRuleCopyMatchesMessengers(t *testing.T) { if err != nil { t.Skipf("messenger's content.go is not beside this module (%v); nothing to pin against", err) } - mine, err := os.ReadFile("content_test.go") + mine, err := os.ReadFile("content_rule.go") if err != nil { t.Fatal(err) } if !bytes.Equal(mine, theirs) { - t.Error("mesh-mail's copy of messenger's content.go (content_test.go) differs from " + src + + t.Error("mesh-mail's copy of messenger's content.go (content_rule.go) differs from " + src + ": change this copy to match messenger's content rule, verbatim, whenever it changes") } } diff --git a/modules/mesh-mail/cmd/mesh-mail/send.go b/modules/mesh-mail/cmd/mesh-mail/send.go index d4043125..bd8f31c6 100644 --- a/modules/mesh-mail/cmd/mesh-mail/send.go +++ b/modules/mesh-mail/cmd/mesh-mail/send.go @@ -214,8 +214,14 @@ func firstChars(s string, n int) string { // Ask is the ask for this request, composed from it alone: the account, the recipients, the subject and the first // lines of the body (novox/hq ADR 0361 §2), in a form the operator channel's content rule (ADR 0253 §3) lets // through: AskTo names recipients without an address (composeAskTo, at AskMailSend time), and the subject and -// body have every mail address and link or host name masked (maskText) before they reach the ask. The mail -// itself is sent unchanged: neither masking touches s.To, s.Subject or s.Body (novox/hq issue 868). +// body have every mail address and link or host name masked (maskText) before they reach the ask. Messenger +// refuses an ask as a whole on its Headline, Explanation, OnExpiry and each option's Label and Does alone +// (asks.go's TakeAsk) — never Details, which it filters line by line instead (holder.go's sayOn) — so once +// those are composed, the real content rule (content_rule.go, mesh-mail's own run-time copy) judges them +// exactly as messenger would. When the masking still leaves something the rule refuses (an IP, a hardware +// address, a path-shaped reference, a hex hash, … novox/hq issue 868, review round 1), the subject and first +// lines are left out of the Explanation and pointed at Details instead, so the ask always reaches the operator. +// The mail itself is sent unchanged: neither masking touches s.To, s.Subject or s.Body. func (s SendRequest) Ask() asks.Ask { to := s.AskTo subject := maskText(s.Subject) @@ -224,18 +230,34 @@ func (s SendRequest) Ask() asks.Ask { if len([]rune(headline)) > asks.HeadlineLength { headline = "Send mail?" } + onExpiry := "nothing is sent" + options := []struct{ id, label, does string }{ + {OptionSend, "Send", "the mail is sent through " + s.Account + "'s outgoing server"}, + {OptionDecline, "Decline", "nothing is sent"}, + } + // judged is exactly the words messenger refuses (or lets through) an ask as a whole on. + judged := func(explanation string) string { + words := []string{headline, explanation, onExpiry} + for _, o := range options { + words = append(words, o.label, o.does) + } + return strings.Join(words, "\n") + } explanation := fmt.Sprintf("From %s to %s: %q.", s.Account, to, subject) if len([]rune(explanation)) > asks.SummaryLength { explanation = fmt.Sprintf("From %s: %q.", s.Account, subject) } + if _, ok := Check(judged(explanation)); !ok { + explanation = fmt.Sprintf("From %s to %s. The subject and first lines are in Details.", s.Account, to) + if len([]rune(explanation)) > asks.SummaryLength { + explanation = "Send mail. The subject and first lines are in Details." + } + } details := fmt.Sprintf("Asked by %s through the mesh-mail module, at %s UTC.\nAccount: %s\nTo: %s\nSubject: %s\n\n%s", s.Caller, s.At.UTC().Format("15:04 on 2 Jan"), s.Account, to, subject, body) a := asks.Ask{ID: s.ID, Headline: headline, Explanation: explanation, Details: details, - Who: asks.Operator, Expires: s.At.Add(SendAskLasts), OnExpiry: "nothing is sent"} - for _, o := range []struct{ id, label, does string }{ - {OptionSend, "Send", "the mail is sent through " + s.Account + "'s outgoing server"}, - {OptionDecline, "Decline", "nothing is sent"}, - } { + Who: asks.Operator, Expires: s.At.Add(SendAskLasts), OnExpiry: onExpiry} + for _, o := range options { binds, _ := asks.ActDigest(s.Act(o.id)) a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds}) } @@ -454,12 +476,18 @@ func composeAskTo(to []string, accounts []Account) string { } // oneAskRecipient is one recipient's words in the ask: "your account " for one of the accounts given, or -// its first letter, an ellipsis and its domain's labels as words for any other ("j… at example com"). +// its first letter, an ellipsis and its domain's labels as words for any other ("j… at example com"). A +// recipient written with a display name ("Name ") is matched and masked by its address alone, parsed the +// same way AskMailSend already validates it (novox/hq issue 868, review round 1). func oneAskRecipient(addr string, own map[string]string) string { - if name, ok := own[strings.ToLower(addr)]; ok { + bare := addr + if parsed, err := mail.ParseAddress(addr); err == nil { + bare = parsed.Address + } + if name, ok := own[strings.ToLower(bare)]; ok { return "your account " + name } - local, domain, found := strings.Cut(addr, "@") + local, domain, found := strings.Cut(bare, "@") if !found || local == "" { return "a recipient" } -- 2.54.0