@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.
- package/CHANGELOG.md +20 -0
- package/README.md +3 -3
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/package.json +14 -13
- package/src/activation/index.ts +76 -0
- package/src/errors/index.ts +162 -0
- package/src/frontmatter/index.ts +646 -0
- package/src/index.ts +49 -0
- package/src/loader/index.ts +813 -0
- package/src/migration/index.ts +192 -0
- package/src/registry/bridge.ts +169 -0
- package/src/registry/index.ts +414 -0
- package/src/spec/index.ts +158 -0
- package/src/types/index.ts +338 -0
|
@@ -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] };
|