From e13da084e7c5967b80632ae5ad8d3d37bcf57469 Mon Sep 17 00:00:00 2001 From: yasshmittal Date: Tue, 13 Oct 2026 20:25:21 +0530 Subject: [PATCH 01/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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":