From 4c375dafedb4360ea6e999deceecda87c5c90c9d Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 12:12:32 +0200 Subject: [PATCH] =?UTF-8?q?Judge=20a=20machine=20root-free=20only=20on=20a?= =?UTF-8?q?=20positive,=20fresh=20measure,=20and=20believe=20a=20verified?= =?UTF-8?q?=20sender=20only=20there=20(hq=20ADR=200259=20=C2=A78,=20review?= =?UTF-8?q?=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",