Files
mesh-controller/internal/outward/outward.go
T
jochen 7a92224886
mesh/delivery-group group fix/383-a-proposal-reads-whole-on-the-phone checking: 0 of 2 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
A settings proposal shows its values whole where the sender is proven, and its message is the change alone (hq issue 383)
The operator saw "+ shares: media=‹path›" on the phone and could not tell what they were
approving: a mount point, a share name or a private address is the thing being approved and
must be readable. The ask now carries the change twice: the explanation under the content
rule, as before, and the whole (asks.Ask.Whole), which the router shows only on a channel
that proves who answers; only a value shaped like a secret is withheld there (outward.Secret,
and the proposal is refused if one were left). The message is the headline, one line per key,
who proposed it and when; the fingerprint and how to read the proposal whole are the Details
answer's (asks.Ask.Details). The masking stays for conditions and for channels that prove
nothing. The warrant binds as before: the ask's digest covers the whole and the Details, the
act digest the exact values.

Vendors mesh-sdk go at the commit that adds Whole and Details to an ask.
2026-10-10 15:38:00 +02:00

324 lines
11 KiB
Go

// Package outward is the operator channel's content rule, as the controller holds its own words to it
// (novox/hq ADR 0234 §6, to-be 45 §5).
//
// **What may leave the mesh is machine names and words.** The messenger refuses a message that carries
// an address, a domain, a path or anything shaped like a secret, and withholds its words — so a
// condition whose summary carried a resolver's address reached the operator as "this message carried an
// IPv4 address, so its words are withheld" instead of the alert (2026-10-06). The rule is the
// messenger's, and stays the messenger's: this package mirrors its patterns so that the controller can
// hold a condition's summary to it where the summary is made, and a test can fail a summary that would
// be withheld. Detail — an address, a socket's error, a path — belongs in a condition's evidence, which
// stays inside the mesh.
//
// Mirrored, not imported: the messenger is a module of the catalogue with its own module path, and a
// pattern changed there is changed here (the table in outward_test.go names the shapes both refuse).
// Where the two differ, this one may only be the stricter: what passes here passes there.
package outward
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 + ")" }
// The messenger's patterns (mesh-catalog modules/messenger content.go), one for one.
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, as the messenger reads them.
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
}
}
// wordSeparators split a text into the words the messenger reads one by one.
const wordSeparators = "\"'`()[]{}<>,;|"
func isSeparator(r rune) bool { return unicode.IsSpace(r) || strings.ContainsRune(wordSeparators, r) }
// Check says whether a text may leave the mesh, and when not, why. **The mesh's own machine names may
// appear** (ADR 0234 §6): a word that is one of machines is read as a name, whatever its shape —
// never as a host name, a path or a random string. A machine name joined to a domain is a domain.
func Check(text string, machines ...string) (Refusal, bool) {
text = withoutMachines(text, machines)
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, isSeparator) {
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
}
// Secret says whether a text carries a secret's shape, the one class that leaves the mesh nowhere — the
// messenger's CheckSecret, one for one. It is what an ask's whole words (asks.Ask.Whole, novox/hq issue 383)
// are held to: on a channel that proves who answers, a path or an address is what the operator approves and
// is shown; a secret never is.
func Secret(text string, machines ...string) (Refusal, bool) {
text = withoutMachines(text, machines)
if refusal, ok := secretShape(text); !ok {
return refusal, false
}
return randomRun(text)
}
// 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
}
// What Scrub says in words, beyond the messenger's patterns: an address with its port and what a
// socket says around it ("udp 192.0.2.1:53"), a bracketed IPv6 address, a key block whole, a secret's
// value, a drive path whole.
var (
scrubURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://\S*`)
scrubIPv4 = regexp.MustCompile(`\b\d{1,3}(\.\d{1,3}){3}(:\d+)?\b`)
scrubIPv6 = regexp.MustCompile(`\[[0-9a-fA-F:.%]*:[0-9a-fA-F:.%]*\](:\d+)?`)
scrubPEM = regexp.MustCompile(`(?s)-----BEGIN [A-Z ]+-----.*?(-----END [A-Z ]+-----|$)`)
scrubAssigned = regexp.MustCompile(`(?i)\b(password|passwd|passphrase|secret|token|api[_-]?key|apikey|credential|private[_-]?key)\s*[=:]\s*\S+`)
scrubWinPath = regexp.MustCompile(`(?i)\b[a-z]:\\\S*`)
)
// Scrub is a text with everything Check refuses said in words instead: an address as "an address", a
// path as "a path", a secret's shape as "(withheld)". The text's own words, and the machine names, are
// kept. What Scrub cannot make pass is replaced whole by fallback — never sent as it was.
func Scrub(text, fallback string, machines ...string) string {
if _, ok := Check(text, machines...); ok {
return text
}
out := scrubURL.ReplaceAllString(text, "an address")
out = reEmail.ReplaceAllString(out, "an address")
out = scrubIPv4.ReplaceAllString(out, "an address")
out = reMAC.ReplaceAllString(out, "a hardware address")
out = scrubIPv6.ReplaceAllString(out, "an address")
for i := 0; i < 4 && reIPv6.MatchString(out); i++ {
out = reIPv6.ReplaceAllString(out, "${1}an address${6}")
}
out = scrubPEM.ReplaceAllString(out, "(withheld)")
for _, re := range []*regexp.Regexp{reJWT, reBotToken, reKnown, reHex} {
out = re.ReplaceAllString(out, "(withheld)")
}
out = scrubAssigned.ReplaceAllString(out, "${1} (withheld)")
out = scrubWinPath.ReplaceAllString(out, "a path")
out = reRun.ReplaceAllStringFunc(out, func(run string) string {
if looksRandom(run) {
return "(withheld)"
}
return run
})
out = eachWord(out, func(w string) string {
switch {
case isMachine(w, machines):
return w
case isPath(w):
return "a path"
case isHostName(w):
return "a host name"
}
return w
})
if _, ok := Check(out, machines...); ok {
return out
}
return fallback
}
// withoutMachines is a text with every machine name that stands as a word of its own read as a plain
// word, so the patterns do not read a name as anything else.
func withoutMachines(text string, machines []string) string {
if len(machines) == 0 {
return text
}
return eachWord(text, func(w string) string {
if isMachine(w, machines) {
return "machine"
}
return w
})
}
// eachWord is a text with each word, as the messenger splits and trims it, put through say; what
// separates the words, and the punctuation a word ends in, are kept.
func eachWord(text string, say func(w string) string) string {
var b strings.Builder
start := -1
flush := func(end int) {
if start < 0 {
return
}
word := text[start:end]
w := strings.TrimRight(word, ".:!?")
if w == "" {
b.WriteString(word)
} else {
b.WriteString(say(w) + word[len(w):])
}
start = -1
}
for i, r := range text {
if isSeparator(r) {
flush(i)
b.WriteRune(r)
continue
}
if start < 0 {
start = i
}
}
flush(len(text))
return b.String()
}
func isMachine(w string, machines []string) bool {
for _, m := range machines {
if m != "" && strings.EqualFold(w, m) {
return true
}
}
return false
}
// 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.
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 {
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
}