bareloop 0.7.0 → 0.9.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/validate.js CHANGED
@@ -155,3 +155,124 @@ export function sweepSecretLiterals(root, red) {
155
155
  }
156
156
  })(root, '');
157
157
  }
158
+
159
+ // ── the catastrophic-backtracking reject (F49) ────────────────────────────────
160
+ // Housed here, with the other primitives BOTH validators share, because THREE
161
+ // regex fields reach an untimed evaluator and they are split across the two
162
+ // documents: the agent's `artifact-written.pattern` (plan.js) and the
163
+ // operator's `judged.pattern` + `gapKeep` (job.js). plan.js already imports
164
+ // job.js, so job.js cannot import plan.js — and a second copy here would be the
165
+ // drift class SECRET_PATTERNS exists to prevent: a shape one side rejects and
166
+ // the other admits. ONE inventory, imported by both.
167
+
168
+ /** Length of an UNBOUNDED quantifier token at `src[i]` (`*`, `+`, or `{n,}`,
169
+ * plus an optional trailing lazy `?`), or 0. Bounded forms (`?`, `{n}`,
170
+ * `{n,m}`) return 0: they cannot drive exponential backtracking (a bounded
171
+ * outer repeat is polynomial at worst, and the body is self-authored — F49).
172
+ * @param {string} src @param {number} i */
173
+ function unboundedQuantLen(src, i) {
174
+ let len = src[i] === '*' || src[i] === '+' ? 1 : 0;
175
+ if (!len && src[i] === '{') { const m = /^\{\d+,\}/.exec(src.slice(i)); if (m) len = m[0].length; }
176
+ if (len && src[i + len] === '?') len++; // the lazy modifier is part of the same token
177
+ return len;
178
+ }
179
+
180
+ /**
181
+ * F49 — a heuristic reject for the catastrophic-backtracking footgun: an
182
+ * unbounded quantifier applied to a group whose body itself repeats unboundedly
183
+ * (`(a+)+`, `(\d*)*`, `([a-z]+){1,}`) — including through a redundant wrapping
184
+ * group (`((a+))+`, `(?:(a+))*`), which is the SAME exponential class as `(a+)+`
185
+ * and is caught by propagating the inner repeat up through the wrapper. Such a
186
+ * pattern can hang `RegExp.test` for seconds on a short crafted body, and THREE
187
+ * fields feed it to an evaluator with NO timeout — one per document:
188
+ * - the AGENT's `artifact-written.pattern`, run by `evalExits` (F49, the
189
+ * original site). Self-DoS only: the agent authors both the pattern and
190
+ * (via the worker) the artifact, so a hang burns only its own run's
191
+ * wall-clock, and there is no arbiter compromise — it cannot escape the
192
+ * fence, forge a green, or leak a secret.
193
+ * - the OPERATOR's `judged.pattern` (`runClose`) and `gapKeep` (`boundGap`),
194
+ * compiled and run against close output in BARELOOP'S OWN PROCESS. These
195
+ * are worse than an operator self-DoS, and the reason is F67's lesson: the
196
+ * regex blocks the MAIN EVENT LOOP, so the in-process stall fuse — a timer
197
+ * in that same loop — cannot fire. The run dies to the OUTSIDE watchdog and
198
+ * is read as a model stall, so a bad regex in a SIGNED spec presents as a
199
+ * provider problem. MEASURED: `boundGap(stream, '(a+)+$')` on a 40-char
200
+ * body does not finish in 20s.
201
+ * So the reject targets the dominant exponential class and fails it as a
202
+ * mechanical gap at the validation gate, before any tokens burn. A full ReDoS
203
+ * analyzer needs a real regex engine (an external native dep we do not take for
204
+ * a LOW issue); exotic overlapping-alternation blowup is out of scope by
205
+ * decision (PARKED to the close-authoring rung — PRD v1.55).
206
+ *
207
+ * SCOPE IS ASYMMETRIC, both directions named on purpose:
208
+ * - false NEGATIVE: overlapping-alternation blowup (`(a|ab)+`-class) is not
209
+ * detected — there is no inner QUANTIFIER to find, so the shape scan cannot
210
+ * see it; the blowup comes from overlapping alternation BRANCHES under a
211
+ * repeat. A genuine limit of the approach, not a bug in it. Widening the
212
+ * detector changes which SIGNED specs are admissible, which is
213
+ * arbiter-adjacent — parked, not chased (PRD v1.55).
214
+ * - false POSITIVE: a group whose repetitions are disambiguated by a literal
215
+ * anchor/delimiter (`(?:^- .+$\n?)+`, `(?:CHANGELOG:.+\n)+`) is FLAGGED even
216
+ * though a real engine runs it linearly — the scan sees the nested-quantifier
217
+ * SHAPE, not the disambiguation. Rejecting it is the FAIL-SAFE direction (it
218
+ * never admits an unsafe pattern), and the cost is bounded: the plan drafter
219
+ * gets a mechanical gap and rewrites (drop the outer `+`, or match once).
220
+ * Detecting "safe because anchored" needs the same real engine we declined,
221
+ * and guessing it wrong would ADMIT an exponential pattern — so the shape
222
+ * reject stands, and the over-rejection is a named, accepted limitation.
223
+ *
224
+ * The input is guaranteed to compile as a RegExp (checked first by the caller),
225
+ * so this scan assumes valid, balanced JS regex syntax.
226
+ * @param {string} src a compiled regex source string
227
+ * @returns {boolean} true iff a nested unbounded quantifier is present
228
+ */
229
+ export function hasNestedQuantifier(src) {
230
+ /** @type {{ quant: boolean }[]} */
231
+ const stack = [];
232
+ for (let i = 0; i < src.length; i++) {
233
+ const c = src[i];
234
+ if (c === '\\') { i++; continue; } // escaped atom — the next char is a literal
235
+ if (c === '[') { // character class: quantifier chars inside are literals
236
+ i++;
237
+ if (src[i] === '^') i++;
238
+ // NO POSIX leading-`]`-is-a-literal rule: JS is not POSIX here. `[]` is the
239
+ // EMPTY class (matches nothing) and `[^]` the any-char idiom — both close at
240
+ // that first `]`. Skipping it ran the scan past the class's real end and
241
+ // swallowed every quantifier after it, so `x[^](a+)+$` read as safe while
242
+ // RegExp.test on it does not finish in 15s on a 31-char body. A false
243
+ // NEGATIVE is the dangerous direction (F49), so the scan follows JS.
244
+ //
245
+ // Honest limit on the monotonicity claim: this is a PARSE correction, not
246
+ // the "add rejections only" tightening F49's rule contemplates, and it is
247
+ // not strictly rejection-monotonic. Measured over 14,862 compilable
248
+ // generated patterns (2,369 previously rejected): 2 rejections disappear,
249
+ // both pathological `[]`-bearing shapes whose old `true` came from the very
250
+ // misparse being fixed, and both verified to finish RegExp.test in ~0ms.
251
+ // Every entry of the checked-in REDOS_BAD/GOOD/OVERREJECTED corpus is
252
+ // unchanged. So no real hazard was un-rejected — but the claim is
253
+ // "measured, no hazard lost", never "monotonic by construction".
254
+ while (i < src.length && src[i] !== ']') { if (src[i] === '\\') i++; i++; }
255
+ continue;
256
+ }
257
+ if (c === '(') { stack.push({ quant: false }); continue; }
258
+ if (c === ')') {
259
+ const g = stack.pop();
260
+ const qlen = unboundedQuantLen(src, i + 1);
261
+ if (g && g.quant && qlen) return true; // group repeats unboundedly AND its body did too
262
+ // The group is an unbounded-repeated atom of its parent when it is directly
263
+ // re-quantified (qlen) OR its own body already repeats unboundedly (g.quant):
264
+ // a redundant wrapper — ((a+))+ , (?:(a+))* , (((\d*)))+ — is the SAME
265
+ // exponential class as (a+)+, so an inner repeat must propagate THROUGH the
266
+ // enclosing group or the outer quantifier's close never sees it. Propagation
267
+ // is MONOTONIC (it only ever SETS quant=true → only ever adds rejections): it
268
+ // can widen over-rejection — the named FAIL-SAFE direction — but can never
269
+ // introduce a false negative, which is the dangerous one (F49).
270
+ if ((qlen || (g && g.quant)) && stack.length) stack[stack.length - 1].quant = true;
271
+ i += qlen;
272
+ continue;
273
+ }
274
+ const qlen = unboundedQuantLen(src, i); // a quantifier applying to the preceding atom, inside the current group
275
+ if (qlen) { if (stack.length) stack[stack.length - 1].quant = true; i += qlen - 1; }
276
+ }
277
+ return false;
278
+ }
@@ -0,0 +1,261 @@
1
+ // THE WORK BRANCH — runner mechanics, arbiter side. PRD Addendum v1.57 §3,
2
+ // hamr's ruling verbatim:
3
+ //
4
+ // "The agent creates a NEW BRANCH before it touches any code — a HARD RULE, no
5
+ // exceptions. Not a default, not a preference: no job edits the branch it was
6
+ // handed, and none edits `main`. Blast radius is bounded by where the work
7
+ // lands, not only by what the fence allows. Named with a meaningful slug —
8
+ // tool and problem, human-readable, of the form `bareloop-typecheck-fix`. A
9
+ // human scanning `git branch` must be able to tell what the run was for
10
+ // without opening the spec, which a run-id slug does not give them."
11
+ //
12
+ // WHAT IT IS AND IS NOT, stated here rather than left to be inferred (v1.57's own
13
+ // known-limitations discipline). bareloop never commits: the run leaves its work
14
+ // in the tree. So the guarantee this module actually delivers is precise —
15
+ //
16
+ // * the run's tree is standing on a branch NOBODY handed it, from the first
17
+ // moment a write-capable worker can exist to the end of the run, and
18
+ // * the handed ref (`main`, or whatever was checked out) is never moved,
19
+ // never written through, and never the thing a later commit attaches to.
20
+ //
21
+ // It is NOT containment: v1.41's local-trust model stands untouched, and a close
22
+ // or worker child can still reach anything the operator's OS user can reach. The
23
+ // branch bounds where the work LANDS. That is the whole claim.
24
+ //
25
+ // THE NAME IS OURS, NEVER THE MODEL'S. A branch name is arbiter bookkeeping: it
26
+ // says which history a run's blast radius belongs to. A model-authored one would
27
+ // be the agent authoring the arbiter's own books, so the name is DERIVED,
28
+ // mechanically and deterministically, from the SIGNED spec's own `job` field
29
+ // (see `workBranchName`).
30
+ //
31
+ // WHY THIS FILE OWNS ITS OWN git SEAM. `src/kinds.js` has one too, and its
32
+ // docstring says what it is: "git, read-only, in a repository" — the CLOSE's
33
+ // measuring instrument, whose faults are close faults. This one MUTATES the
34
+ // repository (it creates and checks out a ref) and its faults are runner
35
+ // instrument stops before a single token is bought. Two verbs, two failure
36
+ // vocabularies, two owners; the read-only one is not exported and could not be
37
+ // shared without widening it into a write seam.
38
+
39
+ import { execFile } from 'node:child_process';
40
+ import { promisify } from 'node:util';
41
+
42
+ const exec = promisify(execFile);
43
+
44
+ /** Every work branch carries it. It is what makes `main`, `master` and every
45
+ * branch a human could plausibly have handed the run INEXPRESSIBLE as a work
46
+ * branch name — a structural guarantee rather than a denylist somebody has to
47
+ * keep current. */
48
+ export const WORK_BRANCH_PREFIX = 'bareloop-';
49
+
50
+ /** The full legal shape of a work branch. Deliberately narrower than git's own
51
+ * `check-ref-format`: the kebab alphabet the job validator already holds every
52
+ * spec slug to (`src/job.js` SLUG_RE), plus the prefix. Everything git forbids —
53
+ * `..`, `@{`, a trailing `.lock`, a leading `-`, a `/` component, control
54
+ * characters — is unreachable inside it, so ref-safety is a property of the
55
+ * alphabet and never a sanitiser somebody has to remember to call. */
56
+ export const WORK_BRANCH_RE = /^bareloop-[a-z0-9][a-z0-9-]*$/;
57
+
58
+ /** The slug's ceiling, before any collision suffix. A ref component has a
59
+ * filesystem-length limit and a human scanning `git branch` has a much smaller
60
+ * one; 60 characters is longer than every job slug in `jobs/` and short enough
61
+ * to read in a column. */
62
+ export const MAX_SLUG_LEN = 60;
63
+
64
+ /** How far the collision walk goes before it STOPS and says so. A run that
65
+ * cannot find a free name in this many attempts is looking at a patient nobody
66
+ * has swept in a very long time, and silently walking to `-500` would be the
67
+ * runner papering over an operator problem. */
68
+ export const MAX_BRANCH_COLLISIONS = 50;
69
+
70
+ /** git, in a repository, WRITE-CLASS. A non-zero exit is a value, never a throw:
71
+ * every caller here decides for itself whether git refusing is a stop.
72
+ * @param {string} cwd @param {string[]} args
73
+ * @returns {Promise<{ok: boolean, out: string, err: string}>}
74
+ */
75
+ async function git(cwd, args) {
76
+ try {
77
+ const { stdout, stderr } = await exec('git', args, {
78
+ cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 1024 * 1024,
79
+ });
80
+ return { ok: true, out: String(stdout ?? ''), err: String(stderr ?? '') };
81
+ } catch (e) {
82
+ const err = /** @type {any} */ (e);
83
+ // A spawn failure (no git on PATH, cwd does not exist) has no streams at all;
84
+ // its `message` is the only thing there is to report, and reporting nothing
85
+ // would turn "git is not installed" into a mysterious silent refusal.
86
+ const detail = String(err?.stderr ?? '').trim() || String(err?.message ?? err).trim();
87
+ return { ok: false, out: String(err?.stdout ?? ''), err: detail };
88
+ }
89
+ }
90
+
91
+ /**
92
+ * The spec's own words, reduced to the kebab alphabet. Lossy on purpose: the
93
+ * output alphabet IS the ref-safety guarantee, so anything outside it collapses
94
+ * to a separator rather than being escaped into something clever.
95
+ * @param {unknown} s
96
+ * @returns {string} `''` when nothing usable survives
97
+ */
98
+ function slugify(s) {
99
+ return String(s ?? '')
100
+ .toLowerCase()
101
+ .replace(/[^a-z0-9]+/g, '-')
102
+ .replace(/^-+/, '')
103
+ .slice(0, MAX_SLUG_LEN)
104
+ .replace(/-+$/, '');
105
+ }
106
+
107
+ /**
108
+ * The branch name a job's run works on, DERIVED from the signed spec.
109
+ *
110
+ * **The source is `spec.job`, and the choice is load-bearing.** It is the one
111
+ * human-authored, REQUIRED, kebab-case name the spec carries; it is inside the
112
+ * spec hash, so the branch name cannot drift from what the operator signed; and
113
+ * it is stable across a resume and across a cold re-run of the same spec, which
114
+ * is precisely why the collision walk below has to exist. In practice it already
115
+ * reads as tool-and-problem (`bareagent-u-types`, `litectx-u-types`), which is
116
+ * the shape hamr's ruling asks for.
117
+ *
118
+ * **The goal prose was REJECTED as a source**, and not for tidiness: a goal is
119
+ * long, is edited freely between runs without changing what the job IS, and
120
+ * opens on boilerplate ("An existing pytest test suite for the SOAR
121
+ * orchestrator…"). Deriving from it would make the branch name unstable under
122
+ * edits that change nothing, and unreadable when it was stable. A run id was
123
+ * rejected by the ruling itself.
124
+ *
125
+ * Deterministic, pure, and total: any input yields a name matching
126
+ * `WORK_BRANCH_RE`, so a caller never has to check.
127
+ * @param {any} spec a job-v1 spec (validated or raw)
128
+ * @returns {string}
129
+ */
130
+ export function workBranchName(spec) {
131
+ const slug = slugify(spec?.job);
132
+ // `job` is REQUIRED and kebab-validated by `validateJob`, which runs before the
133
+ // runner ever reaches here — so the fallback is unreachable through `runJob`.
134
+ // It exists because this function is exported and total: a name is the wrong
135
+ // thing to throw about, and `bareloop-job` is honest ("a bareloop job") rather
136
+ // than invented.
137
+ return `${WORK_BRANCH_PREFIX}${slug || 'job'}`;
138
+ }
139
+
140
+ /** does a local branch of this name exist? @param {string} cwd @param {string} name */
141
+ async function branchExists(cwd, name) {
142
+ const r = await git(cwd, ['show-ref', '--verify', '--quiet', `refs/heads/${name}`]);
143
+ return r.ok;
144
+ }
145
+
146
+ /**
147
+ * Put the run on its OWN branch, before it can touch any code.
148
+ *
149
+ * The three shapes, and each one's honest answer:
150
+ *
151
+ * - **Cold.** `name` is free → create it at HEAD and switch. The handed branch
152
+ * keeps its ref and its history; a new branch at HEAD moves no commit, so this
153
+ * costs the patient nothing and changes nothing it can measure (the declared
154
+ * close's seed is HEAD either way).
155
+ * - **Cold, and the name is taken.** Suffix `-2`, `-3`… until one is free.
156
+ * Deterministic given the repository, and it NEVER reuses another run's
157
+ * branch — a second run of the same spec is a second blast radius.
158
+ * - **Resume.** `resume` names the branch the killed leg recorded on its own
159
+ * spine. It is checked out, never re-created and never suffixed: the work the
160
+ * operator already paid for is sitting on it, and minting `-2` beside it would
161
+ * strand exactly the progress a resume exists to keep. A `resume` branch that
162
+ * is GONE is a STOP, never a silent fresh start — the run would be grading a
163
+ * tree whose history it does not own.
164
+ *
165
+ * **Every failure is a stop with a name.** Not a git repository, no git on PATH,
166
+ * a checkout git refuses, a crowded namespace, a resume branch a human deleted:
167
+ * each comes back as `stop` and `branch: null`. There is no fallback to "just
168
+ * work on whatever was handed to us" anywhere in this function, because that
169
+ * fallback IS the thing the hard rule forbids.
170
+ *
171
+ * A DETACHED head is not a failure. Creating a branch from it is exactly the
172
+ * bounding the rule asks for (and it attaches a head that was floating), so it
173
+ * succeeds with `from: null` — there was no branch to be handed, and the record
174
+ * says so rather than inventing one.
175
+ *
176
+ * @param {string} workdir the patient (the fence's root)
177
+ * @param {object} o
178
+ * @param {string} o.name the derived name (`workBranchName`); refused unless it
179
+ * matches `WORK_BRANCH_RE`, which is what makes `main` unreachable even from a
180
+ * hand-passed argument
181
+ * @param {string|null} [o.resume] RESUME — the branch this run already owns
182
+ * @returns {Promise<{stop: string, branch: null}
183
+ * | {stop: null, branch: string, created: boolean, resumed: boolean, from: string|null, base: string|null,
184
+ * repo: string, collided: number}>}
185
+ */
186
+ export async function prepareWorkBranch(workdir, { name, resume = null }) {
187
+ const wanted = String(name ?? '');
188
+ if (!WORK_BRANCH_RE.test(wanted)) {
189
+ return { stop: `the work branch name ${JSON.stringify(wanted)} is not a bareloop work branch (${WORK_BRANCH_RE.source}) — a run never works on a branch it was handed`, branch: null };
190
+ }
191
+
192
+ // Is there a repository at all? Asked FIRST and by its own question, so "this
193
+ // patient is not a git checkout" reads as itself instead of arriving later
194
+ // disguised as a checkout failure.
195
+ //
196
+ // `--show-toplevel` rather than `--git-dir`, and the answer is REPORTED: git
197
+ // resolves upwards, so a workdir that is a SUBDIRECTORY of a repository gets a
198
+ // branch in that enclosing repository — a blast radius wider than the operator
199
+ // handed over. That is the pre-existing semantics of every other git read in
200
+ // the flow (`seedAtHead`, `changedSet`), so it is not changed here; what it must
201
+ // not be is invisible, and the record names the repository that got the branch.
202
+ const inside = await git(workdir, ['rev-parse', '--show-toplevel']);
203
+ if (!inside.ok) {
204
+ return { stop: `the work branch cannot be created in ${workdir}: ${inside.err || 'not a git repository'}`, branch: null };
205
+ }
206
+ const repo = inside.out.trim().split('\n')[0].trim() || workdir;
207
+
208
+ // WHERE the run was handed. A symbolic HEAD names the handed branch; a detached
209
+ // one names nothing, and `null` is the honest reading of that (never the string
210
+ // "HEAD", which a reader cannot tell from a branch called HEAD).
211
+ const sym = await git(workdir, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
212
+ const from = sym.ok ? sym.out.trim() || null : null;
213
+ const rev = await git(workdir, ['rev-parse', 'HEAD']);
214
+ const base = rev.ok ? (rev.out.trim().split('\n')[0].trim() || null) : null;
215
+
216
+ if (resume !== null && resume !== undefined) {
217
+ const own = String(resume);
218
+ if (!WORK_BRANCH_RE.test(own)) {
219
+ return { stop: `the recorded work branch ${JSON.stringify(own)} is not a bareloop work branch (${WORK_BRANCH_RE.source}) — this resume would continue on a branch the run does not own`, branch: null };
220
+ }
221
+ if (!await branchExists(workdir, own)) {
222
+ return { stop: `the work branch ${JSON.stringify(own)} this run was killed on no longer exists in ${workdir} — the resume stops rather than starting a new branch beside work it cannot see`, branch: null };
223
+ }
224
+ // Already standing on it (the ordinary case — a kill leaves the tree where it
225
+ // was) is a NO-OP, deliberately: `git checkout` onto the current branch is
226
+ // harmless today, and not running it at all is what keeps a dirty in-flight
227
+ // tree out of git's hands entirely.
228
+ if (from !== own) {
229
+ const co = await git(workdir, ['checkout', own]);
230
+ if (!co.ok) return { stop: `could not return to the work branch ${JSON.stringify(own)} in ${workdir}: ${co.err}`, branch: null };
231
+ }
232
+ return { stop: null, branch: own, created: false, resumed: true, from, base, repo, collided: 0 };
233
+ }
234
+
235
+ // The collision walk. `-2` is the first suffix rather than `-1`, so the branch a
236
+ // human reads without a number is the first run and the numbers count runs the
237
+ // way a person does.
238
+ //
239
+ // CHECK-THEN-ACT, named rather than fixed: another process can create the
240
+ // candidate between the `branchExists` read and the `checkout -b` below. It is
241
+ // documented instead of closed because git already fails CLOSED on it — `git
242
+ // checkout -b` REFUSES an existing branch, so the race ends in the named stop
243
+ // right beneath this loop, never in two runs sharing one blast radius. Closing
244
+ // it properly means atomic ref creation, which is a wider change than the
245
+ // failure it would prevent.
246
+ let collided = 0;
247
+ let candidate = wanted;
248
+ while (await branchExists(workdir, candidate)) {
249
+ collided += 1;
250
+ if (collided >= MAX_BRANCH_COLLISIONS) {
251
+ return { stop: `every work-branch name from ${JSON.stringify(wanted)} through ${JSON.stringify(`${wanted}-${MAX_BRANCH_COLLISIONS}`)} is already taken in ${workdir} — sweep the patient's old work branches rather than letting the runner walk further`, branch: null };
252
+ }
253
+ candidate = `${wanted}-${collided + 1}`;
254
+ }
255
+
256
+ const co = await git(workdir, ['checkout', '-b', candidate]);
257
+ if (!co.ok) {
258
+ return { stop: `could not create the work branch ${JSON.stringify(candidate)} in ${workdir}: ${co.err}`, branch: null };
259
+ }
260
+ return { stop: null, branch: candidate, created: true, resumed: false, from, base, repo, collided };
261
+ }