diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index f5b47ec..9737ac0 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1111,7 +1111,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 @@ -1578,9 +1578,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, @@ -1626,15 +1627,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 } diff --git a/cmd/mesh-host/units_test.go b/cmd/mesh-host/units_test.go index e5c6654..d679d22 100644 --- a/cmd/mesh-host/units_test.go +++ b/cmd/mesh-host/units_test.go @@ -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) + } +} diff --git a/go.mod b/go.mod index 2c02e75..fbdc81b 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/internal/accounts/accounts.go b/internal/accounts/accounts.go index 740c22c..5e9cf4f 100644 --- a/internal/accounts/accounts.go +++ b/internal/accounts/accounts.go @@ -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, ¬Judged) { + reason += "; " + firstLine(err.Error()) + } + return Unhealthy, reason + case errors.As(err, ¬Judged): + // 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()) diff --git a/internal/accounts/exec.go b/internal/accounts/exec.go index ed256c4..67ededc 100644 --- a/internal/accounts/exec.go +++ b/internal/accounts/exec.go @@ -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, ¬Judged) { + 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 ` 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. diff --git a/internal/accounts/root_test.go b/internal/accounts/root_test.go new file mode 100644 index 0000000..a738238 --- /dev/null +++ b/internal/accounts/root_test.go @@ -0,0 +1,280 @@ +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) + // pacmanCalls counts the package manager's calls: one for every path found. + pacmanCalls int +} + +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] == "-Qo": + m.pacmanCalls++ + var out, errs []string + for _, p := range args[1:] { + if m.packaged[p] { + out = append(out, p+" is owned by somepackage 1.0-1") + } else { + errs = append(errs, "error: No package owns "+p) + } + } + said := strings.Join(out, "\n") + if len(errs) > 0 { + return said, errors.New("pacman exited 1: " + strings.Join(errs, "\n")) + } + return said, nil + } + 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 -Qo ") { + 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) + } +} diff --git a/internal/accounts/ways.go b/internal/accounts/ways.go new file mode 100644 index 0000000..9aa525c --- /dev/null +++ b/internal/accounts/ways.go @@ -0,0 +1,800 @@ +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" + "github.com/novox/mesh-host/rootsearch" + "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 — the whole search, the walk and the package +// manager's answers together (rootsearch.Bound, which the controller reads too); MaxBackoff the longest wait +// after failures. +const ( + SearchBound = rootsearch.Bound + 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 = rootsearch.ReasonPending + ReasonIncomplete = rootsearch.ReasonIncomplete +) + +// 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 by its caller (SearchBound, the whole search): +// a search that does not finish is an unanswered question, never "none". A walk find could not complete — it +// exited with an error — is incomplete too, unless every error was a file that vanished while it walked. +func (e Exec) setuidSearch(ctx context.Context, mounts []string) ([]string, error) { + // 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 %s", SearchBound) + } + if err != nil && (strings.TrimSpace(out) == "" || !onlyVanished(err)) { + return nil, fmt.Errorf("the search for setuid programs did not complete its walk: %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) + return e.unpackaged(ctx, paths) +} + +// onlyVanished says every error find reported was a file that went while it walked — the one error that leaves +// the walk complete for what still exists. find's own words come after its exit, one per line. +func onlyVanished(err error) bool { + said := false + for _, l := range errorLines(err, "find") { + said = true + if !strings.Contains(l, "No such file or directory") { + return false + } + } + return said +} + +// errorLines are the lines a command said on its error stream, as the runner words a failure: " exited N: +// ", the prefix taken off the first line. Empty lines are dropped. +func errorLines(err error, name string) []string { + words := err.Error() + if strings.HasPrefix(words, name+" exited") { + if i := strings.Index(words, ": "); i >= 0 { + words = words[i+2:] + } + } + var out []string + for _, l := range strings.Split(words, "\n") { + if l = strings.TrimSpace(l); l != "" { + out = append(out, l) + } + } + return out +} + +// unpackaged is every path no installed package owns: pacman asked about all of them in one call, else apk +// about each. Never a guess: a path neither said owned nor said unowned is an unanswered question. +func (e Exec) unpackaged(ctx context.Context, paths []string) ([]string, error) { + if len(paths) == 0 { + return nil, nil + } + out, err := e.Run(ctx, "pacman", append([]string{"-Qo"}, paths...)...) + if err == nil || !errors.Is(err, exec.ErrNotFound) && !errors.Is(err, fs.ErrNotExist) { + owned := map[string]bool{} + for _, l := range strings.Split(out, "\n") { + if p, _, ok := strings.Cut(strings.TrimSpace(l), " is owned by "); ok { + owned[p] = true + } + } + unowned := map[string]bool{} + if err != nil { + // pacman exits 1 when a path is owned by nothing, and says which on its error stream. + for _, l := range errorLines(err, "pacman") { + if _, p, ok := strings.Cut(l, "No package owns "); ok { + unowned[strings.TrimSpace(p)] = true + } else { + return nil, fmt.Errorf("pacman could not say who owns the setuid programs found: %s", l) + } + } + } + var out []string + for _, p := range paths { + switch { + case owned[p]: + case unowned[p]: + out = append(out, p) + default: + return nil, fmt.Errorf("pacman said nothing of who owns %s", p) + } + } + return out, nil + } + var none []string + for _, p := range paths { + said, err := e.Run(ctx, "apk", "info", "-W", p) + if err != nil { + return nil, fmt.Errorf("no package manager could say who owns %s: %w", p, err) + } + if !strings.Contains(said, " is owned by ") { + none = append(none, p) + } + } + return none, 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, ¬Judged) { + 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 +} diff --git a/internal/accounts/ways_test.go b/internal/accounts/ways_test.go new file mode 100644 index 0000000..b62de60 --- /dev/null +++ b/internal/accounts/ways_test.go @@ -0,0 +1,390 @@ +package accounts + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "github.com/novox/mesh-host/rootsearch" + "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") + } +} + +// The search's bound is the one the controller reads (rootsearch.Bound): a search may run its whole fifteen +// minutes, with no shorter limit inside it that would end every long walk incomplete (the review of 2026-10-09). +func TestTheSearchRunsToItsBoundWithNoShorterLimitInside(t *testing.T) { + if rootsearch.Bound != 15*time.Minute { + t.Fatalf("the shared bound is %s", rootsearch.Bound) + } + m := clean() + var left time.Duration + run := func(ctx context.Context, name string, args ...string) (string, error) { + if name == "find" { + if d, ok := ctx.Deadline(); ok { + left = time.Until(d) + } + } + return m.run(ctx, name, args...) + } + ctx, cancel := context.WithTimeout(t.Context(), SearchBound) + defer cancel() + if _, err := (Exec{Run: run}).setuidSearch(ctx, []string{"/"}); err != nil { + t.Fatal(err) + } + if left < SearchBound-time.Minute { + t.Fatalf("find was given %s, less than the search's bound of %s", left, SearchBound) + } +} + +// The package manager is asked about every path found in one call, and answers each: owned, or owned by nothing. +func TestThePackageManagerIsAskedOnceForEveryPathFound(t *testing.T) { + m := clean() + m.setuid = "/usr/bin/sudo\n/usr/bin/passwd\n/usr/local/bin/x\n/opt/y\n" + m.packaged = map[string]bool{"/usr/bin/sudo": true, "/usr/bin/passwd": true} + found, err := (Exec{Run: m.run}).setuidSearch(t.Context(), []string{"/"}) + if err != nil { + t.Fatal(err) + } + if m.pacmanCalls != 1 { + t.Errorf("pacman was asked %d times for four paths", m.pacmanCalls) + } + if strings.Join(found, ",") != "/opt/y,/usr/local/bin/x" { + t.Errorf("unowned: %v", found) + } + // An error pacman says that is not "owned by nothing" is no answer. + odd := func(ctx context.Context, name string, args ...string) (string, error) { + if name == "pacman" { + return "", errors.New("pacman exited 1: error: could not open database") + } + return m.run(ctx, name, args...) + } + if _, err := (Exec{Run: odd}).setuidSearch(t.Context(), []string{"/"}); err == nil { + t.Error("a package manager that could not answer was read as an answer") + } +} + +// find that exits with an error has not walked everything: the search is incomplete, unless every error was a +// file that vanished while it walked. +func TestAWalkFindDidNotCompleteIsIncomplete(t *testing.T) { + m := clean() + m.setuid = "/usr/local/bin/x\n" + m.findErr = errors.New("find exited 1: find: '/mnt/data/private': Permission denied") + if _, err := (Exec{Run: m.run}).setuidSearch(t.Context(), []string{"/"}); err == nil { + t.Fatal("a walk that could not enter a directory was read as complete") + } + m.findErr = errors.New("find exited 1: find: '/tmp/a': No such file or directory\nfind: '/tmp/b': No such file or directory") + found, err := (Exec{Run: m.run}).setuidSearch(t.Context(), []string{"/"}) + if err != nil || len(found) != 1 { + t.Fatalf("files that vanished while find walked: %v %v", found, err) + } +} diff --git a/internal/apply/agent_home_guard.go b/internal/apply/agent_home_guard.go new file mode 100644 index 0000000..960d1dc --- /dev/null +++ b/internal/apply/agent_home_guard.go @@ -0,0 +1,76 @@ +package apply + +// What is never placed below an agent's home (novox/hq ADR 0266 §7). +// +// An account that never becomes root owns every directory in its home, so it can swap a link into the path +// between the engine's check and the engine's write. The engine writes a whole file there through a +// descriptor that followed no link (home_links.go), but it unpacks an archive, and writes into an existing +// file, by path. So below the home of an account the declaration says never becomes root, neither is placed. +// The catalogue's own check (checks/agenthome) refuses either under `${machine:agent-home}`, so no module +// asks; this is the engine refusing it again where it applies. The machine's own trees, other accounts' +// homes and a directory's owner below a home are placement_guard.go's (novox/hq issue 339). + +import ( + "fmt" + "path/filepath" + + "github.com/novox/mesh-host/internal/declaration" +) + +// AgentHomeRefusedError is an archive or a written-into file the engine will not place below an agent's home. +type AgentHomeRefusedError struct { + Path, What, Home string +} + +func (e *AgentHomeRefusedError) Error() string { + return fmt.Sprintf("%s is not placed: %s below %s, 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 (novox/hq ADR 0266); "+ + "nothing was touched", e.Path, e.What, e.Home) +} + +// refuseUnderAgentHome says why a resource is not placed below one of agentHomes; nil when it may be. +func refuseUnderAgentHome(r declaration.Resource, agentHomes []string) error { + what := "" + switch res := r.(type) { + case *declaration.Archive: + what = "an archive unpacked" + case *declaration.File: + if res.Into != "" { + what = "a file written into (" + res.Into + ")" + } + } + if what == "" { + return nil + } + path := filepath.Clean(r.Target()) + for _, p := range []string{path, resolved(path)} { + for _, home := range agentHomes { + if atOrBelow(p, home) { + return &AgentHomeRefusedError{Path: path, What: what, Home: home} + } + } + } + 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 +} diff --git a/internal/apply/agent_home_guard_test.go b/internal/apply/agent_home_guard_test.go new file mode 100644 index 0000000..d263ed3 --- /dev/null +++ b/internal/apply/agent_home_guard_test.go @@ -0,0 +1,36 @@ +package apply + +// Defends novox/hq ADR 0266 §7: no archive is unpacked and no file is written into below the home of an account +// the declaration says never becomes root; a whole file there still passes. + +import ( + "testing" + + "github.com/novox/mesh-host/internal/declaration" +) + +func TestNothingIsUnpackedOrWrittenIntoBelowAnAgentsHome(t *testing.T) { + was := accountsOfHomes + accountsOfHomes = func() map[string]homeAccount { + return map[string]homeAccount{"/home/agent": {"agent", 1001}, "/home/operator": {"operator", 1000}} + } + t.Cleanup(func() { accountsOfHomes = was }) + agent := []string{"/home/agent"} + if err := refuseUnderAgentHome(&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 := refuseUnderAgentHome(&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 := refuseUnderAgentHome(&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) + } + if err := refuseUnderAgentHome(&declaration.Archive{ID: "a.x", Type: declaration.TypeArchive, Path: "/home/operator/x", Owner: "operator"}, agent); err != nil { + t.Errorf("an archive in a home whose account may become root is placement_guard.go's to judge, not this: %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) + } +} diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 00abb28..3b106ec 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -471,6 +471,7 @@ func ApplyMindingWindows( // which declared directories were already here: one this apply makes is the mesh's, one found may not be. refreshRuntimeRoots(ctx) in.refused = refusedPlaces(d.Resources) + in.agentHomes = rootNeverHomes(d) in.dirsBefore = map[string]bool{} for _, resource := range d.Resources { in.declares[resource.Identity()] = declaredDigest(resource) @@ -956,6 +957,17 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru return begin(r), err } } + // Nothing below a home is touched through a link an account put there (novox/hq ADR 0266). + // And nothing is unpacked or written into below an agent's home (agent_home_guard.go). + switch r.(type) { + case *declaration.Directory, *declaration.File, *declaration.Archive: + if err := refuseUnderAgentHome(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, previous, in.dirsBefore[filepath.Clean(res.Path)]) @@ -1072,12 +1084,12 @@ func applyDirectory(r *declaration.Directory, previous store.Applied, wasBefore // 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) } @@ -1219,7 +1231,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 @@ -1240,7 +1252,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) @@ -1272,20 +1284,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 } @@ -1343,7 +1355,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 @@ -1697,6 +1719,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 @@ -1944,6 +1973,9 @@ type inputs struct { // refused is every directory and access the engine will not place, by path, with why (novox/hq issue 339): // each fails, and a container mounting one fails with it. refused map[string]error + // 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 (agent_home_guard.go). + agentHomes []string // dirsBefore is every declared directory that was on the machine when this apply began (novox/hq issue // 339): one the apply itself made — a file's parent — is the mesh's, one that was there may not be. dirsBefore map[string]bool diff --git a/internal/apply/home_links.go b/internal/apply/home_links.go new file mode 100644 index 0000000..414076c --- /dev/null +++ b/internal/apply/home_links.go @@ -0,0 +1,161 @@ +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 ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +// homes is every home of a person's or an agent's account on this machine, as placement_guard.go reads them +// from the user database. A variable so a test can name the homes it made. +var homes = func() []string { + var out []string + for home := range accountsOfHomes() { + out = append(out, home) + } + // In one order: the user database is read into a map, and a map's order is not one. + sort.Strings(out) + 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) +} diff --git a/internal/apply/home_links_test.go b/internal/apply/home_links_test.go new file mode 100644 index 0000000..97a47e3 --- /dev/null +++ b/internal/apply/home_links_test.go @@ -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/agent,/home/operator,/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) + } +} diff --git a/internal/apply/homes_linux.go b/internal/apply/homes_linux.go new file mode 100644 index 0000000..58ad05a --- /dev/null +++ b/internal/apply/homes_linux.go @@ -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 +} diff --git a/internal/apply/homes_other.go b/internal/apply/homes_other.go new file mode 100644 index 0000000..64974f1 --- /dev/null +++ b/internal/apply/homes_other.go @@ -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) +} diff --git a/internal/apply/user.go b/internal/apply/user.go index 5eed355..44e227b 100644 --- a/internal/apply/user.go +++ b/internal/apply/user.go @@ -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) }) } diff --git a/internal/apply/user_test.go b/internal/apply/user_test.go index d3e4cf8..8459c46 100644 --- a/internal/apply/user_test.go +++ b/internal/apply/user_test.go @@ -294,3 +294,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) + } +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index a66d8ba..ea03cf5 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -389,8 +389,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 @@ -484,6 +501,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 } diff --git a/internal/declaration/root_test.go b/internal/declaration/root_test.go new file mode 100644 index 0000000..cd2908c --- /dev/null +++ b/internal/declaration/root_test.go @@ -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]) + } + } +} diff --git a/internal/link/messages.go b/internal/link/messages.go index 4016691..85a1ca2 100644 --- a/internal/link/messages.go +++ b/internal/link/messages.go @@ -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. @@ -363,6 +369,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. diff --git a/internal/link/messages_test.go b/internal/link/messages_test.go index c7f5371..d2cb235 100644 --- a/internal/link/messages_test.go +++ b/internal/link/messages_test.go @@ -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"}}, diff --git a/rootsearch/rootsearch.go b/rootsearch/rootsearch.go new file mode 100644 index 0000000..a1c70c0 --- /dev/null +++ b/rootsearch/rootsearch.go @@ -0,0 +1,19 @@ +// Package rootsearch holds what the node-engine's search for setuid programs and the controller that reads its +// verdicts must agree on (novox/hq ADR 0266): how long one search may run, and the words a verdict starts with +// while it has no answer. Public, so the controller imports these rather than keeps copies that could drift. +package rootsearch + +import "time" + +// Bound is how long one search for setuid programs may run. It governs the whole search, the walk and the +// package manager's answers together; the controller does not raise an agent account as not judged while the +// first search after a start is within it. +const Bound = 15 * time.Minute + +// The reasons of a root verdict the search could not answer yet. +const ( + // ReasonPending starts the reason while the first search since the engine started runs. + ReasonPending = "not judged yet (search running)" + // ReasonIncomplete starts the reason when no search has finished: one failed or ran out its bound. + ReasonIncomplete = "not judged (search incomplete)" +)