Skip to content

fix(mcp): tighten the allow-list name check; never copy a non-object policy - #56

Merged
ndemianc merged 2 commits into
developfrom
fix/mcp-allowlist-review
Jul 28, 2026
Merged

fix(mcp): tighten the allow-list name check; never copy a non-object policy#56
ndemianc merged 2 commits into
developfrom
fix/mcp-allowlist-review

Conversation

@ndemianc

Copy link
Copy Markdown
Contributor

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 had done 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 chars

Shape 2 is why "must contain __" is still the wrong test. When a server's name alone reaches the cap, the cut lands inside that first segment and no separator survives:

namespaceToolName('s'.repeat(70), 'tool')  ->  'sss…sss_a1b2c3'   // 64 chars, no '__'

That case has to stay legal or "Always allow" silently does nothing for that server — the bug the previous round fixed. So this tightens without reintroducing it.

Being too strict fails silently, so the guard is swept rather than sampled: 560 (server, tool) length pairs across the truncation boundary must all validate, and the test asserts the sweep actually reached the truncated and separator-less regions, so it cannot quietly 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 user's policy, destroying it. A malformed value is now discarded rather than migrated.

3. The blocking await — already fixed, verified

f247f1a landed this before merge. I checked it rather than assuming: fire-and-forget with a .catch() is right, since the call is already approved by the click and the write only affects future runs.

That commit did drop the security rationale from the comment, so I restored it — the button only ever adds an allow, is offered only for non-destructive tools, and mcpAllowAlways re-checks regardless — plus a note on why it is deliberately not awaited.

Verification

49 mcpConfig cases; 23 suites, 0 failures.

New negative coverage is aimed squarely at the finding: read_file, abc, at-cap-without-a-hash-tag, an upper-case hash tail, and a hash-looking tail on a short name are all rejected — while every swept namespaceToolName output is accepted.

Related

#55 (S4b, the G1 launch gate) is rebased onto develop and retargeted. The two are independent; either order works, and whichever lands second needs a trivial rebase.

…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.
Copilot AI review requested due to automatic review settings July 28, 2026 00:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR tightens MCP allow-list persistence by making tool-name validation match the actual output shapes of namespaceToolName, and by preventing malformed mcp.toolPolicy values from being migrated into corrupted objects when persisting “Always allow”.

Changes:

  • Strengthens isNamespacedToolName to distinguish real namespaced/truncated MCP tool names from “safe-looking” but inert strings (with expanded adversarial + sweep coverage).
  • Prevents non-object mcp.toolPolicy values (e.g. a string) from being copied into a bogus index-map during “Always allow”.
  • Restores security/behavior rationale around fire-and-forget persistence of the allow-list write.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
extensions/levelcode-ai/test/mcpConfig.test.js Adds swept + adversarial tests to pin the intended tool-name contract and rejection cases.
extensions/levelcode-ai/mcpConfig.js Tightens tool-name allow-list key validation to match the two emitted shapes.
extensions/levelcode-ai/extension.js Guards allow-list persistence against malformed settings values; clarifies rationale for non-blocking persistence.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +779 to +781
function isPlainObject(v) {
return !!v && typeof v === 'object' && !Array.isArray(v);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — fixed in bb1c154. The comment claimed more than the code did, which is the same mistake this PR was already fixing elsewhere.

Verified before changing it:

new String("x")  ->  true      <- doc said "not a boxed primitive"
new Date()       ->  true
new Map()        ->  true

The boxed string is the one that actually bites: its character indices would be copied into mcp.toolPolicy — precisely the corruption this guard was added for one round earlier.

Now prototype-checked:

new String / Date / Map / RegExp  ->  false
{} / Object.create(null)          ->  true
[] / null                         ->  false

Object.create(null) is accepted deliberately — it is still a plain map, and some JSON handling produces one on purpose to dodge the __proto__ trap.

Comment thread extensions/levelcode-ai/mcpConfig.js Outdated
Comment on lines +253 to +255
if (!/^[A-Za-z0-9_-]+$/.test(name)) { return false; }
return name.indexOf(NAME_SEPARATOR) !== -1 // shape 1
|| (name.length === MAX_TOOL_NAME && HASH_TAGGED.test(name)); // shape 2

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right — fixed in bb1c154. sanitizeSegment never returns empty, so a separator always has something on both sides, and abc__ / __abc are not names this module can emit.

Worth recording how the obvious fix fails, because it is the third variation of the same trap in this file. indexOf('__') > 0 looks correct and is not:

namespaceToolName("__a", "b")  ->  "__a__b"

A server legitimately named __a produces a name whose first separator sits at index 0, so the index test rejects it — and "Always allow" silently stops working for that server. Exactly the failure mode as the original __-required regex, just reached from the other side.

An anchored regex backtracks to the separator at index 3 and accepts it, so shape 1 is now:

/^[A-Za-z0-9_-]+__[A-Za-z0-9_-]+$/

Behaviour after the change:

abc__             false     <- your case
__abc             false     <- your case
__                false
__a__b            true      <- leading-underscore SERVER, must stay legal
github__foo__bar  true      <- multiple separators
sss…sss_a1b2c3    true      <- truncated, no separator at all (shape 2)

I checked the __a__b case before writing the fix rather than after, having twice now shipped a tightening that quietly rejected a legitimate name. The sweep also runs every server length over 's', '_' and '-' now, so that case is covered by construction instead of by one hand-picked example.

50 mcpConfig cases; 23 suites, 0 failures.

Both review findings on #56 were the same failure: a comment claiming more than
the code did.

1. isPlainObject said "not a boxed primitive" and accepted them.

   `!!v && typeof v === 'object' && !Array.isArray(v)` is the reflex version, and
   `new String('x')`, `new Date()` and `new Map()` all pass it. The first is the
   one that bites: its character indices would be copied into mcp.toolPolicy — the
   exact corruption the guard was added to prevent, one round earlier.

   Now checks the prototype. A null prototype is accepted deliberately:
   Object.create(null) is still a plain map, and some JSON handling produces one
   on purpose to dodge the __proto__ trap.

2. Shape 1 accepted `abc__` and `__abc`.

   sanitizeSegment never returns empty, so a separator always has something on
   both sides; those two are not names this module can emit.

   The obvious fix — `indexOf('__') > 0` — is WRONG, and quietly so. A server
   legitimately named `__a` yields `__a__b`, whose FIRST separator is at index 0:

     namespaceToolName('__a', 'b')  ->  '__a__b'

   The index test rejects it and "Always allow" silently stops working for that
   server, which is the same class of bug as the original `__`-required regex. An
   anchored regex backtracks to the separator at index 3 and accepts. Verified
   before writing the fix, not after.

   The sweep now runs each server length over 's', '_' and '-' so the
   leading-underscore case is covered by construction rather than by one example.

50 mcpConfig cases; 23 suites, 0 failures.
Copilot AI review requested due to automatic review settings July 28, 2026 01:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment on lines 774 to 776
* (Global) tier, matching the application scope the setting is declared with, so a repo can never flip
* it. Reads the current value the same user-scoped way it is read at run start. Idempotent, and refuses
* a tool name that is not a namespaced server__tool to avoid writing junk from a malformed message.
@ndemianc
ndemianc merged commit c39e162 into develop Jul 28, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants