Archived
Do not raise agent-can-become-root while the first setuid search is within its bound (hq ADR 0266, note E) #175
@@ -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