Judge a machine root-free only on a positive, fresh measure, and believe a verified sender only there (hq ADR 0259 §8, review H2/H3)

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.
This commit is contained in:
2026-10-09 13:48:56 +02:00
parent e2a45b18ba
commit 4c375dafed
9 changed files with 509 additions and 305 deletions
+267 -222
View File
@@ -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
}