diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 31aa626..29af743 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1559,9 +1559,10 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Queue, sa // healthAsReported is a statement as the report and the event carry it. func healthAsReported(st liveness.Statement, ns *network.Statement) *link.Health { - // ReadinessContract: this engine reads a resource's declared `health` and judges it (ADR 0240 Phase - // B), which is what tells the controller it may be sent the field. - h := &link.Health{Contract: link.ReadinessContract, At: st.At.UTC(), Resources: []link.ResourceHealth{}} + // RootContract: this engine reads a resource's declared `health` and judges it (ADR 0240 Phase B), and + // judges a user's declared `root` (novox/hq ADR 0266), which is what tells the controller it may be sent + // either field. + h := &link.Health{Contract: link.RootContract, At: st.At.UTC(), Resources: []link.ResourceHealth{}} for _, r := range st.Resources { h.Resources = append(h.Resources, link.ResourceHealth{Module: r.Module, Resource: r.ID, Kind: r.Kind, Target: r.Target, State: r.State, Reason: r.Reason, Since: r.Since.UTC(), Streak: r.Streak, @@ -1607,15 +1608,21 @@ func withUnits(h *link.Health, us *units.Statement) *link.Health { // withAccounts adds to a statement every account a module put in a group (novox/hq ADR 0252), as that // module's resource of kind account: healthy, or unhealthy with why — "relogin needed" when the account's -// running session began before it was put in the group. Nil says nothing of them. +// running session began before it was put in the group, or "can become root without a person" for one declared +// never to (novox/hq ADR 0266), which is also marked root never. Nil says nothing of them. func withAccounts(h *link.Health, as *accounts.Statement) *link.Health { if as == nil { return h } for _, v := range as.Accounts { - h.Resources = append(h.Resources, link.ResourceHealth{Module: v.Module, Resource: v.ID, + rh := link.ResourceHealth{Module: v.Module, Resource: v.ID, Kind: link.KindAccount, Target: v.Name, State: v.State, Reason: v.Reason, Since: v.Since.UTC(), - Streak: v.Streak, Account: v.Name}) + Streak: v.Streak, Account: v.Name} + // Said, so the controller knows healthy here also means no way to root was found (novox/hq ADR 0266). + if v.Root { + rh.Root = declaration.RootNever + } + h.Resources = append(h.Resources, rh) } return h } diff --git a/cmd/mesh-host/units_test.go b/cmd/mesh-host/units_test.go index e5c6654..d679d22 100644 --- a/cmd/mesh-host/units_test.go +++ b/cmd/mesh-host/units_test.go @@ -75,3 +75,26 @@ func TestTheStatementNamesTheAccountsManager(t *testing.T) { } } } + +// An account declared never to become root (novox/hq ADR 0266) is said with root never, under the contract +// that tells the controller this engine judges it; an account judged for its groups alone is not. +func TestTheStatementSaysAnAccountJudgedForRoot(t *testing.T) { + at := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC) + as := &accounts.Statement{At: at, Accounts: []accounts.Verdict{ + {Account: accounts.Account{Module: "claude-code", ID: "claude-code.agent", Name: "agent", Root: true}, + State: accounts.Unhealthy, Reason: accounts.ReasonRoot + ": in the group docker, which grants root", Since: at}, + {Account: accounts.Account{Module: "openrazer", ID: "openrazer.account", Name: "operator", + Groups: []string{"openrazer"}}, State: accounts.Healthy, Since: at}, + }} + h := withAccounts(healthAsReported(liveness.Statement{At: at}, nil), as) + if h.Contract != link.RootContract || link.RootContract != 3 { + t.Fatalf("contract %d", h.Contract) + } + got := map[string]string{} + for _, r := range h.Resources { + got[r.Resource] = r.Root + } + if got["claude-code.agent"] != "never" || got["openrazer.account"] != "" { + t.Fatalf("root said: %v", got) + } +} diff --git a/internal/accounts/accounts.go b/internal/accounts/accounts.go index 740c22c..1b4f186 100644 --- a/internal/accounts/accounts.go +++ b/internal/accounts/accounts.go @@ -21,6 +21,16 @@ // controller raises it as the module's condition on two statements in a row, and clears it on the first // healthy one. // +// **And an account declared never to become root without a person** (`root: never`, novox/hq ADR 0266) — +// the account agent sessions run as on a machine where they must not reach root by themselves — is judged +// on every look for whether it can anyway: by its uid, by a group that grants root to its members, by any +// sudo rule at all, or by reading a secret the mesh placed for another account (the tool runner's bus +// credential among them, which carries every co-hosted module's grants). Judged first, whether or not +// anybody is logged in: a way to root does not wait for a session. Any way found is unhealthy, its reason +// starting ReasonRoot and naming every way; a question the machine did not answer is unknown, never +// healthy. This is the fact the controller reads to tell a machine where no agent can become root from one +// where an agent can. +// // **It reads; it never acts** (ADR 0240 rule 6): the user and group databases, the machine's own service // manager's `show` of the account's manager unit, and that process's status file. It never starts the // account's manager, which asking that manager itself would. @@ -47,6 +57,17 @@ const ( // ReasonRelogin starts the reason of an account whose running session lacks a group it is in. const ReasonRelogin = "relogin needed" +// ReasonRoot starts the reason of an account declared never to become root without a person that can +// (novox/hq ADR 0266); every way found follows it. +const ReasonRoot = "can become root without a person" + +// RootGroups are the groups whose members become root by membership alone, without a sudo rule naming +// them: the administrators' groups a distribution's own rules or polkit treat as root, the container +// runtime's socket (a container with the host's root mounted), the raw disks, and the virtualisation +// daemons that start a guest with the host's devices. A closed list, so what the judge calls "grants root" +// is written down and reviewable rather than guessed per machine. +var RootGroups = []string{"root", "wheel", "sudo", "admin", "docker", "disk", "lxd", "incus-admin", "libvirt"} + // Account is one user resource of a module that declares groups. type Account struct { Module string @@ -54,6 +75,11 @@ type Account struct { ID string Name string Groups []string + // Root is true for an account declared never to become root without a person (novox/hq ADR 0266). + Root bool + // Secrets are the paths of every secret the declaration places for another account, judged for + // whether this one can read them; only for a Root account. + Secrets []string } // Of is every account a declaration has a module put in a group. held is every resource an adopted @@ -66,20 +92,39 @@ func Of(d *declaration.Declaration, held map[string]bool) []Account { var out []Account for _, r := range d.Resources { u, ok := r.(*declaration.User) - if !ok || len(u.Groups) == 0 || held[u.ID] || u.Name == "" { + root := ok && u.Root == declaration.RootNever + if !ok || (len(u.Groups) == 0 && !root) || held[u.ID] || u.Name == "" { continue } at := strings.LastIndex(u.ID, ".") if at <= 0 || strings.HasPrefix(u.ID, declaration.AdoptionPrefix) { continue } - out = append(out, Account{Module: u.ID[:at], ID: u.ID, Name: u.Name, - Groups: append([]string(nil), u.Groups...)}) + a := Account{Module: u.ID[:at], ID: u.ID, Name: u.Name, Groups: append([]string(nil), u.Groups...), Root: root} + if root { + a.Secrets = secretsOf(d, u.Name) + } + out = append(out, a) } sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID }) return out } +// secretsOf is the path of every secret file the declaration places for anybody but account: sealed whole, +// or with sealed values in its content. One the account owns is its own to read. +func secretsOf(d *declaration.Declaration, account string) []string { + var out []string + for _, r := range d.Resources { + f, ok := r.(*declaration.File) + if !ok || f.Path == "" || (f.Sealed == "" && len(f.Secrets) == 0) || f.Owner == account { + continue + } + out = append(out, f.Path) + } + sort.Strings(out) + return out +} + // Session is what an account's own service manager holds. type Session struct { // Running is whether the manager runs. @@ -94,6 +139,10 @@ type Reader interface { InDatabase(ctx context.Context, account string) ([]string, error) // Session is the account's own service manager: whether it runs, and which of groups it holds. Session(ctx context.Context, account string, groups []string) (Session, error) + // Escalation is every way the account can become root without a person, in words — its uid, a group + // of RootGroups, a sudo rule, a secret of secrets it can read — and none when there is none. Reads + // only (novox/hq ADR 0266). + Escalation(ctx context.Context, account string, secrets []string) ([]string, error) } // Verdict is one account's state as a statement says it. @@ -190,6 +239,19 @@ func (j *Judge) Look(ctx context.Context) (Statement, bool) { // judge is one account's verdict on one look. func (j *Judge) judge(ctx context.Context, a Account) (string, string) { + if a.Root { + ways, err := j.reader.Escalation(ctx, a.Name, a.Secrets) + if err != nil { + return Unknown, "whether the account can become root could not be read: " + firstLine(err.Error()) + } + if len(ways) > 0 { + return Unhealthy, ReasonRoot + ": " + strings.Join(ways, "; ") + } + if len(a.Groups) == 0 { + // Declared for root alone: nothing of a session to read. + return Healthy, "" + } + } in, err := j.reader.InDatabase(ctx, a.Name) if err != nil { return Unknown, "the user database could not be read: " + firstLine(err.Error()) diff --git a/internal/accounts/exec.go b/internal/accounts/exec.go index ed256c4..7108028 100644 --- a/internal/accounts/exec.go +++ b/internal/accounts/exec.go @@ -4,20 +4,165 @@ import ( "context" "errors" "fmt" + "io/fs" "os" + "os/exec" "path/filepath" + "slices" + "strconv" "strings" + "syscall" ) // Runner runs a command and answers what it printed — the apply's own (apply.ExecRunner). type Runner func(ctx context.Context, name string, args ...string) (string, error) // Exec reads the machine through its command lines and the process table. **Reads only**: `id`, `getent`, -// the machine's own manager's `systemctl show`, and a status file under /proc (a test holds it). +// the machine's own manager's `systemctl show`, `sudo -l -U` (which lists, as root, what sudo would let an +// account run, and runs nothing), a status file under /proc and a secret's owner and mode (a test holds +// both). type Exec struct { Run Runner // Proc is where the process table is; empty is /proc. A test points it at a directory of its own. Proc string + // Stat is a file's owner and mode; nil is the machine's own (os.Stat). A test gives files of its own. + Stat func(path string) (FileMode, error) +} + +// FileMode is what decides whether an account reads a file: its owner, its group and its permission bits. +type FileMode struct { + UID, GID int + Perm fs.FileMode +} + +// Escalation is every way account can become root without a person (novox/hq ADR 0266): its uid, a group +// of RootGroups the user database lists it in, any sudo rule naming it or a group of it, and any of secrets +// it can read by owner, group or other bits. A secret not there yet is skipped: there is nothing to read. +// The parent directories are not walked, so a file the bits allow and a directory hides is still said: +// the judge errs toward saying a way that is not, never toward missing one that is. +func (e Exec) Escalation(ctx context.Context, account string, secrets []string) ([]string, error) { + var ways []string + uidOut, err := e.Run(ctx, "id", "-u", account) + if err != nil { + return nil, fmt.Errorf("the user database did not answer about %q: %w", account, err) + } + uid, err := strconv.Atoi(strings.TrimSpace(uidOut)) + if err != nil { + return nil, fmt.Errorf("the user database gave %q as %q's number", strings.TrimSpace(uidOut), account) + } + if uid == 0 { + ways = append(ways, "its uid is 0") + } + names, err := e.InDatabase(ctx, account) + if err != nil { + return nil, err + } + for _, g := range names { + if slices.Contains(RootGroups, g) { + ways = append(ways, "in the group "+g+", which grants root") + } + } + listed, err := e.Run(ctx, "sudo", "-l", "-U", account) + rules, err := SudoRules(listed, err) + if err != nil { + return nil, err + } + if len(rules) > 0 { + ways = append(ways, "sudo grants it: "+strings.Join(rules, ", ")) + } + if len(secrets) > 0 { + gidsOut, err := e.Run(ctx, "id", "-G", account) + if err != nil { + return nil, fmt.Errorf("the user database did not answer about %q's groups: %w", account, err) + } + gids := map[int]bool{} + for _, f := range strings.Fields(gidsOut) { + if n, err := strconv.Atoi(f); err == nil { + gids[n] = true + } + } + stat := e.Stat + if stat == nil { + stat = statOf + } + for _, path := range secrets { + m, err := stat(path) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return nil, fmt.Errorf("the secret %s could not be read for its owner and mode: %w", path, err) + } + if Readable(m, uid, gids) { + ways = append(ways, "it can read the secret "+path) + } + } + } + return ways, nil +} + +// Readable is whether an account of uid, in the groups gids, reads a file of m by its permission bits, as +// the kernel decides it: the owner's bits for the owner (root reads everything), the group's for a member, +// the others' for anybody else. +func Readable(m FileMode, uid int, gids map[int]bool) bool { + switch { + case uid == 0: + return true + case m.UID == uid: + return m.Perm&0o400 != 0 + case gids[m.GID]: + return m.Perm&0o040 != 0 + default: + return m.Perm&0o004 != 0 + } +} + +// SudoRules is the rules `sudo -l -U ` lists, from what it printed and how it ended: none when +// the account "is not allowed to run sudo" or sudo is not on the machine; every indented line after "may +// run the following commands" otherwise. Any rule counts — the decision is no sudo for the account at +// all, so a rule that asks for a password the account was never given is still a rule somebody can give +// it one for. Output that says neither is an error: unread is never none. +func SudoRules(out string, err error) ([]string, error) { + if err != nil && (errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist)) { + return nil, nil + } + text := out + if err != nil { + text += "\n" + err.Error() + } + if strings.Contains(text, "is not allowed to run sudo") { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("sudo did not list the account's rules: %w", err) + } + var rules []string + listing := false + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, "may run the following commands") { + listing = true + continue + } + if listing && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) && strings.TrimSpace(line) != "" { + rules = append(rules, strings.TrimSpace(line)) + } + } + if !listing { + return nil, fmt.Errorf("sudo listed neither rules nor a refusal: %q", firstLine(out)) + } + return rules, nil +} + +func statOf(path string) (FileMode, error) { + info, err := os.Stat(path) + if err != nil { + return FileMode{}, err + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return FileMode{}, fmt.Errorf("%s has no owner this platform reports", path) + } + return FileMode{UID: int(st.Uid), GID: int(st.Gid), Perm: info.Mode().Perm()}, nil } // InDatabase is `id -nG`: every group the user database lists the account in. diff --git a/internal/accounts/root_test.go b/internal/accounts/root_test.go new file mode 100644 index 0000000..a888145 --- /dev/null +++ b/internal/accounts/root_test.go @@ -0,0 +1,197 @@ +package accounts + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os/exec" + "strings" + "testing" + + "github.com/novox/mesh-host/internal/declaration" +) + +// An account declared never to become root without a person (novox/hq ADR 0266, "how it is checked"): each +// way to root is found and said, none is healthy whether or not anybody is logged in, an unanswered question +// is unknown, and the judge only reads. + +// agentMachine is a fake machine for the escalation question: the account's uid, its groups by name and +// number, what sudo lists, and the secrets' owners and modes. +type agentMachine struct { + uid string + groups string + gids string + sudo string + sudoErr error + files map[string]FileMode + failsID bool + asked []string +} + +func (m *agentMachine) run(_ context.Context, name string, args ...string) (string, error) { + line := name + " " + strings.Join(args, " ") + m.asked = append(m.asked, line) + switch { + case m.failsID && name == "id": + return "", errors.New("id exited 1: no such user") + case line == "id -u agent": + return m.uid + "\n", nil + case line == "id -nG agent": + return m.groups + "\n", nil + case line == "id -G agent": + return m.gids + "\n", nil + case line == "sudo -l -U agent": + return m.sudo, m.sudoErr + } + return "", errors.New("not a command the judge may run: " + line) +} + +func (m *agentMachine) stat(path string) (FileMode, error) { + if f, ok := m.files[path]; ok { + return f, nil + } + return FileMode{}, fs.ErrNotExist +} + +const notAllowed = "User agent is not allowed to run sudo on box.\n" + +var agent = Account{Module: "claude-code", ID: "claude-code.agent", Name: "agent", Root: true, + Secrets: []string{"/var/lib/mesh/node-tools/broker"}} + +func clean() *agentMachine { + return &agentMachine{uid: "1600", groups: "agent", gids: "1600", sudo: notAllowed, + files: map[string]FileMode{"/var/lib/mesh/node-tools/broker": {UID: 1500, GID: 1500, Perm: 0o600}}} +} + +func lookAgent(t *testing.T, m *agentMachine) Verdict { + t.Helper() + j := New(Exec{Run: m.run, Stat: m.stat}) + j.Set([]Account{agent}) + st, _ := j.Look(t.Context()) + if len(st.Accounts) != 1 { + t.Fatalf("the statement: %+v", st) + } + for _, a := range m.asked { + if !strings.HasPrefix(a, "id ") && a != "sudo -l -U agent" { + t.Errorf("the judge asked something that is not a read: %q", a) + } + } + return st.Accounts[0] +} + +func TestAnAgentAccountWithNoWayToRootIsHealthyWithNobodyLoggedIn(t *testing.T) { + if v := lookAgent(t, clean()); v.State != Healthy || v.Reason != "" || !v.Root { + t.Fatalf("no way to root: %+v", v) + } +} + +func TestEachWayToRootIsSaid(t *testing.T) { + for _, c := range []struct { + name string + set func(*agentMachine) + said string + }{ + {"uid 0", func(m *agentMachine) { m.uid = "0" }, "its uid is 0"}, + {"docker", func(m *agentMachine) { m.groups = "agent docker" }, "in the group docker, which grants root"}, + {"wheel", func(m *agentMachine) { m.groups = "agent wheel" }, "in the group wheel, which grants root"}, + {"sudo", func(m *agentMachine) { + m.sudo = "Matching Defaults entries for agent on box:\n env_reset\n\nUser agent may run the following commands on box:\n (ALL) NOPASSWD: ALL\n" + }, "sudo grants it: (ALL) NOPASSWD: ALL"}, + {"secret by others", func(m *agentMachine) { + m.files["/var/lib/mesh/node-tools/broker"] = FileMode{UID: 1500, GID: 1500, Perm: 0o644} + }, "it can read the secret /var/lib/mesh/node-tools/broker"}, + {"secret by group", func(m *agentMachine) { + m.gids = "1600 1500" + m.files["/var/lib/mesh/node-tools/broker"] = FileMode{UID: 1500, GID: 1500, Perm: 0o640} + }, "it can read the secret /var/lib/mesh/node-tools/broker"}, + } { + t.Run(c.name, func(t *testing.T) { + m := clean() + c.set(m) + v := lookAgent(t, m) + if v.State != Unhealthy || !strings.HasPrefix(v.Reason, ReasonRoot+": ") || !strings.Contains(v.Reason, c.said) { + t.Fatalf("want %q said: %+v", c.said, v) + } + }) + } +} + +func TestEveryWayIsSaidAtOnce(t *testing.T) { + m := clean() + m.groups = "agent docker" + m.sudo = "User agent may run the following commands on box:\n (ALL) ALL\n" + v := lookAgent(t, m) + if !strings.Contains(v.Reason, "docker") || !strings.Contains(v.Reason, "(ALL) ALL") { + t.Fatalf("both ways: %+v", v) + } +} + +func TestAnUnansweredQuestionIsUnknownNeverHealthy(t *testing.T) { + m := clean() + m.failsID = true + if v := lookAgent(t, m); v.State != Unknown { + t.Fatalf("an unread database: %+v", v) + } + m = clean() + m.sudo = "something sudo never says\n" + if v := lookAgent(t, m); v.State != Unknown { + t.Fatalf("an unread sudo: %+v", v) + } +} + +func TestASecretNotThereYetIsNoWay(t *testing.T) { + m := clean() + delete(m.files, "/var/lib/mesh/node-tools/broker") + if v := lookAgent(t, m); v.State != Healthy { + t.Fatalf("no secret placed: %+v", v) + } +} + +func TestSudoRules(t *testing.T) { + none := []struct { + out string + err error + }{ + {notAllowed, nil}, + {notAllowed, errors.New("sudo exited 1: ")}, + {"", fmt.Errorf("sudo: %w", exec.ErrNotFound)}, + } + for _, c := range none { + if rules, err := SudoRules(c.out, c.err); err != nil || len(rules) != 0 { + t.Errorf("%q, %v: rules %v, err %v", c.out, c.err, rules, err) + } + } + rules, err := SudoRules("Matching Defaults entries for agent on box:\n env_reset\n\nUser agent may run the following commands on box:\n (ALL) /usr/bin/systemctl\n (root) NOPASSWD: /usr/bin/true\n", nil) + if err != nil || len(rules) != 2 || rules[0] != "(ALL) /usr/bin/systemctl" { + t.Fatalf("two rules: %v, %v", rules, err) + } + if _, err := SudoRules("", errors.New("sudo exited 1: sudo: unable to resolve host")); err == nil { + t.Error("a failing sudo that said neither was read as no rules") + } +} + +func TestReadable(t *testing.T) { + m := FileMode{UID: 1500, GID: 1500, Perm: 0o600} + if Readable(m, 1600, map[int]bool{1600: true}) || !Readable(m, 1500, nil) || !Readable(m, 0, nil) { + t.Fatal("owner bits") + } +} + +func TestOfJudgesARootNeverAccountWithNoGroupsAndItsSecrets(t *testing.T) { + d, err := declaration.ParseTrusted([]byte(`{"declaration":1,"resources":[ + {"id":"claude-code.agent","type":"user","name":"agent","root":"never"}, + {"id":"zsh.login","type":"user","name":"operator","shell":"/bin/zsh"}, + {"id":"node-tools.need-broker","type":"file","path":"/var/lib/mesh/broker","sealed":"x","owner":"operator"}, + {"id":"claude-code.own","type":"file","path":"/home/agent/own","sealed":"x","owner":"agent"}, + {"id":"claude-code.plain","type":"file","path":"/etc/plain","content":"x"} + ]}`)) + if err != nil { + t.Fatal(err) + } + got := Of(d, nil) + if len(got) != 1 || got[0].ID != "claude-code.agent" || !got[0].Root || + len(got[0].Secrets) != 1 || got[0].Secrets[0] != "/var/lib/mesh/broker" { + t.Fatalf("judged: %+v", got) + } +} diff --git a/internal/declaration/root_test.go b/internal/declaration/root_test.go new file mode 100644 index 0000000..cd2908c --- /dev/null +++ b/internal/declaration/root_test.go @@ -0,0 +1,26 @@ +package declaration + +import ( + "strings" + "testing" +) + +// A user's root (novox/hq ADR 0266): "never" or absent, and nothing else. +func TestAUsersRootIsNeverOrAbsent(t *testing.T) { + for _, c := range []struct { + root string + ok bool + }{{`,"root":"never"`, true}, {``, true}, {`,"root":"always"`, false}, {`,"root":"no"`, false}} { + d, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"claude-code.agent","type":"user","name":"agent"` + + c.root + `}]}`)) + if c.ok != (err == nil) { + t.Errorf("%s: err %v", c.root, err) + } + if err != nil && !strings.Contains(err.Error(), `"never"`) { + t.Errorf("%s: the refusal does not name the allowed value: %v", c.root, err) + } + if c.ok && c.root != "" && d.Resources[0].(*User).Root != RootNever { + t.Errorf("%s: read as %+v", c.root, d.Resources[0]) + } + } +} diff --git a/internal/link/messages_test.go b/internal/link/messages_test.go index c7f5371..d2cb235 100644 --- a/internal/link/messages_test.go +++ b/internal/link/messages_test.go @@ -142,6 +142,9 @@ func TestTheWireFormatIsExactlyTheseFieldNames(t *testing.T) { []string{"module", "resource", "kind", "target", "state", "reason", "since", "streak", "restarts", "check", "needs"}}, {HealthSaid{Node: "n"}, []string{"node", "health"}}, + // novox/hq ADR 0266: an account judged for whether it can become root without a person. + {ResourceHealth{Module: "m", Resource: "m.agent", Kind: KindAccount, Account: "agent", Root: "never"}, + []string{"module", "resource", "kind", "target", "state", "since", "account", "root"}}, // novox/hq ADR 0241: the machine's own networking, beside its resources. {Health{Contract: ReadinessContract, Resources: []ResourceHealth{}, Network: &NetworkHealth{State: "unhealthy", Parts: []NetworkPart{}}}, []string{"contract", "at", "resources", "network"}},