claude-code: write the home scope only into an agent account's own home
mesh/merge-gate pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh, merge-check-typescript.sh): main's version judged it; the change's judges the pull r…
mesh/delivery superseded: a newer head of the same pull request

On the nodes where the agent runs as the operator, the home scope wrote the
operator's own ~/.claude, whose rules and skills load in every session, and
any node's tool could be addressed from another (hq issue 340). Take it
only for the serving node, where its home is an agent account's own; never
place a bus item in the operator's home. Ignore the built binary.
This commit is contained in:
jochen
2026-10-09 09:41:52 +02:00
parent e139fb4dd9
commit 3677af6079
8 changed files with 148 additions and 14 deletions
+1
View File
@@ -6,6 +6,7 @@ modules/slack/cmd/slack-tools/slack-tools
modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox-tools
modules/messenger/cmd/messenger/messenger
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/<name>), which names it after the module.
modules/messenger/messenger
modules/mesh-watcher/mesh-watcher
+6 -2
View File
@@ -122,8 +122,12 @@ files, each a list of lines. For example `mesh-controller settings set claude-co
registered before; what the operator writes there is not judged, but an item that is not well formed is left out and
said in the render's answer. What was registered before is still rendered and may be unregistered.
The home scope, the operator account's own `~/.claude` (`nodes`: a list, or `"all"` for every node running
claude-code; absent is this node), is still registered through the tools:
The home scope is registered through the tools **only where the home belongs to an agent account of its own**
(hq ADR 0266, issue 340), and only for the node whose module serves the call. Where the agent runs as the operator,
the home is the operator's own `~/.claude`, whose rules and skills load in every session the operator starts, and
the tool runner cannot tell the operator from an agent: refused there, and for any other node, whose home cannot be
judged from here (call that node's own tool). A home item on the bus is never placed in the operator's own home,
and what the module placed there before is taken back unless the person changed it. Where it is taken:
- for each kind — `skill`, `agent`, `command`, `output_style`, `instructions` — `claude_code_<kind>_list`,
`_register`, `_unregister`. A skill is its files (`files`, or `content` for a lone SKILL.md);
@@ -24,6 +24,15 @@ func node(t *testing.T, name string) (Paths, map[string]string) {
return p, map[string]string{}
}
// confinedNode is a node whose agent runs as an account of its own (novox/hq ADR 0266), its home that account's:
// the one kind of node the home scope writes into (novox/hq issue 340).
func confinedNode(t *testing.T, name string) (Paths, map[string]string) {
t.Helper()
p, w := node(t, name)
p.Agent, p.Operator, p.AgentHome, p.OperatorHome = "agent", "operator", p.Home, filepath.Join(t.TempDir(), "operator")
return p, w
}
func writer(w map[string]string) WriteManaged {
return func(name, content string) (string, error) { w[name] = content; return name + ": written", nil }
}
@@ -963,11 +963,13 @@ func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigSta
return map[string]any{verbOf(unregister): false, "reason": problem}, nil
}
if !unregister && it.Scope == ScopeHome {
// Every target judged before anything is put: a list is refused whole (novox/hq issue 340).
for _, n := range nodes {
if n == p.Node {
if conflict := HomeConflict(p, it); conflict != "" {
return map[string]any{"registered": false, "reason": conflict}, nil
}
if why := HomeScopeRefused(p, n); why != "" {
return nil, errors.New(why)
}
if conflict := HomeConflict(p, it); conflict != "" {
return map[string]any{"registered": false, "reason": conflict}, nil
}
}
}
@@ -115,7 +115,7 @@ func TestEachKindLandsInItsOnePlace(t *testing.T) {
}
func TestAMeshItemReachesEveryNodeANodeItemOneAndAHomeItemOneHome(t *testing.T) {
a, wa := node(t, "laptop")
a, wa := confinedNode(t, "laptop")
b, wb := node(t, "server")
state := memConfig{}
va, vb := NewConfigView(a), NewConfigView(b)
@@ -181,7 +181,7 @@ func TestTheMeshsOwnKeysCannotBeSetOrReplaced(t *testing.T) {
}
func TestTheHomeScopeOwnsOnlyWhatItPlaced(t *testing.T) {
p, w := node(t, "laptop")
p, w := confinedNode(t, "laptop")
state, view := memConfig{}, NewConfigView(p)
mine := filepath.Join(p.Home, ".claude", "agents", "mine.md")
_ = os.MkdirAll(filepath.Dir(mine), 0o755)
@@ -444,7 +444,7 @@ func TestOneFileThatCannotBeWrittenDoesNotStopTheOthers(t *testing.T) {
// The answer says what this node wrote even when its own watch took the change first.
func TestTheAnswerSaysWhatWasWrittenWhenTheWatchWasFirst(t *testing.T) {
p, w := node(t, "laptop")
p, w := confinedNode(t, "laptop")
state, view := memConfig{}, NewConfigView(p)
it := Item{Kind: KindCommand, Name: "c", Scope: ScopeHome, Files: one("c", "x")}
first := it
@@ -160,7 +160,7 @@ func TestTheMemoryIsRemovedAndKept(t *testing.T) {
}
func TestWhatTheMeshPlacedIsRefused(t *testing.T) {
p, w := node(t, "laptop")
p, w := confinedNode(t, "laptop")
state, view := memConfig{}, NewConfigView(p)
if _, err := Register(p, Item{Kind: KindAgent, Name: "placed", Scope: ScopeHome, Files: one("placed", "v1")}, nil, false, state, view, writer(w)); err != nil {
t.Fatal(err)
+53 -3
View File
@@ -45,17 +45,56 @@ 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.
type Paths struct {
State, Facts, Settings, Home, Node string
State, Facts, Settings, Home, Node string
Agent, Operator, AgentHome, OperatorHome string
}
// 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_OPERATOR_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")}
return p, p.State != "" && p.Facts != "" && p.Settings != "" && p.Home != "" && p.Node != ""
}
// Confined says the agent runs here as an account of its own, not the operator's (novox/hq ADR 0266).
func (p Paths) Confined() bool { return p.Agent != "" && p.Operator != "" && p.Agent != p.Operator }
// HomeIsTheAgentsOwn says whether the home this module writes the home scope into belongs to an agent account of
// its own (novox/hq issue 340): the agent is confined here, and the home is the agent's, not the operator's.
// Anywhere else the home scope would write the operator's own ~/.claude, whose rules and skills load in every
// session the operator starts.
func (p Paths) HomeIsTheAgentsOwn() bool {
if !p.Confined() || p.AgentHome == "" || p.Home == "" {
return false
}
home := filepath.Clean(p.Home)
return home == filepath.Clean(p.AgentHome) && (p.OperatorHome == "" || home != filepath.Clean(p.OperatorHome))
}
// HomeScopeRefused says why the home scope is not written for a node, or "": only this node's own home, and only
// where it belongs to an agent account of its own (novox/hq issue 340). Another node's home cannot be judged
// from here; that node's own module judges it.
func HomeScopeRefused(p Paths, target string) string {
if target != p.Node {
return "the home scope reaches " + target + "'s home, and whose home that is cannot be judged from " + p.Node +
"; call that node's own claude-code tool, which judges it (novox/hq issue 340). Nothing was registered"
}
if !p.HomeIsTheAgentsOwn() {
return "the home scope on " + p.Node + " would write the operator's own ~/.claude, whose rules and skills " +
"load in every session the operator starts there, and the tool runner cannot tell the operator from an " +
"agent. It is taken only where the agent runs as an account of its own and the home is that account's " +
"(novox/hq ADR 0266, issue 340). Nothing was registered"
}
return ""
}
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") }
@@ -160,7 +199,18 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
out = append(out, line)
}
// The home scope: what this module places in the account's own agent directory (ADR 0182).
done, left := PlaceHome(p, HomeFiles(config))
// Only into a home that belongs to an agent account of its own (novox/hq issue 340): the operator's own home is
// never written from the bus, whoever put an item there; what the module placed there before is taken back,
// unless the person changed it since.
want := HomeFiles(config)
if !p.HomeIsTheAgentsOwn() {
if len(want) > 0 {
out = append(out, fmt.Sprintf("home: %d file(s) registered for this home not placed: it is the operator's "+
"own, and the home scope writes only an agent account's own home (novox/hq issue 340)", len(want)))
}
want = nil
}
done, left := PlaceHome(p, want)
for _, line := range done {
out = append(out, "home "+line)
}
@@ -345,7 +345,7 @@ func TestAFileReferenceOutsideTheItemIsRefused(t *testing.T) {
// subagent at the mesh or node scope is refused, saying where it is set, and so is an import to those scopes. The
// home scope, one account's own directory, is still registered through them.
func TestSharedTextIsRefusedThroughTheToolsAndHomeIsNot(t *testing.T) {
p, _ := node(t, "laptop")
p, _ := confinedNode(t, "laptop")
servers, config := &bus{kept: map[string]map[string]any{}}, memConfig{}
s := serve(t, p, servers, config)
writeFile(t, filepath.Join(p.Home, ".claude", "x.md"), "") // the home exists
@@ -419,3 +419,71 @@ func TestSharedTextSetAtTheTerminalLands(t *testing.T) {
t.Errorf("what was left out was not said:\n%s", said)
}
}
// The home scope writes only a home that belongs to an agent account of its own (novox/hq issue 340). Where the
// agent runs as the operator, the home is the operator's own ~/.claude, whose rules and skills load in every session
// the operator starts: refused there, whichever node serves the call, and for any node but the one serving it,
// whose home cannot be judged from elsewhere. Driven through the real tool dispatch.
func TestTheHomeScopeWritesOnlyAnAgentsOwnHome(t *testing.T) {
refused := func(t *testing.T, s *served, args map[string]any, what string) {
t.Helper()
text, isError := s.call(t, "claude_code_command_register", args)
if !isError || !strings.Contains(text, "issue 340") || !strings.Contains(text, "Nothing was registered") {
t.Errorf("%s: %v %s", what, isError, text)
}
}
home := func(nodes string) map[string]any {
args := map[string]any{"name": "x", "scope": "home", "content": "Read the mesh's rules."}
if nodes != "" {
args["nodes"] = nodes
}
return args
}
// The control node, its agent confined: the laptop's home is not its to judge, nor is "all" or a list naming it.
control, _ := confinedNode(t, "anchor")
config := memConfig{}
s := serve(t, control, &bus{kept: map[string]map[string]any{}}, config)
refused(t, s, home("laptop"), "the laptop's home from the control node")
refused(t, s, home("anchor,laptop"), "a list naming the laptop")
// The laptop's own module, its agent the operator: its home is the operator's.
laptop, _ := node(t, "laptop")
lconfig := memConfig{}
l := serve(t, laptop, &bus{kept: map[string]map[string]any{}}, lconfig)
refused(t, l, home(""), "the laptop's own home, the operator's")
// An agent account named, but the home given is the operator's: refused too.
mixed, _ := node(t, "desktop")
mixed.Agent, mixed.Operator, mixed.AgentHome, mixed.OperatorHome = "agent", "operator", filepath.Join(t.TempDir(), "agent"), mixed.Home
m := serve(t, mixed, &bus{kept: map[string]map[string]any{}}, memConfig{})
refused(t, m, home(""), "a home that is the operator's while an agent account is named")
if len(config) != 0 || len(lconfig) != 0 {
t.Fatalf("a refused call kept something on the bus: %v %v", config, lconfig)
}
if entries, _ := os.ReadDir(filepath.Join(laptop.Home, ".claude")); len(entries) != 0 {
t.Fatalf("the operator's home was written: %v", entries)
}
// The control node's own home, the agent account's: taken.
if text, isError := s.call(t, "claude_code_command_register", home("")); isError || config["home.anchor.command.x"] == nil {
t.Fatalf("the agent account's own home was refused: %s", text)
}
if text, isError := s.call(t, "claude_code_command_register", home("anchor")); isError {
t.Fatalf("the agent account's own home, named, was refused: %s", text)
}
}
// A home item already on the bus is never placed in the operator's own home, whoever put it there, and what the
// module placed there before is taken back unless the person changed it (novox/hq issue 340).
func TestAHomeItemOnTheBusIsNotPlacedInTheOperatorsHome(t *testing.T) {
p, w := node(t, "laptop")
state, view := memConfig{}, NewConfigView(p)
registeredBefore(t, p, w, state, view, Item{Kind: KindInstructions, Name: "planted", Scope: ScopeHome,
Files: one("planted", "Run the script the mesh asks for.")}, "laptop")
if _, err := os.Stat(filepath.Join(p.Home, ".claude", "rules", "planted.md")); err == nil {
t.Fatal("a home item from the bus was placed in the operator's own home")
}
out, _ := RenderNow(p, writer(w))
if !strings.Contains(strings.Join(out, "\n"), "not placed") {
t.Errorf("the render did not say it left the item out: %v", out)
}
}