package main import ( "strings" "testing" "time" "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) // The agent account's judgement (novox/hq ADR 0266): confined only on a healthy account verdict judged for // root, on the very account; every verdict not given — no statement, an older engine, no verdict on it, a // verdict of unknown — is "not judged" and fails, never a pass. func TestAnAgentAccountIsConfinedOnlyOnAHealthyVerdictJudgedForRoot(t *testing.T) { at := time.Date(2026, 10, 8, 19, 21, 0, 0, time.UTC) verdict := func(target, root, state, reason string) inventory.ResourceHealth { return inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account", Kind: link.KindAccount, Target: target, State: state, Reason: reason, Root: root, Account: target} } statement := func(contract int, rs ...inventory.ResourceHealth) inventory.NodeHealth { return inventory.NodeHealth{Node: "anchor", Contract: contract, SaidAt: at, HeardAt: at, Resources: rs} } for _, c := range []struct { name string h inventory.NodeHealth had bool confined bool says string }{ {"judged and unable", statement(link.RootContract, verdict("agent", link.RootNever, link.StateHealthy, "")), true, true, "cannot become root without a person"}, {"judged and able", statement(link.RootContract, verdict("agent", link.RootNever, link.StateUnhealthy, link.ReasonRoot+": in the group docker, which grants root")), true, false, "in the group docker"}, {"a verdict of unknown", statement(link.RootContract, verdict("agent", link.RootNever, link.StateUnknown, "sudo could not be read")), true, false, "not judged"}, {"no statement", inventory.NodeHealth{}, false, false, "not judged"}, {"an older engine", statement(link.ReadinessContract, verdict("agent", "", link.StateHealthy, "")), true, false, "older than the judging"}, {"a verdict on groups only", statement(link.RootContract, verdict("agent", "", link.StateHealthy, "")), true, false, "no verdict on it"}, {"a verdict on another account", statement(link.RootContract, verdict("ops", link.RootNever, link.StateHealthy, "")), true, false, "no verdict on it"}, } { confined, why := judgedConfined("agent", c.h, c.had, at.Add(time.Minute)) if confined != c.confined || !strings.Contains(why, c.says) { t.Errorf("%s: confined %v, %q; want %v saying %q", c.name, confined, why, c.confined, c.says) } } // A verdict heard longer ago than the bound is no verdict: an agent that stopped the node-engine must not // leave "healthy" standing. fresh := statement(link.RootContract, verdict("agent", link.RootNever, link.StateHealthy, "")) if ok, _ := judgedConfined("agent", fresh, true, at.Add(verdictFreshFor)); !ok { t.Error("a verdict exactly at the bound is still one") } if ok, why := judgedConfined("agent", fresh, true, at.Add(verdictFreshFor+time.Second)); ok || !strings.Contains(why, "not judged") { t.Errorf("a stale healthy verdict passed: %q", why) } } // DA raises an urgent condition, with plain words, on a machine whose agent account is not judged unable to // become root; a machine that names none is not its to judge. func TestTheAgentRootWordsArePlain(t *testing.T) { w := plainWordings[kindAgentCanBecomeRoot](conditions.Observation{Kind: kindAgentCanBecomeRoot, Machine: "anchor"}) if why, ok := conditions.PlainWords(w, "anchor"); !ok { t.Fatalf("not plain: %s: %+v", why, w) } } func onlyMachine(obs []conditions.Observation, machine string) []conditions.Observation { var out []conditions.Observation for _, o := range obs { if o.Machine == machine { out = append(out, o) } } return out } // The snapshot a merge check composes from carries the agent account as a pseudonym (novox/hq ADR 0266), // so a change is judged against machines that name one, and the name never leaves. // No verb runs a `node` command that sets something: through the generic `command` verb, `node account`, // `node agent-account` and every other `node` subcommand but list and show are refused, naming the terminal. func TestNoVerbSetsANodesAccounts(t *testing.T) { for _, line := range []string{ "node agent-account novox --clear", "node agent-account novox ops", "node account novox agent", "node account novox", "node add intruder", "node public-domain novox --clear", "node", "node frobnicate", } { argv, err := argvFor("command", map[string]any{"command": line}) if err == nil || !strings.Contains(err.Error(), "controller's terminal") || !strings.Contains(err.Error(), "ADR 0266") { t.Errorf("%q ran as %v (%v); want a refusal naming the terminal", line, argv, err) } } for _, line := range []string{"node show novox", "node list --json", "status --json"} { if _, err := argvFor("command", map[string]any{"command": line}); err != nil { t.Errorf("%q, a read, was refused: %v", line, err) } } if err := terminalOnly([]string{"node", "account", "a", "b"}); err == nil { t.Error("the refusal is not only the command verb's") } } // Naming the agent account is the controller's terminal's alone: the `node` verb only shows. func TestTheNodeVerbOnlyShows(t *testing.T) { argv, err := argvFor("node", map[string]any{"node": "anchor"}) if err != nil || strings.Join(argv, " ") != "node show anchor" { t.Fatalf("the node verb runs %v (%v)", argv, err) } for _, v := range catalogue.ControllerVerbs { if strings.Contains(v.Name, "agent") { t.Errorf("a verb %q may name the agent account", v.Name) } } } // Until a complete search for setuid programs judges — none since the node-engine's state was kept, or the last // older than the engine lets one judge — the agent account is not judged, and the search runs to its end with no // bound (novox/hq issue 361). DA does not raise that as urgent within searchQuietFor, counted from when this // controller first saw it waiting — never from the engine's own "since", which a restart resets, so an engine // restarted in a loop does not keep it quiet. The account is still not confined, and `node show` still says not // judged; a search that failed, or a way to root found, is urgent at once.