Skip to content

Commit b16b2c5

Browse files
ndemiancclaude
andcommitted
fix(mcp): keep the MCP chip and refusal message honest about destructive tools
PR #31 review (Copilot). Both findings had one root: a destructiveHint tool can never be enabled by the allow-list (classifyMcpTool forces 'ask' regardless), yet two callers spoke as if it could. 1. The startup chip counted allow-listed tools by calling classifyMcpTool WITHOUT annotations, while runTool calls it WITH them. So a destructive-but-allow-listed tool was counted as "allow-listed" in the chip and then refused at call time — the chip lied. Now threads the same route annotations into the count. 2. The refusal message unconditionally told the model to add the tool to levelcode.ai.mcp.toolPolicy as "allow". For a destructive tool that is impossible, so the model would allow-list it, retry, and be refused again. Root-cause fix rather than two patches: classifyMcpTool now also returns policyCanAllow (false only for the destructive tighten-path), and the refusal message moves OUT of agent.js into explainMcpRefusal() beside the classifier — the message that drifted from the policy now branches solely on the verdict, in the same module, and is unit-testable off the editor (agent.js requires vscode). agent.js just calls it. Verified: - 37 tests (2 new + policyCanAllow assertions on the existing POLICY cases). - Both fixes mutation-checked: a destructive verdict claiming policyCanAllow:true fails; a message that ignores the discriminator (the original bug) fails. - End-to-end against the S2 fixture with 'boom' marked destructive: the exact chip-count logic returns 0/3 for an allow-listed destructive tool, the message omits any toolPolicy instruction, and a plain allow-listed tool still counts 1 and would run. The chip-count line itself lives in agent.js and so is covered by that integration run + reading, not CI — same as the router glue. - Full gate: 18 suites, 0 failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ea02d1a commit b16b2c5

3 files changed

Lines changed: 78 additions & 16 deletions

File tree

extensions/levelcode-ai/agent.js

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

2323
const SYSTEM_BASE = [
@@ -457,13 +457,12 @@ async function runTool(tu, ctx) {
457457
if (verdict.approve !== 'allow') {
458458
// S3 deliberately ships no approval CARD (S4 owns it), so anything the user has not
459459
// explicitly allow-listed is REFUSED rather than run — the alternative would be silently
460-
// executing third-party code on the user's behalf with no way to say no. The message is
461-
// addressed to the model but written for the user's benefit: it names the exact setting.
460+
// executing third-party code on the user's behalf with no way to say no. The explanation
461+
// lives in mcpConfig beside the classifier so it can't drift from it (PR #31 review): a
462+
// destructive tool is refused for a reason the allow-list cannot fix, and must not be
463+
// described as allow-listable.
462464
ctx.post({ type: 'agentTool', icon: 'shield', text: '🔌 mcp · refused ' + tu.name + ' — ' + verdict.reason });
463-
return 'ERROR: the MCP tool "' + tu.name + '" is not approved to run (' + verdict.reason + '), and '
464-
+ 'approval prompts are not available in this build. To allow it, the USER must add '
465-
+ '"' + tu.name + '": "allow" to the "levelcode.ai.mcp.toolPolicy" setting. Do NOT retry it in '
466-
+ 'this run — continue without it, or tell the user what you needed it for.';
465+
return explainMcpRefusal(tu.name, verdict);
467466
}
468467
const server = getServer(route.server);
469468
if (!server || !server.alive) { return 'ERROR: the MCP server "' + route.server + '" is not running.'; }
@@ -543,8 +542,13 @@ async function setupMcp(ctx, wsFolders, dbg) {
543542
if (!built.tools.length) { return empty; }
544543

545544
// Show the allow-listed count up front: with no policy set it reads "0/12 allow-listed", which is
546-
// what makes a later refusal legible instead of looking broken.
547-
const allowed = built.tools.filter((t) => classifyMcpTool(t.name, cfg.toolPolicy).approve === 'allow').length;
545+
// what makes a later refusal legible instead of looking broken. Pass the SAME annotations runTool
546+
// will (PR #31 review) — otherwise a destructive-but-allow-listed tool is counted here yet refused
547+
// there, and the chip lies. The route always exists for a built tool; guard defensively anyway.
548+
const allowed = built.tools.filter((t) => {
549+
const route = built.routes.get(t.name);
550+
return classifyMcpTool(t.name, cfg.toolPolicy, route && route.annotations).approve === 'allow';
551+
}).length;
548552
const summary = handles.map((h) => h.name + ' (' + h.tools.length + ')').join(', ');
549553
dbg('mcp.ready', { servers: handles.map((h) => h.name), tools: built.tools.length, allowed });
550554
ctx.post({ type: 'agentTool', icon: 'plug', text: '🔌 mcp · ' + summary + ' · ' + allowed + '/' + built.tools.length + ' allow-listed' });

extensions/levelcode-ai/mcpConfig.js

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -309,25 +309,52 @@ function buildAgentTools(servers, opts) {
309309
* only push toward asking, never toward allowing: a `destructiveHint` overrides an allow-list entry
310310
* (worst case, one extra prompt), while a `readOnlyHint` grants nothing on its own.
311311
*
312+
* `policyCanAllow` answers a question the callers kept getting wrong (PR #31 review): would adding
313+
* this tool to the allow-list actually grant it? For every ordinary refusal, yes. For a `destructiveHint`
314+
* refusal, NO — a server hint may only tighten, so the allow-list cannot override it. Callers use this to
315+
* avoid (a) counting a destructive-but-allow-listed tool as "allow-listed" in the startup chip, and
316+
* (b) telling the model to allow-list a tool that allow-listing can never enable.
317+
*
312318
* @param {string} name the namespaced tool name (server__tool)
313319
* @param {object} [policy] user map, e.g. { 'github__list_issues': 'allow', '*': 'ask' }
314320
* @param {object} [annotations] the server's own hints for this tool (untrusted)
315-
* @returns {{ approve: 'ask'|'allow', reason: string }}
321+
* @returns {{ approve: 'ask'|'allow', reason: string, policyCanAllow: boolean }}
316322
*/
317323
function classifyMcpTool(name, policy, annotations) {
318324
if (annotations && annotations.destructiveHint === true) {
319-
return { approve: 'ask', reason: 'the server marks this tool destructive' };
325+
return { approve: 'ask', reason: 'the server marks this tool destructive', policyCanAllow: false };
320326
}
321327
const p = policy || {};
322328
const exact = p[name];
323-
if (exact === 'allow') { return { approve: 'allow', reason: 'allow-listed by you' }; }
324-
if (exact === 'ask') { return { approve: 'ask', reason: 'set to ask by you' }; }
329+
if (exact === 'allow') { return { approve: 'allow', reason: 'allow-listed by you', policyCanAllow: true }; }
330+
if (exact === 'ask') { return { approve: 'ask', reason: 'set to ask by you', policyCanAllow: true }; }
325331
const star = p['*'];
326-
if (star === 'allow') { return { approve: 'allow', reason: 'allow-listed by you (*)' }; }
327-
return { approve: 'ask', reason: 'third-party tool (default)' };
332+
if (star === 'allow') { return { approve: 'allow', reason: 'allow-listed by you (*)', policyCanAllow: true }; }
333+
return { approve: 'ask', reason: 'third-party tool (default)', policyCanAllow: true };
334+
}
335+
336+
/**
337+
* The agent-facing explanation for a refused MCP call in a build with no approval card (S3). It lives
338+
* HERE, beside classifyMcpTool, on purpose: the PR #31 review caught this message telling the model to
339+
* allow-list a destructive tool that allow-listing can never enable — the message had drifted from the
340+
* policy. Keeping both in one module (and unit-testing this off the editor) is what stops the drift
341+
* recurring. Branches solely on the verdict, so it cannot disagree with the classifier.
342+
*
343+
* @param {string} name the namespaced tool name
344+
* @param {{reason:string, policyCanAllow:boolean}} verdict from classifyMcpTool (a non-'allow' one)
345+
* @returns {string}
346+
*/
347+
function explainMcpRefusal(name, verdict) {
348+
const head = 'ERROR: the MCP tool "' + name + '" is not approved to run (' + verdict.reason + '). ';
349+
const fix = verdict.policyCanAllow
350+
? 'This build has no per-call approval prompt, so the only way to permit it is for the USER to add '
351+
+ '"' + name + '": "allow" to the "levelcode.ai.mcp.toolPolicy" setting. '
352+
: 'Such tools always require per-call approval — which this build does not yet provide — so it '
353+
+ 'CANNOT be enabled through the allow-list. ';
354+
return head + fix + 'Do NOT retry it in this run — continue without it, or tell the user what you needed it for.';
328355
}
329356

330357
module.exports = {
331-
loadServerConfig, namespaceToolName, assignToolNames, buildAgentTools, classifyMcpTool,
358+
loadServerConfig, namespaceToolName, assignToolNames, buildAgentTools, classifyMcpTool, explainMcpRefusal,
332359
BUILTIN_TOOL_NAMES, MAX_TOOL_NAME, MAX_TOOL_DESC, MAX_SERVERS, MAX_TOOLS_PER_SERVER, WORKSPACE_CONFIG_PATH
333360
};

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,12 +250,43 @@ test('POLICY: a server destructiveHint OVERRIDES an allow-list entry (tighten-on
250250
const r = M.classifyMcpTool('gh__nuke', { 'gh__nuke': 'allow' }, { destructiveHint: true });
251251
assert.strictEqual(r.approve, 'ask');
252252
assert.ok(/destructive/.test(r.reason));
253+
// PR #31 review: the allow-list did not override it, so it must ALSO report that the allow-list
254+
// cannot help — otherwise the chip counts it and the refusal message misdirects the model.
255+
assert.strictEqual(r.policyCanAllow, false);
256+
});
257+
258+
test('POLICY: policyCanAllow is true for every refusal the allow-list CAN fix', () => {
259+
// Everything except a destructive hint is unblockable by editing the policy — the callers rely on
260+
// this to decide whether to say "add it to the allow-list".
261+
assert.strictEqual(M.classifyMcpTool('gh__x').policyCanAllow, true); // default ask
262+
assert.strictEqual(M.classifyMcpTool('gh__x', { 'gh__x': 'ask' }).policyCanAllow, true); // explicit ask
263+
assert.strictEqual(M.classifyMcpTool('gh__x', { 'gh__x': 'allow' }).policyCanAllow, true); // already allowed
264+
assert.strictEqual(M.classifyMcpTool('gh__x', {}, { readOnlyHint: true }).policyCanAllow, true);
253265
});
254266

255267
test('POLICY: a readOnlyHint grants nothing on its own (annotations are untrusted)', () => {
256268
assert.strictEqual(M.classifyMcpTool('gh__read', {}, { readOnlyHint: true }).approve, 'ask');
257269
});
258270

271+
test('REFUSAL: the message tells the model to allow-list ONLY when that would work', () => {
272+
// The exact bug from the PR #31 review: a destructive tool was told to allow-list itself, which
273+
// classifyMcpTool can never honour. Drive explainMcpRefusal off the real verdicts so the message
274+
// and the policy cannot disagree.
275+
const destructive = M.classifyMcpTool('gh__nuke', { 'gh__nuke': 'allow' }, { destructiveHint: true });
276+
const mDestructive = M.explainMcpRefusal('gh__nuke', destructive);
277+
assert.ok(!/toolPolicy/.test(mDestructive), 'must NOT point a destructive tool at the allow-list');
278+
assert.ok(/destructive/.test(mDestructive) && /CANNOT/.test(mDestructive), 'must say why it is unfixable');
279+
280+
const plain = M.classifyMcpTool('gh__read'); // default ask, fixable
281+
const mPlain = M.explainMcpRefusal('gh__read', plain);
282+
assert.ok(/"gh__read": "allow"/.test(mPlain) && /toolPolicy/.test(mPlain), 'must name the exact setting to add');
283+
284+
for (const m of [mDestructive, mPlain]) {
285+
assert.ok(/^ERROR:/.test(m), 'stays an ERROR string so the loop treats it as a tool failure');
286+
assert.ok(/Do NOT retry/.test(m), 'must tell the model not to retry, or it loops');
287+
}
288+
});
289+
259290
// ---- 4. buildAgentTools: MCP tool specs → agent descriptors + routing table (S3) --------------
260291

261292
const SRV = (name, tools) => ({ name, tools });

0 commit comments

Comments
 (0)