diff --git a/cmd/mesh-controller/probe_agent_root.go b/cmd/mesh-controller/probe_agent_root.go index b7aff456..1c3ccccf 100644 --- a/cmd/mesh-controller/probe_agent_root.go +++ b/cmd/mesh-controller/probe_agent_root.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/link" ) @@ -24,7 +25,12 @@ import ( // that machine names (`agent_account`), and the operator's account while it names none; // 2. can an agent run a command it chooses, through the mesh's own tools, as an account that can — the login // shell's `execute` runs as the machine's runtime account, which the mesh's acting tools give passwordless -// sudo? +// sudo? **Only where `execute` is served** (novox/hq ADR 0268): `execute` is an optional verb its holder +// withholds on a machine whose `execute` setting is not `serve`. The holder's seat being held there says +// nothing; whether the verb is served does. It counts as served while either says so — the holder's +// setting as the controller resolves it for that machine (a withheld value not yet pushed is still served), +// or the bus's discovery hearing the verb answered there — so a withheld setting the machine has not acted +// on yet, or a holder answering against its setting, is never taken for closed. // // The measurement is the sudo module's on that machine (`sudo_escalation`); a machine it does not run on, or // that does not answer, is a probe that could not run — said, never taken for "no". @@ -39,8 +45,45 @@ const ( sudoModule = "sudo" sudoEscalation = "sudo_escalation" loginShellSeat = "node-login-shell" + // loginShellVerb is the seat's verb that runs a command, and the name of the setting its holder withholds + // it by (novox/hq ADR 0268). + loginShellVerb = "execute" + // executeServes is the one value of that setting that serves the verb; anything else withholds it. + executeServes = "serve" ) +// loginShellServed judges whether the login shell's execute is served on a machine, failing closed (novox/hq +// ADR 0268): served while the holder's setting there is `serve` (or the holder has no such setting and claims +// the verb), or while the bus heard the verb answered there, or while the bus could not be asked. why says +// which, in words, for the condition. +func loginShellServed(holder string, claims bool, setting *any, heard, asked bool) (bool, string) { + var why []string + switch { + case setting != nil: + if v, _ := (*setting).(string); v == executeServes { + why = append(why, holder+"'s execute setting there is "+executeServes) + } + case claims: + why = append(why, holder+" claims execute and has no setting that withholds it") + } + if heard { + why = append(why, "the bus hears execute answered there") + } else if !asked { + why = append(why, "the bus could not be asked whether execute is answered there") + } + return len(why) > 0, strings.Join(why, "; ") +} + +// claimServes says whether a manifest's claim of a seat names a verb among those it serves. +func claimServes(m catalogue.Manifest, seat, verb string) bool { + for _, c := range m.Claims { + if c.Name == seat && slices.Contains(c.Serves, verb) { + return true + } + } + return false +} + // escalation is the sudo module's answer for one account. type escalation struct { Account string `json:"account"` @@ -55,8 +98,10 @@ type agentRootFacts struct { Agent string // the account agents run as there; "" when none run there AgentNamed bool // the coding-agent module named it, rather than it being taken for the operator's Runtime string // the account the machine's runtime runs as - LoginShell bool // the login shell seat is held there, running commands as the runtime's account - Answers map[string]escalation + LoginShell bool // the login shell's execute is served there, running commands as the runtime's account + // LoginShellWhy is why execute counts as served there, in words: its holder's setting, the bus, or both. + LoginShellWhy string + Answers map[string]escalation } // agentRootObservations judges one machine's facts: an urgent condition while an agent can become root there @@ -74,8 +119,12 @@ func agentRootObservations(f agentRootFacts) []conditions.Observation { } if f.LoginShell && f.Runtime != "" { if e := f.Answers[f.Runtime]; e.Root { - ways = append(ways, fmt.Sprintf("the login shell runs any command an agent gives it as %s, which can "+ - "become root without a person: %s", f.Runtime, e.Why)) + served := "" + if f.LoginShellWhy != "" { + served = " (" + f.LoginShellWhy + ")" + } + ways = append(ways, fmt.Sprintf("the login shell runs any command an agent gives it as %s%s, which can "+ + "become root without a person: %s", f.Runtime, served, e.Why)) } } if len(ways) == 0 { @@ -121,15 +170,6 @@ func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, e } } } - for _, e := range entries { - if e.Manifest.ClaimsSeat(loginShellSeat) { - for _, node := range e.On { - if f := facts[node]; f != nil { - f.LoginShell = true - } - } - } - } machines := make([]string, 0, len(facts)) for m := range facts { machines = append(machines, m) @@ -137,6 +177,44 @@ func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, e sort.Strings(machines) var out []conditions.Observation var unread []string + if len(machines) == 0 { + return nil, nil + } + // Whether the login shell's execute is served where a trusted party runs (novox/hq ADR 0268): the + // holder's setting for that machine, and what the bus hears answered there. A discovery that could not be + // asked leaves only the setting, which is said: the probe then cannot show the verb withheld. + heard, derr := discoverSeatVerbs(ctx, d.js.Conn()) + if derr != nil { + unread = append(unread, "the bus's discovery could not be asked whether the login shell's execute is served: "+derr.Error()) + } + for _, e := range entries { + if !e.Manifest.ClaimsSeat(loginShellSeat) { + continue + } + for _, node := range e.On { + f := facts[node] + if f == nil { + continue + } + var setting *any + if _, declared := e.Manifest.Settings[loginShellVerb]; declared { + layers, err := inv.SettingsFor(ctx, node, e.Manifest.Module) + if err != nil { + unread = append(unread, node+": "+e.Manifest.Module+"'s settings could not be read: "+err.Error()) + f.LoginShell, f.LoginShellWhy = true, "its holder's setting could not be read" + continue + } + for _, s := range catalogue.Effective(e.Manifest, layers) { + if s.Key == loginShellVerb { + v := s.Value + setting = &v + } + } + } + f.LoginShell, f.LoginShellWhy = loginShellServed(e.Manifest.Module, claimServes(e.Manifest, loginShellSeat, loginShellVerb), + setting, heard[loginShellSeat][loginShellVerb][node], derr == nil) + } + } for _, m := range machines { f := facts[m] record, err := inv.NodeByName(ctx, m) diff --git a/cmd/mesh-controller/probe_agent_root_test.go b/cmd/mesh-controller/probe_agent_root_test.go index 5ca7364b..247e69f3 100644 --- a/cmd/mesh-controller/probe_agent_root_test.go +++ b/cmd/mesh-controller/probe_agent_root_test.go @@ -58,3 +58,45 @@ func TestTheRootConditionIsSaidInPlainWords(t *testing.T) { t.Errorf("not plain: %s", why) } } + +// novox/hq ADR 0268: the login shell counts only where its execute is served, and fails closed — the holder's +// setting resolving to anything but serve and the bus hearing no execute there is the one way it is withheld. +func TestTheLoginShellCountsOnlyWhereExecuteIsServed(t *testing.T) { + val := func(v any) *any { return &v } + cases := []struct { + name string + claims bool + setting *any + heard, asked bool + served bool + saysInTheWhys string + }{ + {"withheld by the setting and silent on the bus", true, val("withhold"), false, true, false, ""}, + {"withheld by the setting, the machine not yet pushed", true, val("withhold"), true, true, true, "the bus hears"}, + {"the setting serves", true, val("serve"), false, true, true, "setting there is serve"}, + {"a wrong value withholds, as the holder does", true, val("Serve"), false, true, false, ""}, + {"the setting withholds, the bus could not be asked", true, val("withhold"), false, false, true, "could not be asked"}, + {"a holder with no such setting that claims execute", true, nil, false, true, true, "claims execute"}, + {"a holder with no such setting that does not claim it, heard all the same", false, nil, true, true, true, "the bus hears"}, + {"a holder with no such setting that does not claim it, silent", false, nil, false, true, false, ""}, + } + for _, c := range cases { + served, why := loginShellServed("zsh", c.claims, c.setting, c.heard, c.asked) + if served != c.served || (c.saysInTheWhys != "" && !strings.Contains(why, c.saysInTheWhys)) { + t.Errorf("%s: served %v (%q), want %v saying %q", c.name, served, why, c.served, c.saysInTheWhys) + } + } + + // The control-node with execute withheld and agents of their own account: nothing is said. + f := agentRootFacts{Machine: "anchor", Trusted: []string{"telegram"}, Runtime: "ops", Agent: "agents", AgentNamed: true, + Answers: map[string]escalation{"ops": {Root: true, Why: "NOPASSWD"}, "agents": {Why: "none"}}} + f.LoginShell, f.LoginShellWhy = loginShellServed("zsh", true, val("withhold"), false, true) + if got := agentRootObservations(f); len(got) != 0 { + t.Errorf("execute withheld and agents confined: %+v", got) + } + // Withheld in the setting, still answered on the bus: said, with why. + f.LoginShell, f.LoginShellWhy = loginShellServed("zsh", true, val("withhold"), true, true) + if got := agentRootObservations(f); len(got) != 1 || !strings.Contains(got[0].Summary, "the bus hears execute answered there") { + t.Errorf("execute still answered: %+v", got) + } +} diff --git a/cmd/mesh-controller/probes.go b/cmd/mesh-controller/probes.go index 6e034d07..6f84ef68 100644 --- a/cmd/mesh-controller/probes.go +++ b/cmd/mesh-controller/probes.go @@ -637,31 +637,8 @@ const ( // discoverHolders asks the bus's discovery who serves what, and answers seat → machine for every // endpoint a seat's verb is served on. func discoverHolders(ctx context.Context, conn *nats.Conn) (map[string]map[string]bool, error) { - inbox := conn.NewRespInbox() - sub, err := conn.SubscribeSync(inbox) - if err != nil { - return nil, err - } - defer func() { _ = sub.Unsubscribe() }() - if err := conn.PublishRequest("$SRV.INFO", inbox, nil); err != nil { - return nil, fmt.Errorf("asking the bus who serves what: %w", err) - } out := map[string]map[string]bool{} - deadline := time.Now().Add(discoveryPatience) - for time.Now().Before(deadline) { - wait, cancel := context.WithTimeout(ctx, discoveryQuiet) - msg, err := sub.NextMsgWithContext(wait) - cancel() - if err != nil { - if ctx.Err() != nil { - return nil, ctx.Err() - } - break - } - var info micro.Info - if json.Unmarshal(msg.Data, &info) != nil { - continue - } + err := discoverServices(ctx, conn, func(info micro.Info) { for _, e := range info.Endpoints { seat, node := e.Metadata["seat"], e.Metadata["node"] if seat == "" { @@ -680,8 +657,69 @@ func discoverHolders(ctx context.Context, conn *nats.Conn) (map[string]map[strin out[info.Name] = map[string]bool{} } out[info.Name][info.ID] = true + }) + return out, err +} + +// discoverSeatVerbs asks the bus's discovery the same, one level finer: seat → verb → machine, for every +// seat verb answered (an endpoint's `tool` is its verb). A seat held where a verb is withheld (novox/hq ADR +// 0268) shows the seat and not that verb. +func discoverSeatVerbs(ctx context.Context, conn *nats.Conn) (map[string]map[string]map[string]bool, error) { + out := map[string]map[string]map[string]bool{} + err := discoverServices(ctx, conn, func(info micro.Info) { + for _, e := range info.Endpoints { + seat, verb, node := e.Metadata["seat"], e.Metadata["tool"], e.Metadata["node"] + if seat == "" || verb == "" { + continue + } + if node == "" { + node = info.ID + } + if out[seat] == nil { + out[seat] = map[string]map[string]bool{} + } + if out[seat][verb] == nil { + out[seat][verb] = map[string]bool{} + } + out[seat][verb][node] = true + } + }) + return out, err +} + +// discoverServices asks the bus's discovery once and hands every service's answer to visit, waiting +// discoveryQuiet after the last and discoveryPatience at the most. +func discoverServices(ctx context.Context, conn *nats.Conn, visit func(micro.Info)) error { + if conn == nil { + return errors.New("the controller holds no connection to the bus") } - return out, nil + inbox := conn.NewRespInbox() + sub, err := conn.SubscribeSync(inbox) + if err != nil { + return err + } + defer func() { _ = sub.Unsubscribe() }() + if err := conn.PublishRequest("$SRV.INFO", inbox, nil); err != nil { + return fmt.Errorf("asking the bus who serves what: %w", err) + } + deadline := time.Now().Add(discoveryPatience) + for time.Now().Before(deadline) { + wait, cancel := context.WithTimeout(ctx, discoveryQuiet) + msg, err := sub.NextMsgWithContext(wait) + cancel() + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + break + } + var info micro.Info + if json.Unmarshal(msg.Data, &info) != nil { + continue + } + visit(info) + } + return nil } // probeArchives is D4: every archive the mesh keeps is held by its manifest in the artifact store.