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