Merge pull request 'Ask a node's engine which of its directories the walk would refuse, read-only (issue 358)' (#258) from fix/358-judge-directories-read-only into main

This commit was merged in pull request #258.
This commit is contained in:
2026-10-12 12:48:56 +00:00
13 changed files with 249 additions and 7 deletions
+51
View File
@@ -167,3 +167,54 @@ func TestASetuidSearchAsksOnlyAKnownNodeAndFailsOnARefusal(t *testing.T) {
t.Fatalf("a start: %v, printed %q", err, out.String()) t.Fatalf("a start: %v, printed %q", err, out.String())
} }
} }
// The directory judgement's line asks only a known node, one name and nothing else, fails on a refusal, and says it
// changed nothing (novox/hq issue 358).
func TestADirectoryJudgementAsksOnlyAKnownNodeAndSaysItIsReadOnly(t *testing.T) {
t.Setenv(link.CallerVar, "jo through mesh-cli on anchor")
asked := 0
ask := func(answer link.HandOverAnswer) func(node, by string) (link.HandOverAnswer, error) {
return func(node, by string) (link.HandOverAnswer, error) {
asked++
if node != "novox" || by != "jo through mesh-cli on anchor" {
t.Fatalf("asked %q %q", node, by)
}
return answer, nil
}
}
known := func(string) error { return nil }
var out bytes.Buffer
for _, args := range [][]string{nil, {"novox", "extra"}, {"-x"}} {
if err := judgeDirectoriesAsked(args, known, ask(link.HandOverAnswer{Said: "x"}), &out); err == nil || asked != 0 {
t.Fatalf("%v was asked: %v", args, err)
}
}
if err := judgeDirectoriesAsked([]string{"novox"}, func(string) error { return errors.New("no node called novox") },
ask(link.HandOverAnswer{Said: "x"}), &out); err == nil || asked != 0 {
t.Fatalf("an unknown node: %v", err)
}
if err := judgeDirectoriesAsked([]string{"novox"}, known, ask(link.HandOverAnswer{Refused: "no; nothing was judged"}),
&out); err == nil || !strings.Contains(err.Error(), "novox refused") || out.Len() != 0 {
t.Fatalf("a refusal: %v, printed %q", err, out.String())
}
said := "2 directories declared: 1 placed, 1 would be refused\n/var/lib/a: placed\n/var/lib/b: would be refused: …"
if err := judgeDirectoriesAsked([]string{"novox"}, known, ask(link.HandOverAnswer{Said: said}), &out); err != nil ||
out.String() != "novox (read-only: nothing on novox was changed)\n"+said+"\n" {
t.Fatalf("a judgement: %v, printed %q", err, out.String())
}
}
// The judgement is a seat verb an agent may call — it reads, and changes nothing on the node — and runs exactly
// `node judge-directories <node>` (novox/hq issue 358).
func TestTheDirectoryJudgementIsAVerbThatOnlyReads(t *testing.T) {
argv, err := argvFor("judge-directories", map[string]any{"node": "novox"})
if err != nil || strings.Join(argv, " ") != "node judge-directories novox" {
t.Fatalf("the verb runs %v, %v", argv, err)
}
if _, err := argvFor("judge-directories", map[string]any{}); err == nil {
t.Fatal("the verb ran with no node")
}
if !readingVerbs["judge-directories"] {
t.Fatal("the judgement is not counted a reading verb")
}
}
+61 -2
View File
@@ -127,9 +127,14 @@ func nodeCommand(ctx context.Context, args []string) error {
// and whoever may call a verb includes agents. // and whoever may call a verb includes agents.
return nodeSetuidSearch(ctx, open, args[1:]) return nodeSetuidSearch(ctx, open, args[1:])
case "judge-directories":
// Which of a node's declared directories the walk that follows no link another account could make would
// refuse (novox/hq issue 358). Read-only on the node and here, so a verb may run it too.
return nodeJudgeDirectories(ctx, open, args[1:])
default: default:
return fmt.Errorf("node has no %q; it has add, list, show, public-domain, account, agent-account, hand-over "+ return fmt.Errorf("node has no %q; it has add, list, show, public-domain, account, agent-account, hand-over, "+
"and setuid-search", args[0]) "setuid-search and judge-directories", args[0])
} }
} }
@@ -937,3 +942,57 @@ func printJSONTo(w io.Writer, v any) error {
fmt.Fprintln(w, string(body)) fmt.Fprintln(w, string(body))
return nil return nil
} }
const judgeDirectoriesUsage = "node judge-directories <node> — read-only: which of the directories <node> is " +
"declared the node-engine's walk that follows no link another account could make would refuse, one line each " +
"(novox/hq issue 358). Nothing is made, chmodded or chowned"
// nodeJudgeDirectories asks the node's engine to judge its declared directories, signed with the mesh's key as a
// hand-over is. Read-only: the engine changes nothing and keeps nothing.
func nodeJudgeDirectories(ctx context.Context, open *stores, args []string) error {
known := func(node string) error {
_, err := open.inventory.NodeByName(ctx, node)
return err
}
ask := func(node, by string) (link.HandOverAnswer, error) {
ident, err := open.Identity(ctx)
if err != nil {
return link.HandOverAnswer{}, fmt.Errorf("the mesh's signing key cannot be read, so nothing was asked of %s: %w",
node, err)
}
address, err := broker.BusAddress()
if err != nil {
return link.HandOverAnswer{}, err
}
js, err := broker.Dial(address)
if err != nil {
return link.HandOverAnswer{}, fmt.Errorf("cannot reach the bus, so nothing was asked of %s: %w", node, err)
}
defer js.Close()
return link.AskJudgeDirectories(ctx, js.Conn(), ident, node, by, link.HandOverWithin)
}
return judgeDirectoriesAsked(args, known, ask, os.Stdout)
}
// judgeDirectoriesAsked is the line with its two acts given: whether the mesh knows the node, and the ask. The
// engine's refusal is this command's failure.
func judgeDirectoriesAsked(args []string, known func(node string) error,
ask func(node, by string) (link.HandOverAnswer, error), out io.Writer) error {
if len(args) != 1 || args[0] == "" || strings.HasPrefix(args[0], "-") {
return errors.New(judgeDirectoriesUsage)
}
node := args[0]
if err := known(node); err != nil {
return fmt.Errorf("nothing was asked: %w", err)
}
answer, err := ask(node, handOverBy())
if err != nil {
return err
}
if answer.Refused != "" {
return fmt.Errorf("%s refused: %s", node, answer.Refused)
}
fmt.Fprintf(out, "%s (read-only: nothing on %s was changed)\n", node, node)
fmt.Fprintln(out, answer.Said)
return nil
}
+8 -1
View File
@@ -314,6 +314,11 @@ func (a *verbArguments) commandLine() ([]string, error) {
return nil, err return nil, err
} }
return []string{"node", "show", str("node")}, nil return []string{"node", "show", str("node")}, nil
case "judge-directories":
if err := need("node"); err != nil {
return nil, err
}
return []string{"node", "judge-directories", str("node")}, nil
case "modules": case "modules":
return []string{"module", "list", "--json"}, nil return []string{"module", "list", "--json"}, nil
case "seats": case "seats":
@@ -1479,7 +1484,9 @@ func seatAnnouncement(handlers map[string]link.ToolHandler) micro.Info {
} }
// nodeReads are the `node` subcommands a verb may run: the ones that only read. // nodeReads are the `node` subcommands a verb may run: the ones that only read.
var nodeReads = map[string]bool{"list": true, "show": true} var nodeReads = map[string]bool{"list": true, "show": true,
// Read-only on the node too: the engine judges its directories and changes nothing (novox/hq issue 358).
"judge-directories": true}
// flagsOnly says a command line's rest names no subcommand: empty, or beginning with a flag. For a command // flagsOnly says a command line's rest names no subcommand: empty, or beginning with a flag. For a command
// with no subcommands every word is a flag, its value or a name it reads. // with no subcommands every word is a flag, its value or a name it reads.
+1 -1
View File
@@ -172,7 +172,7 @@ func composeStatus(open *stores) func(context.Context) ([]byte, error) {
var readingVerbs = map[string]bool{ var readingVerbs = map[string]bool{
"tools": true, "calls": true, "status": true, "nodes": true, "node": true, "modules": true, "tools": true, "calls": true, "status": true, "nodes": true, "node": true, "modules": true,
"seats": true, "builds": true, "plan": true, "queue": true, "durations": true, "hand-acts": true, "seats": true, "builds": true, "plan": true, "queue": true, "durations": true, "hand-acts": true,
"doctor": true, "conditions": true, "healers": true, "doctor": true, "conditions": true, "healers": true, "judge-directories": true,
} }
// nudgingListener is the enrolment, nudging the summary when a machine said something new. // nudgingListener is the enrolment, nudging the summary when a machine said something new.
+10 -1
View File
@@ -336,6 +336,13 @@ func AskHandOverSubject(node string) string { return "mesh.node." + node + ".ask
// a hand-over is (link.SetuidSearchContext), for the same reason. // a hand-over is (link.SetuidSearchContext), for the same reason.
func AskSetuidSearchSubject(node string) string { return "mesh.node." + node + ".ask.setuid-search" } func AskSetuidSearchSubject(node string) string { return "mesh.node." + node + ".ask.setuid-search" }
// AskJudgeDirectoriesSubject is where the controller asks one machine's node-engine to judge, read-only, which of
// its declared directories the walk that follows no link another account could make would refuse (novox/hq issue
// 358): a request answered once, signed as a hand-over is (link.JudgeDirectoriesContext), for the same reason.
func AskJudgeDirectoriesSubject(node string) string {
return "mesh.node." + node + ".ask.judge-directories"
}
// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25 // inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25
// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's // §4): with one account, inbox privacy is the permission list or it is nothing, so each user's
// inbox is derived from its own identity and its permissions name that prefix and no other. // inbox is derived from its own identity and its permissions name that prefix and no other.
@@ -614,7 +621,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
// issue 356), which it answers on the request's reply: the one request a node is asked. // issue 356), which it answers on the request's reply: the one request a node is asked.
AskHandOverSubject(p.Node), AskHandOverSubject(p.Node),
// And asking it for a fresh search for setuid programs (novox/hq issue 361), answered the same way. // And asking it for a fresh search for setuid programs (novox/hq issue 361), answered the same way.
AskSetuidSearchSubject(p.Node)} AskSetuidSearchSubject(p.Node),
// And asking it to judge its declared directories, read-only (novox/hq issue 358), answered the same way.
AskJudgeDirectoriesSubject(p.Node)}
// The node-engine witnesses the core builds it places (novox/hq to-be 45 §8, ADR 0236; the // The node-engine witnesses the core builds it places (novox/hq to-be 45 §8, ADR 0236; the
// contract is lease/witness.go): it asks its own machine's node tools PING, and, where the // contract is lease/witness.go): it asks its own machine's node tools PING, and, where the
// machine runs the controller, reads the lease's one key — read, never written. // machine runs the controller, reads the lease's one key — read, never written.
+3 -1
View File
@@ -114,7 +114,9 @@ func TestOnlyWhatCanBeAskedMayAnswer(t *testing.T) {
node, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", PasswordHash: "x"}) node, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", PasswordHash: "x"})
if !node.AllowResponses || !slices.Contains(node.Subscribe, AskHandOverSubject("one")) || if !node.AllowResponses || !slices.Contains(node.Subscribe, AskHandOverSubject("one")) ||
!slices.Contains(node.Subscribe, AskSetuidSearchSubject("one")) || !slices.Contains(node.Subscribe, AskSetuidSearchSubject("one")) ||
slices.Contains(node.Subscribe, AskSetuidSearchSubject("two")) { slices.Contains(node.Subscribe, AskSetuidSearchSubject("two")) ||
!slices.Contains(node.Subscribe, AskJudgeDirectoriesSubject("one")) ||
slices.Contains(node.Subscribe, AskJudgeDirectoriesSubject("two")) {
t.Fatalf("a node cannot answer the hand-over it is asked: %v %v", node.AllowResponses, node.Subscribe) t.Fatalf("a node cannot answer the hand-over it is asked: %v %v", node.AllowResponses, node.Subscribe)
} }
person, _ := PermissionsFor(Principal{Kind: KindPerson, Node: "one", Module: "jo", PasswordHash: "x"}) person, _ := PermissionsFor(Principal{Kind: KindPerson, Node: "one", Module: "jo", PasswordHash: "x"})
+1 -1
View File
@@ -34,7 +34,7 @@ accounts {
} } } }
{ user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: { { user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: {
publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.INFO.NODES.one", "$SRV.PING.node-tools.one", "mesh.control.one.>"] } publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.INFO.NODES.one", "$SRV.PING.node-tools.one", "mesh.control.one.>"] }
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] } subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] }
allow_responses: { max: 1, ttl: "1m" } allow_responses: { max: 1, ttl: "1m" }
} } } }
{ user: "one.nats", password: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb", permissions: { { user: "one.nats", password: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb", permissions: {
+5
View File
@@ -96,6 +96,11 @@ var WritersTable = []WriterRow{
{State: "a fresh setuid search asked of a machine", Writer: "controller, at its terminal", {State: "a fresh setuid search asked of a machine", Writer: "controller, at its terminal",
KeptIn: "the machine, which throws its last search away", Others: "the engine verifies the mesh's signature and starts it, or refuses", KeptIn: "the machine, which throws its last search away", Others: "the engine verifies the mesh's signature and starts it, or refuses",
Subjects: []string{"mesh.node.*.ask.setuid-search"}, Writes: isController}, Subjects: []string{"mesh.node.*.ask.setuid-search"}, Writes: isController},
// A read-only judgement of a machine's declared directories asked of its engine (novox/hq issue 358): signed as a
// hand-over is, under a signing context of its own. It changes nothing on the machine.
{State: "a judgement of a machine's directories asked of it", Writer: "controller",
KeptIn: "nowhere: the engine answers it and keeps nothing", Others: "the engine verifies the mesh's signature and answers, or refuses",
Subjects: []string{"mesh.node.*.ask.judge-directories"}, Writes: isController},
{State: "a machine's applied state and its report", Writer: "the node-engine's apply queue", {State: "a machine's applied state and its report", Writer: "the node-engine's apply queue",
KeptIn: "the machine; the report on the bus", Others: "the reconcile and a delivery enqueue, never apply", KeptIn: "the machine; the report on the bus", Others: "the reconcile and a delivery enqueue, never apply",
// And its health statement between reports (novox/hq ADR 0240): the same writer stating the same // And its health statement between reports (novox/hq ADR 0240): the same writer stating the same
+19
View File
@@ -17,6 +17,7 @@ var designRows = []string{
"a machine's declaration", "a machine's declaration",
"a hand-over asked of a machine", "a hand-over asked of a machine",
"a fresh setuid search asked of a machine", "a fresh setuid search asked of a machine",
"a judgement of a machine's directories asked of it",
"a machine's applied state and its report", "a machine's applied state and its report",
"the controller lease", "the controller lease",
"plans and their tiers", "plans and their tiers",
@@ -186,3 +187,21 @@ func TestASetuidSearchAskHasOnePublisher(t *testing.T) {
} }
} }
} }
// **A judgement of a machine's directories asked of it has one publisher, the controller** (novox/hq issue 358), as a
// hand-over has.
func TestADirectoryJudgementAskHasOnePublisher(t *testing.T) {
for _, p := range []Principal{
{Kind: KindNode, Node: "laptop"},
{Kind: KindNodeTools, Node: "laptop", Module: RuntimeModule},
{Kind: KindModule, Node: "laptop", Module: "notes"},
} {
err := CheckWriters(p, []string{AskJudgeDirectoriesSubject("laptop")})
if err == nil || !strings.Contains(err.Error(), "a judgement of a machine's directories asked of it") {
t.Errorf("%s may ask a judgement of directories: %v", p.Username(), err)
}
}
if err := CheckWriters(Principal{Kind: KindController}, []string{AskJudgeDirectoriesSubject("laptop")}); err != nil {
t.Fatalf("the controller may not ask a judgement: %v", err)
}
}
+7
View File
@@ -490,6 +490,13 @@ var ControllerVerbs = []Verb{
"why": "with consumer or older-than: why — required, and recorded in the hand-act log", "why": "with consumer or older-than: why — required, and recorded in the hand-act log",
"cause": "with consumer or older-than: the cause in a word (cleanup-waiting when absent)", "cause": "with consumer or older-than: the cause in a word (cleanup-waiting when absent)",
}, nil, "confirm")}, }, nil, "confirm")},
{Name: "judge-directories", Description: "Read-only: which of the directories one machine is declared its " +
"node-engine's walk that follows no link another account could make would refuse (novox/hq issue 358), one line " +
"each: placed, would be refused and why (the link and who could replace it), refused already by the placement " +
"guard, or below a home. The engine judges the declaration it keeps and makes, chmods and chowns nothing.",
Input: schema(map[string]string{
"node": "the machine's name",
}, []string{"node"})},
// What a consumer gave up on (novox/hq issue 330): kept in DEAD_LETTERS until a person acts on it. // What a consumer gave up on (novox/hq issue 330): kept in DEAD_LETTERS until a person acts on it.
{Name: "root-free", Description: "Whether each machine named is root-free now (novox/hq ADR 0259 §8): no agent " + {Name: "root-free", Description: "Whether each machine named is root-free now (novox/hq ADR 0259 §8): no agent " +
"there can become root without a person. Judged when asked, never from a condition: free only when the machine " + "there can become root without a person. Judged when asked, never from a condition: free only when the machine " +
+22
View File
@@ -148,6 +148,28 @@ func AskSetuidSearch(ctx context.Context, conn *nats.Conn, signer Signer, node,
"there", record: "a start"}) "there", record: "a start"})
} }
// JudgeDirectoriesContext is prefixed to a directory judgement ask's bytes before signing (novox/hq issue 358): never
// another ask's signature, nor a declaration's. The engine holds the same words.
const JudgeDirectoriesContext = "novox-mesh judge-directories v1\n"
// AskJudgeDirectories asks one machine's node-engine to judge, read-only, which of the directories it is declared
// the walk that follows no link another account could make would refuse (novox/hq issue 358): the ask a hand-over
// is, naming no path, signed under JudgeDirectoriesContext. The engine changes nothing and answers one line per
// directory.
func AskJudgeDirectories(ctx context.Context, conn *nats.Conn, signer Signer, node, by string,
timeout time.Duration) (HandOverAnswer, error) {
if conn == nil {
return HandOverAnswer{}, errors.New("this controller is not on the bus")
}
body, err := signAsk(ctx, signer, JudgeDirectoriesContext, HandOverAsk{Node: node, By: by})
if err != nil {
return HandOverAnswer{}, err
}
return askSigned(ctx, conn, broker.AskJudgeDirectoriesSubject(node), body, node, timeout, askWords{
what: "a judgement of its directories", nothing: "nothing was judged, and nothing is changed by one",
issue: "issue 358", unknown: "ask again: a judgement changes nothing", record: "a judgement"})
}
// SignHandOver fills the ask's expiry and nonce and signs it with the mesh's key, over HandOverContext and the // SignHandOver fills the ask's expiry and nonce and signs it with the mesh's key, over HandOverContext and the
// ask's bytes exactly as they travel. // ask's bytes exactly as they travel.
func SignHandOver(ctx context.Context, signer Signer, ask HandOverAsk) ([]byte, error) { func SignHandOver(ctx context.Context, signer Signer, ask HandOverAsk) ([]byte, error) {
+60
View File
@@ -0,0 +1,60 @@
package link
import (
"context"
"crypto/ed25519"
"encoding/json"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/testbus"
)
// A judgement of a machine's directories is asked of the machine on its own subject, signed under its own context
// (never a hand-over's), naming no path, and the engine's answer comes back (novox/hq issue 358). The engine holds the
// same subject and context (mesh-host internal/link, TestTheDirectoryJudgementAskKeepsItsSubjectAndContext).
func TestADirectoryJudgementIsAskedOfTheMachineSignedUnderItsOwnContext(t *testing.T) {
if broker.AskJudgeDirectoriesSubject("laptop") != "mesh.node.laptop.ask.judge-directories" ||
JudgeDirectoriesContext != "novox-mesh judge-directories v1\n" {
t.Fatalf("the subject %q, the context %q", broker.AskJudgeDirectoriesSubject("laptop"), JudgeDirectoriesContext)
}
conn, err := nats.Connect(testbus.URL(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(conn.Close)
signer, public := testSigner(t)
var heard HandOverAsk
engine, err := conn.Subscribe(broker.AskJudgeDirectoriesSubject("laptop"), func(msg *nats.Msg) {
var signed SignedHandOver
_ = json.Unmarshal(msg.Data, &signed)
if ed25519.Verify(public, append([]byte(HandOverContext), signed.Ask...), signed.Signature) ||
ed25519.Verify(public, append([]byte(SetuidSearchContext), signed.Ask...), signed.Signature) ||
!ed25519.Verify(public, append([]byte(JudgeDirectoriesContext), signed.Ask...), signed.Signature) {
_ = msg.Respond([]byte(`{"refused":"not signed as a judgement"}`))
return
}
_ = json.Unmarshal(signed.Ask, &heard)
body, _ := json.Marshal(HandOverAnswer{Said: "3 directories declared, asked by " + heard.By})
_ = msg.Respond(body)
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Unsubscribe() })
a, err := AskJudgeDirectories(context.Background(), conn, signer, "laptop", "jo", 5*time.Second)
if err != nil || a.Said != "3 directories declared, asked by jo" {
t.Fatalf("answered %+v, %v", a, err)
}
if heard.Node != "laptop" || heard.Path != "" || heard.Nonce == "" || heard.Expires.IsZero() {
t.Fatalf("the engine heard %+v", heard)
}
if _, err := AskJudgeDirectories(context.Background(), conn, signer, "anchor", "jo", 5*time.Second); err == nil ||
!strings.Contains(err.Error(), "nothing was judged") {
t.Fatalf("a machine with no engine listening: %v", err)
}
}
+1
View File
@@ -89,6 +89,7 @@
"cleanup", "cleanup",
"dead-letters", "dead-letters",
"root-free", "root-free",
"judge-directories",
"data", "data",
"build", "build",
"artifacts", "artifacts",