diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 3da235feb3..ab0411ea97 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -988,6 +988,25 @@ unsupported facets are local `unavailable` outcomes. Use `current_document`, `current_address`, and `current_procedure` for direct single-field lookups; use the aggregate tools when you need related context together. +## Integrity attribution and reconstruction claims + +Artifact comparison results include `integrity_mismatch_evidence_ids`, naming +the actual inventory Evidence carriers with declared/observed integrity +mismatches. Argument order does not assign blame. The field is absent on older +comparison records, where culprit attribution remains unknown. Automatically +retained artifact unknowns use these source identities for contradiction links. + +Static/runtime correlation preserves a selected contradiction or unresolved +conclusion without treating it as an observed content change. Such a selection +produces an unresolved pattern and cannot support a cochange hypothesis. +Ordinary observed content changes remain eligible for explicit hypotheses; +correlation does not establish causality. + +An `overall` structural-function reconstruction claim selects every constituent +function dimension and its active scoped unknowns. A question scoped only to +pseudocode therefore keeps the aggregate unknown while an independent assembly +claim may pass. Unknown citations and recommended probes are retained. + ## Integrity record-and-continue Artifact integrity fails closed by default. A request can explicitly select diff --git a/src/domain/artifactComparison.ts b/src/domain/artifactComparison.ts index b3542d7f98..55420b523f 100644 --- a/src/domain/artifactComparison.ts +++ b/src/domain/artifactComparison.ts @@ -90,6 +90,8 @@ export const artifactComparisonResultSchema = z.object({ contradiction: z.int().min(0).default(0), }), changes: z.array(artifactChangeSchema), + // Absent on legacy records; absence does not identify a contradicting side. + integrity_mismatch_evidence_ids: z.array(evidenceIdSchema).optional(), limitations: z.array(z.string()), }); @@ -214,6 +216,17 @@ export const compareArtifacts = ( right_manifest_id: right.inventory.manifest.manifest_id, summary, changes, + integrity_mismatch_evidence_ids: [ + ...new Set( + [left, right].flatMap((source) => + source.inventory.occurrences.some( + (occurrence) => occurrence.hash_status === "mismatched", + ) + ? source.evidence.map((record) => record.evidence_id) + : [], + ), + ), + ].sort(compareUnicodeCodePoints), limitations: [...new Set(limitations)].sort(compareUnicodeCodePoints), }); }; diff --git a/src/domain/staticRuntimeCorrelation.ts b/src/domain/staticRuntimeCorrelation.ts index 9cde634c72..37ec64ebd2 100644 --- a/src/domain/staticRuntimeCorrelation.ts +++ b/src/domain/staticRuntimeCorrelation.ts @@ -258,7 +258,7 @@ const correlate = ( ); const process = runtimeEvidence.result; const runtimeStatus = process[mapping.runtime.dimension]; - const pattern = observedPattern(selectedStatic.status, runtimeStatus); + const pattern = observedPattern(selectedStatic, runtimeStatus); const classification = pattern === "unknown" || pattern === "truncated" ? "unresolved_branch" @@ -413,12 +413,18 @@ const assertComparisonIdentity = (evidence: Evidence): void => { }; const observedPattern = ( - staticStatus: ComparisonStatus, + staticFinding: Pick, runtimeStatus: ComparisonStatus, ): z.infer => { + const staticStatus = staticFinding.status; if (staticStatus === "truncated" || runtimeStatus === "truncated") return "truncated"; - if (staticStatus === "unknown" || runtimeStatus === "unknown") + if ( + staticStatus === "unknown" || + runtimeStatus === "unknown" || + staticFinding.conclusion_kind !== "derived_relationship" || + runtimeStatus === "contradiction" + ) return "unknown"; const staticChanged = staticStatus !== "unchanged"; const runtimeChanged = runtimeStatus !== "unchanged"; diff --git a/src/server/registerArtifactComparisonTool.ts b/src/server/registerArtifactComparisonTool.ts index 39fc1312e9..81f30ab549 100644 --- a/src/server/registerArtifactComparisonTool.ts +++ b/src/server/registerArtifactComparisonTool.ts @@ -95,7 +95,7 @@ export const registerArtifactComparisonTool = ( artifactUnknownInput( left, right, - comparison.status, + comparison, evidence.evidence_id, ), ), @@ -108,22 +108,28 @@ export const registerArtifactComparisonTool = ( const artifactUnknownInput = ( left: { readonly evidence_id: string }, right: { readonly evidence_id: string }, - status: ReturnType["status"], + comparison: ReturnType, comparisonEvidenceId: string, ): RecordUnknownInput | undefined => { + const { status } = comparison; if (status === "unchanged") return undefined; - const contradictory = - (status === "changed" || status === "contradiction") && - left.evidence_id !== right.evidence_id; + const sourceIds = [...new Set([left.evidence_id, right.evidence_id])]; + const contradictingIds = comparison.integrity_mismatch_evidence_ids ?? []; + const contentContradictions = + status === "changed" && left.evidence_id !== right.evidence_id + ? [right.evidence_id] + : []; + const contradictions = + contradictingIds.length > 0 ? contradictingIds : contentContradictions; return { question: `Artifact comparison is ${status} (comparison ${comparisonEvidenceId})`, severity: status === "unknown" || status === "truncated" ? "high" : "medium", domain: "artifact-comparison", - supporting_evidence_ids: contradictory - ? [left.evidence_id] - : [...new Set([left.evidence_id, right.evidence_id])], - contradicting_evidence_ids: contradictory ? [right.evidence_id] : [], + supporting_evidence_ids: sourceIds.filter( + (id) => !contradictions.includes(id), + ), + contradicting_evidence_ids: contradictions, required_authority: "shipped-artifact", required_confidence: "observed", required_environment: null, diff --git a/tests/boundary/mcp/artifactMcp.test.ts b/tests/boundary/mcp/artifactMcp.test.ts index c6b27cc793..74abc4a027 100644 --- a/tests/boundary/mcp/artifactMcp.test.ts +++ b/tests/boundary/mcp/artifactMcp.test.ts @@ -1,3 +1,8 @@ +import { + PROCESS_CAPTURE_REFERENCE, + PROCESS_CAPTURE_RECONSTRUCTION, + PROCESS_COMPARISON_EVIDENCE, +} from "../../../src/contracts/investigationExamples.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; @@ -1164,3 +1169,141 @@ it("discovers artifact target prerequisites and preserves typed MCP refusals", a await Promise.allSettled([client.close(), server.close(), session.close()]); } }); + +it("attributes ASAR integrity contradictions to their actual inputs and withholds correlation support", async () => { + const root = await createTestTempDirectory("rea-asar-attribution-"); + const source = join(root, "source"); + await mkdir(source); + const original = "console.log('ok');\n"; + const changed = "console.log('no');\n"; + await writeFile(join(source, "main.js"), original); + const originalArchive = join(root, "original.asar"); + await createPackageWithOptions(source, originalArchive, {}); + const staleBytes = await readFile(originalArchive); + const offset = staleBytes.indexOf(original); + expect(offset).toBeGreaterThanOrEqual(0); + staleBytes.write(changed, offset, "utf8"); + const staleArchive = join(root, "stale.asar"); + await writeFile(staleArchive, staleBytes); + await writeFile(join(source, "main.js"), changed); + const currentArchive = join(root, "current.asar"); + await createPackageWithOptions(source, currentArchive, {}); + const session = createTestBinarySession(new ArtifactProvider(process.env)); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "artifact-attribution", version: "1" }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + try { + await server.connect(st); + await client.connect(ct); + const observations = []; + for (const path of [originalArchive, staleArchive, currentArchive]) { + expect( + (await client.callTool({ name: "open_binary", arguments: { path } })) + .isError, + ).not.toBe(true); + const inspected = await client.callTool({ + name: "inspect_artifact", + arguments: { integrity_policy: "record-and-continue" }, + }); + expect(inspected.isError).not.toBe(true); + observations.push(toolEvidence(inspected.structuredContent)); + } + for (const record of [ + PROCESS_CAPTURE_REFERENCE, + PROCESS_CAPTURE_RECONSTRUCTION, + PROCESS_COMPARISON_EVIDENCE, + ]) + expect(session.recordEvidence(record).ok).toBe(true); + const [originalEvidence, stale, current] = observations; + if ( + originalEvidence === undefined || + stale === undefined || + current === undefined + ) + throw new Error("Expected three artifact observations"); + for (const [left, right, mismatches] of [ + [stale, current, [stale.evidence_id]], + [current, stale, [stale.evidence_id]], + [stale, stale, [stale.evidence_id]], + [originalEvidence, current, []], + ] as const) + await assertIntegrityComparison(client, session, left, right, mismatches); + } finally { + await Promise.allSettled([client.close(), server.close(), session.close()]); + } +}); + +const assertIntegrityComparison = async ( + client: Client, + session: ReturnType, + left: ReturnType, + right: ReturnType, + mismatches: readonly string[], +) => { + const compared = await client.callTool({ + name: "compare_artifacts", + arguments: { left, right }, + }); + expect(compared.isError).not.toBe(true); + const comparison = toolEvidence(compared.structuredContent); + expect(comparison.normalized_result).toMatchObject({ + status: mismatches.length === 0 ? "changed" : "contradiction", + integrity_mismatch_evidence_ids: mismatches, + }); + const unknown = session + .listUnknowns({ domain: "artifact-comparison" }) + .find((item) => item.question.includes(comparison.evidence_id)); + expect(unknown).toBeDefined(); + if (mismatches.length > 0) { + expect(unknown?.contradicting_evidence_ids).toEqual(mismatches); + expect(unknown?.supporting_evidence_ids).toEqual( + [...new Set([left.evidence_id, right.evidence_id])].filter( + (id) => !mismatches.includes(id), + ), + ); + } + const correlated = await client.callTool({ + name: "correlate_static_and_runtime", + arguments: { + static_comparisons: [comparison], + runtime_comparisons: [PROCESS_COMPARISON_EVIDENCE], + mappings: [ + { + static: { + comparison_evidence_id: comparison.evidence_id, + selector: { kind: "artifact", logical_path: "main.js" }, + }, + runtime: { + comparison_evidence_id: PROCESS_COMPARISON_EVIDENCE.evidence_id, + dimension: "terminal", + }, + side_alignment: "left_to_left", + hypothesis: { + statement: "Member bytes changed while terminal output did not.", + expected_pattern: "static_only", + }, + }, + ], + }, + }); + expect(correlated.isError).not.toBe(true); + expect( + toolEvidence(correlated.structuredContent).normalized_result, + ).toMatchObject( + mismatches.length > 0 + ? { + status: "unknown", + summary: { hypotheses: 0, unresolved: 1 }, + correlations: { + items: [ + { + observed_pattern: "unknown", + classification: "unresolved_branch", + static: { status: "contradiction" }, + }, + ], + }, + } + : { status: "correlated", summary: { hypotheses: 1, unresolved: 0 } }, + ); +};