156 lines
5.3 KiB
TypeScript
156 lines
5.3 KiB
TypeScript
import type { McpServer } from "@modelcontextprotocol/server";
|
|
import canonicalize from "canonicalize";
|
|
import { z } from "zod";
|
|
|
|
import type { BinarySessionPort } from "../application/BinarySession.js";
|
|
import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
|
import { createEvidence } from "../domain/evidence.js";
|
|
import { EvidenceIntegrityError } from "../domain/errors.js";
|
|
import { jsonValueSchema } from "../domain/jsonValue.js";
|
|
import type { RecordUnknownInput } from "../domain/residualUnknown.js";
|
|
import {
|
|
compareProcessCaptures,
|
|
processCaptureSchema,
|
|
} from "../domain/processCapture.js";
|
|
import { recordDerivedEvidence } from "./recordDerivedEvidence.js";
|
|
import { err } from "../domain/result.js";
|
|
import { PROCESS_PROVIDER } from "./sessionToolPolicies.js";
|
|
import { toCallToolResult } from "./toolResult.js";
|
|
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
|
|
|
/** Register deterministic process-capture comparison and contradiction tracking. */
|
|
export const registerProcessComparisonTool = (
|
|
server: McpServer,
|
|
session: BinarySessionPort,
|
|
contract: (typeof SESSION_TOOL_CONTRACTS)[6],
|
|
): void => {
|
|
server.registerTool(
|
|
contract.name,
|
|
toolRegistrationOptions(contract),
|
|
(input) => {
|
|
const parsed = z
|
|
.object({
|
|
left_evidence_id: z.string(),
|
|
left: processCaptureSchema,
|
|
right_evidence_id: z.string(),
|
|
right: processCaptureSchema,
|
|
unknown_registry_approved: z.literal(true).optional(),
|
|
})
|
|
.parse(input);
|
|
const validated = validateCaptureSources(session, parsed);
|
|
if (!validated.ok) return toCallToolResult(validated, contract);
|
|
const comparison = compareProcessCaptures(parsed.left, parsed.right);
|
|
const evidence = createEvidence(undefined, PROCESS_PROVIDER, {
|
|
predicateType: "rea.process-comparison/v2",
|
|
operation: contract.name,
|
|
parameters: {
|
|
left_evidence_id: parsed.left_evidence_id,
|
|
right_evidence_id: parsed.right_evidence_id,
|
|
left_normalization: parsed.left.normalization,
|
|
right_normalization: parsed.right.normalization,
|
|
},
|
|
result: jsonValueSchema.parse(comparison),
|
|
confidence: "derived",
|
|
authority: "analyst-inference",
|
|
limitations: comparison.limitations,
|
|
evidenceLinks: [parsed.left_evidence_id, parsed.right_evidence_id],
|
|
});
|
|
return toCallToolResult(
|
|
recordDerivedEvidence(
|
|
session,
|
|
evidence,
|
|
comparisonUnknownInput(parsed, comparison),
|
|
),
|
|
contract,
|
|
);
|
|
},
|
|
);
|
|
};
|
|
|
|
const validateCaptureSources = (
|
|
session: BinarySessionPort,
|
|
parsed: {
|
|
readonly left_evidence_id: string;
|
|
readonly left: z.infer<typeof processCaptureSchema>;
|
|
readonly right_evidence_id: string;
|
|
readonly right: z.infer<typeof processCaptureSchema>;
|
|
},
|
|
) => {
|
|
for (const [evidenceId, capture] of [
|
|
[parsed.left_evidence_id, parsed.left],
|
|
[parsed.right_evidence_id, parsed.right],
|
|
] as const) {
|
|
const evidence = session.evidenceById(evidenceId);
|
|
const result = processCaptureSchema.safeParse(evidence?.normalized_result);
|
|
if (
|
|
evidence === undefined ||
|
|
evidence.operation !== "capture_process_scenario" ||
|
|
evidence.predicate_type !== "rea.process-capture/v3" ||
|
|
evidence.provider.id !== PROCESS_PROVIDER.id ||
|
|
evidence.provider.name !== PROCESS_PROVIDER.name ||
|
|
evidence.provider.version !== PROCESS_PROVIDER.version ||
|
|
evidence.confidence !== "observed" ||
|
|
evidence.authority !== "controlled-replay" ||
|
|
!result.success ||
|
|
canonicalJson(result.data) !== canonicalJson(capture)
|
|
)
|
|
return err(
|
|
new EvidenceIntegrityError(
|
|
"Process comparison payload does not match its source Evidence",
|
|
),
|
|
);
|
|
}
|
|
return { ok: true as const, value: null };
|
|
};
|
|
|
|
const canonicalJson = (value: unknown): string => {
|
|
const encoded = canonicalize(value);
|
|
if (encoded === undefined)
|
|
throw new TypeError("Process capture could not be canonicalized");
|
|
return encoded;
|
|
};
|
|
|
|
const comparisonUnknownInput = (
|
|
parsed: {
|
|
readonly left_evidence_id: string;
|
|
readonly right_evidence_id: string;
|
|
readonly unknown_registry_approved?: true | undefined;
|
|
},
|
|
comparison: ReturnType<typeof compareProcessCaptures>,
|
|
): RecordUnknownInput | undefined => {
|
|
if (
|
|
parsed.unknown_registry_approved !== true ||
|
|
comparison.status === "unchanged"
|
|
)
|
|
return undefined;
|
|
const differingScopes = [
|
|
["terminal", comparison.terminal],
|
|
["exit", comparison.exit],
|
|
["filesystem", comparison.filesystem],
|
|
["protocol", comparison.protocol],
|
|
["process", comparison.process],
|
|
]
|
|
.filter(([, status]) => status !== "unchanged")
|
|
.map(([scope]) => scope)
|
|
.join(", ");
|
|
return {
|
|
approved: true,
|
|
question: `Process captures disagree across: ${differingScopes}`,
|
|
severity: "high",
|
|
domain: "process-comparison",
|
|
supporting_evidence_ids: [parsed.left_evidence_id],
|
|
contradicting_evidence_ids: [parsed.right_evidence_id],
|
|
required_authority: "controlled-replay",
|
|
required_confidence: "observed",
|
|
required_environment: null,
|
|
recommended_probes: [
|
|
{
|
|
operation: "capture_process_scenario",
|
|
rationale:
|
|
"Repeat both scenarios under the same controlled environment.",
|
|
},
|
|
],
|
|
relationships: [],
|
|
};
|
|
};
|