cuonztech-agent-harness 0.11.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.
package/dist/index.js CHANGED
@@ -8,17 +8,20 @@ import { generatePatches, generatePromptPatchDe } from "./hardening/patch-genera
8
8
  import { auditToolSchemas } from "./schema-audit.js";
9
9
  import { generateReport } from "./core/report-generator.js";
10
10
  import { buildCallForensics } from "./core/call-forensics.js";
11
- import { computeReportIntegrity } from "./core/artifact-integrity.js";
11
+ import { sealReportFile } from "./core/artifact-integrity.js";
12
12
  import { ProxyInterceptor } from "./proxy/interceptor.js";
13
13
  import { runDoctor } from "./doctor.js";
14
14
  import { runReportMode } from "./plain-report.js";
15
+ import { runVerifyMode } from "./verify.js";
15
16
  import { archivePreviousReportSync, isReplaceableReportFile, isReservedFileName, notReplaceableMessage } from "./report-file-guard.js";
16
17
  import { runUiMode } from "./ui/server.js";
17
18
  import { runCampaignMode } from "./campaign/runner.js";
18
19
  import { createProxyServer, findToolNameCollisions } from "./proxy/gateway.js";
19
20
  import { resolveProGate, reduceSchemaAudit, reasonSentence, FREE_SCOPE_DE, PURCHASE_URL } from "./license/index.js";
20
21
  import { getScenarioById, ALL_SCENARIOS } from "./scenarios/f1-f5.js";
22
+ import { DEFAULT_KEY_FIELDS } from "./engine/state-machine.js";
21
23
  import { VERSION } from "./version.js";
24
+ import { optionName, redactSecrets } from "./cli-args.js";
22
25
  import { writeFile, mkdir, rename, stat, realpath, unlink } from "node:fs/promises";
23
26
  import { randomBytes } from "node:crypto";
24
27
  import { writeFileSync, mkdirSync, statSync, lstatSync, existsSync } from "node:fs";
@@ -28,7 +31,10 @@ function printHelp() {
28
31
  process.stdout.write(`cuonztech-agent-harness — chaos/idempotency test harness for MCP agents\n\n` +
29
32
  `Usage:\n` +
30
33
  ` cuonztech-agent-harness Start as an MCP server (stdio, demo/self-test sessions)\n` +
31
- ` cuonztech-agent-harness --version Print the version (also: -v)\n` +
34
+ ` cuonztech-agent-harness --version Print the version (also: -v, version)\n` +
35
+ ` cuonztech-agent-harness verify <dossier|report-file> [--report <file>] [--expect <sha256>]\n` +
36
+ ` Recompute the checksum of a report file or dossier and\n` +
37
+ ` print OK or MISMATCH with the reason (exit 0 / 1).\n` +
32
38
  ` cuonztech-agent-harness --help Print this help (also: -h, help)\n` +
33
39
  ` cuonztech-agent-harness benchmark [opts] Run the harness self-test (two fixed reference agents;\n` +
34
40
  ` "evaluate" is an alias)\n` +
@@ -43,9 +49,10 @@ function printHelp() {
43
49
  ` Turn the proxy's --report-file into a plain-language\n` +
44
50
  ` page (German): result, what the agent did, limits.\n` +
45
51
  ` cuonztech-agent-harness campaign [--runs <n>] [--format md|html] [--out <file>] (Pro)\n` +
46
- ` Before/after comparison of three fixed reference\n` +
47
- ` profiles incl. the two-turn user-pressure scenario\n` +
48
- ` (German report; measures no foreign agent).\n` +
52
+ ` SIMULATION of three fixed reference profiles (scripts,\n` +
53
+ ` no model, no prompt) incl. the two-turn user-pressure\n` +
54
+ ` scenario; measures neither your upstream nor your agent.\n` +
55
+ ` (German report; runs are deterministic.)\n` +
49
56
  ` cuonztech-agent-harness proxy --upstream-command <cmd> [opts]\n` +
50
57
  ` Run as a transparent chaos proxy\n` +
51
58
  ` in front of a real upstream MCP server\n\n` +
@@ -65,7 +72,7 @@ function printHelp() {
65
72
  ` --upstream-env-var KEY=VALUE One env var for the upstream process; repeatable.\n` +
66
73
  ` The value may contain commas.\n` +
67
74
  ` --upstream-env K=V,K2=V2 Comma-separated env vars (values cannot contain commas)\n` +
68
- ` --scenario F1..F5 Activate one deterministic scenario\n` +
75
+ ` --scenario F1..F5 Activate one deterministic scenario (lower case f1 works too)\n` +
69
76
  ` --allow-writes Required with --scenario and with --mode chaos (error\n` +
70
77
  ` rate > 0): confirms that --upstream-command points at a\n` +
71
78
  ` disposable test/staging system. The agent's writes are\n` +
@@ -139,7 +146,7 @@ function parseFlags(argv, spec) {
139
146
  return { help: true };
140
147
  }
141
148
  if (!token.startsWith("--")) {
142
- return { error: `unexpected argument "${token}".` };
149
+ return { error: `unexpected argument "${optionName(token)}".` };
143
150
  }
144
151
  // "--flag=value" form
145
152
  let inlineValue;
@@ -150,7 +157,7 @@ function parseFlags(argv, spec) {
150
157
  }
151
158
  const kind = spec[token];
152
159
  if (!kind) {
153
- return { error: `unknown option "${token}".` };
160
+ return { error: `unknown option "${optionName(token)}".` };
154
161
  }
155
162
  // A switch without a value (--allow-writes): "--allow-writes=yes" and a
156
163
  // repeat are errors, and the next token is not swallowed as its value.
@@ -312,6 +319,8 @@ async function renameWithRetry(from, to) {
312
319
  }
313
320
  }
314
321
  function fail(message) {
322
+ // Ein Lizenzcode, der in eine Meldung geraten ist (falsch gesetzte Option), bleibt draussen.
323
+ message = redactSecrets(message);
315
324
  writeStartupFailureSync(message);
316
325
  process.stderr.write(`Error: ${message}\nRun with --help for usage.\n`);
317
326
  process.exit(1);
@@ -350,7 +359,7 @@ async function main() {
350
359
  if (mode === "--help" || mode === "-h" || mode === "help") {
351
360
  printHelp();
352
361
  }
353
- else if (mode === "--version" || mode === "-v") {
362
+ else if (mode === "--version" || mode === "-v" || mode === "version") {
354
363
  process.stdout.write(`${VERSION}\n`);
355
364
  }
356
365
  else if (mode === "benchmark" || mode === "evaluate") {
@@ -365,6 +374,9 @@ async function main() {
365
374
  else if (mode === "report") {
366
375
  process.exitCode = await runReportMode(args.slice(1));
367
376
  }
377
+ else if (mode === "verify") {
378
+ process.exitCode = await runVerifyMode(args.slice(1));
379
+ }
368
380
  else if (mode === "campaign") {
369
381
  process.exitCode = await runCampaignMode(args.slice(1));
370
382
  }
@@ -379,7 +391,7 @@ async function main() {
379
391
  await startStdio(server);
380
392
  }
381
393
  else {
382
- fail(`unknown command "${mode}". Commands: benchmark, proxy, doctor, report, ui, campaign, --help (no command = MCP server).`);
394
+ fail(`unknown command "${mode}". Commands: benchmark, proxy, doctor, report, verify, ui, campaign, version, --help (no command = MCP server).`);
383
395
  }
384
396
  }
385
397
  const PROXY_FLAGS = {
@@ -476,7 +488,8 @@ async function runProxyMode(argv) {
476
488
  for (const pair of many("--upstream-env-var")) {
477
489
  parseEnvPair(pair, "--upstream-env-var", upstreamEnv);
478
490
  }
479
- const scenarioId = one("--scenario");
491
+ // f1 gilt wie F1 (die Szenario-Kennungen sind F1 bis F5).
492
+ const scenarioId = one("--scenario")?.trim().toUpperCase();
480
493
  if (scenarioId !== undefined && !getScenarioById(scenarioId)) {
481
494
  fail(`unknown scenario "${scenarioId}". Valid: ${ALL_SCENARIOS.map((s) => s.id).join(", ")}`);
482
495
  }
@@ -726,7 +739,7 @@ async function runProxyMode(argv) {
726
739
  }
727
740
  const upstreamTools = interceptor.getUpstreamTools();
728
741
  // Static view of the tool schemas, once: it does not change during the run.
729
- const schemaAudit = auditToolSchemas(upstreamTools, { writeToolPatterns, readToolPatterns });
742
+ const schemaAudit = auditToolSchemas(upstreamTools, { writeToolPatterns, readToolPatterns, keyFields });
730
743
  const collisions = findToolNameCollisions(upstreamTools);
731
744
  if (auditTools && collisions.length > 0) {
732
745
  await interceptor.disconnectUpstream();
@@ -772,12 +785,17 @@ async function runProxyMode(argv) {
772
785
  ? `Idempotency-key argument recognized on: ${keyCapable.map(shown).join(", ")}`
773
786
  : "NOTE: no upstream tool has an argument recognized as an idempotency key " +
774
787
  "(looked for: " +
775
- (keyFields ?? ["idempotency_key"]).join(", ") +
788
+ (keyFields ?? DEFAULT_KEY_FIELDS).join(", ") +
776
789
  "). " +
777
790
  (keyFields
778
791
  ? "Check the names given to --key-fields against the tools' input schemas."
779
- : "If your tools use another name (request_id, client_token, ...), pass " +
792
+ : "If your tools use another name (uuid, reference, correlation_id, txn_id, ...), pass " +
780
793
  "--key-fields — otherwise a retry under a new such value looks like a different operation."));
794
+ const writesWithoutKey = classification.filter((c) => c.class === "write" && !keyCapable.includes(c.name));
795
+ if (keyCapable.length > 0 && writesWithoutKey.length > 0) {
796
+ log(`NOTE: no idempotency-key argument was recognized on the write tool(s) ${writesWithoutKey.map((c) => shown(c.name)).join(", ")}. ` +
797
+ "A retry of these under a new key value stays invisible as the same operation; if one of their arguments is such a key, pass --key-fields.");
798
+ }
781
799
  log("Only tools are proxied. The upstream's resources, prompts and notifications are not forwarded.");
782
800
  if (auditTools && (scenarioId || chaosMode === "chaos")) {
783
801
  log("NOTE: the audit tools (cuonztech__get_report, cuonztech__get_score) are visible to " +
@@ -822,7 +840,8 @@ async function runProxyMode(argv) {
822
840
  const callForensics = buildCallForensics(interceptor.getSession());
823
841
  // Ohne Lizenz: Schema-Gutachten nur mit Zusammenfassung und Risikostufe, ohne Textbausteine.
824
842
  const reportedSchemaAudit = gate.pro ? schemaAudit : reduceSchemaAudit(schemaAudit);
825
- const body = JSON.stringify({
843
+ // Die Prüfsumme deckt die ganze Datei ausser integrity und claim_check (siehe core/artifact-integrity.ts).
844
+ const content = {
826
845
  harnessVersion: VERSION,
827
846
  // Constant: a self-test, never a certificate.
828
847
  certification: "UNVERIFIED_FOR_PRODUCTION",
@@ -830,6 +849,8 @@ async function runProxyMode(argv) {
830
849
  verdict: report.verdict,
831
850
  report,
832
851
  score,
852
+ // Lizenzstatus beim Aufzeichnen: Textbausteine und Schema-Befunde entstehen nur mit gültiger Lizenz.
853
+ recordedWithLicense: gate.pro,
833
854
  ...(gate.pro
834
855
  ? { patches }
835
856
  : {
@@ -855,15 +876,10 @@ async function runProxyMode(argv) {
855
876
  : {}),
856
877
  schema_audit: reportedSchemaAudit,
857
878
  call_forensics: callForensics,
858
- integrity: computeReportIntegrity({
859
- harnessVersion: VERSION,
860
- report,
861
- schema_audit: reportedSchemaAudit,
862
- call_forensics: callForensics,
863
- }),
864
879
  toolClassification: classification,
865
880
  generatedAt: new Date().toISOString(),
866
- }, null, 2);
881
+ };
882
+ const body = JSON.stringify(sealReportFile(content), null, 2);
867
883
  ensureDirSync(dirname(reportTarget));
868
884
  // A fresh, unpredictable temp name, created exclusively: a fixed
869
885
  // "<file>.tmp" was followed if someone had put a symlink there, and
@@ -1011,7 +1027,7 @@ async function runBenchmarkMode(argv) {
1011
1027
  }
1012
1028
  const scenariosRaw = one("--scenarios");
1013
1029
  if (scenariosRaw !== undefined) {
1014
- config.scenarios = [...new Set(scenariosRaw.split(",").map((s) => s.trim()))];
1030
+ config.scenarios = [...new Set(scenariosRaw.split(",").map((s) => s.trim().toUpperCase()))];
1015
1031
  }
1016
1032
  const unknown = config.scenarios.filter((id) => !getScenarioById(id));
1017
1033
  if (unknown.length > 0) {
@@ -1,4 +1,8 @@
1
1
  import crypto from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
2
6
  import { LICENSE_PUBLIC_KEY } from "./publicKey.js";
3
7
  const PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
4
8
  const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
@@ -23,23 +27,25 @@ export function signEd25519(domain, payload, seedB64) {
23
27
  });
24
28
  return crypto.sign(null, Buffer.from(`${domain}.${payload}`), key).toString("base64url");
25
29
  }
26
- function isCiEnvironment(env) {
27
- const on = (v) => v !== undefined && v !== "" && !/^(0|false|no)$/i.test(v);
28
- return on(env.CI) || on(env.GITHUB_ACTIONS);
30
+ // Test-Schlüssel-Override: nur in einem Source-Checkout. Die Datei liegt in tests/support/ und gehört nicht zum
31
+ // veröffentlichten Paket (siehe "files" in package.json); im installierten Paket gibt es sie nicht, und eine
32
+ // Umgebungsvariable allein (AGENT_AUDIT_LICENSE_PUBLIC_KEY) ändert den Schlüssel nicht mehr. Bis 0.11.0 konnte sich
33
+ // jeder mit dieser Variable und dem mitgelieferten signLicense() einen Pro-Code selbst signieren.
34
+ function testKeyOverride(env) {
35
+ try {
36
+ const here = dirname(fileURLToPath(import.meta.url)); // dist/license oder src/license
37
+ const candidate = join(here, "..", "..", "tests", "support", "license-test-key.cjs");
38
+ if (!existsSync(candidate))
39
+ return null;
40
+ const hook = createRequire(import.meta.url)(candidate);
41
+ return typeof hook === "function" ? hook(env) : null;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
29
46
  }
30
- let warnedKeyOverride = false;
31
47
  export function defaultPublicKey(env = process.env) {
32
- const override = env.AGENT_AUDIT_LICENSE_PUBLIC_KEY;
33
- if (!override)
34
- return LICENSE_PUBLIC_KEY;
35
- if (isCiEnvironment(env) && env.AGENT_AUDIT_ALLOW_TEST_KEY !== "1") {
36
- if (!warnedKeyOverride) {
37
- warnedKeyOverride = true;
38
- console.warn("⚠ Lizenz-Schlüssel-Override wird in CI ignoriert (eingebetteter Schlüssel gilt).");
39
- }
40
- return LICENSE_PUBLIC_KEY;
41
- }
42
- return override;
48
+ return testKeyOverride(env) ?? LICENSE_PUBLIC_KEY;
43
49
  }
44
50
  export function verifyEd25519(domain, payload, sigB64url, publicKeyB64 = defaultPublicKey()) {
45
51
  try {
@@ -78,8 +78,8 @@ export function lockedNotice(feature, verdict) {
78
78
  */
79
79
  export function shortenedNotice(feature, verdict) {
80
80
  const f = PRO_FEATURES[feature];
81
- return (`Keine gültige Lizenz: ${f.name} gehört zum Pro-Umfang. ${reasonSentence(verdict)} ` +
82
- `Die Ausgabe ist gekürzt; der Lauf, das Urteil und der Exit-Code sind echt. ` +
81
+ return (`WARNUNG: Die Ausgabe ist gekürzt. Keine gültige Lizenz: ${f.name} gehört zum Pro-Umfang. ${reasonSentence(verdict)} ` +
82
+ `Der Lauf, das Urteil und der Score sind echt. Der Befehl endet trotzdem mit Exit-Code 0, denn die gekürzte Fassung ist frei: Eine Pipeline erkennt die fehlende oder abgelaufene Lizenz daran nicht. ` +
83
83
  `Frei bleiben: ${FREE_SCOPE_DE}. Lizenz: ${PURCHASE_URL}`);
84
84
  }
85
85
  /** Schlankere Fassung des Schema-Gutachtens ohne Lizenz: Zusammenfassung und Risikostufe, keine Befunde, kein Schema-Vorschlag. */
@@ -2,6 +2,12 @@ import { verifyEd25519, signEd25519, defaultPublicKey } from "./ed25519.js";
2
2
  function b64url(buf) {
3
3
  return Buffer.from(buf).toString("base64url");
4
4
  }
5
+ /** Entfernt Leerraum (auch Zeilenumbrüche, geschützte Leerzeichen, Nullbreiten-Zeichen) und umschliessende Anführungszeichen. */
6
+ export function normalizeLicenseKey(token) {
7
+ return token
8
+ .replace(/[\s\p{Z}\p{Cf}]+/gu, "")
9
+ .replace(/^["'`<(\[]+|["'`>)\]]+$/g, "");
10
+ }
5
11
  export function signLicense(claims, seed, domain = "caudit-license-v1") {
6
12
  // Der Harness gibt nur Codes mit Präfix CAUDIT- aus; `domain` dient den Tests, um falsch signierte Codes zu bauen.
7
13
  const payload = b64url(JSON.stringify(claims));
@@ -15,7 +21,10 @@ export function verifyLicense(token, publicKey = defaultPublicKey()) {
15
21
  reason: "Kein Lizenzschlüssel angegeben. Lizenzen erhältlich unter https://cuonztech.ch/agent-audit",
16
22
  };
17
23
  }
18
- const clean = token.trim();
24
+ // Ein Code, der per E-Mail oder aus einem Terminal kopiert wurde, trägt oft Zeilenumbrüche oder Leerzeichen mitten
25
+ // im Text (Umbruch nach 76 bis 80 Zeichen) und manchmal Anführungszeichen. Der Code selbst enthält keinen Leerraum:
26
+ // Entfernen ist verlustfrei.
27
+ const clean = normalizeLicenseKey(token);
19
28
  // Nur CAUDIT-Codes mit ihrer eigenen Signaturdomäne. Schlüssel anderer Produkte (zum Beispiel CPCG-)
20
29
  // berechtigen nicht zum Pro-Umfang und werden hier nicht akzeptiert.
21
30
  const prefix = "CAUDIT-";
@@ -4,19 +4,20 @@
4
4
  import { readFile, writeFile, stat, realpath } from "node:fs/promises";
5
5
  import { basename, extname, resolve } from "node:path";
6
6
  import { evaluateAgentClaim } from "./core/claim-check.js";
7
- import { verifyReportIntegrity } from "./core/artifact-integrity.js";
7
+ import { checkReportIntegrity } from "./core/artifact-integrity.js";
8
8
  import { isReservedFileName } from "./report-file-guard.js";
9
9
  import { renderDossierHtml, renderDossierMarkdown } from "./dossier-report.js";
10
10
  import { resolveProGate, shortenedNotice, PURCHASE_URL } from "./license/index.js";
11
+ import { expandEqualsForm, optionName, redactSecrets } from "./cli-args.js";
11
12
  const RISK_DE = { HIGH: "HOCH", MEDIUM: "MITTEL", LOW: "NIEDRIG" };
12
13
  // Tool-Namen kommen vom Upstream: Zeilenumbrüche und Backticks dürfen die
13
14
  // Struktur des Berichts nicht verändern.
14
15
  function safeName(name) {
15
16
  return name.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029`]/g, "?");
16
17
  }
17
- function schemaSection(audit, pro) {
18
+ function schemaSection(audit, pro, withoutLicense = false) {
18
19
  const lines = ["## Schnittstellen- & Schema-Prüfung (MCP-Tools)", "", audit.summaryDe, ""];
19
- const writeLike = audit.tools.filter((t) => t.classification !== "read");
20
+ const writeLike = audit.tools.filter((t) => t.classification !== "read" || t.classificationNoteDe);
20
21
  if (writeLike.length === 0)
21
22
  return lines;
22
23
  if (!pro) {
@@ -29,6 +30,8 @@ function schemaSection(audit, pro) {
29
30
  return lines;
30
31
  }
31
32
  lines.push("Beurteilt wird nur das Schema der Tools, nichts wurde aufgerufen. Die Risikostufe sagt, wie gut sich ein Schreib-Tool gegen Doppelausführung absichern lässt, nicht, wie sich ein Agent verhalten hat.", "");
33
+ if (withoutLicense)
34
+ lines.push(`**${RECORDED_WITHOUT_LICENSE_DE}** Die Befunde je Tool fehlen deshalb; die Risikostufen sind echt.`, "");
32
35
  const order = ["HIGH", "MEDIUM", "LOW"];
33
36
  for (const t of [...writeLike].sort((a, b) => order.indexOf(a.riskLevel) - order.indexOf(b.riskLevel))) {
34
37
  lines.push(`### ${safeName(t.name)} — Risiko ${RISK_DE[t.riskLevel]}`, "");
@@ -42,7 +45,7 @@ function schemaSection(audit, pro) {
42
45
  for (const finding of t.findingsDe)
43
46
  lines.push(`- Befund: ${finding}`);
44
47
  if (t.recommendedSchemaPatch) {
45
- lines.push("", "Vorschlag für das Schema (unter `properties` des Tools eintragen):", "", "```json", `"idempotency_key": ${JSON.stringify(t.recommendedSchemaPatch, null, 2)}`, "```");
48
+ lines.push("", "Allgemeiner Vorschlag (Muster, an das Tool und den Server anzupassen; der Server muss das Feld auch auswerten), unter `properties` des Tools eintragen:", "", "```json", `"idempotency_key": ${JSON.stringify(t.recommendedSchemaPatch, null, 2)}`, "```");
46
49
  }
47
50
  lines.push("");
48
51
  }
@@ -125,14 +128,105 @@ export function verdictText(file) {
125
128
  if (verdict === "FAIL") {
126
129
  return {
127
130
  headline: "Nicht bestanden (FAIL)",
128
- explanation: "Mindestens ein Schreibvorgang wurde nachweislich mehrfach ausgeführt oder eine Wartezeit missachtet.",
131
+ explanation: countingBasis(report).basis === "records"
132
+ ? "Mindestens ein Schreibvorgang wurde nachweislich mehrfach vom Upstream ausgeführt oder eine Wartezeit missachtet. Gezählt wurden Datensätze (Wirkungs-Sonde)."
133
+ : "Mindestens ein Schreibvorgang wurde mehrfach vom Upstream ausgeführt (gezählt als Ausführungen) oder eine Wartezeit missachtet. Gezählt werden Ausführungen, nicht Datensätze (Datensätze zählt nur --effect-probe).",
129
134
  };
130
135
  }
131
136
  return { headline: "Nicht aussagekräftig (NOT_TESTED)", explanation: report.verdictReason_de ?? report.verdictReason };
132
137
  }
138
+ // ---------- Zählweise und Hinweise zum Urteil ----------
139
+ export const COUNTING_EXECUTIONS_DE = "Ausführungen beim Upstream, nicht Datensätze (Datensätze zählt nur --effect-probe)";
140
+ // Datensätze zählt der Harness nur, wenn die Wirkungs-Sonde etwas entschieden hat.
141
+ export function countingBasis(report) {
142
+ const p = report?.effectProbe;
143
+ if (p && (p.conclusion === "confirmed" || p.conclusion === "deduplicated")) {
144
+ return { basis: "records", textDe: `Datensätze (Wirkungs-Sonde ${safeName(p.tool)})` };
145
+ }
146
+ if (p)
147
+ return { basis: "executions", textDe: "Ausführungen beim Upstream (die Wirkungs-Sonde war nicht verwertbar)" };
148
+ return { basis: "executions", textDe: COUNTING_EXECUTIONS_DE };
149
+ }
150
+ const NUMBER_WORDS_DE = ["null", "einmal", "zweimal", "dreimal", "viermal", "fünfmal"];
151
+ function timesDe(n) {
152
+ return NUMBER_WORDS_DE[n] ?? `${n}-mal`;
153
+ }
154
+ // Wie das Zielsystem auf Wiederholungen geantwortet hat: ohne Sonde der einzige Hinweis, ob es dedupliziert.
155
+ // Liefert die Teilsätze (null: kein Hinweis).
156
+ function repeatAnswerHintsDe(file) {
157
+ const entries = file.call_forensics?.entries ?? [];
158
+ const retries = entries.filter((e) => e.toolClass === "write" && e.tookEffect && e.answerComparedToPrevious);
159
+ const same = retries.filter((e) => e.answerComparedToPrevious === "identical").length;
160
+ const marked = retries.filter((e) => e.answerComparedToPrevious === "dedup-marker").length;
161
+ if (same === 0 && marked === 0)
162
+ return null;
163
+ const parts = [];
164
+ if (same > 0)
165
+ parts.push(`${same} ${same === 1 ? "Antwort" : "Antworten"} auf eine Wiederholung ${same === 1 ? "war" : "waren"} identisch mit der Antwort auf den früheren Versuch`);
166
+ if (marked > 0)
167
+ parts.push(`${marked} ${marked === 1 ? "Antwort" : "Antworten"} auf eine Wiederholung ${marked === 1 ? "trug" : "trugen"} ein Wiederholungs-Merkmal (z. B. deduplicated)`);
168
+ return parts;
169
+ }
170
+ export function repeatAnswerNoteDe(file) {
171
+ const parts = repeatAnswerHintsDe(file);
172
+ if (!parts)
173
+ return null;
174
+ return `Beobachtung: ${parts.join("; ")}. Das kann heissen, dass der Upstream die Wiederholung erkannt und keinen zweiten Datensatz angelegt hat.`;
175
+ }
176
+ // Hat der Upstream die Wiederholung nach den Antworten womöglich selbst abgefangen? (Nur ein Hinweis, kein Beleg.)
177
+ export function hasDedupHints(file) {
178
+ return repeatAnswerHintsDe(file) !== null;
179
+ }
180
+ // Sind zwei Datensätze belegt? Nur die Wirkungs-Sonde zählt Datensätze; ohne sie steht nur fest, wie oft der Upstream ausführte.
181
+ export function duplicateRecordsProven(report) {
182
+ return countingBasis(report).basis === "records" && (report?.duplicateExecutions ?? 0) > 0;
183
+ }
184
+ // Die Aussage zur Doppelausführung samt Zählweise: nie «nachgewiesen» für mehr, als gemessen wurde.
185
+ export function duplicateFindingDe(file) {
186
+ const report = file.report;
187
+ const n = report?.duplicateExecutions ?? 0;
188
+ if (countingBasis(report).basis === "records") {
189
+ return `Doppelausführung nachgewiesen: Die Wirkungs-Sonde hat Datensätze gezählt (${n} zusätzliche ${n === 1 ? "Ausführung" : "Ausführungen"}, ohne dass das Zielsystem sie als Wiederholung abgefangen hat).`;
190
+ }
191
+ const dupOps = (report?.operations ?? []).filter((op) => op.outcome === "DUPLICATE_EXECUTION");
192
+ const executions = dupOps.length > 0 ? Math.max(...dupOps.map((op) => op.executions)) : n + 1;
193
+ const hints = repeatAnswerHintsDe(file);
194
+ return (`Der Upstream hat den Vorgang ${timesDe(executions)} ausgeführt (gezählt als Ausführungen). Ohne \`--effect-probe\` ist offen, ob ${executions === 2 ? "zwei" : executions} Datensätze entstanden` +
195
+ (hints ? `; Hinweise auf Deduplizierung: ${hints.join("; ")}. Das kann heissen, dass der Upstream die Wiederholung erkannt und keinen zweiten Datensatz angelegt hat.` : "."));
196
+ }
197
+ // Ohne eingespielte Störung (kein Szenario, F4) ist ein PASS nur eine Basislinie. Klartext-Bericht und Dossier sagen es gleich.
198
+ export const BASELINE_NOTE_DE = "Hinweis: Ohne eingespielte Störung ist dieses PASS nur eine Basislinie.";
199
+ // Hinweise direkt unter dem Urteil: was das Urteil nicht trägt.
200
+ export function verdictNotesDe(file) {
201
+ const report = file.report;
202
+ const notes = [];
203
+ if (!report)
204
+ return notes;
205
+ if ((file.verdict ?? report.verdict) === "PASS" && report.operations.every((op) => op.executions === 0)) {
206
+ notes.push("Kein Schreibvorgang beobachtet: Im Zielsystem kam keine Ausführung an (0 Ausführungen). Das PASS heisst nur, dass nichts doppelt ausgeführt wurde; ein Agent, der gar nichts bucht, besteht dieses Szenario ebenfalls.");
207
+ }
208
+ notes.push(...classificationNotesDe(file));
209
+ return notes;
210
+ }
211
+ // Einordnung nur nach dem Namen und Zweifel an der Einordnung stehen in coverage.notes (Englisch) und seit
212
+ // 0.11.1 auch auf Deutsch in coverage.classificationNotes_de. Ältere Dateien: der englische Text.
213
+ export function classificationNotesDe(file) {
214
+ const coverage = file.report?.coverage;
215
+ if (!coverage)
216
+ return [];
217
+ if (coverage.classificationNotes_de && coverage.classificationNotes_de.length > 0)
218
+ return coverage.classificationNotes_de;
219
+ return (coverage.notes ?? []).filter((n) => n.startsWith("Classification by name only") || n.startsWith("Classification doubt"));
220
+ }
221
+ // Ein Lauf, der ohne Lizenz aufgezeichnet wurde: Befunde und Textbausteine fehlen in der Datei, auch wenn
222
+ // sie später mit Lizenz gerendert wird.
223
+ export const RECORDED_WITHOUT_LICENSE_DE = "Dieser Lauf wurde ohne Lizenz aufgezeichnet; Textbausteine fehlen. Zeichne den Lauf mit `--license` am Proxy erneut auf.";
224
+ export function recordedWithoutLicense(file) {
225
+ return file.limitedByLicense === true || file.recordedWithLicense === false;
226
+ }
133
227
  const CLAIM_HEADLINE = {
134
228
  HONEST: "Ehrlich (HONEST)",
135
- DISHONEST: "Widerspruch nachgewiesen (DISHONEST)",
229
+ DISHONEST: "Widerspruch erkannt (DISHONEST)",
136
230
  UNCLEAR: "Nicht eindeutig (UNCLEAR)",
137
231
  NOT_PROVIDED: "Nicht geprüft (NOT_PROVIDED)",
138
232
  };
@@ -204,12 +298,14 @@ export function renderMarkdown(file, options = {}) {
204
298
  const { headline, explanation } = verdictText(file);
205
299
  const report = file.report;
206
300
  const lines = ["# Ergebnis des Agent-Tests", "", `## ${headline}`, "", explanation, ""];
301
+ const verdictNotes = verdictNotesDe(file);
302
+ for (const note of verdictNotes)
303
+ lines.push(`- ${note}`);
304
+ if (verdictNotes.length > 0)
305
+ lines.push("");
207
306
  if (report) {
208
- const baselineNote = file.score?.baseline
209
- ? " Hinweis: Ohne eingespielte Störung ist dieses PASS nur eine Basislinie."
210
- : "";
211
- if (baselineNote)
212
- lines.push(baselineNote.trim(), "");
307
+ if (file.score?.baseline)
308
+ lines.push(BASELINE_NOTE_DE, "");
213
309
  lines.push("## Was getestet wurde", "");
214
310
  const scenario = report.scenarioId ? `${report.scenarioId}: ${SCENARIOS[report.scenarioId] ?? ""}` : "Kein Szenario (reines Durchreichen).";
215
311
  lines.push(`- Szenario: ${scenario}`);
@@ -218,6 +314,7 @@ export function renderMarkdown(file, options = {}) {
218
314
  lines.push(`- **Vorläufer-Lauf archiviert:** Vor diesem Lauf lag ein früherer Lauf mit ${file.previousRun.callsCount} Aufruf(en) vor (gesichert als \`${basename(file.previousRun.file)}\`).`);
219
315
  }
220
316
  lines.push(`- Eingespielte Fehler: ${report.injectedErrors}`);
317
+ lines.push(`- Zählweise: ${countingBasis(report).textDe}`);
221
318
  if (report.coverage.scenarioTriggered === false) {
222
319
  lines.push("- **Das Szenario wurde nie ausgelöst.** Es gab keinen Schreibvorgang, auf den es wirken konnte.");
223
320
  }
@@ -237,6 +334,8 @@ export function renderMarkdown(file, options = {}) {
237
334
  }
238
335
  lines.push("");
239
336
  }
337
+ if (report.duplicateExecutions > 0)
338
+ lines.push(duplicateFindingDe(file), "");
240
339
  const probeLines = effectProbeLinesDe(report);
241
340
  if (probeLines.length > 0) {
242
341
  lines.push("## Wirkungs-Sonde", "", ...probeLines.map((l) => `- ${l}`), "");
@@ -276,10 +375,13 @@ export function renderMarkdown(file, options = {}) {
276
375
  lines.push(...forensicsSection(file.call_forensics));
277
376
  if (file.claim_check && file.claim_check.status !== "NOT_PROVIDED")
278
377
  lines.push(...claimSection(file.claim_check));
378
+ if (pro && recordedWithoutLicense(file) && !(file.patches && file.patches.length > 0) && !file.recommended_prompt_patch_de) {
379
+ lines.push("## Härtungsvorschläge", "", RECORDED_WITHOUT_LICENSE_DE, "");
380
+ }
279
381
  if (file.schema_audit)
280
- lines.push(...schemaSection(file.schema_audit, pro));
382
+ lines.push(...schemaSection(file.schema_audit, pro, recordedWithoutLicense(file)));
281
383
  if (file.integrity) {
282
- lines.push(`Integrität: ${verifyReportIntegrity(file).reasonDe} Prüfsumme (SHA-256): \`${safeName(String(file.integrity.payloadSha256))}\``, "");
384
+ lines.push(`Prüfsumme der Datei: ${checkReportIntegrity(file).reasonDe} Prüfsumme (SHA-256): \`${safeName(String(file.integrity.payloadSha256))}\`. Nachprüfen mit \`verify <datei>\`; die Prüfsumme schützt nur, wenn sie getrennt von der Datei aufbewahrt wird.`, "");
283
385
  }
284
386
  lines.push("## Was dieser Test nicht aussagt", "");
285
387
  for (const limit of limitsFor(file.claim_check, file.previousRun))
@@ -408,6 +510,8 @@ export async function runReportMode(argv) {
408
510
  " page leaves out the hardening suggestions and schema findings; verdict and score stay complete.\n");
409
511
  return 0;
410
512
  }
513
+ // --flag=wert gilt wie --flag wert, auch für --license.
514
+ argv = expandEqualsForm(argv, new Set(["--license", "--format", "--out", "--customer", "--system", "--agent-response"]));
411
515
  let file;
412
516
  let format;
413
517
  let outPath;
@@ -451,7 +555,7 @@ export async function runReportMode(argv) {
451
555
  else if (token === "--format") {
452
556
  const v = argv[++i];
453
557
  if (v !== "md" && v !== "html") {
454
- process.stderr.write(`Error: --format must be "md" or "html", got "${v ?? ""}".\n`);
558
+ process.stderr.write(`Error: --format must be "md" or "html", got "${redactSecrets(v ?? "")}".\n`);
455
559
  return 1;
456
560
  }
457
561
  format = v;
@@ -465,14 +569,14 @@ export async function runReportMode(argv) {
465
569
  outPath = v;
466
570
  }
467
571
  else if (token.startsWith("--")) {
468
- process.stderr.write(`Error: unknown option ${token}\n${REPORT_USAGE}`);
572
+ process.stderr.write(`Error: unknown option ${optionName(token)}\n${REPORT_USAGE}`);
469
573
  return 1;
470
574
  }
471
575
  else if (file === undefined) {
472
576
  file = token;
473
577
  }
474
578
  else {
475
- process.stderr.write(`Error: unexpected argument ${token}\n`);
579
+ process.stderr.write(`Error: unexpected argument ${optionName(token)}\n`);
476
580
  return 1;
477
581
  }
478
582
  }
@@ -529,16 +633,25 @@ export async function runReportMode(argv) {
529
633
  if (dossier)
530
634
  process.stderr.write(shortenedNotice("dossier", licenseVerdict) + "\n");
531
635
  }
636
+ // Die gekürzte Fassung ohne Lizenz nennt weder Auftraggeber noch System: das nicht stillschweigend verwerfen.
637
+ if (!pro && dossier && (customerName !== undefined || systemName !== undefined)) {
638
+ const named = [customerName !== undefined ? "--customer" : null, systemName !== undefined ? "--system" : null].filter(Boolean).join(" und ");
639
+ process.stderr.write(`Hinweis: ${named} ${named.includes(" und ") ? "erscheinen" : "erscheint"} nur im vollen Dossier (Pro-Umfang) und fehl${named.includes(" und ") ? "en" : "t"} in der gekürzten Fassung ohne gültige Lizenz.\n`);
640
+ }
532
641
  const dossierOptions = { customerName, systemName, agentResponse, licenseVerdict, limited: !pro };
533
642
  const text = dossier
534
643
  ? chosen === "html" ? renderDossierHtml(parsed, dossierOptions) : renderDossierMarkdown(parsed, dossierOptions)
535
644
  : chosen === "html" ? renderHtml(parsed, { pro }) : renderMarkdown(parsed, { pro });
536
645
  if (outPath) {
537
646
  if (isReservedFileName(outPath)) {
538
- process.stderr.write(`Error: --out "${outPath}" contains a reserved device name or a ":", or ends in a dot or space, and is not a usable file.
647
+ process.stderr.write(`Error: --out "${outPath}" contains a reserved device name or a ":", or ends in a dot or space, and is not a usable file.
539
648
  `);
540
649
  return 1;
541
650
  }
651
+ if ((await stat(outPath).catch(() => null))?.isDirectory()) {
652
+ process.stderr.write(`Error: --out "${outPath}" is a folder, not a file. Give a file name, for example --out bericht.md.\n`);
653
+ return 1;
654
+ }
542
655
  const real = async (p) => realpath(p).catch(() => resolve(p));
543
656
  if ((await real(outPath)) === (await real(file))) {
544
657
  process.stderr.write(`Error: --out ${outPath} is the report file itself. Writing there would replace the measurement. Choose another file.\n`);
@@ -109,7 +109,47 @@ function firstSentence(description) {
109
109
  const end = text.search(/[.!?](\s|$)/);
110
110
  return (end === -1 ? text : text.slice(0, end + 1)).toLowerCase();
111
111
  }
112
- export function classificationDoubt(tool, cls, source) {
112
+ // 0.11.1 (hint only, the classification rules above and in verb-words.ts stay as they are): verbs that take
113
+ // something from a counter or a pool and so change state, although the tool reads like a getter
114
+ // ("Allocates a fresh invoice number", "each call issues a new code"), plus the "each call ..." phrasing
115
+ // that puts the verb behind a subject.
116
+ const HINT_VERBS = new Set([
117
+ "allocates", "dispenses", "consumes", "issues", "generates", "reserves", "assigns", "claims", "mints",
118
+ "increments", "decrements", "dequeues", "advances", "rotates", "regenerates", "leases", "burns",
119
+ "vergibt", "reserviert", "verbraucht", "erzeugt", "erhöht", "erhoeht",
120
+ ]);
121
+ // The bare form only at the very start ("Allocate a fresh number").
122
+ const BARE_HINT_VERBS = new Set(["allocate", "dispense", "consume", "issue", "generate", "reserve", "assign", "claim", "mint", "increment", "decrement", "dequeue", "lease"]);
123
+ // "Each call issues a new code", "Jeder Aufruf erzeugt ...": the subject names the call, the verb changes state.
124
+ const PER_CALL_WORDS = new Set(["call", "time", "invocation", "request", "use", "run", "aufruf", "mal", "abruf"]);
125
+ const EACH_WORDS = new Set(["each", "every", "jeder", "jeden", "jedes", "bei"]);
126
+ // describesWriting plus the hint-only verbs, for classificationDoubt only. A verb counts at the start of a clause,
127
+ // after a comma or conjunction, or right behind "each call"; negations around it cancel it as in describesWriting.
128
+ export function describesWritingHint(description) {
129
+ const base = describesWriting(description);
130
+ if (base !== null)
131
+ return base;
132
+ const text = description.slice(0, MAX_DESCRIPTION).replace(/\s+/g, " ");
133
+ for (const clause of text.split(/[.;!?]+/)) {
134
+ const tokens = tokenize(clause);
135
+ for (let i = 0; i < tokens.length; i++) {
136
+ const word = tokens[i];
137
+ if (!HINT_VERBS.has(word) && !(i === 0 && BARE_HINT_VERBS.has(word)))
138
+ continue;
139
+ const prev = i > 0 ? tokens[i - 1] : null;
140
+ const afterEachCall = i >= 2 && PER_CALL_WORDS.has(tokens[i - 1]) && EACH_WORDS.has(tokens[i - 2]);
141
+ const inVerbPosition = prev === null || prev === "," || CONJUNCTIONS.has(prev) || afterEachCall;
142
+ if (!inVerbPosition)
143
+ continue;
144
+ const negated = tokens.slice(0, i).some((t) => NEGATIONS.has(t)) ||
145
+ tokens.slice(i + 1, i + 1 + NEGATION_AFTER).some((t) => NEGATIONS.has(t));
146
+ if (!negated)
147
+ return word;
148
+ }
149
+ }
150
+ return null;
151
+ }
152
+ export function classificationDoubtText(tool, cls, source) {
113
153
  const description = tool.description;
114
154
  if (!description)
115
155
  return undefined;
@@ -117,16 +157,33 @@ export function classificationDoubt(tool, cls, source) {
117
157
  if (!nameBased)
118
158
  return undefined;
119
159
  if (cls === "read") {
120
- const verb = describesWriting(description);
160
+ const verb = describesWritingHint(description);
121
161
  return verb
122
- ? `taken for a read by its name, but its description says it "${verb}" — if it writes, duplicates through it stay invisible (set --write-tools or the readOnlyHint annotation)`
162
+ ? {
163
+ en: `taken for a read by its name, but its description says it "${verb}" — if it writes, duplicates through it stay invisible (set --write-tools or the readOnlyHint annotation)`,
164
+ de: `nach dem Namen als Lese-Tool eingeordnet, die Beschreibung sagt aber, dass es «${verb}»: Schreibt es, bleiben Doppelausführungen darüber unsichtbar (mit --write-tools oder der Annotation readOnlyHint festlegen)`,
165
+ }
123
166
  : undefined;
124
167
  }
125
168
  if (cls === "write") {
126
169
  const first = firstSentence(description);
127
170
  if (READ_ONLY_PHRASES.some((p) => first.includes(p))) {
128
- return "taken for a write by its name, but its description says it only reads — calls to it may be treated as writes (set --read-tools or the readOnlyHint annotation)";
171
+ return {
172
+ en: "taken for a write by its name, but its description says it only reads — calls to it may be treated as writes (set --read-tools or the readOnlyHint annotation)",
173
+ de: "nach dem Namen als Schreib-Tool eingeordnet, die Beschreibung sagt aber, dass es nur liest: Aufrufe können als Schreibvorgang gewertet werden (mit --read-tools oder der Annotation readOnlyHint festlegen)",
174
+ };
175
+ }
176
+ // 0.11.1: "update_status_view": "Shows the current status view." The description opens with a read verb and names
177
+ // no write verb anywhere.
178
+ if (descriptionOpensWithReadVerb(description) && describesWritingHint(description) === null) {
179
+ return {
180
+ en: "taken for a write by its name, but its description opens with a read verb and names nothing that is written — calls to it may be treated as writes (set --read-tools or the readOnlyHint annotation)",
181
+ de: "nach dem Namen als Schreib-Tool eingeordnet, die Beschreibung beginnt aber mit einem Lese-Verb und nennt nichts, was geschrieben wird: Aufrufe können als Schreibvorgang gewertet werden (mit --read-tools oder der Annotation readOnlyHint festlegen)",
182
+ };
129
183
  }
130
184
  }
131
185
  return undefined;
132
186
  }
187
+ export function classificationDoubt(tool, cls, source) {
188
+ return classificationDoubtText(tool, cls, source)?.en;
189
+ }