package apply // A link under a person's home is never followed as root (novox/hq ADR 0266, the review of 2026-10-08). // // The node-engine runs as root and places files and directories under homes: the operator's shell // configuration, the agent's `~/.claude`. Everything below a home is the account's to change. An account that // replaces `~/.claude` with a symbolic link to /etc, between two applies, would have root change the owner and // mode of /etc, or write a file into it, on the next one — and on a machine whose agents run as an account of // their own, that account is exactly the one that must not become root. // // So for a path strictly below a home: // // - **a symbolic link in any component below the home, the path's own included, refuses the resource**, said // in words, and nothing is done to it — before anything is read, written, chowned or chmodded; // - **the owner and mode are set through a descriptor opened without following a link**, each component // opened from the one above it (homes_linux.go), so a link put in place after the check is not followed // either; a link that is itself the thing to own is owned as a link, never its target; // - **a file is written through its directory's descriptor**, made with O_EXCL|O_NOFOLLOW and renamed within // that directory, so neither the file nor a parent can be swapped for a link between check and write. // // The home itself is the machine's, made by root, and is followed as it is. A path outside every home is // handled exactly as before: a module's system paths may be links the machine set up (a resolver's file is). // // Which homes: every account the user database lists with a uid of 1000 or more, other than the overflow // account, and every home under /home. A service account's home under /var/lib is a module's own directory, // and is left as it was. import ( "errors" "fmt" "io/fs" "os" "path/filepath" "sort" "strings" ) // homes is every home of a person's or an agent's account on this machine, as placement_guard.go reads them // from the user database. A variable so a test can name the homes it made. var homes = func() []string { var out []string for home := range accountsOfHomes() { out = append(out, home) } // In one order: the user database is read into a map, and a map's order is not one. sort.Strings(out) return out } // homeAbove is the home a path lies strictly below, the deepest when homes nest; "" when none. func homeAbove(path string) string { path = filepath.Clean(path) hs := homes() sort.Slice(hs, func(i, j int) bool { return len(hs[i]) > len(hs[j]) }) for _, h := range hs { if strings.HasPrefix(path, h+string(os.PathSeparator)) { return h } } return "" } // LinkUnderHomeError is a resource refused because a component below a home is a symbolic link. type LinkUnderHomeError struct { Path, Link, Home string } func (e *LinkUnderHomeError) Error() string { return fmt.Sprintf("%s is a symbolic link inside the home %s, and the node-engine runs as root: it never "+ "follows a link an account can put there, so nothing was done to %s (novox/hq ADR 0266). Make %s a "+ "directory or file again, or ask why something replaced it", e.Link, e.Home, e.Path, e.Link) } // refuseLinksUnderHome refuses a path below a home when any component below the home, its own included, is a // symbolic link. Components that do not exist yet end the walk: what is missing is made without following. func refuseLinksUnderHome(path string) error { home := homeAbove(path) if home == "" { return nil } rel, err := filepath.Rel(home, filepath.Clean(path)) if err != nil { return err } at := home for _, part := range strings.Split(rel, string(os.PathSeparator)) { at = filepath.Join(at, part) info, err := os.Lstat(at) if errors.Is(err, fs.ErrNotExist) { return nil } if err != nil { return err } if info.Mode()&os.ModeSymlink != 0 { return &LinkUnderHomeError{Path: path, Link: at, Home: home} } } return nil } // HardLinkUnderHomeError is a regular file below a home with more than one link: the account that owns the // home may have linked a file it does not own there, and root acting on it by name would act on that file // (novox/hq ADR 0266). Refused, whatever the resource. type HardLinkUnderHomeError struct { Path string Links uint64 } func (e *HardLinkUnderHomeError) Error() string { return fmt.Sprintf("%s has %d links: below a home a file with more than one is not owned, chmodded or read "+ "as root, since one of its names may be a file the home's account does not own", e.Path, e.Links) } // chmodDirPath is chmodPath for a directory resource: below a home, what is there must be a directory. func chmodDirPath(path string, mode os.FileMode) error { home := homeAbove(path) if home == "" { return os.Chmod(path, mode) } return chmodUnderAs(home, path, mode, kindDir) } // statPath is os.Stat, except below a home, where it never follows a link. func statPath(path string) (os.FileInfo, error) { if homeAbove(path) != "" { return os.Lstat(path) } return os.Stat(path) } // chmodPath sets a mode; below a home through a descriptor that followed no link. func chmodPath(path string, mode os.FileMode) error { home := homeAbove(path) if home == "" { return os.Chmod(path, mode) } return chmodUnder(home, path, mode) } // chownPath sets an owner; below a home through a descriptor that followed no link, and a link itself is // owned as a link. func chownPath(path string, uid, gid int) error { home := homeAbove(path) if home == "" { return os.Chown(path, uid, gid) } if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { return os.Lchown(path, uid, gid) } return chownUnder(home, path, uid, gid) } // readPath reads a file; below a home without following a link. func readPath(path string) ([]byte, error) { home := homeAbove(path) if home == "" { return os.ReadFile(path) } return readUnder(home, path) }