pi-codex-marketplace 0.1.6 → 0.1.7
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-codex-marketplace",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Bridge Package for Codex Marketplace compatibility in Pi — Global/Project Bridge State, atomic persistence, and /codex-marketplace TUI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
"optional": false
|
|
35
35
|
}
|
|
36
36
|
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"yaml": "2.9.0"
|
|
39
|
+
},
|
|
37
40
|
"overrides": {},
|
|
38
41
|
"devDependencies": {
|
|
39
42
|
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
import { createHash } from 'node:crypto';
|
|
10
10
|
import { lstatSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
11
11
|
import { join, relative, sep } from 'node:path';
|
|
12
|
+
import { TextDecoder } from 'node:util';
|
|
12
13
|
|
|
13
14
|
import { parseFrontmatter } from '@earendil-works/pi-coding-agent';
|
|
15
|
+
import { CST, Lexer, isCollection, parseDocument, visit } from 'yaml';
|
|
14
16
|
|
|
15
17
|
import type { Scope } from '../bridge-state/types.js';
|
|
18
|
+
import { readBoundedFileSync } from '../registration/bounded-read.js';
|
|
16
19
|
import { BUDGET } from '../registration/budget.js';
|
|
17
20
|
import { CODE, RULE, blocking, sortFindings, warning, type ValidationFinding } from '../registration/findings.js';
|
|
18
21
|
|
|
@@ -39,7 +42,7 @@ export interface ClassificationResult {
|
|
|
39
42
|
plugin?: CompatiblePlugin;
|
|
40
43
|
/** Valid manifest identity even when the complete Plugin is Invalid/Incompatible. */
|
|
41
44
|
identity?: string;
|
|
42
|
-
/** Hash of the exact manifest, descriptors, and resources used to derive this result. */
|
|
45
|
+
/** Hash of the exact manifest, descriptors, Agent Profiles, and resources used to derive this result. */
|
|
43
46
|
captureFingerprint: string;
|
|
44
47
|
findings: ValidationFinding[];
|
|
45
48
|
}
|
|
@@ -53,6 +56,14 @@ export interface ClassificationOptions {
|
|
|
53
56
|
const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
54
57
|
const UNSUPPORTED_COMPONENTS = new Set(['apps', 'commands', 'hooks', 'mcp', 'mcpServers', 'servers', 'extensions']);
|
|
55
58
|
const INERT_MANIFEST_FIELDS = new Set(['version', 'description', 'author', 'homepage', 'repository', 'license', 'keywords', 'interface']);
|
|
59
|
+
const AGENT_INTERFACE_STRING_FIELDS = new Set([
|
|
60
|
+
'brand_color',
|
|
61
|
+
'default_prompt',
|
|
62
|
+
'display_name',
|
|
63
|
+
'icon_large',
|
|
64
|
+
'icon_small',
|
|
65
|
+
'short_description',
|
|
66
|
+
]);
|
|
56
67
|
|
|
57
68
|
function finding(
|
|
58
69
|
opts: ClassificationOptions,
|
|
@@ -77,6 +88,10 @@ function parseDescriptor(text: string): { frontmatter?: Record<string, unknown>;
|
|
|
77
88
|
}
|
|
78
89
|
}
|
|
79
90
|
|
|
91
|
+
function isMapping(value: unknown): value is Record<string, unknown> {
|
|
92
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
80
95
|
/** Read the authoritative manifest identity without projecting a Compatible Plugin. */
|
|
81
96
|
export function pluginIdentity(root: string, marketplaceId: string): string | undefined {
|
|
82
97
|
try {
|
|
@@ -103,6 +118,320 @@ class MaterialCapture {
|
|
|
103
118
|
}
|
|
104
119
|
}
|
|
105
120
|
|
|
121
|
+
interface AgentProfileResult {
|
|
122
|
+
invocationPolicy?: InvocationPolicy;
|
|
123
|
+
findings: ValidationFinding[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function agentProfileBudgetExceeded(
|
|
127
|
+
opts: ClassificationOptions,
|
|
128
|
+
pointer: string,
|
|
129
|
+
outcome: string,
|
|
130
|
+
): AgentProfileResult {
|
|
131
|
+
return {
|
|
132
|
+
findings: [finding(
|
|
133
|
+
opts,
|
|
134
|
+
CODE.BUDGET_EXCEEDED,
|
|
135
|
+
RULE.BUDGET_EXCEEDED,
|
|
136
|
+
'plugin',
|
|
137
|
+
pointer,
|
|
138
|
+
outcome,
|
|
139
|
+
)],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function invalidAgentProfile(opts: ClassificationOptions, pointer: string): AgentProfileResult {
|
|
144
|
+
return {
|
|
145
|
+
findings: [finding(
|
|
146
|
+
opts,
|
|
147
|
+
CODE.SKILL_AGENT_PROFILE_INVALID,
|
|
148
|
+
RULE.SKILL_AGENT_PROFILE_INVALID,
|
|
149
|
+
'skill',
|
|
150
|
+
pointer,
|
|
151
|
+
'Skill Agent Profile must be valid YAML that parses to a mapping',
|
|
152
|
+
)],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function agentProfileYamlComplexityViolation(text: string): string | undefined {
|
|
157
|
+
let tokens = 0;
|
|
158
|
+
let flowDepth = 0;
|
|
159
|
+
let inlineBlockDepth = 0;
|
|
160
|
+
let atLineStart = true;
|
|
161
|
+
let indentation = 0;
|
|
162
|
+
const indentationStack: number[] = [];
|
|
163
|
+
|
|
164
|
+
for (const lexeme of new Lexer().lex(text)) {
|
|
165
|
+
tokens += 1;
|
|
166
|
+
if (tokens > BUDGET.maxAgentProfileYamlTokens) {
|
|
167
|
+
return `YAML token count exceeds ${BUDGET.maxAgentProfileYamlTokens}`;
|
|
168
|
+
}
|
|
169
|
+
const type = CST.tokenType(lexeme);
|
|
170
|
+
if (type === 'newline') {
|
|
171
|
+
atLineStart = true;
|
|
172
|
+
indentation = 0;
|
|
173
|
+
inlineBlockDepth = 0;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (atLineStart && type === 'space' && lexeme.startsWith(' ')) {
|
|
177
|
+
indentation += lexeme.length;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (atLineStart && type === 'comment') continue;
|
|
181
|
+
if (atLineStart) {
|
|
182
|
+
while (
|
|
183
|
+
indentationStack.length > 0
|
|
184
|
+
&& indentationStack[indentationStack.length - 1]! >= indentation
|
|
185
|
+
) {
|
|
186
|
+
indentationStack.pop();
|
|
187
|
+
}
|
|
188
|
+
indentationStack.push(indentation);
|
|
189
|
+
if (indentationStack.length > BUDGET.maxAgentProfileYamlDepth) {
|
|
190
|
+
return `YAML block depth exceeds ${BUDGET.maxAgentProfileYamlDepth}`;
|
|
191
|
+
}
|
|
192
|
+
atLineStart = false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (type === 'flow-map-start' || type === 'flow-seq-start') {
|
|
196
|
+
flowDepth += 1;
|
|
197
|
+
if (flowDepth > BUDGET.maxAgentProfileYamlDepth) {
|
|
198
|
+
return `YAML flow depth exceeds ${BUDGET.maxAgentProfileYamlDepth}`;
|
|
199
|
+
}
|
|
200
|
+
} else if (type === 'flow-map-end' || type === 'flow-seq-end') {
|
|
201
|
+
flowDepth = Math.max(0, flowDepth - 1);
|
|
202
|
+
} else if (
|
|
203
|
+
flowDepth === 0
|
|
204
|
+
&& (type === 'seq-item-ind' || type === 'explicit-key-ind' || type === 'map-value-ind')
|
|
205
|
+
) {
|
|
206
|
+
inlineBlockDepth += 1;
|
|
207
|
+
if (inlineBlockDepth > BUDGET.maxAgentProfileYamlDepth) {
|
|
208
|
+
return `YAML inline block depth exceeds ${BUDGET.maxAgentProfileYamlDepth}`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function validateAgentProfile(text: string, pointer: string, opts: ClassificationOptions): AgentProfileResult {
|
|
216
|
+
const findings: ValidationFinding[] = [];
|
|
217
|
+
let document: ReturnType<typeof parseDocument>;
|
|
218
|
+
try {
|
|
219
|
+
const violation = agentProfileYamlComplexityViolation(text);
|
|
220
|
+
if (violation) {
|
|
221
|
+
return agentProfileBudgetExceeded(
|
|
222
|
+
opts,
|
|
223
|
+
pointer,
|
|
224
|
+
`Skill Agent Profile exceeds Validation Budget: ${violation}`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
document = parseDocument(text, { logLevel: 'silent', prettyErrors: false });
|
|
228
|
+
if (document.errors.length > 0) throw document.errors[0];
|
|
229
|
+
} catch {
|
|
230
|
+
return invalidAgentProfile(opts, pointer);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let nodeCount = 0;
|
|
234
|
+
let astViolation: string | undefined;
|
|
235
|
+
visit(document, (_key, node, path) => {
|
|
236
|
+
nodeCount += 1;
|
|
237
|
+
if (nodeCount > BUDGET.maxAgentProfileYamlNodes) {
|
|
238
|
+
astViolation = `YAML node count exceeds ${BUDGET.maxAgentProfileYamlNodes}`;
|
|
239
|
+
return visit.BREAK;
|
|
240
|
+
}
|
|
241
|
+
const collectionDepth = path.reduce(
|
|
242
|
+
(depth, ancestor) => depth + (isCollection(ancestor) ? 1 : 0),
|
|
243
|
+
isCollection(node) ? 1 : 0,
|
|
244
|
+
);
|
|
245
|
+
if (collectionDepth > BUDGET.maxAgentProfileYamlDepth) {
|
|
246
|
+
astViolation = `YAML AST depth exceeds ${BUDGET.maxAgentProfileYamlDepth}`;
|
|
247
|
+
return visit.BREAK;
|
|
248
|
+
}
|
|
249
|
+
return undefined;
|
|
250
|
+
});
|
|
251
|
+
if (astViolation) {
|
|
252
|
+
return agentProfileBudgetExceeded(
|
|
253
|
+
opts,
|
|
254
|
+
pointer,
|
|
255
|
+
`Skill Agent Profile exceeds Validation Budget: ${astViolation}`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let parsed: unknown;
|
|
260
|
+
try {
|
|
261
|
+
parsed = document.toJS({ maxAliasCount: BUDGET.maxAgentProfileYamlAliases });
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (
|
|
264
|
+
error instanceof ReferenceError
|
|
265
|
+
&& error.message === 'Excessive alias count indicates a resource exhaustion attack'
|
|
266
|
+
) {
|
|
267
|
+
return agentProfileBudgetExceeded(
|
|
268
|
+
opts,
|
|
269
|
+
pointer,
|
|
270
|
+
`Skill Agent Profile exceeds Validation Budget: YAML alias expansion exceeds ${BUDGET.maxAgentProfileYamlAliases}`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return invalidAgentProfile(opts, pointer);
|
|
274
|
+
}
|
|
275
|
+
if (!isMapping(parsed)) {
|
|
276
|
+
return invalidAgentProfile(opts, pointer);
|
|
277
|
+
}
|
|
278
|
+
const profile = parsed;
|
|
279
|
+
|
|
280
|
+
for (const key of Object.keys(profile).sort((a, b) => a.localeCompare(b))) {
|
|
281
|
+
if (key === 'interface' || key === 'policy') continue;
|
|
282
|
+
findings.push(finding(
|
|
283
|
+
opts,
|
|
284
|
+
CODE.UNSUPPORTED_ACTIVE_COMPONENT,
|
|
285
|
+
RULE.UNSUPPORTED_ACTIVE_COMPONENT,
|
|
286
|
+
'skill',
|
|
287
|
+
`${pointer}#/${key}`,
|
|
288
|
+
key === 'dependencies'
|
|
289
|
+
? 'Compatibility Profile v1 does not support Skill Agent Profile dependencies'
|
|
290
|
+
: `Unknown Skill Agent Profile field '${key}' may declare active behaviour and is fail-closed`,
|
|
291
|
+
));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const interfaceMetadata = profile.interface;
|
|
295
|
+
if (isMapping(interfaceMetadata)) {
|
|
296
|
+
for (const [key, value] of Object.entries(interfaceMetadata).sort(([a], [b]) => a.localeCompare(b))) {
|
|
297
|
+
if (AGENT_INTERFACE_STRING_FIELDS.has(key) && typeof value === 'string' && value.trim()) continue;
|
|
298
|
+
findings.push(warning({
|
|
299
|
+
code: CODE.INERT_METADATA_IGNORED,
|
|
300
|
+
rule: 'COMP-W01',
|
|
301
|
+
target: 'skill',
|
|
302
|
+
pointer: `${pointer}#/interface/${key}`,
|
|
303
|
+
outcome: `Ignored malformed or unknown Skill Agent Profile presentation member '${key}'`,
|
|
304
|
+
scope: opts.scope,
|
|
305
|
+
phase: 'validation',
|
|
306
|
+
}));
|
|
307
|
+
}
|
|
308
|
+
} else if (Object.hasOwn(profile, 'interface')) {
|
|
309
|
+
findings.push(warning({
|
|
310
|
+
code: CODE.INERT_METADATA_IGNORED,
|
|
311
|
+
rule: 'COMP-W01',
|
|
312
|
+
target: 'skill',
|
|
313
|
+
pointer: `${pointer}#/interface`,
|
|
314
|
+
outcome: 'Ignored malformed Skill Agent Profile interface metadata',
|
|
315
|
+
scope: opts.scope,
|
|
316
|
+
phase: 'validation',
|
|
317
|
+
}));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const policy = profile.policy;
|
|
321
|
+
if (!isMapping(policy)) {
|
|
322
|
+
if (Object.hasOwn(profile, 'policy')) {
|
|
323
|
+
findings.push(finding(
|
|
324
|
+
opts,
|
|
325
|
+
CODE.SKILL_AGENT_PROFILE_INVALID,
|
|
326
|
+
RULE.SKILL_AGENT_PROFILE_INVALID,
|
|
327
|
+
'skill',
|
|
328
|
+
`${pointer}#/policy`,
|
|
329
|
+
'Skill Agent Profile policy must be a mapping when declared',
|
|
330
|
+
));
|
|
331
|
+
}
|
|
332
|
+
return { findings };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
for (const key of Object.keys(policy).sort((a, b) => a.localeCompare(b))) {
|
|
336
|
+
if (key === 'allow_implicit_invocation') continue;
|
|
337
|
+
findings.push(finding(
|
|
338
|
+
opts,
|
|
339
|
+
CODE.UNSUPPORTED_ACTIVE_COMPONENT,
|
|
340
|
+
RULE.UNSUPPORTED_ACTIVE_COMPONENT,
|
|
341
|
+
'skill',
|
|
342
|
+
`${pointer}#/policy/${key}`,
|
|
343
|
+
`Unknown Skill Agent Profile policy '${key}' may declare active behaviour and is fail-closed`,
|
|
344
|
+
));
|
|
345
|
+
}
|
|
346
|
+
const allowImplicit = policy.allow_implicit_invocation;
|
|
347
|
+
if (typeof allowImplicit === 'boolean') {
|
|
348
|
+
return { invocationPolicy: allowImplicit ? 'implicit' : 'explicit', findings };
|
|
349
|
+
}
|
|
350
|
+
if (Object.hasOwn(policy, 'allow_implicit_invocation')) {
|
|
351
|
+
findings.push(finding(
|
|
352
|
+
opts,
|
|
353
|
+
CODE.SKILL_AGENT_PROFILE_INVALID,
|
|
354
|
+
RULE.SKILL_AGENT_PROFILE_INVALID,
|
|
355
|
+
'skill',
|
|
356
|
+
`${pointer}#/policy/allow_implicit_invocation`,
|
|
357
|
+
'allow_implicit_invocation must be a boolean when declared',
|
|
358
|
+
));
|
|
359
|
+
}
|
|
360
|
+
return { findings };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function loadAgentProfile(
|
|
364
|
+
pluginRoot: string,
|
|
365
|
+
skillDirectory: string,
|
|
366
|
+
skillName: string,
|
|
367
|
+
opts: ClassificationOptions,
|
|
368
|
+
capture: MaterialCapture,
|
|
369
|
+
): AgentProfileResult {
|
|
370
|
+
const pointer = `skills/${skillName}/agents/openai.yaml`;
|
|
371
|
+
const profilePath = join(skillDirectory, 'agents', 'openai.yaml');
|
|
372
|
+
try {
|
|
373
|
+
lstatSync(profilePath);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { findings: [] };
|
|
376
|
+
capture.add(`agent-profile-error:${skillName}`, pointer);
|
|
377
|
+
return {
|
|
378
|
+
findings: [finding(
|
|
379
|
+
opts,
|
|
380
|
+
CODE.SKILL_AGENT_PROFILE_INVALID,
|
|
381
|
+
RULE.SKILL_AGENT_PROFILE_INVALID,
|
|
382
|
+
'skill',
|
|
383
|
+
pointer,
|
|
384
|
+
'Skill Agent Profile cannot be inspected safely',
|
|
385
|
+
)],
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
const canonicalPluginRoot = realpathSync.native(pluginRoot);
|
|
391
|
+
const canonicalSkillDirectory = realpathSync.native(skillDirectory);
|
|
392
|
+
const canonicalProfilePath = realpathSync.native(profilePath);
|
|
393
|
+
if (
|
|
394
|
+
!isWithin(canonicalSkillDirectory, canonicalProfilePath)
|
|
395
|
+
|| isSnapshotExcluded(canonicalPluginRoot, canonicalProfilePath)
|
|
396
|
+
) {
|
|
397
|
+
throw new TypeError('not a snapshot-covered regular file owned by the Skill');
|
|
398
|
+
}
|
|
399
|
+
const read = readBoundedFileSync(canonicalProfilePath, BUDGET.maxAgentProfileBytes);
|
|
400
|
+
if (!read.ok) {
|
|
401
|
+
capture.add(`agent-profile-budget:${skillName}`, `${pointer}:${read.observedBytes}`);
|
|
402
|
+
return agentProfileBudgetExceeded(
|
|
403
|
+
opts,
|
|
404
|
+
pointer,
|
|
405
|
+
`Skill Agent Profile exceeds Validation Budget: ${read.observedBytes} bytes > ${BUDGET.maxAgentProfileBytes}`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
capture.add(`agent-profile:${skillName}`, read.bytes);
|
|
409
|
+
let text: string;
|
|
410
|
+
try {
|
|
411
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(read.bytes);
|
|
412
|
+
} catch {
|
|
413
|
+
return invalidAgentProfile(opts, pointer);
|
|
414
|
+
}
|
|
415
|
+
return validateAgentProfile(text, pointer, opts);
|
|
416
|
+
} catch {
|
|
417
|
+
capture.add(`agent-profile-error:${skillName}`, pointer);
|
|
418
|
+
return {
|
|
419
|
+
findings: [finding(
|
|
420
|
+
opts,
|
|
421
|
+
CODE.SKILL_AGENT_PROFILE_INVALID,
|
|
422
|
+
RULE.SKILL_AGENT_PROFILE_INVALID,
|
|
423
|
+
'skill',
|
|
424
|
+
pointer,
|
|
425
|
+
'Skill Agent Profile must resolve within its owning Skill to a readable regular file covered by the Validation Snapshot',
|
|
426
|
+
)],
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function descriptorInvocationPolicy(value: unknown): InvocationPolicy | undefined {
|
|
432
|
+
return typeof value === 'boolean' ? (value ? 'explicit' : 'implicit') : undefined;
|
|
433
|
+
}
|
|
434
|
+
|
|
106
435
|
function isWithin(root: string, target: string): boolean {
|
|
107
436
|
return target === root || target.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
108
437
|
}
|
|
@@ -128,6 +457,7 @@ function resourcesIn(root: string, skillDirectory: string, capture: MaterialCapt
|
|
|
128
457
|
const path = join(directory, entry.name);
|
|
129
458
|
if (entry.isDirectory()) walk(path, next, depth + 1);
|
|
130
459
|
else {
|
|
460
|
+
if (next === 'agents/openai.yaml') continue;
|
|
131
461
|
const stat = lstatSync(path);
|
|
132
462
|
let chargeSize = stat.size;
|
|
133
463
|
if (stat.isSymbolicLink()) {
|
|
@@ -261,16 +591,20 @@ export function classifyPlugin(root: string, opts: ClassificationOptions): Class
|
|
|
261
591
|
if (descriptor.frontmatter?.['disable-model-invocation'] !== undefined && typeof descriptor.frontmatter['disable-model-invocation'] !== 'boolean') {
|
|
262
592
|
findings.push(finding(opts, CODE.SKILL_DESCRIPTOR_INVALID, RULE.SKILL_DESCRIPTOR_INVALID, 'skill', `skills/${entry.name}/SKILL.md#/disable-model-invocation`, 'disable-model-invocation must be a boolean when declared'));
|
|
263
593
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
594
|
+
const agentProfile = loadAgentProfile(root, skillDirectory, entry.name, opts, capture);
|
|
595
|
+
findings.push(...agentProfile.findings);
|
|
596
|
+
const descriptorPolicy = descriptorInvocationPolicy(descriptor.frontmatter?.['disable-model-invocation']);
|
|
597
|
+
if (descriptorPolicy && agentProfile.invocationPolicy && descriptorPolicy !== agentProfile.invocationPolicy) {
|
|
598
|
+
findings.push(finding(
|
|
599
|
+
opts,
|
|
600
|
+
CODE.SKILL_AGENT_PROFILE_INVALID,
|
|
601
|
+
RULE.SKILL_AGENT_PROFILE_INVALID,
|
|
602
|
+
'skill',
|
|
603
|
+
`skills/${entry.name}/agents/openai.yaml#/policy/allow_implicit_invocation`,
|
|
604
|
+
'Skill Descriptor and Skill Agent Profile declare contradictory Invocation Policies',
|
|
605
|
+
));
|
|
272
606
|
}
|
|
273
|
-
const
|
|
607
|
+
const invocationPolicy = descriptorPolicy ?? agentProfile.invocationPolicy ?? 'implicit';
|
|
274
608
|
const pluginId = manifest && typeof manifest.name === 'string' ? `${opts.marketplaceId}/${manifest.name}` : '';
|
|
275
609
|
const resourceResult = resourcesIn(root, skillDirectory, capture);
|
|
276
610
|
if (resourceResult.error) {
|
|
@@ -282,7 +616,7 @@ export function classifyPlugin(root: string, opts: ClassificationOptions): Class
|
|
|
282
616
|
name,
|
|
283
617
|
path: skillDirectory,
|
|
284
618
|
resources: resourceResult.resources,
|
|
285
|
-
invocationPolicy
|
|
619
|
+
invocationPolicy,
|
|
286
620
|
});
|
|
287
621
|
}
|
|
288
622
|
}
|
|
@@ -305,7 +639,7 @@ export function classifyPlugin(root: string, opts: ClassificationOptions): Class
|
|
|
305
639
|
const identity = manifest && typeof manifest.name === 'string' && KEBAB.test(manifest.name)
|
|
306
640
|
? `${opts.marketplaceId}/${manifest.name}`
|
|
307
641
|
: undefined;
|
|
308
|
-
if (sorted.some((item) => item.code === CODE.PLUGIN_MANIFEST_INVALID || item.code === CODE.SKILL_DESCRIPTOR_INVALID)) {
|
|
642
|
+
if (sorted.some((item) => item.code === CODE.PLUGIN_MANIFEST_INVALID || item.code === CODE.SKILL_DESCRIPTOR_INVALID || item.code === CODE.SKILL_AGENT_PROFILE_INVALID)) {
|
|
309
643
|
return { classification: 'invalid', identity, captureFingerprint: capture.fingerprint(), findings: sorted };
|
|
310
644
|
}
|
|
311
645
|
if (sorted.some((item) => item.code === CODE.UNSUPPORTED_ACTIVE_COMPONENT && item.classification === 'blocking')) {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { closeSync, constants, fstatSync, openSync, readSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
export type BoundedReadResult =
|
|
5
|
+
| { ok: true; bytes: Buffer }
|
|
6
|
+
| { ok: false; observedBytes: number };
|
|
7
|
+
|
|
8
|
+
export type BoundedHashResult =
|
|
9
|
+
| { ok: true; bytesRead: number; contentHash: string }
|
|
10
|
+
| { ok: false; observedBytes: number };
|
|
11
|
+
|
|
12
|
+
function regularFileOpenFlags(): number {
|
|
13
|
+
const noFollow: unknown = constants.O_NOFOLLOW;
|
|
14
|
+
const nonBlock: unknown = constants.O_NONBLOCK;
|
|
15
|
+
if (
|
|
16
|
+
typeof noFollow !== 'number'
|
|
17
|
+
|| noFollow === 0
|
|
18
|
+
|| typeof nonBlock !== 'number'
|
|
19
|
+
|| nonBlock === 0
|
|
20
|
+
) {
|
|
21
|
+
throw new Error('secure non-blocking regular-file open is unavailable on this platform');
|
|
22
|
+
}
|
|
23
|
+
return constants.O_RDONLY | noFollow | nonBlock;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Read at most `maxBytes + 1` bytes from one regular file without a stat/read allocation race. */
|
|
27
|
+
export function readBoundedFileSync(path: string, maxBytes: number): BoundedReadResult {
|
|
28
|
+
const descriptor = openSync(path, regularFileOpenFlags());
|
|
29
|
+
try {
|
|
30
|
+
const stat = fstatSync(descriptor);
|
|
31
|
+
if (!stat.isFile()) throw new TypeError('not a regular file');
|
|
32
|
+
if (stat.size > maxBytes) return { ok: false, observedBytes: stat.size };
|
|
33
|
+
|
|
34
|
+
const buffer = Buffer.allocUnsafe(maxBytes + 1);
|
|
35
|
+
let bytesRead = 0;
|
|
36
|
+
while (bytesRead < buffer.length) {
|
|
37
|
+
const count = readSync(descriptor, buffer, bytesRead, buffer.length - bytesRead, null);
|
|
38
|
+
if (count === 0) break;
|
|
39
|
+
bytesRead += count;
|
|
40
|
+
}
|
|
41
|
+
if (bytesRead > maxBytes) return { ok: false, observedBytes: bytesRead };
|
|
42
|
+
return { ok: true, bytes: buffer.subarray(0, bytesRead) };
|
|
43
|
+
} finally {
|
|
44
|
+
closeSync(descriptor);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Hash a regular file incrementally while reading at most `maxBytes + 1` bytes. */
|
|
49
|
+
export function hashBoundedFileSync(path: string, maxBytes: number): BoundedHashResult {
|
|
50
|
+
const descriptor = openSync(path, regularFileOpenFlags());
|
|
51
|
+
try {
|
|
52
|
+
const stat = fstatSync(descriptor);
|
|
53
|
+
if (!stat.isFile()) throw new TypeError('not a regular file');
|
|
54
|
+
if (stat.size > maxBytes) return { ok: false, observedBytes: stat.size };
|
|
55
|
+
|
|
56
|
+
const hash = createHash('sha256');
|
|
57
|
+
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1));
|
|
58
|
+
let bytesRead = 0;
|
|
59
|
+
while (bytesRead <= maxBytes) {
|
|
60
|
+
const remaining = maxBytes + 1 - bytesRead;
|
|
61
|
+
const count = readSync(descriptor, buffer, 0, Math.min(buffer.length, remaining), null);
|
|
62
|
+
if (count === 0) {
|
|
63
|
+
return { ok: true, bytesRead, contentHash: hash.digest('hex') };
|
|
64
|
+
}
|
|
65
|
+
bytesRead += count;
|
|
66
|
+
if (bytesRead > maxBytes) return { ok: false, observedBytes: bytesRead };
|
|
67
|
+
hash.update(buffer.subarray(0, count));
|
|
68
|
+
}
|
|
69
|
+
return { ok: false, observedBytes: bytesRead };
|
|
70
|
+
} finally {
|
|
71
|
+
closeSync(descriptor);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* never partial or best-effort validation.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
export const VALIDATION_RULESET = 'ruleset:
|
|
11
|
-
export const VALIDATION_BUDGET = 'budget:
|
|
10
|
+
export const VALIDATION_RULESET = 'ruleset:v2';
|
|
11
|
+
export const VALIDATION_BUDGET = 'budget:v2';
|
|
12
12
|
/** Compatibility Profile reference bound into every snapshot (full profile contract is #19). */
|
|
13
13
|
export const COMPATIBILITY_PROFILE = 'profile:v1';
|
|
14
14
|
|
|
@@ -21,8 +21,18 @@ export const BUDGET = {
|
|
|
21
21
|
maxTotalBytes: 512 * 1024 * 1024,
|
|
22
22
|
/** Maximum Marketplace Catalog file bytes. */
|
|
23
23
|
maxCatalogBytes: 1 * 1024 * 1024,
|
|
24
|
+
/** Maximum bytes read and synchronously parsed from one Skill Agent Profile. */
|
|
25
|
+
maxAgentProfileBytes: 64 * 1024,
|
|
26
|
+
/** Maximum YAML lexer tokens accepted before composing a Skill Agent Profile. */
|
|
27
|
+
maxAgentProfileYamlTokens: 8_192,
|
|
28
|
+
/** Maximum YAML collection nesting accepted in a Skill Agent Profile. */
|
|
29
|
+
maxAgentProfileYamlDepth: 32,
|
|
30
|
+
/** Maximum composed YAML AST nodes accepted in a Skill Agent Profile. */
|
|
31
|
+
maxAgentProfileYamlNodes: 2_048,
|
|
32
|
+
/** Maximum alias expansion count while materializing a Skill Agent Profile. */
|
|
33
|
+
maxAgentProfileYamlAliases: 32,
|
|
24
34
|
/** Maximum plugins entries in a catalog. */
|
|
25
35
|
maxEntries: 1024,
|
|
26
36
|
/** Maximum declared marketplace name length. */
|
|
27
37
|
maxNameLength: 64,
|
|
28
|
-
} as const;
|
|
38
|
+
} as const;
|
|
@@ -77,6 +77,7 @@ export const RULE = {
|
|
|
77
77
|
SKILL_DESCRIPTOR_INVALID: 'COMP-02',
|
|
78
78
|
UNSUPPORTED_ACTIVE_COMPONENT: 'COMP-03',
|
|
79
79
|
PLUGIN_ID_COLLISION: 'COMP-04',
|
|
80
|
+
SKILL_AGENT_PROFILE_INVALID: 'COMP-05',
|
|
80
81
|
REGISTRATION_NOT_FOUND: 'REG-01',
|
|
81
82
|
UPDATE_PLAN_INCOMPLETE: 'UPD-01',
|
|
82
83
|
INSTALLATION_NOT_FOUND: 'INSTALL-01',
|
|
@@ -132,6 +133,7 @@ export const CODE = {
|
|
|
132
133
|
UNSUPPORTED_ACTIVE_COMPONENT: 'UNSUPPORTED_ACTIVE_COMPONENT',
|
|
133
134
|
INERT_METADATA_IGNORED: 'INERT_METADATA_IGNORED',
|
|
134
135
|
PLUGIN_ID_COLLISION: 'PLUGIN_ID_COLLISION',
|
|
136
|
+
SKILL_AGENT_PROFILE_INVALID: 'SKILL_AGENT_PROFILE_INVALID',
|
|
135
137
|
REGISTRATION_NOT_FOUND: 'REGISTRATION_NOT_FOUND',
|
|
136
138
|
UPDATE_PLAN_INCOMPLETE: 'UPDATE_PLAN_INCOMPLETE',
|
|
137
139
|
INSTALLATION_NOT_FOUND: 'INSTALLATION_NOT_FOUND',
|
|
@@ -15,13 +15,13 @@ import { createHash } from 'node:crypto';
|
|
|
15
15
|
import {
|
|
16
16
|
lstatSync,
|
|
17
17
|
readdirSync,
|
|
18
|
-
readFileSync,
|
|
19
18
|
readlinkSync,
|
|
20
19
|
realpathSync,
|
|
21
20
|
statSync,
|
|
22
21
|
} from 'node:fs';
|
|
23
22
|
import { join, sep } from 'node:path';
|
|
24
23
|
|
|
24
|
+
import { hashBoundedFileSync } from './bounded-read.js';
|
|
25
25
|
import { BUDGET, COMPATIBILITY_PROFILE, VALIDATION_BUDGET, VALIDATION_RULESET } from './budget.js';
|
|
26
26
|
import { CODE, RULE, blocking, type ValidationFinding } from './findings.js';
|
|
27
27
|
import type { SourceKey } from './source-key.js';
|
|
@@ -207,15 +207,26 @@ function walkTree(
|
|
|
207
207
|
return;
|
|
208
208
|
}
|
|
209
209
|
let contentHash = '';
|
|
210
|
+
let observedSize = st.size;
|
|
210
211
|
try {
|
|
211
|
-
|
|
212
|
+
const bytesBeforeFile = totalBytes - st.size;
|
|
213
|
+
const hashed = hashBoundedFileSync(abs, BUDGET.maxTotalBytes - bytesBeforeFile);
|
|
214
|
+
if (!hashed.ok) {
|
|
215
|
+
failBudget(
|
|
216
|
+
`Validation Budget exceeded: ${bytesBeforeFile + hashed.observedBytes} bytes > ${BUDGET.maxTotalBytes}`,
|
|
217
|
+
);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
totalBytes = bytesBeforeFile + hashed.bytesRead;
|
|
221
|
+
observedSize = hashed.bytesRead;
|
|
222
|
+
contentHash = hashed.contentHash;
|
|
212
223
|
} catch (e) {
|
|
213
224
|
const err = e as NodeJS.ErrnoException;
|
|
214
225
|
if (err.code === 'ENOENT') continue;
|
|
215
226
|
failBudget(`unable to hash content: ${err.message}`);
|
|
216
227
|
return;
|
|
217
228
|
}
|
|
218
|
-
entries.push({ relPath: rel, type: 'file', mode: lst.mode, size:
|
|
229
|
+
entries.push({ relPath: rel, type: 'file', mode: lst.mode, size: observedSize, contentHash });
|
|
219
230
|
}
|
|
220
231
|
};
|
|
221
232
|
|