Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/Netclaw.Actors.Tests/Skills/SkillInventoryRefresherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,32 @@ public void Refresh_preserves_all_sources_and_applies_canonical_precedence()
Assert.Contains(result.AcceptedSkills, skill => skill.Name == "new-native");
}

[Fact]
public void Managed_git_plugins_stay_between_server_feeds_and_external_sources()
{
var feedRoot = _paths.ServerFeedDirectory("managed");
var gitRoot = Path.Join(_home, "git-plugin");
var externalRoot = Path.Join(_home, "external");
WriteSkill(_paths.SkillsDirectory, "native-only", "native");
WriteSkill(feedRoot, "shared", "feed wins");
WriteSkill(gitRoot, "shared", "git loses");
WriteSkill(gitRoot, "git-only", "managed git");
WriteSkill(externalRoot, "shared", "external loses");
WriteSkill(externalRoot, "external-only", "external");

var refresher = CreateRefresher(
new SkillFeedsConfig { Feeds = [new SkillFeedSource { Name = "managed" }] },
[new ResolvedExternalSource("external", [externalRoot], AllowSymlinks: false)]);

refresher.ReplaceManagedGitPluginSourcesAndRefresh(
[new ResolvedExternalSource("managed-git:fixture", [gitRoot], AllowSymlinks: false)]);
var result = refresher.Refresh();

Assert.Equal("feed wins", _registry.GetByName("shared")!.Description);
Assert.Contains(result.AcceptedSkills, skill => skill.Name == "git-only");
Assert.Contains(result.AcceptedSkills, skill => skill.Name == "external-only");
}

[Fact]
public void ReplaceAll_never_exposes_a_partially_replaced_inventory()
{
Expand Down
36 changes: 29 additions & 7 deletions src/Netclaw.Actors/Skills/SkillInventoryRefresher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public sealed class SkillInventoryRefresher
private readonly IReadOnlyList<ResolvedExternalSource> _externalSources;
private readonly SkillRegistry _registry;
private readonly SkillIndexPublisher _indexPublisher;
private IReadOnlyList<ResolvedExternalSource> _managedGitPluginSources = [];

public SkillInventoryRefresher(
NetclawPaths paths,
Expand All @@ -38,17 +39,38 @@ public MergedSkillScanResult Refresh()
{
lock (_refreshLock)
{
var result = SkillScanner.ScanAndMerge(
_paths.SkillsDirectory,
ResolveServerFeedSources(),
_externalSources);
return RefreshCore();
}
}

_registry.ReplaceAll(result.AcceptedSkills, result.Issues);
_indexPublisher.Publish();
return result;
/// <summary>
/// Replaces the managed Git plugin paths and publishes one inventory snapshot.
/// </summary>
public MergedSkillScanResult ReplaceManagedGitPluginSourcesAndRefresh(
IReadOnlyList<ResolvedExternalSource> managedGitPluginSources)
{
ArgumentNullException.ThrowIfNull(managedGitPluginSources);

lock (_refreshLock)
{
_managedGitPluginSources = managedGitPluginSources;
return RefreshCore();
}
}

private MergedSkillScanResult RefreshCore()
{
var result = SkillScanner.ScanAndMerge(
_paths.SkillsDirectory,
ResolveServerFeedSources(),
_managedGitPluginSources,
_externalSources);

_registry.ReplaceAll(result.AcceptedSkills, result.Issues);
_indexPublisher.Publish();
return result;
}

private IReadOnlyList<ResolvedExternalSource> ResolveServerFeedSources()
{
var sources = new List<ResolvedExternalSource>();
Expand Down
17 changes: 16 additions & 1 deletion src/Netclaw.Actors/Skills/SkillScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,17 @@ public static MergedSkillScanResult ScanAndMerge(
string nativeSkillsDirectory,
IReadOnlyList<ResolvedExternalSource> serverFeedSources,
IReadOnlyList<ResolvedExternalSource> externalSources)
=> ScanAndMerge(nativeSkillsDirectory, serverFeedSources, Array.Empty<ResolvedExternalSource>(), externalSources);

/// <summary>
/// Scans all configured skill tiers. Managed Git plugins are lower priority than
/// organization feeds and higher priority than local external directories.
/// </summary>
public static MergedSkillScanResult ScanAndMerge(
string nativeSkillsDirectory,
IReadOnlyList<ResolvedExternalSource> serverFeedSources,
IReadOnlyList<ResolvedExternalSource> managedGitPluginSources,
IReadOnlyList<ResolvedExternalSource> externalSources)
{
var nativeScan = Scan(nativeSkillsDirectory, allowSymlinks: false, strictNameMatch: true);
var allAccepted = new List<SkillEntry>(nativeScan.AcceptedSkills);
Expand All @@ -219,7 +230,11 @@ public static MergedSkillScanResult ScanAndMerge(
// Server feed sources (second tier — org-managed private skill servers)
MergeSources(serverFeedSources, allAccepted, allIssues, knownNames, allowFrontmatterlessFlatFiles: false);

// External filesystem sources (third tier — Claude Code, Open Code, custom paths)
// Managed Git plugins are verified, immutable third-party content. They yield
// to organization feeds but take priority over locally discovered sources.
MergeSources(managedGitPluginSources, allAccepted, allIssues, knownNames, allowFrontmatterlessFlatFiles: false);

// External filesystem sources are the lowest-precedence tier.
MergeSources(externalSources, allAccepted, allIssues, knownNames, allowFrontmatterlessFlatFiles: true);

return new MergedSkillScanResult(allAccepted, allIssues);
Expand Down
9 changes: 9 additions & 0 deletions src/Netclaw.Actors/Tools/SkillManageTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,8 @@ private bool IsSystemSkill(string name)
return $"Cannot {verb} system skills. System skills are read-only.";
if (IsServerFeedSkill(skill))
return $"Cannot {verb} server feed skills. Server feed skill directories are read-only.";
if (IsManagedGitPluginSkill(skill))
return $"Cannot {verb} managed Git plugin skills. Managed Git plugin directories are read-only.";
if (IsExternalSkill(skill))
return $"Cannot {verb} external skills. External skill directories are read-only.";
return null;
Expand All @@ -471,6 +473,13 @@ private bool IsExternalSkill(SkillEntry skill)
return !PathUtility.IsWithinRoot(skillPath, nativeRoot);
}

private bool IsManagedGitPluginSkill(SkillEntry skill)
{
var managedRoot = PathUtility.Normalize(_paths.ManagedGitSkillsDirectory);
var skillPath = PathUtility.Normalize(Path.GetDirectoryName(skill.FilePath)!);
return PathUtility.IsWithinRoot(skillPath, managedRoot);
}

private static void AtomicWrite(string path, string content)
{
var tempPath = path + ".tmp";
Expand Down
9 changes: 9 additions & 0 deletions src/Netclaw.Configuration/SkillSyncResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ namespace Netclaw.Configuration;
/// </summary>
public static class SkillSyncResult
{
public const string ServerFeedSourceKind = "server-feed";
public const string GitPluginSourceKind = "git-plugin";

/// <summary>Result data for one pass.</summary>
public sealed class Response : IWireType
{
Expand All @@ -26,6 +29,9 @@ public sealed class SourceRow : IWireType
{
public required string Name { get; init; }

/// <summary>The source type that owns this result.</summary>
public string SourceKind { get; init; } = ServerFeedSourceKind;

public int ChangedCount { get; init; }

public int UnchangedCount { get; init; }
Expand All @@ -45,6 +51,9 @@ public sealed class SourceRow : IWireType

/// <summary>The declared plugin version, when available.</summary>
public string? Version { get; init; }

/// <summary>Safe package diagnostics that do not stop this source.</summary>
public IReadOnlyList<string> Notices { get; init; } = [];
}

/// <summary>Result data for the inventory publication owned by this pass.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,15 @@ public async Task Syncs_skill_and_subagent_from_real_skillserver_container()
TimeProvider.System,
new NoOpSkillContentScanner(),
NullLogger<ServerFeedSkillSyncService>.Instance,
[]);
[],
feed => new SkillServerClient(new HttpClient(new HttpClientHandler())
{
BaseAddress = new Uri(feed.Url),
}),
CreatePluginStateStore(paths),
new GitSkillPluginAcquirer(
new HttpClient(new HttpClientHandler()), paths, TimeProvider.System, new NoOpSkillContentScanner()),
NullNotificationSink.Instance);

await service.SyncAsync(CancellationToken.None);

Expand All @@ -139,6 +147,13 @@ public async Task Syncs_skill_and_subagent_from_real_skillserver_container()
Assert.NotNull(registry.TryGetByName("code-reviewer"));
}

private static ManagedPluginStateStore CreatePluginStateStore(NetclawPaths paths)
{
new SchemaMigrator(paths, NullLogger<SchemaMigrator>.Instance)
.MigrateAsync(paths.SqliteDbPath, CancellationToken.None).GetAwaiter().GetResult();
return new ManagedPluginStateStore(paths, TimeProvider.System);
}

private static async Task SeedSkillServerAsync()
{
using var client = new SkillServerClient(ServerUrl, ApiKey);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ public void System_skills_are_readable_but_not_writable(ShellPlatform platform)
Assert.True(policy.IsDenied(skillPath));
}

[Fact]
public void Managed_git_plugin_skills_are_readable_but_not_writable_or_shell_accessible()
{
var paths = new NetclawPaths(Path.Combine(Path.GetTempPath(), "netclaw-policy-contract"));
var policy = DaemonToolPathPolicyFactory.Create(
paths,
ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux));
var skillPath = Path.Combine(paths.ManagedGitSkillsDirectory, "fixture", "commit", "SKILL.md");

Assert.False(policy.IsReadDenied(skillPath));
Assert.True(policy.IsDenied(skillPath));
Assert.True(policy.CommandReferencesDeniedPath($"cat '{skillPath}'"));
}

[Theory]
[InlineData("tool-index.md")]
[InlineData("mcp/synthetic-server.md")]
Expand Down
Loading
Loading