mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: [Breaking] Refactor AgentFileSkillsSource for depth-based discovery and predicate filters (#6109)
* Refactor AgentFileSkillsSource to use filter predicates and add AgentFileSkillFilterContext - Replace hardcoded script/resource directory lists with configurable ScriptFilter and ResourceFilter predicates - Add AgentFileSkillFilterContext class to provide contextual file information to filter predicates - Replace MaxSearchDepth constant with configurable SearchDepth option - Update AgentFileSkillsSourceOptions with new filter and search depth properties - Update tests to reflect the new filtering approach Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Log '(none)' instead of empty string for missing file extensions in debug output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides contextual information about a discovered file to the
|
||||
/// <see cref="AgentFileSkillsSourceOptions.ScriptFilter"/> and
|
||||
/// <see cref="AgentFileSkillsSourceOptions.ResourceFilter"/> predicates.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class AgentFileSkillFilterContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentFileSkillFilterContext"/> class.
|
||||
/// </summary>
|
||||
/// <param name="skillName">The name of the skill (from SKILL.md frontmatter).</param>
|
||||
/// <param name="relativeFilePath">
|
||||
/// The path to the script or resource file relative to the skill directory (using forward slashes).
|
||||
/// </param>
|
||||
internal AgentFileSkillFilterContext(string skillName, string relativeFilePath)
|
||||
{
|
||||
this.SkillName = Throw.IfNullOrWhitespace(skillName);
|
||||
this.RelativeFilePath = Throw.IfNullOrWhitespace(relativeFilePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the skill as declared in the SKILL.md frontmatter.
|
||||
/// </summary>
|
||||
/// <example><c>unit-converter</c></example>
|
||||
public string SkillName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the script or resource file relative to the skill directory (using forward slashes).
|
||||
/// For root-level files this is just the filename; for nested files it includes the subdirectory.
|
||||
/// </summary>
|
||||
/// <example>
|
||||
/// <c>run.py</c> for a script at skill root,
|
||||
/// <c>scripts/convert.js</c> for a nested script, or
|
||||
/// <c>references/guide.md</c> for a nested resource.
|
||||
/// </example>
|
||||
public string RelativeFilePath { get; }
|
||||
}
|
||||
@@ -30,18 +30,12 @@ namespace Microsoft.Agents.AI;
|
||||
internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
{
|
||||
private const string SkillFileName = "SKILL.md";
|
||||
private const int MaxSearchDepth = 2;
|
||||
|
||||
// "." means the skill directory root itself (no subdirectory descent constraint)
|
||||
private const string RootDirectoryIndicator = ".";
|
||||
private const int DefaultSearchDepth = 2;
|
||||
private const int MaxSkillDirectorySearchDepth = 2;
|
||||
|
||||
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 subdirectory names per https://agentskills.io/specification#directory-structure
|
||||
private static readonly string[] s_defaultScriptDirectories = ["scripts"];
|
||||
private static readonly string[] s_defaultResourceDirectories = ["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.
|
||||
@@ -63,8 +57,9 @@ 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> _scriptDirectories;
|
||||
private readonly IReadOnlyList<string> _resourceDirectories;
|
||||
private readonly int _searchDepth;
|
||||
private readonly Func<AgentFileSkillFilterContext, bool>? _scriptFilter;
|
||||
private readonly Func<AgentFileSkillFilterContext, bool>? _resourceFilter;
|
||||
private readonly AgentFileSkillScriptRunner? _scriptRunner;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
@@ -111,13 +106,9 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
options?.AllowedScriptExtensions ?? s_defaultScriptExtensions,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
this._scriptDirectories = options?.ScriptDirectories is not null
|
||||
? [.. ValidateAndNormalizeDirectoryNames(options.ScriptDirectories, this._logger)]
|
||||
: s_defaultScriptDirectories;
|
||||
|
||||
this._resourceDirectories = options?.ResourceDirectories is not null
|
||||
? [.. ValidateAndNormalizeDirectoryNames(options.ResourceDirectories, this._logger)]
|
||||
: s_defaultResourceDirectories;
|
||||
this._searchDepth = Throw.IfLessThan(options?.SearchDepth ?? DefaultSearchDepth, 1);
|
||||
this._scriptFilter = options?.ScriptFilter;
|
||||
this._resourceFilter = options?.ResourceFilter;
|
||||
|
||||
this._scriptRunner = scriptRunner;
|
||||
}
|
||||
@@ -174,7 +165,7 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
results.Add(Path.GetFullPath(directory));
|
||||
}
|
||||
|
||||
if (currentDepth >= MaxSearchDepth)
|
||||
if (currentDepth >= MaxSkillDirectorySearchDepth)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -305,216 +296,246 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans configured resource directories within a skill directory for resource files matching the configured extensions.
|
||||
/// Scans the skill directory recursively (up to the configured search depth) for resource files
|
||||
/// matching the configured extensions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// By default, scans <c>references/</c> and <c>assets/</c> subdirectories as specified by the
|
||||
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>.
|
||||
/// Configure <see cref="AgentFileSkillsSourceOptions.ResourceDirectories"/> 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.
|
||||
/// If a <see cref="AgentFileSkillsSourceOptions.ResourceFilter"/> predicate is configured, files
|
||||
/// that do not satisfy it are excluded.
|
||||
/// </remarks>
|
||||
private List<AgentFileSkillResource> DiscoverResourceFiles(string skillDirectoryFullPath, string skillName)
|
||||
{
|
||||
var resources = new List<AgentFileSkillResource>();
|
||||
|
||||
foreach (string directory in this._resourceDirectories.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal);
|
||||
|
||||
// GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1")
|
||||
string targetDirectory = isRootDirectory
|
||||
? skillDirectoryFullPath
|
||||
: Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar;
|
||||
|
||||
if (!Directory.Exists(targetDirectory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Directory-level symlink check: skip if targetDirectory (or any intermediate
|
||||
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
|
||||
// trusted path, and the security boundary guards files within it, not the path itself.
|
||||
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogResourceSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
#if NET
|
||||
var enumerationOptions = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = false,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.ReparsePoint,
|
||||
};
|
||||
|
||||
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
|
||||
#else
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalize the enumerated path to guard against non-canonical forms.
|
||||
// e.g. "references/../../../etc/shadow" → "/etc/shadow"
|
||||
string resolvedFilePath = Path.GetFullPath(filePath);
|
||||
|
||||
// Path containment: reject if the resolved path escapes the target directory.
|
||||
// e.g. "/etc/shadow".StartsWith("/skills/myskill/references/") → false → skip
|
||||
if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
this.ScanDirectoryForResources(skillDirectoryFullPath, skillDirectoryFullPath, skillName, resources, currentDepth: 1);
|
||||
|
||||
return resources;
|
||||
}
|
||||
|
||||
private void ScanDirectoryForResources(string targetDirectory, string skillDirectoryFullPath, string skillName, List<AgentFileSkillResource> resources, int currentDepth)
|
||||
{
|
||||
if (currentDepth > this._searchDepth)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool isRootDirectory = string.Equals(targetDirectory, skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Directory-level symlink check: skip if targetDirectory (or any intermediate
|
||||
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
|
||||
// trusted path, and the security boundary guards files within it, not the path itself.
|
||||
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogResourceSymlinkDirectory(this._logger, skillName, SanitizePathForLog(targetDirectory));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
#if NET
|
||||
var enumerationOptions = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = false,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.ReparsePoint,
|
||||
};
|
||||
|
||||
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
|
||||
#else
|
||||
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;
|
||||
}
|
||||
|
||||
// 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), string.IsNullOrEmpty(extension) ? "(none)" : extension);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalize the enumerated path to guard against non-canonical forms.
|
||||
// e.g. "references/../../../etc/shadow" → "/etc/shadow"
|
||||
string resolvedFilePath = Path.GetFullPath(filePath);
|
||||
|
||||
// Path containment: reject if the resolved path escapes the skill directory.
|
||||
// e.g. "/etc/shadow".StartsWith("/skills/myskill/") → false → skip
|
||||
if (!resolvedFilePath.StartsWith(skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
}
|
||||
|
||||
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, skillDirectoryFullPath))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute relative path and normalize separators.
|
||||
// e.g. "/skills/myskill/references/guide.md" → "references/guide.md"
|
||||
string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length));
|
||||
|
||||
// Apply user-provided filter predicate
|
||||
if (this._resourceFilter is not null && !this._resourceFilter(new AgentFileSkillFilterContext(skillName, relativePath)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath));
|
||||
}
|
||||
|
||||
// Recurse into subdirectories if within depth limit
|
||||
if (currentDepth < this._searchDepth)
|
||||
{
|
||||
#if NET
|
||||
foreach (string subdirectory in Directory.EnumerateDirectories(targetDirectory, "*", enumerationOptions))
|
||||
#else
|
||||
foreach (string subdirectory in this.SafeEnumerateDirectories(targetDirectory))
|
||||
#endif
|
||||
{
|
||||
this.ScanDirectoryForResources(subdirectory, skillDirectoryFullPath, skillName, resources, currentDepth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans configured script directories within a skill directory for script files matching the configured extensions.
|
||||
/// Scans the skill directory recursively (up to the configured search depth) for script files
|
||||
/// matching the configured extensions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// By default, scans the <c>scripts/</c> subdirectory as specified by the
|
||||
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>.
|
||||
/// Configure <see cref="AgentFileSkillsSourceOptions.ScriptDirectories"/> 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.
|
||||
/// If a <see cref="AgentFileSkillsSourceOptions.ScriptFilter"/> predicate is configured, files
|
||||
/// that do not satisfy it are excluded.
|
||||
/// </remarks>
|
||||
private List<AgentFileSkillScript> DiscoverScriptFiles(string skillDirectoryFullPath, string skillName)
|
||||
{
|
||||
var scripts = new List<AgentFileSkillScript>();
|
||||
|
||||
foreach (string directory in this._scriptDirectories.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
this.ScanDirectoryForScripts(skillDirectoryFullPath, skillDirectoryFullPath, skillName, scripts, currentDepth: 1);
|
||||
|
||||
return scripts;
|
||||
}
|
||||
|
||||
private void ScanDirectoryForScripts(string targetDirectory, string skillDirectoryFullPath, string skillName, List<AgentFileSkillScript> scripts, int currentDepth)
|
||||
{
|
||||
if (currentDepth > this._searchDepth)
|
||||
{
|
||||
bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal);
|
||||
return;
|
||||
}
|
||||
|
||||
// GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1")
|
||||
string targetDirectory = isRootDirectory
|
||||
? skillDirectoryFullPath
|
||||
: Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar;
|
||||
bool isRootDirectory = string.Equals(targetDirectory, skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!Directory.Exists(targetDirectory))
|
||||
// Directory-level symlink check: skip if targetDirectory (or any intermediate
|
||||
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
|
||||
// trusted path, and the security boundary guards files within it, not the path itself.
|
||||
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogScriptSymlinkDirectory(this._logger, skillName, SanitizePathForLog(targetDirectory));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
#if NET
|
||||
var enumerationOptions = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = false,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.ReparsePoint,
|
||||
};
|
||||
|
||||
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
|
||||
#else
|
||||
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;
|
||||
}
|
||||
|
||||
// Directory-level symlink check: skip if targetDirectory (or any intermediate
|
||||
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
|
||||
// trusted path, and the security boundary guards files within it, not the path itself.
|
||||
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
|
||||
// Normalize the enumerated path to guard against non-canonical forms.
|
||||
// e.g. "scripts/../../../etc/shadow" → "/etc/shadow"
|
||||
string resolvedFilePath = Path.GetFullPath(filePath);
|
||||
|
||||
// Path containment: reject if the resolved path escapes the skill directory.
|
||||
// e.g. "/etc/shadow".StartsWith("/skills/myskill/") → false → skip
|
||||
if (!resolvedFilePath.StartsWith(skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogScriptSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory));
|
||||
LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
#if NET
|
||||
var enumerationOptions = new EnumerationOptions
|
||||
// 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, skillDirectoryFullPath))
|
||||
{
|
||||
RecurseSubdirectories = false,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.ReparsePoint,
|
||||
};
|
||||
|
||||
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
|
||||
#else
|
||||
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))
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
continue;
|
||||
LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
}
|
||||
|
||||
// Normalize the enumerated path to guard against non-canonical forms.
|
||||
// e.g. "scripts/../../../etc/shadow" → "/etc/shadow"
|
||||
string resolvedFilePath = Path.GetFullPath(filePath);
|
||||
|
||||
// Path containment: reject if the resolved path escapes the target directory.
|
||||
// e.g. "/etc/shadow".StartsWith("/skills/myskill/scripts/") → false → skip
|
||||
if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute relative path and normalize separators.
|
||||
// e.g. "/skills/myskill/scripts/parsepdf.py" → "scripts/parsepdf.py"
|
||||
string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length));
|
||||
|
||||
// Apply user-provided filter predicate
|
||||
if (this._scriptFilter is not null && !this._scriptFilter(new AgentFileSkillFilterContext(skillName, relativePath)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner));
|
||||
}
|
||||
|
||||
return scripts;
|
||||
// Recurse into subdirectories if within depth limit
|
||||
if (currentDepth < this._searchDepth)
|
||||
{
|
||||
#if NET
|
||||
foreach (string subdirectory in Directory.EnumerateDirectories(targetDirectory, "*", enumerationOptions))
|
||||
#else
|
||||
foreach (string subdirectory in this.SafeEnumerateDirectories(targetDirectory))
|
||||
#endif
|
||||
{
|
||||
this.ScanDirectoryForScripts(subdirectory, skillDirectoryFullPath, skillName, scripts, currentDepth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -542,6 +563,31 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
return false;
|
||||
}
|
||||
|
||||
#if !NET
|
||||
/// <summary>
|
||||
/// Best-effort directory enumeration for target frameworks without
|
||||
/// <c>EnumerationOptions.IgnoreInaccessible</c> support. Returns an empty
|
||||
/// array when the caller lacks permission to read the directory contents,
|
||||
/// so a single inaccessible child does not abort the entire skill scan.
|
||||
/// </summary>
|
||||
private string[] SafeEnumerateDirectories(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.GetDirectories(path);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
LogDirectoryAccessDenied(this._logger, SanitizePathForLog(path));
|
||||
}
|
||||
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private static string ParseYamlScalarValue(string yamlContent, Match kvMatch)
|
||||
{
|
||||
string value = kvMatch.Groups[3].Value;
|
||||
@@ -664,46 +710,6 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ValidateAndNormalizeDirectoryNames(IEnumerable<string> directories, ILogger logger)
|
||||
{
|
||||
foreach (string directory in directories)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
throw new ArgumentException("Directory names must not be null or whitespace.", nameof(directories));
|
||||
}
|
||||
|
||||
// "." is valid — it means the skill root directory.
|
||||
if (string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal))
|
||||
{
|
||||
yield return directory;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reject absolute paths and any path segments that escape upward.
|
||||
if (Path.IsPathRooted(directory) || ContainsParentTraversalSegment(directory))
|
||||
{
|
||||
LogDirectoryNameSkippedInvalid(logger, directory);
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return NormalizePath(directory);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ContainsParentTraversalSegment(string directory)
|
||||
{
|
||||
foreach (string segment in directory.Split('/', '\\'))
|
||||
{
|
||||
if (segment == "..")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
[LoggerMessage(LogLevel.Information, "Discovered {Count} potential skills")]
|
||||
private static partial void LogSkillsDiscovered(ILogger logger, int count);
|
||||
|
||||
@@ -743,6 +749,6 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
[LoggerMessage(LogLevel.Warning, "Skipping script directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symlink")]
|
||||
private static partial void LogScriptSymlinkDirectory(ILogger logger, string skillName, string directoryName);
|
||||
|
||||
[LoggerMessage(LogLevel.Warning, "Skipping invalid directory name '{DirectoryName}': must be a relative path with no '..' segments")]
|
||||
private static partial void LogDirectoryNameSkippedInvalid(ILogger logger, string directoryName);
|
||||
[LoggerMessage(LogLevel.Warning, "Skipping directory '{DirectoryPath}': access denied")]
|
||||
private static partial void LogDirectoryAccessDenied(ILogger logger, string directoryPath);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
@@ -32,28 +33,31 @@ public sealed class AgentFileSkillsSourceOptions
|
||||
public IEnumerable<string>? AllowedScriptExtensions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets relative directory 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.
|
||||
/// Gets or sets the maximum depth to search for script and resource files within each skill directory.
|
||||
/// A value of <c>1</c> searches only the skill root directory. A value of <c>2</c> searches the root
|
||||
/// and one level of subdirectories.
|
||||
/// When <see langword="null"/>, the source uses the default depth of <c>2</c>.
|
||||
/// </summary>
|
||||
public IEnumerable<string>? ScriptDirectories { get; set; }
|
||||
/// <remarks>
|
||||
/// Must be greater than or equal to <c>1</c>; lower values are rejected by the constructor.
|
||||
/// </remarks>
|
||||
public int? SearchDepth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets relative directory 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.
|
||||
/// Gets or sets a predicate that filters discovered script files.
|
||||
/// The predicate receives an <see cref="AgentFileSkillFilterContext"/> containing the skill's name
|
||||
/// and the file's path relative to the skill directory.
|
||||
/// Return <see langword="true"/> to include the file or <see langword="false"/> to exclude it.
|
||||
/// When <see langword="null"/>, all scripts matching the allowed extensions are included.
|
||||
/// </summary>
|
||||
public IEnumerable<string>? ResourceDirectories { get; set; }
|
||||
public Func<AgentFileSkillFilterContext, bool>? ScriptFilter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a predicate that filters discovered resource files.
|
||||
/// The predicate receives an <see cref="AgentFileSkillFilterContext"/> containing the skill's name
|
||||
/// and the file's path relative to the skill directory.
|
||||
/// Return <see langword="true"/> to include the file or <see langword="false"/> to exclude it.
|
||||
/// When <see langword="null"/>, all resources matching the allowed extensions are included.
|
||||
/// </summary>
|
||||
public Func<AgentFileSkillFilterContext, bool>? ResourceFilter { get; set; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user