Run the router as its own account, ask unlinking of the linked account, and take no approval where an agent can become root (hq ADR 0259 §7, §8)
mesh/delivery-group group feat/asks-answered-on-any-channel checking: 3 of 4 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate fail: builds messenger, sudo → ace, g14, novox, shanks; no bus step; a manifest the change touches fails the module check: modules/messenge…
mesh/repo-check fail: its merge-check.sh failed: long-running resources without health: 70

This commit is contained in:
jochen
2026-10-08 18:39:09 +02:00
parent 8f2be28dcb
commit 2989c03f90
20 changed files with 937 additions and 111 deletions
+89
View File
@@ -0,0 +1,89 @@
package main
// Who can become root here without a person (novox/hq ADR 0259 §8): the controller's self-check asks it of
// every machine where the router or a channel proving its sender runs, for the accounts agents run as and
// the account the machine's runtime runs commands as.
//
// Root without a person is any of: a sudo rule letting the account run every command as root with no
// password; membership of a group that is root by another name (docker, which mounts the host; disk, which
// reads it raw; wheel or sudo only where a rule grants them without a password, which the listing shows); or
// the account being root itself.
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"}
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.
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)
}
out = append(out, m.escalation(a))
}
return out, nil
}
func (m *Machine) escalation(account string) Escalation {
e := Escalation{Account: account}
if account == "root" {
e.Root, e.Why = true, "it is root"
return e
}
id := m.Run(bg(), "id", "-nG", account)
if id.Status != 0 || id.Err != "" {
e.Why = "no such account here"
return e
}
for _, g := range strings.Fields(id.Stdout) {
if why, ok := rootGroups[g]; ok {
e.Root, e.Why = true, "a member of "+why
return e
}
}
// 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)
if r.Status != 0 || r.Err != "" {
if strings.Contains(r.Stdout+r.Stderr, "not allowed to run sudo") {
e.Why = "no sudo rule names it"
return e
}
e.Why = "sudo could not be asked: " + strings.TrimSpace(firstLine(r.Stderr+r.Err))
return e
}
if strings.Contains(r.Stdout, "is not allowed to run sudo") {
e.Why = "no sudo rule names it"
return e
}
rules := ParseList(r.Stdout, account)
if rules.PasswordlessAll {
for _, rule := range rules.Rules {
if hasTag(rule.Tags, "NOPASSWD") && contains(rule.Commands, "ALL") && runsAsRoot(rule.RunAs) {
e.Root, e.Why = true, "sudo lets it run every command as root without a password ("+rule.Line+")"
return e
}
}
}
e.Why = "sudo asks it for a password, or lets it run named commands only"
return e
}
@@ -0,0 +1,34 @@
package main
import (
"strings"
"testing"
)
// novox/hq ADR 0259 §8: who can become root here without a person, as the self-check asks it.
func TestWhoCanBecomeRootWithoutAPerson(t *testing.T) {
var calls []call
run := byLine(map[string]Ran{
"id -nG ops": {Stdout: "ops wheel\n"},
"sudo -n -l -U ops": {Stdout: "User ops may run the following commands on anchor:\n (ALL : ALL) NOPASSWD: ALL\n"},
"id -nG agents": {Stdout: "agents\n"},
"sudo -n -l -U agents": {Stdout: "User agents is not allowed to run sudo on anchor.\n"},
"id -nG builder": {Stdout: "builder docker\n"},
"id -nG careful": {Stdout: "careful wheel\n"},
"sudo -n -l -U careful": {Stdout: "User careful may run the following commands on anchor:\n (ALL : ALL) ALL\n"},
"id -nG ghost": {Status: 1, Stderr: "id: 'ghost': no such user"},
}, &calls)
got, err := machine(run, 1000).Escalations([]string{"ops", "agents", "builder", "careful", "ghost", "root"})
if err != nil {
t.Fatal(err)
}
want := map[string]bool{"ops": true, "agents": false, "builder": true, "careful": false, "ghost": false, "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)
}
}
+20
View File
@@ -44,6 +44,26 @@ 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: a sudo rule running every " +
"command as root with no password, or a group that is root by another name (docker, disk). Each " +
"account with yes or no and why. Asked by the self-check where the router or a channel proving its " +
"sender runs (novox/hq ADR 0259).",
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 " +