mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(artifacts)!: authenticate complete inspection and summary inputs
BREAKING CHANGE: Artifact inventories require scoped coverage. Obsolete coverage_status and prose-based coverage inference are removed. Inspection carriers authenticate every retained inventory substep.
This commit is contained in:
1 parent
db8128dc0d
commit
0405c9d361
22 files changed
+732
-353
No files matched your search
@@ -0,0 +1,166 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js";
|
||||
import { ARTIFACT_COMPARISON_EXAMPLE } from "../../contracts/artifactComparisonExample.js";
|
||||
import { artifactInventorySummaryViewResultSchema } from "../../domain/analysisView/analysisView.js";
|
||||
import {
|
||||
createEvidence,
|
||||
parseEvidence,
|
||||
type Evidence,
|
||||
} from "../../domain/evidence.js";
|
||||
import {
|
||||
EvidenceIntegrityError,
|
||||
EvidenceReferenceError,
|
||||
} from "../../domain/evidenceErrors.js";
|
||||
import { resolveArtifactComparisonEvidence } from "./ArtifactComparisonEvidence.js";
|
||||
|
||||
const artifactSummary = () => {
|
||||
const parent = ARTIFACT_COMPARISON_EXAMPLE.left;
|
||||
const summary = inspectAnalysisView(
|
||||
{
|
||||
source: { kind: "inline", evidence: parent },
|
||||
view: { kind: "summary" },
|
||||
},
|
||||
(evidenceId) => (evidenceId === parent.evidence_id ? parent : undefined),
|
||||
);
|
||||
if (!summary.ok) throw summary.error;
|
||||
return { parent, summary: summary.value };
|
||||
};
|
||||
|
||||
const recreateSummary = (
|
||||
summary: Evidence,
|
||||
normalizedResult: Evidence["normalized_result"],
|
||||
parameters: Evidence["parameters"] = summary.parameters,
|
||||
): Evidence => {
|
||||
if (summary.subject === null)
|
||||
throw new Error("artifact summary fixture must identify its subject");
|
||||
return createEvidence(
|
||||
{
|
||||
path: summary.subject.local_path,
|
||||
sha256: summary.subject.digest.sha256,
|
||||
format: summary.subject.format,
|
||||
...(summary.subject.architecture === null
|
||||
? {}
|
||||
: { architecture: summary.subject.architecture }),
|
||||
},
|
||||
summary.provider,
|
||||
{
|
||||
predicateType: summary.predicate_type,
|
||||
operation: summary.operation,
|
||||
parameters,
|
||||
result: normalizedResult,
|
||||
...(summary.analysis_profile === null
|
||||
? {}
|
||||
: { analysisProfile: summary.analysis_profile }),
|
||||
rawResult: summary.raw_result,
|
||||
confidence: summary.confidence,
|
||||
authority: summary.authority,
|
||||
environment: summary.environment,
|
||||
limitations: summary.limitations,
|
||||
...(summary.relationship_coverage === null
|
||||
? {}
|
||||
: { relationshipCoverage: summary.relationship_coverage }),
|
||||
locations: summary.locations,
|
||||
evidenceLinks: summary.evidence_links,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
it("reports a missing retained parent instead of treating summary facts as inventory", () => {
|
||||
const { summary } = artifactSummary();
|
||||
const resolved = resolveArtifactComparisonEvidence(summary, undefined);
|
||||
expect(resolved.ok).toBe(false);
|
||||
if (!resolved.ok) {
|
||||
expect(resolved.error).toBeInstanceOf(EvidenceReferenceError);
|
||||
expect(resolved.error).toMatchObject({ reason: "missing" });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses retained Evidence resolution to reject a mismatched parent identity", () => {
|
||||
const { summary } = artifactSummary();
|
||||
const otherParent = ARTIFACT_COMPARISON_EXAMPLE.right;
|
||||
const resolved = resolveArtifactComparisonEvidence(
|
||||
summary,
|
||||
() => otherParent,
|
||||
);
|
||||
expect(resolved.ok).toBe(false);
|
||||
if (!resolved.ok) {
|
||||
expect(resolved.error).toBeInstanceOf(EvidenceReferenceError);
|
||||
expect(resolved.error).toMatchObject({ reason: "identity_mismatch" });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a summary that declares the wrong parent operation", () => {
|
||||
const { parent, summary } = artifactSummary();
|
||||
const summaryResult = artifactInventorySummaryViewResultSchema.parse(
|
||||
summary.normalized_result,
|
||||
);
|
||||
const normalizedResult = {
|
||||
...summaryResult,
|
||||
parent_operation: "inspect_artifact",
|
||||
};
|
||||
const forged = recreateSummary(summary, normalizedResult);
|
||||
const resolved = resolveArtifactComparisonEvidence(forged, (evidenceId) =>
|
||||
evidenceId === parent.evidence_id ? parent : undefined,
|
||||
);
|
||||
expect(resolved.ok).toBe(false);
|
||||
if (!resolved.ok)
|
||||
expect(resolved.error).toBeInstanceOf(EvidenceIntegrityError);
|
||||
});
|
||||
|
||||
it("rejects summary parameters that do not identify the projected parent", () => {
|
||||
const { parent, summary } = artifactSummary();
|
||||
const forged = recreateSummary(summary, summary.normalized_result, {
|
||||
...summary.parameters,
|
||||
parent_evidence_id: `ev_${"f".repeat(64)}`,
|
||||
});
|
||||
const resolved = resolveArtifactComparisonEvidence(forged, (evidenceId) =>
|
||||
evidenceId === parent.evidence_id ? parent : undefined,
|
||||
);
|
||||
expect(resolved.ok).toBe(false);
|
||||
if (!resolved.ok)
|
||||
expect(resolved.error).toBeInstanceOf(EvidenceIntegrityError);
|
||||
});
|
||||
|
||||
it("rejects summary facts that do not reproject from the complete parent", () => {
|
||||
const { parent, summary } = artifactSummary();
|
||||
const summaryResult = artifactInventorySummaryViewResultSchema.parse(
|
||||
summary.normalized_result,
|
||||
);
|
||||
const normalizedResult = {
|
||||
...summaryResult,
|
||||
summary: {
|
||||
...summaryResult.summary,
|
||||
available_counts: {
|
||||
...summaryResult.summary.available_counts,
|
||||
nodes: summaryResult.summary.available_counts.nodes + 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
const forged = recreateSummary(summary, normalizedResult);
|
||||
const resolved = resolveArtifactComparisonEvidence(forged, (evidenceId) =>
|
||||
evidenceId === parent.evidence_id ? parent : undefined,
|
||||
);
|
||||
expect(resolved.ok).toBe(false);
|
||||
if (!resolved.ok)
|
||||
expect(resolved.error).toBeInstanceOf(EvidenceIntegrityError);
|
||||
});
|
||||
|
||||
it("accepts display-only subject changes excluded from Evidence identity", () => {
|
||||
const { parent, summary } = artifactSummary();
|
||||
if (summary.subject === null)
|
||||
throw new Error("artifact summary fixture must identify its subject");
|
||||
const displayOnly = parseEvidence({
|
||||
...summary,
|
||||
subject: {
|
||||
...summary.subject,
|
||||
name: "renamed.app",
|
||||
local_path: "/display-only/renamed.app",
|
||||
},
|
||||
});
|
||||
const resolved = resolveArtifactComparisonEvidence(
|
||||
displayOnly,
|
||||
(evidenceId) => (evidenceId === parent.evidence_id ? parent : undefined),
|
||||
);
|
||||
expect(resolved).toEqual({ ok: true, value: parent });
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { EvidenceLookup } from "../EvidenceInputResolver.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
import { inspectAnalysisViewValidated } from "../analysisView/AnalysisViewService.js";
|
||||
import { artifactInventorySummaryViewResultSchema } from "../../domain/analysisView/analysisView.js";
|
||||
import {
|
||||
EvidenceIntegrityError,
|
||||
EvidenceReferenceError,
|
||||
} from "../../domain/evidenceErrors.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
|
||||
/** Resolve only an artifact summary's exact retained owner before comparison. */
|
||||
export const resolveArtifactComparisonEvidence = (
|
||||
evidence: Evidence,
|
||||
evidenceById: EvidenceLookup | undefined,
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
if (evidence.operation !== "inspect_analysis_view") return ok(evidence);
|
||||
|
||||
const summary = artifactInventorySummaryViewResultSchema.safeParse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
if (!summary.success)
|
||||
return err(
|
||||
new AnalysisInputError("compare_artifacts", undefined, [
|
||||
{
|
||||
path: ["evidence"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Artifact comparison accepts only an artifact inventory summary from inspect_analysis_view.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const parentId = summary.data.parent_evidence_id;
|
||||
const parent = evidenceById?.(parentId);
|
||||
if (parent === undefined)
|
||||
return err(
|
||||
new EvidenceReferenceError(
|
||||
parentId,
|
||||
"missing",
|
||||
"retained complete artifact inventory Evidence",
|
||||
null,
|
||||
),
|
||||
);
|
||||
const reprojected = inspectAnalysisViewValidated(
|
||||
{
|
||||
source: { kind: "retained-evidence", evidence_id: parentId },
|
||||
view: { kind: "summary" },
|
||||
},
|
||||
(evidenceId) => (evidenceId === parentId ? parent : undefined),
|
||||
);
|
||||
if (!reprojected.ok) return reprojected;
|
||||
if (reprojected.value.evidence_id !== evidence.evidence_id)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Artifact summary does not match its retained parent inventory.",
|
||||
),
|
||||
);
|
||||
return ok(parent);
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { realpath } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
artifactInventoryOutputSchema,
|
||||
type ArtifactInventoryOutput,
|
||||
artifactInventoryResultSchema,
|
||||
type ArtifactInventoryResult,
|
||||
} from "../../domain/artifactGraph.js";
|
||||
import { abortIfNeeded } from "../ArtifactHash.js";
|
||||
import { scanCanonicalArtifactInventoryInScope } from "./scanCanonical.js";
|
||||
@@ -13,9 +13,9 @@ import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySn
|
||||
export const inventoryArtifact = async (
|
||||
inputPath: string,
|
||||
options: ArtifactInventoryOptions,
|
||||
): Promise<ArtifactInventoryOutput> => {
|
||||
): Promise<ArtifactInventoryResult> => {
|
||||
const snapshot = await scanArtifactInventory(inputPath, options);
|
||||
return artifactInventoryOutputSchema.parse(snapshot);
|
||||
return artifactInventoryResultSchema.parse(snapshot);
|
||||
};
|
||||
|
||||
/** Scan an artifact once and retain the complete immutable graph for projection. */
|
||||
|
||||
@@ -289,7 +289,6 @@ const buildInventorySnapshot = async (
|
||||
provenance: reader?.provenance() ?? [],
|
||||
integrity_contradictions: integrityContradictions,
|
||||
coverage,
|
||||
coverage_status: coverage.status === "complete" ? "complete" : "partial",
|
||||
limitations: buildLimitations(
|
||||
[...traversal.limitations, ...edgeResult.limitations],
|
||||
integrityContradictions,
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { digestCanonicalValue } from "../domain/canonicalDigest.js";
|
||||
import {
|
||||
artifactGraphDigest,
|
||||
artifactIdForContent,
|
||||
artifactManifestId,
|
||||
occurrenceIdForLocation,
|
||||
} from "../domain/artifactIdentity.js";
|
||||
import type {
|
||||
ArtifactEdge,
|
||||
ArtifactNode,
|
||||
ArtifactOccurrence,
|
||||
IntegrityContradiction,
|
||||
} from "../domain/artifactGraph.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
|
||||
const inventory = (digit: string) => {
|
||||
const sha = digit.repeat(64);
|
||||
const artifactId = `art_${digestCanonicalValue({ sha256: sha }, "Artifact example")}`;
|
||||
const occurrenceId = `occ_${digestCanonicalValue({ root: artifactId }, "Artifact example")}`;
|
||||
const nodes = [
|
||||
const artifactId = artifactIdForContent(sha);
|
||||
const nodes: ArtifactNode[] = [
|
||||
{
|
||||
artifact_id: artifactId,
|
||||
format: "file",
|
||||
@@ -18,9 +28,13 @@ const inventory = (digit: string) => {
|
||||
limitations: [],
|
||||
},
|
||||
];
|
||||
const occurrences = [
|
||||
const occurrences: ArtifactOccurrence[] = [
|
||||
{
|
||||
occurrence_id: occurrenceId,
|
||||
occurrence_id: occurrenceIdForLocation({
|
||||
rootArtifactId: artifactId,
|
||||
logicalPath: ".",
|
||||
entryKind: "file",
|
||||
}),
|
||||
artifact_id: artifactId,
|
||||
parent_occurrence_id: null,
|
||||
logical_path: ".",
|
||||
@@ -36,36 +50,30 @@ const inventory = (digit: string) => {
|
||||
limitations: [],
|
||||
},
|
||||
];
|
||||
const graphSha256 = digestCanonicalValue(
|
||||
{
|
||||
nodes,
|
||||
occurrences,
|
||||
edges: [],
|
||||
integrity_contradictions: [],
|
||||
},
|
||||
"Artifact example",
|
||||
);
|
||||
const edges: ArtifactEdge[] = [];
|
||||
const integrityContradictions: IntegrityContradiction[] = [];
|
||||
const graphSha256 = artifactGraphDigest({
|
||||
nodes,
|
||||
occurrences,
|
||||
edges,
|
||||
contradictions: integrityContradictions,
|
||||
});
|
||||
return jsonValueSchema.parse({
|
||||
manifest: {
|
||||
manifest_id: `agm_${digestCanonicalValue(
|
||||
{
|
||||
root_artifact_id: artifactId,
|
||||
graph_sha256: graphSha256,
|
||||
},
|
||||
"Artifact example",
|
||||
)}`,
|
||||
manifest_id: artifactManifestId(artifactId, graphSha256),
|
||||
root_artifact_id: artifactId,
|
||||
root_sha256: sha,
|
||||
root_format: "file",
|
||||
graph_sha256: graphSha256,
|
||||
node_count: nodes.length,
|
||||
occurrence_count: occurrences.length,
|
||||
edge_count: 0,
|
||||
edge_count: edges.length,
|
||||
},
|
||||
nodes,
|
||||
occurrences,
|
||||
edges: [],
|
||||
edges,
|
||||
provenance: [],
|
||||
coverage: { scope: "root", status: "complete" },
|
||||
limitations: [],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -104,7 +104,7 @@ export const SESSION_TOOL_CONTRACTS = [
|
||||
),
|
||||
session(
|
||||
"compare_artifacts",
|
||||
"Compare complete artifact inventories by logical occurrence path, content identity, metadata, and graph relations. Pass the inventory Evidence nested in each inspect_artifact result as left and right. Every delta cites both inputs, and gaps yield truncated or unknown, never equivalence. Returns every change inline.",
|
||||
"Compare complete artifact inventories by logical occurrence path, content identity, metadata, and graph relations. Pass the inventory Evidence nested in each inspect_artifact result as left and right, or pass its inspect_analysis_view summary while the complete parent Evidence remains retained in this session. Summary facts are not treated as a complete inventory; comparison resolves the exact retained parent. Every delta cites both complete inputs, and gaps yield truncated or unknown, never equivalence. Returns every change inline.",
|
||||
artifactComparisonInputSchema,
|
||||
),
|
||||
session(
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
listArchitecturesSchema,
|
||||
} from "../domain/native/nativeInspection.js";
|
||||
import { artifactExtractionResultSchema } from "../domain/artifactGraph.js";
|
||||
import { artifactInspectionOutputSchema } from "../domain/artifactInspection.js";
|
||||
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
|
||||
import { interfaceBuilderAnalysisSchema } from "../domain/apple/interfaceBuilderGraph.js";
|
||||
import { dylibResolutionResultSchema } from "../domain/apple/dylibResolution.js";
|
||||
import { keyedArchiveResultSchema } from "../domain/apple/keyedArchive.js";
|
||||
@@ -404,7 +404,7 @@ export const nativeOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
export const artifactOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
inspect_artifact: evidenceResultOf(
|
||||
z.union([
|
||||
artifactInspectionOutputSchema,
|
||||
artifactInspectionResultSchema,
|
||||
artifactInventorySummaryViewResultSchema,
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -61,6 +61,7 @@ describe("artifact graph path and provenance fields", () => {
|
||||
effects: ["read"],
|
||||
},
|
||||
],
|
||||
coverage: { scope: "container-entries", status: "complete" },
|
||||
integrity_contradictions: [],
|
||||
limitations: [],
|
||||
});
|
||||
@@ -69,4 +70,35 @@ describe("artifact graph path and provenance fields", () => {
|
||||
expect(parsed.provenance[0]?.arguments).toHaveLength(1_025);
|
||||
expect(parsed.provenance[0]?.arguments[0]).toBe(arguments_[0]);
|
||||
});
|
||||
|
||||
it("requires current scoped coverage and rejects the obsolete status alias", () => {
|
||||
const inventory = {
|
||||
manifest: {
|
||||
manifest_id: `agm_${"b".repeat(64)}`,
|
||||
root_artifact_id: `art_${"a".repeat(64)}`,
|
||||
root_sha256: "c".repeat(64),
|
||||
root_format: "plist",
|
||||
graph_sha256: "d".repeat(64),
|
||||
node_count: 1,
|
||||
occurrence_count: 1,
|
||||
edge_count: 0,
|
||||
},
|
||||
nodes: [],
|
||||
occurrences: [],
|
||||
edges: [],
|
||||
provenance: [],
|
||||
integrity_contradictions: [],
|
||||
limitations: [],
|
||||
};
|
||||
|
||||
expect(artifactInventoryResultSchema.safeParse(inventory).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
artifactInventoryResultSchema.safeParse({
|
||||
...inventory,
|
||||
coverage_status: "complete",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -196,32 +196,18 @@ export type ArtifactInventoryCoverage = z.infer<
|
||||
typeof artifactInventoryCoverageSchema
|
||||
>;
|
||||
|
||||
/** Complete inventory observation containing the stable artifact graph. */
|
||||
export const artifactInventoryResultSchema = z.object({
|
||||
/** Current inventory observation containing the stable artifact graph. */
|
||||
export const artifactInventoryResultSchema = z.strictObject({
|
||||
manifest: artifactGraphManifestSchema,
|
||||
nodes: z.array(artifactNodeSchema),
|
||||
occurrences: z.array(artifactOccurrenceSchema),
|
||||
edges: z.array(artifactEdgeSchema),
|
||||
provenance: z.array(artifactCommandSchema),
|
||||
integrity_contradictions: z.array(integrityContradictionSchema).default([]),
|
||||
// Omitted by older retained Evidence; the inventory parser owns its compatibility policy.
|
||||
coverage: artifactInventoryCoverageSchema.optional(),
|
||||
coverage: artifactInventoryCoverageSchema,
|
||||
limitations: z.array(z.string()),
|
||||
/** Optional only for persisted inventory Evidence written before this field. */
|
||||
coverage_status: z.enum(["complete", "partial"]).optional(),
|
||||
});
|
||||
|
||||
/** Current producers always declare coverage; historical Evidence inputs may omit it. */
|
||||
export const artifactInventoryOutputSchema =
|
||||
artifactInventoryResultSchema.safeExtend({
|
||||
coverage: artifactInventoryCoverageSchema,
|
||||
coverage_status:
|
||||
artifactInventoryResultSchema.shape.coverage_status.unwrap(),
|
||||
});
|
||||
export type ArtifactInventoryOutput = z.infer<
|
||||
typeof artifactInventoryOutputSchema
|
||||
>;
|
||||
|
||||
/** One safely materialized artifact beneath caller-approved output root. */
|
||||
const extractedArtifactSchema = z.object({
|
||||
artifact_id: artifactIdSchema,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
artifactInventoryOutputSchema,
|
||||
artifactInventoryResultSchema,
|
||||
type ArtifactInventoryResult,
|
||||
} from "./artifactGraph.js";
|
||||
@@ -75,7 +74,10 @@ export const artifactInspectionResultSchema = z.strictObject({
|
||||
operation: z.literal("inventory_artifact"),
|
||||
status: z.literal("completed"),
|
||||
evidence_id: evidenceIdSchema,
|
||||
evidence: evidenceSchema,
|
||||
evidence: evidenceSchema.safeExtend({
|
||||
operation: z.literal("inventory_artifact"),
|
||||
normalized_result: artifactInventoryResultSchema,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
@@ -86,7 +88,7 @@ export const artifactInspectionResultSchema = z.strictObject({
|
||||
next_probes: z.array(nextProbeSchema),
|
||||
coverage: z.strictObject({
|
||||
status: z.enum(["complete-within-substeps", "partial", "truncated"]),
|
||||
substeps_completed: z.literal(1),
|
||||
substeps_completed: z.number().int().min(1),
|
||||
observations_retained: z.number().int().min(0),
|
||||
observations_omitted: z.number().int().min(0),
|
||||
hypotheses_retained: z.number().int().min(0),
|
||||
@@ -104,24 +106,6 @@ export type ArtifactInspectionResult = z.infer<
|
||||
typeof artifactInspectionResultSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* The current producer guarantees a concrete inventory payload. Specialize
|
||||
* its output without narrowing the historical inspection/Evidence input
|
||||
* parser or applying inventory defaults inside an authenticated imported record.
|
||||
*/
|
||||
export const artifactInspectionOutputSchema =
|
||||
artifactInspectionResultSchema.safeExtend({
|
||||
substeps: z
|
||||
.array(
|
||||
artifactInspectionResultSchema.shape.substeps.element.safeExtend({
|
||||
evidence: evidenceSchema.safeExtend({
|
||||
normalized_result: artifactInventoryOutputSchema,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
});
|
||||
|
||||
/** Project one successful inventory Evidence record into an inspection report. */
|
||||
export const createArtifactInspection = (
|
||||
inventoryInput: unknown,
|
||||
|
||||
@@ -18,10 +18,6 @@ import {
|
||||
} from "./artifactIdentity.js";
|
||||
import { parseEvidence, type Evidence } from "./evidence.js";
|
||||
import { artifactInspectionResultSchema } from "./artifactInspection.js";
|
||||
import {
|
||||
isZipPackageFormat,
|
||||
isZipInventoryScopeLimitation,
|
||||
} from "./zipPackageFormat.js";
|
||||
|
||||
/** Complete or partially assembled inventory used by comparison workflows. */
|
||||
export interface InventorySet {
|
||||
@@ -54,34 +50,42 @@ export interface ParsedInventorySet {
|
||||
export const parseArtifactInventoryEvidence = (
|
||||
input: unknown,
|
||||
): ParsedInventorySet => {
|
||||
const pages = (Array.isArray(input) ? input : [input]).map((value) => {
|
||||
const evidence = parseEvidence(value);
|
||||
const inventoryEvidence =
|
||||
evidence.operation === "inventory_artifact"
|
||||
? evidence
|
||||
: evidence.operation === "inspect_artifact"
|
||||
? artifactInspectionResultSchema.parse(evidence.normalized_result)
|
||||
.substeps[0]?.evidence
|
||||
: undefined;
|
||||
const evidence = (Array.isArray(input) ? input : [input]).map(parseEvidence);
|
||||
const pages = evidence.flatMap((carrier) => {
|
||||
let inventoryEvidence: readonly Evidence[] | undefined;
|
||||
if (carrier.operation === "inventory_artifact") {
|
||||
inventoryEvidence = [carrier];
|
||||
} else if (carrier.operation === "inspect_artifact") {
|
||||
const inspection = artifactInspectionResultSchema.parse(
|
||||
carrier.normalized_result,
|
||||
);
|
||||
if (inspection.coverage.substeps_completed !== inspection.substeps.length)
|
||||
throw new TypeError(
|
||||
"Artifact inspection substep count does not match its coverage declaration",
|
||||
);
|
||||
inventoryEvidence = inspection.substeps.map((substep) =>
|
||||
parseEvidence(substep.evidence),
|
||||
);
|
||||
}
|
||||
if (inventoryEvidence === undefined)
|
||||
throw new TypeError(
|
||||
"Artifact comparison requires inspect_artifact Evidence",
|
||||
);
|
||||
const inventory = artifactInventoryResultSchema.parse(
|
||||
inventoryEvidence.normalized_result,
|
||||
);
|
||||
validateArtifactInventorySubject(
|
||||
inventoryEvidence.subject,
|
||||
inventory.manifest,
|
||||
);
|
||||
validateInventoryPage(inventory);
|
||||
return { evidence, inventory };
|
||||
return inventoryEvidence.map((pageEvidence) => {
|
||||
const inventory = artifactInventoryResultSchema.parse(
|
||||
pageEvidence.normalized_result,
|
||||
);
|
||||
validateArtifactInventorySubject(
|
||||
pageEvidence.subject,
|
||||
inventory.manifest,
|
||||
);
|
||||
validateInventoryPage(inventory);
|
||||
return inventory;
|
||||
});
|
||||
});
|
||||
return {
|
||||
evidence: pages.map(({ evidence }) => evidence),
|
||||
inventory: assembleInventoryResults(
|
||||
pages.map(({ inventory }) => inventory),
|
||||
),
|
||||
evidence,
|
||||
inventory: assembleInventoryResults(pages),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -137,10 +141,7 @@ const assembleInventoryResults = (
|
||||
throw new TypeError("Artifact inventory requires Evidence pages");
|
||||
const manifestJson = canonicalJson(first.manifest, "Artifact inventory");
|
||||
const coverageJson = canonicalJson(
|
||||
{
|
||||
coverage: first.coverage ?? null,
|
||||
coverage_status: first.coverage_status ?? null,
|
||||
},
|
||||
first.coverage,
|
||||
"Artifact inventory coverage",
|
||||
);
|
||||
const nodes = new Map<string, ArtifactNode>();
|
||||
@@ -150,13 +151,8 @@ const assembleInventoryResults = (
|
||||
if (canonicalJson(page.manifest, "Artifact inventory") !== manifestJson)
|
||||
throw new TypeError("Artifact inventory pages do not share one manifest");
|
||||
if (
|
||||
canonicalJson(
|
||||
{
|
||||
coverage: page.coverage ?? null,
|
||||
coverage_status: page.coverage_status ?? null,
|
||||
},
|
||||
"Artifact inventory coverage",
|
||||
) !== coverageJson
|
||||
canonicalJson(page.coverage, "Artifact inventory coverage") !==
|
||||
coverageJson
|
||||
)
|
||||
throw new TypeError(
|
||||
"Artifact inventory pages do not share one coverage declaration",
|
||||
@@ -221,7 +217,7 @@ const orderedInventory = (
|
||||
edges,
|
||||
integrityContradictions: first.integrity_contradictions,
|
||||
limitations,
|
||||
coverage: resolveInventoryCoverage({ ...first, limitations }),
|
||||
coverage: first.coverage,
|
||||
complete:
|
||||
nodes.length === first.manifest.node_count &&
|
||||
occurrences.length === first.manifest.occurrence_count &&
|
||||
@@ -267,61 +263,6 @@ const orderOccurrences = (
|
||||
});
|
||||
};
|
||||
|
||||
// Normalize both retained coverage formats at this owning boundary.
|
||||
const resolveInventoryCoverage = (
|
||||
inventory: ArtifactInventoryResult,
|
||||
): ArtifactInventoryCoverage => {
|
||||
const legacy = legacyInventoryCoverage(
|
||||
inventory.manifest.root_format,
|
||||
inventory.limitations,
|
||||
inventory.integrity_contradictions.length,
|
||||
);
|
||||
if (inventory.coverage !== undefined) {
|
||||
if (
|
||||
inventory.coverage_status !== undefined &&
|
||||
(inventory.coverage.status === "complete") !==
|
||||
(inventory.coverage_status === "complete")
|
||||
)
|
||||
throw new TypeError(
|
||||
"Artifact inventory has conflicting coverage declarations",
|
||||
);
|
||||
return inventory.coverage;
|
||||
}
|
||||
return inventory.coverage_status === undefined
|
||||
? legacy
|
||||
: {
|
||||
...legacy,
|
||||
status:
|
||||
inventory.coverage_status === "complete" ? "complete" : "unknown",
|
||||
};
|
||||
};
|
||||
|
||||
// Retained pre-coverage records cannot classify arbitrary prose. Recognize only
|
||||
// the historical producer vocabulary here; unfamiliar diagnostics remain unknown.
|
||||
const legacyInventoryCoverage = (
|
||||
format: ArtifactInventoryResult["manifest"]["root_format"],
|
||||
limitations: readonly string[],
|
||||
contradictionCount: number,
|
||||
): ArtifactInventoryCoverage => ({
|
||||
scope:
|
||||
isZipPackageFormat(format) ||
|
||||
["directory", "asar", "dmg", "pkg", "mach-o-universal"].includes(format)
|
||||
? "container-entries"
|
||||
: "root",
|
||||
status: limitations.some(
|
||||
(limitation) =>
|
||||
limitation !== "Artifact has no child container entries." &&
|
||||
!isZipInventoryScopeLimitation(limitation) &&
|
||||
!(
|
||||
contradictionCount > 0 &&
|
||||
limitation ===
|
||||
`${String(contradictionCount)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`
|
||||
),
|
||||
)
|
||||
? "unknown"
|
||||
: "complete",
|
||||
});
|
||||
|
||||
const validateCompleteInventory = (inventory: InventorySet): void => {
|
||||
const nodeIds = new Set(inventory.nodes.map(({ artifact_id: id }) => id));
|
||||
const occurrenceIds = new Set(
|
||||
|
||||
@@ -7,9 +7,6 @@ import type {
|
||||
|
||||
/** Immutable inventory produced by one complete artifact scan. */
|
||||
export interface ArtifactInventorySnapshot {
|
||||
readonly coverage_status: NonNullable<
|
||||
ArtifactInventoryResult["coverage_status"]
|
||||
>;
|
||||
readonly manifest: ArtifactInventoryResult["manifest"];
|
||||
readonly nodes: readonly ArtifactNode[];
|
||||
readonly occurrences: ArtifactInventoryResult["occurrences"];
|
||||
@@ -18,6 +15,6 @@ export interface ArtifactInventorySnapshot {
|
||||
ArtifactInventoryResult["provenance"][number]
|
||||
>;
|
||||
readonly integrity_contradictions: readonly IntegrityContradiction[];
|
||||
readonly coverage?: ArtifactInventoryCoverage;
|
||||
readonly coverage: ArtifactInventoryCoverage;
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
@@ -55,9 +55,3 @@ export const zipPackageFormatForPath = (
|
||||
/** Inventory lists entries, not all bytes surrounding the central directory. */
|
||||
export const ZIP_NON_ENTRY_LIMITATION =
|
||||
"ZIP inventory lists central-directory entries only. Non-entry bytes, including an APK Signing Block before the central directory or a ZIP comment after it, are not occurrences.";
|
||||
|
||||
/** Recognize the same inventory scope in current and previously retained Evidence. */
|
||||
export const isZipInventoryScopeLimitation = (limitation: string): boolean =>
|
||||
limitation === ZIP_NON_ENTRY_LIMITATION ||
|
||||
limitation ===
|
||||
"ZIP inventory lists central-directory entries only. Bytes after the central directory, including an APK Signing Block or a ZIP comment, are not occurrences.";
|
||||
@@ -180,24 +180,29 @@ const evidenceValues = (
|
||||
if (kind === "evidence") return [evidence.evidence_id];
|
||||
if (kind === "capture")
|
||||
return isProcessCaptureEvidence(evidence) ? [evidence.evidence_id] : [];
|
||||
const inventoryResult =
|
||||
evidence.operation === "inventory_artifact"
|
||||
? evidence.normalized_result
|
||||
: evidence.operation === "inspect_artifact"
|
||||
? (() => {
|
||||
const inspection = artifactInspectionResultSchema.safeParse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
return inspection.success
|
||||
? inspection.data.substeps[0]?.evidence.normalized_result
|
||||
: undefined;
|
||||
})()
|
||||
: undefined;
|
||||
if (inventoryResult === undefined) return [];
|
||||
const inventory = artifactInventoryResultSchema.safeParse(inventoryResult);
|
||||
if (!inventory.success) return [];
|
||||
if (kind === "manifest") return [inventory.data.manifest.manifest_id];
|
||||
return inventory.data.occurrences.map(({ occurrence_id: id }) => id);
|
||||
let inventoryResults: readonly unknown[];
|
||||
if (evidence.operation === "inventory_artifact")
|
||||
inventoryResults = [evidence.normalized_result];
|
||||
else if (evidence.operation === "inspect_artifact") {
|
||||
const inspection = artifactInspectionResultSchema.safeParse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
if (!inspection.success) return [];
|
||||
inventoryResults = inspection.data.substeps.map(
|
||||
({ evidence: inventoryEvidence }) => inventoryEvidence.normalized_result,
|
||||
);
|
||||
} else return [];
|
||||
const values: string[] = [];
|
||||
for (const inventoryResult of inventoryResults) {
|
||||
const inventory = artifactInventoryResultSchema.safeParse(inventoryResult);
|
||||
if (!inventory.success) continue;
|
||||
if (kind === "manifest") values.push(inventory.data.manifest.manifest_id);
|
||||
else
|
||||
values.push(
|
||||
...inventory.data.occurrences.map(({ occurrence_id: id }) => id),
|
||||
);
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
const isProcessCaptureEvidence = (evidence: LedgerEvidence): boolean => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toolContract } from "../contracts/toolContracts.js";
|
||||
import { compareArtifacts } from "../domain/artifactComparison.js";
|
||||
import { createEvidence, parseEvidence } from "../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { resolveArtifactComparisonEvidence } from "../application/artifacts/ArtifactComparisonEvidence.js";
|
||||
import type { RecordUnknownInput } from "../domain/residualUnknown.js";
|
||||
import { recordDerivedEvidence } from "./recordDerivedEvidence.js";
|
||||
import { runDerivedOperation } from "./runDerivedOperation.js";
|
||||
@@ -20,6 +21,9 @@ export const registerArtifactComparisonTool = (
|
||||
contract: ReturnType<typeof toolContract<"compare_artifacts">>,
|
||||
): void => {
|
||||
const admission = withAdmittedAnalysis({ kind: "session", session });
|
||||
const evidenceById = (evidenceId: string) =>
|
||||
session.evidenceForAnalysis?.(evidenceId) ??
|
||||
session.evidenceById(evidenceId);
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
toolRegistrationOptions(contract),
|
||||
@@ -34,8 +38,20 @@ export const registerArtifactComparisonTool = (
|
||||
context,
|
||||
contract.name,
|
||||
() => {
|
||||
const left = parseEvidence(input.left);
|
||||
const right = parseEvidence(input.right);
|
||||
const suppliedLeft = parseEvidence(input.left);
|
||||
const suppliedRight = parseEvidence(input.right);
|
||||
const resolvedLeft = resolveArtifactComparisonEvidence(
|
||||
suppliedLeft,
|
||||
evidenceById,
|
||||
);
|
||||
if (!resolvedLeft.ok) throw resolvedLeft.error;
|
||||
const resolvedRight = resolveArtifactComparisonEvidence(
|
||||
suppliedRight,
|
||||
evidenceById,
|
||||
);
|
||||
if (!resolvedRight.ok) throw resolvedRight.error;
|
||||
const left = resolvedLeft.value;
|
||||
const right = resolvedRight.value;
|
||||
return { left, right, comparison: compareArtifacts(left, right) };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -105,7 +105,6 @@ const assertCoverageControls = async ({
|
||||
derive({
|
||||
...inventory,
|
||||
coverage: { scope: "root", status: "unknown" },
|
||||
coverage_status: "partial",
|
||||
limitations: [],
|
||||
}),
|
||||
);
|
||||
@@ -125,26 +124,14 @@ const assertCoverageControls = async ({
|
||||
limitations: expect.arrayContaining(["Artifact inventory scopes differ."]),
|
||||
});
|
||||
|
||||
const { coverage, coverage_status: _coverageStatus, ...legacy } = inventory;
|
||||
expect(coverage).toBeDefined();
|
||||
const retained = await compare(left, derive(legacy));
|
||||
expect(retained.normalized_result).toMatchObject({
|
||||
status: "unchanged",
|
||||
summary: { unchanged: 1, unknown: 0 },
|
||||
});
|
||||
for (const limitation of [
|
||||
"Child inventory is unavailable.",
|
||||
"Missing child observations; 1 integrity contradiction(s) were recorded.",
|
||||
]) {
|
||||
const unfamiliarLegacy = await compare(
|
||||
left,
|
||||
derive({ ...legacy, limitations: [limitation] }),
|
||||
);
|
||||
expect(unfamiliarLegacy.normalized_result).toMatchObject({
|
||||
status: "unknown",
|
||||
summary: { unchanged: 0, unknown: 1 },
|
||||
});
|
||||
}
|
||||
const { coverage: _coverage, ...withoutCoverage } = inventory;
|
||||
expect(() => artifactInventoryResultSchema.parse(withoutCoverage)).toThrow();
|
||||
expect(() =>
|
||||
artifactInventoryResultSchema.parse({
|
||||
...inventory,
|
||||
coverage_status: "complete",
|
||||
}),
|
||||
).toThrow();
|
||||
};
|
||||
|
||||
cliTest(
|
||||
|
||||
@@ -34,7 +34,7 @@ it("compares identical complete plist inventories across CLI and MCP and creates
|
||||
if (cliInventory === undefined)
|
||||
throw new Error("CLI inspection did not retain inventory Evidence");
|
||||
expect(cliInventory.normalized_result).toMatchObject({
|
||||
coverage_status: "complete",
|
||||
coverage: { scope: "root", status: "complete" },
|
||||
limitations: ["Artifact has no child container entries."],
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ it("compares identical complete plist inventories across CLI and MCP and creates
|
||||
if (mcpInventory === undefined)
|
||||
throw new Error("MCP inspection did not retain inventory Evidence");
|
||||
expect(mcpInventory.normalized_result).toMatchObject({
|
||||
coverage_status: "complete",
|
||||
coverage: { scope: "root", status: "complete" },
|
||||
limitations: ["Artifact has no child container entries."],
|
||||
});
|
||||
|
||||
|
||||
@@ -264,7 +264,7 @@ describe("case-distinct APK resource names", () => {
|
||||
await runProviderAnalysis(path, "inventory_artifact", {}),
|
||||
);
|
||||
expect(inventory.normalized_result).toMatchObject({
|
||||
coverage_status: "complete",
|
||||
coverage: { scope: "container-entries", status: "complete" },
|
||||
});
|
||||
const result = projectAndroidApplicationEvidence({
|
||||
inventory_evidence: [inventory],
|
||||
|
||||
@@ -9,12 +9,13 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { inventoryArtifact } from "../../fixtures/artifactInventory.js";
|
||||
import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";
|
||||
import { ARTIFACT_COMPARISON_EXAMPLE } from "../../../src/contracts/artifactComparisonExample.js";
|
||||
import {
|
||||
artifactComparisonResultSchema,
|
||||
compareArtifacts,
|
||||
} from "../../../src/domain/artifactComparison.js";
|
||||
import { parseArtifactInventoryEvidence } from "../../../src/domain/artifactInventoryEvidence.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createEvidence, type Evidence } from "../../../src/domain/evidence.js";
|
||||
import { jsonValueSchema } from "../../../src/domain/jsonValue.js";
|
||||
|
||||
const PROVIDER = {
|
||||
@@ -42,6 +43,66 @@ const observe = async (path: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
const inspectPages = (
|
||||
pages: readonly Evidence[],
|
||||
substepsCompleted = pages.length,
|
||||
) => {
|
||||
const first = pages[0];
|
||||
if (first === undefined) throw new Error("Missing inspection page");
|
||||
const inventory = artifactInventoryResultSchema.parse(
|
||||
first.normalized_result,
|
||||
);
|
||||
return createEvidence(
|
||||
{
|
||||
path: "artifact-inspection-example",
|
||||
sha256: inventory.manifest.root_sha256,
|
||||
format: inventory.manifest.root_format,
|
||||
},
|
||||
PROVIDER,
|
||||
{
|
||||
operation: "inspect_artifact",
|
||||
parameters: {},
|
||||
result: jsonValueSchema.parse({
|
||||
inspection_id: `ai_${"f".repeat(64)}`,
|
||||
subject: {
|
||||
manifest_id: inventory.manifest.manifest_id,
|
||||
root_artifact_id: inventory.manifest.root_artifact_id,
|
||||
root_sha256: inventory.manifest.root_sha256,
|
||||
root_format: inventory.manifest.root_format,
|
||||
},
|
||||
substeps: pages.map((evidence, index) => ({
|
||||
substep_id: `ais_${(index + 1).toString(16).padStart(64, "0")}`,
|
||||
operation: "inventory_artifact",
|
||||
status: "completed",
|
||||
evidence_id: evidence.evidence_id,
|
||||
evidence,
|
||||
})),
|
||||
observations: [],
|
||||
hypotheses: [],
|
||||
contradictions: [],
|
||||
unexplored_branches: [],
|
||||
next_probes: [],
|
||||
coverage: {
|
||||
status: "complete-within-substeps",
|
||||
substeps_completed: substepsCompleted,
|
||||
observations_retained: 0,
|
||||
observations_omitted: 0,
|
||||
hypotheses_retained: 0,
|
||||
hypotheses_omitted: 0,
|
||||
unexplored_branches_retained: 0,
|
||||
unexplored_branches_omitted: 0,
|
||||
next_probes_retained: 0,
|
||||
next_probes_omitted: 0,
|
||||
},
|
||||
evidence_links: pages.map(({ evidence_id }) => evidence_id),
|
||||
limitations: [],
|
||||
}),
|
||||
confidence: "observed",
|
||||
authority: "shipped-artifact",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const changedArtifactComparison = async () => {
|
||||
const parent = await createTestTempDirectory("rea-artifact-compare-");
|
||||
const leftPath = join(parent, "left.app");
|
||||
@@ -64,26 +125,16 @@ const changedArtifactComparison = async () => {
|
||||
};
|
||||
|
||||
describe("leaf artifact comparison", () => {
|
||||
it("accepts persisted complete leaf inventory Evidence without coverage_status", async () => {
|
||||
it("rejects inventory Evidence without required producer coverage", async () => {
|
||||
const root = await createTestTempDirectory("rea-artifact-plist-compare-");
|
||||
const leftPath = join(root, "left.plist");
|
||||
const rightPath = join(root, "right.plist");
|
||||
const xml =
|
||||
'<?xml version="1.0"?><plist version="1.0"><dict><key>label</key><string>hello</string></dict></plist>';
|
||||
await Promise.all([writeFile(leftPath, xml), writeFile(rightPath, xml)]);
|
||||
const [left, right] = await Promise.all([
|
||||
observe(leftPath),
|
||||
observe(rightPath),
|
||||
]);
|
||||
// Persisted inventory Evidence predates coverage_status. The known leaf
|
||||
// notice remains complete when normalized at the inventory boundary.
|
||||
await writeFile(leftPath, xml);
|
||||
const left = await observe(leftPath);
|
||||
const parsed = artifactInventoryResultSchema.parse(left.normalized_result);
|
||||
const {
|
||||
coverage: _coverage,
|
||||
coverage_status: _coverageStatus,
|
||||
...legacyInventory
|
||||
} = parsed;
|
||||
const legacy = createEvidence(
|
||||
const { coverage: _coverage, ...withoutCoverage } = parsed;
|
||||
const malformed = createEvidence(
|
||||
{
|
||||
path: left.subject!.local_path,
|
||||
sha256: left.subject!.digest.sha256,
|
||||
@@ -93,16 +144,12 @@ describe("leaf artifact comparison", () => {
|
||||
{
|
||||
operation: "inventory_artifact",
|
||||
parameters: {},
|
||||
result: jsonValueSchema.parse(legacyInventory),
|
||||
result: jsonValueSchema.parse(withoutCoverage),
|
||||
confidence: "observed",
|
||||
authority: "shipped-artifact",
|
||||
},
|
||||
);
|
||||
expect(compareArtifacts(legacy, right)).toMatchObject({
|
||||
status: "unchanged",
|
||||
summary: { unchanged: 1, unknown: 0 },
|
||||
changes: [],
|
||||
});
|
||||
expect(() => parseArtifactInventoryEvidence(malformed)).toThrow();
|
||||
});
|
||||
|
||||
it("keeps content and executable changes visible for complete plist roots", async () => {
|
||||
@@ -150,8 +197,95 @@ describe("leaf artifact comparison", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("artifact inspection inventory substeps", () => {
|
||||
it("assembles every paged inventory substep and retains its outer Evidence once", async () => {
|
||||
const root = await createTestTempDirectory("rea-inspect-pages-");
|
||||
const path = join(root, "fixture.app");
|
||||
await mkdir(path);
|
||||
await Promise.all([
|
||||
writeFile(join(path, "first.js"), "first();"),
|
||||
writeFile(join(path, "second.js"), "second();"),
|
||||
]);
|
||||
const source = await observe(path);
|
||||
const pages = fragmentInventoryEvidence(source, 2);
|
||||
const inspection = inspectPages(pages);
|
||||
const parsed = parseArtifactInventoryEvidence(inspection);
|
||||
|
||||
expect(parsed.evidence).toEqual([inspection]);
|
||||
expect(parsed.inventory.complete).toBe(true);
|
||||
expect(parsed.inventory.nodes).toHaveLength(
|
||||
artifactInventoryResultSchema.parse(source.normalized_result).nodes
|
||||
.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects inspection substeps with different inventory manifests", async () => {
|
||||
const root = await createTestTempDirectory("rea-inspect-mixed-pages-");
|
||||
const firstPath = join(root, "first.plist");
|
||||
const secondPath = join(root, "second.plist");
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
firstPath,
|
||||
'<?xml version="1.0"?><plist version="1.0"><dict><key>a</key><string>1</string></dict></plist>',
|
||||
),
|
||||
writeFile(
|
||||
secondPath,
|
||||
'<?xml version="1.0"?><plist version="1.0"><dict><key>b</key><string>2</string></dict></plist>',
|
||||
),
|
||||
]);
|
||||
|
||||
const inspection = inspectPages([
|
||||
await observe(firstPath),
|
||||
await observe(secondPath),
|
||||
]);
|
||||
expect(() => parseArtifactInventoryEvidence(inspection)).toThrow(
|
||||
"Artifact inventory pages do not share one manifest",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects inspection coverage counts that disagree with retained substeps", async () => {
|
||||
const root = await createTestTempDirectory("rea-inspect-count-mismatch-");
|
||||
const path = join(root, "fixture.app");
|
||||
await mkdir(path);
|
||||
await Promise.all([
|
||||
writeFile(join(path, "first.js"), "first();"),
|
||||
writeFile(join(path, "second.js"), "second();"),
|
||||
]);
|
||||
const pages = fragmentInventoryEvidence(await observe(path), 2);
|
||||
const inspection = inspectPages(pages, 1);
|
||||
|
||||
expect(() => parseArtifactInventoryEvidence(inspection)).toThrow(
|
||||
"Artifact inspection substep count does not match its coverage declaration",
|
||||
);
|
||||
});
|
||||
|
||||
it("authenticates later inventory substeps instead of silently ignoring them", async () => {
|
||||
const root = await createTestTempDirectory("rea-inspect-invalid-page-");
|
||||
const path = join(root, "fixture.app");
|
||||
await mkdir(path);
|
||||
await writeFile(join(path, "main.js"), "main();");
|
||||
const source = await observe(path);
|
||||
const [first, later] = fragmentInventoryEvidence(source, 2);
|
||||
if (first === undefined || later === undefined)
|
||||
throw new Error("Missing inventory page");
|
||||
const graph = artifactInventoryResultSchema.parse(later.normalized_result);
|
||||
const tamperedLater: Evidence = {
|
||||
...later,
|
||||
normalized_result: jsonValueSchema.parse({
|
||||
...graph,
|
||||
limitations: ["Changed after this Evidence identifier was computed."],
|
||||
}),
|
||||
};
|
||||
const inspection = inspectPages([first, tamperedLater]);
|
||||
|
||||
expect(() => parseArtifactInventoryEvidence(inspection)).toThrow(
|
||||
"Evidence semantic identifier does not match its record",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("artifact inventory coverage", () => {
|
||||
it("keeps explicit and legacy unknown coverage fail-closed", async () => {
|
||||
it("uses current unknown coverage without deriving it from limitation prose", async () => {
|
||||
const root = await createTestTempDirectory("rea-artifact-coverage-");
|
||||
const path = join(root, "complete.plist");
|
||||
await writeFile(
|
||||
@@ -162,7 +296,7 @@ describe("artifact inventory coverage", () => {
|
||||
const graph = artifactInventoryResultSchema.parse(
|
||||
complete.normalized_result,
|
||||
);
|
||||
const contradictory = createEvidence(
|
||||
const unknownCoverage = createEvidence(
|
||||
{
|
||||
path: complete.subject!.local_path,
|
||||
sha256: complete.subject!.digest.sha256,
|
||||
@@ -172,63 +306,19 @@ describe("artifact inventory coverage", () => {
|
||||
{
|
||||
operation: "inventory_artifact",
|
||||
parameters: {},
|
||||
result: jsonValueSchema.parse({ ...graph, coverage_status: "partial" }),
|
||||
result: jsonValueSchema.parse({
|
||||
...graph,
|
||||
coverage: { scope: "root", status: "unknown" },
|
||||
limitations: ["No extra child entries were observed."],
|
||||
}),
|
||||
confidence: "derived",
|
||||
authority: "shipped-artifact",
|
||||
},
|
||||
);
|
||||
expect(() => parseArtifactInventoryEvidence(contradictory)).toThrow(
|
||||
"Artifact inventory has conflicting coverage declarations",
|
||||
);
|
||||
|
||||
const { coverage: _coverage, ...legacyStatusGraph } = graph;
|
||||
const partial = createEvidence(
|
||||
{
|
||||
path: complete.subject!.local_path,
|
||||
sha256: complete.subject!.digest.sha256,
|
||||
format: complete.subject!.format,
|
||||
},
|
||||
PROVIDER,
|
||||
{
|
||||
operation: "inventory_artifact",
|
||||
parameters: {},
|
||||
result: jsonValueSchema.parse({
|
||||
...legacyStatusGraph,
|
||||
coverage_status: "partial",
|
||||
}),
|
||||
confidence: "observed",
|
||||
authority: "shipped-artifact",
|
||||
},
|
||||
);
|
||||
expect(parseArtifactInventoryEvidence(partial).inventory.complete).toBe(
|
||||
true,
|
||||
);
|
||||
expect(compareArtifacts(partial, partial)).toMatchObject({
|
||||
status: "unknown",
|
||||
summary: { unchanged: 0, unknown: 1 },
|
||||
});
|
||||
|
||||
const { coverage_status: _coverageStatus, ...legacyGraph } =
|
||||
legacyStatusGraph;
|
||||
const unknownLegacy = createEvidence(
|
||||
{
|
||||
path: complete.subject!.local_path,
|
||||
sha256: complete.subject!.digest.sha256,
|
||||
format: complete.subject!.format,
|
||||
},
|
||||
PROVIDER,
|
||||
{
|
||||
operation: "inventory_artifact",
|
||||
parameters: {},
|
||||
result: jsonValueSchema.parse({
|
||||
...legacyGraph,
|
||||
limitations: ["Producer omitted unexamined child entries."],
|
||||
}),
|
||||
confidence: "observed",
|
||||
authority: "shipped-artifact",
|
||||
},
|
||||
);
|
||||
expect(compareArtifacts(unknownLegacy, unknownLegacy)).toMatchObject({
|
||||
expect(
|
||||
parseArtifactInventoryEvidence(unknownCoverage).inventory.complete,
|
||||
).toBe(true);
|
||||
expect(compareArtifacts(unknownCoverage, unknownCoverage)).toMatchObject({
|
||||
status: "unknown",
|
||||
summary: { unchanged: 0, unknown: 1 },
|
||||
});
|
||||
@@ -248,7 +338,6 @@ describe("artifact inventory coverage", () => {
|
||||
const fragmentGraph = artifactInventoryResultSchema.parse(
|
||||
fragment.normalized_result,
|
||||
);
|
||||
const { coverage: _coverage, ...legacyFragmentGraph } = fragmentGraph;
|
||||
const mixed = createEvidence(
|
||||
{
|
||||
path: fragment.subject!.local_path,
|
||||
@@ -260,8 +349,8 @@ describe("artifact inventory coverage", () => {
|
||||
operation: "inventory_artifact",
|
||||
parameters: fragment.parameters,
|
||||
result: jsonValueSchema.parse({
|
||||
...legacyFragmentGraph,
|
||||
coverage_status: "partial",
|
||||
...fragmentGraph,
|
||||
coverage: { ...fragmentGraph.coverage, status: "unknown" },
|
||||
}),
|
||||
confidence: "derived",
|
||||
authority: "shipped-artifact",
|
||||
@@ -274,6 +363,42 @@ describe("artifact inventory coverage", () => {
|
||||
});
|
||||
|
||||
describe("artifact comparison", () => {
|
||||
it("keeps the canonical public example valid and fully comparable", () => {
|
||||
const left = parseArtifactInventoryEvidence(
|
||||
ARTIFACT_COMPARISON_EXAMPLE.left,
|
||||
);
|
||||
const right = parseArtifactInventoryEvidence(
|
||||
ARTIFACT_COMPARISON_EXAMPLE.right,
|
||||
);
|
||||
|
||||
expect(left.inventory).toMatchObject({
|
||||
complete: true,
|
||||
coverage: { scope: "root", status: "complete" },
|
||||
});
|
||||
expect(right.inventory).toMatchObject({
|
||||
complete: true,
|
||||
coverage: { scope: "root", status: "complete" },
|
||||
});
|
||||
expect(
|
||||
artifactComparisonResultSchema.parse(
|
||||
compareArtifacts(
|
||||
ARTIFACT_COMPARISON_EXAMPLE.left,
|
||||
ARTIFACT_COMPARISON_EXAMPLE.right,
|
||||
),
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "changed",
|
||||
summary: { changed: 1, unknown: 0 },
|
||||
changes: [
|
||||
expect.objectContaining({
|
||||
logical_path: ".",
|
||||
classification: "changed",
|
||||
dimensions: expect.arrayContaining(["content"]),
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies deterministic path changes and cites both inventories", async () => {
|
||||
const firstObservation = await changedArtifactComparison();
|
||||
const secondObservation = await changedArtifactComparison();
|
||||
@@ -480,7 +605,7 @@ describe("artifact source-map relationship coverage", () => {
|
||||
({ occurrence_id }) => occurrence_id === map.occurrence_id,
|
||||
);
|
||||
|
||||
expect(inventory.coverage?.status).toBe("unknown");
|
||||
expect(inventory.coverage.status).toBe("unknown");
|
||||
expect(map.limitations).toContain(
|
||||
'Source-map target is unknown because multiple occurrences share "main.js".',
|
||||
);
|
||||
@@ -731,7 +856,7 @@ describe("artifact comparison fragment integrity", () => {
|
||||
});
|
||||
|
||||
describe("ZIP non-entry coverage", () => {
|
||||
it("retains pre-directory APK block changes and compares historical scope descriptions", async () => {
|
||||
it("retains pre-directory APK block changes under current ZIP coverage", async () => {
|
||||
const root = await createTestTempDirectory("rea-apk-block-compare-");
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add("classes.dex", new TextReader("unchanged entry"));
|
||||
@@ -776,43 +901,5 @@ describe("ZIP non-entry coverage", () => {
|
||||
expect.objectContaining({ logical_path: ".", dimensions: ["content"] }),
|
||||
],
|
||||
});
|
||||
const {
|
||||
coverage: _coverage,
|
||||
coverage_status: _coverageStatus,
|
||||
...legacyInventory
|
||||
} = inventory;
|
||||
const withLimitation = (limitation: string) =>
|
||||
createEvidence(
|
||||
{
|
||||
path: paths[0]!,
|
||||
sha256: inventory.manifest.root_sha256,
|
||||
format: "apk",
|
||||
},
|
||||
PROVIDER,
|
||||
{
|
||||
operation: "inventory_artifact",
|
||||
parameters: {},
|
||||
result: jsonValueSchema.parse({
|
||||
...legacyInventory,
|
||||
limitations: [limitation],
|
||||
}),
|
||||
confidence: "observed",
|
||||
authority: "shipped-artifact",
|
||||
},
|
||||
);
|
||||
const historical = withLimitation(
|
||||
"ZIP inventory lists central-directory entries only. Bytes after the central directory, including an APK Signing Block or a ZIP comment, are not occurrences.",
|
||||
);
|
||||
expect(compareArtifacts(historical, left)).toMatchObject({
|
||||
status: "unchanged",
|
||||
summary: { unchanged: 2, unknown: 0 },
|
||||
changes: [],
|
||||
});
|
||||
expect(
|
||||
compareArtifacts(
|
||||
withLimitation("ZIP entry bytes could not be read."),
|
||||
left,
|
||||
).status,
|
||||
).toBe("unknown");
|
||||
});
|
||||
});
|
||||
@@ -225,7 +225,7 @@ describe("HarmonyOS inventory coverage", () => {
|
||||
);
|
||||
const inventory = await inventoryApplicationPackage(path);
|
||||
expect(inventory.normalized_result).toMatchObject({
|
||||
coverage_status: "complete",
|
||||
coverage: { scope: "container-entries", status: "complete" },
|
||||
});
|
||||
|
||||
const projection = requireDeterministicProjection(
|
||||
|
||||
@@ -306,30 +306,23 @@ it("records an explicitly continued mismatch, preserves verified siblings, and n
|
||||
}),
|
||||
]),
|
||||
});
|
||||
const { coverage: _coverage, ...legacyInventory } = inventory;
|
||||
const historical = createEvidence(
|
||||
const { coverage: _coverage, ...inventoryWithoutCoverage } = inventory;
|
||||
const missingCoverage = createEvidence(
|
||||
{ path: archive, sha256: inventory.manifest.root_sha256, format: "asar" },
|
||||
compact.provider,
|
||||
{
|
||||
operation: "inventory_artifact",
|
||||
parameters: {},
|
||||
result: jsonValueSchema.parse(legacyInventory),
|
||||
result: jsonValueSchema.parse(inventoryWithoutCoverage),
|
||||
},
|
||||
);
|
||||
const retainedComparison = await client.callTool({
|
||||
name: "compare_artifacts",
|
||||
arguments: { left: evidence, right: historical },
|
||||
});
|
||||
expect(
|
||||
retainedComparison.isError,
|
||||
JSON.stringify(retainedComparison),
|
||||
).not.toBe(true);
|
||||
expect(
|
||||
toolEvidence(retainedComparison.structuredContent).normalized_result,
|
||||
).toMatchObject({
|
||||
status: "contradiction",
|
||||
summary: { unchanged: 2, contradiction: 2, unknown: 0 },
|
||||
arguments: { left: evidence, right: missingCoverage },
|
||||
});
|
||||
expect(retainedComparison.isError, JSON.stringify(retainedComparison)).toBe(
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
await Promise.allSettled([client.close(), server.close(), session.close()]);
|
||||
}
|
||||
@@ -547,4 +540,65 @@ it("returns full artifact graphs inline and compares changed inventories", async
|
||||
}
|
||||
});
|
||||
|
||||
it("compares summary Evidence through retained complete inspection parents", async () => {
|
||||
const directory = await createTestTempDirectory("rea-artifact-summary-mcp-");
|
||||
const archive = join(directory, "one.zip");
|
||||
const changedArchive = join(directory, "two.zip");
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add("a.txt", new TextReader("same"));
|
||||
await writeFile(archive, await writer.close());
|
||||
const changedWriter = new ZipWriter(new Uint8ArrayWriter());
|
||||
await changedWriter.add("a.txt", new TextReader("same"));
|
||||
await changedWriter.add("b.txt", new TextReader("new"));
|
||||
await writeFile(changedArchive, await changedWriter.close());
|
||||
|
||||
const session = createTestBinarySession(new ArtifactProvider(process.env));
|
||||
const server = createServer({ kind: "session", session });
|
||||
const client = new Client({
|
||||
name: "artifact-summary-mcp-test",
|
||||
version: "1",
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const summaries = [];
|
||||
for (const path of [archive, changedArchive]) {
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path },
|
||||
});
|
||||
expect(opened.isError).not.toBe(true);
|
||||
const inspected = await client.callTool({
|
||||
name: "inspect_artifact",
|
||||
arguments: { detail: "summary" },
|
||||
});
|
||||
expect(inspected.isError).not.toBe(true);
|
||||
summaries.push(toolEvidence(inspected.structuredContent));
|
||||
}
|
||||
const [left, right] = summaries;
|
||||
if (left === undefined || right === undefined)
|
||||
throw new Error("missing artifact summary Evidence");
|
||||
expect(left.operation).toBe("inspect_analysis_view");
|
||||
expect(right.operation).toBe("inspect_analysis_view");
|
||||
const compared = await client.callTool({
|
||||
name: "compare_artifacts",
|
||||
arguments: { left, right },
|
||||
});
|
||||
expect(compared.isError).not.toBe(true);
|
||||
const result = toolEvidence(compared.structuredContent);
|
||||
expect(result.normalized_result).toMatchObject({ status: "changed" });
|
||||
const leftParentId = z
|
||||
.object({ parent_evidence_id: z.string() })
|
||||
.parse(left.normalized_result).parent_evidence_id;
|
||||
const rightParentId = z
|
||||
.object({ parent_evidence_id: z.string() })
|
||||
.parse(right.normalized_result).parent_evidence_id;
|
||||
expect(result.evidence_links).toEqual([leftParentId, rightParentId]);
|
||||
} finally {
|
||||
await Promise.allSettled([client.close(), server.close(), session.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
const toolEvidence = (value: unknown) => parseEvidence(value);
|
||||
@@ -15,7 +15,10 @@ import { ARTIFACT_COMPARISON_EXAMPLE } from "../../../src/contracts/artifactComp
|
||||
import { PROCESS_CAPTURE_REFERENCE } from "../../../src/contracts/investigationExamples.js";
|
||||
import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createArtifactInspection } from "../../../src/domain/artifactInspection.js";
|
||||
import {
|
||||
artifactInspectionResultSchema,
|
||||
createArtifactInspection,
|
||||
} from "../../../src/domain/artifactInspection.js";
|
||||
import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";
|
||||
import { jsonValueSchema } from "../../../src/domain/jsonValue.js";
|
||||
import { createPromptCompletionSource } from "../../../src/server/promptCompletion.js";
|
||||
@@ -204,6 +207,65 @@ describe("guided prompt completion from investigation records", () => {
|
||||
await session.close();
|
||||
expect(await completion.complete("evidence", "ev_")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("guided prompt completion from retained records", () => {
|
||||
it("offers identifiers from every retained inspection inventory substep", async () => {
|
||||
const session = createTestBinarySession(() => client([]));
|
||||
const leftInspection = createArtifactInspection(
|
||||
ARTIFACT_COMPARISON_EXAMPLE.left,
|
||||
);
|
||||
const rightInspection = createArtifactInspection(
|
||||
ARTIFACT_COMPARISON_EXAMPLE.right,
|
||||
);
|
||||
const inspection = artifactInspectionResultSchema.parse({
|
||||
...leftInspection,
|
||||
substeps: [...leftInspection.substeps, ...rightInspection.substeps],
|
||||
evidence_links: [
|
||||
...leftInspection.evidence_links,
|
||||
...rightInspection.evidence_links,
|
||||
],
|
||||
});
|
||||
const inspectionEvidence = createEvidence(undefined, fixtureProvider, {
|
||||
operation: "inspect_artifact",
|
||||
parameters: {},
|
||||
result: jsonValueSchema.parse(inspection),
|
||||
confidence: "observed",
|
||||
authority: "shipped-artifact",
|
||||
evidenceLinks: inspection.evidence_links,
|
||||
});
|
||||
expect(session.recordEvidence(inspectionEvidence).ok).toBe(true);
|
||||
const completion = createPromptCompletionSource(session, session);
|
||||
const expectedManifests = [
|
||||
ARTIFACT_COMPARISON_EXAMPLE.left,
|
||||
ARTIFACT_COMPARISON_EXAMPLE.right,
|
||||
]
|
||||
.map(
|
||||
({ normalized_result }) =>
|
||||
artifactInventoryResultSchema.parse(normalized_result).manifest
|
||||
.manifest_id,
|
||||
)
|
||||
.sort();
|
||||
const expectedOccurrences = [
|
||||
ARTIFACT_COMPARISON_EXAMPLE.left,
|
||||
ARTIFACT_COMPARISON_EXAMPLE.right,
|
||||
]
|
||||
.flatMap(({ normalized_result }) =>
|
||||
artifactInventoryResultSchema
|
||||
.parse(normalized_result)
|
||||
.occurrences.map(({ occurrence_id }) => occurrence_id),
|
||||
)
|
||||
.sort();
|
||||
|
||||
expect(await completion.complete("manifest", "agm_")).toEqual(
|
||||
expectedManifests,
|
||||
);
|
||||
expect(await completion.complete("occurrence", "occ_")).toEqual(
|
||||
expectedOccurrences,
|
||||
);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("deduplicates, case-folds, and returns deterministic live evidence IDs", async () => {
|
||||
const session = createTestBinarySession(() => client([]));
|
||||
|
||||
Reference in new issue
Block a user