package main // Who can become root here without a person (novox/hq ADR 0259 §8): the controller's self-check may ask it for // the accounts agents run as and the account the machine's runtime runs commands as. // // **Conservative, as the review of 2026-10-09 asked.** An account can become root without a person when: // - it is root; // - it is a member of a group that is root by another name (docker, disk, root, lxd, incus-admin, libvirt); // - **sudo lists any rule for it at all** — with NOPASSWD or without, for every command or for one. A named // command is root when it can start a shell or write a file (systemctl, tee, bash, pacman, an editor, …), // and no list of safe commands is kept: none is proven safe here. A rule that asks a password is root too, // because sudo keeps a password typed by the person for minutes, during which a program of the same // account needs none. This is the node-engine's own rule for an agent account (ADR 0266: "any sudo rule"). // // **What cannot be judged is an error, never a no**: an account this machine does not know, sudo that could not // be asked, a listing that cannot be read. The caller says it was not measured. // // **Measured as the runtime's account**, which these tools run as: an agent that could act as that account // could change this answer, so the controller takes the agent account's verdict from the node-engine, which // runs as root (ADR 0266), and this tool is a reading for a person, not that verdict. import ( "fmt" "regexp" "strings" ) // Escalation is one account's answer. type Escalation struct { Account string `json:"account"` Root bool `json:"root_without_a_person"` Why string `json:"why"` } // rootGroups are groups that are root by another name. var rootGroups = map[string]string{"docker": "the docker group, which can mount the machine's root file system", "disk": "the disk group, which reads and writes the drives raw", "root": "the root group", "lxd": "the lxd group, which runs privileged containers", "incus-admin": "the incus-admin group, which runs privileged containers", "libvirt": "the libvirt group, which runs machines with the host's disks"} var accountShape = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`) // Escalations answers, for each account, whether it can become root here without a person. An account that // cannot be judged fails the whole answer: never a no. func (m *Machine) Escalations(accounts []string) ([]Escalation, error) { if len(accounts) == 0 { return nil, fmt.Errorf("name the accounts to judge") } var out []Escalation for _, a := range accounts { if !accountShape.MatchString(a) { return nil, fmt.Errorf("%q is not an account name", a) } e, err := m.escalation(a) if err != nil { return nil, fmt.Errorf("%s cannot be judged, so nothing is said of it: %v", a, err) } out = append(out, e) } return out, nil } func (m *Machine) escalation(account string) (Escalation, error) { e := Escalation{Account: account} if account == "root" { e.Root, e.Why = true, "it is root" return e, nil } id := m.Run(bg(), "id", "-nG", account) if id.Status != 0 || id.Err != "" { return e, fmt.Errorf("this machine does not know the account: %s", strings.TrimSpace(firstLine(id.Stderr+id.Err))) } for _, g := range strings.Fields(id.Stdout) { if why, ok := rootGroups[g]; ok { e.Root, e.Why = true, "a member of "+why return e, nil } } // sudo's own word, for that account: listing another account's rules needs root, which the runtime's // account escalates to as the mesh's acting tools do. r := m.Run(bg(), "sudo", "-n", "-l", "-U", account) listing := r.Stdout + r.Stderr if strings.Contains(listing, "is not allowed to run sudo") { e.Why = "no sudo rule names it, and no group it is in is root by another name" return e, nil } if r.Status != 0 || r.Err != "" { return e, fmt.Errorf("sudo could not be asked: %s", strings.TrimSpace(firstLine(r.Stderr+r.Err))) } rules := ParseList(r.Stdout, account) if !mayRun.MatchString(firstMayRun(r.Stdout)) { return e, fmt.Errorf("sudo's listing could not be read") } if len(rules.Rules) == 0 { e.Why = "sudo lists no rule for it, and no group it is in is root by another name" return e, nil } var lines []string for _, rule := range rules.Rules { lines = append(lines, rule.Line) } how := "a password the person typed is kept by sudo for minutes" for _, rule := range rules.Rules { if hasTag(rule.Tags, "NOPASSWD") { how = "with no password" break } } e.Root, e.Why = true, "sudo has a rule for it ("+strings.Join(lines, "; ")+"), "+how+ ": any rule counts, since no command is proven unable to reach root" return e, nil } // firstMayRun is the listing's "User … may run the following commands on …:" line, or "". func firstMayRun(out string) string { for _, l := range strings.Split(out, "\n") { if l = strings.TrimSpace(l); mayRun.MatchString(l) { return l } } return "" }