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