Files
mesh-controller/internal/outward/outward.go
T
jochen 68557b412f
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
mesh/delivery-group group fix/383-a-proposal-reads-whole-on-the-phone ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
Review: a path is read as a path in the whole words, and Details say what the desk shows (issue 383)
The review of 2026-10-10: a mixed-case path with a digit ("/mnt/Photos_2024/Jochen") read as a
random string and was withheld from the whole words, the symptom again (outward.Secret now judges
a run with a slash piece by piece, as the messenger's CheckSecret does); and the Details line on
masking read, on the phone, as if something there were masked.
2026-10-10 15:46:55 +02:00

338 lines
12 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. 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 hold whatever the run holds.
func Secret(text string, machines ...string) (Refusal, bool) {
text = withoutMachines(text, machines)
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
}
// 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
}