diff --git a/.agents/skills/reverse-engineer-anything/references/javascript-applications.md b/.agents/skills/reverse-engineer-anything/references/javascript-applications.md index 92ccfdd8a7..55d3c6c874 100644 --- a/.agents/skills/reverse-engineer-anything/references/javascript-applications.md +++ b/.agents/skills/reverse-engineer-anything/references/javascript-applications.md @@ -22,6 +22,11 @@ requires complete application analysis; `source_paths: []` is invalid, not an unfiltered request. `detail: "summary"` reduces delivery and does not by itself reduce analysis work. +ASAR header/index admission is fixed at 16 MiB / 16777216 bytes for this +release and has no runtime override. `source_paths`, `detail`, and +`max_heap_mb` do not bypass or raise it. Analyze an extracted directory, or +supply an archive whose header fits. + Exact `#alias` specifiers resolve through the importer's enclosing package `imports` map to package-local files; nested `node_modules` packages use their own map. Wildcard, recursive, and external-package import diff --git a/docs/electron-observation.md b/docs/electron-observation.md index 612dd98f89..db396e42a1 100644 --- a/docs/electron-observation.md +++ b/docs/electron-observation.md @@ -245,4 +245,10 @@ 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. +ASAR observation reads the header through one open file handle and validates its +declared lengths against the archive size. Header/index admission is the same +fixed 16 MiB / 16777216 byte budget used by static ASAR reads. This release has +no runtime override. An oversized header is not parsed; selecting a member path +does not bypass admission, and the page stays unauthorized. The header cache +separately retains at most 16 MiB of already-admitted encoded headers across +eight archives. Evicting that cache does not raise admission. diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 2e69b5111c..02d73a132e 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -106,6 +106,15 @@ only reduces delivery; source selection reduces text acquisition and analysis. Explicit native provider or snapshot routing rejects `--source-path` rather than ignoring it. +ASAR header/index admission is a fixed 16 MiB / 16777216 bytes for this +release. There is no runtime override. The archive index is read before any +member can be selected, so `source_paths`, `--source-path`, `detail: +"summary"`, `max_heap_mb`, and available host memory do not bypass or raise +it. When the header exceeds that allowance, the failure names this budget. +Analyze an extracted application directory, which does not admit an ASAR +header, or supply an archive whose header fits. Repeating the unchanged +request fails the same way. + ## Read the analysis `analyze_javascript_application` returns the complete analysis Evidence by @@ -172,7 +181,8 @@ for the tool-result contract. Use MCP `detail: "summary"` for a large application, then read selected views of the retained analysis. Summary changes delivery only. To reduce source parsing, -select exact [source paths](#select-sources-before-analysis). +select exact [source paths](#select-sources-before-analysis). Source selection +does not bypass ASAR header/index admission. If a complete response exceeds the transport budget, use its retained Evidence ID for a summary, module page, or item, or export the bundle. See diff --git a/src/application/javascript/JavaScriptApplicationService.ts b/src/application/javascript/JavaScriptApplicationService.ts index fa66a55218..942325b394 100644 --- a/src/application/javascript/JavaScriptApplicationService.ts +++ b/src/application/javascript/JavaScriptApplicationService.ts @@ -188,6 +188,7 @@ const artifactFailureToOperationError = ( ...(cause.partialObservation === undefined ? {} : { partialObservation: cause.partialObservation }), + ...(cause.admission === undefined ? {} : { admission: cause.admission }), }, ); diff --git a/src/artifacts/ArtifactOperationFailure.ts b/src/artifacts/ArtifactOperationFailure.ts index 13a7adda0e..54e9eb5052 100644 --- a/src/artifacts/ArtifactOperationFailure.ts +++ b/src/artifacts/ArtifactOperationFailure.ts @@ -32,6 +32,9 @@ export const translateArtifactFailure = ( ...(cause.partialObservation === undefined ? {} : { partialObservation: cause.partialObservation }), + ...(cause.admission === undefined + ? {} + : { admission: cause.admission }), }, ); bindArtifactFileCleanup(cause, error); diff --git a/src/artifacts/ArtifactReader.ts b/src/artifacts/ArtifactReader.ts index 33cb771ea2..06ea3719d2 100644 --- a/src/artifacts/ArtifactReader.ts +++ b/src/artifacts/ArtifactReader.ts @@ -1,3 +1,4 @@ +import type { ArtifactAdmissionLimit } from "../domain/artifactOperationError.js"; import type { ArtifactIntegrityClaim, ArtifactIntegrityDetails, @@ -28,6 +29,8 @@ export interface ArtifactReaderFailureOptions extends ErrorOptions { readonly partialObservation?: AnalysisPartialObservation | undefined; /** The constrained resource bound for a limit failure. */ readonly boundary?: ArtifactReaderBoundary; + /** Named admission whose recovery must not be inferred from other limit text. */ + readonly admission?: ArtifactAdmissionLimit; } /** Archive-neutral entry metadata. Reader adapters never choose output paths. */ @@ -192,6 +195,8 @@ export class ArtifactReaderFailure extends Error { readonly partialObservation: AnalysisPartialObservation | undefined; /** The constrained bound for a `limit` reason, when the reader knows it. */ readonly boundary: ArtifactReaderBoundary | undefined; + /** Named admission for a limit whose public recovery is owned by the producer. */ + readonly admission: ArtifactAdmissionLimit | undefined; constructor( readonly reason: @@ -217,6 +222,7 @@ export class ArtifactReaderFailure extends Error { options?.cleanupLeases ?? (this.cleanup === undefined ? [] : undefined); this.partialObservation = options?.partialObservation; this.boundary = options?.boundary; + this.admission = options?.admission; } /** Preserve the primary failure and observation. Supplied leases must cover all merged cleanup. */ @@ -261,6 +267,9 @@ export class ArtifactReaderFailure extends Error { ...(primary.boundary === undefined ? {} : { boundary: primary.boundary }), + ...(primary.admission === undefined + ? {} + : { admission: primary.admission }), ...(mergedObservation === undefined ? {} : { partialObservation: mergedObservation }), diff --git a/src/artifacts/AsarHeader.ts b/src/artifacts/AsarHeader.ts index f98d026e19..dc62efb580 100644 --- a/src/artifacts/AsarHeader.ts +++ b/src/artifacts/AsarHeader.ts @@ -5,9 +5,37 @@ import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; import { ArtifactReaderFailure } from "./ArtifactReader.js"; import { err, ok, type Result } from "../domain/result.js"; -/** Shared resource budget for the eagerly decoded ASAR header, not member bytes. */ +/** + * Fixed header/index admission for this release. The header is decoded before + * any member path can be selected. There is no caller or configuration override. + */ export const MAX_ASAR_HEADER_BYTES = 16 * 1024 * 1024; +const MEBIBYTE = 1024 * 1024; + +/** User-visible recovery for the fixed ASAR header/index admission budget. */ +export const asarHeaderAdmissionRecovery = (): string => { + const bytes = MAX_ASAR_HEADER_BYTES; + const mebibytes = bytes / MEBIBYTE; + const allowance = Number.isInteger(mebibytes) + ? `${String(mebibytes)} MiB / ${String(bytes)} bytes` + : `${String(bytes)} bytes`; + return [ + `ASAR header/index admission is limited to ${allowance}.`, + "This release has no runtime override.", + "Selecting fewer sources or narrowing an internal path does not bypass header admission, because the archive index is read before any member can be selected.", + "Raising max_heap_mb or available host memory does not raise it.", + "Analyze an extracted application directory, which does not admit an ASAR header, or supply an archive whose header fits this allowance.", + "Repeating the unchanged request fails the same way.", + ].join(" "); +}; + +/** Refuse an ASAR whose header/index exceeds the fixed admission budget. */ +export const asarHeaderAdmissionFailure = (): ArtifactReaderFailure => + new ArtifactReaderFailure("limit", asarHeaderAdmissionRecovery(), { + admission: { kind: "asar-header", maximumBytes: MAX_ASAR_HEADER_BYTES }, + }); + /** Absolute archive prefix required to inspect an admitted header. */ export const MAX_ASAR_HEADER_PROBE_BYTES = MAX_ASAR_HEADER_BYTES + 8; @@ -345,10 +373,7 @@ export const admitAsarHeader = async ( const parsed = parseAsarHeaderExtents(prefix, observed.size); if (!parsed.ok) throw parsed.error; if (parsed.value.headerBytes > MAX_ASAR_HEADER_BYTES) - throw new ArtifactReaderFailure( - "limit", - `ASAR header exceeds the ${String(MAX_ASAR_HEADER_BYTES)} byte decode budget`, - ); + throw asarHeaderAdmissionFailure(); return { archiveBytes: observed.size, ...parsed.value, diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.ts b/src/artifacts/inventory/ArtifactGraphConstruction.ts index 3cf2c9caee..bad8bdc6ff 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.ts @@ -1,6 +1,6 @@ import { hasGodotPckHeader } from "../../domain/godotPckFormat.js"; import type { ArtifactClassificationEvidence } from "../ArtifactClassificationProbe.js"; -import { ArtifactReaderFailure } from "../ArtifactReader.js"; +import { asarHeaderAdmissionFailure } from "../AsarHeader.js"; import { hasDestinationPath } from "../ArtifactPaths.js"; import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; import { isJavaScriptSourcePath } from "../../domain/javascript/javascriptArtifactFiles.js"; @@ -529,11 +529,7 @@ export const classifyArtifactContent = ( path.toLowerCase().endsWith(".asar") && asarHeader.state !== "rejected" ) { - if (asarHeader.state === "limit") - throw new ArtifactReaderFailure( - "limit", - "ASAR header exceeds the bounded classification or decode budget", - ); + if (asarHeader.state === "limit") throw asarHeaderAdmissionFailure(); if (asarHeader.state === "matched") return { kind: "container", format: "asar" }; } diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 0cd1ffcca0..678e91926b 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -374,6 +374,12 @@ const artifactStateErrorDetails = ( reason: error.reason, ...(error.reason === "limit" ? { truncated: true } : {}), ...(error.detail === undefined ? {} : { detail: error.detail }), + ...(error.admission === undefined + ? {} + : { + admission: error.admission.kind, + maximum_header_bytes: error.admission.maximumBytes, + }), }; if (error instanceof UnknownRegistryError) return { reason: error.reason }; if (error instanceof EvidenceFileError) diff --git a/src/domain/artifactOperationError.ts b/src/domain/artifactOperationError.ts index 916c810584..8e4fc38797 100644 --- a/src/domain/artifactOperationError.ts +++ b/src/domain/artifactOperationError.ts @@ -7,9 +7,17 @@ import { type AnalysisErrorOptions, } from "./analysisErrorBase.js"; +/** Fixed admission named by the producing reader, with its effective byte ceiling. */ +export interface ArtifactAdmissionLimit { + readonly kind: "asar-header"; + readonly maximumBytes: number; +} + /** Artifact inventory or extraction failed a typed safety boundary. */ export class ArtifactOperationError extends AnalysisError { readonly _tag = "ArtifactOperationError"; + override readonly userMessage: string | undefined; + readonly admission: ArtifactAdmissionLimit | undefined; constructor( readonly operation: @@ -37,7 +45,10 @@ export class ArtifactOperationError extends AnalysisError { readonly artifactDetails?: ArtifactIntegrityDetails, /** The specific constraint that failed, such as the colliding path. */ readonly detail?: string, - options?: AnalysisErrorOptions, + options?: AnalysisErrorOptions & { + readonly userMessage?: string; + readonly admission?: ArtifactAdmissionLimit; + }, ) { super( artifactDetails === undefined @@ -45,6 +56,10 @@ export class ArtifactOperationError extends AnalysisError { : artifactDetailsMessage(operation, reason, artifactDetails), options, ); + this.admission = options?.admission; + this.userMessage = + options?.userMessage ?? + (options?.admission === undefined ? undefined : detail); } } diff --git a/tests/boundary/filesystem/asarHeaderAdmission.test.ts b/tests/boundary/filesystem/asarHeaderAdmission.test.ts index 479d4e14b1..e3dd60d78a 100644 --- a/tests/boundary/filesystem/asarHeaderAdmission.test.ts +++ b/tests/boundary/filesystem/asarHeaderAdmission.test.ts @@ -3,11 +3,18 @@ import { join } from "node:path"; import { createPackage } from "@electron/asar"; import { expect, it, vi } from "vitest"; +import { translateArtifactFailure } from "../../../src/artifacts/ArtifactOperationFailure.js"; +import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js"; import { admitAsarHeader, + asarHeaderAdmissionRecovery, MAX_ASAR_HEADER_BYTES, } from "../../../src/artifacts/AsarHeader.js"; +import { ArtifactOperationError } from "../../../src/domain/artifactOperationError.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { inventoryArtifact } from "../../fixtures/artifactInventory.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; const createFixture = async () => { const root = await createTestTempDirectory("rea-asar-header-admission-"); @@ -126,6 +133,110 @@ it("separates the header decode budget from container validity at its exact boun jsonBytes: 2, }); else - await expect(admit(archive)).rejects.toMatchObject({ reason: "limit" }); + await expect(admit(archive)).rejects.toMatchObject({ + reason: "limit", + message: asarHeaderAdmissionRecovery(), + admission: { + kind: "asar-header", + maximumBytes: MAX_ASAR_HEADER_BYTES, + }, + }); } }); + +const writeOversizedHeaderArchive = async (): Promise => { + const root = await createTestTempDirectory("rea-asar-header-recovery-"); + const archive = join(root, "app.asar"); + const headerBytes = MAX_ASAR_HEADER_BYTES + 4; + const json = Buffer.from("{}"); + const prefix = Buffer.alloc(16); + prefix.writeUInt32LE(4, 0); + prefix.writeUInt32LE(headerBytes, 4); + prefix.writeUInt32LE(headerBytes - 4, 8); + prefix.writeUInt32LE(json.length, 12); + await writeFile(archive, Buffer.concat([prefix, json])); + await truncate(archive, headerBytes + 8); + return archive; +}; + +it("names the fixed header admission budget when source selection cannot bypass it", async () => { + const recovery = asarHeaderAdmissionRecovery(); + expect(MAX_ASAR_HEADER_BYTES).toBe(16 * 1024 * 1024); + expect(recovery).toContain("ASAR header/index admission"); + expect(recovery).toContain( + `${String(MAX_ASAR_HEADER_BYTES / (1024 * 1024))} MiB / ${String(MAX_ASAR_HEADER_BYTES)} bytes`, + ); + expect(recovery).toContain("no runtime override"); + expect(recovery).toContain("Selecting fewer sources"); + expect(recovery).toContain("does not bypass header admission"); + expect(recovery).toContain("extracted application directory"); + expect(recovery).not.toContain("Narrow the requested path"); + + const archive = await writeOversizedHeaderArchive(); + let inventoryFailure: unknown; + try { + await inventoryArtifact(archive); + } catch (cause: unknown) { + inventoryFailure = cause; + } + expect(inventoryFailure).toBeInstanceOf(ArtifactReaderFailure); + if (!(inventoryFailure instanceof ArtifactReaderFailure)) + throw new Error("inventory failure was not an artifact reader failure"); + expect(inventoryFailure.cleanup).toBeUndefined(); + const wrapped = ArtifactReaderFailure.withCleanup(inventoryFailure, { + reason: "close failed", + resources: ["header"], + }); + expect(wrapped.message).toBe(recovery); + expect(wrapped.admission).toEqual(inventoryFailure.admission); + const inventory = projectAnalysisError( + translateArtifactFailure("inventory_artifact", inventoryFailure), + ); + expect(inventory).toMatchObject({ + code: "truncated", + message: recovery, + remediation: { action: recovery }, + details: { + reason: "limit", + truncated: true, + detail: recovery, + admission: "asar-header", + maximum_header_bytes: MAX_ASAR_HEADER_BYTES, + }, + }); + expect( + projectAnalysisError(translateArtifactFailure("inspect_artifact", wrapped)) + .message, + ).toBe(recovery); + + const selected = await analyzeJavaScriptApplication({ + input_path: archive, + source_paths: ["package.json"], + max_heap_mb: 2048, + }); + const unselected = await analyzeJavaScriptApplication({ + input_path: archive, + }); + if (selected.ok || unselected.ok) + throw new Error("ASAR header admission succeeded"); + expect(projectAnalysisError(selected.error).message).toBe(recovery); + expect(projectAnalysisError(unselected.error).message).toBe(recovery); + expect(projectAnalysisError(selected.error).remediation.action).toBe( + recovery, + ); +}); + +it("keeps unrelated artifact limit recovery distinct from header admission", () => { + expect( + projectAnalysisError( + new ArtifactOperationError( + "inventory_artifact", + "limit", + undefined, + "Directory traversal reached its examined-entry budget", + ), + ).message, + ).toBe( + "Artifact is too large to process safely. Narrow the requested path or use a smaller artifact.", + ); +});