bareloop 0.4.0 → 0.5.1
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 +135 -0
- package/README.md +10 -10
- package/bareloop.context.md +147 -24
- package/package.json +2 -2
- package/src/exits.js +163 -0
- package/src/index.js +7 -1
- package/src/interpret.js +25 -22
- package/src/job.js +231 -88
- package/src/plan.js +327 -0
- package/src/planrun.js +788 -0
- package/src/ralph.js +18 -6
- package/src/run.js +41 -4
- package/types/exits.d.ts +51 -0
- package/types/index.d.ts +4 -1
- package/types/interpret.d.ts +98 -0
- package/types/job.d.ts +12 -0
- package/types/plan.d.ts +77 -0
- package/types/planrun.d.ts +59 -0
- package/types/ralph.d.ts +20 -3
- package/types/run.d.ts +12 -2
package/src/plan.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// plan-v1 validator — the AGENT-authored half of the two-doc story (Layer 2,
|
|
2
|
+
// design record 2026-07-21; PRD v1.12). The plan is the ONLY document the
|
|
3
|
+
// emergent middle authors: an ordered list of bounded steps, each with a tool
|
|
4
|
+
// grant, a rounds bound, a deliverable target, and a form-check exit. This
|
|
5
|
+
// validator gates it before tokens burn, against the SIGNED job spec:
|
|
6
|
+
//
|
|
7
|
+
// verbs ⊆ the spec's tool ceiling → `verb-escape` (the ceiling
|
|
8
|
+
// exists and the plan overreached — distinct from the operator-side
|
|
9
|
+
// `request-red`, which is admission demand against the product menu)
|
|
10
|
+
// bounds ≤ the shell caps → `bounds`
|
|
11
|
+
// scopes/targets inside the signed fence → `scope-escape` / `invalid-value`
|
|
12
|
+
// exits from the closed menu only → `exit-illegal` (arbiter
|
|
13
|
+
// inexpressibility: an exit is declarative data the shell evaluates with
|
|
14
|
+
// its own fixed code — `run` cannot be laundered through it)
|
|
15
|
+
// check references resolve against the SIGNED checks menu → `check-unknown`
|
|
16
|
+
// (decision 1: a check the spec doesn't sign does not exist)
|
|
17
|
+
//
|
|
18
|
+
// Like its siblings (validate.js, job.js) it never throws on JSON text or
|
|
19
|
+
// plain parsed data; every failure is a named {code, path, detail} red.
|
|
20
|
+
|
|
21
|
+
import { TOOL_MENU, LOCKED_TOOLS } from './job.js';
|
|
22
|
+
import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals } from './validate.js';
|
|
23
|
+
|
|
24
|
+
/** the closed exit menu (PRD v1.12 §3 + decision 1's `check-passes`): the
|
|
25
|
+
* shell evaluates every form with its own fixed code, never a command — the
|
|
26
|
+
* same both-directions inexpressibility guard as the two-validator split */
|
|
27
|
+
export const EXIT_TYPES = Object.freeze(['artifact-written', 'tree-changed', 'json-valid', 'check-passes']);
|
|
28
|
+
/** decision 8: AND-only conjunction, bounded — no OR (a weak arm would
|
|
29
|
+
* launder the exit), no NOT */
|
|
30
|
+
export const MAX_EXITS_PER_STEP = 2;
|
|
31
|
+
/** plan size bound — per-step rounds bound the spend, but an unbounded step
|
|
32
|
+
* COUNT is still an unbounded claim on the wallet; same 8 as loop.maxIterations */
|
|
33
|
+
export const MAX_PLAN_STEPS = 8;
|
|
34
|
+
/** the write-class verbs (BA-13: `edit` is judged by the same fence as
|
|
35
|
+
* `write`) — a step granting one is a WRITE step for the F17 pairing rule
|
|
36
|
+
* and the v1.18 target requirement */
|
|
37
|
+
export const WRITE_VERBS = Object.freeze(['write', 'edit']);
|
|
38
|
+
const PLAN_FIELDS = ['schema', 'steps'];
|
|
39
|
+
// decision 7: NO dependsOn — v1 is strictly sequential, array order IS the
|
|
40
|
+
// order; a field nothing consumes is a live-looking knob with zero effect
|
|
41
|
+
// (the F16 inert-op class). The arbiter's own vocabulary (close, budget,
|
|
42
|
+
// fence) is absent by construction — unknown-field at every depth.
|
|
43
|
+
const STEP_FIELDS = ['id', 'action', 'tools', 'rounds', 'target', 'exit'];
|
|
44
|
+
const EXIT_FIELDS = {
|
|
45
|
+
'artifact-written': ['type', 'path', 'pattern'],
|
|
46
|
+
'tree-changed': ['type', 'scope'],
|
|
47
|
+
'json-valid': ['type', 'path'],
|
|
48
|
+
'check-passes': ['type', 'name'],
|
|
49
|
+
};
|
|
50
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
51
|
+
|
|
52
|
+
/** Length of an UNBOUNDED quantifier token at `src[i]` (`*`, `+`, or `{n,}`,
|
|
53
|
+
* plus an optional trailing lazy `?`), or 0. Bounded forms (`?`, `{n}`,
|
|
54
|
+
* `{n,m}`) return 0: they cannot drive exponential backtracking (a bounded
|
|
55
|
+
* outer repeat is polynomial at worst, and the body is self-authored — F49).
|
|
56
|
+
* @param {string} src @param {number} i */
|
|
57
|
+
function unboundedQuantLen(src, i) {
|
|
58
|
+
let len = src[i] === '*' || src[i] === '+' ? 1 : 0;
|
|
59
|
+
if (!len && src[i] === '{') { const m = /^\{\d+,\}/.exec(src.slice(i)); if (m) len = m[0].length; }
|
|
60
|
+
if (len && src[i + len] === '?') len++; // the lazy modifier is part of the same token
|
|
61
|
+
return len;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* F49 — a heuristic reject for the catastrophic-backtracking footgun: an
|
|
66
|
+
* unbounded quantifier applied to a group whose body itself repeats unboundedly
|
|
67
|
+
* (`(a+)+`, `(\d*)*`, `([a-z]+){1,}`) — including through a redundant wrapping
|
|
68
|
+
* group (`((a+))+`, `(?:(a+))*`), which is the SAME exponential class as `(a+)+`
|
|
69
|
+
* and is caught by propagating the inner repeat up through the wrapper. Such a
|
|
70
|
+
* pattern can hang `RegExp.test` for
|
|
71
|
+
* seconds on a short crafted body, and `evalExits` runs the agent-authored
|
|
72
|
+
* `artifact-written` pattern with NO timeout. This is self-DoS only — the agent
|
|
73
|
+
* authors both the pattern and (via the worker) the artifact, so a hang burns
|
|
74
|
+
* only its own run's wall-clock; there is NO arbiter compromise (it cannot
|
|
75
|
+
* escape the fence, forge a green, or leak a secret). So the reject targets the
|
|
76
|
+
* dominant exponential class and fails it as a mechanical gap at the validation
|
|
77
|
+
* gate, before any tokens burn. A full ReDoS analyzer needs a real regex engine
|
|
78
|
+
* (an external native dep we do not take for a LOW issue); exotic
|
|
79
|
+
* overlapping-alternation blowup is out of scope by decision.
|
|
80
|
+
*
|
|
81
|
+
* SCOPE IS ASYMMETRIC, both directions named on purpose:
|
|
82
|
+
* - false NEGATIVE: overlapping-alternation blowup (`(a|ab)+`-class) is not
|
|
83
|
+
* detected — out of scope (self-DoS only, no arbiter compromise).
|
|
84
|
+
* - false POSITIVE: a group whose repetitions are disambiguated by a literal
|
|
85
|
+
* anchor/delimiter (`(?:^- .+$\n?)+`, `(?:CHANGELOG:.+\n)+`) is FLAGGED even
|
|
86
|
+
* though a real engine runs it linearly — the scan sees the nested-quantifier
|
|
87
|
+
* SHAPE, not the disambiguation. Rejecting it is the FAIL-SAFE direction (it
|
|
88
|
+
* never admits an unsafe pattern), and the cost is bounded: the plan drafter
|
|
89
|
+
* gets a mechanical gap and rewrites (drop the outer `+`, or match once).
|
|
90
|
+
* Detecting "safe because anchored" needs the same real engine we declined,
|
|
91
|
+
* and guessing it wrong would ADMIT an exponential pattern — so the shape
|
|
92
|
+
* reject stands, and the over-rejection is a named, accepted limitation.
|
|
93
|
+
*
|
|
94
|
+
* The input is guaranteed to compile as a RegExp (checked first by the caller),
|
|
95
|
+
* so this scan assumes valid, balanced JS regex syntax.
|
|
96
|
+
* @param {string} src a compiled regex source string
|
|
97
|
+
* @returns {boolean} true iff a nested unbounded quantifier is present
|
|
98
|
+
*/
|
|
99
|
+
export function hasNestedQuantifier(src) {
|
|
100
|
+
/** @type {{ quant: boolean }[]} */
|
|
101
|
+
const stack = [];
|
|
102
|
+
for (let i = 0; i < src.length; i++) {
|
|
103
|
+
const c = src[i];
|
|
104
|
+
if (c === '\\') { i++; continue; } // escaped atom — the next char is a literal
|
|
105
|
+
if (c === '[') { // character class: quantifier chars inside are literals
|
|
106
|
+
i++;
|
|
107
|
+
if (src[i] === '^') i++;
|
|
108
|
+
if (src[i] === ']') i++; // a leading ] is a literal member, not the close
|
|
109
|
+
while (i < src.length && src[i] !== ']') { if (src[i] === '\\') i++; i++; }
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (c === '(') { stack.push({ quant: false }); continue; }
|
|
113
|
+
if (c === ')') {
|
|
114
|
+
const g = stack.pop();
|
|
115
|
+
const qlen = unboundedQuantLen(src, i + 1);
|
|
116
|
+
if (g && g.quant && qlen) return true; // group repeats unboundedly AND its body did too
|
|
117
|
+
// The group is an unbounded-repeated atom of its parent when it is directly
|
|
118
|
+
// re-quantified (qlen) OR its own body already repeats unboundedly (g.quant):
|
|
119
|
+
// a redundant wrapper — ((a+))+ , (?:(a+))* , (((\d*)))+ — is the SAME
|
|
120
|
+
// exponential class as (a+)+, so an inner repeat must propagate THROUGH the
|
|
121
|
+
// enclosing group or the outer quantifier's close never sees it. Propagation
|
|
122
|
+
// is MONOTONIC (it only ever SETS quant=true → only ever adds rejections): it
|
|
123
|
+
// can widen over-rejection — the named FAIL-SAFE direction — but can never
|
|
124
|
+
// introduce a false negative, which is the dangerous one (F49).
|
|
125
|
+
if ((qlen || (g && g.quant)) && stack.length) stack[stack.length - 1].quant = true;
|
|
126
|
+
i += qlen;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const qlen = unboundedQuantLen(src, i); // a quantifier applying to the preceding atom, inside the current group
|
|
130
|
+
if (qlen) { if (stack.length) stack[stack.length - 1].quant = true; i += qlen - 1; }
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @typedef {{code: string, path: string, detail?: string, verb?: string}} Red */
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Validate an agent-authored plan (`schema: "plan-v1"`) against the SIGNED
|
|
139
|
+
* job spec. Never throws on JSON text or plain parsed data; every failure is
|
|
140
|
+
* a named red. Returns the parsed plan on ok (single parse), null on any red.
|
|
141
|
+
* @param {object|string} input parsed plan, or raw JSON text (parse failures are a red)
|
|
142
|
+
* @param {{ job?: any, maxStepRounds?: number }} [opts] `job`: the
|
|
143
|
+
* validateJob-GREEN four-field spec (the ceiling, the fence, and the checks
|
|
144
|
+
* menu all come from it — a missing or non-plan-shape job fails CLOSED);
|
|
145
|
+
* `maxStepRounds`: the shell's per-step rounds ceiling (interpret's
|
|
146
|
+
* tool-mode TURNS_PER_ATTEMPT) — a step may tighten it, never exceed it.
|
|
147
|
+
* @returns {{ ok: boolean, reds: Red[], plan: object|null }}
|
|
148
|
+
*/
|
|
149
|
+
export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
|
|
150
|
+
/** @type {Red[]} */
|
|
151
|
+
const reds = [];
|
|
152
|
+
/** @type {(code: string, path: string, detail?: string) => void} */
|
|
153
|
+
const red = (code, path, detail) => { reds.push(detail ? { code, path, detail } : { code, path }); };
|
|
154
|
+
|
|
155
|
+
// The signed side, fail-CLOSED (the validate.js fence-invalid pattern): the
|
|
156
|
+
// ceiling/fence/menu are meaningless without a plan-shape spec, and an open
|
|
157
|
+
// gate on a malformed one would validate a plan against nothing.
|
|
158
|
+
if (!isObj(job) || !isNonEmptyString(/** @type {any} */ (job).goal) || !Array.isArray(/** @type {any} */ (job).writeScope)) {
|
|
159
|
+
return { ok: false, reds: [{ code: 'job-invalid', path: 'job', detail: 'a plan validates only against a validateJob-green plan shape spec (goal/verdictType/close/checks[]) — validate the job first' }], plan: null };
|
|
160
|
+
}
|
|
161
|
+
const spec = /** @type {Record<string, any>} */ (job);
|
|
162
|
+
/** the signed tool ceiling — validateJob permits omission, meaning the full menu */
|
|
163
|
+
const ceiling = Array.isArray(spec.tools) ? spec.tools : [...TOOL_MENU];
|
|
164
|
+
const fence = spec.writeScope.map(globToPrefix);
|
|
165
|
+
const insideFence = (/** @type {string} */ p) => fence.some((f) => p === f || p.startsWith(f + '/'));
|
|
166
|
+
const checkNames = Array.isArray(spec.checks) ? spec.checks.map((/** @type {any} */ c) => c?.name).filter(isNonEmptyString) : [];
|
|
167
|
+
|
|
168
|
+
let p = input;
|
|
169
|
+
if (typeof p === 'string') {
|
|
170
|
+
try { p = JSON.parse(p); } catch (e) {
|
|
171
|
+
return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: String(/** @type {Error} */ (e).message) }], plan: null };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (!isObj(p)) return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: 'plan must be a JSON object' }], plan: null };
|
|
175
|
+
const plan = /** @type {Record<string, any>} */ (p);
|
|
176
|
+
|
|
177
|
+
// 1. shape — unknown top-level fields red (a smuggled close/checks/budget
|
|
178
|
+
// lands here: the arbiter is inexpressible in the plan vocabulary)
|
|
179
|
+
for (const key of Object.keys(plan)) {
|
|
180
|
+
if (!PLAN_FIELDS.includes(key)) red('unknown-field', key, 'the plan vocabulary is steps only — the arbiter (close/budget/fence/checks) is signed, never planned');
|
|
181
|
+
}
|
|
182
|
+
if (plan.schema === undefined) red('missing-required', 'schema');
|
|
183
|
+
else if (plan.schema !== 'plan-v1') red('invalid-value', 'schema', `expected "plan-v1", got ${JSON.stringify(plan.schema)}`);
|
|
184
|
+
|
|
185
|
+
// 2. steps — ordered array (decision 7: order IS the order), bounded count
|
|
186
|
+
if (!Array.isArray(plan.steps) || plan.steps.length === 0) {
|
|
187
|
+
red('missing-required', 'steps', 'non-empty ordered array — v1 executes strictly sequentially, array order is the order');
|
|
188
|
+
} else if (plan.steps.length > MAX_PLAN_STEPS) {
|
|
189
|
+
red('bounds', 'steps', `max ${MAX_PLAN_STEPS} steps — per-step rounds bound the spend, the step count bounds the claim on the wallet`);
|
|
190
|
+
} else {
|
|
191
|
+
const seen = new Set();
|
|
192
|
+
plan.steps.forEach((/** @type {any} */ s, /** @type {number} */ i) => {
|
|
193
|
+
const at = `steps.${i}`;
|
|
194
|
+
if (!isObj(s)) { red('invalid-value', at, 'step must be an object'); return; }
|
|
195
|
+
for (const key of Object.keys(s)) {
|
|
196
|
+
if (!STEP_FIELDS.includes(key)) red('unknown-field', `${at}.${key}`, key === 'dependsOn' ? 'no dependsOn in v1 — strictly sequential, array order is the order (an inert knob is a fake contrast lever)' : 'not a step field — the arbiter is inexpressible here');
|
|
197
|
+
}
|
|
198
|
+
if (!isNonEmptyString(s.id) || !SLUG_RE.test(s.id)) red('invalid-value', `${at}.id`, 'kebab-case slug');
|
|
199
|
+
else if (seen.has(s.id)) red('duplicate-id', `${at}.id`, s.id);
|
|
200
|
+
else seen.add(s.id);
|
|
201
|
+
|
|
202
|
+
if (!isNonEmptyString(s.action)) red('missing-required', `${at}.action`, 'the step\'s task text — a step without one is an unaimed worker');
|
|
203
|
+
|
|
204
|
+
// tools ⊆ the SIGNED ceiling: a verb beyond it is verb-escape with the
|
|
205
|
+
// verb as structured data (the ledger counts overreach per verb) — an
|
|
206
|
+
// unknown string stays a typo (invalid-value), never an escape
|
|
207
|
+
let writeStep = false;
|
|
208
|
+
if (s.tools === undefined) red('missing-required', `${at}.tools`, 'every step declares its grant — the narrowed menu is the step boundary');
|
|
209
|
+
else if (!(Array.isArray(s.tools) && s.tools.length > 0
|
|
210
|
+
&& s.tools.every((/** @type {unknown} */ t) => typeof t === 'string')
|
|
211
|
+
&& new Set(s.tools).size === s.tools.length)) {
|
|
212
|
+
red('invalid-value', `${at}.tools`, `non-empty unique subset of the spec ceiling [${ceiling.join(', ')}]`);
|
|
213
|
+
} else {
|
|
214
|
+
// three disjoint classes: a LOCKED verb (run — never in any ceiling) is
|
|
215
|
+
// an escape the ledger must never bury as a typo; a menu verb outside
|
|
216
|
+
// the SIGNED ceiling is an escape too (overreach, not admission demand);
|
|
217
|
+
// a string in neither list is a typo (invalid-value)
|
|
218
|
+
const unknown = s.tools.filter((/** @type {string} */ t) => !TOOL_MENU.includes(t) && !LOCKED_TOOLS.includes(t));
|
|
219
|
+
if (unknown.length) red('invalid-value', `${at}.tools`, `unknown tool(s) ${unknown.join(', ')} — menu: ${TOOL_MENU.join('|')}`);
|
|
220
|
+
for (const t of s.tools.filter((/** @type {string} */ t) => LOCKED_TOOLS.includes(t))) {
|
|
221
|
+
reds.push({ code: 'verb-escape', path: `${at}.tools`, verb: t, detail: `"${t}" is locked at every layer — a worker that can run commands can run its own close` });
|
|
222
|
+
}
|
|
223
|
+
for (const t of s.tools.filter((/** @type {string} */ t) => TOOL_MENU.includes(t) && !ceiling.includes(t))) {
|
|
224
|
+
reds.push({ code: 'verb-escape', path: `${at}.tools`, verb: t, detail: `"${t}" is outside the signed ceiling [${ceiling.join(', ')}] — the plan may narrow the grant, never widen it` });
|
|
225
|
+
}
|
|
226
|
+
writeStep = s.tools.some((/** @type {string} */ t) => WRITE_VERBS.includes(t));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// rounds ≤ the shell cap (cap-not-estimate; the step bound IS maxTurns)
|
|
230
|
+
if (!(Number.isInteger(s.rounds) && s.rounds >= 1 && s.rounds <= maxStepRounds)) {
|
|
231
|
+
red('bounds', `${at}.rounds`, `integer 1..${maxStepRounds} — the step bound is the Gate's maxTurns, it may tighten the shell cap, never exceed it`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// target (v1.18): the per-step deliverable — required on write steps,
|
|
235
|
+
// always inside the signed fence when present
|
|
236
|
+
if (s.target === undefined || s.target === '') {
|
|
237
|
+
if (writeStep) red('missing-required', `${at}.target`, 'a write-granted step declares its deliverable path (v1.18) — an untargeted write step is unattributable');
|
|
238
|
+
} else if (!isNonEmptyString(s.target) || !scopeContained(s.target)) {
|
|
239
|
+
red('invalid-value', `${at}.target`, 'a relative path inside the run dir — no absolute paths, no ".." segments');
|
|
240
|
+
} else if (!insideFence(globToPrefix(s.target))) {
|
|
241
|
+
red('scope-escape', `${at}.target`, `"${s.target}" is outside the signed fence [${spec.writeScope.join(', ')}]`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
validateExit(s, at, red, { checkNames, fence: spec.writeScope, insideFence, writeStep });
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 3. secrets sweep — the agent-authored document is the riskier entry point
|
|
249
|
+
sweepSecretLiterals(plan, red);
|
|
250
|
+
|
|
251
|
+
return { ok: reds.length === 0, reds, plan: reds.length === 0 ? plan : null };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* A step's exit: AND-only conjunction from the closed menu, max 2 (decision
|
|
256
|
+
* 8). Every form is declarative data the shell evaluates with its own fixed
|
|
257
|
+
* code — `exit-illegal` is the arbiter-inexpressibility red.
|
|
258
|
+
* @param {Record<string, any>} s the step
|
|
259
|
+
* @param {string} at step path prefix
|
|
260
|
+
* @param {(code: string, path: string, detail?: string) => void} red
|
|
261
|
+
* @param {{ checkNames: string[], fence: string[], insideFence: (p: string) => boolean, writeStep: boolean }} ctx
|
|
262
|
+
*/
|
|
263
|
+
function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep }) {
|
|
264
|
+
if (!Array.isArray(s.exit) || s.exit.length === 0) {
|
|
265
|
+
red('missing-required', `${at}.exit`, `non-empty array from the closed menu ${EXIT_TYPES.join('|')} — ALL listed exits must pass (AND-only); a step without an exit has no progress gate`);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (s.exit.length > MAX_EXITS_PER_STEP) {
|
|
269
|
+
red('exit-illegal', `${at}.exit`, `max ${MAX_EXITS_PER_STEP} exits — AND-only conjunction, bounded (decision 8)`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
let hasTreeChanged = false;
|
|
273
|
+
let hasCheck = false;
|
|
274
|
+
s.exit.forEach((/** @type {any} */ e, /** @type {number} */ i) => {
|
|
275
|
+
const eAt = `${at}.exit.${i}`;
|
|
276
|
+
if (!isObj(e) || !EXIT_TYPES.includes(e.type)) {
|
|
277
|
+
red('exit-illegal', eAt, `menu: ${EXIT_TYPES.join('|')} — an exit is declarative data the shell evaluates with its own fixed code, never a command`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
for (const key of Object.keys(e)) {
|
|
281
|
+
if (!EXIT_FIELDS[e.type].includes(key)) red('unknown-field', `${eAt}.${key}`, `not a ${e.type} field (commands and script bodies land here)`);
|
|
282
|
+
}
|
|
283
|
+
if (e.type === 'check-passes') {
|
|
284
|
+
hasCheck = true;
|
|
285
|
+
if (!isNonEmptyString(e.name)) red('invalid-value', `${eAt}.name`, 'the signed check\'s name');
|
|
286
|
+
else if (!checkNames.includes(e.name)) {
|
|
287
|
+
// decision 1: a check the spec doesn't sign DOES NOT EXIST — and the
|
|
288
|
+
// detail names the signed menu so the replan can aim, not guess
|
|
289
|
+
red('check-unknown', eAt, `"${e.name}" is not a signed check — the agent references checks, never authors them; signed menu: [${checkNames.join(', ') || 'none'}]`);
|
|
290
|
+
}
|
|
291
|
+
} else if (e.type === 'tree-changed') {
|
|
292
|
+
hasTreeChanged = true;
|
|
293
|
+
if (!isNonEmptyString(e.scope) || !scopeContained(e.scope) || globToPrefix(e.scope).includes('*')) {
|
|
294
|
+
red('invalid-value', `${eAt}.scope`, 'a contained scope (trailing /** or /* only, no ".." or absolute)');
|
|
295
|
+
} else if (!insideFence(globToPrefix(e.scope))) {
|
|
296
|
+
red('scope-escape', `${eAt}.scope`, `"${e.scope}" is outside the signed fence [${fence.join(', ')}]`);
|
|
297
|
+
}
|
|
298
|
+
} else { // artifact-written | json-valid — a named file path inside the fence
|
|
299
|
+
if (!isNonEmptyString(e.path) || !scopeContained(e.path)) {
|
|
300
|
+
red('invalid-value', `${eAt}.path`, 'a relative file path inside the run dir — no absolute paths, no ".." segments');
|
|
301
|
+
} else if (!insideFence(globToPrefix(e.path))) {
|
|
302
|
+
red('scope-escape', `${eAt}.path`, `"${e.path}" is outside the signed fence [${fence.join(', ')}]`);
|
|
303
|
+
}
|
|
304
|
+
if (e.type === 'artifact-written' && e.pattern !== undefined) {
|
|
305
|
+
if (!isNonEmptyString(e.pattern)) red('invalid-value', `${eAt}.pattern`, 'regex source string');
|
|
306
|
+
else {
|
|
307
|
+
let compiled = false;
|
|
308
|
+
try { new RegExp(e.pattern, 'm'); compiled = true; }
|
|
309
|
+
catch { red('invalid-value', `${eAt}.pattern`, 'must compile as a RegExp'); }
|
|
310
|
+
// F49: a compiled-but-catastrophic pattern (nested unbounded
|
|
311
|
+
// quantifier) can hang the untimed exit evaluator — reject it here as
|
|
312
|
+
// a mechanical gap so the replan rewrites it, before any tokens burn.
|
|
313
|
+
if (compiled && hasNestedQuantifier(e.pattern)) {
|
|
314
|
+
red('invalid-value', `${eAt}.pattern`, `nested unbounded quantifier (e.g. (a+)+ , (\\d*)* , (x+){1,}) — a catastrophic-backtracking footgun that can hang the exit evaluator (F49); rewrite without a repeated group inside a repeat`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
// The F17 pairing rule: the seed tree is green, so a lone check-passes on a
|
|
321
|
+
// write-granted step would pass on the UNTOUCHED repo — an unearned exit.
|
|
322
|
+
// The POC's changed-from-seed stage existed for exactly this; here it is a
|
|
323
|
+
// validation law, stated in the plan itself (never hardwired shell code).
|
|
324
|
+
if (writeStep && hasCheck && !hasTreeChanged) {
|
|
325
|
+
red('exit-illegal', `${at}.exit`, 'check-passes on a write-granted step requires the tree-changed conjunct — the seed tree is green, so a lone check would pass on the untouched repo (F17/F46 already-green trap)');
|
|
326
|
+
}
|
|
327
|
+
}
|