.NET: Align skill folder discovery with spec (#5078)

* add class-based skills

* address formating issues

* Remove generated filtered-unit.slnx and add to .gitignore

The filtered solution file is generated dynamically by
eng/scripts/New-FilteredSolution.ps1 during CI. Checking it in
risks it becoming stale and out-of-sync with the real solution.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Remove generated filtered-unit.slnx and add to .gitignore

The filtered solution file is generated dynamically by
eng/scripts/New-FilteredSolution.ps1 during CI. Checking it in
risks it becoming stale and out-of-sync with the real solution.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* discover scripts and resource from folders defined in spec

* Remove Step05 and Step06 DI skill samples

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* address review comments

* fix build error

* Fix mixed path separators in skill folder discovery on .NET Framework

Path.Combine with forward-slash folder names (e.g. "scripts/f1") produces
mixed separators on Windows, causing the StartsWith containment check to
fail against Path.GetFullPath-resolved file paths. Wrap in Path.GetFullPath
to canonicalize separators before the containment comparison.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* address comment

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
SergeyMenshykh
2026-04-07 13:15:46 +01:00
committed by GitHub
Unverified
parent 090b88a956
commit d73c06fa8c
4 changed files with 829 additions and 153 deletions
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
@@ -31,9 +32,16 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
private const string SkillFileName = "SKILL.md";
private const int MaxSearchDepth = 2;
// "." means the skill directory root itself (no sub-folder descent constraint)
private const string RootFolderIndicator = ".";
private static readonly string[] s_defaultScriptExtensions = [".py", ".js", ".sh", ".ps1", ".cs", ".csx"];
private static readonly string[] s_defaultResourceExtensions = [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"];
// Standard sub-folder names per https://agentskills.io/specification#directory-structure
private static readonly string[] s_defaultScriptFolders = ["scripts"];
private static readonly string[] s_defaultResourceFolders = ["references", "assets"];
// Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters.
// Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block.
// The \uFEFF? prefix allows an optional UTF-8 BOM that some editors prepend.
@@ -55,6 +63,8 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
private readonly IEnumerable<string> _skillPaths;
private readonly HashSet<string> _allowedResourceExtensions;
private readonly HashSet<string> _allowedScriptExtensions;
private readonly IReadOnlyList<string> _scriptFolders;
private readonly IReadOnlyList<string> _resourceFolders;
private readonly AgentFileSkillScriptRunner? _scriptRunner;
private readonly ILogger _logger;
@@ -88,22 +98,28 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
ILoggerFactory? loggerFactory = null)
{
this._skillPaths = Throw.IfNull(skillPaths);
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<AgentFileSkillsSource>();
var resolvedOptions = options ?? new AgentFileSkillsSourceOptions();
ValidateExtensions(resolvedOptions.AllowedResourceExtensions);
ValidateExtensions(resolvedOptions.AllowedScriptExtensions);
ValidateExtensions(options?.AllowedResourceExtensions);
ValidateExtensions(options?.AllowedScriptExtensions);
this._allowedResourceExtensions = new HashSet<string>(
resolvedOptions.AllowedResourceExtensions ?? s_defaultResourceExtensions,
options?.AllowedResourceExtensions ?? s_defaultResourceExtensions,
StringComparer.OrdinalIgnoreCase);
this._allowedScriptExtensions = new HashSet<string>(
resolvedOptions.AllowedScriptExtensions ?? s_defaultScriptExtensions,
options?.AllowedScriptExtensions ?? s_defaultScriptExtensions,
StringComparer.OrdinalIgnoreCase);
this._scriptFolders = options?.ScriptFolders is not null
? [.. ValidateAndNormalizeFolderNames(options.ScriptFolders, this._logger)]
: s_defaultScriptFolders;
this._resourceFolders = options?.ResourceFolders is not null
? [.. ValidateAndNormalizeFolderNames(options.ResourceFolders, this._logger)]
: s_defaultResourceFolders;
this._scriptRunner = scriptRunner;
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<AgentFileSkillsSource>();
}
/// <inheritdoc/>
@@ -179,8 +195,13 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
return null;
}
var resources = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name);
var scripts = this.DiscoverScriptFiles(skillDirectoryFullPath, frontmatter.Name);
// Append a trailing separator so path-containment checks don't false-match
// sibling directories. e.g. "/skills/myskill" matches "/skills/myskill-evil/",
// but "/skills/myskill/" does not.
string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar;
var resources = this.DiscoverResourceFiles(normalizedSkillDirectoryFullPath, frontmatter.Name);
var scripts = this.DiscoverScriptFiles(normalizedSkillDirectoryFullPath, frontmatter.Name);
return new AgentFileSkill(
frontmatter: frontmatter,
@@ -282,147 +303,213 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
/// <summary>
/// Scans a skill directory for resource files matching the configured extensions.
/// Scans configured resource folders within a skill directory for resource files matching the configured extensions.
/// </summary>
/// <remarks>
/// Recursively walks <paramref name="skillDirectoryFullPath"/> and collects files whose extension
/// matches the allowed set, excluding <c>SKILL.md</c> itself. Each candidate
/// is validated against path-traversal and symlink-escape checks; unsafe files are skipped with
/// a warning.
/// By default, scans <c>references/</c> and <c>assets/</c> sub-folders as specified by the
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>.
/// Configure <see cref="AgentFileSkillsSourceOptions.ResourceFolders"/> to scan different or
/// additional directories, including <c>"."</c> for the skill root itself.
/// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped.
/// </remarks>
private List<AgentFileSkillResource> DiscoverResourceFiles(string skillDirectoryFullPath, string skillName)
{
string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar;
var resources = new List<AgentFileSkillResource>();
foreach (string folder in this._resourceFolders.Distinct(StringComparer.OrdinalIgnoreCase))
{
bool isRootFolder = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal);
// GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1")
string targetDirectory = isRootFolder
? skillDirectoryFullPath
: Path.GetFullPath(Path.Combine(skillDirectoryFullPath, folder)) + Path.DirectorySeparatorChar;
if (!Directory.Exists(targetDirectory))
{
continue;
}
// Directory-level symlink check: skip if targetDirectory (or any intermediate
// segment) is a reparse point. The root folder is excluded — it's a caller-supplied
// trusted path, and the security boundary guards files within it, not the path itself.
if (!isRootFolder && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourceSymlinkFolder(this._logger, skillName, SanitizePathForLog(folder));
}
continue;
}
#if NET
var enumerationOptions = new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};
var enumerationOptions = new EnumerationOptions
{
RecurseSubdirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};
foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", enumerationOptions))
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
#else
foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", SearchOption.AllDirectories))
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly))
#endif
{
string fileName = Path.GetFileName(filePath);
// Exclude SKILL.md itself
if (string.Equals(fileName, SkillFileName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
string fileName = Path.GetFileName(filePath);
// Filter by extension
string extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension))
{
if (this._logger.IsEnabled(LogLevel.Debug))
// Exclude SKILL.md itself
if (string.Equals(fileName, SkillFileName, StringComparison.OrdinalIgnoreCase))
{
LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension);
continue;
}
continue;
}
// Normalize the enumerated path to guard against non-canonical forms
string resolvedFilePath = Path.GetFullPath(filePath);
// Path containment check
if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
{
if (this._logger.IsEnabled(LogLevel.Warning))
// Filter by extension
string extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension))
{
LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
if (this._logger.IsEnabled(LogLevel.Debug))
{
LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension);
}
continue;
}
continue;
}
// Normalize the enumerated path to guard against non-canonical forms.
// e.g. "references/../../../etc/shadow" → "/etc/shadow"
string resolvedFilePath = Path.GetFullPath(filePath);
// Symlink check
if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
// Path containment: reject if the resolved path escapes the target folder.
// e.g. "/etc/shadow".StartsWith("/skills/myskill/references/") → false → skip
if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase))
{
LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
continue;
}
// Per-file symlink check: detects if the file (or any intermediate segment)
// is a reparse point. e.g. "references/secret.md" → symlink to "/etc/shadow"
if (HasSymlinkInPath(resolvedFilePath, targetDirectory))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
}
// Compute relative path and normalize to forward slashes
string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length));
resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath));
continue;
}
// Compute relative path and normalize separators.
// e.g. "/skills/myskill/references/guide.md" → "references/guide.md"
string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length));
resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath));
}
}
return resources;
}
/// <summary>
/// Scans a skill directory for script files matching the configured extensions.
/// Scans configured script folders within a skill directory for script files matching the configured extensions.
/// </summary>
/// <remarks>
/// Recursively walks the skill directory and collects files whose extension
/// matches the allowed set. Each candidate is validated against path-traversal
/// and symlink-escape checks; unsafe files are skipped with a warning.
/// By default, scans the <c>scripts/</c> sub-folder as specified by the
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>.
/// Configure <see cref="AgentFileSkillsSourceOptions.ScriptFolders"/> to scan different or
/// additional directories, including <c>"."</c> for the skill root itself.
/// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped.
/// </remarks>
private List<AgentFileSkillScript> DiscoverScriptFiles(string skillDirectoryFullPath, string skillName)
{
string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar;
var scripts = new List<AgentFileSkillScript>();
foreach (string folder in this._scriptFolders.Distinct(StringComparer.OrdinalIgnoreCase))
{
bool isRootFolder = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal);
// GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1")
string targetDirectory = isRootFolder
? skillDirectoryFullPath
: Path.GetFullPath(Path.Combine(skillDirectoryFullPath, folder)) + Path.DirectorySeparatorChar;
if (!Directory.Exists(targetDirectory))
{
continue;
}
// Directory-level symlink check: skip if targetDirectory (or any intermediate
// segment) is a reparse point. The root folder is excluded — it's a caller-supplied
// trusted path, and the security boundary guards files within it, not the path itself.
if (!isRootFolder && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogScriptSymlinkFolder(this._logger, skillName, SanitizePathForLog(folder));
}
continue;
}
#if NET
var enumerationOptions = new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};
var enumerationOptions = new EnumerationOptions
{
RecurseSubdirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};
foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", enumerationOptions))
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
#else
foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", SearchOption.AllDirectories))
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly))
#endif
{
// Filter by extension
string extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension))
{
continue;
}
// Normalize the enumerated path to guard against non-canonical forms
string resolvedFilePath = Path.GetFullPath(filePath);
// Path containment check
if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
{
if (this._logger.IsEnabled(LogLevel.Warning))
// Filter by extension
string extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension))
{
LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
continue;
}
continue;
}
// Normalize the enumerated path to guard against non-canonical forms.
// e.g. "scripts/../../../etc/shadow" → "/etc/shadow"
string resolvedFilePath = Path.GetFullPath(filePath);
// Symlink check
if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
// Path containment: reject if the resolved path escapes the target folder.
// e.g. "/etc/shadow".StartsWith("/skills/myskill/scripts/") → false → skip
if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase))
{
LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
continue;
}
// Per-file symlink check: detects if the file (or any intermediate segment)
// is a reparse point. e.g. "scripts/run.py" → symlink to "/etc/shadow"
if (HasSymlinkInPath(resolvedFilePath, targetDirectory))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
}
// Compute relative path and normalize to forward slashes
string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length));
scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner));
continue;
}
// Compute relative path and normalize separators.
// e.g. "/skills/myskill/scripts/parsepdf.py" → "scripts/parsepdf.py"
string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length));
scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner));
}
}
return scripts;
@@ -431,14 +518,14 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
/// <summary>
/// Checks whether any segment in the path (relative to the directory) is a symlink.
/// </summary>
private static bool HasSymlinkInPath(string fullPath, string normalizedDirectoryPath)
private static bool HasSymlinkInPath(string pathToCheck, string trustedBasePath)
{
string relativePath = fullPath.Substring(normalizedDirectoryPath.Length);
string relativePath = pathToCheck.Substring(trustedBasePath.Length);
string[] segments = relativePath.Split(
new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
[Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
StringSplitOptions.RemoveEmptyEntries);
string currentPath = normalizedDirectoryPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
string currentPath = trustedBasePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
foreach (string segment in segments)
{
@@ -454,21 +541,28 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
/// <summary>
/// Normalizes a relative path by replacing backslashes with forward slashes
/// and trimming a leading "./" prefix.
/// Normalizes a relative path or folder name by stripping a leading "./"/".\",
/// trimming trailing directory separators, and replacing backslashes with forward
/// slashes.
/// </summary>
private static string NormalizePath(string path)
{
// Strip leading "./" or ".\"
if (path.StartsWith("./", StringComparison.Ordinal) ||
path.StartsWith(".\\", StringComparison.Ordinal))
{
path = path.Substring(2);
}
// Trim trailing directory separators
path = path.TrimEnd('/', '\\');
// Normalize all separators to forward slashes
if (path.IndexOf('\\') >= 0)
{
path = path.Replace('\\', '/');
}
if (path.StartsWith("./", StringComparison.Ordinal))
{
path = path.Substring(2);
}
return path;
}
@@ -508,6 +602,46 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
}
private static IEnumerable<string> ValidateAndNormalizeFolderNames(IEnumerable<string> folders, ILogger logger)
{
foreach (string folder in folders)
{
if (string.IsNullOrWhiteSpace(folder))
{
throw new ArgumentException("Folder names must not be null or whitespace.", nameof(folders));
}
// "." is valid — it means the skill root directory.
if (string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal))
{
yield return folder;
continue;
}
// Reject absolute paths and any path segments that escape upward.
if (Path.IsPathRooted(folder) || ContainsParentTraversalSegment(folder))
{
LogFolderNameSkippedInvalid(logger, folder);
continue;
}
yield return NormalizePath(folder);
}
}
private static bool ContainsParentTraversalSegment(string folder)
{
foreach (string segment in folder.Split('/', '\\'))
{
if (segment == "..")
{
return true;
}
}
return false;
}
[LoggerMessage(LogLevel.Information, "Discovered {Count} potential skills")]
private static partial void LogSkillsDiscovered(ILogger logger, int count);
@@ -532,6 +666,9 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
[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.Warning, "Skipping resource folder '{FolderName}' in skill '{SkillName}': folder path contains a symlink")]
private static partial void LogResourceSymlinkFolder(ILogger logger, string skillName, string folderName);
[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);
@@ -540,4 +677,10 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
[LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' is a symlink that resolves outside the skill directory")]
private static partial void LogScriptSymlinkEscape(ILogger logger, string skillName, string scriptPath);
[LoggerMessage(LogLevel.Warning, "Skipping script folder '{FolderName}' in skill '{SkillName}': folder path contains a symlink")]
private static partial void LogScriptSymlinkFolder(ILogger logger, string skillName, string folderName);
[LoggerMessage(LogLevel.Warning, "Skipping invalid folder name '{FolderName}': must be a relative path with no '..' segments")]
private static partial void LogFolderNameSkippedInvalid(ILogger logger, string folderName);
}
@@ -30,4 +30,30 @@ public sealed class AgentFileSkillsSourceOptions
/// <c>.ps1</c>, <c>.cs</c>, <c>.csx</c>.
/// </summary>
public IEnumerable<string>? AllowedScriptExtensions { get; set; }
/// <summary>
/// Gets or sets relative folder paths to scan for script files within each skill directory.
/// Values may be single-segment names (e.g., <c>"scripts"</c>) or multi-segment relative
/// paths (e.g., <c>"sub/scripts"</c>). Use <c>"."</c> to include files directly at the
/// skill root. Leading <c>"./"</c> prefixes, trailing separators, and backslashes are
/// normalized automatically; paths containing <c>".."</c> segments or absolute paths are
/// rejected.
/// When <see langword="null"/>, defaults to <c>scripts</c> (per the
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>).
/// When set, replaces the defaults entirely.
/// </summary>
public IEnumerable<string>? ScriptFolders { get; set; }
/// <summary>
/// Gets or sets relative folder paths to scan for resource files within each skill directory.
/// Values may be single-segment names (e.g., <c>"references"</c>) or multi-segment relative
/// paths (e.g., <c>"sub/resources"</c>). Use <c>"."</c> to include files directly at the
/// skill root. Leading <c>"./"</c> prefixes, trailing separators, and backslashes are
/// normalized automatically; paths containing <c>".."</c> segments or absolute paths are
/// rejected.
/// When <see langword="null"/>, defaults to <c>references</c> and <c>assets</c> (per the
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>).
/// When set, replaces the defaults entirely.
/// </summary>
public IEnumerable<string>? ResourceFolders { get; set; }
}
@@ -114,9 +114,10 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreAlsoDiscoveredAsync()
public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreNotDiscoveredAsync()
{
// Arrange — scripts at any depth in the skill directory are discovered
// Arrange — scripts outside configured folders are not discovered; only files directly
// inside the configured folder are picked up (no subdirectory recursion)
string skillDir = CreateSkillDir(this._testRoot, "root-scripts", "Root scripts skill", "Body.");
CreateFile(skillDir, "convert.py", "print('root')");
CreateFile(skillDir, "tools/helper.sh", "echo 'helper'");
@@ -125,12 +126,9 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
// Act
var skills = await source.GetSkillsAsync(CancellationToken.None);
// Assert
// Assert — neither file is in the default scripts/ folder, so no scripts are discovered
Assert.Single(skills);
var scriptNames = skills[0].Scripts!.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList();
Assert.Equal(2, scriptNames.Count);
Assert.Contains("convert.py", scriptNames);
Assert.Contains("tools/helper.sh", scriptNames);
Assert.Empty(skills[0].Scripts!);
}
[Fact]
@@ -230,6 +228,55 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
Assert.Equal(1.60934, capturedArgs["factor"]);
}
[Fact]
public async Task GetSkillsAsync_ScriptFoldersWithNestedPath_DiscoversScriptsAsync()
{
// Arrange — ScriptFolders configured with a multi-segment relative path (f1/f2/f3)
string skillDir = CreateSkillDir(this._testRoot, "nested-script-skill", "Nested script folder", "Body.");
CreateFile(skillDir, "f1/f2/f3/run.py", "print('nested')");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ScriptFolders = ["f1/f2/f3"] });
// Act
var skills = await source.GetSkillsAsync(CancellationToken.None);
// Assert — script file inside the deeply nested folder is discovered
Assert.Single(skills);
Assert.Single(skills[0].Scripts!);
Assert.Equal("f1/f2/f3/run.py", skills[0].Scripts![0].Name);
}
[Theory]
[InlineData("./scripts")]
[InlineData("./scripts/f1")]
[InlineData("./scripts/f1", "./f2")]
public async Task GetSkillsAsync_ScriptFolderWithDotSlashPrefix_DiscoversScriptsAsync(params string[] folders)
{
// Arrange — "./"-prefixed folders are equivalent to their counterparts without the prefix;
// the leading "./" is transparently normalized by Path.GetFullPath during file enumeration.
string skillDir = CreateSkillDir(this._testRoot, "dotslash-script-skill", "Dot-slash prefix", "Body.");
foreach (string folder in folders)
{
string folderWithoutDotSlash = folder.Substring(2); // strip "./"
CreateFile(skillDir, $"{folderWithoutDotSlash}/run.py", "print('dotslash')");
}
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ScriptFolders = folders });
// Act
var skills = await source.GetSkillsAsync(CancellationToken.None);
// Assert — scripts are discovered with names identical to using folders without "./"
Assert.Single(skills);
Assert.Equal(folders.Length, skills[0].Scripts!.Count);
foreach (string folder in folders)
{
string expectedName = $"{folder.Substring(2)}/run.py";
Assert.Contains(skills[0].Scripts!, s => s.Name == expectedName);
}
}
private static string CreateSkillDir(string root, string name, string description, string body)
{
string skillDir = Path.Combine(root, name);
@@ -199,12 +199,14 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
[Fact]
public async Task GetSkillsAsync_FilesWithMatchingExtensions_DiscoveredAsResourcesAsync()
{
// Arrange — create resource files in the skill directory
// Arrange — create resource files in spec-defined sub-folders
string skillDir = Path.Combine(this._testRoot, "resource-skill");
string refsDir = Path.Combine(skillDir, "refs");
string refsDir = Path.Combine(skillDir, "references");
string assetsDir = Path.Combine(skillDir, "assets");
Directory.CreateDirectory(refsDir);
Directory.CreateDirectory(assetsDir);
File.WriteAllText(Path.Combine(refsDir, "FAQ.md"), "FAQ content");
File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
File.WriteAllText(Path.Combine(assetsDir, "data.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: resource-skill\ndescription: Has resources\n---\nSee docs for details.");
@@ -217,18 +219,19 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
Assert.Single(skills);
var skill = skills[0];
Assert.Equal(2, skill.Resources!.Count);
Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/FAQ.md", StringComparison.OrdinalIgnoreCase));
Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/data.json", StringComparison.OrdinalIgnoreCase));
Assert.Contains(skill.Resources!, r => r.Name.Equals("references/FAQ.md", StringComparison.OrdinalIgnoreCase));
Assert.Contains(skill.Resources!, r => r.Name.Equals("assets/data.json", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task GetSkillsAsync_FilesWithNonMatchingExtensions_NotDiscoveredAsync()
{
// Arrange — create a file with an extension not in the default list
// Arrange — create a file with an extension not in the default list inside a spec folder
string skillDir = Path.Combine(this._testRoot, "ext-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "image.png"), "fake image");
File.WriteAllText(Path.Combine(skillDir, "data.json"), "{}");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "image.png"), "fake image");
File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: ext-skill\ndescription: Extension test\n---\nBody.");
@@ -241,7 +244,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
Assert.Single(skills);
var skill = skills[0];
Assert.Single(skill.Resources!);
Assert.Equal("data.json", skill.Resources![0].Name);
Assert.Equal("references/data.json", skill.Resources![0].Name);
}
[Fact]
@@ -249,8 +252,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
{
// 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");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "notes.md"), "notes");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: selfref-skill\ndescription: Self ref test\n---\nBody.");
@@ -263,15 +267,18 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
Assert.Single(skills);
var skill = skills[0];
Assert.Single(skill.Resources!);
Assert.Equal("notes.md", skill.Resources![0].Name);
Assert.Equal("references/notes.md", skill.Resources![0].Name);
}
[Fact]
public async Task GetSkillsAsync_NestedResourceFiles_DiscoveredAsync()
{
// Arrange — resource files in nested subdirectories
// Arrange — resource files directly in references/ are discovered; subdirectories are not scanned
string skillDir = Path.Combine(this._testRoot, "nested-res-skill");
string deepDir = Path.Combine(skillDir, "level1", "level2");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "top.md"), "top content");
string deepDir = Path.Combine(refsDir, "level1", "level2");
Directory.CreateDirectory(deepDir);
File.WriteAllText(Path.Combine(deepDir, "deep.md"), "deep content");
File.WriteAllText(
@@ -282,21 +289,23 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Act
var skills = await source.GetSkillsAsync();
// Assert
// Assert — only the file directly in references/ is discovered; the nested file is not
Assert.Single(skills);
var skill = skills[0];
Assert.Single(skill.Resources!);
Assert.Contains(skill.Resources!, r => r.Name.Equals("level1/level2/deep.md", StringComparison.OrdinalIgnoreCase));
Assert.Contains(skill.Resources!, r => r.Name.Equals("references/top.md", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(skill.Resources!, r => r.Name.Contains("deep.md", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task GetSkillsAsync_CustomResourceExtensions_UsedForDiscoveryAsync()
{
// Arrange — use a source with custom extensions
// Arrange — use a source with custom extensions; files placed in spec folder
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"), "{}");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "data.custom"), "custom data");
File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: custom-ext-skill\ndescription: Custom extensions\n---\nBody.");
@@ -309,7 +318,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
Assert.Single(skills);
var skill = skills[0];
Assert.Single(skill.Resources!);
Assert.Equal("data.custom", skill.Resources![0].Name);
Assert.Equal("references/data.custom", skill.Resources![0].Name);
}
[Theory]
@@ -327,7 +336,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
{
// Arrange & Act
string skillDir = this.CreateSkillDirectory("null-ext", "A skill", "Body.");
File.WriteAllText(Path.Combine(skillDir, "notes.md"), "notes");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "notes.md"), "notes");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Assert — default extensions include .md
@@ -351,9 +362,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredAsync()
public async Task GetSkillsAsync_ResourceInSkillRoot_NotDiscoveredByDefaultAsync()
{
// Arrange — resource file directly in the skill directory (not in a subdirectory)
// Arrange — resource files directly in the skill directory (not in a spec sub-folder)
string skillDir = Path.Combine(this._testRoot, "root-resource-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content");
@@ -366,7 +377,29 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Act
var skills = await source.GetSkillsAsync();
// Assert — both root-level resource files should be discovered
// Assert — root-level files are NOT discovered unless "." is in ResourceFolders
Assert.Single(skills);
Assert.Empty(skills[0].Resources!);
}
[Fact]
public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredWhenRootFolderConfiguredAsync()
{
// Arrange — "." in ResourceFolders opts into root-level resource discovery
string skillDir = Path.Combine(this._testRoot, "root-opt-in-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-opt-in-skill\ndescription: Root opt-in\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFolders = ["references", "assets", "."] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — both root-level resource files (and SKILL.md excluded) should be discovered
Assert.Single(skills);
var skill = skills[0];
Assert.Equal(2, skill.Resources!.Count);
@@ -374,6 +407,54 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
Assert.Contains(skill.Resources!, r => r.Name.Equals("config.json", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task GetSkillsAsync_ResourceInNonSpecFolder_NotDiscoveredByDefaultAsync()
{
// Arrange — resource in a non-spec folder (neither references/ nor assets/)
string skillDir = Path.Combine(this._testRoot, "non-spec-skill");
string customDir = Path.Combine(skillDir, "docs");
Directory.CreateDirectory(customDir);
File.WriteAllText(Path.Combine(customDir, "readme.md"), "docs content");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: non-spec-skill\ndescription: Non-spec folder\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync();
// Assert — non-spec folders are not scanned by default
Assert.Single(skills);
Assert.Empty(skills[0].Resources!);
}
[Fact]
public async Task GetSkillsAsync_CustomResourceFolders_ReplacesDefaultsAsync()
{
// Arrange — custom ResourceFolders replaces the spec defaults
string skillDir = Path.Combine(this._testRoot, "custom-folder-skill");
string customDir = Path.Combine(skillDir, "docs");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(customDir);
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(customDir, "readme.md"), "docs content");
File.WriteAllText(Path.Combine(refsDir, "ref.md"), "ref content");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: custom-folder-skill\ndescription: Custom folder\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFolders = ["docs"] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — only docs/ is scanned; references/ is NOT scanned
Assert.Single(skills);
var skill = skills[0];
Assert.Single(skill.Resources!);
Assert.Equal("docs/readme.md", skill.Resources![0].Name);
}
[Fact]
public async Task GetSkillsAsync_NoResourceFiles_ReturnsEmptyResourcesAsync()
{
@@ -437,14 +518,14 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
[Fact]
public async Task ReadSkillResourceAsync_ValidResource_ReturnsContentAsync()
{
// Arrange — create a skill with a resource file discovered from the directory
// Arrange — create a skill with a resource file discovered from the references folder
string skillDir = this.CreateSkillDirectory("read-skill", "A skill", "See docs for details.");
string refsDir = Path.Combine(skillDir, "refs");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content here.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
var skills = await source.GetSkillsAsync();
var resource = skills[0].Resources!.First(r => r.Name == "refs/doc.md");
var resource = skills[0].Resources!.First(r => r.Name == "references/doc.md");
// Act
var content = await resource.ReadAsync();
@@ -495,16 +576,18 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
[Fact]
public async Task GetSkillsAsync_SymlinkInPath_SkipsSymlinkedResourcesAsync()
{
// Arrange — a "refs" subdirectory is a symlink pointing outside the skill directory
// Arrange — references/ is a symlink pointing outside the skill directory;
// a legitimate file lives in assets/ and should still be discovered.
string skillDir = Path.Combine(this._testRoot, "symlink-escape-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "legit.md"), "legit content");
string assetsDir = Path.Combine(skillDir, "assets");
Directory.CreateDirectory(assetsDir);
File.WriteAllText(Path.Combine(assetsDir, "legit.md"), "legit content");
string outsideDir = Path.Combine(this._testRoot, "outside");
Directory.CreateDirectory(outsideDir);
File.WriteAllText(Path.Combine(outsideDir, "secret.md"), "secret content");
string refsLink = Path.Combine(skillDir, "refs");
string refsLink = Path.Combine(skillDir, "references");
try
{
Directory.CreateSymbolicLink(refsLink, outsideDir);
@@ -523,11 +606,129 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Act
var skills = await source.GetSkillsAsync();
// Assert — skill should still load, but symlinked resources should be excluded
// Assert — skill should still load, the symlinked references/ is skipped, assets/legit.md is found
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-escape-skill");
Assert.NotNull(skill);
Assert.Single(skill.Resources!);
Assert.Equal("legit.md", skill.Resources![0].Name);
Assert.Equal("assets/legit.md", skill.Resources![0].Name);
}
[Fact]
public async Task GetSkillsAsync_SymlinkedResourceFolder_SkipsWithoutEnumeratingAsync()
{
// Arrange — references/ is a symlink pointing outside the skill directory.
// The directory-level check should skip it entirely (no file enumeration),
// so even files with valid extensions in the target are not discovered.
string skillDir = Path.Combine(this._testRoot, "symlink-folder-skip");
string assetsDir = Path.Combine(skillDir, "assets");
Directory.CreateDirectory(assetsDir);
File.WriteAllText(Path.Combine(assetsDir, "legit.md"), "legit content");
string outsideDir = Path.Combine(this._testRoot, "outside-resources");
Directory.CreateDirectory(outsideDir);
File.WriteAllText(Path.Combine(outsideDir, "external.md"), "external content");
File.WriteAllText(Path.Combine(outsideDir, "data.json"), "{}");
string refsLink = Path.Combine(skillDir, "references");
try
{
Directory.CreateSymbolicLink(refsLink, outsideDir);
}
catch (IOException)
{
// Symlink creation requires elevation on some platforms; skip gracefully.
return;
}
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: symlink-folder-skip\ndescription: Symlinked folder skip\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync();
// Assert — only assets/legit.md is found; the symlinked references/ folder is skipped entirely
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-folder-skip");
Assert.NotNull(skill);
Assert.Single(skill.Resources!);
Assert.Equal("assets/legit.md", skill.Resources![0].Name);
}
[Fact]
public async Task GetSkillsAsync_SymlinkedScriptFolder_SkipsWithoutEnumeratingAsync()
{
// Arrange — scripts/ is a symlink pointing outside the skill directory.
// The directory-level check should skip it entirely.
string skillDir = Path.Combine(this._testRoot, "symlink-script-skip");
Directory.CreateDirectory(skillDir);
string outsideDir = Path.Combine(this._testRoot, "outside-scripts");
Directory.CreateDirectory(outsideDir);
File.WriteAllText(Path.Combine(outsideDir, "malicious.py"), "import os; os.system('rm -rf /')");
string scriptsLink = Path.Combine(skillDir, "scripts");
try
{
Directory.CreateSymbolicLink(scriptsLink, outsideDir);
}
catch (IOException)
{
return;
}
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: symlink-script-skip\ndescription: Symlinked script folder\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync();
// Assert — skill loads but scripts from the symlinked folder are not discovered
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-script-skip");
Assert.NotNull(skill);
Assert.Empty(skill.Scripts!);
}
[Fact]
public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsCustomFolderAsync()
{
// Arrange — custom resource folder "sub/resources" where "sub" is a symlink.
// The directory-level HasSymlinkInPath check should detect the intermediate symlink.
string skillDir = Path.Combine(this._testRoot, "symlink-intermediate");
Directory.CreateDirectory(skillDir);
string outsideDir = Path.Combine(this._testRoot, "outside-intermediate");
string outsideResources = Path.Combine(outsideDir, "resources");
Directory.CreateDirectory(outsideResources);
File.WriteAllText(Path.Combine(outsideResources, "data.md"), "data");
string subLink = Path.Combine(skillDir, "sub");
try
{
Directory.CreateSymbolicLink(subLink, outsideDir);
}
catch (IOException)
{
return;
}
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: symlink-intermediate\ndescription: Intermediate symlink\n---\nBody.");
var source = new AgentFileSkillsSource(
this._testRoot,
s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFolders = ["sub/resources"] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — the symlinked intermediate segment causes the folder to be skipped
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-intermediate");
Assert.NotNull(skill);
Assert.Empty(skill.Resources!);
}
#endif
@@ -693,6 +894,170 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
Assert.Null(fm.Metadata);
}
[Theory]
[InlineData("..")]
[InlineData("../escape")]
[InlineData("sub/../escape")]
[InlineData("/absolute")]
[InlineData("\\absolute")]
public void Constructor_InvalidFolderName_SkipsInvalidFolders(string badFolder)
{
// Arrange & Act — invalid folders are skipped with a warning rather than throwing
var source1 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [badFolder] });
var source2 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceFolders = [badFolder] });
// Assert
Assert.NotNull(source1);
Assert.NotNull(source2);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Constructor_NullOrWhitespaceFolderName_ThrowsArgumentException(string? badFolder)
{
// Arrange & Act & Assert — null/whitespace is a contract violation, not a config error
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [badFolder!] }));
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceFolders = [badFolder!] }));
}
[Theory]
[InlineData("scripts")]
[InlineData("my-scripts")]
[InlineData("sub/folder")]
[InlineData(".")]
[InlineData("./scripts")]
[InlineData("./scripts/f1")]
[InlineData("my..scripts")]
public void Constructor_ValidFolderName_DoesNotThrow(string validFolder)
{
// Arrange & Act & Assert
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [validFolder] });
Assert.NotNull(source);
}
[Fact]
public async Task GetSkillsAsync_DuplicateFoldersAfterNormalization_NoDuplicateResourcesAsync()
{
// Arrange — "references" and "./references" refer to the same directory;
// after normalization they should be deduplicated so resources appear only once.
string skillDir = Path.Combine(this._testRoot, "dedup-folder-skill");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "FAQ.md"), "FAQ content");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: dedup-folder-skill\ndescription: Dedup test\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFolders = ["references", "./references"] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — only one copy of the resource despite two equivalent folder entries
Assert.Single(skills);
Assert.Single(skills[0].Resources!);
Assert.Equal("references/FAQ.md", skills[0].Resources![0].Name);
}
[Fact]
public async Task GetSkillsAsync_TrailingSlashFolderNormalized_NoDuplicateResourcesAsync()
{
// Arrange — "references/" should be normalized to "references"
string skillDir = Path.Combine(this._testRoot, "trailing-slash-skill");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: trailing-slash-skill\ndescription: Trailing slash test\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFolders = ["references", "references/"] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — trailing slash variant deduplicated
Assert.Single(skills);
Assert.Single(skills[0].Resources!);
Assert.Equal("references/data.json", skills[0].Resources![0].Name);
}
[Fact]
public async Task GetSkillsAsync_BackslashFolderNormalized_NoDuplicateScriptsAsync()
{
// Arrange — ".\\scripts" should be normalized to "scripts"
string skillDir = Path.Combine(this._testRoot, "backslash-skill");
string scriptsDir = Path.Combine(skillDir, "scripts");
Directory.CreateDirectory(scriptsDir);
File.WriteAllText(Path.Combine(scriptsDir, "run.py"), "print('hello')");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: backslash-skill\ndescription: Backslash test\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ScriptFolders = ["scripts", ".\\scripts"] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — backslash variant deduplicated
Assert.Single(skills);
Assert.Single(skills[0].Scripts!);
Assert.Equal("scripts/run.py", skills[0].Scripts![0].Name);
}
[Theory]
[InlineData("./references")]
[InlineData("./assets/docs")]
public async Task GetSkillsAsync_ResourceFolderWithDotSlashPrefix_DiscoversResourcesAsync(string folder)
{
// Arrange — "./references" and "./assets/docs" are equivalent to "references" and "assets/docs";
// the leading "./" is transparently normalized by Path.GetFullPath during file enumeration.
string folderWithoutDotSlash = folder.Substring(2); // strip "./"
string skillDir = Path.Combine(this._testRoot, "dotslash-res-skill");
string targetDir = Path.Combine(skillDir, folderWithoutDotSlash.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(targetDir);
File.WriteAllText(Path.Combine(targetDir, "data.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: dotslash-res-skill\ndescription: Dot-slash prefix\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFolders = [folder] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — the resource is discovered with a name identical to using the folder without "./"
Assert.Single(skills);
Assert.Single(skills[0].Resources!);
Assert.Equal($"{folderWithoutDotSlash}/data.json", skills[0].Resources![0].Name);
}
[Fact]
public async Task GetSkillsAsync_ResourceFoldersWithNestedPath_DiscoversResourcesAsync()
{
// Arrange — ResourceFolders configured with a multi-segment relative path (f1/f2/f3)
string skillDir = Path.Combine(this._testRoot, "nested-folder-skill");
string nestedDir = Path.Combine(skillDir, "f1", "f2", "f3");
Directory.CreateDirectory(nestedDir);
File.WriteAllText(Path.Combine(nestedDir, "data.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: nested-folder-skill\ndescription: Nested folder\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFolders = ["f1/f2/f3"] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — resource file inside the deeply nested folder is discovered
Assert.Single(skills);
var skill = skills[0];
Assert.Single(skill.Resources!);
Assert.Equal("f1/f2/f3/data.json", skill.Resources![0].Name);
}
private string CreateSkillDirectory(string name, string description, string body)
{
string skillDir = Path.Combine(this._testRoot, name);
@@ -710,4 +1075,99 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), rawContent);
return skillDir;
}
[Theory]
[InlineData("txt")]
[InlineData("")]
[InlineData(" ")]
public void Constructor_InvalidScriptExtension_ThrowsArgumentException(string badExtension)
{
// Arrange & Act & Assert
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(
this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { AllowedScriptExtensions = new string[] { badExtension } }));
}
[Fact]
public async Task GetSkillsAsync_SkillBeyondMaxDepth_NotDiscoveredAsync()
{
// Arrange — create a skill at depth 3 (exceeds MaxSearchDepth = 2)
string deepDir = Path.Combine(this._testRoot, "l1", "l2", "l3", "deep-skill");
Directory.CreateDirectory(deepDir);
File.WriteAllText(
Path.Combine(deepDir, "SKILL.md"),
"---\nname: deep-skill\ndescription: Too deep\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync();
// Assert — skill at depth 3 should not be discovered
Assert.DoesNotContain(skills, s => s.Frontmatter.Name == "deep-skill");
}
[Fact]
public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootFolderConfiguredAsync()
{
// Arrange — script file directly in the skill directory with ScriptFolders = ["."]
string skillDir = Path.Combine(this._testRoot, "root-script-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "run.py"), "print('hello')");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: root-script-skill\ndescription: Root script\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ScriptFolders = ["."] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — script at the skill root should be discovered
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "root-script-skill");
Assert.NotNull(skill);
Assert.Single(skill.Scripts!);
Assert.Equal("run.py", skill.Scripts![0].Name);
}
#if NET
[Fact]
public async Task GetSkillsAsync_SymlinkedFileInRealFolder_SkipsSymlinkedFileAsync()
{
// Arrange — references/ is a real directory, but one file inside it is a symlink
// pointing outside the skill directory. The per-file symlink check should skip it.
string skillDir = Path.Combine(this._testRoot, "symlink-file-skill");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "legit.md"), "legit content");
string outsideDir = Path.Combine(this._testRoot, "outside-file");
Directory.CreateDirectory(outsideDir);
File.WriteAllText(Path.Combine(outsideDir, "secret.md"), "secret content");
string symlinkFile = Path.Combine(refsDir, "leak.md");
try
{
File.CreateSymbolicLink(symlinkFile, Path.Combine(outsideDir, "secret.md"));
}
catch (IOException)
{
// Symlink creation requires elevation on some platforms; skip gracefully.
return;
}
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: symlink-file-skill\ndescription: Symlinked file\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync();
// Assert — only legit.md should be discovered; the symlinked leak.md is skipped
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-file-skill");
Assert.NotNull(skill);
Assert.Single(skill.Resources!);
Assert.Equal("references/legit.md", skill.Resources![0].Name);
}
#endif
}