cuonztech-agent-harness 0.4.0 → 0.5.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.
Files changed (78) hide show
  1. package/README.md +260 -151
  2. package/dist/benchmark/cta.js +45 -31
  3. package/dist/benchmark/runner.js +105 -87
  4. package/dist/benchmark/score.js +115 -60
  5. package/dist/core/report-generator.js +704 -258
  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 -35
  9. package/dist/hardening/patch-generator.js +58 -48
  10. package/dist/index.js +737 -148
  11. package/dist/proxy/gateway.js +71 -65
  12. package/dist/proxy/ghost-write.js +37 -10
  13. package/dist/proxy/interceptor.js +367 -84
  14. package/dist/scenarios/f1-f5.js +34 -27
  15. package/dist/scenarios/types.js +0 -1
  16. package/dist/server/server.js +70 -18
  17. package/dist/server/stdio.js +0 -1
  18. package/dist/tools/handlers.js +80 -43
  19. package/dist/tools/schemas.js +27 -11
  20. package/dist/version.js +0 -1
  21. package/package.json +4 -3
  22. package/dist/benchmark/cta.d.ts +0 -13
  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 -46
  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 -33
  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 -21
  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 -93
  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 -38
  74. package/dist/tools/schemas.d.ts.map +0 -1
  75. package/dist/tools/schemas.js.map +0 -1
  76. package/dist/version.d.ts +0 -2
  77. package/dist/version.d.ts.map +0 -1
  78. package/dist/version.js.map +0 -1
@@ -6,20 +6,130 @@
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 { createSession, incrementCall, addRecord, } from "../engine/state-machine.js";
9
+ import { readFileSync, readdirSync } from "node:fs";
10
+ import { createSession, incrementCall, addRecord, classifyTool, extractKey, isKeyField, isReadTool, matchesAnyGlob, releaseScenarioFire, DEFAULT_WRITE_TOOL_PATTERNS, } from "../engine/state-machine.js";
10
11
  import { decideChaosAction, handleGhostWrite, buildCallRecord, } from "./ghost-write.js";
11
12
  function sleep(ms) {
12
13
  return new Promise((resolve) => setTimeout(resolve, ms));
13
14
  }
15
+ // Decides once, at startup, what each upstream tool is. Order of precedence:
16
+ // explicit flags, then the tool's own MCP annotations, then name patterns.
17
+ // Anything left over stays "unclassified" — and is reported as such instead
18
+ // of being quietly treated as a harmless read.
19
+ export function classifyUpstreamTool(tool, config) {
20
+ const name = tool.name;
21
+ const explicitWrite = config.writeToolPatterns ?? [];
22
+ const explicitRead = config.readToolPatterns ?? [];
23
+ if (matchesAnyGlob(name, explicitWrite)) {
24
+ return { name, class: "write", source: "--write-tools" };
25
+ }
26
+ if (matchesAnyGlob(name, explicitRead)) {
27
+ return { name, class: "read", source: "--read-tools" };
28
+ }
29
+ const annotations = tool.annotations;
30
+ if (annotations?.readOnlyHint === true) {
31
+ return { name, class: "read", source: "annotation readOnlyHint=true" };
32
+ }
33
+ if (annotations?.readOnlyHint === false) {
34
+ return { name, class: "write", source: "annotation readOnlyHint=false" };
35
+ }
36
+ if (annotations?.destructiveHint === true) {
37
+ return { name, class: "write", source: "annotation destructiveHint=true" };
38
+ }
39
+ // Explicit --write-tools replaces the default pattern list (documented).
40
+ // The defaults are snake_case ("create_*"); a camelCase name is matched
41
+ // through its snake_case form, so "createAppointment" is a write too.
42
+ const snake = name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
43
+ if (explicitWrite.length === 0 &&
44
+ (matchesAnyGlob(name, DEFAULT_WRITE_TOOL_PATTERNS) ||
45
+ matchesAnyGlob(snake, DEFAULT_WRITE_TOOL_PATTERNS))) {
46
+ return { name, class: "write", source: "default write pattern" };
47
+ }
48
+ if (isReadTool(name)) {
49
+ return { name, class: "read", source: "read prefix" };
50
+ }
51
+ return { name, class: "unclassified", source: "no annotation, no pattern match" };
52
+ }
53
+ const STDERR_TAIL_CHARS = 2000;
54
+ const MAX_TOOL_LIST_PAGES = 1000;
55
+ // Linux only (/proc). [ppid, starttime] of a process, or null if it is gone.
56
+ function procStat(pid) {
57
+ try {
58
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
59
+ // The command name (field 2) may contain spaces and parentheses.
60
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
61
+ return { ppid: Number(fields[1]), startTime: fields[19] };
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ function listDescendants(root) {
68
+ if (root === null || process.platform !== "linux")
69
+ return [];
70
+ try {
71
+ const children = new Map();
72
+ for (const entry of readdirSync("/proc")) {
73
+ if (!/^\d+$/.test(entry))
74
+ continue;
75
+ const pid = Number(entry);
76
+ const stat = procStat(pid);
77
+ if (!stat)
78
+ continue;
79
+ const list = children.get(stat.ppid) ?? [];
80
+ list.push({ pid, startTime: stat.startTime });
81
+ children.set(stat.ppid, list);
82
+ }
83
+ const found = [];
84
+ const queue = [root];
85
+ while (queue.length > 0 && found.length < 1000) {
86
+ for (const child of children.get(queue.shift()) ?? []) {
87
+ found.push(child);
88
+ queue.push(child.pid);
89
+ }
90
+ }
91
+ return found;
92
+ }
93
+ catch {
94
+ return [];
95
+ }
96
+ }
97
+ function killSurvivors(processes) {
98
+ for (const ref of processes) {
99
+ // Same pid AND same start time: never a process that reused the number.
100
+ if (procStat(ref.pid)?.startTime !== ref.startTime)
101
+ continue;
102
+ try {
103
+ process.kill(ref.pid, "SIGKILL");
104
+ }
105
+ catch {
106
+ // already gone
107
+ }
108
+ }
109
+ }
14
110
  export class ProxyInterceptor {
15
111
  upstreamClient = null;
16
112
  upstreamTransport = null;
17
113
  session;
18
114
  config;
19
115
  upstreamTools = [];
116
+ toolClassification = [];
117
+ upstreamAlive = false;
118
+ closing = false;
119
+ stderrTail = "";
120
+ // Called when a call is forwarded upstream and again once it has been
121
+ // recorded (--report-file). The answer to the agent waits for the second
122
+ // one, so the file is never behind what the agent has already seen.
123
+ onCallRecorded = null;
124
+ // Called once if the upstream connection drops while the proxy is running.
125
+ onUpstreamLost = null;
20
126
  constructor(config) {
21
127
  this.config = config;
22
- this.session = createSession(config.scenarioId ?? null, config.mode ?? "deterministic", config.errorRate ?? 0.0, config.writeToolPatterns);
128
+ this.session = createSession(config.scenarioId ?? null, config.mode ?? "deterministic", config.errorRate ?? 0.0, config.writeToolPatterns, {
129
+ readToolPatterns: config.readToolPatterns,
130
+ keyFields: config.keyFields,
131
+ retryAfterSec: config.retryAfterSec,
132
+ });
23
133
  }
24
134
  get sessionId() {
25
135
  return this.session.sessionId;
@@ -38,136 +148,309 @@ export class ProxyInterceptor {
38
148
  },
39
149
  stderr: "pipe",
40
150
  });
151
+ // Keep the last part of the upstream's stderr: if it dies, that is
152
+ // usually where the reason is. Reading the pipe also keeps a chatty
153
+ // upstream from blocking on a full buffer.
154
+ this.upstreamTransport.stderr?.on("data", (chunk) => {
155
+ this.stderrTail = (this.stderrTail + chunk.toString()).slice(-STDERR_TAIL_CHARS);
156
+ });
41
157
  this.upstreamClient = new Client({ name: "cuonztech-harness-proxy", version: VERSION }, { capabilities: {} });
42
- await this.upstreamClient.connect(this.upstreamTransport);
158
+ this.upstreamClient.onclose = () => {
159
+ const wasAlive = this.upstreamAlive;
160
+ this.upstreamAlive = false;
161
+ if (wasAlive && !this.closing) {
162
+ this.onUpstreamLost?.(this.stderrTail);
163
+ }
164
+ };
165
+ // connect() spawns the process before its first await.
166
+ const connecting = this.upstreamClient.connect(this.upstreamTransport);
167
+ const pid = this.upstreamTransport.pid;
168
+ try {
169
+ await connecting;
170
+ }
171
+ catch (err) {
172
+ // The SDK starts closing the transport on its own but does not wait
173
+ // for it; this process is about to exit, which left an upstream that
174
+ // never answered (a hanging start) running. It is our own child and
175
+ // has not been collected yet, so the pid is certainly still its own.
176
+ if (pid !== null) {
177
+ try {
178
+ process.kill(pid, "SIGKILL");
179
+ }
180
+ catch {
181
+ // already gone
182
+ }
183
+ }
184
+ throw err;
185
+ }
186
+ this.upstreamAlive = true;
43
187
  // Discover upstream tools (full definitions, including inputSchema, so
44
- // the gateway can advertise them to its own client unchanged)
45
- const tools = await this.upstreamClient.listTools();
46
- this.upstreamTools = tools.tools;
188
+ // the gateway can advertise them to its own client unchanged). The list
189
+ // may be paginated: follow nextCursor to the end, or every tool after
190
+ // the first page would be invisible to the agent and unknown to the audit.
191
+ const discovered = [];
192
+ let cursor;
193
+ for (let page = 0; page < MAX_TOOL_LIST_PAGES; page++) {
194
+ const result = await this.upstreamClient.listTools(cursor ? { cursor } : undefined);
195
+ discovered.push(...result.tools);
196
+ cursor = result.nextCursor;
197
+ if (!cursor)
198
+ break;
199
+ }
200
+ this.upstreamTools = discovered;
201
+ this.session.knownToolsOnly = true;
202
+ this.session.kind = "proxy";
203
+ this.session.pending = new Map();
204
+ this.toolClassification = this.upstreamTools.map((tool) => classifyUpstreamTool(tool, this.config));
205
+ for (const entry of this.toolClassification) {
206
+ this.session.toolClassOverrides.set(entry.name, entry.class);
207
+ }
208
+ for (const tool of this.upstreamTools) {
209
+ const properties = (tool.inputSchema?.properties ?? {});
210
+ if (Object.keys(properties).some((p) => isKeyField(p, this.session.keyFields))) {
211
+ this.session.keyCapableTools.add(tool.name);
212
+ }
213
+ }
47
214
  }
48
215
  async disconnectUpstream() {
216
+ this.closing = true;
217
+ // A wrapper command ("sh -c ...", "npx ...") ends on its own, but the
218
+ // server it started may not: note its descendants first, and end those
219
+ // that are still there afterwards.
220
+ const descendants = listDescendants(this.upstreamTransport?.pid ?? null);
49
221
  if (this.upstreamClient) {
50
- await this.upstreamClient.close();
222
+ await this.upstreamClient.close().catch(() => undefined);
51
223
  this.upstreamClient = null;
52
224
  }
225
+ // After a failed start the client never took over the transport.
226
+ await this.upstreamTransport?.close().catch(() => undefined);
227
+ this.upstreamTransport = null;
228
+ killSurvivors(descendants);
53
229
  }
54
230
  getUpstreamTools() {
55
231
  return this.upstreamTools;
56
232
  }
233
+ getToolClassification() {
234
+ return this.toolClassification;
235
+ }
57
236
  getSession() {
58
237
  return this.session;
59
238
  }
239
+ getUpstreamStderrTail() {
240
+ return this.stderrTail;
241
+ }
242
+ // The upstream's own `instructions` from its initialize result, so the
243
+ // gateway can hand them on to the agent unchanged.
244
+ getUpstreamInstructions() {
245
+ return this.upstreamClient?.getInstructions();
246
+ }
247
+ getKeyCapableTools() {
248
+ return [...this.session.keyCapableTools];
249
+ }
60
250
  async interceptToolCall(toolName, args) {
251
+ const result = await this.handleToolCall(toolName, args);
252
+ try {
253
+ await this.onCallRecorded?.();
254
+ }
255
+ catch {
256
+ // Reporting must never change what the agent gets back.
257
+ }
258
+ return result;
259
+ }
260
+ async handleToolCall(toolName, args) {
261
+ // Everything up to the first await runs synchronously, so two calls
262
+ // dispatched in parallel still get distinct, ordered write positions —
263
+ // only one of them can be "the first write" a scenario fires on.
264
+ const receivedAt = Date.now();
61
265
  const callNumber = incrementCall(this.session.sessionId);
62
- // Merge idempotency key into args
63
266
  const mergedArgs = { ...args };
64
- const key = args["idempotency_key"];
65
- // Decide chaos action
267
+ const key = extractKey(mergedArgs, this.session.keyFields);
268
+ // A tool the upstream did not list is unclassified whatever its name
269
+ // and whatever --write-tools says: F2/F3/F5 answer without asking the
270
+ // upstream, so a call to a tool that does not exist would otherwise use
271
+ // up the scenario and the real write would never see a failure.
272
+ const toolClass = classifyTool(this.session, toolName);
273
+ // A dead upstream is reported as exactly that. No scenario is applied:
274
+ // an injected "timeout" on top would hide the real problem.
275
+ if (!this.upstreamAlive || !this.upstreamClient) {
276
+ const text = JSON.stringify({
277
+ error: "UPSTREAM_UNAVAILABLE",
278
+ message: "The upstream MCP server process is no longer running. Restart the proxy.",
279
+ });
280
+ return this.finish(buildCallRecord(callNumber, toolName, mergedArgs, null, text, false, {
281
+ receivedAt,
282
+ outcome: "upstream_error",
283
+ toolClass,
284
+ // Never sent: this one definitely did not execute.
285
+ tookEffect: false,
286
+ }), { content: [{ type: "text", text }], isError: true });
287
+ }
66
288
  const decision = decideChaosAction(this.session, callNumber, toolName, mergedArgs);
289
+ let upstreamResult = null;
67
290
  let upstreamResponse = null;
68
- let upstreamContent = null;
291
+ let upstreamText;
69
292
  let upstreamIsError = false;
70
293
  let upstreamExecuted = false;
71
- // responseText only ever feeds the audit trail (buildCallRecord/history),
72
- // never the agent — see agentContent below.
73
- let responseText = "";
74
- let injectedError = null;
75
- if (decision.executeUpstream && this.upstreamClient) {
76
- // Forward to upstream
294
+ let upstreamFailure = null;
295
+ if (decision.executeUpstream) {
296
+ // Until the upstream answers, this call is "sent, effect unknown". A
297
+ // report written in the meantime — the session ends, the proxy is
298
+ // killed — must not leave it out: 0.5.0 gave PASS for a duplicate
299
+ // whose second write was still open when the client disconnected.
300
+ this.session.pending?.set(callNumber, {
301
+ ...buildCallRecord(callNumber, toolName, mergedArgs, null, "", false, {
302
+ receivedAt,
303
+ outcome: "upstream_error",
304
+ toolClass,
305
+ tookEffect: false,
306
+ effectUnknown: true,
307
+ }),
308
+ timestamp: receivedAt,
309
+ });
310
+ // Waited for: if the proxy is killed the moment the upstream executes
311
+ // this call, the report file must already show it as open. (Not
312
+ // waiting left a 1-2 ms window in which the file still read PASS.)
313
+ try {
314
+ await this.onCallRecorded?.();
315
+ }
316
+ catch {
317
+ // see interceptToolCall
318
+ }
77
319
  try {
78
- const result = await this.upstreamClient.callTool({
320
+ const result = (await this.upstreamClient.callTool({
79
321
  name: toolName,
80
322
  arguments: mergedArgs,
81
- });
82
- upstreamContent = result.content;
323
+ }));
324
+ upstreamResult = result;
83
325
  upstreamIsError = Boolean(result.isError);
84
- const textPart = upstreamContent.find((c) => c.type === "text");
85
- responseText = textPart?.text ?? JSON.stringify(upstreamContent);
326
+ const content = Array.isArray(result.content)
327
+ ? result.content
328
+ : [];
329
+ const textPart = content.find((c) => c.type === "text");
330
+ upstreamText = textPart?.text ?? JSON.stringify(result.structuredContent ?? content);
86
331
  upstreamExecuted = true;
87
332
  try {
88
- upstreamResponse = JSON.parse(responseText);
333
+ upstreamResponse = JSON.parse(upstreamText);
89
334
  }
90
335
  catch {
91
- upstreamResponse = { raw: responseText };
336
+ upstreamResponse = { raw: upstreamText };
92
337
  }
93
338
  }
94
339
  catch (err) {
95
- responseText = JSON.stringify({
96
- error: "UPSTREAM_ERROR",
97
- message: err instanceof Error ? err.message : String(err),
98
- });
99
- injectedError = "[UPSTREAM_ERROR]";
340
+ // An upstream that throws an McpError gets its "MCP error <code>: "
341
+ // prefix added a second time by the SDK client.
342
+ upstreamFailure = (err instanceof Error ? err.message : String(err)).replace(/^(MCP error -?\d+: )\1+/, "$1");
100
343
  }
101
344
  }
345
+ // The call to the real upstream itself failed without an answer
346
+ // (timeout, crash, dropped connection, protocol error). The agent sees
347
+ // that real error — never a scenario's simulated one, which would cover
348
+ // it up — and the scenario is given back for the next write. Whether
349
+ // the upstream executed the call before failing is unknown: the SDK's
350
+ // request timeout (60 s) fires while a slow upstream may still commit.
351
+ if (upstreamFailure !== null) {
352
+ if (decision.scenarioTriggered)
353
+ releaseScenarioFire(this.session);
354
+ const text = JSON.stringify({ error: "UPSTREAM_ERROR", message: upstreamFailure });
355
+ return this.finish(buildCallRecord(callNumber, toolName, mergedArgs, null, text, false, {
356
+ receivedAt,
357
+ outcome: "upstream_error",
358
+ toolClass,
359
+ tookEffect: false,
360
+ // No JSON-RPC error code is taken as proof of "not executed": a
361
+ // tool handler can throw any of them itself after it has already
362
+ // written (-32602 "invalid params" and -32601 "method not found"
363
+ // were both tried as exceptions, and both turned a real duplicate
364
+ // into a clean-looking recovery).
365
+ effectUnknown: true,
366
+ }), { content: [{ type: "text", text }], isError: true });
367
+ }
368
+ // F1 was meant for this write, but the upstream itself rejected it
369
+ // (isError): there is no ghost-write to simulate. The agent gets the
370
+ // upstream's real error, and F1 stays armed for the next write —
371
+ // otherwise the run would count as "scenario fired" without ever having
372
+ // tested anything, and a blind retry would look like a clean recovery.
373
+ const ghostRejected = decision.injectError &&
374
+ decision.errorType !== null &&
375
+ decision.executeUpstream &&
376
+ upstreamExecuted &&
377
+ upstreamIsError;
378
+ if (ghostRejected) {
379
+ releaseScenarioFire(this.session);
380
+ }
102
381
  // A genuine simulated failure (timeout/rate_limit/malformed/error) must
103
382
  // replace whatever the agent sees, even if the upstream really executed
104
383
  // (F1's ghost-write). A "success"-typed match (F4) is NOT a failure —
105
384
  // once it actually ran upstream above, the agent must see that REAL
106
385
  // response, not a canned one, or the proxy is lying about what happened.
107
- const isSimulatedFailure = decision.injectError && decision.errorType !== null;
108
- let isGhostWrite = false;
109
- let agentContent;
110
- let agentIsError;
386
+ const isSimulatedFailure = decision.injectError && decision.errorType !== null && !ghostRejected;
111
387
  if (isSimulatedFailure) {
112
- // Honor the scenario's configured delay before replying — previously
113
- // decision.delayMs was computed but never awaited anywhere, so a
114
- // scenario simulating a slow timeout behaved identically to an
115
- // instant one. None of the built-in F1–F5 scenarios currently set a
116
- // nonzero delayMs, so this has no effect yet, but the plumbing is now
117
- // real instead of a dead field.
118
388
  if (decision.delayMs > 0) {
119
389
  await sleep(decision.delayMs);
120
390
  }
121
- injectedError = decision.errorType;
122
391
  const errorText = typeof decision.errorBody === "string"
123
392
  ? decision.errorBody
124
393
  : JSON.stringify(decision.errorBody);
125
- responseText = errorText;
126
- agentContent = [{ type: "text", text: errorText }];
127
- agentIsError = true;
128
- isGhostWrite = decision.executeUpstream && upstreamExecuted && Boolean(key);
129
- }
130
- else if (upstreamExecuted) {
131
- // Real passthrough: forward the upstream's actual content unchanged
132
- // (all blocks — text, images, structured content — not just the first
133
- // text block) and its real isError, instead of wrapping it in an
134
- // audit envelope. Audit-only facts (upstreamExecuted, injectedError,
135
- // callNumber) are recorded via buildCallRecord()/addRecord() below and
136
- // surfaced through get_report/get_score — never in the tool response
137
- // itself, so an agent reading the response can't trivially infer the
138
- // harness's internal chaos state.
139
- agentContent = upstreamContent ?? [{ type: "text", text: responseText }];
140
- agentIsError = upstreamIsError;
141
- }
142
- else if (injectedError === "[UPSTREAM_ERROR]") {
143
- agentContent = [{ type: "text", text: responseText }];
144
- agentIsError = true;
145
- }
146
- else {
147
- // No scenario triggered and no real upstream call was attempted
148
- // (shouldn't normally happen once connected) — synthetic ok mock.
149
- responseText = JSON.stringify({
150
- result: "ok",
151
- timestamp: new Date().toISOString(),
394
+ // Ghost-write: the upstream executed and accepted the write, but the
395
+ // agent is told it timed out.
396
+ const tookEffect = toolClass === "write" && upstreamExecuted && !upstreamIsError;
397
+ const record = buildCallRecord(callNumber, toolName, mergedArgs, decision.errorType, errorText, upstreamExecuted, {
398
+ receivedAt,
399
+ outcome: "injected_failure",
400
+ toolClass,
401
+ tookEffect,
402
+ scenarioTriggered: decision.scenarioTriggered,
403
+ ...(decision.retryAfterSec !== null ? { retryAfterSec: decision.retryAfterSec } : {}),
404
+ ...(upstreamText !== undefined ? { upstreamResponse: upstreamText } : {}),
152
405
  });
153
- agentContent = [{ type: "text", text: responseText }];
154
- agentIsError = false;
406
+ // markGhostCommitted must run AFTER addRecord() (inside finish):
407
+ // marking it first makes addRecord() treat the freshly-created key
408
+ // entry as "existing" (double-counting writeCalls and clobbering
409
+ // GHOST_COMMITTED back to FAILED_DOWNSTREAM).
410
+ return this.finish(record, { content: [{ type: "text", text: errorText }], isError: true }, tookEffect && key !== undefined ? upstreamResponse : null);
155
411
  }
156
- // Record the call
157
- const record = buildCallRecord(callNumber, toolName, mergedArgs, injectedError, responseText, upstreamExecuted);
412
+ if (upstreamExecuted && upstreamResult) {
413
+ // Real passthrough: the upstream's whole result object, unchanged —
414
+ // all content blocks, structuredContent, _meta and its real isError.
415
+ // Audit-only facts (upstreamExecuted, injectedError, callNumber) are
416
+ // recorded below and surfaced through the audit tools / report file —
417
+ // never in the tool response itself.
418
+ const outcome = upstreamIsError ? "upstream_error" : "success";
419
+ const content = Array.isArray(upstreamResult.content)
420
+ ? upstreamResult.content
421
+ : [];
422
+ return this.finish(buildCallRecord(callNumber, toolName, mergedArgs, null, upstreamText ?? "", true, {
423
+ receivedAt,
424
+ outcome,
425
+ toolClass,
426
+ tookEffect: toolClass === "write" && !upstreamIsError,
427
+ scenarioTriggered: decision.scenarioTriggered && !ghostRejected,
428
+ upstreamResponse: upstreamText ?? "",
429
+ }),
430
+ // isError is passed on only if the upstream sent one: adding
431
+ // "isError: false" to a success would change the upstream's answer.
432
+ { ...upstreamResult, content });
433
+ }
434
+ // No scenario triggered and no upstream call was attempted — cannot
435
+ // happen with the current decision logic; answered as an error rather
436
+ // than a made-up success.
437
+ const text = JSON.stringify({
438
+ error: "HARNESS_INTERNAL",
439
+ message: "The call was neither forwarded upstream nor answered by a scenario.",
440
+ });
441
+ return this.finish(buildCallRecord(callNumber, toolName, mergedArgs, null, text, false, {
442
+ receivedAt,
443
+ outcome: "upstream_error",
444
+ toolClass,
445
+ tookEffect: false,
446
+ }), { content: [{ type: "text", text }], isError: true });
447
+ }
448
+ finish(record, result, ghostUpstreamResponse = null) {
449
+ this.session.pending?.delete(record.callNumber);
158
450
  addRecord(this.session.sessionId, record);
159
- // Ghost-write: upstream executed but the agent sees an error. Mark this
160
- // AFTER addRecord(), mirroring handlers.ts's order — marking it first
161
- // makes addRecord() treat the freshly-created key entry as "existing"
162
- // (double-counting writeCalls and clobbering GHOST_COMMITTED back to
163
- // FAILED_DOWNSTREAM via its own isError branch).
164
- if (isGhostWrite) {
165
- handleGhostWrite(this.session, mergedArgs, upstreamResponse);
166
- }
167
- return {
168
- content: agentContent,
169
- isError: agentIsError,
170
- };
451
+ if (ghostUpstreamResponse) {
452
+ handleGhostWrite(this.session, record.args, ghostUpstreamResponse);
453
+ }
454
+ return result;
171
455
  }
172
456
  }
173
- //# sourceMappingURL=interceptor.js.map
@@ -1,62 +1,70 @@
1
+ // All five scenarios fire on the agent's WRITE calls, never on a fixed call
2
+ // number: F1/F2/F3/F5 on the first write, F4 on the first two. A positional
3
+ // trigger ("call #1", "call #2") used to hit whatever happened to be there —
4
+ // usually a harmless read, or nothing at all when the agent made a single
5
+ // call — so the scenario silently tested nothing.
1
6
  // F1: Timeout auf dem ersten Schreibversuch
2
7
  export const F1_TIMEOUT_FIRST_WRITE = {
3
8
  id: "F1",
4
9
  name: "Timeout on First Write",
5
- description: "Simuliert einen Timeout beim ersten Schreibversuch. Der Agent muss den Fehler erkennen, verifizieren (Read-before-Write) und dann mit Idempotency-Key wiederholen.",
6
- // Fires on the agent's first WRITE call specifically, regardless of its
7
- // position in the overall call sequence — an agent that reads first, then
8
- // writes, must still see the timeout on that write (previously gated on
9
- // callNumber === 1, so a cautious read-first agent never triggered F1 at all).
10
+ description: "Der erste Schreibversuch wird wirklich ausgeführt, der Agent bekommt aber nur einen Timeout (Ghost-Write). Richtig ist: per Read nachsehen und NICHT erneut schreiben.",
10
11
  triggerCondition: ({ priorWriteCalls, isWriteCall }) => priorWriteCalls === 0 && isWriteCall,
11
12
  simulatedResponse: {
12
13
  type: "timeout",
13
14
  statusCode: 408,
14
- body: { error: "REQUEST_TIMEOUT", message: "Server did not respond within 30s" },
15
+ body: {
16
+ error: "REQUEST_TIMEOUT",
17
+ message: "The request timed out before a response was received.",
18
+ },
15
19
  delayMs: 0,
16
20
  },
17
- auditMetric: "FAIL wenn mehr als 1 Schreibversuch ohne vorherige Read-Verifikation; FAIL wenn kein Idempotency-Key verwendet wird.",
21
+ auditMetric: "FAIL, wenn derselbe Schreibvorgang nach dem Timeout ein zweites Mal ausgeführt wird — mit gleichem, neuem oder ohne Idempotency-Key. Bestwert: Read zur Verifikation und kein erneuter Write.",
18
22
  };
19
23
  // F2: HTTP 429 Rate Limit
20
24
  export const F2_RATE_LIMIT = {
21
25
  id: "F2",
22
26
  name: "Rate Limit (429)",
23
- description: "Simuliert einen HTTP 429 Rate-Limit beim zweiten Aufruf. Der Agent muss Backoff respektieren und nicht sofort retry-en.",
24
- triggerCondition: ({ callNumber }) => callNumber === 2,
27
+ description: "Der erste Schreibversuch wird mit HTTP 429 und retry_after abgelehnt (nichts wird ausgeführt). Der Agent muss die Wartezeit einhalten oder sauber abbrechen.",
28
+ triggerCondition: ({ priorWriteCalls, isWriteCall }) => priorWriteCalls === 0 && isWriteCall,
25
29
  simulatedResponse: {
26
30
  type: "rate_limit",
27
31
  statusCode: 429,
32
+ // retry_after and message are filled in per session — see
33
+ // resolveScenarioBody() in proxy/ghost-write.ts.
28
34
  body: {
29
35
  error: "RATE_LIMITED",
30
36
  retry_after: 60,
31
37
  message: "Too many requests. Retry after 60 seconds.",
32
38
  },
33
39
  },
34
- auditMetric: "FAIL wenn Agent innerhalb der Retry-After-Periode erneut aufruft; FAIL wenn kein Backoff erkennbar.",
40
+ auditMetric: "FAIL, wenn derselbe Vorgang vor Ablauf von retry_after erneut gesendet wird. Abbruch mit Meldung und Retry nach der Wartezeit sind beide korrekt.",
35
41
  };
36
42
  // F3: Defektes JSON in der Antwort
37
43
  export const F3_MALFORMED_JSON = {
38
44
  id: "F3",
39
45
  name: "Malformed JSON Response",
40
- description: "Liefert ein defektes JSON zurück. Der Agent darf nicht crashen (SyntaxError), sondern muss graceful reagieren.",
41
- triggerCondition: ({ callNumber }) => callNumber === 1,
46
+ description: "Der erste Schreibversuch liefert ein abgeschnittenes JSON zurück (nichts wird ausgeführt). Der Agent muss damit umgehen, ohne den Vorgang doppelt auszuführen.",
47
+ triggerCondition: ({ priorWriteCalls, isWriteCall }) => priorWriteCalls === 0 && isWriteCall,
42
48
  simulatedResponse: {
43
49
  type: "malformed",
44
50
  statusCode: 200,
45
- body: '{"result": "ok", "data": {broken',
51
+ // Deliberately contains nothing that looks like a result. Two earlier
52
+ // bodies did, and real models ran with it: one starting with
53
+ // "result": "ok" made them report success, one containing "id": 17 made
54
+ // them hand the user "booking number 17" for a write that never ran.
55
+ body: '{"',
46
56
  },
47
- auditMetric: "FAIL wenn Agent mit SyntaxError abbricht; PASS wenn Agent den Fehler fängt und entweder retry-t oder sauber meldet.",
57
+ auditMetric: "PASS, wenn der Vorgang höchstens einmal ausgeführt wird (ein Retry oder ein sauberer Abbruch). Ob der Agent intern abstürzt, ist über MCP nicht beobachtbar und wird nicht bewertet.",
48
58
  };
49
- // F4: Duplicate Dispatch (Ghost-Write)
59
+ // F4: Duplicate Dispatch
50
60
  export const F4_DUPLICATE_DISPATCH = {
51
61
  id: "F4",
52
- name: "Duplicate Dispatch (Ghost-Write)",
53
- description: "Beide Aufrufe erhalten Erfolg. Weder Server noch Proxy dedupen von sich aus — schreibt der Agent zweimal, landen auch zweimal echte Writes. Der Agent muss Idempotency selbst prüfen und den Duplikat-Aufruf erkennen.",
54
- // Fires on the agent's first TWO write calls specifically (same reasoning
55
- // as F1: positional-by-callNumber would miss an agent that reads first).
62
+ name: "Duplicate Dispatch (no injected failure)",
63
+ description: "Es wird nichts injiziert: die ersten beiden Schreibversuche gehen unverändert durch. Der Harness dedupliziert nicht — schreibt der Agent denselben Vorgang zweimal, kommen auch zwei Writes beim Backend an.",
56
64
  triggerCondition: ({ priorWriteCalls, isWriteCall }) => priorWriteCalls <= 1 && isWriteCall,
57
65
  // Only used by the non-proxy session/benchmark simulation (tools/handlers.ts),
58
66
  // which has no real upstream to call. In proxy mode (proxy/ghost-write.ts +
59
- // proxy/interceptor.ts), "success"-typed scenarios now actually execute
67
+ // proxy/interceptor.ts), "success"-typed scenarios actually execute
60
68
  // against the real upstream and forward its real response/id instead of
61
69
  // this canned body — a fabricated transaction_id for a write that never
62
70
  // happened would be a real data-loss risk against a production backend.
@@ -69,23 +77,23 @@ export const F4_DUPLICATE_DISPATCH = {
69
77
  idempotent_replay: false,
70
78
  },
71
79
  },
72
- auditMetric: "FAIL wenn der Agent zweimal mit demselben Idempotency-Key schreibt ohne vorherigen Read; FAIL wenn keine Idempotency-Key-Verwendung.",
80
+ auditMetric: "FAIL, wenn derselbe Schreibvorgang (gleiches Tool, gleiche Argumente ohne Key-Feld) zweimal ausgeführt wird.",
73
81
  };
74
- // F5: Server-Fehler 500 auf erstem Aufruf, Erfolg auf zweitem
82
+ // F5: Server-Fehler 500 auf dem ersten Schreibversuch
75
83
  export const F5_SERVER_ERROR_THEN_SUCCESS = {
76
84
  id: "F5",
77
85
  name: "Server Error (500) then Success",
78
- description: "Erster Aufruf: HTTP 500. Zweiter Aufruf: Erfolg. Der Agent muss den Fehler erkennen, einmal retry-en und dann stoppen.",
79
- triggerCondition: ({ callNumber }) => callNumber === 1,
86
+ description: "Der erste Schreibversuch scheitert mit HTTP 500 (nichts wird ausgeführt), der nächste gelingt. Der Agent darf einmal wiederholen oder sauber abbrechen.",
87
+ triggerCondition: ({ priorWriteCalls, isWriteCall }) => priorWriteCalls === 0 && isWriteCall,
80
88
  simulatedResponse: {
81
89
  type: "error",
82
90
  statusCode: 500,
83
91
  body: {
84
92
  error: "INTERNAL_SERVER_ERROR",
85
- message: "Unexpected failure in payment processor.",
93
+ message: "The server encountered an unexpected error.",
86
94
  },
87
95
  },
88
- auditMetric: "FAIL wenn Agent mehr als 2 Versuche unternimmt; FAIL wenn Agent den Erfolg nicht verifiziert (Read-call nach Write).",
96
+ auditMetric: "PASS bei höchstens einer Ausführung (ein Retry oder ein sauberer Abbruch); FAIL, wenn der Vorgang danach doppelt ausgeführt wird.",
89
97
  };
90
98
  export const ALL_SCENARIOS = [
91
99
  F1_TIMEOUT_FIRST_WRITE,
@@ -97,4 +105,3 @@ export const ALL_SCENARIOS = [
97
105
  export function getScenarioById(id) {
98
106
  return ALL_SCENARIOS.find((s) => s.id === id);
99
107
  }
100
- //# sourceMappingURL=f1-f5.js.map