diff --git a/cmd/mesh-controller/proposals.go b/cmd/mesh-controller/proposals.go index fc5db024..5bec29d7 100644 --- a/cmd/mesh-controller/proposals.go +++ b/cmd/mesh-controller/proposals.go @@ -28,6 +28,13 @@ package main // ask says so, and the whole is read with `settings proposals ` — whose fingerprint must be the one on the // phone. Fail closed: a proposal nothing can carry to the operator is refused at once, in words, and never left // waiting for an answer that cannot come. +// +// **On a channel that proves who answers, the values are shown WHOLE** (novox/hq issue 383, the operator's +// decision of 2026-10-10): a mount point, a share name or a private address is the thing being approved and must +// be readable, so the ask carries them whole beside the explanation (asks.Ask.Whole), the router shows that only +// on a kind that verifies its sender, and only a value shaped like a secret is withheld there. 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. import ( "context" @@ -156,35 +163,40 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin if len([]rune(headline)) > asks.HeadlineLength { headline = verb + " settings?" } - shown, whole := p.change(machines) - var b strings.Builder - if p.Clear { - fmt.Fprintf(&b, "Clear the settings of %s on %s, back to what the module says?\n\n", p.Module, p.where()) - } else { - fmt.Fprintf(&b, "Set the settings of %s on %s to these values?\n\n", p.Module, p.where()) + // The message reads at a glance (issues 383 and 639): a summary of what changes where, then at most three + // points, a key each; every key with its whole value, who proposed it, the fingerprint and how to read it at the + // terminal are its Details, which the router notes after the answers. + shown, shownAll, shownWhole := p.change(machines, false) + whole, wholeAll, _ := p.change(machines, true) + summary := p.summary() + compose := func(points []string) string { + return strings.Join(append([]string{summary}, points...), "\n") } - fmt.Fprintf(&b, "Proposed by %s, at %s. ", sayable(p.From, machines), p.At.Local().Format("15:04 on 2 Jan")) - switch { - case p.Clear && p.HadLayer: - b.WriteString("The layer it removes:\n\n") - case p.Clear: - b.WriteString("There is no layer to remove; approving changes nothing.") - case !p.HadLayer: - b.WriteString("There is no layer yet; this is the whole of it:\n\n") - default: - b.WriteString("The layer is replaced whole; what changes against it:\n\n") + // The Details: every setting it sets, one line each, uncut — whole where the sender is proven (WholeDetails), + // masked elsewhere — then who proposed it, the fingerprint and how to read it at the terminal. + details := func(lines []string) string { + var d strings.Builder + d.WriteString(strings.Join(lines, "\n") + "\n") + d.WriteString(p.onApprove() + "\n") + fmt.Fprintf(&d, "Proposed by %s at %s. Fingerprint %s.\n", sayable(p.From, machines), p.At.Local().Format("15:04 on 2 Jan"), + fingerprint(p.Digest)) + if !shownWhole { + d.WriteString("Where who answers is not proven, its values are shown as ‹address›, ‹path› or ‹withheld›.\n") + } + fmt.Fprintf(&d, "Read it whole, with this fingerprint: settings proposals %s at the controller's terminal, or the "+ + "controller's settings verb with proposal %s through the mesh MCP server.", id, id) + return d.String() } - b.WriteString(shown) - fmt.Fprintf(&b, "\n\nFingerprint %s.", fingerprint(p.Digest)) - if !whole { - b.WriteString(" Parts shown as ‹address›, ‹path› or ‹withheld› may not leave the mesh: read it whole, with " + - "this fingerprint, through the mesh MCP server (mesh-controller.settings, proposal " + id + ") or with " + - "mesh-cli settings proposals " + id + ".") - } - b.WriteString(" " + p.onApprove()) - q := asks.Ask{ID: id, Headline: headline, Explanation: b.String(), Who: asks.Operator, + q := asks.Ask{ID: id, Headline: headline, Explanation: compose(shown), Who: asks.Operator, Expires: p.At.Add(askApproveFor), OnExpiry: "the proposal is discarded; nothing changes", - About: p.about()} + About: p.about(), Details: details(shownAll)} + // Only where a value was masked: an ask whose values all pass the content rule shows the same everywhere. + if w := compose(whole); w != q.Explanation { + q.Whole = w + } + if w := details(wholeAll); w != q.Details { + q.WholeDetails = w + } options := map[string]int{} for i, act := range p.actions(id) { binds, _ := asks.ActDigest(boundAct(act)) @@ -203,55 +215,141 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin return q, options } -// shownMost is the most of a change the phone is shown, in bytes; the rest is read whole with `settings proposals`. -const shownMost = 1400 +// summary is the proposal's first line, what changes where: how many settings, how many stay, and that Approve sends +// the layer at once by a walk, one machine at a time (novox/hq ADR 0344); the rest of what Approve does +// (onApprove) is the Details'. +func (p settingsProposal) summary() string { + if p.Clear { + if !p.HadLayer { + return "There is no layer to remove; approving changes nothing." + } + return fmt.Sprintf("Removes the %d setting(s) of %s set on %s; approved, the layer is removed and that is sent at once "+ + "by a walk to %s, one machine at a time, each judged.", len(leaves(p.Before, "")), p.Module, p.where(), p.reaches()) + } + added, changed, removed := settingsChange(p.Before, p.Values) + n := len(added) + len(changed) + len(removed) + unchanged := len(leaves(p.Values, "")) - len(added) - len(changed) + if n == 0 { + return fmt.Sprintf("Nothing changes: the %d setting(s) of %s on %s are as they stand.", unchanged, p.Module, p.where()) + } + stay := "" + if unchanged > 0 { + stay = fmt.Sprintf(", and %d stay as they are", unchanged) + } + return fmt.Sprintf("Changes %d setting(s) of %s on %s%s; approved, the layer is set and sent at once by a walk to %s, "+ + "one machine at a time, each judged.", n, p.Module, p.where(), stay, p.reaches()) +} -// change is what changes, line by line, each value shown under the content rule, and whether every value was -// shown whole: "+ key: value" added, "~ key: value (was: old)" changed, "- key (was: old)" removed, and the -// count of keys unchanged. -func (p settingsProposal) change(machines []string) (string, bool) { +// change is what changes: as at most asks.MostPoints points for the glance, as every line whole for the note, and +// whether every value was shown exactly: "+ key: value" +// added, "~ key: value (was: old)" changed, "- key (was: old)" removed. Each value is shown under the content rule +// (sayableValue), or — exact, for a channel that proves who answers — as it is, withheld only when shaped like a +// secret (wholeValue). More changes than points: the first two, and the keys of the rest; a point longer than a +// phone shows at a glance is cut and says so. The note carries every line uncut (the review of #304: Approve sets +// exactly what the operator can read). +func (p settingsProposal) change(machines []string, exact bool) ([]string, []string, bool) { whole := true say := func(v any) string { s, w := sayableValue(v, machines) + if exact { + s, w = wholeValue(v, machines) + } whole = whole && w return s } - var lines []string + type line struct{ key, text string } + var lines []line if p.Clear { was := leaves(p.Before, "") for _, k := range layerKeys(was) { - lines = append(lines, fmt.Sprintf("- %s: %s", k, say(was[k]))) + lines = append(lines, line{k, fmt.Sprintf("- %s: %s", k, say(was[k]))}) } } else { added, changed, removed := settingsChange(p.Before, p.Values) was, now := leaves(p.Before, ""), leaves(p.Values, "") for _, k := range added { - lines = append(lines, fmt.Sprintf("+ %s: %s", k, say(now[k]))) + lines = append(lines, line{k, fmt.Sprintf("+ %s: %s", k, say(now[k]))}) } for _, k := range changed { - lines = append(lines, fmt.Sprintf("~ %s: %s (was: %s)", k, say(now[k]), say(was[k]))) + lines = append(lines, line{k, fmt.Sprintf("~ %s: %s (was: %s)", k, say(now[k]), say(was[k]))}) } for _, k := range removed { - lines = append(lines, fmt.Sprintf("- %s (was: %s)", k, say(was[k]))) - } - unchanged := len(now) - len(added) - len(changed) - switch { - case len(lines) == 0 && unchanged > 0: - lines = append(lines, fmt.Sprintf("= nothing changes: the %d key(s) are as they stand", unchanged)) - case unchanged > 0: - lines = append(lines, fmt.Sprintf("= %d key(s) unchanged", unchanged)) + lines = append(lines, line{k, fmt.Sprintf("- %s (was: %s)", k, say(was[k]))}) } } - out := strings.Join(lines, "\n") - if len(out) > shownMost { - cut := shownMost - for cut > 0 && out[cut] != '\n' { - cut-- + // **Each line is judged as the router will judge it** (the review of #304, ADR 0347 §1 and §3): a line the + // content rule (masked) or the secret rule (whole) would refuse — a value given to a secret's name, say + // "+ password: hunter2" — is said with its key still named and its value withheld, never left for the + // router to withhold whole and unnamed. A line refused even so is the propose's refusal (judgeLines). + for i, l := range lines { + if lineLeaves(l.text, exact, machines) { + continue } - out = out[:cut] + fmt.Sprintf("\n… NOT SHOWN IN FULL here: %d more bytes", len(strings.Join(lines, "\n"))-cut) + lines[i].text = l.text[:2] + l.key + " (value withheld)" whole = false } - return out, whole + all := make([]string, len(lines)) + for i, l := range lines { + all[i] = l.text + } + // What the note holds of a value: whole where the glance is the whole one (shown only where the sender is + // proven, with the whole details beside it), under the content rule elsewhere — so the marker says no more than + // the note it points to (the review of ADR 0347). + inNote := "in the note" + if exact { + inNote = "whole in the note" + } + var out []string + for i, l := range lines { + if len(lines) > asks.MostPoints && i == asks.MostPoints-1 { + var rest []string + for _, r := range lines[i:] { + rest = append(rest, r.key) + } + out = append(out, fmt.Sprintf("and %d more, each %s: %s", len(rest), inNote, strings.Join(rest, ", "))) + break + } + out = append(out, l.text) + } + // A point too long for a glance is cut, and says so: its value is the note's. + cut := "… (cut: " + inNote + ")" + for i, l := range out { + if r := []rune(l); len(r) > asks.PointLength { + out[i] = string(r[:asks.PointLength-len([]rune(cut))]) + cut + } + } + return out, all, whole +} + +// lineLeaves says whether a line may leave the mesh as the router judges it: under the content rule for the masked +// form, the secret rule alone for the whole one. +func lineLeaves(line string, exact bool, machines []string) bool { + if exact { + _, ok := outward.Secret(line, machines...) + return ok + } + _, ok := outward.Check(line, machines...) + return ok +} + +// judgeLines is the first line of an ask's details the router would refuse, or withhold, and why: masked under +// the content rule line by line, whole under the secret rule. Empty when every line may leave. +func judgeLines(details string, exact bool, machines []string) (string, string) { + for _, line := range strings.Split(details, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + if exact { + if refusal, ok := outward.Secret(line, machines...); !ok { + return line, refusal.String() + } + continue + } + if refusal, ok := outward.Check(line, machines...); !ok { + return line, refusal.String() + } + } + return "", "" } func layerKeys(m map[string]any) []string { @@ -298,6 +396,27 @@ func sayableValue(v any, machines []string) (string, bool) { return out, out == text } +// wholeValue is a value as a channel that proves who answers is shown it (asks.Ask.Whole), and whether it was +// shown whole: as it is, unless it is shaped like a secret (outward.Secret), which is withheld whole — never in +// part, so no half of a key reaches the phone. +func wholeValue(v any, machines []string) (string, bool) { + text, ok := v.(string) + if !ok { + raw, err := json.Marshal(v) + if err != nil { + return markWithheld, false + } + text = string(raw) + } + if text == "" { + return `""`, true + } + if _, ok := outward.Secret(text, machines...); !ok { + return markWithheld, false + } + return text, true +} + // sayable is a text with what may not leave the mesh replaced in place by a marker; what the markers cannot make // pass is withheld whole. func sayable(text string, machines []string) string { @@ -430,6 +549,23 @@ func (pr proposer) propose(ctx context.Context, in proposeInput) (string, error) return refuse("the ask's words would carry %s, which may not leave the mesh, and the change could not be "+ "shown without it; %s", refusal, atTheTerminalInstead(in)) } + // The whole words are shown only where the sender is proven, and never a secret's shape: wholeValue withholds + // one, and the router would refuse the ask if one were left, so it is refused here first, in words. + if refusal, ok := outward.Secret(q.Whole, machines...); !ok { + return refuse("the change shown whole would carry %s, which may not leave the mesh on any channel; %s", + refusal, atTheTerminalInstead(in)) + } + // So are the details (ADR 0347 §1, §3): every line the router would withhold from the note, or refuse the ask + // for, is refused here, in the controller's own words, naming the line's key — Approve never sets a value the + // operator was shown nothing of. + if line, why := judgeLines(q.Details, false, machines); line != "" { + return refuse("the change's line %q would carry %s, which the operator's note may not show; %s", + keyOfLine(line), why, atTheTerminalInstead(in)) + } + if line, why := judgeLines(q.WholeDetails, true, machines); line != "" { + return refuse("the change's line %q shown whole would carry %s, which may not leave the mesh on any channel; %s", + keyOfLine(line), why, atTheTerminalInstead(in)) + } // Fail closed, before anything is kept: no router, no grant, no channel means no ask. if pr.routerHere != nil { here, err := pr.routerHere(ctx) @@ -819,3 +955,21 @@ func pushTo(node string) string { } return node } + +// keyOfLine is what a refusal names of a line: the key of a change's line ("+ key: …"), or the line's first words. +func keyOfLine(line string) string { + if len(line) > 2 && strings.ContainsRune("+~-=", rune(line[0])) && line[1] == ' ' { + rest := line[2:] + if i := strings.IndexAny(rest, ": "); i > 0 { + rest = rest[:i] + } + if _, ok := outward.Secret(rest); !ok { + return "whose key is shaped like a secret" + } + return rest + } + if r := []rune(line); len(r) > 40 { + return string(r[:40]) + "…" + } + return line +} diff --git a/cmd/mesh-controller/proposals_test.go b/cmd/mesh-controller/proposals_test.go index 2a405d2c..5a6f759f 100644 --- a/cmd/mesh-controller/proposals_test.go +++ b/cmd/mesh-controller/proposals_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "os" "slices" @@ -116,24 +117,43 @@ func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) { if q.Options[0].Binds == q.Options[1].Binds { t.Error("Approve and Decline bind the same act") } - for _, line := range []string{ - "Set the settings of mounts on shanks to these values?", - "Proposed by g14/claude-code, through the mesh-controller seat, at " + r.now.Local().Format("15:04 on 2 Jan") + ".", - "+ sources: recalbox=‹address›@‹path›:ro", - "~ shares: library=‹path› (was: none)", - "- old (was: x)", - "Fingerprint " + fingerprint(layerDigest(mountsSources)) + ".", - "read it whole, with this fingerprint", - } { - if !strings.Contains(q.Explanation, line) { - t.Errorf("the explanation lacks %q:\n%s", line, q.Explanation) - } + // The message reads at a glance (issues 383 and 639): a summary, then one point per key — and nothing else: + // who proposed it, the fingerprint and the how-to are the Details, which the router notes after the answers. + summary := "Changes 3 setting(s) of mounts on shanks; approved, the layer is set and sent at once by a walk to shanks, one machine at a time, each judged." + if q.Explanation != summary+"\n+ sources: recalbox=‹address›@‹path›:ro\n~ shares: library=‹path› (was: none)\n- old (was: x)" { + t.Errorf("the explanation:\n%s", q.Explanation) + } + if err := asks.Glance(q.Explanation); err != nil { + t.Error(err) } for _, leak := range []string{"nas.lan", "/mnt/recalbox", "/mnt/library", "smb://"} { if strings.Contains(q.Explanation, leak) { t.Errorf("the explanation carries %q, which may not leave the mesh", leak) } } + // Where the sender is proven, the values whole: the mount point and the share are what is approved. + if q.Whole != summary+"\n+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro\n~ shares: library=/mnt/library (was: none)\n- old (was: x)" || + asks.Glance(q.Whole) != nil { + t.Errorf("the whole words:\n%s", q.Whole) + } + for _, line := range []string{ + "Proposed by g14/claude-code, through the mesh-controller seat at " + r.now.Local().Format("15:04 on 2 Jan") + + ". Fingerprint " + fingerprint(layerDigest(mountsSources)) + ".", + "Where who answers is not proven, its values are shown as ‹address›, ‹path› or ‹withheld›.", + "Read it whole, with this fingerprint: settings proposals " + kept(r).ID + " at the controller's terminal", + } { + if !strings.Contains(q.Details, line) { + t.Errorf("Details lack %q:\n%s", line, q.Details) + } + } + if refusal, ok := outward.Check(q.Details, "anchor", "laptop", "shanks"); !ok { + t.Errorf("the router would withhold a line of the details: %s", refusal) + } + for _, howTo := range []string{"ingerprint", "settings proposals", "settings verb", "not proven", "Proposed by"} { + if strings.Contains(q.Explanation, howTo) || strings.Contains(q.Whole, howTo) { + t.Errorf("the message carries the how-to %q", howTo) + } + } all := []string{q.Headline, q.Explanation, q.OnExpiry} for _, o := range q.Options { all = append(all, o.Label, o.Does) @@ -141,6 +161,9 @@ func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) { if refusal, ok := outward.Check(strings.Join(all, "\n"), "anchor", "laptop", "shanks"); !ok { t.Errorf("the router would refuse the ask: %s", refusal) } + if refusal, ok := outward.Secret(q.Whole, "anchor", "laptop", "shanks"); !ok { + t.Errorf("the router would refuse the whole words: %s", refusal) + } // Kept as the controller's own ask, about no condition, with the proposal whole and its digests. kept := r.theProposal(t) p := kept.Proposal @@ -173,11 +196,98 @@ func TestAMeshWideLayerIsProposed(t *testing.T) { } q := r.askSent(t) if q.Headline != "Set notes on the whole mesh?" || q.About != "settings.notes.mesh" || - !strings.Contains(q.Explanation, "Set the settings of notes on the whole mesh to these values?") { + q.Explanation != "Changes 1 setting(s) of notes on the whole mesh; approved, the layer is set and sent at once by a walk to every machine running notes, one machine at a time, each judged.\n+ x: 1" || + q.Whole != "" { t.Errorf("%+v", q) } } +// kept is the one proposal the rig keeps. +func kept(r *proposerRig) asked { + for _, a := range r.store { + if a.Proposal != nil { + return a + } + } + return asked{} +} + +// The switch between the masked and the whole change (issue 383): the same change, under the content rule and +// exact, differs in the masked values alone; a value shaped like a secret is withheld in both, whole, with its +// key still named; and a change no value of which is masked carries no whole words of its own. +func TestAProposalShowsItsValuesWholeOnlyWhereTheSenderIsProvenAndNeverASecret(t *testing.T) { + machines := []string{"anchor", "laptop", "shanks"} + r := newProposerRig(t) + values := map[string]any{"shares": "media=/storage/media", "sources": "recalbox=smb://nas.lan/recalbox@/mnt/recalbox", "font": "Inter 13"} + if _, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: values}); err != nil { + t.Fatal(err) + } + q := r.askSent(t) + masked, _, _ := kept(r).Proposal.change(machines, false) + whole, _, _ := kept(r).Proposal.change(machines, true) + if !strings.HasSuffix(q.Explanation, "\n"+strings.Join(masked, "\n")) || !strings.HasSuffix(q.Whole, "\n"+strings.Join(whole, "\n")) { + t.Fatalf("the ask does not carry the change masked and whole:\n%s\n--\n%s", q.Explanation, q.Whole) + } + if want := []string{"+ font: Inter 13", "+ shares: media=‹path›", "+ sources: recalbox=‹address›@‹path›"}; strings.Join(masked, "\n") != strings.Join(want, "\n") { + t.Errorf("masked: %q", masked) + } + if want := []string{"+ font: Inter 13", "+ shares: media=/storage/media", "+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox"}; strings.Join(whole, "\n") != strings.Join(want, "\n") { + t.Errorf("whole: %q", whole) + } + if _, ok := outward.Secret(q.Whole, "shanks"); !ok { + t.Error("the router would refuse the whole words") + } + // Mutation: the masked and the whole change differ in exactly the lines whose value was masked. + differ := 0 + for i := range masked { + if masked[i] != whole[i] { + differ++ + if !strings.Contains(masked[i], "‹") || strings.Contains(whole[i], "‹") { + t.Errorf("the lines differ otherwise than by the mask:\n%s\n%s", masked[i], whole[i]) + } + } + } + if differ != 2 { + t.Errorf("%d lines differ, and the mask covered 2", differ) + } + // A value shaped like a secret is withheld in both, whole, with its key still named. + r1 := newProposerRig(t) + secrets := map[string]any{"github": "ghp_abcdefghijklmnopqrstuvwxyz0123456789", "cert": "-----BEGIN CERTIFICATE-----"} + if _, err := r1.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: secrets}); err != nil { + t.Fatal(err) + } + q1 := r1.askSent(t) + for _, line := range []string{"+ cert: ‹withheld›", "+ github: ‹withheld›"} { + if !strings.Contains(q1.Explanation, line) { + t.Errorf("lacks %q:\n%s", line, q1.Explanation) + } + } + for _, secret := range []string{"ghp_", "BEGIN CERTIFICATE"} { + if strings.Contains(q1.Explanation, secret) || strings.Contains(q1.Whole, secret) || strings.Contains(q1.Details, secret) { + t.Errorf("the secret %q reaches the phone", secret) + } + } + // More keys than points (issue 639): the first two, then the keys of the rest, read whole at the terminal. + r3 := newProposerRig(t) + five := map[string]any{"a": "1", "b": "2", "c": "3", "d": "4", "e": "5"} + if _, err := r3.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: five}); err != nil { + t.Fatal(err) + } + if q3 := r3.askSent(t); !strings.HasSuffix(q3.Explanation, "\n+ a: 1\n+ b: 2\nand 3 more, each in the note: c, d, e") || asks.Glance(q3.Explanation) != nil || + !strings.Contains(q3.Details, "settings proposals") { + t.Errorf("five keys:\n%s", q3.Explanation) + } + // No value masked: the message is the same everywhere, and the ask says no whole words. + r2 := newProposerRig(t) + if _, err := r2.pr.propose(context.Background(), proposeInput{module: "dunst", node: "laptop", values: map[string]any{"font-size": 13, "width": 500}}); err != nil { + t.Fatal(err) + } + if q2 := r2.askSent(t); q2.Whole != "" || !strings.HasSuffix(q2.Explanation, "\n+ font-size: 13\n+ width: 500") || + strings.Contains(q2.Details, "not proven") { + t.Errorf("%+v", q2) + } +} + // A proposal expired unanswered is kept so by the reconciling, and a warrant for it afterwards sets nothing. func TestAnExpiredProposalIsKeptExpired(t *testing.T) { r := newAskerRig(t) @@ -205,7 +315,8 @@ func TestAClearIsProposedAndShowsWhatItRemoves(t *testing.T) { } q := r.askSent(t) if q.Headline != "Clear notes on laptop?" || !strings.Contains(q.Explanation, "- places.data.owner: 1001:1001") || - !strings.Contains(q.Explanation, "- places.data.path: ‹path›") { + !strings.Contains(q.Explanation, "- places.data.path: ‹path›") || !strings.Contains(q.Whole, "- places.data.path: /srv/notes") || + !strings.HasPrefix(q.Explanation, "Removes the 2 setting(s) of notes set on laptop; approved, the layer is removed and that is sent at once by a walk to laptop, one machine at a time") { t.Errorf("%+v", q) } if p := r.theProposal(t).Proposal; !p.Clear || p.Digest != layerDigest(r.before) || len(r.judged) != 0 { @@ -744,3 +855,125 @@ func captureStdout(t *testing.T, f func()) string { _ = w.Close() return <-done } + +// The review of #304: Approve sets exactly what the operator can read. A proposal of five keys, one value 300 +// characters long, shows two keys at a glance, says what it cut, and carries every key with its whole value in its +// Details for a channel that proves who answers (WholeDetails), and every key masked for one that does not. +func TestEveryKeyAndItsWholeValueReachTheOperator(t *testing.T) { + var long strings.Builder + long.WriteString("library=/storage") + for i := 0; long.Len() < 300; i++ { + long.WriteString(fmt.Sprintf("/album-%02d", i)) + } + value := long.String()[:300] + values := map[string]any{"a": "1", "b": "2", "c": "3", "d": "4", "shares": value} + r := newProposerRig(t) + if _, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: values}); err != nil { + t.Fatal(err) + } + q := r.askSent(t) + if err := q.Check(r.now); err != nil { + t.Fatal(err) + } + // What the operator reads on a proving channel: the whole words, then the note's whole details. + visible := q.Whole + "\n" + q.WholeDetails + if q.Whole == "" { + visible = q.Explanation + "\n" + q.WholeDetails + } + for k, v := range values { + if !strings.Contains(visible, "+ "+k+": "+v.(string)) { + t.Errorf("the operator never reads %s whole:\n%s", k, visible) + } + } + // The masked glance points to a note that holds the values masked, and says no more; the whole glance, shown only + // beside the whole details, says they are whole there. + if asks.Glance(q.Explanation) != nil || !strings.HasSuffix(q.Explanation, "\nand 3 more, each in the note: c, d, shares") || + strings.Contains(q.Explanation, "whole in the note") || !strings.HasSuffix(q.Whole, "\nand 3 more, each whole in the note: c, d, shares") { + t.Errorf("the glance:\n%s", q.Explanation) + } + for k := range values { + if !strings.Contains(q.Details, "+ "+k+": ") { + t.Errorf("the masked details lack %s:\n%s", k, q.Details) + } + } + if strings.Contains(q.Details, "/storage") { + t.Errorf("the masked details show a path:\n%s", q.Details) + } + if refusal, ok := outward.Check(q.Details, "shanks"); !ok { + t.Errorf("the router would withhold the masked details: %s", refusal) + } + if _, ok := outward.Secret(q.WholeDetails, "shanks"); !ok { + t.Error("the router would refuse the whole details") + } + // A value too long for a glance is cut there, and says so. + r2 := newProposerRig(t) + if _, err := r2.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: map[string]any{"shares": value}}); err != nil { + t.Fatal(err) + } + q2 := r2.askSent(t) + // The cut point is 160 characters, its marker inside the bound. + if last := q2.Whole[strings.LastIndex(q2.Whole, "\n")+1:]; len([]rune(last)) != asks.PointLength { + t.Errorf("the cut point is %d characters, not %d: %q", len([]rune(last)), asks.PointLength, last) + } + if !strings.HasSuffix(q2.Whole, "… (cut: whole in the note)") || !strings.HasSuffix(q2.Explanation, "‹path›") && !strings.HasSuffix(q2.Explanation, "… (cut: in the note)") || !strings.Contains(q2.WholeDetails, "+ shares: "+value) || asks.Glance(q2.Whole) != nil { + t.Errorf("the cut is not said, or the note lacks the whole:\n%s\n--\n%s", q2.Whole, q2.WholeDetails) + } +} + +// The review of #304: a value given to a secret's name, past the second key, is said with its key named and its +// value withheld, in the note on every channel; the router has nothing left to withhold unnamed or refuse. A line +// the router would refuse even so is refused at propose, in the controller's words. +func TestASecretNamedKeyIsNamedAndItsValueWithheld(t *testing.T) { + r := newProposerRig(t) + values := map[string]any{"a": "1", "b": "2", "c": "3", "password": "hunter2"} + if _, err := r.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: values}); err != nil { + t.Fatal(err) + } + q := r.askSent(t) + for _, form := range []string{q.Details, q.WholeDetails, q.Explanation, q.Whole} { + if strings.Contains(form, "hunter2") { + t.Errorf("the value reaches the phone:\n%s", form) + } + } + if !strings.Contains(q.Details, "+ password (value withheld)") { + t.Errorf("the key is not named with its value withheld:\n%s", q.Details) + } + if q.WholeDetails != "" && !strings.Contains(q.WholeDetails, "+ password (value withheld)") { + t.Errorf("the whole details do not name the key:\n%s", q.WholeDetails) + } + if line, why := judgeLines(q.Details, false, nil); line != "" { + t.Errorf("the router would withhold %q: %s", line, why) + } + if line, why := judgeLines(q.WholeDetails, true, nil); line != "" { + t.Errorf("the router would refuse %q: %s", line, why) + } + if !strings.HasSuffix(q.Explanation, "\nand 2 more, each in the note: c, password") { + t.Errorf("the glance:\n%s", q.Explanation) + } + // A key shaped like a secret cannot be named on any channel: refused at propose, in the controller's words, and + // the key itself not echoed. + r2 := newProposerRig(t) + secretKey := "ghp_abcdefghijklmnopqrstuvwxyz0123456789" + _, err := r2.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", + values: map[string]any{"a": "1", "b": "2", secretKey: "3"}}) + if err == nil || !strings.Contains(err.Error(), "Nothing was proposed") || strings.Contains(err.Error(), secretKey) { + t.Errorf("a secret-shaped key: %v", err) + } + if len(r2.sent) != 0 { + t.Errorf("the refused proposal was asked") + } +} + +// keyOfLine names a change's key, and never one shaped like a secret. +func TestARefusalNamesTheKeyAndNeverASecret(t *testing.T) { + for line, want := range map[string]string{ + "+ password (value withheld)": "password", + "~ shares: ‹path› (was: none)": "shares", + "+ ghp_abcdefghijklmnopqrstuvwxyz0123456789: 1": "whose key is shaped like a secret", + "Proposed by g14 at 15:04.": "Proposed by g14 at 15:04.", + } { + if got := keyOfLine(line); got != want { + t.Errorf("%q: %q, want %q", line, got, want) + } + } +} diff --git a/cmd/mesh-controller/settings_walk_test.go b/cmd/mesh-controller/settings_walk_test.go index f8cca40a..48c34f2e 100644 --- a/cmd/mesh-controller/settings_walk_test.go +++ b/cmd/mesh-controller/settings_walk_test.go @@ -82,8 +82,11 @@ func TestTheAskSaysAnApprovedLayerIsSentAtOnce(t *testing.T) { if err := q.Check(r.now); err != nil { t.Fatalf("the ask is refused: %v", err) } - if !strings.Contains(q.Explanation, "and sent at once to "+c.where+", one machine at a time, each judged, "+ - "every build there kept; its end is told as every walk's is") { + // At a glance (ADR 0345): the summary says it is sent at once by a walk, one machine at a time; the rest of + // what Approve does is the Details', which the router notes after the answers. + if !strings.Contains(q.Explanation, "sent at once by a walk to "+c.where+", one machine at a time, each judged") || + !strings.Contains(q.Details, "and sent at once to "+c.where+", one machine at a time, each judged, "+ + "every build there kept; its end is told as every walk's is") { t.Errorf("the ask does not say the layer is sent at once to %s:\n%s", c.where, q.Explanation) } approve := q.Options[0] @@ -93,7 +96,7 @@ func TestTheAskSaysAnApprovedLayerIsSentAtOnce(t *testing.T) { if !strings.Contains(words, "a walk sends it to "+c.where+" at once") { t.Errorf("the proposer's answer:\n%s", words) } - for _, said := range []string{q.Explanation, approve.Does, words} { + for _, said := range []string{q.Explanation, q.Whole, approve.Does, words} { if strings.Contains(said, "push") { t.Errorf("a push is still named, as if a second act were needed:\n%s", said) } diff --git a/go.mod b/go.mod index 60ad6d19..a25c10d7 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-controller go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 github.com/jackc/pgx/v5 v5.10.0 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 diff --git a/go.sum b/go.sum index 96650c70..75618b31 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,12 @@ git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f h1:8N5OW2mdTNIck git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/internal/outward/outward.go b/internal/outward/outward.go index 6de9d9f6..6167b440 100644 --- a/internal/outward/outward.go +++ b/internal/outward/outward.go @@ -80,6 +80,57 @@ func Check(text string, machines ...string) (Refusal, bool) { 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): @@ -92,23 +143,17 @@ func Check(text string, machines ...string) (Refusal, bool) { return Refusal{"secret", "a value given to a secret's name"}, false case reHex.MatchString(text): return Refusal{"secret", "a long hexadecimal string"}, false - case reWinPath.MatchString(text): - return Refusal{"path", "a drive path"}, 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 } } - 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 } diff --git a/internal/outward/outward_test.go b/internal/outward/outward_test.go index fc1eed1c..0901cd70 100644 --- a/internal/outward/outward_test.go +++ b/internal/outward/outward_test.go @@ -86,3 +86,40 @@ func TestScrubAlwaysGivesWhatMayLeave(t *testing.T) { t.Errorf("a text that passes was changed: %q", got) } } + +// Secret is the one class that leaves the mesh nowhere (novox/hq issue 383): it refuses every secret's shape +// Check refuses, and nothing Check refuses as an address or a path — those an ask's whole words may carry. +func TestSecretRefusesOnlyASecretsShape(t *testing.T) { + for _, text := range []string{ + "-----BEGIN RSA PRIVATE KEY-----", + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc", + "123456789:ABCdefGHIjklMNOpqrSTUvwxYZ0123456789ab", + "ghp_abcdefghijklmnopqrstuvwxyz0123456789", + "password=hunter2", + "0123456789abcdef0123456789abcdef", + "a key xK9mQ2vL8pR4tW7yB3nF6hJ1dG5sA0zC", + } { + r, ok := Secret(text) + if ok || r.Class != "secret" { + t.Errorf("not refused as a secret: %q (%s)", text, r) + } + } + for _, text := range []string{ + "recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro", + "library=/mnt/library", + "photos=/mnt/Photos_2024/Jochen", + "games=smb://nas.lan/Recalbox_Games2024", + "/srv/media/Series_Archive/Season01", + "10.77.0.9:53", + "jochen@example.com", + "C:\\Users\\jo", + "anchor and the laptop", + } { + if r, ok := Secret(text, "anchor"); !ok { + t.Errorf("refused as %s: %q", r, text) + } + if _, ok := Check(text, "anchor"); ok && text != "anchor and the laptop" { + t.Errorf("Check lets %q leave, so Secret is not the narrower rule", text) + } + } +} diff --git a/testdata/beside/CAPTURED b/testdata/beside/CAPTURED index 1cbaf6a8..5e6e1f87 100644 --- a/testdata/beside/CAPTURED +++ b/testdata/beside/CAPTURED @@ -9,7 +9,7 @@ a second place to clean besides the catalogue itself. mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile mesh-host 7a41e120635b3f7b6cb94cca8b3fc4093e4e6110 examples/foundation-first-node.lock -mesh-sdk 33917f91ac3cc213b879b32f3c70e2aa70395f98 conformance/events/module-event.json +mesh-sdk 5933eb757d443293631be2bed27a96288c2348d8 conformance/events/module-event.json To move them, from this repository's root, with the two repositories checked out beside it: diff --git a/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go b/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go index 008e69af..596bb8f8 100644 --- a/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go +++ b/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go @@ -170,10 +170,18 @@ func (a Ask) Digest() string { canonical(&b, v) } } - if a.Whole != "" || a.Details != "" { + // Whole and Details are written whenever either or WholeDetails is given, and WholeDetails after them whenever + // it is given (novox/hq ADR 0347): the fields after the options are then always one of three fixed shapes — + // none, the two, or the three — and the count of values the sequence holds tells them apart, so no two asks + // encode the same (the review of mesh-sdk #25). An ask with none of them digests as before they existed, and + // one without WholeDetails as before it existed. + if a.Whole != "" || a.Details != "" || a.WholeDetails != "" { canonical(&b, a.Whole) canonical(&b, a.Details) } + if a.WholeDetails != "" { + canonical(&b, a.WholeDetails) + } sum := sha256.Sum256([]byte(b.String())) return "sha256:" + hex.EncodeToString(sum[:]) } @@ -202,9 +210,59 @@ type Ask struct { // The asker withholds a value shaped like a secret in it, and the router refuses the ask when one is left; // Explanation stays under the whole content rule everywhere else. Empty, Explanation is shown everywhere. Whole string `json:"whole,omitempty"` - // Details is what the Details answer on the ask's message shows, line by line under the content rule: a - // fingerprint, how to read the proposal whole at the terminal. Empty, an ask's own message offers no Details. + // Details are the ask's secondary words, line by line under the content rule: who proposed it, a + // fingerprint, how to read it whole. The router shows them in the note of the ask's own message + // (Message.Note), after what the ask's options do and when it expires — never in its body (novox/hq + // issue 639). Details string `json:"details,omitempty"` + // WholeDetails are the Details with their exact values whole, shown in place of Details where Whole is shown + // in place of the explanation (a kind that proves who answers, given the ask's answers): every value an + // approval sets, whole, so the person reads exactly what they approve (novox/hq issue 383). Held, like + // Whole, to the secret rule alone; the router refuses the ask when a secret's shape is left in it. Empty, + // Details are shown everywhere. + WholeDetails string `json:"whole-details,omitempty"` +} + +// What an ask's explanation is held to, so it reads at a glance on a phone (novox/hq issue 639): a summary +// of two or three short sentences on its first line — what changes, why, what it costs or risks — then at +// most three points, a line each, only where they add something. Whole is held to the same. Who asked, when, +// the fingerprint and how to read it whole are Details, never the explanation. +const ( + SummaryLength = 300 + MostPoints = 3 + PointLength = 160 +) + +// Glance says why an explanation does not read at a glance, or nil: its first line, the summary, longer than +// SummaryLength; more than MostPoints lines after it; a point longer than PointLength. Blank lines are not +// counted. An asker holds its asks to it in its own tests; the router says, without refusing, an ask that +// does not hold to it. +func Glance(explanation string) error { + var lines []string + for _, l := range strings.Split(explanation, "\n") { + if strings.TrimSpace(l) != "" { + lines = append(lines, strings.TrimSpace(l)) + } + } + var problems []string + switch { + case len(lines) == 0: + problems = append(problems, "it has no summary") + case len([]rune(lines[0])) > SummaryLength: + problems = append(problems, fmt.Sprintf("its summary is %d characters, more than %d", len([]rune(lines[0])), SummaryLength)) + } + if len(lines) > 1+MostPoints { + problems = append(problems, fmt.Sprintf("it has %d points after its summary, more than %d", len(lines)-1, MostPoints)) + } + for i, l := range lines { + if i > 0 && len([]rune(l)) > PointLength { + problems = append(problems, fmt.Sprintf("its point %d is %d characters, more than %d", i, len([]rune(l)), PointLength)) + } + } + if len(problems) > 0 { + return errors.New("the explanation does not read at a glance: " + strings.Join(problems, "; ")) + } + return nil } // The bounds of an ask (novox/hq ADR 0234 §8, ADR 0259 §4). @@ -435,8 +493,14 @@ type Message struct { Title string `json:"title"` Body string `json:"body"` Buttons []Button `json:"buttons,omitempty"` - Urgent bool `json:"urgent,omitempty"` - Silent bool `json:"silent,omitempty"` + // Note is the message's secondary words, after its body and its buttons: who asked and when, what each + // answer does, when it expires, and the ask's Details. A channel shows it smaller, collapsed or set apart + // where it can, and after the body where it cannot (novox/hq issue 639). A channel that bounds a message's + // length cuts the body first and the note only when the title and the note alone do not fit, saying each + // cut (novox/hq ADR 0347 §5). + Note string `json:"note,omitempty"` + Urgent bool `json:"urgent,omitempty"` + Silent bool `json:"silent,omitempty"` // Reply is the Choice or LinkAsked this answers, by its ID: the channel shows it where that was made. Reply string `json:"reply,omitempty"` // To is the account linked as the operator on this kind, for a channel that verifies its sender: where diff --git a/vendor/modules.txt b/vendor/modules.txt index 4a86d3fb..7607b731 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1,4 +1,4 @@ -# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c +# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 ## explicit; go 1.22 git.novox.be/novox/mesh-sdk/go/asks # github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op