@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.
- package/.bongos-core.json +27 -17
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/windows-builders.md +43 -16
- package/modules/agents/lib/validate.js +56 -0
- package/modules/government/protected-surfaces.json +8 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/agents-sync.js +482 -0
- package/scripts/migrate.sh +23 -0
- package/src/module-api.js +1 -1
- package/tests/agents_sync.mjs +401 -0
- package/tests/agents_validate.mjs +118 -1
- package/tests/government_protected_surfaces.mjs +132 -1
|
@@ -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
|
-
|
|
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', () => {
|