Skip to content
Closed
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
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 GitSkillPluginStateStore CreatePluginStateStore(NetclawPaths paths)
{
new SchemaMigrator(paths, NullLogger<SchemaMigrator>.Instance)
.MigrateAsync(paths.SqliteDbPath, CancellationToken.None).GetAwaiter().GetResult();
return new GitSkillPluginStateStore(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
19 changes: 19 additions & 0 deletions src/Netclaw.Daemon.Tests/Services/GitSkillPluginAcquirerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,25 @@ public async Task Acquire_uses_a_fixed_commit_without_a_GitHub_commit_lookup()
Assert.Single(handler.UserAgents);
}

[Fact]
public async Task Acquire_accepts_a_64_character_fixed_commit()
{
var archive = CreateArchive(
("repo/.codex-plugin/plugin.json", Manifest("1.0.0"), TarEntryType.RegularFile),
("repo/skills/alpha/SKILL.md", Skill("alpha"), TarEntryType.RegularFile));
var handler = new GitHubHandler(archive);
var commit = new string('a', 64);
var source = Source();
source.ReferenceKind = GitSkillPluginReferenceKind.Commit;
source.Reference = commit;

var candidate = await CreateAcquirer(handler).AcquireAsync(
source, commit, TestContext.Current.CancellationToken);

Assert.Equal(commit, candidate.Commit);
Assert.Equal(0, handler.CommitRequestCount);
}

[Theory]
[MemberData(nameof(CorruptArchives))]
public async Task Acquire_rejects_malformed_archive_content(byte[] archive)
Expand Down
Loading
Loading