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
@@ -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;
Binary file
@@ -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
  }
@@ -42,4 +63,5 @@ export declare function armSystems(scenario: Scenario): {
42
63
  withoutSkill: string | undefined;
43
64
  withSkill: string;
44
65
  };
66
+ export declare function withRetry(runner: Runner, tries?: number, baseDelayMs?: number): Runner;
45
67
  export declare function runScenario(scenario: Scenario, runner: Runner): Promise<TestReport>;
@@ -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
- const trials = Math.min(50, Math.max(1, Math.round(num(doc.trials) ?? 5)));
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,121 @@ 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
- return /\bpass\b/i.test(out) && !/\bfail\b/i.test(out);
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";
73
+ }
74
+ // A live run makes trials * 2 arms * 2 (answer + judge) API calls. One
75
+ // transient 429 or 5xx among them used to throw and discard every trial already
76
+ // paid for. Retry the transient failures with backoff; a non-429 4xx (bad key,
77
+ // unknown model) is a real error and is not retried.
78
+ function isTransient(err) {
79
+ const m = String(err?.message ?? err);
80
+ return (/\b(429|500|502|503|504)\b/.test(m) ||
81
+ /timeout|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|fetch failed|network/i.test(m));
82
+ }
83
+ export function withRetry(runner, tries = 4, baseDelayMs = 500) {
84
+ return async (req) => {
85
+ let lastErr;
86
+ for (let attempt = 0; attempt < tries; attempt++) {
87
+ try {
88
+ return await runner(req);
89
+ }
90
+ catch (e) {
91
+ lastErr = e;
92
+ if (attempt === tries - 1 || !isTransient(e))
93
+ throw e;
94
+ await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** attempt));
95
+ }
96
+ }
97
+ throw lastErr;
98
+ };
57
99
  }
58
100
  async function runArm(runner, scenario, system) {
59
101
  let passes = 0;
102
+ let unparseable = 0;
60
103
  for (let i = 0; i < scenario.trials; i++) {
61
104
  const answer = await runner({ system, user: scenario.task, model: scenario.model });
62
- if (await judgeOne(runner, scenario, answer))
105
+ const v = await judgeOne(runner, scenario, answer);
106
+ if (v === "pass")
63
107
  passes++;
108
+ else if (v === "unparseable")
109
+ unparseable++;
64
110
  }
65
- return { trials: scenario.trials, passes, passRate: (passes / scenario.trials) * 100 };
111
+ // Scored trials exclude the ones the judge did not answer, so the rate is a
112
+ // rate over evidence rather than over attempts.
113
+ const scored = scenario.trials - unparseable;
114
+ return {
115
+ trials: scenario.trials,
116
+ scored,
117
+ unparseable,
118
+ passes,
119
+ passRate: scored > 0 ? (passes / scored) * 100 : 0,
120
+ };
66
121
  }
67
122
  export async function runScenario(scenario, runner) {
68
123
  const systems = armSystems(scenario);
69
- const withoutSkill = await runArm(runner, scenario, systems.withoutSkill);
70
- const withSkill = await runArm(runner, scenario, systems.withSkill);
124
+ // Wrap once so both the subject answers and the judge calls survive a
125
+ // transient blip instead of throwing away the whole (paid-for) scenario.
126
+ const r = withRetry(runner);
127
+ const withoutSkill = await runArm(r, scenario, systems.withoutSkill);
128
+ const withSkill = await runArm(r, scenario, systems.withSkill);
71
129
  const deltaPoints = Math.round((withSkill.passRate - withoutSkill.passRate) * 10) / 10;
130
+ // The verdict is a significance test, not a threshold on the gap.
131
+ //
132
+ // It used to be `>= +15 points helps, <= -15 hurts`, with 5 trials per arm
133
+ // as the default. At that size 5/5 against 4/5 is +20 points and a two-sided
134
+ // Fisher p of 1.0000: the least significant result obtainable, reported as a
135
+ // finding, and `hurts` set exit code 1. The +-15 band sits inside the noise
136
+ // floor at every trial count below roughly 25 per arm, so the threshold was
137
+ // measuring sample size more than it was measuring the skill.
138
+ const p = fisherExactTwoSided(withSkill.passes, withSkill.scored, withoutSkill.passes, withoutSkill.scored);
139
+ const ci = deltaInterval(withSkill.passes, withSkill.scored, withoutSkill.passes, withoutSkill.scored);
140
+ // With no scoreable trial in an arm the pass rate and its interval are
141
+ // 0/0 = NaN; say so plainly instead of printing "NaN to NaN points".
142
+ const bothScored = withSkill.scored > 0 && withoutSkill.scored > 0;
72
143
  const notes = [
73
- `${scenario.trials} trials per arm; probabilistic. Treat small deltas as noise and raise trials for confidence.`,
144
+ bothScored
145
+ ? `${scenario.trials} trials per arm. Two-sided Fisher exact p = ${p < 0.0001 ? "<0.0001" : p.toFixed(4)}; ` +
146
+ `95% interval on the delta ${ci.lo >= 0 ? "+" : ""}${ci.lo} to ${ci.hi >= 0 ? "+" : ""}${ci.hi} points.`
147
+ : `${scenario.trials} trials per arm. An arm produced no scoreable trial, so no p-value or delta interval can be computed.`,
74
148
  ];
149
+ const unscored = withSkill.unparseable + withoutSkill.unparseable;
150
+ if (unscored) {
151
+ 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.`);
152
+ }
75
153
  let verdict;
76
- if (scenario.trials < 5) {
154
+ if (withSkill.scored === 0 || withoutSkill.scored === 0) {
77
155
  verdict = "inconclusive";
78
- notes.push("fewer than 5 trials: not enough signal to conclude.");
156
+ notes.push("an arm produced no scoreable trial; there is nothing to compare.");
79
157
  }
80
- else if (deltaPoints >= 15)
158
+ else if (p >= 0.05) {
159
+ verdict = "no measurable effect";
160
+ if (Math.abs(deltaPoints) >= 15) {
161
+ notes.push(`the ${deltaPoints >= 0 ? "+" : ""}${deltaPoints} point gap is not significant at this sample size (p = ${p.toFixed(4)}). ` +
162
+ `Raise trials rather than reading the gap.`);
163
+ }
164
+ }
165
+ else if (deltaPoints > 0)
81
166
  verdict = "helps";
82
- else if (deltaPoints <= -15)
83
- verdict = "hurts";
84
167
  else
85
- verdict = "no measurable effect";
168
+ verdict = "hurts";
86
169
  return {
87
170
  scenario: scenario.name,
88
171
  skill: scenario.skillName,
@@ -90,6 +173,8 @@ export async function runScenario(scenario, runner) {
90
173
  withSkill,
91
174
  withoutSkill,
92
175
  deltaPoints,
176
+ p,
177
+ ci,
93
178
  verdict,
94
179
  notes,
95
180
  };
@@ -8,13 +8,27 @@ export function openaiRunner(apiKey) {
8
8
  if (req.system)
9
9
  messages.push({ role: "system", content: req.system });
10
10
  messages.push({ role: "user", content: req.user });
11
+ // o-series reasoning models (o1, o3, o4, ...) reject any non-default
12
+ // temperature with a 400, and a chat-sized 1024 cap starves them: their
13
+ // hidden reasoning tokens count against max_completion_tokens, so a small
14
+ // cap can return empty content. So omit temperature and give them headroom.
15
+ const isReasoning = /^o[0-9]/i.test(req.model);
16
+ const body = {
17
+ model: req.model,
18
+ messages,
19
+ max_completion_tokens: isReasoning ? 8192 : 1024,
20
+ };
21
+ // temperature: 0 for the judge, 1 for the subject (see
22
+ // RunnerRequest.deterministic), but only where the model accepts it.
23
+ if (!isReasoning)
24
+ body.temperature = req.deterministic ? 0 : 1;
11
25
  const res = await fetch("https://api.openai.com/v1/chat/completions", {
12
26
  method: "POST",
13
27
  headers: {
14
28
  "content-type": "application/json",
15
29
  authorization: `Bearer ${apiKey}`,
16
30
  },
17
- body: JSON.stringify({ model: req.model, messages, max_completion_tokens: 1024 }),
31
+ body: JSON.stringify(body),
18
32
  redirect: "error",
19
33
  signal: AbortSignal.timeout(60000),
20
34
  });
@@ -0,0 +1,30 @@
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
+ };
@@ -0,0 +1,89 @@
1
+ // The statistics behind an `efaimo test` verdict.
2
+ //
3
+ // The verdict used to be a bare threshold on the difference in pass rates:
4
+ // >= +15 points "helps", <= -15 "hurts", exit code 1 on "hurts". With the
5
+ // default 5 trials per arm, 5/5 against 4/5 is +20 points and would have been
6
+ // reported as "helps" with a two-sided Fisher p of 1.0000 - the least
7
+ // significant result the test can produce, labelled as a finding. At 8 trials
8
+ // per arm the 95% interval on a difference of proportions is roughly +-49
9
+ // points wide, so the whole +-15 band sits inside the noise for every trial
10
+ // count the tool supports below about 25 per arm.
11
+ //
12
+ // A tool whose argument is that people publish numbers they cannot support
13
+ // should not do that itself. So the verdict is now gated on a p-value, the
14
+ // interval is reported, and both are printed rather than hidden.
15
+ //
16
+ // Fisher's exact test, not chi-squared: the counts here are single digits, and
17
+ // chi-squared's approximation is not trustworthy at that size.
18
+ /** ln(n!) via lgamma, so the 2x2 table math stays exact enough at small n. */
19
+ function lnFactorial(n) {
20
+ let s = 0;
21
+ for (let i = 2; i <= n; i++)
22
+ s += Math.log(i);
23
+ return s;
24
+ }
25
+ /** Probability of exactly this 2x2 table under the hypergeometric null. */
26
+ function lnHypergeom(a, b, c, d) {
27
+ const n = a + b + c + d;
28
+ return (lnFactorial(a + b) + lnFactorial(c + d) + lnFactorial(a + c) + lnFactorial(b + d) -
29
+ lnFactorial(n) - lnFactorial(a) - lnFactorial(b) - lnFactorial(c) - lnFactorial(d));
30
+ }
31
+ /**
32
+ * Two-sided Fisher exact test on
33
+ * [withPass, withFail]
34
+ * [withoutPass, withoutFail]
35
+ *
36
+ * Sums the probability of every table at least as extreme as the observed one,
37
+ * which is the standard two-sided construction and needs no normal
38
+ * approximation.
39
+ */
40
+ export function fisherExactTwoSided(withPass, withTrials, withoutPass, withoutTrials) {
41
+ const a = withPass, b = withTrials - withPass;
42
+ const c = withoutPass, d = withoutTrials - withoutPass;
43
+ if (withTrials <= 0 || withoutTrials <= 0)
44
+ return 1;
45
+ const obs = lnHypergeom(a, b, c, d);
46
+ const rowTop = a + b, rowBot = c + d, colPass = a + c;
47
+ const lo = Math.max(0, colPass - rowBot);
48
+ const hi = Math.min(rowTop, colPass);
49
+ // A tiny epsilon so floating point does not drop the observed table itself.
50
+ const EPS = 1e-9;
51
+ let p = 0;
52
+ for (let x = lo; x <= hi; x++) {
53
+ const l = lnHypergeom(x, rowTop - x, colPass - x, rowBot - (colPass - x));
54
+ if (l <= obs + EPS)
55
+ p += Math.exp(l);
56
+ }
57
+ return Math.min(1, p);
58
+ }
59
+ /**
60
+ * Wilson score interval for one proportion. Chosen over the normal-approximation
61
+ * interval because that one produces a zero-width interval at 0/n and n/n,
62
+ * which is exactly where these runs land (8/8 and 0/8 both occur in the two
63
+ * committed example runs).
64
+ */
65
+ export function wilson(passes, trials, z = 1.96) {
66
+ if (trials <= 0)
67
+ return { lo: 0, hi: 1 };
68
+ const p = passes / trials;
69
+ const z2 = z * z;
70
+ const denom = 1 + z2 / trials;
71
+ const centre = p + z2 / (2 * trials);
72
+ const half = z * Math.sqrt((p * (1 - p)) / trials + z2 / (4 * trials * trials));
73
+ return { lo: Math.max(0, (centre - half) / denom), hi: Math.min(1, (centre + half) / denom) };
74
+ }
75
+ /**
76
+ * Interval on the DIFFERENCE of two proportions, in percentage points, by the
77
+ * Newcombe method: combine each arm's Wilson interval. Reported so a reader can
78
+ * see how wide the uncertainty actually is instead of inferring it from a
79
+ * point estimate.
80
+ */
81
+ export function deltaInterval(withPass, withTrials, withoutPass, withoutTrials) {
82
+ const w = wilson(withPass, withTrials);
83
+ const o = wilson(withoutPass, withoutTrials);
84
+ const p1 = withPass / withTrials, p2 = withoutPass / withoutTrials;
85
+ const d = p1 - p2;
86
+ const lo = d - Math.sqrt((p1 - w.lo) ** 2 + (o.hi - p2) ** 2);
87
+ const hi = d + Math.sqrt((w.hi - p1) ** 2 + (p2 - o.lo) ** 2);
88
+ return { lo: Math.round(Math.max(-1, lo) * 1000) / 10, hi: Math.round(Math.min(1, hi) * 1000) / 10 };
89
+ }
@@ -9,4 +9,12 @@
9
9
  * secrets are not forwarded to spawned MCP servers: minimalChildEnv() passes an
10
10
  * allowlist that excludes API keys.
11
11
  */
12
- export declare function loadDotEnv(dir?: string): string[];
12
+ /**
13
+ * The only keys this loader may set. It used to set ANY key found in a .env in
14
+ * the CURRENT DIRECTORY, which means a repository you cloned could inject
15
+ * NODE_TLS_REJECT_UNAUTHORIZED=0 and silently disable certificate verification
16
+ * for every later HTTPS call, including the ones carrying your API key. The
17
+ * file header already said only two keys were wanted; this enforces it.
18
+ */
19
+ export declare const ALLOWED: Set<string>;
20
+ export declare function loadDotEnv(dir?: string, allowed?: ReadonlySet<string>): string[];
@@ -11,7 +11,15 @@ import { resolve } from "node:path";
11
11
  * secrets are not forwarded to spawned MCP servers: minimalChildEnv() passes an
12
12
  * allowlist that excludes API keys.
13
13
  */
14
- export function loadDotEnv(dir = process.cwd()) {
14
+ /**
15
+ * The only keys this loader may set. It used to set ANY key found in a .env in
16
+ * the CURRENT DIRECTORY, which means a repository you cloned could inject
17
+ * NODE_TLS_REJECT_UNAUTHORIZED=0 and silently disable certificate verification
18
+ * for every later HTTPS call, including the ones carrying your API key. The
19
+ * file header already said only two keys were wanted; this enforces it.
20
+ */
21
+ export const ALLOWED = new Set(["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]);
22
+ export function loadDotEnv(dir = process.cwd(), allowed = ALLOWED) {
15
23
  let text;
16
24
  try {
17
25
  text = readFileSync(resolve(dir, ".env"), "utf8");
@@ -32,6 +40,8 @@ export function loadDotEnv(dir = process.cwd()) {
32
40
  const key = line.slice(0, eq).trim();
33
41
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
34
42
  continue;
43
+ if (!allowed.has(key))
44
+ continue;
35
45
  if (process.env[key] !== undefined)
36
46
  continue;
37
47
  let val = line.slice(eq + 1).trim();
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Strip terminal control characters from text that originated outside efaimo.
3
+ * Tab and newline survive because layout depends on them; everything else that
4
+ * can move a cursor, clear a line, or address the terminal is removed.
5
+ */
6
+ export declare function safeText(s: string): string;
7
+ /** Same, for a value that may be undefined. */
8
+ export declare function safeTextOpt(s: string | undefined): string | undefined;
@@ -0,0 +1,48 @@
1
+ // Text that came from the thing being audited must not be able to drive the
2
+ // terminal it is reported in.
3
+ //
4
+ // Tool names, titles, descriptions, server instructions, stderr tails and
5
+ // repo-scan excerpts all originate with the target. They were interpolated
6
+ // into stdout raw, so a hostile server could emit ESC[1A ESC[2K inside a tool
7
+ // name and rewrite the grade line printed above its own finding: the auditor
8
+ // reads "grade A (100), 0 errors" while the finding that contradicts it is
9
+ // erased. OSC 8 forges a hyperlink; OSC 52 writes the reader's clipboard.
10
+ // `--no-color` did not help, because that only suppresses the colours efaimo
11
+ // itself adds.
12
+ //
13
+ // This matters out of proportion to its severity. An auditing tool whose
14
+ // verdict can be rewritten by the thing it audits has a credibility problem
15
+ // before it has a security one, and the HTTP transport is exactly where a user
16
+ // expects no code execution and therefore no consequences.
17
+ //
18
+ // Applied at the RENDER boundary, never on ingestion: escape bytes in a
19
+ // description are real bytes that a model would receive and really do cost
20
+ // tokens, so `weigh` has to keep counting them. What must not happen is that
21
+ // they reach a terminal as control codes.
22
+ //
23
+ // JSON output needs no pass here: JSON.stringify already encodes control
24
+ // characters as  escape sequences, which are inert text.
25
+ // C0 except tab and newline, DEL, and the C1 block. C1 is included because a
26
+ // lone 0x9B is CSI in a terminal that decodes single-byte C1, which is the
27
+ // same attack without an ESC byte to grep for.
28
+ // Built from code points rather than written as literals so this file does not
29
+ // itself contain the bytes it exists to remove; the same reason gates.mjs next
30
+ // door builds its dash class with String.fromCharCode.
31
+ const CONTROLS = new RegExp("[" +
32
+ "\\u0000-\\u0008" + // C0 up to backspace (tab 0x09 and newline 0x0A kept)
33
+ "\\u000B-\\u001F" + // vertical tab through unit separator, ESC included
34
+ "\\u007F" + // DEL
35
+ "\\u0080-\\u009F" + // C1, single-byte CSI/OSC among them
36
+ "]", "g");
37
+ /**
38
+ * Strip terminal control characters from text that originated outside efaimo.
39
+ * Tab and newline survive because layout depends on them; everything else that
40
+ * can move a cursor, clear a line, or address the terminal is removed.
41
+ */
42
+ export function safeText(s) {
43
+ return s.replace(CONTROLS, "");
44
+ }
45
+ /** Same, for a value that may be undefined. */
46
+ export function safeTextOpt(s) {
47
+ return s === undefined ? undefined : safeText(s);
48
+ }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Kept in sync with package.json by test/meta.test.ts. */
2
- export declare const VERSION = "0.1.0";
2
+ export declare const VERSION = "0.1.2";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Kept in sync with package.json by test/meta.test.ts. */
2
- export const VERSION = "0.1.0";
2
+ export const VERSION = "0.1.2";
@@ -69,7 +69,9 @@ export async function weighSkills(set) {
69
69
  let refFileTokens = 0;
70
70
  let refFileCount = 0;
71
71
  for (const ref of skill.referencedPaths) {
72
- if (!ref.exists || isBinaryPath(ref.resolved))
72
+ // Never read outside the skill root. S106 warns about these, but only
73
+ // after weigh had already opened the file and counted its tokens.
74
+ if (ref.escapes || !ref.exists || isBinaryPath(ref.resolved))
73
75
  continue;
74
76
  const text = readTextSafe(ref.resolved);
75
77
  if (text === undefined)
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The context window a token count is reported as a share of.
3
+ *
4
+ * There is no single correct denominator: it is a property of the model the
5
+ * host happens to be running, not of the server being measured. Every current
6
+ * frontier Claude model (Fable 5, Opus 5, Opus 4.8/4.7/4.6, Sonnet 5, Sonnet
7
+ * 4.6) is 1M; Haiku 4.5 is 200K, as are many non-Claude and local models, where the
8
+ * same tool definitions cost 5x the share shown here.
9
+ *
10
+ * So the share is always printed with the window it was computed against, and
11
+ * `--window` lets the caller name their own. The absolute token count is the
12
+ * number efaimo actually stands behind; this is a readability aid on top of it.
13
+ *
14
+ * Note that no rule grades on this share -- E128's thresholds are absolute
15
+ * token counts, so changing the window never moves a grade.
16
+ */
17
+ export declare const DEFAULT_CONTEXT_WINDOW = 1000000;
18
+ export declare function setContextWindow(tokens: number): void;
19
+ export declare function getContextWindow(): number;
20
+ /** "1M", "200k", "128k", "4,096" -- how the window is named in output. */
21
+ export declare function formatWindow(tokens?: number): string;
22
+ /**
23
+ * "~0.1% of a 1M window" -- always names the denominator, so the reader is
24
+ * never left guessing which model it assumed.
25
+ */
26
+ export declare function formatWindowShare(tokens: number, window?: number): string;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The context window a token count is reported as a share of.
3
+ *
4
+ * There is no single correct denominator: it is a property of the model the
5
+ * host happens to be running, not of the server being measured. Every current
6
+ * frontier Claude model (Fable 5, Opus 5, Opus 4.8/4.7/4.6, Sonnet 5, Sonnet
7
+ * 4.6) is 1M; Haiku 4.5 is 200K, as are many non-Claude and local models, where the
8
+ * same tool definitions cost 5x the share shown here.
9
+ *
10
+ * So the share is always printed with the window it was computed against, and
11
+ * `--window` lets the caller name their own. The absolute token count is the
12
+ * number efaimo actually stands behind; this is a readability aid on top of it.
13
+ *
14
+ * Note that no rule grades on this share -- E128's thresholds are absolute
15
+ * token counts, so changing the window never moves a grade.
16
+ */
17
+ export const DEFAULT_CONTEXT_WINDOW = 1_000_000;
18
+ let contextWindow = DEFAULT_CONTEXT_WINDOW;
19
+ export function setContextWindow(tokens) {
20
+ if (!Number.isFinite(tokens) || tokens <= 0) {
21
+ throw new Error(`--window must be a positive number of tokens, got ${tokens}`);
22
+ }
23
+ contextWindow = tokens;
24
+ }
25
+ export function getContextWindow() {
26
+ return contextWindow;
27
+ }
28
+ /** "1M", "200k", "128k", "4,096" -- how the window is named in output. */
29
+ export function formatWindow(tokens = contextWindow) {
30
+ if (tokens >= 1_000_000 && tokens % 1_000_000 === 0)
31
+ return `${tokens / 1_000_000}M`;
32
+ if (tokens >= 1_000 && tokens % 1_000 === 0)
33
+ return `${tokens / 1_000}k`;
34
+ return tokens.toLocaleString("en-US");
35
+ }
36
+ /**
37
+ * "~0.1% of a 1M window" -- always names the denominator, so the reader is
38
+ * never left guessing which model it assumed.
39
+ */
40
+ export function formatWindowShare(tokens, window = contextWindow) {
41
+ const pct = (tokens / window) * 100;
42
+ const shown = pct < 0.1 && pct > 0 ? "<0.1" : pct.toFixed(1);
43
+ return `~${shown}% of a ${formatWindow(window)} window`;
44
+ }