135 lines
6.2 KiB
Go
135 lines
6.2 KiB
Go
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.
|