diff --git a/docs/binary-diagnostics.md b/docs/binary-diagnostics.md index 058ebbcd42..d5cf4d19da 100644 --- a/docs/binary-diagnostics.md +++ b/docs/binary-diagnostics.md @@ -100,6 +100,8 @@ one section or symbol, or a stable page without re-running the decoder. CLI one-shot invocations pass portable inline Evidence JSON; an MCP session uses the retained `evidence_id`. Page `limit` is required and bounded by the measured MCP stdio budget documented on the tool contract. +An empty retained section or symbol collection has no valid item index. Its page +view can still return an empty page. Malformed authenticated layout input remains rejected with the owning schema's safe field diagnostics under `source.normalized_result`, including the field path and type or value reason. Evidence authentication and decoder validation diff --git a/docs/website-module-trace.md b/docs/website-module-trace.md index 0b36c22de0..f5e55d3e07 100644 --- a/docs/website-module-trace.md +++ b/docs/website-module-trace.md @@ -41,6 +41,10 @@ cannot be established from that URL alone; choose the context explicitly when known. `import_map` is optional and reads local JSON only. Its exact bytes and base URL are retained; selecting it does not prove the map was installed in the captured page. HTTP(S) importer contexts without URL userinfo are supported initially. +An unusable reported URL leaves the importer context unavailable; supply +`importer_url` explicitly when its value is known. An explicitly supplied malformed +URL is reported against that input field. URLs using unsupported schemes remain +capability limitations. ## Interpret the evidence @@ -65,6 +69,9 @@ The selected source is loaded from `files/` under the manifest's current directory. Relocation retains the manifest's originally reported paths and the actual current source path separately. Unsafe paths, symlink source directories, nonregular files, wrong digests/sizes and malformed UTF-8 fail explicitly. +Malformed source bytes or unsafe source paths declared by the manifest are +artifact failures. Malformed caller-selected manifest or import-map files identify +the supplied input field. ## Engine and lifecycle diff --git a/src/application/WebModuleTraceService.test.ts b/src/application/WebModuleTraceService.test.ts index 62f6d27147..6713520997 100644 --- a/src/application/WebModuleTraceService.test.ts +++ b/src/application/WebModuleTraceService.test.ts @@ -160,6 +160,81 @@ describe("selected module trace workflow", () => { }); }); +describe("reported module context provenance", () => { + it.each(["", "not a URL", "https://user:password@app.test/main.js"])( + "retains unusable reported context as a capability limitation: %s", + async (reportedUrl) => { + const artifacts = webModuleArtifactsFixture(); + const script = artifacts.manifest.scripts[0]; + if (script === undefined) throw new Error("fixture script missing"); + script.url = reportedUrl; + const service = new WebModuleTraceService( + { load: () => Promise.resolve(ok(artifacts)) }, + { + resolve: () => { + throw new Error("must not resolve without a usable context"); + }, + }, + ); + const response = await service.trace(args); + if (response.ok) throw new Error("expected unavailable context"); + expect(projectAnalysisError(response.error)).toMatchObject({ + code: "capability_unavailable", + message: expect.stringContaining("Select importer_url explicitly"), + details: { reason: "reported_source_context_unavailable" }, + }); + expect(projectAnalysisError(response.error).details).not.toHaveProperty( + "issues", + ); + }, + ); + it("keeps an unsupported reported scheme distinct from malformed context", async () => { + const artifacts = webModuleArtifactsFixture(); + const script = artifacts.manifest.scripts[0]; + if (script === undefined) throw new Error("fixture script missing"); + script.url = "file:///captured/main.js"; + const response = await new WebModuleTraceService( + { load: async () => ok(artifacts) }, + webModuleResolverFixture, + ).trace(args); + if (response.ok) throw new Error("expected unsupported context"); + expect(projectAnalysisError(response.error)).toMatchObject({ + code: "capability_unavailable", + message: expect.stringContaining("reported source URL"), + details: { reason: "unsupported_context_scheme" }, + }); + }); + it("honors an explicit context while retaining the unusable reported source URL", async () => { + const artifacts = webModuleArtifactsFixture(); + const script = artifacts.manifest.scripts[0]; + if (script === undefined) throw new Error("fixture script missing"); + script.url = "not a URL"; + const result = await new WebModuleTraceService( + { load: async () => ok(artifacts) }, + webModuleResolverFixture, + ).trace({ ...args, importer_url: "https://selected.test/base/main.js" }); + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + parameters: { importer_url: "https://selected.test/base/main.js" }, + normalized_result: { + source: { script: { url: "not a URL" } }, + importer: { + url: "https://selected.test/base/main.js", + basis: "caller-selected", + }, + imports: expect.arrayContaining([ + expect.objectContaining({ + resolution: { + state: "resolved", + url: "https://selected.test/base/dep.js", + }, + }), + ]), + }, + }); + }); +}); + it.each([null, { request: "opaque cancellation" }])( "retains completed module Evidence and the exact late abort reason: %j", async (reason) => { diff --git a/src/application/WebModuleTraceService.ts b/src/application/WebModuleTraceService.ts index 1a31ad65d4..b5f5878a67 100644 --- a/src/application/WebModuleTraceService.ts +++ b/src/application/WebModuleTraceService.ts @@ -127,8 +127,10 @@ export class WebModuleTraceService { if (!bound.ok) return bound; const selected = bound.value; const importerUrl = input.importer_url ?? selected.url; - const importerAdmission = admitContext(importerUrl, ["importer_url"]); - if (!importerAdmission.ok) return importerAdmission; + if (input.importer_url === undefined) { + const importerAdmission = admitContext(importerUrl); + if (!importerAdmission.ok) return importerAdmission; + } const syntax = collectWebModuleImports(data.source, data.sourceFile.path); const literals = syntax.imports .filter((reference) => reference.specifier !== null) @@ -285,34 +287,50 @@ export class WebModuleTraceService { const admitContext = ( value: string, - path: readonly string[], + path?: readonly string[], ): Result => { + const reportedUnavailable = () => + err( + new AnalysisCapabilityUnavailableError( + "web-module-resolution", + OPERATION, + "reported_source_context_unavailable", + { + userMessage: + "The manifest's reported source URL is not a usable HTTP(S) module context. Select importer_url explicitly for this script.", + }, + ), + ); let url: URL; try { url = new URL(value); } catch (cause: unknown) { void cause; + if (path === undefined) return reportedUnavailable(); return invalid( path, "Expected an absolute context URL. Select importer_url explicitly when the reported source URL is unknown.", ); } - if (url.username !== "" || url.password !== "") + if (url.username !== "" || url.password !== "") { + if (path === undefined) return reportedUnavailable(); return invalid( path, "Module context URLs must not contain username/password userinfo.", ); - if (url.protocol !== "http:" && url.protocol !== "https:") + } + if (url.protocol !== "http:" && url.protocol !== "https:") { return err( new AnalysisCapabilityUnavailableError( "web-module-resolution", OPERATION, "unsupported_context_scheme", { - userMessage: `${path.join(".")} ${value} uses unsupported ${url.protocol} context. This trace currently supports HTTP(S) importer/map contexts; select a supported context explicitly when appropriate.`, + userMessage: `${path?.join(".") ?? "The reported source URL"} ${value} uses unsupported ${url.protocol} context. This trace currently supports HTTP(S) importer/map contexts; select a supported context explicitly when appropriate.`, }, ), ); + } return ok(null); }; const invalid = (path: readonly string[], message: string) => diff --git a/src/browser/assets/LocalWebScriptArtifacts.ts b/src/browser/assets/LocalWebScriptArtifacts.ts index 18a699bd5c..bd2bc1bc88 100644 --- a/src/browser/assets/LocalWebScriptArtifacts.ts +++ b/src/browser/assets/LocalWebScriptArtifacts.ts @@ -79,6 +79,9 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort { }, ), ); + // The index has selected a script. Its source metadata belongs to the + // manifest, rather than to the caller's index or file selection. + callerSelected = false; const portable = normalizeArtifactPath(selected.content.relative_path); if (portable !== selected.content.relative_path) throw new ArtifactReaderFailure( @@ -91,7 +94,6 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort { `Windows source path cannot contain backslashes as literal name characters: ${JSON.stringify(portable)}`, ); const selectedParent = dirname(input.manifest_path); - callerSelected = false; targetPath = selectedParent; const root = await realpath(selectedParent); const sourcePath = join(root, "files", portable); diff --git a/src/browser/assets/WebArtifactReads.ts b/src/browser/assets/WebArtifactReads.ts index 222fac4887..b8e46d5e19 100644 --- a/src/browser/assets/WebArtifactReads.ts +++ b/src/browser/assets/WebArtifactReads.ts @@ -115,7 +115,16 @@ export const webArtifactReadError = ( context.operation, "io", undefined, - `${context.field.join(".")}: ${cause.code}: ${cause.message}`, + `${context.targetPath}: ${cause.code}: ${cause.message}`, + { cause }, + ); + if (!context.callerSelected && cause instanceof WebArtifactFormatFailure) + return new ArtifactOperationError( + context.operation, + "format", + undefined, + `${context.targetPath}: ${cause.message}`, + { cause }, ); if (cause instanceof WebArtifactFormatFailure || cause instanceof z.ZodError) return new AnalysisInputError(context.operation, { cause }, [ diff --git a/src/domain/analysisView/binaryLayoutView.ts b/src/domain/analysisView/binaryLayoutView.ts index 09128066e6..0c4840ff82 100644 --- a/src/domain/analysisView/binaryLayoutView.ts +++ b/src/domain/analysisView/binaryLayoutView.ts @@ -91,14 +91,24 @@ const selectIndexed = ( ): Result => { const row = rows[index]; if (row !== undefined) return ok(row); + if (rows.length === 0) + return err( + inputError([ + { + path: ["view", "selector", "index"], + reason: "invalid_value", + message: `No retained ${collection} entries can be selected by index.`, + }, + ]), + ); return err( inputError([ { path: ["view", "selector", "index"], reason: "out_of_range", - message: `${collection} index ${String(index)} is outside 0..${String(Math.max(0, rows.length - 1))} (${String(rows.length)} entries).`, + message: `${collection} index ${String(index)} is outside 0..${String(rows.length - 1)} (${String(rows.length)} entries).`, minimum: 0, - maximum: Math.max(0, rows.length - 1), + maximum: rows.length - 1, }, ]), ); diff --git a/tests/acceptance/webArtifactErrorProvenance.test.ts b/tests/acceptance/webArtifactErrorProvenance.test.ts new file mode 100644 index 0000000000..34b47097cf --- /dev/null +++ b/tests/acceptance/webArtifactErrorProvenance.test.ts @@ -0,0 +1,234 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { expect } from "vitest"; + +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { webModuleArtifactsFixture } from "../fixtures/webModuleTrace.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +const capturedSource = async (bytes: Buffer, reportedUrl: string) => { + const root = await createTestTempDirectory("rea-web-error-provenance-"); + const { manifest } = webModuleArtifactsFixture("import(name)"); + const script = manifest.scripts[0]; + if (script?.content.state !== "exported") throw new Error("Expected source"); + script.url = reportedUrl; + script.content.sha256 = createHash("sha256").update(bytes).digest("hex"); + script.content.bytes = bytes.length; + manifest.output_directory = root; + manifest.analysis_input = { + input_path: join(root, "files"), + format: "directory", + }; + const sourcePath = join(root, "files", script.content.relative_path); + const manifestPath = join(root, "manifest.json"); + await mkdir(dirname(sourcePath), { recursive: true }); + await writeFile(sourcePath, bytes); + await writeFile(manifestPath, JSON.stringify(manifest)); + return { root, manifest, manifestPath, sourcePath }; +}; + +cliTest( + "attributes captured source faults and caller-selected map faults through CLI and stdio MCP", + async ({ cli }) => { + const { call } = await connectLocalToolsMcp(); + for (const reason of ["format", "path"] as const) { + const fixture = await capturedSource( + reason === "format" + ? Buffer.from([255, 254]) + : Buffer.from("import(name)"), + "https://app.test/main.js", + ); + if (reason === "path") { + const script = fixture.manifest.scripts[0]; + if (script?.content.state !== "exported") + throw new Error("Expected source"); + script.content.relative_path = "../outside.js"; + await writeFile(fixture.manifestPath, JSON.stringify(fixture.manifest)); + } + const mapPath = join(fixture.root, "main.js.map"); + await writeFile( + mapPath, + '{"version":3,"sources":[],"names":[],"mappings":""}', + ); + const selections = [ + { + operation: "trace_web_module_imports", + arguments: [ + "trace-web-module-imports", + fixture.manifestPath, + "0", + "--json", + ], + input: { manifest_path: fixture.manifestPath, script_index: 0 }, + }, + { + operation: "trace_web_source_location", + arguments: [ + "trace-web-source-location", + fixture.manifestPath, + "0", + mapPath, + "https://app.test/main.js.map", + "1", + "0", + "--json", + ], + input: { + manifest_path: fixture.manifestPath, + script_index: 0, + source_map: { path: mapPath, url: "https://app.test/main.js.map" }, + generated_position: { line: 1, column: 0 }, + }, + }, + ]; + for (const selection of selections) { + const response = await cli.run({ arguments: selection.arguments }); + expect(response.exitCode, response.stdout + response.stderr).toBe(1); + const mcp = parseMcpToolError( + await call(selection.operation, selection.input), + ); + for (const error of [response.json, mcp.error]) + expect(error).toMatchObject({ + code: "artifact_operation_failed", + details: { + operation: selection.operation, + reason, + detail: expect.stringContaining( + reason === "format" ? fixture.sourcePath : "../outside.js", + ), + }, + }); + } + } + + const fixture = await capturedSource( + Buffer.from("import(name)"), + "https://app.test/main.js", + ); + const mapPath = join(fixture.root, "invalid.map"); + await writeFile(mapPath, Buffer.from([255, 254])); + for (const selection of [ + { + operation: "trace_web_module_imports", + arguments: [ + "trace-web-module-imports", + fixture.manifestPath, + "0", + "--import-map-path", + mapPath, + "--import-map-base-url", + "https://app.test/", + "--json", + ], + input: { + manifest_path: fixture.manifestPath, + script_index: 0, + import_map: { path: mapPath, base_url: "https://app.test/" }, + }, + field: ["import_map", "path"], + }, + { + operation: "trace_web_source_location", + arguments: [ + "trace-web-source-location", + fixture.manifestPath, + "0", + mapPath, + "https://app.test/main.js.map", + "1", + "0", + "--json", + ], + input: { + manifest_path: fixture.manifestPath, + script_index: 0, + source_map: { path: mapPath, url: "https://app.test/main.js.map" }, + generated_position: { line: 1, column: 0 }, + }, + field: ["source_map", "path"], + }, + ]) { + const response = await cli.run({ arguments: selection.arguments }); + expect(response.exitCode, response.stdout + response.stderr).toBe(1); + const mcp = parseMcpToolError( + await call(selection.operation, selection.input), + ); + for (const error of [response.json, mcp.error]) + expect(error).toMatchObject({ + code: "invalid_request", + details: { + issues: [{ path: selection.field, reason: "invalid_format" }], + }, + }); + } + }, + 120_000, +); + +cliTest( + "requires usable reported context and honors an explicit importer through CLI and stdio MCP", + async ({ cli }) => { + const { call } = await connectLocalToolsMcp(); + for (const reportedUrl of ["not a URL", "file:///captured/main.js"]) { + const fixture = await capturedSource( + Buffer.from("import(name)"), + reportedUrl, + ); + const response = await cli.run({ + arguments: [ + "trace-web-module-imports", + fixture.manifestPath, + "0", + "--json", + ], + }); + expect(response.exitCode, response.stdout + response.stderr).toBe(1); + const mcp = parseMcpToolError( + await call("trace_web_module_imports", { + manifest_path: fixture.manifestPath, + script_index: 0, + }), + ); + for (const error of [response.json, mcp.error]) + expect(error).toMatchObject({ + code: "capability_unavailable", + details: { + reason: reportedUrl.startsWith("file:") + ? "unsupported_context_scheme" + : "reported_source_context_unavailable", + }, + }); + const importerUrl = "https://selected.test/base/main.js"; + const selected = await cli.run({ + arguments: [ + "trace-web-module-imports", + fixture.manifestPath, + "0", + "--importer-url", + importerUrl, + "--json", + ], + }); + expect(selected.exitCode, selected.stdout + selected.stderr).toBe(0); + const selectedMcp = await call("trace_web_module_imports", { + manifest_path: fixture.manifestPath, + script_index: 0, + importer_url: importerUrl, + }); + expect(selectedMcp.isError).not.toBe(true); + expect(selectedMcp.structuredContent).toEqual(selected.json); + expect(selected.json).toMatchObject({ + parameters: { importer_url: importerUrl }, + normalized_result: { + source: { script: { url: reportedUrl } }, + importer: { url: importerUrl, basis: "caller-selected" }, + engine: null, + }, + }); + } + }, + 120_000, +); diff --git a/tests/boundary/cli/analysisViewCli.test.ts b/tests/boundary/cli/analysisViewCli.test.ts index 74d9cd2780..e8407f14e3 100644 --- a/tests/boundary/cli/analysisViewCli.test.ts +++ b/tests/boundary/cli/analysisViewCli.test.ts @@ -29,6 +29,7 @@ import { environmentManifestCaptureFixture, environmentStateCaptureFixture, } from "../../fixtures/analysisView.js"; +import { binaryLayoutFixture } from "../../fixtures/binaryDiagnostics/layout.js"; import { createEvidence, parseEvidence, @@ -738,6 +739,79 @@ cliTest( }, ); +cliTest( + "reports empty retained layout collections without invented index bounds", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-empty-layout-view-cli-"); + const empty = analysisViewLayoutEvidence(binaryLayoutFixture()); + for (const collection of ["sections", "symbols"] as const) { + const result = await cli.run({ + arguments: [ + "inspect-analysis-view", + JSON.stringify({ + source: { kind: "inline", evidence: empty }, + view: { kind: "item", collection, selector: { index: 0 } }, + }), + "--json", + ], + environment: { + HOME: root, + XDG_CONFIG_HOME: root, + XDG_CACHE_HOME: root, + }, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["view", "selector", "index"], + reason: "invalid_value", + message: expect.stringContaining(`No retained ${collection}`), + }, + ], + }, + }); + expect(result.json).not.toHaveProperty("details.issues.0.minimum"); + expect(result.json).not.toHaveProperty("details.issues.0.maximum"); + } + const populated = await cli.run({ + arguments: [ + "inspect-analysis-view", + JSON.stringify({ + source: { kind: "inline", evidence: analysisViewLayoutEvidence() }, + view: { + kind: "item", + collection: "sections", + selector: { index: 3 }, + }, + }), + "--json", + ], + environment: { + HOME: root, + XDG_CONFIG_HOME: root, + XDG_CACHE_HOME: root, + }, + }); + expect(populated.exitCode).toBe(1); + expect(populated.json).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["view", "selector", "index"], + reason: "out_of_range", + minimum: 0, + maximum: 2, + }, + ], + }, + }); + }, +); + cliTest( "projects native function pseudocode without requiring Ghidra", async ({ cli }) => { diff --git a/tests/boundary/filesystem/webModuleArtifacts.test.ts b/tests/boundary/filesystem/webModuleArtifacts.test.ts index 549d038313..70b3d1b6db 100644 --- a/tests/boundary/filesystem/webModuleArtifacts.test.ts +++ b/tests/boundary/filesystem/webModuleArtifacts.test.ts @@ -11,6 +11,7 @@ import { dirname, join } from "node:path"; import { describe, expect, it, onTestFinished } from "vitest"; import { exportWebScripts } from "../../../src/application/WebScriptExportService.js"; import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; +import { LocalWebScriptArtifacts } from "../../../src/browser/assets/LocalWebScriptArtifacts.js"; import { LocalWebModuleArtifacts } from "../../../src/browser/modules/WebModuleArtifacts.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { webScriptExportResultSchema } from "../../../src/domain/webScriptExport.js"; @@ -107,20 +108,38 @@ describe("selected captured module artifact boundary", () => { ); expect(response.error.message).toContain("declared_sha256="); }); - it("rejects unavailable indices and invalid UTF-8 source bytes", async () => { - const f = await fixture(Buffer.from([255, 254])); - for (const script_index of [0, 10]) { - const response = await new LocalWebModuleArtifacts().load({ - manifest_path: f.manifestPath, - script_index, - }); - expect(response.ok).toBe(false); - if (!response.ok) - expect(projectAnalysisError(response.error).code).toBe( - "invalid_request", - ); - } + it("rejects an unavailable index", async () => { + const f = await fixture(); + const response = await new LocalWebModuleArtifacts().load({ + manifest_path: f.manifestPath, + script_index: 10, + }); + expect(response.ok).toBe(false); + if (!response.ok) + expect(projectAnalysisError(response.error).code).toBe("invalid_request"); }); + it.each(["trace_web_module_imports", "trace_web_source_location"] as const)( + "attributes selected source UTF-8 failure to the artifact for %s", + async (operation) => { + const f = await fixture(Buffer.from([255, 254])); + const response = await new LocalWebScriptArtifacts(operation).load({ + manifest_path: f.manifestPath, + script_index: 0, + }); + if (response.ok) throw new Error("expected invalid source bytes"); + expect(projectAnalysisError(response.error)).toMatchObject({ + code: "artifact_operation_failed", + details: { + operation, + reason: "format", + detail: expect.stringContaining(f.sourcePath), + }, + }); + expect(projectAnalysisError(response.error).details).not.toHaveProperty( + "issues", + ); + }, + ); it("rejects source symlinks even when bytes have the expected digest", async () => { const f = await fixture(); const real = join(f.root, "original.js"); @@ -158,7 +177,15 @@ describe("selected captured module artifact boundary", () => { manifest_path: f.manifestPath, script_index: 0, }); - expect(escaped.ok).toBe(false); + if (escaped.ok) throw new Error("expected malformed source path"); + expect(projectAnalysisError(escaped.error)).toMatchObject({ + code: "artifact_operation_failed", + details: { + operation: "trace_web_module_imports", + reason: "path", + detail: expect.stringContaining("../capture.json"), + }, + }); }); it("reads only an explicit local map and rejects non-object JSON", async () => { const f = await fixture(); @@ -257,6 +284,13 @@ describe("caller-selected web module files", () => { expect(response.error).toMatchObject({ _tag: "ArtifactOperationError", reason: "io", + cause: expect.objectContaining({ code: "ENOENT" }), }); + expect(projectAnalysisError(response.error)).toMatchObject({ + details: { detail: expect.stringContaining(`${sourcePath}:`) }, + }); + expect(projectAnalysisError(response.error).details?.detail).not.toMatch( + /^script_index:/u, + ); }); }); diff --git a/tests/boundary/mcp/analysisViewMcp.test.ts b/tests/boundary/mcp/analysisViewMcp.test.ts index f45cb0c045..238ba67955 100644 --- a/tests/boundary/mcp/analysisViewMcp.test.ts +++ b/tests/boundary/mcp/analysisViewMcp.test.ts @@ -55,7 +55,10 @@ import { analysisViewResultSchema, il2cppMetadataSummaryViewResultSchema, } from "../../../src/domain/analysisView/analysisView.js"; -import { BINARY_LAYOUT_TEST_PROVIDER } from "../../fixtures/binaryDiagnostics/layout.js"; +import { + BINARY_LAYOUT_TEST_PROVIDER, + binaryLayoutFixture, +} from "../../fixtures/binaryDiagnostics/layout.js"; import { wasmArtifactFixture } from "../../fixtures/wasm/artifact.js"; import { evmInterfaceFixture } from "../../fixtures/evm/interface.js"; import type { RelationshipCoverage } from "../../../src/domain/relationshipCoverage.js"; @@ -658,6 +661,39 @@ it("reports a tampered inline Evidence ID at its full MCP source path", async () }); }); +it("reports empty retained layout selectors without index bounds", async () => { + const { client, session } = await connect(); + const parent = analysisViewLayoutEvidence(binaryLayoutFixture()); + expect(session.recordEvidence(parent).ok).toBe(true); + for (const collection of ["sections", "symbols"] as const) { + const response = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { kind: "retained-evidence", evidence_id: parent.evidence_id }, + view: { kind: "item", collection, selector: { index: 0 } }, + }, + }); + const error = parseMcpToolError(response).error; + expect(error).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["view", "selector", "index"], + reason: "invalid_value", + message: expect.stringContaining(`No retained ${collection}`), + }, + ], + }, + }); + expect(error.details).not.toHaveProperty("issues.0.minimum"); + expect(error.details).not.toHaveProperty("issues.0.maximum"); + } + expect(session.evidenceById(parent.evidence_id)).toEqual( + parseEvidence(parent), + ); +}); + it("locates malformed PCAP results within inline and retained view sources", async () => { const { client, session } = await connect(); const cases = [ @@ -1433,6 +1469,7 @@ it("reports environment coverage identically from retained, portable and file MC const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas( client, "inspect_analysis_view", + schemaValidation.assertions, ); const parent = analysisViewCaptureEvidence(environmentStateCaptureFixture()); expect(session.recordEvidence(parent).ok).toBe(true);