Let the operator set the agent's managed settings through claude-code

managed-settings.json carried only the mesh's fixed keys, so permissions and
auto-mode rules could only be set by hand per machine, outside the mesh.
A managed_settings setting is laid under the mesh's keys, which still win.
This commit is contained in:
jochen
2026-10-04 17:31:20 +02:00
parent a831087a02
commit 8067e91409
4 changed files with 57 additions and 6 deletions
+7 -1
View File
@@ -22,7 +22,7 @@ whenever the node's tool runtime collects the module's tools:
| file | holds |
|---|---|
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
| `managed-settings.json` | the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, and the key-helper while the node holds an API-key licence |
| `managed-settings.json` | the keys set in this module's `managed_settings` setting, under the mesh's own: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, and the key-helper while the node holds an API-key licence |
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions |
Under the operator's home, only `~/.claude/.credentials.json`, and only when the licence manager hands
@@ -58,6 +58,12 @@ Per node or for the whole mesh, through `mesh-controller.settings module=claude-
registered through the tools; keyed by name, in the vendor's `.mcp.json` entry shape
(`{"type":"http","url":…}` or `{"type":"stdio","command":…,"args":[…]}`). The name `mesh` is the
module's own and cannot be set. Put a person's own servers here, or they stop loading.
- `managed_settings` — keys of the agent's managed settings, in the vendor's `settings.json` shape:
`permissions` (allow, ask, deny), `autoMode` (environment, allow, soft_deny), `env`, hooks and so on.
Managed settings outrank every other scope, so a rule here holds in every session on the node. The
mesh's own keys (`attribution`, `allowAllClaudeAiMcps`, `apiKeyHelper`) are laid last and cannot be
set. A setting layer is replaced whole: setting `managed_settings` without `role` or `mcp_servers`
clears those in that layer.
## On a machine that carried the predecessor
@@ -94,6 +94,40 @@ func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T)
}
}
func TestTheOperatorsManagedSettingsAreLaidUnderTheMeshsOwnKeys(t *testing.T) {
settings := Settings{ManagedSettings: map[string]any{
"autoMode": map[string]any{"allow": []any{"merging an approved pull request"}},
"permissions": map[string]any{"deny": []any{"Bash(rm -rf /)"}},
"attribution": map[string]any{"commit": "made by a machine"},
"allowAllClaudeAiMcps": false,
"apiKeyHelper": "/somewhere/else",
}}
read := func(binding *Binding) map[string]any {
var m map[string]any
out := Render(Facts{Console: "x"}, settings, binding, "/h", nil)
if err := json.Unmarshal([]byte(out["managed-settings.json"]), &m); err != nil {
t.Fatal(err)
}
return m
}
m := read(nil)
if allow := m["autoMode"].(map[string]any)["allow"].([]any); len(allow) != 1 || allow[0] != "merging an approved pull request" {
t.Errorf("the operator's auto mode was not carried: %v", m["autoMode"])
}
if m["permissions"] == nil {
t.Errorf("the operator's permissions were not carried: %v", m)
}
if !reflect.DeepEqual(m["attribution"], map[string]any{"commit": "", "pr": ""}) || m["allowAllClaudeAiMcps"] != true {
t.Errorf("a setting replaced the mesh's own keys: %v", m)
}
if _, ok := m["apiKeyHelper"]; ok {
t.Errorf("a setting named a key-helper the licence did not: %v", m)
}
if helper := read(&Binding{Licence: "api", Kind: "api-key"})["apiKeyHelper"]; helper != "/h" {
t.Errorf("an API-key licence's key-helper was replaced: %v", helper)
}
}
// ---- the credentials file -----------------------------------------------------------------------------
func i64(v int64) *int64 { return &v }
+15 -4
View File
@@ -10,9 +10,11 @@ package main
// 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 mesh's keys only: 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.
// 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 (
@@ -38,6 +40,8 @@ type Facts struct {
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.
@@ -105,7 +109,14 @@ func Render(facts Facts, settings Settings, binding *Binding, helperPath string,
}
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console}
managed := map[string]any{"attribution": map[string]any{"commit": "", "pr": ""}, "allowAllClaudeAiMcps": true}
managed := map[string]any{}
for key, value := range settings.ManagedSettings {
managed[key] = value
}
// 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")
if binding != nil && binding.Kind == "api-key" {
managed["apiKeyHelper"] = helperPath
}
+1 -1
View File
@@ -69,7 +69,7 @@
"mode": "0600",
"owner": "${machine:account}",
"merge": "json",
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {}\n}\n"
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {}\n}\n"
}
],
"build": {