vigiles 35.0.0 → 36.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.
@@ -7,6 +7,9 @@ import type { HarnessTestDriver, HarnessDriverContext, ParsedRun } from "../../c
7
7
  * `--dangerously-bypass-approvals-and-sandbox` make it run unattended;
8
8
  * `--skip-git-repo-check` lets it run in a bare temp dir; `--ignore-user-config`
9
9
  * keeps the host's `~/.codex` out. The prompt is the trailing positional.
10
+ * `ctx.allowedTools` is ignored: `codex exec` runs with approvals bypassed, so
11
+ * everything is already approved. `ctx.tools` is refused (throws): Codex has no
12
+ * per-run tool availability.
10
13
  * Pure, so the arg shape is unit-tested.
11
14
  */
12
15
  export declare function buildCodexArgs(ctx: HarnessDriverContext): string[];
@@ -26,9 +26,15 @@ const mock_model_js_1 = require("./mock-model.js");
26
26
  * `--dangerously-bypass-approvals-and-sandbox` make it run unattended;
27
27
  * `--skip-git-repo-check` lets it run in a bare temp dir; `--ignore-user-config`
28
28
  * keeps the host's `~/.codex` out. The prompt is the trailing positional.
29
+ * `ctx.allowedTools` is ignored: `codex exec` runs with approvals bypassed, so
30
+ * everything is already approved. `ctx.tools` is refused (throws): Codex has no
31
+ * per-run tool availability.
29
32
  * Pure, so the arg shape is unit-tested.
30
33
  */
31
34
  function buildCodexArgs(ctx) {
35
+ if (ctx.tools !== undefined) {
36
+ throw new Error("tools is not supported for codex: it has no per-run tool availability, and a list that withheld nothing would let a test pass over a run where nothing was withheld. Drop tools, or assert on what the agent did.");
37
+ }
32
38
  return [
33
39
  "exec",
34
40
  "--ignore-user-config",
@@ -108,10 +108,17 @@ export interface HarnessDriverContext {
108
108
  /** Whether a settings file was written (CC: pass `--settings`). */
109
109
  readonly hasSettings: boolean;
110
110
  /**
111
- * The ONLY tools the agent has, when the spec lists them; undefined = no
112
- * restriction (every tool is offered, the default four are pre-approved).
111
+ * Which tools EXIST in the session, when the spec lists them (CC: `--tools`);
112
+ * undefined = every tool exists. A driver whose harness has no such concept
113
+ * (Codex) throws on one rather than ignore it.
113
114
  */
114
115
  readonly tools?: readonly string[];
116
+ /**
117
+ * Which tools are PRE-APPROVED (CC: `--allowedTools`); undefined = the
118
+ * default four. It never removes a tool. A driver with no such concept
119
+ * ignores it.
120
+ */
121
+ readonly allowedTools?: readonly string[];
115
122
  /** Capture the full event transcript instead of just the final result. */
116
123
  readonly transcript: boolean;
117
124
  /** Path to a plugin dir to install natively, if any. */
@@ -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
@@ -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
  ]));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "35.0.0",
3
+ "version": "36.0.0",
4
4
  "description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
5
5
  "keywords": [
6
6
  "claude-code",