mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer delivery to the same trunk took over its walk
mesh/delivery-group group feat/asks-answered-on-any-channel stopped: a member was stopped
The confirmation review of 2026-10-09 found D-root and DA writing one key, machine.<m>.agent-root, from two probes with different words, so it flapped every run; D-root is now root-not-free. D-root raised the urgent condition after every node-engine restart while the first setuid search ran; it now keeps the same quiet window as DA, and the root-free verb still answers that machine not free. agentConfined takes the judging clock.
252 lines
11 KiB
Go
252 lines
11 KiB
Go
package main
|
|
|
|
// The account agents run as (novox/hq ADR 0266).
|
|
//
|
|
// On the control node every agent session ran as the operator's account, which may become root without a
|
|
// password — so any agent there could become root without a person, and ADR 0259 §8 (an answer from the
|
|
// operator's phone authorises an act) rests on that being false where the router and its channels run. The
|
|
// decision: a node may name an account its agents run as, of their own and without sudo; the operator's
|
|
// account keeps its sudo.
|
|
//
|
|
// - **Named at the controller's terminal only** (`node agent-account`): not a verb, not a setting, so no
|
|
// agent can name itself another account. Empty is a real state: agents run as the operator there.
|
|
// - **Composed** as `${machine:agent-account}`, `${machine:agent-home}` and `${machine:agent-root}` for the
|
|
// agent's module, which declares the account with `root: never`, and as MESH_AGENT_ACCOUNT and
|
|
// MESH_AGENT_HOME for its tools (catalogue/machine_into_files.go, runtime.go).
|
|
// - **Judged by the machine itself.** The node-engine reads, on every look, whether an account declared
|
|
// `root: never` can become root without a person — uid 0, a group that grants root, a sudo rule, a
|
|
// secret of the mesh it may read — and says it as the declaring module's account verdict, marked
|
|
// Root "never". agentConfined reads that verdict; the self-check (probe DA) raises
|
|
// `agent-can-become-root` while it does not hold, and `node show` says it.
|
|
//
|
|
// A verdict not given is never a pass: an engine older than the judging, an account not yet declared, a
|
|
// statement that says nothing of it — each is "not judged", and fails.
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/conditions"
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
)
|
|
|
|
// kindAgentCanBecomeRoot is the condition raised while a machine's agent account can become root without
|
|
// a person, or is not judged (ADR 0266).
|
|
const kindAgentCanBecomeRoot = "agent-can-become-root"
|
|
|
|
// agentAccountProbe is the self-check's probe of it.
|
|
const agentAccountProbe = "DA"
|
|
|
|
// agentConfined says whether the agents of a machine that names an agent account are confined: the
|
|
// machine's newest statement holds a healthy account verdict, judged for root, on that account. named is
|
|
// false for a machine that names none — agents run as the operator account there, which this does not
|
|
// judge. why is said either way, in the mesh's words; err is a store that could not be read.
|
|
//
|
|
// The one judgement: `node show`, the self-check, and ADR 0259's router honouring a verified sender read
|
|
// it here.
|
|
// now is the judging clock, threaded so a caller judging several things at one instant judges them all at it.
|
|
func agentConfined(ctx context.Context, inv *inventory.Inventory, node string, now time.Time) (named, confined bool, why string, err error) {
|
|
n, err := inv.NodeByName(ctx, node)
|
|
if err != nil {
|
|
return false, false, "", err
|
|
}
|
|
if n.AgentAccount == "" {
|
|
return false, false, fmt.Sprintf("%s names no agent account: agents run as the operator account (%s)",
|
|
node, orNoneKnown(n.Account)), nil
|
|
}
|
|
h, had, err := inv.HealthOf(ctx, node)
|
|
if err != nil {
|
|
return true, false, "", err
|
|
}
|
|
confined, why = judgedConfined(n.AgentAccount, h, had, now)
|
|
return true, confined, why, nil
|
|
}
|
|
|
|
// verdictFreshFor is how old the statement holding the verdict may be, by this controller's clock. A
|
|
// node-engine states its health on every change and at least every five minutes (mesh-host's sayAnyway), so
|
|
// three statements missed is a node-engine stopped, or a machine away. **A stale verdict is not a pass**: an
|
|
// agent that stopped the node-engine must not leave "cannot become root" standing from before.
|
|
const verdictFreshFor = 15 * time.Minute
|
|
|
|
// judgedConfined is the judgement over one statement, without the store, at now.
|
|
func judgedConfined(agent string, h inventory.NodeHealth, had bool, now time.Time) (bool, string) {
|
|
if !had {
|
|
return false, fmt.Sprintf("the agent account %s is not judged: the machine's node-engine has stated "+
|
|
"nothing of what it runs", agent)
|
|
}
|
|
if age := now.Sub(h.HeardAt); age > verdictFreshFor {
|
|
return false, fmt.Sprintf("the agent account %s is not judged: the machine's newest statement was heard at "+
|
|
"%s, more than %d minutes ago, and a verdict that old is not a verdict on now", agent,
|
|
h.HeardAt.Local().Format("2006-01-02 15:04"), int(verdictFreshFor.Minutes()))
|
|
}
|
|
if h.Contract < link.RootContract {
|
|
return false, fmt.Sprintf("the agent account %s is not judged: the machine's node-engine is older than "+
|
|
"the judging of an account's root (its statement's contract is %d, the judging is %d)",
|
|
agent, h.Contract, link.RootContract)
|
|
}
|
|
var verdicts []inventory.ResourceHealth
|
|
for _, r := range h.Resources {
|
|
if r.Kind == link.KindAccount && r.Target == agent && r.Root == link.RootNever {
|
|
verdicts = append(verdicts, r)
|
|
}
|
|
}
|
|
if len(verdicts) == 0 {
|
|
return false, fmt.Sprintf("the agent account %s is not judged: the machine's newest statement holds no "+
|
|
"verdict on it — no module there declares it never to become root, or the declaration naming it "+
|
|
"has not been applied", agent)
|
|
}
|
|
sort.Slice(verdicts, func(i, j int) bool {
|
|
return verdicts[i].Module+verdicts[i].Resource < verdicts[j].Module+verdicts[j].Resource
|
|
})
|
|
for _, v := range verdicts {
|
|
switch v.State {
|
|
case link.StateHealthy:
|
|
case link.StateUnhealthy:
|
|
// The engine's own words, which start with link.ReasonRoot when it found a way to root.
|
|
return false, fmt.Sprintf("the agent account %s %s (said by %s's %s)", agent,
|
|
orNoneKnown(v.Reason), v.Module, v.Resource)
|
|
default:
|
|
return false, fmt.Sprintf("the agent account %s is not judged: %s (%s's %s, %s)", agent,
|
|
orNoneKnown(v.Reason), v.Module, v.Resource, v.State)
|
|
}
|
|
}
|
|
return true, fmt.Sprintf("the agent account %s cannot become root without a person (judged %s)", agent,
|
|
h.SaidAt.Local().Format("2006-01-02 15:04"))
|
|
}
|
|
|
|
// searchQuietFor is how long the controller lets an agent account's verdict wait for the node-engine's setuid
|
|
// search before that is itself the urgent condition: the engine's bound on one search (link.RootSearchBound, the
|
|
// engine's own value), counted from when this controller first saw it waiting, never from the engine's start.
|
|
const searchQuietFor = link.RootSearchBound
|
|
|
|
// The kinds of an agent account's verdict, for the quiet a search earns.
|
|
const (
|
|
verdictOther = iota // anything else: a stale statement, an older engine, no verdict, another unknown
|
|
verdictPending // waiting for the search, and otherwise healthy
|
|
verdictComplete // judged: healthy, or a way to root found
|
|
)
|
|
|
|
// rootVerdictKind reads a statement for the agent account (novox/hq ADR 0266): pending when every verdict on it
|
|
// is healthy or not judged yet because the engine's setuid search runs, at least one of them that; complete when
|
|
// every verdict is healthy or one found a way to root. The engine's own "since" is not read: it starts again at
|
|
// every restart of the engine.
|
|
func rootVerdictKind(agent string, h inventory.NodeHealth, had bool, now time.Time) int {
|
|
if !had || now.Sub(h.HeardAt) > verdictFreshFor || h.Contract < link.RootContract {
|
|
return verdictOther
|
|
}
|
|
pending, any := false, false
|
|
for _, r := range h.Resources {
|
|
if r.Kind != link.KindAccount || r.Target != agent || r.Root != link.RootNever {
|
|
continue
|
|
}
|
|
any = true
|
|
switch {
|
|
case r.State == link.StateHealthy:
|
|
case r.State == link.StateUnhealthy:
|
|
return verdictComplete
|
|
case r.State == link.StateUnknown && strings.HasPrefix(r.Reason, link.ReasonRootPending):
|
|
pending = true
|
|
default:
|
|
return verdictOther
|
|
}
|
|
}
|
|
switch {
|
|
case !any:
|
|
return verdictOther
|
|
case pending:
|
|
return verdictPending
|
|
}
|
|
return verdictComplete
|
|
}
|
|
|
|
// searchStillRunning says the one thing keeping an agent account from being judged is the node-engine's setuid
|
|
// search, and that this controller first saw it waiting less than searchQuietFor ago — kept in the store, so a
|
|
// node-engine restarted in a loop does not keep it quiet. A complete verdict forgets when it began.
|
|
func searchStillRunning(ctx context.Context, inv *inventory.Inventory, node, agent string, h inventory.NodeHealth,
|
|
had bool, now time.Time) (bool, error) {
|
|
switch rootVerdictKind(agent, h, had, now) {
|
|
case verdictComplete:
|
|
return false, inv.RootSearchJudged(ctx, node)
|
|
case verdictPending:
|
|
since, err := inv.RootSearchPending(ctx, node, now)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return now.Sub(since) <= searchQuietFor, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// probeAgentAccounts is DA: every machine that names an agent account has it judged, on its node-engine's
|
|
// newest statement, unable to become root without a person (ADR 0266).
|
|
func probeAgentAccounts(ctx context.Context, d *doctor) ([]conditions.Observation, error) {
|
|
inv := d.open.inventory
|
|
nodes, err := inv.Nodes(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []conditions.Observation
|
|
for _, n := range nodes {
|
|
if n.AgentAccount == "" {
|
|
continue
|
|
}
|
|
h, had, err := inv.HealthOf(ctx, n.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
now := time.Now()
|
|
quiet, err := searchStillRunning(ctx, inv, n.Name, n.AgentAccount, h, had, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
confined, why := judgedConfined(n.AgentAccount, h, had, now)
|
|
if confined {
|
|
continue
|
|
}
|
|
// Not judged yet only because the first search since the node-engine started is still running: not the
|
|
// urgent condition after every restart. The agent is still not confined — ADR 0259's router reads
|
|
// agentConfined, not this — and `node show` still says not judged. Loud again once the search fails,
|
|
// runs out its bound, or the statement goes stale.
|
|
if quiet {
|
|
continue
|
|
}
|
|
out = append(out, conditions.Observation{Scope: conditions.ScopeMachine, ID: n.Name, Token: "agent-root",
|
|
Machine: n.Name, Severity: conditions.Urgent,
|
|
Summary: fmt.Sprintf("on %s, %s (ADR 0266): an agent there may become root without a person, and "+
|
|
"no answer from a channel authorises an act there (ADR 0259 §8)", n.Name, why),
|
|
Said: why})
|
|
}
|
|
return sortedFound(out), nil
|
|
}
|
|
|
|
// agentAccountLines is what `node show` says of the account agents run as.
|
|
func agentAccountLines(ctx context.Context, inv *inventory.Inventory, n inventory.Node) []string {
|
|
if n.AgentAccount == "" {
|
|
return []string{fmt.Sprintf(" agents run as the operator account (%s); no agent account is named",
|
|
orNoneKnown(n.Account))}
|
|
}
|
|
_, confined, why, err := agentConfined(ctx, inv, n.Name, time.Now())
|
|
if err != nil {
|
|
return []string{fmt.Sprintf(" agents run as %s (home %s); whether it can become root could NOT be read: %v",
|
|
n.AgentAccount, n.AgentHome(), err)}
|
|
}
|
|
verdict := "CAN become root, or is not judged: " + why
|
|
if confined {
|
|
verdict = why
|
|
}
|
|
return []string{fmt.Sprintf(" agents run as %s (home %s)", n.AgentAccount, n.AgentHome()),
|
|
" " + verdict}
|
|
}
|
|
|
|
// orNoneKnown is a value, or that none is known.
|
|
func orNoneKnown(s string) string {
|
|
if strings.TrimSpace(s) == "" {
|
|
return "none known"
|
|
}
|
|
return s
|
|
}
|