195 lines
7.2 KiB
Go
195 lines
7.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/conditions"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
)
|
|
|
|
// The self-check's probe of who can become root where the trusted parties run (novox/hq ADR 0259 §8, rule 3).
|
|
//
|
|
// The router and every channel proving its sender run as accounts of their own, so that no agent reads what
|
|
// they hold or speaks as them. **Root on their machine undoes all of it.** So on every machine where a module
|
|
// of its own account runs, this probe asks two questions, and raises an urgent condition while either is yes:
|
|
//
|
|
// 1. can an account an agent runs as become root without a person there — passwordless sudo, or a group
|
|
// that is root by another name (docker, disk)? The agent's account is the one the coding-agent module on
|
|
// 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?
|
|
//
|
|
// 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".
|
|
|
|
// kindAgentRoot is the condition an agent able to become root where a trusted party runs raises.
|
|
const kindAgentRoot = "agent-root"
|
|
|
|
// The tools the probe asks, of the modules on each machine.
|
|
const (
|
|
agentModule = "claude-code"
|
|
agentStatusTool = "claude_code_status"
|
|
sudoModule = "sudo"
|
|
sudoEscalation = "sudo_escalation"
|
|
loginShellSeat = "node-login-shell"
|
|
)
|
|
|
|
// escalation is the sudo module's answer for one account.
|
|
type escalation struct {
|
|
Account string `json:"account"`
|
|
Root bool `json:"root_without_a_person"`
|
|
Why string `json:"why"`
|
|
}
|
|
|
|
// agentRootFacts is what one machine says, as the probe reads it.
|
|
type agentRootFacts struct {
|
|
Machine string
|
|
Trusted []string // the modules of their own account on it
|
|
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
|
|
}
|
|
|
|
// agentRootObservations judges one machine's facts: an urgent condition while an agent can become root there
|
|
// without a person, one way or the other, naming which.
|
|
func agentRootObservations(f agentRootFacts) []conditions.Observation {
|
|
var ways []string
|
|
if f.Agent != "" {
|
|
if e := f.Answers[f.Agent]; e.Root {
|
|
named := "the operator's account, which agents run as while the coding-agent module names no other"
|
|
if f.AgentNamed {
|
|
named = "the account agents run as"
|
|
}
|
|
ways = append(ways, fmt.Sprintf("%s (%s) can become root without a person: %s", f.Agent, named, e.Why))
|
|
}
|
|
}
|
|
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))
|
|
}
|
|
}
|
|
if len(ways) == 0 {
|
|
return nil
|
|
}
|
|
sort.Strings(f.Trusted)
|
|
return []conditions.Observation{{Scope: conditions.ScopeMachine, ID: f.Machine, Token: kindAgentRoot,
|
|
Machine: f.Machine, Kind: kindAgentRoot, Severity: conditions.Urgent,
|
|
Summary: fmt.Sprintf("an agent can become root on %s without a person, where %s run as accounts of their "+
|
|
"own; until it cannot, the router approves nothing proven there (novox/hq ADR 0259 §8): %s",
|
|
f.Machine, strings.Join(f.Trusted, ", "), strings.Join(ways, "; ")),
|
|
Headline: "Root without you on " + f.Machine,
|
|
Needs: "choose how programs working for you on " + f.Machine + " stop becoming root without asking you.",
|
|
Explanation: "The modules that prove your answers from your phone run on " + f.Machine + ", and a program " +
|
|
"working for you there can become root without asking you, so it could answer in your name. Until " +
|
|
"that changes, answers from your phone can only acknowledge.",
|
|
Resolved: "Nothing on " + f.Machine + " becomes root without you any more"}}
|
|
}
|
|
|
|
// probeAgentRoot is the probe: every machine a module of its own account runs on, judged.
|
|
func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, error) {
|
|
inv := d.open.inventory
|
|
entries, err := inv.Catalogued(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
facts := map[string]*agentRootFacts{}
|
|
agentOn, sudoOn := map[string]bool{}, map[string]bool{}
|
|
for _, e := range entries {
|
|
for _, node := range e.On {
|
|
switch {
|
|
case e.Manifest.RunsAs != "":
|
|
f := facts[node]
|
|
if f == nil {
|
|
f = &agentRootFacts{Machine: node, Answers: map[string]escalation{}}
|
|
facts[node] = f
|
|
}
|
|
f.Trusted = append(f.Trusted, e.Manifest.Module)
|
|
case e.Manifest.Module == agentModule:
|
|
agentOn[node] = true
|
|
case e.Manifest.Module == sudoModule:
|
|
sudoOn[node] = true
|
|
}
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
sort.Strings(machines)
|
|
var out []conditions.Observation
|
|
var unread []string
|
|
for _, m := range machines {
|
|
f := facts[m]
|
|
record, err := inv.NodeByName(ctx, m)
|
|
if err != nil {
|
|
unread = append(unread, m+": "+err.Error())
|
|
continue
|
|
}
|
|
f.Runtime = record.Account
|
|
if agentOn[m] {
|
|
f.Agent = record.Account
|
|
if a, err := link.AskModuleToolOn(ctx, d.js.Conn(), agentModule, agentStatusTool, m, map[string]any{}, 10*time.Second); err == nil && a.Error == "" {
|
|
var status struct {
|
|
AgentAccount string `json:"agent_account"`
|
|
}
|
|
if json.Unmarshal(a.Result, &status) == nil && status.AgentAccount != "" {
|
|
f.Agent, f.AgentNamed = status.AgentAccount, true
|
|
}
|
|
}
|
|
}
|
|
if !sudoOn[m] {
|
|
unread = append(unread, m+": the sudo module is not assigned there, so who can become root is not measured")
|
|
continue
|
|
}
|
|
var accounts []string
|
|
for _, a := range []string{f.Agent, f.Runtime} {
|
|
if a != "" && !slices.Contains(accounts, a) {
|
|
accounts = append(accounts, a)
|
|
}
|
|
}
|
|
if len(accounts) == 0 {
|
|
continue
|
|
}
|
|
a, err := link.AskModuleToolOn(ctx, d.js.Conn(), sudoModule, sudoEscalation, m, map[string]any{"accounts": accounts}, 15*time.Second)
|
|
if err == nil && a.Error != "" {
|
|
err = fmt.Errorf("%s", a.Error)
|
|
}
|
|
if err != nil {
|
|
unread = append(unread, m+": "+err.Error())
|
|
continue
|
|
}
|
|
var answers []escalation
|
|
if err := json.Unmarshal(a.Result, &answers); err != nil {
|
|
unread = append(unread, m+": the sudo module's answer could not be read: "+err.Error())
|
|
continue
|
|
}
|
|
for _, e := range answers {
|
|
f.Answers[e.Account] = e
|
|
}
|
|
out = append(out, agentRootObservations(*f)...)
|
|
}
|
|
if len(unread) > 0 {
|
|
return out, fmt.Errorf("who can become root could not be measured: %s", strings.Join(unread, "; "))
|
|
}
|
|
return out, nil
|
|
}
|