@graphorin/skills 0.6.1 → 0.7.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.
@@ -0,0 +1,813 @@
1
+ /**
2
+ * Skill loader.
3
+ *
4
+ * Implements three-tier progressive disclosure:
5
+ *
6
+ * - **Tier 1** (always): {@link Skill.metadata} - parsed at load
7
+ * time from the SKILL.md frontmatter.
8
+ * - **Tier 2** (on activation): {@link Skill.body} - the loader
9
+ * reads the markdown body lazily; subsequent calls return the
10
+ * cached value.
11
+ * - **Tier 3** (on demand): {@link Skill.resources} - the loader
12
+ * walks the skill directory lazily; resource bytes are only read
13
+ * when {@link SkillResource.read} is invoked.
14
+ *
15
+ * The loader supports four sources:
16
+ *
17
+ * - `{ kind: 'folder', path }` - read SKILL.md from disk.
18
+ * - `{ kind: 'npm-package', ... }` - install via the supply-chain
19
+ * helper from `@graphorin/security/supply-chain`, then read.
20
+ * - `{ kind: 'git-repo', ... }` - shallow-clone via the
21
+ * supply-chain helper, then read.
22
+ * - `{ kind: 'inline', skill: ... }` - caller supplies the parsed
23
+ * payload; the loader only validates the frontmatter. Useful for
24
+ * tests and bundled defaults.
25
+ *
26
+ * @packageDocumentation
27
+ */
28
+
29
+ import type { Stats } from 'node:fs';
30
+ import { readdir, readFile, stat } from 'node:fs/promises';
31
+ import { extname, join, relative, resolve, sep } from 'node:path';
32
+
33
+ import {
34
+ installSkillFromGit,
35
+ installSkillFromNpm,
36
+ type ResolvedSkillTrustPolicy,
37
+ resolveTrustPolicy,
38
+ type SkillSignatureVerificationResult,
39
+ type SupplyChainPolicy,
40
+ verifySkillSignature,
41
+ } from '@graphorin/security/supply-chain';
42
+
43
+ import {
44
+ InputFilterRequiredError,
45
+ SkillFrontmatterConflictError,
46
+ SkillLoadError,
47
+ SkillRequiredFieldMissingError,
48
+ SkillRuntimeCompatError,
49
+ } from '../errors/index.js';
50
+ import {
51
+ parseAllowedToolsValue,
52
+ parseFrontmatterYaml,
53
+ parseHandoffInputFilter,
54
+ parseToolsField,
55
+ splitSkillMd,
56
+ type ValidatedFrontmatter,
57
+ validateFrontmatter,
58
+ } from '../frontmatter/index.js';
59
+ import type {
60
+ FrontmatterDiagnostic,
61
+ FrontmatterValidatorPolicy,
62
+ HandoffInputFilterDeclaration,
63
+ InlineSkillTool,
64
+ Skill,
65
+ SkillMetadata,
66
+ SkillResource,
67
+ SkillSource,
68
+ SkillsTrustLevel,
69
+ SkillToolDeclaration,
70
+ UnknownFieldPolicy,
71
+ } from '../types/index.js';
72
+
73
+ /** Options forwarded to {@link loadSkillFromSource}. */
74
+ export interface LoadSkillOptions {
75
+ readonly conflictPolicy?: FrontmatterValidatorPolicy;
76
+ readonly unknownFieldPolicy?: UnknownFieldPolicy;
77
+ readonly runtimeVersion?: string;
78
+ readonly supplyChainPolicy?: SupplyChainPolicy;
79
+ readonly signal?: AbortSignal;
80
+ /** Override the bundled MIME-type guesser for resource files. */
81
+ readonly mediaTypeFor?: (path: string) => string | undefined;
82
+ }
83
+
84
+ /** Aggregate options accepted by {@link loadSkills}. */
85
+ export interface LoadSkillsOptions extends LoadSkillOptions {
86
+ /**
87
+ * Fail fast if any source produces a {@link SkillLoadError}. When
88
+ * `false` (default) the loader logs the source path on the
89
+ * diagnostic and continues with the next source.
90
+ */
91
+ readonly throwOnSourceError?: boolean;
92
+ }
93
+
94
+ const SKILL_MANIFEST_FILENAME = 'SKILL.md';
95
+
96
+ const DEFAULT_MEDIA_TYPES: Readonly<Record<string, string>> = Object.freeze({
97
+ '.md': 'text/markdown; charset=utf-8',
98
+ '.txt': 'text/plain; charset=utf-8',
99
+ '.json': 'application/json',
100
+ '.yaml': 'application/yaml',
101
+ '.yml': 'application/yaml',
102
+ '.ts': 'text/typescript; charset=utf-8',
103
+ '.js': 'text/javascript; charset=utf-8',
104
+ '.mjs': 'text/javascript; charset=utf-8',
105
+ '.cjs': 'text/javascript; charset=utf-8',
106
+ '.py': 'text/x-python; charset=utf-8',
107
+ '.html': 'text/html; charset=utf-8',
108
+ '.css': 'text/css; charset=utf-8',
109
+ '.png': 'image/png',
110
+ '.jpg': 'image/jpeg',
111
+ '.jpeg': 'image/jpeg',
112
+ '.gif': 'image/gif',
113
+ '.svg': 'image/svg+xml',
114
+ '.pdf': 'application/pdf',
115
+ });
116
+
117
+ /**
118
+ * Load a single skill from any supported source. The loader runs the
119
+ * full frontmatter validator and resolves the supply-chain trust
120
+ * policy so the returned {@link Skill} is ready to be inserted into a
121
+ * `SkillRegistry`.
122
+ *
123
+ * @stable
124
+ */
125
+ export async function loadSkillFromSource(
126
+ source: SkillSource,
127
+ options: LoadSkillOptions = {},
128
+ ): Promise<Skill> {
129
+ switch (source.kind) {
130
+ case 'folder':
131
+ return loadFromFolder(source.path, source, options);
132
+ case 'inline':
133
+ return loadFromInline(source, options);
134
+ case 'npm-package': {
135
+ const installArgs: Parameters<typeof installSkillFromNpm>[0] = {
136
+ packageName: source.packageName,
137
+ };
138
+ if (source.version !== undefined)
139
+ (installArgs as Mutable<typeof installArgs>).version = source.version;
140
+ if (source.trustLevel !== undefined)
141
+ (installArgs as Mutable<typeof installArgs>).trustLevel = source.trustLevel;
142
+ if (options.supplyChainPolicy !== undefined)
143
+ (installArgs as Mutable<typeof installArgs>).policy = options.supplyChainPolicy;
144
+ if (options.signal !== undefined)
145
+ (installArgs as Mutable<typeof installArgs>).signal = options.signal;
146
+ const status = await installSkillFromNpm(installArgs);
147
+ const installPath = status.installPath;
148
+ if (installPath === undefined) {
149
+ throw new SkillLoadError(
150
+ source.packageName,
151
+ 'npm install completed without an install path; cannot continue.',
152
+ );
153
+ }
154
+ const resolved = await locateSkillRoot(installPath, source.packageName);
155
+ return loadFromFolder(resolved, source, options, status.signature);
156
+ }
157
+ case 'git-repo': {
158
+ const installArgs: Parameters<typeof installSkillFromGit>[0] = {
159
+ repoUrl: source.url,
160
+ };
161
+ if (source.ref !== undefined) (installArgs as Mutable<typeof installArgs>).ref = source.ref;
162
+ if (source.trustLevel !== undefined)
163
+ (installArgs as Mutable<typeof installArgs>).trustLevel = source.trustLevel;
164
+ if (options.supplyChainPolicy !== undefined)
165
+ (installArgs as Mutable<typeof installArgs>).policy = options.supplyChainPolicy;
166
+ if (options.signal !== undefined)
167
+ (installArgs as Mutable<typeof installArgs>).signal = options.signal;
168
+ const status = await installSkillFromGit(installArgs);
169
+ const installPath = status.installPath;
170
+ if (installPath === undefined) {
171
+ throw new SkillLoadError(
172
+ source.url,
173
+ 'git clone completed without a clone path; cannot continue.',
174
+ );
175
+ }
176
+ const resolved = await locateSkillRoot(installPath, source.url);
177
+ return loadFromFolder(resolved, source, options, status.signature);
178
+ }
179
+ default: {
180
+ const exhaustive: never = source;
181
+ void exhaustive;
182
+ throw new SkillLoadError('<unknown>', 'unsupported skill source.');
183
+ }
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Load multiple skills concurrently. The sources are loaded in parallel and
189
+ * the returned array preserves input order. When `throwOnSourceError === false`
190
+ * (default) a failing source is logged and skipped; otherwise the first
191
+ * rejection propagates out unchanged.
192
+ *
193
+ * @stable
194
+ */
195
+ export async function loadSkills(
196
+ sources: ReadonlyArray<SkillSource>,
197
+ options: LoadSkillsOptions = {},
198
+ ): Promise<ReadonlyArray<Skill>> {
199
+ const { throwOnSourceError = false, ...inner } = options;
200
+ const loaded = await Promise.all(
201
+ sources.map(async (source): Promise<Skill | null> => {
202
+ try {
203
+ return await loadSkillFromSource(source, inner);
204
+ } catch (err) {
205
+ if (throwOnSourceError) throw err;
206
+ // We cannot create a Skill from a failed source, but we want to
207
+ // preserve a structured diagnostic so callers can audit which
208
+ // sources failed without re-running the loader.
209
+ // eslint-disable-next-line no-console
210
+ console.warn(
211
+ `[graphorin/skills] Failed to load source ${describeSource(source)}: ${(err as Error).message}`,
212
+ );
213
+ return null;
214
+ }
215
+ }),
216
+ );
217
+ return Object.freeze(loaded.filter((skill): skill is Skill => skill !== null));
218
+ }
219
+
220
+ async function loadFromInline(
221
+ source: Extract<SkillSource, { kind: 'inline' }>,
222
+ options: LoadSkillOptions,
223
+ ): Promise<Skill> {
224
+ const trustPolicy = resolveTrustPolicy(
225
+ { kind: 'folder', path: source.skill.basePath ?? '<inline>' },
226
+ 'trusted',
227
+ );
228
+ const { metadata, diagnostics, body } = parseAndValidate(source.skill.skillMd, options);
229
+ const resourceList = source.skill.resources ?? [];
230
+ const resources: SkillResource[] = resourceList.map((entry) => {
231
+ const relativePath = entry.path;
232
+ const path =
233
+ source.skill.basePath !== undefined ? join(source.skill.basePath, entry.path) : entry.path;
234
+ const mediaType = options.mediaTypeFor?.(entry.path) ?? guessMediaType(entry.path);
235
+ return Object.freeze({
236
+ path,
237
+ relativePath,
238
+ ...(mediaType === undefined ? {} : { mediaType }),
239
+ async read() {
240
+ return new TextEncoder().encode(entry.content);
241
+ },
242
+ async readText() {
243
+ return entry.content;
244
+ },
245
+ } satisfies SkillResource);
246
+ });
247
+ return buildSkill({
248
+ metadata,
249
+ diagnostics,
250
+ body,
251
+ source,
252
+ trustPolicy,
253
+ ...(source.skill.tools === undefined ? {} : { tools: source.skill.tools }),
254
+ bodyLoader: () => Promise.resolve(body),
255
+ resourceLoader: () => Promise.resolve(resources),
256
+ });
257
+ }
258
+
259
+ async function loadFromFolder(
260
+ folderPath: string,
261
+ source: SkillSource,
262
+ options: LoadSkillOptions,
263
+ precomputedSignature?: SkillSignatureVerificationResult | undefined,
264
+ ): Promise<Skill> {
265
+ const absolutePath = resolve(folderPath);
266
+ let stats: Stats;
267
+ try {
268
+ stats = await stat(absolutePath);
269
+ } catch (err) {
270
+ throw new SkillLoadError(
271
+ describeSource(source),
272
+ `Could not stat skill directory '${absolutePath}'.`,
273
+ { cause: err },
274
+ );
275
+ }
276
+ if (!stats.isDirectory()) {
277
+ throw new SkillLoadError(
278
+ describeSource(source),
279
+ `Skill source must be a directory; '${absolutePath}' is not.`,
280
+ );
281
+ }
282
+ const manifestPath = join(absolutePath, SKILL_MANIFEST_FILENAME);
283
+ let skillMd: string;
284
+ try {
285
+ skillMd = await readFile(manifestPath, 'utf8');
286
+ } catch (err) {
287
+ throw new SkillLoadError(describeSource(source), `SKILL.md is missing at '${manifestPath}'.`, {
288
+ hint: 'Add a SKILL.md file with a YAML frontmatter block.',
289
+ cause: err,
290
+ });
291
+ }
292
+ const parsed = parseAndValidate(skillMd, options);
293
+ const { diagnostics, body } = parsed;
294
+ // RP-9: trust is granted by the integrator, never the artifact. An operator
295
+ // override on the source wins; absent one, the artifact's self-declared
296
+ // 'trusted'/'trusted-with-scripts' is capped at 'unknown' so a downloaded
297
+ // skill cannot promote itself out of the sandbox + taint-marking. The cap
298
+ // applies to EVERY source kind (mcp-skills-01): npm/git sources previously
299
+ // took the SKILL.md's self-declared level verbatim, and - because the
300
+ // signature trust root allows an inline key in the same SKILL.md - a
301
+ // malicious package could inline its own key, self-sign, declare
302
+ // 'trusted', and load unsandboxed with no operator involvement. The
303
+ // resolved level is written back onto the metadata so every downstream
304
+ // consumer (tool stamping, sandbox tier, inbound sanitization) sees it.
305
+ const operatorTrust = extractTrustLevel(source);
306
+ const effectiveTrust: SkillsTrustLevel =
307
+ operatorTrust ?? capSelfDeclaredTrust(parsed.metadata.graphorinTrustLevel);
308
+ const metadata: SkillMetadata =
309
+ effectiveTrust === parsed.metadata.graphorinTrustLevel
310
+ ? parsed.metadata
311
+ : (Object.freeze({
312
+ ...parsed.metadata,
313
+ graphorinTrustLevel: effectiveTrust,
314
+ }) as SkillMetadata);
315
+
316
+ let signature: SkillSignatureVerificationResult | undefined = precomputedSignature;
317
+ if (signature === undefined && metadata.graphorinSignaturePresent) {
318
+ try {
319
+ signature = await verifySkillSignature({
320
+ skillMd,
321
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
322
+ });
323
+ } catch (err) {
324
+ // Surface the signature failure as a diagnostic - the supply-
325
+ // chain installer is the one that decides whether to refuse the
326
+ // install based on the resolved trust policy. The folder loader
327
+ // tolerates an unverifiable signature so operators can iterate
328
+ // on local skills before signing.
329
+ // eslint-disable-next-line no-console
330
+ console.warn(
331
+ `[graphorin/skills] Signature verification of '${metadata.name}' failed: ${(err as Error).message}`,
332
+ );
333
+ }
334
+ }
335
+
336
+ const trustPolicy = resolveTrustPolicy(
337
+ source.kind === 'folder'
338
+ ? { kind: 'folder', path: absolutePath }
339
+ : source.kind === 'npm-package'
340
+ ? source.version === undefined
341
+ ? { kind: 'npm-package', packageName: source.packageName }
342
+ : { kind: 'npm-package', packageName: source.packageName, version: source.version }
343
+ : source.kind === 'git-repo'
344
+ ? source.ref === undefined
345
+ ? { kind: 'git-repo', url: source.url }
346
+ : { kind: 'git-repo', url: source.url, ref: source.ref }
347
+ : { kind: 'folder', path: absolutePath },
348
+ coerceForSupplyChain(metadata.graphorinTrustLevel),
349
+ );
350
+
351
+ return buildSkill({
352
+ metadata,
353
+ diagnostics,
354
+ body,
355
+ source,
356
+ trustPolicy,
357
+ basePath: absolutePath,
358
+ ...(signature === undefined ? {} : { signature }),
359
+ bodyLoader: () => Promise.resolve(body),
360
+ resourceLoader: async (signal) => listResources(absolutePath, options, signal),
361
+ });
362
+ }
363
+
364
+ async function listResources(
365
+ rootPath: string,
366
+ options: LoadSkillOptions,
367
+ signal?: AbortSignal,
368
+ ): Promise<ReadonlyArray<SkillResource>> {
369
+ const out: SkillResource[] = [];
370
+ for await (const file of walk(rootPath, signal)) {
371
+ const relativePath = relative(rootPath, file);
372
+ if (relativePath === SKILL_MANIFEST_FILENAME) continue;
373
+ const mediaType = options.mediaTypeFor?.(relativePath) ?? guessMediaType(relativePath);
374
+ out.push(
375
+ Object.freeze({
376
+ path: file,
377
+ relativePath: relativePath.split(sep).join('/'),
378
+ ...(mediaType === undefined ? {} : { mediaType }),
379
+ async read(innerSignal?: AbortSignal): Promise<Uint8Array> {
380
+ const buffer = await readFile(file, {
381
+ ...(innerSignal === undefined ? {} : { signal: innerSignal }),
382
+ });
383
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
384
+ },
385
+ async readText(innerSignal?: AbortSignal): Promise<string> {
386
+ return readFile(file, {
387
+ encoding: 'utf8',
388
+ ...(innerSignal === undefined ? {} : { signal: innerSignal }),
389
+ });
390
+ },
391
+ } satisfies SkillResource),
392
+ );
393
+ }
394
+ return Object.freeze(out);
395
+ }
396
+
397
+ async function* walk(root: string, signal?: AbortSignal): AsyncIterable<string> {
398
+ const entries = await readdir(root, { withFileTypes: true });
399
+ for (const entry of entries) {
400
+ if (signal?.aborted === true) return;
401
+ const full = join(root, entry.name);
402
+ if (entry.isDirectory()) {
403
+ yield* walk(full, signal);
404
+ continue;
405
+ }
406
+ if (entry.isFile()) {
407
+ yield full;
408
+ }
409
+ }
410
+ }
411
+
412
+ async function locateSkillRoot(installPath: string, sourceLabel: string): Promise<string> {
413
+ const direct = join(installPath, SKILL_MANIFEST_FILENAME);
414
+ try {
415
+ await stat(direct);
416
+ return installPath;
417
+ } catch {
418
+ // continue
419
+ }
420
+ // RP-10: npm packages land under `node_modules/<packageName>`. The label is
421
+ // the package name for npm sources; for git it is a URL and this probe
422
+ // simply misses, falling through to the one-level-deep scan below.
423
+ const nodeModulesRoot = join(installPath, 'node_modules', sourceLabel);
424
+ try {
425
+ await stat(join(nodeModulesRoot, SKILL_MANIFEST_FILENAME));
426
+ return nodeModulesRoot;
427
+ } catch {
428
+ // continue
429
+ }
430
+ const entries = await readdir(installPath, { withFileTypes: true });
431
+ for (const entry of entries) {
432
+ if (entry.isDirectory()) {
433
+ const child = join(installPath, entry.name, SKILL_MANIFEST_FILENAME);
434
+ try {
435
+ await stat(child);
436
+ return join(installPath, entry.name);
437
+ } catch {
438
+ // continue
439
+ }
440
+ }
441
+ }
442
+ throw new SkillLoadError(
443
+ sourceLabel,
444
+ `Could not locate SKILL.md inside '${installPath}'. Expected at the top-level or one folder deep.`,
445
+ );
446
+ }
447
+
448
+ interface ParsedAndValidated {
449
+ readonly metadata: SkillMetadata;
450
+ readonly diagnostics: ReadonlyArray<FrontmatterDiagnostic>;
451
+ readonly body: string;
452
+ }
453
+
454
+ function parseAndValidate(skillMd: string, options: LoadSkillOptions): ParsedAndValidated {
455
+ const split = splitSkillMd(skillMd);
456
+ const frontmatter = parseFrontmatterYaml(split.frontmatter);
457
+ const validateOptions: Parameters<typeof validateFrontmatter>[1] = {};
458
+ if (options.conflictPolicy !== undefined)
459
+ (validateOptions as Mutable<typeof validateOptions>).conflictPolicy = options.conflictPolicy;
460
+ if (options.unknownFieldPolicy !== undefined)
461
+ (validateOptions as Mutable<typeof validateOptions>).unknownFieldPolicy =
462
+ options.unknownFieldPolicy;
463
+ if (options.runtimeVersion !== undefined)
464
+ (validateOptions as Mutable<typeof validateOptions>).runtimeVersion = options.runtimeVersion;
465
+ const validated = validateFrontmatter(frontmatter, validateOptions);
466
+ for (const diag of validated.diagnostics) {
467
+ if (diag.severity === 'error' && diag.kind === 'missing-required-field') {
468
+ throw new SkillRequiredFieldMissingError(diag.field, {
469
+ ...(diag.hint === undefined ? {} : { hint: diag.hint }),
470
+ });
471
+ }
472
+ }
473
+ const metadata = buildMetadata(validated, frontmatter);
474
+ const diagnostics: FrontmatterDiagnostic[] = [...validated.diagnostics];
475
+ appendUntrustedHandoffDiagnostic(metadata, diagnostics, options.conflictPolicy);
476
+ appendInvalidFieldTypeDiagnostics(frontmatter, validated, diagnostics);
477
+ enforceErrorPolicy(metadata, validated, diagnostics, options);
478
+ return Object.freeze({
479
+ metadata,
480
+ diagnostics: Object.freeze(diagnostics),
481
+ body: split.body,
482
+ });
483
+ }
484
+
485
+ /**
486
+ * RP-11(d): a frontmatter field that is present but unparseable must not be
487
+ * silently dropped (`?? []`). Surface an `invalid-field-type` diagnostic so
488
+ * callers can audit the rejected value, as the parser contracts mandate.
489
+ */
490
+ function appendInvalidFieldTypeDiagnostics(
491
+ raw: Record<string, unknown>,
492
+ validated: ValidatedFrontmatter,
493
+ diagnostics: FrontmatterDiagnostic[],
494
+ ): void {
495
+ const handoffRaw = validated.resolved.handoffInputFilter.value;
496
+ if (handoffRaw !== undefined && parseHandoffInputFilter(handoffRaw) === null) {
497
+ diagnostics.push(
498
+ Object.freeze({
499
+ kind: 'invalid-field-type',
500
+ field: 'handoff-input-filter',
501
+ severity: 'warn',
502
+ message: "'handoff-input-filter' has an unsupported shape; the declaration was ignored.",
503
+ hint: "Use 'lastUser', 'lastN: <n>', 'none', or 'full'.",
504
+ }),
505
+ );
506
+ }
507
+ const toolsRaw = raw['graphorin-tools'];
508
+ if (toolsRaw !== undefined && parseToolsField(toolsRaw) === null) {
509
+ diagnostics.push(
510
+ Object.freeze({
511
+ kind: 'invalid-field-type',
512
+ field: 'graphorin-tools',
513
+ severity: 'warn',
514
+ message: "'graphorin-tools' must be a list of tool declarations; the value was ignored.",
515
+ hint: 'Provide a YAML list, e.g. `graphorin-tools:` with `- name: read_file` entries.',
516
+ }),
517
+ );
518
+ }
519
+ }
520
+
521
+ /**
522
+ * RP-11(b): under `conflictPolicy: 'error'`, an error-severity diagnostic
523
+ * fails the load through the matching typed exception instead of being
524
+ * silently surfaced. Default (`'warn'`) keeps these as diagnostics.
525
+ */
526
+ function enforceErrorPolicy(
527
+ metadata: SkillMetadata,
528
+ validated: ValidatedFrontmatter,
529
+ diagnostics: ReadonlyArray<FrontmatterDiagnostic>,
530
+ options: LoadSkillOptions,
531
+ ): void {
532
+ if (options.conflictPolicy !== 'error') return;
533
+ for (const diag of diagnostics) {
534
+ if (diag.severity !== 'error') continue;
535
+ if (diag.kind === 'invalid-runtime-compat') {
536
+ const declared = validated.resolved.runtimeCompat.value;
537
+ throw new SkillRuntimeCompatError(
538
+ metadata.name,
539
+ typeof declared === 'string' ? declared : String(declared),
540
+ options.runtimeVersion ?? '<unknown>',
541
+ diag.hint === undefined ? undefined : { hint: diag.hint },
542
+ );
543
+ }
544
+ if (diag.kind === 'conflict') {
545
+ throw new SkillFrontmatterConflictError(
546
+ metadata.name,
547
+ diag.field,
548
+ [diag.field],
549
+ diag.hint === undefined ? undefined : { hint: diag.hint },
550
+ );
551
+ }
552
+ }
553
+ }
554
+
555
+ function buildMetadata(
556
+ validated: ValidatedFrontmatter,
557
+ raw: Record<string, unknown>,
558
+ ): SkillMetadata {
559
+ const name = String(validated.resolved.name.value ?? '').trim();
560
+ const description = String(validated.resolved.description.value ?? '').trim();
561
+ const allowedToolsRaw = validated.resolved.allowedTools.value;
562
+ const allowedTools =
563
+ allowedToolsRaw === undefined ? undefined : parseAllowedToolsValue(allowedToolsRaw);
564
+ const handoffFilter =
565
+ validated.resolved.handoffInputFilter.value === undefined
566
+ ? undefined
567
+ : parseHandoffInputFilter(validated.resolved.handoffInputFilter.value);
568
+ const trustLevelRaw = validated.resolved.trustLevel.value;
569
+ // Phase 08 § "Frontmatter validator" recognises four explicit trust
570
+ // levels: 'trusted' | 'trusted-with-scripts' | 'unknown' |
571
+ // 'untrusted'. A skill that did not declare the field is treated as
572
+ // 'unknown' (sandbox forced; signature optional) - that is the
573
+ // default-deny posture per DEC-148 risk mitigation.
574
+ const trustLevel: SkillsTrustLevel = (() => {
575
+ if (
576
+ trustLevelRaw === 'trusted' ||
577
+ trustLevelRaw === 'trusted-with-scripts' ||
578
+ trustLevelRaw === 'untrusted' ||
579
+ trustLevelRaw === 'unknown'
580
+ ) {
581
+ return trustLevelRaw;
582
+ }
583
+ return 'unknown';
584
+ })();
585
+ const sandbox = validated.resolved.sandbox.value;
586
+ const sensitivity = validated.resolved.sensitivity.value;
587
+ const sensitivityDefaultsRaw = validated.resolved.sensitivityDefaults.value;
588
+ const sensitivityDefaults =
589
+ sensitivityDefaultsRaw !== null && typeof sensitivityDefaultsRaw === 'object'
590
+ ? Object.freeze({ ...(sensitivityDefaultsRaw as Record<string, string>) })
591
+ : undefined;
592
+ const metadataObj = validated.resolved.metadata.value;
593
+ const metadataValue =
594
+ metadataObj !== null && typeof metadataObj === 'object'
595
+ ? Object.freeze({ ...(metadataObj as Record<string, unknown>) })
596
+ : undefined;
597
+ const result: Mutable<SkillMetadata> = {
598
+ name,
599
+ description,
600
+ disableModelInvocation: validated.resolved.disableModelInvocation.value === true,
601
+ graphorinTrustLevel: trustLevel,
602
+ graphorinSignaturePresent: 'graphorin-signature' in raw,
603
+ raw: Object.freeze({ ...raw }),
604
+ };
605
+ if (typeof validated.resolved.license.value === 'string')
606
+ result.license = validated.resolved.license.value;
607
+ if (typeof validated.resolved.compatibility.value === 'string')
608
+ result.compatibility = validated.resolved.compatibility.value;
609
+ if (metadataValue !== undefined) result.metadata = metadataValue;
610
+ if (allowedTools !== undefined && allowedTools !== null)
611
+ result.allowedTools = Object.freeze([...allowedTools]);
612
+ if (typeof validated.resolved.runtimeCompat.value === 'string')
613
+ result.graphorinRuntimeCompat = validated.resolved.runtimeCompat.value;
614
+ if (typeof sensitivity === 'string') result.graphorinSensitivity = sensitivity;
615
+ if (sensitivityDefaults !== undefined) result.graphorinSensitivityDefaults = sensitivityDefaults;
616
+ if (sandbox !== null && typeof sandbox === 'object')
617
+ result.graphorinSandbox = Object.freeze({ ...(sandbox as Record<string, unknown>) });
618
+ if (handoffFilter !== undefined && handoffFilter !== null)
619
+ result.graphorinHandoffInputFilter = handoffFilter;
620
+ if (typeof validated.resolved.anthropicSpec.value === 'string')
621
+ result.graphorinAnthropicSpec = validated.resolved.anthropicSpec.value;
622
+ if (typeof validated.resolved.version.value === 'string')
623
+ result.graphorinVersion = validated.resolved.version.value;
624
+ return Object.freeze(result) as SkillMetadata;
625
+ }
626
+
627
+ function appendUntrustedHandoffDiagnostic(
628
+ metadata: SkillMetadata,
629
+ diagnostics: FrontmatterDiagnostic[],
630
+ conflictPolicy: FrontmatterValidatorPolicy = 'warn',
631
+ ): void {
632
+ // Untrusted skills that declared `filter: full` are an explicit
633
+ // attempt to exfiltrate the entire conversation through a sub-agent
634
+ // - Phase 08 / ADR-040 mandate that we WARN and ignore. The agent
635
+ // runtime in Phase 12 will refuse the filter regardless of this
636
+ // diagnostic.
637
+ if (metadata.graphorinTrustLevel === 'untrusted') {
638
+ if (metadata.graphorinHandoffInputFilter?.kind === 'full') {
639
+ diagnostics.push(
640
+ Object.freeze({
641
+ kind: 'untrusted-handoff-filter-required',
642
+ field: 'graphorin-handoff-input-filter',
643
+ severity: 'warn',
644
+ message: `Untrusted skill '${metadata.name}' declared 'graphorin-handoff-input-filter: full'. The full-message filter is rejected for untrusted skills; the runtime will fall back to 'lastUser'.`,
645
+ hint: "Replace 'full' with 'lastUser' (or another bounded filter) to silence this diagnostic.",
646
+ }),
647
+ );
648
+ } else if (metadata.graphorinHandoffInputFilter === undefined) {
649
+ diagnostics.push(
650
+ Object.freeze({
651
+ kind: 'untrusted-handoff-filter-required',
652
+ field: 'graphorin-handoff-input-filter',
653
+ severity: conflictPolicy === 'error' ? 'error' : 'warn',
654
+ message: `Untrusted skill '${metadata.name}' did not declare 'graphorin-handoff-input-filter'. The runtime will throw if the skill invokes Agent.toTool()/handoff().`,
655
+ hint: "Add 'graphorin-handoff-input-filter: lastUser' to the SKILL.md frontmatter.",
656
+ }),
657
+ );
658
+ }
659
+ }
660
+ }
661
+
662
+ interface BuildSkillArgs {
663
+ readonly metadata: SkillMetadata;
664
+ readonly diagnostics: ReadonlyArray<FrontmatterDiagnostic>;
665
+ readonly body: string;
666
+ readonly source: SkillSource;
667
+ readonly trustPolicy: ResolvedSkillTrustPolicy;
668
+ readonly basePath?: string;
669
+ readonly signature?: SkillSignatureVerificationResult;
670
+ readonly tools?: ReadonlyArray<InlineSkillTool>;
671
+ readonly bodyLoader: () => Promise<string>;
672
+ readonly resourceLoader: (signal?: AbortSignal) => Promise<ReadonlyArray<SkillResource>>;
673
+ }
674
+
675
+ function buildSkill(args: BuildSkillArgs): Skill {
676
+ let bodyCache: string | null = null;
677
+ let resourcesCache: ReadonlyArray<SkillResource> | null = null;
678
+ let bodyPromise: Promise<string> | null = null;
679
+ let resourcesPromise: Promise<ReadonlyArray<SkillResource>> | null = null;
680
+ const toolDeclarations = parseToolsField(args.metadata.raw['graphorin-tools']) ?? [];
681
+ const tools = Object.freeze([...(args.tools ?? [])]);
682
+ return Object.freeze({
683
+ metadata: args.metadata,
684
+ source: args.source,
685
+ ...(args.basePath === undefined ? {} : { basePath: args.basePath }),
686
+ trustPolicy: args.trustPolicy,
687
+ ...(args.signature === undefined ? {} : { signature: args.signature }),
688
+ async body(_signal?: AbortSignal): Promise<string> {
689
+ if (bodyCache !== null) return bodyCache;
690
+ if (bodyPromise === null) {
691
+ bodyPromise = args.bodyLoader().then((value) => {
692
+ bodyCache = value;
693
+ return value;
694
+ });
695
+ }
696
+ return bodyPromise;
697
+ },
698
+ async resources(signal?: AbortSignal): Promise<ReadonlyArray<SkillResource>> {
699
+ if (resourcesCache !== null) return resourcesCache;
700
+ if (resourcesPromise === null) {
701
+ resourcesPromise = args.resourceLoader(signal).then((value) => {
702
+ resourcesCache = value;
703
+ return value;
704
+ });
705
+ }
706
+ return resourcesPromise;
707
+ },
708
+ tools(): ReadonlyArray<InlineSkillTool> {
709
+ return tools;
710
+ },
711
+ toolDeclarations(): ReadonlyArray<SkillToolDeclaration> {
712
+ return toolDeclarations;
713
+ },
714
+ diagnostics(): ReadonlyArray<FrontmatterDiagnostic> {
715
+ return args.diagnostics;
716
+ },
717
+ } satisfies Skill);
718
+ }
719
+
720
+ function describeSource(source: SkillSource): string {
721
+ switch (source.kind) {
722
+ case 'folder':
723
+ return `folder:${source.path}`;
724
+ case 'npm-package':
725
+ return source.version === undefined
726
+ ? `npm:${source.packageName}`
727
+ : `npm:${source.packageName}@${source.version}`;
728
+ case 'git-repo':
729
+ return source.ref === undefined ? `git:${source.url}` : `git:${source.url}@${source.ref}`;
730
+ case 'inline':
731
+ return 'inline';
732
+ default: {
733
+ const exhaustive: never = source;
734
+ void exhaustive;
735
+ return 'unknown';
736
+ }
737
+ }
738
+ }
739
+
740
+ function extractTrustLevel(source: SkillSource): SkillsTrustLevel | undefined {
741
+ if (source.kind === 'npm-package' || source.kind === 'git-repo' || source.kind === 'folder')
742
+ return source.trustLevel;
743
+ return undefined;
744
+ }
745
+
746
+ /**
747
+ * Cap a folder skill's self-declared trust level. A directory on disk -
748
+ * possibly downloaded from the internet - cannot self-promote to a trusted
749
+ * tier without an operator override (RP-9): `trusted` /
750
+ * `trusted-with-scripts` collapse to `'unknown'` (sandbox forced, signature
751
+ * optional, outputs taint-marked), while `untrusted` / `unknown` pass
752
+ * through unchanged.
753
+ */
754
+ function capSelfDeclaredTrust(level: SkillsTrustLevel): SkillsTrustLevel {
755
+ return level === 'trusted' || level === 'trusted-with-scripts' ? 'unknown' : level;
756
+ }
757
+
758
+ /**
759
+ * Translate the loader's four-valued trust level into the three-
760
+ * valued trust level the supply-chain installer expects.
761
+ *
762
+ * The 'unknown' value collapses to 'untrusted' so the supply-chain
763
+ * resolver picks the strict signature + `--ignore-scripts` policy.
764
+ * The loader keeps the original 'unknown' on the skill's metadata
765
+ * record so the runtime sandbox tier resolver continues to apply
766
+ * the correct policy.
767
+ */
768
+ function coerceForSupplyChain(
769
+ level: SkillsTrustLevel,
770
+ ): 'trusted' | 'trusted-with-scripts' | 'untrusted' {
771
+ if (level === 'unknown') return 'untrusted';
772
+ return level;
773
+ }
774
+
775
+ function guessMediaType(path: string): string | undefined {
776
+ const ext = extname(path).toLowerCase();
777
+ return DEFAULT_MEDIA_TYPES[ext];
778
+ }
779
+
780
+ /**
781
+ * Required handoff-filter declaration helper. Returns the typed
782
+ * declaration the loader parsed from frontmatter; throws
783
+ * {@link InputFilterRequiredError} when the skill is untrusted and the
784
+ * field is missing. Used by the agent runtime in Phase 12 right
785
+ * before instantiating an untrusted skill's sub-agent.
786
+ *
787
+ * @stable
788
+ */
789
+ export function requireHandoffInputFilter(metadata: SkillMetadata): HandoffInputFilterDeclaration {
790
+ if (metadata.graphorinHandoffInputFilter !== undefined) {
791
+ if (
792
+ metadata.graphorinTrustLevel === 'untrusted' &&
793
+ metadata.graphorinHandoffInputFilter.kind === 'full'
794
+ ) {
795
+ // ADR-040: `full` is rejected for untrusted skills regardless
796
+ // of declaration. Fall back to the bounded default.
797
+ return Object.freeze({ kind: 'lastUser' });
798
+ }
799
+ return metadata.graphorinHandoffInputFilter;
800
+ }
801
+ if (metadata.graphorinTrustLevel === 'untrusted') {
802
+ throw new InputFilterRequiredError(metadata.name);
803
+ }
804
+ // `'unknown'` skills require an explicit declaration too - default-
805
+ // deny posture per Phase 08. Trusted skills inherit the framework's
806
+ // bounded `lastN(10)` default.
807
+ if (metadata.graphorinTrustLevel === 'unknown') {
808
+ throw new InputFilterRequiredError(metadata.name);
809
+ }
810
+ return Object.freeze({ kind: 'lastN', n: 10 });
811
+ }
812
+
813
+ type Mutable<T> = { -readonly [K in keyof T]: T[K] };