From c544c2a17befd2d94a7bc30133c5e9cd8b7c4e2c Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 13:55:06 +0200 Subject: [PATCH] =?UTF-8?q?Key=20root-not-free=20apart=20from=20DA,=20keep?= =?UTF-8?q?=20ADR=200266's=20quiet=20window=20there,=20and=20judge=20at=20?= =?UTF-8?q?one=20clock=20(hq=20ADR=200259=20=C2=A78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The confirmation review of 2026-10-09 found D-root and DA writing one key, machine..agent-root, from two probes with different words, so it flapped every run; D-root is now root-not-free. D-root raised the urgent condition after every node-engine restart while the first setuid search ran; it now keeps the same quiet window as DA, and the root-free verb still answers that machine not free. agentConfined takes the judging clock. --- cmd/mesh-controller/agent_account.go | 7 ++- cmd/mesh-controller/agent_account_test.go | 6 +- cmd/mesh-controller/doctor.go | 2 +- cmd/mesh-controller/probe_agent_root.go | 61 +++++++++++++++----- cmd/mesh-controller/probe_agent_root_test.go | 52 ++++++++++++++--- 5 files changed, 100 insertions(+), 28 deletions(-) diff --git a/cmd/mesh-controller/agent_account.go b/cmd/mesh-controller/agent_account.go index 9436997e..e23d9b74 100644 --- a/cmd/mesh-controller/agent_account.go +++ b/cmd/mesh-controller/agent_account.go @@ -48,7 +48,8 @@ const agentAccountProbe = "DA" // // 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) { +// now is the judging clock, threaded so a caller judging several things at one instant judges them all at it. +func agentConfined(ctx context.Context, inv *inventory.Inventory, node string, now time.Time) (named, confined bool, why string, err error) { n, err := inv.NodeByName(ctx, node) if err != nil { return false, false, "", err @@ -61,7 +62,7 @@ func agentConfined(ctx context.Context, inv *inventory.Inventory, node string) ( if err != nil { return true, false, "", err } - confined, why = judgedConfined(n.AgentAccount, h, had, time.Now()) + confined, why = judgedConfined(n.AgentAccount, h, had, now) return true, confined, why, nil } @@ -228,7 +229,7 @@ func agentAccountLines(ctx context.Context, inv *inventory.Inventory, n inventor 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) + _, confined, why, err := agentConfined(ctx, inv, n.Name, time.Now()) 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)} diff --git a/cmd/mesh-controller/agent_account_test.go b/cmd/mesh-controller/agent_account_test.go index 68143eca..f05790a1 100644 --- a/cmd/mesh-controller/agent_account_test.go +++ b/cmd/mesh-controller/agent_account_test.go @@ -106,10 +106,10 @@ func TestTheSelfCheckSaysAnAgentAccountThatCanBecomeRoot(t *testing.T) { 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 { + if named, confined, why, err := agentConfined(ctx, inv, "anchor", time.Now()); 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 || + if named, _, why, err := agentConfined(ctx, inv, "laptop", time.Now()); err != nil || named || !strings.Contains(why, "operator account") { t.Fatalf("agentConfined on a machine naming none: %v %q %v", named, why, err) } @@ -246,7 +246,7 @@ func TestASearchStillRunningAfterARestartIsNotUrgent(t *testing.T) { 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") { + if _, confined, why, _ := agentConfined(ctx, inv, "anchor", time.Now()); confined || !strings.Contains(why, "not judged") { t.Fatalf("an account whose search runs was read as confined: %q", why) } // The engine restarted again and again, each statement's own since fresh: the controller's clock runs on. diff --git a/cmd/mesh-controller/doctor.go b/cmd/mesh-controller/doctor.go index c229a64d..3f78f57d 100644 --- a/cmd/mesh-controller/doctor.go +++ b/cmd/mesh-controller/doctor.go @@ -130,7 +130,7 @@ var probeRegistry = []probe{ // person, an answer proven there proves nothing. {ID: "D-root", Asserts: "no agent can become root without a person on a machine where the router or a channel " + "proving its sender runs: not by its own account, and not through a tool that runs its command as an account " + - "that can", From: "ADR 0259 §8", Kind: kindAgentRoot, Phase: 2, run: probeAgentRoot}, + "that can", From: "ADR 0259 §8", Kind: kindRootNotFree, Phase: 2, run: probeAgentRoot}, {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/probe_agent_root.go b/cmd/mesh-controller/probe_agent_root.go index 5042fa22..de18aaf4 100644 --- a/cmd/mesh-controller/probe_agent_root.go +++ b/cmd/mesh-controller/probe_agent_root.go @@ -36,14 +36,17 @@ import ( // measure is no longer part of this judgement: it ran in the machine's runtime, as the very account an agent // could become, so it could not be believed. // -// The one judgement (judgeRoot) is read two ways: the self-check raises `agent-root` on every machine where +// The one judgement (judgeRoot) is read two ways: the self-check raises `root-not-free` on every machine where // the router or a module of its own account runs and the judgement fails; and the `root-free` verb answers it // live, to the router, which honours a verified sender only on its pass. A machine holding the operator's // graphical session, where a messaging client's desktop app may run, is not judged here: the operator accepted // that gap for now (hq issue 344). -// kindAgentRoot is the condition a trusted party's machine that is not root-free raises. -const kindAgentRoot = "agent-root" +// kindRootNotFree is the condition a trusted party's machine that is not root-free raises. Its own key, apart +// from ADR 0266's agent-can-become-root (Token agent-root, from DA): the two judge different things — DA the +// agent account alone, this the whole of root-free — and one key from two probes flapped between them (the +// confirmation review of 2026-10-09). +const kindRootNotFree = "root-not-free" // routerSeat is the seat the router holds: where it runs counts as a trusted party's machine. const routerSeat = "operator-channel" @@ -92,6 +95,9 @@ type rootFacts struct { // Execute is whether the login shell's execute is served there; ExecuteWhy why, in words. Execute bool ExecuteWhy string + // SearchPending is the agent account unjudged only because the node-engine's first setuid search runs, + // within its bound (ADR 0266's quiet window). + SearchPending bool } // rootVerdict is the judgement on one machine, as the root-free verb answers it. @@ -100,6 +106,9 @@ type rootVerdict struct { Free bool `json:"free"` Why string `json:"why"` Judged time.Time `json:"judged"` + // Quiet is a machine not free only because its first setuid search still runs, within its bound: the + // self-check raises nothing for it then (ADR 0266's quiet window). It is never free for it. + Quiet bool `json:"quiet,omitempty"` } // judgeRoot is the one judgement: free only when nothing failed to read, an agent account is named and judged @@ -127,6 +136,8 @@ func judgeRoot(f rootFacts, now time.Time) rootVerdict { } if len(not) > 0 { v.Why = strings.Join(not, "; ") + // The one failure is the agent account not judged yet, because its first search runs. + v.Quiet = len(not) == 1 && f.SearchPending && f.AgentNamed && !f.Confined && len(f.Unread) == 0 && !f.Execute return v } v.Free = true @@ -142,7 +153,11 @@ type rootReader struct { heard map[string]map[string]map[string]bool asked error // confined is agentConfined; settings the login shell holder's settings on a machine. Replaceable in a test. - confined func(ctx context.Context, node string) (named, confined bool, why string, err error) + confined func(ctx context.Context, node string, now time.Time) (named, confined bool, why string, err error) + // quiet says the one thing keeping a machine's agent account unjudged is the node-engine's first setuid + // search, within its bound (ADR 0266, searchStillRunning). Read by the self-check alone, to raise nothing + // then; nil reads no quiet. It never makes a machine root-free. + quiet func(ctx context.Context, node string, now time.Time) bool settings func(ctx context.Context, node, module string) ([]catalogue.Layer, error) } @@ -154,26 +169,29 @@ func newRootReader(ctx context.Context, inv *inventory.Inventory, conn *nats.Con } else { r.heard, r.asked = discoverSeatVerbs(ctx, conn) } - r.confined = func(ctx context.Context, node string) (bool, bool, string, error) { - return agentConfined(ctx, inv, node) + r.confined = func(ctx context.Context, node string, now time.Time) (bool, bool, string, error) { + return agentConfined(ctx, inv, node, now) } r.settings = inv.SettingsFor return r } -// facts reads one machine. -func (r *rootReader) facts(ctx context.Context, machine string) rootFacts { +// facts reads one machine, at now. +func (r *rootReader) facts(ctx context.Context, machine string, now time.Time) rootFacts { f := rootFacts{Machine: machine} if r.read != nil { f.Unread = append(f.Unread, "the catalogue's placements could not be read: "+r.read.Error()) } - named, confined, why, err := r.confined(ctx, machine) + named, confined, why, err := r.confined(ctx, machine, now) if err != nil { f.Unread = append(f.Unread, "the account agents run as could not be read: "+err.Error()) } else { f.AgentNamed, f.Confined, f.ConfinedWhy = named, confined, why } f.Execute, f.ExecuteWhy = r.executeServed(ctx, machine) + if r.quiet != nil && f.AgentNamed && !f.Confined { + f.SearchPending = r.quiet(ctx, machine, now) + } return f } @@ -240,7 +258,7 @@ func claimServes(m catalogue.Manifest, seat, verb string) bool { func judgeRootFree(ctx context.Context, r *rootReader, machines []string, now time.Time) []rootVerdict { out := make([]rootVerdict, 0, len(machines)) for _, m := range machines { - out = append(out, judgeRoot(r.facts(ctx, m), now)) + out = append(out, judgeRoot(r.facts(ctx, m, now), now)) } return out } @@ -263,8 +281,8 @@ func trustedMachines(entries []inventory.Entry) map[string][]string { func agentRootObservation(v rootVerdict, trusted []string) conditions.Observation { trusted = append([]string(nil), trusted...) sort.Strings(trusted) - return conditions.Observation{Scope: conditions.ScopeMachine, ID: v.Machine, Token: kindAgentRoot, - Machine: v.Machine, Kind: kindAgentRoot, Severity: conditions.Urgent, + return conditions.Observation{Scope: conditions.ScopeMachine, ID: v.Machine, Token: kindRootNotFree, + Machine: v.Machine, Kind: kindRootNotFree, Severity: conditions.Urgent, Summary: fmt.Sprintf("%s is not root-free, where %s run: until it is, the router approves nothing proven "+ "there (novox/hq ADR 0259 §8): %s", v.Machine, strings.Join(trusted, ", "), v.Why), Headline: "Phone answers held on " + v.Machine, @@ -281,10 +299,25 @@ func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, e if d.js != nil { conn = d.js.Conn() } - r := newRootReader(ctx, d.open.inventory, conn) + inv := d.open.inventory + r := newRootReader(ctx, inv, conn) if r.read != nil { return nil, r.read } + // The self-check alone reads ADR 0266's quiet window: nothing raised while a machine's first setuid search + // runs, within its bound. The root-free verb never reads it, so the machine still answers not free. + r.quiet = func(ctx context.Context, node string, now time.Time) bool { + n, err := inv.NodeByName(ctx, node) + if err != nil || n.AgentAccount == "" { + return false + } + h, had, err := inv.HealthOf(ctx, node) + if err != nil { + return false + } + quiet, err := searchStillRunning(ctx, inv, node, n.AgentAccount, h, had, now) + return err == nil && quiet + } return rootObservations(ctx, r, trustedMachines(r.entries), time.Now()), nil } @@ -297,7 +330,7 @@ func rootObservations(ctx context.Context, r *rootReader, trusted map[string][]s sort.Strings(machines) var out []conditions.Observation for _, v := range judgeRootFree(ctx, r, machines, now) { - if !v.Free { + if !v.Free && !v.Quiet { out = append(out, agentRootObservation(v, trusted[v.Machine])) } } diff --git a/cmd/mesh-controller/probe_agent_root_test.go b/cmd/mesh-controller/probe_agent_root_test.go index acca8056..52c56a93 100644 --- a/cmd/mesh-controller/probe_agent_root_test.go +++ b/cmd/mesh-controller/probe_agent_root_test.go @@ -50,7 +50,7 @@ func TestTheRootReaderFailsClosed(t *testing.T) { return &rootReader{ entries: []inventory.Entry{{Manifest: shell, On: []string{"anchor"}}}, heard: map[string]map[string]map[string]bool{}, - confined: func(context.Context, string) (bool, bool, string, error) { + confined: func(context.Context, string, time.Time) (bool, bool, string, error) { return true, true, "the agent account agents cannot become root without a person", nil }, settings: func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, nil }, @@ -62,17 +62,17 @@ func TestTheRootReaderFailsClosed(t *testing.T) { } cases := map[string]func(*rootReader){ "no agent account named, no coding-agent module there": func(r *rootReader) { - r.confined = func(context.Context, string) (bool, bool, string, error) { + r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) { return false, false, "anchor names no agent account: agents run as the operator account (ops)", nil } }, "the node-engine's verdict not read": func(r *rootReader) { - r.confined = func(context.Context, string) (bool, bool, string, error) { + r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) { return false, false, "", errors.New("the store did not answer") } }, "a stale verdict": func(r *rootReader) { - r.confined = func(context.Context, string) (bool, bool, string, error) { + r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) { return true, false, "the agent account agents is not judged: the machine's newest statement was heard at …", nil } }, @@ -119,12 +119,12 @@ func TestTheProbeSaysEachMachineThatIsNotRootFree(t *testing.T) { t.Fatalf("trusted %v", trusted) } r := &rootReader{entries: entries, heard: map[string]map[string]map[string]bool{}, - confined: func(_ context.Context, node string) (bool, bool, string, error) { + confined: func(_ context.Context, node string, _ time.Time) (bool, bool, string, error) { return false, false, node + " names no agent account: agents run as the operator account (ops)", nil }, settings: func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, nil }} got := rootObservations(context.Background(), r, trusted, rootNow) - if len(got) != 1 || got[0].Machine != "anchor" || got[0].Kind != kindAgentRoot || got[0].Severity != conditions.Urgent || + if len(got) != 1 || got[0].Machine != "anchor" || got[0].Kind != kindRootNotFree || got[0].Severity != conditions.Urgent || !strings.Contains(got[0].Summary, "messenger, telegram") || !strings.Contains(got[0].Summary, "names no agent account") { t.Fatalf("said %+v", got) } @@ -133,7 +133,9 @@ func TestTheProbeSaysEachMachineThatIsNotRootFree(t *testing.T) { Needs: o.Needs, Resolved: o.Resolved}, o.Machine); !ok { t.Errorf("not plain: %s", why) } - r.confined = func(context.Context, string) (bool, bool, string, error) { return true, true, "confined", nil } + r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) { + return true, true, "confined", nil + } if got := rootObservations(context.Background(), r, trusted, rootNow); len(got) != 0 { t.Errorf("said of a free machine: %+v", got) } @@ -225,3 +227,39 @@ func TestAMachineWaitingForItsFirstSetuidSearchIsNotRootFree(t *testing.T) { t.Errorf("a machine whose first setuid search is pending was judged root-free: %+v", v) } } + +// The confirmation review of 2026-10-09, on #154 beside ADR 0266: D-root keeps ADR 0266's quiet window — nothing +// raised while the one thing unjudged is the first setuid search, within its bound — while the root-free verb +// still answers the machine not free; and D-root's condition has a key of its own, apart from DA's. +func TestRootNotFreeIsQuietWhileTheFirstSearchRunsAndKeyedApartFromDA(t *testing.T) { + entries := []inventory.Entry{{Manifest: catalogue.Manifest{Module: "telegram", RunsAs: "telegram"}, On: []string{"anchor"}}} + r := &rootReader{entries: entries, heard: map[string]map[string]map[string]bool{}, + confined: func(context.Context, string, time.Time) (bool, bool, string, error) { + return true, false, "the agent account agents is not judged: the search for setuid programs runs", nil + }, + settings: func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, nil }, + quiet: func(context.Context, string, time.Time) bool { return true }} + trusted := trustedMachines(entries) + if got := rootObservations(context.Background(), r, trusted, rootNow); len(got) != 0 { + t.Errorf("raised while the first search runs: %+v", got) + } + if v := judgeRootFree(context.Background(), r, []string{"anchor"}, rootNow)[0]; v.Free || !v.Quiet { + t.Errorf("root-free while the first search runs: %+v", v) + } + // Quiet hides nothing else: the login shell served as well is said. + r.heard = map[string]map[string]map[string]bool{loginShellSeat: {"execute": {"anchor": true}}} + if got := rootObservations(context.Background(), r, trusted, rootNow); len(got) != 1 { + t.Errorf("a second failure was kept quiet: %+v", got) + } + // Past its bound, said. + r.heard, r.quiet = map[string]map[string]map[string]bool{}, func(context.Context, string, time.Time) bool { return false } + got := rootObservations(context.Background(), r, trusted, rootNow) + if len(got) != 1 { + t.Fatalf("a search past its bound was not said: %+v", got) + } + // One key per judgement: DA's is machine..agent-root, this one its own. + da := conditions.Observation{Scope: conditions.ScopeMachine, ID: "anchor", Token: "agent-root", Machine: "anchor"} + if got[0].Key() == da.Key() { + t.Errorf("D-root and DA share the key %s", da.Key()) + } +}