From b1cb9542cca2e7ccad779e0f40ec2d4f4f78f246 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 21:50:23 +0200 Subject: [PATCH] Refuse a placement at the machine's own directories, and use a found directory as found A module's places setting can move a directory anywhere, and the engine chowned whatever it was pointed at as root: a directory at /etc owned by the agent account would hand it /etc (hq ADR 0266). Refuse the machine's roots, the kernel's and the engine's trees, another account's home, and an archive or written-into file below an agent's home; and leave the owner and mode of a directory the mesh did not make. --- internal/apply/apply.go | 57 ++++++- internal/apply/placement_guard.go | 217 +++++++++++++++++++++++++ internal/apply/placement_guard_test.go | 141 ++++++++++++++++ internal/store/store.go | 5 + 4 files changed, 417 insertions(+), 3 deletions(-) create mode 100644 internal/apply/placement_guard.go create mode 100644 internal/apply/placement_guard_test.go diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 0f99c69..f0f236b 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -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 { @@ -919,15 +928,19 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru 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: @@ -972,7 +985,15 @@ 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 { @@ -987,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 { @@ -1647,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 @@ -1874,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. diff --git a/internal/apply/placement_guard.go b/internal/apply/placement_guard.go new file mode 100644 index 0000000..fe73ed6 --- /dev/null +++ b/internal/apply/placement_guard.go @@ -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/ 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 +} diff --git a/internal/apply/placement_guard_test.go b/internal/apply/placement_guard_test.go new file mode 100644 index 0000000..4fb10dd --- /dev/null +++ b/internal/apply/placement_guard_test.go @@ -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") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 90f0bb3..35c95a6 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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). //