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.
- package/README.md +220 -149
- package/dist/benchmark/cta.js +42 -28
- package/dist/benchmark/runner.js +105 -87
- package/dist/benchmark/score.js +112 -60
- package/dist/core/report-generator.js +454 -257
- package/dist/core/state-engine.js +1 -2
- package/dist/engine/state-diff.js +4 -2
- package/dist/engine/state-machine.js +132 -31
- package/dist/hardening/patch-generator.js +49 -48
- package/dist/index.js +521 -147
- package/dist/proxy/gateway.js +70 -65
- package/dist/proxy/ghost-write.js +37 -10
- package/dist/proxy/interceptor.js +240 -81
- package/dist/scenarios/f1-f5.js +33 -26
- package/dist/scenarios/types.js +0 -1
- package/dist/server/server.js +3 -4
- package/dist/server/stdio.js +0 -1
- package/dist/tools/handlers.js +80 -43
- package/dist/tools/schemas.js +22 -6
- package/dist/version.js +0 -1
- package/package.json +4 -3
- package/dist/benchmark/cta.d.ts +0 -13
- package/dist/benchmark/cta.d.ts.map +0 -1
- package/dist/benchmark/cta.js.map +0 -1
- package/dist/benchmark/runner.d.ts +0 -19
- package/dist/benchmark/runner.d.ts.map +0 -1
- package/dist/benchmark/runner.js.map +0 -1
- package/dist/benchmark/score.d.ts +0 -26
- package/dist/benchmark/score.d.ts.map +0 -1
- package/dist/benchmark/score.js.map +0 -1
- package/dist/core/report-generator.d.ts +0 -47
- package/dist/core/report-generator.d.ts.map +0 -1
- package/dist/core/report-generator.js.map +0 -1
- package/dist/core/state-engine.d.ts +0 -2
- package/dist/core/state-engine.d.ts.map +0 -1
- package/dist/core/state-engine.js.map +0 -1
- package/dist/engine/state-diff.d.ts +0 -33
- package/dist/engine/state-diff.d.ts.map +0 -1
- package/dist/engine/state-diff.js.map +0 -1
- package/dist/engine/state-machine.d.ts +0 -46
- package/dist/engine/state-machine.d.ts.map +0 -1
- package/dist/engine/state-machine.js.map +0 -1
- package/dist/hardening/patch-generator.d.ts +0 -12
- package/dist/hardening/patch-generator.d.ts.map +0 -1
- package/dist/hardening/patch-generator.js.map +0 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/proxy/gateway.d.ts +0 -4
- package/dist/proxy/gateway.d.ts.map +0 -1
- package/dist/proxy/gateway.js.map +0 -1
- package/dist/proxy/ghost-write.d.ts +0 -17
- package/dist/proxy/ghost-write.d.ts.map +0 -1
- package/dist/proxy/ghost-write.js.map +0 -1
- package/dist/proxy/interceptor.d.ts +0 -33
- package/dist/proxy/interceptor.d.ts.map +0 -1
- package/dist/proxy/interceptor.js.map +0 -1
- package/dist/scenarios/f1-f5.d.ts +0 -9
- package/dist/scenarios/f1-f5.d.ts.map +0 -1
- package/dist/scenarios/f1-f5.js.map +0 -1
- package/dist/scenarios/types.d.ts +0 -21
- package/dist/scenarios/types.d.ts.map +0 -1
- package/dist/scenarios/types.js.map +0 -1
- package/dist/server/server.d.ts +0 -3
- package/dist/server/server.d.ts.map +0 -1
- package/dist/server/server.js.map +0 -1
- package/dist/server/stdio.d.ts +0 -7
- package/dist/server/stdio.d.ts.map +0 -1
- package/dist/server/stdio.js.map +0 -1
- package/dist/tools/handlers.d.ts +0 -93
- package/dist/tools/handlers.d.ts.map +0 -1
- package/dist/tools/handlers.js.map +0 -1
- package/dist/tools/schemas.d.ts +0 -38
- package/dist/tools/schemas.d.ts.map +0 -1
- package/dist/tools/schemas.js.map +0 -1
- package/dist/version.d.ts +0 -2
- package/dist/version.d.ts.map +0 -1
- 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
|
-
|
|
46
|
-
|
|
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 =
|
|
65
|
-
//
|
|
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
|
|
195
|
+
let upstreamText;
|
|
69
196
|
let upstreamIsError = false;
|
|
70
197
|
let upstreamExecuted = false;
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
204
|
+
}));
|
|
205
|
+
upstreamResult = result;
|
|
83
206
|
upstreamIsError = Boolean(result.isError);
|
|
84
|
-
const
|
|
85
|
-
|
|
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(
|
|
214
|
+
upstreamResponse = JSON.parse(upstreamText);
|
|
89
215
|
}
|
|
90
216
|
catch {
|
|
91
|
-
upstreamResponse = { raw:
|
|
217
|
+
upstreamResponse = { raw: upstreamText };
|
|
92
218
|
}
|
|
93
219
|
}
|
|
94
220
|
catch (err) {
|
|
95
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
isError: agentIsError,
|
|
170
|
-
};
|
|
329
|
+
this.onCallRecorded?.();
|
|
330
|
+
return result;
|
|
171
331
|
}
|
|
172
332
|
}
|
|
173
|
-
//# sourceMappingURL=interceptor.js.map
|
package/dist/scenarios/f1-f5.js
CHANGED
|
@@ -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: "
|
|
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: {
|
|
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
|
|
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: "
|
|
24
|
-
triggerCondition: ({
|
|
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
|
|
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: "
|
|
41
|
-
triggerCondition: ({
|
|
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
|
-
|
|
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: "
|
|
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
|
|
59
|
+
// F4: Duplicate Dispatch
|
|
50
60
|
export const F4_DUPLICATE_DISPATCH = {
|
|
51
61
|
id: "F4",
|
|
52
62
|
name: "Duplicate Dispatch (Ghost-Write)",
|
|
53
|
-
description: "
|
|
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
|
|
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
|
|
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
|
|
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: "
|
|
79
|
-
triggerCondition: ({
|
|
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: "
|
|
93
|
+
message: "The server encountered an unexpected error.",
|
|
86
94
|
},
|
|
87
95
|
},
|
|
88
|
-
auditMetric: "
|
|
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
|
package/dist/scenarios/types.js
CHANGED
package/dist/server/server.js
CHANGED
|
@@ -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:
|
|
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
|
package/dist/server/stdio.js
CHANGED