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.
This commit is contained in:
jochen
2026-10-09 10:10:05 +02:00
parent d4af8bba1b
commit e2e8be72fa
4 changed files with 124 additions and 29 deletions
+76
View File
@@ -0,0 +1,76 @@
package apply
// What is never placed below an agent's home (novox/hq ADR 0266 §7).
//
// An account that never becomes root owns every directory in its home, so it can swap a link into the path
// between the engine's check and the engine's write. The engine writes a whole file there through a
// descriptor that followed no link (home_links.go), but it unpacks an archive, and writes into an existing
// file, by path. So below the home of an account the declaration says never becomes root, neither is placed.
// The catalogue's own check (checks/agenthome) refuses either under `${machine:agent-home}`, so no module
// asks; this is the engine refusing it again where it applies. The machine's own trees, other accounts'
// homes and a directory's owner below a home are placement_guard.go's (novox/hq issue 339).
import (
"fmt"
"path/filepath"
"github.com/novox/mesh-host/internal/declaration"
)
// AgentHomeRefusedError is an archive or a written-into file the engine will not place below an agent's home.
type AgentHomeRefusedError struct {
Path, What, Home string
}
func (e *AgentHomeRefusedError) Error() string {
return fmt.Sprintf("%s is not placed: %s below %s, 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 (novox/hq ADR 0266); "+
"nothing was touched", e.Path, e.What, e.Home)
}
// refuseUnderAgentHome says why a resource is not placed below one of agentHomes; nil when it may be.
func refuseUnderAgentHome(r declaration.Resource, agentHomes []string) error {
what := ""
switch res := r.(type) {
case *declaration.Archive:
what = "an archive unpacked"
case *declaration.File:
if res.Into != "" {
what = "a file written into (" + res.Into + ")"
}
}
if what == "" {
return nil
}
path := filepath.Clean(r.Target())
for _, p := range []string{path, resolved(path)} {
for _, home := range agentHomes {
if atOrBelow(p, home) {
return &AgentHomeRefusedError{Path: path, What: what, Home: home}
}
}
}
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
}
+36
View File
@@ -0,0 +1,36 @@
package apply
// Defends novox/hq ADR 0266 §7: no archive is unpacked and no file is written into below the home of an account
// the declaration says never becomes root; a whole file there still passes.
import (
"testing"
"github.com/novox/mesh-host/internal/declaration"
)
func TestNothingIsUnpackedOrWrittenIntoBelowAnAgentsHome(t *testing.T) {
was := accountsOfHomes
accountsOfHomes = func() map[string]homeAccount {
return map[string]homeAccount{"/home/agent": {"agent", 1001}, "/home/operator": {"operator", 1000}}
}
t.Cleanup(func() { accountsOfHomes = was })
agent := []string{"/home/agent"}
if err := refuseUnderAgentHome(&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 := refuseUnderAgentHome(&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 := refuseUnderAgentHome(&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)
}
if err := refuseUnderAgentHome(&declaration.Archive{ID: "a.x", Type: declaration.TypeArchive, Path: "/home/operator/x", Owner: "operator"}, agent); err != nil {
t.Errorf("an archive in a home whose account may become root is placement_guard.go's to judge, not this: %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)
}
}
+8
View File
@@ -471,6 +471,7 @@ func ApplyMindingWindows(
// which declared directories were already here: one this apply makes is the mesh's, one found may not be.
refreshRuntimeRoots(ctx)
in.refused = refusedPlaces(d.Resources)
in.agentHomes = rootNeverHomes(d)
in.dirsBefore = map[string]bool{}
for _, resource := range d.Resources {
in.declares[resource.Identity()] = declaredDigest(resource)
@@ -957,8 +958,12 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru
}
}
// Nothing below a home is touched through a link an account put there (novox/hq ADR 0266).
// And nothing is unpacked or written into below an agent's home (agent_home_guard.go).
switch r.(type) {
case *declaration.Directory, *declaration.File, *declaration.Archive:
if err := refuseUnderAgentHome(r, in.agentHomes); err != nil {
return begin(r), err
}
if err := refuseLinksUnderHome(r.Target()); err != nil {
return begin(r), err
}
@@ -1968,6 +1973,9 @@ type inputs struct {
// refused is every directory and access the engine will not place, by path, with why (novox/hq issue 339):
// each fails, and a container mounting one fails with it.
refused map[string]error
// 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 (agent_home_guard.go).
agentHomes []string
// dirsBefore is every declared directory that was on the machine when this apply began (novox/hq issue
// 339): one the apply itself made — a file's parent — is the mesh's, one that was there may not be.
dirsBefore map[string]bool
+4 -29
View File
@@ -26,46 +26,21 @@ package apply
// 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.
// 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 {
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)
}
for home := range accountsOfHomes() {
out = append(out, home)
}
return out
}