Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95b93626d2 | ||
|
|
68557b412f | ||
|
|
7a92224886 |
@@ -27,6 +27,13 @@ package main
|
||||
// ask says so, and the whole is read with `settings proposals <id>` — 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("On a channel that does not prove who answers, these 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 "+
|
||||
"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)
|
||||
|
||||
@@ -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)) + ".",
|
||||
"On a channel that does not prove who answers, these values are shown as ‹address›, ‹path› or ‹withheld›.",
|
||||
"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", "does not prove", "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, "does not prove who answers") {
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@@ -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 h1:Su4JYpZ+zhNovkGA2DgxiZdcuHpjw2tPJzc/7PljhXg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.13/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=
|
||||
|
||||
+56
-11
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -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).
|
||||
|
||||
Vendored
+1
-1
@@ -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
|
||||
## explicit; go 1.22
|
||||
git.novox.be/novox/mesh-sdk/go/asks
|
||||
# github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op
|
||||
|
||||
Reference in New Issue
Block a user