From 60f771a11e9889ce69cce6ad631b6b186ac742dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Andrade?= <95101505+viniciusdsandrade@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:40:43 -0300 Subject: [PATCH 01/12] fix(apple): limit already-loaded reuse to rpath dependencies (#2292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(apple): limit already-loaded reuse to rpath dependencies dyld reuses an already-loaded image by install name only for @rpath/ dependencies (Loader.cpp:802-816) and evaluates other dependency forms against their expanded path for each loader. When multiple images in different directories depend on @loader_path/ libraries sharing an install name, checking loaded images before candidate expansion caused subsequent loaders to bind to the first loaded image's copy or appear resolved when their own copy was absent. Limit byInstallName registration and lookup to install names that start with @rpath/, leaving caller-relative and literal dependencies to expand and evaluate their own candidate paths. Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com> * test(apple): verify loader_path dependencies across isolated directories Assert that multiple images depending on @loader_path/ libraries with the same install name do not reuse already-loaded images across loaders. When the second loader's helper is absent, its candidate path is absent and dyld reports required-load-unresolved; when both helpers exist, each loader resolves to its own directory. Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com> --------- Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com> --- docs/apple-application-analysis.md | 2 +- src/domain/apple/dylibResolution.test.ts | 130 ++++++++++++++++++++ src/domain/apple/dylibResolution.ts | 10 +- src/domain/apple/dylibResolutionFindings.ts | 2 +- 4 files changed, 140 insertions(+), 4 deletions(-) diff --git a/docs/apple-application-analysis.md b/docs/apple-application-analysis.md index e00454c3e..666717fc5 100644 --- a/docs/apple-application-analysis.md +++ b/docs/apple-application-analysis.md @@ -174,7 +174,7 @@ Resolution follows dyld: - `@rpath` tries each `LC_RPATH` of the loading image first, then those of the image that loaded it, up to the process root. - Dependencies load dependents-first in load-command order, and each image - loads once per process. A later request whose install name matches an + loads once per process. A later @rpath request whose install name matches an already loaded image reuses it. - Symlinks such as `Versions/Current` are followed inside the analyzed root. An escape through `..` or an absolute link is `escapes-target`. diff --git a/src/domain/apple/dylibResolution.test.ts b/src/domain/apple/dylibResolution.test.ts index 2d63ce393..13bb7c4f6 100644 --- a/src/domain/apple/dylibResolution.test.ts +++ b/src/domain/apple/dylibResolution.test.ts @@ -597,6 +597,136 @@ describe("dyld load order", () => { }); }); +describe("loader-relative dylib resolution", () => { + it("does not reuse already loaded images by install name for @loader_path dependencies", async () => { + const pluginA = "Contents/PlugIns/A/pluginA.dylib"; + const helperA = "Contents/PlugIns/A/libhelper.dylib"; + const pluginB = "Contents/PlugIns/B/pluginB.dylib"; + const trace = await traceDylibLoading( + memoryView({ + [MAIN]: executable({ + dependencies: [ + dependency("@executable_path/../PlugIns/A/pluginA.dylib"), + dependency("@executable_path/../PlugIns/B/pluginB.dylib"), + ], + }), + [pluginA]: parsed( + slice({ + dependencies: [dependency("@loader_path/libhelper.dylib")], + }), + ), + [helperA]: parsed( + slice({ + install_name: "@loader_path/libhelper.dylib", + }), + ), + [pluginB]: parsed( + slice({ + dependencies: [dependency("@loader_path/libhelper.dylib")], + }), + ), + }), + { roots: [MAIN] }, + ); + + const edgeA = edgeFor(trace, pluginA, "@loader_path/libhelper.dylib"); + expect(edgeA.resolution).toEqual({ + status: "resolved", + image: helperA, + }); + expect(edgeA.candidates).toEqual([ + expect.objectContaining({ + source: "loader_path", + outcome: "resolved", + resolved_path: helperA, + }), + ]); + + const edgeB = edgeFor(trace, pluginB, "@loader_path/libhelper.dylib"); + expect(edgeB.resolution).toEqual({ + status: "unresolved", + image: null, + }); + expect(edgeB.candidates).toEqual([ + expect.objectContaining({ + path: "Contents/PlugIns/B/libhelper.dylib", + source: "loader_path", + outcome: "absent", + resolved_path: null, + }), + ]); + expect(trace.findings).toContainEqual( + expect.objectContaining({ + kind: "required-load-unresolved", + image: pluginB, + }), + ); + }); + + it("resolves @loader_path dependencies to the loader's own directory when multiple helpers exist", async () => { + const pluginA = "Contents/PlugIns/A/pluginA.dylib"; + const helperA = "Contents/PlugIns/A/libhelper.dylib"; + const pluginB = "Contents/PlugIns/B/pluginB.dylib"; + const helperB = "Contents/PlugIns/B/libhelper.dylib"; + const trace = await traceDylibLoading( + memoryView({ + [MAIN]: executable({ + dependencies: [ + dependency("@executable_path/../PlugIns/A/pluginA.dylib"), + dependency("@executable_path/../PlugIns/B/pluginB.dylib"), + ], + }), + [pluginA]: parsed( + slice({ + dependencies: [dependency("@loader_path/libhelper.dylib")], + }), + ), + [helperA]: parsed( + slice({ + install_name: "@loader_path/libhelper.dylib", + }), + ), + [pluginB]: parsed( + slice({ + dependencies: [dependency("@loader_path/libhelper.dylib")], + }), + ), + [helperB]: parsed( + slice({ + install_name: "@loader_path/libhelper.dylib", + }), + ), + }), + { roots: [MAIN] }, + ); + + expect( + edgeFor(trace, pluginA, "@loader_path/libhelper.dylib"), + ).toMatchObject({ + resolution: { status: "resolved", image: helperA }, + candidates: [ + expect.objectContaining({ + source: "loader_path", + outcome: "resolved", + resolved_path: helperA, + }), + ], + }); + expect( + edgeFor(trace, pluginB, "@loader_path/libhelper.dylib"), + ).toMatchObject({ + resolution: { status: "resolved", image: helperB }, + candidates: [ + expect.objectContaining({ + source: "loader_path", + outcome: "resolved", + resolved_path: helperB, + }), + ], + }); + }); +}); + describe("dyld slice compatibility and coverage", () => { it("lets an x86_64h process load generic x86_64 but not arm64e load arm64", async () => { const files = { diff --git a/src/domain/apple/dylibResolution.ts b/src/domain/apple/dylibResolution.ts index 00077efa1..799befdb1 100644 --- a/src/domain/apple/dylibResolution.ts +++ b/src/domain/apple/dylibResolution.ts @@ -427,7 +427,9 @@ const resolveDependency = async ( ): Promise => { const current = context.loaded.get(loader); const chain = current?.chain ?? [loader]; - const loaded = context.byInstallName.get(dependency.install_name); + const loaded = dependency.install_name.startsWith("@rpath/") + ? context.byInstallName.get(dependency.install_name) + : undefined; const candidates: Candidate[] = []; if (loaded !== undefined) candidates.push({ @@ -506,7 +508,10 @@ const traceProcess = async ( chain: [context.root], conditional: false, }); - if (rootSlice.install_name !== null) + if ( + rootSlice.install_name !== null && + rootSlice.install_name.startsWith("@rpath/") + ) context.byInstallName.set(rootSlice.install_name, context.root); const pending = [context.root]; while (pending.length > 0) { @@ -537,6 +542,7 @@ const traceProcess = async ( }); if ( slice.install_name !== null && + slice.install_name.startsWith("@rpath/") && !context.byInstallName.has(slice.install_name) ) context.byInstallName.set(slice.install_name, image); diff --git a/src/domain/apple/dylibResolutionFindings.ts b/src/domain/apple/dylibResolutionFindings.ts index e4cc1ddf2..ca506c17b 100644 --- a/src/domain/apple/dylibResolutionFindings.ts +++ b/src/domain/apple/dylibResolutionFindings.ts @@ -91,7 +91,7 @@ export const DYLIB_RESOLUTION_LIMITATIONS = [ "Slices are matched by dyld's graded architectures (an x86_64h process also loads x86_64). arm64e processes that disable pointer authentication can also load arm64 slices; that fallback is not modeled.", "Absolute install names and rpaths are outside the analyzed root and are not evaluated, including /System and /usr/lib libraries that the dyld shared cache usually provides.", "Leaf and relative install names depend on dyld fallback paths, DYLD_* variables, and the working directory; they are undetermined.", - "Load order follows dyld's dependents-first traversal in load-command order. An image reached through several chains is resolved once per process, with the rpath stack and conditionality of the first chain; a later request whose install name matches an already loaded image reuses it.", + "Load order follows dyld's dependents-first traversal in load-command order. An image reached through several chains is resolved once per process, with the rpath stack and conditionality of the first chain; a later @rpath request whose install name matches an already loaded image reuses it.", "@loader_path uses each image's symlink-resolved path within the analyzed root.", "Code-signing checks that can reject a found image, such as library validation and the hardened runtime, are not evaluated.", "Dylib compatibility versions are not compared. Apple's linker documentation describes a load-time rejection when an image's compatibility version is older than the loader requires, but dyld on macOS 26 was observed to load such an image.", From d73ed6fce24f6d1372c80319cd41d29d66666159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E4=BD=B3=E6=B5=A9Alben?= <35291372+XiaoBinGan@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:43:10 +0800 Subject: [PATCH 02/12] fix(domain): preserve retryable capability failures through redaction (#2494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit redactExplicitFailure rebuilds each typed failure field by field so explicit literals are removed while the projected code stays the same. The rebuild for AnalysisCapabilityUnavailableError carried providerId, operation, reason and userMessage but not retryable, and the constructor defaults that field to false. Redacting a transient capability failure therefore made it permanent and changed the remediation shown to the caller. Pass the field through with the same conditional-spread shape used for userMessage. The constructor recomputes retryable from cleanupIncomplete, and cleanup is already part of the reconstructed context, so a value that was true before redaction stays true and a false value stays absent. Verified with 66 error projection, access-denied, redaction and capture-failure tests, check:fast and verify:test-discovery on macOS arm64. The regression test failed with "expected false to be true" before the change. Co-authored-by: 吴佳浩 --- src/domain/explicitSensitiveFailure.test.ts | 18 ++++++++++++++++++ src/domain/explicitSensitiveFailure.ts | 1 + 2 files changed, 19 insertions(+) diff --git a/src/domain/explicitSensitiveFailure.test.ts b/src/domain/explicitSensitiveFailure.test.ts index 68aaf9f8f..757e9887a 100644 --- a/src/domain/explicitSensitiveFailure.test.ts +++ b/src/domain/explicitSensitiveFailure.test.ts @@ -116,6 +116,24 @@ it("keeps unsupported profile reasons distinct from malformed input", () => { expect(JSON.stringify(projectAnalysisError(result))).not.toContain("private"); }); +it("preserves a retryable capability failure through explicit-value redaction", () => { + // Retryability is a semantic field of the error, not presentation: dropping + // it turns a transient admission failure into a permanent one and changes + // the remediation the caller is shown. + const error = new AnalysisCapabilityUnavailableError( + "mitmproxy", + "inspect_web_network_capture", + "Runtime admission for /targets/private/capture failed transiently", + { retryable: true }, + ); + expect(projectAnalysisError(error).retryable).toBe(true); + const result = redactExplicitFailure(error, ["private"]); + expect(result).toBeInstanceOf(AnalysisCapabilityUnavailableError); + expect((result as AnalysisCapabilityUnavailableError).retryable).toBe(true); + expect(projectAnalysisError(result).retryable).toBe(true); + expect(JSON.stringify(projectAnalysisError(result))).not.toContain("private"); +}); + it("coarsens format issue pointers without creating replacement coordinates", () => { const error = new AnalysisInputError( "inspect_web_network_capture", diff --git a/src/domain/explicitSensitiveFailure.ts b/src/domain/explicitSensitiveFailure.ts index 05a4f2f88..8a0fac095 100644 --- a/src/domain/explicitSensitiveFailure.ts +++ b/src/domain/explicitSensitiveFailure.ts @@ -154,6 +154,7 @@ export const redactExplicitFailure = ( ...(error.userMessage === undefined ? {} : { userMessage: text(error.userMessage) }), + ...(error.retryable ? { retryable: true } : {}), }, ); if (error instanceof AnalysisResourceConstraintError) From f6556ad2bc102d80223f5b434136f546dbd2bbab Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:44:09 -0700 Subject: [PATCH 03/12] fix(pcap): order evidence ties by code point instead of host locale (#2509) The pcap summary sorted equal-count endpoints and protocol stacks with String#localeCompare, and Flutter build inspection sorted APK ABI directory names the same way. localeCompare follows the host's ICU locale, so the same capture produced differently ordered Evidence on different hosts: under da_DK "aarp" sorts after "zz", under lt_LT "syslog" sorts before "ssdp", and every locale puts "fe80::1" before "fe80:1::1". Use the repository's compareUnicodeCodePoints, as the other Evidence producers already do. Co-authored-by: Claude Opus 5.5 --- src/flutter/FlutterBuildProvider.ts | 5 +++-- src/pcap/PcapOutput.test.ts | 18 ++++++++++++++++++ src/pcap/PcapOutput.ts | 5 +++-- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/flutter/FlutterBuildProvider.ts b/src/flutter/FlutterBuildProvider.ts index 2c088b2c9..9a6e6fe50 100644 --- a/src/flutter/FlutterBuildProvider.ts +++ b/src/flutter/FlutterBuildProvider.ts @@ -23,6 +23,7 @@ import { AnalysisUnsupportedTargetError, } from "../domain/analysisErrorCore.js"; import { AnalysisProtocolError } from "../domain/analysisErrorCore.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { err, ok, type Result } from "../domain/result.js"; import { @@ -759,7 +760,7 @@ export class FlutterBuildProvider { toolchain_capped: value.libflutter.toolchainCapped, }, })) - .sort((left, right) => left.abi.localeCompare(right.abi)); + .sort((left, right) => compareUnicodeCodePoints(left.abi, right.abi)); const flutterDetected = abis.some((entry) => entry.libapp.present); const partial = abis.some( @@ -871,7 +872,7 @@ export class FlutterBuildProvider { (left, right) => (preference.indexOf(left.abi) + 1 || preference.length + 1) - (preference.indexOf(right.abi) + 1 || preference.length + 1) || - left.abi.localeCompare(right.abi), + compareUnicodeCodePoints(left.abi, right.abi), )[0]! : candidates.find((candidate) => candidate.abi === abi); if (selected === undefined) diff --git a/src/pcap/PcapOutput.test.ts b/src/pcap/PcapOutput.test.ts index 6cfa6b0d3..c0e158438 100644 --- a/src/pcap/PcapOutput.test.ts +++ b/src/pcap/PcapOutput.test.ts @@ -83,6 +83,24 @@ it("counts IPv6 endpoints once per packet and preserves every examined fact", as expect(result.partial).toBe(false); }); +it("orders equal-count endpoints and protocols by code point, not host locale", async () => { + // Locale collation puts "fe80::1" first and, under da-DK, "aarp" after "zz". + const result = await parseTsharkJsonOutput( + capture([ + { "ipv6.src": ["fe80::1"], "frame.protocols": ["eth:llc:zz"] }, + { "ipv6.src": ["fe80:1::1"], "frame.protocols": ["eth:llc:aarp"] }, + ]), + ); + expect(result.endpoints.map(({ address }) => address)).toEqual([ + "fe80:1::1", + "fe80::1", + ]); + expect(result.protocolDistribution.map(({ protocol }) => protocol)).toEqual([ + "eth:llc:aarp", + "eth:llc:zz", + ]); +}); + it("preserves all DNS addresses and their exact examined count", async () => { const result = await parseTsharkJsonOutput( capture( diff --git a/src/pcap/PcapOutput.ts b/src/pcap/PcapOutput.ts index 95cb81926..b14d7ba3c 100644 --- a/src/pcap/PcapOutput.ts +++ b/src/pcap/PcapOutput.ts @@ -4,6 +4,7 @@ import { AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; import { parseRecordedTransportUrl } from "../domain/recordedTransportUrl.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; const OPERATION = "inspect_pcap_capture"; /** Bounds JSON decoding plus its expanded object graph before JSON.parse. */ @@ -284,7 +285,7 @@ export const parseTsharkJsonOutput = async ( signal?.throwIfAborted(); return { endpoints: [...endpoints] - .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .sort((a, b) => b[1] - a[1] || compareUnicodeCodePoints(a[0], b[0])) .map(([address, packet_count]) => ({ address, port: null, @@ -300,7 +301,7 @@ export const parseTsharkJsonOutput = async ( tlsSniValues: [...sni], tlsSniCount: sni.size, protocolDistribution: [...protocols] - .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .sort((a, b) => b[1] - a[1] || compareUnicodeCodePoints(a[0], b[0])) .map(([protocol, packet_count]) => ({ protocol, packet_count })), protocolCount: protocols.size, httpRequestUris: [...uris], From 12303812a8d70f198c99c7aeb0df3aaa8166543c Mon Sep 17 00:00:00 2001 From: Rohit Ghumare <48523873+rohitg00@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:15:03 +0530 Subject: [PATCH 04/12] fix(evidence): admit bundle imports across session transitions (#2303) --- src/server/registerSessionRecordTools.ts | 78 +++++---- .../mcp/evidenceImportLifecycle.test.ts | 155 ++++++++++++++++++ 2 files changed, 202 insertions(+), 31 deletions(-) create mode 100644 tests/boundary/mcp/evidenceImportLifecycle.test.ts diff --git a/src/server/registerSessionRecordTools.ts b/src/server/registerSessionRecordTools.ts index a41d60cac..68daab23b 100644 --- a/src/server/registerSessionRecordTools.ts +++ b/src/server/registerSessionRecordTools.ts @@ -12,6 +12,8 @@ import { err, ok } from "../domain/result.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { inspectEvidenceBundle } from "../application/investigation/InspectEvidenceBundle.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; +import { withAdmittedAnalysis } from "./analysisAdmission.js"; +import { runAdmittedToolOperation } from "./admittedToolOperation.js"; interface EvidenceToolRegistration { readonly server: EvidenceMcpServer; @@ -92,40 +94,54 @@ const registerImportEvidenceTool = ({ resources, importContract, }: EvidenceToolRegistration): void => { + const admission = withAdmittedAnalysis({ kind: "session", session }); server.registerTool( importContract.name, toolRegistrationOptions(importContract), - async (input, context) => { - const path = input.path; - const signal = context.mcpReq.signal; - const loaded = await readImmutableEvidenceBundle(path, signal, resources); - if (!loaded.ok) - return server.delivery.toCallToolResult(loaded, importContract); - const retainedUnknownRevisions = new Set( - bundleForSerialization(session).unknowns.map((unknown) => - unknownRevisionKey(unknown), - ), - ); - if (signal.aborted) - return server.delivery.toCallToolResult( - err(new AnalysisCancelledError("import_evidence_bundle")), - importContract, - ); - const imported = session.importEvidenceBundle(loaded.value); - return imported.ok - ? server.delivery.toCallToolResult( - ok({ - imported: imported.value, - unknowns_added: loaded.value.unknowns.filter( - (unknown) => - !retainedUnknownRevisions.has(unknownRevisionKey(unknown)), - ).length, - total: bundleForSerialization(session).records.length, - }), - importContract, - ) - : server.delivery.toCallToolResult(imported, importContract); - }, + async (input, context) => + runAdmittedToolOperation( + server, + admission, + importContract.name, + context.mcpReq.signal, + async () => { + const path = input.path; + const signal = context.mcpReq.signal; + const loaded = await readImmutableEvidenceBundle( + path, + signal, + resources, + ); + if (!loaded.ok) + return server.delivery.toCallToolResult(loaded, importContract); + const retainedUnknownRevisions = new Set( + bundleForSerialization(session).unknowns.map((unknown) => + unknownRevisionKey(unknown), + ), + ); + if (signal.aborted) + return server.delivery.toCallToolResult( + err(new AnalysisCancelledError("import_evidence_bundle")), + importContract, + ); + const imported = session.importEvidenceBundle(loaded.value); + return imported.ok + ? server.delivery.toCallToolResult( + ok({ + imported: imported.value, + unknowns_added: loaded.value.unknowns.filter( + (unknown) => + !retainedUnknownRevisions.has( + unknownRevisionKey(unknown), + ), + ).length, + total: bundleForSerialization(session).records.length, + }), + importContract, + ) + : server.delivery.toCallToolResult(imported, importContract); + }, + ), ); }; diff --git a/tests/boundary/mcp/evidenceImportLifecycle.test.ts b/tests/boundary/mcp/evidenceImportLifecycle.test.ts new file mode 100644 index 000000000..f0371ef89 --- /dev/null +++ b/tests/boundary/mcp/evidenceImportLifecycle.test.ts @@ -0,0 +1,155 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, onTestFinished, vi } from "vitest"; + +import { createEvidence } from "../../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { + createDeferred, + createTestBinarySession, +} from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const connectImportSession = async () => { + const root = await createTestTempDirectory("rea-mcp-import-lifecycle-"); + const session = createTestBinarySession(() => { + throw new Error("Evidence import must not start a binary provider"); + }); + const server = createServer({ kind: "session", session }); + const client = new Client({ + name: "evidence-import-lifecycle", + version: "1", + }); + const readCompleted = createDeferred(); + const releaseRead = createDeferred(); + const originalFileClose = OwnedFileHandle.prototype.close; + vi.spyOn(OwnedFileHandle.prototype, "close").mockImplementationOnce( + async function (this: OwnedFileHandle) { + await originalFileClose.call(this); + readCompleted.resolve(); + await releaseRead.promise; + }, + ); + const closeStarted = createDeferred(); + const originalSessionClose = session.close.bind(session); + const events: string[] = []; + vi.spyOn(session, "close").mockImplementation((...args) => { + const closing = originalSessionClose(...args); + closeStarted.resolve(); + return closing.then((result) => { + events.push("close"); + return result; + }); + }); + const originalImport = session.importEvidenceBundle.bind(session); + vi.spyOn(session, "importEvidenceBundle").mockImplementation((bundle) => { + const imported = originalImport(bundle); + events.push("import"); + return imported; + }); + onTestFinished(async () => { + releaseRead.resolve(); + vi.restoreAllMocks(); + await client.close(); + await server.close(); + await session.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const laterImportDispatched = createDeferred(); + let importsDispatched = 0; + const send = clientTransport.send.bind(clientTransport); + vi.spyOn(clientTransport, "send").mockImplementation( + async (message, ...args) => { + await send(message, ...args); + if ( + "method" in message && + message.method === "tools/call" && + message.params?.name === "import_evidence_bundle" + ) { + importsDispatched += 1; + if (importsDispatched === 2) laterImportDispatched.resolve(); + } + }, + ); + await server.connect(serverTransport); + await client.connect(clientTransport); + const importBundle = async (operation: string) => { + const record = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { operation, parameters: {}, result: { retained: operation } }, + ); + const path = join(root, `${operation}.json`); + await writeFile(path, JSON.stringify(createEvidenceBundle([record]))); + return client.callTool({ + name: "import_evidence_bundle", + arguments: { path }, + }); + }; + return { + client, + session, + events, + importBundle, + readCompleted: readCompleted.promise, + closeStarted: closeStarted.promise, + laterImportDispatched: laterImportDispatched.promise, + releaseRead: () => releaseRead.resolve(), + }; +}; + +it("drains a file import before close_binary clears the session", async () => { + const fixture = await connectImportSession(); + const importing = fixture.importBundle("before_close"); + await fixture.readCompleted; + const closing = fixture.client.callTool({ + name: "close_binary", + arguments: {}, + }); + await fixture.closeStarted; + await fixture.client.ping(); + fixture.releaseRead(); + const [imported, closed] = await Promise.all([importing, closing]); + + expect(imported.isError, JSON.stringify(imported)).not.toBe(true); + expect(imported.structuredContent).toMatchObject({ + result: { imported: 1, unknowns_added: 0, total: 1 }, + }); + expect(closed.isError, JSON.stringify(closed)).not.toBe(true); + expect(fixture.events).toEqual(["import", "close"]); + expect(fixture.session.exportEvidenceBundle().records).toEqual([]); + await fixture.client.ping(); +}); + +it("admits a later import after close_binary without retaining the earlier bundle", async () => { + const fixture = await connectImportSession(); + const earlier = fixture.importBundle("before_close"); + await fixture.readCompleted; + const closing = fixture.client.callTool({ + name: "close_binary", + arguments: {}, + }); + await fixture.closeStarted; + const later = fixture.importBundle("after_close"); + await fixture.laterImportDispatched; + await fixture.client.ping(); + fixture.releaseRead(); + const results = await Promise.all([earlier, closing, later]); + + for (const result of results) + expect(result.isError, JSON.stringify(result)).not.toBe(true); + expect(results[2]?.structuredContent).toMatchObject({ + result: { imported: 1, unknowns_added: 0, total: 1 }, + }); + expect(fixture.events).toEqual(["import", "close", "import"]); + expect( + fixture.session + .exportEvidenceBundle() + .records.map((record) => record.operation), + ).toEqual(["after_close"]); + await fixture.client.ping(); +}); From 1a1b67c8885e9ff33f7a06ed2e027ebfdb83e084 Mon Sep 17 00:00:00 2001 From: Aurantium <3550271410@qq.com> Date: Mon, 12 Oct 2026 23:46:28 -0700 Subject: [PATCH 05/12] fix(javascript): preserve POSIX runtime path case on Windows (#2422) Use POSIX path semantics for recorded POSIX roots when reconciling runtime Evidence on Windows. Preserve Windows mapping behavior and cover implicit and explicit file roots through the reconciliation service. Assisted-by: OpenAI GPT-6 Co-authored-by: Aurantium-47 <280805072+Aurantium-47@users.noreply.github.com> --- .../javascriptRuntimeStaticCandidates.ts | 4 +- .../javascriptApplicationWorkflows.test.ts | 68 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/domain/javascript/javascriptRuntimeStaticCandidates.ts b/src/domain/javascript/javascriptRuntimeStaticCandidates.ts index 1f02c820d..810ab7f5e 100644 --- a/src/domain/javascript/javascriptRuntimeStaticCandidates.ts +++ b/src/domain/javascript/javascriptRuntimeStaticCandidates.ts @@ -1,4 +1,4 @@ -import { posix, relative, win32 } from "node:path"; +import { posix, win32 } from "node:path"; import type { ApplicationNode } from "./javascriptApplicationGraphSchemas.js"; import type { JavaScriptApplicationGraph } from "./javascriptApplicationGraph.js"; @@ -216,7 +216,7 @@ const pathBelowRoot = (root: string, value: string): string | null => { const remainder = rootStyle === "windows" ? win32.relative(root, value) - : relative(root, value); + : posix.relative(root, value); const portableRemainder = remainder.replaceAll("\\", "/"); if ( remainder === "" || diff --git a/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts b/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts index 17c3f0048..b79407821 100644 --- a/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts +++ b/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts @@ -21,6 +21,7 @@ import { type ApplicationVersionComparisonResult, } from "../../../src/domain/javascript/javascriptApplicationVersionComparisonSchemas.js"; import { javascriptRuntimeReconciliationResultSchema } from "../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js"; +import { electronPageInspectionSchema } from "../../../src/domain/javascript/electronObservation.js"; import { compareJavaScriptApplicationVersions } from "../../../src/domain/javascript/javascriptApplicationVersionComparison.js"; import { createJavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js"; import { traceApplicationFeature } from "../../../src/domain/javascript/javascriptFeatureTrace.js"; @@ -444,6 +445,73 @@ describe("JavaScript application workflow error diagnostics", () => { }); describe("JavaScript runtime reconciliation local paths", () => { + it.each([ + { + name: "case-sensitive POSIX input root", + root: null, + path: "/applications/Example.app/Contents/Resources/app/renderer.js", + status: "unknown", + }, + { + name: "case-sensitive POSIX mapping root", + root: "/runtime/App", + path: "/runtime/app/renderer.js", + status: "unknown", + }, + { + name: "matching POSIX mapping root", + root: "/runtime/App", + path: "/runtime/App/renderer.js", + status: "matched", + }, + { + name: "case-insensitive Windows mapping root", + root: String.raw`C:\runtime\App`, + path: String.raw`c:\runtime\app\renderer.js`, + status: "matched", + }, + ])("uses $name on every host", ({ root, path, status }) => { + const base = JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE; + const original = base.runtime_observations[0]!; + const inspection = electronPageInspectionSchema.parse( + original.normalized_result, + ); + const runtime = createEvidence(undefined, original.provider, { + predicateType: original.predicate_type, + operation: original.operation, + parameters: { ...original.parameters, include_script_sources: false }, + result: { + ...inspection, + scripts: { + ...inspection.scripts, + items: inspection.scripts.items.map((script) => ({ + ...script, + file_path: path, + source: { included: false, reason: "not-requested" }, + })), + }, + }, + confidence: original.confidence, + authority: original.authority, + }); + const reconciled = reconcileJavaScriptRuntimeEvidence({ + static_layers: base.static_layers.map((layer) => ({ + ...layer, + runtime_mappings: root === null ? [] : [{ kind: "file-root", root }], + })), + runtime_observations: [runtime], + }); + if (!reconciled.ok) throw reconciled.error; + const result = javascriptRuntimeReconciliationResultSchema.parse( + reconciled.value.normalized_result, + ); + expect( + result.reconciliations.find( + ({ entity_kind }) => entity_kind === "script", + ), + ).toMatchObject({ status }); + }); + it("resolves relative file mapping roots before deriving result Evidence", () => { const absoluteRoot = resolve(process.cwd(), "runtime-cache-fixture"); const relativeRoot = relative(process.cwd(), absoluteRoot); From c6acd19b6d842699ab807d9526dc762c743686f4 Mon Sep 17 00:00:00 2001 From: Mykola Dementiev Date: Tue, 13 Oct 2026 08:48:17 +0200 Subject: [PATCH 06/12] fix(ida): stop the private input of a universal Mach-O at the end of the selected slice (#2291) * test(ida): expect the private input of a universal Mach-O to end at the selected slice Cover a universal file larger than one read chunk with another slice after the selected one, for both a middle slice and the first slice. * fix(ida): stop the private input of a universal Mach-O at the end of the selected slice A read chunk that starts after the slice produced a negative end offset, and Buffer.subarray counts a negative bound from the end, so the head of the following slice was written to the private input and its digest. --- src/ida/IdaWorkspace.ts | 6 +- src/ida/IdaWorkspaceThinning.test.ts | 93 +++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/src/ida/IdaWorkspace.ts b/src/ida/IdaWorkspace.ts index 0b3c36231..c7d9e642c 100644 --- a/src/ida/IdaWorkspace.ts +++ b/src/ida/IdaWorkspace.ts @@ -85,8 +85,10 @@ export class IdaWorkspace { slice === undefined ? position + bytes.length : Math.min(position + bytes.length, slice.offset + slice.size); - const admitted = bytes.subarray(start - position, end - position); - if (admitted.length > 0) { + // A chunk after the slice has end < position, and a negative subarray + // bound would count from the chunk's end instead of being empty. + if (end > start) { + const admitted = bytes.subarray(start - position, end - position); inputHash.update(admitted); await input.write(admitted); } diff --git a/src/ida/IdaWorkspaceThinning.test.ts b/src/ida/IdaWorkspaceThinning.test.ts index 7feca3ddc..7d83f089b 100644 --- a/src/ida/IdaWorkspaceThinning.test.ts +++ b/src/ida/IdaWorkspaceThinning.test.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { parseBinaryTarget } from "../application/BinaryTargetResolver.js"; import { parseExecutableHeader } from "../domain/binaryTarget.js"; -import { fat } from "../domain/binaryTarget.fixture.js"; +import { fat, thinMach } from "../domain/binaryTarget.fixture.js"; import { createIdaTarget, RecordingIdaMcp, @@ -35,6 +35,45 @@ const universalTarget = async (hostArchitecture: "arm64" | "x64") => { return { bytes, target: resolved.value }; }; +/** One slice of a synthetic universal file: CPU type, offset and size. */ +interface Layout { + readonly cpu: number; + readonly offset: number; + readonly size: number; +} + +/** + * Write a universal file whose slices span more than one 64 KiB read, each + * filled with its own byte so bytes outside the selected slice are visible. + */ +const largeUniversalTarget = async ( + layout: readonly Layout[], + hostArchitecture: "arm64" | "x64", +) => { + const directory = await createTestTempDirectory("rea-ida-thin-large-"); + roots.push(directory); + const path = join(directory, "universal"); + const total = Math.max(...layout.map(({ offset, size }) => offset + size)); + const bytes = Buffer.alloc(total); + bytes.writeUInt32BE(0xcafebabe, 0); + bytes.writeUInt32BE(layout.length, 4); + layout.forEach(({ cpu, offset, size }, index) => { + const entry = 8 + index * 20; + bytes.writeUInt32BE(cpu, entry); + bytes.writeUInt32BE(offset, entry + 8); + bytes.writeUInt32BE(size, entry + 12); + bytes.writeUInt32BE(14, entry + 16); + bytes.fill(0x11 * (index + 1), offset, offset + size); + thinMach(0xfeedfacf, cpu).copy(bytes, offset); + }); + await writeFile(path, bytes); + const resolved = await parseBinaryTarget(path, { hostArchitecture }); + if (!resolved.ok) throw resolved.error; + if (resolved.value.format !== "mach-o-universal") + throw new Error("the universal fixture did not resolve as a FAT container"); + return { bytes, target: resolved.value }; +}; + describe("IDA workspace input thinning", () => { it.skipIf(process.platform === "win32")( "thins the selected arm64 slice into the private headless input", @@ -78,6 +117,58 @@ describe("IDA workspace input thinning", () => { }, ); + it.skipIf(process.platform === "win32")( + "copies exactly the selected slice when later slices follow it across read boundaries", + async () => { + const { bytes, target } = await largeUniversalTarget( + [ + { cpu: 0x01000007, offset: 4096, size: 8480 }, + { cpu: 0x0100000c, offset: 16384, size: 33464 }, + { cpu: 0x00000007, offset: 65536, size: 33464 }, + ], + "arm64", + ); + const workspace = await IdaWorkspace.allocate(target); + roots.push(workspace.directory); + await workspace.copyInput(); + + const input = await readFile(workspace.inputPath); + const { offset, size } = target.universalSlice; + expect(size, "the arm64 slice is selected").toBe(33464); + expect(input.length, "no byte after the slice is copied").toBe(size); + expect(input.equals(bytes.subarray(offset, offset + size))).toBe(true); + expect(workspace.inputSha256).toBe( + createHash("sha256") + .update(bytes.subarray(offset, offset + size)) + .digest("hex"), + ); + await workspace.remove(); + }, + ); + + it.skipIf(process.platform === "win32")( + "copies exactly the first slice when it is selected and others follow", + async () => { + const { bytes, target } = await largeUniversalTarget( + [ + { cpu: 0x01000007, offset: 16384, size: 37456 }, + { cpu: 0x0100000c, offset: 65536, size: 33464 }, + ], + "x64", + ); + const workspace = await IdaWorkspace.allocate(target); + roots.push(workspace.directory); + await workspace.copyInput(); + + const input = await readFile(workspace.inputPath); + const { offset, size } = target.universalSlice; + expect(size, "the x86_64 slice is selected").toBe(37456); + expect(input.length, "no byte after the slice is copied").toBe(size); + expect(input.equals(bytes.subarray(offset, offset + size))).toBe(true); + await workspace.remove(); + }, + ); + it("keeps a whole-file copy for thin targets", async () => { const { root, target } = await createIdaTarget("arm64"); roots.push(root); From 2c6a4fac4ff3477ac14f3d780894c5b390880607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E4=BD=B3=E6=B5=A9Alben?= <35291372+XiaoBinGan@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:48:26 +0800 Subject: [PATCH 07/12] fix(process): accept a trace identifier the comparison result can represent (#2488) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trace specification owns the declared identifier vocabulary and the comparison result echoes those identifiers verbatim into raw_trace[].event_id, matched_variant and diagnostic event_ids. The result module restated the shared IDENTIFIER_PATTERN by hand with a 64-character cap, so a specification with a longer identifier validated and then its own comparison result failed to parse: compareProcessTraces threw a raw ZodError at the caller instead of returning a comparison. Export the identifier schema from the specification as the single owner and reuse it in the evaluation module, removing the capped copy. IDENTIFIER_PATTERN has no length bound and every other consumer uses it unbounded, so the duplicate was the outlier. Removing the bound rather than adding one keeps the advertised trace_spec input contract unchanged; identifiers of 64 characters or fewer behave exactly as before. Verified with 48 process tests, 24 boundary CLI/MCP trace tests, check:fast and verify:test-discovery on macOS arm64. The regression test failed with the ZodError before the change and passes after it. Co-authored-by: 吴佳浩 --- .../process/processTraceComparison.test.ts | 28 +++++++++++++++ src/domain/process/processTraceEvaluation.ts | 9 +++-- .../process/processTraceSpecification.ts | 35 ++++++++++++------- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/src/domain/process/processTraceComparison.test.ts b/src/domain/process/processTraceComparison.test.ts index caeaf7999..2207352e3 100644 --- a/src/domain/process/processTraceComparison.test.ts +++ b/src/domain/process/processTraceComparison.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { parseProcessCapture } from "./processCaptureParsing.js"; import type { ProcessTraceSpecification } from "./processTraceSpecification.js"; +import { processTraceSpecificationSchema as specificationSchema } from "./processTraceSpecification.js"; import { processTraceComparisonResultSchema } from "./processTraceEvaluation.js"; import { capture, @@ -100,6 +101,33 @@ describe("generic capture trace comparison", () => { right: { matched_variant: "two" }, }); }); + + it("compares a declared identifier the comparison result can represent", () => { + // The specification owns the trace identifier vocabulary and the result + // echoes those identifiers verbatim, so both must accept the same values + // rather than handing the caller a raw schema error for its own input. + const eventId = `e${"0".repeat(64)}`; + const specification: ProcessTraceSpecification = { + events: [ + { + id: eventId, + source: "terminal_raw", + exact: terminal, + cardinality: { kind: "required" }, + }, + ], + language: { + kind: "finite_traces", + variants: [{ id: "only", trace: [eventId] }], + }, + }; + expect(specificationSchema.safeParse(specification).success).toBe(true); + const left = capture(values(["terminal", "process"])); + expect(compareProcessTraces(left, left, specification)).toMatchObject({ + verdict: "equivalent", + left: { matched_variant: "only", raw_trace: [{ event_id: eventId }] }, + }); + }); }); describe("process trace constraints and evidence sufficiency", () => { diff --git a/src/domain/process/processTraceEvaluation.ts b/src/domain/process/processTraceEvaluation.ts index 51a2a15f8..7c232a480 100644 --- a/src/domain/process/processTraceEvaluation.ts +++ b/src/domain/process/processTraceEvaluation.ts @@ -13,13 +13,12 @@ import { import { canonicalTraceJson, processTraceCardinalityBounds, + processTraceIdentifierSchema, type ProcessTraceSpecification, } from "./processTraceSpecification.js"; -const identifierSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._\x2d]{0,63}$/u); - const matchedEventSchema = z.strictObject({ - event_id: identifierSchema, + event_id: processTraceIdentifierSchema, location: processObservationLocationSchema, }); @@ -32,7 +31,7 @@ const sideResultSchema = z.union([ z.strictObject({ ...sideResultShape, status: z.literal("pass"), - matched_variant: identifierSchema.nullable(), + matched_variant: processTraceIdentifierSchema.nullable(), }), z.strictObject({ ...sideResultShape, @@ -53,7 +52,7 @@ const diagnosticSchema = z.strictObject({ ]), side: z.enum(["left", "right"]), message: z.string(), - event_ids: z.array(identifierSchema), + event_ids: z.array(processTraceIdentifierSchema), locations: z.array(processObservationLocationSchema), }); diff --git a/src/domain/process/processTraceSpecification.ts b/src/domain/process/processTraceSpecification.ts index 09c14cf10..4e28774e0 100644 --- a/src/domain/process/processTraceSpecification.ts +++ b/src/domain/process/processTraceSpecification.ts @@ -9,7 +9,16 @@ import { processObservationSourceSchema, } from "./processObservation.js"; -const identifierSchema = z.string().regex(IDENTIFIER_PATTERN); +/** + * A declared trace, event or variant identifier. The specification owns this + * vocabulary and the comparison result echoes those identifiers verbatim, so + * the result module must reuse this schema rather than restate the pattern: + * two bounds on the same identifier would reject the result of an accepted + * specification. + */ +export const processTraceIdentifierSchema = z + .string() + .regex(IDENTIFIER_PATTERN); const cardinalitySchema = z.discriminatedUnion("kind", [ z.strictObject({ kind: z.literal("required") }), @@ -31,7 +40,7 @@ const cardinalitySchema = z.discriminatedUnion("kind", [ ]); const eventDeclarationSchema = z.strictObject({ - id: identifierSchema, + id: processTraceIdentifierSchema, source: processObservationSourceSchema, exact: jsonValueSchema, ignore_fields: z @@ -56,29 +65,31 @@ const partialOrderLanguageSchema = z.strictObject({ happens_before: z .array( z.strictObject({ - before: identifierSchema, - after: identifierSchema, + before: processTraceIdentifierSchema, + after: processTraceIdentifierSchema, }), ) .default([]), not_before: z .array( z.strictObject({ - event: identifierSchema, - anchor: identifierSchema, + event: processTraceIdentifierSchema, + anchor: processTraceIdentifierSchema, }), ) .default([]), unordered_groups: z - .array(z.strictObject({ events: z.array(identifierSchema).min(2) })) + .array( + z.strictObject({ events: z.array(processTraceIdentifierSchema).min(2) }), + ) .default([]), - prefix: z.array(identifierSchema).default([]), - suffix: z.array(identifierSchema).default([]), + prefix: z.array(processTraceIdentifierSchema).default([]), + suffix: z.array(processTraceIdentifierSchema).default([]), }); const finiteTraceTokenSchema = z.union([ - identifierSchema, - z.strictObject({ unordered: z.array(identifierSchema).min(2) }), + processTraceIdentifierSchema, + z.strictObject({ unordered: z.array(processTraceIdentifierSchema).min(2) }), ]); const finiteTraceLanguageSchema = z.strictObject({ @@ -86,7 +97,7 @@ const finiteTraceLanguageSchema = z.strictObject({ variants: z .array( z.strictObject({ - id: identifierSchema, + id: processTraceIdentifierSchema, trace: z.array(finiteTraceTokenSchema), }), ) From ab62ee9dcf98e4717d0b3445330f7cfdfe50c9f4 Mon Sep 17 00:00:00 2001 From: Stefano Rizzello Date: Tue, 13 Oct 2026 08:49:34 +0200 Subject: [PATCH 08/12] fix(windows): preserve caller affinity only within one processor group (#2202) * fix(windows): preserve caller affinity only within one processor group `spawnProcess` read the caller's affinity mask and applied it to the suspended child unconditionally. A mask names processors relative to one processor group, and Windows assigns each new process to a group of its own choosing, so on hosts with more than 64 logical processors the child is often placed in another group. `SetProcessAffinityMask` then rejects the mask with ERROR_INVALID_PARAMETER (87) and the spawn fails, making every owned provider process start intermittently on such hosts. Affinity is a nicety, not an authority or isolation control, so it is now applied only when the caller and the suspended child both occupy the same single processor group. A child placed in another group keeps that group's default affinity and the spawn proceeds. Job Object membership, kill-on-owner-close policy, owner normalization and priority preservation are unchanged, and the mask failure remains fatal within a shared group, where it is not expected. Single-group hosts, including every host with 64 or fewer logical processors, are unaffected: `GetProcessGroupAffinity` reports one group for both processes and the same mask is applied as before. The optional process-boundary fixture gains an `affinity` observation, and the native verification lane asserts that an owned spawn succeeds and that the child's mask matches the caller's whenever both share a single group. Fixes #2181 Co-Authored-By: Claude Opus 5 * fix(windows): refuse to widen a pinned caller's affinity across groups Addresses review feedback on the first commit. An unconstrained caller loses nothing when Windows places an owned child in another processor group, so that spawn proceeds with the group's default. A caller deliberately pinned to fewer processors than its group holds is a different case: silently accepting the default would hand the provider tree the whole group, which `docs/installation.md` tells operators to rely on affinity to prevent. Such a spawn now fails naming the constraint instead. `GetProcessAffinityMask` already reports the group's active set, which distinguishes the two without another query. Selecting the caller's group at creation through PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY was tried first and rejected: `CreateProcessW` fails with ERROR_INVALID_PARAMETER on a host with a single processor group, both alongside PROC_THREAD_ATTRIBUTE_JOB_LIST and on its own, so it cannot carry the ordinary path. The boundary fixture's affinity observation now reports a process id's own mask rather than its own, because a separate probe process is assigned its own group and cannot stand in for the process that spawns the child. Masks and the group's active set cross as fixed-width hexadecimal strings: a mask can set bits above the safe-integer range, where `JSON.parse` would round two different masks to one value. The verification lane asserts the mask is inherited exactly when caller and child share a group, and otherwise that the caller was unconstrained. Co-Authored-By: Claude Opus 5 * docs(windows): record what owned-process affinity does not preserve A mask covering its group's whole active set does not distinguish a caller the system placed in that group from one that selected the group deliberately, so an owned child can still be placed elsewhere with its processor count bounded and its group placement lost. Treating that case as constrained instead is not available: on a multi-group host an ordinary process is assigned a group by the system and its mask is that group's active set, so refusing it would fail every owned spawn there. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- docs/windows-ghidra-p0.md | 8 +++-- native/windows/README.md | 20 ++++++++++--- native/windows/src/process.cc | 30 ++++++++++++++++++- scripts/verify-windows-native.mjs | 36 +++++++++++++++++++++++ tests/fixtures/windows/processBoundary.cc | 36 +++++++++++++++++++++++ 5 files changed, 123 insertions(+), 7 deletions(-) diff --git a/docs/windows-ghidra-p0.md b/docs/windows-ghidra-p0.md index d14add07c..9cd3bb6d8 100644 --- a/docs/windows-ghidra-p0.md +++ b/docs/windows-ghidra-p0.md @@ -153,8 +153,12 @@ the private runtime, isolating Ghidra preferences, logs, and caches. Process creation assigns a suspended child atomically to an owned Job Object. Membership and kill-on-owner-close policy are verified before resume. Breakaway is disabled. Only the child's default file owner is normalized; -the caller token stays unchanged. Caller CPU affinity and priority are -preserved. Close, cancellation, timeout, startup failure, and provider failure +the caller token stays unchanged. Caller priority is preserved, and caller CPU +affinity whenever the caller and the child share a processor group. A child that +Windows places in another group keeps that group's default when the caller asked +for no bound of its own; a caller pinned to fewer processors than its group +holds is refused there rather than widened. +Close, cancellation, timeout, startup failure, and provider failure terminate the job and verify all members have settled before runtime cleanup. Cleanup removes admitted objects by handle and unlinks reparse entries without following their targets. Abrupt owner death kills the job but can leave diff --git a/native/windows/README.md b/native/windows/README.md index c63540140..e2543260f 100644 --- a/native/windows/README.md +++ b/native/windows/README.md @@ -69,10 +69,22 @@ without this lane reports Windows native controls as unavailable. limits only for its JavaScript helpers when running on Bun, which ignores their V8 heap flags; Node.js launches are unchanged. - Only the suspended child's default file owner is normalized. Caller token - privileges and ownership remain unchanged. Caller CPU affinity and process - priority are preserved in the child. Environment names are sorted and - deduplicated using Windows ordinal comparison, preserving their native Unicode - semantics and caller-selected values. + privileges and ownership remain unchanged. Caller process priority is + preserved in the child, and caller CPU affinity whenever both occupy the same + processor group. Windows chooses a new process's group itself and rejects a + mask belonging to another group, so a child placed elsewhere keeps that + group's default when the caller asked for no bound of its own. A caller + deliberately pinned to fewer processors than its group holds is a constraint + such a child cannot express, and is refused rather than widened to that + group's default. A mask covering its group's whole active set does not + distinguish a caller Windows placed in that group from one that selected the + group deliberately, so such a child can still be placed elsewhere: its + processor count stays bounded, its group placement is not preserved, and + neither that case nor the refusal above has a verification lane, because both + need a host whose processors span more than one group. + Environment names are sorted and deduplicated using Windows + ordinal comparison, preserving their native Unicode semantics and + caller-selected values. - Cleanup walks locked directory objects, removes files by handle, and unlinks reparse entries without traversing their targets. Failed removal retains the root and ancestor leases for retry; a partially removed runtime accepts only diff --git a/native/windows/src/process.cc b/native/windows/src/process.cc index ede2b5622..80a4d0ac5 100644 --- a/native/windows/src/process.cc +++ b/native/windows/src/process.cc @@ -68,6 +68,18 @@ static ULONGLONG jobMemoryLimit(napi_env env, napi_value value) { return limit; } +// An affinity mask is meaningful only inside one processor group, so only a +// process occupying exactly one group has a group a child can inherit. A +// process whose threads span groups, or whose membership cannot be read, has no +// single group. +static bool singleProcessorGroup(HANDLE process, USHORT& group) { + USHORT count = 1; + std::array groups{}; + if (!GetProcessGroupAffinity(process, &count, groups.data()) || count != 1) return false; + group = groups[0]; + return true; +} + static void pipe(Handle& read, Handle& write, bool parentReads = true) { SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE}; HANDLE rawRead, rawWrite; @@ -168,11 +180,27 @@ std::unique_ptr spawnProcess(const std::wstring& command, const std::ws TOKEN_OWNER owner{reinterpret_cast(tokenUser.data())->User.Sid}; require(SetTokenInformation(token.get(), TokenOwner, &owner, sizeof(owner)), "Set suspended process file owner failed", command); + // A mask names processors inside one processor group, and Windows assigns a + // new process to a group of its own choosing, so a mask belonging to another + // group is rejected (ERROR_INVALID_PARAMETER). An unconstrained caller, whose + // mask is its group's whole active set, asks for no bound the child's own + // group cannot give, so such a child keeps that group's default and the spawn + // proceeds. A narrower mask is a deliberate CPU constraint that a child in + // another group cannot express, and silently widening it would hand the + // provider tree the whole group, so that spawn fails naming the constraint. DWORD_PTR affinity = 0, systemAffinity = 0; require(GetProcessAffinityMask(GetCurrentProcess(), &affinity, &systemAffinity), "Read caller process affinity failed", command); - if (affinity != 0) + USHORT callerGroup = 0, childGroup = 0; + const bool sharedGroup = singleProcessorGroup(GetCurrentProcess(), callerGroup) && + singleProcessorGroup(result->process.get(), childGroup) && + callerGroup == childGroup; + if (affinity != 0 && sharedGroup) require(SetProcessAffinityMask(result->process.get(), affinity), "Preserve caller process affinity failed", command); + else + require(affinity == 0 || affinity == systemAffinity, + "Caller CPU affinity cannot bound an owned process in another processor group", command, + ERROR_INVALID_PARAMETER); const DWORD priority = GetPriorityClass(GetCurrentProcess()); require(priority != 0 && SetPriorityClass(result->process.get(), priority), "Preserve caller process priority failed", command); diff --git a/scripts/verify-windows-native.mjs b/scripts/verify-windows-native.mjs index 3db2613ad..8623081f9 100644 --- a/scripts/verify-windows-native.mjs +++ b/scripts/verify-windows-native.mjs @@ -662,6 +662,42 @@ setInterval(()=>{},1000);\n`, unicodeNamesPreserved: true, }); report.processes.windowsEnvironmentSemantics = true; + // Observed through this process's own id: a separate probe process would + // get its own processor group, so it cannot stand in for the process that + // spawns the child. + const callerAffinity = JSON.parse( + execFileSync(boundaryFixture, ["affinity", String(process.pid)], { + encoding: "utf8", + }), + ); + const affinityChild = native.call("process_spawn", [ + boundaryFixture, + `${quote(boundaryFixture)} affinity`, + workspace, + environment, + ]); + const observedAffinity = await wait(affinityChild); + native.call("process_close", [affinityChild.handle]); + assert.equal(observedAffinity.exitCode, 0); + const childAffinity = JSON.parse(observedAffinity.output); + // Masks cross as hexadecimal strings because a mask can exceed the + // safe-integer range. Sharing one processor group makes the caller's mask + // applicable, so it must be inherited exactly. Windows placing the child in + // another group is only permitted when the caller asked for no bound of its + // own, which its group's whole active set establishes; a narrower mask has + // to fail the spawn rather than widen to that group's default. + const sharedGroup = + callerAffinity.singleProcessorGroup && + childAffinity.singleProcessorGroup && + callerAffinity.processorGroup === childAffinity.processorGroup; + if (sharedGroup) + assert.equal(childAffinity.affinityMask, callerAffinity.affinityMask); + else assert.equal(callerAffinity.affinityMask, callerAffinity.systemMask); + report.processes.callerAffinityBoundsOwnedProcessOrRefusesToWiden = true; + report.callerAffinity = { + ...callerAffinity, + sharedGroupWithChild: sharedGroup, + }; } const ownerFixture = join(workspace, "owner.mjs"); const ownerPidFile = join(workspace, "crash-child.pid"); diff --git a/tests/fixtures/windows/processBoundary.cc b/tests/fixtures/windows/processBoundary.cc index 18dcb2dbd..05ee218e8 100644 --- a/tests/fixtures/windows/processBoundary.cc +++ b/tests/fixtures/windows/processBoundary.cc @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -33,6 +34,41 @@ int wmain(int count, wchar_t** args) { value(L"REA_\u00df") == expectedSharp && value(L"REA_SS") == L"double" ? "true" : "false"); return 0; } + if ((count == 2 || count == 3) && std::wstring(args[1]) == L"affinity") { + // Without a process id this observes itself, which is what an owned child + // reports. With one it observes that process, so a caller's own affinity is + // read from the process that spawns the child rather than from a separate + // probe Windows may place in another processor group. + HANDLE opened = nullptr; + if (count == 3) { + opened = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, + static_cast(std::wcstoul(args[2], nullptr, 10))); + if (!opened) return 9; + } + const HANDLE target = opened ? opened : GetCurrentProcess(); + DWORD_PTR affinity = 0, systemAffinity = 0; + USHORT groupCount = 1; + USHORT groups[1]{}; + const bool read = GetProcessAffinityMask(target, &affinity, &systemAffinity) != 0; + const bool single = read && GetProcessGroupAffinity(target, &groupCount, groups) && groupCount == 1; + if (opened) CloseHandle(opened); + if (!read) return 8; + // A mask can set bits above JavaScript's safe-integer range, so masks cross + // as fixed-width hexadecimal strings and are compared without rounding. The + // group's own active set distinguishes an unconstrained process from one + // deliberately pinned to fewer processors. + auto mask = [](DWORD_PTR value, char* text) { + std::snprintf(text, 19, "0x%08lX%08lX", static_cast(value >> 32), + static_cast(value & 0xFFFFFFFFu)); + return text; + }; + char affinityText[19]{}, systemText[19]{}; + std::printf("{\"affinityMask\":\"%s\",\"systemMask\":\"%s\"," + "\"singleProcessorGroup\":%s,\"processorGroup\":%u}\n", + mask(affinity, affinityText), mask(systemAffinity, systemText), + single ? "true" : "false", single ? static_cast(groups[0]) : 0u); + return 0; + } if (count == 4 && std::wstring(args[1]) == L"reparse-directory") { // Only the conformance lane's disposable, nonempty source directory is used. const auto substitute = L"\\??\\" + std::wstring(args[3]); From 68ecf3df8c666fdbe1ced8089c64340af614d1c3 Mon Sep 17 00:00:00 2001 From: chAngE <2815456271@qq.com> Date: Tue, 13 Oct 2026 14:49:47 +0800 Subject: [PATCH 09/12] fix(browser): exclude sensitive binary lengths from historical captures (#2477) Co-authored-by: chAngE <172472630+chAng-L19@users.noreply.github.com> --- bridge/mitmproxy/capture.py | 2 +- .../fixtures/generate-mitmproxy-capture.py | 14 +++ src/browser/history/HarCapture.ts | 3 +- .../browser/historicalWebCapture.test.ts | 88 +++++++++++++++++++ 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/bridge/mitmproxy/capture.py b/bridge/mitmproxy/capture.py index fa0535c02..dcd90e745 100644 --- a/bridge/mitmproxy/capture.py +++ b/bridge/mitmproxy/capture.py @@ -93,7 +93,7 @@ def project_record(state, secrets): safe_bytes, credential_url = transport_value(value, path, transport_url) content_base64 = base64.b64encode(value).decode("ascii") digest = hashlib.sha256(value).hexdigest() - hidden = any(secret in value or secret in safe_bytes for secret in byte_secrets) or any(secret in content_base64 or secret in digest for secret in secrets) + hidden = any(secret in value or secret in safe_bytes for secret in byte_secrets) or any(secret in content_base64 or secret in digest or secret in str(len(value)) for secret in secrets) safe_text = None try: safe_text = safe_bytes.decode("utf-8", errors="strict") diff --git a/scripts/fixtures/generate-mitmproxy-capture.py b/scripts/fixtures/generate-mitmproxy-capture.py index 4be3dd4a7..880c5e690 100644 --- a/scripts/fixtures/generate-mitmproxy-capture.py +++ b/scripts/fixtures/generate-mitmproxy-capture.py @@ -45,6 +45,20 @@ class Generator: assert "ows-secret" not in json.dumps(projected), projected if isinstance(value, bytes): assert projected["binary_fields"][-1]["state"] == "redacted", projected + body = b"A" * 12345 + state = {"response": {"content": body}} + control = adapter.project_record(state, []) + field = control["binary_fields"][0] + assert field["state"] == "retained" and field["bytes"] == len(body), control + for literal in ["12345", "2345"]: + # Only the generated byte count matches this exclusion. + assert literal not in control["reported"]["response"]["content"] + assert literal not in field["content_base64"] and literal not in field["sha256"] + projected = adapter.project_record(state, [literal]) + assert projected["reported"]["response"]["content"] is None, projected + assert projected["binary_fields"] == [{"pointer": "/response/content", "representation": "producer-bytes", "state": "redacted", "content_base64": None, "bytes": None, "sha256": None}], projected + assert {"pointer": "/response/content", "reason": "explicit-sensitive-value"} in projected["redactions"], projected + assert literal not in json.dumps(projected), projected (root / "native-error-classifications.json").write_text(json.dumps({"passed": len(classifications) + 2})) first = http.HTTPFlow(connection.Client(peername=("127.0.0.1", 1), sockname=("127.0.0.1", 2)), connection.Server(address=("example.test", 80))) first.id = "original-producer-id" diff --git a/src/browser/history/HarCapture.ts b/src/browser/history/HarCapture.ts index 83b442f5c..81c5ffb15 100644 --- a/src/browser/history/HarCapture.ts +++ b/src/browser/history/HarCapture.ts @@ -144,7 +144,8 @@ const projectHar = ( (literal) => item.includes(literal) || contentBase64.includes(literal) || - sha256.includes(literal), + sha256.includes(literal) || + String(bytes.length).includes(literal), ); binary_fields.push( hidden diff --git a/tests/boundary/browser/historicalWebCapture.test.ts b/tests/boundary/browser/historicalWebCapture.test.ts index a0a9f10e2..781172cd3 100644 --- a/tests/boundary/browser/historicalWebCapture.test.ts +++ b/tests/boundary/browser/historicalWebCapture.test.ts @@ -7,6 +7,9 @@ import { execFile } from "node:child_process"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { parseEvidence } from "../../../src/domain/evidence.js"; +import { webNetworkCaptureSchema } from "../../../src/domain/webNetworkCapture.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const entrypoint = fileURLToPath( new URL("../../../scripts/rea.mjs", import.meta.url), ); @@ -41,6 +44,91 @@ const cli = async ( }; import { historicalHar } from "../../fixtures/historicalHar.js"; +it("excludes declared binary lengths through CLI, MCP and retained capture pages", async () => { + const root = await createTestTempDirectory("rea-har-byte-length-"); + const path = join(root, "capture.har"); + const fixture = historicalHar(); + const entry = fixture.log.entries[0]; + if (entry === undefined) throw new Error("fixture missing"); + const body = Buffer.alloc(12_345, "A"); + const content = body.toString("base64"); + const digest = createHash("sha256").update(body).digest("hex"); + await writeFile( + path, + JSON.stringify({ + log: { + ...fixture.log, + entries: [ + { + ...entry, + response: { + ...entry.response, + content: { + size: 99, + mimeType: "text/plain", + encoding: "base64", + text: content, + }, + }, + }, + ], + }, + }), + ); + const { call } = await connectLocalToolsMcp(); + const args = { capture_path: path, format: "har" }; + const control = await call("inspect_web_network_capture", args); + expect(control.isError).not.toBe(true); + const retained = parseEvidence(control.structuredContent); + const controlCapture = webNetworkCaptureSchema.parse( + retained.normalized_result, + ); + expect(controlCapture.records[0]?.binary_fields[0]).toMatchObject({ + state: "retained", + bytes: body.length, + content_base64: content, + sha256: digest, + }); + for (const literal of ["12345", "2345"]) { + // Only the derived count matches, so this cannot pass via another exclusion. + expect(content).not.toContain(literal); + expect(digest).not.toContain(literal); + const fromCli = await cli(path, "har", ["--sensitive-value", literal]); + expect(fromCli.ok, fromCli.stdout).toBe(true); + const evidence = parseEvidence(JSON.parse(fromCli.stdout)); + const response = await call("inspect_web_network_capture", { + ...args, + sensitive_values: [literal], + }); + expect(response.isError).not.toBe(true); + const fromMcp = parseEvidence(response.structuredContent); + expect(fromMcp.normalized_result).toEqual(evidence.normalized_result); + const capture = webNetworkCaptureSchema.parse(evidence.normalized_result); + expect(capture.records[0]?.reported).toMatchObject({ + response: { content: { size: 99, text: null } }, + }); + expect(capture.records[0]?.binary_fields[0]).toEqual({ + pointer: "/response/content/text", + representation: "har-base64-content", + state: "redacted", + bytes: null, + content_base64: null, + sha256: null, + }); + for (const detail of ["preview", "complete"]) { + const page = await call("inspect_analysis_view", { + source: { kind: "retained-evidence", evidence_id: fromMcp.evidence_id }, + view: { kind: "capture", detail }, + }); + expect(page.isError).not.toBe(true); + const result = parseEvidence(page.structuredContent); + expect(result.normalized_result).toMatchObject({ + items: [{ binary_fields: [{ state: "redacted", bytes: null }] }], + }); + } + } +}, 60_000); + it("reads actual upstream HAR packages in an owned process and retains selected producer order", async () => { const root = await mkdtemp(join(tmpdir(), "rea-history-har-")); onTestFinished(() => rm(root, { recursive: true, force: true })); From e74e4e951bf59c42b25779b45d1c7b8dde6b618f Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:50:44 -0400 Subject: [PATCH 10/12] fix(screenshot): reject invalid PNG header dimensions (#2472) --- src/domain/pngHeader.test.ts | 21 +++++++++++++ src/domain/pngHeader.ts | 8 ++++- src/native/NativeUiObservation.test.ts | 31 +++++++++++++++++++ .../cdpScreenshotDocumentIdentity.test.ts | 31 +++++++++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/domain/pngHeader.test.ts b/src/domain/pngHeader.test.ts index e8df531bc..676a832e0 100644 --- a/src/domain/pngHeader.test.ts +++ b/src/domain/pngHeader.test.ts @@ -33,3 +33,24 @@ it("rejects incomplete headers, invalid signatures and zero dimensions", () => { expect(readPngHeaderDimensions(zero)).toBeUndefined(); } }); + +it.each([0, 12, 14, 0xffff_ffff])("rejects IHDR data length %s", (length) => { + const bytes = Buffer.from(png); + bytes.writeUInt32BE(length, 8); + expect(readPngHeaderDimensions(bytes)).toBeUndefined(); +}); + +it.each([16, 20])("rejects out-of-range PNG dimension at %s", (offset) => { + const bytes = Buffer.from(png); + bytes.writeUInt32BE(0x8000_0000, offset); + expect(readPngHeaderDimensions(bytes)).toBeUndefined(); +}); + +it("accepts the largest dimension representable by the PNG specification", () => { + const bytes = Buffer.from(png); + bytes.writeUInt32BE(0x7fff_ffff, 16); + expect(readPngHeaderDimensions(bytes)).toEqual({ + width: 0x7fff_ffff, + height: 1, + }); +}); diff --git a/src/domain/pngHeader.ts b/src/domain/pngHeader.ts index 58cdd7d3f..5ab8c7c3d 100644 --- a/src/domain/pngHeader.ts +++ b/src/domain/pngHeader.ts @@ -8,10 +8,16 @@ export const readPngHeaderDimensions = ( if ( bytes.byteLength < 24 || !bytes.subarray(0, 8).equals(PNG_SIGNATURE) || + bytes.readUInt32BE(8) !== 13 || !bytes.subarray(12, 16).equals(IHDR_TYPE) ) return undefined; const width = bytes.readUInt32BE(16); const height = bytes.readUInt32BE(20); - return width === 0 || height === 0 ? undefined : { width, height }; + return width === 0 || + height === 0 || + width > 0x7fff_ffff || + height > 0x7fff_ffff + ? undefined + : { width, height }; }; diff --git a/src/native/NativeUiObservation.test.ts b/src/native/NativeUiObservation.test.ts index d2238bd01..527afd8db 100644 --- a/src/native/NativeUiObservation.test.ts +++ b/src/native/NativeUiObservation.test.ts @@ -35,6 +35,37 @@ const validPng = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jv4sAAAAASUVORK5CYII=", "base64", ); +it.each([ + { offset: 8, value: 12 }, + { offset: 16, value: 0x8000_0000 }, + { offset: 20, value: 0x8000_0000 }, +])( + "rejects invalid IHDR metadata with a matching digest", + async ({ offset, value }) => { + const bytes = Buffer.from(validPng); + bytes.writeUInt32BE(value, offset); + const screenshot = { + mime_type: "image/png", + base64: bytes.toString("base64"), + sha256: createHash("sha256").update(bytes).digest("hex"), + width: bytes.readUInt32BE(16), + height: bytes.readUInt32BE(20), + }; + const result = await observeNativeUi( + target, + "observe_native_ui", + { ...scope, screenshot: true }, + { + environment: {}, + invoke: async () => ({ + ok: true, + result: { ...snapshot, screenshot }, + }), + }, + ); + expect(result.ok).toBe(false); + }, +); describe("native UI screenshot validation", () => { it("rejects malformed or mismatched screenshot bytes from the helper", async () => { const screenshot = { diff --git a/tests/boundary/browser/cdpScreenshotDocumentIdentity.test.ts b/tests/boundary/browser/cdpScreenshotDocumentIdentity.test.ts index 012ad9663..b3fcbc592 100644 --- a/tests/boundary/browser/cdpScreenshotDocumentIdentity.test.ts +++ b/tests/boundary/browser/cdpScreenshotDocumentIdentity.test.ts @@ -3,6 +3,37 @@ import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; import { captureWebScreenshotInputSchema } from "../../../src/domain/webScreenshot.js"; import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; describe("screenshot committed document identity", () => { + it.each([ + { offset: 8, value: 14 }, + { offset: 16, value: 0x8000_0000 }, + { offset: 20, value: 0x8000_0000 }, + ])("rejects invalid screenshot IHDR metadata", async ({ offset, value }) => { + const bytes = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jv4sAAAAASUVORK5CYII=", + "base64", + ); + bytes.writeUInt32BE(value, offset); + const browser = await startFakeCdpBrowser({ + commandResult: ({ method }) => + method === "Page.captureScreenshot" + ? { data: bytes.toString("base64") } + : undefined, + }); + onTestFinished(async () => { + await browser.close(); + }); + const result = await new CdpBrowserProvider().captureScreenshot( + captureWebScreenshotInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + }), + ); + expect(result).toMatchObject({ + ok: false, + error: { reason: "protocol_error" }, + }); + }); it.each(["loader-main", "loader-reloaded"])( "distinguishes %s for the same URL", async (screenshotDocumentLoader) => { From 6a04f922e16ad4d171ba0c07e32238efdaf43e2d Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:51:23 -0400 Subject: [PATCH 11/12] fix(javascript): parse explicit CommonJS wrapper syntax (#2475) --- .../javascript/javascriptSourceParser.ts | 59 +++++++++++++------ ...eferenceSourceTypeScriptExtensions.test.ts | 25 ++++++++ .../process/javascriptSourceModes.test.ts | 39 ++++++++++++ 3 files changed, 106 insertions(+), 17 deletions(-) create mode 100644 tests/boundary/process/javascriptSourceModes.test.ts diff --git a/src/domain/javascript/javascriptSourceParser.ts b/src/domain/javascript/javascriptSourceParser.ts index ce5619ead..630d5e9bf 100644 --- a/src/domain/javascript/javascriptSourceParser.ts +++ b/src/domain/javascript/javascriptSourceParser.ts @@ -37,10 +37,19 @@ export const parserPluginsForPath = (sourcePath?: string): ParserPlugin[] => { : ["decorators-legacy", typescript]; }; -/** `.mjs` and `.mts` are modules by extension; other paths leave the mode to syntax. */ +/** `.mjs` and `.mts` explicitly select module mode. */ export const isExplicitModuleSourcePath = (path?: string): boolean => /\.m[jt]s$/iu.test(path ?? ""); +const runtimeSourceTypeForPath = ( + path?: string, +): "commonjs" | "module" | "unambiguous" => + /\.cjs$/iu.test(path ?? "") + ? "commonjs" + : isExplicitModuleSourcePath(path) + ? "module" + : "unambiguous"; + export interface RecoveredJavaScriptParse { readonly file: ParsedJavaScriptSource; /** Errors that apply only if a source of unknown mode is an ES module. */ @@ -58,25 +67,25 @@ export const parseRecoveredJavaScript = ( sourcePath: string | undefined, options: Omit, ): RecoveredJavaScriptParse => { - // `.cjs` is CommonJS by extension, so module-only errors cannot apply. - if (/\.cjs$/iu.test(sourcePath ?? "")) + const sourceType = runtimeSourceTypeForPath(sourcePath); + if (sourceType !== "unambiguous") { + const normalizedOptions = { ...options }; + if (sourceType === "commonjs") { + // Babel owns these wrapper semantics in CommonJS mode and rejects + // explicit script overrides, including the reference-import options. + delete normalizedOptions.allowAwaitOutsideFunction; + delete normalizedOptions.allowReturnOutsideFunction; + delete normalizedOptions.allowNewTargetOutsideFunction; + } return { file: parse(source, { - ...options, - sourceType: "script", - errorRecovery: true, - }), - moduleOnlyErrors: [], - }; - if (isExplicitModuleSourcePath(sourcePath)) - return { - file: parse(source, { - ...options, - sourceType: "module", + ...normalizedOptions, + sourceType, errorRecovery: true, }), moduleOnlyErrors: [], }; + } const file = parse(source, { ...options, sourceType: "unambiguous", @@ -98,18 +107,34 @@ export const parseRecoveredJavaScript = ( }; }; -/** Parse JavaScript or TypeScript once without attaching comments to AST nodes. */ +/** Parse inert JavaScript or TypeScript facts without attaching comments to AST nodes. */ export const parseJavaScriptSource = ( source: string, sourcePath?: string, ): ParsedJavaScriptSource | null => { try { - return parse(source, { - sourceType: "unambiguous", + const runtimeSourceType = runtimeSourceTypeForPath(sourcePath); + const commonjs = runtimeSourceType === "commonjs"; + const options = { errorRecovery: true, attachComment: false, plugins: parserPluginsForPath(sourcePath), + }; + const file = parse(source, { + ...options, + // Inert artifact analysis retains module declarations even in .cjs assets. + // Infer their syntax here; reference imports keep filename-mode diagnostics. + sourceType: commonjs ? "unambiguous" : runtimeSourceType, + allowReturnOutsideFunction: commonjs, + allowNewTargetOutsideFunction: commonjs, }); + // Unambiguous mode retains module-only diagnostics after choosing a script. + // The .cjs extension establishes wrapper mode, so obtain its own diagnostics. + return commonjs && + file.program.sourceType === "script" && + file.errors.length > 0 + ? parse(source, { ...options, sourceType: "commonjs" }) + : file; } catch (cause: unknown) { // Unparseable source is represented by the null return. void cause; diff --git a/tests/boundary/filesystem/referenceSourceTypeScriptExtensions.test.ts b/tests/boundary/filesystem/referenceSourceTypeScriptExtensions.test.ts index d537a1dca..7b3d8189c 100644 --- a/tests/boundary/filesystem/referenceSourceTypeScriptExtensions.test.ts +++ b/tests/boundary/filesystem/referenceSourceTypeScriptExtensions.test.ts @@ -6,6 +6,31 @@ import { expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { importReferenceSource } from "../../support/referenceSourceResourceScope.js"; +it("imports valid CommonJS wrapper syntax without inventing a parse failure", async () => { + const root = await createTestTempDirectory("rea-reference-commonjs-mode-"); + await Promise.all([ + writeFile( + join(root, "main.cjs"), + 'void new.target; require("./dep.cjs"); return;\n', + ), + writeFile(join(root, "dep.cjs"), "module.exports = 1;\n"), + ]); + const imported = await importReferenceSource({ + root, + caller: "native-commonjs-source", + policy: { secretPatterns: [] }, + }); + if (!imported.ok) throw imported.error; + expect(imported.value.parse_failures).toEqual([]); + expect(imported.value.relationships).toContainEqual({ + from_path: "main.cjs", + to: "dep.cjs", + kind: "requires", + resolution: "internal", + parse_state: "parsed", + }); +}); + it.each([ ["main.ts", "dep.js", "dep.ts"], ["main.mts", "dep.mjs", "dep.mts"], diff --git a/tests/boundary/process/javascriptSourceModes.test.ts b/tests/boundary/process/javascriptSourceModes.test.ts new file mode 100644 index 000000000..64c15a2d8 --- /dev/null +++ b/tests/boundary/process/javascriptSourceModes.test.ts @@ -0,0 +1,39 @@ +import { expect } from "vitest"; + +import { parseJavaScriptSource } from "../../../src/domain/javascript/javascriptSourceParser.js"; +import { processTest } from "../../support/process/processFixture.js"; + +for (const source of [ + "return;", + "void new.target;", + "var await = 1; return;", + "with ({ value: 1 }) { void value; } void new.target;", +]) { + processTest( + `accepts Node CommonJS syntax while retaining module errors: ${source}`, + async ({ processes }) => { + const commonjs = await processes.run( + process.execPath, + ["--check", "--input-type=commonjs"], + { input: source, timeoutMs: 5_000 }, + ); + expect(commonjs.exitCode).toBe(0); + const module = await processes.run( + process.execPath, + ["--check", "--input-type=module"], + { input: source, timeoutMs: 5_000 }, + ); + expect(module.exitCode).not.toBe(0); + + const parsed = parseJavaScriptSource(source, "main.cjs"); + expect(parsed).not.toBeNull(); + expect(parsed?.errors).toEqual([]); + expect( + parseJavaScriptSource(source, "main.mjs")?.errors.length, + ).toBeGreaterThan(0); + expect( + parseJavaScriptSource(source, "main.mts")?.errors.length, + ).toBeGreaterThan(0); + }, + ); +} From a07ceaec36346a7c73e66efe35c8af4654029c84 Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:51:29 -0400 Subject: [PATCH 12/12] fix(config): reject NUL bytes in Hopper loader arguments (#2474) --- src/config.test.ts | 12 ++++++++++++ src/config/parsers.ts | 13 +++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/config.test.ts b/src/config.test.ts index eb784802f..67db7e111 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -3,6 +3,18 @@ import { describe, expect, it } from "vitest"; import { parseConfig } from "./config/parseConfig.js"; import { projectAnalysisError } from "./domain/analysisErrorProjection.js"; +it("reports a configuration failure for a NUL in Hopper loader arguments", () => { + const result = parseConfig({ + HOPPER_LOADER_ARGS_JSON: JSON.stringify(["--option", "value\0suffix"]), + }); + if (result.ok) + throw new Error("NUL argv cannot be passed to a child process"); + expect(result.error).toMatchObject({ + settings: [{ setting: "HOPPER_LOADER_ARGS_JSON" }], + }); + expect(result.error.message).toContain("NUL"); +}); + describe("runtime configuration", () => { it("allows target-free startup and applies runtime defaults", () => { const empty = parseConfig({}); diff --git a/src/config/parsers.ts b/src/config/parsers.ts index fc842f884..52feae622 100644 --- a/src/config/parsers.ts +++ b/src/config/parsers.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { ConfigurationError } from "../domain/configurationErrors.js"; +import { nulFreeStringSchema } from "../domain/localPath.js"; import { err, ok, type Result } from "../domain/result.js"; import { safeParseJson } from "../domain/safeJson.js"; @@ -20,7 +21,8 @@ export const parseLoaderArgs = ( return parseConfiguredStringArray( encoded, "HOPPER_LOADER_ARGS_JSON", - z.string(), + nulFreeStringSchema, + "must encode an array of strings without NUL bytes", ); }; @@ -28,6 +30,7 @@ const parseConfiguredStringArray = ( encoded: string, setting: string, itemSchema: z.ZodString, + requirement = "must encode an array of strings", ): Result => { const decoded = safeParseJson(encoded); if (!decoded.ok) @@ -37,13 +40,7 @@ const parseConfiguredStringArray = ( const parsed = z.array(itemSchema).safeParse(decoded.value); return parsed.success ? ok(parsed.data) - : err( - configuredArrayError( - setting, - "must encode an array of strings", - parsed.error, - ), - ); + : err(configuredArrayError(setting, requirement, parsed.error)); }; const configuredArrayError = (