fix(browser): match exact WebMCP receiver segments

Inspect AST property segments instead of flattened suffixes. Reject dotted-key and suffix collisions while retaining page-declared trust and source evidence through the CLI.

Refs: #4205, #4279

Co-authored-by: Geekwolf <geekwolf@163.com>
This commit is contained in:
morluto
2026-10-16 08:14:51 +08:00
parent b3389a5b08
commit 38f92d108e
3 changed files with 124 additions and 4 deletions
+21
View File
@@ -1161,6 +1161,27 @@ it("retains direct and literal computed call names", () => {
]);
});
it("requires WebMCP receiver and method names to occupy exact member segments", () => {
const result = analyzeCapturedWebBundle(
inspection(`
navigator.modelContext.registerTool({ name: "navigator" });
local.modelContext.registerTool({ name: "exact nested" });
const appmodelContext = { registerTool() {} };
appmodelContext.registerTool({ name: "receiver suffix" });
const app = { analyticsmodelContext: { registerTool() {} } };
app.analyticsmodelContext.registerTool({ name: "nested suffix" });
window["modelContext.registerTool"]({ name: "dotted property" });
window["modelContext"].registerTool({ name: "computed receiver" });
`),
);
expect(result.observations.webmcp_declarations).toEqual([
expect.objectContaining({ name: "navigator" }),
expect.objectContaining({ name: "exact nested" }),
expect.objectContaining({ name: "computed receiver" }),
]);
});
it("recognizes tRPC procedures and named GraphQL operations in captured scripts", () => {
const result = analyzeCapturedWebBundle(
inspection(`
+23 -4
View File
@@ -11,6 +11,7 @@ import type { JavaScriptOpenReceiverFact } from "./javascript/javascriptSemantic
import {
semanticStaticPropertyKey,
semanticStaticPropertyName,
unwrapJavaScriptExpression,
} from "./javascript/javascriptAstValues.js";
import {
calleeName,
@@ -208,13 +209,31 @@ const inspectCall = (
node,
accumulator,
});
if (
name.endsWith("modelContext.registerTool") ||
name === "modelContext.registerTool"
)
if (isWebMcpRegistrationCallee(node.callee))
addWebMcpDeclaration(script, node, accumulator);
};
/** Match exact WebMCP receiver and method properties without flattening keys. */
const isWebMcpRegistrationCallee = (callee: t.Node): boolean => {
const method = unwrapJavaScriptExpression(callee).node;
if (!t.isMemberExpression(method) && !t.isOptionalMemberExpression(method))
return false;
if (
semanticStaticPropertyKey(method.property, method.computed) !==
"registerTool"
)
return false;
const receiver = unwrapJavaScriptExpression(method.object).node;
if (t.isIdentifier(receiver, { name: "modelContext" })) return true;
return (
(t.isMemberExpression(receiver) ||
t.isOptionalMemberExpression(receiver)) &&
semanticStaticPropertyKey(receiver.property, receiver.computed) ===
"modelContext"
);
};
const inspectRouteProperty = (
script: IncludedScript,
node: t.ObjectProperty,
@@ -0,0 +1,80 @@
import { expect } from "vitest";
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest(
"keeps WebMCP receiver segments exact through the public CLI",
async ({ cli }) => {
const source = `
navigator.modelContext.registerTool({ name: "navigator" });
const app = { analyticsmodelContext: { registerTool() {} } };
app.analyticsmodelContext.registerTool({ name: "nested suffix" });
window["modelContext.registerTool"]({ name: "dotted property" });
`;
const browser = await startFakeCdpBrowser({
commandResult: ({ method }) =>
method === "Debugger.getScriptSource"
? { scriptSource: source }
: undefined,
});
try {
const result = await cli.run({
arguments: [
"analyze-web-bundle",
browser.endpoint,
"allowed-page",
"--allowed-origins",
browser.allowedOrigin,
"--observation-ms",
"0",
"--json",
],
});
expect(result.exitCode, result.stdout).toBe(0);
expect(result.json).toMatchObject({
normalized_result: {
observations: {
webmcp_declarations: [
expect.objectContaining({
name: "navigator",
trust: "page-declared-untrusted",
}),
],
},
},
});
const inspected = await cli.run({
arguments: [
"inspect-web-page",
browser.endpoint,
"allowed-page",
"--allowed-origins",
browser.allowedOrigin,
"--observation-ms",
"0",
"--include-script-sources",
"--json",
],
});
expect(inspected.exitCode, inspected.stdout).toBe(0);
expect(inspected.json).toMatchObject({
normalized_result: {
scripts: {
items: expect.arrayContaining([
expect.objectContaining({
source: expect.objectContaining({
artifact: expect.objectContaining({ text: source }),
}),
}),
]),
},
},
});
expect(browser.activeConnectionCount()).toBe(0);
} finally {
await browser.close();
}
},
);