diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md index 541aa37c..d9cf9fe7 100644 --- a/modules/claude-code/README.md +++ b/modules/claude-code/README.md @@ -26,6 +26,13 @@ And, for where the agent runs as an account of its own (hq ADR 0266): - `/usr/local/bin/claude-agent`, root's, `0755`: starts the agent as that account — `claude` itself when run as it, otherwise `sudo -iu claude` through the operator's own sudo, carrying the guard's override only when it is set in the operator's shell. +- `/etc/claude-code/agent-account`, root's, `0644`: the name of that account, the machine fact + `agent-account`, for the shell to read — the controller fills no machine fact into shell code. +- **`claude` in the operator's interactive zsh is `claude-agent`** where the machine names an agent account: + zsh code in the login shell's `normal` slot (hq ADR 0204) reads that file and adds + `alias claude=claude-agent` only when it names an account other than the shell's own. Where agents run as + the operator's account the file names that account, and nothing is added. `claude-agent` still runs the + real `claude`: an alias is a word of an interactive zsh, and neither `exec`, `sudo` nor its `sh` sees it. ## What it writes diff --git a/modules/claude-code/cmd/claude-code/shell_test.go b/modules/claude-code/cmd/claude-code/shell_test.go new file mode 100644 index 00000000..9ce22880 --- /dev/null +++ b/modules/claude-code/cmd/claude-code/shell_test.go @@ -0,0 +1,193 @@ +package main + +// The agent's session from the operator's shell (novox/hq ADR 0266): where a machine names an account of the +// agents' own, `claude` typed in the operator's interactive zsh is `claude-agent`; where agents run as the +// operator's account, nothing is added. The manifest writes the account agents run as to a file, and the zsh +// code it contributes reads it: the controller never fills a machine fact into shell code. + +import ( + "encoding/json" + "os" + "os/exec" + "os/user" + "path/filepath" + "strings" + "testing" +) + +const agentAccountFile = "/etc/claude-code/agent-account" + +type manifestShell struct { + Resources []map[string]any `json:"resources"` + Shell []struct { + For string `json:"for"` + Slot string `json:"slot"` + Code string `json:"code"` + } `json:"shell"` +} + +func readManifestShell(t *testing.T) manifestShell { + t.Helper() + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m manifestShell + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +func resourceByID(t *testing.T, m manifestShell, id string) map[string]any { + t.Helper() + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + t.Fatalf("the manifest has no resource %q", id) + return nil +} + +// zshCode is the one piece of zsh code the module contributes. +func zshCode(t *testing.T, m manifestShell) string { + t.Helper() + var code []string + for _, s := range m.Shell { + if s.For == "zsh" { + code = append(code, s.Code) + } + } + if len(code) != 1 { + t.Fatalf("the module contributes %d pieces of zsh code; one is expected", len(code)) + } + return code[0] +} + +// The file names the account agents run as, from the machine fact, which is the operator's where none is named. +func TestTheAccountAgentsRunAsIsWrittenForTheShell(t *testing.T) { + r := resourceByID(t, readManifestShell(t), "agent-account-name") + if r["type"] != "file" || r["path"] != agentAccountFile || r["mode"] != "0644" { + t.Fatalf("the account's file is %v", r) + } + if r["content"] != "${machine:agent-account}\n" { + t.Fatalf("the account's file holds %q", r["content"]) + } + if _, has := r["owner"]; has { + t.Fatal("the account's file must be root's, as claude-agent is") + } +} + +// The alias stands once, and only inside the guard that compares the account with the shell's own. +func TestTheAliasStandsOnlyBehindTheGuard(t *testing.T) { + code := zshCode(t, readManifestShell(t)) + if n := strings.Count(code, "alias claude="); n != 1 { + t.Fatalf("the code names the alias %d times", n) + } + lines := strings.Split(code, "\n") + guard, alias, end := -1, -1, -1 + for i, l := range lines { + l = strings.TrimSpace(l) + switch { + case l == "if [[ -n $agent && $agent != $USERNAME ]]; then": + guard = i + case l == "alias claude=claude-agent": + alias = i + case l == "fi" && guard >= 0 && alias > guard && end < 0: + end = i + } + } + if guard < 0 || alias < 0 || end < 0 || !(guard < alias && alias < end) { + t.Fatalf("the alias is not inside the guard:\n%s", code) + } + if strings.Contains(code, "${") { + t.Fatal("the code holds ${…}, which the controller does not fill in shell code and zsh reads as its own") + } +} + +// Run in zsh, the code adds the alias only where the file names an account other than the shell's. +func TestTheCodeAddsTheAliasOnlyForAnotherAccount(t *testing.T) { + zsh, err := exec.LookPath("zsh") + if err != nil { + t.Skip("no zsh here") + } + me, err := user.Current() + if err != nil { + t.Fatal(err) + } + code := zshCode(t, readManifestShell(t)) + cases := []struct { + name string + content *string + alias bool + }{ + {"an agent account of its own", ptr("agent-of-its-own\n"), true}, + {"agents run as the operator's account", ptr(me.Username + "\n"), false}, + {"an empty file", ptr(""), false}, + {"no file", nil, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + file := filepath.Join(t.TempDir(), "agent-account") + if c.content != nil { + if err := os.WriteFile(file, []byte(*c.content), 0o644); err != nil { + t.Fatal(err) + } + } + script := strings.ReplaceAll(code, agentAccountFile, file) + "alias claude || true\n" + out, err := exec.Command(zsh, "-f", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("%v: %s", err, out) + } + got := strings.TrimSpace(string(out)) + if c.alias && got != "claude=claude-agent" { + t.Fatalf("no alias: %q", got) + } + if !c.alias && got != "" { + t.Fatalf("an alias, or output, where none belongs: %q", got) + } + }) + } +} + +// claude-agent runs the real claude, never itself: with the alias in force in the shell that types claude, +// claude-agent's own `exec claude` finds the program on PATH, since an alias is a word of an interactive zsh +// and neither exec, sudo nor claude-agent's sh sees it. +func TestClaudeAgentRunsTheRealClaudeUnderTheAlias(t *testing.T) { + zsh, err := exec.LookPath("zsh") + if err != nil { + t.Skip("no zsh here") + } + me, err := user.Current() + if err != nil { + t.Fatal(err) + } + start, _ := resourceByID(t, readManifestShell(t), "start")["content"].(string) + if !strings.Contains(start, "exec claude \"$@\"") { + t.Fatalf("claude-agent no longer runs claude itself:\n%s", start) + } + bin := t.TempDir() + // Rendered for an account that is the caller's own, the branch that runs claude without sudo. + // A line after the shebang says the call went through it. + agent := strings.ReplaceAll(start, "${machine:agent-account}", me.Username) + agent = strings.Replace(agent, "\n", "\necho through claude-agent\n", 1) + if err := os.WriteFile(filepath.Join(bin, "claude-agent"), []byte(agent), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bin, "claude"), []byte("#!/bin/sh\necho \"the real claude $*\"\n"), 0o755); err != nil { + t.Fatal(err) + } + // zsh reads a -c string whole before it runs any of it, so the typed line is read after the alias, by eval. + cmd := exec.Command(zsh, "-f", "-c", "alias claude=claude-agent\neval 'claude --resume'\n") + cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH")) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%v: %s", err, out) + } + if got := strings.TrimSpace(string(out)); got != "through claude-agent\nthe real claude --resume" { + t.Fatalf("claude under the alias ran %q", got) + } +} + +func ptr(s string) *string { return &s } diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index 1bd23230..b8870f52 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -91,6 +91,13 @@ "mode": "0755", "content": "#!/bin/sh\n# The mesh's (module claude-code, novox/hq ADR 0266): start the agent as the account agents run as on this\n# machine. Written whole at every push: an edit here is overwritten.\nagent='${machine:agent-account}'\nif [ \"$(id -un)\" = \"$agent\" ]; then\n\texec claude \"$@\"\nfi\n# Another account, the operator's, becomes the agent's through its own sudo: the person is the authority. The\n# operator's override of the guard travels only when it is set in this shell, as it would reach claude.\nif [ -n \"$MESH_GUARD_OVERRIDE\" ]; then\n\texec sudo -iu \"$agent\" env MESH_GUARD_OVERRIDE=\"$MESH_GUARD_OVERRIDE\" claude \"$@\"\nfi\nexec sudo -iu \"$agent\" claude \"$@\"\n" }, + { + "id": "agent-account-name", + "type": "file", + "path": "/etc/claude-code/agent-account", + "mode": "0644", + "content": "${machine:agent-account}\n" + }, { "id": "agent-account", "type": "user", @@ -130,6 +137,13 @@ "content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {},\n \"instructions\": {},\n \"output_styles\": {},\n \"skills\": {},\n \"commands\": {},\n \"agents\": {}\n}\n" } ], + "shell": [ + { + "for": "zsh", + "slot": "normal", + "code": "# The agent's session (module claude-code, novox/hq ADR 0266): where this machine names an account of the\n# agents' own, claude in an interactive zsh is claude-agent, which starts the session as that account. Where\n# agents run as the operator's account the file names that account, and nothing is added. claude-agent runs\n# the real claude: exec, sudo and its sh see no alias.\nif [[ -r /etc/claude-code/agent-account ]]; then\n\t() {\n\t\tlocal agent=$(