Merge remote-tracking branch 'origin/main' into feat/the-agent-account-is-declared
mesh/merge-gate pass: builds claude-code, claude-licence-manager, restic, sudo → ace, g14, novox, shanks; no bus step; every machine composes with the chan…
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the change's judges the pull requests after it merges; it…
mesh/delivery superseded: a newer head of the same pull request

This commit is contained in:
jochen
2026-10-09 23:37:04 +02:00
145 changed files with 14890 additions and 1433 deletions
+2
View File
@@ -0,0 +1,2 @@
# The bundle built here (manifest build.artifacts[0].binary): never committed.
/sudo-tools
+122
View File
@@ -0,0 +1,122 @@
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 ""
}
@@ -0,0 +1,70 @@
package main
import (
"strings"
"testing"
)
// novox/hq ADR 0259 §8, as the review of 2026-10-09 asked: any sudo rule is root without a person — NOPASSWD on
// a named command (systemctl, tee, bash, pacman) as much as on all of them, and a rule asking a password too —
// and what cannot be judged is an error, never a no.
func TestWhoCanBecomeRootWithoutAPerson(t *testing.T) {
var calls []call
listing := func(account, rule string) Ran {
return Ran{Stdout: "User " + account + " may run the following commands on anchor:\n " + rule + "\n"}
}
run := byLine(map[string]Ran{
"id -nG ops": {Stdout: "ops wheel\n"},
"sudo -n -l -U ops": listing("ops", "(ALL : ALL) NOPASSWD: ALL"),
"id -nG agents": {Stdout: "agents\n"},
"sudo -n -l -U agents": {Stdout: "User agents is not allowed to run sudo on anchor.\n", Status: 1},
"id -nG builder": {Stdout: "builder docker\n"},
"id -nG careful": {Stdout: "careful wheel\n"},
"sudo -n -l -U careful": listing("careful", "(ALL : ALL) ALL"),
"id -nG units": {Stdout: "units\n"},
"sudo -n -l -U units": listing("units", "(root) NOPASSWD: /usr/bin/systemctl"),
"id -nG writer": {Stdout: "writer\n"},
"sudo -n -l -U writer": listing("writer", "(root) NOPASSWD: /usr/bin/tee /etc/hosts"),
"id -nG packager": {Stdout: "packager\n"},
"sudo -n -l -U packager": listing("packager", "(root) NOPASSWD: /usr/bin/pacman -Syu"),
"id -nG asother": {Stdout: "asother\n"},
"sudo -n -l -U asother": listing("asother", "(ops) NOPASSWD: /bin/bash"),
"id -nG containers": {Stdout: "containers lxd\n"},
}, &calls)
got, err := machine(run, 1000).Escalations([]string{"ops", "agents", "builder", "careful", "units", "writer",
"packager", "asother", "containers", "root"})
if err != nil {
t.Fatal(err)
}
want := map[string]bool{"ops": true, "agents": false, "builder": true, "careful": true, "units": true, "writer": true,
"packager": true, "asother": true, "containers": true, "root": true}
for _, e := range got {
if e.Root != want[e.Account] || e.Why == "" {
t.Errorf("%s: root %v (%s), want %v", e.Account, e.Root, e.Why, want[e.Account])
}
}
if _, err := machine(run, 1000).Escalations([]string{"a b"}); err == nil || !strings.Contains(err.Error(), "not an account name") {
t.Errorf("an account name that is not one was asked: %v", err)
}
}
// What cannot be judged is never a no: an unknown account, sudo that cannot be asked, a listing that cannot be
// read — each fails the whole answer.
func TestWhatCannotBeJudgedIsAnError(t *testing.T) {
var calls []call
run := byLine(map[string]Ran{
"id -nG ghost": {Status: 1, Stderr: "id: 'ghost': no such user"},
"id -nG agents": {Stdout: "agents\n"},
"sudo -n -l -U agents": {Status: 1, Stderr: "sudo: a password is required"},
"id -nG garbled": {Stdout: "garbled\n"},
"sudo -n -l -U garbled": {Stdout: "something else entirely\n"},
"id -nG fine": {Stdout: "fine\n"},
"sudo -n -l -U fine": {Stdout: "User fine is not allowed to run sudo on anchor.\n", Status: 1},
}, &calls)
for _, a := range []string{"ghost", "agents", "garbled"} {
got, err := machine(run, 1000).Escalations([]string{"fine", a})
if err == nil || !strings.Contains(err.Error(), a+" cannot be judged") {
t.Errorf("%s: answered %+v (%v), want an error", a, got, err)
}
}
}
+21
View File
@@ -44,6 +44,27 @@ func tools(m *Machine) []stdio.Tool {
Input: schema(map[string]any{}),
Run: func(map[string]any) (any, error) { return m.CheckEscalation() },
},
{
Name: "sudo_escalation",
Description: "Can these accounts become root on this machine without a person: any sudo rule for it " +
"(with or without a password, for any command), or a group that is root by another name (docker, disk, " +
"root, lxd, incus-admin, libvirt). Each account with yes or no and why; an account that cannot be " +
"judged fails the answer, never a no. Read as the runtime's account: a reading for a person — the " +
"agent account's verdict is the node-engine's (novox/hq ADR 0259 §8, ADR 0266).",
Input: schema(map[string]any{"accounts": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "the accounts to judge"}}),
Run: func(a map[string]any) (any, error) {
var accounts []string
if list, ok := a["accounts"].([]any); ok {
for _, x := range list {
if s, ok := x.(string); ok {
accounts = append(accounts, s)
}
}
}
return m.Escalations(accounts)
},
},
{
Name: "sudo_drop_ins",
Description: "The files of /etc/sudoers.d with owner, mode and size, whether sudo reads each (a name with a dot " +
+1
View File
@@ -7,6 +7,7 @@
"tools": [
"sudo_rules",
"sudo_check",
"sudo_escalation",
"sudo_drop_ins"
],
"resources": [