Refuse an archive or a file written into under the agent's home

The node-engine writes those as root after checking the path, and the agent
account owns its home and could swap a link in between (hq ADR 0266). No
module does it today; this keeps a future one from opening the gap.
This commit is contained in:
jochen
2026-10-09 10:14:25 +02:00
parent 9afea75f93
commit 87862f3bf5
4 changed files with 144 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/agenthome
go 1.22
+91
View File
@@ -0,0 +1,91 @@
// Command agenthome refuses, in every module's manifest, a resource placed under the agent account's home
// that the node-engine cannot write without a gap (novox/hq ADR 0266).
//
// The agent account owns its home and everything in it, so it can put a symbolic or hard link anywhere
// there. The node-engine, as root, writes a whole file or a directory below a home through descriptors it
// opened itself and refuses a link it finds; it unpacks an archive, and writes into an existing file (`into`:
// json or block), only after checking the path, so a link swapped in between the check and the write would
// point root's write at any file. No module does either today. This check keeps it so: a resource of type
// `archive`, or a `file` with `into`, whose path is under ${machine:agent-home}, is refused.
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// AgentHome is the machine fact naming the agent account's home.
const AgentHome = "${machine:agent-home}"
// Finding is one resource refused.
type Finding struct {
Module, ID, Why string
}
// Check reads every manifest under root/modules and answers what it refuses.
func Check(root string) ([]Finding, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
var out []Finding
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
for _, r := range m.Resources {
p, _ := r["path"].(string)
if !strings.HasPrefix(p, AgentHome) {
continue
}
id, _ := r["id"].(string)
kind, _ := r["type"].(string)
into, _ := r["into"].(string)
switch {
case kind == "archive":
out = append(out, Finding{m.Module, id, "an archive unpacked under the agent's home"})
case kind == "file" && into != "":
out = append(out, Finding{m.Module, id, "a file written into (" + into + ") under the agent's home"})
}
}
}
return out, nil
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, f := range found {
fmt.Printf("%s: %s is %s: the node-engine writes it as root after checking the path, and the agent "+
"account could swap a link in between (novox/hq ADR 0266); place a whole file or a directory instead\n",
f.Module, f.ID, f.Why)
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("agent-home: no archive and no file written into under the agent's home")
}
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func manifest(t *testing.T, root, module, resources string) {
t.Helper()
dir := filepath.Join(root, "modules", module)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
body := `{"module":"` + module + `","resources":[` + resources + `]}`
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestAnArchiveOrAFileWrittenIntoUnderTheAgentsHomeIsRefused(t *testing.T) {
root := t.TempDir()
manifest(t, root, "a", `{"id":"theme","type":"archive","path":"${machine:agent-home}/.local/share/x"}`)
manifest(t, root, "b", `{"id":"cfg","type":"file","path":"${machine:agent-home}/.claude.json","into":"json","content":"{}"}`)
manifest(t, root, "c", `{"id":"dir","type":"directory","path":"${machine:agent-home}/.claude"},`+
`{"id":"whole","type":"file","path":"${machine:agent-home}/x","content":"y"},`+
`{"id":"op","type":"archive","path":"${machine:account-home}/.local/share/x"}`)
found, err := Check(root)
if err != nil {
t.Fatal(err)
}
if len(found) != 2 || found[0].Module != "a" || found[1].Module != "b" {
t.Fatalf("refused %+v", found)
}
}
func TestTheCatalogueHasNone(t *testing.T) {
found, err := Check("../..")
if err != nil {
t.Fatal(err)
}
if len(found) != 0 {
t.Fatalf("%+v", found)
}
}
+5
View File
@@ -24,9 +24,14 @@
# 4. the paths (novox/hq ADR 0260): no module names a path under /run, /var/lib, /usr/local/lib or /etc
# that another module's manifest places — modules talk over the bus, not through each other's files.
# Over every module; checks/paths/shared and checks/paths/known narrow it, and known only shrinks.
# 5. the agent's home (novox/hq ADR 0266): no module unpacks an archive, or writes into a file, under
# ${machine:agent-home} — the node-engine does both after checking the path, and the agent account owns
# that home and could swap a link in between.
set -eu
(cd checks/words && go test -count=1 . && go run . ../..)
# The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it.
(cd checks/agenthome && go test -count=1 . && go run . ../..)
# The lists that narrow the paths check may not grow unseen: the base branch's copies are what they are
# judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in.
base_lists=""