diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md index 89958cf..935bec3 100644 --- a/modules/claude-code/README.md +++ b/modules/claude-code/README.md @@ -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 diff --git a/modules/claude-code/cmd/claude-code/claude_code_test.go b/modules/claude-code/cmd/claude-code/claude_code_test.go index 23ae840..54a58c1 100644 --- a/modules/claude-code/cmd/claude-code/claude_code_test.go +++ b/modules/claude-code/cmd/claude-code/claude_code_test.go @@ -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 } diff --git a/modules/claude-code/cmd/claude-code/render.go b/modules/claude-code/cmd/claude-code/render.go index 69d6732..ac346a8 100644 --- a/modules/claude-code/cmd/claude-code/render.go +++ b/modules/claude-code/cmd/claude-code/render.go @@ -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 } diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index 8d2a888..8931a63 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -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": {