From fcfbf7e69e3d5b1ac109e8ade08bb917749816c4 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 18:37:07 +0200 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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 From a5e8baf6da51d3b2b3c8822f2a37dd6e23f3064a Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 11:39:52 +0200 Subject: [PATCH 12/16] Register a module only from the repository the catalogue builds it from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent could make a repository of its own, or fork one the mesh builds from, commit a module.json naming sudo or mesh-host, and ask the build verb for it: the outcome was registered under that name, and the next push made whoever wrote it root on every node (novox/hq ADR 0266 §7, the review of 2026-10-09). The trunk rule checked the trunk of the repository built, which was the agent's. The take-in, which every outcome reaches whichever verb asked it, now registers a module only from its registered repository, and a new module only from a repository the catalogue already builds from (a merge adding one); anything else only when the build request was kept as asked at the controller's terminal (migration 0084). Through a verb, a build of a repository the catalogue builds nothing from is not asked at all. --- cmd/mesh-controller/build.go | 18 +- cmd/mesh-controller/build_source.go | 197 ++++++++++++++++++ cmd/mesh-controller/build_source_test.go | 186 +++++++++++++++++ cmd/mesh-controller/build_test.go | 8 +- cmd/mesh-controller/gate_test.go | 3 +- cmd/mesh-controller/push_recreates_test.go | 2 +- cmd/mesh-controller/recorded_kept_test.go | 2 +- cmd/mesh-controller/replays_test.go | 4 +- cmd/mesh-controller/same_source_test.go | 4 +- ...-a-build-asked-at-the-terminal-says-so.sql | 11 + internal/inventory/pending.go | 23 +- 11 files changed, 443 insertions(+), 15 deletions(-) create mode 100644 cmd/mesh-controller/build_source.go create mode 100644 cmd/mesh-controller/build_source_test.go create mode 100644 internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index 083139ea..a69d8379 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -451,6 +451,10 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa if err != nil { return "", err } + // Through a verb, only a repository the catalogue builds from (novox/hq ADR 0266). + if err := verbMayAsk(ctx, source, repository); err != nil { + return "", err + } ident, err := openIdentity(ctx) if err != nil { @@ -500,8 +504,10 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa // never before, so an ask that failed never reads as a build in flight. A dry run registers nothing, and // is not kept. keep := !dryRun && asker != "" + // Asked at the terminal is what lets its outcome register a module from a repository the catalogue does + // not build it from (novox/hq ADR 0266); never through a verb. asked := inventory.BuildRequest{ID: request.ID, Repository: source.Repository, Seat: source.Seat, Path: path, - Ref: ref, For: asker} + Ref: ref, For: asker, AtTerminal: startedAtTheTerminal()} if wait == 0 { // Asked and not waited for (novox/hq issue 176): the outcome is the role's event, and the @@ -631,6 +637,13 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu return manifest, kept, fmt.Errorf("%s built %s (%s), and the mesh does not register it: %w", result.On, result.Repository, short(result.Commit), err) } + // **Only from the repository the catalogue builds the module from** (novox/hq ADR 0266): else the trunk + // below is the trunk of whatever repository was built, which may be one an agent made — and a module named + // `sudo` from it would be what the next push sends. Another repository is the operator's, at the terminal. + if err := mayRegisterFrom(ctx, inv, manifest.Module, recorded, result.ID); err != nil { + return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On, + manifest.Module, short(result.Commit), err) + } // **Only a commit on the trunk is published** (novox/hq ADR 0238): a commit off its repository's // default branch — a pull request's head, a feature branch built by hand, a `rebuild` or `replay // --register` of one — is for checking, and is never a module's version; nothing could then send it. @@ -719,6 +732,9 @@ func buildAndShow(ctx context.Context, source buildSource, path, ref string, wai if err != nil { return err } + if err := verbMayAsk(ctx, source, repository); err != nil { + return err + } ident, err := openIdentity(ctx) if err != nil { return err diff --git a/cmd/mesh-controller/build_source.go b/cmd/mesh-controller/build_source.go new file mode 100644 index 00000000..2d8b0ae5 --- /dev/null +++ b/cmd/mesh-controller/build_source.go @@ -0,0 +1,197 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// Where a build may register a module from (novox/hq ADR 0266). +// +// A build's outcome registers its module, and a registered module is what the next push sends. Before this, +// an outcome registered whatever its manifest named, from whichever repository it was built from: an agent +// that made a repository of its own, committed `modules/sudo/module.json` granting itself a rule without a +// password, and asked the `build` verb for it had its repository registered as the module `sudo` — whose next +// push made the agent root on every node. A fork of the node-engine did the same everywhere. The trunk rule +// (ADR 0238) did not stop it: the trunk it checked was the trunk of the repository built, which was the +// agent's own. +// +// So **an outcome registers a module only from the repository the catalogue already builds that module +// from**; and a module new to the catalogue only from a repository the catalogue already builds another +// module from — whose trunk takes a reviewed, approved merge, which is how a merge adds a module (novox/hq +// issue 300). Anything else — a module moved to another repository, a module from a repository the +// catalogue has never built — is the operator's, at the controller's terminal: allowed only when the build +// request was kept as asked there. Judged at the take-in, which every outcome reaches whoever hears it and +// whichever verb asked it (`build`, `rebuild`, `replay --register`, `assign` with build), and before the ask +// for a call through a verb, so an agent cannot have a build node run a repository the catalogue does not +// build from at all. + +// errNotItsSource is an outcome refused for where it was built from. +var errNotItsSource = errors.New("not built from the repository the catalogue builds it from") + +// startedAtTheTerminal says this process was started at the controller's terminal: neither a verb nor a seat call +// started it. runVerb sets both for every command a verb runs (seatverbs.go), and a verb is the only way an +// agent reaches the controller. +func startedAtTheTerminal() bool { + return os.Getenv(verbVar) == "" && os.Getenv(link.CallerVar) == "" +} + +// sourceForms compares sources however each is spelled: a path on a seat's holder (ADR 0111) or a URL — a +// build asked of a seat's path is registered with the URL composed from it when its outcome does not echo +// the seat. A seat's path is composed into its URL where the seat's holder is known, so both spellings of +// one repository are one; where it is not, a seat's path matches only the same seat's same path. +type sourceForms struct { + bases map[string]string // the seat's clone base, `scheme://host:port`, by seat; "" where it is not known + base func(seat string) string +} + +// newSourceForms reads the seats' bases from the mesh once, when first needed. +func newSourceForms(ctx context.Context, inv *inventory.Inventory) *sourceForms { + f := &sourceForms{bases: map[string]string{}} + var world *catalogue.World + f.base = func(seat string) string { + if b, known := f.bases[seat]; known { + return b + } + if world == nil { + w := catalogue.World{} + if shelf, err := inv.Catalogue(ctx); err == nil { + if read, err := theRestOfTheMesh(ctx, inv, shelf, ""); err == nil { + w = read + } + } + world = &w + } + b, err := seatBase(*world, seat) + if err != nil { + b = "" + } + f.bases[seat] = b + return b + } + return f +} + +// canonical is one spelling of a repository: lower case, no `.git`, no trailing slash, and a seat's path as +// the URL its holder serves it at where that is known. +func (f *sourceForms) canonical(repository, seat string) string { + trim := func(s string) string { + s = strings.TrimSpace(strings.ToLower(s)) + s = strings.TrimRight(s, "/") + return strings.TrimRight(strings.TrimSuffix(s, ".git"), "/") + } + if seat == "" { + return trim(repository) + } + if b := f.base(seat); b != "" { + return trim(b + "/" + strings.Trim(repository, "/")) + } + return "seat:" + seat + ":" + trim(strings.Trim(repository, "/")) +} + +// same says two sources are one repository. +func (f *sourceForms) same(aRepository, aSeat, bRepository, bSeat string) bool { + if aRepository == "" || bRepository == "" { + return false + } + return f.canonical(aRepository, aSeat) == f.canonical(bRepository, bSeat) +} + +// buildsFrom says the catalogue builds some module from this repository. +func (f *sourceForms) buildsFrom(entries []inventory.Entry, repository, seat string) bool { + for _, e := range entries { + if e.Provided || e.Source.Repository == "" { + continue + } + if f.same(e.Source.Repository, e.Source.Seat, repository, seat) { + return true + } + } + return false +} + +// mayRegisterFrom says whether a build's outcome may register module from the source it was built from +// (novox/hq ADR 0266): from the module's registered repository; for a module new to the catalogue, from a +// repository the catalogue builds another module from; else only when the build was asked at the terminal. +func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module string, built inventory.Source, + buildID string) error { + forms := newSourceForms(ctx, inv) + was, err := inv.SourceOf(ctx, module) + isNew := errors.Is(err, inventory.ErrNoSuchModule) + if err != nil && !isNew { + return err + } + var why string + switch { + case !isNew && forms.same(was.Repository, was.Seat, built.Repository, built.Seat): + return nil + case !isNew: + registered := was.Repository + if registered == "" { + registered = "no repository (it was handed over by hand)" + } + why = fmt.Sprintf("%s is built from %s, and this build is of %s", module, sourceWords(registered, was.Seat), + sourceWords(built.Repository, built.Seat)) + default: + entries, err := inv.Catalogued(ctx) + if err != nil { + return err + } + if forms.buildsFrom(entries, built.Repository, built.Seat) { + return nil + } + why = fmt.Sprintf("%s is new to the catalogue, and %s is no repository the catalogue builds a module from", + module, sourceWords(built.Repository, built.Seat)) + } + terminal, err := inv.AskedAtTheTerminal(ctx, buildID) + if err != nil { + return err + } + if terminal { + fmt.Printf("%s: %s — registered, as asked at the controller's terminal\n", buildID, why) + return nil + } + return fmt.Errorf("%w: %s. A module is registered from another repository only by a build asked at the "+ + "controller's terminal, never through a verb: a registered module is what the next push sends, and whoever "+ + "may call a verb includes agents (novox/hq ADR 0266)", errNotItsSource, why) +} + +// sourceWords is a source as a person reads it. +func sourceWords(repository, seat string) string { + if seat == "" { + return repository + } + return buildSource{Repository: repository, Seat: seat}.String() +} + +// verbMayAsk refuses, for a call through a verb, a build of a repository the catalogue builds no module from +// (novox/hq ADR 0266): the build node would run what an agent wrote, and its outcome could never be +// registered anyway. url is the repository as it is cloned. At the terminal anything may be asked. +func verbMayAsk(ctx context.Context, source buildSource, url string) error { + if startedAtTheTerminal() { + return nil + } + open, err := openStores(ctx) + if err != nil { + return err + } + defer open.Close() + entries, err := open.inventory.Catalogued(ctx) + if err != nil { + return err + } + forms := newSourceForms(ctx, open.inventory) + if forms.buildsFrom(entries, source.Repository, source.Seat) || forms.buildsFrom(entries, url, "") { + return nil + } + return terminalRefusal("%s is no repository the catalogue builds a module from, and a build of any other is "+ + "asked at the controller's terminal only, never through a verb: a build node runs what the repository "+ + "says, and its outcome would register a module the next push sends — whoever may call a verb includes "+ + "agents (novox/hq ADR 0266). Nothing was asked", source) +} diff --git a/cmd/mesh-controller/build_source_test.go b/cmd/mesh-controller/build_source_test.go new file mode 100644 index 00000000..b49ba7bf --- /dev/null +++ b/cmd/mesh-controller/build_source_test.go @@ -0,0 +1,186 @@ +package main + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// The routes to root a review of ADR 0266 found (novox/hq ADR 0266 §7): an agent makes a repository of its +// own — or forks one the mesh builds from — commits a module.json naming a module the mesh runs everywhere +// (`sudo`, granting itself a rule without a password; `mesh-host`, the node-engine), and asks the `build` verb +// for it. Its outcome was registered under that name from the agent's repository, and the next push sent it. + +// onTrunk is an outcome of a commit on its repository's trunk, as the build seat says it. +func onTrunk(id, repository, seat, path string, manifest map[string]any) link.BuildResult { + raw, _ := json.Marshal(manifest) + r := link.BuildResult{ID: id, Repository: "http://forge.internal:20000/" + repository + ".git", Path: path, + Ref: "main", On: "anchor", Commit: "c0ffee0123456789", Manifest: raw, + Trunk: "main", OnTrunk: true, Branches: []string{"main"}} + if seat != "" { + r.Source = &link.SourceOnSeat{Seat: seat, Repository: repository} + } + return r +} + +// keptAsked keeps a build request as the asker would: through a verb, or at the terminal. +func keptAsked(t *testing.T, inv *inventory.Inventory, id, repository string, atTerminal bool) { + t.Helper() + if err := inv.RecordBuildRequest(t.Context(), inventory.BuildRequest{ID: id, Repository: repository, Seat: "git", + For: "build", AtTerminal: atTerminal}); err != nil { + t.Fatal(err) + } +} + +// theCatalogue is a mesh whose sudo is built from the catalogue repository and whose node-engine from its own. +func theCatalogue(t *testing.T) *stores { + t.Helper() + open := aMesh(t) + ctx := t.Context() + for _, b := range []link.BuildResult{ + onTrunk("build-sudo", "novox/mesh-catalog", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "1"}), + onTrunk("build-host", "novox/mesh-host", "git", "", map[string]any{"module": "mesh-host", "version": "1"}), + } { + keptAsked(t, open.inventory, b.ID, b.Source.Repository, true) + if _, _, err := takeIn(ctx, open.inventory, b); err != nil { + t.Fatal(err) + } + } + return open +} + +func TestABuildFromAnAgentsRepositoryIsNotRegisteredAsAModuleTheMeshHolds(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + for _, c := range []struct { + name, repository, path, module string + }{ + {"its own repository naming sudo", "agent/sudo", "modules/sudo", "sudo"}, + {"a fork of the catalogue", "agent/mesh-catalog", "modules/sudo", "sudo"}, + {"a fork of the node-engine", "agent/mesh-host", "", "mesh-host"}, + } { + t.Run(c.name, func(t *testing.T) { + id := "build-" + strings.ReplaceAll(c.repository, "/", "-") + keptAsked(t, open.inventory, id, c.repository, false) // through the build verb + evil := onTrunk(id, c.repository, "git", c.path, map[string]any{"module": c.module, "version": "evil"}) + _, _, err := takeIn(ctx, open.inventory, evil) + if !errors.Is(err, errNotItsSource) { + t.Fatalf("a build of %s was taken in as %s: %v", c.repository, c.module, err) + } + shelf, err := open.inventory.Catalogue(ctx) + if err != nil { + t.Fatal(err) + } + if got := shelf[c.module].Version; got != "1" { + t.Fatalf("%s is now %q, from %s", c.module, got, c.repository) + } + if _, found, _ := open.inventory.BuildByID(ctx, id); !found { + t.Errorf("the refused build %s is not recorded", id) + } + }) + } +} + +func TestANewModuleFromARepositoryTheCatalogueDoesNotBuildFromIsNotRegistered(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + keptAsked(t, open.inventory, "build-new", "agent/tools", false) + _, _, err := takeIn(ctx, open.inventory, onTrunk("build-new", "agent/tools", "git", "", + map[string]any{"module": "agent-tools", "version": "1"})) + if !errors.Is(err, errNotItsSource) { + t.Fatalf("a new module from an agent's repository was taken in: %v", err) + } + // And one asked of nobody here — an outcome on the bus no request was kept for — the same. + _, _, err = takeIn(ctx, open.inventory, onTrunk("build-unasked", "agent/tools", "git", "", + map[string]any{"module": "agent-tools", "version": "1"})) + if !errors.Is(err, errNotItsSource) { + t.Fatalf("an outcome nobody asked for was taken in: %v", err) + } + if shelf, _ := open.inventory.Catalogue(ctx); shelf["agent-tools"].Module != "" { + t.Fatal("the refused module is in the catalogue") + } +} + +// The operator at the terminal may still move a module, or add one from a new repository. +func TestAtTheTerminalAnotherRepositoryIsRegistered(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + keptAsked(t, open.inventory, "build-moved", "novox/sudo", true) + if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-moved", "novox/sudo", "git", "", + map[string]any{"module": "sudo", "version": "2"})); err != nil { + t.Fatalf("a move the operator asked for at the terminal was refused: %v", err) + } + if src, _ := open.inventory.SourceOf(ctx, "sudo"); src.Repository != "novox/sudo" { + t.Fatalf("sudo is built from %q", src.Repository) + } + keptAsked(t, open.inventory, "build-external", "someone/app", true) + if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-external", "someone/app", "", "", + map[string]any{"module": "app", "version": "1"})); err != nil { + t.Fatalf("a new module the operator asked for at the terminal was refused: %v", err) + } +} + +// The delivery's flow is untouched: a merge's rebuild of a module from its own repository, and a merge adding +// a module to a repository the catalogue builds from (novox/hq issue 300), are registered with no terminal. +func TestADeliveryFromTheRegisteredRepositoryIsRegistered(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + rebuilt := onTrunk("build-plan", "novox/mesh-catalog", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "2"}) + if err := open.inventory.RecordBuildRequest(ctx, inventory.BuildRequest{ID: rebuilt.ID, + Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/sudo", For: "plan"}); err != nil { + t.Fatal(err) + } + if _, _, err := takeIn(ctx, open.inventory, rebuilt); err != nil { + t.Fatalf("a plan's build of the module's own repository was refused: %v", err) + } + added := onTrunk("build-merge", "novox/mesh-catalog", "git", "modules/zram", map[string]any{"module": "zram", "version": "1"}) + if _, _, err := takeIn(ctx, open.inventory, added); err != nil { + t.Fatalf("a module a merge added to the catalogue repository was refused: %v", err) + } + shelf, _ := open.inventory.Catalogue(ctx) + if shelf["sudo"].Version != "2" || shelf["zram"].Module == "" { + t.Fatalf("not registered: sudo %q, zram %q", shelf["sudo"].Version, shelf["zram"].Module) + } +} + +// Through a verb, a build of a repository the catalogue builds nothing from is not even asked: the build node +// would run what the agent wrote. +func TestAVerbAsksNoBuildOfARepositoryTheCatalogueDoesNotBuildFrom(t *testing.T) { + theCatalogue(t) + ctx := t.Context() + t.Setenv(verbVar, "build") + t.Setenv(link.CallerVar, "node-tools.anchor, through the mesh-controller seat") + err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, "http://forge.internal:20000/agent/sudo.git") + var policy *heldAtTheTerminal + if !errors.As(err, &policy) { + t.Fatalf("a verb's build of an agent's repository was asked: %v", err) + } + if err := verbMayAsk(ctx, buildSource{Repository: "novox/mesh-catalog", Seat: "git"}, + "http://forge.internal:20000/novox/mesh-catalog.git"); err != nil { + t.Fatalf("a verb's build of the catalogue repository was refused: %v", err) + } + t.Setenv(verbVar, "") + t.Setenv(link.CallerVar, "") + if err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, ""); err != nil { + t.Fatalf("the terminal was refused: %v", err) + } +} + +// asTheOperator keeps a build as asked at the controller's terminal, as the operator's first build of a module +// from a repository the catalogue does not yet build from is (novox/hq ADR 0266), and hands it back. +func asTheOperator(t *testing.T, inv *inventory.Inventory, b link.BuildResult) link.BuildResult { + t.Helper() + repository, seat := b.Repository, "" + if b.Source != nil { + repository, seat = b.Source.Repository, b.Source.Seat + } + if err := inv.RecordBuildRequest(t.Context(), inventory.BuildRequest{ID: b.ID, Repository: repository, Seat: seat, + Path: b.Path, For: "build", AtTerminal: true}); err != nil { + t.Fatal(err) + } + return b +} diff --git a/cmd/mesh-controller/build_test.go b/cmd/mesh-controller/build_test.go index a7b0c695..aa70ff81 100644 --- a/cmd/mesh-controller/build_test.go +++ b/cmd/mesh-controller/build_test.go @@ -19,11 +19,11 @@ func TestABuildHeardIsRecordedAndRegistered(t *testing.T) { open := aMesh(t) ctx := t.Context() manifest, _ := json.Marshal(map[string]any{"module": "shop", "version": "3"}) - m, _, err := takeIn(ctx, open.inventory, link.BuildResult{ + m, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory, link.BuildResult{ ID: "b-1", Repository: "http://forge.internal:20000/novox/shop.git", Path: "modules/shop", Ref: "main", On: "anchor", Commit: "abcdef0123", Manifest: manifest, Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/shop"}, - }) + })) if err != nil { t.Fatal(err) } @@ -78,7 +78,7 @@ func TestABuildAtACommitKeepsTheBranchTheModuleFollows(t *testing.T) { Path: "modules/unifi", Ref: ref, On: "anchor", Commit: commit, Manifest: manifest, Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}} } - if _, _, err := takeIn(ctx, open.inventory, result("b-1", "main", "1111111aaaa")); err != nil { + if _, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory, result("b-1", "main", "1111111aaaa"))); err != nil { t.Fatal(err) } if _, _, err := takeIn(ctx, open.inventory, result("b-2", "9c97a8a", "9c97a8a1d2c3")); err != nil { @@ -117,7 +117,7 @@ func TestAnOlderBuildHeardLaterDoesNotReplaceTheNewer(t *testing.T) { Path: "modules/postgres", Ref: "main", On: "anchor", Commit: "efff5415", Manifest: manifest, Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}} } - if _, _, err := takeIn(ctx, open.inventory, result(newer, "4bcd5f73")); err != nil { + if _, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory, result(newer, "4bcd5f73"))); err != nil { t.Fatal(err) } _, _, err := takeIn(ctx, open.inventory, result(older, "0ab07fa9")) diff --git a/cmd/mesh-controller/gate_test.go b/cmd/mesh-controller/gate_test.go index e8d55ce2..9bbd46d3 100644 --- a/cmd/mesh-controller/gate_test.go +++ b/cmd/mesh-controller/gate_test.go @@ -221,7 +221,8 @@ func TestABuildThatFailsItsGateIsRolledBackOnItsFirstMachineAndGoesNoFurther(t * t.Fatalf("sent again after the rollback: %v", g.sent) } _, _, err = takeIn(ctx, inv, link.BuildResult{ID: "build-2", Repository: "novox/mesh-catalog", Path: "modules/app", - Commit: "c2", Manifest: mustJSON(t, catalogue.Manifest{Module: "app", Version: "c2"})}) + Commit: "c2", Manifest: mustJSON(t, catalogue.Manifest{Module: "app", Version: "c2"}), + Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}) if err == nil || !strings.Contains(err.Error(), "failed its gate") { t.Fatalf("the failed build was registered again: %v", err) } diff --git a/cmd/mesh-controller/push_recreates_test.go b/cmd/mesh-controller/push_recreates_test.go index be91f5c1..1fc486a0 100644 --- a/cmd/mesh-controller/push_recreates_test.go +++ b/cmd/mesh-controller/push_recreates_test.go @@ -26,7 +26,7 @@ func TestAPushSaysWhatItRecreates(t *testing.T) { aContainerBuild(t, "postgres", "c1111111", "", start.Add(time.Second), map[string][2]string{"server": {image("e"), ""}}), } { - if _, _, err := takeIn(ctx, inv, b); err != nil { + if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, b)); err != nil { t.Fatal(err) } } diff --git a/cmd/mesh-controller/recorded_kept_test.go b/cmd/mesh-controller/recorded_kept_test.go index bbd8e192..07bce8bb 100644 --- a/cmd/mesh-controller/recorded_kept_test.go +++ b/cmd/mesh-controller/recorded_kept_test.go @@ -71,7 +71,7 @@ func TestARecordedBuildIsCarriedOnlyByAPersonsPush(t *testing.T) { aContainerBuild(t, "mailu", "c1111111", "", start.Add(time.Second), map[string][2]string{"smtp": {mailImage, ""}, "imap": {mailImage, ""}}), } { - if _, _, err := takeIn(ctx, inv, b); err != nil { + if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, b)); err != nil { t.Fatal(err) } } diff --git a/cmd/mesh-controller/replays_test.go b/cmd/mesh-controller/replays_test.go index b1e5f495..138f0180 100644 --- a/cmd/mesh-controller/replays_test.go +++ b/cmd/mesh-controller/replays_test.go @@ -539,8 +539,8 @@ func TestReplay301APersonsPushOfAHeldRecordedBuildIsNoRepair(t *testing.T) { inv := open.inventory start := time.Now().Add(-time.Hour) image := "registry.invalid:5000/resolver/server@sha256:" + strings.Repeat("e", 64) - if _, _, err := takeIn(ctx, inv, aContainerBuild(t, "resolver", "c1111111", catalogue.PolicyRecord, start, - map[string][2]string{"server": {image, ""}})); err != nil { + if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, aContainerBuild(t, "resolver", "c1111111", catalogue.PolicyRecord, start, + map[string][2]string{"server": {image, ""}}))); err != nil { t.Fatal(err) } for _, node := range []string{"anchor", "laptop"} { diff --git a/cmd/mesh-controller/same_source_test.go b/cmd/mesh-controller/same_source_test.go index 9f1949a1..be2dc8c2 100644 --- a/cmd/mesh-controller/same_source_test.go +++ b/cmd/mesh-controller/same_source_test.go @@ -54,7 +54,7 @@ func TestARebuildOfAnUnchangedSourceKeepsItsArtifacts(t *testing.T) { // Another module's merge rebuilt it: a new commit, a new image digest, the same source. anImageBuild(t, "app", "", "c2bbbbbb", strings.Repeat("b", 64), "src1:same", start.Add(time.Minute)), } { - if _, _, err := takeIn(ctx, inv, b); err != nil { + if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, b)); err != nil { t.Fatalf("build %d: %v", i, err) } } @@ -114,7 +114,7 @@ func TestABusRebuiltFromAnUnchangedSourceDemandsNoBusStep(t *testing.T) { b.Manifest = manifest return b } - if _, _, err := takeIn(ctx, inv, bus("", "n1111111", strings.Repeat("a", 64), "src1:bus", start)); err != nil { + if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, bus("", "n1111111", strings.Repeat("a", 64), "src1:bus", start))); err != nil { t.Fatal(err) } if _, err := inv.Assign(ctx, "anchor", "nats"); err != nil { diff --git a/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql b/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql new file mode 100644 index 00000000..78a8d98c --- /dev/null +++ b/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql @@ -0,0 +1,11 @@ +-- A build asked at the controller's terminal says so (novox/hq ADR 0266). +-- +-- A build's outcome registers its module, and a module is what the next push sends: a module named `sudo` +-- built from a repository an agent made would grant whoever wrote it root on every node it is assigned. +-- So an outcome may register a module only from the repository the catalogue already builds it from — or, +-- for a module new to the catalogue, from a repository the catalogue already builds another module from. +-- Anything else — a module moved to another repository, a new module from a new repository — is the +-- operator's, at the controller's terminal. This column is how the take-in tells: true only for a build +-- request kept by a `build` or `replay --register` run at the terminal, never through a verb (whoever may +-- call a verb includes agents). False for every request kept before this column existed. +alter table build_request add column at_terminal boolean not null default false; diff --git a/internal/inventory/pending.go b/internal/inventory/pending.go index e9d82f57..9cb5dd38 100644 --- a/internal/inventory/pending.go +++ b/internal/inventory/pending.go @@ -38,6 +38,9 @@ type BuildRequest struct { // unknown. Read as in flight until its outcome or its bound. OutcomeUnknown string At time.Time + // AtTerminal says the request was asked at the controller's terminal, never through a verb (novox/hq ADR + // 0266): what lets its outcome register a module from a repository the catalogue does not build it from. + AtTerminal bool } // Name is the module this request is expected to register, read from its directory: the last element of @@ -73,16 +76,30 @@ func (i *Inventory) RecordBuildRequest(ctx context.Context, a BuildRequest) erro notAsked = &a.NotAsked } if _, err := i.store.Pool().Exec(ctx, - `insert into build_request (id, repository, seat, source_path, ref, commit_hash, asked_for, not_asked, asked_at) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `insert into build_request (id, repository, seat, source_path, ref, commit_hash, asked_for, not_asked, asked_at, + at_terminal) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) on conflict (id) do nothing`, - a.ID, a.Repository, a.Seat, strings.Trim(a.Path, "/"), a.Ref, a.Commit, a.For, notAsked, at); err != nil { + a.ID, a.Repository, a.Seat, strings.Trim(a.Path, "/"), a.Ref, a.Commit, a.For, notAsked, at, + a.AtTerminal); err != nil { return err } _, err := i.store.Pool().Exec(ctx, `delete from build_request where asked_at < $1`, time.Now().Add(-KeptFor)) return err } +// AskedAtTheTerminal says whether the build of this id was kept as asked at the controller's terminal (novox/hq +// ADR 0266). False for a build no request was kept for — a check, a dry run, an outcome nobody here asked for — +// and for every request asked through a verb. +func (i *Inventory) AskedAtTheTerminal(ctx context.Context, id string) (bool, error) { + var at bool + err := i.store.Pool().QueryRow(ctx, `select at_terminal from build_request where id = $1`, id).Scan(&at) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return at, err +} + // MarkNotAsked says a kept build request was never handed over: the words are kept, unless its outcome was // heard first. func (i *Inventory) MarkNotAsked(ctx context.Context, id, why string) error { From 1b780eae3a87f1aad46c83edba7b985e7daff90d Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 11:41:05 +0200 Subject: [PATCH 13/16] Put back on a failed gate only a build of the module's own repository A build refused for its repository is still recorded, and a fork carries the commit the module was registered at: the rollback's search for the previous build would have found it and registered it by the back door. --- cmd/mesh-controller/build_source_test.go | 36 ++++++++++++++++++++++++ internal/inventory/gate.go | 15 ++++++++++ 2 files changed, 51 insertions(+) diff --git a/cmd/mesh-controller/build_source_test.go b/cmd/mesh-controller/build_source_test.go index b49ba7bf..6a9301fc 100644 --- a/cmd/mesh-controller/build_source_test.go +++ b/cmd/mesh-controller/build_source_test.go @@ -184,3 +184,39 @@ func asTheOperator(t *testing.T, inv *inventory.Inventory, b link.BuildResult) l } return b } + +// A rollback puts back only a build of the module's own repository: an agent's build of the module's name, +// recorded and refused, at the very commit the machine ran before (a fork carries it), is never registered by +// the back door of a failed gate. +func TestARollbackNeverPutsBackABuildFromAnotherRepository(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + inv := open.inventory + fork := onTrunk("build-1791500000000000000", "agent/mesh-catalog", "git", "modules/sudo", + map[string]any{"module": "sudo", "version": "evil"}) + fork.Commit = "c0ffee0123456789" // the commit sudo was registered at + keptAsked(t, inv, fork.ID, "agent/mesh-catalog", false) + if _, _, err := takeIn(ctx, inv, fork); !errors.Is(err, errNotItsSource) { + t.Fatalf("the fork's build was taken in: %v", err) + } + failed := onTrunk("build-1791600000000000000", "novox/mesh-catalog", "git", "modules/sudo", + map[string]any{"module": "sudo", "version": "2"}) + failed.Commit = "badbadbad0123456" + if _, _, err := takeIn(ctx, inv, failed); err != nil { + t.Fatal(err) + } + record, _, err := inv.BuildByID(ctx, failed.ID) + if err != nil { + t.Fatal(err) + } + previous, found, err := inv.PreviousBuild(ctx, "sudo", "c0ffee0123456789", record) + if err != nil { + t.Fatal(err) + } + if found && previous.ID == fork.ID { + t.Fatalf("a rollback would put back the fork's build %s", previous.ID) + } + if !found || previous.ID != "build-sudo" { + t.Fatalf("a rollback puts back %q (found %v), want the registered build-sudo", previous.ID, found) + } +} diff --git a/internal/inventory/gate.go b/internal/inventory/gate.go index 58bc4e80..7baa82e7 100644 --- a/internal/inventory/gate.go +++ b/internal/inventory/gate.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/jackc/pgx/v5" @@ -169,6 +170,12 @@ func (i *Inventory) PreviousBuild(ctx context.Context, module, commit string, fa if b.ID == failed.ID || (commit != "" && b.Commit != commit) { continue } + // Only a build of the repository the failed build was made from — the module's, since its take-in + // registered it (novox/hq ADR 0266): a build of the module's name from another repository is recorded + // and was never registered, and putting it back would register it now. + if failed.Repository != "" && !sameRepositoryAs(b.Repository, failed.Repository) { + continue + } if !failed.AskedOrAt().IsZero() && !b.AskedOrAt().Before(failed.AskedOrAt()) { continue } @@ -190,6 +197,14 @@ func (i *Inventory) PreviousBuild(ctx context.Context, module, commit string, fa return Build{}, false, nil } +// sameRepositoryAs says two recorded repositories are one, however their case or `.git` is spelled. +func sameRepositoryAs(a, b string) bool { + trim := func(s string) string { + return strings.TrimSuffix(strings.TrimRight(strings.ToLower(strings.TrimSpace(s)), "/"), ".git") + } + return trim(a) == trim(b) +} + // RestoreModule puts a module's registered build back to an earlier one: its manifest, the commit it // was built from, and when it was asked — as now, so the build that failed its gate, asked before, can // never register over it again (issue 219's order). The source's head is left where the merge moved it: From d15eee61bb18a5d5e2bb234868d50e6e601461eb Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 11:42:05 +0200 Subject: [PATCH 14/16] Do not raise agent-can-become-root while the first setuid search is still within its bound After every node-engine restart the account verdict says not judged yet until the engine's first search for setuid programs ends, and DA raised the urgent condition each time. The account stays unconfined and node show still says not judged; the condition is raised once the search fails, runs out its bound, finds a way to root, or the statement goes stale. --- cmd/mesh-controller/agent_account.go | 41 ++++++++++++++++- cmd/mesh-controller/agent_account_test.go | 55 +++++++++++++++++++++++ internal/link/protocol.go | 10 +++++ 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/cmd/mesh-controller/agent_account.go b/cmd/mesh-controller/agent_account.go index e288c1a1..ef797b29 100644 --- a/cmd/mesh-controller/agent_account.go +++ b/cmd/mesh-controller/agent_account.go @@ -117,6 +117,37 @@ func judgedConfined(agent string, h inventory.NodeHealth, had bool, now time.Tim h.SaidAt.Local().Format("2006-01-02 15:04")) } +// searchQuietFor is how long a verdict may say its setuid search is still running before that is itself the +// urgent condition: the node-engine's bound on one search, and one statement more (a node-engine states its +// health at least every five minutes) for the verdict that follows it to be heard. +const searchQuietFor = link.RootSearchBound + 5*time.Minute + +// searchStillRunning says the one thing keeping an agent account from being judged is the node-engine's first +// search for setuid programs, still within its bound (novox/hq ADR 0266): a fresh statement from an engine that +// judges root, holding a verdict on the account, every verdict on it healthy or not judged yet because that +// search runs — and none of those for longer than searchQuietFor. A way to root found, a search that failed or +// did not finish, any other unknown, a stale statement: false, and DA raises it. +func searchStillRunning(agent string, h inventory.NodeHealth, had bool, now time.Time) bool { + if !had || now.Sub(h.HeardAt) > verdictFreshFor || h.Contract < link.RootContract { + return false + } + pending := false + for _, r := range h.Resources { + if r.Kind != link.KindAccount || r.Target != agent || r.Root != link.RootNever { + continue + } + switch { + case r.State == link.StateHealthy: + case r.State == link.StateUnknown && strings.HasPrefix(r.Reason, link.ReasonRootPending) && + !r.Since.IsZero() && now.Sub(r.Since) <= searchQuietFor: + pending = true + default: + return false + } + } + return pending +} + // 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) { @@ -134,10 +165,18 @@ func probeAgentAccounts(ctx context.Context, d *doctor) ([]conditions.Observatio if err != nil { return nil, err } - confined, why := judgedConfined(n.AgentAccount, h, had, time.Now()) + now := time.Now() + confined, why := judgedConfined(n.AgentAccount, h, had, now) if confined { continue } + // Not judged yet only because the first search since the node-engine started is still running: not the + // urgent condition after every restart. The agent is still not confined — ADR 0259's router reads + // agentConfined, not this — and `node show` still says not judged. Loud again once the search fails, + // runs out its bound, or the statement goes stale. + if searchStillRunning(n.AgentAccount, h, had, now) { + 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 "+ diff --git a/cmd/mesh-controller/agent_account_test.go b/cmd/mesh-controller/agent_account_test.go index 3d384620..acb9a1ce 100644 --- a/cmd/mesh-controller/agent_account_test.go +++ b/cmd/mesh-controller/agent_account_test.go @@ -197,3 +197,58 @@ func TestTheNodeVerbOnlyShows(t *testing.T) { } } } + +// After every node-engine restart its search for setuid programs runs for up to its bound, and the agent account +// is not judged until it ends. DA does not raise that as urgent while the search is within its bound — the +// account is still not confined, and `node show` still says not judged — and raises it once the search failed, +// ran out its bound, or found a way to root. +func TestASearchStillRunningAfterARestartIsNotUrgent(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + inv := open.inventory + if _, err := inv.NodeByName(ctx, "anchor"); err != nil { + if _, err := inv.AddNode(ctx, "anchor"); err != nil { + t.Fatal(err) + } + } + if err := inv.SetAgentAccount(ctx, "anchor", "agent", ""); err != nil { + t.Fatal(err) + } + d := &doctor{open: open} + say := func(state, reason string, since time.Time) []conditions.Observation { + t.Helper() + v := inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account", + Kind: link.KindAccount, Target: "agent", State: state, Reason: reason, Root: link.RootNever, + Account: "agent", Since: since} + if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.RootContract, + SaidAt: time.Now(), HeardAt: time.Now(), Resources: []inventory.ResourceHealth{v}}); err != nil { + t.Fatal(err) + } + found, err := probeAgentAccounts(ctx, d) + if err != nil { + t.Fatal(err) + } + return onlyMachine(found, "anchor") + } + running := link.ReasonRootPending + ": the search for setuid programs, started at 19:21, has not finished yet" + if found := say(link.StateUnknown, running, time.Now().Add(-2*time.Minute)); len(found) != 0 { + t.Fatalf("a search two minutes into its bound was raised: %+v", found) + } + if _, confined, why, _ := agentConfined(ctx, inv, "anchor"); confined || !strings.Contains(why, "not judged") { + t.Fatalf("an account whose search runs was read as confined: %q", why) + } + if found := say(link.StateUnknown, running, time.Now().Add(-searchQuietFor-time.Minute)); len(found) != 1 || + found[0].Severity != conditions.Urgent { + t.Fatalf("a search past its bound was not urgent: %+v", found) + } + incomplete := "not judged (search incomplete): the search for setuid programs did not finish (last tried at 19:23: " + + "timeout); it is tried again later" + if found := say(link.StateUnknown, incomplete, time.Now().Add(-time.Minute)); len(found) != 1 || + found[0].Severity != conditions.Urgent { + t.Fatalf("a search that did not finish was not urgent: %+v", found) + } + if found := say(link.StateUnhealthy, link.ReasonRoot+": in the group docker; "+running, time.Now()); len(found) != 1 || + found[0].Severity != conditions.Urgent { + t.Fatalf("a way to root found while the search runs was not urgent: %+v", found) + } +} diff --git a/internal/link/protocol.go b/internal/link/protocol.go index 36448195..4c1d83a9 100644 --- a/internal/link/protocol.go +++ b/internal/link/protocol.go @@ -430,6 +430,16 @@ const RootContract = 3 // own words (mesh-host internal/accounts ReasonRoot). const ReasonRoot = "can become root without a person" +// ReasonRootPending starts the reason of an account verdict the node-engine cannot give yet because its search +// for setuid programs, started when the engine started, has not finished (mesh-host internal/accounts +// ReasonPending): not judged yet, said as such, never a pass. The search is bounded (RootSearchBound); one that +// fails or runs out its bound is said in other words, as not judged (search incomplete). +const ReasonRootPending = "not judged yet (search running)" + +// RootSearchBound is the longest the node-engine lets one search for setuid programs run (mesh-host +// internal/accounts SearchBound). +const RootSearchBound = 15 * time.Minute + // 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" From 95e7a7120ab00ac4f7a51d3b25a38a01d66fed70 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 12:23:47 +0200 Subject: [PATCH 15/16] Register only from a protected trunk of the same repository, and mark the serving controller never the terminal A source repository's name is not its identity, and a trunk anyone may push to makes the trunk rule mean nothing (the review of 2026-10-09). Through any verb a module now registers only from a repository on the mesh's forge whose trunk refuses direct pushes, requires a status and lets no administrator merge past one, asked of the forge's own tools; and only from the repository by the forge's id, recorded at registration (migration 0084), so one deleted and made again under the name is refused. The serving controller marks its environment, so nothing it runs or starts reads as the terminal, and a terminal request covers only the repository and path it asked. --- cmd/mesh-controller/build.go | 14 +- cmd/mesh-controller/build_source.go | 274 ++++++++++++++++-- cmd/mesh-controller/build_source_test.go | 139 ++++++++- cmd/mesh-controller/push.go | 2 + internal/inventory/catalogue.go | 19 ++ ...-a-build-asked-at-the-terminal-says-so.sql | 7 + internal/inventory/pending.go | 17 +- 7 files changed, 433 insertions(+), 39 deletions(-) diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index a69d8379..6f1c0cd5 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -640,7 +640,15 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu // **Only from the repository the catalogue builds the module from** (novox/hq ADR 0266): else the trunk // below is the trunk of whatever repository was built, which may be one an agent made — and a module named // `sudo` from it would be what the next push sends. Another repository is the operator's, at the terminal. - if err := mayRegisterFrom(ctx, inv, manifest.Module, recorded, result.ID); err != nil { + trunk := result.Trunk + if was, err := inv.SourceOf(ctx, manifest.Module); err == nil && followedBranch(was.Ref) != "" { + trunk = followedBranch(was.Ref) + } + if trunk == "" { + trunk = "main" + } + repoID, err := mayRegisterFrom(ctx, inv, manifest.Module, recorded, result.ID, result.Path, trunk) + if err != nil { return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On, manifest.Module, short(result.Commit), err) } @@ -691,6 +699,10 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu } return manifest, kept, err } + // Which repository it is registered from, by the forge's own id (novox/hq ADR 0266). + if err := inv.SetSourceIdentity(ctx, manifest.Module, repoID); err != nil { + return manifest, kept, err + } // The keep set just moved, and new bytes just landed (novox/hq ADR 0189). Asked here rather // than on a timer of its own: this is the only moment either is true. Never fatal — the build // worked and the module is registered. diff --git a/cmd/mesh-controller/build_source.go b/cmd/mesh-controller/build_source.go index 2d8b0ae5..053639de 100644 --- a/cmd/mesh-controller/build_source.go +++ b/cmd/mesh-controller/build_source.go @@ -2,10 +2,13 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "os" + "regexp" "strings" + "time" "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/inventory" @@ -35,11 +38,24 @@ import ( // errNotItsSource is an outcome refused for where it was built from. var errNotItsSource = errors.New("not built from the repository the catalogue builds it from") -// startedAtTheTerminal says this process was started at the controller's terminal: neither a verb nor a seat call -// started it. runVerb sets both for every command a verb runs (seatverbs.go), and a verb is the only way an -// agent reaches the controller. +// servedVar marks every process the serving controller starts — each verb's command, each child — and the serving +// process itself, so none of them can read as the operator at the terminal (novox/hq ADR 0266). Set by serve +// before it answers anything, inherited by every child through os.Environ. +const servedVar = "MESH_SERVED_BY_THE_CONTROLLER" + +// startedAtTheTerminal says this process was started at the controller's terminal: not the serving controller, +// not anything it started, not a verb's command, not a seat call's. The serving controller marks its own +// environment (servedVar), so a build asked in it, or by any process it starts, never reads as the terminal's; +// runVerb also names the verb and the caller. func startedAtTheTerminal() bool { - return os.Getenv(verbVar) == "" && os.Getenv(link.CallerVar) == "" + return os.Getenv(servedVar) == "" && os.Getenv(verbVar) == "" && os.Getenv(link.CallerVar) == "" +} + +// markServed marks this process, and so everything it starts, as the serving controller's. +func markServed() { + if err := os.Setenv(servedVar, "1"); err != nil { + panic("the serving controller could not mark its environment: " + err.Error()) + } } // sourceForms compares sources however each is spelled: a path on a seat's holder (ADR 0111) or a URL — a @@ -116,21 +132,166 @@ func (f *sourceForms) buildsFrom(entries []inventory.Entry, repository, seat str return false } -// mayRegisterFrom says whether a build's outcome may register module from the source it was built from -// (novox/hq ADR 0266): from the module's registered repository; for a module new to the catalogue, from a -// repository the catalogue builds another module from; else only when the build was asked at the terminal. +// forgeFacts is what the mesh's forge says of a repository a module is registered from (novox/hq ADR 0266). +type forgeFacts struct { + // ID is the forge's own id of the repository: what tells it from one deleted and made again by its name. + ID int64 + // Guarded is whether the branch is protected as a module's trunk must be: no direct push, at least one + // required status, and no administrator merging past one. Why says what is missing when it is not. + Guarded bool + Why string +} + +// askTheForge asks the forge, through its module's tools on the bus, for a repository's id and its branch's +// protection. A variable so a test needs no forge. +var askTheForge = func(ctx context.Context, owner, repo, branch string) (forgeFacts, error) { + js, err := aBus() + if err != nil { + return forgeFacts{}, err + } + defer js.Close() + bus := link.OverNATS{Conn: js.Conn()} + ask := func(tool string, args map[string]any, into any) error { + raw, _ := json.Marshal(args) + answer, err := link.Ask(ctx, bus, "gitea", tool, raw, 30*time.Second) + if err != nil { + return err + } + if answer.Error != "" { + return fmt.Errorf("gitea.%s: %s", tool, answer.Error) + } + return json.Unmarshal(answer.Result, into) + } + var found struct { + Result struct { + ID int64 `json:"id"` + FullName string `json:"full_name"` + } `json:"result"` + } + if err := ask("gitea_api", map[string]any{"path": "/repos/" + owner + "/" + repo}, &found); err != nil { + return forgeFacts{}, err + } + if found.Result.ID == 0 { + return forgeFacts{}, fmt.Errorf("the forge named no id for %s/%s", owner, repo) + } + var rules struct { + Rules []struct { + Rule string `json:"rule"` + Push bool `json:"push"` + RequiredStatuses []string `json:"required_statuses"` + AdminMayOverride bool `json:"admin_may_override"` + } `json:"rules"` + } + if err := ask("gitea_branch_protection_get", map[string]any{"owner": owner, "repo": repo}, &rules); err != nil { + return forgeFacts{}, err + } + facts := forgeFacts{ID: found.Result.ID, Why: fmt.Sprintf("no protection rule covers %s", branch)} + for _, r := range rules.Rules { + if !ruleCovers(r.Rule, branch) { + continue + } + switch { + case r.Push: + facts.Why = fmt.Sprintf("the rule %s lets a person push to %s directly", r.Rule, branch) + case len(r.RequiredStatuses) == 0: + facts.Why = fmt.Sprintf("the rule %s requires no status before a merge into %s", r.Rule, branch) + case r.AdminMayOverride: + facts.Why = fmt.Sprintf("the rule %s lets an administrator merge into %s past a status", r.Rule, branch) + default: + return forgeFacts{ID: facts.ID, Guarded: true}, nil + } + } + return facts, nil +} + +// ruleCovers says a protection rule's name — a branch, or a glob of them — covers a branch, as the forge reads it. +func ruleCovers(rule, branch string) bool { + if rule == branch { + return true + } + if !strings.ContainsAny(rule, "*?[") { + return false + } + var re strings.Builder + re.WriteString("^") + for i := 0; i < len(rule); i++ { + switch c := rule[i]; { + case c == '*' && i+1 < len(rule) && rule[i+1] == '*': + re.WriteString(".*") + i++ + case c == '*': + re.WriteString("[^/]*") + case c == '?': + re.WriteString("[^/]") + default: + re.WriteString(regexp.QuoteMeta(string(c))) + } + } + re.WriteString("$") + ok, _ := regexp.MatchString(re.String(), branch) + return ok +} + +// onTheForge is a source's owner and name on the mesh's own forge (the git seat's holder), and whether it is +// there at all. +func (f *sourceForms) onTheForge(repository, seat string) (owner, name string, ok bool) { + var rest string + switch { + case seat == gitSeat: + rest = strings.Trim(repository, "/") + case seat == "": + base := f.base(gitSeat) + if base == "" { + return "", "", false + } + url, prefix := f.canonical(repository, ""), f.canonical(base, "")+"/" + if !strings.HasPrefix(url, prefix) { + return "", "", false + } + // The case the forge spells it with: the URL as given, past the base. + rest = strings.TrimSuffix(strings.Trim(repository[len(prefix):], "/"), ".git") + default: + return "", "", false + } + parts := strings.Split(rest, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + return parts[0], parts[1], true +} + +// mayRegisterFrom says whether a build's outcome may register module from the source it was built from, and the +// forge's id of that repository to record (novox/hq ADR 0266). Through any verb, only: +// +// - from the module's registered repository — the same repository by the forge's own id, not only its name; or, +// for a module new to the catalogue, from a repository the catalogue builds another module from; +// - and from a repository on the mesh's forge whose trunk is protected as a trunk must be: no direct push, at +// least one required status, no administrator merging past one. +// +// Anything else only when the build request was kept as asked at the controller's terminal, for this very +// repository and path. path is the module's directory as built; branch the trunk it is registered from. func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module string, built inventory.Source, - buildID string) error { + buildID, path, branch string) (int64, error) { forms := newSourceForms(ctx, inv) was, err := inv.SourceOf(ctx, module) isNew := errors.Is(err, inventory.ErrNoSuchModule) if err != nil && !isNew { - return err + return 0, err } + terminal, err := askedHereFor(ctx, inv, forms, buildID, built, path) + if err != nil { + return 0, err + } + refuse := func(why string) (int64, error) { + return 0, fmt.Errorf("%w: %s. A module is registered from such a source only by a build asked at the "+ + "controller's terminal, never through a verb: a registered module is what the next push sends, and whoever "+ + "may call a verb includes agents (novox/hq ADR 0266)", errNotItsSource, why) + } + var why string + var alongside []inventory.Entry // the modules a new one's repository already builds switch { case !isNew && forms.same(was.Repository, was.Seat, built.Repository, built.Seat): - return nil case !isNew: registered := was.Repository if registered == "" { @@ -141,25 +302,94 @@ func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module strin default: entries, err := inv.Catalogued(ctx) if err != nil { - return err + return 0, err } - if forms.buildsFrom(entries, built.Repository, built.Seat) { - return nil + for _, e := range entries { + if !e.Provided && e.Source.Repository != "" && + forms.same(e.Source.Repository, e.Source.Seat, built.Repository, built.Seat) { + alongside = append(alongside, e) + } + } + if len(alongside) == 0 { + why = fmt.Sprintf("%s is new to the catalogue, and %s is no repository the catalogue builds a module from", + module, sourceWords(built.Repository, built.Seat)) } - why = fmt.Sprintf("%s is new to the catalogue, and %s is no repository the catalogue builds a module from", - module, sourceWords(built.Repository, built.Seat)) } - terminal, err := inv.AskedAtTheTerminal(ctx, buildID) + if why != "" && !terminal { + return refuse(why) + } + + owner, name, onForge := forms.onTheForge(built.Repository, built.Seat) + if !onForge { + if terminal { + fmt.Printf("%s: %s is not on the mesh's forge — registered, as asked at the controller's terminal\n", + buildID, sourceWords(built.Repository, built.Seat)) + return 0, nil + } + return refuse(fmt.Sprintf("%s is not on the mesh's forge, so whether its trunk is protected cannot be read", + sourceWords(built.Repository, built.Seat))) + } + facts, err := askTheForge(ctx, owner, name, branch) if err != nil { - return err + if terminal { + fmt.Printf("%s: the forge could not be asked about %s/%s (%v) — registered, as asked at the controller's "+ + "terminal\n", buildID, owner, name, err) + return 0, nil + } + return refuse(fmt.Sprintf("the forge could not say whether %s/%s's %s is protected: %v", owner, name, branch, err)) } if terminal { - fmt.Printf("%s: %s — registered, as asked at the controller's terminal\n", buildID, why) - return nil + if why != "" || !facts.Guarded { + fmt.Printf("%s: %s — registered, as asked at the controller's terminal\n", buildID, + strings.Trim(why+"; "+facts.Why, "; ")) + } + return facts.ID, nil } - return fmt.Errorf("%w: %s. A module is registered from another repository only by a build asked at the "+ - "controller's terminal, never through a verb: a registered module is what the next push sends, and whoever "+ - "may call a verb includes agents (novox/hq ADR 0266)", errNotItsSource, why) + if !facts.Guarded { + return refuse(fmt.Sprintf("%s/%s's %s is not protected as a module's trunk must be: %s", owner, name, branch, + facts.Why)) + } + // The same repository by the forge's id, not only its name: one deleted and made again is another. + recorded := map[string]int64{} + if !isNew { + id, err := inv.SourceIdentity(ctx, module) + if err != nil { + return 0, err + } + recorded[module] = id + } + for _, e := range alongside { + id, err := inv.SourceIdentity(ctx, e.Manifest.Module) + if err != nil { + return 0, err + } + recorded[e.Manifest.Module] = id + } + for m, id := range recorded { + if id != 0 && id != facts.ID { + return refuse(fmt.Sprintf("%s/%s is not the repository %s was registered from: the forge knows it as "+ + "repository %d, and %s was registered from repository %d — one of that name deleted and made again", + owner, name, m, facts.ID, m, id)) + } + } + return facts.ID, nil +} + +// askedHereFor says the build was asked at the controller's terminal, for this repository and this path: a kept +// request marked so, whose source is the outcome's (novox/hq ADR 0266). +func askedHereFor(ctx context.Context, inv *inventory.Inventory, forms *sourceForms, buildID string, + built inventory.Source, path string) (bool, error) { + r, found, err := inv.BuildRequestByID(ctx, buildID) + if err != nil || !found || !r.AtTerminal { + return false, err + } + if !forms.same(r.Repository, r.Seat, built.Repository, built.Seat) || + strings.Trim(r.Path, "/") != strings.Trim(path, "/") { + fmt.Printf("%s was asked at the terminal of %s at %q, and its outcome is of %s at %q: not the terminal's\n", + buildID, sourceWords(r.Repository, r.Seat), r.Path, sourceWords(built.Repository, built.Seat), path) + return false, nil + } + return true, nil } // sourceWords is a source as a person reads it. diff --git a/cmd/mesh-controller/build_source_test.go b/cmd/mesh-controller/build_source_test.go index 6a9301fc..69fc5d8a 100644 --- a/cmd/mesh-controller/build_source_test.go +++ b/cmd/mesh-controller/build_source_test.go @@ -1,9 +1,15 @@ package main import ( + "context" "encoding/json" "errors" + "hash/fnv" + "os" + "os/exec" + "slices" "strings" + "sync" "testing" "github.com/novox/mesh-controller/internal/inventory" @@ -28,10 +34,10 @@ func onTrunk(id, repository, seat, path string, manifest map[string]any) link.Bu } // keptAsked keeps a build request as the asker would: through a verb, or at the terminal. -func keptAsked(t *testing.T, inv *inventory.Inventory, id, repository string, atTerminal bool) { +func keptAsked(t *testing.T, inv *inventory.Inventory, id, repository, path string, atTerminal bool) { t.Helper() if err := inv.RecordBuildRequest(t.Context(), inventory.BuildRequest{ID: id, Repository: repository, Seat: "git", - For: "build", AtTerminal: atTerminal}); err != nil { + Path: path, For: "build", AtTerminal: atTerminal}); err != nil { t.Fatal(err) } } @@ -45,7 +51,7 @@ func theCatalogue(t *testing.T) *stores { onTrunk("build-sudo", "novox/mesh-catalog", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "1"}), onTrunk("build-host", "novox/mesh-host", "git", "", map[string]any{"module": "mesh-host", "version": "1"}), } { - keptAsked(t, open.inventory, b.ID, b.Source.Repository, true) + keptAsked(t, open.inventory, b.ID, b.Source.Repository, b.Path, true) if _, _, err := takeIn(ctx, open.inventory, b); err != nil { t.Fatal(err) } @@ -65,7 +71,7 @@ func TestABuildFromAnAgentsRepositoryIsNotRegisteredAsAModuleTheMeshHolds(t *tes } { t.Run(c.name, func(t *testing.T) { id := "build-" + strings.ReplaceAll(c.repository, "/", "-") - keptAsked(t, open.inventory, id, c.repository, false) // through the build verb + keptAsked(t, open.inventory, id, c.repository, c.path, false) // through the build verb evil := onTrunk(id, c.repository, "git", c.path, map[string]any{"module": c.module, "version": "evil"}) _, _, err := takeIn(ctx, open.inventory, evil) if !errors.Is(err, errNotItsSource) { @@ -88,7 +94,7 @@ func TestABuildFromAnAgentsRepositoryIsNotRegisteredAsAModuleTheMeshHolds(t *tes func TestANewModuleFromARepositoryTheCatalogueDoesNotBuildFromIsNotRegistered(t *testing.T) { open := theCatalogue(t) ctx := t.Context() - keptAsked(t, open.inventory, "build-new", "agent/tools", false) + keptAsked(t, open.inventory, "build-new", "agent/tools", "", false) _, _, err := takeIn(ctx, open.inventory, onTrunk("build-new", "agent/tools", "git", "", map[string]any{"module": "agent-tools", "version": "1"})) if !errors.Is(err, errNotItsSource) { @@ -109,7 +115,7 @@ func TestANewModuleFromARepositoryTheCatalogueDoesNotBuildFromIsNotRegistered(t func TestAtTheTerminalAnotherRepositoryIsRegistered(t *testing.T) { open := theCatalogue(t) ctx := t.Context() - keptAsked(t, open.inventory, "build-moved", "novox/sudo", true) + keptAsked(t, open.inventory, "build-moved", "novox/sudo", "", true) if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-moved", "novox/sudo", "git", "", map[string]any{"module": "sudo", "version": "2"})); err != nil { t.Fatalf("a move the operator asked for at the terminal was refused: %v", err) @@ -117,7 +123,10 @@ func TestAtTheTerminalAnotherRepositoryIsRegistered(t *testing.T) { if src, _ := open.inventory.SourceOf(ctx, "sudo"); src.Repository != "novox/sudo" { t.Fatalf("sudo is built from %q", src.Repository) } - keptAsked(t, open.inventory, "build-external", "someone/app", true) + if err := open.inventory.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-external", + Repository: "http://forge.internal:20000/someone/app.git", For: "build", AtTerminal: true}); err != nil { + t.Fatal(err) + } if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-external", "someone/app", "", "", map[string]any{"module": "app", "version": "1"})); err != nil { t.Fatalf("a new module the operator asked for at the terminal was refused: %v", err) @@ -195,7 +204,7 @@ func TestARollbackNeverPutsBackABuildFromAnotherRepository(t *testing.T) { fork := onTrunk("build-1791500000000000000", "agent/mesh-catalog", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "evil"}) fork.Commit = "c0ffee0123456789" // the commit sudo was registered at - keptAsked(t, inv, fork.ID, "agent/mesh-catalog", false) + keptAsked(t, inv, fork.ID, "agent/mesh-catalog", "modules/sudo", false) if _, _, err := takeIn(ctx, inv, fork); !errors.Is(err, errNotItsSource) { t.Fatalf("the fork's build was taken in: %v", err) } @@ -220,3 +229,117 @@ func TestARollbackNeverPutsBackABuildFromAnotherRepository(t *testing.T) { t.Fatalf("a rollback puts back %q (found %v), want the registered build-sudo", previous.ID, found) } } + +// theForge is what the forge says in a test, by owner/name: a repository not named here is protected as a +// trunk must be, with an id of its own. +var theForge sync.Map + +func init() { + askTheForge = func(_ context.Context, owner, repo, _ string) (forgeFacts, error) { + if said, ok := theForge.Load(owner + "/" + repo); ok { + switch f := said.(type) { + case error: + return forgeFacts{}, f + case forgeFacts: + return f, nil + } + } + h := fnv.New32a() + _, _ = h.Write([]byte(owner + "/" + repo)) + return forgeFacts{ID: int64(h.Sum32()), Guarded: true}, nil + } +} + +// A module's trunk the forge does not protect — direct pushes, no required status — or a forge that cannot say, +// registers nothing through a verb: the trunk rule means nothing on a branch anyone pushes to. +func TestATrunkTheForgeDoesNotProtectRegistersNothing(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + theForge.Store("novox/unguarded", forgeFacts{ID: 7, Why: "the rule main lets a person push to main directly"}) + t.Cleanup(func() { theForge.Delete("novox/unguarded") }) + first := onTrunk("build-unguarded-1", "novox/unguarded", "git", "", map[string]any{"module": "unguarded", "version": "1"}) + keptAsked(t, open.inventory, first.ID, "novox/unguarded", "", true) + if _, _, err := takeIn(ctx, open.inventory, first); err != nil { + t.Fatalf("at the terminal: %v", err) + } + again := onTrunk("build-unguarded-2", "novox/unguarded", "git", "", map[string]any{"module": "unguarded", "version": "2"}) + if _, _, err := takeIn(ctx, open.inventory, again); !errors.Is(err, errNotItsSource) || + !strings.Contains(err.Error(), "push to main directly") { + t.Fatalf("a rebuild from an unprotected trunk was taken in: %v", err) + } + theForge.Store("novox/unguarded", errors.New("nothing serves gitea.gitea_api")) + if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-unguarded-3", "novox/unguarded", "git", "", + map[string]any{"module": "unguarded", "version": "3"})); !errors.Is(err, errNotItsSource) { + t.Fatalf("a forge that could not say was read as a protected trunk: %v", err) + } + if shelf, _ := open.inventory.Catalogue(ctx); shelf["unguarded"].Version != "1" { + t.Fatalf("unguarded is %q", shelf["unguarded"].Version) + } +} + +// A repository deleted and made again under the module's repository's name is another repository: the forge's +// id, recorded at registration, tells them apart. +func TestARepositoryMadeAgainUnderItsNameIsNotTheModulesSource(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + theForge.Store("novox/remade", forgeFacts{ID: 100, Guarded: true}) + t.Cleanup(func() { theForge.Delete("novox/remade") }) + first := onTrunk("build-remade-1", "novox/remade", "git", "", map[string]any{"module": "remade", "version": "1"}) + keptAsked(t, open.inventory, first.ID, "novox/remade", "", true) + if _, _, err := takeIn(ctx, open.inventory, first); err != nil { + t.Fatal(err) + } + if id, _ := open.inventory.SourceIdentity(ctx, "remade"); id != 100 { + t.Fatalf("the forge's id was not recorded: %d", id) + } + theForge.Store("novox/remade", forgeFacts{ID: 101, Guarded: true}) // deleted, and made again by an agent + if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-remade-2", "novox/remade", "git", "", + map[string]any{"module": "remade", "version": "evil"})); !errors.Is(err, errNotItsSource) || + !strings.Contains(err.Error(), "made again") { + t.Fatalf("a repository made again under the name was taken in: %v", err) + } + // And a new module from it, beside the one registered from the first, the same. + if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-remade-3", "novox/remade", "git", "modules/other", + map[string]any{"module": "other", "version": "1"})); !errors.Is(err, errNotItsSource) { + t.Fatalf("a new module from a repository made again was taken in: %v", err) + } +} + +// The terminal's mark is the operator's for the repository and path they asked: an outcome of another, under that +// build's id, is not theirs. +func TestATerminalRequestCoversOnlyWhatItAsked(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + if err := open.inventory.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-asked", Repository: "novox/app", + Seat: "git", Path: "modules/app", For: "build", AtTerminal: true}); err != nil { + t.Fatal(err) + } + other := onTrunk("build-asked", "agent/sudo", "git", "modules/app", map[string]any{"module": "sudo", "version": "evil"}) + if _, _, err := takeIn(ctx, open.inventory, other); !errors.Is(err, errNotItsSource) { + t.Fatalf("an outcome of another repository under a terminal request's id was taken in: %v", err) + } + elsewhere := onTrunk("build-asked", "novox/app", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "evil"}) + if _, _, err := takeIn(ctx, open.inventory, elsewhere); !errors.Is(err, errNotItsSource) { + t.Fatalf("an outcome of another path under a terminal request's id was taken in: %v", err) + } +} + +// The serving controller, and everything it starts, is never the terminal: a build asked in it reads as asked +// through the mesh even when no verb and no caller is named. +func TestTheServingControllerIsNeverTheTerminal(t *testing.T) { + t.Setenv(verbVar, "") + t.Setenv(link.CallerVar, "") + t.Setenv(servedVar, "") + if !startedAtTheTerminal() { + t.Fatal("a process started by hand is not the terminal") + } + markServed() + if startedAtTheTerminal() { + t.Fatal("the serving controller reads as the terminal") + } + child := exec.Command(os.Args[0], "-test.run=^$") + child.Env = os.Environ() + if !slices.Contains(child.Env, servedVar+"=1") { + t.Fatal("what the serving controller starts does not carry its mark") + } +} diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index a4f54d63..7495ea4e 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -65,6 +65,8 @@ func connectLink(ctx context.Context, inv *inventory.Inventory, enroller link.En } func serve(ctx context.Context) (err error) { + // Nothing this process does, or starts, is the operator at the terminal (novox/hq ADR 0266). + markServed() // The one process whose log is read over time, so the one that says each change to a node's // unmet seat dependencies once (novox/hq ADR 0207). logUnheldChanges = true diff --git a/internal/inventory/catalogue.go b/internal/inventory/catalogue.go index 3112962c..71350a92 100644 --- a/internal/inventory/catalogue.go +++ b/internal/inventory/catalogue.go @@ -169,6 +169,25 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr return nil } +// SourceIdentity is the forge's id of the repository a module is registered from, 0 when none is recorded. +func (i *Inventory) SourceIdentity(ctx context.Context, module string) (int64, error) { + var id *int64 + err := i.store.Pool().QueryRow(ctx, `select source_repo_id from module where name = $1`, module).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return 0, fmt.Errorf("%w: %s", ErrNoSuchModule, module) + } + if err != nil || id == nil { + return 0, err + } + return *id, nil +} + +// SetSourceIdentity records the forge's id of the repository a module is registered from; 0 records none. +func (i *Inventory) SetSourceIdentity(ctx context.Context, module string, id int64) error { + _, err := i.store.Pool().Exec(ctx, `update module set source_repo_id = nullif($2::bigint, 0) where name = $1`, module, id) + return err +} + // hasModule is whether the catalogue holds a module of that name. func (i *Inventory) hasModule(ctx context.Context, name string) (bool, error) { var one int diff --git a/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql b/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql index 78a8d98c..650a8cf8 100644 --- a/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql +++ b/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql @@ -9,3 +9,10 @@ -- request kept by a `build` or `replay --register` run at the terminal, never through a verb (whoever may -- call a verb includes agents). False for every request kept before this column existed. alter table build_request add column at_terminal boolean not null default false; + +-- And which repository a module is registered from, by the forge's own id for it (novox/hq ADR 0266): a name +-- is not an identity. A repository deleted and made again under the same name is another repository, with +-- none of the protection the first had until someone sets it; its outcome must not register as the module's. +-- Recorded when a build of it is registered from the mesh's own forge; null until then, and for a source the +-- forge does not hold. +alter table module add column source_repo_id bigint; diff --git a/internal/inventory/pending.go b/internal/inventory/pending.go index 9cb5dd38..0e761e31 100644 --- a/internal/inventory/pending.go +++ b/internal/inventory/pending.go @@ -88,16 +88,17 @@ func (i *Inventory) RecordBuildRequest(ctx context.Context, a BuildRequest) erro return err } -// AskedAtTheTerminal says whether the build of this id was kept as asked at the controller's terminal (novox/hq -// ADR 0266). False for a build no request was kept for — a check, a dry run, an outcome nobody here asked for — -// and for every request asked through a verb. -func (i *Inventory) AskedAtTheTerminal(ctx context.Context, id string) (bool, error) { - var at bool - err := i.store.Pool().QueryRow(ctx, `select at_terminal from build_request where id = $1`, id).Scan(&at) +// BuildRequestByID is the build request kept under this id, and whether one was kept. +func (i *Inventory) BuildRequestByID(ctx context.Context, id string) (BuildRequest, bool, error) { + var a BuildRequest + err := i.store.Pool().QueryRow(ctx, + `select id, repository, seat, source_path, ref, commit_hash, asked_for, at_terminal, asked_at + from build_request where id = $1`, id).Scan(&a.ID, &a.Repository, &a.Seat, &a.Path, &a.Ref, &a.Commit, + &a.For, &a.AtTerminal, &a.At) if errors.Is(err, pgx.ErrNoRows) { - return false, nil + return BuildRequest{}, false, nil } - return at, err + return a, err == nil, err } // MarkNotAsked says a kept build request was never handed over: the words are kept, unless its outcome was From 2e1a9abbf70d1532bd65ab595a064e9355c0c693 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 12:25:10 +0200 Subject: [PATCH 16/16] Count the quiet after a restart from when the controller first saw the search pending The node-engine's own since starts again at every restart, so an agent that restarted the engine in a loop kept agent-can-become-root quiet for ever (the review of 2026-10-09). The controller now keeps when it first saw the verdict waiting for the setuid search (migration 0085), forgets it at the next complete verdict, and raises once the engine's own bound has passed since; the bound and the pending reason are read from mesh-host's rootsearch. --- cmd/mesh-controller/agent_account.go | 70 ++++++++++++++----- cmd/mesh-controller/agent_account_test.go | 49 +++++++++---- ...keeps-when-a-root-search-began-pending.sql | 9 +++ internal/inventory/nodes.go | 21 ++++++ internal/link/protocol.go | 12 ++-- .../novox/mesh-host/rootsearch/rootsearch.go | 19 +++++ vendor/modules.txt | 1 + 7 files changed, 143 insertions(+), 38 deletions(-) create mode 100644 internal/inventory/migrations/0085-the-controller-keeps-when-a-root-search-began-pending.sql create mode 100644 vendor/github.com/novox/mesh-host/rootsearch/rootsearch.go diff --git a/cmd/mesh-controller/agent_account.go b/cmd/mesh-controller/agent_account.go index ef797b29..9436997e 100644 --- a/cmd/mesh-controller/agent_account.go +++ b/cmd/mesh-controller/agent_account.go @@ -117,35 +117,67 @@ func judgedConfined(agent string, h inventory.NodeHealth, had bool, now time.Tim h.SaidAt.Local().Format("2006-01-02 15:04")) } -// searchQuietFor is how long a verdict may say its setuid search is still running before that is itself the -// urgent condition: the node-engine's bound on one search, and one statement more (a node-engine states its -// health at least every five minutes) for the verdict that follows it to be heard. -const searchQuietFor = link.RootSearchBound + 5*time.Minute +// searchQuietFor is how long the controller lets an agent account's verdict wait for the node-engine's setuid +// search before that is itself the urgent condition: the engine's bound on one search (link.RootSearchBound, the +// engine's own value), counted from when this controller first saw it waiting, never from the engine's start. +const searchQuietFor = link.RootSearchBound -// searchStillRunning says the one thing keeping an agent account from being judged is the node-engine's first -// search for setuid programs, still within its bound (novox/hq ADR 0266): a fresh statement from an engine that -// judges root, holding a verdict on the account, every verdict on it healthy or not judged yet because that -// search runs — and none of those for longer than searchQuietFor. A way to root found, a search that failed or -// did not finish, any other unknown, a stale statement: false, and DA raises it. -func searchStillRunning(agent string, h inventory.NodeHealth, had bool, now time.Time) bool { +// The kinds of an agent account's verdict, for the quiet a search earns. +const ( + verdictOther = iota // anything else: a stale statement, an older engine, no verdict, another unknown + verdictPending // waiting for the search, and otherwise healthy + verdictComplete // judged: healthy, or a way to root found +) + +// rootVerdictKind reads a statement for the agent account (novox/hq ADR 0266): pending when every verdict on it +// is healthy or not judged yet because the engine's setuid search runs, at least one of them that; complete when +// every verdict is healthy or one found a way to root. The engine's own "since" is not read: it starts again at +// every restart of the engine. +func rootVerdictKind(agent string, h inventory.NodeHealth, had bool, now time.Time) int { if !had || now.Sub(h.HeardAt) > verdictFreshFor || h.Contract < link.RootContract { - return false + return verdictOther } - pending := false + pending, any := false, false for _, r := range h.Resources { if r.Kind != link.KindAccount || r.Target != agent || r.Root != link.RootNever { continue } + any = true switch { case r.State == link.StateHealthy: - case r.State == link.StateUnknown && strings.HasPrefix(r.Reason, link.ReasonRootPending) && - !r.Since.IsZero() && now.Sub(r.Since) <= searchQuietFor: + case r.State == link.StateUnhealthy: + return verdictComplete + case r.State == link.StateUnknown && strings.HasPrefix(r.Reason, link.ReasonRootPending): pending = true default: - return false + return verdictOther } } - return pending + switch { + case !any: + return verdictOther + case pending: + return verdictPending + } + return verdictComplete +} + +// searchStillRunning says the one thing keeping an agent account from being judged is the node-engine's setuid +// search, and that this controller first saw it waiting less than searchQuietFor ago — kept in the store, so a +// node-engine restarted in a loop does not keep it quiet. A complete verdict forgets when it began. +func searchStillRunning(ctx context.Context, inv *inventory.Inventory, node, agent string, h inventory.NodeHealth, + had bool, now time.Time) (bool, error) { + switch rootVerdictKind(agent, h, had, now) { + case verdictComplete: + return false, inv.RootSearchJudged(ctx, node) + case verdictPending: + since, err := inv.RootSearchPending(ctx, node, now) + if err != nil { + return false, err + } + return now.Sub(since) <= searchQuietFor, nil + } + return false, nil } // probeAgentAccounts is DA: every machine that names an agent account has it judged, on its node-engine's @@ -166,6 +198,10 @@ func probeAgentAccounts(ctx context.Context, d *doctor) ([]conditions.Observatio return nil, err } now := time.Now() + quiet, err := searchStillRunning(ctx, inv, n.Name, n.AgentAccount, h, had, now) + if err != nil { + return nil, err + } confined, why := judgedConfined(n.AgentAccount, h, had, now) if confined { continue @@ -174,7 +210,7 @@ func probeAgentAccounts(ctx context.Context, d *doctor) ([]conditions.Observatio // urgent condition after every restart. The agent is still not confined — ADR 0259's router reads // agentConfined, not this — and `node show` still says not judged. Loud again once the search fails, // runs out its bound, or the statement goes stale. - if searchStillRunning(n.AgentAccount, h, had, now) { + if quiet { continue } out = append(out, conditions.Observation{Scope: conditions.ScopeMachine, ID: n.Name, Token: "agent-root", diff --git a/cmd/mesh-controller/agent_account_test.go b/cmd/mesh-controller/agent_account_test.go index acb9a1ce..68143eca 100644 --- a/cmd/mesh-controller/agent_account_test.go +++ b/cmd/mesh-controller/agent_account_test.go @@ -1,6 +1,7 @@ package main import ( + "github.com/novox/mesh-host/rootsearch" "strings" "testing" "time" @@ -199,10 +200,14 @@ func TestTheNodeVerbOnlyShows(t *testing.T) { } // After every node-engine restart its search for setuid programs runs for up to its bound, and the agent account -// is not judged until it ends. DA does not raise that as urgent while the search is within its bound — the -// account is still not confined, and `node show` still says not judged — and raises it once the search failed, -// ran out its bound, or found a way to root. +// is not judged until it ends. DA does not raise that as urgent while the search is within its bound, counted from +// when this controller first saw it waiting — never from the engine's own "since", which a restart resets, so an +// engine restarted in a loop does not keep it quiet. The account is still not confined, and `node show` still +// says not judged; a search that failed, or a way to root found, is urgent at once. func TestASearchStillRunningAfterARestartIsNotUrgent(t *testing.T) { + if searchQuietFor != rootsearch.Bound { + t.Fatalf("the quiet is %s and the node-engine's bound %s: they are one value", searchQuietFor, rootsearch.Bound) + } open := aMesh(t) ctx := t.Context() inv := open.inventory @@ -215,11 +220,12 @@ func TestASearchStillRunningAfterARestartIsNotUrgent(t *testing.T) { t.Fatal(err) } d := &doctor{open: open} - say := func(state, reason string, since time.Time) []conditions.Observation { + say := func(state, reason string) []conditions.Observation { t.Helper() + // The engine's since is always now: it was just restarted. v := inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account", Kind: link.KindAccount, Target: "agent", State: state, Reason: reason, Root: link.RootNever, - Account: "agent", Since: since} + Account: "agent", Since: time.Now()} if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.RootContract, SaidAt: time.Now(), HeardAt: time.Now(), Resources: []inventory.ResourceHealth{v}}); err != nil { t.Fatal(err) @@ -231,23 +237,36 @@ func TestASearchStillRunningAfterARestartIsNotUrgent(t *testing.T) { return onlyMachine(found, "anchor") } running := link.ReasonRootPending + ": the search for setuid programs, started at 19:21, has not finished yet" - if found := say(link.StateUnknown, running, time.Now().Add(-2*time.Minute)); len(found) != 0 { - t.Fatalf("a search two minutes into its bound was raised: %+v", found) + healthy := func() { + t.Helper() + if found := say(link.StateHealthy, ""); len(found) != 0 { + t.Fatalf("a healthy verdict raised: %+v", found) + } + } + if found := say(link.StateUnknown, running); len(found) != 0 { + t.Fatalf("a search first seen now was raised: %+v", found) } if _, confined, why, _ := agentConfined(ctx, inv, "anchor"); confined || !strings.Contains(why, "not judged") { t.Fatalf("an account whose search runs was read as confined: %q", why) } - if found := say(link.StateUnknown, running, time.Now().Add(-searchQuietFor-time.Minute)); len(found) != 1 || - found[0].Severity != conditions.Urgent { - t.Fatalf("a search past its bound was not urgent: %+v", found) + // The engine restarted again and again, each statement's own since fresh: the controller's clock runs on. + if _, err := inv.RootSearchPending(ctx, "anchor", time.Now().Add(-searchQuietFor-time.Minute)); err != nil { + t.Fatal(err) } - incomplete := "not judged (search incomplete): the search for setuid programs did not finish (last tried at 19:23: " + - "timeout); it is tried again later" - if found := say(link.StateUnknown, incomplete, time.Now().Add(-time.Minute)); len(found) != 1 || - found[0].Severity != conditions.Urgent { + healthy() // a complete verdict forgets when the waiting began … + if _, err := inv.RootSearchPending(ctx, "anchor", time.Now().Add(-searchQuietFor-time.Minute)); err != nil { + t.Fatal(err) // … and this restart loop began past the bound + } + if found := say(link.StateUnknown, running); len(found) != 1 || found[0].Severity != conditions.Urgent { + t.Fatalf("a search pending past the bound, by the controller's clock, was not urgent: %+v", found) + } + healthy() + incomplete := rootsearch.ReasonIncomplete + ": the search for setuid programs did not finish (last tried at " + + "19:23: timeout); it is tried again later" + if found := say(link.StateUnknown, incomplete); len(found) != 1 || found[0].Severity != conditions.Urgent { t.Fatalf("a search that did not finish was not urgent: %+v", found) } - if found := say(link.StateUnhealthy, link.ReasonRoot+": in the group docker; "+running, time.Now()); len(found) != 1 || + if found := say(link.StateUnhealthy, link.ReasonRoot+": in the group docker; "+running); len(found) != 1 || found[0].Severity != conditions.Urgent { t.Fatalf("a way to root found while the search runs was not urgent: %+v", found) } diff --git a/internal/inventory/migrations/0085-the-controller-keeps-when-a-root-search-began-pending.sql b/internal/inventory/migrations/0085-the-controller-keeps-when-a-root-search-began-pending.sql new file mode 100644 index 00000000..66c10a2a --- /dev/null +++ b/internal/inventory/migrations/0085-the-controller-keeps-when-a-root-search-began-pending.sql @@ -0,0 +1,9 @@ +-- The controller keeps when it first saw an agent account's root verdict waiting for the node-engine's search +-- for setuid programs (novox/hq ADR 0266), cleared by the next complete verdict. +-- +-- The self-check does not raise `agent-can-become-root` while that search is within its bound, so a restart is +-- not an urgent condition each time. The node-engine's own "since" starts again at every restart: an agent +-- that restarted the engine in a loop kept the condition quiet for ever. This time is the controller's, kept +-- across the engine's restarts and its own, so the quiet ends once the bound has passed since the search first +-- read as pending, however often the engine started again. +alter table node add column agent_root_pending_since timestamptz; diff --git a/internal/inventory/nodes.go b/internal/inventory/nodes.go index f71f2697..03f970b6 100644 --- a/internal/inventory/nodes.go +++ b/internal/inventory/nodes.go @@ -1292,3 +1292,24 @@ func (i *Inventory) Sequence(ctx context.Context, id string) (int64, error) { } return *n, nil } + +// RootSearchPending keeps when the controller first saw this node's agent account waiting for the node-engine's +// setuid search (novox/hq ADR 0266) and answers it: the first time it is seen since the last complete verdict, +// at, kept; seen again, what was kept. Kept across the engine's restarts and the controller's own. +func (i *Inventory) RootSearchPending(ctx context.Context, node string, at time.Time) (time.Time, error) { + var since time.Time + err := i.store.Pool().QueryRow(ctx, + `update node set agent_root_pending_since = coalesce(agent_root_pending_since, $2) + where name = $1 returning agent_root_pending_since`, node, at).Scan(&since) + if errors.Is(err, pgx.ErrNoRows) { + return time.Time{}, fmt.Errorf("%w: %s", ErrNoSuchNode, node) + } + return since, err +} + +// RootSearchJudged forgets when a search began pending: a complete verdict came. +func (i *Inventory) RootSearchJudged(ctx context.Context, node string) error { + _, err := i.store.Pool().Exec(ctx, + `update node set agent_root_pending_since = null where name = $1 and agent_root_pending_since is not null`, node) + return err +} diff --git a/internal/link/protocol.go b/internal/link/protocol.go index 4c1d83a9..9c73db5f 100644 --- a/internal/link/protocol.go +++ b/internal/link/protocol.go @@ -8,6 +8,7 @@ package link import ( "encoding/base64" + "github.com/novox/mesh-host/rootsearch" "strconv" "strings" "time" @@ -431,14 +432,13 @@ const RootContract = 3 const ReasonRoot = "can become root without a person" // ReasonRootPending starts the reason of an account verdict the node-engine cannot give yet because its search -// for setuid programs, started when the engine started, has not finished (mesh-host internal/accounts -// ReasonPending): not judged yet, said as such, never a pass. The search is bounded (RootSearchBound); one that -// fails or runs out its bound is said in other words, as not judged (search incomplete). -const ReasonRootPending = "not judged yet (search running)" +// for setuid programs, started when the engine started, has not finished: not judged yet, said as such, never a +// pass. The node-engine's own words (mesh-host rootsearch.ReasonPending), read from it rather than copied. +const ReasonRootPending = rootsearch.ReasonPending // RootSearchBound is the longest the node-engine lets one search for setuid programs run (mesh-host -// internal/accounts SearchBound). -const RootSearchBound = 15 * time.Minute +// rootsearch.Bound): the one value both read. +const RootSearchBound = rootsearch.Bound // 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). diff --git a/vendor/github.com/novox/mesh-host/rootsearch/rootsearch.go b/vendor/github.com/novox/mesh-host/rootsearch/rootsearch.go new file mode 100644 index 00000000..a1c70c0b --- /dev/null +++ b/vendor/github.com/novox/mesh-host/rootsearch/rootsearch.go @@ -0,0 +1,19 @@ +// Package rootsearch holds what the node-engine's search for setuid programs and the controller that reads its +// verdicts must agree on (novox/hq ADR 0266): how long one search may run, and the words a verdict starts with +// while it has no answer. Public, so the controller imports these rather than keeps copies that could drift. +package rootsearch + +import "time" + +// Bound is how long one search for setuid programs may run. It governs the whole search, the walk and the +// package manager's answers together; the controller does not raise an agent account as not judged while the +// first search after a start is within it. +const Bound = 15 * time.Minute + +// The reasons of a root verdict the search could not answer yet. +const ( + // ReasonPending starts the reason while the first search since the engine started runs. + ReasonPending = "not judged yet (search running)" + // ReasonIncomplete starts the reason when no search has finished: one failed or ran out its bound. + ReasonIncomplete = "not judged (search incomplete)" +) diff --git a/vendor/modules.txt b/vendor/modules.txt index fb673637..b8440549 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -78,6 +78,7 @@ github.com/nats-io/nuid # 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/rootsearch github.com/novox/mesh-host/validate # go.uber.org/automaxprocs v1.6.0 ## explicit; go 1.20