@twinsearth/w2m-dsh-plugin 0.9.7 → 0.10.2
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/CHANGELOG.md +273 -0
- package/ROADMAP.md +135 -0
- package/package.json +11 -4
- package/src/agent/agent.mjs +38 -32
- package/src/agent/approval-index.mjs +300 -0
- package/src/agent/authority.mjs +305 -0
- package/src/agent/blocks.mjs +426 -0
- package/src/agent/health.mjs +330 -0
- package/src/agent/storage.mjs +380 -0
- package/src/plugin/tools.mjs +1 -1
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Numeric authority: who may do what, expressed as an integer.
|
|
3
|
+
*
|
|
4
|
+
* ## Why an integer rather than a list of names
|
|
5
|
+
*
|
|
6
|
+
* The roadmap has authority appearing in four of its five layers -- the permission model, the approval
|
|
7
|
+
* flow, committee voting weights, and the defence hierarchy where a Coordinator outranks an Analyst. An
|
|
8
|
+
* enumerated set of role names would have to be re-partitioned by each of those layers, and each
|
|
9
|
+
* re-partition would be a place for two of them to disagree. An integer orders every actor on one axis,
|
|
10
|
+
* so "does A outrank B" is a comparison rather than a lookup table that has to be kept in step.
|
|
11
|
+
*
|
|
12
|
+
* The model is Unix-like on purpose: `matching_exempt_level` mirrors root's exemption from file
|
|
13
|
+
* permission checks. That is a *deliberate* hole in the rules, so it is the most carefully bounded thing
|
|
14
|
+
* in this file -- see `evaluate`.
|
|
15
|
+
*
|
|
16
|
+
* ## The four bands
|
|
17
|
+
*
|
|
18
|
+
* | Band | Range | May |
|
|
19
|
+
* |---|---|---|
|
|
20
|
+
* | ordinary | 0–49 | create goals and session data within its own scope |
|
|
21
|
+
* | captain | 50–69 | create team goals, manage team membership |
|
|
22
|
+
* | org admin | 70–89 | create organisation goals, register actors, set overrides |
|
|
23
|
+
* | system admin | 90+ | create global goals, configure across organisations, override any lower constraint |
|
|
24
|
+
*
|
|
25
|
+
* ## What this module deliberately does not do
|
|
26
|
+
*
|
|
27
|
+
* It does not decide *what* an action is, only whether an actor's level satisfies a stated rule. Action
|
|
28
|
+
* classification -- is this a write, a scope change, a registration -- belongs to whatever knows the
|
|
29
|
+
* action, and putting it here would mean this file had to be edited every time a new action appeared.
|
|
30
|
+
* That is how an authority model becomes a bottleneck and then gets bypassed.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The bands, as data rather than as branches, so a reader can enumerate them. */
|
|
34
|
+
export const AUTHORITY_BANDS = Object.freeze([
|
|
35
|
+
{ name: 'ordinary', min: 0, max: 49, may: 'create goals and session data within its own scope' },
|
|
36
|
+
{ name: 'captain', min: 50, max: 69, may: 'create team goals, manage team membership' },
|
|
37
|
+
{ name: 'org_admin', min: 70, max: 89, may: 'create organisation goals, register actors, set overrides' },
|
|
38
|
+
{ name: 'system_admin', min: 90, max: null, may: 'create global goals, configure across organisations, override any lower constraint' },
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** The highest level this model will accept. Above it, a level is a mistake rather than a promotion. */
|
|
42
|
+
export const AUTHORITY_MAX = 100;
|
|
43
|
+
|
|
44
|
+
/** Verdicts an authority check can reach. */
|
|
45
|
+
export const AUTHORITY_VERDICT = Object.freeze({
|
|
46
|
+
ALLOW: 'allow',
|
|
47
|
+
NEEDS_APPROVAL: 'needs_approval',
|
|
48
|
+
DENY: 'deny',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Is this a usable authority level?
|
|
53
|
+
*
|
|
54
|
+
* Rejects non-integers as well as out-of-range values. `50.5` is not "between captain and org admin"; it
|
|
55
|
+
* is a value nobody defined, and accepting it would make band boundaries behave differently for actors
|
|
56
|
+
* that are supposed to be equal.
|
|
57
|
+
*
|
|
58
|
+
* @param {unknown} level
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
export function isAuthorityLevel(level) {
|
|
62
|
+
return Number.isInteger(level) && level >= 0 && level <= AUTHORITY_MAX;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Which band a level falls in, or `null` for a value that is not a level.
|
|
67
|
+
*
|
|
68
|
+
* @param {unknown} level
|
|
69
|
+
* @returns {{name: string, min: number, max: number|null, may: string}|null}
|
|
70
|
+
*/
|
|
71
|
+
export function bandOf(level) {
|
|
72
|
+
if (!isAuthorityLevel(level)) return null;
|
|
73
|
+
for (const band of AUTHORITY_BANDS) {
|
|
74
|
+
if (level >= band.min && (band.max === null || level <= band.max)) return band;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A rule about an action.
|
|
81
|
+
*
|
|
82
|
+
* `minLevel` is the floor for performing it at all. `matchingExemptLevel`, when present, is the level at
|
|
83
|
+
* or above which the *membership* part of the rule is skipped -- the Unix-root analogy. `scope` names what
|
|
84
|
+
* the action is performed against, so a refusal can say which rule refused it.
|
|
85
|
+
*
|
|
86
|
+
* @typedef {object} AuthorityRule
|
|
87
|
+
* @property {string} action
|
|
88
|
+
* @property {number} minLevel
|
|
89
|
+
* @property {number|null} [matchingExemptLevel]
|
|
90
|
+
* @property {string|null} [scope]
|
|
91
|
+
* @property {string} [why]
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Cheap structural validation, separate from evaluation.
|
|
96
|
+
*
|
|
97
|
+
* A malformed rule is a configuration defect and should be caught where it is written, not silently
|
|
98
|
+
* treated as a rule that permits nothing -- "denied" and "misconfigured" look identical from the outside
|
|
99
|
+
* and only one of them means someone should fix something.
|
|
100
|
+
*
|
|
101
|
+
* @param {unknown} rule
|
|
102
|
+
* @returns {string|null} a reason, or `null` when the rule is well formed.
|
|
103
|
+
*/
|
|
104
|
+
export function ruleProblem(rule) {
|
|
105
|
+
if (rule === null || typeof rule !== 'object') return 'rule must be an object';
|
|
106
|
+
if (typeof rule.action !== 'string' || rule.action.trim() === '') return 'rule.action must be a non-empty string';
|
|
107
|
+
if (!isAuthorityLevel(rule.minLevel)) return `rule.minLevel must be an integer 0..${AUTHORITY_MAX}`;
|
|
108
|
+
if (rule.matchingExemptLevel !== undefined && rule.matchingExemptLevel !== null
|
|
109
|
+
&& !isAuthorityLevel(rule.matchingExemptLevel)) {
|
|
110
|
+
return `rule.matchingExemptLevel must be an integer 0..${AUTHORITY_MAX} or null`;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Evaluate one rule against one actor.
|
|
117
|
+
*
|
|
118
|
+
* The order of the three checks is the whole security property, so it is stated rather than implied:
|
|
119
|
+
*
|
|
120
|
+
* 1. **Exemption**, and only when the rule names one. An exempt actor skips the *membership* check --
|
|
121
|
+
* not the level check, and not the existence of the rule. This is the Unix-root analogy: root
|
|
122
|
+
* bypasses a file's permission bits, not the fact that the file must exist.
|
|
123
|
+
* 2. **Membership**, when the rule has a scope and the caller supplied one. An actor outside the scope
|
|
124
|
+
* needs a supervisor, not a refusal, which is why this yields `needs_approval` rather than `deny`.
|
|
125
|
+
* 3. **Level**, the plain floor.
|
|
126
|
+
*
|
|
127
|
+
* A rule with no `matchingExemptLevel` exempts **nobody**. That is the decision most worth stating: the
|
|
128
|
+
* tempting default is "absent means unconstrained", and it is wrong in the one direction that matters --
|
|
129
|
+
* a rule author who forgets the field would silently create an unrestricted action.
|
|
130
|
+
*
|
|
131
|
+
* @param {AuthorityRule} rule
|
|
132
|
+
* @param {{level: number, inScope?: boolean|null, id?: string|null}} actor
|
|
133
|
+
* @returns {{verdict: string, action: string|null, level: number|null, exempt: boolean, reason: string}}
|
|
134
|
+
*/
|
|
135
|
+
export function evaluate(rule, actor) {
|
|
136
|
+
const problem = ruleProblem(rule);
|
|
137
|
+
if (problem !== null) {
|
|
138
|
+
return {
|
|
139
|
+
verdict: AUTHORITY_VERDICT.DENY,
|
|
140
|
+
action: rule?.action ?? null,
|
|
141
|
+
level: actor?.level ?? null,
|
|
142
|
+
exempt: false,
|
|
143
|
+
reason: `malformed rule: ${problem}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (!isAuthorityLevel(actor?.level)) {
|
|
147
|
+
return {
|
|
148
|
+
verdict: AUTHORITY_VERDICT.DENY,
|
|
149
|
+
action: rule.action,
|
|
150
|
+
level: actor?.level ?? null,
|
|
151
|
+
exempt: false,
|
|
152
|
+
reason: `actor has no usable authority level (got ${JSON.stringify(actor?.level ?? null)})`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const exemptLevel = rule.matchingExemptLevel ?? null;
|
|
157
|
+
const exempt = exemptLevel !== null && actor.level >= exemptLevel;
|
|
158
|
+
|
|
159
|
+
// 2. Membership, skipped entirely for an exempt actor.
|
|
160
|
+
if (!exempt && rule.scope !== undefined && rule.scope !== null && actor.inScope === false) {
|
|
161
|
+
return {
|
|
162
|
+
verdict: AUTHORITY_VERDICT.NEEDS_APPROVAL,
|
|
163
|
+
action: rule.action,
|
|
164
|
+
level: actor.level,
|
|
165
|
+
exempt: false,
|
|
166
|
+
reason: `outside the scope of "${rule.action}" (${rule.scope}); a supervisor within it may approve`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 3. The floor. Checked even for an exempt actor -- see the header.
|
|
171
|
+
if (actor.level < rule.minLevel) {
|
|
172
|
+
return {
|
|
173
|
+
verdict: AUTHORITY_VERDICT.NEEDS_APPROVAL,
|
|
174
|
+
action: rule.action,
|
|
175
|
+
level: actor.level,
|
|
176
|
+
exempt,
|
|
177
|
+
reason: `level ${actor.level} is below the minimum ${rule.minLevel} for "${rule.action}"`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
verdict: AUTHORITY_VERDICT.ALLOW,
|
|
183
|
+
action: rule.action,
|
|
184
|
+
level: actor.level,
|
|
185
|
+
exempt,
|
|
186
|
+
reason: exempt
|
|
187
|
+
? `allowed: level ${actor.level} meets the exemption ${exemptLevel} for "${rule.action}"`
|
|
188
|
+
: `allowed: level ${actor.level} meets the minimum ${rule.minLevel} for "${rule.action}"`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Evaluate several rules, stopping at the first one that is not a plain allow.
|
|
194
|
+
*
|
|
195
|
+
* ## Why it stops, rather than evaluating everything
|
|
196
|
+
*
|
|
197
|
+
* An earlier draft evaluated all rules and returned the strictest verdict. It is the more obvious design
|
|
198
|
+
* and the less useful one, for two reasons that both matter to the person reading the answer:
|
|
199
|
+
*
|
|
200
|
+
* * **`decisive` must be unique.** With every rule evaluated, a list can hold several non-allow
|
|
201
|
+
* decisions and the caller has to pick one -- and whichever it picks, the *stated* reason for a
|
|
202
|
+
* refusal stops being the reason. An operator asking "why was this not allowed" needs one rule.
|
|
203
|
+
* * **The unevaluated rules may not even apply.** A rule is evaluated against an actor, and an actor's
|
|
204
|
+
* scope can be learned from the outcome of an earlier rule; continuing past a gate would evaluate
|
|
205
|
+
* later rules against a context the gate had already made moot.
|
|
206
|
+
*
|
|
207
|
+
* Stopping at the *first* non-allow -- not merely at the first deny -- follows from the same argument. A
|
|
208
|
+
* gate is a gate whether it says `deny` or `needs_approval`, and in both cases the caller's next action
|
|
209
|
+
* is determined and the rest of the list is decoration.
|
|
210
|
+
*
|
|
211
|
+
* @param {AuthorityRule[]} rules
|
|
212
|
+
* @param {{level: number, inScope?: boolean|null, id?: string|null}} actor
|
|
213
|
+
* @returns {{verdict: string, decisions: object[], decisive: object|null}}
|
|
214
|
+
*/
|
|
215
|
+
export function evaluateAll(rules, actor) {
|
|
216
|
+
const decisions = [];
|
|
217
|
+
for (const rule of Array.isArray(rules) ? rules : []) {
|
|
218
|
+
const decision = evaluate(rule, actor);
|
|
219
|
+
decisions.push(decision);
|
|
220
|
+
if (decision.verdict !== AUTHORITY_VERDICT.ALLOW) {
|
|
221
|
+
return { verdict: decision.verdict, decisions, decisive: decision };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return { verdict: AUTHORITY_VERDICT.ALLOW, decisions, decisive: null };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The level required to sign off on a refusal.
|
|
229
|
+
*
|
|
230
|
+
* One band up, floored at the captain band, because the document's approval flow is a chain --
|
|
231
|
+
* captain, then org admin, then system admin -- and a chain that can be satisfied by the actor's own peer
|
|
232
|
+
* group is not a chain. An actor already at the top band has no higher approver, so the top band signs
|
|
233
|
+
* for itself; saying otherwise would make a system-admin action unapprovable.
|
|
234
|
+
*
|
|
235
|
+
* @param {number} level
|
|
236
|
+
* @returns {number|null}
|
|
237
|
+
*/
|
|
238
|
+
export function requiredApproverLevel(level) {
|
|
239
|
+
const band = bandOf(level);
|
|
240
|
+
if (band === null) return null;
|
|
241
|
+
if (band.name === 'system_admin') return 90;
|
|
242
|
+
const higher = AUTHORITY_BANDS[AUTHORITY_BANDS.indexOf(band) + 1];
|
|
243
|
+
return higher === undefined ? null : higher.min;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Is `approver` allowed to sign off on `subject`'s pending action?
|
|
248
|
+
*
|
|
249
|
+
* Strictly higher, not equal-or-higher. Two captains approving each other is a peer ring, and the whole
|
|
250
|
+
* point of the chain is that a decision can be escalated past the level that wanted it. Self-approval is
|
|
251
|
+
* refused for the same reason, stated separately because it is the case an implementation is most likely
|
|
252
|
+
* to let through by comparing only levels.
|
|
253
|
+
*
|
|
254
|
+
* @param {{level: number, id?: string|null}} approver
|
|
255
|
+
* @param {{level: number, id?: string|null}} subject
|
|
256
|
+
* @returns {{ok: boolean, reason: string, required: number|null}}
|
|
257
|
+
*/
|
|
258
|
+
export function mayApprove(approver, subject) {
|
|
259
|
+
const required = requiredApproverLevel(subject?.level);
|
|
260
|
+
if (required === null) {
|
|
261
|
+
return { ok: false, required: null, reason: `subject has no usable authority level (got ${JSON.stringify(subject?.level ?? null)})` };
|
|
262
|
+
}
|
|
263
|
+
if (!isAuthorityLevel(approver?.level)) {
|
|
264
|
+
return { ok: false, required, reason: `approver has no usable authority level (got ${JSON.stringify(approver?.level ?? null)})` };
|
|
265
|
+
}
|
|
266
|
+
if (approver.id !== undefined && approver.id !== null && approver.id === subject?.id) {
|
|
267
|
+
return { ok: false, required, reason: 'an actor may not approve its own request' };
|
|
268
|
+
}
|
|
269
|
+
if (approver.level < required) {
|
|
270
|
+
return { ok: false, required, reason: `approver level ${approver.level} is below the required ${required}` };
|
|
271
|
+
}
|
|
272
|
+
return { ok: true, required, reason: `approver level ${approver.level} meets the required ${required}` };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Compose a child actor's authority from its parent's.
|
|
277
|
+
*
|
|
278
|
+
* **This never increases it.** No rule in the source design grants delegation, and the default an
|
|
279
|
+
* implementation reaches for -- "a captain's delegate is a captain" -- turns any single compromised
|
|
280
|
+
* actor into an escalation path. Where the source is silent, the safe reading is the narrow one, and the
|
|
281
|
+
* `reason` says which of the two rules applied so a later reader can tell a deliberate cap from a bug.
|
|
282
|
+
*
|
|
283
|
+
* @param {{level: number, id?: string|null}} parent
|
|
284
|
+
* @param {{level?: number, cap?: number|null, id?: string|null}} child
|
|
285
|
+
* @returns {{level: number|null, capped: boolean, reason: string}}
|
|
286
|
+
*/
|
|
287
|
+
export function deriveAuthority(parent, child = {}) {
|
|
288
|
+
if (!isAuthorityLevel(parent?.level)) {
|
|
289
|
+
return { level: null, capped: false, reason: `parent has no usable authority level (got ${JSON.stringify(parent?.level ?? null)})` };
|
|
290
|
+
}
|
|
291
|
+
if (child.level !== undefined && !isAuthorityLevel(child.level)) {
|
|
292
|
+
return { level: null, capped: false, reason: `child level is not usable (got ${JSON.stringify(child.level)})` };
|
|
293
|
+
}
|
|
294
|
+
const asked = child.level ?? parent.level;
|
|
295
|
+
const cap = isAuthorityLevel(child.cap) ? child.cap : parent.level;
|
|
296
|
+
const ceiling = Math.min(parent.level, cap);
|
|
297
|
+
const level = Math.min(asked, ceiling);
|
|
298
|
+
return {
|
|
299
|
+
level,
|
|
300
|
+
capped: level < asked,
|
|
301
|
+
reason: level < asked
|
|
302
|
+
? `reduced from ${asked} to ${level}: a derived actor never exceeds its parent (${parent.level}) or its cap (${cap})`
|
|
303
|
+
: `granted ${level}: within parent (${parent.level}) and cap (${cap})`,
|
|
304
|
+
};
|
|
305
|
+
}
|