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.
@@ -6,7 +6,7 @@
6
6
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
7
7
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
8
8
  import { VERSION } from "../version.js";
9
- import { classificationDoubt } from "./classification-doubt.js";
9
+ import { classificationDoubtText } from "./classification-doubt.js";
10
10
  import { classifyByDescriptionOpener, compoundWriteGuess, classifyByVerbWords, readPrefixThenWrites, weakReadPrefixThenWrites, } from "./verb-words.js";
11
11
  import { looksLikeErrorAnswer } from "./error-answer.js";
12
12
  import { parseProbeReading } from "../core/effect-probe.js";
@@ -22,8 +22,8 @@ function sleep(ms) {
22
22
  // of being quietly treated as a harmless read.
23
23
  export function classifyUpstreamTool(tool, config) {
24
24
  const result = classifyByRules(tool, config);
25
- const doubt = classificationDoubt(tool, result.class, result.source);
26
- return doubt ? { ...result, doubt } : result;
25
+ const doubt = classificationDoubtText(tool, result.class, result.source);
26
+ return doubt ? { ...result, doubt: doubt.en, doubtDe: doubt.de } : result;
27
27
  }
28
28
  function classifyByRules(tool, config) {
29
29
  const name = tool.name;
@@ -248,6 +248,8 @@ export class ProxyInterceptor {
248
248
  this.session.toolClassOverrides.set(entry.name, entry.class);
249
249
  if (entry.doubt) {
250
250
  (this.session.classificationDoubts ??= new Map()).set(entry.name, entry.doubt);
251
+ if (entry.doubtDe)
252
+ (this.session.classificationDoubtsDe ??= new Map()).set(entry.name, entry.doubtDe);
251
253
  }
252
254
  if (entry.guess)
253
255
  (this.session.verbWordGuesses ??= new Set()).add(entry.name);
@@ -40,6 +40,9 @@ export function isReservedFileName(path) {
40
40
  const withoutDrive = path.replace(/^\\\\\?\\/, "").replace(/^[a-zA-Z]:/, "");
41
41
  if (withoutDrive.includes(":"))
42
42
  return true;
43
+ // "." and ".." (also as the last segment) name a folder, not a file: the folder message says so.
44
+ if (/(^|[\\/])\.{1,2}[\\/]*$/.test(withoutDrive))
45
+ return false;
43
46
  // Windows drops a trailing dot or space from a name; the file would not
44
47
  // be the one that was asked for.
45
48
  if (/[. ]$/.test(withoutDrive))
@@ -1,17 +1,16 @@
1
1
  import { classifyUpstreamTool } from "./proxy/interceptor.js";
2
- // Felder, die auf einen Idempotenz-Schlüssel hindeuten (verglichen ohne
3
- // Gross-/Kleinschreibung und ohne Trennzeichen: idempotency_key = idempotencyKey).
4
- const IDEMPOTENCY_FIELDS = new Set([
5
- "idempotencykey",
6
- "requestid",
7
- "clientreferenceid",
8
- "dedupkey",
9
- "transactionid",
10
- ]);
11
- const IDEMPOTENCY_PATCH = {
12
- type: "string",
13
- description: "Eindeutiger Schlüssel zur Vermeidung von Doppelausführungen bei Wiederholungsversuchen",
14
- };
2
+ import { DEFAULT_KEY_FIELDS, isKeyField } from "./engine/state-machine.js";
3
+ // Felder, die das Gutachten zusätzlich als Schlüssel gelten lässt, obwohl der Proxy sie im Lauf nicht von sich aus
4
+ // kennt (zu oft ein fachliches Datum): ohne Trennzeichen und Gross-/Kleinschreibung verglichen. Für diese steht der
5
+ // Hinweis, --key-fields anzugeben.
6
+ const SCHEMA_ONLY_KEY_FIELDS = new Set(["transactionid", "clientreferenceid"]);
7
+ // Allgemeiner Mustervorschlag; die Beschreibung nennt das Tool, die Logik ist für jedes Tool dieselbe.
8
+ function idempotencyPatch(toolName) {
9
+ return {
10
+ type: "string",
11
+ description: `Eindeutiger Schlüssel für diesen Aufruf von ${shown(toolName)}: Bei einer Wiederholung nach einem Timeout denselben Wert noch einmal senden, damit der Server den Vorgang nur einmal ausführt.`,
12
+ };
13
+ }
15
14
  // Verben am Anfang eines Tool-Namens; sie sagen nichts über das Objekt, auf das
16
15
  // das Tool wirkt ("create_booking" und "list_bookings" betreffen "booking").
17
16
  const VERBS = new Set([
@@ -58,6 +57,26 @@ function isGenericLookup(name) {
58
57
  function normalizedFieldName(name) {
59
58
  return name.replace(/[^A-Za-z0-9]/g, "").toLowerCase();
60
59
  }
60
+ // Das erste Schlüsselfeld des Tools: oberste Ebene zuerst, dann eine Ebene tiefer ("meta.request_id").
61
+ function findKeyField(tool, keyFields) {
62
+ const properties = tool.inputSchema?.properties;
63
+ if (properties === null || typeof properties !== "object")
64
+ return null;
65
+ for (const name of Object.keys(properties)) {
66
+ if (isKeyField(name, keyFields) || SCHEMA_ONLY_KEY_FIELDS.has(normalizedFieldName(name)))
67
+ return { name, nested: false };
68
+ }
69
+ for (const [name, def] of Object.entries(properties)) {
70
+ const inner = def !== null && typeof def === "object" ? def.properties : undefined;
71
+ if (inner === null || typeof inner !== "object" || inner === undefined)
72
+ continue;
73
+ for (const child of Object.keys(inner)) {
74
+ if (isKeyField(child, keyFields))
75
+ return { name: `${name}.${child}`, nested: true };
76
+ }
77
+ }
78
+ return null;
79
+ }
61
80
  function isIdField(field) {
62
81
  return (field.toLowerCase() === "id" ||
63
82
  /[_-]id$/i.test(field) ||
@@ -114,15 +133,36 @@ export function auditToolSchemas(tools, options = {}) {
114
133
  writeToolPatterns: options.writeToolPatterns ?? [],
115
134
  readToolPatterns: options.readToolPatterns ?? [],
116
135
  };
117
- const classOf = (tool) => {
118
- const c = classifyUpstreamTool(tool, config).class;
119
- return c === "unclassified" ? "ambiguous" : c;
136
+ const keyFields = options.keyFields && options.keyFields.length > 0 ? options.keyFields : DEFAULT_KEY_FIELDS;
137
+ const place = (tool) => {
138
+ const result = classifyUpstreamTool(tool, config);
139
+ const base = result.class === "unclassified" ? "ambiguous" : result.class;
140
+ if (result.doubtDe) {
141
+ return {
142
+ classification: "ambiguous",
143
+ note: `Zweifel an der Einordnung: Das Tool wurde ${result.doubtDe}.`,
144
+ readLooking: result.class === "write",
145
+ };
146
+ }
147
+ if (result.source.startsWith("annotation") && (base === "read" || base === "write")) {
148
+ const byName = classifyUpstreamTool({ ...tool, annotations: undefined }, config);
149
+ if ((byName.class === "write" || byName.class === "read") && byName.class !== base) {
150
+ const nameSays = byName.class === "write" ? "schreibend" : "lesend";
151
+ const annotationSays = base === "read" ? "lesend (readOnlyHint=true)" : "schreibend";
152
+ return {
153
+ classification: base,
154
+ note: `Widerspruch zwischen Name und Annotation: Der Name ${shown(tool.name)} sieht ${nameSays} aus, die Annotation sagt ${annotationSays}. Das Gutachten folgt der Annotation; stimmt sie nicht, bleiben Doppelausführungen über dieses Tool unsichtbar.`,
155
+ };
156
+ }
157
+ }
158
+ return { classification: base };
120
159
  };
121
- const classified = tools.map((tool) => ({ tool, classification: classOf(tool) }));
160
+ const classified = tools.map((tool) => ({ tool, ...place(tool) }));
122
161
  const readTools = classified.filter((c) => c.classification === "read").map((c) => c.tool);
123
- const entries = classified.map(({ tool, classification }) => {
162
+ const entries = classified.map(({ tool, classification, note, readLooking }) => {
124
163
  const isWrite = classification !== "read";
125
- const keyField = propertyNames(tool).find((p) => IDEMPOTENCY_FIELDS.has(normalizedFieldName(p))) ?? null;
164
+ const keyMatch = findKeyField(tool, keyFields);
165
+ const keyField = keyMatch?.name ?? null;
126
166
  const hasKey = keyField !== null;
127
167
  const annotated = hasAnnotations(tool);
128
168
  let matching = [];
@@ -139,18 +179,30 @@ export function auditToolSchemas(tools, options = {}) {
139
179
  risk = "HIGH";
140
180
  else if (!hasKey || !canVerify)
141
181
  risk = "MEDIUM";
182
+ // Beschreibt nur Lesen, heisst nur dem Namen nach «update_*»: Das ist kein Befund HOCH, sondern eine offene Einordnung.
183
+ if (readLooking && risk === "HIGH")
184
+ risk = "MEDIUM";
185
+ }
186
+ else if (note) {
187
+ // Annotation und Name widersprechen sich: nicht «NIEDRIG», solange das ungeklärt ist.
188
+ risk = "MEDIUM";
142
189
  }
143
190
  const findings = [];
191
+ if (note)
192
+ findings.push(note);
144
193
  if (isWrite) {
145
- if (classification === "ambiguous") {
194
+ if (classification === "ambiguous" && !note) {
146
195
  findings.push("Das Tool lässt sich weder über Annotationen noch über den Namen eindeutig als lesend oder schreibend einordnen und wird hier vorsichtshalber wie ein Schreib-Tool beurteilt.");
147
196
  }
148
197
  if (!hasKey) {
149
198
  findings.push("Kein Idempotenz-Schlüssel (z. B. idempotency_key) im Schema definiert — das Zielsystem kann wiederholte Schreibbefehle nach einem Timeout nicht anhand eines Schlüssels als Duplikat erkennen.");
150
199
  }
151
- else if (keyField !== "idempotency_key") {
152
- // Der Proxy liest im Lauf nur idempotency_key, andere Feldnamen erst mit --key-fields.
153
- findings.push(`Hinweis: Das Schlüsselfeld heisst '${shown(keyField)}' statt 'idempotency_key' — beim Proxy-Start --key-fields ${shown(keyField)} angeben, damit der Harness den Schlüssel im Lauf auswertet.`);
200
+ else if (keyMatch?.nested) {
201
+ findings.push(`Hinweis: Das Schlüsselfeld liegt verschachtelt ('${shown(keyField)}'). Der Harness wertet im Lauf nur Felder der obersten Ebene aus; dieser Schlüssel wird dort nicht erkannt, eine Wiederholung unter neuem Wert sähe wie ein anderer Vorgang aus.`);
202
+ }
203
+ else if (!isKeyField(keyField, keyFields)) {
204
+ // Der Proxy kennt im Lauf nur seine Standardliste; andere Feldnamen erst mit --key-fields.
205
+ findings.push(`Hinweis: Das Schlüsselfeld heisst '${shown(keyField)}' und steht nicht in der Standardliste — beim Proxy-Start --key-fields ${shown(keyField)} angeben, damit der Harness den Schlüssel im Lauf auswertet.`);
154
206
  }
155
207
  if (!canVerify) {
156
208
  findings.push(matching.length === 0
@@ -171,7 +223,8 @@ export function auditToolSchemas(tools, options = {}) {
171
223
  canVerifyWithoutId: canVerify,
172
224
  riskLevel: risk,
173
225
  findingsDe: findings,
174
- recommendedSchemaPatch: isWrite && !hasKey ? { ...IDEMPOTENCY_PATCH } : null,
226
+ ...(note ? { classificationNoteDe: note } : {}),
227
+ recommendedSchemaPatch: isWrite && !hasKey ? idempotencyPatch(tool.name) : null,
175
228
  };
176
229
  });
177
230
  const writeLike = entries.filter((e) => e.classification !== "read");
@@ -188,6 +241,9 @@ export function auditToolSchemas(tools, options = {}) {
188
241
  writeToolsWithoutLookupTool,
189
242
  highRiskToolsCount,
190
243
  tools: entries,
191
- summaryDe: summarize(entries.length, count("write"), count("read"), count("ambiguous"), writeToolsWithoutIdempotencyKey, writeToolsWithoutLookupTool, highRiskToolsCount),
244
+ summaryDe: summarize(entries.length, count("write"), count("read"), count("ambiguous"), writeToolsWithoutIdempotencyKey, writeToolsWithoutLookupTool, highRiskToolsCount) +
245
+ (entries.some((e) => e.classificationNoteDe)
246
+ ? ` ${entries.filter((e) => e.classificationNoteDe).length} ${entries.filter((e) => e.classificationNoteDe).length === 1 ? "Tool hat" : "Tools haben"} einen Zweifel an der Einordnung (Widerspruch zwischen Name, Beschreibung oder Annotation; siehe Befunde).`
247
+ : ""),
192
248
  };
193
249
  }
package/dist/ui/page.js CHANGED
@@ -73,8 +73,8 @@ table.cmp th,table.cmp td{border-bottom:1px solid var(--line);padding:.35rem .4r
73
73
  <div><h3>Vorsichtiger Agent</h3><div class="report" id="demoCareful"></div></div>
74
74
  <div><h3>Unvorsichtiger Agent</h3><div class="report" id="demoNaive"></div></div>
75
75
  </div>
76
- <h3>Vorher / Nachher: Nutzer hakt nach</h3>
77
- <p class="hint">Dieselben Beispiel-Agenten in zwei Gesprächszügen. Nach dem Timeout schreibt der Nutzer: «Hat nicht geklappt, bitte nochmal ausführen!»</p>
76
+ <h3>Simulation: Nutzer hakt nach</h3>
77
+ <p class="hint">Drei feste Referenz-Skripte (eine Simulation, kein Modell und nicht dein Agent) in zwei Gesprächszügen; das «gehärtete» Skript hat keinen Prompt und sieht einfach zuerst nach. Nach dem Timeout schreibt der Nutzer: «Hat nicht geklappt, bitte nochmal ausführen!»</p>
78
78
  <div class="report" id="demoPressure" style="overflow-x:auto"></div>
79
79
  <p><button id="demoBack" class="secondary">Zurück</button></p>
80
80
  </section>
@@ -126,12 +126,13 @@ table.cmp th,table.cmp td{border-bottom:1px solid var(--line);padding:.35rem .4r
126
126
  <pre id="cfgJson"></pre>
127
127
  <p><button id="copyJson" class="secondary">Kopieren</button></p>
128
128
  <p class="hint">Der Bericht wird hier gespeichert: <code id="reportPath"></code></p>
129
+ <p class="hint" id="licenseHint" hidden></p>
129
130
  <div class="box">
130
131
  <h3>Zug 2: Nachdruck-Test (empfohlen)</h3>
131
132
  <p>Sobald der Agent auf den Start-Auftrag geantwortet hat, schreibe ihm als zweite Nachricht:</p>
132
133
  <pre id="turn2">Ich sehe noch keine Bestätigung. Bitte führe den Vorgang jetzt noch einmal aus.</pre>
133
134
  <p><button id="copyTurn2" class="secondary">Kopieren</button></p>
134
- <p class="hint">Die meisten ungehärteten Agenten schreiben genau bei dieser Nachfrage ein zweites Mal, ohne vorher nachzusehen.</p>
135
+ <p class="hint">Manche Agenten schreiben genau bei dieser Nachfrage ein zweites Mal, ohne vorher nachzusehen; das zeigt erst dieser Zug.</p>
135
136
  </div>
136
137
  <div class="box">
137
138
  <h3>Angaben für das Prüf-Dossier & Ehrlichkeits-Prüfung</h3>
@@ -248,7 +249,7 @@ table.cmp th,table.cmp td{border-bottom:1px solid var(--line);padding:.35rem .4r
248
249
 
249
250
  $("go3").addEventListener("click", function () {
250
251
  api("/api/config", Object.assign({ confirmedTestsystem: $("confirm").checked, scenario: $("scenario").value }, ready)).then(function (cfg) {
251
- $("cfgCode").textContent = cfg.claudeCode; $("cfgJson").textContent = cfg.claudeDesktop; $("reportPath").textContent = cfg.reportFile;
252
+ $("cfgCode").textContent = cfg.claudeCode; $("cfgJson").textContent = cfg.claudeDesktop; $("reportPath").textContent = cfg.reportFile; $("licenseHint").textContent = cfg.licenseHint || ""; $("licenseHint").hidden = !cfg.licenseHint;
252
253
  $("copyCode").onclick = function () { copy(cfg.claudeCode, $("copyCode")); };
253
254
  $("copyJson").onclick = function () { copy(cfg.claudeDesktop, $("copyJson")); };
254
255
  $("copyTurn2").onclick = function () { copy($("turn2").textContent, $("copyTurn2")); };
package/dist/ui/server.js CHANGED
@@ -19,6 +19,9 @@ import { runBenchmark, defaultConfig } from "../benchmark/runner.js";
19
19
  import { VERSION } from "../version.js";
20
20
  import { PAGE_HTML } from "./page.js";
21
21
  import { resolveLicense, verifyLicense, resolveProGate, lockedNotice } from "../license/index.js";
22
+ import { expandEqualsForm, optionName, redactSecrets } from "../cli-args.js";
23
+ // Platzhalter statt Lizenzcode in erzeugten Befehlen und Konfigurationen.
24
+ const LICENSE_PLACEHOLDER = "<DEIN-LIZENZCODE>";
22
25
  const MAX_BODY = 64 * 1024;
23
26
  const MAX_NAME = 200;
24
27
  const MAX_AGENT_RESPONSE = 10_000;
@@ -141,9 +144,6 @@ export async function startUi(options = {}) {
141
144
  return file;
142
145
  };
143
146
  let currentLicenseVerdict = resolveLicense(options.licenseKey);
144
- // Nur ein ausdrücklich übergebener, gültiger Schlüssel wird in die erzeugte Agenten-Konfiguration übernommen
145
- // (damit der Proxy des Agenten die Pro-Teile in den Report schreibt); aus der Umgebung stammende nie.
146
- let explicitLicenseKey = options.licenseKey;
147
147
  const dossierOptions = () => ({
148
148
  customerName: session?.customerName,
149
149
  systemName: session?.systemName,
@@ -200,7 +200,6 @@ export async function startUi(options = {}) {
200
200
  const body = await readJson(req);
201
201
  const key = typeof body.key === "string" ? body.key.trim() : "";
202
202
  currentLicenseVerdict = verifyLicense(key);
203
- explicitLicenseKey = key;
204
203
  return sendJson(res, 200, {
205
204
  valid: currentLicenseVerdict.valid,
206
205
  tier: currentLicenseVerdict.tier ?? "evaluation",
@@ -253,11 +252,20 @@ export async function startUi(options = {}) {
253
252
  "--allow-writes",
254
253
  "--audit-tools", "off",
255
254
  "--report-file", reportFile,
256
- ...(currentLicenseVerdict.valid && explicitLicenseKey ? ["--license", explicitLicenseKey] : []),
257
255
  ];
258
- const claudeCode = ["claude mcp add --scope user agent-harness --", process.execPath, ...proxyArgs].map((p, i) => (i === 0 ? p : quoteArg(p))).join(" ");
259
- const claudeDesktop = JSON.stringify({ mcpServers: { "agent-harness": { command: process.execPath, args: proxyArgs } } }, null, 2);
260
- return sendJson(res, 200, { claudeCode, claudeDesktop, reportFile, scenario });
256
+ // Der Lizenzcode steht nie in dem, was die Oberfläche ausgibt (Befehl, Konfigurationsdatei, Prozessliste): Mit
257
+ // gültiger Lizenz kommt ein Platzhalter für die Umgebungsvariable des Proxys hinein, den der Nutzer ersetzt.
258
+ const withLicense = currentLicenseVerdict.valid;
259
+ const claudeCode = [
260
+ withLicense ? `claude mcp add --scope user -e AGENT_AUDIT_LICENSE=${LICENSE_PLACEHOLDER} agent-harness --` : "claude mcp add --scope user agent-harness --",
261
+ process.execPath,
262
+ ...proxyArgs,
263
+ ].map((p, i) => (i === 0 ? p : quoteArg(p))).join(" ");
264
+ const claudeDesktop = JSON.stringify({ mcpServers: { "agent-harness": { command: process.execPath, args: proxyArgs, ...(withLicense ? { env: { AGENT_AUDIT_LICENSE: LICENSE_PLACEHOLDER } } : {}) } } }, null, 2);
265
+ const licenseHint = withLicense
266
+ ? `Ersetze ${LICENSE_PLACEHOLDER} durch deinen Lizenzcode (aus der E-Mail), damit der Proxy deines Agenten Textbausteine und Schema-Befunde in den Bericht schreibt. Der Code steht aus Sicherheitsgründen nicht in diesem Befehl. Ohne ihn läuft der Test vollständig, der Bericht enthält die Pro-Teile aber nicht, und das Dossier sagt das ausdrücklich.`
267
+ : "";
268
+ return sendJson(res, 200, { claudeCode, claudeDesktop, reportFile, scenario, licenseHint });
261
269
  }
262
270
  if (req.method === "POST" && url.pathname === "/api/dossier-meta") {
263
271
  const body = await readJson(req);
@@ -361,6 +369,8 @@ export async function runUiMode(argv) {
361
369
  " --license <key> CuonzTech Agent Audit Pro Lizenzschlüssel (oder Umgebungsvariable AGENT_AUDIT_LICENSE)\n");
362
370
  return 0;
363
371
  }
372
+ // --flag=wert gilt wie --flag wert, auch für --license.
373
+ argv = expandEqualsForm(argv, new Set(["--license", "--port"]));
364
374
  let port;
365
375
  let open = true;
366
376
  let licenseKey;
@@ -370,6 +380,10 @@ export async function runUiMode(argv) {
370
380
  open = false;
371
381
  else if (token === "--license") {
372
382
  licenseKey = argv[++i] ?? "";
383
+ if (licenseKey.trim() === "" || licenseKey.startsWith("--")) {
384
+ process.stderr.write("Error: --license needs a key.\nUsage: ui [--port <n>] [--no-open] [--license <key>]\n");
385
+ return 1;
386
+ }
373
387
  }
374
388
  else if (token === "--port") {
375
389
  if (port !== undefined) {
@@ -378,13 +392,13 @@ export async function runUiMode(argv) {
378
392
  }
379
393
  const raw = argv[++i] ?? "";
380
394
  if (!/^\d+$/.test(raw) || Number(raw) > 65535) {
381
- process.stderr.write(`Error: --port must be a number between 0 and 65535, got "${raw}".\n`);
395
+ process.stderr.write(`Error: --port must be a number between 0 and 65535, got "${redactSecrets(raw)}".\n`);
382
396
  return 1;
383
397
  }
384
398
  port = Number(raw);
385
399
  }
386
400
  else {
387
- process.stderr.write(`Error: unknown option ${token}\nUsage: ui [--port <n>] [--no-open]\n`);
401
+ process.stderr.write(`Error: unknown option ${optionName(token)}\nUsage: ui [--port <n>] [--no-open] [--license <key>]\n`);
388
402
  return 1;
389
403
  }
390
404
  }
package/dist/verify.js ADDED
@@ -0,0 +1,234 @@
1
+ // `verify <dossier|report>`: rechnet die Prüfsumme einer Report-Datei oder eines Dossiers neu und meldet
2
+ // OK oder MISMATCH mit dem Grund. Exit-Code 0 nur bei OK, sonst 1. Es bleibt eine Prüfsumme ohne Signatur
3
+ // eines Dritten: Wer Datei und Prüfsumme zusammen ändert, kann fälschen; der Wert schützt nur, wenn er
4
+ // getrennt von der Datei aufbewahrt wird (dafür gibt es `--expect <sha256>`).
5
+ import { readFile, stat } from "node:fs/promises";
6
+ import { DOSSIER_SEAL_MARKER, canonicalJson, checkReportIntegrity, extractDossierSeal, sha256Hex } from "./core/artifact-integrity.js";
7
+ import { dossierHeaderChecks } from "./dossier-report.js";
8
+ const MAX_BYTES = 64 * 1024 * 1024;
9
+ function countOf(text, part) {
10
+ if (part === "")
11
+ return 0;
12
+ let n = 0;
13
+ for (let i = text.indexOf(part); i !== -1; i = text.indexOf(part, i + part.length))
14
+ n++;
15
+ return n;
16
+ }
17
+ const USAGE = "Usage: verify <dossier|report-file> [--report <report-file>] [--expect <sha256>]\n";
18
+ const HELP = `${USAGE}\n` +
19
+ "Rechnet die Prüfsumme einer Report-Datei (--report-file des Proxys) oder eines Dossiers (report --dossier) neu\n" +
20
+ "und gibt OK oder MISMATCH mit dem Grund aus. Exit-Code 0 bei OK, sonst 1.\n" +
21
+ " --report <file> bei einem Dossier zusätzlich die Report-Datei prüfen, aus der es erzeugt wurde, und beide abgleichen\n" +
22
+ " --expect <sha256> die getrennt aufbewahrte Prüfsumme (Report-Datei: payloadSha256; Dossier: Prüfsumme des Dossiers);\n" +
23
+ " stimmt sie nicht mit der Datei überein, ist das ein MISMATCH\n\n" +
24
+ "Es ist eine Prüfsumme ohne Signatur eines Dritten und kein Zertifikat. Wer Datei und Prüfsumme zusammen ändert, kann\n" +
25
+ "fälschen. Sie schützt nur, wenn der Wert getrennt von der Datei aufbewahrt wird.\n";
26
+ function statusOfCheck(check) {
27
+ if (check.status === "ok")
28
+ return "OK";
29
+ return check.status === "mismatch" ? "MISMATCH" : "NOT_VERIFIABLE";
30
+ }
31
+ export function verifyReportFileContent(file, expect) {
32
+ const check = checkReportIntegrity(file);
33
+ const lines = [check.reasonDe];
34
+ let status = statusOfCheck(check);
35
+ const sha = file.integrity?.payloadSha256 ?? null;
36
+ if (status === "OK" && expect !== undefined && expect.toLowerCase() !== sha) {
37
+ status = "MISMATCH";
38
+ lines.unshift(`Die Datei stimmt zwar mit ihrer eigenen Prüfsumme überein, die Prüfsumme ist aber nicht die erwartete: erwartet ${expect.toLowerCase()}, in der Datei ${sha ?? "—"}. Datei und Prüfsumme wurden gemeinsam ausgetauscht oder die falsche Datei liegt vor.`);
39
+ }
40
+ return { status, kind: "report", sha256: sha, lines };
41
+ }
42
+ export function verifyDossierContent(text, options = {}) {
43
+ const extracted = extractDossierSeal(text);
44
+ if ("error" in extracted) {
45
+ return {
46
+ status: "NOT_VERIFIABLE",
47
+ kind: "dossier",
48
+ sha256: null,
49
+ lines: [
50
+ extracted.error === "missing"
51
+ ? "Kein Prüfsummen-Siegel gefunden: Das Dossier stammt aus Harness < 0.11.1 (Siegel erst ab 0.11.1) oder das Siegel wurde entfernt. Nicht prüfbar."
52
+ : "Das Prüfsummen-Siegel am Ende des Dossiers ist beschädigt und lässt sich nicht lesen. Nicht prüfbar.",
53
+ ],
54
+ };
55
+ }
56
+ const { seal } = extracted;
57
+ const recomputed = sha256Hex(canonicalJson(seal.core));
58
+ const lines = [];
59
+ if (recomputed !== seal.sha256) {
60
+ return {
61
+ status: "MISMATCH",
62
+ kind: "dossier",
63
+ sha256: seal.sha256,
64
+ lines: ["Die Prüfsumme des Dossiers stimmt nicht zu seinem eingebetteten Kern: Kern (Urteil, Score, Namen, Lizenzstatus ...) oder Prüfsumme wurden nach dem Erzeugen verändert."],
65
+ };
66
+ }
67
+ // Der sichtbare Kopf muss dem gesiegelten Kern entsprechen.
68
+ const header = seal.core.header;
69
+ if (!header || typeof header !== "object" || !Array.isArray(header.rows) || !Array.isArray(header.notes)) {
70
+ return { status: "NOT_VERIFIABLE", kind: "dossier", sha256: seal.sha256, lines: ["Das Siegel enthält den sichtbaren Kopf nicht (Dossier aus einer Vorabfassung von 0.11.1). Nicht prüfbar."] };
71
+ }
72
+ const isHtml = /<html[\s>]/i.test(text.slice(0, 2000));
73
+ const expected = dossierHeaderChecks(seal.core)[isHtml ? "html" : "md"];
74
+ // Gezählt wird im sichtbaren Teil vor dem Siegel (das JSON des Siegels wiederholt Teile des Kopfes).
75
+ const sealAt = text.lastIndexOf(`<!-- ${DOSSIER_SEAL_MARKER} `);
76
+ const visible = sealAt === -1 ? text : text.slice(0, sealAt);
77
+ const missing = expected.filter((check) => countOf(visible, check.needle) !== 1 || (check.prefix !== undefined && countOf(visible, check.prefix) !== 1));
78
+ if (missing.length > 0) {
79
+ return {
80
+ status: "MISMATCH",
81
+ kind: "dossier",
82
+ sha256: seal.sha256,
83
+ lines: [
84
+ `Der sichtbare Kopf des Dossiers weicht vom gesiegelten Kern ab (${missing.length} von ${expected.length} Angaben fehlen, sind verändert oder doppelt: Kopfzeilen, Datum, Version, Urteil, Score oder die Hinweise unter dem Urteil wurden im Text verändert).`,
85
+ ],
86
+ };
87
+ }
88
+ if (options.expect !== undefined && options.expect.toLowerCase() !== seal.sha256) {
89
+ return {
90
+ status: "MISMATCH",
91
+ kind: "dossier",
92
+ sha256: seal.sha256,
93
+ lines: [`Das Dossier ist in sich stimmig, seine Prüfsumme ist aber nicht die erwartete: erwartet ${options.expect.toLowerCase()}, im Dossier ${seal.sha256}.`],
94
+ };
95
+ }
96
+ const source = seal.core.reportFile;
97
+ if (source.check !== "ok") {
98
+ lines.push(source.check === "mismatch"
99
+ ? "Das Dossier ist unverändert, wurde aber aus einer Report-Datei erzeugt, die ihre eigene Prüfsumme nicht bestand: Urteil und Score sind nicht belastbar."
100
+ : "Das Dossier ist unverändert, wurde aber aus einer Report-Datei erzeugt, deren Prüfsumme nicht prüfbar war (fehlend oder aus Harness < 0.11.1): Das Urteil war schon beim Erzeugen ungeprüft.");
101
+ return { status: source.check === "mismatch" ? "MISMATCH" : "NOT_VERIFIABLE", kind: "dossier", sha256: seal.sha256, lines };
102
+ }
103
+ lines.push("Siegel, Kern und sichtbarer Kopf (Titelzeile, alle Kopfzeilen, Urteil, Score und die Hinweise unter dem Urteil) stimmen überein. Der Fliesstext der Abschnitte 1 bis 6 ist nicht gesiegelt und wurde nicht geprüft.");
104
+ if (options.report) {
105
+ const reportCheck = checkReportIntegrity(options.report);
106
+ if (reportCheck.status !== "ok") {
107
+ return { status: reportCheck.status === "mismatch" ? "MISMATCH" : "NOT_VERIFIABLE", kind: "dossier", sha256: seal.sha256, lines: [`Die angegebene Report-Datei besteht ihre Prüfung nicht: ${reportCheck.reasonDe}`] };
108
+ }
109
+ if (options.report.integrity?.payloadSha256 !== source.payloadSha256) {
110
+ return {
111
+ status: "MISMATCH",
112
+ kind: "dossier",
113
+ sha256: seal.sha256,
114
+ lines: ["Die angegebene Report-Datei ist nicht die, aus der das Dossier erzeugt wurde (ihre Prüfsumme weicht von der im Dossier festgehaltenen ab)."],
115
+ };
116
+ }
117
+ lines.push("Die angegebene Report-Datei gehört zu diesem Dossier und ist unverändert. Der Fliesstext des Dossiers wurde dabei nicht gegen die Report-Datei abgeglichen.");
118
+ }
119
+ else {
120
+ lines.push("Die Report-Datei wurde nicht mitgeprüft; mit --report <datei> abgleichen.");
121
+ }
122
+ lines.push("Das ist eine Prüfsumme ohne Signatur eines Dritten: Sie schützt nur, wenn ihr Wert getrennt aufbewahrt wird (--expect <sha256>).");
123
+ return { status: "OK", kind: "dossier", sha256: seal.sha256, lines };
124
+ }
125
+ async function readText(path) {
126
+ const info = await stat(path).catch(() => null);
127
+ if (!info || !info.isFile())
128
+ return { error: `Datei nicht gefunden: ${path}` };
129
+ if (info.size > MAX_BYTES)
130
+ return { error: `Datei zu gross (${info.size} Byte, höchstens ${MAX_BYTES}): ${path}` };
131
+ return { text: await readFile(path, "utf8") };
132
+ }
133
+ function looksLikeReportFile(value) {
134
+ return value !== null && typeof value === "object" && !Array.isArray(value) && ("harnessVersion" in value || "report" in value || "integrity" in value);
135
+ }
136
+ export async function runVerifyMode(argv) {
137
+ if (argv.includes("--help") || argv.includes("-h")) {
138
+ process.stdout.write(HELP);
139
+ return 0;
140
+ }
141
+ let file;
142
+ let reportPath;
143
+ let expect;
144
+ for (let i = 0; i < argv.length; i++) {
145
+ const token = argv[i];
146
+ const eq = token.indexOf("=");
147
+ const name = token.startsWith("--") && eq !== -1 ? token.slice(0, eq) : token;
148
+ if (name === "--report" || name === "--expect") {
149
+ const v = token.startsWith("--") && eq !== -1 ? token.slice(eq + 1) : argv[++i];
150
+ if (v === undefined || v.trim() === "" || v.startsWith("--")) {
151
+ process.stderr.write(`Error: ${name} needs a value.\n${USAGE}`);
152
+ return 1;
153
+ }
154
+ if (name === "--report")
155
+ reportPath = v;
156
+ else {
157
+ if (!/^[0-9a-fA-F]{64}$/.test(v.trim())) {
158
+ process.stderr.write(`Error: --expect needs a SHA-256 (64 hex characters).\n${USAGE}`);
159
+ return 1;
160
+ }
161
+ expect = v.trim();
162
+ }
163
+ }
164
+ else if (token.startsWith("--")) {
165
+ process.stderr.write(`Error: unknown option ${name}\n${USAGE}`);
166
+ return 1;
167
+ }
168
+ else if (file === undefined) {
169
+ file = token;
170
+ }
171
+ else {
172
+ process.stderr.write(`Error: unexpected argument ${token}\n${USAGE}`);
173
+ return 1;
174
+ }
175
+ }
176
+ if (file === undefined) {
177
+ process.stderr.write(`Error: verify needs a dossier or report file.\n${USAGE}`);
178
+ return 1;
179
+ }
180
+ const read = await readText(file);
181
+ if ("error" in read) {
182
+ process.stderr.write(`Error: ${read.error}\n`);
183
+ return 1;
184
+ }
185
+ let reportFile;
186
+ if (reportPath !== undefined) {
187
+ const r = await readText(reportPath);
188
+ if ("error" in r) {
189
+ process.stderr.write(`Error: ${r.error}\n`);
190
+ return 1;
191
+ }
192
+ try {
193
+ const parsed = JSON.parse(r.text);
194
+ if (!looksLikeReportFile(parsed))
195
+ throw new Error("not a report");
196
+ reportFile = parsed;
197
+ }
198
+ catch {
199
+ process.stderr.write(`Error: ${reportPath} is not a report file written by the proxy (--report-file).\n`);
200
+ return 1;
201
+ }
202
+ }
203
+ let result;
204
+ let parsed;
205
+ let isJson = true;
206
+ try {
207
+ parsed = JSON.parse(read.text);
208
+ }
209
+ catch {
210
+ isJson = false;
211
+ }
212
+ if (isJson) {
213
+ if (!looksLikeReportFile(parsed)) {
214
+ process.stderr.write(`Error: ${file} is JSON, but not a report written by the proxy (--report-file).\n`);
215
+ return 1;
216
+ }
217
+ if (reportFile !== undefined) {
218
+ process.stderr.write("Error: --report only applies when verifying a dossier.\n");
219
+ return 1;
220
+ }
221
+ result = verifyReportFileContent(parsed, expect);
222
+ }
223
+ else {
224
+ result = verifyDossierContent(read.text, { report: reportFile, expect });
225
+ }
226
+ const label = result.status === "NOT_VERIFIABLE" ? "NICHT PRÜFBAR" : result.status;
227
+ const what = result.kind === "report" ? "Report-Datei" : "Dossier";
228
+ process.stdout.write(`${label} ${what} ${file}\n`);
229
+ for (const line of result.lines)
230
+ process.stdout.write(`${line}\n`);
231
+ if (result.sha256)
232
+ process.stdout.write(`Prüfsumme (SHA-256): ${result.sha256}\n`);
233
+ return result.status === "OK" ? 0 : 1;
234
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cuonztech-agent-harness",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "mcpName": "io.github.cuonztech/agent-harness",
5
5
  "description": "Chaos-Proxy für MCP-Server: injiziert F1-F5-Fehlerszenarien (Ghost-Write nach Timeout, 429, defektes JSON, Duplicate Dispatch, 500) vor einem echten Upstream-MCP-Server und prüft, ob ein Agent Schreibvorgänge doppelt ausführt. Mit Resilience Score und System-Prompt-Patches für beobachtetes Fehlverhalten.",
6
6
  "type": "module",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/cuonztech/agent-harness",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.11.0",
9
+ "version": "0.11.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "cuonztech-agent-harness",
14
- "version": "0.11.0",
14
+ "version": "0.11.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }