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).
163 lines
5.4 KiB
Go
163 lines
5.4 KiB
Go
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)
|
|
}
|