diff --git a/src/application/EvidenceBundleCommands.ts b/src/application/EvidenceBundleCommands.ts index cacb1d6b86..6292254d3f 100644 --- a/src/application/EvidenceBundleCommands.ts +++ b/src/application/EvidenceBundleCommands.ts @@ -1,3 +1,4 @@ +import { readArtifactInspectionSources } from "../domain/artifactInspection.js"; import { dispatchQueuedInterrupts } from "../process/queuedInterrupts.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { requireAuthenticationHeadroom } from "./JsonAuthenticationHeadroom.js"; @@ -84,7 +85,16 @@ export const deliverAnalysisEvidence = async ( }); if (!headroom.ok) return err(headroom.error.retainPartialObservation(evidence)); - const bundle = createImmutableEvidenceBundle([evidence]); + const sourceEvidence = + evidence.operation === "inspect_artifact" + ? readArtifactInspectionSources(evidence.normalized_result) + : ok([]); + if (!sourceEvidence.ok) + return err(sourceEvidence.error.retainPartialObservation(evidence)); + const bundle = createImmutableEvidenceBundle([ + ...sourceEvidence.value, + evidence, + ]); const delivered = detail === "summary" ? inspectAnalysisViewValidated( diff --git a/src/cli/analysisEvidenceDelivery.ts b/src/cli/analysisEvidenceDelivery.ts index c51653c096..a661b277da 100644 --- a/src/cli/analysisEvidenceDelivery.ts +++ b/src/cli/analysisEvidenceDelivery.ts @@ -1,3 +1,4 @@ +import { readArtifactInspectionSources } from "../domain/artifactInspection.js"; import type { AnalysisViewRequest } from "../domain/analysisView/analysisView.js"; import { z } from "incur"; import { parseEvidence, type Evidence } from "../domain/evidence.js"; @@ -97,6 +98,9 @@ export const deliverCliAnalysisEvidence = async ( detail, evidenceOutput, ...(input.view === undefined ? {} : { view: input.view }), + ...(input.sourceEvidence === undefined + ? {} + : { sourceEvidence: input.sourceEvidence }), }); }; @@ -127,13 +131,22 @@ export const publishCliAnalysisEvidence = async ( return projectAnalysisError( delivered.error.retainPartialObservation(complete), ); + const inspectionSources = + complete.operation === "inspect_artifact" + ? readArtifactInspectionSources(complete.normalized_result) + : ok([]); + if (!inspectionSources.ok) + return projectAnalysisError( + inspectionSources.error.retainPartialObservation(complete), + ); const published = await publishEvidenceDelivery({ bundle: createEvidenceBundle([ ...new Map( - [...(input.sourceEvidence ?? []), complete].map((record) => [ - record.evidence_id, - record, - ]), + [ + ...(input.sourceEvidence ?? []), + ...inspectionSources.value, + complete, + ].map((record) => [record.evidence_id, record]), ).values(), ]), complete, diff --git a/src/domain/analysisView/artifactInventoryView.ts b/src/domain/analysisView/artifactInventoryView.ts index 77721ef46c..6902e37fa2 100644 --- a/src/domain/analysisView/artifactInventoryView.ts +++ b/src/domain/analysisView/artifactInventoryView.ts @@ -123,10 +123,13 @@ export const artifactInventorySummarySchema = z.strictObject({ limitation_count: z.number().int().nonnegative(), }); -const inputError = (message: string): AnalysisInputError => +const inputError = ( + field: "source" | "view", + message: string, +): AnalysisInputError => new AnalysisInputError("inspect_analysis_view", undefined, [ { - path: ["source"], + path: [field], reason: "invalid_value", message, }, @@ -221,7 +224,7 @@ const sourceFailure = ( if (cause instanceof z.ZodError) return err(analysisViewResultInputError(input, cause)); if (!(cause instanceof ArtifactInventoryValidationError)) throw cause; - return err(inputError(cause.message)); + return err(inputError("source", cause.message)); }; /** Project exact source records; exhausted pages cover available records, not runtime behavior. */ @@ -233,12 +236,16 @@ export const projectArtifactInventoryView = ( if (operation !== "inventory_artifact" && operation !== "inspect_artifact") return err( inputError( + "source", "Inventory views require inventory_artifact or inspect_artifact Evidence.", ), ); if (view.kind !== "summary" && view.kind !== "inventory") return err( - inputError("Artifact Evidence supports summary and inventory views."), + inputError( + "view", + "Artifact Evidence supports summary and inventory views.", + ), ); const parsed = parseSource(parent); if (!parsed.ok) return parsed; diff --git a/src/domain/artifactInspection.ts b/src/domain/artifactInspection.ts index 458d76ece3..408cb1f42a 100644 --- a/src/domain/artifactInspection.ts +++ b/src/domain/artifactInspection.ts @@ -1,3 +1,10 @@ +import { readEvidencePayload } from "./evidenceValidation.js"; +import type { EvidenceIntegrityError } from "./evidenceErrors.js"; +import { ok, type Result } from "./result.js"; +import { + canBorrowParsedJsonSnapshot, + immutableJsonSnapshotMember, +} from "./immutableJson.js"; import { artifactIntegrityPairOf, artifactSha256PairSchema, @@ -437,3 +444,22 @@ const inspectionCoverage = ( if (inventory.coverage.status !== "complete") return "partial"; return "complete-within-substeps"; }; + +/** Own the atomic source closure published or retained with an inspection. */ +export const readArtifactInspectionSources = ( + result: unknown, +): Result => { + const parsed = readEvidencePayload(artifactInspectionResultSchema, result); + if (!parsed.ok) return parsed; + const inspection = canBorrowParsedJsonSnapshot(result, parsed.value) + ? result + : parsed.value; + const substeps = immutableJsonSnapshotMember(inspection, "substeps"); + return ok( + inspection.substeps.map((step, index) => { + const ownedStep = immutableJsonSnapshotMember(substeps, String(index)); + immutableJsonSnapshotMember(ownedStep, "evidence"); + return step.evidence; + }), + ); +}; diff --git a/src/server/registerArtifactTools.ts b/src/server/registerArtifactTools.ts index 7b6a8656db..e852654ce7 100644 --- a/src/server/registerArtifactTools.ts +++ b/src/server/registerArtifactTools.ts @@ -1,13 +1,9 @@ -import { - canBorrowParsedJsonSnapshot, - immutableJsonSnapshotMember, -} from "../domain/immutableJson.js"; import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import { ARTIFACT_TOOL_CONTRACTS } from "../contracts/artifactToolContracts.js"; import { createArtifactExtractionDestination } from "../application/artifacts/ArtifactExtractionDestination.js"; import { registerEvidenceTools } from "./registerEvidenceTools.js"; -import { artifactInspectionResultSchema } from "../domain/artifactInspection.js"; +import { readArtifactInspectionSources } from "../domain/artifactInspection.js"; import { retainedSummaryDelivery, withoutDeliveryDetail, @@ -40,16 +36,9 @@ export const registerArtifactTools = ( parametersForEvidence: decodeWithoutDelivery, sourceEvidence: (operation, result) => { if (operation !== "inspect_artifact") return []; - const parsed = artifactInspectionResultSchema.parse(result); - const inspection = canBorrowParsedJsonSnapshot(result, parsed) - ? result - : parsed; - const substeps = immutableJsonSnapshotMember(inspection, "substeps"); - return inspection.substeps.map((step, index) => { - const ownedStep = immutableJsonSnapshotMember(substeps, String(index)); - immutableJsonSnapshotMember(ownedStep, "evidence"); - return step.evidence; - }); + const parsed = readArtifactInspectionSources(result); + if (!parsed.ok) throw parsed.error; + return parsed.value; }, }); }; diff --git a/tests/boundary/cli/analysisViewCli.test.ts b/tests/boundary/cli/analysisViewCli.test.ts index 816526f118..32621844a1 100644 --- a/tests/boundary/cli/analysisViewCli.test.ts +++ b/tests/boundary/cli/analysisViewCli.test.ts @@ -38,7 +38,10 @@ import { artifactInspectionResultSchema } from "../../../src/domain/artifactInsp import type { JsonValue } from "../../../src/domain/jsonValue.js"; import { z } from "zod"; import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js"; -import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { + createEvidenceBundle, + parseEvidenceBundle, +} from "../../../src/domain/evidenceBundle.js"; import { inspectEvidenceBundle } from "../../../src/application/investigation/InspectEvidenceBundle.js"; cliTest( @@ -264,11 +267,72 @@ cliTest( const writer = new ZipWriter(new Uint8ArrayWriter()); await writer.add("main.js", new TextReader("console.log('portable');\n")); await writeFile(archive, await writer.close()); + const bundlePath = join(root, "inspection-bundle.json"); const inspected = await cli.run({ - arguments: ["inspect-artifact", archive, "--json"], + arguments: [ + "inspect-artifact", + archive, + "--detail", + "summary", + "--evidence_output", + bundlePath, + "--json", + ], }); expect(inspected.exitCode).toBe(0); - const parent = parseEvidence(inspected.json); + const savedBundle = parseEvidenceBundle( + JSON.parse(await readFile(bundlePath, "utf8")), + ); + const parent = savedBundle.records.find( + (record) => record.operation === "inspect_artifact", + )!; + const atomicId = artifactInspectionResultSchema.parse( + parent.normalized_result, + ).substeps[0]!.evidence_id; + expect(savedBundle.records.map((record) => record.evidence_id)).toContain( + atomicId, + ); + const call = await cli.sharedMcpCall?.(); + if (call === undefined) throw new Error("Expected shared MCP session"); + expect( + (await call("import_evidence_bundle", { path: bundlePath })).isError, + ).not.toBe(true); + const atomicView = await call("inspect_analysis_view", { + source: { kind: "retained-evidence", evidence_id: atomicId }, + view: { + kind: "inventory", + collection: "occurrences", + logical_paths: ["main.js"], + }, + }); + expect(atomicView.isError).not.toBe(true); + expect( + parseEvidence(atomicView.structuredContent).normalized_result, + ).toMatchObject({ + parent_evidence_id: atomicId, + items: [expect.objectContaining({ logical_path: "main.js" })], + }); + const unsupportedRequest = join(root, "unsupported-view.json"); + const unsupportedInput = { + source: { kind: "inline", evidence: parent }, + view: { kind: "page", collection: "sections" }, + }; + await writeFile(unsupportedRequest, JSON.stringify(unsupportedInput)); + const unsupported = await cli.run({ + arguments: ["inspect-analysis-view", unsupportedRequest, "--json"], + }); + expect(unsupported.exitCode).toBe(1); + expect(unsupported.json).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["view"], + message: "Artifact Evidence supports summary and inventory views.", + }, + ], + }, + }); await unlink(archive); const request = join(root, "view.json"); await writeFile(