Files
mesh-host/internal/apply/home_links.go
T
jochen e2e8be72fa Keep main's placement guard, and refuse only an archive or a written-into file below an agent's home
Issue 339's guard (#63/#64/#65) now refuses the machine's own trees, another account's
directory below a home, and uses a found directory as found; this branch's own copy of
those rules goes. What stays is the one rule only an account that never becomes root
needs: below its home it owns every parent and could swap a link in between the
engine's check and an unpack or a write-into (hq ADR 0266 §7). One reader of the user
database serves both.
2026-10-09 10:10:05 +02:00

160 lines
5.8 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 (
"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)
}
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)
}