From 5fc37b44a94e12d3e4d4576ae11e9d010a9f976a Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 20:36:46 +0200 Subject: [PATCH] Follow no link below a home as root, and judge more ways to root An account could replace ~/.claude with a link to /etc and have the node-engine chown, chmod or write through it on the next apply. Below a person's or an agent's home every component is now opened without following a link, and a link refuses the resource in words. The root judge also reads doas and polkit rules, the container runtimes' sockets with their ACLs, ACLs on the secrets, and setuid-root programs no package owns, in the C locale; Judged and NotJudged write down exactly what it covers (hq ADR 0266 review). --- cmd/mesh-host/main.go | 2 +- go.mod | 2 +- internal/accounts/exec.go | 43 ++- internal/accounts/root_test.go | 53 +++- internal/accounts/ways.go | 434 ++++++++++++++++++++++++++++++ internal/accounts/ways_test.go | 171 ++++++++++++ internal/apply/apply.go | 40 ++- internal/apply/home_links.go | 162 +++++++++++ internal/apply/home_links_test.go | 205 ++++++++++++++ internal/apply/homes_linux.go | 178 ++++++++++++ internal/apply/homes_other.go | 53 ++++ internal/apply/user.go | 37 ++- 12 files changed, 1347 insertions(+), 33 deletions(-) create mode 100644 internal/accounts/ways.go create mode 100644 internal/accounts/ways_test.go create mode 100644 internal/apply/home_links.go create mode 100644 internal/apply/home_links_test.go create mode 100644 internal/apply/homes_linux.go create mode 100644 internal/apply/homes_other.go diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 29af743..69e4a39 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -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 diff --git a/go.mod b/go.mod index 2c02e75..fbdc81b 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,11 @@ go 1.26.0 require ( github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 + golang.org/x/sys v0.48.0 ) require ( github.com/klauspost/compress v1.20.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect - golang.org/x/sys v0.48.0 // indirect ) diff --git a/internal/accounts/exec.go b/internal/accounts/exec.go index 7108028..5720521 100644 --- a/internal/accounts/exec.go +++ b/internal/accounts/exec.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" "syscall" + "time" ) // Runner runs a command and answers what it printed — the apply's own (apply.ExecRunner). @@ -27,6 +28,14 @@ type Exec struct { 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. @@ -35,9 +44,11 @@ type FileMode struct { Perm fs.FileMode } -// Escalation is every way account can become root without a person (novox/hq ADR 0266): its uid, a group -// of RootGroups the user database lists it in, any sudo rule naming it or a group of it, and any of secrets -// it can read by owner, group or other bits. A secret not there yet is skipped: there is nothing to read. +// 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) { @@ -70,17 +81,17 @@ func (e Exec) Escalation(ctx context.Context, account string, secrets []string) 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 { - 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 - } - } stat := e.Stat if stat == nil { stat = statOf @@ -98,7 +109,11 @@ func (e Exec) Escalation(ctx context.Context, account string, secrets []string) } } } - return ways, nil + more, err := e.moreWays(ctx, account, uid, names, gids, secrets) + if err != nil { + return nil, err + } + return append(ways, more...), nil } // Readable is whether an account of uid, in the groups gids, reads a file of m by its permission bits, as diff --git a/internal/accounts/root_test.go b/internal/accounts/root_test.go index a888145..07f4713 100644 --- a/internal/accounts/root_test.go +++ b/internal/accounts/root_test.go @@ -27,6 +27,47 @@ type agentMachine struct { 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 +} + +func (m *agentMachine) readFile(path string) ([]byte, error) { + if t, ok := m.texts[path]; ok { + return []byte(t), nil + } + return nil, fs.ErrNotExist +} + +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) { @@ -43,6 +84,13 @@ func (m *agentMachine) run(_ context.Context, name string, args ...string) (stri return m.gids + "\n", nil case line == "sudo -l -U agent": return m.sudo, m.sudoErr + case name == "find": + 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) } @@ -66,14 +114,15 @@ func clean() *agentMachine { func lookAgent(t *testing.T, m *agentMachine) Verdict { t.Helper() - j := New(Exec{Run: m.run, Stat: m.stat}) + 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" { + if !strings.HasPrefix(a, "id ") && a != "sudo -l -U agent" && !strings.HasPrefix(a, "find / -xdev") && + !strings.HasPrefix(a, "pacman -Qqo ") { t.Errorf("the judge asked something that is not a read: %q", a) } } diff --git a/internal/accounts/ways.go b/internal/accounts/ways.go new file mode 100644 index 0000000..062e531 --- /dev/null +++ b/internal/accounts/ways.go @@ -0,0 +1,434 @@ +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", + "write access to a container runtime's socket (docker, podman, containerd), by owner, group, other or " + + "POSIX ACL", + "read access to a secret the mesh placed for another account, by owner, group, other or POSIX ACL", + "a setuid- or setgid-root program that no installed package owns, anywhere on the root filesystem", +} + +// 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)", +} + +// 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 +} + +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 the root filesystem, for Every: the judge +// looks every minute, and a setuid program appears only by root's act. +type SetuidCache struct { + Every time.Duration + mu sync.Mutex + at time.Time + found []string + err error +} + +func (c *SetuidCache) get(now time.Time, search func() ([]string, error)) ([]string, error) { + if c == nil { + return search() + } + c.mu.Lock() + defer c.mu.Unlock() + if c.at.IsZero() || now.Sub(c.at) >= c.Every || c.err != nil { + c.found, c.err = search() + c.at = now + } + return c.found, c.err +} + +// 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) ([]string, error) { + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + out, err := e.Run(ctx, "find", "/", "-xdev", "(", "-path", "/proc", "-o", "-path", "/sys", "-o", + "-path", "/var/lib/docker", "-o", "-path", "/var/lib/containers", ")", "-prune", "-o", + "-type", "f", "-user", "root", "-perm", "/6000", "-print") + 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 []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) + } + 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) + } + } + + // 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 + } + unowned, err := e.Cache.get(now(), func() ([]string, error) { return e.setuidSearch(ctx) }) + if err != nil { + return nil, err + } + for _, p := range unowned { + ways = append(ways, "a setuid-root program no package owns: "+p) + } + return ways, nil +} + +// walkFiles calls fn for every regular file below dir, through readDir. +func walkFiles(readDir func(string) ([]fs.DirEntry, error), dir string, fn func(string) error) error { + entries, err := readDir(dir) + if err != nil { + return err + } + for _, en := range entries { + p := filepath.Join(dir, en.Name()) + if en.IsDir() { + if err := walkFiles(readDir, p, fn); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + continue + } + if err := fn(p); err != nil { + return err + } + } + return nil +} + +// CLocale runs a command in the C locale, so what the judge parses — sudo's listing above all — is one +// language whatever the machine's is; stdin closed, output captured. +func CLocale(ctx context.Context, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C", "LANGUAGE=C") + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + var exit *exec.ExitError + if errors.As(err, &exit) { + return string(out), fmt.Errorf("%s exited %d: %s", name, exit.ExitCode(), strings.TrimSpace(stderr.String())) + } + return string(out), fmt.Errorf("%s: %w", name, err) + } + return string(out), nil +} diff --git a/internal/accounts/ways_test.go b/internal/accounts/ways_test.go new file mode 100644 index 0000000..16a83b1 --- /dev/null +++ b/internal/accounts/ways_test.go @@ -0,0 +1,171 @@ +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}, + {"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 TestTheSetuidSearchIsKeptForItsInterval(t *testing.T) { + c := &SetuidCache{Every: time.Hour} + searched := 0 + search := func() ([]string, error) { searched++; return nil, nil } + at := time.Date(2026, 10, 8, 19, 0, 0, 0, time.UTC) + c.get(at, search) + c.get(at.Add(30*time.Minute), search) + c.get(at.Add(61*time.Minute), search) + if searched != 2 { + t.Fatalf("searched %d times in 61 minutes, want 2", searched) + } +} + +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") + } +} diff --git a/internal/apply/apply.go b/internal/apply/apply.go index be49645..e26adcf 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -918,6 +918,13 @@ 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). + switch r.(type) { + case *declaration.Directory, *declaration.File, *declaration.Archive: + if err := refuseLinksUnderHome(r.Target()); err != nil { + return begin(r), err + } + } switch res := r.(type) { case *declaration.Directory: return applyDirectory(res) @@ -972,7 +979,7 @@ func applyDirectory(r *declaration.Directory) (Outcome, error) { 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 @@ -989,12 +996,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 := chmodPath(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 +1143,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 +1164,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 +1196,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 +1267,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 +1627,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 diff --git a/internal/apply/home_links.go b/internal/apply/home_links.go new file mode 100644 index 0000000..8cf9a24 --- /dev/null +++ b/internal/apply/home_links.go @@ -0,0 +1,162 @@ +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 +} + +// statPath is os.Stat, except below a home, where it never follows a link. +func statPath(path string) (os.FileInfo, error) { + if homeAbove(path) != "" { + return os.Lstat(path) + } + return os.Stat(path) +} + +// chmodPath sets a mode; below a home through a descriptor that followed no link. +func chmodPath(path string, mode os.FileMode) error { + home := homeAbove(path) + if home == "" { + return os.Chmod(path, mode) + } + return chmodUnder(home, path, mode) +} + +// chownPath sets an owner; below a home through a descriptor that followed no link, and a link itself is +// owned as a link. +func chownPath(path string, uid, gid int) error { + home := homeAbove(path) + if home == "" { + return os.Chown(path, uid, gid) + } + if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { + return os.Lchown(path, uid, gid) + } + return chownUnder(home, path, uid, gid) +} + +// readPath reads a file; below a home without following a link. +func readPath(path string) ([]byte, error) { + home := homeAbove(path) + if home == "" { + return os.ReadFile(path) + } + return readUnder(home, path) +} diff --git a/internal/apply/home_links_test.go b/internal/apply/home_links_test.go new file mode 100644 index 0000000..e74a4f7 --- /dev/null +++ b/internal/apply/home_links_test.go @@ -0,0 +1,205 @@ +//go:build linux + +package apply + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "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") + } +} diff --git a/internal/apply/homes_linux.go b/internal/apply/homes_linux.go new file mode 100644 index 0000000..b70f818 --- /dev/null +++ b/internal/apply/homes_linux.go @@ -0,0 +1,178 @@ +//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} +} + +// openUnder opens the file or directory at path below home, following no link, for its metadata. +func openUnder(home, path string) (int, error) { + dir, err := openDirUnder(home, filepath.Dir(path)) + if err != nil { + return -1, err + } + defer unix.Close(dir) + fd, err := unix.Openat(dir, filepath.Base(path), unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0) + if err != nil { + return -1, linkOr(path, home, path, "open", err) + } + return fd, nil +} + +func chmodUnder(home, path string, mode os.FileMode) error { + fd, err := openUnder(home, path) + 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 := openUnder(home, path) + if err != nil { + return nil, err + } + f := os.NewFile(uintptr(fd), path) + defer f.Close() + var st unix.Stat_t + if err := unix.Fstat(fd, &st); err == nil && st.Mode&unix.S_IFMT != unix.S_IFREG { + return nil, fmt.Errorf("%s is not a regular file", path) + } + return io.ReadAll(f) +} + +// mkdirAllUnder makes the directories missing below home down to dir, each made in its parent's descriptor +// and opened without following a link; answers those it made, deepest first, as makeDirsSaying does. +func mkdirAllUnder(home, dir string, mode os.FileMode) ([]string, error) { + rel, err := filepath.Rel(home, filepath.Clean(dir)) + if err != nil || strings.HasPrefix(rel, "..") { + return nil, fmt.Errorf("%s is not below %s", dir, home) + } + fd, err := unix.Open(home, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) + if err != nil { + return nil, &os.PathError{Op: "open", Path: home, Err: err} + } + defer func() { unix.Close(fd) }() + var made []string + if rel == "." { + return nil, nil + } + at := home + for _, part := range strings.Split(rel, string(os.PathSeparator)) { + at = filepath.Join(at, part) + if err := unix.Mkdirat(fd, part, uint32(mode.Perm())); err == nil { + made = append([]string{at}, made...) + } else if !errors.Is(err, unix.EEXIST) { + return made, &os.PathError{Op: "mkdir", Path: at, Err: err} + } + next, err := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return made, linkOr(at, home, dir, "open", err) + } + unix.Close(fd) + fd = next + } + return made, nil +} + +// writeUnder writes a file below home atomically, through its directory's descriptor: made O_EXCL|O_NOFOLLOW +// under a name of its own, given its mode, and renamed over the file within that directory. +func writeUnder(home, path string, content []byte, mode os.FileMode) error { + dir, err := openDirUnder(home, filepath.Dir(path)) + if err != nil { + return err + } + defer unix.Close(dir) + name := ".mesh-host-" + strconv.FormatInt(time.Now().UnixNano(), 36) + "-" + strconv.Itoa(os.Getpid()) + fd, err := unix.Openat(dir, name, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o600) + if err != nil { + return &os.PathError{Op: "create", Path: filepath.Join(filepath.Dir(path), name), Err: err} + } + f := os.NewFile(uintptr(fd), name) + _, werr := f.Write(content) + if werr == nil { + werr = f.Sync() + } + if werr == nil { + if err := unix.Fchmod(fd, uint32(mode.Perm())); err != nil { + werr = &os.PathError{Op: "chmod", Path: path, Err: err} + } + } + if cerr := f.Close(); werr == nil { + werr = cerr + } + if werr == nil { + if err := unix.Renameat(dir, name, dir, filepath.Base(path)); err != nil { + werr = &os.PathError{Op: "rename", Path: path, Err: err} + } + } + if werr != nil { + _ = unix.Unlinkat(dir, name, 0) + } + return werr +} diff --git a/internal/apply/homes_other.go b/internal/apply/homes_other.go new file mode 100644 index 0000000..9e97a80 --- /dev/null +++ b/internal/apply/homes_other.go @@ -0,0 +1,53 @@ +//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 chownUnder(home, path string, uid, gid int) error { + if err := refuseLinksUnderHome(path); err != nil { + return err + } + return os.Lchown(path, uid, gid) +} + +func readUnder(home, path string) ([]byte, error) { + if err := refuseLinksUnderHome(path); err != nil { + return nil, err + } + return os.ReadFile(path) +} + +func mkdirAllUnder(home, dir string, mode os.FileMode) ([]string, error) { + if err := refuseLinksUnderHome(dir); err != nil { + return nil, err + } + var made []string + for d := filepath.Clean(dir); d != home; d = filepath.Dir(d) { + if _, err := os.Lstat(d); err == nil { + break + } + made = append(made, d) + } + return made, os.MkdirAll(dir, mode) +} + +func writeUnder(home, path string, content []byte, mode os.FileMode) error { + if err := refuseLinksUnderHome(path); err != nil { + return err + } + return writeAtomicallyByPath(path, content, mode) +} diff --git a/internal/apply/user.go b/internal/apply/user.go index 7ef0689..8926690 100644 --- a/internal/apply/user.go +++ b/internal/apply/user.go @@ -302,7 +302,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 +359,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 +392,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 +413,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 +433,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 +458,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) }) }