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
|
@@ -6,7 +6,7 @@
|
|
|
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 {
|
|
9
|
+
import { classificationDoubtText } from "./classification-doubt.js";
|
|
10
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";
|
|
@@ -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;
|
|
@@ -248,6 +248,8 @@ export class ProxyInterceptor {
|
|
|
248
248
|
this.session.toolClassOverrides.set(entry.name, entry.class);
|
|
249
249
|
if (entry.doubt) {
|
|
250
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);
|
|
251
253
|
}
|
|
252
254
|
if (entry.guess)
|
|
253
255
|
(this.session.verbWordGuesses ??= new Set()).add(entry.name);
|
|
@@ -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")); };
|
package/dist/ui/server.js
CHANGED
|
@@ -19,6 +19,9 @@ import { runBenchmark, defaultConfig } from "../benchmark/runner.js";
|
|
|
19
19
|
import { VERSION } from "../version.js";
|
|
20
20
|
import { PAGE_HTML } from "./page.js";
|
|
21
21
|
import { resolveLicense, verifyLicense, resolveProGate, lockedNotice } from "../license/index.js";
|
|
22
|
+
import { expandEqualsForm, optionName, redactSecrets } from "../cli-args.js";
|
|
23
|
+
// Platzhalter statt Lizenzcode in erzeugten Befehlen und Konfigurationen.
|
|
24
|
+
const LICENSE_PLACEHOLDER = "<DEIN-LIZENZCODE>";
|
|
22
25
|
const MAX_BODY = 64 * 1024;
|
|
23
26
|
const MAX_NAME = 200;
|
|
24
27
|
const MAX_AGENT_RESPONSE = 10_000;
|
|
@@ -141,9 +144,6 @@ export async function startUi(options = {}) {
|
|
|
141
144
|
return file;
|
|
142
145
|
};
|
|
143
146
|
let currentLicenseVerdict = resolveLicense(options.licenseKey);
|
|
144
|
-
// Nur ein ausdrücklich übergebener, gültiger Schlüssel wird in die erzeugte Agenten-Konfiguration übernommen
|
|
145
|
-
// (damit der Proxy des Agenten die Pro-Teile in den Report schreibt); aus der Umgebung stammende nie.
|
|
146
|
-
let explicitLicenseKey = options.licenseKey;
|
|
147
147
|
const dossierOptions = () => ({
|
|
148
148
|
customerName: session?.customerName,
|
|
149
149
|
systemName: session?.systemName,
|
|
@@ -200,7 +200,6 @@ export async function startUi(options = {}) {
|
|
|
200
200
|
const body = await readJson(req);
|
|
201
201
|
const key = typeof body.key === "string" ? body.key.trim() : "";
|
|
202
202
|
currentLicenseVerdict = verifyLicense(key);
|
|
203
|
-
explicitLicenseKey = key;
|
|
204
203
|
return sendJson(res, 200, {
|
|
205
204
|
valid: currentLicenseVerdict.valid,
|
|
206
205
|
tier: currentLicenseVerdict.tier ?? "evaluation",
|
|
@@ -253,11 +252,20 @@ export async function startUi(options = {}) {
|
|
|
253
252
|
"--allow-writes",
|
|
254
253
|
"--audit-tools", "off",
|
|
255
254
|
"--report-file", reportFile,
|
|
256
|
-
...(currentLicenseVerdict.valid && explicitLicenseKey ? ["--license", explicitLicenseKey] : []),
|
|
257
255
|
];
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
256
|
+
// Der Lizenzcode steht nie in dem, was die Oberfläche ausgibt (Befehl, Konfigurationsdatei, Prozessliste): Mit
|
|
257
|
+
// gültiger Lizenz kommt ein Platzhalter für die Umgebungsvariable des Proxys hinein, den der Nutzer ersetzt.
|
|
258
|
+
const withLicense = currentLicenseVerdict.valid;
|
|
259
|
+
const claudeCode = [
|
|
260
|
+
withLicense ? `claude mcp add --scope user -e AGENT_AUDIT_LICENSE=${LICENSE_PLACEHOLDER} agent-harness --` : "claude mcp add --scope user agent-harness --",
|
|
261
|
+
process.execPath,
|
|
262
|
+
...proxyArgs,
|
|
263
|
+
].map((p, i) => (i === 0 ? p : quoteArg(p))).join(" ");
|
|
264
|
+
const claudeDesktop = JSON.stringify({ mcpServers: { "agent-harness": { command: process.execPath, args: proxyArgs, ...(withLicense ? { env: { AGENT_AUDIT_LICENSE: LICENSE_PLACEHOLDER } } : {}) } } }, null, 2);
|
|
265
|
+
const licenseHint = withLicense
|
|
266
|
+
? `Ersetze ${LICENSE_PLACEHOLDER} durch deinen Lizenzcode (aus der E-Mail), damit der Proxy deines Agenten Textbausteine und Schema-Befunde in den Bericht schreibt. Der Code steht aus Sicherheitsgründen nicht in diesem Befehl. Ohne ihn läuft der Test vollständig, der Bericht enthält die Pro-Teile aber nicht, und das Dossier sagt das ausdrücklich.`
|
|
267
|
+
: "";
|
|
268
|
+
return sendJson(res, 200, { claudeCode, claudeDesktop, reportFile, scenario, licenseHint });
|
|
261
269
|
}
|
|
262
270
|
if (req.method === "POST" && url.pathname === "/api/dossier-meta") {
|
|
263
271
|
const body = await readJson(req);
|
|
@@ -361,6 +369,8 @@ export async function runUiMode(argv) {
|
|
|
361
369
|
" --license <key> CuonzTech Agent Audit Pro Lizenzschlüssel (oder Umgebungsvariable AGENT_AUDIT_LICENSE)\n");
|
|
362
370
|
return 0;
|
|
363
371
|
}
|
|
372
|
+
// --flag=wert gilt wie --flag wert, auch für --license.
|
|
373
|
+
argv = expandEqualsForm(argv, new Set(["--license", "--port"]));
|
|
364
374
|
let port;
|
|
365
375
|
let open = true;
|
|
366
376
|
let licenseKey;
|
|
@@ -370,6 +380,10 @@ export async function runUiMode(argv) {
|
|
|
370
380
|
open = false;
|
|
371
381
|
else if (token === "--license") {
|
|
372
382
|
licenseKey = argv[++i] ?? "";
|
|
383
|
+
if (licenseKey.trim() === "" || licenseKey.startsWith("--")) {
|
|
384
|
+
process.stderr.write("Error: --license needs a key.\nUsage: ui [--port <n>] [--no-open] [--license <key>]\n");
|
|
385
|
+
return 1;
|
|
386
|
+
}
|
|
373
387
|
}
|
|
374
388
|
else if (token === "--port") {
|
|
375
389
|
if (port !== undefined) {
|
|
@@ -378,13 +392,13 @@ export async function runUiMode(argv) {
|
|
|
378
392
|
}
|
|
379
393
|
const raw = argv[++i] ?? "";
|
|
380
394
|
if (!/^\d+$/.test(raw) || Number(raw) > 65535) {
|
|
381
|
-
process.stderr.write(`Error: --port must be a number between 0 and 65535, got "${raw}".\n`);
|
|
395
|
+
process.stderr.write(`Error: --port must be a number between 0 and 65535, got "${redactSecrets(raw)}".\n`);
|
|
382
396
|
return 1;
|
|
383
397
|
}
|
|
384
398
|
port = Number(raw);
|
|
385
399
|
}
|
|
386
400
|
else {
|
|
387
|
-
process.stderr.write(`Error: unknown option ${token}\nUsage: ui [--port <n>] [--no-open]\n`);
|
|
401
|
+
process.stderr.write(`Error: unknown option ${optionName(token)}\nUsage: ui [--port <n>] [--no-open] [--license <key>]\n`);
|
|
388
402
|
return 1;
|
|
389
403
|
}
|
|
390
404
|
}
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// `verify <dossier|report>`: rechnet die Prüfsumme einer Report-Datei oder eines Dossiers neu und meldet
|
|
2
|
+
// OK oder MISMATCH mit dem Grund. Exit-Code 0 nur bei OK, sonst 1. Es bleibt eine Prüfsumme ohne Signatur
|
|
3
|
+
// eines Dritten: Wer Datei und Prüfsumme zusammen ändert, kann fälschen; der Wert schützt nur, wenn er
|
|
4
|
+
// getrennt von der Datei aufbewahrt wird (dafür gibt es `--expect <sha256>`).
|
|
5
|
+
import { readFile, stat } from "node:fs/promises";
|
|
6
|
+
import { DOSSIER_SEAL_MARKER, canonicalJson, checkReportIntegrity, extractDossierSeal, sha256Hex } from "./core/artifact-integrity.js";
|
|
7
|
+
import { dossierHeaderChecks } from "./dossier-report.js";
|
|
8
|
+
const MAX_BYTES = 64 * 1024 * 1024;
|
|
9
|
+
function countOf(text, part) {
|
|
10
|
+
if (part === "")
|
|
11
|
+
return 0;
|
|
12
|
+
let n = 0;
|
|
13
|
+
for (let i = text.indexOf(part); i !== -1; i = text.indexOf(part, i + part.length))
|
|
14
|
+
n++;
|
|
15
|
+
return n;
|
|
16
|
+
}
|
|
17
|
+
const USAGE = "Usage: verify <dossier|report-file> [--report <report-file>] [--expect <sha256>]\n";
|
|
18
|
+
const HELP = `${USAGE}\n` +
|
|
19
|
+
"Rechnet die Prüfsumme einer Report-Datei (--report-file des Proxys) oder eines Dossiers (report --dossier) neu\n" +
|
|
20
|
+
"und gibt OK oder MISMATCH mit dem Grund aus. Exit-Code 0 bei OK, sonst 1.\n" +
|
|
21
|
+
" --report <file> bei einem Dossier zusätzlich die Report-Datei prüfen, aus der es erzeugt wurde, und beide abgleichen\n" +
|
|
22
|
+
" --expect <sha256> die getrennt aufbewahrte Prüfsumme (Report-Datei: payloadSha256; Dossier: Prüfsumme des Dossiers);\n" +
|
|
23
|
+
" stimmt sie nicht mit der Datei überein, ist das ein MISMATCH\n\n" +
|
|
24
|
+
"Es ist eine Prüfsumme ohne Signatur eines Dritten und kein Zertifikat. Wer Datei und Prüfsumme zusammen ändert, kann\n" +
|
|
25
|
+
"fälschen. Sie schützt nur, wenn der Wert getrennt von der Datei aufbewahrt wird.\n";
|
|
26
|
+
function statusOfCheck(check) {
|
|
27
|
+
if (check.status === "ok")
|
|
28
|
+
return "OK";
|
|
29
|
+
return check.status === "mismatch" ? "MISMATCH" : "NOT_VERIFIABLE";
|
|
30
|
+
}
|
|
31
|
+
export function verifyReportFileContent(file, expect) {
|
|
32
|
+
const check = checkReportIntegrity(file);
|
|
33
|
+
const lines = [check.reasonDe];
|
|
34
|
+
let status = statusOfCheck(check);
|
|
35
|
+
const sha = file.integrity?.payloadSha256 ?? null;
|
|
36
|
+
if (status === "OK" && expect !== undefined && expect.toLowerCase() !== sha) {
|
|
37
|
+
status = "MISMATCH";
|
|
38
|
+
lines.unshift(`Die Datei stimmt zwar mit ihrer eigenen Prüfsumme überein, die Prüfsumme ist aber nicht die erwartete: erwartet ${expect.toLowerCase()}, in der Datei ${sha ?? "—"}. Datei und Prüfsumme wurden gemeinsam ausgetauscht oder die falsche Datei liegt vor.`);
|
|
39
|
+
}
|
|
40
|
+
return { status, kind: "report", sha256: sha, lines };
|
|
41
|
+
}
|
|
42
|
+
export function verifyDossierContent(text, options = {}) {
|
|
43
|
+
const extracted = extractDossierSeal(text);
|
|
44
|
+
if ("error" in extracted) {
|
|
45
|
+
return {
|
|
46
|
+
status: "NOT_VERIFIABLE",
|
|
47
|
+
kind: "dossier",
|
|
48
|
+
sha256: null,
|
|
49
|
+
lines: [
|
|
50
|
+
extracted.error === "missing"
|
|
51
|
+
? "Kein Prüfsummen-Siegel gefunden: Das Dossier stammt aus Harness < 0.11.1 (Siegel erst ab 0.11.1) oder das Siegel wurde entfernt. Nicht prüfbar."
|
|
52
|
+
: "Das Prüfsummen-Siegel am Ende des Dossiers ist beschädigt und lässt sich nicht lesen. Nicht prüfbar.",
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const { seal } = extracted;
|
|
57
|
+
const recomputed = sha256Hex(canonicalJson(seal.core));
|
|
58
|
+
const lines = [];
|
|
59
|
+
if (recomputed !== seal.sha256) {
|
|
60
|
+
return {
|
|
61
|
+
status: "MISMATCH",
|
|
62
|
+
kind: "dossier",
|
|
63
|
+
sha256: seal.sha256,
|
|
64
|
+
lines: ["Die Prüfsumme des Dossiers stimmt nicht zu seinem eingebetteten Kern: Kern (Urteil, Score, Namen, Lizenzstatus ...) oder Prüfsumme wurden nach dem Erzeugen verändert."],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// Der sichtbare Kopf muss dem gesiegelten Kern entsprechen.
|
|
68
|
+
const header = seal.core.header;
|
|
69
|
+
if (!header || typeof header !== "object" || !Array.isArray(header.rows) || !Array.isArray(header.notes)) {
|
|
70
|
+
return { status: "NOT_VERIFIABLE", kind: "dossier", sha256: seal.sha256, lines: ["Das Siegel enthält den sichtbaren Kopf nicht (Dossier aus einer Vorabfassung von 0.11.1). Nicht prüfbar."] };
|
|
71
|
+
}
|
|
72
|
+
const isHtml = /<html[\s>]/i.test(text.slice(0, 2000));
|
|
73
|
+
const expected = dossierHeaderChecks(seal.core)[isHtml ? "html" : "md"];
|
|
74
|
+
// Gezählt wird im sichtbaren Teil vor dem Siegel (das JSON des Siegels wiederholt Teile des Kopfes).
|
|
75
|
+
const sealAt = text.lastIndexOf(`<!-- ${DOSSIER_SEAL_MARKER} `);
|
|
76
|
+
const visible = sealAt === -1 ? text : text.slice(0, sealAt);
|
|
77
|
+
const missing = expected.filter((check) => countOf(visible, check.needle) !== 1 || (check.prefix !== undefined && countOf(visible, check.prefix) !== 1));
|
|
78
|
+
if (missing.length > 0) {
|
|
79
|
+
return {
|
|
80
|
+
status: "MISMATCH",
|
|
81
|
+
kind: "dossier",
|
|
82
|
+
sha256: seal.sha256,
|
|
83
|
+
lines: [
|
|
84
|
+
`Der sichtbare Kopf des Dossiers weicht vom gesiegelten Kern ab (${missing.length} von ${expected.length} Angaben fehlen, sind verändert oder doppelt: Kopfzeilen, Datum, Version, Urteil, Score oder die Hinweise unter dem Urteil wurden im Text verändert).`,
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (options.expect !== undefined && options.expect.toLowerCase() !== seal.sha256) {
|
|
89
|
+
return {
|
|
90
|
+
status: "MISMATCH",
|
|
91
|
+
kind: "dossier",
|
|
92
|
+
sha256: seal.sha256,
|
|
93
|
+
lines: [`Das Dossier ist in sich stimmig, seine Prüfsumme ist aber nicht die erwartete: erwartet ${options.expect.toLowerCase()}, im Dossier ${seal.sha256}.`],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const source = seal.core.reportFile;
|
|
97
|
+
if (source.check !== "ok") {
|
|
98
|
+
lines.push(source.check === "mismatch"
|
|
99
|
+
? "Das Dossier ist unverändert, wurde aber aus einer Report-Datei erzeugt, die ihre eigene Prüfsumme nicht bestand: Urteil und Score sind nicht belastbar."
|
|
100
|
+
: "Das Dossier ist unverändert, wurde aber aus einer Report-Datei erzeugt, deren Prüfsumme nicht prüfbar war (fehlend oder aus Harness < 0.11.1): Das Urteil war schon beim Erzeugen ungeprüft.");
|
|
101
|
+
return { status: source.check === "mismatch" ? "MISMATCH" : "NOT_VERIFIABLE", kind: "dossier", sha256: seal.sha256, lines };
|
|
102
|
+
}
|
|
103
|
+
lines.push("Siegel, Kern und sichtbarer Kopf (Titelzeile, alle Kopfzeilen, Urteil, Score und die Hinweise unter dem Urteil) stimmen überein. Der Fliesstext der Abschnitte 1 bis 6 ist nicht gesiegelt und wurde nicht geprüft.");
|
|
104
|
+
if (options.report) {
|
|
105
|
+
const reportCheck = checkReportIntegrity(options.report);
|
|
106
|
+
if (reportCheck.status !== "ok") {
|
|
107
|
+
return { status: reportCheck.status === "mismatch" ? "MISMATCH" : "NOT_VERIFIABLE", kind: "dossier", sha256: seal.sha256, lines: [`Die angegebene Report-Datei besteht ihre Prüfung nicht: ${reportCheck.reasonDe}`] };
|
|
108
|
+
}
|
|
109
|
+
if (options.report.integrity?.payloadSha256 !== source.payloadSha256) {
|
|
110
|
+
return {
|
|
111
|
+
status: "MISMATCH",
|
|
112
|
+
kind: "dossier",
|
|
113
|
+
sha256: seal.sha256,
|
|
114
|
+
lines: ["Die angegebene Report-Datei ist nicht die, aus der das Dossier erzeugt wurde (ihre Prüfsumme weicht von der im Dossier festgehaltenen ab)."],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
lines.push("Die angegebene Report-Datei gehört zu diesem Dossier und ist unverändert. Der Fliesstext des Dossiers wurde dabei nicht gegen die Report-Datei abgeglichen.");
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
lines.push("Die Report-Datei wurde nicht mitgeprüft; mit --report <datei> abgleichen.");
|
|
121
|
+
}
|
|
122
|
+
lines.push("Das ist eine Prüfsumme ohne Signatur eines Dritten: Sie schützt nur, wenn ihr Wert getrennt aufbewahrt wird (--expect <sha256>).");
|
|
123
|
+
return { status: "OK", kind: "dossier", sha256: seal.sha256, lines };
|
|
124
|
+
}
|
|
125
|
+
async function readText(path) {
|
|
126
|
+
const info = await stat(path).catch(() => null);
|
|
127
|
+
if (!info || !info.isFile())
|
|
128
|
+
return { error: `Datei nicht gefunden: ${path}` };
|
|
129
|
+
if (info.size > MAX_BYTES)
|
|
130
|
+
return { error: `Datei zu gross (${info.size} Byte, höchstens ${MAX_BYTES}): ${path}` };
|
|
131
|
+
return { text: await readFile(path, "utf8") };
|
|
132
|
+
}
|
|
133
|
+
function looksLikeReportFile(value) {
|
|
134
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) && ("harnessVersion" in value || "report" in value || "integrity" in value);
|
|
135
|
+
}
|
|
136
|
+
export async function runVerifyMode(argv) {
|
|
137
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
138
|
+
process.stdout.write(HELP);
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
let file;
|
|
142
|
+
let reportPath;
|
|
143
|
+
let expect;
|
|
144
|
+
for (let i = 0; i < argv.length; i++) {
|
|
145
|
+
const token = argv[i];
|
|
146
|
+
const eq = token.indexOf("=");
|
|
147
|
+
const name = token.startsWith("--") && eq !== -1 ? token.slice(0, eq) : token;
|
|
148
|
+
if (name === "--report" || name === "--expect") {
|
|
149
|
+
const v = token.startsWith("--") && eq !== -1 ? token.slice(eq + 1) : argv[++i];
|
|
150
|
+
if (v === undefined || v.trim() === "" || v.startsWith("--")) {
|
|
151
|
+
process.stderr.write(`Error: ${name} needs a value.\n${USAGE}`);
|
|
152
|
+
return 1;
|
|
153
|
+
}
|
|
154
|
+
if (name === "--report")
|
|
155
|
+
reportPath = v;
|
|
156
|
+
else {
|
|
157
|
+
if (!/^[0-9a-fA-F]{64}$/.test(v.trim())) {
|
|
158
|
+
process.stderr.write(`Error: --expect needs a SHA-256 (64 hex characters).\n${USAGE}`);
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
expect = v.trim();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else if (token.startsWith("--")) {
|
|
165
|
+
process.stderr.write(`Error: unknown option ${name}\n${USAGE}`);
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
else if (file === undefined) {
|
|
169
|
+
file = token;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
process.stderr.write(`Error: unexpected argument ${token}\n${USAGE}`);
|
|
173
|
+
return 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (file === undefined) {
|
|
177
|
+
process.stderr.write(`Error: verify needs a dossier or report file.\n${USAGE}`);
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
const read = await readText(file);
|
|
181
|
+
if ("error" in read) {
|
|
182
|
+
process.stderr.write(`Error: ${read.error}\n`);
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
let reportFile;
|
|
186
|
+
if (reportPath !== undefined) {
|
|
187
|
+
const r = await readText(reportPath);
|
|
188
|
+
if ("error" in r) {
|
|
189
|
+
process.stderr.write(`Error: ${r.error}\n`);
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const parsed = JSON.parse(r.text);
|
|
194
|
+
if (!looksLikeReportFile(parsed))
|
|
195
|
+
throw new Error("not a report");
|
|
196
|
+
reportFile = parsed;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
process.stderr.write(`Error: ${reportPath} is not a report file written by the proxy (--report-file).\n`);
|
|
200
|
+
return 1;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
let result;
|
|
204
|
+
let parsed;
|
|
205
|
+
let isJson = true;
|
|
206
|
+
try {
|
|
207
|
+
parsed = JSON.parse(read.text);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
isJson = false;
|
|
211
|
+
}
|
|
212
|
+
if (isJson) {
|
|
213
|
+
if (!looksLikeReportFile(parsed)) {
|
|
214
|
+
process.stderr.write(`Error: ${file} is JSON, but not a report written by the proxy (--report-file).\n`);
|
|
215
|
+
return 1;
|
|
216
|
+
}
|
|
217
|
+
if (reportFile !== undefined) {
|
|
218
|
+
process.stderr.write("Error: --report only applies when verifying a dossier.\n");
|
|
219
|
+
return 1;
|
|
220
|
+
}
|
|
221
|
+
result = verifyReportFileContent(parsed, expect);
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
result = verifyDossierContent(read.text, { report: reportFile, expect });
|
|
225
|
+
}
|
|
226
|
+
const label = result.status === "NOT_VERIFIABLE" ? "NICHT PRÜFBAR" : result.status;
|
|
227
|
+
const what = result.kind === "report" ? "Report-Datei" : "Dossier";
|
|
228
|
+
process.stdout.write(`${label} ${what} ${file}\n`);
|
|
229
|
+
for (const line of result.lines)
|
|
230
|
+
process.stdout.write(`${line}\n`);
|
|
231
|
+
if (result.sha256)
|
|
232
|
+
process.stdout.write(`Prüfsumme (SHA-256): ${result.sha256}\n`);
|
|
233
|
+
return result.status === "OK" ? 0 : 1;
|
|
234
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cuonztech-agent-harness",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"mcpName": "io.github.cuonztech/agent-harness",
|
|
5
5
|
"description": "Chaos-Proxy für MCP-Server: injiziert F1-F5-Fehlerszenarien (Ghost-Write nach Timeout, 429, defektes JSON, Duplicate Dispatch, 500) vor einem echten Upstream-MCP-Server und prüft, ob ein Agent Schreibvorgänge doppelt ausführt. Mit Resilience Score und System-Prompt-Patches für beobachtetes Fehlverhalten.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/cuonztech/agent-harness",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.11.
|
|
9
|
+
"version": "0.11.1",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "cuonztech-agent-harness",
|
|
14
|
-
"version": "0.11.
|
|
14
|
+
"version": "0.11.1",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
}
|