@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.
- package/.bongos-core.json +63 -58
- package/clients/bongos-client/index.d.ts +2 -2
- 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
- package/docs/adr/0309-durable-module-enablement-db-override.md +116 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +6 -0
- package/docs/copy-inventory.md +24 -23
- package/docs/copy-registry.json +34 -25
- package/docs/file-map.md +2 -2
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/bongos-cli-release.md +1 -1
- package/docs/recipes/managed-settings-remote-control.md +1 -1
- package/migrations/core_248_module_enablement_overrides.sql +50 -0
- package/modules/dev-box/app/README.md +4 -3
- package/modules/hall-ui/public/collab-lib.js +33 -1
- package/modules/hall-ui/public/collab.css +33 -4
- package/modules/hall-ui/public/collab.html +9 -2
- package/modules/hall-ui/public/collab.js +68 -15
- package/modules/hall-ui/public/dom-utils.js +23 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claude-materialize.js +39 -34
- package/scripts/hall-preview/server.js +38 -1
- package/src/bongos/module-overrides.js +117 -0
- package/src/bongos/routes/modules.js +56 -23
- package/src/module-api.js +1 -1
- package/src/modules.js +120 -14
- package/src/platform-server.js +47 -5
- package/tests/allow_rule_guard.mjs +192 -0
- package/tests/box_ship_permissions.mjs +143 -62
- package/tests/box_sweep_docs.mjs +7 -3
- package/tests/claude_materialize.mjs +8 -4
- package/tests/collab_page.mjs +136 -1
- package/tests/host_topology_skips.mjs +1 -0
- package/tests/modules.mjs +110 -0
- package/tests/modules_route_wiring.mjs +103 -0
- package/docs/recipes/builder-box-lifecycle.md +0 -181
- package/docs/recipes/devbox-app-release.md +0 -103
- package/docs/recipes/devbox-app-windows-test.md +0 -128
- 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
|
-
|
|
38
|
-
const 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(
|
|
41
|
-
'ship.js MUST be pre-allowed — a
|
|
42
|
-
// The rest of the core ship loop a
|
|
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(
|
|
56
|
+
assert.ok(allow.includes(`${tool}(node scripts/gds/${s}.js:*)`), `${s}.js should be pre-allowed`);
|
|
45
57
|
}
|
|
46
58
|
});
|
|
47
59
|
|
|
48
|
-
|
|
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(
|
|
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
|
|
66
|
-
|
|
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[
|
|
71
|
-
assert.ok(fs.existsSync(p), `allow-rule references a missing script: ${m[
|
|
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
|
|
83
|
-
// "x "; `
|
|
84
|
-
// part is, so `git status && git stash` is not covered by a `git status` rule.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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:
|
|
95
|
-
const isBlockedPwsh = (command) =>
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
const 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
|
|
147
|
-
assert.ok(!isBlocked(
|
|
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
|
-
|
|
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 ['
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
'
|
|
172
|
-
'
|
|
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
|
-
|
|
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 =
|
|
186
|
-
assert.deepEqual([...rest].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
|
-
|
|
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(
|
|
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 = ['
|
|
206
|
-
|
|
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
|
|
293
|
+
for (const rule of allowList()) {
|
|
212
294
|
const m = rule.match(SCRIPT_RULE);
|
|
213
|
-
if (m) assert.ok(known.has(m[
|
|
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
|
-
|
|
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(
|
|
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(),
|
|
308
|
+
assert.deepEqual([...STARTER_ALLOW].sort(), allowList().map((rule) => bongosify(rule)).sort());
|
|
228
309
|
});
|
|
229
310
|
|
|
230
311
|
console.log('');
|
package/tests/box_sweep_docs.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
771
|
-
|
|
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.
|
|
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/,
|
package/tests/collab_page.mjs
CHANGED
|
@@ -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\(
|
|
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)',
|