From a17b86cf930af9512684cf88544a97ee048b566c Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 21:16:29 +0200 Subject: [PATCH] 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. --- checks/agenthome/go.mod | 3 ++ checks/agenthome/main.go | 91 +++++++++++++++++++++++++++++++++++ checks/agenthome/main_test.go | 45 +++++++++++++++++ merge-check.sh | 5 ++ 4 files changed, 144 insertions(+) create mode 100644 checks/agenthome/go.mod create mode 100644 checks/agenthome/main.go create mode 100644 checks/agenthome/main_test.go diff --git a/checks/agenthome/go.mod b/checks/agenthome/go.mod new file mode 100644 index 00000000..58e4acf0 --- /dev/null +++ b/checks/agenthome/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-catalog/checks/agenthome + +go 1.22 diff --git a/checks/agenthome/main.go b/checks/agenthome/main.go new file mode 100644 index 00000000..53e41e18 --- /dev/null +++ b/checks/agenthome/main.go @@ -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 . +// +// 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") +} diff --git a/checks/agenthome/main_test.go b/checks/agenthome/main_test.go new file mode 100644 index 00000000..95b8dd30 --- /dev/null +++ b/checks/agenthome/main_test.go @@ -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) + } +} diff --git a/merge-check.sh b/merge-check.sh index 2b07c81f..4ac0bfef 100755 --- a/merge-check.sh +++ b/merge-check.sh @@ -22,9 +22,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=""