From 6d32975ca6ac43a1f1f76293af438cf2e2f0df33 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 21:46:18 +0200 Subject: [PATCH] Refuse the agent-home gap reached through a module's own directory or a literal home A path written as ${dir:} can resolve under the agent's home, and a literal /home path may be it; both escaped the check (hq ADR 0266). --- checks/agenthome/main.go | 37 +++++++++++++++++++++++++++++++++-- checks/agenthome/main_test.go | 6 +++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/checks/agenthome/main.go b/checks/agenthome/main.go index 53e41e18..b8a06d95 100644 --- a/checks/agenthome/main.go +++ b/checks/agenthome/main.go @@ -6,7 +6,9 @@ // 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. +// `archive`, or a `file` with `into`, whose path is under ${machine:agent-home} — written so, reached through +// one of the module's own directories (${dir:}), or written as a literal /home path — is refused. The +// node-engine refuses the same below the home of any account declared never to become root, whatever the path. // // go run . // @@ -18,6 +20,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "sort" "strings" ) @@ -25,6 +28,29 @@ import ( // AgentHome is the machine fact naming the agent account's home. const AgentHome = "${machine:agent-home}" +// ofDir is where a manifest names one of its own directories: ${dir:}. +var ofDir = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9_-]*)\}`) + +// resolve replaces each ${dir:} with that resource's own path, as far as the manifest says it, so a path +// reaching the agent's home through one of the module's directories is seen for what it is. +func resolve(p string, dirs map[string]string) string { + for i := 0; i < 8 && ofDir.MatchString(p); i++ { + p = ofDir.ReplaceAllStringFunc(p, func(word string) string { + if to, ok := dirs[ofDir.FindStringSubmatch(word)[1]]; ok { + return to + } + return word + }) + } + return p +} + +// underAHome says whether a resolved path lies under the agent's home: by its machine fact, or written as a +// literal home, which no definition may carry anyway (ADR 0112) and which may well be the agent's. +func underAHome(p string) bool { + return strings.HasPrefix(p, AgentHome) || strings.HasPrefix(p, "/home/") || p == "/home" +} + // Finding is one resource refused. type Finding struct { Module, ID, Why string @@ -50,9 +76,16 @@ func Check(root string) ([]Finding, error) { if err := json.Unmarshal(raw, &m); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } + dirs := map[string]string{} + for _, r := range m.Resources { + id, _ := r["id"].(string) + if p, _ := r["path"].(string); id != "" && p != "" { + dirs[id] = p + } + } for _, r := range m.Resources { p, _ := r["path"].(string) - if !strings.HasPrefix(p, AgentHome) { + if !underAHome(resolve(p, dirs)) { continue } id, _ := r["id"].(string) diff --git a/checks/agenthome/main_test.go b/checks/agenthome/main_test.go index 95b8dd30..0d0368f1 100644 --- a/checks/agenthome/main_test.go +++ b/checks/agenthome/main_test.go @@ -25,11 +25,15 @@ func TestAnArchiveOrAFileWrittenIntoUnderTheAgentsHomeIsRefused(t *testing.T) { 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"}`) + manifest(t, root, "d", `{"id":"home","type":"directory","path":"${machine:agent-home}/.claude"},`+ + `{"id":"inner","type":"directory","path":"${dir:home}/inner"},`+ + `{"id":"via-dir","type":"archive","path":"${dir:inner}/theme"}`) + manifest(t, root, "e", `{"id":"literal","type":"file","path":"/home/agent/.bashrc","into":"block","content":"x"}`) found, err := Check(root) if err != nil { t.Fatal(err) } - if len(found) != 2 || found[0].Module != "a" || found[1].Module != "b" { + if len(found) != 4 || found[0].Module != "a" || found[1].Module != "b" || found[2].ID != "via-dir" || found[3].ID != "literal" { t.Fatalf("refused %+v", found) } }