package main import ( "context" "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/inventory" ) // 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**, 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. 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). The // engine gives that verdict only from a complete search for setuid programs younger than mesh-host's // rootsearch.FreshFor; before one, it says "not judged yet", which is no pass (novox/hq issue 361); // 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. // // **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 `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). // 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" const ( 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. 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, "; ") } // 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 // SearchPending is the agent account unjudged only because the node-engine's setuid search runs, within // the quiet the controller gives it (searchQuietFor; ADR 0266's quiet window, issue 361). SearchPending bool } // 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"` // Quiet is a machine not free only because its setuid search still runs, within searchQuietFor: 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 // 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, "; ") // The one failure is the agent account not judged yet, because its search runs. v.Quiet = len(not) == 1 && f.SearchPending && f.AgentNamed && !f.Confined && len(f.Unread) == 0 && !f.Execute 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, 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 setuid // search, within searchQuietFor (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) } 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, now time.Time) (bool, bool, string, error) { return agentConfined(ctx, inv, node, now) } r.settings = inv.SettingsFor return r } // 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, 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 } // 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 { if c.Name == seat && slices.Contains(c.Serves, verb) { return true } } return false } // 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), now)) } return out } // 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 { if e.Manifest.RunsAs != "" || e.Manifest.ClaimsSeat(routerSeat) { trusted[node] = append(trusted[node], e.Manifest.Module) } } } 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: 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, 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() } 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 setuid search // runs, within searchQuietFor. 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 } obs := rootObservations(ctx, r, trustedMachines(r.entries), time.Now()) // A membership that lost its verified sender to a judgement that failed at a push gets it back here, once // the machines pass (issue 930); the bound is one self-check. giveVerifiedSendersBack(ctx, d) return obs, 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 for _, v := range judgeRootFree(ctx, r, machines, now) { if !v.Free && !v.Quiet { out = append(out, agentRootObservation(v, trusted[v.Machine])) } } return out } // 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 }