vigiles 27.2.0 → 28.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.
@@ -75,8 +75,8 @@ function injectableEventsFor(root) {
75
75
  */
76
76
  exports.loadHookProgram = load_hook_js_1.loadHook;
77
77
  /** Load a registered provider (`.vigiles/providers/<name>`) → its definition. */
78
- async function loadProvider(file) {
79
- const abs = (0, node_path_1.resolve)(process.cwd(), file);
78
+ async function loadProvider(file, root = process.cwd()) {
79
+ const abs = (0, node_path_1.resolve)(root, file);
80
80
  const { pathToFileURL } = require("node:url");
81
81
  let mod;
82
82
  try {
@@ -95,8 +95,8 @@ async function loadProvider(file) {
95
95
  return def;
96
96
  }
97
97
  /** Path of the tamper-evident stamp sidecar for a hook file. */
98
- function hookStampPath(file) {
99
- return (0, node_path_1.resolve)(process.cwd(), ".vigiles/hooks", (0, node_path_1.basename)(file) + ".json");
98
+ function hookStampPath(file, root = process.cwd()) {
99
+ return (0, node_path_1.resolve)(root, ".vigiles/hooks", (0, node_path_1.basename)(file) + ".json");
100
100
  }
101
101
  /**
102
102
  * Perform the state writes a hook declared, after its output has been emitted.
@@ -104,14 +104,14 @@ function hookStampPath(file) {
104
104
  * thrown on) is announced — silence here would be a hook that believes it
105
105
  * remembered something.
106
106
  */
107
- function applyHookWrites(file, outcome) {
107
+ function applyHookWrites(file, outcome, root) {
108
108
  const { ok, refused } = (0, hook_program_js_1.outcomeWrites)(outcome);
109
109
  for (const name of refused) {
110
110
  console.error(`vigiles: refused to record ${name} from ${file} — not a valid state key.`);
111
111
  }
112
112
  for (const w of ok) {
113
113
  try {
114
- (0, hook_state_store_js_1.writeHookState)(file, w);
114
+ (0, hook_state_store_js_1.writeHookState)(file, w, { cwd: root });
115
115
  }
116
116
  catch (e) {
117
117
  console.error(`vigiles: could not record ${w.name} from ${file}: ${String(e)}`);
@@ -124,13 +124,13 @@ function applyHookWrites(file, outcome) {
124
124
  * that can't resolve yields its default (never throws). The pure registry +
125
125
  * decision logic live in core/hook-providers.ts — this only injects the real IO.
126
126
  */
127
- async function gatherHookContext(program, file) {
127
+ async function gatherHookContext(program, file, root) {
128
128
  const needs = (0, hook_program_js_1.hookNeeds)(program);
129
129
  if (needs.length === 0)
130
130
  return {};
131
131
  // Only load the registered-provider registry if a provider() ref is declared.
132
132
  const hasRef = needs.some((n) => typeof n !== "string" && n.kind === "provider-ref");
133
- const registry = hasRef ? await loadProviderRegistry() : {};
133
+ const registry = hasRef ? await loadProviderRegistry(root) : {};
134
134
  const { execSync } = require("node:child_process");
135
135
  const { isCI } = require("ci-info");
136
136
  return (0, hook_providers_js_1.gatherContext)(needs, {
@@ -138,12 +138,12 @@ async function gatherHookContext(program, file) {
138
138
  encoding: "utf-8",
139
139
  stdio: ["ignore", "pipe", "ignore"],
140
140
  }),
141
- cwd: process.cwd(),
141
+ cwd: root,
142
142
  platform: process.platform,
143
143
  isCI,
144
144
  // The namespace is bound HERE, from the hook's own path — core never sees
145
145
  // it, so no key a hook can spell reaches another owner's store.
146
- readState: (key) => (0, hook_state_store_js_1.readHookState)(file, key),
146
+ readState: (key) => (0, hook_state_store_js_1.readHookState)(file, key, root),
147
147
  now: Date.now(),
148
148
  }, registry);
149
149
  }
@@ -152,11 +152,11 @@ async function gatherHookContext(program, file) {
152
152
  * for `provider()` ref resolution. A bad/unloadable provider file is skipped (the
153
153
  * ref then yields its default ""), never crashes a live session.
154
154
  */
155
- async function loadProviderRegistry() {
155
+ async function loadProviderRegistry(root) {
156
156
  const registry = {};
157
- for (const file of (0, hook_install_js_1.discoverProviderFiles)(process.cwd())) {
157
+ for (const file of (0, hook_install_js_1.discoverProviderFiles)(root)) {
158
158
  try {
159
- const def = await loadProvider(file);
159
+ const def = await loadProvider(file, root);
160
160
  registry[def.name] = def;
161
161
  }
162
162
  catch {
@@ -166,9 +166,9 @@ async function loadProviderRegistry() {
166
166
  return registry;
167
167
  }
168
168
  /** Append an observe-mode record to `.vigiles/hook-observations.jsonl` (best-effort). */
169
- function recordObservation(file, on, would, reason) {
169
+ function recordObservation(file, on, would, reason, root) {
170
170
  try {
171
- const dir = (0, node_path_1.resolve)(process.cwd(), ".vigiles");
171
+ const dir = (0, node_path_1.resolve)(root, ".vigiles");
172
172
  (0, node_fs_1.mkdirSync)(dir, { recursive: true });
173
173
  const line = JSON.stringify({
174
174
  ts: new Date().toISOString(),
@@ -189,7 +189,7 @@ function recordObservation(file, on, would, reason) {
189
189
  * shadow/rollout path. Harness-neutral — exit 2 / exit 0 are identical on Claude
190
190
  * Code and Codex; the record is vigiles-local.
191
191
  */
192
- function emitGate(decision, on, mode, file) {
192
+ function emitGate(decision, on, mode, file, root) {
193
193
  const action = (0, hook_program_js_1.gateAction)(decision, mode);
194
194
  switch (action.kind) {
195
195
  case "block":
@@ -200,7 +200,7 @@ function emitGate(decision, on, mode, file) {
200
200
  mode: "enforce",
201
201
  rule: file,
202
202
  reason: action.reason,
203
- });
203
+ }, root);
204
204
  console.error(action.reason);
205
205
  process.exit(2);
206
206
  return;
@@ -212,7 +212,7 @@ function emitGate(decision, on, mode, file) {
212
212
  mode: "enforce",
213
213
  rule: file,
214
214
  reason: action.reason,
215
- });
215
+ }, root);
216
216
  process.stdout.write(JSON.stringify({
217
217
  hookSpecificOutput: {
218
218
  hookEventName: on,
@@ -229,8 +229,8 @@ function emitGate(decision, on, mode, file) {
229
229
  mode: "observe",
230
230
  rule: file,
231
231
  reason: action.reason,
232
- });
233
- recordObservation(file, on, action.would, action.reason);
232
+ }, root);
233
+ recordObservation(file, on, action.would, action.reason, root);
234
234
  console.error(`⚠ [vigiles observe] ${on}: would ${action.would} — ${action.reason}`);
235
235
  return; // exit 0 — observe never blocks
236
236
  case "allow":
@@ -247,9 +247,9 @@ function emitGate(decision, on, mode, file) {
247
247
  * observed wedge came from a `package.json` the author was not thinking about at
248
248
  * the time — it had merge-conflict markers in it, nothing to do with hooks.
249
249
  */
250
- function hookLoadPathFiles(hookFile) {
250
+ function hookLoadPathFiles(hookFile, root) {
251
251
  const files = [];
252
- let dir = (0, node_path_1.dirname)((0, node_path_1.resolve)(process.cwd(), hookFile));
252
+ let dir = (0, node_path_1.dirname)((0, node_path_1.resolve)(root, hookFile));
253
253
  for (;;) {
254
254
  const pkg = (0, node_path_1.resolve)(dir, "package.json");
255
255
  files.push(pkg);
@@ -275,15 +275,15 @@ function hookLoadPathFiles(hookFile) {
275
275
  break;
276
276
  dir = up;
277
277
  }
278
- files.push((0, node_path_1.resolve)(process.cwd(), ".vigilesrc.json"));
278
+ files.push((0, node_path_1.resolve)(root, ".vigilesrc.json"));
279
279
  return files;
280
280
  }
281
281
  /**
282
282
  * The conflicted files on this hook's load path, if any — the difference between
283
283
  * "your hook is broken" and "your repo is mid-merge and the hook is collateral".
284
284
  */
285
- function conflictedLoadPathFiles(hookFile) {
286
- return hookLoadPathFiles(hookFile)
285
+ function conflictedLoadPathFiles(hookFile, root) {
286
+ return hookLoadPathFiles(hookFile, root)
287
287
  .filter((p) => {
288
288
  try {
289
289
  return ((0, node_fs_1.existsSync)(p) && (0, merge_conflict_js_1.hasMergeConflictMarkers)((0, node_fs_1.readFileSync)(p, "utf-8")));
@@ -292,7 +292,7 @@ function conflictedLoadPathFiles(hookFile) {
292
292
  return false; // unreadable is a different problem; don't guess about it
293
293
  }
294
294
  })
295
- .map((p) => (0, node_path_1.relative)(process.cwd(), p) || p);
295
+ .map((p) => (0, node_path_1.relative)(root, p) || p);
296
296
  }
297
297
  /**
298
298
  * Print the loud stderr banner that accompanies a REPAIR-only pass-through, and
@@ -321,14 +321,15 @@ function announceRepairEscape(file, why) {
321
321
  * `.claude/settings.json` to unwire the gate. Observed 2026-08-03.
322
322
  */
323
323
  function verifyStampOrRefuse(file, event) {
324
- const stampPath = hookStampPath(file);
324
+ const { root } = event;
325
+ const stampPath = hookStampPath(file, root);
325
326
  if (!(0, node_fs_1.existsSync)(stampPath))
326
327
  return;
327
328
  try {
328
329
  const { stamp } = JSON.parse((0, node_fs_1.readFileSync)(stampPath, "utf-8"));
329
- const source = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(process.cwd(), file), "utf-8");
330
+ const source = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(root, file), "utf-8");
330
331
  if (stamp && !(0, hook_program_js_1.verifyHookStamp)(source, stamp)) {
331
- if ((0, hook_program_js_1.isStampRepairEvent)(event, file, process.cwd())) {
332
+ if ((0, hook_program_js_1.isStampRepairEvent)(event, file, root)) {
332
333
  announceRepairEscape(file, "does not match its compiled stamp");
333
334
  return;
334
335
  }
@@ -388,21 +389,39 @@ async function runHookProgramCommand(file) {
388
389
  catch {
389
390
  /* no stdin */
390
391
  }
391
- let event = {};
392
+ let payload = {};
392
393
  try {
393
- event = JSON.parse(raw);
394
+ payload = JSON.parse(raw);
394
395
  }
395
396
  catch {
396
397
  /* malformed → empty event */
397
398
  }
398
- // The root repo-relative path prefixes resolve against. `$CLAUDE_PROJECT_DIR`
399
- // first (the same root the harness resolved THIS hook's own path against),
400
- // then the payload's `cwd`; never `process.cwd()`, which under a git worktree
401
- // can be a different checkout. See `projectRootOf`.
402
- const projectRoot = (0, hook_program_js_1.projectRootOf)(event, process.env);
399
+ // 🔴 THE ROOT IS RESOLVED ONCE, HERE, AND RIDES ON THE EVENT. Everything below
400
+ // reads `event.root`; nothing recomputes it and nothing is handed a root
401
+ // beside an event it might disagree with. That disagreement is the defect
402
+ // this shape exists to prevent — the stamp sidecar, the hook's own source,
403
+ // the state store, the ledger and the provider registry each used to resolve
404
+ // against `process.cwd()` while the decision layer resolved against the
405
+ // payload, so under a worktree the tamper check did not misfire, it did not
406
+ // run at all.
407
+ //
408
+ // This is the only `process.cwd()` on the runtime's own execution path, and
409
+ // it is the documented last resort for a payload that declares no root. The
410
+ // two others in this file are back-compat defaults on exported helpers
411
+ // (`loadProvider`, `hookStampPath`) for callers outside the runtime; the
412
+ // runtime itself always passes a root and never takes them.
413
+ const event = (0, hook_program_js_1.resolveHookEvent)(payload, process.env, process.cwd());
414
+ const root = event.root;
415
+ // ⚠️ THE DECISION LAYER MUST NOT SEE THE FALLBACK, and this is not a detail.
416
+ // `pathView` treats an undefined root as "I cannot place this path" and errs
417
+ // toward SILENCE. Handing it `process.cwd()` instead would turn that silence
418
+ // into confident decisions measured against a directory nobody declared —
419
+ // quietly widening what gates fire on. IO paths need a usable root; verdicts
420
+ // need an honest one, and they are not the same question.
421
+ const declaredRoot = event.rootDeclared ? event.root : undefined;
403
422
  let program;
404
423
  try {
405
- program = await (0, exports.loadHookProgram)(file);
424
+ program = await (0, exports.loadHookProgram)(file, root);
406
425
  }
407
426
  catch (err) {
408
427
  // A LOAD failure is a fact about the harness, not a verdict about the
@@ -430,7 +449,7 @@ async function runHookProgramCommand(file) {
430
449
  // not go through PreToolUse(Bash)).
431
450
  // Everything else stays BLOCKED, and the escapes are whitelists of commands
432
451
  // that are WRITES — see `isLoadPathRepairEvent` for why no command is one.
433
- const conflicted = conflictedLoadPathFiles(file);
452
+ const conflicted = conflictedLoadPathFiles(file, root);
434
453
  // 🔴 THE THROWN MESSAGE IS THE ONLY THING THAT NAMES THE REAL CAUSE when the
435
454
  // merge-conflict heuristic above does not fire. Without it this said just
436
455
  // "cannot be loaded" — a diagnosis that sends the reader looking in the wrong
@@ -444,13 +463,13 @@ async function runHookProgramCommand(file) {
444
463
  `may be fine)`
445
464
  : `cannot be loaded — ${thrown}`;
446
465
  if ((0, hook_program_js_1.isLoadPathRepairEvent)(event, file, {
447
- // The root the REST of this runtime already uses: `hookStampPath` and
448
- // `verifyStampOrRefuse` read the hook and its sidecar via `process.cwd()`,
449
- // so a repair accepted against any other root would name a file the
450
- // runtime never reads. The hook's own path cannot supply it (a hook sits
451
- // at any depth, and a `.git` probe would be a disk read core does not do).
452
- root: process.cwd(),
453
- loadPathFiles: hookLoadPathFiles(file),
466
+ // The root the REST of this runtime already uses — now the PROJECT's,
467
+ // not the process's. A repair accepted against any other root would name
468
+ // a file the runtime never reads. The hook's own path cannot supply it (a
469
+ // hook sits at any depth, and a `.git` probe would be a disk read core
470
+ // does not do), so it is passed in.
471
+ root,
472
+ loadPathFiles: hookLoadPathFiles(file, root),
454
473
  })) {
455
474
  announceRepairEscape(file, cause);
456
475
  return;
@@ -466,7 +485,7 @@ async function runHookProgramCommand(file) {
466
485
  `${file}, ${merge_conflict_js_1.HARNESS_CONFIG_FILES.join(", ")} is broken — those writes are ` +
467
486
  `allowed even while this refuses, and a Bash gate never gated file tools ` +
468
487
  `at all. The hook then loads and the gate decides normally again.\n` +
469
- `vigiles: those paths resolve under ${process.cwd()} — plus any ancestor ` +
488
+ `vigiles: those paths resolve under ${root} — plus any ancestor ` +
470
489
  `\`package.json\` Node actually reads, so whatever is named above as the ` +
471
490
  `cause is writable. A path in a DIFFERENT checkout is refused: it cannot ` +
472
491
  `repair this failure.\n` +
@@ -494,7 +513,7 @@ async function runHookProgramCommand(file) {
494
513
  verifyStampOrRefuse(file, event);
495
514
  switch ((0, hook_program_js_1.dispatchKind)(program)) {
496
515
  case "inject": {
497
- const ctx = await gatherHookContext(program, file);
516
+ const ctx = await gatherHookContext(program, file, root);
498
517
  const injection = (0, hook_program_js_1.injectionOf)(program, event, ctx);
499
518
  process.stdout.write(JSON.stringify({
500
519
  hookSpecificOutput: {
@@ -508,20 +527,20 @@ async function runHookProgramCommand(file) {
508
527
  kind: "injection",
509
528
  context: injection.context,
510
529
  records: injection.records,
511
- });
530
+ }, root);
512
531
  return;
513
532
  }
514
533
  case "react": {
515
- const ctx = await gatherHookContext(program, file);
516
- warnIfPathUndecidable(event, projectRoot);
517
- const reaction = (0, hook_program_js_1.runReact)(program, event, ctx, projectRoot);
534
+ const ctx = await gatherHookContext(program, file, root);
535
+ warnIfPathUndecidable(event, declaredRoot);
536
+ const reaction = (0, hook_program_js_1.runReact)(program, event, ctx, declaredRoot);
518
537
  // A notice has to REACH someone. stderr at exit 0 goes to the debug log
519
538
  // and nothing else (the host's docs are explicit: "Claude never sees it"),
520
539
  // and a react always exits 0 because its type has no `deny` — so stderr
521
540
  // alone delivered nowhere. Emit the same `additionalContext` shape the
522
541
  // shipped refs/eval-lock nudges use, gated on the ACTIVE adapter's
523
542
  // `injectableEvents` so this is per-harness fact, not a CC literal.
524
- const injectable = injectableEventsFor(projectRoot ?? process.cwd());
543
+ const injectable = injectableEventsFor(root);
525
544
  const delivery = (0, hook_program_js_1.noticeDelivery)(reaction, program.on, injectable);
526
545
  if (delivery.kind === "inject") {
527
546
  process.stdout.write(JSON.stringify({
@@ -537,7 +556,7 @@ async function runHookProgramCommand(file) {
537
556
  // Removing it would break existing consumers to gain nothing.
538
557
  if (reaction.kind === "notice")
539
558
  console.error(reaction.message);
540
- applyHookWrites(file, { kind: "reaction", reaction });
559
+ applyHookWrites(file, { kind: "reaction", reaction }, root);
541
560
  if (reaction.kind === "run") {
542
561
  const { spawnSync } = require("node:child_process");
543
562
  const res = spawnSync(reaction.command, {
@@ -549,29 +568,29 @@ async function runHookProgramCommand(file) {
549
568
  return;
550
569
  }
551
570
  case "file-gate": {
552
- const ctx = await gatherHookContext(program, file);
553
- warnIfPathUndecidable(event, projectRoot);
554
- emitGate((0, hook_program_js_1.decideFileGate)(program, event, ctx, projectRoot), program.on, (0, hook_program_js_1.hookMode)(program), file);
571
+ const ctx = await gatherHookContext(program, file, root);
572
+ warnIfPathUndecidable(event, declaredRoot);
573
+ emitGate((0, hook_program_js_1.decideFileGate)(program, event, ctx, declaredRoot), program.on, (0, hook_program_js_1.hookMode)(program), file, root);
555
574
  return;
556
575
  }
557
576
  case "bash-gate": {
558
- const ctx = await gatherHookContext(program, file);
559
- // The same `projectRoot` the file gates get: without it every
577
+ const ctx = await gatherHookContext(program, file, root);
578
+ // The same `declaredRoot` the file gates get: without it every
560
579
  // repo-relative prefix in a DENYLIST matcher (`touches`/`writesTo`) is
561
580
  // matched by over-blocking alone, and with it an absolute token is placed
562
581
  // exactly. Measured bypass this closes: `sed -i s/a/b/ <abs>/paper.tex`
563
582
  // exited 0 against a guard that blocked the relative spelling.
564
- emitGate((0, hook_program_js_1.decideProgram)(program, event, ctx, projectRoot), program.on, (0, hook_program_js_1.hookMode)(program), file);
583
+ emitGate((0, hook_program_js_1.decideProgram)(program, event, ctx, declaredRoot), program.on, (0, hook_program_js_1.hookMode)(program), file, root);
565
584
  return;
566
585
  }
567
586
  case "prompt-gate": {
568
- const ctx = await gatherHookContext(program, file);
569
- emitGate((0, hook_program_js_1.decidePromptGate)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file);
587
+ const ctx = await gatherHookContext(program, file, root);
588
+ emitGate((0, hook_program_js_1.decidePromptGate)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file, root);
570
589
  return;
571
590
  }
572
591
  case "stop-gate": {
573
- const ctx = await gatherHookContext(program, file);
574
- emitGate((0, hook_program_js_1.decideStopGate)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file);
592
+ const ctx = await gatherHookContext(program, file, root);
593
+ emitGate((0, hook_program_js_1.decideStopGate)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file, root);
575
594
  return;
576
595
  }
577
596
  }
@@ -20,5 +20,5 @@ import { type AnyHook } from "./core/hook-program.js";
20
20
  * @throws {HookCompileError} when the file can't be imported, or has no
21
21
  * default-exported hook program.
22
22
  */
23
- export declare function loadHook(file: string): Promise<AnyHook>;
23
+ export declare function loadHook(file: string, root?: string): Promise<AnyHook>;
24
24
  //# sourceMappingURL=load-hook.d.ts.map
package/dist/load-hook.js CHANGED
@@ -39,8 +39,8 @@ const hook_program_js_1 = require("./core/hook-program.js");
39
39
  * @throws {HookCompileError} when the file can't be imported, or has no
40
40
  * default-exported hook program.
41
41
  */
42
- async function loadHook(file) {
43
- const abs = (0, node_path_1.resolve)(process.cwd(), file);
42
+ async function loadHook(file, root = process.cwd()) {
43
+ const abs = (0, node_path_1.resolve)(root, file);
44
44
  let mod;
45
45
  try {
46
46
  mod = (await import((0, node_url_1.pathToFileURL)(abs).href));
package/dist/run-hook.js CHANGED
@@ -189,7 +189,23 @@ function runHookWith(command, input, opts, deps) {
189
189
  ran: false,
190
190
  conditionReason: verdict.why,
191
191
  };
192
- const res = (0, run_script_js_1.runScriptWith)(command, JSON.stringify(input), opts, deps);
192
+ // 🔴 A SPAWN DIRECTORY WITH NO DECLARED ROOT MEANS "THIS DIRECTORY IS THE
193
+ // PROJECT". Without this, a test that says `{ cwd: dir }` and nothing else
194
+ // inherits whatever `$CLAUDE_PROJECT_DIR` the AMBIENT shell exports, and since
195
+ // the runtime prefers the env over the payload (see `projectRootOf`), the hook
196
+ // resolves a root the test never named. Measured 2026-09-19: five rail suites
197
+ // pass with the variable unset — which is CI, and this container — and fail
198
+ // when it is set, which is any run inside a live Claude Code session. That is
199
+ // a one-sided failure, invisible exactly where it would be caught.
200
+ //
201
+ // An EXPLICIT value always wins, including the empty string: a test that pins
202
+ // `CLAUDE_PROJECT_DIR: ""` is saying "no env root, resolve from the payload",
203
+ // and that case has its own coverage.
204
+ const rooted = opts.cwd !== undefined &&
205
+ !Object.prototype.hasOwnProperty.call(opts.env ?? {}, "CLAUDE_PROJECT_DIR")
206
+ ? { ...opts, env: { ...opts.env, CLAUDE_PROJECT_DIR: opts.cwd } }
207
+ : opts;
208
+ const res = (0, run_script_js_1.runScriptWith)(command, JSON.stringify(input), rooted, deps);
193
209
  const json = parseHookOutput(res.stdout);
194
210
  const { blocked, decision, haltsTurn, blockedBy } = decideHook(res.exitCode, json, protocol);
195
211
  return {
@@ -33,12 +33,12 @@ exports.runScript = runScript;
33
33
  */
34
34
  const node_child_process_1 = require("node:child_process");
35
35
  const node_fs_1 = require("node:fs");
36
- const node_os_1 = require("node:os");
37
36
  const node_path_1 = require("node:path");
38
37
  const egress_js_1 = require("./egress.js");
39
38
  const sandbox_js_1 = require("./sandbox.js");
40
39
  const check_count_js_1 = require("./check-count.js");
41
40
  const coverage_probe_js_1 = require("./coverage-probe.js");
41
+ const tmp_root_js_1 = require("./core/tmp-root.js");
42
42
  /**
43
43
  * True when the exit code is the shell's own report that it never reached the
44
44
  * program — so nothing of the harness ran and no surface may be credited.
@@ -260,7 +260,7 @@ function snapshotTree(dir) {
260
260
  return out;
261
261
  }
262
262
  function sandboxedSpawn(command, stdin, opts) {
263
- const ioDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-hook-sbx-"));
263
+ const ioDir = (0, tmp_root_js_1.makeTmpDir)("hook-sbx");
264
264
  const home = (0, node_path_1.join)(ioDir, "home");
265
265
  (0, node_fs_1.mkdirSync)(home);
266
266
  // The hook's confined writable work dir: the caller's cwd if given, else a
@@ -354,7 +354,7 @@ function readEgressResult(resultFile) {
354
354
  : { status: 1, signal: null, stdout: "", stderr: "", counters: "" };
355
355
  }
356
356
  function egressSpawn(command, stdin, opts) {
357
- const ioDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-hook-egr-"));
357
+ const ioDir = (0, tmp_root_js_1.makeTmpDir)("hook-egr");
358
358
  const home = (0, node_path_1.join)(ioDir, "home");
359
359
  (0, node_fs_1.mkdirSync)(home);
360
360
  const work = opts.cwd ?? (0, node_path_1.join)(ioDir, "work");
package/dist/sandbox.js CHANGED
@@ -33,9 +33,9 @@ exports.runSandboxed = runSandboxed;
33
33
  */
34
34
  const node_child_process_1 = require("node:child_process");
35
35
  const node_fs_1 = require("node:fs");
36
- const node_os_1 = require("node:os");
37
36
  const node_path_1 = require("node:path");
38
37
  const runtime_js_1 = require("./adapters/claude-code/runtime.js");
38
+ const tmp_root_js_1 = require("./core/tmp-root.js");
39
39
  let cachedAvailable;
40
40
  /**
41
41
  * Whether this environment can ACTUALLY confine untrusted code under bubblewrap.
@@ -247,7 +247,7 @@ const WRAPPER = [
247
247
  bwrap-backed integration test (skipped without bwrap), not the unit gate —
248
248
  the pure policy/args/parse helpers above carry the testable logic. */
249
249
  function runSandboxed(opts) {
250
- const ioDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-sbx-"));
250
+ const ioDir = (0, tmp_root_js_1.makeTmpDir)("sbx");
251
251
  const home = (0, node_path_1.join)(ioDir, "home");
252
252
  (0, node_fs_1.mkdirSync)(home);
253
253
  const scriptF = (0, node_path_1.join)(ioDir, "script.json");
@@ -34,7 +34,6 @@ exports.formatGateReport = formatGateReport;
34
34
  const node_fs_1 = require("node:fs");
35
35
  const foreign_runner_js_1 = require("./core/foreign-runner.js");
36
36
  const eval_load_phase_js_1 = require("./core/eval-load-phase.js");
37
- const node_os_1 = require("node:os");
38
37
  const node_path_1 = require("node:path");
39
38
  const node_child_process_1 = require("node:child_process");
40
39
  const scan_js_1 = require("./scan.js");
@@ -46,6 +45,7 @@ const harness_test_js_1 = require("./harness-test.js");
46
45
  const plugin_loader_js_1 = require("./adapters/claude-code/plugin-loader.js");
47
46
  const eval_js_2 = require("./adapters/codex/eval.js");
48
47
  const driver_js_1 = require("./adapters/codex/driver.js");
48
+ const tmp_root_js_1 = require("./core/tmp-root.js");
49
49
  function buildProbe(dir, harness) {
50
50
  if (harness === "codex") {
51
51
  return {
@@ -529,7 +529,7 @@ function gateRubric(gate) {
529
529
  }
530
530
  /** Run ONE attack against the unstubbed plugin → the agent's output (or errored). */
531
531
  async function runGateAttack(dir, job, deps, model) {
532
- const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-gate-"));
532
+ const cwd = (0, tmp_root_js_1.makeTmpDir)("gate");
533
533
  try {
534
534
  const out = await deps.driver.runner({
535
535
  task: job.attack,
package/dist/test.d.ts CHANGED
@@ -51,6 +51,7 @@
51
51
  * distinction in its config rather than in its imports.
52
52
  */
53
53
  export { recordCheck } from "./check-count.js";
54
+ export { makeTmpDir, cleanupTmpDir } from "./core/tmp-root.js";
54
55
  export { runScript } from "./run-script.js";
55
56
  export type { RunScriptOptions, ScriptRunResult } from "./run-script.js";
56
57
  export { runHook, parseHookOutput, decideHook, propertyHook, fileToolEvents, egressRoutes, } from "./run-hook.js";
package/dist/test.js CHANGED
@@ -66,8 +66,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
66
66
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
67
67
  };
68
68
  Object.defineProperty(exports, "__esModule", { value: true });
69
- exports.parseOutput = exports.parseResultEvent = exports.parseSubagents = exports.parseToolCalls = exports.runHarness = exports.runHarnessTest = exports.experimental_formatPluginGuardReport = exports.experimental_verifyPluginGuards = exports.experimental_alternateSpellings = exports.formatGuardrailReport = exports.assertBlocksDisasters = exports.unblockedDisasters = exports.verifyGuardrail = exports.DISASTER_CATALOG = exports.cacheTokens = exports.outputTokens = exports.inputTokens = exports.tokens = exports.latency = exports.cost = exports.mcp = exports.allowed = exports.blocked = exports.subagent = exports.didNotWrite = exports.wrote = exports.turns = exports.received = exports.hookFired = exports.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.recordCheck = void 0;
70
- 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.sandboxAvailable = exports.specTrusted = exports.decideSandbox = exports.parseHooks = void 0;
69
+ exports.parseSubagents = exports.parseToolCalls = exports.runHarness = exports.runHarnessTest = exports.experimental_formatPluginGuardReport = exports.experimental_verifyPluginGuards = exports.experimental_alternateSpellings = exports.formatGuardrailReport = exports.assertBlocksDisasters = exports.unblockedDisasters = exports.verifyGuardrail = exports.DISASTER_CATALOG = exports.cacheTokens = exports.outputTokens = exports.inputTokens = exports.tokens = exports.latency = exports.cost = exports.mcp = exports.allowed = exports.blocked = exports.subagent = exports.didNotWrite = exports.wrote = exports.turns = exports.received = exports.hookFired = exports.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;
70
+ 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.sandboxAvailable = exports.specTrusted = exports.decideSandbox = exports.parseHooks = exports.parseOutput = exports.parseResultEvent = void 0;
71
71
  // --- reporting: how much did this script actually do? ---
72
72
  // `vigiles test` can otherwise see only an exit code, so a file that runs NOTHING
73
73
  // prints the same `✓` as one that ran and passed (measured 2026-08-08 on a file
@@ -76,6 +76,21 @@ exports.experimental_makeDockerRuntime = exports.experimental_dockerRuntime = ex
76
76
  // visible to the runner too. See check-count.ts.
77
77
  var check_count_js_1 = require("./check-count.js");
78
78
  Object.defineProperty(exports, "recordCheck", { enumerable: true, get: function () { return check_count_js_1.recordCheck; } });
79
+ // --- fixture roots: a temp directory whose two spellings agree ---
80
+ // 🔴 DO NOT hand-roll `mkdtempSync(join(tmpdir(), …))` in a harness. On macOS
81
+ // `/var` is a symlink to `/private/var`, so that shape hands you a directory with
82
+ // TWO spellings: Node resolves a module's own URL to the realpath but leaves
83
+ // `process.argv[1]` and any path you composed as typed. Every assertion that
84
+ // compares them is then red on macOS and green on Linux, for a reason that
85
+ // belongs to neither the test nor the code under test — measured three times in
86
+ // one consumer suite (#241, zernie/research-paper-pipeline#9).
87
+ //
88
+ // `makeTmpDir` resolves the root once, at creation. The trap is unreachable from
89
+ // anything built under it, including a symlink the harness creates ITSELF to test
90
+ // symlink handling — that stays a genuine test, because it is explicit.
91
+ var tmp_root_js_1 = require("./core/tmp-root.js");
92
+ Object.defineProperty(exports, "makeTmpDir", { enumerable: true, get: function () { return tmp_root_js_1.makeTmpDir; } });
93
+ Object.defineProperty(exports, "cleanupTmpDir", { enumerable: true, get: function () { return tmp_root_js_1.cleanupTmpDir; } });
79
94
  // --- the process primitives: runScript (any program) + runHook (plus a decision) ---
80
95
  // `runScript` runs any program and reports what it DID (exit, both streams,
81
96
  // writes, egress). `runHook` is that plus the hook protocol: event to stdin,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "27.2.0",
3
+ "version": "28.0.0",
4
4
  "description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
5
5
  "keywords": [
6
6
  "claude-code",