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.
155 lines
6.3 KiB
Go
155 lines
6.3 KiB
Go
package main
|
|
|
|
// What the module writes into the agent's machine-wide managed directory (novox/hq design 36 §1–§4).
|
|
// Pure: composed from the facts the mesh rendered, the settings the operator set and the licence the node
|
|
// holds, so what lands under /etc is tested without a machine.
|
|
//
|
|
// Three files, owned whole by this module:
|
|
//
|
|
// managed-mcp.json the tool servers every session loads: the mesh's console as `mesh`, and the
|
|
// servers the operator declared or registered through this module. Exclusive by
|
|
// the vendor's rule — a server not listed here does not load (operator's choice,
|
|
// 2026-10-03).
|
|
// managed-settings.json the keys the operator set in this module's `managed_settings` (the agent's
|
|
// permissions and auto mode, say), under the mesh's own keys, which always win:
|
|
// the repositories' attribution convention, the claude.ai connectors kept beside
|
|
// the managed servers, and — for an API-key licence only — the key-helper. A
|
|
// person's preferences are theirs, in their own settings.
|
|
// CLAUDE.md how a session on this mesh works, who this node is, the conventions.
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// ManagedDir is the agent's machine-wide managed directory.
|
|
const ManagedDir = "/etc/claude-code"
|
|
|
|
const meshEntry = "mesh"
|
|
|
|
// Facts are what the mesh rendered for this node.
|
|
type Facts struct {
|
|
Node string `json:"node"`
|
|
Console string `json:"console"`
|
|
}
|
|
|
|
// Settings are the operator's, for the mesh or this node.
|
|
type Settings struct {
|
|
Role string `json:"role"`
|
|
MCPServers map[string]map[string]any `json:"mcp_servers"`
|
|
// ManagedSettings are keys of the agent's managed settings the operator sets, for the mesh or a node.
|
|
ManagedSettings map[string]any `json:"managed_settings"`
|
|
}
|
|
|
|
// Binding is the licence this node holds, as it was last applied.
|
|
type Binding struct {
|
|
Licence string `json:"licence"`
|
|
Kind string `json:"kind"` // subscription | api-key
|
|
Generation int64 `json:"generation,omitempty"`
|
|
}
|
|
|
|
// Servers are tool server entries by name, in the vendor's `.mcp.json` shape.
|
|
type Servers map[string]map[string]any
|
|
|
|
var serverName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
|
|
|
// EntryProblem says why the vendor's managed file would not take an entry, or "" when it would: a name of
|
|
// letters, digits, `-` and `_`, and an http/sse server with a url or a stdio server with a command.
|
|
func EntryProblem(name string, entry map[string]any) string {
|
|
if !serverName.MatchString(name) {
|
|
return fmt.Sprintf("%q is not a name the agent takes: letters, digits, - and _", name)
|
|
}
|
|
if name == meshEntry {
|
|
return fmt.Sprintf("%q is the mesh's own entry", meshEntry)
|
|
}
|
|
kind, _ := entry["type"].(string)
|
|
if kind == "" {
|
|
kind = "stdio"
|
|
}
|
|
switch kind {
|
|
case "http", "sse", "streamable-http":
|
|
if u, _ := entry["url"].(string); u != "" {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("an %s server needs a url", kind)
|
|
case "stdio":
|
|
if c, _ := entry["command"].(string); c != "" {
|
|
return ""
|
|
}
|
|
return "a stdio server needs a command"
|
|
}
|
|
return fmt.Sprintf("%q is not a server type the agent knows (http, sse, stdio)", kind)
|
|
}
|
|
|
|
// jsonFile is a value as the managed files are written: two-space indent, a trailing newline, nothing
|
|
// escaped that need not be.
|
|
func jsonFile(v any) string {
|
|
var b bytes.Buffer
|
|
enc := json.NewEncoder(&b)
|
|
enc.SetEscapeHTML(false)
|
|
enc.SetIndent("", " ")
|
|
_ = enc.Encode(v)
|
|
return b.String()
|
|
}
|
|
|
|
// Render composes the three files. registered — what was registered through this module and applies
|
|
// here — is laid over the servers the operator set in its settings; config is the rest of what was
|
|
// registered (ADR 0216): its settings laid over the operator's `managed_settings`, its instruction sections
|
|
// after the mesh's own text. The plugin itself is Marketplace's, and the home's PlaceHome's.
|
|
func Render(facts Facts, settings Settings, binding *Binding, helperPath string, registered Servers, config Config) map[string]string {
|
|
return RenderWithMesh(facts, settings, binding, helperPath, registered, config, nil)
|
|
}
|
|
|
|
// RenderWithMesh is Render with what the mesh said about its tools (novox/hq ADR 0245): the "instead of"
|
|
// table, after the mesh's own text and before the sections registered for the agent.
|
|
func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath string, registered Servers, config Config,
|
|
mesh *MeshTools) map[string]string {
|
|
servers := map[string]any{}
|
|
for _, layer := range []map[string]map[string]any{settings.MCPServers, registered} {
|
|
for name, entry := range layer {
|
|
if EntryProblem(name, entry) != "" {
|
|
continue // the mesh's own entry, or one the agent would refuse
|
|
}
|
|
servers[name] = entry
|
|
}
|
|
}
|
|
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console}
|
|
|
|
// The operator's `managed_settings` (ADR 0213), set at the controller's terminal alone: nothing registered
|
|
// through this module's tools is laid over it (novox/hq issue 340).
|
|
managed := mergeSettings(map[string]any{}, settings.ManagedSettings)
|
|
// The mesh's own keys are laid last: a setting never replaces them.
|
|
managed["attribution"] = map[string]any{"commit": "", "pr": ""}
|
|
managed["allowAllClaudeAiMcps"] = true
|
|
delete(managed, "apiKeyHelper")
|
|
// The plugin's marketplace and the plugin itself, as entries in the operator's own maps, the mesh's
|
|
// entry winning: the operator may know more marketplaces and enable more plugins.
|
|
for key, value := range MarketplaceKeys() {
|
|
entries := map[string]any{}
|
|
if held, ok := managed[key].(map[string]any); ok {
|
|
for k, v := range held {
|
|
entries[k] = v
|
|
}
|
|
}
|
|
for k, v := range value.(map[string]any) {
|
|
entries[k] = v
|
|
}
|
|
managed[key] = entries
|
|
}
|
|
if binding != nil && binding.Kind == "api-key" {
|
|
managed["apiKeyHelper"] = helperPath
|
|
}
|
|
role := strings.TrimSpace(settings.Role)
|
|
if role == "" {
|
|
role = "not stated — set it in this module's settings for the node"
|
|
}
|
|
return map[string]string{
|
|
"managed-mcp.json": jsonFile(map[string]any{"mcpServers": servers}),
|
|
"managed-settings.json": jsonFile(managed),
|
|
"CLAUDE.md": instructionsText(facts.Node, role) + InsteadOf(mesh) + InstructionSections(config),
|
|
}
|
|
}
|