cuonztech-agent-harness 0.8.0 → 0.9.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.
@@ -82,6 +82,10 @@ export const OUTCOMES = {
82
82
  text: "Es bleibt unklar, ob der Vorgang ausgeführt wurde. Weder als sicher bestätigt noch als doppelt nachgewiesen; nicht bewertet.",
83
83
  },
84
84
  DUPLICATE_EXECUTION: { good: false, text: "Der Vorgang wurde mehrfach ausgeführt." },
85
+ DEDUPLICATED_RETRY: {
86
+ good: true,
87
+ text: "Der Vorgang wurde nochmals gesendet, das Zielsystem hat die Wiederholung aber selbst erkannt: Die Wirkungs-Sonde hat keinen zweiten Datensatz gezählt.",
88
+ },
85
89
  RETRY_AFTER_VIOLATED: { good: false, text: "Der Agent hat zu früh erneut gesendet, vor Ablauf der angegebenen Wartezeit." },
86
90
  };
87
91
  export const LIMITS = [
@@ -171,6 +175,30 @@ function claimSection(claim) {
171
175
  lines.push("");
172
176
  return lines;
173
177
  }
178
+ const PROBE_CONCLUSION_DE = {
179
+ confirmed: "bestätigt: Jeder Schreibversuch hat einen Datensatz erzeugt.",
180
+ deduplicated: "dedupliziert: Wiederholte oder gescheiterte Schreibvorgänge haben keinen neuen Datensatz erzeugt. Die Bewertung rechnet mit Datensätzen statt mit Ausführungen.",
181
+ partial: "teilweise: Einige Wiederholungen haben einen Datensatz erzeugt, andere nicht. Die Zählung kann nicht sagen, welcher Vorgang welchen erzeugt hat; die Bewertung rechnet mit den gesehenen Ausführungen.",
182
+ inconclusive: "nicht verwertbar: Die Bewertung rechnet mit den gesehenen Ausführungen.",
183
+ no_writes: "kein Schreibvorgang erreichte das Zielsystem, es gab nichts zu vergleichen.",
184
+ };
185
+ // Wirkungs-Sonde (--effect-probe): was gezählt wurde und was daraus folgt. Ohne
186
+ // Sonde leer.
187
+ export function effectProbeLinesDe(report) {
188
+ const p = report?.effectProbe;
189
+ if (!p)
190
+ return [];
191
+ const count = p.before === null || p.after === null ? "Zählung nicht verfügbar" : `Datensätze vorher ${p.before}, nachher ${p.after}, neu ${p.delta}`;
192
+ const lines = [
193
+ `Der Harness hat das Lese-Tool \`${p.tool.replace(/[`\r\n]/g, "?")}\` selbst aufgerufen und Datensätze gezählt: ${count}. ${p.attempts} Schreibversuch(e) in ${p.operations} Vorgang/Vorgängen erreichten das Zielsystem.`,
194
+ `Schluss: ${PROBE_CONCLUSION_DE[p.conclusion]}`,
195
+ ];
196
+ if (p.conclusion === "inconclusive" || p.conclusion === "partial") {
197
+ lines.push(`Begründung (Originaltext): ${p.note.replace(/[\r\n]+/g, " ")}`);
198
+ }
199
+ lines.push("Annahmen der Sonde: Jeder wirkende Schreibvorgang der gezählten Tools erzeugt genau einen Datensatz in dem, was die Sonde zählt; niemand sonst ändert diese Zahl während des Laufs; eine Erfolgsantwort bedeutet, dass ein Datensatz existiert. Sie sagt nichts über die Absicht des Agenten.");
200
+ return lines;
201
+ }
174
202
  export function renderMarkdown(file, options = {}) {
175
203
  const pro = options.pro ?? true;
176
204
  const { headline, explanation } = verdictText(file);
@@ -209,6 +237,10 @@ export function renderMarkdown(file, options = {}) {
209
237
  }
210
238
  lines.push("");
211
239
  }
240
+ const probeLines = effectProbeLinesDe(report);
241
+ if (probeLines.length > 0) {
242
+ lines.push("## Wirkungs-Sonde", "", ...probeLines.map((l) => `- ${l}`), "");
243
+ }
212
244
  if (file.score) {
213
245
  lines.push("## Kennzahl", "");
214
246
  if (file.score.overall === null || file.score.overall === undefined) {
@@ -0,0 +1,89 @@
1
+ const MAX_DESCRIPTION = 600;
2
+ // A verb counts only where a verb stands in a description: at the start of a
3
+ // clause ("Books a room"), or after a comma or a conjunction ("... and bills the
4
+ // first night", ", or creates one"). The same words elsewhere are mostly nouns
5
+ // ("Lists issues", "Returns charges", "the booking that updates the view").
6
+ // Third person singular present (German: present); the bare form only at the
7
+ // start ("Create a booking").
8
+ const WRITE_VERBS = new Set([
9
+ "creates", "books", "bills", "charges", "sends", "deletes", "removes", "cancels", "updates",
10
+ "modifies", "writes", "inserts", "submits", "pays", "transfers", "refunds", "stores", "saves",
11
+ "persists", "reserves", "registers", "approves", "issues", "withdraws", "deposits", "purchases",
12
+ "places", "confirms", "schedules", "posts", "adds", "executes", "triggers", "publishes", "logs",
13
+ "sets", "notifies", "emails", "uploads", "generates", "archives", "overwrites", "increments", "marks",
14
+ "erstellt", "bucht", "sendet", "löscht", "loescht", "storniert", "ändert", "aendert", "schreibt",
15
+ "speichert", "zahlt", "belastet", "verrechnet", "überweist", "ueberweist", "registriert", "entfernt",
16
+ "schickt", "versendet", "aktualisiert", "reserviert", "bestätigt", "bestaetigt", "setzt", "erzeugt",
17
+ "überschreibt", "ueberschreibt", "verbucht",
18
+ ]);
19
+ const BARE_WRITE_VERBS = new Set([
20
+ "create", "book", "bill", "charge", "send", "delete", "remove", "cancel", "update", "modify",
21
+ "write", "insert", "submit", "pay", "transfer", "refund", "store", "save", "persist", "reserve",
22
+ "register", "approve", "issue", "withdraw", "deposit", "purchase", "place", "confirm", "schedule",
23
+ "post", "add", "execute", "trigger", "publish", "log", "set", "notify", "email", "upload", "generate",
24
+ "archive", "overwrite", "increment", "mark",
25
+ ]);
26
+ const CONJUNCTIONS = new Set(["and", "or", "then", "also", "und", "oder", "dann", "sowie", "danach", "plus"]);
27
+ // A verb inside a negated clause says the opposite ("Never writes or sends anything",
28
+ // "Sendet nichts, löscht nichts").
29
+ const NEGATIONS = new Set([
30
+ "not", "never", "no", "without", "doesn't", "don't", "won't", "nothing", "nobody",
31
+ "nicht", "kein", "keine", "keinen", "ohne", "nie", "niemals", "nichts",
32
+ ]);
33
+ const NEGATION_AFTER = 3; // "bucht nichts", "does nothing"
34
+ function tokenize(clause) {
35
+ // Words and commas; letters by Unicode class (\b knows only ASCII).
36
+ return clause.toLowerCase().match(/[\p{L}\p{N}']+|,/gu) ?? [];
37
+ }
38
+ function describesWriting(description) {
39
+ const text = description.slice(0, MAX_DESCRIPTION).replace(/\s+/g, " ");
40
+ for (const clause of text.split(/[.;!?]+/)) {
41
+ const tokens = tokenize(clause);
42
+ for (let i = 0; i < tokens.length; i++) {
43
+ const word = tokens[i];
44
+ const prev = i > 0 ? tokens[i - 1] : null;
45
+ const inVerbPosition = prev === null || prev === "," || CONJUNCTIONS.has(prev);
46
+ const isVerb = inVerbPosition && (WRITE_VERBS.has(word) || (i === 0 && BARE_WRITE_VERBS.has(word)));
47
+ if (!isVerb)
48
+ continue;
49
+ const negated = tokens.slice(0, i).some((t) => NEGATIONS.has(t)) ||
50
+ tokens.slice(i + 1, i + 1 + NEGATION_AFTER).some((t) => NEGATIONS.has(t));
51
+ if (!negated)
52
+ return word;
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+ // Unambiguous statements only, and only in the FIRST sentence ("The id field is
58
+ // read-only" in a later sentence is about a parameter, not about the tool).
59
+ const READ_ONLY_PHRASES = [
60
+ "read-only", "read only", "readonly", "only reads", "no side effect", "no side-effect",
61
+ "does not modify any", "does not change any", "does not write", "nur lesend", "schreibgeschützt",
62
+ "schreibgeschuetzt", "nur lesen",
63
+ ];
64
+ function firstSentence(description) {
65
+ const text = description.slice(0, MAX_DESCRIPTION).replace(/\s+/g, " ").trim();
66
+ const end = text.search(/[.!?](\s|$)/);
67
+ return (end === -1 ? text : text.slice(0, end + 1)).toLowerCase();
68
+ }
69
+ export function classificationDoubt(tool, cls, source) {
70
+ const description = tool.description;
71
+ if (!description)
72
+ return undefined;
73
+ const nameBased = source === "default write pattern" || source === "read prefix";
74
+ if (!nameBased)
75
+ return undefined;
76
+ if (cls === "read") {
77
+ const verb = describesWriting(description);
78
+ return verb
79
+ ? `taken for a read by its name, but its description says it "${verb}" — if it writes, duplicates through it stay invisible (set --write-tools or the readOnlyHint annotation)`
80
+ : undefined;
81
+ }
82
+ if (cls === "write") {
83
+ const first = firstSentence(description);
84
+ if (READ_ONLY_PHRASES.some((p) => first.includes(p))) {
85
+ return "taken for a write by its name, but its description says it only reads — calls to it may be treated as writes (set --read-tools or the readOnlyHint annotation)";
86
+ }
87
+ }
88
+ return undefined;
89
+ }
@@ -86,6 +86,7 @@ export function createProxyServer(interceptor, options = {}) {
86
86
  isError: true,
87
87
  };
88
88
  }
89
+ await interceptor.refreshEffectProbe(true);
89
90
  const withSession = { session_id: interceptor.sessionId, format };
90
91
  if (name === `${AUDIT_TOOL_PREFIX}get_report`) {
91
92
  return handleGetReport(withSession, { persist: false });
@@ -6,6 +6,8 @@
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 { classificationDoubt } from "./classification-doubt.js";
10
+ import { parseProbeReading } from "../core/effect-probe.js";
9
11
  import { readFileSync, readdirSync } from "node:fs";
10
12
  import { createSession, incrementCall, addRecord, classifyTool, extractKey, isKeyField, isReadTool, matchesAnyGlob, releaseScenarioFire, DEFAULT_WRITE_TOOL_PATTERNS, } from "../engine/state-machine.js";
11
13
  import { decideChaosAction, handleGhostWrite, buildCallRecord, } from "./ghost-write.js";
@@ -17,6 +19,11 @@ function sleep(ms) {
17
19
  // Anything left over stays "unclassified" — and is reported as such instead
18
20
  // of being quietly treated as a harmless read.
19
21
  export function classifyUpstreamTool(tool, config) {
22
+ const result = classifyByRules(tool, config);
23
+ const doubt = classificationDoubt(tool, result.class, result.source);
24
+ return doubt ? { ...result, doubt } : result;
25
+ }
26
+ function classifyByRules(tool, config) {
20
27
  const name = tool.name;
21
28
  const explicitWrite = config.writeToolPatterns ?? [];
22
29
  const explicitRead = config.readToolPatterns ?? [];
@@ -51,6 +58,7 @@ export function classifyUpstreamTool(tool, config) {
51
58
  return { name, class: "unclassified", source: "no annotation, no pattern match" };
52
59
  }
53
60
  const STDERR_TAIL_CHARS = 2000;
61
+ const PROBE_TIMEOUT_MS = 15_000;
54
62
  const MAX_TOOL_LIST_PAGES = 1000;
55
63
  // Linux only (/proc). [ppid, starttime] of a process, or null if it is gone.
56
64
  function procStat(pid) {
@@ -123,6 +131,11 @@ export class ProxyInterceptor {
123
131
  onCallRecorded = null;
124
132
  // Called once if the upstream connection drops while the proxy is running.
125
133
  onUpstreamLost = null;
134
+ // --effect-probe: called when a background reading of the probe has landed,
135
+ // so the report file can be written again with it.
136
+ onProbeRefreshed = null;
137
+ probeTimer = null;
138
+ probeInFlight = null;
126
139
  constructor(config) {
127
140
  this.config = config;
128
141
  this.session = createSession(config.scenarioId ?? null, config.mode ?? "deterministic", config.errorRate ?? 0.0, config.writeToolPatterns, {
@@ -204,6 +217,9 @@ export class ProxyInterceptor {
204
217
  this.toolClassification = this.upstreamTools.map((tool) => classifyUpstreamTool(tool, this.config));
205
218
  for (const entry of this.toolClassification) {
206
219
  this.session.toolClassOverrides.set(entry.name, entry.class);
220
+ if (entry.doubt) {
221
+ (this.session.classificationDoubts ??= new Map()).set(entry.name, entry.doubt);
222
+ }
207
223
  }
208
224
  for (const tool of this.upstreamTools) {
209
225
  const properties = (tool.inputSchema?.properties ?? {});
@@ -227,6 +243,118 @@ export class ProxyInterceptor {
227
243
  this.upstreamTransport = null;
228
244
  killSurvivors(descendants);
229
245
  }
246
+ // --effect-probe: take the baseline reading. Called once after connect, before
247
+ // the first agent call. Throws with a message that says what to fix.
248
+ async initEffectProbe() {
249
+ const cfg = this.config.effectProbe;
250
+ if (!cfg)
251
+ return;
252
+ const entry = this.toolClassification.find((t) => t.name === cfg.tool);
253
+ if (!entry) {
254
+ throw new Error(`--effect-probe "${cfg.tool}": the upstream has no such tool.`);
255
+ }
256
+ if (entry.class !== "read") {
257
+ throw new Error(`--effect-probe "${cfg.tool}": the probe must be a READ tool, but it is taken for ${entry.class === "write" ? "a write" : "a tool that is neither read nor write"} (${entry.source}). The harness calls it itself, repeatedly: a write would change what it measures. Pick a read tool, or classify it with --read-tools.`);
258
+ }
259
+ let reading;
260
+ try {
261
+ reading = await this.readProbe();
262
+ }
263
+ catch (err) {
264
+ throw new Error(`--effect-probe "${cfg.tool}": ${err instanceof Error ? err.message : String(err)}`);
265
+ }
266
+ this.session.effectProbe = {
267
+ ...cfg,
268
+ before: reading.count,
269
+ after: reading.count,
270
+ error: null,
271
+ readAtCalls: 0,
272
+ ...(reading.warning ? { warning: reading.warning } : {}),
273
+ };
274
+ }
275
+ // The probe is read OFF the answer path: after a call a trailing, debounced
276
+ // reading is scheduled, and the report file is written again when it lands.
277
+ // The agent never waits for it, and its timing (retry_after) is not touched.
278
+ // After an error the next scheduled reading waits 10 s.
279
+ scheduleEffectProbeRefresh() {
280
+ const probe = this.session.effectProbe;
281
+ if (!probe || this.probeTimer || this.closing)
282
+ return;
283
+ const wait = probe.error !== null ? 10_000 : 400;
284
+ this.probeTimer = setTimeout(() => {
285
+ this.probeTimer = null;
286
+ void this.refreshEffectProbe().then(() => {
287
+ this.onProbeRefreshed?.();
288
+ // Calls were still open (or arrived meanwhile): the reading is behind.
289
+ if (probe.readAtCalls !== this.session.history.length)
290
+ this.scheduleEffectProbeRefresh();
291
+ });
292
+ }, wait);
293
+ this.probeTimer.unref?.();
294
+ }
295
+ // Latest reading. `force` (a report is being read, the proxy is ending) waits
296
+ // for a reading even right after an error. One reading at a time; a second
297
+ // caller waits for the one in flight and uses it.
298
+ async refreshEffectProbe(force = false) {
299
+ const probe = this.session.effectProbe;
300
+ if (!probe)
301
+ return;
302
+ if (this.probeInFlight)
303
+ await this.probeInFlight;
304
+ const calls = this.session.history.length;
305
+ if (calls === probe.readAtCalls && probe.error === null)
306
+ return;
307
+ if (!force && probe.error !== null && probe.lastAttemptAt !== undefined && Date.now() - probe.lastAttemptAt < 10_000)
308
+ return;
309
+ // Calls still open: a record may be half-written. Leave the old reading;
310
+ // the report treats open calls as "inconclusive".
311
+ if ((this.session.pending?.size ?? 0) > 0)
312
+ return;
313
+ this.probeInFlight = (async () => {
314
+ probe.lastAttemptAt = Date.now();
315
+ try {
316
+ const reading = await this.readProbe();
317
+ probe.after = reading.count;
318
+ probe.error = null;
319
+ if (reading.warning)
320
+ probe.warning = reading.warning;
321
+ else
322
+ delete probe.warning;
323
+ }
324
+ catch (err) {
325
+ probe.error = err instanceof Error ? err.message : String(err);
326
+ }
327
+ // The history length BEFORE the reading: calls that arrived during it
328
+ // make the reading older than the history, and the report says so.
329
+ probe.readAtCalls = calls;
330
+ })();
331
+ try {
332
+ await this.probeInFlight;
333
+ }
334
+ finally {
335
+ this.probeInFlight = null;
336
+ }
337
+ }
338
+ async readProbe() {
339
+ const cfg = this.config.effectProbe;
340
+ if (!cfg)
341
+ throw new Error("no probe configured");
342
+ if (!this.upstreamClient || !this.upstreamAlive) {
343
+ throw new Error("the upstream is not running any more");
344
+ }
345
+ let result;
346
+ try {
347
+ result = await this.upstreamClient.callTool({ name: cfg.tool, arguments: cfg.args }, undefined, { timeout: PROBE_TIMEOUT_MS });
348
+ }
349
+ catch (err) {
350
+ const message = err instanceof Error ? err.message : String(err);
351
+ if (/timed out/i.test(message)) {
352
+ throw new Error(`the probe tool did not answer within ${PROBE_TIMEOUT_MS / 1000} s (use a faster read tool, or narrow it with --effect-probe-args)`);
353
+ }
354
+ throw new Error(`the probe call failed: ${message}`);
355
+ }
356
+ return parseProbeReading(result, cfg.countPath);
357
+ }
230
358
  getUpstreamTools() {
231
359
  return this.upstreamTools;
232
360
  }
@@ -255,6 +383,7 @@ export class ProxyInterceptor {
255
383
  catch {
256
384
  // Reporting must never change what the agent gets back.
257
385
  }
386
+ this.scheduleEffectProbeRefresh();
258
387
  return result;
259
388
  }
260
389
  async handleToolCall(toolName, args, signal) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cuonztech-agent-harness",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "mcpName": "io.github.cuonztech/agent-harness",
5
5
  "description": "Chaos-Proxy für MCP-Server: injiziert F1-F5-Fehlerszenarien (Ghost-Write nach Timeout, 429, defektes JSON, Duplicate Dispatch, 500) vor einem echten Upstream-MCP-Server und prüft, ob ein Agent Schreibvorgänge doppelt ausführt. Mit Resilience Score und System-Prompt-Patches für beobachtetes Fehlverhalten.",
6
6
  "type": "module",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/cuonztech/agent-harness",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.8.0",
9
+ "version": "0.9.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "cuonztech-agent-harness",
14
- "version": "0.8.0",
14
+ "version": "0.9.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }