@bongos/core 1.19.880 → 1.19.882

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.
@@ -6,6 +6,12 @@
6
6
  // dead-ended on `node scripts/gds/ship.js` at the auto classifier; this guards
7
7
  // against that allow-rule being silently dropped.
8
8
  //
9
+ // task 1004014: every check below runs once per SHELL TOOL. Claude Code's shell tool is
10
+ // `Bash` on a Mac or Linux box and `PowerShell` on a Windows laptop, and a `Bash(...)`
11
+ // rule says nothing about a PowerShell command — so a Windows session met the prompt
12
+ // on every routine read. The list of tools is `.claude/hooks/shell-tools.js`, the same
13
+ // one the guards read: a rule may only exist for a tool git-guard and secret-gate see.
14
+ //
9
15
  // Run: node tests/box_ship_permissions.mjs
10
16
 
11
17
  import { strict as assert } from 'node:assert';
@@ -20,6 +26,7 @@ const settingsPath = path.join(__dirname, '..', '.claude', 'settings.json');
20
26
  skipUnlessPresent('.claude/settings.json');
21
27
  const require = createRequire(import.meta.url);
22
28
  const guard = requireHostModule(require, '../.claude/hooks/git-guard.js');
29
+ const { SHELL_TOOLS } = requireHostModule(require, '../.claude/hooks/shell-tools.js');
23
30
  const { STARTER_ALLOW, bongosify } = require('../scripts/gds/claude-materialize.js');
24
31
 
25
32
  let passed = 0, failed = 0;
@@ -27,26 +34,30 @@ function test(name, fn) {
27
34
  try { fn(); passed++; console.log(` ok ${name}`); }
28
35
  catch (err) { failed++; console.log(` FAIL ${name}\n ${err.message}`); }
29
36
  }
37
+ // One case per shell tool, named for it, so a failure says WHICH tool's rules are wrong.
38
+ function perTool(name, fn) {
39
+ for (const tool of SHELL_TOOLS) test(`[${tool}] ${name}`, () => fn(tool));
40
+ }
30
41
 
31
42
  const raw = fs.readFileSync(settingsPath, 'utf8');
32
43
  let settings;
33
44
  test('.claude/settings.json is valid JSON', () => {
34
45
  settings = JSON.parse(raw);
35
46
  });
47
+ const allowList = () => (settings.permissions && settings.permissions.allow) || [];
36
48
 
37
- test('permissions.allow pre-allows the blessed ship/claim/reauth scripts (box auto-mode would deny them otherwise)', () => {
38
- const allow = (settings.permissions && settings.permissions.allow) || [];
49
+ perTool('permissions.allow pre-allows the blessed ship/claim/reauth scripts (auto-mode would deny them otherwise)', (tool) => {
50
+ const allow = allowList();
39
51
  // The exact command the #1027 nightmare hard-stopped on:
40
- assert.ok(allow.includes('Bash(node scripts/gds/ship.js:*)'),
41
- 'ship.js MUST be pre-allowed — a dev box cannot complete a ship if its own classifier denies the command');
42
- // The rest of the core ship loop a box runs hands-free:
52
+ assert.ok(allow.includes(`${tool}(node scripts/gds/ship.js:*)`),
53
+ 'ship.js MUST be pre-allowed — a session cannot complete a ship if its own classifier denies the command');
54
+ // The rest of the core ship loop a session runs hands-free:
43
55
  for (const s of ['claim', 'reauth', 'release', 'paste-token']) {
44
- assert.ok(allow.includes(`Bash(node scripts/gds/${s}.js:*)`), `${s}.js should be pre-allowed`);
56
+ assert.ok(allow.includes(`${tool}(node scripts/gds/${s}.js:*)`), `${s}.js should be pre-allowed`);
45
57
  }
46
58
  });
47
59
 
48
- test('permissions.allow pre-allows the generic GDS API channel api.js (auto-mode denies the whole autonomous GDS loop otherwise)', () => {
49
- const allow = (settings.permissions && settings.permissions.allow) || [];
60
+ perTool('permissions.allow pre-allows the generic GDS API channel api.js (auto-mode denies the whole autonomous GDS loop otherwise)', (tool) => {
50
61
  // task 1716 (completes 1051): every GDS operation without a dedicated script —
51
62
  // idea-triage promote, create-task, blocker-resolve, criterion ratify, goal
52
63
  // create, cost-via-API, security reports — rides the generic caller
@@ -57,18 +68,18 @@ test('permissions.allow pre-allows the generic GDS API channel api.js (auto-mode
57
68
  // GDS server enforces builders.rank on every request (ADR 0016), so this rule
58
69
  // grants nothing beyond the caller's rank; it removes a redundant, weaker,
59
70
  // wrong harness prompt (owner-decided 2026-07-02, ADR 0104).
60
- assert.ok(allow.includes('Bash(node scripts/gds/api.js:*)'),
71
+ assert.ok(allowList().includes(`${tool}(node scripts/gds/api.js:*)`),
61
72
  'api.js MUST be pre-allowed — the autonomous GDS loop (promote/create-task/blocker-resolve/cost) rides this generic channel; auto-mode denies it otherwise');
62
73
  });
63
74
 
75
+ const SCRIPT_RULE = new RegExp(String.raw`^(${SHELL_TOOLS.join('|')})\(node scripts\/gds\/([a-z-]+)\.js:\*\)$`);
76
+
64
77
  test('every allow-rule points at a script that actually exists (no inert rules)', () => {
65
- const allow = (settings.permissions && settings.permissions.allow) || [];
66
- const re = /^Bash\(node (scripts\/gds\/[a-z-]+\.js):\*\)$/;
67
- for (const rule of allow) {
68
- const m = rule.match(re);
78
+ for (const rule of allowList()) {
79
+ const m = rule.match(SCRIPT_RULE);
69
80
  if (!m) continue; // only audit the gds-script rules
70
- const p = path.join(__dirname, '..', m[1]);
71
- assert.ok(fs.existsSync(p), `allow-rule references a missing script: ${m[1]}`);
81
+ const p = path.join(__dirname, '..', 'scripts', 'gds', `${m[2]}.js`);
82
+ assert.ok(fs.existsSync(p), `allow-rule references a missing script: ${m[2]}.js`);
72
83
  }
73
84
  });
74
85
 
@@ -79,24 +90,32 @@ test('every allow-rule points at a script that actually exists (no inert rules)'
79
90
  // git-guard blocks, and must stay read-only.
80
91
  // ---------------------------------------------------------------------------
81
92
 
82
- // How Claude Code reads a Bash rule: `Bash(x:*)` covers x and anything starting
83
- // "x "; `Bash(x)` covers exactly x. A chained command is covered only when every
84
- // part is, so `git status && git stash` is not covered by a `git status` rule.
85
- function ruleCovers(rule, part) {
86
- const m = rule.match(/^Bash\((.*?)(:\*)?\)$/);
87
- if (!m) return false;
88
- return part === m[1] || (Boolean(m[2]) && part.startsWith(`${m[1]} `));
93
+ // How Claude Code reads a shell rule: `Tool(x:*)` covers x and anything starting
94
+ // "x "; `Tool(x)` covers exactly x. A chained or piped command is covered only when
95
+ // every part is, so `git status && git stash` is not covered by a `git status` rule.
96
+ // PowerShell rules match case-INSENSITIVELY (documented), which matters here because
97
+ // git's own flags do not: `-d` and `-D` are different commands.
98
+ //
99
+ // WHAT THIS MODEL IS NOT. It knows rule shape, separators and case folding — nothing else. It
100
+ // reads `git status $(…)`, `$env:X=…; node …` and `gh pr view 1 > x` as covered, where the real
101
+ // client asks for each whatever the allow-list says (ADR 0304's amendment records where that was
102
+ // read). So no such sample is listed: it would be red here for a command that does prompt.
103
+ function ruleCovers(rule, part, tool) {
104
+ const m = rule.match(/^(\w+)\((.*?)(:\*)?\)$/);
105
+ if (!m || m[1] !== tool) return false;
106
+ const fold = (s) => (tool === 'PowerShell' ? s.toLowerCase() : s);
107
+ return fold(part) === fold(m[2]) || (Boolean(m[3]) && fold(part).startsWith(`${fold(m[2])} `));
89
108
  }
90
- function allowCovers(allow, command) {
109
+ function allowCovers(allow, command, tool) {
91
110
  const parts = command.split(/&&|\|\||[;|&\n]/).map((s) => s.trim()).filter(Boolean);
92
- return parts.every((part) => allow.some((rule) => ruleCovers(rule, part)));
111
+ return parts.every((part) => allow.some((rule) => ruleCovers(rule, part, tool)));
93
112
  }
94
- const isBlocked = (command) => guard.decide({ tool_name: 'Bash', tool_input: { command } }, {}).block;
95
- const isBlockedPwsh = (command) => guard.decide({ tool_name: 'PowerShell', tool_input: { command } }, {}).block;
113
+ const isBlocked = (command, tool) => guard.decide({ tool_name: tool, tool_input: { command } }, {}).block;
114
+ const isBlockedPwsh = (command) => isBlocked(command, 'PowerShell');
96
115
 
97
116
  // At least one sample per git-guard rule, keyed by the rule's own name.
98
117
  const BLOCKED_SAMPLES = {
99
- 'git stash': ['git stash', 'git stash pop', 'git stash push -m wip', 'git status && git stash'],
118
+ 'git stash': ['git stash', 'git stash pop', 'git stash push -m wip', 'git status && git stash', 'git status; git stash'],
100
119
  'git worktree remove': ['git worktree remove ../wt', 'git worktree remove --force ../wt'],
101
120
  'git push origin main': ['git push origin main', 'git push -u origin HEAD:main', 'git push --force origin task-1:main'],
102
121
  'git branch -D': ['git branch -D task-1', 'git branch --list -D task-1', 'git branch -a -D task-1'],
@@ -114,13 +133,20 @@ const BLOCKED_SAMPLES_PWSH = {
114
133
  'git branch -D': ['GIT branch -D task-1', 'Git.exe branch -a -D task-1'],
115
134
  };
116
135
 
117
- test('every git-guard rule has blocked samples here (a new rule forces a new sample)', () => {
136
+ // MERGE NOTE (task 1004052 strand walk). Two rounds built PowerShell coverage from
137
+ // opposite ends and collided here: task 1004014 made every case in this file run once
138
+ // per tool (`perTool`), and task 1004028 added the sample set above, arguing correctly
139
+ // that a Bash-shaped string replayed against PowerShell only re-proves the spelling that
140
+ // already matched. Neither supersedes the other — 1004014 is breadth (eight cases, both
141
+ // tools), 1004028 is depth (one case, in spellings only PowerShell resolves). Both are
142
+ // kept: the per-tool wrapper stays, and the PowerShell-spelled set is asserted inside it.
143
+ perTool('every git-guard rule has blocked samples here (a new rule forces a new sample)', (tool) => {
118
144
  for (const [name] of guard.RULES) {
119
145
  assert.ok((BLOCKED_SAMPLES[name] || []).length, `no blocked sample for git-guard rule "${name}"`);
120
146
  assert.ok((BLOCKED_SAMPLES_PWSH[name] || []).length, `no PowerShell-spelled sample for git-guard rule "${name}"`);
121
147
  }
122
148
  for (const samples of Object.values(BLOCKED_SAMPLES)) {
123
- for (const cmd of samples) assert.ok(isBlocked(cmd), `sample is not actually blocked by git-guard: ${cmd}`);
149
+ for (const cmd of samples) assert.ok(isBlocked(cmd, tool), `sample is not actually blocked by git-guard: ${cmd}`);
124
150
  }
125
151
  for (const samples of Object.values(BLOCKED_SAMPLES_PWSH)) {
126
152
  for (const cmd of samples) {
@@ -140,36 +166,50 @@ test('the PowerShell samples are genuinely different spellings, not a copy', ()
140
166
  }
141
167
  });
142
168
 
143
- test('no allow-rule covers a command git-guard blocks', () => {
144
- const allow = settings.permissions.allow;
169
+ perTool('no allow-rule covers a command git-guard blocks', (tool) => {
170
+ const allow = allowList();
145
171
  for (const rule of allow) {
146
- const text = rule.replace(/^Bash\(/, '').replace(/(:\*)?\)$/, '');
147
- assert.ok(!isBlocked(text), `allow-rule is itself a git-guard-blocked command: ${rule}`);
172
+ const m = rule.match(/^(\w+)\((.*?)(:\*)?\)$/);
173
+ if (m && m[1] === tool) assert.ok(!isBlocked(m[2], tool), `allow-rule is itself a git-guard-blocked command: ${rule}`);
148
174
  }
149
175
  for (const cmd of Object.values(BLOCKED_SAMPLES).flat()) {
150
- assert.ok(!allowCovers(allow, cmd), `an allow-rule covers a git-guard-blocked command: ${cmd}`);
176
+ assert.ok(!allowCovers(allow, cmd, tool), `an allow-rule covers a git-guard-blocked command: ${cmd}`);
151
177
  }
152
178
  });
153
179
 
154
- test('the check has teeth: a too-broad git rule IS caught', () => {
180
+ perTool('the check has teeth: a too-broad git rule IS caught', (tool) => {
155
181
  const samples = Object.values(BLOCKED_SAMPLES).flat();
156
- for (const broad of ['Bash(git:*)', 'Bash(git stash:*)', 'Bash(git worktree:*)', 'Bash(git push:*)', 'Bash(git branch:*)', 'Bash(git branch --list:*)']) {
157
- assert.ok(samples.some((cmd) => allowCovers([broad], cmd)), `${broad} should cover a blocked sample`);
182
+ for (const broad of ['git:*', 'git stash:*', 'git worktree:*', 'git push:*', 'git branch:*', 'git branch --list:*']) {
183
+ assert.ok(samples.some((cmd) => allowCovers([`${tool}(${broad})`], cmd, tool)), `${tool}(${broad}) should cover a blocked sample`);
158
184
  }
159
185
  });
160
186
 
161
- // The whole git/gh allow-list, pinned. A flag can turn a read into a write or an
162
- // exec (`git fetch --upload-pack=<cmd>`, `git fetch origin x:main`, `git branch -D`),
163
- // so `branch` and `fetch` appear only as the exact commands a session runs.
187
+ test('teeth: a rule for one shell tool covers nothing run by the other', () => {
188
+ assert.equal(allowCovers(['Bash(git status:*)'], 'git status', 'PowerShell'), false);
189
+ assert.equal(allowCovers(['PowerShell(git status:*)'], 'git status', 'Bash'), false);
190
+ });
191
+
192
+ test('teeth: a PowerShell rule matches case-insensitively, a Bash rule does not', () => {
193
+ assert.equal(allowCovers(['PowerShell(git branch -d:*)'], 'git branch -D task-1', 'PowerShell'), true,
194
+ 'this is why no PowerShell rule may sit next to a flag whose other case is a different command');
195
+ assert.equal(allowCovers(['Bash(git branch -d:*)'], 'git branch -D task-1', 'Bash'), false);
196
+ });
197
+
198
+ // The whole git/gh allow-list, pinned — once, and carried by every shell tool. A flag
199
+ // can turn a read into a write or an exec (`git fetch --upload-pack=<cmd>`,
200
+ // `git fetch origin x:main`, `git branch -D`), so `branch` and `fetch` appear only as
201
+ // the exact commands a session runs.
164
202
  //
165
203
  // NO `git diff|log|show` rule, on purpose. Claude Code already runs their read-only
166
- // forms without a prompt in every mode and checks their flags; a prefix rule would
167
- // switch that check off (see WRITES_DRESSED_AS_READS below — both were reproduced).
168
- const GIT_GH_RULES = [
169
- 'Bash(git status:*)',
170
- 'Bash(git branch --list)', 'Bash(git branch --show-current)', 'Bash(git branch -a)', 'Bash(git branch -vv)',
171
- 'Bash(git fetch)', 'Bash(git fetch origin)', 'Bash(git fetch origin main)',
172
- 'Bash(gh pr view:*)', 'Bash(gh pr checks:*)', 'Bash(gh pr list:*)', 'Bash(gh run view:*)', 'Bash(gh run list:*)',
204
+ // forms without a prompt and checks their flags; a prefix rule would switch that check
205
+ // off (see WRITES_DRESSED_AS_READS below — both were reproduced). That holds for the
206
+ // PowerShell tool too: it has its own built-in read-only handling, and an allow-rule is
207
+ // matched before it.
208
+ const GIT_GH_COMMANDS = [
209
+ 'git status:*',
210
+ 'git branch --list', 'git branch --show-current', 'git branch -a', 'git branch -vv',
211
+ 'git fetch', 'git fetch origin', 'git fetch origin main',
212
+ 'gh pr view:*', 'gh pr checks:*', 'gh pr list:*', 'gh run view:*', 'gh run list:*',
173
213
  ];
174
214
  // gds CLIs audited read-only under EVERY flag they take — a prefix rule allows them all.
175
215
  // Audited and left OUT: backlog-review.js (POSTs from its default walk) and
@@ -178,12 +218,44 @@ const READ_ONLY_CLIS = ['start', 'status', 'recall', 'me', 'doctor', 'preflight'
178
218
  'ship-check', 'fitness', 'strand-watch', 'stale-worktrees', 'read-session-export'];
179
219
  const BLESSED_WRITERS = ['ship', 'claim', 'reauth', 'release', 'paste-token', 'api']; // tasks 1051 / 1716
180
220
 
181
- const SCRIPT_RULE = /^Bash\(node scripts\/gds\/([a-z-]+)\.js:\*\)$/;
182
-
183
- test('every allow-rule that is not a gds script is in the pinned git/gh set (adding one is a deliberate edit here)', () => {
221
+ perTool('every allow-rule that is not a gds script is in the pinned git/gh set (adding one is a deliberate edit here)', (tool) => {
184
222
  // Catches `Bash(gh:*)`, `Bash(gh *)`, `Bash(git -C x status:*)`, a bare `Bash` — anything unlisted.
185
- const rest = settings.permissions.allow.filter((rule) => !SCRIPT_RULE.test(rule));
186
- assert.deepEqual([...rest].sort(), [...GIT_GH_RULES].sort());
223
+ const rest = allowList().filter((rule) => rule.startsWith(`${tool}(`) || rule === tool).filter((rule) => !SCRIPT_RULE.test(rule));
224
+ assert.deepEqual([...rest].sort(), GIT_GH_COMMANDS.map((c) => `${tool}(${c})`).sort());
225
+ });
226
+
227
+ test('every allow-rule belongs to a shell tool the guards see, and every tool carries the SAME list', () => {
228
+ // A rule for a tool outside SHELL_TOOLS would pre-allow commands git-guard and
229
+ // secret-gate never look at — allowing first and guarding second (ADR 0304).
230
+ const byTool = new Map(SHELL_TOOLS.map((tool) => [tool, []]));
231
+ for (const rule of allowList()) {
232
+ const m = rule.match(/^(\w+)\((.*)\)$/);
233
+ assert.ok(m && byTool.has(m[1]), `allow-rule is not for a guarded shell tool: ${rule}`);
234
+ byTool.get(m[1]).push(m[2]);
235
+ }
236
+ const [first, ...others] = SHELL_TOOLS;
237
+ for (const tool of others) {
238
+ assert.deepEqual([...byTool.get(tool)].sort(), [...byTool.get(first)].sort(),
239
+ `${tool} and ${first} must pre-allow the same commands — a twin that drifts is a prompt on one machine only`);
240
+ }
241
+ });
242
+
243
+ test('the RESTRICTIVE lists are twinned too: a shell command denied for one tool is denied for every tool', () => {
244
+ // The half that matters more. A deny with no twin is a hard stop on a Linux box and nothing at
245
+ // all on a Windows laptop — and git-guard, the only other barrier, is cooperative and fails open.
246
+ const shellRule = new RegExp(String.raw`^(${SHELL_TOOLS.join('|')})\((.*)\)$`);
247
+ for (const key of ['deny', 'ask']) {
248
+ const byTool = new Map(SHELL_TOOLS.map((tool) => [tool, []]));
249
+ for (const rule of settings.permissions[key] || []) {
250
+ const m = rule.match(shellRule);
251
+ if (m) byTool.get(m[1]).push(m[2]); // Read(...) and the rest are not shell rules
252
+ }
253
+ const [first, ...others] = SHELL_TOOLS;
254
+ for (const tool of others) {
255
+ assert.deepEqual([...byTool.get(tool)].sort(), [...byTool.get(first)].sort(), `permissions.${key}: ${tool} vs ${first}`);
256
+ }
257
+ }
258
+ assert.ok(settings.permissions.deny.includes('PowerShell(git stash:*)'), 'the one shell deny this repo carries');
187
259
  });
188
260
 
189
261
  // "Read" commands whose flags write a file, read one the Read deny-list protects,
@@ -195,36 +267,45 @@ const WRITES_DRESSED_AS_READS = [
195
267
  'git diff --no-index -- /dev/null .env',
196
268
  'git fetch --upload-pack=./x origin',
197
269
  'git fetch origin task-1:main',
270
+ // a read piped or chained into something that writes or executes: every part must be covered
271
+ 'git status | Out-File -FilePath .git/hooks/pre-push',
272
+ 'gh pr view 1 --json body -q .body | Set-Content .git/hooks/pre-push',
273
+ 'gh pr view 1 --json body -q .body | Invoke-Expression',
274
+ 'gh pr list | iex',
275
+ 'git status; Remove-Item -Recurse -Force src',
276
+ 'gh pr view 1 | tee .git/hooks/pre-push',
198
277
  ];
199
278
 
200
- test('no allow-rule lets a write dressed as a read through (--output, --no-index, --upload-pack, a refspec)', () => {
279
+ perTool('no allow-rule lets a write dressed as a read through (--output, --no-index, --upload-pack, a refspec, a pipe into a writer)', (tool) => {
201
280
  for (const cmd of WRITES_DRESSED_AS_READS) {
202
- assert.ok(!allowCovers(settings.permissions.allow, cmd), `runs without a prompt: ${cmd}`);
281
+ assert.ok(!allowCovers(allowList(), cmd, tool), `runs without a prompt: ${cmd}`);
203
282
  }
204
283
  // Teeth: the prefix rules this list deliberately does NOT carry would let them through.
205
- const tempting = ['Bash(git log:*)', 'Bash(git show:*)', 'Bash(git diff:*)', 'Bash(git fetch:*)'];
206
- for (const cmd of WRITES_DRESSED_AS_READS) assert.ok(allowCovers(tempting, cmd), `teeth: ${cmd}`);
284
+ const tempting = ['git log:*', 'git show:*', 'git diff:*', 'git fetch:*', 'Out-File:*', 'Set-Content:*',
285
+ 'Invoke-Expression:*', 'iex:*', 'Remove-Item:*', 'tee:*'].map((c) => `${tool}(${c})`);
286
+ for (const cmd of WRITES_DRESSED_AS_READS) {
287
+ assert.ok(allowCovers([...allowList(), ...tempting], cmd, tool), `teeth: ${cmd}`);
288
+ }
207
289
  });
208
290
 
209
291
  test('every gds-script allow-rule is a blessed writer or an audited read-only CLI', () => {
210
292
  const known = new Set([...BLESSED_WRITERS, ...READ_ONLY_CLIS]);
211
- for (const rule of settings.permissions.allow) {
293
+ for (const rule of allowList()) {
212
294
  const m = rule.match(SCRIPT_RULE);
213
- if (m) assert.ok(known.has(m[1]), `${m[1]}.js is pre-allowed but not audited here — a prefix rule allows every flag it takes`);
295
+ if (m) assert.ok(known.has(m[2]), `${m[2]}.js is pre-allowed but not audited here — a prefix rule allows every flag it takes`);
214
296
  }
215
297
  });
216
298
 
217
- test('the read-only subset is pre-allowed', () => {
218
- const allow = settings.permissions.allow;
299
+ perTool('the read-only subset is pre-allowed', (tool) => {
219
300
  for (const cmd of ['git status', 'git status --short', 'git branch --list', 'git fetch origin',
220
301
  'gh pr view 922', 'gh pr checks 922', 'gh pr list', 'gh run view 1 --log-failed', 'gh run list --limit 5',
221
302
  ...READ_ONLY_CLIS.map((s) => `node scripts/gds/${s}.js`)]) {
222
- assert.ok(allowCovers(allow, cmd), `should run without a prompt: ${cmd}`);
303
+ assert.ok(allowCovers(allowList(), cmd, tool), `should run without a prompt: ${cmd}`);
223
304
  }
224
305
  });
225
306
 
226
307
  test('the instance starter mirrors this list in verb form (claude-materialize.js STARTER_ALLOW)', () => {
227
- assert.deepEqual([...STARTER_ALLOW].sort(), settings.permissions.allow.map((rule) => bongosify(rule)).sort());
308
+ assert.deepEqual([...STARTER_ALLOW].sort(), allowList().map((rule) => bongosify(rule)).sort());
228
309
  });
229
310
 
230
311
  console.log('');
@@ -766,19 +766,23 @@ t('every allow-rule names a verb the CLI actually registers', () => {
766
766
  // The drift this catches: a rule naming a verb that was renamed or never existed
767
767
  // is silently inert — it looks like a permission and grants nothing, so the
768
768
  // builder still meets the classifier and nobody can see why.
769
+ const checkedByTool = {};
769
770
  for (const rule of m.STARTER_ALLOW) {
770
- if (!rule.startsWith('Bash(bongos ')) continue; // the git/gh reads (task 1003992) name no verb
771
- const inner = rule.replace(/^Bash\(/, '').replace(/:\*\)$/, '');
771
+ const [, tool, inner] = rule.match(/^(\w+)\((.*?)(?::\*)?\)$/); // any shell tool (task 1004014)
772
+ if (!inner.startsWith('bongos ')) continue; // the git/gh reads (task 1003992) name no verb
772
773
  const verb = inner.split(' ')[1];
773
774
  assert.ok(cliVerbs[verb], `allow-rule "${rule}" names "${verb}", which is not a registered bongos verb`);
775
+ checkedByTool[tool] = (checkedByTool[tool] || 0) + 1;
774
776
  }
777
+ assert.ok(checkedByTool.Bash > 0, 'the loop checked something');
778
+ assert.equal(checkedByTool.PowerShell, checkedByTool.Bash, 'a Windows session gets every verb a Bash one does');
775
779
  });
776
780
 
777
781
  t('the escape hatch is NOT blanket-allowed', () => {
778
782
  // `bongos exec` runs any script inside the core by path. Allow-listing it bare
779
783
  // would be a blanket grant wearing a narrow rule's clothes.
780
- assert.ok(!m.STARTER_ALLOW.includes('Bash(bongos exec:*)'),
781
- 'bongos exec must never be allowed bare');
784
+ assert.deepEqual(m.STARTER_ALLOW.filter((r) => /^\w+\(bongos exec(:\*| \*)?\)$/.test(r)), [],
785
+ 'bongos exec must never be allowed bare — under any shell tool');
782
786
  const execRules = m.STARTER_ALLOW.filter((r) => r.includes('bongos exec'));
783
787
  for (const r of execRules) {
784
788
  assert.match(r, /bongos exec scripts\/gds\/[a-z-]+\.js/,
@@ -30,6 +30,7 @@ const TESTS_DIR = path.dirname(fileURLToPath(import.meta.url));
30
30
  // Every test file permitted to skip itself wholesale, with the topology it needs.
31
31
  // Adding a line here is a deliberate, reviewable act — that is the point.
32
32
  const ALLOWED = {
33
+ allow_rule_guard: '.claude/hooks/allow-rule-guard.js (package-excluded)',
33
34
  art_stage: 'scripts/gds/art-stage.js (art-pipeline tooling, not in the core)',
34
35
  bongos_downloads: 'config/bongos-cli.json (instance content)',
35
36
  box_dns_repoint: 'infra/box-dns-cloudflare.sh (host topology)',
package/tests/init.mjs CHANGED
@@ -204,16 +204,18 @@ test('versionPayload: explicit criterion_id is slugified', () => {
204
204
  assert.equal(init.versionPayload(spec).criteria[0].criterion_id, 'prove-the-loop');
205
205
  });
206
206
 
207
- // versions.track is NOT NULL with no DB default — leaving it off the payload is
208
- // exactly the 1.18.18 bug (every POST /versions 500ed, blocking init outright).
209
- test('versionPayload: always sends an explicit, valid track', () => {
210
- assert.equal(init.versionPayload(fullSpec()).track, 'product'); // instance's first version = product
211
- const internal = fullSpec();
212
- internal.version.track = 'internal';
213
- assert.equal(init.versionPayload(internal).track, 'internal'); // explicit internal respected
214
- const junk = fullSpec();
215
- junk.version.track = 'bogus';
216
- assert.equal(init.versionPayload(junk).track, 'product'); // junk coerced, never forwarded
207
+ // The inverse of the case this replaces. That case pinned "always send an
208
+ // explicit track", guarding the 1.18.18 bug where omitting it 500ed every
209
+ // POST /versions against a NOT NULL column with no default. core_205 gave the
210
+ // column DEFAULT 'product' and task 1003665 retired the concept, so the payload
211
+ // must now NOT carry it — and a spec that still names one must not smuggle it
212
+ // through to a route that no longer accepts the field.
213
+ test('versionPayload: sends no track, and drops one a spec still names', () => {
214
+ assert.equal('track' in init.versionPayload(fullSpec()), false);
215
+ const legacy = fullSpec();
216
+ legacy.version.track = 'internal';
217
+ assert.equal('track' in init.versionPayload(legacy), false,
218
+ 'an old spec file cannot reintroduce the field');
217
219
  });
218
220
 
219
221
  // ---- provisioning arm (ADR 0111 §3, task 1946) ----
@@ -661,12 +663,13 @@ async function runSeedTests() {
661
663
  assert.ok(findTaskPost(api, /Production topology/), 'filed the manual fallback task on error');
662
664
  });
663
665
 
664
- await at('seedViaApi: version POST carries the explicit track (the NOT NULL column)', async () => {
666
+ await at('seedViaApi: the version POST carries NO track (task 1003665)', async () => {
665
667
  const api = fakeApi();
666
668
  await init.seedViaApi(provSpec({}), { log: () => {}, api });
667
669
  const vpost = api.calls.find((c) => c.path.endsWith('/versions'));
668
670
  assert.ok(vpost, 'POSTed the version');
669
- assert.equal(vpost.body.track, 'product');
671
+ assert.equal('track' in vpost.body, false,
672
+ 'POST /versions no longer accepts the field; the DB default supplies the column');
670
673
  });
671
674
 
672
675
  await at('seedViaApi: the ideator kickoff task POSTs kind=decision + discipline=ideator', async () => {
@@ -59,16 +59,21 @@ t('dbCreateCmd / migrateCmd use the slug db + GDS-only migrate', () => {
59
59
  assert.match(P.migrateCmd(coTenant), /PGDATABASE=mercury MEDUSA_GDS_ONLY=1 \.\/scripts\/migrate\.sh/);
60
60
  });
61
61
 
62
- t('seedFirstVersionCmd: idempotent (only-if-empty) V1 seed against the slug db, track explicit, one criterion — the fix for the empty-versions bug (task 2119)', () => {
62
+ t('seedFirstVersionCmd: idempotent (only-if-empty) V1 seed against the slug db, no track, one criterion — the fix for the empty-versions bug (task 2119)', () => {
63
63
  const cmd = P.seedFirstVersionCmd(coTenant);
64
64
  // targets the instance's OWN db, matching createdb/migrate
65
65
  assert.match(cmd, /psql .*-d mercury /, 'seeds the instance db');
66
66
  assert.match(cmd, /ON_ERROR_STOP=1/, 'aborts on any error (atomic, no half-seed)');
67
67
  // the version the greenfield init spec defines — ONE source of truth
68
68
  const v = P.instanceInitSpec(coTenant).version;
69
- assert.match(cmd, /INSERT INTO versions \(id, name, track, status, done_when\)/);
69
+ assert.match(cmd, /INSERT INTO versions \(id, name, status, done_when\)/);
70
70
  assert.match(cmd, new RegExp(`\\$v\\$${reEsc(v.id)}\\$v\\$`), 'seeds the spec version id (V1)');
71
- assert.match(cmd, /\$v\$product\$v\$/, 'track is set EXPLICITLY (versions.track is NOT NULL, no default since mig 112)');
71
+ // task 1003665 retired the two-track model. The seed names no track: it runs on
72
+ // a freshly-MIGRATED database, so core_205's DEFAULT 'product' fills the still-
73
+ // NOT NULL column. If that default is ever dropped, THIS seed 500s first and a
74
+ // new instance cannot stand up — which is why the assertion is stated that way.
75
+ assert.doesNotMatch(cmd, /\btrack\b/, 'the seed names no track column');
76
+ assert.doesNotMatch(cmd, /\$v\$product\$v\$/, 'nor smuggles its value in positionally');
72
77
  assert.match(cmd, /\$v\$building\$v\$/, 'status building, mirroring seedViaApi');
73
78
  assert.ok(cmd.includes(v.done_when), 'carries the spec done_when verbatim (incl. the → arrows)');
74
79
  // the IDEMPOTENT guard — only seeds when there is NO version yet, so it never disturbs an existing project
@@ -57,7 +57,9 @@ function fakeDb({ goalRow = { id: 77, title: 'Foundations' } } = {}) {
57
57
  query: async (sql, params) => {
58
58
  log.push({ sql, params });
59
59
  if (/^\s*(BEGIN|COMMIT|ROLLBACK)/i.test(sql)) return { rows: [] };
60
- if (/INSERT INTO versions/i.test(sql)) return { rows: [{ id: params[0], name: params[1], status: params[2], track: params[3] }] };
60
+ // params are positional: (id, name, status, scope_doc_path, done_when).
61
+ // `track` sat at index 3 until task 1003665 removed it from the INSERT.
62
+ if (/INSERT INTO versions/i.test(sql)) return { rows: [{ id: params[0], name: params[1], status: params[2] }] };
61
63
  if (/INSERT INTO goals/i.test(sql)) return { rows: [goalRow] };
62
64
  if (/INSERT INTO done_when_criteria/i.test(sql)) {
63
65
  return { rows: [{ id: log.length, version_id: params[0], criterion_id: params[1], criterion_md: params[2], sort_order: params[3], goal_id: params[4], satisfied: false }] };
@@ -83,7 +85,7 @@ const CRITERIA = [
83
85
  console.log('createVersion — the version, its first goal, and its criteria:');
84
86
 
85
87
  const out = await createVersion({
86
- id: 'V1', name: 'Foundations', status: 'building', track: 'product',
88
+ id: 'V1', name: 'Foundations', status: 'building',
87
89
  doneWhen: 'The loop closes.', criteria: CRITERIA, createdBy: 7,
88
90
  }, { pool: db.pool });
89
91
 
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { strict as assert } from 'node:assert';
12
12
  import { createRequire } from 'node:module';
13
+ import fs from 'node:fs';
13
14
 
14
15
  const require = createRequire(import.meta.url);
15
16
  const { validate } = require('../src/bongos/routes/_helpers.js');
@@ -23,12 +24,14 @@ function test(name, fn) {
23
24
  }
24
25
 
25
26
  // The EXACT schema declared in versions.js → POST /versions validateOrRespond().
26
- // Keep in sync if the route's schema changes.
27
+ // Keep in sync if the route's schema changes — and note that "keep in sync" is a
28
+ // promise a copy cannot keep on its own, so the track case below reads the
29
+ // ROUTE SOURCE rather than this object. A copy that still declared `track` would
30
+ // happily prove the old contract forever.
27
31
  const VERSIONS_POST_SCHEMA = {
28
32
  id: { required: true, type: 'string', maxLength: 64, minLength: 1 },
29
33
  name: { required: true, type: 'string', maxLength: 200, minLength: 1 },
30
34
  status: { type: 'string', maxLength: 32 },
31
- track: { type: 'string', maxLength: 16 },
32
35
  done_when: { type: 'string', maxLength: 8000 },
33
36
  scope_doc_path: { type: 'string', maxLength: 256 },
34
37
  criteria: { type: 'array', maxItems: 100 },
@@ -75,18 +78,40 @@ test('INIT: unknown extra fields pass through (forward-compat)', () => {
75
78
  assert.equal(validate({ id: 'V1', name: 'x', future_field: 42 }, VERSIONS_POST_SCHEMA), null);
76
79
  });
77
80
 
78
- // versions.track is NOT NULL with no DB default (migration 003) — the create
79
- // path must accept it so callers can choose, and default it so omitting it can
80
- // never again 500 the route (the @bongos/core 1.18.18 `bongos init` blocker).
81
- test('INIT: explicit track passes the schema guard (both tracks)', () => {
82
- assert.equal(validate({ id: 'V1', name: 'x', track: 'product' }, VERSIONS_POST_SCHEMA), null);
83
- assert.equal(validate({ id: 'GDS-V5', name: 'x', track: 'internal' }, VERSIONS_POST_SCHEMA), null);
81
+ // The two-track (product/internal) model was retired by task 1002006 and its code
82
+ // unwound by task 1003665: role/discipline is the sole work-type axis. These read
83
+ // the ROUTE'S OWN SOURCE, not the copied schema above — the point is that the
84
+ // concept is gone from the file that enforces it.
85
+ //
86
+ // What made the removal safe, and what would make it unsafe again: versions.track
87
+ // is still NOT NULL, so an INSERT omitting it is legal ONLY because core_205 gave
88
+ // the column DEFAULT 'product'. Drop that default and every create 500s. The
89
+ // comments this task deleted asserted the opposite ("no DB default") and were
90
+ // stale from core_205 onward — which is precisely why five insert paths went on
91
+ // supplying a value nothing read.
92
+ const routeSrc = fs.readFileSync(
93
+ new URL('../modules/lifecycle/routes/versions.js', import.meta.url), 'utf8');
94
+
95
+ test('the route no longer knows what a track is', () => {
96
+ for (const gone of ['VALID_VERSION_TRACKS', 'bad_track']) {
97
+ assert.ok(!routeSrc.includes(gone), `${gone} is gone from POST /versions`);
98
+ }
99
+ assert.ok(!/\btrack\b/.test(routeSrc.replace(/tracked|tracking/g, '')),
100
+ 'no track identifier survives in the versions route');
84
101
  });
85
102
 
86
- test('BAD: non-string track → structured 400 shape, not a DB 500', () => {
87
- const r = validate({ id: 'V1', name: 'x', track: 42 }, VERSIONS_POST_SCHEMA);
88
- assert.ok(r);
89
- assert.ok(r.details.some((d) => d.field === 'track'));
103
+ test('a version is created WITHOUT a track, and that is the whole contract', () => {
104
+ // The create shape `bongos init` and provision-repo.js now send.
105
+ assert.equal(validate({ id: 'V1', name: 'x' }, VERSIONS_POST_SCHEMA), null);
106
+ assert.equal(validate({ id: 'GDS-V5', name: 'x', status: 'planning' }, VERSIONS_POST_SCHEMA), null);
107
+ });
108
+
109
+ test('the INSERT does not name the column either', () => {
110
+ const dbSrc = fs.readFileSync(
111
+ new URL('../modules/lifecycle/db-versions.js', import.meta.url), 'utf8');
112
+ const insert = dbSrc.slice(dbSrc.indexOf('INSERT INTO versions'));
113
+ assert.ok(!/^[^`]*\btrack\b/.test(insert.slice(0, insert.indexOf('`'))),
114
+ 'INSERT INTO versions names no track column — the DB default supplies it');
90
115
  });
91
116
 
92
117
  // ---- BAD shapes must return a structured 400, never throw / 500 ----