@polygraphso/litmus 0.8.1 → 0.9.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.
@@ -3,7 +3,7 @@ import {
3
3
  METHODOLOGY_VERSION,
4
4
  parseServerRef,
5
5
  serverKey
6
- } from "./chunk-ZR6XRGMQ.js";
6
+ } from "./chunk-44R4ZYOE.js";
7
7
 
8
8
  // ../probes/src/harness.ts
9
9
  import { execFile as execFile3 } from "child_process";
@@ -1078,15 +1078,29 @@ function instructionMimicry(text) {
1078
1078
  }
1079
1079
  return findings;
1080
1080
  }
1081
- var SENSITIVE_QUERY_KEY = /(?:^|[?&])[^=&]*(?:key|token|secret|password|passwd|auth|session|cookie|canary|api|env|cred)[^=&]*=/i;
1081
+ var SENSITIVE_QUERY_KEY_TOKENS = [
1082
+ "key",
1083
+ "token",
1084
+ "secret",
1085
+ "password",
1086
+ "passwd",
1087
+ "auth",
1088
+ "session",
1089
+ "cookie",
1090
+ "canary",
1091
+ "api",
1092
+ "env",
1093
+ "cred"
1094
+ ];
1082
1095
  function looksExfilQuery(url) {
1083
1096
  const q = url.indexOf("?");
1084
1097
  if (q < 0) return false;
1085
1098
  const query = url.slice(q + 1);
1086
- if (SENSITIVE_QUERY_KEY.test(url)) return true;
1087
1099
  for (const pair of query.split("&")) {
1088
1100
  const eq = pair.indexOf("=");
1089
1101
  if (eq < 0) continue;
1102
+ const key = pair.slice(0, eq).toLowerCase();
1103
+ if (SENSITIVE_QUERY_KEY_TOKENS.some((t) => key.includes(t))) return true;
1090
1104
  let v = pair.slice(eq + 1);
1091
1105
  try {
1092
1106
  v = decodeURIComponent(v);
@@ -1100,7 +1114,7 @@ function looksExfilQuery(url) {
1100
1114
  }
1101
1115
  function markdownTricks(text) {
1102
1116
  const findings = [];
1103
- const proto = /\b(?:javascript|data):[^\s)"'<>]+/gi;
1117
+ const proto = /\b(?:javascript|data):[^\s)"'<>*`]+/gi;
1104
1118
  for (let m = proto.exec(text); m; m = proto.exec(text)) {
1105
1119
  findings.push({
1106
1120
  kind: "markdown-trick",
@@ -1109,7 +1123,7 @@ function markdownTricks(text) {
1109
1123
  offset: m.index
1110
1124
  });
1111
1125
  }
1112
- const exfilImg = /!?\[[^\]]*\]\((https?:\/\/[^)\s]*\?[^)\s]*=[^)\s]*)\)/gi;
1126
+ const exfilImg = /!?\[[^\]]{0,200}\]\((https?:\/\/[^)\s?]{0,400}\?[^)\s=]{0,200}=[^)\s]{0,200})\)/gi;
1113
1127
  for (let m = exfilImg.exec(text); m; m = exfilImg.exec(text)) {
1114
1128
  const url = m[1] ?? m[0];
1115
1129
  if (!looksExfilQuery(url)) continue;
@@ -1126,7 +1140,9 @@ var INTERNALS_LEAK = [
1126
1140
  // V8 / Node stack frame: `at fn (/abs/file.js:12:5)` or `at /abs/file.js:12:5`
1127
1141
  // (a leading path/drive/`node:`/`file:` is required, so a "meet at 10:30:45"
1128
1142
  // timestamp can't trip it).
1129
- /^\s*at\s+(?:.*\s)?\(?(?:\/|[A-Za-z]:[\\/]|node:|file:\/\/)[^\s()]*:\d+:\d+\)?\s*$/m,
1143
+ // Bounded quantifiers ({0,300}) keep this linear: overlapping `.*\s` + `[^\s()]*`
1144
+ // + trailing `\s*$` over untrusted output is otherwise polynomial (js/polynomial-redos).
1145
+ /^\s*at\s+(?:[^\n]{0,300}\s)?\(?(?:\/|[A-Za-z]:[\\/]|node:|file:\/\/)[^\s()]{0,300}:\d+:\d+\)?\s*$/m,
1130
1146
  // Node uncaught-rejection / fatal banners.
1131
1147
  /\b(?:UnhandledPromiseRejection(?:Warning)?|unhandledRejection|FATAL ERROR:|Fatal error:)\b/,
1132
1148
  // Python traceback header + frame.
@@ -1138,8 +1154,9 @@ var INTERNALS_LEAK = [
1138
1154
  // Go panic with its goroutine dump (`panic: … goroutine 1 [running]:`).
1139
1155
  /\bpanic:[\s\S]{0,300}?\bgoroutine\s+\d+\s+\[/,
1140
1156
  // Ruby backtrace frame (`from app.rb:10:in 'method'` / older backtick form);
1141
- // requires a `.rb` file + `:line:in` so prose can't trip it.
1142
- /[\w./-]+\.rb:\d+:in\s+['\x60]/,
1157
+ // requires a `.rb` file + `:line:in` so prose can't trip it. The lookbehind +
1158
+ // bounded run keep `[\w./-]+\.rb` linear (the `.`-overlap is otherwise polynomial).
1159
+ /(?<![\w./-])[\w./-]{1,200}\.rb:\d+:in\s+['\x60]/,
1143
1160
  // .NET stack frame (`at NS.Method() in C:\path\File.cs:line 12`).
1144
1161
  /\bat\s+[\w.<>+]+\([^)]*\)\s+in\s+\S+:line\s+\d+/i,
1145
1162
  // Rust panic banner (`thread 'main' panicked at …`).
@@ -2157,6 +2174,444 @@ function checkDocker() {
2157
2174
  });
2158
2175
  }
2159
2176
 
2177
+ // ../probes/src/skills/load-skill.ts
2178
+ import { readFileSync, readdirSync, statSync } from "fs";
2179
+ import { join as join3, relative, sep } from "path";
2180
+ import { createHash as createHash2 } from "crypto";
2181
+ var SkillLoadError = class extends Error {
2182
+ };
2183
+ var MAX_FILES = 4096;
2184
+ var EXEC_EXT = /\.(?:sh|bash|zsh|py|js|mjs|cjs|ts|rb|pl|php)$/i;
2185
+ function sha256hex(buf) {
2186
+ return createHash2("sha256").update(buf).digest("hex");
2187
+ }
2188
+ function looksExecutable(relPath, bytes) {
2189
+ if (EXEC_EXT.test(relPath)) return true;
2190
+ return bytes.subarray(0, 2).toString("latin1") === "#!";
2191
+ }
2192
+ function enumerateFiles(dir) {
2193
+ const out = [];
2194
+ const walk = (d) => {
2195
+ let entries;
2196
+ try {
2197
+ entries = readdirSync(d);
2198
+ } catch {
2199
+ return;
2200
+ }
2201
+ for (const name of entries) {
2202
+ if (name === "node_modules" || name === ".git") continue;
2203
+ const p = join3(d, name);
2204
+ let st;
2205
+ try {
2206
+ st = statSync(p);
2207
+ } catch {
2208
+ continue;
2209
+ }
2210
+ if (st.isDirectory()) walk(p);
2211
+ else if (st.isFile()) out.push(relative(dir, p).split(sep).join("/").normalize("NFC"));
2212
+ }
2213
+ };
2214
+ walk(dir);
2215
+ return out.sort();
2216
+ }
2217
+ function splitFrontmatter(src) {
2218
+ if (!src.startsWith("---")) return { frontmatter: "", body: src };
2219
+ const firstNL = src.indexOf("\n");
2220
+ if (firstNL < 0) return { frontmatter: "", body: src };
2221
+ const end = src.indexOf("\n---", firstNL);
2222
+ if (end < 0) return { frontmatter: "", body: src };
2223
+ const close = src.indexOf("\n", end + 1);
2224
+ return {
2225
+ frontmatter: src.slice(firstNL + 1, end),
2226
+ body: close < 0 ? "" : src.slice(close + 1)
2227
+ };
2228
+ }
2229
+ function extractDescription(frontmatter) {
2230
+ const lines = frontmatter.split("\n");
2231
+ for (let i = 0; i < lines.length; i++) {
2232
+ const m = /^description\s*:\s*(.*)$/i.exec(lines[i]);
2233
+ if (!m) continue;
2234
+ const v = m[1].trim();
2235
+ if (/^[>|][+-]?$/.test(v)) {
2236
+ const collected = [];
2237
+ for (let j = i + 1; j < lines.length; j++) {
2238
+ const line = lines[j];
2239
+ if (line.trim() === "") continue;
2240
+ if (/^\s/.test(line)) collected.push(line.trim());
2241
+ else break;
2242
+ }
2243
+ return collected.join(" ");
2244
+ }
2245
+ return v.replace(/^['"]|['"]$/g, "");
2246
+ }
2247
+ return "";
2248
+ }
2249
+ function loadSkill(dir) {
2250
+ const relPaths = enumerateFiles(dir);
2251
+ if (relPaths.length > MAX_FILES) relPaths.length = MAX_FILES;
2252
+ const skillMdRel = relPaths.find((p) => p.toLowerCase() === "skill.md");
2253
+ if (!skillMdRel) throw new SkillLoadError(`no SKILL.md in ${dir}`);
2254
+ const files = [];
2255
+ for (const relPath of relPaths) {
2256
+ let bytes;
2257
+ try {
2258
+ bytes = readFileSync(join3(dir, relPath));
2259
+ } catch {
2260
+ continue;
2261
+ }
2262
+ files.push({ relPath, bytes, isExecutable: looksExecutable(relPath, bytes) });
2263
+ }
2264
+ const manifest = files.map((f) => `${f.relPath}\0${sha256hex(f.bytes)}`).join("\n");
2265
+ const contentHash = "0x" + sha256hex(manifest);
2266
+ const src = files.find((f) => f.relPath === skillMdRel).bytes.toString("utf8");
2267
+ const { frontmatter, body } = splitFrontmatter(src);
2268
+ return {
2269
+ dir,
2270
+ frontmatter,
2271
+ description: extractDescription(frontmatter),
2272
+ body,
2273
+ files,
2274
+ contentHash
2275
+ };
2276
+ }
2277
+
2278
+ // ../probes/src/skills/scanners-skill.ts
2279
+ function stripExamples(md) {
2280
+ return md.replace(/```[\s\S]*?```/g, " ").replace(/~~~[\s\S]*?~~~/g, " ").replace(/`[^`\n]*`/g, " ").split("\n").filter((line) => !/^\s*>/.test(line)).join("\n");
2281
+ }
2282
+ function isBareSystemColon(f) {
2283
+ return f.kind === "instruction-mimicry" && /(?:^|[\s>])system\s*:/i.test(f.match) && !f.match.includes("<");
2284
+ }
2285
+ function skillInjection(body) {
2286
+ const text = stripExamples(body);
2287
+ return [
2288
+ ...invisibleUnicode(text),
2289
+ ...instructionMimicry(text).filter((f) => !isBareSystemColon(f)),
2290
+ ...markdownTricks(text)
2291
+ ];
2292
+ }
2293
+ function skillInjectionFails(findings) {
2294
+ return hasHighSeverity(findings);
2295
+ }
2296
+ var TRANSMIT_VERB = /\b(?:send|post|put|upload|exfiltrate|transmit|e-?mail|forward|leak|publish|curl|wget|fetch)\b/i;
2297
+ var SECRET_NOUN = /\b(?:credentials?|secrets?|tokens?|api[\s_-]?keys?|access[\s_-]?keys?|passwords?|passwd|private[\s_-]?keys?|ssh[\s_-]?keys?|\.env\b|env(?:ironment)?\s+variables?|aws[\s_-]?(?:secret|access)|bearer\s+token)\b/i;
2298
+ var SINK = /(?:https?:\/\/\S+|\bto\s+(?:a\s+|an\s+|the\s+|your\s+|our\s+)?(?:remote|external|attacker|third[\s-]?party)?\s*(?:server|endpoint|host|url|webhook|domain))/i;
2299
+ function exfilInstruction(text) {
2300
+ const findings = [];
2301
+ const stripped = stripExamples(text);
2302
+ for (const raw of stripped.split(/(?<=[.!?])\s+|\n/)) {
2303
+ const sentence = raw.trim();
2304
+ if (!sentence) continue;
2305
+ if (TRANSMIT_VERB.test(sentence) && SECRET_NOUN.test(sentence) && SINK.test(sentence)) {
2306
+ findings.push({ kind: "exfil-instruction", severity: "high", match: sentence.slice(0, 160) });
2307
+ }
2308
+ }
2309
+ return findings;
2310
+ }
2311
+ var DANGEROUS = [
2312
+ // pipe a network fetch straight into a shell — the classic remote-exec.
2313
+ { re: /\b(?:curl|wget|fetch)\b[^\n|]*\|\s*(?:sudo\s+)?(?:ba)?sh\b/i, severity: "high" },
2314
+ // base64/hex decode piped into a shell or eval'd.
2315
+ { re: /\bbase64\s+(?:--decode|-d|-D)\b[^\n|]*\|\s*(?:ba)?sh\b/i, severity: "high" },
2316
+ // reverse shells.
2317
+ { re: /\b(?:bash|sh)\s+-i\b[^\n]*(?:>&|\d>&)/i, severity: "high" },
2318
+ { re: /\/dev\/tcp\/[^\s/]+\/\d+/i, severity: "high" },
2319
+ { re: /\bn(?:et)?cat?\b[^\n]*\s-e\b/i, severity: "high" },
2320
+ // lower-confidence: dynamic exec of strings / blanket destructive fs — MEDIUM,
2321
+ // recorded but does not floor the letter on its own.
2322
+ { re: /\beval\s*\(/i, severity: "medium" },
2323
+ { re: /\bsubprocess\.[A-Za-z]+\([^)]*shell\s*=\s*True/i, severity: "medium" },
2324
+ { re: /\bos\.system\s*\(/i, severity: "medium" },
2325
+ { re: /\brm\s+-rf\s+(?:\/|~|\$)/i, severity: "medium" }
2326
+ ];
2327
+ function dangerousCommand(text, file) {
2328
+ const findings = [];
2329
+ const scan = (s, label) => {
2330
+ for (const { re, severity } of DANGEROUS) {
2331
+ const m = re.exec(s);
2332
+ if (m) {
2333
+ findings.push({
2334
+ kind: "dangerous-command",
2335
+ severity,
2336
+ match: (label ? `${label}: ` : "") + m[0].slice(0, 120),
2337
+ offset: m.index,
2338
+ ...file ? { file } : {}
2339
+ });
2340
+ }
2341
+ }
2342
+ };
2343
+ scan(text);
2344
+ for (const m of text.matchAll(/[A-Za-z0-9+/]{16,}={0,2}/g)) {
2345
+ const d = decode(m[0], "base64");
2346
+ if (d && /\|\s*(?:ba)?sh\b|\/dev\/tcp\//i.test(d)) scan(d, "base64-decoded");
2347
+ }
2348
+ return findings;
2349
+ }
2350
+ function decode(s, enc) {
2351
+ try {
2352
+ const d = Buffer.from(s, enc).toString("utf8");
2353
+ return /[\x20-\x7e]/.test(d) ? d : null;
2354
+ } catch {
2355
+ return null;
2356
+ }
2357
+ }
2358
+ var OVER_BROAD = /\b(?:always|every\s+(?:file|request|time|message|prompt)|all\s+(?:requests|files|prompts|messages)|regardless\s+of|no\s+matter\s+what)\b/i;
2359
+ function overBroadTrigger(description) {
2360
+ const m = OVER_BROAD.exec(description);
2361
+ return m ? [{ kind: "over-broad-trigger", severity: "low", match: m[0], offset: m.index }] : [];
2362
+ }
2363
+
2364
+ // ../probes/src/skills/grade-skill.ts
2365
+ var DISQUALIFYING = /* @__PURE__ */ new Set(["S-01", "S-03"]);
2366
+ var CAPPING = /* @__PURE__ */ new Set(["S-04", "S-05"]);
2367
+ function gradeSkillCategories(categories) {
2368
+ const byCode = (code) => categories.find((c) => c.code === code);
2369
+ const s01 = byCode("S-01");
2370
+ const failed = categories.filter((c) => c.status === "fail").map((c) => c.code);
2371
+ const skipped = categories.filter((c) => c.status === "skipped").map((c) => c.code);
2372
+ if (failed.some((c) => DISQUALIFYING.has(c))) {
2373
+ const which = failed.filter((c) => DISQUALIFYING.has(c)).join(", ");
2374
+ return {
2375
+ grade: "F",
2376
+ rationale: `Disqualifying failure in ${which} \u2014 the skill instructs prompt injection or data exfiltration into an agent that loads it.`
2377
+ };
2378
+ }
2379
+ if (failed.some((c) => CAPPING.has(c))) {
2380
+ const which = failed.filter((c) => CAPPING.has(c)).join(", ");
2381
+ return {
2382
+ grade: "D",
2383
+ rationale: `Overreach in ${which} \u2014 a dangerous bundled command or an undeclared capability. No injection or exfil instruction, so the grade caps at D.`
2384
+ };
2385
+ }
2386
+ if (s01?.status !== "pass") {
2387
+ return {
2388
+ grade: "F",
2389
+ rationale: "S-01 did not complete \u2014 the SKILL.md could not be parsed/scanned, so the skill is treated as ungraded/unsafe."
2390
+ };
2391
+ }
2392
+ const allPass = categories.every((c) => c.status === "pass");
2393
+ if (allPass) {
2394
+ return {
2395
+ grade: "A",
2396
+ rationale: "All skill categories passed: no injection or exfil instruction in the body, and no dangerous bundled command or undeclared capability. A reflects static scanning, not behavioral proof."
2397
+ };
2398
+ }
2399
+ const note = skipped.length ? ` Not verified: ${skipped.join(", ")} (${skipped.map((c) => byCode(c)?.reason).filter(Boolean).join("; ")}).` : "";
2400
+ return {
2401
+ grade: "B",
2402
+ rationale: `Injection and exfil checks passed; some categories not verified.${note}`
2403
+ };
2404
+ }
2405
+
2406
+ // ../probes/src/skills/skill-harness.ts
2407
+ var SKILL_METHODOLOGY_VERSION = "litmus-skill-v1";
2408
+ var SKILL_BUNDLE_SCHEMA_VERSION = "0.1.0";
2409
+ var DISCLAIMER2 = "litmus-skill-v1 is a deterministic STATIC scan of the skill's text and bundled files. It is not behavioral proof: a skill's instructions are interpreted by an agent at runtime, bundled scripts are not executed in this version, and a command constructed or fetched at runtime is not detectable by static scanning. An A means the static checks found no injection, exfil instruction, or dangerous bundled command \u2014 not that the skill is safe to run unsupervised.";
2410
+ function cat(code, status, findings, reason) {
2411
+ return { code, status, findings, ...reason ? { reason } : {} };
2412
+ }
2413
+ function runSkillLitmus(dir, opts = {}) {
2414
+ const ranAt = opts.ranAt ?? (/* @__PURE__ */ new Date()).toISOString();
2415
+ const harness = { package: "@polygraph/probes", version: opts.harnessVersion ?? SKILL_METHODOLOGY_VERSION, node: process.version };
2416
+ const base = { schemaVersion: SKILL_BUNDLE_SCHEMA_VERSION, methodologyVersion: SKILL_METHODOLOGY_VERSION, ranAt, harness, disclaimer: DISCLAIMER2 };
2417
+ let loaded;
2418
+ try {
2419
+ loaded = loadSkill(dir);
2420
+ } catch (e) {
2421
+ const reason = e instanceof SkillLoadError ? e.message : "failed to load skill";
2422
+ const categories2 = [cat("S-01", "skipped", [], reason)];
2423
+ const { grade: grade2, rationale: rationale2 } = gradeSkillCategories(categories2);
2424
+ return { ...base, skillRef: opts.skillRef ?? dir, contentHash: "0x", categories: categories2, advisories: [], grade: grade2, gradeRationale: rationale2 };
2425
+ }
2426
+ const injFindings = [...skillInjection(loaded.body), ...skillInjection(loaded.frontmatter)];
2427
+ const s01 = cat("S-01", skillInjectionFails(injFindings) ? "fail" : "pass", injFindings);
2428
+ const exfil = exfilInstruction(loaded.body);
2429
+ const s03 = cat("S-03", exfil.some((f) => f.severity === "high") ? "fail" : "pass", exfil);
2430
+ const execFiles = loaded.files.filter((f) => f.isExecutable);
2431
+ const dangFindings = [];
2432
+ for (const f of execFiles) dangFindings.push(...dangerousCommand(f.bytes.toString("utf8"), f.relPath));
2433
+ const dangHigh = dangFindings.filter((f) => f.severity === "high");
2434
+ const s04 = cat(
2435
+ "S-04",
2436
+ dangHigh.length > 0 ? "fail" : "pass",
2437
+ dangHigh,
2438
+ execFiles.length === 0 ? "no bundled executable scripts" : void 0
2439
+ );
2440
+ const categories = [s01, s03, s04];
2441
+ const { grade, rationale } = gradeSkillCategories(categories);
2442
+ const advisories = [
2443
+ ...overBroadTrigger(loaded.description),
2444
+ ...dangFindings.filter((f) => f.severity !== "high")
2445
+ ];
2446
+ return {
2447
+ ...base,
2448
+ skillRef: opts.skillRef ?? dir,
2449
+ contentHash: loaded.contentHash,
2450
+ categories,
2451
+ advisories,
2452
+ grade,
2453
+ gradeRationale: rationale
2454
+ };
2455
+ }
2456
+
2457
+ // ../probes/src/skills/quality-judge.ts
2458
+ function openAICompatJudge(cfg) {
2459
+ let base = cfg.baseUrl;
2460
+ while (base.endsWith("/")) base = base.slice(0, -1);
2461
+ const url = `${base}/chat/completions`;
2462
+ return {
2463
+ id: `openai-compat:${cfg.model}`,
2464
+ async complete(system, user) {
2465
+ const res = await fetch(url, {
2466
+ method: "POST",
2467
+ headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}` },
2468
+ body: JSON.stringify({
2469
+ model: cfg.model,
2470
+ max_tokens: 1024,
2471
+ messages: [
2472
+ { role: "system", content: system },
2473
+ { role: "user", content: user }
2474
+ ]
2475
+ })
2476
+ });
2477
+ if (!res.ok) throw new Error(`judge request failed: HTTP ${res.status}`);
2478
+ const data = await res.json();
2479
+ return data?.choices?.[0]?.message?.content ?? "";
2480
+ }
2481
+ };
2482
+ }
2483
+ function judgeFromEnv(env = process.env) {
2484
+ const apiKey = env.LITMUS_LLM_API_KEY;
2485
+ const model = env.LITMUS_LLM_MODEL;
2486
+ if (!apiKey || !model) return null;
2487
+ return openAICompatJudge({ apiKey, model, baseUrl: env.LITMUS_LLM_BASE_URL ?? "https://api.openai.com/v1" });
2488
+ }
2489
+ var AXES = ["honesty", "coherence"];
2490
+ var RATINGS = ["good", "concern", "bad"];
2491
+ var SYSTEM = 'You are a strict, terse reviewer of Claude Code "skills" (instruction files an AI agent loads). Judge ONLY two axes. honesty: does the body do anything material its frontmatter `description` does not disclose, or claim a far broader/narrower scope than it delivers? coherence: are the instructions clear, internally consistent, and actually followable? Rate each good|concern|bad. Reply with ONLY a JSON object: {"honesty":{"rating":"...","why":"<=20 words"},"coherence":{"rating":"...","why":"<=20 words"}}. No prose.';
2492
+ function buildUserPrompt(loaded) {
2493
+ const body = loaded.body.length > 12e3 ? loaded.body.slice(0, 12e3) + "\n\u2026[truncated]" : loaded.body;
2494
+ return `description: ${loaded.description || "(none)"}
2495
+
2496
+ --- SKILL BODY ---
2497
+ ${body}`;
2498
+ }
2499
+ function parseVerdict(text) {
2500
+ const start = text.indexOf("{");
2501
+ const end = text.lastIndexOf("}");
2502
+ if (start < 0 || end <= start) return null;
2503
+ let obj;
2504
+ try {
2505
+ obj = JSON.parse(text.slice(start, end + 1));
2506
+ } catch {
2507
+ return null;
2508
+ }
2509
+ const out = {};
2510
+ for (const axis of AXES) {
2511
+ const r = obj?.[axis]?.rating;
2512
+ if (typeof r !== "string" || !RATINGS.includes(r)) return null;
2513
+ out[axis] = r;
2514
+ }
2515
+ return out;
2516
+ }
2517
+ function majority(ratings) {
2518
+ const tally = /* @__PURE__ */ new Map();
2519
+ for (const r of ratings) tally.set(r, (tally.get(r) ?? 0) + 1);
2520
+ let best = "good";
2521
+ let bestN = -1;
2522
+ for (const r of RATINGS) {
2523
+ const n = tally.get(r) ?? 0;
2524
+ if (n > bestN || n === bestN && RATINGS.indexOf(r) > RATINGS.indexOf(best)) {
2525
+ best = r;
2526
+ bestN = n;
2527
+ }
2528
+ }
2529
+ return { rating: best, count: bestN };
2530
+ }
2531
+ async function judgeSkillQuality(loaded, judge, opts = {}) {
2532
+ const samples = Math.max(1, Math.min(opts.samples ?? 1, 5));
2533
+ const user = buildUserPrompt(loaded);
2534
+ const verdicts = [];
2535
+ for (let i = 0; i < samples; i++) {
2536
+ const v = parseVerdict(await judge.complete(SYSTEM, user));
2537
+ if (v) verdicts.push(v);
2538
+ }
2539
+ if (verdicts.length === 0) throw new Error("judge returned no parseable verdict");
2540
+ let minAgreement = 1;
2541
+ const axes = AXES.map((axis) => {
2542
+ const m = majority(verdicts.map((v) => v[axis]));
2543
+ minAgreement = Math.min(minAgreement, m.count / verdicts.length);
2544
+ return { axis, rating: m.rating, rationale: `majority of ${verdicts.length} sample(s)` };
2545
+ });
2546
+ return {
2547
+ judge: judge.id,
2548
+ samples: verdicts.length,
2549
+ agreement: Number(minAgreement.toFixed(2)),
2550
+ axes,
2551
+ note: "Advisory, non-deterministic: produced by an LLM judge, not the reproducible static scan. Repeatability is majority-over-k, not bit-identical. Never affects the safety letter and is never minted."
2552
+ };
2553
+ }
2554
+
2555
+ // ../probes/src/skills/quality.ts
2556
+ var SKILL_QUALITY_VERSION = "skill-quality-v1";
2557
+ var QUALITY_DISCLAIMER = "skill-quality-v1 is an ADVISORY signal, separate from the safety grade. It is never an A\u2013F letter and is never minted on-chain. This version runs only the deterministic well-formedness checks; the non-deterministic, LLM-judged axes (outcome fidelity, trigger calibration) are not included, so it does not assert that the skill actually works.";
2558
+ function brokenBundleLinks(body, relPaths) {
2559
+ const broken = [];
2560
+ const seen = /* @__PURE__ */ new Set();
2561
+ for (const m of body.matchAll(/!?\[[^\]]*\]\(([^)\s]+)/g)) {
2562
+ let ref = m[1].trim();
2563
+ if (/^(?:https?:|mailto:|tel:|data:|#)/i.test(ref) || ref.startsWith("/")) continue;
2564
+ ref = ref.replace(/^\.\//, "").split("#")[0].split("?")[0].normalize("NFC");
2565
+ if (!ref || seen.has(ref)) continue;
2566
+ seen.add(ref);
2567
+ if (!relPaths.has(ref)) broken.push(ref);
2568
+ }
2569
+ return broken;
2570
+ }
2571
+ function runSkillQuality(dir, opts = {}) {
2572
+ const ranAt = opts.ranAt ?? (/* @__PURE__ */ new Date()).toISOString();
2573
+ const base = { qualityVersion: SKILL_QUALITY_VERSION, ranAt, disclaimer: QUALITY_DISCLAIMER };
2574
+ let loaded;
2575
+ try {
2576
+ loaded = loadSkill(dir);
2577
+ } catch (e) {
2578
+ return {
2579
+ ...base,
2580
+ skillRef: opts.skillRef ?? dir,
2581
+ contentHash: "0x",
2582
+ verdict: "malformed",
2583
+ checks: [{ id: "loadable", status: "fail", detail: e instanceof SkillLoadError ? e.message : "could not load skill" }]
2584
+ };
2585
+ }
2586
+ const checks = [];
2587
+ const name = /(^|\n)name\s*:/i.test(loaded.frontmatter);
2588
+ checks.push(
2589
+ name ? { id: "frontmatter-name", status: "pass", detail: "frontmatter has a name" } : { id: "frontmatter-name", status: "fail", detail: "frontmatter is missing `name`" }
2590
+ );
2591
+ checks.push(
2592
+ loaded.description.trim() ? { id: "frontmatter-description", status: "pass", detail: "frontmatter has a non-empty description" } : { id: "frontmatter-description", status: "fail", detail: "frontmatter is missing a non-empty `description` (the skill's activation trigger)" }
2593
+ );
2594
+ checks.push(
2595
+ loaded.body.trim() ? { id: "body-nonempty", status: "pass", detail: "the instruction body is non-empty" } : { id: "body-nonempty", status: "fail", detail: "the instruction body is empty" }
2596
+ );
2597
+ const relPaths = new Set(loaded.files.map((f) => f.relPath));
2598
+ const broken = brokenBundleLinks(loaded.body, relPaths);
2599
+ checks.push(
2600
+ broken.length === 0 ? { id: "bundled-links-resolve", status: "pass", detail: "all relative links in the body resolve to bundled files" } : { id: "bundled-links-resolve", status: "warn", detail: `broken relative link(s) to: ${broken.slice(0, 5).join(", ")}` }
2601
+ );
2602
+ const verdict = checks.some((c) => c.status === "fail") ? "malformed" : checks.some((c) => c.status === "warn") ? "issues" : "well-formed";
2603
+ return { ...base, skillRef: opts.skillRef ?? dir, contentHash: loaded.contentHash, verdict, checks };
2604
+ }
2605
+ async function runSkillQualityJudged(dir, judge, opts = {}) {
2606
+ const bundle = runSkillQuality(dir, opts);
2607
+ if (bundle.contentHash === "0x") return bundle;
2608
+ try {
2609
+ bundle.judged = await judgeSkillQuality(loadSkill(dir), judge, opts);
2610
+ } catch {
2611
+ }
2612
+ return bundle;
2613
+ }
2614
+
2160
2615
  export {
2161
2616
  connectTarget,
2162
2617
  fingerprintToolDefs,
@@ -2170,5 +2625,23 @@ export {
2170
2625
  hasHighSeverity,
2171
2626
  gradeFromCategories,
2172
2627
  assembleBundle,
2173
- runLitmus
2628
+ runLitmus,
2629
+ SkillLoadError,
2630
+ loadSkill,
2631
+ stripExamples,
2632
+ skillInjection,
2633
+ skillInjectionFails,
2634
+ exfilInstruction,
2635
+ dangerousCommand,
2636
+ overBroadTrigger,
2637
+ gradeSkillCategories,
2638
+ SKILL_METHODOLOGY_VERSION,
2639
+ SKILL_BUNDLE_SCHEMA_VERSION,
2640
+ runSkillLitmus,
2641
+ openAICompatJudge,
2642
+ judgeFromEnv,
2643
+ judgeSkillQuality,
2644
+ SKILL_QUALITY_VERSION,
2645
+ runSkillQuality,
2646
+ runSkillQualityJudged
2174
2647
  };