From f5f315cc95563ee0e76934cbddd0087312a4d68c Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 16:27:30 +0200 Subject: [PATCH 01/11] Grant a seat's traffic by caller and by kind, so an ask's asker and a channel's kind are facts the bus enforces (hq ADR 0259) --- cmd/mesh-controller/busobjects.go | 32 ++++ internal/broker/derived.go | 4 +- internal/broker/membership.go | 10 ++ internal/broker/nats.go | 39 ++++- internal/broker/seattraffic.go | 253 +++++++++++++++++++++++++++ internal/broker/seattraffic_test.go | 240 +++++++++++++++++++++++++ internal/catalogue/kinded_test.go | 95 ++++++++++ internal/catalogue/manifest.go | 3 + internal/catalogue/resolve.go | 16 +- internal/catalogue/seats_declared.go | 115 ++++++++++++ internal/inventory/busrecords.go | 36 +++- 11 files changed, 831 insertions(+), 12 deletions(-) create mode 100644 internal/broker/seattraffic.go create mode 100644 internal/broker/seattraffic_test.go create mode 100644 internal/catalogue/kinded_test.go diff --git a/cmd/mesh-controller/busobjects.go b/cmd/mesh-controller/busobjects.go index 71c99431..3c21d1d9 100644 --- a/cmd/mesh-controller/busobjects.go +++ b/cmd/mesh-controller/busobjects.go @@ -53,9 +53,26 @@ func assertBusObjects(ctx context.Context, inv *inventory.Inventory, r broker.Ra if err != nil { return nil, err } + // And the work queues of seats that name their caller or their kind, with each holder's worker + // (novox/hq ADR 0259 §3): an ask queues until the router takes it, a channel's work until that kind + // takes it. + trafficStreams, trafficWorkers, err := seatTrafficObjects(ctx, inv) + if err != nil { + return nil, err + } + for _, s := range trafficStreams { + if err := r.EnsureStream(s); err != nil { + return nil, fmt.Errorf("asserting the work queue %s: %w", s.Name, err) + } + } // Every one tried, and every failure named: one module's consumer the bus refuses is no reason // the modules after it in the list hear nothing (novox/hq issue 208, where this runs on each send). var failed []error + for _, c := range trafficWorkers { + if err := r.EnsureConsumer(c); err != nil { + failed = append(failed, fmt.Errorf("the worker %s on %s: %w", c.Name, c.Stream, err)) + } + } for _, c := range consumers { if err := r.EnsureConsumer(c.Consumer); err != nil { failed = append(failed, fmt.Errorf("how %s on %s hears what it consumes: %w", c.Module, c.Node, err)) @@ -130,6 +147,21 @@ func moduleConsumers(ctx context.Context, inv *inventory.Inventory) ([]broker.Mo return broker.ConsumersOf(users), nil } +// seatTrafficObjects is the work queues and workers of seats that name their caller or their kind, from +// the records the user list is composed from. +func seatTrafficObjects(ctx context.Context, inv *inventory.Inventory) ([]broker.Stream, []broker.Consumer, error) { + records, err := inv.BusRecords(ctx) + if err != nil { + return nil, nil, err + } + users, err := broker.Users(records) + if err != nil { + return nil, nil, err + } + streams, workers := broker.SeatTrafficObjects(users) + return streams, workers, nil +} + // moduleConsumerCount is how many modules hear what they consume, for the raise's one line. func moduleConsumerCount(ctx context.Context, inv *inventory.Inventory) (int, error) { consumers, err := moduleConsumers(ctx, inv) diff --git a/internal/broker/derived.go b/internal/broker/derived.go index 28831526..0a31ef41 100644 --- a/internal/broker/derived.go +++ b/internal/broker/derived.go @@ -126,7 +126,9 @@ func ConsumerFor(p Principal) (Consumer, bool) { // A module that reacts to anything — a module's events or a role's (novox/hq ADR 0121). Watching // a role was missing here, so the one module that does it got no consumer at all: it started, // connected, and its graph stayed empty with nothing anywhere reporting why. - if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0) { + // And one that hears its own answers on a seat it uses (novox/hq ADR 0259 §3): an asker's warrants. + hearsItsOwn := len(SeatTrafficOf(p.Module, nil, p.Uses, nil).Subscribe) > 0 + if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0 && !hearsItsOwn) { return Consumer{}, false } perms, err := PermissionsFor(p) diff --git a/internal/broker/membership.go b/internal/broker/membership.go index ed4d3c51..2929378c 100644 --- a/internal/broker/membership.go +++ b/internal/broker/membership.go @@ -50,6 +50,12 @@ type Membership struct { // and refuses, with the reason, what is not on it — the bus enforces only the union over every // module on the machine. State []StateIssued `json:"state,omitempty"` + // SeatTraffic is what this module's code may submit, say, hear, take, ask, answer and read on seats + // that name their caller or their kind (novox/hq ADR 0259 §3). The runtime carrying the module + // publishes, takes and answers for it only what is listed here: the bus enforces only the union + // over every module on the machine, so one module's code reaching another's name or kind through + // the runtime is the runtime's to refuse. + SeatTraffic *SeatTraffic `json:"seat-traffic,omitempty"` } // Served is one address a tool is answered on. @@ -109,6 +115,10 @@ func MembershipFor(node string, d Declared, where Placements) Membership { } } m.State = stateIssuedFor(d, node) + if t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches); len(t.Publish)+len(t.Subscribe)+ + len(t.Answers)+len(t.Workers)+len(t.Records) > 0 { + m.SeatTraffic = &t + } if len(d.Invokes) > 0 { m.Reaches = map[string][]string{} for _, t := range d.Invokes { diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 3e765d71..9372e0a4 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -63,6 +63,18 @@ type Seat struct { Emits []string Serves []string Versions []string // protocol versions served beside the current one; empty for v1 only + + // Kinded says the seat is a kinded bench (novox/hq ADR 0234 §2, ADR 0259 §3): each holder claims one + // kind, and its verbs' subjects carry it. Kind is the kind this principal's claim names, for a seat it + // holds. + Kinded bool + Kind string + // ByCaller are the accepts and emits whose last token names the calling module (ADR 0259 §3). + ByCaller []string + // Proofs are the seat's proof verbs: core request and reply, never on a stream (ADR 0259 §3). + Proofs []string + // Records are the holder's buckets, by their full name, each user reads under its own name. + Records []string } // A Principal is one user of the bus. Its permissions are derived from what it declares and @@ -530,6 +542,9 @@ func PermissionsFor(p Principal) (Permissions, error) { // 2b. Events of a role it watches, under the seat's own namespace. Subscribe only: watching a // role is hearing what it announced, not taking part in it. for _, w := range p.Watches { + if w.Kinded { + continue // composed by SeatTrafficOf below + } for _, e := range w.Emits { sub = append(sub, seatSubject(w, "event", e)) } @@ -548,6 +563,13 @@ func PermissionsFor(p Principal) (Permissions, error) { // 3. Seats it holds: full participation. for _, s := range p.Holds { + if s.isNewTraffic() { + // Composed by SeatTrafficOf below, worker and all; only its tools are served here. + for _, t := range s.Serves { + sub = append(sub, seatToolSubject(s, t, p.Node)) + } + continue + } // Taking work from the role's queue: the worker consumer every holder shares (asked // about, pulled from, acknowledged), on the seat's own stream (novox/hq ADR 0190). A // holder pulls — asks the consumer for its next message, answered on its own inbox — @@ -590,7 +612,7 @@ func PermissionsFor(p Principal) (Permissions, error) { // seat's inbound subject and watch other modules' traffic, nor publish its outbound // events and lie about outcomes (design 29 §2). for _, s := range p.Uses { - for _, a := range s.Accepts { + for _, a := range plainVerbs(s, s.Accepts) { pub = append(pub, seatSubject(s, "accept", a)) } for _, t := range s.Serves { @@ -603,6 +625,12 @@ func PermissionsFor(p Principal) (Permissions, error) { pub = append(pub, stateGrants(stateAccess{Module: p.Module, Node: p.Node, Keeps: p.State, PerMachine: p.PerMachine, Reads: p.Reads, KeyedReads: p.KeyedReads})...) + // 6. Its traffic on seats that name their caller or their kind, ask proofs or keep records + // (novox/hq ADR 0259 §3). + tp, ts := SeatTrafficOf(p.Module, p.Holds, p.Uses, p.Watches).grants() + pub = append(pub, tp...) + sub = append(sub, ts...) + case KindNodeTools: // **One process serves what every module on the machine would have served for itself** // (novox/hq ADR 0175). Each carried module's whole tool namespace — the same grant that @@ -680,6 +708,15 @@ func PermissionsFor(p Principal) (Permissions, error) { pub = append(pub, stateGrants(stateAccess{Module: d.Module, Node: p.Node, Keeps: stateNames(d.State), PerMachine: perMachineNames(d.State), Reads: d.Reads, KeyedReads: d.KeyedReads})...) } + // **And the seat traffic of the modules it carries** (novox/hq ADR 0259 §3): a bundle reaches the + // bus only through its runtime, so the runtime is granted the union. That one module's code does + // not publish under another's name or kind through it is the runtime's to keep, from the seat + // traffic each module's membership lists. + for _, d := range p.Carries { + tp, ts := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches).grants() + pub = append(pub, tp...) + sub = append(sub, ts...) + } sub = unique(sub) pub = unique(pub) } diff --git a/internal/broker/seattraffic.go b/internal/broker/seattraffic.go new file mode 100644 index 00000000..1cb4c1ee --- /dev/null +++ b/internal/broker/seattraffic.go @@ -0,0 +1,253 @@ +package broker + +import "sort" + +// What a module may say and take on the seats it holds, uses and watches, beyond tools (novox/hq ADR +// 0259 §3, to-be 46 §10). +// +// Three rules are added to the ones a seat always had, each a subject whose last token names who may +// publish it, granted to that publisher alone — the way a node seat's event about a machine carries the +// machine (ADR 0219): +// +// - **A verb named by its caller** (`by-caller`). A user of the seat submits that accept, and hears that +// event, under its own module's name and no other: `accept.ask.`, `event.decided.`. The +// holder takes every caller's accept and says the event to any caller. So an ask's asker is a fact the +// server enforces, and a warrant reaches only the asker it is for. +// - **A kinded bench** (ADR 0234 §2). A holder claims one kind and takes its own kind's accepts, says its +// own kind's events and asks its own kind's proofs, and nothing of another kind; a user submits to any +// kind. Each kind has its own worker on the seat's queue, so a holder that is away keeps its work and +// holds up no other kind. +// - **A proof** (`proofs`): core request and reply on `mesh.seat..proof..`. No stream's +// subjects cover it, so what travels there — a code typed by the operator — is never persisted. A kinded +// holder asks with its own kind; the modules that watch the seat answer. +// +// And one read: **a holder's records**, a bucket the seat names, read by each user under its own name only +// (`$KV...>`), so an asker reads the state of its own asks and no other asker's. + +// Worker is one durable consumer a holder pulls a seat's work from. +type Worker struct { + Stream string + Consumer string + Filter string +} + +// SeatTraffic is one module's seat traffic beyond tools. Publish and Subscribe are subject patterns, in the +// server's wildcards; the runtime that carries the module checks a bundle's request against them, since +// the runtime's own principal holds the union of every module it carries. +type SeatTraffic struct { + // Publish is what it submits (accepts of seats it uses), says (events of seats it holds) and asks + // (proofs of seats it holds a kind of). + Publish []string `json:"publish,omitempty"` + // Subscribe is what it hears: events of seats it uses that are named by caller, events of seats it + // watches, and accepts of seats it holds. + Subscribe []string `json:"subscribe,omitempty"` + // Answers is the proof subjects it answers, as a watcher of a kinded seat. + Answers []string `json:"answers,omitempty"` + // Workers are the work queues it takes from, as a holder. + Workers []Worker `json:"workers,omitempty"` + // Records is the direct-get subjects of the records it reads under its own name. + Records []string `json:"records,omitempty"` +} + +// KindedBenches are the seats that may be kinded (ADR 0234 §2): making another is a decision, recorded. +var KindedBenches = map[string]bool{"channel": true, "intake": true} + +// WorkerName is the worker a seat's holders pull from: one for the seat, or one per kind on a kinded bench. +func WorkerName(seat, kind string) string { + if kind == "" { + return "SEAT_" + upperSnake(seat) + "_worker" + } + return "SEAT_" + upperSnake(seat) + "_" + upperSnake(kind) + "_worker" +} + +func namesVerb(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +// SeatTrafficOf derives one module's seat traffic from the seats it holds, uses and watches. Only seats +// carrying one of the rules above are read: every other seat is composed as it always was. +func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic { + var t SeatTraffic + for _, s := range holds { + if !s.isNewTraffic() { + continue + } + kind := "" + if s.Kinded { + kind = s.Kind + if kind == "" || !safeSubject.MatchString(kind) { + // A kinded claim without a usable kind is refused at registration; here it is granted + // nothing, which is the same answer at the last place it could be asked. + continue + } + } + if len(s.Accepts) > 0 { + stream := seatStreamName(s.Name) + filter := "mesh.seat." + s.Name + ".accept.>" + if kind != "" { + filter = "mesh.seat." + s.Name + ".accept.*." + kind + } + t.Workers = append(t.Workers, Worker{Stream: stream, Consumer: WorkerName(s.Name, kind), Filter: filter}) + } + for _, a := range s.Accepts { + switch { + case kind != "": + t.Subscribe = append(t.Subscribe, seatSubject(s, "accept", a+"."+kind)) + case namesVerb(s.ByCaller, a): + t.Subscribe = append(t.Subscribe, seatSubject(s, "accept", a+".*")) + } + } + for _, e := range s.Emits { + switch { + case kind != "": + t.Publish = append(t.Publish, seatSubject(s, "event", e+"."+kind)) + case namesVerb(s.ByCaller, e): + t.Publish = append(t.Publish, seatSubject(s, "event", e+".*")) + } + } + if kind != "" { + for _, v := range s.Proofs { + t.Publish = append(t.Publish, seatSubject(s, "proof", v+"."+kind)) + } + } + } + for _, s := range uses { + if !s.isNewTraffic() { + continue + } + for _, a := range s.Accepts { + switch { + case namesVerb(s.ByCaller, a): + t.Publish = append(t.Publish, seatSubject(s, "accept", a+"."+module)) + case s.Kinded: + t.Publish = append(t.Publish, seatSubject(s, "accept", a+".*")) + } + } + for _, e := range s.Emits { + if namesVerb(s.ByCaller, e) { + t.Subscribe = append(t.Subscribe, seatSubject(s, "event", e+"."+module)) + } + } + for _, b := range s.Records { + if !safeSubject.MatchString(b) { + continue + } + t.Records = append(t.Records, "$JS.API.DIRECT.GET.KV_"+b+".$KV."+b+"."+module+".>") + } + } + for _, w := range watches { + if w.Kinded { + for _, e := range w.Emits { + t.Subscribe = append(t.Subscribe, seatSubject(w, "event", e+".*")) + } + for _, v := range w.Proofs { + t.Answers = append(t.Answers, seatSubject(w, "proof", v+".*")) + } + } + } + t.Publish = unique(t.Publish) + t.Subscribe = unique(t.Subscribe) + t.Answers = unique(t.Answers) + t.Records = unique(t.Records) + sort.Slice(t.Workers, func(i, j int) bool { return t.Workers[i].Consumer < t.Workers[j].Consumer }) + return t +} + +// grants is the bus permissions seat traffic needs: the subjects themselves, and the JetStream API a +// worker is pulled and acknowledged through and a record is read through. +func (t SeatTraffic) grants() (pub, sub []string) { + pub = append(pub, t.Publish...) + sub = append(sub, t.Subscribe...) + sub = append(sub, t.Answers...) + for _, w := range t.Workers { + pub = append(pub, + "$JS.API.CONSUMER.INFO."+w.Stream+"."+w.Consumer, + "$JS.API.CONSUMER.MSG.NEXT."+w.Stream+"."+w.Consumer, + "$JS.ACK."+w.Stream+"."+w.Consumer+".>") + } + pub = append(pub, t.Records...) + return pub, sub +} + +// isNewTraffic says whether a seat carries any of the rules above, so a seat that carries none is +// composed exactly as before them. +func (s Seat) isNewTraffic() bool { + return s.Kinded || len(s.ByCaller) > 0 || len(s.Proofs) > 0 || len(s.Records) > 0 +} + +// plainVerbs is a seat's accepts or emits with those the rules above compose taken out: a verb named by +// its caller and every verb of a kinded bench are composed by SeatTrafficOf and nowhere else. +func plainVerbs(s Seat, verbs []string) []string { + if s.Kinded { + return nil + } + var out []string + for _, v := range verbs { + if !namesVerb(s.ByCaller, v) { + out = append(out, v) + } + } + return out +} + +// SeatTrafficObjects is the work queues and workers the seat traffic of every composed user implies +// (novox/hq ADR 0259 §3): a queue for each seat a holder takes work from, and each holder's worker on it — +// one per kind on a kinded bench, filtered to that kind, so the kinds never take each other's work. Only +// seats carrying the rules above; the mesh's own seats' queues are RaiseSeats'. +func SeatTrafficObjects(users []Principal) ([]Stream, []Consumer) { + streams := map[string]Stream{} + consumers := map[string]Consumer{} + add := func(module string, holds []Seat) { + for _, w := range SeatTrafficOf(module, holds, nil, nil).Workers { + seat := "" + for _, s := range holds { + if seatStreamName(s.Name) == w.Stream { + seat = s.Name + } + } + streams[w.Stream] = Stream{ + Name: w.Stream, + Subjects: []string{"mesh.seat." + seat + ".accept.>"}, + Retention: RetentionWorkQueue, + MaxAge: 7 * 24 * 60 * 60, + Why: "work submitted to the " + seat + " seat; its holders take it, each kind its own, " + + "and it queues while nobody does", + } + consumers[w.Consumer] = Consumer{ + Name: w.Consumer, + Stream: w.Stream, + Filters: []string{w.Filter}, + AckWaitSeconds: 60, + MaxDeliver: 5, + Why: module + " holds " + seat + "; it pulls one ask at a time and acknowledges once it has " + + "recorded it, so a crash redelivers rather than loses", + } + } + } + for _, p := range users { + switch p.Kind { + case KindModule: + add(p.Module, p.Holds) + case KindNodeTools: + for _, d := range p.Carries { + add(d.Module, d.Holds) + } + } + } + var ss []Stream + for _, s := range streams { + ss = append(ss, s) + } + sort.Slice(ss, func(i, j int) bool { return ss[i].Name < ss[j].Name }) + var cs []Consumer + for _, c := range consumers { + cs = append(cs, c) + } + sort.Slice(cs, func(i, j int) bool { return cs[i].Name < cs[j].Name }) + return ss, cs +} diff --git a/internal/broker/seattraffic_test.go b/internal/broker/seattraffic_test.go new file mode 100644 index 00000000..3df3c708 --- /dev/null +++ b/internal/broker/seattraffic_test.go @@ -0,0 +1,240 @@ +package broker + +import ( + "strings" + "testing" +) + +// The seats of novox/hq ADR 0259 §3, as the messenger declares them. +func operatorChannel() Seat { + return Seat{Name: "operator-channel", Scope: "mesh", Accepts: []string{"ask", "cancel"}, + Emits: []string{"decided"}, Serves: []string{"open", "history", "notify"}, + ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"messenger_asks"}} +} + +func channelSeat(kind string) Seat { + return Seat{Name: "channel", Scope: "mesh", Accepts: []string{"show", "edit", "send"}, Kinded: true, Kind: kind} +} + +func intakeSeat(kind string) Seat { + return Seat{Name: "intake", Scope: "mesh", Emits: []string{"choice", "link"}, Proofs: []string{"code"}, + Kinded: true, Kind: kind} +} + +func allowed(patterns []string, subject string) bool { + for _, p := range patterns { + if subjectMatches(p, subject) { + return true + } + } + return false +} + +func perms(t *testing.T, p Principal) Permissions { + t.Helper() + got, err := PermissionsFor(p) + if err != nil { + t.Fatal(err) + } + return got +} + +func TestAnAskerAsksAndHearsUnderItsOwnNameOnly(t *testing.T) { + asker := Principal{Kind: KindModule, Node: "anchor", Module: "mesh-delivery", Uses: []Seat{operatorChannel()}} + got := perms(t, asker) + for _, s := range []string{ + "mesh.seat.operator-channel.accept.ask.mesh-delivery", + "mesh.seat.operator-channel.accept.cancel.mesh-delivery", + "$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.mesh-delivery.a1", + } { + if !allowed(got.Publish, s) { + t.Errorf("an asker may not publish %s", s) + } + } + for _, s := range []string{ + "mesh.seat.operator-channel.accept.ask.mesh-controller", + "mesh.seat.operator-channel.accept.ask.*", + "mesh.seat.operator-channel.event.decided.mesh-delivery", + "$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.mesh-controller.a1", + "$KV.messenger_asks.mesh-delivery.a1", + } { + if allowed(got.Publish, s) { + t.Errorf("an asker may publish %s, which is not its own to submit", s) + } + } + if !allowed(got.Subscribe, "mesh.seat.operator-channel.event.decided.mesh-delivery") { + t.Error("an asker does not hear its own warrants") + } + if allowed(got.Subscribe, "mesh.seat.operator-channel.event.decided.mesh-controller") { + t.Error("an asker hears another asker's warrants") + } + // And its own consumer carries its warrants, so a restart catches up. + c, ok := ConsumerFor(asker) + if !ok || !allowed(c.Filters, "mesh.seat.operator-channel.event.decided.mesh-delivery") { + t.Errorf("the asker's consumer does not carry its warrants: %v", c.Filters) + } +} + +func TestOnlyTheHolderPublishesAWarrant(t *testing.T) { + users, err := Users(Records{ + Nodes: []string{"anchor"}, + Assigned: map[string][]Declared{"anchor": { + {Module: "messenger", Holds: []Seat{operatorChannel()}, Uses: []Seat{channelSeat("")}, + Watches: []Seat{{Name: "intake", Emits: []string{"choice", "link"}, Kinded: true, Proofs: []string{"code"}}}}, + {Module: "mesh-delivery", Uses: []Seat{operatorChannel()}}, + {Module: "telegram", Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}}, + }}, + }) + if err != nil { + t.Fatal(err) + } + for _, u := range users { + got := perms(t, u) + says := allowed(got.Publish, "mesh.seat.operator-channel.event.decided.mesh-delivery") + if says != (u.Module == "messenger") { + t.Errorf("%s %s publish a warrant", u.Username(), map[bool]string{true: "may", false: "may not"}[says]) + } + } +} + +func TestTheHolderTakesEveryCallersAskThroughItsWorker(t *testing.T) { + got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "messenger", Holds: []Seat{operatorChannel()}}) + if !allowed(got.Subscribe, "mesh.seat.operator-channel.accept.ask.mesh-delivery") { + t.Error("the router does not take an ask") + } + for _, s := range []string{ + "$JS.API.CONSUMER.MSG.NEXT.SEAT_OPERATOR_CHANNEL.SEAT_OPERATOR_CHANNEL_worker", + "$JS.ACK.SEAT_OPERATOR_CHANNEL.SEAT_OPERATOR_CHANNEL_worker.x", + "mesh.seat.operator-channel.event.decided.mesh-controller", + } { + if !allowed(got.Publish, s) { + t.Errorf("the router may not publish %s", s) + } + } + if allowed(got.Publish, "mesh.seat.operator-channel.accept.ask.messenger") { + t.Error("the holder may ask its own seat without using it") + } +} + +func TestAKindedHolderReachesItsOwnKindAndNoOther(t *testing.T) { + got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "telegram", + Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}}) + for _, s := range []string{ + "mesh.seat.intake.event.choice.telegram", "mesh.seat.intake.event.link.telegram", + "mesh.seat.intake.proof.code.telegram", + "$JS.API.CONSUMER.MSG.NEXT.SEAT_CHANNEL.SEAT_CHANNEL_TELEGRAM_worker", + } { + if !allowed(got.Publish, s) { + t.Errorf("telegram may not publish %s", s) + } + } + for _, s := range []string{ + "mesh.seat.intake.event.choice.desktop", "mesh.seat.intake.proof.code.desktop", + "mesh.seat.channel.accept.show.telegram", + "$JS.API.CONSUMER.MSG.NEXT.SEAT_CHANNEL.SEAT_CHANNEL_DESKTOP_worker", + "mesh.seat.operator-channel.event.decided.mesh-delivery", + } { + if allowed(got.Publish, s) { + t.Errorf("telegram may publish %s", s) + } + } + if !allowed(got.Subscribe, "mesh.seat.channel.accept.show.telegram") || + allowed(got.Subscribe, "mesh.seat.channel.accept.show.desktop") { + t.Error("telegram does not take exactly its own kind's work") + } + if allowed(got.Subscribe, "mesh.seat.intake.proof.code.telegram") { + t.Error("a channel answers its own proofs") + } +} + +func TestTheWatcherAnswersProofsAndHearsEveryKind(t *testing.T) { + got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "messenger", + Uses: []Seat{channelSeat("")}, + Watches: []Seat{{Name: "intake", Emits: []string{"choice"}, Kinded: true, Proofs: []string{"code"}}}}) + for _, s := range []string{"mesh.seat.intake.event.choice.telegram", "mesh.seat.intake.proof.code.desktop"} { + if !allowed(got.Subscribe, s) { + t.Errorf("the router does not hear %s", s) + } + } + if !allowed(got.Publish, "mesh.seat.channel.accept.show.telegram") { + t.Error("the router cannot send a channel its work") + } + if allowed(got.Publish, "mesh.seat.intake.event.choice.telegram") || allowed(got.Publish, "mesh.seat.intake.proof.code.telegram") { + t.Error("the router may say a channel's answer or proof") + } +} + +func TestNoStreamKeepsAProof(t *testing.T) { + users, _ := Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": { + {Module: "telegram", Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}}, + {Module: "messenger", Holds: []Seat{operatorChannel()}}, + }}}) + streams, _ := SeatTrafficObjects(users) + streams = append(streams, MeshStreams()...) + for _, s := range streams { + for _, subject := range s.Subjects { + if subjectMatches(subject, "mesh.seat.intake.proof.code.telegram") { + t.Errorf("%s keeps a proof (%s)", s.Name, subject) + } + } + } +} + +func TestEachKindHasAWorkerOfItsOwn(t *testing.T) { + users, _ := Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": { + {Module: "telegram", Holds: []Seat{channelSeat("telegram")}}, + {Module: "desk-channel", Holds: []Seat{channelSeat("desktop")}}, + {Module: "messenger", Holds: []Seat{operatorChannel()}}, + }}}) + streams, workers := SeatTrafficObjects(users) + names := map[string]string{} + for _, w := range workers { + names[w.Name] = strings.Join(w.Filters, ",") + } + want := map[string]string{ + "SEAT_CHANNEL_TELEGRAM_worker": "mesh.seat.channel.accept.*.telegram", + "SEAT_CHANNEL_DESKTOP_worker": "mesh.seat.channel.accept.*.desktop", + "SEAT_OPERATOR_CHANNEL_worker": "mesh.seat.operator-channel.accept.>", + } + for n, f := range want { + if names[n] != f { + t.Errorf("worker %s filters %q, want %q", n, names[n], f) + } + } + if len(streams) != 2 { + t.Errorf("want the queues of channel and operator-channel, got %v", streams) + } +} + +func TestTheRuntimeIsGrantedTheUnionAndTheMembershipEachModulesShare(t *testing.T) { + telegram := Declared{Module: "telegram", Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}} + desk := Declared{Module: "desk-channel", Holds: []Seat{channelSeat("desktop"), intakeSeat("desktop")}} + got := perms(t, Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, Carries: []Declared{telegram, desk}}) + for _, s := range []string{"mesh.seat.intake.event.choice.telegram", "mesh.seat.intake.event.choice.desktop"} { + if !allowed(got.Publish, s) { + t.Errorf("the runtime may not publish %s for a module it carries", s) + } + } + m := MembershipFor("anchor", telegram, Placements{}) + if m.SeatTraffic == nil || !allowed(m.SeatTraffic.Publish, "mesh.seat.intake.event.choice.telegram") || + allowed(m.SeatTraffic.Publish, "mesh.seat.intake.event.choice.desktop") { + t.Errorf("telegram's membership does not list exactly its own kind: %+v", m.SeatTraffic) + } + if plain := MembershipFor("anchor", Declared{Module: "plain"}, Placements{}); plain.SeatTraffic != nil { + t.Error("a module with no such seat is given seat traffic") + } +} + +func TestASeatWithoutTheNewRulesIsComposedAsBefore(t *testing.T) { + old := Seat{Name: "node-build-agent", Scope: "node", Accepts: []string{"build"}, Emits: []string{"built"}} + got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "builder", Holds: []Seat{old}}) + for _, s := range []string{"mesh.seat.node-build-agent.event.built", + "$JS.API.CONSUMER.MSG.NEXT.SEAT_NODE_BUILD_AGENT.SEAT_NODE_BUILD_AGENT_worker"} { + if !allowed(got.Publish, s) { + t.Errorf("an old seat's holder lost %s", s) + } + } + if !allowed(got.Subscribe, "mesh.seat.node-build-agent.accept.build") { + t.Error("an old seat's holder lost its accept") + } +} diff --git a/internal/catalogue/kinded_test.go b/internal/catalogue/kinded_test.go new file mode 100644 index 00000000..4e752214 --- /dev/null +++ b/internal/catalogue/kinded_test.go @@ -0,0 +1,95 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The router of novox/hq ADR 0259: it declares the operator's seat and the two kinded benches. +func router() Manifest { + return Manifest{Module: "messenger", Tools: []string{"open", "history", "notify"}, + State: []StateDeclaration{{Name: "asks"}}, + DefinesSeats: []SeatDeclaration{ + {Name: "operator-channel", Scope: ScopeMesh, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"}, + ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"asks"}, + Serves: []Verb{{Name: "open"}, {Name: "history"}, {Name: "notify"}}}, + {Name: "channel", Scope: ScopeMesh, Kinded: true, Accepts: []string{"show", "edit", "send"}}, + {Name: "intake", Scope: ScopeMesh, Kinded: true, Emits: []string{"choice", "link"}, Proofs: []string{"code"}}, + }, + Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh}}, + Uses: []string{"channel"}} +} + +func aChannel(module, kind string) Manifest { + return Manifest{Module: module, Claims: []Claim{ + {Name: "channel", Scope: ScopeMesh, Kind: kind}, {Name: "intake", Scope: ScopeMesh, Kind: kind}}} +} + +func TestTwoChannelsOfDifferentKindsHoldTheBenches(t *testing.T) { + shelf := Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"), + "desk-channel": aChannel("desk-channel", "desktop")} + if got := problemsFor(t, shelf); got != "" { + t.Fatalf("two kinds were refused: %s", got) + } + for _, m := range shelf { + if got := declaredSeatProblems(m); len(got) > 0 { + t.Fatalf("%s: %v", m.Module, got) + } + } +} + +func TestASecondClaimOfOneKindIsRefused(t *testing.T) { + got := problemsFor(t, Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"), + "telegram-two": aChannel("telegram-two", "telegram")}) + if !strings.Contains(got, `of kind "telegram", which telegram already claims`) { + t.Fatalf("a second holder of one kind stood: %s", got) + } +} + +func TestAKindedBenchNeedsAKindAndNoOtherSeatTakesOne(t *testing.T) { + got := problemsFor(t, Shelf{"messenger": router(), "nameless": aChannel("nameless", "")}) + if !strings.Contains(got, "claims the kinded bench channel and names no kind") { + t.Fatalf("a claim without a kind stood: %s", got) + } + odd := Manifest{Module: "odd", Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh, Kind: "telegram"}}} + got = problemsFor(t, Shelf{"messenger": router(), "odd": odd}) + if !strings.Contains(got, "only a kinded bench takes a kind") { + t.Fatalf("a kind on a seat that is not kinded stood: %s", got) + } + dotted := problemsFor(t, Shelf{"messenger": router(), "dotted": aChannel("dotted", "a.b")}) + if !strings.Contains(dotted, "not a usable name") { + t.Fatalf("a kind that would widen a subject stood: %s", dotted) + } +} + +func TestOnlyChannelAndIntakeAreKinded(t *testing.T) { + m := Manifest{Module: "x", DefinesSeats: []SeatDeclaration{{Name: "pager", Kinded: true, Accepts: []string{"page"}}}} + if got := strings.Join(declaredSeatProblems(m), "; "); !strings.Contains(got, "only channel and intake are kinded") { + t.Fatalf("another kinded bench was declared: %s", got) + } +} + +func TestTheNewRulesAreHeldToWhatTheSeatSays(t *testing.T) { + m := Manifest{Module: "x", DefinesSeats: []SeatDeclaration{{Name: "thing", Accepts: []string{"do"}, + ByCaller: []string{"undo"}, Proofs: []string{"code"}, Records: []string{"nothing"}}}} + got := strings.Join(declaredSeatProblems(m), "; ") + for _, want := range []string{"names thing.undo by its caller, which the seat neither accepts nor emits", + "declares proofs on thing, which is not kinded", `read its records "nothing", which it keeps no state of`} { + if !strings.Contains(got, want) { + t.Errorf("not refused: %q in %s", want, got) + } + } +} + +// Two kinds on one machine are two holders, and one kind on two machines is a second claimant. +func TestEachKindIsItsOwnHolderWhenResolved(t *testing.T) { + modules := []Manifest{aChannel("telegram", "telegram"), aChannel("desk-channel", "desktop")} + held, problems := checkClaims(modules, Node{Name: "anchor"}, nil, nil) + if len(problems) > 0 || len(held) != 4 { + t.Fatalf("two kinds on one machine: held %v, problems %v", held, problems) + } + _, problems = checkClaims([]Manifest{aChannel("telegram", "telegram")}, Node{Name: "home"}, held, nil) + if len(problems) == 0 { + t.Fatal("one kind held on two machines was not refused") + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 8dcd7a51..6f16db81 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -64,6 +64,9 @@ type Claim struct { // text/template over one piece — its fields and `module` — in the tool's own grammar (novox/hq ADR // 0255). The data is the mesh's, the format the holder's, as a module's facts template is. Renders map[string]string `json:"renders,omitempty"` + // Kind is the kind this module holds a kinded bench as (novox/hq ADR 0234 §2, ADR 0259): `telegram`, + // `desktop`. Refused on any other seat, and a second claim of one kind is refused. + Kind string `json:"kind,omitempty"` } // ServesFor is what this claim offers a seat's protocol: the verbs it names, else the module's diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index de3b5305..6ed02150 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -120,6 +120,16 @@ type Held struct { Node string Module string Site string + // Kind is the kind a kinded bench is held as (novox/hq ADR 0234 §2): each kind is its own holder. + Kind string +} + +// heldKey is what one holder holds: the seat, and its kind on a kinded bench. +func heldKey(claim, kind string) string { + if kind == "" { + return canonicalSeat(claim) + } + return canonicalSeat(claim) + "/" + kind } // Resolution is what a node should run, and why. @@ -874,7 +884,7 @@ func checkClaims(modules []Manifest, node Node, elsewhere []Held, holdings []Hel // **One seat under either of its names** (novox/hq ADR 0122): a manifest registered before // a rename claims the former name, and one written after it the current — two claimants of // one seat, compared by the seat they resolve to and not by how each spelled it. - seat := canonicalSeat(c.Name) + seat := heldKey(c.Name, c.Kind) if other, taken := byScope[scope][seat]; taken { problems = append(problems, fmt.Sprintf( "%s and %s both claim %q, and only one thing may hold it per %s", @@ -883,14 +893,14 @@ func checkClaims(modules []Manifest, node Node, elsewhere []Held, holdings []Hel } byScope[scope][seat] = m.Module held = append(held, Held{Claim: c.Name, Scope: scope, Node: node.Name, - Module: m.Module, Site: node.Site}) + Module: m.Module, Site: node.Site, Kind: c.Kind}) } } // And against the rest of the mesh, for the scopes that reach past this machine. for _, h := range held { for _, e := range elsewhere { - if e.Node == node.Name || canonicalSeat(e.Claim) != canonicalSeat(h.Claim) || e.Scope != h.Scope { + if e.Node == node.Name || heldKey(e.Claim, e.Kind) != heldKey(h.Claim, h.Kind) || e.Scope != h.Scope { continue } switch h.Scope { diff --git a/internal/catalogue/seats_declared.go b/internal/catalogue/seats_declared.go index 5fb89bdf..b97fc1f1 100644 --- a/internal/catalogue/seats_declared.go +++ b/internal/catalogue/seats_declared.go @@ -51,6 +51,35 @@ type SeatDeclaration struct { // owns its own, which is why a seat is also the answer for a module that needs retention // its events cannot have. RetainSeconds int `json:"retain-seconds,omitempty"` + + // Kinded makes the seat a kinded bench (novox/hq ADR 0234 §2, ADR 0259 §3): its holders are different + // modules, each claiming one kind, and each verb's subject carries the kind. Only the benches in + // KindedBenches may be kinded; making another is a decision, recorded. + Kinded bool `json:"kinded,omitempty"` + // ByCaller are accepts and emits whose subject's last token names the calling module (ADR 0259 §3): a + // user submits such an accept, and hears such an event, under its own name and no other. + ByCaller []string `json:"by-caller,omitempty"` + // Proofs are verbs carried as core request and reply, never on a stream: what travels on them (a code + // the operator typed) is never kept (ADR 0259 §3). On a kinded bench a holder asks with its own kind and + // the modules watching the seat answer. + Proofs []string `json:"proofs,omitempty"` + // Records are state buckets of the declaring module that each user reads under its own name — the + // keys `.…` and no other (ADR 0259 §3). + Records []string `json:"records,omitempty"` +} + +// KindedBenches are the seats that may be kinded (novox/hq ADR 0234 §2): `channel` sends to the operator, +// `intake` takes what the operator answers. Another is a decision, recorded, as ADR 0223 asks of a bench. +var KindedBenches = map[string]bool{"channel": true, "intake": true} + +// NamedByCaller says whether one of the seat's verbs is named by its caller. +func (s SeatDeclaration) NamedByCaller(verb string) bool { + for _, v := range s.ByCaller { + if v == verb { + return true + } + } + return false } // At is this declaration's scope, with the default applied. Mesh by default, because a seat @@ -132,6 +161,7 @@ func declaredSeatProblems(m Manifest) []string { "%s declares %s.%s, which is not a usable verb", m.Module, s.Name, v)) } } + problems = append(problems, trafficProblems(m, s)...) } for _, u := range m.Uses { @@ -142,6 +172,56 @@ func declaredSeatProblems(m Manifest) []string { return problems } +// trafficProblems is what one declaration of the rules of ADR 0259 §3 can be judged on alone. +func trafficProblems(m Manifest, s SeatDeclaration) []string { + var problems []string + if s.Kinded && !KindedBenches[s.Name] { + problems = append(problems, fmt.Sprintf( + "%s declares %s as a kinded bench; only channel and intake are kinded, and another is a "+ + "decision, recorded (novox/hq ADR 0234)", m.Module, s.Name)) + } + if s.Kinded && len(s.ByCaller) > 0 { + problems = append(problems, fmt.Sprintf( + "%s declares %s kinded and names verbs by their caller; a kinded bench's subjects carry the kind", + m.Module, s.Name)) + } + for _, v := range s.ByCaller { + inAccepts, inEmits := false, false + for _, a := range s.Accepts { + inAccepts = inAccepts || a == v + } + for _, e := range s.Emits { + inEmits = inEmits || e == v + } + if !inAccepts && !inEmits { + problems = append(problems, fmt.Sprintf( + "%s names %s.%s by its caller, which the seat neither accepts nor emits", m.Module, s.Name, v)) + } + } + for _, v := range s.Proofs { + if !name.MatchString(v) || strings.Contains(v, ".") { + problems = append(problems, fmt.Sprintf("%s declares the proof %s.%s, which is not a usable verb", + m.Module, s.Name, v)) + } + } + if len(s.Proofs) > 0 && !s.Kinded { + problems = append(problems, fmt.Sprintf( + "%s declares proofs on %s, which is not kinded; a proof is asked by a holder of a kind", + m.Module, s.Name)) + } + for _, r := range s.Records { + kept := false + for _, st := range m.State { + kept = kept || st.Name == r + } + if !kept { + problems = append(problems, fmt.Sprintf( + "%s says %s's users read its records %q, which it keeps no state of", m.Module, s.Name, r)) + } + } + return problems +} + // A Shelf is every manifest the mesh has registered, by module name. type Shelf map[string]Manifest @@ -182,8 +262,19 @@ func CatalogueProblems(shelf Shelf) []string { return ok } + // Who claims each kind of a kinded bench, so a second claim of one kind is refused (ADR 0234 §2). + kindsTaken := map[string]string{} + for _, module := range shelfOrder(shelf) { m := shelf[module] + for _, c := range m.Claims { + if c.Kind != "" { + if _, isModuleSeat := declared[c.Name]; !isModuleSeat { + problems = append(problems, fmt.Sprintf( + "%s claims %s of kind %q, and only a kinded bench takes a kind", module, c.Name, c.Kind)) + } + } + } // A `uses` naming nothing is where ADR 0110's guarantee lands under a derived set: the // same refusal, at the same moment, from a set nobody maintains by hand. @@ -214,6 +305,7 @@ func CatalogueProblems(shelf Shelf) []string { } continue } + problems = append(problems, kindProblems(module, c, s, kindsTaken)...) if c.At() != s.At() { problems = append(problems, fmt.Sprintf( "%s claims %s at scope %q, and %s declares it at %s", @@ -239,6 +331,29 @@ func CatalogueProblems(shelf Shelf) []string { return problems } +// kindProblems is a claim judged against a declared seat's kind: a kinded bench takes one claim per kind, +// a usable name; any other seat takes none. +func kindProblems(module string, c Claim, s SeatDeclaration, taken map[string]string) []string { + switch { + case !s.Kinded && c.Kind != "": + return []string{fmt.Sprintf("%s claims %s of kind %q, and only a kinded bench takes a kind", + module, c.Name, c.Kind)} + case !s.Kinded: + return nil + case c.Kind == "": + return []string{fmt.Sprintf("%s claims the kinded bench %s and names no kind", module, c.Name)} + case !name.MatchString(c.Kind) || strings.Contains(c.Kind, "."): + return []string{fmt.Sprintf("%s claims %s of kind %q, which is not a usable name", module, c.Name, c.Kind)} + } + key := c.Name + "/" + c.Kind + if first, ok := taken[key]; ok && first != module { + return []string{fmt.Sprintf("%s claims %s of kind %q, which %s already claims; a kind has one holder", + module, c.Name, c.Kind, first)} + } + taken[key] = module + return nil +} + // unserved is what a seat's protocol promises and the claimant does not answer. Only the tools // are checked: `accepts` and `emits` are wired by the runtime from the declaration, while a tool // is code the module either has or has not written — under the claim's serves, or among its own. diff --git a/internal/inventory/busrecords.go b/internal/inventory/busrecords.go index 60935291..3fd78d65 100644 --- a/internal/inventory/busrecords.go +++ b/internal/inventory/busrecords.go @@ -34,9 +34,12 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) { // that seat promises. **Across the whole catalogue, not one manifest**: a seat is declared by // one module and held by another, which is the whole reason a seat exists (ADR 0118). seats := map[string]catalogue.SeatDeclaration{} + // And who declared each, so a seat's records are named as the declaring module's buckets (ADR 0259). + declarers := map[string]string{} for _, m := range declared { for _, s := range m.DefinesSeats { seats[s.Name] = s + declarers[s.Name] = m.Module } } // And the mesh's own, which carry protocol too (novox/hq ADR 0121). Added after the modules' @@ -78,7 +81,7 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) { "%s is assigned to %s and is not in the catalogue, so what it may say cannot "+ "be derived", module, n.Name) } - d := declaredFor(m, seats) + d := declaredFor(m, seats, declarers) // And the state offered to it as a seat's holder by the modules beside it (novox/hq ADR 0255). // For this machine's key alone (novox/hq ADR 0260): a bar shows its own machine's draw. for _, sr := range catalogue.ReadsGranted(m, onMachine(declared, modules), n.Name) { @@ -121,7 +124,7 @@ func onMachine(declared map[string]catalogue.Manifest, modules []string) []catal // declaredFor is one module's manifest as the composer needs it: what it says about itself, and the // protocol of every seat it holds or uses. -func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaration) broker.Declared { +func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaration, declarers map[string]string) broker.Declared { // A consumed name is a module's event unless it names a seat, and only somebody holding the seat // set can tell (novox/hq ADR 0121). Split here, because the composer cannot look at a name and // know — and a role's event read as a module's is a subscription to a namespace nobody owns. @@ -132,6 +135,16 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio if named { // A seat's event when the seat says it; else the event of the module of that name — a seat and // the module holding it may share a name (mesh-delivery, novox/hq ADR 0239). + if s, isASeat := seats[emitter]; isASeat && s.Kinded { + // A kinded bench's event is named `` or `.*` and heard from every kind; its + // proofs are answered by whoever watches it (ADR 0259 §3). + ev := strings.TrimSuffix(event, ".*") + if catalogue.SeatSays(s.Emits, ev) { + watches = append(watches, broker.Seat{Name: s.Name, Scope: s.Scope, Emits: []string{ev}, + Kinded: true, Proofs: s.Proofs}) + continue + } + } if s, isASeat := seats[emitter]; isASeat && catalogue.SeatSays(s.Emits, event) { watches = append(watches, broker.Seat{Name: s.Name, Emits: []string{event}}) continue @@ -168,12 +181,14 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio // Every seat with a protocol, the mesh's own included. One that says only who does a job is // not here and grants nothing, which is most of them. if s, hasAProtocol := seats[c.Name]; hasAProtocol { - d.Holds = append(d.Holds, asSeat(s)) + held := asSeat(s, declarers[s.Name]) + held.Kind = c.Kind + d.Holds = append(d.Holds, held) } } for _, name := range m.Uses { if s, declaredSomewhere := seats[name]; declaredSomewhere { - d.Uses = append(d.Uses, asSeat(s)) + d.Uses = append(d.Uses, asSeat(s, declarers[s.Name])) } } return d @@ -204,9 +219,16 @@ func (i *Inventory) DeclaredBuckets(ctx context.Context) ([]broker.Bucket, error return out, nil } -func asSeat(s catalogue.SeatDeclaration) broker.Seat { - return broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits, - Serves: catalogue.VerbNames(s.Serves)} +func asSeat(s catalogue.SeatDeclaration, declarer string) broker.Seat { + seat := broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits, + Serves: catalogue.VerbNames(s.Serves), Kinded: s.Kinded, ByCaller: s.ByCaller, Proofs: s.Proofs} + // A seat's records are its declaring module's buckets, named as the bus holds them (ADR 0259 §3). + for _, r := range s.Records { + if declarer != "" { + seat.Records = append(seat.Records, broker.BucketName(declarer, r)) + } + } + return seat } // MeshSeats are the mesh's own seats that carry a protocol, as the bus needs them: what to make a work From c9be75b13ee56b12d561d74d8f14c5541e61ad8b Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 16:37:12 +0200 Subject: [PATCH 02/11] Carry a channel's capabilities on its claim and tell the router every kind, so an answer is judged by the controller's record and not the channel's word --- internal/broker/membership.go | 45 ++++++++++++++++++++++++++-- internal/broker/nats.go | 2 ++ internal/broker/seattraffic.go | 13 ++++++++ internal/broker/seattraffic_test.go | 32 ++++++++++++++++++++ internal/catalogue/kinded_test.go | 21 +++++++++++++ internal/catalogue/manifest.go | 4 +++ internal/catalogue/seats_declared.go | 35 ++++++++++++++++++++++ internal/inventory/busrecords.go | 1 + 8 files changed, 151 insertions(+), 2 deletions(-) diff --git a/internal/broker/membership.go b/internal/broker/membership.go index 2929378c..1909e99e 100644 --- a/internal/broker/membership.go +++ b/internal/broker/membership.go @@ -84,6 +84,8 @@ type Placements struct { // Interchangeable is each module whose definition says its instances are the same anywhere, // so the module's plain subject is issued to all of them in one queue. Interchangeable map[string]bool + // Kinds is every kind held of a kinded bench, by whom and with what capabilities (ADR 0259 §5). + Kinds []KindHeld } // AnswersForTheModule says whether an instance of a module on one machine is issued the module's @@ -115,8 +117,18 @@ func MembershipFor(node string, d Declared, where Placements) Membership { } } m.State = stateIssuedFor(d, node) - if t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches); len(t.Publish)+len(t.Subscribe)+ - len(t.Answers)+len(t.Workers)+len(t.Records) > 0 { + t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches) + for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) { + if !s.Kinded { + continue + } + for _, k := range where.Kinds { + if k.Seat == s.Name && !kindListed(t.Kinds, k) { + t.Kinds = append(t.Kinds, k) + } + } + } + if len(t.Publish)+len(t.Subscribe)+len(t.Answers)+len(t.Workers)+len(t.Records)+len(t.Kinds) > 0 { m.SeatTraffic = &t } if len(d.Invokes) > 0 { @@ -155,5 +167,34 @@ func PlacementsOf(r Records, interchangeable map[string]bool) Placements { for _, nodes := range p.Nodes { sort.Strings(nodes) } + for node, declared := range r.Assigned { + for _, d := range declared { + for _, s := range d.Holds { + if s.Kinded && s.Kind != "" { + p.Kinds = append(p.Kinds, KindHeld{Seat: s.Name, Kind: s.Kind, Module: d.Module, Node: node, + Capabilities: s.Capabilities}) + } + } + } + } + sort.Slice(p.Kinds, func(i, j int) bool { + a, b := p.Kinds[i], p.Kinds[j] + if a.Seat != b.Seat { + return a.Seat < b.Seat + } + if a.Kind != b.Kind { + return a.Kind < b.Kind + } + return a.Node < b.Node + }) return p } + +func kindListed(list []KindHeld, k KindHeld) bool { + for _, x := range list { + if x.Seat == k.Seat && x.Kind == k.Kind && x.Module == k.Module && x.Node == k.Node { + return true + } + } + return false +} diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 9372e0a4..d0481852 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -75,6 +75,8 @@ type Seat struct { Proofs []string // Records are the holder's buckets, by their full name, each user reads under its own name. Records []string + // Capabilities are what this principal's claim of a kinded bench promises (ADR 0234 §2). + Capabilities []string } // A Principal is one user of the bus. Its permissions are derived from what it declares and diff --git a/internal/broker/seattraffic.go b/internal/broker/seattraffic.go index 1cb4c1ee..d2ebee8c 100644 --- a/internal/broker/seattraffic.go +++ b/internal/broker/seattraffic.go @@ -47,6 +47,19 @@ type SeatTraffic struct { Workers []Worker `json:"workers,omitempty"` // Records is the direct-get subjects of the records it reads under its own name. Records []string `json:"records,omitempty"` + // Kinds are the holders of every kinded bench it uses or watches, with what each promises: the one + // account of which channel is which, and what it can carry, that the router judges an answer by. The + // controller's, from the claims, never a channel's word (novox/hq ADR 0259 §5). + Kinds []KindHeld `json:"kinds,omitempty"` +} + +// KindHeld is one kind of a kinded bench and who holds it. +type KindHeld struct { + Seat string `json:"seat"` + Kind string `json:"kind"` + Module string `json:"module"` + Node string `json:"node"` + Capabilities []string `json:"capabilities,omitempty"` } // KindedBenches are the seats that may be kinded (ADR 0234 §2): making another is a decision, recorded. diff --git a/internal/broker/seattraffic_test.go b/internal/broker/seattraffic_test.go index 3df3c708..2f8b7bbd 100644 --- a/internal/broker/seattraffic_test.go +++ b/internal/broker/seattraffic_test.go @@ -238,3 +238,35 @@ func TestASeatWithoutTheNewRulesIsComposedAsBefore(t *testing.T) { t.Error("an old seat's holder lost its accept") } } + +// The router learns which channel is which, and what each promises, from the controller's membership: +// the claims, never a channel's word (ADR 0259 §5). +func TestTheRoutersMembershipNamesEveryKindAndItsCapabilities(t *testing.T) { + tg := channelSeat("telegram") + tg.Capabilities = []string{"choice", "verified-sender"} + desk := channelSeat("desktop") + desk.Capabilities = []string{"choice"} + router := Declared{Module: "messenger", Holds: []Seat{operatorChannel()}, Uses: []Seat{channelSeat("")}} + records := Records{Nodes: []string{"anchor", "laptop"}, Assigned: map[string][]Declared{ + "anchor": {router, {Module: "telegram", Holds: []Seat{tg}}}, + "laptop": {{Module: "desk-channel", Holds: []Seat{desk}}}, + }} + where := PlacementsOf(records, nil) + m := MembershipFor("anchor", router, where) + if m.SeatTraffic == nil || len(m.SeatTraffic.Kinds) != 2 { + t.Fatalf("the router is not told the kinds: %+v", m.SeatTraffic) + } + byKind := map[string]KindHeld{} + for _, k := range m.SeatTraffic.Kinds { + byKind[k.Kind] = k + } + if k := byKind["telegram"]; k.Module != "telegram" || k.Node != "anchor" || !namesVerb(k.Capabilities, "verified-sender") { + t.Errorf("telegram is %+v", k) + } + if k := byKind["desktop"]; k.Module != "desk-channel" || namesVerb(k.Capabilities, "verified-sender") { + t.Errorf("the desk is %+v", k) + } + if other := MembershipFor("anchor", Declared{Module: "mesh-delivery", Uses: []Seat{operatorChannel()}}, where); other.SeatTraffic != nil && len(other.SeatTraffic.Kinds) > 0 { + t.Error("an asker is told the channels") + } +} diff --git a/internal/catalogue/kinded_test.go b/internal/catalogue/kinded_test.go index 4e752214..c1fd1aa3 100644 --- a/internal/catalogue/kinded_test.go +++ b/internal/catalogue/kinded_test.go @@ -93,3 +93,24 @@ func TestEachKindIsItsOwnHolderWhenResolved(t *testing.T) { t.Fatal("one kind held on two machines was not refused") } } + +// A channel's capabilities come from the fixed vocabulary, and only a kinded claim carries any. +func TestCapabilitiesAreTheVocabularysAndOnlyOnAKindedClaim(t *testing.T) { + good := aChannel("telegram", "telegram") + good.Claims[0].Capabilities = []string{"deliver", "choice", "verified-sender", "max-length:4096"} + if got := problemsFor(t, Shelf{"messenger": router(), "telegram": good}); got != "" { + t.Fatalf("the vocabulary was refused: %s", got) + } + bad := aChannel("telegram", "telegram") + bad.Claims[0].Capabilities = []string{"trusted", "max-length:lots"} + got := problemsFor(t, Shelf{"messenger": router(), "telegram": bad}) + for _, w := range []string{`"trusted"`, `"max-length:lots"`} { + if !strings.Contains(got, w+", which channel-capabilities/1 does not have") { + t.Errorf("%s was not refused: %s", w, got) + } + } + odd := Manifest{Module: "odd", Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh, Capabilities: []string{"deliver"}}}} + if got := problemsFor(t, Shelf{"messenger": router(), "odd": odd}); !strings.Contains(got, "only a kinded bench's claim carries them") { + t.Errorf("capabilities on a seat that is not kinded stood: %s", got) + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 6f16db81..bbae198c 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -67,6 +67,10 @@ type Claim struct { // Kind is the kind this module holds a kinded bench as (novox/hq ADR 0234 §2, ADR 0259): `telegram`, // `desktop`. Refused on any other seat, and a second claim of one kind is refused. Kind string `json:"kind,omitempty"` + // Capabilities are what a channel of this kind promises, from the fixed vocabulary + // channel-capabilities/1 (novox/hq ADR 0234 §2): the router judges an answer by these, read from the + // controller's record of this claim and never from the channel. + Capabilities []string `json:"capabilities,omitempty"` } // ServesFor is what this claim offers a seat's protocol: the verbs it names, else the module's diff --git a/internal/catalogue/seats_declared.go b/internal/catalogue/seats_declared.go index b97fc1f1..fd4b0899 100644 --- a/internal/catalogue/seats_declared.go +++ b/internal/catalogue/seats_declared.go @@ -2,6 +2,7 @@ package catalogue import ( "fmt" + "regexp" "sort" "strings" ) @@ -331,9 +332,43 @@ func CatalogueProblems(shelf Shelf) []string { return problems } +// ChannelCapabilities is the fixed vocabulary `channel-capabilities/1` (novox/hq ADR 0234 §2): a word +// outside it is refused. `max-length:` takes a number. +var ChannelCapabilities = map[string]bool{ + "deliver": true, "reaches-away": true, "loud": true, "silent": true, "edit": true, + "reaches-when-mesh-down": true, "private": true, + "choice": true, "reply": true, "threads": true, "operator-first": true, + "verified-sender": true, "exact-render": true, "code-factor": true, "key-factor": true, +} + +var maxLength = regexp.MustCompile(`^max-length:[1-9][0-9]{0,6}$`) + +// capabilityProblems are the words of a claim outside the vocabulary, and capabilities on a claim of a +// seat that is not kinded. +func capabilityProblems(module string, c Claim, kinded bool) []string { + if len(c.Capabilities) == 0 { + return nil + } + if !kinded { + return []string{fmt.Sprintf("%s claims %s with capabilities, and only a kinded bench's claim carries them", + module, c.Name)} + } + var problems []string + for _, w := range c.Capabilities { + if !ChannelCapabilities[w] && !maxLength.MatchString(w) { + problems = append(problems, fmt.Sprintf( + "%s claims %s with the capability %q, which channel-capabilities/1 does not have", module, c.Name, w)) + } + } + return problems +} + // kindProblems is a claim judged against a declared seat's kind: a kinded bench takes one claim per kind, // a usable name; any other seat takes none. func kindProblems(module string, c Claim, s SeatDeclaration, taken map[string]string) []string { + if problems := capabilityProblems(module, c, s.Kinded); len(problems) > 0 { + return problems + } switch { case !s.Kinded && c.Kind != "": return []string{fmt.Sprintf("%s claims %s of kind %q, and only a kinded bench takes a kind", diff --git a/internal/inventory/busrecords.go b/internal/inventory/busrecords.go index 3fd78d65..4af67be5 100644 --- a/internal/inventory/busrecords.go +++ b/internal/inventory/busrecords.go @@ -183,6 +183,7 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio if s, hasAProtocol := seats[c.Name]; hasAProtocol { held := asSeat(s, declarers[s.Name]) held.Kind = c.Kind + held.Capabilities = c.Capabilities d.Holds = append(d.Holds, held) } } From 9372e80cec4712a499d099eb5292b96f48485151 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 18:31:31 +0200 Subject: [PATCH 03/11] =?UTF-8?q?Serve=20a=20trusted=20holder=20from=20a?= =?UTF-8?q?=20runtime=20of=20its=20own=20account,=20refuse=20it=20in=20the?= =?UTF-8?q?=20machine's=20runtime,=20and=20say=20while=20an=20agent=20can?= =?UTF-8?q?=20become=20root=20where=20it=20runs=20(hq=20ADR=200259=20?= =?UTF-8?q?=C2=A78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/mesh-controller/busobjects.go | 26 ++- cmd/mesh-controller/doctor.go | 5 + cmd/mesh-controller/probe_agent_root.go | 194 +++++++++++++++++++ cmd/mesh-controller/probe_agent_root_test.go | 60 ++++++ cmd/mesh-controller/seats.go | 6 + cmd/mesh-controller/seats_test.go | 12 ++ internal/broker/membership.go | 16 +- internal/broker/nats.go | 13 ++ internal/broker/seattraffic.go | 51 ++++- internal/broker/seattraffic_test.go | 78 +++++++- internal/broker/users.go | 14 +- internal/catalogue/declaration.go | 16 +- internal/catalogue/kinded_test.go | 47 ++++- internal/catalogue/manifest.go | 8 + internal/catalogue/runtime.go | 82 ++++++++ internal/catalogue/runtime_test.go | 46 +++++ internal/catalogue/seats_declared.go | 72 +++++++ internal/inventory/busrecords.go | 26 ++- 18 files changed, 747 insertions(+), 25 deletions(-) create mode 100644 cmd/mesh-controller/probe_agent_root.go create mode 100644 cmd/mesh-controller/probe_agent_root_test.go diff --git a/cmd/mesh-controller/busobjects.go b/cmd/mesh-controller/busobjects.go index 3c21d1d9..bb367a92 100644 --- a/cmd/mesh-controller/busobjects.go +++ b/cmd/mesh-controller/busobjects.go @@ -60,14 +60,14 @@ func assertBusObjects(ctx context.Context, inv *inventory.Inventory, r broker.Ra if err != nil { return nil, err } - for _, s := range trafficStreams { - if err := r.EnsureStream(s); err != nil { - return nil, fmt.Errorf("asserting the work queue %s: %w", s.Name, err) - } - } // Every one tried, and every failure named: one module's consumer the bus refuses is no reason // the modules after it in the list hear nothing (novox/hq issue 208, where this runs on each send). var failed []error + for _, s := range trafficStreams { + if err := r.EnsureStream(s); err != nil { + failed = append(failed, fmt.Errorf("the work queue %s: %w", s.Name, err)) + } + } for _, c := range trafficWorkers { if err := r.EnsureConsumer(c); err != nil { failed = append(failed, fmt.Errorf("the worker %s on %s: %w", c.Name, c.Stream, err)) @@ -159,6 +159,22 @@ func seatTrafficObjects(ctx context.Context, inv *inventory.Inventory) ([]broker return nil, nil, err } streams, workers := broker.SeatTrafficObjects(users) + // And the queue of every such seat the catalogue declares, held or not: work queues from registration, + // so what is submitted before a holder is assigned waits for it (the correctness review of 2026-10-08). + declared, err := inv.DeclaredTrafficSeats(ctx) + if err != nil { + return nil, nil, err + } + have := map[string]bool{} + for _, s := range streams { + have[s.Name] = true + } + for _, s := range broker.TrafficQueues(declared) { + if !have[s.Name] { + streams = append(streams, s) + have[s.Name] = true + } + } return streams, workers, nil } diff --git a/cmd/mesh-controller/doctor.go b/cmd/mesh-controller/doctor.go index b694dd43..c229a64d 100644 --- a/cmd/mesh-controller/doctor.go +++ b/cmd/mesh-controller/doctor.go @@ -126,6 +126,11 @@ var probeRegistry = []probe{ {ID: agentAccountProbe, Asserts: "every machine that names an agent account has it judged, on its node-engine's " + "newest statement, unable to become root without a person", From: "ADR 0266, ADR 0259 §8", Kind: kindAgentCanBecomeRoot, Phase: 1, run: probeAgentAccounts}, + // Root where the trusted parties run (novox/hq ADR 0259 §8): while an agent can become root there without a + // person, an answer proven there proves nothing. + {ID: "D-root", Asserts: "no agent can become root without a person on a machine where the router or a channel " + + "proving its sender runs: not by its own account, and not through a tool that runs its command as an account " + + "that can", From: "ADR 0259 §8", Kind: kindAgentRoot, Phase: 2, run: probeAgentRoot}, {ID: "DW", Asserts: "the watchdogs of the signals table ran within three of their intervals", From: "ADR 0227 rule 6: the watchers are watched", Kind: "watchdogs-silent", Phase: 1, run: probeWatchdogs}, // The core's health definitions (novox/hq to-be 45 §8, ADR 0236): what a core component's new build is diff --git a/cmd/mesh-controller/probe_agent_root.go b/cmd/mesh-controller/probe_agent_root.go new file mode 100644 index 00000000..b7aff456 --- /dev/null +++ b/cmd/mesh-controller/probe_agent_root.go @@ -0,0 +1,194 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "sort" + "strings" + "time" + + "github.com/novox/mesh-controller/internal/conditions" + "github.com/novox/mesh-controller/internal/link" +) + +// The self-check's probe of who can become root where the trusted parties run (novox/hq ADR 0259 §8, rule 3). +// +// The router and every channel proving its sender run as accounts of their own, so that no agent reads what +// they hold or speaks as them. **Root on their machine undoes all of it.** So on every machine where a module +// of its own account runs, this probe asks two questions, and raises an urgent condition while either is yes: +// +// 1. can an account an agent runs as become root without a person there — passwordless sudo, or a group +// that is root by another name (docker, disk)? The agent's account is the one the coding-agent module on +// that machine names (`agent_account`), and the operator's account while it names none; +// 2. can an agent run a command it chooses, through the mesh's own tools, as an account that can — the login +// shell's `execute` runs as the machine's runtime account, which the mesh's acting tools give passwordless +// sudo? +// +// The measurement is the sudo module's on that machine (`sudo_escalation`); a machine it does not run on, or +// that does not answer, is a probe that could not run — said, never taken for "no". + +// kindAgentRoot is the condition an agent able to become root where a trusted party runs raises. +const kindAgentRoot = "agent-root" + +// The tools the probe asks, of the modules on each machine. +const ( + agentModule = "claude-code" + agentStatusTool = "claude_code_status" + sudoModule = "sudo" + sudoEscalation = "sudo_escalation" + loginShellSeat = "node-login-shell" +) + +// escalation is the sudo module's answer for one account. +type escalation struct { + Account string `json:"account"` + Root bool `json:"root_without_a_person"` + Why string `json:"why"` +} + +// agentRootFacts is what one machine says, as the probe reads it. +type agentRootFacts struct { + Machine string + Trusted []string // the modules of their own account on it + Agent string // the account agents run as there; "" when none run there + AgentNamed bool // the coding-agent module named it, rather than it being taken for the operator's + Runtime string // the account the machine's runtime runs as + LoginShell bool // the login shell seat is held there, running commands as the runtime's account + Answers map[string]escalation +} + +// agentRootObservations judges one machine's facts: an urgent condition while an agent can become root there +// without a person, one way or the other, naming which. +func agentRootObservations(f agentRootFacts) []conditions.Observation { + var ways []string + if f.Agent != "" { + if e := f.Answers[f.Agent]; e.Root { + named := "the operator's account, which agents run as while the coding-agent module names no other" + if f.AgentNamed { + named = "the account agents run as" + } + ways = append(ways, fmt.Sprintf("%s (%s) can become root without a person: %s", f.Agent, named, e.Why)) + } + } + if f.LoginShell && f.Runtime != "" { + if e := f.Answers[f.Runtime]; e.Root { + ways = append(ways, fmt.Sprintf("the login shell runs any command an agent gives it as %s, which can "+ + "become root without a person: %s", f.Runtime, e.Why)) + } + } + if len(ways) == 0 { + return nil + } + sort.Strings(f.Trusted) + return []conditions.Observation{{Scope: conditions.ScopeMachine, ID: f.Machine, Token: kindAgentRoot, + Machine: f.Machine, Kind: kindAgentRoot, Severity: conditions.Urgent, + Summary: fmt.Sprintf("an agent can become root on %s without a person, where %s run as accounts of their "+ + "own; until it cannot, the router approves nothing proven there (novox/hq ADR 0259 §8): %s", + f.Machine, strings.Join(f.Trusted, ", "), strings.Join(ways, "; ")), + Headline: "Root without you on " + f.Machine, + Needs: "choose how programs working for you on " + f.Machine + " stop becoming root without asking you.", + Explanation: "The modules that prove your answers from your phone run on " + f.Machine + ", and a program " + + "working for you there can become root without asking you, so it could answer in your name. Until " + + "that changes, answers from your phone can only acknowledge.", + Resolved: "Nothing on " + f.Machine + " becomes root without you any more"}} +} + +// probeAgentRoot is the probe: every machine a module of its own account runs on, judged. +func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, error) { + inv := d.open.inventory + entries, err := inv.Catalogued(ctx) + if err != nil { + return nil, err + } + facts := map[string]*agentRootFacts{} + agentOn, sudoOn := map[string]bool{}, map[string]bool{} + for _, e := range entries { + for _, node := range e.On { + switch { + case e.Manifest.RunsAs != "": + f := facts[node] + if f == nil { + f = &agentRootFacts{Machine: node, Answers: map[string]escalation{}} + facts[node] = f + } + f.Trusted = append(f.Trusted, e.Manifest.Module) + case e.Manifest.Module == agentModule: + agentOn[node] = true + case e.Manifest.Module == sudoModule: + sudoOn[node] = true + } + } + } + for _, e := range entries { + if e.Manifest.ClaimsSeat(loginShellSeat) { + for _, node := range e.On { + if f := facts[node]; f != nil { + f.LoginShell = true + } + } + } + } + machines := make([]string, 0, len(facts)) + for m := range facts { + machines = append(machines, m) + } + sort.Strings(machines) + var out []conditions.Observation + var unread []string + for _, m := range machines { + f := facts[m] + record, err := inv.NodeByName(ctx, m) + if err != nil { + unread = append(unread, m+": "+err.Error()) + continue + } + f.Runtime = record.Account + if agentOn[m] { + f.Agent = record.Account + if a, err := link.AskModuleToolOn(ctx, d.js.Conn(), agentModule, agentStatusTool, m, map[string]any{}, 10*time.Second); err == nil && a.Error == "" { + var status struct { + AgentAccount string `json:"agent_account"` + } + if json.Unmarshal(a.Result, &status) == nil && status.AgentAccount != "" { + f.Agent, f.AgentNamed = status.AgentAccount, true + } + } + } + if !sudoOn[m] { + unread = append(unread, m+": the sudo module is not assigned there, so who can become root is not measured") + continue + } + var accounts []string + for _, a := range []string{f.Agent, f.Runtime} { + if a != "" && !slices.Contains(accounts, a) { + accounts = append(accounts, a) + } + } + if len(accounts) == 0 { + continue + } + a, err := link.AskModuleToolOn(ctx, d.js.Conn(), sudoModule, sudoEscalation, m, map[string]any{"accounts": accounts}, 15*time.Second) + if err == nil && a.Error != "" { + err = fmt.Errorf("%s", a.Error) + } + if err != nil { + unread = append(unread, m+": "+err.Error()) + continue + } + var answers []escalation + if err := json.Unmarshal(a.Result, &answers); err != nil { + unread = append(unread, m+": the sudo module's answer could not be read: "+err.Error()) + continue + } + for _, e := range answers { + f.Answers[e.Account] = e + } + out = append(out, agentRootObservations(*f)...) + } + if len(unread) > 0 { + return out, fmt.Errorf("who can become root could not be measured: %s", strings.Join(unread, "; ")) + } + return out, nil +} diff --git a/cmd/mesh-controller/probe_agent_root_test.go b/cmd/mesh-controller/probe_agent_root_test.go new file mode 100644 index 00000000..5ca7364b --- /dev/null +++ b/cmd/mesh-controller/probe_agent_root_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/conditions" +) + +// novox/hq ADR 0259 §8, rule 3: the probe fails today — agents run as the operator's account, which has +// passwordless sudo, and the login shell runs their commands as it — and passes only once neither holds. +func TestAnAgentAbleToBecomeRootWhereTheRouterRunsIsSaid(t *testing.T) { + root := escalation{Root: true, Why: "(ALL : ALL) NOPASSWD: ALL"} + none := escalation{Why: "no rule lets it without a password"} + base := func() agentRootFacts { + return agentRootFacts{Machine: "anchor", Trusted: []string{"telegram", "messenger"}, Runtime: "ops", + Answers: map[string]escalation{}} + } + + today := base() + today.Agent, today.LoginShell = "ops", true + today.Answers["ops"] = root + got := agentRootObservations(today) + if len(got) != 1 || got[0].Severity != conditions.Urgent || got[0].Kind != kindAgentRoot || + !strings.Contains(got[0].Summary, "the operator's account") || !strings.Contains(got[0].Summary, "the login shell") { + t.Fatalf("today: %+v", got) + } + + agentsMoved := base() + agentsMoved.Agent, agentsMoved.AgentNamed, agentsMoved.LoginShell = "agents", true, true + agentsMoved.Answers["agents"], agentsMoved.Answers["ops"] = none, root + if got := agentRootObservations(agentsMoved); len(got) != 1 || strings.Contains(got[0].Summary, "agents (") || + !strings.Contains(got[0].Summary, "the login shell") { + t.Errorf("agents of their own account, the login shell still the runtime's: %+v", got) + } + + closed := base() + closed.Agent, closed.AgentNamed = "agents", true + closed.Answers["agents"], closed.Answers["ops"] = none, root + if got := agentRootObservations(closed); len(got) != 0 { + t.Errorf("agents of their own account and no login shell there: %+v", got) + } + + noAgents := base() + noAgents.Answers["ops"] = root + if got := agentRootObservations(noAgents); len(got) != 0 { + t.Errorf("no agent runs there and no login shell is held: %+v", got) + } +} + +// Its words are plain, as every condition's are (novox/hq ADR 0253). +func TestTheRootConditionIsSaidInPlainWords(t *testing.T) { + f := agentRootFacts{Machine: "anchor", Trusted: []string{"telegram"}, Runtime: "ops", Agent: "ops", + Answers: map[string]escalation{"ops": {Root: true, Why: "x"}}} + o := agentRootObservations(f)[0] + if why, ok := conditions.PlainWords(conditions.Words{Headline: o.Headline, Explanation: o.Explanation, + Needs: o.Needs, Resolved: o.Resolved}, "anchor"); !ok { + t.Errorf("not plain: %s", why) + } +} diff --git a/cmd/mesh-controller/seats.go b/cmd/mesh-controller/seats.go index 793897a6..07910266 100644 --- a/cmd/mesh-controller/seats.go +++ b/cmd/mesh-controller/seats.go @@ -135,6 +135,12 @@ func handOver(ctx context.Context, seatName, to string, adding bool) error { if !ok || nodeName == "" || module == "" { return fmt.Errorf("the new holder is named /, not %q", to) } + // A kinded bench is held once per kind, by the claims themselves (novox/hq ADR 0234 §2, ADR 0259): the + // record of who holds a seat has no kind, so a handover would name one holder for every kind. + if catalogue.KindedBenches[seatName] { + return fmt.Errorf("%s is a kinded bench: each kind is held by the module claiming it, and is not handed "+ + "over by `seat` — assign the module that claims the kind, or unassign the one that does", seatName) + } open, err := openStores(ctx) if err != nil { return err diff --git a/cmd/mesh-controller/seats_test.go b/cmd/mesh-controller/seats_test.go index 9fc61356..7db64bf8 100644 --- a/cmd/mesh-controller/seats_test.go +++ b/cmd/mesh-controller/seats_test.go @@ -1,6 +1,8 @@ package main import ( + "context" + "strings" "testing" "github.com/novox/mesh-controller/internal/catalogue" @@ -62,3 +64,13 @@ func TestAClaimOutsideTheSetIsShownNotHidden(t *testing.T) { t.Fatalf("a claim outside the set was not shown: %+v", outside) } } + +// A kinded bench is not handed over by `seat`: each kind is held by its claim (novox/hq ADR 0259). +func TestAKindedBenchIsNotHandedOver(t *testing.T) { + for _, bench := range []string{"channel", "intake"} { + err := handOver(context.Background(), bench, "anchor/telegram", false) + if err == nil || !strings.Contains(err.Error(), "is a kinded bench") { + t.Errorf("%s: %v", bench, err) + } + } +} diff --git a/internal/broker/membership.go b/internal/broker/membership.go index 1909e99e..8b4a4537 100644 --- a/internal/broker/membership.go +++ b/internal/broker/membership.go @@ -172,7 +172,7 @@ func PlacementsOf(r Records, interchangeable map[string]bool) Placements { for _, s := range d.Holds { if s.Kinded && s.Kind != "" { p.Kinds = append(p.Kinds, KindHeld{Seat: s.Name, Kind: s.Kind, Module: d.Module, Node: node, - Capabilities: s.Capabilities}) + Capabilities: placedCapabilities(s.Capabilities, d.RunsAs)}) } } } @@ -190,6 +190,20 @@ func PlacementsOf(r Records, interchangeable map[string]bool) Placements { return p } +// placedCapabilities is what a kind's claim promises, as far as its placement lets the router believe it +// (novox/hq ADR 0259 §8): `verified-sender` only from a holder that runs as an account of its own, on a bus +// account of its own — never one the machine's runtime carries as the operator's account. +func placedCapabilities(declared []string, runsAs string) []string { + var out []string + for _, c := range declared { + if c == "verified-sender" && runsAs == "" { + continue + } + out = append(out, c) + } + return out +} + func kindListed(list []KindHeld, k KindHeld) bool { for _, x := range list { if x.Seat == k.Seat && x.Kind == k.Kind && x.Module == k.Module && x.Node == k.Node { diff --git a/internal/broker/nats.go b/internal/broker/nats.go index d0481852..9252703d 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -77,6 +77,9 @@ type Seat struct { Records []string // Capabilities are what this principal's claim of a kinded bench promises (ADR 0234 §2). Capabilities []string + // DeclaredBy is the module that declares the seat. On a kinded bench it alone submits work to a kind + // and answers its proofs (novox/hq ADR 0259 §8): the router, not any user or watcher of the bench. + DeclaredBy string } // A Principal is one user of the bus. Its permissions are derived from what it declares and @@ -710,6 +713,16 @@ func PermissionsFor(p Principal) (Permissions, error) { pub = append(pub, stateGrants(stateAccess{Module: d.Module, Node: p.Node, Keeps: stateNames(d.State), PerMachine: perMachineNames(d.State), Reads: d.Reads, KeyedReads: d.KeyedReads})...) } + // **Never the traffic of a trusted holder** (novox/hq ADR 0259 §8): the machine's runtime runs as the + // operator's account, which every agent runs as, so a module saying warrants or speaking for a kind + // that proves its sender is never composed into it — refused here, naming it, whatever registration + // let through. + for _, d := range p.Carries { + if why := trustedTraffic(d); why != "" { + return Permissions{}, fmt.Errorf("%s on %s is carried by the machine's runtime, and %s: it runs "+ + "as an account of its own, never the runtime's (novox/hq ADR 0259)", d.Module, p.Node, why) + } + } // **And the seat traffic of the modules it carries** (novox/hq ADR 0259 §3): a bundle reaches the // bus only through its runtime, so the runtime is granted the union. That one module's code does // not publish under another's name or kind through it is the runtime's to keep, from the seat diff --git a/internal/broker/seattraffic.go b/internal/broker/seattraffic.go index d2ebee8c..e8f69682 100644 --- a/internal/broker/seattraffic.go +++ b/internal/broker/seattraffic.go @@ -137,7 +137,8 @@ func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic { switch { case namesVerb(s.ByCaller, a): t.Publish = append(t.Publish, seatSubject(s, "accept", a+"."+module)) - case s.Kinded: + case s.Kinded && module == s.DeclaredBy: + // Work for a kind is put on its queue by the bench's own router, and by no other user. t.Publish = append(t.Publish, seatSubject(s, "accept", a+".*")) } } @@ -158,8 +159,11 @@ func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic { for _, e := range w.Emits { t.Subscribe = append(t.Subscribe, seatSubject(w, "event", e+".*")) } - for _, v := range w.Proofs { - t.Answers = append(t.Answers, seatSubject(w, "proof", v+".*")) + if module == w.DeclaredBy { + // A code is answered by the bench's own router, and by no other watcher. + for _, v := range w.Proofs { + t.Answers = append(t.Answers, seatSubject(w, "proof", v+".*")) + } } } } @@ -228,15 +232,16 @@ func SeatTrafficObjects(users []Principal) ([]Stream, []Consumer) { Subjects: []string{"mesh.seat." + seat + ".accept.>"}, Retention: RetentionWorkQueue, MaxAge: 7 * 24 * 60 * 60, - Why: "work submitted to the " + seat + " seat; its holders take it, each kind its own, " + - "and it queues while nobody does", + Why: "work submitted to the " + seat + " seat; its holders take it, each kind its own, and it queues while nobody does", } consumers[w.Consumer] = Consumer{ Name: w.Consumer, Stream: w.Stream, Filters: []string{w.Filter}, AckWaitSeconds: 60, - MaxDeliver: 5, + // No bound on redelivery: a channel away for a day keeps its work, offered again later and + // later by its holder's runtime (novox/hq ADR 0259; the correctness review of 2026-10-08). + MaxDeliver: 0, Why: module + " holds " + seat + "; it pulls one ask at a time and acknowledges once it has " + "recorded it, so a crash redelivers rather than loses", } @@ -264,3 +269,37 @@ func SeatTrafficObjects(users []Principal) ([]Stream, []Consumer) { sort.Slice(cs, func(i, j int) bool { return cs[i].Name < cs[j].Name }) return ss, cs } + +// trustedTraffic is why a module's seat traffic is the trusted holder's (novox/hq ADR 0259 §8), or "": it +// says a seat's event to one caller each (a warrant), or holds a kind of a kinded bench that proves its sender. +func trustedTraffic(d Declared) string { + for _, s := range d.Holds { + for _, e := range s.Emits { + if namesVerb(s.ByCaller, e) { + return "it says " + s.Name + "'s " + e + " to one caller each" + } + } + if s.Kinded && namesVerb(s.Capabilities, "verified-sender") { + return "it holds " + s.Name + " of kind " + s.Kind + ", which proves its sender" + } + } + return "" +} + +// TrafficQueues is the work queue of every seat naming its caller or its kind that accepts work, held or not +// (novox/hq ADR 0259 §3): what is submitted before a holder is assigned waits for it. +func TrafficQueues(seats []Seat) []Stream { + var out []Stream + seen := map[string]bool{} + for _, s := range seats { + if len(s.Accepts) == 0 || !s.isNewTraffic() || seen[s.Name] { + continue + } + seen[s.Name] = true + out = append(out, Stream{Name: seatStreamName(s.Name), Subjects: []string{"mesh.seat." + s.Name + ".accept.>"}, + Retention: RetentionWorkQueue, MaxAge: 7 * 24 * 60 * 60, + Why: "work submitted to the " + s.Name + " seat; its holders take it, each kind its own, and it queues while nobody does"}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} diff --git a/internal/broker/seattraffic_test.go b/internal/broker/seattraffic_test.go index 2f8b7bbd..c5b6944f 100644 --- a/internal/broker/seattraffic_test.go +++ b/internal/broker/seattraffic_test.go @@ -13,12 +13,13 @@ func operatorChannel() Seat { } func channelSeat(kind string) Seat { - return Seat{Name: "channel", Scope: "mesh", Accepts: []string{"show", "edit", "send"}, Kinded: true, Kind: kind} + return Seat{Name: "channel", Scope: "mesh", Accepts: []string{"show", "edit", "send"}, Kinded: true, Kind: kind, + DeclaredBy: "messenger"} } func intakeSeat(kind string) Seat { return Seat{Name: "intake", Scope: "mesh", Emits: []string{"choice", "link"}, Proofs: []string{"code"}, - Kinded: true, Kind: kind} + Kinded: true, Kind: kind, DeclaredBy: "messenger"} } func allowed(patterns []string, subject string) bool { @@ -80,7 +81,7 @@ func TestOnlyTheHolderPublishesAWarrant(t *testing.T) { Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": { {Module: "messenger", Holds: []Seat{operatorChannel()}, Uses: []Seat{channelSeat("")}, - Watches: []Seat{{Name: "intake", Emits: []string{"choice", "link"}, Kinded: true, Proofs: []string{"code"}}}}, + Watches: []Seat{{Name: "intake", Emits: []string{"choice", "link"}, Kinded: true, Proofs: []string{"code"}, DeclaredBy: "messenger"}}}, {Module: "mesh-delivery", Uses: []Seat{operatorChannel()}}, {Module: "telegram", Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}}, }}, @@ -150,7 +151,7 @@ func TestAKindedHolderReachesItsOwnKindAndNoOther(t *testing.T) { func TestTheWatcherAnswersProofsAndHearsEveryKind(t *testing.T) { got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "messenger", Uses: []Seat{channelSeat("")}, - Watches: []Seat{{Name: "intake", Emits: []string{"choice"}, Kinded: true, Proofs: []string{"code"}}}}) + Watches: []Seat{{Name: "intake", Emits: []string{"choice"}, Kinded: true, Proofs: []string{"code"}, DeclaredBy: "messenger"}}}) for _, s := range []string{"mesh.seat.intake.event.choice.telegram", "mesh.seat.intake.proof.code.desktop"} { if !allowed(got.Subscribe, s) { t.Errorf("the router does not hear %s", s) @@ -248,7 +249,7 @@ func TestTheRoutersMembershipNamesEveryKindAndItsCapabilities(t *testing.T) { desk.Capabilities = []string{"choice"} router := Declared{Module: "messenger", Holds: []Seat{operatorChannel()}, Uses: []Seat{channelSeat("")}} records := Records{Nodes: []string{"anchor", "laptop"}, Assigned: map[string][]Declared{ - "anchor": {router, {Module: "telegram", Holds: []Seat{tg}}}, + "anchor": {router, {Module: "telegram", Holds: []Seat{tg}, RunsAs: "telegram"}}, "laptop": {{Module: "desk-channel", Holds: []Seat{desk}}}, }} where := PlacementsOf(records, nil) @@ -270,3 +271,70 @@ func TestTheRoutersMembershipNamesEveryKindAndItsCapabilities(t *testing.T) { t.Error("an asker is told the channels") } } + +// novox/hq ADR 0259 §8: only the bench's own router answers its proofs and puts work on a kind's queue. +func TestOnlyTheBenchsRouterAnswersProofsAndSubmitsWork(t *testing.T) { + other := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "eavesdropper", + Uses: []Seat{channelSeat("")}, + Watches: []Seat{{Name: "intake", Emits: []string{"choice"}, Kinded: true, Proofs: []string{"code"}, DeclaredBy: "messenger"}}}) + if allowed(other.Subscribe, "mesh.seat.intake.proof.code.telegram") { + t.Error("a watcher that is not the router answers codes") + } + if allowed(other.Publish, "mesh.seat.channel.accept.show.telegram") { + t.Error("a user that is not the router puts work on a kind's queue") + } + if !allowed(other.Subscribe, "mesh.seat.intake.event.choice.telegram") { + t.Error("a watcher no longer hears the bench's events") + } +} + +// novox/hq ADR 0259 §8: the machine's runtime runs as the operator's account; it never carries a module +// that says warrants or speaks for a kind proving its sender, and such a module has its own account. +func TestTheMachinesRuntimeNeverCarriesATrustedHolder(t *testing.T) { + tg := channelSeat("telegram") + tg.Capabilities = []string{"choice", "verified-sender"} + for name, d := range map[string]Declared{ + "the router": {Module: "messenger", Holds: []Seat{operatorChannel()}}, + "a verified channel": {Module: "telegram", Holds: []Seat{tg}}, + } { + if _, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, + Carries: []Declared{d}}); err == nil || !strings.Contains(err.Error(), "an account of its own") { + t.Errorf("%s was composed into the machine's runtime: %v", name, err) + } + } + desk := channelSeat("desktop") + desk.Capabilities = []string{"choice"} + if _, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, + Carries: []Declared{{Module: "desk-channel", Holds: []Seat{desk}}}}); err != nil { + t.Errorf("a channel proving nothing was refused: %v", err) + } + users, err := Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": { + {Module: RuntimeModule}, {Module: "telegram", Holds: []Seat{tg}, RunsAs: "telegram"}, + {Module: "messenger", Holds: []Seat{operatorChannel()}, RunsAs: "messenger"}, + }}}) + if err != nil { + t.Fatal(err) + } + for _, u := range users { + if u.Kind == KindNodeTools { + for _, d := range u.Carries { + if d.RunsAs != "" { + t.Errorf("the machine's runtime carries %s", d.Module) + } + } + if _, err := PermissionsFor(u); err != nil { + t.Errorf("the runtime could not be composed: %v", err) + } + } + } +} + +// novox/hq ADR 0259 §8: verified-sender reaches the router only from a holder of its own account. +func TestVerifiedSenderIsBelievedOnlyFromAHolderOfItsOwnAccount(t *testing.T) { + if got := placedCapabilities([]string{"choice", "verified-sender"}, ""); namesVerb(got, "verified-sender") { + t.Errorf("a carried holder keeps verified-sender: %v", got) + } + if got := placedCapabilities([]string{"choice", "verified-sender"}, "telegram"); !namesVerb(got, "verified-sender") { + t.Errorf("a holder of its own account lost verified-sender: %v", got) + } +} diff --git a/internal/broker/users.go b/internal/broker/users.go index e06ad472..83924b60 100644 --- a/internal/broker/users.go +++ b/internal/broker/users.go @@ -50,6 +50,9 @@ type Declared struct { // Checks are the module's own tools its health asks, each `.` (novox/hq ADR 0240, to-be // 48 §3): the machine's node-engine asks them of its own node tools, and is granted that and no more. Checks []string + // RunsAs is the account the module runs as in a runtime of its own (novox/hq ADR 0259 §8): it is never + // carried by the machine's runtime, and reaches the bus on its own account. + RunsAs string } // Records is what composing a user list needs to know about the mesh, and nothing more. @@ -120,10 +123,13 @@ func Users(r Records) ([]Principal, error) { }) } if runtimeHere { - out = append(out, Principal{ - Kind: KindNodeTools, Node: node, Module: RuntimeModule, - Carries: append([]Declared(nil), r.Assigned[node]...), - }) + var carried []Declared + for _, d := range r.Assigned[node] { + if d.RunsAs == "" { + carried = append(carried, d) + } + } + out = append(out, Principal{Kind: KindNodeTools, Node: node, Module: RuntimeModule, Carries: carried}) } } for _, node := range sortedCopy(r.Enrolling) { diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index fadab8da..7d7f1e5e 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -1101,9 +1101,23 @@ func (r Resolution) compose(with Rendering, owner map[string]string, } owner[fmt.Sprint(process["id"])] = RuntimeModule out = append(out, process) - // What each module's bundles are given is read as the account the runtime runs as. + // And a runtime of its own for each module of its own account, after it (novox/hq ADR 0259 §8). + owns, err := r.ownRuntimes(with) + if err != nil { + return nil, err + } + for _, p := range owns { + id := fmt.Sprint(p["id"]) + owner[id] = strings.TrimSuffix(id, "."+OwnRuntimeID()) + out = append(out, p) + } + // What each module's bundles are given is read as the account the runtime runs as — not what a + // module of its own account is given, which its own account reads. words := map[string]map[string]string{} for _, m := range r.Modules { + if m.RunsAs != "" { + continue + } w, err := bundleWords(m, with) if err != nil { return nil, err diff --git a/internal/catalogue/kinded_test.go b/internal/catalogue/kinded_test.go index c1fd1aa3..15b15201 100644 --- a/internal/catalogue/kinded_test.go +++ b/internal/catalogue/kinded_test.go @@ -8,7 +8,7 @@ import ( // The router of novox/hq ADR 0259: it declares the operator's seat and the two kinded benches. func router() Manifest { return Manifest{Module: "messenger", Tools: []string{"open", "history", "notify"}, - State: []StateDeclaration{{Name: "asks"}}, + State: []StateDeclaration{{Name: "asks"}}, RunsAs: "messenger", SecretsOwner: "messenger", DefinesSeats: []SeatDeclaration{ {Name: "operator-channel", Scope: ScopeMesh, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"}, ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"asks"}, @@ -98,6 +98,7 @@ func TestEachKindIsItsOwnHolderWhenResolved(t *testing.T) { func TestCapabilitiesAreTheVocabularysAndOnlyOnAKindedClaim(t *testing.T) { good := aChannel("telegram", "telegram") good.Claims[0].Capabilities = []string{"deliver", "choice", "verified-sender", "max-length:4096"} + good.RunsAs = "telegram" if got := problemsFor(t, Shelf{"messenger": router(), "telegram": good}); got != "" { t.Fatalf("the vocabulary was refused: %s", got) } @@ -114,3 +115,47 @@ func TestCapabilitiesAreTheVocabularysAndOnlyOnAKindedClaim(t *testing.T) { t.Errorf("capabilities on a seat that is not kinded stood: %s", got) } } + +// novox/hq ADR 0259 §8: a module saying warrants, or speaking for a kind that proves its sender, runs as an +// account of its own — never carried by the machine's runtime, which runs as the operator's account. +func TestATrustedHolderMustRunAsAnAccountOfItsOwn(t *testing.T) { + r := router() + r.RunsAs = "" + if got := problemsFor(t, Shelf{"messenger": r}); !strings.Contains(got, "messenger must run as an account of its own") { + t.Errorf("a router on the machine's runtime stood: %s", got) + } + tg := aChannel("telegram", "telegram") + tg.Claims[0].Capabilities = []string{"choice", "verified-sender"} + if got := problemsFor(t, Shelf{"messenger": router(), "telegram": tg}); !strings.Contains(got, "telegram must run as an account of its own") { + t.Errorf("a verified channel on the machine's runtime stood: %s", got) + } + desk := aChannel("desk-channel", "desktop") + desk.Claims[0].Capabilities = []string{"choice"} + if got := problemsFor(t, Shelf{"messenger": router(), "desk-channel": desk}); got != "" { + t.Errorf("a channel proving nothing was held to it: %s", got) + } +} + +func TestRunsAsIsAnAccountOfTheModulesOwn(t *testing.T) { + ok := Manifest{Module: "telegram", RunsAs: "telegram", SecretsOwner: "telegram", + OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}}, + Resources: []map[string]any{{"id": "account", "type": "user", "name": "telegram"}}} + if got := RunsAsProblems(ok); len(got) != 0 { + t.Fatalf("a sound runs-as was refused: %v", got) + } + for want, change := range map[string]func(*Manifest){ + "never root": func(m *Manifest) { m.RunsAs, m.SecretsOwner = "root", "root" }, + "not an account name": func(m *Manifest) { m.RunsAs = "${machine:account}" }, + "which it does not make": func(m *Manifest) { m.Resources = nil }, + "declares no own secret": func(m *Manifest) { m.OwnSecrets = nil }, + "they are the account's own": func(m *Manifest) { m.SecretsOwner = "" }, + } { + m := ok + m.Resources = append([]map[string]any(nil), ok.Resources...) + m.OwnSecrets = OwnSecrets{"broker": {Path: "/x"}} + change(&m) + if got := strings.Join(RunsAsProblems(m), "; "); !strings.Contains(got, want) { + t.Errorf("want %q, got %q", want, got) + } + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index bbae198c..842580cf 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -647,6 +647,13 @@ type Manifest struct { // cannot use. SecretsOwner string `json:"secrets-owner,omitempty"` + // RunsAs is the account this module's tools bundle runs as, in a runtime of its own on a bus account of + // its own (novox/hq ADR 0259 §8): never the machine's runtime, which runs as the operator's account and + // carries every module on the machine. The account is one the module makes (a `user` resource of that + // name), owns its secrets (`secrets-owner`), and is neither root nor the operator's. Required of a module + // that says a warrant, or speaks for a channel kind that proves its sender. + RunsAs string `json:"runs-as,omitempty"` + // Keeps is where this module wants every operator-sealed secret in the mesh written — the // vault's field, and so far nobody else's (novox/hq ADR 0085, amended). // @@ -1627,6 +1634,7 @@ func ParseManifest(raw []byte) (Manifest, error) { // prefix. Whether a seat anybody names exists, and whether a holder answers for it, are // facts about the catalogue and are checked at registration (CatalogueProblems). problems = append(problems, declaredSeatProblems(m)...) + problems = append(problems, RunsAsProblems(m)...) if m.Computed != "" && len(m.Resources) > 0 { // One or the other. A module that both ships files and has them computed would leave // nobody able to say where a given file came from. diff --git a/internal/catalogue/runtime.go b/internal/catalogue/runtime.go index 71a5c87a..d0e3bc32 100644 --- a/internal/catalogue/runtime.go +++ b/internal/catalogue/runtime.go @@ -170,6 +170,10 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) { if with.Adopted && m.Filtering != nil { continue } + if m.RunsAs != "" { + // Served by a runtime of its own, on its own account (ownRuntimes): never the machine's. + continue + } words, err := bundleWords(m, with) if err != nil { return nil, err @@ -232,6 +236,84 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) { return process, nil } +// OwnRuntimeID names the process a module of its own account is served by (novox/hq ADR 0259 §8). +func OwnRuntimeID() string { return "own-runtime" } + +// ownRuntimes are the processes the modules of their own account are served by (novox/hq ADR 0259 §8): each +// the machine's runtime program — the same build, run from the same source — serving that one module alone, +// as the module's own account, on the module's own bus credential. Never the machine's runtime, which runs +// as the operator's account and carries every module on the machine. +func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) { + var own []Manifest + for _, m := range r.Modules { + if m.RunsAs != "" && !(with.Adopted && m.Filtering != nil) { + own = append(own, m) + } + } + if len(own) == 0 { + return nil, nil + } + var runtime *Manifest + for i := range r.Modules { + if r.Modules[i].Module == RuntimeModule { + runtime = &r.Modules[i] + } + } + if runtime == nil || len(runtime.Bundles) != 1 || runtime.Bundles[0].Binary == "" { + return nil, fmt.Errorf("%s runs as its own account in a runtime of its own, and %s is not here to run it "+ + "from: assign %s to %s first (novox/hq ADR 0259)", own[0].Module, RuntimeModule, RuntimeModule, r.Node) + } + program := runtime.Bundles[0] + var out []map[string]any + for _, m := range own { + credential, declared := m.OwnSecrets["broker"] + if !declared { + return nil, fmt.Errorf("%s runs as its own account and declares no own secret broker", m.Module) + } + var served, restartOn []string + for _, b := range m.Bundles { + for _, load := range b.Loads { + if launcher, has := b.Launchers[load]; has { + load = launcher + } + served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load) + } + if len(b.Loads) > 0 { + restartOn = append(restartOn, m.Module+"."+BundleID(b.Name)) + } + } + if len(served) == 0 { + return nil, fmt.Errorf("%s runs as its own account and its build produced no bundle to serve", m.Module) + } + sort.Strings(served) + restartOn = append(restartOn, m.Module+"."+NeedID("broker")) + sort.Strings(restartOn) + env := map[string]string{RuntimeToolModules: strings.Join(served, ","), RuntimeBrokerFile: credential.Path} + words, err := bundleWords(m, with) + if err != nil { + return nil, err + } + if len(words) > 0 { + body, err := json.Marshal(map[string]map[string]string{m.Module: words}) + if err != nil { + return nil, err + } + env[RuntimeToolEnv] = string(body) + } + process := map[string]any{ + "id": m.Module + "." + OwnRuntimeID(), "type": "process", "name": m.Module + "-runtime", + "source": program.Source, "digest": program.Digest, + "run": []any{"./" + program.Binary}, "env": env, "restart-on": toAny(restartOn), + "user": m.RunsAs, + } + if err := artifactsInto(process, RuntimeModule, with); err != nil { + return nil, err + } + out = append(out, process) + } + return out, nil +} + func toAny(in []string) []any { out := make([]any, 0, len(in)) for _, s := range in { diff --git a/internal/catalogue/runtime_test.go b/internal/catalogue/runtime_test.go index 5d2b9f65..cba8b954 100644 --- a/internal/catalogue/runtime_test.go +++ b/internal/catalogue/runtime_test.go @@ -457,3 +457,49 @@ func TestAGoToolsBundleIsServedByItsBinary(t *testing.T) { t.Error("a Go bundle loading a file it does not contain was admitted") } } + +// novox/hq ADR 0259 §8: a module of its own account is served by a runtime of its own — the machine's +// runtime program, as that account, on that module's own credential — and never by the machine's runtime, +// which runs as the operator's account and is given none of its words. +func TestAModuleOfItsOwnAccountIsServedByARuntimeOfItsOwn(t *testing.T) { + with := Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}, "telegram": {"broker": "own"}}} + goRuntime := Manifest{Module: RuntimeModule, Version: "1", + OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}}, + Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "go", + System: "arch", From: "cmd/node-tools"}}}} + goRuntime, err := goRuntime.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) + if err != nil { + t.Fatal(err) + } + telegram := aToolsModule(t, "telegram", "tools/index.js") + telegram.RunsAs, telegram.SecretsOwner = "telegram", "telegram" + telegram.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}} + out, err := Resolution{Node: "anchor", Account: "ops", + Modules: []Manifest{aToolsModule(t, "nftables", "tools/index.js"), telegram, goRuntime}}.Declaration(with) + if err != nil { + t.Fatal(err) + } + machine := fileNamed(out, RuntimeModule+"."+RuntimeProcessID()) + if served := machine["env"].(map[string]string)[RuntimeToolModules]; strings.Contains(served, "telegram") || !strings.Contains(served, "nftables") { + t.Errorf("the machine's runtime serves %q", served) + } + own := fileNamed(out, "telegram."+OwnRuntimeID()) + if own == nil { + t.Fatalf("telegram has no runtime of its own: %v", ids(out)) + } + env := own["env"].(map[string]string) + if own["user"] != "telegram" || fmt.Sprint(own["run"]) != "[./node-tools]" || + env[RuntimeBrokerFile] != "/var/lib/telegram/broker" || + env[RuntimeToolModules] != "telegram="+BundleRoot+"/telegram/tools/tools/index.js" { + t.Errorf("its own runtime: user %v run %v env %v", own["user"], own["run"], env) + } + if _, told := env[RuntimeOperatorAccount]; told { + t.Error("a runtime of a module's own account is told the operator's account") + } + // Without the machine's runtime to run it from, it is refused in words. + if _, err := (Resolution{Node: "anchor", Modules: []Manifest{telegram}}).ownRuntimes(with); err == nil { + t.Error("a module of its own account composed without a runtime program") + } +} diff --git a/internal/catalogue/seats_declared.go b/internal/catalogue/seats_declared.go index fd4b0899..1728a9bb 100644 --- a/internal/catalogue/seats_declared.go +++ b/internal/catalogue/seats_declared.go @@ -321,6 +321,16 @@ func CatalogueProblems(shelf Shelf) []string { } } } + // **A trusted holder runs as its own account** (novox/hq ADR 0259 §8): a module saying warrants, or + // speaking for a kind that proves its sender, is never carried by a machine's runtime. + for _, module := range shelfOrder(shelf) { + m := shelf[module] + if why := TrustedHolding(m, declared); why != "" && m.RunsAs == "" { + problems = append(problems, fmt.Sprintf( + "%s must run as an account of its own (runs-as): %s, and the machine's runtime runs as the "+ + "operator's account, which every agent runs as (novox/hq ADR 0259)", module, why)) + } + } // A read of a module's state that module does not keep (novox/hq ADR 0201) — said only where the // owner is on the shelf, as a consumer may be installed before its emitter. var manifests []Manifest @@ -416,3 +426,65 @@ func shelfOrder(shelf Shelf) []string { sort.Strings(out) return out } + +var accountName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,30}$`) + +// RunsAsProblems is what one manifest's `runs-as` is held to (novox/hq ADR 0259 §8): an account of the +// module's own making — a `user` resource of that name — that owns its secrets, with a bus account of its own, +// and that is neither root nor the operator's. +func RunsAsProblems(m Manifest) []string { + if m.RunsAs == "" { + return nil + } + var problems []string + say := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) } + switch { + case !accountName.MatchString(m.RunsAs): + say("%s runs as %q, which is not an account name of the module's own", m.Module, m.RunsAs) + return problems + case m.RunsAs == "root": + say("%s runs as root; a module of its own account runs as an account it makes, never root", m.Module) + } + made := false + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) == "user" && fmt.Sprint(r["name"]) == m.RunsAs { + made = true + } + } + if !made { + say("%s runs as %s, which it does not make: a user resource named %s", m.Module, m.RunsAs, m.RunsAs) + } + if _, has := m.OwnSecrets["broker"]; !has { + say("%s runs as its own account and declares no own secret broker: its runtime reaches the bus on an "+ + "account of its own", m.Module) + } + if m.SecretsOwner != m.RunsAs { + say("%s runs as %s, and its secrets belong to %q: they are the account's own", m.Module, m.RunsAs, m.SecretsOwner) + } + return problems +} + +// TrustedHolding is why a module must run as its own account (novox/hq ADR 0259 §8), or "": it holds a seat +// whose events it says to one caller each (a warrant), or speaks for a kind of a kinded bench that proves +// its sender. Neither may be carried by the machine's runtime, which runs as the operator's account. +func TrustedHolding(m Manifest, declared map[string]SeatDeclaration) string { + for _, c := range m.Claims { + s, ok := declared[c.Name] + if !ok { + continue + } + for _, e := range s.Emits { + if s.NamedByCaller(e) { + return fmt.Sprintf("it holds %s, whose %s it says to one caller each", c.Name, e) + } + } + if s.Kinded { + for _, capability := range c.Capabilities { + if capability == "verified-sender" { + return fmt.Sprintf("it holds %s of kind %s, which proves its sender", c.Name, c.Kind) + } + } + } + } + return "" +} diff --git a/internal/inventory/busrecords.go b/internal/inventory/busrecords.go index 4af67be5..68ca4f54 100644 --- a/internal/inventory/busrecords.go +++ b/internal/inventory/busrecords.go @@ -141,7 +141,7 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio ev := strings.TrimSuffix(event, ".*") if catalogue.SeatSays(s.Emits, ev) { watches = append(watches, broker.Seat{Name: s.Name, Scope: s.Scope, Emits: []string{ev}, - Kinded: true, Proofs: s.Proofs}) + Kinded: true, Proofs: s.Proofs, DeclaredBy: declarers[s.Name]}) continue } } @@ -168,6 +168,8 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio Reads: m.Reads, // And the tools its health asks (novox/hq ADR 0240): the machine's node-engine is granted them. Checks: catalogue.HealthChecks(m), + // And whether it runs as an account of its own (novox/hq ADR 0259 §8). + RunsAs: m.RunsAs, } // Whether it can be given an account at all: delivered as its own secret named broker, so one // that declares none has nowhere to read it (novox/hq issue 195). @@ -222,7 +224,8 @@ func (i *Inventory) DeclaredBuckets(ctx context.Context) ([]broker.Bucket, error func asSeat(s catalogue.SeatDeclaration, declarer string) broker.Seat { seat := broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits, - Serves: catalogue.VerbNames(s.Serves), Kinded: s.Kinded, ByCaller: s.ByCaller, Proofs: s.Proofs} + Serves: catalogue.VerbNames(s.Serves), Kinded: s.Kinded, ByCaller: s.ByCaller, Proofs: s.Proofs, + DeclaredBy: declarer} // A seat's records are its declaring module's buckets, named as the bus holds them (ADR 0259 §3). for _, r := range s.Records { if declarer != "" { @@ -300,3 +303,22 @@ func heldHere(claimed []broker.Seat, holdings []catalogue.Held, node, module str } return out } + +// DeclaredTrafficSeats is every seat any registered module declares that names its caller or its kind +// (novox/hq ADR 0259 §3), as the bus needs it: assigned or not, so its work queue exists from registration. +func (i *Inventory) DeclaredTrafficSeats(ctx context.Context) ([]broker.Seat, error) { + declared, err := i.Catalogue(ctx) + if err != nil { + return nil, fmt.Errorf("cannot read the catalogue: %w", err) + } + var out []broker.Seat + for _, m := range declared { + for _, s := range m.DefinesSeats { + seat := asSeat(s, m.Module) + if seat.Kinded || len(seat.ByCaller) > 0 { + out = append(out, seat) + } + } + } + return out, nil +} From b3fd360ddb0e1b0a0c971adeee24509415f0b5fc Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 10:10:59 +0200 Subject: [PATCH 04/11] Count the login shell where its execute is served, not where its seat is held (hq ADR 0268) The control-node withholds execute through its holder's setting since ADR 0268, so probe D-root read a closed path as open. It now counts the verb as served while the holder's setting for that machine is serve, or the bus hears execute answered there, or the bus could not be asked: a withheld value not yet pushed, or a holder answering against its setting, is never taken for closed. --- cmd/mesh-controller/probe_agent_root.go | 106 ++++++++++++++++--- cmd/mesh-controller/probe_agent_root_test.go | 42 ++++++++ cmd/mesh-controller/probes.go | 88 ++++++++++----- 3 files changed, 197 insertions(+), 39 deletions(-) diff --git a/cmd/mesh-controller/probe_agent_root.go b/cmd/mesh-controller/probe_agent_root.go index b7aff456..1c3ccccf 100644 --- a/cmd/mesh-controller/probe_agent_root.go +++ b/cmd/mesh-controller/probe_agent_root.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/link" ) @@ -24,7 +25,12 @@ import ( // that machine names (`agent_account`), and the operator's account while it names none; // 2. can an agent run a command it chooses, through the mesh's own tools, as an account that can — the login // shell's `execute` runs as the machine's runtime account, which the mesh's acting tools give passwordless -// sudo? +// sudo? **Only where `execute` is served** (novox/hq ADR 0268): `execute` is an optional verb its holder +// withholds on a machine whose `execute` setting is not `serve`. The holder's seat being held there says +// nothing; whether the verb is served does. It counts as served while either says so — the holder's +// setting as the controller resolves it for that machine (a withheld value not yet pushed is still served), +// or the bus's discovery hearing the verb answered there — so a withheld setting the machine has not acted +// on yet, or a holder answering against its setting, is never taken for closed. // // The measurement is the sudo module's on that machine (`sudo_escalation`); a machine it does not run on, or // that does not answer, is a probe that could not run — said, never taken for "no". @@ -39,8 +45,45 @@ const ( sudoModule = "sudo" sudoEscalation = "sudo_escalation" loginShellSeat = "node-login-shell" + // loginShellVerb is the seat's verb that runs a command, and the name of the setting its holder withholds + // it by (novox/hq ADR 0268). + loginShellVerb = "execute" + // executeServes is the one value of that setting that serves the verb; anything else withholds it. + executeServes = "serve" ) +// loginShellServed judges whether the login shell's execute is served on a machine, failing closed (novox/hq +// ADR 0268): served while the holder's setting there is `serve` (or the holder has no such setting and claims +// the verb), or while the bus heard the verb answered there, or while the bus could not be asked. why says +// which, in words, for the condition. +func loginShellServed(holder string, claims bool, setting *any, heard, asked bool) (bool, string) { + var why []string + switch { + case setting != nil: + if v, _ := (*setting).(string); v == executeServes { + why = append(why, holder+"'s execute setting there is "+executeServes) + } + case claims: + why = append(why, holder+" claims execute and has no setting that withholds it") + } + if heard { + why = append(why, "the bus hears execute answered there") + } else if !asked { + why = append(why, "the bus could not be asked whether execute is answered there") + } + return len(why) > 0, strings.Join(why, "; ") +} + +// claimServes says whether a manifest's claim of a seat names a verb among those it serves. +func claimServes(m catalogue.Manifest, seat, verb string) bool { + for _, c := range m.Claims { + if c.Name == seat && slices.Contains(c.Serves, verb) { + return true + } + } + return false +} + // escalation is the sudo module's answer for one account. type escalation struct { Account string `json:"account"` @@ -55,8 +98,10 @@ type agentRootFacts struct { Agent string // the account agents run as there; "" when none run there AgentNamed bool // the coding-agent module named it, rather than it being taken for the operator's Runtime string // the account the machine's runtime runs as - LoginShell bool // the login shell seat is held there, running commands as the runtime's account - Answers map[string]escalation + LoginShell bool // the login shell's execute is served there, running commands as the runtime's account + // LoginShellWhy is why execute counts as served there, in words: its holder's setting, the bus, or both. + LoginShellWhy string + Answers map[string]escalation } // agentRootObservations judges one machine's facts: an urgent condition while an agent can become root there @@ -74,8 +119,12 @@ func agentRootObservations(f agentRootFacts) []conditions.Observation { } if f.LoginShell && f.Runtime != "" { if e := f.Answers[f.Runtime]; e.Root { - ways = append(ways, fmt.Sprintf("the login shell runs any command an agent gives it as %s, which can "+ - "become root without a person: %s", f.Runtime, e.Why)) + served := "" + if f.LoginShellWhy != "" { + served = " (" + f.LoginShellWhy + ")" + } + ways = append(ways, fmt.Sprintf("the login shell runs any command an agent gives it as %s%s, which can "+ + "become root without a person: %s", f.Runtime, served, e.Why)) } } if len(ways) == 0 { @@ -121,15 +170,6 @@ func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, e } } } - for _, e := range entries { - if e.Manifest.ClaimsSeat(loginShellSeat) { - for _, node := range e.On { - if f := facts[node]; f != nil { - f.LoginShell = true - } - } - } - } machines := make([]string, 0, len(facts)) for m := range facts { machines = append(machines, m) @@ -137,6 +177,44 @@ func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, e sort.Strings(machines) var out []conditions.Observation var unread []string + if len(machines) == 0 { + return nil, nil + } + // Whether the login shell's execute is served where a trusted party runs (novox/hq ADR 0268): the + // holder's setting for that machine, and what the bus hears answered there. A discovery that could not be + // asked leaves only the setting, which is said: the probe then cannot show the verb withheld. + heard, derr := discoverSeatVerbs(ctx, d.js.Conn()) + if derr != nil { + unread = append(unread, "the bus's discovery could not be asked whether the login shell's execute is served: "+derr.Error()) + } + for _, e := range entries { + if !e.Manifest.ClaimsSeat(loginShellSeat) { + continue + } + for _, node := range e.On { + f := facts[node] + if f == nil { + continue + } + var setting *any + if _, declared := e.Manifest.Settings[loginShellVerb]; declared { + layers, err := inv.SettingsFor(ctx, node, e.Manifest.Module) + if err != nil { + unread = append(unread, node+": "+e.Manifest.Module+"'s settings could not be read: "+err.Error()) + f.LoginShell, f.LoginShellWhy = true, "its holder's setting could not be read" + continue + } + for _, s := range catalogue.Effective(e.Manifest, layers) { + if s.Key == loginShellVerb { + v := s.Value + setting = &v + } + } + } + f.LoginShell, f.LoginShellWhy = loginShellServed(e.Manifest.Module, claimServes(e.Manifest, loginShellSeat, loginShellVerb), + setting, heard[loginShellSeat][loginShellVerb][node], derr == nil) + } + } for _, m := range machines { f := facts[m] record, err := inv.NodeByName(ctx, m) diff --git a/cmd/mesh-controller/probe_agent_root_test.go b/cmd/mesh-controller/probe_agent_root_test.go index 5ca7364b..247e69f3 100644 --- a/cmd/mesh-controller/probe_agent_root_test.go +++ b/cmd/mesh-controller/probe_agent_root_test.go @@ -58,3 +58,45 @@ func TestTheRootConditionIsSaidInPlainWords(t *testing.T) { t.Errorf("not plain: %s", why) } } + +// novox/hq ADR 0268: the login shell counts only where its execute is served, and fails closed — the holder's +// setting resolving to anything but serve and the bus hearing no execute there is the one way it is withheld. +func TestTheLoginShellCountsOnlyWhereExecuteIsServed(t *testing.T) { + val := func(v any) *any { return &v } + cases := []struct { + name string + claims bool + setting *any + heard, asked bool + served bool + saysInTheWhys string + }{ + {"withheld by the setting and silent on the bus", true, val("withhold"), false, true, false, ""}, + {"withheld by the setting, the machine not yet pushed", true, val("withhold"), true, true, true, "the bus hears"}, + {"the setting serves", true, val("serve"), false, true, true, "setting there is serve"}, + {"a wrong value withholds, as the holder does", true, val("Serve"), false, true, false, ""}, + {"the setting withholds, the bus could not be asked", true, val("withhold"), false, false, true, "could not be asked"}, + {"a holder with no such setting that claims execute", true, nil, false, true, true, "claims execute"}, + {"a holder with no such setting that does not claim it, heard all the same", false, nil, true, true, true, "the bus hears"}, + {"a holder with no such setting that does not claim it, silent", false, nil, false, true, false, ""}, + } + for _, c := range cases { + served, why := loginShellServed("zsh", c.claims, c.setting, c.heard, c.asked) + if served != c.served || (c.saysInTheWhys != "" && !strings.Contains(why, c.saysInTheWhys)) { + t.Errorf("%s: served %v (%q), want %v saying %q", c.name, served, why, c.served, c.saysInTheWhys) + } + } + + // The control-node with execute withheld and agents of their own account: nothing is said. + f := agentRootFacts{Machine: "anchor", Trusted: []string{"telegram"}, Runtime: "ops", Agent: "agents", AgentNamed: true, + Answers: map[string]escalation{"ops": {Root: true, Why: "NOPASSWD"}, "agents": {Why: "none"}}} + f.LoginShell, f.LoginShellWhy = loginShellServed("zsh", true, val("withhold"), false, true) + if got := agentRootObservations(f); len(got) != 0 { + t.Errorf("execute withheld and agents confined: %+v", got) + } + // Withheld in the setting, still answered on the bus: said, with why. + f.LoginShell, f.LoginShellWhy = loginShellServed("zsh", true, val("withhold"), true, true) + if got := agentRootObservations(f); len(got) != 1 || !strings.Contains(got[0].Summary, "the bus hears execute answered there") { + t.Errorf("execute still answered: %+v", got) + } +} diff --git a/cmd/mesh-controller/probes.go b/cmd/mesh-controller/probes.go index 6e034d07..6f84ef68 100644 --- a/cmd/mesh-controller/probes.go +++ b/cmd/mesh-controller/probes.go @@ -637,31 +637,8 @@ const ( // discoverHolders asks the bus's discovery who serves what, and answers seat → machine for every // endpoint a seat's verb is served on. func discoverHolders(ctx context.Context, conn *nats.Conn) (map[string]map[string]bool, error) { - inbox := conn.NewRespInbox() - sub, err := conn.SubscribeSync(inbox) - if err != nil { - return nil, err - } - defer func() { _ = sub.Unsubscribe() }() - if err := conn.PublishRequest("$SRV.INFO", inbox, nil); err != nil { - return nil, fmt.Errorf("asking the bus who serves what: %w", err) - } out := map[string]map[string]bool{} - deadline := time.Now().Add(discoveryPatience) - for time.Now().Before(deadline) { - wait, cancel := context.WithTimeout(ctx, discoveryQuiet) - msg, err := sub.NextMsgWithContext(wait) - cancel() - if err != nil { - if ctx.Err() != nil { - return nil, ctx.Err() - } - break - } - var info micro.Info - if json.Unmarshal(msg.Data, &info) != nil { - continue - } + err := discoverServices(ctx, conn, func(info micro.Info) { for _, e := range info.Endpoints { seat, node := e.Metadata["seat"], e.Metadata["node"] if seat == "" { @@ -680,8 +657,69 @@ func discoverHolders(ctx context.Context, conn *nats.Conn) (map[string]map[strin out[info.Name] = map[string]bool{} } out[info.Name][info.ID] = true + }) + return out, err +} + +// discoverSeatVerbs asks the bus's discovery the same, one level finer: seat → verb → machine, for every +// seat verb answered (an endpoint's `tool` is its verb). A seat held where a verb is withheld (novox/hq ADR +// 0268) shows the seat and not that verb. +func discoverSeatVerbs(ctx context.Context, conn *nats.Conn) (map[string]map[string]map[string]bool, error) { + out := map[string]map[string]map[string]bool{} + err := discoverServices(ctx, conn, func(info micro.Info) { + for _, e := range info.Endpoints { + seat, verb, node := e.Metadata["seat"], e.Metadata["tool"], e.Metadata["node"] + if seat == "" || verb == "" { + continue + } + if node == "" { + node = info.ID + } + if out[seat] == nil { + out[seat] = map[string]map[string]bool{} + } + if out[seat][verb] == nil { + out[seat][verb] = map[string]bool{} + } + out[seat][verb][node] = true + } + }) + return out, err +} + +// discoverServices asks the bus's discovery once and hands every service's answer to visit, waiting +// discoveryQuiet after the last and discoveryPatience at the most. +func discoverServices(ctx context.Context, conn *nats.Conn, visit func(micro.Info)) error { + if conn == nil { + return errors.New("the controller holds no connection to the bus") } - return out, nil + inbox := conn.NewRespInbox() + sub, err := conn.SubscribeSync(inbox) + if err != nil { + return err + } + defer func() { _ = sub.Unsubscribe() }() + if err := conn.PublishRequest("$SRV.INFO", inbox, nil); err != nil { + return fmt.Errorf("asking the bus who serves what: %w", err) + } + deadline := time.Now().Add(discoveryPatience) + for time.Now().Before(deadline) { + wait, cancel := context.WithTimeout(ctx, discoveryQuiet) + msg, err := sub.NextMsgWithContext(wait) + cancel() + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + break + } + var info micro.Info + if json.Unmarshal(msg.Data, &info) != nil { + continue + } + visit(info) + } + return nil } // probeArchives is D4: every archive the mesh keeps is held by its manifest in the artifact store. From 5fa8e40667c6bce5b94bc688d963ebacea988539 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 11:03:48 +0200 Subject: [PATCH 05/11] =?UTF-8?q?Raise=20agent-root=20where=20who=20can=20?= =?UTF-8?q?become=20root=20is=20not=20measured,=20so=20the=20router=20neve?= =?UTF-8?q?r=20reads=20a=20missing=20measure=20as=20a=20no=20(hq=20ADR=200?= =?UTF-8?q?259=20=C2=A78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A machine where the router or a verified channel runs and the sudo module is absent or does not answer made the probe fail to run, which raises nothing the router reads, so it went on approving there. Each such machine now raises the same urgent condition, saying it was not measured. --- cmd/mesh-controller/probe_agent_root.go | 44 ++++++++++++++++---- cmd/mesh-controller/probe_agent_root_test.go | 21 ++++++++++ 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/cmd/mesh-controller/probe_agent_root.go b/cmd/mesh-controller/probe_agent_root.go index 1c3ccccf..67e9b5be 100644 --- a/cmd/mesh-controller/probe_agent_root.go +++ b/cmd/mesh-controller/probe_agent_root.go @@ -32,8 +32,10 @@ import ( // or the bus's discovery hearing the verb answered there — so a withheld setting the machine has not acted // on yet, or a holder answering against its setting, is never taken for closed. // -// The measurement is the sudo module's on that machine (`sudo_escalation`); a machine it does not run on, or -// that does not answer, is a probe that could not run — said, never taken for "no". +// The measurement is the sudo module's on that machine (`sudo_escalation`). **What cannot be measured is not a +// pass**: a machine it does not run on, or that does not answer, raises the same urgent condition, saying it +// was not measured — the router reads the condition, and a probe that merely failed to run would leave it +// approving there (hq ADR 0259 §8, as amended on 2026-10-09). // kindAgentRoot is the condition an agent able to become root where a trusted party runs raises. const kindAgentRoot = "agent-root" @@ -183,10 +185,8 @@ func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, e // Whether the login shell's execute is served where a trusted party runs (novox/hq ADR 0268): the // holder's setting for that machine, and what the bus hears answered there. A discovery that could not be // asked leaves only the setting, which is said: the probe then cannot show the verb withheld. + // A discovery that could not be asked counts execute as served (loginShellServed), and says so. heard, derr := discoverSeatVerbs(ctx, d.js.Conn()) - if derr != nil { - unread = append(unread, "the bus's discovery could not be asked whether the login shell's execute is served: "+derr.Error()) - } for _, e := range entries { if !e.Manifest.ClaimsSeat(loginShellSeat) { continue @@ -200,8 +200,7 @@ func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, e if _, declared := e.Manifest.Settings[loginShellVerb]; declared { layers, err := inv.SettingsFor(ctx, node, e.Manifest.Module) if err != nil { - unread = append(unread, node+": "+e.Manifest.Module+"'s settings could not be read: "+err.Error()) - f.LoginShell, f.LoginShellWhy = true, "its holder's setting could not be read" + f.LoginShell, f.LoginShellWhy = true, e.Manifest.Module+"'s setting there could not be read: "+err.Error() continue } for _, s := range catalogue.Effective(e.Manifest, layers) { @@ -265,8 +264,35 @@ func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, e } out = append(out, agentRootObservations(*f)...) } - if len(unread) > 0 { - return out, fmt.Errorf("who can become root could not be measured: %s", strings.Join(unread, "; ")) + // Each machine whose measure failed is said as not measured, the same condition: never a pass. + for _, m := range machines { + var why []string + for _, u := range unread { + if strings.HasPrefix(u, m+": ") { + why = append(why, strings.TrimPrefix(u, m+": ")) + } + } + if len(why) > 0 { + out = append(out, agentRootUnmeasured(*facts[m], strings.Join(why, "; "))) + } } return out, nil } + +// agentRootUnmeasured is the condition of a machine where a trusted party runs and who can become root was +// not measured: urgent, like a measured yes, because the router believes a proof only where it is a no. +func agentRootUnmeasured(f agentRootFacts, why string) conditions.Observation { + trusted := append([]string(nil), f.Trusted...) + sort.Strings(trusted) + return conditions.Observation{Scope: conditions.ScopeMachine, ID: f.Machine, Token: kindAgentRoot, + Machine: f.Machine, Kind: kindAgentRoot, Severity: conditions.Urgent, + Summary: fmt.Sprintf("whether an agent can become root on %s without a person is not measured, where %s "+ + "run as accounts of their own; until it is, the router approves nothing proven there (novox/hq ADR "+ + "0259 §8): %s", f.Machine, strings.Join(trusted, ", "), why), + Headline: "Root not checked on " + f.Machine, + Needs: "let the mesh check who can become root on " + f.Machine + ": assign the sudo module there, or bring it back.", + Explanation: "The modules that prove your answers from your phone run on " + f.Machine + ", and the mesh " + + "could not check whether a program working for you there can become root without asking you. Until " + + "it can, answers from your phone can only acknowledge.", + Resolved: "The mesh checks who can become root on " + f.Machine + " again"} +} diff --git a/cmd/mesh-controller/probe_agent_root_test.go b/cmd/mesh-controller/probe_agent_root_test.go index 247e69f3..608c02bf 100644 --- a/cmd/mesh-controller/probe_agent_root_test.go +++ b/cmd/mesh-controller/probe_agent_root_test.go @@ -100,3 +100,24 @@ func TestTheLoginShellCountsOnlyWhereExecuteIsServed(t *testing.T) { t.Errorf("execute still answered: %+v", got) } } + +// hq ADR 0259 §8 as amended on 2026-10-09: a machine where a trusted party runs and who can become root is not +// measured raises the same urgent condition, so the router, which reads the condition, approves nothing there. +func TestRootNotMeasuredWhereTheRouterRunsIsSaidAndNeverAPass(t *testing.T) { + o := agentRootUnmeasured(agentRootFacts{Machine: "anchor", Trusted: []string{"telegram", "messenger"}}, + "the sudo module is not assigned there, so who can become root is not measured") + if o.Kind != kindAgentRoot || o.Severity != conditions.Urgent || o.Machine != "anchor" || + !strings.Contains(o.Summary, "not measured") || !strings.Contains(o.Summary, "messenger, telegram") { + t.Errorf("%+v", o) + } + if why, ok := conditions.PlainWords(conditions.Words{Headline: o.Headline, Explanation: o.Explanation, + Needs: o.Needs, Resolved: o.Resolved}, "anchor"); !ok { + t.Errorf("not plain: %s", why) + } + // The same key as a measured yes, so the router's one rule reads both. + measured := agentRootObservations(agentRootFacts{Machine: "anchor", Trusted: []string{"telegram"}, Runtime: "ops", + Agent: "ops", Answers: map[string]escalation{"ops": {Root: true, Why: "x"}}})[0] + if o.Key() != measured.Key() { + t.Errorf("keys differ: %s, %s", o.Key(), measured.Key()) + } +} From e2a45b18ba036caa437b444f4974a12d8dea0639 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 12:03:28 +0200 Subject: [PATCH 06/11] =?UTF-8?q?Refuse=20a=20module=20of=20its=20own=20ac?= =?UTF-8?q?count=20running=20as=20the=20node's=20operator=20or=20agent=20a?= =?UTF-8?q?ccount=20(hq=20ADR=200259=20=C2=A78,=20review=20L4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/catalogue/runtime.go | 10 ++++++++++ internal/catalogue/runtime_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/internal/catalogue/runtime.go b/internal/catalogue/runtime.go index d0e3bc32..b7f3fbe4 100644 --- a/internal/catalogue/runtime.go +++ b/internal/catalogue/runtime.go @@ -266,6 +266,16 @@ func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) { program := runtime.Bundles[0] var out []map[string]any for _, m := range own { + // Never the node's operator account, nor the account agents run as there (the review of 2026-10-09): + // either would hand what it holds back to the very accounts it is kept from. + switch { + case r.Account != "" && m.RunsAs == r.Account: + return nil, fmt.Errorf("%s runs as %s, the operator's account on %s: a module of its own account never "+ + "runs as it (novox/hq ADR 0259 §8)", m.Module, m.RunsAs, r.Node) + case r.AgentAccount != "" && m.RunsAs == r.AgentAccount: + return nil, fmt.Errorf("%s runs as %s, the account agents run as on %s: a module of its own account "+ + "never runs as it (novox/hq ADR 0259 §8)", m.Module, m.RunsAs, r.Node) + } credential, declared := m.OwnSecrets["broker"] if !declared { return nil, fmt.Errorf("%s runs as its own account and declares no own secret broker", m.Module) diff --git a/internal/catalogue/runtime_test.go b/internal/catalogue/runtime_test.go index cba8b954..9c16bdb5 100644 --- a/internal/catalogue/runtime_test.go +++ b/internal/catalogue/runtime_test.go @@ -503,3 +503,29 @@ func TestAModuleOfItsOwnAccountIsServedByARuntimeOfItsOwn(t *testing.T) { t.Error("a module of its own account composed without a runtime program") } } + +// The review of 2026-10-09 (L4): a module of its own account never runs as the node's operator account, nor +// as the account agents run as there — either would hand what it holds back to the accounts it is kept from. +func TestAModuleOfItsOwnAccountIsRefusedTheOperatorsAndTheAgentsAccount(t *testing.T) { + with := Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}, "telegram": {"broker": "own"}}} + goRuntime := Manifest{Module: RuntimeModule, Version: "1", + OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}}, + Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "go", + System: "arch", From: "cmd/node-tools"}}}} + goRuntime, err := goRuntime.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) + if err != nil { + t.Fatal(err) + } + for _, account := range []string{"ops", "agent"} { + telegram := aToolsModule(t, "telegram", "tools/index.js") + telegram.RunsAs, telegram.SecretsOwner = account, account + telegram.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}} + _, err := Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent", + Modules: []Manifest{telegram, goRuntime}}.ownRuntimes(with) + if err == nil || !strings.Contains(err.Error(), account) { + t.Errorf("telegram running as %s was composed: %v", account, err) + } + } +} From 4c375dafedb4360ea6e999deceecda87c5c90c9d Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 12:12:32 +0200 Subject: [PATCH 07/11] =?UTF-8?q?Judge=20a=20machine=20root-free=20only=20?= =?UTF-8?q?on=20a=20positive,=20fresh=20measure,=20and=20believe=20a=20ver?= =?UTF-8?q?ified=20sender=20only=20there=20(hq=20ADR=200259=20=C2=A78,=20r?= =?UTF-8?q?eview=20H2/H3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent-root probe read the sudo module's answer, given in the machine's runtime as the very account an agent could become, and took a missing account, a missing answer or no accounts as a pass. One judgement now decides: the machine names an agent account its node-engine judged unable to become root within 15 minutes (agentConfined, mesh-controller #164), and the login shell's execute is not served there; anything not read is not free. The probe raises agent-root on it, the new root-free verb answers it live for the router, and a push composes verified-sender for a kind only while its machine and the router's pass it. --- cmd/mesh-controller/probe_agent_root.go | 489 ++++++++++--------- cmd/mesh-controller/probe_agent_root_test.go | 208 +++++--- cmd/mesh-controller/push.go | 5 +- cmd/mesh-controller/seatverbs.go | 10 +- internal/broker/membership.go | 28 +- internal/broker/seattraffic_test.go | 60 ++- internal/broker/users.go | 4 + internal/catalogue/verbs.go | 9 + module.json | 1 + 9 files changed, 509 insertions(+), 305 deletions(-) diff --git a/cmd/mesh-controller/probe_agent_root.go b/cmd/mesh-controller/probe_agent_root.go index 67e9b5be..5042fa22 100644 --- a/cmd/mesh-controller/probe_agent_root.go +++ b/cmd/mesh-controller/probe_agent_root.go @@ -2,51 +2,54 @@ package main import ( "context" - "encoding/json" "fmt" "slices" "sort" "strings" "time" + "github.com/nats-io/nats.go" + "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/conditions" - "github.com/novox/mesh-controller/internal/link" + "github.com/novox/mesh-controller/internal/inventory" ) -// The self-check's probe of who can become root where the trusted parties run (novox/hq ADR 0259 §8, rule 3). +// Who can become root where the trusted parties run (novox/hq ADR 0259 §8, as reviewed on 2026-10-09). // // The router and every channel proving its sender run as accounts of their own, so that no agent reads what -// they hold or speaks as them. **Root on their machine undoes all of it.** So on every machine where a module -// of its own account runs, this probe asks two questions, and raises an urgent condition while either is yes: +// they hold or speaks as them. **Root on their machine undoes all of it**, and so does an agent running as the +// operator's account there. A machine is **root-free** — an answer proven there may authorise — only when all +// of these are measured, now, and hold: // -// 1. can an account an agent runs as become root without a person there — passwordless sudo, or a group -// that is root by another name (docker, disk)? The agent's account is the one the coding-agent module on -// that machine names (`agent_account`), and the operator's account while it names none; -// 2. can an agent run a command it chooses, through the mesh's own tools, as an account that can — the login -// shell's `execute` runs as the machine's runtime account, which the mesh's acting tools give passwordless -// sudo? **Only where `execute` is served** (novox/hq ADR 0268): `execute` is an optional verb its holder -// withholds on a machine whose `execute` setting is not `serve`. The holder's seat being held there says -// nothing; whether the verb is served does. It counts as served while either says so — the holder's -// setting as the controller resolves it for that machine (a withheld value not yet pushed is still served), -// or the bus's discovery hearing the verb answered there — so a withheld setting the machine has not acted -// on yet, or a holder answering against its setting, is never taken for closed. +// 1. the machine names an account agents run as (novox/hq ADR 0266), so no agent runs as the operator's +// account, which may become root; +// 2. its node-engine — running as root, which no agent controls — judged that account unable to become root +// without a person, in a statement heard within the last 15 minutes (agentConfined, judgedConfined); +// 3. the login shell's `execute` is not served there (novox/hq ADR 0268): its holder's setting withholds it +// **and** the bus was asked and heard no `execute` answered there. `execute` runs commands as the machine's +// runtime account, which the mesh's acting tools give passwordless sudo; that account is taken to become +// root, always, so no measure of it is asked. // -// The measurement is the sudo module's on that machine (`sudo_escalation`). **What cannot be measured is not a -// pass**: a machine it does not run on, or that does not answer, raises the same urgent condition, saying it -// was not measured — the router reads the condition, and a probe that merely failed to run would leave it -// approving there (hq ADR 0259 §8, as amended on 2026-10-09). +// **Nothing else is a pass.** A machine that names no agent account, an unknown machine, a store or bus that +// could not be read, a verdict stale or absent — each is not root-free, and says why. The sudo module's own +// measure is no longer part of this judgement: it ran in the machine's runtime, as the very account an agent +// could become, so it could not be believed. +// +// The one judgement (judgeRoot) is read two ways: the self-check raises `agent-root` on every machine where +// the router or a module of its own account runs and the judgement fails; and the `root-free` verb answers it +// live, to the router, which honours a verified sender only on its pass. A machine holding the operator's +// graphical session, where a messaging client's desktop app may run, is not judged here: the operator accepted +// that gap for now (hq issue 344). -// kindAgentRoot is the condition an agent able to become root where a trusted party runs raises. +// kindAgentRoot is the condition a trusted party's machine that is not root-free raises. const kindAgentRoot = "agent-root" -// The tools the probe asks, of the modules on each machine. +// routerSeat is the seat the router holds: where it runs counts as a trusted party's machine. +const routerSeat = "operator-channel" + const ( - agentModule = "claude-code" - agentStatusTool = "claude_code_status" - sudoModule = "sudo" - sudoEscalation = "sudo_escalation" - loginShellSeat = "node-login-shell" + loginShellSeat = "node-login-shell" // loginShellVerb is the seat's verb that runs a command, and the name of the setting its holder withholds // it by (novox/hq ADR 0268). loginShellVerb = "execute" @@ -57,7 +60,7 @@ const ( // loginShellServed judges whether the login shell's execute is served on a machine, failing closed (novox/hq // ADR 0268): served while the holder's setting there is `serve` (or the holder has no such setting and claims // the verb), or while the bus heard the verb answered there, or while the bus could not be asked. why says -// which, in words, for the condition. +// which, in words. func loginShellServed(holder string, claims bool, setting *any, heard, asked bool) (bool, string) { var why []string switch { @@ -76,6 +79,152 @@ func loginShellServed(holder string, claims bool, setting *any, heard, asked boo return len(why) > 0, strings.Join(why, "; ") } +// rootFacts is what the judgement reads of one machine. +type rootFacts struct { + Machine string + // Unread is every read that failed, in words: any one is a fail. + Unread []string + // AgentNamed is whether the machine names an agent account; Confined whether its node-engine judged it + // unable to become root, freshly; ConfinedWhy the judgement's words either way. + AgentNamed bool + Confined bool + ConfinedWhy string + // Execute is whether the login shell's execute is served there; ExecuteWhy why, in words. + Execute bool + ExecuteWhy string +} + +// rootVerdict is the judgement on one machine, as the root-free verb answers it. +type rootVerdict struct { + Machine string `json:"machine"` + Free bool `json:"free"` + Why string `json:"why"` + Judged time.Time `json:"judged"` +} + +// judgeRoot is the one judgement: free only when nothing failed to read, an agent account is named and judged +// confined, and execute is not served. +func judgeRoot(f rootFacts, now time.Time) rootVerdict { + v := rootVerdict{Machine: f.Machine, Judged: now.UTC()} + var not []string + if len(f.Unread) > 0 { + not = append(not, "not measured: "+strings.Join(f.Unread, "; ")) + } + switch { + case f.ConfinedWhy == "": + // Not read (said above), or nothing said of it: never a pass. + if len(f.Unread) == 0 { + not = append(not, "whether agents there can become root was not judged") + } + case !f.AgentNamed: + not = append(not, "agents run as the operator's account there, which may become root ("+f.ConfinedWhy+")") + case !f.Confined: + not = append(not, f.ConfinedWhy) + } + if f.Execute { + not = append(not, "the login shell runs any command an agent gives it as the machine's runtime account, "+ + "which can become root ("+orNoneKnown(f.ExecuteWhy)+")") + } + if len(not) > 0 { + v.Why = strings.Join(not, "; ") + return v + } + v.Free = true + v.Why = f.ConfinedWhy + "; the login shell's execute is not served there" + return v +} + +// rootReader reads the facts of machines live: the catalogue's placements, the node-engine's verdicts and the +// bus's discovery, each once per reader. +type rootReader struct { + entries []inventory.Entry + read error + heard map[string]map[string]map[string]bool + asked error + // confined is agentConfined; settings the login shell holder's settings on a machine. Replaceable in a test. + confined func(ctx context.Context, node string) (named, confined bool, why string, err error) + settings func(ctx context.Context, node, module string) ([]catalogue.Layer, error) +} + +func newRootReader(ctx context.Context, inv *inventory.Inventory, conn *nats.Conn) *rootReader { + r := &rootReader{} + r.entries, r.read = inv.Catalogued(ctx) + if conn == nil { + r.asked = fmt.Errorf("this process holds no connection to the bus") + } else { + r.heard, r.asked = discoverSeatVerbs(ctx, conn) + } + r.confined = func(ctx context.Context, node string) (bool, bool, string, error) { + return agentConfined(ctx, inv, node) + } + r.settings = inv.SettingsFor + return r +} + +// facts reads one machine. +func (r *rootReader) facts(ctx context.Context, machine string) rootFacts { + f := rootFacts{Machine: machine} + if r.read != nil { + f.Unread = append(f.Unread, "the catalogue's placements could not be read: "+r.read.Error()) + } + named, confined, why, err := r.confined(ctx, machine) + if err != nil { + f.Unread = append(f.Unread, "the account agents run as could not be read: "+err.Error()) + } else { + f.AgentNamed, f.Confined, f.ConfinedWhy = named, confined, why + } + f.Execute, f.ExecuteWhy = r.executeServed(ctx, machine) + return f +} + +// executeServed is whether the login shell's execute is served on a machine, failing closed: the bus not +// asked, the placements not read, or a holder's setting not read, is served. +func (r *rootReader) executeServed(ctx context.Context, machine string) (bool, string) { + heard := r.heard[loginShellSeat][loginShellVerb][machine] + asked := r.asked == nil + var whys []string + served := false + holders := 0 + for _, e := range r.entries { + if !e.Manifest.ClaimsSeat(loginShellSeat) || !slices.Contains(e.On, machine) { + continue + } + holders++ + var setting *any + if _, declared := e.Manifest.Settings[loginShellVerb]; declared { + layers, err := r.settings(ctx, machine, e.Manifest.Module) + if err != nil { + served = true + whys = append(whys, e.Manifest.Module+"'s setting there could not be read: "+err.Error()) + continue + } + for _, s := range catalogue.Effective(e.Manifest, layers) { + if s.Key == loginShellVerb { + v := s.Value + setting = &v + } + } + } + if s, why := loginShellServed(e.Manifest.Module, claimServes(e.Manifest, loginShellSeat, loginShellVerb), + setting, heard, asked); s { + served = true + whys = append(whys, why) + } + } + if holders == 0 { + // Nobody is assigned to serve it; the bus must still hear nobody answering it. + if s, why := loginShellServed("no holder", false, nil, heard, asked); s { + served = true + whys = append(whys, why) + } + } + if r.read != nil { + served = true + whys = append(whys, "who holds the login shell there could not be read") + } + return served, strings.Join(whys, "; ") +} + // claimServes says whether a manifest's claim of a seat names a verb among those it serves. func claimServes(m catalogue.Manifest, seat, verb string) bool { for _, c := range m.Claims { @@ -86,213 +235,109 @@ func claimServes(m catalogue.Manifest, seat, verb string) bool { return false } -// escalation is the sudo module's answer for one account. -type escalation struct { - Account string `json:"account"` - Root bool `json:"root_without_a_person"` - Why string `json:"why"` +// judgeRootFree is the root-free verb's answer: each named machine judged now. It never fails: what could not +// be read is a machine not free, saying so. +func judgeRootFree(ctx context.Context, r *rootReader, machines []string, now time.Time) []rootVerdict { + out := make([]rootVerdict, 0, len(machines)) + for _, m := range machines { + out = append(out, judgeRoot(r.facts(ctx, m), now)) + } + return out } -// agentRootFacts is what one machine says, as the probe reads it. -type agentRootFacts struct { - Machine string - Trusted []string // the modules of their own account on it - Agent string // the account agents run as there; "" when none run there - AgentNamed bool // the coding-agent module named it, rather than it being taken for the operator's - Runtime string // the account the machine's runtime runs as - LoginShell bool // the login shell's execute is served there, running commands as the runtime's account - // LoginShellWhy is why execute counts as served there, in words: its holder's setting, the bus, or both. - LoginShellWhy string - Answers map[string]escalation -} - -// agentRootObservations judges one machine's facts: an urgent condition while an agent can become root there -// without a person, one way or the other, naming which. -func agentRootObservations(f agentRootFacts) []conditions.Observation { - var ways []string - if f.Agent != "" { - if e := f.Answers[f.Agent]; e.Root { - named := "the operator's account, which agents run as while the coding-agent module names no other" - if f.AgentNamed { - named = "the account agents run as" - } - ways = append(ways, fmt.Sprintf("%s (%s) can become root without a person: %s", f.Agent, named, e.Why)) - } - } - if f.LoginShell && f.Runtime != "" { - if e := f.Answers[f.Runtime]; e.Root { - served := "" - if f.LoginShellWhy != "" { - served = " (" + f.LoginShellWhy + ")" - } - ways = append(ways, fmt.Sprintf("the login shell runs any command an agent gives it as %s%s, which can "+ - "become root without a person: %s", f.Runtime, served, e.Why)) - } - } - if len(ways) == 0 { - return nil - } - sort.Strings(f.Trusted) - return []conditions.Observation{{Scope: conditions.ScopeMachine, ID: f.Machine, Token: kindAgentRoot, - Machine: f.Machine, Kind: kindAgentRoot, Severity: conditions.Urgent, - Summary: fmt.Sprintf("an agent can become root on %s without a person, where %s run as accounts of their "+ - "own; until it cannot, the router approves nothing proven there (novox/hq ADR 0259 §8): %s", - f.Machine, strings.Join(f.Trusted, ", "), strings.Join(ways, "; ")), - Headline: "Root without you on " + f.Machine, - Needs: "choose how programs working for you on " + f.Machine + " stop becoming root without asking you.", - Explanation: "The modules that prove your answers from your phone run on " + f.Machine + ", and a program " + - "working for you there can become root without asking you, so it could answer in your name. Until " + - "that changes, answers from your phone can only acknowledge.", - Resolved: "Nothing on " + f.Machine + " becomes root without you any more"}} -} - -// probeAgentRoot is the probe: every machine a module of its own account runs on, judged. -func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, error) { - inv := d.open.inventory - entries, err := inv.Catalogued(ctx) - if err != nil { - return nil, err - } - facts := map[string]*agentRootFacts{} - agentOn, sudoOn := map[string]bool{}, map[string]bool{} +// trustedMachines are the machines where the router or a module of its own account runs, each with those +// modules. +func trustedMachines(entries []inventory.Entry) map[string][]string { + trusted := map[string][]string{} for _, e := range entries { for _, node := range e.On { - switch { - case e.Manifest.RunsAs != "": - f := facts[node] - if f == nil { - f = &agentRootFacts{Machine: node, Answers: map[string]escalation{}} - facts[node] = f - } - f.Trusted = append(f.Trusted, e.Manifest.Module) - case e.Manifest.Module == agentModule: - agentOn[node] = true - case e.Manifest.Module == sudoModule: - sudoOn[node] = true + if e.Manifest.RunsAs != "" || e.Manifest.ClaimsSeat(routerSeat) { + trusted[node] = append(trusted[node], e.Manifest.Module) } } } - machines := make([]string, 0, len(facts)) - for m := range facts { + return trusted +} + +// agentRootObservation is the condition of a trusted party's machine that is not root-free. +func agentRootObservation(v rootVerdict, trusted []string) conditions.Observation { + trusted = append([]string(nil), trusted...) + sort.Strings(trusted) + return conditions.Observation{Scope: conditions.ScopeMachine, ID: v.Machine, Token: kindAgentRoot, + Machine: v.Machine, Kind: kindAgentRoot, Severity: conditions.Urgent, + Summary: fmt.Sprintf("%s is not root-free, where %s run: until it is, the router approves nothing proven "+ + "there (novox/hq ADR 0259 §8): %s", v.Machine, strings.Join(trusted, ", "), v.Why), + Headline: "Phone answers held on " + v.Machine, + Needs: "give the programs working for you on " + v.Machine + " an account that cannot become root.", + Explanation: "The modules that prove your answers from your phone run on " + v.Machine + ", and the mesh " + + "cannot show that a program working for you there is unable to become root or to act as you. Until " + + "it can, answers from your phone can only acknowledge.", + Resolved: "Answers from your phone can approve again on " + v.Machine} +} + +// probeAgentRoot is the probe: every trusted party's machine, judged by the one judgement. +func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, error) { + var conn *nats.Conn + if d.js != nil { + conn = d.js.Conn() + } + r := newRootReader(ctx, d.open.inventory, conn) + if r.read != nil { + return nil, r.read + } + return rootObservations(ctx, r, trustedMachines(r.entries), time.Now()), nil +} + +// rootObservations judges the machines and says each that fails. +func rootObservations(ctx context.Context, r *rootReader, trusted map[string][]string, now time.Time) []conditions.Observation { + var machines []string + for m := range trusted { machines = append(machines, m) } sort.Strings(machines) var out []conditions.Observation - var unread []string - if len(machines) == 0 { - return nil, nil - } - // Whether the login shell's execute is served where a trusted party runs (novox/hq ADR 0268): the - // holder's setting for that machine, and what the bus hears answered there. A discovery that could not be - // asked leaves only the setting, which is said: the probe then cannot show the verb withheld. - // A discovery that could not be asked counts execute as served (loginShellServed), and says so. - heard, derr := discoverSeatVerbs(ctx, d.js.Conn()) - for _, e := range entries { - if !e.Manifest.ClaimsSeat(loginShellSeat) { - continue - } - for _, node := range e.On { - f := facts[node] - if f == nil { - continue - } - var setting *any - if _, declared := e.Manifest.Settings[loginShellVerb]; declared { - layers, err := inv.SettingsFor(ctx, node, e.Manifest.Module) - if err != nil { - f.LoginShell, f.LoginShellWhy = true, e.Manifest.Module+"'s setting there could not be read: "+err.Error() - continue - } - for _, s := range catalogue.Effective(e.Manifest, layers) { - if s.Key == loginShellVerb { - v := s.Value - setting = &v - } - } - } - f.LoginShell, f.LoginShellWhy = loginShellServed(e.Manifest.Module, claimServes(e.Manifest, loginShellSeat, loginShellVerb), - setting, heard[loginShellSeat][loginShellVerb][node], derr == nil) + for _, v := range judgeRootFree(ctx, r, machines, now) { + if !v.Free { + out = append(out, agentRootObservation(v, trusted[v.Machine])) } } - for _, m := range machines { - f := facts[m] - record, err := inv.NodeByName(ctx, m) - if err != nil { - unread = append(unread, m+": "+err.Error()) - continue - } - f.Runtime = record.Account - if agentOn[m] { - f.Agent = record.Account - if a, err := link.AskModuleToolOn(ctx, d.js.Conn(), agentModule, agentStatusTool, m, map[string]any{}, 10*time.Second); err == nil && a.Error == "" { - var status struct { - AgentAccount string `json:"agent_account"` - } - if json.Unmarshal(a.Result, &status) == nil && status.AgentAccount != "" { - f.Agent, f.AgentNamed = status.AgentAccount, true - } - } - } - if !sudoOn[m] { - unread = append(unread, m+": the sudo module is not assigned there, so who can become root is not measured") - continue - } - var accounts []string - for _, a := range []string{f.Agent, f.Runtime} { - if a != "" && !slices.Contains(accounts, a) { - accounts = append(accounts, a) - } - } - if len(accounts) == 0 { - continue - } - a, err := link.AskModuleToolOn(ctx, d.js.Conn(), sudoModule, sudoEscalation, m, map[string]any{"accounts": accounts}, 15*time.Second) - if err == nil && a.Error != "" { - err = fmt.Errorf("%s", a.Error) - } - if err != nil { - unread = append(unread, m+": "+err.Error()) - continue - } - var answers []escalation - if err := json.Unmarshal(a.Result, &answers); err != nil { - unread = append(unread, m+": the sudo module's answer could not be read: "+err.Error()) - continue - } - for _, e := range answers { - f.Answers[e.Account] = e - } - out = append(out, agentRootObservations(*f)...) - } - // Each machine whose measure failed is said as not measured, the same condition: never a pass. - for _, m := range machines { - var why []string - for _, u := range unread { - if strings.HasPrefix(u, m+": ") { - why = append(why, strings.TrimPrefix(u, m+": ")) - } - } - if len(why) > 0 { - out = append(out, agentRootUnmeasured(*facts[m], strings.Join(why, "; "))) - } - } - return out, nil + return out } -// agentRootUnmeasured is the condition of a machine where a trusted party runs and who can become root was -// not measured: urgent, like a measured yes, because the router believes a proof only where it is a no. -func agentRootUnmeasured(f agentRootFacts, why string) conditions.Observation { - trusted := append([]string(nil), f.Trusted...) - sort.Strings(trusted) - return conditions.Observation{Scope: conditions.ScopeMachine, ID: f.Machine, Token: kindAgentRoot, - Machine: f.Machine, Kind: kindAgentRoot, Severity: conditions.Urgent, - Summary: fmt.Sprintf("whether an agent can become root on %s without a person is not measured, where %s "+ - "run as accounts of their own; until it is, the router approves nothing proven there (novox/hq ADR "+ - "0259 §8): %s", f.Machine, strings.Join(trusted, ", "), why), - Headline: "Root not checked on " + f.Machine, - Needs: "let the mesh check who can become root on " + f.Machine + ": assign the sudo module there, or bring it back.", - Explanation: "The modules that prove your answers from your phone run on " + f.Machine + ", and the mesh " + - "could not check whether a program working for you there can become root without asking you. Until " + - "it can, answers from your phone can only acknowledge.", - Resolved: "The mesh checks who can become root on " + f.Machine + " again"} +// rootClock is the clock the root-free verb judges by. +var rootClock = time.Now + +// rootFreeAnswer is the root-free verb: the named machines, each judged now by the serving controller. Only it +// answers: a process that is not serving says so, and a caller reads that as no machine free. +func rootFreeAnswer(ctx context.Context, machines string, now time.Time) (any, error) { + d := doctorFrom + if d == nil || d.open == nil || d.open.inventory == nil { + return nil, fmt.Errorf("this controller is not serving, so it judges no machine root-free: ask again, and " + + "the serving controller answers") + } + var names []string + for _, m := range strings.Split(machines, ",") { + if m = strings.TrimSpace(m); m != "" && !slices.Contains(names, m) { + names = append(names, m) + } + } + if len(names) == 0 { + return nil, fmt.Errorf("root-free judges the machines named, and none was") + } + var conn *nats.Conn + if d.js != nil { + conn = d.js.Conn() + } + return map[string]any{"machines": judgeRootFree(ctx, newRootReader(ctx, d.open.inventory, conn), names, now)}, nil +} + +// rootFreeNow is the machines judged root-free, for composing a push's memberships: only those that pass. +func rootFreeNow(ctx context.Context, r *rootReader, machines []string, now time.Time) map[string]bool { + free := map[string]bool{} + for _, v := range judgeRootFree(ctx, r, machines, now) { + if v.Free { + free[v.Machine] = true + } + } + return free } diff --git a/cmd/mesh-controller/probe_agent_root_test.go b/cmd/mesh-controller/probe_agent_root_test.go index 608c02bf..dbf6d275 100644 --- a/cmd/mesh-controller/probe_agent_root_test.go +++ b/cmd/mesh-controller/probe_agent_root_test.go @@ -1,66 +1,144 @@ package main import ( + "context" + "errors" "strings" "testing" + "time" + "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/conditions" + "github.com/novox/mesh-controller/internal/inventory" ) -// novox/hq ADR 0259 §8, rule 3: the probe fails today — agents run as the operator's account, which has -// passwordless sudo, and the login shell runs their commands as it — and passes only once neither holds. -func TestAnAgentAbleToBecomeRootWhereTheRouterRunsIsSaid(t *testing.T) { - root := escalation{Root: true, Why: "(ALL : ALL) NOPASSWD: ALL"} - none := escalation{Why: "no rule lets it without a password"} - base := func() agentRootFacts { - return agentRootFacts{Machine: "anchor", Trusted: []string{"telegram", "messenger"}, Runtime: "ops", - Answers: map[string]escalation{}} - } +var rootNow = time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC) - today := base() - today.Agent, today.LoginShell = "ops", true - today.Answers["ops"] = root - got := agentRootObservations(today) - if len(got) != 1 || got[0].Severity != conditions.Urgent || got[0].Kind != kindAgentRoot || - !strings.Contains(got[0].Summary, "the operator's account") || !strings.Contains(got[0].Summary, "the login shell") { - t.Fatalf("today: %+v", got) +// A machine is root-free only on a positive measure of each thing (the review of 2026-10-09, H2/H3): every +// read succeeded, an agent account is named and judged confined by the node-engine, execute is not served. +func TestRootFreeIsAPositiveMeasureAndNothingElse(t *testing.T) { + pass := rootFacts{Machine: "anchor", AgentNamed: true, Confined: true, + ConfinedWhy: "the agent account agents cannot become root without a person (judged 2026-10-09 12:00)"} + if v := judgeRoot(pass, rootNow); !v.Free || v.Machine != "anchor" || !v.Judged.Equal(rootNow) { + t.Fatalf("the one pass: %+v", v) } - - agentsMoved := base() - agentsMoved.Agent, agentsMoved.AgentNamed, agentsMoved.LoginShell = "agents", true, true - agentsMoved.Answers["agents"], agentsMoved.Answers["ops"] = none, root - if got := agentRootObservations(agentsMoved); len(got) != 1 || strings.Contains(got[0].Summary, "agents (") || - !strings.Contains(got[0].Summary, "the login shell") { - t.Errorf("agents of their own account, the login shell still the runtime's: %+v", got) + fails := map[string]func(*rootFacts){ + "a read failed": func(f *rootFacts) { f.Unread = []string{"the store did not answer"} }, + "no agent account named": func(f *rootFacts) { f.AgentNamed, f.Confined = false, false }, + "not confined": func(f *rootFacts) { f.Confined = false }, + "nothing said of it": func(f *rootFacts) { f.ConfinedWhy = "" }, + "execute served": func(f *rootFacts) { f.Execute, f.ExecuteWhy = true, "the bus hears execute answered there" }, + "confined, but agent read": func(f *rootFacts) { f.Unread, f.ConfinedWhy = []string{"x"}, "" }, } - - closed := base() - closed.Agent, closed.AgentNamed = "agents", true - closed.Answers["agents"], closed.Answers["ops"] = none, root - if got := agentRootObservations(closed); len(got) != 0 { - t.Errorf("agents of their own account and no login shell there: %+v", got) - } - - noAgents := base() - noAgents.Answers["ops"] = root - if got := agentRootObservations(noAgents); len(got) != 0 { - t.Errorf("no agent runs there and no login shell is held: %+v", got) + for name, mutate := range fails { + f := pass + mutate(&f) + if v := judgeRoot(f, rootNow); v.Free || v.Why == "" { + t.Errorf("%s: judged %+v", name, v) + } } } -// Its words are plain, as every condition's are (novox/hq ADR 0253). -func TestTheRootConditionIsSaidInPlainWords(t *testing.T) { - f := agentRootFacts{Machine: "anchor", Trusted: []string{"telegram"}, Runtime: "ops", Agent: "ops", - Answers: map[string]escalation{"ops": {Root: true, Why: "x"}}} - o := agentRootObservations(f)[0] +// The reader fails closed on every case the review named: the agent account read only where the coding-agent +// module runs (old :225), no account to measure taken for a pass (old :246), and a measure that did not answer +// taken for "not root" (old :114, :123). +func TestTheRootReaderFailsClosed(t *testing.T) { + shell := catalogue.Manifest{Module: "zsh", Claims: []catalogue.Claim{{Name: loginShellSeat, Serves: []string{"execute"}}}, + Settings: map[string]catalogue.SettingDeclaration{"execute": {Default: "withhold"}}} + reader := func() *rootReader { + return &rootReader{ + entries: []inventory.Entry{{Manifest: shell, On: []string{"anchor"}}}, + heard: map[string]map[string]map[string]bool{}, + confined: func(context.Context, string) (bool, bool, string, error) { + return true, true, "the agent account agents cannot become root without a person", nil + }, + settings: func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, nil }, + } + } + ctx := context.Background() + if v := judgeRootFree(ctx, reader(), []string{"anchor"}, rootNow)[0]; !v.Free { + t.Fatalf("the control: agents confined, execute withheld and unheard, everything read: %+v", v) + } + cases := map[string]func(*rootReader){ + "no agent account named, no coding-agent module there": func(r *rootReader) { + r.confined = func(context.Context, string) (bool, bool, string, error) { + return false, false, "anchor names no agent account: agents run as the operator account (ops)", nil + } + }, + "the node-engine's verdict not read": func(r *rootReader) { + r.confined = func(context.Context, string) (bool, bool, string, error) { + return false, false, "", errors.New("the store did not answer") + } + }, + "a stale verdict": func(r *rootReader) { + r.confined = func(context.Context, string) (bool, bool, string, error) { + return true, false, "the agent account agents is not judged: the machine's newest statement was heard at …", nil + } + }, + "the bus not asked": func(r *rootReader) { r.asked = errors.New("no bus") }, + "the placements not read": func(r *rootReader) { r.read = errors.New("no store") }, + "the holder's setting not read": func(r *rootReader) { + r.settings = func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, errors.New("no store") } + }, + "execute heard on the bus": func(r *rootReader) { + r.heard = map[string]map[string]map[string]bool{loginShellSeat: {"execute": {"anchor": true}}} + }, + "a holder that serves execute": func(r *rootReader) { + r.entries[0].Manifest.Settings = nil + }, + } + for name, mutate := range cases { + r := reader() + mutate(r) + if v := judgeRootFree(ctx, r, []string{"anchor"}, rootNow)[0]; v.Free { + t.Errorf("%s: judged free: %+v", name, v) + } + } + // A machine with no login shell holder at all: still the bus must hear none. + r := reader() + r.entries = nil + r.heard = map[string]map[string]map[string]bool{loginShellSeat: {"execute": {"anchor": true}}} + if v := judgeRootFree(ctx, r, []string{"anchor"}, rootNow)[0]; v.Free { + t.Errorf("execute answered by a module nobody assigned: %+v", v) + } +} + +// The probe says agent-root, by the same judgement, on each machine where the router or a module of its own +// account runs and that is not root-free; urgent and in plain words; nothing where every one is free. +func TestTheProbeSaysEachMachineThatIsNotRootFree(t *testing.T) { + entries := []inventory.Entry{ + {Manifest: catalogue.Manifest{Module: "telegram", RunsAs: "telegram"}, On: []string{"anchor"}}, + {Manifest: catalogue.Manifest{Module: "messenger", RunsAs: "messenger", + Claims: []catalogue.Claim{{Name: routerSeat}}}, On: []string{"anchor"}}, + {Manifest: catalogue.Manifest{Module: "xorg", Claims: []catalogue.Claim{{Name: catalogue.DisplayServerSeat}}}, + On: []string{"laptop"}}, + } + trusted := trustedMachines(entries) + if len(trusted["anchor"]) != 2 || len(trusted["laptop"]) != 0 { + t.Fatalf("trusted %v", trusted) + } + r := &rootReader{entries: entries, heard: map[string]map[string]map[string]bool{}, + confined: func(_ context.Context, node string) (bool, bool, string, error) { + return false, false, node + " names no agent account: agents run as the operator account (ops)", nil + }, + settings: func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, nil }} + got := rootObservations(context.Background(), r, trusted, rootNow) + if len(got) != 1 || got[0].Machine != "anchor" || got[0].Kind != kindAgentRoot || got[0].Severity != conditions.Urgent || + !strings.Contains(got[0].Summary, "messenger, telegram") || !strings.Contains(got[0].Summary, "names no agent account") { + t.Fatalf("said %+v", got) + } + o := got[0] if why, ok := conditions.PlainWords(conditions.Words{Headline: o.Headline, Explanation: o.Explanation, - Needs: o.Needs, Resolved: o.Resolved}, "anchor"); !ok { + Needs: o.Needs, Resolved: o.Resolved}, o.Machine); !ok { t.Errorf("not plain: %s", why) } + r.confined = func(context.Context, string) (bool, bool, string, error) { return true, true, "confined", nil } + if got := rootObservations(context.Background(), r, trusted, rootNow); len(got) != 0 { + t.Errorf("said of a free machine: %+v", got) + } } -// novox/hq ADR 0268: the login shell counts only where its execute is served, and fails closed — the holder's -// setting resolving to anything but serve and the bus hearing no execute there is the one way it is withheld. +// novox/hq ADR 0268: the login shell counts only where its execute is served, and fails closed. func TestTheLoginShellCountsOnlyWhereExecuteIsServed(t *testing.T) { val := func(v any) *any { return &v } cases := []struct { @@ -86,38 +164,24 @@ func TestTheLoginShellCountsOnlyWhereExecuteIsServed(t *testing.T) { t.Errorf("%s: served %v (%q), want %v saying %q", c.name, served, why, c.served, c.saysInTheWhys) } } - - // The control-node with execute withheld and agents of their own account: nothing is said. - f := agentRootFacts{Machine: "anchor", Trusted: []string{"telegram"}, Runtime: "ops", Agent: "agents", AgentNamed: true, - Answers: map[string]escalation{"ops": {Root: true, Why: "NOPASSWD"}, "agents": {Why: "none"}}} - f.LoginShell, f.LoginShellWhy = loginShellServed("zsh", true, val("withhold"), false, true) - if got := agentRootObservations(f); len(got) != 0 { - t.Errorf("execute withheld and agents confined: %+v", got) - } - // Withheld in the setting, still answered on the bus: said, with why. - f.LoginShell, f.LoginShellWhy = loginShellServed("zsh", true, val("withhold"), true, true) - if got := agentRootObservations(f); len(got) != 1 || !strings.Contains(got[0].Summary, "the bus hears execute answered there") { - t.Errorf("execute still answered: %+v", got) - } } -// hq ADR 0259 §8 as amended on 2026-10-09: a machine where a trusted party runs and who can become root is not -// measured raises the same urgent condition, so the router, which reads the condition, approves nothing there. -func TestRootNotMeasuredWhereTheRouterRunsIsSaidAndNeverAPass(t *testing.T) { - o := agentRootUnmeasured(agentRootFacts{Machine: "anchor", Trusted: []string{"telegram", "messenger"}}, - "the sudo module is not assigned there, so who can become root is not measured") - if o.Kind != kindAgentRoot || o.Severity != conditions.Urgent || o.Machine != "anchor" || - !strings.Contains(o.Summary, "not measured") || !strings.Contains(o.Summary, "messenger, telegram") { - t.Errorf("%+v", o) +// The root-free verb is the serving controller's alone, answered in its process and never as a command; a +// controller not serving answers an error, which the router reads as no machine free. +func TestRootFreeIsAnsweredOnlyByTheServingController(t *testing.T) { + was := doctorFrom + doctorFrom = nil + t.Cleanup(func() { doctorFrom = was }) + if _, err := rootFreeAnswer(context.Background(), "anchor", rootNow); err == nil { + t.Error("a controller not serving judged a machine") } - if why, ok := conditions.PlainWords(conditions.Words{Headline: o.Headline, Explanation: o.Explanation, - Needs: o.Needs, Resolved: o.Resolved}, "anchor"); !ok { - t.Errorf("not plain: %s", why) + if !inProcess["root-free"] { + t.Error("root-free is not answered in the serving process") } - // The same key as a measured yes, so the router's one rule reads both. - measured := agentRootObservations(agentRootFacts{Machine: "anchor", Trusted: []string{"telegram"}, Runtime: "ops", - Agent: "ops", Answers: map[string]escalation{"ops": {Root: true, Why: "x"}}})[0] - if o.Key() != measured.Key() { - t.Errorf("keys differ: %s, %s", o.Key(), measured.Key()) + if _, err := argvFor("root-free", map[string]any{"machines": "anchor"}); err == nil { + t.Error("root-free ran as a command") + } + if _, err := readArguments("root-free", map[string]any{"machines": []any{"anchor"}}); err == nil { + t.Error("root-free took its machines as a list; they are named in one text, separated by commas") } } diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index e1903da2..230f4611 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -1250,11 +1250,14 @@ func issueMemberships(ctx context.Context, open *stores, server *link.Server, se if err != nil { return err } - where := broker.PlacementsOf(records, records.Interchangeable) bus, ok := server.Bus().(link.OverNATS) if !ok { return nil } + // Which machines are root-free now (novox/hq ADR 0259 §8): a channel's verified sender is composed for the + // router only from one, beside a router on one. Judged once per push, by the root-free verb's judgement. + records.RootFree = rootFreeNow(ctx, newRootReader(ctx, open.inventory, bus.Conn), records.Nodes, time.Now()) + where := broker.PlacementsOf(records, records.Interchangeable) // **Every declared state's bucket, before the memberships that name it** (novox/hq ADR 0201). The // raise at start asserts them too, but a module registered and assigned since would otherwise have // its bucket only after the control plane next restarts — found the first time a module declared diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 039b84e4..f53cb1cd 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -282,6 +282,8 @@ func (a *verbArguments) commandLine() ([]string, error) { return nil, errors.New("tools is answered from the records, not by a command") case "dead-letters": return nil, errors.New("dead-letters is answered by the serving controller, on its own connection, not by a command") + case "root-free": + return nil, errors.New("root-free is judged by the serving controller, on its own connection, not by a command") case "status": return []string{"status", "--json"}, nil case "nodes": @@ -1093,6 +1095,9 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) { if verb == "dead-letters" { return deadLettersAnswer(ctx, a) } + if verb == "root-free" { + return rootFreeAnswer(ctx, a.given["machines"], rootClock()) + } if verb == "doctor" { // From the serving controller, which runs the self-check and hears the signals // (novox/hq to-be 45 §4): the last verdict at once, or a run now. @@ -1183,7 +1188,10 @@ func actsOnAPlan(args map[string]any) bool { var inProcess = map[string]bool{"tools": true, "calls": true, "doctor": true, // What a consumer gave up on, read and changed on the serving controller's own connection (novox/hq // issue 330). - "dead-letters": true} + "dead-letters": true, + // Whether a machine is root-free, judged live on the serving controller's store and connection (novox/hq ADR + // 0259 §8): the router asks it before an approval. + "root-free": true} // answersFirst is a command line whose caller is answered before it runs: a push, by its verb or // through `command`. A push sends the machine holding the bus first when its user list changed, the diff --git a/internal/broker/membership.go b/internal/broker/membership.go index 8b4a4537..b1bb81b2 100644 --- a/internal/broker/membership.go +++ b/internal/broker/membership.go @@ -2,6 +2,7 @@ package broker import ( "encoding/json" + "slices" "sort" "strings" ) @@ -167,12 +168,25 @@ func PlacementsOf(r Records, interchangeable map[string]bool) Placements { for _, nodes := range p.Nodes { sort.Strings(nodes) } + // Where the router runs: a verified sender is believed only while its machine is root-free too. A router + // placed nowhere, or on more than one machine, frees nothing. + var routerNodes []string + for node, declared := range r.Assigned { + for _, d := range declared { + for _, s := range d.Holds { + if s.Name == routerSeat && !slices.Contains(routerNodes, node) { + routerNodes = append(routerNodes, node) + } + } + } + } + routerFree := len(routerNodes) == 1 && r.RootFree[routerNodes[0]] for node, declared := range r.Assigned { for _, d := range declared { for _, s := range d.Holds { if s.Kinded && s.Kind != "" { p.Kinds = append(p.Kinds, KindHeld{Seat: s.Name, Kind: s.Kind, Module: d.Module, Node: node, - Capabilities: placedCapabilities(s.Capabilities, d.RunsAs)}) + Capabilities: placedCapabilities(s.Capabilities, d.RunsAs, routerFree && r.RootFree[node])}) } } } @@ -190,13 +204,19 @@ func PlacementsOf(r Records, interchangeable map[string]bool) Placements { return p } +// routerSeat is the seat the router of asks holds (novox/hq ADR 0259 §3). +const routerSeat = "operator-channel" + // placedCapabilities is what a kind's claim promises, as far as its placement lets the router believe it // (novox/hq ADR 0259 §8): `verified-sender` only from a holder that runs as an account of its own, on a bus -// account of its own — never one the machine's runtime carries as the operator's account. -func placedCapabilities(declared []string, runsAs string) []string { +// account of its own — never one the machine's runtime carries as the operator's account — and only while +// its machine and the router's were root-free when composed (rootFree; the review of 2026-10-09, H3). The +// membership carrying it is composed at a push, so it can outlive a pass that later fails: the router asks +// the controller's root-free verb again before it honours an approval, and that is the check that holds. +func placedCapabilities(declared []string, runsAs string, rootFree bool) []string { var out []string for _, c := range declared { - if c == "verified-sender" && runsAs == "" { + if c == "verified-sender" && (runsAs == "" || !rootFree) { continue } out = append(out, c) diff --git a/internal/broker/seattraffic_test.go b/internal/broker/seattraffic_test.go index c5b6944f..08110088 100644 --- a/internal/broker/seattraffic_test.go +++ b/internal/broker/seattraffic_test.go @@ -251,7 +251,7 @@ func TestTheRoutersMembershipNamesEveryKindAndItsCapabilities(t *testing.T) { records := Records{Nodes: []string{"anchor", "laptop"}, Assigned: map[string][]Declared{ "anchor": {router, {Module: "telegram", Holds: []Seat{tg}, RunsAs: "telegram"}}, "laptop": {{Module: "desk-channel", Holds: []Seat{desk}}}, - }} + }, RootFree: map[string]bool{"anchor": true}} where := PlacementsOf(records, nil) m := MembershipFor("anchor", router, where) if m.SeatTraffic == nil || len(m.SeatTraffic.Kinds) != 2 { @@ -329,12 +329,62 @@ func TestTheMachinesRuntimeNeverCarriesATrustedHolder(t *testing.T) { } } -// novox/hq ADR 0259 §8: verified-sender reaches the router only from a holder of its own account. +// novox/hq ADR 0259 §8: verified-sender reaches the router only from a holder of its own account, on a machine +// root-free when composed, with the router's own machine root-free too (the review of 2026-10-09, H3). func TestVerifiedSenderIsBelievedOnlyFromAHolderOfItsOwnAccount(t *testing.T) { - if got := placedCapabilities([]string{"choice", "verified-sender"}, ""); namesVerb(got, "verified-sender") { + if got := placedCapabilities([]string{"choice", "verified-sender"}, "", true); namesVerb(got, "verified-sender") { t.Errorf("a carried holder keeps verified-sender: %v", got) } - if got := placedCapabilities([]string{"choice", "verified-sender"}, "telegram"); !namesVerb(got, "verified-sender") { - t.Errorf("a holder of its own account lost verified-sender: %v", got) + if got := placedCapabilities([]string{"choice", "verified-sender"}, "telegram", true); !namesVerb(got, "verified-sender") { + t.Errorf("a holder of its own account on a root-free machine lost verified-sender: %v", got) + } + if got := placedCapabilities([]string{"choice", "verified-sender"}, "telegram", false); namesVerb(got, "verified-sender") || + !namesVerb(got, "choice") { + t.Errorf("a holder on a machine not root-free keeps verified-sender, or lost the rest: %v", got) + } +} + +// The kinds the router is told carry verified-sender only while the channel's machine and the router's are +// both root-free as composed; no record of a pass is no pass, and neither is a router placed nowhere. +func TestVerifiedSenderNeedsTheChannelsAndTheRoutersMachinesRootFree(t *testing.T) { + tg := channelSeat("telegram") + tg.Capabilities = []string{"choice", "verified-sender"} + router := Declared{Module: "messenger", Holds: []Seat{operatorChannel()}, RunsAs: "messenger"} + telegram := Declared{Module: "telegram", Holds: []Seat{tg}, RunsAs: "telegram"} + verified := func(r Records) bool { + for _, k := range PlacementsOf(r, nil).Kinds { + if k.Kind == "telegram" { + return namesVerb(k.Capabilities, "verified-sender") + } + } + t.Fatal("telegram not placed") + return false + } + same := func(free map[string]bool) Records { + return Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {router, telegram}}, RootFree: free} + } + apart := func(free map[string]bool) Records { + return Records{Nodes: []string{"anchor", "relay"}, + Assigned: map[string][]Declared{"anchor": {router}, "relay": {telegram}}, RootFree: free} + } + if !verified(same(map[string]bool{"anchor": true})) { + t.Error("both on one root-free machine: verified-sender withheld") + } + if verified(same(nil)) { + t.Error("no record of a pass, and verified-sender kept") + } + if verified(apart(map[string]bool{"relay": true})) { + t.Error("the router's machine not root-free, and verified-sender kept") + } + if verified(apart(map[string]bool{"anchor": true})) { + t.Error("the channel's machine not root-free, and verified-sender kept") + } + if !verified(apart(map[string]bool{"anchor": true, "relay": true})) { + t.Error("both machines root-free: verified-sender withheld") + } + noRouter := Records{Nodes: []string{"relay"}, Assigned: map[string][]Declared{"relay": {telegram}}, + RootFree: map[string]bool{"relay": true}} + if verified(noRouter) { + t.Error("no router placed, and verified-sender kept") } } diff --git a/internal/broker/users.go b/internal/broker/users.go index 83924b60..adddd6e9 100644 --- a/internal/broker/users.go +++ b/internal/broker/users.go @@ -70,6 +70,10 @@ type Records struct { // Interchangeable is each module whose definition says its instances are the same anywhere // (ADR 0160), which decides whether the module's plain subject is issued to every instance. Interchangeable map[string]bool + // RootFree is each machine judged root-free when this was composed (novox/hq ADR 0259 §8): it names an + // account agents run as, judged unable to become root by its node-engine, and serves no login shell + // execute. A machine absent is not free: no record of a pass is no pass. + RootFree map[string]bool } // Users is every user the composed file should contain, in the order it will be written. diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 6f2525fa..eccd04dd 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -429,6 +429,15 @@ var ControllerVerbs = []Verb{ "cause": "with consumer or older-than: the cause in a word (cleanup-waiting when absent)", }, nil, "confirm")}, // 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 " + + "there can become root without a person. Judged when asked, never from a condition: free only when the machine " + + "names an account its agents run as, its node-engine judged that account unable to become root within the " + + "last 15 minutes, and the login shell's execute is not served there. Anything else, a read that failed " + + "included, is not free and says why. The router asks it before an answer from a channel proving its sender " + + "may approve. Only reads.", + Input: schema(map[string]string{ + "machines": "the machines to judge, separated by commas", + }, []string{"machines"})}, {Name: "dead-letters", Description: "Every message a consumer on the bus gave up on after handing it over " + "as often as it may, kept in DEAD_LETTERS: whose consumer, the subject, how often it was handed over and " + "when it was given up, newest first. With id: that one whole, with what it said. With deliver: hand it " + diff --git a/module.json b/module.json index ca6f2374..89f35a66 100644 --- a/module.json +++ b/module.json @@ -72,6 +72,7 @@ "retire", "cleanup", "dead-letters", + "root-free", "data", "build", "artifacts", From 0e46b8302d0d5f39f15edccab610bd1c13bb282f Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 12:24:52 +0200 Subject: [PATCH 08/11] Let root-free take its machines as a list, as the router names them The router's contract names the machines as a JSON array. A verb's argument declared a list now takes an array of names (or one text separated by commas), and refuses anything else in it. --- cmd/mesh-controller/probe_agent_root_test.go | 15 ++++++++++++-- cmd/mesh-controller/seatverbs.go | 21 ++++++++++++++++++++ internal/catalogue/verbs.go | 21 +++++++++++++++++--- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/cmd/mesh-controller/probe_agent_root_test.go b/cmd/mesh-controller/probe_agent_root_test.go index dbf6d275..3a8da86e 100644 --- a/cmd/mesh-controller/probe_agent_root_test.go +++ b/cmd/mesh-controller/probe_agent_root_test.go @@ -181,7 +181,18 @@ func TestRootFreeIsAnsweredOnlyByTheServingController(t *testing.T) { if _, err := argvFor("root-free", map[string]any{"machines": "anchor"}); err == nil { t.Error("root-free ran as a command") } - if _, err := readArguments("root-free", map[string]any{"machines": []any{"anchor"}}); err == nil { - t.Error("root-free took its machines as a list; they are named in one text, separated by commas") + // The router names its machines as a list (its contract with this verb); one text separated by commas is + // the same; anything else in the list is refused. + for _, given := range []any{[]any{"anchor", "relay"}, "anchor, relay"} { + a, err := readArguments("root-free", map[string]any{"machines": given}) + if err != nil || a.given["machines"] != "anchor,relay" && a.given["machines"] != "anchor, relay" { + t.Errorf("root-free given %v read %v (%v)", given, a, err) + } + } + if _, err := readArguments("root-free", map[string]any{"machines": []any{"anchor", 7}}); err == nil { + t.Error("root-free took a number for a machine") + } + if _, err := readArguments("status", map[string]any{"machines": []any{"anchor"}}); err == nil { + t.Error("a verb that takes no list took one") } } diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index f53cb1cd..c22a6cf0 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -114,6 +114,14 @@ func declaredArguments(v catalogue.Verb) (names []string, switches map[string]bo return names, switches } +// isList says a verb's argument is declared a list of text (catalogue's listed): given as a JSON array, it is +// read as its items joined by commas, as the same argument given as one text would be. +func isList(v catalogue.Verb, name string) bool { + props, _ := v.Input["properties"].(map[string]any) + p, _ := props[name].(map[string]any) + return p != nil && p["type"] == "array" +} + // readArguments refuses what the verb does not take, before anything is composed. func readArguments(verb string, args map[string]any) (*verbArguments, error) { v, known := controllerVerb(verb) @@ -150,6 +158,19 @@ func readArguments(verb string, args map[string]any) (*verbArguments, error) { return nil, fmt.Errorf("%s: %q is text, not true or false", verb, k) } value = fmt.Sprint(x) + case []any: + if !isList(v, k) { + return nil, fmt.Errorf("%s: %q is text, and was given a list", verb, k) + } + items := make([]string, 0, len(x)) + for _, item := range x { + text, ok := item.(string) + if !ok || strings.TrimSpace(text) == "" || strings.Contains(text, ",") { + return nil, fmt.Errorf("%s: %q is a list of names, and holds %v", verb, k, item) + } + items = append(items, strings.TrimSpace(text)) + } + value = strings.Join(items, ",") default: return nil, fmt.Errorf("%s: %q is text, and was given %T", verb, k, x) } diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index eccd04dd..f63b91df 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -435,9 +435,9 @@ var ControllerVerbs = []Verb{ "last 15 minutes, and the login shell's execute is not served there. Anything else, a read that failed " + "included, is not free and says why. The router asks it before an answer from a channel proving its sender " + "may approve. Only reads.", - Input: schema(map[string]string{ - "machines": "the machines to judge, separated by commas", - }, []string{"machines"})}, + Input: listed(schema(map[string]string{ + "machines": "the machines to judge, by name: a list, or one text separated by commas", + }, []string{"machines"}), "machines")}, {Name: "dead-letters", Description: "Every message a consumer on the bus gave up on after handing it over " + "as often as it may, kept in DEAD_LETTERS: whose consumer, the subject, how often it was handed over and " + "when it was given up, newest first. With id: that one whole, with what it said. With deliver: hand it " + @@ -554,6 +554,21 @@ func schema(properties map[string]string, required []string, switches ...string) return out } +// listed makes the named properties of a schema lists of text: a caller gives them as a JSON array (or, as +// any argument, one text separated by commas). +func listed(in map[string]any, names ...string) map[string]any { + props, _ := in["properties"].(map[string]any) + for _, n := range names { + p, _ := props[n].(map[string]any) + if p == nil { + panic("a list that is not a property: " + n) + } + props[n] = map[string]any{"type": "array", "items": map[string]any{"type": "string"}, + "description": p["description"]} + } + return in +} + // unpromised is what a claim says it serves and the seat's protocol never promised. func unpromised(serves []string, promised []Verb) []string { has := map[string]bool{} From 983bf65141f11f6e87daf1e35dec8324bb9ce8c4 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 13:51:18 +0200 Subject: [PATCH 09/11] =?UTF-8?q?Answer=20the=20controller's=20own=20verbs?= =?UTF-8?q?,=20root-free=20among=20them,=20from=20the=20serving=20controll?= =?UTF-8?q?er=20alone=20(hq=20ADR=200259=20=C2=A78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The confirmation review asked who may answer root-free on the bus. Composed from the controller's own manifest, the module principal of the machine running the controller and that machine's runtime were granted the controller seat's tool subjects too: either could answer root-free, and the runtime's credential is one an agent on that machine may hold. The controller's seat is now served by the controller principal alone, in grants and memberships; TestOnlyTheServingControllerMayAnswerRootFree failed before (3 answerers) and passes. And a machine waiting for its first setuid search is not root-free, whatever ADR 0266's quiet window does to the self-check. --- cmd/mesh-controller/probe_agent_root_test.go | 29 +++++ internal/broker/membership.go | 3 + internal/broker/nats.go | 16 ++- internal/inventory/rootfree_grants_test.go | 119 +++++++++++++++++++ 4 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 internal/inventory/rootfree_grants_test.go 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) + } +} From 5866b2db94308c02c56c6b4d2e907713513e4980 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 13:51:54 +0200 Subject: [PATCH 10/11] =?UTF-8?q?Hold=20a=20private=20kind's=20holder=20to?= =?UTF-8?q?=20an=20account=20of=20its=20own,=20as=20a=20verified=20one=20i?= =?UTF-8?q?s=20(hq=20ADR=200259=20=C2=A77,=20=C2=A78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A private kind is where the router shows a link's code, and the code makes an account the operator's. Registration refused a verified-sender holder on the machine's runtime and let a private one stand; it now refuses both (the confirmation review of 2026-10-09, low). --- internal/catalogue/kinded_test.go | 7 +++++++ internal/catalogue/seats_declared.go | 9 +++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/internal/catalogue/kinded_test.go b/internal/catalogue/kinded_test.go index 15b15201..896a87f3 100644 --- a/internal/catalogue/kinded_test.go +++ b/internal/catalogue/kinded_test.go @@ -134,6 +134,13 @@ func TestATrustedHolderMustRunAsAnAccountOfItsOwn(t *testing.T) { if got := problemsFor(t, Shelf{"messenger": router(), "desk-channel": desk}); got != "" { t.Errorf("a channel proving nothing was held to it: %s", got) } + // A kind that is `private` shows a link's code, which links an account as the operator: its holder is + // trusted with it, so it runs as its own account too (the confirmation review of 2026-10-09). + private := aChannel("desk-channel", "desktop") + private.Claims[0].Capabilities = []string{"choice", "private"} + if got := problemsFor(t, Shelf{"messenger": router(), "desk-channel": private}); !strings.Contains(got, "desk-channel must run as an account of its own") { + t.Errorf("a private channel on the machine's runtime stood: %s", got) + } } func TestRunsAsIsAnAccountOfTheModulesOwn(t *testing.T) { diff --git a/internal/catalogue/seats_declared.go b/internal/catalogue/seats_declared.go index 1728a9bb..972eabf7 100644 --- a/internal/catalogue/seats_declared.go +++ b/internal/catalogue/seats_declared.go @@ -466,7 +466,8 @@ func RunsAsProblems(m Manifest) []string { // TrustedHolding is why a module must run as its own account (novox/hq ADR 0259 §8), or "": it holds a seat // whose events it says to one caller each (a warrant), or speaks for a kind of a kinded bench that proves -// its sender. Neither may be carried by the machine's runtime, which runs as the operator's account. +// its sender, or is private (a link's code is shown there). None may be carried by the machine's runtime, which +// runs as the operator's account. func TrustedHolding(m Manifest, declared map[string]SeatDeclaration) string { for _, c := range m.Claims { s, ok := declared[c.Name] @@ -480,8 +481,12 @@ func TrustedHolding(m Manifest, declared map[string]SeatDeclaration) string { } if s.Kinded { for _, capability := range c.Capabilities { - if capability == "verified-sender" { + switch capability { + case "verified-sender": return fmt.Sprintf("it holds %s of kind %s, which proves its sender", c.Name, c.Kind) + case "private": + // A private kind is shown a link's code, which makes an account the operator's. + return fmt.Sprintf("it holds %s of kind %s, which is private: a link's code is shown there", c.Name, c.Kind) } } } From c544c2a17befd2d94a7bc30133c5e9cd8b7c4e2c Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 13:55:06 +0200 Subject: [PATCH 11/11] =?UTF-8?q?Key=20root-not-free=20apart=20from=20DA,?= =?UTF-8?q?=20keep=20ADR=200266's=20quiet=20window=20there,=20and=20judge?= =?UTF-8?q?=20at=20one=20clock=20(hq=20ADR=200259=20=C2=A78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The confirmation review of 2026-10-09 found D-root and DA writing one key, machine..agent-root, from two probes with different words, so it flapped every run; D-root is now root-not-free. D-root raised the urgent condition after every node-engine restart while the first setuid search ran; it now keeps the same quiet window as DA, and the root-free verb still answers that machine not free. agentConfined takes the judging clock. --- cmd/mesh-controller/agent_account.go | 7 ++- cmd/mesh-controller/agent_account_test.go | 6 +- cmd/mesh-controller/doctor.go | 2 +- cmd/mesh-controller/probe_agent_root.go | 61 +++++++++++++++----- cmd/mesh-controller/probe_agent_root_test.go | 52 ++++++++++++++--- 5 files changed, 100 insertions(+), 28 deletions(-) diff --git a/cmd/mesh-controller/agent_account.go b/cmd/mesh-controller/agent_account.go index 9436997e..e23d9b74 100644 --- a/cmd/mesh-controller/agent_account.go +++ b/cmd/mesh-controller/agent_account.go @@ -48,7 +48,8 @@ const agentAccountProbe = "DA" // // The one judgement: `node show`, the self-check, and ADR 0259's router honouring a verified sender read // it here. -func agentConfined(ctx context.Context, inv *inventory.Inventory, node string) (named, confined bool, why string, err error) { +// now is the judging clock, threaded so a caller judging several things at one instant judges them all at it. +func agentConfined(ctx context.Context, inv *inventory.Inventory, node string, now time.Time) (named, confined bool, why string, err error) { n, err := inv.NodeByName(ctx, node) if err != nil { return false, false, "", err @@ -61,7 +62,7 @@ func agentConfined(ctx context.Context, inv *inventory.Inventory, node string) ( if err != nil { return true, false, "", err } - confined, why = judgedConfined(n.AgentAccount, h, had, time.Now()) + confined, why = judgedConfined(n.AgentAccount, h, had, now) return true, confined, why, nil } @@ -228,7 +229,7 @@ func agentAccountLines(ctx context.Context, inv *inventory.Inventory, n inventor return []string{fmt.Sprintf(" agents run as the operator account (%s); no agent account is named", orNoneKnown(n.Account))} } - _, confined, why, err := agentConfined(ctx, inv, n.Name) + _, confined, why, err := agentConfined(ctx, inv, n.Name, time.Now()) if err != nil { return []string{fmt.Sprintf(" agents run as %s (home %s); whether it can become root could NOT be read: %v", n.AgentAccount, n.AgentHome(), err)} diff --git a/cmd/mesh-controller/agent_account_test.go b/cmd/mesh-controller/agent_account_test.go index 68143eca..f05790a1 100644 --- a/cmd/mesh-controller/agent_account_test.go +++ b/cmd/mesh-controller/agent_account_test.go @@ -106,10 +106,10 @@ func TestTheSelfCheckSaysAnAgentAccountThatCanBecomeRoot(t *testing.T) { if found, err = probeAgentAccounts(ctx, d); err != nil || len(onlyMachine(found, "anchor")) != 0 { t.Fatalf("a judged agent account still fails: %+v %v", found, err) } - if named, confined, why, err := agentConfined(ctx, inv, "anchor"); err != nil || !named || !confined { + if named, confined, why, err := agentConfined(ctx, inv, "anchor", time.Now()); err != nil || !named || !confined { t.Fatalf("agentConfined on anchor: %v %v %q %v", named, confined, why, err) } - if named, _, why, err := agentConfined(ctx, inv, "laptop"); err != nil || named || + if named, _, why, err := agentConfined(ctx, inv, "laptop", time.Now()); err != nil || named || !strings.Contains(why, "operator account") { t.Fatalf("agentConfined on a machine naming none: %v %q %v", named, why, err) } @@ -246,7 +246,7 @@ func TestASearchStillRunningAfterARestartIsNotUrgent(t *testing.T) { if found := say(link.StateUnknown, running); len(found) != 0 { t.Fatalf("a search first seen now was raised: %+v", found) } - if _, confined, why, _ := agentConfined(ctx, inv, "anchor"); confined || !strings.Contains(why, "not judged") { + if _, confined, why, _ := agentConfined(ctx, inv, "anchor", time.Now()); confined || !strings.Contains(why, "not judged") { t.Fatalf("an account whose search runs was read as confined: %q", why) } // The engine restarted again and again, each statement's own since fresh: the controller's clock runs on. diff --git a/cmd/mesh-controller/doctor.go b/cmd/mesh-controller/doctor.go index c229a64d..3f78f57d 100644 --- a/cmd/mesh-controller/doctor.go +++ b/cmd/mesh-controller/doctor.go @@ -130,7 +130,7 @@ var probeRegistry = []probe{ // person, an answer proven there proves nothing. {ID: "D-root", Asserts: "no agent can become root without a person on a machine where the router or a channel " + "proving its sender runs: not by its own account, and not through a tool that runs its command as an account " + - "that can", From: "ADR 0259 §8", Kind: kindAgentRoot, Phase: 2, run: probeAgentRoot}, + "that can", From: "ADR 0259 §8", Kind: kindRootNotFree, Phase: 2, run: probeAgentRoot}, {ID: "DW", Asserts: "the watchdogs of the signals table ran within three of their intervals", From: "ADR 0227 rule 6: the watchers are watched", Kind: "watchdogs-silent", Phase: 1, run: probeWatchdogs}, // The core's health definitions (novox/hq to-be 45 §8, ADR 0236): what a core component's new build is diff --git a/cmd/mesh-controller/probe_agent_root.go b/cmd/mesh-controller/probe_agent_root.go index 5042fa22..de18aaf4 100644 --- a/cmd/mesh-controller/probe_agent_root.go +++ b/cmd/mesh-controller/probe_agent_root.go @@ -36,14 +36,17 @@ import ( // measure is no longer part of this judgement: it ran in the machine's runtime, as the very account an agent // could become, so it could not be believed. // -// The one judgement (judgeRoot) is read two ways: the self-check raises `agent-root` on every machine where +// The one judgement (judgeRoot) is read two ways: the self-check raises `root-not-free` on every machine where // the router or a module of its own account runs and the judgement fails; and the `root-free` verb answers it // live, to the router, which honours a verified sender only on its pass. A machine holding the operator's // graphical session, where a messaging client's desktop app may run, is not judged here: the operator accepted // that gap for now (hq issue 344). -// kindAgentRoot is the condition a trusted party's machine that is not root-free raises. -const kindAgentRoot = "agent-root" +// kindRootNotFree is the condition a trusted party's machine that is not root-free raises. Its own key, apart +// from ADR 0266's agent-can-become-root (Token agent-root, from DA): the two judge different things — DA the +// agent account alone, this the whole of root-free — and one key from two probes flapped between them (the +// confirmation review of 2026-10-09). +const kindRootNotFree = "root-not-free" // routerSeat is the seat the router holds: where it runs counts as a trusted party's machine. const routerSeat = "operator-channel" @@ -92,6 +95,9 @@ type rootFacts struct { // Execute is whether the login shell's execute is served there; ExecuteWhy why, in words. Execute bool ExecuteWhy string + // SearchPending is the agent account unjudged only because the node-engine's first setuid search runs, + // within its bound (ADR 0266's quiet window). + SearchPending bool } // rootVerdict is the judgement on one machine, as the root-free verb answers it. @@ -100,6 +106,9 @@ type rootVerdict struct { Free bool `json:"free"` Why string `json:"why"` Judged time.Time `json:"judged"` + // Quiet is a machine not free only because its first setuid search still runs, within its bound: the + // self-check raises nothing for it then (ADR 0266's quiet window). It is never free for it. + Quiet bool `json:"quiet,omitempty"` } // judgeRoot is the one judgement: free only when nothing failed to read, an agent account is named and judged @@ -127,6 +136,8 @@ func judgeRoot(f rootFacts, now time.Time) rootVerdict { } if len(not) > 0 { v.Why = strings.Join(not, "; ") + // The one failure is the agent account not judged yet, because its first search runs. + v.Quiet = len(not) == 1 && f.SearchPending && f.AgentNamed && !f.Confined && len(f.Unread) == 0 && !f.Execute return v } v.Free = true @@ -142,7 +153,11 @@ type rootReader struct { heard map[string]map[string]map[string]bool asked error // confined is agentConfined; settings the login shell holder's settings on a machine. Replaceable in a test. - confined func(ctx context.Context, node string) (named, confined bool, why string, err error) + confined func(ctx context.Context, node string, now time.Time) (named, confined bool, why string, err error) + // quiet says the one thing keeping a machine's agent account unjudged is the node-engine's first setuid + // search, within its bound (ADR 0266, searchStillRunning). Read by the self-check alone, to raise nothing + // then; nil reads no quiet. It never makes a machine root-free. + quiet func(ctx context.Context, node string, now time.Time) bool settings func(ctx context.Context, node, module string) ([]catalogue.Layer, error) } @@ -154,26 +169,29 @@ func newRootReader(ctx context.Context, inv *inventory.Inventory, conn *nats.Con } else { r.heard, r.asked = discoverSeatVerbs(ctx, conn) } - r.confined = func(ctx context.Context, node string) (bool, bool, string, error) { - return agentConfined(ctx, inv, node) + r.confined = func(ctx context.Context, node string, now time.Time) (bool, bool, string, error) { + return agentConfined(ctx, inv, node, now) } r.settings = inv.SettingsFor return r } -// facts reads one machine. -func (r *rootReader) facts(ctx context.Context, machine string) rootFacts { +// facts reads one machine, at now. +func (r *rootReader) facts(ctx context.Context, machine string, now time.Time) rootFacts { f := rootFacts{Machine: machine} if r.read != nil { f.Unread = append(f.Unread, "the catalogue's placements could not be read: "+r.read.Error()) } - named, confined, why, err := r.confined(ctx, machine) + named, confined, why, err := r.confined(ctx, machine, now) if err != nil { f.Unread = append(f.Unread, "the account agents run as could not be read: "+err.Error()) } else { f.AgentNamed, f.Confined, f.ConfinedWhy = named, confined, why } f.Execute, f.ExecuteWhy = r.executeServed(ctx, machine) + if r.quiet != nil && f.AgentNamed && !f.Confined { + f.SearchPending = r.quiet(ctx, machine, now) + } return f } @@ -240,7 +258,7 @@ func claimServes(m catalogue.Manifest, seat, verb string) bool { func judgeRootFree(ctx context.Context, r *rootReader, machines []string, now time.Time) []rootVerdict { out := make([]rootVerdict, 0, len(machines)) for _, m := range machines { - out = append(out, judgeRoot(r.facts(ctx, m), now)) + out = append(out, judgeRoot(r.facts(ctx, m, now), now)) } return out } @@ -263,8 +281,8 @@ func trustedMachines(entries []inventory.Entry) map[string][]string { func agentRootObservation(v rootVerdict, trusted []string) conditions.Observation { trusted = append([]string(nil), trusted...) sort.Strings(trusted) - return conditions.Observation{Scope: conditions.ScopeMachine, ID: v.Machine, Token: kindAgentRoot, - Machine: v.Machine, Kind: kindAgentRoot, Severity: conditions.Urgent, + return conditions.Observation{Scope: conditions.ScopeMachine, ID: v.Machine, Token: kindRootNotFree, + Machine: v.Machine, Kind: kindRootNotFree, Severity: conditions.Urgent, Summary: fmt.Sprintf("%s is not root-free, where %s run: until it is, the router approves nothing proven "+ "there (novox/hq ADR 0259 §8): %s", v.Machine, strings.Join(trusted, ", "), v.Why), Headline: "Phone answers held on " + v.Machine, @@ -281,10 +299,25 @@ func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, e if d.js != nil { conn = d.js.Conn() } - r := newRootReader(ctx, d.open.inventory, conn) + inv := d.open.inventory + r := newRootReader(ctx, inv, conn) if r.read != nil { return nil, r.read } + // The self-check alone reads ADR 0266's quiet window: nothing raised while a machine's first setuid search + // runs, within its bound. The root-free verb never reads it, so the machine still answers not free. + r.quiet = func(ctx context.Context, node string, now time.Time) bool { + n, err := inv.NodeByName(ctx, node) + if err != nil || n.AgentAccount == "" { + return false + } + h, had, err := inv.HealthOf(ctx, node) + if err != nil { + return false + } + quiet, err := searchStillRunning(ctx, inv, node, n.AgentAccount, h, had, now) + return err == nil && quiet + } return rootObservations(ctx, r, trustedMachines(r.entries), time.Now()), nil } @@ -297,7 +330,7 @@ func rootObservations(ctx context.Context, r *rootReader, trusted map[string][]s sort.Strings(machines) var out []conditions.Observation for _, v := range judgeRootFree(ctx, r, machines, now) { - if !v.Free { + if !v.Free && !v.Quiet { out = append(out, agentRootObservation(v, trusted[v.Machine])) } } diff --git a/cmd/mesh-controller/probe_agent_root_test.go b/cmd/mesh-controller/probe_agent_root_test.go index acca8056..52c56a93 100644 --- a/cmd/mesh-controller/probe_agent_root_test.go +++ b/cmd/mesh-controller/probe_agent_root_test.go @@ -50,7 +50,7 @@ func TestTheRootReaderFailsClosed(t *testing.T) { return &rootReader{ entries: []inventory.Entry{{Manifest: shell, On: []string{"anchor"}}}, heard: map[string]map[string]map[string]bool{}, - confined: func(context.Context, string) (bool, bool, string, error) { + confined: func(context.Context, string, time.Time) (bool, bool, string, error) { return true, true, "the agent account agents cannot become root without a person", nil }, settings: func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, nil }, @@ -62,17 +62,17 @@ func TestTheRootReaderFailsClosed(t *testing.T) { } cases := map[string]func(*rootReader){ "no agent account named, no coding-agent module there": func(r *rootReader) { - r.confined = func(context.Context, string) (bool, bool, string, error) { + r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) { return false, false, "anchor names no agent account: agents run as the operator account (ops)", nil } }, "the node-engine's verdict not read": func(r *rootReader) { - r.confined = func(context.Context, string) (bool, bool, string, error) { + r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) { return false, false, "", errors.New("the store did not answer") } }, "a stale verdict": func(r *rootReader) { - r.confined = func(context.Context, string) (bool, bool, string, error) { + r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) { return true, false, "the agent account agents is not judged: the machine's newest statement was heard at …", nil } }, @@ -119,12 +119,12 @@ func TestTheProbeSaysEachMachineThatIsNotRootFree(t *testing.T) { t.Fatalf("trusted %v", trusted) } r := &rootReader{entries: entries, heard: map[string]map[string]map[string]bool{}, - confined: func(_ context.Context, node string) (bool, bool, string, error) { + confined: func(_ context.Context, node string, _ time.Time) (bool, bool, string, error) { return false, false, node + " names no agent account: agents run as the operator account (ops)", nil }, settings: func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, nil }} got := rootObservations(context.Background(), r, trusted, rootNow) - if len(got) != 1 || got[0].Machine != "anchor" || got[0].Kind != kindAgentRoot || got[0].Severity != conditions.Urgent || + if len(got) != 1 || got[0].Machine != "anchor" || got[0].Kind != kindRootNotFree || got[0].Severity != conditions.Urgent || !strings.Contains(got[0].Summary, "messenger, telegram") || !strings.Contains(got[0].Summary, "names no agent account") { t.Fatalf("said %+v", got) } @@ -133,7 +133,9 @@ func TestTheProbeSaysEachMachineThatIsNotRootFree(t *testing.T) { Needs: o.Needs, Resolved: o.Resolved}, o.Machine); !ok { t.Errorf("not plain: %s", why) } - r.confined = func(context.Context, string) (bool, bool, string, error) { return true, true, "confined", nil } + r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) { + return true, true, "confined", nil + } if got := rootObservations(context.Background(), r, trusted, rootNow); len(got) != 0 { t.Errorf("said of a free machine: %+v", got) } @@ -225,3 +227,39 @@ func TestAMachineWaitingForItsFirstSetuidSearchIsNotRootFree(t *testing.T) { t.Errorf("a machine whose first setuid search is pending was judged root-free: %+v", v) } } + +// The confirmation review of 2026-10-09, on #154 beside ADR 0266: D-root keeps ADR 0266's quiet window — nothing +// raised while the one thing unjudged is the first setuid search, within its bound — while the root-free verb +// still answers the machine not free; and D-root's condition has a key of its own, apart from DA's. +func TestRootNotFreeIsQuietWhileTheFirstSearchRunsAndKeyedApartFromDA(t *testing.T) { + entries := []inventory.Entry{{Manifest: catalogue.Manifest{Module: "telegram", RunsAs: "telegram"}, On: []string{"anchor"}}} + r := &rootReader{entries: entries, heard: map[string]map[string]map[string]bool{}, + confined: func(context.Context, string, time.Time) (bool, bool, string, error) { + return true, false, "the agent account agents is not judged: the search for setuid programs runs", nil + }, + settings: func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, nil }, + quiet: func(context.Context, string, time.Time) bool { return true }} + trusted := trustedMachines(entries) + if got := rootObservations(context.Background(), r, trusted, rootNow); len(got) != 0 { + t.Errorf("raised while the first search runs: %+v", got) + } + if v := judgeRootFree(context.Background(), r, []string{"anchor"}, rootNow)[0]; v.Free || !v.Quiet { + t.Errorf("root-free while the first search runs: %+v", v) + } + // Quiet hides nothing else: the login shell served as well is said. + r.heard = map[string]map[string]map[string]bool{loginShellSeat: {"execute": {"anchor": true}}} + if got := rootObservations(context.Background(), r, trusted, rootNow); len(got) != 1 { + t.Errorf("a second failure was kept quiet: %+v", got) + } + // Past its bound, said. + r.heard, r.quiet = map[string]map[string]map[string]bool{}, func(context.Context, string, time.Time) bool { return false } + got := rootObservations(context.Background(), r, trusted, rootNow) + if len(got) != 1 { + t.Fatalf("a search past its bound was not said: %+v", got) + } + // One key per judgement: DA's is machine..agent-root, this one its own. + da := conditions.Observation{Scope: conditions.ScopeMachine, ID: "anchor", Token: "agent-root", Machine: "anchor"} + if got[0].Key() == da.Key() { + t.Errorf("D-root and DA share the key %s", da.Key()) + } +}