diff --git a/docs/guides/wasm/wasm-artifacts.md b/docs/guides/wasm/wasm-artifacts.md index be31df870f..1bef747b31 100644 --- a/docs/guides/wasm/wasm-artifacts.md +++ b/docs/guides/wasm/wasm-artifacts.md @@ -1,7 +1,8 @@ # Offline WebAssembly artifacts `inspect_wasm_artifact` / `inspect-wasm-artifact` inspects explicitly selected -local WASM bytes using caller-supplied WABT 1.0.42. No active binary target is +local WASM bytes using caller-supplied WABT 1.0.x (verified with 1.0.42). No +active binary target is required. REA does not install tools or fetch artifacts and does not execute WASM. `open_binary` is not the WASM inspection route. A recognized WASM v1 header @@ -28,11 +29,17 @@ been exercised on macOS arm64; other hosts require their own verification. Configuration failures do not establish anything about the selected WASM bytes. For a rejected version probe, `details.diagnostics` identifies the executable, -tool, expected version and configuration key; `details.captured_output` preserves +tool, verified version, accepted release range and configuration key; +`details.captured_output` preserves its actual stdout and stderr, including empty banners or warnings. Fix the reported directory, executable access or release rather than changing the module -or repeating an unchanged call. All three tools must report 1.0.42 on stdout with -empty stderr. For CLI calls, set the environment before the next invocation. For +or repeating an unchanged call. All three tools must report the same release on +stdout with empty stderr, and that release must fall within the accepted range +(`>=1.0.42 <1.1.0`, verified with 1.0.42). A release above 1.0.42 in that range is +admitted: the observed banner becomes `tool_profile.release` and the +`provider.version`, and the report states that only 1.0.42 was verified. Managed +downloads stay pinned to 1.0.42. For CLI calls, set the environment before the +next invocation. For MCP, change the server's launch environment and restart/reconnect that server; exporting a variable in a different shell does not update a running server. REA never installs tools or relaxes version admission to recover. diff --git a/src/domain/wasm/wasmArtifact.ts b/src/domain/wasm/wasmArtifact.ts index b0d3e8d479..d53278eebf 100644 --- a/src/domain/wasm/wasmArtifact.ts +++ b/src/domain/wasm/wasmArtifact.ts @@ -4,6 +4,15 @@ import { createHash } from "node:crypto"; import type { EvidenceSubjectTarget } from "../evidence.js"; import { absoluteLocalPathStringSchema } from "../localPath.js"; +/** Release banners of the admitted WABT range, owned here so this schema stays pure. */ +const WABT_ACCEPTED_MINOR = 42; +const WABT_SOURCE_COMMIT = "ff0ef7e0009402740c805a9744c09b05be063e48"; +const wabtReleaseBanner = z + .string() + .regex(/^1\.0\.\d+$/u) + .refine((release) => Number(release.split(".")[2]) >= WABT_ACCEPTED_MINOR, { + message: `Expected one admitted WABT banner (^1.0.${String(WABT_ACCEPTED_MINOR)})`, + }); const digest = z.string().regex(/^[a-f0-9]{64}$/); const artifact = z.strictObject({ @@ -73,8 +82,13 @@ export const wasmArtifactSchema = z }), tool_profile: z.strictObject({ source: z.literal("https://github.com/WebAssembly/wabt"), - release: z.literal("1.0.42"), - source_commit: z.literal("ff0ef7e0009402740c805a9744c09b05be063e48"), + release: wabtReleaseBanner, + /** + * Names the verified upstream revision only when that release is what + * ran; a caller-supplied newer 1.0.x build has no revision REA knows, so + * the field is null rather than a claim about a build that did not run. + */ + source_commit: z.literal(WABT_SOURCE_COMMIT).nullable(), commands: z .array( z.strictObject({ @@ -139,6 +153,7 @@ export const wasmArtifactSchema = z value.tool_profile.commands[0]?.arguments.includes( "--ignore-custom-section-errors", ); + const releaseVersion = value.tool_profile.release; for (const [index, command] of value.tool_profile.commands.entries()) { const expected = ["wasm-validate", "wasm-objdump", "wasm2wat"][index]; const argumentsExpected = @@ -149,7 +164,7 @@ export const wasmArtifactSchema = z : ["--enable-all", "module.wasm"]; if ( command.tool !== expected || - command.version_banner.trim() !== "1.0.42" || + command.version_banner.trim() !== releaseVersion || JSON.stringify(command.arguments) !== JSON.stringify(argumentsExpected) ) context.addIssue({ diff --git a/src/wasm/WabtArtifactProvider.test.ts b/src/wasm/WabtArtifactProvider.test.ts index 6efb08d4d3..9189874193 100644 --- a/src/wasm/WabtArtifactProvider.test.ts +++ b/src/wasm/WabtArtifactProvider.test.ts @@ -10,7 +10,21 @@ import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRoo import { spawnOwnedProviderProcess } from "../process/ProviderProcess.js"; import { OwnedCommandFailure } from "../process/OwnedCommand.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; -import { WABT_LIMITS } from "./WabtRelease.js"; +import { + WABT_LIMITS, + admitWabtVersion, + WABT_ACCEPTED_RANGE, +} from "./WabtRelease.js"; + +// Admission must not outrun the `1.0.x` banners wasmArtifactSchema accepts. +it("admits 1.0.x releases but not the 1.1.0 upper boundary", () => { + expect(WABT_ACCEPTED_RANGE).toBe(">=1.0.42 <1.1.0"); + expect(admitWabtVersion("1.0.41").status).toBe("unsupported"); + expect(admitWabtVersion("1.0.42").status).toBe("verified"); + expect(admitWabtVersion("1.0.99").status).toBe("compatible"); + expect(admitWabtVersion("1.1.0").status).toBe("unsupported"); + expect(admitWabtVersion("2.0.0").status).toBe("unsupported"); +}); import { wabtFailure } from "./WabtFailures.js"; const nodeHostEnvironment = ( @@ -538,6 +552,153 @@ it.each([ }, ); +it("admits a compatible caller WABT release and records the observed banner", async () => { + const root = await realpath( + await createTestTempDirectory("rea-wabt-compatible-release-"), + ); + const suffix = process.platform === "win32" ? ".exe" : ""; + for (const name of ["wasm-validate", "wasm-objdump", "wasm2wat"]) + await writeFile(join(root, name + suffix), "owned executable seam", { + mode: 0o700, + }); + const modulePath = join(root, "selected.wasm"); + await writeFile(modulePath, Buffer.alloc(76, 1)); + const dump = await readFile( + new URL("../../tests/fixtures/wasm/objdump.txt", import.meta.url), + "utf8", + ); + const provider = new WabtArtifactProvider( + { REA_WABT_BIN_DIRECTORY: root }, + (input) => { + const version = input.arguments[0] === "--version"; + const stdout = version + ? "1.0.44\n" + : input.command.includes("wasm-objdump") + ? dump + : input.command.includes("wasm2wat") + ? '(module (@custom "test" "payload"))\n' + : ""; + return spawnOwnedProviderProcess({ + ...input, + hostEnvironment: nodeHostEnvironment(input.hostEnvironment), + command: process.execPath, + arguments: [ + "-e", + `process.stdout.write(${JSON.stringify(stdout)}); process.exit(0);`, + ], + }); + }, + ); + try { + const result = await provider.inspect({ + path: modulePath, + glue_paths: [], + candidate_paths: [], + }); + if (!result.ok) throw result.error; + const report = wasmArtifactSchema.parse(result.value.result); + expect(report.tool_profile.release).toBe("1.0.44"); + expect(report.tool_profile.source_commit).toBeNull(); + expect( + report.tool_profile.commands.map((command) => + command.version_banner.trim(), + ), + ).toEqual(["1.0.44", "1.0.44", "1.0.44"]); + expect(report.limitations).toContainEqual( + expect.stringContaining("uses caller-supplied WABT 1.0.44"), + ); + expect(result.value.provider.version).toBe("wabt@1.0.44"); + } finally { + await provider.close(); + } +}); + +it("keeps the compatible-release warning when validation rejects the input", async () => { + const root = await realpath( + await createTestTempDirectory("rea-wabt-compatible-rejection-"), + ); + const suffix = process.platform === "win32" ? ".exe" : ""; + for (const name of ["wasm-validate", "wasm-objdump", "wasm2wat"]) + await writeFile(join(root, name + suffix), "owned executable seam", { + mode: 0o700, + }); + const modulePath = join(root, "rejected.wasm"); + await writeFile(modulePath, Buffer.alloc(76, 1)); + const provider = new WabtArtifactProvider( + { REA_WABT_BIN_DIRECTORY: root }, + (input) => { + const version = input.arguments[0] === "--version"; + const rejected = input.command.includes("wasm-validate"); + return spawnOwnedProviderProcess({ + ...input, + hostEnvironment: nodeHostEnvironment(input.hostEnvironment), + command: process.execPath, + arguments: [ + "-e", + `process.stderr.write(${JSON.stringify(rejected && !version ? "rejected" : "")}); process.stdout.write(${JSON.stringify(version ? "1.0.44\n" : "")}); process.exit(${rejected && !version ? 1 : 0});`, + ], + }); + }, + ); + try { + const result = await provider.inspect({ + path: modulePath, + glue_paths: [], + candidate_paths: [], + }); + if (result.ok) throw new Error("Expected validation rejection"); + expect(result.error.partialObservation).toMatchObject({ + normalized_result: { validation: "rejected-by-selected-profile" }, + limitations: expect.arrayContaining([ + expect.stringContaining("uses caller-supplied WABT 1.0.44"), + ]), + }); + } finally { + await provider.close(); + } +}); + +it("rejects configured WABT tools whose admitted releases disagree", async () => { + const root = await realpath( + await createTestTempDirectory("rea-wabt-mixed-releases-"), + ); + const suffix = process.platform === "win32" ? ".exe" : ""; + for (const name of ["wasm-validate", "wasm-objdump", "wasm2wat"]) + await writeFile(join(root, name + suffix), "owned executable seam", { + mode: 0o700, + }); + const provider = new WabtArtifactProvider( + { REA_WABT_BIN_DIRECTORY: root }, + (input) => + spawnOwnedProviderProcess({ + ...input, + hostEnvironment: nodeHostEnvironment(input.hostEnvironment), + command: process.execPath, + arguments: [ + "-e", + `process.stdout.write(${JSON.stringify( + input.command.includes("wasm-objdump") ? "1.0.44\n" : "1.0.42\n", + )}); process.exit(0);`, + ], + }), + ); + try { + const result = await provider.inspect({ + path: "/must-not-read-before-configuration.wasm", + glue_paths: [], + candidate_paths: [], + }); + if (result.ok) throw new Error("Expected mixed-release rejection"); + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("capability_unavailable"); + expect(projected.message).toContain( + "Configured WABT tools report different releases", + ); + } finally { + await provider.close(); + } +}); + it("retries a retained input descriptor before admitting the next WABT operation", async () => { const root = await createTestTempDirectory("rea-wabt-read-cleanup-"); const path = join(root, "selected.wasm"); diff --git a/src/wasm/WabtArtifactProvider.ts b/src/wasm/WabtArtifactProvider.ts index 0b7abc613f..a7f529d73b 100644 --- a/src/wasm/WabtArtifactProvider.ts +++ b/src/wasm/WabtArtifactProvider.ts @@ -58,7 +58,10 @@ import { WabtRuntime } from "./WabtRuntime.js"; import { WABT_PROVIDER_IDENTITY, WABT_LIMITS, + WABT_SOURCE_COMMIT, WABT_VERSION, + admitWabtVersion, + wabtReleaseLine, } from "./WabtRelease.js"; import { wabtFailure, @@ -154,6 +157,9 @@ export class WabtArtifactProvider implements WasmArtifactPort { field: ["path"], }; let toolProfile: WasmArtifact["tool_profile"] | undefined; + let admittedVersion = WABT_VERSION; + let observedRelease: string | undefined; + let compatibleLimitation: string | undefined; let artifact: WasmArtifact["artifact"] | undefined; const candidates: WasmArtifact["candidates"] = []; const glue: AcquiredGlue[] = []; @@ -164,6 +170,10 @@ export class WabtArtifactProvider implements WasmArtifactPort { candidates: [...candidates], glue: [...glue], }); + const admittedIdentity = () => ({ + ...WABT_PROVIDER_IDENTITY, + version: `wabt@${admittedVersion}`, + }); const acquiredEvidence = () => toolProfile === undefined ? undefined @@ -175,7 +185,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { format: "file", sha256: artifact.sha256, }, - WABT_PROVIDER_IDENTITY, + admittedIdentity(), { operation: OPERATION, parameters: input, @@ -188,6 +198,9 @@ export class WabtArtifactProvider implements WasmArtifactPort { : validationState === "valid" ? "WABT validated the selected module, but inspection did not complete. Acquired file identities and glue references do not establish runtime loading or execution." : "WABT validation has no completed result. Acquired file identities and glue references do not establish module validity, runtime loading or execution.", + ...(compatibleLimitation === undefined + ? [] + : [compatibleLimitation]), ], }, ); @@ -349,13 +362,15 @@ export class WabtArtifactProvider implements WasmArtifactPort { executable_path: command.path, tool: command.tool, expected_version: WABT_VERSION, + accepted_releases: wabtReleaseLine(), }; const version = await execute(command, ["--version"]); - if ( - version.stdout.text.trim() !== WABT_VERSION || - version.stderr.text !== "" - ) { - const reason = `Unsupported ${command.tool} version banner; expected ${WABT_VERSION}.`; + const admission = + version.exitCode === 0 && version.stderr.text === "" + ? admitWabtVersion(version.stdout.text) + : undefined; + if (admission === undefined || admission.status === "unsupported") { + const reason = `${admission?.message ?? `Unsupported ${command.tool} version banner`}; expected stdout from ${wabtReleaseLine()} with empty stderr.`; throw new AnalysisCapabilityUnavailableError( WABT_PROVIDER_IDENTITY.id, OPERATION, @@ -372,14 +387,38 @@ export class WabtArtifactProvider implements WasmArtifactPort { }, ); } + // All three tools come from one directory, so admit only banners that agree. + if (observedRelease === undefined) observedRelease = admission.version; + if (observedRelease !== admission.version) { + const reason = `Configured WABT tools report different releases (${observedRelease} and ${admission.version}); select one complete ${wabtReleaseLine()} installation.`; + throw new AnalysisCapabilityUnavailableError( + WABT_PROVIDER_IDENTITY.id, + OPERATION, + reason, + { + userMessage: reason, + diagnostics: versionProbe, + capturedOutput: { + stdout: version.stdout.text, + stderr: version.stderr.text, + truncated: version.diagnosticTruncated === true, + }, + remediationAction: wabtConfigurationRecoveryAction(), + }, + ); + } + if (admission.status === "compatible") + compatibleLimitation = admission.limitation; + admittedVersion = admission.version; command.version_banner = version.stdout.text; versionProbe = undefined; } // Success and validation failure report the same admitted tool/execution facts. toolProfile = { source: "https://github.com/WebAssembly/wabt", - release: WABT_VERSION, - source_commit: "ff0ef7e0009402740c805a9744c09b05be063e48", + release: admittedVersion, + source_commit: + admittedVersion === WABT_VERSION ? WABT_SOURCE_COMMIT : null, commands: commands.map( ({ selection: _selection, ...command }) => command, ), @@ -475,7 +514,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { { partialObservation: createEvidence( { path: input.path, format: "file", sha256: selected.sha256 }, - WABT_PROVIDER_IDENTITY, + admittedIdentity(), { operation: OPERATION, parameters: input, @@ -490,6 +529,9 @@ export class WabtArtifactProvider implements WasmArtifactPort { locations: [{ kind: "artifact-path", path: input.path }], limitations: [ "WABT rejected these exact selected bytes with --enable-all and --ignore-custom-section-errors; no complete inspection or target execution occurred.", + ...(compatibleLimitation === undefined + ? [] + : [compatibleLimitation]), ], }, ), @@ -608,6 +650,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { "JavaScript references are AST-observed static literals only. Candidate matches are associations, not proven data flow, URL-to-file identity or runtime loading. Computed references and aliases are not resolved.", "No target execution or implicit fetch. WABT tools are trusted caller-supplied executables; SHA-256 identifies their bytes, not publisher authenticity. No OS memory sandbox is claimed.", `Each WABT command has a ${String(commandTimeoutMs)} ms deadline and a fixed 32 MiB combined output budget. The deadline is not a whole-inspection budget. Oversized output fails instead of producing truncated success.`, + ...(compatibleLimitation === undefined ? [] : [compatibleLimitation]), ...optionalLimitations, ]; const report = wasmArtifactSchema.parse({ @@ -634,7 +677,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { limitations, }); result = ok( - createAnalysisExecution(report, WABT_PROVIDER_IDENTITY, { + createAnalysisExecution(report, admittedIdentity(), { subject: { path: input.path, format: "file", diff --git a/src/wasm/WabtFailures.test.ts b/src/wasm/WabtFailures.test.ts index 66c9ba94ba..7ad73b7e20 100644 --- a/src/wasm/WabtFailures.test.ts +++ b/src/wasm/WabtFailures.test.ts @@ -127,6 +127,7 @@ it.each([ executable_path: "/tools/wasm2wat", tool: "wasm2wat", expected_version: "1.0.42" as const, + accepted_releases: "WABT >=1.0.42 <1.1.0 (verified with 1.0.42)", }; const failure = wabtFailure(cause, "configuration", "/selected.wasm", { commandTimeoutMs: 1234, diff --git a/src/wasm/WabtFailures.ts b/src/wasm/WabtFailures.ts index d7bc63aaed..1014abd281 100644 --- a/src/wasm/WabtFailures.ts +++ b/src/wasm/WabtFailures.ts @@ -34,6 +34,7 @@ import { MAX_WASM_COMMAND_TIMEOUT_MS, } from "../domain/wasm/wasmArtifact.js"; import { + WABT_ACCEPTED_RANGE, WABT_LIMITS, WABT_PROVIDER_IDENTITY, WABT_VERSION, @@ -47,6 +48,7 @@ export type WabtVersionProbe = { readonly executable_path: string; readonly tool: string; readonly expected_version: typeof WABT_VERSION; + readonly accepted_releases: string; }; type WabtFailureContext = { @@ -68,7 +70,7 @@ export const wabtConfigurationRecoveryAction = (cause?: unknown): string => { : code === "ENOENT" ? "The reported configured tool is missing. " : ""; - return `${action}Set absolute REA_WABT_BIN_DIRECTORY to a directory that contains wasm-validate, wasm-objdump and wasm2wat from WABT ${WABT_VERSION}; each --version must report ${WABT_VERSION} on stdout with empty stderr. Correct the reported tool path, access or release before retrying, not the WASM input. Set this in the shell for the next CLI call, or in the MCP server launch environment and restart that server; a shell export does not update a running MCP server. Do not repeat the unchanged call or install tools implicitly.`; + return `${action}Set absolute REA_WABT_BIN_DIRECTORY to a directory that contains wasm-validate, wasm-objdump and wasm2wat from one WABT installation; each --version must report ${WABT_ACCEPTED_RANGE} on stdout with empty stderr. Releases in that range other than ${WABT_VERSION} are admitted and reported as the observed banner. Correct the reported tool path, access or release before retrying, not the WASM input. Set this in the shell for the next CLI call, or in the MCP server launch environment and restart that server; a shell export does not update a running MCP server. Do not repeat the unchanged call or install tools implicitly.`; }; /** Past the output budget, retained text is a prefix of an oversized stream; keep a bounded excerpt of each. */ const outputLimitExcerpt = (snapshot: ProviderProcessSnapshot) => ({ diff --git a/src/wasm/WabtRelease.ts b/src/wasm/WabtRelease.ts index 2280106fa9..8d54b14df9 100644 --- a/src/wasm/WabtRelease.ts +++ b/src/wasm/WabtRelease.ts @@ -1,5 +1,18 @@ -/** Exact upstream release whose producer output is admitted by this adapter. */ +import { satisfies, valid } from "semver"; + +/** Exact upstream release whose producer output this adapter verified. */ export const WABT_VERSION = "1.0.42"; +/** + * Releases admitted for caller-configured tools. WABT changes `wasm-validate`, + * `wasm-objdump` and `wasm2wat` output only by addition within a minor version; + * 1.0.42 is the release this adapter verified. Managed downloads stay pinned. + * The upper bound is exclusive of 1.1.0 so admission matches the `1.0.x` + * banners `wasmArtifactSchema` accepts; letting 1.1.0 through would admit a + * tool profile whose report the schema then rejects after inspection ran. + */ +export const WABT_ACCEPTED_RANGE = ">=1.0.42 <1.1.0"; +/** Upstream revision of the verified release. */ +export const WABT_SOURCE_COMMIT = "ff0ef7e0009402740c805a9744c09b05be063e48"; export const WABT_PROVIDER_IDENTITY = { id: "wabt-artifact", name: "REA WABT artifact adapter", @@ -10,3 +23,41 @@ export const WABT_LIMITS = { toolBytes: 64 * 1024 * 1024, outputBytes: 32 * 1024 * 1024, } as const; + +/** Admission of the banners reported by one configured WABT tool directory. */ +export type WabtVersionAdmission = + | { readonly status: "verified"; readonly version: string } + | { + readonly status: "compatible"; + readonly version: string; + readonly limitation: string; + } + | { readonly status: "unsupported"; readonly message: string }; + +/** Caller-facing accepted releases, naming the verified build. */ +export const wabtReleaseLine = (): string => + `WABT ${WABT_ACCEPTED_RANGE} (verified with ${WABT_VERSION})`; + +/** + * Release warning for inspections that did not reach a report. Successful + * reports carry the same text through the admission limitation, so a failure + * never drops the compatible-release disclaimer that a success would state. + */ +export const wabtReleaseLimitation = (version: string): string => + `This inspection uses caller-supplied WABT ${version}. REA verified the tool arguments and output parsing against ${WABT_VERSION}; other releases in ${WABT_ACCEPTED_RANGE} are admitted and the observed banner is reported.`; + +/** Classify one `--version` banner against the accepted release range. */ +export const admitWabtVersion = (banner: string): WabtVersionAdmission => { + const token = banner.trim(); + if (valid(token) !== token || !satisfies(token, WABT_ACCEPTED_RANGE)) + return { + status: "unsupported", + message: `Unsupported WABT version ${token.length === 0 ? "banner" : token}; accepted releases are ${wabtReleaseLine()}`, + }; + if (token === WABT_VERSION) return { status: "verified", version: token }; + return { + status: "compatible", + version: token, + limitation: wabtReleaseLimitation(token), + }; +};