fix(analysis): preserve artifact and selection error ownership

This commit is contained in:
morluto committed 2026-10-24 22:54:05 +08:00
1 parent 69f99c5198
commit 4f212c6a30
11 files changed
+527 -25

No files matched your search

+2
View File
@@ -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
+7
View File
@@ -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
@@ -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) => {
+24 -6
View File
@@ -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<null, AnalysisError> => {
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) =>
@@ -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);
+10 -1
View File
@@ -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 }, [
+12 -2
View File
@@ -91,14 +91,24 @@ const selectIndexed = <Row>(
): Result<Row, AnalysisError> => {
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,
},
]),
);
@@ -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,
);
@@ -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 }) => {
@@ -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,
);
});
});
+38 -1
View File
@@ -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);