claude-code: write the agent's home as its own account where it has one

On the control node agents are to run as an account that cannot become root
(hq ADR 0266). The runtime keeps running as the operator, so the token, the
identity and the home-scope items go to the agent's home through a helper
started as that account; the licence and the account file follow the move,
what was placed in the operator's home goes once the agent holds the licence,
and the guard refuses every tool of a session running as anyone else,
recording to the journal. An API key is taken only from the agent's home and
only when it is one, here and in the licence manager, so the tool cannot carry
off the runtime's credential. Unchanged where no agent account is named.
This commit is contained in:
jochen
2026-10-09 10:14:18 +02:00
parent 7339ce8556
commit b57e6e8144
17 changed files with 1467 additions and 102 deletions
+110 -30
View File
@@ -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
}