refactor(verifiers): consolidate capture result admission and parity

This commit is contained in:
morluto committed 2026-10-26 23:12:34 +08:00
1 parent 99062feaa7
commit a84e0cfcfe
7 files changed
+134 -68

No files matched your search

+9 -10
View File
@@ -38,7 +38,15 @@ export const requireMcpOutput = (
throw new Error(`${operation} failed: ${mcpTextValue(result)}`); throw new Error(`${operation} failed: ${mcpTextValue(result)}`);
if (contract.name !== operation) if (contract.name !== operation)
throw new Error(`${operation} received the ${contract.name} contract`); throw new Error(`${operation} received the ${contract.name} contract`);
return contract.outputSchema.parse(jsonValue(result)); const text = jsonValue(result);
const output = contract.outputSchema.parse(text);
if (result.structuredContent !== undefined) {
// The producer emits both representations from the same JSON value.
// Compare before schema projection can discard disagreeing source fields.
if (!isDeepStrictEqual(text, result.structuredContent))
throw new Error(`${operation} text and structured results disagree`);
}
return output;
}; };
const operationResultKey = (contract) => { const operationResultKey = (contract) => {
@@ -124,15 +132,6 @@ export const requireMcpEvidenceBundle = (result) => {
throw new Error( throw new Error(
"get_evidence_bundle returned summary metadata instead of complete Evidence", "get_evidence_bundle returned summary metadata instead of complete Evidence",
); );
if (result.structuredContent !== undefined) {
const structured = contract.outputSchema.parse(
result.structuredContent,
).result;
if (!isDeepStrictEqual(bundle, structured))
throw new Error(
"get_evidence_bundle text and structured results disagree",
);
}
return authenticateParsedEvidenceBundle(bundle); return authenticateParsedEvidenceBundle(bundle);
}; };
+2 -5
View File
@@ -26,7 +26,7 @@ import {
} from "./lib/http3-verifier-lifecycle.mjs"; } from "./lib/http3-verifier-lifecycle.mjs";
import { import {
requireMcpEvidenceBundle, requireMcpEvidenceBundle,
mcpTextValue, requireMcpOutput,
} from "./lib/mcp-verifier-results.mjs"; } from "./lib/mcp-verifier-results.mjs";
const root = process.argv[2]; const root = process.argv[2];
@@ -137,14 +137,11 @@ async function mcp(input, name) {
join(directory, `${name}.mcp.reply.json`), join(directory, `${name}.mcp.reply.json`),
JSON.stringify(reply, null, 2), JSON.stringify(reply, null, 2),
); );
const value = JSON.parse(mcpTextValue(reply)); const evidence = requireMcpOutput(reply, contract.name, contract);
assert.notEqual(reply.isError, true, mcpTextValue(reply));
assert.ok( assert.ok(
validateOutput(reply.structuredContent), validateOutput(reply.structuredContent),
JSON.stringify(validateOutput.errors), JSON.stringify(validateOutput.errors),
); );
const evidence = contract.outputSchema.parse(value);
assert.deepEqual(reply.structuredContent, value);
retainedEvidence.push(evidence); retainedEvidence.push(evidence);
return evidence; return evidence;
} }
+22 -19
View File
@@ -1,6 +1,10 @@
#!/usr/bin/env node #!/usr/bin/env node
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { requireMcpEvidenceBundle } from "./lib/mcp-verifier-results.mjs"; import {
requireMcpEvidenceBundle,
requireMcpOutput,
requireMcpToolError,
} from "./lib/mcp-verifier-results.mjs";
import { estimateTokenCount } from "tokenx"; import { estimateTokenCount } from "tokenx";
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
@@ -124,12 +128,13 @@ await withVerifierCleanup(async () => {
name: tool.name, name: tool.name,
arguments: input, arguments: input,
}); });
assert.notEqual(response.isError, true, JSON.stringify(response));
assert.ok( assert.ok(
ajv.validate(tool.outputSchema, response.structuredContent), ajv.validate(tool.outputSchema, response.structuredContent),
JSON.stringify(ajv.errors), JSON.stringify(ajv.errors),
); );
const evidence = parseEvidence(response.structuredContent); const evidence = parseEvidence(
requireMcpOutput(response, "inspect_packet_capture"),
);
const report = evidence.normalized_result; const report = evidence.normalized_result;
assert.equal( assert.equal(
report.artifact.sha256, report.artifact.sha256,
@@ -305,12 +310,13 @@ await withVerifierCleanup(async () => {
name: pcapTool.name, name: pcapTool.name,
arguments: { path: capturePath, detail: "summary" }, arguments: { path: capturePath, detail: "summary" },
}); });
assert.notEqual(response.isError, true, JSON.stringify(response));
assert.ok( assert.ok(
pcapAjv.validate(pcapTool.outputSchema, response.structuredContent), pcapAjv.validate(pcapTool.outputSchema, response.structuredContent),
pcapAjv.errorsText(), pcapAjv.errorsText(),
); );
const summary = parseEvidence(response.structuredContent).normalized_result; const summary = parseEvidence(
requireMcpOutput(response, "inspect_pcap_capture"),
).normalized_result;
assert.equal(summary.summary.capture_start, expectedCaptureStart); assert.equal(summary.summary.capture_start, expectedCaptureStart);
assert.equal(summary.summary.capture_end, expectedCaptureEnd); assert.equal(summary.summary.capture_end, expectedCaptureEnd);
assert.equal( assert.equal(
@@ -343,10 +349,9 @@ await withVerifierCleanup(async () => {
view: { kind: "pcap", facet: "protocols", limit: 10 }, view: { kind: "pcap", facet: "protocols", limit: 10 },
}, },
}); });
assert.notEqual(page.isError, true, JSON.stringify(page));
assert.equal( assert.equal(
parseEvidence(page.structuredContent).normalized_result parseEvidence(requireMcpOutput(page, "inspect_analysis_view"))
.parent_evidence_id, .normalized_result.parent_evidence_id,
source.evidence_id, source.evidence_id,
); );
cases++; cases++;
@@ -368,19 +373,16 @@ await withVerifierCleanup(async () => {
name: tool.name, name: tool.name,
arguments: input, arguments: input,
}); });
assert.equal(response.isError, true); requireMcpToolError(response);
await client.ping(); await client.ping();
cases++; cases++;
} }
await writeFile(path, "not a capture"); await writeFile(path, "not a capture");
assert.equal( requireMcpToolError(
( await client.callTool({
await client.callTool({ name: tool.name,
name: tool.name, arguments: { path, fields: ["frame.number"] },
arguments: { path, fields: ["frame.number"] }, }),
})
).isError,
true,
); );
cases++; cases++;
const discovery = (await client.listTools()).tools.find( const discovery = (await client.listTools()).tools.find(
@@ -414,12 +416,13 @@ await withVerifierCleanup(async () => {
name: discovery.name, name: discovery.name,
arguments: input, arguments: input,
}); });
assert.notEqual(response.isError, true, JSON.stringify(response));
assert.ok( assert.ok(
ajv.validate(discovery.outputSchema, response.structuredContent), ajv.validate(discovery.outputSchema, response.structuredContent),
JSON.stringify(ajv.errors), JSON.stringify(ajv.errors),
); );
const evidence = parseEvidence(response.structuredContent); const evidence = parseEvidence(
requireMcpOutput(response, "inspect_packet_fields"),
);
const report = evidence.normalized_result; const report = evidence.normalized_result;
const kind = input.kind ?? "protocols"; const kind = input.kind ?? "protocols";
const query = (input.query ?? "").toLowerCase(); const query = (input.query ?? "").toLowerCase();
+13 -9
View File
@@ -10,6 +10,11 @@ import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { Ajv2020 } from "ajv/dist/2020.js"; import { Ajv2020 } from "ajv/dist/2020.js";
import { parseEvidence } from "../dist/domain/evidence.js"; import { parseEvidence } from "../dist/domain/evidence.js";
import { toolContract } from "../dist/contracts/toolContracts.js";
import {
requireMcpOutput,
requireMcpToolError,
} from "./lib/mcp-verifier-results.mjs";
// Caller-supplied producer evidence stays local; no fixture capture or secrets are bundled. // Caller-supplied producer evidence stays local; no fixture capture or secrets are bundled.
const [controlPath, lossPath, reportPath] = process.argv.slice(2); const [controlPath, lossPath, reportPath] = process.argv.slice(2);
@@ -40,6 +45,7 @@ transport.stderr?.on("data", (bytes) => {
}); });
const ajv = new Ajv2020({ strict: false, validateFormats: false }); const ajv = new Ajv2020({ strict: false, validateFormats: false });
const measurements = []; const measurements = [];
const contract = toolContract("inspect_web_network_capture");
try { try {
try { try {
await client.connect(transport); await client.connect(transport);
@@ -68,12 +74,13 @@ try {
name: tool.name, name: tool.name,
arguments: input, arguments: input,
}); });
assert.notEqual(response.isError, true); const mcp = parseEvidence(
requireMcpOutput(response, contract.name, contract),
);
assert.ok( assert.ok(
validateOutput(response.structuredContent), validateOutput(response.structuredContent),
JSON.stringify(validateOutput.errors), JSON.stringify(validateOutput.errors),
); );
const mcp = parseEvidence(response.structuredContent);
const completed = await promisify(execFile)( const completed = await promisify(execFile)(
process.execPath, process.execPath,
[entrypoint, "inspect-web-network-capture", path, "qlog", "--json"], [entrypoint, "inspect-web-network-capture", path, "qlog", "--json"],
@@ -153,17 +160,14 @@ try {
view: { kind: "api-surface" }, view: { kind: "api-surface" },
}, },
}); });
assert.equal(retained.isError, true); assert.equal(requireMcpToolError(retained).code, "unsupported_target");
assert.equal(
JSON.parse(retained.content.find((item) => item.type === "text").text)
.error.code,
"unsupported_target",
);
const selected = await client.callTool({ const selected = await client.callTool({
name: tool.name, name: tool.name,
arguments: { ...input, record_ordinals: [capture.total_records - 1, 0] }, arguments: { ...input, record_ordinals: [capture.total_records - 1, 0] },
}); });
const subset = parseEvidence(selected.structuredContent).normalized_result; const subset = parseEvidence(
requireMcpOutput(selected, contract.name, contract),
).normalized_result;
assert.deepEqual( assert.deepEqual(
subset.records.map((record) => record.ordinal), subset.records.map((record) => record.ordinal),
[capture.total_records - 1, 0], [capture.total_records - 1, 0],
+17 -12
View File
@@ -1,6 +1,10 @@
#!/usr/bin/env node #!/usr/bin/env node
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { requireMcpEvidenceBundle } from "./lib/mcp-verifier-results.mjs"; import {
requireMcpEvidenceBundle,
requireMcpOutput,
requireMcpToolError,
} from "./lib/mcp-verifier-results.mjs";
import { estimateTokenCount } from "tokenx"; import { estimateTokenCount } from "tokenx";
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
@@ -47,9 +51,7 @@ const assertRejectedSelector = (
field, field,
expectedExitCode = 1, expectedExitCode = 1,
) => { ) => {
assert.equal(response.isError, true); const error = requireMcpToolError(response);
const text = response.content.find((item) => item.type === "text")?.text;
const { error } = JSON.parse(text ?? "{}");
assert.equal(error.code, "invalid_request"); assert.equal(error.code, "invalid_request");
assert.equal(error.details.operation, operation); assert.equal(error.details.operation, operation);
assert.deepEqual( assert.deepEqual(
@@ -123,12 +125,13 @@ await withVerifierCleanup(async () => {
name: advertised.name, name: advertised.name,
arguments: input, arguments: input,
}); });
assert.notEqual(response.isError, true, JSON.stringify(response));
assert.ok( assert.ok(
ajv.validate(advertised.outputSchema, response.structuredContent), ajv.validate(advertised.outputSchema, response.structuredContent),
JSON.stringify(ajv.errors), JSON.stringify(ajv.errors),
); );
const evidence = parseEvidence(response.structuredContent); const evidence = parseEvidence(
requireMcpOutput(response, "decode_signal_recording"),
);
const report = evidence.normalized_result; const report = evidence.normalized_result;
for (const byte of ["52", "45", "41"]) for (const byte of ["52", "45", "41"])
assert.match( assert.match(
@@ -238,12 +241,13 @@ await withVerifierCleanup(async () => {
name: advertised.name, name: advertised.name,
arguments: input, arguments: input,
}); });
assert.notEqual(response.isError, true, JSON.stringify(response));
assert.ok( assert.ok(
ajv.validate(advertised.outputSchema, response.structuredContent), ajv.validate(advertised.outputSchema, response.structuredContent),
JSON.stringify(ajv.errors), JSON.stringify(ajv.errors),
); );
const evidence = parseEvidence(response.structuredContent); const evidence = parseEvidence(
requireMcpOutput(response, "decode_signal_recording"),
);
const report = evidence.normalized_result; const report = evidence.normalized_result;
assert.deepEqual(report.output_window, { assert.deepEqual(report.output_window, {
total_lines: 120, total_lines: 120,
@@ -298,9 +302,9 @@ await withVerifierCleanup(async () => {
name: advertised.name, name: advertised.name,
arguments: input, arguments: input,
}); });
assert.equal(response.isError, true);
if (rejectedField !== undefined) if (rejectedField !== undefined)
assertRejectedSelector(response, advertised.name, rejectedField); assertRejectedSelector(response, advertised.name, rejectedField);
else requireMcpToolError(response);
await client.ping(); await client.ping();
cases++; cases++;
} }
@@ -309,7 +313,7 @@ await withVerifierCleanup(async () => {
name: advertised.name, name: advertised.name,
arguments: { path, decoders: "uart" }, arguments: { path, decoders: "uart" },
}); });
assert.equal(invalid.isError, true); requireMcpToolError(invalid);
cases++; cases++;
const discoveryTool = (await client.listTools()).tools.find( const discoveryTool = (await client.listTools()).tools.find(
({ name }) => name === "inspect_signal_decoders", ({ name }) => name === "inspect_signal_decoders",
@@ -367,12 +371,13 @@ await withVerifierCleanup(async () => {
name: discoveryTool.name, name: discoveryTool.name,
arguments: input, arguments: input,
}); });
assert.notEqual(response.isError, true, JSON.stringify(response));
assert.ok( assert.ok(
ajv.validate(discoveryTool.outputSchema, response.structuredContent), ajv.validate(discoveryTool.outputSchema, response.structuredContent),
JSON.stringify(ajv.errors), JSON.stringify(ajv.errors),
); );
const evidence = parseEvidence(response.structuredContent); const evidence = parseEvidence(
requireMcpOutput(response, "inspect_signal_decoders"),
);
const report = evidence.normalized_result; const report = evidence.normalized_result;
discoveryMeasurements.push({ discoveryMeasurements.push({
input, input,
+15 -13
View File
@@ -18,7 +18,11 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js"; import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js";
import { runOwnedCommand } from "../dist/process/OwnedCommand.js"; import { runOwnedCommand } from "../dist/process/OwnedCommand.js";
import { WEB_NETWORK_CAPTURE_LIMITS } from "../dist/domain/webNetworkCapture.js"; import { WEB_NETWORK_CAPTURE_LIMITS } from "../dist/domain/webNetworkCapture.js";
import { mcpTextValue } from "./lib/mcp-verifier-results.mjs"; import {
mcpTextValue,
requireMcpOutput,
requireMcpToolError,
} from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
const executable = process.env.REA_MITMDUMP_COMMAND; const executable = process.env.REA_MITMDUMP_COMMAND;
@@ -903,15 +907,15 @@ async function inspect(mode, input, errorCategory) {
name: "inspect_web_network_capture", name: "inspect_web_network_capture",
arguments: input, arguments: input,
}); });
const value = JSON.parse(mcpTextValue(result));
if (errorCategory !== undefined) { if (errorCategory !== undefined) {
assert.equal(result.isError, true); const error = requireMcpToolError(result);
assert.equal(value.error.category, errorCategory); assert.equal(error.category, errorCategory);
const rawError = JSON.parse(mcpTextValue(result)).error;
for (const literal of input.sensitive_values ?? []) for (const literal of input.sensitive_values ?? [])
assert.ok(!JSON.stringify(value.error).includes(literal)); assert.ok(!JSON.stringify(rawError).includes(literal));
return value.error; return error;
} }
assert.notEqual(result.isError, true, mcpTextValue(result)); const value = requireMcpOutput(result, "inspect_web_network_capture");
assertSensitiveLimitations(value, input.sensitive_values ?? []); assertSensitiveLimitations(value, input.sensitive_values ?? []);
if (input.format === "har") { if (input.format === "har") {
const surface = await client.callTool({ const surface = await client.callTool({
@@ -923,13 +927,12 @@ async function inspect(mode, input, errorCategory) {
}); });
if ((input.sensitive_values ?? []).length > 0) { if ((input.sensitive_values ?? []).length > 0) {
// Derived view text cannot be checked against undisclosed declarations. // Derived view text cannot be checked against undisclosed declarations.
assert.equal(surface.isError, true); requireMcpToolError(surface);
const refused = JSON.parse(mcpTextValue(surface)); const refused = JSON.parse(mcpTextValue(surface));
for (const literal of input.sensitive_values) for (const literal of input.sensitive_values)
assert.ok(!JSON.stringify(refused).includes(literal)); assert.ok(!JSON.stringify(refused).includes(literal));
} else { } else {
assert.notEqual(surface.isError, true, mcpTextValue(surface)); const projected = requireMcpOutput(surface, "inspect_analysis_view");
const projected = JSON.parse(mcpTextValue(surface));
assert.equal(projected.normalized_result.kind, "api-surface"); assert.equal(projected.normalized_result.kind, "api-surface");
assert.equal( assert.equal(
projected.normalized_result.records.examined, projected.normalized_result.records.examined,
@@ -944,13 +947,12 @@ async function inspect(mode, input, errorCategory) {
}, },
}); });
if ((input.sensitive_values ?? []).length > 0) { if ((input.sensitive_values ?? []).length > 0) {
assert.equal(manifest.isError, true); requireMcpToolError(manifest);
const refusedManifest = JSON.parse(mcpTextValue(manifest)); const refusedManifest = JSON.parse(mcpTextValue(manifest));
for (const literal of input.sensitive_values) for (const literal of input.sensitive_values)
assert.ok(!JSON.stringify(refusedManifest).includes(literal)); assert.ok(!JSON.stringify(refusedManifest).includes(literal));
} else { } else {
assert.notEqual(manifest.isError, true, mcpTextValue(manifest)); const projected = requireMcpOutput(manifest, "inspect_analysis_view");
const projected = JSON.parse(mcpTextValue(manifest));
assert.equal(projected.normalized_result.kind, "environment-manifest"); assert.equal(projected.normalized_result.kind, "environment-manifest");
assert.equal(projected.normalized_result.unknowns.length, 3); assert.equal(projected.normalized_result.unknowns.length, 3);
for (const record of projected.normalized_result.records) for (const record of projected.normalized_result.records)
@@ -219,6 +219,62 @@ it("returns complete named Evidence with its subject, provider and analysis prov
); );
}); });
it("admits matching complete text and structured Evidence and rejects either malformed shape", async () => {
const { requireMcpOutput } = await verifierResults();
const evidence = namedEvidence();
expect(
requireMcpOutput(
{ ...textReply(evidence), structuredContent: evidence },
"address_name",
),
).toEqual(evidence);
const malformed = { ...evidence, normalized_result: { name: "entry" } };
for (const [text, structuredContent] of [
[evidence, malformed],
[malformed, evidence],
])
expect(() =>
requireMcpOutput(
{ ...textReply(text), structuredContent },
"address_name",
),
).toThrow();
});
it("rejects disagreement in lifecycle envelope fields that the named schema would otherwise discard", async () => {
const { requireMcpOutput } = await verifierResults();
const result = retainedBundle();
const text = { result, vendor_note: "text observation" };
const structuredContent = { result, vendor_note: "different observation" };
const contract = toolContract("get_evidence_bundle");
expect(contract.outputSchema.parse(text)).toEqual(
contract.outputSchema.parse(structuredContent),
);
expect(() =>
requireMcpOutput(
{ ...textReply(text), structuredContent },
"get_evidence_bundle",
),
).toThrow("text and structured results disagree");
});
it("rejects disagreement in complete Evidence provenance before downstream extraction", async () => {
const { requireMcpOutput } = await verifierResults();
const evidence = namedEvidence();
expect(() =>
requireMcpOutput(
{
...textReply(evidence),
structuredContent: {
...evidence,
raw_result: { engine_name: "different" },
},
},
"address_name",
),
).toThrow("text and structured results disagree");
});
it("rejects mismatched named contracts, error-marked success and malformed normalized results", async () => { it("rejects mismatched named contracts, error-marked success and malformed normalized results", async () => {
const { requireMcpOutput } = await verifierResults(); const { requireMcpOutput } = await verifierResults();
const evidence = namedEvidence(); const evidence = namedEvidence();