@bongos/core 1.19.700 → 1.19.701

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 CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.700",
6
- "core_contract": "1.19.700",
7
- "source_commit": "7af21bffa8227c67a1c257f64a8fd73f2c1760dc",
5
+ "core_version": "1.19.701",
6
+ "core_contract": "1.19.701",
7
+ "source_commit": "6c01baa69654043099e16c9bf0353eff61d2012d",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-13T03:16:24.200Z",
9
+ "built_at": "2026-09-13T03:22:47.893Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 477,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2128,
14
+ "functional_verbatim": 2130,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2629,
20
- "tree_sha256": "3e7d4889874e8dafb6bc2f7d30722f62c5495396f2760d7a3d06985a79b6adb8",
19
+ "file_count": 2631,
20
+ "tree_sha256": "640bb539971ad78a750aa80116d26c7f1f81fc1b2afb21ef0fd33855189fe10a",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2802,7 +2802,7 @@
2802
2802
  {
2803
2803
  "path": "docs/module-api-changelog.md",
2804
2804
  "mode": "0000644",
2805
- "sha256": "ba12cd298d7b45de5f7764920115a0419101b4ba4f172ef3ff95ef8798c57508"
2805
+ "sha256": "4fc39aaaae76afb8153485474fc9f3459a2ee380b96f59d1d693a4a7680db464"
2806
2806
  },
2807
2807
  {
2808
2808
  "path": "docs/modules-contract.md",
@@ -5642,7 +5642,7 @@
5642
5642
  {
5643
5643
  "path": "modules/lifecycle/conflict-resolve.js",
5644
5644
  "mode": "0000644",
5645
- "sha256": "3e20f4965d1d83d17f8e777201bd7e4fd7857843295180542914de16ae51e936"
5645
+ "sha256": "4114eb2632a95a9b830956d0e476ad92ed6eaaa526d9bd46e7ddba8a6fb566e3"
5646
5646
  },
5647
5647
  {
5648
5648
  "path": "modules/lifecycle/criterion-suggest.js",
@@ -5729,6 +5729,11 @@
5729
5729
  "mode": "0000644",
5730
5730
  "sha256": "23ee20e57c67adb5d2ec29c654d2bc469434cafa010b375a8a4452ced1df49e3"
5731
5731
  },
5732
+ {
5733
+ "path": "modules/lifecycle/executed-closure.js",
5734
+ "mode": "0000644",
5735
+ "sha256": "70be81206f577910d250e70ed0841466bbe329691324156b5b15bb75abcd93d3"
5736
+ },
5732
5737
  {
5733
5738
  "path": "modules/lifecycle/github-push.js",
5734
5739
  "mode": "0000644",
@@ -5852,7 +5857,7 @@
5852
5857
  {
5853
5858
  "path": "modules/lifecycle/publish-reconciler.js",
5854
5859
  "mode": "0000644",
5855
- "sha256": "28c61d55fe2b492f0fce4c99b60e88f0cbeb542c47c04482558818d65628e0d7"
5860
+ "sha256": "9f3e3ae52f9fa49a1f5638d92a4b985d0c23fe0433d917d017aa78876c41ca43"
5856
5861
  },
5857
5862
  {
5858
5863
  "path": "modules/lifecycle/rot.js",
@@ -7777,12 +7782,12 @@
7777
7782
  {
7778
7783
  "path": "package-lock.json",
7779
7784
  "mode": "0000644",
7780
- "sha256": "4cab339364e2c40b9adb593184fe0c78b8775c11203d6551d019c12de6675e5d"
7785
+ "sha256": "f7fb304e4955f8024186863281f2540c2e92299e49b16f7f6ea0f78c6d46d22a"
7781
7786
  },
7782
7787
  {
7783
7788
  "path": "package.json",
7784
7789
  "mode": "0000644",
7785
- "sha256": "ed864f4879e51f4bc1adf7222e46b6fb9446fa93d59ea3b17bc26dbde19f18e2"
7790
+ "sha256": "be0e756de883b5e3291f721efe8515540771994df5d83f0eb96bd07f87ecf1e8"
7786
7791
  },
7787
7792
  {
7788
7793
  "path": "public-docs/index.html",
@@ -9547,7 +9552,7 @@
9547
9552
  {
9548
9553
  "path": "src/module-api.js",
9549
9554
  "mode": "0000644",
9550
- "sha256": "46a02032df3b4b36ac48c77f8f373ee44735d00dca00ec483100ffd0f7b9062e"
9555
+ "sha256": "2fdd8c72769bba600991d5c886878c7390ef2086f67ad8c1036555e45631a971"
9551
9556
  },
9552
9557
  {
9553
9558
  "path": "src/module-loader/catalog.js",
@@ -10559,6 +10564,11 @@
10559
10564
  "mode": "0000644",
10560
10565
  "sha256": "d6a78357533fdeea3bc33fe97cfcd27b1d04747c600d0a7800ddfca5199dbb1f"
10561
10566
  },
10567
+ {
10568
+ "path": "tests/executed_closure.mjs",
10569
+ "mode": "0000644",
10570
+ "sha256": "9f4b768e74b542e080f2f2d92db3e5d7a05a66e0fe8822528a40e1b9399f178c"
10571
+ },
10562
10572
  {
10563
10573
  "path": "tests/explore_visible_states.mjs",
10564
10574
  "mode": "0000644",
@@ -12222,7 +12232,7 @@
12222
12232
  {
12223
12233
  "path": "tests/publish_reconciler.mjs",
12224
12234
  "mode": "0000644",
12225
- "sha256": "4c7c58a1fd28427fbcace0d20a9446f88c2a90cc19eb5fd4826236207732be26"
12235
+ "sha256": "92806699b18a064233330d844d4a5bd85ae5ebc628231be81bcdc4548063f074"
12226
12236
  },
12227
12237
  {
12228
12238
  "path": "tests/push_path_brief.mjs",
@@ -1859,5 +1859,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1859
1859
  landed since 1.19.698 with no explicit bump. run 34734848584. (task 1002620)
1860
1860
  1.19.700 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1861
1861
  landed since 1.19.699 with no explicit bump. run 34735048203. (task 1002620)
1862
+ 1.19.701 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1863
+ landed since 1.19.700 with no explicit bump. run 34735311256. (task 1002620)
1862
1864
  ---------------------------------------------------------------------------
1863
1865
  ```
@@ -51,6 +51,16 @@ const githubPush = require('./github-push');
51
51
  const { repoInfo, branding } = require('../../src/module-api');
52
52
  const { loadRepoInfo } = repoInfo;
53
53
  const { registerMergeDriver } = require('../../scripts/gds/install-git-hooks');
54
+ const { computeExecutedClosure, branchTouchesExecutedCode } = require('./executed-closure');
55
+
56
+ // The closure is a property of the SERVER's trusted tree, not of the branch being
57
+ // resolved, so it is computed once per root and cached. Asking the clone what our code
58
+ // executes would let the branch answer the question the check exists to ask.
59
+ const closureCache = new Map();
60
+ function executedClosure(root) {
61
+ if (!closureCache.has(root)) closureCache.set(root, computeExecutedClosure({ root }));
62
+ return closureCache.get(root);
63
+ }
54
64
 
55
65
  const execFileP = promisify(execFile);
56
66
 
@@ -141,7 +151,8 @@ async function hasConflictMarkers(gitIn) {
141
151
  // { resolved: true, head_sha, branch, regenerated } — merged + pushed
142
152
  // { resolved: false, skipped: '<why>' [, conflicted: [...] ] } — left as-is
143
153
  // skipped reasons: disabled | unconfigured | already_up_to_date | clone_failed |
144
- // fetch_failed | checkout_failed | markers_remain | push_failed |
154
+ // fetch_failed | checkout_failed | diff_failed | branch_modifies_executed_code
155
+ // (with executes[]) | markers_remain | push_failed |
145
156
  // unresolvable_conflict (with conflicted[]) | generator_failed | commit_failed.
146
157
  // `regenerated` says whether step 7b had to commit post-merge generator drift on top
147
158
  // of the merge (task 1003546) — head_sha is that commit when it did.
@@ -211,6 +222,28 @@ async function resolveBranchConflicts({ branch } = {}, deps = {}) {
211
222
  if (r.code !== 0) return { resolved: false, skipped: 'checkout_failed' };
212
223
  const before = (await git(['rev-parse', 'HEAD'])).stdout.trim();
213
224
 
225
+ // 5b. REFUSE A BRANCH THAT EDITS THE CODE THIS FUNCTION IS ABOUT TO RUN
226
+ // (task 1003367, audit B1). Everything below executes out of the clone with the
227
+ // live server's environment — the merge driver at step 6 and the generators at
228
+ // 7b — so a branch that rewrote any of it would be running its own code as the
229
+ // server. This check must sit BEFORE the merge, not after it: `git merge` invokes
230
+ // `node scripts/gds/git-merge-regen.js` by RELATIVE path with cwd at the clone
231
+ // root, so the driver is the clone's copy and it runs at step 6.
232
+ //
233
+ // Fail CLOSED and hand the merge to a human. The alternative — scrubbing the
234
+ // clone and running anyway — has to be right about every file it restores, and
235
+ // the closure is 80-odd files deep through src/instance-config and src/bongos/*,
236
+ // not the scripts/ directory alone.
237
+ const touched = await git(['diff', '--name-only', 'refs/remotes/origin/main...HEAD']);
238
+ if (touched.code !== 0) return { resolved: false, skipped: 'diff_failed' };
239
+ const executes = branchTouchesExecutedCode(
240
+ touched.stdout.split('\n'),
241
+ deps.executedClosure || executedClosure(repoRoot),
242
+ );
243
+ if (executes.length) {
244
+ return { resolved: false, skipped: 'branch_modifies_executed_code', executes };
245
+ }
246
+
214
247
  // 6. Merge origin/main into the branch. The otb-regen driver resolves the
215
248
  // generated files; union resolves docs/adr/README.md; any genuine conflict
216
249
  // leaves the merge non-zero.
@@ -392,7 +425,8 @@ async function defaultRunGenerators({ work, execFile = execFileP } = {}) {
392
425
  // is a no-op for the strand.
393
426
  // Returns { refreshed: true, head_sha, branch } | { refreshed: false, skipped }.
394
427
  // skipped reasons: disabled | unconfigured | clone_failed | fetch_failed |
395
- // checkout_failed | merge_conflict | generator_failed | no_regen_diff |
428
+ // checkout_failed | diff_failed | branch_modifies_executed_code (with executes[]) |
429
+ // merge_conflict | generator_failed | no_regen_diff |
396
430
  // markers_remain | commit_failed | push_failed.
397
431
  async function refreshStaleGenerated({ branch } = {}, deps = {}) {
398
432
  const disabled = deps.disabled !== undefined ? deps.disabled : process.env.CONFLICT_RESOLVE_DISABLED === '1';
@@ -441,6 +475,22 @@ async function refreshStaleGenerated({ branch } = {}, deps = {}) {
441
475
  r = await git(['checkout', '-B', branch, `refs/remotes/origin/${branch}`]);
442
476
  if (r.code !== 0) return { refreshed: false, skipped: 'checkout_failed' };
443
477
 
478
+ // 4b. Same refusal as resolveBranchConflicts step 5b (task 1003367, audit B1). This
479
+ // path registers no merge driver, so its only execution vector is runGenerators
480
+ // at step 6 — but the check still belongs BEFORE the merge, because after it the
481
+ // tree is a mix of both sides and `origin/main...HEAD` no longer answers "what
482
+ // did this BRANCH change". Same closure, same fail-closed posture, so the two
483
+ // heals cannot drift into different security postures.
484
+ const touched = await git(['diff', '--name-only', 'refs/remotes/origin/main...HEAD']);
485
+ if (touched.code !== 0) return { refreshed: false, skipped: 'diff_failed' };
486
+ const executes = branchTouchesExecutedCode(
487
+ touched.stdout.split('\n'),
488
+ deps.executedClosure || executedClosure(repoRoot),
489
+ );
490
+ if (executes.length) {
491
+ return { refreshed: false, skipped: 'branch_modifies_executed_code', executes };
492
+ }
493
+
444
494
  // 5. Merge origin/main. Expected CLEAN for this strand (the PR was mergeable and
445
495
  // the failing signal was a stale-generated CHECK, not a conflict). A genuine
446
496
  // conflict → abort + report merge_conflict so the resolveBranchConflicts path
@@ -0,0 +1,183 @@
1
+ // modules/lifecycle/executed-closure.js — which files does the merge resolver EXECUTE
2
+ // out of a builder's branch? (task 1003367, audit ref B1, 2026-08-29 security audit.)
3
+ //
4
+ // WHY. resolveBranchConflicts and refreshStaleGenerated clone a builder's branch into a
5
+ // temp tree and run code out of it with the LIVE SERVER's environment — DATABASE_URL,
6
+ // BUILDER_SECRET_KEY, GITHUB_PUSH_TOKEN, DISCORD_BOT_TOKEN all inherited. That happens
7
+ // on two separate paths, and the second one is easy to miss:
8
+ //
9
+ // 1. the `otb-regen` MERGE DRIVER. registerMergeDriver writes the driver command
10
+ // `node scripts/gds/git-merge-regen.js %O %A %B %P` with cwd at the clone root, so
11
+ // git runs the CLONE's copy during `git merge` — before any post-merge step.
12
+ // 2. the GENERATORS, run after the merge as execFile(node, [<clone>/scripts/gds/…]).
13
+ //
14
+ // Both resolve their repo root from __dirname, which is why the trusted-code problem
15
+ // cannot be solved by pointing them at the server's own copy: a server-rooted
16
+ // git-merge-regen.js would regenerate the SERVER's checkout instead of the clone.
17
+ //
18
+ // WHAT THIS MODULE IS FOR. The audit prescribed restoring scripts/gds/ from origin/main.
19
+ // That is not sufficient on its own: every generator requires ../../src/instance-config,
20
+ // and gen-api-docs additionally requires src/bongos/route-rank-check, api-prefix and
21
+ // routes/_helpers. A restore scoped to scripts/gds/ leaves those builder-controlled and
22
+ // the hole open. So rather than curate a list by hand, we COMPUTE the transitive
23
+ // require-closure of the real entrypoints and let the callers refuse to auto-resolve a
24
+ // branch that touches any of it.
25
+ //
26
+ // The closure is computed against the SERVER's trusted tree on purpose. The question is
27
+ // "what does OUR code execute", and asking the clone would let the branch answer it.
28
+ //
29
+ // THE RESIDUAL ASSUMPTION, stated so it can be re-checked rather than rediscovered.
30
+ // This is a STATIC walk of string-literal require()s, so it is sound exactly as far as
31
+ // that style holds. A computed require of real code — string concatenation, a template
32
+ // literal, a variable — inside a closure member would be invisible here and the closure
33
+ // would under-report what a branch can make the server run. That is not hypothetical:
34
+ // src/module-loader/loader.js is in the closure and computed-requires every module's
35
+ // route file, but only from loadModules(), which nothing on the generator path calls.
36
+ // tests/executed_closure.mjs pins the known computed requires per file and fails the
37
+ // build on a new one — which catches a change to OUR tree, and is therefore a tripwire,
38
+ // not a widening. If a generator ever starts loading modules, or a closure member
39
+ // switches to dynamic require, this walk must grow to match before it is trusted again.
40
+ //
41
+ // COST. computeExecutedClosure does synchronous readFileSync/statSync over ~80 files.
42
+ // It runs in the live server process, so that is worth knowing — but it is called from a
43
+ // ~5-minute background sweep, never a request path, and conflict-resolve.js caches it per
44
+ // repo root for the process lifetime. Revisit if the entrypoint set grows a lot, or if
45
+ // the cache key ever stops being a constant.
46
+ //
47
+ // Route files are deliberately NOT in here: src/bongos/route-rank-check.js reads them as
48
+ // DATA, never require()s them (ADR 0083's one-way rule, and its own comment says so), and
49
+ // gen-api-docs scans route sources textually. That distinction is what makes a narrow
50
+ // closure correct — the resolver still REGENERATES from the builder's merged tree, it
51
+ // just never EXECUTES it.
52
+
53
+ 'use strict';
54
+
55
+ const fs = require('node:fs');
56
+ const path = require('node:path');
57
+
58
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
59
+
60
+ // The two execution roots, as repo-relative POSIX paths.
61
+ //
62
+ // BOTH script-tree spellings are listed while the compat shim exists. Task 1003705 moves
63
+ // these to scripts/bongos/ and leaves scripts/gds/ holding WORKING forwarders, which git
64
+ // still executes by the driver's relative path — so a guard that knew only the new name
65
+ // would stop seeing edits to code that still runs. Missing entries are skipped, so this
66
+ // is correct before the rename, during the shim window, and after C3d retires it.
67
+ const ENTRYPOINTS = Object.freeze([
68
+ // 1. the merge driver (runs DURING `git merge`)
69
+ 'scripts/gds/git-merge-regen.js',
70
+ 'scripts/bongos/git-merge-regen.js',
71
+ // 2. the generators (run after the merge) — mirrors GENERATOR_SCRIPTS in
72
+ // conflict-resolve.js; asserted identical by tests/executed_closure.mjs so the two
73
+ // lists cannot drift apart.
74
+ 'scripts/gds/gen-repo-map.js',
75
+ 'scripts/bongos/gen-repo-map.js',
76
+ 'scripts/gds/gen-file-map.js',
77
+ 'scripts/bongos/gen-file-map.js',
78
+ 'scripts/gds/gen-session-index.js',
79
+ 'scripts/bongos/gen-session-index.js',
80
+ 'scripts/gds/copy-inventory.js',
81
+ 'scripts/bongos/copy-inventory.js',
82
+ 'scripts/gds/gen-api-docs.js',
83
+ 'scripts/bongos/gen-api-docs.js',
84
+ 'scripts/gds/gen-api-client.js',
85
+ 'scripts/bongos/gen-api-client.js',
86
+ ]);
87
+
88
+ // Files that are not require()d but still decide what runs. .gitattributes names which
89
+ // paths the otb-regen driver is applied to, so editing it changes what git hands the
90
+ // driver and how much of the tree that driver rewrites during a merge.
91
+ const TRUSTED_NON_CODE = Object.freeze(['.gitattributes']);
92
+
93
+ const REQUIRE_RE = /\brequire\(\s*(['"])([^'"]+)\1\s*\)/g;
94
+
95
+ function toPosix(p) {
96
+ return p.split(path.sep).join('/');
97
+ }
98
+
99
+ // Resolve a relative require specifier to a repo-relative file, trying the extensions
100
+ // node would. Returns null for bare/`node:` specifiers and for anything that escapes the
101
+ // repo — a require that leaves the tree is not a file a branch can edit.
102
+ function resolveRelative(fromFileAbs, spec, { root }) {
103
+ if (!spec.startsWith('.')) return null;
104
+ const base = path.resolve(path.dirname(fromFileAbs), spec);
105
+ const candidates = [base, base + '.js', base + '.json', path.join(base, 'index.js')];
106
+ for (const c of candidates) {
107
+ let st;
108
+ try {
109
+ st = fs.statSync(c);
110
+ } catch {
111
+ continue;
112
+ }
113
+ if (!st.isFile()) continue;
114
+ const rel = toPosix(path.relative(root, c));
115
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
116
+ return rel;
117
+ }
118
+ return null;
119
+ }
120
+
121
+ // computeExecutedClosure — every file the resolver can execute out of a cloned branch.
122
+ //
123
+ // A STATIC walk of require() literals. It deliberately does not try to follow a computed
124
+ // require: the one that exists (gen-api-docs' require(path.join(CORE_ROOT,
125
+ // 'package.json'))) resolves to package.json, which is parsed as JSON rather than run.
126
+ // If a computed require of real CODE is ever added, the closure would under-report — so
127
+ // tests/executed_closure.mjs fails the build on any computed require inside the closure,
128
+ // which is the honest way to hold a static analysis to its assumption.
129
+ function computeExecutedClosure({ root = REPO_ROOT, entrypoints = ENTRYPOINTS } = {}) {
130
+ const seen = new Set();
131
+ const queue = [];
132
+
133
+ for (const rel of entrypoints) {
134
+ const abs = path.join(root, rel);
135
+ if (fs.existsSync(abs)) queue.push(rel);
136
+ }
137
+
138
+ while (queue.length) {
139
+ const rel = queue.shift();
140
+ if (seen.has(rel)) continue;
141
+ seen.add(rel);
142
+
143
+ const abs = path.join(root, rel);
144
+ let src;
145
+ try {
146
+ src = fs.readFileSync(abs, 'utf8');
147
+ } catch {
148
+ continue;
149
+ }
150
+ REQUIRE_RE.lastIndex = 0;
151
+ let m;
152
+ while ((m = REQUIRE_RE.exec(src))) {
153
+ const next = resolveRelative(abs, m[2], { root });
154
+ if (next && !seen.has(next)) queue.push(next);
155
+ }
156
+ }
157
+
158
+ for (const rel of TRUSTED_NON_CODE) seen.add(rel);
159
+ return Object.freeze([...seen].sort());
160
+ }
161
+
162
+ // branchTouchesExecutedCode — pure predicate over `git diff --name-only` output.
163
+ //
164
+ // Returns the SORTED INTERSECTION rather than a boolean so the caller can name the
165
+ // offending files in its skip reason. A resolver that refuses without saying which file
166
+ // tripped it is one nobody can act on.
167
+ function branchTouchesExecutedCode(touchedPaths, closure) {
168
+ const inClosure = new Set(closure);
169
+ const hits = [];
170
+ for (const raw of touchedPaths || []) {
171
+ const rel = toPosix(String(raw).trim());
172
+ if (rel && inClosure.has(rel)) hits.push(rel);
173
+ }
174
+ return [...new Set(hits)].sort();
175
+ }
176
+
177
+ module.exports = {
178
+ ENTRYPOINTS,
179
+ TRUSTED_NON_CODE,
180
+ REPO_ROOT,
181
+ computeExecutedClosure,
182
+ branchTouchesExecutedCode,
183
+ };
@@ -386,6 +386,21 @@ async function runSweep(deps = {}) {
386
386
  `strand:unresolvable_conflict${files ? `:${files}` : ''}`,
387
387
  `PR #${m.number} (${branch}) has a hand-written conflict (${files || 'unknown files'}) only its builder can resolve`
388
388
  );
389
+ } else if (rr && rr.skipped === 'branch_modifies_executed_code') {
390
+ // task 1003367 (audit B1): DEFINITIVE in exactly the sense P-3 means it —
391
+ // the resolver refused because the branch edits code the resolver would
392
+ // have executed, and no number of later sweeps changes that. Without this
393
+ // branch the skip fell through both chains with no log and no flag, so the
394
+ // PR retried silently every ~5 min forever. Announcing it is not polish:
395
+ // a refusal nobody is told about is indistinguishable from a resolver that
396
+ // quietly stopped working, and this file's own header says a strand must
397
+ // reach its builder when it is DETECTED.
398
+ const files = (rr.executes || []).slice(0, 5).join(',');
399
+ await announceStrand(
400
+ r,
401
+ `strand:branch_modifies_executed_code${files ? `:${files}` : ''}`,
402
+ `PR #${m.number} (${branch}) changes code the merge resolver executes (${files || 'unknown files'}) — it will not be auto-resolved; a human must land it`
403
+ );
389
404
  }
390
405
  } catch (e) {
391
406
  errFn(`[publish-reconciler] conflict-resolve for task ${r.task_id} (non-blocking): ${e && e.message}`);
@@ -470,6 +485,19 @@ async function runSweep(deps = {}) {
470
485
  logFn(
471
486
  `[publish-reconciler] task ${r.task_id} (${branch}) PR #${m.number} red (${m.reason}) but regeneration produced no diff — a real check failure, left for a human`
472
487
  );
488
+ } else if (rr && rr.skipped === 'branch_modifies_executed_code') {
489
+ // task 1003367 (audit B1). A tree-level determination, so it burns a cap
490
+ // slot like the two above rather than retrying as if it were flaky infra:
491
+ // the answer is the same on every future sweep. Announced for the same
492
+ // reason as the conflict path — silence here is a PR that never lands and
493
+ // never says why.
494
+ attempts.set(branch, n + 1);
495
+ const files = (rr.executes || []).slice(0, 5).join(',');
496
+ await announceStrand(
497
+ r,
498
+ `strand:branch_modifies_executed_code${files ? `:${files}` : ''}`,
499
+ `PR #${m.number} (${branch}) changes code the regen heal executes (${files || 'unknown files'}) — it will not be auto-healed; a human must land it`
500
+ );
473
501
  }
474
502
  } catch (e) {
475
503
  errFn(`[publish-reconciler] regen-heal for task ${r.task_id} (non-blocking): ${e && e.message}`);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.700",
3
+ "version": "1.19.701",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.700",
9
+ "version": "1.19.701",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.700",
3
+ "version": "1.19.701",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
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.700'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.701'; // 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');
@@ -0,0 +1,257 @@
1
+ // tests/executed_closure.mjs — task 1003367, audit ref B1 (2026-08-29 security audit).
2
+ //
3
+ // THE DEFECT. resolveBranchConflicts and refreshStaleGenerated clone a builder's branch
4
+ // and run code out of it with the live server's environment. The audit prescribed
5
+ // restoring scripts/gds/ and .gitattributes from origin/main after the merge and before
6
+ // runGenerators. Building it that way would have left the hole open TWICE, and both of
7
+ // those are what these tests pin:
8
+ //
9
+ // TOO NARROW — the generators require ../../src/instance-config, and gen-api-docs also
10
+ // requires src/bongos/route-rank-check, api-prefix and routes/_helpers. A restore
11
+ // scoped to scripts/gds/ leaves those builder-controlled and still executed.
12
+ // TOO LATE — `git merge` runs the otb-regen driver, whose registered command is
13
+ // `node scripts/gds/git-merge-regen.js` by RELATIVE path with cwd at the clone root.
14
+ // That is the clone's copy, and it runs at the merge, before any post-merge step.
15
+ //
16
+ // So the assertions below care about two things: that the closure reaches past
17
+ // scripts/, and that the refusal happens BEFORE git merge is ever issued.
18
+
19
+ import { strict as assert } from 'node:assert';
20
+ import { test } from 'node:test';
21
+ import { createRequire } from 'node:module';
22
+ import path from 'node:path';
23
+ import fs from 'node:fs';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const ec = require('../modules/lifecycle/executed-closure.js');
28
+ const cr = require('../modules/lifecycle/conflict-resolve.js');
29
+
30
+ // fileURLToPath, not URL.pathname: the latter percent-encodes spaces and leaves a Windows
31
+ // drive behind a leading slash (tests/test_path_guard.mjs fails the build on it).
32
+ const REPO_ROOT = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
33
+ const CLOSURE = ec.computeExecutedClosure();
34
+
35
+ // ---- what the closure must reach ------------------------------------------
36
+
37
+ test('the closure reaches the src/ files a scripts/-only restore would have missed', () => {
38
+ // The exact four the audit's prescription would have left builder-writable. If this
39
+ // ever goes quiet it means the generators stopped requiring them, which is a reason to
40
+ // re-derive the fix, not to delete the test.
41
+ for (const rel of [
42
+ 'src/instance-config.js',
43
+ 'src/bongos/route-rank-check.js',
44
+ 'src/bongos/api-prefix.js',
45
+ 'src/bongos/routes/_helpers.js',
46
+ ]) {
47
+ assert.ok(CLOSURE.includes(rel),
48
+ `${rel} is require()d by a generator the resolver executes, so a branch editing it `
49
+ + 'runs code as the server — it must be in the executed closure');
50
+ }
51
+ });
52
+
53
+ test('the closure includes the MERGE DRIVER, not just the generators', () => {
54
+ // The half the audit's "after the merge" framing missed entirely.
55
+ const driver = CLOSURE.filter((f) => /git-merge-regen\.js$/.test(f));
56
+ assert.ok(driver.length > 0,
57
+ 'git-merge-regen.js runs DURING git merge as the clone\'s own copy — omitting it '
58
+ + 'would leave the earliest execution vector unguarded');
59
+ });
60
+
61
+ test('the closure is deep, not just the entrypoints', () => {
62
+ // A static walk that silently failed to follow requires would still "pass" the two
63
+ // tests above if it happened to list them. Depth is the evidence it actually walked:
64
+ // the entrypoints alone are a dozen files.
65
+ assert.ok(CLOSURE.length > 40,
66
+ `closure has only ${CLOSURE.length} files — the require walk is probably not recursing`);
67
+ });
68
+
69
+ test('.gitattributes is guarded even though nothing requires it', () => {
70
+ assert.ok(CLOSURE.includes('.gitattributes'),
71
+ 'it decides which paths the otb-regen driver is applied to, so it changes what runs');
72
+ });
73
+
74
+ // ---- holding the static analysis to its own assumption ---------------------
75
+
76
+ test('every computed require inside the closure is one we have accounted for', () => {
77
+ // computeExecutedClosure follows string-literal requires only, so it is sound exactly
78
+ // as far as that assumption holds. Rather than assert the assumption (it is false —
79
+ // there are computed requires in here), pin the KNOWN set with a reason each, and fail
80
+ // on anything new. A computed require that appears later is the case where the closure
81
+ // would silently under-report what a branch can make the server execute.
82
+ //
83
+ // Why each known one does not widen the closure IN PRACTICE: all of them sit inside a
84
+ // function body, so they resolve when that function is CALLED, and nothing on the
85
+ // generator path calls them. src/module-loader/loader.js is the one worth naming —
86
+ // it is reachable (gen-file-map -> claude-materialize -> bin/bongos.js -> init.js ->
87
+ // module-api -> modules.js -> loader.js) and it computed-requires every module's route
88
+ // file, but only from loadModules(), which generation never invokes. If a generator
89
+ // ever loads modules, this closure must grow to cover modules/**/routes/*.js.
90
+ const ACCOUNTED = new Map([
91
+ ['src/module-loader/loader.js', 2], // routes/<key>.js + pollerPath, from loadModules()
92
+ ['scripts/gds/onboard.js', 3], // provision/oauth-secret/onboardPlan, inside the verb
93
+ ['scripts/gds/package-core.js', 5], // publish-manifest/mirror-redact/secret-scrub/…
94
+ ['scripts/gds/upgrade.js', 1], // a `node -e` child, not a require in this process
95
+ ['scripts/gds/gen-api-docs.js', 1], // require(path.join(CORE_ROOT,'package.json')) — JSON
96
+ ]);
97
+
98
+ // Strip line comments and single/double-quoted strings first: prose and message strings
99
+ // that merely CONTAIN the word were the bulk of the first run's hits.
100
+ const strip = (src) => src
101
+ .split('\n')
102
+ .map((l) => l.replace(/\/\/.*$/, ''))
103
+ .join('\n')
104
+ .replace(/'(?:[^'\\]|\\.)*'/g, "''")
105
+ .replace(/"(?:[^"\\]|\\.)*"/g, '""')
106
+ .replace(/`(?:[^`\\]|\\.)*`/g, '``');
107
+
108
+ const found = new Map();
109
+ for (const rel of CLOSURE) {
110
+ if (!rel.endsWith('.js')) continue;
111
+ let src;
112
+ try { src = fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8'); } catch { continue; }
113
+ const hits = (strip(src).match(/\brequire\(\s*[^'")\s]/g) || []).length;
114
+ if (hits) found.set(rel, hits);
115
+ }
116
+
117
+ const unexpected = [...found.keys()].filter((f) => !ACCOUNTED.has(f)).sort();
118
+ assert.deepEqual(unexpected, [],
119
+ 'a NEW computed require appeared inside the executed closure — decide whether it can '
120
+ + 'load code out of a cloned branch before adding it to ACCOUNTED');
121
+
122
+ for (const [file, n] of ACCOUNTED) {
123
+ if (!found.has(file)) continue; // the file left the closure; the next assertion covers that
124
+ assert.equal(found.get(file), n,
125
+ `${file} now has ${found.get(file)} computed require(s), not ${n} — re-check whether `
126
+ + 'the new one runs on the generator path');
127
+ }
128
+ });
129
+
130
+ test('ENTRYPOINTS covers every generator the resolver actually runs', () => {
131
+ // Two hand-maintained lists in two files is how a new generator gets executed without
132
+ // ever entering the closure. Read conflict-resolve's own list and compare by NAME.
133
+ const src = fs.readFileSync(path.join(REPO_ROOT, 'modules/lifecycle/conflict-resolve.js'), 'utf8');
134
+ const block = src.match(/const GENERATOR_SCRIPTS = \[([\s\S]*?)\];/);
135
+ assert.ok(block, 'could not read GENERATOR_SCRIPTS out of conflict-resolve.js');
136
+ const listed = [...block[1].matchAll(/'([^']+)'/g)].map((m) => m[1]);
137
+ assert.ok(listed.length > 0, 'GENERATOR_SCRIPTS parsed empty');
138
+
139
+ const basenames = new Set(ec.ENTRYPOINTS.map((p) => p.split('/').pop()));
140
+ for (const rel of listed) {
141
+ assert.ok(basenames.has(rel.split('/').pop()),
142
+ `${rel} is executed by the resolver but is not an executed-closure entrypoint`);
143
+ }
144
+ });
145
+
146
+ test('both script-tree spellings are entrypoints while the compat shim exists', () => {
147
+ // Task 1003705 moves these to scripts/bongos/ and leaves scripts/gds/ holding WORKING
148
+ // forwarders. The driver command is a relative path, so the old spelling still
149
+ // executes; a guard that knew only one name would stop seeing edits to live code.
150
+ for (const tree of ['scripts/gds', 'scripts/bongos']) {
151
+ assert.ok(ec.ENTRYPOINTS.some((p) => p === `${tree}/git-merge-regen.js`),
152
+ `${tree}/git-merge-regen.js must be an entrypoint while both trees can execute`);
153
+ }
154
+ });
155
+
156
+ // ---- the predicate ---------------------------------------------------------
157
+
158
+ test('branchTouchesExecutedCode returns the sorted, deduped intersection', () => {
159
+ const closure = ['.gitattributes', 'src/a.js', 'src/b.js'];
160
+ assert.deepEqual(
161
+ ec.branchTouchesExecutedCode(['src/b.js', 'docs/x.md', 'src/a.js', 'src/b.js'], closure),
162
+ ['src/a.js', 'src/b.js'],
163
+ 'the caller names the offending files in its skip reason, so order and dupes matter');
164
+ assert.deepEqual(ec.branchTouchesExecutedCode(['docs/x.md', 'README.md'], closure), [],
165
+ 'an ordinary branch must not be refused');
166
+ assert.deepEqual(ec.branchTouchesExecutedCode(['', ' ', null], closure), [],
167
+ 'blank lines from git output are not matches');
168
+ });
169
+
170
+ // ---- the guard, and WHEN it fires ------------------------------------------
171
+
172
+ // Minimal git seam. Records every command so the ORDER can be asserted -- that is the
173
+ // whole point of this fix, not merely that a refusal happened.
174
+ function mkExec(touched) {
175
+ const calls = [];
176
+ const exec = async (args) => {
177
+ calls.push(args);
178
+ let a = args;
179
+ if (a[0] === '-C') a = a.slice(2);
180
+ const sub = a.join(' ');
181
+ if (a[0] === 'diff' && sub.includes('origin/main...HEAD')) {
182
+ return { code: 0, stdout: `${touched.join('\n')}\n`, stderr: '' };
183
+ }
184
+ if (a[0] === 'rev-parse') return { code: 0, stdout: 'aaaaaaa\n', stderr: '' };
185
+ if (a[0] === 'grep') return { code: 1, stdout: '', stderr: '' };
186
+ return { code: 0, stdout: '', stderr: '' };
187
+ };
188
+ exec.calls = calls;
189
+ return exec;
190
+ }
191
+
192
+ function mkDeps(touched, over = {}) {
193
+ const exec = mkExec(touched);
194
+ return {
195
+ exec,
196
+ executedClosure: ['.gitattributes', 'src/instance-config.js', 'scripts/gds/git-merge-regen.js'],
197
+ runGenerators: async () => ({ code: 0 }),
198
+ githubPush: {
199
+ isConfigured: () => true,
200
+ isSafeBranch: () => true,
201
+ resolveToken: async () => 'tok',
202
+ buildAuthUrl: () => 'https://x-access-token:REDACTED@github.com/o/r.git',
203
+ },
204
+ repoInfo: { owner: 'o', repo: 'r' },
205
+ registerMergeDriver: () => {},
206
+ repoRoot: '/srv/repo',
207
+ mkdtemp: () => '/tmp/otb-closure-test',
208
+ rmtmp: () => {},
209
+ ...over,
210
+ _exec: exec,
211
+ };
212
+ }
213
+
214
+ const ranMerge = (exec) => exec.calls.some((c) => {
215
+ const a = c[0] === '-C' ? c.slice(2) : c;
216
+ return a[0] === 'merge' && a[1] !== '--abort';
217
+ });
218
+
219
+ for (const [label, fn, okKey] of [
220
+ ['resolveBranchConflicts', (d) => cr.resolveBranchConflicts({ branch: 'task-1' }, d), 'resolved'],
221
+ ['refreshStaleGenerated', (d) => cr.refreshStaleGenerated({ branch: 'task-1' }, d), 'refreshed'],
222
+ ]) {
223
+ test(`${label} refuses a branch that edits code it would execute`, async () => {
224
+ const deps = mkDeps(['docs/x.md', 'src/instance-config.js']);
225
+ const r = await fn(deps);
226
+ assert.equal(r[okKey], false);
227
+ assert.equal(r.skipped, 'branch_modifies_executed_code');
228
+ assert.deepEqual(r.executes, ['src/instance-config.js'],
229
+ 'the refusal must name the file, or nobody can act on it');
230
+ });
231
+
232
+ test(`${label} refuses BEFORE running git merge`, async () => {
233
+ // The assertion that distinguishes this fix from the one the audit prescribed. A
234
+ // post-merge restore would already have run the branch's merge driver by now.
235
+ const deps = mkDeps(['scripts/gds/git-merge-regen.js']);
236
+ const r = await fn(deps);
237
+ assert.equal(r.skipped, 'branch_modifies_executed_code');
238
+ assert.equal(ranMerge(deps._exec), false,
239
+ 'git merge ran despite the refusal — the clone\'s own merge driver would have '
240
+ + 'executed, which is the earlier of the two vectors this task closes');
241
+ });
242
+
243
+ test(`${label} still proceeds for an ordinary branch`, async () => {
244
+ // Red-capability for the two above: if the guard refused everything, they would pass
245
+ // for the wrong reason.
246
+ const deps = mkDeps(['docs/x.md', 'modules/hall-ui/public/shell.js']);
247
+ const r = await fn(deps);
248
+ assert.notEqual(r.skipped, 'branch_modifies_executed_code');
249
+ assert.equal(ranMerge(deps._exec), true, 'an ordinary branch must still be merged');
250
+ });
251
+
252
+ test(`${label} refuses on a .gitattributes edit`, async () => {
253
+ const deps = mkDeps(['.gitattributes']);
254
+ const r = await fn(deps);
255
+ assert.equal(r.skipped, 'branch_modifies_executed_code');
256
+ });
257
+ }
@@ -1763,3 +1763,61 @@ test('1003217 an older deps bundle with no `raised` key still logs on reconciled
1763
1763
  assert.equal(logs.length, 1);
1764
1764
  assert.doesNotMatch(logs[0], /raised/, 'no phantom "raised 0" noise when the key is absent');
1765
1765
  });
1766
+
1767
+ // ---------------------------------------------------------------------------
1768
+ // task 1003367 (audit B1) — the resolver's new refusal must REACH someone.
1769
+ //
1770
+ // Round 1 of this task's grade failed on exactly this: the guard was written, its
1771
+ // comment said "hand the merge to a human", and no call site told anyone. The skip
1772
+ // reason fell through both if/else chains with no log line and no strand flag, so a
1773
+ // PR stuck on it retried silently every ~5 min forever. These pin the announcement,
1774
+ // not the refusal — the refusal itself lives in tests/executed_closure.mjs.
1775
+ // ---------------------------------------------------------------------------
1776
+
1777
+ test('1003367 a branch that edits resolver-executed code flags the strand immediately, naming the file', async () => {
1778
+ const deps = mkDeps({
1779
+ rows: [{ task_id: 620, builder_id: 6, worktree_name: 'wt-exec', title: 'x', security_sensitive: false,
1780
+ confirmed_at: new Date().toISOString() }],
1781
+ mergeByBranch: { 'claude/wt-exec': { merged: false, merged_now: false, number: 620, reason: 'merge_conflict' } },
1782
+ conflictResolveImpl: async () => ({
1783
+ resolved: false, skipped: 'branch_modifies_executed_code', executes: ['src/instance-config.js'],
1784
+ }),
1785
+ });
1786
+ const r = await reconciler.runSweep(deps);
1787
+ assert.equal(deps._calls.flag.length, 1, 'a definitive refusal is flagged on THIS sweep, not left to the stale WARN');
1788
+ assert.equal(deps._calls.flag[0].taskId, 620);
1789
+ assert.match(deps._calls.flag[0].reason, /^strand:branch_modifies_executed_code:src\/instance-config\.js/,
1790
+ 'the reason names the offending file, so the builder knows what to take out of the branch');
1791
+ assert.ok(deps._calls.errors.some((m) => /STRAND task 620/.test(m)),
1792
+ 'and it WARNs — an unannounced refusal is indistinguishable from a resolver that quietly stopped');
1793
+ assert.equal(r.reconciled, 0, 'such a PR still never ships');
1794
+ });
1795
+
1796
+ test('1003367 the regen-heal path announces the same refusal rather than retrying it forever', async () => {
1797
+ const deps = mkDeps({
1798
+ rows: [{ task_id: 621, builder_id: 6, worktree_name: 'wt-exec2', title: 'x', security_sensitive: false,
1799
+ confirmed_at: new Date().toISOString() }],
1800
+ statusByBranch: { 'claude/wt-exec2': PENDING },
1801
+ mergeByBranch: { 'claude/wt-exec2': mergeableRed('failing_check:unit') },
1802
+ refreshStaleImpl: async () => ({
1803
+ refreshed: false, skipped: 'branch_modifies_executed_code', executes: ['.gitattributes'],
1804
+ }),
1805
+ });
1806
+ await reconciler.runSweep(deps);
1807
+ assert.equal(deps._calls.flag.length, 1, 'the second heal path must not be the silent one');
1808
+ assert.match(deps._calls.flag[0].reason, /^strand:branch_modifies_executed_code:\.gitattributes/);
1809
+ });
1810
+
1811
+ test('1003367 an ordinary skip on either path still announces NOTHING', async () => {
1812
+ // Red-capability for the two above: if every skip reason announced, they would pass
1813
+ // for the wrong reason and the "definitive vs transient" distinction would be gone.
1814
+ const deps = mkDeps({
1815
+ rows: [{ task_id: 622, builder_id: 6, worktree_name: 'wt-ok', title: 'x', security_sensitive: false,
1816
+ confirmed_at: new Date().toISOString() }],
1817
+ mergeByBranch: { 'claude/wt-ok': { merged: false, merged_now: false, number: 622, reason: 'merge_conflict' } },
1818
+ conflictResolveImpl: async () => ({ resolved: false, skipped: 'clone_failed' }),
1819
+ });
1820
+ await reconciler.runSweep(deps);
1821
+ assert.equal(deps._calls.flag.length, 0,
1822
+ 'a transient infra miss must keep retrying quietly, not flag the builder');
1823
+ });