diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 64823a4728..6034644493 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -19,8 +19,24 @@ Inspect native SAZ or HAR 1.2 offline with REA, or connect Everywhere's separate - `analyze_web_bundle` / `rea analyze-web-bundle` parses selected script artifacts without execution and derives chunk edges, route and endpoint candidates, vendor fingerprints, static WebMCP declarations, and optionally source-map/original-source evidence. - `observe_web_session` / `rea observe-web-session` arms an observation window of the requested duration for an external user action and records ordered reload, SPA navigation, redirect, failure, lifecycle, and target-termination metadata. - `discover_webmcp_tools` / `rea discover-webmcp-tools` uses the experimental CDP WebMCP domain to return every in-scope registration and its complete observed `input_schema` declaration and canonical `input_schema_sha256` digest inline. Declarations are untrusted JSON data, including declared types, required fields, enums, examples and reference URLs. REA does not resolve schema references. These fields replace `input_schema_shape`. Registrations with the same name and URL in distinct observed frames retain separate identities. `tool_key` includes the observed frame ID; it is not a stable identity across navigation or browser sessions. Same-frame registration updates replace that owner only, and removal leaves other owners intact. Declared `readOnly`, `untrustedContent`, `consequential`, and `autosubmit` annotation hints are reported per registration: `true`/`false` are declared values, `null` means the declaration did not provide the hint in that capture, and a missing `consequential` key means the hint was not recorded, either in a historical capture or because the observed annotation was invalid. Consult discovery completeness and exclusions to distinguish these cases. Malformed annotation values retain the registration with incomplete coverage and an `invalid_protocol_value` exclusion. Other malformed protocol payloads can fail discovery explicitly. REA never exposes `WebMCP.invokeTool`. + - `compare_web_captures` / `rea compare-web-captures` compares passive captures or exact-step-aligned browser scenarios. Scenario results commit reproducible literal normalization and expose alignment failures plus artifact-level action, screenshot, DOM, accessibility, URL, history, storage, and event differences. Missing or truncated evidence is never treated as equivalence. +For `analyze_web_bundle`, each `webmcp_declarations` entry's +`schema_property_names` is a source-derived projection of statically known own +property keys, not schema validation. `schema_property_names_coverage` reports +how complete that projection is: `complete` means a literal properties object +was fully enumerated (including an empty literal); `partial` means a literal +object was available but some computed or spread membership was unresolved, +and the name array retains keys that were known; `unknown` means the schema or +properties owner could not be enumerated as an object literal, including +explicit non-object values; `not_present` means no effective schema or own +`properties` member was present after the `inputSchema ?? input_schema` alias +selection. `not_present` describes this projection only: it does not validate a +schema or rule out properties expressed through other schema constructs. Older +observations may omit the coverage field; treat omission as `unknown` rather +than inferring completeness from the array. + Passive DOM comparison checks complete node inventories and complete frame parent/child structure independently. Frame IDs and sibling enumeration order are capture-local and do not establish a change; duplicate IDs, missing parents, diff --git a/src/domain/javascript/javascriptAstValues.test.ts b/src/domain/javascript/javascriptAstValues.test.ts index 457cbc3258..67324136fe 100644 --- a/src/domain/javascript/javascriptAstValues.test.ts +++ b/src/domain/javascript/javascriptAstValues.test.ts @@ -4,6 +4,7 @@ import { expect, it } from "vitest"; import { semanticStaticPropertyKey, staticObjectProperty, + staticObjectPropertyNames, } from "./javascriptAstValues.js"; import { SEMANTIC_EXPRESSION_DEPTH_LIMIT } from "./javascriptSemanticResourceLimits.js"; @@ -70,3 +71,188 @@ it("bounds nested inline object spreads without losing a shallow final property" .status, ).toBe("dynamic"); }); + +it("collects complete own-key sets through bounded literal spreads", () => { + const names = staticObjectPropertyNames( + t.objectExpression([ + t.spreadElement( + t.objectExpression([ + t.objectProperty(t.identifier("query"), t.objectExpression([])), + t.objectProperty(t.identifier("shared"), t.numericLiteral(1)), + ]), + ), + t.objectProperty(t.identifier("direct"), t.objectExpression([])), + t.objectProperty(t.identifier("shared"), t.numericLiteral(2)), + ]), + ); + expect(names).toEqual({ + status: "complete", + names: ["query", "shared", "direct"], + }); +}); + +it("keeps key enumeration unknown when object membership is unresolved", () => { + const dynamicSpread = t.identifier("dynamicProperties"); + const computedKey = t.identifier("propertyName"); + const cases = [ + t.objectExpression([ + t.spreadElement(dynamicSpread), + t.objectProperty(t.identifier("direct"), t.objectExpression([])), + ]), + t.objectExpression([ + t.objectProperty(computedKey, t.objectExpression([]), true), + t.objectProperty(t.identifier("direct"), t.objectExpression([])), + ]), + t.objectExpression([ + t.spreadElement( + t.objectExpression([ + t.objectMethod( + "get", + t.identifier("query"), + [], + t.blockStatement([]), + ), + ]), + ), + ]), + ]; + for (const object of cases) + expect(staticObjectPropertyNames(object).status).toBe("partial"); +}); + +it("retains known keys alongside unresolved object membership", () => { + expect( + staticObjectPropertyNames( + t.objectExpression([ + t.spreadElement(t.identifier("dynamicProperties")), + t.objectProperty(t.identifier("direct"), t.objectExpression([])), + t.objectProperty( + t.identifier("propertyName"), + t.objectExpression([]), + true, + ), + ]), + ), + ).toEqual({ status: "partial", names: ["direct"] }); +}); + +it("does not claim a complete key set after exceeding spread depth", () => { + let object = t.objectExpression([ + t.objectProperty(t.identifier("query"), t.objectExpression([])), + ]); + for (let depth = 0; depth < SEMANTIC_EXPRESSION_DEPTH_LIMIT; depth++) + object = t.objectExpression([t.spreadElement(object)]); + expect(staticObjectPropertyNames(object).status).toBe("complete"); + expect( + staticObjectPropertyNames(t.objectExpression([t.spreadElement(object)])) + .status, + ).toBe("partial"); +}); + +it("enumerates a wide inline spread without a variadic call overflow", () => { + const properties = Array.from({ length: 130_000 }, (_, index) => + t.objectProperty(t.identifier(`key${index}`), t.numericLiteral(index)), + ); + const result = staticObjectPropertyNames( + t.objectExpression([t.spreadElement(t.objectExpression(properties))]), + ); + expect(result.status).toBe("complete"); + if (result.status === "complete") { + expect(result.names).toHaveLength(properties.length); + expect(result.names[0]).toBe("key0"); + expect(result.names.at(-1)).toBe("key129999"); + } +}); + +it("excludes prototype setters from own keys but preserves other __proto__ forms", () => { + const setter = t.objectProperty( + t.identifier("__proto__"), + t.objectExpression([ + t.objectProperty(t.identifier("inherited"), t.numericLiteral(1)), + ]), + ); + expect( + staticObjectPropertyNames( + t.objectExpression([ + setter, + t.objectProperty(t.identifier("own"), t.numericLiteral(2)), + ]), + ), + ).toEqual({ status: "complete", names: ["own"] }); + + const ownForms = [ + t.objectProperty(t.stringLiteral("__proto__"), t.numericLiteral(1), true), + t.objectProperty( + t.identifier("__proto__"), + t.identifier("__proto__"), + false, + true, + ), + t.objectMethod( + "method", + t.identifier("__proto__"), + [], + t.blockStatement([]), + ), + ]; + for (const member of ownForms) + expect(staticObjectPropertyNames(t.objectExpression([member]))).toEqual({ + status: "complete", + names: ["__proto__"], + }); +}); + +it("keeps inherited lookup uncertain after a top-level prototype setter", () => { + const setter = t.objectProperty( + t.identifier("__proto__"), + t.objectExpression([ + t.objectProperty(t.identifier("inherited"), t.numericLiteral(1)), + ]), + ); + expect( + staticObjectProperty(t.objectExpression([setter]), "inherited").status, + ).toBe("dynamic"); + expect( + staticObjectProperty( + t.objectExpression([ + setter, + t.objectProperty(t.identifier("inherited"), t.numericLiteral(2)), + ]), + "inherited", + ).status, + ).toBe("explicit"); + expect( + staticObjectProperty( + t.objectExpression([ + t.objectProperty(t.identifier("inherited"), t.numericLiteral(2)), + setter, + ]), + "inherited", + ).status, + ).toBe("explicit"); + const spreadSource = t.objectExpression([ + setter, + t.objectProperty(t.identifier("visible"), t.numericLiteral(3)), + ]); + expect( + staticObjectProperty( + t.objectExpression([t.spreadElement(spreadSource)]), + "inherited", + ).status, + ).toBe("missing"); +}); + +it("omits prototype setters copied by literal object spread", () => { + const source = t.objectExpression([ + t.objectProperty( + t.identifier("__proto__"), + t.objectExpression([ + t.objectProperty(t.identifier("hidden"), t.numericLiteral(1)), + ]), + ), + t.objectProperty(t.identifier("visible"), t.numericLiteral(2)), + ]); + expect( + staticObjectPropertyNames(t.objectExpression([t.spreadElement(source)])), + ).toEqual({ status: "complete", names: ["visible"] }); +}); diff --git a/src/domain/javascript/javascriptAstValues.ts b/src/domain/javascript/javascriptAstValues.ts index f0b971100d..d4b50588b0 100644 --- a/src/domain/javascript/javascriptAstValues.ts +++ b/src/domain/javascript/javascriptAstValues.ts @@ -130,6 +130,79 @@ type StaticObjectProperty = | { readonly status: "missing" | "dynamic" } | { readonly status: "explicit"; readonly property: t.ObjectProperty }; +type StaticObjectMember = + | { readonly kind: "unknown" } + | { readonly kind: "prototype-setter" } + | { + readonly kind: "property"; + readonly property: t.ObjectProperty | t.ObjectMethod; + }; + +const UNKNOWN_STATIC_OBJECT_MEMBER: StaticObjectMember = { kind: "unknown" }; + +const isObjectPrototypeSetter = (property: t.ObjectProperty): boolean => + !property.computed && + !property.shorthand && + semanticStaticPropertyKey(property.key, false) === "__proto__"; + +/** Expand only bounded literal spreads, preserving unknown writes in order. */ +const appendStaticObjectMembers = ( + members: StaticObjectMember[], + object: t.ObjectExpression, + depth: number, + isNullish?: (node: t.Node) => boolean, +): void => { + for (const property of object.properties) { + if (!t.isSpreadElement(property)) { + if (t.isObjectProperty(property) && isObjectPrototypeSetter(property)) { + // A top-level setter changes lookup inheritance but is never an own + // key; one copied through object spread has no effect on the target. + if (depth === 0) members.push({ kind: "prototype-setter" }); + continue; + } + members.push({ kind: "property", property }); + continue; + } + const { node: spread, depth: wrappers } = unwrapJavaScriptExpression( + property.argument, + ); + if (t.isObjectExpression(spread)) { + const nextDepth = depth + wrappers + 1; + // Copying a getter can run code that changes another source key. + if ( + nextDepth > SEMANTIC_EXPRESSION_DEPTH_LIMIT || + spread.properties.some((entry) => + t.isObjectMethod(entry, { kind: "get" }), + ) + ) { + members.push(UNKNOWN_STATIC_OBJECT_MEMBER); + continue; + } + appendStaticObjectMembers(members, spread, nextDepth, isNullish); + continue; + } + // These primitive boxes have no own keys; strings can own index keys. + if ( + t.isBooleanLiteral(spread) || + t.isNumericLiteral(spread) || + t.isBigIntLiteral(spread) || + isNullish?.(property.argument) === true + ) + continue; + members.push(UNKNOWN_STATIC_OBJECT_MEMBER); + } +}; + +const staticObjectMembers = ( + object: t.ObjectExpression, + depth: number, + isNullish?: (node: t.Node) => boolean, +): readonly StaticObjectMember[] => { + const members: StaticObjectMember[] = []; + appendStaticObjectMembers(members, object, depth, isNullish); + return members; +}; + /** Find the effective explicit property, preserving unresolved overrides. */ export const staticObjectProperty = ( object: t.Node | undefined, @@ -138,49 +211,57 @@ export const staticObjectProperty = ( ): StaticObjectProperty => { if (object === undefined) return { status: "missing" }; if (!t.isObjectExpression(object)) return { status: "dynamic" }; - const lookup = ( - current: t.ObjectExpression, - depth: number, - ): StaticObjectProperty => { - for (const property of current.properties.toReversed()) { - if (t.isSpreadElement(property)) { - const { node: spread, depth: wrappers } = unwrapJavaScriptExpression( - property.argument, - ); - if (t.isObjectExpression(spread)) { - const nextDepth = depth + wrappers + 1; - // Copying a getter can run code that changes another source key. - if ( - nextDepth > SEMANTIC_EXPRESSION_DEPTH_LIMIT || - spread.properties.some((entry) => - t.isObjectMethod(entry, { kind: "get" }), - ) - ) - return { status: "dynamic" }; - const found = lookup(spread, nextDepth); - if (found.status === "missing") continue; - return found; - } - // These primitive boxes have no own keys; strings can own index keys. - if ( - t.isBooleanLiteral(spread) || - t.isNumericLiteral(spread) || - t.isBigIntLiteral(spread) || - isNullish?.(property.argument) === true - ) - continue; - return { status: "dynamic" }; - } - const key = semanticStaticPropertyKey(property.key, property.computed); - if (key === null) return { status: "dynamic" }; - if (key !== name) continue; - return t.isObjectProperty(property) - ? { status: "explicit", property } - : { status: "dynamic" }; + let prototypeMaySupply = false; + for (const member of staticObjectMembers(object, 0, isNullish).toReversed()) { + if (member.kind === "prototype-setter") { + prototypeMaySupply = true; + continue; } - return { status: "missing" }; - }; - return lookup(object, 0); + if (member.kind === "unknown") return { status: "dynamic" }; + const { property } = member; + const key = semanticStaticPropertyKey(property.key, property.computed); + if (key === null) return { status: "dynamic" }; + if (key !== name) continue; + return t.isObjectProperty(property) + ? { status: "explicit", property } + : { status: "dynamic" }; + } + return prototypeMaySupply ? { status: "dynamic" } : { status: "missing" }; +}; + +export type StaticObjectPropertyNames = + | { readonly status: "missing" | "dynamic" } + | { readonly status: "partial"; readonly names: readonly string[] } + | { readonly status: "complete"; readonly names: readonly string[] }; + +/** Retain known own names and report unresolved object membership separately. */ +export const staticObjectPropertyNames = ( + object: t.Node | undefined, + isNullish?: (node: t.Node) => boolean, +): StaticObjectPropertyNames => { + if (object === undefined) return { status: "missing" }; + if (!t.isObjectExpression(object)) return { status: "dynamic" }; + const names = new Set(); + let complete = true; + for (const member of staticObjectMembers(object, 0, isNullish)) { + if (member.kind === "prototype-setter") continue; + if (member.kind === "unknown") { + complete = false; + continue; + } + const key = semanticStaticPropertyKey( + member.property.key, + member.property.computed, + ); + if (key === null) { + complete = false; + continue; + } + names.add(key); + } + return complete + ? { status: "complete", names: [...names] } + : { status: "partial", names: [...names] }; }; /** Later properties own a key; an intervening dynamic write leaves it unknown. */ diff --git a/src/domain/webBundleAnalysis.ts b/src/domain/webBundleAnalysis.ts index 4eef3cf949..d25625b92e 100644 --- a/src/domain/webBundleAnalysis.ts +++ b/src/domain/webBundleAnalysis.ts @@ -220,7 +220,17 @@ export const webBundleAnalysisSchema = z.object({ z.object({ name: z.string().nullable(), description: z.string().nullable(), - schema_property_names: z.array(z.string()), + schema_property_names: z + .array(z.string()) + .describe( + "Statically known own property names from the declared schema projection; a partial list may omit unresolved members.", + ), + schema_property_names_coverage: z + .enum(["complete", "partial", "unknown", "not_present"]) + .optional() + .describe( + "Coverage of the schema property-name projection. Missing means unknown for historical observations. Not a schema-validity result.", + ), trust: z.literal("page-declared-untrusted"), location: sourceLocationSchema, }), diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index c1ad169ff7..f7af053d30 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -158,6 +158,232 @@ it("discloses widened returned-reference effects only for the affected script", ).toEqual(["/candidate"]); }); +it.each([ + { + label: "inline declaration and schema spreads", + source: `document.modelContext.registerTool({ + ...{ + name: "spread-tool", + description: "Copied from a literal", + inputSchema: { + ...{ + type: "object", + properties: { query: { type: "string" } } + } + } + } + });`, + name: "spread-tool", + description: "Copied from a literal", + properties: ["query"], + coverage: "complete", + }, + { + label: "unrelated trailing literal spread", + source: `document.modelContext.registerTool({ + name: "direct-tool", + description: "Explicit metadata", + inputSchema: { properties: { query: { type: "string" } } }, + ...{ timeout: 5 } + });`, + name: "direct-tool", + description: "Explicit metadata", + properties: ["query"], + coverage: "complete", + }, + { + label: "null spread after explicit metadata", + source: `document.modelContext.registerTool({ + name: "null-safe-tool", + description: "Null spread is inert", + inputSchema: { properties: { query: { type: "string" } } }, + ...null + });`, + name: "null-safe-tool", + description: "Null spread is inert", + properties: ["query"], + coverage: "complete", + }, + { + label: "snake-case schema property from a spread", + source: `document.modelContext.registerTool({ + ...{ + name: "snake-tool", + description: "Snake-case schema", + input_schema: { properties: { item_id: { type: "string" } } } + } + });`, + name: "snake-tool", + description: "Snake-case schema", + properties: ["item_id"], + coverage: "complete", + }, +])( + "preserves WebMCP facts through $label", + ({ source, name, description, properties, coverage }) => { + const result = analyzeCapturedWebBundle(inspection(source)); + + expect(result.observations.webmcp_declarations).toEqual([ + expect.objectContaining({ + name, + description, + schema_property_names: properties, + schema_property_names_coverage: coverage, + trust: "page-declared-untrusted", + }), + ]); + expect(result.completeness.status).toBe("complete"); + }, +); + +it.each([ + { + label: "unknown spread after explicit facts", + source: `document.modelContext.registerTool({ + name: "possibly-overridden", + description: "Possibly overridden", + inputSchema: { properties: { query: { type: "string" } } }, + ...dynamicOptions + });`, + name: null, + description: null, + properties: [], + coverage: "unknown", + }, + { + label: "explicit facts after an unknown spread", + source: `document.modelContext.registerTool({ + ...dynamicOptions, + name: "known-after-spread", + description: "Explicitly restored", + inputSchema: { properties: { query: { type: "string" } } } + });`, + name: "known-after-spread", + description: "Explicitly restored", + properties: ["query"], + coverage: "complete", + }, +])( + "preserves rightmost uncertainty for $label", + ({ source, name, description, properties, coverage }) => { + const result = analyzeCapturedWebBundle(inspection(source)); + + expect(result.observations.webmcp_declarations).toEqual([ + expect.objectContaining({ + name, + description, + schema_property_names: properties, + schema_property_names_coverage: coverage, + trust: "page-declared-untrusted", + }), + ]); + expect(result.completeness.status).toBe("complete"); + }, +); + +it.each([ + { + label: "an unknown camel-case schema override", + source: `document.modelContext.registerTool({ + ...dynamicOptions, + inputSchema: { properties: { camel: {} } }, + ...anotherDynamicOptions, + input_schema: { properties: { snake: {} } }, + name: "tool", + description: "Explicit metadata" + });`, + name: "tool", + properties: [], + coverage: "unknown", + }, + { + label: "an explicit null camel-case schema", + source: `document.modelContext.registerTool({ + name: "null-alias", + description: "Fallback applies", + inputSchema: null, + input_schema: { properties: { snake: {} } } + });`, + name: "null-alias", + properties: ["snake"], + coverage: "complete", + }, + { + label: "literal property spreads", + source: `document.modelContext.registerTool({ + name: "spread-properties", + inputSchema: { properties: { ...{ query: {} }, direct: {} } } + });`, + name: "spread-properties", + properties: ["direct", "query"], + coverage: "complete", + }, + { + label: "prototype setters are not schema properties", + source: `document.modelContext.registerTool({ + name: "prototype-properties", + inputSchema: { properties: { __proto__: { hidden: {} }, direct: {}, ["__proto__"]: {} } } + });`, + name: "prototype-properties", + properties: ["__proto__", "direct"], + coverage: "complete", + }, + { + label: "own schema properties before a prototype setter", + source: `document.modelContext.registerTool({ + name: "own-before-prototype", + inputSchema: { properties: { known: {} }, __proto__: { properties: { inherited: {} } } } + });`, + name: "own-before-prototype", + properties: ["known"], + coverage: "complete", + }, + { + label: "own schema properties after a prototype setter", + source: `document.modelContext.registerTool({ + name: "own-after-prototype", + inputSchema: { __proto__: { properties: { inherited: {} } }, properties: { known: {} } } + });`, + name: "own-after-prototype", + properties: ["known"], + coverage: "complete", + }, + { + label: "an unresolved property spread", + source: `document.modelContext.registerTool({ + name: "unknown-properties", + inputSchema: { properties: { ...dynamicProperties, direct: {} } } + });`, + name: "unknown-properties", + properties: ["direct"], + coverage: "partial", + }, + { + label: "an unresolved computed property key", + source: `document.modelContext.registerTool({ + name: "computed-properties", + inputSchema: { properties: { [propertyName]: {}, direct: {} } } + });`, + name: "computed-properties", + properties: ["direct"], + coverage: "partial", + }, +])( + "preserves WebMCP schema ownership for $label", + ({ source, name, properties, coverage }) => { + const result = analyzeCapturedWebBundle(inspection(source)); + + expect(result.observations.webmcp_declarations).toEqual([ + expect.objectContaining({ + name, + schema_property_names: properties, + schema_property_names_coverage: coverage, + trust: "page-declared-untrusted", + }), + ]); + }, +); + describe("web bundle analyzer", () => { it("extracts graph, route, endpoint, vendor, and WebMCP evidence", () => { const source = ` @@ -206,6 +432,7 @@ describe("web bundle analyzer", () => { name: "lookup-user", trust: "page-declared-untrusted", schema_property_names: ["userId", "verbose"], + schema_property_names_coverage: "complete", }), ]); expect(result.inferences).toEqual( @@ -266,6 +493,7 @@ describe("web bundle analyzer", () => { name, description, schema_property_names: [propertyName], + schema_property_names_coverage: "complete", trust: "page-declared-untrusted", }); }); @@ -1481,10 +1709,24 @@ describe("web metadata key semantics", () => { expect.objectContaining({ name: "new", schema_property_names: ["literal"], + schema_property_names_coverage: "partial", + }), + expect.objectContaining({ + name: null, + schema_property_names_coverage: "unknown", + }), + expect.objectContaining({ + name: null, + schema_property_names_coverage: "not_present", + }), + expect.objectContaining({ + name: "restored", + schema_property_names_coverage: "unknown", + }), + expect.objectContaining({ + name: "valid", + schema_property_names_coverage: "not_present", }), - expect.objectContaining({ name: null }), - expect.objectContaining({ name: "restored" }), - expect.objectContaining({ name: "valid" }), ]); }); @@ -1514,20 +1756,86 @@ describe("web metadata key semantics", () => { expect( result.observations.webmcp_declarations.map( - ({ name, schema_property_names }) => ({ name, schema_property_names }), + ({ name, schema_property_names, schema_property_names_coverage }) => ({ + name, + schema_property_names, + schema_property_names_coverage, + }), ), ).toEqual([ - { name: "literal", schema_property_names: ["", "properties", "type"] }, - { name: "missing properties", schema_property_names: [] }, - { name: "dynamic properties", schema_property_names: [] }, - { name: "null properties", schema_property_names: [] }, - { name: "missing schema", schema_property_names: [] }, - { name: "dynamic schema", schema_property_names: [] }, - { name: "null schema", schema_property_names: [] }, - { name: "spread schema", schema_property_names: [] }, - { name: "computed literal keys", schema_property_names: ["", "literal"] }, + { + name: "literal", + schema_property_names: ["", "properties", "type"], + schema_property_names_coverage: "partial", + }, + { + name: "missing properties", + schema_property_names: [], + schema_property_names_coverage: "not_present", + }, + { + name: "dynamic properties", + schema_property_names: [], + schema_property_names_coverage: "unknown", + }, + { + name: "null properties", + schema_property_names: [], + schema_property_names_coverage: "unknown", + }, + { + name: "missing schema", + schema_property_names: [], + schema_property_names_coverage: "not_present", + }, + { + name: "dynamic schema", + schema_property_names: [], + schema_property_names_coverage: "unknown", + }, + { + name: "null schema", + schema_property_names: [], + schema_property_names_coverage: "not_present", + }, + { + name: "spread schema", + schema_property_names: [], + schema_property_names_coverage: "unknown", + }, + { + name: "computed literal keys", + schema_property_names: ["", "literal"], + schema_property_names_coverage: "partial", + }, ]); }); + + it("keeps missing coverage absent when parsing historical observations", () => { + const analyzed = analyzeCapturedWebBundle( + inspection( + `document.modelContext.registerTool({ name: "legacy", inputSchema: { properties: {} } });`, + ), + ); + const declaration = analyzed.observations.webmcp_declarations[0]; + expect(declaration).toBeDefined(); + if (declaration === undefined) throw new Error("Expected declaration"); + const { schema_property_names_coverage: _coverage, ...legacyDeclaration } = + declaration; + const parsed = webBundleAnalysisSchema.parse({ + ...analyzed, + observations: { + ...analyzed.observations, + webmcp_declarations: [legacyDeclaration], + }, + }); + expect( + Object.hasOwn( + parsed.observations.webmcp_declarations[0] ?? {}, + "schema_property_names_coverage", + ), + ).toBe(false); + }); }); it("retains WebMCP declarations through optional member and call chaining", () => { @@ -1695,6 +2003,8 @@ it("retains distinct WebMCP descriptions and names while deduplicating equivalen document.modelContext.registerTool({ name: "lookup", description: "second", inputSchema: { properties: { alpha: {} } } }); document.modelContext.registerTool({ name: "equivalent", inputSchema: { properties: { alpha: {}, beta: {} } } }); document.modelContext.registerTool({ name: "equivalent", inputSchema: { properties: { beta: {}, alpha: {}, alpha: {} } } }); + document.modelContext.registerTool({ name: "empty-status", inputSchema: { properties: {} } }); + document.modelContext.registerTool({ name: "empty-status", inputSchema: { properties: dynamicProperties } }); document.modelContext.registerTool({ description: "unnamed" }); document.modelContext.registerTool({ name: "", description: "unnamed" }); `), @@ -1702,10 +2012,16 @@ it("retains distinct WebMCP descriptions and names while deduplicating equivalen expect( result.observations.webmcp_declarations.map( - ({ name, description, schema_property_names }) => ({ + ({ name, description, schema_property_names, + schema_property_names_coverage, + }) => ({ + name, + description, + schema_property_names, + schema_property_names_coverage, }), ), ).toEqual([ @@ -1713,19 +2029,44 @@ it("retains distinct WebMCP descriptions and names while deduplicating equivalen name: "lookup", description: "first", schema_property_names: ["alpha"], + schema_property_names_coverage: "complete", }, { name: "lookup", description: "second", schema_property_names: ["alpha"], + schema_property_names_coverage: "complete", }, { name: "equivalent", description: null, schema_property_names: ["alpha", "beta"], + schema_property_names_coverage: "complete", + }, + { + name: "empty-status", + description: null, + schema_property_names: [], + schema_property_names_coverage: "complete", + }, + { + name: "empty-status", + description: null, + schema_property_names: [], + schema_property_names_coverage: "unknown", + }, + { + name: null, + description: "unnamed", + schema_property_names: [], + schema_property_names_coverage: "not_present", + }, + { + name: "", + description: "unnamed", + schema_property_names: [], + schema_property_names_coverage: "not_present", }, - { name: null, description: "unnamed", schema_property_names: [] }, - { name: "", description: "unnamed", schema_property_names: [] }, ]); expect(result.observations.webmcp_declarations[2]?.location).toEqual({ script_key: `scr_${"1".repeat(64)}`, diff --git a/src/domain/webBundleAnalyzerAst.ts b/src/domain/webBundleAnalyzerAst.ts index ce98e1b959..f9972a27f5 100644 --- a/src/domain/webBundleAnalyzerAst.ts +++ b/src/domain/webBundleAnalyzerAst.ts @@ -2,28 +2,18 @@ import * as t from "@babel/types"; import { sanitizeBrowserUrl } from "./browserObservation.js"; import { + isNullishJavaScriptSyntax, readExactJavaScriptLiteral, - semanticStaticPropertyName, + staticObjectProperty, } from "./javascript/javascriptAstValues.js"; -/** Pick the endpoint argument for common network-call patterns. */ - +/** Read a statically known own property value from an object expression. */ export const objectValue = ( object: t.ObjectExpression, name: string, ): t.ObjectProperty["value"] | undefined => { - let value: t.ObjectProperty["value"] | undefined; - for (const property of object.properties) { - if (t.isSpreadElement(property)) { - value = undefined; - continue; - } - const key = semanticStaticPropertyName(property.key, property.computed); - if (key === "" && !t.isStringLiteral(property.key)) value = undefined; - else if (key === name) - value = t.isObjectProperty(property) ? property.value : undefined; - } - return value; + const result = staticObjectProperty(object, name, isNullishJavaScriptSyntax); + return result.status === "explicit" ? result.property.value : undefined; }; export const objectString = ( diff --git a/src/domain/webBundleAnalyzerInspection.ts b/src/domain/webBundleAnalyzerInspection.ts index 696a049f8f..a9a36b78c0 100644 --- a/src/domain/webBundleAnalyzerInspection.ts +++ b/src/domain/webBundleAnalyzerInspection.ts @@ -12,6 +12,9 @@ import { isIntrinsicMapLookup } from "./javascript/javascriptSemanticMapOperatio import { semanticStaticPropertyKey, semanticStaticPropertyName, + isNullishJavaScriptSyntax, + staticObjectProperty, + staticObjectPropertyNames, unwrapJavaScriptExpression, } from "./javascript/javascriptAstValues.js"; import { @@ -23,7 +26,6 @@ import { location, locationFields, objectString, - objectValue, resolveSpecifier, isUrlLikeModuleSpecifier, } from "./webBundleAnalyzerAst.js"; @@ -365,23 +367,60 @@ const addWebMcpDeclaration = ( if (!t.isObjectExpression(declaration)) return; const name = objectString(declaration, "name"); const description = objectString(declaration, "description"); - const schema = - objectValue(declaration, "inputSchema") ?? - objectValue(declaration, "input_schema"); - const properties = t.isObjectExpression(schema) - ? objectValue(schema, "properties") - : undefined; - const schemaPropertyNames = t.isObjectExpression(properties) - ? properties.properties.flatMap((property) => { - if (!t.isObjectProperty(property)) return []; - const key = semanticStaticPropertyKey(property.key, property.computed); - return key === null ? [] : [key]; - }) - : []; + const camelSchema = staticObjectProperty( + declaration, + "inputSchema", + isNullishJavaScriptSyntax, + ); + const schemaProperty = + camelSchema.status === "missing" || + (camelSchema.status === "explicit" && + isNullishJavaScriptSyntax(camelSchema.property.value)) + ? staticObjectProperty( + declaration, + "input_schema", + isNullishJavaScriptSyntax, + ) + : camelSchema; + let schemaPropertyNames: readonly string[] = []; + let schemaPropertyNamesCoverage: NonNullable< + WebMcpDeclaration["schema_property_names_coverage"] + > = "unknown"; + if (schemaProperty.status === "missing") { + schemaPropertyNamesCoverage = "not_present"; + } else if (schemaProperty.status === "explicit") { + const schema = schemaProperty.property.value; + if (t.isObjectExpression(schema)) { + const propertiesProperty = staticObjectProperty( + schema, + "properties", + isNullishJavaScriptSyntax, + ); + if (propertiesProperty.status === "missing") { + schemaPropertyNamesCoverage = "not_present"; + } else if (propertiesProperty.status === "explicit") { + const properties = propertiesProperty.property.value; + if (t.isObjectExpression(properties)) { + const projection = staticObjectPropertyNames( + properties, + isNullishJavaScriptSyntax, + ); + if ( + projection.status === "complete" || + projection.status === "partial" + ) { + schemaPropertyNames = projection.names; + schemaPropertyNamesCoverage = projection.status; + } + } + } + } + } const metadata = { name: name ?? null, description: description ?? null, schema_property_names: [...new Set(schemaPropertyNames)].sort(), + schema_property_names_coverage: schemaPropertyNamesCoverage, }; // Deduplicate the metadata we return, preserving omission and descriptions. // Property names are a variable-length source-derived list: NUL-joins diff --git a/tests/acceptance/browser/webBundleSchemaCoverageCliMcp.test.ts b/tests/acceptance/browser/webBundleSchemaCoverageCliMcp.test.ts new file mode 100644 index 0000000000..657fa19f15 --- /dev/null +++ b/tests/acceptance/browser/webBundleSchemaCoverageCliMcp.test.ts @@ -0,0 +1,133 @@ +import { expect } from "vitest"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const capturedSource = ` +const dynamic = globalThis.options; +document.modelContext.registerTool({ + name: "spread", + inputSchema: { properties: { ...{ query: {} }, direct: {} } } +}); +document.modelContext.registerTool({ + name: "partial", + inputSchema: { properties: { known: {}, ...dynamic } } +}); +document.modelContext.registerTool({ + name: "empty-status", description: "same", + inputSchema: { properties: dynamic } +}); +document.modelContext.registerTool({ + name: "empty-status", description: "same", + inputSchema: { properties: {} } +}); +document.modelContext.registerTool({ + name: "own-after-prototype-setter", + inputSchema: { + properties: { own: {} }, + __proto__: { properties: { inherited: {} } } + } +}); +`; + +const inspectDeclarations = (document: unknown) => { + const parsed = + toolContract("analyze_web_bundle").outputSchema.parse(document); + const parsedEvidence = parseEvidence(parsed); + return { + evidence: parsedEvidence, + declarations: parsed.normalized_result.observations.webmcp_declarations, + }; +}; + +const expectCoverageCases = ( + declarations: ReturnType["declarations"], +) => { + expect(declarations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "spread", + schema_property_names: ["direct", "query"], + schema_property_names_coverage: "complete", + }), + expect.objectContaining({ + name: "partial", + schema_property_names: ["known"], + schema_property_names_coverage: "partial", + }), + expect.objectContaining({ + name: "empty-status", + description: "same", + schema_property_names: [], + schema_property_names_coverage: "unknown", + }), + expect.objectContaining({ + name: "empty-status", + description: "same", + schema_property_names: [], + schema_property_names_coverage: "complete", + }), + expect.objectContaining({ + name: "own-after-prototype-setter", + schema_property_names: ["own"], + schema_property_names_coverage: "complete", + }), + ]), + ); + expect( + declarations.filter(({ name }) => name === "empty-status"), + ).toHaveLength(2); +}; + +cliTest( + "preserves WebMCP schema-name coverage through compiled CLI and stdio MCP", + async ({ cli }) => { + const browser = await startFakeCdpBrowser({ + commandResult: ({ method }) => + method === "Debugger.getScriptSource" + ? { scriptSource: capturedSource } + : undefined, + }); + try { + const contract = toolContract("analyze_web_bundle"); + const cliReply = await cli.run({ + arguments: [ + "analyze-web-bundle", + browser.endpoint, + "allowed-page", + "--allowed-origins", + browser.allowedOrigin, + "--observation-ms", + "0", + "--json", + ], + }); + expect(cliReply.exitCode, cliReply.stdout + cliReply.stderr).toBe(0); + const cliAnalysis = inspectDeclarations(cliReply.json); + expectCoverageCases(cliAnalysis.declarations); + + const { call } = await connectLocalToolsMcp(); + const input = { + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + include_script_sources: true, + fetch_source_maps: false, + }; + const response = await call(contract.name, input); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const mcpAnalysis = inspectDeclarations(response.structuredContent); + expectCoverageCases(mcpAnalysis.declarations); + expect(mcpAnalysis.evidence.operation).toBe("analyze_web_bundle"); + expect(mcpAnalysis.evidence.authority).toBe("external-service"); + expect(mcpAnalysis.declarations).toEqual(cliAnalysis.declarations); + } finally { + await browser.close(); + } + }, + 120_000, +);