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

CopilotAI 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.

FileDescription
extensions/levelcode-ai/test/mcpConfig.test.jsAdds swept + adversarial tests to pin the intended tool-name contract and rejection cases.
extensions/levelcode-ai/mcpConfig.jsTightens tool-name allow-list key validation to match the two emitted shapes.
extensions/levelcode-ai/extension.jsGuards 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
ContributorAuthor

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 threadextensions/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
ContributorAuthor

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.
CopilotAI review requested due to automatic review settings July 28, 2026 01:19

CopilotAI 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 developJul 28, 2026
2 checks passed
Sign up for freeto 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

@ndemianc