efaimo 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +128 -5
- package/README.md +69 -7
- package/SKILL.md +16 -9
- package/dist/check/check.js +8 -0
- package/dist/cli.js +179 -27
- package/dist/clients/introspect.js +10 -2
- package/dist/core/engine.d.ts +2 -1
- package/dist/core/engine.js +4 -0
- package/dist/core/types.d.ts +121 -0
- package/dist/find/bm25.d.ts +77 -0
- package/dist/find/bm25.js +107 -0
- package/dist/find/find.d.ts +120 -0
- package/dist/find/find.js +403 -0
- package/dist/find/tokenize.d.ts +64 -0
- package/dist/find/tokenize.js +116 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +13 -3
- package/dist/reporters/json.d.ts +19 -1
- package/dist/reporters/json.js +13 -2
- package/dist/reporters/markdown.d.ts +2 -1
- package/dist/reporters/markdown.js +109 -11
- package/dist/reporters/pretty.d.ts +12 -1
- package/dist/reporters/pretty.js +177 -10
- package/dist/rules/find/index.d.ts +4 -0
- package/dist/rules/find/index.js +262 -0
- package/dist/rules/injection.d.ts +1 -2
- package/dist/rules/injection.js +0 -11
- package/dist/rules/mcp/index.js +21 -1
- package/dist/rules/skill/index.js +5 -3
- package/dist/rules/version.d.ts +26 -0
- package/dist/rules/version.js +26 -0
- package/dist/targets/repoScan.js +0 -0
- package/dist/testing/anthropicRunner.d.ts +30 -0
- package/dist/testing/anthropicRunner.js +43 -8
- package/dist/testing/harness.d.ts +33 -1
- package/dist/testing/harness.js +66 -7
- package/dist/testing/openaiRunner.js +15 -8
- package/dist/testing/stats.d.ts +16 -2
- package/dist/testing/stats.js +26 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/weigh/anthropicExact.d.ts +12 -1
- package/dist/weigh/anthropicExact.js +9 -1
- package/dist/weigh/weigh.js +10 -3
- package/docs/INTEGRATIONS.md +15 -1
- package/docs/METHODOLOGY.md +111 -7
- package/docs/RULES.md +42 -5
- package/examples/scenario.crm.yaml +8 -0
- package/examples/scenario.example.yaml +8 -0
- package/package.json +2 -2
package/dist/reporters/pretty.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import pc from "picocolors";
|
|
2
2
|
import { formatWindowShare } from "../weigh/window.js";
|
|
3
3
|
import { sortFindings } from "../core/grade.js";
|
|
4
|
+
import { minimumDetectableDelta } from "../testing/stats.js";
|
|
4
5
|
import { VERSION } from "../version.js";
|
|
5
6
|
import { safeText } from "../util/safeText.js";
|
|
6
7
|
const DOCS_RULES = "https://github.com/efaimo-ai/efaimo/blob/main/docs/RULES.md";
|
|
@@ -32,8 +33,23 @@ function gradeColor(letter, text) {
|
|
|
32
33
|
function n(x) {
|
|
33
34
|
return x.toLocaleString("en-US");
|
|
34
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* A value that has to stay inside one line of a table or one line of a
|
|
38
|
+
* finding.
|
|
39
|
+
*
|
|
40
|
+
* `safeText` strips control characters but deliberately keeps newlines,
|
|
41
|
+
* because a detail block is meant to be multi-line. That is the wrong default
|
|
42
|
+
* for a single cell: a tool named
|
|
43
|
+
* `nl_tool\n 9 forged_row weather, forecast` renders as a SECOND
|
|
44
|
+
* row byte-identical in shape to a real one, and under `--no-color`, which is
|
|
45
|
+
* how CI logs read, there is no tell at all. The audited thing must not be
|
|
46
|
+
* able to write rows in the auditor's table.
|
|
47
|
+
*/
|
|
48
|
+
function cell(s) {
|
|
49
|
+
return safeText(s).replace(/[\r\n]+/g, " ");
|
|
50
|
+
}
|
|
35
51
|
function renderFinding(lines, f) {
|
|
36
|
-
lines.push(` ${sevGlyph(f)} ${paint(pc.bold, f.ruleId)} ${
|
|
52
|
+
lines.push(` ${sevGlyph(f)} ${paint(pc.bold, f.ruleId)} ${cell(f.message)}`);
|
|
37
53
|
if (f.detail) {
|
|
38
54
|
for (const d of safeText(f.detail).split("\n"))
|
|
39
55
|
lines.push(paint(pc.dim, ` ${d}`));
|
|
@@ -80,13 +96,23 @@ export function renderCheckPretty(report) {
|
|
|
80
96
|
export function renderScenarioPlan(s) {
|
|
81
97
|
const calls = s.trials * 2 * 2;
|
|
82
98
|
const firstLine = s.task.split("\n")[0].slice(0, 60);
|
|
99
|
+
const mde = minimumDetectableDelta(s.trials);
|
|
83
100
|
return [
|
|
84
101
|
paint(pc.dim, `efaimo v${VERSION}`),
|
|
85
102
|
`test (dry run) ${paint(pc.bold, s.name)}`,
|
|
86
103
|
"",
|
|
87
104
|
` skill ${s.skillName}`,
|
|
88
105
|
` model ${s.model}`,
|
|
106
|
+
` judge ${s.judgeModel}${s.judgeModel === s.model
|
|
107
|
+
? paint(pc.yellow, " (same model grades its own answers; set judge_model or --judge-model to separate them)")
|
|
108
|
+
: ""}`,
|
|
89
109
|
` plan ${s.trials} trials x 2 arms x (task + judge) = ${paint(pc.bold, String(calls))} API calls`,
|
|
110
|
+
// What this plan cannot detect, before any tokens are spent. A run that
|
|
111
|
+
// cannot reach significance produces a green that means nothing, and the
|
|
112
|
+
// only useful moment to learn that is now.
|
|
113
|
+
` power ${Number.isFinite(mde)
|
|
114
|
+
? `the smallest gap this size can call significant is ${paint(mde > 40 ? pc.yellow : pc.dim, `${mde.toFixed(0)} points`)}${mde > 40 ? paint(pc.yellow, " (raise trials)") : ""}`
|
|
115
|
+
: paint(pc.red, "no outcome at this size can reach p < 0.05; raise trials")}`,
|
|
90
116
|
` task ${paint(pc.dim, firstLine + (s.task.length > 60 ? "..." : ""))}`,
|
|
91
117
|
"",
|
|
92
118
|
paint(pc.yellow, "dry run: no API calls made. add --live to run it (Claude models need ANTHROPIC_API_KEY, GPT models need OPENAI_API_KEY)."),
|
|
@@ -98,12 +124,14 @@ export function renderTestReportPretty(r) {
|
|
|
98
124
|
const lines = [
|
|
99
125
|
paint(pc.dim, `efaimo v${VERSION}`),
|
|
100
126
|
`test ${paint(pc.bold, r.scenario)}`,
|
|
101
|
-
`skill ${r.skill} model ${r.model}`,
|
|
127
|
+
`skill ${r.skill} model ${r.model} judge ${r.judgeModel}${r.judgeModel === r.model ? paint(pc.dim, " (same model)") : ""}`,
|
|
102
128
|
"",
|
|
103
129
|
` with skill ${r.withSkill.passes}/${r.withSkill.scored} pass (${r.withSkill.passRate.toFixed(0)}%)`,
|
|
104
130
|
` without skill ${r.withoutSkill.passes}/${r.withoutSkill.scored} pass (${r.withoutSkill.passRate.toFixed(0)}%)`,
|
|
105
131
|
` delta ${paint(pc.bold, `${sign}${r.deltaPoints} points`)} ${paint(verdictColor, r.verdict)}`,
|
|
106
|
-
|
|
132
|
+
Number.isFinite(r.ci.lo)
|
|
133
|
+
? ` significance p = ${r.p < 0.0001 ? "<0.0001" : r.p.toFixed(4)} 95% CI ${r.ci.lo >= 0 ? "+" : ""}${r.ci.lo} to ${r.ci.hi >= 0 ? "+" : ""}${r.ci.hi} points`
|
|
134
|
+
: ` significance not computable: an arm produced no scoreable trial`,
|
|
107
135
|
"",
|
|
108
136
|
];
|
|
109
137
|
for (const n of r.notes)
|
|
@@ -128,21 +156,21 @@ export function renderSkillSetPretty(res) {
|
|
|
128
156
|
for (const s of [...res.perSkill].sort((a, b) => a.report.grade.score - b.report.grade.score)) {
|
|
129
157
|
const g = s.report.grade;
|
|
130
158
|
const c = s.report.counts;
|
|
131
|
-
lines.push(` ${gradeColor(g.letter, `${g.letter} (${String(g.score).padStart(3)})`)} ${
|
|
159
|
+
lines.push(` ${gradeColor(g.letter, `${g.letter} (${String(g.score).padStart(3)})`)} ${cell(s.name).padEnd(nameW)} ` +
|
|
132
160
|
`${paint(pc.red, String(c.error))}e ${paint(pc.yellow, String(c.warn))}w ${paint(pc.cyan, String(c.info))}i`);
|
|
133
161
|
}
|
|
134
162
|
if (res.setFindings.length) {
|
|
135
163
|
lines.push("");
|
|
136
164
|
lines.push(paint(pc.bold, "across the set"));
|
|
137
165
|
for (const f of sortFindings(res.setFindings)) {
|
|
138
|
-
lines.push(` ${sevGlyph(f)} ${paint(pc.bold, f.ruleId)} ${
|
|
166
|
+
lines.push(` ${sevGlyph(f)} ${paint(pc.bold, f.ruleId)} ${cell(f.message)}`);
|
|
139
167
|
}
|
|
140
168
|
}
|
|
141
169
|
const flagged = res.perSkill.filter((s) => s.report.findings.length);
|
|
142
170
|
if (flagged.length) {
|
|
143
171
|
lines.push("");
|
|
144
172
|
for (const s of flagged) {
|
|
145
|
-
lines.push(paint(pc.bold, `${
|
|
173
|
+
lines.push(paint(pc.bold, `${cell(s.name)} ${gradeColor(s.report.grade.letter, s.report.grade.letter)}`));
|
|
146
174
|
for (const f of s.report.findings) {
|
|
147
175
|
lines.push(` ${sevGlyph(f)} ${paint(pc.bold, f.ruleId)} ${safeText(f.message)}`);
|
|
148
176
|
if (f.fixHint)
|
|
@@ -168,7 +196,7 @@ export function renderServerWeighPretty(w) {
|
|
|
168
196
|
if (w.instructionsTokens > 0)
|
|
169
197
|
lines.push(` server instructions ${n(w.instructionsTokens).padStart(4)}`);
|
|
170
198
|
if (w.anthropicExactTotal !== undefined) {
|
|
171
|
-
lines.push(` ${paint(pc.green, "anthropic exact")} ${n(w.anthropicExactTotal).padStart(8)} (count_tokens
|
|
199
|
+
lines.push(` ${paint(pc.green, "anthropic exact")} ${n(w.anthropicExactTotal).padStart(8)} (count_tokens, ${safeText(w.anthropicExactModel ?? "model not recorded")})`);
|
|
172
200
|
}
|
|
173
201
|
if (w.perTool.length) {
|
|
174
202
|
lines.push("");
|
|
@@ -181,9 +209,9 @@ export function renderServerWeighPretty(w) {
|
|
|
181
209
|
// column in its own flagship demo. Capped so one pathological name cannot
|
|
182
210
|
// push the numbers off a terminal.
|
|
183
211
|
const shown = w.perTool.slice(0, 8);
|
|
184
|
-
const toolW = Math.min(38, Math.max(20, ...shown.map((t) =>
|
|
212
|
+
const toolW = Math.min(38, Math.max(20, ...shown.map((t) => cell(t.name).length)));
|
|
185
213
|
for (const [i, t] of shown.entries()) {
|
|
186
|
-
lines.push(` ${String(i + 1).padStart(2)}. ${
|
|
214
|
+
lines.push(` ${String(i + 1).padStart(2)}. ${cell(t.name).padEnd(toolW)} ${n(t.tokens.claudeStyle).padStart(7)} desc ${n(t.descriptionTokens)} | schema ${n(t.schemaTokens)}`);
|
|
187
215
|
}
|
|
188
216
|
if (w.perTool.length > 8)
|
|
189
217
|
lines.push(paint(pc.dim, ` (+${w.perTool.length - 8} more)`));
|
|
@@ -203,7 +231,7 @@ export function renderSkillWeighPretty(w) {
|
|
|
203
231
|
lines.push("");
|
|
204
232
|
lines.push(` ${"skill".padEnd(28)} ${"metadata".padStart(8)} ${"body".padStart(9)} ${"lines".padStart(6)} refs`);
|
|
205
233
|
for (const s of w.perSkill) {
|
|
206
|
-
lines.push(` ${
|
|
234
|
+
lines.push(` ${cell(s.name).padEnd(28)} ${n(s.metadataTokens).padStart(8)} ${n(s.bodyTokens).padStart(9)} ${String(s.bodyLines).padStart(6)} ${s.refFileCount ? `${s.refFileCount} files ${n(s.refFileTokens)}` : "-"}`);
|
|
207
235
|
}
|
|
208
236
|
lines.push("");
|
|
209
237
|
lines.push(`totals: metadata ${paint(pc.bold, n(w.totals.metadata))} (always loaded) | body ${n(w.totals.body)} (on trigger) | referenced ${n(w.totals.refFiles)} (on demand)`);
|
|
@@ -212,6 +240,145 @@ export function renderSkillWeighPretty(w) {
|
|
|
212
240
|
lines.push(paint(pc.dim, `note: ${safeText(note)}`));
|
|
213
241
|
return lines.join("\n");
|
|
214
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* `efaimo find`.
|
|
245
|
+
*
|
|
246
|
+
* Two numbers, both as fractions with their denominators printed, neither a
|
|
247
|
+
* letter. The first is a property of the catalog; the second is a simulation
|
|
248
|
+
* and is labelled as one on the line itself, because a saturated 100% next to
|
|
249
|
+
* a real measurement reads like a good score unless the page says otherwise.
|
|
250
|
+
*/
|
|
251
|
+
export function renderFindPretty(f) {
|
|
252
|
+
const lines = [];
|
|
253
|
+
lines.push(paint(pc.dim, `efaimo v${VERSION}`));
|
|
254
|
+
lines.push(`find mcp ${paint(pc.bold, safeText(f.label))}`);
|
|
255
|
+
lines.push(paint(pc.dim, `tools ${f.toolCount} result window ${f.method.topK} BM25 k1=${f.method.bm25.k1} b=${f.method.bm25.b}, ${f.method.queryTerms} query terms`));
|
|
256
|
+
lines.push("");
|
|
257
|
+
// Coloured on the rule condition, not on invented cutoffs. An earlier
|
|
258
|
+
// version banded this at 95 and 80 percent, two numbers that appear in no
|
|
259
|
+
// rule, no doc and no ADR, inside the one command whose whole design note
|
|
260
|
+
// says it does not grade. Red means E141 fired; that is a threshold the
|
|
261
|
+
// ruleset already owns.
|
|
262
|
+
const dpct = f.distinct.pct;
|
|
263
|
+
const anyOwnsNothing = f.distinct.count < f.distinct.total;
|
|
264
|
+
const dcolor = f.distinctVacuous ? pc.dim : anyOwnsNothing ? pc.red : pc.green;
|
|
265
|
+
const dFrac = `${f.distinct.count}/${f.distinct.total} (${dpct}%)`;
|
|
266
|
+
const pFrac = `${f.probe.returned}/${f.probe.total} (${f.probe.pct}%)`;
|
|
267
|
+
// Pad on the plain text, never on the painted string: colour codes are bytes
|
|
268
|
+
// that padEnd counts and a terminal does not, which is how a coloured column
|
|
269
|
+
// ends up crooked in exactly the demo everyone screenshots.
|
|
270
|
+
const fracW = Math.max(dFrac.length, pFrac.length);
|
|
271
|
+
lines.push(`distinct ${paint(dcolor, dFrac)}${" ".repeat(fracW - dFrac.length)}` +
|
|
272
|
+
` ${paint(pc.dim, "tools that own a word no other tool has")}`);
|
|
273
|
+
if (f.distinctVacuous) {
|
|
274
|
+
lines.push(paint(pc.yellow, ` vacuous: a one-tool catalog has nothing to be distinct from, so this is 100% for any tool`));
|
|
275
|
+
}
|
|
276
|
+
lines.push(`probe ${paint(pc.dim, pFrac)}${" ".repeat(fracW - pFrac.length)}` +
|
|
277
|
+
` ${paint(pc.dim, "returned by a simulated search for their own description")}`);
|
|
278
|
+
if (f.windowCoversCatalog) {
|
|
279
|
+
lines.push(paint(pc.yellow, ` vacuous: ${f.toolCount} tools fit inside a window of ${f.method.topK}, so none can fall outside it`));
|
|
280
|
+
}
|
|
281
|
+
lines.push("");
|
|
282
|
+
// Worst first. A reader scanning the top of the table should be looking at
|
|
283
|
+
// the tools that cannot be told apart, not at an alphabetical list.
|
|
284
|
+
const byWorst = [...f.perTool].sort((a, b) => {
|
|
285
|
+
const ar = a.rank ?? Number.POSITIVE_INFINITY;
|
|
286
|
+
const br = b.rank ?? Number.POSITIVE_INFINITY;
|
|
287
|
+
return (a.ownTermCount - b.ownTermCount ||
|
|
288
|
+
br - ar ||
|
|
289
|
+
(a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
290
|
+
});
|
|
291
|
+
const shown = byWorst.slice(0, 15);
|
|
292
|
+
const nameW = Math.min(34, Math.max(12, ...shown.map((t) => cell(t.name).length)));
|
|
293
|
+
lines.push(paint(pc.dim, ` words ${"tool".padEnd(nameW)} vocabulary no other tool has`));
|
|
294
|
+
for (const t of shown) {
|
|
295
|
+
const count = t.ownTermCount === 0
|
|
296
|
+
? paint(pc.red, " 0")
|
|
297
|
+
: t.ownTermCount === 1
|
|
298
|
+
? paint(pc.yellow, " 1")
|
|
299
|
+
: paint(pc.green, String(t.ownTermCount).padStart(5));
|
|
300
|
+
let tail;
|
|
301
|
+
if (t.ownTermCount === 0) {
|
|
302
|
+
tail = paint(pc.red, t.sharedWith.length ? `none: every word also on ${cell(t.sharedWith.join(", "))}` : "none");
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
const more = t.ownTermCount - t.ownTerms.length;
|
|
306
|
+
tail = paint(pc.dim, cell(t.ownTerms.join(", ")) + (more > 0 ? ` (+${more})` : ""));
|
|
307
|
+
}
|
|
308
|
+
// The probe only ever appears as a flag on a row it failed. A column of
|
|
309
|
+
// "1" down the whole table would be a number that never varies presented
|
|
310
|
+
// as if it were a measurement.
|
|
311
|
+
const probe = t.rank === undefined
|
|
312
|
+
? paint(pc.red, " [probe: no description]")
|
|
313
|
+
: t.reachable
|
|
314
|
+
? ""
|
|
315
|
+
: paint(pc.red, ` [probe: rank ${t.rank}]`);
|
|
316
|
+
lines.push(` ${count} ${cell(t.name).padEnd(nameW)} ${tail}${probe}`);
|
|
317
|
+
}
|
|
318
|
+
// Describe the hidden rows from the hidden rows, never from an assumption.
|
|
319
|
+
// This line used to read "(+N more, each owning at least one word)" without
|
|
320
|
+
// checking: on a catalog where all 18 tools owned nothing it printed that
|
|
321
|
+
// three lines under a `0/18` headline saying the opposite.
|
|
322
|
+
const hiddenRows = byWorst.slice(shown.length);
|
|
323
|
+
if (hiddenRows.length) {
|
|
324
|
+
const hiddenAtZero = hiddenRows.filter((t) => t.ownTermCount === 0).length;
|
|
325
|
+
lines.push(paint(pc.dim, ` (+${hiddenRows.length} more` +
|
|
326
|
+
(hiddenAtZero ? `, ${hiddenAtZero} of them owning no word` : ", each owning at least one word") +
|
|
327
|
+
`; --json for all of them)`));
|
|
328
|
+
}
|
|
329
|
+
return lines.join("\n");
|
|
330
|
+
}
|
|
331
|
+
/** The findings half of `efaimo find`, printed under the table. */
|
|
332
|
+
export function renderFindFindingsPretty(f, findings) {
|
|
333
|
+
const lines = [""];
|
|
334
|
+
if (!findings.length) {
|
|
335
|
+
lines.push(paint(pc.green, " no findability findings."));
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
for (const x of sortFindings(findings))
|
|
339
|
+
renderFinding(lines, x);
|
|
340
|
+
}
|
|
341
|
+
lines.push("");
|
|
342
|
+
for (const note of f.notes) {
|
|
343
|
+
// The two vacuity notes are already printed inline, in yellow, on the row
|
|
344
|
+
// the reader is looking at. Repeating them here as prose was the same
|
|
345
|
+
// warning twice in one screen; the data still carries them for --json and
|
|
346
|
+
// --md, where there is no inline row to carry it.
|
|
347
|
+
if (note.startsWith("VACUOUS"))
|
|
348
|
+
continue;
|
|
349
|
+
for (const [i, line] of wrapNote(safeText(note)).entries()) {
|
|
350
|
+
lines.push(paint(pc.dim, i === 0 ? `note: ${line}` : ` ${line}`));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// What to do next. A clean run used to end on "no findability findings" and
|
|
354
|
+
// a rules URL, which tells a first-time reader nothing about how to keep it
|
|
355
|
+
// that way. `distinct` is named because it is the number that can fail at
|
|
356
|
+
// any catalog size; the probe cannot.
|
|
357
|
+
if (!f.distinctVacuous) {
|
|
358
|
+
lines.push(paint(pc.dim, `gate it: efaimo find "<server>" --min-distinct ${Math.floor(f.distinct.pct)}` +
|
|
359
|
+
(f.distinct.pct < 100 ? " (raise it as you fix the tools above)" : "")));
|
|
360
|
+
}
|
|
361
|
+
lines.push(paint(pc.dim, `rules: ${DOCS_RULES}`));
|
|
362
|
+
return lines.join("\n");
|
|
363
|
+
}
|
|
364
|
+
/** Soft-wrap a long note at ~92 columns on word boundaries, so terminals do not ragged-wrap mid-number. */
|
|
365
|
+
function wrapNote(s, width = 92) {
|
|
366
|
+
const out = [];
|
|
367
|
+
for (const paragraph of s.split("\n")) {
|
|
368
|
+
let line = "";
|
|
369
|
+
for (const word of paragraph.split(/\s+/)) {
|
|
370
|
+
if (line && line.length + 1 + word.length > width) {
|
|
371
|
+
out.push(line);
|
|
372
|
+
line = word;
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
line = line ? `${line} ${word}` : word;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
out.push(line);
|
|
379
|
+
}
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
215
382
|
export function renderDiffPretty(d, opts) {
|
|
216
383
|
const lines = [];
|
|
217
384
|
const sign = d.delta >= 0 ? "+" : "";
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Findability rules, E141-E146.
|
|
3
|
+
*
|
|
4
|
+
* A separate family with its own command on purpose. They are not readiness
|
|
5
|
+
* (which asks whether a 2026-07-28 client can talk to this server) and not
|
|
6
|
+
* quality (which asks what a model experiences once a tool is in front of it).
|
|
7
|
+
* They ask a third thing: under deferred loading, does anything ever put this
|
|
8
|
+
* tool in front of the model at all.
|
|
9
|
+
*
|
|
10
|
+
* E141 is the one that is not a heuristic. The others read a simulation and
|
|
11
|
+
* are worth less; they are kept because the simulation is the only thing here
|
|
12
|
+
* that models the real mechanism, and because the cases they do catch (a tool
|
|
13
|
+
* with no description, two tools with the same one) are worth catching.
|
|
14
|
+
*
|
|
15
|
+
* None of them feeds `check`'s grade, and `find` produces no grade of its own.
|
|
16
|
+
* ADR-030 has the reasoning.
|
|
17
|
+
*/
|
|
18
|
+
function cap(items, n) {
|
|
19
|
+
return { shown: items.slice(0, n), more: Math.max(0, items.length - n) };
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A target-supplied value going into a rule message or detail.
|
|
23
|
+
*
|
|
24
|
+
* The reporters strip control characters but keep newlines, because a detail
|
|
25
|
+
* block is legitimately multi-line. That makes a newline inside a tool name a
|
|
26
|
+
* way for the audited server to add its own line to the auditor's output. The
|
|
27
|
+
* values these rules interpolate - names, terms - are all single-line by
|
|
28
|
+
* nature, so they are flattened here, at the point where a multi-line region
|
|
29
|
+
* is built out of them.
|
|
30
|
+
*/
|
|
31
|
+
function one(s) {
|
|
32
|
+
return s.replace(/[\r\n]+/g, " ");
|
|
33
|
+
}
|
|
34
|
+
const list = (xs) => xs.map(one).join(", ");
|
|
35
|
+
const e141 = {
|
|
36
|
+
id: "E141",
|
|
37
|
+
title: "tool owns no exclusive vocabulary",
|
|
38
|
+
surface: "find",
|
|
39
|
+
check({ find }) {
|
|
40
|
+
const shared = find.perTool.filter((t) => t.ownTermCount === 0);
|
|
41
|
+
const { shown, more } = cap(shared, 5);
|
|
42
|
+
const findings = shown.map((t) => ({
|
|
43
|
+
ruleId: "E141",
|
|
44
|
+
severity: "warn",
|
|
45
|
+
title: "tool owns no exclusive vocabulary",
|
|
46
|
+
// Stated as what it is: an existence claim about queries, not a
|
|
47
|
+
// prediction about one search. Every term this tool has appears on some
|
|
48
|
+
// other tool, so there is no query that matches it alone. Whether it
|
|
49
|
+
// still wins a shared query depends on term frequencies, which is a much
|
|
50
|
+
// weaker thing to rely on than owning a word.
|
|
51
|
+
message: `tool "${one(t.name)}" has no term that no other tool has, so no search can return it without also returning a competitor`,
|
|
52
|
+
target: t.name,
|
|
53
|
+
// "also appears on", not "covered by": no single tool on this list
|
|
54
|
+
// necessarily carries the whole vocabulary. They are the tools with the
|
|
55
|
+
// most overlap, which is where to look first when writing the fix.
|
|
56
|
+
detail: t.sharedWith.length ? `every word it has also appears on: ${list(t.sharedWith)} (most overlap first)` : undefined,
|
|
57
|
+
fixHint: "put something in the description that only this tool does: the object it acts on, the format it returns, the case it is for",
|
|
58
|
+
}));
|
|
59
|
+
if (more) {
|
|
60
|
+
findings.push({
|
|
61
|
+
ruleId: "E141",
|
|
62
|
+
severity: "warn",
|
|
63
|
+
title: "tool owns no exclusive vocabulary",
|
|
64
|
+
message: `...and ${more} more tools that share every one of their terms with another tool`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return findings;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
const e142 = {
|
|
71
|
+
id: "E142",
|
|
72
|
+
title: "a search for the tool's own description does not return it",
|
|
73
|
+
surface: "find",
|
|
74
|
+
check({ find }) {
|
|
75
|
+
const findings = [];
|
|
76
|
+
const unsearchable = find.perTool.filter((t) => t.rank === undefined);
|
|
77
|
+
for (const t of cap(unsearchable, 5).shown) {
|
|
78
|
+
findings.push({
|
|
79
|
+
ruleId: "E142",
|
|
80
|
+
severity: "warn",
|
|
81
|
+
title: "a search for the tool's own description does not return it",
|
|
82
|
+
message: `tool "${one(t.name)}" offers no words to search for: its description is empty or made only of function words, so nothing but the exact name reaches it`,
|
|
83
|
+
target: t.name,
|
|
84
|
+
fixHint: "write a description containing the words someone would use to describe the task this tool does",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const missed = find.perTool.filter((t) => t.rank !== undefined && !t.reachable);
|
|
88
|
+
for (const t of cap(missed, 5).shown) {
|
|
89
|
+
findings.push({
|
|
90
|
+
ruleId: "E142",
|
|
91
|
+
severity: "warn",
|
|
92
|
+
title: "a search for the tool's own description does not return it",
|
|
93
|
+
message: `tool "${one(t.name)}" ranks ${t.rank} of ${find.toolCount} for a search built from its own description (${list(t.query)}), outside a result window of ${find.method.topK}`,
|
|
94
|
+
target: t.name,
|
|
95
|
+
detail: t.outrankedBy.length ? `outranked by: ${list(t.outrankedBy)}` : undefined,
|
|
96
|
+
fixHint: "make the description carry a term the other tools do not share",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const more = cap(unsearchable, 5).more + cap(missed, 5).more;
|
|
100
|
+
if (more) {
|
|
101
|
+
findings.push({
|
|
102
|
+
ruleId: "E142",
|
|
103
|
+
severity: "warn",
|
|
104
|
+
title: "a search for the tool's own description does not return it",
|
|
105
|
+
message: `...and ${more} more tools a search for their own description does not return`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return findings;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
const e143 = {
|
|
112
|
+
id: "E143",
|
|
113
|
+
title: "tools are indistinguishable to a search",
|
|
114
|
+
surface: "find",
|
|
115
|
+
check({ find }) {
|
|
116
|
+
// Report each pair once. A ties with B is the same fact as B ties with A,
|
|
117
|
+
// and printing both halves doubles the finding count, which would double
|
|
118
|
+
// the apparent size of the problem.
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
const findings = [];
|
|
121
|
+
for (const t of find.perTool) {
|
|
122
|
+
for (const other of t.tiedWith) {
|
|
123
|
+
const key = t.name < other ? `${one(t.name)} ${one(other)}` : `${one(other)} ${one(t.name)}`;
|
|
124
|
+
if (seen.has(key))
|
|
125
|
+
continue;
|
|
126
|
+
seen.add(key);
|
|
127
|
+
if (findings.length >= 5)
|
|
128
|
+
continue;
|
|
129
|
+
findings.push({
|
|
130
|
+
ruleId: "E143",
|
|
131
|
+
severity: "warn",
|
|
132
|
+
title: "tools are indistinguishable to a search",
|
|
133
|
+
message: `tools "${one(t.name)}" and "${one(other)}" score identically for a search built from "${one(t.name)}"'s description (${list(t.query)}); only the alphabetical tie-break separates them`,
|
|
134
|
+
target: t.name,
|
|
135
|
+
fixHint: "give each description a term the other does not have, or merge the tools",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// The true pair count comes from tiedWithCount, not from the names above.
|
|
140
|
+
// `tiedWith` is capped at three per tool for display, so counting the
|
|
141
|
+
// pairs it yields reported 30 for a catalog whose real answer was 66: a
|
|
142
|
+
// count of what survived a display cap, printed as a count of the problem.
|
|
143
|
+
// Each pair is counted from both ends, hence the halving.
|
|
144
|
+
const totalPairs = Math.round(find.perTool.reduce((n, t) => n + t.tiedWithCount, 0) / 2);
|
|
145
|
+
if (totalPairs > findings.length) {
|
|
146
|
+
findings.push({
|
|
147
|
+
ruleId: "E143",
|
|
148
|
+
severity: "warn",
|
|
149
|
+
title: "tools are indistinguishable to a search",
|
|
150
|
+
message: `...and ${totalPairs - findings.length} more indistinguishable pairs (${totalPairs} in total)`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return findings;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
const e146 = {
|
|
157
|
+
id: "E146",
|
|
158
|
+
title: "the only word a tool owns is its own name",
|
|
159
|
+
surface: "find",
|
|
160
|
+
check({ find }) {
|
|
161
|
+
// Names ARE searchable, so this tool is findable in principle and E141
|
|
162
|
+
// correctly does not fire. It is not findable by anyone describing a task,
|
|
163
|
+
// which is the situation deferred loading creates, so the two cases are
|
|
164
|
+
// reported separately rather than merged into one number.
|
|
165
|
+
const nameOnly = find.perTool.filter((t) => t.ownTermCount > 0 && t.ownOutsideNameCount === 0);
|
|
166
|
+
const { shown, more } = cap(nameOnly, 5);
|
|
167
|
+
const findings = shown.map((t) => ({
|
|
168
|
+
ruleId: "E146",
|
|
169
|
+
severity: "info",
|
|
170
|
+
title: "the only word a tool owns is its own name",
|
|
171
|
+
message: t.ownTermCount === 1
|
|
172
|
+
? `tool "${one(t.name)}" owns one term and it comes from its own name; nothing in its description is unique, so only someone who already knows the name can single it out`
|
|
173
|
+
: `tool "${one(t.name)}" owns ${t.ownTermCount} terms and every one of them comes from its own name; nothing in its description is unique, so only someone who already knows the name can single it out`,
|
|
174
|
+
target: t.name,
|
|
175
|
+
fixHint: "put a word in the description that no other tool's description uses",
|
|
176
|
+
}));
|
|
177
|
+
if (more) {
|
|
178
|
+
findings.push({
|
|
179
|
+
ruleId: "E146",
|
|
180
|
+
severity: "info",
|
|
181
|
+
title: "the only word a tool owns is its own name",
|
|
182
|
+
message: `...and ${more} more tools whose only exclusive words are their own name`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return findings;
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
const e144 = {
|
|
189
|
+
id: "E144",
|
|
190
|
+
title: "tool name carries no domain term",
|
|
191
|
+
surface: "find",
|
|
192
|
+
check({ find }) {
|
|
193
|
+
// All-generic AND no name token of its own.
|
|
194
|
+
//
|
|
195
|
+
// "Generic" alone was too weak: the fixture server's `add` is a perfectly
|
|
196
|
+
// descriptive name for an arithmetic tool, and the rule told its author to
|
|
197
|
+
// "put the subject in the name" with no usable suggestion. What separates
|
|
198
|
+
// `add` from `run` is not the word list, it is whether the word belongs to
|
|
199
|
+
// this tool alone in this catalog. `ownTermCount > ownOutsideNameCount`
|
|
200
|
+
// means at least one exclusive term came from the name, which is exactly
|
|
201
|
+
// the case where a broad pattern search does reach it.
|
|
202
|
+
const generic = find.perTool.filter((t) => t.genericName && t.ownTermCount === t.ownOutsideNameCount);
|
|
203
|
+
const { shown, more } = cap(generic, 5);
|
|
204
|
+
const findings = shown.map((t) => ({
|
|
205
|
+
ruleId: "E144",
|
|
206
|
+
severity: "info",
|
|
207
|
+
title: "tool name carries no domain term",
|
|
208
|
+
message: `tool "${one(t.name)}" is named only with generic words, so a broad pattern search (Claude writes things like ".*weather.*") can reach it only through its description`,
|
|
209
|
+
target: t.name,
|
|
210
|
+
fixHint: "put the subject in the name: what does it act on",
|
|
211
|
+
}));
|
|
212
|
+
if (more) {
|
|
213
|
+
findings.push({
|
|
214
|
+
ruleId: "E144",
|
|
215
|
+
severity: "info",
|
|
216
|
+
title: "tool name carries no domain term",
|
|
217
|
+
message: `...and ${more} more tools named only with generic words`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return findings;
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
const e145 = {
|
|
224
|
+
id: "E145",
|
|
225
|
+
title: "deferred catalog with tools a search cannot single out",
|
|
226
|
+
surface: "find",
|
|
227
|
+
check({ find }) {
|
|
228
|
+
if (!find.deferRecommended)
|
|
229
|
+
return [];
|
|
230
|
+
// The union, not the sum. These two sets overlap heavily - a tool with no
|
|
231
|
+
// exclusive vocabulary is often also one the probe misses - and adding
|
|
232
|
+
// them read as twice as many problems as there are. On a 15-tool server
|
|
233
|
+
// this line once claimed 24.
|
|
234
|
+
const affected = new Set();
|
|
235
|
+
for (const t of find.perTool) {
|
|
236
|
+
if (t.ownTermCount === 0 || !t.reachable)
|
|
237
|
+
affected.add(t.name);
|
|
238
|
+
}
|
|
239
|
+
if (!affected.size)
|
|
240
|
+
return [];
|
|
241
|
+
return [
|
|
242
|
+
{
|
|
243
|
+
ruleId: "E145",
|
|
244
|
+
severity: "warn",
|
|
245
|
+
title: "deferred catalog with tools a search cannot single out",
|
|
246
|
+
// The reason comes from the analysis, not from a repeat of one
|
|
247
|
+
// threshold. Anthropic lists several conditions; hardcoding the token
|
|
248
|
+
// one here printed "~3500 tokens, past the ~10.0k" on a catalog that
|
|
249
|
+
// qualified on tool count instead.
|
|
250
|
+
message: `tool search is recommended for this catalog (${find.deferBecause.map(one).join("; ")}), ` +
|
|
251
|
+
`and ${affected.size} of ${find.toolCount} tool${affected.size === 1 ? " cannot" : "s cannot"} be singled out by a search. ` +
|
|
252
|
+
`Once this catalog is deferred, a tool nothing surfaces costs nothing and does nothing.`,
|
|
253
|
+
fixHint: "fix the descriptions above, or split the server so fewer tools compete in one catalog",
|
|
254
|
+
},
|
|
255
|
+
];
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
export const FIND_RULES = [e141, e142, e143, e144, e145, e146];
|
|
259
|
+
/** E141-E146 are findability rules: reported by `efaimo find`, never part of a `check` grade. */
|
|
260
|
+
export function isFindabilityRuleId(id) {
|
|
261
|
+
return /^E14\d$/.test(id);
|
|
262
|
+
}
|
package/dist/rules/injection.js
CHANGED
|
@@ -9,19 +9,16 @@ export const INJECTION_PATTERNS = [
|
|
|
9
9
|
{
|
|
10
10
|
key: "override-instructions",
|
|
11
11
|
re: /ignore\s+(?:all\s+|any\s+)?(?:previous|prior|earlier|above)\s+(?:instructions|messages|rules|prompts)/i,
|
|
12
|
-
severity: "error",
|
|
13
12
|
label: "attempts to override prior instructions",
|
|
14
13
|
},
|
|
15
14
|
{
|
|
16
15
|
key: "hide-from-user",
|
|
17
16
|
re: /do\s+not\s+(?:tell|reveal|mention|inform|show|disclose)\b[\s\S]{0,40}\b(?:user|human|operator)/i,
|
|
18
|
-
severity: "error",
|
|
19
17
|
label: "instructs the agent to hide behavior from the user",
|
|
20
18
|
},
|
|
21
19
|
{
|
|
22
20
|
key: "act-without-consent",
|
|
23
21
|
re: /without\s+(?:telling|asking|notifying|informing)\s+(?:the\s+)?(?:user|human)/i,
|
|
24
|
-
severity: "error",
|
|
25
22
|
label: "instructs the agent to act without user consent",
|
|
26
23
|
},
|
|
27
24
|
{
|
|
@@ -29,51 +26,43 @@ export const INJECTION_PATTERNS = [
|
|
|
29
26
|
// "post" and "token" dropped: they match API documentation ("Token counting
|
|
30
27
|
// POST /v1/...") far more than exfiltration.
|
|
31
28
|
re: /\b(?:api[_-]?key|secret|password|credential)s?\b[\s\S]{0,40}\b(?:send|forward|exfiltrat|upload|leak|transmit|include\s+it)\b/i,
|
|
32
|
-
severity: "error",
|
|
33
29
|
label: "references sending credentials or secrets somewhere",
|
|
34
30
|
},
|
|
35
31
|
{
|
|
36
32
|
key: "sensitive-file-read",
|
|
37
33
|
re: /\b(?:read|open|cat|print|upload)\b[\s\S]{0,40}(?:\.env\b|\.ssh\b|id_rsa|credentials|\.aws\b|\.npmrc\b)/i,
|
|
38
|
-
severity: "error",
|
|
39
34
|
label: "references reading sensitive local files",
|
|
40
35
|
},
|
|
41
36
|
{
|
|
42
37
|
key: "disable-safety",
|
|
43
38
|
re: /\b(?:disable|bypass|ignore|remove)\s+(?:safety|guardrails?|filters?|restrictions?|policy|policies)\b/i,
|
|
44
|
-
severity: "error",
|
|
45
39
|
label: "asks to bypass safety or policy",
|
|
46
40
|
},
|
|
47
41
|
{
|
|
48
42
|
key: "cross-tool-steering",
|
|
49
43
|
re: /before\s+(?:using|calling|running)\b[^.?!]{0,40}?\btools?\b[\s,]{1,4}(?:always\s+|first\s+|please\s+)?(?:call|run|use|fetch|invoke)\b/i,
|
|
50
|
-
severity: "warn",
|
|
51
44
|
label: "steers the agent to call another tool first (cross-tool steering)",
|
|
52
45
|
},
|
|
53
46
|
{
|
|
54
47
|
key: "templated-exfil-url",
|
|
55
48
|
// Bounded runs (no adjacent unbounded same-class quantifiers) to avoid ReDoS.
|
|
56
49
|
re: /https?:\/\/[^\s"'<>]{1,150}[?&][^\s"'<>]{0,150}(?:\$\{[^}]{1,60}\}|\{\{[^}]{1,60}\}\}|\{[^}]{1,60}\})/,
|
|
57
|
-
severity: "warn",
|
|
58
50
|
label: "URL with a templated query parameter (possible exfiltration channel)",
|
|
59
51
|
},
|
|
60
52
|
{
|
|
61
53
|
key: "zero-width-chars",
|
|
62
54
|
// eslint-disable-next-line no-control-regex
|
|
63
55
|
re: /[]/,
|
|
64
|
-
severity: "warn",
|
|
65
56
|
label: "zero-width or invisible characters present",
|
|
66
57
|
},
|
|
67
58
|
{
|
|
68
59
|
key: "hidden-html-comment",
|
|
69
60
|
re: /<!--[^>]{0,240}\b(?:must|always|never|call|run|fetch|send|ignore)\b[^>]{0,240}-->/i,
|
|
70
|
-
severity: "warn",
|
|
71
61
|
label: "HTML comment containing imperative instructions (hidden from rendered view)",
|
|
72
62
|
},
|
|
73
63
|
{
|
|
74
64
|
key: "persona-override",
|
|
75
65
|
re: /\byou\s+are\s+now\b|\bpretend\s+to\s+be\b|\bnew\s+system\s+prompt\b/i,
|
|
76
|
-
severity: "warn",
|
|
77
66
|
label: "persona or system-prompt override language",
|
|
78
67
|
},
|
|
79
68
|
];
|
package/dist/rules/mcp/index.js
CHANGED
|
@@ -166,6 +166,23 @@ const e105 = {
|
|
|
166
166
|
},
|
|
167
167
|
];
|
|
168
168
|
}
|
|
169
|
+
if (bare.kind === "exit") {
|
|
170
|
+
// A crash on the bare request is not a timeout and not a clean answer: the
|
|
171
|
+
// server did not serve a stateless tools/list, so a 2026-07-28 client
|
|
172
|
+
// cannot talk to it. Left unhandled, this outcome produced NO finding, so
|
|
173
|
+
// a crashing server looked one item MORE migrated than one that answers
|
|
174
|
+
// with a not-initialized error.
|
|
175
|
+
return [
|
|
176
|
+
{
|
|
177
|
+
ruleId: "E105",
|
|
178
|
+
severity: "warn",
|
|
179
|
+
title: "requires the removed initialize handshake",
|
|
180
|
+
message: `the server process exited on a bare stateless tools/list (${bare.errorMessage ?? "no reply"}), so it does not answer requests without a prior initialize`,
|
|
181
|
+
detail: `${SPEC} removes initialize and sessions; a server that exits on a bare request cannot serve a ${SPEC} client. A server that answers, even with an error, is judged by E106/E107/E118 instead.`,
|
|
182
|
+
fixHint: "upgrade to a 2.x SDK, or answer requests without a prior initialize instead of exiting",
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
}
|
|
169
186
|
if (bare.kind === "timeout") {
|
|
170
187
|
return [
|
|
171
188
|
{
|
|
@@ -526,7 +543,10 @@ const e124 = {
|
|
|
526
543
|
}
|
|
527
544
|
}
|
|
528
545
|
if (totalParams > 5 && undocumented / totalParams > 0.3) {
|
|
529
|
-
|
|
546
|
+
// Prepend, not append: capList keeps the first 5, and on the worst schemas
|
|
547
|
+
// (5+ oversized enums) an appended summary was exactly the line that got
|
|
548
|
+
// dropped. The aggregate is the headline, so it leads.
|
|
549
|
+
findings.unshift({
|
|
530
550
|
ruleId: "E124",
|
|
531
551
|
severity: "warn",
|
|
532
552
|
title: "schema issues",
|