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/CHANGELOG.md +292 -0
- package/README.md +1 -1
- package/bareloop.context.md +177 -24
- package/package.json +3 -3
- package/src/authorflow.js +7 -2
- package/src/authorjob.js +15 -1
- package/src/authorscout.js +83 -9
- package/src/bundle.js +40 -46
- package/src/calibrate.js +10 -0
- package/src/cli.js +52 -19
- package/src/close-integrity.js +325 -0
- package/src/closetimeout.js +165 -0
- package/src/declaredclose.js +9 -2
- package/src/index.js +14 -0
- package/src/job.js +84 -5
- package/src/judged.js +108 -5
- package/src/kinds.js +12 -0
- package/src/plan.js +5 -1
- package/src/planrun.js +392 -46
- package/src/providers.js +176 -0
- package/src/ralph.js +40 -7
- package/src/ratelimit.js +116 -0
- package/src/reuse.js +12 -2
- package/src/reviewdoor.js +26 -6
- package/src/run.js +83 -8
- package/src/transport.js +29 -12
- package/src/validate.js +59 -0
- package/types/authorscout.d.ts +27 -2
- package/types/close-integrity.d.ts +196 -0
- package/types/closetimeout.d.ts +126 -0
- package/types/declaredclose.d.ts +6 -1
- package/types/index.d.ts +1 -0
- package/types/job.d.ts +4 -0
- package/types/judged.d.ts +7 -2
- package/types/kinds.d.ts +9 -0
- package/types/planrun.d.ts +17 -2
- package/types/providers.d.ts +104 -0
- package/types/ralph.d.ts +19 -5
- package/types/ratelimit.d.ts +59 -0
- package/types/reuse.d.ts +2 -0
- package/types/reviewdoor.d.ts +3 -1
- package/types/run.d.ts +33 -2
- package/types/transport.d.ts +10 -4
- package/types/validate.d.ts +48 -0
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
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
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
|
-
|
|
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:
|
|
82
|
+
defaultModel: entry.tiers.sonnet,
|
|
66
83
|
});
|
|
67
84
|
const MODEL = modelResolution.model;
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
+
}
|