Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
246 changes: 161 additions & 85 deletions dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
Expand Down Expand Up @@ -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.
Expand All @@ -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;

Expand Down Expand Up @@ -88,6 +98,7 @@ public AgentFileSkillsSource(
ILoggerFactory? loggerFactory = null)
{
this._skillPaths = Throw.IfNull(skillPaths);
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<AgentFileSkillsSource>();

var resolvedOptions = options ?? new AgentFileSkillsSourceOptions();

Expand All @@ -102,8 +113,15 @@ public AgentFileSkillsSource(
resolvedOptions.AllowedScriptExtensions ?? s_defaultScriptExtensions,
StringComparer.OrdinalIgnoreCase);

this._scriptFolders = resolvedOptions.ScriptFolders is not null
? [.. FilterValidFolderNames(resolvedOptions.ScriptFolders, this._logger)]
: s_defaultScriptFolders;

this._resourceFolders = resolvedOptions.ResourceFolders is not null
? [.. FilterValidFolderNames(resolvedOptions.ResourceFolders, this._logger)]
: s_defaultResourceFolders;

this._scriptRunner = scriptRunner;
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<AgentFileSkillsSource>();
}

/// <inheritdoc/>
Expand Down Expand Up @@ -282,147 +300,175 @@ private bool TryParseFrontmatter(string content, string skillFilePath, [NotNullW
}

/// <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>();

#if NET
var enumerationOptions = new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};

foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", enumerationOptions))
#else
foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", SearchOption.AllDirectories))
#endif
foreach (string folder in this._resourceFolders.Distinct(StringComparer.OrdinalIgnoreCase))
{
string fileName = Path.GetFileName(filePath);
string targetDirectory = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal)
? skillDirectoryFullPath
: Path.Combine(skillDirectoryFullPath, folder);

// Exclude SKILL.md itself
if (string.Equals(fileName, SkillFileName, StringComparison.OrdinalIgnoreCase))
if (!Directory.Exists(targetDirectory))
{
continue;
}

// Filter by extension
string extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension))
#if NET
var enumerationOptions = new EnumerationOptions
{
RecurseSubdirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};

foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
Comment thread
SergeyMenshykh marked this conversation as resolved.
#else
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly))
#endif
{
if (this._logger.IsEnabled(LogLevel.Debug))
string fileName = Path.GetFileName(filePath);

// Exclude SKILL.md itself
if (string.Equals(fileName, SkillFileName, StringComparison.OrdinalIgnoreCase))
{
LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension);
continue;
}

continue;
}
// Filter by extension
string extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension))
{
if (this._logger.IsEnabled(LogLevel.Debug))
{
LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension);
}

// Normalize the enumerated path to guard against non-canonical forms
string resolvedFilePath = Path.GetFullPath(filePath);
continue;
}

// Path containment check
if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
{
if (this._logger.IsEnabled(LogLevel.Warning))
// Normalize the enumerated path to guard against non-canonical forms
string resolvedFilePath = Path.GetFullPath(filePath);

// Path containment check
if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
{
LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
}
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
}

continue;
}
continue;
}

// Symlink check
if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
// Symlink check
if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath))
{
LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
}

continue;
}

continue;
}
// Compute relative path and normalize to forward slashes
string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length));

// Compute relative path and normalize to forward slashes
string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length));
resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath));
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>();

#if NET
var enumerationOptions = new EnumerationOptions
foreach (string folder in this._scriptFolders.Distinct(StringComparer.OrdinalIgnoreCase))
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};
string targetDirectory = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal)
? skillDirectoryFullPath
: Path.Combine(skillDirectoryFullPath, folder);

foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", enumerationOptions))
#else
foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", SearchOption.AllDirectories))
#endif
{
// Filter by extension
string extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension))
if (!Directory.Exists(targetDirectory))
{
continue;
}

// Normalize the enumerated path to guard against non-canonical forms
string resolvedFilePath = Path.GetFullPath(filePath);
#if NET
var enumerationOptions = new EnumerationOptions
{
RecurseSubdirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};

// Path containment check
if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
#else
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly))
#endif
{
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
string resolvedFilePath = Path.GetFullPath(filePath);

// Symlink check
if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
// Path containment check
if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
{
LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
}

continue;
}

continue;
}
// Symlink check
if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath))
{
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 to forward slashes
string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length));

scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner));
}
}

return scripts;
Expand Down Expand Up @@ -508,6 +554,33 @@ private static void ValidateExtensions(IEnumerable<string>? extensions)
}
}

private static IEnumerable<string> FilterValidFolderNames(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) || folder.Contains("..", StringComparison.Ordinal))
{
LogFolderNameSkippedInvalid(logger, folder);
continue;
}
Comment thread
SergeyMenshykh marked this conversation as resolved.

yield return folder;
}
Comment thread
SergeyMenshykh marked this conversation as resolved.
Outdated
}

[LoggerMessage(LogLevel.Information, "Discovered {Count} potential skills")]
private static partial void LogSkillsDiscovered(ILogger logger, int count);

Expand Down Expand Up @@ -540,4 +613,7 @@ private static void ValidateExtensions(IEnumerable<string>? extensions)

[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 invalid folder name '{FolderName}': must be a relative path with no '..' segments")]
private static partial void LogFolderNameSkippedInvalid(ILogger logger, string folderName);
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,22 @@ public sealed class AgentFileSkillsSourceOptions
/// <c>.ps1</c>, <c>.cs</c>, <c>.csx</c>.
/// </summary>
public IEnumerable<string>? AllowedScriptExtensions { get; set; }

/// <summary>
/// Gets or sets the sub-folder names to scan for script files, relative to the skill directory.
/// Use <c>"."</c> to include files directly at the skill root.
/// 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; }
Comment thread
rogerbarreto marked this conversation as resolved.

/// <summary>
/// Gets or sets the sub-folder names to scan for resource files, relative to the skill directory.
/// Use <c>"."</c> to include files directly at the skill root.
/// 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>
Comment thread
SergeyMenshykh marked this conversation as resolved.
public IEnumerable<string>? ResourceFolders { get; set; }
}
Loading
Loading