| File: Agents\Hooks\AgentTelemetryCatalog.cs | Web Access |
| Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Formats.Tar; using System.IO.Compression; using System.Text.Json; using System.Text.RegularExpressions; using Aspire.Cli.Agents.AspireSkills; namespace Aspire.Cli.Agents.Hooks; /// <summary> /// Reads skills and references from the embedded skill manifest, and MCP tools from the canonical hook. /// </summary> internal sealed partial class AgentTelemetryCatalog { private static readonly Lazy<AgentTelemetryCatalog> s_bundled = new(LoadBundled); internal static AgentTelemetryCatalog Bundled => s_bundled.Value; internal IReadOnlySet<string> Skills { get; } internal IReadOnlySet<string> Tools { get; } internal IReadOnlySet<string> References { get; } private AgentTelemetryCatalog(HashSet<string> skills, HashSet<string> tools, HashSet<string> references) { Skills = skills; Tools = tools; References = references; } internal static AgentTelemetryCatalog Parse(SkillBundleManifest manifest, string script) { if (manifest.Skills is not { Length: > 0 }) { throw new InvalidDataException("The bundled skill manifest must contain skills."); } var skills = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var references = new HashSet<string>(StringComparer.OrdinalIgnoreCase); foreach (var skill in manifest.Skills) { if (skill?.Name is not { Length: > 0 } name) { throw new InvalidDataException("The bundled skill manifest contains an unnamed skill."); } AspireSkillsBundleProvider.ValidateSkillName(name); if (!skills.Add(name)) { throw new InvalidDataException($"The bundled skill manifest contains duplicate skill '{name}'."); } if (skill.Files is not { Length: > 0 } files) { throw new InvalidDataException($"The bundled skill '{name}' is missing its file inventory."); } foreach (var file in files) { var path = AspireSkillsBundleProvider.NormalizeRelativePath(file?.RelativePath).Replace('\\', '/'); // SKILL.md is an invocation, not a reference. Evals, scripts, and other manifest // assets must not become new telemetry dimensions merely because they are shipped. if (path.StartsWith(AspireSkillsBundleLayout.ReferencesDirectoryName + "/", StringComparison.Ordinal)) { references.Add($"{name}/{path}"); } } } // The manifest does not yet describe MCP tools. Retain the canonical tool allowlist until // structured tool metadata is available, without parsing shell declarations for skills. // The script contains one shell declaration in this form: // ASPIRE_MCP_TOOLS="doctor list_resources ..." // McpToolsDeclaration matches the entire declaration and captures the quoted text as "values". var declarations = McpToolsDeclaration().Matches(script); if (declarations.Count != 1) { throw new InvalidDataException("The bundled telemetry hook must declare ASPIRE_MCP_TOOLS exactly once."); } // A null separator makes string.Split treat all Unicode whitespace as delimiters. Removing // empty entries allows the captured tool identifiers to be separated by repeated whitespace. var tools = declarations[0].Groups["values"].Value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); if (tools.Length == 0 || tools.Any(tool => !ToolIdentifier().IsMatch(tool))) { throw new InvalidDataException("The bundled telemetry hook contains an invalid MCP tool allowlist."); } return new(skills, new HashSet<string>(tools, StringComparer.OrdinalIgnoreCase), references); } private static AgentTelemetryCatalog LoadBundled() { // Read only compiled resources, never user-installed skills or scripts. No extraction, // network lookup, or skill-content reads are needed on the hook path. using var archive = typeof(AgentTelemetryCatalog).Assembly.GetManifestResourceStream(EmbeddedAspireSkillsBundleProvider.ArchiveResourceName) ?? throw new InvalidDataException("The bundled skills archive is missing."); var manifest = ReadManifest(archive); using var stream = typeof(AgentTelemetryCatalog).Assembly.GetManifestResourceStream(TelemetryHookInstaller.ShellResourceName) ?? throw new InvalidDataException("The bundled telemetry hook is missing."); using var reader = new StreamReader(stream); return Parse(manifest, reader.ReadToEnd()); } internal static SkillBundleManifest ReadManifest(Stream archive) { using var gzip = new GZipStream(archive, CompressionMode.Decompress, leaveOpen: true); using var tar = new TarReader(gzip); SkillBundleManifest? manifest = null; while (tar.GetNextEntry() is { } entry) { // The manifest is at the archive root or one wrapper directory below it: // The manifest is either at the root or inside one versioned wrapper directory. var parts = entry.Name.Split('/', StringSplitOptions.RemoveEmptyEntries); if (parts is not ([AspireSkillsBundleLayout.ManifestFileName] or [_, AspireSkillsBundleLayout.ManifestFileName])) { continue; } if (manifest is not null || entry.DataStream is null || entry.EntryType is not (TarEntryType.RegularFile or TarEntryType.V7RegularFile)) { throw new InvalidDataException("The bundled skills archive must contain one regular skill manifest."); } manifest = JsonSerializer.Deserialize(entry.DataStream, AspireSkillsJsonSerializerContext.Default.SkillBundleManifest) ?? throw new InvalidDataException("The bundled skill manifest is empty."); } return manifest ?? throw new InvalidDataException($"The bundled skills archive is missing {AspireSkillsBundleLayout.ManifestFileName}."); } // ASPIRE_MCP_TOOLS="doctor list_resources ..." is literal, whitespace-separated data. [GeneratedRegex("""^ASPIRE_MCP_TOOLS="(?<values>[^"]*)"[ \t]*\r?$""", RegexOptions.Multiline)] private static partial Regex McpToolsDeclaration(); [GeneratedRegex("""\A[a-zA-Z0-9_-]+\z""")] private static partial Regex ToolIdentifier(); }