@agentforge/skills 0.17.1 → 0.17.2

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/README.md CHANGED
@@ -20,10 +20,30 @@ yarn add @agentforge/skills
20
20
 
21
21
  ## Overview
22
22
 
23
- `@agentforge/skills` provides the skill discovery, registration, activation, and trust policy engine for AgentForge agents. It implements the [Agent Skills Specification](https://agentskills.io) for composable, modular agent capabilities.
23
+ `@agentforge/skills` provides Agent Skill discovery, registration, access, and trust policy enforcement for AgentForge agents. Within Agent Skill access, Skill activation loads trusted instructions while resource access loads supporting files. It implements the [Agent Skills Specification](https://agentskills.io) for composable, modular agent capabilities.
24
24
 
25
25
  Full source code and API will be available after ST-07002 (Move Skills Source Files). See the [AgentForge docs](https://tvscoundrel.github.io/agentforge/) for usage guides and tutorials.
26
26
 
27
+ ## Agent Skill Access
28
+
29
+ Access Agent Skill instructions and supporting resources through the Tools bound
30
+ to a `SkillRegistry`:
31
+
32
+ ```typescript
33
+ import { SkillRegistry } from '@agentforge/skills';
34
+
35
+ const registry = new SkillRegistry({
36
+ enabled: true,
37
+ skillRoots: [{ path: '.agentskills', trust: 'workspace' }],
38
+ });
39
+
40
+ const [activateSkill, readSkillResource] = registry.toActivationTools();
41
+ ```
42
+
43
+ `resolveResourcePath` remains exported for compatibility, but it is deprecated
44
+ and planned for removal in the next major release. Use the
45
+ `read-skill-resource` Tool for Agent Skill resource access instead.
46
+
27
47
  ## License
28
48
 
29
49
  MIT — see [LICENSE](../../LICENSE) for details.
package/dist/index.cjs CHANGED
@@ -223,23 +223,12 @@ function scanAllSkillRoots(skillRoots) {
223
223
  function extractBody(content) {
224
224
  return matter__default.default(content).content.trim();
225
225
  }
226
- var activateSkillSchema = zod.z.object({
227
- name: zod.z.string().describe('The name of the skill to activate (e.g., "code-review")')
228
- });
229
- var readSkillResourceSchema = zod.z.object({
230
- name: zod.z.string().describe("The name of the skill that owns the resource"),
231
- path: zod.z.string().describe('Relative path to the resource file within the skill directory (e.g., "references/GUIDE.md", "scripts/setup.sh")')
232
- });
233
226
  var activationLogger = core.createLogger("agentforge:skills:activation", {
234
227
  level: core.LogLevel.INFO
235
228
  });
236
- function buildMissingSkillMessage(registry, name) {
237
- const availableNames = registry.getNames();
229
+ function formatMissingSkillMessage(name, availableNames) {
238
230
  const suggestion = availableNames.length > 0 ? ` Available skills: ${availableNames.join(", ")}` : " No skills are currently registered.";
239
- return {
240
- availableCount: availableNames.length,
241
- errorMessage: `Skill "${name}" not found.${suggestion}`
242
- };
231
+ return `Skill "${name}" not found.${suggestion}`;
243
232
  }
244
233
 
245
234
  // src/trust.ts
@@ -331,114 +320,96 @@ function evaluateSkillActivationPolicy(trustLevel) {
331
320
  }
332
321
  }
333
322
 
334
- // src/activation-activate-tool.ts
335
- function createActivateSkillTool(registry) {
336
- return new core.ToolBuilder().name("activate-skill").description(
337
- "Activate an Agent Skill by name, loading its full instructions for trusted roots. Returns the complete SKILL.md body content for workspace or explicitly trusted skills. Use this when you see a relevant skill in <available_skills> or <untrusted_skills>; activation is blocked for untrusted roots until they are promoted."
338
- ).category(core.ToolCategory.SKILLS).tags(["skill", "activation", "agent-skills"]).schema(activateSkillSchema).implement(async ({ name }) => {
339
- const skill = registry.get(name);
340
- if (!skill) {
341
- const { availableCount, errorMessage } = buildMissingSkillMessage(registry, name);
342
- activationLogger.warn("Skill activation failed \u2014 not found", { name, availableCount });
343
- return errorMessage;
344
- }
345
- const policyDecision = evaluateSkillActivationPolicy(skill.trustLevel);
346
- if (!policyDecision.allowed) {
347
- activationLogger.warn("Skill activation blocked \u2014 trust policy", {
348
- name,
349
- trustLevel: skill.trustLevel,
350
- reason: policyDecision.reason,
351
- message: policyDecision.message
352
- });
353
- registry.emitEvent("trust:policy-denied" /* TRUST_POLICY_DENIED */, {
354
- name: skill.metadata.name,
355
- resourcePath: "SKILL.md",
356
- trustLevel: skill.trustLevel,
357
- reason: policyDecision.reason,
358
- message: policyDecision.message
359
- });
360
- return policyDecision.message;
361
- }
362
- const skillMdPath = path.resolve(skill.skillPath, "SKILL.md");
363
- try {
364
- const content = fs.readFileSync(skillMdPath, "utf-8");
365
- const body = extractBody(content);
366
- activationLogger.info("Skill activated", {
367
- name: skill.metadata.name,
368
- skillPath: skill.skillPath,
369
- bodyLength: body.length,
370
- trustLevel: skill.trustLevel,
371
- activationReason: policyDecision.reason
372
- });
373
- registry.emitEvent("skill:activated" /* SKILL_ACTIVATED */, {
374
- name: skill.metadata.name,
375
- skillPath: skill.skillPath,
376
- bodyLength: body.length,
377
- trustLevel: skill.trustLevel
378
- });
379
- return body;
380
- } catch (error) {
381
- const message = error instanceof Error ? error.message : String(error);
382
- activationLogger.error("Skill activation failed \u2014 read error", {
383
- name,
384
- skillPath: skill.skillPath,
385
- error: message
386
- });
387
- return `Failed to read skill "${name}" instructions: ${message}`;
388
- }
389
- }).build();
390
- }
391
- function resolveResourcePath(skillPath, resourcePath) {
323
+ // src/agent-skill-access.ts
324
+ var PATH_TRAVERSAL_MESSAGE = "Path traversal is not allowed \u2014 resource paths must stay within the skill directory";
325
+ var SYMLINK_ESCAPE_MESSAGE = "Symlink target escapes the skill directory \u2014 access denied";
326
+ function formatError(error) {
327
+ return error instanceof Error ? error.message : String(error);
328
+ }
329
+ function formatResourceReadError(error, resolvedPath) {
330
+ const message = formatError(error);
331
+ const detailIndex = message.indexOf(", ");
332
+ const prefix = detailIndex === -1 ? message : message.slice(0, detailIndex);
333
+ return `${prefix}, open '${resolvedPath}'`;
334
+ }
335
+ function resolveCanonicalResourcePath(skillPath, resourcePath) {
392
336
  if (path.isAbsolute(resourcePath)) {
393
- return { success: false, error: "Absolute resource paths are not allowed" };
337
+ return { kind: "access-denied", message: "Absolute resource paths are not allowed" };
394
338
  }
395
- const segments = resourcePath.split(/[/\\]/);
396
- if (segments.some((seg) => seg === "..")) {
397
- return { success: false, error: "Path traversal is not allowed \u2014 resource paths must stay within the skill directory" };
339
+ if (resourcePath.split(/[/\\]/).some((segment) => segment === "..")) {
340
+ return { kind: "access-denied", message: PATH_TRAVERSAL_MESSAGE };
398
341
  }
399
- const resolvedPath = path.resolve(skillPath, resourcePath);
400
342
  const resolvedSkillPath = path.resolve(skillPath);
401
- const rel = path.relative(resolvedSkillPath, resolvedPath);
402
- if (rel.startsWith("..") || path.resolve(resolvedSkillPath, rel) !== resolvedPath) {
403
- return { success: false, error: "Path traversal is not allowed \u2014 resource paths must stay within the skill directory" };
343
+ const resolvedPath = path.resolve(resolvedSkillPath, resourcePath);
344
+ const lexicalRelativePath = path.relative(resolvedSkillPath, resolvedPath);
345
+ if (lexicalRelativePath.startsWith("..") || path.isAbsolute(lexicalRelativePath) || path.resolve(resolvedSkillPath, lexicalRelativePath) !== resolvedPath) {
346
+ return { kind: "access-denied", message: PATH_TRAVERSAL_MESSAGE };
404
347
  }
348
+ let canonicalSkillPath;
405
349
  try {
406
- const realSkillRoot = fs.realpathSync(resolvedSkillPath);
407
- const realTarget = fs.realpathSync(resolvedPath);
408
- const realRel = path.relative(realSkillRoot, realTarget);
409
- if (realRel.startsWith("..") || path.isAbsolute(realRel)) {
410
- return { success: false, error: "Symlink target escapes the skill directory \u2014 access denied" };
350
+ canonicalSkillPath = fs.realpathSync(resolvedSkillPath);
351
+ } catch (error) {
352
+ return { kind: "read-failure", error: formatResourceReadError(error, resolvedPath) };
353
+ }
354
+ let canonicalPath;
355
+ try {
356
+ canonicalPath = fs.realpathSync(resolvedPath);
357
+ } catch (error) {
358
+ if (error.code === "ENOENT") {
359
+ return {
360
+ kind: "resource-not-found",
361
+ resolvedPath,
362
+ error: formatResourceReadError(error, resolvedPath)
363
+ };
411
364
  }
412
- } catch {
365
+ return {
366
+ kind: "target-read-failure",
367
+ resolvedPath,
368
+ error: formatResourceReadError(error, resolvedPath)
369
+ };
413
370
  }
414
- return { success: true, resolvedPath };
371
+ const canonicalRelativePath = path.relative(canonicalSkillPath, canonicalPath);
372
+ if (canonicalRelativePath.startsWith("..") || path.isAbsolute(canonicalRelativePath)) {
373
+ return { kind: "access-denied", message: SYMLINK_ESCAPE_MESSAGE };
374
+ }
375
+ return { kind: "success", resolvedPath, canonicalPath, canonicalRelativePath };
415
376
  }
416
-
417
- // src/activation-resource-tool.ts
418
- function createReadSkillResourceTool(registry) {
419
- return new core.ToolBuilder().name("read-skill-resource").description(
420
- "Read a resource file from an activated Agent Skill. Returns the content of a file within the skill directory (e.g., references/, scripts/, assets/). The path must be relative to the skill root and cannot traverse outside it. SKILL.md is only readable from workspace or trusted roots."
421
- ).category(core.ToolCategory.SKILLS).tags(["skill", "resource", "agent-skills"]).schema(readSkillResourceSchema).implement(async ({ name, path: resourcePath }) => {
422
- const skill = registry.get(name);
377
+ var AgentSkillAccess = class {
378
+ constructor(registry) {
379
+ this.registry = registry;
380
+ }
381
+ async readResource(name, resourcePath) {
382
+ const skill = this.registry.get(name);
423
383
  if (!skill) {
424
- const { errorMessage } = buildMissingSkillMessage(registry, name);
425
- activationLogger.warn("Skill resource load failed \u2014 skill not found", { name, resourcePath });
426
- return errorMessage;
384
+ const availableNames = this.registry.getNames();
385
+ activationLogger.warn("Skill resource load failed \u2014 skill not found", {
386
+ name,
387
+ resourcePath
388
+ });
389
+ return { kind: "skill-not-found", name, availableNames };
427
390
  }
428
- const pathResult = resolveResourcePath(skill.skillPath, resourcePath);
429
- if (!pathResult.success) {
391
+ const pathResult = resolveCanonicalResourcePath(skill.skillPath, resourcePath);
392
+ if (pathResult.kind === "access-denied") {
430
393
  activationLogger.warn("Skill resource load blocked \u2014 path traversal", {
394
+ name,
395
+ resourcePath,
396
+ error: pathResult.message
397
+ });
398
+ return pathResult;
399
+ }
400
+ if (pathResult.kind === "read-failure") {
401
+ activationLogger.warn("Skill resource load failed \u2014 file not found or unreadable", {
431
402
  name,
432
403
  resourcePath,
433
404
  error: pathResult.error
434
405
  });
435
- return pathResult.error;
406
+ return { ...pathResult, name, resourcePath };
436
407
  }
437
408
  const skillInstructionsPath = path.resolve(skill.skillPath, "SKILL.md");
438
409
  let isSkillInstructions = pathResult.resolvedPath.toLowerCase() === skillInstructionsPath.toLowerCase();
439
- if (!isSkillInstructions) {
410
+ if (!isSkillInstructions && pathResult.kind === "success") {
440
411
  try {
441
- isSkillInstructions = fs.realpathSync(pathResult.resolvedPath).toLowerCase() === fs.realpathSync(skillInstructionsPath).toLowerCase();
412
+ isSkillInstructions = pathResult.canonicalPath.toLowerCase() === fs.realpathSync(skillInstructionsPath).toLowerCase();
442
413
  } catch {
443
414
  }
444
415
  }
@@ -452,20 +423,24 @@ function createReadSkillResourceTool(registry) {
452
423
  reason: activationDecision.reason,
453
424
  message: activationDecision.message
454
425
  });
455
- registry.emitEvent("trust:policy-denied" /* TRUST_POLICY_DENIED */, {
426
+ this.registry.emitEvent("trust:policy-denied" /* TRUST_POLICY_DENIED */, {
456
427
  name: skill.metadata.name,
457
428
  resourcePath: "SKILL.md",
458
429
  trustLevel: skill.trustLevel,
459
430
  reason: activationDecision.reason,
460
431
  message: activationDecision.message
461
432
  });
462
- return activationDecision.message;
433
+ return {
434
+ kind: "access-denied",
435
+ reason: activationDecision.reason,
436
+ message: activationDecision.message
437
+ };
463
438
  }
464
439
  }
465
440
  const policyDecision = evaluateTrustPolicy(
466
- resourcePath,
441
+ pathResult.kind === "success" ? pathResult.canonicalRelativePath : resourcePath,
467
442
  skill.trustLevel,
468
- registry.getAllowUntrustedScripts()
443
+ this.registry.getAllowUntrustedScripts()
469
444
  );
470
445
  if (!policyDecision.allowed) {
471
446
  activationLogger.warn("Skill resource load blocked \u2014 trust policy", {
@@ -475,14 +450,18 @@ function createReadSkillResourceTool(registry) {
475
450
  reason: policyDecision.reason,
476
451
  message: policyDecision.message
477
452
  });
478
- registry.emitEvent("trust:policy-denied" /* TRUST_POLICY_DENIED */, {
453
+ this.registry.emitEvent("trust:policy-denied" /* TRUST_POLICY_DENIED */, {
479
454
  name: skill.metadata.name,
480
455
  resourcePath,
481
456
  trustLevel: skill.trustLevel,
482
457
  reason: policyDecision.reason,
483
458
  message: policyDecision.message
484
459
  });
485
- return policyDecision.message;
460
+ return {
461
+ kind: "access-denied",
462
+ reason: policyDecision.reason,
463
+ message: policyDecision.message
464
+ };
486
465
  }
487
466
  if (policyDecision.reason !== "not-script" /* NOT_SCRIPT */) {
488
467
  activationLogger.info("Skill resource trust policy \u2014 allowed", {
@@ -491,28 +470,29 @@ function createReadSkillResourceTool(registry) {
491
470
  trustLevel: skill.trustLevel,
492
471
  reason: policyDecision.reason
493
472
  });
494
- registry.emitEvent("trust:policy-allowed" /* TRUST_POLICY_ALLOWED */, {
473
+ this.registry.emitEvent("trust:policy-allowed" /* TRUST_POLICY_ALLOWED */, {
495
474
  name: skill.metadata.name,
496
475
  resourcePath,
497
476
  trustLevel: skill.trustLevel,
498
477
  reason: policyDecision.reason
499
478
  });
500
479
  }
501
- try {
502
- const content = fs.readFileSync(pathResult.resolvedPath, "utf-8");
503
- activationLogger.info("Skill resource loaded", {
504
- name: skill.metadata.name,
480
+ if (pathResult.kind === "resource-not-found" || pathResult.kind === "target-read-failure") {
481
+ activationLogger.warn("Skill resource load failed \u2014 file not found or unreadable", {
482
+ name,
505
483
  resourcePath,
506
- resolvedPath: pathResult.resolvedPath,
507
- contentLength: content.length
484
+ error: pathResult.error
508
485
  });
509
- registry.emitEvent("skill:resource-loaded" /* SKILL_RESOURCE_LOADED */, {
510
- name: skill.metadata.name,
486
+ return {
487
+ kind: pathResult.kind === "resource-not-found" ? "resource-not-found" : "read-failure",
488
+ name,
511
489
  resourcePath,
512
- resolvedPath: pathResult.resolvedPath,
513
- contentLength: content.length
514
- });
515
- return content;
490
+ error: pathResult.error
491
+ };
492
+ }
493
+ let content;
494
+ try {
495
+ content = fs.readFileSync(pathResult.canonicalPath, "utf-8");
516
496
  } catch (error) {
517
497
  const message = error instanceof Error ? error.message : String(error);
518
498
  activationLogger.warn("Skill resource load failed \u2014 file not found or unreadable", {
@@ -520,10 +500,156 @@ function createReadSkillResourceTool(registry) {
520
500
  resourcePath,
521
501
  error: message
522
502
  });
523
- return `Failed to read resource "${resourcePath}" from skill "${name}": ${message}`;
503
+ if (error.code === "ENOENT") {
504
+ return { kind: "resource-not-found", name, resourcePath, error: message };
505
+ }
506
+ return { kind: "read-failure", name, resourcePath, error: message };
507
+ }
508
+ activationLogger.info("Skill resource loaded", {
509
+ name: skill.metadata.name,
510
+ resourcePath,
511
+ resolvedPath: pathResult.resolvedPath,
512
+ contentLength: content.length
513
+ });
514
+ this.registry.emitEvent("skill:resource-loaded" /* SKILL_RESOURCE_LOADED */, {
515
+ name: skill.metadata.name,
516
+ resourcePath,
517
+ resolvedPath: pathResult.resolvedPath,
518
+ contentLength: content.length
519
+ });
520
+ return { kind: "success", content };
521
+ }
522
+ async activate(name) {
523
+ const skill = this.registry.get(name);
524
+ if (!skill) {
525
+ const availableNames = this.registry.getNames();
526
+ activationLogger.warn("Skill activation failed \u2014 not found", {
527
+ name,
528
+ availableCount: availableNames.length
529
+ });
530
+ return { kind: "skill-not-found", name, availableNames };
531
+ }
532
+ const policyDecision = evaluateSkillActivationPolicy(skill.trustLevel);
533
+ if (!policyDecision.allowed) {
534
+ activationLogger.warn("Skill activation blocked \u2014 trust policy", {
535
+ name,
536
+ trustLevel: skill.trustLevel,
537
+ reason: policyDecision.reason,
538
+ message: policyDecision.message
539
+ });
540
+ this.registry.emitEvent("trust:policy-denied" /* TRUST_POLICY_DENIED */, {
541
+ name: skill.metadata.name,
542
+ resourcePath: "SKILL.md",
543
+ trustLevel: skill.trustLevel,
544
+ reason: policyDecision.reason,
545
+ message: policyDecision.message
546
+ });
547
+ return {
548
+ kind: "access-denied",
549
+ reason: policyDecision.reason,
550
+ message: policyDecision.message
551
+ };
552
+ }
553
+ let body;
554
+ try {
555
+ const content = fs.readFileSync(path.resolve(skill.skillPath, "SKILL.md"), "utf-8");
556
+ body = extractBody(content);
557
+ } catch (error) {
558
+ const message = error instanceof Error ? error.message : String(error);
559
+ activationLogger.error("Skill activation failed \u2014 read error", {
560
+ name,
561
+ skillPath: skill.skillPath,
562
+ error: message
563
+ });
564
+ return { kind: "read-failure", name, error: message };
565
+ }
566
+ activationLogger.info("Skill activated", {
567
+ name: skill.metadata.name,
568
+ skillPath: skill.skillPath,
569
+ bodyLength: body.length,
570
+ trustLevel: skill.trustLevel,
571
+ activationReason: policyDecision.reason
572
+ });
573
+ this.registry.emitEvent("skill:activated" /* SKILL_ACTIVATED */, {
574
+ name: skill.metadata.name,
575
+ skillPath: skill.skillPath,
576
+ bodyLength: body.length,
577
+ trustLevel: skill.trustLevel
578
+ });
579
+ return { kind: "success", body };
580
+ }
581
+ };
582
+ var activateSkillSchema = zod.z.object({
583
+ name: zod.z.string().describe('The name of the skill to activate (e.g., "code-review")')
584
+ });
585
+ var readSkillResourceSchema = zod.z.object({
586
+ name: zod.z.string().describe("The name of the skill that owns the resource"),
587
+ path: zod.z.string().describe('Relative path to the resource file within the skill directory (e.g., "references/GUIDE.md", "scripts/setup.sh")')
588
+ });
589
+
590
+ // src/activation-activate-tool.ts
591
+ function createActivateSkillTool(registry) {
592
+ const access = new AgentSkillAccess(registry);
593
+ return new core.ToolBuilder().name("activate-skill").description(
594
+ "Activate an Agent Skill by name, loading its full instructions for trusted roots. Returns the complete SKILL.md body content for workspace or explicitly trusted skills. Use this when you see a relevant skill in <available_skills> or <untrusted_skills>; activation is blocked for untrusted roots until they are promoted."
595
+ ).category(core.ToolCategory.SKILLS).tags(["skill", "activation", "agent-skills"]).schema(activateSkillSchema).implement(async ({ name }) => {
596
+ const result = await access.activate(name);
597
+ switch (result.kind) {
598
+ case "success":
599
+ return result.body;
600
+ case "skill-not-found":
601
+ return formatMissingSkillMessage(result.name, result.availableNames);
602
+ case "access-denied":
603
+ return result.message;
604
+ case "read-failure":
605
+ return `Failed to read skill "${result.name}" instructions: ${result.error}`;
606
+ }
607
+ }).build();
608
+ }
609
+ function createReadSkillResourceTool(registry) {
610
+ const access = new AgentSkillAccess(registry);
611
+ return new core.ToolBuilder().name("read-skill-resource").description(
612
+ "Read a resource file from an activated Agent Skill. Returns the content of a file within the skill directory (e.g., references/, scripts/, assets/). The path must be relative to the skill root and cannot traverse outside it. SKILL.md is only readable from workspace or trusted roots."
613
+ ).category(core.ToolCategory.SKILLS).tags(["skill", "resource", "agent-skills"]).schema(readSkillResourceSchema).implement(async ({ name, path: resourcePath }) => {
614
+ const result = await access.readResource(name, resourcePath);
615
+ switch (result.kind) {
616
+ case "success":
617
+ return result.content;
618
+ case "skill-not-found":
619
+ return formatMissingSkillMessage(result.name, result.availableNames);
620
+ case "access-denied":
621
+ return result.message;
622
+ case "resource-not-found":
623
+ case "read-failure":
624
+ return `Failed to read resource "${result.resourcePath}" from skill "${result.name}": ${result.error}`;
524
625
  }
525
626
  }).build();
526
627
  }
628
+ function resolveResourcePath(skillPath, resourcePath) {
629
+ if (path.isAbsolute(resourcePath)) {
630
+ return { success: false, error: "Absolute resource paths are not allowed" };
631
+ }
632
+ const segments = resourcePath.split(/[/\\]/);
633
+ if (segments.some((seg) => seg === "..")) {
634
+ return { success: false, error: "Path traversal is not allowed \u2014 resource paths must stay within the skill directory" };
635
+ }
636
+ const resolvedPath = path.resolve(skillPath, resourcePath);
637
+ const resolvedSkillPath = path.resolve(skillPath);
638
+ const rel = path.relative(resolvedSkillPath, resolvedPath);
639
+ if (rel.startsWith("..") || path.resolve(resolvedSkillPath, rel) !== resolvedPath) {
640
+ return { success: false, error: "Path traversal is not allowed \u2014 resource paths must stay within the skill directory" };
641
+ }
642
+ try {
643
+ const realSkillRoot = fs.realpathSync(resolvedSkillPath);
644
+ const realTarget = fs.realpathSync(resolvedPath);
645
+ const realRel = path.relative(realSkillRoot, realTarget);
646
+ if (realRel.startsWith("..") || path.isAbsolute(realRel)) {
647
+ return { success: false, error: "Symlink target escapes the skill directory \u2014 access denied" };
648
+ }
649
+ } catch {
650
+ }
651
+ return { success: true, resolvedPath };
652
+ }
527
653
 
528
654
  // src/activation.ts
529
655
  function createSkillActivationTools(registry) {