claude-code: refuse registering what a session runs, so no agent can plant it in the operator's sessions
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: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request

The tool runner is not told who called, so a hook, a managed setting, a
permission rule or a tool server registered through these tools could come
from an agent confined on another node and run as the operator there
(hq issue 340). They are set at the controller's terminal now; a skill,
subagent or command that runs something by itself is refused too, kept
hooks and settings are never rendered, and claude_code_registrations
lists what is registered and by which node.
This commit is contained in:
jochen
2026-10-09 02:30:11 +02:00
parent a1406e7d79
commit 736743fedc
11 changed files with 650 additions and 336 deletions
+22 -30
View File
@@ -126,7 +126,7 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
}
var items map[string]Item
_ = readJSON(p.config(), &items)
config := ConfigOf(items)
config := ConfigOf(Taken(items))
mesh := ReadMeshTools(p)
if mesh != nil {
mesh.Refused = RefusedCounts(GuardDataOf(p, mesh).Log)
@@ -446,6 +446,7 @@ type ServerState interface {
Put(key string, value any) error
Delete(key string) error
Keys() ([]string, error)
Get(key string) (json.RawMessage, bool, error)
}
// ServerChange is one change to the `servers` state, as a watch hands it over.
@@ -528,15 +529,22 @@ func OnServerChange(v *ServerView, c ServerChange, p Paths, write WriteManaged)
return what + " " + c.Key, nil
}
// RegisterServer registers (or, with no entry, unregisters) a server: a put (or delete) per scope in the
// `servers` state, taken into this node's view at once so the answer says what it did here; every other
// node takes it from its watch, and a node that joins later from the current state.
// ServerAtTheTerminal is why a tool server is never registered through this module (novox/hq issue 340): every
// session on the nodes it reaches starts it, a stdio server as a program run as whoever started the session, and
// an http one is handed what the session asks — and the tool runner is not told who called it.
const ServerAtTheTerminal = "a tool server is started, or handed what the agent asks it, by every session on the nodes it " +
"reaches, as whoever starts that session — the operator, whose account holds root — and the tool runner is not " +
"told who called it, so an agent on another node could register one. It is set at the controller's terminal " +
"only, in this module's mcp_servers setting: mesh-controller settings set claude-code '{\"mcp_servers\": {...}}' " +
"[--node <node>] (novox/hq issue 340). Nothing was registered"
// RegisterServer unregisters a server: a delete per scope in the `servers` state, taken into this node's view at
// once so the answer says what it did here; every other node takes it from its watch. A registration — an entry
// given — is refused (ServerAtTheTerminal): what is in the state from before stays, and is only ever removed here.
func RegisterServer(p Paths, r Registration, servers ServerState, v *ServerView, write WriteManaged,
others func() ([]string, error)) (map[string]any, error) {
if r.Entry != nil {
if problem := EntryProblem(r.Name, r.Entry); problem != "" {
return map[string]any{"registered": false, "reason": problem}, nil
}
return nil, errors.New(ServerAtTheTerminal)
}
scopes := r.Nodes
if len(scopes) == 0 {
@@ -547,20 +555,10 @@ func RegisterServer(p Paths, r Registration, servers ServerState, v *ServerView,
before, _ := json.Marshal(v.Effective())
for _, scope := range scopes {
key := KeyOf(scope, r.Name)
var err error
if r.Entry != nil {
err = servers.Put(key, r.Entry)
} else {
err = servers.Delete(key)
}
if err != nil {
if err := servers.Delete(key); err != nil {
return nil, err
}
op := "put"
if r.Entry == nil {
op = "delete"
}
v.Take(ServerChange{Key: key, Op: op, Value: r.Entry})
v.Take(ServerChange{Key: key, Op: "delete"})
}
after, _ := json.Marshal(v.Effective())
changed := string(before) != string(after)
@@ -568,11 +566,7 @@ func RegisterServer(p Paths, r Registration, servers ServerState, v *ServerView,
for _, s := range scopes {
here = here || s == "all" || s == p.Node
}
verb := "registered"
if r.Entry == nil {
verb = "unregistered"
}
answer := map[string]any{verb: r.Name, "on": scopes}
answer := map[string]any{"unregistered": r.Name, "on": scopes}
switch {
case !here:
answer["here"] = "not this node"
@@ -588,10 +582,8 @@ func RegisterServer(p Paths, r Registration, servers ServerState, v *ServerView,
}
answer["rendered"] = rendered
}
if r.Entry == nil {
if _, still := v.Effective()[r.Name]; still {
answer["still"] = r.Name + " still applies here from another registration (for every node, or for this one); unregister that too"
}
if _, still := v.Effective()[r.Name]; still {
answer["still"] = r.Name + " still applies here from another registration (for every node, or for this one); unregister that too"
}
if len(r.Nodes) == 0 {
// The question the operator wanted asked: here only, or more?
@@ -604,8 +596,8 @@ func RegisterServer(p Paths, r Registration, servers ServerState, v *ServerView,
}
}
if len(elsewhere) > 0 {
answer["also"] = fmt.Sprintf("claude-code also runs on %s. To %s it there too, call again with nodes: \"all\" or a list of those nodes.",
strings.Join(elsewhere, ", "), map[bool]string{true: "register", false: "unregister"}[r.Entry != nil])
answer["also"] = fmt.Sprintf("claude-code also runs on %s. To unregister it there too, call again with nodes: \"all\" or a list of those nodes.",
strings.Join(elsewhere, ", "))
} else {
answer["also"] = "To do the same on every node running claude-code, call again with nodes: \"all\"."
}