Files
mesh-controller/cmd/mesh-controller/probe_agent_root.go
T
jschoubben 5fa8e40667 Raise agent-root where who can become root is not measured, so the router never reads a missing measure as a no (hq ADR 0259 §8)
A machine where the router or a verified channel runs and the sudo module is absent or does not answer
made the probe fail to run, which raises nothing the router reads, so it went on approving there. Each
such machine now raises the same urgent condition, saying it was not measured.
2026-10-09 13:48:56 +02:00

299 lines
12 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"slices"
"sort"
"strings"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/link"
)
// The self-check's probe of who can become root where the trusted parties run (novox/hq ADR 0259 §8, rule 3).
//
// The router and every channel proving its sender run as accounts of their own, so that no agent reads what
// they hold or speaks as them. **Root on their machine undoes all of it.** So on every machine where a module
// of its own account runs, this probe asks two questions, and raises an urgent condition while either is yes:
//
// 1. can an account an agent runs as become root without a person there — passwordless sudo, or a group
// that is root by another name (docker, disk)? The agent's account is the one the coding-agent module on
// that machine names (`agent_account`), and the operator's account while it names none;
// 2. can an agent run a command it chooses, through the mesh's own tools, as an account that can — the login
// shell's `execute` runs as the machine's runtime account, which the mesh's acting tools give passwordless
// sudo? **Only where `execute` is served** (novox/hq ADR 0268): `execute` is an optional verb its holder
// withholds on a machine whose `execute` setting is not `serve`. The holder's seat being held there says
// nothing; whether the verb is served does. It counts as served while either says so — the holder's
// setting as the controller resolves it for that machine (a withheld value not yet pushed is still served),
// or the bus's discovery hearing the verb answered there — so a withheld setting the machine has not acted
// on yet, or a holder answering against its setting, is never taken for closed.
//
// The measurement is the sudo module's on that machine (`sudo_escalation`). **What cannot be measured is not a
// pass**: a machine it does not run on, or that does not answer, raises the same urgent condition, saying it
// was not measured — the router reads the condition, and a probe that merely failed to run would leave it
// approving there (hq ADR 0259 §8, as amended on 2026-10-09).
// kindAgentRoot is the condition an agent able to become root where a trusted party runs raises.
const kindAgentRoot = "agent-root"
// The tools the probe asks, of the modules on each machine.
const (
agentModule = "claude-code"
agentStatusTool = "claude_code_status"
sudoModule = "sudo"
sudoEscalation = "sudo_escalation"
loginShellSeat = "node-login-shell"
// loginShellVerb is the seat's verb that runs a command, and the name of the setting its holder withholds
// it by (novox/hq ADR 0268).
loginShellVerb = "execute"
// executeServes is the one value of that setting that serves the verb; anything else withholds it.
executeServes = "serve"
)
// loginShellServed judges whether the login shell's execute is served on a machine, failing closed (novox/hq
// ADR 0268): served while the holder's setting there is `serve` (or the holder has no such setting and claims
// the verb), or while the bus heard the verb answered there, or while the bus could not be asked. why says
// which, in words, for the condition.
func loginShellServed(holder string, claims bool, setting *any, heard, asked bool) (bool, string) {
var why []string
switch {
case setting != nil:
if v, _ := (*setting).(string); v == executeServes {
why = append(why, holder+"'s execute setting there is "+executeServes)
}
case claims:
why = append(why, holder+" claims execute and has no setting that withholds it")
}
if heard {
why = append(why, "the bus hears execute answered there")
} else if !asked {
why = append(why, "the bus could not be asked whether execute is answered there")
}
return len(why) > 0, strings.Join(why, "; ")
}
// claimServes says whether a manifest's claim of a seat names a verb among those it serves.
func claimServes(m catalogue.Manifest, seat, verb string) bool {
for _, c := range m.Claims {
if c.Name == seat && slices.Contains(c.Serves, verb) {
return true
}
}
return false
}
// escalation is the sudo module's answer for one account.
type escalation struct {
Account string `json:"account"`
Root bool `json:"root_without_a_person"`
Why string `json:"why"`
}
// agentRootFacts is what one machine says, as the probe reads it.
type agentRootFacts struct {
Machine string
Trusted []string // the modules of their own account on it
Agent string // the account agents run as there; "" when none run there
AgentNamed bool // the coding-agent module named it, rather than it being taken for the operator's
Runtime string // the account the machine's runtime runs as
LoginShell bool // the login shell'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{}
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
}
}
}
machines := make([]string, 0, len(facts))
for m := range facts {
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 _, 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
}
// 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"}
}