@cloudhal/agent-runtime 0.0.0-stage → 0.1.0
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/LICENSE +202 -0
- package/README.md +8 -2
- package/dist/src/approvals.d.ts +127 -0
- package/dist/src/approvals.js +346 -0
- package/dist/src/audit.d.ts +75 -0
- package/dist/src/audit.js +209 -0
- package/dist/src/index.d.ts +13 -0
- package/dist/src/index.js +13 -0
- package/dist/src/modes.d.ts +124 -0
- package/dist/src/modes.js +279 -0
- package/dist/src/ownership.d.ts +76 -0
- package/dist/src/ownership.js +200 -0
- package/dist/src/policy.d.ts +197 -0
- package/dist/src/policy.js +508 -0
- package/dist/src/roles.d.ts +110 -0
- package/dist/src/roles.js +292 -0
- package/dist/src/site-access.d.ts +35 -0
- package/dist/src/site-access.js +81 -0
- package/dist/src/skills.d.ts +47 -0
- package/dist/src/skills.js +151 -0
- package/dist/src/taxonomy.d.ts +41 -0
- package/dist/src/taxonomy.js +78 -0
- package/package.json +44 -3
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Scopes follow `area:verb`; `*` grants everything (the Owner, local stdio, auth-less servers).
|
|
7
|
+
* RFC-0005 §1 adds own-limited variants (`…:own`) and person-only scopes. A stronger scope implies
|
|
8
|
+
* the weaker ones (`implies`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Tool scopes: what agent keys may hold and what MCP tools require (I7). `*`, and the person-only
|
|
51
|
+
* scopes, are never grantable to a key.
|
|
52
|
+
*/
|
|
53
|
+
export const KEY_SCOPES = [
|
|
54
|
+
"site:read",
|
|
55
|
+
"site:write",
|
|
56
|
+
"content:read",
|
|
57
|
+
"content:write",
|
|
58
|
+
"content:write:own",
|
|
59
|
+
"content:draft:own",
|
|
60
|
+
// RFC-0014 §5 (ADR-0028): file body proposals a person accepts or rejects; changes no content,
|
|
61
|
+
// so `plan` keys hold it. Every write variant implies it.
|
|
62
|
+
"content:propose",
|
|
63
|
+
"preview:create",
|
|
64
|
+
"site:publish",
|
|
65
|
+
"site:publish:own",
|
|
66
|
+
"site:rollback",
|
|
67
|
+
"asset:upload",
|
|
68
|
+
"asset:manage",
|
|
69
|
+
// RFC-0006 §9.1: read-only site analytics (aggregates, no personal data). Implies nothing and
|
|
70
|
+
// nothing implies it except `*`.
|
|
71
|
+
"analytics:read",
|
|
72
|
+
// RFC-0007 §10.1: reader comments. Reading carries no email or IP hash (the contract has no
|
|
73
|
+
// field for them); moderating and replying have own variants for Authors' entries.
|
|
74
|
+
"comments:read",
|
|
75
|
+
"comments:moderate",
|
|
76
|
+
"comments:moderate:own",
|
|
77
|
+
"comments:reply",
|
|
78
|
+
"comments:reply:own",
|
|
79
|
+
// RFC-0008 §10.1: read the schedule; create and change releases and gates (`own`: only one's
|
|
80
|
+
// own nodes); approve releases and let `auto`-mode releases run without a person (`own`: every
|
|
81
|
+
// item is one's own). `releases:publish` is implied by `site:publish`; it exists on its own so
|
|
82
|
+
// an owner can give a key "schedule, never publish now".
|
|
83
|
+
"schedules:read",
|
|
84
|
+
"schedules:write",
|
|
85
|
+
"schedules:write:own",
|
|
86
|
+
"releases:publish",
|
|
87
|
+
"releases:publish:own",
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/** Scopes only a person holds (through their role): never stored on a key, never satisfy a tool. */
|
|
91
|
+
export const PERSON_SCOPES = [
|
|
92
|
+
"audit:read",
|
|
93
|
+
"site:settings",
|
|
94
|
+
"members:invite",
|
|
95
|
+
"members:manage",
|
|
96
|
+
"agents:manage",
|
|
97
|
+
// RFC-0034 §3: new sites belong to people who run the installation.
|
|
98
|
+
"site:create",
|
|
99
|
+
// RFC-0007 §10.1: commenter emails, a commenter's export and erasure — people only.
|
|
100
|
+
"commenters:data",
|
|
101
|
+
// RFC-0008 §10.1 (D7): creating, editing, resuming, running and deleting automations — standing
|
|
102
|
+
// rules of autonomy stay with people.
|
|
103
|
+
"automations:manage",
|
|
104
|
+
// RFC-0016 (D6): in-editor AI commands; agents bring their own model.
|
|
105
|
+
"ai:invoke",
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
/** @deprecated use `KEY_SCOPES` (what a key may carry). */
|
|
109
|
+
export const ALL_SCOPES = [...KEY_SCOPES];
|
|
110
|
+
|
|
111
|
+
/** Every scope except `*`. */
|
|
112
|
+
export const SCOPES = [...KEY_SCOPES, ...PERSON_SCOPES];
|
|
113
|
+
|
|
114
|
+
export function isScope(value ) {
|
|
115
|
+
return value === "*" || (typeof value === "string" && (SCOPES ).includes(value));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function isKeyScope(value ) {
|
|
119
|
+
return typeof value === "string" && (KEY_SCOPES ).includes(value);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Direct implications (the lattice of RFC-0005 §1); `implies` follows them transitively. */
|
|
123
|
+
const IMPLIES = {
|
|
124
|
+
"content:write": ["content:write:own"],
|
|
125
|
+
"content:write:own": ["content:draft:own"],
|
|
126
|
+
"content:draft:own": ["content:propose"],
|
|
127
|
+
// RFC-0008 §10.1: publishing later is no more power than publishing now.
|
|
128
|
+
"site:publish": ["site:publish:own", "releases:publish"],
|
|
129
|
+
"site:publish:own": ["releases:publish:own"],
|
|
130
|
+
"releases:publish": ["releases:publish:own"],
|
|
131
|
+
"schedules:write": ["schedules:write:own"],
|
|
132
|
+
"asset:manage": ["asset:upload"],
|
|
133
|
+
"members:manage": ["members:invite"],
|
|
134
|
+
"comments:moderate": ["comments:moderate:own"],
|
|
135
|
+
"comments:reply": ["comments:reply:own"],
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** `a` grants at least what `b` grants. */
|
|
139
|
+
export function implies(a , b ) {
|
|
140
|
+
if (a === "*" || a === b) return true;
|
|
141
|
+
return (IMPLIES[a] ?? []).some((x) => implies(x, b));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The scopes `scope` implies, strongest first (not including itself). */
|
|
145
|
+
export function weakerScopes(scope ) {
|
|
146
|
+
const out = [];
|
|
147
|
+
const walk = (s ) => {
|
|
148
|
+
for (const x of IMPLIES[s] ?? []) {
|
|
149
|
+
if (!out.includes(x)) out.push(x);
|
|
150
|
+
walk(x);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
walk(scope);
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Does a set of scopes grant `scope` (directly, through `*`, or through a stronger scope)? */
|
|
158
|
+
export function holds(scopes , scope ) {
|
|
159
|
+
return scopes.some((s) => isScope(s) && implies(s, scope));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The own limit an allowed call carries (RFC-0005 §9): the caller holds only an own variant of the
|
|
164
|
+
* tool's scope, so the handler must check the target. `draft` = never-published entries, status
|
|
165
|
+
* draft/review only.
|
|
166
|
+
*/
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
/** Own variants that satisfy a tool scope with a limit, strongest first. */
|
|
179
|
+
const OWN_VARIANTS = {
|
|
180
|
+
"content:write": [
|
|
181
|
+
{ scope: "content:write:own", level: "own" },
|
|
182
|
+
{ scope: "content:draft:own", level: "draft" },
|
|
183
|
+
],
|
|
184
|
+
"site:publish": [{ scope: "site:publish:own", level: "own" }],
|
|
185
|
+
"comments:moderate": [{ scope: "comments:moderate:own", level: "own" }],
|
|
186
|
+
"comments:reply": [{ scope: "comments:reply:own", level: "own" }],
|
|
187
|
+
"schedules:write": [{ scope: "schedules:write:own", level: "own" }],
|
|
188
|
+
"releases:publish": [{ scope: "releases:publish:own", level: "own" }],
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* What a call does, decided from its arguments (RFC-0007 §10.2), for tools whose policy depends
|
|
193
|
+
* on them (`moderate_comments` may hide in `edits` mode but never approve):
|
|
194
|
+
*
|
|
195
|
+
* | Class | Meaning | plan | manual | edits | auto | bypass (local) |
|
|
196
|
+
* |---|---|---|---|---|---|---|
|
|
197
|
+
* | `read` | changes nothing | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
198
|
+
* | `advisory` | changes only notes people read before deciding (labels) | ✗ | ✓ | ✓ | ✓ | ✓ |
|
|
199
|
+
* | `reduce` | reversible, can only make something less visible | ✗ | waits | ✓ | ✓ | ✓ |
|
|
200
|
+
* | `publish` | makes content public, or removes it, at a time | ✗ | waits | waits | low-risk in window ✓, else waits | ✓ |
|
|
201
|
+
* | `person` | makes something public | ✗ | waits | waits | waits | **waits** |
|
|
202
|
+
* | `propose` | files a proposal a person accepts or rejects; changes no content (ADR-0028) | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
203
|
+
*
|
|
204
|
+
* `person` calls wait with cause `tool` in every mode, `bypass` included, and for people calling
|
|
205
|
+
* over MCP too (they are asked in their own client). `publish` (RFC-0008 §10.3) follows ADR-0015's
|
|
206
|
+
* publish rules instead: in `auto` mode the decision carries `autoApprove: "low-risk-release"` and
|
|
207
|
+
* the server applies the low-risk check and the window rule (§10.7) to the release itself.
|
|
208
|
+
* Tools without `classify`, or whose `classify` returns nothing for a call, behave as before.
|
|
209
|
+
*/
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
/** A staff member as the policy layer sees them. */
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
/** The mode that actually applies: `bypass` only counts locally, remote keys fall back to `edits`. */
|
|
285
|
+
export function effectiveMode(principal ) {
|
|
286
|
+
if (principal.actor.kind !== "agent") return undefined;
|
|
287
|
+
const mode = principal.mode ?? "edits";
|
|
288
|
+
return mode === "bypass" && !principal.local ? "edits" : mode;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Why a call waits for a person (stored on approvals and audit rows). `role`: the caller's role
|
|
293
|
+
* allows less than the call (an Author's publish that is not own-only, RFC-0005 §6).
|
|
294
|
+
*/
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The single place that decides whether a tool call may run (ADR-0004). Humans and agents share
|
|
317
|
+
* the same model: scopes come from the role or API key, approvals from annotations and the
|
|
318
|
+
* declared skill. Skills never grant permission — they can only ask for more approval.
|
|
319
|
+
*/
|
|
320
|
+
export class PolicyEngine {
|
|
321
|
+
#tools = new Map ();
|
|
322
|
+
|
|
323
|
+
constructor(tools ) {
|
|
324
|
+
for (const t of tools) this.#tools.set(t.name, t);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
tool(name ) {
|
|
328
|
+
return this.#tools.get(name);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Every tool, in registration order (a run envelope lists the ones a run may call). */
|
|
332
|
+
tools() {
|
|
333
|
+
return [...this.#tools.values()];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
evaluate(
|
|
337
|
+
principal ,
|
|
338
|
+
toolName ,
|
|
339
|
+
options = {},
|
|
340
|
+
) {
|
|
341
|
+
const tool = this.#tools.get(toolName);
|
|
342
|
+
if (!tool) return { kind: "deny", reason: `unknown tool ${toolName}` };
|
|
343
|
+
const cls = tool.classify?.(options.args ?? {});
|
|
344
|
+
const required = cls === "propose" && tool.proposeScopes ? tool.proposeScopes : tool.scopes;
|
|
345
|
+
let limit ;
|
|
346
|
+
const missing = [];
|
|
347
|
+
for (const s of required) {
|
|
348
|
+
// Person-only scopes never satisfy a tool (I7), not even through `*`.
|
|
349
|
+
if (PERSON_SCOPES.includes(s)) {
|
|
350
|
+
missing.push(s);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (holds(principal.scopes, s)) continue;
|
|
354
|
+
const variant = tool.own
|
|
355
|
+
? OWN_VARIANTS[s]?.find((v) => holds(principal.scopes, v.scope))
|
|
356
|
+
: undefined;
|
|
357
|
+
if (variant && !limit) {
|
|
358
|
+
limit =
|
|
359
|
+
tool.own === "changeset"
|
|
360
|
+
? { level: "own", target: "changeset" }
|
|
361
|
+
: tool.own === "comment"
|
|
362
|
+
? { level: "own", target: "comment" }
|
|
363
|
+
: tool.own === "release"
|
|
364
|
+
? { level: "own", target: "release" }
|
|
365
|
+
: { level: variant.level, target: "entry" };
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
missing.push(s);
|
|
369
|
+
}
|
|
370
|
+
if (missing.length > 0)
|
|
371
|
+
return { kind: "deny", reason: `missing scope ${missing.join(", ")}`, code: "missing_scope" };
|
|
372
|
+
if (principal.skill) {
|
|
373
|
+
// A skill may only call tools inside the scopes it declared (own variants count).
|
|
374
|
+
const declared = principal.skill.scopes;
|
|
375
|
+
const covered = (s ) =>
|
|
376
|
+
holds(declared, s) ||
|
|
377
|
+
(tool.own !== undefined && (OWN_VARIANTS[s] ?? []).some((v) => holds(declared, v.scope)));
|
|
378
|
+
const outside = required.filter((s) => !covered(s));
|
|
379
|
+
if (outside.length > 0)
|
|
380
|
+
return {
|
|
381
|
+
kind: "deny",
|
|
382
|
+
reason: `skill ${principal.skill.name} did not declare ${outside.join(", ")}`,
|
|
383
|
+
code: "skill",
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
const mode = effectiveMode(principal);
|
|
387
|
+
const limited = limit ? { limit } : {};
|
|
388
|
+
if (cls)
|
|
389
|
+
return classified(tool, cls, principal, mode, {
|
|
390
|
+
confirmed: options.confirmed === true,
|
|
391
|
+
limited,
|
|
392
|
+
});
|
|
393
|
+
const writes = tool.annotations.readOnlyHint !== true;
|
|
394
|
+
if (mode === "plan" && writes)
|
|
395
|
+
return {
|
|
396
|
+
kind: "deny",
|
|
397
|
+
reason: "plan mode is read-only: propose the change instead",
|
|
398
|
+
code: "mode",
|
|
399
|
+
};
|
|
400
|
+
const needsApproval =
|
|
401
|
+
tool.requiresApproval ||
|
|
402
|
+
tool.annotations.destructiveHint === true ||
|
|
403
|
+
(principal.skill?.approval ?? []).includes(toolName) ||
|
|
404
|
+
(mode === "manual" && writes);
|
|
405
|
+
if (needsApproval && !options.confirmed && mode !== "bypass") {
|
|
406
|
+
// A tool that always asks (publish, rollback) says so first; then plain destructive ones.
|
|
407
|
+
const cause = tool.requiresApproval
|
|
408
|
+
? "tool"
|
|
409
|
+
: tool.annotations.destructiveHint
|
|
410
|
+
? "destructive"
|
|
411
|
+
: (principal.skill?.approval ?? []).includes(toolName)
|
|
412
|
+
? "skill"
|
|
413
|
+
: "manual-mode";
|
|
414
|
+
const reason = {
|
|
415
|
+
"manual-mode": `manual mode: ${toolName} needs a person's approval`,
|
|
416
|
+
destructive: `${toolName} is destructive`,
|
|
417
|
+
tool: `${toolName} requires approval`,
|
|
418
|
+
skill: `${toolName} requires approval`,
|
|
419
|
+
role: `${toolName} needs someone whose role allows it`,
|
|
420
|
+
}[cause];
|
|
421
|
+
// `auto` lets a low-risk publish through; rollback and deletions always wait for a person.
|
|
422
|
+
const auto = mode === "auto" && toolName === "publish";
|
|
423
|
+
return {
|
|
424
|
+
kind: "approval-required",
|
|
425
|
+
reason,
|
|
426
|
+
cause,
|
|
427
|
+
...(auto ? { autoApprove: "low-risk-publish" } : {}),
|
|
428
|
+
...limited,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
return { kind: "allow", ...limited };
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** The decision for a classified call (RFC-0007 §10.2), after the scope and skill checks. */
|
|
436
|
+
function classified(
|
|
437
|
+
tool ,
|
|
438
|
+
cls ,
|
|
439
|
+
principal ,
|
|
440
|
+
mode ,
|
|
441
|
+
o ,
|
|
442
|
+
) {
|
|
443
|
+
// Reading changes nothing; a proposal changes nothing until a person accepts it, so `plan`
|
|
444
|
+
// mode may propose (ADR-0015's follow-up, ADR-0028).
|
|
445
|
+
if (cls === "read" || cls === "propose") return { kind: "allow", ...o.limited };
|
|
446
|
+
if (mode === "plan")
|
|
447
|
+
return {
|
|
448
|
+
kind: "deny",
|
|
449
|
+
reason: "plan mode is read-only: propose the change instead",
|
|
450
|
+
code: "mode",
|
|
451
|
+
};
|
|
452
|
+
if (o.confirmed) return { kind: "allow", ...o.limited };
|
|
453
|
+
const wait = (cause , reason ) => ({
|
|
454
|
+
kind: "approval-required",
|
|
455
|
+
reason,
|
|
456
|
+
cause,
|
|
457
|
+
...o.limited,
|
|
458
|
+
});
|
|
459
|
+
// A person makes something public, in every mode — `bypass` included — and people over MCP
|
|
460
|
+
// are asked in their own client.
|
|
461
|
+
if (cls === "person")
|
|
462
|
+
return wait("tool", `${tool.name} makes something public: a person decides`);
|
|
463
|
+
if ((principal.skill?.approval ?? []).includes(tool.name))
|
|
464
|
+
return wait("skill", `${tool.name} requires approval`);
|
|
465
|
+
// RFC-0008 §10.3: scheduled publishing follows ADR-0015's publish rules. `bypass` (local only)
|
|
466
|
+
// lets it through; `auto` may skip the person for a low-risk release inside the window, which
|
|
467
|
+
// the server decides from the release; every other mode, and people over MCP, wait.
|
|
468
|
+
if (cls === "publish") {
|
|
469
|
+
if (mode === "bypass") return { kind: "allow", ...o.limited };
|
|
470
|
+
return {
|
|
471
|
+
kind: "approval-required",
|
|
472
|
+
reason: `${tool.name} makes content public or removes it at a time: a person approves it`,
|
|
473
|
+
cause: mode === "manual" ? "manual-mode" : "tool",
|
|
474
|
+
...(mode === "auto" ? { autoApprove: "low-risk-release" } : {}),
|
|
475
|
+
...o.limited,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
// Labels are notes people read before deciding: free in `manual` (a waiting label would be the
|
|
479
|
+
// person's own work); reductions wait there.
|
|
480
|
+
if (cls === "reduce" && mode === "manual")
|
|
481
|
+
return wait("manual-mode", `manual mode: ${tool.name} needs a person's approval`);
|
|
482
|
+
return { kind: "allow", ...o.limited };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Default scope sets per actor kind, for principals without a membership source (auth-less
|
|
487
|
+
* servers, tests, local scripts). With auth, a person's scopes come from their role (RFC-0005 I1).
|
|
488
|
+
*/
|
|
489
|
+
export function defaultScopes(actor ) {
|
|
490
|
+
switch (actor.kind) {
|
|
491
|
+
case "human":
|
|
492
|
+
case "system":
|
|
493
|
+
return ["*"];
|
|
494
|
+
case "agent":
|
|
495
|
+
return [
|
|
496
|
+
"site:read",
|
|
497
|
+
"content:read",
|
|
498
|
+
"content:write",
|
|
499
|
+
"preview:create",
|
|
500
|
+
"asset:upload",
|
|
501
|
+
"analytics:read",
|
|
502
|
+
// RFC-0007 D2: agents read comments (never an email or IP hash).
|
|
503
|
+
"comments:read",
|
|
504
|
+
// RFC-0008 §10.1: agents read the schedule.
|
|
505
|
+
"schedules:read",
|
|
506
|
+
];
|
|
507
|
+
}
|
|
508
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staff roles (RFC-0005 §2, ADR-0018): named sets of the policy engine's scopes, so people and
|
|
3
|
+
* agents stay one kind of actor and an agent key never does more than the person who created it.
|
|
4
|
+
* This module is the single source for the policy engine, REST, MCP and Studio (via `/api/instance`).
|
|
5
|
+
*/
|
|
6
|
+
import { type AgentMode } from "./modes.js";
|
|
7
|
+
import { type Scope } from "./policy.js";
|
|
8
|
+
export type Role = "owner" | "administrator" | "editor" | "author" | "contributor";
|
|
9
|
+
/** Highest rank first: Owner > Administrator > Editor > Author > Contributor. */
|
|
10
|
+
export declare const ROLES: readonly Role[];
|
|
11
|
+
export declare function isRole(value: unknown): value is Role;
|
|
12
|
+
/** Higher is more senior. */
|
|
13
|
+
export declare function rank(role: Role): number;
|
|
14
|
+
/**
|
|
15
|
+
* Role → scopes (RFC-0005 §2.1). Implied scopes are not repeated. The Owner holds `*`, so a future
|
|
16
|
+
* scope reaches the Owner without a migration; Administrator holds every non-wildcard scope.
|
|
17
|
+
*/
|
|
18
|
+
export declare const ROLE_SCOPES: Readonly<Record<Role, readonly Scope[]>>;
|
|
19
|
+
export declare function roleScopes(role: Role): Scope[];
|
|
20
|
+
/**
|
|
21
|
+
* The most autonomous mode a key created by this role may hold (RFC-0005 §5.2): a Contributor has
|
|
22
|
+
* no publish power, so `auto` would be meaningless. `bypass` stays local only.
|
|
23
|
+
*/
|
|
24
|
+
export declare function modeCap(role: Role): AgentMode;
|
|
25
|
+
export declare const MODE_CAPS: Readonly<Record<Role, AgentMode>>;
|
|
26
|
+
/** `mode`, lowered to `cap` when it is more autonomous. */
|
|
27
|
+
export declare function clampMode(mode: AgentMode, cap: AgentMode): AgentMode;
|
|
28
|
+
/** Remote modes a role's keys may hold, least autonomous first. */
|
|
29
|
+
export declare function modesFor(role: Role): AgentMode[];
|
|
30
|
+
/**
|
|
31
|
+
* Narrows a key's scopes to a role (RFC-0005 §5.1): keeps each key scope the role holds; else the
|
|
32
|
+
* strongest weaker variant the role holds; else drops it. Person-only scopes and `*` never survive
|
|
33
|
+
* on a key (I7).
|
|
34
|
+
*
|
|
35
|
+
* `narrow(["content:write","site:publish"], author)` → `["content:write:own","site:publish:own"]`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function narrow(keyScopes: readonly string[], roleScopeSet: readonly Scope[]): Scope[];
|
|
38
|
+
/** Scopes a key requested that the role cannot give it (after narrowing nothing is dropped). */
|
|
39
|
+
export declare function scopesAboveRole(requested: readonly Scope[], role: Role): Scope[];
|
|
40
|
+
/** Why a member action breaks the rank rule (RFC-0005 §3, I4, I12), as data. */
|
|
41
|
+
export type RankProblem = "self" | "owner_role" | "target_is_owner" | "role_too_high" | "not_allowed";
|
|
42
|
+
export declare function describeRankProblem(problem: RankProblem): string;
|
|
43
|
+
/** May `inviter` invite someone as `role`? Editors invite Contributors and Authors only (†). */
|
|
44
|
+
export declare function inviteProblem(inviter: Role, role: Role): RankProblem | undefined;
|
|
45
|
+
export declare function canInvite(inviter: Role, role: Role): boolean;
|
|
46
|
+
/** Roles `inviter` may invite, highest first. */
|
|
47
|
+
export declare function invitableRoles(inviter: Role): Role[];
|
|
48
|
+
export interface MemberRef {
|
|
49
|
+
userId: string;
|
|
50
|
+
role: Role;
|
|
51
|
+
}
|
|
52
|
+
/** May `actor` change `target`'s role to `to`? */
|
|
53
|
+
export declare function roleChangeProblem(actor: MemberRef, target: MemberRef, to: Role): RankProblem | undefined;
|
|
54
|
+
/** May `actor` remove `target`? */
|
|
55
|
+
export declare function removalProblem(actor: MemberRef, target: MemberRef): RankProblem | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* What a person can do, for UI gating (RFC-0005 I16: cosmetic; the server decides). Each ability
|
|
58
|
+
* is a scope check except `ownership.transfer` (Owner only).
|
|
59
|
+
*/
|
|
60
|
+
export declare const ABILITIES: {
|
|
61
|
+
readonly "entries.create": "content:draft:own";
|
|
62
|
+
readonly "entries.editOwnPublished": "content:write:own";
|
|
63
|
+
readonly "entries.editAny": "content:write";
|
|
64
|
+
readonly "pages.write": "content:write";
|
|
65
|
+
readonly "publish.own": "site:publish:own";
|
|
66
|
+
readonly "publish.any": "site:publish";
|
|
67
|
+
readonly rollback: "site:rollback";
|
|
68
|
+
readonly "structure.write": "site:write";
|
|
69
|
+
readonly "assets.upload": "asset:upload";
|
|
70
|
+
readonly "assets.manage": "asset:manage";
|
|
71
|
+
readonly "activity.all": "audit:read";
|
|
72
|
+
readonly "settings.write": "site:settings";
|
|
73
|
+
readonly "members.invite": "members:invite";
|
|
74
|
+
readonly "members.manage": "members:manage";
|
|
75
|
+
readonly "agents.manage": "agents:manage";
|
|
76
|
+
readonly "sites.create": "site:create";
|
|
77
|
+
readonly "analytics.read": "analytics:read";
|
|
78
|
+
readonly "analytics.settings": "site:settings";
|
|
79
|
+
readonly "comments.read": "comments:read";
|
|
80
|
+
readonly "comments.moderate": "comments:moderate:own";
|
|
81
|
+
readonly "comments.moderateAny": "comments:moderate";
|
|
82
|
+
readonly "comments.reply": "comments:reply:own";
|
|
83
|
+
readonly "comments.replyAny": "comments:reply";
|
|
84
|
+
readonly "commenters.data": "commenters:data";
|
|
85
|
+
readonly "comments.settings": "site:settings";
|
|
86
|
+
readonly "schedules.read": "schedules:read";
|
|
87
|
+
readonly "schedules.write": "schedules:write:own";
|
|
88
|
+
readonly "schedules.writeAny": "schedules:write";
|
|
89
|
+
readonly "releases.approve": "releases:publish:own";
|
|
90
|
+
readonly "releases.approveAny": "releases:publish";
|
|
91
|
+
readonly "automations.manage": "automations:manage";
|
|
92
|
+
readonly "schedules.settings": "site:settings";
|
|
93
|
+
readonly "ai.invoke": "ai:invoke";
|
|
94
|
+
readonly "ai.settings": "site:settings";
|
|
95
|
+
readonly "ownership.transfer": "owner";
|
|
96
|
+
};
|
|
97
|
+
export type Ability = keyof typeof ABILITIES;
|
|
98
|
+
/** The ability map for a person (`role` undefined: no membership source, everything allowed). */
|
|
99
|
+
export declare function abilities(role: Role | undefined, scopes: readonly Scope[]): Record<Ability, boolean>;
|
|
100
|
+
/** Invite expiry (RFC-0005 §7.1): 7 days by default, 1 hour to 14 days, Administrator ≤ 72 h. */
|
|
101
|
+
export declare const INVITE_DEFAULT_HOURS: number;
|
|
102
|
+
export declare const INVITE_MIN_HOURS = 1;
|
|
103
|
+
export declare const INVITE_MAX_HOURS: number;
|
|
104
|
+
export declare const INVITE_ADMIN_MAX_HOURS = 72;
|
|
105
|
+
/** The longest an invite for `role` may last. */
|
|
106
|
+
export declare function inviteMaxHours(role: Role): number;
|
|
107
|
+
/** Why an expiry is refused for an invite as `role`, or undefined. */
|
|
108
|
+
export declare function inviteExpiryProblem(role: Role, hours: number): string | undefined;
|
|
109
|
+
/** The default expiry for an invite as `role` (Administrator invites are capped). */
|
|
110
|
+
export declare function inviteDefaultHours(role: Role): number;
|