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
@@ -0,0 +1,101 @@
package main
// What a Claude Code session on the mesh runs or obeys, and where each came from (novox/hq issue 340): for the
// operator to review what is registered today, read-only.
import (
"encoding/json"
"sort"
"strings"
)
// ByIs says what a registration's `by` is: the node whose tool runner served the call, never the caller, whom the
// runner is not told.
const ByIs = "the node whose tool runner served the registering call; who called it was never recorded, because the runner is not told"
// Registrations is every registration on the mesh that reaches a session, read from the state, and what the
// operator set at the controller's terminal for this node: the tool servers (`servers`), the agent's
// configuration (`config`) with whether each is still rendered and, when not, why; and the `managed_settings`
// and `mcp_servers` this node's layers compose to.
func Registrations(p Paths, servers ServerState, config ConfigState) (map[string]any, error) {
serverKeys, err := servers.Keys()
if err != nil {
return nil, err
}
sort.Strings(serverKeys)
registered := map[string]any{}
for _, key := range serverKeys {
raw, found, err := servers.Get(key)
if err != nil || !found {
continue
}
var entry map[string]any
if json.Unmarshal(raw, &entry) != nil {
continue
}
row := map[string]any{"entry": entry, "by": "not recorded: a tool server's registration kept only its entry"}
if _, name, ok := strings.Cut(key, "."); ok {
if problem := EntryProblem(name, entry); problem != "" {
row["rendered"] = "no: " + problem
} else {
row["rendered"] = "yes, on the nodes its key names"
}
}
registered[key] = row
}
configKeys, err := config.Keys()
if err != nil {
return nil, err
}
sort.Strings(configKeys)
items := map[string]any{}
for _, key := range configKeys {
raw, found, err := config.Get(key)
if err != nil || !found {
continue
}
var it Item
if json.Unmarshal(raw, &it) != nil {
continue
}
_, node, _, _, ok := ParseKey(key)
if !ok {
continue
}
row := map[string]any{"kind": it.Kind, "scope": it.Scope, "by": it.By, "at": it.At}
files := make([]string, 0, len(it.Files))
for f := range it.Files {
files = append(files, f)
}
sort.Strings(files)
if len(files) > 0 {
row["files"] = files
}
switch it.Kind {
case KindHook:
row["event"], row["matcher"], row["command"] = it.Event, it.Matcher, it.Command
case KindSettings:
row["settings"] = it.Settings
}
switch problem := it.Problem(); {
case it.Key(node) != key:
row["rendered"] = "no: its key does not say what the item is"
case problem != "":
row["rendered"] = "no: " + problem
default:
row["rendered"] = "yes, as " + it.Scope + " " + it.Kind
}
items[key] = row
}
var settings Settings
_ = readJSON(p.Settings, &settings)
return map[string]any{
"set at the controller's terminal, as this node composes it": map[string]any{
"managed_settings": settings.ManagedSettings, "mcp_servers": settings.MCPServers},
"tool servers registered through this module": registered,
"configuration registered through this module": items,
"by": ByIs,
}, nil
}