diff --git a/internal/apply/agent_home_guard.go b/internal/apply/agent_home_guard.go new file mode 100644 index 0000000..960d1dc --- /dev/null +++ b/internal/apply/agent_home_guard.go @@ -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 +} diff --git a/internal/apply/agent_home_guard_test.go b/internal/apply/agent_home_guard_test.go new file mode 100644 index 0000000..d263ed3 --- /dev/null +++ b/internal/apply/agent_home_guard_test.go @@ -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) + } +} diff --git a/internal/apply/apply.go b/internal/apply/apply.go index b53c8d5..3b106ec 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -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 diff --git a/internal/apply/home_links.go b/internal/apply/home_links.go index d62d789..7c359c9 100644 --- a/internal/apply/home_links.go +++ b/internal/apply/home_links.go @@ -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 }