Skip to content

Commit 480ed3b

Browse files
ziggyclaude
andcommitted
fix: harden MCP settings merge — reserve nanoclaw, validate shape, propagate updates
P1: nanoclaw server name is reserved — filtered from settings.json and spread last so it can never be overridden by user/skill config. P2: global MCP definitions are source-of-truth — always overwrite existing entries so package renames and flag changes propagate. P3: validate settings.json mcpServers shape before generating tool patterns — reject non-string commands and invalid server names. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bbbdb99 commit 480ed3b

2 files changed

Lines changed: 38 additions & 11 deletions

File tree

container/agent-runner/src/index.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -356,9 +356,15 @@ function waitForIpcMessage(): Promise<string | null> {
356356
* allowing agent teams subagents to run to completion.
357357
* Also pipes IPC messages into the stream during the query.
358358
*/
359+
// Names reserved for programmatic servers — cannot be overridden via settings.json.
360+
const RESERVED_MCP_NAMES = new Set(['nanoclaw']);
361+
362+
// Validate that a key is a safe MCP server name (alphanumeric, hyphens, underscores).
363+
const VALID_MCP_NAME = /^[a-zA-Z0-9_-]+$/;
364+
359365
/**
360366
* Read mcpServers from the per-group settings.json (at CLAUDE_CONFIG_DIR).
361-
* Returns an empty object if not found or on error.
367+
* Filters out reserved names and entries with invalid shape.
362368
*/
363369
function loadSettingsMcpServers(): Record<string, { command: string; args?: string[]; env?: Record<string, string> }> {
364370
const configDir = process.env.CLAUDE_CONFIG_DIR;
@@ -367,7 +373,27 @@ function loadSettingsMcpServers(): Record<string, { command: string; args?: stri
367373
try {
368374
if (!fs.existsSync(settingsPath)) return {};
369375
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
370-
return settings.mcpServers || {};
376+
const raw = settings.mcpServers;
377+
if (!raw || typeof raw !== 'object') return {};
378+
379+
const validated: Record<string, { command: string; args?: string[]; env?: Record<string, string> }> = {};
380+
for (const [name, config] of Object.entries(raw)) {
381+
if (RESERVED_MCP_NAMES.has(name)) {
382+
log(`Ignoring reserved MCP server name in settings: ${name}`);
383+
continue;
384+
}
385+
if (!VALID_MCP_NAME.test(name)) {
386+
log(`Ignoring invalid MCP server name in settings: ${name}`);
387+
continue;
388+
}
389+
const cfg = config as Record<string, unknown>;
390+
if (!cfg || typeof cfg !== 'object' || typeof cfg.command !== 'string') {
391+
log(`Ignoring MCP server with invalid config: ${name}`);
392+
continue;
393+
}
394+
validated[name] = cfg as { command: string; args?: string[]; env?: Record<string, string> };
395+
}
396+
return validated;
371397
} catch {
372398
return {};
373399
}
@@ -438,7 +464,8 @@ async function runQuery(
438464
// Skills and agents add servers by editing settings.json — they're picked up here.
439465
const settingsMcpServers = loadSettingsMcpServers();
440466
const allMcpServers: Record<string, unknown> = {
441-
// The nanoclaw IPC server is always programmatic (needs runtime vars)
467+
// Settings-based servers first, then nanoclaw last (cannot be overridden)
468+
...settingsMcpServers,
442469
nanoclaw: {
443470
command: 'node',
444471
args: [mcpServerPath],
@@ -448,8 +475,6 @@ async function runQuery(
448475
NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0',
449476
},
450477
},
451-
// All other servers come from settings.json
452-
...settingsMcpServers,
453478
};
454479

455480
// Build allowedTools dynamically: base tools + mcp__{name}__* for each server

src/container-runner.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -124,18 +124,20 @@ function ensureGroupDirs(
124124
if (Object.keys(globalMcpServers).length > 0) {
125125
settings.mcpServers = globalMcpServers;
126126
}
127-
fs.writeFileSync(
128-
settingsFile,
129-
JSON.stringify(settings, null, 2) + '\n',
130-
);
127+
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
131128
} else {
132-
// Sync global MCP servers into existing settings (add new ones, don't remove manual ones)
129+
// Sync global MCP servers into existing settings.
130+
// Global definitions are source-of-truth — always overwrite so config
131+
// updates (package renames, flag changes) propagate to all groups.
132+
// Manually-added servers (not in globalMcpServers) are preserved.
133133
try {
134134
const existing = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
135135
const existingServers = existing.mcpServers || {};
136136
let changed = false;
137137
for (const [name, config] of Object.entries(globalMcpServers)) {
138-
if (!existingServers[name]) {
138+
if (
139+
JSON.stringify(existingServers[name]) !== JSON.stringify(config)
140+
) {
139141
existingServers[name] = config;
140142
changed = true;
141143
}

0 commit comments

Comments
 (0)