Merge pull request 'Keep quiet for the setuid search the engine now runs to completion (hq issue 361)' (#190) from fix/361-the-setuid-search-runs-to-completion into main

This commit was merged in pull request #190.
This commit is contained in:
2026-10-09 23:28:36 +00:00
9 changed files with 88 additions and 51 deletions
+7 -5
View File
@@ -119,9 +119,11 @@ func judgedConfined(agent string, h inventory.NodeHealth, had bool, now time.Tim
}
// 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
// search before that is itself the urgent condition: the longest a search is expected to take (link.RootSearchQuiet,
// the engine's own value; novox/hq issue 361 — the search runs to completion, with no bound of its own), counted
// from when this controller first saw it waiting, never from the engine's start. Quiet raises nothing; it never
// makes the agent account confined, which only a healthy verdict from a complete, fresh search does.
const searchQuietFor = link.RootSearchQuiet
// The kinds of an agent account's verdict, for the quiet a search earns.
const (
@@ -207,10 +209,10 @@ func probeAgentAccounts(ctx context.Context, d *doctor) ([]conditions.Observatio
if confined {
continue
}
// Not judged yet only because the first search since the node-engine started is still running: not the
// Not judged yet only because the node-engine's search runs and no complete, fresh one judges: 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.
// waits past searchQuietFor, or the statement goes stale.
if quiet {
continue
}
+13 -7
View File
@@ -199,14 +199,20 @@ 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, 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.
// Until a complete search for setuid programs judges — none since the node-engine's state was kept, or the last
// older than the engine lets one judge — the agent account is not judged, and the search runs to its end with no
// bound (novox/hq issue 361). DA does not raise that as urgent within searchQuietFor, 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)
if searchQuietFor != rootsearch.Quiet {
t.Fatalf("the quiet is %s and the node-engine's %s: they are one value", searchQuietFor, rootsearch.Quiet)
}
// A daily search that finishes within the quiet never leaves the account unjudged between two of them.
if rootsearch.FreshFor < rootsearch.Every+rootsearch.Quiet {
t.Fatalf("a complete search judges for %s, less than a day's search (%s) and the quiet (%s)",
rootsearch.FreshFor, rootsearch.Every, rootsearch.Quiet)
}
open := aMesh(t)
ctx := t.Context()
+11 -9
View File
@@ -25,7 +25,9 @@ import (
// 1. the machine names an account agents run as (novox/hq ADR 0266), so no agent runs as the operator's
// account, which may become root;
// 2. its node-engine — running as root, which no agent controls — judged that account unable to become root
// without a person, in a statement heard within the last 15 minutes (agentConfined, judgedConfined);
// without a person, in a statement heard within the last 15 minutes (agentConfined, judgedConfined). The
// engine gives that verdict only from a complete search for setuid programs younger than mesh-host's
// rootsearch.FreshFor; before one, it says "not judged yet", which is no pass (novox/hq issue 361);
// 3. the login shell's `execute` is not served there (novox/hq ADR 0268): its holder's setting withholds it
// **and** the bus was asked and heard no `execute` answered there. `execute` runs commands as the machine's
// runtime account, which the mesh's acting tools give passwordless sudo; that account is taken to become
@@ -95,8 +97,8 @@ 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 is the agent account unjudged only because the node-engine's setuid search runs, within
// the quiet the controller gives it (searchQuietFor; ADR 0266's quiet window, issue 361).
SearchPending bool
}
@@ -106,7 +108,7 @@ 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
// Quiet is a machine not free only because its setuid search still runs, within searchQuietFor: the
// self-check raises nothing for it then (ADR 0266's quiet window). It is never free for it.
Quiet bool `json:"quiet,omitempty"`
}
@@ -136,7 +138,7 @@ 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.
// The one failure is the agent account not judged yet, because its search runs.
v.Quiet = len(not) == 1 && f.SearchPending && f.AgentNamed && !f.Confined && len(f.Unread) == 0 && !f.Execute
return v
}
@@ -154,8 +156,8 @@ type rootReader struct {
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, 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
// quiet says the one thing keeping a machine's agent account unjudged is the node-engine's setuid
// search, within searchQuietFor (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)
@@ -304,8 +306,8 @@ func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, e
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.
// The self-check alone reads ADR 0266's quiet window: nothing raised while a machine's setuid search
// runs, within searchQuietFor. 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 == "" {
+7 -7
View File
@@ -201,7 +201,7 @@ func TestRootFreeIsAnsweredOnlyByTheServingController(t *testing.T) {
}
// The confirmation review of 2026-10-09: ADR 0266's quiet window (#175) keeps the self-check from raising
// agent-can-become-root while the node-engine's first setuid search runs. It must not make root-free answer free:
// agent-can-become-root while the node-engine's setuid search runs and no complete one judges. It must not make root-free answer free:
// root-free needs a complete, fresh verdict. A verdict still waiting for the search is "not judged" to
// agentConfined, so the machine is not root-free, whatever the quiet says — and the same statement, complete
// and healthy, is the control.
@@ -224,12 +224,12 @@ func TestAMachineWaitingForItsFirstSetuidSearchIsNotRootFree(t *testing.T) {
t.Fatal("the statement is not one the quiet window counts as waiting for the search")
}
if v := judged(pending); v.Free {
t.Errorf("a machine whose first setuid search is pending was judged root-free: %+v", v)
t.Errorf("a machine whose 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
// raised while the one thing unjudged is the setuid search, within searchQuietFor — 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"}}}
@@ -241,21 +241,21 @@ func TestRootNotFreeIsQuietWhileTheFirstSearchRunsAndKeyedApartFromDA(t *testing
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)
t.Errorf("raised while the 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)
t.Errorf("root-free while the 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.
// Past searchQuietFor, 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)
t.Fatalf("a search past searchQuietFor was not said: %+v", got)
}
// One key per judgement: DA's is machine.<m>.agent-root, this one its own.
da := conditions.Observation{Scope: conditions.ScopeMachine, ID: "anchor", Token: "agent-root", Machine: "anchor"}
+2 -2
View File
@@ -3,7 +3,7 @@ module github.com/novox/mesh-controller
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970
github.com/jackc/pgx/v5 v5.10.0
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
@@ -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-20261009141741-d8ff1540df56
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009231844-b8c854611812
+4 -4
View File
@@ -1,7 +1,7 @@
git.novox.be/novox/mesh-host v0.0.0-20261009141741-d8ff1540df56 h1:Vut7OdwSAL0rFaavaFmv3+FIGS3ISM4jA+xTiS88nvg=
git.novox.be/novox/mesh-host v0.0.0-20261009141741-d8ff1540df56/go.mod h1:mBxSf6wULwn0bdpHkIHUnhTvNzqnULnjoRN28dUgSBU=
git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689 h1:Ti2P9nwders7YQ/hq3X/dPo+CXMj5pdUcfA5P/c12CU=
git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-host v0.0.0-20261009231844-b8c854611812 h1:pzVzwF5VMWaTECxu8+Pd1dNoOHNEm7upC5wPadQTkBw=
git.novox.be/novox/mesh-host v0.0.0-20261009231844-b8c854611812/go.mod h1:K3/xEzVgmrNKLMV2vv4M80MwmPnQNXqvQ4C5Jj0fJT4=
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 h1:9tFDQsgmI+4X7/BpZGXIr+HemPKE7YddYGqWV0lINAI=
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
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=
+8 -6
View File
@@ -431,14 +431,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: 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.
// ReasonRootPending starts the reason of an account verdict the node-engine cannot give yet because no complete
// search for setuid programs judges — none has finished, or the last is older than the engine lets one judge
// (mesh-host rootsearch.FreshFor) — and one runs: 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
// rootsearch.Bound): the one value both read.
const RootSearchBound = rootsearch.Bound
// RootSearchQuiet is how long an agent account may wait for the node-engine's search for setuid programs before
// the waiting is itself the urgent condition (mesh-host rootsearch.Quiet, novox/hq issue 361): the search has no
// bound of its own, and this is the longest one is expected to take at idle priority. The one value both read.
const RootSearchQuiet = rootsearch.Quiet
// 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).
+33 -8
View File
@@ -1,19 +1,44 @@
// 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.
// verdicts must agree on (novox/hq ADR 0266, issue 361): how often a full search runs, how long a complete one
// judges, how long the controller stays quiet while one runs, 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.
//
// **Why a complete search may judge for a day and more.** The search looks for a setuid- or setgid-root program
// no package owns, a way for the agent account to become root. Only root can make such a file: the agent account
// can neither set the setuid bit on a file root owns nor give a file it owns to root. So a search that walked
// everything stays sound until root acts — and root's acts through the mesh (an apply that changes an account, a
// group, a sudo rule, a package or runs an action) start a new search at once. What is left is root acting by
// hand, which is the operator; FreshFor bounds how long that goes unseen.
//
// **And why no bound on one search.** On a machine with millions of files the walk takes longer than any bound
// worth stating (the control-node's did not finish in fifteen minutes, issue 361), and a search ended early
// judges nothing, so a bound made such a machine never judged. The search runs to its end at idle priority,
// once at a time, keeping how far its walk got every minute so an engine restarted midway carries it on; an
// incomplete search is never "free". A walk resumed more than FreshFor after it started is begun again.
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
const (
// Every is how often a full search runs at most, counted from the start of the last complete one; an
// apply that changes what root controls starts one sooner.
Every = 24 * time.Hour
// FreshFor is how long a complete search judges, counted from its start: after it, the verdict is "not
// judged yet" until a newer search completes. Every plus Quiet, so a daily search that finishes within
// the controller's quiet leaves no hour unjudged.
FreshFor = Every + Quiet
// Quiet is how long the controller raises nothing while an agent account waits for a search, counted
// from when it first saw it waiting: the longest a full search is expected to take at idle priority on
// the largest machine. Past it, waiting is itself the urgent condition. It never makes a machine free.
Quiet = 12 * time.Hour
)
// 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 starts the reason while no complete search judges: none has finished since the engine's
// state was kept, or the last one is older than FreshFor, and one runs.
ReasonPending = "not judged yet (search running)"
// ReasonIncomplete starts the reason when no search has finished: one failed or ran out its bound.
// ReasonIncomplete starts the reason when no complete search judges and the last one failed: it is tried
// again after a back-off.
ReasonIncomplete = "not judged (search incomplete)"
)
+3 -3
View File
@@ -1,4 +1,4 @@
# git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689
# git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970
## explicit; go 1.22
git.novox.be/novox/mesh-sdk/go/asks
# github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op
@@ -78,7 +78,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-20261009141741-d8ff1540df56
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261009231844-b8c854611812
## explicit; go 1.26.0
github.com/novox/mesh-host/internal/declaration
github.com/novox/mesh-host/rootsearch
@@ -135,4 +135,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-20261009141741-d8ff1540df56
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009231844-b8c854611812