From 7a92224886f993c5716a43b8fb74c20795a34b43 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 10 Oct 2026 15:38:00 +0200 Subject: [PATCH] A settings proposal shows its values whole where the sender is proven, and its message is the change alone (hq issue 383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator saw "+ shares: media=‹path›" on the phone and could not tell what they were approving: a mount point, a share name or a private address is the thing being approved and must be readable. The ask now carries the change twice: the explanation under the content rule, as before, and the whole (asks.Ask.Whole), which the router shows only on a channel that proves who answers; only a value shaped like a secret is withheld there (outward.Secret, and the proposal is refused if one were left). 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. The warrant binds as before: the ask's digest covers the whole and the Details, the act digest the exact values. Vendors mesh-sdk go at the commit that adds Whole and Details to an ask. --- cmd/mesh-controller/proposals.go | 106 +++++++++++----- cmd/mesh-controller/proposals_test.go | 117 +++++++++++++++--- go.mod | 2 +- go.sum | 4 +- internal/outward/outward.go | 53 ++++++-- internal/outward/outward_test.go | 34 +++++ .../novox/mesh-sdk/go/asks/asks.go | 17 ++- vendor/modules.txt | 2 +- 8 files changed, 272 insertions(+), 63 deletions(-) diff --git a/cmd/mesh-controller/proposals.go b/cmd/mesh-controller/proposals.go index 124aee3d..6481ae71 100644 --- a/cmd/mesh-controller/proposals.go +++ b/cmd/mesh-controller/proposals.go @@ -27,6 +27,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" @@ -154,39 +161,39 @@ 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 + // The message (issue 383): the change, one line per key, then who proposed it and when — the headline says + // what is set where, and the router adds what every ask says. The fingerprint and the how-to are Details'. + compose := func(change string) string { + var b strings.Builder + if p.Clear && !p.HadLayer { + b.WriteString("There is no layer to remove; approving changes nothing.\n") + } else { + b.WriteString(change + "\n") + } + fmt.Fprintf(&b, "Proposed by %s at %s.", sayable(p.From, machines), p.At.Local().Format("15:04 on 2 Jan")) + return b.String() + } + shown, shownWhole := p.change(machines, false) + whole, _ := p.change(machines, true) + var d strings.Builder + fmt.Fprintf(&d, "Fingerprint %s.\n", fingerprint(p.Digest)) + if !shownWhole { + d.WriteString("Parts shown as ‹address›, ‹path› or ‹withheld› may not leave the mesh where the sender is not proven.\n") + } + fmt.Fprintf(&d, "Read it whole, with this fingerprint: settings proposals %s at the controller's terminal, or "+ + "mesh-controller.settings with proposal %s through the mesh MCP server.\n", id, id) 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()) + d.WriteString("Approved, the layer is removed at once and the machine takes it at its next push.") } else { - fmt.Fprintf(&b, "Set the settings of %s on %s to these values?\n\n", p.Module, p.where()) + d.WriteString("Approved, the layer is set at once and the machine takes it at its next push.") } - 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") - } - 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 + ".") - } - if p.Clear { - b.WriteString(" Approved, the layer is removed at once and the machine takes it at its next push.") - } else { - b.WriteString(" Approved, the layer is set at once and the machine takes it at its next push.") - } - 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: d.String()} + if whole != shown { + // Only where a value was masked: an ask whose values all pass the content rule shows the same everywhere. + q.Whole = compose(whole) + } options := map[string]int{} for i, act := range p.actions(id) { binds, _ := asks.ActDigest(boundAct(act)) @@ -208,13 +215,17 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin // shownMost is the most of a change the phone is shown, in bytes; the rest is read whole with `settings proposals`. const shownMost = 1400 -// 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, line by line, 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. 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). +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 } @@ -242,6 +253,8 @@ func (p settingsProposal) change(machines []string) (string, bool) { 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)) + case len(lines) == 0: + lines = append(lines, "= the layer has no keys") } } out := strings.Join(lines, "\n") @@ -300,6 +313,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 { @@ -432,6 +466,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..2eaba0e2 100644 --- a/cmd/mesh-controller/proposals_test.go +++ b/cmd/mesh-controller/proposals_test.go @@ -116,24 +116,36 @@ 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's shape (issue 383): one line per key, then who proposed it and when — and nothing else: the + // fingerprint and the how-to are the Details answer's. + proposedBy := "Proposed by g14/claude-code, through the mesh-controller seat at " + r.now.Local().Format("15:04 on 2 Jan") + "." + if q.Explanation != "+ sources: recalbox=‹address›@‹path›:ro\n~ shares: library=‹path› (was: none)\n- old (was: x)\n"+proposedBy { + t.Errorf("the explanation:\n%s", q.Explanation) } 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 != "+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro\n~ shares: library=/mnt/library (was: none)\n- old (was: x)\n"+proposedBy { + t.Errorf("the whole words:\n%s", q.Whole) + } + for _, line := range []string{ + "Fingerprint " + fingerprint(layerDigest(mountsSources)) + ".", + "Parts shown as ‹address›, ‹path› or ‹withheld› may not leave the mesh where the sender is not proven.", + "Read it whole, with this fingerprint: settings proposals " + kept(r).ID + " at the controller's terminal", + "Approved, the layer is set at once and the machine takes it at its next push.", + } { + if !strings.Contains(q.Details, line) { + t.Errorf("Details lack %q:\n%s", line, q.Details) + } + } + for _, howTo := range []string{"ingerprint", "settings proposals", "mesh-controller.settings", "may not leave", "Approved,"} { + 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 +153,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 +188,84 @@ 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?") { + !strings.HasPrefix(q.Explanation, "+ x: 1\nProposed by ") || 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) { + r := newProposerRig(t) + values := map[string]any{"shares": "media=/storage/media", "sources": "recalbox=smb://nas.lan/recalbox@/mnt/recalbox", + "github": "ghp_abcdefghijklmnopqrstuvwxyz0123456789", "cert": "-----BEGIN CERTIFICATE-----", "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([]string{"anchor", "laptop", "shanks"}, false) + whole, _ := kept(r).Proposal.change([]string{"anchor", "laptop", "shanks"}, true) + if !strings.Contains(q.Explanation, masked) || !strings.Contains(q.Whole, whole) { + t.Fatalf("the ask does not carry the change masked and whole:\n%s\n--\n%s", q.Explanation, q.Whole) + } + for _, line := range []string{"+ cert: ‹withheld›", "+ font: Inter 13", "+ github: ‹withheld›", "+ shares: media=‹path›", "+ sources: recalbox=‹address›@‹path›"} { + if !strings.Contains(masked, line) { + t.Errorf("masked, lacks %q:\n%s", line, masked) + } + } + for _, line := range []string{"+ cert: ‹withheld›", "+ font: Inter 13", "+ github: ‹withheld›", "+ shares: media=/storage/media", + "+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox"} { + if !strings.Contains(whole, line) { + t.Errorf("whole, lacks %q:\n%s", line, whole) + } + } + for _, secret := range []string{"ghp_", "BEGIN CERTIFICATE"} { + if strings.Contains(q.Explanation, secret) || strings.Contains(q.Whole, secret) || strings.Contains(q.Details, secret) { + t.Errorf("the secret %q reaches the phone", secret) + } + } + 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. + m, w := strings.Split(masked, "\n"), strings.Split(whole, "\n") + if len(m) != len(w) { + t.Fatalf("masked %d lines, whole %d", len(m), len(w)) + } + differ := 0 + for i := range m { + if m[i] != w[i] { + differ++ + if !strings.Contains(m[i], "‹") || strings.Contains(w[i], "‹") { + t.Errorf("the lines differ otherwise than by the mask:\n%s\n%s", m[i], w[i]) + } + } + } + if differ != 2 { + t.Errorf("%d lines differ, and the mask covered 2", differ) + } + // 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.HasPrefix(q2.Explanation, "+ font-size: 13\n+ width: 500\nProposed by ") || + strings.Contains(q2.Details, "may not leave") { + 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 +293,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.Contains(q.Details, "Approved, the layer is removed 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 067a2f55..12b41c61 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.11-0.20261009143344-f047d0a4a970 + git.novox.be/novox/mesh-sdk/go v0.1.13-0.20261010132341-4f3efc98e0c0 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 c7f30d1a..03c303ed 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ git.novox.be/novox/mesh-host v0.0.0-20261009231844-b8c854611812 h1:pzVzwF5VMWaTECxu8+Pd1dNoOHNEm7upC5wPadQTkBw= git.novox.be/novox/mesh-host v0.0.0-20261009231844-b8c854611812/go.mod h1:K3/xEzVgmrNKLMV2vv4M80MwmPnQNXqvQ4C5Jj0fJT4= -git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 h1:9tFDQsgmI+4X7/BpZGXIr+HemPKE7YddYGqWV0lINAI= -git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.13-0.20261010132341-4f3efc98e0c0 h1:qi0slgoZVexIr/gxfLhN8kJxy2LVvGTHiWuuGBPkfDo= +git.novox.be/novox/mesh-sdk/go v0.1.13-0.20261010132341-4f3efc98e0c0/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..a2466329 100644 --- a/internal/outward/outward.go +++ b/internal/outward/outward.go @@ -80,6 +80,43 @@ 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. +func Secret(text string, machines ...string) (Refusal, bool) { + text = withoutMachines(text, machines) + if refusal, ok := secretShape(text); !ok { + return refusal, false + } + return randomRun(text) +} + +// 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 +129,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..45fb2bf3 100644 --- a/internal/outward/outward_test.go +++ b/internal/outward/outward_test.go @@ -86,3 +86,37 @@ 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", + "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/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go b/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go index 81737ac4..008e69af 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 @@ -154,7 +154,9 @@ func canonical(b *strings.Builder, v string) { // // It is over the ask's named fields in a fixed order, each written canonically, and the expiry as UTC // RFC 3339 to the nanosecond — never over a language's encoding of the struct, so a field added to Ask -// later changes no digest until it is added here, on purpose. +// later changes no digest until it is added here, on purpose. Whole and Details (novox/hq issue 383) follow +// the options only when the ask gives either: an ask without them digests as it did before they existed, so +// a router and an asker of different builds still agree on every such ask. func (a Ask) Digest() string { var b strings.Builder b.WriteString("novox.ask.v1\n") @@ -168,6 +170,10 @@ func (a Ask) Digest() string { canonical(&b, v) } } + if a.Whole != "" || a.Details != "" { + canonical(&b, a.Whole) + canonical(&b, a.Details) + } sum := sha256.Sum256([]byte(b.String())) return "sha256:" + hex.EncodeToString(sum[:]) } @@ -190,6 +196,15 @@ type Ask struct { // About is what the ask is about (a condition's key): a newer ask about it replaces the older. About string `json:"about,omitempty"` Urgent bool `json:"urgent,omitempty"` + // Whole is the explanation with its exact values whole, shown in place of Explanation on a channel kind + // that proves who answers (verified-sender) and carries the ask's answers (novox/hq issue 383): what the + // person approves — a mount point, a share, a private address — must be readable where they approve it. + // 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 string `json:"details,omitempty"` } // The bounds of an ask (novox/hq ADR 0234 §8, ADR 0259 §4). diff --git a/vendor/modules.txt b/vendor/modules.txt index 486530b9..73171ffe 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1,4 +1,4 @@ -# git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 +# git.novox.be/novox/mesh-sdk/go v0.1.13-0.20261010132341-4f3efc98e0c0 ## explicit; go 1.22 git.novox.be/novox/mesh-sdk/go/asks # github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op