Name the account agents run as on a node, and say whether it can become root
On the control node every agent ran as the operator's account, which has passwordless sudo, so an agent could become root without a person (hq ADR 0266). A node now names an agent account at the controller's terminal only; the agent's module declares it never to become root, the node-engine judges that, and the self-check (DA) raises agent-can-become-root while it does not hold, so ADR 0259's router can rest on it.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
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"
|
||||
|
||||
"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.
|
||||
func agentConfined(ctx context.Context, inv *inventory.Inventory, node string) (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)
|
||||
return true, confined, why, nil
|
||||
}
|
||||
|
||||
// judgedConfined is the judgement over one statement, without the store.
|
||||
func judgedConfined(agent string, h inventory.NodeHealth, had bool) (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 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"))
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
confined, why := judgedConfined(n.AgentAccount, h, had)
|
||||
if confined {
|
||||
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)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
snapshot "github.com/novox/mesh-controller/internal/facts"
|
||||
"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, 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 TestTheSelfCheckSaysAnAgentAccountThatCanBecomeRoot(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
for _, n := range []string{"anchor", "laptop"} {
|
||||
if _, err := inv.NodeByName(ctx, n); err != nil {
|
||||
if _, err := inv.AddNode(ctx, n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := inv.SetAccount(ctx, n, "ops", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := inv.SetAgentAccount(ctx, "anchor", "agent", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := &doctor{open: open}
|
||||
found, err := probeAgentAccounts(ctx, d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found = onlyMachine(found, "anchor")
|
||||
if len(found) != 1 || found[0].Machine != "anchor" || found[0].Severity != conditions.Urgent ||
|
||||
!strings.Contains(found[0].Said, "not judged") {
|
||||
t.Fatalf("a named agent account with no verdict: %+v", found)
|
||||
}
|
||||
w := plainWordings[kindAgentCanBecomeRoot](conditions.Observation{Kind: kindAgentCanBecomeRoot, Machine: "anchor"})
|
||||
if w.Headline == "" || w.Needs == "" || w.Resolved == "" {
|
||||
t.Errorf("the condition has no plain words: %+v", w)
|
||||
}
|
||||
|
||||
healthy := inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account",
|
||||
Kind: link.KindAccount, Target: "agent", State: link.StateHealthy, Root: link.RootNever, Account: "agent"}
|
||||
if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.RootContract,
|
||||
SaidAt: time.Now(), HeardAt: time.Now(), Resources: []inventory.ResourceHealth{healthy}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if found, err = probeAgentAccounts(ctx, d); err != nil || len(onlyMachine(found, "anchor")) != 0 {
|
||||
t.Fatalf("a judged agent account still fails: %+v %v", found, err)
|
||||
}
|
||||
if named, confined, why, err := agentConfined(ctx, inv, "anchor"); err != nil || !named || !confined {
|
||||
t.Fatalf("agentConfined on anchor: %v %v %q %v", named, confined, why, err)
|
||||
}
|
||||
if named, _, why, err := agentConfined(ctx, inv, "laptop"); err != nil || named ||
|
||||
!strings.Contains(why, "operator account") {
|
||||
t.Fatalf("agentConfined on a machine naming none: %v %q %v", named, why, err)
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
func TestTheFactsCarryTheAgentAccountAsAPseudonym(t *testing.T) {
|
||||
open, _ := aMeshWithSecrets(t)
|
||||
ctx := t.Context()
|
||||
if err := open.inventory.SetAccount(ctx, "anchor", "keeper", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := open.inventory.SetAgentAccount(ctx, "anchor", "warden", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := gatherFacts(ctx, open, "2.11.17")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := f.Encode()
|
||||
if strings.Contains(string(body), "warden") {
|
||||
t.Error("the agent account's name is in the snapshot")
|
||||
}
|
||||
m, ok := f.Machine(snapshot.Pseudonym("machine", "anchor"))
|
||||
if !ok || m.AgentAccount != snapshot.Pseudonym("account", "warden") || m.Account == m.AgentAccount {
|
||||
t.Fatalf("the anchor's agent account reads as %q (operator %q)", m.AgentAccount, m.Account)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,11 @@ var probeRegistry = []probe{
|
||||
{ID: probeReconnectsID, Asserts: "no user of the bus had its connection dropped more than twelve times in the " +
|
||||
"last hour: the bus module's nats_closed_connections", From: "issue 327", Kind: kindBusReconnects,
|
||||
Phase: 1, run: probeReconnects},
|
||||
// The account agents run as (novox/hq ADR 0266): where a machine names one, its node-engine has judged it
|
||||
// unable to become root without a person — what ADR 0259 §8 rests an authorised answer on.
|
||||
{ID: agentAccountProbe, Asserts: "every machine that names an agent account has it judged, on its node-engine's " +
|
||||
"newest statement, unable to become root without a person", From: "ADR 0266, ADR 0259 §8",
|
||||
Kind: kindAgentCanBecomeRoot, Phase: 1, run: probeAgentAccounts},
|
||||
{ID: "DW", Asserts: "the watchdogs of the signals table ran within three of their intervals",
|
||||
From: "ADR 0227 rule 6: the watchers are watched", Kind: "watchdogs-silent", Phase: 1, run: probeWatchdogs},
|
||||
// The core's health definitions (novox/hq to-be 45 §8, ADR 0236): what a core component's new build is
|
||||
|
||||
@@ -269,7 +269,8 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
|
||||
engines := map[string]bool{}
|
||||
for _, n := range nodes {
|
||||
m := snapshot.Machine{Name: scrub.Machine(n.Name), Length: len(n.Name), Adopted: n.Adopted,
|
||||
AccountHome: scrub.Text(n.AccountHome), NodeEngine: n.HostVersion, PublicDomain: domains[n.Name]}
|
||||
AccountHome: scrub.Text(n.AccountHome), NodeEngine: n.HostVersion, PublicDomain: domains[n.Name],
|
||||
AgentAccount: scrub.Account(n.AgentAccount), AgentAccountHome: scrub.Text(n.AgentAccountHome)}
|
||||
switch n.Account {
|
||||
case "", "root":
|
||||
m.Account = n.Account
|
||||
|
||||
@@ -1013,6 +1013,11 @@ func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf m
|
||||
return notes, err
|
||||
}
|
||||
}
|
||||
if m.AgentAccount != "" {
|
||||
if err := inv.SetAgentAccount(ctx, m.Name, m.AgentAccount, m.AgentAccountHome); err != nil {
|
||||
return notes, err
|
||||
}
|
||||
}
|
||||
if m.PublicDomain != "" {
|
||||
if err := inv.SetPublicDomain(ctx, m.Name, m.PublicDomain); err != nil {
|
||||
return notes, err
|
||||
|
||||
@@ -73,7 +73,7 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke
|
||||
for _, r := range h.Resources {
|
||||
kept := inventory.ResourceHealth{Module: r.Module, Resource: r.Resource, Kind: r.Kind, Target: r.Target,
|
||||
State: r.State, Reason: r.Reason, Since: r.Since, Streak: r.Streak, Restarts: r.Restarts,
|
||||
Check: r.Check, Needs: r.Needs, Account: r.Account}
|
||||
Check: r.Check, Needs: r.Needs, Account: r.Account, Root: r.Root}
|
||||
resources = append(resources, kept)
|
||||
if r.State == link.StateUnhealthy && r.Module != "" {
|
||||
unhealthy[r.Module] = append(unhealthy[r.Module], kept)
|
||||
|
||||
@@ -100,6 +100,12 @@ func nodeCommand(ctx context.Context, args []string) error {
|
||||
"containers reaching outward. The machine reports which of its links face outside; see " +
|
||||
"`node show <name>`")
|
||||
|
||||
case "agent-account":
|
||||
// The account agents run as on this machine, when it is not the operator's (novox/hq ADR 0266). Here,
|
||||
// at the controller's terminal, and nowhere else: no verb and no setting names it, so no agent can
|
||||
// name itself another account.
|
||||
return nodeAgentAccount(ctx, inv, args[1:])
|
||||
|
||||
case "account":
|
||||
// The operator's login on this machine (novox/hq to-be 29): what a home-scoped file is
|
||||
// owned by and which account `ssh <node>` uses. Reports with no argument; sets with one;
|
||||
@@ -107,7 +113,7 @@ func nodeCommand(ctx context.Context, args []string) error {
|
||||
return nodeAccount(ctx, inv, args[1:])
|
||||
|
||||
default:
|
||||
return fmt.Errorf("node has no %q; it has add, list, show, public-domain and account", args[0])
|
||||
return fmt.Errorf("node has no %q; it has add, list, show, public-domain, account and agent-account", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +184,57 @@ func nodeAccount(ctx context.Context, inv *inventory.Inventory, positionals []st
|
||||
return nil
|
||||
}
|
||||
|
||||
const agentAccountUsage = "node agent-account <name> — what it is now; " +
|
||||
"<name> <account> [home] to name the account agents run as (home defaults to /home/<account>); " +
|
||||
"<name> --clear to have them run as the operator account again"
|
||||
|
||||
// nodeAgentAccount reports, names or clears the account agents run as on a node (novox/hq ADR 0266). Read-
|
||||
// shaped with no account, like public-domain; clearing is asked for by name.
|
||||
func nodeAgentAccount(ctx context.Context, inv *inventory.Inventory, args []string) error {
|
||||
set := flag.NewFlagSet("node agent-account", flag.ContinueOnError)
|
||||
clear := set.Bool("clear", false, "agents run as the operator account again")
|
||||
positionals, err := parseAround(set, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(positionals) == 0 || len(positionals) > 3 {
|
||||
return errors.New(agentAccountUsage)
|
||||
}
|
||||
node := positionals[0]
|
||||
switch {
|
||||
case *clear && len(positionals) > 1:
|
||||
return fmt.Errorf("name an agent account for %s or --clear, not both", node)
|
||||
case *clear:
|
||||
if err := inv.SetAgentAccount(ctx, node, "", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("agents on %s run as the operator account again\n", node)
|
||||
fmt.Printf(" run `push %s` to send it; the agent account itself is kept (the mesh never deletes a login)\n", node)
|
||||
return nil
|
||||
case len(positionals) >= 2:
|
||||
home := ""
|
||||
if len(positionals) == 3 {
|
||||
home = positionals[2]
|
||||
}
|
||||
if err := inv.SetAgentAccount(ctx, node, positionals[1], home); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("agents on %s run as %s\n", node, positionals[1])
|
||||
fmt.Printf(" run `push %s` to send it; the self-check says once its node-engine has judged it "+
|
||||
"unable to become root\n", node)
|
||||
return nil
|
||||
default:
|
||||
n, err := inv.NodeByName(ctx, node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, line := range agentAccountLines(ctx, inv, n) {
|
||||
fmt.Println(strings.TrimPrefix(line, " "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
const publicDomainUsage = "node public-domain <name> — what it is now; " +
|
||||
"<name> <domain> to set it; <name> --clear to take it away"
|
||||
|
||||
@@ -592,6 +649,10 @@ func showNode(ctx context.Context, inv *inventory.Inventory, name string) error
|
||||
if err := showMode(ctx, inv, node); err != nil {
|
||||
return err
|
||||
}
|
||||
// Whom agents run as here, and whether that account can become root without a person (ADR 0266).
|
||||
for _, line := range agentAccountLines(ctx, inv, node) {
|
||||
fmt.Println(line)
|
||||
}
|
||||
|
||||
// The domain its routed names are composed under, when it has one (novox/hq ADR 0066). Shown
|
||||
// only when set: a machine that serves nothing to the outside has no domain, and saying so of
|
||||
|
||||
@@ -110,6 +110,15 @@ var plainWordings = map[string]func(conditions.Observation) words{
|
||||
"reaches it. It keeps running what it has.", m),
|
||||
Resolved: m + " can get new instructions again"}
|
||||
}),
|
||||
kindAgentCanBecomeRoot: worded(func(o conditions.Observation) words {
|
||||
m := machineOr(o, "a machine")
|
||||
return words{Headline: "Sessions on " + m + " could become root",
|
||||
Needs: "take the sessions' own account out of every group and rule that grants root; the details say which.",
|
||||
Explanation: fmt.Sprintf("Assistant sessions on %s run under an account of their own, so that none "+
|
||||
"can take over the machine without you. The machine cannot show that this holds now, so an answer "+
|
||||
"from your phone authorises nothing there until it does.", m),
|
||||
Resolved: "Sessions on " + m + " cannot become root again"}
|
||||
}),
|
||||
"own-address-banned": worded(func(o conditions.Observation) words {
|
||||
m := machineOr(o, "a machine")
|
||||
return words{Headline: m + " has banned the mesh",
|
||||
|
||||
@@ -136,7 +136,8 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
||||
resolved, err := catalogue.Resolve(shelf, assigned,
|
||||
catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities,
|
||||
At: onNetwork[nodeName], PublicDomain: publicDomain,
|
||||
Account: who.Account, AccountHome: who.AccountHome}, world)
|
||||
Account: who.Account, AccountHome: who.AccountHome,
|
||||
AgentAccount: who.AgentAccount, AgentAccountHome: who.AgentAccountHome}, world)
|
||||
if err != nil {
|
||||
// The node's own set does not compose. Marked, because this is the only failure here that
|
||||
// a mesh-wide gatherer may pass over — see notResolvable.
|
||||
@@ -885,8 +886,13 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
judgesRoot, err := engineJudgesRoot(ctx, inv, node)
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
return catalogue.Rendering{
|
||||
ReadsHealth: readsHealth,
|
||||
JudgesRoot: judgesRoot,
|
||||
BusMembership: memberships[node],
|
||||
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Foreseen: foreseen, Ports: ports,
|
||||
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
||||
@@ -909,6 +915,16 @@ func engineReadsHealth(ctx context.Context, inv *inventory.Inventory, node strin
|
||||
return had && stated.Contract >= link.ReadinessContract, nil
|
||||
}
|
||||
|
||||
// engineJudgesRoot says whether a machine's node-engine judges a user's declared `root` (novox/hq ADR 0266),
|
||||
// by its own newest statement, for the same reason as engineReadsHealth: an older engine parses strictly.
|
||||
func engineJudgesRoot(ctx context.Context, inv *inventory.Inventory, node string) (bool, error) {
|
||||
stated, had, err := inv.HealthOf(ctx, node)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return had && stated.Contract >= link.RootContract, nil
|
||||
}
|
||||
|
||||
// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR
|
||||
// 0199): the zone settled from that node's settings, the node's private address, the port the
|
||||
// answering listen is published on there.
|
||||
|
||||
@@ -35,4 +35,4 @@ require (
|
||||
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and
|
||||
// fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without
|
||||
// `go mod vendor` fails loudly, at once, everywhere.
|
||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac
|
||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261008163010-8390fab5cb35
|
||||
|
||||
@@ -4,6 +4,8 @@ git.novox.be/novox/mesh-host v0.0.0-20261007120832-bdd44154ccac h1:KvnKtJ2rWeIE/
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261007120832-bdd44154ccac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac h1:yLtFS0pDCCqIE9Zx8hgXEFG9fUWzf8L9WQoKV+Amk1E=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261008163010-8390fab5cb35 h1:3uag/9Tv4Y3ippY5Yr1rIIBh23Ur9RbhzOB4CLbKz0I=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261008163010-8390fab5cb35/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The account agents run as (novox/hq ADR 0266): a module names it as a machine fact — the agent account
|
||||
// where the node names one, the operator's otherwise — and asks the node-engine to judge it never to become
|
||||
// root only where it is the agents' own.
|
||||
|
||||
func TestTheAgentAccountFactFallsBackToTheOperatorAndIsNeverRootOnlyWhenItsOwn(t *testing.T) {
|
||||
facts := machineFacts(Resolution{Node: "anchor", Account: "ops"}, nil, "")
|
||||
if facts["agent-account"] != "ops" || facts["agent-home"] != "/home/ops" || facts["agent-root"] != "" {
|
||||
t.Errorf("with no agent account named, agents run as the operator: %v", facts)
|
||||
}
|
||||
facts = machineFacts(Resolution{Node: "anchor", Account: "ops", AccountHome: "/srv/ops"}, nil, "")
|
||||
if facts["agent-home"] != "/srv/ops" {
|
||||
t.Errorf("the operator's stated home is the agent's home when they are one account: %v", facts)
|
||||
}
|
||||
facts = machineFacts(Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent"}, nil, "")
|
||||
if facts["agent-account"] != "agent" || facts["agent-home"] != "/home/agent" || facts["agent-root"] != RootNever {
|
||||
t.Errorf("a named agent account is the agents', never root: %v", facts)
|
||||
}
|
||||
if facts["account"] != "ops" {
|
||||
t.Errorf("the operator account is still the operator's: %v", facts)
|
||||
}
|
||||
facts = machineFacts(Resolution{Node: "anchor", AgentAccount: "agent", AgentAccountHome: "/var/lib/agent"}, nil, "")
|
||||
if facts["agent-home"] != "/var/lib/agent" || facts["agent-root"] != RootNever {
|
||||
t.Errorf("an agent account with a stated home on a machine with no operator: %v", facts)
|
||||
}
|
||||
if _, has := machineFacts(Resolution{Node: "anchor"}, nil, "")["agent-account"]; has {
|
||||
t.Error("a machine with no account at all names an agent account")
|
||||
}
|
||||
}
|
||||
|
||||
// The agent's module, in the shape the catalogue's declares it: the account, never root where it is its
|
||||
// own; its directory under that home, owned by it.
|
||||
const agentModule = `{"module": "agent", "version": "1", "resources": [
|
||||
{"id": "account", "type": "user", "name": "${machine:agent-account}", "root": "${machine:agent-root}"},
|
||||
{"id": "home", "type": "directory", "path": "${machine:agent-home}/.agent", "mode": "0700",
|
||||
"owner": "${machine:agent-account}"}
|
||||
]}`
|
||||
|
||||
func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(agentModule))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
compose := func(r Resolution, with Rendering) (user, home map[string]any) {
|
||||
t.Helper()
|
||||
r.Node, r.Modules = "anchor", []Manifest{m}
|
||||
out, err := r.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fileNamed(out, "agent.account"), fileNamed(out, "agent.home")
|
||||
}
|
||||
|
||||
user, home := compose(Resolution{Account: "ops", AgentAccount: "agent"}, Rendering{JudgesRoot: true})
|
||||
if user["name"] != "agent" || user[RootField] != RootNever {
|
||||
t.Errorf("an engine that judges root is sent the agent account never to become root: %v", user)
|
||||
}
|
||||
if home["path"] != "/home/agent/.agent" || home["owner"] != "agent" {
|
||||
t.Errorf("the agent's directory is under its own home, its own: %v", home)
|
||||
}
|
||||
|
||||
user, _ = compose(Resolution{Account: "ops", AgentAccount: "agent"}, Rendering{})
|
||||
if _, sent := user[RootField]; sent || user["name"] != "agent" {
|
||||
t.Errorf("an older engine, which parses strictly, is sent root: %v", user)
|
||||
}
|
||||
|
||||
user, home = compose(Resolution{Account: "ops"}, Rendering{JudgesRoot: true})
|
||||
if _, sent := user[RootField]; sent || user["name"] != "ops" {
|
||||
t.Errorf("where agents run as the operator, root asserts nothing and is not sent: %v", user)
|
||||
}
|
||||
if home["path"] != "/home/ops/.agent" || home["owner"] != "ops" {
|
||||
t.Errorf("with no agent account, the agent's directory is the operator's: %v", home)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRuntimeIsToldTheAgentAccount(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
||||
envOf := func(r Resolution) map[string]string {
|
||||
t.Helper()
|
||||
r.Node, r.Modules = "anchor", []Manifest{aToolsModule(t, "nftables", "tools/index.js"), theRuntime(t)}
|
||||
out, err := r.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||
if process == nil {
|
||||
t.Fatal("no runtime process was composed")
|
||||
}
|
||||
return process["env"].(map[string]string)
|
||||
}
|
||||
env := envOf(Resolution{Account: "ops", AgentAccount: "agent"})
|
||||
if env[RuntimeAgentAccount] != "agent" || env[RuntimeAgentHome] != "/home/agent" || env[RuntimeOperatorAccount] != "ops" {
|
||||
t.Errorf("the runtime is not told whom agents run as: %v", env)
|
||||
}
|
||||
env = envOf(Resolution{Account: "ops"})
|
||||
if env[RuntimeAgentAccount] != "ops" || env[RuntimeAgentHome] != "/home/ops" {
|
||||
t.Errorf("with no agent account, agents run as the operator: %v", env)
|
||||
}
|
||||
env = envOf(Resolution{})
|
||||
if _, set := env[RuntimeAgentAccount]; set {
|
||||
t.Errorf("a machine with no account names an agent account: %v", env)
|
||||
}
|
||||
if problems := bundleEnvProblems("x", Artifact{Name: "b", Kind: ArtifactBundle, Loads: []string{"x"},
|
||||
Env: map[string]string{RuntimeAgentAccount: "me"}}); len(problems) == 0 {
|
||||
t.Error("a bundle may tell the runtime whom agents run as")
|
||||
}
|
||||
}
|
||||
@@ -399,7 +399,7 @@ func versionOf(digest string) string {
|
||||
// telling the runtime what it is, which is the mesh's to say (novox/hq ADR 0192).
|
||||
var bundleEnvWords = map[string]bool{
|
||||
RuntimeToolModules: true, RuntimeBrokerFile: true, RuntimeOperatorAccount: true,
|
||||
RuntimeOperatorHome: true, RuntimeToolEnv: true,
|
||||
RuntimeOperatorHome: true, RuntimeToolEnv: true, RuntimeAgentAccount: true, RuntimeAgentHome: true,
|
||||
}
|
||||
|
||||
// bundleEnvProblems says what is wrong with what a bundle says it is given (novox/hq ADR 0192):
|
||||
|
||||
@@ -241,6 +241,31 @@ type Rendering struct {
|
||||
// refuses a field it does not know, whole — so to it the field is not sent, and what it runs is
|
||||
// judged by liveness alone.
|
||||
ReadsHealth bool
|
||||
|
||||
// JudgesRoot says this machine's node-engine judges a user's declared `root` (novox/hq ADR 0266: its
|
||||
// statement's contract is link.RootContract or later). To an older, strict engine the field is not
|
||||
// sent, and the account it names is not judged — which the self-check says, as not judged.
|
||||
JudgesRoot bool
|
||||
}
|
||||
|
||||
// RootField is a user resource's field saying the account must never become root without a person
|
||||
// (novox/hq ADR 0266).
|
||||
const RootField = "root"
|
||||
|
||||
// rootInto composes a user's `root` for the node-engine: taken away when it asserts nothing (empty — a
|
||||
// machine where agents run as the operator) or when the engine is older than the field and parses
|
||||
// strictly; kept as "never" otherwise.
|
||||
func rootInto(resource map[string]any, with Rendering) {
|
||||
if resource["type"] != "user" {
|
||||
return
|
||||
}
|
||||
value, has := resource[RootField]
|
||||
if !has {
|
||||
return
|
||||
}
|
||||
if s, _ := value.(string); s == "" || !with.JudgesRoot {
|
||||
delete(resource, RootField)
|
||||
}
|
||||
}
|
||||
|
||||
// machinePort is where a module's port lives on this machine, or the port itself when the mesh has
|
||||
@@ -980,6 +1005,9 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
// How it is ready, in the node-engine's words: its endpoint as the port this machine
|
||||
// published it on — or not sent at all to an engine older than the field (ADR 0240).
|
||||
healthInto(copied, m, with)
|
||||
// And a user's `root` (novox/hq ADR 0266): sent only when it asserts something, to an engine
|
||||
// that judges it.
|
||||
rootInto(copied, with)
|
||||
// The account's environment and every module's shell code, where this module holds the
|
||||
// seat that places them (novox/hq ADR 0203, ADR 0204). Gathered from every module on
|
||||
// the node, as the jails are, and **last of every placeholder pass**: shell code is a
|
||||
|
||||
@@ -78,9 +78,47 @@ func machineFacts(r Resolution, names map[string]string, meshRange string) map[s
|
||||
out["account"] = r.Account
|
||||
out["account-home"] = accountHomeOf(r.Account, r.AccountHome)
|
||||
}
|
||||
// The account agents run as here, and whether it must never become root (novox/hq ADR 0266). The agent
|
||||
// account where the node names one; the operator account otherwise, so a module writing the agent's
|
||||
// home names one fact on every machine. `agent-root` is "never" only for an account of the agents' own:
|
||||
// the user resource naming it then asks the node-engine to judge it, and on a machine where agents run
|
||||
// as the operator it is empty, asserting nothing — the operator's account may become root there.
|
||||
if agent, home := r.agentAccount(); agent != "" {
|
||||
out["agent-account"] = agent
|
||||
out["agent-home"] = home
|
||||
out["agent-root"] = ""
|
||||
if r.AgentAccount != "" {
|
||||
out["agent-root"] = RootNever
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RootNever is what a user resource's `root` says of an account that must never become root without a
|
||||
// person (novox/hq ADR 0266); the node-engine judges it.
|
||||
const RootNever = "never"
|
||||
|
||||
// agentAccount is the account agents run as on this machine and its home: the agent account when the node
|
||||
// names one (novox/hq ADR 0266), else the operator account; empty when neither is known.
|
||||
func (r Resolution) agentAccount() (string, string) {
|
||||
if r.AgentAccount != "" {
|
||||
return r.AgentAccount, agentHomeOf(r.AgentAccount, r.AgentAccountHome)
|
||||
}
|
||||
if r.Account != "" {
|
||||
return r.Account, accountHomeOf(r.Account, r.AccountHome)
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// agentHomeOf is where the agent account's home is: what was stored, or /home/<account>. Never /root: the
|
||||
// agent account is never root.
|
||||
func agentHomeOf(account, home string) string {
|
||||
if home != "" {
|
||||
return home
|
||||
}
|
||||
return "/home/" + account
|
||||
}
|
||||
|
||||
// accountHomeOf is where an account's home is: what was stored, or the derived default — /root for
|
||||
// root, /home/<account> otherwise. The one place the default is written, so a fact and the store
|
||||
// cannot disagree about it.
|
||||
@@ -105,8 +143,10 @@ func machineInto(resource map[string]any, facts map[string]string, module string
|
||||
// (novox/hq to-be 29), the same reason its content names ${machine:address}. And the name a
|
||||
// `user` shape sets the login shell of, and the user a user-scoped unit or a process runs as:
|
||||
// the shell module makes the operator's account its holder's login shell, and the desktop's
|
||||
// watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person.
|
||||
for _, field := range []string{"path", "owner", "content", "name", "user"} {
|
||||
// watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person. And a
|
||||
// user's `root`: the agent's module declares the account agents run as with ${machine:agent-root},
|
||||
// "never" only where that account is the agents' own (novox/hq ADR 0266).
|
||||
for _, field := range []string{"path", "owner", "content", "name", "user", "root"} {
|
||||
s, ok := resource[field].(string)
|
||||
if !ok {
|
||||
continue
|
||||
|
||||
@@ -32,6 +32,11 @@ type Node struct {
|
||||
// to-be 29). What a home-scoped file is owned by and what ${machine:account} resolves to.
|
||||
Account string
|
||||
AccountHome string
|
||||
// AgentAccount is the login agents run as here when it is not the operator's, AgentAccountHome its
|
||||
// home when not derived (novox/hq ADR 0266). What ${machine:agent-account} resolves to; empty means
|
||||
// agents run as the operator account.
|
||||
AgentAccount string
|
||||
AgentAccountHome string
|
||||
}
|
||||
|
||||
// World is what the rest of the mesh already has.
|
||||
@@ -134,6 +139,10 @@ type Resolution struct {
|
||||
// here without a store lookup.
|
||||
Account string
|
||||
AccountHome string
|
||||
// AgentAccount and AgentAccountHome are the account agents run as here when it is not the
|
||||
// operator's, and its home (novox/hq ADR 0266); empty when agents run as the operator account.
|
||||
AgentAccount string
|
||||
AgentAccountHome string
|
||||
// Capabilities are the machine's, as its profile reported them, carried from the node so a
|
||||
// contribution placed only where the machine has something (`if-capability`, novox/hq ADR 0255)
|
||||
// is decided here without a store lookup.
|
||||
@@ -703,7 +712,8 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
}
|
||||
|
||||
resolution := Resolution{Node: node.Name, At: node.At, PublicDomain: node.PublicDomain,
|
||||
Account: node.Account, AccountHome: node.AccountHome, Capabilities: node.Capabilities,
|
||||
Account: node.Account, AccountHome: node.AccountHome, AgentAccount: node.AgentAccount,
|
||||
AgentAccountHome: node.AgentAccountHome, Capabilities: node.Capabilities,
|
||||
Because: because, Needs: needs, Unhostable: unhostable, Kept: kept}
|
||||
for _, n := range providersFirst(order, catalogue) {
|
||||
resolution.Modules = append(resolution.Modules, catalogue[n])
|
||||
|
||||
@@ -83,6 +83,11 @@ const (
|
||||
RuntimeBrokerFile = "MESH_BROKER_FILE"
|
||||
RuntimeOperatorAccount = "MESH_OPERATOR_ACCOUNT"
|
||||
RuntimeOperatorHome = "MESH_OPERATOR_HOME"
|
||||
// RuntimeAgentAccount and RuntimeAgentHome are the account agents run as on the machine and its home
|
||||
// (novox/hq ADR 0266): the agent account where the node names one, the operator account otherwise.
|
||||
// The agent's module writes the agent's home from them; absent where neither account is known.
|
||||
RuntimeAgentAccount = "MESH_AGENT_ACCOUNT"
|
||||
RuntimeAgentHome = "MESH_AGENT_HOME"
|
||||
// RuntimeToolEnv is every served module's composed environment, as JSON (novox/hq ADR 0192):
|
||||
// {"<module>": {"<word>": "<value>"}}. The runtime takes it at start, removes it from its own
|
||||
// environment and hands each module's words to that module's bundles alone. In the unit, so a
|
||||
@@ -215,6 +220,10 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
|
||||
env[RuntimeOperatorHome] = accountHomeOf(r.Account, r.AccountHome)
|
||||
process["user"] = r.Account
|
||||
}
|
||||
if agent, home := r.agentAccount(); agent != "" {
|
||||
env[RuntimeAgentAccount] = agent
|
||||
env[RuntimeAgentHome] = home
|
||||
}
|
||||
// Routed through the artifact store as this network reaches it now, like everything the mesh
|
||||
// built; refused with the same words when there is no store to route through.
|
||||
if err := artifactsInto(process, RuntimeModule, with); err != nil {
|
||||
|
||||
@@ -131,6 +131,10 @@ type Machine struct {
|
||||
// home is when that is not the derived one.
|
||||
Account string `json:"account,omitempty"`
|
||||
AccountHome string `json:"account-home,omitempty"`
|
||||
// AgentAccount is the account agents run as there when it is not the operator's (a pseudonym), and
|
||||
// AgentAccountHome its home when not derived (novox/hq ADR 0266).
|
||||
AgentAccount string `json:"agent-account,omitempty"`
|
||||
AgentAccountHome string `json:"agent-account-home,omitempty"`
|
||||
// PublicDomain is the domain it answers for, its labels replaced.
|
||||
PublicDomain string `json:"public-domain,omitempty"`
|
||||
// Assigned is every module assigned there.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The agent account (novox/hq ADR 0266): recorded and read back with every node, its home derived when
|
||||
// not stated, cleared by an empty name — and refused when it is root, the operator's own account, or no
|
||||
// login at all, because each of those would say agents have an account of their own while they do not.
|
||||
func TestAgentAccountIsRecordedAndRefusedWhereItWouldNotConfine(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := context.Background()
|
||||
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.SetAccount(ctx, "anchor", "operator", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
n, err := inv.NodeByName(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n.AgentAccount != "" || n.AgentHome() != "" {
|
||||
t.Fatalf("a node that names none has agent account %q, home %q", n.AgentAccount, n.AgentHome())
|
||||
}
|
||||
|
||||
if err := inv.SetAgentAccount(ctx, "anchor", "agent", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, _ = inv.NodeByName(ctx, "anchor")
|
||||
if n.AgentAccount != "agent" || n.AgentHome() != "/home/agent" {
|
||||
t.Fatalf("agent account %q, home %q; want agent, /home/agent", n.AgentAccount, n.AgentHome())
|
||||
}
|
||||
all, err := inv.Nodes(ctx)
|
||||
if err != nil || len(all) != 1 || all[0].AgentAccount != "agent" {
|
||||
t.Fatalf("the listing does not carry the agent account: %+v %v", all, err)
|
||||
}
|
||||
|
||||
if err := inv.SetAgentAccount(ctx, "anchor", "agent", "/srv/agent"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ = inv.NodeByName(ctx, "anchor"); n.AgentHome() != "/srv/agent" {
|
||||
t.Fatalf("the stated home is %q", n.AgentHome())
|
||||
}
|
||||
|
||||
for _, c := range []struct{ account, home, says string }{
|
||||
{"root", "", "may not run as root"},
|
||||
{"operator", "", "operator account"},
|
||||
{"Agent", "", "not a login name"},
|
||||
{"9agent", "", "not a login name"},
|
||||
{"agent", "relative", "absolute"},
|
||||
{"", "/home/x", "without an agent account"},
|
||||
} {
|
||||
err := inv.SetAgentAccount(ctx, "anchor", c.account, c.home)
|
||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%q %q: %v; want a refusal saying %q", c.account, c.home, err, c.says)
|
||||
}
|
||||
}
|
||||
if n, _ = inv.NodeByName(ctx, "anchor"); n.AgentAccount != "agent" {
|
||||
t.Fatalf("a refusal changed the record: %q", n.AgentAccount)
|
||||
}
|
||||
|
||||
if err := inv.SetAgentAccount(ctx, "anchor", "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ = inv.NodeByName(ctx, "anchor"); n.AgentAccount != "" || n.AgentAccountHome != "" {
|
||||
t.Fatalf("clearing left %q %q", n.AgentAccount, n.AgentAccountHome)
|
||||
}
|
||||
if err := inv.SetAgentAccount(ctx, "nowhere", "agent", ""); !errors.Is(err, ErrNoSuchNode) {
|
||||
t.Fatalf("an unknown node: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ type ResourceHealth struct {
|
||||
// Account is the account whose own service manager runs it, or the account a resource of kind account
|
||||
// is (novox/hq ADR 0254).
|
||||
Account string `json:"account,omitempty"`
|
||||
// Root is "never" on an account verdict that judged whether the account can become root without a
|
||||
// person (novox/hq ADR 0266).
|
||||
Root string `json:"root,omitempty"`
|
||||
}
|
||||
|
||||
// NodeHealth is a machine's newest statement, as kept.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- A node names the account its 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: any agent there could become root without a person. The decision is an account of the
|
||||
-- agents' own, without sudo, beside the operator's, who keeps theirs. Stated by the operator at the
|
||||
-- controller's terminal, like the operator account (migration 0036), and never by a verb or a setting,
|
||||
-- so no agent can change which account it is.
|
||||
--
|
||||
-- Empty rather than null, as the operator account is: empty is a real state, "agents run as the
|
||||
-- operator's account here" — a workstation's today. The home is stored only when it is not
|
||||
-- /home/<account>; empty means derive it.
|
||||
alter table node add column agent_account text not null default '';
|
||||
alter table node add column agent_account_home text not null default '';
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -69,6 +70,14 @@ type Node struct {
|
||||
Account string
|
||||
AccountHome string
|
||||
|
||||
// AgentAccount is the login agents run as on this machine when it is not the operator's — `agent`
|
||||
// on the control node (novox/hq ADR 0266): an account of their own, without sudo, so no agent there
|
||||
// can become root without a person. Empty means agents run as the operator account. Stated at the
|
||||
// controller's terminal only, never by a verb or a setting. AgentAccountHome is its home when not
|
||||
// /home/<account>; empty means derive it.
|
||||
AgentAccount string
|
||||
AgentAccountHome string
|
||||
|
||||
// HostVersion is the version of the host this machine reported running (novox/hq 04-ISSUES/087).
|
||||
// Empty when it has not said since the mesh began keeping it — which is not the same as running
|
||||
// no host, so nothing derives "behind" from an empty one.
|
||||
@@ -91,6 +100,17 @@ func (n Node) Home() string {
|
||||
}
|
||||
}
|
||||
|
||||
// AgentHome is the agent account's home, derived when not stored; empty when no agent account is named.
|
||||
func (n Node) AgentHome() string {
|
||||
if n.AgentAccount == "" {
|
||||
return ""
|
||||
}
|
||||
if n.AgentAccountHome != "" {
|
||||
return n.AgentAccountHome
|
||||
}
|
||||
return "/home/" + n.AgentAccount
|
||||
}
|
||||
|
||||
// Silent is how long since this node was last heard from, and whether it ever was.
|
||||
func (n Node) Silent() (time.Duration, bool) {
|
||||
if n.LastSeen.IsZero() {
|
||||
@@ -147,14 +167,14 @@ func (i *Inventory) AddNodeAs(ctx context.Context, name string, adopted bool) (N
|
||||
// nodeColumns and scanNode are the one reading of a node row, so every way of finding a node
|
||||
// says whether it is adopted.
|
||||
const nodeColumns = `id, name, created, last_seen, adopted, adopted_since, account, account_home,
|
||||
host_version`
|
||||
agent_account, agent_account_home, host_version`
|
||||
|
||||
func scanNode(row pgx.Row) (Node, error) {
|
||||
var n Node
|
||||
var seen, since *time.Time
|
||||
var host *string
|
||||
if err := row.Scan(&n.ID, &n.Name, &n.Created, &seen, &n.Adopted, &since,
|
||||
&n.Account, &n.AccountHome, &host); err != nil {
|
||||
&n.Account, &n.AccountHome, &n.AgentAccount, &n.AgentAccountHome, &host); err != nil {
|
||||
return Node{}, err
|
||||
}
|
||||
if host != nil {
|
||||
@@ -184,6 +204,63 @@ func (i *Inventory) SetAccount(ctx context.Context, node, account, home string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// loginName is what a login may be called: what useradd accepts by default, lower case, a letter or
|
||||
// an underscore first.
|
||||
var loginName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
|
||||
|
||||
// SetAgentAccount records the account agents run as on a node, and optionally its home (novox/hq ADR
|
||||
// 0266). An empty account clears it: agents run as the operator account again.
|
||||
//
|
||||
// **Refused, rather than recorded and judged later:** root, which is the very thing the account exists
|
||||
// to keep agents from; the node's operator account, which may become root without a password and is
|
||||
// what agents ran as before — naming it here would say the agents have an account of their own while
|
||||
// they do not; and a name no machine would accept as a login.
|
||||
func (i *Inventory) SetAgentAccount(ctx context.Context, node, account, home string) error {
|
||||
account, home = strings.TrimSpace(account), strings.TrimSpace(home)
|
||||
if account == "" && home != "" {
|
||||
return errors.New("a home without an agent account says nothing; name the account too")
|
||||
}
|
||||
if account != "" {
|
||||
if err := AgentAccountRefusal(account, home); err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := i.NodeByName(ctx, node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n.Account != "" && n.Account == account {
|
||||
return fmt.Errorf("%s is %s's operator account: agents would run as the operator, who may become "+
|
||||
"root; clear the agent account instead (node agent-account %s --clear)", account, node, node)
|
||||
}
|
||||
}
|
||||
tag, err := i.store.Pool().Exec(ctx,
|
||||
`update node set agent_account = $1, agent_account_home = $2 where name = $3`, account, home, node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("%w: %s", ErrNoSuchNode, node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AgentAccountRefusal is why an agent account cannot be named, or nil: root, a malformed login, or a
|
||||
// home that is not an absolute path.
|
||||
func AgentAccountRefusal(account, home string) error {
|
||||
if account == "root" {
|
||||
return errors.New("agents may not run as root: the agent account exists to keep them from it " +
|
||||
"(novox/hq ADR 0266)")
|
||||
}
|
||||
if !loginName.MatchString(account) {
|
||||
return fmt.Errorf("%q is not a login name: lower case letters, digits, _ and -, a letter or _ first, "+
|
||||
"at most 32", account)
|
||||
}
|
||||
if home != "" && !strings.HasPrefix(home, "/") {
|
||||
return fmt.Errorf("the agent account's home %q is not an absolute path", home)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Nodes are every node record, oldest first.
|
||||
func (i *Inventory) Nodes(ctx context.Context) ([]Node, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
|
||||
@@ -420,6 +420,20 @@ const LivenessContract = 1
|
||||
// because an older one parses strictly and would refuse the whole declaration for it.
|
||||
const ReadinessContract = 2
|
||||
|
||||
// RootContract is the statement of an engine that also judges a user's declared `root` (novox/hq ADR 0266):
|
||||
// whether an account declared never to become root without a person can — uid 0, a group that grants root,
|
||||
// a sudo rule, a secret of the mesh it may read. Only to such an engine is the field sent: an older one
|
||||
// parses strictly and would refuse the whole declaration for it.
|
||||
const RootContract = 3
|
||||
|
||||
// ReasonRoot starts the reason of an account verdict that found a way to root (ADR 0266); the node-engine's
|
||||
// own words (mesh-host internal/accounts ReasonRoot).
|
||||
const ReasonRoot = "can become root without a person"
|
||||
|
||||
// RootNever is the value of a user's `root`, and of a verdict's Root, that the account must never become
|
||||
// root without a person (ADR 0266).
|
||||
const RootNever = "never"
|
||||
|
||||
// Health is one statement of a machine's long-running resources (to-be 48 §4): in every report, as the
|
||||
// event HealthSubject between reports on each change, and again every minute while one is not healthy.
|
||||
// The node-engine's own (mesh-host internal/link Health); a test on each side holds the field names.
|
||||
@@ -542,6 +556,10 @@ type ResourceHealth struct {
|
||||
// manager, and the account itself for a resource of kind KindAccount (novox/hq ADR 0254). Empty from an
|
||||
// engine older than that, and for anything the machine's own manager or runtime runs.
|
||||
Account string `json:"account,omitempty"`
|
||||
// Root is "never" on a verdict of kind KindAccount whose account is declared never to become root
|
||||
// without a person (novox/hq ADR 0266): the engine judged that too, and a healthy verdict says it cannot.
|
||||
// Empty from an engine older than RootContract, and on every other verdict.
|
||||
Root string `json:"root,omitempty"`
|
||||
}
|
||||
|
||||
// HealthSaid is the health event's body: the machine and its statement. The machine is read from the
|
||||
|
||||
+20
@@ -383,8 +383,25 @@ type User struct {
|
||||
// has it not. On the account rather than on the unit, because it is the account's: two units of
|
||||
// one account cannot disagree about it, and undeclaring one of them must not stop the other.
|
||||
Linger *bool `json:"linger,omitempty"`
|
||||
|
||||
// Root says whether this account may become root without a person (novox/hq ADR 0266). "never"
|
||||
// is the agents' own account: the login an agent session runs as on a machine where it must not
|
||||
// reach root by itself. Empty asserts nothing, as Shell's does.
|
||||
//
|
||||
// **A statement the engine judges, never one it acts on.** The apply gives an account it creates
|
||||
// no password, no sudo rule and no group beyond those declared, as it always has, and takes none
|
||||
// away from one it finds: a sudo rule or a group granted by hand is a person's to remove, and a
|
||||
// declaration that silently stripped them would be the mesh deciding what a person's machine
|
||||
// grants. What "never" adds is the look: on every look the engine reads whether the account can
|
||||
// become root by itself — by its uid, a group that grants root, any sudo rule, or a secret the mesh
|
||||
// placed that it can read — and says it unhealthy while it can (internal/accounts), so the
|
||||
// controller can tell a machine where it holds from one where it does not.
|
||||
Root string `json:"root,omitempty"`
|
||||
}
|
||||
|
||||
// RootNever is the one value Root takes besides empty: the account never becomes root without a person.
|
||||
const RootNever = "never"
|
||||
|
||||
// Network is a named network on this machine.
|
||||
//
|
||||
// **A name and nothing else.** Not a driver, a subnet or a gateway: each of those is something a
|
||||
@@ -478,6 +495,9 @@ func (u *User) validate(where string, _ bool) []string {
|
||||
if u.Home != "" && !strings.HasPrefix(u.Home, "/") {
|
||||
problems = append(problems, where+": a home directory is an absolute path")
|
||||
}
|
||||
if u.Root != "" && u.Root != RootNever {
|
||||
problems = append(problems, fmt.Sprintf("%s: root is %q or absent, and %q is neither", where, RootNever, u.Root))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
|
||||
Vendored
+2
-2
@@ -75,7 +75,7 @@ github.com/nats-io/nkeys
|
||||
# github.com/nats-io/nuid v1.0.1
|
||||
## explicit
|
||||
github.com/nats-io/nuid
|
||||
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac
|
||||
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261008163010-8390fab5cb35
|
||||
## explicit; go 1.26.0
|
||||
github.com/novox/mesh-host/internal/declaration
|
||||
github.com/novox/mesh-host/validate
|
||||
@@ -133,4 +133,4 @@ golang.org/x/text/width
|
||||
# golang.org/x/time v0.15.0
|
||||
## explicit; go 1.25.0
|
||||
golang.org/x/time/rate
|
||||
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac
|
||||
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261008163010-8390fab5cb35
|
||||
|
||||
Reference in New Issue
Block a user