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.
- package/dist/adapters/codex/driver.d.ts +3 -0
- package/dist/adapters/codex/driver.js +6 -0
- package/dist/cli-commands.d.ts +1 -1
- package/dist/cli-commands.js +1 -0
- package/dist/cli-main.js +13 -0
- package/dist/cli.js +51 -0
- package/dist/core/eval-driver.d.ts +9 -1
- package/dist/core/eval-spec-parse.d.ts +14 -0
- package/dist/core/eval-spec-parse.js +168 -0
- package/dist/core/harness-driver.d.ts +9 -2
- package/dist/core/run-env.d.ts +46 -1
- package/dist/core/run-env.js +59 -0
- package/dist/core/stub-rules.d.ts +199 -0
- package/dist/core/stub-rules.js +397 -0
- package/dist/eval-cache.d.ts +19 -1
- package/dist/eval-cache.js +1 -1
- package/dist/eval-entry.d.ts +28 -0
- package/dist/eval-entry.js +63 -10
- package/dist/eval-surface.d.ts +2 -0
- package/dist/eval.d.ts +80 -32
- package/dist/eval.js +293 -91
- package/dist/harness-test.d.ts +61 -23
- package/dist/harness-test.js +125 -48
- package/dist/services.d.ts +1 -1
- package/dist/services.js +1 -1
- package/dist/stub.d.ts +57 -0
- package/dist/stub.js +103 -0
- package/dist/test.d.ts +4 -1
- package/dist/test.js +8 -4
- package/dist/tool-stub.d.ts +24 -29
- package/dist/tool-stub.js +63 -74
- package/package.json +1 -1
- package/skills/test-harness/references/observing-a-run.md +13 -12
- package/skills/test-harness/references/writing-tests.md +1 -0
package/dist/harness-test.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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`
|
|
135
|
-
* takes permission rules (`Bash(git *)`); `--tools` takes tool
|
|
136
|
-
*
|
|
137
|
-
*
|
|
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(
|
|
160
|
+
export declare function toolAvailabilityList(tools: readonly string[]): readonly string[];
|
|
140
161
|
/**
|
|
141
|
-
* A scripted call to a tool an EXPLICIT `
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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[],
|
|
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
|
-
* `
|
|
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
|
package/dist/harness-test.js
CHANGED
|
@@ -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`
|
|
291
|
-
* takes permission rules (`Bash(git *)`); `--tools` takes tool
|
|
292
|
-
*
|
|
293
|
-
*
|
|
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(
|
|
296
|
-
const names =
|
|
297
|
-
|
|
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 `
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
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,
|
|
307
|
-
if (
|
|
315
|
+
function unofferedScriptedTool(model, tools) {
|
|
316
|
+
if (tools === undefined)
|
|
308
317
|
return undefined;
|
|
309
|
-
const
|
|
318
|
+
const exist = toolAvailabilityList(tools);
|
|
310
319
|
const at = model.findIndex((t) => t.tool !== undefined &&
|
|
311
320
|
!t.tool.startsWith("mcp__") &&
|
|
312
|
-
!
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
347
|
-
//
|
|
348
|
-
|
|
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.
|
|
354
|
-
//
|
|
355
|
-
//
|
|
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
|
-
...(
|
|
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
|
-
|
|
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` (
|
|
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)
|
|
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,
|
|
491
|
-
const problem =
|
|
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.
|
|
549
|
-
|
|
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.
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
|
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
|
|
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/dist/services.d.ts
CHANGED
|
@@ -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 `
|
|
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 `
|
|
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
|
|
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.
|
|
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):
|
|
200
|
-
|
|
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
|