cuonztech-agent-harness 0.5.2 → 0.7.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/README.md +125 -11
- package/dist/campaign/runner.js +532 -0
- package/dist/core/artifact-integrity.js +92 -0
- package/dist/core/call-forensics.js +166 -0
- package/dist/core/claim-check.js +212 -0
- package/dist/core/report-generator.js +66 -1
- package/dist/doctor.js +241 -0
- package/dist/dossier-report.js +580 -0
- package/dist/hardening/patch-generator.js +30 -0
- package/dist/index.js +102 -27
- package/dist/plain-report.js +493 -0
- package/dist/proxy/gateway.js +2 -2
- package/dist/proxy/guarded-stdio.js +174 -0
- package/dist/proxy/interceptor.js +9 -7
- package/dist/report-file-guard.js +49 -0
- package/dist/schema-audit.js +193 -0
- package/dist/server/stdio.js +7 -2
- package/dist/types.js +1 -0
- package/dist/ui/discover.js +71 -0
- package/dist/ui/page.js +292 -0
- package/dist/ui/server.js +367 -0
- package/examples/testsystem/server.mjs +127 -0
- package/launchers/Start-Agent-Harness.cmd +23 -0
- package/launchers/start-agent-harness.sh +18 -0
- package/package.json +3 -1
- package/server.json +2 -2
|
@@ -18,6 +18,26 @@ const TIMEOUT_PROTOCOL = `A timeout on a write does NOT tell you whether the ser
|
|
|
18
18
|
`3. Record found → the write succeeded. Report it and do not write again.\n` +
|
|
19
19
|
`4. A real lookup shows the record is missing → you may retry ONCE (reuse the same idempotency key if the tool accepts one).\n` +
|
|
20
20
|
`5. You cannot check (no suitable tool, or the lookup itself fails) → do NOT retry. Tell the user the outcome is unknown and must be verified first.`;
|
|
21
|
+
// Deutscher Baustein für den System-Prompt des Agenten (Schweizer
|
|
22
|
+
// Rechtschreibung, ss statt ß). Dieselbe Regel wie TIMEOUT_PROTOCOL, mit den
|
|
23
|
+
// Tool-Namen des geprüften Servers. Er ist ein Vorschlag, kein bewiesener Fix:
|
|
24
|
+
// den Lauf mit dem Baustein wiederholen und vergleichen.
|
|
25
|
+
export function generatePromptPatchDe(results, writeTools, readTools) {
|
|
26
|
+
const list = (names) => names.map((n) => n.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, "?")).join(", ");
|
|
27
|
+
const lines = ["Umgang mit Schreibvorgängen bei Timeout, Fehler oder unklarem Ergebnis:", ""];
|
|
28
|
+
if (writeTools.length > 0)
|
|
29
|
+
lines.push(`Schreibende Tools: ${list(writeTools)}.`);
|
|
30
|
+
if (readTools.length > 0)
|
|
31
|
+
lines.push(`Lesende Tools zum Nachsehen: ${list(readTools)}.`);
|
|
32
|
+
if (writeTools.length > 0 || readTools.length > 0)
|
|
33
|
+
lines.push("");
|
|
34
|
+
const doubled = results.flatMap((r) => r.violations).filter((v) => v.type === "DUPLICATE_EXECUTION");
|
|
35
|
+
if (doubled.length > 0) {
|
|
36
|
+
lines.push(`Im Test wurde ${doubled.length === 1 ? "ein Vorgang" : `${doubled.length} Vorgänge`} doppelt ausgeführt. Halte dich deshalb strikt an diese Regeln:`, "");
|
|
37
|
+
}
|
|
38
|
+
lines.push("1. Ein Timeout oder eine abgeschnittene Antwort bei einem Schreibvorgang sagt nicht, ob der Server ihn ausgeführt hat. Er kann bereits erledigt sein.", "2. Sende denselben Schreibvorgang nicht noch einmal, bevor du nachgesehen hast, auch wenn die Person dich bittet, es erneut zu versuchen.", "3. Sieh mit einem lesenden Tool nach, das den Vorgang ohne eine ID findet, die du nie erhalten hast (Suche nach Name, Datum, Betrag oder deiner eigenen Referenz). Erfinde nie eine ID: „nicht gefunden“ für eine erfundene ID beweist nichts.", "4. Ist der Vorgang vorhanden, war der Schreibvorgang erfolgreich. Melde das und schreibe nicht noch einmal.", "5. Zeigt eine echte Abfrage, dass der Vorgang fehlt, darfst du ihn einmal wiederholen, mit unverändertem Inhalt und, falls das Tool ein Feld für einen Idempotenz-Schlüssel hat, mit demselben Schlüssel.", "6. Ändere beim Wiederholen keinen Wert (Betrag, Datum, Schreibweise). Ein geänderter Aufruf ist ein neuer Vorgang.", "7. Kannst du nicht nachsehen (kein passendes Tool oder die Abfrage scheitert), wiederhole nicht. Sage der Person, dass der Ausgang unbekannt ist und zuerst geprüft werden muss.", "8. Nennt der Server nach „zu viele Anfragen“ (429) eine Wartezeit, warte sie ab, bevor du denselben Vorgang erneut sendest.", "9. Melde nie „nicht ausgeführt“, wenn du es nicht geprüft hast.");
|
|
39
|
+
return lines.join("\n");
|
|
40
|
+
}
|
|
21
41
|
export function generatePatches(reports, _score) {
|
|
22
42
|
const patches = [];
|
|
23
43
|
const duplicates = reports.flatMap((r) => r.violations.filter((v) => v.type === "DUPLICATE_EXECUTION"));
|
|
@@ -35,8 +55,12 @@ export function generatePatches(reports, _score) {
|
|
|
35
55
|
trigger: afterTimeout.length > 0
|
|
36
56
|
? `Agent executed the same write again after a timeout ${afterTimeout.length} time(s) — the server had already executed the first attempt.`
|
|
37
57
|
: `Agent sent a write again ${lookalikes.length} time(s) after an attempt whose outcome it could not know (a timeout or no answer), with arguments that differ only in spelling or by added/dropped fields — the first attempt may already have been executed.`,
|
|
58
|
+
trigger_de: afterTimeout.length > 0
|
|
59
|
+
? `Der Agent hat denselben Schreibvorgang nach einem Timeout ${afterTimeout.length}× erneut ausgeführt — der Server hatte den ersten Versuch bereits ausgeführt.`
|
|
60
|
+
: `Der Agent hat ${lookalikes.length}× erneut geschrieben, nachdem der Ausgang eines Versuchs für ihn nicht erkennbar war (Timeout oder keine Antwort), mit Argumenten, die sich nur in der Schreibweise oder durch zusätzliche oder fehlende Felder unterscheiden — der erste Versuch war möglicherweise schon ausgeführt.`,
|
|
38
61
|
promptSnippet: `CRITICAL — Timeout on a write ("ghost write"):\n${TIMEOUT_PROTOCOL}`,
|
|
39
62
|
toolGuidance: `Offer a lookup that works without the ID returned by the write (by customer, date, reference), and make write tools idempotent: same key + same payload = same result, no second effect.`,
|
|
63
|
+
toolGuidance_de: `Ein Nachschlage-Tool anbieten, das ohne die vom Schreibvorgang zurückgegebene ID funktioniert (nach Kunde, Datum oder Referenz), und Schreib-Tools idempotent machen: gleicher Schlüssel und gleicher Inhalt ergeben dasselbe Ergebnis, ohne zweite Wirkung.`,
|
|
40
64
|
});
|
|
41
65
|
}
|
|
42
66
|
// Duplicate without any timeout involved: the same write went out twice.
|
|
@@ -46,8 +70,10 @@ export function generatePatches(reports, _score) {
|
|
|
46
70
|
category: "IDEMPOTENCY",
|
|
47
71
|
severity: "critical",
|
|
48
72
|
trigger: `Agent sent the same write ${afterSuccess.length} more time(s) although the harness had reported no failure for it.`,
|
|
73
|
+
trigger_de: `Der Agent hat denselben Schreibvorgang ${afterSuccess.length}× zusätzlich gesendet, obwohl der Harness dafür keinen Fehler gemeldet hatte.`,
|
|
49
74
|
promptSnippet: `CRITICAL — One write per operation:\nSend each write exactly once. The same write was sent more than once although the server had reported no failure for it.\n1. Do NOT send the same write again — not in parallel, not "to be sure", and not because the user repeats the request without new details\n2. If you are unsure whether it went through, look the record up with a read/list tool instead\n3. Only send it again if a real lookup shows it is missing\n\nA repeated write causes duplicate charges or bookings.`,
|
|
50
75
|
toolGuidance: `Return an identifier for the created record in every write response, so it can be referenced and looked up.`,
|
|
76
|
+
toolGuidance_de: `Jede Schreibantwort sollte eine Kennung des angelegten Datensatzes enthalten, damit er referenziert und nachgeschlagen werden kann.`,
|
|
51
77
|
});
|
|
52
78
|
}
|
|
53
79
|
const retryAfter = reports.flatMap((r) => r.violations.filter((v) => v.type === "RETRY_AFTER_VIOLATED"));
|
|
@@ -56,8 +82,10 @@ export function generatePatches(reports, _score) {
|
|
|
56
82
|
category: "RATE_LIMIT",
|
|
57
83
|
severity: "critical",
|
|
58
84
|
trigger: `Agent re-sent a rate-limited operation before retry_after had elapsed ${retryAfter.length} time(s).`,
|
|
85
|
+
trigger_de: `Der Agent hat einen wegen zu vieler Anfragen abgelehnten Vorgang ${retryAfter.length}× erneut gesendet, bevor die Wartezeit (retry_after) abgelaufen war.`,
|
|
59
86
|
promptSnippet: `CRITICAL — Rate Limits:\nWhen a tool answers with a rate limit (HTTP 429) and a retry_after value:\n1. Do NOT call that tool again before retry_after has elapsed\n2. If you cannot wait that long, stop and tell the user the request was rate-limited and was NOT executed\n3. Never retry immediately "just to see"`,
|
|
60
87
|
toolGuidance: `Return retry_after as a structured field (seconds), not only inside a message string.`,
|
|
88
|
+
toolGuidance_de: `retry_after als strukturiertes Feld (Sekunden) zurückgeben, nicht nur innerhalb eines Meldungstextes.`,
|
|
61
89
|
});
|
|
62
90
|
}
|
|
63
91
|
const unverified = reports.flatMap((r) => r.operations.filter((op) => op.outcome === "UNVERIFIED_AFTER_TIMEOUT"));
|
|
@@ -68,8 +96,10 @@ export function generatePatches(reports, _score) {
|
|
|
68
96
|
category: "VERIFICATION",
|
|
69
97
|
severity: "warning",
|
|
70
98
|
trigger: `Agent left ${unverified.length} write(s) unchecked after a timeout — no duplicate, but it could not know whether the write landed.`,
|
|
99
|
+
trigger_de: `Der Agent hat ${unverified.length} ${unverified.length === 1 ? "Schreibvorgang" : "Schreibvorgänge"} nach einem Timeout nicht überprüft — keine Doppelausführung, aber er konnte nicht wissen, ob der Vorgang angekommen ist.`,
|
|
71
100
|
promptSnippet: `WARNING — Check after a timeout:\n${TIMEOUT_PROTOCOL}\nNever tell the user a timed-out write "was not executed" unless a real lookup showed that.`,
|
|
72
101
|
toolGuidance: `Offer a lookup that works without the ID returned by the write (by customer, date, reference), so a result can be checked after a lost response.`,
|
|
102
|
+
toolGuidance_de: `Ein Nachschlage-Tool anbieten, das ohne die vom Schreibvorgang zurückgegebene ID funktioniert (nach Kunde, Datum oder Referenz), damit sich das Ergebnis nach einer verlorenen Antwort prüfen lässt.`,
|
|
73
103
|
});
|
|
74
104
|
}
|
|
75
105
|
return patches;
|
package/dist/index.js
CHANGED
|
@@ -4,16 +4,24 @@ import { startStdio } from "./server/stdio.js";
|
|
|
4
4
|
import { runBenchmark, defaultConfig } from "./benchmark/runner.js";
|
|
5
5
|
import { formatCTA, buildTelemetry, COMING_SOON_NOTICE } from "./benchmark/cta.js";
|
|
6
6
|
import { computeScore } from "./benchmark/score.js";
|
|
7
|
-
import { generatePatches } from "./hardening/patch-generator.js";
|
|
7
|
+
import { generatePatches, generatePromptPatchDe } from "./hardening/patch-generator.js";
|
|
8
|
+
import { auditToolSchemas } from "./schema-audit.js";
|
|
8
9
|
import { generateReport } from "./core/report-generator.js";
|
|
10
|
+
import { buildCallForensics } from "./core/call-forensics.js";
|
|
11
|
+
import { computeReportIntegrity } from "./core/artifact-integrity.js";
|
|
9
12
|
import { ProxyInterceptor } from "./proxy/interceptor.js";
|
|
13
|
+
import { runDoctor } from "./doctor.js";
|
|
14
|
+
import { runReportMode } from "./plain-report.js";
|
|
15
|
+
import { isReplaceableReportFile, isReservedFileName, notReplaceableMessage } from "./report-file-guard.js";
|
|
16
|
+
import { runUiMode } from "./ui/server.js";
|
|
17
|
+
import { runCampaignMode } from "./campaign/runner.js";
|
|
10
18
|
import { createProxyServer, findToolNameCollisions } from "./proxy/gateway.js";
|
|
11
19
|
import { getScenarioById, ALL_SCENARIOS } from "./scenarios/f1-f5.js";
|
|
12
20
|
import { VERSION } from "./version.js";
|
|
13
21
|
import { writeFile, mkdir, rename, stat, realpath, unlink } from "node:fs/promises";
|
|
14
22
|
import { randomBytes } from "node:crypto";
|
|
15
23
|
import { writeFileSync, mkdirSync, statSync, lstatSync, existsSync } from "node:fs";
|
|
16
|
-
import { dirname, resolve } from "node:path";
|
|
24
|
+
import { basename, dirname, resolve } from "node:path";
|
|
17
25
|
const args = process.argv.slice(2);
|
|
18
26
|
function printHelp() {
|
|
19
27
|
process.stdout.write(`cuonztech-agent-harness — chaos/idempotency test harness for MCP agents\n\n` +
|
|
@@ -23,6 +31,20 @@ function printHelp() {
|
|
|
23
31
|
` cuonztech-agent-harness --help Print this help (also: -h, help)\n` +
|
|
24
32
|
` cuonztech-agent-harness benchmark [opts] Run the harness self-test (two fixed reference agents;\n` +
|
|
25
33
|
` "evaluate" is an alias)\n` +
|
|
34
|
+
` cuonztech-agent-harness doctor --upstream-command <cmd> [opts]\n` +
|
|
35
|
+
` Check the setup before a run (Node version, server\n` +
|
|
36
|
+
` start, tool classification, report file). Calls only\n` +
|
|
37
|
+
` initialize and tools/list; runs no tool.\n` +
|
|
38
|
+
` cuonztech-agent-harness ui [--port <n>] [--no-open]\n` +
|
|
39
|
+
` Guided web interface for people without a terminal\n` +
|
|
40
|
+
` (local only, 127.0.0.1): demo, setup check, result.\n` +
|
|
41
|
+
` cuonztech-agent-harness report <report-file> [--format md|html] [--out <file>]\n` +
|
|
42
|
+
` Turn the proxy's --report-file into a plain-language\n` +
|
|
43
|
+
` page (German): result, what the agent did, limits.\n` +
|
|
44
|
+
` cuonztech-agent-harness campaign [--runs <n>] [--format md|html] [--out <file>]\n` +
|
|
45
|
+
` Before/after comparison of three fixed reference\n` +
|
|
46
|
+
` profiles incl. the two-turn user-pressure scenario\n` +
|
|
47
|
+
` (German report; measures no foreign agent).\n` +
|
|
26
48
|
` cuonztech-agent-harness proxy --upstream-command <cmd> [opts]\n` +
|
|
27
49
|
` Run as a transparent chaos proxy\n` +
|
|
28
50
|
` in front of a real upstream MCP server\n\n` +
|
|
@@ -43,6 +65,10 @@ function printHelp() {
|
|
|
43
65
|
` The value may contain commas.\n` +
|
|
44
66
|
` --upstream-env K=V,K2=V2 Comma-separated env vars (values cannot contain commas)\n` +
|
|
45
67
|
` --scenario F1..F5 Activate one deterministic scenario\n` +
|
|
68
|
+
` --allow-writes Required with --scenario and with --mode chaos (error\n` +
|
|
69
|
+
` rate > 0): confirms that --upstream-command points at a\n` +
|
|
70
|
+
` disposable test/staging system. The agent's writes are\n` +
|
|
71
|
+
` executed there for real.\n` +
|
|
46
72
|
` --mode deterministic|chaos chaos = stochastic error injection (set --error-rate)\n` +
|
|
47
73
|
` --error-rate <0..1> Error probability in chaos mode\n` +
|
|
48
74
|
` --retry-after <seconds> retry_after announced by F2's 429 (default: 60;\n` +
|
|
@@ -97,6 +123,18 @@ function parseFlags(argv, spec) {
|
|
|
97
123
|
if (!kind) {
|
|
98
124
|
return { error: `unknown option "${token}".` };
|
|
99
125
|
}
|
|
126
|
+
// A switch without a value (--allow-writes): "--allow-writes=yes" and a
|
|
127
|
+
// repeat are errors, and the next token is not swallowed as its value.
|
|
128
|
+
if (kind === "boolean") {
|
|
129
|
+
if (inlineValue !== undefined) {
|
|
130
|
+
return { error: `option ${token} takes no value.` };
|
|
131
|
+
}
|
|
132
|
+
if (values.has(token)) {
|
|
133
|
+
return { error: `option ${token} was given more than once.` };
|
|
134
|
+
}
|
|
135
|
+
values.set(token, ["true"]);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
100
138
|
let value;
|
|
101
139
|
if (inlineValue !== undefined) {
|
|
102
140
|
value = inlineValue;
|
|
@@ -223,26 +261,6 @@ function fail(message) {
|
|
|
223
261
|
process.stderr.write(`Error: ${message}\nRun with --help for usage.\n`);
|
|
224
262
|
process.exit(1);
|
|
225
263
|
}
|
|
226
|
-
// Windows device names: writing to "NUL" or "CON" as a relative path can
|
|
227
|
-
// create a real file of that name (or block on a device).
|
|
228
|
-
// Checked for every path segment ("C:\\NUL\\x.json" creates a directory
|
|
229
|
-
// called NUL), and a ":" after the drive letter is refused as well (it
|
|
230
|
-
// addresses an alternate data stream and leaves an empty file behind).
|
|
231
|
-
function isReservedFileName(path) {
|
|
232
|
-
if (process.platform !== "win32")
|
|
233
|
-
return false;
|
|
234
|
-
// "\\?\C:\dir\file" is an ordinary long-path spelling.
|
|
235
|
-
const withoutDrive = path.replace(/^\\\\\?\\/, "").replace(/^[a-zA-Z]:/, "");
|
|
236
|
-
if (withoutDrive.includes(":"))
|
|
237
|
-
return true;
|
|
238
|
-
// Windows drops a trailing dot or space from a name; the file would not
|
|
239
|
-
// be the one that was asked for.
|
|
240
|
-
if (/[. ]$/.test(withoutDrive))
|
|
241
|
-
return true;
|
|
242
|
-
return withoutDrive
|
|
243
|
-
.split(/[\\/]+/)
|
|
244
|
-
.some((segment) => /^(nul|con|prn|aux|com[1-9]|lpt[1-9])(\..*)?$/i.test(segment));
|
|
245
|
-
}
|
|
246
264
|
// Best-effort lookup of --report-file before the full parse, so a command
|
|
247
265
|
// line that is rejected by the parser still updates that file.
|
|
248
266
|
// Walks the arguments the way the parser does, so that the VALUE of another
|
|
@@ -257,7 +275,8 @@ function peekReportFile(argv, spec) {
|
|
|
257
275
|
// Followed by another option: there is no path (the parser rejects it).
|
|
258
276
|
return value === undefined || value.startsWith("--") ? null : value;
|
|
259
277
|
}
|
|
260
|
-
|
|
278
|
+
// A switch (--allow-writes) takes no value: the next token is not one.
|
|
279
|
+
if (spec[token] && spec[token] !== "boolean")
|
|
261
280
|
i++;
|
|
262
281
|
}
|
|
263
282
|
return null;
|
|
@@ -285,6 +304,18 @@ async function main() {
|
|
|
285
304
|
else if (mode === "proxy") {
|
|
286
305
|
await runProxyMode(args.slice(1));
|
|
287
306
|
}
|
|
307
|
+
else if (mode === "doctor") {
|
|
308
|
+
process.exitCode = await runDoctor(args.slice(1));
|
|
309
|
+
}
|
|
310
|
+
else if (mode === "report") {
|
|
311
|
+
process.exitCode = await runReportMode(args.slice(1));
|
|
312
|
+
}
|
|
313
|
+
else if (mode === "campaign") {
|
|
314
|
+
process.exitCode = await runCampaignMode(args.slice(1));
|
|
315
|
+
}
|
|
316
|
+
else if (mode === "ui") {
|
|
317
|
+
process.exitCode = await runUiMode(args.slice(1));
|
|
318
|
+
}
|
|
288
319
|
else if (mode === undefined) {
|
|
289
320
|
// Default: MCP server mode (stdio)
|
|
290
321
|
const server = createServer();
|
|
@@ -293,7 +324,7 @@ async function main() {
|
|
|
293
324
|
await startStdio(server);
|
|
294
325
|
}
|
|
295
326
|
else {
|
|
296
|
-
fail(`unknown command "${mode}". Commands: benchmark, proxy, --help (no command = MCP server).`);
|
|
327
|
+
fail(`unknown command "${mode}". Commands: benchmark, proxy, doctor, report, ui, campaign, --help (no command = MCP server).`);
|
|
297
328
|
}
|
|
298
329
|
}
|
|
299
330
|
const PROXY_FLAGS = {
|
|
@@ -312,6 +343,7 @@ const PROXY_FLAGS = {
|
|
|
312
343
|
"--key-fields": "single",
|
|
313
344
|
"--audit-tools": "single",
|
|
314
345
|
"--report-file": "single",
|
|
346
|
+
"--allow-writes": "boolean",
|
|
315
347
|
};
|
|
316
348
|
function parseEnvPair(pair, flag, into) {
|
|
317
349
|
const eq = pair.indexOf("=");
|
|
@@ -328,7 +360,7 @@ async function runProxyMode(argv) {
|
|
|
328
360
|
return;
|
|
329
361
|
}
|
|
330
362
|
const peeked = peekReportFile(argv, PROXY_FLAGS);
|
|
331
|
-
if (peeked && peeked.trim() !== "" && !isReservedFileName(peeked)) {
|
|
363
|
+
if (peeked && peeked.trim() !== "" && !isReservedFileName(peeked) && isReplaceableReportFile(peeked)) {
|
|
332
364
|
startupReportFile = peeked;
|
|
333
365
|
}
|
|
334
366
|
if ("error" in parsed)
|
|
@@ -366,7 +398,7 @@ async function runProxyMode(argv) {
|
|
|
366
398
|
let errorRate = 0.0;
|
|
367
399
|
if (errorRateRaw !== undefined) {
|
|
368
400
|
if (!/^(\d+(\.\d+)?|\.\d+)$/.test(errorRateRaw) || Number(errorRateRaw) > 1) {
|
|
369
|
-
fail(`--error-rate must be a number between 0 and 1, got "${errorRateRaw}".`);
|
|
401
|
+
fail(`--error-rate must be a plain decimal number between 0 and 1 (like 0.2; no exponent such as 1e-5), got "${errorRateRaw}".`);
|
|
370
402
|
}
|
|
371
403
|
if (chaosMode !== "chaos") {
|
|
372
404
|
fail("--error-rate only has an effect together with --mode chaos.");
|
|
@@ -405,11 +437,30 @@ async function runProxyMode(argv) {
|
|
|
405
437
|
fail(`--upstream-cwd "${upstreamCwd}" is not an existing directory.`);
|
|
406
438
|
}
|
|
407
439
|
}
|
|
440
|
+
// The most common first-run mistake is a script path that does not exist
|
|
441
|
+
// (the README example is a placeholder). Check it here and say what to do,
|
|
442
|
+
// instead of letting the upstream fail with a Node stack trace. Inline
|
|
443
|
+
// scripts (node -e / -p) have no file to check.
|
|
444
|
+
const inlineScript = upstreamArgs.some((a) => ["-e", "--eval", "-p", "--print"].includes(a));
|
|
445
|
+
const scriptArg = upstreamArgs.find((a) => !a.startsWith("-"));
|
|
446
|
+
if (!inlineScript &&
|
|
447
|
+
scriptArg !== undefined &&
|
|
448
|
+
/^node(\.exe)?$/i.test(basename(upstreamCommand))) {
|
|
449
|
+
const scriptPath = resolve(upstreamCwd ?? process.cwd(), scriptArg);
|
|
450
|
+
if (!existsSync(scriptPath)) {
|
|
451
|
+
fail(`upstream script not found: "${scriptPath}"\n` +
|
|
452
|
+
"Replace the example path with the entry file of your MCP server, e.g.\n" +
|
|
453
|
+
" --upstream-arg path\\to\\your-server.js (or use --upstream-cwd to set its folder)");
|
|
454
|
+
}
|
|
455
|
+
}
|
|
408
456
|
if (reportFile !== undefined) {
|
|
409
457
|
if (isReservedFileName(reportFile)) {
|
|
410
458
|
fail(`--report-file "${reportFile}" contains a reserved device name or a ":", or ends in a dot or space, and is not a usable file.`);
|
|
411
459
|
}
|
|
412
460
|
const info = await stat(reportFile).catch(() => null);
|
|
461
|
+
if (info?.isFile() && !isReplaceableReportFile(reportFile)) {
|
|
462
|
+
fail(notReplaceableMessage("--report-file", reportFile));
|
|
463
|
+
}
|
|
413
464
|
if (info?.isDirectory()) {
|
|
414
465
|
fail(`--report-file "${reportFile}" is a directory, not a file.`);
|
|
415
466
|
}
|
|
@@ -445,6 +496,17 @@ async function runProxyMode(argv) {
|
|
|
445
496
|
log("WARNING: --mode chaos with a 0% error rate — this run will inject nothing. " +
|
|
446
497
|
"Pass e.g. --error-rate 0.2 to actually trigger stochastic failures.");
|
|
447
498
|
}
|
|
499
|
+
// Every scenario and every chaos run with a non-zero error rate lets the
|
|
500
|
+
// agent's writes reach the upstream for real. That needs a deliberate yes,
|
|
501
|
+
// given before the upstream is even started.
|
|
502
|
+
const writesReachUpstream = scenarioId !== undefined || (chaosMode === "chaos" && !chaosInjectsNothing);
|
|
503
|
+
if (writesReachUpstream && one("--allow-writes") === undefined) {
|
|
504
|
+
fail("this run executes the agent's writes against the upstream for real" +
|
|
505
|
+
(scenarioId ? ` (scenario ${scenarioId})` : " (chaos mode)") +
|
|
506
|
+
".\n" +
|
|
507
|
+
"Only do that against a disposable test/staging system, never production.\n" +
|
|
508
|
+
"If --upstream-command points at such a system, confirm it by adding --allow-writes.");
|
|
509
|
+
}
|
|
448
510
|
if (scenarioId === "F1") {
|
|
449
511
|
log("WARNING: scenario F1 really executes the agent's first write upstream and then " +
|
|
450
512
|
"reports a timeout to the agent. Only point --upstream-command at a disposable " +
|
|
@@ -511,6 +573,8 @@ async function runProxyMode(argv) {
|
|
|
511
573
|
: "\nThe upstream wrote nothing to stderr. Check the command, its arguments and that it speaks MCP over stdio."));
|
|
512
574
|
}
|
|
513
575
|
const upstreamTools = interceptor.getUpstreamTools();
|
|
576
|
+
// Static view of the tool schemas, once: it does not change during the run.
|
|
577
|
+
const schemaAudit = auditToolSchemas(upstreamTools, { writeToolPatterns, readToolPatterns });
|
|
514
578
|
const collisions = findToolNameCollisions(upstreamTools);
|
|
515
579
|
if (auditTools && collisions.length > 0) {
|
|
516
580
|
await interceptor.disconnectUpstream();
|
|
@@ -589,6 +653,7 @@ async function runProxyMode(argv) {
|
|
|
589
653
|
const report = generateReport(interceptor.getSession());
|
|
590
654
|
const score = computeScore([report]);
|
|
591
655
|
const patches = generatePatches([report], score);
|
|
656
|
+
const callForensics = buildCallForensics(interceptor.getSession());
|
|
592
657
|
const body = JSON.stringify({
|
|
593
658
|
harnessVersion: VERSION,
|
|
594
659
|
// Constant: a self-test, never a certificate.
|
|
@@ -598,6 +663,16 @@ async function runProxyMode(argv) {
|
|
|
598
663
|
report,
|
|
599
664
|
score,
|
|
600
665
|
patches,
|
|
666
|
+
// 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,
|
|
669
|
+
call_forensics: callForensics,
|
|
670
|
+
integrity: computeReportIntegrity({
|
|
671
|
+
harnessVersion: VERSION,
|
|
672
|
+
report,
|
|
673
|
+
schema_audit: schemaAudit,
|
|
674
|
+
call_forensics: callForensics,
|
|
675
|
+
}),
|
|
601
676
|
toolClassification: classification,
|
|
602
677
|
generatedAt: new Date().toISOString(),
|
|
603
678
|
}, null, 2);
|
|
@@ -704,7 +779,7 @@ async function runProxyMode(argv) {
|
|
|
704
779
|
log("The connection to the client was closed (stdin ended, or a message was too large for the MCP transport). Shutting down.");
|
|
705
780
|
void shutdown();
|
|
706
781
|
};
|
|
707
|
-
await startStdio(server);
|
|
782
|
+
await startStdio(server, { log });
|
|
708
783
|
}
|
|
709
784
|
const BENCHMARK_FLAGS = {
|
|
710
785
|
"--runs": "single",
|