Skip to content

Commit fab9021

Browse files
committed
fix(mcp): share the tool-name rule instead of re-deriving it in extension.js
Review on #41. mcpAllowAlways hand-rolled its own regex to validate what "Always allow" writes into levelcode.ai.mcp.toolPolicy, and that second copy of the naming rule had drifted from the one mcpConfig owns. The reviewer flagged three things. One is not true, one is, and checking the first turned up a worse bug underneath it. NOT TRUE — "tool names containing extra `__` are rejected". They are not: `_` is inside the character class, so the leading group absorbs them and `github__foo__bar` matches. TRUE, and worse than reported — a name can legitimately have NO separator at all. When a server's name alone reaches the 64-char cap, namespaceToolName truncates INSIDE that first segment and appends a hash tag: namespaceToolName('s'.repeat(70), 'tool') -> 'ssss…ssss_a1b2c3' // no '__' The old regex required `__`, so it rejected a name this module itself produced and "Always allow" silently did nothing for that server. TRUE — no length bound, so a malformed message could persist an arbitrarily long settings key. And Object.assign over the existing setting hands a literal `__proto__` (which survives JSON.parse as a real own key) to the prototype setter rather than copying it. Fixed by moving the rule to where it belongs: mcpConfig now exports isNamespacedToolName (alphabet + MAX_TOOL_NAME bound) and its existing safeCopy, and extension.js uses both. One definition, beside the function whose output it describes. One trap worth recording: relaxing the separator requirement silently removed an ACCIDENTAL protection — `__proto__` failed the old regex only because it has no non-underscore character before its `__`. isNamespacedToolName therefore rejects UNSAFE_KEYS explicitly, so the guarantee no longer rides on an unrelated rule keeping a particular shape. My own new test caught that regression. 47 mcpConfig cases (up from 44); 23 suites, 0 failures.
1 parent f8d542a commit fab9021

3 files changed

Lines changed: 91 additions & 6 deletions

File tree

extensions/levelcode-ai/extension.js

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const { loadSkills, skillsMenu, getSkillBody } = require('./skills');
2727
const { openCustomize } = require('./customize');
2828
const { importFromVscode } = require('./importVscode');
2929
const { reapMcp } = require('./mcpClient');
30-
const { userScopedSetting } = require('./mcpConfig');
30+
const { userScopedSetting, isNamespacedToolName, safeCopy } = require('./mcpConfig');
3131

3232
const SECRET_KEY = 'levelcode.ai.anthropicKey'; // legacy Anthropic key location (kept for back-compat)
3333
const FILE_EXCLUDES = '{**/node_modules/**,**/.git/**,**/out/**,**/dist/**,**/.vscode-test/**,**/*.map}';
@@ -776,14 +776,23 @@ let approvalSeq = 0;
776776
* a tool name that is not a namespaced server__tool to avoid writing junk from a malformed message.
777777
*/
778778
async function mcpAllowAlways(name) {
779-
if (typeof name !== 'string' || !/^[A-Za-z0-9_-]+__[A-Za-z0-9_-]+$/.test(name)) {
780-
dbg('mcp.allow.reject', { name }); return;
779+
// isNamespacedToolName owns the rule (mcpConfig.js), rather than a second regex here: this used to
780+
// hand-roll one that required a `__` separator, which REJECTED names namespaceToolName legitimately
781+
// produces — a server whose name alone hits the 64-char cap truncates inside that first segment and
782+
// comes back without one. "Always allow" then silently did nothing for that server. The shared check
783+
// also bounds the length, so a malformed message cannot persist an arbitrarily long settings key.
784+
if (!isNamespacedToolName(name)) {
785+
dbg('mcp.allow.reject', { name: String(name).slice(0, 80) }); return;
781786
}
782787
try {
783788
const cfg = aiConfig();
784-
const cur = userScopedSetting(cfg.inspect('mcp.toolPolicy'), {}) || {};
789+
// safeCopy, not Object.assign: the existing value comes from the user's settings.json, where a
790+
// literal "__proto__" key survives JSON.parse as a real own property. Object.assign would hand it
791+
// to the prototype setter instead of copying it; safeCopy drops the unsafe keys outright.
792+
const cur = safeCopy(userScopedSetting(cfg.inspect('mcp.toolPolicy'), {}) || {});
785793
if (cur[name] === 'allow') { return; }
786-
await cfg.update('mcp.toolPolicy', Object.assign({}, cur, { [name]: 'allow' }), vscode.ConfigurationTarget.Global);
794+
cur[name] = 'allow';
795+
await cfg.update('mcp.toolPolicy', cur, vscode.ConfigurationTarget.Global);
787796
post({ type: 'agentTool', icon: 'check', text: '🔌 mcp · always allow ' + name });
788797
dbg('mcp.allow.persisted', { name });
789798
} catch (e) {

extensions/levelcode-ai/mcpConfig.js

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,36 @@ function namespaceToolName(server, tool) {
219219
return full.slice(0, MAX_TOOL_NAME - tag.length - 1) + '_' + tag;
220220
}
221221

222+
/**
223+
* Could `name` have come out of namespaceToolName? The guard for anything that PERSISTS a tool name —
224+
* today, "Always allow" writing a key into `levelcode.ai.mcp.toolPolicy`.
225+
*
226+
* It lives here, beside the function whose output it describes, because the caller was hand-rolling its
227+
* own regex, and two copies of one naming rule is how they drift apart.
228+
*
229+
* Deliberately does NOT require the `__` separator, however much the `server__tool` shape invites it.
230+
* When a server's name alone reaches the cap, truncation cuts INSIDE that first segment and the
231+
* hash-tagged result carries no separator at all:
232+
*
233+
* namespaceToolName('s'.repeat(70), 'tool') -> 'sss…sss_a1b2c3' // 64 chars, no '__'
234+
*
235+
* Requiring it would reject a name this module itself produced, and "Always allow" would then silently
236+
* do nothing for that server. The alphabet and the length are the real guarantee — they are what makes a
237+
* name safe to write into settings — so those are what this checks.
238+
*
239+
* UNSAFE_KEYS is then rejected EXPLICITLY. The separator requirement used to exclude `__proto__` by
240+
* accident (it has no non-underscore character before its `__`); dropping that requirement takes the
241+
* accident with it, since underscores are otherwise legal. Stating it outright means the protection no
242+
* longer depends on an unrelated rule staying a certain shape.
243+
*/
244+
function isNamespacedToolName(name) {
245+
return typeof name === 'string'
246+
&& name.length > 0
247+
&& name.length <= MAX_TOOL_NAME
248+
&& UNSAFE_KEYS.indexOf(name) === -1
249+
&& /^[A-Za-z0-9_-]+$/.test(name);
250+
}
251+
222252
/**
223253
* Assign a final, unique, provider-legal name to every (server, tool) pair — the last line of defence
224254
* before names reach the wire. Collisions (with a built-in, or between two servers whose names
@@ -452,7 +482,8 @@ function describeMcpCall(name, args, route) {
452482
}
453483

454484
module.exports = {
455-
loadServerConfig, userScopedSetting, namespaceToolName, assignToolNames, buildAgentTools,
485+
loadServerConfig, userScopedSetting, namespaceToolName, isNamespacedToolName, assignToolNames,
486+
buildAgentTools, safeCopy,
456487
toolCountsByServer, classifyMcpTool, explainMcpRefusal, describeMcpCall,
457488
BUILTIN_TOOL_NAMES, MAX_TOOL_NAME, MAX_TOOL_DESC, MAX_ARG_CHARS, MAX_SERVERS, MAX_TOOLS_PER_SERVER, WORKSPACE_CONFIG_PATH
458489
};

extensions/levelcode-ai/test/mcpConfig.test.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,4 +543,49 @@ test('CARD: arguments are shown in full but bounded, and never throw', () => {
543543
assert.strictEqual(M.describeMcpCall('s__t', undefined, { server: 's', tool: 't' }).argsText, '');
544544
});
545545

546+
// ---- isNamespacedToolName: the guard for anything that PERSISTS a tool name ----
547+
// "Always allow" writes the name into settings, so this decides what can be written.
548+
549+
test('PERSIST: accepts every name namespaceToolName can produce', () => {
550+
const produced = [
551+
M.namespaceToolName('github', 'list_issues'),
552+
M.namespaceToolName('github', 'foo__bar'), // tool name already contains the separator
553+
M.namespaceToolName('a', 'b'),
554+
M.namespaceToolName('srv', 't'.repeat(90)), // truncated on the TOOL side
555+
M.namespaceToolName('s'.repeat(70), 'tool'), // truncated inside the SERVER segment
556+
M.namespaceToolName('has spaces', 'and.dots') // sanitized to the legal alphabet
557+
];
558+
for (const name of produced) {
559+
assert.ok(M.isNamespacedToolName(name), 'must accept a name it produced: ' + name);
560+
}
561+
562+
// The regression this replaced: a server name long enough to be truncated comes back with NO `__`,
563+
// so a validator that required the separator rejected it and "Always allow" silently did nothing.
564+
const noSeparator = M.namespaceToolName('s'.repeat(70), 'tool');
565+
assert.ok(!noSeparator.includes('__'), 'this case must actually lack the separator, or the test is vacuous');
566+
assert.ok(M.isNamespacedToolName(noSeparator));
567+
});
568+
569+
test('PERSIST: rejects prototype-pollution keys, junk, and unbounded names', () => {
570+
for (const bad of ['__proto__', 'constructor', 'prototype']) {
571+
assert.ok(!M.isNamespacedToolName(bad), bad + ' must never become a settings key');
572+
}
573+
assert.ok(!M.isNamespacedToolName('x'.repeat(M.MAX_TOOL_NAME + 1)), 'must be bounded by MAX_TOOL_NAME');
574+
assert.ok(M.isNamespacedToolName('x'.repeat(M.MAX_TOOL_NAME)), 'the cap itself is legal');
575+
for (const bad of ['', 'has space', 'semi;colon', 'quote"', 'slash/es', null, undefined, 42, {}, []]) {
576+
assert.ok(!M.isNamespacedToolName(bad), 'must reject ' + JSON.stringify(bad));
577+
}
578+
});
579+
580+
test('PERSIST: safeCopy drops the keys that reach the prototype setter', () => {
581+
// JSON.parse creates a REAL own __proto__ key, which is how one arrives from settings.json.
582+
const fromSettings = JSON.parse('{"gh__list":"allow","__proto__":"allow","constructor":"allow"}');
583+
const copy = M.safeCopy(fromSettings);
584+
585+
assert.strictEqual(copy.gh__list, 'allow', 'legitimate entries survive');
586+
assert.ok(!Object.prototype.hasOwnProperty.call(copy, '__proto__'), '__proto__ must be dropped');
587+
assert.ok(!Object.prototype.hasOwnProperty.call(copy, 'constructor'), 'constructor must be dropped');
588+
assert.strictEqual(Object.getPrototypeOf(copy), Object.prototype, 'the copy keeps a clean prototype');
589+
});
590+
546591
console.log('\nmcpConfig.js: ' + n + ' tests passed.');

0 commit comments

Comments
 (0)