From fffaface66fb91243979224c5f9b278b2517fb62 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 16:33:52 +0200 Subject: [PATCH] A settings proposal reads at a glance, its values whole where the sender is proven (issues 383 and 639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator could not read the settings proposals on the phone: the change, the fingerprint and a how-to paragraph in one wall of plain text, and the values masked as ‹path› and ‹address›, so the approval was blind. The ask is now a summary of what changes where ("Changes 3 setting(s) of mounts on shanks; approved, the layer is set at once and shanks takes it at its next push."), then at most three points, one key each (more keys: the first two and the names of the rest). Who proposed it, the fingerprint and how to read it whole are its Details, which the router notes after the answers. The change is carried twice: under the content rule, and whole (asks.Ask.Whole), shown only on a channel that proves who answers, a value shaped like a secret withheld in both (from the earlier work on issue 383, #202). Vendors mesh-sdk at 9588871 (fix/639-an-ask-reads-at-a-glance: Message.Note, asks.Glance), and captures its conformance events at that commit. --- cmd/mesh-controller/proposals.go | 165 ++++++++++++------ cmd/mesh-controller/proposals_test.go | 138 +++++++++++++-- go.mod | 2 +- go.sum | 2 + internal/outward/outward.go | 67 +++++-- internal/outward/outward_test.go | 37 ++++ testdata/beside/CAPTURED | 2 +- .../novox/mesh-sdk/go/asks/asks.go | 56 +++++- vendor/modules.txt | 2 +- 9 files changed, 388 insertions(+), 83 deletions(-) diff --git a/cmd/mesh-controller/proposals.go b/cmd/mesh-controller/proposals.go index fc5db024..97d44fda 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,30 @@ 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; who proposed it, the fingerprint and how to read it whole are its Details, which the + // router notes after the answers. + shown, shownWhole := p.change(machines, false) + whole, _ := 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") + var d strings.Builder + 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") } - 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, + 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) + 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: d.String()} + if w := compose(whole); w != q.Explanation { + // Only where a value was masked: an ask whose values all pass the content rule shows the same everywhere. + q.Whole = w + } options := map[string]int{} for i, act := range p.actions(id) { binds, _ := asks.ActDigest(boundAct(act)) @@ -203,53 +205,87 @@ 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 the machine +// takes the layer at its next push. +func (p settingsProposal) summary() string { + takes := p.Node + if p.Node == "" { + takes = "every machine" + } + 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 goes at once and %s takes it at its next push.", + len(leaves(p.Before, "")), p.Module, p.where(), takes) + } + 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 at once and %s takes it at its next push.", + n, p.Module, p.where(), stay, takes) +} -// 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, and whether every value was shown whole: "+ 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. Either is read whole with `settings proposals`. +func (p settingsProposal) change(machines []string, exact bool) ([]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-- + 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: %s", len(rest), strings.Join(rest, ", "))) + whole = false + break + } + out = append(out, l.text) + } + for i, l := range out { + if r := []rune(l); len(r) > asks.PointLength { + out[i] = string(r[:asks.PointLength-1]) + "…" + whole = false } - out = out[:cut] + fmt.Sprintf("\n… NOT SHOWN IN FULL here: %d more bytes", len(strings.Join(lines, "\n"))-cut) - whole = false } return out, whole } @@ -298,6 +334,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 +487,12 @@ 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)) + } // Fail closed, before anything is kept: no router, no grant, no channel means no ask. if pr.routerHere != nil { here, err := pr.routerHere(ctx) diff --git a/cmd/mesh-controller/proposals_test.go b/cmd/mesh-controller/proposals_test.go index 2a405d2c..f2656ba3 100644 --- a/cmd/mesh-controller/proposals_test.go +++ b/cmd/mesh-controller/proposals_test.go @@ -116,24 +116,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 at once and shanks takes it at its next push." + 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 +160,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 +195,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 at once and every machine takes it at its next push.\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: 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 +314,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 goes at once") { t.Errorf("%+v", q) } if p := r.theProposal(t).Proposal; !p.Clear || p.Digest != layerDigest(r.before) || len(r.judged) != 0 { diff --git a/go.mod b/go.mod index 60ad6d19..b1202b12 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.20261013141520-9588871d6629 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..e8f550a7 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ 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= 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..fe654bd6 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 9588871d6629e160bb0667f3d97566a0f8f7a4a6 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..0f6f3aa3 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 @@ -202,11 +202,55 @@ 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"` } +// 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). const ( MostOptions = 4 @@ -435,8 +479,12 @@ 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; it is never left out (novox/hq issue 639). + 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..ced81f12 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.20261013141520-9588871d6629 ## explicit; go 1.22 git.novox.be/novox/mesh-sdk/go/asks # github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op