diff --git a/src/Netclaw.Cli.Tests/Doctor/ConfigSchemaDoctorCheckTests.cs b/src/Netclaw.Cli.Tests/Doctor/ConfigSchemaDoctorCheckTests.cs index 41b2e97f7..e60e12cad 100644 --- a/src/Netclaw.Cli.Tests/Doctor/ConfigSchemaDoctorCheckTests.cs +++ b/src/Netclaw.Cli.Tests/Doctor/ConfigSchemaDoctorCheckTests.cs @@ -481,6 +481,37 @@ await File.WriteAllTextAsync(paths.NetclawConfigPath, Assert.Equal(DoctorSeverity.Pass, result.Severity); } + [Fact] + public async Task ReturnsPass_WhenGitSkillPluginSourceIsValid() + { + var basePath = CreateTempBasePath(); + var paths = new NetclawPaths(basePath); + paths.EnsureDirectoriesExist(); + + await File.WriteAllTextAsync(paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "SkillFeeds": { + "Plugins": [{ + "Name": "dotnet-skills", + "Repository": "Aaronontheweb/dotnet-skills", + "Format": "codex", + "ReferenceKind": "Commit", + "Reference": "13e26d39ed01d97ea592235d041304d289f4ba07", + "Enabled": true, + "TimeoutSeconds": 60 + }] + } + } + """, TestContext.Current.CancellationToken); + + var check = new ConfigSchemaDoctorCheck(paths); + var result = await check.RunAsync(TestContext.Current.CancellationToken); + + Assert.Equal(DoctorSeverity.Pass, result.Severity); + } + [Fact] public async Task ReturnsPass_WhenToolsAudienceProfilesAndMcpCapabilityValid() { diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SkillSourcesConfigViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SkillSourcesConfigViewModelTests.cs index 789a8692d..a04e8cbbf 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/SkillSourcesConfigViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/SkillSourcesConfigViewModelTests.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using Microsoft.Extensions.Configuration; +using System.Text.Json; using Netclaw.Cli.Tui.Config; using Netclaw.Configuration; using Netclaw.Configuration.Secrets; @@ -66,6 +67,49 @@ public async Task Rescan_all_does_not_badge_disabled_remote_servers() Assert.DoesNotContain("advertised", remoteRow.Detail, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void Toggle_feed_preserves_managed_Git_plugins() + { + File.WriteAllText( + _paths.NetclawConfigPath, + """ + {"configVersion":1,"SkillFeeds":{"Feeds":[{"Name":"custom-feed","Url":"https://feed.example.test","Enabled":true}],"Plugins":[{"Name":"dotnet-skills","Repository":"owner/repository","Format":"codex","Subdirectory":"packages/plugin","ReferenceKind":"Branch","Reference":"main","Enabled":true,"TimeoutSeconds":90}]}} + """); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); + + vm.CommitToggleEnabled(new SkillSourceActionTarget(SkillSourceKind.RemoteSkillServer, "custom-feed")); + + using var document = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var section = document.RootElement.GetProperty("SkillFeeds"); + Assert.False(section.GetProperty("Feeds")[0].GetProperty("Enabled").GetBoolean()); + var plugin = Assert.Single(section.GetProperty("Plugins").EnumerateArray()); + Assert.Equal("owner/repository", plugin.GetProperty("Repository").GetString()); + Assert.Equal("packages/plugin", plugin.GetProperty("Subdirectory").GetString()); + Assert.Equal("Branch", plugin.GetProperty("ReferenceKind").GetString()); + Assert.Equal(90, plugin.GetProperty("TimeoutSeconds").GetInt32()); + } + + [Fact] + public void Remove_last_feed_preserves_managed_Git_plugins() + { + File.WriteAllText( + _paths.NetclawConfigPath, + """ + {"configVersion":1,"SkillFeeds":{"Feeds":[{"Name":"custom-feed","Url":"https://feed.example.test","Enabled":true}],"Plugins":[{"Name":"dotnet-skills","Repository":"owner/repository","Format":"codex","ReferenceKind":"Commit","Reference":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","Enabled":false,"TimeoutSeconds":60}]}} + """); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); + + vm.CommitRemoveSource(new SkillSourceActionTarget(SkillSourceKind.RemoteSkillServer, "custom-feed")); + + using var document = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var section = document.RootElement.GetProperty("SkillFeeds"); + Assert.Empty(section.GetProperty("Feeds").EnumerateArray()); + var plugin = Assert.Single(section.GetProperty("Plugins").EnumerateArray()); + Assert.Equal("dotnet-skills", plugin.GetProperty("Name").GetString()); + Assert.Equal("Commit", plugin.GetProperty("ReferenceKind").GetString()); + Assert.False(plugin.GetProperty("Enabled").GetBoolean()); + } + [Fact] public async Task Rescan_all_invalidates_a_cached_count_when_the_server_is_disabled() { diff --git a/src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs b/src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs index 2a3f8606a..105b6adf2 100644 --- a/src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs +++ b/src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs @@ -2263,7 +2263,7 @@ private bool SaveExternalConfig(ExternalSkillsConfig external) private bool SaveSkillFeedsConfig(SkillFeedsConfigDocument feeds) => TryEditConfig(root => { - if (feeds.Feeds.Count == 0) + if (feeds.Feeds.Count == 0 && feeds.Plugins.Count == 0) root.Remove("SkillFeeds"); else root["SkillFeeds"] = BuildSkillFeedsSection(feeds); @@ -2522,6 +2522,24 @@ private static Dictionary BuildSkillFeedsSection(SkillFeedsConfi return (object)item; }).ToArray(), + ["Plugins"] = config.Plugins.Select(static plugin => + { + var item = new Dictionary + { + ["Name"] = plugin.Name, + ["Repository"] = plugin.Repository, + ["Format"] = plugin.Format, + ["ReferenceKind"] = plugin.ReferenceKind.ToString(), + ["Reference"] = plugin.Reference, + ["Enabled"] = plugin.Enabled, + ["TimeoutSeconds"] = plugin.TimeoutSeconds, + }; + + if (!string.IsNullOrWhiteSpace(plugin.Subdirectory)) + item["Subdirectory"] = plugin.Subdirectory; + + return (object)item; + }).ToArray(), }; private static LocalSkillScanDisplay ScanLocalSkills(string directory, bool allowSymlinks) @@ -2617,6 +2635,8 @@ private sealed class SkillFeedsConfigDocument public int SyncIntervalMinutes { get; set; } = 60; public List Feeds { get; set; } = []; + + public List Plugins { get; set; } = []; } private sealed class SkillFeedConfigEntry diff --git a/src/Netclaw.Configuration.Tests/GitSkillPluginSourceValidatorTests.cs b/src/Netclaw.Configuration.Tests/GitSkillPluginSourceValidatorTests.cs new file mode 100644 index 000000000..3a930f7b1 --- /dev/null +++ b/src/Netclaw.Configuration.Tests/GitSkillPluginSourceValidatorTests.cs @@ -0,0 +1,120 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Xunit; + +namespace Netclaw.Configuration.Tests; + +public sealed class GitSkillPluginSourceValidatorTests +{ + [Theory] + [InlineData("Aaronontheweb/dotnet-skills", "Aaronontheweb/dotnet-skills")] + [InlineData("https://github.com/Aaronontheweb/dotnet-skills", "Aaronontheweb/dotnet-skills")] + [InlineData("https://github.com/Aaronontheweb/dotnet-skills.git", "Aaronontheweb/dotnet-skills")] + public void Repository_normalization_accepts_public_GitHub_forms(string value, string expected) + { + Assert.True(GitSkillPluginSourceValidator.TryNormalizeRepository(value, out var actual, out _)); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("http://github.com/owner/repo")] + [InlineData("https://user@github.com/owner/repo")] + [InlineData("https://github.com:8443/owner/repo")] + [InlineData("https://gitlab.com/owner/repo")] + [InlineData("https://github.com/owner/repo?token=secret")] + [InlineData("https://github.com/owner/repo#readme")] + [InlineData("--upload-pack=bad")] + public void Repository_normalization_rejects_unsafe_transport_forms(string value) + { + Assert.False(GitSkillPluginSourceValidator.TryNormalizeRepository(value, out _, out _)); + } + + [Fact] + public void Source_validation_requires_a_full_commit_identity() + { + var source = Source(); + source.ReferenceKind = GitSkillPluginReferenceKind.Commit; + source.Reference = "abc123"; + + Assert.False(GitSkillPluginSourceValidator.TryValidateSource(source, out var error)); + Assert.Contains("full 40-character", error); + } + + [Fact] + public void Fingerprint_changes_for_each_source_semantic() + { + var source = Source(); + var original = GitSkillPluginSourceValidator.Fingerprint(source); + + source.Subdirectory = "plugin"; + + Assert.NotEqual(original, GitSkillPluginSourceValidator.Fingerprint(source)); + } + + [Fact] + public void Collection_validation_rejects_duplicate_names() + { + var first = Source(); + var second = Source(); + second.Repository = "owner/other"; + + Assert.False(GitSkillPluginSourceValidator.TryValidateSources([first, second], out var error)); + Assert.Contains("occurs more than once", error); + } + + [Fact] + public void Collection_validation_rejects_more_than_twenty_sources() + { + var sources = Enumerable.Range(0, 21).Select(index => + { + var source = Source(); + source.Name = $"source-{index}"; + return source; + }).ToArray(); + + Assert.False(GitSkillPluginSourceValidator.TryValidateSources(sources, out var error)); + Assert.Contains("No more than 20", error); + } + + [Theory] + [InlineData("./plugin/")] + [InlineData("/plugin")] + [InlineData("plugin/../other")] + public void Source_validation_rejects_a_noncanonical_subdirectory(string subdirectory) + { + var source = Source(); + source.Subdirectory = subdirectory; + + Assert.False(GitSkillPluginSourceValidator.TryValidateSource(source, out _)); + } + + [Theory] + [InlineData("owner/.")] + [InlineData("owner/..")] + [InlineData("./repository")] + public void Repository_normalization_rejects_dot_components(string repository) + { + Assert.False(GitSkillPluginSourceValidator.TryNormalizeRepository(repository, out _, out _)); + } + + [Fact] + public void Source_validation_rejects_an_unknown_reference_type() + { + var source = Source(); + source.ReferenceKind = (GitSkillPluginReferenceKind)99; + + Assert.False(GitSkillPluginSourceValidator.TryValidateSource(source, out _)); + } + + private static GitSkillPluginSource Source() => new() + { + Name = "dotnet-skills", + Repository = "Aaronontheweb/dotnet-skills", + Format = "codex", + ReferenceKind = GitSkillPluginReferenceKind.Branch, + Reference = "main", + }; +} diff --git a/src/Netclaw.Configuration/NetclawPaths.cs b/src/Netclaw.Configuration/NetclawPaths.cs index dd48483fe..2a70f26cf 100644 --- a/src/Netclaw.Configuration/NetclawPaths.cs +++ b/src/Netclaw.Configuration/NetclawPaths.cs @@ -42,6 +42,15 @@ public sealed class NetclawPaths // ── Server feed skills (from private skill-server instances) ── public string ServerFeedsDirectory => Path.Combine(SkillsDirectory, ".server-feeds"); + // Managed plugin commits remain immutable while the registry can reference them. + public string ManagedGitSkillsDirectory => Path.Combine(SkillsDirectory, ".git-plugins"); + + public string ManagedGitSkillDirectory(string sourceName) + => Path.Combine(ManagedGitSkillsDirectory, sourceName); + + public string ManagedGitSkillCommitDirectory(string sourceName, string commit) + => Path.Combine(ManagedGitSkillDirectory(sourceName), "commits", commit); + public string ServerFeedDirectory(string feedName) => Path.Combine(ServerFeedsDirectory, feedName); @@ -185,6 +194,7 @@ private IEnumerable StandardDirectories() yield return SkillsDirectory; yield return SystemSkillsDirectory; yield return ServerFeedsDirectory; + yield return ManagedGitSkillsDirectory; yield return ProjectsDirectory; yield return ClientDirectory; yield return EnvironmentDirectory; diff --git a/src/Netclaw.Configuration/OperationalAlert.cs b/src/Netclaw.Configuration/OperationalAlert.cs index d3ad7a7cb..022856912 100644 --- a/src/Netclaw.Configuration/OperationalAlert.cs +++ b/src/Netclaw.Configuration/OperationalAlert.cs @@ -42,6 +42,7 @@ public enum AlertType // New values stay at the end so prior ordinal values remain stable. ReminderScheduleFailed, ChannelReconnected, + SkillPluginSecurityRejected, } /// diff --git a/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json b/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json index 86f84e8e7..a6ba197c4 100644 --- a/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json +++ b/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json @@ -627,6 +627,26 @@ "required": ["Name", "Url"], "additionalProperties": false } + }, + "Plugins": { + "type": "array", + "maxItems": 20, + "description": "Managed public GitHub skill plugins.", + "items": { + "type": "object", + "properties": { + "Name": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 64 }, + "Repository": { "type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" }, + "Format": { "type": "string", "enum": ["codex"], "default": "codex" }, + "Subdirectory": { "type": ["string", "null"], "minLength": 1, "maxLength": 512 }, + "ReferenceKind": { "type": "string", "enum": ["Branch", "Commit"] }, + "Reference": { "type": "string", "minLength": 1, "maxLength": 256 }, + "Enabled": { "type": "boolean", "default": true }, + "TimeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 300, "default": 60 } + }, + "required": ["Name", "Repository", "Format", "ReferenceKind", "Reference"], + "additionalProperties": false + } } }, "additionalProperties": false diff --git a/src/Netclaw.Configuration/SkillFeedsConfig.cs b/src/Netclaw.Configuration/SkillFeedsConfig.cs index e12ae5a62..13103bbb0 100644 --- a/src/Netclaw.Configuration/SkillFeedsConfig.cs +++ b/src/Netclaw.Configuration/SkillFeedsConfig.cs @@ -19,6 +19,9 @@ public sealed class SkillFeedsConfig /// public List Feeds { get; set; } = []; + /// Managed public GitHub skill plugins. + public List Plugins { get; set; } = []; + /// /// How often (in minutes) to re-check feeds for updated skills. /// Default: 60 (once per hour). Set to 0 to disable periodic sync @@ -27,6 +30,261 @@ public sealed class SkillFeedsConfig public int SyncIntervalMinutes { get; set; } = 60; } +/// The type of Git reference that a managed plugin follows. +public enum GitSkillPluginReferenceKind +{ + Branch, + Commit, +} + +/// A managed GitHub source for a content-only skill plugin. +public sealed class GitSkillPluginSource +{ + public string Name { get; set; } = ""; + public string Repository { get; set; } = ""; + public string Format { get; set; } = "codex"; + public string? Subdirectory { get; set; } + public GitSkillPluginReferenceKind ReferenceKind { get; set; } + public string Reference { get; set; } = ""; + public bool Enabled { get; set; } = true; + public int TimeoutSeconds { get; set; } = 60; +} + +/// Validates and canonicalizes managed GitHub plugin source data. +public static class GitSkillPluginSourceValidator +{ + public const int MaximumSourceCount = 20; + + public static bool TryValidateSources(IReadOnlyList sources, out string error) + { + error = ""; + if (sources.Count > MaximumSourceCount) + { + error = $"No more than {MaximumSourceCount} GitHub plugins can be configured."; + return false; + } + var duplicate = sources.GroupBy(source => source.Name, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + error = $"Plugin name '{duplicate.Key}' occurs more than once."; + return false; + } + foreach (var source in sources) + { + if (!TryValidateSource(source, out error)) + return false; + } + return true; + } + + public static bool TryNormalizeRepository(string value, out string repository, out string error) + { + repository = ""; + error = ""; + if (string.IsNullOrWhiteSpace(value) || value.StartsWith("-", StringComparison.Ordinal)) + { + error = "The repository must be GitHub owner/repository shorthand or a canonical GitHub HTTPS URL."; + return false; + } + + var candidate = value.Trim(); + if (TryParseRepositoryParts(candidate, out var owner, out var name)) + { + repository = $"{owner}/{name}"; + return true; + } + + if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri) + || !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + || !string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase) + || !uri.IsDefaultPort + || !string.IsNullOrEmpty(uri.UserInfo) + || !string.IsNullOrEmpty(uri.Query) + || !string.IsNullOrEmpty(uri.Fragment)) + { + error = "The repository URL must be canonical GitHub HTTPS without credentials, a port, a query, or a fragment."; + return false; + } + + var path = uri.AbsolutePath.Trim('/'); + if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + path = path[..^4]; + if (!TryParseRepositoryParts(path, out owner, out name)) + { + error = "The GitHub URL must contain one owner and one repository."; + return false; + } + + repository = $"{owner}/{name}"; + return true; + } + + public static bool TryValidateSource(GitSkillPluginSource source, out string error) + { + ArgumentNullException.ThrowIfNull(source); + + if (!TryValidateName(source.Name, out error)) + return false; + if (!TryNormalizeRepository(source.Repository, out var repository, out error) + || !string.Equals(repository, source.Repository, StringComparison.Ordinal)) + { + error = error.Length > 0 ? error : "The repository is not canonical owner/repository form."; + return false; + } + if (!string.Equals(source.Format, "codex", StringComparison.Ordinal)) + { + error = "The plugin format must be 'codex'."; + return false; + } + if (!TryNormalizeRelativePath(source.Subdirectory, allowEmpty: true, out var subdirectory, out error)) + return false; + if (!string.Equals(source.Subdirectory, subdirectory, StringComparison.Ordinal)) + { + error = "The repository subdirectory must use its canonical relative form."; + return false; + } + if (!Enum.IsDefined(source.ReferenceKind)) + { + error = "The plugin reference type is not supported."; + return false; + } + if (!TryValidateReference(source.ReferenceKind, source.Reference, out error)) + return false; + if (source.TimeoutSeconds is < 1 or > 300) + { + error = "The plugin timeout must be from 1 through 300 seconds."; + return false; + } + return true; + } + + public static bool TryValidateName(string value, out string error) + { + error = ""; + if (string.IsNullOrEmpty(value) || value.Length > 64 || !IsLowerKebab(value)) + { + error = "The plugin name must use lowercase letters, numbers, and single hyphens."; + return false; + } + return true; + } + + public static bool TryValidateReference( + GitSkillPluginReferenceKind kind, + string value, + out string error) + { + error = ""; + if (string.IsNullOrWhiteSpace(value)) + { + error = "The reference is required."; + return false; + } + var candidate = value.Trim(); + if (!string.Equals(candidate, value, StringComparison.Ordinal) + || candidate.Length > 256 || candidate.StartsWith("-", StringComparison.Ordinal) + || candidate.EndsWith(".", StringComparison.Ordinal) || candidate.EndsWith("/", StringComparison.Ordinal) + || candidate.Contains("..", StringComparison.Ordinal) || candidate.Contains("//", StringComparison.Ordinal) + || candidate.Contains("@{", StringComparison.Ordinal) || candidate.EndsWith(".lock", StringComparison.OrdinalIgnoreCase) + || candidate.Any(static c => char.IsWhiteSpace(c) || char.IsControl(c) + || c is '~' or '^' or ':' or '?' or '*' or '[' or '\\')) + { + error = "The reference is not a safe Git branch or commit."; + return false; + } + if (kind == GitSkillPluginReferenceKind.Commit + && (candidate.Length is not (40 or 64) || !candidate.All(char.IsAsciiHexDigit))) + { + error = "A commit reference must be a full 40-character or 64-character hexadecimal identity."; + return false; + } + return true; + } + + public static bool TryNormalizeRelativePath( + string? value, + bool allowEmpty, + out string? path, + out string error) + { + path = null; + error = ""; + if (string.IsNullOrWhiteSpace(value)) + { + if (allowEmpty) + return true; + error = "The relative path is required."; + return false; + } + if (value.Contains('\\', StringComparison.Ordinal)) + { + error = "Repository paths cannot contain backslashes."; + return false; + } + var candidate = value.Trim(); + if (candidate.StartsWith("./", StringComparison.Ordinal)) + candidate = candidate[2..]; + candidate = candidate.Trim('/'); + var segments = candidate.Split('/'); + if (Path.IsPathRooted(value) || candidate.Length > 512 || candidate.Length == 0 + || segments.Any(static segment => segment.Length is 0 or > 255 || segment is "." or "..") + || candidate.Any(char.IsControl)) + { + error = "The repository subdirectory must be a safe relative path within the path limits."; + return false; + } + path = string.Join('/', segments); + return true; + } + + public static string Fingerprint(GitSkillPluginSource source) + { + var content = string.Join('\n', source.Repository, source.Subdirectory ?? "", source.Format, + source.ReferenceKind.ToString(), source.Reference); + return Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(content))); + } + + private static bool TryParseRepositoryParts(string value, out string owner, out string repository) + { + owner = ""; + repository = ""; + var parts = value.Split('/'); + if (parts.Length != 2 || parts.Any(static part => !IsGitHubName(part))) + return false; + owner = parts[0]; + repository = parts[1]; + return true; + } + + private static bool IsGitHubName(string value) + => value.Length > 0 && value.Length <= 100 + && value is not "." and not ".." + && value.All(static c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.'); + + private static bool IsLowerKebab(string value) + { + if (value[0] is '-' || value[^1] is '-') + return false; + var previousHyphen = false; + foreach (var c in value) + { + if (c is '-') + { + if (previousHyphen) + return false; + previousHyphen = true; + continue; + } + if (!char.IsAsciiLetterLower(c) && !char.IsAsciiDigit(c)) + return false; + previousHyphen = false; + } + return true; + } +} + /// /// A single skill server feed source. /// diff --git a/src/Netclaw.Configuration/SkillSyncResult.cs b/src/Netclaw.Configuration/SkillSyncResult.cs index 7085fe091..144dabdd6 100644 --- a/src/Netclaw.Configuration/SkillSyncResult.cs +++ b/src/Netclaw.Configuration/SkillSyncResult.cs @@ -39,6 +39,12 @@ public sealed class SourceRow : IWireType /// A safe operator message. This value never includes secrets. public string? Error { get; init; } + + /// The installed Git commit, when this row represents a Git plugin. + public string? Commit { get; init; } + + /// The declared plugin version, when available. + public string? Version { get; init; } } /// Result data for the inventory publication owned by this pass. diff --git a/src/Netclaw.Daemon.Tests/Services/GitSkillPluginStateStoreTests.cs b/src/Netclaw.Daemon.Tests/Services/GitSkillPluginStateStoreTests.cs new file mode 100644 index 000000000..2810a07fa --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Services/GitSkillPluginStateStoreTests.cs @@ -0,0 +1,187 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using Netclaw.Configuration; +using Netclaw.Daemon.Services; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Daemon.Tests.Services; + +public sealed class GitSkillPluginStateStoreTests : IDisposable +{ + private const string Commit = "13e26d39ed01d97ea592235d041304d289f4ba07"; + private const string LaterCommit = "23e26d39ed01d97ea592235d041304d289f4ba08"; + private readonly DisposableTempDir _temp = new(); + private readonly NetclawPaths _paths; + + public GitSkillPluginStateStoreTests() => _paths = new NetclawPaths(_temp.Path); + + public void Dispose() + { + SqliteTestPools.Clear(_paths); + _temp.Dispose(); + } + + [Fact] + public async Task Migration_and_store_preserve_receipts_and_scope_rejections_by_fingerprint() + { + var paths = _paths; + var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 11, 12, 0, 0, TimeSpan.Zero)); + var migrator = new SchemaMigrator(paths, NullLogger.Instance); + await migrator.MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); + var store = new GitSkillPluginStateStore(paths, time); + var source = Source(); + var fingerprint = GitSkillPluginSourceValidator.Fingerprint(source); + + Assert.True(await store.SaveRejectionAsync( + source.Name, fingerprint, Commit, "bad content", true, + TestContext.Current.CancellationToken)); + Assert.Null(await store.GetRejectionAsync( + source.Name, new string('a', 64), Commit, + TestContext.Current.CancellationToken)); + + await store.SaveReceiptAsync(source, Commit, "1.0.0", TestContext.Current.CancellationToken); + + var receipt = Assert.Single(await store.LoadReceiptsAsync(TestContext.Current.CancellationToken)); + Assert.Equal(fingerprint, receipt.SourceFingerprint); + Assert.Equal(Commit, receipt.InstalledCommit); + Assert.Equal(Commit, receipt.LastObservedCommit); + Assert.NotNull(await store.GetRejectionAsync( + source.Name, fingerprint, Commit, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Last_observed_commit_changes_without_replacing_the_installed_commit() + { + var paths = _paths; + var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 11, 12, 0, 0, TimeSpan.Zero)); + await new SchemaMigrator(paths, NullLogger.Instance) + .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); + var store = new GitSkillPluginStateStore(paths, time); + var source = Source(); + await store.SaveReceiptAsync(source, Commit, "1.0.0", TestContext.Current.CancellationToken); + var installed = await store.GetReceiptAsync(source.Name, TestContext.Current.CancellationToken); + Assert.NotNull(installed); + + time.Advance(TimeSpan.FromHours(1)); + Assert.True(await store.UpdateLastObservedCommitAsync( + source.Name, LaterCommit, TestContext.Current.CancellationToken)); + + var observed = await store.GetReceiptAsync(source.Name, TestContext.Current.CancellationToken); + Assert.NotNull(observed); + Assert.Equal(Commit, observed.InstalledCommit); + Assert.Equal(LaterCommit, observed.LastObservedCommit); + Assert.Equal(installed.InstalledAt, observed.InstalledAt); + Assert.False(await store.UpdateLastObservedCommitAsync( + "missing", LaterCommit, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task RemoveSourcesExcept_removes_rejection_only_sources() + { + var paths = _paths; + await new SchemaMigrator(paths, NullLogger.Instance) + .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); + var store = new GitSkillPluginStateStore(paths, TimeProvider.System); + var source = Source(); + var fingerprint = GitSkillPluginSourceValidator.Fingerprint(source); + await store.SaveRejectionAsync( + source.Name, fingerprint, Commit, "bad content", false, + TestContext.Current.CancellationToken); + + await store.RemoveSourcesExceptAsync([], TestContext.Current.CancellationToken); + + Assert.Null(await store.GetRejectionAsync( + source.Name, fingerprint, Commit, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task SaveRejection_sanitizes_and_limits_the_durable_reason() + { + var paths = _paths; + await new SchemaMigrator(paths, NullLogger.Instance) + .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); + var store = new GitSkillPluginStateStore(paths, TimeProvider.System); + var source = Source(); + var fingerprint = GitSkillPluginSourceValidator.Fingerprint(source); + var reason = "bad\r\n" + new string('x', GitSkillPluginStateStore.MaximumRejectionReasonLength + 100); + + await store.SaveRejectionAsync( + source.Name, fingerprint, Commit, reason, true, TestContext.Current.CancellationToken); + + var rejection = await store.GetRejectionAsync( + source.Name, fingerprint, Commit, TestContext.Current.CancellationToken); + Assert.NotNull(rejection); + Assert.Equal(GitSkillPluginStateStore.MaximumRejectionReasonLength, rejection.Reason.Length); + Assert.DoesNotContain('\r', rejection.Reason); + Assert.DoesNotContain('\n', rejection.Reason); + } + + [Fact] + public async Task SaveRejection_preserves_a_later_security_classification() + { + var paths = _paths; + await new SchemaMigrator(paths, NullLogger.Instance) + .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); + var store = new GitSkillPluginStateStore(paths, TimeProvider.System); + var source = Source(); + var fingerprint = GitSkillPluginSourceValidator.Fingerprint(source); + await store.SaveRejectionAsync( + source.Name, fingerprint, Commit, "invalid metadata", false, + TestContext.Current.CancellationToken); + + await store.SaveRejectionAsync( + source.Name, fingerprint, Commit, "security rejection", true, + TestContext.Current.CancellationToken); + + var rejection = await store.GetRejectionAsync( + source.Name, fingerprint, Commit, TestContext.Current.CancellationToken); + Assert.NotNull(rejection); + Assert.True(rejection.SecurityRejection); + Assert.Equal("security rejection", rejection.Reason); + } + + [Fact] + public async Task Security_alert_claim_succeeds_once_and_rejects_nonsecurity_records() + { + var paths = _paths; + await new SchemaMigrator(paths, NullLogger.Instance) + .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); + var store = new GitSkillPluginStateStore(paths, TimeProvider.System); + var source = Source(); + var fingerprint = GitSkillPluginSourceValidator.Fingerprint(source); + await store.SaveRejectionAsync( + source.Name, fingerprint, Commit, "invalid metadata", false, + TestContext.Current.CancellationToken); + + Assert.False(await store.TryClaimSecurityAlertAsync( + source.Name, fingerprint, Commit, TestContext.Current.CancellationToken)); + + await store.SaveRejectionAsync( + source.Name, fingerprint, Commit, "security rejection", true, + TestContext.Current.CancellationToken); + Assert.True(await store.TryClaimSecurityAlertAsync( + source.Name, fingerprint, Commit, TestContext.Current.CancellationToken)); + Assert.False(await store.TryClaimSecurityAlertAsync( + source.Name, fingerprint, Commit, TestContext.Current.CancellationToken)); + + var rejection = await store.GetRejectionAsync( + source.Name, fingerprint, Commit, TestContext.Current.CancellationToken); + Assert.NotNull(rejection); + Assert.True(rejection.AlertEmitted); + } + + private static GitSkillPluginSource Source() => new() + { + Name = "fixture", + Repository = "owner/repository", + Format = "codex", + ReferenceKind = GitSkillPluginReferenceKind.Branch, + Reference = "main", + }; +} diff --git a/src/Netclaw.Daemon/Services/GitSkillPluginStateStore.cs b/src/Netclaw.Daemon/Services/GitSkillPluginStateStore.cs new file mode 100644 index 000000000..256e2d27c --- /dev/null +++ b/src/Netclaw.Daemon/Services/GitSkillPluginStateStore.cs @@ -0,0 +1,309 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Data.Sqlite; +using Netclaw.Configuration; + +namespace Netclaw.Daemon.Services; + +internal sealed record GitSkillPluginReceipt( + string SourceName, + string Repository, + string Format, + string? Subdirectory, + GitSkillPluginReferenceKind ReferenceKind, + string Reference, + string SourceFingerprint, + string InstalledCommit, + string LastObservedCommit, + string? InstalledVersion, + DateTimeOffset InstalledAt); + +internal sealed record GitSkillPluginRejection( + string SourceName, + string SourceFingerprint, + string Commit, + string Reason, + bool SecurityRejection, + bool AlertEmitted, + DateTimeOffset RejectedAt); + +internal sealed class GitSkillPluginStateStore +{ + internal const int MaximumRejectionReasonLength = 1_024; + private readonly string _connectionString; + private readonly TimeProvider _timeProvider; + + public GitSkillPluginStateStore(NetclawPaths paths, TimeProvider timeProvider) + { + _connectionString = new SqliteConnectionStringBuilder + { + DataSource = paths.SqliteDbPath, + Mode = SqliteOpenMode.ReadWriteCreate, + }.ToString(); + _timeProvider = timeProvider; + } + + public async Task> LoadReceiptsAsync( + CancellationToken cancellationToken) + { + var receipts = new List(); + await using var connection = await OpenAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = + """ + SELECT source_name, repository_url, plugin_format, plugin_subdirectory, + reference_kind, reference_value, source_fingerprint, installed_commit, + last_observed_commit, installed_version, installed_at + FROM git_skill_plugin_receipts + ORDER BY source_name; + """; + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + receipts.Add(new GitSkillPluginReceipt( + reader.GetString(0), + reader.GetString(1), + reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetString(3), + Enum.Parse(reader.GetString(4)), + reader.GetString(5), + reader.GetString(6), + reader.GetString(7), + reader.GetString(8), + reader.IsDBNull(9) ? null : reader.GetString(9), + DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(10)))); + } + + return receipts; + } + + public async Task GetReceiptAsync( + string sourceName, + CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = + """ + SELECT repository_url, plugin_format, plugin_subdirectory, + reference_kind, reference_value, source_fingerprint, installed_commit, + last_observed_commit, installed_version, installed_at + FROM git_skill_plugin_receipts + WHERE source_name = $source; + """; + command.Parameters.AddWithValue("$source", sourceName); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + return null; + + return new GitSkillPluginReceipt( + sourceName, + reader.GetString(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + Enum.Parse(reader.GetString(3)), + reader.GetString(4), + reader.GetString(5), + reader.GetString(6), + reader.GetString(7), + reader.IsDBNull(8) ? null : reader.GetString(8), + DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(9))); + } + + public async Task SaveReceiptAsync( + GitSkillPluginSource source, + string commit, + string? version, + CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = + """ + INSERT INTO git_skill_plugin_receipts ( + source_name, repository_url, plugin_format, plugin_subdirectory, + reference_kind, reference_value, source_fingerprint, installed_commit, + last_observed_commit, installed_version, installed_at) + VALUES ($source, $repository, $format, $subdirectory, + $referenceKind, $reference, $fingerprint, $commit, $commit, $version, $installedAt) + ON CONFLICT(source_name) DO UPDATE SET + repository_url = excluded.repository_url, + plugin_format = excluded.plugin_format, + plugin_subdirectory = excluded.plugin_subdirectory, + reference_kind = excluded.reference_kind, + reference_value = excluded.reference_value, + source_fingerprint = excluded.source_fingerprint, + installed_commit = excluded.installed_commit, + last_observed_commit = excluded.last_observed_commit, + installed_version = excluded.installed_version, + installed_at = excluded.installed_at; + """; + command.Parameters.AddWithValue("$source", source.Name); + command.Parameters.AddWithValue("$repository", source.Repository); + command.Parameters.AddWithValue("$format", source.Format); + command.Parameters.AddWithValue("$subdirectory", (object?)source.Subdirectory ?? DBNull.Value); + command.Parameters.AddWithValue("$referenceKind", source.ReferenceKind.ToString()); + command.Parameters.AddWithValue("$reference", source.Reference); + command.Parameters.AddWithValue("$fingerprint", GitSkillPluginSourceValidator.Fingerprint(source)); + command.Parameters.AddWithValue("$commit", commit); + command.Parameters.AddWithValue("$version", (object?)version ?? DBNull.Value); + command.Parameters.AddWithValue("$installedAt", _timeProvider.GetUtcNow().ToUnixTimeMilliseconds()); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async Task UpdateLastObservedCommitAsync( + string sourceName, + string commit, + CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = + """ + UPDATE git_skill_plugin_receipts + SET last_observed_commit = $commit + WHERE source_name = $source; + """; + command.Parameters.AddWithValue("$source", sourceName); + command.Parameters.AddWithValue("$commit", commit); + return await command.ExecuteNonQueryAsync(cancellationToken) > 0; + } + + public async Task GetRejectionAsync( + string sourceName, + string sourceFingerprint, + string commit, + CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = + """ + SELECT reason, security_rejection, alert_emitted, rejected_at + FROM git_skill_plugin_rejections + WHERE source_name = $source AND source_fingerprint = $fingerprint + AND commit_identity = $commit; + """; + command.Parameters.AddWithValue("$source", sourceName); + command.Parameters.AddWithValue("$fingerprint", sourceFingerprint); + command.Parameters.AddWithValue("$commit", commit); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + return null; + + return new GitSkillPluginRejection( + sourceName, + sourceFingerprint, + commit, + reader.GetString(0), + reader.GetInt64(1) != 0, + reader.GetInt64(2) != 0, + DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(3))); + } + + public async Task SaveRejectionAsync( + string sourceName, + string sourceFingerprint, + string commit, + string reason, + bool securityRejection, + CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = + """ + INSERT INTO git_skill_plugin_rejections ( + source_name, source_fingerprint, commit_identity, reason, security_rejection, + alert_emitted, rejected_at) + VALUES ($source, $fingerprint, $commit, $reason, $security, 0, $rejectedAt) + ON CONFLICT(source_name, source_fingerprint, commit_identity) DO UPDATE SET + reason = excluded.reason, + security_rejection = MAX( + git_skill_plugin_rejections.security_rejection, + excluded.security_rejection), + rejected_at = excluded.rejected_at; + """; + command.Parameters.AddWithValue("$source", sourceName); + command.Parameters.AddWithValue("$fingerprint", sourceFingerprint); + command.Parameters.AddWithValue("$commit", commit); + command.Parameters.AddWithValue("$reason", SanitizeReason(reason)); + command.Parameters.AddWithValue("$security", securityRejection ? 1 : 0); + command.Parameters.AddWithValue("$rejectedAt", _timeProvider.GetUtcNow().ToUnixTimeMilliseconds()); + return await command.ExecuteNonQueryAsync(cancellationToken) > 0; + } + + public async Task TryClaimSecurityAlertAsync( + string sourceName, + string sourceFingerprint, + string commit, + CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = + """ + UPDATE git_skill_plugin_rejections + SET alert_emitted = 1 + WHERE source_name = $source AND source_fingerprint = $fingerprint + AND commit_identity = $commit AND security_rejection = 1 + AND alert_emitted = 0; + """; + command.Parameters.AddWithValue("$source", sourceName); + command.Parameters.AddWithValue("$fingerprint", sourceFingerprint); + command.Parameters.AddWithValue("$commit", commit); + return await command.ExecuteNonQueryAsync(cancellationToken) > 0; + } + + public async Task RemoveSourcesExceptAsync( + IReadOnlyCollection sourceNames, + CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken); + var names = sourceNames.ToHashSet(StringComparer.Ordinal); + var stored = new HashSet(StringComparer.Ordinal); + await using (var read = connection.CreateCommand()) + { + read.Transaction = transaction; + read.CommandText = + "SELECT source_name FROM git_skill_plugin_receipts UNION SELECT source_name FROM git_skill_plugin_rejections;"; + await using var reader = await read.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + stored.Add(reader.GetString(0)); + } + + foreach (var source in stored.Where(name => !names.Contains(name))) + { + foreach (var table in new[] { "git_skill_plugin_receipts", "git_skill_plugin_rejections" }) + { + await using var delete = connection.CreateCommand(); + delete.Transaction = transaction; + delete.CommandText = $"DELETE FROM {table} WHERE source_name = $source;"; + delete.Parameters.AddWithValue("$source", source); + await delete.ExecuteNonQueryAsync(cancellationToken); + } + } + + await transaction.CommitAsync(cancellationToken); + } + + private async Task OpenAsync(CancellationToken cancellationToken) + { + var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(cancellationToken); + return connection; + } + + private static string SanitizeReason(string reason) + { + var sanitized = new string(reason.Select(static character => char.IsControl(character) ? ' ' : character).ToArray()) + .Trim(); + return sanitized.Length <= MaximumRejectionReasonLength + ? sanitized + : sanitized[..MaximumRejectionReasonLength]; + } +} diff --git a/src/Netclaw.Daemon/migrations/sqlite/007_git_skill_plugins.sql b/src/Netclaw.Daemon/migrations/sqlite/007_git_skill_plugins.sql new file mode 100644 index 000000000..ca2ef3523 --- /dev/null +++ b/src/Netclaw.Daemon/migrations/sqlite/007_git_skill_plugins.sql @@ -0,0 +1,30 @@ +-- Netclaw SQLite migration 007 +-- Stores managed Git plugin publications and rejected commit identities. + +CREATE TABLE IF NOT EXISTS git_skill_plugin_receipts ( + source_name TEXT NOT NULL PRIMARY KEY, + repository_url TEXT NOT NULL, + plugin_format TEXT NOT NULL, + plugin_subdirectory TEXT, + reference_kind TEXT NOT NULL, + reference_value TEXT NOT NULL, + source_fingerprint TEXT NOT NULL, + installed_commit TEXT NOT NULL, + last_observed_commit TEXT NOT NULL, + installed_version TEXT, + installed_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS git_skill_plugin_rejections ( + source_name TEXT NOT NULL, + source_fingerprint TEXT NOT NULL, + commit_identity TEXT NOT NULL, + reason TEXT NOT NULL, + security_rejection INTEGER NOT NULL DEFAULT 0, + alert_emitted INTEGER NOT NULL DEFAULT 0, + rejected_at INTEGER NOT NULL, + PRIMARY KEY (source_name, source_fingerprint, commit_identity) +); + +CREATE INDEX IF NOT EXISTS git_skill_plugin_rejections_source_idx + ON git_skill_plugin_rejections(source_name); diff --git a/src/Netclaw.Security.Tests/Skills/RegexSkillContentScannerTests.cs b/src/Netclaw.Security.Tests/Skills/RegexSkillContentScannerTests.cs index 4c7582945..746d50d6f 100644 --- a/src/Netclaw.Security.Tests/Skills/RegexSkillContentScannerTests.cs +++ b/src/Netclaw.Security.Tests/Skills/RegexSkillContentScannerTests.cs @@ -74,10 +74,10 @@ public async Task ScanAsync_high_risk_rejected() Assert.NotNull(result.Reason); } - // -- Detector failure -> Rejected ------------------------------------- + // -- Detector failure -> Failed --------------------------------------- [Fact] - public async Task ScanAsync_detector_failure_rejects_with_scanning_failed() + public async Task ScanAsync_detector_failure_returns_failed_verdict() { var scanner = new RegexSkillContentScanner( new ThrowingPromptInjectionDetector(), @@ -85,7 +85,8 @@ public async Task ScanAsync_detector_failure_rejects_with_scanning_failed() var result = await scanner.ScanAsync("skill", "content", TestContext.Current.CancellationToken); - Assert.Equal(ScanVerdict.Rejected, result.Verdict); + Assert.Equal(ScanVerdict.Failed, result.Verdict); + Assert.False(result.IsAllowed); Assert.Equal("content scanning failed", result.Reason); } diff --git a/src/Netclaw.Security/Skills/ISkillContentScanner.cs b/src/Netclaw.Security/Skills/ISkillContentScanner.cs index d2d821cea..66411e135 100644 --- a/src/Netclaw.Security/Skills/ISkillContentScanner.cs +++ b/src/Netclaw.Security/Skills/ISkillContentScanner.cs @@ -29,7 +29,10 @@ public enum ScanVerdict Warning, /// Content was rejected due to a high-severity match. - Rejected + Rejected, + + /// The scanner could not establish a verdict. + Failed } /// @@ -39,10 +42,11 @@ public enum ScanVerdict /// Explanation when content triggers a warning or rejection; null when allowed. public sealed record SkillScanResult(ScanVerdict Verdict, string? Reason) { - /// Backward-compatible: true when is not . - public bool IsAllowed => Verdict != ScanVerdict.Rejected; + /// True only when the scanner established an allowed verdict. + public bool IsAllowed => Verdict is ScanVerdict.Allowed or ScanVerdict.Warning; public static SkillScanResult Allow() => new(ScanVerdict.Allowed, null); public static SkillScanResult Warn(string reason) => new(ScanVerdict.Warning, reason); public static SkillScanResult Reject(string reason) => new(ScanVerdict.Rejected, reason); + public static SkillScanResult Fail(string reason) => new(ScanVerdict.Failed, reason); } diff --git a/src/Netclaw.Security/Skills/RegexSkillContentScanner.cs b/src/Netclaw.Security/Skills/RegexSkillContentScanner.cs index eea67a801..336dfec59 100644 --- a/src/Netclaw.Security/Skills/RegexSkillContentScanner.cs +++ b/src/Netclaw.Security/Skills/RegexSkillContentScanner.cs @@ -42,7 +42,7 @@ public async Task ScanAsync( catch (Exception ex) { _logger.LogWarning(ex, "Skill content scanning failed for '{SkillName}'", skillName); - return SkillScanResult.Reject("content scanning failed"); + return SkillScanResult.Fail("content scanning failed"); } if (detection.Risk == PromptInjectionRisk.None)