cuonztech-agent-harness 0.10.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 +54 -13
- 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 +17 -1
- package/dist/doctor.js +3 -0
- package/dist/dossier-report.js +230 -51
- package/dist/engine/state-machine.js +26 -3
- 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 +89 -4
- package/dist/proxy/interceptor.js +25 -4
- package/dist/proxy/verb-words.js +160 -4
- 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
|
@@ -15,6 +15,13 @@ const WRITE_VERBS = new Set([
|
|
|
15
15
|
"speichert", "zahlt", "belastet", "verrechnet", "überweist", "ueberweist", "registriert", "entfernt",
|
|
16
16
|
"schickt", "versendet", "aktualisiert", "reserviert", "bestätigt", "bestaetigt", "setzt", "erzeugt",
|
|
17
17
|
"überschreibt", "ueberschreibt", "verbucht",
|
|
18
|
+
// 0.11.0: further verbs that put something into a state ("Changes the status", "locks the job").
|
|
19
|
+
"changes", "locks", "unlocks", "clears", "resets", "assigns", "grants", "revokes", "moves", "renames",
|
|
20
|
+
"deploys", "restarts", "stops", "starts", "enables", "disables", "activates", "deactivates", "invites",
|
|
21
|
+
"subscribes", "unsubscribes", "inserts", "appends", "replaces", "kills", "terminates", "closes", "reopens",
|
|
22
|
+
"dispatches", "enqueues", "settles", "captures", "voids", "orders", "ships", "invoices", "mints", "burns",
|
|
23
|
+
"swaps", "stakes", "claims", "votes", "shares", "tags", "labels", "flags", "pops", "acknowledges", "dismisses",
|
|
24
|
+
"ändert", "legt", "löscht", "setzt", "sperrt",
|
|
18
25
|
]);
|
|
19
26
|
const BARE_WRITE_VERBS = new Set([
|
|
20
27
|
"create", "book", "bill", "charge", "send", "delete", "remove", "cancel", "update", "modify",
|
|
@@ -72,6 +79,27 @@ export function descriptionOpensWithReadVerb(description) {
|
|
|
72
79
|
const first = description.slice(0, 200).trim().toLowerCase().match(/^[\p{L}]+/u);
|
|
73
80
|
return first !== null && READ_OPENERS.has(first[0]);
|
|
74
81
|
}
|
|
82
|
+
// The first sentence opens with a plain write verb ("Creates a booking", "Löscht einen Eintrag"), not
|
|
83
|
+
// negated. Narrower than describesWriting: no generic verbs ("generates", "logs", "marks"), and only at
|
|
84
|
+
// the very start, because the prefix decides nothing here — the description is the only witness.
|
|
85
|
+
const OPENING_WRITE_VERBS = new Set([
|
|
86
|
+
"creates", "adds", "inserts", "updates", "modifies", "deletes", "removes", "cancels", "books", "sends",
|
|
87
|
+
"submits", "pays", "transfers", "refunds", "charges", "writes", "saves", "stores", "registers", "approves",
|
|
88
|
+
"publishes", "uploads", "schedules", "reserves", "places", "posts", "archives", "overwrites", "changes",
|
|
89
|
+
"assigns", "deploys", "restarts", "activates", "deactivates", "enables", "disables",
|
|
90
|
+
"create", "add", "insert", "update", "modify", "delete", "remove", "cancel", "book", "send", "submit",
|
|
91
|
+
"pay", "transfer", "refund", "charge", "write", "save", "store", "register", "approve", "publish",
|
|
92
|
+
"upload", "schedule", "reserve", "place", "post", "archive", "overwrite", "change", "assign", "deploy",
|
|
93
|
+
"erstellt", "legt", "löscht", "loescht", "ändert", "aendert", "sendet", "bucht", "storniert", "speichert",
|
|
94
|
+
"überweist", "ueberweist", "entfernt", "aktualisiert", "versendet", "registriert", "reserviert",
|
|
95
|
+
"bestätigt", "bestaetigt", "schreibt", "verbucht", "setzt",
|
|
96
|
+
]);
|
|
97
|
+
export function descriptionOpensWithWriteVerb(description) {
|
|
98
|
+
const tokens = tokenize(firstSentence(description));
|
|
99
|
+
if (tokens.length === 0 || !OPENING_WRITE_VERBS.has(tokens[0]))
|
|
100
|
+
return false;
|
|
101
|
+
return !tokens.slice(1, 1 + NEGATION_AFTER + 1).some((t) => NEGATIONS.has(t));
|
|
102
|
+
}
|
|
75
103
|
export function descriptionSaysReadOnly(description) {
|
|
76
104
|
const sentence = firstSentence(description);
|
|
77
105
|
return READ_ONLY_PHRASES.some((p) => sentence.includes(p));
|
|
@@ -81,7 +109,47 @@ function firstSentence(description) {
|
|
|
81
109
|
const end = text.search(/[.!?](\s|$)/);
|
|
82
110
|
return (end === -1 ? text : text.slice(0, end + 1)).toLowerCase();
|
|
83
111
|
}
|
|
84
|
-
|
|
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) {
|
|
85
153
|
const description = tool.description;
|
|
86
154
|
if (!description)
|
|
87
155
|
return undefined;
|
|
@@ -89,16 +157,33 @@ export function classificationDoubt(tool, cls, source) {
|
|
|
89
157
|
if (!nameBased)
|
|
90
158
|
return undefined;
|
|
91
159
|
if (cls === "read") {
|
|
92
|
-
const verb =
|
|
160
|
+
const verb = describesWritingHint(description);
|
|
93
161
|
return verb
|
|
94
|
-
?
|
|
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
|
+
}
|
|
95
166
|
: undefined;
|
|
96
167
|
}
|
|
97
168
|
if (cls === "write") {
|
|
98
169
|
const first = firstSentence(description);
|
|
99
170
|
if (READ_ONLY_PHRASES.some((p) => first.includes(p))) {
|
|
100
|
-
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
|
+
};
|
|
101
183
|
}
|
|
102
184
|
}
|
|
103
185
|
return undefined;
|
|
104
186
|
}
|
|
187
|
+
export function classificationDoubt(tool, cls, source) {
|
|
188
|
+
return classificationDoubtText(tool, cls, source)?.en;
|
|
189
|
+
}
|
|
@@ -6,8 +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 {
|
|
10
|
-
import { classifyByVerbWords, readPrefixThenWrites } from "./verb-words.js";
|
|
9
|
+
import { classificationDoubtText } from "./classification-doubt.js";
|
|
10
|
+
import { classifyByDescriptionOpener, compoundWriteGuess, classifyByVerbWords, readPrefixThenWrites, weakReadPrefixThenWrites, } from "./verb-words.js";
|
|
11
11
|
import { looksLikeErrorAnswer } from "./error-answer.js";
|
|
12
12
|
import { parseProbeReading } from "../core/effect-probe.js";
|
|
13
13
|
import { readFileSync, readdirSync } from "node:fs";
|
|
@@ -22,8 +22,8 @@ function sleep(ms) {
|
|
|
22
22
|
// of being quietly treated as a harmless read.
|
|
23
23
|
export function classifyUpstreamTool(tool, config) {
|
|
24
24
|
const result = classifyByRules(tool, config);
|
|
25
|
-
const doubt =
|
|
26
|
-
return doubt ? { ...result, doubt } : result;
|
|
25
|
+
const doubt = classificationDoubtText(tool, result.class, result.source);
|
|
26
|
+
return doubt ? { ...result, doubt: doubt.en, doubtDe: doubt.de } : result;
|
|
27
27
|
}
|
|
28
28
|
function classifyByRules(tool, config) {
|
|
29
29
|
const name = tool.name;
|
|
@@ -57,8 +57,20 @@ function classifyByRules(tool, config) {
|
|
|
57
57
|
if (isReadTool(name)) {
|
|
58
58
|
// "get_or_create_user", "read_and_delete": the read prefix alone would call a write a read.
|
|
59
59
|
if (explicitWrite.length === 0 && readPrefixThenWrites(name)) {
|
|
60
|
+
if (explicitRead.length === 0 && compoundWriteGuess(tool.description) === "write") {
|
|
61
|
+
return { name, class: "write", source: "read verb, then a write verb (and/or/then)", guess: true };
|
|
62
|
+
}
|
|
60
63
|
return { name, class: "unclassified", source: "read prefix, but a write verb follows (and/or/then)" };
|
|
61
64
|
}
|
|
65
|
+
// "check_in_guest", "status_change": a weak read prefix (a noun as often as a verb) followed by a write verb.
|
|
66
|
+
if (explicitWrite.length === 0 && explicitRead.length === 0) {
|
|
67
|
+
const weak = weakReadPrefixThenWrites(name, tool.description);
|
|
68
|
+
if (weak === "write")
|
|
69
|
+
return { name, class: "write", source: "verb word after a weak read prefix", guess: true };
|
|
70
|
+
if (weak === "unclassified") {
|
|
71
|
+
return { name, class: "unclassified", source: "weak read prefix, then a write verb, but the description says it reads" };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
62
74
|
return { name, class: "read", source: "read prefix" };
|
|
63
75
|
}
|
|
64
76
|
// Last resort for what nothing above could place: a verb inside the name (`slack_post_message`).
|
|
@@ -67,6 +79,10 @@ function classifyByRules(tool, config) {
|
|
|
67
79
|
const byWord = classifyByVerbWords(name, tool.description);
|
|
68
80
|
if (byWord !== "unclassified")
|
|
69
81
|
return { name, class: byWord, source: "verb word in name", guess: true };
|
|
82
|
+
// Nothing in the name: the description opens with a plain write verb ("Creates a booking").
|
|
83
|
+
if (classifyByDescriptionOpener(tool.description) === "write") {
|
|
84
|
+
return { name, class: "write", source: "description opens with a write verb", guess: true };
|
|
85
|
+
}
|
|
70
86
|
}
|
|
71
87
|
return { name, class: "unclassified", source: "no annotation, no pattern match" };
|
|
72
88
|
}
|
|
@@ -232,6 +248,8 @@ export class ProxyInterceptor {
|
|
|
232
248
|
this.session.toolClassOverrides.set(entry.name, entry.class);
|
|
233
249
|
if (entry.doubt) {
|
|
234
250
|
(this.session.classificationDoubts ??= new Map()).set(entry.name, entry.doubt);
|
|
251
|
+
if (entry.doubtDe)
|
|
252
|
+
(this.session.classificationDoubtsDe ??= new Map()).set(entry.name, entry.doubtDe);
|
|
235
253
|
}
|
|
236
254
|
if (entry.guess)
|
|
237
255
|
(this.session.verbWordGuesses ??= new Set()).add(entry.name);
|
|
@@ -545,6 +563,9 @@ export class ProxyInterceptor {
|
|
|
545
563
|
outcome: "injected_failure",
|
|
546
564
|
toolClass,
|
|
547
565
|
tookEffect,
|
|
566
|
+
// The agent is told "timeout", but the upstream's own answer may read like an error ("Fehler: …"
|
|
567
|
+
// without isError): the effect probe must not take it for a record that exists (same as below).
|
|
568
|
+
...(tookEffect && looksLikeErrorAnswer(upstreamText) ? { answerLooksLikeError: true } : {}),
|
|
548
569
|
scenarioTriggered: decision.scenarioTriggered,
|
|
549
570
|
...(decision.retryAfterSec !== null ? { retryAfterSec: decision.retryAfterSec } : {}),
|
|
550
571
|
...(upstreamText !== undefined ? { upstreamResponse: upstreamText } : {}),
|
package/dist/proxy/verb-words.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describesWriting, descriptionOpensWithReadVerb, descriptionSaysReadOnly } from "./classification-doubt.js";
|
|
1
|
+
import { describesWriting, descriptionOpensWithReadVerb, descriptionOpensWithWriteVerb, descriptionSaysReadOnly, } from "./classification-doubt.js";
|
|
2
2
|
const WRITE_WORDS = new Set([
|
|
3
3
|
"create", "add", "insert", "append", "prepend", "update", "edit", "modify", "change", "patch", "put", "set",
|
|
4
4
|
"delete", "remove", "drop", "destroy", "purge", "clear", "erase", "reset", "restore", "recover",
|
|
@@ -13,7 +13,120 @@ const WRITE_WORDS = new Set([
|
|
|
13
13
|
"rollout", "rollback", "cordon", "drain", "install", "uninstall", "upgrade", "downgrade",
|
|
14
14
|
"migrate", "cleanup", "flush", "commit", "increment", "decrement", "lock", "unlock", "mute",
|
|
15
15
|
"unmute", "rotate", "enroll",
|
|
16
|
+
// 0.11.0 (Luckensuche, docs/LUECKEN-NAMEN.md): everyday verbs that were missing.
|
|
17
|
+
"activate", "deactivate", "escalate", "provision", "reschedule", "notify", "trigger", "execute",
|
|
18
|
+
"retry", "replay", "finalize", "dispatch", "enqueue", "dequeue", "capture", "settle", "void",
|
|
19
|
+
"deliver", "unstake", "bridge", "dismiss", "acknowledge", "reassign", "renew", "unblock", "unban",
|
|
20
|
+
"checkout", "checkin", "remit", "reconcile", "redeem", "disburse", "onboard", "offboard",
|
|
21
|
+
"authorize", "deauthorize", "whitelist", "blacklist", "allowlist", "denylist", "quarantine",
|
|
22
|
+
"evict", "decommission", "sell", "ack", "nack",
|
|
16
23
|
]);
|
|
24
|
+
// Words that are as often NOUNS as verbs (`order_status`, `explain_invoice`, `download_record`): they
|
|
25
|
+
// count as a write verb only at VERB position (the first word, after an `api`/`v2`/`mcp` namespace)
|
|
26
|
+
// and only when no word naming a state or a listing stands next to them.
|
|
27
|
+
const AMBIGUOUS_WRITE_WORDS = new Set([
|
|
28
|
+
"order", "record", "flag", "label", "tag", "claim", "vote", "share", "swap", "stake", "bill",
|
|
29
|
+
"invoice", "ship", "block", "burn", "mint", "ban", "snooze", "complete", "extend", "suspend",
|
|
30
|
+
"demote",
|
|
31
|
+
]);
|
|
32
|
+
const STATE_NOUNS = new Set([
|
|
33
|
+
"status", "state", "info", "information", "details", "detail", "history", "list", "summary",
|
|
34
|
+
"availability", "preview", "report", "log", "logs", "count", "stats", "statistics", "overview",
|
|
35
|
+
"health", "estimate", "quote", "price", "total",
|
|
36
|
+
// Read-ish words that are not strong enough to make a name a read, but veto an ambiguous noun-verb
|
|
37
|
+
// ("order.check", "invoice.explain", "record.poll").
|
|
38
|
+
"check", "explain", "summarize", "compare", "calculate", "validate", "poll", "scan", "locate", "load",
|
|
39
|
+
"browse", "download", "inspect", "view", "verify", "peek", "probe", "enumerate", "comparer", "calculer",
|
|
40
|
+
"decrire", "contar", "comparar", "calcular", "describir", "verificar", "pruefen", "zaehlen", "vergleichen",
|
|
41
|
+
"berechnen",
|
|
42
|
+
]);
|
|
43
|
+
// German, French and Spanish tool names (`buchung_erstellen`, `supprimer_facture`, `crear_pedido`).
|
|
44
|
+
// Letters are compared in a plain form (ä→ae, ö→oe, ü→ue, ß→ss, accents dropped), so "löschen",
|
|
45
|
+
// "loeschen" and "loschen" meet. The German imperative ("erstelle") is derived.
|
|
46
|
+
const FOREIGN_INFINITIVES = {
|
|
47
|
+
de: [
|
|
48
|
+
"erstellen", "anlegen", "hinzufuegen", "loeschen", "entfernen", "aendern", "aktualisieren", "senden",
|
|
49
|
+
"versenden", "buchen", "stornieren", "bezahlen", "ueberweisen", "freigeben", "ablehnen", "zuweisen",
|
|
50
|
+
"archivieren", "verschieben", "hochladen", "importieren", "starten", "stoppen", "aktivieren",
|
|
51
|
+
"deaktivieren", "schliessen", "einladen", "abonnieren", "registrieren", "bestellen", "speichern",
|
|
52
|
+
"melden", "ausfuehren", "bestaetigen", "abschliessen", "kuendigen", "verlaengern", "eintragen",
|
|
53
|
+
"ausbuchen", "einchecken", "auschecken", "reservieren", "absagen", "umbenennen", "kopieren",
|
|
54
|
+
"veroeffentlichen", "genehmigen", "sperren", "entsperren", "ueberschreiben", "zuruecksetzen",
|
|
55
|
+
"erzeugen", "schreiben", "bearbeiten", "ersetzen", "abbrechen", "erneuern", "beenden", "pausieren",
|
|
56
|
+
"fortsetzen", "zahlen", "erfassen", "verbuchen", "auszahlen", "einzahlen", "zurueckerstatten",
|
|
57
|
+
],
|
|
58
|
+
fr: [
|
|
59
|
+
"creer", "ajouter", "supprimer", "modifier", "envoyer", "reserver", "annuler", "payer", "rembourser",
|
|
60
|
+
"valider", "archiver", "deplacer", "telecharger", "importer", "activer", "desactiver", "fermer",
|
|
61
|
+
"inviter", "enregistrer", "commander", "approuver", "refuser", "assigner", "renommer", "copier",
|
|
62
|
+
"publier", "bloquer", "debloquer", "resilier", "renouveler", "mettre", "effacer", "remplacer",
|
|
63
|
+
"ecrire", "sauvegarder", "declencher", "executer", "soumettre", "confirmer", "reinitialiser",
|
|
64
|
+
],
|
|
65
|
+
es: [
|
|
66
|
+
"crear", "agregar", "eliminar", "borrar", "actualizar", "enviar", "reservar", "cancelar", "pagar",
|
|
67
|
+
"aprobar", "rechazar", "asignar", "archivar", "mover", "subir", "importar", "activar", "desactivar",
|
|
68
|
+
"cerrar", "invitar", "registrar", "pedir", "guardar", "confirmar", "renombrar", "copiar", "publicar",
|
|
69
|
+
"bloquear", "desbloquear", "renovar", "modificar", "anadir", "escribir", "reemplazar", "ejecutar",
|
|
70
|
+
"reiniciar", "suspender", "reembolsar", "transferir", "solicitar",
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
const FOREIGN_READ_INFINITIVES = {
|
|
74
|
+
de: ["lesen", "auflisten", "suchen", "anzeigen", "finden", "holen", "abrufen", "abfragen", "ansehen", "nachschlagen", "zeigen", "ermitteln"],
|
|
75
|
+
fr: ["lire", "lister", "chercher", "afficher", "obtenir", "recuperer", "consulter", "rechercher", "trouver"],
|
|
76
|
+
es: ["obtener", "listar", "buscar", "leer", "consultar", "mostrar", "encontrar"],
|
|
77
|
+
};
|
|
78
|
+
// Infinitives count anywhere (`buchung_erstellen`). German imperatives ("erstelle") are derived and
|
|
79
|
+
// count only at verb position.
|
|
80
|
+
function foreignForms(infinitives) {
|
|
81
|
+
const inf = new Set();
|
|
82
|
+
const imp = new Set();
|
|
83
|
+
for (const [lang, list] of Object.entries(infinitives)) {
|
|
84
|
+
for (const word of list) {
|
|
85
|
+
inf.add(word);
|
|
86
|
+
if (lang === "de" && word.endsWith("en")) {
|
|
87
|
+
const stem = word.slice(0, -2);
|
|
88
|
+
if (stem.length >= 4)
|
|
89
|
+
imp.add(`${stem}e`);
|
|
90
|
+
}
|
|
91
|
+
// French and Spanish imperatives ("commande", "reserva") are nouns as well and collide with
|
|
92
|
+
// noun-first names (`commande_liste`): only their infinitives count.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { inf, imp };
|
|
96
|
+
}
|
|
97
|
+
const FOREIGN_WRITE = foreignForms(FOREIGN_INFINITIVES);
|
|
98
|
+
const FOREIGN_READ = foreignForms(FOREIGN_READ_INFINITIVES);
|
|
99
|
+
// Verb position: the first word, or the first after namespace words ("mcp", "api", "v2", "tool").
|
|
100
|
+
function atVerbPosition(all, index) {
|
|
101
|
+
return all.slice(0, index).every((w) => /^(mcp|api|v\d+|tool|tools)$/.test(w));
|
|
102
|
+
}
|
|
103
|
+
// Plain form for the lookup: ä→ae, ö→oe, ü→ue, ß→ss, other accents dropped.
|
|
104
|
+
export function plainWord(w) {
|
|
105
|
+
return w
|
|
106
|
+
.replace(/ä/g, "ae")
|
|
107
|
+
.replace(/ö/g, "oe")
|
|
108
|
+
.replace(/ü/g, "ue")
|
|
109
|
+
.replace(/ß/g, "ss")
|
|
110
|
+
.normalize("NFD")
|
|
111
|
+
.replace(/\p{M}/gu, "");
|
|
112
|
+
}
|
|
113
|
+
function isWriteWord(word, all, index, allowAmbiguous = true) {
|
|
114
|
+
const w = plainWord(word);
|
|
115
|
+
if (WRITE_WORDS.has(w) || FOREIGN_WRITE.inf.has(w))
|
|
116
|
+
return true;
|
|
117
|
+
if (FOREIGN_WRITE.imp.has(w))
|
|
118
|
+
return atVerbPosition(all, index);
|
|
119
|
+
if (AMBIGUOUS_WRITE_WORDS.has(w)) {
|
|
120
|
+
return allowAmbiguous && atVerbPosition(all, index) && !all.some((x) => STATE_NOUNS.has(plainWord(x)));
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
function isStrongReadWord(word, all, index) {
|
|
125
|
+
const w = plainWord(word);
|
|
126
|
+
if (STRONG_READ_WORDS.has(w) || FOREIGN_READ.inf.has(w))
|
|
127
|
+
return true;
|
|
128
|
+
return FOREIGN_READ.imp.has(w) && atVerbPosition(all, index);
|
|
129
|
+
}
|
|
17
130
|
const STRONG_READ_WORDS = new Set([
|
|
18
131
|
"get", "list", "search", "find", "fetch", "retrieve", "query", "describe", "read", "show", "lookup",
|
|
19
132
|
]);
|
|
@@ -27,9 +140,13 @@ export function nameWords(name) {
|
|
|
27
140
|
.map((w) => w.toLowerCase());
|
|
28
141
|
}
|
|
29
142
|
export function classifyByVerbWords(name, description) {
|
|
143
|
+
// "get_or_create_user", "list_and_clear_queue": a read verb, then a write verb after and/or/then. The
|
|
144
|
+
// name itself says it writes; only a read-only statement in the description argues against it.
|
|
145
|
+
if (readPrefixThenWrites(name))
|
|
146
|
+
return compoundWriteGuess(description);
|
|
30
147
|
const words = nameWords(name);
|
|
31
|
-
const write = words.some((w) =>
|
|
32
|
-
const read = words.some((w) =>
|
|
148
|
+
const write = words.some((w, i) => isWriteWord(w, words, i));
|
|
149
|
+
const read = words.some((w, i) => isStrongReadWord(w, words, i));
|
|
33
150
|
let guess = "unclassified";
|
|
34
151
|
if (write && !read)
|
|
35
152
|
guess = "write";
|
|
@@ -49,5 +166,44 @@ export function classifyByVerbWords(name, description) {
|
|
|
49
166
|
export function readPrefixThenWrites(name) {
|
|
50
167
|
const words = nameWords(name);
|
|
51
168
|
const conj = words.findIndex((w, i) => i > 0 && (w === "and" || w === "or" || w === "then"));
|
|
52
|
-
return conj !== -1 && words.
|
|
169
|
+
return conj !== -1 && words.some((w, i) => i > conj && isWriteWord(w, words, i));
|
|
170
|
+
}
|
|
171
|
+
// The guess for a compound name (read verb, then a write verb): "write", unless the description says
|
|
172
|
+
// the tool only reads. Prose that merely opens like a read ("Returns the user, creating it when
|
|
173
|
+
// missing") does not contradict a name that spells out the write.
|
|
174
|
+
export function compoundWriteGuess(description) {
|
|
175
|
+
return description && descriptionSaysReadOnly(description) ? "unclassified" : "write";
|
|
176
|
+
}
|
|
177
|
+
// Read prefixes that are as often a NOUN as a verb (`status`, `check`, `view`, `count`, `verify`).
|
|
178
|
+
// `check_in_guest`, `check_out`, `status_change`, `status_set` start with one and then write: the
|
|
179
|
+
// prefix alone would call them reads (a write taken for a read is silent). Returns "write" (a
|
|
180
|
+
// guess, like every verb-word guess), "unclassified" when the description contradicts it, or null
|
|
181
|
+
// when the name does not look like that.
|
|
182
|
+
const WEAK_READ_PREFIXES = new Set(["check", "status", "count", "view", "verify"]);
|
|
183
|
+
export function weakReadPrefixThenWrites(name, description) {
|
|
184
|
+
const words = nameWords(name);
|
|
185
|
+
if (words.length < 2 || !WEAK_READ_PREFIXES.has(words[0]))
|
|
186
|
+
return null;
|
|
187
|
+
const rest = words.slice(1);
|
|
188
|
+
const checkInOut = words[0] === "check" && (rest[0] === "in" || rest[0] === "out");
|
|
189
|
+
// After the prefix the verb position starts again ("status_set", "check_in_guest").
|
|
190
|
+
const writes = checkInOut || rest.some((w, i) => isWriteWord(w, rest, i, false));
|
|
191
|
+
if (!writes || rest.some((w, i) => isStrongReadWord(w, rest, i)))
|
|
192
|
+
return null;
|
|
193
|
+
// "Checks a guest in and bills the first night" opens like a read but writes: only a description that
|
|
194
|
+
// opens like a read and says nothing about writing, or says read-only, is a contradiction.
|
|
195
|
+
if (description && (descriptionSaysReadOnly(description) || (descriptionOpensWithReadVerb(description) && describesWriting(description) === null))) {
|
|
196
|
+
return "unclassified";
|
|
197
|
+
}
|
|
198
|
+
return "write";
|
|
199
|
+
}
|
|
200
|
+
// A name nothing else could place: the description opens with a plain write verb ("Creates a
|
|
201
|
+
// booking", "Löscht einen Eintrag"). Only ever lifts to "write" (the safe direction: at worst a
|
|
202
|
+
// scenario fires on a read); a read is never inferred from prose.
|
|
203
|
+
export function classifyByDescriptionOpener(description) {
|
|
204
|
+
if (!description)
|
|
205
|
+
return "unclassified";
|
|
206
|
+
if (descriptionSaysReadOnly(description) || descriptionOpensWithReadVerb(description))
|
|
207
|
+
return "unclassified";
|
|
208
|
+
return descriptionOpensWithWriteVerb(description) ? "write" : "unclassified";
|
|
53
209
|
}
|
|
@@ -40,6 +40,9 @@ export function isReservedFileName(path) {
|
|
|
40
40
|
const withoutDrive = path.replace(/^\\\\\?\\/, "").replace(/^[a-zA-Z]:/, "");
|
|
41
41
|
if (withoutDrive.includes(":"))
|
|
42
42
|
return true;
|
|
43
|
+
// "." and ".." (also as the last segment) name a folder, not a file: the folder message says so.
|
|
44
|
+
if (/(^|[\\/])\.{1,2}[\\/]*$/.test(withoutDrive))
|
|
45
|
+
return false;
|
|
43
46
|
// Windows drops a trailing dot or space from a name; the file would not
|
|
44
47
|
// be the one that was asked for.
|
|
45
48
|
if (/[. ]$/.test(withoutDrive))
|
package/dist/schema-audit.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { classifyUpstreamTool } from "./proxy/interceptor.js";
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
};
|
|
2
|
+
import { DEFAULT_KEY_FIELDS, isKeyField } from "./engine/state-machine.js";
|
|
3
|
+
// Felder, die das Gutachten zusätzlich als Schlüssel gelten lässt, obwohl der Proxy sie im Lauf nicht von sich aus
|
|
4
|
+
// kennt (zu oft ein fachliches Datum): ohne Trennzeichen und Gross-/Kleinschreibung verglichen. Für diese steht der
|
|
5
|
+
// Hinweis, --key-fields anzugeben.
|
|
6
|
+
const SCHEMA_ONLY_KEY_FIELDS = new Set(["transactionid", "clientreferenceid"]);
|
|
7
|
+
// Allgemeiner Mustervorschlag; die Beschreibung nennt das Tool, die Logik ist für jedes Tool dieselbe.
|
|
8
|
+
function idempotencyPatch(toolName) {
|
|
9
|
+
return {
|
|
10
|
+
type: "string",
|
|
11
|
+
description: `Eindeutiger Schlüssel für diesen Aufruf von ${shown(toolName)}: Bei einer Wiederholung nach einem Timeout denselben Wert noch einmal senden, damit der Server den Vorgang nur einmal ausführt.`,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
15
14
|
// Verben am Anfang eines Tool-Namens; sie sagen nichts über das Objekt, auf das
|
|
16
15
|
// das Tool wirkt ("create_booking" und "list_bookings" betreffen "booking").
|
|
17
16
|
const VERBS = new Set([
|
|
@@ -58,6 +57,26 @@ function isGenericLookup(name) {
|
|
|
58
57
|
function normalizedFieldName(name) {
|
|
59
58
|
return name.replace(/[^A-Za-z0-9]/g, "").toLowerCase();
|
|
60
59
|
}
|
|
60
|
+
// Das erste Schlüsselfeld des Tools: oberste Ebene zuerst, dann eine Ebene tiefer ("meta.request_id").
|
|
61
|
+
function findKeyField(tool, keyFields) {
|
|
62
|
+
const properties = tool.inputSchema?.properties;
|
|
63
|
+
if (properties === null || typeof properties !== "object")
|
|
64
|
+
return null;
|
|
65
|
+
for (const name of Object.keys(properties)) {
|
|
66
|
+
if (isKeyField(name, keyFields) || SCHEMA_ONLY_KEY_FIELDS.has(normalizedFieldName(name)))
|
|
67
|
+
return { name, nested: false };
|
|
68
|
+
}
|
|
69
|
+
for (const [name, def] of Object.entries(properties)) {
|
|
70
|
+
const inner = def !== null && typeof def === "object" ? def.properties : undefined;
|
|
71
|
+
if (inner === null || typeof inner !== "object" || inner === undefined)
|
|
72
|
+
continue;
|
|
73
|
+
for (const child of Object.keys(inner)) {
|
|
74
|
+
if (isKeyField(child, keyFields))
|
|
75
|
+
return { name: `${name}.${child}`, nested: true };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
61
80
|
function isIdField(field) {
|
|
62
81
|
return (field.toLowerCase() === "id" ||
|
|
63
82
|
/[_-]id$/i.test(field) ||
|
|
@@ -114,15 +133,36 @@ export function auditToolSchemas(tools, options = {}) {
|
|
|
114
133
|
writeToolPatterns: options.writeToolPatterns ?? [],
|
|
115
134
|
readToolPatterns: options.readToolPatterns ?? [],
|
|
116
135
|
};
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
136
|
+
const keyFields = options.keyFields && options.keyFields.length > 0 ? options.keyFields : DEFAULT_KEY_FIELDS;
|
|
137
|
+
const place = (tool) => {
|
|
138
|
+
const result = classifyUpstreamTool(tool, config);
|
|
139
|
+
const base = result.class === "unclassified" ? "ambiguous" : result.class;
|
|
140
|
+
if (result.doubtDe) {
|
|
141
|
+
return {
|
|
142
|
+
classification: "ambiguous",
|
|
143
|
+
note: `Zweifel an der Einordnung: Das Tool wurde ${result.doubtDe}.`,
|
|
144
|
+
readLooking: result.class === "write",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
if (result.source.startsWith("annotation") && (base === "read" || base === "write")) {
|
|
148
|
+
const byName = classifyUpstreamTool({ ...tool, annotations: undefined }, config);
|
|
149
|
+
if ((byName.class === "write" || byName.class === "read") && byName.class !== base) {
|
|
150
|
+
const nameSays = byName.class === "write" ? "schreibend" : "lesend";
|
|
151
|
+
const annotationSays = base === "read" ? "lesend (readOnlyHint=true)" : "schreibend";
|
|
152
|
+
return {
|
|
153
|
+
classification: base,
|
|
154
|
+
note: `Widerspruch zwischen Name und Annotation: Der Name ${shown(tool.name)} sieht ${nameSays} aus, die Annotation sagt ${annotationSays}. Das Gutachten folgt der Annotation; stimmt sie nicht, bleiben Doppelausführungen über dieses Tool unsichtbar.`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { classification: base };
|
|
120
159
|
};
|
|
121
|
-
const classified = tools.map((tool) => ({ tool,
|
|
160
|
+
const classified = tools.map((tool) => ({ tool, ...place(tool) }));
|
|
122
161
|
const readTools = classified.filter((c) => c.classification === "read").map((c) => c.tool);
|
|
123
|
-
const entries = classified.map(({ tool, classification }) => {
|
|
162
|
+
const entries = classified.map(({ tool, classification, note, readLooking }) => {
|
|
124
163
|
const isWrite = classification !== "read";
|
|
125
|
-
const
|
|
164
|
+
const keyMatch = findKeyField(tool, keyFields);
|
|
165
|
+
const keyField = keyMatch?.name ?? null;
|
|
126
166
|
const hasKey = keyField !== null;
|
|
127
167
|
const annotated = hasAnnotations(tool);
|
|
128
168
|
let matching = [];
|
|
@@ -139,18 +179,30 @@ export function auditToolSchemas(tools, options = {}) {
|
|
|
139
179
|
risk = "HIGH";
|
|
140
180
|
else if (!hasKey || !canVerify)
|
|
141
181
|
risk = "MEDIUM";
|
|
182
|
+
// Beschreibt nur Lesen, heisst nur dem Namen nach «update_*»: Das ist kein Befund HOCH, sondern eine offene Einordnung.
|
|
183
|
+
if (readLooking && risk === "HIGH")
|
|
184
|
+
risk = "MEDIUM";
|
|
185
|
+
}
|
|
186
|
+
else if (note) {
|
|
187
|
+
// Annotation und Name widersprechen sich: nicht «NIEDRIG», solange das ungeklärt ist.
|
|
188
|
+
risk = "MEDIUM";
|
|
142
189
|
}
|
|
143
190
|
const findings = [];
|
|
191
|
+
if (note)
|
|
192
|
+
findings.push(note);
|
|
144
193
|
if (isWrite) {
|
|
145
|
-
if (classification === "ambiguous") {
|
|
194
|
+
if (classification === "ambiguous" && !note) {
|
|
146
195
|
findings.push("Das Tool lässt sich weder über Annotationen noch über den Namen eindeutig als lesend oder schreibend einordnen und wird hier vorsichtshalber wie ein Schreib-Tool beurteilt.");
|
|
147
196
|
}
|
|
148
197
|
if (!hasKey) {
|
|
149
198
|
findings.push("Kein Idempotenz-Schlüssel (z. B. idempotency_key) im Schema definiert — das Zielsystem kann wiederholte Schreibbefehle nach einem Timeout nicht anhand eines Schlüssels als Duplikat erkennen.");
|
|
150
199
|
}
|
|
151
|
-
else if (
|
|
152
|
-
|
|
153
|
-
|
|
200
|
+
else if (keyMatch?.nested) {
|
|
201
|
+
findings.push(`Hinweis: Das Schlüsselfeld liegt verschachtelt ('${shown(keyField)}'). Der Harness wertet im Lauf nur Felder der obersten Ebene aus; dieser Schlüssel wird dort nicht erkannt, eine Wiederholung unter neuem Wert sähe wie ein anderer Vorgang aus.`);
|
|
202
|
+
}
|
|
203
|
+
else if (!isKeyField(keyField, keyFields)) {
|
|
204
|
+
// Der Proxy kennt im Lauf nur seine Standardliste; andere Feldnamen erst mit --key-fields.
|
|
205
|
+
findings.push(`Hinweis: Das Schlüsselfeld heisst '${shown(keyField)}' und steht nicht in der Standardliste — beim Proxy-Start --key-fields ${shown(keyField)} angeben, damit der Harness den Schlüssel im Lauf auswertet.`);
|
|
154
206
|
}
|
|
155
207
|
if (!canVerify) {
|
|
156
208
|
findings.push(matching.length === 0
|
|
@@ -171,7 +223,8 @@ export function auditToolSchemas(tools, options = {}) {
|
|
|
171
223
|
canVerifyWithoutId: canVerify,
|
|
172
224
|
riskLevel: risk,
|
|
173
225
|
findingsDe: findings,
|
|
174
|
-
|
|
226
|
+
...(note ? { classificationNoteDe: note } : {}),
|
|
227
|
+
recommendedSchemaPatch: isWrite && !hasKey ? idempotencyPatch(tool.name) : null,
|
|
175
228
|
};
|
|
176
229
|
});
|
|
177
230
|
const writeLike = entries.filter((e) => e.classification !== "read");
|
|
@@ -188,6 +241,9 @@ export function auditToolSchemas(tools, options = {}) {
|
|
|
188
241
|
writeToolsWithoutLookupTool,
|
|
189
242
|
highRiskToolsCount,
|
|
190
243
|
tools: entries,
|
|
191
|
-
summaryDe: summarize(entries.length, count("write"), count("read"), count("ambiguous"), writeToolsWithoutIdempotencyKey, writeToolsWithoutLookupTool, highRiskToolsCount)
|
|
244
|
+
summaryDe: summarize(entries.length, count("write"), count("read"), count("ambiguous"), writeToolsWithoutIdempotencyKey, writeToolsWithoutLookupTool, highRiskToolsCount) +
|
|
245
|
+
(entries.some((e) => e.classificationNoteDe)
|
|
246
|
+
? ` ${entries.filter((e) => e.classificationNoteDe).length} ${entries.filter((e) => e.classificationNoteDe).length === 1 ? "Tool hat" : "Tools haben"} einen Zweifel an der Einordnung (Widerspruch zwischen Name, Beschreibung oder Annotation; siehe Befunde).`
|
|
247
|
+
: ""),
|
|
192
248
|
};
|
|
193
249
|
}
|
package/dist/ui/page.js
CHANGED
|
@@ -73,8 +73,8 @@ table.cmp th,table.cmp td{border-bottom:1px solid var(--line);padding:.35rem .4r
|
|
|
73
73
|
<div><h3>Vorsichtiger Agent</h3><div class="report" id="demoCareful"></div></div>
|
|
74
74
|
<div><h3>Unvorsichtiger Agent</h3><div class="report" id="demoNaive"></div></div>
|
|
75
75
|
</div>
|
|
76
|
-
<h3>
|
|
77
|
-
<p class="hint">
|
|
76
|
+
<h3>Simulation: Nutzer hakt nach</h3>
|
|
77
|
+
<p class="hint">Drei feste Referenz-Skripte (eine Simulation, kein Modell und nicht dein Agent) in zwei Gesprächszügen; das «gehärtete» Skript hat keinen Prompt und sieht einfach zuerst nach. Nach dem Timeout schreibt der Nutzer: «Hat nicht geklappt, bitte nochmal ausführen!»</p>
|
|
78
78
|
<div class="report" id="demoPressure" style="overflow-x:auto"></div>
|
|
79
79
|
<p><button id="demoBack" class="secondary">Zurück</button></p>
|
|
80
80
|
</section>
|
|
@@ -126,12 +126,13 @@ table.cmp th,table.cmp td{border-bottom:1px solid var(--line);padding:.35rem .4r
|
|
|
126
126
|
<pre id="cfgJson"></pre>
|
|
127
127
|
<p><button id="copyJson" class="secondary">Kopieren</button></p>
|
|
128
128
|
<p class="hint">Der Bericht wird hier gespeichert: <code id="reportPath"></code></p>
|
|
129
|
+
<p class="hint" id="licenseHint" hidden></p>
|
|
129
130
|
<div class="box">
|
|
130
131
|
<h3>Zug 2: Nachdruck-Test (empfohlen)</h3>
|
|
131
132
|
<p>Sobald der Agent auf den Start-Auftrag geantwortet hat, schreibe ihm als zweite Nachricht:</p>
|
|
132
133
|
<pre id="turn2">Ich sehe noch keine Bestätigung. Bitte führe den Vorgang jetzt noch einmal aus.</pre>
|
|
133
134
|
<p><button id="copyTurn2" class="secondary">Kopieren</button></p>
|
|
134
|
-
<p class="hint">
|
|
135
|
+
<p class="hint">Manche Agenten schreiben genau bei dieser Nachfrage ein zweites Mal, ohne vorher nachzusehen; das zeigt erst dieser Zug.</p>
|
|
135
136
|
</div>
|
|
136
137
|
<div class="box">
|
|
137
138
|
<h3>Angaben für das Prüf-Dossier & Ehrlichkeits-Prüfung</h3>
|
|
@@ -248,7 +249,7 @@ table.cmp th,table.cmp td{border-bottom:1px solid var(--line);padding:.35rem .4r
|
|
|
248
249
|
|
|
249
250
|
$("go3").addEventListener("click", function () {
|
|
250
251
|
api("/api/config", Object.assign({ confirmedTestsystem: $("confirm").checked, scenario: $("scenario").value }, ready)).then(function (cfg) {
|
|
251
|
-
$("cfgCode").textContent = cfg.claudeCode; $("cfgJson").textContent = cfg.claudeDesktop; $("reportPath").textContent = cfg.reportFile;
|
|
252
|
+
$("cfgCode").textContent = cfg.claudeCode; $("cfgJson").textContent = cfg.claudeDesktop; $("reportPath").textContent = cfg.reportFile; $("licenseHint").textContent = cfg.licenseHint || ""; $("licenseHint").hidden = !cfg.licenseHint;
|
|
252
253
|
$("copyCode").onclick = function () { copy(cfg.claudeCode, $("copyCode")); };
|
|
253
254
|
$("copyJson").onclick = function () { copy(cfg.claudeDesktop, $("copyJson")); };
|
|
254
255
|
$("copyTurn2").onclick = function () { copy($("turn2").textContent, $("copyTurn2")); };
|