diff --git a/cmd/mesh-controller/agent_account.go b/cmd/mesh-controller/agent_account.go new file mode 100644 index 00000000..e288c1a1 --- /dev/null +++ b/cmd/mesh-controller/agent_account.go @@ -0,0 +1,175 @@ +package main + +// The account agents run as (novox/hq ADR 0266). +// +// On the control node every agent session ran as the operator's account, which may become root without a +// password — so any agent there could become root without a person, and ADR 0259 §8 (an answer from the +// operator's phone authorises an act) rests on that being false where the router and its channels run. The +// decision: a node may name an account its agents run as, of their own and without sudo; the operator's +// account keeps its sudo. +// +// - **Named at the controller's terminal only** (`node agent-account`): not a verb, not a setting, so no +// agent can name itself another account. Empty is a real state: agents run as the operator there. +// - **Composed** as `${machine:agent-account}`, `${machine:agent-home}` and `${machine:agent-root}` for the +// agent's module, which declares the account with `root: never`, and as MESH_AGENT_ACCOUNT and +// MESH_AGENT_HOME for its tools (catalogue/machine_into_files.go, runtime.go). +// - **Judged by the machine itself.** The node-engine reads, on every look, whether an account declared +// `root: never` can become root without a person — uid 0, a group that grants root, a sudo rule, a +// secret of the mesh it may read — and says it as the declaring module's account verdict, marked +// Root "never". agentConfined reads that verdict; the self-check (probe DA) raises +// `agent-can-become-root` while it does not hold, and `node show` says it. +// +// A verdict not given is never a pass: an engine older than the judging, an account not yet declared, a +// statement that says nothing of it — each is "not judged", and fails. + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/novox/mesh-controller/internal/conditions" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// kindAgentCanBecomeRoot is the condition raised while a machine's agent account can become root without +// a person, or is not judged (ADR 0266). +const kindAgentCanBecomeRoot = "agent-can-become-root" + +// agentAccountProbe is the self-check's probe of it. +const agentAccountProbe = "DA" + +// agentConfined says whether the agents of a machine that names an agent account are confined: the +// machine's newest statement holds a healthy account verdict, judged for root, on that account. named is +// false for a machine that names none — agents run as the operator account there, which this does not +// judge. why is said either way, in the mesh's words; err is a store that could not be read. +// +// The one judgement: `node show`, the self-check, and ADR 0259's router honouring a verified sender read +// it here. +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, time.Now()) + return true, confined, why, nil +} + +// verdictFreshFor is how old the statement holding the verdict may be, by this controller's clock. A +// node-engine states its health on every change and at least every five minutes (mesh-host's sayAnyway), so +// three statements missed is a node-engine stopped, or a machine away. **A stale verdict is not a pass**: an +// agent that stopped the node-engine must not leave "cannot become root" standing from before. +const verdictFreshFor = 15 * time.Minute + +// judgedConfined is the judgement over one statement, without the store, at now. +func judgedConfined(agent string, h inventory.NodeHealth, had bool, now time.Time) (bool, string) { + if !had { + return false, fmt.Sprintf("the agent account %s is not judged: the machine's node-engine has stated "+ + "nothing of what it runs", agent) + } + if age := now.Sub(h.HeardAt); age > verdictFreshFor { + return false, fmt.Sprintf("the agent account %s is not judged: the machine's newest statement was heard at "+ + "%s, more than %d minutes ago, and a verdict that old is not a verdict on now", agent, + h.HeardAt.Local().Format("2006-01-02 15:04"), int(verdictFreshFor.Minutes())) + } + if h.Contract < link.RootContract { + return false, fmt.Sprintf("the agent account %s is not judged: the machine's node-engine is older than "+ + "the judging of an account's root (its statement's contract is %d, the judging is %d)", + agent, h.Contract, link.RootContract) + } + var verdicts []inventory.ResourceHealth + for _, r := range h.Resources { + if r.Kind == link.KindAccount && r.Target == agent && r.Root == link.RootNever { + verdicts = append(verdicts, r) + } + } + if len(verdicts) == 0 { + return false, fmt.Sprintf("the agent account %s is not judged: the machine's newest statement holds no "+ + "verdict on it — no module there declares it never to become root, or the declaration naming it "+ + "has not been applied", agent) + } + sort.Slice(verdicts, func(i, j int) bool { + return verdicts[i].Module+verdicts[i].Resource < verdicts[j].Module+verdicts[j].Resource + }) + for _, v := range verdicts { + switch v.State { + case link.StateHealthy: + case link.StateUnhealthy: + // The engine's own words, which start with link.ReasonRoot when it found a way to root. + return false, fmt.Sprintf("the agent account %s %s (said by %s's %s)", agent, + orNoneKnown(v.Reason), v.Module, v.Resource) + default: + return false, fmt.Sprintf("the agent account %s is not judged: %s (%s's %s, %s)", agent, + orNoneKnown(v.Reason), v.Module, v.Resource, v.State) + } + } + return true, fmt.Sprintf("the agent account %s cannot become root without a person (judged %s)", agent, + h.SaidAt.Local().Format("2006-01-02 15:04")) +} + +// 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, time.Now()) + 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 +} diff --git a/cmd/mesh-controller/agent_account_test.go b/cmd/mesh-controller/agent_account_test.go new file mode 100644 index 00000000..3d384620 --- /dev/null +++ b/cmd/mesh-controller/agent_account_test.go @@ -0,0 +1,199 @@ +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, 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 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) + } +} + +// 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) + } + } +} diff --git a/cmd/mesh-controller/calls_verb_test.go b/cmd/mesh-controller/calls_verb_test.go index 1aaea74f..606a53f8 100644 --- a/cmd/mesh-controller/calls_verb_test.go +++ b/cmd/mesh-controller/calls_verb_test.go @@ -37,8 +37,6 @@ func TestAPushAnswersBeforeItSends(t *testing.T) { }{ {"push", map[string]any{"node": "anchor", "why": "w"}, true}, {"push", map[string]any{"why": "w"}, true}, - {"command", map[string]any{"command": "push anchor --why w"}, true}, - {"command", map[string]any{"command": "push --behind --why=w"}, true}, {"command", map[string]any{"command": "builds"}, false}, {"status", map[string]any{}, false}, {"assign", map[string]any{"node": "anchor", "module": "m"}, false}, diff --git a/cmd/mesh-controller/doctor.go b/cmd/mesh-controller/doctor.go index 54f533ba..b694dd43 100644 --- a/cmd/mesh-controller/doctor.go +++ b/cmd/mesh-controller/doctor.go @@ -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 diff --git a/cmd/mesh-controller/facts.go b/cmd/mesh-controller/facts.go index 3990026c..01ffd437 100644 --- a/cmd/mesh-controller/facts.go +++ b/cmd/mesh-controller/facts.go @@ -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 diff --git a/cmd/mesh-controller/handacts_test.go b/cmd/mesh-controller/handacts_test.go index b8d445b7..7d2713d5 100644 --- a/cmd/mesh-controller/handacts_test.go +++ b/cmd/mesh-controller/handacts_test.go @@ -22,10 +22,6 @@ func TestARepairByHandWithoutAReasonIsRefused(t *testing.T) { {"plans", map[string]any{"close": "plan-1"}}, {"plans", map[string]any{"stop": "plan-1"}}, {"hand-act", map[string]any{"what": "restarted the proxy", "cause": "proxy-stuck"}}, - {"command", map[string]any{"command": "push anchor"}}, - {"command", map[string]any{"command": "plans close plan-1"}}, - {"command", map[string]any{"command": "broker consumer-reset EVENTS controller"}}, - {"command", map[string]any{"command": "hand-act record restarted --cause x"}}, } { argv, err := argvFor(c.verb, c.args) if c.verb == "plans" && err == nil { @@ -65,7 +61,6 @@ func TestARepairByHandCarriesItsReason(t *testing.T) { {"plans", map[string]any{"retry": "plan-1"}, "plans retry plan-1"}, {"hand-act", map[string]any{"what": "restarted", "why": "hung", "cause": "proxy", "condition": "machine.a.silent"}, "hand-act record restarted --why hung --cause proxy --condition machine.a.silent"}, - {"command", map[string]any{"command": "push anchor --why stuck"}, "push anchor --why stuck"}, {"command", map[string]any{"command": "plans plan-1"}, "plans plan-1"}, } { argv, err := argvFor(c.verb, c.args) diff --git a/cmd/mesh-controller/merge_gate.go b/cmd/mesh-controller/merge_gate.go index 269f76cf..dd925642 100644 --- a/cmd/mesh-controller/merge_gate.go +++ b/cmd/mesh-controller/merge_gate.go @@ -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 diff --git a/cmd/mesh-controller/module_health.go b/cmd/mesh-controller/module_health.go index 5a6759ce..b14689b3 100644 --- a/cmd/mesh-controller/module_health.go +++ b/cmd/mesh-controller/module_health.go @@ -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) diff --git a/cmd/mesh-controller/nodes.go b/cmd/mesh-controller/nodes.go index 579c01de..500009a8 100644 --- a/cmd/mesh-controller/nodes.go +++ b/cmd/mesh-controller/nodes.go @@ -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 `") + 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 ` 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 — what it is now; " + + " [home] to name the account agents run as (home defaults to /home/); " + + " --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 — what it is now; " + " to set it; --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 diff --git a/cmd/mesh-controller/plain_words.go b/cmd/mesh-controller/plain_words.go index 3d6d4ed3..61cab51d 100644 --- a/cmd/mesh-controller/plain_words.go +++ b/cmd/mesh-controller/plain_words.go @@ -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", diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 55586d33..faaa2dfe 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -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. diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index baf7f5b3..92e42822 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -55,6 +55,9 @@ func argvFor(verb string, args map[string]any) ([]string, error) { return nil, err } argv, err := a.commandLine() + if err == nil { + err = terminalOnly(argv) + } if len(a.misread) > 0 { // The table and the command line disagree: the verb reads an argument no caller can see // in its schema, so no caller could ever pass it. @@ -249,9 +252,14 @@ func (a *verbArguments) commandLine() ([]string, error) { // settings verb is where what a verb may not set is refused, and one route is one set of words. // The command refuses places and accesses through any verb as well; this says so before it runs. if argv[0] == "settings" && slices.ContainsFunc(argv[1:], func(w string) bool { return w == "set" || w == "clear" }) { - return nil, errors.New("settings are set and cleared through the settings verb, not the generic " + - "command; and places and accesses only at the controller's terminal (novox/hq issue 339). " + - "Nothing was done") + return nil, &heldAtTheTerminal{msg: "settings are set and cleared through the settings verb, not the " + + "generic command; and places and accesses only at the controller's terminal (novox/hq issue 339). " + + "Nothing was done"} + } + // The generic verb only reads (novox/hq ADR 0266): what writes has a named verb that composes its own + // line, or is the operator's at the controller's terminal. + if err := commandReads(argv); err != nil { + return nil, err } // The generic verb is no way round the hand-act log (novox/hq to-be 45 §7): a repair through // it says why, as it would through its own verb. @@ -1071,7 +1079,8 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) { } continue } - if _, err := argvFor(verb, sampleArguments(v)); err != nil { + var policy *heldAtTheTerminal + if _, err := argvFor(verb, sampleArguments(v)); err != nil && !errors.As(err, &policy) { // **A row ahead of this binary is not a reason to go silent.** // // The row is the store's and a control plane follows it (novox/hq ADR 0154), so a verb @@ -1332,3 +1341,131 @@ func seatAnnouncement(handlers map[string]link.ToolHandler) micro.Info { Endpoints: endpoints, } } + +// nodeReads are the `node` subcommands a verb may run: the ones that only read. +var nodeReads = map[string]bool{"list": true, "show": true} + +// flagsOnly says a command line's rest names no subcommand: empty, or beginning with a flag. For a command +// with no subcommands every word is a flag, its value or a name it reads. +func flagsOnly(rest []string) bool { return len(rest) == 0 || strings.HasPrefix(rest[0], "-") } + +// subIn says the rest begins with one of these subcommands. +func subIn(rest []string, subs ...string) bool { + return len(rest) > 0 && slices.Contains(subs, rest[0]) +} + +// commandReadForms are the command lines the generic `command` verb may run (novox/hq ADR 0266): **an allow +// list of the ones that only read**, judged command by command. Anything else — every command that writes a +// record, sends, builds, issues an account or a token, sets a key, accepts, rotates, recovers or exports a +// secret — is refused, and a command added later is refused until it is judged a read. Writing has its named +// verbs, which compose their own lines and are judged by terminalOnly; the rest is the operator's at the +// controller's terminal. +var commandReadForms = map[string]func(rest []string) bool{ + "status": flagsOnly, "version": flagsOnly, "help": flagsOnly, "seats": flagsOnly, "healers": flagsOnly, + "hand-acts": flagsOnly, "durations": flagsOnly, "collection": flagsOnly, "images": flagsOnly, + "artifacts": flagsOnly, "data": flagsOnly, "builds": flagsOnly, "queue": flagsOnly, + // `plan ` previews a node's declaration; it sends nothing. + "plan": func([]string) bool { return true }, + // `plans` lists and `plans ` shows one; `plans stop|close|go` acts. + // Judged on every word, not the first: a flag before the subcommand (`plans --json go `) still acts. + "plans": func(r []string) bool { + return !slices.ContainsFunc(r, func(w string) bool { return slices.Contains(plansActs, w) }) + }, + // `doctor` answers the last run, `probes` and `signals` describe; `doctor run` runs. + "doctor": func(r []string) bool { return flagsOnly(r) || subIn(r, "probes", "signals") }, + "conditions": func(r []string) bool { return flagsOnly(r) || subIn(r, "list", "show", "history") }, + "node": func(r []string) bool { return subIn(r, "list", "show") }, + "module": func(r []string) bool { return subIn(r, "list") }, + "settings": func(r []string) bool { return subIn(r, "show", "preferences") }, + "retire": func(r []string) bool { return subIn(r, "list") }, + "cleanup": func(r []string) bool { return subIn(r, "list") }, + "delivery": func(r []string) bool { return subIn(r, "plan", "walks") }, + // `bus` alone says the bus's step; `bus upgrade` takes one. + "bus": func(r []string) bool { return len(r) == 0 }, + // `mirrors` lists; --record and --confirm keep a mirror. + "mirrors": func(r []string) bool { + return flagsOnly(r) && !slices.ContainsFunc(r, func(w string) bool { + return w == "--record" || w == "-record" || strings.HasPrefix(w, "--record=") || strings.HasPrefix(w, "-record=") || + w == "--confirm" || w == "-confirm" || strings.HasPrefix(w, "--confirm=") + }) + }, +} + +// plansActs are the `plans` subcommands that act on a walk; no other word of a plans line is one of them. +var plansActs = []string{"go", "stop", "close", "retry"} + +// heldAtTheTerminal is a refusal of policy (novox/hq ADR 0266): the verb is known and served, and this line is +// the operator's at the controller's terminal. Never read as a verb this binary is behind on. +type heldAtTheTerminal struct{ msg string } + +func (e *heldAtTheTerminal) Error() string { return e.msg } + +func terminalRefusal(format string, args ...any) error { + return &heldAtTheTerminal{fmt.Sprintf(format, args...)} +} + +// commandReads refuses a line the generic verb may not run, saying what it may. +func commandReads(argv []string) error { + if read, ok := commandReadForms[argv[0]]; ok && read(argv[1:]) { + return nil + } + return terminalRefusal("%q is not a reading command, and the generic command verb only reads (novox/hq ADR 0266): "+ + "whoever may call a verb includes agents, and a line that writes, issues, sets a key or reveals a secret "+ + "would be theirs to run. Use the named verb for it, or run it at the controller's terminal. The verb may "+ + "run: %s. Nothing was done", strings.Join(argv, " "), commandReadNames()) +} + +func commandReadNames() string { + names := make([]string, 0, len(commandReadForms)) + for n := range commandReadForms { + names = append(names, n) + } + sort.Strings(names) + return strings.Join(names, ", ") + " (each in its reading forms)" +} + +// terminalOnlyCommands are the commands no verb runs, whatever composed them (novox/hq ADR 0266): they set +// the operator's key, issue a credential or a token that is answered to the caller, or accept, recover or +// export a secret. Their answers or effects hand whoever calls them what the runtime's account holds. +var terminalOnlyCommands = map[string]string{ + "operator": "the operator's key and credential", + "identity": "the mesh's identity keys", + "token": "a token a machine joins with, answered to the caller", + "broker": "the bus's accounts", + "api": "the controller's API keys", + "licence": "the licences' secrets", +} + +// terminalOnly refuses, through any verb, a command that is the operator's at the controller's terminal +// alone (novox/hq ADR 0266). **Every `node` subcommand that is not a read**: `node account` and +// `node agent-account` above all. Whoever may call a verb includes agents, and an agent that named itself +// the operator account, or cleared the agent account, would have the next send grant it root through the +// sudo module's rule. An allow list, so a subcommand added later is refused until it is judged a read. +func terminalOnly(argv []string) error { + if len(argv) == 0 { + return nil + } + if what, kept := terminalOnlyCommands[argv[0]]; kept { + return terminalRefusal("%s is run at the controller's terminal only, never through a verb: it holds %s, and "+ + "whoever may call a verb includes agents (novox/hq ADR 0266). Nothing was done", argv[0], what) + } + // Of a secret's commands only rotation, which seals the new value to the machine that uses it. + if argv[0] == "secret" && (len(argv) < 2 || argv[1] != "rotate") { + return terminalRefusal("secret %s is run at the controller's terminal only, never through a verb: accepting, "+ + "recovering or exporting a secret hands it to whoever asks, and that includes agents (novox/hq ADR "+ + "0266). Nothing was done", strings.Join(argv[1:], " ")) + } + if argv[0] != "node" { + return nil + } + if len(argv) > 1 && nodeReads[argv[1]] { + return nil + } + sub := "node" + if len(argv) > 1 { + sub += " " + argv[1] + } + return terminalRefusal("%s is run at the controller's terminal only, never through a verb: a node's accounts "+ + "decide who may become root on it (novox/hq ADR 0266). A verb may run node list and node show. "+ + "Nothing was done", sub) +} diff --git a/cmd/mesh-controller/seatverbs_schema_test.go b/cmd/mesh-controller/seatverbs_schema_test.go index 08ca631c..176b3455 100644 --- a/cmd/mesh-controller/seatverbs_schema_test.go +++ b/cmd/mesh-controller/seatverbs_schema_test.go @@ -271,7 +271,6 @@ var accountedFlags = map[string]map[string]string{ "builds": {"n": "=limit"}, "plans": {"n": "=limit", "what-if": "=repository"}, // The machine's tunnel key, named as the verb's other arguments are (novox/hq ADR 0169). - "token issue": {"overlay-key": "=overlay_key"}, "durations": { "json": "set by the verb: the answer is data", "all": "withheld: every measurement of a fortnight is more than a call should carry; `command` reaches it", diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index 127e0714..e1a5bf24 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "github.com/novox/mesh-controller/internal/link" "strings" @@ -108,11 +109,14 @@ func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) { } } -// `token` is `token issue` at a shell, with the machine's tunnel key (novox/hq ADR 0169). -func TestTokenIssuesForAMachineAndItsTunnelKey(t *testing.T) { - argv, err := argvFor("token", map[string]any{"new": "laptop", "overlay_key": "k", "for": "2h"}) - if err != nil || strings.Join(argv, " ") != "token issue --new laptop --overlay-key k --for 2h" { - t.Fatalf("token: %v %v", argv, err) +// `token` answers a joining token to its caller, and whoever may call a verb includes agents: it is the +// controller's terminal's alone (novox/hq ADR 0266), refused through the verb whatever it is given. +func TestTokenIsRefusedThroughAVerb(t *testing.T) { + for _, args := range []map[string]any{{"new": "laptop", "overlay_key": "k", "for": "2h"}, {"node": "ace"}} { + argv, err := argvFor("token", args) + if err == nil || !strings.Contains(err.Error(), "controller's terminal only") { + t.Fatalf("token %v: %v %v", args, argv, err) + } } } @@ -237,20 +241,20 @@ func TestAJSONVerbsAnswerIsItsStandardOutput(t *testing.T) { } } -// `command` is the generic verb: the command line as given, split as a shell would, nothing added — -// so an operator's `node account g14 jochen` is one call through the console rather than a shell on -// the control node (novox/hq ADR 0154, ADR 0175). +// `command` is the generic verb: the command line as given, split as a shell would, nothing added +// (novox/hq ADR 0154, ADR 0175). It once carried an operator's `node account g14 jochen` too; a node's +// accounts are the controller's terminal's alone since ADR 0266 (TestNoVerbSetsANodesAccounts). func TestCommandRunsTheLineAsGiven(t *testing.T) { - argv, err := argvFor("command", map[string]any{"command": "node account g14 jochen"}) - if err != nil || strings.Join(argv, " ") != "node account g14 jochen" { + argv, err := argvFor("command", map[string]any{"command": "node show g14"}) + if err != nil || strings.Join(argv, " ") != "node show g14" { t.Fatalf("a plain line: %v %v", argv, err) } argv, err = argvFor("command", map[string]any{"command": `settings show dnsmasq '{"a": "b c"}' --node ace`}) if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` { t.Fatalf("a quoted word stays one word: %q %v", argv, err) } - argv, err = argvFor("command", map[string]any{"command": `node add "the box" --adopted`}) - if err != nil || len(argv) != 4 || argv[2] != "the box" { + argv, err = argvFor("command", map[string]any{"command": `plan "the box" --json`}) + if err != nil || len(argv) != 3 || argv[1] != "the box" { t.Fatalf("double quotes group: %q %v", argv, err) } if _, err := argvFor("command", map[string]any{"command": " "}); err == nil { @@ -355,3 +359,59 @@ func TestTheControllerAnnouncesTheVerbsItServes(t *testing.T) { } } } + +// The generic verb only reads (novox/hq ADR 0266): an allow list of reading forms, and every line that +// writes, issues, sets a key or reveals a secret refused — the chain a review found ran through it: set the +// operator's key to one the caller holds, rotate secrets sealed to it, open them. +func TestTheCommandVerbOnlyReads(t *testing.T) { + for _, line := range []string{ + "operator key set --replace k", "operator issue", "secret accept a b", "secret rotate a b c", + "secret recover a", "secret export a", "token issue --new x", "identity show", "broker users", + "api key", "licence show", "push anchor --why w", "assign novox m", "settings set m {}", + "settings clear m", "module add f", "module check /etc", "module forget m", "module issue m --node a", + "seat rename a b", "plans close p --why w", "plans go p", "plans retry p", "plans stop p", + "plans --json go p", "plans -n 3 close p", "plans --what-if r retry p", "doctor run", "conditions silence c --why w", + "retire approve x", "cleanup delete x", "delivery check", "delivery go x", "bus upgrade", + "mirrors --record x", "mirrors --confirm", "hand-act record x --why y --cause z", "serve", "migrate", + "prepare", "declare x", "overlay x", "facts", "merge-gate", "check-here", "build x", "rebuild x", + "cancel x", "kill x", "clear x", "replay x", "pause", "resume", "pin a b", "unpin a", "take x", + "converge", "adopt x", "rollout x", "upgrade x", "collect", "board", "builder", "ask x", "frobnicate", + } { + argv, err := argvFor("command", map[string]any{"command": line}) + var policy *heldAtTheTerminal + if err == nil || !errors.As(err, &policy) { + t.Errorf("%q ran as %v (%v); the generic verb only reads", line, argv, err) + } + } + for _, line := range []string{ + "status --json", "version", "seats --json", "healers", "hand-acts --days 3", "durations", "collection", + "images", "artifacts --collected", "data --machine a", "builds --log b", "queue", "plan ace --diff", + "plans", "plans plan-1", "doctor", "doctor probes", "doctor signals", "conditions", "conditions list", + "conditions show c", "conditions history", "node list", "node show ace", "module list", + "settings show m", "settings preferences", "retire list", "cleanup list", "delivery plan --repository r", + "delivery walks", "bus", "mirrors --json", + } { + if _, err := argvFor("command", map[string]any{"command": line}); err != nil { + t.Errorf("%q, a read, was refused: %v", line, err) + } + } +} + +// What hands a caller a key, a credential or a secret is refused whichever verb composed it. +func TestNoVerbSetsTheOperatorsKeyOrRevealsASecret(t *testing.T) { + for _, argv := range [][]string{ + {"operator", "key", "set"}, {"operator", "issue"}, {"identity"}, {"token", "issue"}, {"broker", "users"}, + {"api"}, {"licence"}, {"secret", "export", "x"}, {"secret", "recover", "x"}, {"secret", "accept", "x"}, {"secret"}, + } { + if err := terminalOnly(argv); err == nil { + t.Errorf("%v passed", argv) + } + } + if err := terminalOnly([]string{"secret", "rotate", "n", "m", "s"}); err != nil { + t.Errorf("rotating seals to the machine that uses the secret, and stays a verb's: %v", err) + } + // A policy refusal is not a verb this binary is behind on: every verb is still served. + if _, behind, err := seatToolHandlers(); err != nil || len(behind) != 0 { + t.Fatalf("behind %v: %v", behind, err) + } +} diff --git a/go.mod b/go.mod index a84b281a..88491951 100644 --- a/go.mod +++ b/go.mod @@ -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-20261009103656-1c61b72f354d diff --git a/go.sum b/go.sum index 04d5c9e7..1507aa09 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,12 @@ 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-20261009081005-b28d7bbcbff4 h1:f4rBnKSemuN0Z9dTtRJMigIGfEs6ltFPOILJGHGab74= +git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo= +git.novox.be/novox/mesh-host v0.0.0-20261009101157-2673e7a2c95e h1:H7eVqDILL6e9cMbWSLHTbCqu9ZxDOmyeQhUmWl9QBV0= +git.novox.be/novox/mesh-host v0.0.0-20261009101157-2673e7a2c95e/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo= +git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d h1:IrmJ+lz21n+eSqKrmXREtR/7raUCBJ+fZvs+BNhuXVI= +git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo= 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= diff --git a/internal/catalogue/agent_account_test.go b/internal/catalogue/agent_account_test.go new file mode 100644 index 00000000..90efb041 --- /dev/null +++ b/internal/catalogue/agent_account_test.go @@ -0,0 +1,119 @@ +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}", "home": "${machine:agent-home}", + "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 || user["home"] != "/home/agent" { + 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", AgentAccountHome: "/srv/agent"}, Rendering{JudgesRoot: true}) + if user["home"] != "/srv/agent" { + t.Errorf("an agent account named with a home of its own is made there: %v", user) + } + + 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") + } +} diff --git a/internal/catalogue/build.go b/internal/catalogue/build.go index 7a97b3ff..388b769c 100644 --- a/internal/catalogue/build.go +++ b/internal/catalogue/build.go @@ -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): diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 9e8b5cc7..fadab8da 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -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 @@ -981,6 +1006,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 diff --git a/internal/catalogue/machine_into_files.go b/internal/catalogue/machine_into_files.go index aa79e711..f0e38de6 100644 --- a/internal/catalogue/machine_into_files.go +++ b/internal/catalogue/machine_into_files.go @@ -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/. 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/ otherwise. The one place the default is written, so a fact and the store // cannot disagree about it. @@ -105,8 +143,12 @@ 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), and its `home`, so an account + // the operator named with a home of its own is made there (${machine:agent-home}); the node-engine reads a + // user's home only when it creates the account, so an existing one is never moved. + for _, field := range []string{"path", "owner", "content", "name", "user", "root", "home"} { s, ok := resource[field].(string) if !ok { continue diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index 2cfd163c..de3b5305 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -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]) diff --git a/internal/catalogue/runtime.go b/internal/catalogue/runtime.go index 65d326b8..71a5c87a 100644 --- a/internal/catalogue/runtime.go +++ b/internal/catalogue/runtime.go @@ -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): // {"": {"": ""}}. 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 { diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 332cb4ea..6f2525fa 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -237,9 +237,9 @@ var ControllerVerbs = []Verb{ "node": "the machine that runs the module", "module": "the module's name", }, []string{"node", "module"})}, - {Name: "token", Description: "Issue a one-time token for a machine to join with. Give the public half of the " + - "tunnel key the machine made (`nox-mesh-host key`): the machine is given its address and made a peer of " + - "the hub, and joins through the tunnel. The token is shown once, in the answer.", + {Name: "token", Description: "Refused through a verb since novox/hq ADR 0266: the one-time token a machine " + + "joins with is answered to its caller, and whoever may call a verb includes agents. Issue it at the " + + "controller's terminal: `mesh-controller token issue --new --overlay-key `.", Input: schema(map[string]string{ "node": "a machine the mesh already has a record for", "new": "or the name of a machine to create the record for", @@ -267,10 +267,14 @@ var ControllerVerbs = []Verb{ "list": "\"preferences\": every module's preferences — key, default and why — and the value on each " + "machine it is assigned to with where it comes from; module and node narrow it (novox/hq ADR 0262)", }, nil, "clear", "replace", "history")}, - {Name: "command", Description: "Run one command line of the controller's own, as you would type it at its " + - "shell — `node account g14 jochen`, `node show ace`, `module list` — and answer what it printed. The " + - "generic verb beside the named ones (novox/hq ADR 0154): everything the binary can do, without a verb " + - "per command. Any node may call any tool (ADR 0175), so nothing is held back here.", + {Name: "command", Description: "Run one reading command line of the controller's own, as you would type it at " + + "its shell — `node show ace`, `module list`, `plans`, `conditions show ` — and answer what it " + + "printed. The generic verb beside the named ones (novox/hq ADR 0154), and since ADR 0266 it only reads: " + + "status, version, help, seats, healers, hand-acts, durations, collection, images, artifacts, data, builds, " + + "queue, plan, plans (not stop/close/go), doctor (not run), conditions list/show/history, node list/show, " + + "module list, settings show/preferences, retire list, cleanup list, delivery plan/walks, bus, mirrors (not " + + "--record/--confirm). What writes has its named verb; what sets a key, issues a credential or a token, or " + + "accepts, recovers or exports a secret is the controller's terminal's alone.", Input: schema(map[string]string{ "command": "the command line, as the controller's binary takes it; quotes group a word with spaces", }, []string{"command"})}, diff --git a/internal/facts/facts.go b/internal/facts/facts.go index 368dc0c1..e010916d 100644 --- a/internal/facts/facts.go +++ b/internal/facts/facts.go @@ -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. diff --git a/internal/inventory/agent_account_test.go b/internal/inventory/agent_account_test.go new file mode 100644 index 00000000..44139bb8 --- /dev/null +++ b/internal/inventory/agent_account_test.go @@ -0,0 +1,93 @@ +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"}, + {"postgres", "", "service account"}, + {"systemd-network", "", "service account"}, + {"showcase", "", "service account"}, + {"", "/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) + } + + // The other direction: the operator account may not be named as the agent account either. + if err := inv.SetAccount(ctx, "anchor", "agent", ""); err == nil || !strings.Contains(err.Error(), "agent account") { + t.Fatalf("the operator account named as the agent account: %v; want a refusal", err) + } + if n, _ = inv.NodeByName(ctx, "anchor"); n.Account != "operator" { + t.Fatalf("a refusal changed the operator account: %q", n.Account) + } + + if err := inv.SetAgentAccount(ctx, "anchor", "", ""); err != nil { + t.Fatal(err) + } + if err := inv.SetAccount(ctx, "anchor", "agent", ""); err != nil { + t.Fatalf("with the agent account cleared, the name is free: %v", err) + } + if err := inv.SetAccount(ctx, "anchor", "operator", ""); 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) + } +} diff --git a/internal/inventory/health.go b/internal/inventory/health.go index 3272fd74..8f3797a7 100644 --- a/internal/inventory/health.go +++ b/internal/inventory/health.go @@ -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. diff --git a/internal/inventory/migrations/0083-a-node-names-the-account-its-agents-run-as.sql b/internal/inventory/migrations/0083-a-node-names-the-account-its-agents-run-as.sql new file mode 100644 index 00000000..2f8fbee0 --- /dev/null +++ b/internal/inventory/migrations/0083-a-node-names-the-account-its-agents-run-as.sql @@ -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/; 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 ''; diff --git a/internal/inventory/nodes.go b/internal/inventory/nodes.go index 4c13ad05..f71f2697 100644 --- a/internal/inventory/nodes.go +++ b/internal/inventory/nodes.go @@ -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/; 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 { @@ -172,7 +192,23 @@ func scanNode(row pgx.Row) (Node, error) { // SetAccount records the operator account on a node — its human login — and optionally where that // account's home is (novox/hq to-be 29). An empty home means the mesh derives it. Clearing the // account (empty name) is allowed: a machine may stop having a known operator. +// +// **Never the node's agent account** (novox/hq ADR 0266): the operator account may become root, and the +// agent account exists so agents cannot; naming the one as the other gives agents root. Refused here as +// SetAgentAccount refuses the other direction. func (i *Inventory) SetAccount(ctx context.Context, node, account, home string) error { + account = strings.TrimSpace(account) + if account != "" { + n, err := i.NodeByName(ctx, node) + if err != nil { + return err + } + if n.AgentAccount != "" && n.AgentAccount == account { + return fmt.Errorf("%s is %s's agent account: the operator account may become root, and agents run as "+ + "%s so that they cannot (novox/hq ADR 0266); clear the agent account first "+ + "(node agent-account %s --clear) if the operator is to log in as it", account, node, account, node) + } + } tag, err := i.store.Pool().Exec(ctx, `update node set account = $1, account_home = $2 where name = $3`, account, home, node) if err != nil { @@ -184,6 +220,86 @@ 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 +} + +// serviceAccounts are the system and service accounts a machine of the mesh has, or a module of the +// catalogue declares (showcase, and the accounts ADR 0259 gives the router and the channels). The controller +// cannot read a machine's user database, so this list is the controller's half; the node-engine's half is +// refusing to take an existing account below the first login uid as one that must never become root. +var serviceAccounts = map[string]bool{ + "root": true, "bin": true, "daemon": true, "sys": true, "adm": true, "nobody": true, "mail": true, + "ftp": true, "http": true, "www-data": true, "git": true, "sshd": true, "dbus": true, "polkitd": true, + "postgres": true, "docker": true, "nats": true, "redis": true, "uuidd": true, "dnsmasq": true, + "avahi": true, "rtkit": true, "colord": true, "geoclue": true, "tss": true, "alpm": true, "usbmux": true, + "showcase": true, "messenger": true, "telegram": true, +} + +// serviceAccount says whether a name is a system or service account: one of the list, or a name of +// systemd's own (systemd-…). +func serviceAccount(name string) bool { + return serviceAccounts[name] || strings.HasPrefix(name, "systemd-") +} + +// 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 serviceAccount(account) { + return fmt.Errorf("%q is a system or service account a machine or a module already has: the agent "+ + "account is one the mesh creates for agents alone, which nothing else runs as or owns files as "+ + "(novox/hq ADR 0266); name a new one, such as agent", 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, diff --git a/internal/link/protocol.go b/internal/link/protocol.go index ad959149..36448195 100644 --- a/internal/link/protocol.go +++ b/internal/link/protocol.go @@ -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. @@ -550,6 +564,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 diff --git a/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go b/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go index 2472adf1..ea03cf57 100644 --- a/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go +++ b/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go @@ -198,6 +198,12 @@ type File struct { // by looking rather than by knowing which field won. Sealed string `json:"sealed,omitempty"` + // Trusted is the catalogue's word on whether the settings this file's content took are trusted (novox/hq + // issue 339). It is the controller's to act on, and a controller takes it out before it sends a declaration. + // Accepted here, and nothing is done with it, so that a controller that passes it on — an older one, or one + // rolled back to — never costs a node its whole declaration. Not part of the file's digest. + Trusted *bool `json:"trusted,omitempty"` + // Secrets are sealed values put into Content where it says `${secret:name}`. // // **The one place a secret and a configuration meet, and it happens on the machine.** A @@ -383,8 +389,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 +501,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 } diff --git a/vendor/modules.txt b/vendor/modules.txt index 6eb9699c..fb673637 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -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-20261009103656-1c61b72f354d ## 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-20261009103656-1c61b72f354d