cuonztech-agent-harness 0.6.0 → 0.8.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.
@@ -0,0 +1,174 @@
1
+ import { JSONRPCMessageSchema } from "@modelcontextprotocol/sdk/types.js";
2
+ const MAX_BUFFER = 10 * 1024 * 1024;
3
+ const INTEGER_REASON = "an integer beyond 2^53 (9007199254740992)";
4
+ const DECIMAL_REASON = "a number with more digits than a double can hold exactly (beyond 2^53 precision)";
5
+ const JSON_NUMBER = /^-?(0|[1-9]\d*)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/;
6
+ // Why a JSON number token would not survive JSON.parse unchanged, or undefined.
7
+ // Plain floats such as 1.5, 1e3, 0.1 or 129.95 are fine; an integer written as
8
+ // 9007199254740993.0, 9007199254740993E0 or 90071992547409930e-1 is not.
9
+ function numberProblem(token) {
10
+ const match = JSON_NUMBER.exec(token);
11
+ if (!match)
12
+ return undefined;
13
+ const value = Number(token);
14
+ if (!Number.isFinite(value))
15
+ return INTEGER_REASON;
16
+ const intPart = token.replace(/^-/, "").split(/[.eE]/)[0];
17
+ const fraction = match[2] ?? "";
18
+ // Resolve the exponent: digits * 10^scale, without trailing zeros in digits.
19
+ const rawDigits = (intPart + fraction).replace(/^0+/, "");
20
+ const digits = rawDigits.replace(/0+$/, "");
21
+ if (digits === "")
22
+ return undefined; // zero
23
+ const scale = Number(match[3] ?? "0") - fraction.length + (rawDigits.length - digits.length);
24
+ if (value === 0)
25
+ return DECIMAL_REASON; // underflow, e.g. 1e-400
26
+ if (scale >= 0) {
27
+ try {
28
+ return BigInt(Math.abs(value)) === BigInt(digits) * 10n ** BigInt(scale) ? undefined : INTEGER_REASON;
29
+ }
30
+ catch {
31
+ return INTEGER_REASON;
32
+ }
33
+ }
34
+ return digits.length > 17 ? DECIMAL_REASON : undefined;
35
+ }
36
+ // Reasons why a raw JSON text cannot pass through a parse/serialize round trip.
37
+ export function findUnforwardable(json) {
38
+ const reasons = new Set();
39
+ const n = json.length;
40
+ let i = 0;
41
+ while (i < n) {
42
+ const c = json[i];
43
+ if (c === '"') {
44
+ let j = i + 1;
45
+ while (j < n && json[j] !== '"')
46
+ j += json[j] === "\\" ? 2 : 1;
47
+ let k = j + 1;
48
+ while (k < n && /\s/.test(json[k]))
49
+ k++;
50
+ if (json[k] === ":") {
51
+ let key;
52
+ try {
53
+ key = JSON.parse(json.slice(i, j + 1));
54
+ }
55
+ catch {
56
+ key = undefined;
57
+ }
58
+ if (key === "__proto__")
59
+ reasons.add('an argument named "__proto__"');
60
+ }
61
+ i = j + 1;
62
+ }
63
+ else if (c === "-" || (c !== undefined && c >= "0" && c <= "9")) {
64
+ let j = i + 1;
65
+ while (j < n && /[0-9.eE+-]/.test(json[j]))
66
+ j++;
67
+ const token = json.slice(i, j);
68
+ const reason = numberProblem(token);
69
+ if (reason)
70
+ reasons.add(reason);
71
+ i = j;
72
+ }
73
+ else {
74
+ i++;
75
+ }
76
+ }
77
+ return [...reasons];
78
+ }
79
+ export class GuardedStdioServerTransport {
80
+ stdin;
81
+ stdout;
82
+ notify;
83
+ onclose;
84
+ onerror;
85
+ onmessage;
86
+ buffer;
87
+ started = false;
88
+ constructor(stdin = process.stdin, stdout = process.stdout, notify = () => undefined) {
89
+ this.stdin = stdin;
90
+ this.stdout = stdout;
91
+ this.notify = notify;
92
+ }
93
+ ondata = (chunk) => {
94
+ try {
95
+ const size = (this.buffer?.length ?? 0) + chunk.length;
96
+ if (size > MAX_BUFFER) {
97
+ this.buffer = undefined;
98
+ throw new Error(`stdin message exceeded maximum size of ${MAX_BUFFER} bytes`);
99
+ }
100
+ this.buffer = this.buffer ? Buffer.concat([this.buffer, chunk]) : chunk;
101
+ this.drain();
102
+ }
103
+ catch (error) {
104
+ this.onerror?.(error);
105
+ void this.close();
106
+ }
107
+ };
108
+ onstdinerror = (error) => {
109
+ this.onerror?.(error);
110
+ };
111
+ async start() {
112
+ if (this.started)
113
+ throw new Error("GuardedStdioServerTransport already started!");
114
+ this.started = true;
115
+ this.stdin.on("data", this.ondata);
116
+ this.stdin.on("error", this.onstdinerror);
117
+ }
118
+ drain() {
119
+ for (;;) {
120
+ if (!this.buffer)
121
+ return;
122
+ const index = this.buffer.indexOf("\n");
123
+ if (index === -1)
124
+ return;
125
+ const line = this.buffer.toString("utf8", 0, index).replace(/\r$/, "");
126
+ this.buffer = this.buffer.subarray(index + 1);
127
+ try {
128
+ const parsed = JSON.parse(line);
129
+ if (parsed && parsed.method === "tools/call" && (typeof parsed.id === "string" || typeof parsed.id === "number")) {
130
+ const reasons = findUnforwardable(line);
131
+ if (reasons.length > 0) {
132
+ this.notify(`Refused a tools/call (id ${String(parsed.id)}): it contains ${reasons.join(" and ")}, which cannot be forwarded unchanged.`);
133
+ void this.send({
134
+ jsonrpc: "2.0",
135
+ id: parsed.id,
136
+ result: {
137
+ isError: true,
138
+ content: [
139
+ {
140
+ type: "text",
141
+ text: `The test harness did not forward this call: it contains ${reasons.join(" and ")}. ` +
142
+ "JSON processing would have changed the value, so a different operation than the one you meant would have been executed. " +
143
+ "Send such values as strings and call again.",
144
+ },
145
+ ],
146
+ },
147
+ });
148
+ continue;
149
+ }
150
+ }
151
+ this.onmessage?.(JSONRPCMessageSchema.parse(parsed));
152
+ }
153
+ catch (error) {
154
+ this.onerror?.(error);
155
+ }
156
+ }
157
+ }
158
+ async close() {
159
+ this.stdin.off("data", this.ondata);
160
+ this.stdin.off("error", this.onstdinerror);
161
+ if (this.stdin.listenerCount("data") === 0)
162
+ this.stdin.pause();
163
+ this.buffer = undefined;
164
+ this.onclose?.();
165
+ }
166
+ send(message) {
167
+ return new Promise((resolve) => {
168
+ if (this.stdout.write(JSON.stringify(message) + "\n"))
169
+ resolve();
170
+ else
171
+ this.stdout.once("drain", resolve);
172
+ });
173
+ }
174
+ }
@@ -247,8 +247,8 @@ export class ProxyInterceptor {
247
247
  getKeyCapableTools() {
248
248
  return [...this.session.keyCapableTools];
249
249
  }
250
- async interceptToolCall(toolName, args) {
251
- const result = await this.handleToolCall(toolName, args);
250
+ async interceptToolCall(toolName, args, signal) {
251
+ const result = await this.handleToolCall(toolName, args, signal);
252
252
  try {
253
253
  await this.onCallRecorded?.();
254
254
  }
@@ -257,7 +257,7 @@ export class ProxyInterceptor {
257
257
  }
258
258
  return result;
259
259
  }
260
- async handleToolCall(toolName, args) {
260
+ async handleToolCall(toolName, args, signal) {
261
261
  // Everything up to the first await runs synchronously, so two calls
262
262
  // dispatched in parallel still get distinct, ordered write positions —
263
263
  // only one of them can be "the first write" a scenario fires on.
@@ -317,10 +317,12 @@ export class ProxyInterceptor {
317
317
  // see interceptToolCall
318
318
  }
319
319
  try {
320
- const result = (await this.upstreamClient.callTool({
321
- name: toolName,
322
- arguments: mergedArgs,
323
- }));
320
+ // The client cancelled while this call waited (onCallRecorded or a
321
+ // scenario delay). Do not start the upstream write at all.
322
+ if (signal?.aborted) {
323
+ throw new Error("cancelled by client before dispatch");
324
+ }
325
+ const result = (await this.upstreamClient.callTool({ name: toolName, arguments: mergedArgs }, undefined, { signal }));
324
326
  upstreamResult = result;
325
327
  upstreamIsError = Boolean(result.isError);
326
328
  const content = Array.isArray(result.content)
@@ -0,0 +1,123 @@
1
+ // The proxy writes its report to --report-file at startup and after every call,
2
+ // replacing whatever is there. A mistyped path must not turn into the loss of
3
+ // an unrelated file (the upstream's own script, a config, a spreadsheet): an
4
+ // existing file is only replaced if it is empty or an earlier harness report.
5
+ import { existsSync, readFileSync, renameSync, statSync } from "node:fs";
6
+ import { basename, dirname, extname, join } from "node:path";
7
+ const MAX_REPORT_BYTES = 8 * 1024 * 1024;
8
+ export function isReplaceableReportFile(path) {
9
+ try {
10
+ const info = statSync(path, { throwIfNoEntry: false });
11
+ if (!info || !info.isFile())
12
+ return true; // nothing there, or rejected by other checks
13
+ if (info.size === 0)
14
+ return true;
15
+ if (info.size > MAX_REPORT_BYTES)
16
+ return false;
17
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
18
+ return (parsed !== null &&
19
+ typeof parsed === "object" &&
20
+ typeof parsed.harnessVersion === "string");
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ export function notReplaceableMessage(flag, path) {
27
+ return (`${flag} "${path}" already exists and is not an earlier harness report. ` +
28
+ "Refusing to overwrite it. Choose another file name (for example harness-report.json), " +
29
+ "or remove the file yourself if you really want it gone.");
30
+ }
31
+ // Windows device names: writing to "NUL" or "CON" as a relative path can
32
+ // create a real file of that name (or block on a device).
33
+ // Checked for every path segment ("C:\\NUL\\x.json" creates a directory
34
+ // called NUL), and a ":" after the drive letter is refused as well (it
35
+ // addresses an alternate data stream and leaves an empty file behind).
36
+ export function isReservedFileName(path) {
37
+ if (process.platform !== "win32")
38
+ return false;
39
+ // "\\?\C:\dir\file" is an ordinary long-path spelling.
40
+ const withoutDrive = path.replace(/^\\\\\?\\/, "").replace(/^[a-zA-Z]:/, "");
41
+ if (withoutDrive.includes(":"))
42
+ return true;
43
+ // Windows drops a trailing dot or space from a name; the file would not
44
+ // be the one that was asked for.
45
+ if (/[. ]$/.test(withoutDrive))
46
+ return true;
47
+ return withoutDrive
48
+ .split(/[\\/]+/)
49
+ .some((segment) => /^(nul|con|prn|aux|com[1-9]|lpt[1-9])(\..*)?$/i.test(segment));
50
+ }
51
+ export function inspectReportForArchive(parsed) {
52
+ if (parsed === null || typeof parsed !== "object") {
53
+ return { shouldArchive: false, callsCount: 0 };
54
+ }
55
+ const obj = parsed;
56
+ if (typeof obj.harnessVersion !== "string") {
57
+ return { shouldArchive: false, callsCount: 0 };
58
+ }
59
+ // Startup placeholders or errors from earlier runs never get archived,
60
+ // preventing runaway file proliferation on repeated reconnects.
61
+ if (obj.status === "starting" || typeof obj.startupError === "string") {
62
+ return { shouldArchive: false, callsCount: 0 };
63
+ }
64
+ const reportObj = obj.report;
65
+ const calls = Array.isArray(reportObj?.calls) ? reportObj.calls : [];
66
+ const forensics = obj.call_forensics;
67
+ const timeline = Array.isArray(forensics?.timeline) ? forensics.timeline : [];
68
+ const callsCount = Math.max(calls.length, timeline.length);
69
+ const verdict = typeof obj.verdict === "string" ? obj.verdict : undefined;
70
+ // We archive if there is at least one recorded call or an evaluated verdict (PASS / FAIL)
71
+ const shouldArchive = callsCount > 0 || verdict === "PASS" || verdict === "FAIL";
72
+ return {
73
+ shouldArchive,
74
+ verdict,
75
+ callsCount,
76
+ };
77
+ }
78
+ export function computeArchiveReportPath(filePath, now = new Date()) {
79
+ const dir = dirname(filePath);
80
+ const ext = extname(filePath);
81
+ const name = basename(filePath, ext);
82
+ // ISO timestamp with colon replaced by '-' for Windows filesystem compatibility:
83
+ // e.g. 2026-10-30T17-25-00.123Z
84
+ const safeTimestamp = now.toISOString().replace(/:/g, "-");
85
+ let candidate = join(dir, `${name}.${safeTimestamp}${ext || ".json"}`);
86
+ let counter = 1;
87
+ while (existsSync(candidate)) {
88
+ candidate = join(dir, `${name}.${safeTimestamp}.${counter}${ext || ".json"}`);
89
+ counter++;
90
+ }
91
+ return candidate;
92
+ }
93
+ export function archivePreviousReportSync(filePath, now = new Date()) {
94
+ const info = statSync(filePath, { throwIfNoEntry: false });
95
+ if (!info || !info.isFile())
96
+ return null;
97
+ if (info.size === 0 || info.size > MAX_REPORT_BYTES)
98
+ return null;
99
+ let parsed;
100
+ try {
101
+ parsed = JSON.parse(readFileSync(filePath, "utf-8"));
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ const check = inspectReportForArchive(parsed);
107
+ if (!check.shouldArchive)
108
+ return null;
109
+ const archivePath = computeArchiveReportPath(filePath, now);
110
+ try {
111
+ renameSync(filePath, archivePath);
112
+ }
113
+ catch (err) {
114
+ throw new Error(`could not archive previous report file "${filePath}" to "${archivePath}": ` +
115
+ `${err instanceof Error ? err.message : String(err)}`);
116
+ }
117
+ return {
118
+ archivedPath: archivePath,
119
+ verdict: check.verdict,
120
+ callsCount: check.callsCount,
121
+ archivedAt: now.toISOString(),
122
+ };
123
+ }
@@ -0,0 +1,193 @@
1
+ import { classifyUpstreamTool } from "./proxy/interceptor.js";
2
+ // Felder, die auf einen Idempotenz-Schlüssel hindeuten (verglichen ohne
3
+ // Gross-/Kleinschreibung und ohne Trennzeichen: idempotency_key = idempotencyKey).
4
+ const IDEMPOTENCY_FIELDS = new Set([
5
+ "idempotencykey",
6
+ "requestid",
7
+ "clientreferenceid",
8
+ "dedupkey",
9
+ "transactionid",
10
+ ]);
11
+ const IDEMPOTENCY_PATCH = {
12
+ type: "string",
13
+ description: "Eindeutiger Schlüssel zur Vermeidung von Doppelausführungen bei Wiederholungsversuchen",
14
+ };
15
+ // Verben am Anfang eines Tool-Namens; sie sagen nichts über das Objekt, auf das
16
+ // das Tool wirkt ("create_booking" und "list_bookings" betreffen "booking").
17
+ const VERBS = new Set([
18
+ "create", "add", "book", "send", "update", "delete", "remove", "cancel", "submit", "post", "insert",
19
+ "register", "schedule", "transfer", "refund", "reserve", "issue", "withdraw", "deposit", "purchase",
20
+ "approve", "pay", "charge", "confirm", "place", "write", "set", "make", "new", "check", "apply",
21
+ "get", "list", "search", "find", "read", "fetch", "query", "show", "lookup", "view", "count",
22
+ "describe", "retrieve", "browse", "filter",
23
+ ]);
24
+ const STOP_WORDS = new Set(["a", "an", "the", "by", "for", "of", "in", "to", "on", "with", "from", "my", "me"]);
25
+ // Namen, die kein bestimmtes Objekt bezeichnen: ein Tool, das nur aus Verb und
26
+ // einem solchen Wort besteht ("search", "list_all", "query_records"), sucht überall.
27
+ const GENERIC_NOUNS = new Set(["all", "any", "everything", "record", "item", "object", "data", "entity", "resource", "result"]);
28
+ const LOOKUP_VERBS = new Set(["list", "search", "find", "query", "lookup", "browse", "filter", "get", "read", "fetch", "retrieve", "show", "view"]);
29
+ function tokens(name) {
30
+ return name
31
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
32
+ .toLowerCase()
33
+ .split(/[^a-z0-9]+/)
34
+ .filter((t) => t.length > 0);
35
+ }
36
+ function singular(word) {
37
+ if (word.length > 4 && word.endsWith("ies"))
38
+ return `${word.slice(0, -3)}y`;
39
+ if (word.length > 4 && /(ses|xes|zes|ches|shes)$/.test(word))
40
+ return word.slice(0, -2);
41
+ if (word.length > 3 && word.endsWith("s") && !/(ss|us|is)$/.test(word))
42
+ return word.slice(0, -1);
43
+ return word;
44
+ }
45
+ function nouns(name) {
46
+ return tokens(name)
47
+ .filter((t) => !VERBS.has(t) && !STOP_WORDS.has(t))
48
+ .map(singular);
49
+ }
50
+ function isGenericLookup(name) {
51
+ const parts = tokens(name);
52
+ const first = parts[0];
53
+ if (first === undefined || !LOOKUP_VERBS.has(first))
54
+ return false;
55
+ const rest = nouns(name);
56
+ return rest.length === 0 || rest.every((n) => GENERIC_NOUNS.has(n));
57
+ }
58
+ function normalizedFieldName(name) {
59
+ return name.replace(/[^A-Za-z0-9]/g, "").toLowerCase();
60
+ }
61
+ function isIdField(field) {
62
+ return (field.toLowerCase() === "id" ||
63
+ /[_-]id$/i.test(field) ||
64
+ /[a-z0-9]Id$/.test(field) ||
65
+ /(^|[_-])uuid$/i.test(field) ||
66
+ /[a-z0-9]Uuid$/.test(field));
67
+ }
68
+ function propertyNames(tool) {
69
+ const properties = tool.inputSchema?.properties;
70
+ return properties !== null && typeof properties === "object" ? Object.keys(properties) : [];
71
+ }
72
+ function requiresId(tool) {
73
+ const required = tool.inputSchema?.required;
74
+ return Array.isArray(required) && required.some((f) => typeof f === "string" && isIdField(f));
75
+ }
76
+ function hasAnnotations(tool) {
77
+ const a = tool.annotations;
78
+ if (a === undefined || a === null || typeof a !== "object")
79
+ return false;
80
+ return a.readOnlyHint !== undefined || a.destructiveHint !== undefined || a.idempotentHint !== undefined;
81
+ }
82
+ // Ein Tool-Name kommt vom Upstream: Steuerzeichen dürfen in Texten keine Zeilen vortäuschen.
83
+ export function shown(name) {
84
+ return name.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, "?");
85
+ }
86
+ function plural(count, one, many) {
87
+ return count === 1 ? one : many;
88
+ }
89
+ function summarize(total, writes, reads, ambiguous, withoutKey, withoutLookup, high) {
90
+ if (total === 0)
91
+ return "Der Server meldet keine Tools; es gab nichts zu beurteilen.";
92
+ const parts = [
93
+ `Der Server bietet ${total} ${plural(total, "Tool", "Tools")}: ${writes} schreibend, ${reads} lesend` +
94
+ (ambiguous > 0 ? `, ${ambiguous} nicht eindeutig einzuordnen` : "") +
95
+ ".",
96
+ ];
97
+ if (writes + ambiguous === 0) {
98
+ parts.push("Es wurde kein Schreib-Tool erkannt; damit gibt es keine Doppelausführung zu verhindern und kein Szenario, das auslösen könnte.");
99
+ return parts.join(" ");
100
+ }
101
+ parts.push(withoutKey.length === 0
102
+ ? "Alle Schreib-Tools haben ein Feld für einen Idempotenz-Schlüssel."
103
+ : `${withoutKey.length} ${plural(withoutKey.length, "Schreib-Tool hat", "Schreib-Tools haben")} keinen Idempotenz-Schlüssel (${withoutKey.map(shown).join(", ")}): Das Zielsystem kann einen wiederholten Aufruf nicht als Duplikat erkennen.`);
104
+ parts.push(withoutLookup.length === 0
105
+ ? "Für alle Schreib-Tools gibt es ein Lese- oder Such-Tool ohne Pflicht-ID, mit dem ein Agent nach einem Timeout nachsehen kann."
106
+ : `Für ${withoutLookup.length} ${plural(withoutLookup.length, "Schreib-Tool gibt", "Schreib-Tools gibt")} es kein Lese- oder Such-Tool ohne Pflicht-ID (${withoutLookup.map(shown).join(", ")}): Ein Agent kann nach einem Timeout nicht selbstständig prüfen, ob der Vorgang schon ausgeführt wurde.`);
107
+ parts.push(high === 0
108
+ ? "Kein Tool hat die Risikostufe HOCH."
109
+ : `${high} ${plural(high, "Tool hat", "Tools haben")} die Risikostufe HOCH (weder Schlüssel noch Nachsehen ohne ID).`);
110
+ return parts.join(" ");
111
+ }
112
+ export function auditToolSchemas(tools, options = {}) {
113
+ const config = {
114
+ writeToolPatterns: options.writeToolPatterns ?? [],
115
+ readToolPatterns: options.readToolPatterns ?? [],
116
+ };
117
+ const classOf = (tool) => {
118
+ const c = classifyUpstreamTool(tool, config).class;
119
+ return c === "unclassified" ? "ambiguous" : c;
120
+ };
121
+ const classified = tools.map((tool) => ({ tool, classification: classOf(tool) }));
122
+ const readTools = classified.filter((c) => c.classification === "read").map((c) => c.tool);
123
+ const entries = classified.map(({ tool, classification }) => {
124
+ const isWrite = classification !== "read";
125
+ const keyField = propertyNames(tool).find((p) => IDEMPOTENCY_FIELDS.has(normalizedFieldName(p))) ?? null;
126
+ const hasKey = keyField !== null;
127
+ const annotated = hasAnnotations(tool);
128
+ let matching = [];
129
+ if (isWrite) {
130
+ const own = new Set(nouns(tool.name));
131
+ matching = readTools
132
+ .filter((r) => r.name !== tool.name && (nouns(r.name).some((n) => own.has(n)) || isGenericLookup(r.name)))
133
+ .map((r) => r.name);
134
+ }
135
+ const canVerify = isWrite && readTools.some((r) => matching.includes(r.name) && !requiresId(r));
136
+ let risk = "LOW";
137
+ if (isWrite) {
138
+ if (!hasKey && !canVerify)
139
+ risk = "HIGH";
140
+ else if (!hasKey || !canVerify)
141
+ risk = "MEDIUM";
142
+ }
143
+ const findings = [];
144
+ if (isWrite) {
145
+ if (classification === "ambiguous") {
146
+ findings.push("Das Tool lässt sich weder über Annotationen noch über den Namen eindeutig als lesend oder schreibend einordnen und wird hier vorsichtshalber wie ein Schreib-Tool beurteilt.");
147
+ }
148
+ if (!hasKey) {
149
+ findings.push("Kein Idempotenz-Schlüssel (z. B. idempotency_key) im Schema definiert — das Zielsystem kann wiederholte Schreibbefehle nach einem Timeout nicht anhand eines Schlüssels als Duplikat erkennen.");
150
+ }
151
+ else if (keyField !== "idempotency_key") {
152
+ // Der Proxy liest im Lauf nur idempotency_key, andere Feldnamen erst mit --key-fields.
153
+ findings.push(`Hinweis: Das Schlüsselfeld heisst '${shown(keyField)}' statt 'idempotency_key' — beim Proxy-Start --key-fields ${shown(keyField)} angeben, damit der Harness den Schlüssel im Lauf auswertet.`);
154
+ }
155
+ if (!canVerify) {
156
+ findings.push(matching.length === 0
157
+ ? "Kein passendes Lese- oder Such-Tool ohne Pflicht-ID gefunden — der Agent kann nach einem abgebrochenen Schreibbefehl nicht selbstständig prüfen, ob der Vorgang bereits ausgeführt wurde."
158
+ : `Die passenden Lese-Tools (${matching.map(shown).join(", ")}) verlangen alle eine ID als Pflichtfeld — nach einem Timeout kennt der Agent die vom Server vergebene ID noch nicht und kann den Vorgang nicht selbstständig nachschlagen.`);
159
+ }
160
+ if (!annotated) {
161
+ findings.push("Keine MCP-Tool-Annotationen (readOnlyHint / destructiveHint / idempotentHint) hinterlegt.");
162
+ }
163
+ }
164
+ return {
165
+ name: tool.name,
166
+ classification,
167
+ hasIdempotencyKey: hasKey,
168
+ idempotencyFieldName: keyField,
169
+ hasMcpAnnotations: annotated,
170
+ matchingReadTools: matching,
171
+ canVerifyWithoutId: canVerify,
172
+ riskLevel: risk,
173
+ findingsDe: findings,
174
+ recommendedSchemaPatch: isWrite && !hasKey ? { ...IDEMPOTENCY_PATCH } : null,
175
+ };
176
+ });
177
+ const writeLike = entries.filter((e) => e.classification !== "read");
178
+ const writeToolsWithoutIdempotencyKey = writeLike.filter((e) => !e.hasIdempotencyKey).map((e) => e.name);
179
+ const writeToolsWithoutLookupTool = writeLike.filter((e) => !e.canVerifyWithoutId).map((e) => e.name);
180
+ const count = (c) => entries.filter((e) => e.classification === c).length;
181
+ const highRiskToolsCount = entries.filter((e) => e.riskLevel === "HIGH").length;
182
+ return {
183
+ totalTools: entries.length,
184
+ writeToolsCount: count("write"),
185
+ readToolsCount: count("read"),
186
+ ambiguousToolsCount: count("ambiguous"),
187
+ writeToolsWithoutIdempotencyKey,
188
+ writeToolsWithoutLookupTool,
189
+ highRiskToolsCount,
190
+ tools: entries,
191
+ summaryDe: summarize(entries.length, count("write"), count("read"), count("ambiguous"), writeToolsWithoutIdempotencyKey, writeToolsWithoutLookupTool, highRiskToolsCount),
192
+ };
193
+ }
@@ -1,5 +1,10 @@
1
1
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2
- export async function startStdio(server) {
3
- const transport = new StdioServerTransport();
2
+ import { GuardedStdioServerTransport } from "../proxy/guarded-stdio.js";
3
+ // With `guard`, calls that cannot be forwarded unchanged (see
4
+ // proxy/guarded-stdio.ts) are refused; `guard` receives a log line for each.
5
+ export async function startStdio(server, guard) {
6
+ const transport = guard
7
+ ? new GuardedStdioServerTransport(process.stdin, process.stdout, guard.log)
8
+ : new StdioServerTransport();
4
9
  await server.connect(transport);
5
10
  }
@@ -296,7 +296,10 @@ export async function handleGetReport(args, options = {}) {
296
296
  // Resilience Score + Hardening Patches for THIS session's own audit report —
297
297
  // unlike `benchmark` mode (which scores a hardcoded scripted call sequence
298
298
  // against itself), this scores whatever the connected agent actually did.
299
- export function handleGetScore(args) {
299
+ // `patches: false` (Proxy ohne gültige Lizenz): Score und Urteil unverändert, die Härtungsvorschläge
300
+ // (Textbausteine) gehören zum Pro-Umfang und werden durch einen Hinweis ersetzt.
301
+ export function handleGetScore(args, options = {}) {
302
+ const withPatches = options.patches ?? true;
300
303
  const session = getSession(args.session_id);
301
304
  if (!session) {
302
305
  return {
@@ -311,11 +314,13 @@ export function handleGetScore(args) {
311
314
  }
312
315
  const report = generateReport(session);
313
316
  const score = computeScore([report]);
314
- const patches = generatePatches([report], score);
317
+ const patches = withPatches ? generatePatches([report], score) : [];
315
318
  const format = args.format ?? "markdown";
319
+ const lockedNote = "Keine gültige Lizenz: Die Härtungsvorschläge (Textbausteine) gehören zum Pro-Umfang. " +
320
+ "Score und Urteil sind vollständig. Lizenz: https://cuonztech.ch/agent-harness.html#audit";
316
321
  const output = format === "json"
317
- ? JSON.stringify({ score, patches }, null, 2)
318
- : [formatScoreReport(score), patches.length > 0 ? formatPatchReport(patches) : null]
322
+ ? JSON.stringify({ score, patches, ...(withPatches ? {} : { limitedByLicense: true, licenseNotice: lockedNote }) }, null, 2)
323
+ : [formatScoreReport(score), withPatches ? (patches.length > 0 ? formatPatchReport(patches) : null) : lockedNote]
319
324
  .filter(Boolean)
320
325
  .join("\n\n");
321
326
  return {
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ // Read-only discovery of MCP servers the user has already configured on this
2
+ // machine, so step 3 of the UI does not make them hunt for file paths.
3
+ // Privacy: only name, command and args are ever taken from a config file.
4
+ // "env", "headers" and every other field are never read or returned.
5
+ import { readFileSync, statSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join, resolve } from "node:path";
8
+ const MAX_CONFIG_BYTES = 1024 * 1024;
9
+ const MAX_SERVERS = 50;
10
+ const SELF_NAMES = new Set(["agent-harness", "cuonztech-agent-harness"]);
11
+ function desktopConfigPath() {
12
+ if (process.platform === "win32") {
13
+ return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
14
+ }
15
+ if (process.platform === "darwin") {
16
+ return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
17
+ }
18
+ return join(homedir(), ".config", "Claude", "claude_desktop_config.json");
19
+ }
20
+ function candidateFiles() {
21
+ const files = [
22
+ { source: "Claude Desktop", path: desktopConfigPath() },
23
+ { source: "Cursor", path: join(homedir(), ".cursor", "mcp.json") },
24
+ { source: "Cursor (Projekt)", path: resolve(".cursor", "mcp.json") },
25
+ { source: "Claude Code (Projekt)", path: resolve(".mcp.json") },
26
+ ];
27
+ const custom = process.env.CUONZTECH_MCP_CONFIG_PATH;
28
+ if (custom && custom.trim() !== "")
29
+ files.push({ source: "Eigene Konfiguration", path: custom.trim() });
30
+ return files;
31
+ }
32
+ function pointsAtHarness(name, command, args) {
33
+ if (SELF_NAMES.has(name.toLowerCase()))
34
+ return true;
35
+ return [command, ...args].some((part) => part.toLowerCase().includes("cuonztech-agent-harness"));
36
+ }
37
+ export function discoverMcpServers() {
38
+ const found = [];
39
+ const seen = new Set();
40
+ for (const { source, path } of candidateFiles()) {
41
+ try {
42
+ if (statSync(path).size > MAX_CONFIG_BYTES)
43
+ continue;
44
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
45
+ const servers = parsed?.mcpServers;
46
+ if (!servers || typeof servers !== "object" || Array.isArray(servers))
47
+ continue;
48
+ for (const [name, entry] of Object.entries(servers)) {
49
+ if (!entry || typeof entry !== "object")
50
+ continue;
51
+ const { command, args } = entry;
52
+ if (typeof command !== "string" || command.trim() === "")
53
+ continue;
54
+ const argList = Array.isArray(args) ? args.filter((a) => typeof a === "string") : [];
55
+ if (pointsAtHarness(name, command, argList))
56
+ continue;
57
+ const key = JSON.stringify([name, command, argList]);
58
+ if (seen.has(key))
59
+ continue;
60
+ seen.add(key);
61
+ found.push({ name, source, command, args: argList });
62
+ if (found.length >= MAX_SERVERS)
63
+ return found;
64
+ }
65
+ }
66
+ catch {
67
+ // Missing or unreadable file: not an error for discovery.
68
+ }
69
+ }
70
+ return found;
71
+ }