@cloudhal/agent-runtime 0.0.0-stage → 0.1.1
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,292 @@
|
|
|
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 { AGENT_MODES, isMoreAutonomous, REMOTE_AGENT_MODES } from "./modes.js";
|
|
7
|
+
import { holds, isKeyScope, KEY_SCOPES, weakerScopes } from "./policy.js";
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
/** Highest rank first: Owner > Administrator > Editor > Author > Contributor. */
|
|
12
|
+
export const ROLES = ["owner", "administrator", "editor", "author", "contributor"];
|
|
13
|
+
|
|
14
|
+
export function isRole(value ) {
|
|
15
|
+
return typeof value === "string" && (ROLES ).includes(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Higher is more senior. */
|
|
19
|
+
export function rank(role ) {
|
|
20
|
+
return ROLES.length - ROLES.indexOf(role);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// RFC-0016 (D6): every role may use in-editor AI; the owner's switch and the daily cap limit it.
|
|
24
|
+
const READ = ["site:read", "content:read", "preview:create", "ai:invoke"];
|
|
25
|
+
const EDITOR = [
|
|
26
|
+
...READ,
|
|
27
|
+
"content:write",
|
|
28
|
+
"site:publish",
|
|
29
|
+
"site:rollback",
|
|
30
|
+
"site:write",
|
|
31
|
+
"asset:manage",
|
|
32
|
+
"audit:read",
|
|
33
|
+
"members:invite",
|
|
34
|
+
"analytics:read",
|
|
35
|
+
// RFC-0007 §10.1 (D6): every comment; commenter emails through the person-only scope.
|
|
36
|
+
"comments:read",
|
|
37
|
+
"comments:moderate",
|
|
38
|
+
"comments:reply",
|
|
39
|
+
"commenters:data",
|
|
40
|
+
// RFC-0008 §10.2: every release and gate (`releases:publish` comes with `site:publish`), and
|
|
41
|
+
// automations — an automation's key is narrowed to its owner's role on every call (ADR-0018).
|
|
42
|
+
"schedules:read",
|
|
43
|
+
"schedules:write",
|
|
44
|
+
"automations:manage",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Role → scopes (RFC-0005 §2.1). Implied scopes are not repeated. The Owner holds `*`, so a future
|
|
49
|
+
* scope reaches the Owner without a migration; Administrator holds every non-wildcard scope.
|
|
50
|
+
*/
|
|
51
|
+
export const ROLE_SCOPES = {
|
|
52
|
+
// RFC-0008 §10.2: Contributors read the schedule and make draft releases and requests of their
|
|
53
|
+
// own work; they approve nothing.
|
|
54
|
+
contributor: [
|
|
55
|
+
...READ,
|
|
56
|
+
"content:draft:own",
|
|
57
|
+
"asset:upload",
|
|
58
|
+
"schedules:read",
|
|
59
|
+
"schedules:write:own",
|
|
60
|
+
],
|
|
61
|
+
// RFC-0006 §9.1: Authors and above read analytics; Contributors do not in v1.
|
|
62
|
+
// RFC-0007 §10.1 (D6): Authors read comments and moderate and reply on their own entries;
|
|
63
|
+
// Contributors get no comment scopes in v1.
|
|
64
|
+
// RFC-0008 §10.2: Authors schedule and approve their own releases (`releases:publish:own`
|
|
65
|
+
// comes with `site:publish:own`).
|
|
66
|
+
author: [
|
|
67
|
+
...READ,
|
|
68
|
+
"content:write:own",
|
|
69
|
+
"site:publish:own",
|
|
70
|
+
"asset:upload",
|
|
71
|
+
"analytics:read",
|
|
72
|
+
"comments:read",
|
|
73
|
+
"comments:moderate:own",
|
|
74
|
+
"comments:reply:own",
|
|
75
|
+
"schedules:read",
|
|
76
|
+
"schedules:write:own",
|
|
77
|
+
],
|
|
78
|
+
editor: EDITOR,
|
|
79
|
+
administrator: [...EDITOR, "site:settings", "members:manage", "agents:manage", "site:create"],
|
|
80
|
+
owner: ["*"],
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export function roleScopes(role ) {
|
|
84
|
+
return [...ROLE_SCOPES[role]];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The most autonomous mode a key created by this role may hold (RFC-0005 §5.2): a Contributor has
|
|
89
|
+
* no publish power, so `auto` would be meaningless. `bypass` stays local only.
|
|
90
|
+
*/
|
|
91
|
+
export function modeCap(role ) {
|
|
92
|
+
return role === "contributor" ? "edits" : "auto";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const MODE_CAPS = Object.fromEntries(
|
|
96
|
+
ROLES.map((r) => [r, modeCap(r)]),
|
|
97
|
+
) ;
|
|
98
|
+
|
|
99
|
+
/** `mode`, lowered to `cap` when it is more autonomous. */
|
|
100
|
+
export function clampMode(mode , cap ) {
|
|
101
|
+
return isMoreAutonomous(mode, cap) ? cap : mode;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Remote modes a role's keys may hold, least autonomous first. */
|
|
105
|
+
export function modesFor(role ) {
|
|
106
|
+
const cap = modeCap(role);
|
|
107
|
+
return REMOTE_AGENT_MODES.filter((m) => AGENT_MODES.indexOf(m) <= AGENT_MODES.indexOf(cap));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Narrows a key's scopes to a role (RFC-0005 §5.1): keeps each key scope the role holds; else the
|
|
112
|
+
* strongest weaker variant the role holds; else drops it. Person-only scopes and `*` never survive
|
|
113
|
+
* on a key (I7).
|
|
114
|
+
*
|
|
115
|
+
* `narrow(["content:write","site:publish"], author)` → `["content:write:own","site:publish:own"]`.
|
|
116
|
+
*/
|
|
117
|
+
export function narrow(keyScopes , roleScopeSet ) {
|
|
118
|
+
const out = [];
|
|
119
|
+
const add = (s ) => {
|
|
120
|
+
if (!out.includes(s)) out.push(s);
|
|
121
|
+
};
|
|
122
|
+
for (const k of keyScopes) {
|
|
123
|
+
if (k === "*") {
|
|
124
|
+
for (const s of KEY_SCOPES) if (holds(roleScopeSet, s)) add(s);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (!isKeyScope(k)) continue;
|
|
128
|
+
if (holds(roleScopeSet, k)) add(k);
|
|
129
|
+
else {
|
|
130
|
+
const weaker = weakerScopes(k).find((w) => holds(roleScopeSet, w));
|
|
131
|
+
if (weaker) add(weaker);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Scopes a key requested that the role cannot give it (after narrowing nothing is dropped). */
|
|
138
|
+
export function scopesAboveRole(requested , role ) {
|
|
139
|
+
return requested.filter((s) => !holds(ROLE_SCOPES[role], s));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Why a member action breaks the rank rule (RFC-0005 §3, I4, I12), as data. */
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
const RANK_TEXT = {
|
|
151
|
+
self: "nobody changes their own role or removes themselves",
|
|
152
|
+
owner_role: "the owner role moves only by ownership transfer",
|
|
153
|
+
target_is_owner: "the Owner cannot be demoted or removed; transfer ownership first",
|
|
154
|
+
role_too_high: "your role cannot give this role",
|
|
155
|
+
not_allowed: "your role cannot manage members",
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export function describeRankProblem(problem ) {
|
|
159
|
+
return RANK_TEXT[problem];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** May `inviter` invite someone as `role`? Editors invite Contributors and Authors only (†). */
|
|
163
|
+
export function inviteProblem(inviter , role ) {
|
|
164
|
+
if (role === "owner") return "owner_role";
|
|
165
|
+
const scopes = ROLE_SCOPES[inviter];
|
|
166
|
+
if (holds(scopes, "members:manage")) return undefined;
|
|
167
|
+
if (!holds(scopes, "members:invite")) return "not_allowed";
|
|
168
|
+
return role === "contributor" || role === "author" ? undefined : "role_too_high";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function canInvite(inviter , role ) {
|
|
172
|
+
return inviteProblem(inviter, role) === undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Roles `inviter` may invite, highest first. */
|
|
176
|
+
export function invitableRoles(inviter ) {
|
|
177
|
+
return ROLES.filter((r) => canInvite(inviter, r));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
/** May `actor` change `target`'s role to `to`? */
|
|
186
|
+
export function roleChangeProblem(
|
|
187
|
+
actor ,
|
|
188
|
+
target ,
|
|
189
|
+
to ,
|
|
190
|
+
) {
|
|
191
|
+
if (actor.userId === target.userId) return "self";
|
|
192
|
+
if (!holds(ROLE_SCOPES[actor.role], "members:manage")) return "not_allowed";
|
|
193
|
+
if (target.role === "owner") return "target_is_owner";
|
|
194
|
+
if (to === "owner") return "owner_role";
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** May `actor` remove `target`? */
|
|
199
|
+
export function removalProblem(actor , target ) {
|
|
200
|
+
if (actor.userId === target.userId) return "self";
|
|
201
|
+
if (!holds(ROLE_SCOPES[actor.role], "members:manage")) return "not_allowed";
|
|
202
|
+
if (target.role === "owner") return "target_is_owner";
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* What a person can do, for UI gating (RFC-0005 I16: cosmetic; the server decides). Each ability
|
|
208
|
+
* is a scope check except `ownership.transfer` (Owner only).
|
|
209
|
+
*/
|
|
210
|
+
export const ABILITIES = {
|
|
211
|
+
"entries.create": "content:draft:own",
|
|
212
|
+
"entries.editOwnPublished": "content:write:own",
|
|
213
|
+
"entries.editAny": "content:write",
|
|
214
|
+
"pages.write": "content:write",
|
|
215
|
+
"publish.own": "site:publish:own",
|
|
216
|
+
"publish.any": "site:publish",
|
|
217
|
+
rollback: "site:rollback",
|
|
218
|
+
"structure.write": "site:write",
|
|
219
|
+
"assets.upload": "asset:upload",
|
|
220
|
+
"assets.manage": "asset:manage",
|
|
221
|
+
"activity.all": "audit:read",
|
|
222
|
+
"settings.write": "site:settings",
|
|
223
|
+
"members.invite": "members:invite",
|
|
224
|
+
"members.manage": "members:manage",
|
|
225
|
+
"agents.manage": "agents:manage",
|
|
226
|
+
// RFC-0034 §3: creating sites in Studio.
|
|
227
|
+
"sites.create": "site:create",
|
|
228
|
+
// RFC-0006 §9.1: reading analytics; changing the site's analytics settings (Administrator).
|
|
229
|
+
"analytics.read": "analytics:read",
|
|
230
|
+
"analytics.settings": "site:settings",
|
|
231
|
+
// RFC-0007 §10.1: the comment queue; moderating and replying (`:own` on one's own entries is
|
|
232
|
+
// enough for the first of each pair); commenter emails and erasure; the site's comment settings.
|
|
233
|
+
"comments.read": "comments:read",
|
|
234
|
+
"comments.moderate": "comments:moderate:own",
|
|
235
|
+
"comments.moderateAny": "comments:moderate",
|
|
236
|
+
"comments.reply": "comments:reply:own",
|
|
237
|
+
"comments.replyAny": "comments:reply",
|
|
238
|
+
"commenters.data": "commenters:data",
|
|
239
|
+
"comments.settings": "site:settings",
|
|
240
|
+
// RFC-0008 §10.1: the calendar; releases and gates of one's own nodes, and of anyone's;
|
|
241
|
+
// approving releases (own, any); automations; the kill switch and daily cap.
|
|
242
|
+
"schedules.read": "schedules:read",
|
|
243
|
+
"schedules.write": "schedules:write:own",
|
|
244
|
+
"schedules.writeAny": "schedules:write",
|
|
245
|
+
"releases.approve": "releases:publish:own",
|
|
246
|
+
"releases.approveAny": "releases:publish",
|
|
247
|
+
"automations.manage": "automations:manage",
|
|
248
|
+
"schedules.settings": "site:settings",
|
|
249
|
+
// RFC-0016: in-editor AI commands; the site's AI settings (Administrator).
|
|
250
|
+
"ai.invoke": "ai:invoke",
|
|
251
|
+
"ai.settings": "site:settings",
|
|
252
|
+
"ownership.transfer": "owner",
|
|
253
|
+
} ;
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
/** The ability map for a person (`role` undefined: no membership source, everything allowed). */
|
|
258
|
+
export function abilities(
|
|
259
|
+
role ,
|
|
260
|
+
scopes ,
|
|
261
|
+
) {
|
|
262
|
+
return Object.fromEntries(
|
|
263
|
+
Object.entries(ABILITIES).map(([ability, need]) => [
|
|
264
|
+
ability,
|
|
265
|
+
need === "owner" ? role === undefined || role === "owner" : holds(scopes, need),
|
|
266
|
+
]),
|
|
267
|
+
) ;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Invite expiry (RFC-0005 §7.1): 7 days by default, 1 hour to 14 days, Administrator ≤ 72 h. */
|
|
271
|
+
export const INVITE_DEFAULT_HOURS = 7 * 24;
|
|
272
|
+
export const INVITE_MIN_HOURS = 1;
|
|
273
|
+
export const INVITE_MAX_HOURS = 14 * 24;
|
|
274
|
+
export const INVITE_ADMIN_MAX_HOURS = 72;
|
|
275
|
+
|
|
276
|
+
/** The longest an invite for `role` may last. */
|
|
277
|
+
export function inviteMaxHours(role ) {
|
|
278
|
+
return role === "administrator" ? INVITE_ADMIN_MAX_HOURS : INVITE_MAX_HOURS;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Why an expiry is refused for an invite as `role`, or undefined. */
|
|
282
|
+
export function inviteExpiryProblem(role , hours ) {
|
|
283
|
+
const max = inviteMaxHours(role);
|
|
284
|
+
if (!Number.isInteger(hours) || hours < INVITE_MIN_HOURS || hours > max)
|
|
285
|
+
return `expiresInHours must be a whole number between ${INVITE_MIN_HOURS} and ${max}${role === "administrator" ? " for Administrator invites" : ""}`;
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The default expiry for an invite as `role` (Administrator invites are capped). */
|
|
290
|
+
export function inviteDefaultHours(role ) {
|
|
291
|
+
return Math.min(INVITE_DEFAULT_HOURS, inviteMaxHours(role));
|
|
292
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-site roles (RFC-0034 §2, phase A2; RFC-0005 open question 10). The Owner and Administrators
|
|
3
|
+
* act on every site of the installation. An Editor, Author or Contributor with site assignments
|
|
4
|
+
* works only on those sites, each with its own role (never above Editor); without assignments
|
|
5
|
+
* their installation role applies to every site, as before.
|
|
6
|
+
*
|
|
7
|
+
* REST and MCP resolve the principal once per request, before the site is known; `forSite` turns it
|
|
8
|
+
* into the principal for one site, so every later check (scopes, own-only limits, mode caps,
|
|
9
|
+
* approvals) sees that site's role.
|
|
10
|
+
*/
|
|
11
|
+
import type { PersonRef, Principal } from "./policy.js";
|
|
12
|
+
import { type Role } from "./roles.js";
|
|
13
|
+
/** Roles that may be limited to sites; Owner and Administrators always cover the installation. */
|
|
14
|
+
export declare const SITE_ROLES: readonly Role[];
|
|
15
|
+
export declare function isSiteRole(role: Role): boolean;
|
|
16
|
+
/** True when the person works only on assigned sites. */
|
|
17
|
+
export declare function isSiteLimited(person: PersonRef | undefined): boolean;
|
|
18
|
+
/** The person's role on `siteId`, or undefined when they may not open it. */
|
|
19
|
+
export declare function siteRoleOf(person: PersonRef, siteId: string): Role | undefined;
|
|
20
|
+
/** The sites among `siteIds` the principal may open (all of them when it is not site-limited). */
|
|
21
|
+
export declare function visibleSites<T extends string>(principal: Principal | undefined, siteIds: readonly T[]): T[];
|
|
22
|
+
/**
|
|
23
|
+
* The principal as it acts on `siteId`: undefined when it may not open the site; otherwise its
|
|
24
|
+
* scopes and the person's role are that site's. An agent's own key scopes are narrowed to the
|
|
25
|
+
* site role again, and its mode is capped by it (I5, I6).
|
|
26
|
+
*/
|
|
27
|
+
export declare function forSite(principal: Principal, siteId: string): Principal | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Scopes that act on the whole installation (its team, its activity, every agent): a site-limited
|
|
30
|
+
* person does not hold them outside a site, so they cannot invite people or read installation-wide
|
|
31
|
+
* activity. Inside one of their sites, `forSite` gives that site's role.
|
|
32
|
+
*/
|
|
33
|
+
export declare const INSTALLATION_SCOPES: readonly string[];
|
|
34
|
+
/** The principal outside any one site: a site-limited person loses `INSTALLATION_SCOPES`. */
|
|
35
|
+
export declare function forInstallation(principal: Principal): Principal;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-site roles (RFC-0034 §2, phase A2; RFC-0005 open question 10). The Owner and Administrators
|
|
3
|
+
* act on every site of the installation. An Editor, Author or Contributor with site assignments
|
|
4
|
+
* works only on those sites, each with its own role (never above Editor); without assignments
|
|
5
|
+
* their installation role applies to every site, as before.
|
|
6
|
+
*
|
|
7
|
+
* REST and MCP resolve the principal once per request, before the site is known; `forSite` turns it
|
|
8
|
+
* into the principal for one site, so every later check (scopes, own-only limits, mode caps,
|
|
9
|
+
* approvals) sees that site's role.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { clampMode, modeCap, narrow, roleScopes } from "./roles.js";
|
|
13
|
+
|
|
14
|
+
/** Roles that may be limited to sites; Owner and Administrators always cover the installation. */
|
|
15
|
+
export const SITE_ROLES = ["editor", "author", "contributor"];
|
|
16
|
+
|
|
17
|
+
export function isSiteRole(role ) {
|
|
18
|
+
return SITE_ROLES.includes(role);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** True when the person works only on assigned sites. */
|
|
22
|
+
export function isSiteLimited(person ) {
|
|
23
|
+
return person?.sites !== undefined && isSiteRole(person.role);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The person's role on `siteId`, or undefined when they may not open it. */
|
|
27
|
+
export function siteRoleOf(person , siteId ) {
|
|
28
|
+
if (!isSiteLimited(person)) return person.role;
|
|
29
|
+
return person.sites?.[siteId];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The sites among `siteIds` the principal may open (all of them when it is not site-limited). */
|
|
33
|
+
export function visibleSites (
|
|
34
|
+
principal ,
|
|
35
|
+
siteIds ,
|
|
36
|
+
) {
|
|
37
|
+
const person = principal?.person;
|
|
38
|
+
if (!person || !isSiteLimited(person)) return [...siteIds];
|
|
39
|
+
return siteIds.filter((id) => person.sites?.[id] !== undefined);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The principal as it acts on `siteId`: undefined when it may not open the site; otherwise its
|
|
44
|
+
* scopes and the person's role are that site's. An agent's own key scopes are narrowed to the
|
|
45
|
+
* site role again, and its mode is capped by it (I5, I6).
|
|
46
|
+
*/
|
|
47
|
+
export function forSite(principal , siteId ) {
|
|
48
|
+
const person = principal.person;
|
|
49
|
+
if (!person || !isSiteLimited(person)) return principal;
|
|
50
|
+
const role = person.sites?.[siteId];
|
|
51
|
+
if (!role) return undefined;
|
|
52
|
+
const sitePerson = { ...person, role };
|
|
53
|
+
if (principal.actor.kind !== "agent")
|
|
54
|
+
return { ...principal, scopes: roleScopes(role), person: sitePerson };
|
|
55
|
+
const mode = principal.mode;
|
|
56
|
+
return {
|
|
57
|
+
...principal,
|
|
58
|
+
scopes: narrow(principal.keyScopes ?? principal.scopes, roleScopes(role)),
|
|
59
|
+
person: sitePerson,
|
|
60
|
+
...(mode ? { mode: clampMode(mode, modeCap(role)) } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Scopes that act on the whole installation (its team, its activity, every agent): a site-limited
|
|
66
|
+
* person does not hold them outside a site, so they cannot invite people or read installation-wide
|
|
67
|
+
* activity. Inside one of their sites, `forSite` gives that site's role.
|
|
68
|
+
*/
|
|
69
|
+
export const INSTALLATION_SCOPES = [
|
|
70
|
+
"members:invite",
|
|
71
|
+
"members:manage",
|
|
72
|
+
"agents:manage",
|
|
73
|
+
"audit:read",
|
|
74
|
+
"site:create",
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/** The principal outside any one site: a site-limited person loses `INSTALLATION_SCOPES`. */
|
|
78
|
+
export function forInstallation(principal ) {
|
|
79
|
+
if (!isSiteLimited(principal.person)) return principal;
|
|
80
|
+
return { ...principal, scopes: principal.scopes.filter((s) => !INSTALLATION_SCOPES.includes(s)) };
|
|
81
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type SkillManifest } from "@cloudhal/core-contracts";
|
|
2
|
+
export interface LoadedSkill {
|
|
3
|
+
manifest: SkillManifest;
|
|
4
|
+
/** Directory of the skill. */
|
|
5
|
+
dir: string;
|
|
6
|
+
/** Markdown body (after the frontmatter). Loaded lazily via `body()` for progressive disclosure. */
|
|
7
|
+
body: () => Promise<string>;
|
|
8
|
+
/** The full SKILL.md source (frontmatter included), as served over MCP resources. */
|
|
9
|
+
source: () => Promise<string>;
|
|
10
|
+
references: () => Promise<string[]>;
|
|
11
|
+
/** Reads one file from `references/` by name; throws for names outside that directory. */
|
|
12
|
+
reference: (name: string) => Promise<string>;
|
|
13
|
+
cloudhal: {
|
|
14
|
+
schemaVersion: number;
|
|
15
|
+
requiresCapabilities: string[];
|
|
16
|
+
scopes: string[];
|
|
17
|
+
approval: string[];
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export declare class SkillError extends Error {
|
|
21
|
+
readonly name = "SkillError";
|
|
22
|
+
}
|
|
23
|
+
/** Minimal YAML frontmatter parser for the six spec fields (strings and a flat string map). */
|
|
24
|
+
export declare function parseFrontmatter(markdown: string): {
|
|
25
|
+
frontmatter: Record<string, unknown>;
|
|
26
|
+
body: string;
|
|
27
|
+
};
|
|
28
|
+
/** A skill as plain data (SKILL.md text + reference files), e.g. bundled into a Worker. */
|
|
29
|
+
export interface SkillSource {
|
|
30
|
+
/** Directory name; must equal the manifest `name`. */
|
|
31
|
+
name: string;
|
|
32
|
+
markdown: string;
|
|
33
|
+
references: Record<string, string>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Builds a `LoadedSkill` from data — no filesystem. Validates the manifest against the
|
|
37
|
+
* `SkillManifest` contract and the `cloudhal.*` metadata keys.
|
|
38
|
+
*/
|
|
39
|
+
export declare function skillFromSource(source: SkillSource, dir?: string): LoadedSkill;
|
|
40
|
+
/** Reads one skill directory into a `SkillSource` (Node). */
|
|
41
|
+
export declare function readSkillSource(dir: string): Promise<SkillSource>;
|
|
42
|
+
/** Reads every skill under `root` (one directory per skill), sorted by name. */
|
|
43
|
+
export declare function readSkillSources(root: string): Promise<SkillSource[]>;
|
|
44
|
+
/** Loads one skill directory: validates the manifest against the `SkillManifest` contract and the `cloudhal.*` metadata keys. */
|
|
45
|
+
export declare function loadSkill(dir: string): Promise<LoadedSkill>;
|
|
46
|
+
/** Loads every skill under `root` (one directory per skill). Sorted by name for deterministic listings. */
|
|
47
|
+
export declare function loadSkills(root: string): Promise<LoadedSkill[]>;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { assertValid, } from "@cloudhal/core-contracts";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
export class SkillError extends Error {
|
|
25
|
+
name = "SkillError";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Minimal YAML frontmatter parser for the six spec fields (strings and a flat string map). */
|
|
29
|
+
export function parseFrontmatter(markdown )
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
const m = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
34
|
+
if (!m) throw new SkillError("SKILL.md must start with YAML frontmatter");
|
|
35
|
+
const fm = {};
|
|
36
|
+
let currentMap ;
|
|
37
|
+
for (const raw of (m[1] ).split(/\r?\n/)) {
|
|
38
|
+
if (!raw.trim() || raw.trim().startsWith("#")) continue;
|
|
39
|
+
const nested = raw.match(/^\s+([^:]+):\s*(.*)$/);
|
|
40
|
+
if (nested && currentMap) {
|
|
41
|
+
currentMap[nested[1] ] = unquote((nested[2] ).trim());
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const top = raw.match(/^([A-Za-z][\w-]*):\s*(.*)$/);
|
|
45
|
+
if (!top) throw new SkillError(`invalid frontmatter line: ${raw}`);
|
|
46
|
+
const [, key, value] = top ;
|
|
47
|
+
if (value.trim() === "") {
|
|
48
|
+
currentMap = {};
|
|
49
|
+
fm[key] = currentMap;
|
|
50
|
+
} else {
|
|
51
|
+
currentMap = undefined;
|
|
52
|
+
fm[key] = unquote(value.trim());
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { frontmatter: fm, body: m[2] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function unquote(v ) {
|
|
59
|
+
return (v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))
|
|
60
|
+
? v.slice(1, -1)
|
|
61
|
+
: v;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function list(value ) {
|
|
65
|
+
return (value ?? "")
|
|
66
|
+
.split(",")
|
|
67
|
+
.map((s) => s.trim())
|
|
68
|
+
.filter(Boolean);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A skill as plain data (SKILL.md text + reference files), e.g. bundled into a Worker. */
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Builds a `LoadedSkill` from data — no filesystem. Validates the manifest against the
|
|
81
|
+
* `SkillManifest` contract and the `cloudhal.*` metadata keys.
|
|
82
|
+
*/
|
|
83
|
+
export function skillFromSource(source , dir = `skill:${source.name}`) {
|
|
84
|
+
const { frontmatter, body } = parseFrontmatter(source.markdown);
|
|
85
|
+
assertValid("skill-manifest", frontmatter);
|
|
86
|
+
const manifest = frontmatter;
|
|
87
|
+
if (manifest.name !== source.name)
|
|
88
|
+
throw new SkillError(`skill name "${manifest.name}" must match directory "${source.name}"`);
|
|
89
|
+
const meta = manifest.metadata ?? {};
|
|
90
|
+
const schemaVersion = Number(meta["cloudhal.schemaVersion"] ?? "1");
|
|
91
|
+
if (schemaVersion !== 1)
|
|
92
|
+
throw new SkillError(`${manifest.name}: unsupported cloudhal.schemaVersion ${schemaVersion}`);
|
|
93
|
+
const refs = Object.keys(source.references)
|
|
94
|
+
.filter((f) => f.endsWith(".md"))
|
|
95
|
+
.sort();
|
|
96
|
+
return {
|
|
97
|
+
manifest,
|
|
98
|
+
dir,
|
|
99
|
+
body: async () => body,
|
|
100
|
+
source: async () => source.markdown,
|
|
101
|
+
reference: async (name) => {
|
|
102
|
+
if (!refs.includes(name)) throw new SkillError(`invalid reference name "${name}"`);
|
|
103
|
+
return source.references[name] ;
|
|
104
|
+
},
|
|
105
|
+
references: async () => refs,
|
|
106
|
+
cloudhal: {
|
|
107
|
+
schemaVersion,
|
|
108
|
+
requiresCapabilities: list(meta["cloudhal.requiresCapabilities"]),
|
|
109
|
+
scopes: list(meta["cloudhal.scopes"]),
|
|
110
|
+
approval: list(meta["cloudhal.approval"]),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Reads one skill directory into a `SkillSource` (Node). */
|
|
116
|
+
export async function readSkillSource(dir ) {
|
|
117
|
+
const markdown = await readFile(path.join(dir, "SKILL.md"), "utf8");
|
|
118
|
+
const references = {};
|
|
119
|
+
const refDir = path.join(dir, "references");
|
|
120
|
+
try {
|
|
121
|
+
if ((await stat(refDir)).isDirectory())
|
|
122
|
+
for (const f of (await readdir(refDir)).filter((x) => x.endsWith(".md")).sort())
|
|
123
|
+
references[f] = await readFile(path.join(refDir, f), "utf8");
|
|
124
|
+
} catch {
|
|
125
|
+
// no references
|
|
126
|
+
}
|
|
127
|
+
return { name: path.basename(dir), markdown, references };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Reads every skill under `root` (one directory per skill), sorted by name. */
|
|
131
|
+
export async function readSkillSources(root ) {
|
|
132
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
133
|
+
const sources = await Promise.all(
|
|
134
|
+
entries.filter((e) => e.isDirectory()).map((e) => readSkillSource(path.join(root, e.name))),
|
|
135
|
+
);
|
|
136
|
+
return sources.sort((a, b) => a.name.localeCompare(b.name));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Loads one skill directory: validates the manifest against the `SkillManifest` contract and the `cloudhal.*` metadata keys. */
|
|
140
|
+
export async function loadSkill(dir ) {
|
|
141
|
+
return skillFromSource(await readSkillSource(dir), dir);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Loads every skill under `root` (one directory per skill). Sorted by name for deterministic listings. */
|
|
145
|
+
export async function loadSkills(root ) {
|
|
146
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
147
|
+
const skills = await Promise.all(
|
|
148
|
+
entries.filter((e) => e.isDirectory()).map((e) => loadSkill(path.join(root, e.name))),
|
|
149
|
+
);
|
|
150
|
+
return skills.sort((a, b) => a.manifest.name.localeCompare(b.manifest.name));
|
|
151
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-layer rules for taxonomies and redirects (RFC-0002 §6.4). Data invariants (limits, required
|
|
3
|
+
* terms, slugs) live in core-ts; what an *agent* may do — create terms, point redirects off-site,
|
|
4
|
+
* approve proposals — is decided here, the same for every transport.
|
|
5
|
+
*/
|
|
6
|
+
import type { Actor } from "@cloudhal/core-contracts";
|
|
7
|
+
import { type Principal } from "./policy.js";
|
|
8
|
+
/** `Taxonomy.allowNewTerms`. */
|
|
9
|
+
export type AllowNewTerms = "none" | "propose" | "free";
|
|
10
|
+
/** What a term created by an actor becomes, or why it may not be created. */
|
|
11
|
+
export type TermCreation = {
|
|
12
|
+
readonly allowed: true;
|
|
13
|
+
readonly status: "active" | "proposed";
|
|
14
|
+
} | {
|
|
15
|
+
readonly allowed: false;
|
|
16
|
+
readonly reason: string;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Invariant 11 for an actor: people (and the system) create active terms; agents create proposed
|
|
20
|
+
* terms under `propose`, active ones under `free`, and none under `none` ("ask a person").
|
|
21
|
+
*
|
|
22
|
+
* Given a principal, a person whose role lacks `site:write` (Contributor, Author; RFC-0005 §2.2) is
|
|
23
|
+
* treated like an agent: they propose tags and never add categories.
|
|
24
|
+
*/
|
|
25
|
+
export declare function termCreation(who: Actor | Pick<Principal, "actor" | "scopes">, allowNewTerms: AllowNewTerms): TermCreation;
|
|
26
|
+
/**
|
|
27
|
+
* Why an actor may not point a redirect at `to`, or undefined. Agents redirect to paths of the same
|
|
28
|
+
* site only; an external target (open-redirect risk) is a person's decision (RFC-0002, Security).
|
|
29
|
+
*/
|
|
30
|
+
export declare function redirectTargetProblem(actor: Actor, to: string): string | undefined;
|
|
31
|
+
/** A review decision on a proposed term. */
|
|
32
|
+
export type TermReviewDecision = "approve" | "reject" | "merge";
|
|
33
|
+
/**
|
|
34
|
+
* Why an actor may not take a review decision on a term, or undefined. An agent never approves a
|
|
35
|
+
* term it proposed itself (the person's review is the point of `propose`).
|
|
36
|
+
*/
|
|
37
|
+
export declare function termReviewProblem(actor: Actor, term: {
|
|
38
|
+
readonly proposedBy?: Actor | undefined;
|
|
39
|
+
}, decision: TermReviewDecision): string | undefined;
|
|
40
|
+
/** Freeing an old slug (`reclaim`) deletes a redirect people may rely on: people only. */
|
|
41
|
+
export declare function reclaimProblem(actor: Actor): string | undefined;
|