From fcfbf7e69e3d5b1ac109e8ade08bb917749816c4 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 18:37:07 +0200 Subject: [PATCH 01/11] 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. --- cmd/mesh-controller/agent_account.go | 163 ++++++++++++++++++ cmd/mesh-controller/agent_account_test.go | 160 +++++++++++++++++ cmd/mesh-controller/doctor.go | 5 + cmd/mesh-controller/facts.go | 3 +- cmd/mesh-controller/merge_gate.go | 5 + cmd/mesh-controller/module_health.go | 2 +- cmd/mesh-controller/nodes.go | 63 ++++++- cmd/mesh-controller/plain_words.go | 9 + cmd/mesh-controller/plan.go | 18 +- go.mod | 2 +- go.sum | 2 + internal/catalogue/agent_account_test.go | 113 ++++++++++++ internal/catalogue/build.go | 2 +- internal/catalogue/declaration.go | 28 +++ internal/catalogue/machine_into_files.go | 44 ++++- internal/catalogue/resolve.go | 12 +- internal/catalogue/runtime.go | 9 + internal/facts/facts.go | 4 + internal/inventory/agent_account_test.go | 76 ++++++++ internal/inventory/health.go | 3 + ...de-names-the-account-its-agents-run-as.sql | 13 ++ internal/inventory/nodes.go | 81 ++++++++- internal/link/protocol.go | 18 ++ .../internal/declaration/declaration.go | 20 +++ vendor/modules.txt | 4 +- 25 files changed, 846 insertions(+), 13 deletions(-) create mode 100644 cmd/mesh-controller/agent_account.go create mode 100644 cmd/mesh-controller/agent_account_test.go create mode 100644 internal/catalogue/agent_account_test.go create mode 100644 internal/inventory/agent_account_test.go create mode 100644 internal/inventory/migrations/0083-a-node-names-the-account-its-agents-run-as.sql diff --git a/cmd/mesh-controller/agent_account.go b/cmd/mesh-controller/agent_account.go new file mode 100644 index 00000000..8900b406 --- /dev/null +++ b/cmd/mesh-controller/agent_account.go @@ -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 +} diff --git a/cmd/mesh-controller/agent_account_test.go b/cmd/mesh-controller/agent_account_test.go new file mode 100644 index 00000000..5a157d4c --- /dev/null +++ b/cmd/mesh-controller/agent_account_test.go @@ -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) + } + } +} 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/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/go.mod b/go.mod index a84b281a..6c1418dd 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-20261008163010-8390fab5cb35 diff --git a/go.sum b/go.sum index 04d5c9e7..da80eb63 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/catalogue/agent_account_test.go b/internal/catalogue/agent_account_test.go new file mode 100644 index 00000000..70b18b26 --- /dev/null +++ b/internal/catalogue/agent_account_test.go @@ -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") + } +} 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..928bf8de 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,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 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/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..d807d36b --- /dev/null +++ b/internal/inventory/agent_account_test.go @@ -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) + } +} 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..ee427004 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 { @@ -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, 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..5ab8e65e 100644 --- a/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go +++ b/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go @@ -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 } diff --git a/vendor/modules.txt b/vendor/modules.txt index 6eb9699c..0dd98a66 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-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 From dcbbf4487a39db59f232997c8508b41160bc8125 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 20:39:28 +0200 Subject: [PATCH 02/11] Refuse a node's accounts through any verb, and a stale or service-account agent verdict The generic command verb ran node account and node agent-account, so an agent could name itself the operator account and have the next send grant it root (hq ADR 0266 review). Refuse every node subcommand but list and show through any verb; refuse the operator account as the agent account in both directions and well-known service accounts as an agent account; and count a verdict heard more than 15 minutes ago as not judged, so stopping the node-engine cannot freeze a healthy one. Re-pin mesh-host to its review head. --- cmd/mesh-controller/agent_account.go | 20 ++++++++--- cmd/mesh-controller/agent_account_test.go | 43 +++++++++++++++++++++-- cmd/mesh-controller/seatverbs.go | 27 ++++++++++++++ cmd/mesh-controller/seatverbs_test.go | 12 +++---- go.mod | 2 +- go.sum | 2 ++ internal/catalogue/verbs.go | 7 ++-- internal/inventory/agent_account_test.go | 17 +++++++++ internal/inventory/nodes.go | 39 ++++++++++++++++++++ vendor/modules.txt | 4 +-- 10 files changed, 155 insertions(+), 18 deletions(-) diff --git a/cmd/mesh-controller/agent_account.go b/cmd/mesh-controller/agent_account.go index 8900b406..e288c1a1 100644 --- a/cmd/mesh-controller/agent_account.go +++ b/cmd/mesh-controller/agent_account.go @@ -27,6 +27,7 @@ import ( "fmt" "sort" "strings" + "time" "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/inventory" @@ -60,16 +61,27 @@ func agentConfined(ctx context.Context, inv *inventory.Inventory, node string) ( if err != nil { return true, false, "", err } - confined, why = judgedConfined(n.AgentAccount, h, had) + confined, why = judgedConfined(n.AgentAccount, h, had, time.Now()) 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) { +// 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)", @@ -122,7 +134,7 @@ func probeAgentAccounts(ctx context.Context, d *doctor) ([]conditions.Observatio if err != nil { return nil, err } - confined, why := judgedConfined(n.AgentAccount, h, had) + confined, why := judgedConfined(n.AgentAccount, h, had, time.Now()) if confined { continue } diff --git a/cmd/mesh-controller/agent_account_test.go b/cmd/mesh-controller/agent_account_test.go index 5a157d4c..42dab04f 100644 --- a/cmd/mesh-controller/agent_account_test.go +++ b/cmd/mesh-controller/agent_account_test.go @@ -22,7 +22,7 @@ func TestAnAgentAccountIsConfinedOnlyOnAHealthyVerdictJudgedForRoot(t *testing.T 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} + return inventory.NodeHealth{Node: "anchor", Contract: contract, SaidAt: at, HeardAt: at, Resources: rs} } for _, c := range []struct { name string @@ -45,11 +45,21 @@ func TestAnAgentAccountIsConfinedOnlyOnAHealthyVerdictJudgedForRoot(t *testing.T {"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) + 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 @@ -146,6 +156,35 @@ func TestTheFactsCarryTheAgentAccountAsAPseudonym(t *testing.T) { } } +// 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 only") || + !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"}) diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index baf7f5b3..897cdb45 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. @@ -1332,3 +1335,27 @@ 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} + +// 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 || argv[0] != "node" { + return nil + } + if len(argv) > 1 && nodeReads[argv[1]] { + return nil + } + sub := "node" + if len(argv) > 1 { + sub += " " + argv[1] + } + return fmt.Errorf("%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_test.go b/cmd/mesh-controller/seatverbs_test.go index 127e0714..e0a71f2e 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -237,19 +237,19 @@ 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`}) + argv, err = argvFor("command", map[string]any{"command": `module show "the box" --json`}) if err != nil || len(argv) != 4 || argv[2] != "the box" { t.Fatalf("double quotes group: %q %v", argv, err) } diff --git a/go.mod b/go.mod index 6c1418dd..2e978079 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-20261008163010-8390fab5cb35 +replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261008183646-5fc37b44a94e diff --git a/go.sum b/go.sum index da80eb63..eca6cf1b 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac h1:yLtFS0pDCCqIE 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= +git.novox.be/novox/mesh-host v0.0.0-20261008183646-5fc37b44a94e h1:+XxiuXJqWj7ZcGMB2b/WGPsC8zpmXgmwXnBvjw9C/CM= +git.novox.be/novox/mesh-host v0.0.0-20261008183646-5fc37b44a94e/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/verbs.go b/internal/catalogue/verbs.go index 332cb4ea..2df181ee 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -268,9 +268,10 @@ var ControllerVerbs = []Verb{ "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.", + "shell — `node show ace`, `module list` — and answer what it printed. The generic verb beside the named " + + "ones (novox/hq ADR 0154): what the binary can do, without a verb per command. Held back: every `node` " + + "command but `node list` and `node show` — a node's accounts decide who may become root on it, and are " + + "set at the controller's terminal only (ADR 0266).", 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/inventory/agent_account_test.go b/internal/inventory/agent_account_test.go index d807d36b..44139bb8 100644 --- a/internal/inventory/agent_account_test.go +++ b/internal/inventory/agent_account_test.go @@ -53,6 +53,9 @@ func TestAgentAccountIsRecordedAndRefusedWhereItWouldNotConfine(t *testing.T) { {"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) @@ -64,9 +67,23 @@ func TestAgentAccountIsRecordedAndRefusedWhereItWouldNotConfine(t *testing.T) { 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) } diff --git a/internal/inventory/nodes.go b/internal/inventory/nodes.go index ee427004..f71f2697 100644 --- a/internal/inventory/nodes.go +++ b/internal/inventory/nodes.go @@ -192,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 { @@ -244,6 +260,24 @@ func (i *Inventory) SetAgentAccount(ctx context.Context, node, account, home str 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 { @@ -255,6 +289,11 @@ func AgentAccountRefusal(account, home string) error { 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) } diff --git a/vendor/modules.txt b/vendor/modules.txt index 0dd98a66..f2187cb8 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-20261008163010-8390fab5cb35 +# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261008183646-5fc37b44a94e ## 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-20261008163010-8390fab5cb35 +# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261008183646-5fc37b44a94e From 528951319cc7073bc5fcb0ca7ed20bf32797c715 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 20:53:19 +0200 Subject: [PATCH 03/11] Pin the node-engine at the commit that refuses a system account as the agents' The validator is unchanged; the pin follows the mesh-host pull request's head so the two are judged together (hq ADR 0266). --- go.mod | 2 +- go.sum | 2 ++ vendor/modules.txt | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 2e978079..d1f1b3c5 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-20261008183646-5fc37b44a94e +replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261008185244-76f3ca12b8b8 diff --git a/go.sum b/go.sum index eca6cf1b..97295a44 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ git.novox.be/novox/mesh-host v0.0.0-20261008163010-8390fab5cb35 h1:3uag/9Tv4Y3ip git.novox.be/novox/mesh-host v0.0.0-20261008163010-8390fab5cb35/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs= git.novox.be/novox/mesh-host v0.0.0-20261008183646-5fc37b44a94e h1:+XxiuXJqWj7ZcGMB2b/WGPsC8zpmXgmwXnBvjw9C/CM= git.novox.be/novox/mesh-host v0.0.0-20261008183646-5fc37b44a94e/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo= +git.novox.be/novox/mesh-host v0.0.0-20261008185244-76f3ca12b8b8 h1:T4Bu9ymmcNC0x57EnDkjfZlJ9dvEeK8BRuHZTqw+uuI= +git.novox.be/novox/mesh-host v0.0.0-20261008185244-76f3ca12b8b8/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/vendor/modules.txt b/vendor/modules.txt index f2187cb8..44a2309c 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-20261008183646-5fc37b44a94e +# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261008185244-76f3ca12b8b8 ## 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-20261008183646-5fc37b44a94e +# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261008185244-76f3ca12b8b8 From 20d4f1ae71223b1a91f53caa9ac4512a36a85647 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 21:21:57 +0200 Subject: [PATCH 04/11] Let the generic command verb only read, and keep keys and tokens at the terminal Review found a chain through the command verb: set the operator's key to one the caller holds, rotate secrets so they are sealed to it too, read the sealed copies, open them. Whoever may call a verb includes agents (hq ADR 0266), so command now runs an allow list of reading forms, and operator, identity, token, broker, api, licence and every secret command but rotate are refused through any verb. --- cmd/mesh-controller/agent_account_test.go | 2 +- cmd/mesh-controller/calls_verb_test.go | 2 - cmd/mesh-controller/handacts_test.go | 5 - cmd/mesh-controller/seatverbs.go | 116 ++++++++++++++++++- cmd/mesh-controller/seatverbs_schema_test.go | 1 - cmd/mesh-controller/seatverbs_test.go | 73 ++++++++++-- internal/catalogue/verbs.go | 19 +-- 7 files changed, 188 insertions(+), 30 deletions(-) diff --git a/cmd/mesh-controller/agent_account_test.go b/cmd/mesh-controller/agent_account_test.go index 42dab04f..3d384620 100644 --- a/cmd/mesh-controller/agent_account_test.go +++ b/cmd/mesh-controller/agent_account_test.go @@ -170,7 +170,7 @@ func TestNoVerbSetsANodesAccounts(t *testing.T) { "node frobnicate", } { argv, err := argvFor("command", map[string]any{"command": line}) - if err == nil || !strings.Contains(err.Error(), "controller's terminal only") || + 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) } 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/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/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 897cdb45..2f2e47f6 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -252,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. @@ -1074,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 @@ -1339,13 +1345,111 @@ func seatAnnouncement(handlers map[string]link.ToolHandler) micro.Info { // 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. + "plans": func(r []string) bool { return !subIn(r, "stop", "close", "go") }, + // `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=") + }) + }, +} + +// 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 || argv[0] != "node" { + 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]] { @@ -1355,7 +1459,7 @@ func terminalOnly(argv []string) error { if len(argv) > 1 { sub += " " + argv[1] } - return fmt.Errorf("%s is run at the controller's terminal only, never through a verb: a node's accounts "+ + 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 e0a71f2e..9a6643ab 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) + } } } @@ -249,8 +253,8 @@ func TestCommandRunsTheLineAsGiven(t *testing.T) { 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": `module show "the box" --json`}) - 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,58 @@ 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", "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/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 2df181ee..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,11 +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 show ace`, `module list` — and answer what it printed. The generic verb beside the named " + - "ones (novox/hq ADR 0154): what the binary can do, without a verb per command. Held back: every `node` " + - "command but `node list` and `node show` — a node's accounts decide who may become root on it, and are " + - "set at the controller's terminal only (ADR 0266).", + {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"})}, From 2b8a28adab4d916e569683c23719c9efabf488a9 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 21:32:03 +0200 Subject: [PATCH 05/11] Pin the node-engine at the head that judges an opened file's kind and links Keeps the controller judged together with mesh-host's pull request (hq ADR 0266); the validator and the wire are unchanged. --- go.mod | 2 +- go.sum | 2 ++ vendor/modules.txt | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index d1f1b3c5..c63f48cc 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-20261008185244-76f3ca12b8b8 +replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261008191932-c74cf16b755e diff --git a/go.sum b/go.sum index 97295a44..cac0c8d3 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ git.novox.be/novox/mesh-host v0.0.0-20261008183646-5fc37b44a94e h1:+XxiuXJqWj7Zc git.novox.be/novox/mesh-host v0.0.0-20261008183646-5fc37b44a94e/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo= git.novox.be/novox/mesh-host v0.0.0-20261008185244-76f3ca12b8b8 h1:T4Bu9ymmcNC0x57EnDkjfZlJ9dvEeK8BRuHZTqw+uuI= git.novox.be/novox/mesh-host v0.0.0-20261008185244-76f3ca12b8b8/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo= +git.novox.be/novox/mesh-host v0.0.0-20261008191932-c74cf16b755e h1:oQofUhCNm0m4+pVASxvErqisEOMTkOyWjTAfGqt2lHA= +git.novox.be/novox/mesh-host v0.0.0-20261008191932-c74cf16b755e/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/vendor/modules.txt b/vendor/modules.txt index 44a2309c..21f3d63d 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-20261008185244-76f3ca12b8b8 +# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261008191932-c74cf16b755e ## 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-20261008185244-76f3ca12b8b8 +# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261008191932-c74cf16b755e From b4dff64ae0a35df59943ff290da26a8fbed1541b Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 10:12:41 +0200 Subject: [PATCH 06/11] Refuse a plans line that acts wherever its subcommand stands A flag before the subcommand (plans --json go ) still acts, so the generic command verb judges every word of a plans line, retry included (hq ADR 0266). The rest of this change, places and accesses at the terminal and one line per setting, is issue 339's on main (#168, #170, #172), which now does it for every process a verb runs; this branch's --through-verb flag and its copy of those rules go. --- cmd/mesh-controller/seatverbs.go | 8 +++++++- cmd/mesh-controller/seatverbs_test.go | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 2f2e47f6..92e42822 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -1367,7 +1367,10 @@ var commandReadForms = map[string]func(rest []string) bool{ // `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. - "plans": func(r []string) bool { return !subIn(r, "stop", "close", "go") }, + // 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") }, @@ -1388,6 +1391,9 @@ var commandReadForms = map[string]func(rest []string) bool{ }, } +// 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 } diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index 9a6643ab..e1a5bf24 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -369,7 +369,8 @@ func TestTheCommandVerbOnlyReads(t *testing.T) { "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", "doctor run", "conditions silence c --why w", + "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", From d7fe5b609b4ba4cb226420e2bc83f6080efcb712 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 21:55:31 +0200 Subject: [PATCH 07/11] Pin the node-engine at the head that refuses a placement at the machine's own The validator and the wire are unchanged; the pin follows mesh-host's pull request so the two are judged together (hq ADR 0266). --- go.mod | 2 +- go.sum | 2 ++ vendor/modules.txt | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c63f48cc..6286f72d 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-20261008191932-c74cf16b755e +replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261008195316-9a85dffc11a2 diff --git a/go.sum b/go.sum index cac0c8d3..48a4cf16 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ git.novox.be/novox/mesh-host v0.0.0-20261008185244-76f3ca12b8b8 h1:T4Bu9ymmcNC0x git.novox.be/novox/mesh-host v0.0.0-20261008185244-76f3ca12b8b8/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo= git.novox.be/novox/mesh-host v0.0.0-20261008191932-c74cf16b755e h1:oQofUhCNm0m4+pVASxvErqisEOMTkOyWjTAfGqt2lHA= git.novox.be/novox/mesh-host v0.0.0-20261008191932-c74cf16b755e/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo= +git.novox.be/novox/mesh-host v0.0.0-20261008195316-9a85dffc11a2 h1:hYRCYzJi97QC8l3SMy6v40bc0wqd9Ms54H9jYR0Bei0= +git.novox.be/novox/mesh-host v0.0.0-20261008195316-9a85dffc11a2/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/vendor/modules.txt b/vendor/modules.txt index 21f3d63d..4cb95d49 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-20261008191932-c74cf16b755e +# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261008195316-9a85dffc11a2 ## 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-20261008191932-c74cf16b755e +# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261008195316-9a85dffc11a2 From db702312afc09790f43783e4caab041ffaa9d85d Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 11:02:44 +0200 Subject: [PATCH 08/11] Pin the node-engine at its pull request rebased onto main The engine's pull request now keeps main's placement guard (issue 339) and adds only the agent-home rule; the pin follows it so the two are judged together (hq ADR 0266). Move it to the engine's merged commit before this merges. --- go.mod | 2 +- go.sum | 12 ++---------- .../mesh-host/internal/declaration/declaration.go | 6 ++++++ vendor/modules.txt | 4 ++-- 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/go.mod b/go.mod index 6286f72d..0f39e6eb 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-20261008195316-9a85dffc11a2 +replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4 diff --git a/go.sum b/go.sum index 48a4cf16..264663b9 100644 --- a/go.sum +++ b/go.sum @@ -4,16 +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= -git.novox.be/novox/mesh-host v0.0.0-20261008183646-5fc37b44a94e h1:+XxiuXJqWj7ZcGMB2b/WGPsC8zpmXgmwXnBvjw9C/CM= -git.novox.be/novox/mesh-host v0.0.0-20261008183646-5fc37b44a94e/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo= -git.novox.be/novox/mesh-host v0.0.0-20261008185244-76f3ca12b8b8 h1:T4Bu9ymmcNC0x57EnDkjfZlJ9dvEeK8BRuHZTqw+uuI= -git.novox.be/novox/mesh-host v0.0.0-20261008185244-76f3ca12b8b8/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo= -git.novox.be/novox/mesh-host v0.0.0-20261008191932-c74cf16b755e h1:oQofUhCNm0m4+pVASxvErqisEOMTkOyWjTAfGqt2lHA= -git.novox.be/novox/mesh-host v0.0.0-20261008191932-c74cf16b755e/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo= -git.novox.be/novox/mesh-host v0.0.0-20261008195316-9a85dffc11a2 h1:hYRCYzJi97QC8l3SMy6v40bc0wqd9Ms54H9jYR0Bei0= -git.novox.be/novox/mesh-host v0.0.0-20261008195316-9a85dffc11a2/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo= +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= 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/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go b/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go index 5ab8e65e..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 diff --git a/vendor/modules.txt b/vendor/modules.txt index 4cb95d49..e63d25c7 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-20261008195316-9a85dffc11a2 +# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4 ## 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-20261008195316-9a85dffc11a2 +# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4 From 926dbd7a97a737ef99e54b932a502d5deba18cc7 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 11:02:44 +0200 Subject: [PATCH 09/11] Fill machine facts in a user's home, so an agent account named with a home is made there node agent-account [home] stored a home that nothing used: the account was created at /home/ while its files went to the named home (hq ADR 0266). The engine reads a user's home only when it creates the account, so an existing one is never moved. --- internal/catalogue/agent_account_test.go | 10 ++++++++-- internal/catalogue/machine_into_files.go | 6 ++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/internal/catalogue/agent_account_test.go b/internal/catalogue/agent_account_test.go index 70b18b26..90efb041 100644 --- a/internal/catalogue/agent_account_test.go +++ b/internal/catalogue/agent_account_test.go @@ -36,7 +36,8 @@ func TestTheAgentAccountFactFallsBackToTheOperatorAndIsNeverRootOnlyWhenItsOwn(t // 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": "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}"} ]}` @@ -57,13 +58,18 @@ func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing } user, home := compose(Resolution{Account: "ops", AgentAccount: "agent"}, Rendering{JudgesRoot: true}) - if user["name"] != "agent" || user[RootField] != RootNever { + 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) diff --git a/internal/catalogue/machine_into_files.go b/internal/catalogue/machine_into_files.go index 928bf8de..f0e38de6 100644 --- a/internal/catalogue/machine_into_files.go +++ b/internal/catalogue/machine_into_files.go @@ -145,8 +145,10 @@ func machineInto(resource map[string]any, facts map[string]string, module string // 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. 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"} { + // "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 From d951f22c040c019d79621593fdf4e957c6a25a7b Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 12:12:32 +0200 Subject: [PATCH 10/11] Pin the node-engine at the head whose setuid search runs to its own bound mesh-host #61 dropped the two-minute limit inside the search's 15-minute bound, asks pacman once, and calls a walk with errors incomplete; the pin follows it so the two are judged together (hq ADR 0266). Move it to the engine's merged commit before this merges. --- go.mod | 2 +- go.sum | 2 ++ vendor/modules.txt | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0f39e6eb..a1d9c30c 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-20261009081005-b28d7bbcbff4 +replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009101157-2673e7a2c95e diff --git a/go.sum b/go.sum index 264663b9..10cc176d 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac h1:yLtFS0pDCCqIE 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= 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/vendor/modules.txt b/vendor/modules.txt index e63d25c7..13494e97 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-20261009081005-b28d7bbcbff4 +# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261009101157-2673e7a2c95e ## 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-20261009081005-b28d7bbcbff4 +# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009101157-2673e7a2c95e From e168b60159f7601977ed71ff0ddca5f35cd79cea Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 12:37:12 +0200 Subject: [PATCH 11/11] Pin the node-engine at #61's head, which reads the homes in one order Only the engine's own test changed; the pin follows the head so the two are judged together (hq ADR 0266). Move it to the engine's merged commit before this merges. --- go.mod | 2 +- go.sum | 2 ++ vendor/modules.txt | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index a1d9c30c..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-20261009101157-2673e7a2c95e +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 10cc176d..1507aa09 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4 h1:f4rBnKSemuN0Z 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/vendor/modules.txt b/vendor/modules.txt index 13494e97..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-20261009101157-2673e7a2c95e +# 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-20261009101157-2673e7a2c95e +# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d