@bongos/core 1.19.703 → 1.19.705

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.
@@ -0,0 +1,564 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/exec-path-guard.js — server-side code must not EXECUTE a path it does
3
+ // not statically control (task 1003371, audit ref B50, 2026-08-29 security audit).
4
+ //
5
+ // THE BLIND SPOT THIS EXISTS TO CLOSE. Every other gate asks who may CALL something:
6
+ // route-rank-check.js checks who may reach a route, fitness Check 4 checks the gates
7
+ // exist, permission-path-check.js checks who may EDIT a file. Nothing asked what the
8
+ // server RUNS, or out of whose tree. All three CRITICALs in the 2026-08-29 audit lived
9
+ // in that one gap, and none of them were caught by anything.
10
+ //
11
+ // THE DEFECT SHAPE. modules/lifecycle/conflict-resolve.js clones a builder's branch to
12
+ // a temp dir and runs `execFile(process.execPath, [path.join(work, rel)])` — a script
13
+ // out of the CLONE, with the live server's DATABASE_URL and BUILDER_SECRET_KEY
14
+ // inherited. The path is assembled from `work`, a runtime value, so no amount of
15
+ // reading the call site tells you what binary actually runs.
16
+ //
17
+ // THE RULE. An executed path must be STATICALLY ROOTED AT THE CORE CHECKOUT: built only
18
+ // from string literals, __dirname/__filename, process.execPath, and module-level consts
19
+ // that are themselves so built. A path that reaches the call through a parameter, a
20
+ // let, or any other runtime value is flagged. That is a deliberately blunt rule — it
21
+ // cannot tell a safe runtime path from an unsafe one, so it insists the question never
22
+ // arises. Where a runtime path is genuinely correct, EXEMPTIONS below records why, and
23
+ // the exemption is the review.
24
+ //
25
+ // WHY STATIC AND NOT A RUNTIME GUARD. The dangerous call already ran with full
26
+ // privilege by the time a runtime check could see it, and the sites are few and rarely
27
+ // touched — this is exactly the shape a build-time scan handles well.
28
+ //
29
+ // THE RESIDUAL ASSUMPTION, stated so it can be re-checked rather than rediscovered.
30
+ // This is a LINE/BRACKET scan, not a parse (the repo ships no JS parser — see
31
+ // route-rank-check.js, which reads route files the same way for the same reason). It
32
+ // resolves child_process bindings per file, so a regex's `.exec()` is never mistaken
33
+ // for the child_process one, and it balances brackets to read a multi-line call. It
34
+ // does NOT follow a value across files or through a function return. A path laundered
35
+ // through a helper in another module would read as a bare identifier here and be
36
+ // FLAGGED, not missed — the scan fails toward noise, never toward silence.
37
+
38
+ 'use strict';
39
+
40
+ const fs = require('node:fs');
41
+ const path = require('node:path');
42
+ const { execFileSync } = require('node:child_process');
43
+
44
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
45
+ const NAME = 'no server-side exec/spawn runs a path that is not statically rooted at the core checkout (task 1003371)';
46
+
47
+ // The child_process members that start a process. `fork` included: it runs a module
48
+ // path the same way execFile does.
49
+ const SPAWNERS = new Set([
50
+ 'exec', 'execSync', 'execFile', 'execFileSync', 'spawn', 'spawnSync', 'fork',
51
+ ]);
52
+
53
+ // Identifiers an executed path may be built from without the scan knowing more. These
54
+ // are fixed at module load and cannot be steered by a request or a branch.
55
+ const STATIC_ROOTS = new Set([
56
+ '__dirname', '__filename', 'process', 'path', 'require',
57
+ 'String', 'Number', 'JSON', 'Boolean', 'Array', 'Object',
58
+ ]);
59
+
60
+ // Reviewed exemptions. Each is a call the scan flags where a runtime path is the
61
+ // correct behaviour, with the reason it is safe. An entry is a REVIEW, not a mute:
62
+ // adding one is the moment to argue the case, which is why `why` is asserted non-empty
63
+ // by tests/exec_path_guard.mjs. Keyed `<repo-relative file>:<line>` is deliberately
64
+ // brittle — a line shift re-opens the review rather than carrying a stale approval.
65
+ const EXEMPTIONS = [
66
+ {
67
+ at: 'modules/lifecycle/conflict-resolve.js:382',
68
+ why: 'THE site this check was written for (audit ref B1), and the one place a runtime path is '
69
+ + 'REQUIRED rather than tolerated. Each generator derives its repo root from its own '
70
+ + '__dirname, so running the SERVER\'s copy would regenerate the deploy tree instead of the '
71
+ + 'throwaway clone — rooting this statically would break the feature and corrupt the live '
72
+ + 'checkout. It is safe because of a control OUTSIDE this call: task 1003367 made '
73
+ + 'resolveBranchConflicts refuse to auto-resolve any branch touching '
74
+ + 'computeExecutedClosure() (steps 5b/4b), so this line is unreachable for a branch that '
75
+ + 'edited executed code, and task 1003366 put all eight execution roots at the Metic floor. '
76
+ + 'If that refusal is ever removed this exemption dies with it.',
77
+ },
78
+ {
79
+ at: 'modules/grading/generated-provenance.js:156',
80
+ why: '`dir` defaults to path.join(__dirname, ..., \'scripts\', \'gds\') — the SERVER\'s own tree — '
81
+ + 'and `script` comes from the module-level GENERATORS table, which holds string literals '
82
+ + 'only. The `scriptDir` seam exists so the unit test never spawns a process and is never '
83
+ + 'set in production. The untrusted input here is the CWD, not the script: this runs a '
84
+ + 'trusted generator against a suspect tree, which is the inverse of the B50 shape.',
85
+ },
86
+ {
87
+ at: 'modules/grading/generated-provenance.js:223',
88
+ why: 'The async twin of the call at :156 — same `dir` default, same literal GENERATORS table, '
89
+ + 'same test-only `scriptDir` seam. Listed separately rather than folded in because an '
90
+ + 'exemption is keyed to a line: if one of the two calls changes shape, only that one '
91
+ + 're-opens for review.',
92
+ },
93
+ {
94
+ at: 'src/module-loader/loader.js:190',
95
+ why: 'The plugin loader, and the second site the audit named (B50 "motivating sites"). It '
96
+ + 'require()s a discovered module\'s route file, so the specifier is a runtime path BY '
97
+ + 'DESIGN — a plugin system whose module paths are compile-time constants is not a plugin '
98
+ + 'system. What bounds it is not this call but WHICH ROOTS are discovered: DEFAULT_ROOTS is '
99
+ + 'the core package\'s modules/ plus the instance\'s own, both operator-controlled, never a '
100
+ + 'builder branch. The residual risk — an instance accepting an untrusted module — is a '
101
+ + 'different boundary with its own control: ADR 0107 designates an accept-time module '
102
+ + 'security review, tracked as task 1003400. Exempt here so that work is not silently '
103
+ + 'absorbed into this check.',
104
+ },
105
+ {
106
+ at: 'src/module-loader/loader.js:293',
107
+ why: 'The poller twin of the route require at :190 — same DEFAULT_ROOTS bound, same plugin-by-'
108
+ + 'design rationale, same ADR 0107 / task 1003400 follow-on. Listed on its own line so a '
109
+ + 'change to either call re-opens only that one.',
110
+ },
111
+ {
112
+ at: 'modules/ui-design/scripts/validate-design.js:222',
113
+ why: 'A developer validation script, not a server path: it require()s each adapter\'s index.js '
114
+ + 'to check the adapter contract, and loading the thing under test is the whole point. '
115
+ + 'indexPath is path.join(ADAPTERS_DIR, adapterName, \'index.js\') where ADAPTERS_DIR is '
116
+ + '__dirname-rooted and adapterName comes from reading that same directory — so it cannot '
117
+ + 'escape the core checkout. Flagged only because the scan does not model a directory '
118
+ + 'listing as a constrained source.',
119
+ },
120
+ {
121
+ at: 'src/bongos/llm-cache.js:116',
122
+ why: 'Not an executed path at all. `cmd` is a `git rev-parse HEAD:<p>` command string built in '
123
+ + 'scopeShaFor from a code-defined paths list; the binary is git, resolved from PATH, and '
124
+ + 'nothing is run out of a builder tree. Flagged because the check reads arg 0 of every '
125
+ + 'spawner and cannot tell a command string from a path — the honest cost of a rule blunt '
126
+ + 'enough to have no gaps. NOTE for a future reader: interpolating `p` into a shell string '
127
+ + 'is a real smell of a DIFFERENT class (shell metacharacters, not tree provenance). It is '
128
+ + 'not exploitable today because every caller passes a literal, and it is out of scope for '
129
+ + 'B50 — but it is the thing to fix if `paths` ever becomes request-derived.',
130
+ },
131
+ ];
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // binding resolution — which local names in THIS file are child_process spawners
135
+ // ---------------------------------------------------------------------------
136
+
137
+ const REQUIRE_CP_RE = /(?:const|let|var)\s+(\{[^}]*\}|[A-Za-z_$][\w$]*)\s*=\s*require\(\s*['"](?:node:)?child_process['"]\s*\)/g;
138
+
139
+ // ESM: `import { execFile, spawn as sp } from 'node:child_process'`, and the namespace
140
+ // form. The repo is CJS today, but a check that only understands CJS quietly stops
141
+ // working the day a file converts — and stops without a signal, which is the failure
142
+ // mode this file exists to avoid.
143
+ const IMPORT_CP_RE = /import\s+(\*\s*as\s*[A-Za-z_$][\w$]*|\{[^}]*\}|[A-Za-z_$][\w$]*)\s*from\s*['"](?:node:)?child_process['"]/g;
144
+
145
+ // The UNBOUND form: `require('node:child_process').execFileSync(...)` called straight off
146
+ // the require. It binds no name, so binding resolution alone never sees it — and the
147
+ // first draft of this check short-circuited the WHOLE FILE on "no bindings", so a file
148
+ // spawning this way was skipped entirely rather than scanned. That is the exact
149
+ // inversion of this scan's stated "fails toward noise, never toward silence" contract,
150
+ // and the form is not hypothetical: scripts/gds/autonomy-gate.js and
151
+ // scripts/gds/ship-land.js both already use it.
152
+ const INLINE_CP_RE = new RegExp(
153
+ `require\\(\\s*['"](?:node:)?child_process['"]\\s*\\)\\s*\\.\\s*(?:${[...SPAWNERS].join('|')})\\s*\\(`,
154
+ 'g',
155
+ );
156
+
157
+ // A require() whose specifier is not a literal: the server LOADS AND RUNS that module in
158
+ // its own process, which is the same question this check asks of exec/spawn through a
159
+ // different mechanism. src/module-loader/loader.js is the motivating site the audit named
160
+ // alongside conflict-resolve.js.
161
+ const REQUIRE_CALL_RE = /(?<![.\w$])require\s*\(/g;
162
+
163
+ // Returns { direct:Set<name>, namespaces:Set<name> } — `direct` are names bound to a
164
+ // spawner (destructuring or ESM named import, renames included), `namespaces` are
165
+ // whole-module bindings used as `cp.execFile(...)`.
166
+ function resolveBindings(src) {
167
+ const direct = new Set();
168
+ const namespaces = new Set();
169
+ const takeList = (lhs) => {
170
+ if (!lhs.startsWith('{')) {
171
+ namespaces.add(lhs.replace(/^\*\s*as\s*/, '').trim());
172
+ return;
173
+ }
174
+ for (const part of lhs.slice(1, -1).split(',')) {
175
+ const [orig, alias] = part.split(/:|\bas\b/).map((s) => s.trim());
176
+ if (orig && SPAWNERS.has(orig)) direct.add(alias || orig);
177
+ }
178
+ };
179
+ for (const re of [REQUIRE_CP_RE, IMPORT_CP_RE]) {
180
+ re.lastIndex = 0;
181
+ let m;
182
+ while ((m = re.exec(src))) takeList(m[1].trim());
183
+ }
184
+ return { direct, namespaces };
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // argument extraction — read a call's arguments, balancing brackets and strings
189
+ // ---------------------------------------------------------------------------
190
+
191
+ // From the index of a call's '(', return the raw argument text, or null if unbalanced.
192
+ function readCallArgs(src, openIdx) {
193
+ let depth = 0;
194
+ let quote = null;
195
+ for (let i = openIdx; i < src.length; i += 1) {
196
+ const c = src[i];
197
+ if (quote) {
198
+ if (c === '\\') { i += 1; continue; }
199
+ if (c === quote) quote = null;
200
+ continue;
201
+ }
202
+ if (c === "'" || c === '"' || c === '`') { quote = c; continue; }
203
+ if (c === '(' || c === '[' || c === '{') depth += 1;
204
+ else if (c === ')' || c === ']' || c === '}') {
205
+ depth -= 1;
206
+ if (depth === 0) return src.slice(openIdx + 1, i);
207
+ }
208
+ }
209
+ return null;
210
+ }
211
+
212
+ // Split argument text on TOP-LEVEL commas only.
213
+ function splitArgs(argText) {
214
+ const out = [];
215
+ let depth = 0;
216
+ let quote = null;
217
+ let start = 0;
218
+ for (let i = 0; i < argText.length; i += 1) {
219
+ const c = argText[i];
220
+ if (quote) {
221
+ if (c === '\\') { i += 1; continue; }
222
+ if (c === quote) quote = null;
223
+ continue;
224
+ }
225
+ if (c === "'" || c === '"' || c === '`') { quote = c; continue; }
226
+ if (c === '(' || c === '[' || c === '{') depth += 1;
227
+ else if (c === ')' || c === ']' || c === '}') depth -= 1;
228
+ else if (c === ',' && depth === 0) { out.push(argText.slice(start, i)); start = i + 1; }
229
+ }
230
+ out.push(argText.slice(start));
231
+ return out.map((s) => s.trim()).filter((s) => s.length);
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // staticness — is an expression built only from roots fixed at module load?
236
+ // ---------------------------------------------------------------------------
237
+
238
+ // Blank string CONTENT so it cannot contribute identifiers — with one exception that
239
+ // decides whether this check works at all.
240
+ //
241
+ // A template literal's `${...}` holes are CODE, not content. Blanking a whole template
242
+ // the way a quoted string is blanked leaves `${work}/gen.js` with no free identifiers,
243
+ // and isStatic()'s every() over an empty set is vacuously TRUE — so the path reads as
244
+ // static and the call is never flagged. That is not a missed edge: it is a one-character
245
+ // bypass of the entire check (write a backtick instead of calling path.join) for exactly
246
+ // the runtime-derived path B1/B50 are about. Found by the grader, reproduced, pinned by
247
+ // tests/exec_path_guard.mjs.
248
+ //
249
+ // So a template is reduced to a call-shaped list of its interpolated expressions —
250
+ // `${a}/x/${b}` becomes `(a,b)` — which keeps every identifier the hole referenced and
251
+ // discards the literal text around it. Templates are handled BEFORE the quoted forms so
252
+ // a quote inside a hole cannot be mistaken for a string opener.
253
+ const TEMPLATE_RE = /`(?:[^`\\]|\\.)*`/g;
254
+ const HOLE_RE = /\$\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g;
255
+
256
+ function stripStrings(expr) {
257
+ return expr
258
+ .replace(TEMPLATE_RE, (lit) => {
259
+ const holes = [];
260
+ HOLE_RE.lastIndex = 0;
261
+ let m;
262
+ while ((m = HOLE_RE.exec(lit))) holes.push(m[1]);
263
+ return holes.length ? `(${holes.join(',')})` : '``';
264
+ })
265
+ .replace(/'(?:[^'\\]|\\.)*'/g, "''")
266
+ .replace(/"(?:[^"\\]|\\.)*"/g, '""');
267
+ }
268
+
269
+ // Free identifiers: tokens not preceded by '.' (property names) and not object keys.
270
+ function freeIdentifiers(expr) {
271
+ const bare = stripStrings(expr);
272
+ const out = new Set();
273
+ const re = /(^|[^.\w$])([A-Za-z_$][\w$]*)/g;
274
+ let m;
275
+ while ((m = re.exec(bare))) out.add(m[2]);
276
+ return out;
277
+ }
278
+
279
+ // Module-level `const NAME = <expr>;` declarations — the only indirection the scan
280
+ // follows. Column 0 is the test for module level; a const nested in a function is
281
+ // re-assigned per call and is not a fixed root.
282
+ function moduleConsts(src) {
283
+ const out = new Map();
284
+ const re = /^const\s+([A-Za-z_$][\w$]*)\s*=\s*([^\n]*?);?\s*$/gm;
285
+ let m;
286
+ while ((m = re.exec(src))) out.set(m[1], m[2]);
287
+ return out;
288
+ }
289
+
290
+ // The safe set: STATIC_ROOTS plus module consts that resolve to safe expressions.
291
+ // Iterated to a fixpoint so `const A = __dirname; const B = path.join(A, 'x')` works.
292
+ function safeIdentifiers(src) {
293
+ const consts = moduleConsts(src);
294
+ const safe = new Set(STATIC_ROOTS);
295
+ for (let pass = 0; pass < 5; pass += 1) {
296
+ let grew = false;
297
+ for (const [name, expr] of consts) {
298
+ if (safe.has(name)) continue;
299
+ if ([...freeIdentifiers(expr)].every((id) => safe.has(id))) { safe.add(name); grew = true; }
300
+ }
301
+ if (!grew) break;
302
+ }
303
+ return safe;
304
+ }
305
+
306
+ function isStatic(expr, safe) {
307
+ return [...freeIdentifiers(expr)].every((id) => safe.has(id));
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // masking — blank comments, string bodies and regex literals before matching
312
+ // ---------------------------------------------------------------------------
313
+
314
+ // Call sites are matched against a MASKED copy of the source, never the raw text.
315
+ // Without this the scan reads prose: modules/grading/grader.js carries the words
316
+ // "before worker spawn (GRADER_BYPASS_ENABLED / runOpts.bypass)" inside a note string,
317
+ // and a bare /spawn\s*\(/ matches it and reports the sentence as an executed path.
318
+ // Masking preserves LENGTH and NEWLINES, so offsets and line numbers computed on the
319
+ // mask still address the original text and argument text is read back from it.
320
+ //
321
+ // Regex literals are detected by what precedes them — after an operator or an opening
322
+ // bracket a `/` starts a pattern, after a value it is division. That heuristic is the
323
+ // standard one and it can be wrong; being wrong here masks or unmasks a few characters
324
+ // and can only add a finding, never hide one, which is the direction this scan fails in
325
+ // everywhere else too.
326
+ function maskNonCode(src) {
327
+ const out = src.split('');
328
+ const blank = (i) => { if (out[i] !== '\n') out[i] = ' '; };
329
+ let i = 0;
330
+ let prev = '';
331
+ while (i < src.length) {
332
+ const c = src[i];
333
+ const next = src[i + 1];
334
+ if (c === '/' && next === '/') {
335
+ while (i < src.length && src[i] !== '\n') { blank(i); i += 1; }
336
+ continue;
337
+ }
338
+ if (c === '/' && next === '*') {
339
+ blank(i); blank(i + 1); i += 2;
340
+ while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) { blank(i); i += 1; }
341
+ if (i < src.length) { blank(i); blank(i + 1); i += 2; }
342
+ continue;
343
+ }
344
+ if (c === "'" || c === '"' || c === '`') {
345
+ i += 1; // keep the opening quote so string-shaped args stay recognisable
346
+ while (i < src.length && src[i] !== c) {
347
+ if (src[i] === '\\') { blank(i); i += 1; }
348
+ if (i < src.length) { blank(i); i += 1; }
349
+ }
350
+ if (i < src.length) i += 1;
351
+ prev = c;
352
+ continue;
353
+ }
354
+ if (c === '/' && /[(,=:[!&|?{};+\-*%<>~^]/.test(prev)) {
355
+ i += 1;
356
+ while (i < src.length && src[i] !== '/' && src[i] !== '\n') {
357
+ if (src[i] === '\\') { blank(i); i += 1; }
358
+ if (i < src.length) { blank(i); i += 1; }
359
+ }
360
+ if (i < src.length && src[i] === '/') i += 1;
361
+ prev = '/';
362
+ continue;
363
+ }
364
+ if (!/\s/.test(c)) prev = c;
365
+ i += 1;
366
+ }
367
+ return out.join('');
368
+ }
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // the scan
372
+ // ---------------------------------------------------------------------------
373
+
374
+ // Which argument carries the executed path? Always arg 0. When arg 0 is the node
375
+ // binary, the SCRIPT is the first element of the args array, so that is checked too —
376
+ // `execFile(process.execPath, [path.join(work, rel)])` is the exact defect and its
377
+ // arg 0 is perfectly static.
378
+ function executedExpressions(args) {
379
+ const out = [];
380
+ if (!args.length) return out;
381
+ out.push({ expr: args[0], role: 'executable' });
382
+ const isNode = /process\.execPath/.test(args[0]) || /^['"](?:node|node\.exe)['"]$/.test(args[0].trim());
383
+ if (isNode && args[1] && args[1].trim().startsWith('[')) {
384
+ const first = splitArgs(args[1].trim().slice(1, -1))[0];
385
+ if (first) out.push({ expr: first, role: 'script' });
386
+ }
387
+ return out;
388
+ }
389
+
390
+ // Record one finding if the expression is not statically rooted.
391
+ function consider(findings, { rel, masked, src, idx, callText, role, expr, safe }) {
392
+ if (expr == null || isStatic(expr, safe)) return;
393
+ findings.push({
394
+ file: rel,
395
+ line: src.slice(0, idx).split('\n').length,
396
+ role,
397
+ expr: expr.replace(/\s+/g, ' ').slice(0, 120),
398
+ call: callText,
399
+ });
400
+ }
401
+
402
+ function scanFile(rel, src) {
403
+ // Everything regex-matched below runs against `masked`; argument TEXT is read back
404
+ // from `src` at the same offsets, which masking keeps aligned.
405
+ const masked = maskNonCode(src);
406
+ // Bindings are resolved from the ORIGINAL text: masking blanks string bodies, and the
407
+ // require specifier 'node:child_process' IS a string body — reading bindings off the
408
+ // mask finds none, and the scan silently passes every file (caught in review).
409
+ const { direct, namespaces } = resolveBindings(src);
410
+ const safe = safeIdentifiers(masked);
411
+ const findings = [];
412
+
413
+ // Every way a spawner can be reached: a bound name, a namespace member, and the
414
+ // unbound require('child_process').x() form. NOT short-circuited on "no bindings" —
415
+ // the inline form has none by construction.
416
+ const forms = [
417
+ ...[...direct].map((n) => `(?<![.\\w$])${n}\\s*\\(`),
418
+ ...[...namespaces].map((ns) => `(?<![.\\w$])${ns}\\s*\\.\\s*(?:${[...SPAWNERS].join('|')})\\s*\\(`),
419
+ ];
420
+
421
+ // Bound spawner calls are matched against the MASK, so prose can never be one.
422
+ const hits = [];
423
+ if (forms.length) {
424
+ const bound = new RegExp(forms.join('|'), 'g');
425
+ let b;
426
+ while ((b = bound.exec(masked))) hits.push(b);
427
+ }
428
+
429
+ // The UNBOUND form is matched against the ORIGINAL text, because the thing that
430
+ // identifies it IS a specifier string and masking blanks string bodies — the same trap
431
+ // that once made binding resolution see nothing at all. Prose is still excluded
432
+ // without re-reading the raw text wholesale: had the construct sat inside a comment or
433
+ // a string, its trailing '(' would have been blanked too, so the mask is consulted for
434
+ // that one character.
435
+ INLINE_CP_RE.lastIndex = 0;
436
+ let im;
437
+ while ((im = INLINE_CP_RE.exec(src))) {
438
+ if (masked[im.index + im[0].length - 1] === '(') hits.push(im);
439
+ }
440
+
441
+ for (const m of hits) {
442
+ const openIdx = m.index + m[0].length - 1;
443
+ const argText = readCallArgs(src, openIdx);
444
+ if (argText == null) continue;
445
+ const args = splitArgs(argText);
446
+ const callText = m[0].slice(0, -1).trim().replace(/\s+/g, '');
447
+ for (const { expr, role } of executedExpressions(args)) {
448
+ consider(findings, { rel, masked, src, idx: m.index, callText, role, expr, safe });
449
+ }
450
+ }
451
+
452
+ // Dynamic require(): the server loads and RUNS that module in-process. Same question,
453
+ // different mechanism — src/module-loader/loader.js is the site the audit named.
454
+ REQUIRE_CALL_RE.lastIndex = 0;
455
+ let r;
456
+ while ((r = REQUIRE_CALL_RE.exec(masked))) {
457
+ const openIdx = r.index + r[0].length - 1;
458
+ const argText = readCallArgs(src, openIdx);
459
+ if (argText == null) continue;
460
+ const spec = splitArgs(argText)[0];
461
+ consider(findings, {
462
+ rel, masked, src, idx: r.index, callText: 'require', role: 'module', expr: spec, safe,
463
+ });
464
+ }
465
+
466
+ return findings;
467
+ }
468
+
469
+ // Every tracked first-party server file. Browser assets under public/ never spawn.
470
+ function trackedServerFiles() {
471
+ try {
472
+ // DIRECTORY pathspecs, filtered by extension here, rather than '**/*.js' globs.
473
+ // NOT a bug fix — the glob form enumerated the same 351 files, because a git pathspec
474
+ // '*' crosses '/' (fnmatch without FNM_PATHNAME), so 'src/*.js' already matched every
475
+ // nested file and 'src/**/*.js' — which matches only 63 — was pure redundancy. That
476
+ // equivalence is a non-obvious property of git that a reader has to know to trust the
477
+ // line, and the cost of being wrong about it is coverage that narrows with no signal.
478
+ // A directory pathspec needs no such knowledge. Pinned by the enumeration test below.
479
+ const out = execFileSync('git', ['ls-files', '--', 'src', 'modules'],
480
+ { cwd: REPO_ROOT, encoding: 'utf8' });
481
+ return [...new Set(out.split('\n').map((x) => x.trim()).filter(Boolean))]
482
+ .filter((f) => f.endsWith('.js'))
483
+ .filter((f) => !f.includes('/public/') && !f.includes('/app/'));
484
+ } catch {
485
+ return [];
486
+ }
487
+ }
488
+
489
+ function exemptionFor(f) {
490
+ return EXEMPTIONS.find((e) => e.at === `${f.file}:${f.line}`) || null;
491
+ }
492
+
493
+ function checkExecPathsStatic({ files, read } = {}) {
494
+ const list = files || trackedServerFiles();
495
+ if (!list.length) {
496
+ return {
497
+ name: NAME, ok: false, hardFail: true,
498
+ violations: ['scan defect — enumerated 0 tracked server files; a broken enumeration, not a clean result.'],
499
+ warnings: [], note: 'static scan of src/ + modules/ for executed paths.',
500
+ };
501
+ }
502
+ const reader = read || ((rel) => {
503
+ try { return fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8'); } catch { return null; }
504
+ });
505
+
506
+ const violations = [];
507
+ // Exemptions are NOT warnings. Four reviewed entries emitted as warnings make this check
508
+ // read WARN on every single run, and a check that is permanently yellow is one people stop
509
+ // reading. They ride in `exempt` instead: counted in the note, listed in full by the
510
+ // standalone run (node scripts/gds/exec-path-guard.js).
511
+ const exempt = [];
512
+ let scanned = 0;
513
+ let callSites = 0;
514
+ for (const rel of list) {
515
+ const src = reader(rel);
516
+ if (src == null) continue;
517
+ scanned += 1;
518
+ for (const f of scanFile(rel, src)) {
519
+ callSites += 1;
520
+ const ex = exemptionFor(f);
521
+ if (ex) { exempt.push(`${f.file}:${f.line} exempt — ${ex.why}`); continue; }
522
+ violations.push(
523
+ `${f.file}:${f.line} ${f.call}() runs a ${f.role} path that is not statically rooted at the core `
524
+ + `checkout: \`${f.expr}\`. The server executes it with its OWN environment — DATABASE_URL, `
525
+ + 'BUILDER_SECRET_KEY — so a path assembled at runtime is a path an untrusted tree can steer. '
526
+ + 'Root it at __dirname or a module-level const, or add a reviewed EXEMPTIONS entry in '
527
+ + 'scripts/gds/exec-path-guard.js saying why a runtime path is correct here.',
528
+ );
529
+ }
530
+ }
531
+
532
+ return {
533
+ name: NAME,
534
+ ok: violations.length === 0,
535
+ hardFail: violations.length > 0,
536
+ violations,
537
+ warnings: [],
538
+ exempt,
539
+ note: `${scanned} server file(s) scanned for child_process calls; ${callSites} non-static executed `
540
+ + `path(s) found, ${EXEMPTIONS.length} reviewed exemption(s). An executed path must be built only `
541
+ + 'from literals, __dirname/__filename, process.execPath and module-level consts — the server runs '
542
+ + 'it with its own credentials, so "what runs" must be answerable by reading the call site.',
543
+ };
544
+ }
545
+
546
+ function main() {
547
+ const r = checkExecPathsStatic();
548
+ for (const v of r.violations) console.error(`✗ ${v}`);
549
+ for (const w of r.exempt) console.log(` - ${w}`);
550
+ console.log(`${r.hardFail ? 'FAIL' : 'PASS'} ${r.note}`);
551
+ process.exit(r.hardFail ? 1 : 0);
552
+ }
553
+
554
+ if (require.main === module) main();
555
+
556
+ // Exported surface = what fitness.js, the standalone main and the tests actually use.
557
+ // stripStrings/maskNonCode/isStatic are exported because the template-literal bypass
558
+ // above is the kind of defect that needs a test at ITS OWN altitude, not only through
559
+ // scanFile — a unit-level probe is how that regression stays visible.
560
+ module.exports = {
561
+ checkExecPathsStatic, scanFile, resolveBindings, trackedServerFiles,
562
+ stripStrings, maskNonCode, isStatic, safeIdentifiers,
563
+ EXEMPTIONS, NAME,
564
+ };
@@ -1406,7 +1406,7 @@ const CHECKS = [
1406
1406
  // migrations: same shared, monotonic, human-allocated number line, and until this
1407
1407
  // landed only one of the two was enforced. An ADR number is a citation target, so a
1408
1408
  // duplicate is wrong forever (ADR 0195).
1409
- require('./adr-namespace.js').checkAdrNumbering,
1409
+ require('./adr-namespace.js').checkAdrNumbering, require('./exec-path-guard.js').checkExecPathsStatic, // Check 32 — task 1003371 / B50: what the server EXECUTES, out of whose tree — the blind spot all three 2026-08-29 CRITICALs shared. Rationale + reviewed exemptions live in that file. Sharing Check 31's line (the 19c/19d + 23/24 precedent above): fitness.js is AT the 1,500 ratchet and a new check cannot afford a line until task 1003825 buys it back.
1410
1410
  ];
1411
1411
 
1412
1412
  function runAll() {
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.703'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.705'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -316,6 +316,69 @@ t('a GitHub noreply address yields the login; a local name is only a fallback',
316
316
  'an unreadable history resolves to null, which rule 2 treats as insufficient');
317
317
  });
318
318
 
319
+ console.log('\nthe path wall is wired to the REAL registry (task 1002492):');
320
+
321
+ const permissionPaths = require('../src/bongos/permission-path-check.js');
322
+ const realMatcher = (p) => permissionPaths.surfaceFor(p) !== null;
323
+
324
+ t('the wall is wired to the REAL protected-surface registry', () => {
325
+ // Not a stub: the same synchronously-loaded registry the pre-push hook, the
326
+ // grader pre-pass and main-audit.js read (ADR 0043). If a governance re-map
327
+ // narrows it, this fails rather than silently widening what an agent may reach.
328
+ assert.equal(realMatcher('modules/government/catalog.js'), true, 'the authority surface');
329
+ assert.equal(realMatcher('migrations/001_init.sql'), true, 'schema');
330
+ assert.equal(realMatcher('.claude/hooks/pre-push.js'), true, 'local automation');
331
+ assert.equal(realMatcher('modules/agents/lib/validate.js'), false, "a module's own code is not protected");
332
+ // `.claude/agents/` itself is deliberately NOT in the registry — see the note
333
+ // on ADDED_SINCE_R101 in tests/government_protected_surfaces.mjs. Protecting
334
+ // it would rank-wall the whole agents module by derivation, which is a
335
+ // separate decision from the one this wall makes.
336
+ assert.equal(realMatcher('.claude/agents/historian.md'), false,
337
+ 'not protected — and that is recorded, not accidental');
338
+ });
339
+
340
+ t('a path scope onto the authority surface is withheld a rank AND flagged', () => {
341
+ // Both halves of task 1002492 meeting the rule from 1002490: the definition
342
+ // reaches a protected surface BY PATH, so (a) no git-derived rank is stamped
343
+ // and (b) the scope wall refuses it — imported, disabled, visible.
344
+ const d = sync.parseAgentFile(
345
+ '---\nname: snoop\ntrigger: on-demand\nscope_paths: [modules/government/catalog.js]\n---\nbody', {});
346
+ assert.equal(sync.declaresProtectedScope(d, [], realMatcher), true);
347
+ assert.equal(sync.stampableAuthorRank(d, { gitRank: 'archon', isProtectedPath: realMatcher }), null,
348
+ 'a claimed archon rank must not survive a PATH-spelled protected scope either');
349
+ const p = sync.planOne(d, {
350
+ authorRank: 'archon', allowedModules: ['agents'], isProtectedPath: realMatcher,
351
+ });
352
+ assert.equal(p.action, 'flagged');
353
+ assert.equal(p.authorRank, null);
354
+ assert.equal(p.enabled, false);
355
+ assert.match(p.scopeViolation, /scope_paths|protected surface/i);
356
+ });
357
+
358
+ t('an unprotected path scope still reconciles normally', () => {
359
+ // The wall is a floor, not a ban — a tightening that refused every path scope
360
+ // would pass the test above and break the feature.
361
+ const d = sync.parseAgentFile(
362
+ '---\nname: reader\ntrigger: on-demand\nscope_paths: [modules/agents/lib/]\n---\nbody', {});
363
+ assert.equal(sync.declaresProtectedScope(d, [], realMatcher), false);
364
+ const p = sync.planOne(d, {
365
+ authorRank: 'metic', allowedModules: ['agents'], isProtectedPath: realMatcher,
366
+ });
367
+ assert.equal(p.action, 'upsert');
368
+ assert.equal(p.authorRank, 'metic', 'rank gates nothing here, so the provenance hint is kept');
369
+ });
370
+
371
+ t('declaring paths with NO matcher flags rather than importing clean', () => {
372
+ // The uncheckable case end to end: the plan must not treat "nobody checked"
373
+ // as "nothing was protected".
374
+ const d = sync.parseAgentFile(
375
+ '---\nname: x\ntrigger: on-demand\nscope_paths: [anything]\n---\nbody', {});
376
+ assert.equal(sync.declaresProtectedScope(d, [], null), true, 'unanswerable reads as protected');
377
+ const p = sync.planOne(d, { authorRank: 'archon', allowedModules: ['agents'] });
378
+ assert.equal(p.action, 'flagged', 'imported and visible, never silently clean');
379
+ assert.equal(p.enabled, false);
380
+ });
381
+
319
382
  console.log('\nthe reconcile actually RUNS at deploy:');
320
383
 
321
384
  t('migrate.sh invokes agents-sync, module-gated and fail-open', () => {