Skip to content

Commit e068761

Browse files
committed
fix(mcp): one normalization for the card and the fingerprint
Two more on #55, both mine, both the same root cause: I hardened one function and left its sibling, then left a comment describing the property I had removed. 1. describeMcpLaunch still threw on a truthy non-array `args`. launchFingerprint learned to tolerate it; the card kept doing `(s.args || []).map(...)` and threw `TypeError: .map is not a function` — in the helper that renders a SECURITY CONSENT CARD. A string `env` did not throw but was arguably worse: it enumerated character indices, so the card asked the user to trust `0=e 1=v 2=i 3=l`. Rendering nonsense on a consent prompt is worse than rendering nothing. My "never throws on a malformed entry" test only covered `args: null`, which is why it passed while the real malformed shape crashed. Extended, and verified the new cases reproduce the exact TypeError against the old code. 2. The comment above launchFingerprint contradicted the implementation. It said a malformed args/env "must not fingerprint the same as an absent one". The code collapses both to null, and the tests assert the collapse — I corrected the test in the previous round and left the comment that motivated it. Now the doc states the conservative semantics and why they are safe: normalizeServer rejects those shapes long before the gate, so neither is reachable, and the command itself always differentiates. Fixed structurally rather than pointwise: both now read launchMaterial(), one canonical normalization. This is the third divergence in this file between two places implementing the same rule, and a shared helper is the only version that cannot drift again. A test pins that the card reports the fingerprint that will actually be stored. 61 mcpConfig cases; 23 suites, 0 failures.
1 parent b6d5e1c commit e068761

2 files changed

Lines changed: 69 additions & 18 deletions

File tree

extensions/levelcode-ai/mcpConfig.js

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -514,20 +514,38 @@ function previewArgs(args) {
514514
* truncation helper is the wrong tool for an authorization decision; the cost of a real hash here is
515515
* one call per server per run.
516516
*/
517-
function launchFingerprint(server) {
517+
/**
518+
* The launch material, normalized ONCE — what would be executed, in canonical form.
519+
*
520+
* Shared by launchFingerprint and describeMcpLaunch on purpose. They were normalizing separately, and
521+
* they drifted: the fingerprint learned to survive a non-array `args` while the card kept calling
522+
* `.map` on it and threw. A consent card and the trust record it produces must describe the same thing,
523+
* so they read it from the same place.
524+
*
525+
* MALFORMED SHAPES COLLAPSE TO `null`, which is the same value an ABSENT field gets. That is
526+
* deliberate and conservative: normalizeServer rejects a non-array `args` or non-object `env` long
527+
* before a server reaches the gate, so neither is reachable here, and "no usable args" is the honest
528+
* reading of both. The command itself always differentiates. (An earlier comment claimed malformed and
529+
* absent stayed distinct — they do not, and the tests assert the collapse.)
530+
*
531+
* Env pairs stay STRUCTURAL — [[k, v]] sorted — never joined into "k=v". Joining is ambiguous:
532+
* { 'a': 'b=c' } and { 'a=b': 'c' } both flatten to "a=b=c", a collision handed over for free in the
533+
* one place collisions are the threat.
534+
*/
535+
function launchMaterial(server) {
518536
const s = server || {};
519-
// Non-array args / non-object env become null rather than [] or {}: a malformed entry must not
520-
// fingerprint the same as an absent one, and `.map` on a string would throw in a function whose
521-
// whole job is to be safe to call on anything.
522-
const args = Array.isArray(s.args) ? s.args.map(String) : null;
523-
const rawEnv = (s.env && typeof s.env === 'object' && !Array.isArray(s.env)) ? s.env : null;
524-
// Pairs stay STRUCTURAL — [[k, v]] — instead of being joined into "k=v". Joining is ambiguous:
525-
// { 'a': 'b=c' } and { 'a=b': 'c' } both flatten to "a=b=c", which is a collision handed over for
526-
// free in the one function where collisions are the threat.
527-
const env = rawEnv ? Object.keys(rawEnv).sort().map((k) => [k, String(rawEnv[k])]) : null;
528-
529-
const material = JSON.stringify({ command: String(s.command || ''), args: args, env: env });
530-
return crypto.createHash('sha256').update(material, 'utf8').digest('hex');
537+
const env = (s.env && typeof s.env === 'object' && !Array.isArray(s.env)) ? s.env : null;
538+
return {
539+
command: String(s.command || ''),
540+
args: Array.isArray(s.args) ? s.args.map(String) : null,
541+
env: env ? Object.keys(env).sort().map((k) => [k, String(env[k])]) : null
542+
};
543+
}
544+
545+
function launchFingerprint(server) {
546+
return crypto.createHash('sha256')
547+
.update(JSON.stringify(launchMaterial(server)), 'utf8')
548+
.digest('hex');
531549
}
532550

533551
/**
@@ -560,14 +578,16 @@ function rememberLaunchTrust(server, store) {
560578
*/
561579
function describeMcpLaunch(server) {
562580
const s = server || {};
563-
const quote = (a) => (/[\s"']/.test(String(a)) ? JSON.stringify(String(a)) : String(a));
564-
const env = s.env || {};
565-
const envLines = Object.keys(env).sort().map((k) => k + '=' + String(env[k]));
581+
// Read from launchMaterial, not from `server` directly. Doing its own normalization is what let this
582+
// throw on a string `args` (`.map` is not a function) and render `0=e 1=v 2=i 3=l` for a string
583+
// `env` — junk on the one card whose whole purpose is showing the user exactly what will run.
584+
const material = launchMaterial(s);
585+
const quote = (a) => (/[\s"']/.test(a) ? JSON.stringify(a) : a);
566586
return {
567587
server: String(s.name || ''),
568588
origin: String(s.origin || ''),
569-
commandLine: [String(s.command || '')].concat((s.args || []).map(quote)).join(' '),
570-
envLines: envLines,
589+
commandLine: [material.command].concat((material.args || []).map(quote)).join(' ').trim(),
590+
envLines: (material.env || []).map(([k, v]) => k + '=' + v),
571591
fingerprint: launchFingerprint(s)
572592
};
573593
}

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,37 @@ test('G1: describeMcpLaunch never throws on a malformed entry', () => {
762762
assert.doesNotThrow(() => M.describeMcpLaunch({}));
763763
assert.doesNotThrow(() => M.describeMcpLaunch({ name: 'x', args: null, env: null }));
764764
assert.strictEqual(M.describeMcpLaunch({}).commandLine, '');
765+
766+
// The shapes this test USED to miss. It only covered `null`, so a truthy non-array `args` still
767+
// reached `.map` and threw — in the helper that renders a security consent card.
768+
assert.doesNotThrow(() => M.describeMcpLaunch({ name: 'x', command: 'c', args: 'evil' }));
769+
assert.doesNotThrow(() => M.describeMcpLaunch({ name: 'x', command: 'c', args: 42 }));
770+
assert.doesNotThrow(() => M.describeMcpLaunch({ name: 'x', command: 'c', env: 'evil' }));
771+
assert.doesNotThrow(() => M.describeMcpLaunch({ name: 'x', command: 'c', env: [] }));
772+
});
773+
774+
test('G1: a malformed entry renders nothing rather than junk on the consent card', () => {
775+
// A string env used to enumerate its character indices — the card would ask the user to trust
776+
// `0=e 1=v 2=i 3=l`. Showing nonsense on a consent prompt is worse than showing nothing.
777+
const strEnv = M.describeMcpLaunch({ name: 'x', command: 'c', env: 'evil' });
778+
assert.deepStrictEqual(strEnv.envLines, [], 'no invented env lines');
779+
assert.strictEqual(strEnv.commandLine, 'c', 'the command still shows');
780+
781+
const strArgs = M.describeMcpLaunch({ name: 'x', command: 'c', args: 'evil' });
782+
assert.strictEqual(strArgs.commandLine, 'c', 'a malformed args contributes nothing, not "c e v i l"');
783+
});
784+
785+
test('G1: the card and the fingerprint read the SAME normalized material', () => {
786+
// They normalized separately once and drifted — the fingerprint tolerated a non-array args while
787+
// the card threw on it. A consent card and the trust it produces must describe one thing.
788+
const weird = { name: 'x', command: 'c', args: 'evil', env: 'evil' };
789+
assert.strictEqual(
790+
M.describeMcpLaunch(weird).fingerprint,
791+
M.launchFingerprint(weird),
792+
'the card reports the fingerprint that will actually be stored'
793+
);
794+
// And the card reflects what the fingerprint covers: both ignore the malformed fields.
795+
assert.strictEqual(M.launchFingerprint(weird), M.launchFingerprint({ name: 'x', command: 'c' }));
765796
});
766797

767798
console.log('\nmcpConfig.js: ' + n + ' tests passed.');

0 commit comments

Comments
 (0)