From e13da084e7c5967b80632ae5ad8d3d37bcf57469 Mon Sep 17 00:00:00 2001 From: yasshmittal Date: Tue, 13 Oct 2026 20:25:21 +0530 Subject: [PATCH 01/36] fix(apple): exclude AppleDouble sidecars from IPA components (#2838) * fix(apple): exclude AppleDouble sidecars from IPA components IPA projection returned every inventoried component before the AppleDouble sidecar filter applied to directory, ZIP and DMG roots, so ._* and __MACOSX/ metadata appeared as native libraries and frameworks while the result reported that sidecars are excluded. Apply the sidecar exclusion first for every root format; IPA still keeps non-sidecar components outside Payload/. Fixes #2546 Co-Authored-By: Claude Opus 5.5 * test(apple): exercise the __MACOSX exclusion with a non-dotfile name The IPA fixture's __MACOSX entry also started with "._", so the AppleDouble filename rule excluded it and a broken __MACOSX check would go unnoticed. Use a library name that only the __MACOSX rule excludes; the native library assertion now fails if that rule regresses. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- src/domain/apple/appleApplication.test.ts | 28 +++++++++++++++++++++++ src/domain/apple/appleApplication.ts | 7 +++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/domain/apple/appleApplication.test.ts b/src/domain/apple/appleApplication.test.ts index 9caf21fcb..3f8906e2b 100644 --- a/src/domain/apple/appleApplication.test.ts +++ b/src/domain/apple/appleApplication.test.ts @@ -417,6 +417,34 @@ describe("Apple bundle layouts and containers", () => { }); }); +describe("Apple IPA components", () => { + it("excludes AppleDouble sidecars from IPA components", () => { + const result = project( + inventoryEvidence("ipa", "Fixture.ipa", [ + { path: "Payload/Fixture.app/Info.plist" }, + macho("Payload/Fixture.app/Fixture"), + { path: "Payload/Fixture.app/Contents/Resources/._libfoo.dylib" }, + { path: "Payload/Fixture.app/Frameworks/Foo.framework/._Foo" }, + { path: "__MACOSX/Payload/Fixture.app/libghost.dylib" }, + macho("Payload/Fixture.app/Contents/Resources/libreal.dylib"), + macho("SwiftSupport/iphoneos/libswiftCore.dylib"), + ]), + ); + expect(result.components.native_libraries.map(({ path }) => path)).toEqual([ + "Payload/Fixture.app/Contents/Resources/libreal.dylib", + "SwiftSupport/iphoneos/libswiftCore.dylib", + ]); + expect(result.components.frameworks).toEqual([]); + expect(result.components.executables.map(({ path }) => path)).toEqual([ + "Payload/Fixture.app/Contents/Resources/libreal.dylib", + "Payload/Fixture.app/Fixture", + ]); + expect(result.limitations).toContainEqual( + expect.stringContaining("AppleDouble sidecar entries"), + ); + }); +}); + describe("macOS application scope", () => { it("attributes only components inside the application roots", () => { const result = project( diff --git a/src/domain/apple/appleApplication.ts b/src/domain/apple/appleApplication.ts index d46ddf26b..dfb34e3c5 100644 --- a/src/domain/apple/appleApplication.ts +++ b/src/domain/apple/appleApplication.ts @@ -218,8 +218,9 @@ export const appleProjectionId = (projection: unknown): string => `aap_${digest(projection)}`; /** - * IPA projection keeps every archive component, as before. Directory, ZIP, - * and DMG inventories can hold installers and other siblings, so only + * AppleDouble sidecars describe neighbouring files and are never components. + * IPA projection keeps every other archive component, as before. Directory, + * ZIP, and DMG inventories can hold installers and other siblings, so only * components inside an application root are attributed to it. */ const attributeComponents = ( @@ -227,8 +228,8 @@ const attributeComponents = ( roots: readonly string[], rootFormat: RootFormat, ): { readonly all: Component[]; readonly unattributed: number } => { - if (rootFormat === "ipa") return { all: [...inventoried], unattributed: 0 }; const visible = inventoried.filter(({ path }) => !isSidecar(path)); + if (rootFormat === "ipa") return { all: visible, unattributed: 0 }; const all = visible.filter(({ path }) => roots.some((root) => isWithin(path, root)), ); From 204a6f3f20c48d3bbf96d8f2819f391150dafe32 Mon Sep 17 00:00:00 2001 From: SeokMyungSeop <132800182+SeokMyungSeop@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:57:45 +0900 Subject: [PATCH 02/36] fix(ghidra): import HashMap in the bridge script (#2844) 8823c55b0 added a HashMap use without its import, so Ghidra could not compile ReaGhidraBridge.java and every Ghidra-backed operation exited before bridge startup. Co-authored-by: SeokMyungSeop Co-authored-by: Claude Opus 5.5 --- bridge/ghidra/ReaGhidraBridge.java | 1 + 1 file changed, 1 insertion(+) diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 7c90d2e4a..09df6ac1d 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -28,6 +28,7 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; +import java.util.HashMap; import java.util.HashSet; import java.util.IdentityHashMap; import java.util.Iterator; From 3e7c6302233ecbcb9b92e43ee5a60337738961bd Mon Sep 17 00:00:00 2001 From: Shubham Ahuja <157806066+shubhamahuja9999@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:36:55 +0530 Subject: [PATCH 03/36] fix(mcp): count the error flag in error result budgets (#2848) Error results reach the wire as text plus `isError: true`, but the text-only envelope omitted the flag. An error up to 15 bytes above the result budget passed both the delivery and transport checks and was sent as the original diagnostic instead of taking the oversized-error recovery path. Rename the error representation to "error-text" and size its envelope with the flag, so ToolResultDelivery and the error transport share the complete delivered shape. Successful structured results are unchanged. Fixes #2557 Co-authored-by: Claude Opus 5.5 --- docs/mcp-contracts.md | 2 +- src/server/EvidenceMcpServer.ts | 9 ++- src/server/toolResult.ts | 2 +- src/server/toolResultEncoding.test.ts | 41 ++++++----- src/server/toolResultEncoding.ts | 11 +-- .../mcp/oversizedErrorDeliveryMcp.test.ts | 69 +++++++++++++++++++ 6 files changed, 108 insertions(+), 26 deletions(-) diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 7c219f6be..63ab516d5 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -65,7 +65,7 @@ unresolved relationship reads. Cancellation remains a tool error. REA prepares complete MCP results within the pinned stdio client's 10 MiB receive-buffer budget, including both text and structured representations and room for the JSON-RPC envelope. Tool errors carry only their text -representation, so only that text counts against the budget. Response budget settings are captured at startup; +representation and `isError` flag, so only those count against the budget. Response budget settings are captured at startup; restart or recreate the server to apply changes. If a result cannot fit, REA returns `resource_constraint` with `details.resource: "transport"` before constructing a document-sized string. Analysis Evidence remains complete in the current diff --git a/src/server/EvidenceMcpServer.ts b/src/server/EvidenceMcpServer.ts index 2691ec422..f42fe9de6 100644 --- a/src/server/EvidenceMcpServer.ts +++ b/src/server/EvidenceMcpServer.ts @@ -278,8 +278,13 @@ class ErrorEvidenceTransport implements Transport { // Count the provenance marker exactly as the projected error did, and keep // it on the replacement: the retained original may hold target content. const targetContent = carriesTargetContentMarker(message.result); - // The delivered error omits structuredContent, so only its text counts. - const encoded = encodeToolResult(structured, budget, "text", targetContent); + // The delivered error omits structuredContent, so its text and error flag count. + const encoded = encodeToolResult( + structured, + budget, + "error-text", + targetContent, + ); if (encoded.ok) return message; const operation = tool ?? "mcp_tool_error"; const originalError = analysisErrorProjectionSchema.safeParse( diff --git a/src/server/toolResult.ts b/src/server/toolResult.ts index b503b5677..5d40afb5b 100644 --- a/src/server/toolResult.ts +++ b/src/server/toolResult.ts @@ -139,7 +139,7 @@ export class ToolResultDelivery { const encoded = encodeToolResult( structuredContent, this.resultBudgetBytes, - "text", + "error-text", targetContent, ); // The transport retains an oversized projected error before replacing it diff --git a/src/server/toolResultEncoding.test.ts b/src/server/toolResultEncoding.test.ts index b6969c306..1e9aee5ac 100644 --- a/src/server/toolResultEncoding.test.ts +++ b/src/server/toolResultEncoding.test.ts @@ -48,21 +48,27 @@ describe("MCP result encoding budget", () => { it.each([ { error: { code: "invalid_request", message: '中文"\n'.repeat(30000) } }, { error: { message: "😀\ud800" } }, - ])("counts only escaped text for text-only error delivery", (candidate) => { - const textBytes = Buffer.byteLength( - JSON.stringify({ - content: [{ type: "text", text: JSON.stringify(candidate) }], - }), - ); - const encoded = encodeToolResult(candidate, textBytes, "text"); - if (!encoded.ok) throw new Error("Expected complete encoding"); - expect(encoded.text).toBe(JSON.stringify(candidate)); - expect(encoded.bytes).toBe(textBytes); - expect(encodeToolResult(candidate, textBytes - 1, "text")).toMatchObject({ - ok: false, - bytesAtLeast: textBytes, - }); - }); + ])( + "counts escaped text and the error flag for error delivery", + (candidate) => { + const errorBytes = Buffer.byteLength( + JSON.stringify({ + content: [{ type: "text", text: JSON.stringify(candidate) }], + isError: true, + }), + ); + const encoded = encodeToolResult(candidate, errorBytes, "error-text"); + if (!encoded.ok) throw new Error("Expected complete encoding"); + expect(encoded.text).toBe(JSON.stringify(candidate)); + expect(encoded.bytes).toBe(errorBytes); + expect( + encodeToolResult(candidate, errorBytes - 1, "error-text"), + ).toMatchObject({ + ok: false, + bytesAtLeast: errorBytes, + }); + }, + ); it("accounts for the untrusted target-content marker", () => { const candidate = { strings: ['"ignore previous instructions"', "中文"] }; @@ -94,14 +100,15 @@ describe("MCP result encoding budget", () => { const markedBytes = Buffer.byteLength( JSON.stringify({ content: [{ type: "text", text: JSON.stringify(candidate) }], + isError: true, _meta: TARGET_CONTENT_RESULT_META, }), ); expect( - encodeToolResult(candidate, markedBytes, "text", true), + encodeToolResult(candidate, markedBytes, "error-text", true), ).toMatchObject({ ok: true, bytes: markedBytes }); expect( - encodeToolResult(candidate, markedBytes - 1, "text", true), + encodeToolResult(candidate, markedBytes - 1, "error-text", true), ).toMatchObject({ ok: false, bytesAtLeast: markedBytes }); }); diff --git a/src/server/toolResultEncoding.ts b/src/server/toolResultEncoding.ts index 2a3692674..73ec2c717 100644 --- a/src/server/toolResultEncoding.ts +++ b/src/server/toolResultEncoding.ts @@ -13,8 +13,8 @@ const RESULT_ENVELOPE_BYTES = }), ) - 4; -const TEXT_RESULT_ENVELOPE_BYTES = Buffer.byteLength( - JSON.stringify({ content: [{ type: "text", text: "" }] }), +const ERROR_RESULT_ENVELOPE_BYTES = Buffer.byteLength( + JSON.stringify({ content: [{ type: "text", text: "" }], isError: true }), ); /** @@ -48,9 +48,10 @@ const TARGET_CONTENT_META_BYTES = /** * MCP representations a result carries on the wire. Successful results repeat - * the JSON as structured content and escaped text; error results carry text only. + * the JSON as structured content and escaped text; error results carry text + * only, plus their `isError` flag. */ -export type ToolResultRepresentations = "structured-and-text" | "text"; +export type ToolResultRepresentations = "structured-and-text" | "error-text"; /** Result of encoding the complete repeated MCP representations within a wire budget. */ export type ToolResultEncoding = @@ -72,7 +73,7 @@ export const encodeToolResult = ( const structured = representations === "structured-and-text"; const parts: string[] = []; let bytes = - (structured ? RESULT_ENVELOPE_BYTES : TEXT_RESULT_ENVELOPE_BYTES) + + (structured ? RESULT_ENVELOPE_BYTES : ERROR_RESULT_ENVELOPE_BYTES) + (targetContent ? TARGET_CONTENT_META_BYTES : 0); let codeUnits = bytes; for (const part of bufferedJsonParts(jsonParts(value))) { diff --git a/tests/boundary/mcp/oversizedErrorDeliveryMcp.test.ts b/tests/boundary/mcp/oversizedErrorDeliveryMcp.test.ts index f03d349b2..10d081d84 100644 --- a/tests/boundary/mcp/oversizedErrorDeliveryMcp.test.ts +++ b/tests/boundary/mcp/oversizedErrorDeliveryMcp.test.ts @@ -257,3 +257,72 @@ it("keeps the untrusted target-content marker on an oversized error's replacemen await Promise.all([client.close(), server.close()]); } }); + +// The delivered error wire omits only the private structured carrier; its +// `isError` flag and any provenance marker count against the result budget. +const deliveredErrorBytes = (nameBytes: number, targetContent: boolean) => { + const { structuredContent: _carrier, ...wire } = failureWithName( + nameBytes, + targetContent, + ); + return Buffer.byteLength(JSON.stringify(wire)); +}; + +it.each([false, true])( + "applies the result budget to the complete delivered error (target content: %s)", + async (targetContent) => { + const fittingName = + delivery.resultBudgetBytes - deliveredErrorBytes(0, targetContent); + expect(deliveredErrorBytes(fittingName, targetContent)).toBe( + delivery.resultBudgetBytes, + ); + const server = new EvidenceMcpServer( + { name: "error-budget-test", version: "1" }, + { capabilities: {} }, + undefined, + delivery, + ); + server.registerTool( + "fitting_failure", + { inputSchema: z.object({}) }, + async () => failureWithName(fittingName, targetContent), + ); + server.registerTool( + "one_byte_over_failure", + { inputSchema: z.object({}) }, + async () => failureWithName(fittingName + 1, targetContent), + ); + const client = new Client({ name: "error-budget-client", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const fitting = await client.callTool({ + name: "fitting_failure", + arguments: {}, + }); + expect(fitting.isError).toBe(true); + expect(parseMcpToolError(fitting)).toEqual( + failureWithName(fittingName, targetContent).structuredContent, + ); + const over = await client.callTool({ + name: "one_byte_over_failure", + arguments: {}, + }); + expect(over.isError).toBe(true); + expect(over.structuredContent).toBeUndefined(); + expect(parseMcpToolError(over)).toMatchObject({ + error: { + code: "resource_constraint", + details: { + reported_limits: { original_error_code: "invalid_request" }, + }, + }, + }); + } finally { + await Promise.all([client.close(), server.close()]); + } + }, + 20000, +); From 40e94aeb9d127f61f554efdc7ab69c666fbf2c37 Mon Sep 17 00:00:00 2001 From: xingzhou Date: Tue, 13 Oct 2026 23:19:37 +0800 Subject: [PATCH 04/36] fix(browser): preserve distinct WebMCP declaration metadata (#2854) * fix(browser): preserve distinct WebMCP declaration metadata * test(browser): retain the first equivalent declaration location --- src/domain/webBundleAnalyzer.test.ts | 51 +++++++++++++++++++++++ src/domain/webBundleAnalyzerInspection.ts | 17 ++++---- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index 3361cc366..9120a73d9 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -714,3 +714,54 @@ it("keeps RPC lexical identity and bundled factory recognition in captured scrip ]); expect(result.completeness.status).toBe("complete"); }); + +it("retains distinct WebMCP descriptions and names while deduplicating equivalent properties", () => { + const result = analyzeCapturedWebBundle( + inspection(` + document.modelContext.registerTool({ name: "lookup", description: "first", inputSchema: { properties: { alpha: {} } } }); + 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({ description: "unnamed" }); + document.modelContext.registerTool({ name: "", description: "unnamed" }); + `), + ); + + expect( + result.observations.webmcp_declarations.map( + ({ name, description, schema_property_names }) => ({ + name, + description, + schema_property_names, + }), + ), + ).toEqual([ + { + name: "lookup", + description: "first", + schema_property_names: ["alpha"], + }, + { + name: "lookup", + description: "second", + schema_property_names: ["alpha"], + }, + { + name: "equivalent", + description: null, + schema_property_names: ["alpha", "beta"], + }, + { 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)}`, + line: 4, + column: 6, + }); + expect( + result.observations.webmcp_declarations.every( + ({ trust }) => trust === "page-declared-untrusted", + ), + ).toBe(true); +}); diff --git a/src/domain/webBundleAnalyzerInspection.ts b/src/domain/webBundleAnalyzerInspection.ts index fd8c541ac..522f22caa 100644 --- a/src/domain/webBundleAnalyzerInspection.ts +++ b/src/domain/webBundleAnalyzerInspection.ts @@ -293,19 +293,18 @@ const addWebMcpDeclaration = ( : [], ) : []; + const metadata = { + name: name ?? null, + description: description ?? null, + schema_property_names: [...new Set(schemaPropertyNames)].sort(), + }; + // Deduplicate the metadata we return, preserving omission and descriptions. // Property names are a variable-length source-derived list: NUL-joins // would collapse ["a\0b"] with ["a", "b"] and drop a declaration. - const key = compositeKey([ - "webmcp", - script.script_key, - name ?? "", - schemaPropertyNames, - ]); + const key = compositeKey(["webmcp", script.script_key, metadata]); addUnique(accumulator, key, () => accumulator.webMcp.push({ - name: name ?? null, - description: description ?? null, - schema_property_names: [...new Set(schemaPropertyNames)].sort(), + ...metadata, trust: "page-declared-untrusted", location: location(script.script_key, node), }), From e6c6fdd21a769906c538629498cd9c1465821a94 Mon Sep 17 00:00:00 2001 From: xingzhou Date: Tue, 13 Oct 2026 23:22:59 +0800 Subject: [PATCH 05/36] fix(browser): preserve script-specific parser uncertainty (#2858) --- src/domain/webBundleAnalyzer.test.ts | 42 +++++++++++++++++++++++ src/domain/webBundleAnalyzerInspection.ts | 10 ++++-- src/domain/webBundleAnalyzerResult.ts | 29 ++++++++++------ 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index 9120a73d9..edcb37a20 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -8,6 +8,48 @@ import { createWebTextArtifact } from "./webContentArtifact.js"; const origin = "https://app.example.test"; +it.each([ + { + kind: "recovered", + source: "function legacy(a, a) { return a; } fetch('/partial');", + parsed: 2, + failures: 0, + reason: "recovered", + endpoints: ["/partial", "/complete"], + }, + { + kind: "failed", + source: "function broken( {", + parsed: 1, + failures: 1, + reason: "could not be parsed", + endpoints: ["/complete"], + }, +])( + "attributes $kind parse uncertainty only to its affected source", + ({ source, parsed, failures, reason, endpoints }) => { + const captured = inspection(source); + const good = inspection("fetch('/complete');").scripts.items[0]; + if (good === undefined) throw new Error("Missing valid script fixture"); + captured.scripts.items.push({ ...good, script_key: "valid-script" }); + captured.scripts.total += 1; + const result = analyzeCapturedWebBundle(captured); + expect(result.completeness).toMatchObject({ + status: "partial", + parsed_scripts: parsed, + parse_failures: failures, + }); + expect(result.unknowns).toContainEqual({ + dimension: "javascript_ast", + reason: expect.stringContaining(reason), + affected_script_keys: [captured.scripts.items[0]?.script_key], + }); + expect(result.observations.endpoints.map(({ value }) => value)).toEqual( + endpoints, + ); + }, +); + describe("web bundle analyzer", () => { it("extracts graph, route, endpoint, vendor, and WebMCP evidence", () => { const source = ` diff --git a/src/domain/webBundleAnalyzerInspection.ts b/src/domain/webBundleAnalyzerInspection.ts index 522f22caa..12c35109c 100644 --- a/src/domain/webBundleAnalyzerInspection.ts +++ b/src/domain/webBundleAnalyzerInspection.ts @@ -50,7 +50,8 @@ export interface AnalysisAccumulator { readonly seen: Set; visitedNodes: number; parsedScripts: number; - parseFailures: number; + readonly failedScripts: string[]; + readonly recoveredScripts: string[]; } export const analyzeScript = ( @@ -67,10 +68,12 @@ export const analyzeScript = ( } catch (cause: unknown) { // Unparseable scripts are counted; the failure needs no extra detail. void cause; - accumulator.parseFailures += 1; + accumulator.failedScripts.push(script.script_key); return; } accumulator.parsedScripts += 1; + if ((file.errors?.length ?? 0) > 0) + accumulator.recoveredScripts.push(script.script_key); detectVendorFingerprints(script, accumulator); const openReceiverFacts = classifyParsedJavaScriptOpenReceivers(file); const rpc = createJavaScriptRpcScan( @@ -386,7 +389,8 @@ export const emptyAccumulator = (): AnalysisAccumulator => ({ seen: new Set(), visitedNodes: 0, parsedScripts: 0, - parseFailures: 0, + failedScripts: [], + recoveredScripts: [], }); export const isIncludedScript = ( diff --git a/src/domain/webBundleAnalyzerResult.ts b/src/domain/webBundleAnalyzerResult.ts index 1e08f0f11..4d2509675 100644 --- a/src/domain/webBundleAnalyzerResult.ts +++ b/src/domain/webBundleAnalyzerResult.ts @@ -25,7 +25,8 @@ export const buildWebBundleAnalysis = ( const sourceMapIncomplete = sourceMaps.status === "partial" || sourceMaps.status === "unavailable"; const partial = - accumulator.parseFailures > 0 || + accumulator.failedScripts.length > 0 || + accumulator.recoveredScripts.length > 0 || unavailable.length > 0 || sourceMapIncomplete || omittedScripts !== undefined || @@ -44,9 +45,9 @@ export const buildWebBundleAnalysis = ( }, inferences: accumulator.inferences, unknowns: buildUnknowns({ - sourceScripts, unavailable, - parseFailures: accumulator.parseFailures, + failedScripts: accumulator.failedScripts, + recoveredScripts: accumulator.recoveredScripts, sourceMaps, omittedScripts, omittedSourceMaps, @@ -54,7 +55,7 @@ export const buildWebBundleAnalysis = ( completeness: buildCompleteness({ partial, parsedScripts: accumulator.parsedScripts, - parseFailures: accumulator.parseFailures, + parseFailures: accumulator.failedScripts.length, visitedNodes: accumulator.visitedNodes, }), limitations: bundleLimitations(), @@ -88,9 +89,9 @@ const chunkNode = (script: IncludedScript) => { }; interface UnknownsInput { - readonly sourceScripts: IncludedScript[]; readonly unavailable: string[]; - readonly parseFailures: number; + readonly failedScripts: readonly string[]; + readonly recoveredScripts: readonly string[]; readonly sourceMaps: WebBundleAnalysis["observations"]["source_maps"]; readonly omittedScripts: number | null | undefined; readonly omittedSourceMaps: number | null | undefined; @@ -115,15 +116,23 @@ const buildUnknowns = (input: UnknownsInput): WebBundleAnalysis["unknowns"] => [ affected_script_keys: input.unavailable, }, ]), - ...(input.parseFailures === 0 + ...(input.failedScripts.length === 0 ? [] : [ { dimension: "javascript_ast" as const, reason: "One or more source artifacts could not be parsed", - affected_script_keys: input.sourceScripts.map( - ({ script_key }) => script_key, - ), + affected_script_keys: [...input.failedScripts], + }, + ]), + ...(input.recoveredScripts.length === 0 + ? [] + : [ + { + dimension: "javascript_ast" as const, + reason: + "The parser recovered from errors in one or more source artifacts; findings may be incomplete", + affected_script_keys: [...input.recoveredScripts], }, ]), ...(input.omittedSourceMaps === undefined From 3c39a3f536b2b2dfdbab1109e1da949b59661a7e Mon Sep 17 00:00:00 2001 From: Lucien <164857476+dakjdakd@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:30:18 +0800 Subject: [PATCH 06/36] test(adb): isolate cross-platform activity launch regressions (#2226) * fix(adb): reject timed-out activity launches and retain observations * test(adb): isolate cross-platform activity launch regressions --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- tests/process-global/adbStartMcp.test.ts | 147 +++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tests/process-global/adbStartMcp.test.ts diff --git a/tests/process-global/adbStartMcp.test.ts b/tests/process-global/adbStartMcp.test.ts new file mode 100644 index 000000000..ac34b61e6 --- /dev/null +++ b/tests/process-global/adbStartMcp.test.ts @@ -0,0 +1,147 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it, onTestFinished, vi } from "vitest"; + +import { AdbProvider } from "../../src/adb/AdbProvider.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { createServer } from "../../src/server/createServer.js"; +import { createTestBinarySession } from "../fixtures/binarySession.js"; +import { + execFileBytes, + execFileOutput, +} from "../../src/process/ExecFileOutput.js"; +import { toolContract } from "../../src/contracts/toolContracts.js"; + +vi.mock("../../src/process/ExecFileOutput.js", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../src/process/ExecFileOutput.js") + >()), + execFileOutput: vi.fn(), + execFileBytes: vi.fn(), +})); + +it.each(["start_adb_app", "start_adb_activity"] as const)( + "%s retains launch evidence through MCP with isolated process mocks", + async (operation) => { + const starting = + "Starting: Intent { act=android.intent.action.MAIN cmp=com.example/.Main }\n"; + let stdout = `${starting}Status: ok\nActivity: com.example/.Main\nWaitTime: 315\nComplete\n`; + let stderr = ""; + vi.mocked(execFileOutput).mockResolvedValue({ + stdout: "Android Debug Bridge version 1.0.41\nVersion 34.0.5-stub\n", + stderr: "", + }); + const calls: string[][] = []; + vi.mocked(execFileBytes).mockImplementation(async (_binary, args) => { + calls.push([...args]); + return args.includes("resolve-activity") + ? { + stdout: Buffer.from("com.example/.Main\n"), + stderr: Buffer.alloc(0), + } + : { stdout: Buffer.from(stdout), stderr: Buffer.from(stderr) }; + }); + onTestFinished(() => { + vi.resetAllMocks(); + }); + const server = createServer( + { + kind: "session", + session: createTestBinarySession(() => { + throw new Error("must not start deep provider"); + }), + }, + { adbDeviceAnalysis: new AdbProvider({ environment: {} }) }, + ); + onTestFinished(() => server.close()); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "adb-start-boundary", version: "0.0.0" }); + onTestFinished(() => client.close()); + await client.connect(clientTransport); + const input = + operation === "start_adb_app" + ? { serial: "emulator-5554", package: "com.example" } + : { + serial: "emulator-5554", + action: "android.intent.action.MAIN", + component: "com.example/.Main", + }; + const successful = await client.callTool({ + name: operation, + arguments: input, + }); + expect(successful.isError).not.toBe(true); + const evidence = toolContract(operation).outputSchema.parse( + parseEvidence( + JSON.parse( + (successful.content as { text?: string }[]) + .map((part) => part.text ?? "") + .join(""), + ), + ), + ); + expect(evidence.confidence).toBe("observed"); + expect(evidence.normalized_result).toMatchObject({ + started_activity: "act=android.intent.action.MAIN cmp=com.example/.Main", + reported_activity: "com.example/.Main", + reported_status: "ok", + }); + expect(evidence.raw_result).toEqual({ stdout, stderr }); + + stdout = `${starting}Status: timeout\nActivity: com.example/.Main\nWaitTime: 10004\nComplete\n`; + const timedOut = await client.callTool({ + name: operation, + arguments: input, + }); + expect(timedOut.isError).toBe(true); + const failure = JSON.parse( + (timedOut.content as { text?: string }[]) + .map((part) => part.text ?? "") + .join(""), + ); + expect(failure.error).toMatchObject({ + code: "unsupported_target", + details: { + captured_output: { stdout, stderr, truncated: false }, + partial_observation: { + provider_id: "adb", + operation, + result: { + started_activity: + "act=android.intent.action.MAIN cmp=com.example/.Main", + reported_activity: "com.example/.Main", + reported_status: "timeout", + }, + }, + }, + }); + expect(failure).not.toHaveProperty("confidence"); + + stdout = starting; + stderr = "Error: Activity class does not exist\n"; + const refused = await client.callTool({ + name: operation, + arguments: input, + }); + expect(refused.isError).toBe(true); + expect( + (refused.content as { text?: string }[]) + .map((part) => part.text ?? "") + .join(""), + ).toContain("Activity class does not exist"); + expect(calls.filter((args) => args.includes("start"))).toEqual( + Array.from({ length: 3 }, () => [ + "-s", + "emulator-5554", + "shell", + "am", + "start", + "-W", + ...(operation === "start_adb_app" + ? ["-n", "com.example/.Main"] + : ["-a", "android.intent.action.MAIN", "-n", "com.example/.Main"]), + ]), + ); + }, +); From 688defca0f4a3de04eb199f7754f323c68896c3c Mon Sep 17 00:00:00 2001 From: Lucien <164857476+dakjdakd@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:30:31 +0800 Subject: [PATCH 07/36] fix(comparison): recheck historical calls-only changes (#2223) * fix(comparison): preserve incomplete function call evidence Keep missing, unknown, partial and unresolved call relationships unknown in function comparisons and reconstruction claims. Aggregate IDA caller and callee coverage without overwriting uncertainty. Validate with 144 focused tests, typecheck/lint, docs checks and test discovery. Real providers were not verified; the full headless IDA session suite requires unavailable Windows native controls. * fix(comparison): recheck historical calls-only changes --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- ...uctionVerification.functionSources.test.ts | 117 +++++++++++++++++- src/domain/reconstructionVerification.ts | 46 +++++-- .../mcp/comparisonUnknownsMcp.test.ts | 3 + 3 files changed, 154 insertions(+), 12 deletions(-) diff --git a/src/domain/reconstructionVerification.functionSources.test.ts b/src/domain/reconstructionVerification.functionSources.test.ts index 5dc8db589..e23d37d74 100644 --- a/src/domain/reconstructionVerification.functionSources.test.ts +++ b/src/domain/reconstructionVerification.functionSources.test.ts @@ -53,6 +53,7 @@ const source = ( digit: string, observation: Partial = {}, hasSubject = true, + hasCoverage = true, ): Evidence => createEvidence( hasSubject @@ -69,7 +70,9 @@ const source = ( result: dossier, confidence: "derived", authority: "shipped-artifact", - relationshipCoverage: { status: "complete" }, + ...(hasCoverage + ? { relationshipCoverage: { status: "complete" as const } } + : {}), ...observation, }, ); @@ -197,6 +200,118 @@ describe("function reconstruction source parameters", () => { }); }); +describe("historical calls-only reconstruction changes", () => { + it.each(["missing", "unknown", "partial", "unresolved"] as const)( + "keeps historical calls-only changes unknown with %s call coverage", + (coverage) => { + const changedDossier = functionDossierSchema.parse({ + ...functionDossierSchema.parse(dossier), + callees: [ + { + address: "0x2000", + name: "puts", + classification: null, + body: { available: false, reason: "External function." }, + }, + ], + }); + const complete = source("8", { + result: jsonValueSchema.parse(changedDossier), + }); + const incomplete = source( + "9", + { + result: jsonValueSchema.parse({ + ...changedDossier, + ...(coverage === "unresolved" + ? { + unresolved_calls: [ + { + address: "0x1004", + reason: "Computed target unresolved.", + }, + ], + } + : {}), + }), + ...(coverage === "missing" + ? {} + : { + relationshipCoverage: + coverage === "unknown" + ? { status: "unknown", reason: "Call graph unavailable." } + : coverage === "partial" + ? { + status: "partial", + limitations: ["Additional edges omitted."], + } + : { status: "complete" }, + }), + }, + true, + coverage !== "missing", + ); + const historicalResult = compareFunctions(left, complete); + expect(historicalResult).toMatchObject({ + status: "changed", + summary: { changed: 1 }, + }); + expect(historicalResult.changes).toMatchObject([ + { dimension: "calls", status: "changed" }, + ]); + const completeComparison = comparison( + { + left_evidence_id: left.evidence_id, + right_evidence_id: complete.evidence_id, + }, + {}, + [left, complete], + {}, + historicalResult, + ); + expect( + admittedVerification(completeComparison, [left, complete], "overall")() + .status, + ).toBe("fail"); + const evidence = comparison( + { + left_evidence_id: left.evidence_id, + right_evidence_id: incomplete.evidence_id, + }, + {}, + [left, incomplete], + {}, + historicalResult, + ); + const before = JSON.stringify(evidence); + for (const dimension of ["calls", "overall"] as const) { + expect( + admittedVerification(evidence, [left, incomplete], dimension)(), + ).toMatchObject({ + status: "unknown", + summary: { total: 1, passed: 0, failed: 0, unknown: 1 }, + claims: { + items: [ + expect.objectContaining({ + status: "unknown", + observed_status: "changed", + limitations: expect.arrayContaining([ + expect.stringContaining(`Source ${incomplete.evidence_id}:`), + ]), + }), + ], + }, + }); + } + expect( + admittedVerification(evidence, [left, incomplete], "pseudocode")() + .status, + ).toBe("pass"); + expect(JSON.stringify(evidence)).toBe(before); + }, + ); +}); + describe("function reconstruction call coverage", () => { it("rechecks old unchanged comparisons without affecting unrelated finite dimensions", () => { const incomplete = source("7", { diff --git a/src/domain/reconstructionVerification.ts b/src/domain/reconstructionVerification.ts index dd300a0a2..6f693a158 100644 --- a/src/domain/reconstructionVerification.ts +++ b/src/domain/reconstructionVerification.ts @@ -144,10 +144,15 @@ const evaluateClaim = ( return source; }); const functionSourceFacts = readValidatedSourceFacts(claim, sources); - const observed = observedStatus(claim, comparison); + const observation = comparisonObservation(claim, comparison); + const observed = observation.status; const limitations = [ ...authorityLimitations(claim, sources, sides), - ...sourceCallCoverageLimitations(claim, functionSourceFacts, observed), + ...sourceCallCoverageLimitations( + claim, + functionSourceFacts, + observation.hasIndependentFunctionChange === true, + ), ]; const relatedUnknowns = reconstructionClaimUnknowns( heads, @@ -235,21 +240,40 @@ const validateComparisonIdentity = (claim: Claim, evidence: Evidence): void => { throw new TypeError("Comparison Evidence identity or authority disagrees"); }; -const observedStatus = (claim: Claim, evidence: Evidence): ObservedStatus => { +const comparisonObservation = ( + claim: Claim, + evidence: Evidence, +): { + readonly status: ObservedStatus; + readonly hasIndependentFunctionChange?: boolean; +} => { if (claim.kind === "behavioral") { const result = processCaptureComparisonSchema.parse( evidence.normalized_result, ); validateProcessStatus(result); - return claim.dimension === "overall" - ? result.status - : result[claim.dimension]; + return { + status: + claim.dimension === "overall" ? result.status : result[claim.dimension], + }; } if (claim.kind === "structural-function") { const result = functionComparisonResultSchema.parse( evidence.normalized_result, ); - if (claim.dimension === "overall") return result.status; + if (claim.dimension === "overall") + return { + status: result.status, + // Historical overall changes may come solely from incomplete call sets. + hasIndependentFunctionChange: + result.function_match.status === "mismatched" || + (result.function_match.status === "matched" && + result.dimensions.some( + (dimension) => + dimension.dimension !== "calls" && + dimension.status === "changed", + )), + }; const dimension = result.dimensions.find( (item) => item.dimension === claim.dimension, ); @@ -263,7 +287,7 @@ const observedStatus = (claim: Claim, evidence: Evidence): ObservedStatus => { throw new TypeError( "Function dimension cites Evidence outside comparison closure", ); - return dimension.status; + return { status: dimension.status }; } const result = artifactComparisonResultSchema.parse( evidence.normalized_result, @@ -276,7 +300,7 @@ const observedStatus = (claim: Claim, evidence: Evidence): ObservedStatus => { throw new TypeError( "Artifact change cites Evidence outside comparison closure", ); - return result.status; + return { status: result.status }; }; const validateProcessStatus = ( @@ -340,12 +364,12 @@ const authorityLimitations = ( const sourceCallCoverageLimitations = ( claim: Claim, functionSources: readonly FunctionSourceFacts[], - observed: ObservedStatus, + hasIndependentFunctionChange: boolean, ): string[] => { if ( claim.kind !== "structural-function" || (claim.dimension !== "calls" && - !(claim.dimension === "overall" && observed !== "changed")) + !(claim.dimension === "overall" && !hasIndependentFunctionChange)) ) return []; return functionSources.flatMap(({ evidence_id, callCoverageLimitations }) => diff --git a/tests/boundary/mcp/comparisonUnknownsMcp.test.ts b/tests/boundary/mcp/comparisonUnknownsMcp.test.ts index bf950f83b..f872d2f15 100644 --- a/tests/boundary/mcp/comparisonUnknownsMcp.test.ts +++ b/tests/boundary/mcp/comparisonUnknownsMcp.test.ts @@ -49,6 +49,9 @@ const observeAgain = ( result, confidence: source.confidence, authority: source.authority, + ...(source.relationship_coverage === null + ? {} + : { relationshipCoverage: source.relationship_coverage }), }, ); From d005bd5dcd5fecf540a82b963df693efee38ac2c Mon Sep 17 00:00:00 2001 From: Rohit Ghumare <48523873+rohitg00@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:01:30 +0530 Subject: [PATCH 08/36] fix(javascript): preserve CommonJS lookup and analysis provenance (#2306) * fix(javascript): preserve CommonJS directory and ancestor lookup * test(apple): bound cold archive fixture compilation * test(release): scope preflight schema to selected jobs * fix(javascript): retain requested analysis limits in Evidence --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../JavaScriptApplicationEvidence.ts | 6 + .../JavaScriptApplicationService.ts | 21 +- .../JavaScriptArtifactPathResolution.ts | 42 +++- .../JavaScriptArtifactReconstruction.ts | 8 +- .../javascriptAnalysisLimits.test.ts | 156 +++++++++++++++ .../javascriptCommonJsDirectoryLookup.test.ts | 188 ++++++++++++++++++ .../filesystem/interfaceBuilderBundle.test.ts | 17 +- 7 files changed, 397 insertions(+), 41 deletions(-) create mode 100644 tests/acceptance/applications/javascriptAnalysisLimits.test.ts create mode 100644 tests/acceptance/applications/javascriptCommonJsDirectoryLookup.test.ts diff --git a/src/application/javascript/JavaScriptApplicationEvidence.ts b/src/application/javascript/JavaScriptApplicationEvidence.ts index b812f237e..ca20b813f 100644 --- a/src/application/javascript/JavaScriptApplicationEvidence.ts +++ b/src/application/javascript/JavaScriptApplicationEvidence.ts @@ -100,4 +100,10 @@ const parameters = ( ...(input.source_paths === undefined ? {} : { source_paths: input.source_paths }), + ...(input.max_heap_mb === undefined + ? {} + : { max_heap_mb: input.max_heap_mb }), + ...(input.analysis_timeout_ms === undefined + ? {} + : { analysis_timeout_ms: input.analysis_timeout_ms }), }); diff --git a/src/application/javascript/JavaScriptApplicationService.ts b/src/application/javascript/JavaScriptApplicationService.ts index ea56ce591..fa66a5521 100644 --- a/src/application/javascript/JavaScriptApplicationService.ts +++ b/src/application/javascript/JavaScriptApplicationService.ts @@ -62,26 +62,7 @@ export const analyzeJavaScriptApplicationValidated = async ( }); await checkpointJavaScriptAnalysis(options.signal); reconstructed = await reconstructJavaScriptArtifact( - { - input_path: input.input_path, - format: input.format, - integrity_policy: input.integrity_policy, - ...(input.max_semantic_nodes === undefined - ? {} - : { max_semantic_nodes: input.max_semantic_nodes }), - ...(input.max_semantic_nodes_per_file === undefined - ? {} - : { max_semantic_nodes_per_file: input.max_semantic_nodes_per_file }), - ...(input.source_paths === undefined - ? {} - : { source_paths: input.source_paths }), - ...(input.max_heap_mb === undefined - ? {} - : { max_heap_mb: input.max_heap_mb }), - ...(input.analysis_timeout_ms === undefined - ? {} - : { analysis_timeout_ms: input.analysis_timeout_ms }), - }, + input, scope, options.signal, options.progress, diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index cb29ab2a9..88aa1508f 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -457,9 +457,8 @@ const bareModuleCandidate = ( dependencyDirectory = posix.dirname(confined); } const source = input.files.get(input.sourcePath); - let directory = posix.dirname(input.sourcePath); let nearestNotFound: CandidateResolution | null = null; - while (true) { + for (const directory of packageLookupDirectories(input)) { const candidate = posix.join(directory, dependencyDirectory); const metadata = readPackageMetadata(input, candidate); const continueCommonJsLookup = @@ -487,8 +486,6 @@ const bareModuleCandidate = ( nearestNotFound ??= packageResolution; } else return packageResolution; } - if (directory === "." || directory === "") break; - directory = posix.dirname(directory); } if (nearestNotFound !== null) return nearestNotFound; return unresolvedCandidate("external", [ @@ -496,6 +493,21 @@ const bareModuleCandidate = ( ]); }; +function* packageLookupDirectories( + input: ResolveArtifactPathInput, +): Generator { + let directory = posix.dirname(input.sourcePath); + while (true) { + if ( + input.moduleKind !== "require" || + posix.basename(directory) !== "node_modules" + ) + yield directory; + if (directory === "." || directory === "") return; + directory = posix.dirname(directory); + } +} + /** * Nearest same-container package.json, stopping at a `node_modules` directory * so a nested package cannot inherit an ancestor's map. @@ -960,15 +972,29 @@ const resolveCandidate = ( candidate: string, packageChain: ReadonlySet, emittedSourceCandidates: readonly TypeScriptEmittedSourceCandidate[] = [], -): CandidateResolution => - resolveFileCandidates(input, fileCandidates(candidate, input.moduleKind)) ?? - resolveRuntimeSourceCandidates(input, candidate, emittedSourceCandidates) ?? - resolveDirectory( +): CandidateResolution => { + if (!commonJsDirectoryRequest(input)) { + const file = + resolveFileCandidates( + input, + fileCandidates(candidate, input.moduleKind), + ) ?? + resolveRuntimeSourceCandidates(input, candidate, emittedSourceCandidates); + if (file !== null) return file; + } + return resolveDirectory( input, candidate, readPackageMetadata(input, candidate), packageChain, ); +}; + +const commonJsDirectoryRequest = (input: ResolveArtifactPathInput): boolean => + input.context === "module-specifier" && + input.moduleKind === "require" && + (input.declaredPath.endsWith("/") || + /(?:^|\/)\.{1,2}$/u.test(input.declaredPath)); const resolveDirectory = ( input: ResolveArtifactPathInput, diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index bd95415c6..a5528ee10 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -277,15 +277,9 @@ export const reconstructJavaScriptArtifact = async ( limitations: completed.graph.limitations, }), ); - const { max_heap_mb, analysis_timeout_ms, ...coreInput } = input; - const parameters = { - ...coreInput, - ...(max_heap_mb === undefined ? {} : { max_heap_mb }), - ...(analysis_timeout_ms === undefined ? {} : { analysis_timeout_ms }), - }; const evidence = await createOwnedJavaScriptApplicationEvidenceCooperatively( - parameters, + input, partial, ); throw interruption.retainPartialObservation(evidence); diff --git a/tests/acceptance/applications/javascriptAnalysisLimits.test.ts b/tests/acceptance/applications/javascriptAnalysisLimits.test.ts new file mode 100644 index 000000000..6f0ba8cef --- /dev/null +++ b/tests/acceptance/applications/javascriptAnalysisLimits.test.ts @@ -0,0 +1,156 @@ +import type { Client } from "@modelcontextprotocol/client"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const cases = [ + { + name: "both limits", + args: ["--max-heap-mb", "128", "--analysis-timeout-ms", "30000"], + controls: { max_heap_mb: 128, analysis_timeout_ms: 30000 }, + }, + { + name: "heap limit alone", + args: ["--max-heap-mb", "256"], + controls: { max_heap_mb: 256 }, + }, + { + name: "timeout alone", + args: ["--analysis-timeout-ms", "40000"], + controls: { analysis_timeout_ms: 40000 }, + }, + { + name: "omitted limits", + args: [], + controls: {}, + }, +]; + +cliTest.for(cases)( + "preserves $name in CLI Evidence without adding ambient defaults", + async ({ args, controls }, { cli }) => { + const root = await createTestTempDirectory("rea-js-limit-provenance-"); + await writeFile(join(root, "main.js"), "export const observed = 7;\n"); + const response = await cli.run({ + arguments: [ + "analyze-javascript-application", + root, + ...args, + "--format", + "json", + ], + environment: { NODE_OPTIONS: "--max-old-space-size=512" }, + }); + expect(response.exitCode, response.stdout + response.stderr).toBe(0); + const evidence = parseEvidence(response.json); + expect(evidence.parameters).toEqual({ + format: "auto", + integrity_policy: "fail", + ...controls, + }); + }, +); + +it("preserves MCP analysis limits in retained Evidence and an exported bundle round trip", async () => { + const root = await createTestTempDirectory("rea-js-limit-mcp-"); + await writeFile(join(root, "main.js"), "export const observed = 7;\n"); + const bundleRoot = await createTestTempDirectory("rea-js-limit-bundle-"); + const path = join(bundleRoot, "evidence.json"); + const { client } = await connectLocalToolsMcp({ + NODE_OPTIONS: "--max-old-space-size=512", + }); + const expected = new Map(); + for (const { controls } of cases) { + const response = await client.callTool({ + name: "analyze_javascript_application", + arguments: { input_path: root, ...controls, detail: "summary" }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const { parent_evidence_id } = z + .object({ + normalized_result: z.object({ parent_evidence_id: z.string() }), + }) + .parse(response.structuredContent).normalized_result; + expected.set(parent_evidence_id, { + format: "auto", + integrity_policy: "fail", + ...controls, + }); + } + expect(expected.size).toBe(cases.length); + const retained = await getBundle(client); + for (const [evidence_id, parameters] of expected) + expect( + retained.records.find((record) => record.evidence_id === evidence_id) + ?.parameters, + ).toEqual(parameters); + const exported = await client.callTool({ + name: "export_evidence_bundle", + arguments: { path }, + }); + expect(exported.isError).not.toBe(true); + expect(parseEvidenceBundle(JSON.parse(await readFile(path, "utf8")))).toEqual( + retained, + ); + const closed = await client.callTool({ + name: "close_binary", + arguments: {}, + }); + expect(closed.isError).not.toBe(true); + expect((await getBundle(client)).records).toEqual([]); + const imported = await client.callTool({ + name: "import_evidence_bundle", + arguments: { path }, + }); + expect(imported.isError, JSON.stringify(imported.content)).not.toBe(true); + expect(await getBundle(client)).toEqual(retained); +}, 60_000); + +it("retains caller limits in partial Evidence after an actual analysis timeout", async () => { + const root = await createTestTempDirectory("rea-js-limit-timeout-"); + await writeFile(join(root, "main.js"), "export const observed = 7;\n"); + const { client } = await connectLocalToolsMcp({ + NODE_OPTIONS: "--max-old-space-size=512", + }); + const response = await client.callTool({ + name: "analyze_javascript_application", + arguments: { + input_path: root, + max_heap_mb: 128, + analysis_timeout_ms: 1, + }, + }); + expect(parseMcpToolError(response)).toMatchObject({ + error: { code: "provider_timeout" }, + }); + const bundle = await getBundle(client); + const partial = bundle.records.find( + (record) => record.operation === "analyze_javascript_application", + ); + expect(partial?.parameters).toEqual({ + format: "auto", + integrity_policy: "fail", + max_heap_mb: 128, + analysis_timeout_ms: 1, + }); + await client.ping(); +}, 30_000); + +async function getBundle(client: Client) { + const response = await client.callTool({ + name: "get_evidence_bundle", + arguments: { detail: "complete" }, + }); + expect(response.isError).not.toBe(true); + return parseEvidenceBundle( + z.object({ result: z.unknown() }).parse(response.structuredContent).result, + ); +} diff --git a/tests/acceptance/applications/javascriptCommonJsDirectoryLookup.test.ts b/tests/acceptance/applications/javascriptCommonJsDirectoryLookup.test.ts new file mode 100644 index 000000000..da9fcee86 --- /dev/null +++ b/tests/acceptance/applications/javascriptCommonJsDirectoryLookup.test.ts @@ -0,0 +1,188 @@ +import { execFile } from "node:child_process"; +import { createRequire } from "node:module"; +import { join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { expect } from "vitest"; + +import type { JavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +const execute = promisify(execFile); + +const logicalPath = (root: string, path: string): string => + relative(root, path).split(sep).join("/"); + +const requireSources = ( + requests: readonly (readonly [string, string, string | null])[], +): Readonly> => + Object.fromEntries( + [...new Set(requests.map(([source]) => source))].map((source) => [ + source, + requests + .filter(([path]) => path === source) + .map(([, specifier]) => `require(${JSON.stringify(specifier)});`) + .join("\n"), + ]), + ); + +const expectResolution = ( + graph: JavaScriptApplicationGraph, + source: string, + specifier: string, + kind: "require" | "import", + resolvedPath: string | null, +): void => { + const edges = graph.edges.filter( + ({ relation, properties, evidence }) => + relation === "imports" && + properties.specifier === specifier && + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === source, + ); + expect(edges).toHaveLength(2); + expect(edges.map(({ properties }) => properties)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: kind === "require" ? "require" : "static-import", + resolved_path: resolvedPath, + }), + expect.objectContaining({ + module_link_kind: kind, + resolved_path: resolvedPath, + resolution_status: resolvedPath === null ? "not-found" : "resolved", + }), + ]), + ); +}; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "preserves CommonJS directory requests before path normalization through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-cjs-directory-lookup-"); + const requests = [ + ["main.cjs", "./dep/.", "dep/index.js"], + ["main.cjs", "./dep/", "dep/index.js"], + ["main.cjs", "./parent/child/..", "parent/index.js"], + ["main.cjs", "./dep", "dep.js"], + ["main.cjs", "./entry/.", "entry/dep.js"], + ["main.cjs", "./missing/.", null], + ["main.cjs", "directory/", "node_modules/directory/index.js"], + ["main.cjs", "directory/.", "node_modules/directory/index.js"], + ["main.cjs", "package/dep/.", "node_modules/package/dep/index.js"], + ["parent/child/main.cjs", ".", "parent/child/index.js"], + ["parent/child/main.cjs", "..", "parent/index.js"], + ["typed.cts", "./typed.js/.", "typed.js/index.js"], + ] as const; + await writeFixtureFiles(root, { + "package.json": '{"type":"commonjs"}', + "dep.js": "module.exports = 'file';", + "dep/index.js": "module.exports = 'directory';", + "entry/package.json": '{"main":"./dep/."}', + "entry/dep.js": "module.exports = 'main file';", + "entry/dep/index.js": "module.exports = 'main directory decoy';", + "missing.js": "module.exports = 'missing directory decoy';", + "parent.js": "module.exports = 'parent file';", + "parent/index.js": "module.exports = 'parent directory';", + "parent/child.js": "module.exports = 'child file';", + "parent/child/index.js": "module.exports = 'child directory';", + "node_modules/directory.js": "module.exports = 'package file';", + "node_modules/directory/index.js": + "module.exports = 'package directory';", + "node_modules/package/dep.js": "module.exports = 'subpath file';", + "node_modules/package/dep/index.js": + "module.exports = 'subpath directory';", + "typed.ts": "module.exports = 'emitted source decoy';", + "typed.js/index.js": "module.exports = 'typed request directory';", + ...requireSources(requests), + }); + for (const [source, specifier, expected] of requests) { + const require = createRequire(join(root, source)); + if (expected === null) + expect(() => require.resolve(specifier)).toThrow( + expect.objectContaining({ code: "MODULE_NOT_FOUND" }), + ); + else expect(logicalPath(root, require.resolve(specifier))).toBe(expected); + } + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + for (const [source, specifier, expected] of requests) + expectResolution(graph, source, specifier, "require", expected); + expect(graph.coverage.status).toBe("complete"); + }, +); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "preserves loader-specific node_modules ancestors through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-cjs-ancestor-lookup-"); + const requests = [ + [ + "node_modules/client/main.cjs", + "shared", + "node_modules/shared/index.js", + ], + [ + "node_modules/client/main.cjs", + "@scope/shared", + "node_modules/@scope/shared/index.js", + ], + [ + "node_modules/client/main.cjs", + "private", + "node_modules/client/node_modules/private/index.js", + ], + [ + "node_modules/@scope/client/main.cjs", + "shared", + "node_modules/shared/index.js", + ], + [ + "node_modules/parent/node_modules/client/main.cjs", + "shared", + "node_modules/parent/node_modules/shared/index.js", + ], + ] as const; + const esmSource = "node_modules/client/main.mjs"; + const esmTarget = "node_modules/node_modules/shared/index.js"; + await writeFixtureFiles(root, { + "package.json": '{"type":"commonjs"}', + "node_modules/shared/index.js": "module.exports = 'outer dependency';", + "node_modules/@scope/shared/index.js": + "module.exports = 'scoped dependency';", + "node_modules/node_modules/shared/index.js": + "module.exports = 'ESM dependency';", + "node_modules/node_modules/@scope/shared/index.js": + "module.exports = 'scoped decoy';", + "node_modules/client/node_modules/private/index.js": + "module.exports = 'private dependency';", + "node_modules/parent/node_modules/shared/index.js": + "module.exports = 'nested dependency';", + "node_modules/parent/node_modules/node_modules/shared/index.js": + "module.exports = 'nested decoy';", + [esmSource]: + 'import "shared"; console.log(JSON.stringify(import.meta.resolve("shared")));', + ...requireSources(requests), + }); + for (const [source, specifier, expected] of requests) + expect( + logicalPath(root, createRequire(join(root, source)).resolve(specifier)), + ).toBe(expected); + const control = await execute(process.execPath, [join(root, esmSource)], { + cwd: root, + timeout: 5_000, + }); + expect( + logicalPath(root, fileURLToPath(JSON.parse(control.stdout) as string)), + ).toBe(esmTarget); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + for (const [source, specifier, expected] of requests) + expectResolution(graph, source, specifier, "require", expected); + expectResolution(graph, esmSource, "shared", "import", esmTarget); + expect(graph.coverage.status).toBe("complete"); + }, +); diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 21ec633c5..543248e63 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -405,12 +405,16 @@ describe("keyed archive hierarchy coverage", () => { const resources = join(bundle, "Contents", "Resources"); await mkdir(resources, { recursive: true }); const archive = join(resources, "Foundation.nib"); - await compile("/usr/bin/xcrun", [ - "swift", - "tests/conformance/native/keyed-archive-hierarchy.swift", - archive, - "40", - ]); + await compile( + "/usr/bin/xcrun", + [ + "swift", + "tests/conformance/native/keyed-archive-hierarchy.swift", + archive, + "40", + ], + { timeout: 90_000 }, + ); const analysis = await analyzeInterfaceBuilderBundle({ bundlePath: bundle, @@ -431,6 +435,7 @@ describe("keyed archive hierarchy coverage", () => { ), ).toHaveLength(41); }, + 120_000, ); }); From ed93abed02e03d5423fd85c1f547121f406a06b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=87=E5=AE=B6=E8=89=AF?= <102887115+wjl8636@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:32:31 +0800 Subject: [PATCH 09/36] fix(cli): label each uninstall skill root by its client (#2197) * fix(cli): label each uninstall skill root by its client rea uninstall reported every managed skill destination under the shared "skill" label (with only Claude Code special-cased), so a retained or failed directory could not be told apart from the others. Use the destination's client identifier so the aggregated detail names each root: "shared: retained (...)", "claude_code: removed (...)", "antigravity: retained (...)". Retained and failed entries now point to the specific directory that needs attention. Fixes #2111 Co-Authored-By: Claude Fable 5 * test(cli): align skill summary assertions with client and path labels --------- Co-authored-by: wjl8636 Co-authored-by: Claude Fable 5 Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- src/application/Uninstall.ts | 13 +++-- .../cli/clientProfileLifecycle.test.ts | 7 ++- .../filesystem/clientLifecycle.test.ts | 49 ++++++++++++++++--- 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index a4561c45e..eac36c73d 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -627,11 +627,16 @@ const removeManagedSkills = async ( platform: NodeJS.Platform, writeBlocker?: (path: string) => string | undefined, ): Promise => { - // Every destination shares one summary item, so each result is labeled by - // the directory it refers to. + // Every destination shares one summary item; preserve both client and path. const results = await Promise.all( - skillDestinations(home, undefined, environment, platform).map(({ path }) => - removeManagedSkill(path, path, fileSystem, writeBlocker), + skillDestinations(home, undefined, environment, platform).map( + ({ client, path }) => + removeManagedSkill( + path, + `${client} (${path})`, + fileSystem, + writeBlocker, + ), ), ); const failed = results.find(({ status }) => status === "failed"); diff --git a/tests/boundary/cli/clientProfileLifecycle.test.ts b/tests/boundary/cli/clientProfileLifecycle.test.ts index 4fd566fd7..8a71b669d 100644 --- a/tests/boundary/cli/clientProfileLifecycle.test.ts +++ b/tests/boundary/cli/clientProfileLifecycle.test.ts @@ -287,9 +287,12 @@ cliTest("uninstall names each retained skill directory", async ({ cli }) => { }; const skill = report.items?.find(({ name }) => name === "skill"); expect(skill?.status).toBe("retained"); - for (const directory of [shared, claude]) { + for (const [client, directory] of [ + ["shared", shared], + ["claude_code", claude], + ]) { expect(skill?.detail).toContain( - `${directory}: retained (Skill manifest is not REA-owned.)`, + `${client} (${directory}): retained (Skill manifest is not REA-owned.)`, ); expect(await readFile(join(directory, "SKILL.md"), "utf8")).toBe( "---\nname: unrelated\n---\n", diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 63ca0667a..24f655507 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -1895,14 +1895,11 @@ describe("client configuration filesystem failure reporting", () => { remove: () => Promise.reject(new Error("SECRET removal failure")), }), ); - expect(result.items).toContainEqual( - expect.objectContaining({ - name: "skill", - status: "failed", - detail: expect.stringContaining( - `Managed file could not be removed: ${join(skillRoot, "SKILL.md")}`, - ), - }), + const skillItem = result.items.find(({ name }) => name === "skill"); + expect(skillItem?.status).toBe("failed"); + expect(skillItem?.detail).toContain(`shared (${skillRoot}): failed (`); + expect(skillItem?.detail).toContain( + `Managed file could not be removed: ${join(skillRoot, "SKILL.md")}`, ); expect(JSON.stringify(result)).not.toContain("SECRET"); expect(await readFile(join(skillRoot, "SKILL.md"), "utf8")).toBe( @@ -1944,6 +1941,42 @@ describe("client configuration filesystem failure reporting", () => { }); }); +describe("skill root client identifier labels", () => { + it("labels each retained or removed skill root by its client identifier", async () => { + const home = await createTestTempDirectory("rea-uninstall-skill-labels-"); + roots.push(home); + const customClaudeDirectory = join(home, "claude-config"); + const sharedRoot = join(home, ".agents/skills/reverse-engineer-anything"); + const claudeRoot = join( + customClaudeDirectory, + "skills/reverse-engineer-anything", + ); + await mkdir(sharedRoot, { recursive: true }); + await mkdir(claudeRoot, { recursive: true }); + await writeFile( + join(sharedRoot, "SKILL.md"), + "---\nname: another-skill\n---\n# Not REA-owned\n", + ); + await writeFile(join(claudeRoot, "SKILL.md"), ownedSkillManifest); + + const skillItem = await systemUninstallHost(home, testFileSystem, { + CLAUDE_CONFIG_DIR: customClaudeDirectory, + }).removeSkill(); + + expect(skillItem.name).toBe("skill"); + expect(skillItem.detail).toContain(`shared (${sharedRoot}): retained (`); + expect(skillItem.detail).toContain( + `claude_code (${claudeRoot}): removed (`, + ); + await expect( + readFile(join(claudeRoot, "SKILL.md"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(join(sharedRoot, "SKILL.md"), "utf8")).toContain( + "another-skill", + ); + }); +}); + const testFileSystem: UninstallFileSystem = { readText: (path) => readFile(path, "utf8"), copy: (source, destination) => From 7dcd23c005ac40ae2523861ae8806e29cb1d74ed Mon Sep 17 00:00:00 2001 From: CindyGest <111882715+txiki739@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:33:54 +0200 Subject: [PATCH 10/36] fix(setup): separate host support and stop skill checks after drift (#2073) * fix(host): separate REA host support from Hopper installer targets LinuxDistribution.supported carried two meanings at once: that the host runs REA, and that the automated Hopper installer is tested on it. The doctor read the first meaning from the second, so Debian-derived hosts such as Kali reported unsupported_host while their providers worked normally. Rename the field to hopperSetupSupported, which only the Hopper installer and setup planning consume, and derive host support from the package family through supportsReaHost(). Any distribution with a recognized apt, dnf, or pacman family is now a REA host. The tested Hopper installer list is unchanged, so Debian and Kali keep falling back to configuring an existing provider instead of downloading an untested package. Verified on Kali 2026.3 (Debian testing, x86_64): rea doctor reports a healthy environment with Ghidra 12.1.4, and analyze_function decompiles an x86_64 ELF through the MCP server. npm run check, npm run docs:check, and the application and Ghidra suites pass. No Hopper or IDA host workflow was exercised. * fix(setup): stop skill checks after first drift --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/installation.md | 9 ++- src/application/Doctor.test.ts | 40 ++++++++++--- src/application/DoctorDiagnostics.ts | 6 +- src/application/LinuxHopper.test.ts | 37 ++++++++++-- src/application/LinuxHopper.ts | 43 ++++++++++---- src/application/Setup.configuration.test.ts | 4 +- src/application/Setup.planning.test.ts | 6 +- src/application/Setup.recovery.test.ts | 4 +- src/application/SetupHost.ts | 3 +- src/application/SetupPlan.ts | 3 +- src/application/SetupSkill.ts | 56 +++++++++++++------ src/ghidra/GhidraDoctor.test.ts | 2 +- .../setupSkillCleanupOwnership.test.ts | 2 +- 13 files changed, 156 insertions(+), 59 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index bb0adfbc9..8cddafee0 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -310,9 +310,12 @@ Hopper is separate commercial software with its own license. Its free demo has vendor-defined limits, and a paid license is optional. REA reuses any detected installation and preserves Hopper during uninstall. -The supported native host baseline is macOS 12+, Ubuntu 24.04+, Fedora 41+, -Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy. Ghidra and IDA have their own provider-specific -host requirements; Windows Ghidra uses the [experimental P0 boundary](windows-ghidra-p0.md). +The automated Hopper installer supports macOS 12+, Ubuntu 24.04+, Fedora 41+, +Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy. REA itself runs on any +Linux distribution with a recognized apt, dnf, or pacman package family, where +an existing Hopper launcher is selected through `HOPPER_LAUNCHER_PATH`. Ghidra +and IDA have their own provider-specific host requirements; Windows Ghidra uses +the [experimental P0 boundary](windows-ghidra-p0.md). On macOS, approved setup downloads the official DMG, checks its published size and digest, validates the application bundle, and atomically installs it to diff --git a/src/application/Doctor.test.ts b/src/application/Doctor.test.ts index 4251bbaf2..a39f81e35 100644 --- a/src/application/Doctor.test.ts +++ b/src/application/Doctor.test.ts @@ -74,7 +74,7 @@ describe("doctor", () => { ), ).toEqual({ node: `Install ${SUPPORTED_NODE_VERSION_PROSE}.`, - host: "REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, Omarchy, and the experimental Windows x64 Ghidra P0 boundary.", + host: "REA supports macOS 12+, Linux distributions with a recognized apt, dnf, or pacman package family, and the experimental Windows x64 Ghidra P0 boundary; automated Hopper setup additionally requires Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy.", hopper: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.", target: "Supply a readable local app or program path.", }); @@ -418,10 +418,9 @@ describe("doctor scoped readiness", () => { platform: "linux", linuxDistribution: () => Promise.resolve({ - id: "debian", - versionId: "13", - packageFamily: "deb", - supported: false, + id: "nixos", + packageFamily: undefined, + hopperSetupSupported: false, }), clientRegistrations: () => Promise.resolve([ @@ -451,6 +450,31 @@ describe("doctor scoped readiness", () => { ); }); + it("accepts a Debian-family host that has no tested Hopper package", async () => { + const result = await runDoctor( + undefined, + host({ + platform: "linux", + macosVersion: () => Promise.resolve(undefined), + linuxDistribution: () => + Promise.resolve({ + id: "kali", + versionId: "2026.3", + packageFamily: "deb", + hopperSetupSupported: false, + }), + }), + ); + + expect(result.checks).toContainEqual( + expect.objectContaining({ + name: "host", + ok: true, + detail: "kali 2026.3", + }), + ); + }); + it("requires explicitly selected providers instead of any available provider", async () => { const ghidra = { id: "ghidra", @@ -502,7 +526,7 @@ describe("doctor Linux and Hopper discovery", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }), configuredHopperPath: path, executable: (candidate) => Promise.resolve(candidate === path), @@ -522,7 +546,7 @@ describe("doctor Linux and Hopper discovery", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }), linuxDemoRuntimeCheck: () => Promise.resolve({ @@ -554,7 +578,7 @@ describe("doctor Linux and Hopper discovery", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }), executable: (candidate) => Promise.resolve(candidate === path), supportedLinuxHopper: () => Promise.resolve(false), diff --git a/src/application/DoctorDiagnostics.ts b/src/application/DoctorDiagnostics.ts index 354d08636..1d8a5e621 100644 --- a/src/application/DoctorDiagnostics.ts +++ b/src/application/DoctorDiagnostics.ts @@ -2,7 +2,7 @@ import { SUPPORTED_NODE_VERSION_PROSE, supportsNodeVersion, } from "../domain/runtimeVersion.js"; -import { linuxHopperLauncherPath } from "./LinuxHopper.js"; +import { linuxHopperLauncherPath, supportsReaHost } from "./LinuxHopper.js"; import type { DoctorCheck, DoctorHost, @@ -129,7 +129,7 @@ const hostCheck = async (host: DoctorHost): Promise => { host.platform === "linux" ? await host.linuxDistribution() : undefined; const supported = (host.platform === "darwin" && parseMajor(macosVersion) >= 12) || - (host.platform === "linux" && linuxDistribution?.supported === true) || + (host.platform === "linux" && supportsReaHost(linuxDistribution)) || (host.platform === "win32" && host.architecture === "x64"); const detail = macosVersion ?? @@ -138,7 +138,7 @@ const hostCheck = async (host: DoctorHost): Promise => { : `${linuxDistribution.id} ${linuxDistribution.versionId ?? "unknown"}`); return check("host", supported, detail, { remediation: - "REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, Omarchy, and the experimental Windows x64 Ghidra P0 boundary.", + "REA supports macOS 12+, Linux distributions with a recognized apt, dnf, or pacman package family, and the experimental Windows x64 Ghidra P0 boundary; automated Hopper setup additionally requires Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy.", classification: "unsupported_host", }); }; diff --git a/src/application/LinuxHopper.test.ts b/src/application/LinuxHopper.test.ts index b5cf2d38f..1c37a03ea 100644 --- a/src/application/LinuxHopper.test.ts +++ b/src/application/LinuxHopper.test.ts @@ -7,6 +7,7 @@ import { linuxPackageManagerCommands, linuxSharedLibrariesAvailable, parseLinuxDistribution, + supportsReaHost, type LinuxDistribution, type LinuxHopperDownload, type LinuxHopperInstallHost, @@ -19,7 +20,7 @@ class RecordingLinuxHost implements LinuxHopperInstallHost { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }; archive = new Uint8Array([1, 2, 3]); archiveOk = true; @@ -66,7 +67,7 @@ describe("Linux Hopper host classification", () => { (document, family) => { expect(parseLinuxDistribution(document)).toMatchObject({ packageFamily: family, - supported: true, + hopperSetupSupported: true, }); }, ); @@ -99,8 +100,32 @@ describe("Linux Hopper host classification", () => { "ID=garuda\nID_LIKE=arch\n", "ID=omarchy\n", "ID=omarchy\nID_LIKE=debian\n", - ])("rejects unsupported vendor/version combinations", (document) => { - expect(parseLinuxDistribution(document).supported).toBe(false); + ])( + "rejects vendor/version combinations without a tested Hopper installer", + (document) => { + expect(parseLinuxDistribution(document).hopperSetupSupported).toBe(false); + }, + ); + + it.each([ + 'ID=kali\nVERSION_ID="2026.3"\nID_LIKE=debian\n', + 'ID=debian\nVERSION_ID="13"\nID_LIKE=debian\n', + 'ID=linuxmint\nVERSION_ID="22.1"\nID_LIKE="ubuntu debian"\n', + "ID=manjaro\nID_LIKE=arch\n", + "ID=derivative\nID_LIKE='debian'\n", + "ID='debian'\nVERSION_ID='13'\n", + ])( + "hosts REA on a distribution Hopper has no tested package for: %s", + (document) => { + const distribution = parseLinuxDistribution(document); + expect(distribution.hopperSetupSupported).toBe(false); + expect(supportsReaHost(distribution)).toBe(true); + }, + ); + + it("withholds host support from a distribution with no package family", () => { + expect(supportsReaHost(parseLinuxDistribution("ID=nixos\n"))).toBe(false); + expect(supportsReaHost(undefined)).toBe(false); }); }); @@ -166,7 +191,7 @@ describe("Linux Hopper installation", () => { host.distributionValue = { id: family, packageFamily: family, - supported: true, + hopperSetupSupported: true, }; const result = await installFixture(host); expect(result).toEqual({ @@ -194,7 +219,7 @@ describe("Linux Hopper installation", () => { host.distributionValue = { id: "debian", packageFamily: "deb", - supported: false, + hopperSetupSupported: false, }; if (scenario === "package request failure") host.archiveOk = false; if (scenario === "checksum mismatch") host.archive = new Uint8Array([9]); diff --git a/src/application/LinuxHopper.ts b/src/application/LinuxHopper.ts index 3a46106bb..765f31d29 100644 --- a/src/application/LinuxHopper.ts +++ b/src/application/LinuxHopper.ts @@ -76,19 +76,35 @@ interface LinuxDistributionIdentity { readonly versionId?: string; } -/** Parsed Linux distribution information used for support and package selection. */ +/** + * Parsed Linux distribution information used for package selection. + * + * `hopperSetupSupported` is the automated Hopper installer's tested list. It is + * narrower than the hosts where REA itself runs, which {@link supportsReaHost} + * answers from the package family. + */ export type LinuxDistribution = LinuxDistributionIdentity & ( | { readonly packageFamily: LinuxPackageFamily; - readonly supported: true; + readonly hopperSetupSupported: true; } | { readonly packageFamily: LinuxPackageFamily | undefined; - readonly supported: false; + readonly hopperSetupSupported: false; } ); +/** + * Whether REA's analysis providers run on this distribution. A recognized + * package family means a glibc host with a known package layout, which is what + * the providers need; downloading and installing the official Hopper package + * additionally requires a distribution on its tested list. + */ +export const supportsReaHost = ( + distribution: LinuxDistribution | undefined, +): boolean => distribution?.packageFamily !== undefined; + /** Download response kept independent of Node's global fetch implementation. */ export interface LinuxHopperDownload { readonly ok: boolean; @@ -141,16 +157,19 @@ export const parseLinuxDistribution = (text: string): LinuxDistribution => { const key = match[1]; const encoded = match[2]; if (key === undefined || encoded === undefined) continue; - const value = - encoded.startsWith('"') && encoded.endsWith('"') - ? encoded.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\") - : encoded; + const quote = encoded[0]; + const quoted = (quote === "'" || quote === '"') && encoded.endsWith(quote); + const value = !quoted + ? encoded + : quote === "'" + ? encoded.slice(1, -1) + : encoded.slice(1, -1).replace(/\\(["\\$`])/gu, "$1"); fields.set(key, value); } const id = (fields.get("ID") ?? "unknown").toLowerCase(); const versionId = fields.get("VERSION_ID"); const packageFamily = packageFamilyFor(id, fields.get("ID_LIKE")); - const supported = + const hopperSetupSupported = packageFamily !== undefined && (id === "arch" || id === "cachyos" || @@ -162,9 +181,9 @@ export const parseLinuxDistribution = (text: string): LinuxDistribution => { id, ...(versionId === undefined ? {} : { versionId }), }; - return supported - ? { ...identity, packageFamily, supported: true } - : { ...identity, packageFamily, supported: false }; + return hopperSetupSupported + ? { ...identity, packageFamily, hopperSetupSupported: true } + : { ...identity, packageFamily, hopperSetupSupported: false }; }; /** Read and classify the current Linux distribution. */ @@ -193,7 +212,7 @@ export const installLinuxHopper = async ( if (options.signal?.aborted === true) return { status: "failed", reason: "cancelled" }; const distribution = await host.distribution(); - if (distribution?.supported !== true) + if (distribution?.hopperSetupSupported !== true) return { status: "failed", reason: "unsupported_host" }; let temporary: string | undefined; try { diff --git a/src/application/Setup.configuration.test.ts b/src/application/Setup.configuration.test.ts index 3bd08cd4f..a3f46ed6f 100644 --- a/src/application/Setup.configuration.test.ts +++ b/src/application/Setup.configuration.test.ts @@ -57,7 +57,7 @@ describe("setup workflow", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }; host.hopper = "/opt/hopper/bin/Hopper"; host.linuxDemoRuntimeMissing = true; @@ -79,7 +79,7 @@ describe("setup workflow", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }; host.hopper = "/custom/Hopper"; host.unsupportedHopperVersion = true; diff --git a/src/application/Setup.planning.test.ts b/src/application/Setup.planning.test.ts index d1ca6baea..2548772c9 100644 --- a/src/application/Setup.planning.test.ts +++ b/src/application/Setup.planning.test.ts @@ -70,7 +70,7 @@ describe("setup workflow", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }; const result = await runSetup( { ...options(false), structured: false }, @@ -162,7 +162,7 @@ describe("setup workflow", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }; const result = await runSetup(options(true, true), host); expect(result.appliedActions).toEqual(["installed_hopper"]); @@ -236,7 +236,7 @@ describe("setup workflow lifecycle and provider planning", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }; host.ghidra = "/opt/ghidra_12.1.4_PUBLIC"; host.javaHome = "/usr/lib/jvm/jdk-21"; diff --git a/src/application/Setup.recovery.test.ts b/src/application/Setup.recovery.test.ts index f29c9d375..244aee26b 100644 --- a/src/application/Setup.recovery.test.ts +++ b/src/application/Setup.recovery.test.ts @@ -310,7 +310,7 @@ describe("setup scoped readiness", () => { id: "debian", versionId: "13", packageFamily: "deb", - supported: false, + hopperSetupSupported: false, }; const result = await runSetup(options(true, true), host); expect(result.status).toBe("needs_human"); @@ -326,7 +326,7 @@ describe("setup scoped readiness", () => { id: "debian", versionId: "13", packageFamily: "deb", - supported: false, + hopperSetupSupported: false, }; host.ghidra = "/opt/ghidra_12.1.4_PUBLIC"; host.javaHome = "/usr/lib/jvm/jdk-21"; diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index 022cecc62..84d41229e 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -124,7 +124,8 @@ export const hostRemediation = async ( ? "Upgrade to macOS 12 or newer." : undefined; } - if ((await host.linuxDistribution())?.supported === true) return undefined; + if ((await host.linuxDistribution())?.hopperSetupSupported === true) + return undefined; return "Automated Hopper setup supports Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, and Omarchy; configure an existing supported provider instead."; }; diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts index 35db28de4..f428ecade 100644 --- a/src/application/SetupPlan.ts +++ b/src/application/SetupPlan.ts @@ -56,7 +56,8 @@ export const discoverSetupState = async (input: { const hopperInstallSupported = (input.host.platform === "darwin" && majorVersion((await input.host.macosVersion()) ?? "0") >= 12) || - (input.host.platform === "linux" && linuxDistribution?.supported === true); + (input.host.platform === "linux" && + linuxDistribution?.hopperSetupSupported === true); const installHopper = input.forceHopperInstall || (hopperInstallSupported && diff --git a/src/application/SetupSkill.ts b/src/application/SetupSkill.ts index 83ba96c5a..794202b48 100644 --- a/src/application/SetupSkill.ts +++ b/src/application/SetupSkill.ts @@ -72,6 +72,15 @@ interface CanonicalSkillFile { readonly original: SkillFileState | undefined; } +const canonicalSkillContent = (relativePath: string): Promise => + readFile( + new URL( + `../../skills/${PRODUCT_IDENTITY.skillName}/${relativePath}`, + import.meta.url, + ), + "utf8", + ); + export interface SkillInstallFileOperations { readonly mkdir: (path: string) => Promise; readonly backup: (source: string, destination: string) => Promise; @@ -244,19 +253,36 @@ const canonicalSkillFiles = async ( const destination = join(root, relativePath); return { destination, - content: await readFile( - new URL( - `../../skills/${PRODUCT_IDENTITY.skillName}/${relativePath}`, - import.meta.url, - ), - "utf8", - ), + content: await canonicalSkillContent(relativePath), original: await readOptionalSkillState(destination, resources), }; }), ), ); +const canonicalSkillNeedsInstallByReading = async ( + home: string, + clientIds: readonly string[], + environment: Readonly, + platform: NodeJS.Platform, + resources?: ArtifactResourceScope, +): Promise => { + for (const { path: root } of skillDestinations( + home, + clientIds, + environment, + platform, + )) { + for (const relativePath of MANAGED_SKILL_FILES) { + const destination = join(root, relativePath); + const content = await canonicalSkillContent(relativePath); + const original = await readOptionalSkillState(destination, resources); + if (original?.content !== content) return true; + } + } + return false; +}; + /** Report whether setup would change any file in the managed REA skill bundle. */ export const canonicalSkillNeedsInstall = async ( home: string, @@ -266,15 +292,13 @@ export const canonicalSkillNeedsInstall = async ( resources?: ArtifactResourceScope, ): Promise => { try { - return ( - await canonicalSkillFiles( - home, - clientIds, - environment, - platform, - resources, - ) - ).some(({ content, original }) => original?.content !== content); + return await canonicalSkillNeedsInstallByReading( + home, + clientIds, + environment, + platform, + resources, + ); } catch (cause: unknown) { if ( cause instanceof RegularFileCleanupFailure || diff --git a/src/ghidra/GhidraDoctor.test.ts b/src/ghidra/GhidraDoctor.test.ts index 69b9acb62..844438408 100644 --- a/src/ghidra/GhidraDoctor.test.ts +++ b/src/ghidra/GhidraDoctor.test.ts @@ -61,7 +61,7 @@ const doctorHost = ( id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }), validTarget: () => Promise.resolve(true), executable: (path) => diff --git a/tests/boundary/filesystem/setupSkillCleanupOwnership.test.ts b/tests/boundary/filesystem/setupSkillCleanupOwnership.test.ts index dfb472972..4ca9ea407 100644 --- a/tests/boundary/filesystem/setupSkillCleanupOwnership.test.ts +++ b/tests/boundary/filesystem/setupSkillCleanupOwnership.test.ts @@ -70,7 +70,7 @@ const failFirstCloses = (path: string) => { describe("skill regular-file cleanup ownership", () => { it("retains a failed setup skill read until setup host disposal", async () => { const home = await createTestTempDirectory("rea-setup-skill-close-"); - const [destination] = skillDestinations(home, []); + const [destination] = skillDestinations(home, [], {}, "linux"); if (destination === undefined) throw new Error("Shared skill root missing"); const manifest = join(destination.path, "SKILL.md"); await mkdir(dirname(manifest), { recursive: true }); From ffd8c9c03ddf5409cc7b044f0a1c48b4b0114bee Mon Sep 17 00:00:00 2001 From: Huamnanx <69626391+Ramcharan49@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:34:17 -0700 Subject: [PATCH 11/36] test(evaluation): grade managed agent fixture facts (#1924) * test(evaluation): grade managed agent fixture facts * test(evaluation): cover distinct managed artifact deliveries * fix(evaluation): require unfiltered managed type inventories * fix(evaluation): preserve selected entry point grounding --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/client-verification.md | 19 +- scripts/verify-agent-experience.mjs | 10 +- src/evaluation/AgentEvaluationFixtures.ts | 121 +++++- src/evaluation/KnownAnswerEvaluation.ts | 18 +- .../model-evals/knownAnswerEvaluation.test.ts | 348 +++++++++++++++++- 5 files changed, 506 insertions(+), 10 deletions(-) diff --git a/docs/client-verification.md b/docs/client-verification.md index d9ece9aad..38d0bf399 100644 --- a/docs/client-verification.md +++ b/docs/client-verification.md @@ -458,7 +458,7 @@ artifact path. Its `expectedFirstTool` is `null`, and `targetClarificationPassed` grades that limited routing outcome independently of analysis prose heuristics. Analyzing a nearby example cannot pass this case. -The packaged desktop and parser-comparison scenarios now assess a closed set of +The packaged desktop, parser-comparison, and managed scenarios assess a closed set of known fixture claims. Each final answer must be a strict JSON manifest containing exactly the requested claim IDs, values, Evidence IDs, and Evidence authority and confidence. Golden values are not included in the prompt. The evaluator checks @@ -475,15 +475,24 @@ analyses and use the requested module and export selectors. Boundary tests packa the same source-owned desktop fixture and compare the same source-owned parser files through actual MCP tool results, then verify that fabricated answers fail. +The managed rubric checks the exact public top-level type set and the CLI entry +point's declaring type and method name. The exact type-set claim requires an +unfiltered member inventory: `selection` must be null and the Evidence parameters +must omit `type_names`. A selected inventory can still ground the entry-point +claim. Both inspections must describe the freshly built DLL's SHA-256. Entry-point +resolution additionally requires complete artifact and member metadata, matching +non-null module MVIDs, and a unique method at the header's metadata token. These are +static metadata claims, not runtime behavior; +compiler-generated member counts, token values, and MVIDs are not golden answers. + Per-scenario `factualCorrectness` is `passed`, `failed`, or `not_assessed` within the `configured_fixture_claims` scope. A pass establishes only the configured -claims, not unrestricted factual correctness. Native, managed, browser, +claims, not unrestricted factual correctness. Native, browser, navigation-context, and address-context scenarios have no factual rubric and -remain `not_assessed`; the managed workflow requires both artifact and member -inspection to answer its type and entry-point question. +remain `not_assessed`. The closed factual scenarios request complete producer Evidence on the initial -analysis so their source selectors can authenticate all configured facts. +inspection or analysis so their source selectors can authenticate all configured facts. Summary/page/item workflows are exercised separately by `javascript-module-view`; its heuristic gate does not establish full-producer factual correctness. diff --git a/scripts/verify-agent-experience.mjs b/scripts/verify-agent-experience.mjs index 99ab060dd..71cd6383c 100644 --- a/scripts/verify-agent-experience.mjs +++ b/scripts/verify-agent-experience.mjs @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; import { chmod, copyFile, @@ -259,7 +260,7 @@ try { const execution = await runCodex( answerInstructions.length === 0 ? scenario.prompt - : `${scenario.prompt}\n\nThis closed factual rubric needs complete producer Evidence in the transcript. Request complete detail on each initial application analysis when supported; summary or view records alone are not accepted by this rubric.\n\n${answerInstructions}`, + : `${scenario.prompt}\n\nThis closed factual rubric needs complete producer Evidence in the transcript. Request complete detail on each initial inspection or analysis when supported; summary or view records alone are not accepted by this rubric.\n\n${answerInstructions}`, scenario.approvedTools ?? [], ); const transcriptDirectory = process.env.REA_AGENT_EVAL_TRANSCRIPT_DIR; @@ -440,6 +441,13 @@ async function createTargets(root, includeManaged) { ...fixtures, largeJavascript, managed: join(managedOutput, "AgentEval.dll"), + ...(includeManaged + ? { + managedSha256: createHash("sha256") + .update(await readFile(join(managedOutput, "AgentEval.dll"))) + .digest("hex"), + } + : {}), }; } diff --git a/src/evaluation/AgentEvaluationFixtures.ts b/src/evaluation/AgentEvaluationFixtures.ts index b7ef17efe..b0535a96e 100644 --- a/src/evaluation/AgentEvaluationFixtures.ts +++ b/src/evaluation/AgentEvaluationFixtures.ts @@ -8,7 +8,19 @@ import type { JsonValue } from "../domain/jsonValue.js"; import { javascriptApplicationAnalysisResultSchema } from "../domain/javascript/javascriptApplicationAnalysis.js"; import type { ApplicationNode } from "../domain/javascript/javascriptApplicationGraphSchemas.js"; import { javaScriptExportShapeComparisonResultSchema } from "../domain/javascript/javascriptExportShapeComparisonSchemas.js"; -import type { FixtureClaimExpectation } from "./KnownAnswerEvaluation.js"; +import { + managedArtifactInspectionSchema, + managedMemberInspectionSchema, +} from "../domain/managed/managedArtifact.js"; +import { + managedTokenScopesMatch, + parseManagedInspectionEvidence, +} from "../domain/managed/managedInspectionEvidence.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; +import type { + FixtureClaimContext, + FixtureClaimExpectation, +} from "./KnownAnswerEvaluation.js"; /** Source-owned artifact paths used by the configured known-answer benchmarks. */ export interface AgentEvaluationFixtureTargets { @@ -16,6 +28,8 @@ export interface AgentEvaluationFixtureTargets { readonly javascriptSha256: string; readonly javascriptShapeLeft: string; readonly javascriptShapeRight: string; + readonly managed?: string; + readonly managedSha256?: string; } const desktopFixtureFiles = { @@ -277,17 +291,122 @@ const parserClaims = ( ]; }; +const managedMembers = ({ evidence }: FixtureClaimContext) => { + const { result } = parseManagedInspectionEvidence( + evidence, + "inspect_managed_members", + managedMemberInspectionSchema, + ); + if ( + result.coverage.state !== "complete" || + result.metadata.status !== "complete" || + !managedTokenScopesMatch( + result.identity_scope.requires_mvid, + result.module?.mvid ?? null, + ) + ) + return undefined; + return result; +}; + +const managedClaims = ( + targets: AgentEvaluationFixtureTargets, +): readonly FixtureClaimExpectation[] => { + if (targets.managed === undefined || targets.managedSha256 === undefined) + throw new TypeError( + "Managed fixture grading requires the built DLL path and SHA-256", + ); + const source = { + operation: "inspect_managed_members", + authority: "shipped-artifact", + confidence: "observed", + subject: { path: targets.managed, sha256: targets.managedSha256 }, + } satisfies Omit; + return [ + { + id: "public_types", + expectedValue: ["AgentEval.Profile", "AgentEval.Program"], + source: { + ...source, + select: (_value, context) => { + const members = managedMembers(context); + if ( + members === undefined || + members.selection !== null || + Object.hasOwn(context.evidence.parameters, "type_names") + ) + return undefined; + return members.types + .filter((type) => (type.flags & 7) === 1) + .map((type) => type.full_name) + .sort(compareUnicodeCodePoints); + }, + }, + }, + { + id: "entry_point", + expectedValue: { declaring_type: "AgentEval.Program", name: "Main" }, + source: { + ...source, + select: (_value, context) => { + const members = managedMembers(context); + if (members === undefined) return undefined; + const artifacts = context.deliveredEvidence.filter( + (evidence) => + evidence.operation === "inspect_managed_artifact" && + evidence.authority === source.authority && + evidence.confidence === source.confidence && + evidence.subject?.local_path === source.subject.path && + evidence.subject.digest.sha256 === source.subject.sha256, + ); + if (artifacts.length !== 1) return undefined; + const { result: artifact } = parseManagedInspectionEvidence( + artifacts[0], + "inspect_managed_artifact", + managedArtifactInspectionSchema, + ); + if ( + artifact.coverage.state !== "complete" || + artifact.metadata.status !== "complete" || + !managedTokenScopesMatch( + artifact.module?.mvid ?? null, + members.module?.mvid ?? null, + ) || + artifact.pe.cli?.entry_point.kind !== "metadata-token" + ) + return undefined; + const methods = members.methods.filter( + (method) => method.token === artifact.pe.cli?.entry_point.value, + ); + const method = methods.length === 1 ? methods[0] : undefined; + return method === undefined + ? undefined + : { + declaring_type: method.declaring_type, + name: method.name, + }; + }, + }, + }, + ]; +}; + /** Return the closed known-answer rubric, or undefined for routing-only scenarios. */ export const agentFixtureClaims = ( scenarioId: string, targets: AgentEvaluationFixtureTargets, ): readonly FixtureClaimExpectation[] | undefined => { if (scenarioId === "asar") return asarClaims(targets); + if (scenarioId === "managed") return managedClaims(targets); if (scenarioId === "javascript-export-shape") return parserClaims(targets); return undefined; }; const answerValueDescriptions: Readonly> = { + managed: [ + "public_types: string[], listing the exact public top-level type full names in Unicode code-point order from unfiltered member Evidence (omit type_names).", + 'entry_point: {"declaring_type": string, "name": string}, resolving the CLI metadata entry-point token against the member inventory from the same artifact SHA-256 and module MVID. Cite member Evidence; both artifact and member Evidence must be delivered.', + ], asar: [ 'renderer_api: {"key": string, "members": string[], "world": string, "source": string}, describing the exposed bridge API, its members, world, and source file.', 'renderer_ipc: {"channel": string, "operation": string, "source": string}, describing the renderer-side IPC operation and its source file.', diff --git a/src/evaluation/KnownAnswerEvaluation.ts b/src/evaluation/KnownAnswerEvaluation.ts index c6e4af72b..fe71302eb 100644 --- a/src/evaluation/KnownAnswerEvaluation.ts +++ b/src/evaluation/KnownAnswerEvaluation.ts @@ -5,6 +5,12 @@ import { parseEvidence, type Evidence } from "../domain/evidence.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { canonicalJson } from "../domain/comparisonSemantics.js"; +/** Only authenticated, successful, non-conflicting transcript deliveries. */ +export interface FixtureClaimContext { + readonly evidence: Evidence; + readonly deliveredEvidence: readonly Evidence[]; +} + /** One closed fixture assertion and the producer facts that must support it. */ export interface FixtureClaimExpectation { readonly id: string; @@ -20,7 +26,10 @@ export interface FixtureClaimExpectation { readonly path: string; readonly sha256?: string; }[]; - readonly select: (normalizedResult: JsonValue) => JsonValue | undefined; + readonly select: ( + normalizedResult: JsonValue, + context: FixtureClaimContext, + ) => JsonValue | undefined; }; } @@ -430,7 +439,12 @@ const assessClaim = ( try { if ( !sameJson( - expected.source.select(evidence.normalized_result), + expected.source.select(evidence.normalized_result, { + evidence, + deliveredEvidence: [...delivered.records.values()].filter( + (record) => !delivered.conflicts.has(record.evidence_id), + ), + }), expected.expectedValue, ) ) diff --git a/tests/evaluation/model-evals/knownAnswerEvaluation.test.ts b/tests/evaluation/model-evals/knownAnswerEvaluation.test.ts index cbe59dd7b..daed23cac 100644 --- a/tests/evaluation/model-evals/knownAnswerEvaluation.test.ts +++ b/tests/evaluation/model-evals/knownAnswerEvaluation.test.ts @@ -5,6 +5,16 @@ import { toolContract } from "../../../src/contracts/toolContracts.js"; import { createEvidence, type Evidence } from "../../../src/domain/evidence.js"; import type { JsonValue } from "../../../src/domain/jsonValue.js"; import { evaluateCodexEvents } from "../../../src/evaluation/CodexAgentEval.js"; +import { + agentFixtureAnswerInstructions, + agentFixtureClaims, +} from "../../../src/evaluation/AgentEvaluationFixtures.js"; +import { inspectManagedArtifactBytes } from "../../../src/dotnet/ManagedArtifactInspector.js"; +import { inspectManagedMembersBytes } from "../../../src/dotnet/ManagedMemberInspector.js"; +import { + buildManagedPeFixture, + managedPeFixtureTarget, +} from "../../../src/dotnet/ManagedPe.fixture.js"; import type { FixtureClaimExpectation } from "../../../src/evaluation/KnownAnswerEvaluation.js"; import { ToolResultDelivery } from "../../../src/server/toolResult.js"; @@ -33,7 +43,13 @@ const createObservation = ( const resultFor = (evidence: Evidence) => delivery.toEvidenceToolResult( evidence, - toolContract("analyze_javascript_application"), + toolContract( + evidence.operation === "inspect_managed_artifact" + ? "inspect_managed_artifact" + : evidence.operation === "inspect_managed_members" + ? "inspect_managed_members" + : "analyze_javascript_application", + ), undefined, ); const delivered = ( @@ -95,6 +111,336 @@ const assess = ( ); const answer = (claims: readonly unknown[]) => JSON.stringify({ claims }); +// Synthetic transcript cases exercise grading, not an SDK-built assembly oracle. +const managedTranscript = () => { + const bytes = buildManagedPeFixture({ + typeNamespace: "AgentEval", + typeName: "Program", + methodName: "Main", + }); + const target = managedPeFixtureTarget(bytes, "/targets/AgentEval.dll"); + const artifact = inspectManagedArtifactBytes(bytes, target); + const members = inspectManagedMembersBytes(bytes, target); + const program = members.types[0]; + if (program === undefined) throw new Error("Fixture type missing"); + const result = { + ...members, + types: [ + program, + { + ...program, + token: "0x02000002", + name: "Profile", + full_name: "AgentEval.Profile", + }, + ], + }; + const rubric = agentFixtureClaims("managed", { + javascript: "", + javascriptSha256: "", + javascriptShapeLeft: "", + javascriptShapeRight: "", + managed: target.path, + managedSha256: target.sha256, + }); + if (rubric === undefined) throw new Error("Managed rubric missing"); + const evidenceFor = ( + operation: string, + value: JsonValue, + overrides: Record = {}, + ) => + createEvidence(target, provider, { + operation, + parameters: {}, + result: value, + confidence: "observed", + authority: "shipped-artifact", + ...overrides, + }); + const artifactEvidence = evidenceFor("inspect_managed_artifact", artifact); + const memberEvidence = evidenceFor("inspect_managed_members", result); + const textFor = (evidence: Evidence) => + answer([ + { + id: "public_types", + value: ["AgentEval.Profile", "AgentEval.Program"], + evidence_id: evidence.evidence_id, + confidence: "observed", + authority: "shipped-artifact", + }, + { + id: "entry_point", + value: { declaring_type: "AgentEval.Program", name: "Main" }, + evidence_id: evidence.evidence_id, + confidence: "observed", + authority: "shipped-artifact", + }, + ]); + const grade = ( + events = [delivered(artifactEvidence), delivered(memberEvidence)], + text = textFor(memberEvidence), + ) => + evaluateCodexEvents([...events, final(text)], "inspect_managed_artifact", { + fixtureClaims: rubric, + }); + return { + artifact, + result, + artifactEvidence, + memberEvidence, + evidenceFor, + textFor, + grade, + }; +}; + +describe("managed known-answer fixture grading", () => { + it("grades public types independent of row order and resolves the header token", () => { + expect(managedTranscript().grade().factualCorrectness).toBe("passed"); + }); + + it("does not pin compiler-local entry tokens or MVIDs", () => { + const fixture = managedTranscript(); + const artifact = structuredClone(fixture.artifact); + const members = structuredClone(fixture.result); + if ( + artifact.pe.cli === null || + artifact.module === null || + members.module === null || + members.methods[0] === undefined + ) + throw new Error("Fixture identity missing"); + artifact.pe.cli.entry_point.value = "0x06000009"; + members.methods[0].token = "0x06000009"; + const mvid = "11111111-2222-3333-4444-555555555555"; + artifact.module.mvid = mvid; + members.module.mvid = mvid; + members.identity_scope.requires_mvid = mvid; + members.types.reverse(); + const artifactEvidence = fixture.evidenceFor( + "inspect_managed_artifact", + artifact, + ); + const memberEvidence = fixture.evidenceFor( + "inspect_managed_members", + members, + ); + expect( + fixture.grade( + [delivered(artifactEvidence), delivered(memberEvidence)], + fixture.textFor(memberEvidence), + ).factualCorrectness, + ).toBe("passed"); + }); + + it.each([ + "wrong-token", + "native-rva", + "mvid", + "null-mvid", + "digest", + "partial", + ])("rejects an unsupported artifact entry point: %s", (kind) => { + const fixture = managedTranscript(); + const artifact = structuredClone(fixture.artifact); + if (artifact.pe.cli === null || artifact.module === null) + throw new Error("Fixture identity missing"); + if (kind === "wrong-token") + artifact.pe.cli.entry_point.value = "0x06000002"; + if (kind === "native-rva") artifact.pe.cli.entry_point.kind = "native-rva"; + if (kind === "mvid") + artifact.module.mvid = "11111111-2222-3333-4444-555555555555"; + if (kind === "null-mvid") artifact.module.mvid = null; + if (kind === "digest") artifact.artifact.sha256 = "b".repeat(64); + if (kind === "partial") artifact.coverage.state = "partial"; + const evidence = fixture.evidenceFor("inspect_managed_artifact", artifact); + expect( + fixture.grade([delivered(evidence), delivered(fixture.memberEvidence)]) + .factualCorrectness, + ).toBe("failed"); + }); +}); + +describe("managed inventory scope", () => { + it.each(["selection", "parameters", "both"])( + "rejects selected public-type inventories while retaining entry-point grounding: %s", + (kind) => { + const fixture = managedTranscript(); + const typeNames = ["AgentEval.Profile", "AgentEval.Program"]; + const members = { + ...fixture.result, + selection: + kind === "parameters" + ? null + : { type_names: typeNames, unmatched_type_names: [] }, + }; + const evidence = fixture.evidenceFor("inspect_managed_members", members, { + parameters: kind === "selection" ? {} : { type_names: typeNames }, + }); + expect( + fixture.grade( + [delivered(fixture.artifactEvidence), delivered(evidence)], + fixture.textFor(evidence), + ), + ).toMatchObject({ + factualCorrectness: "failed", + factualAssessment: { + claims: [ + { id: "public_types", status: "failed" }, + { id: "entry_point", status: "passed" }, + ], + }, + }); + }, + ); +}); + +describe("managed member grounding", () => { + it.each([ + "extra-public-type", + "missing-public-type", + "duplicate-method", + "decoy-main", + "scope-digest", + "scope-mvid", + "null-scope", + "partial", + "unavailable", + ])("rejects unsupported member facts: %s", (kind) => { + const fixture = managedTranscript(); + const members = structuredClone(fixture.result); + const method = members.methods[0]; + const type = members.types[0]; + if (method === undefined || type === undefined) + throw new Error("Fixture members missing"); + if (kind === "extra-public-type") + members.types.push({ + ...type, + name: "Extra", + full_name: "AgentEval.Extra", + }); + if (kind === "missing-public-type") members.types.pop(); + if (kind === "duplicate-method") members.methods.push(method); + if (kind === "decoy-main") { + method.name = "Other"; + members.methods.push({ ...method, token: "0x06000002", name: "Main" }); + } + if (kind === "scope-digest") + members.identity_scope.requires_artifact_sha256 = "b".repeat(64); + if (kind === "scope-mvid") + members.identity_scope.requires_mvid = + "11111111-2222-3333-4444-555555555555"; + if (kind === "null-scope") members.identity_scope.requires_mvid = null; + if (kind === "partial") members.metadata.status = "partial"; + if (kind === "unavailable") members.coverage.state = "unavailable"; + const evidence = fixture.evidenceFor("inspect_managed_members", members); + expect( + fixture.grade( + [delivered(fixture.artifactEvidence), delivered(evidence)], + fixture.textFor(evidence), + ).factualCorrectness, + ).toBe("failed"); + }); + + it("rejects distinct authenticated matching artifact deliveries", () => { + const fixture = managedTranscript(); + const secondArtifact = fixture.evidenceFor( + "inspect_managed_artifact", + fixture.artifact, + { parameters: { path: "/targets/AgentEval.dll" } }, + ); + expect(secondArtifact.evidence_id).not.toBe( + fixture.artifactEvidence.evidence_id, + ); + const member = delivered(fixture.memberEvidence); + expect( + fixture.grade([delivered(secondArtifact), member]).factualCorrectness, + ).toBe("passed"); + expect( + fixture.grade([ + delivered(fixture.artifactEvidence), + delivered(secondArtifact), + member, + ]).factualCorrectness, + ).toBe("failed"); + }); + + it("accepts identical repeated artifact deliveries", () => { + const fixture = managedTranscript(); + expect( + fixture.grade([ + delivered(fixture.artifactEvidence), + delivered(fixture.artifactEvidence), + delivered(fixture.memberEvidence), + ]).factualCorrectness, + ).toBe("passed"); + }); + + it("requires companion delivery and rejects conflicting or failed artifact deliveries", () => { + const fixture = managedTranscript(); + const member = delivered(fixture.memberEvidence); + expect(fixture.grade([member]).factualCorrectness).toBe("failed"); + expect( + fixture.grade([ + delivered(fixture.artifactEvidence, { status: "failed" }), + member, + ]).factualCorrectness, + ).toBe("failed"); + const conflicting = { + ...fixture.artifactEvidence, + subject: + fixture.memberEvidence.subject === null + ? null + : { ...fixture.memberEvidence.subject, local_path: "/other.dll" }, + }; + expect( + fixture.grade([ + delivered(fixture.artifactEvidence), + delivered(conflicting), + member, + ]).factualCorrectness, + ).toBe("failed"); + }); + + it("binds the digest, rejects fabricated values and cites the supporting operation", () => { + const fixture = managedTranscript(); + expect( + fixture.grade(undefined, fixture.textFor(fixture.artifactEvidence)) + .factualCorrectness, + ).toBe("failed"); + expect( + fixture.grade( + undefined, + fixture.textFor(fixture.memberEvidence).replace('"Main"', '"Invented"'), + ).factualCorrectness, + ).toBe("failed"); + const other = createEvidence( + { path: "/targets/AgentEval.dll", sha256: "b".repeat(64), format: "pe" }, + provider, + { + operation: "inspect_managed_members", + parameters: {}, + result: fixture.result, + confidence: "observed", + authority: "shipped-artifact", + }, + ); + expect( + fixture.grade( + [delivered(fixture.artifactEvidence), delivered(other)], + fixture.textFor(other), + ).factualCorrectness, + ).toBe("failed"); + }); + + it("keeps golden names out of instructions and preserves routing-only scenarios", () => { + const instructions = agentFixtureAnswerInstructions("managed"); + expect(instructions).toContain("do not read target files directly"); + expect(instructions).not.toMatch(/AgentEval|Profile|Program|Main/u); + expect(agentFixtureAnswerInstructions("native")).toBe(""); + }); +}); + // Removing exact fixture comparison or accepted-source validation must break these regressions. describe("known-answer fixture grading", () => { it("passes closed claims against authenticated production MCP Evidence", () => { From af142f656cf448120ad66ebb545bc92e77fd2401 Mon Sep 17 00:00:00 2001 From: Mykola Dementiev Date: Tue, 13 Oct 2026 17:35:21 +0200 Subject: [PATCH 12/36] fix(mcp): report references a provider truncated in trace_feature (#2348) * test(mcp): expect trace_feature to report references the provider truncated A provider that reports a capped xrefs read makes the trace truncated with a residual unknown for that target. An explicit complete report and a provider that reports nothing leave the trace as before. * fix(mcp): report references a provider truncated in trace_feature Executions gain an optional flag for a capped xrefs read, set by the IDA adapter from the upstream cap. trace_feature marks the trace truncated with a residual unknown for that target only when the provider reports a truncation; a provider that reports nothing keeps its previous result. The trace_feature workflow profile is revised so earlier results are not replayed. * refactor(ida): report reference truncation whenever the runner observed an xrefs cap * test(mcp): cover session forwarding, per-target state, native reports and failures of the trace coverage * test(mcp): format the session forwarding test * test(process): format processCaptureLifecycle.test.ts after the merge * test(ida): declare the alignment each synthetic universal slice offset satisfies * fix(process): report no remaining time after a provider deadline fires Node timers can fire while performance.now() is still a fraction of a millisecond short of the monotonic deadline. ProviderStartupDeadline then reported interruption "timeout" and an aborted signal while remainingMs() returned ceil(0.x) = 1, so polling loops guarded by remainingMs() > 0 (Hopper and Ghidra startup) could run past an elapsed deadline, and the real-timer test "aborts a bounded wait when its real monotonic deadline expires" failed intermittently in CI (expected 1 to be 0). Once the deadline's own timer has classified a timeout, report zero remaining time. A deterministic regression test fires the timer with the monotonic clock 0.4 ms short of the deadline. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit dfdaa0ca74cc84a45dcb186422a0f51aa61bc427) * fix(process): keep monotonic deadline ownership after merge --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- src/ida/IdaNativeProtocol.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ida/IdaNativeProtocol.test.ts b/src/ida/IdaNativeProtocol.test.ts index 384da0420..466a09ad5 100644 --- a/src/ida/IdaNativeProtocol.test.ts +++ b/src/ida/IdaNativeProtocol.test.ts @@ -279,6 +279,11 @@ describe("native IDA provider observations", () => { ); if (!result.ok) throw result.error; expect(result.value.provider.name).toBe("IDA MCP adapter"); + if (operation === "xrefs") + expect( + result.value.referencesTruncated, + "the native producer's explicit cap flag is forwarded", + ).toBe(false); if (operation === "procedure_assembly") expect(result.value.result).toBe("0x1000: ret"); if (operation === "procedure_pseudo_code") { From b19f9e4d2dc0c4e863b4b2e567b3eaf9cf320983 Mon Sep 17 00:00:00 2001 From: hhang <17309584214@163.com> Date: Tue, 13 Oct 2026 23:36:35 +0800 Subject: [PATCH 13/36] fix(javascript): keep spread child argv counts unknown (#2748) * fix(javascript): keep spread child argv counts unknown * test(javascript): own runtime controls and tighten evidence assertions * test(apple): keep dyld slice regression portable --------- Co-authored-by: hhang <169027046+xu91102@users.noreply.github.com> --- .../javascriptSemanticChildProcesses.ts | 5 +- ...javascriptChildProcessArgumentsCli.test.ts | 72 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 tests/acceptance/applications/javascriptChildProcessArgumentsCli.test.ts diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.ts b/src/domain/javascript/javascriptSemanticChildProcesses.ts index 0fd0c9a35..1cffb5164 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.ts @@ -339,7 +339,10 @@ const spawnOptions = (candidate: SpawnCandidate): t.ObjectExpression | null => { const spawnArgvCount = (candidate: SpawnCandidate): number | null => { if (candidate.method === "exec") return null; const args = candidate.node.arguments[1]; - return t.isArrayExpression(args) ? args.elements.length : null; + return t.isArrayExpression(args) && + !args.elements.some((element) => t.isSpreadElement(element)) + ? args.elements.length + : null; }; const objectHasProperty = ( diff --git a/tests/acceptance/applications/javascriptChildProcessArgumentsCli.test.ts b/tests/acceptance/applications/javascriptChildProcessArgumentsCli.test.ts new file mode 100644 index 000000000..84bfca030 --- /dev/null +++ b/tests/acceptance/applications/javascriptChildProcessArgumentsCli.test.ts @@ -0,0 +1,72 @@ +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "keeps child argv counts unknown after spread through %s", + async (surface, { cli, processes }) => { + const root = await createTestTempDirectory("rea-child-argv-"); + await writeFixtureFiles(root, { + "entry.mjs": [ + 'import { spawn, execFile, fork } from "node:child_process";', + 'const args = ["a", "b"];', + 'spawn("spread-spawn", [...args]);', + 'execFile("spread-exec", ["prefix", ...args]);', + 'fork("spread-fork", [...args, "suffix"]);', + 'spawn("fixed", ["a", "b"]);', + 'execFile("empty", []);', + ].join("\n"), + "child.cjs": "console.log(process.argv.slice(2).length);", + }); + const args = ["a", "b"]; + const counts: number[] = []; + for (const values of [ + [...args], + ["prefix", ...args], + [...args, "suffix"], + ["a", "b"], + [], + ]) { + const control = await processes.run( + process.execPath, + [join(root, "child.cjs"), ...values], + { timeoutMs: 5_000 }, + ); + expect(control.exitCode, control.stderr).toBe(0); + counts.push(Number(control.stdout)); + } + expect(counts).toEqual([2, 3, 3, 2, 0]); + const graph = (await analyzeJavaScriptDirectory(surface, cli, root)) + .semantic_graph; + const children = graph.nodes.filter( + (node) => node.kind === "child-process", + ); + expect(children).toHaveLength(5); + for (const command of ["spread-spawn", "spread-exec", "spread-fork"]) { + const child = children.find( + (node) => node.properties.command === command, + ); + expect(child?.properties.argv_count, command).toBeNull(); + expect( + graph.relations.filter( + (relation) => + relation.relation === "supplies-argv" && + relation.target_node_id === child?.node_id, + ), + ).toEqual([]); + } + expect( + children.find((node) => node.properties.command === "fixed")?.properties + .argv_count, + ).toBe(2); + expect( + children.find((node) => node.properties.command === "empty")?.properties + .argv_count, + ).toBe(0); + }, +); From efc491ab4527b9e2084c6519f250472a1322b096 Mon Sep 17 00:00:00 2001 From: Tony Yu <49832190+ysntony@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:36:54 +0800 Subject: [PATCH 14/36] feat(setup): add Kimi Code client support (#2744) * feat(setup): add Kimi Code client support Registers rea in ~/.kimi-code/mcp.json (or $KIMI_CODE_HOME/mcp.json), preserving unrelated settings with a .rea.backup; registration is command-based stdio with no type field, startupTimeoutMs 30000 and toolTimeoutMs 120000 from MCP_STARTUP_POLICY. Strict JSON parsing: empty or whitespace-only files initialize; malformed JSON is rejected untouched with no backup; explicit transport other than stdio reports invalid. Bundled skill uses the shared ~/.agents/skills destination that Kimi Code discovers, installed and verified through the shared-skill flow. Validated by 19 Kimi-specific tests, 260 adjacent-client regressions passing, check:fast and docs:check, and real-client verification on Kimi Code 0.40.1 (173 tools discovered, real mcp__rea__analyze_javascript_application call succeeded). * fix(setup): preserve Kimi tool filters and strict JSON coverage --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/client-configuration.md | 12 + docs/installation.md | 5 +- .../ClientConfigurationDocument.ts | 14 +- src/application/ClientRegistrationStatus.ts | 34 +- src/application/SetupClientConfiguration.ts | 14 + src/application/SupportedClients.ts | 13 + src/mcpStartupPolicy.ts | 2 + .../filesystem/clientLifecycle.test.ts | 1 + .../kimiClientConfiguration.test.ts | 384 ++++++++++++++++++ tests/fixtures/clientEnvironment.ts | 1 + 10 files changed, 468 insertions(+), 12 deletions(-) create mode 100644 tests/boundary/filesystem/kimiClientConfiguration.test.ts diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 1a85dbdfa..409bc7f19 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -129,6 +129,18 @@ and Claude Code reports that autocompact is thrashing. To avoid this: The sizes above come from REA's own `tools/list`. A live Claude Code session against DeepSeek's API is unverified. +## Kimi Code + +Setup registers `rea` in `~/.kimi-code/mcp.json`, or +`$KIMI_CODE_HOME/mcp.json` when set, while preserving unrelated settings and +creating a `.rea.backup` before updating an existing file. Kimi Code infers +stdio from the `command` entry, so setup does not add a `type` field. The +registration includes `startupTimeoutMs = 30000` and `toolTimeoutMs = 120000`. +Kimi Code also discovers personal skills in `~/.agents/skills` and project-local +`.agents/skills`, so no separate skill root is required; setup still installs +and verifies the matching skill files through the normal shared-skill flow. +Start a new Kimi Code session to pick up MCP and skill changes. + ## Pi For [Pi](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/mcp.md), diff --git a/docs/installation.md b/docs/installation.md index 8cddafee0..a39471585 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -279,6 +279,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s | GitHub Copilot CLI | `copilot_cli` | | Command Code | `commandcode` | | Qwen Code | `qwen_code` | +| Kimi Code | `kimi` | | VS Code | `vscode` | | Grok Build | `grok_build` | | OMP | `omp` | @@ -290,8 +291,8 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s | ZCode | `zcode` | | DeepSeek Harness | `dsh` | -`dsh` is main-only until a release after 6.4.0. The registry check above does -not cover it. +`dsh` and `kimi` require a release containing them. The registry check above does +not cover them. See [client configuration and troubleshooting](client-configuration.md) for configuration paths, profiles, trust policies, and schema-limit workarounds. diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index 1b241a98b..a226f8cb6 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -110,9 +110,12 @@ const parseDocument = ( return requireObject(parseToml(text), "The configuration root"); // Pi loads `mcp.json` with a strict `JSON.parse` and no BOM tolerance, so // comments, trailing commas, a BOM, or an empty existing file must be - // rejected instead of registering into a document Pi cannot load. - if (format === "pi") + // rejected instead of registering into a document the client cannot load. + // Kimi also reads mcp.json with JSON.parse and an empty-file fallback. + if (format === "pi" || format === "kimi") { + if (format === "kimi" && text.trim().length === 0) return {}; return requireObject(JSON.parse(text), "The configuration root"); + } // An empty file, which some clients create before any server is added, // holds no settings to preserve. if (isEmptyClientConfigurationText(text)) return {}; @@ -1446,6 +1449,13 @@ export const clientRegistrationEntry = ( ? {} : { environment: { ...environment } }), }; + case "kimi": + // Kimi infers stdio from command; it uses `transport`, not `type`. + return { + command: executable, + args, + ...(Object.keys(environment).length === 0 ? {} : { env: environment }), + }; case "opencode_v2": // V2 connects servers unless `disabled` is true. return { diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index d107c5f32..56a3e81ba 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -66,6 +66,8 @@ const registrationSchema = z command: z.string().min(1), args: z.array(z.string()).default([]), startup_timeout_sec: z.number().positive().optional(), + startupTimeoutMs: z.number().positive().optional(), + toolTimeoutMs: z.number().positive().optional(), type: z.string().optional(), transport: z.string().optional(), serverName: z.string().optional(), @@ -258,6 +260,21 @@ const registrationAligned = ( MCP_STARTUP_POLICY.codexStartupTimeoutSeconds ) return false; + if ( + client.name === "kimi" && + (registration.startupTimeoutMs !== + MCP_STARTUP_POLICY.kimiStartupTimeoutMs || + registration.toolTimeoutMs !== MCP_STARTUP_POLICY.kimiToolTimeoutMs) + ) + return false; + if ( + (client.format === "kimi" || + client.format === "omp" || + client.format === "pi") && + registration.type !== undefined && + registration.type !== "stdio" + ) + return false; if (client.format === "vscode" && registration.type !== "stdio") return false; // A Harness registration only connects as the rea stdio server; another // server name or transport leaves the session without REA tools. @@ -266,13 +283,6 @@ const registrationAligned = ( (registration.serverName !== "rea" || registration.transport !== "stdio") ) return false; - // OMP and Pi infer stdio for a command entry without an explicit type. - if ( - (client.format === "omp" || client.format === "pi") && - registration.type !== undefined && - registration.type !== "stdio" - ) - return false; if ( client.format === "copilot_cli" && (registration.type !== "stdio" || @@ -331,6 +341,12 @@ const parseRegistration = ( }); } const registration = registrationSchema.parse(value); + if ( + client.format === "kimi" && + registration.transport !== undefined && + registration.transport !== "stdio" + ) + throw new TypeError("Expected an stdio registration"); if ( (client.format === "vscode" || client.format === "copilot_cli") && registration.type !== "stdio" @@ -339,7 +355,9 @@ const parseRegistration = ( if (client.format === "commandcode" && registration.transport !== "stdio") throw new TypeError("Expected an stdio registration"); if ( - (client.format === "omp" || client.format === "pi") && + (client.format === "kimi" || + client.format === "omp" || + client.format === "pi") && registration.type !== undefined && registration.type !== "stdio" ) diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index a868c6177..90f4bf4ca 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -704,6 +704,14 @@ const clientConfigurationDesired = ( launcher, environment.value, ); + const previous = parsed?.servers[PRODUCT_IDENTITY.mcpServerKey]; + if ( + client.name === "kimi" && + typeof previous === "object" && + previous !== null && + "enabledTools" in previous + ) + registration.enabledTools = previous.enabledTools; return { ...registration, ...(client.name === "codex" || client.name === "grok_build" @@ -711,5 +719,11 @@ const clientConfigurationDesired = ( startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds, } : {}), + ...(client.name === "kimi" + ? { + startupTimeoutMs: MCP_STARTUP_POLICY.kimiStartupTimeoutMs, + toolTimeoutMs: MCP_STARTUP_POLICY.kimiToolTimeoutMs, + } + : {}), }; }; diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 8407da4c0..55a651a52 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -45,6 +45,7 @@ export interface SetupClient { | "grok" | "omp" | "pi" + | "kimi" | "hermes" | "zcode" | "dsh" @@ -165,6 +166,10 @@ const claudeCodeMarkerDirectory = ({ home, env }: ClientPathContext): string => const codexDirectory = ({ home, env }: ClientPathContext): string => env.CODEX_HOME || join(home, ".codex"); +/** Kimi Code stores MCP declarations beside the rest of its home data. */ +const kimiDirectory = ({ home, env }: ClientPathContext): string => + env.KIMI_CODE_HOME || join(home, ".kimi-code"); + const qoderDirectory = ({ home, env }: ClientPathContext): string => env.QODER_CONFIG_DIR || join(home, ".qoder"); @@ -576,6 +581,14 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ markerPath: qwenCodeDirectory, format: "json", }, + { + name: "kimi", + displayName: "Kimi Code", + configPath: (context: ClientPathContext) => + join(kimiDirectory(context), "mcp.json"), + markerPath: kimiDirectory, + format: "kimi", + }, { name: "vscode", displayName: "VS Code", diff --git a/src/mcpStartupPolicy.ts b/src/mcpStartupPolicy.ts index 280a328c2..dc31114c1 100644 --- a/src/mcpStartupPolicy.ts +++ b/src/mcpStartupPolicy.ts @@ -7,6 +7,8 @@ */ export const MCP_STARTUP_POLICY = { codexStartupTimeoutSeconds: 30, + kimiStartupTimeoutMs: 30_000, + kimiToolTimeoutMs: 120_000, initializeBudgetMs: 15_000, toolsListBudgetMs: 5_000, firstCatalogBudgetMs: 20_000, diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 24f655507..0e4054ae6 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -64,6 +64,7 @@ describe("client configuration filesystem lifecycle", () => { "copilot_cli", "commandcode", "qwen_code", + "kimi", "vscode", "trae", "grok_build", diff --git a/tests/boundary/filesystem/kimiClientConfiguration.test.ts b/tests/boundary/filesystem/kimiClientConfiguration.test.ts new file mode 100644 index 000000000..e0580031a --- /dev/null +++ b/tests/boundary/filesystem/kimiClientConfiguration.test.ts @@ -0,0 +1,384 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { setupRegistrationCommand } from "../../../src/application/SetupHost.js"; +import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; +import { + clientSkillDirectories, + supportedClients, +} from "../../../src/application/SupportedClients.js"; +import { systemUninstallHost } from "../../../src/application/Uninstall.js"; +import { MCP_STARTUP_POLICY } from "../../../src/mcpStartupPolicy.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + clearClientLocationEnvironment, + NPX_REGISTRATION_COMMAND, +} from "../../fixtures/clientEnvironment.js"; + +const command = NPX_REGISTRATION_COMMAND; + +beforeEach(clearClientLocationEnvironment); +afterEach(() => vi.unstubAllEnvs()); + +const kimiPath = ( + home: string, + platform: NodeJS.Platform, + env: Parameters[2] = {}, +) => supportedClients(home, platform, env).find(({ name }) => name === "kimi"); + +const exists = async (path: string): Promise => { + try { + await access(path); + return true; + } catch { + return false; + } +}; + +const expectedRegistration = (launcher: readonly string[]) => ({ + command: launcher[0], + args: launcher.slice(1), + startupTimeoutMs: MCP_STARTUP_POLICY.kimiStartupTimeoutMs, + toolTimeoutMs: MCP_STARTUP_POLICY.kimiToolTimeoutMs, +}); + +describe("Kimi Code configuration paths and skills", () => { + it.each([ + [undefined, join("/home/a/.kimi-code")], + ["", join("/home/a/.kimi-code")], + [join("/tmp", "isolated-kimi"), join("/tmp", "isolated-kimi")], + ])("resolves KIMI_CODE_HOME for %j", (override, directory) => { + const client = kimiPath( + "/home/a", + "linux", + override === undefined ? {} : { KIMI_CODE_HOME: override }, + ); + expect(client).toEqual({ + name: "kimi", + displayName: "Kimi Code", + configPath: join(directory, "mcp.json"), + markerPath: directory, + format: "kimi", + }); + }); + + it("uses the existing shared skill destination without a separate skill root", () => { + expect(clientSkillDirectories("/home/a", ["kimi"])).toEqual([ + { client: "shared", directory: "/home/a/.agents/skills" }, + ]); + }); +}); + +describe("Kimi Code registration lifecycle", () => { + const kimiClient = async () => { + const home = await createTestTempDirectory("rea-kimi-"); + const client = kimiPath(home, process.platform, {}); + if (client?.markerPath === undefined) + throw new Error("missing Kimi Code client"); + await mkdir(client.markerPath, { recursive: true }); + return { home, client }; + }; + + it("preserves the caller's enabledTools selection during refresh", async () => { + const { client } = await kimiClient(); + const enabledTools = ["binary_session", "analyze_javascript_application"]; + await writeFile( + client.configPath, + JSON.stringify({ + mcpServers: { + rea: { command: "old-rea", args: ["mcp"], enabledTools }, + }, + }), + ); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + expect( + JSON.parse(await readFile(client.configPath, "utf8")).mcpServers.rea, + ).toEqual({ + ...expectedRegistration(command), + enabledTools, + }); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + }); + + it("registers without type, preserves unrelated settings, backs up, and is idempotent", async () => { + const { home, client } = await kimiClient(); + const original = `{ + "customSetting": { "enabled": true }, + "mcpServers": { + "other": { "command": "other-server", "startupTimeoutMs": 5000 } + } +} +`; + await writeFile(client.configPath, original); + + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "configured", + backupPath: `${client.configPath}.rea.backup`, + }); + const text = await readFile(client.configPath, "utf8"); + expect(JSON.parse(text)).toEqual({ + customSetting: { enabled: true }, + mcpServers: { + other: { command: "other-server", startupTimeoutMs: 5000 }, + rea: expectedRegistration(command), + }, + }); + expect(text).not.toContain('"type"'); + expect(text).not.toContain('"transport"'); + expect(await readFile(`${client.configPath}.rea.backup`, "utf8")).toBe( + original, + ); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect(await readFile(client.configPath, "utf8")).toBe(text); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + environment: {}, + }) + ).find(({ client: name }) => name === "kimi"), + ).toMatchObject({ state: "aligned", command }); + + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + expect(JSON.parse(await readFile(client.configPath, "utf8"))).toEqual({ + customSetting: { enabled: true }, + mcpServers: { + other: { command: "other-server", startupTimeoutMs: 5000 }, + }, + }); + }); +}); + +describe("Kimi Code package launchers", () => { + const kimiClient = async () => { + const home = await createTestTempDirectory("rea-kimi-launcher-"); + const client = kimiPath(home, process.platform, {}); + if (client?.markerPath === undefined) + throw new Error("missing Kimi Code client"); + await mkdir(client.markerPath, { recursive: true }); + return { home, client } as const; + }; + + it.each([ + ["npx", "linux"], + ["bunx", "linux"], + ["npx", "win32"], + ["bunx", "win32"], + ] as const)( + "keeps the %s launcher aligned on %s", + async (runner, platform) => { + const { home, client } = await kimiClient(); + const launcher = setupRegistrationCommand(platform, runner); + expect( + (await configureClientConfiguration(client, {}, launcher)).status, + ).toBe("configured"); + expect( + JSON.parse(await readFile(client.configPath, "utf8")).mcpServers.rea, + ).toEqual(expectedRegistration(launcher)); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform, + environment: {}, + }) + ).find(({ client: name }) => name === "kimi"), + ).toMatchObject({ state: "aligned", command: launcher }); + expect(await configureClientConfiguration(client, {}, launcher)).toEqual({ + status: "unchanged", + }); + }, + ); +}); + +describe("Kimi Code transport validation", () => { + const kimiClient = async () => { + const home = await createTestTempDirectory("rea-kimi-validation-"); + const client = kimiPath(home, process.platform, {}); + if (client?.markerPath === undefined) + throw new Error("missing Kimi Code client"); + await mkdir(client.markerPath, { recursive: true }); + return { home, client }; + }; + + it("creates a registration in a zero-byte file and accepts whitespace-only files", async () => { + const { home, client } = await kimiClient(); + for (const original of ["", " \n\t"]) { + await writeFile(client.configPath, original); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + expect( + JSON.parse(await readFile(client.configPath, "utf8")).mcpServers.rea, + ).toEqual(expectedRegistration(command)); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect( + (await systemUninstallHost(home).removeClient(client)).status, + ).toBe("removed"); + } + }); + + it.each([ + '{"mcpServers": {', + '{/* comment */ "mcpServers": {}}', + '{"mcpServers": {},}', + '\uFEFF{"mcpServers": {}}', + ])("rejects invalid strict JSON %j without writes", async (original) => { + const { client } = await kimiClient(); + await writeFile(client.configPath, original); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "failed", + reason: "readback", + }); + expect(await readFile(client.configPath, "utf8")).toBe(original); + expect(await exists(`${client.configPath}.rea.backup`)).toBe(false); + }); + + it("accepts explicit stdio transport without causing repeated rewrites", async () => { + const { home, client } = await kimiClient(); + await writeFile( + client.configPath, + JSON.stringify({ + mcpServers: { + rea: { + transport: "stdio", + ...expectedRegistration(command), + }, + }, + }), + ); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + environment: {}, + }) + ).find(({ client: name }) => name === "kimi"), + ).toMatchObject({ state: "aligned", command }); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + environment: {}, + }) + ).find(({ client: name }) => name === "kimi"), + ).toMatchObject({ state: "aligned", command }); + }); + + it.each(["sse"])( + "rejects explicit transport %j instead of silently accepting another protocol", + async (transport) => { + const { home, client } = await kimiClient(); + await writeFile( + client.configPath, + JSON.stringify({ + mcpServers: { + rea: { + transport, + ...expectedRegistration(command), + }, + }, + }), + ); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + environment: {}, + }) + ).find(({ client: name }) => name === "kimi"), + ).toMatchObject({ state: "invalid", command: [] }); + }, + ); +}); + +describe("Kimi Code timeout validation", () => { + const kimiClient = async () => { + const home = await createTestTempDirectory("rea-kimi-timeout-"); + const client = kimiPath(home, process.platform, {}); + if (client?.markerPath === undefined) + throw new Error("missing Kimi Code client"); + await mkdir(client.markerPath, { recursive: true }); + return { home, client } as const; + }; + + it.each([ + ["startupTimeoutMs", 1], + ["toolTimeoutMs", 1], + ["startupTimeoutMs", undefined], + ["toolTimeoutMs", undefined], + ] as const)("reports stale when %s is %j", async (field, value) => { + const { home, client } = await kimiClient(); + const registration = { + ...expectedRegistration(command), + [field]: value, + }; + await writeFile( + client.configPath, + JSON.stringify({ mcpServers: { rea: registration } }), + ); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + environment: {}, + }) + ).find(({ client: name }) => name === "kimi"), + ).toMatchObject({ state: "stale" }); + }); + + it("uses a direct installed REA command without requiring a package runner", async () => { + const { home, client } = await kimiClient(); + const entry = join(home, "scripts", "rea.mjs"); + const launcher = [process.execPath, entry, "mcp"]; + expect( + (await configureClientConfiguration(client, {}, launcher)).status, + ).toBe("configured"); + expect( + ( + await readClientRegistrationStatuses(home, entry, { + environment: {}, + }) + ).find(({ client: name }) => name === "kimi"), + ).toMatchObject({ state: "aligned", command: launcher }); + }); + + it("leaves a foreign rea-key registration out of aligned state", async () => { + const { home, client } = await kimiClient(); + await writeFile( + client.configPath, + JSON.stringify({ + mcpServers: { + rea: { + ...expectedRegistration(command), + command: "another-package", + args: ["mcp"], + }, + }, + }), + ); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + environment: {}, + }) + ).find(({ client: name }) => name === "kimi"), + ).toMatchObject({ state: "stale" }); + }); +}); diff --git a/tests/fixtures/clientEnvironment.ts b/tests/fixtures/clientEnvironment.ts index 9825caeec..5e2ba94c2 100644 --- a/tests/fixtures/clientEnvironment.ts +++ b/tests/fixtures/clientEnvironment.ts @@ -27,6 +27,7 @@ export const CLIENT_LOCATION_ENVIRONMENT = [ "GEMINI_CLI_SYSTEM_DEFAULTS_PATH", "HERMES_DATA_DIR_SUFFIX", "HERMES_HOME", + "KIMI_CODE_HOME", "LOCALAPPDATA", "OMP_PROFILE", "OPENCODE_CONFIG", From 70611713e140a86201cdef659181012cbdd17f4d Mon Sep 17 00:00:00 2001 From: Faheem Ahmad Date: Tue, 13 Oct 2026 08:37:45 -0700 Subject: [PATCH 16/36] fix(targets): compare the Info.plist bplist prefix without ASCII folding (#2100) * fix(targets): compare the Info.plist bplist prefix without ASCII folding The six-byte prefix check used an encoding that maps high-bit bytes onto bplist, so a non-binary property list could be sent to the binary decoder. Co-authored-by: Cursor * fix(targets): satisfy the strict index check in the plist prefix test Co-authored-by: Cursor --------- Co-authored-by: Cursor Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- src/application/AppBundleExecutable.test.ts | 34 +++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/application/AppBundleExecutable.test.ts diff --git a/src/application/AppBundleExecutable.test.ts b/src/application/AppBundleExecutable.test.ts new file mode 100644 index 000000000..942a00112 --- /dev/null +++ b/src/application/AppBundleExecutable.test.ts @@ -0,0 +1,34 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { resolveAppBundleExecutable } from "./AppBundleExecutable.js"; + +describe("app bundle Info.plist magic", () => { + it("does not decode a high-bit fold of bplist as a binary Info.plist", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-bplist-fold-")); + try { + const bundle = join(root, "Fixture.app"); + const plistPath = join(bundle, "Contents", "Info.plist"); + await mkdir(join(bundle, "Contents"), { recursive: true }); + const plist = Buffer.from("bplist00"); + plist[0] = (plist[0] ?? 0) | 0x80; + await writeFile(plistPath, plist); + let decoded = false; + const result = await resolveAppBundleExecutable(bundle, { + readFile: async () => plist, + realpath: async (path) => path, + decodeBinaryPlist: async () => { + decoded = true; + return "Fixture"; + }, + }); + expect(decoded).toBe(false); + expect(result.ok).toBe(false); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); From 2a78763e2070d71818a44bd419df549e13ce203c Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Tue, 13 Oct 2026 08:41:45 -0700 Subject: [PATCH 17/36] feat(native): search analyzed memory for masked byte patterns (#2341) * feat(native): search analyzed memory for masked byte patterns The diff adds search_bytes to the CLI, the shared MCP contract, Ghidra inventory, and the headless bridge, and advertises the same operation on Hopper as unavailable until that provider is verified. Patterns are hexadecimal byte pairs with optional ?? wildcards and an optional equal-length bit mask; the TypeScript boundary and the bridge both require a nonempty pattern, equal lengths, and at least one constrained bit. Ghidra walks initialized blocks in address order within inclusive bounds, keeps overlapping matches inside a single block, and returns each hit with its segment, unique source file offset, and containing procedure, continuing through a cursor bound to the target, profile, pattern, mask, and bounds. Each page reports the inclusive ranges examined and the skipped uninitialized blocks; cancellation returns a typed error and discards the active Ghidra database. address_to_file_offset now uses the same unique-offset helper, which rejects a mapping that is missing, ambiguous, or larger than a safe integer. * fix(native): limit unbounded byte search to loaded memory Without start or end, search_bytes walked every Ghidra block, including non-loaded file parts such as .symtab and overlay blocks, which the docs call out of scope. Unbounded searches now skip those blocks and list them in searched.skipped_not_loaded; explicit bounds in their address space still search them. Document that a match must end at or before end. * fix(native): preserve implicit address spaces in range consumers * fix(native): reject present malformed byte search state --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- bridge/ghidra/ReaGhidraBridge.java | 174 ++++++++++- docs/ghidra-provider.md | 29 ++ docs/native-investigation.md | 14 + docs/user-visible-errors.csv | 3 + scripts/verify-real-ghidra-raw.mjs | 88 ++++++ src/application/DirectAnalysis.ts | 1 + src/cli/coreNativeCommands.ts | 40 +++ src/cliCommandNames.ts | 1 + src/contracts/hopperMemoryContracts.ts | 19 ++ src/contracts/toolContractExamples.ts | 1 + src/contracts/toolEffects.ts | 1 + src/contracts/toolOutputSchemaGroups.ts | 2 + src/domain/hopperValues.ts | 19 +- src/domain/native/addressCoordinate.ts | 11 + src/domain/native/bytePattern.test.ts | 33 +++ src/domain/native/bytePattern.ts | 125 ++++++++ src/ghidra/GhidraInventoryValues.ts | 34 +++ src/ghidra/GhidraProviderCapabilities.ts | 5 + src/hopper/HopperProviderCapabilities.ts | 10 +- tests/boundary/mcp/ghidraInventoryMcp.test.ts | 272 +++++++++++++++++- tests/boundary/mcp/ghidraMcpHarness.ts | 4 +- tests/boundary/mcp/officialArguments.test.ts | 24 +- tests/boundary/mcp/officialTools.test.ts | 67 +++++ tests/fixtures/fakeGhidra.mjs | 39 ++- 24 files changed, 980 insertions(+), 36 deletions(-) create mode 100644 src/domain/native/addressCoordinate.ts create mode 100644 src/domain/native/bytePattern.test.ts create mode 100644 src/domain/native/bytePattern.ts diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 09df6ac1d..84e809ccb 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -142,6 +142,7 @@ public final class ReaGhidraBridge extends HeadlessScript { "set_addresses_names", "inspect_native_load_image", "read_bytes", + "search_bytes", "address_to_file_offset", "ping", "shutdown", @@ -379,6 +380,7 @@ public final class ReaGhidraBridge extends HeadlessScript { return switch (request.method) { case "inspect_native_load_image" -> inspectNativeLoadImage(request.params); case "read_bytes" -> readMemoryBytes(request.params); + case "search_bytes" -> searchMemoryBytes(request.params, descriptor); case "address_to_file_offset" -> addressToFileOffset(request.params); case "address_name" -> addressName(request.params); case "list_documents" -> listDocuments(request.params); @@ -685,6 +687,160 @@ public final class ReaGhidraBridge extends HeadlessScript { return result; } + private JsonObject searchMemoryBytes(JsonObject params, SessionDescriptor descriptor) throws Exception { + // mask, start, end, and cursor are omitted when absent; JSON encoding drops those keys. + Set keys = params.keySet(); + if (!keys.containsAll(Set.of("document", "pattern", "limit")) || + !Set.of("document", "pattern", "mask", "start", "end", "limit", "cursor").containsAll(keys)) + throw new RequestFailure("invalid_request", "Bridge request fields are invalid"); + requireDocument(params); + // Public input validation owns the grammar; validate again at the socket boundary. + String pattern = requireString(params, "pattern").replaceAll("[\\s\\p{Z}\\uFEFF]", ""); + String mask = params.has("mask") ? requireString(params, "mask") : null; + if (mask != null) mask = mask.replaceAll("[\\s\\p{Z}\\uFEFF]", ""); + if (pattern.isEmpty() || pattern.length() % 2 != 0 || + (mask != null && mask.length() != pattern.length())) + throw new RequestFailure("invalid_request", "Mask length must equal a nonempty byte pattern length"); + byte[] bytes = new byte[pattern.length() / 2]; + byte[] masks = new byte[bytes.length]; + boolean constrained = false; + for (int index = 0; index < bytes.length; index++) { + monitor.checkCancelled(); + String pair = pattern.substring(index * 2, index * 2 + 2); + if ((!pair.equals("??") && !isHexadecimal(pair, 0, 2)) || + (mask != null && !isHexadecimal(mask, index * 2, index * 2 + 2))) + throw new RequestFailure("invalid_request", "Use hexadecimal byte pairs or ?? wildcards"); + int selected = mask == null ? 255 : Integer.parseInt(mask.substring(index * 2, index * 2 + 2), 16); + bytes[index] = (byte) (pair.equals("??") ? 0 : Integer.parseInt(pair, 16)); + masks[index] = (byte) (pair.equals("??") ? 0 : selected); + constrained |= masks[index] != 0; + } + if (!constrained) throw new RequestFailure("invalid_request", "Pattern must constrain at least one bit"); + int limit = requireBoundedInteger(params, "limit", 1, 1000); + Address start = params.has("start") ? requireAddress(params, "start") : null; + Address end = params.has("end") ? requireAddress(params, "end") : null; + if (start != null && end != null && + (!start.getAddressSpace().equals(end.getAddressSpace()) || start.compareTo(end) > 0)) + throw new RequestFailure("invalid_request", "Search bounds must be ordered in one address space"); + JsonArray identity = new JsonArray(); + for (String value : new String[] { descriptor.targetSha256, descriptor.profileDigest, + bytesHex(bytes), bytesHex(masks), start == null ? "" : canonicalAddress(start), + end == null ? "" : canonicalAddress(end), optionalString(params, "document") }) identity.add(value); + String binding = bytesHex(MessageDigest.getInstance("SHA-256").digest(identity.toString().getBytes(StandardCharsets.UTF_8))); + String cursor = params.has("cursor") ? requireString(params, "cursor") : null; + Address resume = null; + if (cursor != null) { + if (!cursor.startsWith(binding + ":")) + throw new RequestFailure("invalid_request", "Byte search cursor does not match the target and query"); + resume = parseReaAddress(cursor.substring(binding.length() + 1)); + MemoryBlock resumedBlock = currentProgram.getMemory().getBlock(resume); + Address bound = start == null ? end : start; + if (resumedBlock == null || !resumedBlock.isInitialized() || + (bound != null && !bound.getAddressSpace().equals(resume.getAddressSpace())) || + (start != null && resume.compareTo(start) < 0) || + (end != null && resume.compareTo(end) > 0)) + throw new RequestFailure("invalid_request", "Byte search cursor lies outside initialized query memory"); + } + List ordered = new ArrayList<>(List.of(currentProgram.getMemory().getBlocks())); + ordered.sort(Comparator.comparing(MemoryBlock::getStart)); + JsonArray matches = new JsonArray(); + JsonArray blocks = new JsonArray(); + JsonArray skipped = new JsonArray(); + JsonArray notLoaded = new JsonArray(); + long covered = 0; + String next = null; + for (MemoryBlock block : ordered) { + monitor.checkCancelled(); + Address low = block.getStart(); + Address high = block.getEnd(); + Address bound = start == null ? end : start; + if (bound != null && !bound.getAddressSpace().equals(low.getAddressSpace())) continue; + // Without bounds, search loaded memory only. Non-loaded file parts + // (such as .symtab) and overlays stay reachable through explicit bounds. + if (bound == null && (!block.isLoaded() || block.isOverlay())) { + notLoaded.add(byteSearchCoverage(block, low, high)); + continue; + } + if (start != null && low.compareTo(start) < 0) low = start; + if (end != null && high.compareTo(end) > 0) high = end; + if (low.compareTo(high) > 0) continue; + if (!block.isInitialized()) { + skipped.add(byteSearchCoverage(block, low, high)); + continue; + } + if (next != null) continue; + if (resume != null && low.compareTo(resume) < 0) low = resume; + if (low.compareTo(high) > 0 || !low.getAddressSpace().equals(high.getAddressSpace())) continue; + Address at = low; + Address examinedEnd = high; + while (at != null && at.compareTo(high) <= 0 && high.subtract(at) >= bytes.length - 1L) { + monitor.checkCancelled(); + // Ghidra's end address bounds candidate starts, not the final matched byte. + Address found = currentProgram.getMemory().findBytes(at, high.subtract(bytes.length - 1L), bytes, masks, true, monitor); + monitor.checkCancelled(); + if (found == null || high.subtract(found) < bytes.length - 1L) break; + if (matches.size() == limit) { + next = binding + ":" + canonicalAddress(found); + examinedEnd = found.addNoWrap(bytes.length - 1L); + break; + } + JsonObject match = new JsonObject(); + match.addProperty("address", canonicalAddress(found)); + match.addProperty("segment", block.getName()); + Long offset = targetFileOffset(block, found).offset(); + match.add("file_offset", offset == null ? JsonNull.INSTANCE : GSON.toJsonTree(offset)); + Function function = currentProgram.getFunctionManager().getFunctionContaining(found); + JsonObject procedure = new JsonObject(); + if (function != null) { + procedure.addProperty("address", canonicalAddress(function.getEntryPoint())); + procedure.addProperty("name", function.getName(true)); + } + match.add("procedure", function == null ? JsonNull.INSTANCE : procedure); + matches.add(match); + at = found.next(); + } + JsonObject coverage = byteSearchCoverage(block, low, examinedEnd); + covered = Math.addExact(covered, coverage.get("bytes").getAsLong()); + blocks.add(coverage); + } + JsonObject searched = new JsonObject(); + searched.add("blocks", blocks); + searched.addProperty("bytes", covered); + searched.add("skipped_uninitialized", skipped); + searched.add("skipped_not_loaded", notLoaded); + JsonObject result = new JsonObject(); + result.add("matches", matches); + result.add("searched", searched); + result.addProperty("complete", next == null); + result.add("next_cursor", next == null ? JsonNull.INSTANCE : GSON.toJsonTree(next)); + return result; + } + + private static JsonObject byteSearchCoverage(MemoryBlock block, Address low, Address high) { + JsonObject result = new JsonObject(); + result.addProperty("segment", block.getName()); + result.addProperty("start", canonicalAddress(low)); + result.addProperty("end", canonicalAddress(high)); + result.addProperty("bytes", high.subtract(low) + 1); + return result; + } + + private record TargetFileOffset(Long offset, int mappings) {} + + private TargetFileOffset targetFileOffset(MemoryBlock block, Address address) throws Exception { + Long offset = null; + int mappings = 0; + for (MemoryBlockSourceInfo source : block.getSourceInfos()) { + if (!source.contains(address) || source.getFileBytes().isEmpty() || + !isTargetSource(source.getFileBytes().get())) continue; + long candidate = source.getFileBytesOffset(address); + if (candidate < 0) continue; + offset = candidate; + mappings++; + } + return new TargetFileOffset(mappings == 1 && offset <= 9007199254740991L ? offset : null, mappings); + } + private JsonObject addressToFileOffset(JsonObject params) throws Exception { requireKeys(params, Set.of("document", "address")); requireDocument(params); @@ -692,18 +848,12 @@ public final class ReaGhidraBridge extends HeadlessScript { MemoryBlock block = currentProgram.getMemory().getBlock(address); if (block == null || !block.isInitialized()) throw new RequestFailure("invalid_request", "Address has no initialized file-backed memory: " + canonicalAddress(address)); - Long fileOffset = null; - int mappings = 0; - for (MemoryBlockSourceInfo source : block.getSourceInfos()) { - if (!source.contains(address) || source.getFileBytes().isEmpty() || - !isTargetSource(source.getFileBytes().get())) continue; - long offset = source.getFileBytesOffset(address); - if (offset < 0) continue; - fileOffset = offset; - mappings++; - } - if (mappings != 1) - throw new RequestFailure("invalid_request", "Address has " + mappings + " authoritative target-file mappings: " + canonicalAddress(address)); + TargetFileOffset mapping = targetFileOffset(block, address); + if (mapping.mappings() != 1) + throw new RequestFailure("invalid_request", "Address has " + mapping.mappings() + " authoritative target-file mappings: " + canonicalAddress(address)); + Long fileOffset = mapping.offset(); + if (fileOffset == null) + throw new RequestFailure("invalid_request", "Address lacks a safe file offset: " + canonicalAddress(address)); JsonObject result = new JsonObject(); result.addProperty("address", canonicalAddress(address)); result.addProperty("file_offset", fileOffset); diff --git a/docs/ghidra-provider.md b/docs/ghidra-provider.md index 0876b8ad5..fd23dcd07 100644 --- a/docs/ghidra-provider.md +++ b/docs/ghidra-provider.md @@ -198,3 +198,32 @@ It accepts the same architecture and DLL flags and verifies the selected PE role in the analysis profile. All lanes require matching native controls; package startup alone establishes only package compatibility. + +## Masked byte search + +`search_bytes` (`rea search-bytes PATH '48 8B ?? ?? 89' --provider ghidra`) +searches initialized analyzed memory, including loader fixups. Supply hexadecimal +byte pairs, with `??` for any byte. An optional equal-length hexadecimal `mask` +selects bits: `pattern: "a0"`, `mask: "f0"` matches bytes from `a0` through `af`. +Wildcards remain unconstrained even with an explicit mask. At least one bit +must be constrained. + +Optional `start` and `end` addresses are inclusive and select one address space. +A match must end at or before `end`, so `end` set inside a match excludes it. +Without bounds, loaded memory blocks are eligible, in Ghidra address order. +Blocks that are not loaded (file parts such as `.symtab`) and overlay blocks are +listed in `searched.skipped_not_loaded` and are not searched; pass `start` or +`end` in their address space to search them. +Overlapping matches are included; matches never cross block boundaries. +Each match reports its address, segment, unique source file offset (or null), +and containing procedure (or null). + +Pages default to 100 matches and accept 1 to 1000. Pass `next_cursor` as `cursor` +with the same target, profile, pattern, mask and bounds to continue. `complete` +means no further matches remain. `searched.blocks` and `searched.bytes` describe +the inclusive memory ranges examined for this page, including lookahead for the +next match. Coverage can overlap between pages; do not sum it as unique memory. +`searched.skipped_uninitialized` lists eligible uninitialized blocks on every +page. These blocks are never searched. Cancellation returns a typed error, +discards the active Ghidra database, and does not return a successful partial page. +Hopper byte search remains unavailable until its implementation is verified. diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 5a2fcbd31..f963fd4d8 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -549,3 +549,17 @@ Value projection preserves serialized node kinds, IDs, references and pagination Exact and ambiguous value counts describe emitted root/object value instances; a root and an object may contain the same value. Archive digests identify the original bytes. + +## Locate a byte signature + +Use `search_bytes` after opening a target with Ghidra to locate a known constant +or instruction sequence. For example, `{"pattern":"48 8B ?? ?? 89","limit":100}` +returns ordered addresses with segments, source file offsets and containing +procedures. The CLI equivalent is +`rea search-bytes ./program '48 8B ?? ?? 89' --provider ghidra`. +Use `start`/`end` to constrain the inclusive address window, and continue with +`next_cursor` until `complete` is true. Inspect `searched` for page coverage and +skipped uninitialized blocks. A missing match does not establish absence from +uninitialized memory or across block boundaries. See +[Ghidra byte search](ghidra-provider.md#masked-byte-search) for mask and coverage +semantics. This query never executes or modifies target bytes. diff --git a/docs/user-visible-errors.csv b/docs/user-visible-errors.csv index a9dd0d05d..02237dcdc 100644 --- a/docs/user-visible-errors.csv +++ b/docs/user-visible-errors.csv @@ -1,4 +1,7 @@ harm_rank,location,trigger,current_copy,user_risk,proposed_replacement,implementation_status,verification_result +5,src/domain/native/bytePattern.ts,Unconstrained byte signature,Pattern must constrain at least one bit,Low risk; identifies input correction,Keep current copy,implemented,parser and MCP validation tests +5,src/domain/native/bytePattern.ts,Unequal mask length,Mask length must equal pattern length,Low risk; identifies input correction,Keep current copy,implemented,parser and MCP validation tests +5,bridge/ghidra/ReaGhidraBridge.java:searchMemoryBytes,Cursor reused with another query,Byte search cursor does not match the target and query,Low risk; identifies continuation mismatch,Keep current copy,implemented,real-provider verification required 1,src/domain/errors.ts:AnalysisCapabilityUnavailableError,Requested analysis unavailable,"Provider {providerId} cannot execute {operation}: {reason}",Exposes provider and internal operation/reason,"This analysis is unavailable for the current target. Choose another analysis or target.",implemented,focused assertions passed; full `npm run check` passed 2,src/domain/errors.ts:ProviderAdapterError,Provider adapter fails,"Provider {providerId} adapter failed during {operation}",Exposes provider and implementation boundary,"Analysis could not complete. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed.",implemented,focused assertions passed; full `npm run check` passed 3,src/domain/errors.ts:HopperRemoteError,Remote analysis request fails,"Hopper request failed ({code}): {safeMessage}",Exposes provider name code and upstream text,"Analysis could not complete. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed.",implemented,focused assertions passed; full `npm run check` passed diff --git a/scripts/verify-real-ghidra-raw.mjs b/scripts/verify-real-ghidra-raw.mjs index befba80e0..4341b5cfd 100644 --- a/scripts/verify-real-ghidra-raw.mjs +++ b/scripts/verify-real-ghidra-raw.mjs @@ -114,6 +114,22 @@ try { length: bytes.length, }); assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex")); + const signature = await call("search_bytes", { + pattern: "1e ?? 2f e1", + limit: 1000, + }); + assert.equal(signature.normalized_result.complete, true); + assert.equal(signature.normalized_result.next_cursor, null); + assert.equal(signature.normalized_result.searched.bytes, bytes.length); + assert.deepEqual( + signature.normalized_result.matches.map((match) => match.address), + [entry], + ); + assert.equal(signature.normalized_result.matches[0].file_offset, 0); + assert.equal( + signature.normalized_result.matches[0].procedure.address, + entry, + ); const procedures = await call("list_procedures"); assert.ok( procedures.normalized_result.some( @@ -211,6 +227,69 @@ try { return_42: true, }); } + const repeatedPath = join(workspace, "signatures.bin"); + const repeatedBytes = Buffer.from("b82a000000c3".repeat(1005), "hex"); + const repeatedSha256 = createHash("sha256") + .update(repeatedBytes) + .digest("hex"); + await writeFile(repeatedPath, repeatedBytes); + await call("open_binary", { + path: repeatedPath, + format: "raw-binary", + architecture: "x86_64", + entry_point: "0x1000", + }); + // Import/analysis is complete before timing the first uncached search. + await call("read_bytes", { address: "0x1000", length: 6 }, repeatedSha256); + const started = performance.now(); + const page = await call( + "search_bytes", + { pattern: "b8 ?? 00 00 00 c3", limit: 1000 }, + repeatedSha256, + ); + const searchElapsedMs = performance.now() - started; + assert.equal(page.normalized_result.matches.length, 1000); + assert.equal(page.normalized_result.complete, false); + const rest = await call( + "search_bytes", + { + pattern: "b8 ?? 00 00 00 c3", + limit: 1000, + cursor: page.normalized_result.next_cursor, + }, + repeatedSha256, + ); + assert.equal(rest.normalized_result.matches.length, 5); + assert.equal( + rest.normalized_result.matches[0].address, + `0x${(0x1000 + 6000).toString(16)}`, + ); + assert.equal(rest.normalized_result.complete, true); + const masked = await call( + "search_bytes", + { pattern: "bf", mask: "f0", start: "0x1000", end: "0x1005" }, + repeatedSha256, + ); + assert.deepEqual( + masked.normalized_result.matches.map((match) => match.address), + ["0x1000"], + ); + const overlapping = await call( + "search_bytes", + { pattern: "00 00", start: "0x1000", end: "0x1005" }, + repeatedSha256, + ); + assert.deepEqual( + overlapping.normalized_result.matches.map((match) => match.address), + ["0x1002", "0x1003"], + ); + const wrongCursor = await client.callTool({ + name: "search_bytes", + arguments: { pattern: "c3", cursor: page.normalized_result.next_cursor }, + }); + assert.equal(wrongCursor.isError, true); + await call("close_binary"); + assert.deepEqual(await readFile(repeatedPath), repeatedBytes); report = { ok: true, mocked: false, @@ -231,6 +310,15 @@ try { load_images_verified: true, original_unchanged: true, x86_targets: x86Targets, + byte_search: { + matches: 1005, + page_size: 1000, + first_search_elapsed_ms: searchElapsedMs, + timing_scope: "MCP request after import; first uncached search", + token_usage: "unmeasured", + process_family_cpu_time: "unmeasured", + peak_process_family_memory: "unmeasured", + }, }; } catch (error) { if (stderr.trim()) process.stderr.write(stderr); diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 0bdb69864..8a7874598 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -60,6 +60,7 @@ type DirectAnalysisTool = | "annotate_native_function" | "inspect_native_load_image" | "read_bytes" + | "search_bytes" | "address_to_file_offset" | "binary_overview" | "procedure_pseudo_code" diff --git a/src/cli/coreNativeCommands.ts b/src/cli/coreNativeCommands.ts index 016eedbc0..80d07187f 100644 --- a/src/cli/coreNativeCommands.ts +++ b/src/cli/coreNativeCommands.ts @@ -17,6 +17,46 @@ export const registerCoreNativeCommands = ( runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], ): void => { registerAnnotationCommand(cli, logger, runDirectAnalysis); + cli.command(CLI_COMMANDS.searchBytes, { + description: "Find masked byte patterns in initialized provider memory", + args: z.object({ + path: z.string().describe("Local executable path"), + pattern: z.string().describe("Hexadecimal byte pairs with ?? wildcards"), + }), + options: z.object({ + mask: z.string().optional().describe("Equal-length hexadecimal bit mask"), + start: z.string().optional().describe("Inclusive start address"), + end: z.string().optional().describe("Inclusive end address"), + cursor: z + .string() + .optional() + .describe("Continuation from the same query's next_cursor"), + limit: z + .number() + .int() + .min(1) + .max(1000) + .default(100) + .describe("Maximum matches on this page"), + ...nativeOpenOptionFields, + }), + run: ({ args, options }) => + logCliCommand(logger, CLI_COMMANDS.searchBytes, () => + runDirectAnalysis( + args.path, + "search_bytes", + { + pattern: args.pattern, + limit: options.limit, + ...(options.mask === undefined ? {} : { mask: options.mask }), + ...(options.start === undefined ? {} : { start: options.start }), + ...(options.end === undefined ? {} : { end: options.end }), + ...(options.cursor === undefined ? {} : { cursor: options.cursor }), + }, + directAnalysisOptionsFromCli(logger, options), + ), + ), + }); cli.command(CLI_COMMANDS.inspectNativeLoadImage, { description: "Verify loaded native bytes, source mappings, relocations and entry", diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index d63a9a2bc..b3f2cb0a6 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -39,6 +39,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectNativeDataType: "inspect-native-data-type", inspectNativeLoadImage: "inspect-native-load-image", readBytes: "read-bytes", + searchBytes: "search-bytes", addressToFileOffset: "address-to-file-offset", traceNativeValues: "trace-native-values", inspectNativeInstruction: "inspect-native-instruction", diff --git a/src/contracts/hopperMemoryContracts.ts b/src/contracts/hopperMemoryContracts.ts index e4c49788a..df7e43169 100644 --- a/src/contracts/hopperMemoryContracts.ts +++ b/src/contracts/hopperMemoryContracts.ts @@ -1,8 +1,27 @@ import { z } from "zod"; import { address, document } from "./toolContractHelpers.js"; +import { + bytePatternFields, + validateBytePattern, +} from "../domain/native/bytePattern.js"; /** Hopper memory and file-mapping contracts. */ export const HOPPER_MEMORY_TOOL_DEFINITIONS = [ + { + name: "search_bytes", + description: + "Find masked byte signatures in initialized analyzed memory. Hex byte pairs and ?? wildcards are supported; an optional equal-length hex mask selects bits. Bounds are inclusive. Matches are ordered by provider address and never span blocks. Coverage describes this page; reuse the same query with next_cursor to continue. Uninitialized blocks are listed separately. Ghidra only; Hopper is unavailable.", + inputSchema: z + .strictObject({ + ...bytePatternFields, + document, + start: address.optional(), + end: address.optional(), + limit: z.number().int().min(1).max(1000).default(100), + cursor: z.string().min(1).optional(), + }) + .superRefine(validateBytePattern), + }, { name: "read_bytes", description: diff --git a/src/contracts/toolContractExamples.ts b/src/contracts/toolContractExamples.ts index 66b0f3518..50a745e75 100644 --- a/src/contracts/toolContractExamples.ts +++ b/src/contracts/toolContractExamples.ts @@ -26,6 +26,7 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly< resolve_native_call_targets: { address: "0x1000" }, read_function_instructions: { procedure: "main" }, read_bytes: { address: "0x1000", length: 16 }, + search_bytes: { pattern: "48 8B ?? ?? 89" }, address_to_file_offset: { address: "0x1000" }, procedure_references: { procedure: "main" }, procedure_pseudo_code: { procedure: "main" }, diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 70a40c6a1..939f123eb 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -438,6 +438,7 @@ export const TOOL_EFFECTS: Readonly> = { inspect_native_data_type: evidence, resolve_native_call_targets: evidence, read_bytes: evidence, + search_bytes: evidence, address_to_file_offset: evidence, procedure_references: evidence, procedure_pseudo_code: evidence, diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 76294e2db..1a36c0896 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { byteSearchResultSchema } from "../domain/native/bytePattern.js"; import { artifactInventorySummaryViewResultSchema, machoSummaryViewResultSchema, @@ -108,6 +109,7 @@ const bookmarkFacetSchema = z.discriminatedUnion("state", [ /** Exact structured-content schemas shared by direct analysis providers. */ export const officialOutputSchemas: Readonly> = { + search_bytes: evidenceResultOf(byteSearchResultSchema), annotate_native_function: evidenceResultOf(nativeFunctionAnnotationsSchema), inspect_native_load_image: evidenceResultOf(nativeLoadImageSchema), inspect_native_data_type: evidenceResultOf(nativeDataTypeSchema), diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index cb19ead3e..b42a98383 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { addressCoordinate } from "./native/addressCoordinate.js"; import type { JsonValue } from "./jsonValue.js"; @@ -38,16 +39,6 @@ export const addressDistance = (start: string, end: string): number => { ); }; -const addressCoordinate = ( - value: string, -): { readonly space: string; readonly offset: bigint } | null => { - const separator = value.lastIndexOf(":0x"); - const space = separator < 0 ? "default" : value.slice(0, separator); - const offset = separator < 0 ? value : value.slice(separator + 1); - if (!/^0x[0-9a-f]+$/u.test(offset)) return null; - return { space, offset: BigInt(offset) }; -}; - const procedureMapSchema = z.record(z.string(), z.string()); const addressedNamesSchema = z.array( z.object({ address: z.string(), name: z.string() }), @@ -128,9 +119,9 @@ export const functionBodySchema = z.discriminatedUnion("available", [ }) .superRefine((body, context) => { let total = 0n; - const bounds = new Map(); - const closedSpaces = new Set(); - let previousSpace: string | null = null; + const bounds = new Map(); + const closedSpaces = new Set(); + let previousSpace: string | null | undefined; for (const range of body.ranges) { const start = addressCoordinate(range.start); const end = addressCoordinate(range.end); @@ -146,7 +137,7 @@ export const functionBodySchema = z.discriminatedUnion("available", [ }); return; } - if (previousSpace !== null && previousSpace !== start.space) + if (previousSpace !== undefined && previousSpace !== start.space) closedSpaces.add(previousSpace); const previous = bounds.get(start.space); if ( diff --git a/src/domain/native/addressCoordinate.ts b/src/domain/native/addressCoordinate.ts new file mode 100644 index 000000000..d43b1b4f1 --- /dev/null +++ b/src/domain/native/addressCoordinate.ts @@ -0,0 +1,11 @@ +/** Parse canonical native offsets without discarding their address space. */ +export const addressCoordinate = ( + value: string, +): { readonly space: string | null; readonly offset: bigint } | null => { + const separator = value.lastIndexOf(":0x"); + if (separator === 0) return null; + const space = separator < 0 ? null : value.slice(0, separator); + const offset = separator < 0 ? value : value.slice(separator + 1); + if (!/^0x[0-9a-f]+$/u.test(offset)) return null; + return { space, offset: BigInt(offset) }; +}; diff --git a/src/domain/native/bytePattern.test.ts b/src/domain/native/bytePattern.test.ts new file mode 100644 index 000000000..cc3b6dd9a --- /dev/null +++ b/src/domain/native/bytePattern.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { parseBytePattern } from "./bytePattern.js"; + +describe("masked byte patterns", () => { + it("preserves whole-byte wildcards and combines explicit bit masks", () => { + const parsed = parseBytePattern("48 8b\t??89", "ff f0 ff 0f"); + if (!parsed.ok) throw parsed.error; + expect(parsed.value).toEqual({ + bytes: [0x48, 0x8b, 0, 0x89], + masks: [255, 240, 0, 15], + }); + const exact = parseBytePattern("Aa\u00a000"); + if (!exact.ok) throw exact.error; + expect(exact.value).toEqual({ bytes: [170, 0], masks: [255, 255] }); + }); + + it.each([ + ["", undefined, "pattern"], + ["4", undefined, "pattern"], + ["4?", undefined, "pattern"], + ["gg", undefined, "pattern"], + ["?? ??", undefined, "pattern"], + ["ff", "00", "pattern"], + ["ffff", "ff", "mask"], + ["ff", "??", "mask"], + ])("rejects invalid pattern %s with typed issues", (pattern, mask, field) => { + const parsed = parseBytePattern(pattern, mask); + if (parsed.ok) throw new Error("Expected a rejected pattern"); + expect(parsed.error.issues).toMatchObject([ + { path: [field], reason: "invalid_value" }, + ]); + }); +}); diff --git a/src/domain/native/bytePattern.ts b/src/domain/native/bytePattern.ts new file mode 100644 index 000000000..add5bb7ef --- /dev/null +++ b/src/domain/native/bytePattern.ts @@ -0,0 +1,125 @@ +import { z } from "zod"; +import { addressCoordinate } from "./addressCoordinate.js"; +import { AnalysisInputError } from "../analysisErrorCore.js"; +import { err, ok, type Result } from "../result.js"; + +/** Byte pairs, with whole-byte wildcards; explicit masks can select individual bits. */ +export const bytePatternFields = { + pattern: z + .string() + .regex(/^([0-9a-fA-F]{2}|\?\?)(\s*([0-9a-fA-F]{2}|\?\?))*$/u), + mask: z + .string() + .regex(/^[0-9a-fA-F]{2}(\s*[0-9a-fA-F]{2})*$/u) + .optional(), +}; + +/** Parse once at the input boundary, retaining typed correction issues. */ +export const parseBytePattern = ( + pattern: string, + mask?: string, +): Result<{ bytes: number[]; masks: number[] }, AnalysisInputError> => { + const invalid = (field: string, message: string) => + err( + new AnalysisInputError("search_bytes", undefined, [ + { path: [field], reason: "invalid_value", message }, + ]), + ); + if (!bytePatternFields.pattern.safeParse(pattern).success) + return invalid("pattern", "Use hexadecimal byte pairs or ?? wildcards"); + if (mask !== undefined && !bytePatternFields.mask.safeParse(mask).success) + return invalid("mask", "Use hexadecimal mask byte pairs"); + const pairs = pattern.replace(/\s/gu, "").match(/../gu) ?? []; + const maskPairs = mask?.replace(/\s/gu, "").match(/../gu); + if (maskPairs && maskPairs.length !== pairs.length) + return invalid("mask", "Mask length must equal pattern length"); + const bytes = pairs.map((pair) => + pair === "??" ? 0 : Number.parseInt(pair, 16), + ); + const masks = pairs.map((pair, index) => + pair === "??" ? 0 : Number.parseInt(maskPairs?.[index] ?? "ff", 16), + ); + if (!masks.some((value) => value !== 0)) + return invalid("pattern", "Pattern must constrain at least one bit"); + return ok({ bytes, masks }); +}; + +/** Shared semantic validation for public and provider inputs. */ +export const validateBytePattern = ( + value: { pattern: string; mask?: string | undefined }, + context: z.RefinementCtx, +): void => { + const parsed = parseBytePattern(value.pattern, value.mask); + if (!parsed.ok) + for (const issue of parsed.error.issues) + context.addIssue({ + code: "custom", + path: [...issue.path], + message: issue.message ?? "Invalid byte pattern", + }); +}; + +const coverageBlock = z + .strictObject({ + segment: z.string(), + start: z.string(), + end: z.string(), + bytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + }) + .superRefine((value, context) => { + const start = addressCoordinate(value.start); + const end = addressCoordinate(value.end); + if ( + start === null || + end === null || + start.space !== end.space || + start.offset > end.offset || + end.offset - start.offset + 1n !== BigInt(value.bytes) + ) + context.addIssue({ + code: "custom", + message: + "Byte coverage must equal its inclusive address range in one space", + }); + }); + +/** Inclusive page coverage; matches never span memory blocks. */ +export const byteSearchResultSchema = z + .strictObject({ + matches: z + .array( + z.strictObject({ + address: z.string(), + segment: z.string(), + file_offset: z + .number() + .int() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER) + .nullable(), + procedure: z + .strictObject({ address: z.string(), name: z.string() }) + .nullable(), + }), + ) + .max(1000), + searched: z.strictObject({ + blocks: z.array(coverageBlock), + bytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + skipped_uninitialized: z.array(coverageBlock), + skipped_not_loaded: z.array(coverageBlock), + }), + complete: z.boolean(), + next_cursor: z.string().min(1).nullable(), + }) + .superRefine((value, context) => { + if ( + value.complete !== (value.next_cursor === null) || + value.searched.bytes !== + value.searched.blocks.reduce((sum, block) => sum + block.bytes, 0) + ) + context.addIssue({ + code: "custom", + message: "Byte search coverage and continuation disagree", + }); + }); diff --git a/src/ghidra/GhidraInventoryValues.ts b/src/ghidra/GhidraInventoryValues.ts index 86c17a7fb..85a8b8fbe 100644 --- a/src/ghidra/GhidraInventoryValues.ts +++ b/src/ghidra/GhidraInventoryValues.ts @@ -1,4 +1,9 @@ import { z } from "zod"; +import { + bytePatternFields, + validateBytePattern, + byteSearchResultSchema, +} from "../domain/native/bytePattern.js"; import { nativeLoadImageObservationSchema } from "../domain/native/nativeLoadImage.js"; import { @@ -20,6 +25,7 @@ import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js" export const GHIDRA_INVENTORY_OPERATIONS = [ "inspect_native_load_image", "read_bytes", + "search_bytes", "address_to_file_offset", "address_name", "list_documents", @@ -72,6 +78,16 @@ const searchInput = { }; const inputSchemas = { + search_bytes: z + .strictObject({ + ...bytePatternFields, + document, + start: explicitAddress.optional(), + end: explicitAddress.optional(), + limit: z.number().int().min(1).max(1000).default(100), + cursor: z.string().min(1).optional(), + }) + .superRefine(validateBytePattern), inspect_native_load_image: z.strictObject({}), read_bytes: z.strictObject({ document, @@ -271,6 +287,24 @@ const containingProcedure = z.discriminatedUnion("found", [ ]); const resultSchemas = { + search_bytes: byteSearchResultSchema.superRefine((value, context) => { + const addresses = [ + ...value.matches.flatMap((match) => [ + match.address, + ...(match.procedure === null ? [] : [match.procedure.address]), + ]), + ...[ + ...value.searched.blocks, + ...value.searched.skipped_uninitialized, + ...value.searched.skipped_not_loaded, + ].flatMap((block) => [block.start, block.end]), + ]; + if (addresses.some((value) => !canonicalAddress.safeParse(value).success)) + context.addIssue({ + code: "custom", + message: "Byte search addresses must be canonical", + }); + }), inspect_native_load_image: nativeLoadImageObservationSchema, read_bytes: z .strictObject({ diff --git a/src/ghidra/GhidraProviderCapabilities.ts b/src/ghidra/GhidraProviderCapabilities.ts index 68c3045ce..0714d1ce0 100644 --- a/src/ghidra/GhidraProviderCapabilities.ts +++ b/src/ghidra/GhidraProviderCapabilities.ts @@ -90,6 +90,11 @@ export const limitationsFor = (operation: string): readonly string[] => { ...common, "Bytes reflect Ghidra's initialized memory, including loader fixups. Reads stop at an unmapped or uninitialized byte and report completeness explicitly.", ]; + case "search_bytes": + return [ + ...common, + "Searches initialized loader-adjusted memory independently per block, in provider address order. Bounds and page coverage ends are inclusive. Uninitialized blocks are listed separately. Overlapping matches are included; patterns never cross block boundaries. A cursor resumes the same target, profile and query. Cancellation discards the active search and returns no successful partial page.", + ]; case "address_to_file_offset": return [ ...common, diff --git a/src/hopper/HopperProviderCapabilities.ts b/src/hopper/HopperProviderCapabilities.ts index 00f89fcdb..2dbac4659 100644 --- a/src/hopper/HopperProviderCapabilities.ts +++ b/src/hopper/HopperProviderCapabilities.ts @@ -62,12 +62,16 @@ const MUTATING_OPERATIONS = new Set([ /** Source-owned capabilities without acquiring or probing a Hopper session. */ export const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze( - HOPPER_OPERATIONS.map((operation) => + [...HOPPER_OPERATIONS, "search_bytes" as const].map((operation) => Object.freeze({ provider: HOPPER_PROVIDER_IDENTITY, operation, - available: true, - reason: null, + ...(operation === "search_bytes" + ? { + available: false as const, + reason: "Masked byte search has not been verified for Hopper.", + } + : { available: true as const, reason: null }), effects: Object.freeze({ mutatesArtifact: MUTATING_OPERATIONS.has(operation), launchesProcess: true, diff --git a/tests/boundary/mcp/ghidraInventoryMcp.test.ts b/tests/boundary/mcp/ghidraInventoryMcp.test.ts index 5573c0cbe..ecd331816 100644 --- a/tests/boundary/mcp/ghidraInventoryMcp.test.ts +++ b/tests/boundary/mcp/ghidraInventoryMcp.test.ts @@ -5,9 +5,277 @@ import { join } from "node:path"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { nativeAotPeFixture } from "../../fixtures/nativeaotPe.js"; import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; -import { ok } from "../../../src/domain/result.js"; +import { err, ok } from "../../../src/domain/result.js"; import { ghidraFunctionIdentity } from "../../../src/domain/ghidraValues.fixture.js"; -import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js"; +import { + connectGhidraMcp, + ghidraInstallationHost, + sessionEvidence, +} from "./ghidraMcpHarness.js"; +import { spawn } from "node:child_process"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { GhidraClient } from "../../../src/ghidra/GhidraClient.js"; +import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js"; +import { parseConfig } from "../../../src/config/parseConfig.js"; +import { silentLogger } from "../../../src/logger.js"; +import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js"; +import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { waitForExit } from "../../support/process/processFixture.js"; +import { GhidraSessionError } from "../../../src/ghidra/GhidraSessionError.js"; + +it("retains byte-search coverage and rejects malformed pages before creating Evidence", async () => { + const page = { + matches: [ + { + address: "0x401000", + segment: ".text", + file_offset: null, + procedure: null, + }, + ], + searched: { + blocks: [ + { segment: ".text", start: "0x401000", end: "0x40100f", bytes: 16 }, + ], + bytes: 16, + skipped_uninitialized: [ + { segment: ".bss", start: "0x402000", end: "0x40200f", bytes: 16 }, + ], + skipped_not_loaded: [ + { segment: ".symtab", start: "0x0", end: "0x17f", bytes: 384 }, + ], + }, + complete: false, + next_cursor: "continuation", + }; + let output = jsonValueSchema.parse(page); + const harness = await connectGhidraMcp("byte-search-projection", () => + Promise.resolve(ok(output)), + ); + try { + const first = await harness.mcp.callTool({ + name: "search_bytes", + arguments: { pattern: "48 ??", limit: 1 }, + }); + expect(first.isError).not.toBe(true); + expect( + sessionEvidence(harness.session, first.structuredContent) + .normalized_result, + ).toEqual(page); + output = jsonValueSchema.parse({ + ...page, + matches: [], + complete: true, + next_cursor: null, + }); + const last = await harness.mcp.callTool({ + name: "search_bytes", + arguments: { pattern: "48 ??", cursor: "continuation" }, + }); + expect(last.isError).not.toBe(true); + expect( + sessionEvidence(harness.session, last.structuredContent) + .normalized_result, + ).toEqual(output); + output = jsonValueSchema.parse({ ...page, complete: true }); + const malformed = await harness.mcp.callTool({ + name: "search_bytes", + arguments: { pattern: "49 ??" }, + }); + expect(parseMcpToolError(malformed)).toMatchObject({ + error: { code: "unreadable_output" }, + }); + for (const block of [ + { segment: ".text", start: "0x1000", end: "0x1009", bytes: 1 }, + { segment: ".text", start: "0x1009", end: "0x1000", bytes: 1 }, + { segment: ".text", start: "overlay:0x1000", end: "0x1000", bytes: 1 }, + { segment: ".text", start: "invalid", end: "0x1000", bytes: 1 }, + ]) { + output = jsonValueSchema.parse({ + ...page, + searched: { ...page.searched, blocks: [block], bytes: block.bytes }, + }); + const invalidCoverage = await harness.mcp.callTool({ + name: "search_bytes", + arguments: { pattern: "48 ??" }, + }); + expect(parseMcpToolError(invalidCoverage)).toMatchObject({ + error: { code: "unreadable_output" }, + }); + expect(invalidCoverage.structuredContent).toBeUndefined(); + } + const calls = harness.calls.length; + for (const args of [{ pattern: "??" }, { pattern: "48", mask: "ffff" }]) { + const invalid = await harness.mcp.callTool({ + name: "search_bytes", + arguments: args, + }); + expect(invalid.isError).toBe(true); + expect(JSON.stringify(invalid.content)).toContain( + "Input validation error", + ); + } + expect(harness.calls).toHaveLength(calls); + } finally { + await harness.close(); + } +}); + +it("projects cancelled byte searches as typed failures without successful partial Evidence", async () => { + const harness = await connectGhidraMcp("byte-search-cancelled", () => + Promise.resolve( + err(new GhidraSessionError("cancelled", "Search cancelled")), + ), + ); + try { + const reply = await harness.mcp.callTool({ + name: "search_bytes", + arguments: { pattern: "48 8b" }, + }); + expect(reply.isError).toBe(true); + expect(parseMcpToolError(reply)).toMatchObject({ + error: { code: "cancelled" }, + }); + expect(reply.structuredContent).toBeUndefined(); + } finally { + await harness.close(); + } +}); + +it("searches bytes through MCP and the socket bridge with pagination and skipped coverage", async () => { + const config = parseConfig({ GHIDRA_INSTALL_DIR: "/fixture/ghidra" }); + if (!config.ok) throw config.error; + const provider = new GhidraProvider( + config.value, + silentLogger, + {}, + ghidraInstallationHost(), + (options) => + new GhidraClient({ + ...options, + platform: process.platform, + startupTimeoutMs: 5000, + launcher: { + launch: async (launch) => { + const projectRoot = join(launch.runtimeRoot, "project"); + await mkdir(projectRoot); + const child = spawn( + process.execPath, + [ + fileURLToPath( + new URL("../../fixtures/fakeGhidra.mjs", import.meta.url), + ), + ], + { + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + REA_GHIDRA_FIXTURE_CONFIG: JSON.stringify({ + endpointPath: launch.endpointPath, + token: launch.token, + runId: launch.runId, + providerVersion: launch.providerVersion, + profileDigest: launch.profileDigest, + targetSha256: launch.targetSha256, + transport: launch.transport, + }), + }, + }, + ); + return ok({ + process: child, + ownsProcessLifetime: true as const, + projectRoot, + ghidraLogPath: join(launch.runtimeRoot, "ghidra.log"), + scriptLogPath: join(launch.runtimeRoot, "script.log"), + cleanup: async () => { + const signaled = + child.exitCode === null && child.signalCode === null; + if (signaled) child.kill("SIGTERM"); + return (await waitForExit(child, 2000)) + ? { cleaned: true as const, signaled } + : { cleaned: false as const, reason: "Fixture did not exit" }; + }, + }); + }, + }, + }), + ); + const session = composeBinarySession( + new AnalysisProviderRegistry([provider]), + ); + const server = createServer( + { kind: "session", session }, + { logger: silentLogger }, + ); + const client = new Client({ name: "byte-search", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: process.execPath }, + }); + expect(opened.isError).not.toBe(true); + const first = await client.callTool({ + name: "search_bytes", + arguments: { pattern: "48 8b ?? ?? 89", limit: 1 }, + }); + expect(first.isError, JSON.stringify(first)).not.toBe(true); + const firstEvidence = sessionEvidence(session, first.structuredContent); + expect(firstEvidence.normalized_result).toMatchObject({ + matches: [ + { + address: "0x1000", + segment: ".text", + file_offset: 0, + procedure: { name: "fixture_main" }, + }, + ], + searched: { + bytes: 10, + skipped_uninitialized: [{ segment: ".bss", bytes: 16 }], + }, + complete: false, + next_cursor: "fixture-second-page", + }); + const second = await client.callTool({ + name: "search_bytes", + arguments: { + pattern: "48 8b ?? ?? 89", + limit: 1, + cursor: "fixture-second-page", + }, + }); + expect(second.isError).not.toBe(true); + expect( + sessionEvidence(session, second.structuredContent).normalized_result, + ).toMatchObject({ + matches: [{ address: "0x1005", file_offset: 5 }], + complete: true, + next_cursor: null, + }); + const invalid = await client.callTool({ + name: "search_bytes", + arguments: { pattern: "??" }, + }); + expect(invalid.isError).toBe(true); + expect(JSON.stringify(invalid.content)).toContain( + "Pattern must constrain at least one bit", + ); + } finally { + await client.close(); + await server.close(); + const closed = await session.close(); + if (!closed.ok) throw closed.error; + } +}); it("retains missing PDB coverage in empty PE name-search Evidence and snapshot replay", async () => { const root = await createTestTempDirectory("rea-pdb-evidence-"); diff --git a/tests/boundary/mcp/ghidraMcpHarness.ts b/tests/boundary/mcp/ghidraMcpHarness.ts index 2ff9d0790..fea27b037 100644 --- a/tests/boundary/mcp/ghidraMcpHarness.ts +++ b/tests/boundary/mcp/ghidraMcpHarness.ts @@ -54,7 +54,7 @@ export const connectGhidraMcp = async ( config.value, silentLogger, {}, - installationHost(), + ghidraInstallationHost(), factory, ); const session = composeBinarySession( @@ -100,7 +100,7 @@ export const sessionEvidence = (session: BinarySession, value: unknown) => { return evidence; }; -const installationHost = (): GhidraInstallationHost => ({ +export const ghidraInstallationHost = (): GhidraInstallationHost => ({ platform: "linux", architecture: "x64", readText: () => "application.version=12.1.4\napplication.java.min=21\n", diff --git a/tests/boundary/mcp/officialArguments.test.ts b/tests/boundary/mcp/officialArguments.test.ts index ff0284ac0..a5fe0f2b1 100644 --- a/tests/boundary/mcp/officialArguments.test.ts +++ b/tests/boundary/mcp/officialArguments.test.ts @@ -43,7 +43,23 @@ describe("official operation arguments", () => { const execute: AnalysisOperationPort["execute"] = (name, parameters) => { calls.push({ name, parameters }); return Promise.resolve( - observed(name === "search_strings" ? [] : "int main() {}"), + observed( + name === "search_bytes" + ? { + matches: [], + searched: { + blocks: [], + bytes: 0, + skipped_uninitialized: [], + skipped_not_loaded: [], + }, + complete: true, + next_cursor: null, + } + : name === "search_strings" + ? [] + : "int main() {}", + ), ); }; const createSession = () => @@ -59,6 +75,7 @@ describe("official operation arguments", () => { const client = await connect({ execute }); for (const [name, mcpParameters, cliParameters] of [ ["procedure_pseudo_code", { procedure: "main" }, { procedure: "main" }], + ["search_bytes", { pattern: "48 ??" }, { pattern: "48 ??", limit: 100 }], [ "search_strings", { pattern: "entry" }, @@ -93,6 +110,11 @@ describe("official operation arguments", () => { ["list_procedures", { document: null }], ["procedure_pseudo_code", { procedure: "main", document: 3 }], ["search_strings", { pattern: "entry", case_sensitive: null }], + ["search_bytes", { pattern: "??" }], + ["search_bytes", { pattern: "48 8B", mask: "ff" }], + ["search_bytes", { pattern: "4?" }], + ["search_bytes", { pattern: "48", limit: 1001 }], + ["search_bytes", { pattern: "48", cursor: null }], ] as const) { const result = await client.callTool({ name, arguments: parameters }); expect(result.isError).toBe(true); diff --git a/tests/boundary/mcp/officialTools.test.ts b/tests/boundary/mcp/officialTools.test.ts index 70fbf8587..e28639dd9 100644 --- a/tests/boundary/mcp/officialTools.test.ts +++ b/tests/boundary/mcp/officialTools.test.ts @@ -10,6 +10,12 @@ import { err } from "../../../src/domain/result.js"; import { observed as ok } from "../../fixtures/analysisExecution.js"; import type { JsonValue } from "../../../src/domain/jsonValue.js"; import { createServer } from "../../../src/server/createServer.js"; +import { HopperProvider } from "../../../src/hopper/HopperProvider.js"; +import { parseConfig } from "../../../src/config/parseConfig.js"; +import { silentLogger } from "../../../src/logger.js"; +import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js"; +import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js"; +import { createBinarySessionTargets } from "../../fixtures/binarySession.js"; interface Invocation { readonly name: string; @@ -18,6 +24,67 @@ interface Invocation { const resources: Array<{ close(): Promise }> = []; +it("returns typed Hopper byte-search unavailability without launching the provider", async () => { + const [target] = await createBinarySessionTargets(); + const config = parseConfig({ HOPPER_LAUNCHER_PATH: process.execPath }); + if (!config.ok) throw config.error; + const provider = new HopperProvider(config.value, silentLogger, {}, "linux"); + const session = composeBinarySession( + new AnalysisProviderRegistry([provider]), + ); + const server = createServer( + { kind: "session", session }, + { logger: silentLogger }, + ); + const client = new Client({ + name: "hopper-byte-search-unavailable", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: target }, + }); + expect(opened.isError).not.toBe(true); + const result = await client.callTool({ + name: "search_bytes", + arguments: { pattern: "48 8b" }, + }); + expect(result.isError).toBe(true); + expect(parseMcpToolError(result)).toMatchObject({ + error: { + code: "capability_unavailable", + details: { provider_id: "hopper", operation: "search_bytes" }, + }, + }); + } finally { + await client.close(); + await server.close(); + const closed = await session.close(); + if (!closed.ok) throw closed.error; + } +}); + +it("advertises strict masked-byte arguments and bounded pages", () => { + const contract = OFFICIAL_TOOL_CONTRACTS.find( + ({ name }) => name === "search_bytes", + ); + expect( + contract?.inputSchema.parse({ pattern: "48 8B ?? ?? 89" }), + ).toMatchObject({ limit: 100 }); + expect( + contract?.inputSchema.safeParse({ pattern: "48", ignored: true }).success, + ).toBe(false); + expect( + contract?.inputSchema.safeParse({ pattern: "48", mask: "f0", limit: 1000 }) + .success, + ).toBe(true); +}); + describe("read_bytes contract", () => { it("accepts lengths above the former arbitrary ceiling and rejects zero", () => { const contract = OFFICIAL_TOOL_CONTRACTS.find( diff --git a/tests/fixtures/fakeGhidra.mjs b/tests/fixtures/fakeGhidra.mjs index b08410793..0a130c744 100644 --- a/tests/fixtures/fakeGhidra.mjs +++ b/tests/fixtures/fakeGhidra.mjs @@ -121,7 +121,7 @@ const handleRequest = (socket, server, request, state) => { ? [stringItem("0x2000", "fixture 雪🦊")] : request.method === "list_strings" && state.mode === "oversized_result" ? [stringItem("0x2000", "x".repeat(64 * 1024 * 1024 + 1))] - : inventoryResultFor(request.method); + : inventoryResultFor(request.method, request.params); if (inventory !== undefined) { const response = `${JSON.stringify({ id: request.id, ok: true, result: inventory })}\n`; if (state.mode === "fragmented") { @@ -228,6 +228,7 @@ const sessionInfo = ({ "shutdown", "inspect_native_load_image", "read_bytes", + "search_bytes", "address_to_file_offset", "address_name", "list_documents", @@ -273,8 +274,42 @@ const sessionInfo = ({ }, }); -const inventoryResultFor = (method) => { +const inventoryResultFor = (method, params) => { switch (method) { + case "search_bytes": { + const continued = params.cursor === "fixture-second-page"; + const paged = params.limit === 1 && !continued; + const addresses = continued + ? ["0x1005"] + : paged + ? ["0x1000"] + : ["0x1000", "0x1005"]; + return { + matches: addresses.map((address) => ({ + address, + segment: ".text", + file_offset: address === "0x1000" ? 0 : 5, + procedure: { address: "0x1000", name: "fixture_main" }, + })), + searched: { + blocks: [ + { + segment: ".text", + start: continued ? "0x1005" : "0x1000", + end: "0x1009", + bytes: continued ? 5 : 10, + }, + ], + bytes: continued ? 5 : 10, + skipped_uninitialized: [ + { segment: ".bss", start: "0x2000", end: "0x200f", bytes: 16 }, + ], + skipped_not_loaded: [], + }, + complete: !paged, + next_cursor: paged ? "fixture-second-page" : null, + }; + } case "address_name": return "fixture_main"; case "list_documents": From c580ba940a1a29c586db47ad7df5234d51e0cc4a Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:43:11 -0700 Subject: [PATCH 18/36] fix(browser): treat an unusable cache list as incomplete storage coverage (#2762) Squash merge of https://github.com/morluto/rea/pull/2762. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- src/browser/CdpCaptureStorage.test.ts | 84 +++++++++++++++++++++++++++ src/browser/CdpCaptureStorage.ts | 10 ++-- 2 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 src/browser/CdpCaptureStorage.test.ts diff --git a/src/browser/CdpCaptureStorage.test.ts b/src/browser/CdpCaptureStorage.test.ts new file mode 100644 index 000000000..51bb49a08 --- /dev/null +++ b/src/browser/CdpCaptureStorage.test.ts @@ -0,0 +1,84 @@ +import { expect, it } from "vitest"; + +import { inspectWebPageInputSchema } from "../domain/browserObservation.js"; +import { captureStorage } from "./CdpCaptureStorage.js"; +import { CdpCommandRejection } from "./CdpCommandRejection.js"; +import type { CdpConnection } from "./CdpConnection.js"; + +const NO_CACHE_COMMAND = Symbol("unsupported"); + +const captureWithCacheResult = async ( + cacheResult: unknown, +): Promise>["value"]> => { + const connection = { + send(method: string): Promise { + switch (method) { + case "Storage.getUsageAndQuota": + return Promise.resolve({ usage: 1, quota: 10 }); + case "DOMStorage.getDOMStorageItems": + return Promise.resolve({ entries: [] }); + case "IndexedDB.requestDatabaseNames": + return Promise.resolve({ databaseNames: [] }); + case "Network.getCookies": + return Promise.resolve({ cookies: [] }); + case "CacheStorage.requestCacheNames": + return cacheResult === NO_CACHE_COMMAND + ? Promise.reject( + new CdpCommandRejection( + "inspect_web_page", + method, + -32601, + "Method not found", + ), + ) + : Promise.resolve(cacheResult); + case "CacheStorage.requestEntries": + return Promise.resolve({ cacheDataEntries: [], returnCount: 0 }); + default: + return Promise.reject(new Error(`Unexpected command: ${method}`)); + } + }, + } as unknown as CdpConnection; + const { value } = await captureStorage( + { + connection, + sessionId: undefined, + input: inspectWebPageInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "fixture-page", + include_storage_keys: true, + include_storage_fingerprints: true, + }), + }, + "https://example.test/", + [], + ); + return value; +}; + +it.each([ + ["an observed empty cache list", { caches: [] }, true], + ["a response without a cache list", {}, false], + ["a non-array cache list", { caches: "nope" }, false], + ["a cache list with an unusable entry", { caches: [5] }, false], + ["an unsupported cache command", NO_CACHE_COMMAND, false], +] as const)( + "reports fingerprint completeness for %s", + async (_label, cacheResult, complete) => { + const value = await captureWithCacheResult(cacheResult); + expect(value.cache_names).toEqual([]); + expect(value.fingerprints_complete).toBe(complete); + }, +); + +it.each([false, true])( + "preserves valid cache evidence beside an unusable entry: %s", + async (mixed) => { + const cache = { cacheName: "fixture-cache", cacheId: "fixture-cache-id" }; + const value = await captureWithCacheResult({ + caches: mixed ? [cache, 5] : [cache], + }); + expect(value.cache_names).toEqual(["fixture-cache"]); + expect(value.fingerprints_complete).toBe(!mixed); + }, +); diff --git a/src/browser/CdpCaptureStorage.ts b/src/browser/CdpCaptureStorage.ts index 8ddcea9a8..1e88ca74a 100644 --- a/src/browser/CdpCaptureStorage.ts +++ b/src/browser/CdpCaptureStorage.ts @@ -60,8 +60,8 @@ export const captureStorage = async ( limitations, ) : undefined; - const cacheValues = recordsValue(recordValue(cacheRaw)?.caches); - const caches = capturedCaches(cacheValues); + const cacheField = recordValue(cacheRaw)?.caches; + const caches = capturedCaches(recordsValue(cacheField)); const fingerprints = context.input.include_storage_fingerprints ? await captureStorageFingerprints({ context, @@ -78,8 +78,10 @@ export const captureStorage = async ( indexedRaw !== undefined && Array.isArray(indexedValues) && indexed.length === indexedValues.length && - cacheRaw !== undefined && - caches.length === cacheValues.length; + // Like IndexedDB names, a missing, non-array or partly unusable cache + // list is an incomplete enumeration rather than an empty one. + Array.isArray(cacheField) && + caches.length === cacheField.length; return { value: { origin, From 48b90fbaa4978d1f605b9901d58e6c4ab5ec78b6 Mon Sep 17 00:00:00 2001 From: Iven Koo <73214171+IvenKooLab@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:46:02 +0800 Subject: [PATCH 19/36] fix(native): preserve unresolved calls in untyped route fallback (#2757) Squash merge of https://github.com/morluto/rea/pull/2757. Co-authored-by: IvenKooLab <276572622@qq.com> --- .../EnhancedNativeInvestigation.test.ts | 43 +++++++++ .../native/NativeCallRoutes.test.ts | 53 +++++++++++ src/application/native/NativeCallRoutes.ts | 93 ++++++++++++------- src/application/native/NativeUiActionTrace.ts | 66 +++++++------ src/domain/native/objcSwiftMetadata.ts | 2 + 5 files changed, 193 insertions(+), 64 deletions(-) diff --git a/src/application/native/EnhancedNativeInvestigation.test.ts b/src/application/native/EnhancedNativeInvestigation.test.ts index 087b5981f..e81dcb8b1 100644 --- a/src/application/native/EnhancedNativeInvestigation.test.ts +++ b/src/application/native/EnhancedNativeInvestigation.test.ts @@ -199,6 +199,49 @@ const testAnalysis = ( }); describe("native UI action trace", () => { + it("retains Hopper targetless call evidence alongside fallback callee routes", async () => { + const result = await new EnhancedTools( + testAnalysis({ + procedure_address: () => execution("procedure_address", "0x1000"), + procedure_references: (parameters) => + ok( + createAnalysisExecution( + { + reference_kinds_available: false, + references: [], + unresolved_calls: + parameters.procedure === "0x1000" + ? [{ address: "0x1008", reason: "No static target" }] + : [], + }, + { id: "hopper", name: "Hopper", version: "fixture" }, + ), + ), + }), + ).execute("trace_native_ui_action", { action: "0x1000" }); + if (!result.ok) throw result.error; + const trace = nativeInvestigationTraceSchema.parse(result.value); + expect(trace.edges).toContainEqual( + expect.objectContaining({ + relation: "provider_call", + to: "native:function:0x2000", + }), + ); + expect(trace.unresolved).toContainEqual( + expect.objectContaining({ + from: "native:function:0x1000", + to: null, + reason: "No static target", + evidence: [ + expect.objectContaining({ + description: "Hopper reported an unresolved call: No static target", + location: { address: "0x1008", file_offset: null }, + artifact_sha256: target, + }), + ], + }), + ); + }); it("accepts an explicit native seed without an authored UI connection", async () => { const result = await new EnhancedTools( testAnalysis({ diff --git a/src/application/native/NativeCallRoutes.test.ts b/src/application/native/NativeCallRoutes.test.ts index e54a55dc5..135807c3e 100644 --- a/src/application/native/NativeCallRoutes.test.ts +++ b/src/application/native/NativeCallRoutes.test.ts @@ -7,6 +7,59 @@ import { err, ok } from "../../domain/result.js"; import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; const provider = { id: "fixture", name: "Fixture", version: "1" }; +describe("untyped native call route fallback", () => { + it.each([{ callees: [] }, { callees: ["0x2000"] }])( + "preserves targetless calls and their provider when fallback callees are $callees", + async ({ callees }) => { + const calls: string[] = []; + const referenceProvider = { ...provider, id: "hopper", name: "Hopper" }; + const fallbackProvider = { ...provider, id: "fallback" }; + const signal = new AbortController().signal; + const unknown = { address: "0x1008", reason: "No static target" }; + const result = await readNativeCallRoutes( + { + execute: async (operation, parameters, options) => { + calls.push(operation); + expect(parameters.procedure).toBe("0x1000"); + expect(options).toEqual({ signal, selectorOrigin: "provider" }); + return ok( + createAnalysisExecution( + operation === "procedure_references" + ? { + reference_kinds_available: false, + references: [], + unresolved_calls: [unknown], + } + : callees, + operation === "procedure_references" + ? referenceProvider + : fallbackProvider, + ), + ); + }, + }, + "0x1000", + signal, + "provider", + ); + expect(calls).toEqual(["procedure_references", "procedure_callees"]); + expect(result).toEqual({ + ok: true, + value: { + provider: fallbackProvider, + routes: callees.map((address) => ({ + address, + site: "0x1000", + relation: "provider_call", + resolution: "inferred", + })), + unknowns: [{ ...unknown, provider: referenceProvider }], + }, + }); + }, + ); +}); + describe("typed native call routes", () => { it("preserves computed targets, ambiguity and targetless call sites", async () => { const reference = jsonValueSchema.parse(ghidraReferenceEdge()); diff --git a/src/application/native/NativeCallRoutes.ts b/src/application/native/NativeCallRoutes.ts index fe77f61e9..9848ab86d 100644 --- a/src/application/native/NativeCallRoutes.ts +++ b/src/application/native/NativeCallRoutes.ts @@ -2,12 +2,12 @@ import { z } from "zod"; import type { AnalysisOperationPort, ExecutionOptions, + ProviderIdentity, } from "../AnalysisProvider.js"; import { parseRelatedAddresses, referenceKindSchema, } from "../../domain/hopperValues.js"; -import { err, ok } from "../../domain/result.js"; import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; const referencesSchema = z.object({ @@ -40,15 +40,29 @@ export const readNativeCallRoutes = async ( { procedure, direction: "outgoing" }, options, ); + let unknowns: Array<{ + address: string; + reason: string; + provider: ProviderIdentity; + }> = []; if (execution.ok) { const parsed = referencesSchema.safeParse(execution.value.result); if (!parsed.success) - return err( - new AnalysisOutputError( + return { + ok: false as const, + error: new AnalysisOutputError( "procedure_references", "Invalid typed call references", ), - ); + unknowns, + }; + // Missing detailed reference flags do not invalidate observed targetless + // calls. Keep their producing provider even if another provider supplies + // the fallback callee inventory. + unknowns = parsed.data.unresolved_calls.map((call) => ({ + ...call, + provider: execution.value.provider, + })); const references = parsed.data.references; if ( parsed.data.reference_kinds_available === true || @@ -64,45 +78,52 @@ export const readNativeCallRoutes = async ( targets.add(call.target_address); countBySite.set(call.source_address, targets); } - return ok({ - provider: execution.value.provider, - routes: calls.map((call) => ({ - address: call.target_procedure?.address ?? call.target_address, - site: call.source_address, - relation: - call.kind.available && (call.kind.computed || call.kind.indirect) - ? ("indirect_call" as const) - : ("direct_call" as const), - resolution: - (countBySite.get(call.source_address)?.size ?? 0) > 1 - ? ("ambiguous" as const) - : call.kind.available && - (call.kind.computed || call.kind.indirect) - ? ("resolved" as const) - : ("observed" as const), - })), - unknowns: parsed.data.unresolved_calls, - }); + return { + ok: true as const, + value: { + provider: execution.value.provider, + routes: calls.map((call) => ({ + address: call.target_procedure?.address ?? call.target_address, + site: call.source_address, + relation: + call.kind.available && (call.kind.computed || call.kind.indirect) + ? ("indirect_call" as const) + : ("direct_call" as const), + resolution: + (countBySite.get(call.source_address)?.size ?? 0) > 1 + ? ("ambiguous" as const) + : call.kind.available && + (call.kind.computed || call.kind.indirect) + ? ("resolved" as const) + : ("observed" as const), + })), + unknowns, + }, + }; } } else if (execution.error._tag !== "AnalysisCapabilityUnavailableError") - return err(execution.error); + return { ok: false as const, error: execution.error, unknowns }; const callees = await analysis.execute( "procedure_callees", { procedure }, options, ); - if (!callees.ok) return callees; + if (!callees.ok) + return { ok: false as const, error: callees.error, unknowns }; const parsed = parseRelatedAddresses(callees.value.result, "callees"); return parsed.ok - ? ok({ - provider: callees.value.provider, - routes: parsed.value.map((address) => ({ - address, - site: procedure, - relation: "provider_call" as const, - resolution: "inferred" as const, - })), - unknowns: [], - }) - : parsed; + ? { + ok: true as const, + value: { + provider: callees.value.provider, + routes: parsed.value.map((address) => ({ + address, + site: procedure, + relation: "provider_call" as const, + resolution: "inferred" as const, + })), + unknowns, + }, + } + : { ok: false as const, error: parsed.error, unknowns }; }; diff --git a/src/application/native/NativeUiActionTrace.ts b/src/application/native/NativeUiActionTrace.ts index 141b31bc0..8a6affdf7 100644 --- a/src/application/native/NativeUiActionTrace.ts +++ b/src/application/native/NativeUiActionTrace.ts @@ -414,9 +414,44 @@ const addDirectCallees = async (input: { "provider", ); examined += 1; + if (!execution.ok && execution.error._tag === "AnalysisCancelledError") + return { error: execution.error }; + const unknowns = execution.ok + ? execution.value.unknowns + : execution.unknowns; + for (const [index, unknown] of unknowns.entries()) { + if (edges.size >= input.input.max_edges) { + truncated = true; + omitted += unknowns.length - index; + break; + } + const id = `unknown-dispatch:${current.address}:${unknown.address}`; + edges.set(id, { + id, + from: current.node.id, + to: null, + relation: "indirect_call", + resolution: "unresolved", + reason: unknown.reason, + evidence: [ + { + kind: "reference", + provider: unknown.provider, + description: `${unknown.provider.name} reported an unresolved call: ${unknown.reason}`, + location: { address: unknown.address, file_offset: null }, + artifact_path: null, + artifact_sha256: input.targetSha256, + }, + ], + limitations: [], + }); + } if (!execution.ok) { - if (execution.error._tag === "AnalysisCancelledError") - return { error: execution.error }; + if (edges.size >= input.input.max_edges) { + truncated = true; + omitted += 1; + continue; + } const id = `unknown-callees:${current.address}`; edges.set(id, { id, @@ -432,32 +467,6 @@ const addDirectCallees = async (input: { }); continue; } - for (const unknown of execution.value.unknowns) { - if (edges.size >= input.input.max_edges) { - truncated = true; - omitted++; - break; - } - const id = `unknown-dispatch:${current.address}:${unknown.address}`; - edges.set(id, { - id, - from: current.node.id, - to: null, - relation: "indirect_call", - resolution: "unresolved", - reason: unknown.reason, - evidence: [ - { - kind: "reference", - description: unknown.reason, - location: { address: unknown.address, file_offset: null }, - artifact_path: null, - artifact_sha256: input.targetSha256, - }, - ], - limitations: [], - }); - } for (const [index, route] of execution.value.routes.entries()) { const address = route.address; if (edges.size >= input.input.max_edges) { @@ -469,6 +478,7 @@ const addDirectCallees = async (input: { const evidence = [ { kind: "reference" as const, + provider: execution.value.provider, description: `${execution.value.provider.name} reported ${route.relation} from ${current.address} to ${address}`, location: { address: route.site, file_offset: null }, artifact_path: null, diff --git a/src/domain/native/objcSwiftMetadata.ts b/src/domain/native/objcSwiftMetadata.ts index 5778e5dd7..a360c3202 100644 --- a/src/domain/native/objcSwiftMetadata.ts +++ b/src/domain/native/objcSwiftMetadata.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { providerSchema } from "../evidence.js"; import { compareUnicodeCodePoints, @@ -115,6 +116,7 @@ export const nativeMetadataEvidenceSchema = z.strictObject({ "decompiler", ]), description: z.string().min(1), + provider: providerSchema.optional(), location: nativeMetadataLocationSchema, artifact_path: z.string().min(1).nullable().default(null), artifact_sha256: z From af7638fa9d049283719c9c103bc59b49faab9676 Mon Sep 17 00:00:00 2001 From: Feng Date: Tue, 13 Oct 2026 16:46:23 +0100 Subject: [PATCH 20/36] fix(session): preserve investigation records after failed target opens (#2754) Squash merge of https://github.com/morluto/rea/pull/2754. Co-authored-by: Adkid-Zephyr --- docs/mcp-contracts.md | 8 +- src/application/binary/BinarySession.ts | 4 +- .../mcp/sessionEvidenceRecovery.test.ts | 184 ++++++++++++++++++ 3 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 tests/boundary/mcp/sessionEvidenceRecovery.test.ts diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 63ab516d5..e18e2b343 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -271,9 +271,11 @@ Evidence remain complete inline, and both input forms pass the same semantic, identity, authority, and provenance checks without running the producer again. References belong to the current connection's Evidence ledger. Opening another -target preserves retained records; `close_binary` clears them, even when no -binary is active. A fresh connection has its own ledger. A missing reference -reports its exact ID and `details.reason: "missing"`; the server cannot infer +target or restarting the current target preserves retained records, including +when provider cleanup fails; records remain available for export and retry. +`close_binary` clears them, even when no binary is active. A fresh connection +has its own ledger. A missing reference reports its exact ID and +`details.reason: "missing"`; the server cannot infer whether it was never recorded, cleared, or retained by another connection. Supply complete inline Evidence, repeat its producer, or import an exported Evidence bundle before referencing that imported record. Export a bundle before diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index 3da571832..b151958e7 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -283,7 +283,9 @@ export class BinarySession previous.route.identity.id, ); if (!closed.ok) { - this.clearSessionRecords(); + // A failed open retires the provider cache, not the investigation. + // Preserve Evidence and Unknown history for export and recovery. + this.clearSnapshot(); this.#clearRuntimeAvailability(); return closed; } diff --git a/tests/boundary/mcp/sessionEvidenceRecovery.test.ts b/tests/boundary/mcp/sessionEvidenceRecovery.test.ts new file mode 100644 index 000000000..dab264dbc --- /dev/null +++ b/tests/boundary/mcp/sessionEvidenceRecovery.test.ts @@ -0,0 +1,184 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/server"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { createServer } from "../../../src/server/createServer.js"; +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; +import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; +import { err, ok } from "../../../src/domain/result.js"; +import { + createCacheProvider, + createTestBinarySession, +} from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; + +const bundleResult = (reply: CallToolResult) => + z.object({ result: evidenceBundleSchema }).parse(reply.structuredContent) + .result; + +const successfulTool = async ( + client: Client, + name: string, + arguments_: Record = {}, +): Promise => { + const reply = await client.callTool({ name, arguments: arguments_ }); + expect(reply.isError, `${name}: ${JSON.stringify(reply.content)}`).not.toBe( + true, + ); + return reply; +}; + +const recordUnknownHistory = async (client: Client): Promise => { + const fields = { + severity: "medium", + required_authority: "shipped-artifact", + required_confidence: "observed", + required_environment: null, + recommended_probes: [], + relationships: [], + }; + const unknown = await successfulTool(client, "record_unknown", { + ...fields, + question: "Which source revision produced this target?", + domain: "provenance", + }); + const recorded = z + .object({ result: z.object({ unknown_id: z.string() }) }) + .parse(unknown.structuredContent).result; + await successfulTool(client, "update_unknown", { + ...fields, + unknown_id: recorded.unknown_id, + expected_revision: 1, + status: "investigating", + supporting_evidence_ids: [], + contradicting_evidence_ids: [], + resolution: null, + }); +}; + +it.each(["switch", "restart"] as const)( + "preserves investigation records through failed target %s and cleanup retries", + async (transition) => { + const root = await createTestTempDirectory("rea-mcp-switch-evidence-"); + const first = join(root, "first.hop"); + const second = join(root, "second.hop"); + await writeFile(first, "first source-owned target"); + await writeFile(second, "second source-owned target"); + const application = join(root, "application"); + await mkdir(application); + await writeFile( + join(application, "index.js"), + 'export const label = "retained";', + ); + const nextTarget = transition === "switch" ? second : first; + const calls: string[] = []; + const base = createCacheProvider(calls); + let cleanupAllowed = true; + let created = 0; + const failure = new ProviderCleanupError( + "fixture", + [join(root, "runtime")], + { + reason: "fixture cleanup temporarily unavailable", + }, + ); + const session = createTestBinarySession({ + ...base, + createClient: (target, profile, context) => { + created += 1; + return { + ...base.createClient(target, profile, context), + operationHealthSnapshot: () => ({ + state: cleanupAllowed ? "idle" : "exited", + stage: "analysis", + retryAction: cleanupAllowed ? null : "restart_provider", + exitCode: cleanupAllowed ? null : 1, + requests: [], + }), + close: () => + Promise.resolve(cleanupAllowed ? ok(null) : err(failure)), + }; + }, + }); + const server = createServer( + { kind: "session", session }, + { environment: {} }, + ); + const client = new Client({ + name: "switch-evidence-recovery", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const call = (name: string, arguments_: Record = {}) => + successfulTool(client, name, arguments_); + const query = { address: "0x1000", document: "fixture" }; + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + await call("open_binary", { path: first }); + // The cached primitive must be discarded when the provider is retired, + // independently of the investigation's Evidence and Unknown history. + await call("address_name", query); + const analyzed = await call("analyze_javascript_application", { + input_path: application, + format: "directory", + max_heap_mb: 128, + }); + const applicationId = z + .object({ evidence_id: z.string() }) + .parse(analyzed.structuredContent).evidence_id; + await recordUnknownHistory(client); + const before = bundleResult(await call("get_evidence_bundle")); + expect(before.records).toHaveLength(4); + expect(before.unknowns).toHaveLength(2); + + cleanupAllowed = false; + for (let attempt = 0; attempt < 2; attempt += 1) { + const replacement = await client.callTool({ + name: "open_binary", + arguments: { path: nextTarget }, + }); + expect(parseMcpToolError(replacement)).toMatchObject({ + error: { code: "cleanup_incomplete" }, + }); + expect(session.activeTarget()).toBeUndefined(); + expect(created).toBe(1); + expect(bundleResult(await call("get_evidence_bundle"))).toEqual(before); + } + const exportedPath = join(root, "retained.json"); + await call("export_evidence_bundle", { path: exportedPath }); + expect( + evidenceBundleSchema.parse( + JSON.parse(await readFile(exportedPath, "utf8")), + ), + ).toEqual(before); + + cleanupAllowed = true; + await call("open_binary", { path: nextTarget }); + expect(bundleResult(await call("get_evidence_bundle"))).toEqual(before); + await call("address_name", query); + expect( + calls.filter((operation) => operation === "address_name"), + ).toHaveLength(2); + expect(created).toBe(2); + await call("inspect_analysis_view", { + source: { kind: "retained-evidence", evidence_id: applicationId }, + view: { kind: "page", collection: "modules", offset: 0, limit: 10 }, + }); + await call("close_binary"); + const closed = bundleResult(await call("get_evidence_bundle")); + expect(closed.records).toEqual([]); + expect(closed.unknowns).toEqual([]); + } finally { + cleanupAllowed = true; + await client.close(); + await server.close(); + await session.close(); + } + }, +); From ba06d9ec521f2654f6916ca58e38d33213dd6ddb Mon Sep 17 00:00:00 2001 From: Jeremy Levartovsky <140487034+JayOfTheKeyboard@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:46:46 +1100 Subject: [PATCH 21/36] fix(setup): keep a Cordis operation's own fields when uninstall empties it (#2730) Squash merge of https://github.com/morluto/rea/pull/2730. Co-authored-by: JayOfTheKeyboard <140487034+JayOfTheKeyboard@users.noreply.github.com> --- src/application/DshCordisPatch.ts | 14 +- .../filesystem/dshClientConfiguration.test.ts | 132 ++++++++++++++++++ 2 files changed, 142 insertions(+), 4 deletions(-) diff --git a/src/application/DshCordisPatch.ts b/src/application/DshCordisPatch.ts index 5e0fc10ec..0e34da23e 100644 --- a/src/application/DshCordisPatch.ts +++ b/src/application/DshCordisPatch.ts @@ -8,6 +8,7 @@ import { CordisExpression, DshCordisYaml } from "./DshCordisYaml.js"; export const DSH_ENTRY_ID = "rea-mcp"; export const DSH_CLIENT_PACKAGE = "@deepseek-ai/dsh-mcp-client"; const LAUNCHER_KEYS = new Set(["serverName", "transport", "command", "args"]); +const SELECTOR_KEYS = new Set(["id", "name"]); const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && @@ -295,10 +296,15 @@ export class DshCordisDocument { carried = true; return [this.#entry(desired, invalidated)] as typeof inserted.items; }); - replacements.set( - op.source, - inserted.items.length === 0 ? undefined : copy, - ); + if (inserted.items.length === 0) copy.delete("insert"); + // An emptied operation keeps caller fields unless it would patch REA's row. + const kept = + inserted.items.length > 0 || + (this.#id(op.map) !== DSH_ENTRY_ID && + copy.items.some( + (pair) => !SELECTOR_KEYS.has(this.#yaml.value(pair.key) as string), + )); + replacements.set(op.source, kept ? copy : undefined); } const output = this.#yaml.document.clone(); const outputRoot = output.contents; diff --git a/tests/boundary/filesystem/dshClientConfiguration.test.ts b/tests/boundary/filesystem/dshClientConfiguration.test.ts index c0315f67b..fd65e6b00 100644 --- a/tests/boundary/filesystem/dshClientConfiguration.test.ts +++ b/tests/boundary/filesystem/dshClientConfiguration.test.ts @@ -2195,3 +2195,135 @@ describe("DeepSeek Harness caller node metadata", () => { }, ); }); + +describe("DeepSeek Harness uninstall of a shared operation", () => { + it("keeps a group patch's own fields and comment when its only row was REA's", async () => { + const { home, client } = await harnessClient(); + const entry = canonicalEntry(); + await writeFile( + client.configPath, + `- insert: + - id: user-a + name: plugin-a +# caller group patch +- id: tools + config: + sandbox: strict + insert: + - id: rea-mcp + name: "${entry.name}" + config: + serverName: rea + transport: stdio + command: ${entry.config.command} + args: ${JSON.stringify(entry.config.args)} +`, + ); + + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + const text = await readFile(client.configPath, "utf8"); + expect(parseYaml(text)).toEqual([ + { insert: [{ id: "user-a", name: "plugin-a" }] }, + { id: "tools", config: { sandbox: "strict" } }, + ]); + expect(text).toContain("# caller group patch"); + }); + + it("keeps an operation's own fields when setup removes a second REA row from it", async () => { + // Setup refuses a REA row in a group patch, so the setup path meets the + // keep rule only on an operation without a selector. + const { home, client } = await harnessClient(); + const entry = canonicalEntry(); + const row = (launcher: string) => ` - id: rea-mcp + name: "${entry.name}" + config: + serverName: rea + transport: stdio + command: ${launcher} + args: [mcp] +`; + await writeFile( + client.configPath, + `- insert: +${row("first-launcher")}# caller operation +- disabled: false + insert: +${row("second-launcher")}`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + expect(text).toContain("# caller operation"); + expect(loadedInserts(text)).toHaveLength(1); + expect((parseYaml(text) as unknown[]).at(-1)).toEqual({ disabled: false }); + expect(await dshStatus(home)).toMatchObject({ state: "aligned" }); + }); + + it("drops an emptied operation that targets REA's own row", async () => { + const { home, client } = await harnessClient(); + await writeFile( + client.configPath, + stringifyYaml([ + { insert: [{ id: "user-a", name: "plugin-a" }] }, + { + id: "rea-mcp", + config: { serverName: "rea" }, + insert: [canonicalEntry()], + }, + ]), + ); + + const host = systemUninstallHost(home); + expect((await host.removeClient(client)).status).toBe("removed"); + expect(parseYaml(await readFile(client.configPath, "utf8"))).toEqual([ + { insert: [{ id: "user-a", name: "plugin-a" }] }, + ]); + }); + + it.each([ + { selector: { id: "tools" } }, + { selector: { name: "tools" } }, + { selector: { id: "tools", name: "tools" } }, + ])( + "drops an emptied operation left with only its target selector $selector", + async ({ selector }) => { + const { home, client } = await harnessClient(); + await writeFile( + client.configPath, + stringifyYaml([ + { insert: [{ id: "user-a", name: "plugin-a" }] }, + { ...selector, insert: [canonicalEntry()] }, + ]), + ); + + expect( + (await systemUninstallHost(home).removeClient(client)).status, + ).toBe("removed"); + expect(parseYaml(await readFile(client.configPath, "utf8"))).toEqual([ + { insert: [{ id: "user-a", name: "plugin-a" }] }, + ]); + }, + ); + + it("still drops an insert-only operation that REA's row emptied", async () => { + const { home, client } = await harnessClient(); + await writeFile( + client.configPath, + stringifyYaml([ + { insert: [{ id: "user-a", name: "plugin-a" }] }, + { insert: [canonicalEntry()] }, + ]), + ); + + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + expect(parseYaml(await readFile(client.configPath, "utf8"))).toEqual([ + { insert: [{ id: "user-a", name: "plugin-a" }] }, + ]); + }); +}); From 9240a4e9ac22f6307ca591853c8c56ce2d0a5d56 Mon Sep 17 00:00:00 2001 From: LouisChen <65096591@qq.com> Date: Tue, 13 Oct 2026 23:47:03 +0800 Subject: [PATCH 22/36] fix(javascript): preserve asterisks in block source-map URLs (#2728) Squash merge of https://github.com/morluto/rea/pull/2728. Co-authored-by: LouisChen <3361008+chqcjq@users.noreply.github.com> --- .../javascriptStaticAnalysisCalls.ts | 11 ++- ...javascriptStaticAnalysisSourceMaps.test.ts | 27 ++++- .../applications/blockSourceMapCli.test.ts | 99 +++++++++++++++++++ 3 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 tests/acceptance/applications/blockSourceMapCli.test.ts diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 148f7fb7e..b098b3c26 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -429,7 +429,12 @@ export const addSourceMapDirectives = ( end === null ) continue; - const commentSource = source.slice(start, end); + // The parser owns comment boundaries. Exclude only the closing block + // delimiter; an asterisk elsewhere remains part of the declared URL. + const commentSource = source.slice( + start, + comment.type === "CommentBlock" ? end - 2 : end, + ); if ( !(commentSource.startsWith("//") || commentSource.startsWith("/*")) || !(commentSource[2] === "#" || commentSource[2] === "@") @@ -449,9 +454,7 @@ export const addSourceMapDirectives = ( const urlOffset = commentSource.slice(equalsStart + 1).search(/\S/u); if (urlOffset === -1) continue; const declaredStart = equalsStart + 1 + urlOffset; - const terminator = commentSource - .slice(declaredStart) - .search(commentSource.startsWith("//") ? /\s/u : /[\s*]/u); + const terminator = commentSource.slice(declaredStart).search(/\s/u); const declaredEnd = terminator === -1 ? commentSource.length : declaredStart + terminator; if (declaredEnd === declaredStart) continue; diff --git a/src/domain/javascript/javascriptStaticAnalysisSourceMaps.test.ts b/src/domain/javascript/javascriptStaticAnalysisSourceMaps.test.ts index f5664f73c..6779d35bd 100644 --- a/src/domain/javascript/javascriptStaticAnalysisSourceMaps.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisSourceMaps.test.ts @@ -72,10 +72,10 @@ describe("source map directives", () => { it("preserves Unicode whitespace and URL terminator boundaries", () => { const source = "ğ/*@\u00a0sourceMappingURL\u2003=\ufeffapp.js.map* ignored */"; - const declaredEnd = source.indexOf("* ignored"); + const declaredEnd = source.indexOf(" ignored"); expect(analyzeJavaScriptStaticSource(source).source_map_urls).toEqual([ { - declared_url: "app.js.map", + declared_url: "app.js.map*", location: { start: { line: 1, column: 1 }, end: { line: 1, column: declaredEnd }, @@ -84,6 +84,29 @@ describe("source map directives", () => { ]); }); + it.each([ + ["/*#", "app*part.js.map", " */"], + ["/*@", "*part.js.map", "*/"], + ["/*#", "app.js.map*", "*/"], + ["/*#", "**part.js.map", " */"], + ["/*#", "app.js.map", "*/"], + ["//#", "app*part.js.map", ""], + ])("preserves the URL and range in %s %s%s", (marker, url, suffix) => { + const directive = marker + " sourceMappingURL=" + url; + const result = analyzeJavaScriptStaticSource( + "export const value = 1;\n" + directive + suffix, + ); + expect(result.source_map_urls).toEqual([ + { + declared_url: url, + location: { + start: { line: 2, column: 0 }, + end: { line: 2, column: directive.length }, + }, + }, + ]); + }); + it("reports nothing when no directive is present", () => { expect(sourceMapUrls("const value = 1;")).toEqual([]); }); diff --git a/tests/acceptance/applications/blockSourceMapCli.test.ts b/tests/acceptance/applications/blockSourceMapCli.test.ts new file mode 100644 index 000000000..957cd49c2 --- /dev/null +++ b/tests/acceptance/applications/blockSourceMapCli.test.ts @@ -0,0 +1,99 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, sep } from "node:path"; + +import { createPackageWithOptions, listPackage } from "@electron/asar"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "links a block source-map URL containing an asterisk through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-block-map-"); + const directory = join(root, "input"); + await mkdir(directory); + const url = "app*part.js.map"; + const directive = "/*# sourceMappingURL=" + url; + await writeFile(join(directory, "app.js"), directive + "*/"); + await writeFile( + join(directory, "app_part.js.map"), + JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }), + ); + const archive = join(root, "app.asar"); + await createPackageWithOptions(directory, archive, {}); + // ASAR logical names can contain '*' even on Windows. Rename this header + // key without changing entry bytes, sizes or payload offsets. + const bytes = await readFile(archive); + const key = Buffer.from('"app_part.js.map"'); + const offset = bytes.indexOf(key); + expect(offset).toBeGreaterThan(0); + expect(bytes.indexOf(key, offset + key.length)).toBe(-1); + Buffer.from('"' + url + '"').copy(bytes, offset); + await writeFile(archive, bytes); + expect(listPackage(archive, { isPack: false })).toContain(sep + url); + let document: unknown; + if (surface === "CLI") { + const result = await cli.run({ + arguments: ["analyze-javascript-application", archive, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + document = result.json; + } else { + const { call } = await connectLocalToolsMcp(); + const result = await call("analyze_javascript_application", { + input_path: archive, + format: "asar", + }); + expect(result.isError).not.toBe(true); + document = result.structuredContent; + } + const { + normalized_result: { graph }, + } = z + .object({ + normalized_result: javascriptApplicationAnalysisResultSchema, + }) + .parse(document); + const digest = createHash("sha256").update(url).digest("hex"); + const map = graph.nodes.find(({ observations }) => + observations.some(({ properties }) => properties.declared_url === url), + ); + expect( + map, + "the complete declared URL must identify the map", + ).toBeDefined(); + expect(map?.observations).toContainEqual( + expect.objectContaining({ + properties: { + declared_url: url, + declared_url_sha256: digest, + resolved_path: url, + available: true, + }, + evidence: expect.objectContaining({ + location: { + available: true, + value: { + kind: "source-range", + source: "app.js", + start: { line: 1, column: 0 }, + end: { line: 1, column: directive.length }, + }, + }, + }), + }), + ); + expect(graph.edges).toContainEqual( + expect.objectContaining({ + relation: "maps_to", + target_node_id: map?.node_id, + properties: { declared_url_sha256: digest, resolved_path: url }, + }), + ); + }, +); From f825c869d6e94e27759160f3d0d3e82b5d2f55db Mon Sep 17 00:00:00 2001 From: chAngE <2815456271@qq.com> Date: Tue, 13 Oct 2026 23:47:18 +0800 Subject: [PATCH 23/36] fix(artifacts): bound expanded ASAR listing before allocation (#2724) Squash merge of https://github.com/morluto/rea/pull/2724. Co-authored-by: chAngE <172472630+chAng-L19@users.noreply.github.com> Co-authored-by: morluto --- src/artifacts/AsarArtifactReader.test.ts | 25 ---- src/artifacts/AsarArtifactReader.ts | 74 +++------- src/artifacts/AsarListing.ts | 110 +++++++++++++++ .../filesystem/asarListingAdmission.test.ts | 130 ++++++++++++++++++ .../mcp/asarListingAdmissionMcp.test.ts | 60 ++++++++ tests/fixtures/asarHeader.ts | 27 ++++ 6 files changed, 348 insertions(+), 78 deletions(-) delete mode 100644 src/artifacts/AsarArtifactReader.test.ts create mode 100644 src/artifacts/AsarListing.ts create mode 100644 tests/boundary/filesystem/asarListingAdmission.test.ts create mode 100644 tests/boundary/mcp/asarListingAdmissionMcp.test.ts create mode 100644 tests/fixtures/asarHeader.ts diff --git a/src/artifacts/AsarArtifactReader.test.ts b/src/artifacts/AsarArtifactReader.test.ts deleted file mode 100644 index 54eb78cd4..000000000 --- a/src/artifacts/AsarArtifactReader.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { orderAsarListing } from "./AsarArtifactReader.js"; - -describe("orderAsarListing", () => { - it("orders host-spelled listings by their portable archive path", () => { - const posix = orderAsarListing(["/a0", "/a", "/a/b"], "/"); - const windows = orderAsarListing(["\\a0", "\\a", "\\a\\b"], "\\"); - - // "/" (0x2F) precedes "0" (0x30); "\" (0x5C) would follow it. - expect(posix.map(({ path }) => path)).toEqual(["a", "a/b", "a0"]); - expect(windows.map(({ path }) => path)).toEqual(["a", "a/b", "a0"]); - expect(windows.map(({ listed }) => listed)).toEqual([ - "\\a", - "\\a\\b", - "\\a0", - ]); - }); - - it("keeps POSIX backslashes as literal filename characters", () => { - expect(orderAsarListing(["/a\\b"], "/")).toEqual([ - { listed: "/a\\b", path: "a\\b" }, - ]); - }); -}); diff --git a/src/artifacts/AsarArtifactReader.ts b/src/artifacts/AsarArtifactReader.ts index b7a235f4b..c988ba056 100644 --- a/src/artifacts/AsarArtifactReader.ts +++ b/src/artifacts/AsarArtifactReader.ts @@ -13,7 +13,7 @@ import { isAbsolute, join, relative, sep } from "node:path"; import { Readable, Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; -import { listPackage, statFile, uncache } from "@electron/asar"; +import { getRawHeader, uncache } from "@electron/asar"; import { ArtifactReaderFailure, @@ -26,7 +26,11 @@ import { closeAsarHandle, readValidatedAsarEntry } from "./AsarEntryStream.js"; import { admitAsarHeader } from "./AsarHeader.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; -import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; +import { + readAsarListing, + type AsarListedEntry, + type AsarMetadata, +} from "./AsarListing.js"; /** * Official Electron ASAR adapter with range-streamed member reads. @@ -51,7 +55,7 @@ export class AsarArtifactReader implements ArtifactReader { ) {} async *entries(signal?: AbortSignal): AsyncIterable { - let paths: readonly AsarListedPath[]; + let paths: readonly AsarListedEntry[]; try { await this.prepareContainer(undefined, signal); paths = await this.#withSnapshotOperation(async () => { @@ -62,8 +66,9 @@ export class AsarArtifactReader implements ArtifactReader { this.#archiveSize = admitted.archiveBytes; this.#headerSize = admitted.headerBytes; abortIfNeeded(signal); - const ordered = orderAsarListing( - listPackage(snapshotPath, { isPack: false }), + const ordered = await readAsarListing( + getRawHeader(snapshotPath).header, + signal, ); const archiveMetadata = await lstat(snapshotPath); if (!archiveMetadata.isFile() || archiveMetadata.isSymbolicLink()) @@ -77,34 +82,25 @@ export class AsarArtifactReader implements ArtifactReader { } catch (cause: unknown) { throw asarFailure(this.path, "inventory", cause); } - for (const { listed, path } of paths) { + for (const { metadata, path } of paths) { abortIfNeeded(signal); - if (path.length === 0) continue; - const providerPath = listed.startsWith(sep) - ? listed.slice(sep.length) - : listed; - let metadata: ReturnType; - try { - metadata = statFile(this.#snapshotPath(), providerPath, false); - } catch (cause: unknown) { - throw asarFailure(this.path, `stat ${path}`, cause); - } + const providerPath = path.split("/").join(sep); const kind = - "files" in metadata + metadata.files !== undefined ? "directory" - : "link" in metadata + : metadata.link !== undefined ? "symlink" : "file"; const entry: ArtifactEntry = { path, kind, - declaredSize: "size" in metadata ? metadata.size : null, + declaredSize: metadata.size ?? null, compressedSize: null, - executable: "executable" in metadata && metadata.executable, + executable: metadata.executable === true, encrypted: false, byteOffset: null, declaredSha256: - "integrity" in metadata && + metadata.integrity !== undefined && metadata.integrity.algorithm === "SHA256" && /^[a-f0-9]{64}$/u.test(metadata.integrity.hash) ? metadata.integrity.hash @@ -603,7 +599,7 @@ export class AsarArtifactReader implements ArtifactReader { } } -type AsarFileMetadata = Extract, { size: number }>; +type AsarFileMetadata = AsarMetadata & { readonly size: number }; type AsarEntryState = { readonly metadata: AsarFileMetadata; readonly entry: ArtifactEntry; @@ -652,12 +648,11 @@ const asarUnpackedEntryFailure = ( }; const isAsarFileMetadata = ( - metadata: ReturnType, + metadata: AsarMetadata, ): metadata is AsarFileMetadata => - "size" in metadata && typeof metadata.size === "number" && - !("files" in metadata) && - !("link" in metadata); + metadata.files === undefined && + metadata.link === undefined; const parseArchiveOffset = (value: string): number | undefined => { if (!/^(?:0|[1-9][0-9]*)$/u.test(value)) return undefined; @@ -699,33 +694,6 @@ const waitForOperation = async ( }); }; -interface AsarListedPath { - readonly listed: string; - readonly path: string; -} - -/** - * Pair each host-spelled listing with its portable archive path, ordered by - * the portable path so Windows and POSIX hosts agree. - * - * @electron/asar returns paths assembled with the host path module. Keep that - * spelling for its API calls, but expose archive paths with portable - * separators. On POSIX, backslashes can be literal filename characters and - * must remain untouched. - */ -export const orderAsarListing = ( - listed: readonly string[], - separator: string = sep, -): AsarListedPath[] => - listed - .map((entry) => ({ listed: entry, path: toArtifactPath(entry, separator) })) - .sort((left, right) => compareUnicodeCodePoints(left.path, right.path)); - -const toArtifactPath = (listed: string, separator: string): string => { - const portable = separator === "\\" ? listed.replaceAll("\\", "/") : listed; - return portable.replace(/^\/+|\/+$/gu, ""); -}; - const asarFailure = ( path: string, operation: string, diff --git a/src/artifacts/AsarListing.ts b/src/artifacts/AsarListing.ts new file mode 100644 index 000000000..60875e93b --- /dev/null +++ b/src/artifacts/AsarListing.ts @@ -0,0 +1,110 @@ +import { setImmediate } from "node:timers/promises"; +import { z } from "zod"; + +import { ArtifactReaderFailure } from "./ArtifactReader.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; + +/** Capacity for expanded paths and listing records, independent of packed bytes. */ +const MAX_LISTING_BYTES = 64 * 1024 * 1024; +const nodeSchema = z.looseObject({ + files: z + .custom>( + (value) => + value !== null && typeof value === "object" && !Array.isArray(value), + ) + .optional(), + link: z.string().optional(), + size: z.number().nonnegative().safe().optional(), + offset: z.string().optional(), + unpacked: z.boolean().optional(), + executable: z.boolean().optional(), + integrity: z + .looseObject({ algorithm: z.string(), hash: z.string() }) + .optional(), +}); +export type AsarMetadata = z.infer; +export interface AsarListedEntry { + readonly path: string; + readonly metadata: AsarMetadata; +} + +function* ownChildren(files: Record) { + for (const name in files) + if (Object.hasOwn(files, name)) yield [name, files[name]] as const; +} + +/** Decode each node once; admit expanded paths before concatenation and retain no recursive lookup. */ +export const readAsarListing = async ( + header: unknown, + signal?: AbortSignal, +): Promise => { + const root = nodeSchema.parse(header); + if (root.files === undefined) + throw new ArtifactReaderFailure("format", "ASAR root has no files table"); + const entries: AsarListedEntry[] = []; + const pending = [{ parent: "", children: ownChildren(root.files) }]; + let retainedBytes = 0; + let examined = 0; + while (pending.length > 0) { + if (examined++ % 128 === 0) { + await setImmediate(); + if (signal?.aborted) + throw new ArtifactReaderFailure( + "cancelled", + "ASAR operation cancelled", + ); + } + const frame = pending.at(-1); + if (frame === undefined) break; + const next = frame.children.next(); + if (next.done) { + pending.pop(); + continue; + } + const [name, raw] = next.value; + // A header member is one component, not a host path to normalize. POSIX + // backslashes remain literal; Windows cannot represent them as components. + if ( + name.length === 0 || + name === "." || + name === ".." || + name.includes("/") || + name.includes("\0") || + (process.platform === "win32" && name.includes("\\")) + ) + throw new ArtifactReaderFailure( + "path", + "ASAR header has an unsafe path component", + ); + const units = + frame.parent.length + name.length + (frame.parent.length === 0 ? 0 : 1); + // UTF-16 storage, plus records and traversal bookkeeping. Charge every + // occurrence even when the engine can share a string prefix. + retainedBytes += units * 2 + 256; + if (retainedBytes > MAX_LISTING_BYTES) + throw new ArtifactReaderFailure( + "limit", + "ASAR expanded listing exceeds its 64 MiB representation budget", + ); + const path = frame.parent.length === 0 ? name : `${frame.parent}/${name}`; + const metadata = nodeSchema.parse(raw); + if ( + metadata.files === undefined && + metadata.link === undefined && + metadata.size === undefined + ) + throw new ArtifactReaderFailure( + "format", + "ASAR entry has no directory, link or file metadata", + ); + entries.push({ path, metadata }); + if (metadata.files !== undefined) + pending.push({ + parent: path, + children: ownChildren(metadata.files), + }); + } + return entries.sort((left, right) => + compareUnicodeCodePoints(left.path, right.path), + ); +}; diff --git a/tests/boundary/filesystem/asarListingAdmission.test.ts b/tests/boundary/filesystem/asarListingAdmission.test.ts new file mode 100644 index 000000000..f5ebaa870 --- /dev/null +++ b/tests/boundary/filesystem/asarListingAdmission.test.ts @@ -0,0 +1,130 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { buffer } from "node:stream/consumers"; +import { setImmediate } from "node:timers/promises"; +import { expect, it } from "vitest"; + +import { AsarArtifactReader } from "../../../src/artifacts/AsarArtifactReader.js"; +import { readAsarListing } from "../../../src/artifacts/AsarListing.js"; +import { + asarHeaderBytes, + nestedAsarHeader, +} from "../../fixtures/asarHeader.js"; +import { inventoryArtifact } from "../../fixtures/artifactInventory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const archiveWith = async (bytes: Buffer) => { + const root = await createTestTempDirectory("rea-asar-listing-"); + const path = join(root, "app.asar"); + await writeFile(path, bytes); + return path; +}; + +it("interrupts expanded listing admission between traversal batches", async () => { + const controller = new AbortController(); + const header = { + files: Object.fromEntries( + Array.from({ length: 4000 }, (_, index) => [ + `f${index}`, + { size: 0, offset: "0" }, + ]), + ), + }; + const listing = readAsarListing(header, controller.signal); + await setImmediate(); + controller.abort(); + await expect(listing).rejects.toMatchObject({ reason: "cancelled" }); +}); + +it("rejects path expansion before publishing any inventory, preserving the input", async () => { + const bytes = nestedAsarHeader(64, "d".repeat(256), 4000); + expect(bytes.length).toBeLessThan(150_000); + const path = await archiveWith(bytes); + await expect(inventoryArtifact(path)).rejects.toMatchObject({ + reason: "limit", + message: "ASAR expanded listing exceeds its 64 MiB representation budget", + }); + expect(await readFile(path)).toEqual(bytes); +}); + +it("reads deeply nested packed metadata without recursive listing or member lookup", async () => { + const path = await archiveWith(nestedAsarHeader(3500, "d", 1)); + const reader = new AsarArtifactReader(path); + try { + let count = 0; + for await (const entry of reader.entries()) { + count++; + if (entry.kind === "file") { + expect(entry.path).toBe(`${"d/".repeat(3500)}f0`); + expect(await buffer(await reader.open(entry))).toEqual(Buffer.alloc(0)); + } + } + expect(count).toBe(3501); + } finally { + await reader.close(); + } +}); + +it.each(["..", ".", "", "a/../b", "a/b", "a\0b"])( + "rejects raw ASAR component %j before host normalization can alias another entry", + async (component) => { + const json = JSON.stringify({ + files: { [component]: { size: 0, offset: "0" } }, + }); + await expect( + inventoryArtifact(await archiveWith(asarHeaderBytes(json))), + ).rejects.toMatchObject({ reason: "path" }); + }, +); + +it("orders raw names portably and retains prototype-named entries and links", async () => { + const path = await archiveWith( + asarHeaderBytes( + '{"files":{"a0":{"size":0,"offset":"0"},"__proto__":{"size":0,"offset":"0"},"a":{"files":{"b":{"link":"a0"}}},"😀":{"size":0,"offset":"0"},"\uE000":{"size":0,"offset":"0"}}}', + ), + ); + const reader = new AsarArtifactReader(path); + try { + const entries = []; + for await (const entry of reader.entries()) + entries.push([entry.path, entry.kind]); + expect(entries).toEqual([ + ["__proto__", "file"], + ["a", "directory"], + ["a/b", "symlink"], + ["a0", "file"], + ["\uE000", "file"], + ["😀", "file"], + ]); + } finally { + await reader.close(); + } +}); + +it("preserves POSIX literal backslashes and rejects Windows component separators", async () => { + const path = await archiveWith( + asarHeaderBytes( + JSON.stringify({ files: { "a\\b": { size: 0, offset: "0" } } }), + ), + ); + const reader = new AsarArtifactReader(path); + try { + const list = async () => { + const entries = []; + for await (const entry of reader.entries()) entries.push(entry); + return entries; + }; + if (process.platform === "win32") { + await expect(list()).rejects.toMatchObject({ reason: "path" }); + } else { + const entries = await list(); + expect(entries).toHaveLength(1); + expect(entries[0]?.path).toBe("a\\b"); + expect(await buffer(await reader.open(entries[0]!))).toEqual( + Buffer.alloc(0), + ); + } + } finally { + await reader.close(); + } +}); diff --git a/tests/boundary/mcp/asarListingAdmissionMcp.test.ts b/tests/boundary/mcp/asarListingAdmissionMcp.test.ts new file mode 100644 index 000000000..c8db05e92 --- /dev/null +++ b/tests/boundary/mcp/asarListingAdmissionMcp.test.ts @@ -0,0 +1,60 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it } from "vitest"; + +import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { nestedAsarHeader } from "../../fixtures/asarHeader.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("reports expanded ASAR admission failure through MCP and permits a valid retry", async () => { + const root = await createTestTempDirectory("rea-asar-listing-mcp-"); + const path = join(root, "app.asar"); + const bytes = nestedAsarHeader(64, "d".repeat(256), 4000); + await writeFile(path, bytes); + const session = createTestBinarySession(new ArtifactProvider(process.env)); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "asar-listing-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + expect( + (await client.callTool({ name: "open_binary", arguments: { path } })) + .isError, + ).not.toBe(true); + const result = await client.callTool({ + name: "inspect_artifact", + arguments: {}, + }); + expect(result.isError).toBe(true); + expect(parseMcpToolError(result)).toMatchObject({ + error: { + code: "truncated", + category: "truncated", + }, + }); + expect(result.structuredContent).toBeUndefined(); + expect(await readFile(path)).toEqual(bytes); + const valid = join(root, "valid.asar"); + await writeFile(valid, nestedAsarHeader(1, "lib", 1)); + expect( + ( + await client.callTool({ + name: "open_binary", + arguments: { path: valid }, + }) + ).isError, + ).not.toBe(true); + expect( + (await client.callTool({ name: "inspect_artifact", arguments: {} })) + .isError, + ).not.toBe(true); + } finally { + await Promise.allSettled([client.close(), server.close(), session.close()]); + } +}); diff --git a/tests/fixtures/asarHeader.ts b/tests/fixtures/asarHeader.ts new file mode 100644 index 000000000..65fc0601b --- /dev/null +++ b/tests/fixtures/asarHeader.ts @@ -0,0 +1,27 @@ +/** Serialize a header-only ASAR, including Chromium pickle alignment. */ +export const asarHeaderBytes = (json: string): Buffer => { + const text = Buffer.from(json); + const headerSize = 8 + Math.ceil(text.length / 4) * 4; + const bytes = Buffer.alloc(8 + headerSize); + bytes.writeUInt32LE(4, 0); + bytes.writeUInt32LE(headerSize, 4); + bytes.writeUInt32LE(headerSize - 4, 8); + bytes.writeUInt32LE(text.length, 12); + text.copy(bytes, 16); + return bytes; +}; + +/** Nested metadata without a recursive fixture builder or real directories. */ +export const nestedAsarHeader = ( + depth: number, + name: string, + leaves: number, +): Buffer => { + let json = `{"files":{${Array.from( + { length: leaves }, + (_, index) => `"f${index}":{"size":0,"offset":"0"}`, + ).join(",")}}}`; + for (let index = 0; index < depth; index++) + json = `{"files":{${JSON.stringify(name)}:${json}}}`; + return asarHeaderBytes(json); +}; From 782cc3b060562f0397dd9f48827ef5e4ffc456a9 Mon Sep 17 00:00:00 2001 From: The one -Niu <912906590@qq.com> Date: Tue, 13 Oct 2026 23:47:41 +0800 Subject: [PATCH 24/36] refactor(process): give the prlimit producer budget one owner (#2698) Squash merge of https://github.com/morluto/rea/pull/2698. Co-authored-by: NIU-123370 <191000457+NIU-123370@users.noreply.github.com> --- scripts/verify/architecture-guards.mjs | 24 +++++++++++++++ src/captures/TsharkCaptureProvider.ts | 20 ++---------- src/process/LinuxResourceLimits.test.ts | 41 ++++++++++++++++++++++++- src/process/LinuxResourceLimits.ts | 23 ++++++++++++++ src/signals/SigrokRecordingProvider.ts | 20 ++---------- 5 files changed, 93 insertions(+), 35 deletions(-) diff --git a/scripts/verify/architecture-guards.mjs b/scripts/verify/architecture-guards.mjs index 967e210de..3ca254d7e 100644 --- a/scripts/verify/architecture-guards.mjs +++ b/scripts/verify/architecture-guards.mjs @@ -89,6 +89,30 @@ check( ["src/domain/javascript/javascriptSemanticTraversal.ts"], ); +// A producer that pins both the soft and hard ceiling of every resource must take +// the vector from one owner. Audited 2026-10-13 against the other prlimit lanes: +// src/pcap/PcapNativeBound.ts pairs soft and hard but budgets 300 CPU seconds and +// has no resident-input ceiling; src/evm/EvmoleInterfaceProvider.ts, +// src/firmware/FirmwareCommand.ts and src/javascript/recovery/WakaruCommand.ts +// spell soft-only forms (`--core=0:`, `--core=0`). Those four stay as they are; +// a new paired producer calls prlimitArgumentVector instead of spelling options. +check( + "inline paired prlimit ceilings (use prlimitArgumentVector from src/process/LinuxResourceLimits)", + files, + /--core=0:0/, + ["src/process/LinuxResourceLimits.ts", "src/pcap/PcapNativeBound.ts"], +); + +// Census is spelling-shaped, like the rules above: a lane that expresses the same +// ceilings differently (`1024 ** 3`, camelCase fields) is not caught here, which is +// why the four lanes above are listed rather than left to a future reader. +check( + "inline resident-input budget (use BOUNDED_PRODUCER_LIMITS from src/process/LinuxResourceLimits)", + files, + /input_bytes:\s*32 \* 1024 \* 1024/, + ["src/process/LinuxResourceLimits.ts"], +); + // Fixed-arity backtick keys audited 2026-10-10: every prefix field is NUL-free // (hex digests, file paths, enums, numbers, or compositeKey JSON which escapes // NUL), with free-form source strings only in final position. New backtick-\0 diff --git a/src/captures/TsharkCaptureProvider.ts b/src/captures/TsharkCaptureProvider.ts index 23496aac4..c4c89b88e 100644 --- a/src/captures/TsharkCaptureProvider.ts +++ b/src/captures/TsharkCaptureProvider.ts @@ -8,6 +8,8 @@ import { FIELD_RECORD_BUDGET, } from "./parseFieldDiscovery.js"; import { + BOUNDED_PRODUCER_LIMITS as LIMITS, + prlimitArgumentVector, readLinuxResourceLimits, type LinuxResourceLimits, } from "../process/LinuxResourceLimits.js"; @@ -60,15 +62,6 @@ export const TSHARK_PROVIDER_IDENTITY = { name: "REA TShark capture adapter", version: "tshark@4", } as const; -// Bound the resident input copy and retained text before allocation/collection. -const LIMITS = { - input_bytes: 32 * 1024 * 1024, - output_bytes: 4 * 1024 * 1024, - command_timeout_ms: 30_000, - address_space_bytes: 1024 * 1024 * 1024, - file_bytes: 64 * 1024 * 1024, - cpu_seconds: 30, -} as const; type Launcher = NonNullable[2]>["launcher"]; /** Inspect selected capture files only, retaining cleanup ownership across failures. */ @@ -165,14 +158,7 @@ export class TsharkCaptureProvider implements PacketCapturePort { options?.signal, ); effectiveLimits = await this.#resources.readLimits(limits); - const limitArguments = [ - `--as=${effectiveLimits.address_space_bytes}:${effectiveLimits.address_space_bytes}`, - `--fsize=${effectiveLimits.file_bytes}:${effectiveLimits.file_bytes}`, - `--cpu=${effectiveLimits.cpu_seconds}:${effectiveLimits.cpu_seconds}`, - "--core=0:0", - "--", - command, - ]; + const limitArguments = prlimitArgumentVector(effectiveLimits, command); phase = "workspace"; root = await this.createRuntime(); this.#resources.retain(root, undefined); diff --git a/src/process/LinuxResourceLimits.test.ts b/src/process/LinuxResourceLimits.test.ts index 69a584d74..0b19f773e 100644 --- a/src/process/LinuxResourceLimits.test.ts +++ b/src/process/LinuxResourceLimits.test.ts @@ -1,5 +1,9 @@ import { expect, it } from "vitest"; -import { clampLinuxResourceLimits } from "./LinuxResourceLimits.js"; +import { + BOUNDED_PRODUCER_LIMITS, + clampLinuxResourceLimits, + prlimitArgumentVector, +} from "./LinuxResourceLimits.js"; const configured = { address_space_bytes: 1073741824, file_bytes: 67108864, @@ -41,3 +45,38 @@ it("refuses missing, ambiguous or malformed kernel limits before launch", () => ]) expect(() => clampLinuxResourceLimits(configured, value)).toThrow(); }); +it("pins the shared producer budget in one place", () => { + expect(BOUNDED_PRODUCER_LIMITS).toEqual({ + input_bytes: 33554432, + output_bytes: 4194304, + command_timeout_ms: 30000, + address_space_bytes: 1073741824, + file_bytes: 67108864, + cpu_seconds: 30, + }); +}); +it("pairs each effective ceiling with itself before the producer argv", () => { + expect( + prlimitArgumentVector(BOUNDED_PRODUCER_LIMITS, "/bin/producer"), + ).toEqual([ + "--as=1073741824:1073741824", + "--fsize=67108864:67108864", + "--cpu=30:30", + "--core=0:0", + "--", + "/bin/producer", + ]); + expect( + prlimitArgumentVector( + clampLinuxResourceLimits(BOUNDED_PRODUCER_LIMITS, report("10 20")), + "/bin/producer", + ), + ).toEqual([ + "--as=10:10", + "--fsize=67108864:67108864", + "--cpu=30:30", + "--core=0:0", + "--", + "/bin/producer", + ]); +}); diff --git a/src/process/LinuxResourceLimits.ts b/src/process/LinuxResourceLimits.ts index e6eefcefe..414eb13bb 100644 --- a/src/process/LinuxResourceLimits.ts +++ b/src/process/LinuxResourceLimits.ts @@ -15,6 +15,29 @@ const rows = { cpu_seconds: "Max cpu time", } as const; +/** Bound the resident input copy and retained text before allocation/collection. */ +export const BOUNDED_PRODUCER_LIMITS = { + input_bytes: 32 * 1024 * 1024, + output_bytes: 4 * 1024 * 1024, + command_timeout_ms: 30_000, + address_space_bytes: 1024 * 1024 * 1024, + file_bytes: 64 * 1024 * 1024, + cpu_seconds: 30, +} as const; + +/** Pin soft and hard ceilings together, then hand the producer argv to the limiter. */ +export const prlimitArgumentVector = ( + limits: LinuxResourceLimits, + command: string, +): string[] => [ + `--as=${limits.address_space_bytes}:${limits.address_space_bytes}`, + `--fsize=${limits.file_bytes}:${limits.file_bytes}`, + `--cpu=${limits.cpu_seconds}:${limits.cpu_seconds}`, + "--core=0:0", + "--", + command, +]; + /** Never raise either inherited soft policy or hard ceiling. */ export const clampLinuxResourceLimits = ( configured: LinuxResourceLimits, diff --git a/src/signals/SigrokRecordingProvider.ts b/src/signals/SigrokRecordingProvider.ts index f5f823a1a..541138a6a 100644 --- a/src/signals/SigrokRecordingProvider.ts +++ b/src/signals/SigrokRecordingProvider.ts @@ -7,6 +7,8 @@ import { parseDecoderDiscovery, } from "./parseDecoderDiscovery.js"; import { + BOUNDED_PRODUCER_LIMITS as LIMITS, + prlimitArgumentVector, readLinuxResourceLimits, type LinuxResourceLimits, } from "../process/LinuxResourceLimits.js"; @@ -59,15 +61,6 @@ export const SIGROK_PROVIDER_IDENTITY = { name: "REA sigrok recording adapter", version: "sigrok-cli@0.7.2", } as const; -// Bound the resident input copy and retained text before allocation/collection. -const LIMITS = { - input_bytes: 32 * 1024 * 1024, - output_bytes: 4 * 1024 * 1024, - command_timeout_ms: 30_000, - address_space_bytes: 1024 * 1024 * 1024, - file_bytes: 64 * 1024 * 1024, - cpu_seconds: 30, -} as const; type Launcher = NonNullable[2]>["launcher"]; /** Decode selected recordings only, retaining cleanup ownership across failures. */ @@ -163,14 +156,7 @@ export class SigrokRecordingProvider implements SignalRecordingPort { options?.signal, ); effectiveLimits = await this.#resources.readLimits(LIMITS); - const limitArguments = [ - `--as=${effectiveLimits.address_space_bytes}:${effectiveLimits.address_space_bytes}`, - `--fsize=${effectiveLimits.file_bytes}:${effectiveLimits.file_bytes}`, - `--cpu=${effectiveLimits.cpu_seconds}:${effectiveLimits.cpu_seconds}`, - "--core=0:0", - "--", - command, - ]; + const limitArguments = prlimitArgumentVector(effectiveLimits, command); phase = "workspace"; root = await this.createRuntime(); this.#resources.retain(root, undefined); From 8b1549a264a914a1aec97d6945fc68ec8c5e1f64 Mon Sep 17 00:00:00 2001 From: The one -Niu <912906590@qq.com> Date: Tue, 13 Oct 2026 23:47:55 +0800 Subject: [PATCH 25/36] test: share repeated boundary preambles to restore the jscpd gate (#2691) Squash merge of https://github.com/morluto/rea/pull/2691. Co-authored-by: NIU-123370 <191000457+NIU-123370@users.noreply.github.com> --- src/domain/reconstructionCoverage.fixture.ts | 10 ++ src/domain/reconstructionCoverage.test.ts | 92 ++++--------- src/native/NativeUiObservation.test.ts | 126 +++++------------- tests/boundary/cli/captureCleanup.test.ts | 93 +++++-------- .../boundary/filesystem/installScript.test.ts | 37 +++-- .../setupClientConfigCleanupOwnership.test.ts | 90 +++++++------ 6 files changed, 167 insertions(+), 281 deletions(-) diff --git a/src/domain/reconstructionCoverage.fixture.ts b/src/domain/reconstructionCoverage.fixture.ts index 0a51546b6..2d1e37a19 100644 --- a/src/domain/reconstructionCoverage.fixture.ts +++ b/src/domain/reconstructionCoverage.fixture.ts @@ -143,3 +143,13 @@ export const completeReconstructionCoverageData = () => { ], }); }; + +/** Complete workspace with its first verifier result and boundary narrowed once. */ +export const completeReconstructionCoverageFixture = () => { + const workspace = completeReconstructionCoverageData(); + const result = workspace.verifier_results[0]; + const boundary = workspace.boundaries[0]; + if (result === undefined || boundary === undefined) + throw new Error("Expected complete reconstruction coverage fixture"); + return { workspace, result, boundary }; +}; diff --git a/src/domain/reconstructionCoverage.test.ts b/src/domain/reconstructionCoverage.test.ts index ac7552571..628ad67c1 100644 --- a/src/domain/reconstructionCoverage.test.ts +++ b/src/domain/reconstructionCoverage.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { completeReconstructionCoverageData, + completeReconstructionCoverageFixture, RECONSTRUCTION_COVERAGE_NOW, reconstructionCoverageEvidenceId, } from "./reconstructionCoverage.fixture.js"; @@ -288,11 +289,8 @@ describe("reconstruction coverage verifier selection", () => { describe("reconstruction coverage conflicting latest verifier observations", () => { it("keeps conflicting tied assessments unknown regardless of input order", () => { - const workspace = completeReconstructionCoverageData(); - const result = workspace.verifier_results[0]; - const boundary = workspace.boundaries[0]; - if (result === undefined || boundary === undefined) - throw new Error("Expected complete reconstruction coverage fixture"); + const { workspace, result, boundary } = + completeReconstructionCoverageFixture(); const failed = { ...result, status: "fail" as const, @@ -338,11 +336,8 @@ describe("reconstruction coverage conflicting latest verifier observations", () describe("reconstruction coverage equivalent latest verifier observations", () => { it("coalesces equivalent equal-instant observations and retains all evidence", () => { - const workspace = completeReconstructionCoverageData(); - const result = workspace.verifier_results[0]; - const boundary = workspace.boundaries[0]; - if (result === undefined || boundary === undefined) - throw new Error("Expected complete reconstruction coverage fixture"); + const { workspace, result, boundary } = + completeReconstructionCoverageFixture(); const first = { ...result, observed_at: "2026-07-16T11:00:00.123456789012345678900Z", @@ -388,11 +383,8 @@ describe("reconstruction coverage equivalent latest verifier observations", () = describe("reconstruction coverage verifier timestamp selection", () => { it("lets a later unambiguous observation supersede an older tie", () => { - const workspace = completeReconstructionCoverageData(); - const result = workspace.verifier_results[0]; - const boundary = workspace.boundaries[0]; - if (result === undefined || boundary === undefined) - throw new Error("Expected complete reconstruction coverage fixture"); + const { workspace, result, boundary } = + completeReconstructionCoverageFixture(); const failed = { ...result, status: "fail" as const }; const latestPass = { ...result, @@ -412,50 +404,14 @@ describe("reconstruction coverage verifier timestamp selection", () => { ).toMatchObject({ status: "ready", reasons: [] }); }); - it("orders offset-bearing verifier timestamps chronologically", () => { - const workspace = completeReconstructionCoverageData(); - const result = workspace.verifier_results[0]; - if (result === undefined) - throw new Error("Expected fixture verifier result"); - const semantic = workspace; - const changed = createReconstructionCoverageData({ - ...semantic, - verifier_results: [ - { ...result, observed_at: "2026-07-16T08:00:00.000Z" }, - { - ...result, - observed_at: "2026-07-16T04:00:00.000-05:00", - status: "fail", - }, - ], - }); - - expect( - evaluateReconstructionClosure( - changed, - "replacement.cli", - RECONSTRUCTION_COVERAGE_NOW, - ), - ).toMatchObject({ - status: "failed", - reasons: [expect.objectContaining({ code: "verifier-failed" })], - }); - }); - - it("selects the later result when timestamps differ below millisecond precision", () => { - const workspace = completeReconstructionCoverageData(); - const result = workspace.verifier_results[0]; - if (result === undefined) - throw new Error("Expected fixture verifier result"); + /** Evaluate a workspace whose newest verifier observation fails the closure. */ + const expectNewestObservationFails = (passedAt: string, failedAt: string) => { + const { workspace, result } = completeReconstructionCoverageFixture(); const changed = createReconstructionCoverageData({ ...workspace, verifier_results: [ - { ...result, observed_at: "2026-07-16T11:00:00.1231Z" }, - { - ...result, - observed_at: "2026-07-16T11:00:00.1239Z", - status: "fail", - }, + { ...result, observed_at: passedAt }, + { ...result, observed_at: failedAt, status: "fail" }, ], }); @@ -469,15 +425,26 @@ describe("reconstruction coverage verifier timestamp selection", () => { status: "failed", reasons: [expect.objectContaining({ code: "verifier-failed" })], }); + }; + + it("orders offset-bearing verifier timestamps chronologically", () => { + expectNewestObservationFails( + "2026-07-16T08:00:00.000Z", + "2026-07-16T04:00:00.000-05:00", + ); + }); + + it("selects the later result when timestamps differ below millisecond precision", () => { + expectNewestObservationFails( + "2026-07-16T11:00:00.1231Z", + "2026-07-16T11:00:00.1239Z", + ); }); }); describe("reconstruction coverage verifier future timestamps", () => { it("treats an observation beyond millisecond-precision now as future", () => { - const workspace = completeReconstructionCoverageData(); - const result = workspace.verifier_results[0]; - if (result === undefined) - throw new Error("Expected fixture verifier result"); + const { workspace, result } = completeReconstructionCoverageFixture(); const changed = createReconstructionCoverageData({ ...workspace, verifier_results: [ @@ -505,10 +472,7 @@ describe("reconstruction coverage verifier future timestamps", () => { }); it("rejects verifier observations from the future", () => { - const workspace = completeReconstructionCoverageData(); - const result = workspace.verifier_results[0]; - if (result === undefined) - throw new Error("Expected fixture verifier result"); + const { workspace, result } = completeReconstructionCoverageFixture(); const semantic = workspace; const changed = createReconstructionCoverageData({ ...semantic, diff --git a/src/native/NativeUiObservation.test.ts b/src/native/NativeUiObservation.test.ts index 527afd8db..0d02693d8 100644 --- a/src/native/NativeUiObservation.test.ts +++ b/src/native/NativeUiObservation.test.ts @@ -35,6 +35,28 @@ const validPng = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jv4sAAAAASUVORK5CYII=", "base64", ); + +/** Screenshot payload as the native helper reports one PNG buffer. */ +const screenshotOf = (bytes: Buffer) => ({ + mime_type: "image/png", + base64: bytes.toString("base64"), + sha256: createHash("sha256").update(bytes).digest("hex"), + width: bytes.readUInt32BE(16), + height: bytes.readUInt32BE(20), +}); + +/** Observe the fixture snapshot carrying one screenshot payload. */ +const observeWithScreenshot = (screenshot: Record) => + observeNativeUi( + target, + "observe_native_ui", + { ...scope, screenshot: true }, + { + environment: {}, + invoke: async () => ({ ok: true, result: { ...snapshot, screenshot } }), + }, + ); + it.each([ { offset: 8, value: 12 }, { offset: 16, value: 0x8000_0000 }, @@ -44,49 +66,16 @@ it.each([ async ({ offset, value }) => { const bytes = Buffer.from(validPng); bytes.writeUInt32BE(value, offset); - const screenshot = { - mime_type: "image/png", - base64: bytes.toString("base64"), - sha256: createHash("sha256").update(bytes).digest("hex"), - width: bytes.readUInt32BE(16), - height: bytes.readUInt32BE(20), - }; - const result = await observeNativeUi( - target, - "observe_native_ui", - { ...scope, screenshot: true }, - { - environment: {}, - invoke: async () => ({ - ok: true, - result: { ...snapshot, screenshot }, - }), - }, - ); + const result = await observeWithScreenshot(screenshotOf(bytes)); expect(result.ok).toBe(false); }, ); describe("native UI screenshot validation", () => { it("rejects malformed or mismatched screenshot bytes from the helper", async () => { - const screenshot = { - mime_type: "image/png", - base64: validPng.toString("base64"), + const result = await observeWithScreenshot({ + ...screenshotOf(validPng), sha256: "0".repeat(64), - width: 1, - height: 1, - }; - const result = await observeNativeUi( - target, - "observe_native_ui", - { ...scope, screenshot: true }, - { - environment: {}, - invoke: async () => ({ - ok: true, - result: { ...snapshot, screenshot }, - }), - }, - ); + }); expect(result.ok).toBe(false); }); @@ -97,26 +86,10 @@ describe("native UI screenshot validation", () => { ])( "rejects invalid screenshot field variants from the helper", async (change) => { - const screenshot = { - mime_type: "image/png", - base64: validPng.toString("base64"), - sha256: createHash("sha256").update(validPng).digest("hex"), - width: 1, - height: 1, + const result = await observeWithScreenshot({ + ...screenshotOf(validPng), ...change, - }; - const result = await observeNativeUi( - target, - "observe_native_ui", - { ...scope, screenshot: true }, - { - environment: {}, - invoke: async () => ({ - ok: true, - result: { ...snapshot, screenshot }, - }), - }, - ); + }); expect(result.ok).toBe(false); }, ); @@ -126,49 +99,12 @@ describe("native UI screenshot validation", () => { const type = bytes.subarray(12, 16); type[0] = (type[0] ?? 0) | 0x80; expect(type.toString("ascii")).toBe("IHDR"); - const screenshot = { - mime_type: "image/png", - base64: bytes.toString("base64"), - sha256: createHash("sha256").update(bytes).digest("hex"), - width: 1, - height: 1, - }; - const result = await observeNativeUi( - target, - "observe_native_ui", - { ...scope, screenshot: true }, - { - environment: {}, - invoke: async () => ({ - ok: true, - result: { ...snapshot, screenshot }, - }), - }, - ); + const result = await observeWithScreenshot(screenshotOf(bytes)); expect(result.ok).toBe(false); }); it("accepts a self-consistent PNG screenshot from the helper", async () => { - const bytes = validPng; - const screenshot = { - mime_type: "image/png", - base64: bytes.toString("base64"), - sha256: createHash("sha256").update(bytes).digest("hex"), - width: 1, - height: 1, - }; - const result = await observeNativeUi( - target, - "observe_native_ui", - { ...scope, screenshot: true }, - { - environment: {}, - invoke: async () => ({ - ok: true, - result: { ...snapshot, screenshot }, - }), - }, - ); + const result = await observeWithScreenshot(screenshotOf(validPng)); expect(result.ok).toBe(true); }); diff --git a/tests/boundary/cli/captureCleanup.test.ts b/tests/boundary/cli/captureCleanup.test.ts index 6e5882cd4..40013d6a1 100644 --- a/tests/boundary/cli/captureCleanup.test.ts +++ b/tests/boundary/cli/captureCleanup.test.ts @@ -188,8 +188,12 @@ it("closes the service and retains an unexpected producer rejection", async () = }); }); -it("rejects an oversized selector budget before the CLI invokes its service", async () => { - const cli = Cli.create("capture-selector-test", { sync: false }); +/** Serve one inspection request whose service must never be reached. */ +const rejectBeforeService = async ( + name: string, + args: string[], +): Promise => { + const cli = Cli.create(name, { sync: false }); let called = false; registerCaptureCommands( cli, @@ -207,66 +211,41 @@ it("rejects an oversized selector budget before the CLI invokes its service", as }, ); let stdout = ""; - await cli.serve( - [ - "inspect-packet-capture", - "recording.pcap", - "--fields", - "x".repeat(8192), - "--display_filter", - "界".repeat(8192), - "--format", - "json", - ], - { - stdout: (text) => { - stdout += text; - }, - exit: () => {}, + await cli.serve(args, { + stdout: (text) => { + stdout += text; }, - ); + exit: () => {}, + }); expect(called).toBe(false); + return stdout; +}; + +it("rejects an oversized selector budget before the CLI invokes its service", async () => { + const stdout = await rejectBeforeService("capture-selector-test", [ + "inspect-packet-capture", + "recording.pcap", + "--fields", + "x".repeat(8192), + "--display_filter", + "界".repeat(8192), + "--format", + "json", + ]); expect(stdout).toContain("32 KiB"); }); it("rejects conflicting output options before the CLI invokes its service", async () => { - const cli = Cli.create("capture-window-test", { sync: false }); - let called = false; - registerCaptureCommands( - cli, - silentLogger, - {}, - { - inspectFields: async () => { - throw new Error("Unexpected field discovery call"); - }, - inspect: async () => { - called = true; - throw new Error("Must not inspect"); - }, - close: async () => {}, - }, - ); - let stdout = ""; - await cli.serve( - [ - "inspect-packet-capture", - "recording.pcap", - "--fields", - "ip.addr", - "--all_matches", - "--match_limit", - "20", - "--format", - "json", - ], - { - stdout: (text) => { - stdout += text; - }, - exit: () => {}, - }, - ); - expect(called).toBe(false); + const stdout = await rejectBeforeService("capture-window-test", [ + "inspect-packet-capture", + "recording.pcap", + "--fields", + "ip.addr", + "--all_matches", + "--match_limit", + "20", + "--format", + "json", + ]); expect(stdout).toContain("mutually exclusive"); }); diff --git a/tests/boundary/filesystem/installScript.test.ts b/tests/boundary/filesystem/installScript.test.ts index 0f2e14730..d7e7f78a3 100644 --- a/tests/boundary/filesystem/installScript.test.ts +++ b/tests/boundary/filesystem/installScript.test.ts @@ -209,13 +209,7 @@ describe("installer TTY fallback", { timeout: 20_000 }, () => { cwd: process.cwd(), env: { ...process.env, - HOME: fixture.home, - TMPDIR: fixture.temporary, - PATH: `${fixture.bin}:/usr/bin:/bin`, - FAKE_NPM_LOG: fixture.npmLog, - FAKE_REA_LOG: fixture.reaLog, - FAKE_REA_SOURCE: join(fixture.bin, "rea-source"), - FAKE_NPM_PREFIX: join(fixture.home, ".npm-global"), + ...installerEnvironment(fixture), TERM: "xterm-256color", }, name: "xterm-256color", @@ -581,6 +575,19 @@ const executable = async (path: string, source: string): Promise => { await chmod(path, 0o755); }; +/** Fixture-owned installer environment shared by the exec, pipe and PTY runs. */ +const installerEnvironment = ( + fixture: InstallerFixture, +): Record => ({ + HOME: fixture.home, + TMPDIR: fixture.temporary, + PATH: `${fixture.bin}:/usr/bin:/bin`, + FAKE_NPM_LOG: fixture.npmLog, + FAKE_REA_LOG: fixture.reaLog, + FAKE_REA_SOURCE: join(fixture.bin, "rea-source"), + FAKE_NPM_PREFIX: join(fixture.home, ".npm-global"), +}); + const runInstaller = async ( fixture: InstallerFixture, args: readonly string[] = [], @@ -590,13 +597,7 @@ const runInstaller = async ( execFileAsync("/bin/bash", [script, ...args], { env: { ...process.env, - HOME: fixture.home, - TMPDIR: fixture.temporary, - PATH: `${fixture.bin}:/usr/bin:/bin`, - FAKE_NPM_LOG: fixture.npmLog, - FAKE_REA_LOG: fixture.reaLog, - FAKE_REA_SOURCE: join(fixture.bin, "rea-source"), - FAKE_NPM_PREFIX: join(fixture.home, ".npm-global"), + ...installerEnvironment(fixture), ...overrides, }, }); @@ -618,13 +619,7 @@ const runPipedInstaller = async ( new Promise((resolve, reject) => { const child = spawn("/bin/bash", ["-s", "--", ...args], { env: { - HOME: fixture.home, - TMPDIR: fixture.temporary, - PATH: `${fixture.bin}:/usr/bin:/bin`, - FAKE_NPM_LOG: fixture.npmLog, - FAKE_REA_LOG: fixture.reaLog, - FAKE_REA_SOURCE: join(fixture.bin, "rea-source"), - FAKE_NPM_PREFIX: join(fixture.home, ".npm-global"), + ...installerEnvironment(fixture), ...overrides, }, stdio: ["pipe", "pipe", "pipe"], diff --git a/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts b/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts index ab2b5e5a7..bf7de8819 100644 --- a/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts +++ b/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts @@ -54,6 +54,42 @@ const holdReadHandle = (path: string, reason: string) => { }; }; +/** Linux setup host with its VS Code client configuration directory prepared. */ +const preparedVsCodeSetupHost = async (prefix: string) => { + const home = await createTestTempDirectory(prefix); + const host = systemSetupHost( + { platform: "linux", homeDirectory: home } as DoctorHost, + { HOME: home, XDG_CONFIG_HOME: join(home, "xdg") }, + ); + const client = (await host.supportedClients()).find( + ({ name }) => name === "vscode", + ); + if (client === undefined) throw new Error("VS Code setup client missing"); + await mkdir(dirname(client.configPath), { recursive: true }); + return { home, host, client }; +}; + +/** Release the held read handle and show host cleanup reuses it as a third attempt. */ +const expectCloseReclaimsHandle = async ( + host: { close?: () => Promise }, + closeAttempts: readonly object[], + release: () => void, +) => { + release(); + expect(await host.close?.()).toBeUndefined(); + expect(closeAttempts).toHaveLength(3); + expect(closeAttempts[2]).toBe(closeAttempts[0]); +}; + +/** Give up on the assertion path: release the handle and let cleanup close it. */ +const cleanupHeldHandle = async ( + host: { close?: () => Promise }, + release: () => void, +) => { + release(); + await host.close?.(); +}; + it("retains invalid UTF-8 diagnostics and the uninstall read owner when close fails", async () => { const home = await createTestTempDirectory("rea-uninstall-utf8-close-"); const path = join(home, "client.json"); @@ -77,13 +113,9 @@ it("retains invalid UTF-8 diagnostics and the uninstall read owner when close fa }); expect(await readFile(path)).toEqual(original); expect(closeAttempts).toHaveLength(2); - release(); - expect(await host.close?.()).toBeUndefined(); - expect(closeAttempts).toHaveLength(3); - expect(closeAttempts[2]).toBe(closeAttempts[0]); + await expectCloseReclaimsHandle(host, closeAttempts, release); } finally { - release(); - await host.close?.(); + await cleanupHeldHandle(host, release); } }); @@ -126,13 +158,9 @@ describe("ZCode fallback cleanup ownership", () => { await expect(readFile(client.configPath)).rejects.toMatchObject({ code: "ENOENT", }); - release(); - expect(await host.close?.()).toBeUndefined(); - expect(closeAttempts).toHaveLength(3); - expect(closeAttempts[2]).toBe(closeAttempts[0]); + await expectCloseReclaimsHandle(host, closeAttempts, release); } finally { - release(); - await host.close?.(); + await cleanupHeldHandle(host, release); } }, ); @@ -145,20 +173,9 @@ describe("setup client configuration cleanup ownership", () => { ] as const)( "stops configuration and retains the %s read owner until host close", async (encoding, original) => { - const home = await createTestTempDirectory("rea-setup-config-close-"); - const environment = { - HOME: home, - XDG_CONFIG_HOME: join(home, "xdg"), - }; - const host = systemSetupHost( - { platform: "linux", homeDirectory: home } as DoctorHost, - environment, + const { host, client } = await preparedVsCodeSetupHost( + "rea-setup-config-close-", ); - const client = (await host.supportedClients()).find( - ({ name }) => name === "vscode", - ); - if (client === undefined) throw new Error("VS Code setup client missing"); - await mkdir(dirname(client.configPath), { recursive: true }); await writeFile(client.configPath, original); const { closeAttempts, release } = holdReadHandle( @@ -210,32 +227,17 @@ describe("setup client configuration cleanup ownership", () => { expect(closeAttempts[0]).toBe(closeAttempts[1]); expect(await readFile(client.configPath)).toEqual(original); - release(); - expect(await host.close?.()).toBeUndefined(); - expect(closeAttempts).toHaveLength(3); - expect(closeAttempts[2]).toBe(closeAttempts[0]); + await expectCloseReclaimsHandle(host, closeAttempts, release); } finally { - release(); - await host.close?.(); + await cleanupHeldHandle(host, release); } }, ); it("waits for an admitted configuration read and rejects later admissions", async () => { - const home = await createTestTempDirectory("rea-setup-config-inflight-"); - const environment = { - HOME: home, - XDG_CONFIG_HOME: join(home, "xdg"), - }; - const host = systemSetupHost( - { platform: "linux", homeDirectory: home } as DoctorHost, - environment, + const { host, client } = await preparedVsCodeSetupHost( + "rea-setup-config-inflight-", ); - const client = (await host.supportedClients()).find( - ({ name }) => name === "vscode", - ); - if (client === undefined) throw new Error("VS Code setup client missing"); - await mkdir(dirname(client.configPath), { recursive: true }); await writeFile(client.configPath, '{"servers": {}}\n'); let beginRead!: () => void; From 2b98fc5f518f6da06a4f5157f5b7dd90014bbd6f Mon Sep 17 00:00:00 2001 From: LouisChen <65096591@qq.com> Date: Tue, 13 Oct 2026 23:48:09 +0800 Subject: [PATCH 26/36] fix(cli): reject empty analyze and artifact target paths (#2687) Squash merge of https://github.com/morluto/rea/pull/2687. Co-authored-by: LouisChen <3361008+chqcjq@users.noreply.github.com> Co-authored-by: morluto --- src/cli/artifactCommands.ts | 2 +- src/cli/coreBinaryCommands.ts | 5 ++- tests/acceptance/emptyTargetPathCli.test.ts | 35 +++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 tests/acceptance/emptyTargetPathCli.test.ts diff --git a/src/cli/artifactCommands.ts b/src/cli/artifactCommands.ts index 6b6b452e4..af2eab068 100644 --- a/src/cli/artifactCommands.ts +++ b/src/cli/artifactCommands.ts @@ -225,7 +225,7 @@ const registerInspectionCommand = ( description: "Inspect one artifact and return all available observations and next probes", args: z.object({ - path: z.string().describe("Application or package path"), + path: z.string().min(1).describe("Application or package path"), }), options: z.object({ integrityPolicy: z diff --git a/src/cli/coreBinaryCommands.ts b/src/cli/coreBinaryCommands.ts index 710b39181..8902cf4e9 100644 --- a/src/cli/coreBinaryCommands.ts +++ b/src/cli/coreBinaryCommands.ts @@ -48,7 +48,10 @@ const registerOverviewCommands = ( cli.command(CLI_COMMANDS.analyze, { description: "Get an overview of an app", args: z.object({ - path: z.string().describe("App, program, or analysis database path"), + path: z + .string() + .min(1) + .describe("App, program, or analysis database path"), }), options: overviewOptions.extend({ integrityPolicy: javascriptApplicationOptions.shape.integrityPolicy, diff --git a/tests/acceptance/emptyTargetPathCli.test.ts b/tests/acceptance/emptyTargetPathCli.test.ts new file mode 100644 index 000000000..6f6210d31 --- /dev/null +++ b/tests/acceptance/emptyTargetPathCli.test.ts @@ -0,0 +1,35 @@ +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest.for(["analyze", "inspect-artifact"])( + "%s rejects an explicitly empty target before resolving it against cwd", + async (command, { cli }) => { + const cwd = await createTestTempDirectory("rea-empty-target-"); + const result = await cli.run({ + arguments: [command, "", "--json"], + cwd, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ code: "VALIDATION_ERROR" }); + expect(result.stdout).toContain("path"); + expect(result.stdout).not.toContain("directory_requires_file"); + }, +); + +cliTest( + "inspect-artifact still admits an explicitly selected current directory", + async ({ cli }) => { + const cwd = await createTestTempDirectory("rea-explicit-target-"); + const result = await cli.run({ + arguments: ["inspect-artifact", ".", "--json"], + cwd, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "target_unavailable", + details: { path: cwd, constraint: "directory_requires_file" }, + }); + }, +); From 522b9927c80bf0a3d0ab3343a648a158e94eba0c Mon Sep 17 00:00:00 2001 From: The one -Niu <912906590@qq.com> Date: Tue, 13 Oct 2026 23:48:24 +0800 Subject: [PATCH 27/36] feat(recorded-crash): decode AArch64 core register notes (#2505) Squash merge of https://github.com/morluto/rea/pull/2505. Co-authored-by: NIU-123370 <191000457+NIU-123370@users.noreply.github.com> --- bridge/pwntools/recorded_crash_impl.py | 41 ++++-- docs/recorded-crashes.md | 12 +- .../RecordedCrashService.test.ts | 23 +++ .../binaryDiagnostics/RecordedCrashService.ts | 5 +- .../native/binaryDiagnosticsToolContracts.ts | 2 +- src/domain/native/recordedCrash.test.ts | 95 +++++++++++- src/domain/native/recordedCrash.ts | 103 ++++++++++++- .../RecordedCrashSourceBindings.test.ts | 135 ++++++++++++++++++ .../pwntools/RecordedCrashSourceBindings.ts | 25 ++-- .../RecordedCrashStructureBindings.ts | 9 +- .../RecordedCrashStructureBindings.test.ts | 34 +++++ .../recordedCrashProvider.test.ts | 23 ++- .../binaryDiagnostics/recordedCrash.ts | 115 ++++++++------- 13 files changed, 532 insertions(+), 90 deletions(-) create mode 100644 src/native/pwntools/RecordedCrashSourceBindings.test.ts diff --git a/bridge/pwntools/recorded_crash_impl.py b/bridge/pwntools/recorded_crash_impl.py index 208e59fe6..789c7ca94 100644 --- a/bridge/pwntools/recorded_crash_impl.py +++ b/bridge/pwntools/recorded_crash_impl.py @@ -11,6 +11,12 @@ import sys PROFILE = "pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2" +# Linux ET_CORE machine identifiers whose PRSTATUS layout is interpreted with +# unchanged pwntools 4.15.0 ctypes. Both share pr_cursig@12, pr_pid@32 and +# pr_reg@112; only the register window differs: amd64 is 27 x 8 bytes for a +# 336-byte descriptor, AArch64 is 34 x 8 bytes for a 392-byte descriptor. +CORE_MACHINES = (62, 183) + class CoreFailure(Exception): def __init__(self, reason, message): @@ -51,12 +57,27 @@ def inspect_core(path, cache): # Other declared OSABIs must never select Linux note ctypes. if content[7] not in (0, 3) or content[8] != 0: raise CoreFailure("unsupported-target", "Initial recorded-core profile requires System V or GNU/Linux OSABI with ABI version zero; selected OSABI=" + str(content[7]) + ", ABI version=" + str(content[8]) + ".") - if int.from_bytes(content[16:18], "little") != 4 or int.from_bytes(content[18:20], "little") != 62: - raise CoreFailure("unsupported-target", "Initial recorded-core profile requires x86-64 ET_CORE.") + machine = int.from_bytes(content[18:20], "little") + if int.from_bytes(content[16:18], "little") != 4 or machine not in CORE_MACHINES: + raise CoreFailure("unsupported-target", "Initial recorded-core profile requires ET_CORE for x86-64 or AArch64; selected ET_TYPE=" + str(int.from_bytes(content[16:18], "little")) + ", e_machine=" + str(machine) + ".") from elftools.common.exceptions import ELFError from elftools.common.utils import struct_parse from elftools.elf.elffile import ELFFile - from pwnlib.elf.datatypes import elf_prstatus_amd64, elf_siginfo_64, user_regs_struct_amd64 + from pwnlib.elf.datatypes import ( + elf_prstatus_aarch64, + elf_prstatus_amd64, + elf_siginfo_64, + user_regs_struct_aarch64, + user_regs_struct_amd64, + ) + if machine == 62: + profile_label = "x86-64" + architecture = "x86_64-little-endian" + prstatus_type, registers_type = elf_prstatus_amd64, user_regs_struct_amd64 + else: + profile_label = "AArch64" + architecture = "aarch64-little-endian" + prstatus_type, registers_type = elf_prstatus_aarch64, user_regs_struct_aarch64 try: image = ELFFile(io.BytesIO(content)) header = image.header @@ -107,17 +128,17 @@ def inspect_core(path, cache): if owner != b"CORE\0": continue if note.n_type == "NT_PRSTATUS": - if len(descriptor) < ctypes.sizeof(elf_prstatus_amd64): - raise CoreFailure("format", "Linux amd64 NT_PRSTATUS descriptor is truncated.") - status = elf_prstatus_amd64.from_buffer_copy(descriptor) + if len(descriptor) < ctypes.sizeof(prstatus_type): + raise CoreFailure("format", "Linux " + profile_label + " NT_PRSTATUS descriptor is truncated.") + status = prstatus_type.from_buffer_copy(descriptor) registers = [] - for name, kind in user_regs_struct_amd64._fields_: - relative = elf_prstatus_amd64.pr_reg.offset + getattr(user_regs_struct_amd64, name).offset + for name, kind in registers_type._fields_: + relative = prstatus_type.pr_reg.offset + getattr(registers_type, name).offset registers.append({"name": name, "value": hex(getattr(status.pr_reg, name)), "location": location(descriptor_start + relative, ctypes.sizeof(kind), end)}) threads.append({"note_index": note_index, "historical_pid": ctypes.c_int32(status.pr_pid).value, "recorded_current_signal": status.pr_cursig, "registers": registers}) elif note.n_type == "NT_SIGINFO": if len(descriptor) < ctypes.sizeof(elf_siginfo_64): - raise CoreFailure("format", "Linux amd64 NT_SIGINFO descriptor is truncated.") + raise CoreFailure("format", "Linux " + profile_label + " NT_SIGINFO descriptor is truncated.") signal = elf_siginfo_64.from_buffer_copy(descriptor) number = ctypes.c_int32(signal.si_signo).value code = ctypes.c_int32(signal.si_code).value @@ -125,7 +146,7 @@ def inspect_core(path, cache): signals.append({"note_index": note_index, "number": number, "code": code, "errno": ctypes.c_int32(signal.si_errno).value, "fault_address": hex(signal.sigfault_addr) if known else None, "fault_address_meaning": "recorded-sigsegv-address" if known else "unknown", "thread_association": "unknown"}) - return {"format": "elf-core", "architecture": "x86_64-little-endian", "target_execution": "not-performed", "live_process_identity": "unknown", + return {"format": "elf-core", "architecture": architecture, "target_execution": "not-performed", "live_process_identity": "unknown", "segments": segments, "notes": notes, "note_padding": padding, "threads": threads, "signals": signals, "note_interpretation_completeness": "unknown", "limitations": ["The initial Linux note profile accepts System V/unspecified or GNU/Linux OSABI with ABI version zero; an unspecified OSABI alone does not prove operating-system origin.", diff --git a/docs/recorded-crashes.md b/docs/recorded-crashes.md index 8b1a51662..bd508d051 100644 --- a/docs/recorded-crashes.md +++ b/docs/recorded-crashes.md @@ -1,7 +1,10 @@ # Recorded crash evidence `inspect_recorded_crash` / `rea inspect-recorded-crash` inspect a caller-selected -Linux ELF64 x86-64 little-endian core independently of an active binary target. +Linux ELF64 little-endian core independently of an active binary target. Two +architecture profiles are interpreted, x86-64 and AArch64; each binds registers +to its own `NT_PRSTATUS` layout, and any other `e_machine` is refused as +`unsupported_target` before note interpretation. ```sh export REA_PWNTOOLS_PYTHON=/absolute/existing/venv/bin/python @@ -10,8 +13,8 @@ rea inspect-recorded-crash ./crash.core --json The selected Python needs unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2. REA does not install these dependencies. ELFFile/its original ELF structures and -pwntools' original Linux amd64 ctypes provide parsing and ABI layout. The adapter -adds bounds, representation preservation and lifecycle ownership. +pwntools' original Linux amd64 and aarch64 ctypes provide parsing and ABI layout. +The adapter adds bounds, representation preservation and lifecycle ownership. The initial Linux note profile accepts System V/unspecified and GNU/Linux OSABI with ABI version zero. Other declared ABIs return `unsupported_target` before note interpretation; an unspecified OSABI alone does not prove OS origin. @@ -27,7 +30,8 @@ note interpretation; an unspecified OSABI alone does not prove OS origin. - Every exact Linux CORE PRSTATUS note as a separate recorded thread, with historical PID, signal and lossless uint64 registers bound to source bytes. Each supported note has exactly one interpretation; each thread contains the - complete 27-register ABI set, with names bound to their original ABI offsets. + complete ABI register set for its architecture (27 registers for amd64, + 34 for AArch64), with names bound to their original ABI offsets. Physical note ranges cannot overlap or leave segment gaps. ELF and note headers are independently bound to the snapshot, including extended program counts. - Signed SIGINFO number/code/errno. Initially only SIGSEGV codes 1/2 establish the diff --git a/src/application/binaryDiagnostics/RecordedCrashService.test.ts b/src/application/binaryDiagnostics/RecordedCrashService.test.ts index cdf7cbd8f..8d8f8e94c 100644 --- a/src/application/binaryDiagnostics/RecordedCrashService.test.ts +++ b/src/application/binaryDiagnostics/RecordedCrashService.test.ts @@ -30,6 +30,29 @@ it("labels an envelope containing debugger-derived maps as derived", async () => expect(evidence.raw_result).toBeNull(); }); +it.each([ + ["x86_64-little-endian", "x86_64"], + ["aarch64-little-endian", "arm64"], +] as const)( + "names the CPU family carried by a %s core on created Evidence", + async (architecture, expected) => { + const fixture = recordedCrashFixture("/selected.core", architecture); + const service = new RecordedCrashService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, + identity: RECORDED_CRASH_TEST_PROVIDER, + inspect: () => Promise.resolve(ok(fixture)), + }); + const result = await service.inspect({ + path: fixture.artifact.path, + include_debugger_context: false, + }); + if (!result.ok) throw result.error; + expect(parseEvidence(result.value).subject?.architecture).toBe(expected); + }, +); + it.each([ { path: "relative.elf" }, { path: "/selected.core", approval: true }, diff --git a/src/application/binaryDiagnostics/RecordedCrashService.ts b/src/application/binaryDiagnostics/RecordedCrashService.ts index df137f83e..67a57ca9a 100644 --- a/src/application/binaryDiagnostics/RecordedCrashService.ts +++ b/src/application/binaryDiagnostics/RecordedCrashService.ts @@ -10,6 +10,7 @@ import { import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { inspectRecordedCrashInputSchema, + RECORDED_LINUX_CORE_PROFILES, recordedCrashSchema, } from "../../domain/native/recordedCrash.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; @@ -101,7 +102,9 @@ export class RecordedCrashService { { path: value.artifact.path, format: "elf", - architecture: "x86_64", + architecture: + RECORDED_LINUX_CORE_PROFILES[value.architecture] + .evidenceArchitecture, sha256: value.artifact.sha256, }, this.provider.identity, diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts index 64a80e42d..8c872f9d9 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -67,7 +67,7 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_recorded_crash"), kind: "native-provider", description: - "Inspect a supplied Linux ELF64 amd64 little-endian core without launching its target or attaching to any live PID. Returns all recorded segments, raw note owners/descriptors and source ranges, every Linux CORE PRSTATUS thread/register and SIGINFO fields inline with SHA-256 Evidence. Historical PID is recorded metadata; current process/file identity, note coverage and signal-thread associations stay unknown. Optionally add derived core-only GDB/pwndbg mapping candidates through explicit BYO configuration; zero map flags mean unknown permissions. Uses the same stable snapshot for both stages, bounded complete output and owned cleanup. Initial Linux x64 profile: unchanged pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2; optional unchanged pwndbg 2026.09.15. No implicit executable/library pairing, GDB command language, host configuration changes or dependency installation.", + "Inspect a supplied Linux ELF64 amd64 or AArch64 little-endian core without launching its target or attaching to any live PID. Returns all recorded segments, raw note owners/descriptors and source ranges, every Linux CORE PRSTATUS thread/register and SIGINFO fields inline with SHA-256 Evidence. Historical PID is recorded metadata; current process/file identity, note coverage and signal-thread associations stay unknown. Optionally add derived core-only GDB/pwndbg mapping candidates through explicit BYO configuration; zero map flags mean unknown permissions. Uses the same stable snapshot for both stages, bounded complete output and owned cleanup. Linux x64 host profile: unchanged pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2; optional unchanged pwndbg 2026.09.15. No implicit executable/library pairing, GDB command language, host configuration changes or dependency installation.", inputSchema: inspectRecordedCrashInputSchema, outputSchema: evidenceResultOf(recordedCrashSchema), examples: [ diff --git a/src/domain/native/recordedCrash.test.ts b/src/domain/native/recordedCrash.test.ts index 7a050d417..18414128b 100644 --- a/src/domain/native/recordedCrash.test.ts +++ b/src/domain/native/recordedCrash.test.ts @@ -1,7 +1,8 @@ -import { expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { inspectRecordedCrashInputSchema, recordedCrashSchema, + type RecordedCrash, } from "./recordedCrash.js"; import { recordedCrashFixture, @@ -282,3 +283,95 @@ it("keeps absent map flags unknown and validates reported permission bits", () = }).success, ).toBe(false); }); + +describe("recorded AArch64 core profile", () => { + it("accepts the complete AArch64 register window in ABI order", () => { + const value = recordedCrashSchema.parse( + recordedCrashFixture("/selected/aarch64.core", "aarch64-little-endian"), + ); + expect(value.architecture).toBe("aarch64-little-endian"); + const registers = value.threads[0]?.registers ?? []; + expect(registers.map(({ name }) => name)).toEqual([ + ...Array.from({ length: 31 }, (_, index) => `x${String(index)}`), + "sp", + "pc", + "pstate", + ]); + expect(registers[14]?.value).toBe("0x1122334455667788"); + expect(registers.every(({ location }) => location.bytes === "0x8")).toBe( + true, + ); + }); + + it("keeps the AArch64 note ranges inside their recorded segment", () => { + const value = recordedCrashSchema.parse( + recordedCrashSignalFixture(undefined, "aarch64-little-endian"), + ); + const note = value.notes[0]; + const segment = value.segments[0]; + if (note === undefined || segment === undefined) + throw new Error("Expected recorded notes"); + expect( + BigInt(note.location.offset) + BigInt(note.location.bytes), + ).toBeLessThanOrEqual(BigInt(segment.offset) + BigInt(segment.file_size)); + expect(value.signals[0]?.fault_address).toBe("0x1122334455667788"); + }); + + it.each([ + [ + "an amd64 register name inside an AArch64 window", + (v: RecordedCrash) => ({ + ...v, + threads: v.threads.map((thread) => ({ + ...thread, + registers: thread.registers.map((register, index) => + index === 0 ? { ...register, name: "rax" } : register, + ), + })), + }), + ], + [ + "the amd64 register count", + (v: RecordedCrash) => ({ + ...v, + threads: v.threads.map((thread) => ({ + ...thread, + registers: thread.registers.slice(0, 27), + })), + }), + ], + [ + "a 4-byte AArch64 register source", + (v: RecordedCrash) => ({ + ...v, + threads: v.threads.map((thread) => ({ + ...thread, + registers: thread.registers.map((register, index) => + index === 0 + ? { + ...register, + location: { ...register.location, bytes: "0x4" }, + } + : register, + ), + })), + }), + ], + ] as const)("rejects %s", (_name, change) => { + const value = recordedCrashFixture( + "/selected/aarch64.core", + "aarch64-little-endian", + ); + expect(recordedCrashSchema.safeParse(change(value)).success).toBe(false); + }); + + it("rejects an architecture outside the interpreted profiles", () => { + const value = recordedCrashFixture(); + expect( + recordedCrashSchema.safeParse({ + ...value, + architecture: "arm-little-endian", + }).success, + ).toBe(false); + }); +}); diff --git a/src/domain/native/recordedCrash.ts b/src/domain/native/recordedCrash.ts index d4b533838..8bd04cac1 100644 --- a/src/domain/native/recordedCrash.ts +++ b/src/domain/native/recordedCrash.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import type { BinaryArchitecture } from "../binaryTargetTypes.js"; import { localPathStringSchema } from "../localPath.js"; import { CANONICAL_BASE64_PATTERN } from "../stringPatterns.js"; import { recordedNoteCoverageIssues } from "./recordedCrashNoteCoverage.js"; @@ -18,7 +19,7 @@ const diagnostics = z.strictObject({ const LINUX_CORE_OWNER_BASE64 = "Q09SRQA="; /** Complete register identities in the supported Linux amd64 PRSTATUS ABI order. */ -export const RECORDED_LINUX_AMD64_REGISTERS: readonly string[] = [ +const RECORDED_LINUX_AMD64_REGISTERS: readonly string[] = [ "r15", "r14", "r13", @@ -47,7 +48,95 @@ export const RECORDED_LINUX_AMD64_REGISTERS: readonly string[] = [ "fs", "gs", ]; -const supportedRegisters = new Set(RECORDED_LINUX_AMD64_REGISTERS); +const RECORDED_LINUX_AARCH64_REGISTERS: readonly string[] = [ + "x0", + "x1", + "x2", + "x3", + "x4", + "x5", + "x6", + "x7", + "x8", + "x9", + "x10", + "x11", + "x12", + "x13", + "x14", + "x15", + "x16", + "x17", + "x18", + "x19", + "x20", + "x21", + "x22", + "x23", + "x24", + "x25", + "x26", + "x27", + "x28", + "x29", + "x30", + "sp", + "pc", + "pstate", +]; + +/** Recorded-core architectures whose Linux note ABI is interpreted. */ +export const RECORDED_LINUX_CORE_ARCHITECTURES = [ + "x86_64-little-endian", + "aarch64-little-endian", +] as const; + +export type RecordedCrashArchitecture = + (typeof RECORDED_LINUX_CORE_ARCHITECTURES)[number]; + +/** + * ABI facts for one recorded-core architecture, measured from unchanged pwntools + * 4.15.0 `elf_prstatus_*` and `user_regs_struct_*` structures and mirrored by + * `bridge/pwntools/recorded_crash_impl.py`. Both profiles place `pr_cursig` at + * 12, `pr_pid` at 32 and the register window at 112; the ELF machine, the window + * contents and the Evidence CPU family differ per profile. + */ +export const RECORDED_LINUX_CORE_PROFILES: Record< + RecordedCrashArchitecture, + { + readonly machine: number; + readonly evidenceArchitecture: BinaryArchitecture; + readonly registers: readonly string[]; + readonly registerBytes: number; + readonly registerOffset: number; + readonly prstatusBytes: number; + } +> = { + "x86_64-little-endian": { + machine: 62, + evidenceArchitecture: "x86_64", + registers: RECORDED_LINUX_AMD64_REGISTERS, + registerBytes: 8, + registerOffset: 112, + prstatusBytes: 336, + }, + "aarch64-little-endian": { + machine: 183, + evidenceArchitecture: "arm64", + registers: RECORDED_LINUX_AARCH64_REGISTERS, + registerBytes: 8, + registerOffset: 112, + prstatusBytes: 392, + }, +}; + +const recordedCoreRegisterSets: Record< + RecordedCrashArchitecture, + ReadonlySet +> = { + "x86_64-little-endian": new Set(RECORDED_LINUX_AMD64_REGISTERS), + "aarch64-little-endian": new Set(RECORDED_LINUX_AARCH64_REGISTERS), +}; /** Select an explicit recording and optionally request debugger-derived mapping context. */ export const inspectRecordedCrashInputSchema = z.strictObject({ @@ -108,7 +197,7 @@ const recordedCrashObjectSchema = z.strictObject({ bytes: index, }), format: z.literal("elf-core"), - architecture: z.literal("x86_64-little-endian"), + architecture: z.enum(RECORDED_LINUX_CORE_ARCHITECTURES), target_execution: z.literal("not-performed"), live_process_identity: z.literal("unknown"), segments: z.array( @@ -284,6 +373,8 @@ export const recordedCrashSchema = recordedCrashObjectSchema.superRefine( "Each supported raw note requires exactly one corresponding interpretation.", ); } + const profile = RECORDED_LINUX_CORE_PROFILES[value.architecture]; + const profileRegisters = recordedCoreRegisterSets[value.architecture]; for (const thread of value.threads) { const note = value.notes[thread.note_index]; if ( @@ -295,8 +386,8 @@ export const recordedCrashSchema = recordedCrashObjectSchema.superRefine( continue; } if ( - thread.registers.length !== RECORDED_LINUX_AMD64_REGISTERS.length || - !thread.registers.every(({ name }) => supportedRegisters.has(name)) || + thread.registers.length !== profile.registers.length || + !thread.registers.every(({ name }) => profileRegisters.has(name)) || new Set(thread.registers.map(({ name }) => name)).size !== thread.registers.length ) @@ -305,7 +396,7 @@ export const recordedCrashSchema = recordedCrashObjectSchema.superRefine( ); for (const register of thread.registers) if ( - BigInt(register.location.bytes) !== 8n || + BigInt(register.location.bytes) !== BigInt(profile.registerBytes) || !within( register.location, BigInt(note.descriptor_location.offset), diff --git a/src/native/pwntools/RecordedCrashSourceBindings.test.ts b/src/native/pwntools/RecordedCrashSourceBindings.test.ts new file mode 100644 index 000000000..892fc1049 --- /dev/null +++ b/src/native/pwntools/RecordedCrashSourceBindings.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; + +import { + RECORDED_LINUX_CORE_ARCHITECTURES, + RECORDED_LINUX_CORE_PROFILES, +} from "../../domain/native/recordedCrash.js"; +import { + recordedCrashFixture, + recordedCrashFixtureBytes, +} from "../../../tests/fixtures/binaryDiagnostics/recordedCrash.js"; +import { validateRecordedCrashSources } from "./RecordedCrashSourceBindings.js"; + +const diagnostics = { stdout: "", stderr: "", truncated: false } as const; + +const bind = ( + architecture?: "x86_64-little-endian" | "aarch64-little-endian", +) => { + const report = recordedCrashFixture("/selected.core", architecture); + return { report, bytes: recordedCrashFixtureBytes(report) }; +}; + +describe("recorded core source bindings", () => { + it.each(RECORDED_LINUX_CORE_ARCHITECTURES)( + "binds each %s register to its own source bytes", + (architecture) => { + const { report, bytes } = bind(architecture); + expect(() => + validateRecordedCrashSources(report, bytes, diagnostics), + ).not.toThrow(); + }, + ); + + it("measures the AArch64 register window one slot wide per name", () => { + const profile = RECORDED_LINUX_CORE_PROFILES["aarch64-little-endian"]; + expect(profile.registers.length).toBe(34); + expect(profile.prstatusBytes).toBe(392); + expect(profile.machine).toBe(183); + // The fixtures and the bindings both read these numbers, so they are pinned + // here against the ABI record rather than only used consistently. + expect(profile.registerOffset).toBe(112); + expect(profile.registerBytes).toBe(8); + const amd64 = RECORDED_LINUX_CORE_PROFILES["x86_64-little-endian"]; + expect(amd64.registers.length).toBe(27); + expect(amd64.prstatusBytes).toBe(336); + expect(amd64.machine).toBe(62); + expect(amd64.registerOffset).toBe(112); + expect(amd64.registerBytes).toBe(8); + }); + + it("stamps each crafted core with the machine its profile declares", () => { + for (const architecture of RECORDED_LINUX_CORE_ARCHITECTURES) { + const bytes = bind(architecture).bytes; + expect(bytes.readUInt16LE(18)).toBe( + RECORDED_LINUX_CORE_PROFILES[architecture].machine, + ); + } + }); + + it("rejects an AArch64 register bound to another slot", () => { + const { report, bytes } = bind("aarch64-little-endian"); + const shifted = { + ...report, + threads: report.threads.map((thread) => ({ + ...thread, + registers: thread.registers.map((register, index) => + index === 3 + ? { + ...register, + location: { + ...register.location, + offset: `0x${(BigInt(register.location.offset) + 8n).toString( + 16, + )}`, + }, + } + : register, + ), + })), + }; + expect(() => + validateRecordedCrashSources(shifted, bytes, diagnostics), + ).toThrow(/aarch64-little-endian register identity/); + }); + + it("rejects an AArch64 note shorter than the profile descriptor", () => { + const { report, bytes } = bind("aarch64-little-endian"); + const shortened = { + ...report, + notes: report.notes.map((note, index) => + index === 0 + ? { + ...note, + descriptor_location: { + ...note.descriptor_location, + bytes: "0x150", + }, + } + : note, + ), + }; + expect(() => + validateRecordedCrashSources(shortened, bytes, diagnostics), + ).toThrow(/aarch64-little-endian source bytes/); + }); + + it("rejects an amd64 register name reported for AArch64", () => { + const { report, bytes } = bind("aarch64-little-endian"); + const renamed = { + ...report, + threads: report.threads.map((thread) => ({ + ...thread, + registers: thread.registers.map((register, index) => + index === 0 ? { ...register, name: "rax" } : register, + ), + })), + }; + expect(() => + validateRecordedCrashSources(renamed, bytes, diagnostics), + ).toThrow(/aarch64-little-endian register identity/); + }); + + it("rejects a reported PID that differs from the recorded source bytes", () => { + const { report, bytes } = bind("aarch64-little-endian"); + const wrongPid = { + ...report, + threads: report.threads.map((thread) => ({ + ...thread, + historical_pid: thread.historical_pid + 1, + })), + }; + expect(() => + validateRecordedCrashSources(wrongPid, bytes, diagnostics), + ).toThrow(/aarch64-little-endian source bytes/); + }); +}); diff --git a/src/native/pwntools/RecordedCrashSourceBindings.ts b/src/native/pwntools/RecordedCrashSourceBindings.ts index 4de2555d2..1a08e8721 100644 --- a/src/native/pwntools/RecordedCrashSourceBindings.ts +++ b/src/native/pwntools/RecordedCrashSourceBindings.ts @@ -1,23 +1,26 @@ import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import { - RECORDED_LINUX_AMD64_REGISTERS, + RECORDED_LINUX_CORE_PROFILES, type RecordedCrash, } from "../../domain/native/recordedCrash.js"; -const registerOffsets = new Map( - RECORDED_LINUX_AMD64_REGISTERS.map((name, index) => [name, 112 + index * 8]), -); - -/** Bind interpreted values and register identities to Linux amd64 source bytes. */ +/** Bind interpreted values and register identities to recorded source bytes. */ export function validateRecordedCrashSources( report: RecordedCrash, snapshot: Buffer, diagnostics: RecordedCrash["decoder_diagnostics"], ): void { + const profile = RECORDED_LINUX_CORE_PROFILES[report.architecture]; + const registerOffsets = new Map( + profile.registers.map((name, index) => [ + name, + profile.registerOffset + index * profile.registerBytes, + ]), + ); const fail = () => { throw new AnalysisOutputError( "inspect_recorded_crash", - "Reported PID or signal value differs from its recorded Linux amd64 source bytes.", + `Reported PID or signal value differs from its recorded ${report.architecture} source bytes.`, { capturedOutput: diagnostics }, ); }; @@ -29,10 +32,10 @@ export function validateRecordedCrashSources( if (length < minimumBytes) return fail(); return snapshot.subarray(start, start + length); }; - // Fixed offsets independently checked against unchanged pwntools 4.15.0 - // elf_prstatus_amd64 / elf_siginfo_64; this is the sole supported note ABI. + // Offsets come from unchanged pwntools 4.15.0 elf_prstatus_amd64 / + // elf_prstatus_aarch64 and elf_siginfo_64 through the shared domain profile. for (const thread of report.threads) { - const bytes = descriptor(thread.note_index, 336); + const bytes = descriptor(thread.note_index, profile.prstatusBytes); if ( thread.historical_pid !== bytes.readInt32LE(32) || thread.recorded_current_signal !== bytes.readInt16LE(12) @@ -49,7 +52,7 @@ export function validateRecordedCrashSources( ) throw new AnalysisOutputError( "inspect_recorded_crash", - "Reported register source does not match its Linux amd64 register identity.", + `Reported register source does not match its ${report.architecture} register identity.`, { capturedOutput: diagnostics }, ); } diff --git a/src/native/pwntools/RecordedCrashStructureBindings.ts b/src/native/pwntools/RecordedCrashStructureBindings.ts index 3d1cacce6..c1e2790b2 100644 --- a/src/native/pwntools/RecordedCrashStructureBindings.ts +++ b/src/native/pwntools/RecordedCrashStructureBindings.ts @@ -1,5 +1,8 @@ import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; -import type { RecordedCrash } from "../../domain/native/recordedCrash.js"; +import { + RECORDED_LINUX_CORE_PROFILES, + type RecordedCrash, +} from "../../domain/native/recordedCrash.js"; // Exact amd64/core enum projections in the pinned pyelftools 0.33 profile. const segmentKinds = new Map([ @@ -81,6 +84,8 @@ export function validateRecordedCrashStructure( return snapshot.subarray(Number(offset), Number(offset) + bytes); }; const header = bytesAt(0n, 64); + const expectedMachine = + RECORDED_LINUX_CORE_PROFILES[report.architecture].machine; if ( !header .subarray(0, 6) @@ -90,7 +95,7 @@ export function validateRecordedCrashStructure( ![0, 3].includes(header[7] ?? -1) || header[8] !== 0 || header.readUInt16LE(16) !== 4 || - header.readUInt16LE(18) !== 62 + header.readUInt16LE(18) !== expectedMachine ) fail(); const table = header.readBigUInt64LE(32); diff --git a/tests/boundary/binaryDiagnostics/RecordedCrashStructureBindings.test.ts b/tests/boundary/binaryDiagnostics/RecordedCrashStructureBindings.test.ts index 5b98e4241..970f57f5d 100644 --- a/tests/boundary/binaryDiagnostics/RecordedCrashStructureBindings.test.ts +++ b/tests/boundary/binaryDiagnostics/RecordedCrashStructureBindings.test.ts @@ -4,6 +4,40 @@ import { recordedCrashFixtureBytes, } from "../../fixtures/binaryDiagnostics/recordedCrash.js"; import { validateRecordedCrashStructure } from "../../../src/native/pwntools/RecordedCrashStructureBindings.js"; +import { RECORDED_LINUX_CORE_PROFILES } from "../../../src/domain/native/recordedCrash.js"; + +it.each(["x86_64-little-endian", "aarch64-little-endian"] as const)( + "admits a %s core whose header carries that profile's e_machine", + (architecture) => { + const report = recordedCrashFixture(undefined, architecture); + expect(() => + validateRecordedCrashStructure( + report, + recordedCrashFixtureBytes(report), + report.decoder_diagnostics, + ), + ).not.toThrow(); + }, +); + +it.each([ + ["x86_64-little-endian", "aarch64-little-endian"], + ["aarch64-little-endian", "x86_64-little-endian"], +] as const)( + "rejects a %s report whose header carries the other profile's e_machine", + (reported, embedded) => { + const report = recordedCrashFixture(undefined, reported); + const snapshot = recordedCrashFixtureBytes(report); + snapshot.writeUInt16LE(RECORDED_LINUX_CORE_PROFILES[embedded].machine, 18); + expect(() => + validateRecordedCrashStructure( + report, + snapshot, + report.decoder_diagnostics, + ), + ).toThrow(/Reported core structure differs/); + }, +); it.each([ [6, 0], diff --git a/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts b/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts index de422334b..16c355789 100644 --- a/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts +++ b/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts @@ -1,5 +1,8 @@ import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; -import type { RecordedCrash } from "../../../src/domain/native/recordedCrash.js"; +import type { + RecordedCrash, + RecordedCrashArchitecture, +} from "../../../src/domain/native/recordedCrash.js"; import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, onTestFinished } from "vitest"; @@ -48,6 +51,7 @@ it .runIf(host) .each([ "success", + "success-aarch64", "wrong-register", "wrong-register-source", "missing-thread", @@ -77,9 +81,13 @@ it ] as const)( "validates recorded source bytes and cleanup: %s", async (scenario) => { + const architecture = scenario.endsWith("aarch64") + ? "aarch64-little-endian" + : "x86_64-little-endian"; const { path, value, bytes } = await fixture( true, scenario === "wrong-owner-display", + architecture, ); if (scenario.startsWith("wrong-ei-version-")) bytes[6] = Number(scenario.at(-1)); @@ -123,8 +131,9 @@ it path, include_debugger_context: false, }); - if (scenario === "success") { + if (scenario === "success" || scenario === "success-aarch64") { if (!result.ok) throw result.error; + expect(result.value.architecture).toBe(architecture); expect(result.value.threads).toEqual(value.threads); expect(result.value.diagnostics.stdout).toBe("recorded warning"); } else @@ -340,13 +349,17 @@ const expectOptionalContextFailure = ( }); }; -const fixture = async (includeSignal = false, opaqueOwner = false) => { +const fixture = async ( + includeSignal = false, + opaqueOwner = false, + architecture: RecordedCrashArchitecture = "x86_64-little-endian", +) => { const workspace = await createTestWorkspace("rea-recorded-core-seam-"); onTestFinished(() => removeTestWorkspace(workspace.root)); const path = join(workspace.root, "recorded.core"); const value = includeSignal - ? recordedCrashSignalFixture(path) - : recordedCrashFixture(path); + ? recordedCrashSignalFixture(path, architecture) + : recordedCrashFixture(path, architecture); if (opaqueOwner) { value.notes = value.notes.map((note) => note.index === 0 diff --git a/tests/fixtures/binaryDiagnostics/recordedCrash.ts b/tests/fixtures/binaryDiagnostics/recordedCrash.ts index 518b2d330..55698aac7 100644 --- a/tests/fixtures/binaryDiagnostics/recordedCrash.ts +++ b/tests/fixtures/binaryDiagnostics/recordedCrash.ts @@ -1,17 +1,41 @@ -import type { RecordedCrash } from "../../../src/domain/native/recordedCrash.js"; +import { + RECORDED_LINUX_CORE_PROFILES, + type RecordedCrash, + type RecordedCrashArchitecture, +} from "../../../src/domain/native/recordedCrash.js"; + +// One note is a 12-byte header, the "CORE\0" owner padded to 8 bytes, then the +// descriptor. The first note starts at the end of the owned header and phdr. +const NOTE_START = 0x80; +const OWNER_START = NOTE_START + 12; +const DESCRIPTOR_START = OWNER_START + 8; +const NOTE_HEADER_BYTES = 20; + +const hex = (value: number): string => `0x${value.toString(16)}`; /** Source-owned observation seam; these bytes do not simulate a verified ELF engine. */ export const recordedCrashFixture = ( path = "/artifacts/source-owned.core", + architecture: RecordedCrashArchitecture = "x86_64-little-endian", ): RecordedCrash => { - const descriptor = Buffer.alloc(336); + const profile = RECORDED_LINUX_CORE_PROFILES[architecture]; + const noteBytes = NOTE_HEADER_BYTES + profile.prstatusBytes; + const descriptor = Buffer.alloc(profile.prstatusBytes); descriptor.writeInt32LE(123, 32); descriptor.writeInt16LE(11, 12); - descriptor.writeBigUInt64LE(0x1122334455667788n, 224); + // One distinguishable register carries a full uint64 so retention is asserted. + descriptor.writeBigUInt64LE( + 0x1122334455667788n, + profile.registerOffset + 14 * profile.registerBytes, + ); return { - artifact: { path, sha256: "a".repeat(64), bytes: 512 }, + artifact: { + path, + sha256: "a".repeat(64), + bytes: Math.max(512, DESCRIPTOR_START + profile.prstatusBytes), + }, format: "elf-core", - architecture: "x86_64-little-endian", + architecture, target_execution: "not-performed", live_process_identity: "unknown", segments: [ @@ -20,7 +44,7 @@ export const recordedCrashFixture = ( type: "PT_NOTE", header_location: { offset: "0x40", bytes: "0x38" }, offset: "0x80", - file_size: "0x164", + file_size: hex(noteBytes), memory_size: "0x0", virtual_address: "0x0", physical_address: "0x0", @@ -34,11 +58,14 @@ export const recordedCrashFixture = ( index: 0, segment_index: 0, type: "NT_PRSTATUS", - location: { offset: "0x80", bytes: "0x164" }, - owner_location: { offset: "0x8c", bytes: "0x5" }, + location: { offset: hex(NOTE_START), bytes: hex(noteBytes) }, + owner_location: { offset: hex(OWNER_START), bytes: "0x5" }, owner_bytes_base64: Buffer.from("CORE\0").toString("base64"), owner_display: "CORE", - descriptor_location: { offset: "0x94", bytes: "0x150" }, + descriptor_location: { + offset: hex(DESCRIPTOR_START), + bytes: hex(profile.prstatusBytes), + }, descriptor_bytes_base64: descriptor.toString("base64"), }, ], @@ -48,40 +75,16 @@ export const recordedCrashFixture = ( note_index: 0, historical_pid: 123, recorded_current_signal: 11, - registers: [ - "r15", - "r14", - "r13", - "r12", - "rbp", - "rbx", - "r11", - "r10", - "r9", - "r8", - "rax", - "rcx", - "rdx", - "rsi", - "rdi", - "orig_rax", - "rip", - "cs", - "eflags", - "rsp", - "ss", - "fs_base", - "gs_base", - "ds", - "es", - "fs", - "gs", - ].map((name, index) => ({ + registers: profile.registers.map((name, index) => ({ name, - value: name === "rdi" ? "0x1122334455667788" : "0x0", + value: index === 14 ? "0x1122334455667788" : "0x0", location: { - offset: `0x${(148 + 112 + index * 8).toString(16)}`, - bytes: "0x8", + offset: hex( + DESCRIPTOR_START + + profile.registerOffset + + index * profile.registerBytes, + ), + bytes: hex(profile.registerBytes), }, })), }, @@ -100,27 +103,38 @@ export const recordedCrashFixture = ( }; /** Source-owned SIGSEGV descriptor for scalar/source binding regressions. */ -export const recordedCrashSignalFixture = (path?: string): RecordedCrash => { - const value = recordedCrashFixture(path); +export const recordedCrashSignalFixture = ( + path?: string, + architecture: RecordedCrashArchitecture = "x86_64-little-endian", +): RecordedCrash => { + const value = recordedCrashFixture(path, architecture); + const profile = RECORDED_LINUX_CORE_PROFILES[architecture]; const descriptor = Buffer.alloc(32); descriptor.writeInt32LE(11, 0); descriptor.writeInt32LE(-2, 4); descriptor.writeInt32LE(1, 8); descriptor.writeBigUInt64LE(0x1122334455667788n, 16); - value.artifact.bytes = 640; + const prstatusNoteBytes = NOTE_HEADER_BYTES + profile.prstatusBytes; + const signalNoteStart = NOTE_START + prstatusNoteBytes; + const signalDescriptorStart = signalNoteStart + NOTE_HEADER_BYTES; + const signalNoteBytes = NOTE_HEADER_BYTES + 32; + value.artifact.bytes = Math.max(640, signalDescriptorStart + 32); value.segments = value.segments.map((segment) => ({ ...segment, - file_size: "0x198", + file_size: hex(prstatusNoteBytes + signalNoteBytes), })); value.notes.push({ index: 1, segment_index: 0, type: "NT_SIGINFO", - location: { offset: "0x1e4", bytes: "0x34" }, - owner_location: { offset: "0x1f0", bytes: "0x5" }, + location: { offset: hex(signalNoteStart), bytes: hex(signalNoteBytes) }, + owner_location: { offset: hex(signalNoteStart + 12), bytes: "0x5" }, owner_bytes_base64: Buffer.from("CORE\0").toString("base64"), owner_display: "CORE", - descriptor_location: { offset: "0x1f8", bytes: "0x20" }, + descriptor_location: { + offset: hex(signalDescriptorStart), + bytes: "0x20", + }, descriptor_bytes_base64: descriptor.toString("base64"), }); value.signals.push({ @@ -141,7 +155,10 @@ export const recordedCrashFixtureBytes = (value: RecordedCrash): Buffer => { bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]); bytes.writeUInt32LE(1, 20); bytes.writeUInt16LE(4, 16); - bytes.writeUInt16LE(62, 18); + bytes.writeUInt16LE( + RECORDED_LINUX_CORE_PROFILES[value.architecture].machine, + 18, + ); bytes.writeBigUInt64LE(64n, 32); bytes.writeUInt16LE(56, 54); bytes.writeUInt16LE(value.segments.length, 56); From 75e7965dc3a8a72576b1d5f013b624da81d53607 Mon Sep 17 00:00:00 2001 From: pablodiazjorge <102741971+pablodiazjorge@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:48:41 +0200 Subject: [PATCH 28/36] docs(nativeaot): document the verified Windows-host parser route (#2497) Squash merge of https://github.com/morluto/rea/pull/2497. Co-authored-by: pablodiazjorge --- .../references/native-and-artifacts.md | 4 ++- docs/ghidra-nativeaot.md | 32 +++++++++++++++---- docs/managed-code-analysis.md | 5 +-- docs/testing.md | 6 ++++ docs/windows-ghidra-p0.md | 6 ++-- 5 files changed, 41 insertions(+), 12 deletions(-) diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index a4fc6d18f..33b9a9e79 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -175,7 +175,9 @@ additional evidence only when the provider accepts and matches them. REA has a built-in read-only snapshot parser for x64 PE targets with .NET 8 RTR 9.1 metadata. An optional headless Ghidra adapter adds database annotations and broader metadata recovery on Linux; it requires an explicitly configured -NativeAOT JAR. These are separate routes. See the +NativeAOT JAR. These are separate routes. The built-in parser also runs on +Windows Ghidra P0; read frozen literals from its report, because +`search_strings` does not include them. See the [NativeAOT guide](https://github.com/morluto/rea/blob/main/docs/ghidra-nativeaot.md) for setup, supported layouts, and host restrictions. Preserve binary identity and distinguish derived metadata from Ghidra database observations. Recovered diff --git a/docs/ghidra-nativeaot.md b/docs/ghidra-nativeaot.md index d9505a226..394d7fdf5 100644 --- a/docs/ghidra-nativeaot.md +++ b/docs/ghidra-nativeaot.md @@ -17,10 +17,14 @@ submodule and is a separate, more capable workflow. The parser currently handles x64 PE32+ and RTR 9.1, including bounded DEHYDRATED_DATA processing, MethodTable relationships, virtual slots, and frozen strings. Other RTR versions and PE architectures are reported as -unsupported or partial. The matching .NET 8.0.22 fixture is source-built and -the parser's Windows-host Ghidra workflow has not yet had a real Windows-host -run. This does not establish Windows Ghidra import or decompiler behavior. -Windows Ghidra P0 has no database mutation authority. A PE/CLI or ReadyToRun +unsupported or partial. The matching .NET 8.0.22 fixture is source-built. + +On a Windows host, Windows Ghidra P0 runs the parser without a JAR. Metadata, +MethodTable relationships, virtual slots and frozen strings come from the +parser report, and `analyze_function` decompiles slot targets. Tools that read +the Ghidra database do not see rehydrated bytes (see below), because only the +optional adapter writes them into the database and Windows Ghidra P0 has no +database mutation authority. A PE/CLI or ReadyToRun assembly should use `inspect_managed_artifact`; it is not NativeAOT. ## Build and configure @@ -57,7 +61,9 @@ returns `observations.metadata_recovery` inline from the captured source snapsho including the RTR header, MethodTable addresses, frozen strings, derived overlay identity, and examined/recovered counts. Its enclosing `unsupported` load-image status means independent PE load-image verification was not performed; it does -not mean the metadata parser failed or the executable failed to load. +not mean the metadata parser failed or the executable failed to load. Without +the optional adapter, each recovered type's `type` is `null`, because no Ghidra +DataType exists; select it by `address`. Pass a returned MethodTable address to `inspect_native_data_type`. A direct PE query also derives the snapshot metadata from the authenticated Ghidra session @@ -67,8 +73,12 @@ The database result remains `unavailable` when Ghidra has no DataType at that address; its `metadata_recovery.source` is `read-only-derived-overlay` and carries related type, interface, slot, and target observations. Generated address identities do not claim original managed names. The parser does not -merge frozen literals into Ghidra's database string inventory. Follow slot -targets with `analyze_function` and ordinary cross-references. +merge frozen literals into Ghidra's database string inventory: read them from +`metadata_recovery.frozen_strings`, not `search_strings`. Slot targets come +from the parser report; on Windows P0 without the adapter, a `read_bytes` of +a slot in the rehydrated region returns no bytes. The Linux adapter route +verifies a complete slot read and its target. Follow slot targets with `analyze_function` and ordinary +cross-references. The built-in CLI path uses a MethodTable address returned by the parser or another observation: @@ -129,3 +139,11 @@ compiler/linker symbols and raw directory bytes provide the oracle. Stripped fixtures exercise heuristic detection. Tests do not execute the resulting NativeAOT binaries. Fixture generation and adapter compilation are optional and never prerequisites of ordinary native analysis or setup. + +The real MCP lane runs only on a Linux x64 host with the adapter. The +Windows-host behavior above was checked manually over MCP on Windows 11 x64 +(non-elevated, Ghidra 12.1.4, JDK 21, no JAR) with the source-built win-x64 +symbols fixture. The format, RTR version and frozen strings matched the fixture +oracle; the derived MethodTable, its base type, its interface and a virtual +slot reaching the overridden method matched the linker map; and pseudocode was +readable. No CI lane repeats this check. diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index d60603ce3..1d1a579e6 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -233,8 +233,9 @@ these routes, their layout/host restrictions, derived-memory provenance, and verification coverage. REA's experimental Windows Ghidra boundary admits native x86 and x86-64 PE -applications and DLLs for static analysis. This does not establish Windows -NativeAOT metadata-recovery coverage. +applications and DLLs for static analysis. For x64 PE NativeAOT, the read-only +parser also runs there; the [NativeAOT guide](ghidra-nativeaot.md) lists what a +Windows host covers. For NativeAOT analysis, preserve the selected native artifact's path and digest and report recovered type data with its returned provenance. Linking those types diff --git a/docs/testing.md b/docs/testing.md index 7aa80d22a..213c48e44 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -646,6 +646,12 @@ records independent symbol/directory/SHA oracles; it never executes the target. Linux also builds stripped, ordinary-native and small malformed/ambiguous/layout negative inputs. The optional Windows fixture workflow builds a PE on a Windows runner; analyzing that PE on Linux does not verify a Windows Ghidra host. +On a local Windows host, the .NET 8 ILCompiler finds the linker only through +Visual Studio 2019 or 2022 (`vswhere -version [16,18)`). With only Visual +Studio Build Tools 2026 installed, open its x64 Developer Command Prompt. +In that same `cmd.exe` session, run `set IlcUseEnvironmentalTools=true` and +then `npm run build:fixtures:nativeaot`. Running `vcvars64.bat` as a child of +PowerShell does not preserve its environment in the parent PowerShell session. Build the clean pinned upstream adapter with `build:ghidra:nativeaot`, then set `REA_GHIDRA_NATIVEAOT_JAR`. Run `verify:ghidra:nativeaot -- symbols`, `-- stripped`, diff --git a/docs/windows-ghidra-p0.md b/docs/windows-ghidra-p0.md index 5def0dae4..78e11ee79 100644 --- a/docs/windows-ghidra-p0.md +++ b/docs/windows-ghidra-p0.md @@ -29,8 +29,10 @@ admitted raw NMOS 6502 mapping on Windows. Windows P0 admission remains limited to native PE and raw NMOS 6502 images. For x64 PE NativeAOT RTR 9.1, the read-only snapshot parser can report metadata through `inspect_native_load_image` and `inspect_native_data_type` without a JAR or -database mutation. A real Windows-host NativeAOT Ghidra run is still required -to establish the complete host workflow. +database mutation: MethodTable relationships, virtual slots and frozen strings. +`analyze_function` decompiles slot targets. Database reads of the rehydrated +bytes (`read_bytes`, `search_strings`) return nothing on this host. See +[NativeAOT](ghidra-nativeaot.md). This boundary does not establish general Windows feature parity. Ghidra GUI state, annotations, other target architectures and formats, and Hopper-only From 911894d75822116141897a582c1e26e1d0273dba Mon Sep 17 00:00:00 2001 From: Thang Luu Date: Wed, 14 Oct 2026 00:49:04 +0900 Subject: [PATCH 29/36] feat(wasm): compare retained core-module interfaces (#2464) Squash merge of https://github.com/morluto/rea/pull/2464. Co-authored-by: Thang Luu <76550342+thangldw@users.noreply.github.com> --- .../references/native-and-artifacts.md | 6 + .../2356-wasm-interface-comparison/plan.md | 10 + .../2356-wasm-interface-comparison/spec.md | 61 ++++ .../2356-wasm-interface-comparison/tasks.md | 8 + .../verification.md | 47 +++ docs/wasm-artifacts.md | 43 +++ package.json | 1 + scripts/lib/catalog-builders.mjs | 4 +- scripts/verify-wasm-artifact.mjs | 18 +- scripts/verify-wasm-interface-comparison.mjs | 208 +++++++++++ .../WasmInterfaceComparisonService.test.ts | 198 +++++++++++ .../wasm/WasmInterfaceComparisonService.ts | 250 ++++++++++++++ src/cli/wasmCommands.ts | 26 ++ src/cliCommandNames.ts | 1 + src/contracts/toolEffects.test.ts | 2 + src/contracts/toolEffects.ts | 1 + src/contracts/wasm/wasmComparisonContracts.ts | 63 ++++ src/contracts/wasm/wasmToolContracts.ts | 6 +- src/domain/wasm/wasmArtifact.ts | 2 + src/domain/wasm/wasmInterface.ts | 109 ++++++ src/server/registerWasmTools.ts | 36 ++ src/wasm/WabtArtifactProvider.ts | 10 +- src/wasm/WabtInterface.test.ts | 116 +++++++ src/wasm/WabtInterface.ts | 325 ++++++++++++++++++ src/wasm/WabtOutput.test.ts | 4 +- src/wasm/WabtString.ts | 46 +++ src/wasm/WabtWat.ts | 63 +--- src/wasm/WasmInterfaceBudget.ts | 17 + .../wasmInterfaceComparison.test.ts | 130 +++++++ 29 files changed, 1757 insertions(+), 54 deletions(-) create mode 100644 docs/specs/2356-wasm-interface-comparison/plan.md create mode 100644 docs/specs/2356-wasm-interface-comparison/spec.md create mode 100644 docs/specs/2356-wasm-interface-comparison/tasks.md create mode 100644 docs/specs/2356-wasm-interface-comparison/verification.md create mode 100644 scripts/verify-wasm-interface-comparison.mjs create mode 100644 src/application/wasm/WasmInterfaceComparisonService.test.ts create mode 100644 src/application/wasm/WasmInterfaceComparisonService.ts create mode 100644 src/contracts/wasm/wasmComparisonContracts.ts create mode 100644 src/domain/wasm/wasmInterface.ts create mode 100644 src/wasm/WabtInterface.test.ts create mode 100644 src/wasm/WabtInterface.ts create mode 100644 src/wasm/WabtString.ts create mode 100644 src/wasm/WasmInterfaceBudget.ts create mode 100644 tests/acceptance/wasmInterfaceComparison.test.ts diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index 33b9a9e79..3e185a8a2 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -249,3 +249,9 @@ resolution is explicit; missing references and size mismatch remain visible. Other payloads are opaque, and this operation does not decode images or simulate Windows resource loading. Consult the budgets and supported tree profile in [offline binary diagnostics](https://github.com/morluto/rea/blob/main/docs/binary-diagnostics.md). + +For exact core-module import/export differences, use `compare_wasm_interfaces` +with two complete inspection Evidence records (or current-session retained +references) and explicit scope. Preserve unknown types/duplicate names; static +interface equality is not runtime compatibility. CLI accepts complete inline +Evidence in `rea compare-wasm-interfaces input.json --json`. diff --git a/docs/specs/2356-wasm-interface-comparison/plan.md b/docs/specs/2356-wasm-interface-comparison/plan.md new file mode 100644 index 000000000..a5b9e8ac3 --- /dev/null +++ b/docs/specs/2356-wasm-interface-comparison/plan.md @@ -0,0 +1,10 @@ +# Plan + +1. Check issue/timeline and complete open/closed PR searches; review #2406. +2. Extend the existing core-module WABT inspection in an isolated worktree. +3. Extend producer-owned inspection with optional typed interface facts; + preserve unknown unsupported signatures and exact WAT forms. +4. Add shared Evidence-only comparator and strict input/output contracts. +5. Register additive CLI/MCP workflow and documentation. +6. Validate original-module workflows, malformed/unknown/cancellation cases; + run checks, independent review, repair findings and rerun affected checks. diff --git a/docs/specs/2356-wasm-interface-comparison/spec.md b/docs/specs/2356-wasm-interface-comparison/spec.md new file mode 100644 index 000000000..b4dfb7778 --- /dev/null +++ b/docs/specs/2356-wasm-interface-comparison/spec.md @@ -0,0 +1,61 @@ +# Core WebAssembly interface comparison (#2356) + +## Outcome and scope + +Compare two exact authenticated `inspect_wasm_artifact` Evidence inputs, inline +or retained session references, under required `scope: imports | exports | both`. +No module acquisition or execution occurs during comparison. This is a derived +workflow within existing WABT inspection authority, not a new provider. + +Producer-owned typed `interface` facts are additive and optional in historical +inspection Evidence. WABT's validated canonical WAT owns decoding; exact original +forms and names remain available. Comparison never reparses retained display text. +Function/tag signatures resolve type uses; memory page size defaults to 65536 bytes and explicit custom page sizes are retained; table/memory limits and global types +are compared exactly. Unsupported type/proposal forms remain unknown rather than +being compared by raw text or inferred compatible. Names are exact decoded UTF-8, +without normalization, including empty names, escapes, punctuation and BOM. + +## Comparison contract + +Return both Evidence IDs and original artifact path/digest/length. Group imports +by exact module/name and exports by exact name, independently of kind, so a kind +change is reported as changed. Unique admitted signatures yield added, removed, +changed or unchanged. Duplicate keys retain every original occurrence and yield +unknown; no Cartesian pairing or deduplication. Missing historical facets and +incomplete inventories prevent absence claims. Unknown signatures do not erase +independently observed entries. Ordering is deterministic by direction and names. + +Both subject digest/path and inspection parameters must match normalized artifact +identity. Authenticate Evidence before projection; summary Evidence must be +resolved to its retained complete parent by the caller. Report unknown coverage +and explicit limitations. Exact static equality is not runtime compatibility, +ABI compatibility, linking success or source identity. + +## Authority and overlap + +Reads supplied Evidence only. MCP records derived Evidence through the existing +handler/ledger; CLI accepts portable inline Evidence from an explicit JSON input. +No new provider, implicit fetch, target mutation or foreign-code execution. +PR #2406 (head 86865035) adds component inspection, preamble routing and embedded +core-module inspection. Its component parser is not required or duplicated here; +shared contract/CLI registration changes remain additive. Core GC/recursive types +are outside admitted signature semantics and keep unknown mappings. + +## Resource admission + +Typed producer rows, names and declaration tokenization are admitted before +allocation against the existing 32 MiB WABT output representation capacity. +Comparison uses linear grouping and cooperative transport yields; it never +builds Cartesian duplicate pairings. CLI JSON input retains the shared file +admission gate. Token usage and peak process-family memory remain unmeasured. + +## Verification + +Original owned modules assembled using WABT 1.0.42 cover semantically equivalent +encodings, changes of kind/signature, names/duplicates, imported index spaces, +tables/memory/globals/tags, unknown types and historical incomplete Evidence. +Exercise authenticated identity, cancellation, retained lookup, CLI/MCP parity +and advertised schemas. Run focused existing WASM/Evidence tests, repository +checks and docs validation; independent subagent review before handoff. + +Format reference: https://webassembly.github.io/spec/core/binary/modules.html diff --git a/docs/specs/2356-wasm-interface-comparison/tasks.md b/docs/specs/2356-wasm-interface-comparison/tasks.md new file mode 100644 index 000000000..722ace2c9 --- /dev/null +++ b/docs/specs/2356-wasm-interface-comparison/tasks.md @@ -0,0 +1,8 @@ +# Tasks + +- [x] Confirm scope and overlap boundaries against #2406. +- [x] Record spec and implementation plan. +- [x] Implement typed producer interface and shared comparison. +- [x] Add CLI/MCP contracts and public workflow tests. +- [x] Run focused, static and documentation checks. +- [x] Resolve independent review findings and rerun relevant checks. diff --git a/docs/specs/2356-wasm-interface-comparison/verification.md b/docs/specs/2356-wasm-interface-comparison/verification.md new file mode 100644 index 000000000..ca531e8e4 --- /dev/null +++ b/docs/specs/2356-wasm-interface-comparison/verification.md @@ -0,0 +1,47 @@ +# Verification + +Verified base: a2d8b94dd50dab8d966239dd63ab84718aa8b4b4 (fetched main). +The change was reapplied without conflicts and relevant checks rerun. + +- Focused existing/new parser, provider, service, contract and CLI/MCP tests: + 8 files / 56 tests passed. +- After the final partial-uniqueness correction, reran service and compiled + CLI/MCP boundary tests: 2 files / 5 tests passed. +- `npm run check`: typecheck, lint, formatting, knip, module boundaries and + architecture guards passed. Lint reports warnings; no errors. +- `npm run docs:generate`, `npm run docs:check` and + `npm run verify:test-discovery` passed; every test file was discovered exactly once. +- Real WABT 1.0.42 comparison through compiled CLI and stdio MCP passed: + equivalent padded LEB128 original encodings, function signatures, all admitted + kinds, custom memory page size, escaped/BOM names, empty interfaces, explicit + scope, duplicate imports and unadmitted exception reference signatures. +- Existing real WABT artifact verifier passed after correcting its stale inline + partial-observation assumption to resolve the MCP retained reference from the + Evidence bundle and authenticate the original record. + +## Independent review + +Three findings repaired at their owning boundary: + +1. Memory page size omitted: now preserved, default 65536 canonicalized. +2. Shared signature expansion undercounted: charge every retained occurrence. + Reviewer independently measured the 1000-params/10000-exports case: original + 262956-byte WAT produced 61824014-byte JSON before correction. It now retains + 1949 observations in a partial facet, with 12046676-byte JSON and an explicit + capacity issue. Original WAT stays available; incomplete coverage prevents + absence and uniqueness claims. +3. Partial singleton uniqueness: comparison now returns unknown whenever either + selected inventory is incomplete, including matched names. + +Final independent source review reported no actionable findings. Review does not +replace the executed tests or real-tool evidence. + +## Scope and measurement limits + +On the original implementation base f254f5a, the complete comparison verifier reported 7.98 seconds elapsed, 6.56 user and +3.13 system seconds, and 429441024 bytes maximum RSS via macOS `/usr/bin/time -l`. +These are command-accounting measurements, not aggregate concurrent process-family +memory or model tokens. Token usage and full process-family peak are unmeasured. +Real-tool verification covers macOS arm64 only; Linux/Windows remain unverified. +No module was instantiated/executed and no source was fetched. Static equality +is not runtime compatibility. Full CI is separate from this local verification record. diff --git a/docs/wasm-artifacts.md b/docs/wasm-artifacts.md index 6bc7c23ce..364096251 100644 --- a/docs/wasm-artifacts.md +++ b/docs/wasm-artifacts.md @@ -79,3 +79,46 @@ Both CLI and MCP use the same typed port and application service. MCP publishes SDK-compatible schemas and records Evidence for `get_evidence_bundle` readback. See [WABT provenance](https://github.com/morluto/rea/blob/main/third_party/wabt/README.md) and [testing](testing.md#offline-wasm-artifacts) for real-tool verification. + +## Compare core-module interfaces + +Inspection adds producer-owned typed `interface` facts while keeping exact WAT +import/export forms. Function/tag signatures resolve type indices; table/memory +limits, memory page size and global value type/mutability remain explicit. +Unadmitted types (including GC reference signatures) retain null signatures and +issues. Exact decoded names retain escapes, empty names, BOM and punctuation. +This facet is absent in historical Evidence that did not examine typed interfaces. + +`compare_wasm_interfaces` accepts two complete `inspect_wasm_artifact` Evidence +records, or exact retained references in the current MCP session, and required +`scope: "imports" | "exports" | "both"`. It reads no files and needs no active +WABT toolchain. CLI uses portable inline Evidence in one JSON document: + +```bash +rea compare-wasm-interfaces comparison-input.json --json +``` + +```json +{ + "left": { "kind": "retained-evidence", "evidence_id": "ev_" }, + "right": { "kind": "retained-evidence", "evidence_id": "ev_" }, + "scope": "both" +} +``` + +The references above are MCP-only; put complete Evidence objects in the CLI +input. Resolve summary delivery to its retained complete parent before comparing. +Results retain both input Evidence IDs, artifact paths/digests/lengths and every +selected occurrence. Unique names are added, removed, changed or unchanged; +duplicate names and unsupported signatures are unknown. Incomplete or historical +inventories never establish absence. Imports group by module/name, exports by +name, so a change of kind is visible. Output ordering is deterministic. + +Static type equality does not establish runtime compatibility, ABI compatibility +or linking success. Comparison covers core modules; WebAssembly components and +canonical ABI relationships belong to their separate inspection workflow. + +After building, run `REA_WABT_BIN_DIRECTORY=/absolute/wabt/bin node +scripts/verify-wasm-interface-comparison.mjs` for original-module comparison +through the compiled CLI and stdio MCP. It uses owned fixtures and WABT 1.0.42; +no WASM module is instantiated or executed. diff --git a/package.json b/package.json index 70ab60d1e..b042eb106 100644 --- a/package.json +++ b/package.json @@ -269,6 +269,7 @@ "verify:binary:layout": "npm run build:cached && node scripts/verify-binary-layout.mjs", "verify:go:binary": "npm run build:cached && node scripts/verify-go-binary.mjs", "verify:wasm:package": "npm run build:cached && node scripts/verify-packaged-wasm-artifact.mjs", + "verify:wasm-interface-comparison": "npm run build:cached && node scripts/verify-wasm-interface-comparison.mjs", "verify:wasm:artifact": "npm run build:cached && node scripts/verify-wasm-artifact.mjs", "verify:evm:interface": "npm run build:cached && node scripts/verify-evm-interface.mjs", "verify:solidity:metadata": "npm run build:cached && node scripts/verify-solidity-metadata.mjs", diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index 32ec51ee3..d924ef49c 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -213,7 +213,9 @@ export const providerCatalog = (sources) => { }, { identity: sources.wasmProvider.WABT_PROVIDER_IDENTITY, - contracts: sources.wasmContracts.WASM_TOOL_CONTRACTS, + contracts: sources.wasmContracts.WASM_TOOL_CONTRACTS.filter( + ({ kind }) => kind === "artifact-provider", + ), }, { identity: sources.evmProvider.EVMOLE_PROVIDER_IDENTITY, diff --git a/scripts/verify-wasm-artifact.mjs b/scripts/verify-wasm-artifact.mjs index 1f3bde2ca..305063ccc 100644 --- a/scripts/verify-wasm-artifact.mjs +++ b/scripts/verify-wasm-artifact.mjs @@ -310,7 +310,23 @@ try { }), ); assert.equal(error.category, "invalid_input"); - const failed = parseEvidence(error.details.partial_observation); + const partial = error.details.partial_observation; + assert.equal(partial.kind, "retained-evidence"); + const failedBundleReply = await client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }); + assert.notEqual( + failedBundleReply.isError, + true, + mcpTextValue(failedBundleReply), + ); + const failedBundle = JSON.parse(mcpTextValue(failedBundleReply)).result; + const failed = parseEvidence( + failedBundle.records.find( + ({ evidence_id }) => evidence_id === partial.evidence_id, + ), + ); assert.equal(failed.subject.digest.sha256, sha256(invalidBytes)); assert.equal( failed.normalized_result.validation, diff --git a/scripts/verify-wasm-interface-comparison.mjs b/scripts/verify-wasm-interface-comparison.mjs new file mode 100644 index 000000000..6b215eef3 --- /dev/null +++ b/scripts/verify-wasm-interface-comparison.mjs @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises"; +import { join, resolve, isAbsolute } from "node:path"; +import { tmpdir } from "node:os"; +import { promisify } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { parseEvidence } from "../dist/domain/evidence.js"; + +const execute = promisify(execFile); +const bin = process.env.REA_WABT_BIN_DIRECTORY; +if (!isAbsolute(bin ?? "")) + throw new Error("Supply absolute REA_WABT_BIN_DIRECTORY with WABT 1.0.42"); +const root = await mkdtemp(join(tmpdir(), "rea-wasm-interface-real-")); +const cli = resolve("scripts/rea.mjs"); +const environment = { ...process.env }; +const command = async (file, args) => + ( + await execute(file, args, { + env: environment, + cwd: root, + timeout: 120000, + maxBuffer: 64 * 1024 * 1024, + }) + ).stdout; +const client = new Client({ + name: "wasm-interface-real-verifier", + version: "1", +}); +const transport = new StdioClientTransport({ + command: process.execPath, + args: [cli, "mcp"], + env: environment, + stderr: "pipe", +}); +const source = (parameter) => + `(module (type (func (param ${parameter}) (result i64))) (import "env" "f" (func (type 0))) (table 1 3 funcref) (memory 1 2) (global (mut i64) (i64.const 0)) (tag (param i32)) (export "run\\22\\ef\\bb\\bf" (func 0)) (export "table" (table 0)) (export "memory" (memory 0)) (export "global" (global 0)) (export "tag" (tag 0)))`; +const assemble = async (name, wat) => { + await writeFile(join(root, `${name}.wat`), wat); + const path = join(root, `${name}.wasm`); + await command(join(bin, "wat2wasm"), [ + "--enable-all", + `${name}.wat`, + "-o", + path, + ]); + return path; +}; +const inspect = async (path) => { + const result = await client.callTool({ + name: "inspect_wasm_artifact", + arguments: { path }, + }); + assert.notEqual(result.isError, true, JSON.stringify(result)); + return parseEvidence(result.structuredContent); +}; +const compare = async (left, right, scope = "both") => { + const input = { + left: { kind: "retained-evidence", evidence_id: left.evidence_id }, + right: { kind: "retained-evidence", evidence_id: right.evidence_id }, + scope, + }; + const reply = await client.callTool({ + name: "compare_wasm_interfaces", + arguments: input, + }); + assert.notEqual(reply.isError, true, JSON.stringify(reply)); + const mcp = parseEvidence(reply.structuredContent); + const file = join(root, "comparison.json"); + await writeFile(file, JSON.stringify({ left, right, scope })); + const local = parseEvidence( + JSON.parse( + await command(process.execPath, [ + cli, + "compare-wasm-interfaces", + file, + "--json", + ]), + ), + ); + assert.deepEqual(local, mcp); + return mcp.normalized_result; +}; +try { + assert.equal( + (await command(join(bin, "wat2wasm"), ["--version"])).trim(), + "1.0.42", + ); + await client.connect(transport); + const path = await assemble("baseline", source("i32")); + const original = await readFile(path); + assert.equal(original[8], 1); + assert.ok(original[9] < 128); + // Section lengths allow padded u32 LEB128 encodings. Different original + // byte identities must compare equal after validated type resolution. + const equivalentPath = join(root, "equivalent.wasm"); + await writeFile( + equivalentPath, + Buffer.concat([ + original.subarray(0, 9), + Buffer.from([original[9] | 128, 0]), + original.subarray(10), + ]), + ); + const left = await inspect(path); + const equivalent = await inspect(equivalentPath); + assert.notEqual(left.subject.digest.sha256, equivalent.subject.digest.sha256); + const same = await compare(left, equivalent); + assert.deepEqual(same.summary, { + added: 0, + removed: 0, + changed: 0, + unchanged: 6, + unknown: 0, + }); + const facts = left.normalized_result.interface.entries; + assert.deepEqual(facts.find((e) => e.name === "memory").signature, { + kind: "memory", + page_size: "65536", + address_type: "i32", + minimum: "1", + maximum: "2", + shared: false, + }); + assert.deepEqual(facts.find((e) => e.name === "tag").signature, { + kind: "tag", + params: ["i32"], + results: [], + }); + assert.deepEqual(facts.find((e) => e.name === 'run"\ufeff').signature, { + kind: "func", + params: ["i32"], + results: ["i64"], + }); + const changed = await inspect(await assemble("changed", source("i64"))); + assert.deepEqual((await compare(left, changed)).summary, { + added: 0, + removed: 0, + changed: 2, + unchanged: 4, + unknown: 0, + }); + const smallPage = await inspect( + await assemble( + "small-page", + '(module (memory 1 2 (pagesize 1)) (export "memory" (memory 0)))', + ), + ); + const defaultPage = await inspect( + await assemble( + "default-page", + '(module (memory 1 2) (export "memory" (memory 0)))', + ), + ); + assert.equal((await compare(defaultPage, smallPage)).summary.changed, 1); + const empty = await inspect(await assemble("empty", "(module)")); + assert.equal((await compare(left, empty)).summary.removed, 6); + assert.equal((await compare(empty, left, "exports")).summary.added, 5); + const duplicate = await inspect( + await assemble( + "duplicate", + '(module (import "e" "same" (func (param i32))) (import "e" "same" (func (param i64))))', + ), + ); + const duplicates = await compare(duplicate, duplicate, "imports"); + assert.equal(duplicates.summary.unknown, 1); + assert.equal(duplicates.changes[0].left.length, 2); + const unsupported = await inspect( + await assemble( + "exnref", + '(module (import "e" "abstract-ref" (global exnref)))', + ), + ); + assert.equal((await compare(unsupported, unsupported)).summary.unknown, 1); + console.log( + JSON.stringify( + { + verified: true, + toolchain: "WABT 1.0.42", + transport: "compiled CLI + stdio MCP", + interface_fact_bytes: Buffer.byteLength( + JSON.stringify(left.normalized_result.interface), + ), + comparison_result_bytes: Buffer.byteLength(JSON.stringify(same)), + original_sha256: left.subject.digest.sha256, + equivalent_sha256: equivalent.subject.digest.sha256, + cases: [ + "equivalent padded LEB128", + "function signature change", + "all admitted kinds", + "custom memory page-size change", + "escaped/BOM name", + "empty interface additions/removals", + "scope", + "duplicate imports", + "unsupported exception reference", + ], + runtime_execution: "not-performed", + }, + null, + 2, + ), + ); +} finally { + await client.close(); + await rm(root, { recursive: true, force: true }); +} diff --git a/src/application/wasm/WasmInterfaceComparisonService.test.ts b/src/application/wasm/WasmInterfaceComparisonService.test.ts new file mode 100644 index 000000000..9936c7530 --- /dev/null +++ b/src/application/wasm/WasmInterfaceComparisonService.test.ts @@ -0,0 +1,198 @@ +import { expect, it } from "vitest"; +import { createEvidence } from "../../domain/evidence.js"; +import { wasmInterfaceComparisonSchema } from "../../contracts/wasm/wasmComparisonContracts.js"; +import { compareWasmInterfaces } from "./WasmInterfaceComparisonService.js"; +import type { WasmInterface } from "../../domain/wasm/wasmInterface.js"; +import { wasmArtifactFixture } from "../../../tests/fixtures/wasm/artifact.js"; + +const facts = ( + name: string, + parameter: "i32" | "i64" = "i32", +): WasmInterface => ({ + state: "complete", + issues: [], + entries: [ + { + direction: "export", + module: null, + name, + kind: "func", + index: 0, + signature: { kind: "func", params: [parameter], results: [] }, + wat_form: `(export "${name}" (func 0))`, + issue: null, + }, + ], +}); +const evidence = (interfaceFacts?: WasmInterface) => { + const report = wasmArtifactFixture(); + if (interfaceFacts !== undefined) report.interface = interfaceFacts; + return createEvidence( + { + path: report.artifact.path, + sha256: report.artifact.sha256, + format: "file", + }, + { id: "fixture", name: "typed fixture", version: "1" }, + { + operation: "inspect_wasm_artifact", + parameters: { path: report.artifact.path }, + result: report, + confidence: "observed", + }, + ); +}; +const compare = async ( + left: ReturnType, + right: ReturnType, +) => { + const result = await compareWasmInterfaces({ left, right, scope: "both" }); + if (!result.ok) throw result.error; + return wasmInterfaceComparisonSchema.parse(result.value.normalized_result); +}; + +it("reports all five comparison states, including duplicate ambiguity and explicit selected scope", async () => { + const left = facts("changed"); + const right = facts("changed", "i64"); + left.entries.push( + ...facts("unchanged").entries, + ...facts("removed").entries, + ...facts("dup").entries, + ...facts("dup").entries, + ); + right.entries.push( + ...facts("unchanged").entries, + ...facts("added").entries, + ...facts("dup").entries, + ); + const report = await compare(evidence(left), evidence(right)); + expect(report.summary).toEqual({ + changed: 1, + unchanged: 1, + removed: 1, + added: 1, + unknown: 1, + }); + expect(report.changes.find((c) => c.name === "dup")).toMatchObject({ + state: "unknown", + left: [expect.anything(), expect.anything()], + right: [expect.anything()], + }); + const selected = await compareWasmInterfaces({ + left: evidence(left), + right: evidence(right), + scope: "imports", + }); + if (!selected.ok) throw selected.error; + expect(selected.value.normalized_result).toMatchObject({ + changes: [], + coverage: "complete-within-inputs", + }); +}); + +it("withholds absence claims for historical or partial inventories and unsupported signatures", async () => { + const known = facts("f"); + const absent = await compare(evidence(), evidence(known)); + expect(absent).toMatchObject({ + coverage: "partial", + changes: [{ state: "unknown" }], + }); + const partial: WasmInterface = { + state: "partial", + issues: ["unread import table"], + entries: [], + }; + expect(await compare(evidence(partial), evidence(known))).toMatchObject({ + changes: [{ state: "unknown" }], + }); + const unsupported = facts("f"); + unsupported.entries[0]!.signature = null; + expect(await compare(evidence(known), evidence(unsupported))).toMatchObject({ + changes: [{ state: "unknown" }], + }); + const partialMatch = facts("f"); + partialMatch.state = "partial"; + partialMatch.issues = ["Later imports were not examined"]; + expect(await compare(evidence(partialMatch), evidence(known))).toMatchObject({ + changes: [{ state: "unknown" }], + }); + expect(await compare(evidence(), evidence())).toMatchObject({ + coverage: "unknown", + changes: [], + }); +}); + +it("authenticates semantic Evidence and binds display subject and selection identity", async () => { + const left = JSON.parse(JSON.stringify(evidence(facts("f")))); + left.normalized_result.interface.entries[0].name = "tampered"; + expect( + await compareWasmInterfaces({ + left, + right: evidence(facts("f")), + scope: "both", + }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); + const rebound = JSON.parse(JSON.stringify(evidence(facts("f")))); + rebound.subject.local_path = "/other.wasm"; + expect( + await compareWasmInterfaces({ + left: rebound, + right: evidence(facts("f")), + scope: "both", + }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); +}); + +it("resolves exact retained references and permits cancellation between comparison batches", async () => { + const left = evidence(facts("f")); + const right = evidence(facts("f", "i64")); + const references = { + left: { kind: "retained-evidence", evidence_id: left.evidence_id }, + right: { kind: "retained-evidence", evidence_id: right.evidence_id }, + scope: "both", + }; + expect(await compareWasmInterfaces(references)).toMatchObject({ + ok: false, + error: { _tag: "EvidenceIntegrityError" }, + }); + const result = await compareWasmInterfaces(references, {}, (id) => + id === left.evidence_id ? left : right, + ); + if (!result.ok) throw result.error; + expect(result.value.evidence_links).toEqual([ + left.evidence_id, + right.evidence_id, + ]); + const large = facts("f"); + large.entries.push( + ...Array.from({ length: 2000 }, (_, i) => facts(`f${i}`).entries[0]!), + ); + const controller = new AbortController(); + const pending = compareWasmInterfaces( + { left: evidence(large), right: evidence(large), scope: "both" }, + { signal: controller.signal }, + ); + setImmediate(() => controller.abort()); + expect(await pending).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); +}); + +it.each([false, true])( + "keeps a one-sided unsupported interface unknown with reverse=%s", + async (reverse) => { + const empty: WasmInterface = { state: "complete", issues: [], entries: [] }; + const unsupported = facts("exception"); + unsupported.entries[0]!.signature = null; + unsupported.entries[0]!.issue = "Unsupported exnref global type"; + const sides = reverse ? [unsupported, empty] : [empty, unsupported]; + expect(await compare(evidence(sides[0]), evidence(sides[1]))).toMatchObject( + { + coverage: "partial", + summary: { added: 0, removed: 0, unknown: 1 }, + changes: [{ state: "unknown" }], + }, + ); + }, +); diff --git a/src/application/wasm/WasmInterfaceComparisonService.ts b/src/application/wasm/WasmInterfaceComparisonService.ts new file mode 100644 index 000000000..f920a5dea --- /dev/null +++ b/src/application/wasm/WasmInterfaceComparisonService.ts @@ -0,0 +1,250 @@ +import { + WASM_INTERFACE_REPRESENTATION_BYTES, + wasmInterfaceEntryRepresentationBytes, +} from "../../domain/wasm/wasmInterface.js"; +import { setImmediate } from "node:timers/promises"; +import { + compareWasmInterfacesInputSchema, + wasmInterfaceComparisonSchema, +} from "../../contracts/wasm/wasmComparisonContracts.js"; +import { + createEvidence, + parseEvidence, + type Evidence, +} from "../../domain/evidence.js"; +import { + wasmArtifactSchema, + type WasmArtifact, +} from "../../domain/wasm/wasmArtifact.js"; +import type { WasmInterfaceEntry } from "../../domain/wasm/wasmInterface.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../../domain/analysisErrorCore.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { ARTIFACT_COMPARISON_PROVIDER } from "../InvestigationProviders.js"; +import { + resolvePairedEvidenceRequest, + type EvidenceLookup, +} from "../EvidenceInputResolver.js"; +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { z } from "zod"; + +const OPERATION = "compare_wasm_interfaces"; +const parseInspection = ( + raw: unknown, +): { evidence: Evidence; result: WasmArtifact } => { + const evidence = parseEvidence(raw); + if (evidence.operation !== "inspect_wasm_artifact") + throw new TypeError("Expected complete inspect_wasm_artifact Evidence"); + const result = wasmArtifactSchema.parse(evidence.normalized_result); + if ( + evidence.subject?.digest.sha256 !== result.artifact.sha256 || + evidence.subject.local_path !== result.artifact.path || + evidence.parameters.path !== result.artifact.path + ) + throw new TypeError( + "WASM Evidence subject/selection does not match normalized artifact identity", + ); + return { evidence, result }; +}; + +/** Compare only producer-owned typed facts; no raw WAT or original file is reread. */ +export const compareWasmInterfaces = async ( + rawInput: unknown, + options?: ExecutionOptions, + lookup?: EvidenceLookup, +): Promise> => { + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); + const parsed = compareWasmInterfacesInputSchema.safeParse(rawInput); + if (!parsed.success) + return err( + analysisInputErrorFromIssues(OPERATION, parsed.error.issues, rawInput), + ); + const resolved = resolvePairedEvidenceRequest(parsed.data, lookup); + if (!resolved.ok) return resolved; + try { + await setImmediate(); + options?.signal?.throwIfAborted(); + const left = parseInspection(resolved.value.left); + const right = parseInspection(resolved.value.right); + const scope = parsed.data.scope; + const selected = (entry: WasmInterfaceEntry): boolean => + scope === "both" || + entry.direction === (scope === "imports" ? "import" : "export"); + const groups = new Map< + string, + { + left: WasmInterfaceEntry[]; + right: WasmInterfaceEntry[]; + entry: WasmInterfaceEntry; + } + >(); + let work = 0; + for (const [side, inspection] of [ + ["left", left], + ["right", right], + ] as const) { + for (const entry of inspection.result.interface?.entries ?? []) { + if (++work % 256 === 0) { + await setImmediate(); + options?.signal?.throwIfAborted(); + } + if (!selected(entry)) continue; + const key = JSON.stringify([entry.direction, entry.module, entry.name]); + let group = groups.get(key); + if (group === undefined) { + group = { left: [], right: [], entry }; + groups.set(key, group); + } + group[side].push(entry); + } + } + const knownInventory = (inspection: typeof left): boolean => + inspection.result.interface?.state === "complete"; + const changes: z.infer["changes"] = + []; + const summary = { + added: 0, + removed: 0, + changed: 0, + unchanged: 0, + unknown: 0, + }; + let remaining = WASM_INTERFACE_REPRESENTATION_BYTES; + for (const group of groups.values()) { + remaining -= + 512 + + group.left.reduce( + (sum, entry) => sum + wasmInterfaceEntryRepresentationBytes(entry), + 0, + ) + + group.right.reduce( + (sum, entry) => sum + wasmInterfaceEntryRepresentationBytes(entry), + 0, + ); + if (remaining < 0) + throw new AnalysisResourceConstraintError( + OPERATION, + "capacity", + "WASM comparison exceeds typed interface representation capacity", + { maximum_bytes: WASM_INTERFACE_REPRESENTATION_BYTES }, + ); + if (++work % 256 === 0) { + await setImmediate(); + options?.signal?.throwIfAborted(); + } + let state: keyof typeof summary; + let reason: string | null = null; + const l = group.left[0]; + const r = group.right[0]; + if (group.left.length > 1 || group.right.length > 1) { + state = "unknown"; + reason = + "Duplicate interface names are ambiguous; all occurrences retained"; + } else if (!knownInventory(left) || !knownInventory(right)) { + state = "unknown"; + reason = + "Incomplete interface inventories cannot establish per-key uniqueness or absence"; + } else if ( + [l, r].some( + (entry) => + entry !== undefined && + (entry.signature === null || + entry.issue !== null || + entry.kind === "unknown"), + ) + ) { + state = "unknown"; + reason = + "Signature/type semantics were not admitted for every present entry"; + } else if (l === undefined) state = "added"; + else if (r === undefined) state = "removed"; + else if (l.kind !== r.kind) state = "changed"; + else + state = + JSON.stringify(l.signature) === JSON.stringify(r.signature) + ? "unchanged" + : "changed"; + summary[state]++; + changes.push({ + direction: group.entry.direction, + module: group.entry.module, + name: group.entry.name, + state, + left: group.left, + right: group.right, + reason, + }); + } + const compareText = (a: string, b: string): number => + a < b ? -1 : a > b ? 1 : 0; + changes.sort( + (a, b) => + compareText(a.direction, b.direction) || + compareText(a.module ?? "", b.module ?? "") || + compareText(a.name, b.name), + ); + const coverage = + left.result.interface === undefined && + right.result.interface === undefined + ? "unknown" + : !knownInventory(left) || !knownInventory(right) || summary.unknown > 0 + ? "partial" + : "complete-within-inputs"; + const limitations = [ + "Exact declared interface equality is not runtime/ABI compatibility, successful linking or original-source identity.", + "Typed facts come from the selected validated WABT decoding; unsupported signature semantics and duplicate names remain unknown.", + ]; + const report = wasmInterfaceComparisonSchema.parse({ + left: { + evidence_id: left.evidence.evidence_id, + artifact: left.result.artifact, + }, + right: { + evidence_id: right.evidence.evidence_id, + artifact: right.result.artifact, + }, + scope, + coverage, + changes, + summary, + limitations, + }); + await setImmediate(); + options?.signal?.throwIfAborted(); + return ok( + createEvidence(undefined, ARTIFACT_COMPARISON_PROVIDER, { + operation: OPERATION, + predicateType: "rea.wasm-interface-comparison", + parameters: { + left_evidence_id: left.evidence.evidence_id, + right_evidence_id: right.evidence.evidence_id, + scope, + }, + result: report, + confidence: "derived", + authority: "analyst-inference", + limitations, + evidenceLinks: [left.evidence.evidence_id, right.evidence.evidence_id], + }), + ); + } catch (cause: unknown) { + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); + if (cause instanceof AnalysisError) return err(cause); + return err( + new AnalysisInputError(OPERATION, { cause }, [ + { + path: [], + reason: "invalid_value", + message: cause instanceof Error ? cause.message : String(cause), + }, + ]), + ); + } +}; diff --git a/src/cli/wasmCommands.ts b/src/cli/wasmCommands.ts index b91bda033..73b8f8d3f 100644 --- a/src/cli/wasmCommands.ts +++ b/src/cli/wasmCommands.ts @@ -1,3 +1,6 @@ +import { compareWasmInterfaces } from "../application/wasm/WasmInterfaceComparisonService.js"; +import { parseCliJsonInput } from "../cliJsonInput.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; import { z } from "incur"; import { resolve } from "node:path"; import { wasmArtifactModule } from "../composition/wasm.js"; @@ -13,6 +16,29 @@ export const registerWasmCommands = ( logger: Logger, environment: Readonly, ): void => { + cli.command(CLI_COMMANDS.compareWasmInterfaces, { + description: + "Compare core WASM interfaces from two exact inspection Evidence records", + args: z.object({ + input: z + .string() + .describe( + "Inline JSON or input file containing left/right complete Evidence and required scope", + ), + }), + run: ({ args }) => + withCommandCancellation(async (signal) => + logCliCommand(logger, CLI_COMMANDS.compareWasmInterfaces, async () => { + const input = await parseCliJsonInput( + args.input, + "compare_wasm_interfaces", + ); + if (!input.ok) return input.error; + const result = await compareWasmInterfaces(input.value, { signal }); + return result.ok ? result.value : projectAnalysisError(result.error); + }), + ), + }); const service = wasmArtifactModule.create(environment); cli.command(wasmArtifactModule.cliCommand, { description: "Inspect an explicit local WASM artifact with WABT", diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index b3f2cb0a6..ff208c62e 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -4,6 +4,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectPacketCapture: "inspect-packet-capture", inspectSignalDecoders: "inspect-signal-decoders", decodeSignalRecording: "decode-signal-recording", + compareWasmInterfaces: "compare-wasm-interfaces", inspectWasmArtifact: "inspect-wasm-artifact", inspectSqliteDatabase: "inspect-sqlite-database", inspectEvmInterface: "inspect-evm-interface", diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index fe18c96b3..3a4acca99 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -27,6 +27,8 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { inspect_macho: "runs host Mach-O inspection tools, not the target", inspect_evm_interface: "decodes bytecode in REA's bundled worker without executing it", + compare_wasm_interfaces: + "compares retained inspection Evidence without executing modules", inspect_wasm_artifact: "WABT validates and decompiles the module without executing it", identify_flutter_build: diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 939f123eb..377905480 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -623,6 +623,7 @@ export const TOOL_EFFECTS: Readonly> = { }), compare_process_captures: sessionEvidence, compare_artifacts: sessionEvidence, + compare_wasm_interfaces: sessionEvidence, compare_functions: sessionEvidence, compare_bundles: effects({ mutatesSession: true }), find_changed_behavior: sessionEvidence, diff --git a/src/contracts/wasm/wasmComparisonContracts.ts b/src/contracts/wasm/wasmComparisonContracts.ts new file mode 100644 index 000000000..7eae508b7 --- /dev/null +++ b/src/contracts/wasm/wasmComparisonContracts.ts @@ -0,0 +1,63 @@ +import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; +import { z } from "zod"; +import { evidenceInputSchema } from "../evidenceInputContracts.js"; +import { wasmInterfaceSchema } from "../../domain/wasm/wasmInterface.js"; +import { toolContractMetadata } from "../toolEffects.js"; +import type { ToolContract } from "../toolContractTypes.js"; + +export const compareWasmInterfacesInputSchema = z.strictObject({ + left: evidenceInputSchema.describe( + "Exact complete inspect_wasm_artifact Evidence or retained session reference", + ), + right: evidenceInputSchema.describe( + "Exact complete inspect_wasm_artifact Evidence or retained session reference", + ), + scope: z + .enum(["imports", "exports", "both"]) + .describe( + "Explicit interface selection; static equality does not establish runtime compatibility", + ), +}); +const identity = z.object({ + evidence_id: z.string(), + artifact: z.object({ + path: z.string(), + sha256: z.string(), + bytes: z.number().int().nonnegative(), + }), +}); +export const wasmInterfaceComparisonSchema = z.object({ + left: identity, + right: identity, + scope: z.enum(["imports", "exports", "both"]), + coverage: z.enum(["complete-within-inputs", "partial", "unknown"]), + changes: z.array( + z.object({ + direction: z.enum(["import", "export"]), + module: z.string().nullable(), + name: z.string(), + state: z.enum(["added", "removed", "changed", "unchanged", "unknown"]), + left: wasmInterfaceSchema.shape.entries, + right: wasmInterfaceSchema.shape.entries, + reason: z.string().nullable(), + }), + ), + summary: z.object({ + added: z.number(), + removed: z.number(), + changed: z.number(), + unchanged: z.number(), + unknown: z.number(), + }), + limitations: z.array(z.string()), +}); +export const COMPARE_WASM_INTERFACES_CONTRACT = { + name: "compare_wasm_interfaces", + ...toolContractMetadata("compare_wasm_interfaces"), + kind: "application", + description: + "Compare typed core WASM import/export names, kinds and exact signatures from two authenticated inspect_wasm_artifact Evidence records. Select imports, exports or both explicitly. Retained references resolve only in the current session; CLI accepts portable inline Evidence. Duplicate names, unsupported types and missing historical interface facets remain unknown. Preserve original module digests and every occurrence; no source acquisition, module execution or runtime-compatibility inference.", + inputSchema: compareWasmInterfacesInputSchema, + outputSchema: evidenceResultOf(wasmInterfaceComparisonSchema), + examples: [], +} as const satisfies ToolContract; diff --git a/src/contracts/wasm/wasmToolContracts.ts b/src/contracts/wasm/wasmToolContracts.ts index 34f26f11d..cf05166d2 100644 --- a/src/contracts/wasm/wasmToolContracts.ts +++ b/src/contracts/wasm/wasmToolContracts.ts @@ -1,3 +1,4 @@ +import { COMPARE_WASM_INTERFACES_CONTRACT } from "./wasmComparisonContracts.js"; import { inspectWasmArtifactInputSchema, wasmArtifactSchema, @@ -40,4 +41,7 @@ export const INSPECT_WASM_ARTIFACT_CONTRACT = { } as const satisfies ToolContract; /** Keep the public catalog in its existing order, using its named contract. */ -export const WASM_TOOL_CONTRACTS = [INSPECT_WASM_ARTIFACT_CONTRACT] as const; +export const WASM_TOOL_CONTRACTS = [ + INSPECT_WASM_ARTIFACT_CONTRACT, + COMPARE_WASM_INTERFACES_CONTRACT, +] as const; diff --git a/src/domain/wasm/wasmArtifact.ts b/src/domain/wasm/wasmArtifact.ts index d8c89ddf3..a15735125 100644 --- a/src/domain/wasm/wasmArtifact.ts +++ b/src/domain/wasm/wasmArtifact.ts @@ -1,3 +1,4 @@ +import { wasmInterfaceSchema } from "./wasmInterface.js"; import { z } from "zod"; import { createHash } from "node:crypto"; import type { EvidenceSubjectTarget } from "../evidence.js"; @@ -32,6 +33,7 @@ export const wasmArtifactSchema = z description: z.string(), }), ), + interface: wasmInterfaceSchema.exactOptional(), imports: z.array(z.string()), exports: z.array(z.string()), headers: z.string(), diff --git a/src/domain/wasm/wasmInterface.ts b/src/domain/wasm/wasmInterface.ts new file mode 100644 index 000000000..f5559269c --- /dev/null +++ b/src/domain/wasm/wasmInterface.ts @@ -0,0 +1,109 @@ +import { z } from "zod"; + +const kind = z.enum(["func", "table", "memory", "global", "tag", "unknown"]); +const valueType = z.enum([ + "i32", + "i64", + "f32", + "f64", + "v128", + "funcref", + "externref", +]); +const limit = z.string().regex(/^(?:0|[1-9][0-9]*)$/u); +const signature = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("func"), + params: z.array(valueType), + results: z.array(valueType), + }), + z.object({ + kind: z.literal("tag"), + params: z.array(valueType), + results: z.array(valueType), + }), + z.object({ + kind: z.literal("table"), + element: z.enum(["funcref", "externref"]), + address_type: z.enum(["i32", "i64"]), + minimum: limit, + maximum: limit.nullable(), + shared: z.boolean(), + }), + z.object({ + kind: z.literal("memory"), + page_size: limit, + address_type: z.enum(["i32", "i64"]), + minimum: limit, + maximum: limit.nullable(), + shared: z.boolean(), + }), + z.object({ + kind: z.literal("global"), + value_type: valueType, + mutable: z.boolean(), + }), +]); + +/** Typed observations decoded once at the WABT producer boundary. */ +export const wasmInterfaceSchema = z.object({ + state: z.enum(["complete", "partial"]), + entries: z.array( + z + .object({ + direction: z.enum(["import", "export"]), + module: z.string().nullable(), + name: z.string(), + kind, + index: z.number().int().nonnegative().max(0xffff_ffff).nullable(), + signature: signature.nullable(), + wat_form: z.string(), + issue: z.string().nullable(), + }) + .superRefine((entry, context) => { + if ( + (entry.direction === "export" && entry.module !== null) || + (entry.direction === "import" && entry.module === null) + ) + context.addIssue({ + code: "custom", + message: "Interface direction does not match module-name presence", + }); + if (entry.signature !== null && entry.signature.kind !== entry.kind) + context.addIssue({ + code: "custom", + message: "Interface kind does not match its typed signature", + }); + }), + ), + issues: z.array(z.string()), +}); +export type WasmInterface = z.infer; +export type WasmInterfaceEntry = WasmInterface["entries"][number]; +export type WasmInterfaceSignature = NonNullable< + WasmInterfaceEntry["signature"] +>; + +export type WasmInterfaceValueType = z.infer; + +/** Bound eager typed output and repeated signature expansion independently of WAT bytes. */ +export const WASM_INTERFACE_REPRESENTATION_BYTES = 32 * 1024 * 1024; + +/** Conservative JSON/retention charge; shared arrays still expand at every occurrence. */ +export const wasmInterfaceEntryRepresentationBytes = ( + entry: WasmInterfaceEntry, +): number => { + let bytes = + 1024 + + [entry.module, entry.name, entry.wat_form, entry.issue].reduce( + (sum, value) => sum + (value?.length ?? 0) * 6, + 0, + ); + const signature = entry.signature; + if (signature?.kind === "func" || signature?.kind === "tag") + bytes += (signature.params.length + signature.results.length) * 16; + else if (signature !== null) + for (const value of Object.values(signature)) + if (typeof value === "string") bytes += value.length * 6; + return bytes; +}; diff --git a/src/server/registerWasmTools.ts b/src/server/registerWasmTools.ts index 494972b2f..a7a056cc7 100644 --- a/src/server/registerWasmTools.ts +++ b/src/server/registerWasmTools.ts @@ -1,3 +1,7 @@ +import { compareWasmInterfaces } from "../application/wasm/WasmInterfaceComparisonService.js"; +import { COMPARE_WASM_INTERFACES_CONTRACT } from "../contracts/wasm/wasmComparisonContracts.js"; +import { resolvePairedEvidenceRequest } from "../application/EvidenceInputResolver.js"; +import { recordSessionEvidenceSources } from "./sessionEvidence.js"; import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import type { WasmArtifactService } from "../application/wasm/WasmArtifactService.js"; @@ -21,6 +25,38 @@ export const registerWasmTools = ( withAdmittedAnalysis?: WithAdmittedAnalysis, evidenceById?: EvidenceLookup, ): void => { + const comparison = COMPARE_WASM_INTERFACES_CONTRACT; + const compareHandler = createEvidenceToolHandler( + server, + async (rawInput, options) => { + const input = comparison.inputSchema.safeParse(rawInput); + if (!input.success) + return err( + analysisInputErrorFromIssues( + comparison.name, + input.error.issues, + rawInput, + ), + ); + const sources = resolvePairedEvidenceRequest(input.data, evidenceById); + if (!sources.ok) return sources; + const result = await compareWasmInterfaces(sources.value, options); + if (!result.ok) return result; + const recorded = recordSessionEvidenceSources(recordEvidence, [ + sources.value.left, + sources.value.right, + ]); + return recorded.ok ? result : recorded; + }, + logger, + recordEvidence, + withAdmittedAnalysis, + ); + server.registerTool( + comparison.name, + toolRegistrationOptions(comparison), + compareHandler(comparison), + ); const contract = wasmArtifactModule.contract; const handler = createEvidenceToolHandler( server, diff --git a/src/wasm/WabtArtifactProvider.ts b/src/wasm/WabtArtifactProvider.ts index c5f0aef53..3be21b2a1 100644 --- a/src/wasm/WabtArtifactProvider.ts +++ b/src/wasm/WabtArtifactProvider.ts @@ -362,8 +362,13 @@ export class WabtArtifactProvider implements WasmArtifactPort { OPERATION, "Unexpected WABT decoding output.", ); - const { imports, exports, customSectionNames, debugModuleNames } = - parseWabtWat(wat.stdout.text); + const { + imports, + exports, + customSectionNames, + debugModuleNames, + interface: interfaceFacts, + } = parseWabtWat(wat.stdout.text); capturedOutput = { stdout: dump.stdout.text, stderr: dump.stderr.text, @@ -388,6 +393,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { sections, imports, exports, + interface: interfaceFacts, headers, details, wat: { diff --git a/src/wasm/WabtInterface.test.ts b/src/wasm/WabtInterface.test.ts new file mode 100644 index 000000000..68505f3ba --- /dev/null +++ b/src/wasm/WabtInterface.test.ts @@ -0,0 +1,116 @@ +import { expect, it } from "vitest"; +import { parseWabtWat } from "./WabtWat.js"; + +it("resolves imported index spaces and decoded escaped names without depending on body/type numbering", () => { + const report = parseWabtWat(`(module + (type (;0;) (func (param i32) (result i64))) + (import "a\\22b" "\\ef\\bb\\bf\\00" (func (;0;) (type 0))) + (func (;1;) (type 0) (param i32) (result i64) local.get 0 i64.extend_i32_s) + (export "" (func 1)))`).interface; + expect(report.entries).toMatchObject([ + { + direction: "import", + module: 'a"b', + name: "\ufeff\0", + index: 0, + signature: { kind: "func", params: ["i32"], results: ["i64"] }, + }, + { + direction: "export", + name: "", + index: 1, + signature: { kind: "func", params: ["i32"], results: ["i64"] }, + }, + ]); +}); + +it("retains exact memory/table/global/tag declarations and keeps unsupported references unknown", () => { + const report = parseWabtWat(`(module + (type (func (param i32))) + (type (struct (field i32))) + (import "env" "table" (table 1 3 funcref)) + (memory i64 2 4 shared) + (global (mut i64) (i64.const 1)) + (tag (type 0)) + (func (type 0)) + (func (param (ref null 1))) + (export "memory" (memory 0)) (export "global" (global 0)) + (export "tag" (tag 0)) (export "gc" (func 1)))`).interface; + expect(report.entries.map((e) => e.signature)).toEqual([ + { + kind: "table", + element: "funcref", + address_type: "i32", + minimum: "1", + maximum: "3", + shared: false, + }, + { + kind: "memory", + page_size: "65536", + address_type: "i64", + minimum: "2", + maximum: "4", + shared: true, + }, + { kind: "global", value_type: "i64", mutable: true }, + { kind: "tag", params: ["i32"], results: [] }, + null, + ]); + expect(report.entries[4]?.issue).toBeTruthy(); +}); + +it("does not bind recursive-type index spaces by guessing their cardinality", () => { + const report = parseWabtWat( + `(module (rec (type (struct)) (type (func))) (func (type 1)) (export "f" (func 0)))`, + ).interface; + expect(report.entries).toMatchObject([ + { name: "f", signature: null, issue: expect.any(String) }, + ]); +}); + +it("preserves custom memory page sizes and treats unadmitted nested declarations as unknown", () => { + const custom = parseWabtWat( + '(module (memory 1 2 (pagesize 1)) (export "m" (memory 0)))', + ).interface; + expect(custom.entries[0]?.signature).toMatchObject({ + kind: "memory", + page_size: "1", + }); + const unknown = parseWabtWat( + '(module (memory 1 (future-proposal 0)) (export "m" (memory 0)))', + ).interface; + expect(unknown.entries[0]?.signature).toBeNull(); +}); + +it("refuses typed name expansion within the source WABT byte cap before retaining interface output", () => { + const name = "x".repeat(6 * 1024 * 1024); + expect( + parseWabtWat(`(module (import "${name}" "f" (func)))`).interface, + ).toMatchObject({ + state: "partial", + entries: [], + issues: [expect.stringMatching(/representation budget/u)], + }); +}); + +it("charges shared type signatures at every retained occurrence and preserves bounded partial facts", () => { + const params = Array(1000).fill("i32").join(" "); + const exports = Array.from( + { length: 10000 }, + (_, i) => `(export "f${i}" (func 0))`, + ).join(" "); + const report = parseWabtWat( + `(module (type (func (param ${params}))) (import "e" "f" (func (type 0))) ${exports})`, + ).interface; + expect(report.state).toBe("partial"); + expect(report.entries.length).toBeGreaterThan(1); + expect(report.entries.length).toBeLessThan(10001); + expect(report.entries[0]?.signature).toMatchObject({ + kind: "func", + params: expect.any(Array), + }); + expect(Buffer.byteLength(JSON.stringify(report))).toBeLessThan( + 32 * 1024 * 1024, + ); +}); diff --git a/src/wasm/WabtInterface.ts b/src/wasm/WabtInterface.ts new file mode 100644 index 000000000..65e6fc0fa --- /dev/null +++ b/src/wasm/WabtInterface.ts @@ -0,0 +1,325 @@ +import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; +import { wasmInterfaceEntryRepresentationBytes } from "../domain/wasm/wasmInterface.js"; +import { WasmInterfaceBudget } from "./WasmInterfaceBudget.js"; +import type { + WasmInterface, + WasmInterfaceEntry, + WasmInterfaceSignature, + WasmInterfaceValueType, +} from "../domain/wasm/wasmInterface.js"; +import { watString } from "./WabtString.js"; + +/** Split direct WAT children only; function instruction bodies are never materialized. */ +const parts = (form: string): string[] => { + const budget = new WasmInterfaceBudget(); + const items: string[] = []; + let i = 1; + while (i < form.length - 1) { + if (/\s/u.test(form[i] ?? "")) { + i++; + continue; + } + if (form.startsWith("(;", i)) { + let depth = 1; + i += 2; + while (i < form.length && depth > 0) { + if (form.startsWith("(;", i)) { + depth++; + i += 2; + } else if (form.startsWith(";)", i)) { + depth--; + i += 2; + } else i++; + } + continue; + } + if (form.startsWith(";;", i)) { + const end = form.indexOf("\n", i); + i = end < 0 ? form.length : end + 1; + continue; + } + const start = i; + if (form[i] === '"') { + i = watString(form, i + 1).end; + } else if (form[i] === "(") { + let depth = 1; + i++; + while (i < form.length && depth > 0) { + if (form[i] === '"') i = watString(form, i + 1).end; + else { + if (form[i] === "(") depth++; + else if (form[i] === ")") depth--; + i++; + } + } + } else { + while (i < form.length && !/[\s()]/u.test(form[i] ?? "")) i++; + const atom = form.slice(start, i); + if (items[0] === "func" && items.length > 1 && !atom.startsWith("$")) + break; + } + if (i <= start) throw new Error("Malformed WABT form"); + budget.reserve(64 + (i - start) * 2); + items.push(form.slice(start, i)); + } + return items; +}; +const head = (form: string): string | undefined => parts(form)[0]; +const admittedValues = new Set([ + "i32", + "i64", + "f32", + "f64", + "v128", + "funcref", + "externref", +]); +const isValueType = (type: string): type is WasmInterfaceValueType => + admittedValues.has(type); +const values = ( + form: string, + category: string, +): WasmInterfaceValueType[] | null => { + const result: WasmInterfaceValueType[] = []; + for (const item of parts(form)) { + if (!item.startsWith("(")) continue; + const group = parts(item); + if (group[0] !== category) continue; + for (const type of group.slice(1).filter((t) => !t.startsWith("$"))) { + if (!isValueType(type)) return null; + result.push(type); + } + } + return result; +}; +const functionType = ( + form: string, + kind: "func" | "tag", +): WasmInterfaceSignature | null => { + const params = values(form, "param"); + const results = values(form, "result"); + return params === null || results === null ? null : { kind, params, results }; +}; +const name = (text: string | undefined): string => { + if (text?.startsWith('"') !== true) + throw new Error("WABT interface name is missing"); + return new TextDecoder("utf8", { fatal: true, ignoreBOM: true }).decode( + watString(text, 1).bytes, + ); +}; +const integer = (text: string): string | null => { + if (!/^(?:\d+|0x[\da-f]+)$/iu.test(text)) return null; + return BigInt(text).toString(); +}; + +/** Resolve index spaces at the producer, not from retained raw WAT in consumers. */ +const decodeWabtInterface = ( + forms: readonly string[], + result: WasmInterface, +): WasmInterface => { + const budget = new WasmInterfaceBudget(); + const types: (WasmInterfaceSignature | null)[] = []; + const typeNames = new Map(); + const definitions = new Map< + string, + { signature: WasmInterfaceSignature | null; label: string | undefined }[] + >(); + const typeSpaceUnknown = forms.some((f) => head(f) === "rec"); + for (const form of forms) { + const p = parts(form); + if (p[0] !== "type") continue; + if (p[1]?.startsWith("$") === true) typeNames.set(p[1], types.length); + const body = p.find((item) => item.startsWith("(")); + budget.reserve(256 + (body?.length ?? 0) * 6); + types.push( + body !== undefined && head(body) === "func" && !typeSpaceUnknown + ? functionType(body, "func") + : null, + ); + } + const signature = (form: string): WasmInterfaceSignature | null => { + const p = parts(form); + const kind = p[0]; + if (kind === "func" || kind === "tag") { + const use = p.find((t) => t.startsWith("(type ")); + if (use === undefined) return functionType(form, kind); + const reference = parts(use)[1]; + const index = + reference === undefined + ? undefined + : reference.startsWith("$") + ? typeNames.get(reference) + : Number(reference); + const type = + index === undefined || typeSpaceUnknown ? undefined : types[index]; + return type?.kind === "func" ? { ...type, kind } : null; + } + if (kind === "memory" || kind === "table") { + const groups = p.slice(1).filter((t) => t.startsWith("(")); + let pageSize = "65536"; + for (const group of groups) { + const declaration = parts(group); + if ( + kind !== "memory" || + declaration[0] !== "pagesize" || + declaration.length !== 2 + ) + return null; + const parsedPageSize = integer(declaration[1] ?? ""); + if (parsedPageSize === null) return null; + pageSize = parsedPageSize; + } + const tokens = p + .slice(1) + .filter((t) => !t.startsWith("$") && !t.startsWith("(")); + const address_type = tokens[0] === "i64" ? "i64" : "i32"; + if (tokens[0] === "i64" || tokens[0] === "i32") tokens.shift(); + const minimum = integer(tokens.shift() ?? ""); + const possibleMaximum = + tokens[0] === undefined ? null : integer(tokens[0]); + if (possibleMaximum !== null) tokens.shift(); + const shared = tokens[0] === "shared"; + if (shared) tokens.shift(); + if (minimum === null) return null; + if (kind === "memory") + return tokens.length === 0 + ? { + kind, + page_size: pageSize, + address_type, + minimum, + maximum: possibleMaximum, + shared, + } + : null; + const element = tokens.shift(); + return tokens.length === 0 && + element !== undefined && + (element === "funcref" || element === "externref") + ? { + kind, + address_type, + minimum, + maximum: possibleMaximum, + shared, + element, + } + : null; + } + if (kind === "global") { + const declaration = p[1]?.startsWith("$") === true ? p[2] : p[1]; + const mutable = declaration?.startsWith("(mut ") === true; + const value_type = + mutable && declaration !== undefined + ? parts(declaration)[1] + : declaration; + return value_type !== undefined && isValueType(value_type) + ? { kind, value_type, mutable } + : null; + } + return null; + }; + const addDefinition = ( + form: string, + ): { + index: number; + kind: WasmInterfaceEntry["kind"]; + signature: WasmInterfaceSignature | null; + } => { + const p = parts(form); + const rawKind = p[0] ?? "unknown"; + const kind = ["func", "table", "memory", "global", "tag"].includes(rawKind) + ? (rawKind as WasmInterfaceEntry["kind"]) + : "unknown"; + const space = definitions.get(kind) ?? []; + const item = { + signature: signature(form), + label: p[1]?.startsWith("$") === true ? p[1] : undefined, + }; + budget.reserve(256); + const index = space.length; + space.push(item); + definitions.set(kind, space); + return { index, kind, signature: item.signature }; + }; + for (const form of forms) { + const p = parts(form); + if (p[0] !== "import") continue; + const body = p[3]; + if (body === undefined) throw new Error("Incomplete WABT import"); + const observed = addDefinition(body); + const entry: WasmInterfaceEntry = { + direction: "import", + module: name(p[1]), + name: name(p[2]), + ...observed, + wat_form: form, + issue: + observed.signature === null + ? "Import type is outside admitted core signature semantics" + : null, + }; + budget.reserve(wasmInterfaceEntryRepresentationBytes(entry)); + result.entries.push(entry); + } + for (const form of forms) { + const kind = head(form); + if ( + kind !== undefined && + ["func", "table", "memory", "global", "tag"].includes(kind) + ) + addDefinition(form); + } + for (const form of forms) { + const p = parts(form); + if (p[0] !== "export") continue; + const body = p[2]; + if (body === undefined) throw new Error("Incomplete WABT export"); + const descriptor = parts(body); + const rawKind = descriptor[0] ?? "unknown"; + const kind = ["func", "table", "memory", "global", "tag"].includes(rawKind) + ? (rawKind as WasmInterfaceEntry["kind"]) + : "unknown"; + const reference = descriptor[1]; + const space = definitions.get(kind) ?? []; + const index = + reference === undefined + ? -1 + : reference.startsWith("$") + ? space.findIndex((d) => d.label === reference) + : Number(reference); + const observed = + Number.isSafeInteger(index) && index >= 0 ? space[index] : undefined; + const entry: WasmInterfaceEntry = { + direction: "export", + module: null, + name: name(p[1]), + kind, + index: observed === undefined ? null : index, + signature: observed?.signature ?? null, + wat_form: form, + issue: + observed?.signature == null + ? "Export type/index is outside admitted core signature semantics" + : null, + }; + budget.reserve(wasmInterfaceEntryRepresentationBytes(entry)); + result.entries.push(entry); + } + // Unsupported signatures leave inventory completeness intact. A missing + // signature is unknown per entry, not proof that a name is absent. + return result; +}; + +/** New typed coverage may be incomplete without discarding existing decoded WAT Evidence. */ +export const parseWabtInterface = (forms: readonly string[]): WasmInterface => { + const result: WasmInterface = { state: "complete", entries: [], issues: [] }; + try { + return decodeWabtInterface(forms, result); + } catch (cause: unknown) { + if (!(cause instanceof AnalysisResourceConstraintError)) throw cause; + result.state = "partial"; + result.issues.push(cause.message); + return result; + } +}; diff --git a/src/wasm/WabtOutput.test.ts b/src/wasm/WabtOutput.test.ts index abd20d848..91740f09d 100644 --- a/src/wasm/WabtOutput.test.ts +++ b/src/wasm/WabtOutput.test.ts @@ -229,7 +229,7 @@ it("extracts escaped WAT import/export forms while retaining multiline producer new URL("../../tests/fixtures/wasm/decoded.wat", import.meta.url), "utf8", ); - expect(parseWabtWat(text)).toEqual({ + expect(parseWabtWat(text)).toMatchObject({ imports: ['(import "env" "foo" (func (;0;) (type 0)))'], exports: ['(export "memory" (memory 0))', '(export "run" (func 1))'], customSectionNames: ["test"], @@ -239,7 +239,7 @@ it("extracts escaped WAT import/export forms while retaining multiline producer parseWabtWat( '(module (import "a.b" "c\\0aline" (func)) (export "fake\\22Export[1]:" (func 0)))', ), - ).toEqual({ + ).toMatchObject({ imports: ['(import "a.b" "c\\0aline" (func))'], exports: ['(export "fake\\22Export[1]:" (func 0))'], customSectionNames: [], diff --git a/src/wasm/WabtString.ts b/src/wasm/WabtString.ts new file mode 100644 index 000000000..79615b193 --- /dev/null +++ b/src/wasm/WabtString.ts @@ -0,0 +1,46 @@ +/** Decode a producer WAT string to frame objdump's raw display text. */ +export const watString = (form: string, start: number) => { + let end = start; + while (end < form.length && form[end] !== '"') { + if (form[end] === "\\") end++; + end++; + } + if (end === form.length) + throw new Error("Incomplete WABT custom-section name."); + const name = form.slice(start, end); + // Escapes shrink; literal UTF-8 has the same byte extent as its source. + const bytes = Buffer.alloc(Buffer.byteLength(name, "utf8")); + let length = 0; + for (let index = 0; index < name.length;) { + if (name[index] !== "\\") { + const next = name.indexOf("\\", index); + const end = next < 0 ? name.length : next; + length += bytes.write(name.slice(index, end), length, "utf8"); + index = end; + continue; + } + const hex = name.slice(index + 1, index + 3); + if (/^[\da-f]{2}$/iu.test(hex)) { + bytes[length++] = Number.parseInt(hex, 16); + index += 3; + } else { + const escape = name[index + 1]; + const value = + escape === "t" + ? 9 + : escape === "n" + ? 10 + : escape === "r" + ? 13 + : escape === '"' + ? 34 + : escape === "\\" + ? 92 + : undefined; + if (value === undefined) throw new Error("Unsupported WABT name escape."); + bytes[length++] = value; + index += 2; + } + } + return { bytes: bytes.subarray(0, length), end: end + 1 }; +}; diff --git a/src/wasm/WabtWat.ts b/src/wasm/WabtWat.ts index d766fe617..01b9f16bc 100644 --- a/src/wasm/WabtWat.ts +++ b/src/wasm/WabtWat.ts @@ -1,49 +1,5 @@ -/** Decode a producer WAT string to frame objdump's raw display text. */ -const watString = (form: string, start: number) => { - let end = start; - while (end < form.length && form[end] !== '"') { - if (form[end] === "\\") end++; - end++; - } - if (end === form.length) - throw new Error("Incomplete WABT custom-section name."); - const name = form.slice(start, end); - // Escapes shrink; literal UTF-8 has the same byte extent as its source. - const bytes = Buffer.alloc(Buffer.byteLength(name, "utf8")); - let length = 0; - for (let index = 0; index < name.length;) { - if (name[index] !== "\\") { - const next = name.indexOf("\\", index); - const end = next < 0 ? name.length : next; - length += bytes.write(name.slice(index, end), length, "utf8"); - index = end; - continue; - } - const hex = name.slice(index + 1, index + 3); - if (/^[\da-f]{2}$/iu.test(hex)) { - bytes[length++] = Number.parseInt(hex, 16); - index += 3; - } else { - const escape = name[index + 1]; - const value = - escape === "t" - ? 9 - : escape === "n" - ? 10 - : escape === "r" - ? 13 - : escape === '"' - ? 34 - : escape === "\\" - ? 92 - : undefined; - if (value === undefined) throw new Error("Unsupported WABT name escape."); - bytes[length++] = value; - index += 2; - } - } - return { bytes: bytes.subarray(0, length), end: end + 1 }; -}; +import { parseWabtInterface } from "./WabtInterface.js"; +import { watString } from "./WabtString.js"; /** Read only module-name metadata from WABT's preserved name-section payload. */ const moduleNames = (bytes: Uint8Array): string[] => { @@ -85,6 +41,7 @@ const moduleNames = (bytes: Uint8Array): string[] => { export const parseWabtWat = (text: string) => { if (!/^\(module(?:\s|\))/u.test(text)) throw new Error("Unsupported WABT WAT module prefix."); + const forms: string[] = []; const imports: string[] = []; const exports: string[] = []; const customSectionNames: string[] = []; @@ -140,6 +97,12 @@ export const parseWabtWat = (text: string) => { } else if (char === ")") { if (depth === 2) { const form = text.slice(start, index + 1); + if ( + /^\((?:type|rec|import|export|func|table|memory|global|tag)\s/u.test( + form, + ) + ) + forms.push(form); if (/^\(import\s/u.test(form)) imports.push(form); else if (/^\(export\s/u.test(form)) exports.push(form); else if (/^\(@custom\s/u.test(form)) { @@ -172,5 +135,11 @@ export const parseWabtWat = (text: string) => { } if (depth !== 0 || quoted || commentDepth !== 0) throw new Error("Incomplete WABT WAT output."); - return { imports, exports, customSectionNames, debugModuleNames }; + return { + imports, + exports, + customSectionNames, + debugModuleNames, + interface: parseWabtInterface(forms), + }; }; diff --git a/src/wasm/WasmInterfaceBudget.ts b/src/wasm/WasmInterfaceBudget.ts new file mode 100644 index 000000000..0d928b7ff --- /dev/null +++ b/src/wasm/WasmInterfaceBudget.ts @@ -0,0 +1,17 @@ +import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; +import { WASM_INTERFACE_REPRESENTATION_BYTES } from "../domain/wasm/wasmInterface.js"; + +/** Eager typed observations share the existing WABT output capacity, not an agent token estimate. */ +export class WasmInterfaceBudget { + #remaining = WASM_INTERFACE_REPRESENTATION_BYTES; + reserve(bytes: number): void { + if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining) + throw new AnalysisResourceConstraintError( + "inspect_wasm_artifact", + "capacity", + "Typed WASM interface decoding exceeds the WABT output representation budget", + { maximum_bytes: WASM_INTERFACE_REPRESENTATION_BYTES }, + ); + this.#remaining -= bytes; + } +} diff --git a/tests/acceptance/wasmInterfaceComparison.test.ts b/tests/acceptance/wasmInterfaceComparison.test.ts new file mode 100644 index 000000000..77f6c4319 --- /dev/null +++ b/tests/acceptance/wasmInterfaceComparison.test.ts @@ -0,0 +1,130 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect } from "vitest"; +import { createEvidence, parseEvidence } from "../../src/domain/evidence.js"; +import { wasmInterfaceComparisonSchema } from "../../src/contracts/wasm/wasmComparisonContracts.js"; +import { composeBinarySession } from "../../src/application/binary/BinarySessionComposition.js"; +import { AnalysisProviderRegistry } from "../../src/application/binary/AnalysisProviderRegistry.js"; +import { createServer } from "../../src/server/createServer.js"; +import { wasmArtifactFixture } from "../fixtures/wasm/artifact.js"; +import { cliTest } from "../support/cli/cliFixture.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +cliTest( + "compares portable Evidence identically through CLI and retained MCP with schema/provenance", + async ({ cli }) => { + const sources = (["i32", "i64"] as const).map((parameter) => { + const report = wasmArtifactFixture(); + report.interface = { + state: "complete", + issues: [], + entries: [ + { + direction: "export", + module: null, + name: 'quoted"\ufeffname', + kind: "func", + index: 0, + signature: { kind: "func", params: [parameter], results: [] }, + wat_form: '(export "quoted\\22\\ef\\bb\\bfname" (func 0))', + issue: null, + }, + ], + }; + return createEvidence( + { + path: report.artifact.path, + sha256: report.artifact.sha256, + format: "file", + }, + { id: "typed-fixture", name: "Typed fixture", version: "1" }, + { + operation: "inspect_wasm_artifact", + parameters: { path: report.artifact.path }, + result: report, + confidence: "observed", + }, + ); + }); + const [left, right] = sources; + if (left === undefined || right === undefined) + throw new Error("Missing paired fixture"); + const root = await createTestTempDirectory("rea-wasm-comparison-"); + const path = join(root, "input.json"); + const input = { left, right, scope: "both" }; + await writeFile(path, JSON.stringify(input)); + const cliResult = await cli.run({ + arguments: ["compare-wasm-interfaces", path, "--json"], + }); + expect(cliResult.exitCode).toBe(0); + const cliEvidence = parseEvidence(cliResult.json); + const cliReport = wasmInterfaceComparisonSchema.parse( + cliEvidence.normalized_result, + ); + expect(cliReport).toMatchObject({ + coverage: "complete-within-inputs", + changes: [{ name: 'quoted"\ufeffname', state: "changed" }], + left: { + evidence_id: left.evidence_id, + artifact: { + path: "/artifacts/module.wasm", + sha256: "a".repeat(64), + bytes: 8, + }, + }, + }); + const session = composeBinarySession(new AnalysisProviderRegistry([])); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "wasm-interface-parity", version: "1" }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + try { + await server.connect(st); + await client.connect(ct); + const tool = (await client.listTools()).tools.find( + (t) => t.name === "compare_wasm_interfaces", + ); + if (tool?.outputSchema === undefined) + throw new Error("Missing comparison schema"); + const outputSchema: Record = tool.outputSchema; + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + expect(ajv.validate(tool.inputSchema, input)).toBe(true); + expect(ajv.validate(tool.inputSchema, { left, right })).toBe(false); + const mcp = await client.callTool({ name: tool.name, arguments: input }); + expect(mcp.isError, JSON.stringify(mcp)).not.toBe(true); + expect( + ajv.validate(outputSchema, mcp.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const mcpEvidence = parseEvidence(mcp.structuredContent); + expect(mcpEvidence.normalized_result).toEqual(cliReport); + expect(mcpEvidence.evidence_id).toBe(cliEvidence.evidence_id); + expect(mcpEvidence.evidence_links).toEqual([ + left.evidence_id, + right.evidence_id, + ]); + const retained = await client.callTool({ + name: tool.name, + arguments: { + scope: "both", + left: { kind: "retained-evidence", evidence_id: left.evidence_id }, + right: { kind: "retained-evidence", evidence_id: right.evidence_id }, + }, + }); + expect(retained.isError).not.toBe(true); + expect(parseEvidence(retained.structuredContent).evidence_id).toBe( + mcpEvidence.evidence_id, + ); + const invalid = await client.callTool({ + name: tool.name, + arguments: { ...input, scope: "all" }, + }); + expect(invalid.isError).toBe(true); + } finally { + await client.close(); + await server.close(); + await session.close(); + } + }, +); From 554ea7611ea7eb94d73c80b57be168527de17d79 Mon Sep 17 00:00:00 2001 From: Jin Date: Tue, 13 Oct 2026 11:49:20 -0400 Subject: [PATCH 30/36] fix(adb): keep the device's refusal when a package change exits nonzero (#2344) Squash merge of https://github.com/morluto/rea/pull/2344. Co-authored-by: Hui Co-authored-by: Claude Opus 5.5 --- src/adb/AdbProvider.test.ts | 62 +++++++++++++++++++++++++++++++++++++ src/adb/AdbProvider.ts | 38 +++++++++++++++++++++-- src/adb/AdbShellOutput.ts | 9 ++++-- 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index 23645e456..3d15325fd 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -1127,6 +1127,68 @@ exit 1`, expect(projected.message).toContain("not installed for 0"); }); +// adb exits 1 on a refused package change: `adb install` after printing the +// status line to stderr, `cmd package uninstall` after printing it to stdout. +it("preserves the device's install refusal when adb exits nonzero", async () => { + const { binary, root } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "-s" ] && [ "$3" = "install" ]; then + printf 'Performing Streamed Install\\n' + printf 'adb: failed to install app.apk: Failure [INSTALL_FAILED_ALREADY_EXISTS: Attempt to re-install com.example without first uninstalling.]\\n' >&2 + exit 1 +fi +exit 1`, + ); + const apk = join(root, "app.apk"); + await writeFile(apk, "apk"); + const projected = await refusedBy(providerOver(binary), { + operation: "install_adb_package", + input: { serial: "emulator-5554", apk_path: apk, replace: false }, + }); + expect(projected).toMatchObject({ + code: "unsupported_target", + details: { + reason: + "INSTALL_FAILED_ALREADY_EXISTS: Attempt to re-install com.example without first uninstalling.", + }, + }); + expect(projected.message).toContain( + "INSTALL_FAILED_ALREADY_EXISTS: Attempt to re-install com.example without first uninstalling.", + ); +}); + +it("preserves the device's uninstall refusal when adb exits nonzero", async () => { + const { binary } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "-s" ] && [ "$3" = "uninstall" ]; then printf 'Failure [not installed for 0]\\n'; exit 1; fi +exit 1`, + ); + const projected = await refusedBy(providerOver(binary), { + operation: "uninstall_adb_package", + input: { serial: "emulator-5554", package: "com.absent" }, + }); + expect(projected).toMatchObject({ + code: "unsupported_target", + details: { reason: "not installed for 0" }, + }); + expect(projected.message).toContain("not installed for 0"); +}); + +it("keeps a nonzero install without a device verdict as an unreadable result", async () => { + const { binary, root } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "-s" ] && [ "$3" = "install" ]; then printf 'adb: failed to install app.apk: closed\\n' >&2; exit 1; fi +exit 1`, + ); + const apk = join(root, "app.apk"); + await writeFile(apk, "apk"); + const projected = await refusedBy(providerOver(binary), { + operation: "install_adb_package", + input: { serial: "emulator-5554", apk_path: apk, replace: false }, + }); + expect(projected).toMatchObject({ code: "unreadable_output" }); +}); + it("starts an app through resolved launcher component and force-stops it", async () => { const { binary } = await writeAdbStub( shellStub({ diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index d26345eba..bce989aca 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -71,6 +71,7 @@ import { parseBugreportCopiedPath, parseDumpsysPackageOutput, parseFeatureListOutput, + packageChangeFailureReason, parsePackageChangeOutput, parseProcessListOutput, parsePngDimensions, @@ -248,6 +249,31 @@ const runAdb = async ( }); }; +/** + * adb exits nonzero when the device refuses a package change, printing the + * `Failure [...]` verdict to stderr (install) or stdout (uninstall). Read the + * refusal from that failed run so it is not reported as unreadable output. + */ +const packageChangeRefusal = ( + operation: string, + serial: string, + error: AnalysisError, +): Result | undefined => { + if (!(error instanceof AnalysisProtocolError)) return undefined; + const failure = execFileOutputFailure(error.cause); + if (failure === undefined) return undefined; + const reason = packageChangeFailureReason( + `${failure.stdout}\n${failure.stderr}`, + ); + return reason === null + ? undefined + : err( + new AnalysisUnsupportedTargetError(operation, serial, reason, { + cause: error, + }), + ); +}; + const clientIdentityResult = ( selection: AdbBinarySelection, identity: AdbClientIdentity, @@ -1754,7 +1780,11 @@ export class AdbProvider { { timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES }, signal, ); - if (!installed.ok) return installed; + if (!installed.ok) + return ( + packageChangeRefusal(operation, input.serial, installed.error) ?? + installed + ); const verdict = parsePackageChangeOutput(installed.value.stdout); if (verdict.status === "failure") return err( @@ -1803,7 +1833,11 @@ export class AdbProvider { }, signal, ); - if (!uninstalled.ok) return uninstalled; + if (!uninstalled.ok) + return ( + packageChangeRefusal(operation, input.serial, uninstalled.error) ?? + uninstalled + ); const verdict = parsePackageChangeOutput(uninstalled.value.stdout); if (verdict.status === "failure") return err( diff --git a/src/adb/AdbShellOutput.ts b/src/adb/AdbShellOutput.ts index 726e6164d..6dac4cc0c 100644 --- a/src/adb/AdbShellOutput.ts +++ b/src/adb/AdbShellOutput.ts @@ -293,6 +293,10 @@ export const parseBugreportCopiedPath = (stdout: string): string | null => { return match === null ? null : match[1]!; }; +/** Read the device's `Failure [REASON]` verdict from package-change output. */ +export const packageChangeFailureReason = (output: string): string | null => + /Failure \[([^\]]+)\]/u.exec(output)?.[1] ?? null; + /** * Parse `adb install`/`adb uninstall` output. adb can print `Failure [...]` * with a zero exit code, so the text decides, not the exit status. @@ -305,10 +309,11 @@ export const parsePackageChangeOutput = ( } => { const text = stdout.trim(); if (/^Success$/mu.test(text)) return { status: "success", reason: null }; - const failure = /Failure \[([^\]]+)\]/u.exec(text); return { status: "failure", - reason: failure?.[1] ?? (text === "" ? null : text.slice(0, 512)), + reason: + packageChangeFailureReason(text) ?? + (text === "" ? null : text.slice(0, 512)), }; }; From 168bfe50604b3a435c05f2db9f8a0cf47230c660 Mon Sep 17 00:00:00 2001 From: SII-YDD <38822679+SII-YDD@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:49:33 +0800 Subject: [PATCH 31/36] fix(target): probe WASM before inferred database routing (#2007) * fix(target): probe WASM before inferred database routing * test(wasm): execute recovery arguments from native refusal * test(ci): repair fixture alignment and share MCP assertions --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/wasm-artifacts.md | 5 + src/application/BinaryTargetResolver.ts | 7 +- .../filesystem/targetDiagnostics.test.ts | 69 ++++++- tests/boundary/mcp/analysisViewMcp.test.ts | 13 +- .../boundary/mcp/binaryDiagnosticsMcp.test.ts | 38 ++-- tests/boundary/mcp/evmInterfaceMcp.test.ts | 36 ++-- tests/boundary/mcp/wasmArtifactMcp.test.ts | 192 +++++++++--------- tests/fixtures/advertisedMcpTool.ts | 52 +++++ 8 files changed, 261 insertions(+), 151 deletions(-) create mode 100644 tests/fixtures/advertisedMcpTool.ts diff --git a/docs/wasm-artifacts.md b/docs/wasm-artifacts.md index 364096251..17b6c77f9 100644 --- a/docs/wasm-artifacts.md +++ b/docs/wasm-artifacts.md @@ -13,6 +13,11 @@ Check `binary_session` for configured capability availability. A recognized header is not validation of the complete module; missing WABT is reported separately as an unavailable capability, not as missing native engine support. +The recognized WASM header is checked before automatic database routing from a +.hop filename. Renaming a module to .hop therefore keeps the static-inspection +diagnostic. An explicit database selection or raw/COM format selection retains +the caller's requested interpretation; ordinary .hop database routing is unchanged. + Set `REA_WABT_BIN_DIRECTORY` to an absolute directory containing executable `wasm-validate`, `wasm-objdump` and `wasm2wat` (with `.exe` on Windows). Missing executables, unsupported banners and inaccessible tools produce actionable diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 6f1bc4366..b07364adf 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -162,6 +162,12 @@ export const parseBinaryTarget = async ( availableArchitectures: ["x86"], }); } + // Probe WASM content before suffix-only database routing; an explicit + // database selection keeps the caller's chosen interpretation. + const artifactFormat = + targetKind === "database" + ? undefined + : await detectArtifactFormat(path, handle, signal); if ( targetKind === "database" || (targetKind === undefined && path.toLowerCase().endsWith(".hop")) @@ -173,7 +179,6 @@ export const parseBinaryTarget = async ( kind: "database", format: "analysis-database", }); - const artifactFormat = await detectArtifactFormat(path, handle, signal); if (artifactFormat !== undefined) { const identity = { path, diff --git a/tests/boundary/filesystem/targetDiagnostics.test.ts b/tests/boundary/filesystem/targetDiagnostics.test.ts index dacd21687..998657082 100644 --- a/tests/boundary/filesystem/targetDiagnostics.test.ts +++ b/tests/boundary/filesystem/targetDiagnostics.test.ts @@ -1,4 +1,4 @@ -import { chmod, realpath, writeFile } from "node:fs/promises"; +import { chmod, readFile, realpath, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { systemDoctorHost } from "../../../src/application/Doctor.js"; @@ -78,7 +78,7 @@ describe("executable format refusal diagnostics", () => { }); describe("target route diagnostics", () => { - it.each(["module.wasm", "module.bin", "misleading.js"])( + it.each(["module.wasm", "module.bin", "misleading.js", "misleading.hop"])( "identifies a WASM v1 header in %s without suggesting native provider installation", async (name) => { const directory = await createTestTempDirectory("rea-wasm-target-"); @@ -346,3 +346,68 @@ describe("raw-binary load rejection diagnostics", () => { }); }); }); + +describe("caller-selected and inferred target interpretation", () => { + it.each(["archive.hop", "archive.HOP"])( + "preserves inferred database routing for ZIP content named %s", + async (name) => { + const directory = await createTestTempDirectory("rea-zip-database-"); + const bytes = Buffer.alloc(22); + bytes.writeUInt32LE(0x06054b50, 0); + const zipPath = join(directory, "archive.zip"); + const databasePath = join(directory, name); + await writeFile(zipPath, bytes); + await writeFile(databasePath, bytes); + expect(await parseBinaryTarget(zipPath)).toMatchObject({ + ok: true, + value: { kind: "archive", format: "zip" }, + }); + expect(await parseBinaryTarget(databasePath)).toMatchObject({ + ok: true, + value: { kind: "database", format: "analysis-database" }, + }); + expect(await readFile(databasePath)).toEqual(bytes); + }, + ); + + it("preserves explicit DOS COM interpretation for bytes with a WASM header", async () => { + const directory = await createTestTempDirectory("rea-wasm-explicit-com-"); + const path = join(directory, "module.hop"); + await writeFile(path, Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0])); + expect( + await parseBinaryTarget(path, { formatHint: "dos-com" }), + ).toMatchObject({ + ok: true, + value: { kind: "executable", format: "dos-com", architecture: "x86" }, + }); + }); + it.each(["module.wasm", "misleading.hop"])( + "preserves explicitly selected database routing for %s", + async (name) => { + const directory = await createTestTempDirectory( + "rea-wasm-explicit-database-", + ); + const path = join(directory, name); + await writeFile(path, Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0])); + expect( + await parseBinaryTarget(path, { targetKind: "database" }), + ).toMatchObject({ + ok: true, + value: { kind: "database", format: "analysis-database" }, + }); + }, + ); + + it.each(["ordinary.hop", "ordinary.HOP"])( + "retains automatic database routing for non-WASM %s", + async (name) => { + const directory = await createTestTempDirectory("rea-ordinary-database-"); + const path = join(directory, name); + await writeFile(path, "existing database content"); + expect(await parseBinaryTarget(path)).toMatchObject({ + ok: true, + value: { kind: "database", format: "analysis-database" }, + }); + }, + ); +}); diff --git a/tests/boundary/mcp/analysisViewMcp.test.ts b/tests/boundary/mcp/analysisViewMcp.test.ts index b95aebe57..8fa3f0fb7 100644 --- a/tests/boundary/mcp/analysisViewMcp.test.ts +++ b/tests/boundary/mcp/analysisViewMcp.test.ts @@ -1,3 +1,4 @@ +import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; import { API_SURFACE_EXAMPLE } from "../../../src/contracts/analysisViewApiSurfaceExample.js"; import { createHash } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; @@ -212,16 +213,10 @@ it("advertises exact schemas and projects one layout section from retained Evide inspect: () => Promise.resolve(ok(layout)), }); const { client, session } = await connect(service); - const advertised = (await client.listTools()).tools.find( - (tool) => tool.name === "inspect_analysis_view", + const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas( + client, + "inspect_analysis_view", ); - if (advertised?.outputSchema === undefined) - throw new Error("inspect_analysis_view must publish both schemas"); - const ajv = new Ajv2020({ strict: false, validateFormats: false }); - const inputSchema: Record = advertised.inputSchema; - const outputSchema: Record = advertised.outputSchema; - expect(ajv.validateSchema(inputSchema)).toBe(true); - expect(ajv.validateSchema(outputSchema)).toBe(true); expect( ajv.validate(inputSchema, { source: { kind: "inline", evidence: analysisViewLayoutEvidence() }, diff --git a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts index bea7c89dd..51bda12c7 100644 --- a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts +++ b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts @@ -1,9 +1,11 @@ +import { + advertisedMcpToolSchemas, + assertRecordedMcpEvidence, +} from "../../fixtures/advertisedMcpTool.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import { Ajv2020 } from "ajv/dist/2020.js"; import { expect, it, onTestFinished } from "vitest"; import { BinaryLayoutService } from "../../../src/application/binaryDiagnostics/BinaryLayoutService.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; -import { parseEvidence } from "../../../src/domain/evidence.js"; import { ok } from "../../../src/domain/result.js"; import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; @@ -43,16 +45,11 @@ it("advertises exact valid schemas and records inline layout without a binary ta InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); await client.connect(clientTransport); - const advertised = (await client.listTools()).tools.find( - (tool) => tool.name === "inspect_binary_layout", + const schemas = await advertisedMcpToolSchemas( + client, + "inspect_binary_layout", ); - if (advertised?.outputSchema === undefined) - throw new Error("layout must publish both schemas"); - const ajv = new Ajv2020({ strict: false, validateFormats: false }); - const inputSchema: Record = advertised.inputSchema; - const outputSchema: Record = advertised.outputSchema; - expect(ajv.validateSchema(inputSchema)).toBe(true); - expect(ajv.validateSchema(outputSchema)).toBe(true); + const { ajv, inputSchema } = schemas; expect(ajv.validate(inputSchema, { path: value.artifact.path })).toBe(true); expect( ajv.validate(inputSchema, { @@ -64,19 +61,14 @@ it("advertises exact valid schemas and records inline layout without a binary ta name: "inspect_binary_layout", arguments: { path: value.artifact.path }, }); - expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); - const parsed = toolContract("inspect_binary_layout").outputSchema.parse( - response.structuredContent, + const evidence = assertRecordedMcpEvidence( + response, + "inspect_binary_layout", + schemas, + session, + value, + "observed", ); - const evidence = parseEvidence(parsed); - expect(evidence.confidence).toBe("observed"); - expect(evidence.subject?.digest.sha256).toBe(value.artifact.sha256); - expect(parsed.normalized_result).toEqual(value); - expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence); const viewed = await client.callTool({ name: "inspect_analysis_view", arguments: { diff --git a/tests/boundary/mcp/evmInterfaceMcp.test.ts b/tests/boundary/mcp/evmInterfaceMcp.test.ts index 60f493b2c..2bfed5c81 100644 --- a/tests/boundary/mcp/evmInterfaceMcp.test.ts +++ b/tests/boundary/mcp/evmInterfaceMcp.test.ts @@ -1,3 +1,7 @@ +import { + advertisedMcpToolSchemas, + assertRecordedMcpEvidence, +} from "../../fixtures/advertisedMcpTool.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { createHash } from "node:crypto"; import { Ajv2020 } from "ajv/dist/2020.js"; @@ -66,16 +70,11 @@ it("advertises exact valid schemas and records inline EVM interface evidence wit InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); await client.connect(clientTransport); - const advertised = (await client.listTools()).tools.find( - (tool) => tool.name === "inspect_evm_interface", + const schemas = await advertisedMcpToolSchemas( + client, + "inspect_evm_interface", ); - if (advertised?.outputSchema === undefined) - throw new Error("EVM interface inspection must publish both schemas"); - const ajv = new Ajv2020({ strict: false, validateFormats: false }); - const inputSchema: Record = advertised.inputSchema; - const outputSchema: Record = advertised.outputSchema; - expect(ajv.validateSchema(inputSchema)).toBe(true); - expect(ajv.validateSchema(outputSchema)).toBe(true); + const { ajv, inputSchema } = schemas; expect( ajv.validate(inputSchema, { path: value.artifact.path, encoding: "hex" }), ).toBe(true); @@ -99,19 +98,14 @@ it("advertises exact valid schemas and records inline EVM interface evidence wit name: "inspect_evm_interface", arguments: { path: value.artifact.path, encoding: "hex" }, }); - expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); - const parsed = toolContract("inspect_evm_interface").outputSchema.parse( - response.structuredContent, + assertRecordedMcpEvidence( + response, + "inspect_evm_interface", + schemas, + session, + value, + "inferred", ); - const evidence = parseEvidence(parsed); - expect(evidence.confidence).toBe("inferred"); - expect(evidence.subject?.digest.sha256).toBe(value.artifact.sha256); - expect(parsed.normalized_result).toEqual(value); - expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence); expect( ( await client.callTool({ diff --git a/tests/boundary/mcp/wasmArtifactMcp.test.ts b/tests/boundary/mcp/wasmArtifactMcp.test.ts index 003a37cba..04c5e59da 100644 --- a/tests/boundary/mcp/wasmArtifactMcp.test.ts +++ b/tests/boundary/mcp/wasmArtifactMcp.test.ts @@ -1,5 +1,9 @@ -import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { Ajv2020 } from "ajv/dist/2020.js"; +import { + advertisedMcpToolSchemas, + assertRecordedMcpEvidence, +} from "../../fixtures/advertisedMcpTool.js"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { expect, it, onTestFinished, vi } from "vitest"; import { readFile, realpath, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -20,20 +24,6 @@ import { wasmArtifactExecution, } from "../../fixtures/wasm/artifact.js"; -const wasmToolSchemas = async (client: Client) => { - const advertised = (await client.listTools()).tools.find( - (tool) => tool.name === "inspect_wasm_artifact", - ); - if (advertised?.outputSchema === undefined) - throw new Error("WASM recovery must name an advertised static tool"); - const ajv = new Ajv2020({ strict: false, validateFormats: false }); - const inputSchema: Record = advertised.inputSchema; - const outputSchema: Record = advertised.outputSchema; - expect(ajv.validateSchema(inputSchema)).toBe(true); - expect(ajv.validateSchema(outputSchema)).toBe(true); - return { name: advertised.name, ajv, inputSchema, outputSchema }; -}; - const assertFirstSummaryDelivery = async ( harness: Awaited>, value: ReturnType, @@ -43,7 +33,7 @@ const assertFirstSummaryDelivery = async ( ajv: wasmAjv, inputSchema, outputSchema, - } = await wasmToolSchemas(harness.client); + } = await advertisedMcpToolSchemas(harness.client, "inspect_wasm_artifact"); const summaryInput = { path: value.artifact.path, detail: "summary" }; expect(wasmAjv.validate(inputSchema, summaryInput)).toBe(true); expect( @@ -91,73 +81,86 @@ const assertFirstSummaryDelivery = async ( return { summary, retained }; }; -it("directs rejected native WASM opening to the advertised static tool while preserving missing-WABT diagnostics", async () => { - const directory = await createTestTempDirectory("rea-wasm-recovery-"); - const path = join( - await realpath(directory), - process.platform === "win32" ? "module space.bin" : 'module "quoted".bin', - ); - await writeFile(path, Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0])); - const createClient = vi.fn(() => { - throw new Error("native provider must not start for WASM"); - }); - const session = createTestBinarySession(createClient); - const harness = await createApplicationMcpHarness({ - session, - serverOptions: { environment: {} }, - }); - onTestFinished(harness.close); - const { client } = harness; - const { name, ajv, inputSchema } = await wasmToolSchemas(client); - const input = { path }; - expect( - toolContract("inspect_wasm_artifact").inputSchema.safeParse(input).success, - ).toBe(true); - const opening = await client.callTool({ - name: "open_binary", - arguments: input, - }); - expect(opening.isError).toBe(true); - const failure = parseMcpToolError(opening); - expect(failure).toMatchObject({ - error: { - code: "target_unavailable", - category: "unsupported_target", - details: { constraint: "wasm_requires_static_inspection" }, - }, - }); - expect(failure.error.remediation.action).toContain(JSON.stringify({ path })); - expect(ajv.validate(inputSchema, input)).toBe(true); - const inspection = await client.callTool({ - name, - arguments: input, - }); - expect(inspection.isError).toBe(true); - expect(parseMcpToolError(inspection)).toMatchObject({ - error: { - code: "capability_unavailable", - details: { - operation: "inspect_wasm_artifact", - reason: expect.stringContaining("REA_WABT_BIN_DIRECTORY"), +it.each(["bin", "hop"])( + "directs rejected native WASM opening (%s suffix) to the advertised static tool while preserving missing-WABT diagnostics", + async (extension) => { + const directory = await createTestTempDirectory("rea-wasm-recovery-"); + const path = join( + await realpath(directory), + process.platform === "win32" + ? `module space.${extension}` + : `module "quoted".${extension}`, + ); + await writeFile(path, Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0])); + const createClient = vi.fn(() => { + throw new Error("native provider must not start for WASM"); + }); + const session = createTestBinarySession(createClient); + const harness = await createApplicationMcpHarness({ + session, + serverOptions: { environment: {} }, + }); + onTestFinished(harness.close); + const { client } = harness; + const { name, ajv, inputSchema } = await advertisedMcpToolSchemas( + client, + "inspect_wasm_artifact", + ); + const input = { path }; + const opening = await client.callTool({ + name: "open_binary", + arguments: input, + }); + expect(opening.isError).toBe(true); + const failure = parseMcpToolError(opening); + expect(failure).toMatchObject({ + error: { + code: "target_unavailable", + category: "unsupported_target", + details: { constraint: "wasm_requires_static_inspection" }, }, - }, - }); - const summary = await client.callTool({ - name, - arguments: { ...input, detail: "summary" }, - }); - expect(parseMcpToolError(summary)).toMatchObject({ - error: { - code: "capability_unavailable", - details: { reason: expect.stringContaining("REA_WABT_BIN_DIRECTORY") }, - }, - }); - await client.ping(); - expect(createClient).not.toHaveBeenCalled(); - expect(await readFile(path)).toEqual( - Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]), - ); -}); + }); + const [, recoveryName, serializedInput] = + failure.error.remediation.action.match( + /call (\w+) with (\{.*\}) or run/, + ) ?? []; + expect(recoveryName).toBe(name); + const recoveryInput = z + .object({ path: z.literal(path) }) + .strict() + .parse(JSON.parse(serializedInput ?? "null")); + expect(ajv.validate(inputSchema, recoveryInput)).toBe(true); + const inspection = await client.callTool({ + name: recoveryName ?? "", + arguments: recoveryInput, + }); + expect(inspection.isError).toBe(true); + expect(parseMcpToolError(inspection)).toMatchObject({ + error: { + code: "capability_unavailable", + details: { + operation: "inspect_wasm_artifact", + reason: expect.stringContaining("REA_WABT_BIN_DIRECTORY"), + }, + }, + }); + const summary = await client.callTool({ + name, + arguments: { ...input, detail: "summary" }, + }); + expect(parseMcpToolError(summary)).toMatchObject({ + error: { + code: "capability_unavailable", + details: { reason: expect.stringContaining("REA_WABT_BIN_DIRECTORY") }, + }, + }); + await client.ping(); + expect(createClient).not.toHaveBeenCalled(); + expect(await readFile(path)).toEqual( + Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]), + ); + }, +); it("advertises exact valid schemas and records inline WASM interface evidence without a binary target", async () => { const value = wasmArtifactFixture(); @@ -175,7 +178,11 @@ it("advertises exact valid schemas and records inline WASM interface evidence wi }); onTestFinished(harness.close); const { client } = harness; - const { ajv, inputSchema, outputSchema } = await wasmToolSchemas(client); + const schemas = await advertisedMcpToolSchemas( + client, + "inspect_wasm_artifact", + ); + const { ajv, inputSchema } = schemas; expect(ajv.validate(inputSchema, { path: value.artifact.path })).toBe(true); expect( ajv.validate(inputSchema, { @@ -188,19 +195,14 @@ it("advertises exact valid schemas and records inline WASM interface evidence wi name: "inspect_wasm_artifact", arguments: { path: value.artifact.path }, }); - expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); - const parsed = toolContract("inspect_wasm_artifact").outputSchema.parse( - response.structuredContent, + assertRecordedMcpEvidence( + response, + "inspect_wasm_artifact", + schemas, + session, + value, + "observed", ); - const evidence = parseEvidence(parsed); - expect(evidence.confidence).toBe("observed"); - expect(evidence.subject?.digest.sha256).toBe(value.artifact.sha256); - expect(parsed.normalized_result).toEqual(value); - expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence); expect( ( await client.callTool({ diff --git a/tests/fixtures/advertisedMcpTool.ts b/tests/fixtures/advertisedMcpTool.ts new file mode 100644 index 000000000..854d272ba --- /dev/null +++ b/tests/fixtures/advertisedMcpTool.ts @@ -0,0 +1,52 @@ +import type { Client } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect } from "vitest"; +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import type { BinarySession } from "../../src/application/binary/BinarySession.js"; + +/** Verify the actual advertised schemas, retaining a private Ajv registry. */ +export const advertisedMcpToolSchemas = async ( + client: Client, + name: string, +) => { + const advertised = (await client.listTools()).tools.find( + (tool) => tool.name === name, + ); + if (advertised?.outputSchema === undefined) + throw new Error(`${name} must publish both schemas`); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const inputSchema: Record = advertised.inputSchema; + const outputSchema: Record = advertised.outputSchema; + expect(ajv.validateSchema(inputSchema)).toBe(true); + expect(ajv.validateSchema(outputSchema)).toBe(true); + return { name: advertised.name, ajv, inputSchema, outputSchema }; +}; + +/** Keep public output, provenance and session retention checks together. */ +export const assertRecordedMcpEvidence = ( + response: Awaited>, + name: + | "inspect_wasm_artifact" + | "inspect_binary_layout" + | "inspect_evm_interface", + schemas: Awaited>, + session: BinarySession, + expected: { readonly artifact: { readonly sha256: string } }, + confidence: ReturnType["confidence"], +) => { + expect(response.isError).not.toBe(true); + expect( + schemas.ajv.validate(schemas.outputSchema, response.structuredContent), + JSON.stringify(schemas.ajv.errors), + ).toBe(true); + const parsed = toolContract(name).outputSchema.parse( + response.structuredContent, + ); + const evidence = parseEvidence(parsed); + expect(evidence.confidence).toBe(confidence); + expect(evidence.subject?.digest.sha256).toBe(expected.artifact.sha256); + expect(parsed.normalized_result).toEqual(expected); + expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence); + return evidence; +}; From 77e71d55ec5b3b44e3db366618b3f51ffd41c736 Mon Sep 17 00:00:00 2001 From: TonyWu <75903587+TonyWu2333@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:49:37 +0800 Subject: [PATCH 32/36] fix(native): name numeric Mach-O build-version platforms and tools (#2065) Squash merge of https://github.com/morluto/rea/pull/2065. Co-authored-by: TonyWu <2038838159@qq.com> --- src/native/parsers/otool.ts | 57 +++++++- .../native/nativeLoadKinds.golden.test.ts | 131 ++++++++++++++++++ 2 files changed, 186 insertions(+), 2 deletions(-) diff --git a/src/native/parsers/otool.ts b/src/native/parsers/otool.ts index 3e1c3a90f..8f1ed9dda 100644 --- a/src/native/parsers/otool.ts +++ b/src/native/parsers/otool.ts @@ -63,6 +63,56 @@ const DYLIB_FIELDS = [ const DYLIB_ENCODING_FIELDS = ["time stamp", "options"]; const EMPTY_VALUE_FIELDS = new Set(["options", "segname", "sectname"]); +/** + * Non-verbose otool prints LC_BUILD_VERSION platform and tool codes as + * numbers. Name the codes declared in the SDK's , without + * the PLATFORM_/TOOL_ prefix, so build metadata uses the same symbolic style + * as LC_VERSION_MIN_* platforms. Unknown codes keep their printed value. + */ +const BUILD_PLATFORM_NAMES: ReadonlyMap = new Map([ + ["1", "MACOS"], + ["2", "IOS"], + ["3", "TVOS"], + ["4", "WATCHOS"], + ["5", "BRIDGEOS"], + ["6", "MACCATALYST"], + ["7", "IOSSIMULATOR"], + ["8", "TVOSSIMULATOR"], + ["9", "WATCHOSSIMULATOR"], + ["10", "DRIVERKIT"], + ["11", "VISIONOS"], + ["12", "VISIONOSSIMULATOR"], + ["13", "FIRMWARE"], + ["14", "SEPOS"], + ["15", "MACOS_EXCLAVECORE"], + ["16", "MACOS_EXCLAVEKIT"], + ["17", "IOS_EXCLAVECORE"], + ["18", "IOS_EXCLAVEKIT"], + ["19", "TVOS_EXCLAVECORE"], + ["20", "TVOS_EXCLAVEKIT"], + ["21", "WATCHOS_EXCLAVECORE"], + ["22", "WATCHOS_EXCLAVEKIT"], + ["23", "VISIONOS_EXCLAVECORE"], + ["24", "VISIONOS_EXCLAVEKIT"], +]); +const BUILD_TOOL_NAMES: ReadonlyMap = new Map([ + ["1", "CLANG"], + ["2", "SWIFT"], + ["3", "LD"], + ["4", "LLD"], + ["1024", "METAL"], + ["1025", "AIRLLD"], + ["1026", "AIRNT"], + ["1027", "AIRNT_PLUGIN"], + ["1028", "AIRPACK"], + ["1031", "GPUARCHIVER"], + ["1032", "METAL_FRAMEWORK"], +]); +const buildCodeName = ( + names: ReadonlyMap, + value: string | null, +): string | null => (value === null ? null : (names.get(value) ?? value)); + /** * otool emits LF, but a capture replayed from another host or toolchain can * carry CRLF. The section/command boundaries below match a bare `\n`, so a @@ -295,7 +345,10 @@ const collectBuild = ( if (numberField(fields, "ntools") !== tools.length) throw new TypeError("otool build-tool count does not match ntools"); state.builds.push({ - platform: stringField(fields, "platform"), + platform: buildCodeName( + BUILD_PLATFORM_NAMES, + stringField(fields, "platform"), + ), minimum_os: stringField(fields, "minos"), sdk: stringField(fields, "sdk"), tools, @@ -434,7 +487,7 @@ const parseBuildTools = ( const tool = /^tool\s+(.+)$/u.exec(lines[index] ?? "")?.[1]; const version = /^version\s+(.+)$/u.exec(lines[index + 1] ?? "")?.[1]; if (tool !== undefined && version !== undefined) - tools.push({ name: tool, version }); + tools.push({ name: BUILD_TOOL_NAMES.get(tool) ?? tool, version }); } return tools; }; diff --git a/tests/boundary/providers/native/nativeLoadKinds.golden.test.ts b/tests/boundary/providers/native/nativeLoadKinds.golden.test.ts index 87912a31d..dd83a1b3c 100644 --- a/tests/boundary/providers/native/nativeLoadKinds.golden.test.ts +++ b/tests/boundary/providers/native/nativeLoadKinds.golden.test.ts @@ -229,6 +229,62 @@ it("normalizes legacy version-minimum commands into build metadata", () => { ]); }); +it("names numeric build-version platforms and tools", () => { + const load = parseOtoolLoadCommands( + withOtoolMachHeader( + [ + "Load command 0", + " cmd LC_BUILD_VERSION", + " cmdsize 48", + " platform 1", + " minos 11.0", + " sdk 26.5", + " ntools 2", + " tool 3", + " version 1267.0", + " tool 1029", + " version 1.0", + "Load command 1", + " cmd LC_BUILD_VERSION", + " cmdsize 24", + " platform 99", + " minos 1.0", + " sdk 1.0", + " ntools 0", + "Load command 2", + " cmd LC_BUILD_VERSION", + " cmdsize 32", + " platform MACCATALYST", + " minos 14.0", + " sdk 14.2", + " ntools 1", + " tool SWIFT", + " version 5.9", + "", + ].join("\n"), + ), + ); + expect(load.builds).toEqual([ + { + platform: "MACOS", + minimum_os: "11.0", + sdk: "26.5", + tools: [ + { name: "LD", version: "1267.0" }, + { name: "1029", version: "1.0" }, + ], + }, + { platform: "99", minimum_os: "1.0", sdk: "1.0", tools: [] }, + { + platform: "MACCATALYST", + minimum_os: "14.0", + sdk: "14.2", + tools: [{ name: "SWIFT", version: "5.9" }], + }, + ]); + expect(load.commands[0]?.fields.platform).toBe(1); +}); + it("does not classify LC_LOAD_DYLINKER as a dylib dependency", async () => { const load = parseOtoolLoadCommands( await nativeFixture("native-macho-header/otool-header-load.txt"), @@ -273,3 +329,78 @@ it("limits thread-state entrypoints instead of reporting none silently", async ( "Thread-state entrypoints (LC_UNIXTHREAD/LC_THREAD) are retained as raw load commands; file offsets are not derived.", ); }); + +it.each([ + ["1", "MACOS"], + ["2", "IOS"], + ["3", "TVOS"], + ["4", "WATCHOS"], + ["5", "BRIDGEOS"], + ["6", "MACCATALYST"], + ["7", "IOSSIMULATOR"], + ["8", "TVOSSIMULATOR"], + ["9", "WATCHOSSIMULATOR"], + ["10", "DRIVERKIT"], + ["11", "VISIONOS"], + ["12", "VISIONOSSIMULATOR"], + ["13", "FIRMWARE"], + ["14", "SEPOS"], + ["15", "MACOS_EXCLAVECORE"], + ["16", "MACOS_EXCLAVEKIT"], + ["17", "IOS_EXCLAVECORE"], + ["18", "IOS_EXCLAVEKIT"], + ["19", "TVOS_EXCLAVECORE"], + ["20", "TVOS_EXCLAVEKIT"], + ["21", "WATCHOS_EXCLAVECORE"], + ["22", "WATCHOS_EXCLAVEKIT"], + ["23", "VISIONOS_EXCLAVECORE"], + ["24", "VISIONOS_EXCLAVEKIT"], +] as const)("names build platform %s as %s", (code, name) => { + const load = parseOtoolLoadCommands( + withOtoolMachHeader( + [ + "Load command 0", + "cmd LC_BUILD_VERSION", + "cmdsize 24", + `platform ${code}`, + "minos 1.0", + "sdk 1.0", + "ntools 0", + "", + ].join("\n"), + ), + ); + expect(load.builds[0]?.platform).toBe(name); +}); + +it.each([ + ["1", "CLANG"], + ["2", "SWIFT"], + ["3", "LD"], + ["4", "LLD"], + ["1024", "METAL"], + ["1025", "AIRLLD"], + ["1026", "AIRNT"], + ["1027", "AIRNT_PLUGIN"], + ["1028", "AIRPACK"], + ["1031", "GPUARCHIVER"], + ["1032", "METAL_FRAMEWORK"], +] as const)("names build tool %s as %s", (code, name) => { + const load = parseOtoolLoadCommands( + withOtoolMachHeader( + [ + "Load command 0", + "cmd LC_BUILD_VERSION", + "cmdsize 32", + "platform 1", + "minos 1.0", + "sdk 1.0", + "ntools 1", + `tool ${code}`, + "version 1.0", + "", + ].join("\n"), + ), + ); + expect(load.builds[0]?.tools).toEqual([{ name, version: "1.0" }]); +}); From d86d29c41108cc5d784e324c6cbe50beac70a543 Mon Sep 17 00:00:00 2001 From: akushonkamen <74654766+akushonkamen@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:49:53 +0800 Subject: [PATCH 33/36] fix(mcp): keep advertised inputs within client reference budgets (#1749) Squash merge of https://github.com/morluto/rea/pull/1749. Co-authored-by: akushonkamen Co-authored-by: GLM --- docs/mcp-contracts.md | 13 ++- src/contracts/inputSchemaPresentation.ts | 74 ++++++++++++ src/contracts/toolInputReferences.test.ts | 106 +++++++++++++++++- src/contracts/toolSchemaMetadata.ts | 26 ++++- tests/boundary/mcp/toolSchemaValidity.test.ts | 48 ++++++++ 5 files changed, 259 insertions(+), 8 deletions(-) diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index e18e2b343..876a30f6d 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -604,8 +604,12 @@ failures for such entries name this cause and the operation to rerun with Advertised input and output schemas contain no reachable recursive references. Schemas share repeated definitions through schema-local references while -preserving complete nested fields and validation rules. Input properties retain -their descriptions and literal examples. +preserving complete nested fields and validation rules. Input schemas whose +shared definitions would exhaust a client's declaration-rendering reference +budget (Pi 1.1.0 code-mode shares 32 reference expansions across an input and +renders every later reference as `unknown`) are re-projected without shared +definitions, so every advertised field renders with complete structure. +Input properties retain their descriptions and literal examples. When a host forwards input schemas to the model, their examples consume context. REA's advertised examples therefore never copy complete producer Evidence. @@ -652,7 +656,7 @@ JSON Schema: objects or arrays. - The few schemas whose validation structure alone exceeds the budget (`compare_web_captures`, `build_reconstruction_obligation_ledger`, - `evaluate_reconstruction_coverage`, `project_managed_application_graph`, + `evaluate_reconstruction_coverage`, `compare_application_versions`, `capture_browser_scenario`) are advertised in a reduced form: property names, reference-resolved types (including union alternatives), first-level guidance, and the root's required-field constraints, @@ -662,8 +666,7 @@ JSON Schema: instead of silent acceptance. Boundary tests pin every advertised compact schema to the budget, keep them -valid JSON Schema that still accepts every canonical example, and hold the -set of six reduced presentations as contracts evolve. Client verification +valid JSON Schema that still accepts every canonical example. Client verification also checks provider-facing sizes and canonical examples after conversion by the actual Kimi Code CLI; local captures do not establish Moonshot acceptance. diff --git a/src/contracts/inputSchemaPresentation.ts b/src/contracts/inputSchemaPresentation.ts index 9663dd832..22ad0ae30 100644 --- a/src/contracts/inputSchemaPresentation.ts +++ b/src/contracts/inputSchemaPresentation.ts @@ -30,6 +30,7 @@ const resolveReference = ( root: Readonly>, reference: string, ): unknown => { + if (reference === "#") return root; if (!reference.startsWith("#/")) return undefined; let value: unknown = root; for (const token of reference.slice(2).split("/")) { @@ -229,3 +230,76 @@ export const presentInputJsonSchema = ( const described = describeSchema(flattened, flattened, describeProperty); return isObject(described) ? described : flattened; }; + +/** + * Clients that render advertised inputs into SDK declarations expand + * schema-local references with a shared per-input budget: Pi 1.1.0 code-mode + * allows 32 expansions and degrades every later reference to `unknown` + * (morluto/rea#1632), hiding the fields those references describe. The budget + * counts conservatively — every resolvable, non-cyclic reference occurrence + * in every schema position — so any reference-resolving client stays within + * its measured limit. + */ +export const advertisedReferenceExpansionBudget = 32; + +const definitionKeys = new Set(["$defs", "definitions"]); + +const countReferenceExpansions = ( + node: unknown, + root: Readonly>, + active: ReadonlySet, + count: { value: number }, +): void => { + if (Array.isArray(node)) { + node.forEach((child) => + countReferenceExpansions(child, root, active, count), + ); + return; + } + if (!isObject(node)) return; + if (typeof node.$ref === "string" && !active.has(node.$ref)) { + const target = resolveReference(root, node.$ref); + if (target !== undefined) { + count.value += 1; + countReferenceExpansions( + target, + root, + new Set([...active, node.$ref]), + count, + ); + return; + } + } + for (const [key, value] of Object.entries(node)) { + if (definitionKeys.has(key)) continue; + if (schemaMaps.has(key) && isObject(value)) { + for (const child of Object.values(value)) + countReferenceExpansions(child, root, active, count); + } else if (schemaArrays.has(key) || schemaChildren.has(key)) { + countReferenceExpansions(value, root, active, count); + } + } +}; + +/** Conservative client expansion count over every schema position. */ +export const countLocalReferenceExpansions = ( + schema: Record, +): number => { + const count = { value: 0 }; + countReferenceExpansions(schema, schema, new Set(), count); + return count.value; +}; + +/** + * Keep the shared-definition form of an advertised input while clients can + * still resolve it. Once rendering would exhaust the reference budget, fall + * back to the inline projection, which carries no shared definitions for the + * nonrecursive schemas this repository advertises, so every field renders + * with complete structure in every reference-resolving client. + */ +export const inputSchemaWithinReferenceExpansionBudget = ( + shared: Record, + inline: () => Record, + budget: number, +): Record => + countLocalReferenceExpansions(shared) <= budget ? shared : inline(); diff --git a/src/contracts/toolInputReferences.test.ts b/src/contracts/toolInputReferences.test.ts index beb63b7e7..f75a06531 100644 --- a/src/contracts/toolInputReferences.test.ts +++ b/src/contracts/toolInputReferences.test.ts @@ -3,7 +3,12 @@ import { Ajv2020 } from "ajv/dist/2020.js"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { presentInputJsonSchema } from "./inputSchemaPresentation.js"; +import { + advertisedReferenceExpansionBudget, + countLocalReferenceExpansions, + inputSchemaWithinReferenceExpansionBudget, + presentInputJsonSchema, +} from "./inputSchemaPresentation.js"; import { toolContract } from "./toolContracts.js"; import { toolInputSchemaWithMetadata } from "./toolSchemaMetadata.js"; import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "./javascript/javascriptRuntimeReconciliationExample.js"; @@ -166,6 +171,105 @@ describe("advertised input references", () => { ); }); +describe("advertised reference expansion budget", () => { + it("counts references in keyword-named fields, excluding literals", () => { + const fields = ["default", "enum", "examples", "const", "$defs"]; + const schema = { + type: "object", + properties: Object.fromEntries( + fields.map((name) => [name, { $ref: "#/$defs/value" }]), + ), + $defs: { value: { type: "string" } }, + default: { $ref: "#/$defs/value" }, + examples: [{ $ref: "#/$defs/value" }], + }; + expect(countLocalReferenceExpansions(schema)).toBe(fields.length); + }); + const reusedComponent = z + .object({ kind: z.literal("rec"), value: z.string() }) + .strict(); + const inputWithReusedFields = (count: number) => + z.strictObject( + Object.fromEntries( + Array.from({ length: count }, (_, index) => [ + `field_${String(index).padStart(2, "0")}`, + reusedComponent, + ]), + ), + ); + const advertisedInput = ( + canonical: z.ZodType>>, + ) => + toolInputSchemaWithMetadata({ + ...toolContract("open_binary"), + inputSchema: canonical, + })["~standard"].jsonSchema.input({ target: "draft-2020-12" }); + + it("keeps shared definitions while every client can resolve them", () => { + const advertised = advertisedInput(inputWithReusedFields(8)); + expect(advertised.$defs).toBeDefined(); + expect(countLocalReferenceExpansions(advertised)).toBe(8); + }); + + it("keeps shared definitions exactly at the budget", () => { + const advertised = advertisedInput( + inputWithReusedFields(advertisedReferenceExpansionBudget), + ); + expect(advertised.$defs).toBeDefined(); + expect(countLocalReferenceExpansions(advertised)).toBe( + advertisedReferenceExpansionBudget, + ); + }); + + it("re-projects without shared definitions once the budget is exceeded", () => { + const advertised = advertisedInput( + inputWithReusedFields(advertisedReferenceExpansionBudget + 1), + ); + expect(advertised.$defs).toBeUndefined(); + expect(countLocalReferenceExpansions(advertised)).toBe(0); + const properties = advertised.properties as Record; + expect(properties.field_00).toMatchObject({ + type: "object", + properties: { kind: { const: "rec" }, value: { type: "string" } }, + required: ["kind", "value"], + }); + }); + + it("keeps the shared form untouched when it fits and swaps it when not", () => { + const shared = { + type: "object", + properties: { value: { $ref: "#/$defs/shared" } }, + $defs: { shared: { type: "string" } }, + }; + const inlined = { + type: "object", + properties: { value: { type: "string" } }, + }; + expect( + inputSchemaWithinReferenceExpansionBudget( + shared, + () => inlined, + advertisedReferenceExpansionBudget, + ), + ).toBe(shared); + expect( + inputSchemaWithinReferenceExpansionBudget( + { + ...shared, + properties: Object.fromEntries( + Array.from({ length: 33 }, (_, index) => [ + `field_${index}`, + { $ref: "#/$defs/shared" }, + ]), + ), + }, + () => inlined, + advertisedReferenceExpansionBudget, + ), + ).toBe(inlined); + }); +}); + describe("root input presentation", () => { it("preserves prototype-named properties in object-root presentation", () => { const fields = Object.fromEntries([ diff --git a/src/contracts/toolSchemaMetadata.ts b/src/contracts/toolSchemaMetadata.ts index 5ed63db5b..3034b8354 100644 --- a/src/contracts/toolSchemaMetadata.ts +++ b/src/contracts/toolSchemaMetadata.ts @@ -2,7 +2,11 @@ import { z } from "zod"; import type { ToolContract } from "./toolContractTypes.js"; import { advertisedInputExamples } from "./advertisedInputExamples.js"; -import { presentInputJsonSchema } from "./inputSchemaPresentation.js"; +import { + advertisedReferenceExpansionBudget, + inputSchemaWithinReferenceExpansionBudget, + presentInputJsonSchema, +} from "./inputSchemaPresentation.js"; import { digestCanonicalValue } from "../domain/canonicalDigest.js"; const PROPERTY_DESCRIPTIONS: Readonly> = { @@ -66,7 +70,25 @@ export const toolInputSchemaWithMetadata = ( }); const examples = advertisedInputExamples(contract); return { - ...presentInputJsonSchema(shared, fallbackPropertyDescription), + // Sharing definitions only helps clients that resolve them. A client + // rendering declarations from a shared expansion budget degrades + // every reference past it to `unknown`, so an input whose shared form + // exceeds the budget is re-projected without shared definitions. + ...inputSchemaWithinReferenceExpansionBudget( + presentInputJsonSchema(shared, fallbackPropertyDescription), + () => + presentInputJsonSchema( + project({ + ...options, + libraryOptions: { + ...options.libraryOptions, + reused: "inline", + }, + }), + fallbackPropertyDescription, + ), + advertisedReferenceExpansionBudget, + ), ...(examples.length === 0 ? {} : { examples }), }; }, diff --git a/tests/boundary/mcp/toolSchemaValidity.test.ts b/tests/boundary/mcp/toolSchemaValidity.test.ts index 5a94d27bf..e9d5e944a 100644 --- a/tests/boundary/mcp/toolSchemaValidity.test.ts +++ b/tests/boundary/mcp/toolSchemaValidity.test.ts @@ -217,6 +217,46 @@ function resolveReference(schema: unknown, reference: string): unknown { return value; } +function localReferenceExpansions(schema: Record): number { + // Clients that render SDK declarations from the advertised input expand + // schema-local references against a shared per-input budget; Pi 1.1.0 + // code-mode allows 32 and renders every later reference as `unknown`. + // Count conservatively: every resolvable, non-cyclic reference occurrence + // reached while walking the input, never the definitions themselves. + let count = 0; + const visit = (node: unknown, active: ReadonlySet): void => { + if (Array.isArray(node)) { + node.forEach((child) => visit(child, active)); + return; + } + if (!isRecord(node)) return; + if (typeof node.$ref === "string" && !active.has(node.$ref)) { + const target = resolveReference(schema, node.$ref); + if (target !== undefined) { + count += 1; + visit(target, new Set([...active, node.$ref])); + return; + } + } + for (const [key, value] of Object.entries(node)) { + if ( + [ + "$defs", + "definitions", + "examples", + "default", + "const", + "enum", + ].includes(key) + ) + continue; + visit(value, active); + } + }; + visit(schema, new Set()); + return count; +} + function recursiveReferences(tools: readonly ToolSchemas[]): string[] { return tools.flatMap((tool) => (["inputSchema", "outputSchema"] as const).flatMap((kind) => { @@ -674,6 +714,14 @@ it("ships nonrecursive input and output schemas in the generated catalog", () => expect(recursiveReferences(GENERATED_MCP_TOOL_CATALOG)).toEqual([]); }); +it("keeps advertised input references within the client expansion budget", () => { + for (const tool of GENERATED_MCP_TOOL_CATALOG) + expect( + localReferenceExpansions(tool.inputSchema), + `${tool.name} exhausts the shared reference-expansion budget`, + ).toBeLessThanOrEqual(32); +}); + it("rejects unresolved output-schema references even for uncalled tools", () => { const errors = schemaErrors([ { From af782dd5f56463fcd2a6a0af918569d70696551f Mon Sep 17 00:00:00 2001 From: Royce Date: Tue, 13 Oct 2026 23:51:28 +0800 Subject: [PATCH 34/36] fix(setup): treat empty DSH_HOME as unset (#2686) * fix(setup): treat empty DSH_HOME as unset (#2666) The released `@deepseek-ai/dsh-home-paths` package explicitly treats an empty or whitespace-only `DSH_HOME` as unset and selects the default Harness home patch. `dshHomeDirectory` previously used nullish coalescing (`env.DSH_HOME ?? join(home, ".dsh")`), so an empty override flowed straight through `join(..., "cordis.patch.yml")` and produced a cwd-relative `cordis.patch.yml` outside the client-selected home. Treat empty and whitespace-only overrides as unset, matching the sibling OpenCode override resolver and the released DSH resolver. The patch now lands at `$DSH_HOME/cordis.patch.yml` for explicit overrides, the default `~/.dsh/cordis.patch.yml` for empty/unset, and trimmed whitespace for padded overrides. Adds `src/application/SupportedClients.test.ts` covering the empty, whitespace-only, unset, padded, and explicit cases via the public `supportedClients(...)` entrypoint. Fixes #2666. * test(setup): remove superseded DSH path unit coverage --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- src/application/SupportedClients.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 55a651a52..7c3811a23 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -539,7 +539,7 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ configPath: ({ home, platform, env }: ClientPathContext) => join(devinDirectory({ home, platform, env }), "mcp_config.json"), markerPath: devinDirectory, - format: "json", + format: "devin", }, { name: "opencode", From 59298923a5fc22eb3bfe192511b7fc72aaa85321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E4=BD=B3=E6=B5=A9Alben?= <35291372+XiaoBinGan@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:51:30 +0800 Subject: [PATCH 35/36] fix(apktool): read the resource locale grammar from its owner (#2738) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash merge of https://github.com/morluto/rea/pull/2738. Original pull request submitted by @XiaoBinGan. Co-authored-by: 吴佳浩 --- src/cli/apktoolCommands.ts | 28 +++--- .../boundary/cli/apktoolLocaleOption.test.ts | 92 +++++++++++++++++++ 2 files changed, 106 insertions(+), 14 deletions(-) create mode 100644 tests/boundary/cli/apktoolLocaleOption.test.ts diff --git a/src/cli/apktoolCommands.ts b/src/cli/apktoolCommands.ts index c7795f9d4..217889f5a 100644 --- a/src/cli/apktoolCommands.ts +++ b/src/cli/apktoolCommands.ts @@ -6,7 +6,10 @@ import { createApktoolResourceAnalysisProvider } from "../composition/apktool.js import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; -import type { ApktoolOperation } from "../domain/apktool/apktoolResourceAnalysis.js"; +import { + apktoolInputSchemas, + type ApktoolOperation, +} from "../domain/apktool/apktoolResourceAnalysis.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; @@ -49,22 +52,19 @@ export const registerApktoolCommands = ( args: z.object({ path: z.string().min(1).describe("Local APK file to decode"), }), + // The locale grammar and the string-projection default belong to the + // apktool input owner. Read them from that schema rather than restating a + // narrower locale pattern, which refused the owner's own advertised + // `b+zh+Hans+CN` form at the CLI boundary. options: z.object({ - locale: z - .string() - .min(2) - .max(16) - .regex( - /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/u, - "Locale must look like de or pt-BR", - ) - .optional() - .describe( - "Project strings from res/values- instead of the default", - ), + locale: apktoolInputSchemas.decode_android_resources.shape.locale, strings: z .boolean() - .default(true) + .default( + apktoolInputSchemas.decode_android_resources.shape.include_strings.parse( + undefined, + ), + ) .describe( "Include string resources; use --no-strings to skip projecting them", ), diff --git a/tests/boundary/cli/apktoolLocaleOption.test.ts b/tests/boundary/cli/apktoolLocaleOption.test.ts new file mode 100644 index 000000000..4bc596695 --- /dev/null +++ b/tests/boundary/cli/apktoolLocaleOption.test.ts @@ -0,0 +1,92 @@ +import { expect, it } from "vitest"; +import { Cli } from "incur"; +import pino from "pino"; + +import type { ApktoolResourceAnalysisPort } from "../../../src/application/apktool/ApktoolResourceAnalysisPort.js"; +import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; +import { registerApktoolCommands } from "../../../src/cli/apktoolCommands.js"; +import { presentOmittableDefaults } from "../../../src/cli/omittableDefaults.js"; +import type { ApktoolRequest } from "../../../src/domain/apktool/apktoolResourceAnalysis.js"; +import { ok } from "../../../src/domain/result.js"; + +const invoke = async (argv: readonly string[]) => { + const requests: ApktoolRequest[] = []; + const provider: ApktoolResourceAnalysisPort = { + async inspectAvailability() { + throw new Error("unexpected provider probe"); + }, + async close() {}, + async execute(request) { + requests.push(request); + return ok( + createAnalysisExecution( + { strings: [] }, + { id: "apktool", name: "Apktool", version: null }, + ), + ); + }, + }; + const cli = Cli.create("rea", { sync: false }); + registerApktoolCommands(cli, pino({ level: "silent" }), {}, provider); + presentOmittableDefaults(cli); + + let stdout = ""; + let exitCode = 0; + await cli.serve([...argv], { + env: {}, + exit: (code) => { + exitCode = code; + }, + stdout: (text) => { + stdout += text; + }, + }); + return { exitCode, stdout, requests }; +}; + +/** + * Locales the apktool input owner admits. The owner's own rejection message + * advertises `b+zh+Hans+CN`, so the CLI cannot be narrower than this list + * without refusing a caller-selected locale the decoding lane supports. + */ +const OWNER_ADMITTED_LOCALES = [ + "de", + "pt-BR", + "pt-rBR", + "zh-rCN", + "en-419", + "b+zh+Hans+CN", +] as const; + +it("forwards every locale the apktool input owner admits", async () => { + for (const locale of OWNER_ADMITTED_LOCALES) { + const result = await invoke([ + "decode-android-resources", + "/targets/app.apk", + "--locale", + locale, + "--json", + ]); + + expect(result.exitCode, `exit code for locale ${locale}`).toBe(0); + expect(result.requests, `request for locale ${locale}`).toEqual([ + { + operation: "decode_android_resources", + input: { path: "/targets/app.apk", include_strings: true, locale }, + }, + ]); + } +}); + +it("still refuses a locale the apktool input owner refuses", async () => { + const result = await invoke([ + "decode-android-resources", + "/targets/app.apk", + "--locale", + "not a locale", + "--json", + ]); + + expect(result.exitCode).not.toBe(0); + expect(result.requests).toEqual([]); +}); From 855ed9689e972903d3c6854907dac83207a00141 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E4=BD=B3=E6=B5=A9Alben?= <35291372+XiaoBinGan@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:51:44 +0800 Subject: [PATCH 36/36] fix(application): preserve a cancellation that races a completed provider run (#2710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash merge of https://github.com/morluto/rea/pull/2710. Original pull request submitted by @XiaoBinGan. Co-authored-by: 吴佳浩 --- src/application/ProviderOperationExecution.ts | 20 ++++++++--- .../ApktoolResourceAnalysisService.test.ts | 34 +++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/application/ProviderOperationExecution.ts b/src/application/ProviderOperationExecution.ts index bf4ad4b0e..966a43f29 100644 --- a/src/application/ProviderOperationExecution.ts +++ b/src/application/ProviderOperationExecution.ts @@ -43,7 +43,7 @@ export const executeProviderOperation = async < provider: ProviderOperationPort, confidence: "observed" | "derived", ): Promise> => { - if (options?.signal?.aborted === true) + if (options?.signal?.aborted) return err(new AnalysisCancelledError(operation)); const request = schema.safeParse({ operation, input }); if (!request.success) @@ -51,8 +51,10 @@ export const executeProviderOperation = async < const executed = await provider.execute(request.data, options); if (!executed.ok) return executed; const execution = executed.value; - return ok( - createEvidence(execution.subject ?? undefined, execution.provider, { + const evidence = createEvidence( + execution.subject ?? undefined, + execution.provider, + { operation, parameters: jsonObjectSchema.parse(request.data.input), result: execution.result, @@ -60,6 +62,16 @@ export const executeProviderOperation = async < limitations: execution.limitations, locations: execution.locations, confidence, - }), + }, ); + // A provider that cannot observe the signal may finish its work after the + // caller cancelled, so re-check rather than reporting a completed operation + // the caller no longer wants. The observation it completed is still this + // provider's Evidence, so the cancellation carries it as the partial + // observation instead of discarding a finished result. + if (options?.signal?.aborted) + return err( + new AnalysisCancelledError(operation, { partialObservation: evidence }), + ); + return ok(evidence); }; diff --git a/src/application/apktool/ApktoolResourceAnalysisService.test.ts b/src/application/apktool/ApktoolResourceAnalysisService.test.ts index 6242bb54d..2220ce9e6 100644 --- a/src/application/apktool/ApktoolResourceAnalysisService.test.ts +++ b/src/application/apktool/ApktoolResourceAnalysisService.test.ts @@ -83,3 +83,37 @@ it("preserves cancellation before execution", async () => { if (result.ok) return; expect(result.error).toBeInstanceOf(AnalysisCancelledError); }); + +it("preserves a cancellation that races a completed observation", async () => { + const controller = new AbortController(); + const racingPort: ApktoolResourceAnalysisPort = { + async inspectAvailability() { + return { status: "available", code: null, reason: null, diagnostics: {} }; + }, + async close() {}, + async execute() { + // The provider may finish its work before the abort is delivered, so a + // successful result is not by itself proof the caller still wants it. + controller.abort(); + return ok(execution); + }, + }; + const result = await new ApktoolResourceAnalysisService(racingPort).execute( + "decode_android_resources", + { path: "/targets/app.apk", include_strings: true }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + // The observation the provider completed is retained on the cancellation, so + // a session still records the Evidence instead of losing finished work. + const retained = parseEvidence(result.error.partialObservation); + expect(retained.operation).toBe("decode_android_resources"); + expect(retained.provider).toEqual(identity); + expect(retained.parameters).toEqual({ + path: "/targets/app.apk", + include_strings: true, + }); + expect(retained.confidence).toBe("observed"); +});