@quantakrypto/mcp 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js CHANGED
@@ -1,19 +1,17 @@
1
1
  /**
2
2
  * quantakrypto MCP tools, backed by {@link @quantakrypto/core}.
3
3
  *
4
- * Every tool returns an MCP {@link ToolResult} ({ content, isError? }). Because
5
- * `@quantakrypto/core` is still partly stubbed (several functions throw "not
6
- * implemented"), each handler runs core calls through {@link safe} so a missing
7
- * implementation surfaces as a readable `isError` tool result instead of a
8
- * protocol-level crash. When core lands, the tools work unchanged.
4
+ * Every tool returns an MCP {@link ToolResult} ({ content, isError? }). Each
5
+ * handler runs core calls through {@link safe} so any runtime failure (a bad
6
+ * path, a work-budget overflow, an unexpected error) surfaces as a readable
7
+ * `isError` tool result with host details stripped, rather than a protocol-level
8
+ * crash.
9
9
  */
10
10
  import process from "node:process";
11
- import { VERSION, AbortError, BudgetExceededError, buildInventory, detectors, remediationFor, scan, toCbom, } from "@quantakrypto/core";
11
+ import { VERSION, AbortError, BudgetExceededError, buildInventory, buildRemediateRequest, buildTriageRequest, compareFindings, detectors, fingerprintFinding, languageToExtension, remediationFor, scan, SEVERITY_ORDER, toCbom, verifyFix, vulnerableDependencies, } from "@quantakrypto/core";
12
12
  import { errorResult, textResult } from "./protocol.js";
13
13
  import { resolveRule } from "./rules.js";
14
14
  import { resolveFsConfig, resolveScanPath } from "./fsconfig.js";
15
- /** Severity order for stable, human-friendly summaries. */
16
- const SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"];
17
15
  /** All classical algorithm families we can advise on, used for validation/help. */
18
16
  const ALGORITHM_FAMILIES = [
19
17
  "RSA",
@@ -23,6 +21,7 @@ const ALGORITHM_FAMILIES = [
23
21
  "DH",
24
22
  "DSA",
25
23
  "X25519",
24
+ "X448",
26
25
  "ECIES",
27
26
  "unknown",
28
27
  ];
@@ -100,6 +99,8 @@ function normalizeAlgorithm(input) {
100
99
  return "ECDSA";
101
100
  if (cleaned.includes("ED25519") || cleaned.includes("EDDSA"))
102
101
  return "EdDSA";
102
+ if (cleaned.includes("X448") || cleaned.includes("CURVE448"))
103
+ return "X448";
103
104
  if (cleaned.includes("X25519") || cleaned.includes("CURVE25519"))
104
105
  return "X25519";
105
106
  if (cleaned.includes("ECDH"))
@@ -284,13 +285,22 @@ const explainFindingTool = {
284
285
  if (ruleId) {
285
286
  const resolved = resolveRule(ruleId);
286
287
  resolvedAlgorithm = resolved.algorithm;
287
- lines.push(`Rule: ${ruleId}`);
288
+ const meta = resolved.meta;
289
+ lines.push(`Rule: ${meta ? `${ruleId} — ${meta.title}` : ruleId}`);
288
290
  if (resolved.detector) {
289
291
  lines.push(`Detector: ${resolved.detector.id} — ${resolved.detector.description}`);
290
292
  }
291
293
  else if (resolved.via === "unresolved") {
292
294
  lines.push("No matching detector found in the catalog (rule may be unknown to this core version).");
293
295
  }
296
+ // Rule catalog metadata (severity / category / HNDL / remediation) so the
297
+ // explanation is actionable on its own, not just a detector pointer.
298
+ if (meta) {
299
+ lines.push(`Severity: ${meta.severity} · Category: ${meta.category} · HNDL-exposed: ${meta.hndl ? "yes" : "no"}`);
300
+ lines.push(`What it detects: ${meta.description ?? meta.message}`);
301
+ if (meta.remediation)
302
+ lines.push(`Rule remediation: ${meta.remediation}`);
303
+ }
294
304
  }
295
305
  // Prefer an explicit algorithm; otherwise use the one the rule resolved to.
296
306
  const algorithm = algoInput
@@ -352,7 +362,8 @@ const suggestHybridTool = {
352
362
  lines.push(`Rationale: ${rem.value.detail}`);
353
363
  }
354
364
  else {
355
- // Static fallback table so the tool stays useful even with a stubbed core.
365
+ // Defensive fallback: core has a remediation for every known family, so
366
+ // this only triggers if the lookup throws or the algorithm is unrecognised.
356
367
  lines.push(...staticHybridAdvice(algorithm));
357
368
  }
358
369
  lines.push("");
@@ -453,13 +464,581 @@ const generateCbomTool = {
453
464
  return textResult(JSON.stringify(cbom.value, null, 2));
454
465
  },
455
466
  };
467
+ // ---------------------------------------------------------------------------
468
+ // Copilot tools — let an AI coding agent migrate *through* the deterministic
469
+ // engine ("the model proposes, the engine disposes"). plan_migration scans and
470
+ // orders the work; get_fix_examples shows the code change; verify_fix re-runs
471
+ // the detectors on the agent's edited code to confirm the classical crypto is
472
+ // actually gone; check_dependency and score_delta quantify the work.
473
+ // ---------------------------------------------------------------------------
474
+ // `languageToExtension` + `verifyFix` now live in @quantakrypto/core so this tool and
475
+ // the remediation pipeline share one definition of "the fix is verified".
476
+ /** Before/after migration snippets per classical family. Static + deterministic. */
477
+ const FIX_EXAMPLES = {
478
+ RSA: {
479
+ note: "RSA key establishment → ML-KEM-768 (hybrid X25519MLKEM768). RSA signatures → ML-DSA-65.",
480
+ before: "// key establishment\nconst { publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 3072 });\nconst enc = crypto.publicEncrypt(publicKey, data);",
481
+ after: "// hybrid KEM (e.g. @noble/post-quantum or a TLS 1.3 hybrid group)\nimport { MlKem768 } from 'mlkem';\nconst kem = new MlKem768();\nconst [encapsulated, sharedSecret] = await kem.encap(peerPublicKey); // pair with X25519 for hybrid",
482
+ },
483
+ ECDH: {
484
+ note: "ECDH / DH key agreement → hybrid X25519MLKEM768 (ML-KEM-768 + X25519).",
485
+ before: "const ecdh = crypto.createECDH('prime256v1');\nconst shared = ecdh.computeSecret(peerKey);",
486
+ after: "// TLS: negotiate the X25519MLKEM768 named group. In app code, combine ML-KEM-768\n// with X25519 and KDF the concatenated secrets so security holds if either survives.",
487
+ },
488
+ DH: {
489
+ note: "Finite-field DH → hybrid X25519MLKEM768.",
490
+ before: "const dh = crypto.createDiffieHellman(2048);",
491
+ after: "// Replace with ML-KEM-768 encapsulation, deployed as a hybrid with X25519.",
492
+ },
493
+ X25519: {
494
+ note: "X25519 is modern but classical; wrap it in a hybrid X25519MLKEM768 rather than dropping it.",
495
+ before: "const alice = crypto.generateKeyPairSync('x25519');",
496
+ after: "// Keep X25519 AND add ML-KEM-768; derive the session key from both (hybrid).",
497
+ },
498
+ ECDSA: {
499
+ note: "ECDSA signatures → ML-DSA-65 (Dilithium), or SLH-DSA where statelessness matters.",
500
+ before: "const sig = crypto.sign('sha256', msg, ecPrivateKey);",
501
+ after: "import { ml_dsa65 } from '@noble/post-quantum/ml-dsa';\nconst sig = ml_dsa65.sign(mlDsaSecretKey, msg);",
502
+ },
503
+ EdDSA: {
504
+ note: "Ed25519/Ed448 signatures → ML-DSA-65 (or a hybrid Ed25519+ML-DSA during transition).",
505
+ before: "const sig = crypto.sign(null, msg, ed25519PrivateKey);",
506
+ after: "import { ml_dsa65 } from '@noble/post-quantum/ml-dsa';\nconst sig = ml_dsa65.sign(mlDsaSecretKey, msg);",
507
+ },
508
+ DSA: {
509
+ note: "DSA is deprecated; rotate keys and migrate to ML-DSA-65.",
510
+ before: "crypto.generateKeyPairSync('dsa', { modulusLength: 2048 });",
511
+ after: "import { ml_dsa65 } from '@noble/post-quantum/ml-dsa';\nconst keys = ml_dsa65.keygen();",
512
+ },
513
+ };
514
+ const planMigrationTool = {
515
+ name: "plan_migration",
516
+ description: "Scan a path and return a deterministic, prioritized post-quantum migration " +
517
+ "plan: findings grouped by algorithm, ordered harvest-now-decrypt-later first, " +
518
+ "each with its PQC target and the readiness-score impact. Reads the filesystem, " +
519
+ "so it is gated like scan_path over HTTP.",
520
+ inputSchema: {
521
+ type: "object",
522
+ properties: {
523
+ path: {
524
+ type: "string",
525
+ description: "Absolute or relative path to a file or directory to plan a migration for.",
526
+ },
527
+ },
528
+ required: ["path"],
529
+ additionalProperties: false,
530
+ },
531
+ async handler(args, context) {
532
+ const path = args.path;
533
+ if (typeof path !== "string" || path.length === 0) {
534
+ return errorResult("plan_migration requires a non-empty 'path' string.");
535
+ }
536
+ const opts = buildScanOptions(path, context);
537
+ if (!opts.ok)
538
+ return opts.result;
539
+ const scanned = await safe("scan", () => scan(opts.options));
540
+ if (!scanned.ok)
541
+ return scanned.result;
542
+ const plan = await buildMigrationPlan(scanned.value);
543
+ return {
544
+ content: [
545
+ { type: "text", text: plan.human },
546
+ { type: "text", text: JSON.stringify(plan.structured, null, 2) },
547
+ ],
548
+ };
549
+ },
550
+ };
551
+ /** Build a deterministic, prioritized migration plan from a scan result. */
552
+ async function buildMigrationPlan(result) {
553
+ const findings = result.findings;
554
+ const byPhase = { 1: [], 2: [], 3: [] };
555
+ for (const f of findings) {
556
+ // Phase 1: HNDL-exposed confidentiality (harvest now, decrypt later) — most urgent.
557
+ // Phase 2: forgeable signatures. Phase 3: transport / certificate config.
558
+ if (f.hndl)
559
+ byPhase[1].push(f);
560
+ else if (f.category === "tls" || f.category === "certificate")
561
+ byPhase[3].push(f);
562
+ else
563
+ byPhase[2].push(f);
564
+ }
565
+ const phaseMeta = {
566
+ 1: {
567
+ title: "Harvest-now-decrypt-later (do first)",
568
+ rationale: "Key exchange / public-key encryption. Traffic captured today is decryptable once a quantum computer exists — migrate these first.",
569
+ },
570
+ 2: {
571
+ title: "Quantum-forgeable signatures",
572
+ rationale: "Signatures are not retroactively broken, but become forgeable post-Q-day. Migrate before Q-day, prioritising long-lived keys.",
573
+ },
574
+ 3: {
575
+ title: "Transport & certificate configuration",
576
+ rationale: "TLS versions, cipher suites, and certificate signature algorithms. Adopt hybrid PQC named groups and plan PQC-capable CA re-issuance.",
577
+ },
578
+ };
579
+ async function groupsFor(list) {
580
+ const byAlgo = new Map();
581
+ for (const f of list) {
582
+ const a = (f.algorithm ?? "unknown");
583
+ const arr = byAlgo.get(a) ?? [];
584
+ arr.push(f);
585
+ byAlgo.set(a, arr);
586
+ }
587
+ const groups = [];
588
+ for (const [algorithm, fs] of byAlgo) {
589
+ const rem = await safe("remediationFor", () => remediationFor(algorithm));
590
+ groups.push({
591
+ algorithm,
592
+ count: fs.length,
593
+ hndlCount: fs.filter((f) => f.hndl).length,
594
+ remediation: rem.ok && rem.value ? rem.value.recommendation : "Adopt NIST PQC (ML-KEM / ML-DSA).",
595
+ locations: fs.slice(0, 8).map((f) => `${f.location.file}:${f.location.line}`),
596
+ });
597
+ }
598
+ // Largest groups first within a phase.
599
+ groups.sort((a, b) => b.count - a.count);
600
+ return groups;
601
+ }
602
+ const phases = [];
603
+ for (const p of [1, 2, 3]) {
604
+ if (byPhase[p].length === 0)
605
+ continue;
606
+ phases.push({
607
+ phase: p,
608
+ title: phaseMeta[p].title,
609
+ rationale: phaseMeta[p].rationale,
610
+ groups: await groupsFor(byPhase[p]),
611
+ });
612
+ }
613
+ const lines = [];
614
+ lines.push(`Post-quantum migration plan for ${result.root}`);
615
+ lines.push(`Readiness score: ${result.inventory.readinessScore}/100 · ${findings.length} finding(s) · ${result.inventory.hndlCount} HNDL-exposed`);
616
+ if (findings.length === 0) {
617
+ lines.push("");
618
+ lines.push(result.analyzedFiles === 0
619
+ ? "No analyzable source was scanned — this plan does NOT cover unsupported languages."
620
+ : "No classical asymmetric cryptography found. Keep scanning in CI to hold the line.");
621
+ return { human: lines.join("\n"), structured: { root: result.root, phases: [] } };
622
+ }
623
+ for (const ph of phases) {
624
+ lines.push("");
625
+ lines.push(`Phase ${ph.phase}: ${ph.title}`);
626
+ lines.push(` ${ph.rationale}`);
627
+ for (const g of ph.groups) {
628
+ lines.push(` - ${g.algorithm} × ${g.count}${g.hndlCount ? ` (${g.hndlCount} HNDL)` : ""} → ${g.remediation}`);
629
+ lines.push(` e.g. ${g.locations.join(", ")}${g.count > g.locations.length ? ", …" : ""}`);
630
+ }
631
+ }
632
+ lines.push("");
633
+ lines.push("Use get_fix_examples for per-algorithm code changes, then verify_fix to confirm each edit.");
634
+ return {
635
+ human: lines.join("\n"),
636
+ structured: {
637
+ root: result.root,
638
+ readinessScore: result.inventory.readinessScore,
639
+ totalFindings: findings.length,
640
+ hndlExposed: result.inventory.hndlCount,
641
+ phases,
642
+ },
643
+ };
644
+ }
645
+ const getFixExamplesTool = {
646
+ name: "get_fix_examples",
647
+ description: "Return before/after code examples for migrating a classical algorithm to a " +
648
+ "post-quantum / hybrid replacement. Provide an 'algorithm' (RSA, ECDH, ECDSA, …) " +
649
+ "or a 'ruleId' from a finding.",
650
+ inputSchema: {
651
+ type: "object",
652
+ properties: {
653
+ algorithm: {
654
+ type: "string",
655
+ description: "Classical algorithm family to migrate away from.",
656
+ },
657
+ ruleId: { type: "string", description: "A finding's ruleId (resolved to its algorithm)." },
658
+ },
659
+ additionalProperties: false,
660
+ },
661
+ async handler(args) {
662
+ const algoInput = typeof args.algorithm === "string" ? args.algorithm.trim() : "";
663
+ const ruleId = typeof args.ruleId === "string" ? args.ruleId.trim() : "";
664
+ if (!algoInput && !ruleId) {
665
+ return errorResult("get_fix_examples requires 'algorithm' or 'ruleId'.");
666
+ }
667
+ let family;
668
+ if (algoInput) {
669
+ family = normalizeAlgorithm(algoInput);
670
+ }
671
+ else {
672
+ const resolved = resolveRule(ruleId);
673
+ family = resolved.meta?.algorithm ?? resolved.algorithm ?? "unknown";
674
+ }
675
+ // Fold families that share a fix onto the example we have.
676
+ const key = family === "ECIES" ? "RSA" : family;
677
+ const ex = FIX_EXAMPLES[key];
678
+ if (!ex) {
679
+ const rem = await safe("remediationFor", () => remediationFor(family));
680
+ return textResult(`No canned example for ${family}. ${rem.ok && rem.value
681
+ ? rem.value.recommendation
682
+ : "Adopt NIST PQC (ML-KEM / ML-DSA), deployed as hybrids."}`);
683
+ }
684
+ const text = [
685
+ `Migration example — ${family}`,
686
+ ex.note,
687
+ "",
688
+ "BEFORE (classical):",
689
+ ex.before,
690
+ "",
691
+ "AFTER (post-quantum / hybrid):",
692
+ ex.after,
693
+ "",
694
+ "Deploy as a hybrid (classical + PQC) first; drop the classical half once the PQC side is proven.",
695
+ ].join("\n");
696
+ return {
697
+ content: [
698
+ { type: "text", text },
699
+ { type: "text", text: JSON.stringify({ algorithm: family, ...ex }, null, 2) },
700
+ ],
701
+ };
702
+ },
703
+ };
704
+ const verifyFixTool = {
705
+ name: "verify_fix",
706
+ description: "Run the quantakrypto detectors over a code snippet (NOT the filesystem) and " +
707
+ "report any classical crypto that remains. Use this to confirm an edit actually " +
708
+ "removed the quantum-vulnerable usage. Provide 'code' plus a 'language' or 'filename'.",
709
+ inputSchema: {
710
+ type: "object",
711
+ properties: {
712
+ code: { type: "string", description: "The source code to check." },
713
+ language: {
714
+ type: "string",
715
+ description: "Language of the code (js, ts, python, go, java, csharp, rust, ruby, c, …).",
716
+ },
717
+ filename: {
718
+ type: "string",
719
+ description: "Optional filename; its extension selects the detectors (overrides 'language').",
720
+ },
721
+ },
722
+ required: ["code"],
723
+ additionalProperties: false,
724
+ },
725
+ async handler(args) {
726
+ const code = typeof args.code === "string" ? args.code : "";
727
+ if (!code)
728
+ return errorResult("verify_fix requires a non-empty 'code' string.");
729
+ const filename = typeof args.filename === "string" ? args.filename.trim() : "";
730
+ const language = typeof args.language === "string" ? args.language.trim() : "";
731
+ if (!filename && !language) {
732
+ return errorResult("verify_fix requires a 'language' or a 'filename' to know which detectors to run.");
733
+ }
734
+ if (!filename && languageToExtension(language) === null) {
735
+ return errorResult(`verify_fix: unknown language "${language}". Supported: js/ts, python, go, java, kotlin, csharp, rust, ruby, c/c++ — or pass a 'filename'.`);
736
+ }
737
+ const res = await safe("verifyFix", () => verifyFix(code, { filename, language }));
738
+ if (!res.ok)
739
+ return res.result;
740
+ const { supported, findings } = res.value;
741
+ if (findings.length === 0) {
742
+ const caveat = supported
743
+ ? "Fix verified: no classical asymmetric cryptography detected in this snippet."
744
+ : "No classical crypto detected — but this language is NOT one the scanner analyzes, so this is not a verification. Use a supported language.";
745
+ return textResult(caveat);
746
+ }
747
+ const lines = [`Still ${findings.length} classical finding(s) — fix NOT complete:`];
748
+ for (const f of findings) {
749
+ lines.push(`- [${f.severity}] ${f.ruleId} (line ${f.location.line})${f.hndl ? " (HNDL)" : ""} — ${f.message}`);
750
+ }
751
+ return {
752
+ content: [
753
+ { type: "text", text: lines.join("\n") },
754
+ { type: "text", text: JSON.stringify(findings, null, 2) },
755
+ ],
756
+ };
757
+ },
758
+ };
759
+ const checkDependencyTool = {
760
+ name: "check_dependency",
761
+ description: "Check whether a package is in quantakrypto's known quantum-vulnerable dependency " +
762
+ "database (the classical crypto it exposes). Provide 'name' and optional 'ecosystem' (default npm).",
763
+ inputSchema: {
764
+ type: "object",
765
+ properties: {
766
+ name: {
767
+ type: "string",
768
+ description: "Package name to look up (e.g. 'node-forge', 'jsonwebtoken').",
769
+ },
770
+ ecosystem: { type: "string", description: "Package ecosystem. Default: npm." },
771
+ },
772
+ required: ["name"],
773
+ additionalProperties: false,
774
+ },
775
+ async handler(args) {
776
+ const name = typeof args.name === "string" ? args.name.trim().toLowerCase() : "";
777
+ if (!name)
778
+ return errorResult("check_dependency requires a non-empty 'name'.");
779
+ const ecosystem = typeof args.ecosystem === "string" ? args.ecosystem.trim().toLowerCase() : "npm";
780
+ const db = await safe("vulnerableDependencies", () => vulnerableDependencies);
781
+ if (!db.ok)
782
+ return db.result;
783
+ const hit = db.value.find((d) => d.name.toLowerCase() === name && d.ecosystem.toLowerCase() === ecosystem);
784
+ if (!hit) {
785
+ return textResult(`"${name}" (${ecosystem}) is NOT in the known quantum-vulnerable dependency database. ` +
786
+ "That is not proof it's safe — it may simply not be catalogued, or its crypto may be in your own code. Scan the source too.");
787
+ }
788
+ const rem = await safe("remediationFor", () => remediationFor(hit.algorithms[0] ?? "unknown"));
789
+ const text = [
790
+ `${hit.name} (${hit.ecosystem}) — quantum-vulnerable [${hit.severity}]`,
791
+ `Exposes: ${hit.algorithms.join(", ")}`,
792
+ `Why: ${hit.reason}`,
793
+ rem.ok && rem.value
794
+ ? `Migrate toward: ${rem.value.recommendation}`
795
+ : "Migrate toward NIST PQC (ML-KEM / ML-DSA).",
796
+ ].join("\n");
797
+ return {
798
+ content: [
799
+ { type: "text", text },
800
+ { type: "text", text: JSON.stringify(hit, null, 2) },
801
+ ],
802
+ };
803
+ },
804
+ };
805
+ const scoreDeltaTool = {
806
+ name: "score_delta",
807
+ description: "Compute the readiness-score and HNDL change between two finding sets (e.g. before " +
808
+ "and after a migration). Pass 'before' and 'after' as arrays of findings from " +
809
+ "scan_path --format json.",
810
+ inputSchema: {
811
+ type: "object",
812
+ properties: {
813
+ before: {
814
+ type: "array",
815
+ description: "Findings before the change (from a scan's JSON findings).",
816
+ },
817
+ after: { type: "array", description: "Findings after the change." },
818
+ },
819
+ required: ["before", "after"],
820
+ additionalProperties: false,
821
+ },
822
+ async handler(args) {
823
+ if (!Array.isArray(args.before) || !Array.isArray(args.after)) {
824
+ return errorResult("score_delta requires 'before' and 'after' arrays of findings.");
825
+ }
826
+ const before = args.before;
827
+ const after = args.after;
828
+ const invBefore = await safe("buildInventory", () => buildInventory(before));
829
+ if (!invBefore.ok)
830
+ return invBefore.result;
831
+ const invAfter = await safe("buildInventory", () => buildInventory(after));
832
+ if (!invAfter.ok)
833
+ return invAfter.result;
834
+ const dScore = invAfter.value.readinessScore - invBefore.value.readinessScore;
835
+ const dHndl = invAfter.value.hndlCount - invBefore.value.hndlCount;
836
+ const dCount = after.length - before.length;
837
+ const sign = (n) => (n > 0 ? `+${n}` : `${n}`);
838
+ const text = [
839
+ "Readiness delta",
840
+ `Score: ${invBefore.value.readinessScore} → ${invAfter.value.readinessScore} (${sign(dScore)})`,
841
+ `Findings: ${before.length} → ${after.length} (${sign(dCount)})`,
842
+ `HNDL: ${invBefore.value.hndlCount} → ${invAfter.value.hndlCount} (${sign(dHndl)})`,
843
+ dScore > 0
844
+ ? "Progress: readiness improved."
845
+ : dScore < 0
846
+ ? "Regression: readiness dropped — new classical crypto was introduced."
847
+ : "No net change in readiness.",
848
+ ].join("\n");
849
+ return {
850
+ content: [
851
+ { type: "text", text },
852
+ {
853
+ type: "text",
854
+ text: JSON.stringify({
855
+ before: {
856
+ score: invBefore.value.readinessScore,
857
+ findings: before.length,
858
+ hndl: invBefore.value.hndlCount,
859
+ },
860
+ after: {
861
+ score: invAfter.value.readinessScore,
862
+ findings: after.length,
863
+ hndl: invAfter.value.hndlCount,
864
+ },
865
+ delta: { score: dScore, findings: dCount, hndl: dHndl },
866
+ }, null, 2),
867
+ },
868
+ ],
869
+ };
870
+ },
871
+ };
872
+ const triageFindingsTool = {
873
+ name: "triage_findings",
874
+ description: "Produce a deterministic triage REQUEST bundle (rubric + verdict schema + " +
875
+ "per-finding metadata) for YOU (the host agent) to reason over. This tool does " +
876
+ "NOT call any model and needs no API key. Assess each finding's real-world " +
877
+ "exposure, then call apply_triage with your verdicts. Pass 'findings' as an " +
878
+ "array from scan_path --format json.",
879
+ inputSchema: {
880
+ type: "object",
881
+ properties: {
882
+ findings: { type: "array", description: "Findings from a scan's JSON output." },
883
+ },
884
+ required: ["findings"],
885
+ additionalProperties: false,
886
+ },
887
+ async handler(args) {
888
+ if (!Array.isArray(args.findings)) {
889
+ return errorResult("triage_findings requires a 'findings' array.");
890
+ }
891
+ const findings = args.findings;
892
+ const request = buildTriageRequest(findings, "metadata");
893
+ // Pair each context with the finding fingerprint the verdict must echo back.
894
+ const items = findings.map((f, i) => ({
895
+ fingerprint: fingerprintFinding(f),
896
+ context: request.contexts[i],
897
+ }));
898
+ const bundle = { rubric: request.rubric, schema: request.schema, items };
899
+ return {
900
+ content: [
901
+ {
902
+ type: "text",
903
+ text: "Triage request. For each item, decide {exposureScore 0-100, priority, rationale} " +
904
+ "per the rubric, then call apply_triage with the same 'findings' and a 'verdicts' " +
905
+ "array (each carrying the item's 'fingerprint'). You never suppress a finding.",
906
+ },
907
+ { type: "text", text: JSON.stringify(bundle, null, 2) },
908
+ ],
909
+ };
910
+ },
911
+ };
912
+ /** Validate one caller-supplied triage verdict. Returns null when malformed. */
913
+ function parseVerdict(v) {
914
+ if (typeof v !== "object" || v === null)
915
+ return null;
916
+ const o = v;
917
+ const fingerprint = o.fingerprint;
918
+ const exposureScore = o.exposureScore;
919
+ const priority = o.priority;
920
+ const rationale = o.rationale;
921
+ if (typeof fingerprint !== "string")
922
+ return null;
923
+ if (typeof exposureScore !== "number" || exposureScore < 0 || exposureScore > 100)
924
+ return null;
925
+ if (priority !== "now" && priority !== "soon" && priority !== "later")
926
+ return null;
927
+ if (typeof rationale !== "string")
928
+ return null;
929
+ return { fingerprint, exposureScore, priority, rationale };
930
+ }
931
+ const applyTriageTool = {
932
+ name: "apply_triage",
933
+ description: "Deterministically attach your triage verdicts to their findings and re-sort by " +
934
+ "exposure (highest first). Never suppresses. Pass the same 'findings' array you " +
935
+ "triaged plus a 'verdicts' array of { fingerprint, exposureScore, priority, rationale }.",
936
+ inputSchema: {
937
+ type: "object",
938
+ properties: {
939
+ findings: { type: "array", description: "The findings that were triaged." },
940
+ verdicts: { type: "array", description: "One verdict per finding, keyed by fingerprint." },
941
+ },
942
+ required: ["findings", "verdicts"],
943
+ additionalProperties: false,
944
+ },
945
+ async handler(args) {
946
+ if (!Array.isArray(args.findings) || !Array.isArray(args.verdicts)) {
947
+ return errorResult("apply_triage requires 'findings' and 'verdicts' arrays.");
948
+ }
949
+ const findings = args.findings;
950
+ const byFingerprint = new Map();
951
+ let skipped = 0;
952
+ for (const raw of args.verdicts) {
953
+ const v = parseVerdict(raw);
954
+ if (v)
955
+ byFingerprint.set(v.fingerprint, v);
956
+ else
957
+ skipped++;
958
+ }
959
+ const annotated = findings.map((f) => {
960
+ const v = byFingerprint.get(fingerprintFinding(f));
961
+ return v
962
+ ? {
963
+ ...f,
964
+ triage: {
965
+ exposureScore: v.exposureScore,
966
+ priority: v.priority,
967
+ rationale: v.rationale,
968
+ },
969
+ }
970
+ : f;
971
+ });
972
+ annotated.sort((a, b) => {
973
+ const ea = a.triage?.exposureScore ?? -1;
974
+ const eb = b.triage?.exposureScore ?? -1;
975
+ if (eb !== ea)
976
+ return eb - ea;
977
+ return compareFindings(a, b);
978
+ });
979
+ const applied = annotated.filter((f) => f.triage).length;
980
+ const head = `Triaged ${applied}/${findings.length} finding(s), re-sorted by exposure.` +
981
+ (skipped ? ` ${skipped} malformed verdict(s) ignored.` : "");
982
+ return {
983
+ content: [
984
+ { type: "text", text: head },
985
+ { type: "text", text: JSON.stringify(annotated, null, 2) },
986
+ ],
987
+ };
988
+ },
989
+ };
990
+ const remediateFindingsTool = {
991
+ name: "remediate_findings",
992
+ description: "Produce a deterministic remediation REQUEST bundle (rubric + fix schema + " +
993
+ "per-finding metadata + fingerprints) for YOU (the host agent) to fix. This " +
994
+ "tool calls no model and needs no key. For each finding, propose the corrected " +
995
+ "FULL file content, then VERIFY with verify_fix and keep only fixes that clear " +
996
+ "the finding. Never touch files with secrets; never auto-merge. Pass 'findings' " +
997
+ "from scan_path --format json.",
998
+ inputSchema: {
999
+ type: "object",
1000
+ properties: {
1001
+ findings: { type: "array", description: "Findings from a scan's JSON output." },
1002
+ },
1003
+ required: ["findings"],
1004
+ additionalProperties: false,
1005
+ },
1006
+ async handler(args) {
1007
+ if (!Array.isArray(args.findings)) {
1008
+ return errorResult("remediate_findings requires a 'findings' array.");
1009
+ }
1010
+ const findings = args.findings;
1011
+ const request = buildRemediateRequest(findings, "metadata");
1012
+ const items = findings.map((f, i) => ({
1013
+ fingerprint: fingerprintFinding(f),
1014
+ context: request.contexts[i],
1015
+ }));
1016
+ const bundle = { instructions: request.instructions, schema: request.schema, items };
1017
+ return {
1018
+ content: [
1019
+ {
1020
+ type: "text",
1021
+ text: "Remediation request. For each item, propose {path, newContent, explanation} " +
1022
+ "(the FULL corrected file), call verify_fix on your newContent, and keep only " +
1023
+ "verified fixes. Skip any file containing secrets. This never merges anything.",
1024
+ },
1025
+ { type: "text", text: JSON.stringify(bundle, null, 2) },
1026
+ ],
1027
+ };
1028
+ },
1029
+ };
456
1030
  /**
457
1031
  * Tools that read arbitrary filesystem paths. Disabled by default on the HTTP
458
1032
  * transport (see {@link ./http.ts}) because a hosted endpoint must not be an
459
1033
  * arbitrary-file-read oracle (security audit Q-01). The stdio transport, which
460
1034
  * trusts the local user, always exposes them.
461
1035
  */
462
- export const FS_TOOL_NAMES = ["scan_path", "inventory_crypto", "generate_cbom"];
1036
+ export const FS_TOOL_NAMES = [
1037
+ "scan_path",
1038
+ "inventory_crypto",
1039
+ "generate_cbom",
1040
+ "plan_migration",
1041
+ ];
463
1042
  /** All quantakrypto MCP tools, in a stable order. */
464
1043
  export const quantakryptoTools = [
465
1044
  scanPathTool,
@@ -468,6 +1047,16 @@ export const quantakryptoTools = [
468
1047
  suggestHybridTool,
469
1048
  listRulesTool,
470
1049
  generateCbomTool,
1050
+ // Copilot tools — migrate through the engine.
1051
+ planMigrationTool,
1052
+ getFixExamplesTool,
1053
+ verifyFixTool,
1054
+ checkDependencyTool,
1055
+ scoreDeltaTool,
1056
+ // BYOK triage + remediation — deterministic request/apply (host agent reasons; offline).
1057
+ triageFindingsTool,
1058
+ applyTriageTool,
1059
+ remediateFindingsTool,
471
1060
  ];
472
1061
  /** The core version these tools are built against (re-exported for diagnostics). */
473
1062
  export const CORE_VERSION = VERSION;