diff --git a/.gitignore b/.gitignore index 01c8bb0b..cc53768f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ modules/mesh-watcher/cmd/mesh-watcher/mesh-watcher modules/claude-code/cmd/claude-code/claude-code # ...and the same built from the module's root (go build ./cmd/), which names it after the module. modules/messenger/messenger +modules/claude-code/claude-code modules/mesh-watcher/mesh-watcher diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md index cbdde015..5adbcd6c 100644 --- a/modules/claude-code/README.md +++ b/modules/claude-code/README.md @@ -60,7 +60,7 @@ The agent kept reaching for `ssh journalctl` while the service manager each verb with the commands it replaces. Generated, never written by hand: a verb that gains `replaces` is in the next render. Ordered by what the guard here refused most, then the machines' seats, the modules, the mesh's seats; at most sixteen rows, the rest one `mesh_search` away. -- **The guard.** A PreToolUse hook on `Bash`, `Edit`, `Write`, `MultiEdit` and `NotebookEdit`: this module's own +- **The guard.** A PreToolUse hook on every tool, judging `Bash`, `Edit`, `Write`, `MultiEdit` and `NotebookEdit` and letting the rest through (but see the account rule below): this module's own binary (`claude-code guard`), copied root's into the plugin with what it judges with (`guard.json`: the machines by name, domain and address, and the replaced commands), run by its path under `/etc/claude-code` so the session cannot change it. It refuses `ssh`, `scp`, `sftp`, `rsync`, `mosh` and `autossh` to a mesh machine @@ -182,11 +182,51 @@ Remove these by hand, once; the mesh removes nothing it did not make (ADR 0182): - `~/.claude/skills/cleanup/`, `~/.claude/skills/hal-switch-license/` - the hand-made console entry in `~/.claude.json` under `mcpServers` — it is ignored now anyway +## Where the agent runs as an account of its own (hq ADR 0266) + +A machine may name an **agent account** beside the operator's, at the controller's terminal +(`mesh-controller node agent-account `); the control node does. Agents there run as that +account, which cannot become root without a person, and the operator keeps their own account and its sudo. +The runtime hands this module `MESH_AGENT_ACCOUNT` and `MESH_AGENT_HOME` — the operator's where none is +named, and read as the operator's from a controller that sets neither — and everything it writes for the +agent goes to that account's home: + +- the licence token (`~/.claude/.credentials.json`) and the account it is logged in as (`~/.claude.json`); +- an API-key licence and its key-helper, in `~/.claude/mesh/`, since the account cannot enter the + module's state; +- the items registered at the home scope, recorded per home (`home-placed-.json`). + +The runtime's account cannot enter that home (0700, the agent's), so a path under it is touched by a +**helper running as the agent's account**: this module's binary as the plugin carries it +(`/etc/claude-code/marketplace/plugins/nox-mesh/guard/guard home-fs`, root's), started once through +`sudo -n -u ` and kept, one JSON request and answer per line. The escalation goes down only: the +runtime's account may become the agent's, never the other way. + +When a machine moves its agent to such an account, the licence follows at once — a binding counts as +applied only while the agent's account holds it — and what this module placed under the operator's home +goes: the access-token-only credentials file (one holding a refresh token is a login the person made, and +stays, said), the home-scope items unless changed by hand, the key kept in the state. + +**The guard there refuses every tool call of a session that does not run as the agent's account** — +shell, file edits, reads and the mesh's tools alike — and no override lifts it: the operator's account may become root, and an +agent started in it by habit must do nothing. Its record goes to the machine's journal +(`journalctl -t mesh-claude-code-guard`), which the session's account may add to and not change; +`claude_code_guard` reads it there, keeping only the lines the journal says the guard's own binary wrote. That +is not proof: the account can run the guard's binary itself and add lines in the guard's words. What it +cannot do is remove one. + +**An API key is taken only from the agent's own home.** `claude_code_add_api_key` reads a file and removes it, +and the runtime runs as the operator: a path anywhere would hand whoever calls it the runtime's credential or +the operator's keys. So the file must lie under the home of the account the agent runs as, reached through no +symbolic link, is read as that account, must be at most 4 KiB and hold an Anthropic API key (`sk-ant-…`) — +otherwise nothing is handed over and nothing removed, and the refusal never repeats what the file holds. The +licence manager refuses a key of another shape too. + ## Escalation Writing `/etc/claude-code` needs root. The runtime runs as the operator account, and the module uses that account's passwordless `sudo`; on a machine without it, `claude_code_render` says so and nothing -is written. +is written. The same sudo starts the helper as the agent's account, where there is one. ## Code diff --git a/modules/claude-code/cmd/claude-code/agent_account.go b/modules/claude-code/cmd/claude-code/agent_account.go new file mode 100644 index 00000000..003d4bff --- /dev/null +++ b/modules/claude-code/cmd/claude-code/agent_account.go @@ -0,0 +1,90 @@ +package main + +// Where the agent runs as an account of its own (novox/hq ADR 0266). +// +// The control node names an agent account beside the operator's: agents run there as an account that cannot +// become root without a person. The controller hands this module both — MESH_AGENT_ACCOUNT and its home, and +// the operator's — and everything this module writes for the agent goes to the agent's: the licence token, the +// account it is logged in as, an API key and its helper, the items registered at the home scope (homefs.go). +// +// Two things follow when a machine moves its agents to such an account: +// +// - **the licence follows at once.** The binding applied is the same, so nothing would fetch the token again +// until the licence rotates; the agent's new home would hold none. A binding counts as applied only while +// the account the agent runs as holds it. +// - **what the module placed in the operator's home goes** (ADR 0182: the mesh owns what it places). The +// credentials file the module wrote there holds an access token and never a refresh token — so a file that +// holds no refresh token is the module's, and is removed — only once the agent's own home holds the licence, +// so a move that has not fetched it yet leaves the operator's in place — with the API key and helper it +// kept in its state. +// A file holding a refresh token is a login the person made there: theirs, left, and said. The items placed +// at the home scope there go as unregistering takes them — each unless the person changed it since. + +import ( + "os" + "path/filepath" +) + +// holdsApplied says whether the account the agent runs as holds a licence of this kind. +func holdsApplied(p Paths, kind string) bool { + if kind == "api-key" { + _, err := fsys.Stat(p.apiKey()) + return err == nil + } + return GrantOf(ReadCredentials(p.credentials())) != nil +} + +// holdsAnyLicence says whether the agent's account holds a token or an API key now. +func holdsAnyLicence(p Paths) bool { + return holdsApplied(p, "subscription") || holdsApplied(p, "api-key") +} + +// RetireOperatorHome takes back what this module placed for the agent under the operator's home, once the agent +// runs as an account of its own; answers what it did, or nothing. +func RetireOperatorHome(p Paths) []string { + if !p.Confined() || p.OperatorHome == "" || filepath.Clean(p.OperatorHome) == filepath.Clean(p.Home) { + return nil + } + var out []string + file := filepath.Join(p.OperatorHome, ".claude", ".credentials.json") + if creds := ReadCredentials(file); creds != nil { + switch { + case HoldsLogin(creds): + out = append(out, "left "+file+": it holds a login the person made, which is theirs; the agent here runs as "+ + p.Agent+" and no longer reads it") + case GrantOf(creds) != nil && !holdsAnyLicence(p): + out = append(out, "kept the token this module placed in "+file+" until "+p.Agent+"'s home holds the licence") + case GrantOf(creds) != nil: + if err := os.Remove(file); err != nil { + out = append(out, "the token this module placed in "+file+" could not be removed: "+err.Error()) + } else { + out = append(out, "removed the token this module placed in "+file+": the agent here runs as "+p.Agent) + } + } + } + // The items placed at the home scope in the operator's home: each removed unless the person changed it, + // as unregistering would; they are placed in the agent's home now, from a record of its own. + record := filepath.Join(p.State, "home-placed.json") + if _, err := os.Stat(record); err == nil { + done, left := placeHomeIn(filepath.Join(p.OperatorHome, ".claude"), record, nil) + for _, line := range done { + out = append(out, "operator's home "+line) + } + for _, line := range left { + out = append(out, "operator's home "+line) + } + var still Placed + if readJSON(record, &still) && len(still) == 0 { + _ = os.Remove(record) + } + } + for _, name := range []string{"api-key", "api-key-helper"} { + old := filepath.Join(p.State, name) + if _, err := os.Stat(old); err == nil { + if err := os.Remove(old); err == nil { + out = append(out, "removed "+old+": the key and its helper are kept where "+p.Agent+" reads them") + } + } + } + return out +} diff --git a/modules/claude-code/cmd/claude-code/agent_account_test.go b/modules/claude-code/cmd/claude-code/agent_account_test.go new file mode 100644 index 00000000..33a938ac --- /dev/null +++ b/modules/claude-code/cmd/claude-code/agent_account_test.go @@ -0,0 +1,455 @@ +package main + +// Where the agent runs as an account of its own (novox/hq ADR 0266): the home touched through the helper, the +// licence that follows the account, what goes from the operator's home, and the guard that refuses a session +// running as anyone else. + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "io" + "io/fs" + "net" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" +) + +// inProcess is a helper served in this process over pipes, counting what it was asked; kill ends it, as a +// helper that died would. +type inProcess struct { + asked atomic.Int64 + mu sync.Mutex + kill func() +} + +func (c *inProcess) die() { + c.mu.Lock() + defer c.mu.Unlock() + c.kill() +} + +func helperInProcess(account string, counted *inProcess) *Helper { + return &Helper{Account: account, Start: func() (io.WriteCloser, io.Reader, func(), error) { + reqR, reqW := io.Pipe() + ansR, ansW := io.Pipe() + counting := &countingReader{r: reqR, n: &counted.asked} + go func() { _ = ServeHelper(counting, ansW); _ = ansW.Close() }() + counted.mu.Lock() + counted.kill = func() { _ = reqR.Close(); _ = ansW.Close() } + counted.mu.Unlock() + return reqW, ansR, func() { _ = reqW.Close() }, nil + }} +} + +type countingReader struct { + r io.Reader + n *atomic.Int64 +} + +func (c *countingReader) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + c.n.Add(int64(bytes.Count(p[:n], []byte("\n")))) + return n, err +} + +func agentFiles(t *testing.T) (*Files, string, *inProcess) { + t.Helper() + home := filepath.Join(t.TempDir(), "agent") + if err := os.MkdirAll(home, 0o700); err != nil { + t.Fatal(err) + } + counted := &inProcess{} + return &Files{Home: home, Account: "agent", Helper: helperInProcess("agent", counted)}, home, counted +} + +func TestTheAgentsHomeIsTouchedThroughTheHelper(t *testing.T) { + f, home, counted := agentFiles(t) + file := filepath.Join(home, ".claude", "skills", "x", "SKILL.md") + if err := f.MkdirAll(filepath.Dir(file), 0o755); err != nil { + t.Fatal(err) + } + if err := f.WriteFile(file, []byte("hello"), 0o600); err != nil { + t.Fatal(err) + } + if got, err := f.ReadFile(file); err != nil || string(got) != "hello" { + t.Fatalf("read back %q, %v", got, err) + } + info, err := f.Stat(file) + if err != nil || info.Size() != 5 || info.Mode().Perm() != 0o600 || info.IsDir() { + t.Fatalf("stat: %v %v", info, err) + } + if err := f.Chmod(file, 0o644); err != nil { + t.Fatal(err) + } + if info, _ := f.Lstat(file); info.Mode().Perm() != 0o644 { + t.Fatalf("chmod did not take: %v", info.Mode()) + } + if err := f.CreateExclusive(file, []byte("again"), 0o600); !errors.Is(err, fs.ErrExist) { + t.Fatalf("a file that is there is said to exist, got %v", err) + } + if _, err := f.ReadFile(filepath.Join(home, "absent")); !os.IsNotExist(err) { + t.Fatalf("an absent file is said not to exist, as the filesystem says it, got %v", err) + } + entries, err := f.ReadDir(filepath.Join(home, ".claude", "skills")) + if err != nil || len(entries) != 1 || entries[0].Name() != "x" || !entries[0].IsDir() { + t.Fatalf("readdir: %v %v", entries, err) + } + var walked []string + if err := f.WalkDir(filepath.Join(home, ".claude"), func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(home, path) + walked = append(walked, rel) + return nil + }); err != nil { + t.Fatal(err) + } + if strings.Join(walked, ",") != ".claude,.claude/skills,.claude/skills/x,.claude/skills/x/SKILL.md" { + t.Fatalf("walked %v", walked) + } + moved := filepath.Join(home, "moved") + if err := f.Rename(file, moved); err != nil { + t.Fatal(err) + } + if err := f.Remove(moved); err != nil { + t.Fatal(err) + } + if err := f.RemoveAll(filepath.Join(home, ".claude")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(home, ".claude")); !os.IsNotExist(err) { + t.Fatal("remove-all left the directory") + } + if counted.asked.Load() < 12 { + t.Fatalf("every operation under the home goes through the helper; it was asked %d times", counted.asked.Load()) + } +} + +func TestAPathOutsideTheAgentsHomeIsNeverTheHelpers(t *testing.T) { + f, home, counted := agentFiles(t) + state := t.TempDir() + if err := f.WriteFile(filepath.Join(state, "x"), []byte("y"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := f.ReadFile(filepath.Join(state, "x")); err != nil { + t.Fatal(err) + } + // A sibling whose name begins with the home's is not under it. + if f.through(home+"-other/x") || !f.through(home) || !f.through(home+"/.claude.json") { + t.Fatal("the home is the home and nothing beside it") + } + if err := f.Rename(filepath.Join(state, "x"), filepath.Join(home, "x")); err == nil { + t.Fatal("a move between the home and elsewhere is refused: no one account may do both") + } + if counted.asked.Load() != 0 { + t.Fatalf("the helper was asked %d times for paths that are not the agent's", counted.asked.Load()) + } + if (&Files{}).through(home) { + t.Fatal("with no agent account of its own, nothing goes through a helper") + } +} + +func TestAHelperThatDiedIsStartedAgain(t *testing.T) { + f, home, counted := agentFiles(t) + if err := f.WriteFile(filepath.Join(home, "a"), []byte("1"), 0o600); err != nil { + t.Fatal(err) + } + counted.die() + if got, err := f.ReadFile(filepath.Join(home, "a")); err != nil || string(got) != "1" { + t.Fatalf("after the helper died the next request is answered by a new one: %q %v", got, err) + } +} + +func TestTheHelperRefusesARelativePathAndAnUnknownOperation(t *testing.T) { + if a := serveOne(Op{Op: "read", Path: "relative"}); a.Err == "" { + t.Fatal("a relative path is refused") + } + if a := serveOne(Op{Op: "exec", Path: "/"}); !strings.Contains(a.Err, "no such operation") { + t.Fatalf("only the filesystem's operations are served, got %+v", a) + } +} + +// ---- the paths -------------------------------------------------------------------------------------------- + +func envOf(m map[string]string) func(string) string { return func(k string) string { return m[k] } } + +func TestTheAgentsAccountAndHomeComeFromTheRuntime(t *testing.T) { + base := map[string]string{"MESH_CLAUDE_CODE_STATE": "/s", "MESH_CLAUDE_CODE_FACTS": "/s/f", "MESH_CLAUDE_CODE_SETTINGS": "/s/x", + "MESH_NODE": "anchor", "MESH_OPERATOR_ACCOUNT": "operator", "MESH_OPERATOR_HOME": "/home/operator"} + p, ok := PathsFrom(envOf(base)) + if !ok || p.Home != "/home/operator" || p.Agent != "operator" || p.Confined() { + t.Fatalf("a controller that names no agent account: the operator's, as before; got %+v", p) + } + with := map[string]string{"MESH_AGENT_ACCOUNT": "agent", "MESH_AGENT_HOME": "/home/agent"} + for k, v := range base { + with[k] = v + } + p, ok = PathsFrom(envOf(with)) + if !ok || p.Home != "/home/agent" || p.Agent != "agent" || p.OperatorHome != "/home/operator" || !p.Confined() { + t.Fatalf("an agent account of its own: its home; got %+v", p) + } + if p.keyDir() != "/home/agent/.claude/mesh" || p.placed() != "/s/home-placed-agent.json" { + t.Fatalf("the key and the record follow the agent's account: %s %s", p.keyDir(), p.placed()) + } + with["MESH_AGENT_ACCOUNT"], with["MESH_AGENT_HOME"] = "operator", "/home/operator" + if p, _ = PathsFrom(envOf(with)); p.Confined() || p.keyDir() != "/s" || p.placed() != "/s/home-placed.json" { + t.Fatalf("the agent account named as the operator's is the operator's: %+v", p) + } +} + +// ---- the licence follows the account ---------------------------------------------------------------------- + +func TestABindingAppliedIsFetchedAgainWhenTheAgentsHomeHoldsNoToken(t *testing.T) { + p, _ := node(t, "anchor") + writeFile(t, p.binding(), `{"licence":"personal","kind":"subscription","generation":4}`) + asked := 0 + ask := func(string, any) (json.RawMessage, error) { asked++; return json.RawMessage(`{}`), nil } + if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 4}, ask, writer(map[string]string{})); err != nil { + t.Fatal(err) + } + if asked != 1 { + t.Fatalf("a home with no token is asked for one, though the binding is the one applied; asked %d", asked) + } + writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"a","expiresAt":1}}`) + if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 4}, ask, writer(map[string]string{})); err != nil { + t.Fatal(err) + } + if asked != 1 { + t.Fatal("a home that holds the token applied is not asked again") + } +} + +// ---- what goes from the operator's home ------------------------------------------------------------------- + +func confined(t *testing.T) Paths { + t.Helper() + p, _ := node(t, "anchor") + p.Agent, p.Operator = "agent", "operator" + p.OperatorHome = filepath.Join(filepath.Dir(p.Home), "operator") + if err := os.MkdirAll(filepath.Join(p.OperatorHome, ".claude", "rules"), 0o700); err != nil { + t.Fatal(err) + } + return p +} + +func TestTheTokenTheModulePlacedInTheOperatorsHomeGoes(t *testing.T) { + p := confined(t) + file := filepath.Join(p.OperatorHome, ".claude", ".credentials.json") + writeFile(t, file, `{"claudeAiOauth":{"accessToken":"a","expiresAt":1}}`) + writeFile(t, filepath.Join(p.State, "api-key"), "k\n") + if said := strings.Join(RetireOperatorHome(p), "\n"); !strings.Contains(said, "until agent's home holds") { + t.Fatalf("while the agent's home holds no licence the operator's token stays: %s", said) + } + if _, err := os.Stat(file); err != nil { + t.Fatal("the operator's token went before the agent's home held one") + } + writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"b","expiresAt":1}}`) + said := strings.Join(RetireOperatorHome(p), "\n") + if _, err := os.Stat(file); !os.IsNotExist(err) { + t.Fatalf("the access-token-only file is the module's and goes: %s", said) + } + if _, err := os.Stat(filepath.Join(p.State, "api-key")); !os.IsNotExist(err) { + t.Fatal("the key kept where the agent's account cannot read it goes") + } +} + +func TestALoginThePersonMadeInTheOperatorsHomeStays(t *testing.T) { + p := confined(t) + file := filepath.Join(p.OperatorHome, ".claude", ".credentials.json") + writeFile(t, file, `{"claudeAiOauth":{"accessToken":"a","refreshToken":"r","expiresAt":1}}`) + said := strings.Join(RetireOperatorHome(p), "\n") + if _, err := os.Stat(file); err != nil || !strings.Contains(said, "login the person made") { + t.Fatalf("a refresh token is a person's login: left and said; said %q", said) + } +} + +func TestWhatWasPlacedInTheOperatorsHomeGoesUnlessChanged(t *testing.T) { + p := confined(t) + dir := filepath.Join(p.OperatorHome, ".claude") + writeFile(t, filepath.Join(dir, "rules", "a.md"), "placed") + writeFile(t, filepath.Join(dir, "rules", "b.md"), "changed by hand") + raw, _ := json.Marshal(Placed{"rules/a.md": digest("placed"), "rules/b.md": digest("placed")}) + writeFile(t, filepath.Join(p.State, "home-placed.json"), string(raw)) + RetireOperatorHome(p) + if _, err := os.Stat(filepath.Join(dir, "rules", "a.md")); !os.IsNotExist(err) { + t.Fatal("an item placed and unchanged goes, as unregistering takes it") + } + if _, err := os.Stat(filepath.Join(dir, "rules", "b.md")); err != nil { + t.Fatal("an item the person changed is theirs now and stays") + } + if _, err := os.Stat(filepath.Join(p.State, "home-placed.json")); !os.IsNotExist(err) { + t.Fatal("the operator's home's record goes once it holds nothing") + } +} + +func TestNothingGoesWhereTheAgentRunsAsTheOperator(t *testing.T) { + p, _ := node(t, "anchor") + p.OperatorHome = p.Home + file := p.credentials() + writeFile(t, file, `{"claudeAiOauth":{"accessToken":"a","expiresAt":1}}`) + if said := RetireOperatorHome(p); len(said) != 0 { + t.Fatalf("said %v", said) + } + if _, err := os.Stat(file); err != nil { + t.Fatal("the token stays where the agent reads it") + } +} + +// ---- the guard -------------------------------------------------------------------------------------------- + +func TestASessionNotRunningAsTheAgentsAccountDoesNothing(t *testing.T) { + g := Guard{Data: GuardData{Node: "anchor", Agent: "agent"}} + echo := HookInput{ToolName: "Bash", ToolInput: map[string]any{"command": "echo hello"}} + v := g.JudgeAs("operator", echo) + if !v.Refuse || v.Rule != "account" || v.Overridable || !strings.Contains(v.Message, "claude-agent") { + t.Fatalf("a session as the operator is refused whatever it runs, with no override: %+v", v) + } + if v := g.JudgeAs("operator", HookInput{ToolName: "Write", ToolInput: map[string]any{"file_path": "/tmp/x"}}); !v.Refuse { + t.Fatal("a file edit too") + } + if v := g.JudgeAs("agent", echo); v.Refuse { + t.Fatalf("the agent's own account runs as before: %+v", v) + } + if v := (Guard{Data: GuardData{Node: "laptop"}}).JudgeAs("operator", echo); v.Refuse { + t.Fatal("where agents run as the operator, the operator's session runs as before") + } +} + +func TestWhereTheAgentHasItsOwnAccountTheRecordIsTheJournal(t *testing.T) { + p, _ := node(t, "anchor") + if d := GuardDataOf(p, nil); d.Agent != "" || strings.HasPrefix(d.Log, JournalRecord) { + t.Fatalf("as the operator: the module's own file; got %+v", d) + } + p.Agent, p.Operator = "agent", "operator" + d := GuardDataOf(p, nil) + if d.Agent != "agent" || d.Log != JournalRecord+GuardJournalID { + t.Fatalf("as an account of its own: the journal, and the account named; got %+v", d) + } + + socket := filepath.Join(t.TempDir(), "journal") + conn, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: socket, Net: "unixgram"}) + if err != nil { + t.Skipf("no unix datagram socket here: %v", err) + } + defer conn.Close() + was := journalSocket + journalSocket = socket + t.Cleanup(func() { journalSocket = was }) + if err := RecordGuard(d.Log, GuardEntry{Decision: "refused", Rule: "account", Tool: "Bash", Command: "a\nb"}); err != nil { + t.Fatal(err) + } + buf := make([]byte, 4096) + n, _, err := conn.ReadFrom(buf) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSuffix(string(buf[:n]), "\n"), "\n") + if len(lines) != 3 || lines[0] != "SYSLOG_IDENTIFIER="+GuardJournalID || !strings.HasPrefix(lines[2], "MESSAGE={") { + t.Fatalf("one line per field, the entry as one line of JSON: %q", lines) + } + + readWas := readJournal + t.Cleanup(func() { readJournal = readWas }) + readJournal = func(id string, last int) ([]byte, error) { + if id != GuardJournalID { + t.Fatalf("read %s", id) + } + msg, _ := json.Marshal(strings.TrimPrefix(lines[2], "MESSAGE=")) + forged, _ := json.Marshal(`{"decision":"overridden","rule":"ssh","tool":"Bash","command":"forged"}`) + return []byte(`{"MESSAGE":` + string(msg) + `,"_EXE":"` + HelperPath() + `"}` + "\n" + + `{"MESSAGE":` + string(forged) + `,"_EXE":"/usr/bin/logger"}` + "\n"), nil + } + if got := ReadGuardLog(d.Log, 10); len(got) != 1 || got[0].Rule != "account" || got[0].Command != "a\nb" { + t.Fatalf("the record reads back from the journal: %+v", got) + } + if err := RecordGuard(JournalRecord+"x", GuardEntry{}); err != nil && !strings.Contains(err.Error(), "journal") { + t.Fatal(err) + } + journalSocket = filepath.Join(t.TempDir(), "absent") + if err := RecordGuard(d.Log, GuardEntry{}); err == nil { + t.Fatal("a journal that cannot be reached is an error, so an override is not honoured unrecorded") + } +} + +// ---- an API key is taken only from the agent's own home --------------------------------------------------- + +func TestAnAPIKeyFileOutsideTheAgentsHomeIsRefusedAndKept(t *testing.T) { + p, _ := node(t, "anchor") + asked := 0 + ask := func(string, any) (json.RawMessage, error) { asked++; return json.RawMessage(`{}`), nil } + outside := filepath.Join(filepath.Dir(p.Home), "runtime-credential") + writeFile(t, outside, "sk-ant-api03-secret-0123456789abcdefghij\n") + inside := filepath.Join(p.Home, "not-a-key") + writeFile(t, inside, "-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n") + linked := filepath.Join(p.Home, "linked") + if err := os.Symlink(outside, linked); err != nil { + t.Fatal(err) + } + for _, file := range []string{outside, p.Home + "/../runtime-credential", inside, linked, "relative", p.Home} { + _, err := AddAPIKey(p, "api", file, false, ask) + if err == nil || strings.Contains(err.Error(), "secret") { + t.Fatalf("%s: refused, without repeating what it holds; got %v", file, err) + } + } + for _, file := range []string{outside, inside} { + if _, err := os.Stat(file); err != nil { + t.Fatalf("%s was removed though it was refused", file) + } + } + if asked != 0 { + t.Fatal("nothing refused reaches the licence manager") + } +} + +func TestTheGuardIsAskedAboutEveryToolAndRefusesEachForAnotherAccount(t *testing.T) { + if GuardMatcher != "*" { + t.Fatalf("the guard is asked about every tool, got %q", GuardMatcher) + } + g := Guard{Data: GuardData{Node: "anchor", Agent: "agent"}} + for _, tool := range []string{"Read", "Grep", "WebFetch", "mcp__mesh__mesh_call", "Task"} { + if v := g.JudgeAs("operator", HookInput{ToolName: tool}); !v.Refuse { + t.Fatalf("%s as another account is refused", tool) + } + if v := g.JudgeAs("agent", HookInput{ToolName: tool}); v.Refuse { + t.Fatalf("%s as the agent's account passes", tool) + } + } +} + +func TestAMoveWritesTheAccountFileBesideTheToken(t *testing.T) { + p, w := node(t, "anchor") + writeFile(t, p.binding(), `{"licence":"personal","kind":"subscription","generation":4}`) + ask := func(address string, args any) (json.RawMessage, error) { + key := args.(map[string]any)["public_key"].(string) + g, _ := json.Marshal(Grant{AccessToken: "at", ExpiresAt: now + 3_600_000}) + box, _ := Seal(string(g), key) + return json.Marshal(Current{Licence: "personal", Kind: "subscription", Generation: 4, Sealed: &box, + Identity: &Identity{AccountUUID: "u-1", EmailAddress: "operator@example.org"}}) + } + if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 4}, ask, writer(w)); err != nil { + t.Fatal(err) + } + if creds(t, p)["accessToken"] != "at" { + t.Fatal("the token is in the new home") + } + if id := ReadIdentity(p.account()); id == nil || id.AccountUUID != "u-1" { + t.Fatalf("the account file names the account whose token the new home holds, got %+v", id) + } +} + +func TestAnAnswerPastTheBoundIsRefused(t *testing.T) { + long := strings.Repeat("x", 100) + "\n" + if _, err := readLine(bufio.NewReaderSize(strings.NewReader(long), 16), 50); err == nil { + t.Fatal("a line past the bound is an error") + } + if got, err := readLine(bufio.NewReaderSize(strings.NewReader(long), 16), 200); err != nil || string(got) != long { + t.Fatalf("a line within it is read whole across the buffer: %q %v", got, err) + } +} diff --git a/modules/claude-code/cmd/claude-code/claude_code_test.go b/modules/claude-code/cmd/claude-code/claude_code_test.go index f024c10d..60f3bfd7 100644 --- a/modules/claude-code/cmd/claude-code/claude_code_test.go +++ b/modules/claude-code/cmd/claude-code/claude_code_test.go @@ -417,7 +417,7 @@ func TestAnAPIKeyIsHandedOverSealedAndItsFileRemoved(t *testing.T) { p, _ := node(t, "laptop") manager, _ := GenerateKeyPair() file := filepath.Join(p.Home, "api-key") - writeFile(t, file, "sk-ant-api03-secret\n") + writeFile(t, file, "sk-ant-api03-secret-0123456789abcdefghij\n") var sent []string ask := func(address string, args any) (json.RawMessage, error) { raw, _ := json.Marshal(args) @@ -427,7 +427,7 @@ func TestAnAPIKeyIsHandedOverSealedAndItsFileRemoved(t *testing.T) { return json.Marshal(map[string]any{"public_key": manager.PublicKey}) case "seat:anthropic-licence-manager.adopt": box := args.(map[string]any)["sealed"].(SealedBox) - if key, err := Open(box, manager.PrivateKey); err != nil || key != "sk-ant-api03-secret" { + if key, err := Open(box, manager.PrivateKey); err != nil || key != "sk-ant-api03-secret-0123456789abcdefghij" { t.Fatalf("the manager opened %q, %v", key, err) } return json.Marshal(map[string]any{"adopted": true}) diff --git a/modules/claude-code/cmd/claude-code/config.go b/modules/claude-code/cmd/claude-code/config.go index fb14151e..ad592ddf 100644 --- a/modules/claude-code/cmd/claude-code/config.go +++ b/modules/claude-code/cmd/claude-code/config.go @@ -419,11 +419,11 @@ func (v *ConfigView) Items() map[string]Item { // an older snapshot never lands after a newer one. func (v *ConfigView) writeThroughLocked() bool { now, _ := indented(v.items) - before, _ := os.ReadFile(v.p.config()) + before, _ := fsys.ReadFile(v.p.config()) if string(before) == string(now) { return false } - _ = os.WriteFile(v.p.config(), now, 0o600) + _ = fsys.WriteFile(v.p.config(), now, 0o600) return true } @@ -479,8 +479,11 @@ type GuardFiles struct { Data GuardData } -// GuardMatcher is what the guard is asked about: the shell, and the agent's own file edits. -const GuardMatcher = "Bash|Edit|Write|MultiEdit|NotebookEdit" +// GuardMatcher is what the guard is asked about: every tool. Its own rules judge the shell and the agent's file +// edits and let every other tool through; the rule of the account agents run as (novox/hq ADR 0266) refuses +// every tool of a session running as another, reading and the mesh's tools included, so that session can do +// nothing at all. +const GuardMatcher = "*" // GuardCommand is the hook's command line: by its path under the managed directory, root's — never the // copy the agent may keep of a plugin, which the session could change. @@ -673,9 +676,13 @@ const deletedByHand = "deleted-by-hand" // placed that the person deleted stays deleted until the item is unregistered. Answers what it did and what // it left alone, and why. func PlaceHome(p Paths, want map[string]string) (done []string, left []string) { - dir := filepath.Join(p.Home, ".claude") + return placeHomeIn(filepath.Join(p.Home, ".claude"), p.placed(), want) +} + +// placeHomeIn is PlaceHome for one agent directory and its own record of what was placed there. +func placeHomeIn(dir, record string, want map[string]string) (done []string, left []string) { var placed Placed - if !readJSON(p.placed(), &placed) { + if !readJSON(record, &placed) { placed = Placed{} } paths := make([]string, 0, len(want)) @@ -690,7 +697,7 @@ func PlaceHome(p Paths, want map[string]string) (done []string, left []string) { left = append(left, rel+": "+why+", left alone") continue } - current, err := os.ReadFile(full) + current, err := fsys.ReadFile(full) exists := err == nil ours, wasPlaced := placed[rel] switch { @@ -712,7 +719,7 @@ func PlaceHome(p Paths, want map[string]string) (done []string, left []string) { left = append(left, rel+": changed by hand since it was placed, left alone") continue } - if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + if err := fsys.MkdirAll(filepath.Dir(full), 0o755); err != nil { left = append(left, rel+": "+err.Error()) continue } @@ -720,7 +727,7 @@ func PlaceHome(p Paths, want map[string]string) (done []string, left []string) { if isScript(rel, content) { mode = 0o755 } - if err := os.WriteFile(full, []byte(content), mode); err != nil { + if err := fsys.WriteFile(full, []byte(content), mode); err != nil { left = append(left, rel+": "+err.Error()) continue } @@ -740,13 +747,13 @@ func PlaceHome(p Paths, want map[string]string) (done []string, left []string) { left = append(left, rel+": no longer registered, but "+why+", left alone") continue } - current, err := os.ReadFile(full) + current, err := fsys.ReadFile(full) if err == nil && digest(string(current)) != ours { left = append(left, rel+": no longer registered, but changed by hand, left alone") delete(placed, rel) continue } - if err := os.Remove(full); err != nil && !os.IsNotExist(err) { + if err := fsys.Remove(full); err != nil && !os.IsNotExist(err) { left = append(left, rel+": no longer registered, and could not be removed: "+err.Error()) continue } @@ -756,7 +763,7 @@ func PlaceHome(p Paths, want map[string]string) (done []string, left []string) { done = append(done, rel+": removed") } raw, _ := indented(placed) - if err := os.WriteFile(p.placed(), raw, 0o600); err != nil { + if err := fsys.WriteFile(record, raw, 0o600); err != nil { left = append(left, "the record of what was placed could not be saved: "+err.Error()) } return done, left @@ -769,7 +776,7 @@ func linkedParent(dir, rel string) string { at := dir for _, seg := range parts[:len(parts)-1] { at = filepath.Join(at, seg) - if info, err := os.Lstat(at); err == nil && info.Mode()&os.ModeSymlink != 0 { + if info, err := fsys.Lstat(at); err == nil && info.Mode()&os.ModeSymlink != 0 { return at + " is a symbolic link" } } @@ -779,7 +786,7 @@ func linkedParent(dir, rel string) string { // removeEmptyParents removes empty directories from dir up to, not including, root. func removeEmptyParents(root, dir string) { for dir != root && strings.HasPrefix(dir, root+string(filepath.Separator)) { - if err := os.Remove(dir); err != nil { + if err := fsys.Remove(dir); err != nil { return } dir = filepath.Dir(dir) @@ -795,7 +802,7 @@ func HomeConflict(p Paths, it Item) string { if _, ours := placed[rel]; ours { continue } - if _, err := os.Stat(filepath.Join(p.Home, ".claude", filepath.FromSlash(rel))); err == nil { + if _, err := fsys.Stat(filepath.Join(p.Home, ".claude", filepath.FromSlash(rel))); err == nil { return fmt.Sprintf("%s already exists in this home and the mesh did not place it; pick another name, or import it", rel) } } @@ -843,12 +850,12 @@ func HomeItems(p Paths, c Config, loaded Servers) []HomeItem { } out = append(out, h) } - if entries, err := os.ReadDir(filepath.Join(dir, "skills")); err == nil { + if entries, err := fsys.ReadDir(filepath.Join(dir, "skills")); err == nil { for _, e := range entries { if !e.IsDir() { continue } - body, err := os.ReadFile(filepath.Join(dir, "skills", e.Name(), "SKILL.md")) + body, err := fsys.ReadFile(filepath.Join(dir, "skills", e.Name(), "SKILL.md")) if err != nil { continue // the vendor's own synced folders carry none at their top } @@ -856,7 +863,7 @@ func HomeItems(p Paths, c Config, loaded Servers) []HomeItem { } } for kind, sub := range map[string]string{KindAgent: "agents", KindCommand: "commands", KindOutputStyle: "output-styles", KindInstructions: "rules"} { - entries, err := os.ReadDir(filepath.Join(dir, sub)) + entries, err := fsys.ReadDir(filepath.Join(dir, sub)) if err != nil { continue } @@ -864,11 +871,11 @@ func HomeItems(p Paths, c Config, loaded Servers) []HomeItem { if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { continue } - body, _ := os.ReadFile(filepath.Join(dir, sub, e.Name())) + body, _ := fsys.ReadFile(filepath.Join(dir, sub, e.Name())) add(kind, strings.TrimSuffix(e.Name(), ".md"), sub+"/"+e.Name(), string(body)) } } - if body, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md")); err == nil { + if body, err := fsys.ReadFile(filepath.Join(dir, "CLAUDE.md")); err == nil { add(KindMemory, memoryName, "CLAUDE.md", string(body)) } sort.Slice(out, func(i, j int) bool { @@ -887,12 +894,12 @@ func ImportFromHome(p Paths, kind, name string) (Item, error) { switch kind { case KindSkill: root := filepath.Join(dir, "skills", name) - err := filepath.WalkDir(root, func(full string, d os.DirEntry, err error) error { + err := fsys.WalkDir(root, func(full string, d os.DirEntry, err error) error { if err != nil || d.IsDir() { return err } rel, _ := filepath.Rel(root, full) - raw, err := os.ReadFile(full) + raw, err := fsys.ReadFile(full) if err != nil { return err } @@ -904,7 +911,7 @@ func ImportFromHome(p Paths, kind, name string) (Item, error) { } case KindAgent, KindCommand, KindOutputStyle, KindInstructions: sub := map[string]string{KindAgent: "agents", KindCommand: "commands", KindOutputStyle: "output-styles", KindInstructions: "rules"}[kind] - raw, err := os.ReadFile(filepath.Join(dir, sub, name+".md")) + raw, err := fsys.ReadFile(filepath.Join(dir, sub, name+".md")) if err != nil { return Item{}, fmt.Errorf("the %s %s in this home: %w", kind, name, err) } diff --git a/modules/claude-code/cmd/claude-code/grant.go b/modules/claude-code/cmd/claude-code/grant.go index 7762c1b5..e184da94 100644 --- a/modules/claude-code/cmd/claude-code/grant.go +++ b/modules/claude-code/cmd/claude-code/grant.go @@ -15,7 +15,6 @@ import ( "bytes" "encoding/json" "math" - "os" "path/filepath" ) @@ -72,7 +71,7 @@ func (c Credentials) oauth() map[string]any { // ReadCredentials reads the file, keeping numbers as written; nil when there is none. func ReadCredentials(path string) Credentials { - raw, err := os.ReadFile(path) + raw, err := fsys.ReadFile(path) if err != nil { return nil } @@ -172,7 +171,7 @@ func ReplacedBy(local Credentials, g Grant) Credentials { // WriteCredentials writes atomically at 0600: a partial credentials file must never be read as a whole one. func WriteCredentials(path string, c Credentials) error { - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } raw, err := indented(c) @@ -180,10 +179,10 @@ func WriteCredentials(path string, c Credentials) error { return err } tmp := path + ".mesh-tmp" - if err := os.WriteFile(tmp, raw, 0o600); err != nil { + if err := fsys.WriteFile(tmp, raw, 0o600); err != nil { return err } - return os.Rename(tmp, path) + return fsys.Rename(tmp, path) } // indented is JSON as the agent's own files are written: two-space indent, a trailing newline, nothing diff --git a/modules/claude-code/cmd/claude-code/guard.go b/modules/claude-code/cmd/claude-code/guard.go index 6bdf56a8..9dd2e00b 100644 --- a/modules/claude-code/cmd/claude-code/guard.go +++ b/modules/claude-code/cmd/claude-code/guard.go @@ -41,6 +41,9 @@ type GuardData struct { Machines []Machine `json:"machines"` Replacements []Replacement `json:"replacements"` Log string `json:"log"` + // Agent is the account every session here must run as, where the machine names an agent account of its + // own (novox/hq ADR 0266); empty where agents run as the operator. + Agent string `json:"agent,omitempty"` } // HookInput is what the agent hands a PreToolUse hook. @@ -54,7 +57,7 @@ type HookInput struct { // Verdict is the guard's answer. type Verdict struct { Refuse bool `json:"refuse"` - Rule string `json:"rule,omitempty"` // ssh | hosts-file | hostaliases | override-named + Rule string `json:"rule,omitempty"` // ssh | hosts-file | hostaliases | override-named | account Machine string `json:"machine,omitempty"` Message string `json:"message,omitempty"` // Overridable is false for a refusal no override lifts: the agent naming the override itself. @@ -88,6 +91,29 @@ type Guard struct { Resolve func(string) []string } +// JudgeAs is the verdict on one tool call of a session running as an account. **On a machine whose agents run +// as an account of their own, a session running as any other does nothing** (novox/hq ADR 0266): the +// operator's account there may become root without a person, and an agent started in it by habit would carry +// that into every command. Refused whatever the command, and no override lifts it — the operator's own shell is +// how the operator acts as themselves. +func (g Guard) JudgeAs(account string, in HookInput) Verdict { + if g.Data.Agent != "" && account != g.Data.Agent { + return Verdict{Refuse: true, Rule: "account", Message: fmt.Sprintf("Refused: on %s agents run as %s, an "+ + "account that cannot become root without a person, and this session runs as %s (novox/hq ADR 0266). "+ + "No tool it calls here is let through. End it, and start the agent with `claude-agent`, which runs it as "+ + "%s; what needs the operator's own rights is the operator's to run in their own shell.", + orThisMachine(g.Data.Node), g.Data.Agent, account, g.Data.Agent)} + } + return g.Judge(in) +} + +func orThisMachine(node string) string { + if node == "" { + return "this machine" + } + return node +} + // Judge is the verdict on one tool call. func (g Guard) Judge(in HookInput) Verdict { switch in.ToolName { diff --git a/modules/claude-code/cmd/claude-code/guard_run.go b/modules/claude-code/cmd/claude-code/guard_run.go index 04896035..134fea29 100644 --- a/modules/claude-code/cmd/claude-code/guard_run.go +++ b/modules/claude-code/cmd/claude-code/guard_run.go @@ -10,8 +10,9 @@ package main // process, which is fixed at exec. A command's `export`, an `env` key in a settings file, a variable the // session sets for its tools — none of them reaches it. A nested session the agent starts with the // variable is refused before it starts: the command names the override; -// - **recorded**: every override is a line in the module's guard.log, with why, the command and the -// machine — and an override that cannot be recorded is not honoured. Refusals are recorded the same way. +// - **recorded**: every override is a line in the module's guard.log — or, where the agent runs as an account +// of its own, in the machine's journal (novox/hq ADR 0266) — with why, the command and the machine; an +// override that cannot be recorded is not honoured. Refusals are recorded the same way. // // A guard that fails — a fault in this code — exits 1, which the agent shows and does not block on: a guard // that refused every command would stop the session the operator needs to repair it. @@ -25,6 +26,7 @@ import ( "net" "os" "os/exec" + "os/user" "path/filepath" "strconv" "strings" @@ -55,11 +57,26 @@ type GuardEntry struct { Session string `json:"session,omitempty"` } +// JournalRecord is the guard's record where the agent runs as an account of its own (novox/hq ADR 0266): the +// session's account cannot enter the module's state, and must not be able to rewrite what it was refused, so +// each line goes to the machine's journal, which the account may add to and not change. The record's +// location is then this prefix and the journal's identifier. +const JournalRecord = "journal:" + +// GuardJournalID is the identifier the guard's lines carry in the journal. +const GuardJournalID = "mesh-claude-code-guard" + +// journalSocket is where the journal takes a line; a test points it at its own. +var journalSocket = "/run/systemd/journal/socket" + // RecordGuard appends one line to the record. func RecordGuard(path string, e GuardEntry) error { if path == "" { return fmt.Errorf("no record is kept here") } + if id, ok := strings.CutPrefix(path, JournalRecord); ok { + return recordInJournal(id, e) + } if info, err := os.Stat(path); err == nil && info.Size() > GuardLogMax { _ = os.Rename(path, path+".1") } @@ -75,9 +92,68 @@ func RecordGuard(path string, e GuardEntry) error { return f.Close() } +// recordInJournal says one line to the journal by its native protocol: the entry as JSON in MESSAGE, one line, +// so nothing in a command can add a field of its own. +func recordInJournal(id string, e GuardEntry) error { + raw, err := json.Marshal(e) + if err != nil { + return err + } + conn, err := net.Dial("unixgram", journalSocket) + if err != nil { + return fmt.Errorf("the journal cannot be reached: %w", err) + } + defer conn.Close() + _, err = conn.Write([]byte("SYSLOG_IDENTIFIER=" + id + "\nPRIORITY=5\nMESSAGE=" + string(raw) + "\n")) + return err +} + +// readJournal is the journal's last lines of the guard's record, read as root through the runtime account's +// sudo where this process is not root: what other accounts said is not the runtime account's to read. As JSON, +// with the field the journal itself sets for the program that wrote the line (_EXE), which no writer chooses. +var readJournal = func(id string, last int) ([]byte, error) { + args := []string{"journalctl", "-q", "--no-pager", "-o", "json", "--output-fields=MESSAGE,_EXE", "-t", id, + "-n", strconv.Itoa(last)} + if os.Geteuid() != 0 { + args = append([]string{"sudo", "-n"}, args...) + } + cmd := exec.Command(args[0], args[1:]...) + cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C") + return cmd.Output() +} + +// guardsOwnLines keeps, of the journal's JSON lines, the messages the guard's own binary wrote (novox/hq ADR +// 0266). The agent's account may add to the journal under any identifier — `logger -t` — and the journal +// says which program wrote each line; a line another program wrote under the guard's identifier is not the +// guard's record. The agent can still run the guard's binary itself, so the record is the guard's words, not +// proof that each refusal happened in a session: what it cannot do is remove one. +func guardsOwnLines(raw []byte, exe string) []byte { + var out []byte + for _, line := range bytes.Split(raw, []byte("\n")) { + var f struct { + Message any `json:"MESSAGE"` + Exe string `json:"_EXE"` + } + if json.Unmarshal(line, &f) != nil || f.Exe != exe { + continue + } + if m, ok := f.Message.(string); ok { + out = append(append(out, m...), '\n') + } + } + return out +} + // ReadGuardLog is the record's last lines, newest last. func ReadGuardLog(path string, last int) []GuardEntry { - raw, err := os.ReadFile(path) + read := os.ReadFile + if id, ok := strings.CutPrefix(path, JournalRecord); ok { + read = func(string) ([]byte, error) { + raw, err := readJournal(id, last) + return guardsOwnLines(raw, HelperPath()), err + } + } + raw, err := read(path) if err != nil { return []GuardEntry{} } @@ -183,6 +259,14 @@ func isAgent(cmdline []byte) bool { return false } +// currentAccount is the account the session runs as; a test sets its own. +var currentAccount = func() string { + if u, err := user.Current(); err == nil { + return u.Username + } + return "uid " + strconv.Itoa(os.Getuid()) +} + // runGuard is the hook: the tool call in, the verdict out as the exit code. func runGuard(dataPath string, stdin io.Reader, stderr io.Writer) (code int) { defer func() { @@ -201,7 +285,7 @@ func runGuard(dataPath string, stdin io.Reader, stderr io.Writer) (code int) { _ = json.Unmarshal(raw, &data) } g := Guard{Data: data, SSH: sshDashG, Resolve: quickLookup} - v := g.Judge(in) + v := g.JudgeAs(currentAccount(), in) if !v.Refuse { return 0 } diff --git a/modules/claude-code/cmd/claude-code/home.go b/modules/claude-code/cmd/claude-code/home.go index 94576679..216e91dd 100644 --- a/modules/claude-code/cmd/claude-code/home.go +++ b/modules/claude-code/cmd/claude-code/home.go @@ -98,7 +98,7 @@ func readItem(dir, rel string, folder bool) (map[string]string, map[string]fs.Fi return nil, nil, errors.New(why) } full := filepath.Join(dir, filepath.FromSlash(rel)) - info, err := os.Lstat(full) + info, err := fsys.Lstat(full) if err != nil { if os.IsNotExist(err) { return nil, nil, fmt.Errorf("%s is not in this home", full) @@ -113,7 +113,7 @@ func readItem(dir, rel string, folder bool) (map[string]string, map[string]fs.Fi if !info.Mode().IsRegular() { return nil, nil, fmt.Errorf("%s is not a file", full) } - raw, err := os.ReadFile(full) + raw, err := fsys.ReadFile(full) if err != nil { return nil, nil, err } @@ -123,7 +123,7 @@ func readItem(dir, rel string, folder bool) (map[string]string, map[string]fs.Fi if !info.IsDir() { return nil, nil, fmt.Errorf("%s is not a folder", full) } - err = filepath.WalkDir(full, func(at string, d fs.DirEntry, err error) error { + err = fsys.WalkDir(full, func(at string, d fs.DirEntry, err error) error { if err != nil { return err } @@ -137,7 +137,7 @@ func readItem(dir, rel string, folder bool) (map[string]string, map[string]fs.Fi return fmt.Errorf("%s is not a file", at) } in, _ := filepath.Rel(full, at) - raw, err := os.ReadFile(at) + raw, err := fsys.ReadFile(at) if err != nil { return err } @@ -232,7 +232,7 @@ func RemoveHome(p Paths, kind, name, why string, now time.Time) (map[string]any, day := filepath.Join(p.State, RemovedDir, now.Format("2006-01-02")) kept := filepath.Join(day, now.Format("150405")+"-"+kind+"-"+name) for n := 2; ; n++ { - if _, err := os.Lstat(kept); os.IsNotExist(err) { + if _, err := fsys.Lstat(kept); os.IsNotExist(err) { break } kept = filepath.Join(day, fmt.Sprintf("%s-%s-%s-%d", now.Format("150405"), kind, name, n)) @@ -244,13 +244,13 @@ func RemoveHome(p Paths, kind, name, why string, now time.Time) (map[string]any, names := map[string]KeptFile{} for in, content := range files { to := filepath.Join(copyRoot, filepath.FromSlash(in)) - if err := os.MkdirAll(filepath.Dir(to), 0o700); err != nil { + if err := fsys.MkdirAll(filepath.Dir(to), 0o700); err != nil { return nil, fmt.Errorf("the copy could not be made, nothing removed: %w", err) } - if err := os.WriteFile(to, []byte(content), modes[in]|0o600); err != nil { + if err := fsys.WriteFile(to, []byte(content), modes[in]|0o600); err != nil { return nil, fmt.Errorf("the copy could not be made, nothing removed: %w", err) } - if back, err := os.ReadFile(to); err != nil || !bytes.Equal(back, []byte(content)) { + if back, err := fsys.ReadFile(to); err != nil || !bytes.Equal(back, []byte(content)) { return nil, fmt.Errorf("the copy of %s does not read back the same, nothing removed", in) } names[in] = KeptFile{Digest: digest(content), Mode: fmt.Sprintf("%04o", modes[in])} @@ -258,7 +258,7 @@ func RemoveHome(p Paths, kind, name, why string, now time.Time) (map[string]any, r := Removal{Action: "removed", At: now.Format(time.RFC3339), Node: p.Node, Kind: kind, Name: name, Path: full, Why: why, Kept: filepath.Join(kept, filepath.FromSlash(rel)), Files: names} note, _ := indented(r) - if err := os.WriteFile(filepath.Join(kept, "removal.json"), note, 0o600); err != nil { + if err := fsys.WriteFile(filepath.Join(kept, "removal.json"), note, 0o600); err != nil { return nil, fmt.Errorf("the copy's note could not be written, nothing removed: %w", err) } @@ -273,9 +273,9 @@ func RemoveHome(p Paths, kind, name, why string, now time.Time) (map[string]any, } } if folder { - err = os.RemoveAll(full) + err = fsys.RemoveAll(full) } else { - err = os.Remove(full) + err = fsys.Remove(full) } if err != nil { return nil, fmt.Errorf("%s could not be removed (the copy is at %s): %w", full, kept, err) @@ -392,7 +392,7 @@ func RestoreHome(p Paths, kept string, now time.Time) (map[string]any, error) { if why := linkedParent(dir, rel+"/x"); why != "" { return nil, errors.New(why + ", nothing restored") } - if _, err := os.Lstat(full); err == nil { + if _, err := fsys.Lstat(full); err == nil { return nil, fmt.Errorf("%s exists now, nothing restored: look at it with claude_code_home_show first", full) } else if !os.IsNotExist(err) { return nil, err @@ -405,10 +405,10 @@ func RestoreHome(p Paths, kept string, now time.Time) (map[string]any, error) { written := []string{} undo := func() { for _, w := range written { - _ = os.Remove(w) + _ = fsys.Remove(w) } if isFolder { - _ = os.RemoveAll(full) + _ = fsys.RemoveAll(full) } } for in, content := range files { @@ -421,28 +421,22 @@ func RestoreHome(p Paths, kept string, now time.Time) (map[string]any, error) { if _, err := fmt.Sscanf(r.Files[in].Mode, "%o", &m); err == nil && m != 0 { mode = os.FileMode(m).Perm() } - if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil { + if err := fsys.MkdirAll(filepath.Dir(to), 0o755); err != nil { undo() return nil, fmt.Errorf("%s could not be restored: %w", full, err) } - f, err := os.OpenFile(to, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) - if err != nil { + if err := fsys.CreateExclusive(to, []byte(content), mode); err != nil { undo() - return nil, fmt.Errorf("%s could not be restored: %w", full, err) + return nil, fmt.Errorf("%s could not be restored: %w", to, err) } - _, werr := f.WriteString(content) - if cerr := f.Close(); werr != nil || cerr != nil { - undo() - return nil, fmt.Errorf("%s could not be restored", to) - } - _ = os.Chmod(to, mode) // the umask may have taken bits the file had + _ = fsys.Chmod(to, mode) // the umask may have taken bits the file had written = append(written, to) } back := Removal{Action: "restored", At: now.UTC().Format(time.RFC3339), Node: p.Node, Kind: r.Kind, Name: r.Name, Path: full, Why: "undoes the removal of " + r.At + ": " + r.Why, Kept: r.Kept, Files: r.Files} note, _ := indented(back) - _ = os.WriteFile(filepath.Join(folder, "restored.json"), note, 0o600) + _ = fsys.WriteFile(filepath.Join(folder, "restored.json"), note, 0o600) logged := logRemoval(p, back) say("restored %s from %s, undoing its removal of %s", full, r.Kept, r.At) answer := map[string]any{"restored": full, "kind": r.Kind, "name": r.Name, "from": r.Kept, "removedAt": r.At, diff --git a/modules/claude-code/cmd/claude-code/homefs.go b/modules/claude-code/cmd/claude-code/homefs.go new file mode 100644 index 00000000..409ae816 --- /dev/null +++ b/modules/claude-code/cmd/claude-code/homefs.go @@ -0,0 +1,543 @@ +package main + +// The agent's home, touched as the agent's account (novox/hq ADR 0266). +// +// On a machine that names an agent account of its own — the control node — agents run as that account, not +// as the operator's, so that no agent there can become root without a person. Its home is its own, 0700: +// the node's tool runtime, which runs this module as the operator's account, can neither read nor write it. +// And this module must: the licence token and the account the agent is logged in as, the items registered +// at the home scope, and the home's own items the status and the removal tools read. +// +// So every path under that home is touched by a helper running as the agent's account: this module's own +// binary, as the plugin carries it under the managed directory (root's, so the agent cannot change it), +// started once through `sudo -n -u ` and kept, speaking one JSON request and one JSON answer per line +// on its stdin and stdout. Once, not per file: a credentials file is looked at every five seconds, and sudo +// says every start in the journal. **The escalation goes down, never up**: the runtime's account may become +// the agent's; nothing here lets the agent's become anything. +// +// Everything else — the module's state, the managed directory, a machine whose agents run as the operator — +// is the plain filesystem, as before. Files routes each path; with no agent account of its own it routes none. + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "os/user" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// HelperArg is the argument that makes this binary the helper (as the agent's account). +const HelperArg = "home-fs" + +// Files is the filesystem as this module touches it: a path under the agent's home through the helper when +// the agent's account is not this process's, every other path as it is. +type Files struct { + // Home is the agent's home and Account its login; Helper starts the helper, nil for none. + Home string + Account string + Helper *Helper +} + +// fsys is the module's: plain until the launch says whose home the agent's is. +var fsys = &Files{} + +// FilesFor is the filesystem for these paths: through the helper when the agent's account is another than the +// one this process runs as. +func FilesFor(p Paths) *Files { + if p.Agent == "" || p.Home == "" { + return &Files{} + } + if me, err := user.Current(); err == nil && me.Username == p.Agent { + return &Files{} + } + return &Files{Home: filepath.Clean(p.Home), Account: p.Agent, Helper: &Helper{Account: p.Agent, Exe: HelperPath()}} +} + +// HelperPath is the helper's binary: the guard's copy of this module's binary in the plugin, root's and +// readable by every account, so the agent's account runs exactly what the mesh placed. +func HelperPath() string { + return filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir, "guard") +} + +// through says whether a path is touched as the agent. +func (f *Files) through(path string) bool { + if f == nil || f.Helper == nil || f.Home == "" { + return false + } + path = filepath.Clean(path) + return path == f.Home || strings.HasPrefix(path, f.Home+string(os.PathSeparator)) +} + +// ---- the operations --------------------------------------------------------------------------------------- + +func (f *Files) ReadFile(path string) ([]byte, error) { + if !f.through(path) { + return os.ReadFile(path) + } + a, err := f.Helper.Do(Op{Op: "read", Path: path}) + return a.Data, err +} + +func (f *Files) WriteFile(path string, data []byte, mode os.FileMode) error { + if !f.through(path) { + return os.WriteFile(path, data, mode) + } + _, err := f.Helper.Do(Op{Op: "write", Path: path, Data: data, Mode: uint32(mode.Perm())}) + return err +} + +// CreateExclusive writes a file that must not be there yet. +func (f *Files) CreateExclusive(path string, data []byte, mode os.FileMode) error { + if !f.through(path) { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return err + } + _, werr := file.Write(data) + return errors.Join(werr, file.Close()) + } + _, err := f.Helper.Do(Op{Op: "create", Path: path, Data: data, Mode: uint32(mode.Perm())}) + return err +} + +func (f *Files) Rename(from, to string) error { + if f.through(from) != f.through(to) { + return fmt.Errorf("%s cannot be moved to %s: one is the agent's home and the other is not", from, to) + } + if !f.through(from) { + return os.Rename(from, to) + } + _, err := f.Helper.Do(Op{Op: "rename", Path: from, To: to}) + return err +} + +func (f *Files) Remove(path string) error { + if !f.through(path) { + return os.Remove(path) + } + _, err := f.Helper.Do(Op{Op: "remove", Path: path}) + return err +} + +func (f *Files) RemoveAll(path string) error { + if !f.through(path) { + return os.RemoveAll(path) + } + _, err := f.Helper.Do(Op{Op: "remove-all", Path: path}) + return err +} + +func (f *Files) MkdirAll(path string, mode os.FileMode) error { + if !f.through(path) { + return os.MkdirAll(path, mode) + } + _, err := f.Helper.Do(Op{Op: "mkdir", Path: path, Mode: uint32(mode.Perm())}) + return err +} + +func (f *Files) Chmod(path string, mode os.FileMode) error { + if !f.through(path) { + return os.Chmod(path, mode) + } + _, err := f.Helper.Do(Op{Op: "chmod", Path: path, Mode: uint32(mode.Perm())}) + return err +} + +func (f *Files) Stat(path string) (fs.FileInfo, error) { + if !f.through(path) { + return os.Stat(path) + } + a, err := f.Helper.Do(Op{Op: "stat", Path: path}) + if err != nil { + return nil, err + } + return a.Info.info(), nil +} + +func (f *Files) Lstat(path string) (fs.FileInfo, error) { + if !f.through(path) { + return os.Lstat(path) + } + a, err := f.Helper.Do(Op{Op: "lstat", Path: path}) + if err != nil { + return nil, err + } + return a.Info.info(), nil +} + +func (f *Files) ReadDir(path string) ([]fs.DirEntry, error) { + if !f.through(path) { + return os.ReadDir(path) + } + a, err := f.Helper.Do(Op{Op: "readdir", Path: path}) + if err != nil { + return nil, err + } + out := make([]fs.DirEntry, 0, len(a.Entries)) + for _, e := range a.Entries { + out = append(out, fs.FileInfoToDirEntry(e.info())) + } + return out, nil +} + +// WalkDir is filepath.WalkDir, through the helper under the agent's home: lexical order, a directory before +// what is in it, a symbolic link reported and never followed. +func (f *Files) WalkDir(root string, fn fs.WalkDirFunc) error { + if !f.through(root) { + return filepath.WalkDir(root, fn) + } + info, err := f.Lstat(root) + if err != nil { + err = fn(root, nil, err) + } else { + err = f.walk(root, fs.FileInfoToDirEntry(info), fn) + } + if err == filepath.SkipDir || err == filepath.SkipAll { + return nil + } + return err +} + +func (f *Files) walk(path string, d fs.DirEntry, fn fs.WalkDirFunc) error { + if err := fn(path, d, nil); err != nil || !d.IsDir() { + if err == filepath.SkipDir && d.IsDir() { + err = nil + } + return err + } + entries, err := f.ReadDir(path) + if err != nil { + if err = fn(path, d, err); err != nil { + if err == filepath.SkipDir { + err = nil + } + return err + } + } + for _, e := range entries { + if err := f.walk(filepath.Join(path, e.Name()), e, fn); err != nil { + if err == filepath.SkipDir { + break + } + return err + } + } + return nil +} + +// ---- the wire --------------------------------------------------------------------------------------------- + +// Op is one request to the helper. +type Op struct { + Op string `json:"op"` + Path string `json:"path"` + To string `json:"to,omitempty"` + Data []byte `json:"data,omitempty"` + Mode uint32 `json:"mode,omitempty"` +} + +// Answer is the helper's to one Op. Err is said by its kind, so the caller's os.IsNotExist and errors.Is +// read it as they would read the filesystem's own. +type Answer struct { + Data []byte `json:"data,omitempty"` + Info *Info `json:"info,omitempty"` + Entries []*Info `json:"entries,omitempty"` + Err string `json:"err,omitempty"` + Kind string `json:"kind,omitempty"` // not-exist | exist | permission +} + +// Info is a file's information as the wire carries it. +type Info struct { + Name string `json:"name"` + Size int64 `json:"size"` + Mode uint32 `json:"mode"` + ModTime time.Time `json:"modTime"` +} + +func infoOf(i fs.FileInfo) *Info { + return &Info{Name: i.Name(), Size: i.Size(), Mode: uint32(i.Mode()), ModTime: i.ModTime()} +} + +func (i *Info) info() fs.FileInfo { + if i == nil { + return fileInfo{} + } + return fileInfo{*i} +} + +type fileInfo struct{ Info } + +func (i fileInfo) Name() string { return i.Info.Name } +func (i fileInfo) Size() int64 { return i.Info.Size } +func (i fileInfo) Mode() fs.FileMode { return fs.FileMode(i.Info.Mode) } +func (i fileInfo) ModTime() time.Time { return i.Info.ModTime } +func (i fileInfo) IsDir() bool { return i.Mode().IsDir() } +func (i fileInfo) Sys() any { return nil } + +func errorOf(op, path string, a Answer) error { + if a.Err == "" { + return nil + } + var kind error + switch a.Kind { + case "not-exist": + kind = fs.ErrNotExist + case "exist": + kind = fs.ErrExist + case "permission": + kind = fs.ErrPermission + default: + return errors.New(a.Err) + } + // The kind itself, unwrapped: os.IsNotExist compares rather than unwraps. + return &fs.PathError{Op: op, Path: path, Err: kind} +} + +// ---- the client ------------------------------------------------------------------------------------------- + +// Helper is the helper as this module keeps it: started at the first request, restarted once when it is gone. +type Helper struct { + Account string + Exe string + // Start is how it is started; nil is through sudo as the account. A test starts it in the process. + Start func() (io.WriteCloser, io.Reader, func(), error) + + mu sync.Mutex + in io.WriteCloser + out *bufio.Reader + close func() +} + +// Do answers one request, starting the helper when it is not running. +func (h *Helper) Do(op Op) (Answer, error) { + h.mu.Lock() + defer h.mu.Unlock() + var last error + for try := 0; try < 2; try++ { + if h.in == nil { + if err := h.start(); err != nil { + return Answer{}, err + } + } + a, err := h.roundTrip(op) + if err == nil { + return a, errorOf(op.Op, op.Path, a) + } + last = err + h.stop() + } + return Answer{}, fmt.Errorf("%s %s as %s: the helper did not answer: %w", op.Op, op.Path, h.Account, last) +} + +func (h *Helper) roundTrip(op Op) (Answer, error) { + raw, err := json.Marshal(op) + if err != nil { + return Answer{}, err + } + if _, err := h.in.Write(append(raw, '\n')); err != nil { + return Answer{}, err + } + line, err := readLine(h.out, helperAnswerMax) + if err != nil { + return Answer{}, err + } + var a Answer + if err := json.Unmarshal(line, &a); err != nil { + return Answer{}, fmt.Errorf("the helper said something that is not an answer: %w", err) + } + return a, nil +} + +// helperAnswerMax bounds one answer of the helper: far above the largest file this module reads from a home +// (an item is at most 256 KiB), so an answer past it is a helper gone wrong, not a file. +const helperAnswerMax = 16 << 20 + +// readLine reads one line of at most max bytes; a longer one is an error and the helper is started again. +func readLine(r *bufio.Reader, max int) ([]byte, error) { + var line []byte + for { + chunk, err := r.ReadSlice('\n') + line = append(line, chunk...) + if len(line) > max { + return nil, fmt.Errorf("the helper's answer is larger than %d bytes", max) + } + if err == bufio.ErrBufferFull { + continue + } + return line, err + } +} + +func (h *Helper) start() error { + start := h.Start + if start == nil { + start = h.sudo + } + in, out, closer, err := start() + if err != nil { + return err + } + h.in, h.out, h.close = in, bufio.NewReaderSize(out, 1<<20), closer + return nil +} + +func (h *Helper) stop() { + if h.in != nil { + _ = h.in.Close() + } + if h.close != nil { + h.close() + } + h.in, h.out, h.close = nil, nil, nil +} + +// sudo starts the helper as the agent's account: the runtime's account may become it (the sudo module's +// rule for the operator's account names every account), and nothing the agent's account holds lets it back. +func (h *Helper) sudo() (io.WriteCloser, io.Reader, func(), error) { + if _, err := os.Stat(h.Exe); err != nil { + return nil, nil, nil, fmt.Errorf("the helper that touches %s's home is not placed yet (%s): the managed "+ + "directory is written first, and the next render reaches the home", h.Account, h.Exe) + } + cmd := exec.Command("sudo", "-n", "-u", h.Account, "--", h.Exe, HelperArg) + cmd.Dir = "/" + // One language for what sudo says when it refuses, so its words are read the same on every machine. + cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C") + in, err := cmd.StdinPipe() + if err != nil { + return nil, nil, nil, err + } + out, err := cmd.StdoutPipe() + if err != nil { + return nil, nil, nil, err + } + var stderr strings.Builder + cmd.Stderr = &limited{w: &stderr, left: 4096} + if err := cmd.Start(); err != nil { + return nil, nil, nil, fmt.Errorf("the helper could not be started as %s: %w", h.Account, err) + } + return in, out, func() { + done := make(chan struct{}) + go func() { _ = cmd.Wait(); close(done) }() + select { + case <-done: + case <-time.After(2 * time.Second): + _ = cmd.Process.Kill() + <-done + } + if s := strings.TrimSpace(stderr.String()); s != "" { + say("the helper as %s said: %s", h.Account, s) + } + }, nil +} + +type limited struct { + w io.Writer + left int +} + +func (l *limited) Write(p []byte) (int, error) { + n := len(p) + if l.left <= 0 { + return n, nil + } + if len(p) > l.left { + p = p[:l.left] + } + l.left -= len(p) + _, _ = l.w.Write(p) + return n, nil +} + +// ---- the helper ------------------------------------------------------------------------------------------- + +// ServeHelper is the helper: one answer per request, until its input ends. It runs as the agent's account and +// does only what that account may, on its own home: the account's permissions are the boundary. +func ServeHelper(in io.Reader, out io.Writer) error { + dec := json.NewDecoder(bufio.NewReaderSize(in, 1<<20)) + enc := json.NewEncoder(out) + for { + var op Op + if err := dec.Decode(&op); err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + if err := enc.Encode(serveOne(op)); err != nil { + return err + } + } +} + +func serveOne(op Op) Answer { + if !filepath.IsAbs(op.Path) || (op.To != "" && !filepath.IsAbs(op.To)) { + return Answer{Err: "a path must be absolute"} + } + mode := os.FileMode(op.Mode).Perm() + var a Answer + var err error + switch op.Op { + case "read": + a.Data, err = os.ReadFile(op.Path) + case "write": + err = os.WriteFile(op.Path, op.Data, mode) + case "create": + var f *os.File + if f, err = os.OpenFile(op.Path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode); err == nil { + _, werr := f.Write(op.Data) + err = errors.Join(werr, f.Close()) + } + case "rename": + err = os.Rename(op.Path, op.To) + case "remove": + err = os.Remove(op.Path) + case "remove-all": + err = os.RemoveAll(op.Path) + case "mkdir": + err = os.MkdirAll(op.Path, mode) + case "chmod": + err = os.Chmod(op.Path, mode) + case "stat", "lstat": + var i fs.FileInfo + if op.Op == "stat" { + i, err = os.Stat(op.Path) + } else { + i, err = os.Lstat(op.Path) + } + if err == nil { + a.Info = infoOf(i) + } + case "readdir": + var entries []fs.DirEntry + entries, err = os.ReadDir(op.Path) + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + for _, e := range entries { + if i, ierr := e.Info(); ierr == nil { + a.Entries = append(a.Entries, infoOf(i)) + } + } + default: + return Answer{Err: "no such operation: " + op.Op} + } + if err != nil { + a = Answer{Err: err.Error()} + switch { + case errors.Is(err, fs.ErrNotExist): + a.Kind = "not-exist" + case errors.Is(err, fs.ErrExist): + a.Kind = "exist" + case errors.Is(err, fs.ErrPermission): + a.Kind = "permission" + } + } + return a +} diff --git a/modules/claude-code/cmd/claude-code/identity.go b/modules/claude-code/cmd/claude-code/identity.go index 241e8f4f..31cb31a9 100644 --- a/modules/claude-code/cmd/claude-code/identity.go +++ b/modules/claude-code/cmd/claude-code/identity.go @@ -8,7 +8,6 @@ package main import ( "bytes" "encoding/json" - "os" ) // Identity is an account as the agent's state file names it. @@ -19,7 +18,7 @@ type Identity struct { } func readState(path string) (map[string]any, bool) { - raw, err := os.ReadFile(path) + raw, err := fsys.ReadFile(path) if err != nil { return nil, false } @@ -54,7 +53,7 @@ func ReadIdentity(path string) *Identity { func WriteIdentity(path string, id Identity) (bool, error) { m, ok := readState(path) if !ok { - if _, err := os.Stat(path); err == nil { + if _, err := fsys.Stat(path); err == nil { return false, nil } m = map[string]any{} @@ -77,8 +76,8 @@ func WriteIdentity(path string, id Identity) (bool, error) { return false, err } tmp := path + ".mesh-tmp" - if err := os.WriteFile(tmp, raw, 0o600); err != nil { + if err := fsys.WriteFile(tmp, raw, 0o600); err != nil { return false, err } - return true, os.Rename(tmp, path) + return true, fsys.Rename(tmp, path) } diff --git a/modules/claude-code/cmd/claude-code/main.go b/modules/claude-code/cmd/claude-code/main.go index da36b049..78da044e 100644 --- a/modules/claude-code/cmd/claude-code/main.go +++ b/modules/claude-code/cmd/claude-code/main.go @@ -234,7 +234,11 @@ func status(p Paths) map[string]any { for n := range Registered(p) { names = append(names, n) } - return map[string]any{"node": p.Node, "licence": licence, "token": token, "holdings": HoldingsOf(p), + agent := map[string]any{"account": p.Agent, "home": p.Home, "own_account": p.Confined()} + if !p.Confined() { + agent["note"] = "agents here run as the operator's account" + } + return map[string]any{"node": p.Node, "agent": agent, "licence": licence, "token": token, "holdings": HoldingsOf(p), "managed": managed, "registered": names} } @@ -372,7 +376,19 @@ func main() { if len(os.Args) == 3 && os.Args[1] == "guard" { os.Exit(runGuard(os.Args[2], os.Stdin, os.Stderr)) } + // The helper that touches the agent's home as the agent's account (novox/hq ADR 0266), started by this + // module through sudo: one answer per request on stdin, until it ends. + if len(os.Args) == 2 && os.Args[1] == HelperArg { + if err := ServeHelper(os.Stdin, os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } p, launched := PathsFrom(os.Getenv) + if launched { + fsys = FilesFor(p) + } if !launched { // Outside a launch — a build, a check — it serves nothing and says why. say("not launched by the runtime with this module's words; serving no tools") @@ -515,7 +531,7 @@ func run(p Paths, view *ServerView, configView *ConfigView) { report() var last string for range time.Tick(5 * time.Second) { - info, err := os.Stat(p.credentials()) + info, err := fsys.Stat(p.credentials()) now := "absent" if err == nil { now = fmt.Sprintf("%d/%d", info.ModTime().UnixNano(), info.Size()) diff --git a/modules/claude-code/cmd/claude-code/mesh.go b/modules/claude-code/cmd/claude-code/mesh.go index 9262116e..71b01b93 100644 --- a/modules/claude-code/cmd/claude-code/mesh.go +++ b/modules/claude-code/cmd/claude-code/mesh.go @@ -258,6 +258,10 @@ func KeepMeshTools(p Paths, m MeshTools) bool { // GuardDataOf is what the guard judges with on this machine. func GuardDataOf(p Paths, m *MeshTools) GuardData { d := GuardData{Node: p.Node, Log: filepath.Join(p.State, "guard.log"), Machines: []Machine{}, Replacements: []Replacement{}} + if p.Confined() { + // The session's account cannot write the module's state, and must not rewrite its own record (ADR 0266). + d.Agent, d.Log = p.Agent, JournalRecord+GuardJournalID + } if m != nil { d.Machines, d.Replacements = m.Machines, m.Replacements } @@ -314,7 +318,10 @@ func GuardStatus(p Paths, last int) map[string]any { "writing /etc/hosts or /etc/resolv.conf, by the shell or the agent's Edit and Write", "HOSTALIASES, named anywhere in a command", "any command naming " + OverrideVar, + "where the machine names an agent account of its own (novox/hq ADR 0266): every command and file edit of a " + + "session that does not run as it, whatever it is, and no override lifts it", }, + "agent_account": map[bool]string{true: p.Agent, false: "none of its own: agents run as the operator's account"}[p.Confined()], "override": OverrideVar + "=, set in the operator's own shell before the session starts; read from the session's " + "environment as it was started, recorded with why, and not honoured when it cannot be recorded", "hook": GuardCommand(), diff --git a/modules/claude-code/cmd/claude-code/node.go b/modules/claude-code/cmd/claude-code/node.go index f17c9430..90a7dc01 100644 --- a/modules/claude-code/cmd/claude-code/node.go +++ b/modules/claude-code/cmd/claude-code/node.go @@ -23,6 +23,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "sort" "strings" "sync" @@ -46,9 +47,12 @@ func Fingerprint(s string) string { // Paths are where this node's files are, from the module's words (ADR 0192). // -// Agent, Operator, AgentHome and OperatorHome are the accounts the runtime names (novox/hq ADR 0266: -// MESH_AGENT_ACCOUNT, MESH_OPERATOR_ACCOUNT, their homes). Read here only to judge whose home the home scope -// writes (novox/hq issue 340); absent, the agent runs as the operator. +// Home is the home of the account the agent runs as, and Agent that account (novox/hq ADR 0266): the runtime's +// MESH_AGENT_ACCOUNT and MESH_AGENT_HOME, which the controller sets to the operator's account where the node +// names no agent account of its own — and which a controller older than that record does not set, so the +// operator's are read then. Operator is the operator's account: Confined says the agent's is another. +// AgentHome and OperatorHome are the runtime's words as given, read to judge whose home the home scope writes +// (novox/hq issue 340). type Paths struct { State, Facts, Settings, Home, Node string Agent, Operator, AgentHome, OperatorHome string @@ -57,9 +61,12 @@ type Paths struct { // PathsFrom reads them, or answers false outside a launch. func PathsFrom(env func(string) string) (Paths, bool) { p := Paths{State: env("MESH_CLAUDE_CODE_STATE"), Facts: env("MESH_CLAUDE_CODE_FACTS"), - Settings: env("MESH_CLAUDE_CODE_SETTINGS"), Home: env("MESH_OPERATOR_HOME"), Node: env("MESH_NODE"), + Settings: env("MESH_CLAUDE_CODE_SETTINGS"), Home: env("MESH_AGENT_HOME"), Node: env("MESH_NODE"), Agent: env("MESH_AGENT_ACCOUNT"), Operator: env("MESH_OPERATOR_ACCOUNT"), AgentHome: env("MESH_AGENT_HOME"), OperatorHome: env("MESH_OPERATOR_HOME")} + if p.Home == "" || p.Agent == "" { + p.Home, p.Agent, p.AgentHome = env("MESH_OPERATOR_HOME"), p.Operator, env("MESH_OPERATOR_HOME") + } return p, p.State != "" && p.Facts != "" && p.Settings != "" && p.Home != "" && p.Node != "" } @@ -112,11 +119,28 @@ func HomeScopeRefused(p Paths, target string) string { func (p Paths) credentials() string { return filepath.Join(p.Home, ".claude", ".credentials.json") } func (p Paths) account() string { return filepath.Join(p.Home, ".claude.json") } func (p Paths) binding() string { return filepath.Join(p.State, "licence.json") } -func (p Paths) apiKey() string { return filepath.Join(p.State, "api-key") } -func (p Paths) helper() string { return filepath.Join(p.State, "api-key-helper") } -func (p Paths) registry() string { return filepath.Join(p.State, "mcp-servers.json") } -func (p Paths) config() string { return filepath.Join(p.State, "config.json") } -func (p Paths) placed() string { return filepath.Join(p.State, "home-placed.json") } +func (p Paths) apiKey() string { return filepath.Join(p.keyDir(), "api-key") } +func (p Paths) helper() string { return filepath.Join(p.keyDir(), "api-key-helper") } + +// keyDir is where an API-key licence and its key-helper live: the module's state, which the agent runs the +// helper from as the operator — or, where the agent runs as an account of its own, which cannot enter the +// module's state, a directory of the module's in that account's agent directory (novox/hq ADR 0266). Either +// way readable by the account the agent runs as, and by it alone. +func (p Paths) keyDir() string { + if p.Confined() { + return filepath.Join(p.Home, ".claude", "mesh") + } + return p.State +} +func (p Paths) registry() string { return filepath.Join(p.State, "mcp-servers.json") } +func (p Paths) config() string { return filepath.Join(p.State, "config.json") } +func (p Paths) placed() string { + if p.Confined() { + // One record per home: what was placed in the operator's is not in the agent's (ADR 0266). + return filepath.Join(p.State, "home-placed-"+p.Agent+".json") + } + return filepath.Join(p.State, "home-placed.json") +} // Ask is a tool on the bus: its address and arguments in, its JSON answer out. type Ask func(address string, args any) (json.RawMessage, error) @@ -125,7 +149,7 @@ type Ask func(address string, args any) (json.RawMessage, error) type WriteManaged func(name, content string) (string, error) func readJSON(path string, into any) bool { - raw, err := os.ReadFile(path) + raw, err := fsys.ReadFile(path) return err == nil && json.Unmarshal(raw, into) == nil } @@ -133,20 +157,20 @@ func readJSON(path string, into any) bool { // moving to this binary keeps the key it had. func Keypair(p Paths) (KeyPair, error) { priv, pub := filepath.Join(p.State, "key.pem"), filepath.Join(p.State, "key.pub.pem") - if _, err := os.Stat(priv); errors.Is(err, os.ErrNotExist) { + if _, err := fsys.Stat(priv); errors.Is(err, os.ErrNotExist) { k, err := GenerateKeyPair() if err != nil { return KeyPair{}, err } - if err := os.WriteFile(priv, []byte(k.PrivateKey), 0o600); err != nil { + if err := fsys.WriteFile(priv, []byte(k.PrivateKey), 0o600); err != nil { return KeyPair{}, err } - if err := os.WriteFile(pub, []byte(k.PublicKey), 0o644); err != nil { + if err := fsys.WriteFile(pub, []byte(k.PublicKey), 0o644); err != nil { return KeyPair{}, err } } - a, err1 := os.ReadFile(priv) - b, err2 := os.ReadFile(pub) + a, err1 := fsys.ReadFile(priv) + b, err2 := fsys.ReadFile(pub) return KeyPair{PrivateKey: string(a), PublicKey: string(b)}, errors.Join(err1, err2) } @@ -234,6 +258,9 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) { for _, line := range leftOut { out = append(out, "left out of the terminal's shared text: "+line) } + // Where the agent moved to an account of its own, what this module placed for it under the operator's home + // goes (ADR 0266). + out = append(out, RetireOperatorHome(p)...) return out, errors.Join(failed...) } @@ -279,7 +306,7 @@ type Holdings struct { func HoldingsOf(p Paths) Holdings { h := Holdings{Node: p.Node, Identity: ReadIdentity(p.account())} creds := ReadCredentials(p.credentials()) - if info, err := os.Stat(p.credentials()); err == nil { + if info, err := fsys.Stat(p.credentials()); err == nil { at := info.ModTime().UTC().Format("2006-01-02T15:04:05.000Z") h.ChangedAt = &at } @@ -297,7 +324,7 @@ func HoldingsOf(p Paths) Holdings { }{Fingerprint(g.AccessToken), g.ExpiresAt} } kind := "" - if _, err := os.Stat(p.apiKey()); err == nil { + if _, err := fsys.Stat(p.apiKey()); err == nil { kind = "api-key" } else if h.Access != nil { kind = "subscription" @@ -371,7 +398,8 @@ func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, e return "this node's binding was released; it keeps its last token until it expires", nil } var applied Binding - if readJSON(p.binding(), &applied) && applied.Generation == b.Generation && applied.Licence == b.Licence { + if readJSON(p.binding(), &applied) && applied.Generation == b.Generation && applied.Licence == b.Licence && + holdsApplied(p, applied.Kind) { return "", nil } out, err := Pull(p, ask, write) @@ -398,13 +426,16 @@ func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) { switched := !had || previous.Licence != c.Licence out := map[string]any{"applied": true, "licence": c.Licence, "kind": c.Kind, "switched": switched} if c.Kind == "api-key" { - if err := os.WriteFile(p.apiKey(), []byte(strings.TrimSpace(plain)+"\n"), 0o600); err != nil { + if err := fsys.MkdirAll(p.keyDir(), 0o700); err != nil { return nil, err } - if err := os.WriteFile(p.helper(), []byte("#!/bin/sh\nexec cat '"+p.apiKey()+"'\n"), 0o700); err != nil { + if err := fsys.WriteFile(p.apiKey(), []byte(strings.TrimSpace(plain)+"\n"), 0o600); err != nil { return nil, err } - _ = os.Chmod(p.helper(), 0o700) + if err := fsys.WriteFile(p.helper(), []byte("#!/bin/sh\nexec cat '"+p.apiKey()+"'\n"), 0o700); err != nil { + return nil, err + } + _ = fsys.Chmod(p.helper(), 0o700) } else { var g Grant if err := json.Unmarshal([]byte(plain), &g); err != nil { @@ -426,10 +457,12 @@ func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) { out = map[string]any{"applied": false, "licence": c.Licence, "reason": d.Reason} } // Away from the API key: it goes, with its helper. - _ = os.Remove(p.apiKey()) - _ = os.Remove(p.helper()) + _ = fsys.Remove(p.apiKey()) + _ = fsys.Remove(p.helper()) } - if switched && c.Identity != nil && c.Identity.AccountUUID != "" { + // The account file follows the token: on a switch, and wherever the agent's account file names no account + // yet — a home the agent has just moved to (ADR 0266) — so the agent shows the account whose token it holds. + if (switched || ReadIdentity(p.account()) == nil) && c.Identity != nil && c.Identity.AccountUUID != "" { changed, err := WriteIdentity(p.account(), *c.Identity) if err == nil { out["account"] = map[bool]string{true: "updated", false: "unchanged"}[changed] @@ -440,7 +473,7 @@ func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) { gen = previous.Generation } raw, _ := json.Marshal(Binding{Licence: c.Licence, Kind: c.Kind, Generation: gen}) - if err := os.WriteFile(p.binding(), append(raw, '\n'), 0o600); err != nil { + if err := fsys.WriteFile(p.binding(), append(raw, '\n'), 0o600); err != nil { return nil, err } // The key-helper comes or goes with the licence's kind. @@ -452,19 +485,66 @@ func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) { return out, nil } +// apiKeyShape is an Anthropic API key; apiKeyFileMax the most a file holding one is read for. +var apiKeyShape = regexp.MustCompile(`^sk-ant-[A-Za-z0-9_-]{20,300}$`) + +const apiKeyFileMax = 4096 + +// agentsOwnFile is a path under the home of the account the agent runs as, with no symbolic link between the +// home and it, the file itself included; refused otherwise. +func agentsOwnFile(p Paths, file string) (string, error) { + if p.Home == "" || !filepath.IsAbs(file) { + return "", fmt.Errorf("%s: the key file is named by its full path, or ~/…, in the agent's home", file) + } + home, clean := filepath.Clean(p.Home), filepath.Clean(file) + rel, err := filepath.Rel(home, clean) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("%s is not in the agent's home (%s): the key file is taken only from there", file, home) + } + at := home + for _, part := range strings.Split(rel, string(filepath.Separator)) { + at = filepath.Join(at, part) + info, err := fsys.Lstat(at) + if err != nil { + return "", fmt.Errorf("the key file: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("%s is a symbolic link: the key file is taken only from the agent's home itself", at) + } + } + return clean, nil +} + // AddAPIKey adds an API key from a file on this node to the licence manager (ADR 0209): sealed to the // manager's public key, handed to the seat's `adopt` on request/reply, and the file removed once taken. With // useHere, this node is switched to the new licence. The key never crosses the bus in the clear and is // never an argument. +// +// **The file is the agent's, never the runtime's** (novox/hq ADR 0266). The tool reads a file and removes it, +// and the runtime runs as the operator's account: a path anywhere would let whoever calls it take the runtime's +// bus credential or the operator's keys off the machine, sealed, and delete the original. So the file must lie +// under the home of the account the agent runs as, reached through no symbolic link, and is read as that +// account; what it holds must be an Anthropic API key, or nothing is handed over and nothing removed. func AddAPIKey(p Paths, name, file string, useHere bool, ask Ask) (map[string]any, error) { - raw, err := os.ReadFile(file) + file, err := agentsOwnFile(p, file) if err != nil { - return nil, fmt.Errorf("the key is read from a file on this node: %w", err) + return nil, err + } + raw, err := fsys.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("the key is read from a file in the agent's home: %w", err) + } + if len(raw) > apiKeyFileMax { + return nil, fmt.Errorf("%s is not a key file: larger than %d bytes; nothing handed over", file, apiKeyFileMax) } key := strings.TrimSpace(string(raw)) if key == "" { return nil, fmt.Errorf("%s is empty", file) } + if !apiKeyShape.MatchString(key) { + // Never the content: the file may hold a secret of another kind. + return nil, fmt.Errorf("%s does not hold an Anthropic API key (sk-ant-…); nothing handed over, nothing removed", file) + } answer, err := ask(SeatVerb("public-key"), map[string]any{}) if err != nil { return nil, err @@ -485,7 +565,7 @@ func AddAPIKey(p Paths, name, file string, useHere bool, ask Ask) (map[string]an } out := map[string]any{"adopted": json.RawMessage(adopted)} // Taken: the key now lives encrypted in the manager's store alone. - if err := os.Remove(file); err != nil { + if err := fsys.Remove(file); err != nil { out["file"] = "could not be removed: " + err.Error() } else { out["file"] = "removed" @@ -575,11 +655,11 @@ func (v *ServerView) Effective() Servers { func (v *ServerView) writeThrough() bool { now, _ := indented(v.Effective()) - before, _ := os.ReadFile(v.p.registry()) + before, _ := fsys.ReadFile(v.p.registry()) if string(before) == string(now) { return false } - _ = os.WriteFile(v.p.registry(), now, 0o600) + _ = fsys.WriteFile(v.p.registry(), now, 0o600) return true } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index f59a7c39..82ee30cf 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -640,6 +640,10 @@ func (m *Manager) Release(ctx context.Context, consumer, by string) (map[string] var licenceName = regexp.MustCompile(`^[A-Za-z0-9@._-]+$`) +// apiKeyShape is what an Anthropic API key looks like. A key of another shape is refused before it is kept, +// so a file that is not a key (a credential, a private key) is never adopted as one (novox/hq ADR 0266). +var apiKeyShape = regexp.MustCompile(`^sk-ant-[A-Za-z0-9_-]{20,300}$`) + // AdoptKey adopts an API key from a file on this node — never an argument (design 39 §6). func (m *Manager) AdoptKey(ctx context.Context, name, file string) (map[string]any, error) { raw, err := os.ReadFile(file) @@ -666,6 +670,10 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str if key == "" { return nil, errors.New("the key is empty") } + if !apiKeyShape.MatchString(key) { + // Never the content: what was handed may be a secret of another kind. + return nil, errors.New("what was handed over is not an Anthropic API key (sk-ant-…): nothing adopted") + } held, err := m.Store.Licence(ctx, name) if err != nil { return nil, err diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go index 5157d7d0..de0bbe0c 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -230,7 +230,7 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) { t.Fatalf("a rotation went from %d to %d", g0, g1) } file := filepath.Join(t.TempDir(), "key") - _ = os.WriteFile(file, []byte("sk-ant-api-key\n"), 0o600) + _ = os.WriteFile(file, []byte("sk-ant-api03-key-0123456789abcdefghij\n"), 0o600) if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil { t.Fatal(err) } @@ -403,13 +403,13 @@ func TestALoginThatDoesNotRefreshMovesNothing(t *testing.T) { func TestAnAPIKeySealedByANodeIsAdopted(t *testing.T) { mm := newMesh(t) ctx := context.Background() - box, _ := Seal("sk-ant-api03-test\n", mm.keys.PublicKey) + box, _ := Seal("sk-ant-api03-test-0123456789abcdefghij\n", mm.keys.PublicKey) r, err := mm.m.AdoptSealedKey(ctx, "api", box, "laptop") if err != nil || r["adopted"] != true { t.Fatalf("%v %v", r, err) } l, _ := mm.store.Licence(ctx, "api") - if plain, _ := mm.m.Crypt.Open(l.Sealed); plain != "sk-ant-api03-test" { + if plain, _ := mm.m.Crypt.Open(l.Sealed); plain != "sk-ant-api03-test-0123456789abcdefghij" { t.Fatalf("stored %q", plain) } other, _ := GenerateKeyPair() @@ -438,3 +438,19 @@ func TestARebuiltStoreNeverGivesAGenerationANodeHasPassed(t *testing.T) { t.Fatalf("the laptop applied generation 16 and was given %d", g) } } + +// What is not an API key is never adopted as one, and the refusal never repeats it (novox/hq ADR 0266). +func TestWhatIsNotAnAPIKeyIsRefused(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + for _, notAKey := range []string{"-----BEGIN OPENSSH PRIVATE KEY-----\nabc", "SUAOPERATORNKEYSEED", "sk-ant-short"} { + box, _ := Seal(notAKey, mm.keys.PublicKey) + _, err := mm.m.AdoptSealedKey(ctx, "api", box, "laptop") + if err == nil || strings.Contains(err.Error(), notAKey) { + t.Fatalf("%q: refused without repeating it, got %v", notAKey, err) + } + } + if l, _ := mm.store.Licence(ctx, "api"); l != nil { + t.Fatal("nothing was kept") + } +}