@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.
- package/LICENSE +78 -0
- package/README.md +238 -0
- package/docs/engineering.local.md +90 -0
- package/docs/project/2026-08-27-agent-registry.md +287 -0
- package/docs/project/foundation.md +31 -0
- package/docs/project/vision.md +18 -0
- package/examples/.gitkeep +0 -0
- package/extensions/pi-agent-registry.ts +378 -0
- package/package.json +105 -0
- package/policy/engineering-lane.json +34 -0
- package/policy/security-policy.json +10 -0
- package/prompts/implementation-planning.md +20 -0
- package/prompts/security-review.md +20 -0
- package/scripts/fleet-lint.mjs +82 -0
- package/src/.gitkeep +0 -0
- package/src/agent-skill-resolver.ts +50 -0
- package/src/asc-execution-surface.ts +64 -0
- package/src/dispatch-authorization.ts +237 -0
- package/src/dispatch-contract.ts +89 -0
- package/src/dispatch-receipt.ts +326 -0
- package/src/dispatch-request.ts +135 -0
- package/src/dispatch.ts +498 -0
- package/src/ec-profiles.ts +392 -0
- package/src/fleet-git-snapshot.ts +323 -0
- package/src/fleet-lint-provenance.ts +356 -0
- package/src/fleet-lint-repository.ts +450 -0
- package/src/fleet-lint-skills.ts +131 -0
- package/src/fleet-lint-types.ts +113 -0
- package/src/fleet-lint-utils.ts +66 -0
- package/src/fleet-lint.ts +375 -0
- package/src/fleet-prompt-compiler.ts +155 -0
- package/src/manifest.ts +678 -0
- package/src/registry-discovery.ts +225 -0
- package/src/registry.ts +280 -0
- package/src/sessions-dir.ts +30 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: engineering-core skill profile loading, skill-source resolution, and child skill-dir materialization.
|
|
3
|
+
// read_when:
|
|
4
|
+
// - changing EC profile handling, extras resolution roots, or materialized skill directory layout.
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { constants, existsSync, realpathSync, statSync } from "node:fs";
|
|
9
|
+
import { lstat, mkdir, mkdtemp, open, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
10
|
+
import { homedir, tmpdir } from "node:os";
|
|
11
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_EC_PROFILES_RELATIVE = "ai-society/core/engineering-core/skills/profiles.json";
|
|
14
|
+
export const EC_PROFILES_ENV = "PI_AGENT_REGISTRY_EC_PROFILES";
|
|
15
|
+
export const EC_PROFILE_SCHEMA = "engineering-core.skill-profiles/1";
|
|
16
|
+
const EC_PROFILES_MAX_BYTES = 2 * 1024 * 1024;
|
|
17
|
+
const PROFILE_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/u;
|
|
18
|
+
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/u;
|
|
19
|
+
const ENVELOPE_KEYS: ReadonlySet<string> = new Set([
|
|
20
|
+
"schema",
|
|
21
|
+
"generated",
|
|
22
|
+
"profiles",
|
|
23
|
+
"deprecated_aliases",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
function decodeStrictUtf8(bytes: Buffer): string {
|
|
27
|
+
try {
|
|
28
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
29
|
+
} catch {
|
|
30
|
+
throw new Error("profiles.json is not strict UTF-8");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasUnpairedSurrogate(value: string): boolean {
|
|
35
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
36
|
+
const code = value.charCodeAt(index);
|
|
37
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
38
|
+
const next = value.charCodeAt(index + 1);
|
|
39
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) return true;
|
|
40
|
+
index += 1;
|
|
41
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function containsUnpairedSurrogate(value: unknown): boolean {
|
|
49
|
+
if (typeof value === "string") return hasUnpairedSurrogate(value);
|
|
50
|
+
if (Array.isArray(value)) return value.some(containsUnpairedSurrogate);
|
|
51
|
+
if (typeof value !== "object" || value === null) return false;
|
|
52
|
+
return Object.entries(value as Record<string, unknown>).some(
|
|
53
|
+
([key, entry]) => hasUnpairedSurrogate(key) || containsUnpairedSurrogate(entry),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface EcProfileSource {
|
|
58
|
+
/** Absolute path of the loaded profiles.json. */
|
|
59
|
+
path: string;
|
|
60
|
+
/** Absolute engineering-core skills root (dirname of profiles.json). */
|
|
61
|
+
skillsRoot: string;
|
|
62
|
+
/** SHA-256 of the exact profiles.json bytes parsed into this source. */
|
|
63
|
+
rawSha256: string;
|
|
64
|
+
/** Loaded source contract (legacy is transition-read compatibility only). */
|
|
65
|
+
schema: typeof EC_PROFILE_SCHEMA | "legacy-raw-map";
|
|
66
|
+
/** Canonical profile name -> ordered member skill names. */
|
|
67
|
+
profiles: Map<string, string[]>;
|
|
68
|
+
/** Deprecated profile alias -> canonical profile name. */
|
|
69
|
+
deprecatedAliases: Map<string, string>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class EcProfileError extends Error {
|
|
73
|
+
constructor(message: string) {
|
|
74
|
+
super(message);
|
|
75
|
+
this.name = "EcProfileError";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function defaultEcProfilesPath(): string {
|
|
80
|
+
const override = process.env[EC_PROFILES_ENV]?.trim();
|
|
81
|
+
if (override) {
|
|
82
|
+
return override.startsWith("~/") ? join(homedir(), override.slice(2)) : resolve(override);
|
|
83
|
+
}
|
|
84
|
+
return resolve(homedir(), DEFAULT_EC_PROFILES_RELATIVE);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function loadEcProfiles(path?: string): Promise<EcProfileSource> {
|
|
88
|
+
const resolvedPath = resolve(path ?? defaultEcProfilesPath());
|
|
89
|
+
let parsed: unknown;
|
|
90
|
+
let rawText: string;
|
|
91
|
+
let rawBytes = Buffer.alloc(0);
|
|
92
|
+
try {
|
|
93
|
+
const initial = await lstat(resolvedPath);
|
|
94
|
+
if (!initial.isFile() || initial.isSymbolicLink() || initial.size > EC_PROFILES_MAX_BYTES) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`profiles.json must be a non-symlink regular file at most ${EC_PROFILES_MAX_BYTES} bytes`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const handle = await open(resolvedPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
100
|
+
try {
|
|
101
|
+
const before = await handle.stat();
|
|
102
|
+
if (
|
|
103
|
+
before.dev !== initial.dev ||
|
|
104
|
+
before.ino !== initial.ino ||
|
|
105
|
+
before.size !== initial.size
|
|
106
|
+
) {
|
|
107
|
+
throw new Error("profiles.json identity changed while opening");
|
|
108
|
+
}
|
|
109
|
+
rawBytes = await handle.readFile();
|
|
110
|
+
rawText = decodeStrictUtf8(rawBytes);
|
|
111
|
+
const after = await handle.stat();
|
|
112
|
+
if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size) {
|
|
113
|
+
throw new Error("profiles.json identity changed while reading");
|
|
114
|
+
}
|
|
115
|
+
} finally {
|
|
116
|
+
await handle.close();
|
|
117
|
+
}
|
|
118
|
+
parsed = JSON.parse(rawText);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
throw new EcProfileError(
|
|
121
|
+
`engineering-core skill profiles could not be read from ${resolvedPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (containsUnpairedSurrogate(parsed)) {
|
|
125
|
+
throw new EcProfileError(
|
|
126
|
+
"engineering-core skill profiles contain an unpaired Unicode surrogate",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (!isRecord(parsed)) {
|
|
130
|
+
throw new EcProfileError(
|
|
131
|
+
`engineering-core skill profiles must be a JSON object: ${resolvedPath}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const isEnvelope = Object.hasOwn(parsed, "schema");
|
|
136
|
+
let schema: EcProfileSource["schema"] = "legacy-raw-map";
|
|
137
|
+
let profilePayload: Record<string, unknown> = parsed;
|
|
138
|
+
const deprecatedAliases = new Map<string, string>();
|
|
139
|
+
|
|
140
|
+
if (isEnvelope) {
|
|
141
|
+
const unknownKeys = Object.keys(parsed).filter((key) => !ENVELOPE_KEYS.has(key));
|
|
142
|
+
if (unknownKeys.length > 0) {
|
|
143
|
+
throw new EcProfileError(
|
|
144
|
+
`unknown engineering-core skill profile envelope keys: ${unknownKeys.sort().join(", ")}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (parsed.schema !== EC_PROFILE_SCHEMA) {
|
|
148
|
+
throw new EcProfileError(
|
|
149
|
+
`engineering-core skill profile schema mismatch: expected ${EC_PROFILE_SCHEMA}, got ${JSON.stringify(parsed.schema)}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (!isRecord(parsed.profiles)) {
|
|
153
|
+
throw new EcProfileError(
|
|
154
|
+
`engineering-core skill profile envelope must contain a profiles object: ${resolvedPath}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
if (!isRecord(parsed.deprecated_aliases)) {
|
|
158
|
+
throw new EcProfileError(
|
|
159
|
+
`engineering-core skill profile envelope must contain a deprecated_aliases object: ${resolvedPath}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
schema = EC_PROFILE_SCHEMA;
|
|
163
|
+
profilePayload = parsed.profiles;
|
|
164
|
+
for (const [alias, target] of Object.entries(parsed.deprecated_aliases)) {
|
|
165
|
+
if (
|
|
166
|
+
!PROFILE_NAME_PATTERN.test(alias) ||
|
|
167
|
+
typeof target !== "string" ||
|
|
168
|
+
!PROFILE_NAME_PATTERN.test(target)
|
|
169
|
+
) {
|
|
170
|
+
throw new EcProfileError(
|
|
171
|
+
`deprecated_aliases must map valid alias names to canonical profile names`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
deprecatedAliases.set(alias, target);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const profiles = parseProfileMap(profilePayload, resolvedPath);
|
|
179
|
+
for (const [alias, target] of deprecatedAliases) {
|
|
180
|
+
if (profiles.has(alias)) {
|
|
181
|
+
throw new EcProfileError(
|
|
182
|
+
`deprecated profile alias "${alias}" collides with a canonical profile name`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (!profiles.has(target)) {
|
|
186
|
+
throw new EcProfileError(
|
|
187
|
+
`deprecated profile alias "${alias}" references unknown canonical profile "${target}"`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
path: resolvedPath,
|
|
194
|
+
skillsRoot: dirname(resolvedPath),
|
|
195
|
+
rawSha256: createHash("sha256").update(rawBytes).digest("hex"),
|
|
196
|
+
schema,
|
|
197
|
+
profiles,
|
|
198
|
+
deprecatedAliases,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Canonical profiles plus transition aliases, for manifest load-time validation. */
|
|
203
|
+
export function knownEcProfiles(ec: EcProfileSource): Map<string, readonly string[]> {
|
|
204
|
+
const known = new Map<string, readonly string[]>(ec.profiles);
|
|
205
|
+
for (const [alias, target] of ec.deprecatedAliases) {
|
|
206
|
+
const members = ec.profiles.get(target);
|
|
207
|
+
if (members) known.set(alias, members);
|
|
208
|
+
}
|
|
209
|
+
return known;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function parseProfileMap(
|
|
213
|
+
payload: Record<string, unknown>,
|
|
214
|
+
resolvedPath: string,
|
|
215
|
+
): Map<string, string[]> {
|
|
216
|
+
const profiles = new Map<string, string[]>();
|
|
217
|
+
for (const [profile, members] of Object.entries(payload)) {
|
|
218
|
+
if (!PROFILE_NAME_PATTERN.test(profile)) {
|
|
219
|
+
throw new EcProfileError(`invalid profile key "${profile}" in ${resolvedPath}`);
|
|
220
|
+
}
|
|
221
|
+
if (
|
|
222
|
+
!Array.isArray(members) ||
|
|
223
|
+
members.some((entry) => typeof entry !== "string" || !SKILL_NAME_PATTERN.test(entry))
|
|
224
|
+
) {
|
|
225
|
+
throw new EcProfileError(`profile "${profile}" must map to an array of valid skill names`);
|
|
226
|
+
}
|
|
227
|
+
if (new Set(members).size !== members.length) {
|
|
228
|
+
throw new EcProfileError(`profile "${profile}" contains duplicate skill names`);
|
|
229
|
+
}
|
|
230
|
+
profiles.set(profile, [...(members as string[])]);
|
|
231
|
+
}
|
|
232
|
+
return profiles;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
236
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export interface SkillSourceRoots {
|
|
240
|
+
ecSkillsRoot: string;
|
|
241
|
+
manifestRoot: string;
|
|
242
|
+
userSkillsRoot: string;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export interface SkillSelection {
|
|
246
|
+
/** EC profile name when the manifest declared one. */
|
|
247
|
+
profile?: string;
|
|
248
|
+
/** Profile member skill names. */
|
|
249
|
+
profileMembers: string[];
|
|
250
|
+
/** Extra skill names declared by the manifest. */
|
|
251
|
+
extras: string[];
|
|
252
|
+
/** Deduplicated member + extra names in stable order. */
|
|
253
|
+
selected: string[];
|
|
254
|
+
/** Skill name -> absolute SKILL.md source path. */
|
|
255
|
+
sources: Map<string, string>;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function resolveSkillSourcePath(name: string, roots: SkillSourceRoots): string | undefined {
|
|
259
|
+
if (!SKILL_NAME_PATTERN.test(name)) {
|
|
260
|
+
throw new EcProfileError(`invalid skill name: ${JSON.stringify(name)}`);
|
|
261
|
+
}
|
|
262
|
+
const candidates = [
|
|
263
|
+
{ root: roots.ecSkillsRoot, path: join(roots.ecSkillsRoot, name, "SKILL.md") },
|
|
264
|
+
{
|
|
265
|
+
root: join(roots.manifestRoot, ".pi", "skills"),
|
|
266
|
+
path: join(roots.manifestRoot, ".pi", "skills", name, "SKILL.md"),
|
|
267
|
+
},
|
|
268
|
+
{ root: roots.userSkillsRoot, path: join(roots.userSkillsRoot, name, "SKILL.md") },
|
|
269
|
+
];
|
|
270
|
+
for (const candidate of candidates) {
|
|
271
|
+
if (!existsSync(candidate.path)) continue;
|
|
272
|
+
const realRoot = realpathSync(candidate.root);
|
|
273
|
+
const realCandidate = realpathSync(candidate.path);
|
|
274
|
+
assertPathWithinRoot(realCandidate, realRoot, `skill ${name}`);
|
|
275
|
+
if (!statSync(realCandidate).isFile()) {
|
|
276
|
+
throw new EcProfileError(`skill source is not a regular file: ${realCandidate}`);
|
|
277
|
+
}
|
|
278
|
+
return realCandidate;
|
|
279
|
+
}
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function planSkillSelection(params: {
|
|
284
|
+
profile?: string;
|
|
285
|
+
extra?: string[];
|
|
286
|
+
ec: EcProfileSource;
|
|
287
|
+
manifestRoot: string;
|
|
288
|
+
userSkillsRoot: string;
|
|
289
|
+
}): SkillSelection {
|
|
290
|
+
const roots: SkillSourceRoots = {
|
|
291
|
+
ecSkillsRoot: params.ec.skillsRoot,
|
|
292
|
+
manifestRoot: params.manifestRoot,
|
|
293
|
+
userSkillsRoot: params.userSkillsRoot,
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
let profileMembers: string[] = [];
|
|
297
|
+
if (params.profile !== undefined) {
|
|
298
|
+
const canonicalProfile = params.ec.deprecatedAliases.get(params.profile) ?? params.profile;
|
|
299
|
+
const members = params.ec.profiles.get(canonicalProfile);
|
|
300
|
+
if (!members) {
|
|
301
|
+
throw new EcProfileError(
|
|
302
|
+
`unknown engineering-core skill profile: ${params.profile} (known: ${[...knownEcProfiles(params.ec).keys()].sort().join(", ") || "none"})`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
const missing = members.filter((name) => resolveSkillSourcePath(name, roots) === undefined);
|
|
306
|
+
if (missing.length > 0) {
|
|
307
|
+
throw new EcProfileError(
|
|
308
|
+
`engineering-core profile "${params.profile}" references missing skills: ${missing.join(", ")}`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
profileMembers = [...members];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const extras = params.extra ?? [];
|
|
315
|
+
const sources = new Map<string, string>();
|
|
316
|
+
for (const name of [...profileMembers, ...extras]) {
|
|
317
|
+
if (!sources.has(name)) {
|
|
318
|
+
const source = resolveSkillSourcePath(name, roots);
|
|
319
|
+
if (source === undefined) {
|
|
320
|
+
throw new EcProfileError(
|
|
321
|
+
`skill "${name}" not found in engineering-core skills root, agent repo .pi/skills, or the user skills root`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
sources.set(name, source);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
...(params.profile !== undefined ? { profile: params.profile } : {}),
|
|
330
|
+
profileMembers,
|
|
331
|
+
extras: [...extras],
|
|
332
|
+
selected: [...sources.keys()],
|
|
333
|
+
sources,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export interface MaterializedSkillDirs {
|
|
338
|
+
dir: string;
|
|
339
|
+
skills: string[];
|
|
340
|
+
cleanup: () => Promise<void>;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Materialize selected skills into a temp directory laid out as
|
|
345
|
+
* `<dir>/<skill-name>/SKILL.md`, matching ASC's materialized skill layout.
|
|
346
|
+
* `disable-model-invocation: true` frontmatter is stripped so the child can
|
|
347
|
+
* invoke the skills (parity with ASC visible-skill materialization).
|
|
348
|
+
*/
|
|
349
|
+
export async function materializeSkillDirs(
|
|
350
|
+
selection: SkillSelection,
|
|
351
|
+
label: string,
|
|
352
|
+
): Promise<MaterializedSkillDirs> {
|
|
353
|
+
const safeLabel = label.replace(/[^a-z0-9-]/giu, "-").slice(0, 48) || "agent";
|
|
354
|
+
const dir = await mkdtemp(join(tmpdir(), `pi-agent-registry-${safeLabel}-`));
|
|
355
|
+
try {
|
|
356
|
+
for (const name of selection.selected) {
|
|
357
|
+
const source = selection.sources.get(name);
|
|
358
|
+
if (!source) {
|
|
359
|
+
throw new EcProfileError(`skill source missing during materialization: ${name}`);
|
|
360
|
+
}
|
|
361
|
+
const sourceStat = await stat(source).catch(() => undefined);
|
|
362
|
+
if (!sourceStat?.isFile()) {
|
|
363
|
+
throw new EcProfileError(`skill source is not a regular file: ${source}`);
|
|
364
|
+
}
|
|
365
|
+
const text = await readFile(source, "utf8");
|
|
366
|
+
const destinationDir = join(dir, name);
|
|
367
|
+
assertPathWithinRoot(destinationDir, dir, `materialized skill ${name}`);
|
|
368
|
+
await mkdir(destinationDir, { recursive: true });
|
|
369
|
+
await writeFile(join(destinationDir, "SKILL.md"), removeDisableModelInvocation(text), "utf8");
|
|
370
|
+
}
|
|
371
|
+
} catch (error) {
|
|
372
|
+
await rm(dir, { recursive: true, force: true });
|
|
373
|
+
throw error;
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
dir,
|
|
377
|
+
skills: [...selection.selected],
|
|
378
|
+
cleanup: () => rm(dir, { recursive: true, force: true }),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function removeDisableModelInvocation(text: string): string {
|
|
383
|
+
return text.replace(/\ndisable-model-invocation:\s*true\s*\n/u, "\n");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Assert a candidate skill path stays within its owning root (defense in depth). */
|
|
387
|
+
export function assertPathWithinRoot(candidate: string, root: string, label: string): void {
|
|
388
|
+
const rel = relative(resolve(root), resolve(candidate));
|
|
389
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
390
|
+
throw new EcProfileError(`${label} resolves outside its allowed root: ${candidate}`);
|
|
391
|
+
}
|
|
392
|
+
}
|