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
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.
338 lines
12 KiB
Go
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
|
|
}
|