diff --git a/docs/electron-observation.md b/docs/electron-observation.md index 37c38712c..eb98a41f3 100644 --- a/docs/electron-observation.md +++ b/docs/electron-observation.md @@ -15,7 +15,11 @@ when both Evidence sets already exist. The request supplies a literal-port loopback endpoint and, for inspection, a target ID. REA accepts local hostless `file://` URLs that resolve to regular -files; remote hosts, encoded path separators, and nonexistent paths are +files, including pages a packaged application serves from inside `app.asar`. +For those, the first `.asar` path component that is a regular file is the +archive, the remainder must name a regular file in its header, and the reported +path is the canonical archive path joined with that member. Remote hosts, +encoded path separators, ASAR directories or links, and nonexistent paths are rejected. The endpoint exposes every eligible target and its local path metadata. @@ -120,3 +124,5 @@ projection is intentionally target-only and partial: it binds the selected application path and capture outcome to the static graph, while frames, scripts, workers, and execution claims remain unavailable until a separate passive runtime capture provides them. + +ASAR observation reads the header through one open file handle, validates its declared lengths against the archive size, and limits encoded JSON headers to 16 MiB before parsing. The header cache also retains at most 16 MiB of encoded headers across eight archives. Oversized headers produce a scope refusal. diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 6594f0733..736a86198 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -449,3 +449,15 @@ and reconstruction cannot treat it as unchanged. canonical `integrity_contradictions` records and marks application graph coverage partial when any mismatch is continued. Contradicted nested ASARs remain opaque. + +`extract_artifact` accepts the same policy in MCP and as `--integrity-policy` +on `rea extract-artifact`. With `record-and-continue` it writes the observed +bytes and returns the `integrity_contradictions` records for the extracted +files. An active ASAR unpacked entry whose companion file is absent cannot be +materialized; extraction fails as `unavailable` with that logical path. + +Packaging tools commonly sign or strip `.asar.unpacked` native binaries after +writing the archive header, so their declared hashes no longer match. Electron +does not check unpacked companions against those hashes at runtime. Integrity +failures for such entries name this cause and the operation to rerun with +`record-and-continue`; REA does not exempt unpacked entries silently. diff --git a/src/application/javascript/JavaScriptArtifactReconstructionInput.ts b/src/application/javascript/JavaScriptArtifactReconstructionInput.ts index f2988ee2b..9c72fd36f 100644 --- a/src/application/javascript/JavaScriptArtifactReconstructionInput.ts +++ b/src/application/javascript/JavaScriptArtifactReconstructionInput.ts @@ -1,10 +1,11 @@ import { z } from "zod"; +import { artifactIntegrityPolicySchema } from "../../domain/artifactIntegrityPolicy.js"; /** Local ASAR/directory reconstruction request. */ export const javascriptArtifactReconstructionInputSchema = z.strictObject({ input_path: z.string().min(1), format: z.enum(["auto", "asar", "directory"]).default("auto"), - integrity_policy: z.enum(["fail", "record-and-continue"]).default("fail"), + integrity_policy: artifactIntegrityPolicySchema, }); /** Parsed local reconstruction request. */ diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index 821455903..db3b21daa 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -183,6 +183,7 @@ class ArtifactClient implements AnalysisClient { inputFormat: this.target.format, outputRoot: parsed.output_root, environment: this.environment, + integrityPolicy: parsed.integrity_policy, }, options?.signal, ); diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts index 89c6291a7..5bf6e94cf 100644 --- a/src/artifacts/extraction/ArtifactExtraction.ts +++ b/src/artifacts/extraction/ArtifactExtraction.ts @@ -23,11 +23,13 @@ import { type ArtifactGraphManifest, type ArtifactNode, type ArtifactOccurrence, + type IntegrityContradiction, } from "../../domain/artifactGraph.js"; import { AnalysisUnsupportedTargetError } from "../../domain/analysisErrorCore.js"; import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; import { scanArtifactInventory } from "../inventory/ArtifactInventory.js"; +import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrityPolicy.js"; /** Local extraction input with the output root chosen by the adapter. */ export interface ArtifactExtractionInput { @@ -35,6 +37,7 @@ export interface ArtifactExtractionInput { readonly inputFormat: BinaryTarget["format"]; readonly outputRoot: string; readonly environment: Readonly; + readonly integrityPolicy: ArtifactIntegrityPolicyName; } /** Extract every regular inventory occurrence into an exclusively owned absent root. */ @@ -49,6 +52,7 @@ export const extractArtifact = async ( const snapshot = await scanArtifactInventory(sourcePath, { signal, environment: input.environment, + integrity: { mode: input.integrityPolicy }, }); return materializeArtifactInventory(input, sourcePath, snapshot, signal); }; @@ -100,6 +104,9 @@ export const materializeArtifactInventory = async ( manifest: snapshot.manifest, occurrences, nodes, + integrityContradictions: snapshot.integrity_contradictions.filter( + ({ occurrence_id: id }) => selectedIds.has(id), + ), }; const selected = activeOccurrences.map((occurrence) => { // REA never decrypts archive entries, so an encrypted entry makes the @@ -110,6 +117,24 @@ export const materializeArtifactInventory = async ( input.inputPath, `Archive entry ${occurrence.logical_path} is encrypted; REA does not decrypt archive entries, so the archive cannot be extracted completely`, ); + // Inventory keeps an ASAR unpacked entry whose companion file is absent, + // but extraction cannot materialize bytes it never observed. + if ( + occurrence.entry_kind === "file" && + occurrence.artifact_id === null && + occurrence.hash_status === "unavailable" + ) + throw new ArtifactReaderFailure( + "unavailable", + `ASAR unpacked companion bytes are unavailable for ${occurrence.logical_path}`, + undefined, + { + logicalPath: occurrence.logical_path, + declaredSha256: null, + calculatedSha256: null, + unpacked: true, + }, + ); if ( (occurrence.entry_kind !== "file" && occurrence.entry_kind !== "slice") || occurrence.artifact_id === null || @@ -293,8 +318,14 @@ const createExtractionResult = ( containment_verified: true, cleanup: { attempted: false, verified: true, residual_paths: [] }, provenance: [], + integrity_contradictions: inventory.integrityContradictions, limitations: [ "All regular files in the active artifact were materialized; nested archive contents remain represented by their containing file.", + ...(inventory.integrityContradictions.length === 0 + ? [] + : [ + `${String(inventory.integrityContradictions.length)} extracted file(s) contradict declared integrity metadata; their bytes are observed-untrusted.`, + ]), ], }); }; @@ -303,6 +334,7 @@ interface LoadedInventory { readonly manifest: ArtifactGraphManifest; readonly occurrences: ReadonlyMap; readonly nodes: ReadonlyMap; + readonly integrityContradictions: readonly IntegrityContradiction[]; } const collectOccurrences = ( diff --git a/src/browser/ElectronFileScope.ts b/src/browser/ElectronFileScope.ts index 63e173791..507edb233 100644 --- a/src/browser/ElectronFileScope.ts +++ b/src/browser/ElectronFileScope.ts @@ -1,5 +1,6 @@ -import { realpath, stat } from "node:fs/promises"; -import { isAbsolute } from "node:path"; +import type { Stats } from "node:fs"; +import { open, realpath, stat } from "node:fs/promises"; +import { isAbsolute, join, sep } from "node:path"; import { fileURLToPath } from "node:url"; /** Resolve one local file URL while rejecting remote hosts and encoded separators. */ @@ -31,14 +32,148 @@ export const authorizedElectronFile = async ( return undefined; } if (!isAbsolute(path) || path.includes("\0")) return undefined; - let canonical: string; try { - if (!(await stat(path)).isFile()) return undefined; - canonical = await realpath(path); + if ((await stat(path)).isFile()) return await realpath(path); } catch (cause: unknown) { - // Missing or unreadable files cannot authorize a local file. + // Electron serves packaged pages from inside app.asar, which the host + // filesystem cannot stat; resolve those through the archive header below. void cause; - return undefined; } - return canonical; + return await authorizedAsarMember(path); }; + +/** + * Resolve `.asar/` the way Electron's file protocol does: the + * first `.asar` path component that is a regular file is the archive, and the + * remainder must name a regular file in its header (packed or unpacked). + */ +const authorizedAsarMember = async ( + path: string, +): Promise => { + const segments = path.split(sep); + for (let index = 1; index < segments.length - 1; index += 1) { + if (!(segments[index] ?? "").toLowerCase().endsWith(".asar")) continue; + const archive = segments.slice(0, index + 1).join(sep); + const member = segments.slice(index + 1); + if (member.some((part) => part === "" || part === "." || part === "..")) + return undefined; + try { + const metadata = await stat(archive); + if (!metadata.isFile()) continue; + const canonical = await realpath(archive); + const files = await asarFiles(canonical, metadata); + return isAsarRegularFile(files, member) + ? join(canonical, ...member) + : undefined; + } catch (cause: unknown) { + // An unreadable or malformed archive cannot authorize its members. + void cause; + return undefined; + } + } + return undefined; +}; + +type ArchiveIdentity = Pick; + +// This is a retained-memory budget for JSON headers, shared across all archives. +const MAX_HEADER_BYTES = 16 * 1024 * 1024; +const MAX_CACHED_HEADERS = 8; +const headerCache = new Map< + string, + { + readonly identity: string; + readonly files: unknown; + readonly bytes: number; + } +>(); +const archiveIdentity = (metadata: ArchiveIdentity): string => + `${String(metadata.dev)}:${String(metadata.ino)}:${String(metadata.size)}:${String(metadata.mtimeMs)}`; + +/** Read the two Chromium pickle headers on one handle before allocating JSON. */ +const asarFiles = async ( + archive: string, + metadata: ArchiveIdentity, +): Promise => { + const identity = archiveIdentity(metadata); + const cached = headerCache.get(archive); + if (cached?.identity === identity) return cached.files; + const handle = await open(archive, "r"); + let files: unknown; + let jsonBytes: number; + try { + const opened = await handle.stat(); + if (!opened.isFile() || archiveIdentity(opened) !== identity) + throw new Error("ASAR identity changed before header read"); + const prefix = Buffer.alloc(16); + const read = await handle.read(prefix, 0, prefix.length, 0); + if (read.bytesRead !== prefix.length || prefix.readUInt32LE(0) !== 4) + throw new Error("Truncated or invalid ASAR size pickle"); + const headerBytes = prefix.readUInt32LE(4); + const payloadBytes = prefix.readUInt32LE(8); + jsonBytes = prefix.readUInt32LE(12); + if ( + headerBytes < 8 || + headerBytes > opened.size - 8 || + payloadBytes !== headerBytes - 4 || + jsonBytes > payloadBytes - 4 || + jsonBytes > MAX_HEADER_BYTES + ) + throw new Error( + "ASAR JSON header leaves the file or exceeds its memory budget", + ); + const json = Buffer.alloc(jsonBytes); + let offset = 0; + while (offset < json.length) { + const part = await handle.read( + json, + offset, + json.length - offset, + 16 + offset, + ); + if (part.bytesRead === 0) throw new Error("Truncated ASAR JSON header"); + offset += part.bytesRead; + } + if (archiveIdentity(await handle.stat()) !== identity) + throw new Error("ASAR identity changed during header read"); + const parsed: unknown = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(json), + ); + if (!isRecord(parsed) || !isRecord(parsed.files)) + throw new Error("ASAR header has no file tree"); + files = parsed.files; + } finally { + await handle.close(); + } + headerCache.delete(archive); + headerCache.set(archive, { identity, files, bytes: jsonBytes }); + const retainedBytes = () => + [...headerCache.values()].reduce((sum, entry) => sum + entry.bytes, 0); + while ( + headerCache.size > MAX_CACHED_HEADERS || + retainedBytes() > MAX_HEADER_BYTES + ) + headerCache.delete(headerCache.keys().next().value ?? archive); + return files; +}; + +const isAsarRegularFile = ( + files: unknown, + member: readonly string[], +): boolean => { + let node: unknown = { files }; + for (const part of member) { + const children = isRecord(node) ? node.files : undefined; + if (!isRecord(children) || !Object.hasOwn(children, part)) return false; + node = children[part]; + } + return ( + isRecord(node) && + typeof node.size === "number" && + !("files" in node) && + !("link" in node) + ); +}; + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/src/cli/artifactCommands.ts b/src/cli/artifactCommands.ts index 5f6957930..fe8efb9cb 100644 --- a/src/cli/artifactCommands.ts +++ b/src/cli/artifactCommands.ts @@ -195,13 +195,22 @@ const registerExtractionCommand = ( args: z.object({ path: z.string().describe("Application or package path"), }), - options: z.object({}), - run: ({ args }) => + options: z.object({ + integrityPolicy: z + .enum(["fail", "record-and-continue"]) + .default("fail") + .describe("Behavior when declared artifact integrity does not match"), + }), + alias: { + integrityPolicy: "integrity-policy", + }, + run: ({ args, options }) => logCliCommand(logger, "extract-artifact", () => runProviderAnalysis( args.path, "extract_artifact", { + integrity_policy: options.integrityPolicy, output_root: createArtifactExtractionDestination(), }, logger, diff --git a/src/contracts/artifactToolContracts.ts b/src/contracts/artifactToolContracts.ts index 744ea34c3..f1935e288 100644 --- a/src/contracts/artifactToolContracts.ts +++ b/src/contracts/artifactToolContracts.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { ToolContract } from "./toolContractTypes.js"; import { artifactOutputSchemas } from "./toolOutputSchemaGroups.js"; +import { artifactIntegrityPolicySchema } from "../domain/artifactIntegrityPolicy.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import { toolContractMetadata } from "./toolEffects.js"; import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js"; @@ -10,16 +11,19 @@ import { dylibResolutionInputSchema } from "../domain/apple/dylibResolution.js"; import { keyedArchiveInputSchema } from "../domain/apple/keyedArchive.js"; /** Exact caller boundary for deterministic artifact inventory. */ export const artifactInventoryInputSchema = z.strictObject({ - integrity_policy: z.enum(["fail", "record-and-continue"]).default("fail"), + integrity_policy: artifactIntegrityPolicySchema, }); /** Extraction needs no selector: it materializes every regular child file. */ -export const artifactExtractionInputSchema = z.strictObject({}); +export const artifactExtractionInputSchema = z.strictObject({ + integrity_policy: artifactIntegrityPolicySchema, +}); /** Provider input containing the destination chosen by the local adapter. */ -export const artifactExtractionExecutionSchema = z.strictObject({ - output_root: z.string().min(1), -}); +export const artifactExtractionExecutionSchema = + artifactExtractionInputSchema.extend({ + output_root: z.string().min(1), + }); const exampleInputSchema = z.record(z.string(), jsonValueSchema); const examples: Readonly>>> = { @@ -68,7 +72,7 @@ export const ARTIFACT_TOOL_CONTRACTS = [ ), artifact( "extract_artifact", - "Extract all regular files from the active archive or application package into a fresh temporary directory chosen by REA. The result includes its location. Rejects traversal and symlink escapes, never overwrites, enforces archive integrity checks, and verifies cleanup.", + "Extract all regular files from the active archive or application package into a fresh temporary directory chosen by REA. The result includes its location. Rejects traversal and symlink escapes, never overwrites, and verifies cleanup. Integrity mismatches fail by default; record-and-continue extracts the observed bytes and returns each mismatch as an explicitly untrusted integrity contradiction.", artifactExtractionInputSchema, ), artifact( diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index b4fe4ad64..b7f4629dd 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -190,8 +190,7 @@ export const analysisErrorUserMessage = (error: AnalysisError): string => { if (error.userMessage !== undefined) return error.userMessage; const standardMessage = standardErrorMessage(error._tag); if (standardMessage !== undefined) return standardMessage; - if (error instanceof ArtifactOperationError) - return artifactMessage(error.operation, error.reason, error.detail); + if (error instanceof ArtifactOperationError) return artifactMessage(error); if (error instanceof EvidenceReferenceError) return error.reason === "missing" ? `Evidence ${error.evidenceId} is not retained in this session. Supply complete inline Evidence, re-run its producer, or import its bundle before using this reference.` @@ -326,20 +325,37 @@ const START_FAILURE_TAGS: ReadonlySet = new Set([ "HopperStartError", ]); -const artifactMessage = ( - operation: ArtifactOperationError["operation"], - reason: ArtifactOperationError["reason"], - detail?: string, -): string => { +/** Operations that accept integrity_policy for declared-integrity mismatches. */ +const INTEGRITY_POLICY_OPERATIONS: ReadonlySet< + ArtifactOperationError["operation"] +> = new Set([ + "inventory_artifact", + "inspect_artifact", + "extract_artifact", + "analyze_javascript_application", +]); + +const artifactMessage = ({ + operation, + reason, + artifactDetails: details, + detail, +}: ArtifactOperationError): string => { if (reason === "cancelled") return "Artifact operation was cancelled. Start it again when ready."; if (reason === "limit") return "Artifact is too large to process safely. Narrow the requested path or use a smaller artifact."; if (reason === "path") return artifactPathMessage(detail); + if (reason === "unavailable" && details?.unpacked === true) + return `ASAR unpacked companion bytes are unavailable for ${details.logicalPath}. Select the archive in place with its .unpacked directory beside it, then retry.`; if (reason === "unavailable") return "Artifact processing is unavailable for the current target or host. Check artifact support and required tools."; - if (reason === "integrity" && operation === "analyze_javascript_application") - return "Artifact bytes contradict declared integrity. If expected, rerun analyze_javascript_application with integrity_policy=record-and-continue (CLI: --integrity-policy record-and-continue) to retain observed bytes as untrusted; otherwise get a fresh copy."; + if (reason === "integrity" && INTEGRITY_POLICY_OPERATIONS.has(operation)) + return `${ + details?.unpacked === true && details.calculatedSha256 !== null + ? `Declared ASAR integrity for unpacked entry ${details.logicalPath} contradicts its companion file; packaging tools commonly sign or strip unpacked native binaries after writing the archive.` + : "Artifact bytes contradict declared integrity." + } If expected, rerun ${operation === "inventory_artifact" ? "inspect_artifact" : operation} with integrity_policy=record-and-continue (CLI: --integrity-policy record-and-continue) to retain observed bytes as untrusted; otherwise get a fresh copy.`; if (reason === "format" || reason === "integrity") return "Artifact is invalid or has changed. Get a fresh copy and try again."; return "Artifact could not be read or written. Check file access and try again."; diff --git a/src/domain/artifactGraph.ts b/src/domain/artifactGraph.ts index 02f612a22..f585658c9 100644 --- a/src/domain/artifactGraph.ts +++ b/src/domain/artifactGraph.ts @@ -226,6 +226,7 @@ export const artifactExtractionResultSchema = z.object({ residual_paths: z.array(boundedRelativePathSchema), }), provenance: z.array(artifactCommandSchema), + integrity_contradictions: z.array(integrityContradictionSchema), limitations: z.array(z.string()), }); diff --git a/src/domain/artifactIntegrityPolicy.ts b/src/domain/artifactIntegrityPolicy.ts new file mode 100644 index 000000000..ccc43be58 --- /dev/null +++ b/src/domain/artifactIntegrityPolicy.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +/** + * Caller choice for declared-versus-observed artifact integrity mismatches. + * + * `fail` rejects the operation; `record-and-continue` keeps the observed bytes + * as explicitly untrusted Evidence and reports each contradiction. + */ +export const artifactIntegrityPolicySchema = z + .enum(["fail", "record-and-continue"]) + .default("fail"); + +/** Parsed artifact integrity policy. */ +export type ArtifactIntegrityPolicyName = z.infer< + typeof artifactIntegrityPolicySchema +>; diff --git a/src/domain/javascript/javascriptApplicationAnalysis.ts b/src/domain/javascript/javascriptApplicationAnalysis.ts index 4360f4190..6320ab20e 100644 --- a/src/domain/javascript/javascriptApplicationAnalysis.ts +++ b/src/domain/javascript/javascriptApplicationAnalysis.ts @@ -13,6 +13,7 @@ import { import { digestSchema } from "../digests.js"; import { prefixedDigestSchema } from "../digests.js"; import { integrityContradictionSchema } from "../artifactGraph.js"; +import { artifactIntegrityPolicySchema } from "../artifactIntegrityPolicy.js"; import { localPathStringSchema } from "../localPath.js"; const countSchema = z.number().int().min(0); @@ -21,7 +22,7 @@ const countSchema = z.number().int().min(0); export const analyzeJavaScriptApplicationInputSchema = z.strictObject({ input_path: localPathStringSchema, format: z.enum(["auto", "asar", "directory"]).default("auto"), - integrity_policy: z.enum(["fail", "record-and-continue"]).default("fail"), + integrity_policy: artifactIntegrityPolicySchema, }); /** Compact counts for the high-level Electron architecture/security surface. */ diff --git a/tests/boundary/browser/cdpElectronProvider.test.ts b/tests/boundary/browser/cdpElectronProvider.test.ts index 77f14d196..8af69a4da 100644 --- a/tests/boundary/browser/cdpElectronProvider.test.ts +++ b/tests/boundary/browser/cdpElectronProvider.test.ts @@ -1,7 +1,8 @@ -import { rm, writeFile } from "node:fs/promises"; +import { mkdir, realpath, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; +import { createPackage } from "@electron/asar"; import { afterEach, describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -199,6 +200,51 @@ describe("CdpElectronProvider target selection", () => { }); }); +describe("CdpElectronProvider packaged pages", () => { + it("lists and inspects packaged pages served from inside app.asar", async () => { + const root = await createTestTempDirectory("rea-electron-asar-provider-"); + temporary.push(root); + const source = join(root, "source"); + await mkdir(source); + await writeFile( + join(source, "index.html"), + "", + ); + await writeFile(join(source, "app.js"), "export const app = true;"); + const archive = join(root, "app.asar"); + await createPackage(source, archive); + const packaged = await realpath(archive); + const browser = await startFakeCdpBrowser({ + electronFileUrl: `${pathToFileURL(join(archive, "index.html")).href}?isBookDoubleClicked=0#/library`, + }); + browsers.push(browser); + const provider = new CdpElectronProvider(); + const listed = await provider.listTargets( + listElectronTargetsInputSchema.parse({ cdp_endpoint: browser.endpoint }), + ); + if (!listed.ok) throw listed.error; + expect(listed.value.targets).toEqual([ + expect.objectContaining({ + target_id: "electron-page", + file_path: join(packaged, "index.html"), + }), + ]); + + const inspected = await provider.inspectPage( + inspectElectronPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + target_id: "electron-page", + observation_ms: 0, + }), + ); + if (!inspected.ok) throw inspected.error; + expect(inspected.value.target.file_path).toBe(join(packaged, "index.html")); + expect(inspected.value.scripts.items).toEqual([ + expect.objectContaining({ file_path: join(packaged, "app.js") }), + ]); + }); +}); + describe("CdpElectronProvider capture", () => { it.each([{}, { scriptSource: null }, { scriptSource: 123 }])( "rejects malformed Electron source replies instead of inventing source: %j", diff --git a/tests/boundary/filesystem/electronFileScope.test.ts b/tests/boundary/filesystem/electronFileScope.test.ts index 5200376f8..316da8662 100644 --- a/tests/boundary/filesystem/electronFileScope.test.ts +++ b/tests/boundary/filesystem/electronFileScope.test.ts @@ -1,7 +1,8 @@ -import { mkdir, symlink, writeFile } from "node:fs/promises"; +import { mkdir, realpath, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; +import { createPackageWithOptions } from "@electron/asar"; import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -48,6 +49,43 @@ describe("Electron file scope", () => { ).toBeUndefined(); }); + it("resolves regular files Electron serves from inside an ASAR archive", async () => { + const root = await createTestTempDirectory("rea-electron-asar-scope-"); + const source = join(root, "source"); + await mkdir(join(source, "build", "static"), { recursive: true }); + await mkdir(join(source, "native")); + await writeFile(join(source, "build", "index.html"), "

packed

"); + await writeFile(join(source, "build", "static", "app.js"), "app();"); + await writeFile(join(source, "native", "addon.node"), "native"); + await writeFile(join(source, "notes.txt"), "plain"); + const archive = join(root, "app.asar"); + await createPackageWithOptions(source, archive, { unpack: "*.node" }); + const canonical = await realpath(archive); + const member = (...parts: string[]) => + pathToFileURL(join(archive, ...parts)).href; + + expect(await authorizedElectronFile(member("build", "index.html"))).toBe( + join(canonical, "build", "index.html"), + ); + expect( + await authorizedElectronFile( + `${member("build", "static", "app.js")}?isBookDoubleClicked=0#/library`, + ), + ).toBe(join(canonical, "build", "static", "app.js")); + expect(await authorizedElectronFile(member("native", "addon.node"))).toBe( + join(canonical, "native", "addon.node"), + ); + expect(await authorizedElectronFile(member("build"))).toBeUndefined(); + expect( + await authorizedElectronFile(member("build", "missing.html")), + ).toBeUndefined(); + expect( + await authorizedElectronFile( + pathToFileURL(join(source, "notes.txt", "index.html")).href, + ), + ).toBeUndefined(); + }); + it("accepts file URLs with long query text", async () => { const root = await createTestTempDirectory("rea-electron-long-url-"); const path = join(root, "index.html"); @@ -56,4 +94,29 @@ describe("Electron file scope", () => { await expect(authorizedElectronFile(value)).resolves.toBe(path); }); + it("resolves an archive beneath an ASAR-named directory and rejects oversized declared headers", async () => { + const root = await createTestTempDirectory("rea-electron-header-"); + const source = join(root, "source"); + await mkdir(source); + await writeFile(join(source, "index.html"), "page"); + const parent = join(root, "parent.asar"); + await mkdir(parent); + const archive = join(parent, "app.asar"); + await createPackageWithOptions(source, archive, {}); + expect( + await authorizedElectronFile( + pathToFileURL(join(archive, "index.html")).href, + ), + ).toBe(join(await realpath(archive), "index.html")); + const malformed = join(root, "bad.asar"); + const header = Buffer.alloc(16); + header.writeUInt32LE(4, 0); + header.writeUInt32LE(0x40000000, 4); + await writeFile(malformed, header); + expect( + await authorizedElectronFile( + pathToFileURL(join(malformed, "index.html")).href, + ), + ).toBeUndefined(); + }); }); diff --git a/tests/boundary/mcp/artifactMcp.test.ts b/tests/boundary/mcp/artifactMcp.test.ts index ee180a37f..da8a586d2 100644 --- a/tests/boundary/mcp/artifactMcp.test.ts +++ b/tests/boundary/mcp/artifactMcp.test.ts @@ -18,6 +18,13 @@ import { ARTIFACT_COMPARISON_EXAMPLE } from "../../../src/contracts/artifactComp import { createServer } from "../../../src/server/createServer.js"; import { observed } from "../../fixtures/analysisExecution.js"; +const integrityMessage = (unpacked: boolean): string => + `${ + unpacked + ? "Declared ASAR integrity for unpacked entry main.js contradicts its companion file; packaging tools commonly sign or strip unpacked native binaries after writing the archive." + : "Artifact bytes contradict declared integrity." + } If expected, rerun inspect_artifact with integrity_policy=record-and-continue (CLI: --integrity-policy record-and-continue) to retain observed bytes as untrusted; otherwise get a fresh copy.`; + it.each([ { unpacked: false, label: "embedded" }, { unpacked: true, label: "unpacked" }, @@ -69,8 +76,7 @@ it.each([ error: { code: "artifact_integrity_mismatch", category: "integrity_mismatch", - message: - "Artifact is invalid or has changed. Get a fresh copy and try again.", + message: integrityMessage(unpacked), details: { logical_path: "main.js", declared_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u), @@ -78,10 +84,7 @@ it.each([ unpacked, }, retryable: false, - remediation: { - action: - "Artifact is invalid or has changed. Get a fresh copy and try again.", - }, + remediation: { action: integrityMessage(unpacked) }, }, }); } finally { diff --git a/tests/boundary/mcp/asarUnpackedExtractionMcp.test.ts b/tests/boundary/mcp/asarUnpackedExtractionMcp.test.ts new file mode 100644 index 000000000..fdb8436ce --- /dev/null +++ b/tests/boundary/mcp/asarUnpackedExtractionMcp.test.ts @@ -0,0 +1,95 @@ +import { readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it, onTestFinished } from "vitest"; +import { z } from "zod"; + +import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createStrippedAsarAddon } from "../../fixtures/strippedAsarAddon.js"; + +const openArchive = async (archive: string): Promise => { + const session = createTestBinarySession(new ArtifactProvider(process.env)); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "asar-unpacked-extraction", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + onTestFinished(async () => { + await Promise.allSettled([client.close(), server.close(), session.close()]); + }); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: archive }, + }); + expect(opened.isError).not.toBe(true); + return client; +}; + +it("extracts a rewritten unpacked companion only when the caller records the mismatch", async () => { + const { archive, addon, stripped } = await createStrippedAsarAddon(); + const client = await openArchive(archive); + + const strict = await client.callTool({ + name: "extract_artifact", + arguments: {}, + }); + expect(parseMcpToolError(strict).error).toMatchObject({ + code: "artifact_integrity_mismatch", + message: expect.stringContaining( + `Declared ASAR integrity for unpacked entry ${addon} contradicts its companion file; packaging tools commonly sign or strip unpacked native binaries after writing the archive. If expected, rerun extract_artifact with integrity_policy=record-and-continue`, + ), + details: { logical_path: addon, unpacked: true }, + }); + + const continued = await client.callTool({ + name: "extract_artifact", + arguments: { integrity_policy: "record-and-continue" }, + }); + expect(continued.isError, JSON.stringify(continued)).not.toBe(true); + const evidence = parseEvidence(continued.structuredContent); + expect(evidence.parameters).toMatchObject({ + integrity_policy: "record-and-continue", + }); + const result = z + .object({ + output_root: z.string(), + integrity_contradictions: z.array( + z.object({ logical_path: z.string(), trust: z.string() }), + ), + limitations: z.array(z.string()), + }) + .parse(evidence.normalized_result); + onTestFinished(() => + rm(result.output_root, { recursive: true, force: true }), + ); + expect(await readFile(join(result.output_root, addon))).toEqual(stripped); + expect(result.integrity_contradictions).toEqual([ + { logical_path: addon, trust: "observed-untrusted" }, + ]); + expect(result.limitations).toContain( + "1 extracted file(s) contradict declared integrity metadata; their bytes are observed-untrusted.", + ); +}); + +it("names a missing unpacked companion instead of reporting a changed artifact", async () => { + const { archive, addon } = await createStrippedAsarAddon(); + await rm(join(`${archive}.unpacked`, addon)); + const client = await openArchive(archive); + + const extracted = await client.callTool({ + name: "extract_artifact", + arguments: {}, + }); + expect(parseMcpToolError(extracted).error).toMatchObject({ + code: "artifact_operation_failed", + category: "unavailable", + message: `ASAR unpacked companion bytes are unavailable for ${addon}. Select the archive in place with its .unpacked directory beside it, then retry.`, + details: { logical_path: addon, unpacked: true }, + }); +}); diff --git a/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts b/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts index e7d593d61..7444a220e 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts @@ -121,11 +121,8 @@ it("ignores unavailable members of a nested ASAR but refuses them as active entr throw new Error("Unavailable active ASAR member must fail extraction"); expect(projectAnalysisError(activeResult.error)).toMatchObject({ code: "artifact_operation_failed", - details: { - operation: "extract_artifact", - reason: "format", - detail: expect.stringContaining("native/addon.node"), - }, + category: "unavailable", + details: { logical_path: "native/addon.node", unpacked: true }, }); await expect(access(activeOutput)).rejects.toThrow(); }); @@ -139,6 +136,7 @@ const inventoriedExtraction = async (source: string, output: string) => { inputFormat: "asar", outputRoot: output, environment: process.env, + integrityPolicy: "fail", }, source, snapshot,