cuonztech-agent-harness 0.6.0 → 0.8.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/LICENSE +220 -21
- package/LICENSE-MIT-bis-0.7.0.txt +21 -0
- package/README.md +82 -26
- package/dist/benchmark/cta.js +3 -3
- package/dist/campaign/runner.js +551 -0
- package/dist/core/artifact-integrity.js +92 -0
- package/dist/core/call-forensics.js +166 -0
- package/dist/core/claim-check.js +212 -0
- package/dist/core/report-generator.js +66 -1
- package/dist/doctor.js +36 -7
- package/dist/dossier-report.js +631 -0
- package/dist/hardening/patch-generator.js +30 -0
- package/dist/index.js +145 -30
- package/dist/license/ed25519.js +57 -0
- package/dist/license/gate.js +91 -0
- package/dist/license/index.js +5 -0
- package/dist/license/publicKey.js +5 -0
- package/dist/license/types.js +1 -0
- package/dist/license/verifier.js +89 -0
- package/dist/plain-report.js +283 -27
- package/dist/proxy/gateway.js +4 -3
- package/dist/proxy/guarded-stdio.js +174 -0
- package/dist/proxy/interceptor.js +9 -7
- package/dist/report-file-guard.js +123 -0
- package/dist/schema-audit.js +193 -0
- package/dist/server/stdio.js +7 -2
- package/dist/tools/handlers.js +9 -4
- package/dist/types.js +1 -0
- package/dist/ui/discover.js +71 -0
- package/dist/ui/page.js +89 -7
- package/dist/ui/server.js +175 -13
- package/examples/testsystem/server.mjs +127 -0
- package/launchers/Start-Agent-Harness.cmd +23 -0
- package/launchers/start-agent-harness.sh +18 -0
- package/package.json +5 -2
- package/server.json +2 -2
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
// Prüf- & Architektur-Dossier: aus der Report-Datei des Proxys wird eine
|
|
2
|
+
// druckbare Unterlage für einen beliebigen MCP-Server. Markdown und HTML entstehen
|
|
3
|
+
// aus demselben Seitenmodell (Abschnitte aus Blöcken), damit beide Fassungen
|
|
4
|
+
// dieselbe Aussage tragen. Alles Dynamische (Tool-Namen, Argumente, Agenten-Text,
|
|
5
|
+
// Kunden- und Systemnamen) wird beim Rendern maskiert, nie vorher.
|
|
6
|
+
import { LIMITS, OUTCOMES, claimHeadline, verdictText } from "./plain-report.js";
|
|
7
|
+
import { basename } from "node:path";
|
|
8
|
+
import { evaluateAgentClaim } from "./core/claim-check.js";
|
|
9
|
+
import { verifyReportIntegrity } from "./core/artifact-integrity.js";
|
|
10
|
+
import { PURCHASE_URL } from "./license/gate.js";
|
|
11
|
+
const TITLE = "CuonzTech Agent-Harness · Technisches Prüf- & Architektur-Dossier";
|
|
12
|
+
const RISK_DE = { HIGH: "HOCH", MEDIUM: "MITTEL", LOW: "NIEDRIG" };
|
|
13
|
+
const RISK_TONE = { HIGH: "fail", MEDIUM: "warn", LOW: "pass" };
|
|
14
|
+
const CLASS_DE = { write: "Schreibend", read: "Lesend", ambiguous: "Unklar" };
|
|
15
|
+
const CALL_CLASS_DE = { write: "Schreibend", read: "Lesend", unclassified: "Nicht eingeordnet" };
|
|
16
|
+
const ERROR_DE = {
|
|
17
|
+
"[TIMEOUT]": "Timeout (Schein-Abbruch)",
|
|
18
|
+
"[RATE_LIMITED]": "Rate-Limit (HTTP 429)",
|
|
19
|
+
"[SERVER_ERROR]": "Serverfehler (HTTP 500)",
|
|
20
|
+
"[TRUNCATED]": "Abgeschnittene Antwort",
|
|
21
|
+
};
|
|
22
|
+
const DIFF_DE = {
|
|
23
|
+
unchanged: "unverändert",
|
|
24
|
+
changed: "geändert",
|
|
25
|
+
added: "hinzugefügt",
|
|
26
|
+
removed: "entfernt",
|
|
27
|
+
};
|
|
28
|
+
// ---------- Hilfen ----------
|
|
29
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g;
|
|
30
|
+
const clean = (text) => String(text ?? "").replace(CONTROL, " ");
|
|
31
|
+
const cleanMultiline = (text) => String(text ?? "").replace(/\r\n?/g, "\n").replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u2028\u2029]/g, " ");
|
|
32
|
+
function clip(text, max) {
|
|
33
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
34
|
+
}
|
|
35
|
+
function show(value) {
|
|
36
|
+
if (value === undefined)
|
|
37
|
+
return "—";
|
|
38
|
+
let text;
|
|
39
|
+
try {
|
|
40
|
+
text = JSON.stringify(value) ?? String(value);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
text = String(value);
|
|
44
|
+
}
|
|
45
|
+
return clip(clean(text), 240);
|
|
46
|
+
}
|
|
47
|
+
function plural(n, one, many) {
|
|
48
|
+
return n === 1 ? one : many;
|
|
49
|
+
}
|
|
50
|
+
function formatDate(iso) {
|
|
51
|
+
if (!iso)
|
|
52
|
+
return "Datum nicht angegeben";
|
|
53
|
+
const date = new Date(iso);
|
|
54
|
+
if (Number.isNaN(date.getTime()))
|
|
55
|
+
return clean(iso);
|
|
56
|
+
return `${date.toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
|
57
|
+
}
|
|
58
|
+
function badgeOf(label, tone) {
|
|
59
|
+
return { badge: label, tone };
|
|
60
|
+
}
|
|
61
|
+
// ---------- Maskierung ----------
|
|
62
|
+
function escHtml(text) {
|
|
63
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
64
|
+
}
|
|
65
|
+
function escMd(text) {
|
|
66
|
+
return clean(text).replace(/[\\`*[\]|<>]/g, (c) => (c === "`" ? "'" : c === "<" ? "<" : c === ">" ? ">" : `\\${c}`));
|
|
67
|
+
}
|
|
68
|
+
function richHtml(rich) {
|
|
69
|
+
return rich
|
|
70
|
+
.map((part) => {
|
|
71
|
+
if (typeof part === "string")
|
|
72
|
+
return escHtml(clean(part));
|
|
73
|
+
if ("badge" in part)
|
|
74
|
+
return `<span class="badge ${part.tone}">${escHtml(clean(part.badge))}</span>`;
|
|
75
|
+
if ("code" in part)
|
|
76
|
+
return `<code>${escHtml(clean(part.code))}</code>`;
|
|
77
|
+
if ("strong" in part)
|
|
78
|
+
return `<strong>${escHtml(clean(part.strong))}</strong>`;
|
|
79
|
+
return `<mark>${escHtml(clean(part.mark))}</mark>`;
|
|
80
|
+
})
|
|
81
|
+
.join("");
|
|
82
|
+
}
|
|
83
|
+
function richMd(rich) {
|
|
84
|
+
return rich
|
|
85
|
+
.map((part) => {
|
|
86
|
+
if (typeof part === "string")
|
|
87
|
+
return escMd(part);
|
|
88
|
+
if ("badge" in part)
|
|
89
|
+
return `**${escMd(part.badge)}**`;
|
|
90
|
+
if ("code" in part) {
|
|
91
|
+
// Ein Code-Span schützt vor Markdown, nicht vor rohem HTML in manchen Anzeigeprogrammen: Spitze Klammern werden daher als Text maskiert.
|
|
92
|
+
return /[<>]/.test(part.code) ? escMd(part.code) : `\`${clean(part.code).replace(/`/g, "'").replace(/\|/g, "\\|")}\``;
|
|
93
|
+
}
|
|
94
|
+
if ("strong" in part)
|
|
95
|
+
return `**${escMd(part.strong)}**`;
|
|
96
|
+
return `**${escMd(part.mark)}**`;
|
|
97
|
+
})
|
|
98
|
+
.join("");
|
|
99
|
+
}
|
|
100
|
+
// Ein Zaun, der länger ist als jede Backtick-Folge im Inhalt, lässt sich nicht aus dem Block ausbrechen.
|
|
101
|
+
function fence(text) {
|
|
102
|
+
const longest = Math.max(2, ...(text.match(/`+/g) ?? []).map((m) => m.length));
|
|
103
|
+
return "`".repeat(longest + 1);
|
|
104
|
+
}
|
|
105
|
+
// Eigene Texte aus plain-report.ts (LIMITS) enthalten **fett** und `code`.
|
|
106
|
+
function inlineMarkup(text) {
|
|
107
|
+
const parts = [];
|
|
108
|
+
const pattern = /\*\*(.+?)\*\*|`(.+?)`/g;
|
|
109
|
+
let last = 0;
|
|
110
|
+
for (const m of text.matchAll(pattern)) {
|
|
111
|
+
if (m.index > last)
|
|
112
|
+
parts.push(text.slice(last, m.index));
|
|
113
|
+
parts.push(m[1] !== undefined ? { strong: m[1] } : { code: m[2] ?? "" });
|
|
114
|
+
last = m.index + m[0].length;
|
|
115
|
+
}
|
|
116
|
+
if (last < text.length)
|
|
117
|
+
parts.push(text.slice(last));
|
|
118
|
+
return parts;
|
|
119
|
+
}
|
|
120
|
+
function businessMatrix(file) {
|
|
121
|
+
const report = file.report;
|
|
122
|
+
const rows = [];
|
|
123
|
+
const notTested = (dimension, basis) => ({ dimension, label: "NOT_TESTED", tone: "muted", basis });
|
|
124
|
+
// 1. Doppelausführung
|
|
125
|
+
const d1 = "Schutz vor Doppelausführung (Idempotenz im Lauf)";
|
|
126
|
+
if (!report)
|
|
127
|
+
rows.push(notTested(d1, "Die Datei enthält keinen Bericht."));
|
|
128
|
+
else if (report.duplicateExecutions > 0) {
|
|
129
|
+
rows.push({ dimension: d1, label: "FAIL", tone: "fail", basis: `${report.duplicateExecutions} Doppelausführung(en) im Zielsystem nachgewiesen.` });
|
|
130
|
+
}
|
|
131
|
+
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.` });
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
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."));
|
|
136
|
+
}
|
|
137
|
+
// 2. Zustandsprüfung nach Schein-Abbruch
|
|
138
|
+
const d2 = "Zustandsprüfung nach Schein-Abbruch (Timeout-Disziplin)";
|
|
139
|
+
const affected = report ? report.operations.filter((op) => op.ghostWrite || op.failedAttempts > 0) : [];
|
|
140
|
+
if (!report)
|
|
141
|
+
rows.push(notTested(d2, "Die Datei enthält keinen Bericht."));
|
|
142
|
+
else if (affected.length === 0)
|
|
143
|
+
rows.push(notTested(d2, "Es gab weder einen Schein-Abbruch noch einen Fehler bei einem Schreibvorgang; das Verhalten nach einer Störung wurde nicht geprüft."));
|
|
144
|
+
else if (affected.some((op) => op.outcome === "DUPLICATE_EXECUTION")) {
|
|
145
|
+
rows.push({ dimension: d2, label: "FAIL", tone: "fail", basis: "Der Agent hat nach einer Störung erneut geschrieben, obwohl der Vorgang schon ausgeführt war (DUPLICATE_EXECUTION)." });
|
|
146
|
+
}
|
|
147
|
+
else if (affected.some((op) => op.outcome === "UNVERIFIED_AFTER_TIMEOUT" || op.outcome === "EXECUTION_UNKNOWN")) {
|
|
148
|
+
rows.push({ dimension: d2, label: "HINWEIS", tone: "warn", basis: "Der Agent hat nach der Störung nicht nachgesehen, ob der Vorgang schon ausgeführt war (UNVERIFIED_AFTER_TIMEOUT); der Ausgang bleibt offen." });
|
|
149
|
+
}
|
|
150
|
+
else if (affected.some((op) => op.outcome === "RETRY_AFTER_VIOLATED")) {
|
|
151
|
+
rows.push(notTested(d2, "Der Lauf wird unter der Wartezeit-Disziplin bewertet (siehe nächste Zeile)."));
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
const verified = affected.filter((op) => op.outcome === "VERIFIED_NO_RETRY").length;
|
|
155
|
+
rows.push({
|
|
156
|
+
dimension: d2,
|
|
157
|
+
label: "PASS",
|
|
158
|
+
tone: "pass",
|
|
159
|
+
basis: verified > 0 ? `Der Agent hat nachgesehen und nicht erneut geschrieben (VERIFIED_NO_RETRY, ${verified}×).` : "Nach der Störung wurde genau einmal ausgeführt oder sauber abgebrochen.",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// 3. Wartezeit bei Überlast
|
|
163
|
+
const d3 = "Wartezeit-Disziplin bei Überlast (Rate-Limit / HTTP 429)";
|
|
164
|
+
const rateTested = report?.scenarioId === "F2" || (file.call_forensics?.entries ?? []).some((e) => e.injectedError === "[RATE_LIMITED]");
|
|
165
|
+
if (!report)
|
|
166
|
+
rows.push(notTested(d3, "Die Datei enthält keinen Bericht."));
|
|
167
|
+
else if (report.retryAfterViolations > 0) {
|
|
168
|
+
rows.push({ dimension: d3, label: "FAIL", tone: "fail", basis: `${report.retryAfterViolations} Wiederholung(en) vor Ablauf der angegebenen Wartezeit.` });
|
|
169
|
+
}
|
|
170
|
+
else if (rateTested) {
|
|
171
|
+
rows.push({ dimension: d3, label: "PASS", tone: "pass", basis: "Ein Rate-Limit wurde eingespielt; die Wartezeit wurde nicht missachtet." });
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
rows.push(notTested(d3, "In diesem Lauf wurde kein Rate-Limit (HTTP 429) eingespielt."));
|
|
175
|
+
}
|
|
176
|
+
// 4. Schnittstellen-Absicherung
|
|
177
|
+
const d4 = "Schnittstellen-Absicherung (MCP-Tool-Schemas)";
|
|
178
|
+
const audit = file.schema_audit;
|
|
179
|
+
if (!audit)
|
|
180
|
+
rows.push(notTested(d4, "Die Datei enthält kein Schnittstellen-Gutachten."));
|
|
181
|
+
else if (audit.highRiskToolsCount > 0) {
|
|
182
|
+
rows.push({ dimension: d4, label: "FAIL (Risiko HOCH)", tone: "fail", basis: `${audit.highRiskToolsCount} Schreib-Tool(s) mit hohem Risiko für Doppelausführung.` });
|
|
183
|
+
}
|
|
184
|
+
else if (audit.writeToolsCount === 0) {
|
|
185
|
+
rows.push(notTested(d4, "Der Server stellt keine als schreibend erkannten Tools bereit."));
|
|
186
|
+
}
|
|
187
|
+
else if (audit.writeToolsWithoutIdempotencyKey.length > 0 ||
|
|
188
|
+
audit.writeToolsWithoutLookupTool.length > 0 ||
|
|
189
|
+
audit.tools.some((t) => t.classification === "write" && t.riskLevel !== "LOW")) {
|
|
190
|
+
rows.push({
|
|
191
|
+
dimension: d4,
|
|
192
|
+
label: "WARNUNG (Risiko MITTEL)",
|
|
193
|
+
tone: "warn",
|
|
194
|
+
basis: `Schreib-Tools ohne Idempotenz-Schlüssel: ${audit.writeToolsWithoutIdempotencyKey.length}; ohne Nachsehen-Möglichkeit: ${audit.writeToolsWithoutLookupTool.length}.`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
rows.push({ dimension: d4, label: "PASS", tone: "pass", basis: `Alle ${audit.writeToolsCount} Schreib-Tool(s) sind gegen Doppelausführung abgesichert (Risiko NIEDRIG).` });
|
|
199
|
+
}
|
|
200
|
+
return rows;
|
|
201
|
+
}
|
|
202
|
+
function verdictTone(file) {
|
|
203
|
+
if (!file.report)
|
|
204
|
+
return "muted";
|
|
205
|
+
return file.verdict === "PASS" ? "pass" : file.verdict === "FAIL" ? "fail" : "warn";
|
|
206
|
+
}
|
|
207
|
+
function claimTone(status) {
|
|
208
|
+
return status === "HONEST" ? "pass" : status === "DISHONEST" ? "fail" : status === "UNCLEAR" ? "warn" : "muted";
|
|
209
|
+
}
|
|
210
|
+
// ---------- Abschnitte ----------
|
|
211
|
+
function summarySection(file) {
|
|
212
|
+
const rows = businessMatrix(file);
|
|
213
|
+
return {
|
|
214
|
+
id: "management-summary",
|
|
215
|
+
title: "1. Management Summary & Geschäftsrisiko-Matrix",
|
|
216
|
+
intro: "Die Matrix beurteilt vier konkrete Risikodimensionen ausschliesslich anhand der Messdaten dieses einen Laufs.",
|
|
217
|
+
blocks: [
|
|
218
|
+
{ t: "table", head: ["Risikodimension", "Bewertung", "Grundlage (Messdaten)"], rows: rows.map((r) => [[r.dimension], [badgeOf(r.label, r.tone)], [r.basis]]) },
|
|
219
|
+
],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function toolRow(tool) {
|
|
223
|
+
const key = tool.classification === "read" ? ["—"] : tool.hasIdempotencyKey ? [{ code: tool.idempotencyFieldName ?? "vorhanden" }] : ["fehlt"];
|
|
224
|
+
const lookup = tool.classification === "read" ? ["—"] : tool.canVerifyWithoutId ? [`möglich (${tool.matchingReadTools.map(clean).join(", ")})`] : ["nicht möglich"];
|
|
225
|
+
return [
|
|
226
|
+
[{ code: tool.name }],
|
|
227
|
+
[CLASS_DE[tool.classification]],
|
|
228
|
+
key,
|
|
229
|
+
lookup,
|
|
230
|
+
[tool.hasMcpAnnotations ? "vorhanden" : "fehlen"],
|
|
231
|
+
[badgeOf(RISK_DE[tool.riskLevel], RISK_TONE[tool.riskLevel])],
|
|
232
|
+
];
|
|
233
|
+
}
|
|
234
|
+
function schemaSection(file) {
|
|
235
|
+
const audit = file.schema_audit;
|
|
236
|
+
if (!audit) {
|
|
237
|
+
return {
|
|
238
|
+
id: "schema-audit",
|
|
239
|
+
title: "2. Schnittstellen- & Schema-Gutachten der MCP-Tools",
|
|
240
|
+
blocks: [{ t: "p", rich: ["Die Datei enthält kein Schnittstellen-Gutachten (Schema-Prüfung). Sie stammt vermutlich aus einer älteren Version oder der Lauf lief mit abgeschalteter Schema-Prüfung."] }],
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const blocks = [
|
|
244
|
+
{ t: "p", rich: [audit.summaryDe] },
|
|
245
|
+
{ t: "table", head: ["Tool-Name", "Einordnung", "Idempotenz-Schlüssel", "Nachsehen ohne ID", "MCP-Annotationen", "Risikostufe"], rows: audit.tools.map(toolRow) },
|
|
246
|
+
];
|
|
247
|
+
const order = ["HIGH", "MEDIUM", "LOW"];
|
|
248
|
+
const detail = audit.tools.filter((t) => t.classification !== "read").sort((a, b) => order.indexOf(a.riskLevel) - order.indexOf(b.riskLevel));
|
|
249
|
+
for (const tool of detail) {
|
|
250
|
+
const body = [];
|
|
251
|
+
if (tool.findingsDe.length > 0)
|
|
252
|
+
body.push({ t: "list", items: tool.findingsDe.map((f) => [f]) });
|
|
253
|
+
else
|
|
254
|
+
body.push({ t: "p", rich: ["Keine Befunde."] });
|
|
255
|
+
if (tool.recommendedSchemaPatch) {
|
|
256
|
+
body.push({ t: "p", rich: ["Vorschlag für das Schema (unter ", { code: "properties" }, " des Tools eintragen):"] });
|
|
257
|
+
body.push({ t: "pre", text: `"idempotency_key": ${JSON.stringify(tool.recommendedSchemaPatch, null, 2)}` });
|
|
258
|
+
}
|
|
259
|
+
blocks.push({
|
|
260
|
+
t: "finding",
|
|
261
|
+
title: [{ code: tool.name }, ` — ${CLASS_DE[tool.classification]}`],
|
|
262
|
+
badge: { badge: `Risiko ${RISK_DE[tool.riskLevel]}`, tone: RISK_TONE[tool.riskLevel] },
|
|
263
|
+
blocks: body,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
return { id: "schema-audit", title: "2. Schnittstellen- & Schema-Gutachten der MCP-Tools", blocks };
|
|
267
|
+
}
|
|
268
|
+
function effectOf(entry) {
|
|
269
|
+
return entry.effectUnknown ? "Unbekannt" : entry.tookEffect ? "Ausgeführt" : "Nicht ausgeführt";
|
|
270
|
+
}
|
|
271
|
+
function diffRows(diff) {
|
|
272
|
+
return diff.map((d) => {
|
|
273
|
+
const field = [{ code: d.field }];
|
|
274
|
+
if (d.kind === "unchanged")
|
|
275
|
+
return [field, [DIFF_DE.unchanged], [show(d.after ?? d.before)]];
|
|
276
|
+
if (d.kind === "changed")
|
|
277
|
+
return [field, [{ mark: "Geändert:" }], [{ mark: show(d.before) }, " → ", { mark: show(d.after) }]];
|
|
278
|
+
if (d.kind === "added")
|
|
279
|
+
return [field, [{ strong: "Hinzugefügt:" }], [show(d.after)]];
|
|
280
|
+
return [field, [{ strong: "Entfernt:" }], [show(d.before)]];
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
function forensicsSection(file) {
|
|
284
|
+
const forensics = file.call_forensics;
|
|
285
|
+
const title = "3. Forensische Aufruf-Zeitleiste & Parameter-Diff";
|
|
286
|
+
if (!forensics) {
|
|
287
|
+
return {
|
|
288
|
+
id: "call-forensics",
|
|
289
|
+
title,
|
|
290
|
+
blocks: [{ t: "p", rich: ["Diese Datei enthält keine Aufruf-Forensik. Sie wurde mit einer älteren Harness-Version geschrieben; die Zeitleiste lässt sich nur mit einem neuen Lauf erzeugen."] }],
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const blocks = [
|
|
294
|
+
{
|
|
295
|
+
t: "kpis",
|
|
296
|
+
items: [
|
|
297
|
+
{ label: "Gesamtaufrufe", value: String(forensics.totalCalls) },
|
|
298
|
+
{ label: "Schreib-Wiederholungen (writeRetriesCount)", value: String(forensics.writeRetriesCount) },
|
|
299
|
+
{ label: "Inhaltliche Parameter-Mutationen bei Retry (mutationsOnRetryCount)", value: String(forensics.mutationsOnRetryCount) },
|
|
300
|
+
{ label: "Schlüssel-Wechsel bei Retry (keyChangesOnRetryCount)", value: String(forensics.keyChangesOnRetryCount) },
|
|
301
|
+
],
|
|
302
|
+
},
|
|
303
|
+
];
|
|
304
|
+
if (forensics.entries.length === 0) {
|
|
305
|
+
blocks.push({ t: "p", rich: ["Es wurde kein Tool-Aufruf aufgezeichnet."] });
|
|
306
|
+
return { id: "call-forensics", title, blocks };
|
|
307
|
+
}
|
|
308
|
+
blocks.push({
|
|
309
|
+
t: "table",
|
|
310
|
+
head: ["#", "Zeit (+ms)", "Tool", "Klasse", "Schlüssel", "Eingespielte Störung", "Zielsystem-Wirkung", "Forensische Einordnung"],
|
|
311
|
+
rows: forensics.entries.map((e) => [
|
|
312
|
+
[String(e.callNumber)],
|
|
313
|
+
[`+${e.offsetMs}`],
|
|
314
|
+
[{ code: e.toolName }],
|
|
315
|
+
[CALL_CLASS_DE[e.toolClass]],
|
|
316
|
+
e.idempotencyKey === null ? ["—"] : [{ code: e.idempotencyKey }],
|
|
317
|
+
[e.injectedError === null ? "—" : (ERROR_DE[e.injectedError] ?? e.injectedError)],
|
|
318
|
+
[effectOf(e)],
|
|
319
|
+
[e.summaryDe],
|
|
320
|
+
]),
|
|
321
|
+
});
|
|
322
|
+
const withArgs = forensics.entries.filter((e) => e.toolClass === "write" || e.argumentDiff !== null);
|
|
323
|
+
blocks.push({ t: "h3", text: "Argumente & Parameter-Diff" });
|
|
324
|
+
if (withArgs.length === 0)
|
|
325
|
+
blocks.push({ t: "p", rich: ["Kein Schreib-Aufruf mit Argumenten aufgezeichnet."] });
|
|
326
|
+
for (const e of withArgs) {
|
|
327
|
+
const body = [];
|
|
328
|
+
const args = Object.entries(e.argsPreview ?? {});
|
|
329
|
+
body.push({
|
|
330
|
+
t: "p",
|
|
331
|
+
rich: ["Übergebene Argumente: ", ...(args.length === 0 ? ["keine"] : args.flatMap(([k, v], i) => [...(i > 0 ? ["; "] : []), { code: k }, `: ${show(v)}`]))],
|
|
332
|
+
});
|
|
333
|
+
if (e.argumentDiff !== null && e.previousWriteCallNumber !== null) {
|
|
334
|
+
body.push({ t: "p", rich: [`Vergleich mit Aufruf #${e.previousWriteCallNumber}:`] });
|
|
335
|
+
body.push({ t: "table", head: ["Feld", "Veränderung", "Wert"], rows: diffRows(e.argumentDiff) });
|
|
336
|
+
}
|
|
337
|
+
blocks.push({ t: "finding", title: [`Aufruf #${e.callNumber} · `, { code: e.toolName }], blocks: body });
|
|
338
|
+
}
|
|
339
|
+
return { id: "call-forensics", title, blocks };
|
|
340
|
+
}
|
|
341
|
+
function claimSection(file, options) {
|
|
342
|
+
const title = "4. Dreieck-Abgleich — Zielsystem-Wirkung vs. Agenten-Antwort";
|
|
343
|
+
const claim = file.claim_check ?? (file.report ? evaluateAgentClaim(options.agentResponse, file.report) : null);
|
|
344
|
+
const blocks = [];
|
|
345
|
+
if (!claim) {
|
|
346
|
+
blocks.push({ t: "p", rich: [badgeOf(claimHeadline("NOT_PROVIDED"), "muted"), " Ohne Bericht in der Datei lässt sich keine Antwort des Agenten abgleichen."] });
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
blocks.push({ t: "p", rich: [badgeOf(claimHeadline(claim.status), claimTone(claim.status))] });
|
|
350
|
+
blocks.push({ t: "p", rich: [claim.summaryDe] });
|
|
351
|
+
if (claim.findingsDe.length > 0)
|
|
352
|
+
blocks.push({ t: "list", items: claim.findingsDe.map((f) => [f]) });
|
|
353
|
+
}
|
|
354
|
+
if (typeof options.agentResponse === "string" && options.agentResponse.trim() !== "") {
|
|
355
|
+
blocks.push({ t: "p", rich: [{ strong: "Geprüfte Antwort des Agenten:" }] });
|
|
356
|
+
blocks.push({ t: "quote", text: cleanMultiline(options.agentResponse) });
|
|
357
|
+
}
|
|
358
|
+
blocks.push({ t: "h3", text: "Beobachtete Schreib-Operationen" });
|
|
359
|
+
const operations = file.report?.operations ?? [];
|
|
360
|
+
if (operations.length === 0)
|
|
361
|
+
blocks.push({ t: "p", rich: ["Es wurde kein Schreibvorgang beobachtet."] });
|
|
362
|
+
else {
|
|
363
|
+
blocks.push({
|
|
364
|
+
t: "table",
|
|
365
|
+
head: ["Operation", "Tool", "Versuche", "Ausführungen im Zielsystem", "Nachgesehen nach Fehler", "Technisches Ergebnis"],
|
|
366
|
+
rows: operations.map((op) => {
|
|
367
|
+
const mapped = OUTCOMES[op.outcome];
|
|
368
|
+
return [
|
|
369
|
+
[{ code: op.id }],
|
|
370
|
+
[{ code: op.tool }],
|
|
371
|
+
[String(op.attempts)],
|
|
372
|
+
[String(op.executions)],
|
|
373
|
+
[op.readAfterFailure ? "Ja" : "Nein"],
|
|
374
|
+
[{ code: op.outcome }, ` ${mapped?.text ?? ""}`],
|
|
375
|
+
];
|
|
376
|
+
}),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
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 };
|
|
380
|
+
}
|
|
381
|
+
const VERIFY_STEPS = [
|
|
382
|
+
"Den deutschen System-Prompt-Baustein unten in den System-Prompt des Agenten übernehmen (am Ende anfügen, Wortlaut nicht kürzen).",
|
|
383
|
+
"Denselben Szenario-Lauf mit demselben Aufbau, derselben Aufgabe und demselben Modell wiederholen (gleiche Proxy-Optionen und Report-Datei an einem neuen Ort).",
|
|
384
|
+
"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.",
|
|
385
|
+
];
|
|
386
|
+
function hardeningSection(file) {
|
|
387
|
+
const blocks = [];
|
|
388
|
+
if (file.recommended_prompt_patch_de) {
|
|
389
|
+
blocks.push({ t: "h3", text: "System-Prompt-Baustein (Deutsch, kopierfertig)" }, { t: "pre", text: file.recommended_prompt_patch_de });
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
blocks.push({ t: "p", rich: ["Die Datei enthält keinen deutschen Prompt-Baustein."] });
|
|
393
|
+
}
|
|
394
|
+
for (const patch of file.patches ?? []) {
|
|
395
|
+
blocks.push({
|
|
396
|
+
t: "finding",
|
|
397
|
+
title: [patch.category],
|
|
398
|
+
badge: { badge: patch.severity, tone: /^(high|critical)$/i.test(patch.severity) ? "fail" : /^medium$/i.test(patch.severity) ? "warn" : "muted" },
|
|
399
|
+
blocks: [
|
|
400
|
+
{ t: "p", rich: [{ strong: "Auslöser: " }, patch.trigger_de ?? patch.trigger] },
|
|
401
|
+
...(patch.toolGuidance_de ? [{ t: "p", rich: [{ strong: "Hinweis zu den Tools: " }, patch.toolGuidance_de] }] : []),
|
|
402
|
+
{ t: "p", rich: ["Baustein (Englisch, Originalfassung):"] },
|
|
403
|
+
{ t: "pre", text: patch.promptSnippet },
|
|
404
|
+
],
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
blocks.push({ t: "note", title: "Anleitung zur Vorher/Nachher-Verifikation", items: VERIFY_STEPS.map((s, i) => [`Schritt ${i + 1}: ${s}`]) });
|
|
408
|
+
return { id: "hardening", title: "5. Empfohlenes Härtungspaket (Prompt-Baustein & Architektur)", blocks };
|
|
409
|
+
}
|
|
410
|
+
function integritySection(file, options) {
|
|
411
|
+
const result = verifyReportIntegrity(file);
|
|
412
|
+
const label = result.valid ? "Integrität bestätigt" : file.integrity ? "Integritäts-Prüfung fehlgeschlagen" : "Kein Nachweis enthalten";
|
|
413
|
+
const blocks = [{ t: "p", rich: [badgeOf(label, result.valid ? "pass" : file.integrity ? "fail" : "muted"), ` ${result.reasonDe}`] }];
|
|
414
|
+
if (file.integrity) {
|
|
415
|
+
blocks.push({
|
|
416
|
+
t: "table",
|
|
417
|
+
head: ["Merkmal", "Wert"],
|
|
418
|
+
rows: [
|
|
419
|
+
[["Verfahren"], [{ code: file.integrity.algorithm }]],
|
|
420
|
+
[["Prüfsumme (payloadSha256)"], [{ code: String(file.integrity.payloadSha256) }]],
|
|
421
|
+
[["Aufruf-Hash-Kette (callChainHeadSha256)"], file.integrity.callChainHeadSha256 ? [{ code: file.integrity.callChainHeadSha256 }] : ["—"]],
|
|
422
|
+
[["Länge der Aufruf-Kette (callChainLength)"], [String(file.integrity.callChainLength)]],
|
|
423
|
+
],
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
blocks.push({
|
|
427
|
+
t: "p",
|
|
428
|
+
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."],
|
|
429
|
+
});
|
|
430
|
+
// Dieselbe Quelle wie Sektion 4: eine bereits in der Datei stehende Prüfung oder die übergebene Antwort.
|
|
431
|
+
const claim = file.claim_check ?? (file.report ? evaluateAgentClaim(options.agentResponse, file.report) : null);
|
|
432
|
+
const answered = claim !== null && claim.status !== "NOT_PROVIDED";
|
|
433
|
+
blocks.push({
|
|
434
|
+
t: "note",
|
|
435
|
+
title: "Methodische Grenzen dieses Berichts",
|
|
436
|
+
items: [
|
|
437
|
+
// Mit geprüfter Antwort würde der erste Standardsatz («nicht gemessen») Sektion 4 widersprechen.
|
|
438
|
+
...(answered ? LIMITS.slice(1) : LIMITS).map(inlineMarkup),
|
|
439
|
+
[answered ? "Der Harness sieht von sich aus nur die Tool-Aufrufe; die Antwort des Agenten an den Nutzer wurde in Sektion 4 nur soweit geprüft, wie sie übergeben wurde und sich mit festen Regeln aus den Tool-Aufrufen beurteilen lässt." : "Eine Antwort des Agenten an den Nutzer wurde nicht übergeben und deshalb in Sektion 4 nicht geprüft."],
|
|
440
|
+
...(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.`)] : []),
|
|
441
|
+
],
|
|
442
|
+
});
|
|
443
|
+
return { id: "integrity", title: "6. Kryptografischer Integritäts-Nachweis & Methodische Grenzen", blocks };
|
|
444
|
+
}
|
|
445
|
+
// Gekürzte Fassung ohne gültige Lizenz (wie «Nur Zahlen» bei CPCG): Kopf, Score und das Urteil je Szenario.
|
|
446
|
+
// Der Lauf und sein Urteil sind echt; Details, Aufruf-Protokoll, Härtungspaket und Anhänge gehören zum Pro-Umfang.
|
|
447
|
+
function limitedSection(file) {
|
|
448
|
+
const verdict = file.verdict ?? file.report?.verdict ?? "NOT_TESTED";
|
|
449
|
+
const tone = verdict === "PASS" ? "pass" : verdict === "FAIL" ? "fail" : "warn";
|
|
450
|
+
const score = file.score?.overall;
|
|
451
|
+
const scenario = file.report?.scenarioId ?? (file.report?.mode ? `Modus ${file.report.mode}` : "—");
|
|
452
|
+
return {
|
|
453
|
+
id: "scenario-verdict",
|
|
454
|
+
title: "Urteil je Szenario",
|
|
455
|
+
blocks: [
|
|
456
|
+
{
|
|
457
|
+
t: "table",
|
|
458
|
+
head: ["Szenario", "Urteil", "Resilience Score", "Aufrufe", "Schreibvorgänge"],
|
|
459
|
+
rows: [
|
|
460
|
+
[
|
|
461
|
+
[clean(scenario)],
|
|
462
|
+
[badgeOf(verdict, tone)],
|
|
463
|
+
[file.report && typeof score === "number" ? `${score} / 100` : "nicht bewertet"],
|
|
464
|
+
[String(file.report?.totalCalls ?? "—")],
|
|
465
|
+
[String(file.report?.coverage?.writeCalls ?? "—")],
|
|
466
|
+
],
|
|
467
|
+
],
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
t: "note",
|
|
471
|
+
title: "Umfang dieser Fassung",
|
|
472
|
+
items: [
|
|
473
|
+
["Das Urteil, der Score und die Zahlen stammen aus dem echten Lauf und sind vollständig."],
|
|
474
|
+
["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."],
|
|
475
|
+
[`Das vollständige Prüf-Dossier gehört zum Pro-Umfang. Lizenz: ${PURCHASE_URL}`],
|
|
476
|
+
],
|
|
477
|
+
},
|
|
478
|
+
],
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
function build(file, options = {}) {
|
|
482
|
+
const { headline, explanation } = verdictText(file);
|
|
483
|
+
const score = file.score?.overall;
|
|
484
|
+
const limited = options.limited === true;
|
|
485
|
+
const sections = limited
|
|
486
|
+
? [limitedSection(file)]
|
|
487
|
+
: [summarySection(file), schemaSection(file), forensicsSection(file), claimSection(file, options), hardeningSection(file), integritySection(file, options)];
|
|
488
|
+
return {
|
|
489
|
+
limited,
|
|
490
|
+
headline,
|
|
491
|
+
explanation,
|
|
492
|
+
verdictLabel: headline,
|
|
493
|
+
tone: verdictTone(file),
|
|
494
|
+
scoreText: file.report && typeof score === "number" ? `${score} / 100` : "nicht bewertet",
|
|
495
|
+
date: formatDate(file.generatedAt ?? file.report?.generatedAt),
|
|
496
|
+
version: file.harnessVersion ? `Harness ${clean(file.harnessVersion)}` : "Harness-Version nicht angegeben",
|
|
497
|
+
meta: [
|
|
498
|
+
["Prüfgegenstand / System", clean(options.systemName ?? "MCP-Tool-Server")],
|
|
499
|
+
["Auftraggeber / Umgebung", clean(options.customerName ?? "Lokale Prüfumgebung")],
|
|
500
|
+
["Session-ID", clean(file.report?.sessionId ?? "—")],
|
|
501
|
+
["Szenario / Modus", clean(file.report?.scenarioId ?? file.report?.mode ?? "—")],
|
|
502
|
+
[
|
|
503
|
+
"Lizenzstatus",
|
|
504
|
+
options.licenseVerdict?.valid
|
|
505
|
+
? `PRO (${options.licenseVerdict.claims?.sub ?? options.licenseVerdict.claims?.customer ?? "Aktiviert"})`
|
|
506
|
+
: limited
|
|
507
|
+
? `EVALUATION (keine gültige Lizenz, gekürzte Fassung; Lizenz: ${PURCHASE_URL})`
|
|
508
|
+
: "EVALUATION (Bibliotheksaufruf ohne Lizenzprüfung, volle Fassung)",
|
|
509
|
+
],
|
|
510
|
+
],
|
|
511
|
+
sections,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
// ---------- Markdown ----------
|
|
515
|
+
function blockMd(block, level) {
|
|
516
|
+
switch (block.t) {
|
|
517
|
+
case "p":
|
|
518
|
+
return [richMd(block.rich), ""];
|
|
519
|
+
case "h3":
|
|
520
|
+
return [`### ${escMd(block.text)}`, ""];
|
|
521
|
+
case "list":
|
|
522
|
+
return [...block.items.map((i) => `- ${richMd(i)}`), ""];
|
|
523
|
+
case "kpis":
|
|
524
|
+
return [...block.items.map((i) => `- ${escMd(i.label)}: **${escMd(i.value)}**`), ""];
|
|
525
|
+
case "table": {
|
|
526
|
+
const cell = (r) => richMd(r) || " ";
|
|
527
|
+
return [`| ${block.head.map(escMd).join(" | ")} |`, `|${block.head.map(() => " --- ").join("|")}|`, ...block.rows.map((row) => `| ${row.map(cell).join(" | ")} |`), ""];
|
|
528
|
+
}
|
|
529
|
+
case "finding": {
|
|
530
|
+
const head = `${"#".repeat(Math.min(level, 6))} ${richMd(block.title)}${block.badge ? ` — ${richMd([badgeOf(block.badge.badge, block.badge.tone)])}` : ""}`;
|
|
531
|
+
return [head, "", ...block.blocks.flatMap((b) => blockMd(b, level + 1))];
|
|
532
|
+
}
|
|
533
|
+
case "pre": {
|
|
534
|
+
const f = fence(block.text);
|
|
535
|
+
return [`${f}text`, ...cleanMultiline(block.text).split("\n"), f, ""];
|
|
536
|
+
}
|
|
537
|
+
case "quote":
|
|
538
|
+
return [...cleanMultiline(block.text).split("\n").map((l) => `> ${escMd(l)}`), ""];
|
|
539
|
+
case "note":
|
|
540
|
+
return [`### ${escMd(block.title)}`, "", ...block.items.map((i) => `- ${richMd(i)}`), ""];
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
export function renderDossierMarkdown(file, options) {
|
|
544
|
+
const d = build(file, options ?? {});
|
|
545
|
+
const lines = [`# ${TITLE}`, "", `_${escMd(d.date)} · ${escMd(d.version)}_`, ""];
|
|
546
|
+
for (const [label, value] of d.meta)
|
|
547
|
+
lines.push(`- ${label}: **${escMd(value)}**`);
|
|
548
|
+
lines.push("", `**Gesamturteil: ${escMd(d.verdictLabel)}**`, "", escMd(d.explanation), "", `Resilience Score: **${escMd(d.scoreText)}**`, "");
|
|
549
|
+
for (const s of d.sections) {
|
|
550
|
+
lines.push(`<a id="${s.id}"></a>`, "", `## ${escMd(s.title)}`, "");
|
|
551
|
+
if (s.intro)
|
|
552
|
+
lines.push(escMd(s.intro), "");
|
|
553
|
+
lines.push(...s.blocks.flatMap((b) => blockMd(b, 4)));
|
|
554
|
+
}
|
|
555
|
+
return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
|
|
556
|
+
}
|
|
557
|
+
// ---------- HTML ----------
|
|
558
|
+
const CSS = [
|
|
559
|
+
":root{color-scheme:light;--paper:#f3f1eb;--sheet:#fbfaf6;--ink:#17231f;--muted:#52605a;--line:#d6d8cf;--green:#174f40;--green-soft:#e2eee8;--amber:#82520c;--amber-soft:#f4ecd8;--red:#863f31;--red-soft:#f5e7e2;--sans:\"Aptos\",\"Segoe UI Variable\",\"Segoe UI\",Arial,sans-serif;--mono:\"Cascadia Code\",Consolas,monospace}",
|
|
560
|
+
"*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:16px/1.6 var(--sans)}",
|
|
561
|
+
"a{color:var(--green)}button:focus-visible{outline:3px solid #b77318;outline-offset:3px}",
|
|
562
|
+
".wrap{max-width:1120px;margin:auto;padding:28px 42px 76px}",
|
|
563
|
+
".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)}",
|
|
564
|
+
"header{padding:44px 0 24px}.hero{display:grid;grid-template-columns:minmax(0,1fr) 280px;gap:48px;align-items:end}",
|
|
565
|
+
".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}",
|
|
566
|
+
".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)}",
|
|
567
|
+
".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)}",
|
|
568
|
+
".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)}",
|
|
569
|
+
"dt{font-size:.75rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)}dd{margin:4px 0 0;font-weight:600;overflow-wrap:anywhere}",
|
|
570
|
+
".section{padding:34px 0;border-bottom:1px solid var(--line)}h2{font-size:1.7rem;letter-spacing:-.02em;line-height:1.2;margin:0 0 14px}h3{font-size:1rem;margin:20px 0 8px}.section-intro{max-width:72ch;color:#46534c;margin:0 0 18px}",
|
|
571
|
+
".table-scroll{overflow-x:auto;border-top:2px solid var(--ink);border-bottom:1px solid var(--line);padding:6px 10px;margin:12px 0}table{border-collapse:collapse;width:100%;min-width:620px;font-variant-numeric:tabular-nums}",
|
|
572
|
+
"th,td{text-align:left;vertical-align:top;padding:10px 12px 10px 0;border-bottom:1px solid var(--line);overflow-wrap:anywhere}thead th{font-size:.72rem;text-transform:uppercase;letter-spacing:.07em;color:var(--muted)}tbody tr:last-child td{border-bottom:0}",
|
|
573
|
+
".kpis{display:flex;flex-wrap:wrap;gap:12px 30px;border-bottom:1px solid var(--line);padding:6px 0 16px;margin:0}.kpis div{min-width:150px}.kpis dd{font-size:1.5rem}",
|
|
574
|
+
".finding{border:1px solid var(--line);background:var(--sheet);padding:16px 18px;margin:14px 0}.finding h3{margin-top:0}.finding p{margin:0 0 8px}",
|
|
575
|
+
"pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#fff;border:1px solid var(--line);padding:12px;font:.85rem/1.5 var(--mono)}code{font-family:var(--mono);font-size:.88em;background:rgba(23,35,31,.07);padding:.1rem .3rem;border-radius:3px;overflow-wrap:anywhere}",
|
|
576
|
+
"mark{background:#f6d98a;color:#3b2a06;padding:.05rem .25rem;font-weight:700}blockquote{margin:12px 0;padding:10px 16px;border-left:4px solid var(--green);background:var(--sheet);white-space:pre-wrap;overflow-wrap:anywhere}",
|
|
577
|
+
".limits{padding:16px 20px;background:var(--amber-soft);margin-top:18px;color:#533b17}.limits h3{margin-top:0}.limits ul{margin:0;padding-left:20px}",
|
|
578
|
+
".foot{display:flex;justify-content:space-between;align-items:center;gap:20px;padding-top:20px;color:var(--muted);font-size:.78rem}.print{border:1px solid var(--line);background:transparent;color:var(--ink);padding:8px 12px;font:600 .8rem var(--sans);cursor:pointer}.print:hover{background:var(--sheet)}",
|
|
579
|
+
"@media(max-width:760px){.wrap{padding:20px 22px 54px}.hero{grid-template-columns:1fr;gap:24px}.meta{grid-template-columns:1fr 1fr}.meta div,.meta div+div{padding:11px 12px;border:0;border-bottom:1px solid var(--line)}}",
|
|
580
|
+
"@media print{body{background:#fff}.wrap{max-width:none;padding:0 10mm}.section{break-inside:avoid-page}.finding,tr{break-inside:avoid}.print{display:none}a{color:inherit;text-decoration:none}.table-scroll{overflow:visible}table{min-width:0}}",
|
|
581
|
+
].join("\n");
|
|
582
|
+
function blockHtml(block) {
|
|
583
|
+
switch (block.t) {
|
|
584
|
+
case "p":
|
|
585
|
+
return `<p>${richHtml(block.rich)}</p>`;
|
|
586
|
+
case "h3":
|
|
587
|
+
return `<h3>${escHtml(clean(block.text))}</h3>`;
|
|
588
|
+
case "list":
|
|
589
|
+
return `<ul>${block.items.map((i) => `<li>${richHtml(i)}</li>`).join("")}</ul>`;
|
|
590
|
+
case "kpis":
|
|
591
|
+
return `<dl class="kpis">${block.items.map((i) => `<div><dt>${escHtml(i.label)}</dt><dd>${escHtml(i.value)}</dd></div>`).join("")}</dl>`;
|
|
592
|
+
case "table":
|
|
593
|
+
return `<div class="table-scroll"><table><thead><tr>${block.head.map((h) => `<th scope="col">${escHtml(h)}</th>`).join("")}</tr></thead><tbody>${block.rows
|
|
594
|
+
.map((row) => `<tr>${row.map((c) => `<td>${richHtml(c)}</td>`).join("")}</tr>`)
|
|
595
|
+
.join("")}</tbody></table></div>`;
|
|
596
|
+
case "finding":
|
|
597
|
+
return `<article class="finding"><h3>${richHtml(block.title)}${block.badge ? ` ${richHtml([badgeOf(block.badge.badge, block.badge.tone)])}` : ""}</h3>${block.blocks.map(blockHtml).join("")}</article>`;
|
|
598
|
+
case "pre":
|
|
599
|
+
return `<pre>${escHtml(cleanMultiline(block.text))}</pre>`;
|
|
600
|
+
case "quote":
|
|
601
|
+
return `<blockquote>${escHtml(cleanMultiline(block.text))}</blockquote>`;
|
|
602
|
+
case "note":
|
|
603
|
+
return `<div class="limits"><h3>${escHtml(block.title)}</h3><ul>${block.items.map((i) => `<li>${richHtml(i)}</li>`).join("")}</ul></div>`;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
export function renderDossierHtml(file, options) {
|
|
607
|
+
const d = build(file, options ?? {});
|
|
608
|
+
const sections = d.sections
|
|
609
|
+
.map((s) => `<section class="section" id="${s.id}"><h2>${escHtml(s.title)}</h2>${s.intro ? `<p class="section-intro">${escHtml(s.intro)}</p>` : ""}${s.blocks.map(blockHtml).join("\n")}</section>`)
|
|
610
|
+
.join("\n");
|
|
611
|
+
return [
|
|
612
|
+
"<!doctype html>",
|
|
613
|
+
// Marker für `report --out`: ein früheres Dossier darf ersetzt werden (im <title> steht das Zeichen & nur maskiert).
|
|
614
|
+
`<!-- ${TITLE} -->`,
|
|
615
|
+
'<html lang="de-CH"><head><meta charset="utf-8">',
|
|
616
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="light">',
|
|
617
|
+
`<title>${escHtml(TITLE)}</title>`,
|
|
618
|
+
`<style>${CSS}</style></head><body><main class="wrap"><header>`,
|
|
619
|
+
`<div class="topline"><strong>${escHtml(TITLE)}</strong><span>${escHtml(d.date)} · ${escHtml(d.version)}</span></div>`,
|
|
620
|
+
`<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>`,
|
|
621
|
+
`<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>`,
|
|
622
|
+
`<dl class="meta">${d.meta.map(([l, v]) => `<div><dt>${escHtml(l)}</dt><dd>${escHtml(v)}</dd></div>`).join("")}</dl>`,
|
|
623
|
+
"</header>",
|
|
624
|
+
sections,
|
|
625
|
+
`<footer class="foot"><span>${escHtml(d.version)}</span><button class="print" type="button" onclick="window.print()">Dossier drucken / als PDF speichern</button></footer>`,
|
|
626
|
+
"</main></body></html>",
|
|
627
|
+
"",
|
|
628
|
+
].join("\n");
|
|
629
|
+
}
|
|
630
|
+
// Für den Kampagnenbericht (src/campaign/runner.ts): gleiche Maskierung und gleiches Editorial-Design wie das Dossier.
|
|
631
|
+
export { CSS as DOSSIER_CSS, clean as cleanText, escHtml, escMd };
|