vigiles 4.0.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/eval.js CHANGED
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EPHEMERAL_HOME_KEEP = void 0;
4
+ exports.resolveSpawnEnv = resolveSpawnEnv;
3
5
  exports.runEval = runEval;
4
6
  exports.measureWith = measureWith;
5
7
  exports.measure = measure;
@@ -13,6 +15,12 @@ exports.parseUsage = parseUsage;
13
15
  exports.aggregate = aggregate;
14
16
  exports.aggregateStats = aggregateStats;
15
17
  exports.aggregateUsage = aggregateUsage;
18
+ exports.isDatedModel = isDatedModel;
19
+ exports.modelTier = modelTier;
20
+ exports.belowModelFloor = belowModelFloor;
21
+ exports.harnessVersionKey = harnessVersionKey;
22
+ exports.ephemeralRunEnv = ephemeralRunEnv;
23
+ exports.seedEphemeralHome = seedEphemeralHome;
16
24
  exports.isRateLimited = isRateLimited;
17
25
  exports.runPool = runPool;
18
26
  exports.runEvalWith = runEvalWith;
@@ -23,6 +31,7 @@ exports.stubbedPluginDir = stubbedPluginDir;
23
31
  exports.promptDistance = promptDistance;
24
32
  exports.checkPromptDiversity = checkPromptDiversity;
25
33
  exports.assertPromptDiversity = assertPromptDiversity;
34
+ exports.packageInstallSet = packageInstallSet;
26
35
  exports.measureTriggerRateWith = measureTriggerRateWith;
27
36
  exports.measureTriggerRate = measureTriggerRate;
28
37
  exports.formatTriggerRateReport = formatTriggerRateReport;
@@ -59,6 +68,8 @@ const proofs_js_1 = require("./core/proofs.js");
59
68
  const harness_test_js_1 = require("./harness-test.js");
60
69
  const eval_cache_js_1 = require("./eval-cache.js");
61
70
  const stats_js_1 = require("./stats.js");
71
+ const tool_intercept_js_1 = require("./tool-intercept.js");
72
+ const tool_stub_js_1 = require("./tool-stub.js");
62
73
  function writeFiles(cwd, files) {
63
74
  for (const [p, content] of Object.entries(files)) {
64
75
  const full = (0, node_path_1.resolve)(cwd, p);
@@ -66,6 +77,19 @@ function writeFiles(cwd, files) {
66
77
  (0, node_fs_1.writeFileSync)(full, content);
67
78
  }
68
79
  }
80
+ /**
81
+ * Resolve the environment a trial's subprocess actually runs with — the
82
+ * SECURITY-CRITICAL decision behind `ephemeralEnv`. When `replaceEnv` is set, the
83
+ * scrubbed `env` is the COMPLETE environment, so the real `$HOME` and inherited
84
+ * secrets are DROPPED; otherwise `env` is an overlay on `base` (byte-identical to
85
+ * the pre-ephemeral behaviour). Extracted from the `v8 ignore`d `spawnAgent` so
86
+ * the one line that enforces the scrub is both unit- and behaviourally-tested — a
87
+ * regression to an always-merge would otherwise silently defeat ephemerality and
88
+ * leak the host environment into an untrusted, model-driven run.
89
+ */
90
+ function resolveSpawnEnv(a, base = process.env) {
91
+ return a.replaceEnv ? (a.env ?? {}) : { ...base, ...a.env };
92
+ }
69
93
  /* v8 ignore start -- real claude subprocess; exercised by bench/, not the unit gate */
70
94
  function spawnAgent(a) {
71
95
  return new Promise((resolvePromise) => {
@@ -91,7 +115,9 @@ function spawnAgent(a) {
91
115
  ];
92
116
  const child = (0, node_child_process_1.spawn)(runtime_js_1.claudeCodeRuntime.agentBinary, args, {
93
117
  cwd: a.cwd,
94
- env: process.env,
118
+ // The security-critical env resolution (overlay vs. scrubbed replacement)
119
+ // lives in the tested `resolveSpawnEnv` seam above, not inline here.
120
+ env: resolveSpawnEnv(a),
95
121
  stdio: ["ignore", "pipe", "pipe"],
96
122
  });
97
123
  let stdout = "";
@@ -124,6 +150,14 @@ async function runEval(spec) {
124
150
  async function measureWith(spec, runner) {
125
151
  if (spec.stubSkillBodies && !spec.pluginDir)
126
152
  throw new Error("measure: `stubSkillBodies` requires `pluginDir`.");
153
+ // stubSkillBodies replaces each skill BODY with a no-op (the run stops at
154
+ // selection), so there is no output to grade — a `judged` check would score an
155
+ // empty body and mislead. The docs warn against this pairing; enforce it.
156
+ if (spec.stubSkillBodies &&
157
+ spec.checks.some((c) => c.toJSON().kind === "judged"))
158
+ throw new Error("measure: `stubSkillBodies` is for firing/`skill()` checks only — it stubs " +
159
+ "the skill body, so there's no output for a `judged` check to grade. Drop " +
160
+ "`stubSkillBodies`, or remove the `judged` check.");
127
161
  const stubbed = spec.stubSkillBodies
128
162
  ? stubbedPluginDir(spec.pluginDir)
129
163
  : undefined;
@@ -137,6 +171,7 @@ async function measureWith(spec, runner) {
137
171
  settings: spec.settings,
138
172
  plugin: spec.plugin,
139
173
  pluginDir,
174
+ interceptTools: spec.interceptTools,
140
175
  },
141
176
  },
142
177
  task: spec.task,
@@ -273,16 +308,37 @@ function formatCheckReport(report) {
273
308
  return lines.join("\n");
274
309
  }
275
310
  /**
276
- * The scored gate (Phase 4): throw if any check's measured rate is below `min` —
277
- * the `measure` counterpart to `assertChecks` (strict). Reads the rate, not a
278
- * single run, so it never trips on one noisy trial.
311
+ * The min rate a check must clear: its per-KIND override in `per`, else the
312
+ * default `min`. Shared by `assertRates` (the throwing gate) and
313
+ * `checkReportToJUnit` (the XML report) so the two never diverge on thresholds.
314
+ */
315
+ function checkRateThreshold(check, min, per) {
316
+ return per?.[check.kind] ?? min;
317
+ }
318
+ /**
319
+ * The scored gate (Phase 4): throw if any check's measured rate is below its
320
+ * threshold — the `measure` counterpart to `assertChecks` (strict). Reads the
321
+ * rate, not a single run, so it never trips on one noisy trial.
322
+ *
323
+ * `min` is the default threshold for every check. `per` overrides it for a check
324
+ * KIND (e.g. `{ min: 0.8, per: { skill: 1.0 } }` — "every check ≥ 80%, but the
325
+ * skill must FIRE on every trial"), so a strict firing/safety check and a
326
+ * lenient quality check gate in one call — the single-skill absolute oracle
327
+ * (`measure({ checks: [skill(), judged()] }) + assertRates`) needs exactly this.
279
328
  */
280
329
  function assertRates(report, opts) {
281
- const below = report.perCheck.filter((c) => c.rate < opts.min);
330
+ // An empty report gates nothing a green that tested NOTHING (the silent-pass
331
+ // the no-silent-skips rule forbids). Fail loudly instead of vacuously passing.
332
+ if (report.perCheck.length === 0) {
333
+ throw new Error("assertRates: the report has no checks to gate — did you call " +
334
+ "`measure({ checks: [...] })` with an empty list? An empty gate is a silent pass.");
335
+ }
336
+ const thresholdFor = (c) => checkRateThreshold(c.check, opts.min, opts.per);
337
+ const below = report.perCheck.filter((c) => c.rate < thresholdFor(c));
282
338
  if (below.length > 0) {
283
- throw new Error(`${String(below.length)} check(s) below the ${(opts.min * 100).toFixed(0)}% min rate:\n` +
339
+ throw new Error(`${String(below.length)} check(s) below their min rate:\n` +
284
340
  below
285
- .map((c) => ` ✗ ${checkLabel(c.check)}: ${(c.rate * 100).toFixed(0)}% ± ${(c.se * 100).toFixed(0)}%`)
341
+ .map((c) => ` ✗ ${checkLabel(c.check)}: ${(c.rate * 100).toFixed(0)}% ± ${(c.se * 100).toFixed(0)}% (min ${(thresholdFor(c) * 100).toFixed(0)}%)`)
286
342
  .join("\n"));
287
343
  }
288
344
  }
@@ -295,18 +351,21 @@ function escapeXml(s) {
295
351
  }
296
352
  /**
297
353
  * Serialize a {@link CheckReport} to JUnit XML (Phase 4) — each check a
298
- * `<testcase>`, failing when its rate is below `min`. Because a check is *data*,
299
- * this falls out for free: CI test reporters, regression baselines, and a
300
- * promptfoo bridge all consume the same shape.
354
+ * `<testcase>`, failing when its rate is below its threshold. `min` is the
355
+ * default; `per` overrides it by check KIND, matching `assertRates` exactly (one
356
+ * shared threshold helper) so the gate and the report can never disagree about
357
+ * which checks failed. Because a check is *data*, this falls out for free: CI
358
+ * test reporters, regression baselines, and a promptfoo bridge all consume it.
301
359
  */
302
360
  function checkReportToJUnit(report, opts = {}) {
303
361
  const min = opts.min ?? 0;
304
- const failures = report.perCheck.filter((c) => c.rate < min).length;
362
+ const thr = (c) => checkRateThreshold(c.check, min, opts.per);
363
+ const failures = report.perCheck.filter((c) => c.rate < thr(c)).length;
305
364
  const cases = report.perCheck
306
365
  .map((c) => {
307
366
  const name = escapeXml(checkLabel(c.check));
308
- const body = c.rate < min
309
- ? `\n <failure message="rate ${(c.rate * 100).toFixed(0)}% below min ${(min * 100).toFixed(0)}% (n=${String(c.n)})"/>\n `
367
+ const body = c.rate < thr(c)
368
+ ? `\n <failure message="rate ${(c.rate * 100).toFixed(0)}% below min ${(thr(c) * 100).toFixed(0)}% (n=${String(c.n)})"/>\n `
310
369
  : "";
311
370
  return ` <testcase classname="vigiles.checks" name="${name}">${body}</testcase>`;
312
371
  })
@@ -325,6 +384,8 @@ function usageFrom(result) {
325
384
  durationMs: num(result?.duration_ms),
326
385
  inputTokens: num(usage.input_tokens),
327
386
  outputTokens: num(usage.output_tokens),
387
+ cacheCreationTokens: num(usage.cache_creation_input_tokens),
388
+ cacheReadTokens: num(usage.cache_read_input_tokens),
328
389
  };
329
390
  }
330
391
  /** Parse per-run cost/latency/tokens from a stream — pure, model-free. */
@@ -424,6 +485,8 @@ function aggregateUsage(usages) {
424
485
  meanDurationMs: n > 0 ? sum((u) => u.durationMs) / n : 0,
425
486
  totalInputTokens: sum((u) => u.inputTokens),
426
487
  totalOutputTokens: sum((u) => u.outputTokens),
488
+ totalCacheCreationTokens: sum((u) => u.cacheCreationTokens),
489
+ totalCacheReadTokens: sum((u) => u.cacheReadTokens),
427
490
  };
428
491
  }
429
492
  /**
@@ -441,6 +504,13 @@ async function runWithCache(runArgs, keyParts, runner, cfg) {
441
504
  tools: runArgs.tools,
442
505
  files: keyParts.files,
443
506
  settings: keyParts.settings,
507
+ env: runArgs.env,
508
+ // A native --plugin-dir install isn't in `files`, so hash its CONTENTS into
509
+ // the key — otherwise editing a skill in it would false-replay.
510
+ pluginDirHash: runArgs.pluginDir ? (0, eval_cache_js_1.hashDir)(runArgs.pluginDir) : undefined,
511
+ // The harness binary evolves fast; a CLI upgrade must invalidate (stale
512
+ // replay otherwise serves a result from a different system prompt).
513
+ harnessVersion: harnessVersion(),
444
514
  trialIndex: keyParts.trialIndex,
445
515
  });
446
516
  const hit = (0, eval_cache_js_1.readCache)(cfg.cacheDir, key);
@@ -454,15 +524,313 @@ async function runWithCache(runArgs, keyParts, runner, cfg) {
454
524
  }
455
525
  return out;
456
526
  }
527
+ /**
528
+ * The `vigiles intercept-tool-hook` command, as an absolute `node <cli> …`
529
+ * invocation — the eval runs in a throwaway cwd where `npx vigiles` wouldn't
530
+ * resolve, so the auto-wired PreToolUse hook must point at this CLI's own `cli.js`
531
+ * (resolved from `__dirname`, the same way `run-hook.ts`/`sandbox.ts` locate their
532
+ * entries).
533
+ */
534
+ const INTERCEPT_TOOL_HOOK_CLI = [(0, node_path_1.join)(__dirname, "cli.js"), (0, node_path_1.join)(__dirname, "..", "dist", "cli.js")].find((p) => (0, node_fs_1.existsSync)(p)) ?? (0, node_path_1.join)(__dirname, "cli.js");
535
+ const INTERCEPT_TOOL_HOOK_CMD = `"${process.execPath}" "${INTERCEPT_TOOL_HOOK_CLI}" intercept-tool-hook`;
536
+ function isRecord(v) {
537
+ return v !== null && typeof v === "object";
538
+ }
539
+ /**
540
+ * A model id is "dated" (honestly pinned) when it ends in an 8-digit date stamp,
541
+ * e.g. `claude-haiku-4-5-20251001`. A floating alias (`haiku`, `sonnet`, or even
542
+ * `claude-sonnet-4-6` with no date) can change underneath you — so a cached or
543
+ * baselined result pinned to it can silently hide model drift. See
544
+ * `docs/eval-architecture.md` (honest model pinning).
545
+ */
546
+ function isDatedModel(model) {
547
+ return /\d{8}$/.test(model);
548
+ }
549
+ /**
550
+ * Capability tier of a model by FAMILY: haiku=1 < sonnet=2 < opus=3 (version is
551
+ * ignored, so `claude-sonnet-4-6` and a dated Sonnet rank equal). An unrecognized
552
+ * family returns `null` — unrankable, so the floor never blocks a model we can't
553
+ * judge (fail-open on ranking). Used by the model floor; aliases and full/dated
554
+ * ids both work.
555
+ */
556
+ function modelTier(id) {
557
+ const s = id.toLowerCase();
558
+ if (s.includes("haiku"))
559
+ return 1;
560
+ if (s.includes("sonnet"))
561
+ return 2;
562
+ if (s.includes("opus"))
563
+ return 3;
564
+ return null;
565
+ }
566
+ /**
567
+ * Is `model` a weaker tier than `floor`? Both must be rankable (see
568
+ * {@link modelTier}); an unrankable model/floor is never "below" (fail-open).
569
+ */
570
+ function belowModelFloor(model, floor) {
571
+ const m = modelTier(model);
572
+ const f = modelTier(floor);
573
+ return m !== null && f !== null && m < f;
574
+ }
575
+ /**
576
+ * Reduce a raw `--version` string to the **major.minor** cache-key token. We key
577
+ * the cache on major.minor, NOT the patch: a patch release rarely changes agent
578
+ * behaviour, so keying patches would churn the cache on every release for no
579
+ * signal; a minor/major bump is where the system prompt / tool defs actually move.
580
+ * (If a specific patch is known to matter, clear the cache or bump
581
+ * `CACHE_FORMAT_VERSION`.) Falls back to the trimmed raw string when no semver is
582
+ * found. Pure + tested.
583
+ */
584
+ function harnessVersionKey(raw) {
585
+ const m = /(\d+)\.(\d+)\.\d+/.exec(raw);
586
+ return m ? `${m[1]}.${m[2]}` : raw.trim();
587
+ }
588
+ /* v8 ignore start -- spawns the real harness binary; memoized, cache-path only */
589
+ let cachedHarnessVersion;
590
+ /**
591
+ * The harness binary version (`claude --version`) reduced to major.minor, for the
592
+ * cache key — so a CLI **minor/major** upgrade (new system prompt / tool defs)
593
+ * invalidates a stale replay, while patches don't churn it. Memoized (one spawn
594
+ * per process), resolved only on the cache path, "unknown" if the binary isn't
595
+ * found (then it doesn't partition the key).
596
+ */
597
+ function harnessVersion() {
598
+ if (cachedHarnessVersion === undefined) {
599
+ try {
600
+ cachedHarnessVersion = harnessVersionKey((0, node_child_process_1.execSync)(`${runtime_js_1.claudeCodeRuntime.agentBinary} --version`, {
601
+ encoding: "utf-8",
602
+ stdio: ["ignore", "pipe", "ignore"],
603
+ }));
604
+ }
605
+ catch {
606
+ cachedHarnessVersion = "unknown";
607
+ }
608
+ }
609
+ return cachedHarnessVersion;
610
+ }
611
+ /* v8 ignore stop */
612
+ /** Warn (once per run) that replaying/recording a cache on a floating alias hides drift. */
613
+ function warnFloatingModel(model) {
614
+ const msg = `vigiles: eval cache is on but the model "${model}" is a floating alias — ` +
615
+ `a replay can serve a result computed against a since-changed model, hiding ` +
616
+ `drift. Pin a dated id (e.g. ...-20251001) for honest replay.`;
617
+ if (process.env.GITHUB_ACTIONS)
618
+ console.log(`::warning::${msg}`);
619
+ else
620
+ console.warn(msg);
621
+ }
622
+ /**
623
+ * Merge the tool-intercept PreToolUse hook into an arm's resolved settings
624
+ * (appending to any existing `PreToolUse` list). Returns the settings unchanged
625
+ * when there are no intercepts. The intercept list itself rides the
626
+ * `VIGILES_INTERCEPT_TOOLS` env, not the settings — see {@link executeTrial}.
627
+ */
628
+ function withInterceptToolHook(settings, intercepts) {
629
+ if (intercepts.length === 0)
630
+ return settings;
631
+ const intercept = (0, tool_intercept_js_1.buildInterceptSettings)(intercepts, {
632
+ command: INTERCEPT_TOOL_HOOK_CMD,
633
+ });
634
+ const base = isRecord(settings) ? settings : {};
635
+ const baseHooks = isRecord(base.hooks) ? base.hooks : {};
636
+ const basePre = Array.isArray(baseHooks.PreToolUse)
637
+ ? baseHooks.PreToolUse
638
+ : [];
639
+ return {
640
+ ...base,
641
+ hooks: {
642
+ ...baseHooks,
643
+ PreToolUse: [...basePre, ...intercept.hooks.PreToolUse],
644
+ },
645
+ };
646
+ }
647
+ /**
648
+ * The default allowlist {@link ephemeralRunEnv} passes through from the real
649
+ * environment. Two groups, both load-bearing for a real-model `claude` run:
650
+ *
651
+ * - **Auth** — the harness's OWN credentials. The eval drives the real `claude`
652
+ * CLI, which authenticates via the user's subscription (`~/.claude`, reached
653
+ * through the fresh HOME's allowed config — see below) OR via these env vars.
654
+ * We mirror the auth surface the runtime port already names
655
+ * (`ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`, see
656
+ * `adapters/claude-code/runtime.ts`) plus the OAuth/token + region variants the
657
+ * CLI accepts, so a token-authed user is not broken by a too-narrow list.
658
+ * - **Runtime** — what any spawned process needs to *function*: `PATH` (resolve
659
+ * `node` / `claude`), the locale/terminal vars (`LANG` / `LC_*` / `TERM`), and
660
+ * `TMPDIR` (which we override to the fresh HOME). Mirrors what `bwrapArgs` /
661
+ * `setenvArgs` in `src/sandbox.ts` set back after `--clearenv`.
662
+ *
663
+ * Notably it does NOT pass through `GIT_*`, `GH_TOKEN`, `SSH_*`, `AWS_*`, or any
664
+ * other non-allowlisted secret-shaped var — those are exactly what an ephemeral
665
+ * run must not see. `CLAUDE_*` is allowlisted by prefix because the CLI reads
666
+ * several `CLAUDE_*` knobs (config dir, etc.) and omitting one is the failure
667
+ * mode this whole guard is conservative against.
668
+ *
669
+ * Conservative by design: a too-broad allowlist is safe (it just leaks a benign
670
+ * var); a too-narrow one silently breaks auth — which is why the feature ships
671
+ * default-OFF until validated against a real run.
672
+ */
673
+ const EPHEMERAL_ALLOW = [
674
+ // Runtime essentials (mirror sandbox.ts setenv-after-clearenv).
675
+ "PATH",
676
+ "LANG",
677
+ "TERM",
678
+ // Anthropic / Claude Code auth + endpoint (mirror runtime.ts + CLI auth vars).
679
+ runtime_js_1.claudeCodeRuntime.modelApiKeyEnv, // ANTHROPIC_API_KEY
680
+ runtime_js_1.claudeCodeRuntime.modelBaseUrlEnv, // ANTHROPIC_BASE_URL
681
+ "ANTHROPIC_AUTH_TOKEN",
682
+ "ANTHROPIC_API_URL",
683
+ "ANTHROPIC_MODEL",
684
+ "ANTHROPIC_DEFAULT_HEADERS",
685
+ "CLAUDE_CODE_USE_BEDROCK",
686
+ "CLAUDE_CODE_USE_VERTEX",
687
+ // Cloud-provider auth the CLI uses for Bedrock/Vertex backends (region/profile
688
+ // only — NOT the secret-shaped AWS_* access keys, which stay dropped).
689
+ "AWS_REGION",
690
+ "AWS_DEFAULT_REGION",
691
+ "AWS_PROFILE",
692
+ "CLOUD_ML_REGION",
693
+ "GOOGLE_CLOUD_PROJECT",
694
+ "GOOGLE_APPLICATION_CREDENTIALS",
695
+ ];
696
+ /** Prefixes passed through wholesale — the CLI reads several `CLAUDE_*` knobs and
697
+ * a `LC_*` locale family; allowlist by prefix so omitting one isn't the silent
698
+ * auth/locale break this guard exists to avoid. */
699
+ const EPHEMERAL_ALLOW_PREFIXES = ["CLAUDE_", "LC_"];
700
+ /**
701
+ * Build an **ephemeral run environment** for a model-driven run: a NEW env object
702
+ * with a *fresh* `HOME` (and `TMPDIR`) pointed at the throwaway `opts.home`, only
703
+ * an allowlist of auth + runtime vars passed through from `base`, and everything
704
+ * else DROPPED. Pure — no fs, no spawn.
705
+ *
706
+ * The rationale is "fresh HOME + only the harness credential injected, **not** a
707
+ * blanket wipe": running a model-driven skill/agent is itself a side effect (the
708
+ * *model*, not the author, chose the actions), so it should not be able to read
709
+ * the real `~/.gitconfig` / `~/.ssh` / `~/.aws` or write to the real `~`. But the
710
+ * real `claude` CLI must still AUTHENTICATE, so the harness's own credentials
711
+ * ({@link EPHEMERAL_ALLOW} — `ANTHROPIC_*`, `CLAUDE_*`, locale/PATH) are
712
+ * re-injected; a blanket `--clearenv`-style wipe would break every eval. Because
713
+ * this needs no kernel features, it is the cross-platform STATE-protection floor
714
+ * (lands on macOS immediately), orthogonal to the Linux bubblewrap HOST
715
+ * confinement in `src/sandbox.ts`.
716
+ *
717
+ * @param base the source environment to filter (usually `process.env`).
718
+ * @param opts.home the throwaway dir to set as `HOME`/`TMPDIR`.
719
+ * @param opts.allow extra var NAMES to pass through (e.g. the `VIGILES_*` keys
720
+ * the eval already injects). Layered ON TOP of the default allowlist.
721
+ */
722
+ function ephemeralRunEnv(base, opts) {
723
+ const out = {};
724
+ const allowExact = new Set([
725
+ ...EPHEMERAL_ALLOW,
726
+ ...(opts.allow ?? []),
727
+ ]);
728
+ for (const [k, v] of Object.entries(base)) {
729
+ if (v === undefined)
730
+ continue;
731
+ const allowed = allowExact.has(k) ||
732
+ EPHEMERAL_ALLOW_PREFIXES.some((p) => k.startsWith(p));
733
+ if (allowed)
734
+ out[k] = v;
735
+ }
736
+ // Fresh HOME + TMPDIR last so they always win over anything passed through.
737
+ out.HOME = opts.home;
738
+ out.TMPDIR = opts.home;
739
+ return out;
740
+ }
741
+ /**
742
+ * Home-relative AUTH files to carry from the real HOME into the throwaway one.
743
+ *
744
+ * A local subscription credential (OAuth token) often lives in a FILE under HOME,
745
+ * not an env var — so scrubbing HOME would lose it and break a local-authed run.
746
+ * This is the file half of the auth allowlist; {@link EPHEMERAL_ALLOW} covers the
747
+ * env-var / host-brokered half. Kept a named constant so it's easy to extend, and
748
+ * deliberately NARROW — only the explicit auth files, never `.gitconfig` / `.ssh`
749
+ * / `.aws`, which are exactly what an ephemeral run must not see.
750
+ */
751
+ exports.EPHEMERAL_HOME_KEEP = [
752
+ ".claude/.credentials.json", // the Claude Code OAuth token
753
+ ];
754
+ /**
755
+ * Seed the throwaway HOME with the harness's own auth FILE(s) — best-effort;
756
+ * covers local file-based OAuth; the env-var/host-brokered path is covered by the
757
+ * allowlist in {@link ephemeralRunEnv}.
758
+ *
759
+ * COPIES (never symlinks) each {@link EPHEMERAL_HOME_KEEP} path from `realHome`
760
+ * into `throwawayHome`, creating parent dirs as needed; a symlink would let the
761
+ * model-driven run write back to the real credential file, defeating ephemerality.
762
+ * A path that doesn't exist in the real HOME is skipped silently (that user auths
763
+ * via env-var / host broker instead). Pure fs — no env, no spawn.
764
+ */
765
+ function seedEphemeralHome(throwawayHome, realHome, keep = exports.EPHEMERAL_HOME_KEEP) {
766
+ for (const rel of keep) {
767
+ const src = (0, node_path_1.join)(realHome, rel);
768
+ if (!(0, node_fs_1.existsSync)(src))
769
+ continue; // env-var / host-brokered auth covers this.
770
+ const dest = (0, node_path_1.join)(throwawayHome, rel);
771
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(dest), { recursive: true });
772
+ (0, node_fs_1.cpSync)(src, dest); // copy, not symlink — keep the real credential read-only.
773
+ }
774
+ }
457
775
  /** Execute one trial in a fresh sandbox; returns its metric row + usage. */
458
776
  async function executeTrial(spec, arm, trialIndex, runner, cfg) {
459
777
  const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-eval-"));
460
778
  try {
461
- const { files, settings } = (0, plugin_loader_js_1.resolveHarness)({
779
+ const resolved = (0, plugin_loader_js_1.resolveHarness)({
462
780
  plugin: arm.plugin,
463
781
  settings: arm.settings,
464
782
  files: { ...spec.fixture, ...arm.files },
465
783
  });
784
+ const { files } = resolved;
785
+ const intercepts = arm.interceptTools ?? [];
786
+ const settings = withInterceptToolHook(resolved.settings, intercepts);
787
+ // The eval-injected overlay (VIGILES_INTERCEPT_TOOLS), if any.
788
+ const overlay = intercepts.length > 0
789
+ ? { [tool_intercept_js_1.INTERCEPT_TOOLS_ENV]: (0, tool_intercept_js_1.serializeIntercepts)(intercepts) }
790
+ : undefined;
791
+ // Opt-in (default OFF): an ephemeral run env — a throwaway HOME under the
792
+ // trial's own temp cwd + a scrubbed, auth-only allowlist. The eval's injected
793
+ // keys (e.g. VIGILES_INTERCEPT_TOOLS) are allowlisted through so interception
794
+ // still works. When OFF, `env`/`replaceEnv` are exactly as before.
795
+ // Tool stubs on PATH (rung R2): write the fake binaries into a bin dir under
796
+ // this trial's cwd; it is PREPENDED to whatever PATH the run uses below, so
797
+ // the fakes win over the real binaries. Absent → no PATH change.
798
+ const stubs = spec.stubs ?? [];
799
+ const stubDir = stubs.length > 0
800
+ ? (0, tool_stub_js_1.stubBinDir)(stubs, (0, node_path_1.join)(cwd, ".vigiles-stubs"))
801
+ : undefined;
802
+ const prependPath = (path) => stubDir === undefined
803
+ ? (path ?? "")
804
+ : path === undefined || path === ""
805
+ ? stubDir
806
+ : `${stubDir}${node_path_1.delimiter}${path}`;
807
+ const ephemeral = spec.ephemeralEnv === true;
808
+ let env;
809
+ let replaceEnv = false;
810
+ if (ephemeral) {
811
+ const home = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)(cwd, "home-"));
812
+ // Carry the harness's own auth FILE (local OAuth) into the fresh HOME —
813
+ // env-var/host-brokered auth is covered by ephemeralRunEnv's allowlist.
814
+ seedEphemeralHome(home, process.env.HOME ?? (0, node_os_1.homedir)());
815
+ env = ephemeralRunEnv(process.env, {
816
+ home,
817
+ allow: overlay ? Object.keys(overlay) : [],
818
+ });
819
+ if (overlay)
820
+ Object.assign(env, overlay);
821
+ // ephemeralRunEnv passes PATH through; prepend the stub dir over it.
822
+ if (stubDir !== undefined)
823
+ env.PATH = prependPath(env.PATH);
824
+ replaceEnv = true;
825
+ }
826
+ else {
827
+ // Legacy overlay path: `spawnAgent` spreads `{ ...process.env, ...env }`, so
828
+ // set PATH in the overlay to the stub dir prepended over process.env.PATH.
829
+ env =
830
+ stubDir !== undefined
831
+ ? { ...overlay, PATH: prependPath(process.env.PATH) }
832
+ : overlay;
833
+ }
466
834
  writeFiles(cwd, files);
467
835
  const hasSettings = settings !== undefined;
468
836
  if (hasSettings) {
@@ -471,11 +839,14 @@ async function executeTrial(spec, arm, trialIndex, runner, cfg) {
471
839
  const out = await runWithCache({
472
840
  task: spec.task,
473
841
  cwd,
474
- model: cfg.model,
842
+ // A model comparison is a harness A/B: an arm may override the model.
843
+ model: arm.model ?? cfg.model,
475
844
  tools: cfg.tools,
476
845
  hasSettings,
477
846
  pluginDir: arm.pluginDir,
478
847
  timeoutMs: cfg.timeoutMs,
848
+ env,
849
+ replaceEnv,
479
850
  }, { files, settings, trialIndex }, runner, cfg);
480
851
  const ctx = makeContext(cwd, out);
481
852
  return { row: spec.measure(ctx), usage: ctx.usage };
@@ -566,6 +937,9 @@ async function runEvalWith(spec, runner) {
566
937
  cache: spec.cache ?? "off",
567
938
  cacheDir: spec.cacheDir ?? (0, node_path_1.resolve)(process.cwd(), ".vigiles", "eval-cache"),
568
939
  };
940
+ if (cfg.cache !== "off" && !isDatedModel(cfg.model)) {
941
+ warnFloatingModel(cfg.model);
942
+ }
569
943
  const retrying = (a) => runWithRetry(a, runner, retries, backoffMs);
570
944
  const units = buildUnits(spec.arms, trials);
571
945
  let spent = 0;
@@ -773,23 +1147,141 @@ function assertPromptDiversity(prompts, opts = {}) {
773
1147
  * be set. `packaged` is present only when vigiles built it, so the caller knows
774
1148
  * to remove it afterward.
775
1149
  */
1150
+ /** The dir holding `<name>/SKILL.md` for a source that may be a plugin (`skills/`
1151
+ * or `.claude/skills/`) or already a loose skills dir. */
1152
+ function collectSkillsSource(dir) {
1153
+ const abs = (0, node_path_1.resolve)(dir);
1154
+ const pluginSkills = (0, node_path_1.join)(abs, "skills");
1155
+ if ((0, node_fs_1.existsSync)(pluginSkills))
1156
+ return pluginSkills;
1157
+ const ccSkills = (0, node_path_1.join)(abs, ".claude", "skills");
1158
+ if ((0, node_fs_1.existsSync)(ccSkills))
1159
+ return ccSkills;
1160
+ return abs;
1161
+ }
1162
+ /**
1163
+ * Copy each `<name>/SKILL.md` skill from `src` into `skillsOut` (stubbing the body
1164
+ * when asked); skip a skill whose name is already `present` so the under-test
1165
+ * skill wins a collision. Returns how many were newly copied.
1166
+ */
1167
+ function copySkillsInto(src, skillsOut, stub, present) {
1168
+ const abs = (0, node_path_1.resolve)(src);
1169
+ if (!(0, node_fs_1.existsSync)(abs))
1170
+ throw new Error(`installSet source not found: ${src} (resolved ${abs})`);
1171
+ let copied = 0;
1172
+ for (const entry of (0, node_fs_1.readdirSync)(abs, { withFileTypes: true })) {
1173
+ if (!entry.isDirectory() || present.has(entry.name))
1174
+ continue;
1175
+ const srcSkill = (0, node_path_1.join)(abs, entry.name, "SKILL.md");
1176
+ if (!(0, node_fs_1.existsSync)(srcSkill))
1177
+ continue;
1178
+ const destDir = (0, node_path_1.join)(skillsOut, entry.name);
1179
+ if (stub) {
1180
+ (0, node_fs_1.mkdirSync)(destDir, { recursive: true });
1181
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(destDir, "SKILL.md"), stubSkillBody((0, node_fs_1.readFileSync)(srcSkill, "utf-8")));
1182
+ }
1183
+ else {
1184
+ (0, node_fs_1.cpSync)((0, node_path_1.join)(abs, entry.name), destDir, { recursive: true });
1185
+ }
1186
+ present.add(entry.name);
1187
+ copied++;
1188
+ }
1189
+ return copied;
1190
+ }
1191
+ /**
1192
+ * Build a combined plugin: the under-test skills PLUS every `installSet` source's
1193
+ * skills, so the skill-under-test competes for selection as in the real harness.
1194
+ * Named after the under-test plugin so `<name>:<skill>` ids still match; the
1195
+ * under-test skills win a name collision. Returns the dir + `added` = how many
1196
+ * installSet skills were merged in (excludes collisions). The report's
1197
+ * `competitors` is derived separately from the FULL pool (see `countSkills`), so
1198
+ * sibling skills already in the under-test source count too. Caller removes the
1199
+ * dir. Pure (filesystem only).
1200
+ */
1201
+ function packageInstallSet(opts) {
1202
+ const root = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-harness-"));
1203
+ try {
1204
+ (0, node_fs_1.mkdirSync)((0, node_path_1.join)(root, ".claude-plugin"), { recursive: true });
1205
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(root, ".claude-plugin", "plugin.json"), JSON.stringify({ name: opts.name, version: "0.0.0" }, null, 2));
1206
+ const skillsOut = (0, node_path_1.join)(root, "skills");
1207
+ (0, node_fs_1.mkdirSync)(skillsOut, { recursive: true });
1208
+ const present = new Set();
1209
+ const underTest = copySkillsInto(opts.underTestSrc, skillsOut, opts.stub, present);
1210
+ if (underTest === 0)
1211
+ throw new Error(`No <name>/SKILL.md skills under the skill-under-test source ${opts.underTestSrc}`);
1212
+ let added = 0;
1213
+ for (const src of opts.installSet)
1214
+ added += copySkillsInto(collectSkillsSource(src), skillsOut, opts.stub, present);
1215
+ return { dir: root, added };
1216
+ }
1217
+ catch (e) {
1218
+ (0, node_fs_1.rmSync)(root, { recursive: true, force: true }); // don't leak the temp dir
1219
+ throw e;
1220
+ }
1221
+ }
1222
+ /** The under-test skills source + plugin name (the namespace `fired` matches). */
1223
+ function underTestSource(spec) {
1224
+ if (spec.skillsDir)
1225
+ return { src: spec.skillsDir, name: "vigiles-loose-skills" };
1226
+ if (spec.pluginDir)
1227
+ return {
1228
+ src: skillsDirOf(spec.pluginDir),
1229
+ name: pluginName(spec.pluginDir) ?? "vigiles-loose-skills",
1230
+ };
1231
+ throw new Error("measureTriggerRate: provide `pluginDir` or `skillsDir`.");
1232
+ }
1233
+ /** Number of `<name>/SKILL.md` skills installed in a plugin — the selection pool. */
1234
+ function countSkills(pluginDir) {
1235
+ const dir = skillsDirOf(pluginDir);
1236
+ if (!(0, node_fs_1.existsSync)(dir))
1237
+ return 0;
1238
+ let n = 0;
1239
+ for (const e of (0, node_fs_1.readdirSync)(dir, { withFileTypes: true }))
1240
+ if (e.isDirectory() && (0, node_fs_1.existsSync)((0, node_path_1.join)(dir, e.name, "SKILL.md")))
1241
+ n++;
1242
+ return n;
1243
+ }
776
1244
  function resolveTriggerPluginDir(spec) {
777
1245
  if (spec.pluginDir && spec.skillsDir)
778
1246
  throw new Error("measureTriggerRate: set `pluginDir` OR `skillsDir`, not both.");
779
- const stub = spec.stubSkillBodies ?? false;
780
- if (spec.skillsDir) {
781
- const packaged = packageSkillsDir(spec.skillsDir, { stub });
782
- return { pluginDir: packaged, packaged };
783
- }
784
- if (spec.pluginDir) {
785
- if (!stub)
786
- return { pluginDir: spec.pluginDir };
1247
+ const stub = spec.stubSkillBodies ?? true; // trigger = frontmatter; body never needed
1248
+ const installSet = spec.installSet ?? [];
1249
+ let pluginDir;
1250
+ let packaged;
1251
+ if (installSet.length > 0) {
1252
+ // Whole-harness tier: merge the under-test skills with the install set so
1253
+ // selection is competitive (the realistic, differentiated measurement).
1254
+ const { src, name } = underTestSource(spec);
1255
+ ({ dir: pluginDir } = packageInstallSet({
1256
+ underTestSrc: src,
1257
+ name,
1258
+ installSet,
1259
+ stub,
1260
+ }));
1261
+ packaged = pluginDir;
1262
+ }
1263
+ else if (spec.skillsDir) {
1264
+ packaged = packageSkillsDir(spec.skillsDir, { stub });
1265
+ pluginDir = packaged;
1266
+ }
1267
+ else if (spec.pluginDir) {
787
1268
  // Stub a real plugin: build a minimal plugin from its skills/ with bodies
788
1269
  // stripped — keep the original plugin NAME so `<name>:<skill>` still matches.
789
- const packaged = stubbedPluginDir(spec.pluginDir);
790
- return { pluginDir: packaged, packaged };
1270
+ packaged = stub ? stubbedPluginDir(spec.pluginDir) : undefined;
1271
+ pluginDir = packaged ?? spec.pluginDir;
791
1272
  }
792
- throw new Error("measureTriggerRate: provide `pluginDir` or `skillsDir`.");
1273
+ else {
1274
+ throw new Error("measureTriggerRate: provide `pluginDir` or `skillsDir`.");
1275
+ }
1276
+ // `competitors` is the REAL selection pressure: every OTHER skill installed in
1277
+ // the resolved plugin (siblings already in the source + any installSet), not
1278
+ // just the installSet delta — so a multi-skill plugin is never mislabeled
1279
+ // "isolated". `max(0, …)` guards a 0-skill pool.
1280
+ return {
1281
+ pluginDir,
1282
+ packaged,
1283
+ competitors: Math.max(0, countSkills(pluginDir) - 1),
1284
+ };
793
1285
  }
794
1286
  /** Run one prompt set × trials through `runner`, aggregating fired counts. */
795
1287
  async function runTriggerSet(prompts, cfg, runner) {
@@ -851,10 +1343,23 @@ async function measureTriggerRateWith(spec, runner) {
851
1343
  label: "irrelevantPrompts",
852
1344
  });
853
1345
  }
854
- const { pluginDir, packaged } = resolveTriggerPluginDir(spec);
1346
+ // Model floor (default Sonnet): trigger-rate under-measures selection on a
1347
+ // weaker model, so FAIL before spending a token rather than report a
1348
+ // false-negative recall. The floor lives in the spec (`minModel`), not an env
1349
+ // override — model choice is part of the measurement definition. Lower it
1350
+ // deliberately for a cheap run.
1351
+ const model = spec.model ?? "sonnet";
1352
+ const minModel = spec.minModel ?? "sonnet";
1353
+ if (belowModelFloor(model, minModel))
1354
+ throw new Error(`measureTriggerRate: model "${model}" is below the minimum "${minModel}" — ` +
1355
+ "trigger-rate under-measures selection on a weaker model " +
1356
+ "(raise the model, or lower `minModel` for a deliberately cheap run).");
1357
+ const { pluginDir, packaged, competitors } = resolveTriggerPluginDir(spec);
855
1358
  const cfg = {
856
1359
  trials: spec.trials ?? 1,
857
- model: spec.model ?? "haiku",
1360
+ // Sonnet, not haiku: trigger-rate is a selection measurement and haiku
1361
+ // under-selects, producing false-negative recall (see TriggerRateSpec.model).
1362
+ model,
858
1363
  tools: spec.allowedTools ?? ["Read", "Edit", "Write", "Bash", "Skill"],
859
1364
  timeoutMs: spec.timeoutMs ?? 240000,
860
1365
  spacing: (spec.spacingSec ?? 4) * 1000,
@@ -867,6 +1372,7 @@ async function measureTriggerRateWith(spec, runner) {
867
1372
  rate: relevant.n > 0 ? relevant.fired / relevant.n : 0,
868
1373
  n: relevant.n,
869
1374
  perPrompt: relevant.perPrompt,
1375
+ competitors,
870
1376
  };
871
1377
  if ((spec.irrelevantPrompts?.length ?? 0) === 0)
872
1378
  return base;
@@ -911,6 +1417,12 @@ function formatTriggerRateReport(report) {
911
1417
  lines.push(` ${p.rate.toFixed(2)} [irrelevant] ${p.prompt.slice(0, 48)}`);
912
1418
  }
913
1419
  }
1420
+ // Honest labelling: an isolated run measures the skill alone, with no competing
1421
+ // skills to evict or out-compete its description — so recall is an UPPER bound
1422
+ // and false-positive a LOWER bound. Say so, or point at the whole-harness count.
1423
+ lines.push(report.competitors > 0
1424
+ ? `whole-harness: measured against ${String(report.competitors)} competing skill(s)`
1425
+ : "isolated: no competing skills — recall is an upper bound, false-positive a lower bound (populate `installSet` for a release-gate measurement)");
914
1426
  return lines.join("\n");
915
1427
  }
916
1428
  //# sourceMappingURL=eval.js.map