cuonztech-agent-harness 0.3.2 → 0.5.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.
Files changed (75) hide show
  1. package/README.md +220 -147
  2. package/dist/benchmark/cta.js +46 -28
  3. package/dist/benchmark/runner.js +105 -82
  4. package/dist/benchmark/score.js +112 -60
  5. package/dist/core/report-generator.js +453 -239
  6. package/dist/core/state-engine.js +1 -2
  7. package/dist/engine/state-diff.js +4 -2
  8. package/dist/engine/state-machine.js +164 -17
  9. package/dist/hardening/patch-generator.js +49 -48
  10. package/dist/index.js +562 -59
  11. package/dist/proxy/gateway.js +71 -65
  12. package/dist/proxy/ghost-write.js +49 -11
  13. package/dist/proxy/interceptor.js +264 -76
  14. package/dist/scenarios/f1-f5.js +40 -21
  15. package/dist/scenarios/types.js +0 -1
  16. package/dist/server/server.js +5 -5
  17. package/dist/server/stdio.js +0 -1
  18. package/dist/tools/handlers.js +90 -39
  19. package/dist/tools/schemas.js +26 -1
  20. package/dist/version.js +12 -0
  21. package/package.json +4 -3
  22. package/dist/benchmark/cta.d.ts +0 -11
  23. package/dist/benchmark/cta.d.ts.map +0 -1
  24. package/dist/benchmark/cta.js.map +0 -1
  25. package/dist/benchmark/runner.d.ts +0 -19
  26. package/dist/benchmark/runner.d.ts.map +0 -1
  27. package/dist/benchmark/runner.js.map +0 -1
  28. package/dist/benchmark/score.d.ts +0 -26
  29. package/dist/benchmark/score.d.ts.map +0 -1
  30. package/dist/benchmark/score.js.map +0 -1
  31. package/dist/core/report-generator.d.ts +0 -47
  32. package/dist/core/report-generator.d.ts.map +0 -1
  33. package/dist/core/report-generator.js.map +0 -1
  34. package/dist/core/state-engine.d.ts +0 -2
  35. package/dist/core/state-engine.d.ts.map +0 -1
  36. package/dist/core/state-engine.js.map +0 -1
  37. package/dist/engine/state-diff.d.ts +0 -33
  38. package/dist/engine/state-diff.d.ts.map +0 -1
  39. package/dist/engine/state-diff.js.map +0 -1
  40. package/dist/engine/state-machine.d.ts +0 -43
  41. package/dist/engine/state-machine.d.ts.map +0 -1
  42. package/dist/engine/state-machine.js.map +0 -1
  43. package/dist/hardening/patch-generator.d.ts +0 -12
  44. package/dist/hardening/patch-generator.d.ts.map +0 -1
  45. package/dist/hardening/patch-generator.js.map +0 -1
  46. package/dist/index.d.ts +0 -3
  47. package/dist/index.d.ts.map +0 -1
  48. package/dist/index.js.map +0 -1
  49. package/dist/proxy/gateway.d.ts +0 -4
  50. package/dist/proxy/gateway.d.ts.map +0 -1
  51. package/dist/proxy/gateway.js.map +0 -1
  52. package/dist/proxy/ghost-write.d.ts +0 -17
  53. package/dist/proxy/ghost-write.d.ts.map +0 -1
  54. package/dist/proxy/ghost-write.js.map +0 -1
  55. package/dist/proxy/interceptor.d.ts +0 -34
  56. package/dist/proxy/interceptor.d.ts.map +0 -1
  57. package/dist/proxy/interceptor.js.map +0 -1
  58. package/dist/scenarios/f1-f5.d.ts +0 -9
  59. package/dist/scenarios/f1-f5.d.ts.map +0 -1
  60. package/dist/scenarios/f1-f5.js.map +0 -1
  61. package/dist/scenarios/types.d.ts +0 -15
  62. package/dist/scenarios/types.d.ts.map +0 -1
  63. package/dist/scenarios/types.js.map +0 -1
  64. package/dist/server/server.d.ts +0 -3
  65. package/dist/server/server.d.ts.map +0 -1
  66. package/dist/server/server.js.map +0 -1
  67. package/dist/server/stdio.d.ts +0 -7
  68. package/dist/server/stdio.d.ts.map +0 -1
  69. package/dist/server/stdio.js.map +0 -1
  70. package/dist/tools/handlers.d.ts +0 -92
  71. package/dist/tools/handlers.d.ts.map +0 -1
  72. package/dist/tools/handlers.js.map +0 -1
  73. package/dist/tools/schemas.d.ts +0 -37
  74. package/dist/tools/schemas.d.ts.map +0 -1
  75. package/dist/tools/schemas.js.map +0 -1
package/dist/index.js CHANGED
@@ -2,112 +2,615 @@
2
2
  import { createServer } from "./server/server.js";
3
3
  import { startStdio } from "./server/stdio.js";
4
4
  import { runBenchmark, defaultConfig } from "./benchmark/runner.js";
5
- import { formatCTA, buildTelemetry } from "./benchmark/cta.js";
5
+ import { formatCTA, buildTelemetry, COMING_SOON_NOTICE } from "./benchmark/cta.js";
6
+ import { computeScore } from "./benchmark/score.js";
7
+ import { generatePatches } from "./hardening/patch-generator.js";
8
+ import { generateReport } from "./core/report-generator.js";
6
9
  import { ProxyInterceptor } from "./proxy/interceptor.js";
7
- import { createProxyServer } from "./proxy/gateway.js";
8
- import { writeFile, mkdir } from "node:fs/promises";
10
+ import { createProxyServer, findToolNameCollisions } from "./proxy/gateway.js";
11
+ import { getScenarioById, ALL_SCENARIOS } from "./scenarios/f1-f5.js";
12
+ import { VERSION } from "./version.js";
13
+ import { writeFile, mkdir, rename, stat } from "node:fs/promises";
14
+ import { writeFileSync, mkdirSync, statSync } from "node:fs";
15
+ import { dirname, basename } from "node:path";
9
16
  const args = process.argv.slice(2);
17
+ function printHelp() {
18
+ process.stdout.write(`cuonztech-agent-harness — chaos/idempotency test harness for MCP agents\n\n` +
19
+ `Usage:\n` +
20
+ ` cuonztech-agent-harness Start as an MCP server (stdio, demo/self-test sessions)\n` +
21
+ ` cuonztech-agent-harness --version Print the version\n` +
22
+ ` cuonztech-agent-harness benchmark [opts] Run the harness self-test (two fixed reference agents;\n` +
23
+ ` "evaluate" is an alias)\n` +
24
+ ` cuonztech-agent-harness proxy --upstream-command <cmd> [opts]\n` +
25
+ ` Run as a transparent chaos proxy\n` +
26
+ ` in front of a real upstream MCP server\n\n` +
27
+ `benchmark options:\n` +
28
+ ` --scenarios F1,F2,... Which scenarios to run (default: all)\n` +
29
+ ` --runs <n> Runs per scenario, 1-1000 (default: 1). The reference agents\n` +
30
+ ` are deterministic: more runs repeat the same result.\n` +
31
+ ` --jitter <ms> Random delay between the careful agent's calls (default: 0);\n` +
32
+ ` does not change any verdict\n\n` +
33
+ `proxy options:\n` +
34
+ ` --upstream-command <cmd> Command to spawn the upstream MCP server (required)\n` +
35
+ ` --upstream-arg <arg> One argument for the upstream command; repeat the flag\n` +
36
+ ` for several. Use this for arguments containing commas.\n` +
37
+ ` --upstream-args <a,b,c> Comma-separated args (cannot contain commas themselves);\n` +
38
+ ` always placed before the --upstream-arg values\n` +
39
+ ` --upstream-cwd <dir> Working directory for the upstream process\n` +
40
+ ` --upstream-env-var KEY=VALUE One env var for the upstream process; repeatable.\n` +
41
+ ` The value may contain commas.\n` +
42
+ ` --upstream-env K=V,K2=V2 Comma-separated env vars (values cannot contain commas)\n` +
43
+ ` --scenario F1..F5 Activate one deterministic scenario\n` +
44
+ ` --mode deterministic|chaos chaos = stochastic error injection (needs --error-rate)\n` +
45
+ ` --error-rate <0..1> Error probability in chaos mode\n` +
46
+ ` --retry-after <seconds> retry_after announced by F2's 429 (default: 60;\n` +
47
+ ` only with --scenario F2)\n` +
48
+ ` --write-tools pat1,pat2,... Glob patterns ("*") for tool names that count as\n` +
49
+ ` writes. Replaces the default verb list (write*,\n` +
50
+ ` create_*, send_*, book_*, pay_*, transfer_*, ...).\n` +
51
+ ` --read-tools pat1,pat2,... Extra glob patterns for tool names that count as reads\n` +
52
+ ` --key-fields a,b Argument names that carry an idempotency key\n` +
53
+ ` (default: idempotency_key, any case/separator)\n` +
54
+ ` --audit-tools on|off Expose cuonztech__get_report / cuonztech__get_score\n` +
55
+ ` to the connected agent (default: on)\n` +
56
+ ` --report-file <path> Write report + score as JSON to this file after every\n` +
57
+ ` call and on shutdown (for CI)\n\n` +
58
+ `Options may be written as "--flag value" or "--flag=value".\n\n` +
59
+ `Tool classification in proxy mode: explicit flags first, then the upstream's own\n` +
60
+ `MCP annotations (readOnlyHint/destructiveHint), then name patterns. The result is\n` +
61
+ `printed to stderr at startup; unclassified tools are passed through but not scored.\n\n` +
62
+ `The proxy is a long-running server: its exit code says nothing about the verdict.\n` +
63
+ `Read the verdict from --report-file.\n\n` +
64
+ `${COMING_SOON_NOTICE}\n`);
65
+ }
66
+ // Strict flag parsing: an unknown flag, a flag without a value, a repeated
67
+ // single-value flag and a stray positional argument are all errors. The old
68
+ // indexOf()-based lookup silently ignored typos like "--scenaro F1" — the
69
+ // run then tested nothing while looking normal.
70
+ function parseFlags(argv, spec) {
71
+ const values = new Map();
72
+ for (let i = 0; i < argv.length; i++) {
73
+ let token = argv[i];
74
+ // Only where an option is expected — as the VALUE of an option (e.g.
75
+ // --upstream-arg --help) it is passed on, not interpreted.
76
+ if (token === "--help" || token === "-h") {
77
+ return { help: true };
78
+ }
79
+ if (!token.startsWith("--")) {
80
+ return { error: `unexpected argument "${token}".` };
81
+ }
82
+ // "--flag=value" form
83
+ let inlineValue;
84
+ const eq = token.indexOf("=");
85
+ if (eq !== -1 && spec[token.slice(0, eq)]) {
86
+ inlineValue = token.slice(eq + 1);
87
+ token = token.slice(0, eq);
88
+ }
89
+ const kind = spec[token];
90
+ if (!kind) {
91
+ return { error: `unknown option "${token}".` };
92
+ }
93
+ let value;
94
+ if (inlineValue !== undefined) {
95
+ value = inlineValue;
96
+ }
97
+ else {
98
+ value = argv[i + 1];
99
+ i++;
100
+ }
101
+ if (value === undefined) {
102
+ return { error: `option ${token} needs a value.` };
103
+ }
104
+ // An empty value is almost always a broken shell variable; treating it
105
+ // as "flag not given" would silently change what the run tests.
106
+ // (--upstream-arg is the exception: an empty string is a legitimate
107
+ // argument to hand to the upstream command.)
108
+ if (value.trim() === "" && token !== "--upstream-arg") {
109
+ return { error: `option ${token} was given an empty value.` };
110
+ }
111
+ const existing = values.get(token);
112
+ if (existing) {
113
+ if (kind === "single") {
114
+ return { error: `option ${token} was given more than once.` };
115
+ }
116
+ existing.push(value);
117
+ }
118
+ else {
119
+ values.set(token, [value]);
120
+ }
121
+ }
122
+ return { values };
123
+ }
124
+ // Set as soon as proxy mode knows where its --report-file goes, so that ANY
125
+ // failed start — including a rejected command line — replaces a report left
126
+ // over from an earlier run instead of leaving its old PASS in place.
127
+ let startupReportFile = null;
128
+ function writeStartupFailureSync(reason) {
129
+ if (!startupReportFile)
130
+ return;
131
+ try {
132
+ if (statSync(startupReportFile, { throwIfNoEntry: false })?.isDirectory())
133
+ return;
134
+ mkdirSync(dirname(startupReportFile), { recursive: true });
135
+ writeFileSync(startupReportFile, JSON.stringify({
136
+ harnessVersion: VERSION,
137
+ certification: "UNVERIFIED_FOR_PRODUCTION",
138
+ verdict: "NOT_TESTED",
139
+ startupError: reason,
140
+ generatedAt: new Date().toISOString(),
141
+ }, null, 2), "utf-8");
142
+ }
143
+ catch {
144
+ // The start already failed; the original error is what gets reported.
145
+ }
146
+ }
147
+ function fail(message) {
148
+ writeStartupFailureSync(message);
149
+ process.stderr.write(`Error: ${message}\nRun with --help for usage.\n`);
150
+ process.exit(1);
151
+ }
152
+ // Windows device names: writing to "NUL" or "CON" as a relative path can
153
+ // create a real file of that name (or block on a device).
154
+ function isReservedFileName(path) {
155
+ return (process.platform === "win32" &&
156
+ /^(nul|con|prn|aux|com[1-9]|lpt[1-9])(\..*)?$/i.test(basename(path)));
157
+ }
158
+ // Best-effort lookup of --report-file before the full parse, so a command
159
+ // line that is rejected by the parser still updates that file.
160
+ function peekReportFile(argv) {
161
+ for (let i = 0; i < argv.length; i++) {
162
+ if (argv[i] === "--report-file" && argv[i + 1])
163
+ return argv[i + 1];
164
+ if (argv[i].startsWith("--report-file="))
165
+ return argv[i].slice("--report-file=".length) || null;
166
+ }
167
+ return null;
168
+ }
169
+ function splitList(raw, flag) {
170
+ if (raw === undefined)
171
+ return undefined;
172
+ const parts = raw.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
173
+ if (parts.length === 0) {
174
+ fail(`${flag} contains no pattern/name.`);
175
+ }
176
+ return parts;
177
+ }
10
178
  async function main() {
11
179
  const mode = args[0];
12
- if (mode === "benchmark" || mode === "evaluate") {
13
- await runBenchmarkMode();
180
+ if (mode === "--help" || mode === "-h" || mode === "help") {
181
+ printHelp();
182
+ }
183
+ else if (mode === "--version" || mode === "-v") {
184
+ process.stdout.write(`${VERSION}\n`);
185
+ }
186
+ else if (mode === "benchmark" || mode === "evaluate") {
187
+ await runBenchmarkMode(args.slice(1));
14
188
  }
15
189
  else if (mode === "proxy") {
16
- await runProxyMode();
190
+ await runProxyMode(args.slice(1));
17
191
  }
18
- else {
192
+ else if (mode === undefined) {
19
193
  // Default: MCP server mode (stdio)
20
194
  const server = createServer();
21
195
  await startStdio(server);
22
196
  }
197
+ else {
198
+ fail(`unknown command "${mode}". Commands: benchmark, proxy, --help (no command = MCP server).`);
199
+ }
23
200
  }
24
- async function runProxyMode() {
25
- const cmdIdx = args.indexOf("--upstream-command");
26
- const upstreamCommand = cmdIdx !== -1 ? args[cmdIdx + 1] : undefined;
27
- if (!upstreamCommand) {
28
- process.stderr.write("Error: proxy mode requires --upstream-command <cmd>\n" +
29
- "Example: cuonztech-agent-harness proxy --upstream-command node --upstream-args dist/server.js\n");
30
- process.exit(1);
201
+ const PROXY_FLAGS = {
202
+ "--upstream-command": "single",
203
+ "--upstream-args": "single",
204
+ "--upstream-arg": "multi",
205
+ "--upstream-cwd": "single",
206
+ "--upstream-env": "single",
207
+ "--upstream-env-var": "multi",
208
+ "--scenario": "single",
209
+ "--mode": "single",
210
+ "--error-rate": "single",
211
+ "--retry-after": "single",
212
+ "--write-tools": "single",
213
+ "--read-tools": "single",
214
+ "--key-fields": "single",
215
+ "--audit-tools": "single",
216
+ "--report-file": "single",
217
+ };
218
+ function parseEnvPair(pair, flag, into) {
219
+ const eq = pair.indexOf("=");
220
+ const key = eq === -1 ? "" : pair.slice(0, eq).trim();
221
+ if (key === "") {
222
+ fail(`${flag} entry "${pair}" is not KEY=VALUE.`);
223
+ }
224
+ into[key] = pair.slice(eq + 1);
225
+ }
226
+ async function runProxyMode(argv) {
227
+ const parsed = parseFlags(argv, PROXY_FLAGS);
228
+ if ("help" in parsed) {
229
+ printHelp();
31
230
  return;
32
231
  }
33
- const argsIdx = args.indexOf("--upstream-args");
34
- const upstreamArgs = argsIdx !== -1 && args[argsIdx + 1] ? args[argsIdx + 1].split(",") : [];
35
- const cwdIdx = args.indexOf("--upstream-cwd");
36
- const upstreamCwd = cwdIdx !== -1 ? args[cwdIdx + 1] : undefined;
37
- const scenarioIdx = args.indexOf("--scenario");
38
- const scenarioId = scenarioIdx !== -1 ? args[scenarioIdx + 1] : undefined;
39
- const modeIdx = args.indexOf("--mode");
40
- const chaosMode = modeIdx !== -1 && args[modeIdx + 1] === "chaos" ? "chaos" : "deterministic";
41
- const errorRateIdx = args.indexOf("--error-rate");
42
- const errorRate = errorRateIdx !== -1 && args[errorRateIdx + 1]
43
- ? parseFloat(args[errorRateIdx + 1])
44
- : 0.0;
232
+ const peeked = peekReportFile(argv);
233
+ if (peeked && peeked.trim() !== "" && !isReservedFileName(peeked)) {
234
+ startupReportFile = peeked;
235
+ }
236
+ if ("error" in parsed)
237
+ fail(parsed.error);
238
+ const one = (name) => parsed.values.get(name)?.[0];
239
+ const many = (name) => parsed.values.get(name) ?? [];
240
+ const upstreamCommand = one("--upstream-command");
241
+ if (!upstreamCommand) {
242
+ fail("proxy mode requires --upstream-command <cmd>\n" +
243
+ "Example: cuonztech-agent-harness proxy --upstream-command node --upstream-arg dist/server.js");
244
+ }
245
+ const upstreamArgs = [
246
+ ...(one("--upstream-args") ? one("--upstream-args").split(",") : []),
247
+ ...many("--upstream-arg"),
248
+ ];
249
+ const upstreamEnv = {};
250
+ const envList = one("--upstream-env");
251
+ if (envList !== undefined) {
252
+ for (const pair of envList.split(","))
253
+ parseEnvPair(pair, "--upstream-env", upstreamEnv);
254
+ }
255
+ for (const pair of many("--upstream-env-var")) {
256
+ parseEnvPair(pair, "--upstream-env-var", upstreamEnv);
257
+ }
258
+ const scenarioId = one("--scenario");
259
+ if (scenarioId !== undefined && !getScenarioById(scenarioId)) {
260
+ fail(`unknown scenario "${scenarioId}". Valid: ${ALL_SCENARIOS.map((s) => s.id).join(", ")}`);
261
+ }
262
+ const modeRaw = one("--mode");
263
+ if (modeRaw !== undefined && modeRaw !== "deterministic" && modeRaw !== "chaos") {
264
+ fail(`--mode must be "deterministic" or "chaos", got "${modeRaw}".`);
265
+ }
266
+ const chaosMode = modeRaw === "chaos" ? "chaos" : "deterministic";
267
+ const errorRateRaw = one("--error-rate");
268
+ let errorRate = 0.0;
269
+ if (errorRateRaw !== undefined) {
270
+ if (!/^(\d+(\.\d+)?|\.\d+)$/.test(errorRateRaw) || Number(errorRateRaw) > 1) {
271
+ fail(`--error-rate must be a number between 0 and 1, got "${errorRateRaw}".`);
272
+ }
273
+ if (chaosMode !== "chaos") {
274
+ fail("--error-rate only has an effect together with --mode chaos.");
275
+ }
276
+ errorRate = Number(errorRateRaw);
277
+ }
278
+ if (scenarioId !== undefined && chaosMode === "chaos") {
279
+ fail("--scenario and --mode chaos cannot be combined: a scenario is deterministic, " +
280
+ "and with both set the chaos mode would never inject anything.");
281
+ }
282
+ const retryAfterRaw = one("--retry-after");
283
+ let retryAfterSec;
284
+ if (retryAfterRaw !== undefined) {
285
+ const value = Number(retryAfterRaw);
286
+ if (!/^(\d+(\.\d+)?|\.\d+)$/.test(retryAfterRaw) || value <= 0 || value > 86_400) {
287
+ fail(`--retry-after must be a positive number of seconds (at most 86400), got "${retryAfterRaw}".`);
288
+ }
289
+ if (scenarioId !== "F2") {
290
+ fail("--retry-after only has an effect together with --scenario F2.");
291
+ }
292
+ retryAfterSec = value;
293
+ }
294
+ const auditToolsRaw = one("--audit-tools");
295
+ if (auditToolsRaw !== undefined && auditToolsRaw !== "on" && auditToolsRaw !== "off") {
296
+ fail(`--audit-tools must be "on" or "off", got "${auditToolsRaw}".`);
297
+ }
298
+ const auditTools = auditToolsRaw !== "off";
299
+ const reportFile = one("--report-file");
300
+ const writeToolPatterns = splitList(one("--write-tools"), "--write-tools");
301
+ const readToolPatterns = splitList(one("--read-tools"), "--read-tools");
302
+ const keyFields = splitList(one("--key-fields"), "--key-fields");
303
+ const upstreamCwd = one("--upstream-cwd");
304
+ if (upstreamCwd !== undefined) {
305
+ const info = await stat(upstreamCwd).catch(() => null);
306
+ if (!info || !info.isDirectory()) {
307
+ fail(`--upstream-cwd "${upstreamCwd}" is not an existing directory.`);
308
+ }
309
+ }
310
+ if (reportFile !== undefined) {
311
+ if (isReservedFileName(reportFile)) {
312
+ fail(`--report-file "${reportFile}" is a reserved device name, not a usable file.`);
313
+ }
314
+ const info = await stat(reportFile).catch(() => null);
315
+ if (info?.isDirectory()) {
316
+ fail(`--report-file "${reportFile}" is a directory, not a file.`);
317
+ }
318
+ }
319
+ const log = (line) => {
320
+ process.stderr.write(`[cuonztech-agent-harness] ${line}\n`);
321
+ };
322
+ if (chaosMode === "chaos" && errorRate === 0) {
323
+ log("WARNING: --mode chaos with a 0% error rate — this run will inject nothing. " +
324
+ "Pass e.g. --error-rate 0.2 to actually trigger stochastic failures.");
325
+ }
326
+ if (scenarioId === "F1") {
327
+ log("WARNING: scenario F1 really executes the agent's first write upstream and then " +
328
+ "reports a timeout to the agent. Only point --upstream-command at a disposable " +
329
+ "test/staging backend, never production.");
330
+ }
331
+ else if (scenarioId || chaosMode === "chaos") {
332
+ log("WARNING: failure injection is active — the agent will receive errors for calls " +
333
+ "that were never sent upstream. Every other write is executed for real. Only " +
334
+ "point --upstream-command at a disposable test/staging backend, never production.");
335
+ }
336
+ if (!scenarioId && chaosMode !== "chaos") {
337
+ log("NOTE: no --scenario and no chaos mode — calls are passed through unchanged. A " +
338
+ "result from this run is a baseline, not a resilience test.");
339
+ }
45
340
  const interceptor = new ProxyInterceptor({
46
- upstream: { command: upstreamCommand, args: upstreamArgs, cwd: upstreamCwd },
341
+ upstream: {
342
+ command: upstreamCommand,
343
+ args: upstreamArgs,
344
+ cwd: upstreamCwd,
345
+ env: upstreamEnv,
346
+ },
47
347
  scenarioId,
48
348
  mode: chaosMode,
49
349
  errorRate,
350
+ writeToolPatterns,
351
+ readToolPatterns,
352
+ keyFields,
353
+ retryAfterSec,
50
354
  });
51
- await interceptor.connectUpstream();
355
+ try {
356
+ await interceptor.connectUpstream();
357
+ }
358
+ catch (err) {
359
+ const tail = interceptor.getUpstreamStderrTail().trim();
360
+ const reason = `could not start or connect to the upstream MCP server ` +
361
+ `("${upstreamCommand}${upstreamArgs.length > 0 ? " " + upstreamArgs.join(" ") : ""}"): ` +
362
+ `${err instanceof Error ? err.message : String(err)}`;
363
+ fail(reason +
364
+ (tail.length > 0
365
+ ? `\nUpstream stderr:\n${tail}`
366
+ : "\nThe upstream wrote nothing to stderr. Check the command, its arguments and that it speaks MCP over stdio."));
367
+ }
368
+ const upstreamTools = interceptor.getUpstreamTools();
369
+ const collisions = findToolNameCollisions(upstreamTools);
370
+ if (auditTools && collisions.length > 0) {
371
+ await interceptor.disconnectUpstream();
372
+ fail(`the upstream already has tool(s) named ${collisions.join(", ")}, which collide with ` +
373
+ "the harness's own audit tools. Start with --audit-tools off (and --report-file to " +
374
+ "collect the result).");
375
+ }
376
+ // Say what each upstream tool was taken for. Scoring depends entirely on
377
+ // this, and a wrong or missing classification is otherwise invisible.
378
+ const classification = interceptor.getToolClassification();
379
+ log(`Upstream tools (${classification.length}):`);
380
+ for (const entry of classification) {
381
+ log(` ${entry.class.padEnd(12)} ${entry.name} (${entry.source})`);
382
+ }
383
+ for (const entry of classification.filter((c) => c.class === "unclassified")) {
384
+ log(`WARNING: tool "${entry.name}" could not be classified as read or write. Its calls ` +
385
+ "are passed through but never trigger a scenario and are not scored — if it writes, " +
386
+ `duplicates through it stay undetected. Classify it with --write-tools "${entry.name}" ` +
387
+ `or --read-tools "${entry.name}".`);
388
+ }
389
+ if (upstreamTools.length > 0 && !classification.some((c) => c.class === "write")) {
390
+ log(`WARNING: none of the upstream's ${upstreamTools.length} tool(s) ` +
391
+ `(${upstreamTools.map((t) => t.name).join(", ")}) is classified as a write. ` +
392
+ "Scenarios will NEVER trigger for this upstream and every report will read NOT_TESTED. " +
393
+ 'If your write tools use different naming, pass e.g. --write-tools "yourprefix_*".');
394
+ }
395
+ const keyCapable = interceptor.getKeyCapableTools();
396
+ log(keyCapable.length > 0
397
+ ? `Idempotency-key argument recognized on: ${keyCapable.join(", ")}`
398
+ : "NOTE: no upstream tool has an argument recognized as an idempotency key " +
399
+ "(looked for: " +
400
+ (keyFields ?? ["idempotency_key"]).join(", ") +
401
+ "). " +
402
+ (keyFields
403
+ ? "Check the names given to --key-fields against the tools' input schemas."
404
+ : "If your tools use another name (request_id, client_token, ...), pass " +
405
+ "--key-fields — otherwise a retry under a new such value looks like a different operation."));
406
+ log("Only tools are proxied. The upstream's resources, prompts and notifications are not forwarded.");
407
+ if (auditTools && (scenarioId || chaosMode === "chaos")) {
408
+ log("NOTE: the audit tools (cuonztech__get_report, cuonztech__get_score) are visible to " +
409
+ "the connected agent and reveal what really happened upstream. For a measurement " +
410
+ "the agent must not be able to peek: use --audit-tools off with --report-file.");
411
+ }
412
+ if (!auditTools && !reportFile) {
413
+ log("WARNING: --audit-tools off without --report-file — this run has no way to hand " +
414
+ "out its result.");
415
+ }
416
+ // --report-file: rewritten after every call (a proxy is usually killed,
417
+ // not shut down politely, so waiting for exit would lose the result).
418
+ // Writes are chained so they cannot overlap, and go through a temp file
419
+ // so a reader never sees a half-written report.
420
+ let pendingWrite = Promise.resolve();
421
+ let reportWriteFailed = false;
422
+ const writeReportFile = () => {
423
+ if (!reportFile)
424
+ return Promise.resolve();
425
+ pendingWrite = pendingWrite.then(async () => {
426
+ const report = generateReport(interceptor.getSession());
427
+ const score = computeScore([report]);
428
+ const patches = generatePatches([report], score);
429
+ const body = JSON.stringify({
430
+ harnessVersion: VERSION,
431
+ // Constant: a self-test, never a certificate.
432
+ certification: "UNVERIFIED_FOR_PRODUCTION",
433
+ // The audit verdict (PASS / FAIL / NOT_TESTED), same as report.verdict.
434
+ verdict: report.verdict,
435
+ report,
436
+ score,
437
+ patches,
438
+ toolClassification: classification,
439
+ generatedAt: new Date().toISOString(),
440
+ }, null, 2);
441
+ try {
442
+ await mkdir(dirname(reportFile), { recursive: true });
443
+ const tmp = `${reportFile}.tmp`;
444
+ await writeFile(tmp, body, "utf-8");
445
+ await rename(tmp, reportFile);
446
+ }
447
+ catch (err) {
448
+ if (!reportWriteFailed) {
449
+ reportWriteFailed = true;
450
+ log(`WARNING: could not write --report-file "${reportFile}": ${err}`);
451
+ }
452
+ }
453
+ });
454
+ return pendingWrite;
455
+ };
456
+ if (reportFile) {
457
+ interceptor.onCallRecorded = () => {
458
+ void writeReportFile();
459
+ };
460
+ await writeReportFile();
461
+ if (reportWriteFailed) {
462
+ await interceptor.disconnectUpstream();
463
+ fail(`cannot write --report-file "${reportFile}".`);
464
+ }
465
+ }
466
+ interceptor.onUpstreamLost = (stderrTail) => {
467
+ log("ERROR: the upstream MCP server process exited or closed its connection. Every " +
468
+ "further tool call is answered with UPSTREAM_UNAVAILABLE; the proxy does not " +
469
+ "restart it.");
470
+ if (stderrTail.trim().length > 0) {
471
+ log(`Last upstream stderr output:\n${stderrTail.trimEnd()}`);
472
+ }
473
+ };
474
+ let shuttingDown = false;
52
475
  const shutdown = async () => {
476
+ if (shuttingDown)
477
+ return;
478
+ shuttingDown = true;
479
+ await writeReportFile();
53
480
  await interceptor.disconnectUpstream();
54
481
  process.exit(0);
55
482
  };
56
483
  process.on("SIGINT", shutdown);
57
484
  process.on("SIGTERM", shutdown);
58
- const server = createProxyServer(interceptor);
485
+ // The client closing our stdin is the normal way an MCP stdio session
486
+ // ends; without this the proxy (and its upstream child) would linger.
487
+ process.stdin.on("end", shutdown);
488
+ process.stdin.on("close", shutdown);
489
+ startupReportFile = null;
490
+ const server = createProxyServer(interceptor, { auditTools });
59
491
  await startStdio(server);
60
492
  }
61
- async function runBenchmarkMode() {
493
+ const BENCHMARK_FLAGS = {
494
+ "--runs": "single",
495
+ "--jitter": "single",
496
+ "--scenarios": "single",
497
+ };
498
+ async function runBenchmarkMode(argv) {
499
+ const parsed = parseFlags(argv, BENCHMARK_FLAGS);
500
+ if ("help" in parsed) {
501
+ printHelp();
502
+ return;
503
+ }
504
+ if ("error" in parsed)
505
+ fail(parsed.error);
506
+ const one = (name) => parsed.values.get(name)?.[0];
62
507
  const config = defaultConfig();
63
- // Parse optional flags
64
- const runsIdx = args.indexOf("--runs");
65
- if (runsIdx !== -1 && args[runsIdx + 1]) {
66
- config.runsPerScenario = parseInt(args[runsIdx + 1], 10);
508
+ // Strict integer parsing: parseInt() alone is too lenient (parseInt("1.5")
509
+ // silently truncates to 1 instead of being rejected) — only a string
510
+ // matching /^-?\d+$/ counts as a valid integer here.
511
+ function parseStrictInt(raw) {
512
+ return /^-?\d+$/.test(raw) ? parseInt(raw, 10) : null;
513
+ }
514
+ const runsRaw = one("--runs");
515
+ if (runsRaw !== undefined) {
516
+ const value = parseStrictInt(runsRaw);
517
+ if (value === null || value < 1 || value > 1000) {
518
+ fail(`--runs must be a positive integer (at most 1000), got "${runsRaw}".`);
519
+ }
520
+ config.runsPerScenario = value;
67
521
  }
68
- const jitterIdx = args.indexOf("--jitter");
69
- if (jitterIdx !== -1 && args[jitterIdx + 1]) {
70
- config.jitterMs = parseInt(args[jitterIdx + 1], 10);
522
+ const jitterRaw = one("--jitter");
523
+ if (jitterRaw !== undefined) {
524
+ const value = parseStrictInt(jitterRaw);
525
+ if (value === null || value < 0 || value > 60_000) {
526
+ fail(`--jitter must be a non-negative integer (ms, at most 60000), got "${jitterRaw}".`);
527
+ }
528
+ config.jitterMs = value;
71
529
  }
72
- const scenariosIdx = args.indexOf("--scenarios");
73
- if (scenariosIdx !== -1 && args[scenariosIdx + 1]) {
74
- config.scenarios = args[scenariosIdx + 1].split(",");
530
+ const scenariosRaw = one("--scenarios");
531
+ if (scenariosRaw !== undefined) {
532
+ config.scenarios = [...new Set(scenariosRaw.split(",").map((s) => s.trim()))];
533
+ }
534
+ const unknown = config.scenarios.filter((id) => !getScenarioById(id));
535
+ if (unknown.length > 0) {
536
+ fail(`unknown scenario(s) "${unknown.join(", ")}". ` +
537
+ `Valid: ${ALL_SCENARIOS.map((s) => s.id).join(", ")}`);
75
538
  }
76
539
  process.stdout.write(`CuonzTech Agent-Harness Benchmark\n`);
540
+ process.stdout.write("NOTE: this replays two fixed built-in reference agents — it does NOT\n" +
541
+ "connect to or measure your own agent. It is a self-test of the harness:\n" +
542
+ "it checks that each reference agent gets the verdict expected for it. To\n" +
543
+ "score YOUR agent, put the harness in front of your MCP server with `proxy` mode.\n\n");
77
544
  process.stdout.write(`Scenarios: ${config.scenarios.join(", ")}\n`);
78
545
  process.stdout.write(`Runs per scenario: ${config.runsPerScenario}\n`);
79
546
  process.stdout.write(`Jitter: ${config.jitterMs}ms\n\n`);
80
547
  const result = await runBenchmark(config);
81
- // Output score to stdout
82
- process.stdout.write(result.markdownScore + "\n");
83
- process.stdout.write(formatCTA(result.score) + "\n\n");
84
- // Output patches if any
85
- if (result.patches.length > 0) {
86
- process.stdout.write(result.markdownPatches + "\n");
548
+ const printRun = (title, run) => {
549
+ process.stdout.write(`${"#".repeat(60)}\n${title}\n${"#".repeat(60)}\n\n`);
550
+ process.stdout.write(run.markdownScore + "\n");
551
+ process.stdout.write(formatCTA(run.score) + "\n\n");
552
+ if (run.patches.length > 0) {
553
+ process.stdout.write(run.markdownPatches + "\n");
554
+ }
555
+ };
556
+ printRun("Reference agent 1/2: CAREFUL (checks after a failure, never re-sends blindly)", result.careful);
557
+ printRun("Reference agent 2/2: NAIVE (re-sends every write immediately, no key)", result.naive);
558
+ if (result.selfTest.passed) {
559
+ const separating = config.scenarios.filter((id) => ["F1", "F2", "F4"].includes(id));
560
+ process.stdout.write("Self-test PASSED: every run got the verdict expected for its reference agent.\n" +
561
+ (separating.length > 0
562
+ ? `The naive agent is expected to fail in ${separating.join(", ")} and did.\n`
563
+ : "Note: in the selected scenarios (F3/F5) the failed attempt never executes, so an " +
564
+ "immediate retry is harmless and BOTH reference agents are expected to pass — " +
565
+ "this selection does not show the harness telling them apart.\n"));
566
+ }
567
+ else {
568
+ process.stdout.write("Self-test FAILED — the harness did not judge its own reference agents as expected:\n" +
569
+ result.selfTest.mismatches.map((m) => ` - ${m}`).join("\n") +
570
+ "\n");
87
571
  }
88
572
  // Save JSON report
89
573
  const reportDir = ".cuonztech";
90
- await mkdir(reportDir, { recursive: true });
91
574
  const jsonPath = `${reportDir}/benchmark-report.json`;
92
- const telemetry = buildTelemetry(result.score, result.patches);
93
- await writeFile(jsonPath, JSON.stringify({
94
- ...telemetry,
95
- score: result.score,
96
- patches: result.patches.map((p) => ({
575
+ const serializeRun = (run) => ({
576
+ ...buildTelemetry(run.score, run.patches),
577
+ score: run.score,
578
+ patches: run.patches.map((p) => ({
97
579
  category: p.category,
98
580
  severity: p.severity,
99
581
  trigger: p.trigger,
100
582
  promptSnippet: p.promptSnippet,
101
583
  })),
102
- reports: result.reports,
103
- generatedAt: new Date().toISOString(),
104
- }, null, 2), "utf-8");
105
- process.stdout.write(`\nReport saved to ${jsonPath}\n`);
106
- // Exit code based on score
107
- process.exit(result.score.overall >= 70 ? 0 : 1);
584
+ reports: run.reports,
585
+ });
586
+ // The exit code reflects the self-test only; a report that cannot be
587
+ // written is a warning, not a different test result.
588
+ try {
589
+ await mkdir(reportDir, { recursive: true });
590
+ await writeFile(jsonPath, JSON.stringify({
591
+ // Same constant under both names: `certification` matches the
592
+ // proxy's report file, `verdict` is kept for readers of older reports.
593
+ certification: "UNVERIFIED_FOR_PRODUCTION",
594
+ verdict: "UNVERIFIED_FOR_PRODUCTION",
595
+ isReferenceRun: true,
596
+ note: "Self-test of the harness with two built-in reference agents. Neither score " +
597
+ "says anything about your own agent.",
598
+ selfTest: result.selfTest,
599
+ careful: serializeRun(result.careful),
600
+ naive: serializeRun(result.naive),
601
+ generatedAt: new Date().toISOString(),
602
+ }, null, 2), "utf-8");
603
+ process.stdout.write(`\nReport saved to ${jsonPath}\n`);
604
+ }
605
+ catch (err) {
606
+ process.stderr.write(`Warning: could not write ${jsonPath}: ${err instanceof Error ? err.message : String(err)}\n`);
607
+ }
608
+ process.stdout.write(`\n${COMING_SOON_NOTICE}\n`);
609
+ // Exit code reflects the self-test, not a score: 0 = the harness judged
610
+ // both reference agents as expected.
611
+ process.exit(result.selfTest.passed ? 0 : 1);
108
612
  }
109
613
  main().catch((err) => {
110
614
  process.stderr.write(`Fatal: ${err}\n`);
111
615
  process.exit(1);
112
616
  });
113
- //# sourceMappingURL=index.js.map