mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
090b88a956
commit
d73c06fa8c
@@ -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; }
|
||||
}
|
||||
|
||||
+54
-7
@@ -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);
|
||||
|
||||
+495
-35
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user