vigiles 35.0.0 → 37.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.
@@ -1,5 +1,5 @@
1
1
  import type { HarnessAdapter } from "./core/adapter.js";
2
- import type { HarnessTestDriver, ToolCall, HookFire, ParsedRun, ModelTurn } from "./core/harness-driver.js";
2
+ import type { HarnessTestDriver, ToolCall, HookFire, ParsedRun, ModelTurn, ModelRequest } from "./core/harness-driver.js";
3
3
  import type { Trace, SubagentTrace } from "./core/eval-driver.js";
4
4
  export type { Trace, SubagentTrace } from "./core/eval-driver.js";
5
5
  import { parseReplies, reachedModel } from "./adapters/claude-code/replies.js";
@@ -53,16 +53,36 @@ export interface HarnessTestSpec {
53
53
  /** The user prompt. Default: "go". */
54
54
  readonly prompt?: string;
55
55
  /**
56
- * The ONLY tools the agent has, when you list them: a tool left out is not
57
- * offered (`claude --tools`, Claude Code 2.0.31+), `[]` is refused, and a
58
- * scripted call to a tool the list leaves out throws before the run starts.
59
- * Omitted, nothing is withheld: every tool is offered and Read, Edit, Write and
60
- * Bash are pre-approved. Each listed tool is also pre-approved
61
- * (`--allowedTools`), so it does not stop on a permission prompt; a permission
62
- * rule such as `Bash(git *)` keeps its specifier for the approval and is
63
- * offered as plain `Bash`. An MCP tool is not a built-in: left out, it is still
64
- * offered but not approved, so a call to it is refused for permission rather
65
- * than as "No such tool".
56
+ * Which tools EXIST in the session (`claude --tools`, Claude Code 2.0.31+). A
57
+ * tool left out is not offered at all, so a call to it is "No such tool
58
+ * available". Omitted, every tool exists. This is the field that withholds a
59
+ * tool. Names only: a permission rule such as `Bash(git *)` is refused (put the
60
+ * rule in `allowedTools`). `[]` is refused (an agent with no tools is never
61
+ * served a scripted turn), and a scripted call to a tool the list leaves out
62
+ * throws before the run starts. An MCP tool is not a built-in, so `--tools`
63
+ * does not withhold it. Codex has no equivalent, so a run there refuses it.
64
+ *
65
+ * | field | Claude Code flag | meaning |
66
+ * | -------------- | ----------------- | ---------------------------- |
67
+ * | `tools` | `--tools` | which tools exist |
68
+ * | `allowedTools` | `--allowedTools` | which are pre-approved |
69
+ */
70
+ readonly tools?: readonly string[];
71
+ /**
72
+ * Which tools are PRE-APPROVED (`claude --allowedTools`), so they do not stop
73
+ * on a permission prompt. Takes permission rules (`Bash(git *)`). Omitted:
74
+ * Read, Edit, Write and Bash are approved. An explicit list REPLACES that
75
+ * default, so `tools: ["Read", "Write"]` with `allowedTools: ["Skill"]` leaves
76
+ * Write unapproved. `[]`: nothing is approved.
77
+ *
78
+ * It never removes a tool, and leaving a tool out is not a fence: in headless
79
+ * mode Claude Code refuses only the calls it would have asked about. For Bash
80
+ * that is a command that creates, changes or removes a file (`touch`, `>`),
81
+ * inside or outside the working directory; a command with no file effect
82
+ * (`ls`, `printf`, `id`) runs unapproved. So a refused `touch` proves the
83
+ * file-change guard is live, not that Bash is withheld. To withhold a tool use
84
+ * `tools`; to forbid its use put a deny rule in the fixture's
85
+ * `settings.permissions.deny`.
66
86
  */
67
87
  readonly allowedTools?: readonly string[];
68
88
  /**
@@ -131,20 +151,34 @@ export declare function parseResultEvent(stdout: string): Record<string, unknown
131
151
  export declare function parseOutput(stdout: string): string;
132
152
  export declare function parseHooks(stdout: string): HookFire[];
133
153
  /**
134
- * The names `--tools` should offer for an `allowedTools` list. `--allowedTools`
135
- * takes permission rules (`Bash(git *)`); `--tools` takes tool NAMES, and a rule
136
- * with a specifier there makes the CLI offer no tools at all. So the specifier is
137
- * dropped (`Bash(git *)` → `Bash`) and repeats collapse. Pure.
154
+ * The names `--tools` takes for a `tools` list: trimmed and deduplicated.
155
+ * `--allowedTools` takes permission rules (`Bash(git *)`); `--tools` takes tool
156
+ * NAMES, and a rule there makes the CLI offer no tools at all. Reducing it to
157
+ * `Bash` would offer more than the author wrote, so a rule is refused: it
158
+ * belongs in `allowedTools`. Pure.
138
159
  */
139
- export declare function toolAvailabilityList(allowed: readonly string[]): readonly string[];
160
+ export declare function toolAvailabilityList(tools: readonly string[]): readonly string[];
140
161
  /**
141
- * A scripted call to a tool an EXPLICIT `allowedTools` does not offer. The CLI
142
- * answers it with "No such tool available" and the run goes on, so a test that
143
- * scripts the call and then asserts on something else passes over a step that
144
- * never happened. Returns the message to throw, or undefined. MCP tools are not
145
- * built-ins, so `--tools` does not withhold them and they are skipped. Pure.
162
+ * A scripted call to a tool an EXPLICIT `tools` list leaves out. The CLI answers
163
+ * it with "No such tool available" and the run goes on, so a test that scripts
164
+ * the call and then asserts on something else passes over a step that never
165
+ * happened. A tool that exists but is not in `allowedTools` is NOT this: the CLI
166
+ * refuses it for permission, which is a result a test can assert on. Returns the
167
+ * message to throw, or undefined. MCP tools are not built-ins, so `--tools` does
168
+ * not withhold them and they are skipped. Pure.
146
169
  */
147
- export declare function unofferedScriptedTool(model: readonly ModelTurn[], allowed: readonly string[] | undefined): string | undefined;
170
+ export declare function unofferedScriptedTool(model: readonly ModelTurn[], tools: readonly string[] | undefined): string | undefined;
171
+ /**
172
+ * The agent's own tool results, read off the requests it sent back to the model,
173
+ * that say a scripted tool does not exist. The CLI answers an unknown name
174
+ * (`Bsh` for `Bash`, an `mcp__` server that is not configured) with an error
175
+ * RESULT and carries on, exit 0, so a test that asserts "the call errored and
176
+ * left no side effect" passes on a typo. `tools` cannot catch it: the pre-flight
177
+ * skips `mcp__` names and only runs when `tools` is given. Side-channel requests
178
+ * are the CLI's own bookkeeping and are not read. Returns the message to throw,
179
+ * or undefined. Pure.
180
+ */
181
+ export declare function noSuchToolMessage(model: readonly ModelTurn[], requests: readonly ModelRequest[]): string | undefined;
148
182
  /**
149
183
  * A run on a `claude` that predates `--tools` exits at argument parsing, and
150
184
  * commander's "unknown option" says nothing about WHY vigiles passed it. Returns
@@ -170,7 +204,7 @@ export declare function parseClaudeRun(stdout: string): ParsedRun;
170
204
  * else from the caller: no harness auth (the scripted mock needs none, and a
171
205
  * config-dir variable would point the run back at the real config), no
172
206
  * identity, no secrets. The same scrubbed shape as the eval tier's
173
- * `ephemeralEnv` (`scrubbedRunEnv`), without the auth.
207
+ * `env: { kind: "ephemeral" }` (`scrubbedRunEnv`), without the auth.
174
208
  */
175
209
  export type HarnessRunHome = {
176
210
  readonly home: "inherit";
@@ -204,6 +238,10 @@ export interface RunHarnessTestOptions {
204
238
  }
205
239
  /** Say so on stderr when side-channel calls arrived and no script turn was served. */
206
240
  export declare function warnUnconsumed(count: number, sideChannelCount: number): void;
241
+ /**
242
+ * What makes a finished run unsound, most specific first, or undefined. Pure.
243
+ */
244
+ export declare function runProblem(stderr: string, model: readonly ModelTurn[], modelRequests: readonly ModelRequest[], tools?: readonly string[]): string | undefined;
207
245
  /**
208
246
  * Run the real agent CLI against a scripted mock model, with the given fixture
209
247
  * and settings (hooks). Deterministic — same script, same result. Adapter-driven
@@ -8,12 +8,14 @@ exports.parseOutput = parseOutput;
8
8
  exports.parseHooks = parseHooks;
9
9
  exports.toolAvailabilityList = toolAvailabilityList;
10
10
  exports.unofferedScriptedTool = unofferedScriptedTool;
11
+ exports.noSuchToolMessage = noSuchToolMessage;
11
12
  exports.unsupportedToolsFlag = unsupportedToolsFlag;
12
13
  exports.buildClaudeArgs = buildClaudeArgs;
13
14
  exports.parseClaudeRun = parseClaudeRun;
14
15
  exports.harnessSpawnEnv = harnessSpawnEnv;
15
16
  exports.claudeAvailable = claudeAvailable;
16
17
  exports.warnUnconsumed = warnUnconsumed;
18
+ exports.runProblem = runProblem;
17
19
  exports.runHarnessTest = runHarnessTest;
18
20
  exports.runHarnessTestIn = runHarnessTestIn;
19
21
  exports.runHarness = runHarness;
@@ -287,32 +289,66 @@ function parseHooks(stdout) {
287
289
  */
288
290
  const TOOLS_FLAG_MIN_CLAUDE = "2.0.31";
289
291
  /**
290
- * The names `--tools` should offer for an `allowedTools` list. `--allowedTools`
291
- * takes permission rules (`Bash(git *)`); `--tools` takes tool NAMES, and a rule
292
- * with a specifier there makes the CLI offer no tools at all. So the specifier is
293
- * dropped (`Bash(git *)` → `Bash`) and repeats collapse. Pure.
292
+ * The names `--tools` takes for a `tools` list: trimmed and deduplicated.
293
+ * `--allowedTools` takes permission rules (`Bash(git *)`); `--tools` takes tool
294
+ * NAMES, and a rule there makes the CLI offer no tools at all. Reducing it to
295
+ * `Bash` would offer more than the author wrote, so a rule is refused: it
296
+ * belongs in `allowedTools`. Pure.
294
297
  */
295
- function toolAvailabilityList(allowed) {
296
- const names = allowed.map((rule) => rule.replace(/\(.*$/, "").trim());
297
- return [...new Set(names.filter((n) => n !== ""))];
298
+ function toolAvailabilityList(tools) {
299
+ const names = tools.map((entry) => entry.trim()).filter((n) => n !== "");
300
+ const rule = names.find((name) => name.includes("("));
301
+ if (rule !== undefined) {
302
+ throw new Error(`tools takes bare tool names, and "${rule}" is a permission rule — put the rule in allowedTools and the name ("${rule.replace(/\(.*$/, "")}") in tools`);
303
+ }
304
+ return [...new Set(names)];
298
305
  }
299
306
  /**
300
- * A scripted call to a tool an EXPLICIT `allowedTools` does not offer. The CLI
301
- * answers it with "No such tool available" and the run goes on, so a test that
302
- * scripts the call and then asserts on something else passes over a step that
303
- * never happened. Returns the message to throw, or undefined. MCP tools are not
304
- * built-ins, so `--tools` does not withhold them and they are skipped. Pure.
307
+ * A scripted call to a tool an EXPLICIT `tools` list leaves out. The CLI answers
308
+ * it with "No such tool available" and the run goes on, so a test that scripts
309
+ * the call and then asserts on something else passes over a step that never
310
+ * happened. A tool that exists but is not in `allowedTools` is NOT this: the CLI
311
+ * refuses it for permission, which is a result a test can assert on. Returns the
312
+ * message to throw, or undefined. MCP tools are not built-ins, so `--tools` does
313
+ * not withhold them and they are skipped. Pure.
305
314
  */
306
- function unofferedScriptedTool(model, allowed) {
307
- if (allowed === undefined)
315
+ function unofferedScriptedTool(model, tools) {
316
+ if (tools === undefined)
308
317
  return undefined;
309
- const offered = toolAvailabilityList(allowed);
318
+ const exist = toolAvailabilityList(tools);
310
319
  const at = model.findIndex((t) => t.tool !== undefined &&
311
320
  !t.tool.startsWith("mcp__") &&
312
- !offered.includes(t.tool));
321
+ !exist.includes(t.tool));
313
322
  return at < 0
314
323
  ? undefined
315
- : `scripted call to "${model[at]?.tool ?? ""}" on turn ${String(at + 1)}, but this run offers only: ${offered.join(", ")} — add it to allowedTools`;
324
+ : `scripted call to "${model[at]?.tool ?? ""}" on turn ${String(at + 1)}, but this run's tools are only: ${exist.join(", ")} — add it to tools`;
325
+ }
326
+ /**
327
+ * The agent's own tool results, read off the requests it sent back to the model,
328
+ * that say a scripted tool does not exist. The CLI answers an unknown name
329
+ * (`Bsh` for `Bash`, an `mcp__` server that is not configured) with an error
330
+ * RESULT and carries on, exit 0, so a test that asserts "the call errored and
331
+ * left no side effect" passes on a typo. `tools` cannot catch it: the pre-flight
332
+ * skips `mcp__` names and only runs when `tools` is given. Side-channel requests
333
+ * are the CLI's own bookkeeping and are not read. Returns the message to throw,
334
+ * or undefined. Pure.
335
+ */
336
+ function noSuchToolMessage(model, requests) {
337
+ // The CLI's own wording, inside its error wrapper, for a name the script
338
+ // calls: the agent calls tools only from the script, so the same words in the
339
+ // prompt or in a hook's injected text are not a result of this run.
340
+ const pattern = /<tool_use_error>Error: No such tool available: ([^\s<]+)<\/tool_use_error>/g;
341
+ const scripted = new Set(model.flatMap((t) => (t.tool === undefined ? [] : [t.tool])));
342
+ const hit = requests
343
+ .filter((r) => r.sideChannel !== true)
344
+ .flatMap((r) => r.messages)
345
+ .flatMap((m) => [...m.text.matchAll(pattern)].map((x) => x[1]))
346
+ .find((name) => name !== undefined && scripted.has(name));
347
+ if (hit === undefined)
348
+ return undefined;
349
+ const at = model.findIndex((t) => t.tool === hit);
350
+ const turn = at < 0 ? "" : ` on turn ${String(at + 1)}`;
351
+ return `scripted call to "${hit}"${turn}: the CLI answered "No such tool available: ${hit}", so the call never ran and the test would carry on over a step that did not happen. Check the name for a typo; an mcp__ tool also needs its server configured in the fixture.`;
316
352
  }
317
353
  /**
318
354
  * A run on a `claude` that predates `--tools` exits at argument parsing, and
@@ -321,7 +357,7 @@ function unofferedScriptedTool(model, allowed) {
321
357
  */
322
358
  function unsupportedToolsFlag(stderr) {
323
359
  return /unknown option '--tools'/.test(stderr)
324
- ? `this \`claude\` does not know \`--tools\`, which runHarnessTest uses to withhold every tool not in \`allowedTools\` (#252). Update Claude Code to ${TOOLS_FLAG_MIN_CLAUDE} or newer. Running without it would hand the agent every tool while the test reads as if it were fenced.`
360
+ ? `this \`claude\` does not know \`--tools\`, which runHarnessTest passes for the \`tools\` field (which tools exist). Update Claude Code to ${TOOLS_FLAG_MIN_CLAUDE} or newer. Running without it would hand the agent every tool while the test reads as if it were restricted.`
325
361
  : undefined;
326
362
  }
327
363
  /**
@@ -330,7 +366,7 @@ function unsupportedToolsFlag(stderr) {
330
366
  * here. Pure, so the arg shape is unit-tested.
331
367
  */
332
368
  function buildClaudeArgs(spec, hasSettings) {
333
- const tools = spec.allowedTools ?? ["Read", "Edit", "Write", "Bash"];
369
+ const approved = spec.allowedTools ?? ["Read", "Edit", "Write", "Bash"];
334
370
  return [
335
371
  "-p",
336
372
  spec.prompt ?? "go",
@@ -343,18 +379,15 @@ function buildClaudeArgs(spec, hasSettings) {
343
379
  ? ["--plugin-dir", (0, node_path_1.resolve)(spec.pluginDir)]
344
380
  : []),
345
381
  ...(hasSettings ? ["--settings", "settings.json"] : []),
346
- // An EXPLICIT list restricts: `--tools` is the AVAILABILITY list, a tool left
347
- // out is not offered at all. `--allowedTools` only PRE-APPROVES (it never
348
- // restricts), so on its own it left every unnamed tool runnable (#252). An
349
- // empty list is `--tools ""` = no tools. With no list, nothing is withheld:
350
- // the default four are pre-approved and every other tool stays available.
351
- ...(spec.allowedTools === undefined
382
+ // `tools` = which tools EXIST (`--tools`, the availability list). Omitted:
383
+ // nothing is withheld.
384
+ ...(spec.tools === undefined
352
385
  ? []
353
- : ["--tools", toolAvailabilityList(spec.allowedTools).join(",")]),
354
- // Pre-approval for the offered tools, so none stops on a permission prompt
355
- // (headless). An empty one approves nothing, so the flag is left out (a bare
386
+ : ["--tools", toolAvailabilityList(spec.tools).join(",")]),
387
+ // `allowedTools` = which are PRE-APPROVED (`--allowedTools`); it never
388
+ // restricts. An empty list approves nothing, so the flag is left out (a bare
356
389
  // `--allowedTools` exits before any model turn).
357
- ...(tools.length === 0 ? [] : ["--allowedTools", ...tools]),
390
+ ...(approved.length === 0 ? [] : ["--allowedTools", ...approved]),
358
391
  ];
359
392
  }
360
393
  /** Build the `claude` argv from the driver context — wraps `buildClaudeArgs`. */
@@ -364,7 +397,8 @@ function buildClaudeArgsFromCtx(ctx) {
364
397
  prompt: ctx.prompt,
365
398
  transcript: ctx.transcript,
366
399
  pluginDir: ctx.pluginDir,
367
- allowedTools: ctx.tools,
400
+ tools: ctx.tools,
401
+ allowedTools: ctx.allowedTools,
368
402
  }, ctx.hasSettings);
369
403
  }
370
404
  /** Parse the `claude` stdout/stream into the unified trace fields. */
@@ -480,15 +514,41 @@ function warnUnconsumed(count, sideChannelCount) {
480
514
  if (unconsumed !== undefined)
481
515
  console.error(unconsumed);
482
516
  }
517
+ /**
518
+ * `tools` made only of names the CLI does not know leaves the agent with none:
519
+ * every request then declares no tools, the mock files it as side-channel
520
+ * traffic, and the script is never served, so the run decides nothing. Requests
521
+ * that all came in that way, under an explicit `tools`, mean exactly that. No
522
+ * request at all (a hook stopped the prompt) is a different run. Pure.
523
+ */
524
+ function toolsResolvedToNothing(tools, requests) {
525
+ if (tools === undefined || requests.length === 0)
526
+ return undefined;
527
+ if (!requests.every((r) => r.sideChannel === true))
528
+ return undefined;
529
+ return `tools ${JSON.stringify(tools)} gave the agent no tools: no name in it is a tool this CLI knows, so no request offered a tool and the script was never served. Check the names for typos.`;
530
+ }
531
+ /**
532
+ * What makes a finished run unsound, most specific first, or undefined. Pure.
533
+ */
534
+ function runProblem(stderr, model, modelRequests, tools) {
535
+ return (unsupportedToolsFlag(stderr) ??
536
+ toolsResolvedToNothing(tools, modelRequests) ??
537
+ // A typo'd last turn makes the CLI ask once more, so the overrun follows
538
+ // from it: report the cause, not its effect.
539
+ noSuchToolMessage(model, modelRequests) ??
540
+ (0, script_overrun_js_1.overrunMessageFor)(model.length, modelRequests));
541
+ }
483
542
  /**
484
543
  * A run that proves nothing about the script fails the test instead of coming
485
- * back as a result: the `claude` on PATH predates `--tools` (#252), or the agent
544
+ * back as a result: the `claude` on PATH predates `--tools` (needed for `tools`), or the agent
486
545
  * asked for a model turn the script did not have and the mock answered with an
487
- * error rather than an invented turn (#340). Removes the run's directory, since
546
+ * error rather than an invented turn (#340), or a scripted call named a tool the
547
+ * CLI does not have. Removes the run's directory, since
488
548
  * no result is handed back to clean it up.
489
549
  */
490
- function assertRunSound(cwd, stderr, scripted, modelRequests) {
491
- const problem = unsupportedToolsFlag(stderr) ?? (0, script_overrun_js_1.overrunMessageFor)(scripted, modelRequests);
550
+ function assertRunSound(cwd, stderr, model, modelRequests, tools) {
551
+ const problem = runProblem(stderr, model, modelRequests, tools);
492
552
  if (problem === undefined)
493
553
  return;
494
554
  (0, node_fs_1.rmSync)(cwd, { recursive: true, force: true });
@@ -539,16 +599,30 @@ async function runHarnessTest(spec, opts = {}) {
539
599
  refuseUnrunnableSpec(spec);
540
600
  return runHarnessTestIn(spec, opts, "inherit");
541
601
  }
602
+ /** The driver's view of one run of `spec`. Pure. */
603
+ function driverContext(spec, cwd, hasSettings, mockArgs) {
604
+ return {
605
+ prompt: spec.prompt ?? "go",
606
+ cwd,
607
+ hasSettings,
608
+ tools: spec.tools,
609
+ allowedTools: spec.allowedTools,
610
+ transcript: spec.transcript ?? false,
611
+ pluginDir: spec.pluginDir,
612
+ mockArgs,
613
+ };
614
+ }
542
615
  /**
543
616
  * The scripted model tells an agent turn from the CLI's own bookkeeping calls by
544
617
  * the tools the request declares (`isMainLoopRequest`). An agent with none is
545
618
  * never served a script turn, and the run would decide on nothing.
546
619
  */
547
620
  function refuseUnrunnableSpec(spec) {
548
- if (spec.allowedTools?.length === 0) {
549
- throw new Error("allowedTools: [] leaves the agent with no tools, and the scripted model only serves a turn to a request that declares tools — no script turn would be consumed. Name the tools the agent has.");
621
+ if (spec.tools !== undefined &&
622
+ toolAvailabilityList(spec.tools).length === 0) {
623
+ throw new Error("tools: [] leaves the agent with no tools (a list of blank names counts as empty), and the scripted model only serves a turn to a request that declares tools — no script turn would be consumed. Name the tools the agent has.");
550
624
  }
551
- const unoffered = unofferedScriptedTool(spec.model, spec.allowedTools);
625
+ const unoffered = unofferedScriptedTool(spec.model, spec.tools);
552
626
  if (unoffered !== undefined)
553
627
  throw new Error(unoffered);
554
628
  }
@@ -585,15 +659,18 @@ async function runHarnessTestIn(spec, opts, home) {
585
659
  const cwd = (0, tmp_root_js_1.makeTmpDir)("harness");
586
660
  writeFixture(cwd, files, settings);
587
661
  const timeoutMs = spec.timeoutMs ?? 60000;
588
- const buildArgs = (mockArgs) => driver.buildArgs({
589
- prompt: spec.prompt ?? "go",
590
- cwd,
591
- hasSettings: settings !== undefined,
592
- tools: spec.allowedTools,
593
- transcript: spec.transcript ?? false,
594
- pluginDir: spec.pluginDir,
595
- mockArgs,
596
- });
662
+ // A driver refuses a spec it cannot run (Codex and `tools`) inside buildArgs.
663
+ // That refusal must not leave the run's temp dir behind; the mock, if started,
664
+ // is closed by the caller's finally.
665
+ const buildArgs = (mockArgs) => {
666
+ try {
667
+ return driver.buildArgs(driverContext(spec, cwd, settings !== undefined, mockArgs));
668
+ }
669
+ catch (error) {
670
+ (0, node_fs_1.rmSync)(cwd, { recursive: true, force: true });
671
+ throw error;
672
+ }
673
+ };
597
674
  // Confined path (Claude Code only): the mock is co-launched in the netns, so
598
675
  // the agent reaches it over the loopback URL the sandbox sets — Claude Code is
599
676
  // env-only (empty mockArgs), so the argv needs no mock-wiring flags.
@@ -612,7 +689,7 @@ async function runHarnessTestIn(spec, opts, home) {
612
689
  // warning, which the sandbox path could not emit at all before.
613
690
  const { count, sideChannelCount } = (0, mock_model_js_1.splitRequestCounts)(out.requests);
614
691
  warnUnconsumed(count, sideChannelCount);
615
- assertRunSound(cwd, "", spec.model.length, out.requests);
692
+ assertRunSound(cwd, "", spec.model, out.requests, spec.tools);
616
693
  return check(makeResult(cwd, out, parseClaudeRun(out.stdout), count, out.requests));
617
694
  }
618
695
  // Direct path: mock runs in this process; the agent reaches it over localhost.
@@ -631,7 +708,7 @@ async function runHarnessTestIn(spec, opts, home) {
631
708
  // it (every request arriving without tool declarations, so nothing looked
632
709
  // like an agent turn) instead of leaving it to be rediscovered.
633
710
  warnUnconsumed(mock.count, mock.sideChannelCount ?? 0);
634
- assertRunSound(cwd, out.stderr, spec.model.length, mock.requests);
711
+ assertRunSound(cwd, out.stderr, spec.model, mock.requests, spec.tools);
635
712
  return check(makeResult(cwd, out, driver.parseRun(out.stdout), mock.count, [
636
713
  ...mock.requests,
637
714
  ]));
@@ -29,7 +29,7 @@
29
29
  * a dev box with NO production access;
30
30
  * - point the task at the disposable service's connection string ONLY;
31
31
  * - keep real credentials OUT of the run (prod `DATABASE_URL`, cloud keys,
32
- * `~/.ssh`) — pair it with the eval tier's `ephemeralEnv` (throwaway HOME +
32
+ * `~/.ssh`) — pair it with the eval tier's `env: { kind: "ephemeral" }` (throwaway HOME +
33
33
  * cleared env) to scrub them so the model has no real keys to misuse.
34
34
  * Treat it like running an untrusted script. A future increment adds an egress
35
35
  * wall (the skill reaches only the model + the service); until then that job is
package/dist/services.js CHANGED
@@ -30,7 +30,7 @@
30
30
  * a dev box with NO production access;
31
31
  * - point the task at the disposable service's connection string ONLY;
32
32
  * - keep real credentials OUT of the run (prod `DATABASE_URL`, cloud keys,
33
- * `~/.ssh`) — pair it with the eval tier's `ephemeralEnv` (throwaway HOME +
33
+ * `~/.ssh`) — pair it with the eval tier's `env: { kind: "ephemeral" }` (throwaway HOME +
34
34
  * cleared env) to scrub them so the model has no real keys to misuse.
35
35
  * Treat it like running an untrusted script. A future increment adds an egress
36
36
  * wall (the skill reaches only the model + the service); until then that job is
package/dist/stub.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `experimental_stub` — a tool stub that answers PER INVOCATION, for the eval
3
+ * tier's `stubs`. The rules and their matching are in `core/stub-rules.ts`; this
4
+ * is the typed helper and the check over a run's recorded calls.
5
+ *
6
+ * ONE experimental root (STABILITY.md "one root per feature"): `rest` and
7
+ * `called` are members, so no stable name rests on an unstable one. The rules
8
+ * themselves are plain data (`ToolStub`): a `.mjs` literal of the same shape is
9
+ * accepted, because the spec boundary parses `stubs` either way.
10
+ */
11
+ import type { Check } from "./check.js";
12
+ import type { Trace } from "./harness-test.js";
13
+ import { type ArgvPattern, type ArgvToken, type StubRule, type ToolStub } from "./core/stub-rules.js";
14
+ /** Bounds for {@link experimental_stub.called}. */
15
+ export interface StubCalledBounds {
16
+ /** At least this many matching calls. Default 1, or 0 when `max` is given. */
17
+ readonly min?: number;
18
+ /** At most this many. Default: no limit. */
19
+ readonly max?: number;
20
+ /** Order-free tokens after the positional prefix (needs a trailing `rest`). */
21
+ readonly contains?: readonly ArgvToken[];
22
+ }
23
+ /**
24
+ * A check over a run's stub log: the ANSWERED invocations of `name` that match
25
+ * `argv` (and `bounds.contains`) number within `[min, max]`. A call no rule
26
+ * answered is not counted (it is reported as unanswered, and `vigiles eval`
27
+ * fails the run on it). A trace with no stub log
28
+ * FAILS — "called at most 0 times" must not pass because nothing was recorded.
29
+ */
30
+ declare function called(name: string, argv: ArgvPattern, bounds?: StubCalledBounds): Check<Trace>;
31
+ /** Parse one stub: a throw names the bad field; the value is plain data. */
32
+ declare function stubOf(name: string, rules: readonly [StubRule, ...StubRule[]]): ToolStub;
33
+ /**
34
+ * A stub for the binary `name`: a non-empty list of rules over its argv, first
35
+ * match wins. An invocation no rule answers fails the eval — name each command
36
+ * the agent runs; do not answer everything with one output.
37
+ *
38
+ * ```ts
39
+ * const { rest } = experimental_stub;
40
+ * const gh = experimental_stub("gh", [
41
+ * { argv: ["issue", "create", rest], reply: { kind: "always", stdout: "https://github.com/o/r/issues/9001\n" } },
42
+ * { argv: ["api", /^repos\/o\/r\/issues\?/], reply: { kind: "always", stdout: "[]" } },
43
+ * ]);
44
+ * ```
45
+ *
46
+ * `experimental_stub.rest` — "any further tokens", last position only.
47
+ * `experimental_stub.called(name, argv, { min, max, contains })` — a check over
48
+ * the run's recorded calls.
49
+ *
50
+ * @experimental
51
+ */
52
+ export declare const experimental_stub: typeof stubOf & {
53
+ rest: import("./core/stub-rules.js").ArgvRest;
54
+ called: typeof called;
55
+ };
56
+ export {};
57
+ //# sourceMappingURL=stub.d.ts.map
package/dist/stub.js ADDED
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.experimental_stub = void 0;
4
+ const eval_spec_parse_js_1 = require("./core/eval-spec-parse.js");
5
+ const stub_rules_js_1 = require("./core/stub-rules.js");
6
+ const result = (pass, message) => ({
7
+ pass,
8
+ score: pass ? 1 : 0,
9
+ message,
10
+ });
11
+ /** The failure for a trace that carries no stub log at all. */
12
+ const noLog = (shown) => result(false, `stub ${shown}: this trace has no stub log — the run declared no stubs, or its tier does not record them`);
13
+ /**
14
+ * How many of a run's stub calls are `name` with an argv matching `argv` (+
15
+ * `contains`), split by whether a rule answered them. Only an ANSWERED call
16
+ * counts toward `called`: "the tool was called" in a measurement means the
17
+ * agent got its answer, so a create that came back as the miss line did not
18
+ * file anything. The run fails on the unanswered call separately.
19
+ */
20
+ function countCalls(calls, name, argv, contains) {
21
+ const matching = calls.filter((c) => c.tool === name && (0, stub_rules_js_1.matchArgv)({ argv, contains }, c.argv));
22
+ const answered = matching.filter((c) => !(0, stub_rules_js_1.isUnanswered)(c.outcome)).length;
23
+ return { answered, unanswered: matching.length - answered };
24
+ }
25
+ /** The [min, max] a `called` check accepts, and how a report prints it. */
26
+ function rangeOf(bounds) {
27
+ const min = bounds.min ?? (bounds.max === undefined ? 1 : 0);
28
+ const max = bounds.max ?? Number.POSITIVE_INFINITY;
29
+ const text = Number.isFinite(max)
30
+ ? `${String(min)}..${String(max)}`
31
+ : `at least ${String(min)}`;
32
+ return { min, max, text };
33
+ }
34
+ /** The verdict of a `called` check over a run's recorded calls. */
35
+ function calledVerdict(calls, shown, expect) {
36
+ const { name, argv, ...bounds } = expect;
37
+ const { min, max, text } = rangeOf(bounds);
38
+ const { answered: n, unanswered } = countCalls(calls, name, argv, bounds.contains);
39
+ const missed = unanswered === 0
40
+ ? ""
41
+ : `; ${String(unanswered)} matching call(s) went unanswered and are not counted`;
42
+ const msg = `stub ${shown} called ${String(n)} time(s) (expected ${text})${missed}`;
43
+ return result(n >= min && n <= max, msg);
44
+ }
45
+ /**
46
+ * A check over a run's stub log: the ANSWERED invocations of `name` that match
47
+ * `argv` (and `bounds.contains`) number within `[min, max]`. A call no rule
48
+ * answered is not counted (it is reported as unanswered, and `vigiles eval`
49
+ * fails the run on it). A trace with no stub log
50
+ * FAILS — "called at most 0 times" must not pass because nothing was recorded.
51
+ */
52
+ function called(name, argv, bounds = {}) {
53
+ // The pattern is parsed the way a rule is (rest position, RegExp flags).
54
+ const rule = { argv, contains: bounds.contains, reply: { kind: "always" } };
55
+ (0, eval_spec_parse_js_1.parseToolStubs)([{ name, rules: [rule] }], "experimental_stub.called");
56
+ const { min, max } = rangeOf(bounds);
57
+ const shown = `${name} ${(0, stub_rules_js_1.describePattern)(argv)}`;
58
+ return {
59
+ kind: "stubCalled",
60
+ eval(t) {
61
+ if (t.stubCalls === undefined)
62
+ return noLog(shown);
63
+ return calledVerdict(t.stubCalls, shown, { name, argv, ...bounds });
64
+ },
65
+ toJSON: () => ({
66
+ kind: "stubCalled",
67
+ tool: name,
68
+ argv: (0, stub_rules_js_1.describePattern)(argv),
69
+ min,
70
+ max: Number.isFinite(max) ? max : null,
71
+ }),
72
+ };
73
+ }
74
+ /** Parse one stub: a throw names the bad field; the value is plain data. */
75
+ function stubOf(name, rules) {
76
+ (0, eval_spec_parse_js_1.parseToolStubs)([{ name, rules }], "experimental_stub");
77
+ return (0, stub_rules_js_1.toToolStub)({ name, rules });
78
+ }
79
+ /**
80
+ * A stub for the binary `name`: a non-empty list of rules over its argv, first
81
+ * match wins. An invocation no rule answers fails the eval — name each command
82
+ * the agent runs; do not answer everything with one output.
83
+ *
84
+ * ```ts
85
+ * const { rest } = experimental_stub;
86
+ * const gh = experimental_stub("gh", [
87
+ * { argv: ["issue", "create", rest], reply: { kind: "always", stdout: "https://github.com/o/r/issues/9001\n" } },
88
+ * { argv: ["api", /^repos\/o\/r\/issues\?/], reply: { kind: "always", stdout: "[]" } },
89
+ * ]);
90
+ * ```
91
+ *
92
+ * `experimental_stub.rest` — "any further tokens", last position only.
93
+ * `experimental_stub.called(name, argv, { min, max, contains })` — a check over
94
+ * the run's recorded calls.
95
+ *
96
+ * @experimental
97
+ */
98
+ // eslint-disable-next-line functional/immutable-data -- the one experimental root: a callable with members, built the way experimental_agent is
99
+ exports.experimental_stub = Object.assign(stubOf, {
100
+ rest: stub_rules_js_1.ARGV_REST,
101
+ called,
102
+ });
103
+ //# sourceMappingURL=stub.js.map
package/dist/test.d.ts CHANGED
@@ -68,7 +68,10 @@ export { DISASTER_CATALOG, verifyGuardrail, unblockedDisasters, assertBlocksDisa
68
68
  export type { DisasterEvent, DisasterCategory, GuardrailResult, VerifyGuardrailOptions, } from "./guardrail-check.js";
69
69
  export { experimental_verifyPluginGuards, experimental_formatPluginGuardReport, } from "./verify-plugin-guards.js";
70
70
  export type { PluginGuardReport, SweptHook, SweptHookOutcome, NonCommandHookAction, VerifyPluginGuardsOptions, } from "./verify-plugin-guards.js";
71
- export * from "./tool-stub.js";
71
+ export { experimental_stub } from "./stub.js";
72
+ export type { StubCalledBounds } from "./stub.js";
73
+ export type { ArgvPattern, ArgvRest, ArgvToken, StubAnswer, StubCall, StubOutcome, StubReply, StubRule, ToolStub, } from "./core/stub-rules.js";
74
+ export type { HomeFiles, HomeSeed, RunEnv } from "./core/run-env.js";
72
75
  export { runHarnessTest, runHarness, parseToolCalls, parseSubagents, parseResultEvent, parseOutput, parseHooks, decideSandbox, specTrusted, sandboxAvailable, } from "./harness-test.js";
73
76
  export { experimental_outputStyleArms } from "./output-style-arms.js";
74
77
  export type { OutputStyleArms } from "./output-style-arms.js";
package/dist/test.js CHANGED
@@ -71,8 +71,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
71
71
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
72
72
  };
73
73
  Object.defineProperty(exports, "__esModule", { value: true });
74
- exports.runHarness = exports.runHarnessTest = exports.experimental_formatPluginGuardReport = exports.experimental_verifyPluginGuards = exports.experimental_alternateSpellings = exports.formatGuardrailReport = exports.assertBlocksDisasters = exports.unblockedDisasters = exports.verifyGuardrail = exports.DISASTER_CATALOG = exports.cacheTokens = exports.outputTokens = exports.inputTokens = exports.tokens = exports.latency = exports.cost = exports.mcp = exports.allowed = exports.blocked = exports.subagent = exports.didNotWrite = exports.wrote = exports.turns = exports.received = exports.hookFired = exports.experimental_replyCount = exports.experimental_eachReply = exports.output = exports.skill = exports.onlyTools = exports.notTool = exports.toolWith = exports.tool = exports.assertChecks = exports.evalChecks = exports.experimental_hookState = exports.loadHook = exports.experimental_assertEmittedOk = exports.experimental_parseEmitted = exports.experimental_emitTool = exports.egressRoutes = exports.fileToolEvents = exports.propertyHook = exports.decideHook = exports.parseHookOutput = exports.runHook = exports.runScript = exports.cleanupTmpDir = exports.makeTmpDir = exports.recordCheck = void 0;
75
- exports.experimental_makeDockerRuntime = exports.experimental_dockerRuntime = exports.experimental_withServices = exports.experimental_startServices = exports.stubSkillBody = exports.parseClaudeRun = exports.formatTriggerRateReport = exports.formatEvalReport = exports.formatCheckReport = exports.checkReportToJUnit = exports.checkPromptDiversity = exports.assertPromptDiversity = exports.assertRates = exports.defineEval = exports.formatContainment = exports.compareContainment = exports.skillContract = exports.mustNotInclude = exports.mustInclude = exports.commandsIn = exports.experimental_outputStyleArms = exports.sandboxAvailable = exports.specTrusted = exports.decideSandbox = exports.parseHooks = exports.parseOutput = exports.parseResultEvent = exports.parseSubagents = exports.parseToolCalls = void 0;
74
+ exports.runHarnessTest = exports.experimental_stub = exports.experimental_formatPluginGuardReport = exports.experimental_verifyPluginGuards = exports.experimental_alternateSpellings = exports.formatGuardrailReport = exports.assertBlocksDisasters = exports.unblockedDisasters = exports.verifyGuardrail = exports.DISASTER_CATALOG = exports.cacheTokens = exports.outputTokens = exports.inputTokens = exports.tokens = exports.latency = exports.cost = exports.mcp = exports.allowed = exports.blocked = exports.subagent = exports.didNotWrite = exports.wrote = exports.turns = exports.received = exports.hookFired = exports.experimental_replyCount = exports.experimental_eachReply = exports.output = exports.skill = exports.onlyTools = exports.notTool = exports.toolWith = exports.tool = exports.assertChecks = exports.evalChecks = exports.experimental_hookState = exports.loadHook = exports.experimental_assertEmittedOk = exports.experimental_parseEmitted = exports.experimental_emitTool = exports.egressRoutes = exports.fileToolEvents = exports.propertyHook = exports.decideHook = exports.parseHookOutput = exports.runHook = exports.runScript = exports.cleanupTmpDir = exports.makeTmpDir = exports.recordCheck = void 0;
75
+ exports.experimental_makeDockerRuntime = exports.experimental_dockerRuntime = exports.experimental_withServices = exports.experimental_startServices = exports.stubSkillBody = exports.parseClaudeRun = exports.formatTriggerRateReport = exports.formatEvalReport = exports.formatCheckReport = exports.checkReportToJUnit = exports.checkPromptDiversity = exports.assertPromptDiversity = exports.assertRates = exports.defineEval = exports.formatContainment = exports.compareContainment = exports.skillContract = exports.mustNotInclude = exports.mustInclude = exports.commandsIn = exports.experimental_outputStyleArms = exports.sandboxAvailable = exports.specTrusted = exports.decideSandbox = exports.parseHooks = exports.parseOutput = exports.parseResultEvent = exports.parseSubagents = exports.parseToolCalls = exports.runHarness = void 0;
76
76
  // --- reporting: how much did this script actually do? ---
77
77
  // `vigiles test` can otherwise see only an exit code, so a file that runs NOTHING
78
78
  // prints the same `✓` as one that ran and passed (measured 2026-08-08 on a file
@@ -196,8 +196,12 @@ Object.defineProperty(exports, "experimental_alternateSpellings", { enumerable:
196
196
  var verify_plugin_guards_js_1 = require("./verify-plugin-guards.js");
197
197
  Object.defineProperty(exports, "experimental_verifyPluginGuards", { enumerable: true, get: function () { return verify_plugin_guards_js_1.experimental_verifyPluginGuards; } });
198
198
  Object.defineProperty(exports, "experimental_formatPluginGuardReport", { enumerable: true, get: function () { return verify_plugin_guards_js_1.experimental_formatPluginGuardReport; } });
199
- // Tool stubs on PATH (rung R2): shadow a CLI tool with a recorded canned result.
200
- __exportStar(require("./tool-stub.js"), exports);
199
+ // Tool stubs on PATH (rung R2): a fake binary that answers PER INVOCATION, by
200
+ // rules over its argv. The helper and its check are experimental; the rule
201
+ // shapes are plain data, because a `.mjs` literal of the same shape is accepted
202
+ // at the spec boundary. Writing the shims is internal (`tool-stub.ts`).
203
+ var stub_js_1 = require("./stub.js");
204
+ Object.defineProperty(exports, "experimental_stub", { enumerable: true, get: function () { return stub_js_1.experimental_stub; } });
201
205
  // The assembled machine — AGNOSTIC SURFACE ONLY. The Claude-Code transport
202
206
  // (`scriptModel`, `claudeCodeDriver`, `buildClaudeArgs`, `claudeAvailable`,
203
207
  // `loadPlugin`, `resolveHarness`) is deliberately NOT re-exported here, so this