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
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:
@@ -0,0 +1,2 @@
|
||||
# The bundle built here (manifest build.artifacts[0].binary): never committed.
|
||||
/sudo-tools
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 " +
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"tools": [
|
||||
"sudo_rules",
|
||||
"sudo_check",
|
||||
"sudo_escalation",
|
||||
"sudo_drop_ins"
|
||||
],
|
||||
"resources": [
|
||||
|
||||
Reference in New Issue
Block a user