@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.
- package/.bongos-core.json +29 -19
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/windows-builders.md +43 -16
- package/modules/agents/lib/validate.js +56 -0
- package/modules/government/protected-surfaces.json +8 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/agents-sync.js +48 -14
- package/scripts/gds/exec-path-guard.js +564 -0
- package/scripts/gds/fitness.js +1 -1
- package/src/module-api.js +1 -1
- package/tests/agents_sync.mjs +63 -0
- package/tests/agents_validate.mjs +118 -1
- package/tests/exec_path_guard.mjs +248 -0
- package/tests/government_protected_surfaces.mjs +132 -1
|
@@ -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
|
+
};
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -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.
|
|
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');
|
package/tests/agents_sync.mjs
CHANGED
|
@@ -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', () => {
|