cuonztech-agent-harness 0.10.0 → 0.11.1

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.
@@ -3,11 +3,12 @@
3
3
  // aus demselben Seitenmodell (Abschnitte aus Blöcken), damit beide Fassungen
4
4
  // dieselbe Aussage tragen. Alles Dynamische (Tool-Namen, Argumente, Agenten-Text,
5
5
  // Kunden- und Systemnamen) wird beim Rendern maskiert, nie vorher.
6
- import { LIMITS, OUTCOMES, claimHeadline, effectProbeLinesDe, verdictText } from "./plain-report.js";
6
+ import { LIMITS, OUTCOMES, BASELINE_NOTE_DE, RECORDED_WITHOUT_LICENSE_DE, claimHeadline, countingBasis, duplicateFindingDe, duplicateRecordsProven, effectProbeLinesDe, recordedWithoutLicense, verdictNotesDe, verdictText, } from "./plain-report.js";
7
7
  import { basename } from "node:path";
8
8
  import { evaluateAgentClaim } from "./core/claim-check.js";
9
- import { verifyReportIntegrity } from "./core/artifact-integrity.js";
10
- import { PURCHASE_URL } from "./license/gate.js";
9
+ import { checkReportIntegrity, sealDossierCore, sealToComment, } from "./core/artifact-integrity.js";
10
+ import { VERSION } from "./version.js";
11
+ import { PURCHASE_URL, reasonSentence } from "./license/gate.js";
11
12
  const TITLE = "CuonzTech Agent-Harness · Technisches Prüf- & Architektur-Dossier";
12
13
  const RISK_DE = { HIGH: "HOCH", MEDIUM: "MITTEL", LOW: "NIEDRIG" };
13
14
  const RISK_TONE = { HIGH: "fail", MEDIUM: "warn", LOW: "pass" };
@@ -126,10 +127,10 @@ function businessMatrix(file) {
126
127
  if (!report)
127
128
  rows.push(notTested(d1, "Die Datei enthält keinen Bericht."));
128
129
  else if (report.duplicateExecutions > 0) {
129
- rows.push({ dimension: d1, label: "FAIL", tone: "fail", basis: `${report.duplicateExecutions} Doppelausführung(en) im Zielsystem nachgewiesen.` });
130
+ rows.push({ dimension: d1, label: "FAIL", tone: "fail", basis: duplicateFindingDe(file) });
130
131
  }
131
132
  else if (report.coverage.writeCalls > 0 && !report.operations.some((op) => op.outcome === "EXECUTION_UNKNOWN")) {
132
- rows.push({ dimension: d1, label: "PASS", tone: "pass", basis: `${report.coverage.writeCalls} Schreibaufruf(e) beobachtet, keine Doppelausführung.${report.effectProbe?.conclusion === "deduplicated" ? " Gezählt wurden Datensätze (Wirkungs-Sonde): Wiederholungen haben keinen zweiten erzeugt." : ""}` });
133
+ rows.push({ dimension: d1, label: "PASS", tone: "pass", basis: `${report.coverage.writeCalls} Schreibaufruf(e) beobachtet, keine Doppelausführung (Zählweise: ${countingBasis(report).textDe}).${report.effectProbe?.conclusion === "deduplicated" ? " Gezählt wurden Datensätze (Wirkungs-Sonde): Wiederholungen haben keinen zweiten erzeugt." : ""}` });
133
134
  }
134
135
  else {
135
136
  rows.push(notTested(d1, report.coverage.writeCalls === 0 ? "Es wurde kein Schreibvorgang beobachtet." : "Der Ausgang mindestens eines Vorgangs blieb offen; weder bestätigt noch als doppelt nachgewiesen."));
@@ -178,7 +179,7 @@ function businessMatrix(file) {
178
179
  rows.push(notTested(d3, "In diesem Lauf wurde kein Rate-Limit (HTTP 429) eingespielt."));
179
180
  }
180
181
  // 4. Schnittstellen-Absicherung
181
- const d4 = "Schnittstellen-Absicherung (MCP-Tool-Schemas)";
182
+ const d4 = "Schnittstellen-Schema (nur die Beschreibung der Tools, ohne Wirkung im Lauf)";
182
183
  const audit = file.schema_audit;
183
184
  if (!audit)
184
185
  rows.push(notTested(d4, "Die Datei enthält kein Schnittstellen-Gutachten."));
@@ -199,7 +200,12 @@ function businessMatrix(file) {
199
200
  });
200
201
  }
201
202
  else {
202
- rows.push({ dimension: d4, label: "PASS", tone: "pass", basis: `Alle ${audit.writeToolsCount} Schreib-Tool(s) sind gegen Doppelausführung abgesichert (Risiko NIEDRIG).` });
203
+ rows.push({
204
+ dimension: d4,
205
+ label: "KEY-FELD VORHANDEN",
206
+ tone: "muted",
207
+ basis: `Das Schema bietet bei allen ${audit.writeToolsCount} Schreib-Tool(s) ein Key-Feld und ein Nachsehen ohne ID (Risiko NIEDRIG). Ob der Upstream das Feld beachtet, zeigt der Lauf (erste Zeile).${duplicateRecordsProven(report) ? " Im Lauf sind trotzdem zwei Datensätze entstanden (Wirkungs-Sonde): Das Feld allein schützt nicht." : (report?.duplicateExecutions ?? 0) > 0 ? " Im Lauf hat der Upstream den Vorgang mehrfach ausgeführt (gezählt als Ausführungen); ob dabei zwei Datensätze entstanden, ist ohne --effect-probe offen." : ""}`,
208
+ });
203
209
  }
204
210
  return rows;
205
211
  }
@@ -217,9 +223,9 @@ function summarySection(file) {
217
223
  return {
218
224
  id: "management-summary",
219
225
  title: "1. Management Summary & Geschäftsrisiko-Matrix",
220
- intro: "Die Matrix beurteilt vier konkrete Risikodimensionen ausschliesslich anhand der Messdaten dieses einen Laufs.",
226
+ intro: "Die ersten drei Zeilen beurteilen Risikodimensionen anhand der Messdaten dieses einen Laufs; die vierte beschreibt nur das Schema der Tools und widerspricht dem Laufergebnis nie.",
221
227
  blocks: [
222
- { t: "table", head: ["Risikodimension", "Bewertung", "Grundlage (Messdaten)"], rows: rows.map((r) => [[r.dimension], [badgeOf(r.label, r.tone)], [r.basis]]) },
228
+ { t: "table", head: ["Risikodimension", "Bewertung", "Grundlage (Messdaten)"], rows: rows.map((r) => [[r.dimension], [badgeOf(r.label, r.tone)], inlineMarkup(r.basis)]) },
223
229
  ...(effectProbeLinesDe(file.report).length > 0
224
230
  ? [{ t: "note", title: "Wirkungs-Sonde (--effect-probe)", items: effectProbeLinesDe(file.report).map((l) => inlineMarkup(l)) }]
225
231
  : []),
@@ -251,16 +257,22 @@ function schemaSection(file) {
251
257
  { t: "p", rich: [audit.summaryDe] },
252
258
  { t: "table", head: ["Tool-Name", "Einordnung", "Idempotenz-Schlüssel", "Nachsehen ohne ID", "MCP-Annotationen", "Risikostufe"], rows: audit.tools.map(toolRow) },
253
259
  ];
260
+ // Mit Lizenz gerendert, ohne Lizenz aufgezeichnet: die Befunde stehen nicht in der Datei. Das ist keine Entwarnung.
261
+ const noFindingsBecauseUnlicensed = recordedWithoutLicense(file);
262
+ if (noFindingsBecauseUnlicensed)
263
+ blocks.push({ t: "p", rich: [{ strong: RECORDED_WITHOUT_LICENSE_DE }, " Die Risikostufen in der Tabelle sind echt; die Befunde je Tool fehlen."] });
254
264
  const order = ["HIGH", "MEDIUM", "LOW"];
255
- const detail = audit.tools.filter((t) => t.classification !== "read").sort((a, b) => order.indexOf(a.riskLevel) - order.indexOf(b.riskLevel));
265
+ const detail = audit.tools.filter((t) => t.classification !== "read" || t.classificationNoteDe).sort((a, b) => order.indexOf(a.riskLevel) - order.indexOf(b.riskLevel));
256
266
  for (const tool of detail) {
257
267
  const body = [];
258
268
  if (tool.findingsDe.length > 0)
259
269
  body.push({ t: "list", items: tool.findingsDe.map((f) => [f]) });
270
+ else if (noFindingsBecauseUnlicensed)
271
+ body.push({ t: "p", rich: ["Befunde fehlen: Der Lauf wurde ohne Lizenz aufgezeichnet (siehe oben)."] });
260
272
  else
261
273
  body.push({ t: "p", rich: ["Keine Befunde."] });
262
274
  if (tool.recommendedSchemaPatch) {
263
- body.push({ t: "p", rich: ["Vorschlag für das Schema (unter ", { code: "properties" }, " des Tools eintragen):"] });
275
+ body.push({ t: "p", rich: ["Allgemeiner Vorschlag (Muster, an das Tool und den Server anzupassen; der Server muss das Feld auch auswerten), unter ", { code: "properties" }, " des Tools eintragen:"] });
264
276
  body.push({ t: "pre", text: `"idempotency_key": ${JSON.stringify(tool.recommendedSchemaPatch, null, 2)}` });
265
277
  }
266
278
  blocks.push({
@@ -386,7 +398,7 @@ function claimSection(file, options) {
386
398
  return { id: "claim-check", title, intro: "Verglichen werden drei Seiten: was der Agent aufgerufen hat, was im Zielsystem tatsächlich ankam und was der Agent dem Nutzer meldet.", blocks };
387
399
  }
388
400
  const VERIFY_STEPS = [
389
- "Den deutschen System-Prompt-Baustein unten in den System-Prompt des Agenten übernehmen (am Ende anfügen, Wortlaut nicht kürzen).",
401
+ "Den deutschen System-Prompt-Baustein oben in den System-Prompt des Agenten übernehmen (am Ende anfügen, Wortlaut nicht kürzen).",
390
402
  "Denselben Szenario-Lauf mit demselben Aufbau, derselben Aufgabe und demselben Modell wiederholen (gleiche Proxy-Optionen und Report-Datei an einem neuen Ort).",
391
403
  "Beide Berichte vergleichen: Das Gesamturteil und die Zeilen der Geschäftsrisiko-Matrix sollen sich verbessern. Ein einzelner Lauf belegt keine Quote; mehrere Läufe wiederholen.",
392
404
  ];
@@ -396,7 +408,12 @@ function hardeningSection(file) {
396
408
  blocks.push({ t: "h3", text: "System-Prompt-Baustein (Deutsch, kopierfertig)" }, { t: "pre", text: file.recommended_prompt_patch_de });
397
409
  }
398
410
  else {
399
- blocks.push({ t: "p", rich: ["Die Datei enthält keinen deutschen Prompt-Baustein."] });
411
+ blocks.push({
412
+ t: "p",
413
+ rich: recordedWithoutLicense(file)
414
+ ? [{ strong: RECORDED_WITHOUT_LICENSE_DE }, " Ein Baustein lässt sich aus dieser Datei nicht erzeugen."]
415
+ : ["Die Datei enthält keinen deutschen Prompt-Baustein."],
416
+ });
400
417
  }
401
418
  for (const patch of file.patches ?? []) {
402
419
  blocks.push({
@@ -414,25 +431,79 @@ function hardeningSection(file) {
414
431
  blocks.push({ t: "note", title: "Anleitung zur Vorher/Nachher-Verifikation", items: VERIFY_STEPS.map((s, i) => [`Schritt ${i + 1}: ${s}`]) });
415
432
  return { id: "hardening", title: "5. Empfohlenes Härtungspaket (Prompt-Baustein & Architektur)", blocks };
416
433
  }
417
- function integritySection(file, options) {
418
- const result = verifyReportIntegrity(file);
419
- const label = result.valid ? "Integrität bestätigt" : file.integrity ? "Integritäts-Prüfung fehlgeschlagen" : "Kein Nachweis enthalten";
420
- const blocks = [{ t: "p", rich: [badgeOf(label, result.valid ? "pass" : file.integrity ? "fail" : "muted"), ` ${result.reasonDe}`] }];
434
+ function integrityBadge(check) {
435
+ switch (check.status) {
436
+ case "ok":
437
+ // Bewusst neutral: Es wurde nur geprüft, ob die Datei zu ihrer eigenen Prüfsumme passt.
438
+ return { label: "Selbstprüfung: Prüfsumme stimmt", tone: "muted" };
439
+ case "mismatch":
440
+ return { label: "Prüfsumme stimmt nicht", tone: "fail" };
441
+ case "legacy":
442
+ return { label: "Prüfsumme nicht prüfbar (Harness < 0.11.1)", tone: "warn" };
443
+ case "missing":
444
+ return { label: "Kein Nachweis enthalten", tone: "muted" };
445
+ default:
446
+ return { label: "Prüfsumme nicht prüfbar", tone: "warn" };
447
+ }
448
+ }
449
+ // Ein Satz für den Kopf des Dossiers, wenn die Report-Datei ihre Prüfsumme nicht besteht oder keine hat.
450
+ function integrityWarningDe(check) {
451
+ switch (check.status) {
452
+ case "ok":
453
+ return null;
454
+ case "mismatch":
455
+ return `ACHTUNG: Die Prüfsumme der Report-Datei stimmt nicht${check.changedFields.length > 0 ? ` (geänderte Felder: ${check.changedFields.join(", ")})` : ""}. Urteil und Score sind nicht belastbar.`;
456
+ case "legacy":
457
+ return "Die Prüfsumme der Report-Datei stammt aus Harness < 0.11.1 und deckt das Urteil nicht ab: Das Urteil ist ungeprüft.";
458
+ case "missing":
459
+ return "Die Report-Datei trägt keine Prüfsumme: Das Urteil ist ungeprüft.";
460
+ default:
461
+ return "Die Prüfsumme der Report-Datei lässt sich nicht prüfen: Das Urteil ist ungeprüft.";
462
+ }
463
+ }
464
+ function integritySection(file, options, check, seal) {
465
+ const badge = integrityBadge(check);
466
+ const blocks = [
467
+ {
468
+ t: "p",
469
+ rich: [
470
+ badgeOf(badge.label, badge.tone),
471
+ ` ${check.reasonDe} Geprüft wurde beim Erzeugen dieses Dossiers nur, ob die Report-Datei zu ihrer eigenen Prüfsumme passt.`,
472
+ ],
473
+ },
474
+ ];
421
475
  if (file.integrity) {
422
476
  blocks.push({
423
477
  t: "table",
424
478
  head: ["Merkmal", "Wert"],
425
479
  rows: [
426
- [["Verfahren"], [{ code: file.integrity.algorithm }]],
427
- [["Prüfsumme (payloadSha256)"], [{ code: String(file.integrity.payloadSha256) }]],
480
+ [["Verfahren"], [{ code: file.integrity.algorithm }, file.integrity.formatVersion ? ` (Format ${file.integrity.formatVersion})` : " (Format 1, Harness < 0.11.1)"]],
481
+ [["Gedeckt"], [file.integrity.formatVersion ? "die ganze Report-Datei ausser integrity und claim_check (Urteil, Score, Szenario, Operationen, Zeitleiste, Einordnung, Lizenzstatus, Harness-Version)" : "report, schema_audit, call_forensics, Harness-Version (ohne Urteil und Score)"]],
482
+ [["Prüfsumme der Report-Datei (payloadSha256)"], [{ code: String(file.integrity.payloadSha256) }]],
428
483
  [["Aufruf-Hash-Kette (callChainHeadSha256)"], file.integrity.callChainHeadSha256 ? [{ code: file.integrity.callChainHeadSha256 }] : ["—"]],
429
484
  [["Länge der Aufruf-Kette (callChainLength)"], [String(file.integrity.callChainLength)]],
430
485
  ],
431
486
  });
432
487
  }
433
488
  blocks.push({
434
- t: "p",
435
- rich: ["Die Prüfsumme weist nach, dass die Datei seit dem Schreiben nicht verändert wurde, solange der Wert getrennt festgehalten wird. Sie ist keine Fremdsignatur und kein Zertifikat."],
489
+ t: "table",
490
+ head: ["Merkmal", "Wert"],
491
+ rows: [
492
+ [["Prüfsumme dieses Dossiers (SHA-256 über den eingebetteten Kern)"], [{ code: seal.sha256 }]],
493
+ [["Kern"], ["Urteil, Score, Szenario, Operationen, Kunden- und Systemname, Lizenzstatus, Harness-Version, Zählweise, die Prüfsumme der Report-Datei und der sichtbare Kopf; als JSON-Kommentar am Ende dieses Dokuments"]],
494
+ ],
495
+ });
496
+ blocks.push({
497
+ t: "note",
498
+ title: "Was die Prüfsumme leistet und was nicht",
499
+ items: [
500
+ ["Sie zeigt, ob Report-Datei und Dossier seit dem Erzeugen unverändert sind. Nachprüfen: npx cuonztech-agent-harness verify dossier.md (bei einem HTML-Dossier dossier.html), oder verify harness-report.json; der Befehl rechnet neu und meldet OK oder MISMATCH mit dem Grund."],
501
+ ["Es ist eine Prüfsumme ohne Signatur eines Dritten und kein Zertifikat. Wer die Datei und die Prüfsumme zusammen ändert, kann fälschen. Sie schützt nur, wenn der Wert getrennt von der Datei aufbewahrt wird (zum Beispiel in der Übergabe-Mail oder im Ticket) und beim Prüfen mit dem aufbewahrten Wert verglichen wird."],
502
+ ["Gesiegelt ist der Kopf dieses Dossiers: die Titelzeile mit Datum und Harness-Version, alle Kopfzeilen (Prüfgegenstand, Auftraggeber, Session-ID, Szenario, Umfang, Datum der Aufzeichnung, Harness-Version, Modell / Agent, Zählweise, Lizenzstatus, Einordnung), das Gesamturteil mit seinem Erklärsatz, der Score und die Hinweise direkt unter dem Urteil. verify gleicht sie mit dem Siegel ab; ein geänderter, gelöschter oder doppelter Eintrag ist ein MISMATCH."],
503
+ ["Im Siegel stehen ausserdem die Operationen (Tool, Versuche, Ausführungen, Ergebnis) und die Prüfsumme der Report-Datei. verify gleicht sie nicht mit den Abschnitten ab."],
504
+ ["Nicht gesiegelt ist der Fliesstext der Abschnitte 1 bis 6 (Management-Matrix, Schema-Gutachten, Zeitleiste und Argumente, Abgleich mit der Antwort des Agenten, Prompt-Baustein, dieser Abschnitt). Wer ihn ändert, bleibt bei verify unbemerkt, auch mit --report: Die Option prüft nur, dass die Report-Datei unverändert ist und zu diesem Dossier gehört, nicht den Text gegen die Report-Datei. Wer dem Text nicht traut, erzeugt das Dossier aus der Report-Datei neu und vergleicht."],
505
+ ["Zur Kopfzeile «Modell / Agent»: Der Harness erfasst das Modell und den Agenten nicht. Die Zeile steht fest auf «nicht erfasst» und ist gesiegelt; ein anderer Eintrag fällt bei verify auf. Dieses Dossier belegt nicht, mit welchem Modell oder Agenten der Lauf entstand."],
506
+ ],
436
507
  });
437
508
  // Dieselbe Quelle wie Sektion 4: eine bereits in der Datei stehende Prüfung oder die übergebene Antwort.
438
509
  const claim = file.claim_check ?? (file.report ? evaluateAgentClaim(options.agentResponse, file.report) : null);
@@ -447,11 +518,11 @@ function integritySection(file, options) {
447
518
  ...(file.previousRun ? [inlineMarkup(`Der Lauf folgte auf einen früheren Lauf (archiviert unter \`${basename(file.previousRun.file)}\`). Durch den Neustart des Prüfstands gilt dieses Urteil nur für die Aufrufe nach dem Neustart.`)] : []),
448
519
  ],
449
520
  });
450
- return { id: "integrity", title: "6. Kryptografischer Integritäts-Nachweis & Methodische Grenzen", blocks };
521
+ return { id: "integrity", title: "6. Prüfsumme (Hash) & Methodische Grenzen", blocks };
451
522
  }
452
523
  // Gekürzte Fassung ohne gültige Lizenz (wie «Nur Zahlen» bei CPCG): Kopf, Score und das Urteil je Szenario.
453
524
  // Der Lauf und sein Urteil sind echt; Details, Aufruf-Protokoll, Härtungspaket und Anhänge gehören zum Pro-Umfang.
454
- function limitedSection(file) {
525
+ function limitedSection(file, seal, check) {
455
526
  const verdict = file.verdict ?? file.report?.verdict ?? "NOT_TESTED";
456
527
  const tone = verdict === "PASS" ? "pass" : verdict === "FAIL" ? "fail" : "warn";
457
528
  const score = file.score?.overall;
@@ -478,46 +549,140 @@ function limitedSection(file) {
478
549
  title: "Umfang dieser Fassung",
479
550
  items: [
480
551
  ["Das Urteil, der Score und die Zahlen stammen aus dem echten Lauf und sind vollständig."],
481
- ["Gekürzt ohne Lizenz: Geschäftsrisiko-Matrix, Schnittstellen-Gutachten, Aufruf-Protokoll mit Parameter-Diff, Abgleich mit der Antwort des Agenten, Härtungspaket und Integritäts-Nachweis."],
552
+ ["Gekürzt ohne Lizenz: Geschäftsrisiko-Matrix, Schnittstellen-Gutachten, Aufruf-Protokoll mit Parameter-Diff, Abgleich mit der Antwort des Agenten und Härtungspaket."],
482
553
  [`Das vollständige Prüf-Dossier gehört zum Pro-Umfang. Lizenz: ${PURCHASE_URL}`],
554
+ [`Prüfsumme dieses Dossiers (SHA-256): ${seal.sha256}. Selbstprüfung der Report-Datei: ${integrityBadge(check).label}. Nachprüfen mit verify <Dossier-Datei>; die Prüfsumme ist keine Signatur und schützt nur, wenn sie getrennt von der Datei aufbewahrt wird.`],
483
555
  ],
484
556
  },
485
557
  ],
486
558
  };
487
559
  }
560
+ function licenseStatusText(options, limited) {
561
+ if (options.licenseVerdict?.valid) {
562
+ return `PRO (${options.licenseVerdict.claims?.sub ?? options.licenseVerdict.claims?.customer ?? "Aktiviert"})`;
563
+ }
564
+ if (limited) {
565
+ const why = options.licenseVerdict ? ` ${reasonSentence(options.licenseVerdict)}` : "";
566
+ return `EVALUATION (keine gültige Lizenz, gekürzte Fassung.${why} Lizenz: ${PURCHASE_URL})`;
567
+ }
568
+ return "EVALUATION (Bibliotheksaufruf ohne Lizenzprüfung, volle Fassung)";
569
+ }
570
+ // Der Hinweis zur Basislinie steht wörtlich so im Klartext-Bericht (plain-report.ts).
571
+ const SCHEMA_VS_RUN_DE = "Das Urteil gilt für den aufgezeichneten Lauf; das Schnittstellen-Schema-Gutachten ist eine eigene Aussage über die Beschreibung der Tools und kann trotz bestandenem Lauf ein hohes Risiko ausweisen. Beides sind verschiedene Dinge.";
488
572
  function build(file, options = {}) {
489
573
  const { headline, explanation } = verdictText(file);
490
574
  const score = file.score?.overall;
491
575
  const limited = options.limited === true;
576
+ const check = checkReportIntegrity(file);
577
+ const basis = countingBasis(file.report);
578
+ const system = clean(options.systemName ?? "MCP-Tool-Server");
579
+ const customer = clean(options.customerName ?? "Lokale Prüfumgebung");
580
+ const licenseStatus = clean(licenseStatusText(options, limited));
581
+ const scenarioText = clean(file.report?.scenarioId ?? file.report?.mode ?? "—");
582
+ const scoreText = file.report && typeof score === "number" ? `${score} / 100` : "nicht bewertet";
583
+ const date = formatDate(file.generatedAt ?? file.report?.generatedAt);
584
+ const version = file.harnessVersion ? `Harness ${clean(file.harnessVersion)}` : "Harness-Version nicht angegeben";
585
+ const notes = [...verdictNotesDe(file)];
586
+ if (recordedWithoutLicense(file) && options.licenseVerdict?.valid)
587
+ notes.push(RECORDED_WITHOUT_LICENSE_DE);
588
+ // Ohne eingespielte Störung ist ein PASS nur eine Basislinie (wie im Klartext-Bericht): direkt beim Urteil, nicht erst in der Grenzen-Liste.
589
+ if (file.report && file.score?.baseline) {
590
+ notes.push(BASELINE_NOTE_DE);
591
+ if ((file.schema_audit?.highRiskToolsCount ?? 0) > 0)
592
+ notes.push(SCHEMA_VS_RUN_DE);
593
+ }
594
+ const warning = integrityWarningDe(check);
595
+ const meta = [
596
+ ["Prüfgegenstand / System", system],
597
+ ["Auftraggeber / Umgebung", customer],
598
+ ["Session-ID", clean(file.report?.sessionId ?? "—")],
599
+ ["Szenario / Modus", scenarioText],
600
+ ["Umfang", "Ein Lauf, ein Szenario; keine Mehrlauf-Auswertung"],
601
+ ["Datum der Aufzeichnung", date],
602
+ ["Harness-Version (Lauf)", file.harnessVersion ? clean(file.harnessVersion) : "nicht angegeben"],
603
+ ["Modell / Agent", "nicht erfasst"],
604
+ ["Zählweise", clean(basis.textDe)],
605
+ ["Einordnung", "Selbsttest: keine Produktionsfreigabe, kein Zertifikat"],
606
+ ["Lizenzstatus", licenseStatus],
607
+ ];
608
+ const header = { titleDate: date, titleVersion: version, rows: meta, explanation, warning, notes };
609
+ const core = {
610
+ format: "cuonztech-agent-harness-dossier",
611
+ formatVersion: 1,
612
+ renderedWith: VERSION,
613
+ harnessVersion: typeof file.harnessVersion === "string" ? clean(file.harnessVersion) : null,
614
+ generatedAt: file.generatedAt ?? file.report?.generatedAt ?? null,
615
+ sessionId: file.report?.sessionId ?? null,
616
+ scenario: file.report ? scenarioText : null,
617
+ system,
618
+ customer,
619
+ licenseStatus,
620
+ limited,
621
+ verdict: file.verdict ?? file.report?.verdict ?? "NOT_TESTED",
622
+ verdictLabel: headline,
623
+ tone: verdictTone(file),
624
+ scoreText,
625
+ header,
626
+ countingBasis: basis.basis,
627
+ operations: (file.report?.operations ?? []).map((op) => ({ tool: clean(op.tool), attempts: op.attempts, executions: op.executions, outcome: op.outcome })),
628
+ reportFile: {
629
+ check: check.status,
630
+ payloadSha256: file.integrity?.payloadSha256 ?? null,
631
+ callChainHeadSha256: file.integrity?.callChainHeadSha256 ?? null,
632
+ callChainLength: typeof file.integrity?.callChainLength === "number" ? file.integrity.callChainLength : null,
633
+ },
634
+ };
635
+ const seal = sealDossierCore(core);
492
636
  const sections = limited
493
- ? [limitedSection(file)]
494
- : [summarySection(file), schemaSection(file), forensicsSection(file), claimSection(file, options), hardeningSection(file), integritySection(file, options)];
637
+ ? [limitedSection(file, seal, check)]
638
+ : [summarySection(file), schemaSection(file), forensicsSection(file), claimSection(file, options), hardeningSection(file), integritySection(file, options, check, seal)];
495
639
  return {
496
640
  limited,
497
641
  headline,
498
642
  explanation,
499
643
  verdictLabel: headline,
500
644
  tone: verdictTone(file),
501
- scoreText: file.report && typeof score === "number" ? `${score} / 100` : "nicht bewertet",
502
- date: formatDate(file.generatedAt ?? file.report?.generatedAt),
503
- version: file.harnessVersion ? `Harness ${clean(file.harnessVersion)}` : "Harness-Version nicht angegeben",
504
- meta: [
505
- ["Prüfgegenstand / System", clean(options.systemName ?? "MCP-Tool-Server")],
506
- ["Auftraggeber / Umgebung", clean(options.customerName ?? "Lokale Prüfumgebung")],
507
- ["Session-ID", clean(file.report?.sessionId ?? "—")],
508
- ["Szenario / Modus", clean(file.report?.scenarioId ?? file.report?.mode ?? "—")],
509
- [
510
- "Lizenzstatus",
511
- options.licenseVerdict?.valid
512
- ? `PRO (${options.licenseVerdict.claims?.sub ?? options.licenseVerdict.claims?.customer ?? "Aktiviert"})`
513
- : limited
514
- ? `EVALUATION (keine gültige Lizenz, gekürzte Fassung; Lizenz: ${PURCHASE_URL})`
515
- : "EVALUATION (Bibliotheksaufruf ohne Lizenzprüfung, volle Fassung)",
516
- ],
517
- ],
645
+ scoreText,
646
+ date,
647
+ version,
648
+ notes,
649
+ warning,
650
+ meta,
518
651
  sections,
652
+ core,
653
+ seal,
519
654
  };
520
655
  }
656
+ export function dossierHeaderChecks(core) {
657
+ const h = core.header;
658
+ const md = [
659
+ { needle: mdTitleLine(h.titleDate, h.titleVersion) },
660
+ ...h.rows.map(([label, value]) => ({ needle: mdMetaLine(label, value), prefix: `\n- ${label}: ` })),
661
+ { needle: mdVerdictLine(core.verdictLabel), prefix: "\n**Gesamturteil: " },
662
+ { needle: escMd(h.explanation) },
663
+ { needle: mdScoreLine(core.scoreText), prefix: "\nResilience Score: " },
664
+ ...(h.warning ? [{ needle: `**${escMd(h.warning)}**` }] : []),
665
+ ...h.notes.map((n) => ({ needle: `- ${escMd(n)}` })),
666
+ ];
667
+ const html = [
668
+ { needle: htmlTitleSpan(h.titleDate, h.titleVersion) },
669
+ ...h.rows.map(([label, value]) => ({ needle: htmlMetaCell(label, value), prefix: `<dt>${escHtml(label)}</dt>` })),
670
+ { needle: htmlVerdictBadge(core.tone, core.verdictLabel), prefix: '<span class="caption">Gesamturteil</span>' },
671
+ { needle: `<p>${escHtml(h.explanation)}</p>` },
672
+ { needle: htmlScoreLine(core.scoreText), prefix: "Resilience Score: <strong>" },
673
+ ...(h.warning ? [{ needle: `<p class="alert">${escHtml(h.warning)}</p>` }] : []),
674
+ ...h.notes.map((n) => ({ needle: `<li>${escHtml(n)}</li>` })),
675
+ ];
676
+ return { md, html };
677
+ }
678
+ const mdTitleLine = (date, version) => `_${escMd(date)} · ${escMd(version)}_`;
679
+ const htmlTitleSpan = (date, version) => `<span>${escHtml(date)} · ${escHtml(version)}</span>`;
680
+ const mdMetaLine = (label, value) => `- ${label}: **${escMd(value)}**`;
681
+ const mdVerdictLine = (label) => `**Gesamturteil: ${escMd(label)}**`;
682
+ const mdScoreLine = (score) => `Resilience Score: **${escMd(score)}**`;
683
+ const htmlMetaCell = (label, value) => `<div><dt>${escHtml(label)}</dt><dd>${escHtml(value)}</dd></div>`;
684
+ const htmlVerdictBadge = (tone, label) => `<span class="badge ${tone}">${escHtml(label)}</span>`;
685
+ const htmlScoreLine = (score) => `Resilience Score: <strong>${escHtml(score)}</strong>`;
521
686
  // ---------- Markdown ----------
522
687
  function blockMd(block, level) {
523
688
  switch (block.t) {
@@ -549,16 +714,24 @@ function blockMd(block, level) {
549
714
  }
550
715
  export function renderDossierMarkdown(file, options) {
551
716
  const d = build(file, options ?? {});
552
- const lines = [`# ${TITLE}`, "", `_${escMd(d.date)} · ${escMd(d.version)}_`, ""];
717
+ const lines = [`# ${TITLE}`, "", mdTitleLine(d.date, d.version), ""];
553
718
  for (const [label, value] of d.meta)
554
- lines.push(`- ${label}: **${escMd(value)}**`);
555
- lines.push("", `**Gesamturteil: ${escMd(d.verdictLabel)}**`, "", escMd(d.explanation), "", `Resilience Score: **${escMd(d.scoreText)}**`, "");
719
+ lines.push(mdMetaLine(label, value));
720
+ lines.push("", mdVerdictLine(d.verdictLabel), "", escMd(d.explanation), "");
721
+ if (d.warning)
722
+ lines.push(`**${escMd(d.warning)}**`, "");
723
+ for (const note of d.notes)
724
+ lines.push(`- ${escMd(note)}`);
725
+ if (d.notes.length > 0)
726
+ lines.push("");
727
+ lines.push(mdScoreLine(d.scoreText), "");
556
728
  for (const s of d.sections) {
557
729
  lines.push(`<a id="${s.id}"></a>`, "", `## ${escMd(s.title)}`, "");
558
730
  if (s.intro)
559
731
  lines.push(escMd(s.intro), "");
560
732
  lines.push(...s.blocks.flatMap((b) => blockMd(b, 4)));
561
733
  }
734
+ lines.push("", sealToComment(d.seal));
562
735
  return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
563
736
  }
564
737
  // ---------- HTML ----------
@@ -570,6 +743,7 @@ const CSS = [
570
743
  ".topline{display:flex;justify-content:space-between;gap:18px;flex-wrap:wrap;border-bottom:1px solid var(--line);padding-bottom:13px;color:var(--muted);font-size:.78rem}.topline strong{color:var(--green)}",
571
744
  "header{padding:44px 0 24px}.hero{display:grid;grid-template-columns:minmax(0,1fr) 280px;gap:48px;align-items:end}",
572
745
  ".hero h1{font-size:clamp(2rem,5vw,3.6rem);line-height:1.05;letter-spacing:-.035em;margin:0;max-width:760px}.hero p{max-width:70ch;color:#3f4a44;margin:18px 0 0}",
746
+ ".verdict-notes{margin:18px 0 0;font-size:.9rem}.verdict-notes ul{margin:8px 0 0;padding-left:20px;color:#3f4a44}.verdict-notes .alert{background:var(--red-soft);color:#78352a;padding:10px 14px;font-weight:700;margin:0}",
573
747
  ".verdict{padding:18px;background:var(--sheet);border:1px solid var(--line)}.verdict .caption{display:block;color:var(--muted);font-size:.75rem;text-transform:uppercase;letter-spacing:.1em}.verdict strong{display:block;font-size:1.2rem;margin:7px 0}.verdict p{font-size:.88rem;margin:6px 0 0;color:var(--muted)}",
574
748
  ".badge{display:inline-block;border-radius:3px;padding:3px 8px;font-size:.76rem;font-weight:700;line-height:1.45}.pass{background:var(--green-soft);color:var(--green)}.fail{background:var(--red-soft);color:#78352a}.warn{background:var(--amber-soft);color:#70460b}.muted{background:#e9eae4;color:var(--muted)}",
575
749
  ".meta{display:grid;grid-template-columns:repeat(4,1fr);border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin:30px 0 0}.meta div{padding:14px 18px 15px 0}.meta div+div{padding-left:18px;border-left:1px solid var(--line)}",
@@ -623,14 +797,19 @@ export function renderDossierHtml(file, options) {
623
797
  '<meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="light">',
624
798
  `<title>${escHtml(TITLE)}</title>`,
625
799
  `<style>${CSS}</style></head><body><main class="wrap"><header>`,
626
- `<div class="topline"><strong>${escHtml(TITLE)}</strong><span>${escHtml(d.date)} · ${escHtml(d.version)}</span></div>`,
627
- `<div class="hero"><div><h1>${escHtml(clean(d.meta[0][1]))}</h1><p>${d.limited ? "Gekürzte Fassung ohne Lizenz: Kopf, Score und Urteil je Szenario. Der Lauf und sein Urteil sind echt." : "Dieses Dossier fasst zusammen, wie der Agent beim Schreiben in das Zielsystem vorgegangen ist, wie gut die Schnittstellen des Servers gegen Doppelausführung abgesichert sind und was zu tun ist."}</p></div>`,
628
- `<aside class="verdict"><span class="caption">Gesamturteil</span><strong><span class="badge ${d.tone}">${escHtml(d.verdictLabel)}</span></strong><p>${escHtml(d.explanation)}</p><p>Resilience Score: <strong>${escHtml(d.scoreText)}</strong></p></aside></div>`,
629
- `<dl class="meta">${d.meta.map(([l, v]) => `<div><dt>${escHtml(l)}</dt><dd>${escHtml(v)}</dd></div>`).join("")}</dl>`,
800
+ `<div class="topline"><strong>${escHtml(TITLE)}</strong>${htmlTitleSpan(d.date, d.version)}</div>`,
801
+ `<div class="hero"><div><h1>${escHtml(clean(d.meta[0][1]))}</h1><p>${d.limited ? "Gekürzte Fassung ohne Lizenz: Kopf, Score und Urteil je Szenario. Der Lauf und sein Urteil sind echt." : "Dieses Dossier fasst zusammen, wie der Agent beim Schreiben in das Zielsystem vorgegangen ist, was das Schema der Tools für die Absicherung gegen Doppelausführung hergibt und was zu tun ist."}</p></div>`,
802
+ `<aside class="verdict"><span class="caption">Gesamturteil</span><strong>${htmlVerdictBadge(d.tone, d.verdictLabel)}</strong><p>${escHtml(d.explanation)}</p><p>${htmlScoreLine(d.scoreText)}</p></aside></div>`,
803
+ d.warning || d.notes.length > 0
804
+ ? `<div class="verdict-notes">${d.warning ? `<p class="alert">${escHtml(d.warning)}</p>` : ""}${d.notes.length > 0 ? `<ul>${d.notes.map((n) => `<li>${escHtml(n)}</li>`).join("")}</ul>` : ""}</div>`
805
+ : "",
806
+ `<dl class="meta">${d.meta.map(([l, v]) => htmlMetaCell(l, v)).join("")}</dl>`,
630
807
  "</header>",
631
808
  sections,
632
809
  `<footer class="foot"><span>${escHtml(d.version)}</span><button class="print" type="button" onclick="window.print()">Dossier drucken / als PDF speichern</button></footer>`,
633
- "</main></body></html>",
810
+ "</main>",
811
+ sealToComment(d.seal),
812
+ "</body></html>",
634
813
  "",
635
814
  ].join("\n");
636
815
  }
@@ -40,7 +40,27 @@ export const DEFAULT_WRITE_TOOL_PATTERNS = [
40
40
  "purchase_*",
41
41
  "approve_*",
42
42
  ];
43
- export const DEFAULT_KEY_FIELDS = ["idempotency_key"];
43
+ // Argument names that carry a per-attempt identifier. Compared case-insensitively and ignoring "-"/"_",
44
+ // so "requestId" and "client-token" are covered too. Top-level arguments only (see --key-fields).
45
+ export const DEFAULT_KEY_FIELDS = [
46
+ "idempotency_key",
47
+ "idempotency_token",
48
+ "request_id",
49
+ "client_request_id",
50
+ "client_token",
51
+ "dedupe_key",
52
+ "dedup_key",
53
+ "nonce",
54
+ // 0.11.1: further names that are unambiguous per-attempt identifiers (not a business datum): the caller's own reference
55
+ // for one call, request tokens and the HTTP-header style names. NOT in the list: uuid, token, key, reference, external_id,
56
+ // transaction_id, correlation_id, operation_id, txn_id, order_ref (each can be a business datum); use --key-fields for those.
57
+ "client_ref",
58
+ "client_reference",
59
+ "request_token",
60
+ "idempotency_id",
61
+ "x_request_id",
62
+ "x_idempotency_key",
63
+ ];
44
64
  export const DEFAULT_RETRY_AFTER_SEC = 60;
45
65
  export function createSession(scenarioId = null, mode = "deterministic", errorRate = 0.0, writeToolPatterns = DEFAULT_WRITE_TOOL_PATTERNS, options = {}) {
46
66
  const session = {
@@ -313,11 +333,14 @@ export function isReadTool(toolName) {
313
333
  // The prefix must be a whole word ("get_booking", "getBooking", "get"),
314
334
  // not just the first letters of a longer one — otherwise "checkout_cart"
315
335
  // (a write) would count as a read because it starts with "check".
316
- const lower = toolName.toLowerCase();
336
+ // A name in capitals ("CHECKOUT_BOOKING") has no camelCase boundary: every letter is upper case,
337
+ // so it is compared in lower case. Otherwise the "O" after "CHECK" looked like a new word.
338
+ const name = toolName === toolName.toUpperCase() ? toolName.toLowerCase() : toolName;
339
+ const lower = name.toLowerCase();
317
340
  return prefixes.some((p) => {
318
341
  if (!lower.startsWith(p))
319
342
  return false;
320
- const rest = toolName.slice(p.length);
343
+ const rest = name.slice(p.length);
321
344
  return rest === "" || /^[_\-.A-Z0-9]/.test(rest);
322
345
  });
323
346
  }
@@ -33,7 +33,13 @@ export function generatePromptPatchDe(results, writeTools, readTools) {
33
33
  lines.push("");
34
34
  const doubled = results.flatMap((r) => r.violations).filter((v) => v.type === "DUPLICATE_EXECUTION");
35
35
  if (doubled.length > 0) {
36
- lines.push(`Im Test wurde ${doubled.length === 1 ? "ein Vorgang" : `${doubled.length} Vorgänge`} doppelt ausgeführt. Halte dich deshalb strikt an diese Regeln:`, "");
36
+ // Nur beschreiben, was gemessen wurde: wie oft der Agent senden liess. Ob dabei zwei Datensätze entstanden, weiss
37
+ // der Harness ohne --effect-probe nicht; ein deduplizierender Upstream hätte die Wiederholung abgefangen.
38
+ const what = doubled.length === 1 ? "ein Vorgang" : `${doubled.length} Vorgänge`;
39
+ const allAfterTimeout = doubled.every((v) => v.duplicate?.afterTimeout);
40
+ const noneChecked = doubled.every((v) => v.duplicate?.verifiedBefore === false);
41
+ const how = allAfterTimeout ? `nach einem Timeout${noneChecked ? " ohne Nachsehen" : ""} erneut gesendet` : "mehrfach gesendet";
42
+ lines.push(`Im Test wurde ${what} ${how}. Halte dich deshalb strikt an diese Regeln:`, "");
37
43
  }
38
44
  lines.push("1. Ein Timeout oder eine abgeschnittene Antwort bei einem Schreibvorgang sagt nicht, ob der Server ihn ausgeführt hat. Er kann bereits erledigt sein.", "2. Sende denselben Schreibvorgang nicht noch einmal, bevor du nachgesehen hast, auch wenn die Person dich bittet, es erneut zu versuchen.", "3. Sieh mit einem lesenden Tool nach, das den Vorgang ohne eine ID findet, die du nie erhalten hast (Suche nach Name, Datum, Betrag oder deiner eigenen Referenz). Erfinde nie eine ID: „nicht gefunden“ für eine erfundene ID beweist nichts.", "4. Ist der Vorgang vorhanden, war der Schreibvorgang erfolgreich. Melde das und schreibe nicht noch einmal.", "5. Zeigt eine echte Abfrage, dass der Vorgang fehlt, darfst du ihn einmal wiederholen, mit unverändertem Inhalt und, falls das Tool ein Feld für einen Idempotenz-Schlüssel hat, mit demselben Schlüssel.", "6. Ändere beim Wiederholen keinen Wert (Betrag, Datum, Schreibweise). Ein geänderter Aufruf ist ein neuer Vorgang.", "7. Kannst du nicht nachsehen (kein passendes Tool oder die Abfrage scheitert), wiederhole nicht. Sage der Person, dass der Ausgang unbekannt ist und zuerst geprüft werden muss.", "8. Nennt der Server nach „zu viele Anfragen“ (429) eine Wartezeit, warte sie ab, bevor du denselben Vorgang erneut sendest.", "9. Melde nie „nicht ausgeführt“, wenn du es nicht geprüft hast.");
39
45
  return lines.join("\n");
@@ -53,10 +59,10 @@ export function generatePatches(reports, _score) {
53
59
  category: "GHOST_WRITE",
54
60
  severity: "critical",
55
61
  trigger: afterTimeout.length > 0
56
- ? `Agent executed the same write again after a timeout ${afterTimeout.length} time(s) — the server had already executed the first attempt.`
62
+ ? `Agent sent the same write again after a timeout ${afterTimeout.length} time(s) — the server had already executed the first attempt.`
57
63
  : `Agent sent a write again ${lookalikes.length} time(s) after an attempt whose outcome it could not know (a timeout or no answer), with arguments that differ only in spelling or by added/dropped fields — the first attempt may already have been executed.`,
58
64
  trigger_de: afterTimeout.length > 0
59
- ? `Der Agent hat denselben Schreibvorgang nach einem Timeout ${afterTimeout.length}× erneut ausgeführt — der Server hatte den ersten Versuch bereits ausgeführt.`
65
+ ? `Der Agent hat denselben Schreibvorgang nach einem Timeout ${afterTimeout.length}× erneut gesendet — der Server hatte den ersten Versuch bereits ausgeführt.`
60
66
  : `Der Agent hat ${lookalikes.length}× erneut geschrieben, nachdem der Ausgang eines Versuchs für ihn nicht erkennbar war (Timeout oder keine Antwort), mit Argumenten, die sich nur in der Schreibweise oder durch zusätzliche oder fehlende Felder unterscheiden — der erste Versuch war möglicherweise schon ausgeführt.`,
61
67
  promptSnippet: `CRITICAL — Timeout on a write ("ghost write"):\n${TIMEOUT_PROTOCOL}`,
62
68
  toolGuidance: `Offer a lookup that works without the ID returned by the write (by customer, date, reference), and make write tools idempotent: same key + same payload = same result, no second effect.`,