@bongos/core 1.19.518 → 1.19.519
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 +22 -12
- package/docs/module-api-changelog.md +2 -0
- package/modules/agents/lib/validate.js +306 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/agents_validate.mjs +354 -0
- package/tests/rank_tier_single_source.mjs +11 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.519",
|
|
6
|
+
"core_contract": "1.19.519",
|
|
7
|
+
"source_commit": "90936b1d6d76a6e72180bcda5829a33cd130e23e",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-03T20:
|
|
9
|
+
"built_at": "2026-09-03T20:29:09.958Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 431,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1969,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2424,
|
|
20
|
+
"tree_sha256": "ac3f45936dd7cae4fca5673e5a6f137a59f8fc05c173c58ed0c1ee2a22d9fc64",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -2597,7 +2597,7 @@
|
|
|
2597
2597
|
{
|
|
2598
2598
|
"path": "docs/module-api-changelog.md",
|
|
2599
2599
|
"mode": "0000644",
|
|
2600
|
-
"sha256": "
|
|
2600
|
+
"sha256": "0006d40a2c0f79224da4831191e3c1d64a5adf42cadfca08507369a5026bc044"
|
|
2601
2601
|
},
|
|
2602
2602
|
{
|
|
2603
2603
|
"path": "docs/modules-contract.md",
|
|
@@ -3574,6 +3574,11 @@
|
|
|
3574
3574
|
"mode": "0000644",
|
|
3575
3575
|
"sha256": "8ae32be783a4ca41637f56626bd83375aa4ae0755e701a0c0170eada8d8303b8"
|
|
3576
3576
|
},
|
|
3577
|
+
{
|
|
3578
|
+
"path": "modules/agents/lib/validate.js",
|
|
3579
|
+
"mode": "0000644",
|
|
3580
|
+
"sha256": "505b62e453d62b820720c00abf661f1ca1ffbfd93d3eba1bdcdf20c7cca95ba7"
|
|
3581
|
+
},
|
|
3577
3582
|
{
|
|
3578
3583
|
"path": "modules/agents/migrations/agents_001_definitions.sql",
|
|
3579
3584
|
"mode": "0000644",
|
|
@@ -7292,12 +7297,12 @@
|
|
|
7292
7297
|
{
|
|
7293
7298
|
"path": "package-lock.json",
|
|
7294
7299
|
"mode": "0000644",
|
|
7295
|
-
"sha256": "
|
|
7300
|
+
"sha256": "a98e002e52d95a3fbdeee7753bdee264e9beb75b05e98ec78d76e717f761a6a6"
|
|
7296
7301
|
},
|
|
7297
7302
|
{
|
|
7298
7303
|
"path": "package.json",
|
|
7299
7304
|
"mode": "0000644",
|
|
7300
|
-
"sha256": "
|
|
7305
|
+
"sha256": "b949b82136e44474714296bbac73758f16c34d51ccafd1fa68859ad701500520"
|
|
7301
7306
|
},
|
|
7302
7307
|
{
|
|
7303
7308
|
"path": "public-docs/index.html",
|
|
@@ -8967,7 +8972,7 @@
|
|
|
8967
8972
|
{
|
|
8968
8973
|
"path": "src/module-api.js",
|
|
8969
8974
|
"mode": "0000644",
|
|
8970
|
-
"sha256": "
|
|
8975
|
+
"sha256": "96bec9279e853e935c59aeb89ed60538750db43c6b34c2fc0f363cb214b300e5"
|
|
8971
8976
|
},
|
|
8972
8977
|
{
|
|
8973
8978
|
"path": "src/module-loader/catalog.js",
|
|
@@ -9059,6 +9064,11 @@
|
|
|
9059
9064
|
"mode": "0000644",
|
|
9060
9065
|
"sha256": "8cc261471a36e3c447df57b46082997b6a50da3c8ff4b214787ae55ab27cb9ef"
|
|
9061
9066
|
},
|
|
9067
|
+
{
|
|
9068
|
+
"path": "tests/agents_validate.mjs",
|
|
9069
|
+
"mode": "0000644",
|
|
9070
|
+
"sha256": "c698b80ab8d64421b5c81e91172e1f219c41924af19721106f1899cd29c2fabc"
|
|
9071
|
+
},
|
|
9062
9072
|
{
|
|
9063
9073
|
"path": "tests/api_cli_flags.mjs",
|
|
9064
9074
|
"mode": "0000644",
|
|
@@ -11337,7 +11347,7 @@
|
|
|
11337
11347
|
{
|
|
11338
11348
|
"path": "tests/rank_tier_single_source.mjs",
|
|
11339
11349
|
"mode": "0000644",
|
|
11340
|
-
"sha256": "
|
|
11350
|
+
"sha256": "6436fe1c0779514294cdd5391149b54d3afd634501ae7a70166348fa25739dce"
|
|
11341
11351
|
},
|
|
11342
11352
|
{
|
|
11343
11353
|
"path": "tests/rate_limit_public.mjs",
|
|
@@ -1473,5 +1473,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1473
1473
|
landed since 1.19.516 with no explicit bump. run 33799755382. (task 1002620)
|
|
1474
1474
|
1.19.518 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1475
1475
|
landed since 1.19.517 with no explicit bump. run 33801792364. (task 1002620)
|
|
1476
|
+
1.19.519 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1477
|
+
landed since 1.19.518 with no explicit bump. run 33802471495. (task 1002620)
|
|
1476
1478
|
---------------------------------------------------------------------------
|
|
1477
1479
|
```
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/agents/lib/validate.js — the pure agent-definition validator (task
|
|
4
|
+
// 1002487, goal 1000038 Phase 1; criterion `agents-malformed-rejected`).
|
|
5
|
+
//
|
|
6
|
+
// Answers one question with no I/O: "is this object a definition this instance is
|
|
7
|
+
// willing to register?" Given a candidate definition and the caller's context
|
|
8
|
+
// (the author's LIVE rank, the module keys in bounds), it returns a verdict plus
|
|
9
|
+
// the full list of what is wrong with it.
|
|
10
|
+
//
|
|
11
|
+
// WHY IT IS PURE, AND WHY THAT IS THE POINT. Definitions arrive from two places —
|
|
12
|
+
// a committed `.claude/agents/*.md` file reconciled at deploy (agents-sync, task
|
|
13
|
+
// 1002490) and an instance-authored DB row. Both paths need the SAME answer, and
|
|
14
|
+
// the answer must be checkable without a database, a model call, or a booted
|
|
15
|
+
// server. So this file requires NOTHING: not the module doorway, not a sibling,
|
|
16
|
+
// not even a node builtin. That also keeps it trivially inside the ADR 0083
|
|
17
|
+
// boundary — `modules/**` may import only `src/module-api.js`, and importing
|
|
18
|
+
// nothing clears that bar by construction.
|
|
19
|
+
//
|
|
20
|
+
// IT MUST NEVER THROW. The loader's contract is log-and-skip: one malformed
|
|
21
|
+
// definition makes the registry smaller, never makes the boot fail. Every access
|
|
22
|
+
// below is therefore defensive — a hostile getter, a circular `trigger_spec`, a
|
|
23
|
+
// frozen object, `null`: all become a returned error, none become an exception
|
|
24
|
+
// into the caller. The tests assert this directly (tests/agents_validate.mjs).
|
|
25
|
+
//
|
|
26
|
+
// FAIL CLOSED IS THE HOUSE RULE, in three specific places a future reader will be
|
|
27
|
+
// tempted to relax:
|
|
28
|
+
// 1. EVENT_CATALOG is the WIRED set, not the imaginable set. v1 dispatches
|
|
29
|
+
// exactly `task.shipped`. An unwired event name is refused rather than
|
|
30
|
+
// stored, because a definition carrying one looks armed in the registry and
|
|
31
|
+
// can never fire — the worst of both readings.
|
|
32
|
+
// 2. An EMPTY allowed-module set means NOTHING is in scope, never everything.
|
|
33
|
+
// A caller that forgets to pass `allowedModules` gets refusals, not a
|
|
34
|
+
// wide-open agent. Same rule, same reason as goal-scope-check's `empty_scope`.
|
|
35
|
+
// 3. An UNRESOLVED author rank has no authority over a protected module. A file
|
|
36
|
+
// cannot grant authority (ADR 0016), so "we could not determine the rank" is
|
|
37
|
+
// treated as "not enough rank", not as "skip the check".
|
|
38
|
+
//
|
|
39
|
+
// THE DB HOLDS THESE BOUNDS TOO, DELIBERATELY. Every limit here mirrors a CHECK
|
|
40
|
+
// constraint in agents_001_definitions.sql. That duplication is defense in depth
|
|
41
|
+
// and is meant to stay: the constraint is what stops a row that skipped this
|
|
42
|
+
// validator, and this validator is what turns a would-be constraint violation
|
|
43
|
+
// into a readable message naming the offending field — an insert that throws at
|
|
44
|
+
// boot is precisely the crash the log-and-skip contract exists to prevent.
|
|
45
|
+
|
|
46
|
+
// The WIRED event set for v1. Widening this is a deliberate act that must land
|
|
47
|
+
// together with something that actually dispatches the new event.
|
|
48
|
+
const EVENT_CATALOG = Object.freeze(['task.shipped']);
|
|
49
|
+
|
|
50
|
+
// Mirrors agents_definitions_trigger_type_chk. 'event' consumes the catalog above;
|
|
51
|
+
// 'on-demand' is the historian shape (a builder asks, the agent answers).
|
|
52
|
+
const TRIGGER_TYPES = Object.freeze(['event', 'on-demand']);
|
|
53
|
+
|
|
54
|
+
// Mirrors agents_definitions_model_tier_chk. No 'main' tier by design — an agent
|
|
55
|
+
// is a subagent, never the main session.
|
|
56
|
+
const MODEL_TIERS = Object.freeze(['default', 'routine']);
|
|
57
|
+
|
|
58
|
+
// The rank ladder, low → high. Mirrors government/catalog.js RANK_ORDER and the
|
|
59
|
+
// agents_definitions_author_rank_chk vocabulary. Duplicated rather than imported
|
|
60
|
+
// because the module boundary forbids reaching into core (ADR 0083) — and it is a
|
|
61
|
+
// closed, rarely-changing vocabulary the DB constraint pins independently.
|
|
62
|
+
const RANK_ORDER = Object.freeze(['xenos', 'thetes', 'metic', 'archon']);
|
|
63
|
+
|
|
64
|
+
// Sub-Metic builders stay out of the permission/methodology core (ADR 0043), and
|
|
65
|
+
// that applies to an agent they author just as it applies to a task they claim.
|
|
66
|
+
const SCOPE_RANK_FLOOR = 'metic';
|
|
67
|
+
|
|
68
|
+
// Filename / URL-segment / CLI-argument safe — the same shape a module key takes,
|
|
69
|
+
// mirroring agents_definitions_name_fmt_chk.
|
|
70
|
+
const NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
71
|
+
const NAME_MAX = 64;
|
|
72
|
+
// Mirrors agents_definitions_persona_size_chk / _trigger_spec_size_chk. A persona
|
|
73
|
+
// is a prompt sent to a paid model on every fire, so an unbounded one is both a
|
|
74
|
+
// cost and a blast radius.
|
|
75
|
+
const PERSONA_MAX = 32768;
|
|
76
|
+
const TRIGGER_SPEC_MAX = 8192;
|
|
77
|
+
|
|
78
|
+
// A plain data object — not an array, not null, not a boxed primitive. Arrays are
|
|
79
|
+
// excluded explicitly because `typeof [] === 'object'` would otherwise let a list
|
|
80
|
+
// masquerade as a spec.
|
|
81
|
+
function isPlainObject(v) {
|
|
82
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isNonEmptyString(v) {
|
|
86
|
+
return typeof v === 'string' && v.trim().length > 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Read one property without trusting the object. A definition can arrive as a
|
|
90
|
+
// db-sourced object with an accessor on it; a throwing getter must become a
|
|
91
|
+
// refusal, not an exception into the loader.
|
|
92
|
+
function safeGet(obj, key) {
|
|
93
|
+
try { return obj[key]; } catch (_) { return undefined; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A scope path must stay inside the tree. Rejects absolute POSIX and Windows
|
|
97
|
+
// paths, and any `..` segment on either separator — the escape a scope wall
|
|
98
|
+
// exists to stop.
|
|
99
|
+
function isSafeScopePath(p) {
|
|
100
|
+
if (!isNonEmptyString(p)) return false;
|
|
101
|
+
if (p.startsWith('/') || p.startsWith('\\')) return false;
|
|
102
|
+
if (/^[a-zA-Z]:/.test(p)) return false;
|
|
103
|
+
return !p.split(/[\\/]/).includes('..');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Validate one candidate agent definition. Pure, total, and never throws.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} obj the candidate definition (file frontmatter or a db row).
|
|
110
|
+
* @param {object} [opts]
|
|
111
|
+
* @param {string|null} [opts.authorRank] the author's LIVE DB rank (ADR 0016).
|
|
112
|
+
* Never read from the definition itself.
|
|
113
|
+
* @param {string[]} [opts.allowedModules] module keys this definition may be
|
|
114
|
+
* scoped to. Empty ⇒ nothing is in scope.
|
|
115
|
+
* @param {string[]} [opts.protectedModules] the subset of those that require Metic+.
|
|
116
|
+
* @returns {{ok: boolean, errors: Array<{code: string, field: string, message: string}>, value: object|null}}
|
|
117
|
+
* `value` is the normalized definition (defaults applied) on success, and
|
|
118
|
+
* `null` on any failure — a rejected definition must never hand back a row
|
|
119
|
+
* something downstream could insert.
|
|
120
|
+
*/
|
|
121
|
+
function validateAgentDefinition(obj, opts = {}) {
|
|
122
|
+
const errors = [];
|
|
123
|
+
const E = (code, field, message) => errors.push({ code, field, message });
|
|
124
|
+
|
|
125
|
+
if (!isPlainObject(obj)) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
errors: [{
|
|
129
|
+
code: 'not_an_object',
|
|
130
|
+
field: '(root)',
|
|
131
|
+
message: `An agent definition must be an object; received ${obj === null ? 'null' : Array.isArray(obj) ? 'an array' : typeof obj}.`,
|
|
132
|
+
}],
|
|
133
|
+
value: null,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Read through safeGet for the same reason the definition is: a caller may hand
|
|
138
|
+
// us a proxy or an accessor-bearing object, and the never-throws contract covers
|
|
139
|
+
// the whole call, not just the definition half of it.
|
|
140
|
+
const options = isPlainObject(opts) ? opts : {};
|
|
141
|
+
const authorRank = safeGet(options, 'authorRank');
|
|
142
|
+
const rawAllowed = safeGet(options, 'allowedModules');
|
|
143
|
+
const rawProtected = safeGet(options, 'protectedModules');
|
|
144
|
+
const allowedModules = Array.isArray(rawAllowed) ? rawAllowed : [];
|
|
145
|
+
const protectedModules = Array.isArray(rawProtected) ? rawProtected : [];
|
|
146
|
+
|
|
147
|
+
// --- name ----------------------------------------------------------------
|
|
148
|
+
const name = safeGet(obj, 'name');
|
|
149
|
+
if (!isNonEmptyString(name) || !NAME_RE.test(name) || name.length > NAME_MAX) {
|
|
150
|
+
E('name_invalid', 'name',
|
|
151
|
+
`name: ${JSON.stringify(name)} must be lowercase kebab-case matching ${NAME_RE} and at most ${NAME_MAX} characters — it is used as a filename, a URL segment and a CLI argument.`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// --- persona --------------------------------------------------------------
|
|
155
|
+
const persona = safeGet(obj, 'persona');
|
|
156
|
+
if (!isNonEmptyString(persona)) {
|
|
157
|
+
E('persona_missing', 'persona',
|
|
158
|
+
'persona: required — an agent with no role prompt has nothing to run. Give it a non-empty string saying what it observes and reports.');
|
|
159
|
+
} else if (persona.length > PERSONA_MAX) {
|
|
160
|
+
E('persona_too_long', 'persona',
|
|
161
|
+
`persona: ${persona.length} characters exceeds the ${PERSONA_MAX} limit — the persona is sent to a paid model on every fire.`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- trigger type ---------------------------------------------------------
|
|
165
|
+
const triggerType = safeGet(obj, 'trigger_type');
|
|
166
|
+
const triggerTypeOk = typeof triggerType === 'string' && TRIGGER_TYPES.includes(triggerType);
|
|
167
|
+
if (!triggerTypeOk) {
|
|
168
|
+
E('trigger_type_unknown', 'trigger_type',
|
|
169
|
+
`trigger_type: ${JSON.stringify(triggerType)} is not a wired trigger type. v1 wires exactly: ${TRIGGER_TYPES.join(', ')}.`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// --- trigger spec ---------------------------------------------------------
|
|
173
|
+
// Defaults to {} when absent, matching the column default. Present-but-wrong is
|
|
174
|
+
// a refusal; absent is a legitimate on-demand definition.
|
|
175
|
+
const rawSpec = safeGet(obj, 'trigger_spec');
|
|
176
|
+
let spec = null;
|
|
177
|
+
if (rawSpec === undefined || rawSpec === null) {
|
|
178
|
+
spec = {};
|
|
179
|
+
} else if (!isPlainObject(rawSpec)) {
|
|
180
|
+
E('trigger_spec_not_object', 'trigger_spec',
|
|
181
|
+
`trigger_spec: must be an object of parameters; received ${Array.isArray(rawSpec) ? 'an array' : typeof rawSpec}. It carries the trigger's parameters, never a payload.`);
|
|
182
|
+
} else {
|
|
183
|
+
spec = rawSpec;
|
|
184
|
+
// The size bound is over the SERIALIZED spec, exactly as the DB CHECK measures
|
|
185
|
+
// it — and JSON.stringify throws on a cycle, which is why this is guarded.
|
|
186
|
+
let serialized = null;
|
|
187
|
+
try {
|
|
188
|
+
serialized = JSON.stringify(spec);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
E('trigger_spec_unserializable', 'trigger_spec',
|
|
191
|
+
`trigger_spec: could not be serialized (${(err && err.message) || 'circular structure'}) — it must be plain JSON data.`);
|
|
192
|
+
spec = null;
|
|
193
|
+
}
|
|
194
|
+
if (serialized !== null && serialized.length > TRIGGER_SPEC_MAX) {
|
|
195
|
+
E('trigger_spec_too_long', 'trigger_spec',
|
|
196
|
+
`trigger_spec: ${serialized.length} serialized characters exceeds the ${TRIGGER_SPEC_MAX} limit — it is parameters, never payload.`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// --- the event catalog, fail-closed ---------------------------------------
|
|
201
|
+
// Checked whenever an event NAME is present, and additionally REQUIRED for an
|
|
202
|
+
// event trigger. An inert `event:` on an on-demand definition is either a
|
|
203
|
+
// typo'd trigger_type or dead config; refusing an unwired name in both places
|
|
204
|
+
// is what "fails closed on any unwired event name" actually means.
|
|
205
|
+
if (spec) {
|
|
206
|
+
const event = safeGet(spec, 'event');
|
|
207
|
+
const hasEventKey = event !== undefined && event !== null;
|
|
208
|
+
if (triggerType === 'event' && !isNonEmptyString(event)) {
|
|
209
|
+
E('trigger_event_missing', 'trigger_spec.event',
|
|
210
|
+
`trigger_spec.event: required for an 'event' trigger — a definition with no event name can never fire. Wired events: ${EVENT_CATALOG.join(', ')}.`);
|
|
211
|
+
} else if (hasEventKey && isNonEmptyString(event) && !EVENT_CATALOG.includes(event)) {
|
|
212
|
+
E('trigger_event_unwired', 'trigger_spec.event',
|
|
213
|
+
`trigger_spec.event: ${JSON.stringify(event)} is not a wired event and is refused rather than stored — nothing dispatches it, so the agent would look armed and never fire. Wired events: ${EVENT_CATALOG.join(', ')}.`);
|
|
214
|
+
} else if (hasEventKey && !isNonEmptyString(event) && triggerType !== 'event') {
|
|
215
|
+
E('trigger_event_unwired', 'trigger_spec.event',
|
|
216
|
+
`trigger_spec.event: ${JSON.stringify(event)} is not a wired event name. Wired events: ${EVENT_CATALOG.join(', ')}.`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- model tier -----------------------------------------------------------
|
|
221
|
+
const rawTier = safeGet(obj, 'model_tier');
|
|
222
|
+
const modelTier = rawTier === undefined || rawTier === null ? 'default' : rawTier;
|
|
223
|
+
if (!MODEL_TIERS.includes(modelTier)) {
|
|
224
|
+
E('model_tier_unknown', 'model_tier',
|
|
225
|
+
`model_tier: ${JSON.stringify(rawTier)} is not a known tier (${MODEL_TIERS.join(', ')}). The tier is stored and resolved to a concrete model at fire time; there is deliberately no 'main' tier.`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// --- scope: shape ---------------------------------------------------------
|
|
229
|
+
const rawScopeModules = safeGet(obj, 'scope_modules');
|
|
230
|
+
let scopeModules = null;
|
|
231
|
+
if (rawScopeModules === undefined || rawScopeModules === null) {
|
|
232
|
+
scopeModules = [];
|
|
233
|
+
} else if (!Array.isArray(rawScopeModules) || !rawScopeModules.every(isNonEmptyString)) {
|
|
234
|
+
E('scope_modules_invalid', 'scope_modules',
|
|
235
|
+
'scope_modules: must be an array of non-empty module-key strings.');
|
|
236
|
+
} else {
|
|
237
|
+
scopeModules = rawScopeModules;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const rawScopePaths = safeGet(obj, 'scope_paths');
|
|
241
|
+
let scopePaths = null;
|
|
242
|
+
if (rawScopePaths === undefined || rawScopePaths === null) {
|
|
243
|
+
scopePaths = [];
|
|
244
|
+
} else if (!Array.isArray(rawScopePaths) || !rawScopePaths.every(isNonEmptyString)) {
|
|
245
|
+
E('scope_paths_invalid', 'scope_paths',
|
|
246
|
+
'scope_paths: must be an array of non-empty repo-relative path strings.');
|
|
247
|
+
} else {
|
|
248
|
+
const escaping = rawScopePaths.filter((p) => !isSafeScopePath(p));
|
|
249
|
+
if (escaping.length > 0) {
|
|
250
|
+
E('scope_paths_invalid', 'scope_paths',
|
|
251
|
+
`scope_paths: ${escaping.map((p) => JSON.stringify(p)).join(', ')} escape the repository tree — a scope path must be relative and contain no '..' segment.`);
|
|
252
|
+
} else {
|
|
253
|
+
scopePaths = rawScopePaths;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --- scope: the wall ------------------------------------------------------
|
|
258
|
+
// Note the empty-allowlist behaviour: with nothing allowed, every declared
|
|
259
|
+
// module is out of bounds. That is the fail-closed default, not an oversight.
|
|
260
|
+
if (scopeModules && scopeModules.length > 0) {
|
|
261
|
+
const outOfBounds = scopeModules.filter((m) => !allowedModules.includes(m));
|
|
262
|
+
if (outOfBounds.length > 0) {
|
|
263
|
+
E('scope_out_of_bounds', 'scope_modules',
|
|
264
|
+
`scope_modules: ${outOfBounds.map((m) => JSON.stringify(m)).join(', ')} ${outOfBounds.length === 1 ? 'is' : 'are'} outside the allowed module set (${allowedModules.length ? allowedModules.join(', ') : 'none — no allowed modules were supplied, so nothing is in scope'}).`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// The rank wall, over whatever scope survived the bounds check. An unresolved
|
|
268
|
+
// rank is treated as insufficient, never as a reason to skip the check.
|
|
269
|
+
const protectedHits = scopeModules.filter((m) => protectedModules.includes(m));
|
|
270
|
+
if (protectedHits.length > 0) {
|
|
271
|
+
const rankIdx = typeof authorRank === 'string' ? RANK_ORDER.indexOf(authorRank) : -1;
|
|
272
|
+
if (rankIdx < RANK_ORDER.indexOf(SCOPE_RANK_FLOOR)) {
|
|
273
|
+
E('scope_protected_rank', 'scope_modules',
|
|
274
|
+
`scope_modules: ${protectedHits.map((m) => JSON.stringify(m)).join(', ')} ${protectedHits.length === 1 ? 'is a protected module' : 'are protected modules'} and require a ${SCOPE_RANK_FLOOR}+ author; the author's resolved rank is ${JSON.stringify(authorRank ?? null)} (ADR 0016 / ADR 0043). Authority comes from the author's live rank, never from the definition.`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (errors.length > 0) return { ok: false, errors, value: null };
|
|
280
|
+
|
|
281
|
+
// The normalized row, built fresh — the caller's object is never mutated, and
|
|
282
|
+
// arrays are copied so a later mutation of the input cannot reach the registry.
|
|
283
|
+
return {
|
|
284
|
+
ok: true,
|
|
285
|
+
errors: [],
|
|
286
|
+
value: {
|
|
287
|
+
name,
|
|
288
|
+
title: isNonEmptyString(safeGet(obj, 'title')) ? safeGet(obj, 'title') : null,
|
|
289
|
+
persona,
|
|
290
|
+
trigger_type: triggerType,
|
|
291
|
+
trigger_spec: { ...spec },
|
|
292
|
+
model_tier: modelTier,
|
|
293
|
+
scope_modules: [...scopeModules],
|
|
294
|
+
scope_paths: [...scopePaths],
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
module.exports = {
|
|
300
|
+
validateAgentDefinition,
|
|
301
|
+
EVENT_CATALOG,
|
|
302
|
+
TRIGGER_TYPES,
|
|
303
|
+
MODEL_TIERS,
|
|
304
|
+
RANK_ORDER,
|
|
305
|
+
SCOPE_RANK_FLOOR,
|
|
306
|
+
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.519",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.519",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.519",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
45
45
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
46
46
|
// the entry to that file. Look for a version's history there, not here.
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
|
-
const CORE_VERSION = '1.19.
|
|
48
|
+
const CORE_VERSION = '1.19.519'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
49
49
|
|
|
50
50
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
51
51
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// tests/agents_validate.mjs — the pure agent-definition validator (task 1002487,
|
|
2
|
+
// goal 1000038).
|
|
3
|
+
//
|
|
4
|
+
// Gates criterion `agents-malformed-rejected`: a malformed definition (unknown
|
|
5
|
+
// trigger; missing persona; `trigger_spec.event` not in EVENT_CATALOG; scope
|
|
6
|
+
// exceeding an allowed module) is refused with a clear error, and the validator
|
|
7
|
+
// NEVER throws — the loader logs and skips, it does not crash the boot.
|
|
8
|
+
//
|
|
9
|
+
// No database, no fixtures, no fs. Every input is a literal, because the thing
|
|
10
|
+
// under test is a pure decision.
|
|
11
|
+
//
|
|
12
|
+
// Run: node tests/agents_validate.mjs
|
|
13
|
+
|
|
14
|
+
import { strict as assert } from 'node:assert';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const {
|
|
19
|
+
validateAgentDefinition,
|
|
20
|
+
EVENT_CATALOG,
|
|
21
|
+
TRIGGER_TYPES,
|
|
22
|
+
MODEL_TIERS,
|
|
23
|
+
RANK_ORDER,
|
|
24
|
+
SCOPE_RANK_FLOOR,
|
|
25
|
+
} = require('../modules/agents/lib/validate.js');
|
|
26
|
+
|
|
27
|
+
let passed = 0;
|
|
28
|
+
let failed = 0;
|
|
29
|
+
async function test(name, fn) {
|
|
30
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
31
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A definition that passes everything, so each case below can break exactly one
|
|
35
|
+
// thing and attribute the refusal to that one thing.
|
|
36
|
+
const OK_DEF = Object.freeze({
|
|
37
|
+
name: 'historian',
|
|
38
|
+
persona: 'Observe the shipped task and report what changed.',
|
|
39
|
+
trigger_type: 'event',
|
|
40
|
+
trigger_spec: { event: 'task.shipped' },
|
|
41
|
+
model_tier: 'default',
|
|
42
|
+
scope_modules: ['agents'],
|
|
43
|
+
});
|
|
44
|
+
const OPTS = Object.freeze({ authorRank: 'archon', allowedModules: ['agents'] });
|
|
45
|
+
|
|
46
|
+
const codes = (r) => r.errors.map((e) => e.code);
|
|
47
|
+
const hasCode = (r, code) => codes(r).includes(code);
|
|
48
|
+
|
|
49
|
+
// --- THE BASELINE -----------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
await test('the exemplar definition validates, and normalizes into a value', () => {
|
|
52
|
+
const r = validateAgentDefinition(OK_DEF, OPTS);
|
|
53
|
+
assert.equal(r.ok, true, `expected ok, got errors: ${JSON.stringify(r.errors)}`);
|
|
54
|
+
assert.deepEqual(r.errors, []);
|
|
55
|
+
assert.equal(r.value.name, 'historian');
|
|
56
|
+
assert.equal(r.value.model_tier, 'default');
|
|
57
|
+
assert.deepEqual(r.value.scope_modules, ['agents']);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await test('a failed validation returns NO value — nothing downstream can insert it', () => {
|
|
61
|
+
const r = validateAgentDefinition({ ...OK_DEF, persona: '' }, OPTS);
|
|
62
|
+
assert.equal(r.ok, false);
|
|
63
|
+
assert.equal(r.value, null, 'a rejected definition must not hand back a usable row');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// --- THE EVENT CATALOG — the fail-closed rule -------------------------------
|
|
67
|
+
|
|
68
|
+
await test('EVENT_CATALOG is exactly the v1 wired set, and is frozen', () => {
|
|
69
|
+
assert.deepEqual(EVENT_CATALOG, ['task.shipped']);
|
|
70
|
+
assert.equal(Object.isFrozen(EVENT_CATALOG), true, 'a caller must not be able to widen the catalog at runtime');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await test('an UNWIRED event name is REFUSED, never silently accepted', () => {
|
|
74
|
+
// The whole point of the catalog: an event nothing dispatches would produce an
|
|
75
|
+
// agent that looks armed and never fires.
|
|
76
|
+
for (const bad of ['task.completed', 'task.shipped ', 'TASK.SHIPPED', 'claim.created', 'task.*']) {
|
|
77
|
+
const r = validateAgentDefinition({ ...OK_DEF, trigger_spec: { event: bad } }, OPTS);
|
|
78
|
+
assert.equal(r.ok, false, `"${bad}" must be refused`);
|
|
79
|
+
assert.ok(hasCode(r, 'trigger_event_unwired'), `"${bad}" → ${JSON.stringify(codes(r))}`);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await test('the refusal NAMES the offending event and the catalog', () => {
|
|
84
|
+
const r = validateAgentDefinition({ ...OK_DEF, trigger_spec: { event: 'task.completed' } }, OPTS);
|
|
85
|
+
const msg = r.errors.find((e) => e.code === 'trigger_event_unwired').message;
|
|
86
|
+
assert.ok(msg.includes('task.completed'), `message must name the offender: ${msg}`);
|
|
87
|
+
assert.ok(msg.includes('task.shipped'), `message must name what IS wired: ${msg}`);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await test('an event trigger with no event name is refused', () => {
|
|
91
|
+
for (const spec of [{}, { event: '' }, { event: ' ' }, { event: null }, { event: 42 }]) {
|
|
92
|
+
const r = validateAgentDefinition({ ...OK_DEF, trigger_spec: spec }, OPTS);
|
|
93
|
+
assert.equal(r.ok, false, `${JSON.stringify(spec)} must be refused`);
|
|
94
|
+
assert.ok(
|
|
95
|
+
hasCode(r, 'trigger_event_missing') || hasCode(r, 'trigger_event_unwired'),
|
|
96
|
+
`${JSON.stringify(spec)} → ${JSON.stringify(codes(r))}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await test('an on-demand definition needs no event at all', () => {
|
|
102
|
+
const r = validateAgentDefinition(
|
|
103
|
+
{ ...OK_DEF, trigger_type: 'on-demand', trigger_spec: {} }, OPTS,
|
|
104
|
+
);
|
|
105
|
+
assert.equal(r.ok, true, JSON.stringify(r.errors));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await test('an on-demand definition carrying an UNWIRED event is still refused', () => {
|
|
109
|
+
// Fails closed on the event NAME wherever it appears. An inert `event:` key on
|
|
110
|
+
// an on-demand agent is either a typo'd trigger_type or dead config; both are
|
|
111
|
+
// bugs the author should be shown rather than have silently accepted.
|
|
112
|
+
const r = validateAgentDefinition(
|
|
113
|
+
{ ...OK_DEF, trigger_type: 'on-demand', trigger_spec: { event: 'task.completed' } }, OPTS,
|
|
114
|
+
);
|
|
115
|
+
assert.equal(r.ok, false);
|
|
116
|
+
assert.ok(hasCode(r, 'trigger_event_unwired'));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await test('an on-demand definition carrying a WIRED event is accepted', () => {
|
|
120
|
+
const r = validateAgentDefinition(
|
|
121
|
+
{ ...OK_DEF, trigger_type: 'on-demand', trigger_spec: { event: 'task.shipped' } }, OPTS,
|
|
122
|
+
);
|
|
123
|
+
assert.equal(r.ok, true, JSON.stringify(r.errors));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// --- THE TRIGGER TYPE -------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
await test('TRIGGER_TYPES is the v1 pair the DB CHECK also pins', () => {
|
|
129
|
+
assert.deepEqual(TRIGGER_TYPES, ['event', 'on-demand']);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await test('an unknown trigger type is refused', () => {
|
|
133
|
+
for (const bad of ['cron', 'webhook', 'Event', 'on demand', '', null, undefined, 7, {}]) {
|
|
134
|
+
const r = validateAgentDefinition({ ...OK_DEF, trigger_type: bad }, OPTS);
|
|
135
|
+
assert.equal(r.ok, false, `${JSON.stringify(bad)} must be refused`);
|
|
136
|
+
assert.ok(hasCode(r, 'trigger_type_unknown'), `${JSON.stringify(bad)} → ${JSON.stringify(codes(r))}`);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await test('a non-object trigger_spec is refused rather than coerced', () => {
|
|
141
|
+
for (const bad of ['task.shipped', 42, [], true]) {
|
|
142
|
+
const r = validateAgentDefinition({ ...OK_DEF, trigger_spec: bad }, OPTS);
|
|
143
|
+
assert.equal(r.ok, false, `${JSON.stringify(bad)} must be refused`);
|
|
144
|
+
assert.ok(hasCode(r, 'trigger_spec_not_object'), `${JSON.stringify(bad)} → ${JSON.stringify(codes(r))}`);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// --- THE PERSONA ------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
await test('a missing or empty persona is refused', () => {
|
|
151
|
+
for (const bad of [undefined, null, '', ' ', 42, {}]) {
|
|
152
|
+
const r = validateAgentDefinition({ ...OK_DEF, persona: bad }, OPTS);
|
|
153
|
+
assert.equal(r.ok, false, `${JSON.stringify(bad)} must be refused`);
|
|
154
|
+
assert.ok(hasCode(r, 'persona_missing'), `${JSON.stringify(bad)} → ${JSON.stringify(codes(r))}`);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await test('an oversized persona is refused at the same bound the DB CHECK holds', () => {
|
|
159
|
+
const r = validateAgentDefinition({ ...OK_DEF, persona: 'x'.repeat(32769) }, OPTS);
|
|
160
|
+
assert.equal(r.ok, false);
|
|
161
|
+
assert.ok(hasCode(r, 'persona_too_long'));
|
|
162
|
+
// ...and exactly at the bound it is fine.
|
|
163
|
+
assert.equal(validateAgentDefinition({ ...OK_DEF, persona: 'x'.repeat(32768) }, OPTS).ok, true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// --- THE NAME ---------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
await test('a name that could not be a filename / URL segment / CLI arg is refused', () => {
|
|
169
|
+
for (const bad of ['Historian', '1historian', 'the historian', 'his/torian', '-historian', '', 'a'.repeat(65)]) {
|
|
170
|
+
const r = validateAgentDefinition({ ...OK_DEF, name: bad }, OPTS);
|
|
171
|
+
assert.equal(r.ok, false, `"${bad}" must be refused`);
|
|
172
|
+
assert.ok(hasCode(r, 'name_invalid'), `"${bad}" → ${JSON.stringify(codes(r))}`);
|
|
173
|
+
}
|
|
174
|
+
assert.equal(validateAgentDefinition({ ...OK_DEF, name: 'a'.repeat(64) }, OPTS).ok, true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// --- THE MODEL TIER ---------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
await test('model_tier defaults to `default` when absent, and rejects an unknown tier', () => {
|
|
180
|
+
assert.deepEqual(MODEL_TIERS, ['default', 'routine']);
|
|
181
|
+
const { model_tier: _omit, ...noTier } = OK_DEF;
|
|
182
|
+
const r = validateAgentDefinition(noTier, OPTS);
|
|
183
|
+
assert.equal(r.ok, true, JSON.stringify(r.errors));
|
|
184
|
+
assert.equal(r.value.model_tier, 'default');
|
|
185
|
+
|
|
186
|
+
// spawn.js's resolveModel() deliberately DEGRADES an unknown tier to the default
|
|
187
|
+
// tier at fire time; that is a runtime safety net, not a licence to author one.
|
|
188
|
+
const bad = validateAgentDefinition({ ...OK_DEF, model_tier: 'main' }, OPTS);
|
|
189
|
+
assert.equal(bad.ok, false);
|
|
190
|
+
assert.ok(hasCode(bad, 'model_tier_unknown'));
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await test('there is no `main` tier — an agent is a subagent, never the main session', () => {
|
|
194
|
+
assert.equal(MODEL_TIERS.includes('main'), false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// --- THE SCOPE WALL ---------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
await test('scope exceeding the allowed modules is refused, naming the offenders', () => {
|
|
200
|
+
const r = validateAgentDefinition(
|
|
201
|
+
{ ...OK_DEF, scope_modules: ['agents', 'government', 'lifecycle'] },
|
|
202
|
+
{ authorRank: 'archon', allowedModules: ['agents'] },
|
|
203
|
+
);
|
|
204
|
+
assert.equal(r.ok, false);
|
|
205
|
+
assert.ok(hasCode(r, 'scope_out_of_bounds'));
|
|
206
|
+
const msg = r.errors.find((e) => e.code === 'scope_out_of_bounds').message;
|
|
207
|
+
assert.ok(msg.includes('government') && msg.includes('lifecycle'), `must name the offenders: ${msg}`);
|
|
208
|
+
assert.ok(!msg.includes("'agents'"), 'must not accuse the module that WAS allowed');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await test('scope FAILS CLOSED when the caller supplies no allowed-module set', () => {
|
|
212
|
+
// The dangerous default. An empty allowlist must mean "nothing is in scope",
|
|
213
|
+
// never "everything is" — the same rule goal-scope-check's empty_scope holds.
|
|
214
|
+
for (const opts of [{ authorRank: 'archon' }, { authorRank: 'archon', allowedModules: [] }, undefined]) {
|
|
215
|
+
const r = validateAgentDefinition(OK_DEF, opts);
|
|
216
|
+
assert.equal(r.ok, false, `${JSON.stringify(opts)} must fail closed`);
|
|
217
|
+
assert.ok(hasCode(r, 'scope_out_of_bounds'), `${JSON.stringify(opts)} → ${JSON.stringify(codes(r))}`);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await test('an empty scope_modules is allowed — it grants nothing', () => {
|
|
222
|
+
const r = validateAgentDefinition({ ...OK_DEF, scope_modules: [] }, { authorRank: 'archon', allowedModules: [] });
|
|
223
|
+
assert.equal(r.ok, true, JSON.stringify(r.errors));
|
|
224
|
+
assert.deepEqual(r.value.scope_modules, []);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await test('a non-array / non-string scope is refused rather than coerced', () => {
|
|
228
|
+
for (const bad of ['agents', 42, {}, ['agents', 7], ['agents', '']]) {
|
|
229
|
+
const r = validateAgentDefinition({ ...OK_DEF, scope_modules: bad }, OPTS);
|
|
230
|
+
assert.equal(r.ok, false, `${JSON.stringify(bad)} must be refused`);
|
|
231
|
+
assert.ok(hasCode(r, 'scope_modules_invalid'), `${JSON.stringify(bad)} → ${JSON.stringify(codes(r))}`);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await test('a scope_path that escapes the tree is refused', () => {
|
|
236
|
+
for (const bad of ['../../etc/passwd', '/etc/passwd', 'modules/../../..', 'C:\\Windows']) {
|
|
237
|
+
const r = validateAgentDefinition({ ...OK_DEF, scope_paths: [bad] }, OPTS);
|
|
238
|
+
assert.equal(r.ok, false, `"${bad}" must be refused`);
|
|
239
|
+
assert.ok(hasCode(r, 'scope_paths_invalid'), `"${bad}" → ${JSON.stringify(codes(r))}`);
|
|
240
|
+
}
|
|
241
|
+
assert.equal(validateAgentDefinition({ ...OK_DEF, scope_paths: ['modules/agents/'] }, OPTS).ok, true);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// --- THE RANK WALL (ADR 0016 — authority is the author's LIVE rank) ---------
|
|
245
|
+
|
|
246
|
+
await test('RANK_ORDER is the ladder low→high, and the scope floor is Metic', () => {
|
|
247
|
+
assert.deepEqual(RANK_ORDER, ['xenos', 'thetes', 'metic', 'archon']);
|
|
248
|
+
assert.equal(SCOPE_RANK_FLOOR, 'metic');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
await test('a sub-Metic author may not scope an agent onto a PROTECTED module', () => {
|
|
252
|
+
const opts = { allowedModules: ['agents', 'government'], protectedModules: ['government'] };
|
|
253
|
+
for (const rank of ['xenos', 'thetes']) {
|
|
254
|
+
const r = validateAgentDefinition({ ...OK_DEF, scope_modules: ['government'] }, { ...opts, authorRank: rank });
|
|
255
|
+
assert.equal(r.ok, false, `${rank} must not reach a protected module`);
|
|
256
|
+
assert.ok(hasCode(r, 'scope_protected_rank'), `${rank} → ${JSON.stringify(codes(r))}`);
|
|
257
|
+
}
|
|
258
|
+
// Metic+ clear the floor.
|
|
259
|
+
for (const rank of ['metic', 'archon']) {
|
|
260
|
+
const r = validateAgentDefinition({ ...OK_DEF, scope_modules: ['government'] }, { ...opts, authorRank: rank });
|
|
261
|
+
assert.equal(r.ok, true, `${rank} should clear the floor: ${JSON.stringify(r.errors)}`);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await test('an UNKNOWN author rank fails CLOSED against a protected module', () => {
|
|
266
|
+
// A file cannot grant authority (ADR 0016). If the caller could not name a live
|
|
267
|
+
// rank, the definition has no authority to reach a protected module.
|
|
268
|
+
const opts = { allowedModules: ['government'], protectedModules: ['government'] };
|
|
269
|
+
for (const rank of [undefined, null, '', 'admin', 'ARCHON']) {
|
|
270
|
+
const r = validateAgentDefinition({ ...OK_DEF, scope_modules: ['government'] }, { ...opts, authorRank: rank });
|
|
271
|
+
assert.equal(r.ok, false, `${JSON.stringify(rank)} must fail closed`);
|
|
272
|
+
assert.ok(hasCode(r, 'scope_protected_rank'), `${JSON.stringify(rank)} → ${JSON.stringify(codes(r))}`);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await test('an unknown rank is NOT fatal when no protected module is in scope', () => {
|
|
277
|
+
// The rank wall guards protected scope only; an ordinary agent does not need a
|
|
278
|
+
// resolved rank to be structurally valid.
|
|
279
|
+
const r = validateAgentDefinition(OK_DEF, { authorRank: 'nonsense', allowedModules: ['agents'] });
|
|
280
|
+
assert.equal(r.ok, true, JSON.stringify(r.errors));
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// --- NEVER CRASH THE BOOT ---------------------------------------------------
|
|
284
|
+
|
|
285
|
+
await test('a non-object definition is refused, not thrown on', () => {
|
|
286
|
+
for (const bad of [null, undefined, 'historian', 42, [], true]) {
|
|
287
|
+
let r;
|
|
288
|
+
assert.doesNotThrow(() => { r = validateAgentDefinition(bad, OPTS); }, `${JSON.stringify(bad)} threw`);
|
|
289
|
+
assert.equal(r.ok, false);
|
|
290
|
+
assert.ok(hasCode(r, 'not_an_object'), `${JSON.stringify(bad)} → ${JSON.stringify(codes(r))}`);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await test('a CIRCULAR trigger_spec is refused, not thrown on', () => {
|
|
295
|
+
// The size check is over the serialized spec, and JSON.stringify THROWS on a
|
|
296
|
+
// cycle. A definition file cannot express one, but a db-sourced object can —
|
|
297
|
+
// and this validator is the thing standing between that and the boot.
|
|
298
|
+
const spec = { event: 'task.shipped' };
|
|
299
|
+
spec.self = spec;
|
|
300
|
+
let r;
|
|
301
|
+
assert.doesNotThrow(() => { r = validateAgentDefinition({ ...OK_DEF, trigger_spec: spec }, OPTS); });
|
|
302
|
+
assert.equal(r.ok, false);
|
|
303
|
+
assert.ok(hasCode(r, 'trigger_spec_unserializable'), JSON.stringify(codes(r)));
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
await test('an oversized trigger_spec is refused at the DB CHECK bound', () => {
|
|
307
|
+
const r = validateAgentDefinition(
|
|
308
|
+
{ ...OK_DEF, trigger_spec: { event: 'task.shipped', pad: 'x'.repeat(9000) } }, OPTS,
|
|
309
|
+
);
|
|
310
|
+
assert.equal(r.ok, false);
|
|
311
|
+
assert.ok(hasCode(r, 'trigger_spec_too_long'));
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
await test('a hostile getter cannot escape the validator', () => {
|
|
315
|
+
// trigger_spec is PARAMETERS, never payload — and a throwing accessor must
|
|
316
|
+
// become a refusal, not an exception into the loader.
|
|
317
|
+
const hostile = { name: 'historian', persona: 'x', trigger_type: 'event' };
|
|
318
|
+
Object.defineProperty(hostile, 'trigger_spec', { get() { throw new Error('boom'); }, enumerable: true });
|
|
319
|
+
let r;
|
|
320
|
+
assert.doesNotThrow(() => { r = validateAgentDefinition(hostile, OPTS); });
|
|
321
|
+
assert.equal(r.ok, false);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await test('EVERY error carries a code, a field and a human message', () => {
|
|
325
|
+
const r = validateAgentDefinition(
|
|
326
|
+
{ name: 'BAD NAME', trigger_type: 'cron', trigger_spec: 'nope', scope_modules: ['government'] },
|
|
327
|
+
{ authorRank: 'xenos', allowedModules: ['agents'] },
|
|
328
|
+
);
|
|
329
|
+
assert.equal(r.ok, false);
|
|
330
|
+
assert.ok(r.errors.length >= 4, `expected the whole list, got ${JSON.stringify(codes(r))}`);
|
|
331
|
+
for (const e of r.errors) {
|
|
332
|
+
assert.equal(typeof e.code, 'string', JSON.stringify(e));
|
|
333
|
+
assert.equal(typeof e.field, 'string', JSON.stringify(e));
|
|
334
|
+
assert.ok(e.message && e.message.length > 10, `message too thin: ${JSON.stringify(e)}`);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await test('validation reports ALL faults at once, not just the first', () => {
|
|
339
|
+
// An author fixing a definition file should not have to re-run four times.
|
|
340
|
+
const r = validateAgentDefinition({ name: 'X', persona: '', trigger_type: 'cron' }, OPTS);
|
|
341
|
+
for (const code of ['name_invalid', 'persona_missing', 'trigger_type_unknown']) {
|
|
342
|
+
assert.ok(hasCode(r, code), `${code} missing from ${JSON.stringify(codes(r))}`);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
await test('the validator never MUTATES the definition it was handed', () => {
|
|
347
|
+
const def = { ...OK_DEF, trigger_spec: { event: 'task.shipped' } };
|
|
348
|
+
const before = JSON.stringify(def);
|
|
349
|
+
validateAgentDefinition(def, OPTS);
|
|
350
|
+
assert.equal(JSON.stringify(def), before);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
console.log(`\nagents_validate: ${passed} passed, ${failed} failed`);
|
|
354
|
+
if (failed > 0) process.exit(1);
|
|
@@ -116,6 +116,17 @@ const STATIC_LADDER_FILES = [
|
|
|
116
116
|
// which holds it against catalog.RANK_ORDER and holds the whole browser parser
|
|
117
117
|
// equal to the server one.
|
|
118
118
|
'modules/hall-ui/public/government.js',
|
|
119
|
+
// task 1002487: the pure agent-definition validator applies the ADR 0043
|
|
120
|
+
// sub-Metic floor to an agent's declared scope, so it needs the ladder to
|
|
121
|
+
// compare against. It is one of the "dependency-free" cases this array exists
|
|
122
|
+
// for, and for a sharper reason than the browser statics above: its whole
|
|
123
|
+
// contract is to be checkable with NO database, and the doorway it would
|
|
124
|
+
// otherwise import (src/module-api.js) eagerly requires ./bongos/pool at load.
|
|
125
|
+
// Deriving the ladder from api.LIVE_RANK_LADDER would therefore drag a
|
|
126
|
+
// Postgres pool into a validator that must run in a bare unit test — so the
|
|
127
|
+
// copy is pinned here instead, which is what makes it provable rather than
|
|
128
|
+
// merely allowed.
|
|
129
|
+
'modules/agents/lib/validate.js',
|
|
119
130
|
];
|
|
120
131
|
test('static ladder copies carry the exact canonical ladder', () => {
|
|
121
132
|
for (const rel of STATIC_LADDER_FILES) {
|