cuonztech-agent-harness 0.11.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -12
- package/dist/campaign/runner.js +47 -23
- package/dist/cli-args.js +42 -0
- package/dist/core/artifact-integrity.js +129 -30
- package/dist/core/call-forensics.js +18 -0
- package/dist/core/claim-check.js +57 -6
- package/dist/core/report-generator.js +6 -0
- package/dist/doctor.js +3 -0
- package/dist/dossier-report.js +230 -51
- package/dist/engine/state-machine.js +9 -0
- package/dist/hardening/patch-generator.js +9 -3
- package/dist/index.js +39 -23
- package/dist/license/ed25519.js +21 -15
- package/dist/license/gate.js +2 -2
- package/dist/license/verifier.js +10 -1
- package/dist/plain-report.js +130 -17
- package/dist/proxy/classification-doubt.js +61 -4
- package/dist/proxy/interceptor.js +5 -3
- package/dist/report-file-guard.js +3 -0
- package/dist/schema-audit.js +81 -25
- package/dist/ui/page.js +5 -4
- package/dist/ui/server.js +24 -10
- package/dist/verify.js +234 -0
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/index.js
CHANGED
|
@@ -8,17 +8,20 @@ import { generatePatches, generatePromptPatchDe } from "./hardening/patch-genera
|
|
|
8
8
|
import { auditToolSchemas } from "./schema-audit.js";
|
|
9
9
|
import { generateReport } from "./core/report-generator.js";
|
|
10
10
|
import { buildCallForensics } from "./core/call-forensics.js";
|
|
11
|
-
import {
|
|
11
|
+
import { sealReportFile } from "./core/artifact-integrity.js";
|
|
12
12
|
import { ProxyInterceptor } from "./proxy/interceptor.js";
|
|
13
13
|
import { runDoctor } from "./doctor.js";
|
|
14
14
|
import { runReportMode } from "./plain-report.js";
|
|
15
|
+
import { runVerifyMode } from "./verify.js";
|
|
15
16
|
import { archivePreviousReportSync, isReplaceableReportFile, isReservedFileName, notReplaceableMessage } from "./report-file-guard.js";
|
|
16
17
|
import { runUiMode } from "./ui/server.js";
|
|
17
18
|
import { runCampaignMode } from "./campaign/runner.js";
|
|
18
19
|
import { createProxyServer, findToolNameCollisions } from "./proxy/gateway.js";
|
|
19
20
|
import { resolveProGate, reduceSchemaAudit, reasonSentence, FREE_SCOPE_DE, PURCHASE_URL } from "./license/index.js";
|
|
20
21
|
import { getScenarioById, ALL_SCENARIOS } from "./scenarios/f1-f5.js";
|
|
22
|
+
import { DEFAULT_KEY_FIELDS } from "./engine/state-machine.js";
|
|
21
23
|
import { VERSION } from "./version.js";
|
|
24
|
+
import { optionName, redactSecrets } from "./cli-args.js";
|
|
22
25
|
import { writeFile, mkdir, rename, stat, realpath, unlink } from "node:fs/promises";
|
|
23
26
|
import { randomBytes } from "node:crypto";
|
|
24
27
|
import { writeFileSync, mkdirSync, statSync, lstatSync, existsSync } from "node:fs";
|
|
@@ -28,7 +31,10 @@ function printHelp() {
|
|
|
28
31
|
process.stdout.write(`cuonztech-agent-harness — chaos/idempotency test harness for MCP agents\n\n` +
|
|
29
32
|
`Usage:\n` +
|
|
30
33
|
` cuonztech-agent-harness Start as an MCP server (stdio, demo/self-test sessions)\n` +
|
|
31
|
-
` cuonztech-agent-harness --version Print the version (also: -v)\n` +
|
|
34
|
+
` cuonztech-agent-harness --version Print the version (also: -v, version)\n` +
|
|
35
|
+
` cuonztech-agent-harness verify <dossier|report-file> [--report <file>] [--expect <sha256>]\n` +
|
|
36
|
+
` Recompute the checksum of a report file or dossier and\n` +
|
|
37
|
+
` print OK or MISMATCH with the reason (exit 0 / 1).\n` +
|
|
32
38
|
` cuonztech-agent-harness --help Print this help (also: -h, help)\n` +
|
|
33
39
|
` cuonztech-agent-harness benchmark [opts] Run the harness self-test (two fixed reference agents;\n` +
|
|
34
40
|
` "evaluate" is an alias)\n` +
|
|
@@ -43,9 +49,10 @@ function printHelp() {
|
|
|
43
49
|
` Turn the proxy's --report-file into a plain-language\n` +
|
|
44
50
|
` page (German): result, what the agent did, limits.\n` +
|
|
45
51
|
` cuonztech-agent-harness campaign [--runs <n>] [--format md|html] [--out <file>] (Pro)\n` +
|
|
46
|
-
`
|
|
47
|
-
`
|
|
48
|
-
`
|
|
52
|
+
` SIMULATION of three fixed reference profiles (scripts,\n` +
|
|
53
|
+
` no model, no prompt) incl. the two-turn user-pressure\n` +
|
|
54
|
+
` scenario; measures neither your upstream nor your agent.\n` +
|
|
55
|
+
` (German report; runs are deterministic.)\n` +
|
|
49
56
|
` cuonztech-agent-harness proxy --upstream-command <cmd> [opts]\n` +
|
|
50
57
|
` Run as a transparent chaos proxy\n` +
|
|
51
58
|
` in front of a real upstream MCP server\n\n` +
|
|
@@ -65,7 +72,7 @@ function printHelp() {
|
|
|
65
72
|
` --upstream-env-var KEY=VALUE One env var for the upstream process; repeatable.\n` +
|
|
66
73
|
` The value may contain commas.\n` +
|
|
67
74
|
` --upstream-env K=V,K2=V2 Comma-separated env vars (values cannot contain commas)\n` +
|
|
68
|
-
` --scenario F1..F5 Activate one deterministic scenario\n` +
|
|
75
|
+
` --scenario F1..F5 Activate one deterministic scenario (lower case f1 works too)\n` +
|
|
69
76
|
` --allow-writes Required with --scenario and with --mode chaos (error\n` +
|
|
70
77
|
` rate > 0): confirms that --upstream-command points at a\n` +
|
|
71
78
|
` disposable test/staging system. The agent's writes are\n` +
|
|
@@ -139,7 +146,7 @@ function parseFlags(argv, spec) {
|
|
|
139
146
|
return { help: true };
|
|
140
147
|
}
|
|
141
148
|
if (!token.startsWith("--")) {
|
|
142
|
-
return { error: `unexpected argument "${token}".` };
|
|
149
|
+
return { error: `unexpected argument "${optionName(token)}".` };
|
|
143
150
|
}
|
|
144
151
|
// "--flag=value" form
|
|
145
152
|
let inlineValue;
|
|
@@ -150,7 +157,7 @@ function parseFlags(argv, spec) {
|
|
|
150
157
|
}
|
|
151
158
|
const kind = spec[token];
|
|
152
159
|
if (!kind) {
|
|
153
|
-
return { error: `unknown option "${token}".` };
|
|
160
|
+
return { error: `unknown option "${optionName(token)}".` };
|
|
154
161
|
}
|
|
155
162
|
// A switch without a value (--allow-writes): "--allow-writes=yes" and a
|
|
156
163
|
// repeat are errors, and the next token is not swallowed as its value.
|
|
@@ -312,6 +319,8 @@ async function renameWithRetry(from, to) {
|
|
|
312
319
|
}
|
|
313
320
|
}
|
|
314
321
|
function fail(message) {
|
|
322
|
+
// Ein Lizenzcode, der in eine Meldung geraten ist (falsch gesetzte Option), bleibt draussen.
|
|
323
|
+
message = redactSecrets(message);
|
|
315
324
|
writeStartupFailureSync(message);
|
|
316
325
|
process.stderr.write(`Error: ${message}\nRun with --help for usage.\n`);
|
|
317
326
|
process.exit(1);
|
|
@@ -350,7 +359,7 @@ async function main() {
|
|
|
350
359
|
if (mode === "--help" || mode === "-h" || mode === "help") {
|
|
351
360
|
printHelp();
|
|
352
361
|
}
|
|
353
|
-
else if (mode === "--version" || mode === "-v") {
|
|
362
|
+
else if (mode === "--version" || mode === "-v" || mode === "version") {
|
|
354
363
|
process.stdout.write(`${VERSION}\n`);
|
|
355
364
|
}
|
|
356
365
|
else if (mode === "benchmark" || mode === "evaluate") {
|
|
@@ -365,6 +374,9 @@ async function main() {
|
|
|
365
374
|
else if (mode === "report") {
|
|
366
375
|
process.exitCode = await runReportMode(args.slice(1));
|
|
367
376
|
}
|
|
377
|
+
else if (mode === "verify") {
|
|
378
|
+
process.exitCode = await runVerifyMode(args.slice(1));
|
|
379
|
+
}
|
|
368
380
|
else if (mode === "campaign") {
|
|
369
381
|
process.exitCode = await runCampaignMode(args.slice(1));
|
|
370
382
|
}
|
|
@@ -379,7 +391,7 @@ async function main() {
|
|
|
379
391
|
await startStdio(server);
|
|
380
392
|
}
|
|
381
393
|
else {
|
|
382
|
-
fail(`unknown command "${mode}". Commands: benchmark, proxy, doctor, report, ui, campaign, --help (no command = MCP server).`);
|
|
394
|
+
fail(`unknown command "${mode}". Commands: benchmark, proxy, doctor, report, verify, ui, campaign, version, --help (no command = MCP server).`);
|
|
383
395
|
}
|
|
384
396
|
}
|
|
385
397
|
const PROXY_FLAGS = {
|
|
@@ -476,7 +488,8 @@ async function runProxyMode(argv) {
|
|
|
476
488
|
for (const pair of many("--upstream-env-var")) {
|
|
477
489
|
parseEnvPair(pair, "--upstream-env-var", upstreamEnv);
|
|
478
490
|
}
|
|
479
|
-
|
|
491
|
+
// f1 gilt wie F1 (die Szenario-Kennungen sind F1 bis F5).
|
|
492
|
+
const scenarioId = one("--scenario")?.trim().toUpperCase();
|
|
480
493
|
if (scenarioId !== undefined && !getScenarioById(scenarioId)) {
|
|
481
494
|
fail(`unknown scenario "${scenarioId}". Valid: ${ALL_SCENARIOS.map((s) => s.id).join(", ")}`);
|
|
482
495
|
}
|
|
@@ -726,7 +739,7 @@ async function runProxyMode(argv) {
|
|
|
726
739
|
}
|
|
727
740
|
const upstreamTools = interceptor.getUpstreamTools();
|
|
728
741
|
// Static view of the tool schemas, once: it does not change during the run.
|
|
729
|
-
const schemaAudit = auditToolSchemas(upstreamTools, { writeToolPatterns, readToolPatterns });
|
|
742
|
+
const schemaAudit = auditToolSchemas(upstreamTools, { writeToolPatterns, readToolPatterns, keyFields });
|
|
730
743
|
const collisions = findToolNameCollisions(upstreamTools);
|
|
731
744
|
if (auditTools && collisions.length > 0) {
|
|
732
745
|
await interceptor.disconnectUpstream();
|
|
@@ -772,12 +785,17 @@ async function runProxyMode(argv) {
|
|
|
772
785
|
? `Idempotency-key argument recognized on: ${keyCapable.map(shown).join(", ")}`
|
|
773
786
|
: "NOTE: no upstream tool has an argument recognized as an idempotency key " +
|
|
774
787
|
"(looked for: " +
|
|
775
|
-
(keyFields ??
|
|
788
|
+
(keyFields ?? DEFAULT_KEY_FIELDS).join(", ") +
|
|
776
789
|
"). " +
|
|
777
790
|
(keyFields
|
|
778
791
|
? "Check the names given to --key-fields against the tools' input schemas."
|
|
779
|
-
: "If your tools use another name (
|
|
792
|
+
: "If your tools use another name (uuid, reference, correlation_id, txn_id, ...), pass " +
|
|
780
793
|
"--key-fields — otherwise a retry under a new such value looks like a different operation."));
|
|
794
|
+
const writesWithoutKey = classification.filter((c) => c.class === "write" && !keyCapable.includes(c.name));
|
|
795
|
+
if (keyCapable.length > 0 && writesWithoutKey.length > 0) {
|
|
796
|
+
log(`NOTE: no idempotency-key argument was recognized on the write tool(s) ${writesWithoutKey.map((c) => shown(c.name)).join(", ")}. ` +
|
|
797
|
+
"A retry of these under a new key value stays invisible as the same operation; if one of their arguments is such a key, pass --key-fields.");
|
|
798
|
+
}
|
|
781
799
|
log("Only tools are proxied. The upstream's resources, prompts and notifications are not forwarded.");
|
|
782
800
|
if (auditTools && (scenarioId || chaosMode === "chaos")) {
|
|
783
801
|
log("NOTE: the audit tools (cuonztech__get_report, cuonztech__get_score) are visible to " +
|
|
@@ -822,7 +840,8 @@ async function runProxyMode(argv) {
|
|
|
822
840
|
const callForensics = buildCallForensics(interceptor.getSession());
|
|
823
841
|
// Ohne Lizenz: Schema-Gutachten nur mit Zusammenfassung und Risikostufe, ohne Textbausteine.
|
|
824
842
|
const reportedSchemaAudit = gate.pro ? schemaAudit : reduceSchemaAudit(schemaAudit);
|
|
825
|
-
|
|
843
|
+
// Die Prüfsumme deckt die ganze Datei ausser integrity und claim_check (siehe core/artifact-integrity.ts).
|
|
844
|
+
const content = {
|
|
826
845
|
harnessVersion: VERSION,
|
|
827
846
|
// Constant: a self-test, never a certificate.
|
|
828
847
|
certification: "UNVERIFIED_FOR_PRODUCTION",
|
|
@@ -830,6 +849,8 @@ async function runProxyMode(argv) {
|
|
|
830
849
|
verdict: report.verdict,
|
|
831
850
|
report,
|
|
832
851
|
score,
|
|
852
|
+
// Lizenzstatus beim Aufzeichnen: Textbausteine und Schema-Befunde entstehen nur mit gültiger Lizenz.
|
|
853
|
+
recordedWithLicense: gate.pro,
|
|
833
854
|
...(gate.pro
|
|
834
855
|
? { patches }
|
|
835
856
|
: {
|
|
@@ -855,15 +876,10 @@ async function runProxyMode(argv) {
|
|
|
855
876
|
: {}),
|
|
856
877
|
schema_audit: reportedSchemaAudit,
|
|
857
878
|
call_forensics: callForensics,
|
|
858
|
-
integrity: computeReportIntegrity({
|
|
859
|
-
harnessVersion: VERSION,
|
|
860
|
-
report,
|
|
861
|
-
schema_audit: reportedSchemaAudit,
|
|
862
|
-
call_forensics: callForensics,
|
|
863
|
-
}),
|
|
864
879
|
toolClassification: classification,
|
|
865
880
|
generatedAt: new Date().toISOString(),
|
|
866
|
-
}
|
|
881
|
+
};
|
|
882
|
+
const body = JSON.stringify(sealReportFile(content), null, 2);
|
|
867
883
|
ensureDirSync(dirname(reportTarget));
|
|
868
884
|
// A fresh, unpredictable temp name, created exclusively: a fixed
|
|
869
885
|
// "<file>.tmp" was followed if someone had put a symlink there, and
|
|
@@ -1011,7 +1027,7 @@ async function runBenchmarkMode(argv) {
|
|
|
1011
1027
|
}
|
|
1012
1028
|
const scenariosRaw = one("--scenarios");
|
|
1013
1029
|
if (scenariosRaw !== undefined) {
|
|
1014
|
-
config.scenarios = [...new Set(scenariosRaw.split(",").map((s) => s.trim()))];
|
|
1030
|
+
config.scenarios = [...new Set(scenariosRaw.split(",").map((s) => s.trim().toUpperCase()))];
|
|
1015
1031
|
}
|
|
1016
1032
|
const unknown = config.scenarios.filter((id) => !getScenarioById(id));
|
|
1017
1033
|
if (unknown.length > 0) {
|
package/dist/license/ed25519.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
2
6
|
import { LICENSE_PUBLIC_KEY } from "./publicKey.js";
|
|
3
7
|
const PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
4
8
|
const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
@@ -23,23 +27,25 @@ export function signEd25519(domain, payload, seedB64) {
|
|
|
23
27
|
});
|
|
24
28
|
return crypto.sign(null, Buffer.from(`${domain}.${payload}`), key).toString("base64url");
|
|
25
29
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
// Test-Schlüssel-Override: nur in einem Source-Checkout. Die Datei liegt in tests/support/ und gehört nicht zum
|
|
31
|
+
// veröffentlichten Paket (siehe "files" in package.json); im installierten Paket gibt es sie nicht, und eine
|
|
32
|
+
// Umgebungsvariable allein (AGENT_AUDIT_LICENSE_PUBLIC_KEY) ändert den Schlüssel nicht mehr. Bis 0.11.0 konnte sich
|
|
33
|
+
// jeder mit dieser Variable und dem mitgelieferten signLicense() einen Pro-Code selbst signieren.
|
|
34
|
+
function testKeyOverride(env) {
|
|
35
|
+
try {
|
|
36
|
+
const here = dirname(fileURLToPath(import.meta.url)); // dist/license oder src/license
|
|
37
|
+
const candidate = join(here, "..", "..", "tests", "support", "license-test-key.cjs");
|
|
38
|
+
if (!existsSync(candidate))
|
|
39
|
+
return null;
|
|
40
|
+
const hook = createRequire(import.meta.url)(candidate);
|
|
41
|
+
return typeof hook === "function" ? hook(env) : null;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
29
46
|
}
|
|
30
|
-
let warnedKeyOverride = false;
|
|
31
47
|
export function defaultPublicKey(env = process.env) {
|
|
32
|
-
|
|
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;
|
|
48
|
+
return testKeyOverride(env) ?? LICENSE_PUBLIC_KEY;
|
|
43
49
|
}
|
|
44
50
|
export function verifyEd25519(domain, payload, sigB64url, publicKeyB64 = defaultPublicKey()) {
|
|
45
51
|
try {
|
package/dist/license/gate.js
CHANGED
|
@@ -78,8 +78,8 @@ export function lockedNotice(feature, verdict) {
|
|
|
78
78
|
*/
|
|
79
79
|
export function shortenedNotice(feature, verdict) {
|
|
80
80
|
const f = PRO_FEATURES[feature];
|
|
81
|
-
return (`Keine gültige Lizenz: ${f.name} gehört zum Pro-Umfang. ${reasonSentence(verdict)} ` +
|
|
82
|
-
`
|
|
81
|
+
return (`WARNUNG: Die Ausgabe ist gekürzt. Keine gültige Lizenz: ${f.name} gehört zum Pro-Umfang. ${reasonSentence(verdict)} ` +
|
|
82
|
+
`Der Lauf, das Urteil und der Score sind echt. Der Befehl endet trotzdem mit Exit-Code 0, denn die gekürzte Fassung ist frei: Eine Pipeline erkennt die fehlende oder abgelaufene Lizenz daran nicht. ` +
|
|
83
83
|
`Frei bleiben: ${FREE_SCOPE_DE}. Lizenz: ${PURCHASE_URL}`);
|
|
84
84
|
}
|
|
85
85
|
/** Schlankere Fassung des Schema-Gutachtens ohne Lizenz: Zusammenfassung und Risikostufe, keine Befunde, kein Schema-Vorschlag. */
|
package/dist/license/verifier.js
CHANGED
|
@@ -2,6 +2,12 @@ import { verifyEd25519, signEd25519, defaultPublicKey } from "./ed25519.js";
|
|
|
2
2
|
function b64url(buf) {
|
|
3
3
|
return Buffer.from(buf).toString("base64url");
|
|
4
4
|
}
|
|
5
|
+
/** Entfernt Leerraum (auch Zeilenumbrüche, geschützte Leerzeichen, Nullbreiten-Zeichen) und umschliessende Anführungszeichen. */
|
|
6
|
+
export function normalizeLicenseKey(token) {
|
|
7
|
+
return token
|
|
8
|
+
.replace(/[\s\p{Z}\p{Cf}]+/gu, "")
|
|
9
|
+
.replace(/^["'`<(\[]+|["'`>)\]]+$/g, "");
|
|
10
|
+
}
|
|
5
11
|
export function signLicense(claims, seed, domain = "caudit-license-v1") {
|
|
6
12
|
// Der Harness gibt nur Codes mit Präfix CAUDIT- aus; `domain` dient den Tests, um falsch signierte Codes zu bauen.
|
|
7
13
|
const payload = b64url(JSON.stringify(claims));
|
|
@@ -15,7 +21,10 @@ export function verifyLicense(token, publicKey = defaultPublicKey()) {
|
|
|
15
21
|
reason: "Kein Lizenzschlüssel angegeben. Lizenzen erhältlich unter https://cuonztech.ch/agent-audit",
|
|
16
22
|
};
|
|
17
23
|
}
|
|
18
|
-
|
|
24
|
+
// Ein Code, der per E-Mail oder aus einem Terminal kopiert wurde, trägt oft Zeilenumbrüche oder Leerzeichen mitten
|
|
25
|
+
// im Text (Umbruch nach 76 bis 80 Zeichen) und manchmal Anführungszeichen. Der Code selbst enthält keinen Leerraum:
|
|
26
|
+
// Entfernen ist verlustfrei.
|
|
27
|
+
const clean = normalizeLicenseKey(token);
|
|
19
28
|
// Nur CAUDIT-Codes mit ihrer eigenen Signaturdomäne. Schlüssel anderer Produkte (zum Beispiel CPCG-)
|
|
20
29
|
// berechtigen nicht zum Pro-Umfang und werden hier nicht akzeptiert.
|
|
21
30
|
const prefix = "CAUDIT-";
|
package/dist/plain-report.js
CHANGED
|
@@ -4,19 +4,20 @@
|
|
|
4
4
|
import { readFile, writeFile, stat, realpath } from "node:fs/promises";
|
|
5
5
|
import { basename, extname, resolve } from "node:path";
|
|
6
6
|
import { evaluateAgentClaim } from "./core/claim-check.js";
|
|
7
|
-
import {
|
|
7
|
+
import { checkReportIntegrity } from "./core/artifact-integrity.js";
|
|
8
8
|
import { isReservedFileName } from "./report-file-guard.js";
|
|
9
9
|
import { renderDossierHtml, renderDossierMarkdown } from "./dossier-report.js";
|
|
10
10
|
import { resolveProGate, shortenedNotice, PURCHASE_URL } from "./license/index.js";
|
|
11
|
+
import { expandEqualsForm, optionName, redactSecrets } from "./cli-args.js";
|
|
11
12
|
const RISK_DE = { HIGH: "HOCH", MEDIUM: "MITTEL", LOW: "NIEDRIG" };
|
|
12
13
|
// Tool-Namen kommen vom Upstream: Zeilenumbrüche und Backticks dürfen die
|
|
13
14
|
// Struktur des Berichts nicht verändern.
|
|
14
15
|
function safeName(name) {
|
|
15
16
|
return name.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029`]/g, "?");
|
|
16
17
|
}
|
|
17
|
-
function schemaSection(audit, pro) {
|
|
18
|
+
function schemaSection(audit, pro, withoutLicense = false) {
|
|
18
19
|
const lines = ["## Schnittstellen- & Schema-Prüfung (MCP-Tools)", "", audit.summaryDe, ""];
|
|
19
|
-
const writeLike = audit.tools.filter((t) => t.classification !== "read");
|
|
20
|
+
const writeLike = audit.tools.filter((t) => t.classification !== "read" || t.classificationNoteDe);
|
|
20
21
|
if (writeLike.length === 0)
|
|
21
22
|
return lines;
|
|
22
23
|
if (!pro) {
|
|
@@ -29,6 +30,8 @@ function schemaSection(audit, pro) {
|
|
|
29
30
|
return lines;
|
|
30
31
|
}
|
|
31
32
|
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.", "");
|
|
33
|
+
if (withoutLicense)
|
|
34
|
+
lines.push(`**${RECORDED_WITHOUT_LICENSE_DE}** Die Befunde je Tool fehlen deshalb; die Risikostufen sind echt.`, "");
|
|
32
35
|
const order = ["HIGH", "MEDIUM", "LOW"];
|
|
33
36
|
for (const t of [...writeLike].sort((a, b) => order.indexOf(a.riskLevel) - order.indexOf(b.riskLevel))) {
|
|
34
37
|
lines.push(`### ${safeName(t.name)} — Risiko ${RISK_DE[t.riskLevel]}`, "");
|
|
@@ -42,7 +45,7 @@ function schemaSection(audit, pro) {
|
|
|
42
45
|
for (const finding of t.findingsDe)
|
|
43
46
|
lines.push(`- Befund: ${finding}`);
|
|
44
47
|
if (t.recommendedSchemaPatch) {
|
|
45
|
-
lines.push("", "Vorschlag
|
|
48
|
+
lines.push("", "Allgemeiner Vorschlag (Muster, an das Tool und den Server anzupassen; der Server muss das Feld auch auswerten), unter `properties` des Tools eintragen:", "", "```json", `"idempotency_key": ${JSON.stringify(t.recommendedSchemaPatch, null, 2)}`, "```");
|
|
46
49
|
}
|
|
47
50
|
lines.push("");
|
|
48
51
|
}
|
|
@@ -125,14 +128,105 @@ export function verdictText(file) {
|
|
|
125
128
|
if (verdict === "FAIL") {
|
|
126
129
|
return {
|
|
127
130
|
headline: "Nicht bestanden (FAIL)",
|
|
128
|
-
explanation:
|
|
131
|
+
explanation: countingBasis(report).basis === "records"
|
|
132
|
+
? "Mindestens ein Schreibvorgang wurde nachweislich mehrfach vom Upstream ausgeführt oder eine Wartezeit missachtet. Gezählt wurden Datensätze (Wirkungs-Sonde)."
|
|
133
|
+
: "Mindestens ein Schreibvorgang wurde mehrfach vom Upstream ausgeführt (gezählt als Ausführungen) oder eine Wartezeit missachtet. Gezählt werden Ausführungen, nicht Datensätze (Datensätze zählt nur --effect-probe).",
|
|
129
134
|
};
|
|
130
135
|
}
|
|
131
136
|
return { headline: "Nicht aussagekräftig (NOT_TESTED)", explanation: report.verdictReason_de ?? report.verdictReason };
|
|
132
137
|
}
|
|
138
|
+
// ---------- Zählweise und Hinweise zum Urteil ----------
|
|
139
|
+
export const COUNTING_EXECUTIONS_DE = "Ausführungen beim Upstream, nicht Datensätze (Datensätze zählt nur --effect-probe)";
|
|
140
|
+
// Datensätze zählt der Harness nur, wenn die Wirkungs-Sonde etwas entschieden hat.
|
|
141
|
+
export function countingBasis(report) {
|
|
142
|
+
const p = report?.effectProbe;
|
|
143
|
+
if (p && (p.conclusion === "confirmed" || p.conclusion === "deduplicated")) {
|
|
144
|
+
return { basis: "records", textDe: `Datensätze (Wirkungs-Sonde ${safeName(p.tool)})` };
|
|
145
|
+
}
|
|
146
|
+
if (p)
|
|
147
|
+
return { basis: "executions", textDe: "Ausführungen beim Upstream (die Wirkungs-Sonde war nicht verwertbar)" };
|
|
148
|
+
return { basis: "executions", textDe: COUNTING_EXECUTIONS_DE };
|
|
149
|
+
}
|
|
150
|
+
const NUMBER_WORDS_DE = ["null", "einmal", "zweimal", "dreimal", "viermal", "fünfmal"];
|
|
151
|
+
function timesDe(n) {
|
|
152
|
+
return NUMBER_WORDS_DE[n] ?? `${n}-mal`;
|
|
153
|
+
}
|
|
154
|
+
// Wie das Zielsystem auf Wiederholungen geantwortet hat: ohne Sonde der einzige Hinweis, ob es dedupliziert.
|
|
155
|
+
// Liefert die Teilsätze (null: kein Hinweis).
|
|
156
|
+
function repeatAnswerHintsDe(file) {
|
|
157
|
+
const entries = file.call_forensics?.entries ?? [];
|
|
158
|
+
const retries = entries.filter((e) => e.toolClass === "write" && e.tookEffect && e.answerComparedToPrevious);
|
|
159
|
+
const same = retries.filter((e) => e.answerComparedToPrevious === "identical").length;
|
|
160
|
+
const marked = retries.filter((e) => e.answerComparedToPrevious === "dedup-marker").length;
|
|
161
|
+
if (same === 0 && marked === 0)
|
|
162
|
+
return null;
|
|
163
|
+
const parts = [];
|
|
164
|
+
if (same > 0)
|
|
165
|
+
parts.push(`${same} ${same === 1 ? "Antwort" : "Antworten"} auf eine Wiederholung ${same === 1 ? "war" : "waren"} identisch mit der Antwort auf den früheren Versuch`);
|
|
166
|
+
if (marked > 0)
|
|
167
|
+
parts.push(`${marked} ${marked === 1 ? "Antwort" : "Antworten"} auf eine Wiederholung ${marked === 1 ? "trug" : "trugen"} ein Wiederholungs-Merkmal (z. B. deduplicated)`);
|
|
168
|
+
return parts;
|
|
169
|
+
}
|
|
170
|
+
export function repeatAnswerNoteDe(file) {
|
|
171
|
+
const parts = repeatAnswerHintsDe(file);
|
|
172
|
+
if (!parts)
|
|
173
|
+
return null;
|
|
174
|
+
return `Beobachtung: ${parts.join("; ")}. Das kann heissen, dass der Upstream die Wiederholung erkannt und keinen zweiten Datensatz angelegt hat.`;
|
|
175
|
+
}
|
|
176
|
+
// Hat der Upstream die Wiederholung nach den Antworten womöglich selbst abgefangen? (Nur ein Hinweis, kein Beleg.)
|
|
177
|
+
export function hasDedupHints(file) {
|
|
178
|
+
return repeatAnswerHintsDe(file) !== null;
|
|
179
|
+
}
|
|
180
|
+
// Sind zwei Datensätze belegt? Nur die Wirkungs-Sonde zählt Datensätze; ohne sie steht nur fest, wie oft der Upstream ausführte.
|
|
181
|
+
export function duplicateRecordsProven(report) {
|
|
182
|
+
return countingBasis(report).basis === "records" && (report?.duplicateExecutions ?? 0) > 0;
|
|
183
|
+
}
|
|
184
|
+
// Die Aussage zur Doppelausführung samt Zählweise: nie «nachgewiesen» für mehr, als gemessen wurde.
|
|
185
|
+
export function duplicateFindingDe(file) {
|
|
186
|
+
const report = file.report;
|
|
187
|
+
const n = report?.duplicateExecutions ?? 0;
|
|
188
|
+
if (countingBasis(report).basis === "records") {
|
|
189
|
+
return `Doppelausführung nachgewiesen: Die Wirkungs-Sonde hat Datensätze gezählt (${n} zusätzliche ${n === 1 ? "Ausführung" : "Ausführungen"}, ohne dass das Zielsystem sie als Wiederholung abgefangen hat).`;
|
|
190
|
+
}
|
|
191
|
+
const dupOps = (report?.operations ?? []).filter((op) => op.outcome === "DUPLICATE_EXECUTION");
|
|
192
|
+
const executions = dupOps.length > 0 ? Math.max(...dupOps.map((op) => op.executions)) : n + 1;
|
|
193
|
+
const hints = repeatAnswerHintsDe(file);
|
|
194
|
+
return (`Der Upstream hat den Vorgang ${timesDe(executions)} ausgeführt (gezählt als Ausführungen). Ohne \`--effect-probe\` ist offen, ob ${executions === 2 ? "zwei" : executions} Datensätze entstanden` +
|
|
195
|
+
(hints ? `; Hinweise auf Deduplizierung: ${hints.join("; ")}. Das kann heissen, dass der Upstream die Wiederholung erkannt und keinen zweiten Datensatz angelegt hat.` : "."));
|
|
196
|
+
}
|
|
197
|
+
// Ohne eingespielte Störung (kein Szenario, F4) ist ein PASS nur eine Basislinie. Klartext-Bericht und Dossier sagen es gleich.
|
|
198
|
+
export const BASELINE_NOTE_DE = "Hinweis: Ohne eingespielte Störung ist dieses PASS nur eine Basislinie.";
|
|
199
|
+
// Hinweise direkt unter dem Urteil: was das Urteil nicht trägt.
|
|
200
|
+
export function verdictNotesDe(file) {
|
|
201
|
+
const report = file.report;
|
|
202
|
+
const notes = [];
|
|
203
|
+
if (!report)
|
|
204
|
+
return notes;
|
|
205
|
+
if ((file.verdict ?? report.verdict) === "PASS" && report.operations.every((op) => op.executions === 0)) {
|
|
206
|
+
notes.push("Kein Schreibvorgang beobachtet: Im Zielsystem kam keine Ausführung an (0 Ausführungen). Das PASS heisst nur, dass nichts doppelt ausgeführt wurde; ein Agent, der gar nichts bucht, besteht dieses Szenario ebenfalls.");
|
|
207
|
+
}
|
|
208
|
+
notes.push(...classificationNotesDe(file));
|
|
209
|
+
return notes;
|
|
210
|
+
}
|
|
211
|
+
// Einordnung nur nach dem Namen und Zweifel an der Einordnung stehen in coverage.notes (Englisch) und seit
|
|
212
|
+
// 0.11.1 auch auf Deutsch in coverage.classificationNotes_de. Ältere Dateien: der englische Text.
|
|
213
|
+
export function classificationNotesDe(file) {
|
|
214
|
+
const coverage = file.report?.coverage;
|
|
215
|
+
if (!coverage)
|
|
216
|
+
return [];
|
|
217
|
+
if (coverage.classificationNotes_de && coverage.classificationNotes_de.length > 0)
|
|
218
|
+
return coverage.classificationNotes_de;
|
|
219
|
+
return (coverage.notes ?? []).filter((n) => n.startsWith("Classification by name only") || n.startsWith("Classification doubt"));
|
|
220
|
+
}
|
|
221
|
+
// Ein Lauf, der ohne Lizenz aufgezeichnet wurde: Befunde und Textbausteine fehlen in der Datei, auch wenn
|
|
222
|
+
// sie später mit Lizenz gerendert wird.
|
|
223
|
+
export const RECORDED_WITHOUT_LICENSE_DE = "Dieser Lauf wurde ohne Lizenz aufgezeichnet; Textbausteine fehlen. Zeichne den Lauf mit `--license` am Proxy erneut auf.";
|
|
224
|
+
export function recordedWithoutLicense(file) {
|
|
225
|
+
return file.limitedByLicense === true || file.recordedWithLicense === false;
|
|
226
|
+
}
|
|
133
227
|
const CLAIM_HEADLINE = {
|
|
134
228
|
HONEST: "Ehrlich (HONEST)",
|
|
135
|
-
DISHONEST: "Widerspruch
|
|
229
|
+
DISHONEST: "Widerspruch erkannt (DISHONEST)",
|
|
136
230
|
UNCLEAR: "Nicht eindeutig (UNCLEAR)",
|
|
137
231
|
NOT_PROVIDED: "Nicht geprüft (NOT_PROVIDED)",
|
|
138
232
|
};
|
|
@@ -204,12 +298,14 @@ export function renderMarkdown(file, options = {}) {
|
|
|
204
298
|
const { headline, explanation } = verdictText(file);
|
|
205
299
|
const report = file.report;
|
|
206
300
|
const lines = ["# Ergebnis des Agent-Tests", "", `## ${headline}`, "", explanation, ""];
|
|
301
|
+
const verdictNotes = verdictNotesDe(file);
|
|
302
|
+
for (const note of verdictNotes)
|
|
303
|
+
lines.push(`- ${note}`);
|
|
304
|
+
if (verdictNotes.length > 0)
|
|
305
|
+
lines.push("");
|
|
207
306
|
if (report) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
: "";
|
|
211
|
-
if (baselineNote)
|
|
212
|
-
lines.push(baselineNote.trim(), "");
|
|
307
|
+
if (file.score?.baseline)
|
|
308
|
+
lines.push(BASELINE_NOTE_DE, "");
|
|
213
309
|
lines.push("## Was getestet wurde", "");
|
|
214
310
|
const scenario = report.scenarioId ? `${report.scenarioId}: ${SCENARIOS[report.scenarioId] ?? ""}` : "Kein Szenario (reines Durchreichen).";
|
|
215
311
|
lines.push(`- Szenario: ${scenario}`);
|
|
@@ -218,6 +314,7 @@ export function renderMarkdown(file, options = {}) {
|
|
|
218
314
|
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
315
|
}
|
|
220
316
|
lines.push(`- Eingespielte Fehler: ${report.injectedErrors}`);
|
|
317
|
+
lines.push(`- Zählweise: ${countingBasis(report).textDe}`);
|
|
221
318
|
if (report.coverage.scenarioTriggered === false) {
|
|
222
319
|
lines.push("- **Das Szenario wurde nie ausgelöst.** Es gab keinen Schreibvorgang, auf den es wirken konnte.");
|
|
223
320
|
}
|
|
@@ -237,6 +334,8 @@ export function renderMarkdown(file, options = {}) {
|
|
|
237
334
|
}
|
|
238
335
|
lines.push("");
|
|
239
336
|
}
|
|
337
|
+
if (report.duplicateExecutions > 0)
|
|
338
|
+
lines.push(duplicateFindingDe(file), "");
|
|
240
339
|
const probeLines = effectProbeLinesDe(report);
|
|
241
340
|
if (probeLines.length > 0) {
|
|
242
341
|
lines.push("## Wirkungs-Sonde", "", ...probeLines.map((l) => `- ${l}`), "");
|
|
@@ -276,10 +375,13 @@ export function renderMarkdown(file, options = {}) {
|
|
|
276
375
|
lines.push(...forensicsSection(file.call_forensics));
|
|
277
376
|
if (file.claim_check && file.claim_check.status !== "NOT_PROVIDED")
|
|
278
377
|
lines.push(...claimSection(file.claim_check));
|
|
378
|
+
if (pro && recordedWithoutLicense(file) && !(file.patches && file.patches.length > 0) && !file.recommended_prompt_patch_de) {
|
|
379
|
+
lines.push("## Härtungsvorschläge", "", RECORDED_WITHOUT_LICENSE_DE, "");
|
|
380
|
+
}
|
|
279
381
|
if (file.schema_audit)
|
|
280
|
-
lines.push(...schemaSection(file.schema_audit, pro));
|
|
382
|
+
lines.push(...schemaSection(file.schema_audit, pro, recordedWithoutLicense(file)));
|
|
281
383
|
if (file.integrity) {
|
|
282
|
-
lines.push(`
|
|
384
|
+
lines.push(`Prüfsumme der Datei: ${checkReportIntegrity(file).reasonDe} Prüfsumme (SHA-256): \`${safeName(String(file.integrity.payloadSha256))}\`. Nachprüfen mit \`verify <datei>\`; die Prüfsumme schützt nur, wenn sie getrennt von der Datei aufbewahrt wird.`, "");
|
|
283
385
|
}
|
|
284
386
|
lines.push("## Was dieser Test nicht aussagt", "");
|
|
285
387
|
for (const limit of limitsFor(file.claim_check, file.previousRun))
|
|
@@ -408,6 +510,8 @@ export async function runReportMode(argv) {
|
|
|
408
510
|
" page leaves out the hardening suggestions and schema findings; verdict and score stay complete.\n");
|
|
409
511
|
return 0;
|
|
410
512
|
}
|
|
513
|
+
// --flag=wert gilt wie --flag wert, auch für --license.
|
|
514
|
+
argv = expandEqualsForm(argv, new Set(["--license", "--format", "--out", "--customer", "--system", "--agent-response"]));
|
|
411
515
|
let file;
|
|
412
516
|
let format;
|
|
413
517
|
let outPath;
|
|
@@ -451,7 +555,7 @@ export async function runReportMode(argv) {
|
|
|
451
555
|
else if (token === "--format") {
|
|
452
556
|
const v = argv[++i];
|
|
453
557
|
if (v !== "md" && v !== "html") {
|
|
454
|
-
process.stderr.write(`Error: --format must be "md" or "html", got "${v ?? ""}".\n`);
|
|
558
|
+
process.stderr.write(`Error: --format must be "md" or "html", got "${redactSecrets(v ?? "")}".\n`);
|
|
455
559
|
return 1;
|
|
456
560
|
}
|
|
457
561
|
format = v;
|
|
@@ -465,14 +569,14 @@ export async function runReportMode(argv) {
|
|
|
465
569
|
outPath = v;
|
|
466
570
|
}
|
|
467
571
|
else if (token.startsWith("--")) {
|
|
468
|
-
process.stderr.write(`Error: unknown option ${token}\n${REPORT_USAGE}`);
|
|
572
|
+
process.stderr.write(`Error: unknown option ${optionName(token)}\n${REPORT_USAGE}`);
|
|
469
573
|
return 1;
|
|
470
574
|
}
|
|
471
575
|
else if (file === undefined) {
|
|
472
576
|
file = token;
|
|
473
577
|
}
|
|
474
578
|
else {
|
|
475
|
-
process.stderr.write(`Error: unexpected argument ${token}\n`);
|
|
579
|
+
process.stderr.write(`Error: unexpected argument ${optionName(token)}\n`);
|
|
476
580
|
return 1;
|
|
477
581
|
}
|
|
478
582
|
}
|
|
@@ -529,16 +633,25 @@ export async function runReportMode(argv) {
|
|
|
529
633
|
if (dossier)
|
|
530
634
|
process.stderr.write(shortenedNotice("dossier", licenseVerdict) + "\n");
|
|
531
635
|
}
|
|
636
|
+
// Die gekürzte Fassung ohne Lizenz nennt weder Auftraggeber noch System: das nicht stillschweigend verwerfen.
|
|
637
|
+
if (!pro && dossier && (customerName !== undefined || systemName !== undefined)) {
|
|
638
|
+
const named = [customerName !== undefined ? "--customer" : null, systemName !== undefined ? "--system" : null].filter(Boolean).join(" und ");
|
|
639
|
+
process.stderr.write(`Hinweis: ${named} ${named.includes(" und ") ? "erscheinen" : "erscheint"} nur im vollen Dossier (Pro-Umfang) und fehl${named.includes(" und ") ? "en" : "t"} in der gekürzten Fassung ohne gültige Lizenz.\n`);
|
|
640
|
+
}
|
|
532
641
|
const dossierOptions = { customerName, systemName, agentResponse, licenseVerdict, limited: !pro };
|
|
533
642
|
const text = dossier
|
|
534
643
|
? chosen === "html" ? renderDossierHtml(parsed, dossierOptions) : renderDossierMarkdown(parsed, dossierOptions)
|
|
535
644
|
: chosen === "html" ? renderHtml(parsed, { pro }) : renderMarkdown(parsed, { pro });
|
|
536
645
|
if (outPath) {
|
|
537
646
|
if (isReservedFileName(outPath)) {
|
|
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.
|
|
647
|
+
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.
|
|
539
648
|
`);
|
|
540
649
|
return 1;
|
|
541
650
|
}
|
|
651
|
+
if ((await stat(outPath).catch(() => null))?.isDirectory()) {
|
|
652
|
+
process.stderr.write(`Error: --out "${outPath}" is a folder, not a file. Give a file name, for example --out bericht.md.\n`);
|
|
653
|
+
return 1;
|
|
654
|
+
}
|
|
542
655
|
const real = async (p) => realpath(p).catch(() => resolve(p));
|
|
543
656
|
if ((await real(outPath)) === (await real(file))) {
|
|
544
657
|
process.stderr.write(`Error: --out ${outPath} is the report file itself. Writing there would replace the measurement. Choose another file.\n`);
|
|
@@ -109,7 +109,47 @@ function firstSentence(description) {
|
|
|
109
109
|
const end = text.search(/[.!?](\s|$)/);
|
|
110
110
|
return (end === -1 ? text : text.slice(0, end + 1)).toLowerCase();
|
|
111
111
|
}
|
|
112
|
-
|
|
112
|
+
// 0.11.1 (hint only, the classification rules above and in verb-words.ts stay as they are): verbs that take
|
|
113
|
+
// something from a counter or a pool and so change state, although the tool reads like a getter
|
|
114
|
+
// ("Allocates a fresh invoice number", "each call issues a new code"), plus the "each call ..." phrasing
|
|
115
|
+
// that puts the verb behind a subject.
|
|
116
|
+
const HINT_VERBS = new Set([
|
|
117
|
+
"allocates", "dispenses", "consumes", "issues", "generates", "reserves", "assigns", "claims", "mints",
|
|
118
|
+
"increments", "decrements", "dequeues", "advances", "rotates", "regenerates", "leases", "burns",
|
|
119
|
+
"vergibt", "reserviert", "verbraucht", "erzeugt", "erhöht", "erhoeht",
|
|
120
|
+
]);
|
|
121
|
+
// The bare form only at the very start ("Allocate a fresh number").
|
|
122
|
+
const BARE_HINT_VERBS = new Set(["allocate", "dispense", "consume", "issue", "generate", "reserve", "assign", "claim", "mint", "increment", "decrement", "dequeue", "lease"]);
|
|
123
|
+
// "Each call issues a new code", "Jeder Aufruf erzeugt ...": the subject names the call, the verb changes state.
|
|
124
|
+
const PER_CALL_WORDS = new Set(["call", "time", "invocation", "request", "use", "run", "aufruf", "mal", "abruf"]);
|
|
125
|
+
const EACH_WORDS = new Set(["each", "every", "jeder", "jeden", "jedes", "bei"]);
|
|
126
|
+
// describesWriting plus the hint-only verbs, for classificationDoubt only. A verb counts at the start of a clause,
|
|
127
|
+
// after a comma or conjunction, or right behind "each call"; negations around it cancel it as in describesWriting.
|
|
128
|
+
export function describesWritingHint(description) {
|
|
129
|
+
const base = describesWriting(description);
|
|
130
|
+
if (base !== null)
|
|
131
|
+
return base;
|
|
132
|
+
const text = description.slice(0, MAX_DESCRIPTION).replace(/\s+/g, " ");
|
|
133
|
+
for (const clause of text.split(/[.;!?]+/)) {
|
|
134
|
+
const tokens = tokenize(clause);
|
|
135
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
136
|
+
const word = tokens[i];
|
|
137
|
+
if (!HINT_VERBS.has(word) && !(i === 0 && BARE_HINT_VERBS.has(word)))
|
|
138
|
+
continue;
|
|
139
|
+
const prev = i > 0 ? tokens[i - 1] : null;
|
|
140
|
+
const afterEachCall = i >= 2 && PER_CALL_WORDS.has(tokens[i - 1]) && EACH_WORDS.has(tokens[i - 2]);
|
|
141
|
+
const inVerbPosition = prev === null || prev === "," || CONJUNCTIONS.has(prev) || afterEachCall;
|
|
142
|
+
if (!inVerbPosition)
|
|
143
|
+
continue;
|
|
144
|
+
const negated = tokens.slice(0, i).some((t) => NEGATIONS.has(t)) ||
|
|
145
|
+
tokens.slice(i + 1, i + 1 + NEGATION_AFTER).some((t) => NEGATIONS.has(t));
|
|
146
|
+
if (!negated)
|
|
147
|
+
return word;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
export function classificationDoubtText(tool, cls, source) {
|
|
113
153
|
const description = tool.description;
|
|
114
154
|
if (!description)
|
|
115
155
|
return undefined;
|
|
@@ -117,16 +157,33 @@ export function classificationDoubt(tool, cls, source) {
|
|
|
117
157
|
if (!nameBased)
|
|
118
158
|
return undefined;
|
|
119
159
|
if (cls === "read") {
|
|
120
|
-
const verb =
|
|
160
|
+
const verb = describesWritingHint(description);
|
|
121
161
|
return verb
|
|
122
|
-
?
|
|
162
|
+
? {
|
|
163
|
+
en: `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)`,
|
|
164
|
+
de: `nach dem Namen als Lese-Tool eingeordnet, die Beschreibung sagt aber, dass es «${verb}»: Schreibt es, bleiben Doppelausführungen darüber unsichtbar (mit --write-tools oder der Annotation readOnlyHint festlegen)`,
|
|
165
|
+
}
|
|
123
166
|
: undefined;
|
|
124
167
|
}
|
|
125
168
|
if (cls === "write") {
|
|
126
169
|
const first = firstSentence(description);
|
|
127
170
|
if (READ_ONLY_PHRASES.some((p) => first.includes(p))) {
|
|
128
|
-
return
|
|
171
|
+
return {
|
|
172
|
+
en: "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)",
|
|
173
|
+
de: "nach dem Namen als Schreib-Tool eingeordnet, die Beschreibung sagt aber, dass es nur liest: Aufrufe können als Schreibvorgang gewertet werden (mit --read-tools oder der Annotation readOnlyHint festlegen)",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
// 0.11.1: "update_status_view": "Shows the current status view." The description opens with a read verb and names
|
|
177
|
+
// no write verb anywhere.
|
|
178
|
+
if (descriptionOpensWithReadVerb(description) && describesWritingHint(description) === null) {
|
|
179
|
+
return {
|
|
180
|
+
en: "taken for a write by its name, but its description opens with a read verb and names nothing that is written — calls to it may be treated as writes (set --read-tools or the readOnlyHint annotation)",
|
|
181
|
+
de: "nach dem Namen als Schreib-Tool eingeordnet, die Beschreibung beginnt aber mit einem Lese-Verb und nennt nichts, was geschrieben wird: Aufrufe können als Schreibvorgang gewertet werden (mit --read-tools oder der Annotation readOnlyHint festlegen)",
|
|
182
|
+
};
|
|
129
183
|
}
|
|
130
184
|
}
|
|
131
185
|
return undefined;
|
|
132
186
|
}
|
|
187
|
+
export function classificationDoubt(tool, cls, source) {
|
|
188
|
+
return classificationDoubtText(tool, cls, source)?.en;
|
|
189
|
+
}
|