Skip to content

Commit 47c2d62

Browse files
committed
fix(mcp): tighten the allow-list name check; never copy a non-object policy
Follow-up review on #41, after merge. Three findings, all valid. 1. isNamespacedToolName was too permissive (and its doc overclaimed). It asked "could this have come out of namespaceToolName?" but only checked the alphabet and the length, so it accepted `abc`, `read_file`, and `'x'.repeat(64)` — none of which namespacing can emit. Such a key would sit inert in mcp.toolPolicy looking like it did something. It now accepts exactly the two shapes namespaceToolName emits: 1. `server__tool` — whenever the joined name fits the cap 2. `<57 chars>_<6-char base36 hash>` — the truncated form, always exactly 64 Shape 2 is why "must contain __" is still wrong: when a server's name ALONE reaches the cap, the cut lands inside that first segment and no separator survives. That case must stay legal or "Always allow" silently does nothing for that server — the bug the previous round fixed. Because being too strict fails SILENTLY, the guard is now swept rather than sampled: 560 (server, tool) length pairs across the truncation boundary must all validate, and the test asserts it actually reached the truncated and separator-less regions so it cannot go vacuous. 2. A non-object mcp.toolPolicy would be copied key-by-key. userScopedSetting returns whatever is in settings.json, of whatever type. A policy that is accidentally a STRING would have safeCopy faithfully copy its character indices — {"0":"a","1":"l",…} — and write that back as the policy, destroying it. A malformed value is now discarded, not migrated. 3. The blocking await on mcpAllowAlways was already fixed in f247f1a. Verified it is correct; restored the security rationale that commit dropped (the button only ever adds an `allow`, is offered only for non-destructive tools, and is re-checked regardless) and recorded why it is deliberately not awaited. 49 mcpConfig cases; 23 suites, 0 failures.
1 parent a1d9c6e commit 47c2d62

3 files changed

Lines changed: 81 additions & 18 deletions

File tree

extensions/levelcode-ai/extension.js

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -775,6 +775,11 @@ let approvalSeq = 0;
775775
* it. Reads the current value the same user-scoped way it is read at run start. Idempotent, and refuses
776776
* a tool name that is not a namespaced server__tool to avoid writing junk from a malformed message.
777777
*/
778+
/** A real map — not an array, not null, not a boxed primitive. Guards what we copy out of settings. */
779+
function isPlainObject(v) {
780+
return !!v && typeof v === 'object' && !Array.isArray(v);
781+
}
782+
778783
async function mcpAllowAlways(name) {
779784
// isNamespacedToolName owns the rule (mcpConfig.js), rather than a second regex here: this used to
780785
// hand-roll one that required a `__` separator, which REJECTED names namespaceToolName legitimately
@@ -789,7 +794,13 @@ async function mcpAllowAlways(name) {
789794
// safeCopy, not Object.assign: the existing value comes from the user's settings.json, where a
790795
// literal "__proto__" key survives JSON.parse as a real own property. Object.assign would hand it
791796
// to the prototype setter instead of copying it; safeCopy drops the unsafe keys outright.
792-
const cur = safeCopy(userScopedSetting(cfg.inspect('mcp.toolPolicy'), {}) || {});
797+
//
798+
// The plain-object check in front of it matters just as much. userScopedSetting hands back
799+
// whatever is in settings.json, of whatever type — and a policy that is accidentally a STRING
800+
// would have safeCopy faithfully copy its character indices ({"0":"a","1":"l",…}) and then write
801+
// that back as the user's policy, destroying it. A malformed value is discarded, not migrated.
802+
const stored = userScopedSetting(cfg.inspect('mcp.toolPolicy'), {});
803+
const cur = safeCopy(isPlainObject(stored) ? stored : {});
793804
if (cur[name] === 'allow') { return; }
794805
cur[name] = 'allow';
795806
await cfg.update('mcp.toolPolicy', cur, vscode.ConfigurationTarget.Global);
@@ -1390,7 +1401,14 @@ class ChatViewProvider {
13901401
case 'stop': dbg('stop.clicked', { running: commandStops.size }); for (const [, stop] of commandStops) { try { stop(); } catch (e) { /* gone */ } } if (abort) { abort.abort(); } clearApprovals(); clearQuestions(); break;
13911402
case 'stopCommand': { dbg('stopCommand', { id: msg.id }); const s = commandStops.get(msg.id); if (s) { try { s(); } catch (e) { /* gone */ } } break; }
13921403
case 'approvalResponse': {
1393-
// Persisting "Always allow" should not block the approved tool call.
1404+
// "Always allow" persists the tool to the allow-list so a FUTURE run skips the prompt. It
1405+
// only ever adds an `allow` (never a broadening default), and the webview offers the button
1406+
// only for non-destructive tools — mcpAllowAlways re-checks regardless.
1407+
//
1408+
// Deliberately not awaited: this call is already approved by the click, and the write only
1409+
// affects later runs, so blocking the tool on a settings round-trip buys nothing. Failures
1410+
// are swallowed inside mcpAllowAlways and logged; the .catch here is belt-and-braces so a
1411+
// rejection can never surface as an unhandled one.
13941412
if (msg.approved && msg.remember && msg.mcpName) {
13951413
Promise.resolve(mcpAllowAlways(msg.mcpName)).catch(() => { /* best-effort */ });
13961414
}

extensions/levelcode-ai/mcpConfig.js

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -226,27 +226,33 @@ function namespaceToolName(server, tool) {
226226
* It lives here, beside the function whose output it describes, because the caller was hand-rolling its
227227
* own regex, and two copies of one naming rule is how they drift apart.
228228
*
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:
229+
* namespaceToolName emits exactly two SHAPES, and this accepts those two and nothing else:
230+
*
231+
* 1. `server__tool` — the separator survives whenever the joined name fits the cap.
232+
* 2. `<57 chars>_<6-char hash>` — the truncated form, always exactly MAX_TOOL_NAME long.
233+
*
234+
* Shape 2 is why a plain "must contain `__`" test is wrong: when a server's name ALONE reaches the cap,
235+
* the cut lands inside that first segment and the result carries no separator at all —
232236
*
233237
* namespaceToolName('s'.repeat(70), 'tool') -> 'sss…sss_a1b2c3' // 64 chars, no '__'
234238
*
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.
239+
* — so requiring one rejects a name this module itself produced, and "Always allow" then silently does
240+
* nothing for that server. Checking the two shapes keeps that case legal while still refusing a bare
241+
* `read_file` or `x`.repeat(64), which namespacing can never emit and which would only sit inert in the
242+
* policy map.
238243
*
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.
244+
* UNSAFE_KEYS is rejected EXPLICITLY rather than left to fall out of the shape rules, so the protection
245+
* does not depend on an unrelated rule keeping a particular form.
243246
*/
247+
const HASH_TAGGED = /_[0-9a-z]{6}$/; // shortHash is base36, lower-case, padded to 6
248+
244249
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+
if (typeof name !== 'string') { return false; }
251+
if (name.length === 0 || name.length > MAX_TOOL_NAME) { return false; }
252+
if (UNSAFE_KEYS.indexOf(name) !== -1) { return false; }
253+
if (!/^[A-Za-z0-9_-]+$/.test(name)) { return false; }
254+
return name.indexOf(NAME_SEPARATOR) !== -1 // shape 1
255+
|| (name.length === MAX_TOOL_NAME && HASH_TAGGED.test(name)); // shape 2
250256
}
251257

252258
/**

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -566,17 +566,56 @@ test('PERSIST: accepts every name namespaceToolName can produce', () => {
566566
assert.ok(M.isNamespacedToolName(noSeparator));
567567
});
568568

569+
test('PERSIST: every name namespaceToolName can emit validates — swept, not sampled', () => {
570+
// isNamespacedToolName is deliberately strict, and the failure mode of being too strict is SILENT:
571+
// "Always allow" writes nothing and the user is simply asked again forever. A handful of examples
572+
// cannot cover the boundary where truncation starts, so sweep both segment lengths across it.
573+
let checked = 0;
574+
let sawTruncated = 0;
575+
let sawNoSeparator = 0;
576+
577+
for (let s = 1; s <= 80; s++) {
578+
for (const t of [1, 2, 7, 30, 63, 64, 90]) {
579+
const name = M.namespaceToolName('s'.repeat(s), 't'.repeat(t));
580+
assert.ok(LEGAL.test(name), 'precondition: emitted name must be provider-legal: ' + name);
581+
assert.ok(M.isNamespacedToolName(name),
582+
'rejected a name namespaceToolName produced (server=' + s + ', tool=' + t + '): ' + name);
583+
checked++;
584+
if (name.length === M.MAX_TOOL_NAME) { sawTruncated++; }
585+
if (!name.includes('__')) { sawNoSeparator++; }
586+
}
587+
}
588+
589+
// Assert the sweep actually reached the interesting regions, so it cannot quietly become vacuous.
590+
assert.ok(checked > 500, 'swept a meaningful space');
591+
assert.ok(sawTruncated > 0, 'the sweep must include truncated names');
592+
assert.ok(sawNoSeparator > 0, 'the sweep must include the separator-less truncation case');
593+
});
594+
569595
test('PERSIST: rejects prototype-pollution keys, junk, and unbounded names', () => {
570596
for (const bad of ['__proto__', 'constructor', 'prototype']) {
571597
assert.ok(!M.isNamespacedToolName(bad), bad + ' must never become a settings key');
572598
}
573599
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');
575600
for (const bad of ['', 'has space', 'semi;colon', 'quote"', 'slash/es', null, undefined, 42, {}, []]) {
576601
assert.ok(!M.isNamespacedToolName(bad), 'must reject ' + JSON.stringify(bad));
577602
}
578603
});
579604

605+
test('PERSIST: rejects safe-looking names that namespacing can never emit', () => {
606+
// Length and alphabet alone are not the contract. These are all "safe" strings, but none can come
607+
// out of namespaceToolName, so none belongs in the tool-policy map — an entry like `read_file` would
608+
// just sit there inert, looking like it did something.
609+
assert.ok(!M.isNamespacedToolName('read_file'), 'a built-in name is not an MCP tool name');
610+
assert.ok(!M.isNamespacedToolName('abc'), 'no separator, not the truncated shape');
611+
assert.ok(!M.isNamespacedToolName('x'.repeat(M.MAX_TOOL_NAME)),
612+
'exactly at the cap but with no hash tag — truncation always appends one');
613+
assert.ok(!M.isNamespacedToolName('x'.repeat(57) + '_ABCDEF'),
614+
'the hash tag is lower-case base36; upper-case is not a shape this module emits');
615+
assert.ok(!M.isNamespacedToolName('short_a1b2c3'),
616+
'a hash-looking tail only counts at exactly MAX_TOOL_NAME, which is the only way truncation ends');
617+
});
618+
580619
test('PERSIST: safeCopy drops the keys that reach the prototype setter', () => {
581620
// JSON.parse creates a REAL own __proto__ key, which is how one arrives from settings.json.
582621
const fromSettings = JSON.parse('{"gh__list":"allow","__proto__":"allow","constructor":"allow"}');

0 commit comments

Comments
 (0)