vigiles 33.1.0 → 33.3.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.
Files changed (66) hide show
  1. package/README.md +3 -2
  2. package/dist/adapter-conformance.js +14 -10
  3. package/dist/adapter.d.ts +3 -1
  4. package/dist/adapters/claude-code/layout.js +3 -0
  5. package/dist/adapters/claude-code/output-style.d.ts +3 -0
  6. package/dist/adapters/claude-code/output-style.js +81 -0
  7. package/dist/adapters/claude-code/replies.d.ts +14 -0
  8. package/dist/adapters/claude-code/replies.js +78 -0
  9. package/dist/adapters/claude-code/runtime.js +70 -0
  10. package/dist/adapters/claude-code/skill-reachability.d.ts +42 -21
  11. package/dist/adapters/claude-code/skill-reachability.js +91 -28
  12. package/dist/audit-report.d.ts +5 -0
  13. package/dist/audit-report.js +29 -20
  14. package/dist/audit-report.template.html +25 -25
  15. package/dist/audit-score.js +2 -2
  16. package/dist/check.d.ts +20 -0
  17. package/dist/check.js +87 -2
  18. package/dist/cli-main.js +249 -30
  19. package/dist/core/adapter.d.ts +7 -0
  20. package/dist/core/config-schema.d.ts +9 -0
  21. package/dist/core/config-schema.js +1 -0
  22. package/dist/core/eval-driver.d.ts +10 -0
  23. package/dist/core/frontmatter-read.d.ts +31 -0
  24. package/dist/core/frontmatter-read.js +4 -2
  25. package/dist/core/harness-driver.d.ts +2 -0
  26. package/dist/core/install-reader.js +15 -0
  27. package/dist/core/layout.d.ts +17 -0
  28. package/dist/core/layout.js +17 -0
  29. package/dist/core/output-style.d.ts +84 -0
  30. package/dist/core/output-style.js +90 -0
  31. package/dist/core/rule-meta.d.ts +1 -1
  32. package/dist/core/rule-meta.js +8 -0
  33. package/dist/core/run-env.d.ts +50 -0
  34. package/dist/core/run-env.js +51 -0
  35. package/dist/core/runtime.d.ts +44 -0
  36. package/dist/core/skill-link-target.d.ts +20 -0
  37. package/dist/core/skill-link-target.js +33 -0
  38. package/dist/core/types.d.ts +2 -0
  39. package/dist/coverage-evidence.d.ts +42 -0
  40. package/dist/coverage-evidence.js +39 -0
  41. package/dist/eval.d.ts +65 -42
  42. package/dist/eval.js +116 -120
  43. package/dist/harness-test.d.ts +65 -1
  44. package/dist/harness-test.js +114 -22
  45. package/dist/optimize.js +1 -5
  46. package/dist/output-style-arms.d.ts +27 -0
  47. package/dist/output-style-arms.js +51 -0
  48. package/dist/plugin-declaration.d.ts +6 -6
  49. package/dist/plugin-declaration.js +6 -6
  50. package/dist/scan-files.js +1 -1
  51. package/dist/scan.d.ts +6 -0
  52. package/dist/scan.js +14 -4
  53. package/dist/score-core.d.ts +3 -0
  54. package/dist/score-core.js +5 -1
  55. package/dist/setup-plan.d.ts +50 -10
  56. package/dist/setup-plan.js +103 -68
  57. package/dist/skill-links.d.ts +183 -0
  58. package/dist/skill-links.js +255 -0
  59. package/dist/test-coverage-files.d.ts +3 -1
  60. package/dist/test-coverage-files.js +20 -6
  61. package/dist/test-coverage.d.ts +18 -1
  62. package/dist/test-coverage.js +101 -15
  63. package/dist/test.d.ts +3 -1
  64. package/dist/test.js +8 -2
  65. package/dist/vigilesrc.schema.json +134 -0
  66. package/package.json +1 -1
package/dist/eval.d.ts CHANGED
@@ -119,7 +119,11 @@ export interface EvalSpec<M extends Metrics> {
119
119
  * the cache, and never read from an env var. Omit for the harness default.
120
120
  */
121
121
  readonly effort?: string | number;
122
- /** Tools the agent may use. Default: Read Edit Write Bash. */
122
+ /**
123
+ * Tools pre-approved for the run (`--allowedTools`, a permission allowlist);
124
+ * the default tools stay available either way. `[]` approves none.
125
+ * Default: Read Edit Write Bash.
126
+ */
123
127
  readonly allowedTools?: readonly string[];
124
128
  /** Per-run timeout ms. Default 240000. */
125
129
  readonly timeoutMs?: number;
@@ -164,8 +168,9 @@ export interface EvalSpec<M extends Metrics> {
164
168
  * **Ships default-OFF** because a too-narrow auth allowlist would silently break
165
169
  * the real `claude` CLI's authentication; leaving it off keeps every existing
166
170
  * eval (including one running right now) authenticating exactly as before. When
167
- * absent / `false`, the per-trial env is byte-identical to today
168
- * (`{ ...process.env, ...arm.env }`). See `docs/safety.md` (ephemerality) and
171
+ * absent / `false`, the per-trial env is the caller's (`{ ...process.env,
172
+ * ...arm.env }`) minus the harness's declared session identity, which no child
173
+ * run inherits in either mode. See `docs/safety.md` (ephemerality) and
169
174
  * `research/cross-platform-sandboxing.md`.
170
175
  */
171
176
  readonly ephemeralEnv?: boolean;
@@ -245,11 +250,13 @@ export interface EvalReport {
245
250
  * Resolve the environment a trial's subprocess actually runs with — the
246
251
  * SECURITY-CRITICAL decision behind `ephemeralEnv`. When `replaceEnv` is set, the
247
252
  * scrubbed `env` is the COMPLETE environment, so the real `$HOME` and inherited
248
- * secrets are DROPPED; otherwise `env` is an overlay on `base` (byte-identical to
249
- * the pre-ephemeral behaviour). Extracted from the `v8 ignore`d `spawnAgent` so
250
- * the one line that enforces the scrub is both unit- and behaviourally-tested — a
251
- * regression to an always-merge would otherwise silently defeat ephemerality and
252
- * leak the host environment into an untrusted, model-driven run.
253
+ * secrets are DROPPED; otherwise `env` is an overlay on `base`. In BOTH, the
254
+ * harness's declared session identity (`runEnv.sessionIdentity`) is removed: a
255
+ * run started from inside a live session must not run as that session, scrubbed
256
+ * or not. Extracted from the `v8 ignore`d `spawnAgent` so the one line that
257
+ * enforces the scrub is both unit- and behaviourally-tested — a regression to an
258
+ * always-merge would otherwise silently defeat ephemerality and leak the host
259
+ * environment into an untrusted, model-driven run.
253
260
  */
254
261
  export declare function resolveSpawnEnv(a: Pick<AgentRunArgs, "env" | "replaceEnv" | "effort">, base?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
255
262
  /**
@@ -264,10 +271,10 @@ export declare const EFFORT_ENV_VAR = "CLAUDE_CODE_EFFORT_LEVEL";
264
271
  *
265
272
  * WHY THIS EXISTS AND WHY IT IS NOT OPTIONAL. Effort has THREE inputs — the
266
273
  * `--effort` flag, the `effortLevel` settings key, and `CLAUDE_CODE_EFFORT_LEVEL`
267
- * — and the env var wins over the flag. `EPHEMERAL_ALLOW_PREFIXES` passes
268
- * `CLAUDE_*` through by design (the CLI reads several such knobs and dropping one
269
- * is the failure mode), so an ambient `CLAUDE_CODE_EFFORT_LEVEL=max` in the
270
- * author's shell survives even the SCRUBBED ephemeral env. Without this pin,
274
+ * — and the env var wins over the flag. The default (inherited) spawn env
275
+ * carries the caller's whole environment, so an ambient
276
+ * `CLAUDE_CODE_EFFORT_LEVEL=max` in the author's shell reaches the run unless
277
+ * something removes it. Without this pin,
271
278
  * hashing effort into the lock would make the lock CONFIDENTLY WRONG: it would
272
279
  * record `low` over a run that executed at `max` — the exact defect the feature
273
280
  * exists to prevent, reintroduced by the fix for it.
@@ -309,6 +316,22 @@ export declare function withEffortGuard(runner: AgentRunner): AgentRunner;
309
316
  * inline there could not be asserted at all. Mirrors `buildCodexArgs`.
310
317
  */
311
318
  export declare function buildAgentArgs(a: AgentRunArgs): string[];
319
+ /**
320
+ * Why a run never reached the model, or null when it did. A run that exited
321
+ * non-zero with no `assistant` event and no `result` event asked the model
322
+ * nothing — a usage error in the command line, a missing binary — and scored as
323
+ * a trial it reads as a miss on every check that needs output and a pass on
324
+ * every bound it cannot break. A run that reached the model and then broke is a
325
+ * scored outcome, not this. Rate-limited runs are left to the retry loop.
326
+ */
327
+ export declare function startFailure(out: Readonly<RunOut>): string | null;
328
+ /**
329
+ * Wrap a runner so a run that never reached the model THROWS instead of being
330
+ * scored — the same choice, for the same reason, as {@link withEffortGuard}:
331
+ * the cause is usually deterministic, so every trial would fail it. Not `async`,
332
+ * for the reason that guard gives.
333
+ */
334
+ export declare function withStartGuard(runner: AgentRunner): AgentRunner;
312
335
  /**
313
336
  * The real `claude`-spawning runner (composition root). Exported so other
314
337
  * real-model entries (e.g. the `audit` trigger tier) bind the same runner.
@@ -391,7 +414,7 @@ export interface MeasureSpec {
391
414
  * the cache, and never read from an env var. Omit for the harness default.
392
415
  */
393
416
  readonly effort?: string | number;
394
- /** Tools the agent may use. */
417
+ /** Tools pre-approved for the run (`--allowedTools`); `[]` approves none. */
395
418
  readonly allowedTools?: readonly string[];
396
419
  /** Per-run timeout ms. */
397
420
  readonly timeoutMs?: number;
@@ -581,51 +604,48 @@ export declare function modelTier(id: string): number | null;
581
604
  export declare function belowModelFloor(model: string, floor: string): boolean;
582
605
  /**
583
606
  * Build an **ephemeral run environment** for a model-driven run: a NEW env object
584
- * with a *fresh* `HOME` (and `TMPDIR`) pointed at the throwaway `opts.home`, only
585
- * an allowlist of auth + runtime vars passed through from `base`, and everything
586
- * else DROPPED. Pure — no fs, no spawn.
607
+ * with a *fresh* `HOME` (and `TMPDIR`) pointed at the throwaway `opts.home`, the
608
+ * OS essentials (`PATH`, `LANG`, `LC_*`, `TERM`), the harness's OWN auth, and
609
+ * nothing else. Pure — no fs, no spawn.
587
610
  *
588
611
  * The rationale is "fresh HOME + only the harness credential injected, **not** a
589
612
  * blanket wipe": running a model-driven skill/agent is itself a side effect (the
590
613
  * *model*, not the author, chose the actions), so it should not be able to read
591
614
  * the real `~/.gitconfig` / `~/.ssh` / `~/.aws` or write to the real `~`. But the
592
- * real `claude` CLI must still AUTHENTICATE, so the harness's own credentials
593
- * ({@link EPHEMERAL_ALLOW} — `ANTHROPIC_*`, `CLAUDE_*`, locale/PATH) are
594
- * re-injected; a blanket `--clearenv`-style wipe would break every eval. Because
595
- * this needs no kernel features, it is the cross-platform STATE-protection floor
596
- * (lands on macOS immediately), orthogonal to the Linux bubblewrap HOST
597
- * confinement in `src/sandbox.ts`.
615
+ * real CLI must still AUTHENTICATE, so the auth names the harness declares
616
+ * (`runtime.runEnv.keep`) are re-injected, and the parent session's identity
617
+ * (`runtime.runEnv.sessionIdentity`) never is. Which names those are is the
618
+ * adapter's fact, not this file's: see `RunEnvPolicy` in `core/runtime.ts`.
619
+ * Because this needs no kernel features, it is the cross-platform
620
+ * STATE-protection floor (lands on macOS immediately), orthogonal to the Linux
621
+ * bubblewrap HOST confinement in `src/sandbox.ts`.
622
+ *
623
+ * Notably it does NOT pass through `GIT_*`, `GH_TOKEN`, `SSH_*`, `AWS_*` access
624
+ * keys, a config-dir override, or any other undeclared variable — those are
625
+ * exactly what an ephemeral run must not see. A too-narrow `keep` breaks auth
626
+ * loudly (the run fails to start); a too-broad one leaks silently, which is why
627
+ * it is an allowlist and not a prefix.
598
628
  *
599
629
  * @param base the source environment to filter (usually `process.env`).
600
630
  * @param opts.home the throwaway dir to set as `HOME`/`TMPDIR`.
601
631
  * @param opts.allow extra var NAMES to pass through (e.g. the `VIGILES_*` keys
602
- * the eval already injects). Layered ON TOP of the default allowlist.
632
+ * the eval already injects). Layered ON TOP of the harness's `keep`; a
633
+ * session-identity name is dropped even when listed here.
603
634
  */
604
635
  export declare function ephemeralRunEnv(base: NodeJS.ProcessEnv | Record<string, string | undefined>, opts: {
605
636
  home: string;
606
637
  allow?: readonly string[];
607
638
  }): Record<string, string>;
608
- /**
609
- * Home-relative AUTH files to carry from the real HOME into the throwaway one.
610
- *
611
- * A local subscription credential (OAuth token) often lives in a FILE under HOME,
612
- * not an env var — so scrubbing HOME would lose it and break a local-authed run.
613
- * This is the file half of the auth allowlist; {@link EPHEMERAL_ALLOW} covers the
614
- * env-var / host-brokered half. Kept a named constant so it's easy to extend, and
615
- * deliberately NARROW — only the explicit auth files, never `.gitconfig` / `.ssh`
616
- * / `.aws`, which are exactly what an ephemeral run must not see.
617
- */
618
- export declare const EPHEMERAL_HOME_KEEP: readonly string[];
619
639
  /**
620
640
  * Seed the throwaway HOME with the harness's own auth FILE(s) — best-effort;
621
- * covers local file-based OAuth; the env-var/host-brokered path is covered by the
622
- * allowlist in `ephemeralRunEnv`.
641
+ * covers local file-based OAuth; the env-var/host-brokered path is covered by
642
+ * `ephemeralRunEnv`. The files are the runtime's declared `keepHomeFiles`.
623
643
  *
624
- * COPIES (never symlinks) each {@link EPHEMERAL_HOME_KEEP} path from `realHome`
625
- * into `throwawayHome`, creating parent dirs as needed; a symlink would let the
626
- * model-driven run write back to the real credential file, defeating ephemerality.
627
- * A path that doesn't exist in the real HOME is skipped silently (that user auths
628
- * via env-var / host broker instead). Pure fs — no env, no spawn.
644
+ * COPIES (never symlinks) each path from `realHome` into `throwawayHome`,
645
+ * creating parent dirs as needed; a symlink would let the model-driven run write
646
+ * back to the real credential file, defeating ephemerality. A path that doesn't
647
+ * exist in the real HOME is skipped silently (that user auths via env-var / host
648
+ * broker instead). Pure fs — no env, no spawn.
629
649
  */
630
650
  export declare function seedEphemeralHome(throwawayHome: string, realHome: string, keep?: readonly string[]): void;
631
651
  /** Whether a run's captured output looks like a rate-limit / overload. Pure. */
@@ -744,7 +764,10 @@ export interface TriggerRateSpec {
744
764
  * false-negative recall. Default `"sonnet"`. Lower it deliberately for a cheap run.
745
765
  */
746
766
  readonly minModel?: string;
747
- /** Tools the agent may use. Default: Read Edit Write Bash Skill. */
767
+ /**
768
+ * Tools pre-approved for the run (`--allowedTools`); `[]` approves none.
769
+ * Default: Read Edit Write Bash Skill.
770
+ */
748
771
  readonly allowedTools?: readonly string[];
749
772
  /** Per-run timeout ms. Default 240000. */
750
773
  readonly timeoutMs?: number;
package/dist/eval.js CHANGED
@@ -1,11 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.claudeCodeLiveDriver = exports.claudeEvalDriver = exports.EPHEMERAL_HOME_KEEP = exports.spawnAgent = exports.EFFORT_ENV_VAR = void 0;
3
+ exports.claudeCodeLiveDriver = exports.claudeEvalDriver = exports.spawnAgent = exports.EFFORT_ENV_VAR = void 0;
4
4
  exports.resolveSpawnEnv = resolveSpawnEnv;
5
5
  exports.pinEffortEnv = pinEffortEnv;
6
6
  exports.effortRejection = effortRejection;
7
7
  exports.withEffortGuard = withEffortGuard;
8
8
  exports.buildAgentArgs = buildAgentArgs;
9
+ exports.startFailure = startFailure;
10
+ exports.withStartGuard = withStartGuard;
9
11
  exports.unregisteredSkillFiles = unregisteredSkillFiles;
10
12
  exports.runEval = runEval;
11
13
  exports.measureWith = measureWith;
@@ -74,6 +76,7 @@ const node_path_1 = require("node:path");
74
76
  const observe_js_1 = require("./observe.js");
75
77
  const plugin_loader_js_1 = require("./adapters/claude-code/plugin-loader.js");
76
78
  const runtime_js_1 = require("./adapters/claude-code/runtime.js");
79
+ const run_env_js_1 = require("./core/run-env.js");
77
80
  const eval_cost_js_1 = require("./eval-cost.js");
78
81
  const proofs_js_1 = require("./core/proofs.js");
79
82
  const model_access_js_1 = require("./adapters/claude-code/model-access.js");
@@ -88,6 +91,14 @@ const tool_intercept_js_1 = require("./tool-intercept.js");
88
91
  const tool_stub_js_1 = require("./tool-stub.js");
89
92
  const tmp_root_js_1 = require("./core/tmp-root.js");
90
93
  const edit_distance_js_1 = require("./core/edit-distance.js");
94
+ /**
95
+ * The harness the eval tier spawns. ONE binding on purpose: the paid tier is
96
+ * still wired to the Claude Code runner rather than chosen through the adapter's
97
+ * eval driver (#325), and every fact this file needs about that harness — its
98
+ * binary, its version key, which env a child may inherit — is read off this
99
+ * runtime's port fields, never spelled here.
100
+ */
101
+ const EVAL_RUNTIME = runtime_js_1.claudeCodeRuntime;
91
102
  function writeFiles(cwd, files) {
92
103
  for (const [p, content] of Object.entries(files)) {
93
104
  const full = (0, node_path_1.resolve)(cwd, p);
@@ -99,15 +110,17 @@ function writeFiles(cwd, files) {
99
110
  * Resolve the environment a trial's subprocess actually runs with — the
100
111
  * SECURITY-CRITICAL decision behind `ephemeralEnv`. When `replaceEnv` is set, the
101
112
  * scrubbed `env` is the COMPLETE environment, so the real `$HOME` and inherited
102
- * secrets are DROPPED; otherwise `env` is an overlay on `base` (byte-identical to
103
- * the pre-ephemeral behaviour). Extracted from the `v8 ignore`d `spawnAgent` so
104
- * the one line that enforces the scrub is both unit- and behaviourally-tested — a
105
- * regression to an always-merge would otherwise silently defeat ephemerality and
106
- * leak the host environment into an untrusted, model-driven run.
113
+ * secrets are DROPPED; otherwise `env` is an overlay on `base`. In BOTH, the
114
+ * harness's declared session identity (`runEnv.sessionIdentity`) is removed: a
115
+ * run started from inside a live session must not run as that session, scrubbed
116
+ * or not. Extracted from the `v8 ignore`d `spawnAgent` so the one line that
117
+ * enforces the scrub is both unit- and behaviourally-tested — a regression to an
118
+ * always-merge would otherwise silently defeat ephemerality and leak the host
119
+ * environment into an untrusted, model-driven run.
107
120
  */
108
121
  function resolveSpawnEnv(a, base = process.env) {
109
122
  const resolved = a.replaceEnv ? (a.env ?? {}) : { ...base, ...a.env };
110
- return pinEffortEnv(resolved, a.effort);
123
+ return pinEffortEnv((0, run_env_js_1.withoutSessionIdentity)(resolved, EVAL_RUNTIME.runEnv), a.effort);
111
124
  }
112
125
  /**
113
126
  * The env var name the harness reads for the reasoning budget. It sits ABOVE the
@@ -121,10 +134,10 @@ exports.EFFORT_ENV_VAR = "CLAUDE_CODE_EFFORT_LEVEL";
121
134
  *
122
135
  * WHY THIS EXISTS AND WHY IT IS NOT OPTIONAL. Effort has THREE inputs — the
123
136
  * `--effort` flag, the `effortLevel` settings key, and `CLAUDE_CODE_EFFORT_LEVEL`
124
- * — and the env var wins over the flag. `EPHEMERAL_ALLOW_PREFIXES` passes
125
- * `CLAUDE_*` through by design (the CLI reads several such knobs and dropping one
126
- * is the failure mode), so an ambient `CLAUDE_CODE_EFFORT_LEVEL=max` in the
127
- * author's shell survives even the SCRUBBED ephemeral env. Without this pin,
137
+ * — and the env var wins over the flag. The default (inherited) spawn env
138
+ * carries the caller's whole environment, so an ambient
139
+ * `CLAUDE_CODE_EFFORT_LEVEL=max` in the author's shell reaches the run unless
140
+ * something removes it. Without this pin,
128
141
  * hashing effort into the lock would make the lock CONFIDENTLY WRONG: it would
129
142
  * record `low` over a run that executed at `max` — the exact defect the feature
130
143
  * exists to prevent, reintroduced by the fix for it.
@@ -215,10 +228,41 @@ function buildAgentArgs(a) {
215
228
  ? ["--plugin-dir", (0, node_path_1.resolve)(a.pluginDir)]
216
229
  : []),
217
230
  ...(a.hasSettings ? ["--settings", "settings.json"] : []),
218
- "--allowedTools",
219
- ...a.tools,
231
+ // A permission allowlist: an empty one approves nothing, so the flag is
232
+ // left out (a bare `--allowedTools` exits before any model turn).
233
+ ...(a.tools.length === 0 ? [] : ["--allowedTools", ...a.tools]),
220
234
  ];
221
235
  }
236
+ /**
237
+ * Why a run never reached the model, or null when it did. A run that exited
238
+ * non-zero with no `assistant` event and no `result` event asked the model
239
+ * nothing — a usage error in the command line, a missing binary — and scored as
240
+ * a trial it reads as a miss on every check that needs output and a pass on
241
+ * every bound it cannot break. A run that reached the model and then broke is a
242
+ * scored outcome, not this. Rate-limited runs are left to the retry loop.
243
+ */
244
+ function startFailure(out) {
245
+ if (out.code === 0 || (0, harness_test_js_1.parseResultEvent)(out.stdout) !== null)
246
+ return null;
247
+ if ((0, harness_test_js_1.reachedModel)(out.stdout) || isRateLimited(out))
248
+ return null;
249
+ const said = (out.stderr ?? "").trim().split("\n").slice(-5).join("\n");
250
+ return `the harness exited ${String(out.code)} before calling the model: ${said || "(no stderr)"}`;
251
+ }
252
+ /**
253
+ * Wrap a runner so a run that never reached the model THROWS instead of being
254
+ * scored — the same choice, for the same reason, as {@link withEffortGuard}:
255
+ * the cause is usually deterministic, so every trial would fail it. Not `async`,
256
+ * for the reason that guard gives.
257
+ */
258
+ function withStartGuard(runner) {
259
+ return (a) => runner(a).then((out) => {
260
+ const failure = startFailure(out);
261
+ if (failure !== null)
262
+ throw new Error(failure);
263
+ return out;
264
+ });
265
+ }
222
266
  /**
223
267
  * The real `claude`-spawning runner (composition root). Exported so other
224
268
  * real-model entries (e.g. the `audit` trigger tier) bind the same runner.
@@ -228,7 +272,7 @@ function buildAgentArgs(a) {
228
272
  * not yet written, is covered by construction. Guarding each call site instead is
229
273
  * the shape that left four of five compilers unprotected in #173.
230
274
  */
231
- exports.spawnAgent = withEffortGuard(spawnAgentRaw);
275
+ exports.spawnAgent = withEffortGuard(withStartGuard(spawnAgentRaw));
232
276
  /* v8 ignore start -- real claude subprocess; exercised by bench/, not the unit gate */
233
277
  /** The unguarded spawn itself; wrapped by {@link spawnAgent}, never bound raw. */
234
278
  function spawnAgentRaw(a) {
@@ -236,7 +280,7 @@ function spawnAgentRaw(a) {
236
280
  (0, foreign_runner_js_1.refuseUnderForeignRunner)("spawning `claude`");
237
281
  return new Promise((resolvePromise) => {
238
282
  const args = buildAgentArgs(a);
239
- const child = (0, node_child_process_1.spawn)(runtime_js_1.claudeCodeRuntime.agentBinary, args, {
283
+ const child = (0, node_child_process_1.spawn)(EVAL_RUNTIME.agentBinary, args, {
240
284
  cwd: a.cwd,
241
285
  // The security-critical env resolution (overlay vs. scrubbed replacement)
242
286
  // lives in the tested `resolveSpawnEnv` seam above, not inline here.
@@ -572,6 +616,7 @@ function parseClaudeRun(out) {
572
616
  toolCalls: (0, harness_test_js_1.parseToolCalls)(out.stdout),
573
617
  hooks: (0, harness_test_js_1.parseHooks)(out.stdout),
574
618
  subagents: (0, harness_test_js_1.parseSubagents)(out.stdout),
619
+ replies: (0, harness_test_js_1.parseReplies)(out.stdout),
575
620
  usage: usageFrom(result),
576
621
  };
577
622
  }
@@ -592,6 +637,7 @@ function makeContext(cwd, out, parse = parseClaudeRun) {
592
637
  hooks: p.hooks,
593
638
  output: p.output,
594
639
  subagents: p.subagents,
640
+ ...(p.replies === undefined ? {} : { replies: p.replies }),
595
641
  usage: p.usage,
596
642
  // The eval tier drives the real API (no mock between the agent and the model),
597
643
  // so the requests can't be captured here — modelRequests is harness-tier only.
@@ -755,7 +801,10 @@ async function runWithCache(runArgs, keyParts, runner, cfg) {
755
801
  trialIndex: keyParts.trialIndex,
756
802
  });
757
803
  const hit = (0, eval_cache_js_1.readCache)(cfg.cacheDir, key);
758
- if (hit) {
804
+ // A run that never reached the model is not replayed: the real runner now
805
+ // throws on one, and an entry cached before that guard existed would
806
+ // otherwise be scored as a trial on every later run.
807
+ if (hit && startFailure(hit.out) === null) {
759
808
  (0, eval_cache_js_1.restoreDir)(runArgs.cwd, hit.files);
760
809
  return hit.out;
761
810
  }
@@ -828,7 +877,7 @@ let cachedHarnessVersion;
828
877
  function harnessVersion() {
829
878
  if (cachedHarnessVersion === undefined) {
830
879
  try {
831
- cachedHarnessVersion = runtime_js_1.claudeCodeRuntime.versionKey((0, node_child_process_1.execSync)(`${runtime_js_1.claudeCodeRuntime.agentBinary} --version`, {
880
+ cachedHarnessVersion = EVAL_RUNTIME.versionKey((0, node_child_process_1.execSync)(`${EVAL_RUNTIME.agentBinary} --version`, {
832
881
  encoding: "utf-8",
833
882
  stdio: ["ignore", "pipe", "ignore"],
834
883
  }));
@@ -875,132 +924,68 @@ function withInterceptToolHook(settings, intercepts) {
875
924
  },
876
925
  };
877
926
  }
878
- /**
879
- * The default allowlist `ephemeralRunEnv` passes through from the real
880
- * environment. Two groups, both load-bearing for a real-model `claude` run:
881
- *
882
- * - **Auth** — the harness's OWN credentials. The eval drives the real `claude`
883
- * CLI, which authenticates via the user's subscription (`~/.claude`, reached
884
- * through the fresh HOME's allowed config — see below) OR via these env vars.
885
- * We mirror the auth surface the runtime port already names
886
- * (`ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`, see
887
- * `adapters/claude-code/runtime.ts`) plus the OAuth/token + region variants the
888
- * CLI accepts, so a token-authed user is not broken by a too-narrow list.
889
- * - **Runtime** — what any spawned process needs to *function*: `PATH` (resolve
890
- * `node` / `claude`), the locale/terminal vars (`LANG` / `LC_*` / `TERM`), and
891
- * `TMPDIR` (which we override to the fresh HOME). Mirrors what `bwrapArgs` /
892
- * `setenvArgs` in `src/sandbox.ts` set back after `--clearenv`.
893
- *
894
- * Notably it does NOT pass through `GIT_*`, `GH_TOKEN`, `SSH_*`, `AWS_*`, or any
895
- * other non-allowlisted secret-shaped var — those are exactly what an ephemeral
896
- * run must not see. `CLAUDE_*` is allowlisted by prefix because the CLI reads
897
- * several `CLAUDE_*` knobs (config dir, etc.) and omitting one is the failure
898
- * mode this whole guard is conservative against.
899
- *
900
- * Conservative by design: a too-broad allowlist is safe (it just leaks a benign
901
- * var); a too-narrow one silently breaks auth — which is why the feature ships
902
- * default-OFF until validated against a real run.
903
- */
904
- const EPHEMERAL_ALLOW = [
905
- // Runtime essentials (mirror sandbox.ts setenv-after-clearenv).
906
- "PATH",
907
- "LANG",
908
- "TERM",
909
- // Anthropic / Claude Code auth + endpoint (mirror runtime.ts + CLI auth vars).
910
- runtime_js_1.claudeCodeRuntime.modelApiKeyEnv, // ANTHROPIC_API_KEY
911
- runtime_js_1.claudeCodeRuntime.modelBaseUrlEnv, // ANTHROPIC_BASE_URL
912
- "ANTHROPIC_AUTH_TOKEN",
913
- "ANTHROPIC_API_URL",
914
- "ANTHROPIC_MODEL",
915
- "ANTHROPIC_DEFAULT_HEADERS",
916
- "CLAUDE_CODE_USE_BEDROCK",
917
- "CLAUDE_CODE_USE_VERTEX",
918
- // Cloud-provider auth the CLI uses for Bedrock/Vertex backends (region/profile
919
- // only — NOT the secret-shaped AWS_* access keys, which stay dropped).
920
- "AWS_REGION",
921
- "AWS_DEFAULT_REGION",
922
- "AWS_PROFILE",
923
- "CLOUD_ML_REGION",
924
- "GOOGLE_CLOUD_PROJECT",
925
- "GOOGLE_APPLICATION_CREDENTIALS",
926
- ];
927
- /** Prefixes passed through wholesale — the CLI reads several `CLAUDE_*` knobs and
928
- * a `LC_*` locale family; allowlist by prefix so omitting one isn't the silent
929
- * auth/locale break this guard exists to avoid. */
930
- const EPHEMERAL_ALLOW_PREFIXES = ["CLAUDE_", "LC_"];
931
927
  /**
932
928
  * Build an **ephemeral run environment** for a model-driven run: a NEW env object
933
- * with a *fresh* `HOME` (and `TMPDIR`) pointed at the throwaway `opts.home`, only
934
- * an allowlist of auth + runtime vars passed through from `base`, and everything
935
- * else DROPPED. Pure — no fs, no spawn.
929
+ * with a *fresh* `HOME` (and `TMPDIR`) pointed at the throwaway `opts.home`, the
930
+ * OS essentials (`PATH`, `LANG`, `LC_*`, `TERM`), the harness's OWN auth, and
931
+ * nothing else. Pure — no fs, no spawn.
936
932
  *
937
933
  * The rationale is "fresh HOME + only the harness credential injected, **not** a
938
934
  * blanket wipe": running a model-driven skill/agent is itself a side effect (the
939
935
  * *model*, not the author, chose the actions), so it should not be able to read
940
936
  * the real `~/.gitconfig` / `~/.ssh` / `~/.aws` or write to the real `~`. But the
941
- * real `claude` CLI must still AUTHENTICATE, so the harness's own credentials
942
- * ({@link EPHEMERAL_ALLOW} — `ANTHROPIC_*`, `CLAUDE_*`, locale/PATH) are
943
- * re-injected; a blanket `--clearenv`-style wipe would break every eval. Because
944
- * this needs no kernel features, it is the cross-platform STATE-protection floor
945
- * (lands on macOS immediately), orthogonal to the Linux bubblewrap HOST
946
- * confinement in `src/sandbox.ts`.
937
+ * real CLI must still AUTHENTICATE, so the auth names the harness declares
938
+ * (`runtime.runEnv.keep`) are re-injected, and the parent session's identity
939
+ * (`runtime.runEnv.sessionIdentity`) never is. Which names those are is the
940
+ * adapter's fact, not this file's: see `RunEnvPolicy` in `core/runtime.ts`.
941
+ * Because this needs no kernel features, it is the cross-platform
942
+ * STATE-protection floor (lands on macOS immediately), orthogonal to the Linux
943
+ * bubblewrap HOST confinement in `src/sandbox.ts`.
944
+ *
945
+ * Notably it does NOT pass through `GIT_*`, `GH_TOKEN`, `SSH_*`, `AWS_*` access
946
+ * keys, a config-dir override, or any other undeclared variable — those are
947
+ * exactly what an ephemeral run must not see. A too-narrow `keep` breaks auth
948
+ * loudly (the run fails to start); a too-broad one leaks silently, which is why
949
+ * it is an allowlist and not a prefix.
947
950
  *
948
951
  * @param base the source environment to filter (usually `process.env`).
949
952
  * @param opts.home the throwaway dir to set as `HOME`/`TMPDIR`.
950
953
  * @param opts.allow extra var NAMES to pass through (e.g. the `VIGILES_*` keys
951
- * the eval already injects). Layered ON TOP of the default allowlist.
954
+ * the eval already injects). Layered ON TOP of the harness's `keep`; a
955
+ * session-identity name is dropped even when listed here.
952
956
  */
953
957
  function ephemeralRunEnv(base, opts) {
954
- const out = {};
955
- const allowExact = new Set([
956
- ...EPHEMERAL_ALLOW,
957
- ...(opts.allow ?? []),
958
- ]);
959
- for (const [k, v] of Object.entries(base)) {
960
- if (v === undefined)
961
- continue;
962
- const allowed = allowExact.has(k) ||
963
- EPHEMERAL_ALLOW_PREFIXES.some((p) => k.startsWith(p));
964
- if (allowed)
965
- out[k] = v;
966
- }
967
- // Fresh HOME + TMPDIR last so they always win over anything passed through.
968
- out.HOME = opts.home;
969
- out.TMPDIR = opts.home;
970
- return out;
958
+ // A fresh object: the trial layers its overlay and stub PATH onto it.
959
+ return {
960
+ ...(0, run_env_js_1.scrubbedRunEnv)(base, {
961
+ home: opts.home,
962
+ policy: EVAL_RUNTIME.runEnv,
963
+ allow: opts.allow,
964
+ }),
965
+ };
971
966
  }
972
- /**
973
- * Home-relative AUTH files to carry from the real HOME into the throwaway one.
974
- *
975
- * A local subscription credential (OAuth token) often lives in a FILE under HOME,
976
- * not an env var — so scrubbing HOME would lose it and break a local-authed run.
977
- * This is the file half of the auth allowlist; {@link EPHEMERAL_ALLOW} covers the
978
- * env-var / host-brokered half. Kept a named constant so it's easy to extend, and
979
- * deliberately NARROW — only the explicit auth files, never `.gitconfig` / `.ssh`
980
- * / `.aws`, which are exactly what an ephemeral run must not see.
981
- */
982
- exports.EPHEMERAL_HOME_KEEP = [
983
- ".claude/.credentials.json", // the Claude Code OAuth token
984
- ];
985
967
  /**
986
968
  * Seed the throwaway HOME with the harness's own auth FILE(s) — best-effort;
987
- * covers local file-based OAuth; the env-var/host-brokered path is covered by the
988
- * allowlist in `ephemeralRunEnv`.
969
+ * covers local file-based OAuth; the env-var/host-brokered path is covered by
970
+ * `ephemeralRunEnv`. The files are the runtime's declared `keepHomeFiles`.
989
971
  *
990
- * COPIES (never symlinks) each {@link EPHEMERAL_HOME_KEEP} path from `realHome`
991
- * into `throwawayHome`, creating parent dirs as needed; a symlink would let the
992
- * model-driven run write back to the real credential file, defeating ephemerality.
993
- * A path that doesn't exist in the real HOME is skipped silently (that user auths
994
- * via env-var / host broker instead). Pure fs — no env, no spawn.
972
+ * COPIES (never symlinks) each path from `realHome` into `throwawayHome`,
973
+ * creating parent dirs as needed; a symlink would let the model-driven run write
974
+ * back to the real credential file, defeating ephemerality. A path that doesn't
975
+ * exist in the real HOME is skipped silently (that user auths via env-var / host
976
+ * broker instead). Pure fs — no env, no spawn.
995
977
  */
996
- function seedEphemeralHome(throwawayHome, realHome, keep = exports.EPHEMERAL_HOME_KEEP) {
978
+ function seedEphemeralHome(throwawayHome, realHome, keep = EVAL_RUNTIME.runEnv?.keepHomeFiles ?? []) {
997
979
  for (const rel of keep) {
998
980
  const src = (0, node_path_1.join)(realHome, rel);
999
981
  if (!(0, node_fs_1.existsSync)(src))
1000
982
  continue; // env-var / host-brokered auth covers this.
1001
983
  const dest = (0, node_path_1.join)(throwawayHome, rel);
1002
984
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(dest), { recursive: true });
1003
- (0, node_fs_1.cpSync)(src, dest); // copy, not symlink — keep the real credential read-only.
985
+ // The DATA, never a link: `cpSync` recreates a symlinked credential
986
+ // (dotfiles, a secret manager) as a link, and the run would write through
987
+ // it into the real file. `copyFileSync` always copies the contents.
988
+ (0, node_fs_1.copyFileSync)(src, dest);
1004
989
  }
1005
990
  }
1006
991
  /** Execute one trial in a fresh sandbox; returns its metric row + usage. */
@@ -1122,14 +1107,25 @@ async function runWithRetry(runArgs, runner, retries, baseMs) {
1122
1107
  /** Map `worker` over `items` with at most `concurrency` in flight, order preserved. */
1123
1108
  async function runPool(items, concurrency, worker) {
1124
1109
  const results = new Array(items.length);
1110
+ // A worker that throws rejects the whole pool; the others must stop taking
1111
+ // items then, or they keep starting (and paying for) work nobody will read.
1112
+ const stop = new AbortController();
1125
1113
  let next = 0;
1126
1114
  const drain = async () => {
1127
1115
  for (;;) {
1116
+ if (stop.signal.aborted)
1117
+ return;
1128
1118
  const i = next++;
1129
1119
  const item = items[i];
1130
1120
  if (i >= items.length || item === undefined)
1131
1121
  return;
1132
- results[i] = await worker(item);
1122
+ try {
1123
+ results[i] = await worker(item);
1124
+ }
1125
+ catch (e) {
1126
+ stop.abort();
1127
+ throw e;
1128
+ }
1133
1129
  }
1134
1130
  };
1135
1131
  const workers = Math.max(1, Math.min(concurrency, items.length || 1));