Sign the hand-over ask, and fail the line on the engine's refusal (hq issue 356, review)

The subject proved nothing: the bus lets any principal allowed to answer reply to a message it received on the reply subject that message named, so a tool server — the operator's account, every agent — could deliver a hand-over to an engine. The controller now signs the ask with the mesh's key over a fixed context (node, path, who asked, a minute's expiry, a fresh nonce), as declarations are signed, and the engine verifies it. The writers table gains the row for mesh.node.*.ask.hand-over; the subject's comment no longer claims who the engine hears. The line's known-node check and the refusal branch are tested; every check was removed in turn and a test failed.
This commit is contained in:
jochen
2026-10-09 19:44:55 +02:00
parent 9006c82393
commit b55a38ca9f
7 changed files with 253 additions and 35 deletions
+8 -3
View File
@@ -291,7 +291,10 @@ func AskReportSubject(node string) string { return "mesh.node." + node + ".ask.r
// AskHandOverSubject is where the controller's terminal asks one machine's node-engine to hand a directory it
// uses as found to the mesh (novox/hq issue 356, issue 339): a request on core NATS, answered once on the reply
// it carries. Only the controller publishes under `mesh.node.`, so the engine hears nobody else.
// it carries. Only the controller is granted a publish here (the writers table holds it), but **that is not who
// the engine hears**: the bus lets any principal allowed to answer reply to a message it received, on the reply
// subject that message named, so a message can arrive here from any responder. The ask is therefore signed with
// the mesh's key (link.SignedHandOver), and the engine verifies it before reading anything out of it.
func AskHandOverSubject(node string) string { return "mesh.node." + node + ".ask.hand-over" }
// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25
@@ -872,8 +875,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
Subscribe: sub,
// A module answers what it was asked — a tool call reaches it on its own namespace, so the
// authority is bounded by having been asked — and so does the controller. A node is asked one
// thing, a hand-over on its own subject (novox/hq issue 356), and answers that. A person is never
// asked anything, and is granted nothing here.
// thing, a hand-over on its own subject (novox/hq issue 356), and answers that: the node is its
// machine's engine, root there already, and it is delivered only its own subjects. What it answers is
// never trusted for being an answer — the controller reads the engine's words and records nothing. A
// person is never asked anything, and is granted nothing here.
AllowResponses: p.Kind == KindModule || p.Kind == KindController || p.Kind == KindNodeTools || p.Kind == KindNode,
}, nil
}
+7
View File
@@ -84,6 +84,13 @@ func kvOf(bucket string) []string { return []string{"$KV." + bucket + ".>"} }
var WritersTable = []WriterRow{
{State: "a machine's declaration", Writer: "controller (lease holder)", KeptIn: "the bus, last per subject",
Others: "read", Subjects: []string{"mesh.node.*.declare"}, Writes: isController},
// The operator's hand-over of a directory used as found, asked of the machine's engine at the controller's
// terminal (novox/hq issue 356). One publisher; and because a responder can still reach the subject through a
// reply, the ask is signed with the mesh's key and the engine verifies it — the row bounds who is granted the
// publish, the signature who is believed.
{State: "a hand-over asked of a machine", Writer: "controller, at its terminal", KeptIn: "the machine, beside its state",
Others: "the engine verifies the mesh's signature and records it, or refuses",
Subjects: []string{"mesh.node.*.ask.hand-over"}, Writes: isController},
{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",
// And its health statement between reports (novox/hq ADR 0240): the same writer stating the same
+20
View File
@@ -15,6 +15,7 @@ import (
// to the table; one dropped from either fails.
var designRows = []string{
"a machine's declaration",
"a hand-over asked of a machine",
"a machine's applied state and its report",
"the controller lease",
"plans and their tiers",
@@ -150,3 +151,22 @@ func TestSubjectsOverlap(t *testing.T) {
}
}
}
// **A hand-over asked of a machine has one publisher, the controller** (novox/hq issue 356): a grant that lets any
// other principal publish it — a node, the node tools, a module — is refused at composition, naming the state.
// (Who the engine believes is the signature's; this bounds who is granted the publish.)
func TestAHandOverAskHasOnePublisher(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{"mesh.node.laptop.ask.hand-over"})
if err == nil || !strings.Contains(err.Error(), "a hand-over asked of a machine") {
t.Errorf("%s may publish a hand-over: %v", p.Username(), err)
}
}
if err := CheckWriters(Principal{Kind: KindController}, []string{"mesh.node.>"}); err != nil {
t.Fatalf("the controller may not ask a hand-over: %v", err)
}
}