From aa253bd13fb2b57d7ed426df734c5d30f6babec5 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:55:34 +0800 Subject: [PATCH] fix(electron): scope IPC pairing to application ownership --- .../javascript/ElectronBoundaryAnalysis.ts | 93 ++++++++------ .../javascript/ElectronBoundaryGraph.ts | 6 +- .../ElectronBoundaryGraphCallPaths.ts | 14 +-- .../javascript/ElectronBoundaryGraphIpc.ts | 29 +++-- .../JavaScriptArtifactGraphBuilder.ts | 75 +++++++---- .../JavaScriptArtifactReconstruction.ts | 5 +- tests/boundary/cli/electronScopedIpc.test.ts | 116 ++++++++++++++++++ tests/fixtures/javascriptArtifactGraph.ts | 2 +- 8 files changed, 260 insertions(+), 80 deletions(-) diff --git a/src/application/javascript/ElectronBoundaryAnalysis.ts b/src/application/javascript/ElectronBoundaryAnalysis.ts index d5d6ab5962..67f4615b01 100644 --- a/src/application/javascript/ElectronBoundaryAnalysis.ts +++ b/src/application/javascript/ElectronBoundaryAnalysis.ts @@ -43,49 +43,72 @@ export const collectElectronIpcRecords = ( ) .sort((left, right) => compareUnicodeCodePoints(left.key, right.key)); -/** Match only exact literal channels and retain ambiguous matches separately. */ -export const classifyElectronIpcPairings = ( - records: readonly ElectronIpcRecord[], -): readonly ElectronIpcPairingState[] => { - const handlers = records.filter(isMainHandler); - return records.filter(isPairableRendererTransmission).map((transmission) => { - const candidates = - transmission.finding.channel === null - ? [] - : handlers.filter( - (handler) => - (handler.finding.channel === transmission.finding.channel || - (handler.finding.receiver !== undefined && - handler.finding.channel === null)) && - compatibleIpcModes(transmission.finding, handler.finding), - ); - return { - transmission, - candidates, - status: - candidates.length === 1 && candidates[0]?.finding.receiver === undefined - ? "paired" - : candidates.length > 0 - ? "ambiguous" - : "unpaired", - }; - }); -}; +/** One interpretation shared by summary and every graph pairing consumer. */ +export interface ElectronIpcContext { + readonly records: readonly ElectronIpcRecord[]; + readonly owners: ReadonlyMap; + readonly states: readonly ElectronIpcPairingState[]; + readonly pairings: readonly ElectronIpcPairing[]; +} -/** Return only pairings supported by one unique literal main handler. */ -export const unambiguousElectronIpcPairings = ( - records: readonly ElectronIpcRecord[], -): readonly ElectronIpcPairing[] => - classifyElectronIpcPairings(records).flatMap((state) => { +/** Equal channel text only establishes a pairing within one proven application owner. */ +export const createElectronIpcContext = ( + analysis: JavaScriptArtifactAnalysis, + applicationOwner: (file: JavaScriptArtifactFile) => string | null, +): ElectronIpcContext => { + const records = collectElectronIpcRecords(analysis); + const owners = new Map( + records.map((record) => [record.key, applicationOwner(record.file)]), + ); + const handlers = records.filter(isMainHandler); + const states: ElectronIpcPairingState[] = records + .filter(isPairableRendererTransmission) + .map((transmission) => { + const owner = owners.get(transmission.key); + const matching = + transmission.finding.channel === null + ? [] + : handlers.filter( + (handler) => + (handler.finding.channel === transmission.finding.channel || + (handler.finding.receiver !== undefined && + handler.finding.channel === null)) && + compatibleIpcModes(transmission.finding, handler.finding), + ); + const candidates = matching.filter( + (handler) => owners.get(handler.key) === owner, + ); + // Unknown ownership can conceal competing dispatch contexts. Different owners + // do not prove runtime isolation, so retain ambiguity rather than absence. + const uncertain = + owner == null || + matching.some((handler) => owners.get(handler.key) == null); + return { + transmission, + candidates, + status: + !uncertain && + candidates.length === 1 && + candidates[0]?.finding.receiver === undefined + ? "paired" + : uncertain || matching.length > 0 + ? "ambiguous" + : "unpaired", + }; + }); + const pairings = states.flatMap((state) => { const handler = state.candidates[0]; return state.status === "paired" && handler !== undefined ? [{ transmission: state.transmission, handler }] : []; }); + return { records, owners, states, pairings }; +}; /** Summarize static Electron findings without treating gaps as absence. */ export const summarizeElectronBoundaries = ( analysis: JavaScriptArtifactAnalysis, + ipcContext: ElectronIpcContext, directoryPaths?: ReadonlySet, ): ElectronBoundarySummary => { const files = new Map(analysis.files.map(({ file }) => [file.path, file])); @@ -113,8 +136,8 @@ export const summarizeElectronBoundaries = ( finding, })), ); - const ipc = collectElectronIpcRecords(analysis); - const pairing = classifyElectronIpcPairings(ipc); + const ipc = ipcContext.records; + const pairing = ipcContext.states; const literalChannels = new Set( ipc.flatMap(({ finding }) => finding.channel === null ? [] : [finding.channel], diff --git a/src/application/javascript/ElectronBoundaryGraph.ts b/src/application/javascript/ElectronBoundaryGraph.ts index d77e496fbe..752c3294f0 100644 --- a/src/application/javascript/ElectronBoundaryGraph.ts +++ b/src/application/javascript/ElectronBoundaryGraph.ts @@ -1,3 +1,4 @@ +import type { ElectronIpcContext } from "./ElectronBoundaryAnalysis.js"; import type { JavaScriptArtifactGraphContext } from "./JavaScriptArtifactGraphContext.js"; import { addElectronIpcBoundaries } from "./ElectronBoundaryGraphIpc.js"; import { addElectronNativeBoundaries } from "./ElectronBoundaryGraphNative.js"; @@ -7,9 +8,10 @@ import { addElectronCallPaths } from "./ElectronBoundaryGraphCallPaths.js"; /** Add every static Electron process and security-boundary fact to the JAG. */ export const addElectronBoundaries = ( context: JavaScriptArtifactGraphContext, + ipc: ElectronIpcContext, ): void => { addElectronWindowBoundaries(context); - addElectronIpcBoundaries(context); + addElectronIpcBoundaries(context, ipc); addElectronNativeBoundaries(context); - addElectronCallPaths(context); + addElectronCallPaths(context, ipc); }; diff --git a/src/application/javascript/ElectronBoundaryGraphCallPaths.ts b/src/application/javascript/ElectronBoundaryGraphCallPaths.ts index f835ea092a..efae2e4f00 100644 --- a/src/application/javascript/ElectronBoundaryGraphCallPaths.ts +++ b/src/application/javascript/ElectronBoundaryGraphCallPaths.ts @@ -3,10 +3,9 @@ import type { ApplicationNode } from "../../domain/javascript/javascriptApplicat import type { ElectronCallPath } from "../../domain/javascript/electronStaticAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { sha256Text } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js"; -import { - collectElectronIpcRecords, - unambiguousElectronIpcPairings, - type ElectronIpcRecord, +import type { + ElectronIpcContext, + ElectronIpcRecord, } from "./ElectronBoundaryAnalysis.js"; import { addElectronInferenceEdge, @@ -30,13 +29,14 @@ interface ProjectedPath { /** Retain context-specific wrapper paths, then join only unique IPC records. */ export const addElectronCallPaths = ( context: JavaScriptArtifactGraphContext, + ipc: ElectronIpcContext, ): void => { const projected: ProjectedPath[] = []; for (const { file, javascript } of context.analysis.files) { if (javascript === null) continue; const coverage = javascriptAnalysisCoverage(javascript); for (const path of javascript.electron.call_paths ?? []) { - const pathId = sha256Text(JSON.stringify(path)); + const pathId = sha256Text(JSON.stringify([file.path, path])); const nodes = path.steps.map((step, index) => context.accumulator.addNode({ kind: "javascript-expression", @@ -137,9 +137,7 @@ export const addElectronCallPaths = ( projected.push({ file, path, coverage, nodes }); } } - for (const { transmission, handler } of unambiguousElectronIpcPairings( - collectElectronIpcRecords(context.analysis), - )) { + for (const { transmission, handler } of ipc.pairings) { const sources = projected.filter( (p) => p.path.entry_kind === "bridge-member" && matches(p, transmission), ); diff --git a/src/application/javascript/ElectronBoundaryGraphIpc.ts b/src/application/javascript/ElectronBoundaryGraphIpc.ts index 23b694ffdb..9cad1de76a 100644 --- a/src/application/javascript/ElectronBoundaryGraphIpc.ts +++ b/src/application/javascript/ElectronBoundaryGraphIpc.ts @@ -15,10 +15,9 @@ import { astObservationEvidence, staticInferenceEvidence, } from "./JavaScriptArtifactGraphEvidence.js"; -import { - collectElectronIpcRecords, - unambiguousElectronIpcPairings, - type ElectronIpcRecord, +import type { + ElectronIpcContext, + ElectronIpcRecord, } from "./ElectronBoundaryAnalysis.js"; import { addElectronAstEdge, @@ -30,6 +29,7 @@ import { interface IpcGraphState { readonly context: JavaScriptArtifactGraphContext; readonly records: readonly ElectronIpcRecord[]; + readonly ipc: ElectronIpcContext; readonly channels: Map; readonly handlers: Map; readonly sources: Map; @@ -54,10 +54,12 @@ interface ValidationObservationInput { /** Project IPC syntax, handler locations, validations, and exact pairings. */ export const addElectronIpcBoundaries = ( context: JavaScriptArtifactGraphContext, + ipc: ElectronIpcContext, ): void => { const state: IpcGraphState = { context, - records: collectElectronIpcRecords(context.analysis), + records: ipc.records, + ipc, channels: new Map(), handlers: new Map(), sources: new Map(), @@ -82,7 +84,12 @@ const addIpcRecord = ( ); if (source === undefined) return; state.sources.set(record.key, source); - const channel = addChannelNode(context, record, coverage); + const channel = addChannelNode( + context, + record, + coverage, + state.ipc.owners.get(record.key) ?? null, + ); state.channels.set(record.key, channel); if (record.finding.mode === "listen" || record.finding.mode === "handle") { const handler = addHandlerNode({ @@ -117,6 +124,7 @@ const addChannelNode = ( context: JavaScriptArtifactGraphContext, record: ElectronIpcRecord, coverage: JavaScriptArtifactGraphCoverage, + owner: string | null, ): ApplicationNode => { const { finding, file } = record; const channelKey = @@ -125,7 +133,10 @@ const addChannelNode = ( : `literal:${finding.channel}`; const identityKey = finding.receiver === undefined - ? channelKey + ? JSON.stringify([ + owner === null ? ["unknown", file.path] : ["application", owner], + channelKey, + ]) : JSON.stringify([ file.path, file.sha256, @@ -204,7 +215,7 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => { const handlerKey = `${finding.side}:${finding.operation}:${finding.channel ?? finding.channel_expression}:${javascriptSourceRangeKey(handlerRange)}`; const identityKey = finding.receiver === undefined - ? handlerKey + ? JSON.stringify([file.path, handlerKey]) : JSON.stringify([ file.path, finding.module_runtime ?? null, @@ -280,7 +291,7 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => { }; const addLiteralPairings = (state: IpcGraphState): void => { - for (const pairing of unambiguousElectronIpcPairings(state.records)) { + for (const pairing of state.ipc.pairings) { const source = state.sources.get(pairing.transmission.key); const handler = state.handlers.get(pairing.handler.key); const coverage = coverageFor(state.context, pairing.transmission.file); diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index 6086968853..dd0f58e97d 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -1,3 +1,4 @@ +import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js"; import { ELECTRON_IDENTITY_LIMITATION } from "../../domain/javascript/javascriptElectronMemberWrites.js"; import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; import { @@ -13,6 +14,7 @@ import { selfReferenceOmissions, dependencyManifest, applicationContainerIdentity, + applicationRootPath, type JavaScriptArtifactGraphContext, } from "./JavaScriptArtifactGraphContext.js"; import { @@ -45,8 +47,9 @@ import { import { addJavaScriptBundlerNodes } from "./JavaScriptArtifactGraphBundlers.js"; import { addElectronBoundaries } from "./ElectronBoundaryGraph.js"; import { - classifyElectronIpcPairings, - collectElectronIpcRecords, + createElectronIpcContext, + summarizeElectronBoundaries, + type ElectronIpcContext, } from "./ElectronBoundaryAnalysis.js"; /** @@ -58,21 +61,33 @@ export function* buildImmutableJavaScriptArtifactGraphSteps( snapshot: ArtifactInventorySnapshot, fileSet: JavaScriptArtifactFileSet, analysis: JavaScriptArtifactAnalysis, -): Generator { +): Generator< + void, + { + readonly graph: JavaScriptApplicationGraph; + readonly electronSummary: ElectronBoundarySummary; + } +> { const input = yield* buildJavaScriptArtifactGraphInputSteps( snapshot, fileSet, analysis, ); // The input was built here and is not retained, so it can be transferred. - return yield* sealTransferredJavaScriptApplicationGraphSteps(input); + return { + graph: yield* sealTransferredJavaScriptApplicationGraphSteps(input.graph), + electronSummary: input.electronSummary, + }; } function* buildJavaScriptArtifactGraphInputSteps( snapshot: ArtifactInventorySnapshot, fileSet: JavaScriptArtifactFileSet, analysis: JavaScriptArtifactAnalysis, -): Generator { +): Generator< + void, + { readonly graph: unknown; readonly electronSummary: ElectronBoundarySummary } +> { const accumulator = new JavaScriptArtifactGraphAccumulator(); const root = createJavaScriptArtifactRootNode(accumulator, snapshot); const parsedPackages = new Set( @@ -183,7 +198,10 @@ function* buildJavaScriptArtifactGraphInputSteps( yield* addJavaScriptModuleRelationshipsSteps(projectionContext); const findingLimitations = yield* addJavaScriptStaticFindingsSteps(projectionContext); - addElectronBoundaries(projectionContext); + const ipc = createElectronIpcContext(analysis, (file) => + applicationRootPath(projectionContext, file), + ); + addElectronBoundaries(projectionContext, ipc); yield; addJavaScriptHtmlRoles(projectionContext); yield; @@ -191,19 +209,31 @@ function* buildJavaScriptArtifactGraphInputSteps( yield; const coverage = graphCoverage(context); return { - schema: "JavaScriptApplicationGraph", - root_node_ids: - packageRoots.length === 0 - ? [root.node_id] - : packageRoots.map(({ node_id: id }) => id), - nodes: accumulator.nodes(), - edges: accumulator.edges(), - coverage, - limitations: [ - ...bundlerLimitations, - ...findingLimitations, - ...graphLimitations(context, coverage.status, relationshipOmissions), - ], + electronSummary: summarizeElectronBoundaries( + analysis, + ipc, + context.directoryPaths, + ), + graph: { + schema: "JavaScriptApplicationGraph", + root_node_ids: + packageRoots.length === 0 + ? [root.node_id] + : packageRoots.map(({ node_id: id }) => id), + nodes: accumulator.nodes(), + edges: accumulator.edges(), + coverage, + limitations: [ + ...bundlerLimitations, + ...findingLimitations, + ...graphLimitations( + context, + coverage.status, + relationshipOmissions, + ipc, + ), + ], + }, }; } @@ -295,9 +325,10 @@ const graphLimitations = ( context: JavaScriptArtifactGraphContext, coverage: "complete" | "partial" | "unknown" | "unavailable", relationshipOmissions: JavaScriptModuleRelationshipOmissions, + ipcContext: ElectronIpcContext, ): string[] => { - const ipc = collectElectronIpcRecords(context.analysis); - const pairings = classifyElectronIpcPairings(ipc); + const ipc = ipcContext.records; + const pairings = ipcContext.states; const electronFindings = context.analysis.files.reduce( (count, { javascript }) => count + @@ -348,7 +379,7 @@ const graphLimitations = ( : []), ...(pairings.some(({ status }) => status === "ambiguous") ? [ - "Some literal IPC channels have multiple compatible main handlers; ambiguous calls are not paired to any handler.", + "Some IPC transmissions have competing handlers or unresolved application ownership; ambiguous calls are not paired to any handler.", ] : []), ...(context.analysis.files.some( diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 17e03aeee2..3d4e2fc4f1 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -46,7 +46,6 @@ import { readJavaScriptArtifactFiles } from "../../artifacts/javascript/JavaScri import { buildImmutableJavaScriptArtifactGraphSteps } from "./JavaScriptArtifactGraphBuilder.js"; import { scanCanonicalArtifactInventoryInScope } from "../../artifacts/inventory/scanCanonical.js"; import { createJavaScriptNextAppContext } from "./JavaScriptNextAppContext.js"; -import { summarizeElectronBoundaries } from "./ElectronBoundaryAnalysis.js"; import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js"; import type { ProgressReporter } from "../ProgressReporter.js"; import { @@ -281,7 +280,7 @@ export const reconstructJavaScriptArtifact = async ( "seal_javascript_application_graph", "Sealing the validated application graph", ); - const graph = await completeJavaScriptAnalysisSteps( + const { graph, electronSummary } = await completeJavaScriptAnalysisSteps( applicationGraphSteps, ); await reportCompletedPhase( @@ -366,7 +365,7 @@ export const reconstructJavaScriptArtifact = async ( : { source_selection: files.source_selection }), graph, semantic_graph: semanticGraph, - electron_summary: summarizeElectronBoundaries(analysis, directoryPaths), + electron_summary: electronSummary, statistics: { relevant_files: files.files.length + files.unavailable_relevant_files.length, diff --git a/tests/boundary/cli/electronScopedIpc.test.ts b/tests/boundary/cli/electronScopedIpc.test.ts index 78721ce5d4..beab7c507f 100644 --- a/tests/boundary/cli/electronScopedIpc.test.ts +++ b/tests/boundary/cli/electronScopedIpc.test.ts @@ -112,3 +112,119 @@ cliTest.for(["CLI", "stdio MCP"] as const)( ).toEqual([]); }, ); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "pairs literal IPC within proven application owners through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-application-ipc-"); + await writeFixtureFiles(root, { + "a/package.json": JSON.stringify({ name: "a", main: "main.cjs" }), + "a/main.cjs": `const { ipcMain, clipboard } = require("electron"); + ipcMain.handle("shared", (event, value) => clipboard.writeText(value));`, + "a/preload.cjs": `const { ipcRenderer, contextBridge } = require("electron"); + contextBridge.exposeInMainWorld("api", { read: (value) => ipcRenderer.invoke("shared", value) });`, + "b/package.json": JSON.stringify({ name: "b", main: "main.cjs" }), + "b/main.cjs": `const { ipcMain, clipboard } = require("electron"); + ipcMain.handle("shared", (event, value) => clipboard.writeText(value));`, + "b/preload.cjs": `const { ipcRenderer, contextBridge } = require("electron"); + contextBridge.exposeInMainWorld("api", { read: (value) => ipcRenderer.invoke("shared", value) });`, + }); + const application = await analyzeJavaScriptDirectory(surface, cli, root); + expect(application.summary.ipc).toMatchObject({ + operations: 4, + paired_renderer_transmissions: 2, + ambiguous_renderer_transmissions: 0, + }); + const pairs = application.graph.edges.filter( + ({ evidence }) => + evidence.extractor.operation === "pair-ipc-literal-channel", + ); + const joins = application.graph.edges.filter( + ({ evidence }) => + evidence.extractor.operation === "join-electron-wrapper-paths", + ); + expect(joins).toHaveLength(2); + expect( + new Set(joins.map(({ target_node_id }) => target_node_id)).size, + ).toBe(2); + const nodes = new Map( + application.graph.nodes.map((node) => [node.node_id, node]), + ); + for (const join of joins) { + const target = nodes.get(join.target_node_id); + expect(target?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + evidence: expect.objectContaining({ + location: expect.objectContaining({ + available: true, + value: expect.objectContaining({ + source: join.properties.handler_path, + }), + }), + }), + }), + ]), + ); + } + expect(pairs).toHaveLength(2); + expect( + new Set(pairs.map(({ target_node_id }) => target_node_id)).size, + ).toBe(2); + expect( + application.graph.nodes.filter(({ kind }) => kind === "ipc-handler"), + ).toHaveLength(2); + for (const edge of pairs) { + expect(edge.evidence.location.available).toBe(true); + const location = edge.evidence.location; + if (!location.available || location.value.kind !== "source-range") + throw new Error("IPC pairing omitted its authored source location"); + expect(edge.properties.handler_path).toBe( + location.value.source.startsWith("a/") ? "a/main.cjs" : "b/main.cjs", + ); + } + expect( + application.graph.nodes.filter(({ kind }) => kind === "ipc-channel"), + ).toHaveLength(2); + }, +); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "retains unknown IPC applicability across distinct or shared application owners through %s", + async (surface, { cli }) => { + for (const sharedSource of [false, true]) { + const root = await createTestTempDirectory( + "rea-unknown-application-ipc-", + ); + await writeFixtureFiles(root, { + "a/package.json": JSON.stringify({ name: "a", main: "main.cjs" }), + "a/main.cjs": sharedSource + ? `require("../shared.cjs");` + : `module.exports = {};`, + "b/package.json": JSON.stringify({ name: "b", main: "main.cjs" }), + "b/main.cjs": `const { ipcMain } = require("electron"); + ${sharedSource ? 'require("../shared.cjs");' : ""} + ipcMain.handle("shared", () => "result");`, + [sharedSource ? "shared.cjs" : "a/preload.cjs"]: + `const { ipcRenderer } = require("electron"); ipcRenderer.invoke("shared");`, + }); + const application = await analyzeJavaScriptDirectory(surface, cli, root); + expect(application.summary.ipc).toMatchObject({ + operations: 2, + paired_renderer_transmissions: 0, + ambiguous_renderer_transmissions: 1, + unpaired_literal_renderer_transmissions: 0, + }); + expect( + application.graph.edges.filter(({ evidence }) => + ["pair-ipc-literal-channel", "join-electron-wrapper-paths"].includes( + evidence.extractor.operation, + ), + ), + ).toEqual([]); + expect( + application.graph.nodes.filter(({ kind }) => kind === "ipc-handler"), + ).toHaveLength(1); + } + }, +); diff --git a/tests/fixtures/javascriptArtifactGraph.ts b/tests/fixtures/javascriptArtifactGraph.ts index fe999410e6..0ec26829e6 100644 --- a/tests/fixtures/javascriptArtifactGraph.ts +++ b/tests/fixtures/javascriptArtifactGraph.ts @@ -8,4 +8,4 @@ export const immutableArtifactGraph = ( ): Promise => completeJavaScriptAnalysisSteps( buildImmutableJavaScriptArtifactGraphSteps(...args), - ); + ).then(({ graph }) => graph);