.NET: Discover skill resources from directory instead of markdown links (#4401)

* discover resources in skills folder

* address pr review comments

* change type of AllowedResourceExtensions

* address pr review comment
This commit is contained in:
SergeyMenshykh
2026-03-04 18:37:11 +00:00
committed by GitHub
Unverified
parent 4dad26fcae
commit afdd1e539d
4 changed files with 330 additions and 157 deletions
@@ -17,8 +17,9 @@ namespace Microsoft.Agents.AI;
/// </summary>
/// <remarks>
/// Searches directories recursively (up to <see cref="MaxSearchDepth"/> levels) for SKILL.md files.
/// Each file is validated for YAML frontmatter and resource integrity. Invalid skills are excluded
/// with logged warnings. Resource paths are checked against path traversal and symlink escape attacks.
/// Each file is validated for YAML frontmatter. Resource files are discovered by scanning the skill
/// directory for files with matching extensions. Invalid resources are skipped with logged warnings.
/// Resource paths are checked against path traversal and symlink escape attacks.
/// </remarks>
internal sealed partial class FileAgentSkillLoader
{
@@ -33,14 +34,6 @@ internal sealed partial class FileAgentSkillLoader
// Example: "---\nname: foo\n---\nBody" → Group 1: "name: foo\n"
private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
// Matches markdown links to local resource files. Group 1 = relative file path.
// Supports optional ./ or ../ prefixes; excludes URLs (no ":" in the path character class).
// Intentionally conservative: only matches paths with word characters, hyphens, dots,
// and forward slashes. Paths with spaces or special characters are not supported.
// Examples: [doc](refs/FAQ.md) → "refs/FAQ.md", [s](./s.json) → "./s.json",
// [p](../shared/doc.txt) → "../shared/doc.txt"
private static readonly Regex s_resourceLinkRegex = new(@"\[.*?\]\((\.?\.?/?[\w][\w\-./]*\.\w+)\)", RegexOptions.Compiled, TimeSpan.FromSeconds(5));
// Matches YAML "key: value" lines. Group 1 = key, Group 2 = quoted value, Group 3 = unquoted value.
// Accepts single or double quotes; the lazy quantifier trims trailing whitespace on unquoted values.
// Examples: "name: foo" → (name, _, foo), "name: 'foo bar'" → (name, foo bar, _),
@@ -52,14 +45,22 @@ internal sealed partial class FileAgentSkillLoader
private static readonly Regex s_validNameRegex = new(@"^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$", RegexOptions.Compiled);
private readonly ILogger _logger;
private readonly HashSet<string> _allowedResourceExtensions;
/// <summary>
/// Initializes a new instance of the <see cref="FileAgentSkillLoader"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
internal FileAgentSkillLoader(ILogger logger)
/// <param name="allowedResourceExtensions">File extensions to recognize as skill resources. When <see langword="null"/>, defaults are used.</param>
internal FileAgentSkillLoader(ILogger logger, IEnumerable<string>? allowedResourceExtensions = null)
{
this._logger = logger;
ValidateExtensions(allowedResourceExtensions);
this._allowedResourceExtensions = new HashSet<string>(
allowedResourceExtensions ?? [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"],
StringComparer.OrdinalIgnoreCase);
}
/// <summary>
@@ -183,9 +184,9 @@ internal sealed partial class FileAgentSkillLoader
}
}
private FileAgentSkill? ParseSkillFile(string skillDirectoryPath)
private FileAgentSkill? ParseSkillFile(string skillDirectoryFullPath)
{
string skillFilePath = Path.Combine(skillDirectoryPath, SkillFileName);
string skillFilePath = Path.Combine(skillDirectoryFullPath, SkillFileName);
string content = File.ReadAllText(skillFilePath, Encoding.UTF8);
@@ -194,17 +195,12 @@ internal sealed partial class FileAgentSkillLoader
return null;
}
List<string> resourceNames = ExtractResourcePaths(body);
if (!this.ValidateResources(skillDirectoryPath, resourceNames, frontmatter.Name))
{
return null;
}
List<string> resourceNames = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name);
return new FileAgentSkill(
frontmatter: frontmatter,
body: body,
sourcePath: skillDirectoryPath,
sourcePath: skillDirectoryFullPath,
resourceNames: resourceNames);
}
@@ -270,34 +266,84 @@ internal sealed partial class FileAgentSkillLoader
return true;
}
private bool ValidateResources(string skillDirectoryPath, List<string> resourceNames, string skillName)
/// <summary>
/// Scans a skill directory for resource files matching the configured extensions.
/// </summary>
/// <remarks>
/// Recursively walks <paramref name="skillDirectoryFullPath"/> and collects files whose extension
/// matches <see cref="_allowedResourceExtensions"/>, excluding <c>SKILL.md</c> itself. Each candidate
/// is validated against path-traversal and symlink-escape checks; unsafe files are skipped with
/// a warning.
/// </remarks>
private List<string> DiscoverResourceFiles(string skillDirectoryFullPath, string skillName)
{
string normalizedSkillPath = Path.GetFullPath(skillDirectoryPath) + Path.DirectorySeparatorChar;
string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar;
foreach (string resourceName in resourceNames)
var resources = new List<string>();
#if NET
var enumerationOptions = new EnumerationOptions
{
string fullPath = Path.GetFullPath(Path.Combine(skillDirectoryPath, resourceName));
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};
if (!IsPathWithinDirectory(fullPath, normalizedSkillPath))
foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", enumerationOptions))
#else
foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", SearchOption.AllDirectories))
#endif
{
string fileName = Path.GetFileName(filePath);
// Exclude SKILL.md itself
if (string.Equals(fileName, SkillFileName, StringComparison.OrdinalIgnoreCase))
{
LogResourcePathTraversal(this._logger, skillName, resourceName);
return false;
continue;
}
if (!File.Exists(fullPath))
// Filter by extension
string extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension))
{
LogMissingResource(this._logger, skillName, resourceName);
return false;
if (this._logger.IsEnabled(LogLevel.Debug))
{
LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension);
}
continue;
}
if (HasSymlinkInPath(fullPath, normalizedSkillPath))
// Normalize the enumerated path to guard against non-canonical forms
// (redundant separators, 8.3 short names, etc.) that would produce
// malformed relative resource names.
string resolvedFilePath = Path.GetFullPath(filePath);
// Path containment check
if (!IsPathWithinDirectory(resolvedFilePath, normalizedSkillDirectoryFullPath))
{
LogResourceSymlinkEscape(this._logger, skillName, resourceName);
return false;
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Symlink check
if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Compute relative path and normalize to forward slashes
string relativePath = resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length);
resources.Add(NormalizeResourcePath(relativePath));
}
return true;
return resources;
}
/// <summary>
@@ -336,22 +382,6 @@ internal sealed partial class FileAgentSkillLoader
return false;
}
private static List<string> ExtractResourcePaths(string content)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var paths = new List<string>();
foreach (Match m in s_resourceLinkRegex.Matches(content))
{
string path = NormalizeResourcePath(m.Groups[1].Value);
if (seen.Add(path))
{
paths.Add(path);
}
}
return paths;
}
/// <summary>
/// Normalizes a relative resource path by trimming a leading <c>./</c> prefix and replacing
/// backslashes with forward slashes so that <c>./refs/doc.md</c> and <c>refs/doc.md</c> are
@@ -372,6 +402,43 @@ internal sealed partial class FileAgentSkillLoader
return path;
}
/// <summary>
/// Replaces control characters in a file path with '?' to prevent log injection
/// via crafted filenames (e.g., filenames containing newlines on Linux).
/// </summary>
private static string SanitizePathForLog(string path)
{
char[]? chars = null;
for (int i = 0; i < path.Length; i++)
{
if (char.IsControl(path[i]))
{
chars ??= path.ToCharArray();
chars[i] = '?';
}
}
return chars is null ? path : new string(chars);
}
private static void ValidateExtensions(IEnumerable<string>? extensions)
{
if (extensions is null)
{
return;
}
foreach (string ext in extensions)
{
if (string.IsNullOrWhiteSpace(ext) || !ext.StartsWith(".", StringComparison.Ordinal))
{
#pragma warning disable CA2208 // Instantiate argument exceptions correctly
throw new ArgumentException($"Each extension must start with '.'. Invalid value: '{ext}'", nameof(FileAgentSkillsProviderOptions.AllowedResourceExtensions));
#pragma warning restore CA2208 // Instantiate argument exceptions correctly
}
}
}
[LoggerMessage(LogLevel.Information, "Discovered {Count} potential skills")]
private static partial void LogSkillsDiscovered(ILogger logger, int count);
@@ -390,18 +457,18 @@ internal sealed partial class FileAgentSkillLoader
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' has an invalid '{FieldName}' value: {Reason}")]
private static partial void LogInvalidFieldValue(ILogger logger, string skillFilePath, string fieldName, string reason);
[LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': referenced resource '{ResourceName}' does not exist")]
private static partial void LogMissingResource(ILogger logger, string skillName, string resourceName);
[LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': resource '{ResourceName}' references a path outside the skill directory")]
private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourceName);
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' references a path outside the skill directory")]
private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourcePath);
[LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': skill from '{NewPath}' skipped in favor of existing skill from '{ExistingPath}'")]
private static partial void LogDuplicateSkillName(ILogger logger, string skillName, string newPath, string existingPath);
[LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': resource '{ResourceName}' is a symlink that resolves outside the skill directory")]
private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourceName);
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' is a symlink that resolves outside the skill directory")]
private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourcePath);
[LoggerMessage(LogLevel.Information, "Reading resource '{FileName}' from skill '{SkillName}'")]
private static partial void LogResourceReading(ILogger logger, string fileName, string skillName);
[LoggerMessage(LogLevel.Debug, "Skipping file '{FilePath}' in skill '{SkillName}': extension '{Extension}' is not in the allowed list")]
private static partial void LogResourceSkippedExtension(ILogger logger, string skillName, string filePath, string extension);
}
@@ -88,7 +88,7 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<FileAgentSkillsProvider>();
this._loader = new FileAgentSkillLoader(this._logger);
this._loader = new FileAgentSkillLoader(this._logger, options?.AllowedResourceExtensions);
this._skills = this._loader.DiscoverAndLoadSkills(skillPaths);
this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills);
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
@@ -17,4 +18,15 @@ public sealed class FileAgentSkillsProviderOptions
/// When <see langword="null"/>, a default template is used.
/// </summary>
public string? SkillsInstructionPrompt { get; set; }
/// <summary>
/// Gets or sets the file extensions recognized as discoverable skill resources.
/// Each value must start with a <c>'.'</c> character (for example, <c>.md</c>), and
/// extension comparisons are performed in a case-insensitive manner.
/// Files in the skill directory (and its subdirectories) whose extension matches
/// one of these values will be automatically discovered as resources.
/// When <see langword="null"/>, a default set of extensions is used
/// (<c>.md</c>, <c>.json</c>, <c>.yaml</c>, <c>.yml</c>, <c>.csv</c>, <c>.xml</c>, <c>.txt</c>).
/// </summary>
public IEnumerable<string>? AllowedResourceExtensions { get; set; }
}
@@ -169,16 +169,17 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
}
[Fact]
public void DiscoverAndLoadSkills_WithValidResourceLinks_ExtractsResourceNames()
public void DiscoverAndLoadSkills_FilesWithMatchingExtensions_DiscoveredAsResources()
{
// Arrange
// Arrange — create resource files in the skill directory
string skillDir = Path.Combine(this._testRoot, "resource-skill");
string refsDir = Path.Combine(skillDir, "refs");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "FAQ.md"), "FAQ content");
File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: resource-skill\ndescription: Has resources\n---\nSee [FAQ](refs/FAQ.md) for details.");
"---\nname: resource-skill\ndescription: Has resources\n---\nSee docs for details.");
// Act
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
@@ -186,29 +187,176 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Assert
Assert.Single(skills);
var skill = skills["resource-skill"];
Assert.Single(skill.ResourceNames);
Assert.Equal("refs/FAQ.md", skill.ResourceNames[0]);
Assert.Equal(2, skill.ResourceNames.Count);
Assert.Contains(skill.ResourceNames, r => r.Equals("refs/FAQ.md", StringComparison.OrdinalIgnoreCase));
Assert.Contains(skill.ResourceNames, r => r.Equals("refs/data.json", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void DiscoverAndLoadSkills_PathTraversal_ExcludesSkill()
public void DiscoverAndLoadSkills_FilesWithNonMatchingExtensions_NotDiscovered()
{
// Arrange — resource links outside the skill directory
string skillDir = Path.Combine(this._testRoot, "traversal-skill");
// Arrange — create a file with an extension not in the default list
string skillDir = Path.Combine(this._testRoot, "ext-skill");
Directory.CreateDirectory(skillDir);
// Create a file outside the skill dir that the traversal would resolve to
File.WriteAllText(Path.Combine(this._testRoot, "secret.txt"), "secret");
File.WriteAllText(Path.Combine(skillDir, "image.png"), "fake image");
File.WriteAllText(Path.Combine(skillDir, "data.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: traversal-skill\ndescription: Traversal attempt\n---\nSee [doc](../secret.txt).");
"---\nname: ext-skill\ndescription: Extension test\n---\nBody.");
// Act
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
// Assert
Assert.Empty(skills);
Assert.Single(skills);
var skill = skills["ext-skill"];
Assert.Single(skill.ResourceNames);
Assert.Equal("data.json", skill.ResourceNames[0]);
}
[Fact]
public void DiscoverAndLoadSkills_SkillMdFile_NotIncludedAsResource()
{
// Arrange — the SKILL.md file itself should not be in the resource list
string skillDir = Path.Combine(this._testRoot, "selfref-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "notes.md"), "notes");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: selfref-skill\ndescription: Self ref test\n---\nBody.");
// Act
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
// Assert
Assert.Single(skills);
var skill = skills["selfref-skill"];
Assert.Single(skill.ResourceNames);
Assert.Equal("notes.md", skill.ResourceNames[0]);
}
[Fact]
public void DiscoverAndLoadSkills_NestedResourceFiles_Discovered()
{
// Arrange — resource files in nested subdirectories
string skillDir = Path.Combine(this._testRoot, "nested-res-skill");
string deepDir = Path.Combine(skillDir, "level1", "level2");
Directory.CreateDirectory(deepDir);
File.WriteAllText(Path.Combine(deepDir, "deep.md"), "deep content");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: nested-res-skill\ndescription: Nested resources\n---\nBody.");
// Act
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
// Assert
Assert.Single(skills);
var skill = skills["nested-res-skill"];
Assert.Single(skill.ResourceNames);
Assert.Contains(skill.ResourceNames, r => r.Equals("level1/level2/deep.md", StringComparison.OrdinalIgnoreCase));
}
private static readonly string[] s_customExtensions = new[] { ".custom" };
private static readonly string[] s_validExtensions = new[] { ".md", ".json", ".custom" };
private static readonly string[] s_mixedValidInvalidExtensions = new[] { ".md", "json" };
[Fact]
public void DiscoverAndLoadSkills_CustomResourceExtensions_UsedForDiscovery()
{
// Arrange — use a loader with custom extensions
var customLoader = new FileAgentSkillLoader(NullLogger.Instance, s_customExtensions);
string skillDir = Path.Combine(this._testRoot, "custom-ext-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "data.custom"), "custom data");
File.WriteAllText(Path.Combine(skillDir, "data.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: custom-ext-skill\ndescription: Custom extensions\n---\nBody.");
// Act
var skills = customLoader.DiscoverAndLoadSkills(new[] { this._testRoot });
// Assert — only .custom files should be discovered, not .json
Assert.Single(skills);
var skill = skills["custom-ext-skill"];
Assert.Single(skill.ResourceNames);
Assert.Equal("data.custom", skill.ResourceNames[0]);
}
[Theory]
[InlineData("txt")]
[InlineData("")]
[InlineData(" ")]
public void Constructor_InvalidExtension_ThrowsArgumentException(string badExtension)
{
// Arrange & Act & Assert
Assert.Throws<ArgumentException>(() => new FileAgentSkillLoader(NullLogger.Instance, new[] { badExtension }));
}
[Fact]
public void Constructor_NullExtensions_UsesDefaults()
{
// Arrange & Act
var loader = new FileAgentSkillLoader(NullLogger.Instance, null);
string skillDir = this.CreateSkillDirectory("null-ext", "A skill", "Body.");
File.WriteAllText(Path.Combine(skillDir, "notes.md"), "notes");
// Assert — default extensions include .md
var skills = loader.DiscoverAndLoadSkills(new[] { this._testRoot });
Assert.Single(skills["null-ext"].ResourceNames);
}
[Fact]
public void Constructor_ValidExtensions_DoesNotThrow()
{
// Arrange & Act & Assert — should not throw
var loader = new FileAgentSkillLoader(NullLogger.Instance, s_validExtensions);
Assert.NotNull(loader);
}
[Fact]
public void Constructor_MixOfValidAndInvalidExtensions_ThrowsArgumentException()
{
// Arrange & Act & Assert — one bad extension in the list should cause failure
Assert.Throws<ArgumentException>(() => new FileAgentSkillLoader(NullLogger.Instance, s_mixedValidInvalidExtensions));
}
[Fact]
public void DiscoverAndLoadSkills_ResourceInSkillRoot_Discovered()
{
// Arrange — resource file directly in the skill directory (not in a subdirectory)
string skillDir = Path.Combine(this._testRoot, "root-resource-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content");
File.WriteAllText(Path.Combine(skillDir, "config.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: root-resource-skill\ndescription: Root resources\n---\nBody.");
// Act
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
// Assert — both root-level resource files should be discovered
Assert.Single(skills);
var skill = skills["root-resource-skill"];
Assert.Equal(2, skill.ResourceNames.Count);
Assert.Contains(skill.ResourceNames, r => r.Equals("guide.md", StringComparison.OrdinalIgnoreCase));
Assert.Contains(skill.ResourceNames, r => r.Equals("config.json", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void DiscoverAndLoadSkills_NoResourceFiles_ReturnsEmptyResourceNames()
{
// Arrange — skill with no resource files
_ = this.CreateSkillDirectory("no-resources", "A skill", "No resources here.");
// Act
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
// Assert
Assert.Single(skills);
Assert.Empty(skills["no-resources"].ResourceNames);
}
[Fact]
@@ -252,8 +400,11 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
[Fact]
public async Task ReadSkillResourceAsync_ValidResource_ReturnsContentAsync()
{
// Arrange
_ = this.CreateSkillDirectoryWithResource("read-skill", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "Document content here.");
// Arrange — create a skill with a resource file discovered from the directory
string skillDir = this.CreateSkillDirectory("read-skill", "A skill", "See docs for details.");
string refsDir = Path.Combine(skillDir, "refs");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content here.");
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
var skill = skills["read-skill"];
@@ -281,7 +432,10 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
public async Task ReadSkillResourceAsync_PathTraversal_ThrowsInvalidOperationExceptionAsync()
{
// Arrange — skill with a legitimate resource, then try to read a traversal path at read time
_ = this.CreateSkillDirectoryWithResource("traverse-read", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "legit");
string skillDir = this.CreateSkillDirectory("traverse-read", "A skill", "See docs.");
string refsDir = Path.Combine(skillDir, "refs");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "legit");
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
var skill = skills["traverse-read"];
@@ -333,75 +487,14 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
Assert.Empty(skills);
}
[Fact]
public void DiscoverAndLoadSkills_DuplicateResourceLinks_DeduplicatesResources()
{
// Arrange — body references the same resource twice
string skillDir = Path.Combine(this._testRoot, "dedup-skill");
string refsDir = Path.Combine(skillDir, "refs");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "content");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: dedup-skill\ndescription: Dedup test\n---\nSee [doc](refs/doc.md) and [again](refs/doc.md).");
// Act
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
// Assert
Assert.Single(skills);
Assert.Single(skills["dedup-skill"].ResourceNames);
}
[Fact]
public void DiscoverAndLoadSkills_DotSlashPrefix_NormalizesToBarePath()
{
// Arrange — body references a resource with ./ prefix
string skillDir = Path.Combine(this._testRoot, "dotslash-skill");
string refsDir = Path.Combine(skillDir, "refs");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "content");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: dotslash-skill\ndescription: Dot-slash test\n---\nSee [doc](./refs/doc.md).");
// Act
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
// Assert
Assert.Single(skills);
var skill = skills["dotslash-skill"];
Assert.Single(skill.ResourceNames);
Assert.Equal("refs/doc.md", skill.ResourceNames[0]);
}
[Fact]
public void DiscoverAndLoadSkills_DotSlashAndBarePath_DeduplicatesResources()
{
// Arrange — body references the same resource with and without ./ prefix
string skillDir = Path.Combine(this._testRoot, "mixed-prefix-skill");
string refsDir = Path.Combine(skillDir, "refs");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "content");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: mixed-prefix-skill\ndescription: Mixed prefix test\n---\nSee [a](./refs/doc.md) and [b](refs/doc.md).");
// Act
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
// Assert
Assert.Single(skills);
var skill = skills["mixed-prefix-skill"];
Assert.Single(skill.ResourceNames);
Assert.Equal("refs/doc.md", skill.ResourceNames[0]);
}
[Fact]
public async Task ReadSkillResourceAsync_DotSlashPrefix_MatchesNormalizedResourceAsync()
{
// Arrange — skill loaded with bare path, caller uses ./ prefix
_ = this.CreateSkillDirectoryWithResource("dotslash-read", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "Document content.");
string skillDir = this.CreateSkillDirectory("dotslash-read", "A skill", "See docs.");
string refsDir = Path.Combine(skillDir, "refs");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content.");
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
var skill = skills["dotslash-read"];
@@ -416,7 +509,10 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
public async Task ReadSkillResourceAsync_BackslashSeparator_MatchesNormalizedResourceAsync()
{
// Arrange — skill loaded with forward-slash path, caller uses backslashes
_ = this.CreateSkillDirectoryWithResource("backslash-read", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "Backslash content.");
string skillDir = this.CreateSkillDirectory("backslash-read", "A skill", "See docs.");
string refsDir = Path.Combine(skillDir, "refs");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Backslash content.");
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
var skill = skills["backslash-read"];
@@ -431,7 +527,10 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
public async Task ReadSkillResourceAsync_DotSlashWithBackslash_MatchesNormalizedResourceAsync()
{
// Arrange — skill loaded with forward-slash path, caller uses .\ prefix with backslashes
_ = this.CreateSkillDirectoryWithResource("mixed-sep-read", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "Mixed separator content.");
string skillDir = this.CreateSkillDirectory("mixed-sep-read", "A skill", "See docs.");
string refsDir = Path.Combine(skillDir, "refs");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Mixed separator content.");
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
var skill = skills["mixed-sep-read"];
@@ -443,14 +542,13 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
}
#if NET
private static readonly string[] s_symlinkResource = ["refs/data.md"];
[Fact]
public void DiscoverAndLoadSkills_SymlinkInPath_ExcludesSkill()
public void DiscoverAndLoadSkills_SymlinkInPath_SkipsSymlinkedResources()
{
// Arrange — a "refs" subdirectory is a symlink pointing outside the skill directory
string skillDir = Path.Combine(this._testRoot, "symlink-escape-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "legit.md"), "legit content");
string outsideDir = Path.Combine(this._testRoot, "outside");
Directory.CreateDirectory(outsideDir);
@@ -469,15 +567,20 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: symlink-escape-skill\ndescription: Symlinked directory escape\n---\nSee [doc](refs/secret.md).");
"---\nname: symlink-escape-skill\ndescription: Symlinked directory escape\n---\nBody.");
// Act
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
// Assert — skill should be excluded because refs/ is a symlink (reparse point)
Assert.False(skills.ContainsKey("symlink-escape-skill"));
// Assert — skill should still load, but symlinked resources should be excluded
Assert.True(skills.ContainsKey("symlink-escape-skill"));
var skill = skills["symlink-escape-skill"];
Assert.Single(skill.ResourceNames);
Assert.Equal("legit.md", skill.ResourceNames[0]);
}
private static readonly string[] s_symlinkResource = ["refs/data.md"];
[Fact]
public async Task ReadSkillResourceAsync_SymlinkInPath_ThrowsInvalidOperationExceptionAsync()
{
@@ -549,13 +652,4 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), rawContent);
return skillDir;
}
private string CreateSkillDirectoryWithResource(string name, string description, string body, string resourceRelativePath, string resourceContent)
{
string skillDir = this.CreateSkillDirectory(name, description, body);
string resourcePath = Path.Combine(skillDir, resourceRelativePath);
Directory.CreateDirectory(Path.GetDirectoryName(resourcePath)!);
File.WriteAllText(resourcePath, resourceContent);
return skillDir;
}
}