// 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;
///
/// A skill source that discovers skills from filesystem directories containing SKILL.md files.
///
///
/// 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.
///
[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 _skillPaths;
private readonly HashSet _allowedResourceExtensions;
private readonly HashSet _allowedScriptExtensions;
private readonly int _searchDepth;
private readonly Func? _scriptFilter;
private readonly Func? _resourceFilter;
private readonly AgentFileSkillScriptRunner? _scriptRunner;
private readonly ILogger _logger;
///
/// Initializes a new instance of the class.
///
/// Path to search for skills.
/// Optional runner for file-based scripts. Required only when skills contain scripts.
/// Optional options that control skill discovery behavior.
/// Optional logger factory.
public AgentFileSkillsSource(
string skillPath,
AgentFileSkillScriptRunner? scriptRunner = null,
AgentFileSkillsSourceOptions? options = null,
ILoggerFactory? loggerFactory = null)
: this([skillPath], scriptRunner, options, loggerFactory)
{
}
///
/// Initializes a new instance of the class.
///
/// Paths to search for skills.
/// Optional runner for file-based scripts. Required only when skills contain scripts.
/// Optional options that control skill discovery behavior.
/// Optional logger factory.
public AgentFileSkillsSource(
IEnumerable skillPaths,
AgentFileSkillScriptRunner? scriptRunner = null,
AgentFileSkillsSourceOptions? options = null,
ILoggerFactory? loggerFactory = null)
{
this._skillPaths = Throw.IfNull(skillPaths);
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger();
ValidateExtensions(options?.AllowedResourceExtensions);
ValidateExtensions(options?.AllowedScriptExtensions);
this._allowedResourceExtensions = new HashSet(
options?.AllowedResourceExtensions ?? s_defaultResourceExtensions,
StringComparer.OrdinalIgnoreCase);
this._allowedScriptExtensions = new HashSet(
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;
}
///
public override Task> GetSkillsAsync(CancellationToken cancellationToken = default)
{
var discoveredPaths = DiscoverSkillDirectories(this._skillPaths);
LogSkillsDiscovered(this._logger, discoveredPaths.Count);
var skills = new List();
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);
}
private static List DiscoverSkillDirectories(IEnumerable skillPaths)
{
var discoveredPaths = new List();
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 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;
}
///
/// Scans the skill directory recursively (up to the configured search depth) for resource files
/// matching the configured extensions.
///
///
/// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped.
/// If a predicate is configured, files
/// that do not satisfy it are excluded.
///
private List DiscoverResourceFiles(string skillDirectoryFullPath, string skillName)
{
var resources = new List();
this.ScanDirectoryForResources(skillDirectoryFullPath, skillDirectoryFullPath, skillName, resources, currentDepth: 1);
return resources;
}
private void ScanDirectoryForResources(string targetDirectory, string skillDirectoryFullPath, string skillName, List 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);
}
}
}
///
/// Scans the skill directory recursively (up to the configured search depth) for script files
/// matching the configured extensions.
///
///
/// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped.
/// If a predicate is configured, files
/// that do not satisfy it are excluded.
///
private List DiscoverScriptFiles(string skillDirectoryFullPath, string skillName)
{
var scripts = new List();
this.ScanDirectoryForScripts(skillDirectoryFullPath, skillDirectoryFullPath, skillName, scripts, currentDepth: 1);
return scripts;
}
private void ScanDirectoryForScripts(string targetDirectory, string skillDirectoryFullPath, string skillName, List 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);
}
}
}
///
/// Checks whether any segment in the path (relative to the directory) is a symlink.
///
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
///
/// Best-effort directory enumeration for target frameworks without
/// EnumerationOptions.IgnoreInaccessible 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.
///
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();
}
}
#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();
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;
}
///
/// Normalizes a relative path or directory name by stripping a leading "./"/".\",
/// trimming trailing separators, and replacing backslashes with forward
/// slashes.
///
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;
}
///
/// Replaces control characters in a file path with '?' to prevent log injection.
///
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? 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);
}