@bongos/core 1.19.702 → 1.19.704

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.
@@ -0,0 +1,401 @@
1
+ // tests/agents_sync.mjs — the deploy-time file↔DB reconcile (task 1002490,
2
+ // goal 1000038 Phase 1, T6).
3
+ //
4
+ // The four rules this script exists to keep, each tested as the FAILURE it
5
+ // prevents rather than as the happy path:
6
+ //
7
+ // 1. a source=db row is never touched → an instance-authored agent
8
+ // survives a deploy
9
+ // 2. author_rank is never taken from git → the committer email is
10
+ // where it could grant attacker-controlled, so it is
11
+ // withheld on any protected scope
12
+ // (ADR 0016)
13
+ // 3. a refused SCOPE imports disabled+flagged → visible and fixable, never armed
14
+ // 4. the sync never ARMS anything → `git push` cannot switch an
15
+ // agent on
16
+ //
17
+ // Pure: no database, no git, no disk except one tmp dir for the absent/present
18
+ // directory cases. The reconcile's DECISIONS are the thing under test.
19
+ //
20
+ // In tests/ rather than modules/agents/tests/ for the same reason as
21
+ // tests/agents_routes.mjs: the module is default:false and module-local tests
22
+ // are discovered only when the module is ENABLED, so they would never run here.
23
+ //
24
+ // Run: node tests/agents_sync.mjs
25
+
26
+ import { strict as assert } from 'node:assert';
27
+ import { createRequire } from 'node:module';
28
+ import fs from 'node:fs';
29
+ import os from 'node:os';
30
+ import path from 'node:path';
31
+
32
+ const require = createRequire(import.meta.url);
33
+ const sync = require('../scripts/gds/agents-sync.js');
34
+ const validate = require('../modules/agents/lib/validate.js');
35
+
36
+ let passed = 0;
37
+ let failed = 0;
38
+ function t(name, fn) {
39
+ try { fn(); console.log(` PASS ${name}`); passed += 1; }
40
+ catch (err) { console.log(` FAIL ${name}\n ${err.message}`); failed += 1; }
41
+ }
42
+
43
+ const FILE = [
44
+ '---',
45
+ 'name: historian',
46
+ 'title: The Historian',
47
+ 'trigger: on-demand',
48
+ 'model_tier: routine',
49
+ 'scope_modules: [agents]',
50
+ '---',
51
+ '',
52
+ 'You are the historian. Answer with citations.',
53
+ ].join('\n');
54
+
55
+ const def = () => sync.parseAgentFile(FILE, { name: 'historian' });
56
+
57
+ console.log('\nparsing — the file is a definition, the body is the persona:');
58
+
59
+ t('frontmatter + body split, with the body as persona', () => {
60
+ const d = def();
61
+ assert.equal(d.name, 'historian');
62
+ assert.equal(d.title, 'The Historian');
63
+ assert.equal(d.trigger_type, 'on-demand', 'the readable `trigger:` spelling maps to the column');
64
+ assert.equal(d.model_tier, 'routine');
65
+ assert.deepEqual(d.scope_modules, ['agents']);
66
+ assert.match(d.persona, /^You are the historian/);
67
+ assert.ok(!('trigger' in d), 'the file spelling is consumed, not left to confuse the validator');
68
+ });
69
+
70
+ t('the FILENAME is the identity when frontmatter omits a name', () => {
71
+ const d = sync.parseAgentFile('---\ntrigger: on-demand\n---\nbody', { name: 'planner' });
72
+ assert.equal(d.name, 'planner', 'planner.md is the planner agent without saying so twice');
73
+ });
74
+
75
+ t('CRLF and a REAL BOM parse — a Windows-authored file is still a definition', () => {
76
+ // The first draft of this test was named for the BOM and contained none —
77
+ // only CRLF — so it asserted nothing about the behaviour its name promised.
78
+ // The second carried a LITERAL U+FEFF, which is invisible and read in review
79
+ // as no BOM at all. Built from the CODE POINT: unambiguous ASCII in source,
80
+ // and the charCodeAt assertion below means the fixture cannot quietly lose it
81
+ // a third time.
82
+ const withBom = String.fromCharCode(0xFEFF) + '---\r\nname: x\r\ntrigger: on-demand\r\n---\r\nbody';
83
+ assert.equal(withBom.charCodeAt(0), 0xFEFF, 'the fixture really starts with a BOM');
84
+ const d = sync.parseAgentFile(withBom, {});
85
+ assert.equal(d.name, 'x');
86
+ assert.equal(d.trigger_type, 'on-demand');
87
+ assert.equal(d.persona, 'body');
88
+ // And the same file WITHOUT the BOM must still parse — the BOM is optional,
89
+ // not required, and a regex that demanded it would break every POSIX file.
90
+ const noBom = sync.parseAgentFile(withBom.slice(1), {});
91
+ assert.equal(noBom.name, 'x');
92
+ });
93
+
94
+ t('`event:` becomes the trigger_spec the schema stores', () => {
95
+ const d = sync.parseAgentFile('---\nname: lens\ntrigger: event\nevent: task.shipped\n---\nb', {});
96
+ assert.deepEqual(d.trigger_spec, { event: 'task.shipped' });
97
+ assert.ok(!('event' in d));
98
+ });
99
+
100
+ t('a file with NO frontmatter yields no definition, and does not throw', () => {
101
+ const d = sync.parseAgentFile('just prose, no fences', { name: 'loose' });
102
+ assert.equal(d.name, 'loose');
103
+ assert.equal(d.trigger_type, undefined, 'nothing is invented from a bare file');
104
+ });
105
+
106
+ t('an unknown key is KEPT, so a typo is refused rather than defaulted', () => {
107
+ const d = sync.parseAgentFile('---\nname: x\ntriger: event\n---\nb', {});
108
+ assert.equal(d.triger, 'event', 'silently dropping it would turn a typo into a default');
109
+ });
110
+
111
+ console.log('\nrule 1 — a source=db row is never touched:');
112
+
113
+ t('an instance-authored row owns its name; the file is ignored', () => {
114
+ const p = sync.planOne(def(), {
115
+ authorRank: 'archon', allowedModules: ['agents'],
116
+ existing: { source: 'db', enabled: true },
117
+ });
118
+ assert.equal(p.action, 'protected');
119
+ assert.match(p.reason, /instance-authored/);
120
+ assert.equal(p.value, undefined, 'no row is produced, so nothing can be written');
121
+ });
122
+
123
+ t('a source=file row with the same name IS reconciled', () => {
124
+ const p = sync.planOne(def(), {
125
+ authorRank: 'archon', allowedModules: ['agents'],
126
+ existing: { source: 'file', enabled: false },
127
+ });
128
+ assert.equal(p.action, 'upsert');
129
+ });
130
+
131
+ console.log('\nrule 2 — authority comes from the live DB rank, never the file:');
132
+
133
+ t('a file claiming author_rank cannot promote itself', () => {
134
+ // The frontmatter says archon; the caller passes the live rank, which is what
135
+ // the wall consults. If the file could win, writing one line would be a
136
+ // privilege escalation whose only gate is code review.
137
+ const claiming = sync.parseAgentFile(
138
+ '---\nname: x\ntrigger: on-demand\nauthor_rank: archon\nscope_modules: [government]\n---\nbody', {});
139
+ const p = sync.planOne(claiming, {
140
+ authorRank: 'xenos', allowedModules: ['government'], protectedModules: ['government'],
141
+ });
142
+ assert.equal(p.action, 'flagged', 'the declared rank did not open the protected scope');
143
+ // NULL, not 'xenos': this definition reaches a protected module, so no
144
+ // git-derived rank is stamped at all (stampableAuthorRank). The file's claim
145
+ // loses, and so does the spoofable git value — both by the same rule.
146
+ assert.equal(p.authorRank, null, 'nothing git-derived is stamped on a protected-scope row');
147
+ assert.match(p.scopeViolation, /metic\+ author/i);
148
+ });
149
+
150
+ t('THE ESCALATION PATH: a spoofed git identity cannot arm a protected scope', () => {
151
+ // git log --format=%ae reports the commit AUTHOR, which any committer sets
152
+ // with `git config user.email` or `--author` — noreply-shaped values included,
153
+ // and nothing verifies them against GitHub. So the resolved rank is
154
+ // attacker-controlled. The wall is that it is never consulted where it could
155
+ // grant: a definition reaching a protected module is stamped NULL whatever
156
+ // git said, which the validator treats as insufficient.
157
+ const d = def(); // scope_modules: [agents]
158
+ assert.equal(sync.declaresProtectedScope(d, ['agents']), true);
159
+ assert.equal(sync.stampableAuthorRank(d, { gitRank: 'archon', protectedModules: ['agents'] }), null,
160
+ 'a claimed archon rank must not survive onto a protected-scope definition');
161
+ const p = sync.planOne(d, {
162
+ authorRank: 'archon', allowedModules: ['agents'], protectedModules: ['agents'],
163
+ });
164
+ assert.equal(p.action, 'flagged', 'so it flags, exactly as an unresolved author would');
165
+ assert.equal(p.authorRank, null, 'and nothing spoofable is stamped on the row');
166
+ assert.equal(p.enabled, false);
167
+ });
168
+
169
+ t('where rank grants nothing, the hint is kept as provenance', () => {
170
+ // The other side: withholding it everywhere would throw away useful
171
+ // provenance for no gain, since on an unprotected scope the rank gates nothing.
172
+ const d = def();
173
+ assert.equal(sync.stampableAuthorRank(d, { gitRank: 'metic', protectedModules: [] }), 'metic');
174
+ const p = sync.planOne(d, { authorRank: 'metic', allowedModules: ['agents'], protectedModules: [] });
175
+ assert.equal(p.action, 'upsert');
176
+ assert.equal(p.authorRank, 'metic');
177
+ });
178
+
179
+ t('an UNRESOLVED author is insufficient, not exempt', () => {
180
+ // A committer who maps to no builder row yields null. Fail-closed: null must
181
+ // behave like "too low", never like "skip the check".
182
+ const p = sync.planOne(def(), {
183
+ authorRank: null, allowedModules: ['agents'], protectedModules: ['agents'],
184
+ });
185
+ assert.equal(p.action, 'flagged');
186
+ assert.equal(p.authorRank, null);
187
+ assert.equal(p.enabled, false);
188
+ });
189
+
190
+ console.log('\nrule 3 — a refused scope is IMPORTED, disabled and flagged:');
191
+
192
+ t('a protected scope below the floor imports flagged, never armed', () => {
193
+ const p = sync.planOne(def(), {
194
+ authorRank: 'thetes', allowedModules: ['agents'], protectedModules: ['agents'],
195
+ });
196
+ assert.equal(p.action, 'flagged');
197
+ assert.equal(p.enabled, false, 'the whole point — present, not armed');
198
+ assert.ok(p.scopeViolation && p.scopeViolation.length > 0, 'the reason is stored so it can be fixed');
199
+ assert.ok(p.value, 'a flagged row still carries the normalized definition');
200
+ assert.equal(p.value.name, 'historian');
201
+ });
202
+
203
+ t('a flagged row keeps its real scope, not a sanitised one', () => {
204
+ // The re-run that recovers the normalized shape must not be allowed to launder
205
+ // the scope — what is stored has to be what the file actually asked for, or
206
+ // the flag names a problem the row no longer shows.
207
+ const p = sync.planOne(def(), {
208
+ authorRank: 'thetes', allowedModules: ['agents'], protectedModules: ['agents'],
209
+ });
210
+ assert.deepEqual(p.value.scope_modules, ['agents']);
211
+ });
212
+
213
+ t('a SHAPE error is skipped — there is no definition to import', () => {
214
+ const p = sync.planOne({ name: 'x', trigger_type: 'telepathy' }, {
215
+ authorRank: 'archon', allowedModules: ['agents'],
216
+ });
217
+ assert.equal(p.action, 'skip');
218
+ assert.equal(p.value, undefined, 'a partial row must never reach the registry');
219
+ assert.match(p.reason, /trigger/i);
220
+ });
221
+
222
+ t('the two are not confused: every SCOPE_CODE is a real validator code', () => {
223
+ // If a code were renamed in the validator, scope refusals would start being
224
+ // reported as shape errors and silently stop being imported.
225
+ const p = validate.validateAgentDefinition(
226
+ { name: 'x', trigger_type: 'on-demand', persona: 'p', scope_modules: ['nope'] },
227
+ { authorRank: 'archon', allowedModules: [] }
228
+ );
229
+ assert.equal(p.ok, false);
230
+ assert.ok(p.errors.some((e) => sync.SCOPE_CODES.includes(e.code)),
231
+ 'scope_out_of_bounds must still be spelled the way SCOPE_CODES expects');
232
+ });
233
+
234
+ console.log('\nrule 4 — the sync never arms anything:');
235
+
236
+ t('a NEW clean definition inserts DISABLED', () => {
237
+ const p = sync.planOne(def(), { authorRank: 'archon', allowedModules: ['agents'] });
238
+ assert.equal(p.action, 'upsert');
239
+ assert.equal(p.enabled, false, 'arming is an operator act, by the same logic as rule 2');
240
+ });
241
+
242
+ t("an EXISTING row keeps the operator's own enabled setting", () => {
243
+ const on = sync.planOne(def(), {
244
+ authorRank: 'archon', allowedModules: ['agents'], existing: { source: 'file', enabled: true },
245
+ });
246
+ assert.equal(on.enabled, true, 'a deploy must not undo an operator switching an agent on');
247
+ const off = sync.planOne(def(), {
248
+ authorRank: 'archon', allowedModules: ['agents'], existing: { source: 'file', enabled: false },
249
+ });
250
+ assert.equal(off.enabled, false);
251
+ });
252
+
253
+ t('a flagged row is forced OFF even if it was armed before', () => {
254
+ // The dangerous ordering: an agent is armed, then its scope is edited to reach
255
+ // a protected surface. Carrying `enabled` forward would arm the new scope.
256
+ const p = sync.planOne(def(), {
257
+ authorRank: 'thetes', allowedModules: ['agents'], protectedModules: ['agents'],
258
+ existing: { source: 'file', enabled: true },
259
+ });
260
+ assert.equal(p.action, 'flagged');
261
+ assert.equal(p.enabled, false, 'a newly-flagged agent must be disarmed, not left running');
262
+ });
263
+
264
+ console.log('\nthe absent directory is a legitimate state:');
265
+
266
+ t('no .claude/agents/ → zero files, no throw', () => {
267
+ // It does not exist in the core tree at all today, and an instance shipping no
268
+ // agents must still deploy cleanly.
269
+ assert.deepEqual(sync.readAgentFiles(path.join(os.tmpdir(), 'agents-sync-absent-xyz')), []);
270
+ });
271
+
272
+ t('a real directory is read, .md only, sorted', () => {
273
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-sync-'));
274
+ fs.writeFileSync(path.join(dir, 'zeta.md'), FILE);
275
+ fs.writeFileSync(path.join(dir, 'alpha.md'), FILE.replace('name: historian', 'name: alpha'));
276
+ fs.writeFileSync(path.join(dir, 'README.txt'), 'not a definition');
277
+ const files = sync.readAgentFiles(dir);
278
+ assert.equal(files.length, 2, 'only .md files are definitions');
279
+ assert.deepEqual(files.map((f) => f.candidate.name), ['alpha', 'historian'], 'sorted, so a run is reproducible');
280
+ });
281
+
282
+ console.log('\nplanSync — the whole reconcile, still pure:');
283
+
284
+ t('one plan entry per file, each carrying its own author rank', () => {
285
+ const files = [
286
+ { file: '.claude/agents/a.md', candidate: sync.parseAgentFile(FILE.replace('historian', 'a'), {}) },
287
+ { file: '.claude/agents/b.md', candidate: sync.parseAgentFile(FILE.replace('historian', 'b'), {}) },
288
+ ];
289
+ const plan = sync.planSync(files, {
290
+ existingByName: new Map([['b', { source: 'db', enabled: true }]]),
291
+ authorRankFor: (f) => (f.endsWith('a.md') ? 'archon' : 'xenos'),
292
+ allowedModules: ['agents'],
293
+ protectedModules: [],
294
+ });
295
+ assert.equal(plan.length, 2);
296
+ assert.equal(plan[0].action, 'upsert');
297
+ assert.equal(plan[0].authorRank, 'archon', 'rank is resolved PER FILE, not once for the run');
298
+ assert.equal(plan[1].action, 'protected', 'the db-owned name is left alone');
299
+ });
300
+
301
+ console.log('\nthe committer mapping — provenance only, and known spoofable:');
302
+
303
+ t('a GitHub noreply address yields the login; a local name is only a fallback', () => {
304
+ // Neither form is trustworthy — both come from the commit author, which the
305
+ // committer sets. The preference is about giving the BEST GUESS at provenance,
306
+ // not about security; the security is that this value never reaches a decision
307
+ // (see the escalation-path test above).
308
+ const login = sync.lastCommitterLogin('x', { runGit: () => '12345+octocat@users.noreply.github.com\nSomeone Else\n' });
309
+ assert.equal(login, 'octocat', 'the noreply address carries the login and cannot be set locally');
310
+ // A generic fixture on purpose: a real builder's login baked into a test is
311
+ // the hardcoded-founder-identity defect audit-authorship.sh exists to catch,
312
+ // and it caught this line in its first draft.
313
+ const fallback = sync.lastCommitterLogin('x', { runGit: () => 'someone@example.com\na-builder\n' });
314
+ assert.equal(fallback, 'a-builder');
315
+ assert.equal(sync.lastCommitterLogin('x', { runGit: () => { throw new Error('not a repo'); } }), null,
316
+ 'an unreadable history resolves to null, which rule 2 treats as insufficient');
317
+ });
318
+
319
+ console.log('\nthe path wall is wired to the REAL registry (task 1002492):');
320
+
321
+ const permissionPaths = require('../src/bongos/permission-path-check.js');
322
+ const realMatcher = (p) => permissionPaths.surfaceFor(p) !== null;
323
+
324
+ t('the wall is wired to the REAL protected-surface registry', () => {
325
+ // Not a stub: the same synchronously-loaded registry the pre-push hook, the
326
+ // grader pre-pass and main-audit.js read (ADR 0043). If a governance re-map
327
+ // narrows it, this fails rather than silently widening what an agent may reach.
328
+ assert.equal(realMatcher('modules/government/catalog.js'), true, 'the authority surface');
329
+ assert.equal(realMatcher('migrations/001_init.sql'), true, 'schema');
330
+ assert.equal(realMatcher('.claude/hooks/pre-push.js'), true, 'local automation');
331
+ assert.equal(realMatcher('modules/agents/lib/validate.js'), false, "a module's own code is not protected");
332
+ // `.claude/agents/` itself is deliberately NOT in the registry — see the note
333
+ // on ADDED_SINCE_R101 in tests/government_protected_surfaces.mjs. Protecting
334
+ // it would rank-wall the whole agents module by derivation, which is a
335
+ // separate decision from the one this wall makes.
336
+ assert.equal(realMatcher('.claude/agents/historian.md'), false,
337
+ 'not protected — and that is recorded, not accidental');
338
+ });
339
+
340
+ t('a path scope onto the authority surface is withheld a rank AND flagged', () => {
341
+ // Both halves of task 1002492 meeting the rule from 1002490: the definition
342
+ // reaches a protected surface BY PATH, so (a) no git-derived rank is stamped
343
+ // and (b) the scope wall refuses it — imported, disabled, visible.
344
+ const d = sync.parseAgentFile(
345
+ '---\nname: snoop\ntrigger: on-demand\nscope_paths: [modules/government/catalog.js]\n---\nbody', {});
346
+ assert.equal(sync.declaresProtectedScope(d, [], realMatcher), true);
347
+ assert.equal(sync.stampableAuthorRank(d, { gitRank: 'archon', isProtectedPath: realMatcher }), null,
348
+ 'a claimed archon rank must not survive a PATH-spelled protected scope either');
349
+ const p = sync.planOne(d, {
350
+ authorRank: 'archon', allowedModules: ['agents'], isProtectedPath: realMatcher,
351
+ });
352
+ assert.equal(p.action, 'flagged');
353
+ assert.equal(p.authorRank, null);
354
+ assert.equal(p.enabled, false);
355
+ assert.match(p.scopeViolation, /scope_paths|protected surface/i);
356
+ });
357
+
358
+ t('an unprotected path scope still reconciles normally', () => {
359
+ // The wall is a floor, not a ban — a tightening that refused every path scope
360
+ // would pass the test above and break the feature.
361
+ const d = sync.parseAgentFile(
362
+ '---\nname: reader\ntrigger: on-demand\nscope_paths: [modules/agents/lib/]\n---\nbody', {});
363
+ assert.equal(sync.declaresProtectedScope(d, [], realMatcher), false);
364
+ const p = sync.planOne(d, {
365
+ authorRank: 'metic', allowedModules: ['agents'], isProtectedPath: realMatcher,
366
+ });
367
+ assert.equal(p.action, 'upsert');
368
+ assert.equal(p.authorRank, 'metic', 'rank gates nothing here, so the provenance hint is kept');
369
+ });
370
+
371
+ t('declaring paths with NO matcher flags rather than importing clean', () => {
372
+ // The uncheckable case end to end: the plan must not treat "nobody checked"
373
+ // as "nothing was protected".
374
+ const d = sync.parseAgentFile(
375
+ '---\nname: x\ntrigger: on-demand\nscope_paths: [anything]\n---\nbody', {});
376
+ assert.equal(sync.declaresProtectedScope(d, [], null), true, 'unanswerable reads as protected');
377
+ const p = sync.planOne(d, { authorRank: 'archon', allowedModules: ['agents'] });
378
+ assert.equal(p.action, 'flagged', 'imported and visible, never silently clean');
379
+ assert.equal(p.enabled, false);
380
+ });
381
+
382
+ console.log('\nthe reconcile actually RUNS at deploy:');
383
+
384
+ t('migrate.sh invokes agents-sync, module-gated and fail-open', () => {
385
+ // A reconcile nothing calls is a script, not a deploy step — review caught
386
+ // that the first cut shipped the machinery with no caller. migrate.sh is the
387
+ // one deploy-time hook the portable core owns, and it applies the schema this
388
+ // writes into a few lines above, so the table exists by the time it runs.
389
+ const sh = fs.readFileSync(new URL('../scripts/migrate.sh', import.meta.url), 'utf8');
390
+ assert.match(sh, /scripts\/gds\/agents-sync\.js/, 'the deploy path must call it');
391
+ assert.match(sh, /isModuleEnabled\('agents'\)/, "gated on the module — it is default:false");
392
+ // Fail-OPEN: a stale registry is a nuisance, an aborted deploy is an outage,
393
+ // and the script is already fail-closed about what it imports.
394
+ assert.match(sh, /agents-sync failed — the registry is stale by one deploy/,
395
+ 'a failed reconcile must not abort the deploy');
396
+ const call = sh.slice(sh.indexOf('agents-sync'));
397
+ assert.doesNotMatch(call.slice(0, 200), /exit 1/, 'no exit 1 on the agents-sync path');
398
+ });
399
+
400
+ console.log(`\nagents_sync: ${passed} passed, ${failed} failed`);
401
+ process.exit(failed ? 1 : 0);
@@ -41,7 +41,15 @@ const OK_DEF = Object.freeze({
41
41
  model_tier: 'default',
42
42
  scope_modules: ['agents'],
43
43
  });
44
- const OPTS = Object.freeze({ authorRank: 'archon', allowedModules: ['agents'] });
44
+ // task 1002492 added the scope_paths half of the rank wall, and with it the rule
45
+ // that DECLARING paths requires the caller to supply the means to check them —
46
+ // there is no `allowedPaths` counterpart to fail closed on, so an absent matcher
47
+ // would be a silent hole. The baseline options therefore carry a matcher; the
48
+ // uncheckable and protected cases each get their own test below.
49
+ const NOTHING_PROTECTED = () => false;
50
+ const OPTS = Object.freeze({
51
+ authorRank: 'archon', allowedModules: ['agents'], isProtectedPath: NOTHING_PROTECTED,
52
+ });
45
53
 
46
54
  const codes = (r) => r.errors.map((e) => e.code);
47
55
  const hasCode = (r, code) => codes(r).includes(code);
@@ -241,6 +249,115 @@ await test('a scope_path that escapes the tree is refused', () => {
241
249
  assert.equal(validateAgentDefinition({ ...OK_DEF, scope_paths: ['modules/agents/'] }, OPTS).ok, true);
242
250
  });
243
251
 
252
+ // --- THE scope_paths HALF OF THE WALL (task 1002492) ------------------------
253
+ //
254
+ // The defect this closes: the rank wall ran over scope_modules only, so
255
+ // `scope_modules: ['government']` was refused below Metic while
256
+ // `scope_paths: ['modules/government/']` reached the same code and passed at any
257
+ // rank. One wall, two halves, disagreeing — and the path half was the one an
258
+ // author would reach for to name a specific surface.
259
+
260
+ const REACHES_GOVERNMENT = (p) => String(p).startsWith('modules/government');
261
+
262
+ await test('a scope_path onto a protected surface is refused below the floor', () => {
263
+ const r = validateAgentDefinition(
264
+ { ...OK_DEF, scope_paths: ['modules/government/catalog.js'] },
265
+ { authorRank: 'thetes', allowedModules: ['agents'], isProtectedPath: REACHES_GOVERNMENT }
266
+ );
267
+ assert.equal(r.ok, false, 'a thetes author may not scope onto the authority surface by path');
268
+ assert.ok(hasCode(r, 'scope_protected_rank'), JSON.stringify(codes(r)));
269
+ assert.match(r.errors.find((e) => e.code === 'scope_protected_rank').message, /scope_paths/);
270
+ });
271
+
272
+ await test('the two halves of the wall now agree', () => {
273
+ // The asymmetry itself, asserted: the same surface named as a module and as a
274
+ // path must reach the same verdict for the same author.
275
+ const asModule = validateAgentDefinition(
276
+ { ...OK_DEF, scope_modules: ['government'] },
277
+ { authorRank: 'thetes', allowedModules: ['agents', 'government'], protectedModules: ['government'], isProtectedPath: NOTHING_PROTECTED }
278
+ );
279
+ const asPath = validateAgentDefinition(
280
+ { ...OK_DEF, scope_paths: ['modules/government/'] },
281
+ { authorRank: 'thetes', allowedModules: ['agents'], isProtectedPath: REACHES_GOVERNMENT }
282
+ );
283
+ assert.equal(asModule.ok, false, 'the module spelling was always refused');
284
+ assert.equal(asPath.ok, false, 'and the path spelling must be too — this is the whole task');
285
+ assert.ok(hasCode(asModule, 'scope_protected_rank'));
286
+ assert.ok(hasCode(asPath, 'scope_protected_rank'));
287
+ });
288
+
289
+ await test('a Metic+ author may scope onto a protected path', () => {
290
+ // The wall is a FLOOR, not a ban — a tightening that refused everyone would
291
+ // pass the test above while breaking the feature.
292
+ const r = validateAgentDefinition(
293
+ { ...OK_DEF, scope_paths: ['modules/government/catalog.js'] },
294
+ { authorRank: 'metic', allowedModules: ['agents'], isProtectedPath: REACHES_GOVERNMENT }
295
+ );
296
+ assert.equal(r.ok, true, JSON.stringify(codes(r)));
297
+ assert.deepEqual(r.value.scope_paths, ['modules/government/catalog.js']);
298
+ });
299
+
300
+ await test('an UNPROTECTED path is unaffected at any rank', () => {
301
+ const r = validateAgentDefinition(
302
+ { ...OK_DEF, scope_paths: ['modules/agents/lib/'] },
303
+ { authorRank: 'xenos', allowedModules: ['agents'], isProtectedPath: REACHES_GOVERNMENT }
304
+ );
305
+ assert.equal(r.ok, true, 'the wall must not become a blanket refusal of path scopes');
306
+ });
307
+
308
+ await test('declaring scope_paths with NO matcher is refused, not admitted', () => {
309
+ // scope_modules fails closed through `allowedModules` defaulting to empty.
310
+ // scope_paths has no such counterpart, so an absent matcher would be a silent
311
+ // hole — "nobody checked" must not read the same as "nothing was protected".
312
+ const r = validateAgentDefinition(
313
+ { ...OK_DEF, scope_paths: ['anything/at/all'] },
314
+ { authorRank: 'archon', allowedModules: ['agents'] }
315
+ );
316
+ assert.equal(r.ok, false);
317
+ assert.ok(hasCode(r, 'scope_paths_uncheckable'), JSON.stringify(codes(r)));
318
+ // …and an ARCHON is refused too: this is not a rank question, it is a
319
+ // "we could not answer the question" question.
320
+ assert.equal(r.value, null);
321
+ });
322
+
323
+ await test('a THROWING matcher counts as a hit, never as a clean path', () => {
324
+ const boom = () => { throw new Error('registry unreadable'); };
325
+ const low = validateAgentDefinition(
326
+ { ...OK_DEF, scope_paths: ['whatever'] },
327
+ { authorRank: 'xenos', allowedModules: ['agents'], isProtectedPath: boom }
328
+ );
329
+ assert.equal(low.ok, false, 'an unanswered question must not resolve to "not protected"');
330
+ assert.ok(hasCode(low, 'scope_protected_rank'));
331
+ // The floor still applies: a Metic+ author is admitted, because the
332
+ // fail-closed reading is "treat as protected", not "refuse everyone".
333
+ const high = validateAgentDefinition(
334
+ { ...OK_DEF, scope_paths: ['whatever'] },
335
+ { authorRank: 'archon', allowedModules: ['agents'], isProtectedPath: boom }
336
+ );
337
+ assert.equal(high.ok, true);
338
+ });
339
+
340
+ await test('ONLY an explicit false clears a path — ambiguity reads as protected', () => {
341
+ // The fail-open trap: `answer === true` would read undefined (a matcher that
342
+ // forgot a return), a truthy string, or null as "not protected" — silently
343
+ // opening the wall on the exact question it exists to ask. Reading ambiguity
344
+ // as protected is loud and gets fixed; reading it as clean is not.
345
+ for (const answer of [undefined, null, 'yes', 0, '']) {
346
+ const r = validateAgentDefinition(
347
+ { ...OK_DEF, scope_paths: ['x'] },
348
+ { authorRank: 'xenos', allowedModules: ['agents'], isProtectedPath: () => answer }
349
+ );
350
+ assert.equal(r.ok, false, `a matcher answering ${JSON.stringify(answer)} must not clear the path`);
351
+ assert.ok(hasCode(r, 'scope_protected_rank'));
352
+ }
353
+ // …and a real `false` does clear it, or the wall would refuse everything.
354
+ const clean = validateAgentDefinition(
355
+ { ...OK_DEF, scope_paths: ['x'] },
356
+ { authorRank: 'xenos', allowedModules: ['agents'], isProtectedPath: () => false }
357
+ );
358
+ assert.equal(clean.ok, true);
359
+ });
360
+
244
361
  // --- THE RANK WALL (ADR 0016 — authority is the author's LIVE rank) ---------
245
362
 
246
363
  await test('RANK_ORDER is the ladder low→high, and the scope floor is Metic', () => {