@vibe-agent-toolkit/agent-skills 0.1.39-rc.8 → 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 (108) 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/build-hook.d.ts +58 -0
  30. package/dist/skill-test/build-hook.d.ts.map +1 -0
  31. package/dist/skill-test/build-hook.js +63 -0
  32. package/dist/skill-test/build-hook.js.map +1 -0
  33. package/dist/skill-test/configure-writer.d.ts +19 -11
  34. package/dist/skill-test/configure-writer.d.ts.map +1 -1
  35. package/dist/skill-test/configure-writer.js +23 -15
  36. package/dist/skill-test/configure-writer.js.map +1 -1
  37. package/dist/skill-test/declared-env.d.ts +72 -0
  38. package/dist/skill-test/declared-env.d.ts.map +1 -0
  39. package/dist/skill-test/declared-env.js +85 -0
  40. package/dist/skill-test/declared-env.js.map +1 -0
  41. package/dist/skill-test/eval-inputs.d.ts +159 -0
  42. package/dist/skill-test/eval-inputs.d.ts.map +1 -0
  43. package/dist/skill-test/eval-inputs.js +158 -0
  44. package/dist/skill-test/eval-inputs.js.map +1 -0
  45. package/dist/skill-test/evals-template.d.ts.map +1 -1
  46. package/dist/skill-test/evals-template.js +27 -10
  47. package/dist/skill-test/evals-template.js.map +1 -1
  48. package/dist/skill-test/exit-codes.d.ts +44 -5
  49. package/dist/skill-test/exit-codes.d.ts.map +1 -1
  50. package/dist/skill-test/exit-codes.js +63 -9
  51. package/dist/skill-test/exit-codes.js.map +1 -1
  52. package/dist/skill-test/experimenter-prompt.d.ts +22 -0
  53. package/dist/skill-test/experimenter-prompt.d.ts.map +1 -1
  54. package/dist/skill-test/experimenter-prompt.js +39 -2
  55. package/dist/skill-test/experimenter-prompt.js.map +1 -1
  56. package/dist/skill-test/grading-adapter.d.ts +42 -0
  57. package/dist/skill-test/grading-adapter.d.ts.map +1 -1
  58. package/dist/skill-test/grading-adapter.js +63 -1
  59. package/dist/skill-test/grading-adapter.js.map +1 -1
  60. package/dist/skill-test/grading-schema.d.ts +73 -5
  61. package/dist/skill-test/grading-schema.d.ts.map +1 -1
  62. package/dist/skill-test/grading-schema.js +20 -5
  63. package/dist/skill-test/grading-schema.js.map +1 -1
  64. package/dist/skill-test/harness-location.d.ts +35 -4
  65. package/dist/skill-test/harness-location.d.ts.map +1 -1
  66. package/dist/skill-test/harness-location.js +111 -16
  67. package/dist/skill-test/harness-location.js.map +1 -1
  68. package/dist/skill-test/index.d.ts +7 -4
  69. package/dist/skill-test/index.d.ts.map +1 -1
  70. package/dist/skill-test/index.js +7 -4
  71. package/dist/skill-test/index.js.map +1 -1
  72. package/dist/skill-test/lock.d.ts +23 -0
  73. package/dist/skill-test/lock.d.ts.map +1 -1
  74. package/dist/skill-test/lock.js +35 -1
  75. package/dist/skill-test/lock.js.map +1 -1
  76. package/dist/skill-test/plugin-env.d.ts +20 -0
  77. package/dist/skill-test/plugin-env.d.ts.map +1 -0
  78. package/dist/skill-test/plugin-env.js +24 -0
  79. package/dist/skill-test/plugin-env.js.map +1 -0
  80. package/dist/skill-test/plugin-layout.d.ts +41 -0
  81. package/dist/skill-test/plugin-layout.d.ts.map +1 -0
  82. package/dist/skill-test/plugin-layout.js +49 -0
  83. package/dist/skill-test/plugin-layout.js.map +1 -0
  84. package/dist/skill-test/preflight.d.ts.map +1 -1
  85. package/dist/skill-test/preflight.js +1 -2
  86. package/dist/skill-test/preflight.js.map +1 -1
  87. package/dist/skill-test/run-harness.d.ts +197 -11
  88. package/dist/skill-test/run-harness.d.ts.map +1 -1
  89. package/dist/skill-test/run-harness.js +401 -65
  90. package/dist/skill-test/run-harness.js.map +1 -1
  91. package/dist/skill-test/staging.d.ts +20 -1
  92. package/dist/skill-test/staging.d.ts.map +1 -1
  93. package/dist/skill-test/staging.js +71 -17
  94. package/dist/skill-test/staging.js.map +1 -1
  95. package/dist/skill-test/vendor-manifest.d.ts +3 -3
  96. package/dist/skill-test/vendor-manifest.d.ts.map +1 -1
  97. package/dist/skill-test/vendor-manifest.js +13 -3
  98. package/dist/skill-test/vendor-manifest.js.map +1 -1
  99. package/dist/validators/packaging-validator.d.ts.map +1 -1
  100. package/dist/validators/packaging-validator.js +134 -1
  101. package/dist/validators/packaging-validator.js.map +1 -1
  102. package/dist/validators/skill-validator.d.ts.map +1 -1
  103. package/dist/validators/skill-validator.js +18 -14
  104. package/dist/validators/skill-validator.js.map +1 -1
  105. package/dist/walk-link-graph.d.ts.map +1 -1
  106. package/dist/walk-link-graph.js +26 -1
  107. package/dist/walk-link-graph.js.map +1 -1
  108. package/package.json +6 -5
@@ -8,17 +8,21 @@
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
- import { getToolVersion, mkdirSyncReal, normalizedTmpdir, probeAuthStatus, safeExecResult, safePath, spawnHeadlessClaude, toForwardSlash, } from '@vibe-agent-toolkit/utils';
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';
17
+ import { assembleChildEnv, computeEnvTokens, resolveInjectEnv } from './declared-env.js';
18
+ import { EvalInputError, parseEvalSuite, stageEvalWorkspaces } from './eval-inputs.js';
16
19
  import { writeEvalsTemplate } from './evals-template.js';
17
- import { BootstrapNeededError, InternalHarnessError, SkillTestExitCode } from './exit-codes.js';
18
- import { assertPromptInvariants, buildExperimenterPrompt, } from './experimenter-prompt.js';
19
- import { parseGradingJson } from './grading-adapter.js';
20
- import { assertSafeHarnessRoot, assertSafeWorkdir, resolveHarnessRoot } from './harness-location.js';
21
- import { acquireHarnessLock } from './lock.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';
23
+ import { assertSafeHarnessRoot, assertSafeWorkdir, prepareHarnessRoot, resolveHarnessRoot } from './harness-location.js';
24
+ import { acquireHarnessLock, installSignalCleanup } from './lock.js';
25
+ import { detectPluginLayout } from './plugin-layout.js';
22
26
  import { runPreflight } from './preflight.js';
23
27
  import { descriptorToSource, stageHarness } from './staging.js';
24
28
  import { verifyVendoredManifest } from './vendor-manifest.js';
@@ -38,13 +42,42 @@ const VENDORED_SKILL_CREATOR_DIR = safePath.resolve(dirname(fileURLToPath(import
38
42
  const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
39
43
  const DEFAULT_MAX_TURNS = 50;
40
44
  const DEFAULT_MAX_BUDGET_USD = 5;
41
- 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) {
42
66
  return opts.timeout === undefined ? DEFAULT_TIMEOUT_MS : opts.timeout * 1000;
43
67
  }
44
- 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) {
45
78
  return opts.stall === undefined ? undefined : opts.stall * 1000;
46
79
  }
47
- function resolveKnobs(opts) {
80
+ export function resolveKnobs(opts) {
48
81
  const result = {
49
82
  maxTurns: opts.maxTurns ?? DEFAULT_MAX_TURNS,
50
83
  maxBudgetUsd: opts.maxBudgetUsd ?? DEFAULT_MAX_BUDGET_USD,
@@ -56,32 +89,60 @@ function resolveKnobs(opts) {
56
89
  result.stallMs = stallMs;
57
90
  return result;
58
91
  }
59
- function buildStageItems(opts) {
92
+ /**
93
+ * Detect the plugin-root layout for a `{ path }` source. The resolver later COPIES
94
+ * the source into a temp dir (losing its plugin ancestry), so we must detect here
95
+ * — while the true on-disk source dir is still known — by resolving the path spec
96
+ * against repoRoot and walking up for `.claude-plugin/plugin.json`. Non-`{path}`
97
+ * sources (npm/url/vendored/workspace) have no local source tree to walk, so they
98
+ * are always staged flat (returns undefined). undefined → flat staging.
99
+ */
100
+ export function detectItemPluginLayout(source, repoRoot) {
101
+ if (!('path' in source))
102
+ return undefined;
103
+ const sourceDir = resolveAssetReference(source.path, repoRoot);
104
+ return detectPluginLayout(sourceDir, existsSync) ?? undefined;
105
+ }
106
+ export function makeStageItem(name, source, repoRoot, role) {
107
+ const pluginLayout = detectItemPluginLayout(source, repoRoot);
108
+ return {
109
+ name,
110
+ source,
111
+ ...(role === undefined ? {} : { role }),
112
+ ...(pluginLayout === undefined ? {} : { pluginLayout }),
113
+ };
114
+ }
115
+ export function buildStageItems(opts, repoRoot) {
60
116
  const items = [];
61
117
  // The FIRST positional skill is the subject under test; the rest of the
62
118
  // primary set and any `--with`/`--with-optional` deps are supporting context.
63
119
  const subjectName = opts.skills[0];
64
120
  for (const name of opts.skills) {
65
121
  const override = opts.withSources?.[name];
66
- const source = override
67
- ? descriptorToSource(override)
68
- : { path: name };
69
- items.push(name === subjectName ? { name, source, role: 'subject' } : { name, source });
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
+ }
132
+ items.push(makeStageItem(name, source, repoRoot, name === subjectName ? 'subject' : undefined));
70
133
  }
71
134
  if (opts.withOptional !== undefined) {
72
135
  for (const [name, spec] of Object.entries(opts.withOptional)) {
73
- items.push({
74
- name,
75
- source: descriptorToSource(spec),
76
- });
136
+ const source = descriptorToSource(spec);
137
+ items.push(makeStageItem(name, source, repoRoot, undefined));
77
138
  }
78
139
  }
79
140
  return items;
80
141
  }
81
- function buildResolveCtx(harnessRoot, repoRoot) {
142
+ export function buildResolveCtx(harnessRoot, repoRoot) {
82
143
  return {
83
144
  repoRoot,
84
- stagingRoot: safePath.join(harnessRoot, 'staged'),
145
+ stagingRoot: safePath.joinUnderRoot(harnessRoot, 'staged'),
85
146
  fetchCacheDir: safePath.join(normalizedTmpdir(), 'vat-fetch-cache'),
86
147
  };
87
148
  }
@@ -104,7 +165,7 @@ const FLAG_DUMMY_VALUES = {
104
165
  * Return a sensible dummy value for a given CLI flag so that value-validation
105
166
  * doesn't reject the argument before `--help` can short-circuit the session.
106
167
  */
107
- function flagDummyValueFor(flag) {
168
+ export function flagDummyValueFor(flag) {
108
169
  return FLAG_DUMMY_VALUES[flag] ?? '1';
109
170
  }
110
171
  /**
@@ -132,7 +193,7 @@ function buildFlagParseProbe() {
132
193
  return result.success;
133
194
  };
134
195
  }
135
- function buildPreflightInput(evalsPath, pluginDirs, opts, knobs) {
196
+ export function buildPreflightInput(evalsPath, pluginDirs, opts, knobs) {
136
197
  const costEstimate = {
137
198
  evalCount: 1,
138
199
  configurations: 1,
@@ -161,13 +222,20 @@ function buildPreflightInput(evalsPath, pluginDirs, opts, knobs) {
161
222
  preflightOpts.requireAuth = opts.requireAuth;
162
223
  return preflightOpts;
163
224
  }
164
- function renderPreflightSummary(checks) {
225
+ export function renderPreflightSummary(checks) {
165
226
  const failed = checks.filter(c => !c.passed);
166
227
  return failed.length > 0
167
228
  ? failed.map(c => ` [FAIL] ${c.name}: ${c.message}`).join('\n')
168
229
  : ' All preflight checks passed.';
169
230
  }
170
- 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) {
171
239
  return opts.dryRun === true || opts.acknowledgedRunsSkillCode === true;
172
240
  }
173
241
  /**
@@ -175,7 +243,7 @@ function isAcknowledged(opts) {
175
243
  * A stall, a timeout, OR a non-zero exit are each authoritative — a non-zero exit
176
244
  * is never laundered into a PASS even if a grading.json happens to be on disk.
177
245
  */
178
- function assertExperimenterSucceeded(spawnResult, stallMs, timeoutMs) {
246
+ export function assertExperimenterSucceeded(spawnResult, stallMs, timeoutMs) {
179
247
  if (spawnResult.stalled) {
180
248
  throw new InternalHarnessError(`Experimenter stalled (no output for ${stallMs ?? 0}ms).`);
181
249
  }
@@ -193,19 +261,197 @@ function assertExperimenterSucceeded(spawnResult, stallMs, timeoutMs) {
193
261
  * that real source dir; otherwise we anchor under the repo root by skill name.
194
262
  */
195
263
  /** The subject skill's display name (trailing segment of the positional skill arg). */
196
- function subjectSkillName(opts) {
264
+ export function subjectSkillName(opts) {
197
265
  const subject = opts.skills[0] ?? 'skill';
198
266
  return basename(toForwardSlash(subject)) || subject;
199
267
  }
200
- 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
+ }
201
274
  const subjectName = opts.skills[0] ?? '';
202
275
  const override = opts.withSources?.[subjectName];
203
- const overridePath = override && typeof override.path === 'string' ? override.path : undefined;
276
+ const overridePath = override && 'path' in override ? override.path : undefined;
204
277
  // Default (no override) resolution treats the positional name as a path.
205
278
  const sourcePath = overridePath ?? subjectName;
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.
206
280
  const sourceDir = safePath.resolve(repoRoot, sourcePath);
207
281
  return safePath.join(sourceDir, evalsSubpath);
208
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
+ }
209
455
  // ---------------------------------------------------------------------------
210
456
  // Main orchestrator
211
457
  // ---------------------------------------------------------------------------
@@ -220,42 +466,65 @@ export async function runSkillTestHarness(opts) {
220
466
  assertSafeWorkdir(opts.workdir);
221
467
  }
222
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;
223
473
  const repoRoot = opts.repoRoot ?? harnessRoot;
224
474
  const evalsSubpath = opts.evalsSubpath ?? DEFAULT_EVALS_SUBPATH;
225
475
  const currentUid = typeof process.getuid === 'function' ? process.getuid() : 0;
476
+ // Tighten an existing directory to 0700 (if present) BEFORE mkdir — adopter
477
+ // may have created the --out dir with default umask (0755). This is strictly
478
+ // safer: we only remove access, never grant it. Symlink still throws.
479
+ prepareHarnessRoot(harnessRoot);
226
480
  // Ensure the harness root directory exists before validating it.
227
481
  mkdirSyncReal(harnessRoot, { recursive: true, mode: 0o700 });
228
482
  // Step 1: Assert safe harness root (symlink/ownership/mode checks) BEFORE
229
483
  // acquiring the lock — never write a lockfile into a directory we have not yet
230
- // confirmed is non-symlinked, owned by the current uid, and 0700.
231
- 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);
232
490
  // Step 2: Acquire exclusive harness lock.
233
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 });
234
500
  try {
235
501
  // Step 3: Stage the harness FIRST — the subject's own evals/evals.json lands
236
- // 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.
237
505
  const resolveCtx = buildResolveCtx(harnessRoot, repoRoot);
238
- const items = buildStageItems(opts);
239
- const { pluginDirs, subjectStagedDir } = await stageHarness({
506
+ const items = buildStageItems(opts, repoRoot);
507
+ const stageResult = await stageHarness({
240
508
  harnessRoot,
241
509
  items,
242
510
  resolve: (source, ctx) => resolveSkillSource(source, ctx),
243
511
  ctx: resolveCtx,
244
512
  currentUid,
245
513
  });
514
+ const { pluginDirs, subjectStagedDir, subjectPluginRoot } = stageResult;
246
515
  if (subjectStagedDir === null) {
247
516
  throw new InternalHarnessError('Staging did not yield a subject directory (no item tagged role:subject).');
248
517
  }
249
- // Step 4: Resolve the subject's eval path inside its staged dir. Bootstrap
250
- // (exit 3) fires when it is absent scaffold a persistent template the user
251
- // 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.
252
526
  const evalsPath = safePath.join(subjectStagedDir, evalsSubpath);
253
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own staged-subject path
254
- if (!existsSync(evalsPath)) {
255
- const scaffoldPath = resolveScaffoldEvalsPath(opts, repoRoot, evalsSubpath);
256
- const written = writeEvalsTemplate(scaffoldPath, subjectSkillName(opts));
257
- throw new BootstrapNeededError(written);
258
- }
527
+ bootstrapEvalSuiteIfMissing(opts, repoRoot, evalsSubpath, evalsPath);
259
528
  // Step 5: Preflight checks.
260
529
  const knobs = resolveKnobs(opts);
261
530
  const preflightInput = buildPreflightInput(evalsPath, pluginDirs, opts, knobs);
@@ -268,6 +537,11 @@ export async function runSkillTestHarness(opts) {
268
537
  summary: `Preflight failed:\n${summary}`,
269
538
  };
270
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;
271
545
  // Step 6: Enforce the §12 security ack (must pass --i-understand-this-runs-skill-code).
272
546
  // Only enforced when not a dry-run and not already acknowledged.
273
547
  if (!isAcknowledged(opts)) {
@@ -278,8 +552,21 @@ export async function runSkillTestHarness(opts) {
278
552
  };
279
553
  }
280
554
  // Step 7: Build effective experimenter prompt and validate invariants.
281
- const resultsDir = safePath.join(harnessRoot, 'results');
555
+ const resultsDir = safePath.joinUnderRoot(harnessRoot, 'results');
282
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`);
283
570
  const gradingOut = safePath.join(resultsDir, 'grading.json');
284
571
  const frictionOut = safePath.join(resultsDir, 'friction.json');
285
572
  const effectivePrompt = opts.promptOverride ??
@@ -288,39 +575,77 @@ export async function runSkillTestHarness(opts) {
288
575
  evalsPath,
289
576
  gradingOut,
290
577
  frictionOut,
578
+ workspacesRoot,
291
579
  baseline: opts.baseline ?? false,
292
580
  });
293
581
  assertPromptInvariants(effectivePrompt);
294
- // 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.
295
594
  const promptFile = safePath.join(resultsDir, 'experimenter-prompt.txt');
296
595
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived path
297
- 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`);
298
615
  // Step 9: Dry-run short-circuit — return assembled info without spawning.
299
616
  if (opts.dryRun === true) {
300
617
  return {
301
618
  harnessPath: harnessRoot,
302
619
  exitCode: SkillTestExitCode.Ok,
303
- 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
+ }),
304
630
  };
305
631
  }
306
632
  // Step 10: Spawn headless Claude.
307
- // Refuse to spawn with an unscrubbed environment. When preflight passes,
308
- // resolvedAuth is always non-null; a null here is an internal invariant
309
- // violation NOT a reason to fall back to the full parent `process.env`,
310
- // which would hand the untrusted skill code every secret it contains.
311
- if (preflightResult.resolvedAuth === null) {
312
- throw new InternalHarnessError('Internal: preflight passed but resolvedAuth is null — refusing to spawn with an unscrubbed environment.');
313
- }
314
- // A reused harness root (--out / --keep) may carry a grading.json from an
315
- // 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).
316
636
  rmSync(gradingOut, { force: true });
637
+ rmSync(frictionOut, { force: true });
317
638
  const timeoutMs = resolveTimeoutMs(opts);
318
639
  const spawnOpts = {
319
- 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',
320
643
  pluginDirs,
321
644
  sandboxDir: harnessRoot,
322
645
  cwd: harnessRoot,
323
- env: preflightResult.resolvedAuth.forwardedEnv,
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,
324
649
  timeoutMs,
325
650
  onStdout: (chunk) => { process.stderr.write(chunk); },
326
651
  onStderr: (chunk) => { process.stderr.write(chunk); },
@@ -345,20 +670,31 @@ export async function runSkillTestHarness(opts) {
345
670
  throw new InternalHarnessError(`grading.json is not valid JSON: ${String(e)}`);
346
671
  }
347
672
  const grading = parseGradingJson(gradingRaw);
348
- const { passed, total } = grading.summary;
349
- 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);
350
682
  const summary = `${allPassed ? 'PASS' : 'FAIL'} ${passed}/${total}`;
351
- // Exit 0 = harness ran to completion and produced a valid grading.json.
352
- // Pass/fail counts are reported in the summary string and grading.json;
353
- // 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).
354
687
  return {
355
688
  harnessPath: harnessRoot,
356
- exitCode: SkillTestExitCode.Ok,
689
+ exitCode: verdictExitCode(allPassed, opts.failOnEvalFailure === true),
357
690
  summary,
358
691
  };
359
692
  }
360
693
  finally {
361
- 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();
362
698
  }
363
699
  }
364
700
  //# sourceMappingURL=run-harness.js.map