@bongos/core 1.19.881 → 1.19.883

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.
Files changed (40) hide show
  1. package/.bongos-core.json +63 -58
  2. package/clients/bongos-client/index.d.ts +2 -2
  3. package/docs/adr/0304-routine-reads-run-without-a-prompt-and-anything-a-flag-can-turn-into-a-write-is-exact-form.md +79 -4
  4. package/docs/adr/0309-durable-module-enablement-db-override.md +116 -0
  5. package/docs/adr/README.md +1 -0
  6. package/docs/api/openapi.json +6 -0
  7. package/docs/copy-inventory.md +24 -23
  8. package/docs/copy-registry.json +34 -25
  9. package/docs/file-map.md +2 -2
  10. package/docs/module-api-changelog.md +4 -0
  11. package/docs/recipes/bongos-cli-release.md +1 -1
  12. package/docs/recipes/managed-settings-remote-control.md +1 -1
  13. package/migrations/core_248_module_enablement_overrides.sql +50 -0
  14. package/modules/dev-box/app/README.md +4 -3
  15. package/modules/hall-ui/public/collab-lib.js +33 -1
  16. package/modules/hall-ui/public/collab.css +33 -4
  17. package/modules/hall-ui/public/collab.html +9 -2
  18. package/modules/hall-ui/public/collab.js +68 -15
  19. package/modules/hall-ui/public/dom-utils.js +23 -1
  20. package/package-lock.json +2 -2
  21. package/package.json +1 -1
  22. package/scripts/gds/claude-materialize.js +39 -34
  23. package/scripts/hall-preview/server.js +38 -1
  24. package/src/bongos/module-overrides.js +117 -0
  25. package/src/bongos/routes/modules.js +56 -23
  26. package/src/module-api.js +1 -1
  27. package/src/modules.js +120 -14
  28. package/src/platform-server.js +47 -5
  29. package/tests/allow_rule_guard.mjs +192 -0
  30. package/tests/box_ship_permissions.mjs +143 -62
  31. package/tests/box_sweep_docs.mjs +7 -3
  32. package/tests/claude_materialize.mjs +8 -4
  33. package/tests/collab_page.mjs +136 -1
  34. package/tests/host_topology_skips.mjs +1 -0
  35. package/tests/modules.mjs +110 -0
  36. package/tests/modules_route_wiring.mjs +103 -0
  37. package/docs/recipes/builder-box-lifecycle.md +0 -181
  38. package/docs/recipes/devbox-app-release.md +0 -103
  39. package/docs/recipes/devbox-app-windows-test.md +0 -128
  40. package/docs/recipes/work-on-a-task-from-your-dev-box.md +0 -122
@@ -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('');
@@ -35,7 +35,11 @@ const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
35
35
  const BOX_JS = 'scripts/gds/box.js';
36
36
  const IDLE_SKILL = '.claude/scheduled-tasks/box-idle-suspend/SKILL.md';
37
37
  const DORMANT_SKILL = '.claude/scheduled-tasks/box-dormant-reclaim/SKILL.md';
38
- const RECIPE = 'docs/recipes/builder-box-lifecycle.md';
38
+ // The fourth surface this guard used to read, docs/recipes/builder-box-lifecycle.md,
39
+ // was deleted with the other three published dev-box recipes (task 1004013,
40
+ // ADR 0305: a recipe lives with what it documents). A file that does not exist
41
+ // cannot invert against box.js, so it drops out of the lists below rather than
42
+ // weakening them — the two SKILL.md surfaces are still read in full.
39
43
 
40
44
  // Comments are stripped before scanning prose: each surface deliberately keeps a
41
45
  // sentence SAYING which model it used to describe, and a naive substring check
@@ -76,7 +80,7 @@ test('no box-sweep doc claims the IDLE sweep destroys, while the code parks', ()
76
80
  /single job is to DESTROY/i,
77
81
  ];
78
82
 
79
- for (const file of [IDLE_SKILL, DORMANT_SKILL, RECIPE]) {
83
+ for (const file of [IDLE_SKILL, DORMANT_SKILL]) {
80
84
  const prose = stripHtmlComments(read(file));
81
85
  for (const lie of lies) {
82
86
  assert.doesNotMatch(prose, lie,
@@ -107,7 +111,7 @@ test('every surface names sweep-dormant as the only automated snapshot delete',
107
111
  // The safety property box.js implements in prose: full reclaim warns first, so
108
112
  // no automated path takes uncommitted work silently. A reader who misses this
109
113
  // is the reader who reaches for deprovision.
110
- for (const file of [IDLE_SKILL, DORMANT_SKILL, RECIPE]) {
114
+ for (const file of [IDLE_SKILL, DORMANT_SKILL]) {
111
115
  assert.match(stripHtmlComments(read(file)), /only automated path that destroys a snapshot|only automated snapshot delete/i,
112
116
  `${file} never tells the reader that sweep-dormant is the only automated path that `
113
117
  + 'destroys a snapshot (and that it warns first) — the safety property this subsystem rests on.');
@@ -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/,
@@ -23,6 +23,16 @@ function lib() {
23
23
  new Function('window', src)(win);
24
24
  return win.OTBCollabLib;
25
25
  }
26
+
27
+ // The role vocabulary is pure too, so RUN it for the same reason — a grep over
28
+ // roleWord()'s source would agree with a roleWord() that was wrong.
29
+ function domUtils() {
30
+ const src = read('modules', 'hall-ui', 'public', 'dom-utils.js');
31
+ const win = { location: { search: '', hash: '', pathname: '/' }, addEventListener() {}, localStorage: null };
32
+ const doc = { addEventListener() {}, querySelector: () => null, querySelectorAll: () => [], documentElement: { dataset: {} } };
33
+ new Function('window', 'document', src)(win, doc);
34
+ return win.OTB;
35
+ }
26
36
  const NOW = Date.parse('2026-08-24T12:00:00Z');
27
37
  const daysAgo = (d) => new Date(NOW - d * 86400000).toISOString();
28
38
 
@@ -71,8 +81,75 @@ test('ageing changes how a row LOOKS, not only where it sits', () => {
71
81
  test('the row markup carries the band class and the label', () => {
72
82
  const js = read('modules', 'hall-ui', 'public', 'collab.js');
73
83
  assert.match(js, /cb-age cb-age--\$\{band\.id\}/, 'the pill\'s class comes from the band');
74
- assert.match(js, /escapeHtml\(band\.label\)/, 'the label is rendered, escaped');
84
+ assert.match(js, /escapeHtml\(L\.ageLabel\(req\.created_at, nowMs\)\)/, 'the label is rendered, escaped');
75
85
  assert.match(js, /L\.ageBand\(req\.created_at, nowMs\)/, 'through the lib, so the boundaries are the tested ones');
86
+ // THE BAND AND THE LABEL ARE TWO SOURCES ON PURPOSE (task 1004041). Until then
87
+ // the pill rendered `band.label`, so the band decided both how loud the row
88
+ // looked AND what it said — which is why fourteen rows past a week carried
89
+ // fourteen copies of "waiting over a week". The class still comes from the
90
+ // band (four treatments, readable down a column) and the words now come from
91
+ // ageLabel (the real age, different on every row). Asserting only one of the
92
+ // two would let them be collapsed back onto each other.
93
+ assert.ok(!/escapeHtml\(band\.label\)/.test(js), 'the words are the real age, not the band name again');
94
+ });
95
+
96
+ test('two rows in the same band do not read identically', () => {
97
+ // The artist's finding, as an executable claim: a signal that never varies is
98
+ // not a signal. Every one of these is band 'stale', and no two say the same
99
+ // thing.
100
+ const L = lib();
101
+ const said = [8, 9, 13, 21, 40, 400].map((d) => {
102
+ assert.equal(L.ageBand(daysAgo(d), NOW).id, 'stale', `${d} days is stale`);
103
+ return L.ageLabel(daysAgo(d), NOW);
104
+ });
105
+ assert.equal(new Set(said).size, said.length, `six stale rows, six different readings: ${said.join(' / ')}`);
106
+ });
107
+
108
+ test('the age label says how long, coarsely, and never accuses an unknown age', () => {
109
+ const L = lib();
110
+ assert.equal(L.ageLabel(daysAgo(0.4), NOW), 'just asked');
111
+ assert.equal(L.ageLabel(daysAgo(1), NOW), 'waiting a day');
112
+ assert.equal(L.ageLabel(daysAgo(4), NOW), 'waiting 4 days');
113
+ assert.equal(L.ageLabel(daysAgo(13), NOW), 'waiting 13 days');
114
+ // Past a fortnight it goes to weeks: 'waiting 38 days' is a precision nobody
115
+ // acts on, the same argument the bands were written on.
116
+ assert.equal(L.ageLabel(daysAgo(21), NOW), 'waiting 3 weeks');
117
+ for (const bad of [null, undefined, '', 'nonsense']) {
118
+ assert.equal(L.ageLabel(bad, NOW), 'just asked', `${JSON.stringify(bad)} must not look neglected`);
119
+ }
120
+ });
121
+
122
+ test('the two calm bands are not chips, and the date is not said twice', () => {
123
+ // The chip appears where there is something to say. All four bands keep their
124
+ // own ink (the test above), but fresh and waiting drop the pill's ring, so a
125
+ // board of healthy asks does not open as a wall of chips.
126
+ const css = read('modules', 'hall-ui', 'public', 'collab.css');
127
+ assert.match(
128
+ css,
129
+ /\.cb-age--fresh,\s*\n\.cb-age--waiting\s*\{[^}]*border-color: transparent/,
130
+ 'the calm bands read as quiet text, not as chips',
131
+ );
132
+ // And the separate date line is gone: the pill carries the age in words and
133
+ // the exact timestamp on its title, so a date beside it was one fact twice.
134
+ const js = read('modules', 'hall-ui', 'public', 'collab.js');
135
+ assert.ok(!/ov-row__when/.test(js), 'no second rendering of the same fact');
136
+ assert.match(js, /title="Asked \$\{escapeHtml\(String\(req\.created_at/, 'the exact date is still reachable');
137
+ });
138
+
139
+ test('the alarm does not sit beside the accent control', () => {
140
+ // DESIGN.md spends the accent only on what is pressable or live. On "Your
141
+ // asks" the age pill (danger ink) used to share the right column with the
142
+ // solid-accent "Someone helped" button, so a warning and a control sat two
143
+ // centimetres apart in one colour family. The age is a FACT about the ask now;
144
+ // the right column on that row holds only what you can press.
145
+ const js = read('modules', 'hall-ui', 'public', 'collab.js');
146
+ const mine = js.slice(js.indexOf('function mineRow('), js.indexOf('function allRow('));
147
+ assert.ok(mine.length > 100, 'found mineRow');
148
+ const right = mine.slice(mine.indexOf('ov-row__right'));
149
+ assert.ok(!right.includes('ageChip'), 'no age pill in the column that holds the accent button');
150
+ assert.ok(right.includes('btn-accent'), 'the accent button is still there');
151
+ const facts = mine.slice(mine.indexOf('ov-row__facts'), mine.indexOf('ov-row__right'));
152
+ assert.ok(facts.includes('ageChip'), 'the age reads as a fact about the ask');
76
153
  });
77
154
 
78
155
  test('oldest first WITHIN the page, in addition to the band', () => {
@@ -264,6 +341,64 @@ test('the page is the three blocks, led by faces, with the ledes gone', () => {
264
341
  assert.match(html, /class="cb-foot"/, 'and it is the foot, not the opener');
265
342
  });
266
343
 
344
+ test('a person meets the page\'s NUMBERS before its pills', () => {
345
+ // Task 1003942 promised "a glass figure strip at the top (waiting on you /
346
+ // your open asks / crafts you answer for)" and shipped without it; the
347
+ // grader's narc worker caught the omission and the artist's verdict named
348
+ // what it cost — with nothing calm at the top, the page opens on age pills.
349
+ const html = read('modules', 'hall-ui', 'public', 'collab.html');
350
+ const js = read('modules', 'hall-ui', 'public', 'collab.js');
351
+ assert.match(html, /<div class="stat-row ov-summary cb-figs" id="cb-figs"/, 'the strip is the shell\'s glass band');
352
+ // Position is the whole claim: before the blocks, not beside them.
353
+ assert.ok(html.indexOf('id="cb-figs"') < html.indexOf('cb-band'), 'the strip is ABOVE the blocks');
354
+ assert.ok(html.indexOf('id="cb-figs"') < html.indexOf('id="cb-all"'), 'and above the board');
355
+ assert.match(js, /function paintFigures\(/, 'the page fills it');
356
+ assert.match(js, /paintFigures\(rowsOf\(forMe\), rowsOf\(mine\), crafts\)/, 'from the reads it already makes — no fourth fetch');
357
+ for (const label of ['Waiting on you', 'Your open asks', 'Roles you answer for']) {
358
+ assert.ok(js.includes(label), `the strip carries "${label}"`);
359
+ }
360
+ // It is painted BEFORE the blocks, so the numbers are on screen first even on
361
+ // a slow paint.
362
+ assert.ok(js.indexOf('paintFigures(') < js.indexOf("paintBlock('for-me'"), 'and painted first');
363
+ });
364
+
365
+ test('ONE WORD, ONE CASING: a sentence takes roleWord, a control takes roleLabel', () => {
366
+ // The artist's third finding, and the design error under it. Collab said
367
+ // "Asks to Builders" in its sub line and "You asked artists" six lines down,
368
+ // because task 1003936 routed one call site through roleLabel() — correct for
369
+ // a picker, wrong in prose — and reasoned about the other separately.
370
+ const dom = read('modules', 'hall-ui', 'public', 'dom-utils.js');
371
+ const js = read('modules', 'hall-ui', 'public', 'collab.js');
372
+ assert.match(dom, /function roleWord\(d\)/, 'prose has its own helper');
373
+ assert.match(dom, /roleAxisLabel, roleLabel, roleWord,/, 'and it is exported');
374
+ // The rule is written down next to the helper that made it necessary, which is
375
+ // the third DONE WHEN clause of task 1004041.
376
+ const nearby = dom.slice(dom.indexOf('function roleLabel('), dom.indexOf('function roleWord('));
377
+ assert.match(nearby, /A CONTROL TAKES roleLabel\(\); A SENTENCE TAKES roleWord\(\)/, 'the rule sits beside roleLabel');
378
+ // The page's sentence no longer capitalises a common noun, and no longer
379
+ // opens on a clause the next clause repeats.
380
+ assert.match(js, /roleWord\(c\)\}s/, 'the sub line names roles as common nouns');
381
+ assert.ok(!/Builders asking builders/.test(js), 'the tautology is gone');
382
+ // A CALL, not a mention: the comment above the sub line names roleLabel() to
383
+ // explain why it is not used, and a bare /roleLabel\(/ would read that as the
384
+ // defect it warns about.
385
+ assert.ok(!/window\.OTB\.roleLabel\(/.test(js), 'no control-voice label inside a sentence on this page');
386
+ });
387
+
388
+ test('roleWord and roleLabel disagree exactly where they should', () => {
389
+ // Executed, not grepped: the acronym exception has to survive both ways, or
390
+ // "You asked uis" ships the next time a ui task files a help request.
391
+ const { roleLabel, roleWord } = domUtils();
392
+ assert.equal(roleLabel('engineer'), 'Engineer');
393
+ assert.equal(roleWord('engineer'), 'engineer');
394
+ assert.equal(roleLabel('ui'), 'UI');
395
+ assert.equal(roleWord('ui'), 'UI', 'an acronym is not a common noun in any sentence');
396
+ assert.equal(roleLabel('unclassified'), 'No role yet');
397
+ assert.equal(roleWord('unclassified'), 'no role yet');
398
+ assert.equal(roleWord(''), '');
399
+ assert.equal(roleWord(null), '');
400
+ });
401
+
267
402
  test('the by-name ask is the one LIT thing — the accent goes on the personal call', () => {
268
403
  const js = read('modules', 'hall-ui', 'public', 'collab.js');
269
404
  const css = read('modules', 'hall-ui', 'public', 'collab.css');
@@ -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)',