cuonztech-agent-harness 0.7.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/dist/index.js CHANGED
@@ -12,10 +12,11 @@ import { computeReportIntegrity } 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 { isReplaceableReportFile, isReservedFileName, notReplaceableMessage } from "./report-file-guard.js";
15
+ import { archivePreviousReportSync, isReplaceableReportFile, isReservedFileName, notReplaceableMessage } from "./report-file-guard.js";
16
16
  import { runUiMode } from "./ui/server.js";
17
17
  import { runCampaignMode } from "./campaign/runner.js";
18
18
  import { createProxyServer, findToolNameCollisions } from "./proxy/gateway.js";
19
+ import { resolveProGate, reduceSchemaAudit, reasonSentence, FREE_SCOPE_DE, PURCHASE_URL } from "./license/index.js";
19
20
  import { getScenarioById, ALL_SCENARIOS } from "./scenarios/f1-f5.js";
20
21
  import { VERSION } from "./version.js";
21
22
  import { writeFile, mkdir, rename, stat, realpath, unlink } from "node:fs/promises";
@@ -35,13 +36,13 @@ function printHelp() {
35
36
  ` Check the setup before a run (Node version, server\n` +
36
37
  ` start, tool classification, report file). Calls only\n` +
37
38
  ` initialize and tools/list; runs no tool.\n` +
38
- ` cuonztech-agent-harness ui [--port <n>] [--no-open]\n` +
39
+ ` cuonztech-agent-harness ui [--port <n>] [--no-open] (Pro: needs a license)\n` +
39
40
  ` Guided web interface for people without a terminal\n` +
40
41
  ` (local only, 127.0.0.1): demo, setup check, result.\n` +
41
42
  ` cuonztech-agent-harness report <report-file> [--format md|html] [--out <file>]\n` +
42
43
  ` Turn the proxy's --report-file into a plain-language\n` +
43
44
  ` page (German): result, what the agent did, limits.\n` +
44
- ` cuonztech-agent-harness campaign [--runs <n>] [--format md|html] [--out <file>]\n` +
45
+ ` cuonztech-agent-harness campaign [--runs <n>] [--format md|html] [--out <file>] (Pro)\n` +
45
46
  ` Before/after comparison of three fixed reference\n` +
46
47
  ` profiles incl. the two-turn user-pressure scenario\n` +
47
48
  ` (German report; measures no foreign agent).\n` +
@@ -91,6 +92,14 @@ function printHelp() {
91
92
  `Tool classification in proxy mode: explicit flags first, then the upstream's own\n` +
92
93
  `MCP annotations (readOnlyHint/destructiveHint), then name patterns. The result is\n` +
93
94
  `printed to stderr at startup; unclassified tools are passed through but not scored.\n\n` +
95
+ ` --license <key> Licence key (or env AGENT_AUDIT_LICENSE), see "Lizenz" below\n\n` +
96
+ `Lizenz (Schlüssel CAUDIT-..., Prüfung offline):\n` +
97
+ ` Der Lauf selbst ist immer vollständig; Urteil, Score und Exit-Code sind echt.\n` +
98
+ ` Frei: ${FREE_SCOPE_DE}.\n` +
99
+ ` Pro: report --dossier (ohne Lizenz nur Kopf, Score, Urteil), ui, campaign, Schema-Befunde\n` +
100
+ ` und Textbausteine (Härtungsvorschläge) im Report.\n` +
101
+ ` Angabe per --license <key> (proxy, report, ui, campaign) oder Umgebungsvariable AGENT_AUDIT_LICENSE.\n` +
102
+ ` Lizenz erwerben: ${PURCHASE_URL}\n\n` +
94
103
  `The proxy is a long-running server: its exit code says nothing about the verdict.\n` +
95
104
  `Read the verdict from --report-file. Exit code 1 means the run itself broke\n` +
96
105
  `(bad arguments, upstream did not start, report file not writable).\n\n` +
@@ -183,6 +192,21 @@ function parseFlags(argv, spec) {
183
192
  // failed start — including a rejected command line — replaces a report left
184
193
  // over from an earlier run instead of leaving its old PASS in place.
185
194
  let startupReportFile = null;
195
+ let archivedPreviousRun = null;
196
+ let archiveAttempted = false;
197
+ function tryArchivePreviousReport() {
198
+ if (archiveAttempted || !startupReportFile)
199
+ return;
200
+ archiveAttempted = true;
201
+ try {
202
+ archivedPreviousRun = archivePreviousReportSync(startupReportFile);
203
+ }
204
+ catch (err) {
205
+ // Fail closed: could not archive existing real report
206
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
207
+ process.exit(1);
208
+ }
209
+ }
186
210
  // mkdir -p without { recursive: true }: on Linux pseudo file systems
187
211
  // (/proc/…) Node's recursive mkdir never returns and spins at full CPU.
188
212
  // Here every level is created once and the first error ends it.
@@ -219,6 +243,7 @@ function writeStartupStateSync(extra) {
219
243
  if (!startupReportFile)
220
244
  return;
221
245
  try {
246
+ tryArchivePreviousReport();
222
247
  // Only ever a regular file (or nothing yet): writing "the report" over
223
248
  // a directory, a device node or a pipe is never what was meant.
224
249
  const existing = statSync(startupReportFile, { throwIfNoEntry: false });
@@ -231,6 +256,16 @@ function writeStartupStateSync(extra) {
231
256
  harnessVersion: VERSION,
232
257
  certification: "UNVERIFIED_FOR_PRODUCTION",
233
258
  verdict: "NOT_TESTED",
259
+ ...(archivedPreviousRun
260
+ ? {
261
+ previousRun: {
262
+ file: archivedPreviousRun.archivedPath,
263
+ verdict: archivedPreviousRun.verdict,
264
+ callsCount: archivedPreviousRun.callsCount,
265
+ archivedAt: archivedPreviousRun.archivedAt,
266
+ },
267
+ }
268
+ : {}),
234
269
  ...extra,
235
270
  generatedAt: new Date().toISOString(),
236
271
  }, null, 2), "utf-8");
@@ -344,6 +379,7 @@ const PROXY_FLAGS = {
344
379
  "--audit-tools": "single",
345
380
  "--report-file": "single",
346
381
  "--allow-writes": "boolean",
382
+ "--license": "single",
347
383
  };
348
384
  function parseEnvPair(pair, flag, into) {
349
385
  const eq = pair.indexOf("=");
@@ -367,6 +403,8 @@ async function runProxyMode(argv) {
367
403
  fail(parsed.error);
368
404
  const one = (name) => parsed.values.get(name)?.[0];
369
405
  const many = (name) => parsed.values.get(name) ?? [];
406
+ // Lizenz: ändert nie den Lauf, nur den Umfang der geschriebenen Ausgabe (Textbausteine, Schema-Befunde).
407
+ const gate = resolveProGate(one("--license"));
370
408
  const upstreamCommand = one("--upstream-command");
371
409
  if (!upstreamCommand) {
372
410
  fail("proxy mode requires --upstream-command <cmd>\n" +
@@ -485,9 +523,20 @@ async function runProxyMode(argv) {
485
523
  }
486
524
  }
487
525
  const reportTarget = reportFile;
526
+ if (!startupReportFile && reportTarget) {
527
+ startupReportFile = reportTarget;
528
+ tryArchivePreviousReport();
529
+ }
488
530
  const log = (line) => {
489
531
  process.stderr.write(`[cuonztech-agent-harness] ${line}\n`);
490
532
  };
533
+ log(gate.pro
534
+ ? "Lizenz: gültig (Pro-Umfang)."
535
+ : `Keine gültige Lizenz: ${reasonSentence(gate.verdict)} Der Lauf ist vollständig, Urteil, Score und Exit-Code sind echt. ` +
536
+ `Im Report fehlen die Pro-Teile (Textbausteine, Schema-Befunde). Lizenz: ${PURCHASE_URL}`);
537
+ if (archivedPreviousRun) {
538
+ log(`NOTE: An earlier report with ${archivedPreviousRun.callsCount} call(s) was archived to "${archivedPreviousRun.archivedPath}".`);
539
+ }
491
540
  // A tool name comes from the upstream: a line break in it must not be
492
541
  // able to fake further lines of this log.
493
542
  const shown = (name) => name.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, "?");
@@ -507,6 +556,13 @@ async function runProxyMode(argv) {
507
556
  "Only do that against a disposable test/staging system, never production.\n" +
508
557
  "If --upstream-command points at such a system, confirm it by adding --allow-writes.");
509
558
  }
559
+ if (writesReachUpstream) {
560
+ // Wortlaut nach LICENSE Ziffer 4a: der Schalter --allow-writes ist die Bestätigung des Anwenders.
561
+ log("ACHTUNG: Der Harness führt die Schreibvorgänge Ihres Agenten wirklich am Upstream aus und löst " +
562
+ "absichtlich Fehler, Wiederholungen und Verzögerungen aus. Nur gegen ein Testsystem mit " +
563
+ "synthetischen Daten einsetzen, das keine Produktivumgebung ist und von der Aussenwelt " +
564
+ "getrennt ist (LICENSE Ziffer 4a). Mit --allow-writes bestätigen Sie das.");
565
+ }
510
566
  if (scenarioId === "F1") {
511
567
  log("WARNING: scenario F1 really executes the agent's first write upstream and then " +
512
568
  "reports a timeout to the agent. Only point --upstream-command at a disposable " +
@@ -652,8 +708,10 @@ async function runProxyMode(argv) {
652
708
  try {
653
709
  const report = generateReport(interceptor.getSession());
654
710
  const score = computeScore([report]);
655
- const patches = generatePatches([report], score);
711
+ const patches = gate.pro ? generatePatches([report], score) : []; // ohne Lizenz nicht erzeugt und nicht geschrieben
656
712
  const callForensics = buildCallForensics(interceptor.getSession());
713
+ // Ohne Lizenz: Schema-Gutachten nur mit Zusammenfassung und Risikostufe, ohne Textbausteine.
714
+ const reportedSchemaAudit = gate.pro ? schemaAudit : reduceSchemaAudit(schemaAudit);
657
715
  const body = JSON.stringify({
658
716
  harnessVersion: VERSION,
659
717
  // Constant: a self-test, never a certificate.
@@ -662,15 +720,35 @@ async function runProxyMode(argv) {
662
720
  verdict: report.verdict,
663
721
  report,
664
722
  score,
665
- patches,
723
+ ...(gate.pro
724
+ ? { patches }
725
+ : {
726
+ limitedByLicense: true,
727
+ licenseNotice: "Keine gültige Lizenz: Textbausteine (Härtungsvorschläge) und Schema-Befunde gehören zum Pro-Umfang und fehlen hier. " +
728
+ `Lauf, Urteil, Score und Exit-Code sind echt. Frei bleiben: ${FREE_SCOPE_DE}. Lizenz: ${PURCHASE_URL}`,
729
+ }),
730
+ ...(archivedPreviousRun
731
+ ? {
732
+ previousRun: {
733
+ file: archivedPreviousRun.archivedPath,
734
+ verdict: archivedPreviousRun.verdict,
735
+ callsCount: archivedPreviousRun.callsCount,
736
+ archivedAt: archivedPreviousRun.archivedAt,
737
+ },
738
+ }
739
+ : {}),
666
740
  // Deutscher Prompt-Baustein und Schema-Gutachten (neu in 0.7.0, optional für Leser).
667
- recommended_prompt_patch_de: generatePromptPatchDe([report], classification.filter((c) => c.class === "write").map((c) => c.name), classification.filter((c) => c.class === "read").map((c) => c.name)),
668
- schema_audit: schemaAudit,
741
+ ...(gate.pro
742
+ ? {
743
+ recommended_prompt_patch_de: generatePromptPatchDe([report], classification.filter((c) => c.class === "write").map((c) => c.name), classification.filter((c) => c.class === "read").map((c) => c.name)),
744
+ }
745
+ : {}),
746
+ schema_audit: reportedSchemaAudit,
669
747
  call_forensics: callForensics,
670
748
  integrity: computeReportIntegrity({
671
749
  harnessVersion: VERSION,
672
750
  report,
673
- schema_audit: schemaAudit,
751
+ schema_audit: reportedSchemaAudit,
674
752
  call_forensics: callForensics,
675
753
  }),
676
754
  toolClassification: classification,
@@ -771,7 +849,7 @@ async function runProxyMode(argv) {
771
849
  process.stdin.on("end", shutdown);
772
850
  process.stdin.on("close", shutdown);
773
851
  startupReportFile = null;
774
- const server = createProxyServer(interceptor, { auditTools });
852
+ const server = createProxyServer(interceptor, { auditTools, patches: gate.pro });
775
853
  // The SDK closes the transport on its own when a single message exceeds
776
854
  // its buffer (about 10 MB). Without this the proxy stayed alive, deaf,
777
855
  // with its upstream child still running.
@@ -0,0 +1,57 @@
1
+ import crypto from "node:crypto";
2
+ import { LICENSE_PUBLIC_KEY } from "./publicKey.js";
3
+ const PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
4
+ const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
5
+ function rawKey(b64, what) {
6
+ const raw = Buffer.from(b64, "base64");
7
+ if (raw.length !== 32)
8
+ throw new Error(`${what}: erwartet 32 Byte (base64), erhalten ${raw.length}`);
9
+ return raw;
10
+ }
11
+ export function generateLicenseKeyPair() {
12
+ const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
13
+ return {
14
+ seed: privateKey.export({ format: "der", type: "pkcs8" }).subarray(-32).toString("base64"),
15
+ publicKey: publicKey.export({ format: "der", type: "spki" }).subarray(-32).toString("base64"),
16
+ };
17
+ }
18
+ export function signEd25519(domain, payload, seedB64) {
19
+ const key = crypto.createPrivateKey({
20
+ key: Buffer.concat([PKCS8_PREFIX, rawKey(seedB64, "LICENSE_SIGNING_SEED")]),
21
+ format: "der",
22
+ type: "pkcs8",
23
+ });
24
+ return crypto.sign(null, Buffer.from(`${domain}.${payload}`), key).toString("base64url");
25
+ }
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);
29
+ }
30
+ let warnedKeyOverride = false;
31
+ 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;
43
+ }
44
+ export function verifyEd25519(domain, payload, sigB64url, publicKeyB64 = defaultPublicKey()) {
45
+ try {
46
+ const key = crypto.createPublicKey({
47
+ key: Buffer.concat([SPKI_PREFIX, rawKey(publicKeyB64, "Public Key")]),
48
+ format: "der",
49
+ type: "spki",
50
+ });
51
+ const sig = Buffer.from(sigB64url, "base64url");
52
+ return crypto.verify(null, Buffer.from(`${domain}.${payload}`), key, sig);
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
@@ -0,0 +1,91 @@
1
+ // Lizenz-Sperre nach dem Muster von CPCG (Privacy Chaos Gate): Der Lauf selbst
2
+ // (Proxy, Szenarien F1 bis F5, Score, PASS/FAIL, Exit-Code) ist nie davon
3
+ // abhängig. Ohne gültige Lizenz wird nur die AUSGABE gekürzt oder ein Pro-Befehl
4
+ // mit einem klaren Hinweis nicht ausgeführt. Die Prüfung der Lizenz selbst
5
+ // (Ed25519, offline) liegt in verifier.ts.
6
+ import { resolveLicense } from "./verifier.js";
7
+ export const PURCHASE_URL = "https://cuonztech.ch/agent-harness.html#audit";
8
+ // Alte Adresse aus früheren Texten des Verifizierers; die Website leitet weiter.
9
+ const LEGACY_URL = "https://cuonztech.ch/agent-audit";
10
+ // Zuordnung frei/Pro. Dieselbe Tabelle steht in der README (Abschnitt «Lizenz und Umfang»).
11
+ export const PRO_FEATURES = {
12
+ dossier: {
13
+ name: "Das Prüf-Dossier (report --dossier)",
14
+ command: "report --dossier",
15
+ mode: "gekürzt",
16
+ free: "Das Dossier enthält nur Kopf, Score und das Urteil je Szenario",
17
+ },
18
+ ui: {
19
+ name: "Die geführte Web-Oberfläche (ui)",
20
+ command: "ui",
21
+ mode: "gesperrt",
22
+ free: "Der Weg über die Kommandozeile (proxy, doctor, report)",
23
+ },
24
+ campaign: {
25
+ name: "Der Kampagnen-Runner (campaign)",
26
+ command: "campaign",
27
+ mode: "gesperrt",
28
+ free: "Der Selbsttest (benchmark) und jeder einzelne Proxy-Lauf",
29
+ },
30
+ "schema-audit": {
31
+ name: "Das Schema-Gutachten (Befunde je Tool und Schema-Vorschläge)",
32
+ command: "proxy --report-file, report",
33
+ mode: "gekürzt",
34
+ free: "Die Zusammenfassung und die Risikostufe je Tool (auch in doctor)",
35
+ },
36
+ hardening: {
37
+ name: "Der Textbaustein- und Prompt-Patch-Generator (Härtungsvorschläge)",
38
+ command: "proxy --report-file, report, cuonztech__get_score",
39
+ mode: "gekürzt",
40
+ free: "Der Score, das Urteil und die Beobachtungen",
41
+ },
42
+ };
43
+ export const FREE_SCOPE_DE = "MCP-Chaos-Proxy mit den Szenarien F1 bis F5, doctor, Kompaktbericht, JSON-Report (--report-file), Resilience Score und PASS/FAIL samt Exit-Code";
44
+ /** Löst die Lizenz auf (--license, sonst Umgebungsvariablen). Ohne gültige Lizenz ist `pro` false. */
45
+ export function resolveProGate(licenseKey) {
46
+ const verdict = resolveLicense(licenseKey);
47
+ return { pro: verdict.valid === true, verdict };
48
+ }
49
+ /** Der Grund in einem Satz, ohne Kauf-Link (der steht immer am Ende des Hinweises). */
50
+ export function reasonSentence(verdict) {
51
+ if (verdict.valid)
52
+ return "Die Lizenz ist gültig.";
53
+ const raw = (verdict.reason ?? "").trim();
54
+ const none = raw === "" || raw === "Nicht lizenziert" || raw.startsWith("Kein Lizenzschlüssel");
55
+ if (none)
56
+ return "Es ist keine Lizenz hinterlegt.";
57
+ const core = raw
58
+ .replace(LEGACY_URL, "")
59
+ .replace(/\s*(Lizenzen erhältlich unter|Verlängerung unter)\s*$/i, "")
60
+ .trim()
61
+ .replace(/\.$/, "");
62
+ return `Ihre Lizenz ist nicht gültig: ${core}.`;
63
+ }
64
+ /**
65
+ * Hinweis für einen Befehl, der ohne Lizenz nicht läuft (`ui`, `campaign`).
66
+ * Form: «Keine gültige Lizenz: <Funktion> gehört zum Pro-Umfang. Frei bleiben …. Lizenz: <URL>»
67
+ */
68
+ export function lockedNotice(feature, verdict) {
69
+ const f = PRO_FEATURES[feature];
70
+ return (`Keine gültige Lizenz: ${f.name} gehört zum Pro-Umfang. ${reasonSentence(verdict)}\n` +
71
+ `Frei bleiben: ${FREE_SCOPE_DE}.\n` +
72
+ `Der Befehl wurde nicht ausgeführt. Lizenz: ${PURCHASE_URL}\n` +
73
+ `Aktivierung: --license <KEY> oder Umgebungsvariable AGENT_AUDIT_LICENSE.\n`);
74
+ }
75
+ /**
76
+ * Hinweis für eine gekürzte Ausgabe (Dossier, Schema-Gutachten, Härtungsvorschläge).
77
+ * Der Lauf selbst, das Urteil und der Exit-Code sind davon unberührt.
78
+ */
79
+ export function shortenedNotice(feature, verdict) {
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. ` +
83
+ `Frei bleiben: ${FREE_SCOPE_DE}. Lizenz: ${PURCHASE_URL}`);
84
+ }
85
+ /** Schlankere Fassung des Schema-Gutachtens ohne Lizenz: Zusammenfassung und Risikostufe, keine Befunde, kein Schema-Vorschlag. */
86
+ export function reduceSchemaAudit(audit) {
87
+ return {
88
+ ...audit,
89
+ tools: audit.tools.map((t) => ({ ...t, findingsDe: [], recommendedSchemaPatch: null })),
90
+ };
91
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./publicKey.js";
2
+ export * from "./ed25519.js";
3
+ export * from "./types.js";
4
+ export * from "./verifier.js";
5
+ export * from "./gate.js";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Öffentlicher Ed25519-Schlüssel des Cuonztech-Lizenzdienstes (32 Byte, base64).
3
+ * Der zugehörige private Seed liegt ausschliesslich serverseitig (config.php) und im KeePass.
4
+ */
5
+ export const LICENSE_PUBLIC_KEY = "OddVMIYDwPSNJSwZiglu4dNLswB+PhWwZi1WZ/XXklE=";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,89 @@
1
+ import { verifyEd25519, signEd25519, defaultPublicKey } from "./ed25519.js";
2
+ function b64url(buf) {
3
+ return Buffer.from(buf).toString("base64url");
4
+ }
5
+ export function signLicense(claims, seed, domain = "caudit-license-v1") {
6
+ // Der Harness gibt nur Codes mit Präfix CAUDIT- aus; `domain` dient den Tests, um falsch signierte Codes zu bauen.
7
+ const payload = b64url(JSON.stringify(claims));
8
+ const sig = signEd25519(domain, payload, seed);
9
+ return `CAUDIT-${payload}.${sig}`;
10
+ }
11
+ export function verifyLicense(token, publicKey = defaultPublicKey()) {
12
+ if (!token || typeof token !== "string" || token.trim() === "") {
13
+ return {
14
+ valid: false,
15
+ reason: "Kein Lizenzschlüssel angegeben. Lizenzen erhältlich unter https://cuonztech.ch/agent-audit",
16
+ };
17
+ }
18
+ const clean = token.trim();
19
+ // Nur CAUDIT-Codes mit ihrer eigenen Signaturdomäne. Schlüssel anderer Produkte (zum Beispiel CPCG-)
20
+ // berechtigen nicht zum Pro-Umfang und werden hier nicht akzeptiert.
21
+ const prefix = "CAUDIT-";
22
+ const domain = "caudit-license-v1";
23
+ if (!clean.startsWith(prefix)) {
24
+ const hint = clean.startsWith("CPCG-")
25
+ ? "Ein CPCG-Schlüssel (Privacy Chaos Gate) berechtigt nicht zum Pro-Umfang des Agent Harness."
26
+ : "Ungültiges Lizenzformat. Ein Lizenzschlüssel für den Agent Harness beginnt mit 'CAUDIT-'.";
27
+ return { valid: false, reason: hint };
28
+ }
29
+ const body = clean.slice(prefix.length);
30
+ const dot = body.lastIndexOf(".");
31
+ if (dot <= 0) {
32
+ return { valid: false, reason: "Lizenzsignatur fehlt oder ist beschädigt." };
33
+ }
34
+ const payload = body.slice(0, dot);
35
+ const sig = body.slice(dot + 1);
36
+ let claims;
37
+ try {
38
+ const json = Buffer.from(payload, "base64url").toString("utf-8");
39
+ claims = JSON.parse(json);
40
+ }
41
+ catch {
42
+ return { valid: false, reason: "Lizenzdaten konnten nicht decodiert werden." };
43
+ }
44
+ // Signature verification (Ed25519)
45
+ const isSigValid = verifyEd25519(domain, payload, sig, publicKey);
46
+ if (!isSigValid) {
47
+ return { valid: false, reason: "Kryptografische Signatur der Lizenz ist ungültig." };
48
+ }
49
+ if (!claims || typeof claims !== "object") {
50
+ return { valid: false, reason: "Lizenzdaten konnten nicht decodiert werden." };
51
+ }
52
+ // Ablauf (Unix-Sekunden): `exp` ist zwingend. Ein Code ohne Ablaufdatum ist ungültig.
53
+ const nowSec = Math.floor(Date.now() / 1000);
54
+ if (typeof claims.exp !== "number" || !Number.isFinite(claims.exp)) {
55
+ return {
56
+ valid: false,
57
+ reason: "Lizenz ist ungültig: Ablaufdatum fehlt.",
58
+ };
59
+ }
60
+ if (claims.exp < nowSec) {
61
+ const expiredDate = new Date(claims.exp * 1000).toISOString().slice(0, 10);
62
+ return {
63
+ valid: false,
64
+ claims,
65
+ tier: claims.tier,
66
+ reason: `Lizenz ist am ${expiredDate} abgelaufen. Verlängerung unter https://cuonztech.ch/agent-audit`,
67
+ };
68
+ }
69
+ return {
70
+ valid: true,
71
+ claims,
72
+ tier: claims.tier || "pro",
73
+ };
74
+ }
75
+ export function resolveLicense(explicitKey) {
76
+ const token = explicitKey ||
77
+ process.env.AGENT_AUDIT_LICENSE ||
78
+ process.env.CUONZTECH_LICENSE_KEY;
79
+ if (token) {
80
+ return verifyLicense(token);
81
+ }
82
+ return {
83
+ valid: false,
84
+ reason: "Nicht lizenziert",
85
+ };
86
+ }
87
+ export const PURCHASE_HINT = "CuonzTech Agent Audit Pro (Prüf-Dossier, geführte Oberfläche, Schema-Gutachten, Härtungsvorschläge, Kampagnen-Runner)" + "\n" +
88
+ "Lizenz: https://cuonztech.ch/agent-harness.html#audit" + "\n" +
89
+ "Aktivierung: --license <KEY> oder Umgebungsvariable AGENT_AUDIT_LICENSE=<KEY>";
@@ -2,22 +2,32 @@
2
2
  // ein Mensch ohne Fachwissen lesen kann: Ergebnis, was getestet wurde, was der
3
3
  // Agent getan hat, was zu tun ist und was dieser Test NICHT aussagt.
4
4
  import { readFile, writeFile, stat, realpath } from "node:fs/promises";
5
- import { extname, resolve } from "node:path";
5
+ import { basename, extname, resolve } from "node:path";
6
6
  import { evaluateAgentClaim } from "./core/claim-check.js";
7
7
  import { verifyReportIntegrity } from "./core/artifact-integrity.js";
8
8
  import { isReservedFileName } from "./report-file-guard.js";
9
9
  import { renderDossierHtml, renderDossierMarkdown } from "./dossier-report.js";
10
+ import { resolveProGate, shortenedNotice, PURCHASE_URL } from "./license/index.js";
10
11
  const RISK_DE = { HIGH: "HOCH", MEDIUM: "MITTEL", LOW: "NIEDRIG" };
11
12
  // Tool-Namen kommen vom Upstream: Zeilenumbrüche und Backticks dürfen die
12
13
  // Struktur des Berichts nicht verändern.
13
14
  function safeName(name) {
14
15
  return name.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029`]/g, "?");
15
16
  }
16
- function schemaSection(audit) {
17
+ function schemaSection(audit, pro) {
17
18
  const lines = ["## Schnittstellen- & Schema-Prüfung (MCP-Tools)", "", audit.summaryDe, ""];
18
19
  const writeLike = audit.tools.filter((t) => t.classification !== "read");
19
20
  if (writeLike.length === 0)
20
21
  return lines;
22
+ if (!pro) {
23
+ // Ohne Lizenz: Zusammenfassung und Risikostufe je Tool; Befunde und Schema-Vorschläge gehören zum Pro-Umfang.
24
+ const order = ["HIGH", "MEDIUM", "LOW"];
25
+ for (const t of [...writeLike].sort((a, b) => order.indexOf(a.riskLevel) - order.indexOf(b.riskLevel))) {
26
+ lines.push(`- ${safeName(t.name)}: Risiko ${RISK_DE[t.riskLevel]}`);
27
+ }
28
+ lines.push("", `Die Befunde je Tool und die kopierfertigen Schema-Vorschläge gehören zum Pro-Umfang (Lizenz: ${PURCHASE_URL}).`, "");
29
+ return lines;
30
+ }
21
31
  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.", "");
22
32
  const order = ["HIGH", "MEDIUM", "LOW"];
23
33
  for (const t of [...writeLike].sort((a, b) => order.indexOf(a.riskLevel) - order.indexOf(b.riskLevel))) {
@@ -83,8 +93,13 @@ export const LIMITS = [
83
93
  ];
84
94
  // Ist die Antwort des Agenten geprüft worden, darf der erste Punkt nicht mehr behaupten, sie werde nicht geprüft.
85
95
  export const LIMIT_ANSWER_CHECKED = "Der Harness sieht von sich aus nur die Tool-Aufrufe; die übergebene Antwort des Agenten wurde oben nur mit festen Regeln gegen die Tool-Aufrufe abgeglichen.";
86
- export function limitsFor(claim) {
87
- return claim && claim.status !== "NOT_PROVIDED" ? [LIMIT_ANSWER_CHECKED, ...LIMITS.slice(1)] : [...LIMITS];
96
+ export const LIMIT_SPLIT_RUN = "Der Lauf folgte auf einen früheren Lauf (archiviert). Durch den Neustart des Prüfstands gilt dieses Ergebnis nur für die Aufrufe seit dem Neustart; Aufrufe vor dem Neustart sind im Archiv gesichert.";
97
+ export function limitsFor(claim, previousRun) {
98
+ const base = claim && claim.status !== "NOT_PROVIDED" ? [LIMIT_ANSWER_CHECKED, ...LIMITS.slice(1)] : [...LIMITS];
99
+ if (previousRun) {
100
+ base.push(LIMIT_SPLIT_RUN);
101
+ }
102
+ return base;
88
103
  }
89
104
  export function verdictText(file) {
90
105
  const report = file.report;
@@ -156,7 +171,8 @@ function claimSection(claim) {
156
171
  lines.push("");
157
172
  return lines;
158
173
  }
159
- export function renderMarkdown(file) {
174
+ export function renderMarkdown(file, options = {}) {
175
+ const pro = options.pro ?? true;
160
176
  const { headline, explanation } = verdictText(file);
161
177
  const report = file.report;
162
178
  const lines = ["# Ergebnis des Agent-Tests", "", `## ${headline}`, "", explanation, ""];
@@ -170,6 +186,9 @@ export function renderMarkdown(file) {
170
186
  const scenario = report.scenarioId ? `${report.scenarioId}: ${SCENARIOS[report.scenarioId] ?? ""}` : "Kein Szenario (reines Durchreichen).";
171
187
  lines.push(`- Szenario: ${scenario}`);
172
188
  lines.push(`- Aufrufe des Agenten: ${report.totalCalls} (davon ${report.coverage.writeCalls} schreibend, ${report.coverage.readCalls} lesend)`);
189
+ if (file.previousRun) {
190
+ 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)}\`).`);
191
+ }
173
192
  lines.push(`- Eingespielte Fehler: ${report.injectedErrors}`);
174
193
  if (report.coverage.scenarioTriggered === false) {
175
194
  lines.push("- **Das Szenario wurde nie ausgelöst.** Es gab keinen Schreibvorgang, auf den es wirken konnte.");
@@ -201,7 +220,10 @@ export function renderMarkdown(file) {
201
220
  lines.push("Die Kennzahl fasst nur diesen einen Lauf zusammen; sie ist kein Vergleichswert zwischen Agenten.", "");
202
221
  }
203
222
  const hasPatches = Boolean(file.patches && file.patches.length > 0);
204
- if (hasPatches || file.recommended_prompt_patch_de) {
223
+ if (!pro) {
224
+ lines.push("## Härtungsvorschläge", "", `Textbausteine für den Prompt des Agenten (Deutsch und Englisch) gehören zum Pro-Umfang. Das Urteil, der Score und die Beobachtungen sind vollständig. Lizenz: ${PURCHASE_URL}`, "");
225
+ }
226
+ else if (hasPatches || file.recommended_prompt_patch_de) {
205
227
  lines.push(hasPatches ? "## Was zu tun ist" : "## Empfohlener Prompt-Baustein", "");
206
228
  if (file.recommended_prompt_patch_de) {
207
229
  lines.push(hasPatches ? "### Textbaustein für den Prompt des Agenten (Deutsch)" : "### Textbaustein für den Prompt des Agenten (Deutsch, vorbeugend)", "", "```text", file.recommended_prompt_patch_de, "```", "");
@@ -210,7 +232,7 @@ export function renderMarkdown(file) {
210
232
  lines.push(`### ${patch.category} (${patch.severity})`, "", patch.trigger_de ?? patch.trigger, "", "Textbaustein für den Prompt des Agenten (Englisch, Originalfassung):", "", "```text", patch.promptSnippet, "```", "");
211
233
  }
212
234
  }
213
- const observations = report.observations_de ?? report.observations.map((o) => `Aufruf ${o.callNumber}, ${o.tool}: ${o.description}`);
235
+ const observations = report.observations_de ?? (report.observations ?? []).map((o) => `Aufruf ${o.callNumber}, ${o.tool}: ${o.description}`);
214
236
  if (observations.length > 0) {
215
237
  lines.push("## Beobachtungen", "");
216
238
  for (const text of observations)
@@ -223,12 +245,12 @@ export function renderMarkdown(file) {
223
245
  if (file.claim_check && file.claim_check.status !== "NOT_PROVIDED")
224
246
  lines.push(...claimSection(file.claim_check));
225
247
  if (file.schema_audit)
226
- lines.push(...schemaSection(file.schema_audit));
248
+ lines.push(...schemaSection(file.schema_audit, pro));
227
249
  if (file.integrity) {
228
250
  lines.push(`Integrität: ${verifyReportIntegrity(file).reasonDe} Prüfsumme (SHA-256): \`${safeName(String(file.integrity.payloadSha256))}\``, "");
229
251
  }
230
252
  lines.push("## Was dieser Test nicht aussagt", "");
231
- for (const limit of limitsFor(file.claim_check))
253
+ for (const limit of limitsFor(file.claim_check, file.previousRun))
232
254
  lines.push(`- ${limit}`);
233
255
  lines.push("");
234
256
  const meta = [file.harnessVersion ? `Harness ${file.harnessVersion}` : null, file.generatedAt ? `erstellt ${file.generatedAt}` : null].filter(Boolean);
@@ -241,8 +263,8 @@ function escapeHtml(text) {
241
263
  }
242
264
  // Rendert das Markdown oben in ein eigenständiges HTML. Bewusst klein gehalten:
243
265
  // nur die Konstrukte, die renderMarkdown erzeugt.
244
- export function renderHtmlBody(file) {
245
- const markdown = renderMarkdown(file);
266
+ export function renderHtmlBody(file, options = {}) {
267
+ const markdown = renderMarkdown(file, options);
246
268
  const out = [];
247
269
  let inCode = false;
248
270
  let inList = false;
@@ -300,8 +322,8 @@ export function renderHtmlBody(file) {
300
322
  closeList();
301
323
  return out;
302
324
  }
303
- export function renderHtml(file) {
304
- const out = renderHtmlBody(file);
325
+ export function renderHtml(file, options = {}) {
326
+ const out = renderHtmlBody(file, options);
305
327
  const verdict = file.verdict ?? "NOT_TESTED";
306
328
  const colour = verdict === "PASS" ? "#1a7f37" : verdict === "FAIL" ? "#cf222e" : "#9a6700";
307
329
  return [
@@ -343,12 +365,15 @@ export async function runReportMode(argv) {
343
365
  " --format md (default) or html\n" +
344
366
  " --out write to this file instead of the terminal. An existing file is only\n" +
345
367
  " replaced if an earlier `report` run wrote it, never the report file itself.\n" +
346
- " --dossier render the full test and architecture dossier (6 sections) instead of the short page\n" +
368
+ " --dossier render the test and architecture dossier (6 sections) instead of the short page.\n" +
369
+ " Without a valid license it is shortened to header, score and verdict per scenario (Pro scope).\n" +
347
370
  " --customer <name> client / environment named in the dossier (needs --dossier)\n" +
348
371
  " --system <name> system under test named in the dossier (needs --dossier)\n" +
349
372
  " --agent-response <text>\n" +
350
373
  " the agent's final answer to the user; it is checked against what the\n" +
351
- " target system actually did (honesty check)\n");
374
+ " target system actually did (honesty check)\n" +
375
+ " --license <key> CuonzTech Agent Audit Pro license key (or AGENT_AUDIT_LICENSE env var). Without one, the short\n" +
376
+ " page leaves out the hardening suggestions and schema findings; verdict and score stay complete.\n");
352
377
  return 0;
353
378
  }
354
379
  let file;
@@ -358,11 +383,20 @@ export async function runReportMode(argv) {
358
383
  let customerName;
359
384
  let systemName;
360
385
  let agentResponse;
386
+ let licenseKey;
361
387
  for (let i = 0; i < argv.length; i++) {
362
388
  const token = argv[i];
363
389
  if (token === "--dossier") {
364
390
  dossier = true;
365
391
  }
392
+ else if (token === "--license") {
393
+ const v = argv[++i];
394
+ if (v === undefined || v.startsWith("--") || v.trim() === "") {
395
+ process.stderr.write(`Error: --license needs a key.\n${REPORT_USAGE}`);
396
+ return 1;
397
+ }
398
+ licenseKey = v;
399
+ }
366
400
  else if (token === "--customer" || token === "--system") {
367
401
  const v = argv[++i];
368
402
  if (v === undefined || v.startsWith("--") || v.trim() === "") {
@@ -456,10 +490,17 @@ export async function runReportMode(argv) {
456
490
  return 1;
457
491
  }
458
492
  const chosen = format ?? (outExt === ".html" || outExt === ".htm" ? "html" : "md");
459
- const dossierOptions = { customerName, systemName, agentResponse };
493
+ const { pro, verdict: licenseVerdict } = resolveProGate(licenseKey);
494
+ // Ohne Lizenz wird nur die Ausgabe gekürzt (Dossier: Kopf, Score, Urteil; Bericht: ohne Härtungsvorschläge
495
+ // und Schema-Befunde). Der Hinweis geht auf stderr, damit er nie in der Berichtsdatei oder einer Pipe landet.
496
+ if (!pro && !process.env.AGENT_AUDIT_SUPPRESS_LICENSE_HINT) {
497
+ if (dossier)
498
+ process.stderr.write(shortenedNotice("dossier", licenseVerdict) + "\n");
499
+ }
500
+ const dossierOptions = { customerName, systemName, agentResponse, licenseVerdict, limited: !pro };
460
501
  const text = dossier
461
502
  ? chosen === "html" ? renderDossierHtml(parsed, dossierOptions) : renderDossierMarkdown(parsed, dossierOptions)
462
- : chosen === "html" ? renderHtml(parsed) : renderMarkdown(parsed);
503
+ : chosen === "html" ? renderHtml(parsed, { pro }) : renderMarkdown(parsed, { pro });
463
504
  if (outPath) {
464
505
  if (isReservedFileName(outPath)) {
465
506
  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.