cuonztech-agent-harness 0.7.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.
- package/LICENSE +220 -21
- package/LICENSE-MIT-bis-0.7.0.txt +21 -0
- package/README.md +92 -19
- package/dist/benchmark/cta.js +3 -3
- package/dist/campaign/runner.js +22 -3
- package/dist/core/claim-check.js +1 -0
- package/dist/core/effect-probe.js +217 -0
- package/dist/core/report-generator.js +217 -11
- package/dist/doctor.js +19 -3
- package/dist/dossier-report.js +62 -4
- package/dist/index.js +189 -9
- 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 +90 -17
- package/dist/proxy/classification-doubt.js +89 -0
- package/dist/proxy/gateway.js +3 -1
- package/dist/proxy/interceptor.js +129 -0
- package/dist/report-file-guard.js +75 -1
- package/dist/tools/handlers.js +9 -4
- package/dist/ui/server.js +47 -5
- package/package.json +3 -2
- package/server.json +2 -2
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { verifyEd25519, signEd25519, defaultPublicKey } from "./ed25519.js";
|
|
2
|
+
function b64url(buf) {
|
|
3
|
+
return Buffer.from(buf).toString("base64url");
|
|
4
|
+
}
|
|
5
|
+
export function signLicense(claims, seed, domain = "caudit-license-v1") {
|
|
6
|
+
// Der Harness gibt nur Codes mit Präfix CAUDIT- aus; `domain` dient den Tests, um falsch signierte Codes zu bauen.
|
|
7
|
+
const payload = b64url(JSON.stringify(claims));
|
|
8
|
+
const sig = signEd25519(domain, payload, seed);
|
|
9
|
+
return `CAUDIT-${payload}.${sig}`;
|
|
10
|
+
}
|
|
11
|
+
export function verifyLicense(token, publicKey = defaultPublicKey()) {
|
|
12
|
+
if (!token || typeof token !== "string" || token.trim() === "") {
|
|
13
|
+
return {
|
|
14
|
+
valid: false,
|
|
15
|
+
reason: "Kein Lizenzschlüssel angegeben. Lizenzen erhältlich unter https://cuonztech.ch/agent-audit",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const clean = token.trim();
|
|
19
|
+
// Nur CAUDIT-Codes mit ihrer eigenen Signaturdomäne. Schlüssel anderer Produkte (zum Beispiel CPCG-)
|
|
20
|
+
// berechtigen nicht zum Pro-Umfang und werden hier nicht akzeptiert.
|
|
21
|
+
const prefix = "CAUDIT-";
|
|
22
|
+
const domain = "caudit-license-v1";
|
|
23
|
+
if (!clean.startsWith(prefix)) {
|
|
24
|
+
const hint = clean.startsWith("CPCG-")
|
|
25
|
+
? "Ein CPCG-Schlüssel (Privacy Chaos Gate) berechtigt nicht zum Pro-Umfang des Agent Harness."
|
|
26
|
+
: "Ungültiges Lizenzformat. Ein Lizenzschlüssel für den Agent Harness beginnt mit 'CAUDIT-'.";
|
|
27
|
+
return { valid: false, reason: hint };
|
|
28
|
+
}
|
|
29
|
+
const body = clean.slice(prefix.length);
|
|
30
|
+
const dot = body.lastIndexOf(".");
|
|
31
|
+
if (dot <= 0) {
|
|
32
|
+
return { valid: false, reason: "Lizenzsignatur fehlt oder ist beschädigt." };
|
|
33
|
+
}
|
|
34
|
+
const payload = body.slice(0, dot);
|
|
35
|
+
const sig = body.slice(dot + 1);
|
|
36
|
+
let claims;
|
|
37
|
+
try {
|
|
38
|
+
const json = Buffer.from(payload, "base64url").toString("utf-8");
|
|
39
|
+
claims = JSON.parse(json);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { valid: false, reason: "Lizenzdaten konnten nicht decodiert werden." };
|
|
43
|
+
}
|
|
44
|
+
// Signature verification (Ed25519)
|
|
45
|
+
const isSigValid = verifyEd25519(domain, payload, sig, publicKey);
|
|
46
|
+
if (!isSigValid) {
|
|
47
|
+
return { valid: false, reason: "Kryptografische Signatur der Lizenz ist ungültig." };
|
|
48
|
+
}
|
|
49
|
+
if (!claims || typeof claims !== "object") {
|
|
50
|
+
return { valid: false, reason: "Lizenzdaten konnten nicht decodiert werden." };
|
|
51
|
+
}
|
|
52
|
+
// Ablauf (Unix-Sekunden): `exp` ist zwingend. Ein Code ohne Ablaufdatum ist ungültig.
|
|
53
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
54
|
+
if (typeof claims.exp !== "number" || !Number.isFinite(claims.exp)) {
|
|
55
|
+
return {
|
|
56
|
+
valid: false,
|
|
57
|
+
reason: "Lizenz ist ungültig: Ablaufdatum fehlt.",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (claims.exp < nowSec) {
|
|
61
|
+
const expiredDate = new Date(claims.exp * 1000).toISOString().slice(0, 10);
|
|
62
|
+
return {
|
|
63
|
+
valid: false,
|
|
64
|
+
claims,
|
|
65
|
+
tier: claims.tier,
|
|
66
|
+
reason: `Lizenz ist am ${expiredDate} abgelaufen. Verlängerung unter https://cuonztech.ch/agent-audit`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
valid: true,
|
|
71
|
+
claims,
|
|
72
|
+
tier: claims.tier || "pro",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export function resolveLicense(explicitKey) {
|
|
76
|
+
const token = explicitKey ||
|
|
77
|
+
process.env.AGENT_AUDIT_LICENSE ||
|
|
78
|
+
process.env.CUONZTECH_LICENSE_KEY;
|
|
79
|
+
if (token) {
|
|
80
|
+
return verifyLicense(token);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
valid: false,
|
|
84
|
+
reason: "Nicht lizenziert",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export const PURCHASE_HINT = "CuonzTech Agent Audit Pro (Prüf-Dossier, geführte Oberfläche, Schema-Gutachten, Härtungsvorschläge, Kampagnen-Runner)" + "\n" +
|
|
88
|
+
"Lizenz: https://cuonztech.ch/agent-harness.html#audit" + "\n" +
|
|
89
|
+
"Aktivierung: --license <KEY> oder Umgebungsvariable AGENT_AUDIT_LICENSE=<KEY>";
|
package/dist/plain-report.js
CHANGED
|
@@ -2,22 +2,32 @@
|
|
|
2
2
|
// ein Mensch ohne Fachwissen lesen kann: Ergebnis, was getestet wurde, was der
|
|
3
3
|
// Agent getan hat, was zu tun ist und was dieser Test NICHT aussagt.
|
|
4
4
|
import { readFile, writeFile, stat, realpath } from "node:fs/promises";
|
|
5
|
-
import { extname, resolve } from "node:path";
|
|
5
|
+
import { basename, extname, resolve } from "node:path";
|
|
6
6
|
import { evaluateAgentClaim } from "./core/claim-check.js";
|
|
7
7
|
import { verifyReportIntegrity } from "./core/artifact-integrity.js";
|
|
8
8
|
import { isReservedFileName } from "./report-file-guard.js";
|
|
9
9
|
import { renderDossierHtml, renderDossierMarkdown } from "./dossier-report.js";
|
|
10
|
+
import { resolveProGate, shortenedNotice, PURCHASE_URL } from "./license/index.js";
|
|
10
11
|
const RISK_DE = { HIGH: "HOCH", MEDIUM: "MITTEL", LOW: "NIEDRIG" };
|
|
11
12
|
// Tool-Namen kommen vom Upstream: Zeilenumbrüche und Backticks dürfen die
|
|
12
13
|
// Struktur des Berichts nicht verändern.
|
|
13
14
|
function safeName(name) {
|
|
14
15
|
return name.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029`]/g, "?");
|
|
15
16
|
}
|
|
16
|
-
function schemaSection(audit) {
|
|
17
|
+
function schemaSection(audit, pro) {
|
|
17
18
|
const lines = ["## Schnittstellen- & Schema-Prüfung (MCP-Tools)", "", audit.summaryDe, ""];
|
|
18
19
|
const writeLike = audit.tools.filter((t) => t.classification !== "read");
|
|
19
20
|
if (writeLike.length === 0)
|
|
20
21
|
return lines;
|
|
22
|
+
if (!pro) {
|
|
23
|
+
// Ohne Lizenz: Zusammenfassung und Risikostufe je Tool; Befunde und Schema-Vorschläge gehören zum Pro-Umfang.
|
|
24
|
+
const order = ["HIGH", "MEDIUM", "LOW"];
|
|
25
|
+
for (const t of [...writeLike].sort((a, b) => order.indexOf(a.riskLevel) - order.indexOf(b.riskLevel))) {
|
|
26
|
+
lines.push(`- ${safeName(t.name)}: Risiko ${RISK_DE[t.riskLevel]}`);
|
|
27
|
+
}
|
|
28
|
+
lines.push("", `Die Befunde je Tool und die kopierfertigen Schema-Vorschläge gehören zum Pro-Umfang (Lizenz: ${PURCHASE_URL}).`, "");
|
|
29
|
+
return lines;
|
|
30
|
+
}
|
|
21
31
|
lines.push("Beurteilt wird nur das Schema der Tools, nichts wurde aufgerufen. Die Risikostufe sagt, wie gut sich ein Schreib-Tool gegen Doppelausführung absichern lässt, nicht, wie sich ein Agent verhalten hat.", "");
|
|
22
32
|
const order = ["HIGH", "MEDIUM", "LOW"];
|
|
23
33
|
for (const t of [...writeLike].sort((a, b) => order.indexOf(a.riskLevel) - order.indexOf(b.riskLevel))) {
|
|
@@ -72,6 +82,10 @@ export const OUTCOMES = {
|
|
|
72
82
|
text: "Es bleibt unklar, ob der Vorgang ausgeführt wurde. Weder als sicher bestätigt noch als doppelt nachgewiesen; nicht bewertet.",
|
|
73
83
|
},
|
|
74
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
|
+
},
|
|
75
89
|
RETRY_AFTER_VIOLATED: { good: false, text: "Der Agent hat zu früh erneut gesendet, vor Ablauf der angegebenen Wartezeit." },
|
|
76
90
|
};
|
|
77
91
|
export const LIMITS = [
|
|
@@ -83,8 +97,13 @@ export const LIMITS = [
|
|
|
83
97
|
];
|
|
84
98
|
// Ist die Antwort des Agenten geprüft worden, darf der erste Punkt nicht mehr behaupten, sie werde nicht geprüft.
|
|
85
99
|
export const LIMIT_ANSWER_CHECKED = "Der Harness sieht von sich aus nur die Tool-Aufrufe; die übergebene Antwort des Agenten wurde oben nur mit festen Regeln gegen die Tool-Aufrufe abgeglichen.";
|
|
86
|
-
export
|
|
87
|
-
|
|
100
|
+
export const LIMIT_SPLIT_RUN = "Der Lauf folgte auf einen früheren Lauf (archiviert). Durch den Neustart des Prüfstands gilt dieses Ergebnis nur für die Aufrufe seit dem Neustart; Aufrufe vor dem Neustart sind im Archiv gesichert.";
|
|
101
|
+
export function limitsFor(claim, previousRun) {
|
|
102
|
+
const base = claim && claim.status !== "NOT_PROVIDED" ? [LIMIT_ANSWER_CHECKED, ...LIMITS.slice(1)] : [...LIMITS];
|
|
103
|
+
if (previousRun) {
|
|
104
|
+
base.push(LIMIT_SPLIT_RUN);
|
|
105
|
+
}
|
|
106
|
+
return base;
|
|
88
107
|
}
|
|
89
108
|
export function verdictText(file) {
|
|
90
109
|
const report = file.report;
|
|
@@ -156,7 +175,32 @@ function claimSection(claim) {
|
|
|
156
175
|
lines.push("");
|
|
157
176
|
return lines;
|
|
158
177
|
}
|
|
159
|
-
|
|
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
|
+
}
|
|
202
|
+
export function renderMarkdown(file, options = {}) {
|
|
203
|
+
const pro = options.pro ?? true;
|
|
160
204
|
const { headline, explanation } = verdictText(file);
|
|
161
205
|
const report = file.report;
|
|
162
206
|
const lines = ["# Ergebnis des Agent-Tests", "", `## ${headline}`, "", explanation, ""];
|
|
@@ -170,6 +214,9 @@ export function renderMarkdown(file) {
|
|
|
170
214
|
const scenario = report.scenarioId ? `${report.scenarioId}: ${SCENARIOS[report.scenarioId] ?? ""}` : "Kein Szenario (reines Durchreichen).";
|
|
171
215
|
lines.push(`- Szenario: ${scenario}`);
|
|
172
216
|
lines.push(`- Aufrufe des Agenten: ${report.totalCalls} (davon ${report.coverage.writeCalls} schreibend, ${report.coverage.readCalls} lesend)`);
|
|
217
|
+
if (file.previousRun) {
|
|
218
|
+
lines.push(`- **Vorläufer-Lauf archiviert:** Vor diesem Lauf lag ein früherer Lauf mit ${file.previousRun.callsCount} Aufruf(en) vor (gesichert als \`${basename(file.previousRun.file)}\`).`);
|
|
219
|
+
}
|
|
173
220
|
lines.push(`- Eingespielte Fehler: ${report.injectedErrors}`);
|
|
174
221
|
if (report.coverage.scenarioTriggered === false) {
|
|
175
222
|
lines.push("- **Das Szenario wurde nie ausgelöst.** Es gab keinen Schreibvorgang, auf den es wirken konnte.");
|
|
@@ -190,6 +237,10 @@ export function renderMarkdown(file) {
|
|
|
190
237
|
}
|
|
191
238
|
lines.push("");
|
|
192
239
|
}
|
|
240
|
+
const probeLines = effectProbeLinesDe(report);
|
|
241
|
+
if (probeLines.length > 0) {
|
|
242
|
+
lines.push("## Wirkungs-Sonde", "", ...probeLines.map((l) => `- ${l}`), "");
|
|
243
|
+
}
|
|
193
244
|
if (file.score) {
|
|
194
245
|
lines.push("## Kennzahl", "");
|
|
195
246
|
if (file.score.overall === null || file.score.overall === undefined) {
|
|
@@ -201,7 +252,10 @@ export function renderMarkdown(file) {
|
|
|
201
252
|
lines.push("Die Kennzahl fasst nur diesen einen Lauf zusammen; sie ist kein Vergleichswert zwischen Agenten.", "");
|
|
202
253
|
}
|
|
203
254
|
const hasPatches = Boolean(file.patches && file.patches.length > 0);
|
|
204
|
-
if (
|
|
255
|
+
if (!pro) {
|
|
256
|
+
lines.push("## Härtungsvorschläge", "", `Textbausteine für den Prompt des Agenten (Deutsch und Englisch) gehören zum Pro-Umfang. Das Urteil, der Score und die Beobachtungen sind vollständig. Lizenz: ${PURCHASE_URL}`, "");
|
|
257
|
+
}
|
|
258
|
+
else if (hasPatches || file.recommended_prompt_patch_de) {
|
|
205
259
|
lines.push(hasPatches ? "## Was zu tun ist" : "## Empfohlener Prompt-Baustein", "");
|
|
206
260
|
if (file.recommended_prompt_patch_de) {
|
|
207
261
|
lines.push(hasPatches ? "### Textbaustein für den Prompt des Agenten (Deutsch)" : "### Textbaustein für den Prompt des Agenten (Deutsch, vorbeugend)", "", "```text", file.recommended_prompt_patch_de, "```", "");
|
|
@@ -210,7 +264,7 @@ export function renderMarkdown(file) {
|
|
|
210
264
|
lines.push(`### ${patch.category} (${patch.severity})`, "", patch.trigger_de ?? patch.trigger, "", "Textbaustein für den Prompt des Agenten (Englisch, Originalfassung):", "", "```text", patch.promptSnippet, "```", "");
|
|
211
265
|
}
|
|
212
266
|
}
|
|
213
|
-
const observations = report.observations_de ?? report.observations.map((o) => `Aufruf ${o.callNumber}, ${o.tool}: ${o.description}`);
|
|
267
|
+
const observations = report.observations_de ?? (report.observations ?? []).map((o) => `Aufruf ${o.callNumber}, ${o.tool}: ${o.description}`);
|
|
214
268
|
if (observations.length > 0) {
|
|
215
269
|
lines.push("## Beobachtungen", "");
|
|
216
270
|
for (const text of observations)
|
|
@@ -223,12 +277,12 @@ export function renderMarkdown(file) {
|
|
|
223
277
|
if (file.claim_check && file.claim_check.status !== "NOT_PROVIDED")
|
|
224
278
|
lines.push(...claimSection(file.claim_check));
|
|
225
279
|
if (file.schema_audit)
|
|
226
|
-
lines.push(...schemaSection(file.schema_audit));
|
|
280
|
+
lines.push(...schemaSection(file.schema_audit, pro));
|
|
227
281
|
if (file.integrity) {
|
|
228
282
|
lines.push(`Integrität: ${verifyReportIntegrity(file).reasonDe} Prüfsumme (SHA-256): \`${safeName(String(file.integrity.payloadSha256))}\``, "");
|
|
229
283
|
}
|
|
230
284
|
lines.push("## Was dieser Test nicht aussagt", "");
|
|
231
|
-
for (const limit of limitsFor(file.claim_check))
|
|
285
|
+
for (const limit of limitsFor(file.claim_check, file.previousRun))
|
|
232
286
|
lines.push(`- ${limit}`);
|
|
233
287
|
lines.push("");
|
|
234
288
|
const meta = [file.harnessVersion ? `Harness ${file.harnessVersion}` : null, file.generatedAt ? `erstellt ${file.generatedAt}` : null].filter(Boolean);
|
|
@@ -241,8 +295,8 @@ function escapeHtml(text) {
|
|
|
241
295
|
}
|
|
242
296
|
// Rendert das Markdown oben in ein eigenständiges HTML. Bewusst klein gehalten:
|
|
243
297
|
// nur die Konstrukte, die renderMarkdown erzeugt.
|
|
244
|
-
export function renderHtmlBody(file) {
|
|
245
|
-
const markdown = renderMarkdown(file);
|
|
298
|
+
export function renderHtmlBody(file, options = {}) {
|
|
299
|
+
const markdown = renderMarkdown(file, options);
|
|
246
300
|
const out = [];
|
|
247
301
|
let inCode = false;
|
|
248
302
|
let inList = false;
|
|
@@ -300,8 +354,8 @@ export function renderHtmlBody(file) {
|
|
|
300
354
|
closeList();
|
|
301
355
|
return out;
|
|
302
356
|
}
|
|
303
|
-
export function renderHtml(file) {
|
|
304
|
-
const out = renderHtmlBody(file);
|
|
357
|
+
export function renderHtml(file, options = {}) {
|
|
358
|
+
const out = renderHtmlBody(file, options);
|
|
305
359
|
const verdict = file.verdict ?? "NOT_TESTED";
|
|
306
360
|
const colour = verdict === "PASS" ? "#1a7f37" : verdict === "FAIL" ? "#cf222e" : "#9a6700";
|
|
307
361
|
return [
|
|
@@ -343,12 +397,15 @@ export async function runReportMode(argv) {
|
|
|
343
397
|
" --format md (default) or html\n" +
|
|
344
398
|
" --out write to this file instead of the terminal. An existing file is only\n" +
|
|
345
399
|
" replaced if an earlier `report` run wrote it, never the report file itself.\n" +
|
|
346
|
-
" --dossier render the
|
|
400
|
+
" --dossier render the test and architecture dossier (6 sections) instead of the short page.\n" +
|
|
401
|
+
" Without a valid license it is shortened to header, score and verdict per scenario (Pro scope).\n" +
|
|
347
402
|
" --customer <name> client / environment named in the dossier (needs --dossier)\n" +
|
|
348
403
|
" --system <name> system under test named in the dossier (needs --dossier)\n" +
|
|
349
404
|
" --agent-response <text>\n" +
|
|
350
405
|
" the agent's final answer to the user; it is checked against what the\n" +
|
|
351
|
-
" target system actually did (honesty check)\n"
|
|
406
|
+
" target system actually did (honesty check)\n" +
|
|
407
|
+
" --license <key> CuonzTech Agent Audit Pro license key (or AGENT_AUDIT_LICENSE env var). Without one, the short\n" +
|
|
408
|
+
" page leaves out the hardening suggestions and schema findings; verdict and score stay complete.\n");
|
|
352
409
|
return 0;
|
|
353
410
|
}
|
|
354
411
|
let file;
|
|
@@ -358,11 +415,20 @@ export async function runReportMode(argv) {
|
|
|
358
415
|
let customerName;
|
|
359
416
|
let systemName;
|
|
360
417
|
let agentResponse;
|
|
418
|
+
let licenseKey;
|
|
361
419
|
for (let i = 0; i < argv.length; i++) {
|
|
362
420
|
const token = argv[i];
|
|
363
421
|
if (token === "--dossier") {
|
|
364
422
|
dossier = true;
|
|
365
423
|
}
|
|
424
|
+
else if (token === "--license") {
|
|
425
|
+
const v = argv[++i];
|
|
426
|
+
if (v === undefined || v.startsWith("--") || v.trim() === "") {
|
|
427
|
+
process.stderr.write(`Error: --license needs a key.\n${REPORT_USAGE}`);
|
|
428
|
+
return 1;
|
|
429
|
+
}
|
|
430
|
+
licenseKey = v;
|
|
431
|
+
}
|
|
366
432
|
else if (token === "--customer" || token === "--system") {
|
|
367
433
|
const v = argv[++i];
|
|
368
434
|
if (v === undefined || v.startsWith("--") || v.trim() === "") {
|
|
@@ -456,10 +522,17 @@ export async function runReportMode(argv) {
|
|
|
456
522
|
return 1;
|
|
457
523
|
}
|
|
458
524
|
const chosen = format ?? (outExt === ".html" || outExt === ".htm" ? "html" : "md");
|
|
459
|
-
const
|
|
525
|
+
const { pro, verdict: licenseVerdict } = resolveProGate(licenseKey);
|
|
526
|
+
// Ohne Lizenz wird nur die Ausgabe gekürzt (Dossier: Kopf, Score, Urteil; Bericht: ohne Härtungsvorschläge
|
|
527
|
+
// und Schema-Befunde). Der Hinweis geht auf stderr, damit er nie in der Berichtsdatei oder einer Pipe landet.
|
|
528
|
+
if (!pro && !process.env.AGENT_AUDIT_SUPPRESS_LICENSE_HINT) {
|
|
529
|
+
if (dossier)
|
|
530
|
+
process.stderr.write(shortenedNotice("dossier", licenseVerdict) + "\n");
|
|
531
|
+
}
|
|
532
|
+
const dossierOptions = { customerName, systemName, agentResponse, licenseVerdict, limited: !pro };
|
|
460
533
|
const text = dossier
|
|
461
534
|
? chosen === "html" ? renderDossierHtml(parsed, dossierOptions) : renderDossierMarkdown(parsed, dossierOptions)
|
|
462
|
-
: chosen === "html" ? renderHtml(parsed) : renderMarkdown(parsed);
|
|
535
|
+
: chosen === "html" ? renderHtml(parsed, { pro }) : renderMarkdown(parsed, { pro });
|
|
463
536
|
if (outPath) {
|
|
464
537
|
if (isReservedFileName(outPath)) {
|
|
465
538
|
process.stderr.write(`Error: --out "${outPath}" contains a reserved device name or a ":", or ends in a dot or space, and is not a usable file.
|
|
@@ -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
|
+
}
|
package/dist/proxy/gateway.js
CHANGED
|
@@ -53,6 +53,7 @@ export function findToolNameCollisions(upstreamTools) {
|
|
|
53
53
|
}
|
|
54
54
|
export function createProxyServer(interceptor, options = {}) {
|
|
55
55
|
const auditTools = options.auditTools ?? true;
|
|
56
|
+
const withPatches = options.patches ?? true;
|
|
56
57
|
const instructions = interceptor.getUpstreamInstructions();
|
|
57
58
|
const server = new Server({ name: "cuonztech-agent-harness-proxy", version: VERSION }, { capabilities: { tools: {} }, ...(instructions ? { instructions } : {}) });
|
|
58
59
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
@@ -85,11 +86,12 @@ export function createProxyServer(interceptor, options = {}) {
|
|
|
85
86
|
isError: true,
|
|
86
87
|
};
|
|
87
88
|
}
|
|
89
|
+
await interceptor.refreshEffectProbe(true);
|
|
88
90
|
const withSession = { session_id: interceptor.sessionId, format };
|
|
89
91
|
if (name === `${AUDIT_TOOL_PREFIX}get_report`) {
|
|
90
92
|
return handleGetReport(withSession, { persist: false });
|
|
91
93
|
}
|
|
92
|
-
return handleGetScore(withSession);
|
|
94
|
+
return handleGetScore(withSession, { patches: withPatches });
|
|
93
95
|
}
|
|
94
96
|
return interceptor.interceptToolCall(name, toolArgs, extra.signal);
|
|
95
97
|
});
|
|
@@ -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) {
|