@vibe-agent-toolkit/agent-skills 0.1.39-rc.9 → 0.1.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/files-config.d.ts +61 -0
  2. package/dist/files-config.d.ts.map +1 -1
  3. package/dist/files-config.js +262 -6
  4. package/dist/files-config.js.map +1 -1
  5. package/dist/index.d.ts +4 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +4 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/plugin-distribution-layout.d.ts +70 -0
  10. package/dist/plugin-distribution-layout.d.ts.map +1 -0
  11. package/dist/plugin-distribution-layout.js +97 -0
  12. package/dist/plugin-distribution-layout.js.map +1 -0
  13. package/dist/skill-packager.d.ts +43 -1
  14. package/dist/skill-packager.d.ts.map +1 -1
  15. package/dist/skill-packager.js +131 -53
  16. package/dist/skill-packager.js.map +1 -1
  17. package/dist/skill-source/git-clone.d.ts +2 -2
  18. package/dist/skill-source/git-clone.d.ts.map +1 -1
  19. package/dist/skill-source/git-clone.js +33 -10
  20. package/dist/skill-source/git-clone.js.map +1 -1
  21. package/dist/skill-source/sources/url-source.d.ts.map +1 -1
  22. package/dist/skill-source/sources/url-source.js +23 -8
  23. package/dist/skill-source/sources/url-source.js.map +1 -1
  24. package/dist/skill-source/sources/workspace-source.d.ts.map +1 -1
  25. package/dist/skill-source/sources/workspace-source.js +16 -8
  26. package/dist/skill-source/sources/workspace-source.js.map +1 -1
  27. package/dist/skill-source/stage.js +6 -2
  28. package/dist/skill-source/stage.js.map +1 -1
  29. package/dist/skill-test/configure-writer.d.ts +19 -11
  30. package/dist/skill-test/configure-writer.d.ts.map +1 -1
  31. package/dist/skill-test/configure-writer.js +23 -15
  32. package/dist/skill-test/configure-writer.js.map +1 -1
  33. package/dist/skill-test/declared-env.d.ts +72 -0
  34. package/dist/skill-test/declared-env.d.ts.map +1 -0
  35. package/dist/skill-test/declared-env.js +85 -0
  36. package/dist/skill-test/declared-env.js.map +1 -0
  37. package/dist/skill-test/eval-inputs.d.ts +159 -0
  38. package/dist/skill-test/eval-inputs.d.ts.map +1 -0
  39. package/dist/skill-test/eval-inputs.js +158 -0
  40. package/dist/skill-test/eval-inputs.js.map +1 -0
  41. package/dist/skill-test/evals-template.d.ts.map +1 -1
  42. package/dist/skill-test/evals-template.js +27 -10
  43. package/dist/skill-test/evals-template.js.map +1 -1
  44. package/dist/skill-test/exit-codes.d.ts +43 -5
  45. package/dist/skill-test/exit-codes.d.ts.map +1 -1
  46. package/dist/skill-test/exit-codes.js +60 -9
  47. package/dist/skill-test/exit-codes.js.map +1 -1
  48. package/dist/skill-test/experimenter-prompt.d.ts +22 -0
  49. package/dist/skill-test/experimenter-prompt.d.ts.map +1 -1
  50. package/dist/skill-test/experimenter-prompt.js +38 -2
  51. package/dist/skill-test/experimenter-prompt.js.map +1 -1
  52. package/dist/skill-test/grading-adapter.d.ts +42 -0
  53. package/dist/skill-test/grading-adapter.d.ts.map +1 -1
  54. package/dist/skill-test/grading-adapter.js +63 -1
  55. package/dist/skill-test/grading-adapter.js.map +1 -1
  56. package/dist/skill-test/grading-schema.d.ts +73 -5
  57. package/dist/skill-test/grading-schema.d.ts.map +1 -1
  58. package/dist/skill-test/grading-schema.js +20 -5
  59. package/dist/skill-test/grading-schema.js.map +1 -1
  60. package/dist/skill-test/harness-location.d.ts +22 -4
  61. package/dist/skill-test/harness-location.d.ts.map +1 -1
  62. package/dist/skill-test/harness-location.js +83 -18
  63. package/dist/skill-test/harness-location.js.map +1 -1
  64. package/dist/skill-test/index.d.ts +7 -4
  65. package/dist/skill-test/index.d.ts.map +1 -1
  66. package/dist/skill-test/index.js +7 -4
  67. package/dist/skill-test/index.js.map +1 -1
  68. package/dist/skill-test/lock.d.ts +23 -0
  69. package/dist/skill-test/lock.d.ts.map +1 -1
  70. package/dist/skill-test/lock.js +34 -0
  71. package/dist/skill-test/lock.js.map +1 -1
  72. package/dist/skill-test/preflight.d.ts.map +1 -1
  73. package/dist/skill-test/preflight.js +1 -2
  74. package/dist/skill-test/preflight.js.map +1 -1
  75. package/dist/skill-test/run-harness.d.ts +193 -19
  76. package/dist/skill-test/run-harness.d.ts.map +1 -1
  77. package/dist/skill-test/run-harness.js +366 -76
  78. package/dist/skill-test/run-harness.js.map +1 -1
  79. package/dist/skill-test/staging.d.ts.map +1 -1
  80. package/dist/skill-test/staging.js +31 -15
  81. package/dist/skill-test/staging.js.map +1 -1
  82. package/dist/skill-test/vendor-manifest.d.ts +3 -3
  83. package/dist/skill-test/vendor-manifest.d.ts.map +1 -1
  84. package/dist/skill-test/vendor-manifest.js +13 -3
  85. package/dist/skill-test/vendor-manifest.js.map +1 -1
  86. package/dist/validators/packaging-validator.d.ts.map +1 -1
  87. package/dist/validators/packaging-validator.js +134 -1
  88. package/dist/validators/packaging-validator.js.map +1 -1
  89. package/dist/validators/skill-validator.d.ts.map +1 -1
  90. package/dist/validators/skill-validator.js +18 -14
  91. package/dist/validators/skill-validator.js.map +1 -1
  92. package/dist/walk-link-graph.d.ts.map +1 -1
  93. package/dist/walk-link-graph.js +26 -1
  94. package/dist/walk-link-graph.js.map +1 -1
  95. package/package.json +6 -5
@@ -8,19 +8,20 @@
8
8
  * → build effective prompt + assertPromptInvariants → dry-run short-circuit
9
9
  * → spawnHeadlessClaude → parseGradingJson → release lock → return result
10
10
  */
11
- import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { randomBytes } from 'node:crypto';
12
+ import { cpSync, existsSync, lstatSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
12
13
  import { basename, dirname } from 'node:path';
13
14
  import { fileURLToPath } from 'node:url';
14
15
  import { getToolVersion, mkdirSyncReal, normalizedTmpdir, probeAuthStatus, resolveAssetReference, safeExecResult, safePath, spawnHeadlessClaude, toForwardSlash, } from '@vibe-agent-toolkit/utils';
15
16
  import { resolveSkillSource } from '../skill-source/resolve-skill-source.js';
16
- import { runPreStageBuild } from './build-hook.js';
17
+ import { assembleChildEnv, computeEnvTokens, resolveInjectEnv } from './declared-env.js';
18
+ import { EvalInputError, parseEvalSuite, stageEvalWorkspaces } from './eval-inputs.js';
17
19
  import { writeEvalsTemplate } from './evals-template.js';
18
- import { BootstrapNeededError, InternalHarnessError, SkillTestExitCode } from './exit-codes.js';
19
- import { assertPromptInvariants, buildExperimenterPrompt, } from './experimenter-prompt.js';
20
- import { parseGradingJson } from './grading-adapter.js';
20
+ import { BootstrapNeededError, InternalHarnessError, SkillTestExitCode, } from './exit-codes.js';
21
+ import { appendIntegrityNonceDirective, assertPromptInvariants, buildExperimenterPrompt, redactNonce, } from './experimenter-prompt.js';
22
+ import { assertGradingNonce, parseGradingJson, reconcileGrading } from './grading-adapter.js';
21
23
  import { assertSafeHarnessRoot, assertSafeWorkdir, prepareHarnessRoot, resolveHarnessRoot } from './harness-location.js';
22
- import { acquireHarnessLock } from './lock.js';
23
- import { withPluginRootEnv } from './plugin-env.js';
24
+ import { acquireHarnessLock, installSignalCleanup } from './lock.js';
24
25
  import { detectPluginLayout } from './plugin-layout.js';
25
26
  import { runPreflight } from './preflight.js';
26
27
  import { descriptorToSource, stageHarness } from './staging.js';
@@ -41,13 +42,42 @@ const VENDORED_SKILL_CREATOR_DIR = safePath.resolve(dirname(fileURLToPath(import
41
42
  const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
42
43
  const DEFAULT_MAX_TURNS = 50;
43
44
  const DEFAULT_MAX_BUDGET_USD = 5;
44
- function resolveTimeoutMs(opts) {
45
+ /**
46
+ * Built-in cost/runtime safety ceilings for the experimenter spawn. These are the
47
+ * SAME values the harness applies as defaults, but exported as an explicit cap so
48
+ * the CLI precedence layer (run.ts) can enforce a critical asymmetry:
49
+ *
50
+ * - a CLI flag (explicit operator intent, typed at the terminal for THIS run)
51
+ * may RAISE a knob above the built-in ceiling;
52
+ * - a value sourced from a committed `test.*` config (which rides along in an
53
+ * untrusted subject repo you may only be testing) may only LOWER a ceiling,
54
+ * never raise it — so cloning + testing a hostile skill can't silently
55
+ * escalate the $5 / 50-turn / 5-minute budget the run bills against.
56
+ *
57
+ * `timeoutSeconds` is expressed in seconds to match the `--timeout`/`test.timeout`
58
+ * unit (the harness multiplies by 1000 internally; see resolveTimeoutMs).
59
+ */
60
+ export const SKILL_TEST_BUILTIN_CAPS = {
61
+ maxTurns: DEFAULT_MAX_TURNS,
62
+ maxBudgetUsd: DEFAULT_MAX_BUDGET_USD,
63
+ timeoutSeconds: DEFAULT_TIMEOUT_MS / 1000,
64
+ };
65
+ export function resolveTimeoutMs(opts) {
45
66
  return opts.timeout === undefined ? DEFAULT_TIMEOUT_MS : opts.timeout * 1000;
46
67
  }
47
- function resolveStallMs(opts) {
68
+ /**
69
+ * Map an eval verdict to a process exit code. Default behavior: a completed run
70
+ * exits Ok regardless of pass/fail (the count lives in the summary). When
71
+ * `failOnEvalFailure` is set, a failing verdict escalates to EvalFailure (4) so
72
+ * CI can gate on eval outcomes without conflating them with harness breakage.
73
+ */
74
+ export function verdictExitCode(allPassed, failOnEvalFailure) {
75
+ return failOnEvalFailure && !allPassed ? SkillTestExitCode.EvalFailure : SkillTestExitCode.Ok;
76
+ }
77
+ export function resolveStallMs(opts) {
48
78
  return opts.stall === undefined ? undefined : opts.stall * 1000;
49
79
  }
50
- function resolveKnobs(opts) {
80
+ export function resolveKnobs(opts) {
51
81
  const result = {
52
82
  maxTurns: opts.maxTurns ?? DEFAULT_MAX_TURNS,
53
83
  maxBudgetUsd: opts.maxBudgetUsd ?? DEFAULT_MAX_BUDGET_USD,
@@ -67,13 +97,13 @@ function resolveKnobs(opts) {
67
97
  * sources (npm/url/vendored/workspace) have no local source tree to walk, so they
68
98
  * are always staged flat (returns undefined). undefined → flat staging.
69
99
  */
70
- function detectItemPluginLayout(source, repoRoot) {
100
+ export function detectItemPluginLayout(source, repoRoot) {
71
101
  if (!('path' in source))
72
102
  return undefined;
73
103
  const sourceDir = resolveAssetReference(source.path, repoRoot);
74
104
  return detectPluginLayout(sourceDir, existsSync) ?? undefined;
75
105
  }
76
- function makeStageItem(name, source, repoRoot, role) {
106
+ export function makeStageItem(name, source, repoRoot, role) {
77
107
  const pluginLayout = detectItemPluginLayout(source, repoRoot);
78
108
  return {
79
109
  name,
@@ -82,16 +112,23 @@ function makeStageItem(name, source, repoRoot, role) {
82
112
  ...(pluginLayout === undefined ? {} : { pluginLayout }),
83
113
  };
84
114
  }
85
- function buildStageItems(opts, repoRoot) {
115
+ export function buildStageItems(opts, repoRoot) {
86
116
  const items = [];
87
117
  // The FIRST positional skill is the subject under test; the rest of the
88
118
  // primary set and any `--with`/`--with-optional` deps are supporting context.
89
119
  const subjectName = opts.skills[0];
90
120
  for (const name of opts.skills) {
91
121
  const override = opts.withSources?.[name];
92
- const source = override
93
- ? descriptorToSource(override)
94
- : { path: name };
122
+ let source;
123
+ if (name === subjectName && opts.subjectSource !== undefined) {
124
+ source = opts.subjectSource;
125
+ }
126
+ else if (override) {
127
+ source = descriptorToSource(override);
128
+ }
129
+ else {
130
+ source = { path: name };
131
+ }
95
132
  items.push(makeStageItem(name, source, repoRoot, name === subjectName ? 'subject' : undefined));
96
133
  }
97
134
  if (opts.withOptional !== undefined) {
@@ -102,7 +139,7 @@ function buildStageItems(opts, repoRoot) {
102
139
  }
103
140
  return items;
104
141
  }
105
- function buildResolveCtx(harnessRoot, repoRoot) {
142
+ export function buildResolveCtx(harnessRoot, repoRoot) {
106
143
  return {
107
144
  repoRoot,
108
145
  stagingRoot: safePath.joinUnderRoot(harnessRoot, 'staged'),
@@ -128,7 +165,7 @@ const FLAG_DUMMY_VALUES = {
128
165
  * Return a sensible dummy value for a given CLI flag so that value-validation
129
166
  * doesn't reject the argument before `--help` can short-circuit the session.
130
167
  */
131
- function flagDummyValueFor(flag) {
168
+ export function flagDummyValueFor(flag) {
132
169
  return FLAG_DUMMY_VALUES[flag] ?? '1';
133
170
  }
134
171
  /**
@@ -156,7 +193,7 @@ function buildFlagParseProbe() {
156
193
  return result.success;
157
194
  };
158
195
  }
159
- function buildPreflightInput(evalsPath, pluginDirs, opts, knobs) {
196
+ export function buildPreflightInput(evalsPath, pluginDirs, opts, knobs) {
160
197
  const costEstimate = {
161
198
  evalCount: 1,
162
199
  configurations: 1,
@@ -185,13 +222,20 @@ function buildPreflightInput(evalsPath, pluginDirs, opts, knobs) {
185
222
  preflightOpts.requireAuth = opts.requireAuth;
186
223
  return preflightOpts;
187
224
  }
188
- function renderPreflightSummary(checks) {
225
+ export function renderPreflightSummary(checks) {
189
226
  const failed = checks.filter(c => !c.passed);
190
227
  return failed.length > 0
191
228
  ? failed.map(c => ` [FAIL] ${c.name}: ${c.message}`).join('\n')
192
229
  : ' All preflight checks passed.';
193
230
  }
194
- function isAcknowledged(opts) {
231
+ /**
232
+ * The single source of truth for "is this run acknowledged?": a dry-run never
233
+ * executes skill code (so it is implicitly acknowledged), otherwise the caller
234
+ * must pass --i-understand-this-runs-skill-code. Narrowed to the two fields it
235
+ * reads so run.ts can reuse the SAME predicate to gate the pre-build ack check
236
+ * (the harness Step-6 check and the run.ts pre-build check cannot diverge).
237
+ */
238
+ export function isAcknowledged(opts) {
195
239
  return opts.dryRun === true || opts.acknowledgedRunsSkillCode === true;
196
240
  }
197
241
  /**
@@ -199,7 +243,7 @@ function isAcknowledged(opts) {
199
243
  * A stall, a timeout, OR a non-zero exit are each authoritative — a non-zero exit
200
244
  * is never laundered into a PASS even if a grading.json happens to be on disk.
201
245
  */
202
- function assertExperimenterSucceeded(spawnResult, stallMs, timeoutMs) {
246
+ export function assertExperimenterSucceeded(spawnResult, stallMs, timeoutMs) {
203
247
  if (spawnResult.stalled) {
204
248
  throw new InternalHarnessError(`Experimenter stalled (no output for ${stallMs ?? 0}ms).`);
205
249
  }
@@ -217,20 +261,197 @@ function assertExperimenterSucceeded(spawnResult, stallMs, timeoutMs) {
217
261
  * that real source dir; otherwise we anchor under the repo root by skill name.
218
262
  */
219
263
  /** The subject skill's display name (trailing segment of the positional skill arg). */
220
- function subjectSkillName(opts) {
264
+ export function subjectSkillName(opts) {
221
265
  const subject = opts.skills[0] ?? 'skill';
222
266
  return basename(toForwardSlash(subject)) || subject;
223
267
  }
224
- function resolveScaffoldEvalsPath(opts, repoRoot, evalsSubpath) {
268
+ export function resolveScaffoldEvalsPath(opts, repoRoot, evalsSubpath) {
269
+ // Prefer the explicit authored source dir resolved by run.ts (the staged/built
270
+ // tree is ephemeral; this is where the user can edit the scaffolded template).
271
+ if (opts.subjectScaffoldDir !== undefined) {
272
+ return safePath.join(opts.subjectScaffoldDir, evalsSubpath);
273
+ }
225
274
  const subjectName = opts.skills[0] ?? '';
226
275
  const override = opts.withSources?.[subjectName];
227
- const overridePath = override && typeof override.path === 'string' ? override.path : undefined;
276
+ const overridePath = override && 'path' in override ? override.path : undefined;
228
277
  // Default (no override) resolution treats the positional name as a path.
229
278
  const sourcePath = overridePath ?? subjectName;
230
279
  // eslint-disable-next-line local/no-unsafe-root-join -- the positional skill source may be an absolute path; resolving it against repoRoot (which returns an absolute sourcePath unchanged) is intentional, documented behavior, not a containment bug.
231
280
  const sourceDir = safePath.resolve(repoRoot, sourcePath);
232
281
  return safePath.join(sourceDir, evalsSubpath);
233
282
  }
283
+ /**
284
+ * Bootstrap (exit 3) when the eval suite is absent everywhere. A real run writes
285
+ * a persistent template at the source scaffold location and reports it; a dry run
286
+ * must never touch the filesystem, so it reports where a real run *would* scaffold
287
+ * and writes nothing. Both surface the same exit-3 BootstrapNeededError. No-op when
288
+ * `evalsPath` already exists.
289
+ */
290
+ function bootstrapEvalSuiteIfMissing(opts, repoRoot, evalsSubpath, evalsPath) {
291
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own staged-subject path
292
+ if (existsSync(evalsPath)) {
293
+ return;
294
+ }
295
+ const scaffoldPath = resolveScaffoldEvalsPath(opts, repoRoot, evalsSubpath);
296
+ if (opts.dryRun === true) {
297
+ throw new BootstrapNeededError(scaffoldPath, { dryRun: true });
298
+ }
299
+ throw new BootstrapNeededError(writeEvalsTemplate(scaffoldPath, subjectSkillName(opts)));
300
+ }
301
+ /**
302
+ * The eval suite (`evals/`, incl. `fixtures/`) is authored TEST INPUT, not a
303
+ * shipped artifact — a built/dist subject won't carry it (packageSkill bundles
304
+ * only link-reachable resources + `files:`). Since the harness reads evals and
305
+ * fixtures relative to the staged subject, overlay the authored suite from the
306
+ * scaffold (source) dir onto the staged subject when it lacks it. No-op when there
307
+ * is no scaffold dir, the suite subpath has no directory, or the staged subject
308
+ * already carries the suite — so authored evals are never overwritten.
309
+ */
310
+ function overlayAuthoredEvalSuite(opts, subjectStagedDir, evalsSubpath) {
311
+ const evalsDir = dirname(evalsSubpath);
312
+ if (evalsDir === '.' || opts.subjectScaffoldDir === undefined)
313
+ return;
314
+ const scaffoldEvalsDir = safePath.join(opts.subjectScaffoldDir, evalsDir);
315
+ const stagedEvalsDir = safePath.join(subjectStagedDir, evalsDir);
316
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own staged/scaffold paths
317
+ if (!existsSync(stagedEvalsDir) && existsSync(scaffoldEvalsDir)) {
318
+ cpSync(scaffoldEvalsDir, stagedEvalsDir, { recursive: true });
319
+ }
320
+ }
321
+ /** Parse the staged eval suite and materialize each eval's input `files` into
322
+ * `<harnessRoot>/workspaces/<id>/`. Returns the workspaces root. The dir is wiped
323
+ * first so a reused harness root cannot leak a prior run's inputs. Throws
324
+ * {@link EvalInputError} (mapped by the caller to exit 2) on a bad suite or a
325
+ * missing input file. */
326
+ export function stageWorkspacesForRun(evalsPath, harnessRoot) {
327
+ const workspacesRoot = safePath.joinUnderRoot(harnessRoot, 'workspaces');
328
+ rmSync(workspacesRoot, { recursive: true, force: true });
329
+ mkdirSyncReal(workspacesRoot, { recursive: true });
330
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- evalsPath is our staged-subject path
331
+ const suite = parseEvalSuite(readFileSync(evalsPath, 'utf-8'));
332
+ return stageEvalWorkspaces({ suite, evalsDir: dirname(evalsPath), workspacesRoot });
333
+ }
334
+ /** Format the model flag string for logging and dry-run summary output. */
335
+ function buildModelFlag(model) {
336
+ return model === undefined ? '(no --model; claude default)' : `--model ${model}`;
337
+ }
338
+ /**
339
+ * Wrap {@link stageWorkspacesForRun} for the orchestrator: returns the
340
+ * `workspacesRoot` string on success, or a {@link RunHarnessResult} early-return
341
+ * value when the suite is invalid or a declared input file is missing. Any other
342
+ * error is re-thrown so it propagates as an InternalHarnessError upstream.
343
+ * Keeping the try/catch in a private helper avoids inflating the orchestrator's
344
+ * cognitive complexity.
345
+ */
346
+ function attemptStageWorkspaces(evalsPath, harnessRoot) {
347
+ try {
348
+ return { workspacesRoot: stageWorkspacesForRun(evalsPath, harnessRoot) };
349
+ }
350
+ catch (e) {
351
+ if (e instanceof EvalInputError) {
352
+ return {
353
+ harnessPath: harnessRoot,
354
+ exitCode: SkillTestExitCode.Preflight,
355
+ summary: `Eval input error:\n ${e.message}`,
356
+ };
357
+ }
358
+ throw e;
359
+ }
360
+ }
361
+ /**
362
+ * Assemble the experimenter's child env: the scrubbed forwarded env unioned with
363
+ * the declared test env (Features A + B) and CLAUDE_PLUGIN_ROOT, then emit the
364
+ * transparency line and any protected-key collision warnings to stderr.
365
+ *
366
+ * Refuses to assemble without a resolved auth — a null here is an internal
367
+ * invariant violation (preflight passed), never a reason to fall back to
368
+ * process.env, which would hand untrusted skill code every secret it contains.
369
+ */
370
+ function resolveDeclaredChildEnv(input) {
371
+ if (input.resolvedAuth === null) {
372
+ throw new InternalHarnessError('Internal: preflight passed but resolvedAuth is null — refusing to spawn with an unscrubbed environment.');
373
+ }
374
+ const envTokens = computeEnvTokens({
375
+ subjectStagedDir: input.subjectStagedDir,
376
+ harnessRoot: input.harnessRoot,
377
+ resultsDir: input.resultsDir,
378
+ evalsSubpath: input.evalsSubpath,
379
+ });
380
+ const injectEnv = resolveInjectEnv(input.opts.env, envTokens);
381
+ const assembled = assembleChildEnv({
382
+ base: input.resolvedAuth.forwardedEnv,
383
+ source: process.env,
384
+ ...(input.opts.passEnv ? { passEnv: input.opts.passEnv } : {}),
385
+ ...(injectEnv ? { injectEnv } : {}),
386
+ subjectPluginRoot: input.subjectPluginRoot,
387
+ });
388
+ for (const warning of assembled.warnings)
389
+ process.stderr.write(`warning: ${warning}\n`);
390
+ process.stderr.write(assembled.line + '\n');
391
+ return assembled;
392
+ }
393
+ /**
394
+ * Build the dry-run summary string. Pure function so it can be unit-tested
395
+ * without running the full harness.
396
+ *
397
+ * The summary covers three scenarios:
398
+ * 1. Declared (buildable) subject — no dist existed, fell back to source dir.
399
+ * 2. Declared (buildable) subject — existing dist was staged WITHOUT rebuilding
400
+ * (potentially stale).
401
+ * 3. Plain source subject — staged as-is, no build step.
402
+ *
403
+ * Always includes the assembled spawn command, the staged-manifest entry count +
404
+ * fingerprint, and the provenance.json path so a stale tree is visible at a glance.
405
+ */
406
+ export function buildDryRunSummary(input) {
407
+ const lines = [];
408
+ if (input.wouldBuild) {
409
+ lines.push('[dry-run] A real run would: build + stage the declared skill, then spawn claude.');
410
+ if (input.dryRunStagedExistingDist === true) {
411
+ lines.push('[dry-run] WARNING: This preview used the EXISTING built dist WITHOUT rebuilding — it may be STALE.', '[dry-run] Run `vat build` before testing to ensure the preview reflects current source.');
412
+ }
413
+ else if (input.dryRunStagedExistingDist === false) {
414
+ lines.push('[dry-run] No built dist exists yet; this preview fell back to the source dir.');
415
+ }
416
+ }
417
+ else {
418
+ lines.push('[dry-run] A real run would: stage the source dir as-is, then spawn claude.');
419
+ }
420
+ const count = input.provenanceEntryCount;
421
+ lines.push(`[dry-run] Would spawn: claude -p ${input.modelFlag} (prompt via stdin)`, `[dry-run] Staged manifest: ${count} entr${count === 1 ? 'y' : 'ies'} | fingerprint: ${input.provenanceFingerprint}`, `[dry-run] Provenance: ${input.provenancePath}`);
422
+ return lines.join('\n');
423
+ }
424
+ /**
425
+ * Remove the harness directory after a run so staged untrusted skill bytes and
426
+ * prompts do not accumulate in OS tmp. No-op when the user asked to keep it, when
427
+ * the dir is a user-supplied location (`--out`/`--workdir`), or when it is already
428
+ * gone. Idempotent and never throws — it runs from a `finally`, so it must not
429
+ * mask the run's real outcome.
430
+ *
431
+ * SAFETY: re-asserts the root is not a symlink immediately before removal (via
432
+ * `lstat`, which does NOT follow the link). A root swapped to a symlink between
433
+ * the run and cleanup is left in place rather than followed — `rmSync(recursive)`
434
+ * could otherwise delete the symlink's target outside tmp.
435
+ */
436
+ export function cleanupHarness(harnessRoot, opts) {
437
+ if (opts.keep || !opts.created)
438
+ return;
439
+ // Best-effort: cleanup runs from a `finally`, so a TOCTOU race (the dir is
440
+ // reaped between checks) or a permission error must never throw out and mask
441
+ // the run's real outcome. Worst case is a leftover 0700 tmp dir, not a failure.
442
+ try {
443
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived harness root
444
+ if (!existsSync(harnessRoot))
445
+ return;
446
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived harness root
447
+ if (lstatSync(harnessRoot).isSymbolicLink())
448
+ return;
449
+ rmSync(harnessRoot, { recursive: true, force: true });
450
+ }
451
+ catch {
452
+ // Swallow: a failed cleanup is not a run failure.
453
+ }
454
+ }
234
455
  // ---------------------------------------------------------------------------
235
456
  // Main orchestrator
236
457
  // ---------------------------------------------------------------------------
@@ -245,6 +466,10 @@ export async function runSkillTestHarness(opts) {
245
466
  assertSafeWorkdir(opts.workdir);
246
467
  }
247
468
  const harnessRoot = opts.out ?? resolveHarnessRoot(opts.skills, opts.workdir);
469
+ // Only auto-remove the dir the harness itself created under OS tmp. An explicit
470
+ // --out (exact dir) or --workdir (user-chosen base) is a location the user owns;
471
+ // treat it like --keep and never delete it.
472
+ const harnessCreated = opts.out === undefined && opts.workdir === undefined;
248
473
  const repoRoot = opts.repoRoot ?? harnessRoot;
249
474
  const evalsSubpath = opts.evalsSubpath ?? DEFAULT_EVALS_SUBPATH;
250
475
  const currentUid = typeof process.getuid === 'function' ? process.getuid() : 0;
@@ -256,49 +481,50 @@ export async function runSkillTestHarness(opts) {
256
481
  mkdirSyncReal(harnessRoot, { recursive: true, mode: 0o700 });
257
482
  // Step 1: Assert safe harness root (symlink/ownership/mode checks) BEFORE
258
483
  // acquiring the lock — never write a lockfile into a directory we have not yet
259
- // confirmed is non-symlinked, owned by the current uid, and 0700.
260
- assertSafeHarnessRoot(harnessRoot, currentUid);
484
+ // confirmed is non-symlinked, owned by the current uid, and 0700. The trusted
485
+ // boundary is the tmp/workdir base the harness root was derived from; every
486
+ // component beneath it (incl. the recursively-created `vat-skill-test` parent)
487
+ // is validated, closing the shared-/tmp TOCTOU on intermediate components.
488
+ const trustedTmpRoot = opts.workdir ?? normalizedTmpdir();
489
+ assertSafeHarnessRoot(harnessRoot, currentUid, trustedTmpRoot);
261
490
  // Step 2: Acquire exclusive harness lock.
262
491
  const lock = acquireHarnessLock(harnessRoot);
492
+ // Release the lock and remove the harness dir if the run is interrupted by
493
+ // SIGINT/SIGTERM — a try/finally alone does not run on a signal, so without
494
+ // this the lockfile (and staged bytes) would leak and break the next run.
495
+ const cleanup = () => {
496
+ lock.release();
497
+ cleanupHarness(harnessRoot, { keep: opts.keep === true, created: harnessCreated });
498
+ };
499
+ const removeSignalCleanup = installSignalCleanup({ onSignal: cleanup });
263
500
  try {
264
- // Step 2.5: Run pre-stage build hook (if configured).
265
- // Runs ONCE before staging so generated artifacts are present for the copy step.
266
- // cwd = configRoot (directory containing vibe-agent-toolkit.config.yaml).
267
- // A non-zero exit throws BuildHookError → mapErrorToExitCode → exit 2 (preflight).
268
- // Ordering note: build necessarily precedes the bootstrap check (Step 4), because
269
- // that check reads the *staged* subject's evals (Step 3), and staging must copy the
270
- // build's output. So a run on a not-yet-scaffolded skill pays the build before
271
- // exiting 3 — acceptable: build emits code artifacts, not the authored evals.json,
272
- // so it can't change the bootstrap outcome. (Token spend is the Step-10 spawn, far
273
- // downstream; the build never reaches it.)
274
- const configRoot = opts.configRoot ?? repoRoot;
275
- if (opts.build !== undefined) {
276
- runPreStageBuild({ buildCommand: opts.build, configRoot });
277
- }
278
501
  // Step 3: Stage the harness FIRST — the subject's own evals/evals.json lands
279
- // inside its staged dir, so we must stage before we can locate it.
502
+ // inside its staged dir, so we must stage before we can locate it. The subject's
503
+ // source is pre-resolved (and, for declared skills, pre-built) by run.ts and
504
+ // arrives via opts.subjectSource; build no longer happens in-domain here.
280
505
  const resolveCtx = buildResolveCtx(harnessRoot, repoRoot);
281
506
  const items = buildStageItems(opts, repoRoot);
282
- const { pluginDirs, subjectStagedDir, subjectPluginRoot } = await stageHarness({
507
+ const stageResult = await stageHarness({
283
508
  harnessRoot,
284
509
  items,
285
510
  resolve: (source, ctx) => resolveSkillSource(source, ctx),
286
511
  ctx: resolveCtx,
287
512
  currentUid,
288
513
  });
514
+ const { pluginDirs, subjectStagedDir, subjectPluginRoot } = stageResult;
289
515
  if (subjectStagedDir === null) {
290
516
  throw new InternalHarnessError('Staging did not yield a subject directory (no item tagged role:subject).');
291
517
  }
292
- // Step 4: Resolve the subject's eval path inside its staged dir. Bootstrap
293
- // (exit 3) fires when it is absent scaffold a persistent template the user
294
- // can edit, then throw pointing at that real location.
518
+ // Step 4: Resolve the subject's eval path inside its staged dir, overlaying the
519
+ // authored suite onto a built/dist subject that doesn't carry it (see
520
+ // overlayAuthoredEvalSuite). The bootstrap below then fires and writes a
521
+ // template to the source scaffold — ONLY when the suite genuinely doesn't exist
522
+ // anywhere, so authored evals are never overwritten.
523
+ overlayAuthoredEvalSuite(opts, subjectStagedDir, evalsSubpath);
524
+ // Bootstrap (exit 3) fires when the suite is absent everywhere — scaffold a
525
+ // persistent template at the source location the user can edit, then throw.
295
526
  const evalsPath = safePath.join(subjectStagedDir, evalsSubpath);
296
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own staged-subject path
297
- if (!existsSync(evalsPath)) {
298
- const scaffoldPath = resolveScaffoldEvalsPath(opts, repoRoot, evalsSubpath);
299
- const written = writeEvalsTemplate(scaffoldPath, subjectSkillName(opts));
300
- throw new BootstrapNeededError(written);
301
- }
527
+ bootstrapEvalSuiteIfMissing(opts, repoRoot, evalsSubpath, evalsPath);
302
528
  // Step 5: Preflight checks.
303
529
  const knobs = resolveKnobs(opts);
304
530
  const preflightInput = buildPreflightInput(evalsPath, pluginDirs, opts, knobs);
@@ -311,6 +537,11 @@ export async function runSkillTestHarness(opts) {
311
537
  summary: `Preflight failed:\n${summary}`,
312
538
  };
313
539
  }
540
+ // Step 5.5: Parse the eval suite and stage per-eval input workspaces.
541
+ const workspaceStageResult = attemptStageWorkspaces(evalsPath, harnessRoot);
542
+ if ('exitCode' in workspaceStageResult)
543
+ return workspaceStageResult;
544
+ const { workspacesRoot } = workspaceStageResult;
314
545
  // Step 6: Enforce the §12 security ack (must pass --i-understand-this-runs-skill-code).
315
546
  // Only enforced when not a dry-run and not already acknowledged.
316
547
  if (!isAcknowledged(opts)) {
@@ -323,6 +554,19 @@ export async function runSkillTestHarness(opts) {
323
554
  // Step 7: Build effective experimenter prompt and validate invariants.
324
555
  const resultsDir = safePath.joinUnderRoot(harnessRoot, 'results');
325
556
  mkdirSyncReal(resultsDir, { recursive: true });
557
+ // Record what was actually staged & tested (the subject identity + the
558
+ // staged manifest fingerprint + per-entry content hashes) so a run is
559
+ // auditable: which source was resolved, and whether it was rebuilt by run.ts.
560
+ const provenance = {
561
+ subject: subjectSkillName(opts),
562
+ fingerprint: stageResult.manifest.fingerprint,
563
+ entries: stageResult.manifest.entries,
564
+ rebuilt: opts.rebuilt === true,
565
+ };
566
+ const provenancePath = safePath.join(resultsDir, 'provenance.json');
567
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
568
+ writeFileSync(provenancePath, JSON.stringify(provenance, null, 2) + '\n', 'utf-8');
569
+ process.stderr.write(`Provenance: ${provenance.fingerprint}\n`);
326
570
  const gradingOut = safePath.join(resultsDir, 'grading.json');
327
571
  const frictionOut = safePath.join(resultsDir, 'friction.json');
328
572
  const effectivePrompt = opts.promptOverride ??
@@ -331,42 +575,77 @@ export async function runSkillTestHarness(opts) {
331
575
  evalsPath,
332
576
  gradingOut,
333
577
  frictionOut,
578
+ workspacesRoot,
334
579
  baseline: opts.baseline ?? false,
335
580
  });
336
581
  assertPromptInvariants(effectivePrompt);
337
- // Step 8: Write the experimenter-prompt.txt.
582
+ // Integrity nonce (Harness B): stamp a secret per-run nonce the experimenter
583
+ // must echo into grading.json, and verify it after the run. This is what
584
+ // distinguishes a grading produced by the experimenter WE prompted from one
585
+ // forged/left behind by untrusted skill code in the shared sandbox. The nonce
586
+ // is appended AFTER any user prompt override so a committed config can't opt
587
+ // out. The full prompt (with the nonce) reaches claude only via stdin (see
588
+ // spawnHeadlessClaude) and is NEVER written to disk; the persisted audit copy
589
+ // below is redacted so skill code can't read the nonce back and forge a match.
590
+ const runNonce = randomBytes(16).toString('hex');
591
+ const noncedPrompt = appendIntegrityNonceDirective(effectivePrompt, runNonce);
592
+ // Step 8: Write the REDACTED experimenter prompt as the audit artifact. The
593
+ // real (nonce-bearing) prompt is passed to the spawn in memory, not from here.
338
594
  const promptFile = safePath.join(resultsDir, 'experimenter-prompt.txt');
339
595
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived path
340
- writeFileSync(promptFile, effectivePrompt + '\n', 'utf-8');
596
+ writeFileSync(promptFile, redactNonce(noncedPrompt, runNonce) + '\n', 'utf-8');
597
+ // Step 7.5: Resolve the declared test env (Features A + B). Token resolution
598
+ // can hard-fail (exit 2) on an unknown ${token}; do it before the dry-run
599
+ // short-circuit so a dry run validates interpolation too.
600
+ const assembledEnv = resolveDeclaredChildEnv({
601
+ opts,
602
+ resolvedAuth: preflightResult.resolvedAuth,
603
+ subjectStagedDir,
604
+ harnessRoot,
605
+ resultsDir,
606
+ evalsSubpath,
607
+ subjectPluginRoot,
608
+ });
609
+ // Echo the selected model on EVERY run (regular + dry-run) so it is
610
+ // unambiguous which model the experimenter spawn uses. The id is forwarded
611
+ // verbatim to `claude --model`; with none set, no flag is passed and claude
612
+ // uses its own default.
613
+ const modelFlag = buildModelFlag(knobs.model);
614
+ process.stderr.write(`Model: ${knobs.model ?? '(claude default)'}\n`);
341
615
  // Step 9: Dry-run short-circuit — return assembled info without spawning.
342
616
  if (opts.dryRun === true) {
343
617
  return {
344
618
  harnessPath: harnessRoot,
345
619
  exitCode: SkillTestExitCode.Ok,
346
- summary: `[dry-run] Would spawn: claude -p (prompt via stdin from ${promptFile})`,
620
+ summary: buildDryRunSummary({
621
+ wouldBuild: opts.wouldBuild === true,
622
+ ...(opts.dryRunStagedExistingDist === undefined
623
+ ? {}
624
+ : { dryRunStagedExistingDist: opts.dryRunStagedExistingDist }),
625
+ provenancePath,
626
+ provenanceFingerprint: provenance.fingerprint,
627
+ provenanceEntryCount: provenance.entries.length,
628
+ modelFlag,
629
+ }),
347
630
  };
348
631
  }
349
632
  // Step 10: Spawn headless Claude.
350
- // Refuse to spawn with an unscrubbed environment. When preflight passes,
351
- // resolvedAuth is always non-null; a null here is an internal invariant
352
- // violation NOT a reason to fall back to the full parent `process.env`,
353
- // which would hand the untrusted skill code every secret it contains.
354
- if (preflightResult.resolvedAuth === null) {
355
- throw new InternalHarnessError('Internal: preflight passed but resolvedAuth is null — refusing to spawn with an unscrubbed environment.');
356
- }
357
- // A reused harness root (--out / --keep) may carry a grading.json from an
358
- // earlier run. Remove it so a post-spawn read can only reflect THIS run.
633
+ // A reused harness root (--out / --keep) may carry a grading.json OR a
634
+ // friction.json from an earlier run. Remove both so a post-spawn read can
635
+ // only reflect THIS run (a stale friction.json must not leak across runs).
359
636
  rmSync(gradingOut, { force: true });
637
+ rmSync(frictionOut, { force: true });
360
638
  const timeoutMs = resolveTimeoutMs(opts);
361
639
  const spawnOpts = {
362
- promptFile,
640
+ // In-memory, nonce-bearing prompt — streamed to stdin, never written to a
641
+ // file the skill could read (see the integrity-nonce note above).
642
+ prompt: noncedPrompt + '\n',
363
643
  pluginDirs,
364
644
  sandboxDir: harnessRoot,
365
645
  cwd: harnessRoot,
366
- // When the SUBJECT is plugin-distributed, export CLAUDE_PLUGIN_ROOT pointing
367
- // at its staged plugin root so the harness mirrors a real plugin install
368
- // (the rest of the scrubbed env is preserved verbatim). Standalone → unchanged.
369
- env: withPluginRootEnv(preflightResult.resolvedAuth.forwardedEnv, subjectPluginRoot),
646
+ // The scrubbed forwarded env, unioned with the declared test env (Features
647
+ // A + B) and CLAUDE_PLUGIN_ROOT when the subject is plugin-distributed.
648
+ env: assembledEnv.env,
370
649
  timeoutMs,
371
650
  onStdout: (chunk) => { process.stderr.write(chunk); },
372
651
  onStderr: (chunk) => { process.stderr.write(chunk); },
@@ -391,20 +670,31 @@ export async function runSkillTestHarness(opts) {
391
670
  throw new InternalHarnessError(`grading.json is not valid JSON: ${String(e)}`);
392
671
  }
393
672
  const grading = parseGradingJson(gradingRaw);
394
- const { passed, total } = grading.summary;
395
- const allPassed = passed === total;
673
+ // Integrity gate (Harness B): reject a grading.json that doesn't echo THIS
674
+ // run's secret nonce BEFORE trusting any verdict — a missing/wrong nonce means
675
+ // the grading was not produced by the experimenter we prompted (e.g. forged by
676
+ // skill code in the sandbox). Thrown GradingNonceError maps to exit 1.
677
+ assertGradingNonce(grading.runNonce, runNonce);
678
+ // Verdict comes from the authoritative per-expectation `passed` flags, NOT
679
+ // the grader's self-reported summary. reconcileGrading throws GradingSkewError
680
+ // if the grader graded nothing or its summary disagrees with the expectations.
681
+ const { passed, total, allPassed } = reconcileGrading(grading);
396
682
  const summary = `${allPassed ? 'PASS' : 'FAIL'} ${passed}/${total}`;
397
- // Exit 0 = harness ran to completion and produced a valid grading.json.
398
- // Pass/fail counts are reported in the summary string and grading.json;
399
- // callers should not use the exit code to distinguish eval pass from fail.
683
+ // Default: exit Ok whenever the harness ran to completion and produced a
684
+ // valid grading.json — pass/fail lives in the summary string and grading.json,
685
+ // and callers should not read the exit code to distinguish eval pass from fail.
686
+ // Opt-in: with failOnEvalFailure, a failing verdict escalates to EvalFailure (4).
400
687
  return {
401
688
  harnessPath: harnessRoot,
402
- exitCode: SkillTestExitCode.Ok,
689
+ exitCode: verdictExitCode(allPassed, opts.failOnEvalFailure === true),
403
690
  summary,
404
691
  };
405
692
  }
406
693
  finally {
407
- lock.release();
694
+ // Remove the signal handlers first (no listener leak across runs), then run
695
+ // the same cleanup: release the lock, then remove the harness dir.
696
+ removeSignalCleanup();
697
+ cleanup();
408
698
  }
409
699
  }
410
700
  //# sourceMappingURL=run-harness.js.map