diff --git a/src/domain/functionDossierEvidence.ts b/src/domain/functionDossierEvidence.ts index c27e67aa47..855836ada0 100644 --- a/src/domain/functionDossierEvidence.ts +++ b/src/domain/functionDossierEvidence.ts @@ -2,6 +2,7 @@ import type { Evidence } from "./evidence.js"; import { AnalysisInputError } from "./analysisErrorCore.js"; import type { EvidenceIntegrityError } from "./evidenceErrors.js"; import { readEvidence, readEvidencePayload } from "./evidenceValidation.js"; +import type { JsonValue } from "./jsonValue.js"; import { err, ok, type Result } from "./result.js"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; import { functionDossierSchema, type FunctionDossier } from "./hopperValues.js"; @@ -29,19 +30,33 @@ export const parseFunctionEvidence = ( const parsed = readEvidence(input); if (!parsed.ok) return parsed; const evidence = parsed.value; - const invalidSelection = (message: string) => + const invalidSelection = (message: string, expected?: JsonValue) => err( new AnalysisInputError(selection.operation, { userMessage: message }, [ - { path: selection.path, reason: "invalid_value", message }, + { + path: selection.path, + reason: "invalid_value", + message, + ...(expected === undefined ? {} : { expected }), + }, ]), ); if ( evidence.operation !== "analyze_function" || evidence.predicate_type !== "rea.analysis" - ) + ) { + const location = selection.path.map(String).join("."); + const subject = + location === "" + ? "supplied Evidence" + : selection.path.length === 1 + ? `${location} Evidence` + : `Evidence at ${location}`; return invalidSelection( - "Function analysis requires analyze_function Evidence with the rea.analysis predicate", + `Function analysis requires analyze_function Evidence with the rea.analysis predicate; the ${subject} has operation ${evidence.operation} and predicate ${evidence.predicate_type}.`, + { operation: "analyze_function", predicate_type: "rea.analysis" }, ); + } if (evidence.subject === null) return invalidSelection( "Function analysis requires artifact-bound Evidence", diff --git a/tests/boundary/mcp/functionComparisonMcp.test.ts b/tests/boundary/mcp/functionComparisonMcp.test.ts index ec99701d45..75bf38e384 100644 --- a/tests/boundary/mcp/functionComparisonMcp.test.ts +++ b/tests/boundary/mcp/functionComparisonMcp.test.ts @@ -258,6 +258,20 @@ it.each([ }, ); +const wrongKindFunctionEvidence = (mismatch: "operation" | "predicate") => + createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: + mismatch === "operation" ? "inventory_artifact" : "analyze_function", + predicateType: + mismatch === "predicate" ? "rea.artifact-inventory" : "rea.analysis", + parameters: {}, + result: {}, + }, + ); + describe("function comparison MCP integration", () => { it("records linked comparison Evidence and its residual unknown", async () => { const session = createTestBinarySession(() => ({ @@ -353,50 +367,116 @@ describe("function comparison MCP integration", () => { ]); } }); +}); - it("rejects inline Evidence with the wrong operation", async () => { - const session = createTestBinarySession(() => ({ - health: () => Promise.resolve(), - execute: () => Promise.resolve(observed(null)), - close: () => Promise.resolve(resultOk(null)), - })); - const wrong = createEvidence( - undefined, - { id: "fixture", name: "Fixture", version: "1" }, - { - operation: "inventory_artifact", - parameters: {}, - result: {}, - }, - ); - session.recordEvidence(wrong); - session.recordEvidence(FUNCTION_COMPARISON_EXAMPLE.right); - const server = createServer({ kind: "session", session }); - const client = new Client({ - name: "function-authority-test", - version: "1", - }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - try { - await server.connect(serverTransport); - await client.connect(clientTransport); - const result = await client.callTool({ +describe("function comparison input-kind diagnostics", () => { + it.each([ + { side: "left", mismatch: "operation" }, + { side: "right", mismatch: "operation" }, + { side: "left", mismatch: "predicate" }, + { side: "right", mismatch: "predicate" }, + ] as const)( + "rejects inline $side Evidence with the wrong $mismatch as an input-selection error", + async ({ side, mismatch }) => { + const session = createTestBinarySession(() => { + throw new Error("Wrong-kind function input must not launch a provider"); + }); + const harness = await createApplicationMcpHarness({ session }); + onTestFinished(harness.close); + const wrong = wrongKindFunctionEvidence(mismatch); + const good = + FUNCTION_COMPARISON_EXAMPLE[side === "left" ? "right" : "left"]; + const result = await harness.client.callTool({ name: "compare_functions", - arguments: { - left: wrong, - right: FUNCTION_COMPARISON_EXAMPLE.right, + arguments: + side === "left" + ? { left: wrong, right: good } + : { left: good, right: wrong }, + }); + expect(parseMcpToolError(result).error).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + recovery: { + kind: "inspect_input_contract", + operation: "compare_functions", + }, + details: { + operation: "compare_functions", + issues: [ + { + path: [side], + reason: "invalid_value", + message: `Function analysis requires analyze_function Evidence with the rea.analysis predicate; the ${side} Evidence has operation ${wrong.operation} and predicate ${wrong.predicate_type}.`, + expected: { + operation: "analyze_function", + predicate_type: "rea.analysis", + }, + }, + ], }, }); - expect(result.isError).toBe(true); - expect(session.exportEvidenceBundle().records).toHaveLength(2); - } finally { - await Promise.allSettled([ - client.close(), - server.close(), - session.close(), - ]); - } + }, + ); + + it("names the retained wrong-kind Evidence side", async () => { + const session = createTestBinarySession(() => { + throw new Error("Wrong-kind retained input must not launch a provider"); + }); + const wrong = wrongKindFunctionEvidence("operation"); + expect(session.recordEvidence(wrong).ok).toBe(true); + expect(session.recordEvidence(FUNCTION_COMPARISON_EXAMPLE.right).ok).toBe( + true, + ); + const harness = await createApplicationMcpHarness({ session }); + onTestFinished(harness.close); + const result = await harness.client.callTool({ + name: "compare_functions", + arguments: { + left: { kind: "retained-evidence", evidence_id: wrong.evidence_id }, + right: { + kind: "retained-evidence", + evidence_id: FUNCTION_COMPARISON_EXAMPLE.right.evidence_id, + }, + }, + }); + expect(parseMcpToolError(result).error).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + details: { + issues: [ + { + path: ["left"], + message: expect.stringContaining( + "the left Evidence has operation inventory_artifact", + ), + }, + ], + }, + }); + expect(session.exportEvidenceBundle().records).toHaveLength(2); + }); + + it("keeps tampered function Evidence an integrity failure", async () => { + const session = createTestBinarySession(() => { + throw new Error("Tampered function input must not launch a provider"); + }); + const harness = await createApplicationMcpHarness({ session }); + onTestFinished(harness.close); + const tampered = { + ...FUNCTION_COMPARISON_EXAMPLE.left, + limitations: [ + ...FUNCTION_COMPARISON_EXAMPLE.left.limitations, + "tampered", + ], + }; + const result = await harness.client.callTool({ + name: "compare_functions", + arguments: { left: tampered, right: FUNCTION_COMPARISON_EXAMPLE.right }, + }); + expect(parseMcpToolError(result).error).toMatchObject({ + code: "evidence_integrity_mismatch", + category: "integrity_mismatch", + }); }); });