mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
* 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>
755 lines
31 KiB
C#
755 lines
31 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
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;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.AI;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Shared.DiagnosticIds;
|
|
using Microsoft.Shared.Diagnostics;
|
|
|
|
namespace Microsoft.Agents.AI;
|
|
|
|
/// <summary>
|
|
/// A skill source that discovers skills from filesystem directories containing SKILL.md files.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Searches directories recursively (up to 2 levels deep) for SKILL.md files.
|
|
/// Each file is validated for YAML frontmatter. Resource and script files are discovered by scanning the skill
|
|
/// directory for files with matching extensions. Invalid resources are skipped with logged warnings.
|
|
/// Resource and script paths are checked against path traversal and symlink escape attacks.
|
|
/// </remarks>
|
|
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
|
internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
|
{
|
|
private const string SkillFileName = "SKILL.md";
|
|
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"];
|
|
|
|
// 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.
|
|
private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
|
|
|
|
// Matches top-level YAML "key: value" lines. Group 1 = key (supports hyphens for keys like allowed-tools),
|
|
// Group 2 = quoted value, Group 3 = unquoted value.
|
|
// Accepts single or double quotes; the lazy quantifier trims trailing whitespace on unquoted values.
|
|
private static readonly Regex s_yamlKeyValueRegex = new(@"^([\w-]+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
|
|
|
|
// Matches a "metadata:" line followed by indented sub-key/value pairs.
|
|
// Group 1 captures the entire indented block beneath the metadata key.
|
|
private static readonly Regex s_yamlMetadataBlockRegex = new(@"^metadata\s*:\s*$\n((?:[ \t]+\S.*\n?)+)", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
|
|
|
|
// Matches indented YAML "key: value" lines within a metadata block.
|
|
// Group 1 = key (supports hyphens), Group 2 = quoted value, Group 3 = unquoted value.
|
|
private static readonly Regex s_yamlIndentedKeyValueRegex = new(@"^\s+([\w-]+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
|
|
|
|
private readonly IEnumerable<string> _skillPaths;
|
|
private readonly HashSet<string> _allowedResourceExtensions;
|
|
private readonly HashSet<string> _allowedScriptExtensions;
|
|
private readonly int _searchDepth;
|
|
private readonly Func<AgentFileSkillFilterContext, bool>? _scriptFilter;
|
|
private readonly Func<AgentFileSkillFilterContext, bool>? _resourceFilter;
|
|
private readonly AgentFileSkillScriptRunner? _scriptRunner;
|
|
private readonly ILogger _logger;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="AgentFileSkillsSource"/> class.
|
|
/// </summary>
|
|
/// <param name="skillPath">Path to search for skills.</param>
|
|
/// <param name="scriptRunner">Optional runner for file-based scripts. Required only when skills contain scripts.</param>
|
|
/// <param name="options">Optional options that control skill discovery behavior.</param>
|
|
/// <param name="loggerFactory">Optional logger factory.</param>
|
|
public AgentFileSkillsSource(
|
|
string skillPath,
|
|
AgentFileSkillScriptRunner? scriptRunner = null,
|
|
AgentFileSkillsSourceOptions? options = null,
|
|
ILoggerFactory? loggerFactory = null)
|
|
: this([skillPath], scriptRunner, options, loggerFactory)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="AgentFileSkillsSource"/> class.
|
|
/// </summary>
|
|
/// <param name="skillPaths">Paths to search for skills.</param>
|
|
/// <param name="scriptRunner">Optional runner for file-based scripts. Required only when skills contain scripts.</param>
|
|
/// <param name="options">Optional options that control skill discovery behavior.</param>
|
|
/// <param name="loggerFactory">Optional logger factory.</param>
|
|
public AgentFileSkillsSource(
|
|
IEnumerable<string> skillPaths,
|
|
AgentFileSkillScriptRunner? scriptRunner = null,
|
|
AgentFileSkillsSourceOptions? options = null,
|
|
ILoggerFactory? loggerFactory = null)
|
|
{
|
|
this._skillPaths = Throw.IfNull(skillPaths);
|
|
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<AgentFileSkillsSource>();
|
|
|
|
ValidateExtensions(options?.AllowedResourceExtensions);
|
|
ValidateExtensions(options?.AllowedScriptExtensions);
|
|
|
|
this._allowedResourceExtensions = new HashSet<string>(
|
|
options?.AllowedResourceExtensions ?? s_defaultResourceExtensions,
|
|
StringComparer.OrdinalIgnoreCase);
|
|
|
|
this._allowedScriptExtensions = new HashSet<string>(
|
|
options?.AllowedScriptExtensions ?? s_defaultScriptExtensions,
|
|
StringComparer.OrdinalIgnoreCase);
|
|
|
|
this._searchDepth = Throw.IfLessThan(options?.SearchDepth ?? DefaultSearchDepth, 1);
|
|
this._scriptFilter = options?.ScriptFilter;
|
|
this._resourceFilter = options?.ResourceFilter;
|
|
|
|
this._scriptRunner = scriptRunner;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
var discoveredPaths = DiscoverSkillDirectories(this._skillPaths);
|
|
|
|
LogSkillsDiscovered(this._logger, discoveredPaths.Count);
|
|
|
|
var skills = new List<AgentSkill>();
|
|
|
|
foreach (string skillPath in discoveredPaths)
|
|
{
|
|
AgentFileSkill? skill = this.ParseSkillDirectory(skillPath);
|
|
if (skill is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
skills.Add(skill);
|
|
|
|
LogSkillLoaded(this._logger, skill.Frontmatter.Name);
|
|
}
|
|
|
|
LogSkillsLoadedTotal(this._logger, skills.Count);
|
|
|
|
return Task.FromResult(skills as IList<AgentSkill>);
|
|
}
|
|
|
|
private static List<string> DiscoverSkillDirectories(IEnumerable<string> skillPaths)
|
|
{
|
|
var discoveredPaths = new List<string>();
|
|
|
|
foreach (string rootDirectory in skillPaths)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
SearchDirectoriesForSkills(rootDirectory, discoveredPaths, currentDepth: 0);
|
|
}
|
|
|
|
return discoveredPaths;
|
|
}
|
|
|
|
private static void SearchDirectoriesForSkills(string directory, List<string> results, int currentDepth)
|
|
{
|
|
string skillFilePath = Path.Combine(directory, SkillFileName);
|
|
if (File.Exists(skillFilePath))
|
|
{
|
|
results.Add(Path.GetFullPath(directory));
|
|
}
|
|
|
|
if (currentDepth >= MaxSkillDirectorySearchDepth)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (string subdirectory in Directory.EnumerateDirectories(directory))
|
|
{
|
|
SearchDirectoriesForSkills(subdirectory, results, currentDepth + 1);
|
|
}
|
|
}
|
|
|
|
private AgentFileSkill? ParseSkillDirectory(string skillDirectoryFullPath)
|
|
{
|
|
string skillFilePath = Path.Combine(skillDirectoryFullPath, SkillFileName);
|
|
string content = File.ReadAllText(skillFilePath, Encoding.UTF8);
|
|
|
|
if (!this.TryParseFrontmatter(content, skillFilePath, out AgentSkillFrontmatter? frontmatter))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// 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,
|
|
content: content,
|
|
path: skillDirectoryFullPath,
|
|
resources: resources,
|
|
scripts: scripts);
|
|
}
|
|
|
|
private bool TryParseFrontmatter(string content, string skillFilePath, [NotNullWhen(true)] out AgentSkillFrontmatter? frontmatter)
|
|
{
|
|
frontmatter = null;
|
|
|
|
Match match = s_frontmatterRegex.Match(content);
|
|
if (!match.Success)
|
|
{
|
|
LogInvalidFrontmatter(this._logger, skillFilePath);
|
|
return false;
|
|
}
|
|
|
|
string yamlContent = match.Groups[1].Value.Trim();
|
|
|
|
string? name = null;
|
|
string? description = null;
|
|
string? license = null;
|
|
string? compatibility = null;
|
|
string? allowedTools = null;
|
|
|
|
foreach (Match kvMatch in s_yamlKeyValueRegex.Matches(yamlContent))
|
|
{
|
|
string key = kvMatch.Groups[1].Value;
|
|
string value = kvMatch.Groups[2].Success
|
|
? kvMatch.Groups[2].Value
|
|
: ParseYamlScalarValue(yamlContent, kvMatch);
|
|
|
|
if (string.Equals(key, "name", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
name = value;
|
|
}
|
|
else if (string.Equals(key, "description", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
description = value;
|
|
}
|
|
else if (string.Equals(key, "license", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
license = value;
|
|
}
|
|
else if (string.Equals(key, "compatibility", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
compatibility = value;
|
|
}
|
|
else if (string.Equals(key, "allowed-tools", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
allowedTools = value;
|
|
}
|
|
}
|
|
|
|
// Parse metadata block (indented key-value pairs under "metadata:").
|
|
AdditionalPropertiesDictionary? metadata = null;
|
|
Match metadataMatch = s_yamlMetadataBlockRegex.Match(yamlContent);
|
|
if (metadataMatch.Success)
|
|
{
|
|
metadata = [];
|
|
foreach (Match kvMatch in s_yamlIndentedKeyValueRegex.Matches(metadataMatch.Groups[1].Value))
|
|
{
|
|
metadata[kvMatch.Groups[1].Value] = kvMatch.Groups[2].Success ? kvMatch.Groups[2].Value : kvMatch.Groups[3].Value;
|
|
}
|
|
}
|
|
|
|
if (!AgentSkillFrontmatter.ValidateName(name, out string? validationReason) ||
|
|
!AgentSkillFrontmatter.ValidateDescription(description, out validationReason))
|
|
{
|
|
LogInvalidFieldValue(this._logger, skillFilePath, "frontmatter", validationReason);
|
|
return false;
|
|
}
|
|
|
|
frontmatter = new AgentSkillFrontmatter(name!, description!, compatibility)
|
|
{
|
|
License = license,
|
|
AllowedTools = allowedTools,
|
|
Metadata = metadata,
|
|
};
|
|
|
|
// skillFilePath is e.g. "/skills/my-skill/SKILL.md".
|
|
// GetDirectoryName strips the filename → "/skills/my-skill".
|
|
// GetFileName then extracts the last segment → "my-skill".
|
|
// This gives us the skill's parent directory name to validate against the frontmatter name.
|
|
string directoryName = Path.GetFileName(Path.GetDirectoryName(skillFilePath)) ?? string.Empty;
|
|
if (!string.Equals(frontmatter.Name, directoryName, StringComparison.Ordinal))
|
|
{
|
|
if (this._logger.IsEnabled(LogLevel.Error))
|
|
{
|
|
LogNameDirectoryMismatch(this._logger, SanitizePathForLog(skillFilePath), frontmatter.Name, SanitizePathForLog(directoryName));
|
|
}
|
|
|
|
frontmatter = null;
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scans the skill directory recursively (up to the configured search depth) for resource files
|
|
/// matching the configured extensions.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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>();
|
|
|
|
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 the skill directory recursively (up to the configured search depth) for script files
|
|
/// matching the configured extensions.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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>();
|
|
|
|
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)
|
|
{
|
|
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))
|
|
{
|
|
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;
|
|
}
|
|
|
|
// 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))
|
|
{
|
|
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, skillDirectoryFullPath))
|
|
{
|
|
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));
|
|
|
|
// 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));
|
|
}
|
|
|
|
// 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>
|
|
/// Checks whether any segment in the path (relative to the directory) is a symlink.
|
|
/// </summary>
|
|
private static bool HasSymlinkInPath(string pathToCheck, string trustedBasePath)
|
|
{
|
|
string relativePath = pathToCheck.Substring(trustedBasePath.Length);
|
|
string[] segments = relativePath.Split(
|
|
[Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
|
|
StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
string currentPath = trustedBasePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
|
|
|
foreach (string segment in segments)
|
|
{
|
|
currentPath = Path.Combine(currentPath, segment);
|
|
|
|
if ((File.GetAttributes(currentPath) & FileAttributes.ReparsePoint) != 0)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
if (value.Length == 0 || value[0] is not ('|' or '>'))
|
|
{
|
|
return value;
|
|
}
|
|
|
|
char scalarStyle = value[0];
|
|
bool keepTrailingNewline = value.Length > 1 && value[1] == '+';
|
|
|
|
int nextLineStart = yamlContent.IndexOf('\n', kvMatch.Index + kvMatch.Length);
|
|
if (nextLineStart < 0)
|
|
{
|
|
return value;
|
|
}
|
|
|
|
nextLineStart++;
|
|
|
|
var blockLines = new List<string>();
|
|
using var reader = new StringReader(yamlContent.Substring(nextLineStart));
|
|
|
|
string? line;
|
|
while ((line = reader.ReadLine()) is not null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(line))
|
|
{
|
|
blockLines.Add(string.Empty);
|
|
continue;
|
|
}
|
|
|
|
if (line[0] != ' ' && line[0] != '\t')
|
|
{
|
|
break;
|
|
}
|
|
|
|
blockLines.Add(line);
|
|
}
|
|
|
|
if (blockLines.Count == 0)
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
int commonIndent = blockLines
|
|
.Where(line => line.Length > 0)
|
|
.Min(line => line.TakeWhile(ch => ch == ' ' || ch == '\t').Count());
|
|
|
|
string[] normalizedLines = blockLines
|
|
.Select(line => line.Length == 0 ? string.Empty : line.Substring(Math.Min(commonIndent, line.Length)))
|
|
.ToArray();
|
|
|
|
string parsedValue = scalarStyle == '|'
|
|
? string.Join("\n", normalizedLines)
|
|
: string.Join(" ", normalizedLines.Where(line => line.Length > 0));
|
|
|
|
return keepTrailingNewline ? parsedValue + "\n" : parsedValue;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Normalizes a relative path or directory name by stripping a leading "./"/".\",
|
|
/// trimming trailing 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('\\', '/');
|
|
}
|
|
|
|
return path;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Replaces control characters in a file path with '?' to prevent log injection.
|
|
/// </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}'", "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);
|
|
|
|
[LoggerMessage(LogLevel.Information, "Loaded skill: {SkillName}")]
|
|
private static partial void LogSkillLoaded(ILogger logger, string skillName);
|
|
|
|
[LoggerMessage(LogLevel.Information, "Successfully loaded {Count} skills")]
|
|
private static partial void LogSkillsLoadedTotal(ILogger logger, int count);
|
|
|
|
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' does not contain valid YAML frontmatter delimited by '---'")]
|
|
private static partial void LogInvalidFrontmatter(ILogger logger, string skillFilePath);
|
|
|
|
[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.Error, "SKILL.md at '{SkillFilePath}': skill name '{SkillName}' does not match parent directory name '{DirectoryName}'")]
|
|
private static partial void LogNameDirectoryMismatch(ILogger logger, string skillFilePath, string skillName, string directoryName);
|
|
|
|
[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, "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 directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symlink")]
|
|
private static partial void LogResourceSymlinkDirectory(ILogger logger, string skillName, string directoryName);
|
|
|
|
[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);
|
|
|
|
[LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' references a path outside the skill directory")]
|
|
private static partial void LogScriptPathTraversal(ILogger logger, string skillName, string scriptPath);
|
|
|
|
[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 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 directory '{DirectoryPath}': access denied")]
|
|
private static partial void LogDirectoryAccessDenied(ILogger logger, string directoryPath);
|
|
}
|