bareloop 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/bundle.js CHANGED
@@ -27,6 +27,7 @@ import { jobSpecHash } from './job.js';
27
27
  import { loadRegistry } from './bridges.js';
28
28
  import { closeStagesOf } from './plan.js';
29
29
  import { shapeForkName } from './reuse.js';
30
+ import { absolutePathLiteralsOf, hashCloseScriptBytes } from './close-integrity.js';
30
31
  // The live export list IS `src/index.js`'s own `Object.keys` — never a second,
31
32
  // hand-kept copy that can drift from the real surface a close script can
32
33
  // legally import. This module is itself exported from `index.js` (a cycle by
@@ -128,50 +129,10 @@ function isRelative(spec) {
128
129
  return spec.startsWith('./') || spec.startsWith('../');
129
130
  }
130
131
 
131
- // Every quoted string literal (single/double/backtick). Backtick literals
132
- // carrying `${…}` interpolation are filtered out below — they are not a
133
- // fixed literal, so they cannot be "a path baked into the script" at all.
134
- const STRING_LITERAL_RE = /'([^'\\]*(?:\\.[^'\\]*)*)'|"([^"\\]*(?:\\.[^"\\]*)*)"|`([^`\\]*(?:\\.[^`\\]*)*)`/g;
135
-
136
- // A close script is entitled to name a real system path (`/usr/bin/env`,
137
- // `/etc/hosts`, …) without that being the F8/F129 hazard — only a path this
138
- // bundle would actually relocate (a patient checkout, a scratch dir) is.
139
- const SYSTEM_PATH_PREFIXES = ['/usr/', '/bin/', '/sbin/', '/lib/', '/lib64/', '/dev/', '/etc/', '/proc/', '/sys/', '/opt/'];
140
-
141
- /**
142
- * F8/F129 — a close judges the cwd the runner gives it, never a path baked
143
- * into the script itself (`src/ralph.js`'s `cwd` is load-bearing precisely
144
- * because every close script in this repo's OWN test suite named an
145
- * absolute path, so cwd never mattered there — see F8). This scans every
146
- * quoted string literal in a close script's source for an absolute POSIX
147
- * path (`/…`) that EXISTS on the exporting machine right now and is not
148
- * under an allow-listed system prefix.
149
- *
150
- * Deliberately monotone and simple, not clever, with named limits: a
151
- * nonexistent absolute-looking string is treated as a NAME, not a path —
152
- * `existsSync` is the only oracle this has for "load-bearing", so it is not
153
- * flagged; a path built by string concatenation (never one whole literal)
154
- * is invisible to this scan; and a literal that only exists inside a `//` or
155
- * `/* *\/` comment is naturally excluded because a comment carries no quote
156
- * characters of its own around the path text.
157
- * @param {string} source close script text
158
- * @returns {string[]} the offending literal path values, in source order
159
- */
160
- function absolutePathLiteralsOf(source) {
161
- /** @type {string[]} */
162
- const hits = [];
163
- for (const m of source.matchAll(STRING_LITERAL_RE)) {
164
- const isBacktick = m[3] !== undefined;
165
- const value = m[1] ?? m[2] ?? m[3];
166
- if (value === undefined) continue;
167
- if (isBacktick && value.includes('${')) continue; // interpolated, not a fixed literal
168
- if (!value.startsWith('/')) continue;
169
- if (SYSTEM_PATH_PREFIXES.some((p) => value.startsWith(p))) continue;
170
- if (!existsSync(value)) continue; // a nonexistent path is a name, not a hazard
171
- hits.push(value);
172
- }
173
- return hits;
174
- }
132
+ // F8/F129 — `absolutePathLiteralsOf` (a close judges the cwd the runner
133
+ // gives it, never a path baked into the script itself) now lives in
134
+ // `src/close-integrity.js`, shared with the run-start precheck that applies
135
+ // the SAME rule to every job, not only exports. Never a second spelling.
175
136
 
176
137
  /**
177
138
  * Every import in a close script this bundle has an opinion about: a
@@ -378,6 +339,20 @@ export function exportBundle({ spec, closeScripts, registryDir, outDir, bareloop
378
339
  for (const lit of absolutePathLiteralsOf(source)) {
379
340
  red('close-absolute-path', at, `${basename(scriptPath)}: bakes in the absolute path "${lit}" — a close judges the cwd the runner gives it, never a path baked into the script (F8/F129)`);
380
341
  }
342
+ // PRD item 27/M2 — the spec's OWN sha256 (if it carries one) must agree
343
+ // with the bytes actually being packed into the bundle: the two
344
+ // signatures (this one, and `bundleHash`'s manifest hash over the
345
+ // rewritten script) cover the same bytes by two different paths, and N4
346
+ // is exactly the hazard of letting a spec's signature drift from what it
347
+ // names. A spec with no sha256 at all is a SEPARATE red (`validateJob`'s
348
+ // `missing-required`, upstream of export) — this only fires when a value
349
+ // is present and wrong.
350
+ if (isNonEmptyString(stage.sha256)) {
351
+ const actual = hashCloseScriptBytes(source);
352
+ if (actual !== stage.sha256) {
353
+ red('close-sha-mismatch', at, `${basename(scriptPath)}: signed sha256 ${stage.sha256.slice(0, 12)}… does not match the script bytes being packed (${actual.slice(0, 12)}…) — re-sign before exporting`);
354
+ }
355
+ }
381
356
  });
382
357
 
383
358
  if (reds.length > 0) return fail();
@@ -391,10 +366,19 @@ export function exportBundle({ spec, closeScripts, registryDir, outDir, bareloop
391
366
 
392
367
  /** @type {Record<string, string>} */
393
368
  const files = {};
369
+ // F132 — keyed by scriptPath (not name) so the spec-rewrite pass below can
370
+ // look up each relocated stage's REWRITTEN-bytes hash without re-hashing.
371
+ // Both `sha256` here and `hashCloseScriptBytes` (src/close-integrity.js)
372
+ // hash the same way (sha256 of the UTF-8 script text) — this is not a
373
+ // second formula, just this module's own existing helper reused.
374
+ /** @type {Map<string, string>} scriptPath -> sha256 of the RELOCATED bytes */
375
+ const relocatedSha = new Map();
394
376
  for (const [scriptPath, name] of byPath) {
395
377
  const rewritten = rewriteSrcImports(scripts[scriptPath]);
396
378
  writeFileSync(join(closeDir, name), rewritten);
397
- files[`close/${name}`] = sha256(rewritten);
379
+ const hash = sha256(rewritten);
380
+ files[`close/${name}`] = hash;
381
+ relocatedSha.set(scriptPath, hash);
398
382
  }
399
383
 
400
384
  const specOut = { ...spec, close: spec.close.map((/** @type {any} */ stage) => {
@@ -404,7 +388,17 @@ export function exportBundle({ spec, closeScripts, registryDir, outDir, bareloop
404
388
  if (!parsed.ok) throw new Error(`unreachable: ${stage.cmd} was validated as a relocatable close cmd`);
405
389
  const name = byPath.get(parsed.scriptPath);
406
390
  const cmd = ['node', `${BUNDLE_TOKEN}/close/${name}`, ...parsed.rest].join(' ');
407
- return { ...stage, cmd };
391
+ // F132 (run mtqwmb9l/mtqwydl4): the bundle's manifest hash covers the
392
+ // RELOCATED bytes (the import rewrite above), but the spec's own
393
+ // `close[].sha256` was left carrying the SOURCE spec's signature — two
394
+ // signatures over two different byte strings, so `checkCloseByteSignature`
395
+ // at run start correctly read the bundle as tampered even though nothing
396
+ // was. Re-sign here, over the bytes actually being packed, so the two
397
+ // signatures agree by construction. Only a stage that already carried a
398
+ // signature gets one written back — a stage export never fabricates a
399
+ // signature that was never there (M2 leaves that to the validator).
400
+ const sha256Out = typeof stage.sha256 === 'string' ? (relocatedSha.get(parsed.scriptPath) ?? stage.sha256) : stage.sha256;
401
+ return { ...stage, cmd, sha256: sha256Out };
408
402
  }) };
409
403
  const specText = `${JSON.stringify(specOut, null, 2)}\n`;
410
404
  writeFileSync(join(outDir, 'spec.json'), specText);
package/src/calibrate.js CHANGED
@@ -63,6 +63,7 @@ import {
63
63
  } from './judged.js';
64
64
  import { JUDGE_ATTEMPTS } from './kinds.js';
65
65
  import { tallyCalls } from './text.js';
66
+ import { PROVIDER_TIMEOUT_MS } from './clock.js';
66
67
 
67
68
  /** @typedef {{code: string, path: string, detail: string, [k: string]: any}} Red */
68
69
 
@@ -330,6 +331,15 @@ async function pipeOnce({ artifactText, card, judgeLoop, attempts, meter, id, ki
330
331
  card,
331
332
  loopFactory: /** @type {any} */ (judgeLoop),
332
333
  attempt,
334
+ // F152/PRD 30.4 — this module's own header already names the hazard ("a
335
+ // dead socket") as one of its three CASUALTY readings, but with no bound
336
+ // at all a truly silent judge endpoint never reaches that reading — it
337
+ // hangs the authoring process forever instead (measured). The gate runs
338
+ // at draft/sign time, which has no wall clock to derive a bound from (the
339
+ // authoring pipeline's only ceiling is money, `ceilingUsd`/`capStop`), so
340
+ // this reuses the same idle-timeout default the run path falls back to
341
+ // when ITS OWN wall is unbounded (`PROVIDER_TIMEOUT_MS`, src/clock.js).
342
+ callBounds: { timeoutMs: PROVIDER_TIMEOUT_MS },
333
343
  onCost: (c) => meter({
334
344
  id, kind, attempt, label: CALIBRATION_LABEL, model: JUDGE_MODEL,
335
345
  costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
package/src/cli.js CHANGED
@@ -19,7 +19,6 @@
19
19
  // run, never the unresolved bundleHash. `history.jsonl` records both hashes
20
20
  // side by side (POC fact 2) so the pairing can never drift silently.
21
21
 
22
- import { createRequire } from 'node:module';
23
22
  import { createInterface } from 'node:readline/promises';
24
23
  import { hostname } from 'node:os';
25
24
  import { execFileSync } from 'node:child_process';
@@ -34,15 +33,13 @@ import {
34
33
  exportBundle, readBundle, resolveBundleSpec, checkEnvelope, bless, verifyBlessing, appendHistory, checkBundleDeps,
35
34
  runJob, makeSpine, loadRegistry, listingRow, jobSpecHash, resolveWorkerModel, JUDGE_MODEL,
36
35
  } from './index.js';
36
+ import { resolveProvider, buildRunnerProviders } from './providers.js';
37
37
 
38
- const require = createRequire(import.meta.url);
39
-
40
- // The runner's own tier menu, the same spelling `scripts/run-u.mjs` uses (not
41
- // exported from `src/` — the tier->model mapping is runner territory, not the
42
- // library's). A `bareloop run` has no `--model` flag (not in the frozen
43
- // spec), so `flagModel` is always undefined and only the spec's own `model`
44
- // (if signed) or this default is ever in play.
45
- const DEFAULT_TIER_MODELS = { sonnet: 'claude-sonnet-5', haiku: 'claude-haiku-4-5-20251001' };
38
+ // The tier->model tables live in `src/providers.js` now (PRD item 28's
39
+ // factory) — one seam instead of a copy hardcoded in each runner. A
40
+ // `bareloop run` has no `--model` flag (not in the frozen spec), so
41
+ // `flagModel` is always undefined below and only the spec's own `model`
42
+ // (if signed) or the resolved provider's default tier is ever in play.
46
43
 
47
44
  /** @param {unknown} v @returns {v is Record<string, any>} */
48
45
  const isObj = (v) => typeof v === 'object' && v !== null;
@@ -58,20 +55,38 @@ const isObj = (v) => typeof v === 'object' && v !== null;
58
55
  * @param {any} spec the (already `$BARELOOP_BUNDLE`-resolved) bundle spec
59
56
  */
60
57
  function buildProviders(apiKey, spec) {
61
- const { AnthropicProvider } = require('bare-agent/providers');
58
+ // `bareloop run`'s frozen key contract (module header above) is
59
+ // ANTHROPIC_API_KEY only — this `apiKey` is always that key. A bundle
60
+ // whose spec names a provider that reads a DIFFERENT env key therefore
61
+ // has no seam into this flow yet, and the one thing this must never do is
62
+ // construct that provider with the Anthropic key: the run would fail at
63
+ // the first call with a vendor 401, which reads as a credential problem
64
+ // rather than as the unbuilt seam it actually is. So it REFUSES here, at
65
+ // $0, naming the key it would have needed. Widening the contract (a
66
+ // signed per-provider key story) is PRD item 28 part (2)/(3), the
67
+ // arbiter's, not this factory rewire's — the runner that actually fires
68
+ // openai-api jobs today is `scripts/run-u.mjs`, which IS key-aware.
69
+ const providerName = isObj(spec) && typeof spec.provider === 'string' ? spec.provider : 'anthropic-api';
70
+ const entry = resolveProvider(providerName);
71
+ if (entry.envKey !== 'ANTHROPIC_API_KEY') {
72
+ throw new Error(
73
+ `bareloop run: this bundle's spec names provider "${providerName}", which reads `
74
+ + `${entry.envKey}. The bundle runner's key contract is ANTHROPIC_API_KEY only, so it `
75
+ + 'refuses rather than calling that provider with the wrong key (PRD item 28 part 2 — '
76
+ + 'a signed per-provider key story is not built). Run this job through scripts/run-u.mjs.',
77
+ );
78
+ }
62
79
  const modelResolution = resolveWorkerModel({
63
80
  specModel: isObj(spec) && typeof spec.model === 'string' ? spec.model : undefined,
64
81
  flagModel: undefined,
65
- defaultModel: DEFAULT_TIER_MODELS.sonnet,
82
+ defaultModel: entry.tiers.sonnet,
66
83
  });
67
84
  const MODEL = modelResolution.model;
68
- const provider = new AnthropicProvider({ apiKey, model: MODEL });
69
- const TIER_MODELS = modelResolution.source === 'spec' ? { ...DEFAULT_TIER_MODELS, sonnet: MODEL } : DEFAULT_TIER_MODELS;
70
- /** @type {Record<string, any>} */
71
- const tierCache = {};
72
- const providerFor = (/** @type {string} */ tier) => (tierCache[tier] ??= (TIER_MODELS[/** @type {keyof typeof TIER_MODELS} */ (tier)] === MODEL ? provider : new AnthropicProvider({ apiKey, model: TIER_MODELS[/** @type {keyof typeof TIER_MODELS} */ (tier)] })));
73
- const judgeProvider = new AnthropicProvider({ apiKey, model: JUDGE_MODEL });
74
- return { provider, providerFor, judgeProvider };
85
+ const tierModels = modelResolution.source === 'spec' ? { ...entry.tiers, sonnet: MODEL } : entry.tiers;
86
+ const baseUrl = isObj(spec) && typeof spec.baseUrl === 'string' ? spec.baseUrl : undefined;
87
+ return buildRunnerProviders({
88
+ providerName, apiKey, model: MODEL, tierModels, baseUrl, judgeApiKey: apiKey, judgeModel: JUDGE_MODEL,
89
+ });
75
90
  }
76
91
 
77
92
  /** @param {string[]} args @returns {{ positional: string[], flags: Record<string, string|true> }} */
@@ -230,7 +245,13 @@ async function doRun(args, { out, err, cwd, env, now, deps }) {
230
245
  out(`bundleHash: ${bundle.manifest.bundleHash}`);
231
246
  return 0;
232
247
  }
233
- ({ provider, providerFor, judgeProvider } = buildProviders(apiKey, bundle.spec));
248
+ try {
249
+ ({ provider, providerFor, judgeProvider } = buildProviders(apiKey, bundle.spec));
250
+ } catch (e) {
251
+ // a refusal from the key contract above: name it and spend nothing
252
+ err(String(/** @type {any} */ (e)?.message ?? e));
253
+ return 1;
254
+ }
234
255
  }
235
256
 
236
257
  // 4. blessing.
@@ -300,6 +321,14 @@ async function doRun(args, { out, err, cwd, env, now, deps }) {
300
321
  mkdirSync(runsDir, { recursive: true });
301
322
  const spineFile = join(runsDir, 'spine.jsonl');
302
323
  const emit = makeSpine(spineFile);
324
+ // PRD item 27/M3 Part B — the close's own books directory, inside the run's
325
+ // own directory so the bundle stays one self-contained unit (hamr,
326
+ // 2026-09-07: "export holds as one unit of itself"). `runJob` autosets the
327
+ // close timeout itself (no `closeTimeoutMs` passed — the bundle CLI has
328
+ // always run on the library default/autoset path, never run-u's operator
329
+ // knob).
330
+ const closeDir = join(runsDir, 'close');
331
+ mkdirSync(closeDir, { recursive: true });
303
332
 
304
333
  let outcome;
305
334
  try {
@@ -310,9 +339,13 @@ async function doRun(args, { out, err, cwd, env, now, deps }) {
310
339
  providerFor,
311
340
  judgeProvider,
312
341
  emit,
342
+ closeDir,
313
343
  shellCapUsd: runSpec.budgetUsd,
314
344
  readShim: 'cap',
315
345
  scout: true,
346
+ // F130/PRD item 27(c) — the bundle CLI has no `--resume` (v1); the
347
+ // honest tail says so instead of naming a flag that does not exist.
348
+ resumable: false,
316
349
  });
317
350
  } catch (e) {
318
351
  err(`runJob crashed: ${/** @type {Error} */ (e).message}`);
@@ -0,0 +1,325 @@
1
+ // Close integrity — PRD item 27 / M1(b), `docs/product/CLOSE-INTEGRITY-BUILD.md`.
2
+ //
3
+ // ONE detector for "does a close script bake in an absolute path", shared by
4
+ // `src/bundle.js` (export-time, mechanical) and the run-start precheck below
5
+ // (every job, not only bundles). Two spellings of the same rule is exactly
6
+ // how F129 shipped an export-only guard while every non-exported job stayed
7
+ // exposed — this module exists so that never happens again.
8
+ //
9
+ // `readCloseScripts` is the reader M2's sha256 fingerprint reuses: pure,
10
+ // never throws on a missing file (reports it absent so a caller can red
11
+ // distinctly from a tampered one). Scope (widened 2026-09-06, orchestrator
12
+ // audit): an INTERPRETER cmd (`node <path> …`, `sh <path> …`, …) OR a bare
13
+ // directly-executable ABSOLUTE path (a `.sh` wrapper with no interpreter
14
+ // prefix, e.g. `jobs/aurora-testgen-cold.json`'s close) — see
15
+ // `closeScriptCandidateToken` (src/validate.js) for the exact shape test.
16
+ // A relative bare executable (`true`, `pytest`) is still out of scope: it
17
+ // names no file at all, so there is nothing to read or sign.
18
+ //
19
+ // M2 (PRD item 27, `docs/product/CLOSE-INTEGRITY-BUILD.md`) adds the
20
+ // close-BYTES half beside the close-PATH half already here: `close[].sha256`
21
+ // is a hex sha256 of the same script bytes `readCloseScripts` already reads,
22
+ // so the fingerprint (`checkCloseByteSignature`/`checkStageByteSignature`)
23
+ // and the minting helper (`signCloseScripts`) both live in this one module —
24
+ // two spellings of "which bytes does this stage sign" is exactly the class
25
+ // of drift F129 shipped once already (see the module header above).
26
+
27
+ import { existsSync, readFileSync } from 'node:fs';
28
+ import { createHash } from 'node:crypto';
29
+ import { isAbsolute, resolve } from 'node:path';
30
+ import { isObj, closeScriptCandidateToken } from './validate.js';
31
+ import { closeStagesOf } from './plan.js';
32
+
33
+ /**
34
+ * Resolve a close stage's `cmd` to the addressable script's ABSOLUTE path on
35
+ * disk — the shape test itself (`closeScriptCandidateToken`, src/validate.js)
36
+ * is shared with `src/job.js`'s sha256 demand; this just resolves the
37
+ * resulting token against `cwd` when it is relative. Returns null for
38
+ * anything the shared shape test rejects (a relative bare executable, an
39
+ * interpreter with no argument, a non-string/empty cmd).
40
+ * @param {unknown} cmd
41
+ * @param {string} cwd
42
+ * @returns {string|null} absolute script path, or null if out of scope
43
+ */
44
+ function closeScriptPathOf(cmd, cwd) {
45
+ const token = closeScriptCandidateToken(cmd);
46
+ if (token === null) return null;
47
+ return isAbsolute(token) ? token : resolve(cwd, token);
48
+ }
49
+
50
+ /**
51
+ * Hex sha256 of a close script's TEXT, exactly the way `src/bundle.js`'s own
52
+ * `sha256(source)` hashes a close script's source before bundling (a JS
53
+ * source file is always valid UTF-8, so hashing the decoded string and
54
+ * hashing the raw file bytes agree) — ONE formula, so a signature minted here
55
+ * and a mismatch read at export time can never disagree about what "the
56
+ * bytes" means.
57
+ * @param {string} text
58
+ * @returns {string} sha256 hex
59
+ */
60
+ export function hashCloseScriptBytes(text) {
61
+ return createHash('sha256').update(text, 'utf8').digest('hex');
62
+ }
63
+
64
+ /**
65
+ * A close script is entitled to name a real system path (`/usr/bin/env`,
66
+ * `/etc/hosts`, …) without that being the F8/F129 hazard — only a path a
67
+ * runner would actually relocate (a patient checkout, a scratch dir) is.
68
+ * @type {string[]}
69
+ */
70
+ export const SYSTEM_PATH_PREFIXES = ['/usr/', '/bin/', '/sbin/', '/lib/', '/lib64/', '/dev/', '/etc/', '/proc/', '/sys/', '/opt/'];
71
+
72
+ // Every quoted string literal (single/double/backtick). Backtick literals
73
+ // carrying `${…}` interpolation are filtered out below — they are not a
74
+ // fixed literal, so they cannot be "a path baked into the script" at all.
75
+ const STRING_LITERAL_RE = /'([^'\\]*(?:\\.[^'\\]*)*)'|"([^"\\]*(?:\\.[^"\\]*)*)"|`([^`\\]*(?:\\.[^`\\]*)*)`/g;
76
+
77
+ /**
78
+ * F8/F129 — a close judges the cwd the runner gives it, never a path baked
79
+ * into the script itself. This scans every quoted string literal in a close
80
+ * script's source for an absolute POSIX path (`/…`) that EXISTS on this
81
+ * machine right now and is not under an allow-listed system prefix.
82
+ *
83
+ * Deliberately monotone and simple, not clever, with named limits: a
84
+ * nonexistent absolute-looking string is treated as a NAME, not a path —
85
+ * `existsSync` is the only oracle this has for "load-bearing", so it is not
86
+ * flagged; a path built by string concatenation (never one whole literal)
87
+ * is invisible to this scan; and a literal that only exists inside a `//` or
88
+ * `/* *\/` comment is naturally excluded because a comment carries no quote
89
+ * characters of its own around the path text.
90
+ * @param {string} source close script text
91
+ * @returns {string[]} the offending literal path values, in source order
92
+ */
93
+ export function absolutePathLiteralsOf(source) {
94
+ /** @type {string[]} */
95
+ const hits = [];
96
+ for (const m of source.matchAll(STRING_LITERAL_RE)) {
97
+ const isBacktick = m[3] !== undefined;
98
+ const value = m[1] ?? m[2] ?? m[3];
99
+ if (value === undefined) continue;
100
+ if (isBacktick && value.includes('${')) continue; // interpolated, not a fixed literal
101
+ if (!value.startsWith('/')) continue;
102
+ if (SYSTEM_PATH_PREFIXES.some((p) => value.startsWith(p))) continue;
103
+ if (!existsSync(value)) continue; // a nonexistent path is a name, not a hazard
104
+ hits.push(value);
105
+ }
106
+ return hits;
107
+ }
108
+
109
+ /**
110
+ * Resolve every `close[].cmd` (or `closeDecl`'s equivalent, via
111
+ * `closeStagesOf`) that names an addressable script (`closeScriptCandidateToken`'s
112
+ * scope: an interpreter cmd or a bare absolute executable) to its absolute
113
+ * path and bytes off disk. The spec's OWN `cmd` path is the address and is never
114
+ * itself judged — only the named script's CONTENT is read here. A relative
115
+ * path resolves against `cwd` (the caller passes the run's own workdir or
116
+ * the spec file's directory, matching how each caller already resolves
117
+ * paths elsewhere). Never throws: a missing/unreadable script reports
118
+ * `bytes: null` so a caller can red it distinctly from a tampered one.
119
+ * `sha256` (M2, additive): the stage's OWN signed field, verbatim, or `null`
120
+ * when the stage carries none — the field this rung's checker compares
121
+ * against `hashCloseScriptBytes(bytes)`. M1's callers read `.stage`/`.path`/
122
+ * `.bytes` only, so this addition is behaviour-preserving for them.
123
+ * @param {any} spec resolved job spec (or anything `closeStagesOf` accepts)
124
+ * @param {string} cwd
125
+ * @returns {{stage: string, path: string, bytes: string|null, sha256: string|null}[]}
126
+ */
127
+ export function readCloseScripts(spec, cwd) {
128
+ const stages = closeStagesOf(spec) ?? [];
129
+ /** @type {{stage: string, path: string, bytes: string|null, sha256: string|null}[]} */
130
+ const out = [];
131
+ for (const stage of stages) {
132
+ if (!isObj(stage) || typeof stage.cmd !== 'string') continue;
133
+ const scriptPath = closeScriptPathOf(stage.cmd, cwd);
134
+ if (!scriptPath) continue;
135
+ /** @type {string|null} */
136
+ let bytes = null;
137
+ try { bytes = readFileSync(scriptPath, 'utf8'); } catch { bytes = null; }
138
+ out.push({
139
+ stage: typeof stage.name === 'string' ? stage.name : scriptPath,
140
+ path: scriptPath,
141
+ bytes,
142
+ sha256: typeof stage.sha256 === 'string' ? stage.sha256 : null,
143
+ });
144
+ }
145
+ return out;
146
+ }
147
+
148
+ /**
149
+ * The $0 run-start integrity check (PRD item 27(c)): red `close-absolute-path`
150
+ * for EVERY job whose close script bakes in an absolute path literal that
151
+ * exists on disk — the same rule `src/bundle.js`'s `exportBundle` already
152
+ * applies at export time, now applied before the close-first precheck and
153
+ * before any provider call, for every run, not only exported bundles. A
154
+ * script this reads couldn't read (missing/unreadable) is not reported here
155
+ * — that is `broken-close`'s job once the close actually tries to run it.
156
+ * @param {any} spec resolved job spec
157
+ * @param {string} cwd
158
+ * @returns {{ ok: true } | { ok: false, reds: {stage: string, path: string, literal: string}[] }}
159
+ */
160
+ export function checkCloseAbsolutePaths(spec, cwd) {
161
+ const scripts = readCloseScripts(spec, cwd);
162
+ /** @type {{stage: string, path: string, literal: string}[]} */
163
+ const reds = [];
164
+ for (const s of scripts) {
165
+ if (s.bytes == null) continue;
166
+ for (const literal of absolutePathLiteralsOf(s.bytes)) {
167
+ reds.push({ stage: s.stage, path: s.path, literal });
168
+ }
169
+ }
170
+ return reds.length > 0 ? { ok: false, reds } : { ok: true };
171
+ }
172
+
173
+ /** @typedef {{stage: string, path: string, expected: string, actual: string|null}} ByteRed */
174
+
175
+ /**
176
+ * The core comparison, shared by the two checkers below: a `readCloseScripts`
177
+ * row signs nothing (`sha256: null`, e.g. a `.sh`-cmd stage or a declared
178
+ * kind) reads as `ok` — M2's validator is what demands the field on the
179
+ * stages that must carry it; this runtime check only ever compares a
180
+ * signature that exists.
181
+ * @param {{stage: string, path: string, bytes: string|null, sha256: string|null}[]} scripts
182
+ * @returns {{ ok: true } | { ok: false, reds: ByteRed[] }}
183
+ */
184
+ function compareSignatures(scripts) {
185
+ /** @type {ByteRed[]} */
186
+ const reds = [];
187
+ for (const s of scripts) {
188
+ if (s.sha256 == null) continue;
189
+ const actual = s.bytes == null ? null : hashCloseScriptBytes(s.bytes);
190
+ if (actual !== s.sha256) reds.push({ stage: s.stage, path: s.path, expected: s.sha256, actual });
191
+ }
192
+ return reds.length > 0 ? { ok: false, reds } : { ok: true };
193
+ }
194
+
195
+ /**
196
+ * M2's run-start integrity check (PRD item 27, `close-tampered`): every
197
+ * signed `close[].sha256` must match the script's bytes off disk, RIGHT NOW —
198
+ * checked before the close-first precheck and before any provider call,
199
+ * exactly where `checkCloseAbsolutePaths` already runs (the two are always
200
+ * called together at that seam). A script this cannot read reads `actual:
201
+ * null` — distinct from a byte mismatch, but still a red: an unreadable
202
+ * signed script is exactly as untrustworthy as a tampered one, and silently
203
+ * treating "gone" as "fine" would be the M1 F129 hazard in a new coat.
204
+ * @param {any} spec resolved job spec
205
+ * @param {string} cwd
206
+ * @returns {{ ok: true } | { ok: false, reds: ByteRed[] }}
207
+ */
208
+ export function checkCloseByteSignature(spec, cwd) {
209
+ return compareSignatures(readCloseScripts(spec, cwd));
210
+ }
211
+
212
+ /** the exact env var name a close script reads for its own books directory
213
+ * (Part B, PRD item 27/M3) — named once so the detector below and the runner
214
+ * that sets it (`src/ralph.js`'s `runClose`) can never drift into two
215
+ * spellings of the same contract. */
216
+ export const CLOSE_DIR_ENV_VAR = 'BARELOOP_CLOSE_DIR';
217
+
218
+ /**
219
+ * PRD item 27/M3, Part B — "the library `runJob` takes `closeDir` and refuses
220
+ * at $0 if a spec's close needs it and none is given." A close script's
221
+ * NEED is read the same way its bytes already are (`readCloseScripts`): a
222
+ * script whose source mentions `BARELOOP_CLOSE_DIR` reads that variable at
223
+ * run time, so a run with no `closeDir` handed to it would run that script
224
+ * against `undefined` — silently, since `env.BARELOOP_CLOSE_DIR` would just
225
+ * be absent from the child's environment rather than throwing. Refusing here,
226
+ * at the same $0 run-start seam as the absolute-path/byte-signature checks,
227
+ * turns that silent gap into a named red before any tokens spend, rather than
228
+ * leaving it to whatever the script's own author remembered to check for.
229
+ *
230
+ * A script that never mentions the variable carries no demand — most
231
+ * predicate-only fixture closes (`true`, `pytest`) have no books of their own
232
+ * to keep, and requiring a `closeDir` for them would be a needless refusal.
233
+ * @param {any} spec resolved job spec
234
+ * @param {string} cwd
235
+ * @param {string|null|undefined} closeDir the value the runner is about to hand
236
+ * the close as `BARELOOP_CLOSE_DIR` (or none)
237
+ * @returns {{ ok: true } | { ok: false, reds: {stage: string, path: string}[] }}
238
+ */
239
+ export function checkCloseDirRequired(spec, cwd, closeDir) {
240
+ if (typeof closeDir === 'string' && closeDir.length > 0) return { ok: true };
241
+ const scripts = readCloseScripts(spec, cwd);
242
+ /** @type {{stage: string, path: string}[]} */
243
+ const reds = [];
244
+ for (const s of scripts) {
245
+ if (s.bytes != null && s.bytes.includes(CLOSE_DIR_ENV_VAR)) reds.push({ stage: s.stage, path: s.path });
246
+ }
247
+ return reds.length > 0 ? { ok: false, reds } : { ok: true };
248
+ }
249
+
250
+ /**
251
+ * The re-verify that runs before EVERY close run, precheck included (PRD
252
+ * item 27, M2 §2) — scoped to the STAGES about to actually run rather than
253
+ * the whole spec, because that is the true blast radius of "was this close
254
+ * script's content tampered with since it was signed" for this particular
255
+ * invocation. Same comparison, same red shape, as `checkCloseByteSignature`;
256
+ * kept as a separate export because its caller (`runCloseStages` in
257
+ * `src/planrun.js`) already holds a stage array, not a spec.
258
+ * @param {any[]} stages a close stage chain (the shape `runStages` takes)
259
+ * @param {string} cwd
260
+ * @returns {{ ok: true } | { ok: false, reds: ByteRed[] }}
261
+ */
262
+ export function checkStageByteSignature(stages, cwd) {
263
+ const scripts = [];
264
+ for (const stage of Array.isArray(stages) ? stages : []) {
265
+ if (!isObj(stage) || typeof stage.cmd !== 'string') continue;
266
+ const scriptPath = closeScriptPathOf(stage.cmd, cwd);
267
+ if (!scriptPath) continue;
268
+ /** @type {string|null} */
269
+ let bytes = null;
270
+ try { bytes = readFileSync(scriptPath, 'utf8'); } catch { bytes = null; }
271
+ scripts.push({
272
+ stage: typeof stage.name === 'string' ? stage.name : scriptPath,
273
+ path: scriptPath,
274
+ bytes,
275
+ sha256: typeof stage.sha256 === 'string' ? stage.sha256 : null,
276
+ });
277
+ }
278
+ return compareSignatures(scripts);
279
+ }
280
+
281
+ /**
282
+ * M2's minting helper (PRD item 27 §3) — fills `close[].sha256` from disk for
283
+ * every addressable-script stage in `spec.close` (`closeScriptCandidateToken`'s
284
+ * scope: an interpreter cmd or a bare absolute executable, e.g. a `.sh`
285
+ * wrapper). Pure: never writes anything itself (the CLI wrapper,
286
+ * `scripts/sign-close.mjs`, gates on `--write`) and never throws on a
287
+ * missing/unreadable script — it names the miss in `missing` and leaves
288
+ * that stage's `sha256` untouched, so a caller can refuse to sign (and
289
+ * refuse to report a hash it never computed) a spec it could not fully
290
+ * hash. A stage outside this rung's scope (a relative bare executable, a
291
+ * declared-kind stage) passes through unchanged and is never listed as
292
+ * missing — it was never addressable to begin with (same scope
293
+ * `readCloseScripts`/`checkCloseAbsolutePaths` already draw).
294
+ *
295
+ * Only ever operates on `spec.close` (the command-close array) — a
296
+ * `closeDecl` spec has no script path to sign and `signCloseScripts` reports
297
+ * `ok:false` for one rather than silently doing nothing to it.
298
+ * @param {any} spec resolved job spec (parsed `jobs/<x>.json`)
299
+ * @param {string} cwd base for resolving a RELATIVE script path (jobs in this
300
+ * repo only ever use absolute `cmd` paths; relative resolution is here for
301
+ * parity with `readCloseScripts` and any future relative-path spec)
302
+ * @returns {{ ok: boolean, spec: any, changes: {stage: string, path: string, old: string|null, new: string}[], missing: {stage: string, path: string}[] }}
303
+ */
304
+ export function signCloseScripts(spec, cwd) {
305
+ if (!isObj(spec) || !Array.isArray(spec.close)) {
306
+ return { ok: false, spec, changes: [], missing: [] };
307
+ }
308
+ /** @type {{stage: string, path: string, old: string|null, new: string}[]} */
309
+ const changes = [];
310
+ /** @type {{stage: string, path: string}[]} */
311
+ const missing = [];
312
+ const newClose = spec.close.map((/** @type {any} */ stage) => {
313
+ if (!isObj(stage)) return stage;
314
+ const scriptPath = closeScriptPathOf(stage.cmd, cwd);
315
+ if (!scriptPath) return stage; // out of this rung's scope — never touched, never reported missing
316
+ /** @type {string|null} */
317
+ let bytes = null;
318
+ try { bytes = readFileSync(scriptPath, 'utf8'); } catch { bytes = null; }
319
+ if (bytes == null) { missing.push({ stage: stage.name, path: scriptPath }); return stage; }
320
+ const hash = hashCloseScriptBytes(bytes);
321
+ changes.push({ stage: stage.name, path: scriptPath, old: typeof stage.sha256 === 'string' ? stage.sha256 : null, new: hash });
322
+ return { ...stage, sha256: hash };
323
+ });
324
+ return { ok: missing.length === 0, spec: { ...spec, close: newClose }, changes, missing };
325
+ }