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