Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 24 additions & 13 deletions extensions/levelcode-ai/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const providers = require('./providers/index');
const { formatVerifyFeedback, verifyOutcome, looksUnrunnable, sniffPort, sniffPreviewUrl, looksReady } = require('./verify');
const { classifyCommand, dangerLabel } = require('./commandSafety');
const { loadProjectRules } = require('./projectRules');
const { loadServerConfig, buildAgentTools, toolCountsByServer, classifyMcpTool, explainMcpRefusal } = require('./mcpConfig');
const { loadServerConfig, buildAgentTools, toolCountsByServer, classifyMcpTool, explainMcpRefusal, describeMcpCall } = require('./mcpConfig');
const { connectAll, getServer } = require('./mcpClient');

const SYSTEM_BASE = [
Expand Down Expand Up @@ -484,19 +484,30 @@ async function runTool(tu, ctx) {
const route = ctx.mcpRoutes && ctx.mcpRoutes.get(tu.name);
if (route) {
const verdict = classifyMcpTool(tu.name, ctx.mcp && ctx.mcp.toolPolicy, route.annotations);
if (verdict.approve !== 'allow') {
// S3 deliberately ships no approval CARD (S4 owns it), so anything the user has not
// explicitly allow-listed is REFUSED rather than run — the alternative would be silently
// executing third-party code on the user's behalf with no way to say no. The explanation
// lives in mcpConfig beside the classifier so it can't drift from it (PR #31 review): a
// destructive tool is refused for a reason the allow-list cannot fix, and must not be
// described as allow-listable.
ctx.post({ type: 'agentTool', icon: 'shield', text: '🔌 mcp · refused ' + tu.name + ' — ' + verdict.reason });
return explainMcpRefusal(tu.name, verdict);
}
const server = getServer(route.server);
if (!server || !server.alive) { return 'ERROR: the MCP server "' + route.server + '" is not running.'; }
ctx.post({ type: 'agentTool', icon: 'plug', text: '🔌 ' + route.server + ' · ' + route.tool });
// S4: a call the user hasn't allow-listed is now PROMPTED, not refused. Autopilot does not relax
// this (G3) — an MCP tool is third-party code — and a server-marked-destructive tool prompts even
// when allow-listed (classifyMcpTool tightens on it). Only 'allow' skips the card.
if (verdict.approve !== 'allow') {
if (typeof ctx.approve !== 'function') {
// No webview to ask through (headless / a test harness) — fall back to S3's safe refusal
// rather than run third-party code with no way to say no.
ctx.post({ type: 'agentTool', icon: 'shield', text: '🔌 mcp · refused ' + tu.name + ' — ' + verdict.reason });
return explainMcpRefusal(tu.name, verdict);
}
const call = describeMcpCall(tu.name, input, route);
const approved = await ctx.approve({
kind: 'mcp', name: tu.name, server: call.server, tool: call.tool,
args: call.argsText, destructive: call.destructive, canAllowAlways: call.canAllowAlways
});
if (!approved) {
ctx.post({ type: 'agentTool', icon: 'shield', text: '🔌 mcp · skipped ' + tu.name });
return 'User declined to run the MCP tool "' + tu.name + '". Do NOT retry it in this run — '
+ 'continue without it, or tell the user what you needed it for.';
}
}
ctx.post({ type: 'agentTool', icon: 'sparkle', text: '🔌 ' + route.server + ' · ' + route.tool });
return await server.call(route.tool, input); // never throws — failures come back as `ERROR: …`
}
return 'ERROR: unknown tool ' + tu.name;
Expand Down Expand Up @@ -586,7 +597,7 @@ async function setupMcp(ctx, wsFolders, dbg) {
const perServer = toolCountsByServer(built.routes);
const summary = handles.map((h) => h.name + ' (' + (perServer.get(h.name) || 0) + ')').join(', ');
dbg('mcp.ready', { servers: handles.map((h) => h.name), tools: built.tools.length, allowed });
ctx.post({ type: 'agentTool', icon: 'plug', text: '🔌 mcp · ' + summary + ' · ' + allowed + '/' + built.tools.length + ' allow-listed' });
ctx.post({ type: 'agentTool', icon: 'sparkle', text: '🔌 mcp · ' + summary + ' · ' + allowed + '/' + built.tools.length + ' allow-listed' });
return built;
} catch (e) {
dbg('mcp.failed', { error: (e && e.message) || String(e) });
Expand Down
42 changes: 40 additions & 2 deletions extensions/levelcode-ai/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const { loadSkills, skillsMenu, getSkillBody } = require('./skills');
const { openCustomize } = require('./customize');
const { importFromVscode } = require('./importVscode');
const { reapMcp } = require('./mcpClient');
const { userScopedSetting } = require('./mcpConfig');
const { userScopedSetting, isNamespacedToolName, safeCopy } = require('./mcpConfig');

const SECRET_KEY = 'levelcode.ai.anthropicKey'; // legacy Anthropic key location (kept for back-compat)
const FILE_EXCLUDES = '{**/node_modules/**,**/.git/**,**/out/**,**/dist/**,**/.vscode-test/**,**/*.map}';
Expand Down Expand Up @@ -769,6 +769,37 @@ async function restoreCheckpoint(turnId) {
const pendingApprovals = new Map();
let approvalSeq = 0;

/**
* Persist an MCP tool to the allow-list (the ONLY thing that grants 'allow' — G3). Writes to the USER
* (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.
*/
async function mcpAllowAlways(name) {
// isNamespacedToolName owns the rule (mcpConfig.js), rather than a second regex here: this used to
// hand-roll one that required a `__` separator, which REJECTED names namespaceToolName legitimately
// produces — a server whose name alone hits the 64-char cap truncates inside that first segment and
// comes back without one. "Always allow" then silently did nothing for that server. The shared check
// also bounds the length, so a malformed message cannot persist an arbitrarily long settings key.
if (!isNamespacedToolName(name)) {
dbg('mcp.allow.reject', { name: String(name).slice(0, 80) }); return;
}
try {
const cfg = aiConfig();
// safeCopy, not Object.assign: the existing value comes from the user's settings.json, where a
// literal "__proto__" key survives JSON.parse as a real own property. Object.assign would hand it
// to the prototype setter instead of copying it; safeCopy drops the unsafe keys outright.
const cur = safeCopy(userScopedSetting(cfg.inspect('mcp.toolPolicy'), {}) || {});
if (cur[name] === 'allow') { return; }
Comment on lines +788 to +793

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 #56.

userScopedSetting returns info.globalValue whatever its type, so nothing between settings.json and safeCopy was checking the shape. A policy that is accidentally a string would have safeCopy faithfully copy its character indices and write that back:

"allow"  ->  { "0": "a", "1": "l", "2": "l", "3": "o", "4": "w", "<tool>": "allow" }

which destroys the user's policy rather than repairing it. An array would do the same with numeric indices.

mcpAllowAlways now normalizes first — a non-plain-object value is discarded, not migrated, on the grounds that we cannot know what a corrupted policy was meant to say, and silently inventing one is worse than starting clean.

cur[name] = 'allow';
await cfg.update('mcp.toolPolicy', cur, vscode.ConfigurationTarget.Global);
post({ type: 'agentTool', icon: 'check', text: '🔌 mcp · always allow ' + name });
dbg('mcp.allow.persisted', { name });
} catch (e) {
dbg('mcp.allow.failed', { name, error: String((e && e.message) || e) });
}
}

/** Ask the webview to approve an action; resolves true/false. */
function requestApproval(req) {
const id = String(++approvalSeq);
Expand Down Expand Up @@ -1358,7 +1389,14 @@ class ChatViewProvider {
case 'send': await handleSend(msg.text); break;
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;
case 'stopCommand': { dbg('stopCommand', { id: msg.id }); const s = commandStops.get(msg.id); if (s) { try { s(); } catch (e) { /* gone */ } } break; }
case 'approvalResponse': resolveApproval(msg.id, msg.approved); break;
case 'approvalResponse': {
// Persisting "Always allow" should not block the approved tool call.
if (msg.approved && msg.remember && msg.mcpName) {
Promise.resolve(mcpAllowAlways(msg.mcpName)).catch(() => { /* best-effort */ });
}
resolveApproval(msg.id, msg.approved);
break;
}
case 'questionsResponse': resolveQuestions(msg.id, msg.answers, msg.notes); break;
case 'accountSignIn': await accountSignIn(msg.provider, msg.create); break;
case 'accountSignOut': await accountSignOut(); break;
Expand Down
85 changes: 79 additions & 6 deletions extensions/levelcode-ai/mcpConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,36 @@ function namespaceToolName(server, tool) {
return full.slice(0, MAX_TOOL_NAME - tag.length - 1) + '_' + tag;
}

/**
* Could `name` have come out of namespaceToolName? The guard for anything that PERSISTS a tool name —
* today, "Always allow" writing a key into `levelcode.ai.mcp.toolPolicy`.
*
* It lives here, beside the function whose output it describes, because the caller was hand-rolling its
* own regex, and two copies of one naming rule is how they drift apart.
*
* Deliberately does NOT require the `__` separator, however much the `server__tool` shape invites it.
* When a server's name alone reaches the cap, truncation cuts INSIDE that first segment and the
* hash-tagged result carries no separator at all:
*
* namespaceToolName('s'.repeat(70), 'tool') -> 'sss…sss_a1b2c3' // 64 chars, no '__'
*
* Requiring it would reject a name this module itself produced, and "Always allow" would then silently
* do nothing for that server. The alphabet and the length are the real guarantee — they are what makes a
* name safe to write into settings — so those are what this checks.
*
* UNSAFE_KEYS is then rejected EXPLICITLY. The separator requirement used to exclude `__proto__` by
* accident (it has no non-underscore character before its `__`); dropping that requirement takes the
* accident with it, since underscores are otherwise legal. Stating it outright means the protection no
* longer depends on an unrelated rule staying a certain shape.
*/
function isNamespacedToolName(name) {
return typeof name === 'string'
&& name.length > 0
&& name.length <= MAX_TOOL_NAME
&& UNSAFE_KEYS.indexOf(name) === -1
&& /^[A-Za-z0-9_-]+$/.test(name);
}
Comment on lines +244 to +250

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.

Both of these are the same finding, and you are right — fixed in #56.

isNamespacedToolName asked "could this have come out of namespaceToolName?" but only checked alphabet and length, so it accepted abc, read_file, and 'x'.repeat(64). The doc overclaimed and the caller's intent was undermined; a key like that would sit inert in the policy map 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

One correction to the reasoning in the second comment: truncation does not always leave a separator. When a server's name alone reaches the cap, the cut lands inside that first segment:

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

So a plain "must contain __" test still rejects a legitimate name — that was the bug the previous round fixed, and shape 2 is what keeps it fixed while adding the strictness you asked for.

The 'x'.repeat(MAX_TOOL_NAME) assertion you flagged is inverted: it is now asserted as a rejection, alongside read_file, abc, an upper-case hash tail, and a hash-looking tail on a short name.

Because being too strict fails silently — "Always allow" writes nothing and the user is asked forever — the accept side 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 go vacuous.


/**
* Assign a final, unique, provider-legal name to every (server, tool) pair — the last line of defence
* before names reach the wire. Collisions (with a built-in, or between two servers whose names
Expand Down Expand Up @@ -400,17 +430,60 @@ function classifyMcpTool(name, policy, annotations) {
* @returns {string}
*/
function explainMcpRefusal(name, verdict) {
// Reached only when there is NO interactive approval to fall back on (a headless run, a test harness).
// With a webview present, S4 shows the per-call card instead of this message.
const head = 'ERROR: the MCP tool "' + name + '" is not approved to run (' + verdict.reason + '). ';
const fix = verdict.policyCanAllow
? 'This build has no per-call approval prompt, so the only way to permit it is for the USER to add '
? 'No interactive approval is available here, so the only way to permit it is for the USER to add '
+ '"' + name + '": "allow" to the "levelcode.ai.mcp.toolPolicy" setting. '
: 'Such tools always require per-call approval — which this build does not yet provide — so it '
+ 'CANNOT be enabled through the allow-list. ';
: 'Such tools always require per-call approval and CANNOT be enabled through the allow-list, so '
+ 'there is no way to run it in this non-interactive context. ';
return head + fix + 'Do NOT retry it in this run — continue without it, or tell the user what you needed it for.';
}

// A tool call's arguments can be large, and the approval card must not be blown open by one. See
// describeMcpCall — the card is capped, the full args still reach the server if approved.
const MAX_ARG_CHARS = 2000;

/** Pretty, bounded JSON for the args shown on the approval card. Never throws (circular/huge input). */
function previewArgs(args) {
if (args == null) { return ''; }
let text;
try { text = JSON.stringify(args, null, 2); }
catch { try { text = String(args); } catch { text = '[unserializable arguments]'; } }
if (text == null) { return ''; }
return text.length > MAX_ARG_CHARS ? text.slice(0, MAX_ARG_CHARS - 1) + '…' : text;
}

/**
* Shape an MCP call for the approval card (S4) — exactly what the user reads before deciding.
*
* Unlike the debug log (G4), the arguments are shown in FULL here, only length-capped. That is not an
* oversight: the card is ephemeral UI shown to the person who owns the credentials, and seeing the real
* arguments — the repo it will touch, the id it will delete — IS the decision. Redacting them would make
* the prompt meaningless. Nothing here is persisted; the card is not the transcript.
*
* `canAllowAlways` is false for a destructive tool: a server-marked-destructive tool can never be moved
* to the allow-list (classifyMcpTool tightens on it), so the card must not offer a button that would do
* nothing. Derived from the same annotation the classifier reads, so the two cannot disagree.
*
* @param {string} name namespaced tool name (server__tool)
* @param {*} args the arguments the model produced for this call
* @param {{server?:string, tool?:string, annotations?:object}} [route]
* @returns {{server:string, tool:string, argsText:string, destructive:boolean, canAllowAlways:boolean}}
*/
function describeMcpCall(name, args, route) {
const r = route || {};
const fallback = String(name == null ? '' : name).split(NAME_SEPARATOR);
const server = typeof r.server === 'string' && r.server ? r.server : (fallback[0] || String(name));
const tool = typeof r.tool === 'string' && r.tool ? r.tool : (fallback.slice(1).join(NAME_SEPARATOR) || String(name));
const destructive = !!(r.annotations && r.annotations.destructiveHint === true);
return { server, tool, argsText: previewArgs(args), destructive, canAllowAlways: !destructive };
}

module.exports = {
loadServerConfig, userScopedSetting, namespaceToolName, assignToolNames, buildAgentTools,
toolCountsByServer, classifyMcpTool, explainMcpRefusal,
BUILTIN_TOOL_NAMES, MAX_TOOL_NAME, MAX_TOOL_DESC, MAX_SERVERS, MAX_TOOLS_PER_SERVER, WORKSPACE_CONFIG_PATH
loadServerConfig, userScopedSetting, namespaceToolName, isNamespacedToolName, assignToolNames,
buildAgentTools, safeCopy,
toolCountsByServer, classifyMcpTool, explainMcpRefusal, describeMcpCall,
BUILTIN_TOOL_NAMES, MAX_TOOL_NAME, MAX_TOOL_DESC, MAX_ARG_CHARS, MAX_SERVERS, MAX_TOOLS_PER_SERVER, WORKSPACE_CONFIG_PATH
};
55 changes: 55 additions & 0 deletions extensions/levelcode-ai/media/chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,9 @@
.tl-ask .asksub { margin-top: 3px; font-size: 11.5px; line-height: 1.5; color: var(--muted); white-space: pre-wrap; word-break: break-word; }
/* recessed well for the command; capped so a long one can't push the buttons off-screen */
.tl-ask .askcode { margin-top: 10px; border-radius: 8px; background: var(--vscode-textCodeBlock-background, rgba(127,127,127,.14)); max-height: 40vh; overflow: auto; }
/* MCP call arguments — wrap rather than force horizontal scroll, and keep them monospace + tidy. */
.tl-ask .mcpargs { margin: 0; padding: 9px 12px; white-space: pre-wrap; word-break: break-word; font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 12px; line-height: 1.5; }
.tl-ask .askbtns .mcp-always { margin-left: auto; }
.tl-ask .askbtns { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 11px; }
.tl-ask .askbtns button { cursor: pointer; border: 1px solid transparent; border-radius: 7px; padding: 5px 14px; font-size: 12px; font-weight: 500;
display: inline-flex; align-items: baseline; gap: 6px; } /* transparent border keeps both buttons the same height */
Expand Down Expand Up @@ -1871,7 +1874,59 @@
// I being asked", which is exactly what it is for. On decision it collapses to a one-line verdict;
// the live run card follows with the output.
let pendingApproval = null; // { done } while a decision is awaited — Enter approves, Esc skips
// MCP tool call (S4) — its own card: server · tool · arguments, so the user sees exactly what a
// third-party tool is about to do. Args are shown in full (capped host-side): that IS the decision.
function addMcpApproval(m){
clearEmpty(); clearStatus(); agentBubble = null;
closeGroup();
const card = document.createElement('div'); card.className = 'tl tl-cmd tl-ask asking';
const dangerLine = m.destructive
? '<div class="askdanger">' + codicon('warning') + ' The server marks this tool <b>destructive</b>. It will always ask, even if allow-listed.</div>'
: '';
const argsWell = (m.args && String(m.args).trim())
? '<div class="askcode"><pre class="cmdsrc mcpargs">' + esc(m.args) + '</pre></div>'
: '<div class="asksub" style="opacity:.7">No arguments.</div>';
// "Always allow" only when the tool CAN be allow-listed — a destructive tool can't, so we don't
// offer a button that would silently do nothing (mirrors describeMcpCall.canAllowAlways).
// "Always allow" reuses the secondary (skip-style) look but is selected by its OWN class — two
// buttons sharing `.approve` would make querySelector('.approve') ambiguous.
const always = m.canAllowAlways
? '<button class="skip mcp-always" title="Run it now and add it to your allow-list so future runs don’t ask">Always allow</button>'
: '';
card.innerHTML =
'<div class="tl-rail"><span class="tl-node">' + codicon('shield') + '</span></div>'
+ '<div class="tl-body"><div class="askcard">'
+ '<div class="asktitle">Run an MCP tool?</div>'
+ '<div class="asksub"><b>' + esc(m.server || '') + '</b> · ' + esc(m.tool || '') + ' — a third-party tool, not one of LevelCode’s own.</div>'
+ dangerLine
+ argsWell
+ '<div class="askbtns">'
+ '<button class="skip" title="Don’t run it (esc)">Skip <kbd>esc</kbd></button>'
+ always
+ '<button class="approve" title="Run it once (⏎)">Allow once <kbd>⏎</kbd></button>'
+ '</div>'
+ '</div></div>';
log.appendChild(card); scrollIfStuck();
const done = (approved, remember) => {
pendingApproval = null;
vscode.postMessage({ type: 'approvalResponse', id: m.id, approved, remember: !!remember, mcpName: m.name });
card.classList.remove('asking');
if (!approved) { card.classList.add('skipped'); }
const verb = !approved ? 'Skipped' : (remember ? 'Always allowed' : 'Approved');
card.querySelector('.tl-body').innerHTML =
'<div class="cmdhead"><span class="cmdverb">' + verb + '</span>'
+ '<span class="cmdchips"><code>' + esc(m.server || '') + '</code><code>' + esc(m.tool || '') + '</code></span>'
+ '<span class="cmdstate ' + (approved ? 'ok' : 'bad') + '">' + codicon(approved ? 'check-circle' : 'circle-slash') + '</span></div>';
forceStick();
};
card.querySelector('.approve').onclick = () => done(true, false); // Allow once (primary)
card.querySelector('.skip:not(.mcp-always)').onclick = () => done(false, false);
const alt = card.querySelector('.mcp-always'); if (alt) { alt.onclick = () => done(true, true); }
pendingApproval = { done }; // Enter = Allow once, Esc = Skip
}

function addApproval(m){
if (m.kind === 'mcp') { return addMcpApproval(m); }
clearEmpty(); clearStatus(); agentBubble = null;
closeGroup(); // a blocking gate never hides inside a collapsed group (D4)
const card = document.createElement('div'); card.className = 'tl tl-cmd tl-ask asking';
Expand Down
Loading