The node-engine's search has no bound any more: it runs at idle priority and judges from its last complete, fresh result. The controller's quiet while an agent account waits is the engine's rootsearch.Quiet, not the old fifteen-minute bound, and the node-engine is pinned at its pull request.
379 lines
16 KiB
Go
379 lines
16 KiB
Go
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
|
|
}
|
|
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
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|