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