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.6",
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
- // Pi conventionally discovers this companion as an Agent Profile. Its invocation and
265
- // external-dependency declarations are active behavior, not an opaque resource.
266
- try {
267
- if (lstatSync(join(skillDirectory, 'agents', 'openai.yaml')).isFile()) {
268
- findings.push(finding(opts, CODE.UNSUPPORTED_ACTIVE_COMPONENT, RULE.UNSUPPORTED_ACTIVE_COMPONENT, 'skill', `skills/${entry.name}/agents/openai.yaml`, 'Compatibility Profile v1 does not support Skill Agent Profiles'));
269
- }
270
- } catch {
271
- // Optional companion is absent; the descriptor remains the only accepted declaration.
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 disabled = descriptor.frontmatter?.['disable-model-invocation'] === true;
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: disabled ? 'explicit' : 'implicit',
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:v1';
11
- export const VALIDATION_BUDGET = 'budget:v1';
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
- contentHash = createHash('sha256').update(readFileSync(abs)).digest('hex');
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: st.size, contentHash });
229
+ entries.push({ relPath: rel, type: 'file', mode: lst.mode, size: observedSize, contentHash });
219
230
  }
220
231
  };
221
232