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.
This commit is contained in:
@@ -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 "+
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user