efaimo 0.1.0 → 0.1.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/CHANGELOG.md +97 -2
- package/README.md +88 -30
- package/SKILL.md +13 -1
- package/dist/check/check.js +6 -2
- package/dist/cli.js +78 -4
- package/dist/clients/rawprobe.d.ts +21 -5
- package/dist/clients/rawprobe.js +101 -43
- package/dist/clients/stateless.js +17 -14
- package/dist/core/engine.js +20 -2
- package/dist/core/grade.js +7 -1
- package/dist/core/types.d.ts +22 -3
- package/dist/reporters/markdown.js +16 -4
- package/dist/reporters/pretty.js +36 -25
- package/dist/rules/injection.js +7 -0
- package/dist/rules/mcp/index.d.ts +14 -0
- package/dist/rules/mcp/index.js +68 -24
- package/dist/skills/parse.js +39 -3
- package/dist/targets/clientConfigs.d.ts +7 -0
- package/dist/targets/clientConfigs.js +4 -1
- package/dist/testing/anthropicRunner.js +2 -0
- package/dist/testing/harness.d.ts +21 -0
- package/dist/testing/harness.js +62 -11
- package/dist/testing/openaiRunner.js +8 -1
- package/dist/testing/stats.d.ts +32 -0
- package/dist/testing/stats.js +91 -0
- package/dist/util/dotenv.d.ts +9 -1
- package/dist/util/dotenv.js +11 -1
- package/dist/util/safeText.d.ts +8 -0
- package/dist/util/safeText.js +48 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/weigh/weigh.js +3 -1
- package/dist/weigh/window.d.ts +26 -0
- package/dist/weigh/window.js +44 -0
- package/docs/METHODOLOGY.md +70 -0
- package/docs/RULES.md +29 -23
- package/examples/contoso-crm-import/SKILL.md +21 -0
- package/examples/csv-cleanup/SKILL.md +17 -0
- package/examples/scenario.crm.yaml +22 -0
- package/examples/scenario.example.yaml +17 -0
- package/package.json +3 -1
package/dist/rules/injection.js
CHANGED
|
@@ -93,7 +93,14 @@ export function scanTextForInjection(text, opts) {
|
|
|
93
93
|
// Deliberately info: these are shallow heuristics an attacker evades
|
|
94
94
|
// trivially. Surfacing them must never move a grade or fail CI, or a
|
|
95
95
|
// clean report would read as a security pass (it is not one).
|
|
96
|
+
//
|
|
97
|
+
// `graded: false` is what makes the first half of that true. It used
|
|
98
|
+
// to be a comment only, while gradeFindings charged every info finding
|
|
99
|
+
// a point, so ten heuristic hits quietly cost a whole letter -- the
|
|
100
|
+
// exact "clean report reads as a security verdict" failure this
|
|
101
|
+
// comment was written to prevent, running in reverse.
|
|
96
102
|
severity: "info",
|
|
103
|
+
graded: false,
|
|
97
104
|
title: "possible injection pattern (heuristic)",
|
|
98
105
|
message: `${opts.where}: ${p.label}`,
|
|
99
106
|
target: opts.where,
|
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import type { McpRule } from "../../core/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Does this tool name read as destructive?
|
|
4
|
+
*
|
|
5
|
+
* This was a single `\b(delete|remove|...)\b` regex, and `_` is a word
|
|
6
|
+
* character, so `\bdelete\b` has no boundary inside `delete_file` - nor inside
|
|
7
|
+
* `deleteFile`. Every conventional MCP tool name escaped it: delete_file,
|
|
8
|
+
* deleteFile, drop_table, removeUser, kill_process, wipe_db all returned
|
|
9
|
+
* false, while only a bare `delete` or `delete-file` matched. E123 is
|
|
10
|
+
* documented in docs/RULES.md as catching "a destructive-looking tool lacking
|
|
11
|
+
* destructiveHint" and it could not fire on essentially any real server.
|
|
12
|
+
*
|
|
13
|
+
* Split on separators AND on camelCase boundaries, then test each segment.
|
|
14
|
+
*/
|
|
15
|
+
export declare function readsDestructive(name: string): boolean;
|
|
2
16
|
export declare const MCP_RULES: McpRule[];
|
|
3
17
|
/** E101-E118 are 2026-07-28 readiness rules: reported as a migration diff, not graded. */
|
|
4
18
|
export declare function isReadinessRuleId(id: string): boolean;
|
package/dist/rules/mcp/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { scanTextForInjection } from "../injection.js";
|
|
2
2
|
import { looksLikeInitGate } from "../../clients/rawprobe.js";
|
|
3
3
|
import { formatTokens, truncate } from "../../util/misc.js";
|
|
4
|
-
|
|
4
|
+
import { formatWindowShare } from "../../weigh/window.js";
|
|
5
|
+
const SPEC = "2026-07-28";
|
|
5
6
|
function isOutcome(x) {
|
|
6
7
|
return !!x && typeof x === "object" && "ok" in x;
|
|
7
8
|
}
|
|
@@ -25,7 +26,7 @@ const REPO_MATCH_RULES = [
|
|
|
25
26
|
category: "sampling",
|
|
26
27
|
severity: "warn",
|
|
27
28
|
title: "uses deprecated Sampling",
|
|
28
|
-
message: (c) => `source references sampling/createMessage (${matchStr(c)}); Sampling is deprecated in ${
|
|
29
|
+
message: (c) => `source references sampling/createMessage (${matchStr(c)}); Sampling is deprecated in ${SPEC} (SEP-2577)`,
|
|
29
30
|
fixHint: "integrate directly with an LLM provider API instead of MCP Sampling",
|
|
30
31
|
},
|
|
31
32
|
{
|
|
@@ -33,7 +34,7 @@ const REPO_MATCH_RULES = [
|
|
|
33
34
|
category: "roots",
|
|
34
35
|
severity: "warn",
|
|
35
36
|
title: "uses deprecated Roots",
|
|
36
|
-
message: (c) => `source references roots/list (${matchStr(c)}); Roots is deprecated in ${
|
|
37
|
+
message: (c) => `source references roots/list (${matchStr(c)}); Roots is deprecated in ${SPEC} (SEP-2577)`,
|
|
37
38
|
fixHint: "pass directories or files via tool parameters, resource URIs, or server configuration",
|
|
38
39
|
},
|
|
39
40
|
{
|
|
@@ -41,7 +42,7 @@ const REPO_MATCH_RULES = [
|
|
|
41
42
|
category: "sse-resume",
|
|
42
43
|
severity: "info",
|
|
43
44
|
title: "relies on removed SSE resumability",
|
|
44
|
-
message: (c) => `source references Last-Event-ID/resumability (${matchStr(c)}); stream resumability is removed in ${
|
|
45
|
+
message: (c) => `source references Last-Event-ID/resumability (${matchStr(c)}); stream resumability is removed in ${SPEC}`,
|
|
45
46
|
fixHint: "persist cross-call state behind server-minted handles passed as tool arguments",
|
|
46
47
|
},
|
|
47
48
|
{
|
|
@@ -49,7 +50,7 @@ const REPO_MATCH_RULES = [
|
|
|
49
50
|
category: "elicitation",
|
|
50
51
|
severity: "warn",
|
|
51
52
|
title: "uses legacy elicitation",
|
|
52
|
-
message: (c) => `source references elicitation/create (${matchStr(c)}); replaced in ${
|
|
53
|
+
message: (c) => `source references elicitation/create (${matchStr(c)}); replaced in ${SPEC} by MRTR results (resultType "input_required" with inputRequests)`,
|
|
53
54
|
fixHint: "return input_required results and correlate retries via requestState (SEP-2322)",
|
|
54
55
|
},
|
|
55
56
|
{
|
|
@@ -57,21 +58,21 @@ const REPO_MATCH_RULES = [
|
|
|
57
58
|
category: "session-state",
|
|
58
59
|
severity: "info",
|
|
59
60
|
title: "possible in-process session state",
|
|
60
|
-
message: (c) => `source shows session-state patterns (${matchStr(c)}); ${
|
|
61
|
+
message: (c) => `source shows session-state patterns (${matchStr(c)}); ${SPEC} statelessness expects server-minted handles passed via tool arguments`,
|
|
61
62
|
},
|
|
62
63
|
{
|
|
63
64
|
id: "E114",
|
|
64
65
|
category: "ping",
|
|
65
66
|
severity: "info",
|
|
66
67
|
title: "uses removed ping",
|
|
67
|
-
message: (c) => `source references the ping utility (${matchStr(c)}); ping is removed in ${
|
|
68
|
+
message: (c) => `source references the ping utility (${matchStr(c)}); ping is removed in ${SPEC}`,
|
|
68
69
|
},
|
|
69
70
|
{
|
|
70
71
|
id: "E115",
|
|
71
72
|
category: "subscribe",
|
|
72
73
|
severity: "info",
|
|
73
74
|
title: "uses replaced resource subscriptions",
|
|
74
|
-
message: (c) => `source references resources/subscribe (${matchStr(c)}); replaced by subscriptions/listen in ${
|
|
75
|
+
message: (c) => `source references resources/subscribe (${matchStr(c)}); replaced by subscriptions/listen in ${SPEC}`,
|
|
75
76
|
},
|
|
76
77
|
];
|
|
77
78
|
const repoMatchRules = REPO_MATCH_RULES.map((spec) => ({
|
|
@@ -105,10 +106,14 @@ const e101 = {
|
|
|
105
106
|
ruleId: "E101",
|
|
106
107
|
severity: "warn",
|
|
107
108
|
title: "legacy SDK generation",
|
|
108
|
-
message: `depends on ${s.package}${s.range ? `@${s.range}` : ""} (pre-${
|
|
109
|
+
message: `depends on ${s.package}${s.range ? `@${s.range}` : ""} (pre-${SPEC} line)`,
|
|
109
110
|
detail: s.language === "ts"
|
|
110
|
-
?
|
|
111
|
-
|
|
111
|
+
? // No maturity label here on purpose. This said "2.x beta since
|
|
112
|
+
// 2026-07" and stayed saying it after both packages cut 2.0.0 on
|
|
113
|
+
// 2026-07-27, which told people to migrate onto a beta that was
|
|
114
|
+
// not one. The package name and major line are the durable facts.
|
|
115
|
+
`the ${SPEC} revision ships as the new @modelcontextprotocol/server package (2.x)`
|
|
116
|
+
: `the ${SPEC} revision ships as mcp 2.x on PyPI`,
|
|
112
117
|
fixHint: "upgrade to the 2.x SDK line to get stateless transport and MRTR support",
|
|
113
118
|
});
|
|
114
119
|
}
|
|
@@ -126,7 +131,7 @@ const e104 = {
|
|
|
126
131
|
ruleId: "E104",
|
|
127
132
|
severity: "warn",
|
|
128
133
|
title: "uses deprecated MCP Logging",
|
|
129
|
-
message: `server declares the logging capability; MCP Logging is deprecated in ${
|
|
134
|
+
message: `server declares the logging capability; MCP Logging is deprecated in ${SPEC} (SEP-2577) and logging/setLevel is removed`,
|
|
130
135
|
fixHint: "log to stderr (stdio) or use OpenTelemetry; per-request level arrives via _meta io.modelcontextprotocol/logLevel",
|
|
131
136
|
},
|
|
132
137
|
];
|
|
@@ -135,7 +140,7 @@ const e104 = {
|
|
|
135
140
|
ruleId: "E104",
|
|
136
141
|
severity: "warn",
|
|
137
142
|
title: "uses deprecated MCP Logging",
|
|
138
|
-
message: `source references MCP logging APIs (${matchStr(count)}); deprecated in ${
|
|
143
|
+
message: `source references MCP logging APIs (${matchStr(count)}); deprecated in ${SPEC} (SEP-2577)`,
|
|
139
144
|
detail,
|
|
140
145
|
fixHint: "log to stderr (stdio) or use OpenTelemetry",
|
|
141
146
|
}));
|
|
@@ -156,7 +161,7 @@ const e105 = {
|
|
|
156
161
|
severity: "warn",
|
|
157
162
|
title: "requires the removed initialize handshake",
|
|
158
163
|
message: `the server rejected a bare stateless tools/list as not initialized (${bare.errorMessage ?? `code ${bare.errorCode}`})`,
|
|
159
|
-
detail: `${
|
|
164
|
+
detail: `${SPEC} removes initialize and sessions; stateless servers answer bare requests carrying version info in _meta. Whether an answering server is fully ${SPEC}-conformant is judged separately (E107 resultType, E118 cache fields, E106 server/discover).`,
|
|
160
165
|
fixHint: "upgrade to a 2.x SDK, or accept requests without a prior initialize",
|
|
161
166
|
},
|
|
162
167
|
];
|
|
@@ -189,7 +194,7 @@ const e106 = {
|
|
|
189
194
|
severity: "warn",
|
|
190
195
|
title: "server/discover not implemented",
|
|
191
196
|
message: `server/discover is not implemented (${d.errorMessage ?? "method not found"})`,
|
|
192
|
-
detail: `${
|
|
197
|
+
detail: `${SPEC} servers MUST implement server/discover (SEP-2575) to advertise versions, capabilities, and identity; clients also use it as the back-compat probe.`,
|
|
193
198
|
fixHint: "the 2.x SDKs implement server/discover for you",
|
|
194
199
|
},
|
|
195
200
|
];
|
|
@@ -207,8 +212,8 @@ const e107 = {
|
|
|
207
212
|
ruleId: "E107",
|
|
208
213
|
severity: "info",
|
|
209
214
|
title: "results missing resultType",
|
|
210
|
-
message: `results do not carry the resultType field required in ${
|
|
211
|
-
detail:
|
|
215
|
+
message: `results do not carry the resultType field required in ${SPEC} ("complete" | "input_required")`,
|
|
216
|
+
detail: `${SPEC} requires resultType on every result (SEP-2322); on list results the value must be "complete". ${SPEC} clients treat missing resultType from earlier-protocol servers as "complete", so this is informational until you upgrade.`,
|
|
212
217
|
},
|
|
213
218
|
];
|
|
214
219
|
},
|
|
@@ -256,7 +261,7 @@ const e112 = {
|
|
|
256
261
|
ruleId: "E112",
|
|
257
262
|
severity: "warn",
|
|
258
263
|
title: "nondeterministic tools/list order",
|
|
259
|
-
message: "tool order differed across two fresh connections; deterministic ordering enables prompt-cache hits in
|
|
264
|
+
message: "tool order differed across two fresh connections; deterministic ordering enables prompt-cache hits in stateless hosts",
|
|
260
265
|
fixHint: "sort tools stably before returning them",
|
|
261
266
|
},
|
|
262
267
|
];
|
|
@@ -322,14 +327,26 @@ const e118 = {
|
|
|
322
327
|
title: "missing cache fields (ttlMs, cacheScope)",
|
|
323
328
|
surface: "mcp",
|
|
324
329
|
check(ctx) {
|
|
325
|
-
|
|
330
|
+
// Two measured surfaces, not one. server/discover only became cacheable in
|
|
331
|
+
// the spec that published on 2026-07-28 (DiscoverResult extends
|
|
332
|
+
// CacheableResult); under the locked RC it extended plain Result, so a
|
|
333
|
+
// server that migrated against the RC can pass tools/list and still miss
|
|
334
|
+
// this. Undefined means the surface was never measured, which is not a
|
|
335
|
+
// finding: E106 already owns "server/discover did not answer".
|
|
336
|
+
const surfaces = [];
|
|
337
|
+
if (ctx.probes?.cacheFieldsPresent === false)
|
|
338
|
+
surfaces.push("tools/list");
|
|
339
|
+
if (ctx.probes?.discoverCacheFieldsPresent === false)
|
|
340
|
+
surfaces.push("server/discover");
|
|
341
|
+
if (!surfaces.length)
|
|
326
342
|
return [];
|
|
343
|
+
const subject = surfaces.length > 1 ? `${surfaces.join(" and ")} results omit` : `${surfaces[0]} result omits`;
|
|
327
344
|
return [
|
|
328
345
|
{
|
|
329
346
|
ruleId: "E118",
|
|
330
347
|
severity: "warn",
|
|
331
348
|
title: "missing cache fields (ttlMs, cacheScope)",
|
|
332
|
-
message:
|
|
349
|
+
message: `${subject} ttlMs and/or cacheScope, which ${SPEC} requires on list and resource-read results (SEP-2549, CacheableResult)`,
|
|
333
350
|
detail: "required on tools/list, prompts/list, resources/list, resources/read, resources/templates/list, and server/discover; cacheScope is \"public\" or \"private\"",
|
|
334
351
|
fixHint: "return ttlMs and cacheScope on these results so clients can cache and stop polling; the 2.x SDKs add them for you",
|
|
335
352
|
},
|
|
@@ -421,7 +438,34 @@ const e122 = {
|
|
|
421
438
|
return capList(findings, 5).shown;
|
|
422
439
|
},
|
|
423
440
|
};
|
|
424
|
-
|
|
441
|
+
// "format" was in this list and is dropped. It only ever meant "format a
|
|
442
|
+
// disk" in the author's head, but `format_date`, `format_output` and
|
|
443
|
+
// `format_json` are among the most common tool names there are, and once the
|
|
444
|
+
// matcher below started working the word turned into pure noise. Nobody could
|
|
445
|
+
// have found that while the rule was firing on nothing.
|
|
446
|
+
const DESTRUCTIVE_WORDS = new Set([
|
|
447
|
+
"delete", "remove", "drop", "purge", "destroy", "overwrite",
|
|
448
|
+
"truncate", "erase", "wipe", "revoke", "reset", "deploy", "kill",
|
|
449
|
+
]);
|
|
450
|
+
/**
|
|
451
|
+
* Does this tool name read as destructive?
|
|
452
|
+
*
|
|
453
|
+
* This was a single `\b(delete|remove|...)\b` regex, and `_` is a word
|
|
454
|
+
* character, so `\bdelete\b` has no boundary inside `delete_file` - nor inside
|
|
455
|
+
* `deleteFile`. Every conventional MCP tool name escaped it: delete_file,
|
|
456
|
+
* deleteFile, drop_table, removeUser, kill_process, wipe_db all returned
|
|
457
|
+
* false, while only a bare `delete` or `delete-file` matched. E123 is
|
|
458
|
+
* documented in docs/RULES.md as catching "a destructive-looking tool lacking
|
|
459
|
+
* destructiveHint" and it could not fire on essentially any real server.
|
|
460
|
+
*
|
|
461
|
+
* Split on separators AND on camelCase boundaries, then test each segment.
|
|
462
|
+
*/
|
|
463
|
+
export function readsDestructive(name) {
|
|
464
|
+
return name
|
|
465
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
466
|
+
.split(/[^A-Za-z0-9]+/)
|
|
467
|
+
.some((seg) => DESTRUCTIVE_WORDS.has(seg.toLowerCase()));
|
|
468
|
+
}
|
|
425
469
|
const e123 = {
|
|
426
470
|
id: "E123",
|
|
427
471
|
title: "missing tool annotations",
|
|
@@ -441,7 +485,7 @@ const e123 = {
|
|
|
441
485
|
fixHint: "annotations let hosts gate confirmations and parallelize safely",
|
|
442
486
|
});
|
|
443
487
|
}
|
|
444
|
-
const risky = tools.filter((t) =>
|
|
488
|
+
const risky = tools.filter((t) => readsDestructive(t.name) && !(t.annotations && "destructiveHint" in t.annotations));
|
|
445
489
|
for (const t of capList(risky, 5).shown) {
|
|
446
490
|
findings.push({
|
|
447
491
|
ruleId: "E123",
|
|
@@ -586,7 +630,7 @@ const e128 = {
|
|
|
586
630
|
ruleId: "E128",
|
|
587
631
|
severity: "warn",
|
|
588
632
|
title: "total context tax",
|
|
589
|
-
message: `tool definitions alone are ~${formatTokens(total)} tokens (
|
|
633
|
+
message: `tool definitions alone are ~${formatTokens(total)} tokens (${formatWindowShare(total)}, estimated) before any work happens`,
|
|
590
634
|
fixHint: "trim descriptions/schemas, split the server, or rely on deferred tool loading",
|
|
591
635
|
},
|
|
592
636
|
];
|
|
@@ -597,7 +641,7 @@ const e128 = {
|
|
|
597
641
|
ruleId: "E128",
|
|
598
642
|
severity: "warn",
|
|
599
643
|
title: "total context tax",
|
|
600
|
-
message: `tool definitions are ~${formatTokens(total)} tokens (
|
|
644
|
+
message: `tool definitions are ~${formatTokens(total)} tokens (${formatWindowShare(total)})`,
|
|
601
645
|
fixHint: "run \`efaimo weigh\` for the per-tool breakdown",
|
|
602
646
|
},
|
|
603
647
|
];
|
package/dist/skills/parse.js
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import YAML from "yaml";
|
|
4
|
-
import { walkFiles } from "../util/misc.js";
|
|
4
|
+
import { walkFiles, readTextSafe } from "../util/misc.js";
|
|
5
|
+
/**
|
|
6
|
+
* Cap on a single SKILL.md. Referenced files were already capped at 512KB by
|
|
7
|
+
* readTextSafe while the body itself went through an uncapped readFileSync:
|
|
8
|
+
* the safer path had the limit and the main one did not. A 35MB SKILL.md
|
|
9
|
+
* tokenized to 7.2 million tokens at 367MB resident, and auditing skills you
|
|
10
|
+
* have NOT installed yet is the stated use case, so this is untrusted input
|
|
11
|
+
* by construction. 2MB is far above any real skill (the largest in the graded
|
|
12
|
+
* corpus is a few tens of KB) and far below a memory problem.
|
|
13
|
+
*/
|
|
14
|
+
const MAX_SKILL_BYTES = 2 * 1024 * 1024;
|
|
5
15
|
const MD_LINK_RE = /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
|
6
16
|
// Only backticked strings that look like a real intra-skill path: a directory
|
|
7
17
|
// separator, no glob star. A bare filename in prose (`package.json`, `*.py`) is
|
|
@@ -21,7 +31,15 @@ export function parseSkillFile(file) {
|
|
|
21
31
|
};
|
|
22
32
|
let raw;
|
|
23
33
|
try {
|
|
24
|
-
|
|
34
|
+
// Capped, like the referenced files this same module already caps at 512KB
|
|
35
|
+
// via readTextSafe. The body went through plain readFileSync with no limit
|
|
36
|
+
// while the SAFER path had a cap, and the stated use case is auditing
|
|
37
|
+
// skills BEFORE you install them, which is untrusted input by definition.
|
|
38
|
+
// A 35MB SKILL.md tokenized to 7.2M tokens and 367MB resident.
|
|
39
|
+
const capped = readTextSafe(file, MAX_SKILL_BYTES);
|
|
40
|
+
if (capped === undefined)
|
|
41
|
+
throw new Error("unreadable");
|
|
42
|
+
raw = capped;
|
|
25
43
|
}
|
|
26
44
|
catch (e) {
|
|
27
45
|
return { ...base, parseError: `cannot read file: ${e.message}` };
|
|
@@ -79,7 +97,25 @@ export function parseSkillFile(file) {
|
|
|
79
97
|
continue;
|
|
80
98
|
seen.add(cleaned);
|
|
81
99
|
const resolved = path.resolve(dir, cleaned);
|
|
82
|
-
|
|
100
|
+
// Does this reference stay inside the skill? Absolute paths, URLs and
|
|
101
|
+
// globs are already rejected above; `../` was not, and weigh.ts then
|
|
102
|
+
// READ every referenced file to count its tokens. A skill fetched from
|
|
103
|
+
// anywhere could point at ../../../.ssh/id_rsa and get back an
|
|
104
|
+
// existence-and-size oracle, published if --md goes to a CI job summary.
|
|
105
|
+
// S106 already knew this was wrong and warned about it - but only after
|
|
106
|
+
// the read had happened, and `weigh` does not run S106 at all.
|
|
107
|
+
//
|
|
108
|
+
// Recorded here rather than filtered, so S106 can still report it and
|
|
109
|
+
// the reader still learns the reference exists. weigh skips the read.
|
|
110
|
+
const rel = path.relative(dir, resolved);
|
|
111
|
+
const escapes = rel.startsWith("..") || path.isAbsolute(rel);
|
|
112
|
+
base.referencedPaths.push({
|
|
113
|
+
raw: cleaned,
|
|
114
|
+
resolved,
|
|
115
|
+
exists: fs.existsSync(resolved),
|
|
116
|
+
escapes,
|
|
117
|
+
source,
|
|
118
|
+
});
|
|
83
119
|
}
|
|
84
120
|
}
|
|
85
121
|
for (const f of walkFiles(dir, { maxDepth: 4 })) {
|
|
@@ -7,6 +7,13 @@ export interface ClientServerEntry {
|
|
|
7
7
|
}
|
|
8
8
|
export interface ClientConfigResult {
|
|
9
9
|
entries: ClientServerEntry[];
|
|
10
|
+
/**
|
|
11
|
+
* Config files that came from the CURRENT DIRECTORY rather than the user
|
|
12
|
+
* config. Every command in these was written by whoever wrote the
|
|
13
|
+
* repository you are standing in, and --client spawns them. The CLI warns
|
|
14
|
+
* about these by name before it executes anything.
|
|
15
|
+
*/
|
|
16
|
+
fromCwd: string[];
|
|
10
17
|
/** Config files that were found and parsed. */
|
|
11
18
|
sources: string[];
|
|
12
19
|
/** Paths that were checked but absent. */
|
|
@@ -27,7 +27,7 @@ export function loadClientServers(client, cwd = process.cwd()) {
|
|
|
27
27
|
default:
|
|
28
28
|
throw new Error(`unknown client "${client}" (supported: ${SUPPORTED_CLIENTS.join(", ")})`);
|
|
29
29
|
}
|
|
30
|
-
const result = { entries: [], sources: [], missing: [] };
|
|
30
|
+
const result = { entries: [], sources: [], missing: [], fromCwd: [] };
|
|
31
31
|
const seen = new Set();
|
|
32
32
|
for (const { file, pick } of candidates) {
|
|
33
33
|
if (!fs.existsSync(file)) {
|
|
@@ -48,6 +48,9 @@ export function loadClientServers(client, cwd = process.cwd()) {
|
|
|
48
48
|
continue;
|
|
49
49
|
}
|
|
50
50
|
result.sources.push(file);
|
|
51
|
+
// Project-scoped config is the untrusted kind: it ships with a repo.
|
|
52
|
+
if (file.startsWith(cwd))
|
|
53
|
+
result.fromCwd.push(file);
|
|
51
54
|
for (const [name, valueRaw] of Object.entries(servers)) {
|
|
52
55
|
if (seen.has(name))
|
|
53
56
|
continue;
|
|
@@ -13,6 +13,8 @@ export function anthropicRunner(apiKey) {
|
|
|
13
13
|
},
|
|
14
14
|
body: JSON.stringify({
|
|
15
15
|
model: req.model,
|
|
16
|
+
// 0 for the judge, 1 for the subject. See RunnerRequest.deterministic.
|
|
17
|
+
temperature: req.deterministic ? 0 : 1,
|
|
16
18
|
max_tokens: 1024,
|
|
17
19
|
...(req.system ? { system: req.system } : {}),
|
|
18
20
|
messages: [{ role: "user", content: req.user }],
|
|
@@ -18,10 +18,24 @@ export interface RunnerRequest {
|
|
|
18
18
|
system?: string;
|
|
19
19
|
user: string;
|
|
20
20
|
model: string;
|
|
21
|
+
/**
|
|
22
|
+
* Sample deterministically. Set for the JUDGE, never for the subject.
|
|
23
|
+
* Neither runner set a temperature, so both defaulted to 1.0 and a "strict
|
|
24
|
+
* grader" asked for one word was being sampled: the same answer could be
|
|
25
|
+
* graded PASS on one trial and FAIL on the next, and that variance lands in
|
|
26
|
+
* the measurement as if it came from the skill. The subject arm keeps
|
|
27
|
+
* temperature 1 on purpose, because the thing being measured is how the
|
|
28
|
+
* model behaves normally.
|
|
29
|
+
*/
|
|
30
|
+
deterministic?: boolean;
|
|
21
31
|
}
|
|
22
32
|
export type Runner = (req: RunnerRequest) => Promise<string>;
|
|
23
33
|
export interface ArmResult {
|
|
24
34
|
trials: number;
|
|
35
|
+
/** Trials the judge actually answered PASS or FAIL on. */
|
|
36
|
+
scored: number;
|
|
37
|
+
/** Trials whose judge verdict was neither; excluded from passRate. */
|
|
38
|
+
unparseable: number;
|
|
25
39
|
passes: number;
|
|
26
40
|
passRate: number;
|
|
27
41
|
}
|
|
@@ -33,6 +47,13 @@ export interface TestReport {
|
|
|
33
47
|
withoutSkill: ArmResult;
|
|
34
48
|
/** withSkill.passRate - withoutSkill.passRate, in percentage points. */
|
|
35
49
|
deltaPoints: number;
|
|
50
|
+
/** Two-sided Fisher exact p on the 2x2 of scored trials. */
|
|
51
|
+
p: number;
|
|
52
|
+
/** 95% interval on the delta, in percentage points (Newcombe). */
|
|
53
|
+
ci: {
|
|
54
|
+
lo: number;
|
|
55
|
+
hi: number;
|
|
56
|
+
};
|
|
36
57
|
verdict: "helps" | "hurts" | "no measurable effect" | "inconclusive";
|
|
37
58
|
notes: string[];
|
|
38
59
|
}
|
package/dist/testing/harness.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fisherExactTwoSided, deltaInterval } from "./stats.js";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import YAML from "yaml";
|
|
@@ -24,7 +25,10 @@ export function parseScenario(file) {
|
|
|
24
25
|
const skill = set.skills[0];
|
|
25
26
|
if (!skill)
|
|
26
27
|
throw new Error(`${file}: no SKILL.md found at '${skillRel}'`);
|
|
27
|
-
|
|
28
|
+
// Default 20, not 5. At 5 per arm a total separation (5/5 vs 0/5) is the ONLY
|
|
29
|
+
// outcome that reaches p < 0.05, so every partial result is uninterpretable
|
|
30
|
+
// and the old default guaranteed a verdict nobody could stand behind.
|
|
31
|
+
const trials = Math.min(50, Math.max(1, Math.round(num(doc.trials) ?? 20)));
|
|
28
32
|
return {
|
|
29
33
|
name,
|
|
30
34
|
skillPath,
|
|
@@ -47,42 +51,87 @@ export function armSystems(scenario) {
|
|
|
47
51
|
const withSkill = `You have access to the following skill. Use it if relevant.\n\n<skill name="${scenario.skillName}">\n${scenario.skillBody}\n</skill>`;
|
|
48
52
|
return { withoutSkill: undefined, withSkill };
|
|
49
53
|
}
|
|
54
|
+
// PASS, FAIL, or neither. "Neither" used to be counted as FAIL: the old test
|
|
55
|
+
// was `/pass/i.test(out) && !/fail/i.test(out)`, so a refusal, an API
|
|
56
|
+
// error string, or a hedge like "passes on X but fails on Y" all became a
|
|
57
|
+
// failed trial. Those are not evidence about the skill, and quietly scoring
|
|
58
|
+
// them against whichever arm produced them biases the result.
|
|
50
59
|
async function judgeOne(runner, scenario, answer) {
|
|
51
60
|
const out = await runner({
|
|
52
61
|
system: JUDGE_SYSTEM,
|
|
53
62
|
user: `TASK:\n${scenario.task}\n\nRUBRIC:\n${scenario.judge}\n\nASSISTANT ANSWER:\n${answer}\n\nVerdict (PASS or FAIL):`,
|
|
54
63
|
model: scenario.model,
|
|
64
|
+
deterministic: true,
|
|
55
65
|
});
|
|
56
|
-
|
|
66
|
+
const saysPass = /\bpass(ed|es)?\b/i.test(out);
|
|
67
|
+
const saysFail = /\bfail(ed|s|ure)?\b/i.test(out);
|
|
68
|
+
if (saysPass && !saysFail)
|
|
69
|
+
return "pass";
|
|
70
|
+
if (saysFail && !saysPass)
|
|
71
|
+
return "fail";
|
|
72
|
+
return "unparseable";
|
|
57
73
|
}
|
|
58
74
|
async function runArm(runner, scenario, system) {
|
|
59
75
|
let passes = 0;
|
|
76
|
+
let unparseable = 0;
|
|
60
77
|
for (let i = 0; i < scenario.trials; i++) {
|
|
61
78
|
const answer = await runner({ system, user: scenario.task, model: scenario.model });
|
|
62
|
-
|
|
79
|
+
const v = await judgeOne(runner, scenario, answer);
|
|
80
|
+
if (v === "pass")
|
|
63
81
|
passes++;
|
|
82
|
+
else if (v === "unparseable")
|
|
83
|
+
unparseable++;
|
|
64
84
|
}
|
|
65
|
-
|
|
85
|
+
// Scored trials exclude the ones the judge did not answer, so the rate is a
|
|
86
|
+
// rate over evidence rather than over attempts.
|
|
87
|
+
const scored = scenario.trials - unparseable;
|
|
88
|
+
return {
|
|
89
|
+
trials: scenario.trials,
|
|
90
|
+
scored,
|
|
91
|
+
unparseable,
|
|
92
|
+
passes,
|
|
93
|
+
passRate: scored > 0 ? (passes / scored) * 100 : 0,
|
|
94
|
+
};
|
|
66
95
|
}
|
|
67
96
|
export async function runScenario(scenario, runner) {
|
|
68
97
|
const systems = armSystems(scenario);
|
|
69
98
|
const withoutSkill = await runArm(runner, scenario, systems.withoutSkill);
|
|
70
99
|
const withSkill = await runArm(runner, scenario, systems.withSkill);
|
|
71
100
|
const deltaPoints = Math.round((withSkill.passRate - withoutSkill.passRate) * 10) / 10;
|
|
101
|
+
// The verdict is a significance test, not a threshold on the gap.
|
|
102
|
+
//
|
|
103
|
+
// It used to be `>= +15 points helps, <= -15 hurts`, with 5 trials per arm
|
|
104
|
+
// as the default. At that size 5/5 against 4/5 is +20 points and a two-sided
|
|
105
|
+
// Fisher p of 1.0000: the least significant result obtainable, reported as a
|
|
106
|
+
// finding, and `hurts` set exit code 1. The +-15 band sits inside the noise
|
|
107
|
+
// floor at every trial count below roughly 25 per arm, so the threshold was
|
|
108
|
+
// measuring sample size more than it was measuring the skill.
|
|
109
|
+
const p = fisherExactTwoSided(withSkill.passes, withSkill.scored, withoutSkill.passes, withoutSkill.scored);
|
|
110
|
+
const ci = deltaInterval(withSkill.passes, withSkill.scored, withoutSkill.passes, withoutSkill.scored);
|
|
72
111
|
const notes = [
|
|
73
|
-
`${scenario.trials} trials per arm
|
|
112
|
+
`${scenario.trials} trials per arm. Two-sided Fisher exact p = ${p < 0.0001 ? "<0.0001" : p.toFixed(4)}; ` +
|
|
113
|
+
`95% interval on the delta ${ci.lo >= 0 ? "+" : ""}${ci.lo} to ${ci.hi >= 0 ? "+" : ""}${ci.hi} points.`,
|
|
74
114
|
];
|
|
115
|
+
const unscored = withSkill.unparseable + withoutSkill.unparseable;
|
|
116
|
+
if (unscored) {
|
|
117
|
+
notes.push(`${unscored} trial(s) produced a judge verdict that was neither PASS nor FAIL and are excluded from the rates, not counted as failures.`);
|
|
118
|
+
}
|
|
75
119
|
let verdict;
|
|
76
|
-
if (
|
|
120
|
+
if (withSkill.scored === 0 || withoutSkill.scored === 0) {
|
|
77
121
|
verdict = "inconclusive";
|
|
78
|
-
notes.push("
|
|
122
|
+
notes.push("an arm produced no scoreable trial; there is nothing to compare.");
|
|
123
|
+
}
|
|
124
|
+
else if (p >= 0.05) {
|
|
125
|
+
verdict = "no measurable effect";
|
|
126
|
+
if (Math.abs(deltaPoints) >= 15) {
|
|
127
|
+
notes.push(`the ${deltaPoints >= 0 ? "+" : ""}${deltaPoints} point gap is not significant at this sample size (p = ${p.toFixed(4)}). ` +
|
|
128
|
+
`Raise trials rather than reading the gap.`);
|
|
129
|
+
}
|
|
79
130
|
}
|
|
80
|
-
else if (deltaPoints
|
|
131
|
+
else if (deltaPoints > 0)
|
|
81
132
|
verdict = "helps";
|
|
82
|
-
else if (deltaPoints <= -15)
|
|
83
|
-
verdict = "hurts";
|
|
84
133
|
else
|
|
85
|
-
verdict = "
|
|
134
|
+
verdict = "hurts";
|
|
86
135
|
return {
|
|
87
136
|
scenario: scenario.name,
|
|
88
137
|
skill: scenario.skillName,
|
|
@@ -90,6 +139,8 @@ export async function runScenario(scenario, runner) {
|
|
|
90
139
|
withSkill,
|
|
91
140
|
withoutSkill,
|
|
92
141
|
deltaPoints,
|
|
142
|
+
p,
|
|
143
|
+
ci,
|
|
93
144
|
verdict,
|
|
94
145
|
notes,
|
|
95
146
|
};
|
|
@@ -14,7 +14,14 @@ export function openaiRunner(apiKey) {
|
|
|
14
14
|
"content-type": "application/json",
|
|
15
15
|
authorization: `Bearer ${apiKey}`,
|
|
16
16
|
},
|
|
17
|
-
|
|
17
|
+
// temperature: 0 for the judge, 1 for the subject. See
|
|
18
|
+
// RunnerRequest.deterministic in harness.ts.
|
|
19
|
+
body: JSON.stringify({
|
|
20
|
+
model: req.model,
|
|
21
|
+
temperature: req.deterministic ? 0 : 1,
|
|
22
|
+
messages,
|
|
23
|
+
max_completion_tokens: 1024,
|
|
24
|
+
}),
|
|
18
25
|
redirect: "error",
|
|
19
26
|
signal: AbortSignal.timeout(60000),
|
|
20
27
|
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-sided Fisher exact test on
|
|
3
|
+
* [withPass, withFail]
|
|
4
|
+
* [withoutPass, withoutFail]
|
|
5
|
+
*
|
|
6
|
+
* Sums the probability of every table at least as extreme as the observed one,
|
|
7
|
+
* which is the standard two-sided construction and needs no normal
|
|
8
|
+
* approximation.
|
|
9
|
+
*/
|
|
10
|
+
export declare function fisherExactTwoSided(withPass: number, withTrials: number, withoutPass: number, withoutTrials: number): number;
|
|
11
|
+
/**
|
|
12
|
+
* Wilson score interval for one proportion. Chosen over the normal-approximation
|
|
13
|
+
* interval because that one produces a zero-width interval at 0/n and n/n,
|
|
14
|
+
* which is exactly where these runs land (8/8 and 0/8 both occur in the two
|
|
15
|
+
* committed example runs).
|
|
16
|
+
*/
|
|
17
|
+
export declare function wilson(passes: number, trials: number, z?: number): {
|
|
18
|
+
lo: number;
|
|
19
|
+
hi: number;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Interval on the DIFFERENCE of two proportions, in percentage points, by the
|
|
23
|
+
* Newcombe method: combine each arm's Wilson interval. Reported so a reader can
|
|
24
|
+
* see how wide the uncertainty actually is instead of inferring it from a
|
|
25
|
+
* point estimate.
|
|
26
|
+
*/
|
|
27
|
+
export declare function deltaInterval(withPass: number, withTrials: number, withoutPass: number, withoutTrials: number): {
|
|
28
|
+
lo: number;
|
|
29
|
+
hi: number;
|
|
30
|
+
};
|
|
31
|
+
/** The smallest per-arm trial count at which a total separation (n/n vs 0/n) reaches p < 0.05. */
|
|
32
|
+
export declare const MIN_TRIALS_FOR_SIGNIFICANCE = 4;
|