Skip to content

Commit f8d542a

Browse files
ndemiancclaude
andcommitted
feat(mcp): per-call approval card — S4a (lifts S3's allow-list-only wall)
S3 REFUSED any MCP tool the user hadn't hand-added to levelcode.ai.mcp.toolPolicy. That was the safe-but-unusable placeholder; S4 replaces it with an actual prompt. Now a tool that isn't allow-listed shows a card — server · tool · the real arguments — with Skip / Allow once / Always allow. "Always allow" writes the tool to the allow-list (Global tier, matching the setting's application scope), so future runs skip the prompt: the allow-list is still the ONE thing that grants 'allow' (G3). Autopilot does not relax any of this — an MCP tool is third-party code. Security properties, all verified end-to-end against the S2 fixture server (five paths: allow-listed runs silently; un-listed prompts→runs on Allow; un-listed prompts→NOT run on Skip; destructive prompts even when allow-listed and offers no "always"; no-webview refuses): - A destructive tool ALWAYS prompts (classifyMcpTool tightens on it) and the card hides "Always allow" — offering it would be a button that does nothing, since a destructive tool can never be allow-listed. canAllowAlways is derived from the same annotation the classifier reads, so they can't disagree. - The arguments are shown IN FULL on the card (only length-capped). That is the decision — the user owns the credentials and needs to see the repo it will touch, the row it will delete. The card is ephemeral UI, never the transcript; the debug-log redaction (G4) is unchanged. - No webview to ask through (headless / tests) → falls back to S3's refusal rather than running third-party code with no way to say no. Pieces: - mcpConfig.describeMcpCall (pure): server/tool from the route with a namespaced-name fallback, bounded+pretty args (never throws on circular input), destructive/canAllowAlways. 3 new tests, mutation-checked (making a destructive tool "always-allowable" fails). - agent.js router: refuse → prompt via ctx.approve({kind:'mcp',…}). - extension.js: mcpAllowAlways writes the allow-list (user-scoped read, Global write, rejects a non-namespaced name). - chat.html: the kind:'mcp' card. VERIFIED VISUALLY — screenshotted both the normal and destructive variants: args wrap, buttons are distinct (two sharing `.approve` was a selector bug, caught and fixed), destructive shows the amber warning and no "Always allow". Also: the MCP chips used icon:'plug', which isn't a registered codicon, so addAgentLine rendered the literal word "plug" in the rail. Swapped to 'sparkle' (the 🔌 emoji still marks it MCP). The identical latent issue on the auto-preview 'globe' chip (extension.js:701) is #35's, left for a follow-up. And explainMcpRefusal's wording ("this build has no prompt") was made accurate — it's now only the non-interactive fallback. Deferred to S4b: the G1 trust-on-first-use LAUNCH gate for workspace-file (.levelcode/mcp.json) servers — they're still read-and-listed but never started. This PR is the per-call gate; the launch gate is its own reviewable slice. Verified: 24 suites, 0 failures (mcpConfig 44 cases). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 78ce378 commit f8d542a

5 files changed

Lines changed: 194 additions & 19 deletions

File tree

extensions/levelcode-ai/agent.js

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const providers = require('./providers/index');
1717
const { formatVerifyFeedback, verifyOutcome, looksUnrunnable, sniffPort, sniffPreviewUrl, looksReady } = require('./verify');
1818
const { classifyCommand, dangerLabel } = require('./commandSafety');
1919
const { loadProjectRules } = require('./projectRules');
20-
const { loadServerConfig, buildAgentTools, toolCountsByServer, classifyMcpTool, explainMcpRefusal } = require('./mcpConfig');
20+
const { loadServerConfig, buildAgentTools, toolCountsByServer, classifyMcpTool, explainMcpRefusal, describeMcpCall } = require('./mcpConfig');
2121
const { connectAll, getServer } = require('./mcpClient');
2222

2323
const SYSTEM_BASE = [
@@ -484,19 +484,30 @@ async function runTool(tu, ctx) {
484484
const route = ctx.mcpRoutes && ctx.mcpRoutes.get(tu.name);
485485
if (route) {
486486
const verdict = classifyMcpTool(tu.name, ctx.mcp && ctx.mcp.toolPolicy, route.annotations);
487-
if (verdict.approve !== 'allow') {
488-
// S3 deliberately ships no approval CARD (S4 owns it), so anything the user has not
489-
// explicitly allow-listed is REFUSED rather than run — the alternative would be silently
490-
// executing third-party code on the user's behalf with no way to say no. The explanation
491-
// lives in mcpConfig beside the classifier so it can't drift from it (PR #31 review): a
492-
// destructive tool is refused for a reason the allow-list cannot fix, and must not be
493-
// described as allow-listable.
494-
ctx.post({ type: 'agentTool', icon: 'shield', text: '🔌 mcp · refused ' + tu.name + ' — ' + verdict.reason });
495-
return explainMcpRefusal(tu.name, verdict);
496-
}
497487
const server = getServer(route.server);
498488
if (!server || !server.alive) { return 'ERROR: the MCP server "' + route.server + '" is not running.'; }
499-
ctx.post({ type: 'agentTool', icon: 'plug', text: '🔌 ' + route.server + ' · ' + route.tool });
489+
// S4: a call the user hasn't allow-listed is now PROMPTED, not refused. Autopilot does not relax
490+
// this (G3) — an MCP tool is third-party code — and a server-marked-destructive tool prompts even
491+
// when allow-listed (classifyMcpTool tightens on it). Only 'allow' skips the card.
492+
if (verdict.approve !== 'allow') {
493+
if (typeof ctx.approve !== 'function') {
494+
// No webview to ask through (headless / a test harness) — fall back to S3's safe refusal
495+
// rather than run third-party code with no way to say no.
496+
ctx.post({ type: 'agentTool', icon: 'shield', text: '🔌 mcp · refused ' + tu.name + ' — ' + verdict.reason });
497+
return explainMcpRefusal(tu.name, verdict);
498+
}
499+
const call = describeMcpCall(tu.name, input, route);
500+
const approved = await ctx.approve({
501+
kind: 'mcp', name: tu.name, server: call.server, tool: call.tool,
502+
args: call.argsText, destructive: call.destructive, canAllowAlways: call.canAllowAlways
503+
});
504+
if (!approved) {
505+
ctx.post({ type: 'agentTool', icon: 'shield', text: '🔌 mcp · skipped ' + tu.name });
506+
return 'User declined to run the MCP tool "' + tu.name + '". Do NOT retry it in this run — '
507+
+ 'continue without it, or tell the user what you needed it for.';
508+
}
509+
}
510+
ctx.post({ type: 'agentTool', icon: 'sparkle', text: '🔌 ' + route.server + ' · ' + route.tool });
500511
return await server.call(route.tool, input); // never throws — failures come back as `ERROR: …`
501512
}
502513
return 'ERROR: unknown tool ' + tu.name;
@@ -586,7 +597,7 @@ async function setupMcp(ctx, wsFolders, dbg) {
586597
const perServer = toolCountsByServer(built.routes);
587598
const summary = handles.map((h) => h.name + ' (' + (perServer.get(h.name) || 0) + ')').join(', ');
588599
dbg('mcp.ready', { servers: handles.map((h) => h.name), tools: built.tools.length, allowed });
589-
ctx.post({ type: 'agentTool', icon: 'plug', text: '🔌 mcp · ' + summary + ' · ' + allowed + '/' + built.tools.length + ' allow-listed' });
600+
ctx.post({ type: 'agentTool', icon: 'sparkle', text: '🔌 mcp · ' + summary + ' · ' + allowed + '/' + built.tools.length + ' allow-listed' });
590601
return built;
591602
} catch (e) {
592603
dbg('mcp.failed', { error: (e && e.message) || String(e) });

extensions/levelcode-ai/extension.js

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,28 @@ async function restoreCheckpoint(turnId) {
769769
const pendingApprovals = new Map();
770770
let approvalSeq = 0;
771771

772+
/**
773+
* Persist an MCP tool to the allow-list (the ONLY thing that grants 'allow' — G3). Writes to the USER
774+
* (Global) tier, matching the application scope the setting is declared with, so a repo can never flip
775+
* it. Reads the current value the same user-scoped way it is read at run start. Idempotent, and refuses
776+
* a tool name that is not a namespaced server__tool to avoid writing junk from a malformed message.
777+
*/
778+
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;
781+
}
782+
try {
783+
const cfg = aiConfig();
784+
const cur = userScopedSetting(cfg.inspect('mcp.toolPolicy'), {}) || {};
785+
if (cur[name] === 'allow') { return; }
786+
await cfg.update('mcp.toolPolicy', Object.assign({}, cur, { [name]: 'allow' }), vscode.ConfigurationTarget.Global);
787+
post({ type: 'agentTool', icon: 'check', text: '🔌 mcp · always allow ' + name });
788+
dbg('mcp.allow.persisted', { name });
789+
} catch (e) {
790+
dbg('mcp.allow.failed', { name, error: String((e && e.message) || e) });
791+
}
792+
}
793+
772794
/** Ask the webview to approve an action; resolves true/false. */
773795
function requestApproval(req) {
774796
const id = String(++approvalSeq);
@@ -1358,7 +1380,13 @@ class ChatViewProvider {
13581380
case 'send': await handleSend(msg.text); break;
13591381
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;
13601382
case 'stopCommand': { dbg('stopCommand', { id: msg.id }); const s = commandStops.get(msg.id); if (s) { try { s(); } catch (e) { /* gone */ } } break; }
1361-
case 'approvalResponse': resolveApproval(msg.id, msg.approved); break;
1383+
case 'approvalResponse':
1384+
// "Always allow" on an MCP card persists the tool to the allow-list BEFORE resolving, so a
1385+
// future run skips the prompt. It only ever adds an ALLOW (never a broadening default), and
1386+
// the webview offers it only for non-destructive tools — mcpAllowAlways re-checks anyway.
1387+
if (msg.approved && msg.remember && msg.mcpName) { await mcpAllowAlways(msg.mcpName); }
1388+
resolveApproval(msg.id, msg.approved);
1389+
break;
13621390
case 'questionsResponse': resolveQuestions(msg.id, msg.answers, msg.notes); break;
13631391
case 'accountSignIn': await accountSignIn(msg.provider, msg.create); break;
13641392
case 'accountSignOut': await accountSignOut(); break;

extensions/levelcode-ai/mcpConfig.js

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -400,17 +400,59 @@ function classifyMcpTool(name, policy, annotations) {
400400
* @returns {string}
401401
*/
402402
function explainMcpRefusal(name, verdict) {
403+
// Reached only when there is NO interactive approval to fall back on (a headless run, a test harness).
404+
// With a webview present, S4 shows the per-call card instead of this message.
403405
const head = 'ERROR: the MCP tool "' + name + '" is not approved to run (' + verdict.reason + '). ';
404406
const fix = verdict.policyCanAllow
405-
? 'This build has no per-call approval prompt, so the only way to permit it is for the USER to add '
407+
? 'No interactive approval is available here, so the only way to permit it is for the USER to add '
406408
+ '"' + name + '": "allow" to the "levelcode.ai.mcp.toolPolicy" setting. '
407-
: 'Such tools always require per-call approval — which this build does not yet provide — so it '
408-
+ 'CANNOT be enabled through the allow-list. ';
409+
: 'Such tools always require per-call approval and CANNOT be enabled through the allow-list, so '
410+
+ 'there is no way to run it in this non-interactive context. ';
409411
return head + fix + 'Do NOT retry it in this run — continue without it, or tell the user what you needed it for.';
410412
}
411413

414+
// A tool call's arguments can be large, and the approval card must not be blown open by one. See
415+
// describeMcpCall — the card is capped, the full args still reach the server if approved.
416+
const MAX_ARG_CHARS = 2000;
417+
418+
/** Pretty, bounded JSON for the args shown on the approval card. Never throws (circular/huge input). */
419+
function previewArgs(args) {
420+
if (args == null) { return ''; }
421+
let text;
422+
try { text = JSON.stringify(args, null, 2); }
423+
catch { try { text = String(args); } catch { text = '[unserializable arguments]'; } }
424+
if (text == null) { return ''; }
425+
return text.length > MAX_ARG_CHARS ? text.slice(0, MAX_ARG_CHARS - 1) + '…' : text;
426+
}
427+
428+
/**
429+
* Shape an MCP call for the approval card (S4) — exactly what the user reads before deciding.
430+
*
431+
* Unlike the debug log (G4), the arguments are shown in FULL here, only length-capped. That is not an
432+
* oversight: the card is ephemeral UI shown to the person who owns the credentials, and seeing the real
433+
* arguments — the repo it will touch, the id it will delete — IS the decision. Redacting them would make
434+
* the prompt meaningless. Nothing here is persisted; the card is not the transcript.
435+
*
436+
* `canAllowAlways` is false for a destructive tool: a server-marked-destructive tool can never be moved
437+
* to the allow-list (classifyMcpTool tightens on it), so the card must not offer a button that would do
438+
* nothing. Derived from the same annotation the classifier reads, so the two cannot disagree.
439+
*
440+
* @param {string} name namespaced tool name (server__tool)
441+
* @param {*} args the arguments the model produced for this call
442+
* @param {{server?:string, tool?:string, annotations?:object}} [route]
443+
* @returns {{server:string, tool:string, argsText:string, destructive:boolean, canAllowAlways:boolean}}
444+
*/
445+
function describeMcpCall(name, args, route) {
446+
const r = route || {};
447+
const fallback = String(name == null ? '' : name).split(NAME_SEPARATOR);
448+
const server = typeof r.server === 'string' && r.server ? r.server : (fallback[0] || String(name));
449+
const tool = typeof r.tool === 'string' && r.tool ? r.tool : (fallback.slice(1).join(NAME_SEPARATOR) || String(name));
450+
const destructive = !!(r.annotations && r.annotations.destructiveHint === true);
451+
return { server, tool, argsText: previewArgs(args), destructive, canAllowAlways: !destructive };
452+
}
453+
412454
module.exports = {
413455
loadServerConfig, userScopedSetting, namespaceToolName, assignToolNames, buildAgentTools,
414-
toolCountsByServer, classifyMcpTool, explainMcpRefusal,
415-
BUILTIN_TOOL_NAMES, MAX_TOOL_NAME, MAX_TOOL_DESC, MAX_SERVERS, MAX_TOOLS_PER_SERVER, WORKSPACE_CONFIG_PATH
456+
toolCountsByServer, classifyMcpTool, explainMcpRefusal, describeMcpCall,
457+
BUILTIN_TOOL_NAMES, MAX_TOOL_NAME, MAX_TOOL_DESC, MAX_ARG_CHARS, MAX_SERVERS, MAX_TOOLS_PER_SERVER, WORKSPACE_CONFIG_PATH
416458
};

extensions/levelcode-ai/media/chat.html

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,9 @@
380380
.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; }
381381
/* recessed well for the command; capped so a long one can't push the buttons off-screen */
382382
.tl-ask .askcode { margin-top: 10px; border-radius: 8px; background: var(--vscode-textCodeBlock-background, rgba(127,127,127,.14)); max-height: 40vh; overflow: auto; }
383+
/* MCP call arguments — wrap rather than force horizontal scroll, and keep them monospace + tidy. */
384+
.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; }
385+
.tl-ask .askbtns .mcp-always { margin-left: auto; }
383386
.tl-ask .askbtns { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 11px; }
384387
.tl-ask .askbtns button { cursor: pointer; border: 1px solid transparent; border-radius: 7px; padding: 5px 14px; font-size: 12px; font-weight: 500;
385388
display: inline-flex; align-items: baseline; gap: 6px; } /* transparent border keeps both buttons the same height */
@@ -1871,7 +1874,59 @@
18711874
// I being asked", which is exactly what it is for. On decision it collapses to a one-line verdict;
18721875
// the live run card follows with the output.
18731876
let pendingApproval = null; // { done } while a decision is awaited — Enter approves, Esc skips
1877+
// MCP tool call (S4) — its own card: server · tool · arguments, so the user sees exactly what a
1878+
// third-party tool is about to do. Args are shown in full (capped host-side): that IS the decision.
1879+
function addMcpApproval(m){
1880+
clearEmpty(); clearStatus(); agentBubble = null;
1881+
closeGroup();
1882+
const card = document.createElement('div'); card.className = 'tl tl-cmd tl-ask asking';
1883+
const dangerLine = m.destructive
1884+
? '<div class="askdanger">' + codicon('warning') + ' The server marks this tool <b>destructive</b>. It will always ask, even if allow-listed.</div>'
1885+
: '';
1886+
const argsWell = (m.args && String(m.args).trim())
1887+
? '<div class="askcode"><pre class="cmdsrc mcpargs">' + esc(m.args) + '</pre></div>'
1888+
: '<div class="asksub" style="opacity:.7">No arguments.</div>';
1889+
// "Always allow" only when the tool CAN be allow-listed — a destructive tool can't, so we don't
1890+
// offer a button that would silently do nothing (mirrors describeMcpCall.canAllowAlways).
1891+
// "Always allow" reuses the secondary (skip-style) look but is selected by its OWN class — two
1892+
// buttons sharing `.approve` would make querySelector('.approve') ambiguous.
1893+
const always = m.canAllowAlways
1894+
? '<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>'
1895+
: '';
1896+
card.innerHTML =
1897+
'<div class="tl-rail"><span class="tl-node">' + codicon('shield') + '</span></div>'
1898+
+ '<div class="tl-body"><div class="askcard">'
1899+
+ '<div class="asktitle">Run an MCP tool?</div>'
1900+
+ '<div class="asksub"><b>' + esc(m.server || '') + '</b> · ' + esc(m.tool || '') + ' — a third-party tool, not one of LevelCode’s own.</div>'
1901+
+ dangerLine
1902+
+ argsWell
1903+
+ '<div class="askbtns">'
1904+
+ '<button class="skip" title="Don’t run it (esc)">Skip <kbd>esc</kbd></button>'
1905+
+ always
1906+
+ '<button class="approve" title="Run it once (⏎)">Allow once <kbd>⏎</kbd></button>'
1907+
+ '</div>'
1908+
+ '</div></div>';
1909+
log.appendChild(card); scrollIfStuck();
1910+
const done = (approved, remember) => {
1911+
pendingApproval = null;
1912+
vscode.postMessage({ type: 'approvalResponse', id: m.id, approved, remember: !!remember, mcpName: m.name });
1913+
card.classList.remove('asking');
1914+
if (!approved) { card.classList.add('skipped'); }
1915+
const verb = !approved ? 'Skipped' : (remember ? 'Always allowed' : 'Approved');
1916+
card.querySelector('.tl-body').innerHTML =
1917+
'<div class="cmdhead"><span class="cmdverb">' + verb + '</span>'
1918+
+ '<span class="cmdchips"><code>' + esc(m.server || '') + '</code><code>' + esc(m.tool || '') + '</code></span>'
1919+
+ '<span class="cmdstate ' + (approved ? 'ok' : 'bad') + '">' + codicon(approved ? 'check-circle' : 'circle-slash') + '</span></div>';
1920+
forceStick();
1921+
};
1922+
card.querySelector('.approve').onclick = () => done(true, false); // Allow once (primary)
1923+
card.querySelector('.skip:not(.mcp-always)').onclick = () => done(false, false);
1924+
const alt = card.querySelector('.mcp-always'); if (alt) { alt.onclick = () => done(true, true); }
1925+
pendingApproval = { done }; // Enter = Allow once, Esc = Skip
1926+
}
1927+
18741928
function addApproval(m){
1929+
if (m.kind === 'mcp') { return addMcpApproval(m); }
18751930
clearEmpty(); clearStatus(); agentBubble = null;
18761931
closeGroup(); // a blocking gate never hides inside a collapsed group (D4)
18771932
const card = document.createElement('div'); card.className = 'tl tl-cmd tl-ask asking';

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,4 +504,43 @@ test('SOURCE: the mcp modules contain no raw control bytes', () => {
504504
}
505505
});
506506

507+
// ---- 6. describeMcpCall: the approval card's content (S4) -------------------------------------
508+
509+
test('CARD: server/tool come from the route, with a namespaced-name fallback', () => {
510+
const d = M.describeMcpCall('github__create_issue', { title: 'x' }, { server: 'github', tool: 'create_issue' });
511+
assert.strictEqual(d.server, 'github');
512+
assert.strictEqual(d.tool, 'create_issue');
513+
// No route → split the namespaced name on the separator rather than showing a blank card.
514+
const f = M.describeMcpCall('github__create_issue', {}, undefined);
515+
assert.strictEqual(f.server, 'github');
516+
assert.strictEqual(f.tool, 'create_issue');
517+
});
518+
519+
test('CARD: a destructive tool cannot be "always allowed"', () => {
520+
// The card must not offer a button that does nothing — a destructive tool can never be allow-listed
521+
// (classifyMcpTool tightens on it), so canAllowAlways is derived from the SAME annotation.
522+
const d = M.describeMcpCall('gh__nuke', {}, { server: 'gh', tool: 'nuke', annotations: { destructiveHint: true } });
523+
assert.strictEqual(d.destructive, true);
524+
assert.strictEqual(d.canAllowAlways, false);
525+
const ok = M.describeMcpCall('gh__list', {}, { server: 'gh', tool: 'list' });
526+
assert.strictEqual(ok.destructive, false);
527+
assert.strictEqual(ok.canAllowAlways, true);
528+
});
529+
530+
test('CARD: arguments are shown in full but bounded, and never throw', () => {
531+
const d = M.describeMcpCall('s__t', { path: '/etc/passwd', n: 3 }, { server: 's', tool: 't' });
532+
assert.ok(d.argsText.includes('/etc/passwd') && d.argsText.includes('"n": 3'), 'the user must SEE the real args');
533+
534+
// Over the cap → truncated with an ellipsis, not dropped and not unbounded.
535+
const big = M.describeMcpCall('s__t', { blob: 'z'.repeat(5000) }, { server: 's', tool: 't' });
536+
assert.ok(big.argsText.length <= M.MAX_ARG_CHARS, 'args preview must obey MAX_ARG_CHARS');
537+
assert.ok(big.argsText.endsWith('…'));
538+
539+
// Circular / weird input must not throw inside the card renderer's data prep.
540+
const circ = {}; circ.self = circ;
541+
assert.doesNotThrow(() => M.describeMcpCall('s__t', circ, { server: 's', tool: 't' }));
542+
assert.strictEqual(M.describeMcpCall('s__t', null, { server: 's', tool: 't' }).argsText, '');
543+
assert.strictEqual(M.describeMcpCall('s__t', undefined, { server: 's', tool: 't' }).argsText, '');
544+
});
545+
507546
console.log('\nmcpConfig.js: ' + n + ' tests passed.');

0 commit comments

Comments
 (0)