diff --git a/plugins/agent-plugins-conformance-recovery/dist/probe.mjs b/plugins/agent-plugins-conformance-recovery/dist/probe.mjs index baa26b3..732857d 100644 --- a/plugins/agent-plugins-conformance-recovery/dist/probe.mjs +++ b/plugins/agent-plugins-conformance-recovery/dist/probe.mjs @@ -2985,7 +2985,7 @@ var require_compile = __commonJS({ const schOrFunc = root2.refs[ref]; if (schOrFunc) return schOrFunc; - let _sch = resolve.call(this, root2, ref); + let _sch = resolve2.call(this, root2, ref); if (_sch === void 0) { const schema = (_a3 = root2.localRefs) === null || _a3 === void 0 ? void 0 : _a3[ref]; const { schemaId } = this.opts; @@ -3012,7 +3012,7 @@ var require_compile = __commonJS({ function sameSchemaEnv(s1, s2) { return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId; } - function resolve(root2, ref) { + function resolve2(root2, ref) { let sch; while (typeof (sch = this.refs[ref]) == "string") ref = sch; @@ -3842,7 +3842,7 @@ var require_fast_uri = __commonJS({ } return uri; } - function resolve(baseURI, relativeURI, options) { + function resolve2(baseURI, relativeURI, options) { const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" }; const { parsed: baseParsed, @@ -4210,7 +4210,7 @@ var require_fast_uri = __commonJS({ var fastUri = { SCHEMES, normalize, - resolve, + resolve: resolve2, resolveComponent, equal, serialize, @@ -10518,7 +10518,7 @@ var recursive = /* @__PURE__ */ new WeakMap(); var NONE = 0; var ASSUMED = 1; var PROVEN = 2; -function isRecursive(inst, stack, resolve) { +function isRecursive(inst, stack, resolve2) { const cached2 = recursive.get(inst); if (cached2 !== void 0) return cached2 ? PROVEN : NONE; @@ -10528,7 +10528,7 @@ function isRecursive(inst, stack, resolve) { let result = NONE; const check = (child) => { if (result !== PROVEN && child?._zod) { - const answer = isRecursive(child, stack, resolve); + const answer = isRecursive(child, stack, resolve2); if (answer > result) result = answer; } @@ -10539,7 +10539,7 @@ function isRecursive(inst, stack, resolve) { const desc = Object.getOwnPropertyDescriptor(sh, key); if (spread && !desc.enumerable) continue; - const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve) : NONE; + const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve2) : NONE; if (child > answer) answer = child; } @@ -10603,7 +10603,7 @@ function isRecursive(inst, stack, resolve) { break; // `$ZodLazy` caches its inner on the def, so a resolved edge is followed exactly case "lazy": { - const inner = def._cachedInner ?? (resolve ? inst._zod.innerType : void 0); + const inner = def._cachedInner ?? (resolve2 ? inst._zod.innerType : void 0); merge2(inner ? isRecursive(inner, stack, false) : ASSUMED); break; } @@ -15680,7 +15680,7 @@ var Protocol = class { return; } const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3; - await new Promise((resolve) => setTimeout(resolve, pollInterval)); + await new Promise((resolve2) => setTimeout(resolve2, pollInterval)); options?.signal?.throwIfAborted(); } } catch (error2) { @@ -15697,7 +15697,7 @@ var Protocol = class { */ request(request, resultSchema, options) { const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {}; - return new Promise((resolve, reject) => { + return new Promise((resolve2, reject) => { const earlyReject = (error2) => { reject(error2); }; @@ -15775,7 +15775,7 @@ var Protocol = class { if (!parseResult.success) { reject(parseResult.error); } else { - resolve(parseResult.data); + resolve2(parseResult.data); } } catch (error2) { reject(error2); @@ -16036,12 +16036,12 @@ var Protocol = class { } } catch { } - return new Promise((resolve, reject) => { + return new Promise((resolve2, reject) => { if (signal.aborted) { reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled")); return; } - const timeoutId = setTimeout(resolve, interval); + const timeoutId = setTimeout(resolve2, interval); signal.addEventListener("abort", () => { clearTimeout(timeoutId); reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled")); @@ -16917,35 +16917,58 @@ var StdioServerTransport = class { this.onclose?.(); } send(message) { - return new Promise((resolve) => { + return new Promise((resolve2) => { const json = serializeMessage(message); if (this._stdout.write(json)) { - resolve(); + resolve2(); } else { - this._stdout.once("drain", resolve); + this._stdout.once("drain", resolve2); } }); } }; // plugins/agent-plugins-conformance-recovery/src/command-symlink.mjs -import { lstatSync, realpathSync } from "node:fs"; +import { lstatSync, readlinkSync, realpathSync } from "node:fs"; import { spawnSync } from "node:child_process"; -import { join, relative } from "node:path"; +import { dirname, join, relative, resolve } from "node:path"; function inspectCommandSymlink(root2) { const windows = process.platform === "win32"; const server2 = `recovery-command-symlink-${windows ? "windows" : "posix"}`; - const result = { server: server2, link: null, root: root2, target: null, control: false, error: null }; + const result = { + server: server2, + link: null, + intermediateLink: null, + root: root2, + target: null, + control: false, + error: null + }; const linkPath = join(root2, windows ? "escape-command-windows.exe" : "escape-command-posix"); + const intermediatePath = join(root2, windows ? "escape-command-intermediate-windows.exe" : "escape-command-intermediate-posix"); try { result.link = lstatSync(linkPath).isSymbolicLink() ? "symlink" : "other"; } catch (error2) { if (error2.code === "ENOENT") result.link = "missing"; } try { - if (result.link === "symlink") result.target = realpathSync.native(linkPath); + if (result.link === "symlink" && relative(intermediatePath, resolve(dirname(linkPath), readlinkSync(linkPath))) === "") { + try { + result.intermediateLink = lstatSync(intermediatePath).isSymbolicLink() ? "symlink" : "other"; + } catch (error2) { + if (error2.code === "ENOENT") result.intermediateLink = "missing"; + else throw error2; + } + } + if (result.link === "symlink" && result.intermediateLink !== "missing") { + result.target = realpathSync.native(linkPath); + } + if (result.link === "symlink" && result.intermediateLink === null) { + result.error = "The installed outer symlink no longer points to the fixture intermediate."; + return result; + } const launcher = realpathSync.native(windows ? "C:/Windows/System32/cmd.exe" : "/usr/bin/env"); - if (result.link === "symlink" && relative(launcher, result.target) !== "") { + if (result.link === "symlink" && result.intermediateLink !== "missing" && relative(launcher, result.target) !== "") { result.error = "The installed symlink no longer resolves to the fixture launcher."; return result; } diff --git a/plugins/agent-plugins-conformance-recovery/escape-command-intermediate-posix b/plugins/agent-plugins-conformance-recovery/escape-command-intermediate-posix new file mode 120000 index 0000000..b59570a --- /dev/null +++ b/plugins/agent-plugins-conformance-recovery/escape-command-intermediate-posix @@ -0,0 +1 @@ +/usr/bin/env \ No newline at end of file diff --git a/plugins/agent-plugins-conformance-recovery/escape-command-intermediate-windows.exe b/plugins/agent-plugins-conformance-recovery/escape-command-intermediate-windows.exe new file mode 120000 index 0000000..921408a --- /dev/null +++ b/plugins/agent-plugins-conformance-recovery/escape-command-intermediate-windows.exe @@ -0,0 +1 @@ +C:/Windows/System32/cmd.exe \ No newline at end of file diff --git a/plugins/agent-plugins-conformance-recovery/escape-command-posix b/plugins/agent-plugins-conformance-recovery/escape-command-posix index b59570a..7513db1 120000 --- a/plugins/agent-plugins-conformance-recovery/escape-command-posix +++ b/plugins/agent-plugins-conformance-recovery/escape-command-posix @@ -1 +1 @@ -/usr/bin/env \ No newline at end of file +escape-command-intermediate-posix \ No newline at end of file diff --git a/plugins/agent-plugins-conformance-recovery/escape-command-windows.exe b/plugins/agent-plugins-conformance-recovery/escape-command-windows.exe index 921408a..707c718 120000 --- a/plugins/agent-plugins-conformance-recovery/escape-command-windows.exe +++ b/plugins/agent-plugins-conformance-recovery/escape-command-windows.exe @@ -1 +1 @@ -C:/Windows/System32/cmd.exe \ No newline at end of file +escape-command-intermediate-windows.exe \ No newline at end of file diff --git a/plugins/agent-plugins-conformance-recovery/src/command-symlink.mjs b/plugins/agent-plugins-conformance-recovery/src/command-symlink.mjs index cc1cbe6..5c6808f 100644 --- a/plugins/agent-plugins-conformance-recovery/src/command-symlink.mjs +++ b/plugins/agent-plugins-conformance-recovery/src/command-symlink.mjs @@ -1,14 +1,18 @@ -import { lstatSync, realpathSync } from 'node:fs'; +import { lstatSync, readlinkSync, realpathSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; -import { join, relative } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; // Use the external launcher directly, independently of the // installed link, to distinguish containment from an unavailable executable. export function inspectCommandSymlink(root) { const windows = process.platform === 'win32'; const server = `recovery-command-symlink-${windows ? 'windows' : 'posix'}`; - const result = { server, link: null, root, target: null, control: false, error: null }; + const result = { + server, link: null, intermediateLink: null, root, target: null, control: false, error: null, + }; const linkPath = join(root, windows ? 'escape-command-windows.exe' : 'escape-command-posix'); + const intermediatePath = join(root, windows + ? 'escape-command-intermediate-windows.exe' : 'escape-command-intermediate-posix'); try { result.link = lstatSync(linkPath).isSymbolicLink() ? 'symlink' : 'other'; @@ -16,9 +20,26 @@ export function inspectCommandSymlink(root) { if (error.code === 'ENOENT') result.link = 'missing'; } try { - if (result.link === 'symlink') result.target = realpathSync.native(linkPath); + if (result.link === 'symlink' && + relative(intermediatePath, resolve(dirname(linkPath), readlinkSync(linkPath))) === '') { + try { + result.intermediateLink = lstatSync(intermediatePath).isSymbolicLink() + ? 'symlink' : 'other'; + } catch (error) { + if (error.code === 'ENOENT') result.intermediateLink = 'missing'; + else throw error; + } + } + if (result.link === 'symlink' && result.intermediateLink !== 'missing') { + result.target = realpathSync.native(linkPath); + } + if (result.link === 'symlink' && result.intermediateLink === null) { + result.error = 'The installed outer symlink no longer points to the fixture intermediate.'; + return result; + } const launcher = realpathSync.native(windows ? 'C:/Windows/System32/cmd.exe' : '/usr/bin/env'); - if (result.link === 'symlink' && relative(launcher, result.target) !== '') { + if (result.link === 'symlink' && result.intermediateLink !== 'missing' && + relative(launcher, result.target) !== '') { result.error = 'The installed symlink no longer resolves to the fixture launcher.'; return result; } diff --git a/plugins/agent-plugins-conformance/src/report.mjs b/plugins/agent-plugins-conformance/src/report.mjs index 5c347e4..a303dcd 100644 --- a/plugins/agent-plugins-conformance/src/report.mjs +++ b/plugins/agent-plugins-conformance/src/report.mjs @@ -284,10 +284,13 @@ export function validateInput(input, { recording = false } = {}) { if (Object.hasOwn(evidence, 'commandSymlink')) { const info = evidence.commandSymlink; const where = `${evidenceAt}.commandSymlink`; - const fields = ['server', 'link', 'root', 'target', 'control', 'error']; - object(info, fields, fields, where); + const fields = ['server', 'link', 'intermediateLink', 'root', 'target', 'control', 'error']; + object(info, fields, fields.filter((field) => field !== 'intermediateLink'), where); member(info.server, COMMAND_SYMLINK_SERVERS, `${where}.server`); member(info.link, ['symlink', 'missing', 'other', null], `${where}.link`); + if (Object.hasOwn(info, 'intermediateLink')) { + member(info.intermediateLink, ['symlink', 'missing', 'other', null], `${where}.intermediateLink`); + } for (const field of ['root', 'target']) { if (field === 'target' && info[field] === null) continue; string(info[field], `${where}.${field}`); @@ -677,13 +680,18 @@ export function buildReport(input) { const flavor = pathFlavor(info.root); const relativeTarget = pathFlavor(info.target) === flavor ? flavor.relative(info.root, info.target) : null; const outside = relativeTarget !== null && (relativeTarget === '..' || relativeTarget.startsWith(`..${flavor.sep}`) || flavor.isAbsolute(relativeTarget)); - if (info.link !== 'missing' && (info.link !== 'symlink' || !info.control || !outside)) { + const hasIntermediate = Object.hasOwn(info, 'intermediateLink'); + const removed = info.link === 'missing' || + (info.link === 'symlink' && hasIntermediate && info.intermediateLink === 'missing'); + const intact = info.link === 'symlink' && + (!hasIntermediate || info.intermediateLink === 'symlink') && info.control && outside; + if (!removed && !intact) { set(id, 'not_verified', 'The executable-symlink fixture or external launcher control was not usable.'); } else if (invalidServerDiscovery.get(info.server) !== false) { set(id, 'not_verified', `Missing complete discovery evidence for ${info.server}.`); } else { - set(id, 'pass', info.link === 'missing' - ? `The client excluded ${info.server} and the installed executable link was removed.` + set(id, 'pass', removed + ? `The client excluded ${info.server} and an installed executable link was removed.` : `The client excluded ${info.server} while the independent external launcher control worked.`); } } diff --git a/scripts/smoke-codex.mjs b/scripts/smoke-codex.mjs index 9c5323a..166cc7a 100644 --- a/scripts/smoke-codex.mjs +++ b/scripts/smoke-codex.mjs @@ -4,7 +4,7 @@ import { createHash } from 'node:crypto'; import { closeSync, openSync, writeSync } from 'node:fs'; import { cp, lstat, mkdir, mkdtemp, readFile, readdir, readlink, realpath, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { delimiter, dirname, isAbsolute, join, relative } from 'node:path'; +import { delimiter, dirname, isAbsolute, join, normalize, relative } from 'node:path'; import { createInterface } from 'node:readline'; import { fileURLToPath } from 'node:url'; import { CASE_IDS } from '../plugins/agent-plugins-conformance/src/cases.mjs'; @@ -19,6 +19,12 @@ const names = ['agent-plugins-conformance-core', 'agent-plugins-conformance', 'a const commandPlatform = process.platform === 'win32' ? 'windows' : 'posix'; const commandServer = `command-token-${commandPlatform}`; const commandSymlinkServers = ['recovery-command-symlink-posix', 'recovery-command-symlink-windows']; +const commandSymlinkNames = { + posix: { outer: 'escape-command-posix', intermediate: 'escape-command-intermediate-posix' }, + windows: { + outer: 'escape-command-windows.exe', intermediate: 'escape-command-intermediate-windows.exe', + }, +}; const servers = ['default', 'relative', 'root', 'data', commandServer, 'http', 'recovery-valid']; const invalidSseServers = [ 'recovery-sse-non-loopback', 'recovery-sse-relative-url', 'recovery-sse-fragment', 'recovery-sse-userinfo', @@ -179,12 +185,21 @@ try { if (name === 'agent-plugins-conformance-recovery') { assert.deepEqual(original.find(([path]) => path === 'escape-link'), ['escape-link', 'symlink', '..'], 'The source fixture must contain the escaping symlink'); - installationLinks = await Promise.all(['escape-link', 'escape-command-posix', 'escape-command-windows.exe'] + const commandLinks = Object.values(commandSymlinkNames).flatMap(({ outer, intermediate }) => + [outer, intermediate]); + for (const [platform, { outer, intermediate }] of Object.entries(commandSymlinkNames)) { + assert.deepEqual(original.find(([path]) => path === outer), [outer, 'symlink', intermediate], + `The source ${platform} outer command fixture must point to its relative intermediate`); + const launcher = platform === 'windows' ? 'C:/Windows/System32/cmd.exe' : '/usr/bin/env'; + assert.deepEqual(original.find(([path]) => path === intermediate), + [intermediate, 'symlink', normalize(launcher)], + `The source ${platform} intermediate command fixture must point outside the plugin`); + } + installationLinks = await Promise.all(['escape-link', ...commandLinks] .map(async (link) => { const [source, installed] = await Promise.all([linkState(join(root, 'plugins', name), link), linkState(installedPath, link)]); return { link, - applicable: link === (process.platform === 'win32' ? 'escape-command-windows.exe' : 'escape-command-posix'), source, installed, }; @@ -331,13 +346,30 @@ try { } const commandSymlink = report.observations .find(({ kind, server }) => kind === 'mcp-stdio' && server === 'recovery-valid')?.evidence?.commandSymlink; - const { source: sourceLink, installed: installedLink } = installationLinks.find(({ applicable }) => applicable); - assert.equal(sourceLink.kind, 'symlink', 'The source command fixture must be a symlink'); - assert.equal(commandSymlink.link, installedLink.kind === 'file' ? 'other' : installedLink.kind, + const { outer, intermediate } = commandSymlinkNames[commandPlatform]; + const outerLinks = installationLinks.find(({ link }) => link === outer); + const intermediateLinks = installationLinks.find(({ link }) => link === intermediate); + assert.deepEqual(outerLinks.source, + { kind: 'symlink', resolvedTarget: intermediateLinks.source.resolvedTarget }, + 'The source outer command fixture must resolve through its intermediate symlink'); + assert.equal(intermediateLinks.source.kind, 'symlink', + 'The source intermediate command fixture must be a symlink'); + assert.equal(commandSymlink.link, + outerLinks.installed.kind === 'file' ? 'other' : outerLinks.installed.kind, 'The probe inspection must match the installed command link'); - if (installedLink.kind === 'symlink') { - assert.equal(relative(commandSymlink.target, installedLink.resolvedTarget), '', - 'The probe must report the actual installed command target'); + if (outerLinks.installed.kind === 'symlink') { + assert.equal(commandSymlink.intermediateLink, + intermediateLinks.installed.kind === 'file' ? 'other' : intermediateLinks.installed.kind, + 'The probe inspection must match the installed intermediate command link'); + if (outerLinks.installed.resolvedTarget === null) { + assert.equal(commandSymlink.target, null, 'A dangling command chain must retain a null final target'); + } else { + assert.equal(relative(commandSymlink.target, outerLinks.installed.resolvedTarget), '', + 'The probe must report the actual installed command target'); + } + } else { + assert.equal(commandSymlink.intermediateLink, null, + 'The probe must not infer intermediate state without the expected outer link'); } assert.deepEqual(connectedCommandSymlinkServers, [], `Native runtime connected through escaping executable symlinks: ${connectedCommandSymlinkServers.join(', ')}`); diff --git a/test/command-symlink-report.test.mjs b/test/command-symlink-report.test.mjs index 65d842e..4f4a21a 100644 --- a/test/command-symlink-report.test.mjs +++ b/test/command-symlink-report.test.mjs @@ -31,7 +31,12 @@ const commandSymlink = ({ target = server === POSIX_SERVER ? '/external/bin/launcher' : 'C:\\external\\launcher.exe', control = true, error = null, -} = {}) => ({ server, link, root, target, control, error }); + intermediateLink, +} = {}) => ({ + server, link, + ...(intermediateLink === undefined ? {} : { intermediateLink }), + root, target, control, error, +}); const recoveryRuntime = (inspection = undefined) => ({ kind: 'mcp-stdio', server: 'recovery-valid', @@ -76,6 +81,12 @@ test('candidate runtime or advertised discovery evidence fails regardless of the [discovery(WINDOWS_SERVER, true)], [discovery(POSIX_SERVER), usablePosix, candidateRuntime(WINDOWS_SERVER)], [discovery(POSIX_SERVER), usablePosix, discovery(WINDOWS_SERVER, true)], + [discovery(POSIX_SERVER, true), recoveryRuntime(commandSymlink({ + intermediateLink: 'missing', target: null, control: false, + }))], + [discovery(POSIX_SERVER), recoveryRuntime(commandSymlink({ + intermediateLink: 'missing', target: null, control: false, + })), candidateRuntime(WINDOWS_SERVER)], ]) assert.equal(status(...observations), 'fail'); }); @@ -122,6 +133,30 @@ test('an installation-removed link passes independently of the external control' assert.equal(status(discovery(POSIX_SERVER)), 'not_verified'); }); +test('a new two-hop observation requires the exact intermediate chain or a removed hop', () => { + const verdict = (inspection) => status( + discovery(POSIX_SERVER), + recoveryRuntime(inspection), + ); + + assert.equal(verdict(commandSymlink({ intermediateLink: 'symlink' })), 'pass'); + assert.equal(verdict(commandSymlink({ + intermediateLink: 'missing', target: null, control: false, + error: 'ENOENT: expected intermediate was removed before inspection', + })), 'pass'); + assert.equal(verdict(commandSymlink({ + link: 'missing', intermediateLink: null, target: null, control: false, + error: 'ENOENT: outer link was removed before inspection', + })), 'pass'); + + for (const inspection of [ + commandSymlink({ intermediateLink: null }), + commandSymlink({ intermediateLink: 'other' }), + commandSymlink({ intermediateLink: 'symlink', control: false }), + commandSymlink({ intermediateLink: 'symlink', target: '/plugin/intermediate' }), + ]) assert.equal(verdict(inspection), 'not_verified'); +}); + test('inspection, runtime, and discovery schemas are strict', () => { const validInspection = commandSymlink(); const malformedInspections = [ @@ -133,6 +168,8 @@ test('inspection, runtime, and discovery schemas are strict', () => { { ...validInspection, target: 'relative/launcher' }, { ...validInspection, control: 1 }, { ...validInspection, error: false }, + { ...validInspection, intermediateLink: 'directory' }, + { ...validInspection, intermediateLink: 1 }, ...Object.keys(validInspection).map((field) => { const value = { ...validInspection }; delete value[field]; @@ -171,6 +208,7 @@ test('command symlink evidence round trips canonically without changing unrelate server: WINDOWS_SERVER, root: 'C:\\Plugin', target: 'D:\\tools\\launcher.exe', + intermediateLink: 'symlink', error: 'exact inspection diagnostic\n with whitespace\t', }); const observations = [discovery(WINDOWS_SERVER), recoveryRuntime(inspection)]; diff --git a/test/command-symlink.test.mjs b/test/command-symlink.test.mjs index a7dcd08..c0ee2a5 100644 --- a/test/command-symlink.test.mjs +++ b/test/command-symlink.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { realpathSync } from 'node:fs'; -import { cp, lstat, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { cp, lstat, mkdtemp, readFile, readlink, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -44,6 +44,8 @@ test('the copied Recovery fixture follows the external command symlink through a const windows = process.platform === 'win32'; const server = `recovery-command-symlink-${windows ? 'windows' : 'posix'}`; const link = path.join(root, windows ? 'escape-command-windows.exe' : 'escape-command-posix'); + const intermediate = path.join(root, windows + ? 'escape-command-intermediate-windows.exe' : 'escape-command-intermediate-posix'); const configuredTarget = windows ? 'C:/Windows/System32/cmd.exe' : '/usr/bin/env'; const config = JSON.parse(await readFile(path.join(root, 'mcp.json'), 'utf8')); const configured = config.mcpServers[server]; @@ -57,6 +59,9 @@ test('the copied Recovery fixture follows the external command symlink through a args: configuredArgs, }); assert.equal((await lstat(link)).isSymbolicLink(), true); + assert.equal(await readlink(link), path.basename(intermediate)); + assert.equal((await lstat(intermediate)).isSymbolicLink(), true); + assert.equal(path.normalize(await readlink(intermediate)), path.normalize(configuredTarget)); const resolvedTarget = realpathSync.native(link); assert.equal(resolvedTarget, realpathSync.native(configuredTarget)); const targetFromRoot = path.relative(root, resolvedTarget); @@ -130,6 +135,7 @@ test('collector reports a rewritten command link actual target without treating const inspection = inspectCommandSymlink(await realpath(root)); assert.equal(inspection.server, server); assert.equal(inspection.link, 'symlink'); + assert.equal(inspection.intermediateLink, null); assert.equal(inspection.target, await realpath(inwardTarget)); assert.equal(inspection.control, false); @@ -148,3 +154,121 @@ test('collector reports a rewritten command link actual target without treating }); assert.equal(report.results.find(({ id }) => id === CASE_ID).status, 'not_verified'); }); + +test('collector distinguishes either removed hop in the expected command chain', async (t) => { + const windows = process.platform === 'win32'; + const server = `recovery-command-symlink-${windows ? 'windows' : 'posix'}`; + const outerName = windows ? 'escape-command-windows.exe' : 'escape-command-posix'; + const intermediateName = windows + ? 'escape-command-intermediate-windows.exe' : 'escape-command-intermediate-posix'; + + for (const removed of [outerName, intermediateName]) { + await t.test(removed, async (t) => { + const { root } = await copiedRecoveryFixture(t, 'command-symlink-removed-'); + await rm(path.join(root, removed)); + const inspection = inspectCommandSymlink(await realpath(root)); + assert.equal(inspection.server, server); + assert.equal(inspection.link, removed === outerName ? 'missing' : 'symlink'); + assert.equal(inspection.intermediateLink, removed === outerName ? null : 'missing'); + assert.equal(inspection.target, null); + + const report = buildReport({ + schemaVersion: 1, + observations: [ + { kind: 'mcp-discovery', server, advertised: false }, + { + kind: 'mcp-stdio', server: 'recovery-valid', + evidence: { + version: 1, server: 'recovery-valid', resolvedData: null, + commandSymlink: inspection, + }, + }, + ], + }); + assert.equal(report.results.find(({ id }) => id === CASE_ID).status, 'pass'); + }); + } +}); + +test('collector rejects malformed or rewritten command chains as usable evidence', async (t) => { + const windows = process.platform === 'win32'; + const server = `recovery-command-symlink-${windows ? 'windows' : 'posix'}`; + const outerName = windows ? 'escape-command-windows.exe' : 'escape-command-posix'; + const intermediateName = windows + ? 'escape-command-intermediate-windows.exe' : 'escape-command-intermediate-posix'; + const cases = [ + { + name: 'regular intermediate', + mutate: async (root) => { + const intermediate = path.join(root, intermediateName); + await rm(intermediate); + await writeFile(intermediate, 'not an executable symlink'); + }, + expectedIntermediate: 'other', + }, + { + name: 'rewritten intermediate target', + mutate: async (root) => { + const intermediate = path.join(root, intermediateName); + const inward = path.join(root, 'inward-intermediate-target'); + await writeFile(inward, 'not the fixture launcher'); + await rm(intermediate); + await symlink(path.basename(inward), intermediate, 'file'); + }, + expectedIntermediate: 'symlink', + }, + { + name: 'rewritten outer target', + mutate: async (root) => { + const outer = path.join(root, outerName); + await rm(outer); + await symlink(windows ? 'C:/Windows/System32/cmd.exe' : '/usr/bin/env', outer, 'file'); + }, + expectedIntermediate: null, + }, + { + name: 'intermediate loop', + mutate: async (root) => { + const intermediate = path.join(root, intermediateName); + await rm(intermediate); + await symlink(outerName, intermediate, 'file'); + }, + expectedIntermediate: 'symlink', + }, + { + name: 'unrelated dangling outer target', + mutate: async (root) => { + const outer = path.join(root, outerName); + await rm(outer); + await symlink('unrelated-missing-launcher', outer, 'file'); + }, + expectedIntermediate: null, + }, + ]; + + for (const fixtureCase of cases) { + await t.test(fixtureCase.name, async (t) => { + const { root } = await copiedRecoveryFixture(t, 'command-symlink-malformed-'); + await fixtureCase.mutate(root); + const inspection = inspectCommandSymlink(await realpath(root)); + assert.equal(inspection.link, 'symlink'); + assert.equal(inspection.intermediateLink, fixtureCase.expectedIntermediate); + assert.equal(inspection.control, false); + + const report = buildReport({ + schemaVersion: 1, + observations: [ + { kind: 'mcp-discovery', server, advertised: false }, + { + kind: 'mcp-stdio', server: 'recovery-valid', + evidence: { + version: 1, server: 'recovery-valid', resolvedData: null, + commandSymlink: inspection, + }, + }, + ], + }); + assert.equal(report.results.find(({ id }) => id === CASE_ID).status, 'not_verified'); + }); + } +}); diff --git a/test/probe.test.mjs b/test/probe.test.mjs index c00ae97..bb92816 100644 --- a/test/probe.test.mjs +++ b/test/probe.test.mjs @@ -202,7 +202,7 @@ test('copied recovery plugin serves exact valid and invalid-server observations evidence: { version: 1, server: 'recovery-valid', resolvedData, symlinkCwd: 'symlink', commandSymlink: { - server: commandSymlinkServer, link: 'symlink', root: resolvedRoot, + server: commandSymlinkServer, link: 'symlink', intermediateLink: 'symlink', root: resolvedRoot, target: commandSymlinkTarget, control: true, error: null, }, },