diff --git a/docs/wasm-artifacts.md b/docs/wasm-artifacts.md index ff58d373d4..8b7f9322eb 100644 --- a/docs/wasm-artifacts.md +++ b/docs/wasm-artifacts.md @@ -71,6 +71,19 @@ selected byte SHA-256 and size, section payload ranges, full header/detail outpu and its UTF-8 SHA-256. Producer format drift and invalid or truncated modules are failures, with validation diagnostics and partial Evidence retaining selected byte identity and tool provenance. +If inspection stops after selected files were read, partial Evidence also keeps +the completed candidate file identities and glue parse observations. A stable +read remains observed when descriptor finalization later fails; cleanup still +requires retry. Partial Evidence for a glue read whose finalization fails +before decoding retains its digest with `not-performed` parse status. Invalid +UTF-8 retains the digest with failed parse status. Neither case invents +references, and a failed file read contributes no invented facts. +These independent observations do not establish that the selected WASM module is +valid: partial results state whether WABT validation is unknown, rejected the +module, or succeeded before a later inspection failure. +CLI errors include this partial Evidence inline. MCP errors can return its +`retained-evidence` reference in `details.partial_observation`; use +`get_evidence_bundle` with `detail: "complete"` to retrieve the matching record. Core validation remains authoritative when an optional custom section cannot be interpreted. WABT warning output is preserved in `custom_section_warnings`; diff --git a/src/wasm/WabtArtifactProvider.test.ts b/src/wasm/WabtArtifactProvider.test.ts index ba584f68c7..5cbb8cf284 100644 --- a/src/wasm/WabtArtifactProvider.test.ts +++ b/src/wasm/WabtArtifactProvider.test.ts @@ -436,6 +436,29 @@ it("returns actionable unavailable without creating a workspace when tools are a }); }); +it("preserves a pre-aborted caller reason without acquiring WASM facts", async () => { + const reason = { source: "caller cancelled before provider admission" }; + const controller = new AbortController(); + controller.abort(reason); + const createRuntime = vi.fn(() => { + throw new Error("Pre-aborted inspection must not create a workspace"); + }); + const provider = new WabtArtifactProvider({}, undefined, createRuntime); + try { + const result = await provider.inspect( + { path: "/not-read.wasm", glue_paths: [], candidate_paths: [] }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toBeUndefined(); + expect(createRuntime).not.toHaveBeenCalled(); + } finally { + await provider.close(); + } +}); + it.each([ { tool: "wasm-validate", stdout: "1.0.41\n", stderr: "" }, { tool: "wasm-objdump", stdout: "", stderr: "" }, @@ -559,6 +582,21 @@ it("retries a retained input descriptor before admitting the next WABT operation cleanupIncomplete: true, cleanupResources: [path], cause: { message: "injected WABT input close failure" }, + partialObservation: { + normalized_result: { + artifact: { + path, + sha256: createHash("sha256") + .update(Buffer.alloc(76, 1)) + .digest("hex"), + bytes: 76, + }, + candidates: [{ path }], + glue: [], + validation: "unknown", + tool_profile: { release: "1.0.42" }, + }, + }, }, }); expect(fileCloseFailure.attempts).toBe(1); @@ -979,6 +1017,140 @@ it.each(outcomeModes)( "owns private artifact bytes and retains %s outcome", (mode) => runOwnedArtifactOutcome(mode), ); + +it.each([ + { + stage: "validation rejection", + mode: "invalid", + validation: "rejected-by-selected-profile", + }, + { stage: "candidate admission", mode: "success", validation: "unknown" }, + { stage: "glue decoding", mode: "success", validation: "unknown" }, + { stage: "glue finalization", mode: "success", validation: "unknown" }, + { stage: "producer output", mode: "malformed-wat", validation: "valid" }, +] as const)( + "retains acquired file facts after $stage without claiming more module coverage", + async ({ stage, mode, validation }) => { + // Owned Node commands supply WABT-shaped responses; this checks provider + // Evidence retention, not real WABT acceptance of the fixture bytes. + const fixture = await createWabtArtifactFixture(); + const candidate = join(fixture.selectedRoot, "candidate.wasm"); + const candidateBytes = Buffer.from("independent candidate bytes"); + await writeFile(candidate, candidateBytes); + const missing = join(fixture.selectedRoot, "missing.wasm"); + const gluePath = join(fixture.selectedRoot, "glue.js"); + const glueSource = 'fetch("./selected.wasm");'; + const glueBytes = + stage === "glue decoding" ? Buffer.from([0xff]) : Buffer.from(glueSource); + await writeFile(gluePath, glueBytes); + const provider = new WabtArtifactProvider( + { REA_WABT_BIN_DIRECTORY: fixture.selectedRoot }, + (input) => + launchWabtOutcome(input, mode, { + ...fixture, + controller: new AbortController(), + }), + ); + if (stage === "glue finalization") { + fileCloseFailure.selectedPath = gluePath; + fileCloseFailure.attempts = 0; + fileCloseFailure.failFirst = true; + } + try { + const result = await provider.inspect({ + path: fixture.path, + candidate_paths: + stage === "candidate admission" ? [candidate, missing] : [candidate], + glue_paths: stage === "candidate admission" ? [] : [gluePath], + }); + if (result.ok) throw new Error("Expected the selected failure stage"); + expect(result.error._tag).toBe( + validation === "rejected-by-selected-profile" + ? "AnalysisInputError" + : validation === "unknown" + ? "AnalysisInputError" + : "AnalysisOutputError", + ); + expect(result.error.partialObservation).toMatchObject({ + normalized_result: { + artifact: { + path: fixture.path, + sha256: createHash("sha256").update(fixture.bytes).digest("hex"), + }, + validation, + tool_profile: { release: "1.0.42" }, + candidates: [ + { path: fixture.path }, + { + path: candidate, + sha256: createHash("sha256").update(candidateBytes).digest("hex"), + }, + ], + glue: + stage === "candidate admission" + ? [] + : [ + { + artifact: { + path: gluePath, + sha256: createHash("sha256") + .update(glueBytes) + .digest("hex"), + }, + parse_status: + stage === "glue finalization" + ? "not-performed" + : stage === "glue decoding" + ? "failed" + : "complete", + references: + stage === "glue decoding" || stage === "glue finalization" + ? [] + : [ + { + value: "./selected.wasm", + candidate_paths: [fixture.path], + }, + ], + }, + ], + }, + }); + if (validation === "rejected-by-selected-profile") + expect(result.error.partialObservation).toMatchObject({ + normalized_result: { + exit_code: 1, + stderr: "module.wasm: invalid bytes\n", + }, + }); + if (validation === "unknown") + expect(result.error).toMatchObject({ + issues: [ + { + path: + stage === "candidate admission" + ? ["candidate_paths", 1] + : ["glue_paths", 0], + }, + ], + }); + if (stage === "glue finalization") { + expect(result.error).toMatchObject({ + cleanupIncomplete: true, + cleanupResources: [gluePath], + }); + expect(fileCloseFailure.attempts).toBe(1); + await provider.close(); + expect(fileCloseFailure.attempts).toBe(2); + expect(result.error.cleanupIncomplete).toBe(false); + } + } finally { + await provider.close(); + fileCloseFailure.selectedPath = ""; + fileCloseFailure.failFirst = false; + } + }, +); it.each([null, { reason: "caller-owned object" }, undefined])( "preserves finalization abort reason %j through cleanup recovery", (reason) => runOwnedArtifactOutcome("cancelled-close-failure", reason), diff --git a/src/wasm/WabtArtifactProvider.ts b/src/wasm/WabtArtifactProvider.ts index 381c2f0679..79253f9475 100644 --- a/src/wasm/WabtArtifactProvider.ts +++ b/src/wasm/WabtArtifactProvider.ts @@ -8,7 +8,11 @@ import type { WasmArtifactExecution, WasmArtifactPort, } from "../application/wasm/WasmArtifactPort.js"; -import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { + completedStableArtifact, + readStableArtifact, + type StableArtifactSnapshot, +} from "../artifacts/readStableArtifact.js"; import { ArtifactResourceScope, ArtifactResourceRetryFailure, @@ -36,6 +40,7 @@ import { wasmArtifactSchema, DEFAULT_WASM_COMMAND_TIMEOUT_MS, type InspectWasmArtifactInput, + type WasmArtifact, } from "../domain/wasm/wasmArtifact.js"; import { err, ok, type Result } from "../domain/result.js"; import { runOwnedCommand } from "../process/OwnedCommand.js"; @@ -56,6 +61,13 @@ import { parseWabtWat } from "./WabtWat.js"; import { associateWasmGlue } from "./WasmGlueAdapter.js"; type Launcher = NonNullable[2]>["launcher"]; +type AcquiredGlue = + | WasmArtifact["glue"][number] + | { + readonly artifact: WasmArtifact["glue"][number]["artifact"]; + readonly parse_status: "not-performed"; + readonly references: []; + }; const OPERATION = "inspect_wasm_artifact"; const digest = (text: string) => createHash("sha256").update(text).digest("hex"); @@ -122,10 +134,71 @@ export class WabtArtifactProvider implements WasmArtifactPort { path: input.path, field: ["path"], }; + let toolProfile: WasmArtifact["tool_profile"] | undefined; + let artifact: WasmArtifact["artifact"] | undefined; + const candidates: WasmArtifact["candidates"] = []; + const glue: AcquiredGlue[] = []; + let validationState: "unknown" | "valid" = "unknown"; + const acquiredFacts = () => ({ + ...(artifact === undefined ? {} : { artifact }), + ...(toolProfile === undefined ? {} : { tool_profile: toolProfile }), + candidates: [...candidates], + glue: [...glue], + }); + const acquiredEvidence = () => + toolProfile === undefined + ? undefined + : createEvidence( + artifact === undefined + ? undefined + : { + path: artifact.path, + format: "file", + sha256: artifact.sha256, + }, + WABT_PROVIDER_IDENTITY, + { + operation: OPERATION, + parameters: input, + result: { ...acquiredFacts(), validation: validationState }, + confidence: "observed", + locations: [{ kind: "artifact-path", path: input.path }], + limitations: [ + artifact === undefined + ? "The selected WASM byte identity was not acquired. The admitted WABT tool profile does not establish module validity or runtime behavior." + : 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.", + ], + }, + ); + const readSelectedInput = async ( + path: string, + onCompleted: (snapshot: StableArtifactSnapshot) => void, + ): Promise => { + try { + const snapshot = await readStableArtifact( + path, + WABT_LIMITS.inputBytes, + options?.signal, + undefined, + this.#resources, + ); + onCompleted(snapshot); + return snapshot; + } catch (cause: unknown) { + const completed = completedStableArtifact(cause); + if (completed !== undefined) onCompleted(completed); + throw cause; + } + }; const commandTimeoutMs = input.command_timeout_ms ?? DEFAULT_WASM_COMMAND_TIMEOUT_MS; try { - if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION); + if (options?.signal?.aborted) + throw new AnalysisCancelledError(OPERATION, { + cause: options.signal.reason, + }); const directory = this.environment.REA_WABT_BIN_DIRECTORY ?? ""; if (!isAbsolute(directory)) throw new Error( @@ -268,7 +341,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { versionProbe = undefined; } // Success and validation failure report the same admitted tool/execution facts. - const toolProfile = { + toolProfile = { source: "https://github.com/WebAssembly/wabt", release: WABT_VERSION, source_commit: "ff0ef7e0009402740c805a9744c09b05be063e48", @@ -277,66 +350,70 @@ export class WabtArtifactProvider implements WasmArtifactPort { output_budget_bytes: WABT_LIMITS.outputBytes, }; phase = "artifact"; - const selected = await readStableArtifact( - input.path, - WABT_LIMITS.inputBytes, - options?.signal, - undefined, - this.#resources, - ); - const artifact = { - path: input.path, - sha256: selected.sha256, - bytes: selected.bytes.length, - }; - const candidates = []; + const selected = await readSelectedInput(input.path, (snapshot) => { + const observed = { + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }; + artifact = observed; + candidates.push(observed); + }); for (const path of new Set([input.path, ...input.candidate_paths])) { + if (path === input.path) continue; artifactInput = { path, - field: - path === input.path - ? ["path"] - : ["candidate_paths", input.candidate_paths.indexOf(path)], + field: ["candidate_paths", input.candidate_paths.indexOf(path)], }; - const bytes = - path === input.path - ? selected - : await readStableArtifact( - path, - WABT_LIMITS.inputBytes, - options?.signal, - undefined, - this.#resources, - ); - candidates.push({ + await readSelectedInput(path, (snapshot) => { + candidates.push({ + path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }); + }); + } + for (const [index, path] of input.glue_paths.entries()) { + artifactInput = { path, field: ["glue_paths", index] }; + const glueIndex = glue.length; + const bytes = await readSelectedInput(path, (snapshot) => { + glue.push({ + artifact: { + path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }, + parse_status: "not-performed", + references: [], + }); + }); + const glueArtifact = { path, sha256: bytes.sha256, bytes: bytes.bytes.length, - }); - } - const glue = []; - for (const [index, path] of input.glue_paths.entries()) { - artifactInput = { path, field: ["glue_paths", index] }; - const bytes = await readStableArtifact( - path, - WABT_LIMITS.inputBytes, - options?.signal, - undefined, - this.#resources, - ); - // Keep source characters intact so locations match the hashed artifact. - const source = new TextDecoder("utf-8", { - fatal: true, - ignoreBOM: true, - }).decode(bytes.bytes); - glue.push({ - artifact: { path, sha256: bytes.sha256, bytes: bytes.bytes.length }, - ...associateWasmGlue( - source, - path, - candidates.map((candidate) => candidate.path), - ), - }); + }; + try { + // Keep source characters intact so locations match the hashed artifact. + const source = new TextDecoder("utf-8", { + fatal: true, + ignoreBOM: true, + }).decode(bytes.bytes); + glue[glueIndex] = { + artifact: glueArtifact, + ...associateWasmGlue( + source, + path, + candidates.map((candidate) => candidate.path), + ), + }; + } catch (cause: unknown) { + glue[glueIndex] = { + artifact: glueArtifact, + parse_status: "failed", + references: [], + }; + throw cause; + } } phase = "producer"; await writeFile(snapshotPath, selected.bytes, { @@ -365,9 +442,8 @@ export class WabtArtifactProvider implements WasmArtifactPort { operation: OPERATION, parameters: input, result: { - artifact, + ...acquiredFacts(), validation: "rejected-by-selected-profile", - tool_profile: toolProfile, exit_code: validation.exitCode ?? null, stdout: validation.stdout.text, stderr: validation.stderr.text, @@ -394,6 +470,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { }, ], ); + validationState = "valid"; const customSectionWarnings: { tool: (typeof commands)[number]["tool"]; text: string; @@ -473,7 +550,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { }; const { sections, headers, details } = parseWabtOutput( dump.stdout.text, - artifact.bytes, + selected.bytes.length, customSectionNames, debugModuleNames, ); @@ -530,6 +607,10 @@ export class WabtArtifactProvider implements WasmArtifactPort { ...(versionProbe === undefined ? {} : { versionProbe }), }, ); + if (failure.partialObservation === undefined) { + const partial = acquiredEvidence(); + if (partial !== undefined) failure.retainPartialObservation(partial); + } runtime?.retainCommandFailure(cause, failure, this.#resources); result = err( failure instanceof AnalysisOutputError && @@ -540,6 +621,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { capturedOutput, cleanup: failure.cleanup, diagnostics: failure.diagnostics, + partialObservation: failure.partialObservation, }).bindCleanupDependencies([failure]) : failure, ); diff --git a/tests/boundary/cli/wasmAcquiredInputsCli.test.ts b/tests/boundary/cli/wasmAcquiredInputsCli.test.ts new file mode 100644 index 0000000000..66a59c3856 --- /dev/null +++ b/tests/boundary/cli/wasmAcquiredInputsCli.test.ts @@ -0,0 +1,163 @@ +import { createHash } from "node:crypto"; +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { analysisErrorProjectionSchema } from "../../../src/contracts/errorSchemas.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const sha256 = (bytes: Buffer | string): string => + createHash("sha256").update(bytes).digest("hex"); + +// These scripts exercise the public adapters with WABT-shaped responses. They +// do not establish how the real WABT release classifies the selected bytes. +cliTest.runIf(process.platform !== "win32")( + "retains selected, candidate, and glue facts after simulated WABT rejection through CLI and stdio MCP", + async ({ cli }) => { + const directory = await createTestTempDirectory("rea-wasm-acquired-"); + const tools = join(directory, "tools"); + await mkdir(tools); + for (const name of ["wasm-validate", "wasm-objdump", "wasm2wat"]) { + const path = join(tools, name); + const body = + name === "wasm-validate" + ? "echo 'fixture validation rejected' >&2\nexit 1" + : "exit 0"; + await writeFile( + path, + `#!/bin/sh +if [ "$1" = "--version" ]; then echo '1.0.42'; exit 0; fi +${body} +`, + { mode: 0o700 }, + ); + await chmod(path, 0o700); + } + const selected = join(directory, "selected.wasm"); + const candidate = join(directory, "candidate.wasm"); + const glue = join(directory, "glue.js"); + const selectedBytes = Buffer.from("0061736d01000000", "hex"); + const candidateBytes = Buffer.from("independent candidate bytes"); + const glueSource = 'fetch("./selected.wasm");'; + const environment = { + HOME: directory, + XDG_CONFIG_HOME: directory, + XDG_CACHE_HOME: directory, + REA_WABT_BIN_DIRECTORY: tools, + }; + await Promise.all([ + writeFile(selected, selectedBytes), + writeFile(candidate, candidateBytes), + writeFile(glue, glueSource), + ]); + const expected = { + artifact: { + path: selected, + sha256: sha256(selectedBytes), + bytes: selectedBytes.length, + }, + candidates: [ + { + path: selected, + sha256: sha256(selectedBytes), + bytes: selectedBytes.length, + }, + { + path: candidate, + sha256: sha256(candidateBytes), + bytes: candidateBytes.length, + }, + ], + glue: [ + { + artifact: { + path: glue, + sha256: sha256(glueSource), + bytes: Buffer.byteLength(glueSource), + }, + parse_status: "complete", + references: [ + { + value: "./selected.wasm", + candidate_paths: [selected], + }, + ], + }, + ], + validation: "rejected-by-selected-profile", + tool_profile: { release: "1.0.42" }, + exit_code: 1, + stderr: "fixture validation rejected\n", + }; + const invocation = await cli.run({ + arguments: [ + "inspect-wasm-artifact", + selected, + "--candidate_paths", + candidate, + "--glue_paths", + glue, + "--format", + "json", + ], + environment, + }); + expect(invocation.exitCode, invocation.stderr).toBe(1); + const cliError = analysisErrorProjectionSchema.parse(invocation.json); + expect(cliError).toMatchObject({ + code: "invalid_request", + details: { + partial_observation: { + normalized_result: expected, + }, + }, + }); + + const { client } = await connectLocalToolsMcp(environment); + const response = await client.callTool({ + name: "inspect_wasm_artifact", + arguments: { + path: selected, + candidate_paths: [candidate], + glue_paths: [glue], + }, + }); + const mcpError = parseMcpToolError(response).error; + expect(mcpError.code).toBe("invalid_request"); + const partialReference = z + .object({ + kind: z.literal("retained-evidence"), + evidence_id: z.string(), + }) + .parse( + z + .object({ partial_observation: z.unknown() }) + .passthrough() + .parse(mcpError.details).partial_observation, + ); + const retainedReply = await client.callTool({ + name: "get_evidence_bundle", + arguments: { detail: "complete" }, + }); + expect(retainedReply.isError).not.toBe(true); + const retainedBundle = toolContract( + "get_evidence_bundle", + ).outputSchema.parse(retainedReply.structuredContent).result; + if ("kind" in retainedBundle) + throw new Error("Expected complete retained Evidence"); + const retained = parseEvidence( + retainedBundle.records.find( + (record) => record.evidence_id === partialReference.evidence_id, + ), + ); + expect(retained.evidence_id).toBe(partialReference.evidence_id); + expect(retained.normalized_result).toMatchObject(expected); + await client.ping(); + }, +);