@tryinget/pi-agent-registry 0.3.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,678 @@
1
+ // ---
2
+ // summary: fail-closed agent.json manifest loading and validation for the ai-society.agent/1 schema.
3
+ // read_when:
4
+ // - changing the manifest schema, validation rules, or path-containment policy.
5
+ // ---
6
+
7
+ import { constants, type Stats } from "node:fs";
8
+ import { lstat, open, readdir, readFile, realpath, stat } from "node:fs/promises";
9
+ import { homedir } from "node:os";
10
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
11
+
12
+ export const AGENT_MANIFEST_SCHEMA = "ai-society.agent/1";
13
+ export const AGENT_MANIFEST_FILENAME = "agent.json";
14
+
15
+ export type AgentThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
16
+
17
+ const MANIFEST_MAX_BYTES = 64 * 1024;
18
+ const SYSTEM_PROMPT_MAX_BYTES = 512 * 1024;
19
+ const AGENT_NAME_PATTERN = /^[a-z][a-z0-9-]*$/u;
20
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u;
21
+ export const AGENT_CREATION_TASK_PATTERN = /^AK-[1-9][0-9]*$/u;
22
+ const TOOL_NAME_PATTERN = /^[a-z0-9_]+$/u;
23
+ const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/u;
24
+ const EXTENSION_NAME_PATTERN = /^[A-Za-z0-9@.][A-Za-z0-9@/._-]*$/u;
25
+ const THINKING_LEVELS: ReadonlySet<string> = new Set([
26
+ "off",
27
+ "minimal",
28
+ "low",
29
+ "medium",
30
+ "high",
31
+ "xhigh",
32
+ "max",
33
+ ]);
34
+ const RESERVED_AGENT_NAMES: ReadonlySet<string> = new Set([
35
+ "custom",
36
+ "explorer",
37
+ "reviewer",
38
+ "tester",
39
+ "researcher",
40
+ "minimal",
41
+ ]);
42
+ export const AGENT_MANIFEST_TOP_LEVEL_KEYS: ReadonlySet<string> = new Set([
43
+ "schema",
44
+ "name",
45
+ "version",
46
+ "display_name",
47
+ "role",
48
+ "creation_task",
49
+ "system_prompt_file",
50
+ "skills",
51
+ "tools",
52
+ "extensions",
53
+ "defaults",
54
+ "scope",
55
+ "activities",
56
+ ]);
57
+
58
+ function decodeStrictUtf8(bytes: Buffer, label: string): string {
59
+ try {
60
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
61
+ } catch {
62
+ throw new Error(`${label} is not strict UTF-8`);
63
+ }
64
+ }
65
+
66
+ function hasUnpairedSurrogate(value: string): boolean {
67
+ for (let index = 0; index < value.length; index += 1) {
68
+ const code = value.charCodeAt(index);
69
+ if (code >= 0xd800 && code <= 0xdbff) {
70
+ const next = value.charCodeAt(index + 1);
71
+ if (!(next >= 0xdc00 && next <= 0xdfff)) return true;
72
+ index += 1;
73
+ } else if (code >= 0xdc00 && code <= 0xdfff) {
74
+ return true;
75
+ }
76
+ }
77
+ return false;
78
+ }
79
+
80
+ function containsUnpairedSurrogate(value: unknown): boolean {
81
+ if (typeof value === "string") return hasUnpairedSurrogate(value);
82
+ if (Array.isArray(value)) return value.some(containsUnpairedSurrogate);
83
+ if (typeof value !== "object" || value === null) return false;
84
+ return Object.entries(value as Record<string, unknown>).some(
85
+ ([key, entry]) => hasUnpairedSurrogate(key) || containsUnpairedSurrogate(entry),
86
+ );
87
+ }
88
+
89
+ export class AgentManifestError extends Error {
90
+ readonly manifestPath: string;
91
+
92
+ constructor(message: string, manifestPath: string) {
93
+ super(`${manifestPath}: ${message}`);
94
+ this.name = "AgentManifestError";
95
+ this.manifestPath = manifestPath;
96
+ }
97
+ }
98
+
99
+ export interface AgentManifestSkills {
100
+ profile?: string;
101
+ extra?: string[];
102
+ }
103
+
104
+ export interface AgentManifestDefaults {
105
+ model: string | null;
106
+ thinking: AgentThinkingLevel;
107
+ }
108
+
109
+ export interface AgentManifestScope {
110
+ repos?: string[];
111
+ forbidden?: string[];
112
+ /** Operator note rendered into the composed system prompt scope section. */
113
+ note?: string;
114
+ }
115
+
116
+ export interface AgentManifest {
117
+ schema: string;
118
+ name: string;
119
+ version?: string;
120
+ display_name?: string;
121
+ /** Canonical human-readable role-card name; required by v2 fleet lint. */
122
+ role?: string;
123
+ /** Exact AK creation-task provenance reference; required by v2 fleet lint. */
124
+ creation_task?: string;
125
+ system_prompt_file: string;
126
+ skills?: AgentManifestSkills;
127
+ tools: string[];
128
+ extensions: string[];
129
+ defaults: AgentManifestDefaults;
130
+ scope?: AgentManifestScope;
131
+ activities: string[];
132
+ /** Absolute path of the agent repo root containing this manifest. */
133
+ root: string;
134
+ /** Absolute path of the agent.json file. */
135
+ manifestPath: string;
136
+ }
137
+
138
+ export interface LoadAgentManifestOptions {
139
+ /**
140
+ * Known engineering-core skill profiles (from skills/profiles.json).
141
+ * Required to fail closed on unknown `skills.profile` at load time.
142
+ */
143
+ ecProfiles?: ReadonlyMap<string, readonly string[]>;
144
+ }
145
+
146
+ export async function loadAgentManifest(
147
+ agentRoot: string,
148
+ options?: LoadAgentManifestOptions,
149
+ ): Promise<AgentManifest> {
150
+ const root = resolve(agentRoot);
151
+ const manifestPath = resolve(root, AGENT_MANIFEST_FILENAME);
152
+ let rawText: string;
153
+ try {
154
+ const initialStat = await lstat(manifestPath);
155
+ if (!initialStat.isFile() || initialStat.isSymbolicLink()) {
156
+ throw new AgentManifestError("agent.json is not a non-symlink regular file", manifestPath);
157
+ }
158
+ if (initialStat.size > MANIFEST_MAX_BYTES) {
159
+ throw new AgentManifestError(`agent.json exceeds ${MANIFEST_MAX_BYTES} bytes`, manifestPath);
160
+ }
161
+ const handle = await open(manifestPath, constants.O_RDONLY | constants.O_NOFOLLOW);
162
+ try {
163
+ const openedStat = await handle.stat();
164
+ if (
165
+ !openedStat.isFile() ||
166
+ openedStat.dev !== initialStat.dev ||
167
+ openedStat.ino !== initialStat.ino ||
168
+ openedStat.size !== initialStat.size
169
+ ) {
170
+ throw new AgentManifestError("agent.json identity changed while opening", manifestPath);
171
+ }
172
+ const rawBytes = await handle.readFile();
173
+ rawText = decodeStrictUtf8(rawBytes, "agent.json");
174
+ const finalStat = await handle.stat();
175
+ if (
176
+ finalStat.dev !== openedStat.dev ||
177
+ finalStat.ino !== openedStat.ino ||
178
+ finalStat.size !== openedStat.size
179
+ ) {
180
+ throw new AgentManifestError("agent.json identity changed while reading", manifestPath);
181
+ }
182
+ } finally {
183
+ await handle.close();
184
+ }
185
+ } catch (error) {
186
+ if (error instanceof AgentManifestError) throw error;
187
+ throw new AgentManifestError(
188
+ `agent.json could not be read: ${error instanceof Error ? error.message : String(error)}`,
189
+ manifestPath,
190
+ );
191
+ }
192
+
193
+ let parsed: unknown;
194
+ try {
195
+ parsed = JSON.parse(rawText);
196
+ } catch (error) {
197
+ throw new AgentManifestError(
198
+ `agent.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
199
+ manifestPath,
200
+ );
201
+ }
202
+
203
+ return validateAgentManifest(parsed, root, manifestPath, options);
204
+ }
205
+
206
+ export function validateAgentManifest(
207
+ candidate: unknown,
208
+ root: string,
209
+ manifestPath: string,
210
+ options?: LoadAgentManifestOptions,
211
+ ): AgentManifest {
212
+ const fail = (message: string): AgentManifestError =>
213
+ new AgentManifestError(message, manifestPath);
214
+
215
+ if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
216
+ throw fail("agent.json must be a JSON object");
217
+ }
218
+ const record = candidate as Record<string, unknown>;
219
+
220
+ if (containsUnpairedSurrogate(candidate)) {
221
+ throw fail("agent.json contains an unpaired Unicode surrogate");
222
+ }
223
+
224
+ if (record.schema !== AGENT_MANIFEST_SCHEMA) {
225
+ throw fail(
226
+ `schema must be "${AGENT_MANIFEST_SCHEMA}" (got ${JSON.stringify(record.schema ?? null)})`,
227
+ );
228
+ }
229
+
230
+ const name = requireString(record.name, "name", fail);
231
+ if (!AGENT_NAME_PATTERN.test(name) || name.length > 64) {
232
+ throw fail(
233
+ `name must match ${AGENT_NAME_PATTERN.source} and be at most 64 characters (got ${JSON.stringify(name)})`,
234
+ );
235
+ }
236
+ if (RESERVED_AGENT_NAMES.has(name)) {
237
+ throw fail(`name is reserved by ASC subagent profiles: ${name}`);
238
+ }
239
+
240
+ let version: string | undefined;
241
+ if (record.version !== undefined) {
242
+ version = requireString(record.version, "version", fail);
243
+ if (!VERSION_PATTERN.test(version)) {
244
+ throw fail(`version must be a semantic version string (got ${JSON.stringify(version)})`);
245
+ }
246
+ }
247
+
248
+ let display_name: string | undefined;
249
+ if (record.display_name !== undefined) {
250
+ display_name = requireString(record.display_name, "display_name", fail);
251
+ }
252
+
253
+ let role: string | undefined;
254
+ if (record.role !== undefined) {
255
+ role = requireString(record.role, "role", fail);
256
+ }
257
+
258
+ let creation_task: string | undefined;
259
+ if (record.creation_task !== undefined) {
260
+ creation_task = requireString(record.creation_task, "creation_task", fail);
261
+ if (!AGENT_CREATION_TASK_PATTERN.test(creation_task)) {
262
+ throw fail("creation_task must match AK-<positive integer>");
263
+ }
264
+ }
265
+
266
+ const system_prompt_file = requireString(record.system_prompt_file, "system_prompt_file", fail);
267
+ if (isAbsolute(system_prompt_file) || hasParentSegment(system_prompt_file)) {
268
+ throw fail(
269
+ `system_prompt_file must be a relative path inside the agent repo (got ${JSON.stringify(system_prompt_file)})`,
270
+ );
271
+ }
272
+ resolveWithinRoot(root, system_prompt_file, "system_prompt_file", fail);
273
+
274
+ let skills: AgentManifestSkills | undefined;
275
+ if (record.skills !== undefined) {
276
+ if (
277
+ typeof record.skills !== "object" ||
278
+ record.skills === null ||
279
+ Array.isArray(record.skills)
280
+ ) {
281
+ throw fail("skills must be an object");
282
+ }
283
+ const skillsRecord = record.skills as Record<string, unknown>;
284
+ let profile: string | undefined;
285
+ if (skillsRecord.profile !== undefined && skillsRecord.profile !== null) {
286
+ profile = requireString(skillsRecord.profile, "skills.profile", fail);
287
+ if (options?.ecProfiles && !options.ecProfiles.has(profile)) {
288
+ throw fail(
289
+ `skills.profile "${profile}" is not a known engineering-core profile (known: ${[...options.ecProfiles.keys()].sort().join(", ") || "none"})`,
290
+ );
291
+ }
292
+ }
293
+ let extra: string[] | undefined;
294
+ if (skillsRecord.extra !== undefined) {
295
+ if (!Array.isArray(skillsRecord.extra)) {
296
+ throw fail("skills.extra must be an array of skill names");
297
+ }
298
+ extra = skillsRecord.extra.map((entry, index) => {
299
+ if (typeof entry !== "string" || !SKILL_NAME_PATTERN.test(entry)) {
300
+ throw fail(`skills.extra[${index}] must be a skill name string`);
301
+ }
302
+ return entry;
303
+ });
304
+ if (new Set(extra).size !== extra.length) {
305
+ throw fail("skills.extra contains duplicate entries");
306
+ }
307
+ }
308
+ if (profile !== undefined || extra !== undefined) {
309
+ skills = { ...(profile ? { profile } : {}), ...(extra ? { extra } : {}) };
310
+ }
311
+ }
312
+
313
+ if (!Array.isArray(record.tools)) {
314
+ throw fail("tools must be an array (empty array declares a read-only agent)");
315
+ }
316
+ const tools = record.tools.map((entry, index) => {
317
+ if (typeof entry !== "string" || !TOOL_NAME_PATTERN.test(entry)) {
318
+ throw fail(`tools[${index}] must be a tool name matching ${TOOL_NAME_PATTERN.source}`);
319
+ }
320
+ return entry;
321
+ });
322
+ if (new Set(tools).size !== tools.length) {
323
+ throw fail("tools contains duplicate entries");
324
+ }
325
+
326
+ let extensions: string[] = [];
327
+ if (record.extensions !== undefined) {
328
+ if (!Array.isArray(record.extensions)) {
329
+ throw fail("extensions must be an array");
330
+ }
331
+ extensions = record.extensions.map((entry, index) => {
332
+ if (
333
+ typeof entry !== "string" ||
334
+ !EXTENSION_NAME_PATTERN.test(entry) ||
335
+ entry.length === 0 ||
336
+ isAbsolute(entry) ||
337
+ hasParentSegment(entry)
338
+ ) {
339
+ throw fail(`extensions[${index}] must be an extension name or a contained ./ path`);
340
+ }
341
+ if (entry.includes("/") && !entry.startsWith("./") && !entry.startsWith("@")) {
342
+ throw fail(
343
+ `extensions[${index}] filesystem paths must start with ./ and stay inside the agent repo`,
344
+ );
345
+ }
346
+ if (entry.startsWith("./")) {
347
+ resolveWithinRoot(root, entry, `extensions[${index}]`, fail);
348
+ }
349
+ return entry;
350
+ });
351
+ if (new Set(extensions).size !== extensions.length) {
352
+ throw fail("extensions contains duplicate entries");
353
+ }
354
+ }
355
+
356
+ let defaults: AgentManifestDefaults = { model: null, thinking: "medium" };
357
+ if (record.defaults !== undefined) {
358
+ if (typeof record.defaults !== "object" || record.defaults === null) {
359
+ throw fail("defaults must be an object");
360
+ }
361
+ const defaultsRecord = record.defaults as Record<string, unknown>;
362
+ let model: string | null = null;
363
+ if (defaultsRecord.model !== undefined && defaultsRecord.model !== null) {
364
+ if (typeof defaultsRecord.model !== "string" || defaultsRecord.model.trim().length === 0) {
365
+ throw fail("defaults.model must be a non-empty provider/model string or null");
366
+ }
367
+ model = defaultsRecord.model.trim();
368
+ }
369
+ let thinking: AgentThinkingLevel = "medium";
370
+ if (defaultsRecord.thinking !== undefined) {
371
+ if (
372
+ typeof defaultsRecord.thinking !== "string" ||
373
+ !THINKING_LEVELS.has(defaultsRecord.thinking)
374
+ ) {
375
+ throw fail(
376
+ `defaults.thinking must be one of off, minimal, low, medium, high, xhigh, max (got ${JSON.stringify(defaultsRecord.thinking)})`,
377
+ );
378
+ }
379
+ thinking = defaultsRecord.thinking as AgentThinkingLevel;
380
+ }
381
+ defaults = { model, thinking };
382
+ }
383
+
384
+ let scope: AgentManifestScope | undefined;
385
+ if (record.scope !== undefined) {
386
+ if (typeof record.scope !== "object" || record.scope === null) {
387
+ throw fail("scope must be an object");
388
+ }
389
+ const scopeRecord = record.scope as Record<string, unknown>;
390
+ const scopeOut: AgentManifestScope = {};
391
+ if (scopeRecord.repos !== undefined) {
392
+ scopeOut.repos = requireStringArray(scopeRecord.repos, "scope.repos", fail);
393
+ }
394
+ if (scopeRecord.forbidden !== undefined) {
395
+ scopeOut.forbidden = requireStringArray(scopeRecord.forbidden, "scope.forbidden", fail);
396
+ }
397
+ if (scopeRecord.note !== undefined) {
398
+ if (typeof scopeRecord.note !== "string") {
399
+ throw fail("scope.note must be a string");
400
+ }
401
+ const note = scopeRecord.note.trim();
402
+ if (note) scopeOut.note = note;
403
+ }
404
+ if (
405
+ scopeOut.repos !== undefined ||
406
+ scopeOut.forbidden !== undefined ||
407
+ scopeOut.note !== undefined
408
+ ) {
409
+ scope = scopeOut;
410
+ }
411
+ }
412
+
413
+ let activities: string[] = [];
414
+ if (record.activities !== undefined) {
415
+ if (!Array.isArray(record.activities)) {
416
+ throw fail("activities must be an array of relative file paths");
417
+ }
418
+ activities = record.activities.map((entry, index) => {
419
+ if (typeof entry !== "string" || isAbsolute(entry) || hasParentSegment(entry)) {
420
+ throw fail(`activities[${index}] must be a relative path inside the agent repo`);
421
+ }
422
+ if (isActivityGlob(dirname(entry))) {
423
+ throw fail(`activities[${index}] may use glob metacharacters only in the file name`);
424
+ }
425
+ return entry;
426
+ });
427
+ if (new Set(activities).size !== activities.length) {
428
+ throw fail("activities contains duplicate entries");
429
+ }
430
+ }
431
+
432
+ return {
433
+ schema: AGENT_MANIFEST_SCHEMA,
434
+ name,
435
+ ...(version ? { version } : {}),
436
+ ...(display_name ? { display_name } : {}),
437
+ ...(role ? { role } : {}),
438
+ ...(creation_task ? { creation_task } : {}),
439
+ system_prompt_file,
440
+ ...(skills ? { skills } : {}),
441
+ tools,
442
+ extensions,
443
+ defaults,
444
+ ...(scope ? { scope } : {}),
445
+ activities,
446
+ root,
447
+ manifestPath,
448
+ };
449
+ }
450
+
451
+ /** Load and return the system prompt file contents (fail-closed). */
452
+ export async function readAgentSystemPrompt(manifest: AgentManifest): Promise<string> {
453
+ const systemPromptPath = resolveWithinRoot(
454
+ manifest.root,
455
+ manifest.system_prompt_file,
456
+ "system_prompt_file",
457
+ (message) => new AgentManifestError(message, manifest.manifestPath),
458
+ );
459
+ try {
460
+ const { path: realSystemPromptPath, fileStat } = await assertExistingPathWithinRoot(
461
+ manifest.root,
462
+ systemPromptPath,
463
+ `system_prompt_file ${JSON.stringify(manifest.system_prompt_file)}`,
464
+ manifest,
465
+ "file",
466
+ );
467
+ if (fileStat.size > SYSTEM_PROMPT_MAX_BYTES) {
468
+ throw new AgentManifestError(
469
+ `system_prompt_file exceeds ${SYSTEM_PROMPT_MAX_BYTES} bytes: ${manifest.system_prompt_file}`,
470
+ manifest.manifestPath,
471
+ );
472
+ }
473
+ const bytes = await readFile(realSystemPromptPath);
474
+ try {
475
+ return decodeStrictUtf8(bytes, "system_prompt_file");
476
+ } catch {
477
+ throw new AgentManifestError("system_prompt_file is not strict UTF-8", manifest.manifestPath);
478
+ }
479
+ } catch (error) {
480
+ if (error instanceof AgentManifestError) throw error;
481
+ throw new AgentManifestError(
482
+ `system_prompt_file could not be read: ${manifest.system_prompt_file}`,
483
+ manifest.manifestPath,
484
+ );
485
+ }
486
+ }
487
+
488
+ /** True when an activities entry uses glob metacharacters (expanded at resolution). */
489
+ export function isActivityGlob(entry: string): boolean {
490
+ return /[*?[]/u.test(entry);
491
+ }
492
+
493
+ /**
494
+ * Expand declared activities into concrete file paths (fail-closed).
495
+ * Literal entries must exist; glob entries must match at least one file.
496
+ */
497
+ export async function expandAgentActivities(manifest: AgentManifest): Promise<string[]> {
498
+ const expanded: string[] = [];
499
+ for (const activity of manifest.activities) {
500
+ const activityPath = resolveWithinRoot(
501
+ manifest.root,
502
+ activity,
503
+ `activities entry ${JSON.stringify(activity)}`,
504
+ (message) => new AgentManifestError(message, manifest.manifestPath),
505
+ );
506
+ if (isActivityGlob(activity)) {
507
+ const pattern = globToRegExp(activity);
508
+ const baseRelative = dirname(activity).split(sep).join("/");
509
+ const basePath = resolve(manifest.root, baseRelative);
510
+ const { path: realBasePath } = await assertExistingPathWithinRoot(
511
+ manifest.root,
512
+ basePath,
513
+ `activities glob base ${JSON.stringify(baseRelative)}`,
514
+ manifest,
515
+ "directory",
516
+ );
517
+ const entries = await readdir(realBasePath, { withFileTypes: true });
518
+ const matches = entries
519
+ .filter((entry) => entry.isFile())
520
+ .map((entry) => (baseRelative === "." ? entry.name : `${baseRelative}/${entry.name}`))
521
+ .filter((relativeEntry) => pattern.test(relativeEntry))
522
+ .sort();
523
+ if (matches.length === 0) {
524
+ throw new AgentManifestError(
525
+ `activities glob matched no files: ${activity}`,
526
+ manifest.manifestPath,
527
+ );
528
+ }
529
+ for (const match of matches) {
530
+ await assertExistingPathWithinRoot(
531
+ manifest.root,
532
+ resolve(manifest.root, match),
533
+ `activities match ${JSON.stringify(match)}`,
534
+ manifest,
535
+ "file",
536
+ );
537
+ }
538
+ expanded.push(...matches);
539
+ continue;
540
+ }
541
+ await assertExistingPathWithinRoot(
542
+ manifest.root,
543
+ activityPath,
544
+ `activities entry ${JSON.stringify(activity)}`,
545
+ manifest,
546
+ "file",
547
+ );
548
+ expanded.push(activity);
549
+ }
550
+ return expanded;
551
+ }
552
+
553
+ /** Minimal leaf-file glob: `*` and `?` never cross a path separator. */
554
+ export function globToRegExp(pattern: string): RegExp {
555
+ let source = "";
556
+ for (let i = 0; i < pattern.length; i++) {
557
+ const char = pattern[i];
558
+ if (char === "*") {
559
+ source += "[^/]*";
560
+ continue;
561
+ }
562
+ if (char === "?") {
563
+ source += "[^/]";
564
+ continue;
565
+ }
566
+ source += char.replace(/[|\\{}()[\]^$+?.]/gu, "\\$&");
567
+ }
568
+ return new RegExp(`^${source}$`, "u");
569
+ }
570
+
571
+ /** Resolve extension entries: contained ./ paths become manifest-root-relative absolute paths. */
572
+ export function resolveAgentExtensions(manifest: AgentManifest): string[] {
573
+ return manifest.extensions.map((entry) =>
574
+ entry.startsWith("./") ? resolve(manifest.root, entry) : entry,
575
+ );
576
+ }
577
+
578
+ /** Verify filesystem-backed extension entries exist and remain inside the agent repo. */
579
+ export async function assertAgentExtensionsExist(manifest: AgentManifest): Promise<void> {
580
+ for (const entry of manifest.extensions) {
581
+ if (!entry.startsWith("./")) continue;
582
+ await assertExistingPathWithinRoot(
583
+ manifest.root,
584
+ resolve(manifest.root, entry),
585
+ `extension ${JSON.stringify(entry)}`,
586
+ manifest,
587
+ "file",
588
+ );
589
+ }
590
+ }
591
+
592
+ export function resolveWithinRoot(
593
+ root: string,
594
+ relativePath: string,
595
+ label: string,
596
+ fail: (message: string) => AgentManifestError,
597
+ ): string {
598
+ const resolved = resolve(root, relativePath);
599
+ const rel = relative(resolve(root), resolved);
600
+ if (rel.startsWith("..") || isAbsolute(rel)) {
601
+ throw fail(`${label} escapes the agent repo root: ${relativePath}`);
602
+ }
603
+ return resolved;
604
+ }
605
+
606
+ async function assertExistingPathWithinRoot(
607
+ root: string,
608
+ candidate: string,
609
+ label: string,
610
+ manifest: AgentManifest,
611
+ kind: "file" | "directory",
612
+ ): Promise<{ path: string; fileStat: Stats }> {
613
+ try {
614
+ const [realRoot, realCandidate] = await Promise.all([realpath(root), realpath(candidate)]);
615
+ const rel = relative(realRoot, realCandidate);
616
+ if (rel.startsWith("..") || isAbsolute(rel)) {
617
+ throw new AgentManifestError(
618
+ `${label} resolves outside the agent repo root: ${candidate}`,
619
+ manifest.manifestPath,
620
+ );
621
+ }
622
+ const fileStat = await stat(realCandidate);
623
+ const validKind = kind === "file" ? fileStat.isFile() : fileStat.isDirectory();
624
+ if (!validKind) {
625
+ throw new AgentManifestError(
626
+ `${label} is not a regular ${kind}: ${candidate}`,
627
+ manifest.manifestPath,
628
+ );
629
+ }
630
+ return { path: realCandidate, fileStat };
631
+ } catch (error) {
632
+ if (error instanceof AgentManifestError) throw error;
633
+ throw new AgentManifestError(
634
+ `${label} does not exist or cannot be resolved inside the agent repo`,
635
+ manifest.manifestPath,
636
+ );
637
+ }
638
+ }
639
+
640
+ function hasParentSegment(value: string): boolean {
641
+ return value.split(/[\\/]/u).includes("..");
642
+ }
643
+
644
+ function requireString(
645
+ value: unknown,
646
+ label: string,
647
+ fail: (message: string) => AgentManifestError,
648
+ ): string {
649
+ if (typeof value !== "string" || value.trim().length === 0) {
650
+ throw fail(`${label} must be a non-empty string`);
651
+ }
652
+ return value.trim();
653
+ }
654
+
655
+ function requireStringArray(
656
+ value: unknown,
657
+ label: string,
658
+ fail: (message: string) => AgentManifestError,
659
+ ): string[] {
660
+ if (!Array.isArray(value)) {
661
+ throw fail(`${label} must be an array of non-empty strings`);
662
+ }
663
+ return value.map((entry, index) => {
664
+ if (typeof entry !== "string" || entry.trim().length === 0) {
665
+ throw fail(`${label}[${index}] must be a non-empty string`);
666
+ }
667
+ return entry.trim();
668
+ });
669
+ }
670
+
671
+ /** Default user-level skills root used for `skills.extra` resolution. */
672
+ export function defaultUserSkillsRoot(): string {
673
+ const override = process.env.PI_AGENT_REGISTRY_USER_SKILLS?.trim();
674
+ if (override) {
675
+ return override.startsWith("~/") ? join(homedir(), override.slice(2)) : resolve(override);
676
+ }
677
+ return resolve(homedir(), ".pi", "agent", "skills");
678
+ }