7 Commits
Author SHA1 Message Date
jochen 9a85dffc11 Search for setuid programs in the background, and judge each polkit rule alone
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery ready: it delivers once merged
A search over a large disk ran inside the look, holding the judge's lock and
stalling the health statement; it now runs apart, serving its last result,
backing off after a failure, and saying not judged until it has one. And a
rule naming a user, or a comment, no longer hides another rule's
unconditional yes (hq ADR 0266).
2026-10-08 21:53:16 +02:00
jochen b1cb9542cc Refuse a placement at the machine's own directories, and use a found directory as found
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
A module's places setting can move a directory anywhere, and the engine
chowned whatever it was pointed at as root: a directory at /etc owned by the
agent account would hand it /etc (hq ADR 0266). Refuse the machine's roots,
the kernel's and the engine's trees, another account's home, and an archive
or written-into file below an agent's home; and leave the owner and mode of a
directory the mesh did not make.
2026-10-08 21:50:23 +02:00
jochen c74cf16b75 Judge an opened file's kind and links below a home, and more ways to root
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
A hard link swapped in for ~/.claude would have had root chown another
account's file; fstat on the descriptor now refuses a second link, a fifo or
an unexpected kind before anything is changed (hq ADR 0266, the re-review).
The judge also finds polkit rules for every account, a runtime's API on TCP,
setgid-to-root programs whoever owns them, setuid programs on every suid
filesystem, and unprotected links; the rest is listed as not judged.
2026-10-08 21:19:32 +02:00
jochen 76f3ca12b8 Refuse to take over a system account as one that never becomes root
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
The controller cannot read a machine's user database, so a name it is given
for the agents' account (hq ADR 0266) may be a service's own; taking it over
would hand agents that service's files. Refuse it, touching nothing.
2026-10-08 20:52:44 +02:00
jochen 5fc37b44a9 Follow no link below a home as root, and judge more ways to root
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
An account could replace ~/.claude with a link to /etc and have the
node-engine chown, chmod or write through it on the next apply. Below a
person's or an agent's home every component is now opened without
following a link, and a link refuses the resource in words.

The root judge also reads doas and polkit rules, the container runtimes'
sockets with their ACLs, ACLs on the secrets, and setuid-root programs no
package owns, in the C locale; Judged and NotJudged write down exactly
what it covers (hq ADR 0266 review).
2026-10-08 20:36:46 +02:00
jochen 8390fab5cb Judge whether an account declared never to become root can, so a machine's agents are known confined
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
An account an agent runs as (novox/hq ADR 0266) is read on every look for a
uid of 0, a group that grants root, any sudo rule and a mesh secret it can
read; any way found is unhealthy and said, a read that fails is unknown.
2026-10-08 18:30:10 +02:00
jochen e420f6587a Let a user say it never becomes root, so the agents' account can be judged
A declaration may now state root: never on a user (novox/hq ADR 0266), and a
health statement carries it under contract 3; the judging follows.
2026-10-08 18:26:31 +02:00
22 changed files with 2932 additions and 30 deletions
+14 -7
View File
@@ -1095,7 +1095,7 @@ func runLink(ctx context.Context, opts options) error {
// And which units its service managers say failed, and whose each is (novox/hq issue 315).
unitJudge = units.New(units.Exec{Run: apply.ExecRunner, System: builtFor})
// And whether an account a module put in a group has it where it runs (novox/hq ADR 0252).
accountJudge = accounts.New(accounts.Exec{Run: accounts.Runner(apply.ExecRunner)})
accountJudge = accounts.New(accounts.Exec{Run: accounts.CLocale, Cache: &accounts.SetuidCache{Every: time.Hour}})
}
// **Standing aside for a successor happens between reconciles and nowhere else** (novox/hq ADR
@@ -1559,9 +1559,10 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Queue, sa
// healthAsReported is a statement as the report and the event carry it.
func healthAsReported(st liveness.Statement, ns *network.Statement) *link.Health {
// ReadinessContract: this engine reads a resource's declared `health` and judges it (ADR 0240 Phase
// B), which is what tells the controller it may be sent the field.
h := &link.Health{Contract: link.ReadinessContract, At: st.At.UTC(), Resources: []link.ResourceHealth{}}
// RootContract: this engine reads a resource's declared `health` and judges it (ADR 0240 Phase B), and
// judges a user's declared `root` (novox/hq ADR 0266), which is what tells the controller it may be sent
// either field.
h := &link.Health{Contract: link.RootContract, At: st.At.UTC(), Resources: []link.ResourceHealth{}}
for _, r := range st.Resources {
h.Resources = append(h.Resources, link.ResourceHealth{Module: r.Module, Resource: r.ID, Kind: r.Kind,
Target: r.Target, State: r.State, Reason: r.Reason, Since: r.Since.UTC(), Streak: r.Streak,
@@ -1607,15 +1608,21 @@ func withUnits(h *link.Health, us *units.Statement) *link.Health {
// withAccounts adds to a statement every account a module put in a group (novox/hq ADR 0252), as that
// module's resource of kind account: healthy, or unhealthy with why — "relogin needed" when the account's
// running session began before it was put in the group. Nil says nothing of them.
// running session began before it was put in the group, or "can become root without a person" for one declared
// never to (novox/hq ADR 0266), which is also marked root never. Nil says nothing of them.
func withAccounts(h *link.Health, as *accounts.Statement) *link.Health {
if as == nil {
return h
}
for _, v := range as.Accounts {
h.Resources = append(h.Resources, link.ResourceHealth{Module: v.Module, Resource: v.ID,
rh := link.ResourceHealth{Module: v.Module, Resource: v.ID,
Kind: link.KindAccount, Target: v.Name, State: v.State, Reason: v.Reason, Since: v.Since.UTC(),
Streak: v.Streak, Account: v.Name})
Streak: v.Streak, Account: v.Name}
// Said, so the controller knows healthy here also means no way to root was found (novox/hq ADR 0266).
if v.Root {
rh.Root = declaration.RootNever
}
h.Resources = append(h.Resources, rh)
}
return h
}
+23
View File
@@ -75,3 +75,26 @@ func TestTheStatementNamesTheAccountsManager(t *testing.T) {
}
}
}
// An account declared never to become root (novox/hq ADR 0266) is said with root never, under the contract
// that tells the controller this engine judges it; an account judged for its groups alone is not.
func TestTheStatementSaysAnAccountJudgedForRoot(t *testing.T) {
at := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
as := &accounts.Statement{At: at, Accounts: []accounts.Verdict{
{Account: accounts.Account{Module: "claude-code", ID: "claude-code.agent", Name: "agent", Root: true},
State: accounts.Unhealthy, Reason: accounts.ReasonRoot + ": in the group docker, which grants root", Since: at},
{Account: accounts.Account{Module: "openrazer", ID: "openrazer.account", Name: "operator",
Groups: []string{"openrazer"}}, State: accounts.Healthy, Since: at},
}}
h := withAccounts(healthAsReported(liveness.Statement{At: at}, nil), as)
if h.Contract != link.RootContract || link.RootContract != 3 {
t.Fatalf("contract %d", h.Contract)
}
got := map[string]string{}
for _, r := range h.Resources {
got[r.Resource] = r.Root
}
if got["claude-code.agent"] != "never" || got["openrazer.account"] != "" {
t.Fatalf("root said: %v", got)
}
}
+1 -1
View File
@@ -5,11 +5,11 @@ go 1.26.0
require (
github.com/nats-io/nats.go v1.54.0
golang.org/x/crypto v0.57.0
golang.org/x/sys v0.48.0
)
require (
github.com/klauspost/compress v1.20.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/sys v0.48.0 // indirect
)
+76 -3
View File
@@ -21,6 +21,16 @@
// controller raises it as the module's condition on two statements in a row, and clears it on the first
// healthy one.
//
// **And an account declared never to become root without a person** (`root: never`, novox/hq ADR 0266) —
// the account agent sessions run as on a machine where they must not reach root by themselves — is judged
// on every look for whether it can anyway: by its uid, by a group that grants root to its members, by any
// sudo rule at all, or by reading a secret the mesh placed for another account (the tool runner's bus
// credential among them, which carries every co-hosted module's grants). Judged first, whether or not
// anybody is logged in: a way to root does not wait for a session. Any way found is unhealthy, its reason
// starting ReasonRoot and naming every way; a question the machine did not answer is unknown, never
// healthy. This is the fact the controller reads to tell a machine where no agent can become root from one
// where an agent can.
//
// **It reads; it never acts** (ADR 0240 rule 6): the user and group databases, the machine's own service
// manager's `show` of the account's manager unit, and that process's status file. It never starts the
// account's manager, which asking that manager itself would.
@@ -28,6 +38,7 @@ package accounts
import (
"context"
"errors"
"fmt"
"sort"
"strings"
@@ -47,6 +58,17 @@ const (
// ReasonRelogin starts the reason of an account whose running session lacks a group it is in.
const ReasonRelogin = "relogin needed"
// ReasonRoot starts the reason of an account declared never to become root without a person that can
// (novox/hq ADR 0266); every way found follows it.
const ReasonRoot = "can become root without a person"
// RootGroups are the groups whose members become root by membership alone, without a sudo rule naming
// them: the administrators' groups a distribution's own rules or polkit treat as root, the container
// runtime's socket (a container with the host's root mounted), the raw disks, and the virtualisation
// daemons that start a guest with the host's devices. A closed list, so what the judge calls "grants root"
// is written down and reviewable rather than guessed per machine.
var RootGroups = []string{"root", "wheel", "sudo", "admin", "docker", "disk", "lxd", "incus-admin", "libvirt"}
// Account is one user resource of a module that declares groups.
type Account struct {
Module string
@@ -54,6 +76,11 @@ type Account struct {
ID string
Name string
Groups []string
// Root is true for an account declared never to become root without a person (novox/hq ADR 0266).
Root bool
// Secrets are the paths of every secret the declaration places for another account, judged for
// whether this one can read them; only for a Root account.
Secrets []string
}
// Of is every account a declaration has a module put in a group. held is every resource an adopted
@@ -66,20 +93,39 @@ func Of(d *declaration.Declaration, held map[string]bool) []Account {
var out []Account
for _, r := range d.Resources {
u, ok := r.(*declaration.User)
if !ok || len(u.Groups) == 0 || held[u.ID] || u.Name == "" {
root := ok && u.Root == declaration.RootNever
if !ok || (len(u.Groups) == 0 && !root) || held[u.ID] || u.Name == "" {
continue
}
at := strings.LastIndex(u.ID, ".")
if at <= 0 || strings.HasPrefix(u.ID, declaration.AdoptionPrefix) {
continue
}
out = append(out, Account{Module: u.ID[:at], ID: u.ID, Name: u.Name,
Groups: append([]string(nil), u.Groups...)})
a := Account{Module: u.ID[:at], ID: u.ID, Name: u.Name, Groups: append([]string(nil), u.Groups...), Root: root}
if root {
a.Secrets = secretsOf(d, u.Name)
}
out = append(out, a)
}
sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID })
return out
}
// secretsOf is the path of every secret file the declaration places for anybody but account: sealed whole,
// or with sealed values in its content. One the account owns is its own to read.
func secretsOf(d *declaration.Declaration, account string) []string {
var out []string
for _, r := range d.Resources {
f, ok := r.(*declaration.File)
if !ok || f.Path == "" || (f.Sealed == "" && len(f.Secrets) == 0) || f.Owner == account {
continue
}
out = append(out, f.Path)
}
sort.Strings(out)
return out
}
// Session is what an account's own service manager holds.
type Session struct {
// Running is whether the manager runs.
@@ -94,6 +140,10 @@ type Reader interface {
InDatabase(ctx context.Context, account string) ([]string, error)
// Session is the account's own service manager: whether it runs, and which of groups it holds.
Session(ctx context.Context, account string, groups []string) (Session, error)
// Escalation is every way the account can become root without a person, in words — its uid, a group
// of RootGroups, a sudo rule, a secret of secrets it can read — and none when there is none. Reads
// only (novox/hq ADR 0266).
Escalation(ctx context.Context, account string, secrets []string) ([]string, error)
}
// Verdict is one account's state as a statement says it.
@@ -190,6 +240,29 @@ func (j *Judge) Look(ctx context.Context) (Statement, bool) {
// judge is one account's verdict on one look.
func (j *Judge) judge(ctx context.Context, a Account) (string, string) {
if a.Root {
ways, err := j.reader.Escalation(ctx, a.Name, a.Secrets)
var notJudged *SetuidNotJudged
switch {
case len(ways) > 0:
// A way found is a way found, whatever else could not be judged; said beside it.
reason := ReasonRoot + ": " + strings.Join(ways, "; ")
if errors.As(err, &notJudged) {
reason += "; " + firstLine(err.Error())
}
return Unhealthy, reason
case errors.As(err, &notJudged):
// The setuid search has no result yet: not judged, said as such — the same words on every look
// until it has one, never a search that stalls the look (novox/hq ADR 0266).
return Unknown, firstLine(err.Error())
case err != nil:
return Unknown, "whether the account can become root could not be read: " + firstLine(err.Error())
}
if len(a.Groups) == 0 {
// Declared for root alone: nothing of a session to read.
return Healthy, ""
}
}
in, err := j.reader.InDatabase(ctx, a.Name)
if err != nil {
return Unknown, "the user database could not be read: " + firstLine(err.Error())
+163 -1
View File
@@ -4,20 +4,182 @@ import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"syscall"
"time"
)
// Runner runs a command and answers what it printed — the apply's own (apply.ExecRunner).
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// Exec reads the machine through its command lines and the process table. **Reads only**: `id`, `getent`,
// the machine's own manager's `systemctl show`, and a status file under /proc (a test holds it).
// the machine's own manager's `systemctl show`, `sudo -l -U` (which lists, as root, what sudo would let an
// account run, and runs nothing), a status file under /proc and a secret's owner and mode (a test holds
// both).
type Exec struct {
Run Runner
// Proc is where the process table is; empty is /proc. A test points it at a directory of its own.
Proc string
// Stat is a file's owner and mode; nil is the machine's own (os.Stat). A test gives files of its own.
Stat func(path string) (FileMode, error)
// ReadFile, ReadDir and ACL read doas's and polkit's rules and a file's POSIX ACL; nil is the machine's.
ReadFile func(path string) ([]byte, error)
ReadDir func(path string) ([]fs.DirEntry, error)
ACL func(path string) ([]ACLEntry, error)
// Cache keeps the search for setuid programs between looks; nil searches on every look.
Cache *SetuidCache
// Now is the clock the cache is kept by; nil is the machine's.
Now func() time.Time
}
// FileMode is what decides whether an account reads a file: its owner, its group and its permission bits.
type FileMode struct {
UID, GID int
Perm fs.FileMode
}
// Escalation is every way account can become root without a person (novox/hq ADR 0266) that the judge
// looks for — Judged lists them, NotJudged what it does not: its uid, a group of RootGroups the user database
// lists it in, any sudo rule naming it or a group of it, any of secrets it can read by owner, group or other
// bits, and the ways of ways.go: doas, polkit, a runtime's socket, an ACL, a setuid program no package owns.
// sudo absent is no sudo rule; doas and polkit (pkexec's grants) are judged from their own rules. A secret not there yet is skipped: there is nothing to read.
// The parent directories are not walked, so a file the bits allow and a directory hides is still said:
// the judge errs toward saying a way that is not, never toward missing one that is.
func (e Exec) Escalation(ctx context.Context, account string, secrets []string) ([]string, error) {
var ways []string
uidOut, err := e.Run(ctx, "id", "-u", account)
if err != nil {
return nil, fmt.Errorf("the user database did not answer about %q: %w", account, err)
}
uid, err := strconv.Atoi(strings.TrimSpace(uidOut))
if err != nil {
return nil, fmt.Errorf("the user database gave %q as %q's number", strings.TrimSpace(uidOut), account)
}
if uid == 0 {
ways = append(ways, "its uid is 0")
}
names, err := e.InDatabase(ctx, account)
if err != nil {
return nil, err
}
for _, g := range names {
if slices.Contains(RootGroups, g) {
ways = append(ways, "in the group "+g+", which grants root")
}
}
listed, err := e.Run(ctx, "sudo", "-l", "-U", account)
rules, err := SudoRules(listed, err)
if err != nil {
return nil, err
}
if len(rules) > 0 {
ways = append(ways, "sudo grants it: "+strings.Join(rules, ", "))
}
gidsOut, err := e.Run(ctx, "id", "-G", account)
if err != nil {
return nil, fmt.Errorf("the user database did not answer about %q's groups: %w", account, err)
}
gids := map[int]bool{}
for _, f := range strings.Fields(gidsOut) {
if n, err := strconv.Atoi(f); err == nil {
gids[n] = true
}
}
if len(secrets) > 0 {
stat := e.Stat
if stat == nil {
stat = statOf
}
for _, path := range secrets {
m, err := stat(path)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, fmt.Errorf("the secret %s could not be read for its owner and mode: %w", path, err)
}
if Readable(m, uid, gids) {
ways = append(ways, "it can read the secret "+path)
}
}
}
more, err := e.moreWays(ctx, account, uid, names, gids, secrets)
var notJudged *SetuidNotJudged
if err != nil && !errors.As(err, &notJudged) {
return nil, err
}
// Every way found, and — when the setuid search has no result yet — that it is not judged.
return append(ways, more...), err
}
// Readable is whether an account of uid, in the groups gids, reads a file of m by its permission bits, as
// the kernel decides it: the owner's bits for the owner (root reads everything), the group's for a member,
// the others' for anybody else.
func Readable(m FileMode, uid int, gids map[int]bool) bool {
switch {
case uid == 0:
return true
case m.UID == uid:
return m.Perm&0o400 != 0
case gids[m.GID]:
return m.Perm&0o040 != 0
default:
return m.Perm&0o004 != 0
}
}
// SudoRules is the rules `sudo -l -U <account>` lists, from what it printed and how it ended: none when
// the account "is not allowed to run sudo" or sudo is not on the machine; every indented line after "may
// run the following commands" otherwise. Any rule counts — the decision is no sudo for the account at
// all, so a rule that asks for a password the account was never given is still a rule somebody can give
// it one for. Output that says neither is an error: unread is never none.
func SudoRules(out string, err error) ([]string, error) {
if err != nil && (errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist)) {
return nil, nil
}
text := out
if err != nil {
text += "\n" + err.Error()
}
if strings.Contains(text, "is not allowed to run sudo") {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("sudo did not list the account's rules: %w", err)
}
var rules []string
listing := false
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "may run the following commands") {
listing = true
continue
}
if listing && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) && strings.TrimSpace(line) != "" {
rules = append(rules, strings.TrimSpace(line))
}
}
if !listing {
return nil, fmt.Errorf("sudo listed neither rules nor a refusal: %q", firstLine(out))
}
return rules, nil
}
func statOf(path string) (FileMode, error) {
info, err := os.Stat(path)
if err != nil {
return FileMode{}, err
}
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return FileMode{}, fmt.Errorf("%s has no owner this platform reports", path)
}
return FileMode{UID: int(st.Uid), GID: int(st.Gid), Perm: info.Mode().Perm()}, nil
}
// InDatabase is `id -nG`: every group the user database lists the account in.
+268
View File
@@ -0,0 +1,268 @@
package accounts
import (
"context"
"errors"
"fmt"
"io/fs"
"os/exec"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
)
// An account declared never to become root without a person (novox/hq ADR 0266, "how it is checked"): each
// way to root is found and said, none is healthy whether or not anybody is logged in, an unanswered question
// is unknown, and the judge only reads.
// agentMachine is a fake machine for the escalation question: the account's uid, its groups by name and
// number, what sudo lists, and the secrets' owners and modes.
type agentMachine struct {
uid string
groups string
gids string
sudo string
sudoErr error
files map[string]FileMode
failsID bool
asked []string
// texts are files' contents (doas's, polkit's); dirs a directory's files by name; acls a file's ACL;
// setuid what find prints, and packaged the paths a package owns.
texts map[string]string
dirs map[string][]string
acls map[string][]ACLEntry
setuid string
findErr error
packaged map[string]bool
// proc is /proc's files, aSafeProc unless a test changes them; findArgs sees find's arguments.
proc map[string]string
findArgs func([]string)
}
func (m *agentMachine) readFile(path string) ([]byte, error) {
if t, ok := m.texts[path]; ok {
return []byte(t), nil
}
if t, ok := m.proc[path]; ok {
return []byte(t), nil
}
return nil, fs.ErrNotExist
}
// aSafeProc is what a machine with nothing to say has in /proc: links protected, nothing on the runtimes'
// ports, one root filesystem.
func aSafeProc() map[string]string {
return map[string]string{
"/proc/sys/fs/protected_hardlinks": "1\n",
"/proc/sys/fs/protected_symlinks": "1\n",
"/proc/net/tcp": " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n" +
" 0: 0100007F:1092 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1\n",
"/proc/mounts": "/dev/sda2 / ext4 rw,relatime 0 0\nproc /proc proc rw,nosuid 0 0\ntmpfs /tmp tmpfs rw,nosuid 0 0\n",
}
}
func (m *agentMachine) readDir(path string) ([]fs.DirEntry, error) {
names, ok := m.dirs[path]
if !ok {
return nil, fs.ErrNotExist
}
var out []fs.DirEntry
for _, n := range names {
out = append(out, fakeEntry(n))
}
return out, nil
}
type fakeEntry string
func (f fakeEntry) Name() string { return string(f) }
func (f fakeEntry) IsDir() bool { return false }
func (f fakeEntry) Type() fs.FileMode { return 0 }
func (f fakeEntry) Info() (fs.FileInfo, error) { return nil, fs.ErrNotExist }
func (m *agentMachine) acl(path string) ([]ACLEntry, error) {
if _, ok := m.files[path]; !ok {
return nil, fs.ErrNotExist
}
return m.acls[path], nil
}
func (m *agentMachine) run(_ context.Context, name string, args ...string) (string, error) {
line := name + " " + strings.Join(args, " ")
m.asked = append(m.asked, line)
switch {
case m.failsID && name == "id":
return "", errors.New("id exited 1: no such user")
case line == "id -u agent":
return m.uid + "\n", nil
case line == "id -nG agent":
return m.groups + "\n", nil
case line == "id -G agent":
return m.gids + "\n", nil
case line == "sudo -l -U agent":
return m.sudo, m.sudoErr
case name == "find":
if m.findArgs != nil {
m.findArgs(args)
}
return m.setuid, m.findErr
case name == "pacman" && len(args) == 2 && args[0] == "-Qqo":
if m.packaged[args[1]] {
return "somepackage\n", nil
}
return "", errors.New("pacman exited 1: error: No package owns " + args[1])
}
return "", errors.New("not a command the judge may run: " + line)
}
func (m *agentMachine) stat(path string) (FileMode, error) {
if f, ok := m.files[path]; ok {
return f, nil
}
return FileMode{}, fs.ErrNotExist
}
const notAllowed = "User agent is not allowed to run sudo on box.\n"
var agent = Account{Module: "claude-code", ID: "claude-code.agent", Name: "agent", Root: true,
Secrets: []string{"/var/lib/mesh/node-tools/broker"}}
func clean() *agentMachine {
return &agentMachine{uid: "1600", groups: "agent", gids: "1600", sudo: notAllowed,
files: map[string]FileMode{"/var/lib/mesh/node-tools/broker": {UID: 1500, GID: 1500, Perm: 0o600}},
proc: aSafeProc()}
}
func lookAgent(t *testing.T, m *agentMachine) Verdict {
t.Helper()
j := New(Exec{Run: m.run, Stat: m.stat, ReadFile: m.readFile, ReadDir: m.readDir, ACL: m.acl})
j.Set([]Account{agent})
st, _ := j.Look(t.Context())
if len(st.Accounts) != 1 {
t.Fatalf("the statement: %+v", st)
}
for _, a := range m.asked {
if !strings.HasPrefix(a, "id ") && a != "sudo -l -U agent" && !strings.HasPrefix(a, "find / ") &&
!strings.HasPrefix(a, "pacman -Qqo ") {
t.Errorf("the judge asked something that is not a read: %q", a)
}
}
return st.Accounts[0]
}
func TestAnAgentAccountWithNoWayToRootIsHealthyWithNobodyLoggedIn(t *testing.T) {
if v := lookAgent(t, clean()); v.State != Healthy || v.Reason != "" || !v.Root {
t.Fatalf("no way to root: %+v", v)
}
}
func TestEachWayToRootIsSaid(t *testing.T) {
for _, c := range []struct {
name string
set func(*agentMachine)
said string
}{
{"uid 0", func(m *agentMachine) { m.uid = "0" }, "its uid is 0"},
{"docker", func(m *agentMachine) { m.groups = "agent docker" }, "in the group docker, which grants root"},
{"wheel", func(m *agentMachine) { m.groups = "agent wheel" }, "in the group wheel, which grants root"},
{"sudo", func(m *agentMachine) {
m.sudo = "Matching Defaults entries for agent on box:\n env_reset\n\nUser agent may run the following commands on box:\n (ALL) NOPASSWD: ALL\n"
}, "sudo grants it: (ALL) NOPASSWD: ALL"},
{"secret by others", func(m *agentMachine) {
m.files["/var/lib/mesh/node-tools/broker"] = FileMode{UID: 1500, GID: 1500, Perm: 0o644}
}, "it can read the secret /var/lib/mesh/node-tools/broker"},
{"secret by group", func(m *agentMachine) {
m.gids = "1600 1500"
m.files["/var/lib/mesh/node-tools/broker"] = FileMode{UID: 1500, GID: 1500, Perm: 0o640}
}, "it can read the secret /var/lib/mesh/node-tools/broker"},
} {
t.Run(c.name, func(t *testing.T) {
m := clean()
c.set(m)
v := lookAgent(t, m)
if v.State != Unhealthy || !strings.HasPrefix(v.Reason, ReasonRoot+": ") || !strings.Contains(v.Reason, c.said) {
t.Fatalf("want %q said: %+v", c.said, v)
}
})
}
}
func TestEveryWayIsSaidAtOnce(t *testing.T) {
m := clean()
m.groups = "agent docker"
m.sudo = "User agent may run the following commands on box:\n (ALL) ALL\n"
v := lookAgent(t, m)
if !strings.Contains(v.Reason, "docker") || !strings.Contains(v.Reason, "(ALL) ALL") {
t.Fatalf("both ways: %+v", v)
}
}
func TestAnUnansweredQuestionIsUnknownNeverHealthy(t *testing.T) {
m := clean()
m.failsID = true
if v := lookAgent(t, m); v.State != Unknown {
t.Fatalf("an unread database: %+v", v)
}
m = clean()
m.sudo = "something sudo never says\n"
if v := lookAgent(t, m); v.State != Unknown {
t.Fatalf("an unread sudo: %+v", v)
}
}
func TestASecretNotThereYetIsNoWay(t *testing.T) {
m := clean()
delete(m.files, "/var/lib/mesh/node-tools/broker")
if v := lookAgent(t, m); v.State != Healthy {
t.Fatalf("no secret placed: %+v", v)
}
}
func TestSudoRules(t *testing.T) {
none := []struct {
out string
err error
}{
{notAllowed, nil},
{notAllowed, errors.New("sudo exited 1: ")},
{"", fmt.Errorf("sudo: %w", exec.ErrNotFound)},
}
for _, c := range none {
if rules, err := SudoRules(c.out, c.err); err != nil || len(rules) != 0 {
t.Errorf("%q, %v: rules %v, err %v", c.out, c.err, rules, err)
}
}
rules, err := SudoRules("Matching Defaults entries for agent on box:\n env_reset\n\nUser agent may run the following commands on box:\n (ALL) /usr/bin/systemctl\n (root) NOPASSWD: /usr/bin/true\n", nil)
if err != nil || len(rules) != 2 || rules[0] != "(ALL) /usr/bin/systemctl" {
t.Fatalf("two rules: %v, %v", rules, err)
}
if _, err := SudoRules("", errors.New("sudo exited 1: sudo: unable to resolve host")); err == nil {
t.Error("a failing sudo that said neither was read as no rules")
}
}
func TestReadable(t *testing.T) {
m := FileMode{UID: 1500, GID: 1500, Perm: 0o600}
if Readable(m, 1600, map[int]bool{1600: true}) || !Readable(m, 1500, nil) || !Readable(m, 0, nil) {
t.Fatal("owner bits")
}
}
func TestOfJudgesARootNeverAccountWithNoGroupsAndItsSecrets(t *testing.T) {
d, err := declaration.ParseTrusted([]byte(`{"declaration":1,"resources":[
{"id":"claude-code.agent","type":"user","name":"agent","root":"never"},
{"id":"zsh.login","type":"user","name":"operator","shell":"/bin/zsh"},
{"id":"node-tools.need-broker","type":"file","path":"/var/lib/mesh/broker","sealed":"x","owner":"operator"},
{"id":"claude-code.own","type":"file","path":"/home/agent/own","sealed":"x","owner":"agent"},
{"id":"claude-code.plain","type":"file","path":"/etc/plain","content":"x"}
]}`))
if err != nil {
t.Fatal(err)
}
got := Of(d, nil)
if len(got) != 1 || got[0].ID != "claude-code.agent" || !got[0].Root ||
len(got[0].Secrets) != 1 || got[0].Secrets[0] != "/var/lib/mesh/broker" {
t.Fatalf("judged: %+v", got)
}
}
+746
View File
@@ -0,0 +1,746 @@
package accounts
// The ways to root the judge looks for beyond uid, groups and sudo (novox/hq ADR 0266, the review of
// 2026-10-08), and the ones it does not. Judged and NotJudged are the one written list: the record quotes it,
// and a way added here is a line added there.
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"golang.org/x/sys/unix"
)
// Judged is every way to root the judge looks for, in the words its reasons use.
var Judged = []string{
"its uid is 0",
"membership of a group that grants root by membership: " + strings.Join(RootGroups, ", "),
"any sudo rule for it or one of its groups, as `sudo -l -U` lists it (with or without a password)",
"any doas rule permitting it or one of its groups (/etc/doas.conf, /etc/opendoas.conf)",
"any polkit rule naming it or one of its groups (/etc/polkit-1/rules.d, /usr/share/polkit-1/rules.d, " +
"/etc/polkit-1/localauthority), which is how pkexec and systemd's own actions are granted",
"a polkit rule that grants every account: a rule of a .rules file that answers polkit.Result.YES and names " +
"no user and no group, or a .pkla section whose Identity is unix-user:* or unix-group:* with a Result of " +
"yes — each rule and section judged on its own, comments taken out",
"write access to a container runtime's socket (docker, podman, containerd), by owner, group, other or " +
"POSIX ACL",
"a container runtime's API listening on TCP port 2375 or 2376 (/proc/net/tcp, /proc/net/tcp6), which any " +
"local account reaches",
"read access to a secret the mesh placed for another account, by owner, group, other or POSIX ACL",
"a program that no installed package owns and is setuid with owner root, or setgid with group root, " +
"whoever owns it, on every local filesystem mounted without nosuid (container and image layers, " +
"network and pseudo filesystems excepted) — searched in the background at most hourly, its last result " +
"served with when it was found; until a search has finished, the verdict says not judged",
"hard links or symbolic links to other accounts' files left unprotected by the kernel " +
"(fs.protected_hardlinks or fs.protected_symlinks is 0)",
}
// NotJudged is what the judge does not look for: each is a way to root it would miss.
var NotJudged = []string{
"a root-run unit, timer, cron entry or script the account can write",
"a directory on root's PATH, or in /etc/profile.d, the account can write",
"root's or the operator's ssh keys or authorized_keys readable or writable by it",
"a file root writes or reads, in a directory the account owns, other than below its home (the node-engine " +
"refuses links there)",
"file capabilities (setcap) on a program",
"a setuid program a package installed that has a flaw of its own",
"a terminal the account shares with a root process (TIOCSTI, sudo without use_pty)",
"a polkit rule that grants every account by logic the text does not show (a JavaScript condition " +
"other than a named user or group), or every account with an active local session",
"a container runtime's API on a TCP port other than 2375 and 2376, or on a unix socket not named here",
"a setuid program on a filesystem not judged: a container or image layer (overlay, squashfs), a network " +
"or FUSE filesystem",
}
// RuntimeSockets are the container runtimes' sockets: write access to one runs a container as root.
var RuntimeSockets = []string{"/run/docker.sock", "/var/run/docker.sock", "/run/podman/podman.sock",
"/run/containerd/containerd.sock"}
// DoasConfigs and PolkitDirs are where those grants live.
var (
DoasConfigs = []string{"/etc/doas.conf", "/etc/opendoas.conf"}
PolkitDirs = []string{"/etc/polkit-1/rules.d", "/usr/share/polkit-1/rules.d", "/etc/polkit-1/localauthority"}
)
// ACLEntry is one named entry of a POSIX ACL: a user's or a group's, and whether it grants read and write
// once the ACL's mask is applied.
type ACLEntry struct {
User bool
ID int
Read, Write bool
}
// The tags and bits of the kernel's ACL xattr.
const (
aclUser = 0x02
aclGroup = 0x08
aclMask = 0x10
)
// ParseACL reads a system.posix_acl_access value: a version, then entries of tag, permission and id; the
// named users' and groups' entries are answered with the mask applied.
func ParseACL(raw []byte) ([]ACLEntry, error) {
if len(raw) < 4 || (len(raw)-4)%8 != 0 {
return nil, fmt.Errorf("an ACL of %d bytes is not one", len(raw))
}
type entry struct {
tag, perm uint16
id uint32
}
var es []entry
mask := uint16(7)
for at := 4; at < len(raw); at += 8 {
e := entry{binary.LittleEndian.Uint16(raw[at:]), binary.LittleEndian.Uint16(raw[at+2:]),
binary.LittleEndian.Uint32(raw[at+4:])}
if e.tag == aclMask {
mask = e.perm
}
es = append(es, e)
}
var out []ACLEntry
for _, e := range es {
if e.tag != aclUser && e.tag != aclGroup {
continue
}
p := e.perm & mask
out = append(out, ACLEntry{User: e.tag == aclUser, ID: int(e.id), Read: p&4 != 0, Write: p&2 != 0})
}
return out, nil
}
// aclOf is a file's ACL from the machine: none when it has none.
func aclOf(path string) ([]ACLEntry, error) {
buf := make([]byte, 1024)
n, err := unix.Getxattr(path, "system.posix_acl_access", buf)
if errors.Is(err, unix.ENODATA) || errors.Is(err, unix.EOPNOTSUPP) {
return nil, nil
}
if err != nil {
return nil, &os.PathError{Op: "getxattr", Path: path, Err: err}
}
return ParseACL(buf[:n])
}
// grantsByACL says whether an ACL entry gives the account read (or write).
func grantsByACL(acl []ACLEntry, uid int, gids map[int]bool, write bool) bool {
for _, e := range acl {
if (e.User && e.ID == uid) || (!e.User && gids[e.ID]) {
if (write && e.Write) || (!write && e.Read) {
return true
}
}
}
return false
}
// Writable is Readable's twin for the write bits.
func Writable(m FileMode, uid int, gids map[int]bool) bool {
switch {
case uid == 0:
return true
case m.UID == uid:
return m.Perm&0o200 != 0
case gids[m.GID]:
return m.Perm&0o020 != 0
default:
return m.Perm&0o002 != 0
}
}
// DoasRules is every line of a doas configuration that permits the account or one of its groups.
func DoasRules(conf string, account string, groups []string) []string {
var out []string
for _, line := range strings.Split(conf, "\n") {
if i := strings.IndexByte(line, '#'); i >= 0 {
line = line[:i]
}
f := strings.Fields(line)
if len(f) < 2 || f[0] != "permit" {
continue
}
i := 1
for i < len(f) && (f[i] == "nopass" || f[i] == "persist" || f[i] == "keepenv" || f[i] == "nolog" ||
f[i] == "setenv" || strings.HasPrefix(f[i], "{")) {
if strings.HasPrefix(f[i], "{") {
for i < len(f) && !strings.HasSuffix(f[i], "}") {
i++
}
}
i++
}
if i >= len(f) {
continue
}
who := f[i]
if who == account || (strings.HasPrefix(who, ":") && contains(groups, who[1:])) {
out = append(out, strings.TrimSpace(line))
}
}
return out
}
// PolkitNames says whether a polkit rule or authority file names the account or one of its groups.
func PolkitNames(text, account string, groups []string) bool {
needles := []string{"unix-user:" + account, `"` + account + `"`, `'` + account + `'`}
for _, g := range groups {
needles = append(needles, "unix-group:"+g, `isInGroup("`+g+`")`, `isInGroup('`+g+`')`)
}
for _, n := range needles {
if strings.Contains(text, n) {
return true
}
}
return false
}
// PolkitGrantsEveryone says whether a polkit rule or authority file grants every account, naming none: a .rules
// file one of whose rules answers polkit.Result.YES with no condition on a user or a group, or a .pkla one of
// whose sections has an Identity of every user or every group with a Result of yes.
//
// **Each rule is judged on its own, comments taken out first** (the third review of 2026-10-08): a rule naming a
// user elsewhere in the file, or a user named only in a comment, must not hide another rule's unconditional
// yes. A rule's text is read, not run: a condition the text does not show is listed under NotJudged.
func PolkitGrantsEveryone(path, text string) bool {
if strings.HasSuffix(path, ".pkla") {
var kept []string
for _, line := range strings.Split(text, "\n") {
if t := strings.TrimSpace(line); strings.HasPrefix(t, "#") || strings.HasPrefix(t, ";") {
continue
}
kept = append(kept, line)
}
for _, section := range strings.Split(strings.Join(kept, "\n"), "[") {
var every, yes bool
for _, line := range strings.Split(section, "\n") {
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok {
continue
}
v = strings.TrimSpace(v)
switch strings.TrimSpace(k) {
case "Identity":
for _, id := range strings.Split(v, ";") {
if id = strings.TrimSpace(id); id == "unix-user:*" || id == "unix-group:*" {
every = true
}
}
case "ResultAny", "ResultActive", "ResultInactive":
if v == "yes" {
yes = true
}
}
}
if every && yes {
return true
}
}
return false
}
for _, rule := range polkitRules(stripJSComments(text)) {
if !strings.Contains(rule, "polkit.Result.YES") {
continue
}
// A condition on a user or a group names whom it grants; one on an active local session grants only an
// account with a seat, which an agent started through sudo has not (listed under NotJudged).
conditioned := false
for _, condition := range []string{".user", "isInGroup", "unix-user:", "unix-group:", ".active", ".local"} {
if strings.Contains(rule, condition) {
conditioned = true
break
}
}
if !conditioned {
return true
}
}
return false
}
// polkitRules is each rule of a .rules file: the text from one polkit.addRule to the next. Text before the first
// is no rule.
func polkitRules(text string) []string {
parts := strings.Split(text, "addRule(")
if len(parts) < 2 {
return nil
}
return parts[1:]
}
// stripJSComments takes /* … */ and // … comments out of a rule file, leaving what is in strings alone.
func stripJSComments(text string) string {
var b strings.Builder
var quote byte
for i := 0; i < len(text); i++ {
c := text[i]
switch {
case quote != 0:
b.WriteByte(c)
if c == '\\' && i+1 < len(text) {
i++
b.WriteByte(text[i])
} else if c == quote {
quote = 0
}
case c == '"' || c == '\'' || c == '`':
quote = c
b.WriteByte(c)
case c == '/' && i+1 < len(text) && text[i+1] == '*':
end := strings.Index(text[i+2:], "*/")
if end < 0 {
return b.String()
}
i += end + 3
case c == '/' && i+1 < len(text) && text[i+1] == '/':
end := strings.IndexByte(text[i:], '\n')
if end < 0 {
return b.String()
}
i += end - 1
default:
b.WriteByte(c)
}
}
return b.String()
}
// RuntimeAPIPorts are the ports a container runtime's API listens on by convention: 2375 plain, 2376 TLS.
var RuntimeAPIPorts = []int{2375, 2376}
// ListeningPorts is every local TCP port in the listening state, from /proc/net/tcp's or tcp6's text.
func ListeningPorts(text string) []int {
var out []int
for i, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if i == 0 || len(f) < 4 || f[3] != "0A" {
continue
}
_, port, ok := strings.Cut(f[1], ":")
if !ok {
continue
}
var n int
if _, err := fmt.Sscanf(port, "%X", &n); err == nil {
out = append(out, n)
}
}
return out
}
// pseudoFS are filesystem types no setuid program is installed on, or that are a container's or an image's
// own layers: not searched.
var pseudoFS = map[string]bool{"proc": true, "sysfs": true, "devtmpfs": true, "devpts": true, "tmpfs": true,
"cgroup": true, "cgroup2": true, "securityfs": true, "pstore": true, "bpf": true, "debugfs": true,
"tracefs": true, "mqueue": true, "hugetlbfs": true, "configfs": true, "fusectl": true, "autofs": true,
"binfmt_misc": true, "efivarfs": true, "overlay": true, "squashfs": true, "nsfs": true, "ramfs": true,
"nfs": true, "nfs4": true, "cifs": true, "smb3": true, "9p": true, "virtiofs": true}
// SuidMounts is every local filesystem a setuid program could run from: the mount points of /proc/mounts whose
// type is not pseudo, network or a container's layer, and that are not mounted nosuid. Root first.
func SuidMounts(text string) []string {
seen := map[string]bool{}
var out []string
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) < 4 || pseudoFS[f[2]] || strings.HasPrefix(f[2], "fuse") {
continue
}
at := strings.ReplaceAll(strings.ReplaceAll(f[1], `\040`, " "), `\011`, "\t")
if strings.HasPrefix(at, "/var/lib/docker") || strings.HasPrefix(at, "/var/lib/containers") ||
strings.HasPrefix(at, "/proc") || strings.HasPrefix(at, "/sys") {
continue
}
if contains(strings.Split(f[3], ","), "nosuid") || seen[at] {
continue
}
seen[at] = true
out = append(out, at)
}
sort.Slice(out, func(i, j int) bool { return out[i] == "/" || (out[j] != "/" && out[i] < out[j]) })
return out
}
func contains(xs []string, s string) bool {
for _, x := range xs {
if x == s {
return true
}
}
return false
}
// SetuidCache keeps the search for setuid programs, which walks every local filesystem, and runs it in the
// background (novox/hq ADR 0266, the third review): a disk too large to search in time must never stall a
// look, the health statement or the apply report. A look reads the last result the search left, and starts a
// new search when the last one is older than Every; a search that failed or did not finish is tried again
// after a back-off that doubles, from Every up to MaxBackoff.
type SetuidCache struct {
Every time.Duration
mu sync.Mutex
wg sync.WaitGroup
at time.Time // when the last search that finished finished
found []string
running bool
startedAt time.Time
failedAt time.Time
failed error
backoff time.Duration
}
// SearchBound is how long one search may run in the background; MaxBackoff the longest wait after failures.
const (
SearchBound = 15 * time.Minute
MaxBackoff = 6 * time.Hour
)
// SetuidNotJudged is the search's answer when it has no result to give: never run to its end yet, or failed
// every time so far. The judge says the way is not judged, never that there is none.
type SetuidNotJudged struct {
// Pending is true while the first search runs; false after one failed or did not finish.
Pending bool
Since time.Time
Err error
}
func (e *SetuidNotJudged) Error() string {
if e.Pending {
return fmt.Sprintf("%s: the search for setuid programs, started at %s, has not finished yet", ReasonPending,
e.Since.Local().Format("15:04"))
}
return fmt.Sprintf("%s: the search for setuid programs did not finish (last tried at %s: %v); it is tried "+
"again later", ReasonIncomplete, e.Since.Local().Format("15:04"), e.Err)
}
// The reasons of a root verdict the setuid search could not answer.
const (
ReasonPending = "not judged yet (search running)"
ReasonIncomplete = "not judged (search incomplete)"
)
// Look answers the last result and when its search finished, starting a search in the background when one is
// due; it never waits for one. Nil c searches in place, for a caller that wants to.
func (c *SetuidCache) Look(now time.Time, search func(ctx context.Context) ([]string, error)) ([]string, time.Time, error) {
if c == nil {
ctx, cancel := context.WithTimeout(context.Background(), SearchBound)
defer cancel()
found, err := search(ctx)
return found, now, err
}
c.mu.Lock()
defer c.mu.Unlock()
due := !c.running && (c.at.IsZero() || now.Sub(c.at) >= c.Every) &&
(c.failed == nil || now.Sub(c.failedAt) >= c.backoff)
if due {
c.running, c.startedAt = true, now
c.wg.Add(1)
go func() {
defer c.wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), SearchBound)
found, err := search(ctx)
cancel()
c.mu.Lock()
defer c.mu.Unlock()
c.running = false
done := time.Now()
if err != nil {
c.failed, c.failedAt = err, done
c.backoff = min(max(2*c.backoff, c.Every), MaxBackoff)
return
}
c.found, c.at, c.failed, c.backoff = found, done, nil, 0
}()
}
switch {
case !c.at.IsZero():
return c.found, c.at, nil
case c.failed != nil:
return nil, time.Time{}, &SetuidNotJudged{Since: c.failedAt, Err: c.failed}
default:
return nil, time.Time{}, &SetuidNotJudged{Pending: true, Since: c.startedAt}
}
}
// Wait is for a test: until the search running, if any, has finished.
func (c *SetuidCache) Wait() { c.wg.Wait() }
// setuidSearch is every setuid- or setgid-root regular file on the root filesystem that no installed package
// owns, found with find and asked of the package manager. Bounded: a search that does not finish is an
// unanswered question, never "none".
func (e Exec) setuidSearch(ctx context.Context, mounts []string) ([]string, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
// Each mount point a starting point of its own, -xdev keeping each to its own filesystem: every local
// filesystem mounted without nosuid is searched once (the re-review of 2026-10-08), and a program setgid to
// root's group counts whoever owns it.
args := append(append([]string{}, mounts...), "-xdev", "(", "-path", "/proc", "-o", "-path", "/sys", "-o",
"-path", "/var/lib/docker", "-o", "-path", "/var/lib/containers", ")", "-prune", "-o",
"-type", "f", "(", "(", "-user", "root", "-perm", "-4000", ")", "-o", "(", "-group", "root", "-perm",
"-2000", ")", ")", "-print")
out, err := e.Run(ctx, "find", args...)
if ctx.Err() != nil {
return nil, fmt.Errorf("the search for setuid programs did not finish within two minutes")
}
if err != nil && strings.TrimSpace(out) == "" {
return nil, fmt.Errorf("the search for setuid programs did not finish: %w", err)
}
var paths []string
for _, l := range strings.Split(out, "\n") {
if l = strings.TrimSpace(l); l != "" {
paths = append(paths, l)
}
}
sort.Strings(paths)
var unowned []string
for _, p := range paths {
owned, err := e.packaged(ctx, p)
if err != nil {
return nil, err
}
if !owned {
unowned = append(unowned, p)
}
}
return unowned, nil
}
// packaged says whether an installed package owns a path: pacman's or apk's answer, never a guess.
func (e Exec) packaged(ctx context.Context, path string) (bool, error) {
out, err := e.Run(ctx, "pacman", "-Qqo", path)
if err == nil {
return strings.TrimSpace(out) != "", nil
}
if strings.Contains(out+err.Error(), "No package owns") || strings.Contains(err.Error(), "exited 1") {
return false, nil
}
if !errors.Is(err, exec.ErrNotFound) && !errors.Is(err, fs.ErrNotExist) {
return false, fmt.Errorf("pacman could not say who owns %s: %w", path, err)
}
out, err = e.Run(ctx, "apk", "info", "-W", path)
if err != nil {
return false, fmt.Errorf("no package manager could say who owns %s: %w", path, err)
}
return strings.Contains(out, " is owned by "), nil
}
// moreWays is every way beyond uid, groups and sudo; an unanswered question is an error, never none.
func (e Exec) moreWays(ctx context.Context, account string, uid int, groups []string, gids map[int]bool,
secrets []string) ([]string, error) {
read := e.ReadFile
if read == nil {
read = os.ReadFile
}
readDir := e.ReadDir
if readDir == nil {
readDir = os.ReadDir
}
acl := e.ACL
if acl == nil {
acl = aclOf
}
stat := e.Stat
if stat == nil {
stat = statOf
}
var ways []string
// doas.
for _, conf := range DoasConfigs {
raw, err := read(conf)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, fmt.Errorf("doas's rules in %s could not be read: %w", conf, err)
}
for _, r := range DoasRules(string(raw), account, groups) {
ways = append(ways, "doas permits it: "+r)
}
}
// polkit.
for _, dir := range PolkitDirs {
var named, everyone []string
err := walkFiles(readDir, dir, func(path string) error {
raw, err := read(path)
if err != nil {
return err
}
if PolkitNames(string(raw), account, groups) {
named = append(named, path)
} else if PolkitGrantsEveryone(path, string(raw)) {
everyone = append(everyone, path)
}
return nil
})
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("polkit's rules in %s could not be read: %w", dir, err)
}
for _, p := range named {
ways = append(ways, "a polkit rule names it or a group of it: "+p)
}
for _, p := range everyone {
ways = append(ways, "a polkit rule grants every account: "+p)
}
}
// The container runtimes' sockets.
seen := map[string]bool{}
for _, s := range RuntimeSockets {
// Two names of one socket are one socket. A test that gives its own files names them as they are.
real, err := filepath.EvalSymlinks(s)
if e.Stat != nil {
real, err = s, nil
}
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, fmt.Errorf("the socket %s could not be read: %w", s, err)
}
if seen[real] {
continue
}
seen[real] = true
m, err := stat(real)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, fmt.Errorf("the socket %s could not be read for its owner and mode: %w", s, err)
}
a, err := acl(real)
if err != nil {
return nil, err
}
if Writable(m, uid, gids) || grantsByACL(a, uid, gids, true) {
ways = append(ways, "it can write the container runtime's socket "+s+", which runs a container as root")
}
}
// The secrets' ACLs: the bits were judged already.
for _, path := range secrets {
a, err := acl(path)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, fmt.Errorf("the secret %s's ACL could not be read: %w", path, err)
}
if grantsByACL(a, uid, gids, false) {
ways = append(ways, "an ACL lets it read the secret "+path)
}
}
// setuid programs no package owns.
now := time.Now
if e.Now != nil {
now = e.Now
}
mountsText, err := read("/proc/mounts")
if err != nil {
return nil, fmt.Errorf("the mounted filesystems could not be read: %w", err)
}
mounts := SuidMounts(string(mountsText))
if len(mounts) == 0 {
return nil, errors.New("no filesystem a setuid program could run from was found in /proc/mounts")
}
// In the background, its last result served (SetuidCache): never a stall. No result yet is said beside
// the other ways, which are judged all the same.
unowned, searchedAt, searchErr := e.Cache.Look(now(), func(sctx context.Context) ([]string, error) {
return e.setuidSearch(sctx, mounts)
})
var notJudged *SetuidNotJudged
if searchErr != nil && !errors.As(searchErr, &notJudged) {
return nil, searchErr
}
for _, p := range unowned {
ways = append(ways, "a setuid-root program no package owns: "+p+" (searched at "+
searchedAt.Local().Format("15:04")+")")
}
// The kernel's protection of links: off, any account may link another's file where root then acts on it.
for _, sysctl := range []string{"protected_hardlinks", "protected_symlinks"} {
raw, err := read("/proc/sys/fs/" + sysctl)
if err != nil {
return nil, fmt.Errorf("fs.%s could not be read: %w", sysctl, err)
}
if strings.TrimSpace(string(raw)) == "0" {
what := map[string]string{"protected_hardlinks": "hard links", "protected_symlinks": "symbolic links"}[sysctl]
ways = append(ways, fmt.Sprintf("%s to other accounts' files are not protected (fs.%s=0)", what, sysctl))
}
}
// A container runtime's API on TCP, which any account on the machine reaches.
for _, table := range []string{"/proc/net/tcp", "/proc/net/tcp6"} {
raw, err := read(table)
if errors.Is(err, fs.ErrNotExist) && table == "/proc/net/tcp6" {
continue // no IPv6 on this machine
}
if err != nil {
return nil, fmt.Errorf("the listening ports in %s could not be read: %w", table, err)
}
for _, port := range ListeningPorts(string(raw)) {
for _, api := range RuntimeAPIPorts {
if port == api {
ways = append(ways, fmt.Sprintf("a container runtime's API listens on TCP port %d, which any "+
"local account reaches", port))
}
}
}
}
if notJudged != nil {
// The other ways are judged; this one is said as not judged, never as none.
return ways, notJudged
}
return ways, nil
}
// walkFiles calls fn for every regular file below dir, through readDir.
func walkFiles(readDir func(string) ([]fs.DirEntry, error), dir string, fn func(string) error) error {
entries, err := readDir(dir)
if err != nil {
return err
}
for _, en := range entries {
p := filepath.Join(dir, en.Name())
if en.IsDir() {
if err := walkFiles(readDir, p, fn); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
continue
}
if err := fn(p); err != nil {
return err
}
}
return nil
}
// CLocale runs a command in the C locale, so what the judge parses — sudo's listing above all — is one
// language whatever the machine's is; stdin closed, output captured.
func CLocale(ctx context.Context, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C", "LANGUAGE=C")
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
var exit *exec.ExitError
if errors.As(err, &exit) {
return string(out), fmt.Errorf("%s exited %d: %s", name, exit.ExitCode(), strings.TrimSpace(stderr.String()))
}
return string(out), fmt.Errorf("%s: %w", name, err)
}
return string(out), nil
}
+320
View File
@@ -0,0 +1,320 @@
package accounts
import (
"context"
"encoding/binary"
"errors"
"fmt"
"os/exec"
"strings"
"testing"
"time"
)
// The ways beyond uid, groups and sudo (novox/hq ADR 0266, the review of 2026-10-08): doas, polkit, a
// runtime's socket, an ACL on a secret, a setuid program no package owns — each said; each unread question
// unknown; and the judge's commands in the C locale.
func TestEachFurtherWayToRootIsSaid(t *testing.T) {
const broker = "/var/lib/mesh/node-tools/broker"
for _, c := range []struct {
name string
set func(*agentMachine)
said string
}{
{"doas by name", func(m *agentMachine) {
m.texts = map[string]string{"/etc/doas.conf": "permit persist operator\npermit nopass agent as root\n"}
}, "doas permits it: permit nopass agent as root"},
{"doas by group", func(m *agentMachine) {
m.groups = "agent builders"
m.texts = map[string]string{"/etc/opendoas.conf": "permit :builders # the builders\n"}
}, "doas permits it: permit :builders"},
{"polkit by name", func(m *agentMachine) {
m.dirs = map[string][]string{"/etc/polkit-1/rules.d": {"10-agent.rules"}}
m.texts = map[string]string{"/etc/polkit-1/rules.d/10-agent.rules": `polkit.addRule(function(a, s) { if (s.user == "agent") return polkit.Result.YES; });`}
}, "a polkit rule names it or a group of it: /etc/polkit-1/rules.d/10-agent.rules"},
{"polkit by group", func(m *agentMachine) {
m.groups = "agent network"
m.dirs = map[string][]string{"/usr/share/polkit-1/rules.d": {"50-nm.rules"}}
m.texts = map[string]string{"/usr/share/polkit-1/rules.d/50-nm.rules": `if (subject.isInGroup("network")) return polkit.Result.YES;`}
}, "a polkit rule names it or a group of it: /usr/share/polkit-1/rules.d/50-nm.rules"},
{"docker socket by group bits", func(m *agentMachine) {
m.gids = "1600 970"
m.files["/run/docker.sock"] = FileMode{UID: 0, GID: 970, Perm: 0o660}
}, "it can write the container runtime's socket /run/docker.sock"},
{"docker socket by ACL", func(m *agentMachine) {
m.files["/run/docker.sock"] = FileMode{UID: 0, GID: 970, Perm: 0o660}
m.acls = map[string][]ACLEntry{"/run/docker.sock": {{User: true, ID: 1600, Read: true, Write: true}}}
}, "it can write the container runtime's socket /run/docker.sock"},
{"secret by ACL", func(m *agentMachine) {
m.acls = map[string][]ACLEntry{broker: {{User: false, ID: 1600, Read: true}}}
}, "an ACL lets it read the secret " + broker},
{"polkit for every account", func(m *agentMachine) {
m.dirs = map[string][]string{"/etc/polkit-1/rules.d": {"00-all.rules"}}
m.texts = map[string]string{"/etc/polkit-1/rules.d/00-all.rules": `polkit.addRule(function(action, subject) { return polkit.Result.YES; });`}
}, "a polkit rule grants every account: /etc/polkit-1/rules.d/00-all.rules"},
{"pkla for every account", func(m *agentMachine) {
m.dirs = map[string][]string{"/etc/polkit-1/localauthority": {"all.pkla"}}
m.texts = map[string]string{"/etc/polkit-1/localauthority/all.pkla": "[all]\nIdentity=unix-user:*\nAction=*\nResultAny=yes\n"}
}, "a polkit rule grants every account: /etc/polkit-1/localauthority/all.pkla"},
{"docker on TCP", func(m *agentMachine) {
m.proc["/proc/net/tcp6"] = " sl local_address rem_address st\n 0: 00000000000000000000000000000000:0947 00000000000000000000000000000000:0000 0A 0\n"
}, "a container runtime's API listens on TCP port 2375"},
{"hard links unprotected", func(m *agentMachine) {
m.proc["/proc/sys/fs/protected_hardlinks"] = "0\n"
}, "hard links to other accounts' files are not protected (fs.protected_hardlinks=0)"},
{"setuid on a second filesystem", func(m *agentMachine) {
m.proc["/proc/mounts"] += "/dev/sdb1 /srv xfs rw 0 0\n/dev/sdc1 /data ext4 rw,nosuid 0 0\n"
m.setuid = "/srv/bin/rootshell\n"
m.findArgs = func(args []string) {
if args[0] != "/" || args[1] != "/srv" || args[2] != "-xdev" {
panic(fmt.Sprintf("searched %v", args))
}
}
}, "a setuid-root program no package owns: /srv/bin/rootshell"},
{"setuid no package owns", func(m *agentMachine) {
m.setuid = "/usr/bin/sudo\n/usr/local/bin/rootshell\n"
m.packaged = map[string]bool{"/usr/bin/sudo": true}
}, "a setuid-root program no package owns: /usr/local/bin/rootshell"},
} {
t.Run(c.name, func(t *testing.T) {
m := clean()
c.set(m)
v := lookAgent(t, m)
if v.State != Unhealthy || !strings.HasPrefix(v.Reason, ReasonRoot+": ") || !strings.Contains(v.Reason, c.said) {
t.Fatalf("want %q said: %+v", c.said, v)
}
})
}
}
func TestWhatGrantsSomebodyElseIsNoWay(t *testing.T) {
m := clean()
m.texts = map[string]string{"/etc/doas.conf": "permit operator\npermit :wheel\n# permit agent\n",
"/usr/share/polkit-1/rules.d/50-default.rules": `polkit.addAdminRule(function(a, s) { return ["unix-group:wheel"]; });`}
m.dirs = map[string][]string{"/usr/share/polkit-1/rules.d": {"50-default.rules"}}
m.files["/run/docker.sock"] = FileMode{UID: 0, GID: 970, Perm: 0o660}
m.setuid = "/usr/bin/sudo\n/usr/bin/passwd\n"
m.packaged = map[string]bool{"/usr/bin/sudo": true, "/usr/bin/passwd": true}
if v := lookAgent(t, m); v.State != Healthy {
t.Fatalf("the operator's and wheel's grants, a socket of another group, packaged setuid programs: %+v", v)
}
}
func TestDoasWithoutSudoIsJudgedFromItsRules(t *testing.T) {
m := clean()
m.sudo, m.sudoErr = "", fmt.Errorf("sudo: %w", exec.ErrNotFound)
m.texts = map[string]string{"/etc/doas.conf": "permit nopass agent\n"}
v := lookAgent(t, m)
if v.State != Unhealthy || !strings.Contains(v.Reason, "doas permits it") {
t.Fatalf("no sudo is no sudo rule, and doas is read for itself: %+v", v)
}
}
func TestAFurtherQuestionUnansweredIsUnknown(t *testing.T) {
m := clean()
m.findErr = errors.New("find: interrupted")
if v := lookAgent(t, m); v.State != Unknown {
t.Fatalf("a search that did not finish: %+v", v)
}
m = clean()
m.setuid = "/usr/local/bin/x\n"
j := New(Exec{Run: func(ctx context.Context, name string, args ...string) (string, error) {
if name == "pacman" || name == "apk" {
return "", fmt.Errorf("%s: %w", name, exec.ErrNotFound)
}
return m.run(ctx, name, args...)
}, Stat: m.stat, ReadFile: m.readFile, ReadDir: m.readDir, ACL: m.acl})
j.Set([]Account{agent})
if st, _ := j.Look(t.Context()); st.Accounts[0].State != Unknown {
t.Fatalf("no package manager to ask: %+v", st.Accounts[0])
}
}
func TestTheSetuidSearchRunsInTheBackgroundAndIsKeptForItsInterval(t *testing.T) {
c := &SetuidCache{Every: time.Hour}
searched := 0
release := make(chan struct{})
search := func(context.Context) ([]string, error) { <-release; searched++; return []string{"/opt/x"}, nil }
at := time.Date(2026, 10, 8, 19, 0, 0, 0, time.UTC)
_, _, err := c.Look(at, search)
var nj *SetuidNotJudged
if !errors.As(err, &nj) || !nj.Pending || !strings.HasPrefix(err.Error(), ReasonPending) {
t.Fatalf("a look while the first search runs answers at once, not judged yet: %v", err)
}
if _, _, err := c.Look(at.Add(time.Minute), search); err == nil {
t.Fatal("still running: still not judged")
}
close(release)
c.Wait()
found, when, err := c.Look(at.Add(30*time.Minute), search)
if err != nil || len(found) != 1 || when.IsZero() {
t.Fatalf("the result once there: %v %v %v", found, when, err)
}
c.Look(at.Add(24*time.Hour), search)
c.Wait()
if searched != 2 {
t.Fatalf("searched %d times, want 2: once at first, once when the result was older than its interval", searched)
}
}
func TestASearchThatFailsIsNotJudgedAndBacksOff(t *testing.T) {
c := &SetuidCache{Every: time.Hour}
tries := 0
search := func(context.Context) ([]string, error) { tries++; return nil, errors.New("did not finish") }
at := time.Now()
c.Look(at, search)
c.Wait()
_, _, err := c.Look(at.Add(time.Minute), search)
var nj *SetuidNotJudged
if !errors.As(err, &nj) || nj.Pending || !strings.HasPrefix(err.Error(), ReasonIncomplete) {
t.Fatalf("a failed search is not judged (search incomplete): %v", err)
}
c.Wait()
if tries != 1 {
t.Fatalf("tried again within its back-off: %d", tries)
}
c.Look(at.Add(2*time.Hour), search)
c.Wait()
if tries != 2 {
t.Fatalf("tried again after its back-off: %d", tries)
}
}
func TestANotJudgedSearchStillSaysAWayFound(t *testing.T) {
j := New(fakeReader{ways: []string{"in the group docker, which grants root"}, err: &SetuidNotJudged{Pending: true, Since: time.Now()}})
j.Set([]Account{{Module: "m", ID: "m.a", Name: "agent", Root: true}})
st, _ := j.Look(context.Background())
if v := st.Accounts[0]; v.State != Unhealthy || !strings.Contains(v.Reason, "docker") || !strings.Contains(v.Reason, ReasonPending) {
t.Fatalf("a way found is unhealthy, with the search's state beside it: %+v", v)
}
j = New(fakeReader{err: &SetuidNotJudged{Since: time.Now(), Err: errors.New("timeout")}})
j.Set([]Account{{Module: "m", ID: "m.a", Name: "agent", Root: true}})
st, _ = j.Look(context.Background())
if v := st.Accounts[0]; v.State != Unknown || !strings.HasPrefix(v.Reason, ReasonIncomplete) {
t.Fatalf("nothing found and the search incomplete: unknown, said so: %+v", v)
}
}
type fakeReader struct {
ways []string
err error
}
func (f fakeReader) InDatabase(context.Context, string) ([]string, error) { return nil, nil }
func (f fakeReader) Session(context.Context, string, []string) (Session, error) {
return Session{}, nil
}
func (f fakeReader) Escalation(context.Context, string, []string) ([]string, error) {
return f.ways, f.err
}
func TestParseACL(t *testing.T) {
entry := func(tag, perm uint16, id uint32) []byte {
b := make([]byte, 8)
binary.LittleEndian.PutUint16(b, tag)
binary.LittleEndian.PutUint16(b[2:], perm)
binary.LittleEndian.PutUint32(b[4:], id)
return b
}
raw := []byte{2, 0, 0, 0}
raw = append(raw, entry(0x01, 6, 0xffffffff)...)
raw = append(raw, entry(0x02, 6, 1600)...) // user agent rw
raw = append(raw, entry(0x04, 4, 0xffffffff)...)
raw = append(raw, entry(0x08, 6, 970)...) // group 970 rw
raw = append(raw, entry(0x10, 4, 0xffffffff)...) // mask r--
raw = append(raw, entry(0x20, 0, 0xffffffff)...)
acl, err := ParseACL(raw)
if err != nil || len(acl) != 2 {
t.Fatalf("%v %v", acl, err)
}
if !acl[0].User || acl[0].ID != 1600 || !acl[0].Read || acl[0].Write {
t.Fatalf("the mask takes write away: %+v", acl[0])
}
if grantsByACL(acl, 1600, nil, true) || !grantsByACL(acl, 1601, map[int]bool{970: true}, false) {
t.Fatal("grants")
}
if _, err := ParseACL([]byte{2, 0, 0}); err == nil {
t.Fatal("a short ACL")
}
}
func TestTheJudgesCommandsRunInTheCLocale(t *testing.T) {
t.Setenv("LC_ALL", "de_DE.UTF-8")
t.Setenv("LANG", "de_DE.UTF-8")
out, err := CLocale(t.Context(), "sh", "-c", `echo "$LC_ALL $LANG"`)
if err != nil || strings.TrimSpace(out) != "C C" {
t.Fatalf("%q %v", out, err)
}
if _, err := CLocale(t.Context(), "sh", "-c", "echo nope >&2; exit 3"); err == nil ||
!strings.Contains(err.Error(), "exited 3: nope") {
t.Fatalf("an exit is said with its words: %v", err)
}
}
func TestTheJudgedListIsWrittenDown(t *testing.T) {
if len(Judged) < 8 || len(NotJudged) == 0 {
t.Fatal("what is judged and what is not are both written down")
}
}
func TestDistributionRulesForASeatOrForSomebodyAreNotEveryone(t *testing.T) {
for _, rule := range []string{
`polkit.addRule(function(a, s) { if (s.local && s.active) return polkit.Result.YES; });`,
`polkit.addRule(function(a, s) { if (s.isInGroup("wheel")) return polkit.Result.YES; });`,
`polkit.addAdminRule(function(a, s) { return ["unix-group:wheel"]; });`,
} {
if PolkitGrantsEveryone("/x.rules", rule) {
t.Errorf("not every account: %s", rule)
}
}
if PolkitGrantsEveryone("/x.pkla", "[a]\nIdentity=unix-group:wheel\nResultAny=yes\n") {
t.Error("a pkla for wheel is not every account")
}
}
func TestAMachineMissingWhatTheJudgeReadsIsUnknown(t *testing.T) {
for _, file := range []string{"/proc/sys/fs/protected_hardlinks", "/proc/net/tcp", "/proc/mounts"} {
m := clean()
delete(m.proc, file)
if v := lookAgent(t, m); v.State != Unknown {
t.Errorf("%s unread: %+v", file, v)
}
}
}
func TestSuidMountsLeaveOutNosuidPseudoAndContainerLayers(t *testing.T) {
got := SuidMounts("/dev/sdb1 /srv xfs rw 0 0\n/dev/sda2 / ext4 rw 0 0\noverlay /var/lib/docker/overlay2/x/merged overlay rw 0 0\n" +
"/dev/sdc1 /data ext4 rw,nosuid 0 0\nproc /proc proc rw 0 0\nhost:/x /mnt/nfs nfs4 rw 0 0\n/dev/sdd1 /my\\040disk ext4 rw 0 0\n")
if strings.Join(got, "|") != "/|/my disk|/srv" {
t.Fatalf("got %q", got)
}
}
// Each polkit rule is judged on its own, comments taken out (the third review of 2026-10-08).
func TestAnUnconditionalPolkitYesIsNotMaskedByAnotherRuleOrAComment(t *testing.T) {
cases := []struct {
name, text string
every bool
}{
{"another rule names a user", `polkit.addRule(function(a, s) { if (s.user == "operator") return polkit.Result.YES; });
polkit.addRule(function(a, s) { return polkit.Result.YES; });`, true},
{"a user named only in a comment", `// subject.user == "x"
polkit.addRule(function(a, s) { /* isInGroup("wheel") */ return polkit.Result.YES; });`, true},
{"the yes only in a comment", `polkit.addRule(function(a, s) { // return polkit.Result.YES;
return polkit.Result.NO; });`, false},
{"a condition in the same rule", `polkit.addRule(function(a, s) { if (s.isInGroup("wheel")) { return polkit.Result.YES; } });`, false},
{"a comment marker inside a string", `polkit.addRule(function(a, s) { var u = "http://x"; return polkit.Result.YES; });`, true},
}
for _, c := range cases {
if got := PolkitGrantsEveryone("/etc/polkit-1/rules.d/x.rules", c.text); got != c.every {
t.Errorf("%s: grants every account %v, want %v", c.name, got, c.every)
}
}
if PolkitGrantsEveryone("/x.pkla", "# [a]\n# Identity=unix-user:*\n# ResultAny=yes\n") {
t.Error("a .pkla section only in comments grants nobody")
}
if !PolkitGrantsEveryone("/x.pkla", "[a]\nIdentity=unix-group:wheel\nResultAny=yes\n[b]\nIdentity=unix-user:*\nResultActive=yes\n") {
t.Error("each .pkla section on its own")
}
}
+86 -11
View File
@@ -75,6 +75,8 @@ type Outcome struct {
groups []string
// unpacked is, for an archive, what it put on the machine (novox/hq issue 162).
unpacked *store.Unpacked
// asFound is, for a directory, that it is used as it was found (novox/hq ADR 0266).
asFound bool
// reads is, for a container, the digest of each file it was created reading, by path — so
// the next apply can say which one changed (novox/hq 04-ISSUES/103).
reads map[string]string
@@ -459,9 +461,15 @@ func ApplyMindingWindows(
// And one patience with the artifact store for the whole apply (novox/hq issue 291): a fetch it
// does not answer during a maintenance window waits for the window instead of failing.
in := inputs{declares: map[string]string{}, known: &known, windows: windows, away: newStoreAway(windows, log),
groups: wanted}
groups: wanted, agentHomes: rootNeverHomes(d)}
in.dirsBefore = map[string]bool{}
for _, resource := range d.Resources {
in.declares[resource.Identity()] = declaredDigest(resource)
if dir, ok := resource.(*declaration.Directory); ok {
if _, err := os.Lstat(dir.Path); err == nil {
in.dirsBefore[filepath.Clean(dir.Path)] = true
}
}
}
// Everything is attempted, and every failure is reported.
@@ -725,6 +733,7 @@ func ApplyMindingWindows(
Linger: outcome.linger,
Groups: outcome.groups,
Unpacked: outcome.unpacked,
AsFound: outcome.asFound,
Holds: holds(resource),
})
if outcome.found != nil {
@@ -918,9 +927,20 @@ type Unseal func(sealed string) ([]byte, error)
func applyOne(ctx context.Context, sys system.System, r declaration.Resource, run Runner,
changed map[string]bool, in inputs, previous store.Applied,
unseal Unseal, keepFound Keep) (Outcome, error) {
// Nothing below a home is touched through a link an account put there (novox/hq ADR 0266).
// And nothing is placed where no module places anything, whoever asked (placement_guard.go).
switch r.(type) {
case *declaration.Directory, *declaration.File, *declaration.Archive:
if err := refusePlacement(r, in.agentHomes); err != nil {
return begin(r), err
}
if err := refuseLinksUnderHome(r.Target()); err != nil {
return begin(r), err
}
}
switch res := r.(type) {
case *declaration.Directory:
return applyDirectory(res)
return applyDirectory(res, previous, in.dirsBefore[filepath.Clean(res.Path)])
case *declaration.File:
return applyFile(res, previous, unseal, keepFound)
case *declaration.Service:
@@ -965,14 +985,22 @@ func modeOf(spec string, fallback os.FileMode) (os.FileMode, error) {
return os.FileMode(parsed), nil
}
func applyDirectory(r *declaration.Directory) (Outcome, error) {
// applyDirectory makes a directory what was declared.
//
// **A directory found here, that the mesh did not make, is used as found** (novox/hq ADR 0266): its owner and
// mode are left, and the outcome says what was declared and what was found. Changing them would be root
// handing a directory it never made — wherever a declaration pointed it — to whatever account was named. A
// directory is the mesh's when its record says the mesh applied it before (a record from before this rule
// counts, which is every directory on a running machine), or when it already has the declared owner and mode,
// so a person who sets them by hand at the machine hands it to the mesh.
func applyDirectory(r *declaration.Directory, previous store.Applied, wasBefore bool) (Outcome, error) {
out := begin(r)
mode, err := modeOf(r.Mode, 0o755)
if err != nil {
return out, err
}
before, err := os.Stat(r.Path)
before, err := statPath(r.Path)
existed := err == nil
if err != nil && !errors.Is(err, os.ErrNotExist) {
return out, err
@@ -980,6 +1008,26 @@ func applyDirectory(r *declaration.Directory) (Outcome, error) {
if existed && !before.IsDir() {
return out, fmt.Errorf("%s exists and is not a directory", r.Path)
}
if existed && wasBefore {
ours := previous.Target != "" && filepath.Clean(previous.Target) == filepath.Clean(r.Path) && !previous.AsFound
if !ours {
owned, err := ownedBy(r.Path, r.Owner)
if err != nil {
return out, err
}
if !owned || before.Mode().Perm() != mode.Perm() {
out.Action, out.asFound = "unchanged", true
owner := r.Owner
if owner == "" {
owner = "root"
}
out.Detail = fmt.Sprintf("found here before the mesh and used as found: its owner and mode %o are "+
"left, though %s and %o were declared (novox/hq ADR 0266); set them by hand to hand it to the mesh",
before.Mode().Perm(), owner, mode.Perm())
return out, nil
}
}
}
if !existed {
if err := makeDirs(r.Path, mode, r.Owner); err != nil {
@@ -989,12 +1037,12 @@ func applyDirectory(r *declaration.Directory) (Outcome, error) {
// Set explicitly even when it existed: MkdirAll applies the mode only on creation, and a
// permission set at creation is not a permission maintained — a lesson this repository
// already paid for once, with world-readable environment files.
if err := os.Chmod(r.Path, mode); err != nil {
if err := chmodDirPath(r.Path, mode); err != nil {
return out, err
}
// Read back.
after, err := os.Stat(r.Path)
after, err := statPath(r.Path)
if err != nil {
return out, fmt.Errorf("made %s and cannot stat it: %w", r.Path, err)
}
@@ -1136,7 +1184,7 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal, keepF
return out, err
}
existing, readErr := os.ReadFile(r.Path)
existing, readErr := readPath(r.Path)
existed := readErr == nil
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return out, readErr
@@ -1157,7 +1205,7 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal, keepF
var beforeMode os.FileMode
beforeOwner := ""
if existed {
if info, err := os.Stat(r.Path); err == nil {
if info, err := statPath(r.Path); err == nil {
beforeMode = info.Mode().Perm()
if uid, gid, ok := ownerOf(info); ok {
beforeOwner = fmt.Sprintf("%d:%d", uid, gid)
@@ -1189,20 +1237,20 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal, keepF
return out, err
}
} else if !modeSame {
if err := os.Chmod(r.Path, mode); err != nil {
if err := chmodPath(r.Path, mode); err != nil {
return out, err
}
}
// Read back — the file, not the call that wrote it.
written, err := os.ReadFile(r.Path)
written, err := readPath(r.Path)
if err != nil {
return out, fmt.Errorf("wrote %s and cannot read it back: %w", r.Path, err)
}
if string(written) != content {
return out, fmt.Errorf("%s does not contain what was declared after writing it", r.Path)
}
info, err := os.Stat(r.Path)
info, err := statPath(r.Path)
if err != nil {
return out, err
}
@@ -1260,7 +1308,17 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal, keepF
//
// A reader of a managed file must never see half of one. The mesh's own configuration is read
// by daemons that reload on change, so a torn write is a service reading a truncated config.
//
// Below a home it is written through its directory's descriptor, following no link (home_links.go).
func writeAtomically(path string, content []byte, mode os.FileMode) error {
if home := homeAbove(path); home != "" {
return writeUnder(home, path, content, mode)
}
return writeAtomicallyByPath(path, content, mode)
}
// writeAtomicallyByPath is writeAtomically for a path below no home.
func writeAtomicallyByPath(path string, content []byte, mode os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".mesh-host-*")
if err != nil {
return err
@@ -1610,6 +1668,13 @@ func reflectOnly(ctx context.Context, sys system.System, r *declaration.Service,
func remove(ctx context.Context, sys system.System, a store.Applied, run Runner,
made map[string]bool) (string, string, error) {
switch declaration.Type(a.Type) {
case declaration.TypeDirectory, declaration.TypeFile, declaration.TypeArchive:
// Removal below a home follows no link an account put there either (novox/hq ADR 0266).
if err := refuseLinksUnderHome(a.Target); err != nil {
return "", "", err
}
}
switch declaration.Type(a.Type) {
case declaration.TypeDirectory:
// **A directory with anything left in it is kept, and that is the rule that protects
// data.** Everything the mesh put inside is itself a declared resource, and orphans are
@@ -1623,6 +1688,10 @@ func remove(ctx context.Context, sys system.System, a store.Applied, run Runner,
// This is the host's own line, applied to the one shape where getting it wrong is not
// recoverable: it removes what it made and leaves what it merely configured. An empty
// directory is what it made. A full one is not.
if a.AsFound {
// Found here before the mesh, and never the mesh's (novox/hq ADR 0266).
return "forgotten", "found here before the mesh and used as found: left as it is", nil
}
entries, err := os.ReadDir(a.Target)
if errors.Is(err, os.ErrNotExist) {
return "forgotten", "no longer there", nil
@@ -1850,6 +1919,12 @@ type inputs struct {
// groups is every group the declaration asks an account to be in, and by which resources
// (novox/hq ADR 0252): a group one user resource stops asking for stays while another asks.
groups Wanted
// agentHomes is the home of every account the declaration says never becomes root (novox/hq ADR
// 0266): nothing is unpacked or written into below one (placement_guard.go).
agentHomes []string
// dirsBefore is every declared directory that was on the machine when this apply began (novox/hq ADR
// 0266): one the apply itself made — a file's parent — is the mesh's, one that was there may not be.
dirsBefore map[string]bool
}
// fileDigest is what a file the container reads holds, by digest.
+184
View File
@@ -0,0 +1,184 @@
package apply
// A link under a person's home is never followed as root (novox/hq ADR 0266, the review of 2026-10-08).
//
// The node-engine runs as root and places files and directories under homes: the operator's shell
// configuration, the agent's `~/.claude`. Everything below a home is the account's to change. An account that
// replaces `~/.claude` with a symbolic link to /etc, between two applies, would have root change the owner and
// mode of /etc, or write a file into it, on the next one — and on a machine whose agents run as an account of
// their own, that account is exactly the one that must not become root.
//
// So for a path strictly below a home:
//
// - **a symbolic link in any component below the home, the path's own included, refuses the resource**, said
// in words, and nothing is done to it — before anything is read, written, chowned or chmodded;
// - **the owner and mode are set through a descriptor opened without following a link**, each component
// opened from the one above it (homes_linux.go), so a link put in place after the check is not followed
// either; a link that is itself the thing to own is owned as a link, never its target;
// - **a file is written through its directory's descriptor**, made with O_EXCL|O_NOFOLLOW and renamed within
// that directory, so neither the file nor a parent can be swapped for a link between check and write.
//
// The home itself is the machine's, made by root, and is followed as it is. A path outside every home is
// handled exactly as before: a module's system paths may be links the machine set up (a resolver's file is).
//
// Which homes: every account the user database lists with a uid of 1000 or more, other than the overflow
// account, and every home under /home. A service account's home under /var/lib is a module's own directory,
// and is left as it was.
import (
"bufio"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
// passwdFile is the user database the homes are read from; a test names its own.
var passwdFile = "/etc/passwd"
// homes is every home of a person's or an agent's account on this machine. A variable so a test can name the
// homes it made.
var homes = func() []string {
f, err := os.Open(passwdFile)
if err != nil {
return nil
}
defer f.Close()
var out []string
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Split(sc.Text(), ":")
if len(fields) < 6 {
continue
}
uid, err := strconv.Atoi(fields[2])
if err != nil {
continue
}
home := filepath.Clean(fields[5])
if home == "/" || home == "." || home == "" {
continue
}
if (uid >= 1000 && uid != 65534) || strings.HasPrefix(home, "/home/") {
out = append(out, home)
}
}
return out
}
// homeAbove is the home a path lies strictly below, the deepest when homes nest; "" when none.
func homeAbove(path string) string {
path = filepath.Clean(path)
hs := homes()
sort.Slice(hs, func(i, j int) bool { return len(hs[i]) > len(hs[j]) })
for _, h := range hs {
if strings.HasPrefix(path, h+string(os.PathSeparator)) {
return h
}
}
return ""
}
// LinkUnderHomeError is a resource refused because a component below a home is a symbolic link.
type LinkUnderHomeError struct {
Path, Link, Home string
}
func (e *LinkUnderHomeError) Error() string {
return fmt.Sprintf("%s is a symbolic link inside the home %s, and the node-engine runs as root: it never "+
"follows a link an account can put there, so nothing was done to %s (novox/hq ADR 0266). Make %s a "+
"directory or file again, or ask why something replaced it", e.Link, e.Home, e.Path, e.Link)
}
// refuseLinksUnderHome refuses a path below a home when any component below the home, its own included, is a
// symbolic link. Components that do not exist yet end the walk: what is missing is made without following.
func refuseLinksUnderHome(path string) error {
home := homeAbove(path)
if home == "" {
return nil
}
rel, err := filepath.Rel(home, filepath.Clean(path))
if err != nil {
return err
}
at := home
for _, part := range strings.Split(rel, string(os.PathSeparator)) {
at = filepath.Join(at, part)
info, err := os.Lstat(at)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return &LinkUnderHomeError{Path: path, Link: at, Home: home}
}
}
return nil
}
// HardLinkUnderHomeError is a regular file below a home with more than one link: the account that owns the
// home may have linked a file it does not own there, and root acting on it by name would act on that file
// (novox/hq ADR 0266). Refused, whatever the resource.
type HardLinkUnderHomeError struct {
Path string
Links uint64
}
func (e *HardLinkUnderHomeError) Error() string {
return fmt.Sprintf("%s has %d links: below a home a file with more than one is not owned, chmodded or read "+
"as root, since one of its names may be a file the home's account does not own", e.Path, e.Links)
}
// chmodDirPath is chmodPath for a directory resource: below a home, what is there must be a directory.
func chmodDirPath(path string, mode os.FileMode) error {
home := homeAbove(path)
if home == "" {
return os.Chmod(path, mode)
}
return chmodUnderAs(home, path, mode, kindDir)
}
// statPath is os.Stat, except below a home, where it never follows a link.
func statPath(path string) (os.FileInfo, error) {
if homeAbove(path) != "" {
return os.Lstat(path)
}
return os.Stat(path)
}
// chmodPath sets a mode; below a home through a descriptor that followed no link.
func chmodPath(path string, mode os.FileMode) error {
home := homeAbove(path)
if home == "" {
return os.Chmod(path, mode)
}
return chmodUnder(home, path, mode)
}
// chownPath sets an owner; below a home through a descriptor that followed no link, and a link itself is
// owned as a link.
func chownPath(path string, uid, gid int) error {
home := homeAbove(path)
if home == "" {
return os.Chown(path, uid, gid)
}
if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 {
return os.Lchown(path, uid, gid)
}
return chownUnder(home, path, uid, gid)
}
// readPath reads a file; below a home without following a link.
func readPath(path string) ([]byte, error) {
home := homeAbove(path)
if home == "" {
return os.ReadFile(path)
}
return readUnder(home, path)
}
+255
View File
@@ -0,0 +1,255 @@
//go:build linux
package apply
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq ADR 0266 (the review of 2026-10-08): below a home, the node-engine — root — follows no
// symbolic link an account can put there: not to chown or chmod, not to write, not to make a directory.
// aLinkedHome is a home the test names as one, with `.claude` replaced by a link to a directory outside it,
// as an account would to have root change /etc.
func aLinkedHome(t *testing.T) (home, outside string) {
t.Helper()
root := t.TempDir()
home = filepath.Join(root, "home", "agent")
outside = filepath.Join(root, "etc")
for _, d := range []string{home, outside} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(outside, "shadow"), []byte("root's"), 0o600); err != nil {
t.Fatal(err)
}
was := homes
homes = func() []string { return []string{home} }
t.Cleanup(func() { homes = was })
return home, outside
}
func link(t *testing.T, target, at string) {
t.Helper()
if err := os.Symlink(target, at); err != nil {
t.Fatal(err)
}
}
func applyOneResource(t *testing.T, resource string) error {
t.Helper()
d := declare(t, resource)
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, noServices, nil, nil)
return err
}
func mustBeLinkRefusal(t *testing.T, err error) {
t.Helper()
var refused *LinkUnderHomeError
if !errors.As(err, &refused) {
t.Fatalf("refused as a link under a home, got %v", err)
}
if !strings.Contains(err.Error(), "never follows a link") {
t.Fatalf("said in words: %v", err)
}
}
func modeOfPath(t *testing.T, p string) os.FileMode {
t.Helper()
info, err := os.Stat(p)
if err != nil {
t.Fatal(err)
}
return info.Mode().Perm()
}
func TestADirectoryUnderAHomeThatIsALinkIsRefusedAndItsTargetUntouched(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, outside, filepath.Join(home, ".claude"))
err := applyOneResource(t, `{"id":"claude-code.agent-home","type":"directory","path":"`+
filepath.Join(home, ".claude")+`","mode":"0700"}`)
mustBeLinkRefusal(t, err)
if m := modeOfPath(t, outside); m != 0o755 {
t.Fatalf("the link's target was chmodded to %o", m)
}
}
func TestAFileThroughALinkedParentUnderAHomeIsRefused(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, outside, filepath.Join(home, ".claude"))
err := applyOneResource(t, `{"id":"m.f","type":"file","path":"`+filepath.Join(home, ".claude", "shadow")+
`","content":"the mesh's\n","mode":"0644"}`)
mustBeLinkRefusal(t, err)
if got, _ := os.ReadFile(filepath.Join(outside, "shadow")); string(got) != "root's" {
t.Fatalf("written through the link: %q", got)
}
if m := modeOfPath(t, filepath.Join(outside, "shadow")); m != 0o600 {
t.Fatalf("chmodded through the link: %o", m)
}
}
func TestAFileThatIsItselfALinkUnderAHomeIsRefused(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, filepath.Join(outside, "shadow"), filepath.Join(home, ".zshrc"))
err := applyOneResource(t, `{"id":"m.f","type":"file","path":"`+filepath.Join(home, ".zshrc")+
`","content":"x\n"}`)
mustBeLinkRefusal(t, err)
if got, _ := os.ReadFile(filepath.Join(outside, "shadow")); string(got) != "root's" {
t.Fatalf("written through the link: %q", got)
}
}
func TestADirectoryAndAFileUnderAHomeAreMadeAsBefore(t *testing.T) {
home, _ := aLinkedHome(t)
dir := filepath.Join(home, ".claude")
if err := applyOneResource(t, `{"id":"m.d","type":"directory","path":"`+dir+`","mode":"0700"}`); err != nil {
t.Fatal(err)
}
if m := modeOfPath(t, dir); m != 0o700 {
t.Fatalf("mode %o", m)
}
file := filepath.Join(home, ".config", "mesh", "env.sh")
if err := applyOneResource(t, `{"id":"m.f","type":"file","path":"`+file+`","content":"A=1\n","mode":"0640"}`); err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(file); string(got) != "A=1\n" || modeOfPath(t, file) != 0o640 {
t.Fatalf("written %q mode %o", got, modeOfPath(t, file))
}
}
// The descriptor half: a link put in place after any check is still not followed.
func TestTheDescriptorCallsFollowNoLinkBelowAHome(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, outside, filepath.Join(home, ".claude"))
mustBeLinkRefusal(t, chmodPath(filepath.Join(home, ".claude"), 0o777))
mustBeLinkRefusal(t, chmodPath(filepath.Join(home, ".claude", "shadow"), 0o777))
if m := modeOfPath(t, filepath.Join(outside, "shadow")); m != 0o600 {
t.Fatalf("chmodded through the link: %o", m)
}
mustBeLinkRefusal(t, writeAtomically(filepath.Join(home, ".claude", "new"), []byte("x"), 0o644))
if _, err := os.Stat(filepath.Join(outside, "new")); !os.IsNotExist(err) {
t.Fatal("written through the link")
}
if _, err := makeDirsSaying(filepath.Join(home, ".claude", "a", "b"), 0o755, ""); err == nil {
t.Fatal("made directories through the link")
}
if _, err := os.Stat(filepath.Join(outside, "a")); !os.IsNotExist(err) {
t.Fatal("made a directory through the link")
}
if _, err := readPath(filepath.Join(home, ".claude", "shadow")); err == nil {
t.Fatal("read through the link")
}
me := os.Getuid()
// A link itself is owned as a link, never its target.
if err := chownPath(filepath.Join(home, ".claude"), me, os.Getgid()); err != nil {
t.Fatalf("a link is owned as a link: %v", err)
}
}
func TestRemovalThroughALinkUnderAHomeIsRefused(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, outside, filepath.Join(home, ".claude"))
_, _, err := remove(context.Background(), archHost(t), store.Applied{ID: "m.f", Type: "file",
Target: filepath.Join(home, ".claude", "shadow")}, nil, nil)
mustBeLinkRefusal(t, err)
if _, err := os.Stat(filepath.Join(outside, "shadow")); err != nil {
t.Fatal("removed through the link")
}
}
func TestAPathOutsideEveryHomeIsHandledAsBefore(t *testing.T) {
_, outside := aLinkedHome(t)
elsewhere := filepath.Join(filepath.Dir(outside), "srv")
if err := os.MkdirAll(elsewhere, 0o755); err != nil {
t.Fatal(err)
}
link(t, outside, filepath.Join(elsewhere, "linked"))
if homeAbove(filepath.Join(elsewhere, "linked", "x")) != "" {
t.Fatal("not below a home")
}
if err := refuseLinksUnderHome(filepath.Join(elsewhere, "linked", "x")); err != nil {
t.Fatal("a system path may be a link the machine set up; only a home's are refused")
}
}
func TestHomesAreAPersonsOrAnAgentsNotAServicesOrRoots(t *testing.T) {
dir := t.TempDir()
passwd := filepath.Join(dir, "passwd")
if err := os.WriteFile(passwd, []byte("root:x:0:0::/root:/bin/bash\n"+
"postgres:x:968:968::/var/lib/postgres:/usr/bin/nologin\n"+
"nobody:x:65534:65534::/:/usr/bin/nologin\n"+
"operator:x:1000:1000::/home/operator:/bin/zsh\n"+
"agent:x:1001:1001::/home/agent:/bin/bash\n"+
"svc:x:990:990::/home/svc:/usr/bin/nologin\n"), 0o644); err != nil {
t.Fatal(err)
}
was := passwdFile
passwdFile = passwd
t.Cleanup(func() { passwdFile = was })
got := strings.Join(homes(), ",")
if got != "/home/operator,/home/agent,/home/svc" {
t.Fatalf("homes %s", got)
}
if homeAbove("/home/agent/.claude") != "/home/agent" || homeAbove("/home/agent") != "" ||
homeAbove("/var/lib/postgres/data") != "" || homeAbove("/root/.ssh") != "" {
t.Fatal("strictly below a person's or an agent's home, and nothing else")
}
}
// A hard link below a home to a file the home's account does not own is never owned, chmodded or read by
// name: fstat on the opened descriptor counts its links first (the re-review of 2026-10-08).
func TestAHardLinkedFileBelowAHomeIsRefused(t *testing.T) {
home, outside := aLinkedHome(t)
shadow := filepath.Join(outside, "shadow")
linked := filepath.Join(home, ".claude")
if err := os.Link(shadow, linked); err != nil {
t.Skipf("no hard link here: %v", err)
}
var hard *HardLinkUnderHomeError
if err := chmodPath(linked, 0o777); !errors.As(err, &hard) {
t.Fatalf("chmod of a hard-linked file below a home is refused, got %v", err)
}
if err := chownPath(linked, os.Getuid(), os.Getgid()); !errors.As(err, &hard) {
t.Fatalf("chown of it is refused, got %v", err)
}
if _, err := readPath(linked); !errors.As(err, &hard) {
t.Fatalf("reading it is refused, got %v", err)
}
if m := modeOfPath(t, shadow); m != 0o600 {
t.Fatalf("the other file's mode changed: %o", m)
}
// As the directory resource sees it: a file where ~/.claude should be is left alone.
if err := chmodDirPath(linked, 0o700); err == nil || !strings.Contains(err.Error(), "where a directory was expected") {
t.Fatalf("a file where a directory was expected is refused, got %v", err)
}
}
func TestADirectoryWhereAFileWasExpectedAndAFifoAreRefused(t *testing.T) {
home, _ := aLinkedHome(t)
dir := filepath.Join(home, "d")
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatal(err)
}
if _, err := readPath(dir); err == nil || !strings.Contains(err.Error(), "where a file was expected") {
t.Fatalf("a directory where a file was expected is refused, got %v", err)
}
fifo := filepath.Join(home, "f")
if err := syscall.Mkfifo(fifo, 0o600); err != nil {
t.Skipf("no fifo here: %v", err)
}
if err := chmodPath(fifo, 0o644); err == nil || !strings.Contains(err.Error(), "neither a file nor a directory") {
t.Fatalf("a fifo is refused, got %v", err)
}
if err := chmodDirPath(dir, 0o700); err != nil {
t.Fatalf("a directory as a directory passes: %v", err)
}
}
+241
View File
@@ -0,0 +1,241 @@
//go:build linux
package apply
// The descriptor half of home_links.go: below a home, every component is opened from the one above it with
// O_NOFOLLOW, so a link is refused by the kernel at the moment of use, not only at the check before it.
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"golang.org/x/sys/unix"
)
// openDirUnder opens the directory rel names below home, following no link below the home.
func openDirUnder(home, dir string) (int, error) {
rel, err := filepath.Rel(home, filepath.Clean(dir))
if err != nil || strings.HasPrefix(rel, "..") {
return -1, fmt.Errorf("%s is not below %s", dir, home)
}
fd, err := unix.Open(home, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return -1, &os.PathError{Op: "open", Path: home, Err: err}
}
if rel == "." {
return fd, nil
}
at := home
for _, part := range strings.Split(rel, string(os.PathSeparator)) {
at = filepath.Join(at, part)
next, err := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
unix.Close(fd)
if err != nil {
return -1, linkOr(at, home, dir, "open", err)
}
fd = next
}
return fd, nil
}
// linkOr says a refused link in the mesh's words, and any other failure as the system's.
func linkOr(at, home, path, op string, err error) error {
if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.ENOTDIR) {
if info, lerr := os.Lstat(at); lerr == nil && info.Mode()&os.ModeSymlink != 0 {
return &LinkUnderHomeError{Path: path, Link: at, Home: home}
}
}
return &os.PathError{Op: op, Path: at, Err: err}
}
// What a caller expects to find at a path below a home: either, a directory, or a regular file.
const (
kindAny = iota
kindDir
kindFile
)
// openUnder opens the file or directory at path below home, following no link, for its metadata.
func openUnder(home, path string) (int, error) { return openUnderAs(home, path, kindAny) }
// openUnderAs opens path below home as what the caller expects, and refuses what root must not act on
// (novox/hq ADR 0266): a directory is opened with O_DIRECTORY|O_NOFOLLOW; whatever was opened is judged by
// fstat on the descriptor itself, before any fchown, fchmod or read — so a name swapped after a check is
// judged as what it now is. Refused: anything but a directory or a regular file (a device, a fifo, a
// socket), a kind other than the one expected, and a regular file with more than one link. The account that
// owns the home can hard-link a file it does not own (root's, where fs.protected_hardlinks is off) into its
// home; owned or chmodded by name, root would hand that file over.
func openUnderAs(home, path string, want int) (int, error) {
dir, err := openDirUnder(home, filepath.Dir(path))
if err != nil {
return -1, err
}
defer unix.Close(dir)
base := filepath.Base(path)
flags := unix.O_RDONLY | unix.O_NOFOLLOW | unix.O_NONBLOCK | unix.O_CLOEXEC
fd := -1
if want != kindFile {
fd, err = unix.Openat(dir, base, flags|unix.O_DIRECTORY, 0)
if errors.Is(err, unix.ENOTDIR) && want == kindAny {
fd, err = unix.Openat(dir, base, flags, 0)
}
} else {
fd, err = unix.Openat(dir, base, flags, 0)
}
if err != nil {
err = linkOr(path, home, path, "open", err)
var link *LinkUnderHomeError
if want == kindDir && !errors.As(err, &link) && errors.Is(err, unix.ENOTDIR) {
return -1, fmt.Errorf("%s is not a directory where a directory was expected: below a home it is left alone", path)
}
return -1, err
}
if err := judgeOpened(fd, path, want); err != nil {
unix.Close(fd)
return -1, err
}
return fd, nil
}
// judgeOpened is openUnderAs's verdict on what the descriptor holds.
func judgeOpened(fd int, path string, want int) error {
var st unix.Stat_t
if err := unix.Fstat(fd, &st); err != nil {
return &os.PathError{Op: "fstat", Path: path, Err: err}
}
switch st.Mode & unix.S_IFMT {
case unix.S_IFDIR:
if want == kindFile {
return fmt.Errorf("%s is a directory where a file was expected: below a home it is left alone", path)
}
case unix.S_IFREG:
if want == kindDir {
return fmt.Errorf("%s is a file where a directory was expected: below a home it is left alone", path)
}
if st.Nlink > 1 {
return &HardLinkUnderHomeError{Path: path, Links: uint64(st.Nlink)}
}
default:
return fmt.Errorf("%s is neither a file nor a directory: below a home it is left alone", path)
}
return nil
}
func chmodUnder(home, path string, mode os.FileMode) error {
return chmodUnderAs(home, path, mode, kindAny)
}
func chmodUnderAs(home, path string, mode os.FileMode, want int) error {
fd, err := openUnderAs(home, path, want)
if err != nil {
return err
}
defer unix.Close(fd)
if err := unix.Fchmod(fd, uint32(mode.Perm())); err != nil {
return &os.PathError{Op: "chmod", Path: path, Err: err}
}
return nil
}
func chownUnder(home, path string, uid, gid int) error {
fd, err := openUnder(home, path)
if err != nil {
return err
}
defer unix.Close(fd)
if err := unix.Fchown(fd, uid, gid); err != nil {
return &os.PathError{Op: "chown", Path: path, Err: err}
}
return nil
}
func readUnder(home, path string) ([]byte, error) {
fd, err := openUnderAs(home, path, kindFile)
if err != nil {
return nil, err
}
f := os.NewFile(uintptr(fd), path)
defer f.Close()
var st unix.Stat_t
if err := unix.Fstat(fd, &st); err == nil && st.Mode&unix.S_IFMT != unix.S_IFREG {
return nil, fmt.Errorf("%s is not a regular file", path)
}
return io.ReadAll(f)
}
// mkdirAllUnder makes the directories missing below home down to dir, each made in its parent's descriptor
// and opened without following a link; answers those it made, deepest first, as makeDirsSaying does.
func mkdirAllUnder(home, dir string, mode os.FileMode) ([]string, error) {
rel, err := filepath.Rel(home, filepath.Clean(dir))
if err != nil || strings.HasPrefix(rel, "..") {
return nil, fmt.Errorf("%s is not below %s", dir, home)
}
fd, err := unix.Open(home, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return nil, &os.PathError{Op: "open", Path: home, Err: err}
}
defer func() { unix.Close(fd) }()
var made []string
if rel == "." {
return nil, nil
}
at := home
for _, part := range strings.Split(rel, string(os.PathSeparator)) {
at = filepath.Join(at, part)
if err := unix.Mkdirat(fd, part, uint32(mode.Perm())); err == nil {
made = append([]string{at}, made...)
} else if !errors.Is(err, unix.EEXIST) {
return made, &os.PathError{Op: "mkdir", Path: at, Err: err}
}
next, err := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
return made, linkOr(at, home, dir, "open", err)
}
unix.Close(fd)
fd = next
}
return made, nil
}
// writeUnder writes a file below home atomically, through its directory's descriptor: made O_EXCL|O_NOFOLLOW
// under a name of its own, given its mode, and renamed over the file within that directory.
func writeUnder(home, path string, content []byte, mode os.FileMode) error {
dir, err := openDirUnder(home, filepath.Dir(path))
if err != nil {
return err
}
defer unix.Close(dir)
name := ".mesh-host-" + strconv.FormatInt(time.Now().UnixNano(), 36) + "-" + strconv.Itoa(os.Getpid())
fd, err := unix.Openat(dir, name, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o600)
if err != nil {
return &os.PathError{Op: "create", Path: filepath.Join(filepath.Dir(path), name), Err: err}
}
f := os.NewFile(uintptr(fd), name)
_, werr := f.Write(content)
if werr == nil {
werr = f.Sync()
}
if werr == nil {
if err := unix.Fchmod(fd, uint32(mode.Perm())); err != nil {
werr = &os.PathError{Op: "chmod", Path: path, Err: err}
}
}
if cerr := f.Close(); werr == nil {
werr = cerr
}
if werr == nil {
if err := unix.Renameat(dir, name, dir, filepath.Base(path)); err != nil {
werr = &os.PathError{Op: "rename", Path: path, Err: err}
}
}
if werr != nil {
_ = unix.Unlinkat(dir, name, 0)
}
return werr
}
+59
View File
@@ -0,0 +1,59 @@
//go:build !linux
package apply
// Where there is no openat with O_NOFOLLOW to rely on, the check before use is all: a link below a home is
// refused, and the call is made by path. The node-engine runs on Linux; this keeps the package building.
import (
"os"
"path/filepath"
)
func chmodUnder(home, path string, mode os.FileMode) error {
if err := refuseLinksUnderHome(path); err != nil {
return err
}
return os.Chmod(path, mode)
}
func chmodUnderAs(home, path string, mode os.FileMode, _ int) error {
return chmodUnder(home, path, mode)
}
const kindDir = 1
func chownUnder(home, path string, uid, gid int) error {
if err := refuseLinksUnderHome(path); err != nil {
return err
}
return os.Lchown(path, uid, gid)
}
func readUnder(home, path string) ([]byte, error) {
if err := refuseLinksUnderHome(path); err != nil {
return nil, err
}
return os.ReadFile(path)
}
func mkdirAllUnder(home, dir string, mode os.FileMode) ([]string, error) {
if err := refuseLinksUnderHome(dir); err != nil {
return nil, err
}
var made []string
for d := filepath.Clean(dir); d != home; d = filepath.Dir(d) {
if _, err := os.Lstat(d); err == nil {
break
}
made = append(made, d)
}
return made, os.MkdirAll(dir, mode)
}
func writeUnder(home, path string, content []byte, mode os.FileMode) error {
if err := refuseLinksUnderHome(path); err != nil {
return err
}
return writeAtomicallyByPath(path, content, mode)
}
+217
View File
@@ -0,0 +1,217 @@
package apply
// Where the node-engine places nothing, whoever asks (novox/hq ADR 0266, the third review of 2026-10-08).
//
// A directory, a file or an archive names its path, and the controller resolves part of that path from what
// a person or a verb set: a module's `places` setting moves a directory anywhere. The engine runs as root, so
// a path it accepts blindly is a path any caller of the controller's settings could hand to any account — a
// directory resource at /etc owned by the agent account gives the agent /etc. So the engine itself refuses,
// whatever the declaration says:
//
// 1. **a directory that is one of the machine's own roots**, or an ancestor of one: /, /etc, /usr, /var,
// /var/lib, /home, /run and the rest of protectedRoots. Modules place files and directories BELOW /etc or
// /var/lib, never the root itself; owning one is owning everything in it;
// 2. **anything below /proc, /sys, /dev or /boot**, and **anything in the node-engine's own trees** (its
// state, its identity, its installed builds) but its own module's;
// 3. **anything below a person's or an agent's home, for an owner other than that home's account**. A
// directory, file or archive below /home/<account> belongs to that account or is not placed: a module
// placing root's, or another account's, file there is placing it where the account controls every parent;
// and a home itself is its account's, so a directory resource naming a home exactly is refused;
// 4. **an archive, or a file written into (`into`), below the home of an account declared `root: never`**,
// however the path is spelled. The engine writes those after checking the path, not through descriptors,
// and that account owns every parent and could swap a link in between.
//
// Each is a refusal of the resource, said in words; nothing is touched.
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/novox/mesh-host/internal/declaration"
)
// protectedRoots are directories no directory resource may be, nor be an ancestor of.
var protectedRoots = []string{"/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib64", "/media", "/mnt",
"/opt", "/proc", "/root", "/run", "/sbin", "/srv", "/sys", "/tmp", "/usr", "/usr/bin", "/usr/lib",
"/usr/lib64", "/usr/local", "/usr/local/bin", "/usr/local/lib", "/usr/local/sbin", "/usr/sbin", "/usr/share",
"/var", "/var/cache", "/var/lib", "/var/lib/mesh", "/var/log", "/var/tmp"}
// forbiddenBelow are trees nothing is placed in: the kernel's and the boot loader's. engineTrees are the
// engine's own, which only its own module (`mesh-host`, whose builds are installed there) places in.
var (
forbiddenBelow = []string{"/proc", "/sys", "/dev", "/boot"}
engineTrees = []string{"/var/lib/mesh-host", "/usr/lib/nox-mesh-host"}
)
// engineModule is the module whose resources may place in the engine's own trees.
const engineModule = "mesh-host."
// PlacementRefusedError is a resource the engine will not place where it says.
type PlacementRefusedError struct {
Path, Why string
}
func (e *PlacementRefusedError) Error() string {
return fmt.Sprintf("%s is not placed: %s (novox/hq ADR 0266); nothing was touched", e.Path, e.Why)
}
// accountsOfHomes is each person's or agent's home and the account it belongs to, from the user database —
// the same homes homeAbove reads. A variable so a test names its own.
var accountsOfHomes = func() map[string]homeAccount {
f, err := os.Open(passwdFile)
if err != nil {
return nil
}
defer f.Close()
out := map[string]homeAccount{}
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Split(sc.Text(), ":")
if len(fields) < 6 {
continue
}
uid, err := strconv.Atoi(fields[2])
if err != nil {
continue
}
home := filepath.Clean(fields[5])
if home == "/" || home == "." || home == "" {
continue
}
if (uid >= 1000 && uid != 65534) || strings.HasPrefix(home, "/home/") {
out[home] = homeAccount{Name: fields[0], UID: uid}
}
}
return out
}
type homeAccount struct {
Name string
UID int
}
// below says whether path is strictly below dir.
func below(path, dir string) bool {
if dir == "/" {
return path != "/"
}
return strings.HasPrefix(path, dir+string(os.PathSeparator))
}
// ownerName is the owner a resource declares, "" for root.
func ownerName(r declaration.Resource) string {
switch res := r.(type) {
case *declaration.Directory:
return res.Owner
case *declaration.File:
return res.Owner
case *declaration.Archive:
return res.Owner
}
return ""
}
// ownedByAccount says whether a declared owner is that account: by name, or by its uid ("1001", "1001:1001").
func ownedByAccount(owner string, a homeAccount) bool {
if owner == a.Name {
return true
}
user, _, _ := strings.Cut(owner, ":")
if uid, err := strconv.Atoi(user); err == nil {
return uid == a.UID
}
return false
}
// refusePlacement says why a directory, file or archive is not placed; nil when it may be. agentHomes are the
// homes of the accounts the declaration says never become root.
func refusePlacement(r declaration.Resource, agentHomes []string) error {
path := filepath.Clean(r.Target())
if !filepath.IsAbs(path) {
return nil // the declaration refuses a relative path already
}
if _, isDir := r.(*declaration.Directory); isDir {
for _, root := range protectedRoots {
if path == root || below(root, path) {
return &PlacementRefusedError{Path: path, Why: root + " is one of the machine's own directories, " +
"and owning it would be owning everything in it"}
}
}
}
for _, tree := range forbiddenBelow {
if path == tree || below(path, tree) {
return &PlacementRefusedError{Path: path, Why: "nothing is placed in " + tree}
}
}
if !strings.HasPrefix(r.Identity(), engineModule) {
for _, tree := range engineTrees {
if path == tree || below(path, tree) {
return &PlacementRefusedError{Path: path, Why: tree + " is the node-engine's own, placed in by its own module alone"}
}
}
}
homes := accountsOfHomes()
if a, isHome := homes[path]; isHome {
return &PlacementRefusedError{Path: path, Why: "it is " + a.Name + "'s home, which is that account's"}
}
var deepest string
for home := range homes {
if below(path, home) && len(home) > len(deepest) {
deepest = home
}
}
if deepest != "" {
a := homes[deepest]
if owner := ownerName(r); !ownedByAccount(owner, a) {
if owner == "" {
owner = "root"
}
return &PlacementRefusedError{Path: path, Why: fmt.Sprintf("it is below %s's home and declared %s's; "+
"below a home only that account's files are placed", a.Name, owner)}
}
}
risky := ""
switch res := r.(type) {
case *declaration.Archive:
risky = "an archive unpacked"
case *declaration.File:
if res.Into != "" {
risky = "a file written into (" + res.Into + ")"
}
}
if risky != "" {
for _, home := range agentHomes {
if path == home || below(path, home) {
return &PlacementRefusedError{Path: path, Why: risky + " below the home of an account that never " +
"becomes root, which owns every parent there and could swap a link in between the check and the write"}
}
}
}
return nil
}
// rootNeverHomes is the home of every account the declaration says never becomes root, from the user database;
// an account not made yet has no home to protect.
func rootNeverHomes(d *declaration.Declaration) []string {
if d == nil {
return nil
}
homes := accountsOfHomes()
var out []string
for _, r := range d.Resources {
u, ok := r.(*declaration.User)
if !ok || u.Root != declaration.RootNever {
continue
}
for home, a := range homes {
if a.Name == u.Name {
out = append(out, home)
}
}
}
return out
}
+141
View File
@@ -0,0 +1,141 @@
package apply
// Defends novox/hq ADR 0266 (the third review): the engine places nothing at one of the machine's own
// directories, in the kernel's or its own trees, below a home for another account, or — for an archive or a
// file written into — below an agent's home; and a directory it did not make is used as found.
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
func withHomes(t *testing.T, homes map[string]homeAccount) {
t.Helper()
was := accountsOfHomes
accountsOfHomes = func() map[string]homeAccount { return homes }
t.Cleanup(func() { accountsOfHomes = was })
}
func TestAMachinesOwnDirectoryIsNeverPlaced(t *testing.T) {
withHomes(t, nil)
for _, path := range []string{"/", "/etc", "/etc/", "/usr", "/var/lib", "/var/lib/mesh", "/home", "/root", "/run"} {
err := refusePlacement(&declaration.Directory{ID: "m.d", Type: declaration.TypeDirectory, Path: path, Owner: "agent"}, nil)
var refused *PlacementRefusedError
if !errors.As(err, &refused) {
t.Errorf("%s as a directory: refused, got %v", path, err)
}
}
for _, path := range []string{"/etc/sudoers.d/x", "/var/lib/mesh/daemons", "/var/lib/postgres", "/usr/local/bin/claude-agent"} {
if err := refusePlacement(&declaration.Directory{ID: "m.d", Type: declaration.TypeDirectory, Path: path}, nil); err != nil {
t.Errorf("%s: a module's own place below a root passes, got %v", path, err)
}
}
for _, path := range []string{"/proc/sys/x", "/sys/x", "/dev/x", "/boot/x", "/var/lib/mesh-host/state.json", "/var/lib/mesh-host"} {
if err := refusePlacement(&declaration.File{ID: "m.f", Type: declaration.TypeFile, Path: path, Content: "x"}, nil); err == nil {
t.Errorf("%s: nothing is placed there", path)
}
}
if err := refusePlacement(&declaration.File{ID: "mesh-host.launcher", Type: declaration.TypeFile,
Path: "/usr/lib/nox-mesh-host/launch", Content: "x"}, nil); err != nil {
t.Errorf("the engine's own module places its builds: %v", err)
}
}
func TestBelowAHomeOnlyThatAccountsFilesArePlaced(t *testing.T) {
withHomes(t, map[string]homeAccount{"/home/operator": {"operator", 1000}, "/home/agent": {"agent", 1001}})
cases := []struct {
r declaration.Resource
ok bool
}{
{&declaration.Directory{ID: "a.d", Type: declaration.TypeDirectory, Path: "/home/agent/.claude", Owner: "agent"}, true},
{&declaration.Directory{ID: "a.d", Type: declaration.TypeDirectory, Path: "/home/agent/.claude", Owner: "1001:1001"}, true},
{&declaration.Directory{ID: "a.d", Type: declaration.TypeDirectory, Path: "/home/agent/.claude"}, false},
{&declaration.Directory{ID: "a.d", Type: declaration.TypeDirectory, Path: "/home/agent/.claude", Owner: "operator"}, false},
{&declaration.Directory{ID: "a.d", Type: declaration.TypeDirectory, Path: "/home/agent", Owner: "agent"}, false},
{&declaration.File{ID: "a.f", Type: declaration.TypeFile, Path: "/home/operator/.zshrc", Owner: "operator", Content: "x"}, true},
}
for _, c := range cases {
if err := refusePlacement(c.r, nil); (err == nil) != c.ok {
t.Errorf("%s owned by %q: ok %v, got %v", c.r.Target(), ownerName(c.r), c.ok, err)
}
}
}
func TestNothingIsUnpackedOrWrittenIntoBelowAnAgentsHome(t *testing.T) {
withHomes(t, map[string]homeAccount{"/home/agent": {"agent", 1001}})
agent := []string{"/home/agent"}
if err := refusePlacement(&declaration.Archive{ID: "a.x", Type: declaration.TypeArchive, Path: "/home/agent/.local/x", Owner: "agent"}, agent); err == nil {
t.Error("an archive below an agent's home is refused")
}
if err := refusePlacement(&declaration.File{ID: "a.f", Type: declaration.TypeFile, Path: "/home/agent/.claude.json", Owner: "agent", Into: "json", Content: "{}"}, agent); err == nil {
t.Error("a file written into below an agent's home is refused")
}
if err := refusePlacement(&declaration.File{ID: "a.f", Type: declaration.TypeFile, Path: "/home/agent/x", Owner: "agent", Content: "x"}, agent); err != nil {
t.Errorf("a whole file there passes: %v", err)
}
d := parse(t, `{"declaration":1,"resources":[{"id":"c.agent","type":"user","name":"agent","root":"never"},`+
`{"id":"c.op","type":"user","name":"operator"}]}`)
if got := rootNeverHomes(d); len(got) != 1 || got[0] != "/home/agent" {
t.Errorf("the agent's home is known by the user database: %v", got)
}
}
func TestADirectoryAtEtcIsRefusedThroughTheApplyAndNothingIsTouched(t *testing.T) {
withHomes(t, nil)
l := &logins{shells: map[string]string{}}
report, _, err := Apply(context.Background(), archHost(t), parse(t,
`{"declaration":1,"resources":[{"id":"m.state","type":"directory","path":"/etc","owner":"agent","mode":"0755"}]}`),
store.State{}, store.OriginDeclared, l.run, nil, nil)
if err == nil || !strings.Contains(err.Error(), "machine's own directories") {
t.Fatalf("refused: %v %+v", err, report)
}
}
func TestADirectoryFoundHereIsUsedAsFound(t *testing.T) {
dir := filepath.Join(t.TempDir(), "found")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
r := &declaration.Directory{ID: "m.d", Type: declaration.TypeDirectory, Path: dir, Mode: "0755"}
out, err := applyDirectory(r, store.Applied{}, true)
if err != nil || !out.asFound || !strings.Contains(out.Detail, "used as found") {
t.Fatalf("a found directory is used as found: %+v %v", out, err)
}
if info, _ := os.Stat(dir); info.Mode().Perm() != 0o700 {
t.Fatalf("its mode was changed: %o", info.Mode().Perm())
}
// Recorded as found, it stays found.
out, _ = applyDirectory(r, store.Applied{ID: "m.d", Target: dir, AsFound: true}, true)
if !out.asFound {
t.Fatal("a directory recorded as found stays found")
}
// The mesh's by its record from an earlier apply: converged as before.
out, err = applyDirectory(r, store.Applied{ID: "m.d", Target: dir}, true)
if err != nil || out.asFound {
t.Fatalf("a directory the mesh applied before is converged: %+v %v", out, err)
}
if info, _ := os.Stat(dir); info.Mode().Perm() != 0o755 {
t.Fatalf("not converged: %o", info.Mode().Perm())
}
// Already as declared: the mesh's from here on.
other := filepath.Join(t.TempDir(), "same")
if err := os.Mkdir(other, 0o755); err != nil {
t.Fatal(err)
}
if out, _ := applyDirectory(&declaration.Directory{ID: "m.e", Type: declaration.TypeDirectory, Path: other, Mode: "0755"}, store.Applied{}, true); out.asFound {
t.Fatal("a found directory already as declared is the mesh's")
}
if action, _, err := remove(context.Background(), nil, store.Applied{Type: "directory", Target: dir, AsFound: true}, nil, nil); err != nil || action != "forgotten" {
t.Fatalf("a directory used as found is never removed: %s %v", action, err)
}
if _, err := os.Stat(dir); err != nil {
t.Fatal("it is still there")
}
}
+45 -7
View File
@@ -21,6 +21,9 @@ import (
// are a package plus configuration **in somebody's home** — so a mesh with no notion of a user
// can manage /etc and nothing anybody looks at.
// FirstLoginUID is the first uid of a person's login on the distributions the mesh runs on (login.defs UID_MIN).
const FirstLoginUID = 1000
// applyUser makes a login match what was declared.
//
// Reconciling, like everything else here: it is not told whether the user is new. Creating,
@@ -51,6 +54,18 @@ func applyUser(ctx context.Context, sys system.System, r *declaration.User, run
return out, err
}
// **An account that must never become root is never a system account taken over** (novox/hq ADR 0266).
// The controller cannot read this machine's user database, so it cannot tell that a name it was given is a
// service's own (postgres, a module's daemon). Taking one over as the agents' account would hand agents
// that service's files and rights. Refused before anything is touched.
if r.Root == declaration.RootNever && exists {
if uid, err := strconv.Atoi(login.UID); err != nil || uid < FirstLoginUID {
return out, fmt.Errorf("%q is declared as an account that never becomes root, and it already exists "+
"here as a system account (uid %s, below %d): it is not taken over; name another account",
r.Name, login.UID, FirstLoginUID)
}
}
// **A shell is refused before anything is touched** (novox/hq issue 228). Refused after the
// account was created or its groups changed, the account would be half the declaration's; a
// refusal fails this resource and leaves the account exactly as it was.
@@ -302,7 +317,7 @@ func own(path, owner string) error {
if err != nil {
return fmt.Errorf("%s should belong to %q: %w", path, owner, err)
}
if err := os.Chown(path, uid, gid); err != nil {
if err := chownPath(path, uid, gid); err != nil {
return fmt.Errorf("cannot give %s to %q: %w", path, owner, err)
}
return nil
@@ -359,7 +374,7 @@ func ownedBy(path, owner string) (bool, error) {
if err != nil {
return false, nil
}
info, err := os.Stat(path)
info, err := statPath(path)
if err != nil {
return false, err
}
@@ -392,6 +407,15 @@ func makeDirs(dir string, mode os.FileMode, owner string) error {
// can take away on removal the parents it made to reach its directory (novox/hq issue 162).
func makeDirsSaying(dir string, mode os.FileMode, owner string) ([]string, error) {
var made []string
if below := homeAbove(dir); below != "" {
// Below a home, each directory is made in its parent's descriptor and none is reached through a link
// (novox/hq ADR 0266).
var err error
if made, err = mkdirAllUnder(below, dir, mode); err != nil {
return made, err
}
return made, giveMade(made, owner)
}
for d := filepath.Clean(dir); ; d = filepath.Dir(d) {
if _, err := os.Lstat(d); !errors.Is(err, os.ErrNotExist) {
break
@@ -404,14 +428,19 @@ func makeDirsSaying(dir string, mode os.FileMode, owner string) ([]string, error
if err := os.MkdirAll(dir, mode); err != nil {
return nil, err
}
return made, giveMade(made, owner)
}
// giveMade gives the directories the host made inside the owner's home to the owner.
func giveMade(made []string, owner string) error {
if owner == "" || len(made) == 0 {
return made, nil
return nil
}
home, err := homeOf(owner)
if err != nil || home == "" {
// A numeric owner — a container's user — has no home, and a name the machine does not
// know fails where the target is given to it. Either way nothing here is a home's.
return made, nil
return nil
}
home = filepath.Clean(home)
for _, d := range made {
@@ -419,10 +448,10 @@ func makeDirsSaying(dir string, mode os.FileMode, owner string) ([]string, error
continue
}
if err := ownMade(d, owner); err != nil {
return made, err
return err
}
}
return made, nil
return nil
}
// homeOf is an owner's home from the user database, and ownMade gives a directory the host made to
@@ -444,10 +473,19 @@ func ownAll(root, owner string) error {
if owner == "" {
return nil
}
return filepath.Walk(root, func(path string, _ os.FileInfo, err error) error {
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// A link in the tree is owned as a link: chown follows one, and root must never give away what it
// points at (novox/hq ADR 0266).
if info != nil && info.Mode()&os.ModeSymlink != 0 {
uid, gid, ierr := idsOf(owner)
if ierr != nil {
return fmt.Errorf("%s should belong to %q: %w", path, owner, ierr)
}
return os.Lchown(path, uid, gid)
}
return own(path, owner)
})
}
+29
View File
@@ -292,3 +292,32 @@ func TestTheFoundShellIsNotOverwrittenByASecondChange(t *testing.T) {
t.Errorf("given back %q, not the shell from before the mesh", l.shells["operator"])
}
}
// An account declared never to become root is never a system account taken over (novox/hq ADR 0266): the
// controller cannot tell a service's account from a free name, so the node-engine refuses it, touching nothing.
func TestARootNeverAccountIsNotASystemAccountTakenOver(t *testing.T) {
l := &logins{shells: map[string]string{}}
run := func(ctx context.Context, name string, args ...string) (string, error) {
if name == "getent" && args[len(args)-1] == "postgres" {
l.asked = append(l.asked, name+" "+strings.Join(args, " "))
return "postgres:x:70:70::/var/lib/postgres:/usr/bin/nologin\n", nil
}
return l.run(ctx, name, args...)
}
declared := func(name string) string {
return `{"declaration":1,"resources":[{"id":"claude-code.agent-account","type":"user","name":"` + name + `","root":"never"}]}`
}
report, _, err := Apply(context.Background(), archHost(t), parse(t, declared("postgres")), store.State{},
store.OriginDeclared, run, nil, nil)
if err == nil || !strings.Contains(err.Error(), "system account") {
t.Fatalf("a system account is refused as the agents' account: %v %+v", err, report)
}
if l.did("usermod") || l.did("useradd") {
t.Fatalf("nothing is changed on the refused account: %v", l.asked)
}
l.shells["agent"] = "/bin/bash" // uid 1500 in the fake: a login
if _, _, err := Apply(context.Background(), archHost(t), parse(t, declared("agent")), store.State{},
store.OriginDeclared, run, nil, nil); err != nil {
t.Fatalf("a login's account is taken: %v", err)
}
}
+20
View File
@@ -383,8 +383,25 @@ type User struct {
// has it not. On the account rather than on the unit, because it is the account's: two units of
// one account cannot disagree about it, and undeclaring one of them must not stop the other.
Linger *bool `json:"linger,omitempty"`
// Root says whether this account may become root without a person (novox/hq ADR 0266). "never"
// is the agents' own account: the login an agent session runs as on a machine where it must not
// reach root by itself. Empty asserts nothing, as Shell's does.
//
// **A statement the engine judges, never one it acts on.** The apply gives an account it creates
// no password, no sudo rule and no group beyond those declared, as it always has, and takes none
// away from one it finds: a sudo rule or a group granted by hand is a person's to remove, and a
// declaration that silently stripped them would be the mesh deciding what a person's machine
// grants. What "never" adds is the look: on every look the engine reads whether the account can
// become root by itself — by its uid, a group that grants root, any sudo rule, or a secret the mesh
// placed that it can read — and says it unhealthy while it can (internal/accounts), so the
// controller can tell a machine where it holds from one where it does not.
Root string `json:"root,omitempty"`
}
// RootNever is the one value Root takes besides empty: the account never becomes root without a person.
const RootNever = "never"
// Network is a named network on this machine.
//
// **A name and nothing else.** Not a driver, a subnet or a gateway: each of those is something a
@@ -478,6 +495,9 @@ func (u *User) validate(where string, _ bool) []string {
if u.Home != "" && !strings.HasPrefix(u.Home, "/") {
problems = append(problems, where+": a home directory is an absolute path")
}
if u.Root != "" && u.Root != RootNever {
problems = append(problems, fmt.Sprintf("%s: root is %q or absent, and %q is neither", where, RootNever, u.Root))
}
return problems
}
+26
View File
@@ -0,0 +1,26 @@
package declaration
import (
"strings"
"testing"
)
// A user's root (novox/hq ADR 0266): "never" or absent, and nothing else.
func TestAUsersRootIsNeverOrAbsent(t *testing.T) {
for _, c := range []struct {
root string
ok bool
}{{`,"root":"never"`, true}, {``, true}, {`,"root":"always"`, false}, {`,"root":"no"`, false}} {
d, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"claude-code.agent","type":"user","name":"agent"` +
c.root + `}]}`))
if c.ok != (err == nil) {
t.Errorf("%s: err %v", c.root, err)
}
if err != nil && !strings.Contains(err.Error(), `"never"`) {
t.Errorf("%s: the refusal does not name the allowed value: %v", c.root, err)
}
if c.ok && c.root != "" && d.Resources[0].(*User).Root != RootNever {
t.Errorf("%s: read as %+v", c.root, d.Resources[0])
}
}
}
+10
View File
@@ -227,6 +227,12 @@ const LivenessContract = 1
// older host parses strictly and refuses the whole declaration for it.
const ReadinessContract = 2
// RootContract is the statement of a host that also reads a user's declared `root` and judges it (novox/hq
// ADR 0266): whether an account declared never to become root without a person can. Like the field before
// it, its presence is what tells the controller this host may be sent `root` on a user; an older host
// refuses the whole declaration for a field it does not know.
const RootContract = 3
// Health is one statement of every long-running resource's state on this machine (to-be 48 §4). Said in
// every report, as an event on each change, and again every minute while anything is not healthy — so a
// lost statement is not a lost fault.
@@ -355,6 +361,10 @@ type ResourceHealth struct {
// controller tell a unit that cannot run before a new login from one that is broken. Empty for anything
// the machine's own manager or a container runtime runs.
Account string `json:"account,omitempty"`
// Root is "never" on a verdict of kind KindAccount for an account declared never to become root without
// a person (novox/hq ADR 0266): healthy then also means the engine found no way for it to. Empty on
// every other verdict, and on an account judged for its groups alone.
Root string `json:"root,omitempty"`
}
// HealthSaid is the health event: a machine's statement between its reports, on HealthSubject.
+3
View File
@@ -142,6 +142,9 @@ func TestTheWireFormatIsExactlyTheseFieldNames(t *testing.T) {
[]string{"module", "resource", "kind", "target", "state", "reason", "since", "streak", "restarts",
"check", "needs"}},
{HealthSaid{Node: "n"}, []string{"node", "health"}},
// novox/hq ADR 0266: an account judged for whether it can become root without a person.
{ResourceHealth{Module: "m", Resource: "m.agent", Kind: KindAccount, Account: "agent", Root: "never"},
[]string{"module", "resource", "kind", "target", "state", "since", "account", "root"}},
// novox/hq ADR 0241: the machine's own networking, beside its resources.
{Health{Contract: ReadinessContract, Resources: []ResourceHealth{}, Network: &NetworkHealth{State: "unhealthy",
Parts: []NetworkPart{}}}, []string{"contract", "at", "resources", "network"}},
+5
View File
@@ -131,6 +131,11 @@ type Applied struct {
// anything else in the directory, and leaves it in place.
Unpacked *Unpacked `json:"unpacked,omitempty"`
// AsFound is, for a directory, that it was there before the mesh first applied it and was not the
// declared owner and mode (novox/hq ADR 0266): the mesh uses it as found, never changing its owner or
// mode, and never removes it. Absent on a record from before: such a directory is the mesh's.
AsFound bool `json:"as_found,omitempty"`
// Reads is, for a container, the digest of each file it was created reading — its env-files
// and the files mounted into it — by path (novox/hq 04-ISSUES/103).
//