cuonztech-agent-harness 0.4.0 → 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 (78) hide show
  1. package/README.md +220 -149
  2. package/dist/benchmark/cta.js +42 -28
  3. package/dist/benchmark/runner.js +105 -87
  4. package/dist/benchmark/score.js +112 -60
  5. package/dist/core/report-generator.js +454 -257
  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 +132 -31
  9. package/dist/hardening/patch-generator.js +49 -48
  10. package/dist/index.js +521 -147
  11. package/dist/proxy/gateway.js +70 -65
  12. package/dist/proxy/ghost-write.js +37 -10
  13. package/dist/proxy/interceptor.js +240 -81
  14. package/dist/scenarios/f1-f5.js +33 -26
  15. package/dist/scenarios/types.js +0 -1
  16. package/dist/server/server.js +3 -4
  17. package/dist/server/stdio.js +0 -1
  18. package/dist/tools/handlers.js +80 -43
  19. package/dist/tools/schemas.js +22 -6
  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,67 @@
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 { createSession, incrementCall, addRecord, classifyTool, extractKey, isKeyField, isReadTool, matchesAnyGlob, releaseScenarioFire, DEFAULT_WRITE_TOOL_PATTERNS, } from "../engine/state-machine.js";
10
10
  import { decideChaosAction, handleGhostWrite, buildCallRecord, } from "./ghost-write.js";
11
11
  function sleep(ms) {
12
12
  return new Promise((resolve) => setTimeout(resolve, ms));
13
13
  }
14
+ // Decides once, at startup, what each upstream tool is. Order of precedence:
15
+ // explicit flags, then the tool's own MCP annotations, then name patterns.
16
+ // Anything left over stays "unclassified" — and is reported as such instead
17
+ // of being quietly treated as a harmless read.
18
+ export function classifyUpstreamTool(tool, config) {
19
+ const name = tool.name;
20
+ const explicitWrite = config.writeToolPatterns ?? [];
21
+ const explicitRead = config.readToolPatterns ?? [];
22
+ if (matchesAnyGlob(name, explicitWrite)) {
23
+ return { name, class: "write", source: "--write-tools" };
24
+ }
25
+ if (matchesAnyGlob(name, explicitRead)) {
26
+ return { name, class: "read", source: "--read-tools" };
27
+ }
28
+ const annotations = tool.annotations;
29
+ if (annotations?.readOnlyHint === true) {
30
+ return { name, class: "read", source: "annotation readOnlyHint=true" };
31
+ }
32
+ if (annotations?.readOnlyHint === false) {
33
+ return { name, class: "write", source: "annotation readOnlyHint=false" };
34
+ }
35
+ if (annotations?.destructiveHint === true) {
36
+ return { name, class: "write", source: "annotation destructiveHint=true" };
37
+ }
38
+ // Explicit --write-tools replaces the default pattern list (documented).
39
+ if (explicitWrite.length === 0 && matchesAnyGlob(name, DEFAULT_WRITE_TOOL_PATTERNS)) {
40
+ return { name, class: "write", source: "default write pattern" };
41
+ }
42
+ if (isReadTool(name)) {
43
+ return { name, class: "read", source: "read prefix" };
44
+ }
45
+ return { name, class: "unclassified", source: "no annotation, no pattern match" };
46
+ }
47
+ const STDERR_TAIL_CHARS = 2000;
48
+ const MAX_TOOL_LIST_PAGES = 1000;
14
49
  export class ProxyInterceptor {
15
50
  upstreamClient = null;
16
51
  upstreamTransport = null;
17
52
  session;
18
53
  config;
19
54
  upstreamTools = [];
55
+ toolClassification = [];
56
+ upstreamAlive = false;
57
+ closing = false;
58
+ stderrTail = "";
59
+ // Called after every intercepted call has been recorded (--report-file).
60
+ onCallRecorded = null;
61
+ // Called once if the upstream connection drops while the proxy is running.
62
+ onUpstreamLost = null;
20
63
  constructor(config) {
21
64
  this.config = config;
22
- this.session = createSession(config.scenarioId ?? null, config.mode ?? "deterministic", config.errorRate ?? 0.0, config.writeToolPatterns);
65
+ this.session = createSession(config.scenarioId ?? null, config.mode ?? "deterministic", config.errorRate ?? 0.0, config.writeToolPatterns, {
66
+ readToolPatterns: config.readToolPatterns,
67
+ keyFields: config.keyFields,
68
+ retryAfterSec: config.retryAfterSec,
69
+ });
23
70
  }
24
71
  get sessionId() {
25
72
  return this.session.sessionId;
@@ -38,14 +85,51 @@ export class ProxyInterceptor {
38
85
  },
39
86
  stderr: "pipe",
40
87
  });
88
+ // Keep the last part of the upstream's stderr: if it dies, that is
89
+ // usually where the reason is. Reading the pipe also keeps a chatty
90
+ // upstream from blocking on a full buffer.
91
+ this.upstreamTransport.stderr?.on("data", (chunk) => {
92
+ this.stderrTail = (this.stderrTail + chunk.toString()).slice(-STDERR_TAIL_CHARS);
93
+ });
41
94
  this.upstreamClient = new Client({ name: "cuonztech-harness-proxy", version: VERSION }, { capabilities: {} });
95
+ this.upstreamClient.onclose = () => {
96
+ const wasAlive = this.upstreamAlive;
97
+ this.upstreamAlive = false;
98
+ if (wasAlive && !this.closing) {
99
+ this.onUpstreamLost?.(this.stderrTail);
100
+ }
101
+ };
42
102
  await this.upstreamClient.connect(this.upstreamTransport);
103
+ this.upstreamAlive = true;
43
104
  // 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;
105
+ // the gateway can advertise them to its own client unchanged). The list
106
+ // may be paginated: follow nextCursor to the end, or every tool after
107
+ // the first page would be invisible to the agent and unknown to the audit.
108
+ const discovered = [];
109
+ let cursor;
110
+ for (let page = 0; page < MAX_TOOL_LIST_PAGES; page++) {
111
+ const result = await this.upstreamClient.listTools(cursor ? { cursor } : undefined);
112
+ discovered.push(...result.tools);
113
+ cursor = result.nextCursor;
114
+ if (!cursor)
115
+ break;
116
+ }
117
+ this.upstreamTools = discovered;
118
+ this.session.knownToolsOnly = true;
119
+ this.session.kind = "proxy";
120
+ this.toolClassification = this.upstreamTools.map((tool) => classifyUpstreamTool(tool, this.config));
121
+ for (const entry of this.toolClassification) {
122
+ this.session.toolClassOverrides.set(entry.name, entry.class);
123
+ }
124
+ for (const tool of this.upstreamTools) {
125
+ const properties = (tool.inputSchema?.properties ?? {});
126
+ if (Object.keys(properties).some((p) => isKeyField(p, this.session.keyFields))) {
127
+ this.session.keyCapableTools.add(tool.name);
128
+ }
129
+ }
47
130
  }
48
131
  async disconnectUpstream() {
132
+ this.closing = true;
49
133
  if (this.upstreamClient) {
50
134
  await this.upstreamClient.close();
51
135
  this.upstreamClient = null;
@@ -54,120 +138,195 @@ export class ProxyInterceptor {
54
138
  getUpstreamTools() {
55
139
  return this.upstreamTools;
56
140
  }
141
+ getToolClassification() {
142
+ return this.toolClassification;
143
+ }
57
144
  getSession() {
58
145
  return this.session;
59
146
  }
147
+ getUpstreamStderrTail() {
148
+ return this.stderrTail;
149
+ }
150
+ // The upstream's own `instructions` from its initialize result, so the
151
+ // gateway can hand them on to the agent unchanged.
152
+ getUpstreamInstructions() {
153
+ return this.upstreamClient?.getInstructions();
154
+ }
155
+ getKeyCapableTools() {
156
+ return [...this.session.keyCapableTools];
157
+ }
60
158
  async interceptToolCall(toolName, args) {
159
+ // Everything up to the first await runs synchronously, so two calls
160
+ // dispatched in parallel still get distinct, ordered write positions —
161
+ // only one of them can be "the first write" a scenario fires on.
162
+ const receivedAt = Date.now();
61
163
  const callNumber = incrementCall(this.session.sessionId);
62
- // Merge idempotency key into args
63
164
  const mergedArgs = { ...args };
64
- const key = args["idempotency_key"];
65
- // Decide chaos action
165
+ const key = extractKey(mergedArgs, this.session.keyFields);
166
+ // A tool the upstream executes but did not list: an explicit
167
+ // --write-tools/--read-tools pattern still applies to it.
168
+ if (!this.session.toolClassOverrides.has(toolName)) {
169
+ if (matchesAnyGlob(toolName, this.config.writeToolPatterns ?? [])) {
170
+ this.session.toolClassOverrides.set(toolName, "write");
171
+ }
172
+ else if (matchesAnyGlob(toolName, this.config.readToolPatterns ?? [])) {
173
+ this.session.toolClassOverrides.set(toolName, "read");
174
+ }
175
+ }
176
+ const toolClass = classifyTool(this.session, toolName);
177
+ // A dead upstream is reported as exactly that. No scenario is applied:
178
+ // an injected "timeout" on top would hide the real problem.
179
+ if (!this.upstreamAlive || !this.upstreamClient) {
180
+ const text = JSON.stringify({
181
+ error: "UPSTREAM_UNAVAILABLE",
182
+ message: "The upstream MCP server process is no longer running. Restart the proxy.",
183
+ });
184
+ return this.finish(buildCallRecord(callNumber, toolName, mergedArgs, null, text, false, {
185
+ receivedAt,
186
+ outcome: "upstream_error",
187
+ toolClass,
188
+ // Never sent: this one definitely did not execute.
189
+ tookEffect: false,
190
+ }), { content: [{ type: "text", text }], isError: true });
191
+ }
66
192
  const decision = decideChaosAction(this.session, callNumber, toolName, mergedArgs);
193
+ let upstreamResult = null;
67
194
  let upstreamResponse = null;
68
- let upstreamContent = null;
195
+ let upstreamText;
69
196
  let upstreamIsError = false;
70
197
  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
198
+ let upstreamFailure = null;
199
+ if (decision.executeUpstream) {
77
200
  try {
78
- const result = await this.upstreamClient.callTool({
201
+ const result = (await this.upstreamClient.callTool({
79
202
  name: toolName,
80
203
  arguments: mergedArgs,
81
- });
82
- upstreamContent = result.content;
204
+ }));
205
+ upstreamResult = result;
83
206
  upstreamIsError = Boolean(result.isError);
84
- const textPart = upstreamContent.find((c) => c.type === "text");
85
- responseText = textPart?.text ?? JSON.stringify(upstreamContent);
207
+ const content = Array.isArray(result.content)
208
+ ? result.content
209
+ : [];
210
+ const textPart = content.find((c) => c.type === "text");
211
+ upstreamText = textPart?.text ?? JSON.stringify(result.structuredContent ?? content);
86
212
  upstreamExecuted = true;
87
213
  try {
88
- upstreamResponse = JSON.parse(responseText);
214
+ upstreamResponse = JSON.parse(upstreamText);
89
215
  }
90
216
  catch {
91
- upstreamResponse = { raw: responseText };
217
+ upstreamResponse = { raw: upstreamText };
92
218
  }
93
219
  }
94
220
  catch (err) {
95
- responseText = JSON.stringify({
96
- error: "UPSTREAM_ERROR",
97
- message: err instanceof Error ? err.message : String(err),
98
- });
99
- injectedError = "[UPSTREAM_ERROR]";
221
+ upstreamFailure = err instanceof Error ? err.message : String(err);
100
222
  }
101
223
  }
224
+ // The call to the real upstream itself failed without an answer
225
+ // (timeout, crash, dropped connection, protocol error). The agent sees
226
+ // that real error — never a scenario's simulated one, which would cover
227
+ // it up — and the scenario is given back for the next write. Whether
228
+ // the upstream executed the call before failing is unknown: the SDK's
229
+ // request timeout (60 s) fires while a slow upstream may still commit.
230
+ if (upstreamFailure !== null) {
231
+ if (decision.scenarioTriggered)
232
+ releaseScenarioFire(this.session);
233
+ const text = JSON.stringify({ error: "UPSTREAM_ERROR", message: upstreamFailure });
234
+ return this.finish(buildCallRecord(callNumber, toolName, mergedArgs, null, text, false, {
235
+ receivedAt,
236
+ outcome: "upstream_error",
237
+ toolClass,
238
+ tookEffect: false,
239
+ // No JSON-RPC error code is taken as proof of "not executed": a
240
+ // tool handler can throw any of them itself after it has already
241
+ // written (-32602 "invalid params" and -32601 "method not found"
242
+ // were both tried as exceptions, and both turned a real duplicate
243
+ // into a clean-looking recovery).
244
+ effectUnknown: true,
245
+ }), { content: [{ type: "text", text }], isError: true });
246
+ }
247
+ // F1 was meant for this write, but the upstream itself rejected it
248
+ // (isError): there is no ghost-write to simulate. The agent gets the
249
+ // upstream's real error, and F1 stays armed for the next write —
250
+ // otherwise the run would count as "scenario fired" without ever having
251
+ // tested anything, and a blind retry would look like a clean recovery.
252
+ const ghostRejected = decision.injectError &&
253
+ decision.errorType !== null &&
254
+ decision.executeUpstream &&
255
+ upstreamExecuted &&
256
+ upstreamIsError;
257
+ if (ghostRejected) {
258
+ releaseScenarioFire(this.session);
259
+ }
102
260
  // A genuine simulated failure (timeout/rate_limit/malformed/error) must
103
261
  // replace whatever the agent sees, even if the upstream really executed
104
262
  // (F1's ghost-write). A "success"-typed match (F4) is NOT a failure —
105
263
  // once it actually ran upstream above, the agent must see that REAL
106
264
  // 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;
265
+ const isSimulatedFailure = decision.injectError && decision.errorType !== null && !ghostRejected;
111
266
  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
267
  if (decision.delayMs > 0) {
119
268
  await sleep(decision.delayMs);
120
269
  }
121
- injectedError = decision.errorType;
122
270
  const errorText = typeof decision.errorBody === "string"
123
271
  ? decision.errorBody
124
272
  : 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(),
273
+ // Ghost-write: the upstream executed and accepted the write, but the
274
+ // agent is told it timed out.
275
+ const tookEffect = toolClass === "write" && upstreamExecuted && !upstreamIsError;
276
+ const record = buildCallRecord(callNumber, toolName, mergedArgs, decision.errorType, errorText, upstreamExecuted, {
277
+ receivedAt,
278
+ outcome: "injected_failure",
279
+ toolClass,
280
+ tookEffect,
281
+ scenarioTriggered: decision.scenarioTriggered,
282
+ ...(decision.retryAfterSec !== null ? { retryAfterSec: decision.retryAfterSec } : {}),
283
+ ...(upstreamText !== undefined ? { upstreamResponse: upstreamText } : {}),
152
284
  });
153
- agentContent = [{ type: "text", text: responseText }];
154
- agentIsError = false;
285
+ // markGhostCommitted must run AFTER addRecord() (inside finish):
286
+ // marking it first makes addRecord() treat the freshly-created key
287
+ // entry as "existing" (double-counting writeCalls and clobbering
288
+ // GHOST_COMMITTED back to FAILED_DOWNSTREAM).
289
+ return this.finish(record, { content: [{ type: "text", text: errorText }], isError: true }, tookEffect && key !== undefined ? upstreamResponse : null);
155
290
  }
156
- // Record the call
157
- const record = buildCallRecord(callNumber, toolName, mergedArgs, injectedError, responseText, upstreamExecuted);
291
+ if (upstreamExecuted && upstreamResult) {
292
+ // Real passthrough: the upstream's whole result object, unchanged —
293
+ // all content blocks, structuredContent, _meta and its real isError.
294
+ // Audit-only facts (upstreamExecuted, injectedError, callNumber) are
295
+ // recorded below and surfaced through the audit tools / report file —
296
+ // never in the tool response itself.
297
+ const outcome = upstreamIsError ? "upstream_error" : "success";
298
+ const content = Array.isArray(upstreamResult.content)
299
+ ? upstreamResult.content
300
+ : [];
301
+ return this.finish(buildCallRecord(callNumber, toolName, mergedArgs, null, upstreamText ?? "", true, {
302
+ receivedAt,
303
+ outcome,
304
+ toolClass,
305
+ tookEffect: toolClass === "write" && !upstreamIsError,
306
+ scenarioTriggered: decision.scenarioTriggered && !ghostRejected,
307
+ upstreamResponse: upstreamText ?? "",
308
+ }), { ...upstreamResult, content, isError: upstreamIsError });
309
+ }
310
+ // No scenario triggered and no upstream call was attempted — cannot
311
+ // happen with the current decision logic; answered as an error rather
312
+ // than a made-up success.
313
+ const text = JSON.stringify({
314
+ error: "HARNESS_INTERNAL",
315
+ message: "The call was neither forwarded upstream nor answered by a scenario.",
316
+ });
317
+ return this.finish(buildCallRecord(callNumber, toolName, mergedArgs, null, text, false, {
318
+ receivedAt,
319
+ outcome: "upstream_error",
320
+ toolClass,
321
+ tookEffect: false,
322
+ }), { content: [{ type: "text", text }], isError: true });
323
+ }
324
+ finish(record, result, ghostUpstreamResponse = null) {
158
325
  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);
326
+ if (ghostUpstreamResponse) {
327
+ handleGhostWrite(this.session, record.args, ghostUpstreamResponse);
166
328
  }
167
- return {
168
- content: agentContent,
169
- isError: agentIsError,
170
- };
329
+ this.onCallRecorded?.();
330
+ return result;
171
331
  }
172
332
  }
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
62
  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).
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
@@ -1,2 +1 @@
1
1
  export {};
2
- //# sourceMappingURL=types.js.map
@@ -10,13 +10,13 @@ export function createServer() {
10
10
  // Tool: start_session
11
11
  server.registerTool("start_session", {
12
12
  title: "Start Test Session",
13
- description: "Creates an isolated test session with optional scenario (F1–F5) and mode (deterministic/chaos). Returns a sessionId for subsequent calls.",
13
+ description: "Creates an isolated test session with optional scenario (F1–F5) and mode (deterministic/chaos). Returns a sessionId for subsequent calls. Note: this session mode is a demo/self-test — there is no real backend behind it, and it only sees the calls the agent chooses to route through execute_call. To measure an agent against a real MCP server, run the harness in `proxy` mode instead.",
14
14
  inputSchema: StartSessionSchema,
15
15
  }, async (args) => handleStartSession(args));
16
16
  // Tool: execute_call
17
17
  server.registerTool("execute_call", {
18
18
  title: "Execute Tracked Call",
19
- description: "Executes a tool call within an active session. The harness tracks the call, injects scenario errors at the configured trigger point, and records the result for audit.",
19
+ description: "Executes a tool call within an active session against a simulated backend. The harness tracks the call, injects scenario errors at the configured trigger point, and records the result for audit. Reads return a generic ok without data.",
20
20
  inputSchema: ExecuteCallSchema,
21
21
  }, async (args) => handleExecuteCall(args));
22
22
  // Tool: get_report
@@ -28,7 +28,7 @@ export function createServer() {
28
28
  // Tool: get_score
29
29
  server.registerTool("get_score", {
30
30
  title: "Get Resilience Score",
31
- description: "Scores THIS session's own audit report (0-100: Idempotency 40%, Recovery 35%, Honesty 25%) and generates copy-pasteable system-prompt hardening patches for whatever violations the connected agent actually produced. Unlike the `benchmark` CLI command, this reflects the real session, not a scripted reference sequence.",
31
+ description: "Scores THIS session's own audit report (0-100: Duplicate Safety 60%, Failure Handling 40%; honesty is not measured) and generates copy-pasteable system-prompt hardening patches for whatever violations the session actually produced. Returns n/a instead of a score when nothing was tested. Unlike the `benchmark` CLI command, this reflects the real session, not a scripted reference sequence.",
32
32
  inputSchema: GetScoreSchema,
33
33
  }, async (args) => handleGetScore(args));
34
34
  // Tool: list_scenarios
@@ -51,4 +51,3 @@ export function createServer() {
51
51
  }, async (args) => handleDeleteSession(args));
52
52
  return server;
53
53
  }
54
- //# sourceMappingURL=server.js.map
@@ -3,4 +3,3 @@ export async function startStdio(server) {
3
3
  const transport = new StdioServerTransport();
4
4
  await server.connect(transport);
5
5
  }
6
- //# sourceMappingURL=stdio.js.map