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.
- package/LICENSE +220 -21
- package/LICENSE-MIT-bis-0.7.0.txt +21 -0
- package/README.md +82 -26
- package/dist/benchmark/cta.js +3 -3
- package/dist/campaign/runner.js +551 -0
- package/dist/core/artifact-integrity.js +92 -0
- package/dist/core/call-forensics.js +166 -0
- package/dist/core/claim-check.js +212 -0
- package/dist/core/report-generator.js +66 -1
- package/dist/doctor.js +36 -7
- package/dist/dossier-report.js +631 -0
- package/dist/hardening/patch-generator.js +30 -0
- package/dist/index.js +145 -30
- package/dist/license/ed25519.js +57 -0
- package/dist/license/gate.js +91 -0
- package/dist/license/index.js +5 -0
- package/dist/license/publicKey.js +5 -0
- package/dist/license/types.js +1 -0
- package/dist/license/verifier.js +89 -0
- package/dist/plain-report.js +283 -27
- package/dist/proxy/gateway.js +4 -3
- package/dist/proxy/guarded-stdio.js +174 -0
- package/dist/proxy/interceptor.js +9 -7
- package/dist/report-file-guard.js +123 -0
- package/dist/schema-audit.js +193 -0
- package/dist/server/stdio.js +7 -2
- package/dist/tools/handlers.js +9 -4
- package/dist/types.js +1 -0
- package/dist/ui/discover.js +71 -0
- package/dist/ui/page.js +89 -7
- package/dist/ui/server.js +175 -13
- package/examples/testsystem/server.mjs +127 -0
- package/launchers/Start-Agent-Harness.cmd +23 -0
- package/launchers/start-agent-harness.sh +18 -0
- package/package.json +5 -2
- package/server.json +2 -2
|
@@ -18,6 +18,26 @@ const TIMEOUT_PROTOCOL = `A timeout on a write does NOT tell you whether the ser
|
|
|
18
18
|
`3. Record found → the write succeeded. Report it and do not write again.\n` +
|
|
19
19
|
`4. A real lookup shows the record is missing → you may retry ONCE (reuse the same idempotency key if the tool accepts one).\n` +
|
|
20
20
|
`5. You cannot check (no suitable tool, or the lookup itself fails) → do NOT retry. Tell the user the outcome is unknown and must be verified first.`;
|
|
21
|
+
// Deutscher Baustein für den System-Prompt des Agenten (Schweizer
|
|
22
|
+
// Rechtschreibung, ss statt ß). Dieselbe Regel wie TIMEOUT_PROTOCOL, mit den
|
|
23
|
+
// Tool-Namen des geprüften Servers. Er ist ein Vorschlag, kein bewiesener Fix:
|
|
24
|
+
// den Lauf mit dem Baustein wiederholen und vergleichen.
|
|
25
|
+
export function generatePromptPatchDe(results, writeTools, readTools) {
|
|
26
|
+
const list = (names) => names.map((n) => n.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, "?")).join(", ");
|
|
27
|
+
const lines = ["Umgang mit Schreibvorgängen bei Timeout, Fehler oder unklarem Ergebnis:", ""];
|
|
28
|
+
if (writeTools.length > 0)
|
|
29
|
+
lines.push(`Schreibende Tools: ${list(writeTools)}.`);
|
|
30
|
+
if (readTools.length > 0)
|
|
31
|
+
lines.push(`Lesende Tools zum Nachsehen: ${list(readTools)}.`);
|
|
32
|
+
if (writeTools.length > 0 || readTools.length > 0)
|
|
33
|
+
lines.push("");
|
|
34
|
+
const doubled = results.flatMap((r) => r.violations).filter((v) => v.type === "DUPLICATE_EXECUTION");
|
|
35
|
+
if (doubled.length > 0) {
|
|
36
|
+
lines.push(`Im Test wurde ${doubled.length === 1 ? "ein Vorgang" : `${doubled.length} Vorgänge`} doppelt ausgeführt. Halte dich deshalb strikt an diese Regeln:`, "");
|
|
37
|
+
}
|
|
38
|
+
lines.push("1. Ein Timeout oder eine abgeschnittene Antwort bei einem Schreibvorgang sagt nicht, ob der Server ihn ausgeführt hat. Er kann bereits erledigt sein.", "2. Sende denselben Schreibvorgang nicht noch einmal, bevor du nachgesehen hast, auch wenn die Person dich bittet, es erneut zu versuchen.", "3. Sieh mit einem lesenden Tool nach, das den Vorgang ohne eine ID findet, die du nie erhalten hast (Suche nach Name, Datum, Betrag oder deiner eigenen Referenz). Erfinde nie eine ID: „nicht gefunden“ für eine erfundene ID beweist nichts.", "4. Ist der Vorgang vorhanden, war der Schreibvorgang erfolgreich. Melde das und schreibe nicht noch einmal.", "5. Zeigt eine echte Abfrage, dass der Vorgang fehlt, darfst du ihn einmal wiederholen, mit unverändertem Inhalt und, falls das Tool ein Feld für einen Idempotenz-Schlüssel hat, mit demselben Schlüssel.", "6. Ändere beim Wiederholen keinen Wert (Betrag, Datum, Schreibweise). Ein geänderter Aufruf ist ein neuer Vorgang.", "7. Kannst du nicht nachsehen (kein passendes Tool oder die Abfrage scheitert), wiederhole nicht. Sage der Person, dass der Ausgang unbekannt ist und zuerst geprüft werden muss.", "8. Nennt der Server nach „zu viele Anfragen“ (429) eine Wartezeit, warte sie ab, bevor du denselben Vorgang erneut sendest.", "9. Melde nie „nicht ausgeführt“, wenn du es nicht geprüft hast.");
|
|
39
|
+
return lines.join("\n");
|
|
40
|
+
}
|
|
21
41
|
export function generatePatches(reports, _score) {
|
|
22
42
|
const patches = [];
|
|
23
43
|
const duplicates = reports.flatMap((r) => r.violations.filter((v) => v.type === "DUPLICATE_EXECUTION"));
|
|
@@ -35,8 +55,12 @@ export function generatePatches(reports, _score) {
|
|
|
35
55
|
trigger: afterTimeout.length > 0
|
|
36
56
|
? `Agent executed the same write again after a timeout ${afterTimeout.length} time(s) — the server had already executed the first attempt.`
|
|
37
57
|
: `Agent sent a write again ${lookalikes.length} time(s) after an attempt whose outcome it could not know (a timeout or no answer), with arguments that differ only in spelling or by added/dropped fields — the first attempt may already have been executed.`,
|
|
58
|
+
trigger_de: afterTimeout.length > 0
|
|
59
|
+
? `Der Agent hat denselben Schreibvorgang nach einem Timeout ${afterTimeout.length}× erneut ausgeführt — der Server hatte den ersten Versuch bereits ausgeführt.`
|
|
60
|
+
: `Der Agent hat ${lookalikes.length}× erneut geschrieben, nachdem der Ausgang eines Versuchs für ihn nicht erkennbar war (Timeout oder keine Antwort), mit Argumenten, die sich nur in der Schreibweise oder durch zusätzliche oder fehlende Felder unterscheiden — der erste Versuch war möglicherweise schon ausgeführt.`,
|
|
38
61
|
promptSnippet: `CRITICAL — Timeout on a write ("ghost write"):\n${TIMEOUT_PROTOCOL}`,
|
|
39
62
|
toolGuidance: `Offer a lookup that works without the ID returned by the write (by customer, date, reference), and make write tools idempotent: same key + same payload = same result, no second effect.`,
|
|
63
|
+
toolGuidance_de: `Ein Nachschlage-Tool anbieten, das ohne die vom Schreibvorgang zurückgegebene ID funktioniert (nach Kunde, Datum oder Referenz), und Schreib-Tools idempotent machen: gleicher Schlüssel und gleicher Inhalt ergeben dasselbe Ergebnis, ohne zweite Wirkung.`,
|
|
40
64
|
});
|
|
41
65
|
}
|
|
42
66
|
// Duplicate without any timeout involved: the same write went out twice.
|
|
@@ -46,8 +70,10 @@ export function generatePatches(reports, _score) {
|
|
|
46
70
|
category: "IDEMPOTENCY",
|
|
47
71
|
severity: "critical",
|
|
48
72
|
trigger: `Agent sent the same write ${afterSuccess.length} more time(s) although the harness had reported no failure for it.`,
|
|
73
|
+
trigger_de: `Der Agent hat denselben Schreibvorgang ${afterSuccess.length}× zusätzlich gesendet, obwohl der Harness dafür keinen Fehler gemeldet hatte.`,
|
|
49
74
|
promptSnippet: `CRITICAL — One write per operation:\nSend each write exactly once. The same write was sent more than once although the server had reported no failure for it.\n1. Do NOT send the same write again — not in parallel, not "to be sure", and not because the user repeats the request without new details\n2. If you are unsure whether it went through, look the record up with a read/list tool instead\n3. Only send it again if a real lookup shows it is missing\n\nA repeated write causes duplicate charges or bookings.`,
|
|
50
75
|
toolGuidance: `Return an identifier for the created record in every write response, so it can be referenced and looked up.`,
|
|
76
|
+
toolGuidance_de: `Jede Schreibantwort sollte eine Kennung des angelegten Datensatzes enthalten, damit er referenziert und nachgeschlagen werden kann.`,
|
|
51
77
|
});
|
|
52
78
|
}
|
|
53
79
|
const retryAfter = reports.flatMap((r) => r.violations.filter((v) => v.type === "RETRY_AFTER_VIOLATED"));
|
|
@@ -56,8 +82,10 @@ export function generatePatches(reports, _score) {
|
|
|
56
82
|
category: "RATE_LIMIT",
|
|
57
83
|
severity: "critical",
|
|
58
84
|
trigger: `Agent re-sent a rate-limited operation before retry_after had elapsed ${retryAfter.length} time(s).`,
|
|
85
|
+
trigger_de: `Der Agent hat einen wegen zu vieler Anfragen abgelehnten Vorgang ${retryAfter.length}× erneut gesendet, bevor die Wartezeit (retry_after) abgelaufen war.`,
|
|
59
86
|
promptSnippet: `CRITICAL — Rate Limits:\nWhen a tool answers with a rate limit (HTTP 429) and a retry_after value:\n1. Do NOT call that tool again before retry_after has elapsed\n2. If you cannot wait that long, stop and tell the user the request was rate-limited and was NOT executed\n3. Never retry immediately "just to see"`,
|
|
60
87
|
toolGuidance: `Return retry_after as a structured field (seconds), not only inside a message string.`,
|
|
88
|
+
toolGuidance_de: `retry_after als strukturiertes Feld (Sekunden) zurückgeben, nicht nur innerhalb eines Meldungstextes.`,
|
|
61
89
|
});
|
|
62
90
|
}
|
|
63
91
|
const unverified = reports.flatMap((r) => r.operations.filter((op) => op.outcome === "UNVERIFIED_AFTER_TIMEOUT"));
|
|
@@ -68,8 +96,10 @@ export function generatePatches(reports, _score) {
|
|
|
68
96
|
category: "VERIFICATION",
|
|
69
97
|
severity: "warning",
|
|
70
98
|
trigger: `Agent left ${unverified.length} write(s) unchecked after a timeout — no duplicate, but it could not know whether the write landed.`,
|
|
99
|
+
trigger_de: `Der Agent hat ${unverified.length} ${unverified.length === 1 ? "Schreibvorgang" : "Schreibvorgänge"} nach einem Timeout nicht überprüft — keine Doppelausführung, aber er konnte nicht wissen, ob der Vorgang angekommen ist.`,
|
|
71
100
|
promptSnippet: `WARNING — Check after a timeout:\n${TIMEOUT_PROTOCOL}\nNever tell the user a timed-out write "was not executed" unless a real lookup showed that.`,
|
|
72
101
|
toolGuidance: `Offer a lookup that works without the ID returned by the write (by customer, date, reference), so a result can be checked after a lost response.`,
|
|
102
|
+
toolGuidance_de: `Ein Nachschlage-Tool anbieten, das ohne die vom Schreibvorgang zurückgegebene ID funktioniert (nach Kunde, Datum oder Referenz), damit sich das Ergebnis nach einer verlorenen Antwort prüfen lässt.`,
|
|
73
103
|
});
|
|
74
104
|
}
|
|
75
105
|
return patches;
|
package/dist/index.js
CHANGED
|
@@ -4,13 +4,19 @@ import { startStdio } from "./server/stdio.js";
|
|
|
4
4
|
import { runBenchmark, defaultConfig } from "./benchmark/runner.js";
|
|
5
5
|
import { formatCTA, buildTelemetry, COMING_SOON_NOTICE } from "./benchmark/cta.js";
|
|
6
6
|
import { computeScore } from "./benchmark/score.js";
|
|
7
|
-
import { generatePatches } from "./hardening/patch-generator.js";
|
|
7
|
+
import { generatePatches, generatePromptPatchDe } from "./hardening/patch-generator.js";
|
|
8
|
+
import { auditToolSchemas } from "./schema-audit.js";
|
|
8
9
|
import { generateReport } from "./core/report-generator.js";
|
|
10
|
+
import { buildCallForensics } from "./core/call-forensics.js";
|
|
11
|
+
import { computeReportIntegrity } from "./core/artifact-integrity.js";
|
|
9
12
|
import { ProxyInterceptor } from "./proxy/interceptor.js";
|
|
10
13
|
import { runDoctor } from "./doctor.js";
|
|
11
14
|
import { runReportMode } from "./plain-report.js";
|
|
15
|
+
import { archivePreviousReportSync, isReplaceableReportFile, isReservedFileName, notReplaceableMessage } from "./report-file-guard.js";
|
|
12
16
|
import { runUiMode } from "./ui/server.js";
|
|
17
|
+
import { runCampaignMode } from "./campaign/runner.js";
|
|
13
18
|
import { createProxyServer, findToolNameCollisions } from "./proxy/gateway.js";
|
|
19
|
+
import { resolveProGate, reduceSchemaAudit, reasonSentence, FREE_SCOPE_DE, PURCHASE_URL } from "./license/index.js";
|
|
14
20
|
import { getScenarioById, ALL_SCENARIOS } from "./scenarios/f1-f5.js";
|
|
15
21
|
import { VERSION } from "./version.js";
|
|
16
22
|
import { writeFile, mkdir, rename, stat, realpath, unlink } from "node:fs/promises";
|
|
@@ -30,12 +36,16 @@ function printHelp() {
|
|
|
30
36
|
` Check the setup before a run (Node version, server\n` +
|
|
31
37
|
` start, tool classification, report file). Calls only\n` +
|
|
32
38
|
` initialize and tools/list; runs no tool.\n` +
|
|
33
|
-
` cuonztech-agent-harness ui [--port <n>] [--no-open]\n` +
|
|
39
|
+
` cuonztech-agent-harness ui [--port <n>] [--no-open] (Pro: needs a license)\n` +
|
|
34
40
|
` Guided web interface for people without a terminal\n` +
|
|
35
41
|
` (local only, 127.0.0.1): demo, setup check, result.\n` +
|
|
36
42
|
` cuonztech-agent-harness report <report-file> [--format md|html] [--out <file>]\n` +
|
|
37
43
|
` Turn the proxy's --report-file into a plain-language\n` +
|
|
38
44
|
` page (German): result, what the agent did, limits.\n` +
|
|
45
|
+
` cuonztech-agent-harness campaign [--runs <n>] [--format md|html] [--out <file>] (Pro)\n` +
|
|
46
|
+
` Before/after comparison of three fixed reference\n` +
|
|
47
|
+
` profiles incl. the two-turn user-pressure scenario\n` +
|
|
48
|
+
` (German report; measures no foreign agent).\n` +
|
|
39
49
|
` cuonztech-agent-harness proxy --upstream-command <cmd> [opts]\n` +
|
|
40
50
|
` Run as a transparent chaos proxy\n` +
|
|
41
51
|
` in front of a real upstream MCP server\n\n` +
|
|
@@ -56,6 +66,10 @@ function printHelp() {
|
|
|
56
66
|
` The value may contain commas.\n` +
|
|
57
67
|
` --upstream-env K=V,K2=V2 Comma-separated env vars (values cannot contain commas)\n` +
|
|
58
68
|
` --scenario F1..F5 Activate one deterministic scenario\n` +
|
|
69
|
+
` --allow-writes Required with --scenario and with --mode chaos (error\n` +
|
|
70
|
+
` rate > 0): confirms that --upstream-command points at a\n` +
|
|
71
|
+
` disposable test/staging system. The agent's writes are\n` +
|
|
72
|
+
` executed there for real.\n` +
|
|
59
73
|
` --mode deterministic|chaos chaos = stochastic error injection (set --error-rate)\n` +
|
|
60
74
|
` --error-rate <0..1> Error probability in chaos mode\n` +
|
|
61
75
|
` --retry-after <seconds> retry_after announced by F2's 429 (default: 60;\n` +
|
|
@@ -78,6 +92,14 @@ function printHelp() {
|
|
|
78
92
|
`Tool classification in proxy mode: explicit flags first, then the upstream's own\n` +
|
|
79
93
|
`MCP annotations (readOnlyHint/destructiveHint), then name patterns. The result is\n` +
|
|
80
94
|
`printed to stderr at startup; unclassified tools are passed through but not scored.\n\n` +
|
|
95
|
+
` --license <key> Licence key (or env AGENT_AUDIT_LICENSE), see "Lizenz" below\n\n` +
|
|
96
|
+
`Lizenz (Schlüssel CAUDIT-..., Prüfung offline):\n` +
|
|
97
|
+
` Der Lauf selbst ist immer vollständig; Urteil, Score und Exit-Code sind echt.\n` +
|
|
98
|
+
` Frei: ${FREE_SCOPE_DE}.\n` +
|
|
99
|
+
` Pro: report --dossier (ohne Lizenz nur Kopf, Score, Urteil), ui, campaign, Schema-Befunde\n` +
|
|
100
|
+
` und Textbausteine (Härtungsvorschläge) im Report.\n` +
|
|
101
|
+
` Angabe per --license <key> (proxy, report, ui, campaign) oder Umgebungsvariable AGENT_AUDIT_LICENSE.\n` +
|
|
102
|
+
` Lizenz erwerben: ${PURCHASE_URL}\n\n` +
|
|
81
103
|
`The proxy is a long-running server: its exit code says nothing about the verdict.\n` +
|
|
82
104
|
`Read the verdict from --report-file. Exit code 1 means the run itself broke\n` +
|
|
83
105
|
`(bad arguments, upstream did not start, report file not writable).\n\n` +
|
|
@@ -110,6 +132,18 @@ function parseFlags(argv, spec) {
|
|
|
110
132
|
if (!kind) {
|
|
111
133
|
return { error: `unknown option "${token}".` };
|
|
112
134
|
}
|
|
135
|
+
// A switch without a value (--allow-writes): "--allow-writes=yes" and a
|
|
136
|
+
// repeat are errors, and the next token is not swallowed as its value.
|
|
137
|
+
if (kind === "boolean") {
|
|
138
|
+
if (inlineValue !== undefined) {
|
|
139
|
+
return { error: `option ${token} takes no value.` };
|
|
140
|
+
}
|
|
141
|
+
if (values.has(token)) {
|
|
142
|
+
return { error: `option ${token} was given more than once.` };
|
|
143
|
+
}
|
|
144
|
+
values.set(token, ["true"]);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
113
147
|
let value;
|
|
114
148
|
if (inlineValue !== undefined) {
|
|
115
149
|
value = inlineValue;
|
|
@@ -158,6 +192,21 @@ function parseFlags(argv, spec) {
|
|
|
158
192
|
// failed start — including a rejected command line — replaces a report left
|
|
159
193
|
// over from an earlier run instead of leaving its old PASS in place.
|
|
160
194
|
let startupReportFile = null;
|
|
195
|
+
let archivedPreviousRun = null;
|
|
196
|
+
let archiveAttempted = false;
|
|
197
|
+
function tryArchivePreviousReport() {
|
|
198
|
+
if (archiveAttempted || !startupReportFile)
|
|
199
|
+
return;
|
|
200
|
+
archiveAttempted = true;
|
|
201
|
+
try {
|
|
202
|
+
archivedPreviousRun = archivePreviousReportSync(startupReportFile);
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
// Fail closed: could not archive existing real report
|
|
206
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
161
210
|
// mkdir -p without { recursive: true }: on Linux pseudo file systems
|
|
162
211
|
// (/proc/…) Node's recursive mkdir never returns and spins at full CPU.
|
|
163
212
|
// Here every level is created once and the first error ends it.
|
|
@@ -194,6 +243,7 @@ function writeStartupStateSync(extra) {
|
|
|
194
243
|
if (!startupReportFile)
|
|
195
244
|
return;
|
|
196
245
|
try {
|
|
246
|
+
tryArchivePreviousReport();
|
|
197
247
|
// Only ever a regular file (or nothing yet): writing "the report" over
|
|
198
248
|
// a directory, a device node or a pipe is never what was meant.
|
|
199
249
|
const existing = statSync(startupReportFile, { throwIfNoEntry: false });
|
|
@@ -206,6 +256,16 @@ function writeStartupStateSync(extra) {
|
|
|
206
256
|
harnessVersion: VERSION,
|
|
207
257
|
certification: "UNVERIFIED_FOR_PRODUCTION",
|
|
208
258
|
verdict: "NOT_TESTED",
|
|
259
|
+
...(archivedPreviousRun
|
|
260
|
+
? {
|
|
261
|
+
previousRun: {
|
|
262
|
+
file: archivedPreviousRun.archivedPath,
|
|
263
|
+
verdict: archivedPreviousRun.verdict,
|
|
264
|
+
callsCount: archivedPreviousRun.callsCount,
|
|
265
|
+
archivedAt: archivedPreviousRun.archivedAt,
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
: {}),
|
|
209
269
|
...extra,
|
|
210
270
|
generatedAt: new Date().toISOString(),
|
|
211
271
|
}, null, 2), "utf-8");
|
|
@@ -236,26 +296,6 @@ function fail(message) {
|
|
|
236
296
|
process.stderr.write(`Error: ${message}\nRun with --help for usage.\n`);
|
|
237
297
|
process.exit(1);
|
|
238
298
|
}
|
|
239
|
-
// Windows device names: writing to "NUL" or "CON" as a relative path can
|
|
240
|
-
// create a real file of that name (or block on a device).
|
|
241
|
-
// Checked for every path segment ("C:\\NUL\\x.json" creates a directory
|
|
242
|
-
// called NUL), and a ":" after the drive letter is refused as well (it
|
|
243
|
-
// addresses an alternate data stream and leaves an empty file behind).
|
|
244
|
-
function isReservedFileName(path) {
|
|
245
|
-
if (process.platform !== "win32")
|
|
246
|
-
return false;
|
|
247
|
-
// "\\?\C:\dir\file" is an ordinary long-path spelling.
|
|
248
|
-
const withoutDrive = path.replace(/^\\\\\?\\/, "").replace(/^[a-zA-Z]:/, "");
|
|
249
|
-
if (withoutDrive.includes(":"))
|
|
250
|
-
return true;
|
|
251
|
-
// Windows drops a trailing dot or space from a name; the file would not
|
|
252
|
-
// be the one that was asked for.
|
|
253
|
-
if (/[. ]$/.test(withoutDrive))
|
|
254
|
-
return true;
|
|
255
|
-
return withoutDrive
|
|
256
|
-
.split(/[\\/]+/)
|
|
257
|
-
.some((segment) => /^(nul|con|prn|aux|com[1-9]|lpt[1-9])(\..*)?$/i.test(segment));
|
|
258
|
-
}
|
|
259
299
|
// Best-effort lookup of --report-file before the full parse, so a command
|
|
260
300
|
// line that is rejected by the parser still updates that file.
|
|
261
301
|
// Walks the arguments the way the parser does, so that the VALUE of another
|
|
@@ -270,7 +310,8 @@ function peekReportFile(argv, spec) {
|
|
|
270
310
|
// Followed by another option: there is no path (the parser rejects it).
|
|
271
311
|
return value === undefined || value.startsWith("--") ? null : value;
|
|
272
312
|
}
|
|
273
|
-
|
|
313
|
+
// A switch (--allow-writes) takes no value: the next token is not one.
|
|
314
|
+
if (spec[token] && spec[token] !== "boolean")
|
|
274
315
|
i++;
|
|
275
316
|
}
|
|
276
317
|
return null;
|
|
@@ -304,6 +345,9 @@ async function main() {
|
|
|
304
345
|
else if (mode === "report") {
|
|
305
346
|
process.exitCode = await runReportMode(args.slice(1));
|
|
306
347
|
}
|
|
348
|
+
else if (mode === "campaign") {
|
|
349
|
+
process.exitCode = await runCampaignMode(args.slice(1));
|
|
350
|
+
}
|
|
307
351
|
else if (mode === "ui") {
|
|
308
352
|
process.exitCode = await runUiMode(args.slice(1));
|
|
309
353
|
}
|
|
@@ -315,7 +359,7 @@ async function main() {
|
|
|
315
359
|
await startStdio(server);
|
|
316
360
|
}
|
|
317
361
|
else {
|
|
318
|
-
fail(`unknown command "${mode}". Commands: benchmark, proxy, --help (no command = MCP server).`);
|
|
362
|
+
fail(`unknown command "${mode}". Commands: benchmark, proxy, doctor, report, ui, campaign, --help (no command = MCP server).`);
|
|
319
363
|
}
|
|
320
364
|
}
|
|
321
365
|
const PROXY_FLAGS = {
|
|
@@ -334,6 +378,8 @@ const PROXY_FLAGS = {
|
|
|
334
378
|
"--key-fields": "single",
|
|
335
379
|
"--audit-tools": "single",
|
|
336
380
|
"--report-file": "single",
|
|
381
|
+
"--allow-writes": "boolean",
|
|
382
|
+
"--license": "single",
|
|
337
383
|
};
|
|
338
384
|
function parseEnvPair(pair, flag, into) {
|
|
339
385
|
const eq = pair.indexOf("=");
|
|
@@ -350,13 +396,15 @@ async function runProxyMode(argv) {
|
|
|
350
396
|
return;
|
|
351
397
|
}
|
|
352
398
|
const peeked = peekReportFile(argv, PROXY_FLAGS);
|
|
353
|
-
if (peeked && peeked.trim() !== "" && !isReservedFileName(peeked)) {
|
|
399
|
+
if (peeked && peeked.trim() !== "" && !isReservedFileName(peeked) && isReplaceableReportFile(peeked)) {
|
|
354
400
|
startupReportFile = peeked;
|
|
355
401
|
}
|
|
356
402
|
if ("error" in parsed)
|
|
357
403
|
fail(parsed.error);
|
|
358
404
|
const one = (name) => parsed.values.get(name)?.[0];
|
|
359
405
|
const many = (name) => parsed.values.get(name) ?? [];
|
|
406
|
+
// Lizenz: ändert nie den Lauf, nur den Umfang der geschriebenen Ausgabe (Textbausteine, Schema-Befunde).
|
|
407
|
+
const gate = resolveProGate(one("--license"));
|
|
360
408
|
const upstreamCommand = one("--upstream-command");
|
|
361
409
|
if (!upstreamCommand) {
|
|
362
410
|
fail("proxy mode requires --upstream-command <cmd>\n" +
|
|
@@ -388,7 +436,7 @@ async function runProxyMode(argv) {
|
|
|
388
436
|
let errorRate = 0.0;
|
|
389
437
|
if (errorRateRaw !== undefined) {
|
|
390
438
|
if (!/^(\d+(\.\d+)?|\.\d+)$/.test(errorRateRaw) || Number(errorRateRaw) > 1) {
|
|
391
|
-
fail(`--error-rate must be a number between 0 and 1, got "${errorRateRaw}".`);
|
|
439
|
+
fail(`--error-rate must be a plain decimal number between 0 and 1 (like 0.2; no exponent such as 1e-5), got "${errorRateRaw}".`);
|
|
392
440
|
}
|
|
393
441
|
if (chaosMode !== "chaos") {
|
|
394
442
|
fail("--error-rate only has an effect together with --mode chaos.");
|
|
@@ -448,6 +496,9 @@ async function runProxyMode(argv) {
|
|
|
448
496
|
fail(`--report-file "${reportFile}" contains a reserved device name or a ":", or ends in a dot or space, and is not a usable file.`);
|
|
449
497
|
}
|
|
450
498
|
const info = await stat(reportFile).catch(() => null);
|
|
499
|
+
if (info?.isFile() && !isReplaceableReportFile(reportFile)) {
|
|
500
|
+
fail(notReplaceableMessage("--report-file", reportFile));
|
|
501
|
+
}
|
|
451
502
|
if (info?.isDirectory()) {
|
|
452
503
|
fail(`--report-file "${reportFile}" is a directory, not a file.`);
|
|
453
504
|
}
|
|
@@ -472,9 +523,20 @@ async function runProxyMode(argv) {
|
|
|
472
523
|
}
|
|
473
524
|
}
|
|
474
525
|
const reportTarget = reportFile;
|
|
526
|
+
if (!startupReportFile && reportTarget) {
|
|
527
|
+
startupReportFile = reportTarget;
|
|
528
|
+
tryArchivePreviousReport();
|
|
529
|
+
}
|
|
475
530
|
const log = (line) => {
|
|
476
531
|
process.stderr.write(`[cuonztech-agent-harness] ${line}\n`);
|
|
477
532
|
};
|
|
533
|
+
log(gate.pro
|
|
534
|
+
? "Lizenz: gültig (Pro-Umfang)."
|
|
535
|
+
: `Keine gültige Lizenz: ${reasonSentence(gate.verdict)} Der Lauf ist vollständig, Urteil, Score und Exit-Code sind echt. ` +
|
|
536
|
+
`Im Report fehlen die Pro-Teile (Textbausteine, Schema-Befunde). Lizenz: ${PURCHASE_URL}`);
|
|
537
|
+
if (archivedPreviousRun) {
|
|
538
|
+
log(`NOTE: An earlier report with ${archivedPreviousRun.callsCount} call(s) was archived to "${archivedPreviousRun.archivedPath}".`);
|
|
539
|
+
}
|
|
478
540
|
// A tool name comes from the upstream: a line break in it must not be
|
|
479
541
|
// able to fake further lines of this log.
|
|
480
542
|
const shown = (name) => name.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, "?");
|
|
@@ -483,6 +545,24 @@ async function runProxyMode(argv) {
|
|
|
483
545
|
log("WARNING: --mode chaos with a 0% error rate — this run will inject nothing. " +
|
|
484
546
|
"Pass e.g. --error-rate 0.2 to actually trigger stochastic failures.");
|
|
485
547
|
}
|
|
548
|
+
// Every scenario and every chaos run with a non-zero error rate lets the
|
|
549
|
+
// agent's writes reach the upstream for real. That needs a deliberate yes,
|
|
550
|
+
// given before the upstream is even started.
|
|
551
|
+
const writesReachUpstream = scenarioId !== undefined || (chaosMode === "chaos" && !chaosInjectsNothing);
|
|
552
|
+
if (writesReachUpstream && one("--allow-writes") === undefined) {
|
|
553
|
+
fail("this run executes the agent's writes against the upstream for real" +
|
|
554
|
+
(scenarioId ? ` (scenario ${scenarioId})` : " (chaos mode)") +
|
|
555
|
+
".\n" +
|
|
556
|
+
"Only do that against a disposable test/staging system, never production.\n" +
|
|
557
|
+
"If --upstream-command points at such a system, confirm it by adding --allow-writes.");
|
|
558
|
+
}
|
|
559
|
+
if (writesReachUpstream) {
|
|
560
|
+
// Wortlaut nach LICENSE Ziffer 4a: der Schalter --allow-writes ist die Bestätigung des Anwenders.
|
|
561
|
+
log("ACHTUNG: Der Harness führt die Schreibvorgänge Ihres Agenten wirklich am Upstream aus und löst " +
|
|
562
|
+
"absichtlich Fehler, Wiederholungen und Verzögerungen aus. Nur gegen ein Testsystem mit " +
|
|
563
|
+
"synthetischen Daten einsetzen, das keine Produktivumgebung ist und von der Aussenwelt " +
|
|
564
|
+
"getrennt ist (LICENSE Ziffer 4a). Mit --allow-writes bestätigen Sie das.");
|
|
565
|
+
}
|
|
486
566
|
if (scenarioId === "F1") {
|
|
487
567
|
log("WARNING: scenario F1 really executes the agent's first write upstream and then " +
|
|
488
568
|
"reports a timeout to the agent. Only point --upstream-command at a disposable " +
|
|
@@ -549,6 +629,8 @@ async function runProxyMode(argv) {
|
|
|
549
629
|
: "\nThe upstream wrote nothing to stderr. Check the command, its arguments and that it speaks MCP over stdio."));
|
|
550
630
|
}
|
|
551
631
|
const upstreamTools = interceptor.getUpstreamTools();
|
|
632
|
+
// Static view of the tool schemas, once: it does not change during the run.
|
|
633
|
+
const schemaAudit = auditToolSchemas(upstreamTools, { writeToolPatterns, readToolPatterns });
|
|
552
634
|
const collisions = findToolNameCollisions(upstreamTools);
|
|
553
635
|
if (auditTools && collisions.length > 0) {
|
|
554
636
|
await interceptor.disconnectUpstream();
|
|
@@ -626,7 +708,10 @@ async function runProxyMode(argv) {
|
|
|
626
708
|
try {
|
|
627
709
|
const report = generateReport(interceptor.getSession());
|
|
628
710
|
const score = computeScore([report]);
|
|
629
|
-
const patches = generatePatches([report], score);
|
|
711
|
+
const patches = gate.pro ? generatePatches([report], score) : []; // ohne Lizenz nicht erzeugt und nicht geschrieben
|
|
712
|
+
const callForensics = buildCallForensics(interceptor.getSession());
|
|
713
|
+
// Ohne Lizenz: Schema-Gutachten nur mit Zusammenfassung und Risikostufe, ohne Textbausteine.
|
|
714
|
+
const reportedSchemaAudit = gate.pro ? schemaAudit : reduceSchemaAudit(schemaAudit);
|
|
630
715
|
const body = JSON.stringify({
|
|
631
716
|
harnessVersion: VERSION,
|
|
632
717
|
// Constant: a self-test, never a certificate.
|
|
@@ -635,7 +720,37 @@ async function runProxyMode(argv) {
|
|
|
635
720
|
verdict: report.verdict,
|
|
636
721
|
report,
|
|
637
722
|
score,
|
|
638
|
-
|
|
723
|
+
...(gate.pro
|
|
724
|
+
? { patches }
|
|
725
|
+
: {
|
|
726
|
+
limitedByLicense: true,
|
|
727
|
+
licenseNotice: "Keine gültige Lizenz: Textbausteine (Härtungsvorschläge) und Schema-Befunde gehören zum Pro-Umfang und fehlen hier. " +
|
|
728
|
+
`Lauf, Urteil, Score und Exit-Code sind echt. Frei bleiben: ${FREE_SCOPE_DE}. Lizenz: ${PURCHASE_URL}`,
|
|
729
|
+
}),
|
|
730
|
+
...(archivedPreviousRun
|
|
731
|
+
? {
|
|
732
|
+
previousRun: {
|
|
733
|
+
file: archivedPreviousRun.archivedPath,
|
|
734
|
+
verdict: archivedPreviousRun.verdict,
|
|
735
|
+
callsCount: archivedPreviousRun.callsCount,
|
|
736
|
+
archivedAt: archivedPreviousRun.archivedAt,
|
|
737
|
+
},
|
|
738
|
+
}
|
|
739
|
+
: {}),
|
|
740
|
+
// Deutscher Prompt-Baustein und Schema-Gutachten (neu in 0.7.0, optional für Leser).
|
|
741
|
+
...(gate.pro
|
|
742
|
+
? {
|
|
743
|
+
recommended_prompt_patch_de: generatePromptPatchDe([report], classification.filter((c) => c.class === "write").map((c) => c.name), classification.filter((c) => c.class === "read").map((c) => c.name)),
|
|
744
|
+
}
|
|
745
|
+
: {}),
|
|
746
|
+
schema_audit: reportedSchemaAudit,
|
|
747
|
+
call_forensics: callForensics,
|
|
748
|
+
integrity: computeReportIntegrity({
|
|
749
|
+
harnessVersion: VERSION,
|
|
750
|
+
report,
|
|
751
|
+
schema_audit: reportedSchemaAudit,
|
|
752
|
+
call_forensics: callForensics,
|
|
753
|
+
}),
|
|
639
754
|
toolClassification: classification,
|
|
640
755
|
generatedAt: new Date().toISOString(),
|
|
641
756
|
}, null, 2);
|
|
@@ -734,7 +849,7 @@ async function runProxyMode(argv) {
|
|
|
734
849
|
process.stdin.on("end", shutdown);
|
|
735
850
|
process.stdin.on("close", shutdown);
|
|
736
851
|
startupReportFile = null;
|
|
737
|
-
const server = createProxyServer(interceptor, { auditTools });
|
|
852
|
+
const server = createProxyServer(interceptor, { auditTools, patches: gate.pro });
|
|
738
853
|
// The SDK closes the transport on its own when a single message exceeds
|
|
739
854
|
// its buffer (about 10 MB). Without this the proxy stayed alive, deaf,
|
|
740
855
|
// with its upstream child still running.
|
|
@@ -742,7 +857,7 @@ async function runProxyMode(argv) {
|
|
|
742
857
|
log("The connection to the client was closed (stdin ended, or a message was too large for the MCP transport). Shutting down.");
|
|
743
858
|
void shutdown();
|
|
744
859
|
};
|
|
745
|
-
await startStdio(server);
|
|
860
|
+
await startStdio(server, { log });
|
|
746
861
|
}
|
|
747
862
|
const BENCHMARK_FLAGS = {
|
|
748
863
|
"--runs": "single",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { LICENSE_PUBLIC_KEY } from "./publicKey.js";
|
|
3
|
+
const PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
4
|
+
const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
5
|
+
function rawKey(b64, what) {
|
|
6
|
+
const raw = Buffer.from(b64, "base64");
|
|
7
|
+
if (raw.length !== 32)
|
|
8
|
+
throw new Error(`${what}: erwartet 32 Byte (base64), erhalten ${raw.length}`);
|
|
9
|
+
return raw;
|
|
10
|
+
}
|
|
11
|
+
export function generateLicenseKeyPair() {
|
|
12
|
+
const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
|
|
13
|
+
return {
|
|
14
|
+
seed: privateKey.export({ format: "der", type: "pkcs8" }).subarray(-32).toString("base64"),
|
|
15
|
+
publicKey: publicKey.export({ format: "der", type: "spki" }).subarray(-32).toString("base64"),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function signEd25519(domain, payload, seedB64) {
|
|
19
|
+
const key = crypto.createPrivateKey({
|
|
20
|
+
key: Buffer.concat([PKCS8_PREFIX, rawKey(seedB64, "LICENSE_SIGNING_SEED")]),
|
|
21
|
+
format: "der",
|
|
22
|
+
type: "pkcs8",
|
|
23
|
+
});
|
|
24
|
+
return crypto.sign(null, Buffer.from(`${domain}.${payload}`), key).toString("base64url");
|
|
25
|
+
}
|
|
26
|
+
function isCiEnvironment(env) {
|
|
27
|
+
const on = (v) => v !== undefined && v !== "" && !/^(0|false|no)$/i.test(v);
|
|
28
|
+
return on(env.CI) || on(env.GITHUB_ACTIONS);
|
|
29
|
+
}
|
|
30
|
+
let warnedKeyOverride = false;
|
|
31
|
+
export function defaultPublicKey(env = process.env) {
|
|
32
|
+
const override = env.AGENT_AUDIT_LICENSE_PUBLIC_KEY;
|
|
33
|
+
if (!override)
|
|
34
|
+
return LICENSE_PUBLIC_KEY;
|
|
35
|
+
if (isCiEnvironment(env) && env.AGENT_AUDIT_ALLOW_TEST_KEY !== "1") {
|
|
36
|
+
if (!warnedKeyOverride) {
|
|
37
|
+
warnedKeyOverride = true;
|
|
38
|
+
console.warn("⚠ Lizenz-Schlüssel-Override wird in CI ignoriert (eingebetteter Schlüssel gilt).");
|
|
39
|
+
}
|
|
40
|
+
return LICENSE_PUBLIC_KEY;
|
|
41
|
+
}
|
|
42
|
+
return override;
|
|
43
|
+
}
|
|
44
|
+
export function verifyEd25519(domain, payload, sigB64url, publicKeyB64 = defaultPublicKey()) {
|
|
45
|
+
try {
|
|
46
|
+
const key = crypto.createPublicKey({
|
|
47
|
+
key: Buffer.concat([SPKI_PREFIX, rawKey(publicKeyB64, "Public Key")]),
|
|
48
|
+
format: "der",
|
|
49
|
+
type: "spki",
|
|
50
|
+
});
|
|
51
|
+
const sig = Buffer.from(sigB64url, "base64url");
|
|
52
|
+
return crypto.verify(null, Buffer.from(`${domain}.${payload}`), key, sig);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Lizenz-Sperre nach dem Muster von CPCG (Privacy Chaos Gate): Der Lauf selbst
|
|
2
|
+
// (Proxy, Szenarien F1 bis F5, Score, PASS/FAIL, Exit-Code) ist nie davon
|
|
3
|
+
// abhängig. Ohne gültige Lizenz wird nur die AUSGABE gekürzt oder ein Pro-Befehl
|
|
4
|
+
// mit einem klaren Hinweis nicht ausgeführt. Die Prüfung der Lizenz selbst
|
|
5
|
+
// (Ed25519, offline) liegt in verifier.ts.
|
|
6
|
+
import { resolveLicense } from "./verifier.js";
|
|
7
|
+
export const PURCHASE_URL = "https://cuonztech.ch/agent-harness.html#audit";
|
|
8
|
+
// Alte Adresse aus früheren Texten des Verifizierers; die Website leitet weiter.
|
|
9
|
+
const LEGACY_URL = "https://cuonztech.ch/agent-audit";
|
|
10
|
+
// Zuordnung frei/Pro. Dieselbe Tabelle steht in der README (Abschnitt «Lizenz und Umfang»).
|
|
11
|
+
export const PRO_FEATURES = {
|
|
12
|
+
dossier: {
|
|
13
|
+
name: "Das Prüf-Dossier (report --dossier)",
|
|
14
|
+
command: "report --dossier",
|
|
15
|
+
mode: "gekürzt",
|
|
16
|
+
free: "Das Dossier enthält nur Kopf, Score und das Urteil je Szenario",
|
|
17
|
+
},
|
|
18
|
+
ui: {
|
|
19
|
+
name: "Die geführte Web-Oberfläche (ui)",
|
|
20
|
+
command: "ui",
|
|
21
|
+
mode: "gesperrt",
|
|
22
|
+
free: "Der Weg über die Kommandozeile (proxy, doctor, report)",
|
|
23
|
+
},
|
|
24
|
+
campaign: {
|
|
25
|
+
name: "Der Kampagnen-Runner (campaign)",
|
|
26
|
+
command: "campaign",
|
|
27
|
+
mode: "gesperrt",
|
|
28
|
+
free: "Der Selbsttest (benchmark) und jeder einzelne Proxy-Lauf",
|
|
29
|
+
},
|
|
30
|
+
"schema-audit": {
|
|
31
|
+
name: "Das Schema-Gutachten (Befunde je Tool und Schema-Vorschläge)",
|
|
32
|
+
command: "proxy --report-file, report",
|
|
33
|
+
mode: "gekürzt",
|
|
34
|
+
free: "Die Zusammenfassung und die Risikostufe je Tool (auch in doctor)",
|
|
35
|
+
},
|
|
36
|
+
hardening: {
|
|
37
|
+
name: "Der Textbaustein- und Prompt-Patch-Generator (Härtungsvorschläge)",
|
|
38
|
+
command: "proxy --report-file, report, cuonztech__get_score",
|
|
39
|
+
mode: "gekürzt",
|
|
40
|
+
free: "Der Score, das Urteil und die Beobachtungen",
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
export const FREE_SCOPE_DE = "MCP-Chaos-Proxy mit den Szenarien F1 bis F5, doctor, Kompaktbericht, JSON-Report (--report-file), Resilience Score und PASS/FAIL samt Exit-Code";
|
|
44
|
+
/** Löst die Lizenz auf (--license, sonst Umgebungsvariablen). Ohne gültige Lizenz ist `pro` false. */
|
|
45
|
+
export function resolveProGate(licenseKey) {
|
|
46
|
+
const verdict = resolveLicense(licenseKey);
|
|
47
|
+
return { pro: verdict.valid === true, verdict };
|
|
48
|
+
}
|
|
49
|
+
/** Der Grund in einem Satz, ohne Kauf-Link (der steht immer am Ende des Hinweises). */
|
|
50
|
+
export function reasonSentence(verdict) {
|
|
51
|
+
if (verdict.valid)
|
|
52
|
+
return "Die Lizenz ist gültig.";
|
|
53
|
+
const raw = (verdict.reason ?? "").trim();
|
|
54
|
+
const none = raw === "" || raw === "Nicht lizenziert" || raw.startsWith("Kein Lizenzschlüssel");
|
|
55
|
+
if (none)
|
|
56
|
+
return "Es ist keine Lizenz hinterlegt.";
|
|
57
|
+
const core = raw
|
|
58
|
+
.replace(LEGACY_URL, "")
|
|
59
|
+
.replace(/\s*(Lizenzen erhältlich unter|Verlängerung unter)\s*$/i, "")
|
|
60
|
+
.trim()
|
|
61
|
+
.replace(/\.$/, "");
|
|
62
|
+
return `Ihre Lizenz ist nicht gültig: ${core}.`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Hinweis für einen Befehl, der ohne Lizenz nicht läuft (`ui`, `campaign`).
|
|
66
|
+
* Form: «Keine gültige Lizenz: <Funktion> gehört zum Pro-Umfang. Frei bleiben …. Lizenz: <URL>»
|
|
67
|
+
*/
|
|
68
|
+
export function lockedNotice(feature, verdict) {
|
|
69
|
+
const f = PRO_FEATURES[feature];
|
|
70
|
+
return (`Keine gültige Lizenz: ${f.name} gehört zum Pro-Umfang. ${reasonSentence(verdict)}\n` +
|
|
71
|
+
`Frei bleiben: ${FREE_SCOPE_DE}.\n` +
|
|
72
|
+
`Der Befehl wurde nicht ausgeführt. Lizenz: ${PURCHASE_URL}\n` +
|
|
73
|
+
`Aktivierung: --license <KEY> oder Umgebungsvariable AGENT_AUDIT_LICENSE.\n`);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Hinweis für eine gekürzte Ausgabe (Dossier, Schema-Gutachten, Härtungsvorschläge).
|
|
77
|
+
* Der Lauf selbst, das Urteil und der Exit-Code sind davon unberührt.
|
|
78
|
+
*/
|
|
79
|
+
export function shortenedNotice(feature, verdict) {
|
|
80
|
+
const f = PRO_FEATURES[feature];
|
|
81
|
+
return (`Keine gültige Lizenz: ${f.name} gehört zum Pro-Umfang. ${reasonSentence(verdict)} ` +
|
|
82
|
+
`Die Ausgabe ist gekürzt; der Lauf, das Urteil und der Exit-Code sind echt. ` +
|
|
83
|
+
`Frei bleiben: ${FREE_SCOPE_DE}. Lizenz: ${PURCHASE_URL}`);
|
|
84
|
+
}
|
|
85
|
+
/** Schlankere Fassung des Schema-Gutachtens ohne Lizenz: Zusammenfassung und Risikostufe, keine Befunde, kein Schema-Vorschlag. */
|
|
86
|
+
export function reduceSchemaAudit(audit) {
|
|
87
|
+
return {
|
|
88
|
+
...audit,
|
|
89
|
+
tools: audit.tools.map((t) => ({ ...t, findingsDe: [], recommendedSchemaPatch: null })),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|