diff --git a/cmd/mesh-controller/probe_agent_root_test.go b/cmd/mesh-controller/probe_agent_root_test.go index 3a8da86e..acca8056 100644 --- a/cmd/mesh-controller/probe_agent_root_test.go +++ b/cmd/mesh-controller/probe_agent_root_test.go @@ -10,6 +10,7 @@ import ( "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" ) var rootNow = time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC) @@ -196,3 +197,31 @@ func TestRootFreeIsAnsweredOnlyByTheServingController(t *testing.T) { t.Error("a verb that takes no list took one") } } + +// The confirmation review of 2026-10-09: ADR 0266's quiet window (#175) keeps the self-check from raising +// agent-can-become-root while the node-engine's first setuid search runs. It must not make root-free answer free: +// root-free needs a complete, fresh verdict. A verdict still waiting for the search is "not judged" to +// agentConfined, so the machine is not root-free, whatever the quiet says — and the same statement, complete +// and healthy, is the control. +func TestAMachineWaitingForItsFirstSetuidSearchIsNotRootFree(t *testing.T) { + now := rootNow + statement := func(state, reason string) inventory.NodeHealth { + return inventory.NodeHealth{Node: "anchor", Contract: link.RootContract, SaidAt: now, HeardAt: now, + Resources: []inventory.ResourceHealth{{Module: "claude-code", Resource: "agent", Kind: link.KindAccount, + Target: "agents", State: state, Reason: reason, Root: link.RootNever}}} + } + judged := func(h inventory.NodeHealth) rootVerdict { + confined, why := judgedConfined("agents", h, true, now) + return judgeRoot(rootFacts{Machine: "anchor", AgentNamed: true, Confined: confined, ConfinedWhy: why}, now) + } + if v := judged(statement(link.StateHealthy, "")); !v.Free { + t.Fatalf("the control: a complete healthy verdict, fresh: %+v", v) + } + pending := statement(link.StateUnknown, link.ReasonRootPending+": the search runs") + if rootVerdictKind("agents", pending, true, now) != verdictPending { + t.Fatal("the statement is not one the quiet window counts as waiting for the search") + } + if v := judged(pending); v.Free { + t.Errorf("a machine whose first setuid search is pending was judged root-free: %+v", v) + } +} diff --git a/internal/broker/membership.go b/internal/broker/membership.go index b1bb81b2..b44aa6af 100644 --- a/internal/broker/membership.go +++ b/internal/broker/membership.go @@ -113,6 +113,9 @@ func MembershipFor(node string, d Declared, where Placements) Membership { m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}", Queue: "serve." + d.Module}) } for _, s := range d.Holds { + if servedOnlyByTheController(s) { + continue // answered by the serving controller alone, never through a membership + } for _, verb := range s.Serves { m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)}) } diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 9252703d..52d1cfba 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -566,8 +566,12 @@ func PermissionsFor(p Principal) (Permissions, error) { "$JS.API.CONSUMER.INFO."+consumerStream(p)+"."+consumerDurable(p), "$JS.API.CONSUMER.MSG.NEXT."+consumerStream(p)+"."+consumerDurable(p)) - // 3. Seats it holds: full participation. + // 3. Seats it holds: full participation — but the controller's own seat, whose verbs only the serving + // controller answers, on its own connection (servedOnlyByTheController). for _, s := range p.Holds { + if servedOnlyByTheController(s) { + continue + } if s.isNewTraffic() { // Composed by SeatTrafficOf below, worker and all; only its tools are served here. for _, t := range s.Serves { @@ -661,6 +665,9 @@ func PermissionsFor(p Principal) (Permissions, error) { pub = append(pub, own+".event."+e) } for _, s := range d.Holds { + if servedOnlyByTheController(s) { + continue + } for _, t := range s.Serves { sub = append(sub, seatToolSubject(s, t, p.Node)) } @@ -795,6 +802,13 @@ func seatSubject(s Seat, kind, verb string) string { // seat carries the node it is asked of, because a flat subject would reach every machine's holder // and the queue group would silently pick a winner (novox/hq ADR 0132, design 33 §4). A holder // subscribes its own node's; a user publishes any node's (`*`) and names the machine in the subject. +// servedOnlyByTheController says a seat's verbs are answered by the serving controller alone, on its own +// connection (the KindController grant), never by a module claiming the seat or a runtime carrying it: the +// controller's own seat. Its verbs decide what the mesh is — and `root-free` decides whether the router believes +// a verified sender (novox/hq ADR 0259 §8) — so a machine's runtime, whose credential an agent on that machine +// may hold, answering one would be an agent answering it (the confirmation review of 2026-10-09). +func servedOnlyByTheController(s Seat) bool { return s.Name == ControllerSeat } + func seatToolSubject(s Seat, verb, node string) string { base := seatSubject(s, "tool", verb) if s.Scope == "node" && node != "" { diff --git a/internal/inventory/rootfree_grants_test.go b/internal/inventory/rootfree_grants_test.go new file mode 100644 index 00000000..119ecff2 --- /dev/null +++ b/internal/inventory/rootfree_grants_test.go @@ -0,0 +1,119 @@ +package inventory + +import ( + "os" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/catalogue" +) + +// grantMatches is a NATS subject pattern matching a subject: `*` one token, `>` the rest. +func grantMatches(pattern, subject string) bool { + p, s := strings.Split(pattern, "."), strings.Split(subject, ".") + for i, tok := range p { + if tok == ">" { + return len(s) > i + } + if i >= len(s) || (tok != "*" && tok != s[i]) { + return false + } + } + return len(p) == len(s) +} + +func grantsAny(patterns []string, subject string) bool { + for _, p := range patterns { + if grantMatches(p, subject) { + return true + } + } + return false +} + +// novox/hq ADR 0259 §8 (confirmation review of 2026-10-09): the router honours a verified sender only on the +// controller's `root-free` word, asked on the bus. So only the serving controller may answer that verb — be +// subscribed to its subject — and nobody may publish into the router's inbox but by answering a request it +// made (allow_responses). Composed here from the controller's own manifest, on a machine where the machine's +// runtime carries it beside an ordinary module, with the router, a channel, a person and an administrator: a +// runtime carrying the controller's module, a node-engine, a channel or anybody else answering `root-free` is +// an agent answering it. +func TestOnlyTheServingControllerMayAnswerRootFree(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + controller, err := catalogue.ParseManifest(raw) + if err != nil { + t.Fatal(err) + } + parse := func(s string) catalogue.Manifest { + m, err := catalogue.ParseManifest([]byte(s)) + if err != nil { + t.Fatal(err) + } + return m + } + dir := `"resources": [{"id": "state", "type": "directory", "mode": "0700", "place": "."}]` + router := parse(`{"module": "messenger", "version": "1", "runs-as": "messenger", + "seats": [{"name": "operator-channel", "scope": "mesh", "serves": ["open"], "accepts": ["ask"], + "emits": ["decided"], "by-caller": ["ask", "decided"]}], + "claims": [{"name": "operator-channel", "scope": "mesh", "serves": ["open"]}], + "invokes": ["seat:mesh-controller.root-free", "seat:mesh-controller.conditions"], + "own-secrets": {"broker": "${dir:state}/broker"}, "secrets-owner": "messenger", + "resources": [{"id": "account", "type": "user", "name": "messenger", "shell": "/usr/bin/nologin", "home": "/var/lib/messenger"}, + {"id": "state", "type": "directory", "mode": "0700", "place": ".", "owner": "messenger"}]}`) + ordinary := parse(`{"module": "lab-bystander", "version": "1", "own-secrets": {"broker": "${dir:state}/broker"}, ` + dir + `}`) + runtime := catalogue.Manifest{Module: broker.RuntimeModule} + + manifests := []catalogue.Manifest{controller, router, ordinary, runtime} + seats := map[string]catalogue.SeatDeclaration{} + declarers := map[string]string{} + for _, m := range manifests { + for _, s := range m.DefinesSeats { + seats[s.Name], declarers[s.Name] = s, m.Module + } + } + for _, own := range catalogue.SeatsWithAProtocol() { + seats[own.Name] = catalogue.SeatDeclaration{Name: own.Name, Scope: own.Scope, Accepts: own.Accepts, + Emits: own.Emits, Serves: own.Serves} + } + records := broker.Records{Nodes: []string{"anchor", "laptop"}, Assigned: map[string][]broker.Declared{}, + People: map[string][]string{"operator": {"*"}, "guest": {"mesh-controller.status"}}, + Interchangeable: map[string]bool{}} + for _, m := range manifests { + records.Assigned["anchor"] = append(records.Assigned["anchor"], declaredFor(m, seats, declarers)) + } + // And a second machine whose runtime carries an ordinary module: where agents run as the operator. + records.Assigned["laptop"] = []broker.Declared{declaredFor(ordinary, seats, declarers), declaredFor(runtime, seats, declarers)} + users, err := broker.Users(records) + if err != nil { + t.Fatal(err) + } + const verb = "mesh.seat.mesh-controller.tool.root-free" + answerers := 0 + for _, u := range users { + p, err := broker.PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + answers := grantsAny(p.Subscribe, verb) + if answers != (u.Kind == broker.KindController) { + t.Errorf("%s (%s) %s subscribe to %s", u.Username(), u.Kind, + map[bool]string{true: "may", false: "may not"}[answers], verb) + } + if answers { + answerers++ + } + // Nobody publishes into the router's inbox but as an answer to what it asked. + for _, inbox := range []string{"_INBOX.anchor.messenger.x1.y", "_INBOX.anchor.messenger.>"} { + if u.Username() != "anchor.messenger" && grantsAny(p.Publish, inbox) { + t.Errorf("%s may publish into the router's inbox (%s) without being asked", u.Username(), inbox) + } + } + } + if answerers != 1 { + t.Errorf("%d principals may answer root-free, want the controller alone", answerers) + } +}