efaimo 0.1.0 → 0.1.2

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +160 -4
  2. package/README.md +89 -31
  3. package/SKILL.md +13 -1
  4. package/dist/check/check.js +14 -2
  5. package/dist/cli.js +95 -9
  6. package/dist/clients/introspect.js +10 -2
  7. package/dist/clients/rawprobe.d.ts +21 -5
  8. package/dist/clients/rawprobe.js +101 -43
  9. package/dist/clients/stateless.js +17 -14
  10. package/dist/core/engine.js +20 -2
  11. package/dist/core/grade.js +7 -1
  12. package/dist/core/types.d.ts +22 -3
  13. package/dist/reporters/markdown.js +16 -4
  14. package/dist/reporters/pretty.js +38 -25
  15. package/dist/rules/injection.d.ts +1 -2
  16. package/dist/rules/injection.js +7 -11
  17. package/dist/rules/mcp/index.d.ts +14 -0
  18. package/dist/rules/mcp/index.js +89 -25
  19. package/dist/rules/skill/index.js +5 -3
  20. package/dist/skills/parse.js +39 -3
  21. package/dist/targets/clientConfigs.d.ts +7 -0
  22. package/dist/targets/clientConfigs.js +4 -1
  23. package/dist/targets/repoScan.js +0 -0
  24. package/dist/testing/anthropicRunner.js +2 -0
  25. package/dist/testing/harness.d.ts +22 -0
  26. package/dist/testing/harness.js +98 -13
  27. package/dist/testing/openaiRunner.js +15 -1
  28. package/dist/testing/stats.d.ts +30 -0
  29. package/dist/testing/stats.js +89 -0
  30. package/dist/util/dotenv.d.ts +9 -1
  31. package/dist/util/dotenv.js +11 -1
  32. package/dist/util/safeText.d.ts +8 -0
  33. package/dist/util/safeText.js +48 -0
  34. package/dist/version.d.ts +1 -1
  35. package/dist/version.js +1 -1
  36. package/dist/weigh/weigh.js +3 -1
  37. package/dist/weigh/window.d.ts +26 -0
  38. package/dist/weigh/window.js +44 -0
  39. package/docs/METHODOLOGY.md +70 -0
  40. package/docs/RULES.md +31 -25
  41. package/examples/contoso-crm-import/SKILL.md +21 -0
  42. package/examples/csv-cleanup/SKILL.md +17 -0
  43. package/examples/scenario.crm.yaml +22 -0
  44. package/examples/scenario.example.yaml +17 -0
  45. package/package.json +4 -2
@@ -23,14 +23,22 @@ export async function introspectServer(target, opts = {}) {
23
23
  stderr: "pipe",
24
24
  });
25
25
  transport = stdio;
26
+ // Split the budget across the two attempts so a dead server fails in about
27
+ // one timeout, not two: the legacy handshake got the whole timeout AND the
28
+ // stateless fallback then re-waited the whole timeout again, so `--timeout 8`
29
+ // took ~16s wall. The handshake takes the larger share; the fallback gets
30
+ // what is left, with a floor so a genuinely stateless server still has time
31
+ // to answer a bare request.
32
+ const startedAt = Date.now();
26
33
  try {
27
- await withTimeout(client.connect(stdio), timeoutMs, "connect (stdio)");
34
+ await withTimeout(client.connect(stdio), Math.max(1, Math.round(timeoutMs * 0.6)), "connect (stdio)");
28
35
  }
29
36
  catch (e) {
30
37
  await safeClose(client, stdio);
31
38
  // A 2026-07-28 stateless server has no initialize, so the SDK handshake
32
39
  // fails by design; try bare stateless requests before giving up.
33
- const rc = await introspectStateless(target, { timeoutMs });
40
+ const remaining = Math.max(timeoutMs - (Date.now() - startedAt), Math.round(timeoutMs * 0.25));
41
+ const rc = await introspectStateless(target, { timeoutMs: remaining });
34
42
  if (rc)
35
43
  return rc;
36
44
  throw connectError(e, target.label, stderrTail);
@@ -6,7 +6,14 @@ import type { ResolvedTarget } from "../targets/resolve.js";
6
6
  * stateless requests, server/discover) need raw transport access.
7
7
  * Every probe fails soft into a { skipped } marker.
8
8
  */
9
- export declare const RC_VERSION = "2026-07-28";
9
+ /**
10
+ * The MCP revision efaimo audits against. It was called RC_VERSION until
11
+ * 2026-08-01, by which point the revision had been published for four days and
12
+ * the name had been lying for all of them. scripts/spec-drift.mjs reads this
13
+ * declaration by name, so renaming it again means updating that regex, which
14
+ * fails loudly rather than silently watching nothing.
15
+ */
16
+ export declare const SPEC_VERSION = "2026-07-28";
10
17
  interface RpcReply {
11
18
  result?: Record<string, unknown>;
12
19
  error?: {
@@ -16,7 +23,7 @@ interface RpcReply {
16
23
  timeout?: boolean;
17
24
  procExit?: number | null;
18
25
  }
19
- export declare function rcMeta(): Record<string, unknown>;
26
+ export declare function specMeta(): Record<string, unknown>;
20
27
  /**
21
28
  * Only an explicit "not initialized" / "session required" error proves the
22
29
  * server requires the removed initialize handshake. A plain timeout is NOT proof
@@ -24,11 +31,20 @@ export declare function rcMeta(): Record<string, unknown>;
24
31
  * inconclusive; a crash is its own signal. This keeps E105 from false-flagging a
25
32
  * healthy but slow server, the worst verdict a readiness checker can emit.
26
33
  *
27
- * Detection is by message, not by error code: the codes SDKs use here are
34
+ * Detection is by message, not by error code, and the spec that published on
35
+ * 2026-07-28 is the argument for why. The codes SDKs use here are
28
36
  * implementation-defined (TS StreamableHTTP sends -32000 for both "Server not
29
37
  * initialized" and "Mcp-Session-Id header is required", -32001 for "Session not
30
- * found"), -32002 means Resource-not-found in the 2025 specs, and the RC
31
- * reserves -32022 for UnsupportedProtocolVersion, so no code is diagnostic.
38
+ * found"), and the MCP-defined ones MOVED between the locked RC and the
39
+ * published spec: MissingRequiredClientCapability went -32003 to -32021 and
40
+ * UnsupportedProtocolVersion went -32004 to -32022, and HeaderMismatch moved
41
+ * the same way, -32001 to -32020. This comment said HeaderMismatch was "added
42
+ * alongside them", which was wrong: the RC already defined it at -32001 in the
43
+ * transport prose, and what the published revision added was its entry in the
44
+ * schema. The distinction matters to anyone still returning -32001.
45
+ * -32002 (resource not found) is retired outright,
46
+ * replaced by -32602 and promised never to be reused. Matching on numbers
47
+ * would have rotted here on 2026-07-28; matching on messages did not.
32
48
  */
33
49
  export declare function looksLikeInitGate(o: ProbeOutcome): boolean;
34
50
  export declare function runProbes(target: Extract<ResolvedTarget, {
@@ -8,12 +8,19 @@ import { VERSION } from "../version.js";
8
8
  * stateless requests, server/discover) need raw transport access.
9
9
  * Every probe fails soft into a { skipped } marker.
10
10
  */
11
- export const RC_VERSION = "2026-07-28";
11
+ /**
12
+ * The MCP revision efaimo audits against. It was called RC_VERSION until
13
+ * 2026-08-01, by which point the revision had been published for four days and
14
+ * the name had been lying for all of them. scripts/spec-drift.mjs reads this
15
+ * declaration by name, so renaming it again means updating that regex, which
16
+ * fails loudly rather than silently watching nothing.
17
+ */
18
+ export const SPEC_VERSION = "2026-07-28";
12
19
  const LEGACY_VERSION = "2025-06-18";
13
- export function rcMeta() {
20
+ export function specMeta() {
14
21
  return {
15
22
  _meta: {
16
- "io.modelcontextprotocol/protocolVersion": RC_VERSION,
23
+ "io.modelcontextprotocol/protocolVersion": SPEC_VERSION,
17
24
  "io.modelcontextprotocol/clientCapabilities": {},
18
25
  },
19
26
  };
@@ -36,11 +43,20 @@ function toOutcome(reply, timeoutMessage) {
36
43
  * inconclusive; a crash is its own signal. This keeps E105 from false-flagging a
37
44
  * healthy but slow server, the worst verdict a readiness checker can emit.
38
45
  *
39
- * Detection is by message, not by error code: the codes SDKs use here are
46
+ * Detection is by message, not by error code, and the spec that published on
47
+ * 2026-07-28 is the argument for why. The codes SDKs use here are
40
48
  * implementation-defined (TS StreamableHTTP sends -32000 for both "Server not
41
49
  * initialized" and "Mcp-Session-Id header is required", -32001 for "Session not
42
- * found"), -32002 means Resource-not-found in the 2025 specs, and the RC
43
- * reserves -32022 for UnsupportedProtocolVersion, so no code is diagnostic.
50
+ * found"), and the MCP-defined ones MOVED between the locked RC and the
51
+ * published spec: MissingRequiredClientCapability went -32003 to -32021 and
52
+ * UnsupportedProtocolVersion went -32004 to -32022, and HeaderMismatch moved
53
+ * the same way, -32001 to -32020. This comment said HeaderMismatch was "added
54
+ * alongside them", which was wrong: the RC already defined it at -32001 in the
55
+ * transport prose, and what the published revision added was its entry in the
56
+ * schema. The distinction matters to anyone still returning -32001.
57
+ * -32002 (resource not found) is retired outright,
58
+ * replaced by -32602 and promised never to be reused. Matching on numbers
59
+ * would have rotted here on 2026-07-28; matching on messages did not.
44
60
  */
45
61
  export function looksLikeInitGate(o) {
46
62
  if (o.kind !== "error")
@@ -192,11 +208,60 @@ function hasCacheFields(result) {
192
208
  return (Object.prototype.hasOwnProperty.call(result, "ttlMs") &&
193
209
  Object.prototype.hasOwnProperty.call(result, "cacheScope"));
194
210
  }
211
+ /** JSON-RPC's own "no such method", unchanged by the 2026-07-28 revision. */
212
+ const METHOD_NOT_FOUND = -32601;
213
+ /**
214
+ * One reading of a server/discover answer, because there are three call sites
215
+ * (stdio, the bare stateless HTTP path, the legacy HTTP path) and they had
216
+ * already drifted: the same four branches were spelled three ways, and only
217
+ * two of them handled a reply that carried neither a result nor an error.
218
+ *
219
+ * Returns undefined when there is nothing to read, so each caller keeps its own
220
+ * fallback: stdio calls that "no answer", while the bare HTTP path deliberately
221
+ * leaves the verdict unset so the legacy branch can still try after initialize.
222
+ */
223
+ function classifyDiscover(reply) {
224
+ if (reply?.result)
225
+ return { verdict: { supported: true }, cacheFields: hasCacheFields(reply.result) };
226
+ if (reply?.error) {
227
+ const detail = `${reply.error.code} ${reply.error.message}`;
228
+ return reply.error.code === METHOD_NOT_FOUND
229
+ ? { verdict: { supported: false, errorMessage: detail } }
230
+ : { verdict: { skipped: `inconclusive (${detail})` } };
231
+ }
232
+ return undefined;
233
+ }
195
234
  function toolOrder(result) {
196
235
  const tools = result?.tools ?? [];
197
236
  return tools.map((x) => x?.name).filter((n) => typeof n === "string");
198
237
  }
238
+ /**
239
+ * Kills every spawned server on the way out, including when a probe throws.
240
+ *
241
+ * The body below used to run its cleanup only on the happy path, and the
242
+ * consequence was worse than a leaked process: with no kill(), the child's
243
+ * stdin is never closed, so it never sees EOF and never exits, and its stdio
244
+ * handles keep THIS process's event loop alive. runProbes catches the throw and
245
+ * the report still prints, and then the CLI hangs forever having already said
246
+ * everything it had to say. Measured: killed at 30s with the cleanup removed,
247
+ * 664ms clean exit with it.
248
+ *
249
+ * statelessStdio next door already had this try/finally; this is the same
250
+ * pattern in the file that was missing it. kill() is idempotent, so the early
251
+ * kills inside still free each server as soon as it is done rather than holding
252
+ * them all open until the end.
253
+ */
199
254
  async function probeStdio(target, budget) {
255
+ const sessions = [];
256
+ try {
257
+ return await probeStdioSessions(target, budget, sessions);
258
+ }
259
+ finally {
260
+ for (const s of sessions)
261
+ s.kill();
262
+ }
263
+ }
264
+ async function probeStdioSessions(target, budget, sessions) {
200
265
  const results = {};
201
266
  // A responsive server answers the bare request in well under a second, so a
202
267
  // generous cap only affects genuinely slow/hung servers (introspect already
@@ -204,13 +269,12 @@ async function probeStdio(target, budget) {
204
269
  // honored rather than floored.
205
270
  const bareT = Math.min(Math.max(budget, 1), 15000);
206
271
  const discoverT = Math.min(Math.max(budget, 1), 10000);
207
- const sessions = [];
208
272
  // A bare stateless tools/list. BOTH legacy (the reference SDK has no init gate)
209
273
  // and RC servers answer it, so RC vs legacy is decided by the RESULT shape
210
274
  // (resultType, cache fields, server/discover), never by whether it answered.
211
275
  const s1 = new StdioSession(target.command, target.args, target.env);
212
276
  sessions.push(s1);
213
- const bare = await s1.request("tools/list", rcMeta(), bareT);
277
+ const bare = await s1.request("tools/list", specMeta(), bareT);
214
278
  const bareOutcome = toOutcome(bare, `no response within ${Math.round(bareT / 1000)}s (the server may require the removed initialize handshake)`);
215
279
  results.bareToolsList = bareOutcome;
216
280
  let order1;
@@ -222,18 +286,10 @@ async function probeStdio(target, budget) {
222
286
  // Probe server/discover only if the server is responsive (answered or errored,
223
287
  // not timed out), to avoid a second long wait on an unresponsive server.
224
288
  if (!s1.exited && bareOutcome.kind !== "timeout") {
225
- const d = await s1.request("server/discover", rcMeta(), discoverT);
226
- if (d.result)
227
- results.serverDiscover = { supported: true };
228
- else if (d.error && d.error.code === -32601) {
229
- results.serverDiscover = { supported: false, errorMessage: `${d.error.code} ${d.error.message}` };
230
- }
231
- else if (d.error) {
232
- results.serverDiscover = { skipped: `inconclusive (${d.error.code} ${d.error.message})` };
233
- }
234
- else {
235
- results.serverDiscover = { skipped: "no answer to server/discover" };
236
- }
289
+ const d = classifyDiscover(await s1.request("server/discover", specMeta(), discoverT));
290
+ results.serverDiscover = d?.verdict ?? { skipped: "no answer to server/discover" };
291
+ if (d?.cacheFields !== undefined)
292
+ results.discoverCacheFieldsPresent = d.cacheFields;
237
293
  }
238
294
  else {
239
295
  results.serverDiscover = { skipped: "server did not answer the bare request" };
@@ -243,7 +299,7 @@ async function probeStdio(target, budget) {
243
299
  if (order1 && order1.length > 1) {
244
300
  const s2 = new StdioSession(target.command, target.args, target.env);
245
301
  sessions.push(s2);
246
- const bare2 = await s2.request("tools/list", rcMeta(), bareT);
302
+ const bare2 = await s2.request("tools/list", specMeta(), bareT);
247
303
  s2.kill();
248
304
  if (bare2.result) {
249
305
  const order2 = toolOrder(bare2.result);
@@ -264,8 +320,13 @@ export async function postMessage(url, extraHeaders, msg, sessionId, protocolVer
264
320
  "content-type": "application/json",
265
321
  accept: "application/json, text/event-stream",
266
322
  "mcp-protocol-version": protocolVersion,
267
- // Mcp-Method is well-defined (SEP-2243); Mcp-Name is required too but its
268
- // value is not the method name, so it is omitted rather than sent wrong.
323
+ // Mcp-Method mirrors the body's method and is required on every request
324
+ // (SEP-2243). Mcp-Name is NOT required here: the published spec requires
325
+ // it only on tools/call, resources/read and prompts/get, where it carries
326
+ // params.name or params.uri, and efaimo sends none of those. Omitting it
327
+ // is correct rather than a known gap. Sending a wrong value would be
328
+ // worse than sending none: a server MUST reject a header that disagrees
329
+ // with the body, now with a dedicated code (-32020 HeaderMismatch).
269
330
  ...(method ? { "Mcp-Method": method } : {}),
270
331
  ...(sessionId ? { "mcp-session-id": sessionId } : {}),
271
332
  ...(extraHeaders ?? {}),
@@ -465,7 +526,7 @@ async function probeHttp(target) {
465
526
  const results = {};
466
527
  const url = target.url;
467
528
  const headers = target.headers;
468
- const bare = await postMessage(url, headers, { jsonrpc: "2.0", id: 1, method: "tools/list", params: rcMeta() }, undefined, RC_VERSION).catch((e) => ({ status: 0, headers: new Headers(), authHeader: undefined, body: undefined, error: e }));
529
+ const bare = await postMessage(url, headers, { jsonrpc: "2.0", id: 1, method: "tools/list", params: specMeta() }, undefined, SPEC_VERSION).catch((e) => ({ status: 0, headers: new Headers(), authHeader: undefined, body: undefined, error: e }));
469
530
  if (bare.error) {
470
531
  const err = bare.error;
471
532
  const isTimeout = /timeout|timed out|aborted/i.test(`${err.name} ${err.message}`);
@@ -490,14 +551,15 @@ async function probeHttp(target) {
490
551
  // leave E106 (a MUST rule) unmeasured on precisely the servers it targets.
491
552
  if (results.bareToolsList && "ok" in results.bareToolsList && results.bareToolsList.ok) {
492
553
  try {
493
- const d = await postMessage(url, headers, { jsonrpc: "2.0", id: 7, method: "server/discover", params: rcMeta() }, undefined, RC_VERSION);
494
- if (d.body?.result)
495
- results.serverDiscover = { supported: true };
496
- else if (d.body?.error && d.body.error.code === -32601) {
497
- results.serverDiscover = { supported: false, errorMessage: `${d.body.error.code} ${d.body.error.message}` };
498
- }
499
- else if (d.body?.error) {
500
- results.serverDiscover = { skipped: `inconclusive (${d.body.error.code} ${d.body.error.message})` };
554
+ const d = await postMessage(url, headers, { jsonrpc: "2.0", id: 7, method: "server/discover", params: specMeta() }, undefined, SPEC_VERSION);
555
+ // No fallback verdict on purpose: a reply carrying neither result nor
556
+ // error leaves serverDiscover unset so the legacy branch below can still
557
+ // ask, after initialize.
558
+ const c = classifyDiscover(d.body);
559
+ if (c) {
560
+ results.serverDiscover = c.verdict;
561
+ if (c.cacheFields !== undefined)
562
+ results.discoverCacheFieldsPresent = c.cacheFields;
501
563
  }
502
564
  }
503
565
  catch {
@@ -507,7 +569,7 @@ async function probeHttp(target) {
507
569
  const bareTools = toolOrder(bare.body && "result" in bare.body ? bare.body.result : undefined);
508
570
  if (bareTools.length > 1) {
509
571
  try {
510
- const again = await postMessage(url, headers, { jsonrpc: "2.0", id: 8, method: "tools/list", params: rcMeta() }, undefined, RC_VERSION);
572
+ const again = await postMessage(url, headers, { jsonrpc: "2.0", id: 8, method: "tools/list", params: specMeta() }, undefined, SPEC_VERSION);
511
573
  const order2 = toolOrder(again.body?.result);
512
574
  if (order2.length)
513
575
  results.toolsOrderDeterministic = arraysEqual(bareTools, order2);
@@ -545,16 +607,12 @@ async function probeHttp(target) {
545
607
  }
546
608
  if (results.serverDiscover === undefined) {
547
609
  const d = await postMessage(url, headers, { jsonrpc: "2.0", id: 4, method: "server/discover", params: {} }, sessionId);
548
- if (d.body?.result)
549
- results.serverDiscover = { supported: true };
550
- else if (d.body?.error && d.body.error.code === -32601) {
551
- results.serverDiscover = { supported: false, errorMessage: `${d.body.error.code} ${d.body.error.message}` };
552
- }
553
- else if (d.body?.error) {
554
- results.serverDiscover = { skipped: `inconclusive (${d.body.error.code} ${d.body.error.message})` };
555
- }
556
- else
557
- results.serverDiscover = { skipped: "no answer to server/discover" };
610
+ const c = classifyDiscover(d.body);
611
+ results.serverDiscover = c?.verdict ?? { skipped: "no answer to server/discover" };
612
+ // ??= for the same reason as the resultType/cacheFields lines above:
613
+ // never clobber a measurement the bare stateless path already took.
614
+ if (c?.cacheFields !== undefined)
615
+ results.discoverCacheFieldsPresent ??= c.cacheFields;
558
616
  }
559
617
  if (order1 && order1.length > 1 && results.toolsOrderDeterministic === undefined) {
560
618
  const init2 = await postMessage(url, headers, {
@@ -1,4 +1,4 @@
1
- import { RC_VERSION, rcMeta, postMessage, StdioSession } from "./rawprobe.js";
1
+ import { SPEC_VERSION, specMeta, postMessage, StdioSession } from "./rawprobe.js";
2
2
  /**
3
3
  * 2026-07-28 introspection path: bare stateless requests, no initialize.
4
4
  * The SDK client enforces the legacy handshake, so a stateless RC server makes
@@ -8,6 +8,15 @@ import { RC_VERSION, rcMeta, postMessage, StdioSession } from "./rawprobe.js";
8
8
  * connect error.
9
9
  */
10
10
  const MAX_PAGES = 20;
11
+ /**
12
+ * Newest shape first, then the RC's. Reading only the RC's field made efaimo
13
+ * report "identity unknown" for precisely the servers that had finished
14
+ * migrating, which is the worst direction for this error to point.
15
+ */
16
+ function discoverIdentity(d) {
17
+ const found = d?._meta?.["io.modelcontextprotocol/serverInfo"] ?? d?.serverInfo;
18
+ return found ? { name: found.name, version: found.version, title: found.title } : undefined;
19
+ }
11
20
  export async function introspectStateless(target, opts = {}) {
12
21
  // The legacy path already burned the caller's timeout once; keep this
13
22
  // attempt tight so a genuinely dead server fails in bounded time.
@@ -26,7 +35,7 @@ async function statelessStdio(target, budget) {
26
35
  let rawFirst;
27
36
  let cursor;
28
37
  for (let page = 0; page < MAX_PAGES; page++) {
29
- const params = cursor ? { ...rcMeta(), cursor } : rcMeta();
38
+ const params = cursor ? { ...specMeta(), cursor } : specMeta();
30
39
  const reply = await session.request("tools/list", params, budget);
31
40
  if (!reply.result) {
32
41
  if (page === 0)
@@ -40,7 +49,7 @@ async function statelessStdio(target, budget) {
40
49
  if (!cursor)
41
50
  break;
42
51
  }
43
- const discover = await session.request("server/discover", rcMeta(), Math.min(budget, 10000));
52
+ const discover = await session.request("server/discover", specMeta(), Math.min(budget, 10000));
44
53
  return buildIntrospection(target.label, "stdio", undefined, pages, rawFirst, discover.result);
45
54
  }
46
55
  finally {
@@ -52,8 +61,8 @@ async function statelessHttp(target) {
52
61
  let rawFirst;
53
62
  let cursor;
54
63
  for (let page = 0; page < MAX_PAGES; page++) {
55
- const params = cursor ? { ...rcMeta(), cursor } : rcMeta();
56
- const reply = await postMessage(target.url, target.headers, { jsonrpc: "2.0", id: 100 + page, method: "tools/list", params }, undefined, RC_VERSION);
64
+ const params = cursor ? { ...specMeta(), cursor } : specMeta();
65
+ const reply = await postMessage(target.url, target.headers, { jsonrpc: "2.0", id: 100 + page, method: "tools/list", params }, undefined, SPEC_VERSION);
57
66
  const result = reply.body?.result;
58
67
  if (!result) {
59
68
  if (page === 0)
@@ -67,7 +76,7 @@ async function statelessHttp(target) {
67
76
  if (!cursor)
68
77
  break;
69
78
  }
70
- const discover = await postMessage(target.url, target.headers, { jsonrpc: "2.0", id: 99, method: "server/discover", params: rcMeta() }, undefined, RC_VERSION).catch(() => undefined);
79
+ const discover = await postMessage(target.url, target.headers, { jsonrpc: "2.0", id: 99, method: "server/discover", params: specMeta() }, undefined, SPEC_VERSION).catch(() => undefined);
71
80
  return buildIntrospection(target.label, "http", "streamable", pages, rawFirst, discover?.body?.result);
72
81
  }
73
82
  function buildIntrospection(label, transport, httpTransport, pages, rawFirst, discoverResult) {
@@ -102,14 +111,8 @@ function buildIntrospection(label, transport, httpTransport, pages, rawFirst, di
102
111
  targetLabel: label,
103
112
  transport,
104
113
  httpTransport,
105
- serverInfo: discover?.serverInfo
106
- ? {
107
- name: discover.serverInfo.name,
108
- version: discover.serverInfo.version,
109
- title: discover.serverInfo.title,
110
- }
111
- : undefined,
112
- protocolVersion: discover?.supportedVersions?.includes(RC_VERSION) ? RC_VERSION : undefined,
114
+ serverInfo: discoverIdentity(discover),
115
+ protocolVersion: discover?.supportedVersions?.includes(SPEC_VERSION) ? SPEC_VERSION : undefined,
113
116
  instructions: discover?.instructions,
114
117
  capabilities: caps,
115
118
  tools,
@@ -6,8 +6,26 @@ export function runRules(rules, ctx) {
6
6
  try {
7
7
  findings.push(...rule.check(ctx));
8
8
  }
9
- catch {
10
- /* skip a throwing rule */
9
+ catch (err) {
10
+ // Say so. This used to be a bare `catch {}`, which meant a rule that
11
+ // threw was indistinguishable from a rule that passed - and since the
12
+ // grade is 100 minus the findings, a crashing rule silently RAISED the
13
+ // score. That is this project's documented dominant failure mode ("green
14
+ // because it checked nothing") sitting inside the engine every check
15
+ // runs through.
16
+ //
17
+ // Reported ungraded on purpose: a broken rule is our defect, not the
18
+ // target's, so it must not cost the target points. It must not be
19
+ // invisible either.
20
+ const id = rule.id ?? "unknown rule";
21
+ findings.push({
22
+ ruleId: "E000",
23
+ severity: "warn",
24
+ graded: false,
25
+ message: `rule ${id} threw and was skipped; this report is incomplete`,
26
+ detail: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
27
+ fixHint: "please report this at https://github.com/efaimo-ai/efaimo/issues",
28
+ });
11
29
  }
12
30
  }
13
31
  return findings;
@@ -1,8 +1,14 @@
1
1
  const PENALTY = { error: 15, warn: 5, info: 1 };
2
2
  export function gradeFindings(findings) {
3
3
  let score = 100;
4
- for (const f of findings)
4
+ // `graded: false` findings are reported but never scored; see Finding.graded
5
+ // and src/rules/injection.ts. Everything else counts, so a new rule has to
6
+ // opt out on purpose rather than by forgetting to opt in.
7
+ for (const f of findings) {
8
+ if (f.graded === false)
9
+ continue;
5
10
  score -= PENALTY[f.severity] ?? 0;
11
+ }
6
12
  score = Math.max(0, score);
7
13
  const letter = score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : score >= 60 ? "D" : "F";
8
14
  return { score, letter };
@@ -8,6 +8,15 @@ export interface Finding {
8
8
  target?: string;
9
9
  detail?: string;
10
10
  fixHint?: string;
11
+ /**
12
+ * Set false by findings that must be reported but must not affect the score.
13
+ * Today that is the injection heuristics (E130, S105): they are shallow
14
+ * patterns an attacker evades trivially, and `src/rules/injection.ts` says
15
+ * so in its own comment. Letting them dock the grade made a linter hint
16
+ * behave like a security verdict, and ten of them cost a full letter.
17
+ * Defaults to graded, so a rule opts out deliberately or not at all.
18
+ */
19
+ graded?: boolean;
11
20
  }
12
21
  export type Surface = "mcp" | "skill";
13
22
  export interface ToolDef {
@@ -71,8 +80,16 @@ export interface ProbeResults {
71
80
  };
72
81
  /** Whether tools/list result carried the RC-required resultType field. */
73
82
  resultTypePresent?: boolean;
74
- /** Whether tools/list result carried both RC-required ttlMs and cacheScope (SEP-2549). */
83
+ /** Whether tools/list result carried both required ttlMs and cacheScope (SEP-2549). */
75
84
  cacheFieldsPresent?: boolean;
85
+ /**
86
+ * Same question for the server/discover result, measured separately because
87
+ * the answer changed between the locked RC and the published spec: the RC's
88
+ * DiscoverResult extended plain Result, the published 2026-07-28 extends
89
+ * CacheableResult, so cache fields are required there now and were not then.
90
+ * Undefined when server/discover did not answer at all (E106's job).
91
+ */
92
+ discoverCacheFieldsPresent?: boolean;
76
93
  /** Same tool order across two fresh connections. */
77
94
  toolsOrderDeterministic?: boolean;
78
95
  /** Non-JSON noise observed on stdout before/between JSON-RPC messages (stdio only). */
@@ -170,10 +187,12 @@ export interface SkillInfo {
170
187
  frontmatterRaw: string;
171
188
  body: string;
172
189
  bodyLines: number;
190
+ /** `escapes` = the reference resolves outside the skill directory. weigh must not read those; S106 reports them. */
173
191
  referencedPaths: {
174
192
  raw: string;
175
193
  resolved: string;
176
194
  exists: boolean;
195
+ escapes: boolean;
177
196
  source: "link" | "code";
178
197
  }[];
179
198
  files: {
@@ -205,8 +224,8 @@ export interface CheckReport {
205
224
  grade: GradeInfo;
206
225
  /**
207
226
  * MCP only: 2026-07-28 readiness findings (E101-E118), reported as an
208
- * ungraded migration diff. The target spec is not final until 2026-07-28,
209
- * so unreadiness is a to-do list, not a quality defect.
227
+ * ungraded migration diff: a migration not yet made is a to-do list, not a
228
+ * quality defect in what shipped.
210
229
  */
211
230
  readiness?: {
212
231
  findings: Finding[];
@@ -1,7 +1,19 @@
1
1
  import { VERSION } from "../version.js";
2
+ import { safeText } from "../util/safeText.js";
2
3
  const SEV_LABEL = { error: "🔴 error", warn: "🟡 warn", info: "🔵 info" };
4
+ // Everything through here came from the audited target, and
5
+ // docs/INTEGRATIONS.md recommends piping --md into $GITHUB_STEP_SUMMARY, so a
6
+ // hostile tool name or description renders into a page the reader trusts.
7
+ // Two things were missing: control characters (raw ESC bytes were written
8
+ // through literally) and backticks (most call sites wrap the value in a code
9
+ // span, so one backtick closes it and the rest of the name becomes markup).
10
+ // The backtick is neutralised with a zero-width space rather than a backslash,
11
+ // because a backslash is not an escape inside a code span.
3
12
  function esc(s) {
4
- return s.replace(/\|/g, "\\|").replace(/\n/g, " ");
13
+ return safeText(s)
14
+ .replace(/\|/g, "\\|")
15
+ .replace(/`/g, "​`")
16
+ .replace(/\n/g, " ");
5
17
  }
6
18
  function findingsTable(lines, findings) {
7
19
  lines.push("| severity | rule | finding |");
@@ -14,7 +26,7 @@ export function renderCheckMarkdown(report) {
14
26
  const lines = [];
15
27
  lines.push(`## efaimo check (${report.surface}): grade ${report.grade.letter} (${report.grade.score})`);
16
28
  lines.push("");
17
- lines.push(`target: \`${report.target}\``);
29
+ lines.push(`target: \`${esc(report.target)}\``);
18
30
  lines.push("");
19
31
  if (!report.findings.length) {
20
32
  lines.push(report.readiness ? "no quality findings. clean." : "no findings. clean.");
@@ -63,7 +75,7 @@ export function renderSkillSetMarkdown(res) {
63
75
  export function renderWeighMarkdown(w) {
64
76
  const lines = [];
65
77
  if (w.kind === "mcp") {
66
- lines.push(`## efaimo weigh: \`${w.label}\``);
78
+ lines.push(`## efaimo weigh: \`${esc(w.label)}\``);
67
79
  lines.push("");
68
80
  lines.push(`tools ${w.toolCount} | resources ${w.resourceCount} | prompts ${w.promptCount}`);
69
81
  lines.push("");
@@ -88,7 +100,7 @@ export function renderWeighMarkdown(w) {
88
100
  }
89
101
  }
90
102
  else {
91
- lines.push(`## efaimo weigh (skills): \`${w.label}\``);
103
+ lines.push(`## efaimo weigh (skills): \`${esc(w.label)}\``);
92
104
  lines.push("");
93
105
  lines.push("| skill | metadata (always) | body (on trigger) | lines | referenced files |");
94
106
  lines.push("|---|---|---|---|---|");